Skip to content
Snippets Groups Projects
Commit d863629b authored by ale's avatar ale
Browse files

Add LMTP runner

parent c8eed950
No related branches found
No related tags found
No related merge requests found
# 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()
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment