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&auml;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()