diff options
Diffstat (limited to 'src/mailman/chains')
| -rw-r--r-- | src/mailman/chains/__init__.py | 0 | ||||
| -rw-r--r-- | src/mailman/chains/accept.py | 58 | ||||
| -rw-r--r-- | src/mailman/chains/base.py | 122 | ||||
| -rw-r--r-- | src/mailman/chains/builtin.py | 86 | ||||
| -rw-r--r-- | src/mailman/chains/discard.py | 47 | ||||
| -rw-r--r-- | src/mailman/chains/headers.py | 156 | ||||
| -rw-r--r-- | src/mailman/chains/hold.py | 178 | ||||
| -rw-r--r-- | src/mailman/chains/reject.py | 59 |
8 files changed, 706 insertions, 0 deletions
diff --git a/src/mailman/chains/__init__.py b/src/mailman/chains/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/mailman/chains/__init__.py diff --git a/src/mailman/chains/accept.py b/src/mailman/chains/accept.py new file mode 100644 index 000000000..bd47f42c8 --- /dev/null +++ b/src/mailman/chains/accept.py @@ -0,0 +1,58 @@ +# Copyright (C) 2007-2009 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/>. + +"""The terminal 'accept' chain.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'AcceptChain', + ] + +import logging + +from mailman.chains.base import TerminalChainBase +from mailman.config import config +from mailman.i18n import _ + + +log = logging.getLogger('mailman.vette') +SEMISPACE = '; ' + + + +class AcceptChain(TerminalChainBase): + """Accept the message for posting.""" + + name = 'accept' + description = _('Accept a message.') + + def _process(self, mlist, msg, msgdata): + """See `TerminalChainBase`.""" + # Start by decorating the message with a header that contains a list + # of all the rules that matched. These metadata could be None or an + # empty list. + rule_hits = msgdata.get('rule_hits') + if rule_hits: + msg['X-Mailman-Rule-Hits'] = SEMISPACE.join(rule_hits) + rule_misses = msgdata.get('rule_misses') + if rule_misses: + msg['X-Mailman-Rule-Misses'] = SEMISPACE.join(rule_misses) + accept_queue = config.switchboards['pipeline'] + accept_queue.enqueue(msg, msgdata) + log.info('ACCEPT: %s', msg.get('message-id', 'n/a')) diff --git a/src/mailman/chains/base.py b/src/mailman/chains/base.py new file mode 100644 index 000000000..bcd946b40 --- /dev/null +++ b/src/mailman/chains/base.py @@ -0,0 +1,122 @@ +# Copyright (C) 2008-2009 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/>. + +"""Base class for terminal chains.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Chain', + 'Link', + 'TerminalChainBase', + ] + + +from zope.interface import implements + +from mailman.config import config +from mailman.interfaces.chain import ( + IChain, IChainIterator, IChainLink, IMutableChain, LinkAction) + + + +class Link: + """A chain link.""" + implements(IChainLink) + + def __init__(self, rule, action=None, chain=None, function=None): + self.rule = rule + self.action = (LinkAction.defer if action is None else action) + self.chain = chain + self.function = function + + + +class TerminalChainBase: + """A base chain that always matches and executes a method. + + The method is called 'process' and must be provided by the subclass. + """ + implements(IChain, IChainIterator) + + def _process(self, mlist, msg, msgdata): + """Process the message for the given mailing list. + + This must be overridden by subclasses. + """ + raise NotImplementedError + + def get_links(self, mlist, msg, msgdata): + """See `IChain`.""" + return iter(self) + + def __iter__(self): + """See `IChainIterator`.""" + truth = config.rules['truth'] + # First, yield a link that always runs the process method. + yield Link(truth, LinkAction.run, function=self._process) + # Now yield a rule that stops all processing. + yield Link(truth, LinkAction.stop) + + + +class Chain: + """Generic chain base class.""" + implements(IMutableChain) + + def __init__(self, name, description): + assert name not in config.chains, ( + 'Duplicate chain name: {0}'.format(name)) + self.name = name + self.description = description + self._links = [] + # Register the chain. + config.chains[name] = self + + def append_link(self, link): + """See `IMutableChain`.""" + self._links.append(link) + + def flush(self): + """See `IMutableChain`.""" + self._links = [] + + def get_links(self, mlist, msg, msgdata): + """See `IChain`.""" + return iter(ChainIterator(self)) + + def get_iterator(self): + """Return an iterator over the links.""" + # We do it this way in order to preserve a separation of interfaces, + # and allows .get_links() to be overridden. + for link in self._links: + yield link + + + +class ChainIterator: + """Generic chain iterator.""" + + implements(IChainIterator) + + def __init__(self, chain): + self._chain = chain + + def __iter__(self): + """See `IChainIterator`.""" + return self._chain.get_iterator() diff --git a/src/mailman/chains/builtin.py b/src/mailman/chains/builtin.py new file mode 100644 index 000000000..05912a2f2 --- /dev/null +++ b/src/mailman/chains/builtin.py @@ -0,0 +1,86 @@ +# Copyright (C) 2007-2009 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/>. + +"""The default built-in starting chain.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'BuiltInChain', + ] + + +import logging + +from zope.interface import implements + +from mailman.chains.base import Link +from mailman.config import config +from mailman.i18n import _ +from mailman.interfaces.chain import IChain, LinkAction + + +log = logging.getLogger('mailman.vette') + + + +class BuiltInChain: + """Default built-in chain.""" + + implements(IChain) + + name = 'built-in' + description = _('The built-in moderation chain.') + + _link_descriptions = ( + ('approved', LinkAction.jump, 'accept'), + ('emergency', LinkAction.jump, 'hold'), + ('loop', LinkAction.jump, 'discard'), + # Do all of the following before deciding whether to hold the message + # for moderation. + ('administrivia', LinkAction.defer, None), + ('implicit-dest', LinkAction.defer, None), + ('max-recipients', LinkAction.defer, None), + ('max-size', LinkAction.defer, None), + ('news-moderation', LinkAction.defer, None), + ('no-subject', LinkAction.defer, None), + ('suspicious-header', LinkAction.defer, None), + # Now if any of the above hit, jump to the hold chain. + ('any', LinkAction.jump, 'hold'), + # Take a detour through the self header matching chain, which we'll + # create later. + ('truth', LinkAction.detour, 'header-match'), + # Finally, the builtin chain selfs to acceptance. + ('truth', LinkAction.jump, 'accept'), + ) + + def __init__(self): + self._cached_links = None + + def get_links(self, mlist, msg, msgdata): + """See `IChain`.""" + if self._cached_links is None: + self._cached_links = links = [] + for rule_name, action, chain_name in self._link_descriptions: + # Get the named rule. + rule = config.rules[rule_name] + # Get the chain, if one is defined. + chain = (None if chain_name is None + else config.chains[chain_name]) + links.append(Link(rule, action, chain)) + return iter(self._cached_links) diff --git a/src/mailman/chains/discard.py b/src/mailman/chains/discard.py new file mode 100644 index 000000000..1899e0340 --- /dev/null +++ b/src/mailman/chains/discard.py @@ -0,0 +1,47 @@ +# Copyright (C) 2007-2009 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/>. + +"""The terminal 'discard' chain.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'DiscardChain', + ] + + +import logging + +from mailman.chains.base import TerminalChainBase +from mailman.i18n import _ + + +log = logging.getLogger('mailman.vette') + + + +class DiscardChain(TerminalChainBase): + """Discard a message.""" + + name = 'discard' + description = _('Discard a message and stop processing.') + + def _process(self, mlist, msg, msgdata): + """See `TerminalChainBase`.""" + log.info('DISCARD: %s', msg.get('message-id', 'n/a')) + # Nothing more needs to happen. diff --git a/src/mailman/chains/headers.py b/src/mailman/chains/headers.py new file mode 100644 index 000000000..2f85d78d0 --- /dev/null +++ b/src/mailman/chains/headers.py @@ -0,0 +1,156 @@ +# Copyright (C) 2007-2009 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/>. + +"""The header-matching chain.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'HeaderMatchChain', + ] + + +import re +import logging +import itertools + +from zope.interface import implements + +from mailman.chains.base import Chain, Link +from mailman.config import config +from mailman.i18n import _ +from mailman.interfaces.chain import IChainIterator, LinkAction +from mailman.interfaces.rules import IRule + + +log = logging.getLogger('mailman.vette') + + + +def make_link(entry): + """Create a Link object. + + :param entry: a 2- or 3-tuple describing a link. If a 2-tuple, it is a + header and a pattern, and a default chain of 'hold' will be used. If + a 3-tuple, the third item is the chain name to use. + :return: an ILink. + """ + if len(entry) == 2: + header, pattern = entry + chain_name = 'hold' + elif len(entry) == 3: + header, pattern, chain_name = entry + # We don't assert that the chain exists here because the jump + # chain may not yet have been created. + else: + raise AssertionError('Bad link description: {0}'.format(entry)) + rule = HeaderMatchRule(header, pattern) + chain = config.chains[chain_name] + return Link(rule, LinkAction.jump, chain) + + + +class HeaderMatchRule: + """Header matching rule used by header-match chain.""" + implements(IRule) + + # Sequential rule counter. + _count = 1 + + def __init__(self, header, pattern): + self._header = header + self._pattern = pattern + self.name = 'header-match-{0:02}'.format(HeaderMatchRule._count) + HeaderMatchRule._count += 1 + self.description = '{0}: {1}'.format(header, pattern) + # XXX I think we should do better here, somehow recording that a + # particular header matched a particular pattern, but that gets ugly + # with RFC 2822 headers. It also doesn't match well with the rule + # name concept. For now, we just record the rather useless numeric + # rule name. I suppose we could do the better hit recording in the + # check() method, and set self.record = False. + self.record = True + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + for value in msg.get_all(self._header, []): + if re.search(self._pattern, value, re.IGNORECASE): + return True + return False + + + +class HeaderMatchChain(Chain): + """Default header matching chain. + + This could be extended by header match rules in the database. + """ + + def __init__(self): + super(HeaderMatchChain, self).__init__( + 'header-match', _('The built-in header matching chain')) + # The header match rules are not global, so don't register them. + # These are the only rules that the header match chain can execute. + self._links = [] + # Initialize header check rules with those from the global + # HEADER_MATCHES variable. + for entry in config.header_matches: + self._links.append(make_link(entry)) + # Keep track of how many global header matching rules we've seen. + # This is so the flush() method will only delete those that were added + # via extend() or append_link(). + self._permanent_link_count = len(self._links) + + def extend(self, header, pattern, chain_name='hold'): + """Extend the existing header matches. + + :param header: The case-insensitive header field name. + :param pattern: The pattern to match the header's value again. The + match is not anchored and is done case-insensitively. + :param chain: Option chain to jump to if the pattern matches any of + the named header values. If not given, the 'hold' chain is used. + """ + self._links.append(make_link((header, pattern, chain_name))) + + def flush(self): + """See `IMutableChain`.""" + del self._links[self._permanent_link_count:] + + def get_links(self, mlist, msg, msgdata): + """See `IChain`.""" + list_iterator = HeaderMatchIterator(mlist) + return itertools.chain(iter(self._links), iter(list_iterator)) + + def __iter__(self): + for link in self._links: + yield link + + + +class HeaderMatchIterator: + """An iterator of both the global and list-specific chain links.""" + + implements(IChainIterator) + + def __init__(self, mlist): + self._mlist = mlist + + def __iter__(self): + """See `IChainIterator`.""" + for entry in self._mlist.header_matches: + yield make_link(entry) diff --git a/src/mailman/chains/hold.py b/src/mailman/chains/hold.py new file mode 100644 index 000000000..16238a541 --- /dev/null +++ b/src/mailman/chains/hold.py @@ -0,0 +1,178 @@ +# Copyright (C) 2007-2009 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/>. + +"""The terminal 'hold' chain.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'HoldChain', + ] + + +import logging + +from email.mime.message import MIMEMessage +from email.mime.text import MIMEText +from email.utils import formatdate, make_msgid +from zope.interface import implements + +from mailman import i18n +from mailman.Message import UserNotification +from mailman.Utils import maketext, oneline, wrap, GetCharSet +from mailman.app.moderator import hold_message +from mailman.app.replybot import autorespond_to_sender, can_acknowledge +from mailman.chains.base import TerminalChainBase +from mailman.config import config +from mailman.interfaces.pending import IPendable + + +log = logging.getLogger('mailman.vette') +SEMISPACE = '; ' +_ = i18n._ + + + +class HeldMessagePendable(dict): + implements(IPendable) + PEND_KEY = 'held message' + + + +class HoldChain(TerminalChainBase): + """Hold a message.""" + + name = 'hold' + description = _('Hold a message and stop processing.') + + def _process(self, mlist, msg, msgdata): + """See `TerminalChainBase`.""" + # Start by decorating the message with a header that contains a list + # of all the rules that matched. These metadata could be None or an + # empty list. + rule_hits = msgdata.get('rule_hits') + if rule_hits: + msg['X-Mailman-Rule-Hits'] = SEMISPACE.join(rule_hits) + rule_misses = msgdata.get('rule_misses') + if rule_misses: + msg['X-Mailman-Rule-Misses'] = SEMISPACE.join(rule_misses) + # Hold the message by adding it to the list's request database. + # XXX How to calculate the reason? + request_id = hold_message(mlist, msg, msgdata, None) + # Calculate a confirmation token to send to the author of the + # message. + pendable = HeldMessagePendable(type=HeldMessagePendable.PEND_KEY, + id=request_id) + token = config.db.pendings.add(pendable) + # Get the language to send the response in. If the sender is a + # member, then send it in the member's language, otherwise send it in + # the mailing list's preferred language. + sender = msg.get_sender() + member = mlist.members.get_member(sender) + language = (member.preferred_language + if member else mlist.preferred_language) + # A substitution dictionary for the email templates. + charset = GetCharSet(mlist.preferred_language) + original_subject = msg.get('subject') + if original_subject is None: + original_subject = _('(no subject)') + else: + original_subject = oneline(original_subject, charset) + substitutions = dict( + listname = mlist.fqdn_listname, + subject = original_subject, + sender = sender, + reason = 'XXX', #reason, + confirmurl = '{0}/{1}'.format(mlist.script_url('confirm'), token), + admindb_url = mlist.script_url('admindb'), + ) + # At this point the message is held, but now we have to craft at least + # two responses. The first will go to the original author of the + # message and it will contain the token allowing them to approve or + # discard the message. The second one will go to the moderators of + # the mailing list, if the list is so configured. + # + # Start by possibly sending a response to the message author. There + # are several reasons why we might not go through with this. If the + # message was gated from NNTP, the author may not even know about this + # list, so don't spam them. If the author specifically requested that + # acknowledgments not be sent, or if the message was bulk email, then + # we do not send the response. It's also possible that either the + # mailing list, or the author (if they are a member) have been + # configured to not send such responses. + if (not msgdata.get('fromusenet') and + can_acknowledge(msg) and + mlist.respond_to_post_requests and + autorespond_to_sender(mlist, sender, language)): + # We can respond to the sender with a message indicating their + # posting was held. + subject = _( + 'Your message to $mlist.fqdn_listname awaits moderator approval') + send_language = msgdata.get('lang', language) + text = maketext('postheld.txt', substitutions, + lang=send_language, mlist=mlist) + adminaddr = mlist.bounces_address + nmsg = UserNotification(sender, adminaddr, subject, text, + send_language) + nmsg.send(mlist) + # Now the message for the list moderators. This one should appear to + # come from <list>-owner since we really don't need to do bounce + # processing on it. + if mlist.admin_immed_notify: + # Now let's temporarily set the language context to that which the + # administrators are expecting. + with i18n.using_language(mlist.preferred_language): + language = mlist.preferred_language + charset = GetCharSet(language) + # We need to regenerate or re-translate a few values in the + # substitution dictionary. + #d['reason'] = _(reason) # XXX reason + substitutions['subject'] = original_subject + # craft the admin notification message and deliver it + subject = _( + '$mlist.fqdn_listname post from $sender requires approval') + nmsg = UserNotification(mlist.owner_address, + mlist.owner_address, + subject, lang=language) + nmsg.set_type('multipart/mixed') + text = MIMEText( + maketext('postauth.txt', substitutions, + raw=True, mlist=mlist), + _charset=charset) + dmsg = MIMEText(wrap(_("""\ +If you reply to this message, keeping the Subject: header intact, Mailman will +discard the held message. Do this if the message is spam. If you reply to +this message and include an Approved: header with the list password in it, the +message will be approved for posting to the list. The Approved: header can +also appear in the first line of the body of the reply.""")), + _charset=GetCharSet(language)) + dmsg['Subject'] = 'confirm ' + token + dmsg['Sender'] = mlist.request_address + dmsg['From'] = mlist.request_address + dmsg['Date'] = formatdate(localtime=True) + dmsg['Message-ID'] = make_msgid() + nmsg.attach(text) + nmsg.attach(MIMEMessage(msg)) + nmsg.attach(MIMEMessage(dmsg)) + nmsg.send(mlist, **dict(tomoderators=True)) + # Log the held message + # XXX reason + reason = 'n/a' + log.info('HOLD: %s post from %s held, message-id=%s: %s', + mlist.fqdn_listname, sender, + msg.get('message-id', 'n/a'), reason) diff --git a/src/mailman/chains/reject.py b/src/mailman/chains/reject.py new file mode 100644 index 000000000..3faf563da --- /dev/null +++ b/src/mailman/chains/reject.py @@ -0,0 +1,59 @@ +# Copyright (C) 2007-2009 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/>. + +"""The terminal 'reject' chain.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'RejectChain', + ] + + +import logging + +from mailman.app.bounces import bounce_message +from mailman.chains.base import TerminalChainBase +from mailman.i18n import _ + + +log = logging.getLogger('mailman.vette') +SEMISPACE = '; ' + + + +class RejectChain(TerminalChainBase): + """Reject/bounce a message.""" + + name = 'reject' + description = _('Reject/bounce a message and stop processing.') + + def _process(self, mlist, msg, msgdata): + """See `TerminalChainBase`.""" + # Start by decorating the message with a header that contains a list + # of all the rules that matched. These metadata could be None or an + # empty list. + rule_hits = msgdata.get('rule_hits') + if rule_hits: + msg['X-Mailman-Rule-Hits'] = SEMISPACE.join(rule_hits) + rule_misses = msgdata.get('rule_misses') + if rule_misses: + msg['X-Mailman-Rule-Misses'] = SEMISPACE.join(rule_misses) + # XXX Exception/reason + bounce_message(mlist, msg) + log.info('REJECT: %s', msg.get('message-id', 'n/a')) |
