summaryrefslogtreecommitdiff
path: root/src/mailman
diff options
context:
space:
mode:
Diffstat (limited to 'src/mailman')
-rw-r--r--src/mailman/app/bounces.py16
-rw-r--r--src/mailman/app/docs/bounces.rst64
-rw-r--r--src/mailman/chains/base.py44
-rw-r--r--src/mailman/chains/builtin.py2
-rw-r--r--src/mailman/chains/dmarc.py39
-rw-r--r--src/mailman/chains/hold.py4
-rw-r--r--src/mailman/chains/moderation.py20
-rw-r--r--src/mailman/chains/reject.py19
-rw-r--r--src/mailman/chains/tests/test_accept.py2
-rw-r--r--src/mailman/chains/tests/test_dmarc.py82
-rw-r--r--src/mailman/commands/cli_withlist.py2
-rw-r--r--src/mailman/core/i18n.py18
-rw-r--r--src/mailman/core/pipelines.py2
-rw-r--r--src/mailman/core/tests/test_pipelines.py51
-rw-r--r--src/mailman/docs/NEWS.rst3
-rw-r--r--src/mailman/interfaces/pipeline.py18
-rw-r--r--src/mailman/interfaces/template.py1
-rw-r--r--src/mailman/mta/deliver.py24
-rw-r--r--src/mailman/rest/tests/test_domains.py1
-rw-r--r--src/mailman/rest/tests/test_lists.py1
-rw-r--r--src/mailman/rest/tests/test_root.py1
-rw-r--r--src/mailman/rules/dmarc.py10
-rw-r--r--src/mailman/rules/docs/dmarc-mitigation.rst6
-rw-r--r--src/mailman/rules/docs/moderation.rst18
-rw-r--r--src/mailman/rules/moderation.py4
-rw-r--r--src/mailman/rules/tests/test_moderation.py12
-rw-r--r--src/mailman/templates/en/list:user:notice:rejected.txt6
27 files changed, 355 insertions, 115 deletions
diff --git a/src/mailman/app/bounces.py b/src/mailman/app/bounces.py
index 1239c916c..0d9a6ccb9 100644
--- a/src/mailman/app/bounces.py
+++ b/src/mailman/app/bounces.py
@@ -45,6 +45,7 @@ elog = logging.getLogger('mailman.error')
blog = logging.getLogger('mailman.bounce')
DOT = '.'
+NL = '\n'
@public
@@ -56,8 +57,12 @@ def bounce_message(mlist, msg, error=None):
:param msg: The original message.
:type msg: `email.message.Message`
:param error: Optional exception causing the bounce. The exception
- instance must have a `.message` attribute.
- :type error: Exception
+ instance must have a `.message` attribute. The exception *may* have a
+ non-None `.reasons` attribute which would be a list of reasons for the
+ rejection, and it may have a non-None `.substitutions` attribute. The
+ latter, along with the formatted reasons will be interpolated into the
+ message (`.reasons` gets put into the `$reasons` placeholder).
+ :type error: RejectMessage
"""
# Bounce a message back to the sender, with an error message if provided
# in the exception argument. .sender might be None or the empty string.
@@ -67,10 +72,9 @@ def bounce_message(mlist, msg, error=None):
return
subject = msg.get('subject', _('(no subject)'))
subject = oneline(subject, mlist.preferred_language.charset)
- if error is None:
- notice = _('[No bounce details are available]')
- else:
- notice = _(error.message)
+ notice = (_('[No bounce details are available]')
+ if error is None
+ else str(error))
# Currently we always craft bounces as MIME messages.
bmsg = UserNotification(msg.sender, mlist.owner_address, subject,
lang=mlist.preferred_language)
diff --git a/src/mailman/app/docs/bounces.rst b/src/mailman/app/docs/bounces.rst
index 99eae8b2c..b25381031 100644
--- a/src/mailman/app/docs/bounces.rst
+++ b/src/mailman/app/docs/bounces.rst
@@ -12,12 +12,12 @@ Mailman can bounce messages back to the original sender. This is essentially
equivalent to rejecting the message with notification. Mailing lists can
bounce a message with an optional error message.
- >>> mlist = create_list('text@example.com')
+ >>> mlist = create_list('ant@example.com')
Any message can be bounced.
>>> msg = message_from_string("""\
- ... To: text@example.com
+ ... To: ant@example.com
... From: aperson@example.com
... Subject: Something important
...
@@ -36,7 +36,7 @@ to the original message author.
1
>>> print(items[0].msg.as_string())
Subject: Something important
- From: text-owner@example.com
+ From: ant-owner@example.com
To: aperson@example.com
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="..."
@@ -54,7 +54,7 @@ to the original message author.
Content-Type: message/rfc822
MIME-Version: 1.0
<BLANKLINE>
- To: text@example.com
+ To: ant@example.com
From: aperson@example.com
Subject: Something important
<BLANKLINE>
@@ -63,18 +63,16 @@ to the original message author.
--...--
An error message can be given when the message is bounced, and this will be
-included in the payload of the text/plain part. The error message must be
+included in the payload of the ``text/plain`` part. The error message must be
passed in as an instance of a ``RejectMessage`` exception.
>>> from mailman.interfaces.pipeline import RejectMessage
>>> error = RejectMessage("This wasn't very important after all.")
>>> bounce_message(mlist, msg, error)
- >>> items = get_queue_messages('virgin')
- >>> len(items)
- 1
+ >>> items = get_queue_messages('virgin', expected_count=1)
>>> print(items[0].msg.as_string())
Subject: Something important
- From: text-owner@example.com
+ From: ant-owner@example.com
To: aperson@example.com
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="..."
@@ -92,10 +90,56 @@ passed in as an instance of a ``RejectMessage`` exception.
Content-Type: message/rfc822
MIME-Version: 1.0
<BLANKLINE>
- To: text@example.com
+ To: ant@example.com
From: aperson@example.com
Subject: Something important
<BLANKLINE>
I sometimes say something important.
<BLANKLINE>
--...--
+
+The ``RejectMessage`` exception can also include a set of reasons, which will
+be interpolated into the message using the ``{reasons}`` placeholder.
+
+ >>> error = RejectMessage("""This message is rejected because:
+ ...
+ ... $reasons
+ ... """, [
+ ... 'I am not happy',
+ ... 'You are not happy',
+ ... 'We are not happy'])
+ >>> bounce_message(mlist, msg, error)
+ >>> items = get_queue_messages('virgin', expected_count=1)
+ >>> print(items[0].msg.as_string())
+ Subject: Something important
+ From: ant-owner@example.com
+ To: aperson@example.com
+ MIME-Version: 1.0
+ Content-Type: multipart/mixed; boundary="..."
+ Message-ID: ...
+ Date: ...
+ Precedence: bulk
+ <BLANKLINE>
+ --...
+ Content-Type: text/plain; charset="us-ascii"
+ MIME-Version: 1.0
+ Content-Transfer-Encoding: 7bit
+ <BLANKLINE>
+ This message is rejected because:
+ <BLANKLINE>
+ I am not happy
+ You are not happy
+ We are not happy
+ <BLANKLINE>
+ --...
+ Content-Type: message/rfc822
+ MIME-Version: 1.0
+ <BLANKLINE>
+ To: ant@example.com
+ From: aperson@example.com
+ Subject: Something important
+ <BLANKLINE>
+ I sometimes say something important.
+ <BLANKLINE>
+ --...
+ <BLANKLINE>
diff --git a/src/mailman/chains/base.py b/src/mailman/chains/base.py
index 9507d8fbc..45301abc3 100644
--- a/src/mailman/chains/base.py
+++ b/src/mailman/chains/base.py
@@ -18,7 +18,6 @@
"""Base class for terminal chains."""
from mailman.config import config
-from mailman.core.i18n import _
from mailman.interfaces.chain import (
IChain, IChainIterator, IChainLink, IMutableChain, LinkAction)
from mailman.interfaces.rules import IRule
@@ -28,24 +27,6 @@ from zope.interface import implementer
@public
-def format_reasons(reasons):
- """Translate and format hold and rejection reasons.
-
- :param reasons: A list of reasons from the rules that hit. Each reason is
- a string to be translated or a tuple consisting of a string with {}
- replacements and one or more replacement values.
- :returns: A list of the translated and formatted strings.
- """
- new_reasons = []
- for reason in reasons:
- if isinstance(reason, tuple):
- new_reasons.append(_(reason[0]).format(*reason[1:]))
- else:
- new_reasons.append(_(reason))
- return new_reasons
-
-
-@public
@implementer(IChainLink)
class Link:
"""A chain link."""
@@ -107,6 +88,31 @@ class TerminalChainBase:
@public
@abstract_component
+@implementer(IChain)
+class JumpChainBase:
+ """A base chain that simplifies jumping to another chain."""
+ def jump_to(self, mlist, msg, msgsdata):
+ """Return the chain to jump to.
+
+ This must be overridden by subclasses.
+
+ :param mlist: The mailing list.
+ :param msg: The message.
+ :param msgdata: The message metadata.
+ :return: The name of the chain to jump to.
+ :rtype: str
+ """
+ raise NotImplementedError
+
+ def get_links(self, mlist, msg, msgdata):
+ jump_chain = self.jump_to(mlist, msg, msgdata)
+ return iter([
+ Link('truth', LinkAction.jump, jump_chain),
+ ])
+
+
+@public
+@abstract_component
@implementer(IMutableChain)
class Chain:
"""Generic chain base class."""
diff --git a/src/mailman/chains/builtin.py b/src/mailman/chains/builtin.py
index 9b97fef38..ad016201f 100644
--- a/src/mailman/chains/builtin.py
+++ b/src/mailman/chains/builtin.py
@@ -41,7 +41,7 @@ class BuiltInChain:
# First check DMARC. For a reject or discard, the rule hits and we
# jump to the moderation chain to do the action. Otherwise, the rule
# misses buts sets msgdata['dmarc'] for the handler.
- ('dmarc-mitigation', LinkAction.jump, 'moderation'),
+ ('dmarc-mitigation', LinkAction.jump, 'dmarc'),
# Discard emails with no valid senders.
('no-senders', LinkAction.jump, 'discard'),
('approved', LinkAction.jump, 'accept'),
diff --git a/src/mailman/chains/dmarc.py b/src/mailman/chains/dmarc.py
new file mode 100644
index 000000000..6a8d4d188
--- /dev/null
+++ b/src/mailman/chains/dmarc.py
@@ -0,0 +1,39 @@
+# Copyright (C) 2017 by the Free Software Foundation, Inc.
+#
+# This file is part of GNU Mailman.
+#
+# GNU Mailman is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option)
+# any later version.
+#
+# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
+
+"""DMARC mitigation chain."""
+
+from mailman.chains.base import JumpChainBase
+from mailman.core.i18n import _
+from public import public
+
+
+@public
+class DMARCMitigationChain(JumpChainBase):
+ """Perform DMARC mitigation."""
+
+ name = 'dmarc'
+ description = _('Process DMARC reject or discard mitigations')
+
+ def jump_to(self, mlist, msg, msgdata):
+ # Which action should be taken?
+ jump_chain = msgdata['dmarc_action']
+ assert jump_chain in ('discard', 'reject'), (
+ '{}: Invalid DMARC action: {} for sender: {}'.format(
+ mlist.list_id, jump_chain,
+ msgdata.get('moderation_sender', '(unknown)')))
+ return jump_chain
diff --git a/src/mailman/chains/hold.py b/src/mailman/chains/hold.py
index edc80da3c..ea25dbc0d 100644
--- a/src/mailman/chains/hold.py
+++ b/src/mailman/chains/hold.py
@@ -24,9 +24,9 @@ from email.mime.text import MIMEText
from email.utils import formatdate, make_msgid
from mailman.app.moderator import hold_message
from mailman.app.replybot import can_acknowledge
-from mailman.chains.base import TerminalChainBase, format_reasons
+from mailman.chains.base import TerminalChainBase
from mailman.config import config
-from mailman.core.i18n import _
+from mailman.core.i18n import _, format_reasons
from mailman.email.message import UserNotification
from mailman.interfaces.autorespond import IAutoResponseSet, Response
from mailman.interfaces.chain import HoldEvent
diff --git a/src/mailman/chains/moderation.py b/src/mailman/chains/moderation.py
index 7cfc0a01e..d8f8fb7e7 100644
--- a/src/mailman/chains/moderation.py
+++ b/src/mailman/chains/moderation.py
@@ -34,17 +34,14 @@ made as to the disposition of the message. `defer` is the default for
members, while `hold` is the default for nonmembers.
"""
-from mailman.chains.base import Link
+from mailman.chains.base import JumpChainBase
from mailman.core.i18n import _
from mailman.interfaces.action import Action
-from mailman.interfaces.chain import IChain, LinkAction
from public import public
-from zope.interface import implementer
@public
-@implementer(IChain)
-class ModerationChain:
+class ModerationChain(JumpChainBase):
"""Dynamically produce a link jumping to the appropriate terminal chain.
The terminal chain will be one of the Accept, Hold, Discard, or Reject
@@ -53,13 +50,12 @@ class ModerationChain:
name = 'moderation'
description = _('Moderation chain')
- def get_links(self, mlist, msg, msgdata):
- """See `IChain`."""
+ def jump_to(self, mlist, msg, msgdata):
# Get the moderation action from the message metadata. It can only be
# one of the expected values (i.e. not Action.defer). See the
# moderation.py rule for details. This is stored in the metadata as a
# string so that it can be stored in the pending table.
- action = Action[msgdata.get('moderation_action')]
+ action = Action[msgdata.get('member_moderation_action')]
# defer is not a valid moderation action.
jump_chain = {
Action.accept: 'accept',
@@ -68,9 +64,7 @@ class ModerationChain:
Action.reject: 'reject',
}.get(action)
assert jump_chain is not None, (
- '{0}: Invalid moderation action: {1} for sender: {2}'.format(
- mlist.fqdn_listname, action,
+ '{}: Invalid moderation action: {} for sender: {}'.format(
+ mlist.list_id, action,
msgdata.get('moderation_sender', '(unknown)')))
- return iter([
- Link('truth', LinkAction.jump, jump_chain),
- ])
+ return jump_chain
diff --git a/src/mailman/chains/reject.py b/src/mailman/chains/reject.py
index 31f66c8fa..6696e1900 100644
--- a/src/mailman/chains/reject.py
+++ b/src/mailman/chains/reject.py
@@ -20,11 +20,13 @@
import logging
from mailman.app.bounces import bounce_message
-from mailman.chains.base import TerminalChainBase, format_reasons
+from mailman.chains.base import TerminalChainBase
from mailman.core.i18n import _
from mailman.interfaces.chain import RejectEvent
from mailman.interfaces.pipeline import RejectMessage
+from mailman.interfaces.template import ITemplateLoader
from public import public
+from zope.component import getUtility
from zope.event import notify
@@ -56,17 +58,10 @@ class RejectChain(TerminalChainBase):
if reasons is None:
error = None
else:
- error = RejectMessage(_("""
-Your message to the {list_name} mailing-list was rejected for the following
-reasons:
-
-{reasons}
-
-The original message as received by Mailman is attached.
-""").format(
- list_name=mlist.display_name, # noqa: E122
- reasons=NEWLINE.join(format_reasons(reasons))
- ))
+ template = getUtility(ITemplateLoader).get(
+ 'list:user:notice:rejected', mlist)
+ error = RejectMessage(
+ template, reasons, dict(listname=mlist.display_name))
bounce_message(mlist, msg, error)
log.info('REJECT: %s', msg.get('message-id', 'n/a'))
notify(RejectEvent(mlist, msg, msgdata, self))
diff --git a/src/mailman/chains/tests/test_accept.py b/src/mailman/chains/tests/test_accept.py
index 65b18d0b4..bf8bad541 100644
--- a/src/mailman/chains/tests/test_accept.py
+++ b/src/mailman/chains/tests/test_accept.py
@@ -51,7 +51,7 @@ class TestAccept(unittest.TestCase):
self._mlist = create_list('ant@example.com')
self._msg = mfs("""\
From: anne@example.com
-To: test@example.com
+To: ant@example.com
Subject: Ignore
""")
diff --git a/src/mailman/chains/tests/test_dmarc.py b/src/mailman/chains/tests/test_dmarc.py
new file mode 100644
index 000000000..8d0072254
--- /dev/null
+++ b/src/mailman/chains/tests/test_dmarc.py
@@ -0,0 +1,82 @@
+# Copyright (C) 2017 by the Free Software Foundation, Inc.
+#
+# This file is part of GNU Mailman.
+#
+# GNU Mailman is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option)
+# any later version.
+#
+# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
+
+"""Test for the DMARC chain."""
+
+import unittest
+
+from mailman.app.lifecycle import create_list
+from mailman.core.chains import process as process_chain
+from mailman.interfaces.chain import DiscardEvent, RejectEvent
+from mailman.testing.helpers import (
+ event_subscribers, get_queue_messages,
+ specialized_message_from_string as mfs)
+from mailman.testing.layers import ConfigLayer
+
+
+class TestDMARC(unittest.TestCase):
+ layer = ConfigLayer
+ maxDiff = None
+
+ def setUp(self):
+ self._mlist = create_list('ant@example.com')
+ self._msg = mfs("""\
+From: anne@example.com
+To: test@example.com
+Subject: Ignore
+
+""")
+
+ def test_discard(self):
+ msgdata = dict(dmarc_action='discard')
+ # When a message is discarded, the only artifacts are a log message
+ # and an event. Catch the event to prove it happened.
+ events = []
+ def handler(event): # noqa: E306
+ if isinstance(event, DiscardEvent):
+ events.append(event)
+ with event_subscribers(handler):
+ process_chain(self._mlist, self._msg, msgdata, start_chain='dmarc')
+ self.assertEqual(len(events), 1)
+ self.assertIs(events[0].msg, self._msg)
+
+ def test_reject(self):
+ msgdata = dict(
+ dmarc_action='reject',
+ moderation_reasons=['DMARC violation'],
+ )
+ # When a message is reject, an event will be triggered and the message
+ # will be bounced.
+ events = []
+ def handler(event): # noqa: E306
+ if isinstance(event, RejectEvent):
+ events.append(event)
+ with event_subscribers(handler):
+ process_chain(self._mlist, self._msg, msgdata, start_chain='dmarc')
+ self.assertEqual(len(events), 1)
+ self.assertIs(events[0].msg, self._msg)
+ items = get_queue_messages('virgin', expected_count=1)
+ # Unpack the rejection message.
+ rejection = items[0].msg.get_payload(0).get_payload()
+ self.assertEqual(rejection, """\
+Your message to the Ant mailing-list was rejected for the following
+reasons:
+
+DMARC violation
+
+The original message as received by Mailman is attached.
+""")
diff --git a/src/mailman/commands/cli_withlist.py b/src/mailman/commands/cli_withlist.py
index b93d9b153..d2b706e34 100644
--- a/src/mailman/commands/cli_withlist.py
+++ b/src/mailman/commands/cli_withlist.py
@@ -80,7 +80,7 @@ def start_python(overrides, banner):
# Set the tab completion.
with ExitStack() as resources:
try: # pragma: nocover
- import readline, rlcompleter # noqa: F401, E401
+ import readline, rlcompleter # noqa: F401,E401
except ImportError: # pragma: nocover
print(_('readline not available'), file=sys.stderr)
pass
diff --git a/src/mailman/core/i18n.py b/src/mailman/core/i18n.py
index 69abe3b9d..d5aa06b5c 100644
--- a/src/mailman/core/i18n.py
+++ b/src/mailman/core/i18n.py
@@ -47,3 +47,21 @@ def initialize(application=None):
def handle_ConfigurationUpdatedEvent(event):
if isinstance(event, ConfigurationUpdatedEvent):
_.default = event.config.mailman.default_language
+
+
+@public
+def format_reasons(reasons):
+ """Translate and format hold and rejection reasons.
+
+ :param reasons: A list of reasons from the rules that hit. Each reason is
+ a string to be translated or a tuple consisting of a string with {}
+ replacements and one or more replacement values.
+ :returns: A list of the translated and formatted strings.
+ """
+ new_reasons = []
+ for reason in reasons:
+ if isinstance(reason, tuple):
+ new_reasons.append(_(reason[0]).format(*reason[1:]))
+ else:
+ new_reasons.append(_(reason))
+ return new_reasons
diff --git a/src/mailman/core/pipelines.py b/src/mailman/core/pipelines.py
index df07bc608..4265d24d1 100644
--- a/src/mailman/core/pipelines.py
+++ b/src/mailman/core/pipelines.py
@@ -55,7 +55,7 @@ def process(mlist, msg, msgdata, pipeline_name='built-in'):
except RejectMessage as error:
vlog.info(
'{} rejected by "{}" pipeline handler "{}": {}'.format(
- message_id, pipeline_name, handler.name, error.message))
+ message_id, pipeline_name, handler.name, str(error)))
bounce_message(mlist, msg, error)
diff --git a/src/mailman/core/tests/test_pipelines.py b/src/mailman/core/tests/test_pipelines.py
index 4ec55798f..3d4ae0ad2 100644
--- a/src/mailman/core/tests/test_pipelines.py
+++ b/src/mailman/core/tests/test_pipelines.py
@@ -48,8 +48,11 @@ class DiscardingHandler:
class RejectHandler:
name = 'rejecting'
+ def __init__(self, message):
+ self.message = message
+
def process(self, mlist, msg, msgdata):
- raise RejectMessage('by test handler')
+ raise RejectMessage(self.message)
@implementer(IPipeline)
@@ -66,8 +69,11 @@ class RejectingPipeline:
name = 'test-rejecting'
description = 'Rejectinging test pipeline'
+ def __init__(self):
+ self.message = 'by test handler'
+
def __iter__(self):
- yield RejectHandler()
+ yield RejectHandler(self.message)
class TestPostingPipeline(unittest.TestCase):
@@ -109,18 +115,49 @@ testing
'"discarding": by test handler'))
def test_rejecting_pipeline(self):
- # If a handler in the pipeline raises DiscardMessage, the message will
- # be thrown away, but with a log message.
+ # If a handler in the pipeline raises RejectMessage, the post will
+ # be bounced with a log message.
mark = LogFileMark('mailman.vette')
process(self._mlist, self._msg, {}, 'test-rejecting')
line = mark.readline()[:-1]
- self.assertTrue(line.endswith(
+ self.assertEqual(
+ line[-80:],
+ '<ant> rejected by "test-rejecting" pipeline handler '
+ '"rejecting": by test handler',
+ line)
+ # In the rejection case, the original message will also be in the
+ # virgin queue.
+ items = get_queue_messages('virgin', expected_count=1)
+ self.assertEqual(
+ str(items[0].msg.get_payload(1).get_payload(0)['subject']),
+ 'a test')
+ # The first payload contains the rejection reason.
+ payload = items[0].msg.get_payload(0).get_payload()
+ self.assertEqual(payload, 'by test handler')
+
+ def test_rejecting_pipeline_without_message(self):
+ # Similar to above, but without a rejection message.
+ pipeline = config.pipelines['test-rejecting']
+ message = pipeline.message
+ self.addCleanup(setattr, pipeline, 'message', message)
+ pipeline.message = None
+ mark = LogFileMark('mailman.vette')
+ process(self._mlist, self._msg, {}, 'test-rejecting')
+ line = mark.readline()[:-1]
+ self.assertEqual(
+ line[-91:],
'<ant> rejected by "test-rejecting" pipeline handler '
- '"rejecting": by test handler'))
+ '"rejecting": [No details are available]',
+ line)
# In the rejection case, the original message will also be in the
# virgin queue.
items = get_queue_messages('virgin', expected_count=1)
- self.assertEqual(str(items[0].msg['subject']), 'a test')
+ self.assertEqual(
+ str(items[0].msg.get_payload(1).get_payload(0)['subject']),
+ 'a test')
+ # The first payload contains the rejection reason.
+ payload = items[0].msg.get_payload(0).get_payload()
+ self.assertEqual(payload, '[No details are available]')
def test_decorate_bulk(self):
# Ensure that bulk postings get decorated with the footer.
diff --git a/src/mailman/docs/NEWS.rst b/src/mailman/docs/NEWS.rst
index 420e0fbe2..4000c135b 100644
--- a/src/mailman/docs/NEWS.rst
+++ b/src/mailman/docs/NEWS.rst
@@ -23,6 +23,7 @@ Bugs
discarded. (Closes #369)
* Various message holds and rejects that gave 'N/A' as a reason now give an
appropriate reason. (Closes #368)
+* Bounce messages are now composed for proper translations.
Command line
------------
@@ -39,6 +40,8 @@ Interfaces
both ``List-ID``s and fully qualified list names, since that's the most
common use case. There's now a separate ``.get_by_fqdn()`` which only
accepts the latter and mirrors the already existing ``.get_by_list_id()``.
+* A new template ``list:user:notice:rejected`` has been added for customizing
+ the bounce message rejection notice.
Other
-----
diff --git a/src/mailman/interfaces/pipeline.py b/src/mailman/interfaces/pipeline.py
index fac89c442..faccc52d2 100644
--- a/src/mailman/interfaces/pipeline.py
+++ b/src/mailman/interfaces/pipeline.py
@@ -17,13 +17,12 @@
"""Interface for describing pipelines."""
+from mailman.core.i18n import _, format_reasons
from public import public
from zope.interface import Attribute, Interface
-# For i18n extraction.
-def _(s):
- return s
+NL = '\n'
# These are thrown but they aren't exceptions so don't inherit from
@@ -44,11 +43,20 @@ class DiscardMessage(BaseException):
class RejectMessage(BaseException):
"""The message will be bounced back to the sender"""
- def __init__(self, message=None):
+ def __init__(self, message=None, reasons=None, substitutions=None):
self.message = message
+ self.reasons = reasons
+ self.substitutions = ({} if substitutions is None else substitutions)
def __str__(self):
- return self.message
+ if self.message is None:
+ return _('[No details are available]')
+ reasons = (_('[No reasons given]')
+ if self.reasons is None
+ else NL.join(format_reasons(self.reasons)))
+ substitutions = self.substitutions.copy()
+ substitutions['reasons'] = reasons
+ return _(self.message, substitutions)
@public
diff --git a/src/mailman/interfaces/template.py b/src/mailman/interfaces/template.py
index 388e95942..ee2466f86 100644
--- a/src/mailman/interfaces/template.py
+++ b/src/mailman/interfaces/template.py
@@ -179,6 +179,7 @@ ALL_TEMPLATES = {
'list:user:notice:post',
'list:user:notice:probe',
'list:user:notice:refuse',
+ 'list:user:notice:rejected',
'list:user:notice:welcome',
}
}
diff --git a/src/mailman/mta/deliver.py b/src/mailman/mta/deliver.py
index db069bd36..e614d5bd1 100644
--- a/src/mailman/mta/deliver.py
+++ b/src/mailman/mta/deliver.py
@@ -90,15 +90,15 @@ def deliver(mlist, msg, msgdata):
if size is None:
size = len(msg.as_string())
substitutions = dict(
- msgid = msg.get('message-id', 'n/a'), # noqa: E221, E251
- listname = mlist.fqdn_listname, # noqa: E221, E251
- sender = original_sender, # noqa: E221, E251
- recip = len(original_recipients), # noqa: E221, E251
- size = size, # noqa: E221, E251
- time = t1 - t0, # noqa: E221, E251
- refused = len(refused), # noqa: E221, E251
- smtpcode = 'n/a', # noqa: E221, E251
- smtpmsg = 'n/a', # noqa: E221, E251
+ msgid = msg.get('message-id', 'n/a'), # noqa: E221,E251
+ listname = mlist.fqdn_listname, # noqa: E221,E251
+ sender = original_sender, # noqa: E221,E251
+ recip = len(original_recipients), # noqa: E221,E251
+ size = size, # noqa: E221,E251
+ time = t1 - t0, # noqa: E221,E251
+ refused = len(refused), # noqa: E221,E251
+ smtpcode = 'n/a', # noqa: E221,E251
+ smtpmsg = 'n/a', # noqa: E221,E251
)
template = config.logging.smtp.every
if template.lower() != 'no':
@@ -141,9 +141,9 @@ def deliver(mlist, msg, msgdata):
template = config.logging.smtp.failure
if template.lower() != 'no':
substitutions.update(
- recip = recipient, # noqa: E221, E251
- smtpcode = code, # noqa: E221, E251
- smtpmsg = smtp_message, # noqa: E221, E251
+ recip = recipient, # noqa: E221,E251
+ smtpcode = code, # noqa: E221,E251
+ smtpmsg = smtp_message, # noqa: E221,E251
)
log.info('%s', expand(template, mlist, substitutions))
# Return the results
diff --git a/src/mailman/rest/tests/test_domains.py b/src/mailman/rest/tests/test_domains.py
index 3bacf7a72..bd6668a5b 100644
--- a/src/mailman/rest/tests/test_domains.py
+++ b/src/mailman/rest/tests/test_domains.py
@@ -346,6 +346,7 @@ class TestDomainTemplates(unittest.TestCase):
'list:user:notice:post': '',
'list:user:notice:probe': '',
'list:user:notice:refuse': '',
+ 'list:user:notice:rejected': '',
'list:user:notice:welcome': 'http://example.org/welcome',
'password': 'some password',
'username': 'anne.person',
diff --git a/src/mailman/rest/tests/test_lists.py b/src/mailman/rest/tests/test_lists.py
index 75a81f3cb..0f3b99393 100644
--- a/src/mailman/rest/tests/test_lists.py
+++ b/src/mailman/rest/tests/test_lists.py
@@ -705,6 +705,7 @@ class TestListTemplates(unittest.TestCase):
'list:user:notice:post': '',
'list:user:notice:probe': '',
'list:user:notice:refuse': '',
+ 'list:user:notice:rejected': '',
'list:user:notice:welcome': 'http://example.org/welcome',
'password': 'some password',
'username': 'anne.person',
diff --git a/src/mailman/rest/tests/test_root.py b/src/mailman/rest/tests/test_root.py
index cb16caeeb..45adc6075 100644
--- a/src/mailman/rest/tests/test_root.py
+++ b/src/mailman/rest/tests/test_root.py
@@ -283,6 +283,7 @@ class TestSiteTemplates(unittest.TestCase):
'list:user:notice:post': '',
'list:user:notice:probe': '',
'list:user:notice:refuse': '',
+ 'list:user:notice:rejected': '',
'list:user:notice:welcome': 'http://example.org/welcome',
'password': 'some password',
'username': 'anne.person',
diff --git a/src/mailman/rules/dmarc.py b/src/mailman/rules/dmarc.py
index 9f9b11673..2741eb1df 100644
--- a/src/mailman/rules/dmarc.py
+++ b/src/mailman/rules/dmarc.py
@@ -298,15 +298,15 @@ class DMARCMitigation:
if mlist.dmarc_mitigate_action is DMARCMitigateAction.no_mitigation:
# Don't bother to check if we're not going to do anything.
return False
- dn, addr = parseaddr(msg.get('from'))
- if maybe_mitigate(mlist, addr):
+ display_name, address = parseaddr(msg.get('from'))
+ if maybe_mitigate(mlist, address):
# If dmarc_mitigate_action is discard or reject, this rule fires
# and jumps to the 'moderation' chain to do the actual discard.
# Otherwise, the rule misses but sets a flag for the dmarc handler
# to do the appropriate action.
msgdata['dmarc'] = True
if mlist.dmarc_mitigate_action is DMARCMitigateAction.discard:
- msgdata['moderation_action'] = 'discard'
+ msgdata['dmarc_action'] = 'discard'
with _.defer_translation():
# This will be translated at the point of use.
msgdata.setdefault('moderation_reasons', []).append(
@@ -324,9 +324,9 @@ class DMARCMitigation:
'contact the mailing list owner at ${listowner}.'))
msgdata.setdefault('moderation_reasons', []).append(
wrap(reason))
- msgdata['moderation_action'] = 'reject'
+ msgdata['dmarc_action'] = 'reject'
else:
return False
- msgdata['moderation_sender'] = addr
+ msgdata['moderation_sender'] = address
return True
return False
diff --git a/src/mailman/rules/docs/dmarc-mitigation.rst b/src/mailman/rules/docs/dmarc-mitigation.rst
index bee7b3a55..cf7ceeb74 100644
--- a/src/mailman/rules/docs/dmarc-mitigation.rst
+++ b/src/mailman/rules/docs/dmarc-mitigation.rst
@@ -102,7 +102,7 @@ message.
True
>>> dump_msgdata(msgdata)
dmarc : True
- moderation_action : discard
+ dmarc_action : discard
moderation_reasons: ['DMARC moderation']
moderation_sender : aperson@example.biz
@@ -121,7 +121,7 @@ We can reject the message with a default reason.
True
>>> dump_msgdata(msgdata)
dmarc : True
- moderation_action : reject
+ dmarc_action : reject
moderation_reasons: ['You are not allowed to post to this mailing list...
moderation_sender : aperson@example.biz
@@ -140,6 +140,6 @@ And, we can reject with a custom message.
True
>>> dump_msgdata(msgdata)
dmarc : True
- moderation_action : reject
+ dmarc_action : reject
moderation_reasons: ['A silly reason']
moderation_sender : aperson@example.biz
diff --git a/src/mailman/rules/docs/moderation.rst b/src/mailman/rules/docs/moderation.rst
index d1cc5fd67..8bdcd2f1a 100644
--- a/src/mailman/rules/docs/moderation.rst
+++ b/src/mailman/rules/docs/moderation.rst
@@ -60,9 +60,9 @@ the eventual moderation chain.
>>> member_rule.check(mlist, member_msg, msgdata)
True
>>> dump_msgdata(msgdata)
- moderation_action : hold
- moderation_reasons: ['The message comes from a moderated member']
- moderation_sender : aperson@example.com
+ member_moderation_action: hold
+ moderation_reasons : ['The message comes from a moderated member']
+ moderation_sender : aperson@example.com
Nonmembers
@@ -107,9 +107,9 @@ rule matches and the message metadata again carries some useful information.
>>> nonmember_rule.check(mlist, nonmember_msg, msgdata)
True
>>> dump_msgdata(msgdata)
- moderation_action : hold
- moderation_reasons: ['The message is not from a list member']
- moderation_sender : bperson@example.com
+ member_moderation_action: hold
+ moderation_reasons : ['The message is not from a list member']
+ moderation_sender : bperson@example.com
Of course, the nonmember action can be set to defer the decision, in which
case the rule does not match.
@@ -161,9 +161,9 @@ nonmember of the list. The rule also matches.
>>> nonmember_rule.check(mlist, msg, msgdata)
True
>>> dump_msgdata(msgdata)
- moderation_action : hold
- moderation_reasons: ['The message is not from a list member']
- moderation_sender : cperson@example.com
+ member_moderation_action: hold
+ moderation_reasons : ['The message is not from a list member']
+ moderation_sender : cperson@example.com
>>> dump_list(mlist.members.members, key=memberkey)
<Member: Anne Person <aperson@example.com>
diff --git a/src/mailman/rules/moderation.py b/src/mailman/rules/moderation.py
index 7f78d3ce7..26f04d632 100644
--- a/src/mailman/rules/moderation.py
+++ b/src/mailman/rules/moderation.py
@@ -82,7 +82,7 @@ class MemberModeration:
elif action is not None:
# We must stringify the moderation action so that it can be
# stored in the pending request table.
- msgdata['moderation_action'] = action.name
+ msgdata['member_moderation_action'] = action.name
msgdata['moderation_sender'] = sender
with _.defer_translation():
# This will be translated at the point of use.
@@ -94,7 +94,7 @@ class MemberModeration:
def _record_action(msgdata, action, sender, reason):
- msgdata['moderation_action'] = action
+ msgdata['member_moderation_action'] = action
msgdata['moderation_sender'] = sender
msgdata.setdefault('moderation_reasons', []).append(reason)
diff --git a/src/mailman/rules/tests/test_moderation.py b/src/mailman/rules/tests/test_moderation.py
index e08bba480..46d848b42 100644
--- a/src/mailman/rules/tests/test_moderation.py
+++ b/src/mailman/rules/tests/test_moderation.py
@@ -142,9 +142,9 @@ A message body.
msgdata = {}
result = rule.check(self._mlist, msg, msgdata)
self.assertTrue(result, 'NonmemberModeration rule should hit')
- self.assertIn('moderation_action', msgdata)
+ self.assertIn('member_moderation_action', msgdata)
self.assertEqual(
- msgdata['moderation_action'], action_name,
+ msgdata['member_moderation_action'], action_name,
'Wrong action for {}: {}'.format(address, action_name))
def test_nonmember_fallback_to_list_defaults(self):
@@ -166,7 +166,7 @@ A message body.
msgdata = {}
result = rule.check(self._mlist, msg, msgdata)
self.assertTrue(result)
- self.assertEqual(msgdata['moderation_action'], 'hold')
+ self.assertEqual(msgdata['member_moderation_action'], 'hold')
# As a side-effect, Anne has been added as a nonmember with a
# moderation action that falls back to the list's default.
anne = self._mlist.nonmembers.get_member('anne@example.com')
@@ -177,7 +177,7 @@ A message body.
# This time, the message should be discarded.
result = rule.check(self._mlist, msg, msgdata)
self.assertTrue(result)
- self.assertEqual(msgdata.get('moderation_action'), 'discard')
+ self.assertEqual(msgdata.get('member_moderation_action'), 'discard')
def test_member_fallback_to_list_defaults(self):
# https://gitlab.com/mailman/mailman/issues/189
@@ -201,14 +201,14 @@ A message body.
msgdata = {}
result = rule.check(self._mlist, msg, msgdata)
self.assertTrue(result)
- self.assertEqual(msgdata.get('moderation_action'), 'accept')
+ self.assertEqual(msgdata.get('member_moderation_action'), 'accept')
# Then the list's default member action is changed.
self._mlist.default_member_action = Action.hold
msg.replace_header('Message-ID', '<bee>')
# This time, the message is held.
result = rule.check(self._mlist, msg, msgdata)
self.assertTrue(result)
- self.assertEqual(msgdata.get('moderation_action'), 'hold')
+ self.assertEqual(msgdata.get('member_moderation_action'), 'hold')
def test_linked_address_nonmembermoderation_misses(self):
# Anne subscribes to a mailing list as a user with her preferred
diff --git a/src/mailman/templates/en/list:user:notice:rejected.txt b/src/mailman/templates/en/list:user:notice:rejected.txt
new file mode 100644
index 000000000..b8e5aab53
--- /dev/null
+++ b/src/mailman/templates/en/list:user:notice:rejected.txt
@@ -0,0 +1,6 @@
+Your message to the $listname mailing-list was rejected for the following
+reasons:
+
+$reasons
+
+The original message as received by Mailman is attached.