diff --git a/Mailman/Queue/LMTPRunner.py b/Mailman/Queue/LMTPRunner.py new file mode 100644 index 0000000000000000000000000000000000000000..b2fdde02c1e7ac8989d76b462ded3437942c04db --- /dev/null +++ b/Mailman/Queue/LMTPRunner.py @@ -0,0 +1,490 @@ +# Copyright (C) 2006-2008 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Mailman LMTP runner (server). + +Most mail servers can be configured to deliver local messages via 'LMTP'[1]. +This module is actually an LMTP server rather than a standard queue runner. + +The LMTP runner opens a local TCP port and waits for the mail server to +connect to it. The messages it receives over LMTP are very minimally parsed +for sanity and if they look okay, they are accepted and injected into +Mailman's incoming queue for normal processing. If they don't look good, or +are destined for a bogus sub-address, they are rejected right away, hopefully +so that the peer mail server can provide better diagnostics. + +[1] RFC 2033 Local Mail Transport Protocol + http://www.faqs.org/rfcs/rfc2033.html""" + + +import os +import email +import smtpd +import logging +import asyncore +import asynchat +import socket + + +from Mailman.Message import Message +from Mailman import mm_cfg +from Mailman.Queue.Runner import Runner +from Mailman.Queue.Switchboard import Switchboard +from Mailman.Handlers.Moderate import matches_p +from Mailman import Utils +from Mailman.Utils import ValidateEmail +from Mailman.Utils import list_exists +from Mailman.Utils import get_domain +from Mailman import MemberAdaptor +from Mailman.OldStyleMemberships import OldStyleMemberships +from Mailman import MailList +from email.Utils import parseaddr +from email._parseaddr import AddressList as _AddressList + + +# We only care about the listname and the sub-addresses as in listname@ or listname-request@ +SUBADDRESS_NAMES = ( + 'bounces', 'confirm', 'join', 'leave', + 'owner', 'request', 'subscribe', 'unsubscribe', + ) + +EMPTYSTRING = '' +NEWLINE = '\n' +DASH = '-' +CRLF = '\r\n' +# These error codes are now obsolete. The LMTP protocol now uses enhanced error codes +#ERR_451 = '451 Requested action aborted: error in processing' +#ERR_501 = '501 Message has defects' +#ERR_502 = '502 Error: command HELO not implemented' +#ERR_550 = config.LMTP_ERR_550 + +# Enhanced error codes +EERR_200 = '2.0.0' +EERR_450 = '4.5.0 Other or undefined protocol status' +EERR_511 = '5.1.1 Bad destination mailbox address' +EERR_513 = '5.1.3 Bad destination list address syntax' +EERR_551 = '5.5.1 Invalid command' +EERR_554 = '5.5.4 Invalid command arguments' +EERR_572 = '5.7.2 The sender is not authorized to send a message to the intended mailing list' + + +# XXX Blech +__version__ = 'Python LMTP queue runner 1.1' + + + +def split_recipient(address): + """Split an address into listname, subaddress and domain parts. + + For example: + + >>> split_recipient('mylist@example.com') + ('mylist', None, 'example.com') + + >>> split_recipient('mylist-request@example.com') + ('mylist', 'request', 'example.com') + + :param address: The destination address. + :return: A 3-tuple of the form (list-shortname, subaddress, domain). + subaddress may be None if this is the list's posting address. + """ + localpart, domain = address.split('@', 1) + localpart = localpart.split('+', 1)[0] + parts = localpart.split(DASH) + if parts[-1] in SUBADDRESS_NAMES: + listname = DASH.join(parts[:-1]) + subaddress = parts[-1] + else: + listname = localpart + subaddress = None + return listname, subaddress, domain + + +class Channel(asynchat.async_chat): + """An LMTP channel.""" + # The LMTP channel is not dependent on the SMTP channel found in Python smtpd, + # It is a complete working LMTP channel, based on smtpd in Python + + COMMAND = 0 + DATA = 1 + # The LHLO boolean determines if the LHLO command has been used or not during a session + # False = LHLO has not been used + # True = LHLO has been used + LHLO = False + + # Don't forget to add: + # QRUNNERS.extend([('LMTPRunner', 1),]) + # to mm_cfg so that the LMTP queue runner gets started with the other queue runners + + def __init__(self, server, conn, addr): + asynchat.async_chat.__init__(self, conn) + self._server = server + self._conn = conn + self._addr = addr + self._line = [] + self._state = self.COMMAND + self._greeting = 0 + self._mailfrom = None + self._rcpttos = [] + self._data = '' + self._fqdn = socket.getfqdn() + self._peer = conn.getpeername() + self.set_terminator('\r\n') + self.push('220 %s %s' % (self._fqdn, __version__)) + + + # smtp_HELO pushs an error if the HELO command is used + def smtp_HELO(self, arg): + self.push('501 '+EERR_551+' Use: LHLO command') + return + + # smtp_EHLO pushs an error if the EHLO command is used + def smtp_EHLO(self, arg): + self.push('501 '+EERR_551+' Use: LHLO command') + return + + def smtp_LHLO(self, arg): + """HELO is not a valid LMTP command.""" + if not arg: + self.push('501 '+EERR_554+' Syntax: lhlo hostname') + return + if self._greeting: + self.push('503 '+EERR_551+' Duplicate LHLO') + else: + self.LHLO = True + self._greeting = arg + # Don't forget '-' after the status code on each line + # except for the last line, if there is a multiline response + self.push('250-%s' % self._fqdn) + # Use following line when Pipelining is supported + #self.push('250-PIPELINING') + self.push('250 ENHANCEDSTATUSCODES') + + def smtp_MAIL(self, arg): + # RFC 2033 requires the client to say LHLO to the server before mail can be sent + if self.LHLO == False: + self.push('503 '+EERR_551+' Need LHLO command') + return + address = self._getaddr('FROM:', arg) if arg else None + if not address: + self.push('501 '+EERR_554+' Syntax: MAIL FROM:<address>') + return + if self._mailfrom: + self.push('503 '+EERR_551+' Nested MAIL command') + return + self._mailfrom = address + self.push('250 '+EERR_200+' Ok Sender address accepted') + + + def smtp_RCPT(self, arg): + if not self._mailfrom: + self.push('503 '+EERR_551+' Need MAIL command') + return + address = self._getaddr('TO:', arg) if arg else None + if not address: + self.push('501 '+EERR_554+' Syntax: RCPT TO:<address>') + return + # Call rcpttocheck to check if list address has syntax errors + if self.rcpttocheck(address) == 'EERR_513': + self.push('550 '+EERR_513+' Syntax: list@domain') + return + # Call rcpttocheck to check if list address is a known address. + if self.rcpttocheck(address) == 'EERR_511': + self.push('550 '+EERR_511+': '+address) + return + # get subaddress and listname + listname = self.mlistname(address) + subaddress = self.subaddress(address) + # Check if sender is authorised to post to list + if not subaddress in SUBADDRESS_NAMES: + if self.listmembercheck(self._mailfrom, listname) == 'EERR_572': + self.push('550 '+EERR_572+': '+address) + return + if subaddress in SUBADDRESS_NAMES: + if self.listmembercheck(self._mailfrom, listname) == 'EERR_572': + if subaddress == 'leave' or subaddress == 'unsubscribe': + self.push('550 '+EERR_572+', the subaddresses -leave and -unsubscribe can not be used by unauthorised senders') + return + self._rcpttos.append(address) + self.push('250 '+EERR_200+' Ok Recipient address accepted') + + def smtp_DATA(self, arg): + if not self._rcpttos: + self.push('503 '+EERR_551+' Need a valid recipient') + return + if arg: + self.push('501 '+EERR_554+' Syntax: DATA') + return + self._state = self.DATA + self.set_terminator('\r\n.\r\n') + self.push('354 '+EERR_200+' End data with <CR><LF>.<CR><LF>') + + def smtp_RSET(self, arg): + if arg: + self.push('501 '+EERR_554+' Syntax: RSET') + return + # Resets the sender, recipients, and data, but not the greeting + self._mailfrom = None + self._rcpttos = [] + self._data = '' + self._state = self.COMMAND + self.push('250 '+EERR_200+' Ok Reset') + + def smtp_NOOP(self, arg): + if arg: + self.push('501 '+EERR_554+' Syntax: NOOP') + else: + self.push('250 '+EERR_200+' Ok') + + def smtp_QUIT(self, arg): + # args is ignored + self.push('221 '+EERR_200+' Goodbye') + self.close_when_done() + + + # Overrides base class for convenience + def push(self, msg): + asynchat.async_chat.push(self, msg + '\r\n') + + # Implementation of base class abstract method + def collect_incoming_data(self, data): + self._line.append(data) + + # factored + def _getaddr(self, keyword, arg): + address = None + keylen = len(keyword) + if arg[:keylen].upper() == keyword: + address = arg[keylen:].strip() + if not address: + pass + elif address[0] == '<' and address[-1] == '>' and address != '<>': + # Addresses can be in the form <person@dom.com> but watch out + # for null address, e.g. <> + address = address[1:-1] + return address + + # Implementation of base class abstract method + def found_terminator(self): + line = EMPTYSTRING.join(self._line) + self._line = [] + if self._state == self.COMMAND: + if not line: + self.push('500 '+EERR_551+' Bad syntax') + return + method = None + i = line.find(' ') + if i < 0: + command = line.upper() + arg = None + else: + command = line[:i].upper() + arg = line[i+1:].strip() + method = getattr(self, 'smtp_' + command, None) + if not method: + self.push('500 '+EERR_551+' Command "%s" not implemented' % command) + return + method(arg) + return + else: + if self._state != self.DATA: + self.push('451 '+EERR_450+' Internal confusion') + return + # Remove extraneous carriage returns and de-transparency according + # to RFC 821, Section 4.5.2. + data = [] + for text in line.split('\r\n'): + if text and text[0] == '.': + data.append(text[1:]) + else: + data.append(text) + self._data = NEWLINE.join(data) + status = self._server.process_message(self._peer, + self._mailfrom, + self._rcpttos, + self._data) + self._rcpttos = [] + self._mailfrom = None + self._state = self.COMMAND + self.set_terminator('\r\n') + if not status: + self.push('250 '+EERR_200+' Ok') + else: + self.push(status) + + # listname parses the given address and returns the name of the list + def mlistname(self, to): + try: + to = parseaddr(to)[1].lower() + listname, subaddress, domain = split_recipient(to) + return listname + except: + return 'Unknown Error' + + # subaddress parses the given address and returns the sub-address of the list + def subaddress(self, to): + try: + to = parseaddr(to)[1].lower() + listname, subaddress, domain = split_recipient(to) + return subaddress + except: + return 'Unknown Error' + # domain parses the given address and returns the domain of the list + def domain(self, to): + try: + to = parseaddr(to)[1].lower() + listname, subaddress, domain = split_recipient(to) + return domain + except: + return 'Unknown Error' + + # rcpttocheck checks if list is known + def rcpttocheck(self, to): + try: + if '@' not in to: + return 'EERR_513' + name = self.mlistname(to) + domain = self.domain(to) + sitedomain = get_domain() + if domain == sitedomain: + if list_exists(name): + return + else: + return 'EERR_511' + except: + return 'Unknown Error rcpt' + + # listmembercheck checks if sender is authorised to post to intended mailing list + def listmembercheck(self, mailfrom, to): + try: + name = self.mlistname(to) + mlist = MailList.MailList(to, lock=False) + if mlist.isMember(mailfrom): + return True + if mlist.generic_nonmember_action != 2: + return True + if matches_p(mailfrom, mlist.accept_these_nonmembers, to): + return True + else: + return 'EERR_572' + except: + return 'Unknown Error' + +class LMTPRunner(Runner, smtpd.SMTPServer): + # Only __init__ is called on startup. Asyncore is responsible for later + # connections from the MTA. slice and numslices are ignored and are + # necessary only to satisfy the API. + def __init__(self, slice=None, numslices=1): + # Don't forget to add: + # LMTP_HOST = 'host' + # LMTP_PORT = port + # to mm_cfg so that we know which host and port to use + localaddr = mm_cfg.LMTP_HOST, mm_cfg.LMTP_PORT + # Do not call Runner's constructor because there's no QDIR to create + smtpd.SMTPServer.__init__(self, localaddr, remoteaddr=None) + + def handle_accept(self): + conn, addr = self.accept() + channel = Channel(self, conn, addr) + + def process_message(self, peer, mailfrom, rcpttos, data): + + #The following code is no longer in use due to .defects not being implemeted yet. + ########################################################################################################### + #try: + # Refresh the list of list names every time we process a message + # since the set of mailing lists could have changed. + # Parse the message data. If there are any defects in the + # message, reject it right away; it's probably spam. + #msg = email.message_from_string(data, Message) + #if msg.defects: + # return ('501 '+EERR_554+'. Message has defects') + #msg['X-MailFrom'] = mailfrom + #except Exception, e: + #config.db.abort() + #return CRLF.join(['451 '+EERR_450+' Requested action aborted: error in processing' for to in rcpttos]) + ########################################################################################################### + + # Need a msg object + # Copied from above section of code + msg = email.message_from_string(data, Message) + # RFC 2033 requires us to return a status code for every recipient. + status = [] + # Now for each address in the recipients, parse the address to first + # see if it's destined for a valid mailing list. If so, then queue + # the message to the appropriate place and record a 250 status for + # that recipient. If not, record a failure status for that recipient. + for to in rcpttos: + try: + to = parseaddr(to)[1].lower() + listname, subaddress, domain = split_recipient(to) + if list_exists(listname) == False: + status.append('550 '+EERR_511) + continue + # The recipient is a valid mailing list; see if it's a valid + # sub-address, and if so, enqueue it. + queue = None + msgdata = dict(listname=listname) + if subaddress == 'bounces': + queue = Switchboard(mm_cfg.BOUNCEQUEUE_DIR) + elif subaddress == 'confirm': + msgdata['toconfirm'] = True + queue = Switchboard(mm_cfg.CMDQUEUE_DIR) + elif subaddress in ('join', 'subscribe'): + msgdata['tojoin'] = True + queue = Switchboard(mm_cfg.CMDQUEUE_DIR) + elif subaddress in ('leave', 'unsubscribe'): + msgdata['toleave'] = True + queue = Switchboard(mm_cfg.CMDQUEUE_DIR) + elif subaddress == 'owner': + msgdata.update(dict( + toowner=True, + # SITE_OWNER_ADDRESS does not exist in Defaults.py + # Add it to mm_cfg + envsender=mm_cfg.SITE_OWNER_ADDRESS, + pipeline=mm_cfg.OWNER_PIPELINE, + )) + queue = Switchboard(mm_cfg.INQUEUE_DIR) + elif subaddress is None: + msgdata['tolist'] = True + queue = Switchboard(mm_cfg.INQUEUE_DIR) + elif subaddress == 'request': + msgdata['torequest'] = True + queue = Switchboard(mm_cfg.CMDQUEUE_DIR) + else: + status.append('550 '+EERR_511) + continue + # If we found a valid subaddress, enqueue the message and add + # a success status for this recipient. + if queue is not None: + queue.enqueue(msg, msgdata) + status.append('250 '+EERR_200+' Ok Message enqueued for '+to) + except Exception, e: + status.append('550 '+EERR_513) + # All done; returning this big status string should give the expected + # response to the LMTP client. + return CRLF.join(status) + + def run(self): + """See `IRunner`.""" + asyncore.loop() + + def stop(self): + """See `IRunner`.""" + asyncore.socket_map.clear() + asyncore.close_all() + self.close()