import string, re import socket import poplib import smtplib import rfc822 import time import types from nettools import * import mailconf lower = string.lower strip = string.strip find = string.find LF = '\n' CRLF = '\r\n' options = {} # program options: # -u: also get user mail (from mailconf.user_accounts) # -s n: get mails of at most n KB size # -t mailfile: debug mapping etc. using mailfile # Exception class class MailgateError(Exception): pass # the mail gateway class Mailgateway: """\ A POP3 --> SMTP mail gateway. Retrieves mail messages for a whole domain from a POP3 account and passes them on to a SMTP server. Because the envelope receiver is generally lost when getting mail from a POP3 account, the program parses the mail headers to retreive the intended recipients. """ changelog = """\ 1.00/bb: initial release 1.01/bb: added address/subject mapping 1.02/19980701/bb: subject mapping only if no other recipients in our domain 1.03/19980703/bb: suppressed forwarding of mail from the local domain 1.04/19980916/bb: added save of undeliverable messages 1.05/19980917/bb: added search of Received: lines 1.06/19990525/bb: duplicate Message-IDs forwarded only once 1.07/19990623/bb: messages > 2 MB are left on the server 1.08/19991208/bb: more than one recipient in mappings 1.081/20000407/bb: syntax change for more than one recipient in mappings 1.09/20000720/bb: protect against M$ "Malformed E-mail Header" vuln. (MS00-043) 1.10/20000817/bb: if there's a single wildcard recipient, forward only to her 1.11/20000918/bb: can relay to recipients outside our mail domain 1.12/20000922/bb: added -t option 1.13/20001013/bb: also try to find the sender in the "Sender:" and "Reply-To:" headers; added forward_from_local and broken_apop to mailconf.py """ version = 'PyPOP mail gateway 1.13' def __init__(self, shost, sdomain): self.smtp_host = shost # SMTP server to forward messages to self.smtp_domain = sdomain # the mail domain we're forwarding to self.at_domain = '@' + lower(sdomain) self.at_domlen = len(self.at_domain) self.maxsize = 0 # unlimited if options.has_key('-s'): self.maxsize = int(options['-s']) # in 1 KB blocks self.localhost = localhost() # this host's FQDN self.pop_host = '' # POP3 server to get messages from self.pop_user = '' # POP3 user name self.pop_pass = '' # POP3 password self.pop_dele = 0 # delete mail after forwarding? self.pop = None # poplib.POP3 object self.head_list = [] # list of headers self.head_dict = {} # dictionary of headers self.sender = (None, None) # original sender self.sender_raw = '' self.sender_local = 0 # 1 if sender is in self.smtp_domain self.subject = '' # message subject self.msgid = '' # message id self.recip = [] # list of recipients in our mail domain self.recip_map = [] # mapping of recipients self.msgids = {} # mapping of message ids to recipients self.debug = 0 # debugging level: 0-4 # 0: message ids # 1: sender # 2: sender + subject # 3: sender + subject + recipients # 4: sender + subject + address mappings def banner(self): return '%s starting on %s' % (self.version, curtime()) def config_pop(self, phost, puser, ppass, dele = 1): self.pop_host = phost self.pop_user = puser self.pop_pass = ppass self.pop_dele = dele def config_map(self, rmap): self.recip_map = [] for pat, addr in rmap: r = None if pat != '*': try: r = re.compile(pat, re.I) except re.error: print '!!! Can\'t compile pattern %s' % pat if type(addr) == types.StringType: addr = (addr,) # tuplefy a string self.recip_map.append( (pat, addr, r) ) def parse_header(self, msg): """parse the message header, filling self.head_list and self.head_dict""" hdrs = {} list = [] hdr = '' val = '' i = -1 while 1: # get the next line i = i + 1 try: line = msg[i] except IndexError: raise MailgateError('Missing mail body') # check for end of headers if line == '': break if hdr and line[0] in ' \t': # it's a continuation line list.append(line) val = val + '\n ' + strip(line) hdrs[hdr] = val elif ':' in line: # it's a header line list.append(line) p = find(line, ':') hdr = lower(line[:p]) # protect against M$ "Malformed E-mail Header" vuln. (MS00-043) if hdr == 'date' and len(line) > 80: print '!!! Buffer overrun detected:', line msg[i] = line = line[:80] + ' (...)' val = strip(line[p+1:]) if hdrs.has_key(hdr): val = hdrs[hdr] + '\n\t' + val hdrs[hdr] = val else: raise MailgateError('Bad header line: ' + `line`) self.head_list = list self.head_dict = hdrs def get_header(self, name, sep = ',\n'): """Get the value of the header item <name>, including all continuation lines and repeated instances of the same name. Repeated instances are separated by <sep>. Return None if the item is not present.""" val = '' name = lower(name) + ':' n = len(name) hit = seen = 0 for line in self.head_list: if lower(line[:n]) == name: hit = seen = 1 line = line[n:] if val: # repeated instance? line = sep + line elif line[0] not in ' \t': hit = 0 else: line = '\n' + line if hit: val = val + line if seen: return strip(val) def get_from(self): """find the sender""" for item in ('From', 'Sender', 'Reply-To'): self.sender_raw = self.get_header(item) if self.sender_raw: self.sender = rfc822.parseaddr(self.sender_raw) self.sender_local = self.islocal(self.sender) return raise MailgateError('No sender in message header') def get_received_for(self): """find "Received: ... for <recipient> ..." and extract the recipient""" r = self.get_header('Received') if r: # m = re.search(r'\s+for\s+(<[^>]+>)', r, re.I) m = re.search(r'\sfor\s+(<?[-+_=%!a-z0-9.]+@[-a-z0-9.]+>?)', r, re.I) if m: return rfc822.parseaddr(m.group(1)) def get_subject(self): """find the subject""" subject = self.get_header('Subject') if subject: self.subject = subject else: self.subject = '' def get_msgid(self): """find the message id""" self.msgid = self.get_header('Message-ID') def get_recipients(self): """collect the recipients in our mail domain""" # if there's a single wildcard recipient, forward only # to this recipient if len(self.recip_map) == 1 \ and self.recip_map[0][0] == '*' \ and len(self.recip_map[0][1]) == 1: to = self.recip_map[0][1][0] self.recip = [ (to, self.localaddr(to)) ] if self.debug > 3: print ' goes to', joinaddr(self.recip[0]) return # collect recipients from To:, Cc: and Bcc: lines to = [] seen = 0 for item in ('To', 'Cc', 'Bcc'): val = self.get_header(item) if val: seen = 1 # seen at least one header a = rfc822.AddrlistClass(val) to = to + a.getaddrlist() self.recip = self.map_recipients(to) # search the Received: lines if necessary if not self.recip: to = self.get_received_for() if to and self.islocal(to): self.recip = [to] # search the Subject: line if necessary if not self.recip and self.subject: to = self.map_recipients( [ (self.subject, '') ] ) if to and to[0][1]: # found a match self.recip = to # raise various errors if no recipient yet if not seen: raise MailgateError('No recipients in message header') elif not self.recip: raise MailgateError('No recipients in mail domain ' + self.smtp_domain) def islocal(self, p): return (lower(p[1][-self.at_domlen:]) == self.at_domain) def map_recipients(self, recip): """Map the recipients in recip using self.recip_map, discard non-local adresses. This also removes duplicate recipients.""" mapped = [] emails = [] for name, email in recip: if not email: continue # handle <> addresses lname = lower(name) lemail = lower(email) for (pat, addrt, cpat) in self.recip_map: if not ( pat == '*' or cpat and (cpat.search(name) or cpat.search(email)) ): continue for addr in addrt: newemail = self.localaddr(addr) log = ' %s maps to %s' % ( joinaddr( (name, email) ), joinaddr(newemail) ) if newemail in emails: if self.debug > 3: print log, '(duplicate)' else: if self.debug > 3: print log mapped.append( (name, newemail) ) emails.append(newemail) break else: if not self.islocal( (name, email) ): if self.debug > 3: print ' %s is not local' % joinaddr(email) continue if email in emails: if self.debug > 3: print ' %s is duplicate' % joinaddr(email) else: mapped.append( (name, email) ) emails.append(email) return mapped def read_header(self): self.get_msgid() self.get_from() self.get_subject() if self.debug == 0: if self.msgid: print ' %s' % self.msgid elif self.debug > 1: print ' %s: %s' % (joinaddr(self.sender), self.subject) elif self.debug > 0: print ' %s' % joinaddr(self.sender) self.get_recipients() if self.debug > 2: print fmtlist(self.recip, lambda p: ' ' + joinaddr(p)) def received(self): """return a suitable "Received:" header line""" return 'Received: from %s by %s with %s (%s)%s\tfor %s; %s' % ( self.pop_host, self.localhost, 'POP3', self.version, CRLF, joinaddr(self.recip[0][1]), rfctime() ) def localaddr(self, addr): if '@' in addr: return addr # domain already there; don't add another! return addr + '@' + self.smtp_domain def errormail(self, sender, to, errors, msgsize, eml): """return an error notification message""" import cgi errmsg = """\ From: %s To: %s Date: %s Subject: Nicht zustellbare Post X-Priority: 1 X-MSMail-Priority: High MIME-Version: 1.0 Content-Type: text/html; charset="iso-8859-1" Content-Transfer-Encoding: 8bit <html> <head> <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1"> <title>Subject: Nicht zustellbare Post</title> </head> <body> Die folgende Meldung mit %d Bytes hat nicht alle Empfänger erreicht.<br> Sie wurde unter dem Namen <code>%s</code> gespeichert und muss manuell weitergeleitet werden. <hr> Die gemeldeten Fehler waren: <pre>%s</pre> <hr> Message-Header: <pre>%s</pre> <hr> </body> </html> """ % ( sender, to, rfctime(), msgsize, cgi.escape(eml), cgi.escape(errors, 1), cgi.escape(fmtlist(self.head_list), 1), ) return string.replace(errmsg, LF, CRLF) def sizemail(self, sender, to, msgsize): """return a "mail too big" notification message""" import cgi errmsg = """\ From: %s To: %s Date: %s Subject: Zu grosse Post X-Priority: 1 X-MSMail-Priority: High MIME-Version: 1.0 Content-Type: text/html; charset="iso-8859-1" Content-Transfer-Encoding: 8bit <html> <head> <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1"> <title>Subject: zu grosse Post</title> </head> <body> Die folgende Meldung mit %d Bytes ist zu gross und muss manuell abgeholt werden werden. <hr> Message-Header: <pre>%s</pre> <hr> </body> </html> """ % ( sender, to, rfctime(), msgsize, cgi.escape(fmtlist(self.head_list), 1), ) return string.replace(errmsg, LF, CRLF) def savemail(self, msgstream): if self.msgid: eml = filter(lambda c: c not in r' /\:*?"<>|', self.msgid) else: eml = 'mailgate' eml = eml + '.eml' try: f = open(eml, 'wb') f.write(msgstream) f.close() except IOError, e: print ' !!! IOError:', str(e), eml, f print msgstream eml = '[in Mailgate.log]' return eml def gateway(self, msg): """take the message msg, which is a list of strings, parse its header and pass it on to the recipients in our mail domain""" # parse the message header try: self.parse_header(msg) self.read_header() except MailgateError, e: print ' !!!', e # forward this message to the Postmaster self.sender_local = 0 if not self.sender_raw: self.sender = (self.version, self.localaddr('mailgateway')) if not self.recip: self.recip = [ ('Postmaster', self.localaddr('Postmaster')) ] msg.insert(0, 'X-Mailgateway-Error: ' + str(e)) # don't forward mails from our local domain if mailconf.forward_from_local and self.sender_local \ and self.sender[1] != self.localaddr('mailgateway'): print ' Message not forwarded: sender is local' return 0 # leave our trail in the message header trail = self.received() self.head_list.insert(0, trail) msg.insert(0, trail) msgstream = string.join(msg, CRLF) # don't forward mails that we've seen already if self.msgid: if self.msgids.has_key(self.msgid): print ' Message-ID %s already forwarded' % self.msgid return 1 # delete self.msgids[self.msgid] = self.recip # forward the message try: s = smtplib.SMTP(self.smtp_host) except socket.error, e: print 'SMTP server', self.smtp_host, 'is not responding:', sockerror(e) return -1 # s.set_debuglevel(1) rc = 0 # assume failure try: rejects = s.sendmail( self.sender[1], map(lambda p: p[1], self.recip), msgstream ) rc = 1 # success! except smtplib.SMTPRecipientsRefused, e: rejects = {str(e): 'Recipients refused'} except smtplib.SMTPSenderRefused, e: rejects = {str(e): self.sender[1]} except smtplib.SMTPDataError, e: rejects = {smtplib.SMTPDataError: ''} except socket.error, e: rejects = {sockerror(e): ''} except Exception, e: rejects = {'Error sending mail': e} # fake the rejects dict # if there were any rejects, notify our Postmaster if rejects: try: s.quit() except: pass del s # save the message eml = self.savemail(msgstream) rc = 1 # message was saved, delete it sender = self.localaddr('mailgateway') to = self.localaddr('Postmaster') errmail = self.errormail(sender, to, fmtdict(rejects, str), len(msgstream), eml) try: s = smtplib.SMTP(self.smtp_host) s.sendmail(sender, [to], errmail) except smtplib.SMTPSenderRefused, e: print str(e), sender except smtplib.SMTPDataError: print smtplib.SMTPDataError except socket.error, e: print ' !!! Can\'t send rejected message:', sockerror(e) print fmtlist(self.head_list, lambda h: ' ! ' + h) print ' ! The message was saved as', eml print errmail try: s.quit() except: pass return rc def forward_all(self): """forward all waiting messages""" self.msgids = {} # clear Message ID memory try: id = hdr = None msgs, bytes = self.pop.stat() s = '%d message%s' % (msgs, (msgs != 1) and 's' or '') if bytes: s = s + ', %d bytes' % bytes print s msglist = self.pop.list()[1] for s in msglist: id, size = string.split(s) # message id and size on POP server size = int(size) sizek = size / 1024 hdr = self.pop.top(id, 0)[1] # get header if self.maxsize and sizek > self.maxsize: print ' !!! Message id %s (%d bytes) is too big' % (id, size) sender = self.localaddr('mailgateway') to = self.localaddr('Postmaster') self.head_list = hdr[:-1] errmail = self.sizemail(sender, to, size) mail = string.split(errmail, CRLF) dele = 0 else: mail = self.pop.retr(id)[1] # get message dele = self.pop_dele ok = self.gateway(mail) if ok == -1: break elif ok == 1: if dele: self.pop.dele(id) id = hdr = None except poplib.error_proto, msg: print ' !!! forward_all:', msg, id and '(msg id %s)' % id or '' if hdr: print fmtlist(hdr, lambda h: ' ! ' + h) except socket.error, msg: print ' !!! forward_all:', sockerror(msg), id and '(msg id %s)' % id or '' if hdr: print fmtlist(hdr, lambda h: ' ! ' + h) def connect_pop(self): try: self.pop = poplib.POP3(self.pop_host) return 1 except socket.error, msg: print '\n !!! connect_pop(%s):' % self.pop_host, sockerror(msg) self.pop = None return 0 except poplib.error_proto, msg: print '\n !!! connect_pop(%s):' % self.pop_host, msg self.pop = None return 0 def login_pop(self): try: # This lame POP server our provider uses announces ifself with an APOP # timestamp, yet APOP doesn't work, so this is a configuration option. # Maybe you have more luck with your provider... if not mailconf.broken_apop: try: # try APOP first self.pop.apop(self.pop_user, self.pop_pass) return 1 except poplib.error_proto, msg: # FIXME: dependent on poplib string! if msg[0][:13] != '-ERR APOP not': raise self.pop.user(self.pop_user) self.pop.pass_(self.pop_pass) return 1 except socket.error, msg: print '\n !!! login_pop(%s):' % self.pop_host, sockerror(msg) return 0 except poplib.error_proto, msg: print '\n !!! login_pop(%s):' % self.pop_host, msg return 0 def disconnect_pop(self): if self.pop: try: self.pop.quit() except: pass self.pop = None def check_pop(self): """check the POP3 mailbox and forward all waiting messages""" print 'Checking %s (%s)...' % (self.pop_host, self.pop_user), if self.connect_pop(): if self.login_pop(): self.forward_all() self.disconnect_pop() def test_mapping(self): print '*** We are in test mode' try: msg = open(options['-t'], 'rt').read() except IOError: print 'Can\'t to open %s; using built-in test message' % options['-t'] msg = """\ Received: from dwas03 by dwas03.intern.dware.ch with SMTP (Microsoft Exchange Internet Mail Service Version 5.0.1457.7) id TCSZLP6V; Tue, 22 Sep 1998 07:30:20 +0200 Received: from pop.site1.csi.com by dwas03 with POP3 (PyPOP mail gateway 1.5) for <bruno.marti@dware.ch>; Tue, 22 Sep 1998 07:30:12 +0200 Received: from mail pickup service by csi.com with Microsoft SMTPSVC; Mon, 21 Sep 1998 17:43:04 -0400 Received: from dub-img-10.compuserve.com (dub-img-10.compuserve.com [149.174.206.140]) by hil-img-ims-3.compuserve.com (8.8.6/8.8.6/IMS-1.6) with ESMTP id RAA12794 for <dware@csi.com>; Mon, 21 Sep 1998 17:42:03 -0400 (EDT) Received: (from root@localhost) by dub-img-10.compuserve.com (8.8.6/8.8.6/2.14) id RAA24230 for dware@csi.com; Mon, 21 Sep 1998 17:42:02 -0400 (EDT) Sender: kuerit@netexp.net Date: Mon, 21 Sep 1998 17:41:42 -0400 From: "Manuel + Petra Ritter" <kuerit@netexp.net> Subject: Back Home again!! Sender: kuerit@netexp.net To: "Andreas Knott" <andreas.knott@ubs.com>, "Martin Wegmueller" <martin.wegmueller@ubs.com>, "Martin Wegmuellerpriv" <wegi@bluewin.ch>, "Marty Riessen" <ARiessen@aol.com>, "Maurice Picard" <maurice.picard@vontobel.ch>, "Guido+Gabi Bollin+Gehri" <113340.3473@compuserve.com>, "bruno marti" <dware@csi.com>, "House 66" <house66@onelist.com> Reply-To: "Manuel + Petra Ritter" <kuerit@netexp.net> Message-ID: <199809211741_MC2-5A2B-F258@compuserve.com> Hallo! """ msg = string.split(msg, '\n') self.debug = 9 # full debugging output try: self.parse_header(msg) self.read_header() except MailgateError, e: print e # end class Mailgateway def check(gw, pop): """check one pop account""" # massage arguments if len(pop) == 5: dele, map = pop[3], pop[4] elif len(pop) == 4: if type(pop[3]) == type(0): dele, map = pop[3], None else: dele, map = 1, pop[3] elif len(pop) == 3: dele, map = 1, None else: print 'Not enough pop arguments:', pop return # do it gw.config_pop(pop[0], pop[1], pop[2], dele) if map is not None: gw.config_map(map) if options.has_key('-t'): gw.test_mapping() else: gw.check_pop() def check_multi(smtp_host, smtp_domain, pop_accounts): gw = Mailgateway(smtp_host, smtp_domain) gw.debug = 1 print gw.banner() for pop in pop_accounts: check(gw, pop) print '-' * 20 def main(): """A mail gateway for dware. Retrieves mails from the spectraWEB POP3 account and passes them on to our internal MS Exchange Server.""" import sys, os, getopt sys.stderr = sys.stdout # tracebacks on stdout! global options opts, args = getopt.getopt(sys.argv[1:], 'us:t:') for o, a in opts: options[o] = a check_multi(mailconf.server, mailconf.domain, mailconf.biz_accounts) if options.has_key('-u'): check_multi(mailconf.server, mailconf.domain, mailconf.user_accounts) if __name__ == '__main__': main()