--- /dev/null
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+#
+# mailplate — apply templates to mail drafts.
+#
+# This script reformats mail drafts according to a given template. The
+# template may be specified on the command line, but mailplate can also use
+# control information from the template files to automatically select an
+# appropriate template (--auto). A selection menu feature is planned (--menu).
+#
+# Applying a template means obtainined select data from an existing mail
+# message (unless --new is specified) and to fill it into appropriate slots in
+# the template. Messages are processed in three parts: headers, body, and
+# signature.
+#
+# The template can define two types of headers: mandatory and preservatory.
+# Mandatory headers take precedence over headers in the existing message and
+# thus overwrite them. Preservatory headers instruct mailplate to port their
+# data from the existing mail message. Headers in the existing message but not
+# defined in the template are dropped, unless --keep-unknown is given.
+#
+# Body and signature are separated by '-- '. If this sentinel is not found,
+# no signature is extracted.
+#
+# Templates can be interpolated and data filled into slots. Helper slots are
+# filled with the output of helper commands (which must be defined in the
+# configuration), environment variable slots are just that, and mail variable
+# slots can be filled with data obtained by running regexps or commands over
+# the message.
+#
+# This script can be run in multiple ways:
+#
+# As a filter, it applies a template to data from stdin and writes the result
+# to stdout.
+#
+# Given a file, it modifies the file, unless it cannot write to the file, in
+# which case it writes to stdout.
+#
+# When --editor is passed, the script spawns sensible-editor on the result. It
+# may thus be used as the editor for your mail user agent.
+#
+# TODO: if headers like From are absent from the mail, they should not be kept
+# but replaced with a default.
+#
+# Copyright © martin f. krafft <madduck@debian.org>
+# Released under the terms of the Artistic Licence 2.0
+#
+
+import email
+import os
+import posix
+import re
+import sys
+import subprocess
+import ConfigParser
+from optparse import OptionParser
+
+###
+### CONSTANTS
+###
+
+MAILPLATEDIR = '~/.mailplate'               # settings directory
+CONFFILE = MAILPLATEDIR + '/config'         # configuration file
+SECTION_GENERAL = 'general'                 # name of general config section
+SECTION_HELPERS = 'helpers'                 # name of helpers config section
+TEMPLATEDIR = MAILPLATEDIR + '/templates'   # location of templates
+
+COMMENTCHAR = '#'   # character commencing a comment line in the template
+REGEXPCHAR = '*'    # character commencing a regexp line in the template
+COMMANDCHAR = '!'   # character commencing a command line in the template
+
+KEEP_SLOT_LEADER = '@'      # character commencing a keep slot
+ENV_SLOT_LEADER = '${'      # character commencing an environment variable slot
+ENV_SLOT_TRAILER = '}'      # character ending an environment variable slot
+HELPER_SLOT_LEADER = '$('   # character commencing a helper slot
+HELPER_SLOT_TRAILER = ')'   # character ending a helper slot
+
+# headers we want to preserve most of the time, and their order
+STD_HEADERS = ('From', 'To', 'Cc', 'Bcc', 'Subject', 'Reply-To', 'In-Reply-To')
+KEEP_HEADERS = { 'KEEP_FROM_HEADER' : STD_HEADERS[:1]
+               , 'KEEP_STD_HEADERS' : STD_HEADERS[1:]
+               }
+
+SIG_DELIM='\n-- \n'
+
+###
+### HELPER FUNCTION DEFINITIONS
+###
+
+def err(s):
+    sys.stderr.write(s + '\n')
+
+# obtain a regexp from a line, run it, return score/dict if matched
+def exec_regexp(line, rawmsg, name):
+    p, r = line[1:].strip().split(' ', 1)
+    m = re.compile(r, re.M | re.I | re.U).search(rawmsg)
+    if m is not None:
+        return (int(p), m.groupdict())
+    return (0, {})
+
+# obtain a command from a line, run it, return score if matched
+def exec_command(line, rawmsg, name):
+    p, r = line[1:].strip().split(' ', 1)
+    s = subprocess.Popen(r, shell=True, stdin=subprocess.PIPE,
+            stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+    try:
+        stdout, stderr = s.communicate(rawmsg)
+        if s.returncode == 0:
+            return int(p)
+        else:
+            return 0
+    except OSError:
+        err("W: command '%s' (template '%s') failed to run." % (r, name))
+        return 0
+
+def interpolate_helpers(s):
+    while True:
+        helper_begin = s.find(HELPER_SLOT_LEADER)
+        if helper_begin < 0: break
+        helper_end = s.find(HELPER_SLOT_TRAILER, helper_begin)
+        helper = s[helper_begin + len(HELPER_SLOT_LEADER):helper_end]
+        try:
+            proc = subprocess.Popen(helpers[helper], shell=True,
+                    stdout=subprocess.PIPE, stderr=sys.stderr)
+            out = proc.communicate()[0]
+            s = s[:helper_begin] + out.strip() + s[helper_end+1:]
+        except KeyError:
+            err('E: unknown helper: ' + helper)
+            sys.exit(posix.EX_DATAERR)
+    return s
+
+def interpolate_env(s):
+    while True:
+        envvar_begin = s.find(ENV_SLOT_LEADER)
+        if envvar_begin < 0: break
+        envvar_end = s.find(ENV_SLOT_TRAILER, envvar_begin)
+        envvar = s[envvar_begin + len(ENV_SLOT_LEADER):envvar_end]
+        value = os.getenv(envvar) or ''
+        s = s[:envvar_begin] + value + s[envvar_end+1:]
+    return s
+
+def interpolate_vars(s):
+    return s % vars
+
+def interpolate(s):
+    return interpolate_helpers(interpolate_env(interpolate_vars(s)))
+
+# sentinel to use as dict value for preserved headers
+class _keep_header: pass
+
+###
+### VARIABLE INITIALISATION
+###
+
+infname = None
+inf = sys.stdin
+outfname = None
+outf = sys.stdout
+templname = None
+templ = None
+vars = {}
+headers = {}
+payload = None
+
+###
+### CONFIGURATION FILE PARSING
+###
+
+CONFFILE = os.path.expanduser(CONFFILE)
+MAILPLATEDIR = os.path.expanduser(MAILPLATEDIR)
+
+# defaults
+config = { 'default_template' : ''
+         , 'template_path' : TEMPLATEDIR
+         }
+helpers = { 'get_quote' : 'fortune -s' }
+
+if not os.path.exists(CONFFILE):
+    # conffile does not exist, let's create it with defaults.
+
+    if not os.path.isdir(MAILPLATEDIR):
+        os.mkdir(MAILPLATEDIR, 0700)
+
+    if not os.path.isfile(CONFFILE):
+        f = file(CONFFILE, 'w')
+        f.write('# mailplate configuration\n[%s]\n' % SECTION_GENERAL)
+        for kvpair in config.iteritems():
+            if len(kvpair[1]) > 0:
+                f.write('%s = %s\n' % kvpair)
+
+        if len(helpers) > 0:
+            f.write('\n[%s]\n' % SECTION_HELPERS)
+            for kvpair in helpers.iteritems():
+                f.write('%s = %s\n' % kvpair)
+
+        f.close()
+
+if not os.access(CONFFILE, os.R_OK):
+    err('E: cannot read configuration file: %s' % CONFFILE)
+    sys.exit(posix.EX_OSFILE)
+
+# now parse
+parser = ConfigParser.SafeConfigParser()
+parser.read(CONFFILE)
+
+# first the GENERAL section into the config dict for all keys with defaults
+for key in config.keys():
+    try:
+        config[key] = parser.get(SECTION_GENERAL, key)
+    except ConfigParser.NoSectionError, ConfigParser.MissingSectionHeaderError:
+        err("E: no section '%s' in %s" % (SECTION_GENERAL, CONFFILE))
+        sys.exit(posix.EX_CONFIG)
+    except ConfigParser.DuplicateSectionError, ConfigParser.ParseError:
+        err('E: parse error on %s' % CONFFILE)
+        sys.exit(posix.EX_CONFIG)
+
+# all HELPERS into the helpers dict
+helpers.update(parser.items(SECTION_HELPERS))
+
+TPATH = os.path.expanduser(config['template_path'])
+
+###
+### COMMAND LINE PARSING
+###
+
+parser = OptionParser()
+parser.usage = 'Usage: $prog [options] <message>'
+parser.add_option('-a', '--auto', dest='auto',
+        default=False, action='store_true',
+        help='turn on template auto-discovery')
+parser.add_option('-m', '--menu', dest='menu',
+        default=False, action='store_true',
+        help='choose from a list of template')
+parser.add_option('-n', '--new', dest='new',
+        default=False, action='store_true',
+        help='create a new message')
+parser.add_option('-e', '--editor', dest='edit',
+        default=False, action='store_true',
+        help='spawn editor once template is applied')
+parser.add_option('-k', '--keep-unknown', dest='keep_unknown',
+        default=False, action='store_true',
+        help='preserve mail headers not specified in template')
+parser.add_option('-d', '--debug', dest='debug',
+        default=False, action='store_true',
+        help='start a debugger after initialisation')
+
+options, args = parser.parse_args()
+
+if options.debug:
+    import pdb
+    pdb.set_trace()
+
+# parse the arguments
+for arg in args:
+    if arg == '-':
+        # filename is -, so do nothing, since stdin/stdout are default
+        continue
+    elif os.path.isfile(arg):
+        # the file exists, so use it as in/out if read/writeable
+        if os.access(arg, os.R_OK):
+            infname = arg
+        if os.access(arg, os.W_OK):
+            outfname = arg
+    elif os.access(os.path.join(TPATH, arg), os.R_OK):
+        # argument referenced an existing template
+        templname = arg
+    else:
+        err('E: unknown argument: %s' % arg)
+        sys.exit(posix.EX_USAGE)
+
+# sanity checks
+if options.auto and options.menu:
+    err('E: cannot combine --auto and --menu')
+    sys.exit(posix.EX_USAGE)
+
+elif (options.auto or options.menu) and templname:
+    err('E: cannot specify a template with --auto or --menu')
+    sys.exit(posix.EX_USAGE)
+
+elif not templname and not (options.auto or options.menu):
+    err('E: no template specified')
+    sys.exit(posix.EX_USAGE)
+
+elif options.menu:
+    err('E: --menu mode not yet implemented')
+    sys.exit(posix.EX_USAGE)
+
+###
+### MAIL PROCESSING
+###
+
+# read in the message from a file, if a filename is given.
+if infname is not None:
+    inf = file(infname, 'r', 1)
+
+# read message into buffer, or preinitialise the buffer if --new is given
+if options.new:
+    rawmsg = '\n'.join((header + ': ' for k in STD_HEADERS)) + '\n'
+else:
+    rawmsg = ''.join(inf.readlines())
+
+if options.auto:
+    best_score = (0, config['default_template'], {})
+    for tf in os.listdir(TPATH):
+        tp = os.path.join(TPATH, tf)
+        if not os.path.isfile(tp) or not os.access(tp, os.R_OK): continue
+
+        # we're iterating all files in the template directory
+        # for each file, obtain and run regexps and commands and accumulate
+        # the score (and variables)
+        score = 0
+        vars = {}
+        f = open(tp, 'r')
+        for line in f:
+            if line[0] == REGEXPCHAR:
+                r = exec_regexp(line, rawmsg, tf)
+                score += r[0]
+                vars.update(r[1])
+
+            elif line[0] == COMMANDCHAR:
+                score += exec_command(line, rawmsg, tf)
+
+        # do we top the currently best score, if so then raise the bar
+        if score > best_score[0]:
+            best_score = (score, tf, vars)
+
+    templname = best_score[1]
+    print >>sys.stderr, \
+            'I: Chose profile %s with score %d.' % (templname, best_score[0])
+    vars = best_score[2]
+
+# now read in the template
+templ = file(os.path.join(TPATH, templname), 'r', 1)
+for line in templ:
+    if not options.auto and line[0] == REGEXPCHAR:
+        # obtain variables from the regexps
+        vars.update(exec_regexp(line, rawmsg, templname)[1])
+
+    if line[0] in (COMMENTCHAR, REGEXPCHAR, COMMANDCHAR):
+        continue
+
+    elif payload is not None:
+        # we're past the headers, so accumulate the payload
+        payload += line
+
+    else:
+        #TODO multiline headers
+        l = line[:-1]
+        if len(l) == 0:
+            payload = '' # end of headers
+        elif l[0] == KEEP_SLOT_LEADER and KEEP_HEADERS.has_key(l[1:]):
+            # found predefined header slot keyword
+            for header in KEEP_HEADERS[l[1:]]:
+                headers[header.lower()] = (header, _keep_header)
+        else:
+            header, content = l.split(':', 1)
+            content = content.strip()
+            if content == KEEP_SLOT_LEADER + 'KEEP':
+                # record header to be preserved
+                content = _keep_header
+            else:
+                content = interpolate(content)
+            headers[header.lower()] = (header, content)
+
+msg = email.message_from_string(rawmsg)
+
+for header, content in msg.items():
+    # iterate all existing mail headers
+    lheader = header.lower()
+    if headers.has_key(lheader):
+        # the template defines this header
+        if headers[lheader][1] == _keep_header:
+            # it's marked as keep, thus use content from email message
+            headers[lheader] = (header, content)
+    elif options.keep_unknown:
+        # the template does not define the header, but --keep-unknown was
+        # given, thus preserve the entire header field
+        headers[lheader] = (header, content)
+
+# open the output file
+if outfname is not None:
+    outf = file(outfname, 'w', 0)
+
+# print the headers, starting with the standard headers in order
+for header in STD_HEADERS:
+    lheader = header.lower()
+    if headers.get(lheader, (None, _keep_header))[1] is not _keep_header:
+        # the template header contains mandatory data, let's print it.
+        hpair = headers[lheader]
+        print >>outf, ': '.join(hpair)
+        # and remove it from the dict
+        del headers[lheader]
+
+for i, (header, content) in headers.iteritems():
+    # print all remaining headers
+    if content == _keep_header: continue
+    print >>outf, ': '.join((header, content))
+
+# print empty line to indicate end of headers.
+print >>outf
+
+# split payload of existing message into body and signature
+body = msg.get_payload().rsplit(SIG_DELIM, 1)
+signature = ''
+if len(body) == 1:
+    body = body[0]
+elif len(body) > 1:
+    signature = body[-1]
+    body = SIG_DELIM.join(body[:-1]).strip()
+# signature may now be ''
+
+# interpolate the template payload
+payload = interpolate(payload)
+# determine whether to interpolate the signature *before* inserting the body
+# to prevent text in the body from being interpolated
+keep_sig = payload.find('@KEEP_SIGNATURE') >= 0
+# interpolate body and signature
+payload = payload.replace('@KEEP_BODY', body, 1)
+if keep_sig:
+    payload = payload.replace('@KEEP_SIGNATURE', signature, 1)
+
+print >>outf, payload.rstrip()
+outf.close()
+
+if options.edit:
+    # finally, spawn the editor, if we wrote into a file
+    if outfname is None:
+        err('E: cannot use --edit without an output file.')
+        sys.exit(posix.EX_USAGE)
+
+    os.execlp('sensible-editor', 'sensible-editor', outfname)