1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
|
# 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
See the variable USE_LMTP in Defaults.py.in for enabling this delivery
mechanism.
"""
import os
import email
import smtpd
import logging
import asyncore
import asynchat
import socket
from email.utils import parseaddr
from mailman.Message import Message
from mailman.configuration import config
from mailman.database.transaction import txn
from mailman.queue import Runner, Switchboard
from mailman.pipeline.moderate import matches_p
elog = logging.getLogger('mailman.error')
qlog = logging.getLogger('mailman.qrunner')
# 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(config.VERP_DELIMITER, 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
# RFC 2033 requires the client to say LHLO to the server before mail can be sent
LHLO = False
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
# If there is a multiline response
# Don't forget '-' after the status code on each line
# except for the last line,
self.push('250-%s' % self._fqdn)
# PIPELINING is supported
self.push('250-PIPELINING')
# ENHANCEDSTATUSCODES are supported
self.push('250 ENHANCEDSTATUSCODES')
def smtp_MAIL(self, arg):
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
listname = self.listname(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, address) == '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)
# lists gets all the lists
def lists(self):
try:
# Accessing config.db locks the database.
listnames = set(config.db.list_manager.names)
# You need to commit to confirm changes and let other processes see any DB changes
config.db.commit()
return listnames
except:
return 'Unknown Error'
# listname parses the given address and returns the name of the list
def listname(self, to):
try:
to = parseaddr(to)[1].lower()
listname, subaddress, domain = split_recipient(to)
listname += '@' + domain
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'
# rcpttocheck checks if list is a known list.
def rcpttocheck(self, to):
try:
listnames = Channel.lists(self)
listname = Channel.listname(self, to)
if listname in listnames:
return
elif '@' in listname:
return 'EERR_511'
else:
return 'EERR_513'
except:
return 'Unknown Error'
# listmember checks if sender is a member of entered mailing list
def listmembercheck(self, mailfrom, address):
mlist = config.db.list_manager.get(unicode(address))
if mlist.generic_nonmember_action != 2:
return False
member = mlist.members.get_member(unicode(mailfrom))
if member:
return
elif matches_p(mailfrom, mlist.accept_these_nonmembers):
return
else:
return 'EERR_572'
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):
localaddr = config.LMTP_HOST, config.LMTP_PORT
# Do not call Runner's constructor because there's no QDIR to create
smtpd.SMTPServer.__init__(self, localaddr, remoteaddr=None)
qlog.debug('LMTP server listening on %s:%s',
config.LMTP_HOST, config.LMTP_PORT)
def handle_accept(self):
conn, addr = self.accept()
channel = Channel(self, conn, addr)
qlog.debug('LMTP accept from %s', addr)
@txn
def process_message(self, peer, mailfrom, rcpttos, data):
try:
# Refresh the list of list names every time we process a message
# since the set of mailing lists could have changed.
listnames = set(config.db.list_manager.names)
# 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:
elog.exception('LMTP message parsing')
config.db.abort()
return CRLF.join(['451 '+EERR_450+' Requested action aborted: error in processing' for to in rcpttos])
# 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)
qlog.debug('to: %s, list: %s, sub: %s, dom: %s',
to, listname, subaddress, domain)
listname += '@' + domain
if listname not in listnames:
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 in ('bounces', 'admin'):
queue = Switchboard(config.BOUNCEQUEUE_DIR)
elif subaddress == 'confirm':
msgdata['toconfirm'] = True
queue = Switchboard(config.CMDQUEUE_DIR)
elif subaddress in ('join', 'subscribe'):
msgdata['tojoin'] = True
queue = Switchboard(config.CMDQUEUE_DIR)
elif subaddress in ('leave', 'unsubscribe'):
msgdata['toleave'] = True
queue = Switchboard(config.CMDQUEUE_DIR)
elif subaddress == 'owner':
msgdata.update(dict(
toowner=True,
envsender=config.SITE_OWNER_ADDRESS,
pipeline=config.OWNER_PIPELINE,
))
queue = Switchboard(config.INQUEUE_DIR)
elif subaddress is None:
msgdata['tolist'] = True
queue = Switchboard(config.INQUEUE_DIR)
elif subaddress == 'request':
msgdata['torequest'] = True
queue = Switchboard(config.CMDQUEUE_DIR)
else:
elog.error('Unknown sub-address: %s', subaddress)
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:
elog.exception('Queue detection: %s', msg['message-id'])
config.db.abort()
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()
|