summaryrefslogtreecommitdiff
path: root/src/mailman/handlers
diff options
context:
space:
mode:
authorBarry Warsaw2017-01-04 15:10:55 +0000
committerBarry Warsaw2017-01-04 15:10:55 +0000
commit9ecb0d462b79bec42e82b6aecea8853262e15753 (patch)
treedc422ff2eec850387fcdb7a3c326f8d3a267a16a /src/mailman/handlers
parent8cb4546b9a2bf87a3e2d465651686ca26a5404db (diff)
parentae3eeb642b452ee844ba7a2999361f31c3c327c7 (diff)
downloadmailman-9ecb0d462b79bec42e82b6aecea8853262e15753.tar.gz
mailman-9ecb0d462b79bec42e82b6aecea8853262e15753.tar.zst
mailman-9ecb0d462b79bec42e82b6aecea8853262e15753.zip
Merge branch 'dmarc' into 'master'
Implement DMARC mitigations in mailman Closes #247 See merge request !215
Diffstat (limited to 'src/mailman/handlers')
-rw-r--r--src/mailman/handlers/cook_headers.py2
-rw-r--r--src/mailman/handlers/dmarc.py226
-rw-r--r--src/mailman/handlers/docs/dmarc-mitigations.rst162
-rw-r--r--src/mailman/handlers/tests/test_dmarc.py441
4 files changed, 830 insertions, 1 deletions
diff --git a/src/mailman/handlers/cook_headers.py b/src/mailman/handlers/cook_headers.py
index 672738bf9..413304ea2 100644
--- a/src/mailman/handlers/cook_headers.py
+++ b/src/mailman/handlers/cook_headers.py
@@ -114,7 +114,7 @@ def process(mlist, msg, msgdata):
d[lcaddr] = pair
new.append(pair)
# List admin wants an explicit Reply-To: added
- if mlist.reply_goes_to_list == ReplyToMunging.explicit_header:
+ if mlist.reply_goes_to_list is ReplyToMunging.explicit_header:
add(parseaddr(mlist.reply_to_address))
# If we're not first stripping existing Reply-To: then we need to add
# the original Reply-To:'s to the list we're building up. In both
diff --git a/src/mailman/handlers/dmarc.py b/src/mailman/handlers/dmarc.py
new file mode 100644
index 000000000..13707d397
--- /dev/null
+++ b/src/mailman/handlers/dmarc.py
@@ -0,0 +1,226 @@
+# Copyright (C) 2016 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/>.
+
+"""Do DMARC Munge From and Wrap Message actions.
+
+This does the work of modifying the messages From: and Cc: or Reply-To: or
+wrapping the message in an outer message with From: and Cc: or Reply-To:
+as appropriate to avoid issues because of the original From: domain's DMARC
+policy. It does this either to selected messages flagged by the DMARC
+moderation rule based on list settings and the original From: domain's DMARC
+policy or to all messages based on list settings."""
+
+import re
+import copy
+import logging
+
+from email.header import Header, decode_header
+from email.mime.message import MIMEMessage
+from email.mime.text import MIMEText
+from email.utils import formataddr, getaddresses, make_msgid
+from mailman.core.i18n import _
+from mailman.interfaces.handler import IHandler
+from mailman.interfaces.mailinglist import DMARCMitigateAction, ReplyToMunging
+from mailman.utilities.string import wrap
+from public import public
+from zope.interface import implementer
+
+
+log = logging.getLogger('mailman.error')
+
+COMMASPACE = ', '
+EMPTYSTRING = ''
+MAXLINELEN = 78
+NONASCII = re.compile('[^\s!-~]')
+# Headers from the original that we want to keep in the wrapper. These are
+# actually regexps matched with re.match so they match anything that starts
+# with the given string unless they end with '$'.
+KEEPERS = (
+ 'archived-at',
+ 'date',
+ 'in-reply-to',
+ 'list-',
+ 'precedence',
+ 'references',
+ 'subject',
+ 'to',
+ 'x-mailman-',
+ )
+
+
+def munged_headers(mlist, msg, msgdata):
+ # This returns a list of tuples (header, content) where header is the
+ # name of a header to be added to or replaced in the wrapper or message
+ # for DMARC mitigation. It sets From: to the string
+ # 'original From: display name' via 'list name' <list posting address>
+ # and adds the original From: to Reply-To: or Cc: per the following.
+ # Our goals for this process are not completely compatible, so we do
+ # the best we can. Our goals are:
+ # 1) as long as the list is not anonymous, the original From: address
+ # should be obviously exposed, i.e. not just in a header that MUAs
+ # don't display.
+ # 2) the original From: address should not be in a comment or display
+ # name in the new From: because it is claimed that multiple domains
+ # in any fields in From: are indicative of spamminess. This means
+ # it should be in Reply-To: or Cc:.
+ # 3) the behavior of an MUA doing a 'reply' or 'reply all' should be
+ # consistent regardless of whether or not the From: is munged.
+ # Goal 3) implies sometimes the original From: should be in Reply-To:
+ # and sometimes in Cc:, and even so, this goal won't be achieved in
+ # all cases with all MUAs. In cases of conflict, the above ordering of
+ # goals is priority order.
+ #
+ # Be as robust as possible here.
+ all_froms = getaddresses(msg.get_all('from', []))
+ # Strip the nulls and bad emails.
+ froms = [email for email in all_froms if '@' in email[1]]
+ if len(froms) == 1:
+ realname, email = original_from = froms[0]
+ else:
+ # No From: or multiple addresses. Just punt and take
+ # the get_sender result.
+ realname = ''
+ email = msgdata['original_sender']
+ original_from = (realname, email)
+ # If there was no display name in the email header, see if we have a
+ # matching member with a display name.
+ if len(realname) == 0:
+ member = mlist.members.get_member(email)
+ if member:
+ realname = member.display_name or email
+ else:
+ realname = email
+ # Remove the domain from realname if it looks like an email address.
+ realname = re.sub(r'@([^ .]+\.)+[^ .]+$', '---', realname)
+ # Make a display name and RFC 2047 encode it if necessary. This is
+ # difficult and kludgy. If the realname came from From: it should be
+ # ASCII or RFC 2047 encoded. If it came from the member record, it should
+ # be a string. If it's from the email address, it should be an ASCII
+ # string. In any case, ensure it's an unencoded string.
+ realname_bits = []
+ for fragment, charset in decode_header(realname):
+ if not charset:
+ # Character set should be ASCII, but use iso-8859-1 anyway.
+ charset = 'iso-8859-1'
+ if not isinstance(fragment, str):
+ realname_bits.append(str(fragment, charset, errors='replace'))
+ else:
+ realname_bits.append(fragment)
+ # The member's display name is a string.
+ realname = EMPTYSTRING.join(realname_bits)
+ # Ensure the i18n context is the list's preferred_language.
+ with _.using(mlist.preferred_language.code):
+ via = _('$realname via $mlist.display_name')
+ # Get an RFC 2047 encoded header string.
+ display_name = str(Header(via, mlist.preferred_language.charset))
+ value = [('From', formataddr((display_name, mlist.posting_address)))]
+ # We've made the munged From:. Now put the original in Reply-To: or Cc:
+ if mlist.reply_goes_to_list is ReplyToMunging.no_munging:
+ # Add original from to Reply-To:
+ add_to = 'Reply-To'
+ else:
+ # Add original from to Cc:
+ add_to = 'Cc'
+ original = getaddresses(msg.get_all(add_to, []))
+ if original_from[1] not in [x[1] for x in original]:
+ original.append(original_from)
+ value.append((add_to, COMMASPACE.join(formataddr(x) for x in original)))
+ return value
+
+
+def munge_from(mlist, msg, msgdata):
+ for key, value in munged_headers(mlist, msg, msgdata):
+ del msg[key]
+ msg[key] = value
+ return
+
+
+def wrap_message(mlist, msg, msgdata):
+ # Create a wrapper message around the original.
+ #
+ # There are various headers in msg that we don't want, so we basically
+ # make a copy of the message, then delete almost everything and set/copy
+ # what we want.
+ original_msg = copy.deepcopy(msg)
+ for key in msg:
+ keep = False
+ for keeper in KEEPERS:
+ if re.match(keeper, key, re.IGNORECASE):
+ keep = True
+ break
+ if not keep:
+ del msg[key]
+ msg['MIME-Version'] = '1.0'
+ msg['Message-ID'] = make_msgid()
+ for key, value in munged_headers(mlist, original_msg, msgdata):
+ msg[key] = value
+ # Are we including dmarc_wrapped_message_text?
+ if len(mlist.dmarc_wrapped_message_text) > 0:
+ part1 = MIMEText(
+ wrap(mlist.dmarc_wrapped_message_text),
+ 'plain',
+ mlist.preferred_language.charset)
+ part1['Content-Disposition'] = 'inline'
+ part2 = MIMEMessage(original_msg)
+ part2['Content-Disposition'] = 'inline'
+ msg['Content-Type'] = 'multipart/mixed'
+ msg.set_payload([part1, part2])
+ else:
+ msg['Content-Type'] = 'message/rfc822'
+ msg['Content-Disposition'] = 'inline'
+ msg.set_payload([original_msg])
+ return
+
+
+def process(mlist, msg, msgdata):
+ # If we're mitigating on policy and we have no hit, return.
+ if not msgdata.get('dmarc') and not mlist.dmarc_mitigate_unconditionally:
+ return
+ # If we're not mitigating, return.
+ if mlist.dmarc_mitigate_action is DMARCMitigateAction.no_mitigation:
+ return
+ if mlist.anonymous_list:
+ # DMARC mitigation is not required for anonymous lists.
+ return
+ if msgdata.get('dmarc') or mlist.dmarc_mitigate_unconditionally:
+ if mlist.dmarc_mitigate_action is DMARCMitigateAction.munge_from:
+ munge_from(mlist, msg, msgdata)
+ elif mlist.dmarc_mitigate_action is DMARCMitigateAction.wrap_message:
+ wrap_message(mlist, msg, msgdata)
+ else:
+ # We can get here if DMARCMitigateAction is reject or discard but
+ # the From: domain has no reject or quarantine policy and
+ # mlist.dmarc_mitigate_unconditionally is True. Log and ignore
+ # this.
+ log.error('Invalid DMARC combination for list: %s', mlist)
+ return
+ else:
+ raise AssertionError(
+ 'handlers/dmarc.py: no hit and unconditional is False')
+
+
+@public
+@implementer(IHandler)
+class DMARC:
+ """Apply DMARC mitigations."""
+
+ name = 'dmarc'
+ description = _('Apply DMARC mitigations.')
+
+ def process(self, mlist, msg, msgdata):
+ """See `IHandler`."""
+ process(mlist, msg, msgdata)
diff --git a/src/mailman/handlers/docs/dmarc-mitigations.rst b/src/mailman/handlers/docs/dmarc-mitigations.rst
new file mode 100644
index 000000000..12a0de0ff
--- /dev/null
+++ b/src/mailman/handlers/docs/dmarc-mitigations.rst
@@ -0,0 +1,162 @@
+=================
+DMARC Mitigations
+=================
+
+In order to mitigate the effects of DMARC_ on mailing list traffic, list
+administrators have the ability to apply transformations to messages delivered
+to list members. These transformations are applied only to individual
+messages sent to list members and not to messages in digests, archives, or
+gated via NNTP.
+
+The messages can be transformed by either munging the ``From:`` header and
+putting original ``From:`` in ``Cc:`` or ``Reply-To:``, or by wrapping the
+original message in an outer message ``From:`` the list.
+
+Exactly which transformations are applied depends on a number of list settings.
+
+The settings and their effects are:
+
+``anonymous_list``
+ If True, no mitigations are ever applied because the message is already
+ ``From:`` the list.
+``dmarc_mitigate_action``
+ The action to apply to messages ``From:`` a domain publishing a DMARC
+ policy of **reject** or **quarantine**, or to all messages depending on the
+ setting of ``dmarc_mitigate_unconditionally``.
+``dmarc_mitigate_unconditionally``
+ If True, apply ``dmarc_mitigate_action`` to all messages, but only if
+ ``dmarc_mitigate_action`` is neither ``reject`` or ``discard``.
+``dmarc_moderation_notice``
+ Text to include in any rejection notice to be sent when
+ ``dmarc_policy_mitigation`` of ``reject`` applies. This overrides the
+ built-in default text.
+``dmarc_wrapped_message_text``
+ Text to be added as a separate ``text/plain`` MIME part preceding the
+ original message part in the wrapped message when a ``wrap_message``
+ mitigation applies. If this is not provided the separate ``text/plain``
+ MIME part is not added.
+``reply_goes_to_list``
+ If this is set to other than no-munging of ``Reply-To:``, the original
+ ``From:`` goes in ``Cc:`` rather than ``Reply-To:``. This is intended to
+ make MUA functions of *reply* and *reply-all* have the same effect with
+ messages to which mitigations have been applied as they do with other
+ messages.
+
+The possible actions for ``dmarc_mitigate_action`` are:
+
+``no_mitigation``
+ Make no transformation to the message.
+``munge_from``
+ Change the ``From:`` header and put the original ``From:`` in ``Reply-To:``
+ or in some cases ``Cc:``.
+``wrap_message``
+ Wrap the message in an outer message with headers from the original message
+ as in ``munge_from``.
+``reject``
+ Bounce the message back to the sender with a default reason or one supplied
+ in ``dmarc_moderation_notice``.
+``discard``
+ Silently discard the message.
+
+Here's what happens when we munge the ``From:``.
+::
+
+ >>> from mailman.interfaces.mailinglist import (
+ ... DMARCMitigateAction, ReplyToMunging)
+
+ >>> mlist = create_list('ant@example.com')
+ >>> mlist.dmarc_mitigate_action = DMARCMitigateAction.munge_from
+ >>> mlist.reply_goes_to_list = ReplyToMunging.no_munging
+
+ >>> msg = message_from_string("""\
+ ... From: Anne Person <aperson@example.com>
+ ... To: ant@example.com
+ ...
+ ... A message of great import.
+ ... """)
+ >>> msgdata = dict(dmarc=True, original_sender='aperson@example.com')
+
+ >>> from mailman.handlers.dmarc import process
+ >>> process(mlist, msg, msgdata)
+ >>> print(msg.as_string())
+ To: ant@example.com
+ From: Anne Person via Ant <ant@example.com>
+ Reply-To: Anne Person <aperson@example.com>
+ <BLANKLINE>
+ A message of great import.
+ <BLANKLINE>
+
+Here we wrap the message without adding a text part.
+::
+
+ >>> mlist.dmarc_mitigate_action = DMARCMitigateAction.wrap_message
+ >>> mlist.dmarc_wrapped_message_text = ''
+
+ >>> msg = message_from_string("""\
+ ... From: Anne Person <aperson@example.com>
+ ... To: ant@example.com
+ ...
+ ... A message of great import.
+ ... """)
+ >>> msgdata = dict(dmarc=True, original_sender='aperson@example.com')
+
+ >>> process(mlist, msg, msgdata)
+ >>> print(msg.as_string())
+ To: ant@example.com
+ MIME-Version: 1.0
+ Message-ID: <...>
+ From: Anne Person via Ant <ant@example.com>
+ Reply-To: Anne Person <aperson@example.com>
+ Content-Type: message/rfc822
+ Content-Disposition: inline
+ <BLANKLINE>
+ From: Anne Person <aperson@example.com>
+ To: ant@example.com
+ <BLANKLINE>
+ A message of great import.
+ <BLANKLINE>
+
+And here's a wrapped message with an added text part.
+::
+
+ >>> mlist.dmarc_wrapped_message_text = 'The original message is attached.'
+
+ >>> msg = message_from_string("""\
+ ... From: Anne Person <aperson@example.com>
+ ... To: ant@example.com
+ ...
+ ... A message of great import.
+ ... """)
+ >>> msgdata = dict(dmarc=True, original_sender='aperson@example.com')
+
+ >>> process(mlist, msg, msgdata)
+ >>> print(msg.as_string())
+ To: ant@example.com
+ MIME-Version: 1.0
+ Message-ID: <...>
+ From: Anne Person via Ant <ant@example.com>
+ Reply-To: Anne Person <aperson@example.com>
+ Content-Type: multipart/mixed; boundary="..."
+ <BLANKLINE>
+ --...
+ Content-Type: text/plain; charset="us-ascii"
+ MIME-Version: 1.0
+ Content-Transfer-Encoding: 7bit
+ Content-Disposition: inline
+ <BLANKLINE>
+ The original message is attached.
+ --...
+ Content-Type: message/rfc822
+ MIME-Version: 1.0
+ Content-Disposition: inline
+ <BLANKLINE>
+ From: Anne Person <aperson@example.com>
+ To: ant@example.com
+ <BLANKLINE>
+ A message of great import.
+ <BLANKLINE>
+ --...--
+ <BLANKLINE>
+
+
+.. _DMARC: https://wikipedia.org/wiki/DMARC
diff --git a/src/mailman/handlers/tests/test_dmarc.py b/src/mailman/handlers/tests/test_dmarc.py
new file mode 100644
index 000000000..bd1e2a1d6
--- /dev/null
+++ b/src/mailman/handlers/tests/test_dmarc.py
@@ -0,0 +1,441 @@
+# Copyright (C) 2016 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 the DMARC handler."""
+
+import unittest
+
+from mailman.app.lifecycle import create_list
+from mailman.app.membership import add_member
+from mailman.handlers import dmarc
+from mailman.interfaces.mailinglist import DMARCMitigateAction, ReplyToMunging
+from mailman.interfaces.subscriptions import RequestRecord
+from mailman.testing.helpers import specialized_message_from_string as mfs
+from mailman.testing.layers import ConfigLayer
+
+
+class TestDMARCMitigations(unittest.TestCase):
+ """Test the dmarc handler."""
+
+ layer = ConfigLayer
+ maxDiff = None
+
+ def setUp(self):
+ self._mlist = create_list('ant@example.com')
+ self._mlist.anonymous_list = False
+ self._mlist.dmarc_policy_mitigate_action = (
+ DMARCMitigateAction.no_mitigation)
+ self._mlist.dmarc_wrapped_message_text = ''
+ self._mlist.dmarc_mitigate_unconditionally = False
+ self._mlist.reply_goes_to_list = ReplyToMunging.no_munging
+ # We can use the same message text for most tests.
+ self._text = """\
+From: anne@example.com
+To: ant@example.com
+Subject: A subject
+X-Mailman-Version: X.Y
+Message-ID: <alpha@example.com>
+Date: Fri, 1 Jan 2016 00:00:01 +0000
+Another-Header: To test removal in wrapper
+MIME-Version: 1.0
+Content-Type: multipart/alternative; boundary="=====abc=="
+
+--=====abc==
+Content-Type: text/plain; charset="us-ascii"
+Content-Transfer-Encoding: 7bit
+
+Some things to say.
+--=====abc==
+Content-Type: text/html; charset="us-ascii"
+Content-Transfer-Encoding: 7bit
+
+<html><head></head><body>Some things to say.</body></html>
+--=====abc==--
+"""
+
+ def test_default_no_change(self):
+ msg = mfs(self._text)
+ dmarc.process(self._mlist, msg, {})
+ self.assertMultiLineEqual(msg.as_string(), self._text)
+
+ def test_anonymous_no_change(self):
+ self._mlist.anonymous_list = True
+ self._mlist.dmarc_mitigate_action = DMARCMitigateAction.munge_from
+ msgdata = {'dmarc': True}
+ msg = mfs(self._text)
+ dmarc.process(self._mlist, msg, msgdata)
+ self.assertMultiLineEqual(msg.as_string(), self._text)
+
+ def test_no_mitigation_no_change_1(self):
+ msg = mfs(self._text)
+ self._mlist.dmarc_mitigate_unconditionally = True
+ dmarc.process(self._mlist, msg, {})
+ self.assertMultiLineEqual(msg.as_string(), self._text)
+
+ def test_no_mitigation_no_change_2(self):
+ msg = mfs(self._text)
+ msgdata = {'dmarc': True}
+ dmarc.process(self._mlist, msg, msgdata)
+ self.assertMultiLineEqual(msg.as_string(), self._text)
+
+ def test_action_reject_mitigate_unconditionally(self):
+ msg = mfs(self._text)
+ self._mlist.dmarc_mitigate_unconditionally = True
+ self._mlist.dmarc_mitigate_action = DMARCMitigateAction.reject
+ dmarc.process(self._mlist, msg, {})
+ self.assertMultiLineEqual(msg.as_string(), self._text)
+
+ def test_action_discard_mitigate_unconditionally(self):
+ msg = mfs(self._text)
+ self._mlist.dmarc_mitigate_unconditionally = True
+ self._mlist.dmarc_mitigate_action = DMARCMitigateAction.discard
+ dmarc.process(self._mlist, msg, {})
+ self.assertMultiLineEqual(msg.as_string(), self._text)
+
+ def test_action_munge_from(self):
+ self._mlist.dmarc_mitigate_action = DMARCMitigateAction.munge_from
+ msgdata = {'dmarc': True}
+ msg = mfs(self._text)
+ dmarc.process(self._mlist, msg, msgdata)
+ self.assertMultiLineEqual(msg.as_string(), """\
+To: ant@example.com
+Subject: A subject
+X-Mailman-Version: X.Y
+Message-ID: <alpha@example.com>
+Date: Fri, 1 Jan 2016 00:00:01 +0000
+Another-Header: To test removal in wrapper
+MIME-Version: 1.0
+Content-Type: multipart/alternative; boundary="=====abc=="
+From: anne--- via Ant <ant@example.com>
+Reply-To: anne@example.com
+
+--=====abc==
+Content-Type: text/plain; charset="us-ascii"
+Content-Transfer-Encoding: 7bit
+
+Some things to say.
+--=====abc==
+Content-Type: text/html; charset="us-ascii"
+Content-Transfer-Encoding: 7bit
+
+<html><head></head><body>Some things to say.</body></html>
+--=====abc==--
+""")
+
+ def test_action_munge_from_no_from(self):
+ self._mlist.dmarc_mitigate_action = DMARCMitigateAction.munge_from
+ msgdata = dict(
+ dmarc=True,
+ original_sender='anne@example.com',
+ )
+ msg = mfs(self._text)
+ del msg['from']
+ dmarc.process(self._mlist, msg, msgdata)
+ self.assertMultiLineEqual(msg.as_string(), """\
+To: ant@example.com
+Subject: A subject
+X-Mailman-Version: X.Y
+Message-ID: <alpha@example.com>
+Date: Fri, 1 Jan 2016 00:00:01 +0000
+Another-Header: To test removal in wrapper
+MIME-Version: 1.0
+Content-Type: multipart/alternative; boundary="=====abc=="
+From: anne--- via Ant <ant@example.com>
+Reply-To: anne@example.com
+
+--=====abc==
+Content-Type: text/plain; charset="us-ascii"
+Content-Transfer-Encoding: 7bit
+
+Some things to say.
+--=====abc==
+Content-Type: text/html; charset="us-ascii"
+Content-Transfer-Encoding: 7bit
+
+<html><head></head><body>Some things to say.</body></html>
+--=====abc==--
+""")
+
+ def test_action_munge_multiple_froms(self):
+ self._mlist.dmarc_mitigate_action = DMARCMitigateAction.munge_from
+ msgdata = dict(
+ dmarc=True,
+ original_sender='cate@example.com',
+ )
+ msg = mfs(self._text)
+ # Put multiple addresses in the From: header. The msgdata must
+ # contain a key naming the "original sender" as determined by the
+ # Message.sender attribute.
+ del msg['from']
+ msg['From'] = 'anne@example.com, bart@example.com'
+ dmarc.process(self._mlist, msg, msgdata)
+ self.assertMultiLineEqual(msg.as_string(), """\
+To: ant@example.com
+Subject: A subject
+X-Mailman-Version: X.Y
+Message-ID: <alpha@example.com>
+Date: Fri, 1 Jan 2016 00:00:01 +0000
+Another-Header: To test removal in wrapper
+MIME-Version: 1.0
+Content-Type: multipart/alternative; boundary="=====abc=="
+From: cate--- via Ant <ant@example.com>
+Reply-To: cate@example.com
+
+--=====abc==
+Content-Type: text/plain; charset="us-ascii"
+Content-Transfer-Encoding: 7bit
+
+Some things to say.
+--=====abc==
+Content-Type: text/html; charset="us-ascii"
+Content-Transfer-Encoding: 7bit
+
+<html><head></head><body>Some things to say.</body></html>
+--=====abc==--
+""")
+
+ def test_action_munge_from_display_name_in_from(self):
+ self._mlist.dmarc_mitigate_action = DMARCMitigateAction.munge_from
+ msgdata = {'dmarc': True}
+ msg = mfs(self._text)
+ del msg['from']
+ msg['From'] = 'Anne Person <anne@example.com>'
+ dmarc.process(self._mlist, msg, msgdata)
+ self.assertMultiLineEqual(msg.as_string(), """\
+To: ant@example.com
+Subject: A subject
+X-Mailman-Version: X.Y
+Message-ID: <alpha@example.com>
+Date: Fri, 1 Jan 2016 00:00:01 +0000
+Another-Header: To test removal in wrapper
+MIME-Version: 1.0
+Content-Type: multipart/alternative; boundary="=====abc=="
+From: Anne Person via Ant <ant@example.com>
+Reply-To: Anne Person <anne@example.com>
+
+--=====abc==
+Content-Type: text/plain; charset="us-ascii"
+Content-Transfer-Encoding: 7bit
+
+Some things to say.
+--=====abc==
+Content-Type: text/html; charset="us-ascii"
+Content-Transfer-Encoding: 7bit
+
+<html><head></head><body>Some things to say.</body></html>
+--=====abc==--
+""")
+
+ def test_action_munge_from_display_name_in_list(self):
+ self._mlist.dmarc_mitigate_action = DMARCMitigateAction.munge_from
+ add_member(
+ self._mlist,
+ RequestRecord('anne@example.com', 'Anna Banana')
+ )
+ msgdata = {'dmarc': True}
+ msg = mfs(self._text)
+ dmarc.process(self._mlist, msg, msgdata)
+ self.assertMultiLineEqual(msg.as_string(), """\
+To: ant@example.com
+Subject: A subject
+X-Mailman-Version: X.Y
+Message-ID: <alpha@example.com>
+Date: Fri, 1 Jan 2016 00:00:01 +0000
+Another-Header: To test removal in wrapper
+MIME-Version: 1.0
+Content-Type: multipart/alternative; boundary="=====abc=="
+From: Anna Banana via Ant <ant@example.com>
+Reply-To: anne@example.com
+
+--=====abc==
+Content-Type: text/plain; charset="us-ascii"
+Content-Transfer-Encoding: 7bit
+
+Some things to say.
+--=====abc==
+Content-Type: text/html; charset="us-ascii"
+Content-Transfer-Encoding: 7bit
+
+<html><head></head><body>Some things to say.</body></html>
+--=====abc==--
+""")
+
+ def test_no_action_without_msgdata(self):
+ self._mlist.dmarc_mitigate_action = DMARCMitigateAction.munge_from
+ msg = mfs(self._text)
+ dmarc.process(self._mlist, msg, {})
+ self.assertMultiLineEqual(msg.as_string(), self._text)
+
+ def test_unconditional_no_msgdata(self):
+ self._mlist.dmarc_mitigate_action = DMARCMitigateAction.munge_from
+ self._mlist.dmarc_mitigate_unconditionally = True
+ msg = mfs(self._text)
+ dmarc.process(self._mlist, msg, {})
+ self.assertMultiLineEqual(msg.as_string(), """\
+To: ant@example.com
+Subject: A subject
+X-Mailman-Version: X.Y
+Message-ID: <alpha@example.com>
+Date: Fri, 1 Jan 2016 00:00:01 +0000
+Another-Header: To test removal in wrapper
+MIME-Version: 1.0
+Content-Type: multipart/alternative; boundary="=====abc=="
+From: anne--- via Ant <ant@example.com>
+Reply-To: anne@example.com
+
+--=====abc==
+Content-Type: text/plain; charset="us-ascii"
+Content-Transfer-Encoding: 7bit
+
+Some things to say.
+--=====abc==
+Content-Type: text/html; charset="us-ascii"
+Content-Transfer-Encoding: 7bit
+
+<html><head></head><body>Some things to say.</body></html>
+--=====abc==--
+""")
+
+ def test_from_in_cc(self):
+ self._mlist.dmarc_mitigate_action = DMARCMitigateAction.munge_from
+ self._mlist.reply_goes_to_list = ReplyToMunging.point_to_list
+ msgdata = {'dmarc': True}
+ msg = mfs(self._text)
+ dmarc.process(self._mlist, msg, msgdata)
+ self.assertMultiLineEqual(msg.as_string(), """\
+To: ant@example.com
+Subject: A subject
+X-Mailman-Version: X.Y
+Message-ID: <alpha@example.com>
+Date: Fri, 1 Jan 2016 00:00:01 +0000
+Another-Header: To test removal in wrapper
+MIME-Version: 1.0
+Content-Type: multipart/alternative; boundary="=====abc=="
+From: anne--- via Ant <ant@example.com>
+Cc: anne@example.com
+
+--=====abc==
+Content-Type: text/plain; charset="us-ascii"
+Content-Transfer-Encoding: 7bit
+
+Some things to say.
+--=====abc==
+Content-Type: text/html; charset="us-ascii"
+Content-Transfer-Encoding: 7bit
+
+<html><head></head><body>Some things to say.</body></html>
+--=====abc==--
+""")
+
+ def test_wrap_message_unconditionally(self):
+ self._mlist.dmarc_mitigate_action = DMARCMitigateAction.wrap_message
+ self._mlist.dmarc_mitigate_unconditionally = True
+ msg = mfs(self._text)
+ dmarc.process(self._mlist, msg, {})
+ # We can't predict the Message-ID in the wrapper so delete it, but
+ # ensure we have one.
+ self.assertIsNotNone(msg.get('message-id'))
+ del msg['message-id']
+ self.assertMultiLineEqual(msg.as_string(), """\
+To: ant@example.com
+Subject: A subject
+X-Mailman-Version: X.Y
+Date: Fri, 1 Jan 2016 00:00:01 +0000
+MIME-Version: 1.0
+From: anne--- via Ant <ant@example.com>
+Reply-To: anne@example.com
+Content-Type: message/rfc822
+Content-Disposition: inline
+
+""" + self._text)
+
+ def test_wrap_message(self):
+ self._mlist.dmarc_mitigate_action = DMARCMitigateAction.wrap_message
+ msgdata = {'dmarc': True}
+ msg = mfs(self._text)
+ dmarc.process(self._mlist, msg, msgdata)
+ # We can't predict the Message-ID in the wrapper so delete it, but
+ # ensure we have one.
+ self.assertIsNotNone(msg.get('message-id'))
+ del msg['message-id']
+ self.assertMultiLineEqual(msg.as_string(), """\
+To: ant@example.com
+Subject: A subject
+X-Mailman-Version: X.Y
+Date: Fri, 1 Jan 2016 00:00:01 +0000
+MIME-Version: 1.0
+From: anne--- via Ant <ant@example.com>
+Reply-To: anne@example.com
+Content-Type: message/rfc822
+Content-Disposition: inline
+
+""" + self._text)
+
+ def test_wrap_message_cc(self):
+ self._mlist.dmarc_mitigate_action = DMARCMitigateAction.wrap_message
+ self._mlist.reply_goes_to_list = ReplyToMunging.point_to_list
+ msgdata = {'dmarc': True}
+ msg = mfs(self._text)
+ dmarc.process(self._mlist, msg, msgdata)
+ # We can't predict the Message-ID in the wrapper so delete it, but
+ # ensure we have one.
+ self.assertIsNotNone(msg.get('message-id'))
+ del msg['message-id']
+ self.assertMultiLineEqual(msg.as_string(), """\
+To: ant@example.com
+Subject: A subject
+X-Mailman-Version: X.Y
+Date: Fri, 1 Jan 2016 00:00:01 +0000
+MIME-Version: 1.0
+From: anne--- via Ant <ant@example.com>
+Cc: anne@example.com
+Content-Type: message/rfc822
+Content-Disposition: inline
+
+""" + self._text)
+
+ def test_rfc2047_encoded_from(self):
+ self._mlist.dmarc_mitigate_action = DMARCMitigateAction.munge_from
+ msgdata = {'dmarc': True}
+ msg = mfs(self._text)
+ del msg['from']
+ msg['From'] = '=?iso-8859-1?Q?A_Pers=F3n?= <anne@example.com>'
+ dmarc.process(self._mlist, msg, msgdata)
+ self.assertMultiLineEqual(msg.as_string(), """\
+To: ant@example.com
+Subject: A subject
+X-Mailman-Version: X.Y
+Message-ID: <alpha@example.com>
+Date: Fri, 1 Jan 2016 00:00:01 +0000
+Another-Header: To test removal in wrapper
+MIME-Version: 1.0
+Content-Type: multipart/alternative; boundary="=====abc=="
+From: =?utf-8?q?A_Pers=C3=B3n_via_Ant?= <ant@example.com>
+Reply-To: =?iso-8859-1?Q?A_Pers=F3n?= <anne@example.com>
+
+--=====abc==
+Content-Type: text/plain; charset="us-ascii"
+Content-Transfer-Encoding: 7bit
+
+Some things to say.
+--=====abc==
+Content-Type: text/html; charset="us-ascii"
+Content-Transfer-Encoding: 7bit
+
+<html><head></head><body>Some things to say.</body></html>
+--=====abc==--
+""")