summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/mailman/handlers/dmarc.py97
-rw-r--r--src/mailman/handlers/docs/dmarc-mitigations.rst121
-rw-r--r--src/mailman/handlers/tests/test_dmarc.py2
-rw-r--r--src/mailman/interfaces/mailinglist.py45
-rw-r--r--src/mailman/rules/dmarc.py121
-rw-r--r--src/mailman/rules/tests/test_dmarc.py38
6 files changed, 232 insertions, 192 deletions
diff --git a/src/mailman/handlers/dmarc.py b/src/mailman/handlers/dmarc.py
index 99d8e08df..13707d397 100644
--- a/src/mailman/handlers/dmarc.py
+++ b/src/mailman/handlers/dmarc.py
@@ -43,6 +43,7 @@ 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
@@ -84,48 +85,49 @@ def munged_headers(mlist, msg, msgdata):
# goals is priority order.
#
# Be as robust as possible here.
- faddrs = getaddresses(msg.get_all('from', []))
+ all_froms = getaddresses(msg.get_all('from', []))
# Strip the nulls and bad emails.
- faddrs = [x for x in faddrs if x[1].find('@') > 0]
- if len(faddrs) == 1:
- realname, email = o_from = faddrs[0]
+ 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']
- o_from = (realname, email)
+ 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 domain from realname if it looks like an email address.
+ # 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 list, 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.
- srn = ''
- for frag, cs in decode_header(realname):
- if not cs:
- # Character set should be ascii, but use iso-8859-1 anyway.
- cs = 'iso-8859-1'
- if not isinstance(frag, str):
- srn += str(frag, cs, errors='replace')
+ # 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:
- srn += frag
- # The list's real_name is a string.
- lrn = mlist.display_name # noqa F841
- realname = srn
+ 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 $lrn')
+ via = _('$realname via $mlist.display_name')
# Get an RFC 2047 encoded header string.
- dn = str(Header(via, mlist.preferred_language.charset))
- retn = [('From', formataddr((dn, mlist.posting_address)))]
+ 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:
@@ -133,52 +135,54 @@ def munged_headers(mlist, msg, msgdata):
else:
# Add original from to Cc:
add_to = 'Cc'
- orig = getaddresses(msg.get_all(add_to, []))
- if o_from[1] not in [x[1] for x in orig]:
- orig.append(o_from)
- retn.append((add_to, COMMASPACE.join(formataddr(x) for x in orig)))
- return retn
+ 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 k, v in munged_headers(mlist, msg, msgdata):
- del msg[k]
- msg[k] = v
+ 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 msg, then delete almost everything and set/copy
+ # make a copy of the message, then delete almost everything and set/copy
# what we want.
- omsg = copy.deepcopy(msg)
+ original_msg = copy.deepcopy(msg)
for key in msg:
keep = False
for keeper in KEEPERS:
- if re.match(keeper, key, re.I):
+ 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 k, v in munged_headers(mlist, omsg, msgdata):
- msg[k] = v
+ 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 = MIMEText(
+ wrap(mlist.dmarc_wrapped_message_text),
+ 'plain',
+ mlist.preferred_language.charset)
part1['Content-Disposition'] = 'inline'
- part2 = MIMEMessage(omsg)
+ 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([omsg])
+ msg.set_payload([original_msg])
return
@@ -186,7 +190,7 @@ 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 we're not mitigating, return.
if mlist.dmarc_mitigate_action is DMARCMitigateAction.no_mitigation:
return
if mlist.anonymous_list:
@@ -198,10 +202,11 @@ def process(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. We just ignore
+ # 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(
diff --git a/src/mailman/handlers/docs/dmarc-mitigations.rst b/src/mailman/handlers/docs/dmarc-mitigations.rst
index d26fc6bdd..12a0de0ff 100644
--- a/src/mailman/handlers/docs/dmarc-mitigations.rst
+++ b/src/mailman/handlers/docs/dmarc-mitigations.rst
@@ -4,128 +4,138 @@ 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 to
-USENET.
+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.
+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 what transformations are applied depends on a number of list settings.
+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.
+ 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 next setting.
+ 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``
- A Flag to apply dmarc_mitigate_action to all messages, but only if
- dmarc_mitigate_action is neither reject or discard.
+ 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 bult-in
- default text.
+ 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.
+ 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
+ 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:
+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:
+ 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 as in
- munge_from.
+ 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.
+ 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.
+Here's what happens when we munge the ``From:``.
+::
- >>> from mailman.interfaces.mailinglist import (DMARCMitigateAction,
- ... ReplyToMunging)
- >>> mlist = create_list('test@example.com')
+ >>> 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: A Person <aperson@example.com>
- ... To: test@example.com
+ ... 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: test@example.com
- From: A Person via Test <test@example.com>
- Reply-To: A Person <aperson@example.com>
+ 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: A Person <aperson@example.com>
- ... To: test@example.com
+ ... 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: test@example.com
+ To: ant@example.com
MIME-Version: 1.0
Message-ID: <...>
- From: A Person via Test <test@example.com>
- Reply-To: A Person <aperson@example.com>
+ From: Anne Person via Ant <ant@example.com>
+ Reply-To: Anne Person <aperson@example.com>
Content-Type: message/rfc822
Content-Disposition: inline
<BLANKLINE>
- From: A Person <aperson@example.com>
- To: test@example.com
+ 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: A Person <aperson@example.com>
- ... To: test@example.com
+ ... 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: test@example.com
+ To: ant@example.com
MIME-Version: 1.0
Message-ID: <...>
- From: A Person via Test <test@example.com>
- Reply-To: A Person <aperson@example.com>
+ From: Anne Person via Ant <ant@example.com>
+ Reply-To: Anne Person <aperson@example.com>
Content-Type: multipart/mixed; boundary="..."
<BLANKLINE>
--...
@@ -140,12 +150,13 @@ And here's a wrapped message with an added text part.
MIME-Version: 1.0
Content-Disposition: inline
<BLANKLINE>
- From: A Person <aperson@example.com>
- To: test@example.com
+ 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
index 6fe15bd18..bd1e2a1d6 100644
--- a/src/mailman/handlers/tests/test_dmarc.py
+++ b/src/mailman/handlers/tests/test_dmarc.py
@@ -15,7 +15,7 @@
# 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."""
+"""Test the DMARC handler."""
import unittest
diff --git a/src/mailman/interfaces/mailinglist.py b/src/mailman/interfaces/mailinglist.py
index 0f1fbace1..44376a7b2 100644
--- a/src/mailman/interfaces/mailinglist.py
+++ b/src/mailman/interfaces/mailinglist.py
@@ -25,21 +25,21 @@ from zope.interface import Attribute, Interface
@public
class DMARCMitigateAction(Enum):
- # Mitigation to apply to messages From: domains publishing an applicable
- # DMARC policy or unconditionally depending on settings.
- no_mitigation = 0
+ # Mitigations to apply to messages From: domains publishing an applicable
+ # DMARC policy, or unconditionally depending on settings.
+ #
# No DMARC mitigations.
+ no_mitigation = 0
+ # Messages From: domains with DMARC policy will have From: replaced by the
+ # list posting address and the original From: added to Reply-To: or Cc:.
munge_from = 1
- # Messages From: domains with DMARC policy will have From: replaced by
- # the list posting address and the original From: added to Reply-To:
- # or Cc:.
- wrap_message = 2
# Messages From: domains with DMARC policy will be wrapped in an outer
# message From: the list posting address.
- reject = 3
+ wrap_message = 2
# Messages From: domains with DMARC policy will be rejected.
- discard = 4
+ reject = 3
# Messages From: domains with DMARC policy will be discarded.
+ discard = 4
@public
@@ -238,24 +238,33 @@ class IMailingList(Interface):
# DMARC attributes.
dmarc_mitigate_action = Attribute(
- """The DMARCMitigateAction to be applied to messages From: a domain
- publishing DMARC p=reject or quarantine and possibly unconditionally.
+ """The mitigation to apply to messages from a DMARC matching domain.
+
+ This is a DMARCMitigateAction to be applied to messages From: a domain
+ publishing DMARC p=reject or quarantine, and possibly unconditionally
+ depending on the setting of dmarc_mitigate_unconditionally.
""")
dmarc_mitigate_unconditionally = Attribute(
- """A Flag to apply dmarc_mitigate_action to all messages but only if
- dmarc_mitigate_action is other than reject or discard.
+ """Should DMARC mitigations apply unconditionally?
+
+ A flag indicating whether to apply dmarc_mitigate_action to all
+ messages, but only if dmarc_mitigate_action is other than reject or
+ discard.
""")
dmarc_moderation_notice = Attribute(
- """Text to include in any rejection notice to be sent when
- DMARCMitigateAction of reject applies.
+ """Text to include in any DMARC rejected message.
+
+ Rejection notices are sent when DMARCMitigateAction of reject applies.
""")
dmarc_wrapped_message_text = Attribute(
- """Text to be added as a separate text/plain MIME part preceding the
- original message part in the wrapped message when
- DMARCMitigateAction of wrap_message applies.
+ """Additional MIME text to include in DMARC wrapped messages.
+
+ This text is added as a separate text/plain MIME part preceding the
+ original message part in the wrapped message when DMARCMitigateAction
+ of wrap_message applies.
""")
# Rosters and subscriptions.
diff --git a/src/mailman/rules/dmarc.py b/src/mailman/rules/dmarc.py
index a213a235b..928914a29 100644
--- a/src/mailman/rules/dmarc.py
+++ b/src/mailman/rules/dmarc.py
@@ -43,6 +43,7 @@ elog = logging.getLogger('mailman.error')
vlog = logging.getLogger('mailman.vette')
DOT = '.'
+EMPTYSTRING = ''
KEEP_LOOKING = object()
LOCAL_FILE_NAME = 'public_suffix_list.dat'
@@ -126,44 +127,53 @@ def parse_suffix_list(filename=None):
suffix_cache[key] = exception
-def _get_dom(d, l):
- # A helper to get a domain name consisting of the first l+1 labels
- # in d.
- dom = d[:min(l+1, len(d))]
- dom.reverse()
- return '.'.join(dom)
+def get_domain(parts, label):
+ # A helper to get a domain name consisting of the first label+1 labels in
+ # parts.
+ domain = parts[:min(label+1, len(parts))]
+ domain.reverse()
+ return DOT.join(domain)
-def _get_org_dom(domain):
+def get_organizational_domain(domain):
# Given a domain name, this returns the corresponding Organizational
# Domain which may be the same as the input.
if len(suffix_cache) == 0:
parse_suffix_list()
hits = []
- d = domain.lower().split('.')
- d.reverse()
- for k in suffix_cache:
- ks = k.split('.')
- if len(d) >= len(ks):
- for i in range(len(ks)-1):
- if d[i] != ks[i] and ks[i] != '*':
+ parts = domain.lower().split('.')
+ parts.reverse()
+ for key in suffix_cache:
+ key_parts = key.split('.')
+ if len(parts) >= len(key_parts):
+ for i in range(len(key_parts) - 1):
+ if parts[i] != key_parts[i] and key_parts[i] != '*':
break
else:
- if d[len(ks)-1] == ks[-1] or ks[-1] == '*':
- hits.append(k)
+ if (parts[len(key_parts) - 1] == key_parts[-1] or
+ key_parts[-1] == '*'):
+ hits.append(key)
if not hits:
- return _get_dom(d, 1)
- l = 0
- for k in hits:
- if suffix_cache[k]:
- # It's an exception
- return _get_dom(d, len(k.split('.'))-1)
- if len(k.split('.')) > l:
- l = len(k.split('.'))
- return _get_dom(d, l)
+ return get_domain(parts, 1)
+ label = 0
+ for key in hits:
+ key_parts = key.split('.')
+ if suffix_cache[key]:
+ # It's an exception.
+ return get_domain(parts, len(key_parts) - 1)
+ if len(key_parts) > label:
+ label = len(key_parts)
+ return get_domain(parts, label)
-def _DMARCProhibited(mlist, email, dmarc_domain, org=False):
+def is_reject_or_quarantine(mlist, email, dmarc_domain, org=False):
+ # This takes a mailing list, an email address as in the From: header, the
+ # _dmarc host name for the domain in question, and a flag stating whether
+ # we should check the organizational domains. It returns one of three
+ # values:
+ # * True if the DMARC policy is reject or quarantine;
+ # * False if is not;
+ # * A special sentinel if we should continue looking
resolver = dns.resolver.Resolver()
resolver.timeout = as_timedelta(
config.dmarc.resolver_timeout).total_seconds()
@@ -173,51 +183,56 @@ def _DMARCProhibited(mlist, email, dmarc_domain, org=False):
txt_recs = resolver.query(dmarc_domain, dns.rdatatype.TXT)
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
return KEEP_LOOKING
- except DNSException as e:
+ except DNSException as error:
elog.error(
'DNSException: Unable to query DMARC policy for %s (%s). %s',
- email, dmarc_domain, e.__doc__)
+ email, dmarc_domain, error.__doc__)
return KEEP_LOOKING
# Be as robust as possible in parsing the result.
results_by_name = {}
cnames = {}
want_names = set([dmarc_domain + '.'])
+ # Check all the TXT records returned by DNS. Keep track of the CNAMEs for
+ # checking later on. Ignore any other non-TXT records.
for txt_rec in txt_recs.response.answer:
if txt_rec.rdtype == dns.rdatatype.CNAME:
cnames[txt_rec.name.to_text()] = (
txt_rec.items[0].target.to_text())
if txt_rec.rdtype != dns.rdatatype.TXT:
continue
- results_by_name.setdefault(
- txt_rec.name.to_text(), []).append(
- "".join(
- [str(x, encoding='utf-8')
- for x in txt_rec.items[0].strings]))
+ result = EMPTYSTRING.join(
+ str(record, encoding='utf-8')
+ for record in txt_rec.items[0].strings)
+ name = txt_rec.name.to_text()
+ results_by_name.setdefault(name, []).append(result)
expands = list(want_names)
seen = set(expands)
while expands:
item = expands.pop(0)
if item in cnames:
if cnames[item] in seen:
- continue # cname loop
+ # CNAME loop.
+ continue
expands.append(cnames[item])
seen.add(cnames[item])
want_names.add(cnames[item])
want_names.discard(item)
- assert len(want_names) == 1, """\
- Error in CNAME processing for {}; want_names != 1.""".format(
- dmarc_domain)
+ assert len(want_names) == 1, (
+ 'Error in CNAME processing for {}; want_names != 1.'.format(
+ dmarc_domain))
for name in want_names:
if name not in results_by_name:
continue
- dmarcs = [x for x in results_by_name[name]
- if x.startswith('v=DMARC1;')]
+ dmarcs = [
+ record for record in results_by_name[name]
+ if record.startswith('v=DMARC1;')
+ ]
if len(dmarcs) == 0:
return KEEP_LOOKING
if len(dmarcs) > 1:
elog.error(
- """RRset of TXT records for %s has %d v=DMARC1 entries;
- testing them all""",
+ 'RRset of TXT records for %s has %d v=DMARC1 entries; '
+ 'testing them all',
dmarc_domain, len(dmarcs))
for entry in dmarcs:
mo = re.search(r'\bsp=(\w*)\b', entry, re.IGNORECASE)
@@ -237,8 +252,7 @@ def _DMARCProhibited(mlist, email, dmarc_domain, org=False):
continue # pragma: no cover
if policy in ('reject', 'quarantine'):
vlog.info(
- """%s: DMARC lookup for %s (%s)
- found p=%s in %s = %s""",
+ '%s: DMARC lookup for %s (%s) found p=%s in %s = %s',
mlist.list_name,
email,
dmarc_domain,
@@ -249,23 +263,24 @@ def _DMARCProhibited(mlist, email, dmarc_domain, org=False):
return False
-def _IsDMARCProhibited(mlist, email):
+def maybe_mitigate(mlist, email):
# This takes an email address, and returns True if DMARC policy is
- # p=reject or quarantine.
+ # p=reject or p=quarantine.
email = email.lower()
# Scan from the right in case quoted local part has an '@'.
local, at, from_domain = email.rpartition('@')
if at != '@':
return False
- x = _DMARCProhibited(mlist, email, '_dmarc.{}'.format(from_domain))
- if x is not KEEP_LOOKING:
- return x
- org_dom = _get_org_dom(from_domain)
+ answer = is_reject_or_quarantine(
+ mlist, email, '_dmarc.{}'.format(from_domain))
+ if answer is not KEEP_LOOKING:
+ return answer
+ org_dom = get_organizational_domain(from_domain)
if org_dom != from_domain:
- x = _DMARCProhibited(
+ answer = is_reject_or_quarantine(
mlist, email, '_dmarc.{}'.format(org_dom), org=True)
- if x is not KEEP_LOOKING:
- return x
+ if answer is not KEEP_LOOKING:
+ return answer
return False
@@ -284,7 +299,7 @@ class DMARCMitigation:
# Don't bother to check if we're not going to do anything.
return False
dn, addr = parseaddr(msg.get('from'))
- if _IsDMARCProhibited(mlist, addr):
+ if maybe_mitigate(mlist, addr):
# 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
diff --git a/src/mailman/rules/tests/test_dmarc.py b/src/mailman/rules/tests/test_dmarc.py
index 52c231439..f15a39b13 100644
--- a/src/mailman/rules/tests/test_dmarc.py
+++ b/src/mailman/rules/tests/test_dmarc.py
@@ -59,7 +59,7 @@ def get_dns_resolver(
It only implements those classes and attributes used by the dmarc rule.
"""
class Name:
- # mock answer.name
+ # Mock answer.name.
def __init__(self, name='_dmarc.example.biz.'):
self.name = name
@@ -67,14 +67,14 @@ def get_dns_resolver(
return self.name
class Item:
- # mock answer.items
+ # Mock answer.items.
def __init__(self, rdata=rdata, cname='_dmarc.example.com.'):
self.strings = [rdata]
- # for CNAMES
+ # For CNAMEs.
self.target = Name(cname)
class Ans_e:
- # mock answer element
+ # Mock answer element.
def __init__(
self,
rtype=rtype,
@@ -86,7 +86,7 @@ def get_dns_resolver(
self.name = Name(name)
class Answer:
- # mock answer
+ # Mock answer.
def __init__(self):
if cloop:
self.answer = [
@@ -143,10 +143,7 @@ def get_dns_resolver(
self.answer = [Ans_e()]
class Resolver:
- # mock dns.resolver.Resolver class.
- def __init__(self):
- pass
-
+ # Mock dns.resolver.Resolver class.
def query(self, domain, data_type):
if data_type != TXT:
raise NoAnswer
@@ -191,17 +188,17 @@ class TestDMARCRules(TestCase):
def test_no_data_for_domain(self):
self.assertEqual(
- dmarc._get_org_dom('sub.dom.example.nxtld'),
+ dmarc.get_organizational_domain('sub.dom.example.nxtld'),
'example.nxtld')
def test_domain_with_wild_card(self):
self.assertEqual(
- dmarc._get_org_dom('ssub.sub.foo.kobe.jp'),
+ dmarc.get_organizational_domain('ssub.sub.foo.kobe.jp'),
'sub.foo.kobe.jp')
def test_exception_to_wild_card(self):
self.assertEqual(
- dmarc._get_org_dom('ssub.sub.city.kobe.jp'),
+ dmarc.get_organizational_domain('ssub.sub.city.kobe.jp'),
'city.kobe.jp')
def test_no_at_sign_in_from_address(self):
@@ -305,9 +302,9 @@ To: ant@example.com
self.assertTrue(rule.check(mlist, msg, {}))
line = mark.readline()
self.assertEqual(
- line[-68:],
- 'RRset of TXT records for _dmarc.example.biz has 2 v=DMARC1 '
- 'entries;\n')
+ line[-85:],
+ 'RRset of TXT records for _dmarc.example.biz has 2 '
+ 'v=DMARC1 entries; testing them all\n')
def test_multiple_cnames(self):
mlist = create_list('ant@example.com')
@@ -324,8 +321,10 @@ To: ant@example.com
self.assertTrue(rule.check(mlist, msg, {}))
line = mark.readline()
self.assertEqual(
- line[-60:],
- 'ant: DMARC lookup for anne@example.biz (_dmarc.example.biz)\n')
+ line[-128:],
+ 'ant: DMARC lookup for anne@example.biz (_dmarc.example.biz) '
+ 'found p=quarantine in _dmarc.example.com. = v=DMARC1; '
+ 'p=quarantine;\n')
def test_looping_cnames(self):
mlist = create_list('ant@example.com')
@@ -342,8 +341,9 @@ To: ant@example.com
self.assertTrue(rule.check(mlist, msg, {}))
line = mark.readline()
self.assertEqual(
- line[-60:],
- 'ant: DMARC lookup for anne@example.biz (_dmarc.example.biz)\n')
+ line[-120:],
+ 'ant: DMARC lookup for anne@example.biz (_dmarc.example.biz) '
+ 'found p=reject in _dmarc.example.org. = v=DMARC1; p=reject;\n')
def test_no_policy(self):
mlist = create_list('ant@example.com')