diff options
| -rw-r--r-- | Mailman/app/chains.py | 300 | ||||
| -rw-r--r-- | Mailman/app/moderator.py | 12 | ||||
| -rw-r--r-- | Mailman/app/replybot.py | 34 | ||||
| -rw-r--r-- | Mailman/app/rules.py | 49 | ||||
| -rw-r--r-- | Mailman/configuration.py | 18 | ||||
| -rw-r--r-- | Mailman/initialize.py | 5 | ||||
| -rw-r--r-- | Mailman/interfaces/chain.py | 68 | ||||
| -rw-r--r-- | Mailman/interfaces/rules.py | 28 | ||||
| -rw-r--r-- | Mailman/rules/__init__.py | 65 | ||||
| -rw-r--r-- | Mailman/rules/any.py | 40 | ||||
| -rw-r--r-- | Mailman/templates/en/postauth.txt | 2 | ||||
| -rw-r--r-- | setup.py | 2 |
12 files changed, 503 insertions, 120 deletions
diff --git a/Mailman/app/chains.py b/Mailman/app/chains.py new file mode 100644 index 000000000..cb5dfafb7 --- /dev/null +++ b/Mailman/app/chains.py @@ -0,0 +1,300 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program 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 2 +# of the License, or (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""The built-in rule chains.""" + +from __future__ import with_statement + +__all__ = [ + 'AcceptChain', + 'DiscardChain', + 'HoldChain', + 'RejectChain', + ] +__metaclass__ = type + + +import logging + +from Mailman import i18n +from Mailman.Message import UserNotification +from Mailman.Utils import maketext, oneline, wrap, GetCharSet +from Mailman.app.bounces import bounce_message +from Mailman.app.moderator import hold_message +from Mailman.app.replybot import autorespond_to_sender, can_acknowledge +from Mailman.configuration import config +from Mailman.i18n import _ +from Mailman.interfaces import IChain, IChainLink, IMutableChain, IPendable +from Mailman.queue import Switchboard + +log = logging.getLogger('mailman.vette') +elog = logging.getLogger('mailman.error') +SEMISPACE = '; ' + + + +class HeldMessagePendable(dict): + implements(IPendable) + PEND_KEY = 'held message' + + + +class DiscardChain: + """Discard a message.""" + implements(IChain) + + name = 'discard' + description = _('Discard a message and stop processing.') + + def process(self, mlist, msg, msgdata): + """See `IChain`.""" + log.info('DISCARD: %s', msg.get('message-id', 'n/a')) + # Nothing more needs to happen. + + + +class HoldChain: + """Hold a message.""" + implements(IChain) + + name = 'hold' + description = _('Hold a message and stop processing.') + + def process(self, mlist, msg, msgdata): + """See `IChain`.""" + # Start by decorating the message with a header that contains a list + # of all the rules that matched. + msg['X-Mailman-Rule-Hits'] = SEMISPACE.join(msgdata['rules']) + # 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. + 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 = { + 'listname' : mlist.fqdn_listname, + 'subject' : original_subject, + 'reason' : 'XXX', #reason, + 'confirmurl': '%s/%s' % (mlist.script_url('confirm'), token), + } + sender = msg.get_sender() + # 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') + language = msgdata.get('lang', lang) + text = maketext('postheld.txt', substitutions, + lang=language, mlist=mlist) + nmsg = UserNotification(sender, adminaddr, subject, text, 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) + d['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', substitution, + 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(lang)) + dmsg['Subject'] = 'confirm ' + token + dmsg['Sender'] = requestaddr + dmsg['From'] = requestaddr + dmsg['Date'] = email.utils.formatdate(localtime=True) + dmsg['Message-ID'] = email.utils.make_msgid() + nmsg.attach(text) + nmsg.attach(MIMEMessage(msg)) + nmsg.attach(MIMEMessage(dmsg)) + nmsg.send(mlist, **{'tomoderators': 1}) + # Log the held message + log.info('HELD: %s post from %s held, message-id=%s: %s', + listname, sender, message_id, reason) + + + +class RejectChain: + """Reject/bounce a message.""" + implements(IChain) + + name = 'reject' + description = _('Reject/bounce a message and stop processing.') + + def process(self, mlist, msg, msgdata): + """See `IChain`.""" + # XXX Exception/reason + bounce_message(mlist, msg) + log.info('REJECT: %s', msg.get('message-id', 'n/a')) + + + +class AcceptChain: + """Accept the message for posting.""" + implements(IChain) + + name = 'accept' + description = _('Accept a message.') + + def process(self, mlist, msg, msgdata): + """See `IChain.`""" + accept_queue = Switchboard(config.PREPQUEUE_DIR) + accept_queue.enqueue(msg, msgdata) + log.info('ACCEPT: %s', msg.get('message-id', 'n/a')) + + + +class Link: + """A chain link.""" + implements(IChainLink) + + def __init__(self, rule, jump): + self.rule = rule + self.jump = jump + + +class Chain: + """Default built-in moderation chain.""" + implements(IMutableChain) + + def __init__(self, name, description): + self.name = name + self.description = description + self._links = [] + + def append_link(self, link): + """See `IMutableChain`.""" + self._links.append(link) + + def flush(self): + """See `IMutableChain`.""" + self._links = [] + + def process(self, mlist, msg, msgdata): + """See `IMutableChain`.""" + msgdata['rules'] = rules = [] + jump = None + for link in self._links: + # The None rule always match. + if link.rule is None: + jump = link.jump + break + # If the rule hits, just to the given chain. + rule = config.rules.get(link.rule) + if rule is None: + elog.error('Rule not found: %s', rule) + elif rule.check(mlist, msg, msgdata): + rules.append(link.rule.name) + # None is a special jump meaning "keep processing this chain". + if link.jump is not None: + jump = link.jump + break + else: + # We got through the entire chain without a jumping rule match, so + # we really don't know what to do. Rather than raise an + # exception, jump to the discard chain. + log.info('Jumping to the discard chain by default.') + jump = 'discard' + # Find the named chain. + chain = config.chains.get(jump) + if chain is None: + elog.error('Chain not found: %s', chain) + # Well, what now? Nothing much left to do but discard the + # message, which we can do by simply returning. + else: + chain.process(mlist, msg, msgdata) + + + +def initialize(): + """Set up chains, both built-in and from the database.""" + for chain_class in (DiscardChain, HoldChain, RejectChain, AcceptChain): + chain = chain_class() + assert chain.name not in config.chains, ( + 'Duplicate chain name: %s' % chain.name) + config.chains[chain.name] = chain + # Set up a couple of other default chains. + default = Chain('built-in', _('The built-in moderation chain'), 'accept') + default.append_link(Link('approved', 'accept')) + default.append_link(Link('emergency', 'hold')) + default.append_link(Link('loop', 'discard')) + # Do all these before deciding whether to hold the message for moderation. + default.append_link(Link('administrivia', None)) + default.append_link(Link('implicit-dest', None)) + default.append_link(Link('max-recipients', None)) + default.append_link(Link('max-size', None)) + default.append_link(Link('news-moderation', None)) + default.append_link(Link('no-subject', None)) + default.append_link(Link('suspicious', None)) + # Now if any of the above hit, jump to the hold chain. + default.append_link(Link('any', 'hold')) + # Finally, the builtin chain defaults to acceptance. + default.append_link(Link(None, 'accept')) + # XXX Read chains from the database and initialize them. + pass diff --git a/Mailman/app/moderator.py b/Mailman/app/moderator.py index 691268153..956dfb773 100644 --- a/Mailman/app/moderator.py +++ b/Mailman/app/moderator.py @@ -51,6 +51,18 @@ slog = logging.getLogger('mailman.subscribe') def hold_message(mlist, msg, msgdata=None, reason=None): + """Hold a message for moderator approval. + + The message is added to the mailing list's request database. + + :param mlist: The mailing list to hold the message on. + :param msg: The message to hold. + :param msgdata: Optional message metadata to hold. If not given, a new + metadata dictionary is created and held with the message. + :param reason: Optional string reason why the message is being held. If + not given, the empty string is used. + :return: An id used to handle the held message later. + """ if msgdata is None: msgdata = {} else: diff --git a/Mailman/app/replybot.py b/Mailman/app/replybot.py index c46931770..d1bc9c487 100644 --- a/Mailman/app/replybot.py +++ b/Mailman/app/replybot.py @@ -25,6 +25,7 @@ from __future__ import with_statement __all__ = [ 'autorespond_to_sender', + 'can_acknowledge', ] import logging @@ -87,3 +88,36 @@ def autorespond_to_sender(mlist, sender, lang=None): mlist.hold_and_cmd_autoresponses[sender] = (today, count + 1) return True + + +def can_acknowledge(msg): + """A boolean specifying whether this message can be acknowledged. + + There are several reasons why a message should not be acknowledged, mostly + related to competing standards or common practices. These include: + + * The message has a X-No-Ack header with any value + * The message has an X-Ack header with a 'no' value + * The message has a Precedence header + * The message has an Auto-Submitted header and that header does not have a + value of 'no' + * The message has an empty Return-Path header, e.g. <> + * The message has any RFC 2369 headers (i.e. List-* headers) + + :param msg: a Message object. + :return: Boolean specifying whether the message can be acknowledged or not + (which is different from whether it will be acknowledged). + """ + # I wrote it this way for clarity and consistency with the docstring. + for header in msg: + if header in ('x-no-ack', 'precedence'): + return False + if header.lower().startswith('list-'): + return False + if msg.get('x-ack', '').lower() == 'no': + return False + if msg.get('auto-submitted', 'no').lower() <> 'no' + return False + if msg.get('return-path') == '<>': + return False + return True diff --git a/Mailman/app/rules.py b/Mailman/app/rules.py index 37f5d9af4..948ee7dd7 100644 --- a/Mailman/app/rules.py +++ b/Mailman/app/rules.py @@ -15,50 +15,21 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, # USA. -"""Process all rules defined by entry points.""" +"""Various rule helpers""" __all__ = [ - 'find_rule', - 'process', + 'initialize', ] from Mailman.app.plugins import get_plugins +from Mailman.configuration import config -def process(mlist, msg, msgdata, rule_set=None): - """Default rule processing plugin. - - Rules are processed in random order. - - :param msg: The message object. - :param msgdata: The message metadata. - :param rule_set: The name of the rules to run. None (the default) means - to run all available rules. - :return: A set of rule names that matched. - """ - # Collect all rules from all rule processors. - rules = set() - for rule_set_class in get_plugins('mailman.rules'): - rules |= set(rule for rule in rule_set_class().rules - if rule_set is None or rule.name in rule_set) - # Now process all rules, returning the set of rules that match. - rule_matches = set() - for rule in rules: - if rule.check(mlist, msg, msgdata): - rule_matches.add(rule.name) - return rule_matches - - - -def find_rule(rule_name): - """Find the named rule. - - :param rule_name: The name of the rule to find. - :return: The named rule, or None if no such rule exists. - """ - for rule_set_class in get_plugins('mailman.rules'): - rule = rule_set_class().get(rule_name) - if rule is not None: - return rule - return None +def initialize(): + """Find and register all rules in all plugins.""" + for rule_finder in get_plugins('mailman.rules'): + for rule in rule_finder(): + assert rule.name not in config.rules, ( + 'Duplicate rule "%s" found in %s' % (rule.name, rule_finder)) + config.rules[rule.name] = rule diff --git a/Mailman/configuration.py b/Mailman/configuration.py index c2e88affd..5fc359578 100644 --- a/Mailman/configuration.py +++ b/Mailman/configuration.py @@ -128,17 +128,18 @@ class Configuration(object): self.PRIVATE_ARCHIVE_FILE_DIR = join(VAR_DIR, 'archives', 'private') # Directories used by the qrunner subsystem self.QUEUE_DIR = qdir = join(VAR_DIR, 'qfiles') - self.INQUEUE_DIR = join(qdir, 'in') - self.OUTQUEUE_DIR = join(qdir, 'out') - self.CMDQUEUE_DIR = join(qdir, 'commands') + self.ARCHQUEUE_DIR = join(qdir, 'archive') + self.BADQUEUE_DIR = join(qdir, 'bad') self.BOUNCEQUEUE_DIR = join(qdir, 'bounces') + self.CMDQUEUE_DIR = join(qdir, 'commands') + self.INQUEUE_DIR = join(qdir, 'in') + self.MAILDIR_DIR = join(qdir, 'maildir') self.NEWSQUEUE_DIR = join(qdir, 'news') - self.ARCHQUEUE_DIR = join(qdir, 'archive') + self.OUTQUEUE_DIR = join(qdir, 'out') + self.PREPQUEUE_DIR = join(qdir, 'prepare') + self.RETRYQUEUE_DIR = join(qdir, 'retry') self.SHUNTQUEUE_DIR = join(qdir, 'shunt') self.VIRGINQUEUE_DIR = join(qdir, 'virgin') - self.BADQUEUE_DIR = join(qdir, 'bad') - self.RETRYQUEUE_DIR = join(qdir, 'retry') - self.MAILDIR_DIR = join(qdir, 'maildir') self.MESSAGES_DIR = join(VAR_DIR, 'messages') # Other useful files self.PIDFILE = join(datadir, 'master-qrunner.pid') @@ -174,6 +175,9 @@ class Configuration(object): # Always add and enable the default server language. code = self.DEFAULT_SERVER_LANGUAGE self.languages.enable_language(code) + # Create the registry of rules and chains. + self.chains = {} + self.rules = {} def add_domain(self, email_host, url_host=None): """Add a virtual domain. diff --git a/Mailman/initialize.py b/Mailman/initialize.py index dff2677f3..7e191a9d2 100644 --- a/Mailman/initialize.py +++ b/Mailman/initialize.py @@ -31,7 +31,9 @@ from zope.interface.verify import verifyObject import Mailman.configuration import Mailman.loginit +from Mailman.app.chains import initialize as initialize_chains from Mailman.app.plugins import get_plugin +from Mailman.app.rules import initialize as initialize_rules from Mailman.interfaces import IDatabase @@ -63,6 +65,9 @@ def initialize_2(debug=False): verifyObject(IDatabase, database) database.initialize(debug) Mailman.configuration.config.db = database + # Initialize the rules and chains. + initialize_rules() + initialize_chains() def initialize(config_path=None, propagate_logs=False): diff --git a/Mailman/interfaces/chain.py b/Mailman/interfaces/chain.py new file mode 100644 index 000000000..226ad232c --- /dev/null +++ b/Mailman/interfaces/chain.py @@ -0,0 +1,68 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program 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 2 +# of the License, or (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Interface describing the basics of chains and links.""" + +from munepy import Enum +from zope.interface import Interface, Attribute + + + +class IChain(Interface): + """A chain of rules.""" + + name = Attribute('Chain name; must be unique.') + description = Attribute('A brief description of the chain.') + + def process(mlist, msg, msgdata): + """Process the message through the chain. + + Processing a message involves running through each link in the chain, + until a jump to another chain occurs or the chain reaches the end. + Reaching the end of the chain with no other disposition is equivalent + to discarding the message. + + :param mlist: The mailing list object. + :param msg: The message object. + :param msgdata: The message metadata. + """ + + + +class IMutableChain(IChain): + """Like `IChain` but can be mutated.""" + + def append_link(link): + """Add a new chain link to the end of this chain. + + :param link: The chain link to add. + """ + + def flush(): + """Delete all links in this chain.""" + + + +class IChainLink(Interface): + """A link in the chain.""" + + rule = Attribute('The rule to run for this link.') + + jump = Attribute( + """The jump action to perform when the rule matches. This may be None + to simply process the next link in the chain. + """) diff --git a/Mailman/interfaces/rules.py b/Mailman/interfaces/rules.py index 5549b8e6b..24e479f81 100644 --- a/Mailman/interfaces/rules.py +++ b/Mailman/interfaces/rules.py @@ -21,11 +21,6 @@ from zope.interface import Interface, Attribute -class DuplicateRuleError(Exception): - """A rule or rule name is added to a processor more than once.""" - - - class IRule(Interface): """A basic rule.""" @@ -43,26 +38,3 @@ class IRule(Interface): :param msg: The message object. :param msgdata: The message metadata. """ - - - -class IRuleSet(Interface): - """A rule processor.""" - - rules = Attribute('The set of all rules this processor knows about.') - - def __getitem__(rule_name): - """Return the named rule. - - :param rule_name: The name of the rule. - :return: The IRule given by this name. - :raise: KeyError if no such rule is known by this processor. - """ - - def get(rule_name, default=None): - """Return the name rule. - - :param rule_name: The name of the rule. - :return: The IRule given by this name, or `default` if no such rule - is known by this processor. - """ diff --git a/Mailman/rules/__init__.py b/Mailman/rules/__init__.py index 299c9f697..a78e9bd05 100644 --- a/Mailman/rules/__init__.py +++ b/Mailman/rules/__init__.py @@ -17,57 +17,34 @@ """The built in rule set.""" -__all__ = ['BuiltinRules'] +__all__ = ['initialize'] __metaclass__ = type import os import sys -from zope.interface import implements -from Mailman.interfaces import DuplicateRuleError, IRule, IRuleSet +from Mailman.interfaces import IRule -class BuiltinRules: - implements(IRuleSet) +def initialize(): + """Initialize the built-in rules. - def __init__(self): - """The set of all built-in rules.""" - self._rules = {} - rule_set = set() - # Find all rules found in all modules inside our package. - mypackage = self.__class__.__module__ - here = os.path.dirname(sys.modules[mypackage].__file__) - for filename in os.listdir(here): - basename, extension = os.path.splitext(filename) - if extension <> '.py': - continue - module_name = mypackage + '.' + basename - __import__(module_name, fromlist='*') - module = sys.modules[module_name] - for name in module.__all__: - rule = getattr(module, name) - if IRule.implementedBy(rule): - if rule.name in self._rules or rule in rule_set: - raise DuplicateRuleError(rule.name) - self._rules[rule.name] = rule - rule_set.add(rule) - - def __getitem__(self, rule_name): - """See `IRuleSet`.""" - return self._rules[rule_name]() - - def get(self, rule_name, default=None): - """See `IRuleSet`.""" - missing = object() - rule = self._rules.get(rule_name, missing) - if rule is missing: - return default - return rule() - - @property - def rules(self): - """See `IRuleSet`.""" - for rule in self._rules.values(): - yield rule() + 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/Mailman/rules/any.py b/Mailman/rules/any.py new file mode 100644 index 000000000..b97ad73d2 --- /dev/null +++ b/Mailman/rules/any.py @@ -0,0 +1,40 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program 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 2 +# of the License, or (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Check if any previous rules have matched.""" + +__all__ = ['Any'] +__metaclass__ = type + + +from zope.interface import implements + +from Mailman.i18n import _ +from Mailman.interfaces import IRule + + + +class Any: + """Look for any previous rule match.""" + implements(IRule) + + name = 'any' + description = _('Look for any previous rule hit.') + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + return len(msgdata.get('rules', [])) > 0 diff --git a/Mailman/templates/en/postauth.txt b/Mailman/templates/en/postauth.txt index a10727716..19f1e384a 100644 --- a/Mailman/templates/en/postauth.txt +++ b/Mailman/templates/en/postauth.txt @@ -1,7 +1,7 @@ As list administrator, your authorization is requested for the following mailing list posting: - List: %(listname)s@%(hostname)s + List: %(listname)s From: %(sender)s Subject: %(subject)s Reason: %(reason)s @@ -86,7 +86,7 @@ Any other spelling is incorrect.""", 'mailman.database' : 'stock = Mailman.database:StockDatabase', 'mailman.styles' : 'default = Mailman.app.styles:DefaultStyle', 'mailman.mta' : 'stock = Mailman.MTA:Manual', - 'mailman.rules' : 'default = Mailman.rules:BuiltinRules', + 'mailman.rules' : 'default = Mailman.rules:initialize', }, # Third-party requirements. install_requires = [ |
