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.
   2 # -*- coding: utf-8 -*-
 
   4 # mailplate — apply templates to mail drafts.
 
   6 # This script reformats mail drafts according to a given template. The
 
   7 # template may be specified on the command line, but mailplate can also use
 
   8 # control information from the template files to automatically select an
 
   9 # appropriate template (--auto). A selection menu feature is planned (--menu).
 
  11 # Applying a template means obtainined select data from an existing mail
 
  12 # message (unless --new is specified) and to fill it into appropriate slots in
 
  13 # the template. Messages are processed in three parts: headers, body, and
 
  16 # The template can define two types of headers: mandatory and preservatory.
 
  17 # Mandatory headers take precedence over headers in the existing message and
 
  18 # thus overwrite them. Preservatory headers instruct mailplate to port their
 
  19 # data from the existing mail message. Headers in the existing message but not
 
  20 # defined in the template are dropped, unless --keep-unknown is given.
 
  22 # Body and signature are separated by '-- '. If this sentinel is not found,
 
  23 # no signature is extracted.
 
  25 # Templates can be interpolated and data filled into slots. Helper slots are
 
  26 # filled with the output of helper commands (which must be defined in the
 
  27 # configuration), environment variable slots are just that, and mail variable
 
  28 # slots can be filled with data obtained by running regexps or commands over
 
  31 # This script can be run in multiple ways:
 
  33 # As a filter, it applies a template to data from stdin and writes the result
 
  36 # Given a file, it modifies the file, unless it cannot write to the file, in
 
  37 # which case it writes to stdout.
 
  39 # When --editor is passed, the script spawns sensible-editor on the result. It
 
  40 # may thus be used as the editor for your mail user agent.
 
  42 # TODO: if headers like From are absent from the mail, they should not be kept
 
  43 # but replaced with a default.
 
  45 # Copyright © martin f. krafft <madduck@debian.org>
 
  46 # Released under the terms of the Artistic Licence 2.0
 
  56 from optparse import OptionParser
 
  62 MAILPLATEDIR = '~/.mailplate'               # settings directory
 
  63 CONFFILE = MAILPLATEDIR + '/config'         # configuration file
 
  64 SECTION_GENERAL = 'general'                 # name of general config section
 
  65 SECTION_HELPERS = 'helpers'                 # name of helpers config section
 
  66 TEMPLATEDIR = MAILPLATEDIR + '/templates'   # location of templates
 
  68 COMMENTCHAR = '#'   # character commencing a comment line in the template
 
  69 REGEXPCHAR = '*'    # character commencing a regexp line in the template
 
  70 COMMANDCHAR = '!'   # character commencing a command line in the template
 
  72 KEEP_SLOT_LEADER = '@'      # character commencing a keep slot
 
  73 ENV_SLOT_LEADER = '${'      # character commencing an environment variable slot
 
  74 ENV_SLOT_TRAILER = '}'      # character ending an environment variable slot
 
  75 HELPER_SLOT_LEADER = '$('   # character commencing a helper slot
 
  76 HELPER_SLOT_TRAILER = ')'   # character ending a helper slot
 
  78 # headers we want to preserve most of the time, and their order
 
  79 STD_HEADERS = ('From', 'To', 'Cc', 'Bcc', 'Subject', 'Reply-To', 'In-Reply-To')
 
  80 KEEP_HEADERS = { 'KEEP_FROM_HEADER' : STD_HEADERS[:1]
 
  81                , 'KEEP_STD_HEADERS' : STD_HEADERS[1:]
 
  87 ### HELPER FUNCTION DEFINITIONS
 
  91     sys.stderr.write(s + '\n')
 
  93 # obtain a regexp from a line, run it, return score/dict if matched
 
  94 def exec_regexp(line, rawmsg, name):
 
  95     p, r = line[1:].strip().split(' ', 1)
 
  96     m = re.compile(r, re.M | re.I | re.U).search(rawmsg)
 
  98         return (int(p), m.groupdict())
 
 101 # obtain a command from a line, run it, return score if matched
 
 102 def exec_command(line, rawmsg, name):
 
 103     p, r = line[1:].strip().split(' ', 1)
 
 104     s = subprocess.Popen(r, shell=True, stdin=subprocess.PIPE,
 
 105             stdout=subprocess.PIPE, stderr=subprocess.PIPE)
 
 107         stdout, stderr = s.communicate(rawmsg)
 
 108         if s.returncode == 0:
 
 113         err("W: command '%s' (template '%s') failed to run." % (r, name))
 
 116 def interpolate_helpers(s):
 
 118         helper_begin = s.find(HELPER_SLOT_LEADER)
 
 119         if helper_begin < 0: break
 
 120         helper_end = s.find(HELPER_SLOT_TRAILER, helper_begin)
 
 121         helper = s[helper_begin + len(HELPER_SLOT_LEADER):helper_end]
 
 123             proc = subprocess.Popen(helpers[helper], shell=True,
 
 124                     stdout=subprocess.PIPE, stderr=sys.stderr)
 
 125             out = proc.communicate()[0]
 
 126             s = s[:helper_begin] + out.strip() + s[helper_end+1:]
 
 128             err('E: unknown helper: ' + helper)
 
 129             sys.exit(posix.EX_DATAERR)
 
 132 def interpolate_env(s):
 
 134         envvar_begin = s.find(ENV_SLOT_LEADER)
 
 135         if envvar_begin < 0: break
 
 136         envvar_end = s.find(ENV_SLOT_TRAILER, envvar_begin)
 
 137         envvar = s[envvar_begin + len(ENV_SLOT_LEADER):envvar_end]
 
 138         value = os.getenv(envvar) or ''
 
 139         s = s[:envvar_begin] + value + s[envvar_end+1:]
 
 142 def interpolate_vars(s):
 
 146     return interpolate_helpers(interpolate_env(interpolate_vars(s)))
 
 148 # sentinel to use as dict value for preserved headers
 
 149 class _keep_header: pass
 
 152 ### VARIABLE INITIALISATION
 
 166 ### CONFIGURATION FILE PARSING
 
 169 CONFFILE = os.path.expanduser(CONFFILE)
 
 170 MAILPLATEDIR = os.path.expanduser(MAILPLATEDIR)
 
 173 config = { 'default_template' : ''
 
 174          , 'template_path' : TEMPLATEDIR
 
 176 helpers = { 'get_quote' : 'fortune -s' }
 
 178 if not os.path.exists(CONFFILE):
 
 179     # conffile does not exist, let's create it with defaults.
 
 181     if not os.path.isdir(MAILPLATEDIR):
 
 182         os.mkdir(MAILPLATEDIR, 0700)
 
 184     if not os.path.isfile(CONFFILE):
 
 185         f = file(CONFFILE, 'w')
 
 186         f.write('# mailplate configuration\n[%s]\n' % SECTION_GENERAL)
 
 187         for kvpair in config.iteritems():
 
 188             if len(kvpair[1]) > 0:
 
 189                 f.write('%s = %s\n' % kvpair)
 
 192             f.write('\n[%s]\n' % SECTION_HELPERS)
 
 193             for kvpair in helpers.iteritems():
 
 194                 f.write('%s = %s\n' % kvpair)
 
 198 if not os.access(CONFFILE, os.R_OK):
 
 199     err('E: cannot read configuration file: %s' % CONFFILE)
 
 200     sys.exit(posix.EX_OSFILE)
 
 203 parser = ConfigParser.SafeConfigParser()
 
 204 parser.read(CONFFILE)
 
 206 # first the GENERAL section into the config dict for all keys with defaults
 
 207 for key in config.keys():
 
 209         config[key] = parser.get(SECTION_GENERAL, key)
 
 210     except ConfigParser.NoSectionError, ConfigParser.MissingSectionHeaderError:
 
 211         err("E: no section '%s' in %s" % (SECTION_GENERAL, CONFFILE))
 
 212         sys.exit(posix.EX_CONFIG)
 
 213     except ConfigParser.DuplicateSectionError, ConfigParser.ParseError:
 
 214         err('E: parse error on %s' % CONFFILE)
 
 215         sys.exit(posix.EX_CONFIG)
 
 217 # all HELPERS into the helpers dict
 
 218 helpers.update(parser.items(SECTION_HELPERS))
 
 220 TPATH = os.path.expanduser(config['template_path'])
 
 223 ### COMMAND LINE PARSING
 
 226 parser = OptionParser()
 
 227 parser.usage = '%prog [options] <message>'
 
 228 parser.add_option('-a', '--auto', dest='auto',
 
 229         default=False, action='store_true',
 
 230         help='turn on template auto-discovery')
 
 231 parser.add_option('-m', '--menu', dest='menu',
 
 232         default=False, action='store_true',
 
 233         help='choose from a list of template')
 
 234 parser.add_option('-n', '--new', dest='new',
 
 235         default=False, action='store_true',
 
 236         help='create a new message')
 
 237 parser.add_option('-e', '--editor', dest='edit',
 
 238         default=False, action='store_true',
 
 239         help='spawn editor once template is applied')
 
 240 parser.add_option('-k', '--keep-unknown', dest='keep_unknown',
 
 241         default=False, action='store_true',
 
 242         help='preserve mail headers not specified in template')
 
 243 parser.add_option('-d', '--debug', dest='debug',
 
 244         default=False, action='store_true',
 
 245         help='start a debugger after initialisation')
 
 247 options, args = parser.parse_args()
 
 253 # parse the arguments
 
 256         # filename is -, so do nothing, since stdin/stdout are default
 
 258     elif os.path.isfile(arg):
 
 259         # the file exists, so use it as in/out if read/writeable
 
 260         if os.access(arg, os.R_OK):
 
 262         if os.access(arg, os.W_OK):
 
 264     elif os.access(os.path.join(TPATH, arg), os.R_OK):
 
 265         # argument referenced an existing template
 
 268         err('E: unknown argument: %s' % arg)
 
 269         sys.exit(posix.EX_USAGE)
 
 272 if options.auto and options.menu:
 
 273     err('E: cannot combine --auto and --menu')
 
 274     sys.exit(posix.EX_USAGE)
 
 276 elif (options.auto or options.menu) and templname:
 
 277     err('E: cannot specify a template with --auto or --menu')
 
 278     sys.exit(posix.EX_USAGE)
 
 280 elif not templname and not (options.auto or options.menu):
 
 281     err('E: no template specified')
 
 282     sys.exit(posix.EX_USAGE)
 
 285     err('E: --menu mode not yet implemented')
 
 286     sys.exit(posix.EX_USAGE)
 
 292 # read in the message from a file, if a filename is given.
 
 293 if infname is not None:
 
 294     inf = file(infname, 'r', 1)
 
 296 # read message into buffer, or preinitialise the buffer if --new is given
 
 298     rawmsg = '\n'.join((header + ': ' for header in STD_HEADERS)) + '\n'
 
 300     rawmsg = ''.join(inf.readlines())
 
 303     best_score = (0, config['default_template'], {})
 
 304     for tf in os.listdir(TPATH):
 
 305         tp = os.path.join(TPATH, tf)
 
 306         if not os.path.isfile(tp) or not os.access(tp, os.R_OK): continue
 
 308         # we're iterating all files in the template directory
 
 309         # for each file, obtain and run regexps and commands and accumulate
 
 310         # the score (and variables)
 
 315             if line[0] == REGEXPCHAR:
 
 316                 r = exec_regexp(line, rawmsg, tf)
 
 320             elif line[0] == COMMANDCHAR:
 
 321                 score += exec_command(line, rawmsg, tf)
 
 323         # do we top the currently best score, if so then raise the bar
 
 324         if score > best_score[0]:
 
 325             best_score = (score, tf, vars)
 
 327     templname = best_score[1]
 
 328     print >>sys.stderr, \
 
 329             'I: Chose profile %s with score %d.' % (templname, best_score[0])
 
 332 # now read in the template
 
 333 templ = file(os.path.join(TPATH, templname), 'r', 1)
 
 335     if not options.auto and line[0] == REGEXPCHAR:
 
 336         # obtain variables from the regexps
 
 337         vars.update(exec_regexp(line, rawmsg, templname)[1])
 
 339     if line[0] in (COMMENTCHAR, REGEXPCHAR, COMMANDCHAR):
 
 342     elif payload is not None:
 
 343         # we're past the headers, so accumulate the payload
 
 347         #TODO multiline headers
 
 350             payload = '' # end of headers
 
 351         elif l[0] == KEEP_SLOT_LEADER and KEEP_HEADERS.has_key(l[1:]):
 
 352             # found predefined header slot keyword
 
 353             for header in KEEP_HEADERS[l[1:]]:
 
 354                 headers[header.lower()] = (header, _keep_header)
 
 356             header, content = l.split(':', 1)
 
 357             content = content.strip()
 
 358             if content == KEEP_SLOT_LEADER + 'KEEP':
 
 359                 # record header to be preserved
 
 360                 content = _keep_header
 
 362                 content = interpolate(content)
 
 363             headers[header.lower()] = (header, content)
 
 365 msg = email.message_from_string(rawmsg)
 
 367 for header, content in msg.items():
 
 368     # iterate all existing mail headers
 
 369     lheader = header.lower()
 
 370     if headers.has_key(lheader):
 
 371         # the template defines this header
 
 372         if headers[lheader][1] == _keep_header:
 
 373             # it's marked as keep, thus use content from email message
 
 374             headers[lheader] = (header, content)
 
 375     elif options.keep_unknown:
 
 376         # the template does not define the header, but --keep-unknown was
 
 377         # given, thus preserve the entire header field
 
 378         headers[lheader] = (header, content)
 
 380 # open the output file
 
 381 if outfname is not None:
 
 382     outf = file(outfname, 'w', 0)
 
 384 # print the headers, starting with the standard headers in order
 
 385 for header in STD_HEADERS:
 
 386     lheader = header.lower()
 
 387     if headers.get(lheader, (None, _keep_header))[1] is not _keep_header:
 
 388         # the template header contains mandatory data, let's print it.
 
 389         hpair = headers[lheader]
 
 390         print >>outf, ': '.join(hpair)
 
 391         # and remove it from the dict
 
 394 for i, (header, content) in headers.iteritems():
 
 395     # print all remaining headers
 
 396     if content == _keep_header: continue
 
 397     print >>outf, ': '.join((header, content))
 
 399 # print empty line to indicate end of headers.
 
 402 # split payload of existing message into body and signature
 
 403 body = msg.get_payload().rsplit(SIG_DELIM, 1)
 
 409     body = SIG_DELIM.join(body[:-1]).strip()
 
 410 # signature may now be ''
 
 412 # interpolate the template payload
 
 413 payload = interpolate(payload)
 
 414 # determine whether to interpolate the signature *before* inserting the body
 
 415 # to prevent text in the body from being interpolated
 
 416 keep_sig = payload.find('@KEEP_SIGNATURE') >= 0
 
 417 # interpolate body and signature
 
 418 payload = payload.replace('@KEEP_BODY', body, 1)
 
 420     payload = payload.replace('@KEEP_SIGNATURE', signature, 1)
 
 422 print >>outf, payload.rstrip()
 
 426     # finally, spawn the editor, if we wrote into a file
 
 428         err('E: cannot use --edit without an output file.')
 
 429         sys.exit(posix.EX_USAGE)
 
 431     os.execlp('sensible-editor', 'sensible-editor', outfname)