#!/usr/bin/env python # -*- coding: utf-8 -*- ''' html_footer.py This script looks for special formated plain messages and convert them into html. It is intended to use this script as a postfix message filter. It can be used as a pipe or as a standalone stmp daemon application. Usage: html_footer.py [OPTION...] -h, --help show this help message -V, --version shows version information -u, --uid=USERNAME run as uid if in daemon mode -p, --pipemode read/write message from/to stdin/stdout -d, --debuglevel=LEVEL default level = info valid levels: critical, error, warning, info, debug -l, --listen=HOST:IP port to listen on (default: 127.0.0.1:10025) -r, --remote=HOST:IP relayhost to deliver to (default: 127.0.0.1:25) -i, --imagepath=PATH path for attachments (default: /var/lib/html_footer) -f, --logfile=FILENAME -k, --kill kills daemon -p, --pidfile=FILENAME pidfile for daemon (default: /var/run/html_footer.pid) The decision if a mail has to be converted is taken by a line with the tags in the signature of the plain mail. Example: -----8<----- Dear .. best regards -- Text signature

Html signature

-----8<----- If image tags a refered in html attachment text, the should be placed in the directory IMG_PATH on the machine the script is running on. The use of inline encoded data is also possible. The img tag is only recognized if it doesn't span over a linebreak. The src-attributes content should be prefixed with file: or without any protocol directive. eg. @copyright: 2012 dass IT GmbH @author: Holger Mueller ''' import logging from pprint import pformat import sys import os import errno import getopt import email from email.mime.text import MIMEText from email.mime.image import MIMEImage from email.mime.multipart import MIMEMultipart from email.charset import Charset from email.utils import make_msgid from smtpd import PureProxy import asyncore from daemon import Daemon import re from urlparse import urlparse # Insert modification in email header X_HEADER = True # # Nothing to configure below! # __version__="20120227" class HyperTextFormatter(object): '''Parse plain text and generate hypertext''' HTML_HEADER=u''' ''' HTML_FOOTER = u'\n' # Regex for referal of image attachements RXP_IMG_TAG = re.compile(ur'(]*src=")([^"]+)("[^>]*>)', re.UNICODE) def __init__(self, header=u''): """initialize the class, a custom html header could be supplied """ if header != u'': self.txt += header else: self.txt = self.HTML_HEADER self.attachments = [] self.parts = 1 def add_txt(self, txt=u''): """add plain text and wrap it to html""" self.txt += self.txt2html(txt) def add_html(self, html=u''): """add html text without modification""" self.txt += html def add_footer(self): """extends the current html text with the default footer""" self.txt += self.HTML_FOOTER def create_mime_attachments(self): """scans current html text, creates a MIME object for every referenced image and replace src-attribute with a cid: reference to the generated MIME objects. Returns the list of generated MIME objects. """ def replacer(m): """callback function for re.sub""" url = urlparse(m.group(2)) filename = os.path.join(options.imagepath, os.path.split(url.path)[1]) fp = open(filename, 'rb') img = MIMEImage(fp.read()) img_id = make_msgid("part%i" % self.parts) img.add_header('Content-ID', img_id) img.add_header('Content-Disposition', 'attachment', filename = url.path) self.attachments.append(img) self.parts += 1 return "%scid:%s%s" %(m.group(1), img_id.strip('<>'), m.group(3)) self.txt = self.RXP_IMG_TAG.sub(replacer, self.txt) return self.attachments def get(self, add_footer=True): if add_footer: self.add_footer() return self.txt def has_attachments(self): """returns True if img tags with file: or no protocol extension found in current html text """ m = self.RXP_IMG_TAG.search(self.txt) if m: url = urlparse(m.group(2)) if (url.path and (not url.scheme or url.scheme == "file")): return True return False def txt2html(self, txt=u""): """helper function to preformat plain text""" html = u'
\n'
        html += txt
        html += u'
