# 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 .
"""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 (
DMARCModerationAction, FromIsList, ReplyToMunging)
from mailman.utilities.string import wrap
from public import public
from zope.interface import implementer
log = logging.getLogger('mailman.error')
COMMASPACE = ', '
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'
# 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.
faddrs = 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]
else:
# No From: or multiple addresses. Just punt and take
# the get_sender result.
realname = ''
email = msgdata['original_sender']
o_from = (realname, email)
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.
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')
else:
srn += frag
# The list's real_name is a string.
lrn = mlist.display_name # noqa F841
realname = srn
# Ensure the i18n context is the list's preferred_language.
with _.using(mlist.preferred_language.code):
via = _('$realname via $lrn')
# Get an RFC 2047 encoded header string.
dn = str(Header(via, mlist.preferred_language.charset))
retn = [('From', formataddr((dn, 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'
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
def munge_from(mlist, msg, msgdata):
for k, v in munged_headers(mlist, msg, msgdata):
del msg[k]
msg[k] = v
return
def wrap_message(mlist, msg, msgdata, dmarc_wrap=False):
# 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
# what we want.
omsg = copy.deepcopy(msg)
for key in msg:
keep = False
for keeper in KEEPERS:
if re.match(keeper, key, re.I):
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
# Are we including dmarc_wrapped_message_text? I.e., do we have text and
# are we wrapping because of dmarc_moderation_action?
if len(mlist.dmarc_wrapped_message_text) > 0 and dmarc_wrap:
part1 = MIMEText(wrap(mlist.dmarc_wrapped_message_text),
'plain',
mlist.preferred_language.charset)
part1['Content-Disposition'] = 'inline'
part2 = MIMEMessage(omsg)
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])
return
def process(mlist, msg, msgdata):
"""Process DMARC actions."""
if ((not msgdata.get('dmarc') or
mlist.dmarc_moderation_action is DMARCModerationAction.none) and
mlist.from_is_list is FromIsList.none):
return
if mlist.anonymous_list:
# DMARC mitigation is not required for anonymous lists.
return
if (mlist.dmarc_moderation_action is not DMARCModerationAction.none and
msgdata.get('dmarc')):
if mlist.dmarc_moderation_action is DMARCModerationAction.munge_from:
munge_from(mlist, msg, msgdata)
elif (mlist.dmarc_moderation_action is
DMARCModerationAction.wrap_message):
wrap_message(mlist, msg, msgdata, dmarc_wrap=True)
else:
raise AssertionError(
'handlers/dmarc.py: dmarc_moderation_action = {}'.format(
mlist.dmarc_moderation_action))
else:
if mlist.from_is_list is FromIsList.munge_from:
munge_from(mlist, msg, msgdata)
elif mlist.from_is_list is FromIsList.wrap_message:
wrap_message(mlist, msg, msgdata)
else:
raise AssertionError(
'handlers/dmarc.py: from_is_list = {}'.format(
mlist.from_is_list))
@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)