Clean up the runners directory.
[mailman.git] / src / mailman / runners / lmtp.py
blob35cff4c945f39da8a84e8feac5bb1365d650461e
1 # Copyright (C) 2006-2016 by the Free Software Foundation, Inc.
3 # This file is part of GNU Mailman.
5 # GNU Mailman is free software: you can redistribute it and/or modify it under
6 # the terms of the GNU General Public License as published by the Free
7 # Software Foundation, either version 3 of the License, or (at your option)
8 # any later version.
10 # GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
11 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12 # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
13 # more details.
15 # You should have received a copy of the GNU General Public License along with
16 # GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
18 # XXX This module needs to be refactored to avoid direct access to the
19 # config.db global.
21 """Mailman LMTP runner (server).
23 Most mail servers can be configured to deliver local messages via 'LMTP'[1].
24 This module is actually an LMTP server rather than a standard runner.
26 The LMTP runner opens a local TCP port and waits for the mail server to
27 connect to it. The messages it receives over LMTP are very minimally parsed
28 for sanity and if they look okay, they are accepted and injected into
29 Mailman's incoming queue for normal processing. If they don't look good, or
30 are destined for a bogus sub-address, they are rejected right away, hopefully
31 so that the peer mail server can provide better diagnostics.
33 [1] RFC 2033 Local Mail Transport Protocol
34 http://www.faqs.org/rfcs/rfc2033.html
35 """
37 import sys
38 import email
39 import logging
40 import asyncore
42 from email.utils import parseaddr
43 from mailman import public
44 from mailman.config import config
45 from mailman.core.runner import Runner
46 from mailman.database.transaction import transactional
47 from mailman.email.message import Message
48 from mailman.interfaces.listmanager import IListManager
49 from mailman.utilities.datetime import now
50 from mailman.utilities.email import add_message_hash
51 from zope.component import getUtility
53 # Python 3.4's smtpd module can't handle non-UTF-8 byte input. Unfortunately
54 # we do get such emails in the wild. Python 3.5's version of the module does
55 # handle it correctly. We vendor a version to use in the Python 3.4 case.
56 if sys.version_info < (3, 5):
57 from mailman.compat import smtpd
58 else:
59 import smtpd
62 elog = logging.getLogger('mailman.error')
63 qlog = logging.getLogger('mailman.runner')
64 slog = logging.getLogger('mailman.smtp')
67 # We only care about the listname and the sub-addresses as in listname@ or
68 # listname-request@. This maps user visible subaddress names (which may
69 # include aliases) to the internal canonical subaddress name.
70 SUBADDRESS_NAMES = dict(
71 admin='bounces',
72 bounces='bounces',
73 confirm='confirm',
74 join='join',
75 leave='leave',
76 owner='owner',
77 request='request',
78 subscribe='join',
79 unsubscribe='leave',
82 # This maps subaddress canonical name to the destination queue that handles
83 # messages sent to that subaddress.
84 SUBADDRESS_QUEUES = dict(
85 bounces='bounces',
86 confirm='command',
87 join='command',
88 leave='command',
89 owner='in',
90 request='command',
93 DASH = '-'
94 CRLF = '\r\n'
95 ERR_451 = '451 Requested action aborted: error in processing'
96 ERR_501 = '501 Message has defects'
97 ERR_502 = '502 Error: command HELO not implemented'
98 ERR_550 = '550 Requested action not taken: mailbox unavailable'
99 ERR_550_MID = '550 No Message-ID header provided'
101 # XXX Blech
102 smtpd.__version__ = 'GNU Mailman LMTP runner 1.1'
105 def split_recipient(address):
106 """Split an address into listname, subaddress and domain parts.
108 For example:
110 >>> split_recipient('mylist@example.com')
111 ('mylist', None, 'example.com')
113 >>> split_recipient('mylist-request@example.com')
114 ('mylist', 'request', 'example.com')
116 :param address: The destination address.
117 :return: A 3-tuple of the form (list-shortname, subaddress, domain).
118 subaddress may be None if this is the list's posting address.
120 localpart, domain = address.split('@', 1)
121 localpart = localpart.split(config.mta.verp_delimiter, 1)[0]
122 parts = localpart.split(DASH)
123 if parts[-1] in SUBADDRESS_NAMES:
124 listname = DASH.join(parts[:-1])
125 subaddress = parts[-1]
126 else:
127 listname = localpart
128 subaddress = None
129 return listname, subaddress, domain
132 class Channel(smtpd.SMTPChannel):
133 """An LMTP channel."""
135 def __init__(self, server, conn, addr):
136 super().__init__(server, conn, addr, decode_data=False)
137 # Stash this here since the subclass uses private attributes. :(
138 self._server = server
140 def smtp_LHLO(self, arg):
141 """The LMTP greeting, used instead of HELO/EHLO."""
142 super().smtp_HELO(arg)
144 def smtp_HELO(self, arg):
145 """HELO is not a valid LMTP command."""
146 self.push(ERR_502)
148 # def push(self, arg):
149 # import pdb; pdb.set_trace()
150 # return super().push(arg)
153 @public
154 class LMTPRunner(Runner, smtpd.SMTPServer):
155 # Only __init__ is called on startup. Asyncore is responsible for later
156 # connections from the MTA. slice and numslices are ignored and are
157 # necessary only to satisfy the API.
159 is_queue_runner = False
161 def __init__(self, name, slice=None):
162 localaddr = config.mta.lmtp_host, int(config.mta.lmtp_port)
163 # Do not call Runner's constructor because there's no QDIR to create
164 qlog.debug('LMTP server listening on %s:%s',
165 localaddr[0], localaddr[1])
166 smtpd.SMTPServer.__init__(self, localaddr, remoteaddr=None)
167 super().__init__(name, slice)
169 def handle_accept(self):
170 conn, addr = self.accept()
171 Channel(self, conn, addr)
172 slog.debug('LMTP accept from %s', addr)
174 @transactional
175 def process_message(self, peer, mailfrom, rcpttos, data, **kwargs):
176 try:
177 # Refresh the list of list names every time we process a message
178 # since the set of mailing lists could have changed.
179 listnames = set(getUtility(IListManager).names)
180 # Parse the message data. If there are any defects in the
181 # message, reject it right away; it's probably spam.
182 msg = email.message_from_bytes(data, Message)
183 except Exception:
184 elog.exception('LMTP message parsing')
185 config.db.abort()
186 return CRLF.join(ERR_451 for to in rcpttos)
187 # Do basic post-processing of the message, checking it for defects or
188 # other missing information.
189 message_id = msg.get('message-id')
190 if message_id is None:
191 return ERR_550_MID
192 if msg.defects:
193 return ERR_501
194 msg.original_size = len(data)
195 add_message_hash(msg)
196 msg['X-MailFrom'] = mailfrom
197 # RFC 2033 requires us to return a status code for every recipient.
198 status = []
199 # Now for each address in the recipients, parse the address to first
200 # see if it's destined for a valid mailing list. If so, then queue
201 # the message to the appropriate place and record a 250 status for
202 # that recipient. If not, record a failure status for that recipient.
203 received_time = now()
204 for to in rcpttos:
205 try:
206 to = parseaddr(to)[1].lower()
207 local, subaddress, domain = split_recipient(to)
208 if subaddress is not None:
209 # Check that local-subaddress is not an actual list name.
210 listname = '{}-{}@{}'.format(local, subaddress, domain)
211 if listname in listnames:
212 local = '{}-{}'.format(local, subaddress)
213 subaddress = None
214 slog.debug('%s to: %s, list: %s, sub: %s, dom: %s',
215 message_id, to, local, subaddress, domain)
216 listname = '{}@{}'.format(local, domain)
217 if listname not in listnames:
218 status.append(ERR_550)
219 continue
220 listid = '{}.{}'.format(local, domain)
221 # The recipient is a valid mailing list. Find the subaddress
222 # if there is one, and set things up to enqueue to the proper
223 # queue.
224 queue = None
225 msgdata = dict(listid=listid,
226 original_size=msg.original_size,
227 received_time=received_time)
228 canonical_subaddress = SUBADDRESS_NAMES.get(subaddress)
229 queue = SUBADDRESS_QUEUES.get(canonical_subaddress)
230 if subaddress is None:
231 # The message is destined for the mailing list.
232 msgdata['to_list'] = True
233 queue = 'in'
234 elif canonical_subaddress is None:
235 # The subaddress was bogus.
236 slog.error('%s unknown sub-address: %s',
237 message_id, subaddress)
238 status.append(ERR_550)
239 continue
240 else:
241 # A valid subaddress.
242 msgdata['subaddress'] = canonical_subaddress
243 if canonical_subaddress == 'owner':
244 msgdata.update(dict(
245 to_owner=True,
246 envsender=config.mailman.site_owner,
248 queue = 'in'
249 # If we found a valid destination, enqueue the message and add
250 # a success status for this recipient.
251 if queue is not None:
252 config.switchboards[queue].enqueue(msg, msgdata)
253 slog.debug('%s subaddress: %s, queue: %s',
254 message_id, canonical_subaddress, queue)
255 status.append('250 Ok')
256 except Exception:
257 slog.exception('Queue detection: %s', msg['message-id'])
258 config.db.abort()
259 status.append(ERR_550)
260 # All done; returning this big status string should give the expected
261 # response to the LMTP client.
262 return CRLF.join(status)
264 def run(self):
265 """See `IRunner`."""
266 asyncore.loop(use_poll=True)
268 def stop(self):
269 """See `IRunner`."""
270 asyncore.socket_map.clear()
271 asyncore.close_all()
272 self.close()