Merge branch 'mid' into 'master'
[mailman.git] / src / mailman / runners / lmtp.py
blob804c79a141ca92108f0ce6fa1389eec2e3488fe1
1 # Copyright (C) 2006-2023 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 <https://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 re
38 import email
39 import logging
41 from aiosmtpd.controller import Controller
42 from aiosmtpd.lmtp import LMTP
43 from contextlib import suppress
44 from email.utils import parseaddr
45 from mailman.config import config
46 from mailman.core.runner import Runner
47 from mailman.database.transaction import transactional
48 from mailman.email.message import Message
49 from mailman.interfaces.domain import IDomainManager
50 from mailman.interfaces.listmanager import IListManager
51 from mailman.interfaces.runner import RunnerInterrupt
52 from mailman.utilities.datetime import now
53 from mailman.utilities.email import add_message_hash
54 from public import public
55 from zope.component import getUtility
58 elog = logging.getLogger('mailman.error')
59 qlog = logging.getLogger('mailman.runner')
60 slog = logging.getLogger('mailman.smtp')
63 # We only care about the listname and the sub-addresses as in listname@ or
64 # listname-request@. This maps user visible subaddress names (which may
65 # include aliases) to the internal canonical subaddress name.
66 SUBADDRESS_NAMES = dict(
67 bounces='bounces',
68 confirm='confirm',
69 join='join',
70 leave='leave',
71 owner='owner',
72 request='request',
73 subscribe='join',
74 unsubscribe='leave',
77 # This maps subaddress canonical name to the destination queue that handles
78 # messages sent to that subaddress.
79 SUBADDRESS_QUEUES = dict(
80 bounces='bounces',
81 confirm='command',
82 join='command',
83 leave='command',
84 owner='in',
85 request='command',
88 DASH = '-'
89 CRLF = '\r\n'
90 ERR_451 = '451 Requested action aborted: error in processing'
91 ERR_501 = '501 Message has defects'
92 ERR_502 = '502 Error: command HELO not implemented'
93 ERR_550 = '550 Requested action not taken: mailbox unavailable'
96 def split_recipient(address):
97 """Split an address into listname, subaddress and domain parts.
99 For example:
101 >>> split_recipient('mylist@example.com')
102 ('mylist', None, 'example.com')
104 >>> split_recipient('mylist-request@example.com')
105 ('mylist', 'request', 'example.com')
107 :param address: The destination address.
108 :return: A 3-tuple of the form (list-shortname, subaddress, domain).
109 subaddress may be None if this is the list's posting address.
111 If the domain of the destination address matches an alias_domain of some
112 IDomain Domain, the domain is replaced by the Domain's mail_host.
114 localpart, domain = address.split('@', 1)
115 domain_manager = getUtility(IDomainManager)
116 for d in domain_manager:
117 if d.alias_domain is not None and domain == d.alias_domain:
118 domain = d.mail_host
119 break
120 localpart = localpart.split(config.mta.verp_delimiter, 1)[0]
121 listname, dash, subaddress = localpart.rpartition('-')
122 if subaddress not in SUBADDRESS_NAMES or listname == '' or dash == '':
123 listname = localpart
124 subaddress = None
125 return listname, subaddress, domain
128 class LMTPHandler:
130 async def handle_RCPT(self, server, session, envelope, to, rcpt_options):
131 # Use a helper function to use the transactional wrapper on since it
132 # doesn't yet work on awaitables (async def funcs.)
133 return self._handle_RCPT(server, session, envelope, to, rcpt_options)
135 @transactional
136 def _handle_RCPT(self, server, session, envelope, to, rcpt_options):
137 listnames = set(getUtility(IListManager).names)
138 try:
139 to = parseaddr(to)[1].lower()
140 local, subaddress, domain = split_recipient(to)
141 if subaddress is not None:
142 # Check that local-subaddress is not an actual list name.
143 listname = '{}-{}@{}'.format(local, subaddress, domain)
144 if listname in listnames:
145 local = '{}-{}'.format(local, subaddress)
146 subaddress = None
147 listname = '{}@{}'.format(local, domain)
148 if listname not in listnames:
149 return ERR_550
150 canonical_subaddress = SUBADDRESS_NAMES.get(subaddress)
151 if subaddress is None:
152 # The message is destined for the mailing list.
153 # nothing to do here, just keep code similar to handle_DATA
154 pass
155 elif canonical_subaddress is None:
156 # The subaddress was bogus.
157 slog.error('unknown sub-address: %s', subaddress)
158 return ERR_550
159 else:
160 # A valid subaddress.
161 # nothing to do here, just keep code similar to handle_DATA
162 pass
163 # recipient validated, just do the same as aiosmtpd.LMTP would do
164 envelope.rcpt_tos.append(to)
165 envelope.rcpt_options.extend(rcpt_options)
166 return '250 Ok'
167 except Exception:
168 slog.exception('Address verification: %s', to)
169 config.db.abort()
170 return ERR_550
172 async def handle_DATA(self, server, session, envelope):
173 # Use a helper function to use the transactional wrapper on since it
174 # doesn't yet work on awaitables (async def funcs.)
175 return self._handle_DATA(server, session, envelope)
177 @transactional
178 def _handle_DATA(self, server, session, envelope):
179 try:
180 # Refresh the list of list names every time we process a message
181 # since the set of mailing lists could have changed.
182 listnames = set(getUtility(IListManager).names)
183 # Parse the message data. If there are any defects in the
184 # message, reject it right away; it's probably spam.
185 msg = email.message_from_bytes(envelope.content, Message)
186 msg.set_unixfrom(envelope.mail_from)
187 except Exception:
188 elog.exception('LMTP message parsing')
189 config.db.abort()
190 return CRLF.join(ERR_451 for to in envelope.rcpt_tos)
191 # Do basic post-processing of the message, checking it for defects or
192 # other missing information.
193 message_id = msg.get('message-id')
194 if message_id is None:
195 # We have observed cases in the wild where bounce DSNs have no
196 # Message-ID; header. Also, there are brain dead phone clients
197 # that don't include a Message-ID: header. Thus, we cave and
198 # generate one. See https://gitlab.com/mailman/mailman/-/issues/448
199 # and https://gitlab.com/mailman/mailman/-/issues/490.
200 message_id = email.utils.make_msgid()
201 msg['Message-ID'] = message_id
202 # Workaround for bogus Message-IDs. See #1065.
203 new_mid = re.sub(r'^<?\[(.*)\]>?', r'<\1>', message_id)
204 if new_mid != message_id:
205 msg.replace_header('Message-ID', new_mid)
206 if msg.defects:
207 return ERR_501
208 msg.original_size = len(envelope.content)
209 add_message_hash(msg)
210 msg['X-MailFrom'] = envelope.mail_from
211 # RFC 2033 requires us to return a status code for every recipient.
212 status = []
213 # Now for each address in the recipients, parse the address to first
214 # see if it's destined for a valid mailing list. If so, then queue
215 # the message to the appropriate place and record a 250 status for
216 # that recipient. If not, record a failure status for that recipient.
217 received_time = now()
218 for to in envelope.rcpt_tos:
219 try:
220 to = parseaddr(to)[1].lower()
221 local, subaddress, domain = split_recipient(to)
222 if subaddress is not None:
223 # Check that local-subaddress is not an actual list name.
224 listname = '{}-{}@{}'.format(local, subaddress, domain)
225 if listname in listnames:
226 local = '{}-{}'.format(local, subaddress)
227 subaddress = None
228 slog.debug('%s to: %s, list: %s, sub: %s, dom: %s',
229 message_id, to, local, subaddress, domain)
230 listname = '{}@{}'.format(local, domain)
231 if listname not in listnames:
232 status.append(ERR_550)
233 continue
234 mlist = getUtility(IListManager).get_by_fqdn(listname)
235 # The recipient is a valid mailing list. Find the subaddress
236 # if there is one, and set things up to enqueue to the proper
237 # queue.
238 queue = None
239 msgdata = dict(listid=mlist.list_id,
240 original_size=msg.original_size,
241 received_time=received_time)
242 canonical_subaddress = SUBADDRESS_NAMES.get(subaddress)
243 queue = SUBADDRESS_QUEUES.get(canonical_subaddress)
244 if subaddress is None:
245 # The message is destined for the mailing list.
246 msgdata['to_list'] = True
247 queue = 'in'
248 elif canonical_subaddress is None:
249 # The subaddress was bogus.
250 slog.error('%s unknown sub-address: %s',
251 message_id, subaddress)
252 status.append(ERR_550)
253 continue
254 else:
255 # A valid subaddress.
256 msgdata['subaddress'] = canonical_subaddress
257 if subaddress == 'request':
258 msgdata['to_request'] = True
259 if canonical_subaddress == 'owner':
260 msgdata.update(dict(
261 to_owner=True,
262 envsender=config.mailman.site_owner,
264 queue = 'in'
265 # If we found a valid destination, enqueue the message and add
266 # a success status for this recipient.
267 if queue is not None:
268 config.switchboards[queue].enqueue(msg, msgdata)
269 slog.debug('%s subaddress: %s, queue: %s',
270 message_id, canonical_subaddress, queue)
271 status.append('250 Ok')
272 except Exception:
273 slog.exception('Queue detection: %s', msg['message-id'])
274 config.db.abort()
275 status.append(ERR_550)
276 # All done; returning this big status string should give the expected
277 # response to the LMTP client.
278 return CRLF.join(status)
281 class LMTPController(Controller):
282 def factory(self):
283 server = LMTP(self.handler)
284 server.__ident__ = 'GNU Mailman LMTP runner 2.0'
285 return server
288 @public
289 class LMTPRunner(Runner):
290 # Only __init__ is called on startup. Asyncore is responsible for later
291 # connections from the MTA. slice and numslices are ignored and are
292 # necessary only to satisfy the API.
294 is_queue_runner = False
296 def __init__(self, name, slice=None):
297 super().__init__(name, slice)
298 hostname = config.mta.lmtp_host
299 port = int(config.mta.lmtp_port)
300 self.lmtp = LMTPController(LMTPHandler(), hostname=hostname, port=port)
301 qlog.debug('LMTP server listening on %s:%s', hostname, port)
303 def run(self):
304 """See `IRunner`."""
305 with suppress(RunnerInterrupt):
306 self.lmtp.start()
307 while not self._stop:
308 self._snooze(0)
309 self.lmtp.stop()