diff options
| author | Barry Warsaw | 2009-01-25 13:01:41 -0500 |
|---|---|---|
| committer | Barry Warsaw | 2009-01-25 13:01:41 -0500 |
| commit | eefd06f1b88b8ecbb23a9013cd223b72ca85c20d (patch) | |
| tree | 72c947fe16fce0e07e996ee74020b26585d7e846 /src/mailman/rules | |
| parent | 07871212f74498abd56bef3919bf3e029eb8b930 (diff) | |
| download | mailman-eefd06f1b88b8ecbb23a9013cd223b72ca85c20d.tar.gz mailman-eefd06f1b88b8ecbb23a9013cd223b72ca85c20d.tar.zst mailman-eefd06f1b88b8ecbb23a9013cd223b72ca85c20d.zip | |
Diffstat (limited to 'src/mailman/rules')
28 files changed, 2169 insertions, 0 deletions
diff --git a/src/mailman/rules/__init__.py b/src/mailman/rules/__init__.py new file mode 100644 index 000000000..6c7034772 --- /dev/null +++ b/src/mailman/rules/__init__.py @@ -0,0 +1,54 @@ +# 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 built in rule set.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'initialize', + ] + + +import os +import sys + +from mailman.interfaces.rules import IRule + + + +def initialize(): + """Initialize the built-in rules. + + Rules are auto-discovered by searching for IRule implementations in all + importable modules in this subpackage. + """ + # Find all rules found in all modules inside our package. + import mailman.rules + here = os.path.dirname(mailman.rules.__file__) + for filename in os.listdir(here): + basename, extension = os.path.splitext(filename) + if extension <> '.py': + continue + module_name = 'mailman.rules.' + basename + __import__(module_name, fromlist='*') + module = sys.modules[module_name] + for name in module.__all__: + rule = getattr(module, name) + if IRule.implementedBy(rule): + yield rule diff --git a/src/mailman/rules/administrivia.py b/src/mailman/rules/administrivia.py new file mode 100644 index 000000000..8807ef952 --- /dev/null +++ b/src/mailman/rules/administrivia.py @@ -0,0 +1,102 @@ +# 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 administrivia rule.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Administrivia', + ] + + +from email.iterators import typed_subpart_iterator +from zope.interface import implements + +from mailman.config import config +from mailman.i18n import _ +from mailman.interfaces.rules import IRule + + +# The list of email commands we search for in the Subject header and payload. +# We probably should get this information from the actual implemented +# commands. +EMAIL_COMMANDS = { + # keyword: (minimum #args, maximum #args) + 'confirm': (1, 1), + 'help': (0, 0), + 'info': (0, 0), + 'lists': (0, 0), + 'options': (0, 0), + 'password': (2, 2), + 'remove': (0, 0), + 'set': (3, 3), + 'subscribe': (0, 3), + 'unsubscribe': (0, 1), + 'who': (0, 2), + } + + + +class Administrivia: + """The administrivia rule.""" + implements(IRule) + + name = 'administrivia' + description = _('Catch mis-addressed email commands.') + record = True + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + # The list must have the administrivia check enabled. + if not mlist.administrivia: + return False + # First check the Subject text. + lines_to_check = [] + subject = str(msg.get('subject', '')) + if subject <> '': + lines_to_check.append(subject) + # Search only the first text/plain subpart of the message. There's + # really no good way to find email commands in any other content type. + for part in typed_subpart_iterator(msg, 'text', 'plain'): + payload = part.get_payload(decode=True) + lines = payload.splitlines() + # Count lines without using enumerate() because blank lines in the + # payload don't count against the maximum examined. + lineno = 0 + for line in lines: + line = line.strip() + if line == '': + continue + lineno += 1 + if lineno > config.mailman.email_commands_max_lines: + break + lines_to_check.append(line) + # Only look at the first text/plain part. + break + # For each line we're checking, split the line into words. Then see + # if it looks like a command with the min-to-max number of arguments. + for line in lines_to_check: + words = [word.lower() for word in line.split()] + if words[0] not in EMAIL_COMMANDS: + # This is not an administrivia command. + continue + minargs, maxargs = EMAIL_COMMANDS[words[0]] + if minargs <= len(words) - 1 <= maxargs: + return True + return False diff --git a/src/mailman/rules/any.py b/src/mailman/rules/any.py new file mode 100644 index 000000000..c337df7f1 --- /dev/null +++ b/src/mailman/rules/any.py @@ -0,0 +1,45 @@ +# 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/>. + +"""Check if any previous rules have matched.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Any', + ] + + +from zope.interface import implements + +from mailman.i18n import _ +from mailman.interfaces.rules import IRule + + + +class Any: + """Look for any previous rule match.""" + implements(IRule) + + name = 'any' + description = _('Look for any previous rule hit.') + record = False + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + return len(msgdata.get('rules', [])) > 0 diff --git a/src/mailman/rules/approved.py b/src/mailman/rules/approved.py new file mode 100644 index 000000000..f81c1ad04 --- /dev/null +++ b/src/mailman/rules/approved.py @@ -0,0 +1,121 @@ +# 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/>. + +"""Look for moderator pre-approval.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Approved', + ] + + +import re +from email.iterators import typed_subpart_iterator +from zope.interface import implements + +from mailman.i18n import _ +from mailman.interfaces.rules import IRule + + +EMPTYSTRING = '' + + + +class Approved: + """Look for moderator pre-approval.""" + implements(IRule) + + name = 'approved' + description = _('The message has a matching Approve or Approved header.') + record = True + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + # See if the message has an Approved or Approve header with a valid + # moderator password. Also look at the first non-whitespace line in + # the file to see if it looks like an Approved header. + missing = object() + password = msg.get('approved', msg.get('approve', missing)) + if password is missing: + # Find the first text/plain part in the message + part = None + stripped = False + for part in typed_subpart_iterator(msg, 'text', 'plain'): + break + payload = part.get_payload(decode=True) + if payload is not None: + lines = payload.splitlines(True) + for lineno, line in enumerate(lines): + if line.strip() <> '': + break + if ':' in line: + header, value = line.split(':', 1) + if header.lower() in ('approved', 'approve'): + password = value.strip() + # Now strip the first line from the payload so the + # password doesn't leak. + del lines[lineno] + reset_payload(part, EMPTYSTRING.join(lines)) + stripped = True + if stripped: + # Now try all the text parts in case it's + # multipart/alternative with the approved line in HTML or + # other text part. We make a pattern from the Approved line + # and delete it from all text/* parts in which we find it. It + # would be better to just iterate forward, but email + # compatability for pre Python 2.2 returns a list, not a true + # iterator. + # + # This will process all the multipart/alternative parts in the + # message as well as all other text parts. We shouldn't find + # the pattern outside the multipart/alternative parts, but if + # we do, it is probably best to delete it anyway as it does + # contain the password. + # + # Make a pattern to delete. We can't just delete a line + # because line of HTML or other fancy text may include + # additional message text. This pattern works with HTML. It + # may not work with rtf or whatever else is possible. + pattern = header + ':(\s| )*' + re.escape(password) + for part in typed_subpart_iterator(msg, 'text'): + payload = part.get_payload(decode=True) + if payload is not None: + if re.search(pattern, payload): + reset_payload(part, re.sub(pattern, '', payload)) + else: + del msg['approved'] + del msg['approve'] + return password is not missing and password == mlist.moderator_password + + + +def reset_payload(part, payload): + # Set decoded payload maintaining content-type, charset, format and delsp. + charset = part.get_content_charset() or 'us-ascii' + content_type = part.get_content_type() + format = part.get_param('format') + delsp = part.get_param('delsp') + del part['content-transfer-encoding'] + del part['content-type'] + part.set_payload(payload, charset) + part.set_type(content_type) + if format: + part.set_param('Format', format) + if delsp: + part.set_param('DelSp', delsp) diff --git a/src/mailman/rules/docs/administrivia.txt b/src/mailman/rules/docs/administrivia.txt new file mode 100644 index 000000000..dba882775 --- /dev/null +++ b/src/mailman/rules/docs/administrivia.txt @@ -0,0 +1,99 @@ +Administrivia +============= + +The 'administrivia' rule matches when the message contains some common email +commands in the Subject header or first few lines of the payload. This is +used to catch messages posted to the list which should have been sent to the +-request robot address. + + >>> mlist = config.db.list_manager.create(u'_xtest@example.com') + >>> mlist.administrivia = True + >>> rule = config.rules['administrivia'] + >>> print rule.name + administrivia + +For example, if the Subject header contains the word 'unsubscribe', the rule +matches. + + >>> msg_1 = message_from_string("""\ + ... From: aperson@example.com + ... Subject: unsubscribe + ... + ... """) + >>> rule.check(mlist, msg_1, {}) + True + +Similarly, if the body of the message contains the word 'subscribe' in the +first few lines of text, the rule matches. + + >>> msg_2 = message_from_string("""\ + ... From: aperson@example.com + ... Subject: I wish to join your list + ... + ... subscribe + ... """) + >>> rule.check(mlist, msg_2, {}) + True + +In both cases, administrivia checking can be disabled. + + >>> mlist.administrivia = False + >>> rule.check(mlist, msg_1, {}) + False + >>> rule.check(mlist, msg_2, {}) + False + +To make the administrivia heuristics a little more robust, the rule actually +looks for a minimum and maximum number of arguments, so that it really does +seem like a mis-addressed email command. In this case, the 'confirm' command +requires at least one argument. We don't give that here so the rule will not +match. + + >>> mlist.administrivia = True + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... Subject: confirm + ... + ... """) + >>> rule.check(mlist, msg, {}) + False + +But a real 'confirm' message will match. + + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... Subject: confirm 12345 + ... + ... """) + >>> rule.check(mlist, msg, {}) + True + +We don't show all the other possible email commands, but you get the idea. + + +Non-administrivia +----------------- + +Of course, messages that don't contain administrivia, don't match the rule. + + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... Subject: examine + ... + ... persuade + ... """) + >>> rule.check(mlist, msg, {}) + False + +Also, only text/plain parts are checked for administrivia, so any email +commands in other content type subparts are ignored. + + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... Subject: some administrivia + ... Content-Type: text/x-special + ... + ... subscribe + ... """) + >>> rule.check(mlist, msg, {}) + False diff --git a/src/mailman/rules/docs/approve.txt b/src/mailman/rules/docs/approve.txt new file mode 100644 index 000000000..dda531a4c --- /dev/null +++ b/src/mailman/rules/docs/approve.txt @@ -0,0 +1,472 @@ +Pre-approved postings +===================== + +Messages can contain a pre-approval, which is used to bypass the message +approval queue. This has several use cases: + +- A list administrator can send an emergency message to the mailing list from + an unregistered address, say if they are away from their normal email. + +- An automated script can be programmed to send a message to an otherwise + moderated list. + +In order to support this, a mailing list can be given a 'moderator password' +which is shared among all the administrators. + + >>> mlist = config.db.list_manager.create(u'_xtest@example.com') + >>> mlist.moderator_password = u'abcxyz' + +The 'approved' rule determines whether the message contains the proper +approval or not. + + >>> rule = config.rules['approved'] + >>> print rule.name + approved + + +No approval +----------- + +If the message has no Approve or Approved header, then the rule does not +match. + + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... + ... An important message. + ... """) + >>> rule.check(mlist, msg, {}) + False + +If the message has an Approve or Approved header with a value that does not +match the moderator password, then the rule does not match. However, the +header is still removed. + + >>> msg['Approve'] = u'12345' + >>> rule.check(mlist, msg, {}) + False + >>> print msg['approve'] + None + + >>> del msg['approve'] + >>> msg['Approved'] = u'12345' + >>> rule.check(mlist, msg, {}) + False + >>> print msg['approved'] + None + + >>> del msg['approved'] + + +Using an approval header +------------------------ + +If the moderator password is given in an Approve header, then the rule +matches, and the Approve header is stripped. + + >>> msg['Approve'] = u'abcxyz' + >>> rule.check(mlist, msg, {}) + True + >>> print msg['approve'] + None + +Similarly, for the Approved header. + + >>> msg['Approved'] = u'abcxyz' + >>> rule.check(mlist, msg, {}) + True + >>> print msg['approved'] + None + + +Using a pseudo-header +--------------------- + +Different mail user agents have varying degrees to which they support custom +headers like Approve and Approved. For this reason, Mailman also supports +using a 'pseudo-header', which is really just the first non-whitespace line in +the payload of the message. If this pseudo-header looks like a matching +Approve or Approved header, the message is similarly allowed to pass. + + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... + ... Approve: abcxyz + ... An important message. + ... """) + >>> rule.check(mlist, msg, {}) + True + +The pseudo-header is removed. + + >>> print msg.as_string() + From: aperson@example.com + Content-Transfer-Encoding: 7bit + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + <BLANKLINE> + An important message. + <BLANKLINE> + +Similarly for the Approved header. + + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... + ... Approved: abcxyz + ... An important message. + ... """) + >>> rule.check(mlist, msg, {}) + True + + >>> print msg.as_string() + From: aperson@example.com + Content-Transfer-Encoding: 7bit + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + <BLANKLINE> + An important message. + <BLANKLINE> + +As before, a mismatch in the pseudo-header does not approve the message, but +the pseudo-header line is still removed. + + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... + ... Approve: 123456 + ... An important message. + ... """) + >>> rule.check(mlist, msg, {}) + False + + >>> print msg.as_string() + From: aperson@example.com + Content-Transfer-Encoding: 7bit + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + <BLANKLINE> + An important message. + <BLANKLINE> + +Similarly for the Approved header. + + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... + ... Approved: 123456 + ... An important message. + ... """) + >>> rule.check(mlist, msg, {}) + False + + >>> print msg.as_string() + From: aperson@example.com + Content-Transfer-Encoding: 7bit + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + <BLANKLINE> + An important message. + <BLANKLINE> + + +MIME multipart support +---------------------- + +Mailman searches for the pseudo-header as the first non-whitespace line in the +first text/plain message part of the message. This allows the feature to be +used with MIME documents. + + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... MIME-Version: 1.0 + ... Content-Type: multipart/mixed; boundary="AAA" + ... + ... --AAA + ... Content-Type: application/x-ignore + ... + ... Approve: 123456 + ... The above line will be ignored. + ... + ... --AAA + ... Content-Type: text/plain + ... + ... Approve: abcxyz + ... An important message. + ... --AAA-- + ... """) + >>> rule.check(mlist, msg, {}) + True + +Like before, the pseudo-header is removed, but only from the text parts. + + >>> print msg.as_string() + From: aperson@example.com + MIME-Version: 1.0 + Content-Type: multipart/mixed; boundary="AAA" + <BLANKLINE> + --AAA + Content-Type: application/x-ignore + <BLANKLINE> + Approve: 123456 + The above line will be ignored. + <BLANKLINE> + --AAA + Content-Transfer-Encoding: 7bit + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + <BLANKLINE> + An important message. + --AAA-- + <BLANKLINE> + +The same goes for the Approved message. + + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... MIME-Version: 1.0 + ... Content-Type: multipart/mixed; boundary="AAA" + ... + ... --AAA + ... Content-Type: application/x-ignore + ... + ... Approved: 123456 + ... The above line will be ignored. + ... + ... --AAA + ... Content-Type: text/plain + ... + ... Approved: abcxyz + ... An important message. + ... --AAA-- + ... """) + >>> rule.check(mlist, msg, {}) + True + +And the header is removed. + + >>> print msg.as_string() + From: aperson@example.com + MIME-Version: 1.0 + Content-Type: multipart/mixed; boundary="AAA" + <BLANKLINE> + --AAA + Content-Type: application/x-ignore + <BLANKLINE> + Approved: 123456 + The above line will be ignored. + <BLANKLINE> + --AAA + Content-Transfer-Encoding: 7bit + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + <BLANKLINE> + An important message. + --AAA-- + <BLANKLINE> + +Here, the correct password is in the non-text/plain part, so it is ignored. + + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... MIME-Version: 1.0 + ... Content-Type: multipart/mixed; boundary="AAA" + ... + ... --AAA + ... Content-Type: application/x-ignore + ... + ... Approve: abcxyz + ... The above line will be ignored. + ... + ... --AAA + ... Content-Type: text/plain + ... + ... Approve: 123456 + ... An important message. + ... --AAA-- + ... """) + >>> rule.check(mlist, msg, {}) + False + +And yet the pseudo-header is still stripped. + + >>> print msg.as_string() + From: aperson@example.com + MIME-Version: 1.0 + Content-Type: multipart/mixed; boundary="AAA" + <BLANKLINE> + --AAA + Content-Type: application/x-ignore + <BLANKLINE> + Approve: abcxyz + The above line will be ignored. + <BLANKLINE> + --AAA + Content-Transfer-Encoding: 7bit + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + <BLANKLINE> + An important message. + --AAA-- + +As before, the same goes for the Approved header. + + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... MIME-Version: 1.0 + ... Content-Type: multipart/mixed; boundary="AAA" + ... + ... --AAA + ... Content-Type: application/x-ignore + ... + ... Approved: abcxyz + ... The above line will be ignored. + ... + ... --AAA + ... Content-Type: text/plain + ... + ... Approved: 123456 + ... An important message. + ... --AAA-- + ... """) + >>> rule.check(mlist, msg, {}) + False + +And the pseudo-header is removed. + + >>> print msg.as_string() + From: aperson@example.com + MIME-Version: 1.0 + Content-Type: multipart/mixed; boundary="AAA" + <BLANKLINE> + --AAA + Content-Type: application/x-ignore + <BLANKLINE> + Approved: abcxyz + The above line will be ignored. + <BLANKLINE> + --AAA + Content-Transfer-Encoding: 7bit + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + <BLANKLINE> + An important message. + --AAA-- + + +Stripping text/html parts +------------------------- + +Because some mail readers will include both a text/plain part and a text/html +alternative, the 'approved' rule has to search the alternatives and strip +anything that looks like an Approve or Approved headers. + + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... MIME-Version: 1.0 + ... Content-Type: multipart/mixed; boundary="AAA" + ... + ... --AAA + ... Content-Type: text/html + ... + ... <html> + ... <head></head> + ... <body> + ... <b>Approved: abcxyz</b> + ... <p>The above line will be ignored. + ... </body> + ... </html> + ... + ... --AAA + ... Content-Type: text/plain + ... + ... Approved: abcxyz + ... An important message. + ... --AAA-- + ... """) + >>> rule.check(mlist, msg, {}) + True + +And the header-like text in the text/html part was stripped. + + >>> print msg.as_string() + From: aperson@example.com + MIME-Version: 1.0 + Content-Type: multipart/mixed; boundary="AAA" + <BLANKLINE> + --AAA + Content-Transfer-Encoding: 7bit + MIME-Version: 1.0 + Content-Type: text/html; charset="us-ascii" + <BLANKLINE> + <html> + <head></head> + <body> + <b></b> + <p>The above line will be ignored. + </body> + </html> + <BLANKLINE> + --AAA + Content-Transfer-Encoding: 7bit + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + <BLANKLINE> + An important message. + --AAA-- + <BLANKLINE> + +This is true even if the rule does not match. + + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... MIME-Version: 1.0 + ... Content-Type: multipart/mixed; boundary="AAA" + ... + ... --AAA + ... Content-Type: text/html + ... + ... <html> + ... <head></head> + ... <body> + ... <b>Approve: 123456</b> + ... <p>The above line will be ignored. + ... </body> + ... </html> + ... + ... --AAA + ... Content-Type: text/plain + ... + ... Approve: 123456 + ... An important message. + ... --AAA-- + ... """) + >>> rule.check(mlist, msg, {}) + False + + >>> print msg.as_string() + From: aperson@example.com + MIME-Version: 1.0 + Content-Type: multipart/mixed; boundary="AAA" + <BLANKLINE> + --AAA + Content-Transfer-Encoding: 7bit + MIME-Version: 1.0 + Content-Type: text/html; charset="us-ascii" + <BLANKLINE> + <html> + <head></head> + <body> + <b></b> + <p>The above line will be ignored. + </body> + </html> + <BLANKLINE> + --AAA + Content-Transfer-Encoding: 7bit + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + <BLANKLINE> + An important message. + --AAA-- + <BLANKLINE> diff --git a/src/mailman/rules/docs/emergency.txt b/src/mailman/rules/docs/emergency.txt new file mode 100644 index 000000000..9d80fdb40 --- /dev/null +++ b/src/mailman/rules/docs/emergency.txt @@ -0,0 +1,72 @@ +Emergency +========= + +When the mailing list has its emergency flag set, all messages posted to the +list are held for moderator approval. + + >>> from mailman.app.lifecycle import create_list + >>> mlist = create_list(u'_xtest@example.com') + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... To: _xtest@example.com + ... Subject: My first post + ... Message-ID: <first> + ... + ... An important message. + ... """) + +The emergency rule is matched as part of the built-in chain. The emergency +rule matches if the flag is set on the mailing list. + + >>> from mailman.core.chains import process + >>> mlist.emergency = True + >>> process(mlist, msg, {}, 'built-in') + +There are two messages in the virgin queue. The one addressed to the original +sender will contain a token we can use to grab the held message out of the +pending requests. + + >>> virginq = config.switchboards['virgin'] + + >>> def get_held_message(): + ... import re + ... qfiles = [] + ... for filebase in virginq.files: + ... qmsg, qdata = virginq.dequeue(filebase) + ... virginq.finish(filebase) + ... qfiles.append(qmsg) + ... from operator import itemgetter + ... qfiles.sort(key=itemgetter('to')) + ... cookie = None + ... for line in qfiles[1].get_payload().splitlines(): + ... mo = re.search('confirm/[^/]+/(?P<cookie>.*)$', line) + ... if mo: + ... cookie = mo.group('cookie') + ... break + ... assert cookie is not None, 'No confirmation token found' + ... data = config.db.pendings.confirm(cookie) + ... requestdb = config.db.requests.get_list_requests(mlist) + ... rkey, rdata = requestdb.get_request(data['id']) + ... return config.db.message_store.get_message_by_id( + ... rdata['_mod_message_id']) + + >>> msg = get_held_message() + >>> print msg.as_string() + From: aperson@example.com + To: _xtest@example.com + Subject: My first post + Message-ID: <first> + X-Mailman-Rule-Hits: emergency + X-Mailman-Rule-Misses: approved + X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW + <BLANKLINE> + An important message. + <BLANKLINE> + +However, if the message metadata has a 'moderator_approved' key set, then even +if the mailing list has its emergency flag set, the message still goes through +to the membership. + + >>> process(mlist, msg, dict(moderator_approved=True), 'built-in') + >>> len(virginq.files) + 0 diff --git a/src/mailman/rules/docs/header-matching.txt b/src/mailman/rules/docs/header-matching.txt new file mode 100644 index 000000000..417000d67 --- /dev/null +++ b/src/mailman/rules/docs/header-matching.txt @@ -0,0 +1,144 @@ +Header matching +=============== + +Mailman can do pattern based header matching during its normal rule +processing. There is a set of site-wide default header matches specified in +the configuration file under the [spam.headers] section. + + >>> from mailman.app.lifecycle import create_list + >>> mlist = create_list(u'_xtest@example.com') + +Because the default [spam.headers] section is empty, we'll just extend the +current header matching chain with a pattern that matches 4 or more stars, +discarding the message if it hits. + + >>> chain = config.chains['header-match'] + >>> chain.extend('x-spam-score', '[*]{4,}', 'discard') + +First, if the message has no X-Spam-Score header, the message passes through +the chain untouched (i.e. no disposition). + + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... To: _xtest@example.com + ... Subject: Not spam + ... Message-ID: <one> + ... + ... This is a message. + ... """) + + >>> from mailman.core.chains import process + +Pass through is seen as nothing being in the log file after processing. + + # XXX This checks the vette log file because there is no other evidence + # that this chain has done anything. + >>> import os + >>> fp = open(os.path.join(config.LOG_DIR, 'vette')) + >>> fp.seek(0, 2) + >>> file_pos = fp.tell() + >>> process(mlist, msg, {}, 'header-match') + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: + <BLANKLINE> + +Now, if the header exists but does not match, then it also passes through +untouched. + + >>> msg['X-Spam-Score'] = '***' + >>> del msg['subject'] + >>> msg['Subject'] = 'This is almost spam' + >>> del msg['message-id'] + >>> msg['Message-ID'] = '<two>' + >>> file_pos = fp.tell() + >>> process(mlist, msg, {}, 'header-match') + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: + <BLANKLINE> + +But now if the header matches, then the message gets discarded. + + >>> del msg['x-spam-score'] + >>> msg['X-Spam-Score'] = '****' + >>> del msg['subject'] + >>> msg['Subject'] = 'This is spam, but barely' + >>> del msg['message-id'] + >>> msg['Message-ID'] = '<three>' + >>> file_pos = fp.tell() + >>> process(mlist, msg, {}, 'header-match') + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: ... DISCARD: <three> + <BLANKLINE> + +For kicks, let's show a message that's really spammy. + + >>> del msg['x-spam-score'] + >>> msg['X-Spam-Score'] = '**********' + >>> del msg['subject'] + >>> msg['Subject'] = 'This is really spammy' + >>> del msg['message-id'] + >>> msg['Message-ID'] = '<four>' + >>> file_pos = fp.tell() + >>> process(mlist, msg, {}, 'header-match') + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: ... DISCARD: <four> + <BLANKLINE> + +Flush out the extended header matching rules. + + >>> chain.flush() + + +List-specific header matching +----------------------------- + +Each mailing list can also be configured with a set of header matching regular +expression rules. These are used to impose list-specific header filtering +with the same semantics as the global [spam.headers] section. + +The list administrator wants to match not on four stars, but on three plus +signs, but only for the current mailing list. + + >>> mlist.header_matches = [('x-spam-score', '[+]{3,}', 'discard')] + +A message with a spam score of two pluses does not match. + + >>> del msg['x-spam-score'] + >>> msg['X-Spam-Score'] = '++' + >>> del msg['message-id'] + >>> msg['Message-ID'] = '<five>' + >>> file_pos = fp.tell() + >>> process(mlist, msg, {}, 'header-match') + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: + +A message with a spam score of three pluses does match. + + >>> del msg['x-spam-score'] + >>> msg['X-Spam-Score'] = '+++' + >>> del msg['message-id'] + >>> msg['Message-ID'] = '<six>' + >>> file_pos = fp.tell() + >>> process(mlist, msg, {}, 'header-match') + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: ... DISCARD: <six> + <BLANKLINE> + +As does a message with a spam score of four pluses. + + >>> del msg['x-spam-score'] + >>> msg['X-Spam-Score'] = '+++' + >>> del msg['message-id'] + >>> msg['Message-ID'] = '<seven>' + >>> file_pos = fp.tell() + >>> process(mlist, msg, {}, 'header-match') + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: ... DISCARD: <seven> + <BLANKLINE> diff --git a/src/mailman/rules/docs/implicit-dest.txt b/src/mailman/rules/docs/implicit-dest.txt new file mode 100644 index 000000000..e5c340dcd --- /dev/null +++ b/src/mailman/rules/docs/implicit-dest.txt @@ -0,0 +1,75 @@ +Implicit destination +==================== + +The 'implicit-dest' rule matches when the mailing list's posting address is +not explicitly mentioned in the set of message recipients. + + >>> mlist = config.db.list_manager.create(u'_xtest@example.com') + >>> rule = config.rules['implicit-dest'] + >>> print rule.name + implicit-dest + +This rule matches messages that have implicit destination, meaning that the +mailing list's posting address isn't included in the explicit recipients. + + >>> mlist.require_explicit_destination = True + >>> mlist.acceptable_aliases = u'' + >>> msg = message_from_string("""\ + ... From: aperson@example.org + ... Subject: An implicit message + ... + ... """) + >>> rule.check(mlist, msg, {}) + True + +You can disable implicit destination checks for the mailing list. + + >>> mlist.require_explicit_destination = False + >>> rule.check(mlist, msg, {}) + False + +Even with some recipients, if the posting address is not included, the rule +will match. + + >>> mlist.require_explicit_destination = True + >>> msg['To'] = 'myfriend@example.com' + >>> rule.check(mlist, msg, {}) + True + +Add the posting address as a recipient and the rule will no longer match. + + >>> msg['Cc'] = '_xtest@example.com' + >>> rule.check(mlist, msg, {}) + False + +Alternatively, if one of the acceptable aliases is in the recipients list, +then the rule will not match. + + >>> del msg['cc'] + >>> rule.check(mlist, msg, {}) + True + >>> mlist.acceptable_aliases = u'myfriend@example.com' + >>> rule.check(mlist, msg, {}) + False + +A message gated from NNTP will obviously have an implicit destination. Such +gated messages will not be held for implicit destination because it's assumed +that Mailman pulled it from the appropriate news group. + + >>> rule.check(mlist, msg, dict(fromusenet=True)) + False + + +Alias patterns +-------------- + +It's also possible to specify an alias pattern, i.e. a regular expression to +match against the recipients. For example, we can say that if there is a +recipient in the example.net domain, then the rule does not match. + + >>> mlist.acceptable_aliases = u'^.*@example.net' + >>> rule.check(mlist, msg, {}) + True + >>> msg['To'] = 'you@example.net' + >>> rule.check(mlist, msg, {}) + False diff --git a/src/mailman/rules/docs/loop.txt b/src/mailman/rules/docs/loop.txt new file mode 100644 index 000000000..61612cd75 --- /dev/null +++ b/src/mailman/rules/docs/loop.txt @@ -0,0 +1,48 @@ +Posting loops +============= + +To avoid a posting loop, Mailman has a rule to check for the existence of an +X-BeenThere header with the value of the list's posting address. + + >>> mlist = config.db.list_manager.create(u'_xtest@example.com') + >>> rule = config.rules['loop'] + >>> print rule.name + loop + +The header could be missing, in which case the rule does not match. + + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... + ... An important message. + ... """) + >>> rule.check(mlist, msg, {}) + False + +The header could be present, but not match the list's posting address. + + >>> msg['X-BeenThere'] = u'not-this-list@example.com' + >>> rule.check(mlist, msg, {}) + False + +If the header is present and does match the posting address, the rule +matches. + + >>> del msg['x-beenthere'] + >>> msg['X-BeenThere'] = mlist.posting_address + >>> rule.check(mlist, msg, {}) + True + +Even if there are multiple X-BeenThere headers, as long as one with the +posting address exists, the rule matches. + + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... X-BeenThere: not-this-list@example.com + ... X-BeenThere: _xtest@example.com + ... X-BeenThere: foo@example.com + ... + ... An important message. + ... """) + >>> rule.check(mlist, msg, {}) + True diff --git a/src/mailman/rules/docs/max-size.txt b/src/mailman/rules/docs/max-size.txt new file mode 100644 index 000000000..117691e59 --- /dev/null +++ b/src/mailman/rules/docs/max-size.txt @@ -0,0 +1,39 @@ +Message size +============ + +The 'message-size' rule matches when the posted message is bigger than a +specified maximum. Generally this is used to prevent huge attachments from +getting posted to the list. This value is calculated in terms of KB (1024 +bytes). + + >>> mlist = config.db.list_manager.create(u'_xtest@example.com') + >>> rule = config.rules['max-size'] + >>> print rule.name + max-size + +For example, setting the maximum message size to 1 means that any message +bigger than that will match the rule. + + >>> mlist.max_message_size = 1 # 1024 bytes + >>> one_line = u'x' * 79 + >>> big_body = u'\n'.join([one_line] * 15) + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... To: _xtest@example.com + ... + ... """ + big_body) + >>> rule.check(mlist, msg, {}) + True + +Setting the maximum message size to zero means no size check is performed. + + >>> mlist.max_message_size = 0 + >>> rule.check(mlist, msg, {}) + False + +Of course, if the maximum size is larger than the message's size, then it's +still okay. + + >>> mlist.max_message_size = msg.original_size/1024.0 + 1 + >>> rule.check(mlist, msg, {}) + False diff --git a/src/mailman/rules/docs/moderation.txt b/src/mailman/rules/docs/moderation.txt new file mode 100644 index 000000000..65be0d7da --- /dev/null +++ b/src/mailman/rules/docs/moderation.txt @@ -0,0 +1,69 @@ +Member moderation +================= + +Each user has a moderation flag. When set, and the list is set to moderate +postings, then only members with a cleared moderation flag will be able to +email the list without having those messages be held for approval. The +'moderation' rule determines whether the message should be moderated or not. + + >>> mlist = config.db.list_manager.create(u'_xtest@example.com') + >>> rule = config.rules['moderation'] + >>> print rule.name + moderation + +In the simplest case, the sender is not a member of the mailing list, so the +moderation rule can't match. + + >>> msg = message_from_string("""\ + ... From: aperson@example.org + ... To: _xtest@example.com + ... Subject: A posted message + ... + ... """) + >>> rule.check(mlist, msg, {}) + False + +Let's add the message author as a non-moderated member. + + >>> user = config.db.user_manager.create_user( + ... u'aperson@example.org', u'Anne Person') + >>> address = list(user.addresses)[0] + >>> from mailman.interfaces.member import MemberRole + >>> member = address.subscribe(mlist, MemberRole.member) + >>> member.is_moderated + False + >>> rule.check(mlist, msg, {}) + False + +Once the member's moderation flag is set though, the rule matches. + + >>> member.is_moderated = True + >>> rule.check(mlist, msg, {}) + True + + +Non-members +----------- + +There is another, related rule for matching non-members, which simply matches +if the sender is /not/ a member of the mailing list. + + >>> rule = config.rules['non-member'] + >>> print rule.name + non-member + +If the sender is a member of this mailing list, the rule does not match. + + >>> rule.check(mlist, msg, {}) + False + +But if the sender is not a member of this mailing list, the rule matches. + + >>> msg = message_from_string("""\ + ... From: bperson@example.org + ... To: _xtest@example.com + ... Subject: A posted message + ... + ... """) + >>> rule.check(mlist, msg, {}) + True diff --git a/src/mailman/rules/docs/news-moderation.txt b/src/mailman/rules/docs/news-moderation.txt new file mode 100644 index 000000000..4c095cc81 --- /dev/null +++ b/src/mailman/rules/docs/news-moderation.txt @@ -0,0 +1,36 @@ +Newsgroup moderation +==================== + +The 'news-moderation' rule matches all messages posted to mailing lists that +gateway to a moderated newsgroup. The reason for this is that such messages +must get forwarded on to the newsgroup moderator. From there it will get +posted to the newsgroup, and from there, gated to the mailing list. It's a +circuitous route, but it works nonetheless by holding all messages posted +directly to the mailing list. + + >>> mlist = config.db.list_manager.create(u'_xtest@example.com') + >>> rule = config.rules['news-moderation'] + >>> print rule.name + news-moderation + +Set the list configuraiton variable to enable newsgroup moderation. + + >>> from mailman.interfaces import NewsModeration + >>> mlist.news_moderation = NewsModeration.moderated + +And now all messages will match the rule. + + >>> msg = message_from_string("""\ + ... From: aperson@example.org + ... Subject: An announcment + ... + ... Great things are happening. + ... """) + >>> rule.check(mlist, msg, {}) + True + +When moderation is turned off, the rule does not match. + + >>> mlist.news_moderation = NewsModeration.none + >>> rule.check(mlist, msg, {}) + False diff --git a/src/mailman/rules/docs/no-subject.txt b/src/mailman/rules/docs/no-subject.txt new file mode 100644 index 000000000..576111cd7 --- /dev/null +++ b/src/mailman/rules/docs/no-subject.txt @@ -0,0 +1,33 @@ +No Subject header +================= + +This rule matches if the message has no Subject header, or if the header is +the empty string when stripped. + + >>> mlist = config.db.list_manager.create(u'_xtest@example.com') + >>> rule = config.rules['no-subject'] + >>> print rule.name + no-subject + +A message with a non-empty subject does not match the rule. + + >>> msg = message_from_string("""\ + ... From: aperson@example.org + ... To: _xtest@example.com + ... Subject: A posted message + ... + ... """) + >>> rule.check(mlist, msg, {}) + False + +Delete the Subject header and the rule matches. + + >>> del msg['subject'] + >>> rule.check(mlist, msg, {}) + True + +Even a Subject header with only whitespace still matches the rule. + + >>> msg['Subject'] = u' ' + >>> rule.check(mlist, msg, {}) + True diff --git a/src/mailman/rules/docs/recipients.txt b/src/mailman/rules/docs/recipients.txt new file mode 100644 index 000000000..3cd49d501 --- /dev/null +++ b/src/mailman/rules/docs/recipients.txt @@ -0,0 +1,40 @@ +Maximum number of recipients +============================ + +The 'max-recipients' rule matches when there are more than the maximum allowed +number of explicit recipients addressed by the message. + + >>> mlist = config.db.list_manager.create(u'_xtest@example.com') + >>> rule = config.rules['max-recipients'] + >>> print rule.name + max-recipients + +In this case, we'll create a message with 5 recipients. These include all +addresses in the To and CC headers. + + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... To: _xtest@example.com, bperson@example.com + ... Cc: cperson@example.com + ... Cc: dperson@example.com (Dan Person) + ... To: Elly Q. Person <eperson@example.com> + ... + ... Hey folks! + ... """) + +For backward compatibility, the message must have fewer than the maximum +number of explicit recipients. + + >>> mlist.max_num_recipients = 5 + >>> rule.check(mlist, msg, {}) + True + + >>> mlist.max_num_recipients = 6 + >>> rule.check(mlist, msg, {}) + False + +Zero means any number of recipients are allowed. + + >>> mlist.max_num_recipients = 0 + >>> rule.check(mlist, msg, {}) + False diff --git a/src/mailman/rules/docs/rules.txt b/src/mailman/rules/docs/rules.txt new file mode 100644 index 000000000..095d11466 --- /dev/null +++ b/src/mailman/rules/docs/rules.txt @@ -0,0 +1,69 @@ +Rules +===== + +Rules are applied to each message as part of a rule chain. Individual rules +simply return a boolean specifying whether the rule matches or not. Chain +links determine what happens when a rule matches. + + +All rules +--------- + +Rules are maintained in the configuration object as a dictionary mapping rule +names to rule objects. + + >>> from zope.interface.verify import verifyObject + >>> from mailman.interfaces.rules import IRule + >>> for rule_name in sorted(config.rules): + ... rule = config.rules[rule_name] + ... print rule_name, verifyObject(IRule, rule) + administrivia True + any True + approved True + emergency True + implicit-dest True + loop True + max-recipients True + max-size True + moderation True + news-moderation True + no-subject True + non-member True + suspicious-header True + truth True + +You can get a rule by name. + + >>> rule = config.rules['emergency'] + >>> verifyObject(IRule, rule) + True + + +Rule checks +----------- + +Individual rules can be checked to see if they match, by running the rule's +`check()` method. This returns a boolean indicating whether the rule was +matched or not. + + >>> mlist = config.db.list_manager.create(u'_xtest@example.com') + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... + ... An important message. + ... """) + +For example, the emergency rule just checks to see if the emergency flag is +set on the mailing list, and the message has not been pre-approved by the list +administrator. + + >>> print rule.name + emergency + >>> mlist.emergency = False + >>> rule.check(mlist, msg, {}) + False + >>> mlist.emergency = True + >>> rule.check(mlist, msg, {}) + True + >>> rule.check(mlist, msg, dict(moderator_approved=True)) + False diff --git a/src/mailman/rules/docs/suspicious.txt b/src/mailman/rules/docs/suspicious.txt new file mode 100644 index 000000000..190a34aca --- /dev/null +++ b/src/mailman/rules/docs/suspicious.txt @@ -0,0 +1,35 @@ +Suspicious headers +================== + +Suspicious headers are a way for Mailman to hold messages that match a +particular regular expression. This mostly historical feature is fairly +confusing to users, and the list attribute that controls this is misnamed. + + >>> mlist = config.db.list_manager.create(u'_xtest@example.com') + >>> rule = config.rules['suspicious-header'] + >>> print rule.name + suspicious-header + +Set the so-called suspicious header configuration variable. + + >>> mlist.bounce_matching_headers = u'From: .*person@(blah.)?example.com' + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... To: _xtest@example.com + ... Subject: An implicit message + ... + ... """) + >>> rule.check(mlist, msg, {}) + True + +But if the header doesn't match the regular expression, the rule won't match. +This one comes from a .org address. + + >>> msg = message_from_string("""\ + ... From: aperson@example.org + ... To: _xtest@example.com + ... Subject: An implicit message + ... + ... """) + >>> rule.check(mlist, msg, {}) + False diff --git a/src/mailman/rules/docs/truth.txt b/src/mailman/rules/docs/truth.txt new file mode 100644 index 000000000..f331e852b --- /dev/null +++ b/src/mailman/rules/docs/truth.txt @@ -0,0 +1,9 @@ +Truth +===== + +The 'truth' rule always matches. This makes it useful as a terminus rule for +unconditionally jumping to another chain. + + >>> rule = config.rules['truth'] + >>> rule.check(False, False, False) + True diff --git a/src/mailman/rules/emergency.py b/src/mailman/rules/emergency.py new file mode 100644 index 000000000..c2cee06c4 --- /dev/null +++ b/src/mailman/rules/emergency.py @@ -0,0 +1,50 @@ +# 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 emergency hold rule.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Emergency', + ] + + +from zope.interface import implements + +from mailman.i18n import _ +from mailman.interfaces.rules import IRule + + + +class Emergency: + """The emergency hold rule.""" + implements(IRule) + + name = 'emergency' + + description = _( + """The mailing list is in emergency hold and this message was not + pre-approved by the list administrator. + """) + + record = True + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + return mlist.emergency and not msgdata.get('moderator_approved') diff --git a/src/mailman/rules/implicit_dest.py b/src/mailman/rules/implicit_dest.py new file mode 100644 index 000000000..3ddffa2cf --- /dev/null +++ b/src/mailman/rules/implicit_dest.py @@ -0,0 +1,99 @@ +# 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 implicit destination rule.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'ImplicitDestination', + ] + + +import re +from email.utils import getaddresses +from zope.interface import implements + +from mailman.i18n import _ +from mailman.interfaces.rules import IRule + + + +class ImplicitDestination: + """The implicit destination rule.""" + implements(IRule) + + name = 'implicit-dest' + description = _('Catch messages with implicit destination.') + record = True + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + # Implicit destination checking must be enabled in the mailing list. + if not mlist.require_explicit_destination: + return False + # Messages gated from NNTP will always have an implicit destination so + # are never checked. + if msgdata.get('fromusenet'): + return False + # Calculate the list of acceptable aliases. If the alias starts with + # a caret (i.e. ^), then it's a regular expression to match against. + aliases = set() + alias_patterns = set() + for alias in mlist.acceptable_aliases.splitlines(): + alias = alias.strip().lower() + if alias.startswith('^'): + alias_patterns.add(alias) + elif '@' in alias: + aliases.add(alias) + else: + # This is not a regular expression, nor a fully-qualified + # email address, so skip it. + pass + # Add the list's posting address, i.e. the explicit address, to the + # set of acceptable aliases. + aliases.add(mlist.posting_address) + # Look at all the recipients. If the recipient is any acceptable + # alias (or the explicit posting address), then this rule does not + # match. If not, then add it to the set of recipients we'll check + # against the alias patterns later. + recipients = set() + for header in ('to', 'cc', 'resent-to', 'resent-cc'): + for fullname, address in getaddresses(msg.get_all(header, [])): + address = address.lower() + if address in aliases: + return False + recipients.add(address) + # Now for all alias patterns, see if any of the recipients matches a + # pattern. If so, then this rule does not match. + for pattern in alias_patterns: + escaped = re.escape(pattern) + for recipient in recipients: + try: + if re.match(pattern, recipient, re.IGNORECASE): + return False + except re.error: + # The pattern is a malformed regular expression. Try + # matching again with the pattern escaped. + try: + if re.match(escaped, recipient, re.IGNORECASE): + return False + except re.error: + pass + # Nothing matched. + return True diff --git a/src/mailman/rules/loop.py b/src/mailman/rules/loop.py new file mode 100644 index 000000000..564d20dc6 --- /dev/null +++ b/src/mailman/rules/loop.py @@ -0,0 +1,48 @@ +# 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/>. + +"""Look for a posting loop.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Loop', + ] + + +from zope.interface import implements + +from mailman.i18n import _ +from mailman.interfaces.rules import IRule + + + +class Loop: + """Look for a posting loop.""" + implements(IRule) + + name = 'loop' + description = _('Look for a posting loop, via the X-BeenThere header.') + record = True + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + # Has this message already been posted to this list? + been_theres = [value.strip().lower() + for value in msg.get_all('x-beenthere', [])] + return mlist.posting_address in been_theres diff --git a/src/mailman/rules/max_recipients.py b/src/mailman/rules/max_recipients.py new file mode 100644 index 000000000..a9cfd4a7f --- /dev/null +++ b/src/mailman/rules/max_recipients.py @@ -0,0 +1,52 @@ +# 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 maximum number of recipients rule.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'MaximumRecipients', + ] + + +from email.utils import getaddresses +from zope.interface import implements + +from mailman.i18n import _ +from mailman.interfaces.rules import IRule + + + +class MaximumRecipients: + """The maximum number of recipients rule.""" + implements(IRule) + + name = 'max-recipients' + description = _('Catch messages with too many explicit recipients.') + record = True + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + # Zero means any number of recipients are allowed. + if mlist.max_num_recipients == 0: + return False + # Figure out how many recipients there are + recipients = getaddresses(msg.get_all('to', []) + + msg.get_all('cc', [])) + return len(recipients) >= mlist.max_num_recipients diff --git a/src/mailman/rules/max_size.py b/src/mailman/rules/max_size.py new file mode 100644 index 000000000..bac79bbab --- /dev/null +++ b/src/mailman/rules/max_size.py @@ -0,0 +1,50 @@ +# 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 maximum message size rule.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'MaximumSize', + ] + + +from zope.interface import implements + +from mailman.i18n import _ +from mailman.interfaces.rules import IRule + + + +class MaximumSize: + """The implicit destination rule.""" + implements(IRule) + + name = 'max-size' + description = _('Catch messages that are bigger than a specified maximum.') + record = True + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + if mlist.max_message_size == 0: + return False + assert hasattr(msg, 'original_size'), ( + 'Message was not sized on initial parsing.') + # The maximum size is specified in 1024 bytes. + return msg.original_size / 1024.0 > mlist.max_message_size diff --git a/src/mailman/rules/moderation.py b/src/mailman/rules/moderation.py new file mode 100644 index 000000000..708983f6b --- /dev/null +++ b/src/mailman/rules/moderation.py @@ -0,0 +1,68 @@ +# 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/>. + +"""Membership related rules.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Moderation', + 'NonMember', + ] + + +from zope.interface import implements + +from mailman.i18n import _ +from mailman.interfaces.rules import IRule + + + +class Moderation: + """The member moderation rule.""" + implements(IRule) + + name = 'moderation' + description = _('Match messages sent by moderated members.') + record = True + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + for sender in msg.get_senders(): + member = mlist.members.get_member(sender) + if member is not None and member.is_moderated: + return True + return False + + + +class NonMember: + """The non-membership rule.""" + implements(IRule) + + name = 'non-member' + description = _('Match messages sent by non-members.') + record = True + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + for sender in msg.get_senders(): + if mlist.members.get_member(sender) is not None: + # The sender is a member of the mailing list. + return False + return True diff --git a/src/mailman/rules/news_moderation.py b/src/mailman/rules/news_moderation.py new file mode 100644 index 000000000..3ead80086 --- /dev/null +++ b/src/mailman/rules/news_moderation.py @@ -0,0 +1,49 @@ +# 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 news moderation rule.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'ModeratedNewsgroup', + ] + + +from zope.interface import implements + +from mailman.i18n import _ +from mailman.interfaces import NewsModeration +from mailman.interfaces.rules import IRule + + + +class ModeratedNewsgroup: + """The news moderation rule.""" + implements(IRule) + + name = 'news-moderation' + description = _( + """Match all messages posted to a mailing list that gateways to a + moderated newsgroup. + """) + record = True + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + return mlist.news_moderation == NewsModeration.moderated diff --git a/src/mailman/rules/no_subject.py b/src/mailman/rules/no_subject.py new file mode 100644 index 000000000..2487867e7 --- /dev/null +++ b/src/mailman/rules/no_subject.py @@ -0,0 +1,46 @@ +# 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 no-Subject header rule.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'NoSubject', + ] + + +from zope.interface import implements + +from mailman.i18n import _ +from mailman.interfaces.rules import IRule + + + +class NoSubject: + """The no-Subject rule.""" + implements(IRule) + + name = 'no-subject' + description = _('Catch messages with no, or empty, Subject headers.') + record = True + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + subject = msg.get('subject', '').strip() + return subject == '' diff --git a/src/mailman/rules/suspicious.py b/src/mailman/rules/suspicious.py new file mode 100644 index 000000000..00e9a5e9e --- /dev/null +++ b/src/mailman/rules/suspicious.py @@ -0,0 +1,100 @@ +# 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 historical 'suspicious header' rule.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'SuspiciousHeader', + ] + + +import re +import logging + +from zope.interface import implements + +from mailman.i18n import _ +from mailman.interfaces.rules import IRule + +log = logging.getLogger('mailman.error') + + + +class SuspiciousHeader: + """The historical 'suspicious header' rule.""" + implements(IRule) + + name = 'suspicious-header' + description = _('Catch messages with suspicious headers.') + record = True + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + return (mlist.bounce_matching_headers and + has_matching_bounce_header(mlist, msg)) + + + +def _parse_matching_header_opt(mlist): + """Return a list of triples [(field name, regex, line), ...].""" + # - Blank lines and lines with '#' as first char are skipped. + # - Leading whitespace in the matchexp is trimmed - you can defeat + # that by, eg, containing it in gratuitous square brackets. + all = [] + for line in mlist.bounce_matching_headers.splitlines(): + line = line.strip() + # Skip blank lines and lines *starting* with a '#'. + if not line or line.startswith('#'): + continue + i = line.find(':') + if i < 0: + # This didn't look like a header line. BAW: should do a + # better job of informing the list admin. + log.error('bad bounce_matching_header line: %s\n%s', + mlist.real_name, line) + else: + header = line[:i] + value = line[i+1:].lstrip() + try: + cre = re.compile(value, re.IGNORECASE) + except re.error as error: + # The regexp was malformed. BAW: should do a better + # job of informing the list admin. + log.error("""\ +bad regexp in bounce_matching_header line: %s +\n%s (cause: %s)""", mlist.real_name, value, error) + else: + all.append((header, cre, line)) + return all + + +def has_matching_bounce_header(mlist, msg): + """Does the message have a matching bounce header? + + :param mlist: The mailing list the message is destined for. + :param msg: The email message object. + :return: True if a header field matches a regexp in the + bounce_matching_header mailing list variable. + """ + for header, cre, line in _parse_matching_header_opt(mlist): + for value in msg.get_all(header, []): + if cre.search(value): + return True + return False diff --git a/src/mailman/rules/truth.py b/src/mailman/rules/truth.py new file mode 100644 index 000000000..45b5560c2 --- /dev/null +++ b/src/mailman/rules/truth.py @@ -0,0 +1,45 @@ +# 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/>. + +"""A rule which always matches.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Truth', + ] + + +from zope.interface import implements + +from mailman.i18n import _ +from mailman.interfaces.rules import IRule + + + +class Truth: + """Look for any previous rule match.""" + implements(IRule) + + name = 'truth' + description = _('A rule which always matches.') + record = False + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + return True |
