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 # 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@madduck.net>
46 # Released under the terms of the Artistic Licence 2.0
49 __name__ = 'mailplate'
50 __description__ = 'reformat mail drafts according to templates'
52 __author__ = 'martin f. krafft <madduck@madduck.net>'
53 __copyright__ = 'Copyright © ' + __author__
54 __licence__ = 'Artistic Licence 2.0'
63 from optparse import OptionParser
69 MAILPLATEDIR = '~/.mailplate' # settings directory
70 CONFFILE = MAILPLATEDIR + '/config' # configuration file
71 SECTION_GENERAL = 'general' # name of general config section
72 SECTION_HELPERS = 'helpers' # name of helpers config section
73 TEMPLATEDIR = MAILPLATEDIR + '/templates' # location of templates
75 COMMENTCHAR = '#' # character commencing a comment line in the template
76 REGEXPCHAR = '*' # character commencing a regexp line in the template
77 COMMANDCHAR = '!' # character commencing a command line in the template
79 KEEP_SLOT_LEADER = '@' # character commencing a keep slot
80 ENV_SLOT_LEADER = '${' # character commencing an environment variable slot
81 ENV_SLOT_TRAILER = '}' # character ending an environment variable slot
82 HELPER_SLOT_LEADER = '$(' # character commencing a helper slot
83 HELPER_SLOT_TRAILER = ')' # character ending a helper slot
85 # headers we want to preserve most of the time, and their order
86 STD_HEADERS = ('From', 'To', 'Cc', 'Bcc', 'Subject', 'Reply-To', 'In-Reply-To')
87 KEEP_HEADERS = { 'KEEP_FROM_HEADER' : STD_HEADERS[:1]
88 , 'KEEP_STD_HEADERS' : STD_HEADERS[1:]
89 , 'KEEP_ALL_HEADERS' : STD_HEADERS
95 ### HELPER FUNCTION DEFINITIONS
99 sys.stderr.write('E: ' + s + '\n')
102 sys.stderr.write('W: ' + s + '\n')
105 if not options.verbose: return
106 sys.stderr.write('I: ' + s + '\n')
108 # obtain a regexp from a line, run it, return score/dict if matched
109 def exec_regexp(line, rawmsg, name):
110 p, r = line[1:].strip().split(' ', 1)
111 m = re.compile(r, re.M | re.I | re.U).search(rawmsg)
113 return (int(p), m.groupdict())
116 # obtain a command from a line, run it, return score if matched
117 def exec_command(line, rawmsg, name):
118 p, r = line[1:].strip().split(' ', 1)
119 s = subprocess.Popen(r, shell=True, stdin=subprocess.PIPE,
120 stdout=subprocess.PIPE, stderr=subprocess.PIPE)
122 stdout, stderr = s.communicate(rawmsg)
123 if s.returncode == 0:
128 warn("command '%s' (template '%s') failed to run." % (r, name))
131 def interpolate_helpers(s):
133 helper_begin = s.find(HELPER_SLOT_LEADER)
134 if helper_begin < 0: break
135 helper_end = s.find(HELPER_SLOT_TRAILER, helper_begin)
136 helper = s[helper_begin + len(HELPER_SLOT_LEADER):helper_end]
138 proc = subprocess.Popen(helpers[helper], shell=True,
139 stdout=subprocess.PIPE, stderr=sys.stderr)
140 out = proc.communicate()[0]
141 s = s[:helper_begin] + out.strip() + s[helper_end+1:]
143 err('unknown helper: ' + helper)
144 sys.exit(posix.EX_DATAERR)
147 def interpolate_env(s):
149 envvar_begin = s.find(ENV_SLOT_LEADER)
150 if envvar_begin < 0: break
151 envvar_end = s.find(ENV_SLOT_TRAILER, envvar_begin)
152 envvar = s[envvar_begin + len(ENV_SLOT_LEADER):envvar_end]
153 value = os.getenv(envvar) or ''
154 s = s[:envvar_begin] + value + s[envvar_end+1:]
157 def interpolate_vars(s):
161 return interpolate_helpers(interpolate_env(interpolate_vars(s)))
163 # sentinel to use as dict value for preserved headers
164 class _keep_header: pass
167 ### VARIABLE INITIALISATION
181 ### COMMAND LINE PARSING
184 parser = OptionParser()
185 parser.prog = __name__
186 parser.version = __version__
187 parser.description = __description__
188 parser.usage = '%prog [options] <message>'
189 parser.add_option('-a', '--auto', dest='auto',
190 default=False, action='store_true',
191 help='turn on template auto-discovery')
192 parser.add_option('-m', '--menu', dest='menu',
193 default=False, action='store_true',
194 help='choose from a list of templates (not yet implemented)')
195 parser.add_option('-n', '--new', dest='new',
196 default=False, action='store_true',
197 help='create a new message')
198 parser.add_option('-e', '--editor', dest='edit',
199 default=False, action='store_true',
200 help='spawn editor once template is applied')
201 parser.add_option('-k', '--keep-unknown', dest='keep_unknown',
202 default=False, action='store_true',
203 help='preserve mail headers not specified in template')
204 parser.add_option('-v', '--verbose', dest='verbose',
205 default=False, action='store_true',
206 help='write informational messages to stderr')
207 parser.add_option('-d', '--debug', dest='debug',
208 default=False, action='store_true',
209 help='start a debugger after initialisation')
210 parser.add_option('-V', '--version', dest='version',
211 default=False, action='store_true',
212 help='display version information')
214 options, args = parser.parse_args()
217 print __name__, __version__ + ' — ' + __description__
219 print 'Written by ' + __author__
221 print 'Released under the ' + __licence__
222 sys.exit(posix.EX_OK)
225 ### CONFIGURATION FILE PARSING
228 CONFFILE = os.path.expanduser(CONFFILE)
229 MAILPLATEDIR = os.path.expanduser(MAILPLATEDIR)
232 config = { 'default_template' : 'default'
233 , 'template_path' : TEMPLATEDIR
235 helpers = { 'get_quote' : 'fortune -s' }
237 if not os.path.exists(CONFFILE):
238 # conffile does not exist, let's create it with defaults.
239 options.verbose = True
241 if not os.path.isdir(MAILPLATEDIR):
242 info('configuration directory not found, creating: ' + MAILPLATEDIR)
243 os.mkdir(MAILPLATEDIR, 0700)
245 if not os.path.isfile(CONFFILE):
246 info('creating a default configuration file: ' + CONFFILE)
247 f = file(CONFFILE, 'w')
248 f.write('# mailplate configuration\n[%s]\n' % SECTION_GENERAL)
249 for kvpair in config.iteritems():
250 if len(kvpair[1]) > 0:
251 f.write('%s = %s\n' % kvpair)
254 f.write('\n[%s]\n' % SECTION_HELPERS)
255 for kvpair in helpers.iteritems():
256 f.write('%s = %s\n' % kvpair)
260 if not os.access(CONFFILE, os.R_OK):
261 err('cannot read configuration file: %s' % CONFFILE)
262 sys.exit(posix.EX_OSFILE)
265 parser = ConfigParser.SafeConfigParser()
266 parser.read(CONFFILE)
268 # first the GENERAL section into the config dict for all keys with defaults
269 for key in config.keys():
271 config[key] = parser.get(SECTION_GENERAL, key)
272 except ConfigParser.NoSectionError, ConfigParser.MissingSectionHeaderError:
273 err("no section '%s' in %s" % (SECTION_GENERAL, CONFFILE))
274 sys.exit(posix.EX_CONFIG)
275 except ConfigParser.NoOptionError:
277 except ConfigParser.DuplicateSectionError, ConfigParser.ParseError:
278 err('parse error on %s' % CONFFILE)
279 sys.exit(posix.EX_CONFIG)
281 # all HELPERS into the helpers dict
282 helpers.update(parser.items(SECTION_HELPERS))
284 TPATH = os.path.expanduser(config['template_path'])
285 if not os.path.isdir(TPATH):
286 info('creating template directory: ' + TPATH)
287 os.mkdir(TPATH, 0700)
289 default_templname = config['default_template']
290 if default_templname is not None:
291 default_templpath = os.path.join(TPATH, default_templname)
292 if not os.path.isfile(default_templpath):
293 info('creating the default template: ' + default_templpath)
294 f = file(default_templpath, 'w')
295 f.write('@KEEP_STD_HEADERS\n\n@KEEP_BODY\n')
302 # parse the arguments
305 # filename is -, so do nothing, since stdin/stdout are default
307 elif os.path.isfile(arg):
308 # the file exists, so use it as in/out if read/writeable
309 if os.access(arg, os.R_OK):
311 if os.access(arg, os.W_OK):
313 elif os.access(os.path.join(TPATH, arg), os.R_OK):
314 # argument referenced an existing template
317 err('unknown argument, and cannot find a template by this name: %s' % arg)
318 sys.exit(posix.EX_USAGE)
321 if options.auto and options.menu:
322 err('cannot combine --auto and --menu')
323 sys.exit(posix.EX_USAGE)
325 elif (options.auto or options.menu) and templname:
326 err('cannot specify a template with --auto or --menu')
327 sys.exit(posix.EX_USAGE)
329 elif not templname and not (options.auto or options.menu):
330 if default_templname is not None:
331 templname = default_templname
333 err('no template specified')
334 sys.exit(posix.EX_USAGE)
337 err('--menu mode not yet implemented')
338 sys.exit(posix.EX_USAGE)
344 # read in the message from a file, if a filename is given.
345 if infname is not None:
346 inf = file(infname, 'r', 1)
348 # read message into buffer, or preinitialise the buffer if --new is given
350 rawmsg = '\n'.join((header + ': ' for header in STD_HEADERS)) + '\n'
352 rawmsg = ''.join(inf.readlines())
355 best_score = (0, default_templname, {})
356 for tf in os.listdir(TPATH):
357 tp = os.path.join(TPATH, tf)
358 if not os.path.isfile(tp) or not os.access(tp, os.R_OK): continue
360 # we're iterating all files in the template directory
361 # for each file, obtain and run regexps and commands and accumulate
362 # the score (and variables)
367 if line[0] == REGEXPCHAR:
368 r = exec_regexp(line, rawmsg, tf)
372 elif line[0] == COMMANDCHAR:
373 score += exec_command(line, rawmsg, tf)
375 # do we top the currently best score, if so then raise the bar
376 if score > best_score[0]:
377 best_score = (score, tf, vars)
379 templname = best_score[1]
381 if templname is None:
382 err('could not determine a template to use and no default is set')
383 sys.exit(posix.EX_CONFIG)
385 info('chose profile %s with score %d.' % (templname, best_score[0]))
388 # now read in the template
389 templpath = os.path.join(TPATH, templname)
391 if not os.path.isfile(templpath):
392 err('not a template: ' + templpath)
393 sys.exit(posix.EX_OSFILE)
395 elif not os.access(templpath, os.R_OK):
396 err('template ' + templpath + ' could not be read.')
397 sys.exit(posix.EX_OSFILE)
399 templ = file(templpath, 'r', 1)
402 if not options.auto and line[0] == REGEXPCHAR:
403 # obtain variables from the regexps
404 vars.update(exec_regexp(line, rawmsg, templname)[1])
406 if line[0] in (COMMENTCHAR, REGEXPCHAR, COMMANDCHAR):
409 elif payload is not None:
410 # we're past the headers, so accumulate the payload
414 #TODO multiline headers
417 payload = '' # end of headers
418 elif l[0] == KEEP_SLOT_LEADER and KEEP_HEADERS.has_key(l[1:]):
419 # found predefined header slot keyword
420 for header in KEEP_HEADERS[l[1:]]:
421 headers[header.lower()] = (header, _keep_header)
423 header, content = l.split(':', 1)
424 content = content.strip()
425 if content == KEEP_SLOT_LEADER + 'KEEP':
426 # record header to be preserved
427 content = _keep_header
429 content = interpolate(content)
430 headers[header.lower()] = (header, content)
432 msg = email.message_from_string(rawmsg)
434 for header, content in msg.items():
435 # iterate all existing mail headers
436 lheader = header.lower()
437 if headers.has_key(lheader):
438 # the template defines this header
439 if headers[lheader][1] == _keep_header:
440 # it's marked as keep, thus use content from email message
441 headers[lheader] = (header, content)
442 elif options.keep_unknown:
443 # the template does not define the header, but --keep-unknown was
444 # given, thus preserve the entire header field
445 headers[lheader] = (header, content)
447 # open the output file
448 if outfname is not None:
449 outf = file(outfname, 'w', 0)
451 # print the headers, starting with the standard headers in order
452 for header in STD_HEADERS:
453 lheader = header.lower()
454 if headers.get(lheader, (None, _keep_header))[1] is not _keep_header:
455 # the template header contains mandatory data, let's print it.
456 hpair = headers[lheader]
457 print >>outf, ': '.join(hpair)
458 # and remove it from the dict
461 for i, (header, content) in headers.iteritems():
462 # print all remaining headers
463 if content == _keep_header: continue
464 print >>outf, ': '.join((header, content))
466 # print empty line to indicate end of headers.
469 # split payload of existing message into body and signature
470 body = msg.get_payload().rsplit(SIG_DELIM, 1)
476 body = SIG_DELIM.join(body[:-1]).strip()
477 # signature may now be ''
479 # interpolate the template payload
480 payload = interpolate(payload)
481 # determine whether to interpolate the signature *before* inserting the body
482 # to prevent text in the body from being interpolated
483 keep_sig = payload.find('@KEEP_SIGNATURE') >= 0
484 # interpolate body and signature
485 payload = payload.replace('@KEEP_BODY', body, 1)
487 payload = payload.replace('@KEEP_SIGNATURE', signature, 1)
489 print >>outf, payload.rstrip()
493 # finally, spawn the editor, if we wrote into a file
495 err('cannot use --edit without an output file.')
496 sys.exit(posix.EX_USAGE)
498 os.execlp('sensible-editor', 'sensible-editor', outfname)