#!/usr/bin/python # -*- coding: utf-8 -*- # # mailplate — reformat mail drafts according to templates # # Please see the mailplate(1) manpage or the homepage for more information: # http://madduck.net/code/mailplate/ # # 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 # __name__ = 'mailplate' __description__ = 'reformat mail drafts according to templates' __version__ = '0.1' __author__ = 'martin f. krafft ' __copyright__ = 'Copyright © ' + __author__ __licence__ = '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:] , 'KEEP_ALL_HEADERS' : STD_HEADERS } SIG_DELIM='\n-- \n' ### ### HELPER FUNCTION DEFINITIONS ### def err(s): sys.stderr.write('E: ' + s + '\n') def warn(s): sys.stderr.write('W: ' + s + '\n') def info(s): if not options.verbose: return sys.stderr.write('I: ' + 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: warn("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('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 ### ### COMMAND LINE PARSING ### parser = OptionParser() parser.prog = __name__ parser.version = __version__ parser.description = __description__ parser.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 templates (not yet implemented)') 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('-v', '--verbose', dest='verbose', default=False, action='store_true', help='write informational messages to stderr') parser.add_option('-d', '--debug', dest='debug', default=False, action='store_true', help='start a debugger after initialisation') parser.add_option('-V', '--version', dest='version', default=False, action='store_true', help='display version information') options, args = parser.parse_args() if options.version: print __name__, __version__ + ' — ' + __description__ print print 'Written by ' + __author__ print __copyright__ print 'Released under the ' + __licence__ sys.exit(posix.EX_OK) ### ### CONFIGURATION FILE PARSING ### CONFFILE = os.path.expanduser(CONFFILE) MAILPLATEDIR = os.path.expanduser(MAILPLATEDIR) # defaults config = { 'default_template' : 'default' , 'template_path' : TEMPLATEDIR } helpers = { 'get_quote' : 'fortune -s' } if not os.path.exists(CONFFILE): # conffile does not exist, let's create it with defaults. options.verbose = True if not os.path.isdir(MAILPLATEDIR): info('configuration directory not found, creating: ' + MAILPLATEDIR) os.mkdir(MAILPLATEDIR, 0700) if not os.path.isfile(CONFFILE): info('creating a default configuration file: ' + 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('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("no section '%s' in %s" % (SECTION_GENERAL, CONFFILE)) sys.exit(posix.EX_CONFIG) except ConfigParser.NoOptionError: continue except ConfigParser.DuplicateSectionError, ConfigParser.ParseError: err('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']) if not os.path.isdir(TPATH): info('creating template directory: ' + TPATH) os.mkdir(TPATH, 0700) default_templname = config['default_template'] if default_templname is not None: default_templpath = os.path.join(TPATH, default_templname) if not os.path.isfile(default_templpath): info('creating the default template: ' + default_templpath) f = file(default_templpath, 'w') f.write('@KEEP_STD_HEADERS\n\n@KEEP_BODY\n') f.close() 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('unknown argument, and cannot find a template by this name: %s' % arg) sys.exit(posix.EX_USAGE) # sanity checks if options.auto and options.menu: err('cannot combine --auto and --menu') sys.exit(posix.EX_USAGE) elif (options.auto or options.menu) and templname: err('cannot specify a template with --auto or --menu') sys.exit(posix.EX_USAGE) elif not templname and not (options.auto or options.menu): if default_templname is not None: templname = default_templname else: err('no template specified') sys.exit(posix.EX_USAGE) elif options.menu: err('--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 header in STD_HEADERS)) + '\n' else: rawmsg = ''.join(inf.readlines()) if options.auto: best_score = (0, default_templname, {}) 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] if templname is None: err('could not determine a template to use and no default is set') sys.exit(posix.EX_CONFIG) info('chose profile %s with score %d.' % (templname, best_score[0])) vars = best_score[2] # now read in the template templpath = os.path.join(TPATH, templname) if not os.path.isfile(templpath): err('not a template: ' + templpath) sys.exit(posix.EX_OSFILE) elif not os.access(templpath, os.R_OK): err('template ' + templpath + ' could not be read.') sys.exit(posix.EX_OSFILE) templ = file(templpath, '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: if 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: err('unknown header slot ' + l + ' found') sys.exit(posix.EX_CONFIG) 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('cannot use --edit without an output file.') sys.exit(posix.EX_USAGE) os.execlp('sensible-editor', 'sensible-editor', outfname)