From: martin f. krafft Date: Thu, 6 Sep 2007 14:24:52 +0000 (+0200) Subject: initial checkin X-Git-Tag: mailplate-0.1~15 X-Git-Url: https://git.madduck.net/code/mailplate.git/commitdiff_plain/70a4199b87bc3f0e2dc3c974dbe26bdd50201079 initial checkin --- 70a4199b87bc3f0e2dc3c974dbe26bdd50201079 diff --git a/mailplate b/mailplate new file mode 100755 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 +# 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] ' +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)