]> git.madduck.net Git - code/mailplate.git/commitdiff

madduck's git repository

Every one of the projects in this repository is available at the canonical URL git://git.madduck.net/madduck/pub/<projectpath> — see each project's metadata for the exact URL.

All patches and comments are welcome. Please squash your changes to logical commits before using git-format-patch and git-send-email to patches@git.madduck.net. If you'd read over the Git project's submission guidelines and adhered to them, I'd be especially grateful.

SSH access, as well as push access can be individually arranged.

If you use my repositories frequently, consider adding the following snippet to ~/.gitconfig and using the third clone URL listed for each project:

[url "git://git.madduck.net/madduck/"]
  insteadOf = madduck:

initial checkin
authormartin f. krafft <madduck@madduck.net>
Thu, 6 Sep 2007 14:24:52 +0000 (16:24 +0200)
committermartin f. krafft <madduck@madduck.net>
Thu, 6 Sep 2007 14:24:52 +0000 (16:24 +0200)
mailplate [new file with mode: 0755]

diff --git a/mailplate b/mailplate
new file mode 100755 (executable)
index 0000000..d6dbc80
--- /dev/null
+++ b/mailplate
@@ -0,0 +1,431 @@
+#!/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)