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 — reformat mail drafts according to templates
 
   6 # Please see the mailplate(1) manpage or the homepage for more information:
 
   7 #   http://madduck.net/code/mailplate/
 
   9 # TODO: if headers like From are absent from the mail, they should not be kept
 
  10 # but replaced with a default.
 
  12 # Copyright © martin f. krafft <madduck@madduck.net>
 
  13 # Released under the terms of the Artistic Licence 2.0
 
  16 __name__ = 'mailplate'
 
  17 __description__ = 'reformat mail drafts according to templates'
 
  19 __author__ = 'martin f. krafft <madduck@madduck.net>'
 
  20 __copyright__ = 'Copyright © ' + __author__
 
  21 __licence__ = 'Artistic Licence 2.0'
 
  30 from optparse import OptionParser
 
  36 MAILPLATEDIR = '~/.mailplate'               # settings directory
 
  37 CONFFILE = MAILPLATEDIR + '/config'         # configuration file
 
  38 SECTION_GENERAL = 'general'                 # name of general config section
 
  39 SECTION_HELPERS = 'helpers'                 # name of helpers config section
 
  40 TEMPLATEDIR = MAILPLATEDIR + '/templates'   # location of templates
 
  42 COMMENTCHAR = '#'   # character commencing a comment line in the template
 
  43 REGEXPCHAR = '*'    # character commencing a regexp line in the template
 
  44 COMMANDCHAR = '!'   # character commencing a command line in the template
 
  46 KEEP_SLOT_LEADER = '@'      # character commencing a keep slot
 
  47 ENV_SLOT_LEADER = '${'      # character commencing an environment variable slot
 
  48 ENV_SLOT_TRAILER = '}'      # character ending an environment variable slot
 
  49 HELPER_SLOT_LEADER = '$('   # character commencing a helper slot
 
  50 HELPER_SLOT_TRAILER = ')'   # character ending a helper slot
 
  52 # headers we want to preserve most of the time, and their order
 
  53 STD_HEADERS = ('From', 'To', 'Cc', 'Bcc', 'Subject', 'Reply-To', 'In-Reply-To')
 
  54 KEEP_HEADERS = { 'KEEP_FROM_HEADER' : STD_HEADERS[:1]
 
  55                , 'KEEP_STD_HEADERS' : STD_HEADERS[1:]
 
  56                , 'KEEP_ALL_HEADERS' : STD_HEADERS
 
  62 ### HELPER FUNCTION DEFINITIONS
 
  66     sys.stderr.write('E: ' + s + '\n')
 
  69     sys.stderr.write('W: ' + s + '\n')
 
  72     if not options.verbose: return
 
  73     sys.stderr.write('I: ' + s + '\n')
 
  75 # obtain a regexp from a line, run it, return score/dict if matched
 
  76 def exec_regexp(line, rawmsg, name):
 
  77     p, r = line[1:].strip().split(' ', 1)
 
  78     m = re.compile(r, re.M | re.I | re.U).search(rawmsg)
 
  80         return (int(p), m.groupdict())
 
  83 # obtain a command from a line, run it, return score if matched
 
  84 def exec_command(line, rawmsg, name):
 
  85     p, r = line[1:].strip().split(' ', 1)
 
  86     s = subprocess.Popen(r, shell=True, stdin=subprocess.PIPE,
 
  87             stdout=subprocess.PIPE, stderr=subprocess.PIPE)
 
  89         stdout, stderr = s.communicate(rawmsg)
 
  95         warn("command '%s' (template '%s') failed to run." % (r, name))
 
  98 def interpolate_helpers(s):
 
 100         helper_begin = s.find(HELPER_SLOT_LEADER)
 
 101         if helper_begin < 0: break
 
 102         helper_end = s.find(HELPER_SLOT_TRAILER, helper_begin)
 
 103         helper = s[helper_begin + len(HELPER_SLOT_LEADER):helper_end]
 
 105             proc = subprocess.Popen(helpers[helper], shell=True,
 
 106                     stdout=subprocess.PIPE, stderr=sys.stderr)
 
 107             out = proc.communicate()[0]
 
 108             s = s[:helper_begin] + out.strip().decode() + s[helper_end+1:]
 
 110             err('unknown helper: ' + helper)
 
 111             sys.exit(posix.EX_DATAERR)
 
 114 def interpolate_env(s):
 
 116         envvar_begin = s.find(ENV_SLOT_LEADER)
 
 117         if envvar_begin < 0: break
 
 118         envvar_end = s.find(ENV_SLOT_TRAILER, envvar_begin)
 
 119         envvar = s[envvar_begin + len(ENV_SLOT_LEADER):envvar_end]
 
 120         value = os.getenv(envvar) or ''
 
 121         s = s[:envvar_begin] + value + s[envvar_end+1:]
 
 124 def interpolate_vars(s):
 
 128     return interpolate_helpers(interpolate_env(interpolate_vars(s)))
 
 130 # sentinel to use as dict value for preserved headers
 
 131 class _keep_header: pass
 
 134 ### VARIABLE INITIALISATION
 
 148 ### COMMAND LINE PARSING
 
 151 parser = OptionParser()
 
 152 parser.prog = __name__
 
 153 parser.version = __version__
 
 154 parser.description = __description__
 
 155 parser.usage = '%prog [options] <message>'
 
 156 parser.add_option('-a', '--auto', dest='auto',
 
 157         default=False, action='store_true',
 
 158         help='turn on template auto-discovery')
 
 159 parser.add_option('-m', '--menu', dest='menu',
 
 160         default=False, action='store_true',
 
 161         help='choose from a list of templates (not yet implemented)')
 
 162 parser.add_option('-n', '--new', dest='new',
 
 163         default=False, action='store_true',
 
 164         help='create a new message')
 
 165 parser.add_option('-e', '--editor', dest='edit',
 
 166         default=False, action='store_true',
 
 167         help='spawn editor once template is applied')
 
 168 parser.add_option('-k', '--keep-unknown', dest='keep_unknown',
 
 169         default=False, action='store_true',
 
 170         help='preserve mail headers not specified in template')
 
 171 parser.add_option('-v', '--verbose', dest='verbose',
 
 172         default=False, action='store_true',
 
 173         help='write informational messages to stderr')
 
 174 parser.add_option('-d', '--debug', dest='debug',
 
 175         default=False, action='store_true',
 
 176         help='start a debugger after initialisation')
 
 177 parser.add_option('-V', '--version', dest='version',
 
 178         default=False, action='store_true',
 
 179         help='display version information')
 
 181 options, args = parser.parse_args()
 
 184     print(__name__, __version__ + ' — ' + __description__)
 
 186     print('Written by ' + __author__)
 
 188     print('Released under the ' + __licence__)
 
 189     sys.exit(posix.EX_OK)
 
 192 ### CONFIGURATION FILE PARSING
 
 195 CONFFILE = os.path.expanduser(CONFFILE)
 
 196 MAILPLATEDIR = os.path.expanduser(MAILPLATEDIR)
 
 199 config = { 'default_template' : 'default'
 
 200          , 'template_path' : TEMPLATEDIR
 
 202 helpers = { 'get_quote' : 'fortune -s' }
 
 204 if not os.path.exists(CONFFILE):
 
 205     # conffile does not exist, let's create it with defaults.
 
 206     options.verbose = True
 
 208     if not os.path.isdir(MAILPLATEDIR):
 
 209         info('configuration directory not found, creating: ' + MAILPLATEDIR)
 
 210         os.mkdir(MAILPLATEDIR, o0700)
 
 212     if not os.path.isfile(CONFFILE):
 
 213         info('creating a default configuration file: ' + CONFFILE)
 
 214         f = file(CONFFILE, 'w')
 
 215         f.write('# mailplate configuration\n[%s]\n' % SECTION_GENERAL)
 
 216         for kvpair in config.iteritems():
 
 217             if len(kvpair[1]) > 0:
 
 218                 f.write('%s = %s\n' % kvpair)
 
 221             f.write('\n[%s]\n' % SECTION_HELPERS)
 
 222             for kvpair in helpers.iteritems():
 
 223                 f.write('%s = %s\n' % kvpair)
 
 227 if not os.access(CONFFILE, os.R_OK):
 
 228     err('cannot read configuration file: %s' % CONFFILE)
 
 229     sys.exit(posix.EX_OSFILE)
 
 232 parser = configparser.ConfigParser()
 
 233 parser.read(CONFFILE)
 
 235 # first the GENERAL section into the config dict for all keys with defaults
 
 236 for key in config.keys():
 
 238         config[key] = parser.get(SECTION_GENERAL, key)
 
 239     except (configparser.NoSectionError,
 
 240             configparser.MissingSectionHeaderError):
 
 241         err("no section '%s' in %s" % (SECTION_GENERAL, CONFFILE))
 
 242         sys.exit(posix.EX_CONFIG)
 
 243     except configparser.NoOptionError:
 
 245     except (configparser.DuplicateSectionError, 
 
 246             configparser.ParseError):
 
 247         err('parse error on %s' % CONFFILE)
 
 248         sys.exit(posix.EX_CONFIG)
 
 250 # all HELPERS into the helpers dict
 
 251 helpers.update(parser.items(SECTION_HELPERS))
 
 253 TPATH = os.path.expanduser(config['template_path'])
 
 254 if not os.path.isdir(TPATH):
 
 255     info('creating template directory: ' + TPATH)
 
 256     os.mkdir(TPATH, o0700)
 
 258 default_templname = config['default_template']
 
 259 if default_templname is not None:
 
 260     default_templpath = os.path.join(TPATH, default_templname)
 
 261     if not os.path.isfile(default_templpath):
 
 262         info('creating the default template: ' + default_templpath)
 
 263         f = open(default_templpath, 'w')
 
 264         f.write('@KEEP_STD_HEADERS\n\n@KEEP_BODY\n')
 
 271 # parse the arguments
 
 276     elif arg.find(os.path.sep) == -1 and os.access(os.path.join(TPATH, arg), os.R_OK):
 
 277         if templname is not None:
 
 278             err("template already specified (%s), unsure what to do with '%s'"
 
 280             sys.exit(posix.EX_USAGE)
 
 281         # argument references an existing template
 
 283     elif os.path.isfile(arg):
 
 284         if infname is not None:
 
 285             err("input file already specified (%s), unsure what to do with '%s'"
 
 287             sys.exit(posix.EX_USAGE)
 
 288         # the file exists, so use it as in/out if read/writeable
 
 289         if os.access(arg, os.R_OK):
 
 291         if os.access(arg, os.W_OK):
 
 294         err('unknown argument: %s' % arg)
 
 295         sys.exit(posix.EX_USAGE)
 
 298 if options.auto and options.menu:
 
 299     err('cannot combine --auto and --menu')
 
 300     sys.exit(posix.EX_USAGE)
 
 302 elif (options.auto or options.menu) and templname:
 
 303     err('cannot specify a template with --auto or --menu')
 
 304     sys.exit(posix.EX_USAGE)
 
 306 elif not templname and not (options.auto or options.menu):
 
 307     if default_templname is not None:
 
 308         templname = default_templname
 
 310         err('no template specified')
 
 311         sys.exit(posix.EX_USAGE)
 
 314     err('--menu mode not yet implemented')
 
 315     sys.exit(posix.EX_USAGE)
 
 321 # read in the message from a file, if a filename is given.
 
 322 if infname is not None:
 
 323     inf = open(infname, 'r', 1)
 
 325 # read message into buffer, or preinitialise the buffer if --new is given
 
 327     rawmsg = '\n'.join((header + ': ' for header in STD_HEADERS)) + '\n'
 
 329     rawmsg = ''.join(inf.readlines())
 
 332     best_score = (0, default_templname, {})
 
 333     for tf in os.listdir(TPATH):
 
 334         tp = os.path.join(TPATH, tf)
 
 335         if not os.path.isfile(tp) or not os.access(tp, os.R_OK): continue
 
 337         # we're iterating all files in the template directory
 
 338         # for each file, obtain and run regexps and commands and accumulate
 
 339         # the score (and variables)
 
 344             if line[0] == REGEXPCHAR:
 
 345                 r = exec_regexp(line, rawmsg, tf)
 
 349             elif line[0] == COMMANDCHAR:
 
 350                 score += exec_command(line, rawmsg, tf)
 
 352         # do we top the currently best score, if so then raise the bar
 
 353         if score > best_score[0]:
 
 354             best_score = (score, tf, vars)
 
 356     templname = best_score[1]
 
 358     if templname is None:
 
 359         err('could not determine a template to use and no default is set')
 
 360         sys.exit(posix.EX_CONFIG)
 
 362     info('chose profile %s with score %d.' % (templname, best_score[0]))
 
 365 # now read in the template
 
 366 templpath = os.path.join(TPATH, templname)
 
 368 if not os.path.isfile(templpath):
 
 369     err('not a template: ' + templpath)
 
 370     sys.exit(posix.EX_OSFILE)
 
 372 elif not os.access(templpath, os.R_OK):
 
 373     err('template ' + templpath + ' could not be read.')
 
 374     sys.exit(posix.EX_OSFILE)
 
 376 templ = open(templpath, 'r', 1)
 
 379     if not options.auto and line[0] == REGEXPCHAR:
 
 380         # obtain variables from the regexps
 
 381         vars.update(exec_regexp(line, rawmsg, templname)[1])
 
 383     if line[0] in (COMMENTCHAR, REGEXPCHAR, COMMANDCHAR):
 
 386     elif payload is not None:
 
 387         # we're past the headers, so accumulate the payload
 
 391         #TODO multiline headers
 
 394             payload = '' # end of headers
 
 395         elif l[0] == KEEP_SLOT_LEADER:
 
 396             if l[1:] in KEEP_HEADERS:
 
 397                 # found predefined header slot keyword
 
 398                 for header in KEEP_HEADERS[l[1:]]:
 
 399                     headers[header.lower()] = (header, _keep_header)
 
 401                 err('unknown header slot ' + l + ' found')
 
 402                 sys.exit(posix.EX_CONFIG)
 
 404             header, content = l.split(':', 1)
 
 405             content = content.strip()
 
 406             if content == KEEP_SLOT_LEADER + 'KEEP':
 
 407                 # record header to be preserved
 
 408                 content = _keep_header
 
 410                 content = interpolate(content)
 
 411             headers[header.lower()] = (header, content)
 
 413 msg = email.message_from_string(rawmsg)
 
 415 for header, content in msg.items():
 
 416     # iterate all existing mail headers
 
 417     lheader = header.lower()
 
 418     if lheader in headers:
 
 419         # the template defines this header
 
 420         if headers[lheader][1] == _keep_header:
 
 421             # it's marked as keep, thus use content from email message
 
 422             headers[lheader] = (header, content)
 
 423     elif options.keep_unknown:
 
 424         # the template does not define the header, but --keep-unknown was
 
 425         # given, thus preserve the entire header field
 
 426         headers[lheader] = (header, content)
 
 428 # open the output file
 
 429 if outfname is not None:
 
 430     outf = open(outfname, 'w')
 
 432 # print the headers, starting with the standard headers in order
 
 433 for header in STD_HEADERS:
 
 434     lheader = header.lower()
 
 435     if headers.get(lheader, (None, _keep_header))[1] is not _keep_header:
 
 436         # the template header contains mandatory data, let's print it.
 
 437         hpair = headers[lheader]
 
 438         print(': '.join(hpair), file=outf)
 
 439         # and remove it from the dict
 
 442 for i, (header, content) in headers.items():
 
 443     # print all remaining headers
 
 444     if content == _keep_header: continue
 
 445     print(': '.join((header, content)), file=outf)
 
 447 # print empty line to indicate end of headers.
 
 450 # split payload of existing message into body and signature
 
 451 body = msg.get_payload().rsplit(SIG_DELIM, 1)
 
 457     body = SIG_DELIM.join(body[:-1]).strip()
 
 458 # signature may now be ''
 
 460 # interpolate the template payload
 
 461 payload = interpolate(payload)
 
 462 # determine whether to interpolate the signature *before* inserting the body
 
 463 # to prevent text in the body from being interpolated
 
 464 keep_sig = payload.find('@KEEP_SIGNATURE') >= 0
 
 465 # interpolate body and signature
 
 466 payload = payload.replace('@KEEP_BODY', body, 1)
 
 468     payload = payload.replace('@KEEP_SIGNATURE', signature, 1)
 
 470 print(payload.rstrip(), file=outf)
 
 474     # finally, spawn the editor, if we wrote into a file
 
 476         err('cannot use --edit without an output file.')
 
 477         sys.exit(posix.EX_USAGE)
 
 479     os.execlp('sensible-editor', 'sensible-editor', outfname)