• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#! /usr/bin/env python3
2"""An RFC 5321 smtp proxy with optional RFC 1870 and RFC 6531 extensions.
3
4Usage: %(program)s [options] [localhost:localport [remotehost:remoteport]]
5
6Options:
7
8    --nosetuid
9    -n
10        This program generally tries to setuid `nobody', unless this flag is
11        set.  The setuid call will fail if this program is not run as root (in
12        which case, use this flag).
13
14    --version
15    -V
16        Print the version number and exit.
17
18    --class classname
19    -c classname
20        Use `classname' as the concrete SMTP proxy class.  Uses `PureProxy' by
21        default.
22
23    --size limit
24    -s limit
25        Restrict the total size of the incoming message to "limit" number of
26        bytes via the RFC 1870 SIZE extension.  Defaults to 33554432 bytes.
27
28    --smtputf8
29    -u
30        Enable the SMTPUTF8 extension and behave as an RFC 6531 smtp proxy.
31
32    --debug
33    -d
34        Turn on debugging prints.
35
36    --help
37    -h
38        Print this message and exit.
39
40Version: %(__version__)s
41
42If localhost is not given then `localhost' is used, and if localport is not
43given then 8025 is used.  If remotehost is not given then `localhost' is used,
44and if remoteport is not given, then 25 is used.
45"""
46
47# Overview:
48#
49# This file implements the minimal SMTP protocol as defined in RFC 5321.  It
50# has a hierarchy of classes which implement the backend functionality for the
51# smtpd.  A number of classes are provided:
52#
53#   SMTPServer - the base class for the backend.  Raises NotImplementedError
54#   if you try to use it.
55#
56#   DebuggingServer - simply prints each message it receives on stdout.
57#
58#   PureProxy - Proxies all messages to a real smtpd which does final
59#   delivery.  One known problem with this class is that it doesn't handle
60#   SMTP errors from the backend server at all.  This should be fixed
61#   (contributions are welcome!).
62#
63#   MailmanProxy - An experimental hack to work with GNU Mailman
64#   <www.list.org>.  Using this server as your real incoming smtpd, your
65#   mailhost will automatically recognize and accept mail destined to Mailman
66#   lists when those lists are created.  Every message not destined for a list
67#   gets forwarded to a real backend smtpd, as with PureProxy.  Again, errors
68#   are not handled correctly yet.
69#
70#
71# Author: Barry Warsaw <barry@python.org>
72#
73# TODO:
74#
75# - support mailbox delivery
76# - alias files
77# - Handle more ESMTP extensions
78# - handle error codes from the backend smtpd
79
80import sys
81import os
82import errno
83import getopt
84import time
85import socket
86import collections
87from warnings import warn
88from email._header_value_parser import get_addr_spec, get_angle_addr
89
90__all__ = [
91    "SMTPChannel", "SMTPServer", "DebuggingServer", "PureProxy",
92    "MailmanProxy",
93]
94
95warn(
96    'The smtpd module is deprecated and unmaintained.  Please see aiosmtpd '
97    '(https://aiosmtpd.readthedocs.io/) for the recommended replacement.',
98    DeprecationWarning,
99    stacklevel=2)
100
101
102# These are imported after the above warning so that users get the correct
103# deprecation warning.
104import asyncore
105import asynchat
106
107
108program = sys.argv[0]
109__version__ = 'Python SMTP proxy version 0.3'
110
111
112class Devnull:
113    def write(self, msg): pass
114    def flush(self): pass
115
116
117DEBUGSTREAM = Devnull()
118NEWLINE = '\n'
119COMMASPACE = ', '
120DATA_SIZE_DEFAULT = 33554432
121
122
123def usage(code, msg=''):
124    print(__doc__ % globals(), file=sys.stderr)
125    if msg:
126        print(msg, file=sys.stderr)
127    sys.exit(code)
128
129
130class SMTPChannel(asynchat.async_chat):
131    COMMAND = 0
132    DATA = 1
133
134    command_size_limit = 512
135    command_size_limits = collections.defaultdict(lambda x=command_size_limit: x)
136
137    @property
138    def max_command_size_limit(self):
139        try:
140            return max(self.command_size_limits.values())
141        except ValueError:
142            return self.command_size_limit
143
144    def __init__(self, server, conn, addr, data_size_limit=DATA_SIZE_DEFAULT,
145                 map=None, enable_SMTPUTF8=False, decode_data=False):
146        asynchat.async_chat.__init__(self, conn, map=map)
147        self.smtp_server = server
148        self.conn = conn
149        self.addr = addr
150        self.data_size_limit = data_size_limit
151        self.enable_SMTPUTF8 = enable_SMTPUTF8
152        self._decode_data = decode_data
153        if enable_SMTPUTF8 and decode_data:
154            raise ValueError("decode_data and enable_SMTPUTF8 cannot"
155                             " be set to True at the same time")
156        if decode_data:
157            self._emptystring = ''
158            self._linesep = '\r\n'
159            self._dotsep = '.'
160            self._newline = NEWLINE
161        else:
162            self._emptystring = b''
163            self._linesep = b'\r\n'
164            self._dotsep = ord(b'.')
165            self._newline = b'\n'
166        self._set_rset_state()
167        self.seen_greeting = ''
168        self.extended_smtp = False
169        self.command_size_limits.clear()
170        self.fqdn = socket.getfqdn()
171        try:
172            self.peer = conn.getpeername()
173        except OSError as err:
174            # a race condition  may occur if the other end is closing
175            # before we can get the peername
176            self.close()
177            if err.errno != errno.ENOTCONN:
178                raise
179            return
180        print('Peer:', repr(self.peer), file=DEBUGSTREAM)
181        self.push('220 %s %s' % (self.fqdn, __version__))
182
183    def _set_post_data_state(self):
184        """Reset state variables to their post-DATA state."""
185        self.smtp_state = self.COMMAND
186        self.mailfrom = None
187        self.rcpttos = []
188        self.require_SMTPUTF8 = False
189        self.num_bytes = 0
190        self.set_terminator(b'\r\n')
191
192    def _set_rset_state(self):
193        """Reset all state variables except the greeting."""
194        self._set_post_data_state()
195        self.received_data = ''
196        self.received_lines = []
197
198
199    # properties for backwards-compatibility
200    @property
201    def __server(self):
202        warn("Access to __server attribute on SMTPChannel is deprecated, "
203            "use 'smtp_server' instead", DeprecationWarning, 2)
204        return self.smtp_server
205    @__server.setter
206    def __server(self, value):
207        warn("Setting __server attribute on SMTPChannel is deprecated, "
208            "set 'smtp_server' instead", DeprecationWarning, 2)
209        self.smtp_server = value
210
211    @property
212    def __line(self):
213        warn("Access to __line attribute on SMTPChannel is deprecated, "
214            "use 'received_lines' instead", DeprecationWarning, 2)
215        return self.received_lines
216    @__line.setter
217    def __line(self, value):
218        warn("Setting __line attribute on SMTPChannel is deprecated, "
219            "set 'received_lines' instead", DeprecationWarning, 2)
220        self.received_lines = value
221
222    @property
223    def __state(self):
224        warn("Access to __state attribute on SMTPChannel is deprecated, "
225            "use 'smtp_state' instead", DeprecationWarning, 2)
226        return self.smtp_state
227    @__state.setter
228    def __state(self, value):
229        warn("Setting __state attribute on SMTPChannel is deprecated, "
230            "set 'smtp_state' instead", DeprecationWarning, 2)
231        self.smtp_state = value
232
233    @property
234    def __greeting(self):
235        warn("Access to __greeting attribute on SMTPChannel is deprecated, "
236            "use 'seen_greeting' instead", DeprecationWarning, 2)
237        return self.seen_greeting
238    @__greeting.setter
239    def __greeting(self, value):
240        warn("Setting __greeting attribute on SMTPChannel is deprecated, "
241            "set 'seen_greeting' instead", DeprecationWarning, 2)
242        self.seen_greeting = value
243
244    @property
245    def __mailfrom(self):
246        warn("Access to __mailfrom attribute on SMTPChannel is deprecated, "
247            "use 'mailfrom' instead", DeprecationWarning, 2)
248        return self.mailfrom
249    @__mailfrom.setter
250    def __mailfrom(self, value):
251        warn("Setting __mailfrom attribute on SMTPChannel is deprecated, "
252            "set 'mailfrom' instead", DeprecationWarning, 2)
253        self.mailfrom = value
254
255    @property
256    def __rcpttos(self):
257        warn("Access to __rcpttos attribute on SMTPChannel is deprecated, "
258            "use 'rcpttos' instead", DeprecationWarning, 2)
259        return self.rcpttos
260    @__rcpttos.setter
261    def __rcpttos(self, value):
262        warn("Setting __rcpttos attribute on SMTPChannel is deprecated, "
263            "set 'rcpttos' instead", DeprecationWarning, 2)
264        self.rcpttos = value
265
266    @property
267    def __data(self):
268        warn("Access to __data attribute on SMTPChannel is deprecated, "
269            "use 'received_data' instead", DeprecationWarning, 2)
270        return self.received_data
271    @__data.setter
272    def __data(self, value):
273        warn("Setting __data attribute on SMTPChannel is deprecated, "
274            "set 'received_data' instead", DeprecationWarning, 2)
275        self.received_data = value
276
277    @property
278    def __fqdn(self):
279        warn("Access to __fqdn attribute on SMTPChannel is deprecated, "
280            "use 'fqdn' instead", DeprecationWarning, 2)
281        return self.fqdn
282    @__fqdn.setter
283    def __fqdn(self, value):
284        warn("Setting __fqdn attribute on SMTPChannel is deprecated, "
285            "set 'fqdn' instead", DeprecationWarning, 2)
286        self.fqdn = value
287
288    @property
289    def __peer(self):
290        warn("Access to __peer attribute on SMTPChannel is deprecated, "
291            "use 'peer' instead", DeprecationWarning, 2)
292        return self.peer
293    @__peer.setter
294    def __peer(self, value):
295        warn("Setting __peer attribute on SMTPChannel is deprecated, "
296            "set 'peer' instead", DeprecationWarning, 2)
297        self.peer = value
298
299    @property
300    def __conn(self):
301        warn("Access to __conn attribute on SMTPChannel is deprecated, "
302            "use 'conn' instead", DeprecationWarning, 2)
303        return self.conn
304    @__conn.setter
305    def __conn(self, value):
306        warn("Setting __conn attribute on SMTPChannel is deprecated, "
307            "set 'conn' instead", DeprecationWarning, 2)
308        self.conn = value
309
310    @property
311    def __addr(self):
312        warn("Access to __addr attribute on SMTPChannel is deprecated, "
313            "use 'addr' instead", DeprecationWarning, 2)
314        return self.addr
315    @__addr.setter
316    def __addr(self, value):
317        warn("Setting __addr attribute on SMTPChannel is deprecated, "
318            "set 'addr' instead", DeprecationWarning, 2)
319        self.addr = value
320
321    # Overrides base class for convenience.
322    def push(self, msg):
323        asynchat.async_chat.push(self, bytes(
324            msg + '\r\n', 'utf-8' if self.require_SMTPUTF8 else 'ascii'))
325
326    # Implementation of base class abstract method
327    def collect_incoming_data(self, data):
328        limit = None
329        if self.smtp_state == self.COMMAND:
330            limit = self.max_command_size_limit
331        elif self.smtp_state == self.DATA:
332            limit = self.data_size_limit
333        if limit and self.num_bytes > limit:
334            return
335        elif limit:
336            self.num_bytes += len(data)
337        if self._decode_data:
338            self.received_lines.append(str(data, 'utf-8'))
339        else:
340            self.received_lines.append(data)
341
342    # Implementation of base class abstract method
343    def found_terminator(self):
344        line = self._emptystring.join(self.received_lines)
345        print('Data:', repr(line), file=DEBUGSTREAM)
346        self.received_lines = []
347        if self.smtp_state == self.COMMAND:
348            sz, self.num_bytes = self.num_bytes, 0
349            if not line:
350                self.push('500 Error: bad syntax')
351                return
352            if not self._decode_data:
353                line = str(line, 'utf-8')
354            i = line.find(' ')
355            if i < 0:
356                command = line.upper()
357                arg = None
358            else:
359                command = line[:i].upper()
360                arg = line[i+1:].strip()
361            max_sz = (self.command_size_limits[command]
362                        if self.extended_smtp else self.command_size_limit)
363            if sz > max_sz:
364                self.push('500 Error: line too long')
365                return
366            method = getattr(self, 'smtp_' + command, None)
367            if not method:
368                self.push('500 Error: command "%s" not recognized' % command)
369                return
370            method(arg)
371            return
372        else:
373            if self.smtp_state != self.DATA:
374                self.push('451 Internal confusion')
375                self.num_bytes = 0
376                return
377            if self.data_size_limit and self.num_bytes > self.data_size_limit:
378                self.push('552 Error: Too much mail data')
379                self.num_bytes = 0
380                return
381            # Remove extraneous carriage returns and de-transparency according
382            # to RFC 5321, Section 4.5.2.
383            data = []
384            for text in line.split(self._linesep):
385                if text and text[0] == self._dotsep:
386                    data.append(text[1:])
387                else:
388                    data.append(text)
389            self.received_data = self._newline.join(data)
390            args = (self.peer, self.mailfrom, self.rcpttos, self.received_data)
391            kwargs = {}
392            if not self._decode_data:
393                kwargs = {
394                    'mail_options': self.mail_options,
395                    'rcpt_options': self.rcpt_options,
396                }
397            status = self.smtp_server.process_message(*args, **kwargs)
398            self._set_post_data_state()
399            if not status:
400                self.push('250 OK')
401            else:
402                self.push(status)
403
404    # SMTP and ESMTP commands
405    def smtp_HELO(self, arg):
406        if not arg:
407            self.push('501 Syntax: HELO hostname')
408            return
409        # See issue #21783 for a discussion of this behavior.
410        if self.seen_greeting:
411            self.push('503 Duplicate HELO/EHLO')
412            return
413        self._set_rset_state()
414        self.seen_greeting = arg
415        self.push('250 %s' % self.fqdn)
416
417    def smtp_EHLO(self, arg):
418        if not arg:
419            self.push('501 Syntax: EHLO hostname')
420            return
421        # See issue #21783 for a discussion of this behavior.
422        if self.seen_greeting:
423            self.push('503 Duplicate HELO/EHLO')
424            return
425        self._set_rset_state()
426        self.seen_greeting = arg
427        self.extended_smtp = True
428        self.push('250-%s' % self.fqdn)
429        if self.data_size_limit:
430            self.push('250-SIZE %s' % self.data_size_limit)
431            self.command_size_limits['MAIL'] += 26
432        if not self._decode_data:
433            self.push('250-8BITMIME')
434        if self.enable_SMTPUTF8:
435            self.push('250-SMTPUTF8')
436            self.command_size_limits['MAIL'] += 10
437        self.push('250 HELP')
438
439    def smtp_NOOP(self, arg):
440        if arg:
441            self.push('501 Syntax: NOOP')
442        else:
443            self.push('250 OK')
444
445    def smtp_QUIT(self, arg):
446        # args is ignored
447        self.push('221 Bye')
448        self.close_when_done()
449
450    def _strip_command_keyword(self, keyword, arg):
451        keylen = len(keyword)
452        if arg[:keylen].upper() == keyword:
453            return arg[keylen:].strip()
454        return ''
455
456    def _getaddr(self, arg):
457        if not arg:
458            return '', ''
459        if arg.lstrip().startswith('<'):
460            address, rest = get_angle_addr(arg)
461        else:
462            address, rest = get_addr_spec(arg)
463        if not address:
464            return address, rest
465        return address.addr_spec, rest
466
467    def _getparams(self, params):
468        # Return params as dictionary. Return None if not all parameters
469        # appear to be syntactically valid according to RFC 1869.
470        result = {}
471        for param in params:
472            param, eq, value = param.partition('=')
473            if not param.isalnum() or eq and not value:
474                return None
475            result[param] = value if eq else True
476        return result
477
478    def smtp_HELP(self, arg):
479        if arg:
480            extended = ' [SP <mail-parameters>]'
481            lc_arg = arg.upper()
482            if lc_arg == 'EHLO':
483                self.push('250 Syntax: EHLO hostname')
484            elif lc_arg == 'HELO':
485                self.push('250 Syntax: HELO hostname')
486            elif lc_arg == 'MAIL':
487                msg = '250 Syntax: MAIL FROM: <address>'
488                if self.extended_smtp:
489                    msg += extended
490                self.push(msg)
491            elif lc_arg == 'RCPT':
492                msg = '250 Syntax: RCPT TO: <address>'
493                if self.extended_smtp:
494                    msg += extended
495                self.push(msg)
496            elif lc_arg == 'DATA':
497                self.push('250 Syntax: DATA')
498            elif lc_arg == 'RSET':
499                self.push('250 Syntax: RSET')
500            elif lc_arg == 'NOOP':
501                self.push('250 Syntax: NOOP')
502            elif lc_arg == 'QUIT':
503                self.push('250 Syntax: QUIT')
504            elif lc_arg == 'VRFY':
505                self.push('250 Syntax: VRFY <address>')
506            else:
507                self.push('501 Supported commands: EHLO HELO MAIL RCPT '
508                          'DATA RSET NOOP QUIT VRFY')
509        else:
510            self.push('250 Supported commands: EHLO HELO MAIL RCPT DATA '
511                      'RSET NOOP QUIT VRFY')
512
513    def smtp_VRFY(self, arg):
514        if arg:
515            address, params = self._getaddr(arg)
516            if address:
517                self.push('252 Cannot VRFY user, but will accept message '
518                          'and attempt delivery')
519            else:
520                self.push('502 Could not VRFY %s' % arg)
521        else:
522            self.push('501 Syntax: VRFY <address>')
523
524    def smtp_MAIL(self, arg):
525        if not self.seen_greeting:
526            self.push('503 Error: send HELO first')
527            return
528        print('===> MAIL', arg, file=DEBUGSTREAM)
529        syntaxerr = '501 Syntax: MAIL FROM: <address>'
530        if self.extended_smtp:
531            syntaxerr += ' [SP <mail-parameters>]'
532        if arg is None:
533            self.push(syntaxerr)
534            return
535        arg = self._strip_command_keyword('FROM:', arg)
536        address, params = self._getaddr(arg)
537        if not address:
538            self.push(syntaxerr)
539            return
540        if not self.extended_smtp and params:
541            self.push(syntaxerr)
542            return
543        if self.mailfrom:
544            self.push('503 Error: nested MAIL command')
545            return
546        self.mail_options = params.upper().split()
547        params = self._getparams(self.mail_options)
548        if params is None:
549            self.push(syntaxerr)
550            return
551        if not self._decode_data:
552            body = params.pop('BODY', '7BIT')
553            if body not in ['7BIT', '8BITMIME']:
554                self.push('501 Error: BODY can only be one of 7BIT, 8BITMIME')
555                return
556        if self.enable_SMTPUTF8:
557            smtputf8 = params.pop('SMTPUTF8', False)
558            if smtputf8 is True:
559                self.require_SMTPUTF8 = True
560            elif smtputf8 is not False:
561                self.push('501 Error: SMTPUTF8 takes no arguments')
562                return
563        size = params.pop('SIZE', None)
564        if size:
565            if not size.isdigit():
566                self.push(syntaxerr)
567                return
568            elif self.data_size_limit and int(size) > self.data_size_limit:
569                self.push('552 Error: message size exceeds fixed maximum message size')
570                return
571        if len(params.keys()) > 0:
572            self.push('555 MAIL FROM parameters not recognized or not implemented')
573            return
574        self.mailfrom = address
575        print('sender:', self.mailfrom, file=DEBUGSTREAM)
576        self.push('250 OK')
577
578    def smtp_RCPT(self, arg):
579        if not self.seen_greeting:
580            self.push('503 Error: send HELO first');
581            return
582        print('===> RCPT', arg, file=DEBUGSTREAM)
583        if not self.mailfrom:
584            self.push('503 Error: need MAIL command')
585            return
586        syntaxerr = '501 Syntax: RCPT TO: <address>'
587        if self.extended_smtp:
588            syntaxerr += ' [SP <mail-parameters>]'
589        if arg is None:
590            self.push(syntaxerr)
591            return
592        arg = self._strip_command_keyword('TO:', arg)
593        address, params = self._getaddr(arg)
594        if not address:
595            self.push(syntaxerr)
596            return
597        if not self.extended_smtp and params:
598            self.push(syntaxerr)
599            return
600        self.rcpt_options = params.upper().split()
601        params = self._getparams(self.rcpt_options)
602        if params is None:
603            self.push(syntaxerr)
604            return
605        # XXX currently there are no options we recognize.
606        if len(params.keys()) > 0:
607            self.push('555 RCPT TO parameters not recognized or not implemented')
608            return
609        self.rcpttos.append(address)
610        print('recips:', self.rcpttos, file=DEBUGSTREAM)
611        self.push('250 OK')
612
613    def smtp_RSET(self, arg):
614        if arg:
615            self.push('501 Syntax: RSET')
616            return
617        self._set_rset_state()
618        self.push('250 OK')
619
620    def smtp_DATA(self, arg):
621        if not self.seen_greeting:
622            self.push('503 Error: send HELO first');
623            return
624        if not self.rcpttos:
625            self.push('503 Error: need RCPT command')
626            return
627        if arg:
628            self.push('501 Syntax: DATA')
629            return
630        self.smtp_state = self.DATA
631        self.set_terminator(b'\r\n.\r\n')
632        self.push('354 End data with <CR><LF>.<CR><LF>')
633
634    # Commands that have not been implemented
635    def smtp_EXPN(self, arg):
636        self.push('502 EXPN not implemented')
637
638
639class SMTPServer(asyncore.dispatcher):
640    # SMTPChannel class to use for managing client connections
641    channel_class = SMTPChannel
642
643    def __init__(self, localaddr, remoteaddr,
644                 data_size_limit=DATA_SIZE_DEFAULT, map=None,
645                 enable_SMTPUTF8=False, decode_data=False):
646        self._localaddr = localaddr
647        self._remoteaddr = remoteaddr
648        self.data_size_limit = data_size_limit
649        self.enable_SMTPUTF8 = enable_SMTPUTF8
650        self._decode_data = decode_data
651        if enable_SMTPUTF8 and decode_data:
652            raise ValueError("decode_data and enable_SMTPUTF8 cannot"
653                             " be set to True at the same time")
654        asyncore.dispatcher.__init__(self, map=map)
655        try:
656            gai_results = socket.getaddrinfo(*localaddr,
657                                             type=socket.SOCK_STREAM)
658            self.create_socket(gai_results[0][0], gai_results[0][1])
659            # try to re-use a server port if possible
660            self.set_reuse_addr()
661            self.bind(localaddr)
662            self.listen(5)
663        except:
664            self.close()
665            raise
666        else:
667            print('%s started at %s\n\tLocal addr: %s\n\tRemote addr:%s' % (
668                self.__class__.__name__, time.ctime(time.time()),
669                localaddr, remoteaddr), file=DEBUGSTREAM)
670
671    def handle_accepted(self, conn, addr):
672        print('Incoming connection from %s' % repr(addr), file=DEBUGSTREAM)
673        channel = self.channel_class(self,
674                                     conn,
675                                     addr,
676                                     self.data_size_limit,
677                                     self._map,
678                                     self.enable_SMTPUTF8,
679                                     self._decode_data)
680
681    # API for "doing something useful with the message"
682    def process_message(self, peer, mailfrom, rcpttos, data, **kwargs):
683        """Override this abstract method to handle messages from the client.
684
685        peer is a tuple containing (ipaddr, port) of the client that made the
686        socket connection to our smtp port.
687
688        mailfrom is the raw address the client claims the message is coming
689        from.
690
691        rcpttos is a list of raw addresses the client wishes to deliver the
692        message to.
693
694        data is a string containing the entire full text of the message,
695        headers (if supplied) and all.  It has been `de-transparencied'
696        according to RFC 821, Section 4.5.2.  In other words, a line
697        containing a `.' followed by other text has had the leading dot
698        removed.
699
700        kwargs is a dictionary containing additional information.  It is
701        empty if decode_data=True was given as init parameter, otherwise
702        it will contain the following keys:
703            'mail_options': list of parameters to the mail command.  All
704                            elements are uppercase strings.  Example:
705                            ['BODY=8BITMIME', 'SMTPUTF8'].
706            'rcpt_options': same, for the rcpt command.
707
708        This function should return None for a normal `250 Ok' response;
709        otherwise, it should return the desired response string in RFC 821
710        format.
711
712        """
713        raise NotImplementedError
714
715
716class DebuggingServer(SMTPServer):
717
718    def _print_message_content(self, peer, data):
719        inheaders = 1
720        lines = data.splitlines()
721        for line in lines:
722            # headers first
723            if inheaders and not line:
724                peerheader = 'X-Peer: ' + peer[0]
725                if not isinstance(data, str):
726                    # decoded_data=false; make header match other binary output
727                    peerheader = repr(peerheader.encode('utf-8'))
728                print(peerheader)
729                inheaders = 0
730            if not isinstance(data, str):
731                # Avoid spurious 'str on bytes instance' warning.
732                line = repr(line)
733            print(line)
734
735    def process_message(self, peer, mailfrom, rcpttos, data, **kwargs):
736        print('---------- MESSAGE FOLLOWS ----------')
737        if kwargs:
738            if kwargs.get('mail_options'):
739                print('mail options: %s' % kwargs['mail_options'])
740            if kwargs.get('rcpt_options'):
741                print('rcpt options: %s\n' % kwargs['rcpt_options'])
742        self._print_message_content(peer, data)
743        print('------------ END MESSAGE ------------')
744
745
746class PureProxy(SMTPServer):
747    def __init__(self, *args, **kwargs):
748        if 'enable_SMTPUTF8' in kwargs and kwargs['enable_SMTPUTF8']:
749            raise ValueError("PureProxy does not support SMTPUTF8.")
750        super(PureProxy, self).__init__(*args, **kwargs)
751
752    def process_message(self, peer, mailfrom, rcpttos, data):
753        lines = data.split('\n')
754        # Look for the last header
755        i = 0
756        for line in lines:
757            if not line:
758                break
759            i += 1
760        lines.insert(i, 'X-Peer: %s' % peer[0])
761        data = NEWLINE.join(lines)
762        refused = self._deliver(mailfrom, rcpttos, data)
763        # TBD: what to do with refused addresses?
764        print('we got some refusals:', refused, file=DEBUGSTREAM)
765
766    def _deliver(self, mailfrom, rcpttos, data):
767        import smtplib
768        refused = {}
769        try:
770            s = smtplib.SMTP()
771            s.connect(self._remoteaddr[0], self._remoteaddr[1])
772            try:
773                refused = s.sendmail(mailfrom, rcpttos, data)
774            finally:
775                s.quit()
776        except smtplib.SMTPRecipientsRefused as e:
777            print('got SMTPRecipientsRefused', file=DEBUGSTREAM)
778            refused = e.recipients
779        except (OSError, smtplib.SMTPException) as e:
780            print('got', e.__class__, file=DEBUGSTREAM)
781            # All recipients were refused.  If the exception had an associated
782            # error code, use it.  Otherwise,fake it with a non-triggering
783            # exception code.
784            errcode = getattr(e, 'smtp_code', -1)
785            errmsg = getattr(e, 'smtp_error', 'ignore')
786            for r in rcpttos:
787                refused[r] = (errcode, errmsg)
788        return refused
789
790
791class MailmanProxy(PureProxy):
792    def __init__(self, *args, **kwargs):
793        warn('MailmanProxy is deprecated and will be removed '
794             'in future', DeprecationWarning, 2)
795        if 'enable_SMTPUTF8' in kwargs and kwargs['enable_SMTPUTF8']:
796            raise ValueError("MailmanProxy does not support SMTPUTF8.")
797        super(PureProxy, self).__init__(*args, **kwargs)
798
799    def process_message(self, peer, mailfrom, rcpttos, data):
800        from io import StringIO
801        from Mailman import Utils
802        from Mailman import Message
803        from Mailman import MailList
804        # If the message is to a Mailman mailing list, then we'll invoke the
805        # Mailman script directly, without going through the real smtpd.
806        # Otherwise we'll forward it to the local proxy for disposition.
807        listnames = []
808        for rcpt in rcpttos:
809            local = rcpt.lower().split('@')[0]
810            # We allow the following variations on the theme
811            #   listname
812            #   listname-admin
813            #   listname-owner
814            #   listname-request
815            #   listname-join
816            #   listname-leave
817            parts = local.split('-')
818            if len(parts) > 2:
819                continue
820            listname = parts[0]
821            if len(parts) == 2:
822                command = parts[1]
823            else:
824                command = ''
825            if not Utils.list_exists(listname) or command not in (
826                    '', 'admin', 'owner', 'request', 'join', 'leave'):
827                continue
828            listnames.append((rcpt, listname, command))
829        # Remove all list recipients from rcpttos and forward what we're not
830        # going to take care of ourselves.  Linear removal should be fine
831        # since we don't expect a large number of recipients.
832        for rcpt, listname, command in listnames:
833            rcpttos.remove(rcpt)
834        # If there's any non-list destined recipients left,
835        print('forwarding recips:', ' '.join(rcpttos), file=DEBUGSTREAM)
836        if rcpttos:
837            refused = self._deliver(mailfrom, rcpttos, data)
838            # TBD: what to do with refused addresses?
839            print('we got refusals:', refused, file=DEBUGSTREAM)
840        # Now deliver directly to the list commands
841        mlists = {}
842        s = StringIO(data)
843        msg = Message.Message(s)
844        # These headers are required for the proper execution of Mailman.  All
845        # MTAs in existence seem to add these if the original message doesn't
846        # have them.
847        if not msg.get('from'):
848            msg['From'] = mailfrom
849        if not msg.get('date'):
850            msg['Date'] = time.ctime(time.time())
851        for rcpt, listname, command in listnames:
852            print('sending message to', rcpt, file=DEBUGSTREAM)
853            mlist = mlists.get(listname)
854            if not mlist:
855                mlist = MailList.MailList(listname, lock=0)
856                mlists[listname] = mlist
857            # dispatch on the type of command
858            if command == '':
859                # post
860                msg.Enqueue(mlist, tolist=1)
861            elif command == 'admin':
862                msg.Enqueue(mlist, toadmin=1)
863            elif command == 'owner':
864                msg.Enqueue(mlist, toowner=1)
865            elif command == 'request':
866                msg.Enqueue(mlist, torequest=1)
867            elif command in ('join', 'leave'):
868                # TBD: this is a hack!
869                if command == 'join':
870                    msg['Subject'] = 'subscribe'
871                else:
872                    msg['Subject'] = 'unsubscribe'
873                msg.Enqueue(mlist, torequest=1)
874
875
876class Options:
877    setuid = True
878    classname = 'PureProxy'
879    size_limit = None
880    enable_SMTPUTF8 = False
881
882
883def parseargs():
884    global DEBUGSTREAM
885    try:
886        opts, args = getopt.getopt(
887            sys.argv[1:], 'nVhc:s:du',
888            ['class=', 'nosetuid', 'version', 'help', 'size=', 'debug',
889             'smtputf8'])
890    except getopt.error as e:
891        usage(1, e)
892
893    options = Options()
894    for opt, arg in opts:
895        if opt in ('-h', '--help'):
896            usage(0)
897        elif opt in ('-V', '--version'):
898            print(__version__)
899            sys.exit(0)
900        elif opt in ('-n', '--nosetuid'):
901            options.setuid = False
902        elif opt in ('-c', '--class'):
903            options.classname = arg
904        elif opt in ('-d', '--debug'):
905            DEBUGSTREAM = sys.stderr
906        elif opt in ('-u', '--smtputf8'):
907            options.enable_SMTPUTF8 = True
908        elif opt in ('-s', '--size'):
909            try:
910                int_size = int(arg)
911                options.size_limit = int_size
912            except:
913                print('Invalid size: ' + arg, file=sys.stderr)
914                sys.exit(1)
915
916    # parse the rest of the arguments
917    if len(args) < 1:
918        localspec = 'localhost:8025'
919        remotespec = 'localhost:25'
920    elif len(args) < 2:
921        localspec = args[0]
922        remotespec = 'localhost:25'
923    elif len(args) < 3:
924        localspec = args[0]
925        remotespec = args[1]
926    else:
927        usage(1, 'Invalid arguments: %s' % COMMASPACE.join(args))
928
929    # split into host/port pairs
930    i = localspec.find(':')
931    if i < 0:
932        usage(1, 'Bad local spec: %s' % localspec)
933    options.localhost = localspec[:i]
934    try:
935        options.localport = int(localspec[i+1:])
936    except ValueError:
937        usage(1, 'Bad local port: %s' % localspec)
938    i = remotespec.find(':')
939    if i < 0:
940        usage(1, 'Bad remote spec: %s' % remotespec)
941    options.remotehost = remotespec[:i]
942    try:
943        options.remoteport = int(remotespec[i+1:])
944    except ValueError:
945        usage(1, 'Bad remote port: %s' % remotespec)
946    return options
947
948
949if __name__ == '__main__':
950    options = parseargs()
951    # Become nobody
952    classname = options.classname
953    if "." in classname:
954        lastdot = classname.rfind(".")
955        mod = __import__(classname[:lastdot], globals(), locals(), [""])
956        classname = classname[lastdot+1:]
957    else:
958        import __main__ as mod
959    class_ = getattr(mod, classname)
960    proxy = class_((options.localhost, options.localport),
961                   (options.remotehost, options.remoteport),
962                   options.size_limit, enable_SMTPUTF8=options.enable_SMTPUTF8)
963    if options.setuid:
964        try:
965            import pwd
966        except ImportError:
967            print('Cannot import module "pwd"; try running with -n option.', file=sys.stderr)
968            sys.exit(1)
969        nobody = pwd.getpwnam('nobody')[2]
970        try:
971            os.setuid(nobody)
972        except PermissionError:
973            print('Cannot setuid "nobody"; try running with -n option.', file=sys.stderr)
974            sys.exit(1)
975    try:
976        asyncore.loop()
977    except KeyboardInterrupt:
978        pass
979