summaryrefslogtreecommitdiff
path: root/src/mailman/queue/lmtp.py
blob: 7fcd0c70cb64b20f6e8454bc985bda49cd1f848b (plain)
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
# Copyright (C) 2006-2010 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman 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 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman 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
# GNU Mailman.  If not, see <http://www.gnu.org/licenses/>.

"""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 email
import smtpd
import logging
import asyncore

from email.utils import parseaddr
from zope.component import getUtility

from mailman.config import config
from mailman.database.transaction import txn
from mailman.email.message import Message
from mailman.interfaces.listmanager import IListManager
from mailman.queue import Runner

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@.  This maps user visible subaddress names (which may
# include aliases) to the internal canonical subaddress name.
SUBADDRESS_NAMES = dict(
    admin='bounces',
    bounces='bounces',
    confirm='confirm',
    join='join',
    leave='leave',
    owner='owner',
    request='request',
    subscribe='join',
    unsubscribe='leave',
    )

# This maps subaddress canonical name to the destination queue that handles
# messages sent to that subaddress.
SUBADDRESS_QUEUES = dict(
    bounces='bounces',
    confirm='command',
    join='command',
    leave='command',
    owner='in',
    request='command',
    )

DASH    = '-'
CRLF    = '\r\n'
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 = '550 Requested action not taken: mailbox unavailable'

# XXX Blech
smtpd.__version__ = 'Python LMTP queue runner 1.0'



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.mta.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(smtpd.SMTPChannel):
    """An LMTP channel."""

    def __init__(self, server, conn, addr):
        smtpd.SMTPChannel.__init__(self, server, conn, addr)
        # Stash this here since the subclass uses private attributes. :(
        self._server = server

    def smtp_LHLO(self, arg):
        """The LMTP greeting, used instead of HELO/EHLO."""
        smtpd.SMTPChannel.smtp_HELO(self, arg)

    def smtp_HELO(self, arg):
        """HELO is not a valid LMTP command."""
        self.push(ERR_502)



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.mta.lmtp_host, int(config.mta.lmtp_port)
        # Do not call Runner's constructor because there's no QDIR to create
        qlog.debug('LMTP server listening on %s:%s',
                   localaddr[0], localaddr[1])
        smtpd.SMTPServer.__init__(self, localaddr, remoteaddr=None)

    def handle_accept(self):
        conn, addr = self.accept()
        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(getUtility(IListManager).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)
            msg.original_size = len(data)
            if msg.defects:
                return ERR_501
            msg['X-MailFrom'] = mailfrom
            message_id = msg['message-id']
        except Exception:
            elog.exception('LMTP message parsing')
            config.db.abort()
            return CRLF.join(ERR_451 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('%s to: %s, list: %s, sub: %s, dom: %s',
                           message_id, to, listname, subaddress, domain)
                listname += '@' + domain
                if listname not in listnames:
                    status.append(ERR_550)
                    continue
                # The recipient is a valid mailing list.  Find the subaddress
                # if there is one, and set things up to enqueue to the proper
                # queue runner.
                queue = None
                msgdata = dict(listname=listname,
                               original_size=msg.original_size)
                canonical_subaddress = SUBADDRESS_NAMES.get(subaddress)
                queue = SUBADDRESS_QUEUES.get(canonical_subaddress)
                if subaddress is None:
                    # The message is destined for the mailing list.
                    msgdata['to_list'] = True
                    queue = 'in'
                elif canonical_subaddress is None:
                    # The subaddress was bogus.
                    elog.error('%s unknown sub-address: %s',
                               message_id, subaddress)
                    status.append(ERR_550)
                    continue
                else:
                    # A valid subaddress.
                    msgdata['subaddress'] = canonical_subaddress
                    if canonical_subaddress == 'owner':
                        msgdata.update(dict(
                            to_owner=True,
                            envsender=config.mailman.site_owner,
                            ))
                        queue = 'in'
                # If we found a valid destination, enqueue the message and add
                # a success status for this recipient.
                if queue is not None:
                    config.switchboards[queue].enqueue(msg, msgdata)
                    qlog.debug('%s subaddress: %s, queue: %s',
                               message_id, canonical_subaddress, queue)
                    status.append('250 Ok')
            except Exception:
                elog.exception('Queue detection: %s', msg['message-id'])
                config.db.abort()
                status.append(ERR_550)
        # 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()