\n' return html class MIMEChanger(object): # Regex to split message from signature RXP_SIGNATURE = re.compile(r'(.*)^--\s+(.*)', re.MULTILINE | re.DOTALL | re.UNICODE) RXP_SIG_HTML = re.compile(ur'^\n', re.MULTILINE | re.UNICODE) def __init__(self): pass def _copy_mime_root(self, msg, strip_content = True): """Make a copy of the non_payload root mime part of msg and change content type to multipart/alternativ. By default drop old Content- headers. """ msg_new = MIMEMultipart() # drop default keys for k in msg_new.keys(): del msg_new[k] # make copy of old header for k, v in msg.items(): if strip_content and k.startswith('Content-'): continue msg_new[k] = v if msg.get_unixfrom(): msg_new.set_unixfrom(msg.get_unixfrom()) if msg.preamble: msg_new.preamble = msg.preamble else: msg_new.preamble = "This is a multi-part message in MIME format...\n" if msg.epilogue: msg_new.epilogue = msg.epilogue # set msg_new type msg_new.set_type('multipart/alternative') return msg_new def _first_text(self,msg): """returns first text/plain part of a message as unicode string""" if not msg.is_multipart(): if msg.get_content_type() != 'text/plain': return u'' else: return self._payload2unicode(msg) else: for m in msg.get_payload(): if m.get_content_type() == 'text/plain': return self._payload2unicode(m) return u'' def _payload2unicode(self, mimeobj): """convert MIME text objects to unicode string""" chrset = mimeobj.get_content_charset(Charset()) return unicode(mimeobj.get_payload(decode=True).decode(chrset)) def _process_multi(self, msg): """multipart messages can be changend in place""" # find the text/plain mime part in payload i = 0 pl = msg.get_payload() for m in pl: if m.get_content_type() == 'text/plain': break i += 1 # change it to the new payload pl[i] = self.new_payload(pl[i]) return msg def _process_plain(self, msg): """make container for plain messages""" msg_new = self._copy_mime_root(msg) new_pl = self.new_payload(msg) for m in new_pl.get_payload(): msg_new.attach(m) return msg_new def _split_content(self, txt = u''): """Cuts content from signature of mail message""" m = self.RXP_SIGNATURE.search(txt) if m: return m.groups() else: return [txt, u''] def _split_signature(self, txt = u''): """Cuts txt and html part of signature text""" return self.RXP_SIG_HTML.split(txt, 1) def alter_message(self, msg): """message modification function""" if not msg.is_multipart(): log.debug('plain message') new_msg = self._process_plain(msg) else: log.debug('multipart message') new_msg = self._process_multi(msg) if X_HEADER: log.debug('add X-Modified-By header') new_msg.add_header('X-Modified-By', 'Html Footer %s' % __version__ ) return new_msg def html_creator(self): """returns a HyperTextFormatter instance, can be overloaded in derived class for better layout creation""" return HyperTextFormatter() def msg_is_to_alter(self, msg): """check if message should be altered in this special case we look for a html/xml tag in the beginning of a line in the the first text/plain mail parts signature """ txt = self._first_text(msg) s = self._split_content(txt)[1] if self.RXP_SIG_HTML.search(s): return True else: return False def new_payload(self, mime_plain): """create a new mime structure from text/plain Examples: multipart/alternative text/plain text/html multipart/alternative text/plain multipart/related text/html image/jpg image/png """ html = self.html_creator() chrset = mime_plain.get_content_charset(Charset()) t = unicode(mime_plain.get_payload(decode=True), chrset) text, signature = self._split_content(t) html.add_txt(text) text += u'-- \n' # strip html from signature text += self._split_signature(signature)[0] state_html = True footer = u'' txtbuffer = u'' try: footer = self._split_signature(signature)[1] except IndexError: pass for l in footer.split(u'\n'): if l == u'': state_html = True if txtbuffer: html.add_txt(txtbuffer) txtbuffer = u'' elif l == u'': state_html = False else: if state_html: html.add_html(l + u'\n') else: txtbuffer += l + u'\n' text += l + u'\n' if txtbuffer: html.add_txt(txtbuffer) if html.has_attachments(): attachments = html.create_mime_attachments() msg_html = MIMEMultipart('related') msg_html.attach(MIMEText(html.get().encode('utf-8'), 'html', 'utf-8')) for a in attachments: msg_html.attach(a) else: msg_html = MIMEText(html.get().encode('utf-8'), 'html', 'utf-8') msg_plain = MIMEText(text.encode('utf-8'), 'plain', 'utf-8') pl = MIMEMultipart('alternative') pl.attach(msg_plain) pl.attach(msg_html) return pl class SMTPHTMLFooterServer(PureProxy): def process_message(self, peer, mailfrom, rcpttos, data): # TODO return error status (as SMTP answer string) if something goes wrong! try: data = modify_data(data) refused = self._deliver(mailfrom, rcpttos, data) except Exception, err: log.exception('Error on delivery: %s', err) return '550 content rejected: %s' % err # TBD: what to do with refused addresses? # print >> DEBUGSTREAM, 'we got some refusals:', refused if refused: log.error('content refused: %s', pformat(refused)) return '550 content rejected:' class FooterDaemon(Daemon): def run(self): asyncore.loop() class Options: uid = '' listen = ('127.0.0.1', 10025) remote = ('127.0.0.1', 25) debuglevel = logging.INFO cmd = 'start' pipemode = False pidfile = '/var/run/hmtl_footer.pid' imagepath = '/var/lib/html_footer' logfile = '' _txt2loglvl = { 'critical' : logging.CRITICAL, 'error': logging.ERROR, 'warning': logging.WARNING, 'info': logging.INFO, 'debug': logging.DEBUG, } def usage(code, msg=''): print >> sys.stderr, __doc__ % globals() if msg: print >> sys.stderr, msg sys.exit(code) def parseargs(): try: opts, args = getopt.getopt( sys.argv[1:], 'u:Vhpd:l:r:i:f:kp:', ['uid=', 'version', 'help', 'pipemode', 'debuglevel=', 'listen=', 'remote=', 'imagepath=', 'logfile=', 'kill', 'pidfile=']) except getopt.error, e: usage(1, e) options = Options() for opt, arg in opts: if opt in ('-h', '--help'): usage(0) elif opt in ('-V', '--version'): print >> sys.stderr, __version__ sys.exit(0) elif opt in ('-u', '--uid'): options.uid = arg elif opt in ('-p', '--pipemode'): options.pipemode = True elif opt in ('-d', '--debuglevel'): if arg in options._txt2loglvl.keys(): options.debuglevel = options._txt2loglvl[arg] else: usage(1, 'Unknown debuglevel %s', arg) elif opt in ('-l', '--listen'): i = arg.find(':') if i < 0: usage(1, 'Bad listen address: %s' % arg) try: options.listen = (arg[:i], int(arg[i+1:])) except ValueError: usage(1, 'Bad local port: %s' % arg) elif opt in ('-r', '--remote'): i = arg.find(':') if i < 0: usage(1, 'Bad remote address: %s' % arg) try: options.remote = (arg[:i], int(arg[i+1:])) except ValueError: usage(1, 'Bad remote port: %s' % arg) elif opt in ('-i', '--imagepath'): options.imagepath = arg elif opt in ('-f', '--logfile'): options.logfile = arg elif opt in ('-k', '--kill'): options.cmd = 'stop' elif opt in ('-p', '--pidfile'): options.pidfile = arg if len(args) > 0: usage(1, 'unknown arguments %s' % ', '.join(args)) return options def modify_data(msg_in): msg = email.message_from_string(msg_in) if mymime.msg_is_to_alter(msg): log.info('Msg(%s): altered' % msg.get('Message-ID','')) msg = mymime.alter_message(msg) return msg.as_string(unixfrom=True) log.debug('Msg out:\n%s' % msg.as_string(unixfrom=True)) else: log.info('Msg(%s): nothing to alter' % msg.get('Message-ID','')) return msg_in # # Main program # if __name__ == '__main__': options = parseargs() logging.basicConfig(level = options.debuglevel, filename = options.logfile) log = logging.getLogger('html_footer') # use as simple pipe filter if options.pipemode: msg_in = sys.stdin.read() log.debug('Msg in:\n%s' % msg_in) try: mymime = MIMEChanger() msg = modify_data(msg_in) log.debug('Msg out:\n%s' % msg) sys.stdout.write(msg) except Exception, err: log.exception(err) sys.stdout.write(msg_in) # run as smtpd else: mymime = MIMEChanger() daemon = FooterDaemon(options.pidfile) if options.cmd == 'stop': log.info('stopping daemon') daemon.stop() sys.exit(0) log.info('starting daemon') if options.uid: try: import pwd except ImportError: log.exception('''Cannot import module "pwd"; try running as pipe filter (-p).''') sys.exit(1) runas = pwd.getpwnam(options.uid)[2] try: os.setuid(runas) except OSError, e: if e.errno != errno.EPERM: raise log.exception('''Cannot setuid "%s"; try running as pipe filer (-p).''' % options.uid) sys.exit(1) log.debug('Creating server instance') server = SMTPHTMLFooterServer(options.listen, options.remote) # if uid is given daemonize if options.uid: daemon.start() else: asyncore.loop()