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/app | |
| parent | 07871212f74498abd56bef3919bf3e029eb8b930 (diff) | |
| download | mailman-eefd06f1b88b8ecbb23a9013cd223b72ca85c20d.tar.gz mailman-eefd06f1b88b8ecbb23a9013cd223b72ca85c20d.tar.zst mailman-eefd06f1b88b8ecbb23a9013cd223b72ca85c20d.zip | |
Diffstat (limited to 'src/mailman/app')
| -rw-r--r-- | src/mailman/app/__init__.py | 0 | ||||
| -rw-r--r-- | src/mailman/app/bounces.py | 63 | ||||
| -rw-r--r-- | src/mailman/app/commands.py | 44 | ||||
| -rw-r--r-- | src/mailman/app/lifecycle.py | 114 | ||||
| -rw-r--r-- | src/mailman/app/membership.py | 137 | ||||
| -rw-r--r-- | src/mailman/app/moderator.py | 351 | ||||
| -rw-r--r-- | src/mailman/app/notifications.py | 136 | ||||
| -rw-r--r-- | src/mailman/app/registrar.py | 163 | ||||
| -rw-r--r-- | src/mailman/app/replybot.py | 125 |
9 files changed, 1133 insertions, 0 deletions
diff --git a/src/mailman/app/__init__.py b/src/mailman/app/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/mailman/app/__init__.py diff --git a/src/mailman/app/bounces.py b/src/mailman/app/bounces.py new file mode 100644 index 000000000..875f615a5 --- /dev/null +++ b/src/mailman/app/bounces.py @@ -0,0 +1,63 @@ +# 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/>. + +"""Application level bounce handling.""" + +from __future__ import unicode_literals + +__metaclass__ = type +__all__ = [ + 'bounce_message', + ] + +import logging + +from email.mime.message import MIMEMessage +from email.mime.text import MIMEText + +from mailman import Message +from mailman import Utils +from mailman.i18n import _ + +log = logging.getLogger('mailman.config') + + + +def bounce_message(mlist, msg, e=None): + # Bounce a message back to the sender, with an error message if provided + # in the exception argument. + sender = msg.get_sender() + subject = msg.get('subject', _('(no subject)')) + subject = Utils.oneline(subject, + Utils.GetCharSet(mlist.preferred_language)) + if e is None: + notice = _('[No bounce details are available]') + else: + notice = _(e.notice) + # Currently we always craft bounces as MIME messages. + bmsg = Message.UserNotification(msg.get_sender(), + mlist.owner_address, + subject, + lang=mlist.preferred_language) + # BAW: Be sure you set the type before trying to attach, or you'll get + # a MultipartConversionError. + bmsg.set_type('multipart/mixed') + txt = MIMEText(notice, + _charset=Utils.GetCharSet(mlist.preferred_language)) + bmsg.attach(txt) + bmsg.attach(MIMEMessage(msg)) + bmsg.send(mlist) diff --git a/src/mailman/app/commands.py b/src/mailman/app/commands.py new file mode 100644 index 000000000..d7676af9c --- /dev/null +++ b/src/mailman/app/commands.py @@ -0,0 +1,44 @@ +# 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/>. + +"""Initialize the email commands.""" + +from __future__ import unicode_literals + +__metaclass__ = type +__all__ = [ + 'initialize', + ] + + +from mailman.config import config +from mailman.core.plugins import get_plugins +from mailman.interfaces.command import IEmailCommand + + + +def initialize(): + """Initialize the email commands.""" + for module in get_plugins('mailman.commands'): + for name in module.__all__: + command_class = getattr(module, name) + if not IEmailCommand.implementedBy(command_class): + continue + assert command_class.name not in config.commands, ( + 'Duplicate email command "{0}" found in {1}'.format( + command_class.name, module)) + config.commands[command_class.name] = command_class() diff --git a/src/mailman/app/lifecycle.py b/src/mailman/app/lifecycle.py new file mode 100644 index 000000000..eec00dc86 --- /dev/null +++ b/src/mailman/app/lifecycle.py @@ -0,0 +1,114 @@ +# 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/>. + +"""Application level list creation.""" + +from __future__ import unicode_literals + +__metaclass__ = type +__all__ = [ + 'create_list', + 'remove_list', + ] + + +import os +import sys +import shutil +import logging + +from mailman import Utils +from mailman.Utils import ValidateEmail +from mailman.config import config +from mailman.core import errors +from mailman.interfaces.member import MemberRole + + +log = logging.getLogger('mailman.error') + + + +def create_list(fqdn_listname, owners=None): + """Create the named list and apply styles.""" + if owners is None: + owners = [] + ValidateEmail(fqdn_listname) + listname, domain = fqdn_listname.split('@', 1) + if domain not in config.domains: + raise errors.BadDomainSpecificationError(domain) + mlist = config.db.list_manager.create(fqdn_listname) + for style in config.style_manager.lookup(mlist): + style.apply(mlist) + # Coordinate with the MTA, as defined in the configuration file. + module_name, class_name = config.mta.incoming.rsplit('.', 1) + __import__(module_name) + getattr(sys.modules[module_name], class_name)().create(mlist) + # Create any owners that don't yet exist, and subscribe all addresses as + # owners of the mailing list. + usermgr = config.db.user_manager + for owner_address in owners: + addr = usermgr.get_address(owner_address) + if addr is None: + # XXX Make this use an IRegistrar instead, but that requires + # sussing out the IDomain stuff. For now, fake it. + user = usermgr.create_user(owner_address) + addr = list(user.addresses)[0] + addr.subscribe(mlist, MemberRole.owner) + return mlist + + + +def remove_list(fqdn_listname, mailing_list=None, archives=True): + """Remove the list and all associated artifacts and subscriptions.""" + removeables = [] + # mailing_list will be None when only residual archives are being removed. + if mailing_list: + # Remove all subscriptions, regardless of role. + for member in mailing_list.subscribers.members: + member.unsubscribe() + # Delete the mailing list from the database. + config.db.list_manager.delete(mailing_list) + # Do the MTA-specific list deletion tasks + module_name, class_name = config.mta.incoming.rsplit('.', 1) + __import__(module_name) + getattr(sys.modules[module_name], class_name)().create(mailing_list) + # Remove the list directory. + removeables.append(os.path.join(config.LIST_DATA_DIR, fqdn_listname)) + # Remove any stale locks associated with the list. + for filename in os.listdir(config.LOCK_DIR): + fn_listname = filename.split('.')[0] + if fn_listname == fqdn_listname: + removeables.append(os.path.join(config.LOCK_DIR, filename)) + if archives: + private_dir = config.PRIVATE_ARCHIVE_FILE_DIR + public_dir = config.PUBLIC_ARCHIVE_FILE_DIR + removeables.extend([ + os.path.join(private_dir, fqdn_listname), + os.path.join(private_dir, fqdn_listname + '.mbox'), + os.path.join(public_dir, fqdn_listname), + os.path.join(public_dir, fqdn_listname + '.mbox'), + ]) + # Now that we know what files and directories to delete, delete them. + for target in removeables: + if os.path.islink(target): + os.unlink(target) + elif os.path.isdir(target): + shutil.rmtree(target) + elif os.path.isfile(target): + os.unlink(target) + else: + log.error('Could not delete list artifact: %s', target) diff --git a/src/mailman/app/membership.py b/src/mailman/app/membership.py new file mode 100644 index 000000000..4b9609469 --- /dev/null +++ b/src/mailman/app/membership.py @@ -0,0 +1,137 @@ +# 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/>. + +"""Application support for membership management.""" + +from __future__ import unicode_literals + +__metaclass__ = type +__all__ = [ + 'add_member', + 'delete_member', + ] + + +from email.utils import formataddr + +from mailman import Message +from mailman import Utils +from mailman import i18n +from mailman.app.notifications import send_goodbye_message +from mailman.config import config +from mailman.core import errors +from mailman.interfaces.member import AlreadySubscribedError, MemberRole + +_ = i18n._ + + + +def add_member(mlist, address, realname, password, delivery_mode, language): + """Add a member right now. + + The member's subscription must be approved by whatever policy the list + enforces. + + :param mlist: the mailing list to add the member to + :type mlist: IMailingList + :param address: the address to subscribe + :type address: string + :param realname: the subscriber's full name + :type realname: string + :param password: the subscriber's password + :type password: string + :param delivery_mode: the delivery mode the subscriber has chosen + :type delivery_mode: DeliveryMode + :param language: the language that the subscriber is going to use + :type language: string + """ + # Let's be extra cautious. + Utils.ValidateEmail(address) + if mlist.members.get_member(address) is not None: + raise AlreadySubscribedError( + mlist.fqdn_listname, address, MemberRole.member) + # Check for banned address here too for admin mass subscribes and + # confirmations. + pattern = Utils.get_pattern(address, mlist.ban_list) + if pattern: + raise errors.MembershipIsBanned(pattern) + # Do the actual addition. First, see if there's already a user linked + # with the given address. + user = config.db.user_manager.get_user(address) + if user is None: + # A user linked to this address does not yet exist. Is the address + # itself known but just not linked to a user? + address_obj = config.db.user_manager.get_address(address) + if address_obj is None: + # Nope, we don't even know about this address, so create both the + # user and address now. + user = config.db.user_manager.create_user(address, realname) + # Do it this way so we don't have to flush the previous change. + address_obj = list(user.addresses)[0] + else: + # The address object exists, but it's not linked to a user. + # Create the user and link it now. + user = config.db.user_manager.create_user() + user.real_name = (realname if realname else address_obj.real_name) + user.link(address_obj) + # Since created the user, then the member, and set preferences on the + # appropriate object. + user.password = password + user.preferences.preferred_language = language + member = address_obj.subscribe(mlist, MemberRole.member) + member.preferences.delivery_mode = delivery_mode + else: + # The user exists and is linked to the address. + for address_obj in user.addresses: + if address_obj.address == address: + break + else: + raise AssertionError( + 'User should have had linked address: {0}'.format(address)) + # Create the member and set the appropriate preferences. + member = address_obj.subscribe(mlist, MemberRole.member) + member.preferences.preferred_language = language + member.preferences.delivery_mode = delivery_mode +## mlist.setMemberOption(email, config.Moderate, +## mlist.default_member_moderation) + + + +def delete_member(mlist, address, admin_notif=None, userack=None): + if userack is None: + userack = mlist.send_goodbye_msg + if admin_notif is None: + admin_notif = mlist.admin_notify_mchanges + # Delete a member, for which we know the approval has been made + member = mlist.members.get_member(address) + language = member.preferred_language + member.unsubscribe() + # And send an acknowledgement to the user... + if userack: + send_goodbye_message(mlist, address, language) + # ...and to the administrator. + if admin_notif: + user = config.db.user_manager.get_user(address) + realname = user.real_name + subject = _('$mlist.real_name unsubscription notification') + text = Utils.maketext( + 'adminunsubscribeack.txt', + {'listname': mlist.real_name, + 'member' : formataddr((realname, address)), + }, mlist=mlist) + msg = Message.OwnerNotification(mlist, subject, text) + msg.send(mlist) diff --git a/src/mailman/app/moderator.py b/src/mailman/app/moderator.py new file mode 100644 index 000000000..b40a34344 --- /dev/null +++ b/src/mailman/app/moderator.py @@ -0,0 +1,351 @@ +# 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/>. + +"""Application support for moderators.""" + +from __future__ import unicode_literals + +__metaclass__ = type +__all__ = [ + 'handle_message', + 'handle_subscription', + 'handle_unsubscription', + 'hold_message', + 'hold_subscription', + 'hold_unsubscription', + ] + +import logging + +from datetime import datetime +from email.utils import formataddr, formatdate, getaddresses, make_msgid + +from mailman import Message +from mailman import Utils +from mailman import i18n +from mailman.app.membership import add_member, delete_member +from mailman.app.notifications import ( + send_admin_subscription_notice, send_welcome_message) +from mailman.config import config +from mailman.core import errors +from mailman.interfaces import Action +from mailman.interfaces.member import AlreadySubscribedError, DeliveryMode +from mailman.interfaces.requests import RequestType + +_ = i18n._ + +vlog = logging.getLogger('mailman.vette') +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: + # Make a copy of msgdata so that subsequent changes won't corrupt the + # request database. TBD: remove the `filebase' key since this will + # not be relevant when the message is resurrected. + msgdata = msgdata.copy() + if reason is None: + reason = '' + # Add the message to the message store. It is required to have a + # Message-ID header. + message_id = msg.get('message-id') + if message_id is None: + msg['Message-ID'] = message_id = unicode(make_msgid()) + assert isinstance(message_id, unicode), ( + 'Message-ID is not a unicode: %s' % message_id) + config.db.message_store.add(msg) + # Prepare the message metadata with some extra information needed only by + # the moderation interface. + msgdata['_mod_message_id'] = message_id + msgdata['_mod_fqdn_listname'] = mlist.fqdn_listname + msgdata['_mod_sender'] = msg.get_sender() + msgdata['_mod_subject'] = msg.get('subject', _('(no subject)')) + msgdata['_mod_reason'] = reason + msgdata['_mod_hold_date'] = datetime.now().isoformat() + # Now hold this request. We'll use the message_id as the key. + requestsdb = config.db.requests.get_list_requests(mlist) + request_id = requestsdb.hold_request( + RequestType.held_message, message_id, msgdata) + return request_id + + + +def handle_message(mlist, id, action, + comment=None, preserve=False, forward=None): + requestdb = config.db.requests.get_list_requests(mlist) + key, msgdata = requestdb.get_request(id) + # Handle the action. + rejection = None + message_id = msgdata['_mod_message_id'] + sender = msgdata['_mod_sender'] + subject = msgdata['_mod_subject'] + if action is Action.defer: + # Nothing to do, but preserve the message for later. + preserve = True + elif action is Action.discard: + rejection = 'Discarded' + elif action is Action.reject: + rejection = 'Refused' + member = mlist.members.get_member(sender) + if member: + language = member.preferred_language + else: + language = None + _refuse(mlist, _('Posting of your message titled "$subject"'), + sender, comment or _('[No reason given]'), language) + elif action is Action.accept: + # Start by getting the message from the message store. + msg = config.db.message_store.get_message_by_id(message_id) + # Delete moderation-specific entries from the message metadata. + for key in msgdata.keys(): + if key.startswith('_mod_'): + del msgdata[key] + # Add some metadata to indicate this message has now been approved. + msgdata['approved'] = True + msgdata['moderator_approved'] = True + # Calculate a new filebase for the approved message, otherwise + # delivery errors will cause duplicates. + if 'filebase' in msgdata: + del msgdata['filebase'] + # Queue the file for delivery by qrunner. Trying to deliver the + # message directly here can lead to a huge delay in web turnaround. + # Log the moderation and add a header. + msg['X-Mailman-Approved-At'] = formatdate(localtime=True) + vlog.info('held message approved, message-id: %s', + msg.get('message-id', 'n/a')) + # Stick the message back in the incoming queue for further + # processing. + config.switchboards['in'].enqueue(msg, _metadata=msgdata) + else: + raise AssertionError('Unexpected action: {0}'.format(action)) + # Forward the message. + if forward: + # Get a copy of the original message from the message store. + msg = config.db.message_store.get_message_by_id(message_id) + # It's possible the forwarding address list is a comma separated list + # of realname/address pairs. + addresses = [addr[1] for addr in getaddresses(forward)] + language = mlist.preferred_language + if len(addresses) == 1: + # If the address getting the forwarded message is a member of + # the list, we want the headers of the outer message to be + # encoded in their language. Otherwise it'll be the preferred + # language of the mailing list. This is better than sending a + # separate message per recipient. + member = mlist.members.get_member(addresses[0]) + if member: + language = member.preferred_language + with i18n.using_language(language): + fmsg = Message.UserNotification( + addresses, mlist.bounces_address, + _('Forward of moderated message'), + lang=language) + fmsg.set_type('message/rfc822') + fmsg.attach(msg) + fmsg.send(mlist) + # Delete the message from the message store if it is not being preserved. + if not preserve: + config.db.message_store.delete_message(message_id) + requestdb.delete_request(id) + # Log the rejection + if rejection: + note = """%s: %s posting: +\tFrom: %s +\tSubject: %s""" + if comment: + note += '\n\tReason: ' + comment + vlog.info(note, mlist.fqdn_listname, rejection, sender, subject) + + + +def hold_subscription(mlist, address, realname, password, mode, language): + data = dict(when=datetime.now().isoformat(), + address=address, + realname=realname, + password=password, + delivery_mode=str(mode), + language=language) + # Now hold this request. We'll use the address as the key. + requestsdb = config.db.requests.get_list_requests(mlist) + request_id = requestsdb.hold_request( + RequestType.subscription, address, data) + vlog.info('%s: held subscription request from %s', + mlist.fqdn_listname, address) + # Possibly notify the administrator in default list language + if mlist.admin_immed_notify: + subject = _( + 'New subscription request to list $mlist.real_name from $address') + text = Utils.maketext( + 'subauth.txt', + {'username' : address, + 'listname' : mlist.fqdn_listname, + 'admindb_url': mlist.script_url('admindb'), + }, mlist=mlist) + # This message should appear to come from the <list>-owner so as + # to avoid any useless bounce processing. + msg = Message.UserNotification( + mlist.owner_address, mlist.owner_address, + subject, text, mlist.preferred_language) + msg.send(mlist, tomoderators=True) + return request_id + + + +def handle_subscription(mlist, id, action, comment=None): + requestdb = config.db.requests.get_list_requests(mlist) + if action is Action.defer: + # Nothing to do. + return + elif action is Action.discard: + # Nothing to do except delete the request from the database. + pass + elif action is Action.reject: + key, data = requestdb.get_request(id) + _refuse(mlist, _('Subscription request'), + data['address'], + comment or _('[No reason given]'), + lang=data['language']) + elif action is Action.accept: + key, data = requestdb.get_request(id) + enum_value = data['delivery_mode'].split('.')[-1] + delivery_mode = DeliveryMode(enum_value) + address = data['address'] + realname = data['realname'] + language = data['language'] + password = data['password'] + try: + add_member(mlist, address, realname, password, + delivery_mode, language) + except AlreadySubscribedError: + # The address got subscribed in some other way after the original + # request was made and accepted. + pass + else: + if mlist.send_welcome_msg: + send_welcome_message(mlist, address, language, delivery_mode) + if mlist.admin_notify_mchanges: + send_admin_subscription_notice( + mlist, address, realname, language) + slog.info('%s: new %s, %s %s', mlist.fqdn_listname, + delivery_mode, formataddr((realname, address)), + 'via admin approval') + else: + raise AssertionError('Unexpected action: {0}'.format(action)) + # Delete the request from the database. + requestdb.delete_request(id) + + + +def hold_unsubscription(mlist, address): + data = dict(address=address) + requestsdb = config.db.requests.get_list_requests(mlist) + request_id = requestsdb.hold_request( + RequestType.unsubscription, address, data) + vlog.info('%s: held unsubscription request from %s', + mlist.fqdn_listname, address) + # Possibly notify the administrator of the hold + if mlist.admin_immed_notify: + subject = _( + 'New unsubscription request from $mlist.real_name by $address') + text = Utils.maketext( + 'unsubauth.txt', + {'address' : address, + 'listname' : mlist.fqdn_listname, + 'admindb_url': mlist.script_url('admindb'), + }, mlist=mlist) + # This message should appear to come from the <list>-owner so as + # to avoid any useless bounce processing. + msg = Message.UserNotification( + mlist.owner_address, mlist.owner_address, + subject, text, mlist.preferred_language) + msg.send(mlist, tomoderators=True) + return request_id + + + +def handle_unsubscription(mlist, id, action, comment=None): + requestdb = config.db.requests.get_list_requests(mlist) + key, data = requestdb.get_request(id) + address = data['address'] + if action is Action.defer: + # Nothing to do. + return + elif action is Action.discard: + # Nothing to do except delete the request from the database. + pass + elif action is Action.reject: + key, data = requestdb.get_request(id) + _refuse(mlist, _('Unsubscription request'), address, + comment or _('[No reason given]')) + elif action is Action.accept: + key, data = requestdb.get_request(id) + try: + delete_member(mlist, address) + except errors.NotAMemberError: + # User has already been unsubscribed. + pass + slog.info('%s: deleted %s', mlist.fqdn_listname, address) + else: + raise AssertionError('Unexpected action: {0}'.format(action)) + # Delete the request from the database. + requestdb.delete_request(id) + + + +def _refuse(mlist, request, recip, comment, origmsg=None, lang=None): + # As this message is going to the requester, try to set the language to + # his/her language choice, if they are a member. Otherwise use the list's + # preferred language. + realname = mlist.real_name + if lang is None: + member = mlist.members.get_member(recip) + if member: + lang = member.preferred_language + text = Utils.maketext( + 'refuse.txt', + {'listname' : mlist.fqdn_listname, + 'request' : request, + 'reason' : comment, + 'adminaddr': mlist.owner_address, + }, lang=lang, mlist=mlist) + with i18n.using_language(lang): + # add in original message, but not wrap/filled + if origmsg: + text = NL.join( + [text, + '---------- ' + _('Original Message') + ' ----------', + str(origmsg) + ]) + subject = _('Request to mailing list "$realname" rejected') + msg = Message.UserNotification(recip, mlist.bounces_address, + subject, text, lang) + msg.send(mlist) diff --git a/src/mailman/app/notifications.py b/src/mailman/app/notifications.py new file mode 100644 index 000000000..9bef9998b --- /dev/null +++ b/src/mailman/app/notifications.py @@ -0,0 +1,136 @@ +# 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/>. + +"""Sending notifications.""" + +from __future__ import unicode_literals + +__metaclass__ = type +__all__ = [ + 'send_admin_subscription_notice', + 'send_goodbye_message', + 'send_welcome_message', + ] + + +from email.utils import formataddr +from lazr.config import as_boolean + +from mailman import Message +from mailman import Utils +from mailman import i18n +from mailman.config import config +from mailman.interfaces.member import DeliveryMode + + +_ = i18n._ + + + +def send_welcome_message(mlist, address, language, delivery_mode, text=''): + """Send a welcome message to a subscriber. + + Prepending to the standard welcome message template is the mailing list's + welcome message, if there is one. + + :param mlist: the mailing list + :type mlist: IMailingList + :param address: The address to respond to + :type address: string + :param language: the language of the response + :type language: string + :param delivery_mode: the type of delivery the subscriber is getting + :type delivery_mode: DeliveryMode + """ + if mlist.welcome_msg: + welcome = Utils.wrap(mlist.welcome_msg) + '\n' + else: + welcome = '' + # Find the IMember object which is subscribed to the mailing list, because + # from there, we can get the member's options url. + member = mlist.members.get_member(address) + options_url = member.options_url + # Get the text from the template. + text += Utils.maketext( + 'subscribeack.txt', { + 'real_name' : mlist.real_name, + 'posting_address' : mlist.fqdn_listname, + 'listinfo_url' : mlist.script_url('listinfo'), + 'optionsurl' : options_url, + 'request_address' : mlist.request_address, + 'welcome' : welcome, + }, lang=language, mlist=mlist) + if delivery_mode is not DeliveryMode.regular: + digmode = _(' (Digest mode)') + else: + digmode = '' + msg = Message.UserNotification( + address, mlist.request_address, + _('Welcome to the "$mlist.real_name" mailing list${digmode}'), + text, language) + msg['X-No-Archive'] = 'yes' + msg.send(mlist, verp=as_boolean(config.mta.verp_personalized_deliveries)) + + + +def send_goodbye_message(mlist, address, language): + """Send a goodbye message to a subscriber. + + Prepending to the standard goodbye message template is the mailing list's + goodbye message, if there is one. + + :param mlist: the mailing list + :type mlist: IMailingList + :param address: The address to respond to + :type address: string + :param language: the language of the response + :type language: string + """ + if mlist.goodbye_msg: + goodbye = Utils.wrap(mlist.goodbye_msg) + '\n' + else: + goodbye = '' + msg = Message.UserNotification( + address, mlist.bounces_address, + _('You have been unsubscribed from the $mlist.real_name mailing list'), + goodbye, language) + msg.send(mlist, verp=as_boolean(config.mta.verp_personalized_deliveries)) + + + +def send_admin_subscription_notice(mlist, address, full_name, language): + """Send the list administrators a subscription notice. + + :param mlist: the mailing list + :type mlist: IMailingList + :param address: the address being subscribed + :type address: string + :param full_name: the name of the subscriber + :type full_name: string + :param language: the language of the address's realname + :type language: string + """ + with i18n.using_language(mlist.preferred_language): + subject = _('$mlist.real_name subscription notification') + full_name = full_name.encode(Utils.GetCharSet(language), 'replace') + text = Utils.maketext( + 'adminsubscribeack.txt', + {'listname' : mlist.real_name, + 'member' : formataddr((full_name, address)), + }, mlist=mlist) + msg = Message.OwnerNotification(mlist, subject, text) + msg.send(mlist) diff --git a/src/mailman/app/registrar.py b/src/mailman/app/registrar.py new file mode 100644 index 000000000..6a2abeba9 --- /dev/null +++ b/src/mailman/app/registrar.py @@ -0,0 +1,163 @@ +# 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/>. + +"""Implementation of the IUserRegistrar interface.""" + +from __future__ import unicode_literals + +__metaclass__ = type +__all__ = [ + 'Registrar', + 'adapt_domain_to_registrar', + ] + + +import datetime + +from pkg_resources import resource_string +from zope.interface import implements + +from mailman.Message import UserNotification +from mailman.Utils import ValidateEmail +from mailman.config import config +from mailman.i18n import _ +from mailman.interfaces.domain import IDomain +from mailman.interfaces.member import MemberRole +from mailman.interfaces.pending import IPendable +from mailman.interfaces.registrar import IRegistrar + + + +class PendableRegistration(dict): + implements(IPendable) + PEND_KEY = 'registration' + + + +class Registrar: + implements(IRegistrar) + + def __init__(self, context): + self._context = context + + def register(self, address, real_name=None, mlist=None): + """See `IUserRegistrar`.""" + # First, do validation on the email address. If the address is + # invalid, it will raise an exception, otherwise it just returns. + ValidateEmail(address) + # Create a pendable for the registration. + pendable = PendableRegistration( + type=PendableRegistration.PEND_KEY, + address=address, + real_name=real_name) + if mlist is not None: + pendable['list_name'] = mlist.fqdn_listname + token = config.db.pendings.add(pendable) + # Set up some local variables for translation interpolation. + domain = IDomain(self._context) + domain_name = _(domain.email_host) + contact_address = domain.contact_address + confirm_url = domain.confirm_url(token) + confirm_address = domain.confirm_address(token) + email_address = address + # Calculate the message's Subject header. XXX Have to deal with + # translating this subject header properly. XXX Must deal with + # VERP_CONFIRMATIONS as well. + subject = 'confirm ' + token + # Send a verification email to the address. + text = _(resource_string('mailman.templates.en', 'verify.txt')) + msg = UserNotification(address, confirm_address, subject, text) + msg.send(mlist=None) + return token + + def confirm(self, token): + """See `IUserRegistrar`.""" + # For convenience + pendable = config.db.pendings.confirm(token) + if pendable is None: + return False + missing = object() + address = pendable.get('address', missing) + real_name = pendable.get('real_name', missing) + list_name = pendable.get('list_name', missing) + if pendable.get('type') != PendableRegistration.PEND_KEY: + # It seems like it would be very difficult to accurately guess + # tokens, or brute force an attack on the SHA1 hash, so we'll just + # throw the pendable away in that case. It's possible we'll need + # to repend the event or adjust the API to handle this case + # better, but for now, the simpler the better. + return False + # We are going to end up with an IAddress for the verified address + # and an IUser linked to this IAddress. See if any of these objects + # currently exist in our database. + usermgr = config.db.user_manager + addr = (usermgr.get_address(address) + if address is not missing else None) + user = (usermgr.get_user(address) + if address is not missing else None) + # If there is neither an address nor a user matching the confirmed + # record, then create the user, which will in turn create the address + # and link the two together + if addr is None: + assert user is None, 'How did we get a user but not an address?' + user = usermgr.create_user(address, real_name) + # Because the database changes haven't been flushed, we can't use + # IUserManager.get_address() to find the IAddress just created + # under the hood. Instead, iterate through the IUser's addresses, + # of which really there should be only one. + for addr in user.addresses: + if addr.address == address: + break + else: + raise AssertionError('Could not find expected IAddress') + elif user is None: + user = usermgr.create_user() + user.real_name = real_name + user.link(addr) + else: + # The IAddress and linked IUser already exist, so all we need to + # do is verify the address. + pass + addr.verified_on = datetime.datetime.now() + # If this registration is tied to a mailing list, subscribe the person + # to the list right now. + list_name = pendable.get('list_name') + if list_name is not None: + mlist = config.db.list_manager.get(list_name) + if mlist: + addr.subscribe(mlist, MemberRole.member) + return True + + def discard(self, token): + # Throw the record away. + config.db.pendings.confirm(token) + + + +def adapt_domain_to_registrar(iface, obj): + """Adapt `IDomain` to `IRegistrar`. + + :param iface: The interface to adapt to. + :type iface: `zope.interface.Interface` + :param obj: The object being adapted. + :type obj: `IDomain` + :return: An `IRegistrar` instance if adaptation succeeded or None if it + didn't. + """ + return (Registrar(obj) + if IDomain.providedBy(obj) and iface is IRegistrar + else None) diff --git a/src/mailman/app/replybot.py b/src/mailman/app/replybot.py new file mode 100644 index 000000000..0537f6645 --- /dev/null +++ b/src/mailman/app/replybot.py @@ -0,0 +1,125 @@ +# 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/>. + +"""Application level auto-reply code.""" + +# XXX This should undergo a rewrite to move this functionality off of the +# mailing list. The reply governor should really apply site-wide per +# recipient (I think). + +from __future__ import unicode_literals + +__metaclass__ = type +__all__ = [ + 'autorespond_to_sender', + 'can_acknowledge', + ] + +import logging +import datetime + +from mailman import Utils +from mailman import i18n +from mailman.config import config + + +log = logging.getLogger('mailman.vette') +_ = i18n._ + + + +def autorespond_to_sender(mlist, sender, lang=None): + """Return True if Mailman should auto-respond to this sender. + + This is only consulted for messages sent to the -request address, or + for posting hold notifications, and serves only as a safety value for + mail loops with email 'bots. + """ + if lang is None: + lang = mlist.preferred_language + max_autoresponses_per_day = int(config.mta.max_autoresponses_per_day) + if max_autoresponses_per_day == 0: + # Unlimited. + return True + today = datetime.date.today() + info = mlist.hold_and_cmd_autoresponses.get(sender) + if info is None or info[0] <> today: + # This is the first time we've seen a -request/post-hold for this + # sender today. + mlist.hold_and_cmd_autoresponses[sender] = (today, 1) + return True + date, count = info + if count < 0: + # They've already hit the limit for today, and we've already notified + # them of this fact, so there's nothing more to do. + log.info('-request/hold autoresponse discarded for: %s', sender) + return False + if count >= max_autoresponses_per_day: + log.info('-request/hold autoresponse limit hit for: %s', sender) + mlist.hold_and_cmd_autoresponses[sender] = (today, -1) + # Send this notification message instead. + text = Utils.maketext( + 'nomoretoday.txt', + {'sender' : sender, + 'listname': mlist.fqdn_listname, + 'num' : count, + 'owneremail': mlist.owner_address, + }, + lang=lang) + with i18n.using_language(lang): + msg = Message.UserNotification( + sender, mlist.owner_address, + _('Last autoresponse notification for today'), + text, lang=lang) + msg.send(mlist) + return False + 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.keys(): + 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 |
