summaryrefslogtreecommitdiff
path: root/src/mailman/app
diff options
context:
space:
mode:
authorBarry Warsaw2009-01-25 13:01:41 -0500
committerBarry Warsaw2009-01-25 13:01:41 -0500
commiteefd06f1b88b8ecbb23a9013cd223b72ca85c20d (patch)
tree72c947fe16fce0e07e996ee74020b26585d7e846 /src/mailman/app
parent07871212f74498abd56bef3919bf3e029eb8b930 (diff)
downloadmailman-eefd06f1b88b8ecbb23a9013cd223b72ca85c20d.tar.gz
mailman-eefd06f1b88b8ecbb23a9013cd223b72ca85c20d.tar.zst
mailman-eefd06f1b88b8ecbb23a9013cd223b72ca85c20d.zip
Diffstat (limited to 'src/mailman/app')
-rw-r--r--src/mailman/app/__init__.py0
-rw-r--r--src/mailman/app/bounces.py63
-rw-r--r--src/mailman/app/commands.py44
-rw-r--r--src/mailman/app/lifecycle.py114
-rw-r--r--src/mailman/app/membership.py137
-rw-r--r--src/mailman/app/moderator.py351
-rw-r--r--src/mailman/app/notifications.py136
-rw-r--r--src/mailman/app/registrar.py163
-rw-r--r--src/mailman/app/replybot.py125
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