diff options
Diffstat (limited to '')
-rw-r--r-- | Mailman/MailList.py | 1346 |
1 files changed, 1346 insertions, 0 deletions
diff --git a/Mailman/MailList.py b/Mailman/MailList.py new file mode 100644 index 00000000..8cffef8c --- /dev/null +++ b/Mailman/MailList.py @@ -0,0 +1,1346 @@ +# Copyright (C) 1998,1999,2000,2001,2002 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + + +"""The class representing a Mailman mailing list. + +Mixes in many task-specific classes. +""" + +import sys +import os +import time +import marshal +import errno +import re +import shutil +import socket +import urllib +import cPickle + +from cStringIO import StringIO +from UserDict import UserDict +from urlparse import urlparse +from types import * + +import email.Iterators +from email.Utils import getaddresses, formataddr, parseaddr + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import Errors +from Mailman import LockFile +from Mailman.UserDesc import UserDesc + +# base classes +from Mailman.Archiver import Archiver +from Mailman.Autoresponder import Autoresponder +from Mailman.Bouncer import Bouncer +from Mailman.Deliverer import Deliverer +from Mailman.Digester import Digester +from Mailman.GatewayManager import GatewayManager +from Mailman.HTMLFormatter import HTMLFormatter +from Mailman.ListAdmin import ListAdmin +from Mailman.SecurityManager import SecurityManager +from Mailman.TopicMgr import TopicMgr + +# gui components package +from Mailman import Gui + +# other useful classes +from Mailman import MemberAdaptor +from Mailman.OldStyleMemberships import OldStyleMemberships +from Mailman import Message +from Mailman import Pending +from Mailman import Site +from Mailman.i18n import _ +from Mailman.Logging.Syslog import syslog + +EMPTYSTRING = '' + + + +# Use mixins here just to avoid having any one chunk be too large. +class MailList(HTMLFormatter, Deliverer, ListAdmin, + Archiver, Digester, SecurityManager, Bouncer, GatewayManager, + Autoresponder, TopicMgr): + + # + # A MailList object's basic Python object model support + # + def __init__(self, name=None, lock=1): + # No timeout by default. If you want to timeout, open the list + # unlocked, then lock explicitly. + # + # Only one level of mixin inheritance allowed + for baseclass in self.__class__.__bases__: + if hasattr(baseclass, '__init__'): + baseclass.__init__(self) + # Initialize volatile attributes + self.InitTempVars(name) + # Default membership adaptor class + self._memberadaptor = OldStyleMemberships(self) + if name: + if lock: + # This will load the database. + self.Lock() + else: + self.Load() + # This extension mechanism allows list-specific overrides of any + # method (well, except __init__(), InitTempVars(), and InitVars() + # I think). + filename = os.path.join(self.fullpath(), 'extend.py') + dict = {} + try: + execfile(filename, dict) + except IOError, e: + if e.errno <> errno.ENOENT: raise + else: + func = dict.get('extend') + if func: + func(self) + + def __getattr__(self, name): + # Because we're using delegation, we want to be sure that attribute + # access to a delegated member function gets passed to the + # sub-objects. This of course imposes a specific name resolution + # order. + try: + return getattr(self._memberadaptor, name) + except AttributeError: + for guicomponent in self._gui: + try: + return getattr(guicomponent, name) + except AttributeError: + pass + else: + raise AttributeError, name + + def __repr__(self): + if self.Locked(): + status = '(locked)' + else: + status = '(unlocked)' + return '<mailing list "%s" %s at %x>' % ( + self.internal_name(), status, id(self)) + + + # + # Lock management + # + def Lock(self, timeout=0): + self.__lock.lock(timeout) + # Must reload our database for consistency. Watch out for lists that + # don't exist. + try: + self.Load() + except Exception: + self.Unlock() + raise + + def Unlock(self): + self.__lock.unlock(unconditionally=1) + + def Locked(self): + return self.__lock.locked() + + + + # + # Useful accessors + # + def internal_name(self): + return self._internal_name + + def fullpath(self): + return self._full_path + + def getListAddress(self, extra=None): + if extra is None: + return '%s@%s' % (self.internal_name(), self.host_name) + return '%s-%s@%s' % (self.internal_name(), extra, self.host_name) + + # For backwards compatibility + def GetBouncesEmail(self): + return self.getListAddress('bounces') + + def GetOwnerEmail(self): + return self.getListAddress('owner') + + def GetRequestEmail(self): + return self.getListAddress('request') + + def GetConfirmEmail(self, cookie): + return mm_cfg.VERP_CONFIRM_FORMAT % { + 'addr' : '%s-confirm' % self.internal_name(), + 'cookie': cookie, + } + '@' + self.host_name + + def GetListEmail(self): + return self.getListAddress() + + def GetMemberAdminEmail(self, member): + """Usually the member addr, but modified for umbrella lists. + + Umbrella lists have other mailing lists as members, and so admin stuff + like confirmation requests and passwords must not be sent to the + member addresses - the sublists - but rather to the administrators of + the sublists. This routine picks the right address, considering + regular member address to be their own administrative addresses. + + """ + if not self.umbrella_list: + return member + else: + acct, host = tuple(member.split('@')) + return "%s%s@%s" % (acct, self.umbrella_member_suffix, host) + + def GetScriptURL(self, scriptname, absolute=0): + return Utils.ScriptURL(scriptname, self.web_page_url, absolute) + \ + '/' + self.internal_name() + + def GetOptionsURL(self, user, obscure=0, absolute=0): + url = self.GetScriptURL('options', absolute) + if obscure: + user = Utils.ObscureEmail(user) + return '%s/%s' % (url, urllib.quote(user.lower())) + + + # + # Instance and subcomponent initialization + # + def InitTempVars(self, name): + """Set transient variables of this and inherited classes.""" + # The timestamp is set whenever we load the state from disk. If our + # timestamp is newer than the modtime of the config.pck file, we don't + # need to reload, otherwise... we do. + self.__timestamp = 0 + self.__lock = LockFile.LockFile( + os.path.join(mm_cfg.LOCK_DIR, name or '<site>') + '.lock', + # TBD: is this a good choice of lifetime? + lifetime = mm_cfg.LIST_LOCK_LIFETIME, + withlogging = mm_cfg.LIST_LOCK_DEBUGGING) + self._internal_name = name + if name: + self._full_path = Site.get_listpath(name) + else: + self._full_path = None + # Only one level of mixin inheritance allowed + for baseclass in self.__class__.__bases__: + if hasattr(baseclass, 'InitTempVars'): + baseclass.InitTempVars(self) + # Now, initialize our gui components + self._gui = [] + for component in dir(Gui): + if component.startswith('_'): + continue + self._gui.append(getattr(Gui, component)()) + + def InitVars(self, name=None, admin='', crypted_password=''): + """Assign default values - some will be overriden by stored state.""" + # Non-configurable list info + if name: + self._internal_name = name + + # When was the list created? + self.created_at = time.time() + + # Must save this state, even though it isn't configurable + self.volume = 1 + self.members = {} # self.digest_members is initted in mm_digest + self.data_version = mm_cfg.DATA_FILE_VERSION + self.last_post_time = 0 + + self.post_id = 1. # A float so it never has a chance to overflow. + self.user_options = {} + self.language = {} + self.usernames = {} + self.passwords = {} + self.new_member_options = mm_cfg.DEFAULT_NEW_MEMBER_OPTIONS + + # This stuff is configurable + self.respond_to_post_requests = 1 + self.advertised = mm_cfg.DEFAULT_LIST_ADVERTISED + self.max_num_recipients = mm_cfg.DEFAULT_MAX_NUM_RECIPIENTS + self.max_message_size = mm_cfg.DEFAULT_MAX_MESSAGE_SIZE + # See the note in Defaults.py concerning DEFAULT_HOST_NAME + # vs. DEFAULT_EMAIL_HOST. + self.host_name = mm_cfg.DEFAULT_HOST_NAME or mm_cfg.DEFAULT_EMAIL_HOST + self.web_page_url = ( + mm_cfg.DEFAULT_URL or + mm_cfg.DEFAULT_URL_PATTERN % mm_cfg.DEFAULT_URL_HOST) + self.owner = [admin] + self.moderator = [] + self.reply_goes_to_list = mm_cfg.DEFAULT_REPLY_GOES_TO_LIST + self.reply_to_address = '' + self.first_strip_reply_to = mm_cfg.DEFAULT_FIRST_STRIP_REPLY_TO + self.admin_immed_notify = mm_cfg.DEFAULT_ADMIN_IMMED_NOTIFY + self.admin_notify_mchanges = \ + mm_cfg.DEFAULT_ADMIN_NOTIFY_MCHANGES + self.require_explicit_destination = \ + mm_cfg.DEFAULT_REQUIRE_EXPLICIT_DESTINATION + self.acceptable_aliases = mm_cfg.DEFAULT_ACCEPTABLE_ALIASES + self.umbrella_list = mm_cfg.DEFAULT_UMBRELLA_LIST + self.umbrella_member_suffix = \ + mm_cfg.DEFAULT_UMBRELLA_MEMBER_ADMIN_SUFFIX + self.send_reminders = mm_cfg.DEFAULT_SEND_REMINDERS + self.send_welcome_msg = mm_cfg.DEFAULT_SEND_WELCOME_MSG + self.send_goodbye_msg = mm_cfg.DEFAULT_SEND_GOODBYE_MSG + self.bounce_matching_headers = \ + mm_cfg.DEFAULT_BOUNCE_MATCHING_HEADERS + self.anonymous_list = mm_cfg.DEFAULT_ANONYMOUS_LIST + internalname = self.internal_name() + self.real_name = internalname[0].upper() + internalname[1:] + self.description = '' + self.info = '' + self.welcome_msg = '' + self.goodbye_msg = '' + self.subscribe_policy = mm_cfg.DEFAULT_SUBSCRIBE_POLICY + self.unsubscribe_policy = mm_cfg.DEFAULT_UNSUBSCRIBE_POLICY + self.private_roster = mm_cfg.DEFAULT_PRIVATE_ROSTER + self.obscure_addresses = mm_cfg.DEFAULT_OBSCURE_ADDRESSES + self.admin_member_chunksize = mm_cfg.DEFAULT_ADMIN_MEMBER_CHUNKSIZE + self.administrivia = mm_cfg.DEFAULT_ADMINISTRIVIA + self.preferred_language = mm_cfg.DEFAULT_SERVER_LANGUAGE + self.available_languages = [] + self.include_rfc2369_headers = 1 + self.include_list_post_header = 1 + self.filter_mime_types = mm_cfg.DEFAULT_FILTER_MIME_TYPES + self.pass_mime_types = mm_cfg.DEFAULT_PASS_MIME_TYPES + self.filter_content = mm_cfg.DEFAULT_FILTER_CONTENT + self.convert_html_to_plaintext = \ + mm_cfg.DEFAULT_CONVERT_HTML_TO_PLAINTEXT + self.filter_action = mm_cfg.DEFAULT_FILTER_ACTION + # Analogs to these are initted in Digester.InitVars + self.nondigestable = mm_cfg.DEFAULT_NONDIGESTABLE + self.personalize = 0 + # New sender-centric moderation (privacy) options + self.default_member_moderation = \ + mm_cfg.DEFAULT_DEFAULT_MEMBER_MODERATION + # Emergency moderation bit + self.emergency = 0 + # This really ought to default to mm_cfg.HOLD, but that doesn't work + # with the current GUI description model. So, 0==Hold, 1==Reject, + # 2==Discard + self.member_moderation_action = 0 + self.member_moderation_notice = '' + self.accept_these_nonmembers = [] + self.hold_these_nonmembers = [] + self.reject_these_nonmembers = [] + self.discard_these_nonmembers = [] + self.forward_auto_discards = mm_cfg.DEFAULT_FORWARD_AUTO_DISCARDS + self.generic_nonmember_action = mm_cfg.DEFAULT_GENERIC_NONMEMBER_ACTION + # Ban lists + self.ban_list = [] + # BAW: This should really be set in SecurityManager.InitVars() + self.password = crypted_password + # Max autoresponses per day. A mapping between addresses and a + # 2-tuple of the date of the last autoresponse and the number of + # autoresponses sent on that date. + self.hold_and_cmd_autoresponses = {} + + # Only one level of mixin inheritance allowed + for baseclass in self.__class__.__bases__: + if hasattr(baseclass, 'InitVars'): + baseclass.InitVars(self) + + # These need to come near the bottom because they're dependent on + # other settings. + self.subject_prefix = mm_cfg.DEFAULT_SUBJECT_PREFIX % self.__dict__ + self.msg_header = mm_cfg.DEFAULT_MSG_HEADER + self.msg_footer = mm_cfg.DEFAULT_MSG_FOOTER + # Set this to Never if the list's preferred language uses us-ascii, + # otherwise set it to As Needed + if Utils.GetCharSet(self.preferred_language) == 'us-ascii': + self.encode_ascii_prefixes = 0 + else: + self.encode_ascii_prefixes = 2 + + + # + # Web API support via administrative categories + # + def GetConfigCategories(self): + class CategoryDict(UserDict): + def __init__(self): + UserDict.__init__(self) + self.keysinorder = mm_cfg.ADMIN_CATEGORIES[:] + def keys(self): + return self.keysinorder + def items(self): + items = [] + for k in mm_cfg.ADMIN_CATEGORIES: + items.append((k, self.data[k])) + return items + def values(self): + values = [] + for k in mm_cfg.ADMIN_CATEGORIES: + values.append(self.data[k]) + return values + + categories = CategoryDict() + # Only one level of mixin inheritance allowed + for gui in self._gui: + k, v = gui.GetConfigCategory() + categories[k] = (v, gui) + return categories + + def GetConfigSubCategories(self, category): + for gui in self._gui: + if hasattr(gui, 'GetConfigSubCategories'): + # Return the first one that knows about the given subcategory + subcat = gui.GetConfigSubCategories(category) + if subcat is not None: + return subcat + return None + + def GetConfigInfo(self, category, subcat=None): + for gui in self._gui: + if hasattr(gui, 'GetConfigInfo'): + value = gui.GetConfigInfo(self, category, subcat) + if value: + return value + + + # + # List creation + # + def Create(self, name, admin, crypted_password, langs=None): + if Utils.list_exists(name): + raise Errors.MMListAlreadyExistsError, name + # Validate what will be the list's posting address. If that's + # invalid, we don't want to create the mailing list. The hostname + # part doesn't really matter, since that better already be valid. + # However, most scripts already catch MMBadEmailError as exceptions on + # the admin's email address, so transform the exception. + postingaddr = '%s@%s' % (name, mm_cfg.DEFAULT_EMAIL_HOST) + try: + Utils.ValidateEmail(postingaddr) + except Errors.MMBadEmailError: + raise Errors.BadListNameError, postingaddr + # Validate the admin's email address + Utils.ValidateEmail(admin) + self._internal_name = name + self._full_path = Site.get_listpath(name, create=1) + # Don't use Lock() since that tries to load the non-existant config.pck + self.__lock.lock() + self.InitVars(name, admin, crypted_password) + self.CheckValues() + if langs is None: + self.available_languages = [self.preferred_language] + else: + self.available_languages = langs + + + + # + # Database and filesystem I/O + # + def __save(self, dict): + # Save the file as a binary pickle, and rotate the old version to a + # backup file. We must guarantee that config.pck is always valid so + # we never rotate unless the we've successfully written the temp file. + # We use pickle now because marshal is not guaranteed to be compatible + # between Python versions. + fname = os.path.join(self.fullpath(), 'config.pck') + fname_tmp = fname + '.tmp.%s.%d' % (socket.gethostname(), os.getpid()) + fname_last = fname + '.last' + fp = None + try: + fp = open(fname_tmp, 'w') + # Use a binary format... it's more efficient. + cPickle.dump(dict, fp, 1) + fp.close() + except IOError, e: + syslog('error', + 'Failed config.pck write, retaining old state.\n%s', e) + if fp is not None: + os.unlink(fname_tmp) + raise + # Now do config.pck.tmp.xxx -> config.pck -> config.pck.last rotation + # as safely as possible. + try: + # might not exist yet + os.unlink(fname_last) + except OSError, e: + if e.errno <> errno.ENOENT: raise + try: + # might not exist yet + os.link(fname, fname_last) + except OSError, e: + if e.errno <> errno.ENOENT: raise + os.rename(fname_tmp, fname) + # Reset the timestamp + self.__timestamp = os.path.getmtime(fname) + + def Save(self): + # Refresh the lock, just to let other processes know we're still + # interested in it. This will raise a NotLockedError if we don't have + # the lock (which is a serious problem!). TBD: do we need to be more + # defensive? + self.__lock.refresh() + # copy all public attributes to serializable dictionary + dict = {} + for key, value in self.__dict__.items(): + if key[0] == '_' or type(value) is MethodType: + continue + dict[key] = value + # Make config.pck unreadable by `other', as it contains all the + # list members' passwords (in clear text). + omask = os.umask(007) + try: + self.__save(dict) + finally: + os.umask(omask) + self.SaveRequestsDb() + self.CheckHTMLArchiveDir() + + def __load(self, dbfile): + # Attempt to load and unserialize the specified database file. This + # could actually be a config.db (for pre-2.1alpha3) or config.pck, + # i.e. a marshal or a binary pickle. Actually, it could also be a + # .last backup file if the primary storage file was corrupt. The + # decision on whether to unpickle or unmarshal is based on the file + # extension, but we always save it using pickle (since only it, and + # not marshal is guaranteed to be compatible across Python versions). + # + # On success return a 2-tuple of (dictionary, None). On error, return + # a 2-tuple of the form (None, errorobj). + if dbfile.endswith('.db') or dbfile.endswith('.db.last'): + loadfunc = marshal.load + elif dbfile.endswith('.pck') or dbfile.endswith('.pck.last'): + loadfunc = cPickle.load + else: + assert 0, 'Bad database file name' + try: + # Check the mod time of the file first. If it matches our + # timestamp, then the state hasn't change since the last time we + # loaded it. Otherwise open the file for loading, below. If the + # file doesn't exist, we'll get an EnvironmentError with errno set + # to ENOENT (EnvironmentError is the base class of IOError and + # OSError). + mtime = os.path.getmtime(dbfile) + if mtime <= self.__timestamp: + # File is not newer + return None, None + fp = open(dbfile) + except EnvironmentError, e: + if e.errno <> errno.ENOENT: raise + # The file doesn't exist yet + return None, e + try: + try: + dict = loadfunc(fp) + if type(dict) <> DictType: + return None, 'Load() expected to return a dictionary' + except (EOFError, ValueError, TypeError, MemoryError, + cPickle.PicklingError), e: + return None, e + finally: + fp.close() + # Update timestamp + self.__timestamp = mtime + return dict, None + + def Load(self, check_version=1): + if not Utils.list_exists(self.internal_name()): + raise Errors.MMUnknownListError + # We first try to load config.pck, which contains the up-to-date + # version of the database. If that fails, perhaps because it's + # corrupted or missing, we'll try to load the backup file + # config.pck.last. + # + # Should both of those fail, we'll look for config.db and + # config.db.last for backwards compatibility with pre-2.1alpha3 + pfile = os.path.join(self.fullpath(), 'config.pck') + plast = pfile + '.last' + dfile = os.path.join(self.fullpath(), 'config.db') + dlast = dfile + '.last' + for file in (pfile, plast, dfile, dlast): + dict, e = self.__load(file) + if dict is None: + if e is not None: + # Had problems with this file; log it and try the next one. + syslog('error', "couldn't load config file %s\n%s", + file, e) + else: + # We already have the most up-to-date state + return + else: + break + else: + # Nothing worked, so we have to give up + syslog('error', 'All %s fallbacks were corrupt, giving up', + self.internal_name()) + raise Errors.MMCorruptListDatabaseError, e + # Now, if we didn't end up using the primary database file, we want to + # copy the fallback into the primary so that the logic in Save() will + # still work. For giggles, we'll copy it to a safety backup. + if file == plast: + shutil.copy(file, pfile) + shutil.copy(file, pfile + '.safety') + elif file == dlast: + shutil.copy(file, dfile) + shutil.copy(file, pfile + '.safety') + # Copy the loaded dictionary into the attributes of the current + # mailing list object, then run sanity check on the data. + self.__dict__.update(dict) + if check_version: + self.CheckVersion(dict) + self.CheckValues() + + + # + # Sanity checks + # + def CheckVersion(self, stored_state): + """Auto-update schema if necessary.""" + if self.data_version >= mm_cfg.DATA_FILE_VERSION: + return + # Initialize any new variables + self.InitVars() + # Then reload the database (but don't recurse). Force a reload even + # if we have the most up-to-date state. + self.__timestamp = 0 + self.Load(check_version=0) + # We must hold the list lock in order to update the schema + waslocked = self.Locked() + if not waslocked: + self.Lock() + try: + from versions import Update + Update(self, stored_state) + self.data_version = mm_cfg.DATA_FILE_VERSION + self.Save() + finally: + if not waslocked: + self.Unlock() + + def CheckValues(self): + """Normalize selected values to known formats.""" + if '' in urlparse(self.web_page_url)[:2]: + # Either the "scheme" or the "network location" part of the parsed + # URL is empty; substitute faulty value with (hopefully sane) + # default. Note that DEFAULT_URL is obsolete. + self.web_page_url = ( + mm_cfg.DEFAULT_URL or + mm_cfg.DEFAULT_URL_PATTERN % mm_cfg.DEFAULT_URL_HOST) + if self.web_page_url and self.web_page_url[-1] <> '/': + self.web_page_url = self.web_page_url + '/' + # Legacy reply_to_address could be an illegal value. We now verify + # upon setting and don't check it at the point of use. + try: + if self.reply_to_address.strip() and self.reply_goes_to_list: + Utils.ValidateEmail(self.reply_to_address) + except Errors.EmailAddressError: + syslog('error', 'Bad reply_to_address "%s" cleared for list: %s', + self.reply_to_address, self.internal_name()) + self.reply_to_address = '' + self.reply_goes_to_list = 0 + # Legacy topics may have bad regular expressions in their patterns + goodtopics = [] + for name, pattern, desc, emptyflag in self.topics: + try: + re.compile(pattern) + except (re.error, TypeError): + syslog('error', 'Bad topic pattern "%s" for list: %s', + pattern, self.internal_name()) + else: + goodtopics.append((name, pattern, desc, emptyflag)) + self.topics = goodtopics + + + # + # Membership management front-ends and assertion checks + # + def InviteNewMember(self, userdesc, text=''): + """Invite a new member to the list. + + This is done by creating a subscription pending for the user, and then + crafting a message to the member informing them of the invitation. + """ + invitee = userdesc.address + requestaddr = self.GetRequestEmail() + # Hack alert! Squirrel away a flag that only invitations have, so + # that we can do something slightly different when an invitation + # subscription is confirmed. In those cases, we don't need further + # admin approval, even if the list is so configured + userdesc.invitation = 1 + cookie = Pending.new(Pending.SUBSCRIPTION, userdesc) + confirmurl = '%s/%s' % (self.GetScriptURL('confirm', absolute=1), + cookie) + listname = self.real_name + text += Utils.maketext( + 'invite.txt', + {'email' : invitee, + 'listname' : listname, + 'hostname' : self.host_name, + 'confirmurl' : confirmurl, + 'requestaddr': requestaddr, + 'cookie' : cookie, + 'listowner' : self.GetOwnerEmail(), + }, mlist=self) + if mm_cfg.VERP_CONFIRMATIONS: + subj = _( + 'You have been invited to join the %(listname)s mailing list') + sender = self.GetConfirmEmail(cookie) + else: + # Do it the old fashioned way + subj = 'confirm ' + cookie + sender = requestaddr + msg = Message.UserNotification( + invitee, sender, subj, + text, lang=self.preferred_language) + msg.send(self) + + def AddMember(self, userdesc, remote=None): + """Front end to member subscription. + + This method enforces subscription policy, validates values, sends + notifications, and any other grunt work involved in subscribing a + user. It eventually calls ApprovedAddMember() to do the actual work + of subscribing the user. + + userdesc is an instance with the following public attributes: + + address -- the unvalidated email address of the member + fullname -- the member's full name (i.e. John Smith) + digest -- a flag indicating whether the user wants digests or not + language -- the requested default language for the user + password -- the user's password + + Other attributes may be defined later. Only address is required; the + others all have defaults (fullname='', digests=0, language=list's + preferred language, password=generated). + + remote is a string which describes where this add request came from. + """ + assert self.Locked() + # Suck values out of userdesc, apply defaults, and reset the userdesc + # attributes (for passing on to ApprovedAddMember()). Lowercase the + # addr's domain part. + email = Utils.LCDomain(userdesc.address) + name = getattr(userdesc, 'fullname', '') + lang = getattr(userdesc, 'language', self.preferred_language) + digest = getattr(userdesc, 'digest', None) + password = getattr(userdesc, 'password', Utils.MakeRandomPassword()) + if digest is None: + if self.nondigestable: + digest = 0 + else: + digest = 1 + # Validate the e-mail address to some degree. + Utils.ValidateEmail(email) + if self.isMember(email): + raise Errors.MMAlreadyAMember, email + if email.lower() == self.GetListEmail().lower(): + # Trying to subscribe the list to itself! + raise Errors.MMBadEmailError + + # Is the subscribing address banned from this list? + ban = 0 + for pattern in self.ban_list: + if pattern.startswith('^'): + # This is a regular expression match + try: + if re.search(pattern, email, re.IGNORECASE): + ban = 1 + break + except re.error: + # BAW: we should probably remove this pattern + pass + else: + # Do the comparison case insensitively + if pattern.lower() == email.lower(): + ban = 1 + break + if ban: + syslog('vette', 'banned subscription: %s (matched: %s)', + email, pattern) + raise Errors.MembershipIsBanned, pattern + + # Sanity check the digest flag + if digest and not self.digestable: + raise Errors.MMCantDigestError + elif not digest and not self.nondigestable: + raise Errors.MMMustDigestError + + userdesc.address = email + userdesc.fullname = name + userdesc.digest = digest + userdesc.language = lang + userdesc.password = password + + # Apply the list's subscription policy. 0 means open subscriptions; 1 + # means the user must confirm; 2 means the admin must approve; 3 means + # the user must confirm and then the admin must approve + if self.subscribe_policy == 0: + self.ApprovedAddMember(userdesc) + elif self.subscribe_policy == 1 or self.subscribe_policy == 3: + # User confirmation required. BAW: this should probably just + # accept a userdesc instance. + cookie = Pending.new(Pending.SUBSCRIPTION, userdesc) + # Send the user the confirmation mailback + if remote is None: + by = remote = '' + else: + by = ' ' + remote + remote = _(' from %(remote)s') + + recipient = self.GetMemberAdminEmail(email) + realname = self.real_name + confirmurl = '%s/%s' % (self.GetScriptURL('confirm', absolute=1), + cookie) + text = Utils.maketext( + 'verify.txt', + {'email' : email, + 'listaddr' : self.GetListEmail(), + 'listname' : realname, + 'cookie' : cookie, + 'requestaddr' : self.GetRequestEmail(), + 'remote' : remote, + 'listadmin' : self.GetOwnerEmail(), + 'confirmurl' : confirmurl, + }, lang=lang, mlist=self) + msg = Message.UserNotification( + recipient, self.GetRequestEmail(), + text=text, lang=lang) + # BAW: See ChangeMemberAddress() for why we do it this way... + del msg['subject'] + msg['Subject'] = 'confirm ' + cookie + msg['Reply-To'] = self.GetRequestEmail() + msg.send(self) + who = formataddr((name, email)) + syslog('subscribe', '%s: pending %s %s', + self.internal_name(), who, by) + raise Errors.MMSubscribeNeedsConfirmation + else: + # Subscription approval is required. Add this entry to the admin + # requests database. BAW: this should probably take a userdesc + # just like above. + self.HoldSubscription(email, name, password, digest, lang) + raise Errors.MMNeedApproval, _( + 'subscriptions to %(realname)s require moderator approval') + + def ApprovedAddMember(self, userdesc, ack=None, admin_notif=None, text=''): + """Add a member right now. + + The member's subscription must be approved by what ever policy the + list enforces. + + userdesc is as above in AddMember(). + + ack is a flag that specifies whether the user should get an + acknowledgement of their being subscribed. Default is to use the + list's default flag value. + + admin_notif is a flag that specifies whether the list owner should get + an acknowledgement of this subscription. Default is to use the list's + default flag value. + """ + assert self.Locked() + # Set up default flag values + if ack is None: + ack = self.send_welcome_msg + if admin_notif is None: + admin_notif = self.admin_notify_mchanges + # Suck values out of userdesc, and apply defaults. + email = Utils.LCDomain(userdesc.address) + name = getattr(userdesc, 'fullname', '') + lang = getattr(userdesc, 'language', self.preferred_language) + digest = getattr(userdesc, 'digest', None) + password = getattr(userdesc, 'password', Utils.MakeRandomPassword()) + if digest is None: + if self.nondigestable: + digest = 0 + else: + digest = 1 + # Let's be extra cautious + Utils.ValidateEmail(email) + if self.isMember(email): + raise Errors.MMAlreadyAMember, email + # Do the actual addition + self.addNewMember(email, realname=name, digest=digest, + password=password, language=lang) + self.setMemberOption(email, mm_cfg.DisableMime, + 1 - self.mime_is_default_digest) + self.setMemberOption(email, mm_cfg.Moderate, + self.default_member_moderation) + # Now send and log results + if digest: + kind = ' (digest)' + else: + kind = '' + syslog('subscribe', '%s: new%s %s', self.internal_name(), + kind, formataddr((email, name))) + if ack: + self.SendSubscribeAck(email, self.getMemberPassword(email), + digest, text) + if admin_notif: + realname = self.real_name + subject = _('%(realname)s subscription notification') + text = Utils.maketext( + "adminsubscribeack.txt", + {"listname" : self.real_name, + "member" : formataddr((name, email)), + }, mlist=self) + msg = Message.OwnerNotification(self, subject, text) + msg.send(self) + + def DeleteMember(self, name, whence=None, admin_notif=0, userack=1): + realname, email = parseaddr(name) + if self.unsubscribe_policy == 0: + self.ApprovedDeleteMember(name, whence, admin_notif, userack) + else: + self.HoldUnsubscription(email) + raise Errors.MMNeedApproval, _( + 'unsubscriptions require moderator approval') + + def ApprovedDeleteMember(self, name, whence=None, + admin_notif=None, userack=None): + if userack is None: + userack = self.send_goodbye_msg + if admin_notif is None: + admin_notif = self.admin_notify_mchanges + # Delete a member, for which we know the approval has been made + fullname, emailaddr = parseaddr(name) + userlang = self.getMemberLanguage(emailaddr) + # Remove the member + self.removeMember(emailaddr) + # And send an acknowledgement to the user... + if userack: + self.SendUnsubscribeAck(emailaddr, userlang) + # ...and to the administrator + if admin_notif: + realname = self.real_name + subject = _('%(realname)s unsubscribe notification') + text = Utils.maketext( + 'adminunsubscribeack.txt', + {'member' : name, + 'listname': self.real_name, + }, mlist=self) + msg = Message.OwnerNotification(self, subject, text) + msg.send(self) + if whence: + whence = "; %s" % whence + else: + whence = "" + syslog('subscribe', '%s: deleted %s%s', + self.internal_name(), name, whence) + + def ChangeMemberName(self, addr, name, globally): + self.setMemberName(addr, name) + if not globally: + return + for listname in Utils.list_names(): + # Don't bother with ourselves + if listname == self.internal_name(): + continue + mlist = MailList(listname, lock=0) + if mlist.host_name <> self.host_name: + continue + if not mlist.isMember(addr): + continue + mlist.Lock() + try: + mlist.setMemberName(addr, name) + mlist.Save() + finally: + mlist.Unlock() + + def ChangeMemberAddress(self, oldaddr, newaddr, globally): + # Changing a member address consists of verifying the new address, + # making sure the new address isn't already a member, and optionally + # going through the confirmation process. + # + # Most of these checks are copied from AddMember + newaddr = Utils.LCDomain(newaddr) + Utils.ValidateEmail(newaddr) + # Raise an exception if this email address is already a member of the + # list, but only if the new address is the same case-wise as the old + # address. + if newaddr == oldaddr and self.isMember(newaddr): + raise Errors.MMAlreadyAMember + if newaddr == self.GetListEmail().lower(): + raise Errors.MMBadEmailError + # Pend the subscription change + cookie = Pending.new(Pending.CHANGE_OF_ADDRESS, + oldaddr, newaddr, globally) + confirmurl = '%s/%s' % (self.GetScriptURL('confirm', absolute=1), + cookie) + realname = self.real_name + lang = self.getMemberLanguage(oldaddr) + text = Utils.maketext( + 'verify.txt', + {'email' : newaddr, + 'listaddr' : self.GetListEmail(), + 'listname' : realname, + 'cookie' : cookie, + 'requestaddr': self.GetRequestEmail(), + 'remote' : '', + 'listadmin' : self.GetOwnerEmail(), + 'confirmurl' : confirmurl, + }, lang=lang, mlist=self) + # BAW: We don't pass the Subject: into the UserNotification + # constructor because it will encode it in the charset of the language + # being used. For non-us-ascii charsets, this means it will probably + # quopri quote it, and thus replies will also be quopri encoded. But + # CommandRunner doesn't yet grok such headers. So, just set the + # Subject: in a separate step, although we have to delete the one + # UserNotification adds. + msg = Message.UserNotification( + newaddr, self.GetRequestEmail(), + text=text, lang=lang) + del msg['subject'] + msg['Subject'] = 'confirm ' + cookie + msg['Reply-To'] = self.GetRequestEmail() + msg.send(self) + + def ApprovedChangeMemberAddress(self, oldaddr, newaddr, globally): + # Change the membership for the current list first. We don't lock and + # save ourself since we assume that the list is already locked. + if self.isMember(newaddr): + # Just delete the old address + if self.isMember(oldaddr): + self.ApprovedDeleteMember(oldaddr, admin_notif=1, userack=1) + else: + self.changeMemberAddress(oldaddr, newaddr) + # If globally is true, then we also include every list for which + # oldaddr is a member. + if not globally: + return + for listname in Utils.list_names(): + # Don't bother with ourselves + if listname == self.internal_name(): + continue + mlist = MailList(listname, lock=0) + if mlist.host_name <> self.host_name: + continue + if not mlist.isMember(oldaddr) or mlist.isMember(newaddr): + continue + mlist.Lock() + try: + mlist.changeMemberAddress(oldaddr, newaddr) + mlist.Save() + finally: + mlist.Unlock() + + + # + # Confirmation processing + # + def ProcessConfirmation(self, cookie, context=None): + data = Pending.confirm(cookie) + if data is None: + raise Errors.MMBadConfirmation, 'data is None' + try: + op = data[0] + data = data[1:] + except ValueError: + raise Errors.MMBadConfirmation, 'op-less data %s' % (data,) + if op == Pending.SUBSCRIPTION: + try: + userdesc = data[0] + # If confirmation comes from the web, context should be a + # UserDesc instance which contains overrides of the original + # subscription information. If it comes from email, then + # context is a Message and isn't relevant, so ignore it. + if isinstance(context, UserDesc): + userdesc += context + addr = userdesc.address + fullname = userdesc.fullname + password = userdesc.password + digest = userdesc.digest + lang = userdesc.language + except ValueError: + raise Errors.MMBadConfirmation, 'bad subscr data %s' % (data,) + # Hack alert! Was this a confirmation of an invitation? + invitation = getattr(userdesc, 'invitation', 0) + # We check for both 2 (approval required) and 3 (confirm + + # approval) because the policy could have been changed in the + # middle of the confirmation dance. + if not invitation and self.subscribe_policy in (2, 3): + self.HoldSubscription(addr, fullname, password, digest, lang) + name = self.real_name + raise Errors.MMNeedApproval, _( + 'subscriptions to %(name)s require administrator approval') + self.ApprovedAddMember(userdesc) + return op, addr, password, digest, lang + elif op == Pending.UNSUBSCRIPTION: + addr = data[0] + # Log file messages don't need to be i18n'd + if isinstance(context, Message.Message): + whence = 'email confirmation' + else: + whence = 'web confirmation' + # Can raise NotAMemberError if they unsub'd via other means + self.ApprovedDeleteMember(addr, whence=whence) + return op, addr + elif op == Pending.CHANGE_OF_ADDRESS: + oldaddr, newaddr, globally = data + self.ApprovedChangeMemberAddress(oldaddr, newaddr, globally) + return op, oldaddr, newaddr + elif op == Pending.HELD_MESSAGE: + id = data[0] + approved = None + # Confirmation should be coming from email, where context should + # be the confirming message. If the message does not have an + # Approved: header, this is a discard, otherwise it's an approval + # (if the passwords match). + if isinstance(context, Message.Message): + # See if it's got an Approved: header, either in the headers, + # or in the first text/plain section of the response. For + # robustness, we'll accept Approve: as well. + approved = context.get('Approved', context.get('Approve')) + if not approved: + try: + subpart = list(email.Iterators.typed_subpart_iterator( + context, 'text', 'plain'))[0] + except IndexError: + subpart = None + if subpart: + s = StringIO(subpart.get_payload()) + while 1: + line = s.readline() + if not line: + break + if not line.strip(): + continue + i = line.find(':') + if i > 0: + if (line[:i].lower() == 'approve' or + line[:i].lower() == 'approved'): + # then + approved = line[i+1:].strip() + break + # Okay, does the approved header match the list password? + if approved and self.Authenticate([mm_cfg.AuthListAdmin, + mm_cfg.AuthListModerator], + approved) <> mm_cfg.UnAuthorized: + action = mm_cfg.APPROVE + else: + action = mm_cfg.DISCARD + try: + self.HandleRequest(id, action) + except KeyError: + # Most likely because the message has already been disposed of + # via the admindb page. + syslog('error', 'Could not process HELD_MESSAGE: %s', id) + return (op,) + elif op == Pending.RE_ENABLE: + member = data[1] + self.setDeliveryStatus(member, MemberAdaptor.ENABLED) + return op, member + + def ConfirmUnsubscription(self, addr, lang=None, remote=None): + if lang is None: + lang = self.getMemberLanguage(addr) + cookie = Pending.new(Pending.UNSUBSCRIPTION, addr) + confirmurl = '%s/%s' % (self.GetScriptURL('confirm', absolute=1), + cookie) + realname = self.real_name + if remote is not None: + by = " " + remote + remote = _(" from %(remote)s") + else: + by = "" + remote = "" + text = Utils.maketext( + 'unsub.txt', + {'email' : addr, + 'listaddr' : self.GetListEmail(), + 'listname' : realname, + 'cookie' : cookie, + 'requestaddr' : self.GetRequestEmail(), + 'remote' : remote, + 'listadmin' : self.GetOwnerEmail(), + 'confirmurl' : confirmurl, + }, lang=lang, mlist=self) + msg = Message.UserNotification( + addr, self.GetRequestEmail(), + text=text, lang=lang) + # BAW: See ChangeMemberAddress() for why we do it this way... + del msg['subject'] + msg['Subject'] = 'confirm ' + cookie + msg['Reply-To'] = self.GetRequestEmail() + msg.send(self) + + + # + # Miscellaneous stuff + # + def HasExplicitDest(self, msg): + """True if list name or any acceptable_alias is included among the + to or cc addrs.""" + # BAW: fall back to Utils.ParseAddr if the first test fails. + # this is the list's full address + listfullname = '%s@%s' % (self.internal_name(), self.host_name) + recips = [] + # check all recipient addresses against the list's explicit addresses, + # specifically To: Cc: and Resent-to: + to = [] + for header in ('to', 'cc', 'resent-to', 'resent-cc'): + to.extend(getaddresses(msg.get_all(header, []))) + for fullname, addr in to: + # It's possible that if the header doesn't have a valid + # (i.e. RFC822) value, we'll get None for the address. So skip + # it. + if addr is None: + continue + addr = addr.lower() + localpart = addr.split('@')[0] + if (# TBD: backwards compatibility: deprecated + localpart == self.internal_name() or + # exact match against the complete list address + addr == listfullname): + return 1 + recips.append((addr, localpart)) + # + # helper function used to match a pattern against an address. Do it + def domatch(pattern, addr): + try: + if re.match(pattern, addr): + return 1 + except re.error: + # The pattern is a malformed regexp -- try matching safely, + # with all non-alphanumerics backslashed: + if re.match(re.escape(pattern), addr): + return 1 + # + # Here's the current algorithm for matching acceptable_aliases: + # + # 1. If the pattern does not have an `@' in it, we first try matching + # it against just the localpart. This was the behavior prior to + # 2.0beta3, and is kept for backwards compatibility. + # (deprecated). + # + # 2. If that match fails, or the pattern does have an `@' in it, we + # try matching against the entire recip address. + for addr, localpart in recips: + for alias in self.acceptable_aliases.split('\n'): + stripped = alias.strip() + if not stripped: + # ignore blank or empty lines + continue + if '@' not in stripped and domatch(stripped, localpart): + return 1 + if domatch(stripped, addr): + return 1 + return 0 + + def parse_matching_header_opt(self): + """Return a list of triples [(field name, regex, line), ...].""" + # - Blank lines and lines with '#' as first char are skipped. + # - Leading whitespace in the matchexp is trimmed - you can defeat + # that by, eg, containing it in gratuitous square brackets. + all = [] + for line in self.bounce_matching_headers.split('\n'): + line = line.strip() + # Skip blank lines and lines *starting* with a '#'. + if not line or line[0] == "#": + continue + i = line.find(':') + if i < 0: + # This didn't look like a header line. BAW: should do a + # better job of informing the list admin. + syslog('config', 'bad bounce_matching_header line: %s\n%s', + self.real_name, line) + else: + header = line[:i] + value = line[i+1:].lstrip() + try: + cre = re.compile(value, re.IGNORECASE) + except re.error, e: + # The regexp was malformed. BAW: should do a better + # job of informing the list admin. + syslog('config', '''\ +bad regexp in bounce_matching_header line: %s +\n%s (cause: %s)''', self.real_name, value, e) + else: + all.append((header, cre, line)) + return all + + def hasMatchingHeader(self, msg): + """Return true if named header field matches a regexp in the + bounce_matching_header list variable. + + Returns constraint line which matches or empty string for no + matches. + """ + for header, cre, line in self.parse_matching_header_opt(): + for value in msg.get_all(header, []): + if cre.search(value): + return line + return 0 + + def autorespondToSender(self, sender): + """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. + """ + # No limit + if mm_cfg.MAX_AUTORESPONSES_PER_DAY == 0: + return 1 + today = time.localtime()[:3] + info = self.hold_and_cmd_autoresponses.get(sender) + if info is None or info[0] <> today: + # First time we've seen a -request/post-hold for this sender + self.hold_and_cmd_autoresponses[sender] = (today, 1) + # BAW: no check for MAX_AUTORESPONSES_PER_DAY <= 1 + return 1 + date, count = info + if count < 0: + # They've already hit the limit for today. + syslog('vette', '-request/hold autoresponse discarded for: %s', + sender) + return 0 + if count >= mm_cfg.MAX_AUTORESPONSES_PER_DAY: + syslog('vette', '-request/hold autoresponse limit hit for: %s', + sender) + self.hold_and_cmd_autoresponses[sender] = (today, -1) + # Send this notification message instead + text = Utils.maketext( + 'nomoretoday.txt', + {'sender' : sender, + 'listname': '%s@%s' % (self.real_name, self.host_name), + 'num' : count, + 'owneremail': self.GetOwnerEmail(), + }) + msg = Message.UserNotification( + sender, self.GetOwnerEmail(), + _('Last autoresponse notification for today'), + text) + msg.send(self) + return 0 + self.hold_and_cmd_autoresponses[sender] = (today, count+1) + return 1 + + + + # + # Multilingual (i18n) support + # + def GetAvailableLanguages(self): + langs = self.available_languages + # If we don't add this, and the site admin has never added any + # language support to the list, then the general admin page may have a + # blank field where the list owner is supposed to chose the list's + # preferred language. + if mm_cfg.DEFAULT_SERVER_LANGUAGE not in langs: + langs.append(mm_cfg.DEFAULT_SERVER_LANGUAGE) + return langs |