]> git.madduck.net Git - code/mailplate.git/blob - mailplate

madduck's git repository

Every one of the projects in this repository is available at the canonical URL git://git.madduck.net/madduck/pub/<projectpath> — see each project's metadata for the exact URL.

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.

SSH access, as well as push access can be individually arranged.

If you use my repositories frequently, consider adding the following snippet to ~/.gitconfig and using the third clone URL listed for each project:

[url "git://git.madduck.net/madduck/"]
  insteadOf = madduck:

use default if not template name specified
[code/mailplate.git] / mailplate
1 #!/usr/bin/python
2 # -*- coding: utf-8 -*-
3 #
4 # mailplate — reformat mail drafts according to templates
5 #
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).
10 #
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
14 # signature.
15 #
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.
21 #
22 # Body and signature are separated by '-- '. If this sentinel is not found,
23 # no signature is extracted.
24 #
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
29 # the message.
30 #
31 # This script can be run in multiple ways:
32 #
33 # As a filter, it applies a template to data from stdin and writes the result
34 # to stdout.
35 #
36 # Given a file, it modifies the file, unless it cannot write to the file, in
37 # which case it writes to stdout.
38 #
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.
41 #
42 # TODO: if headers like From are absent from the mail, they should not be kept
43 # but replaced with a default.
44 #
45 # Copyright © martin f. krafft <madduck@madduck.net>
46 # Released under the terms of the Artistic Licence 2.0
47 #
48
49 __name__ = 'mailplate'
50 __description__ = 'reformat mail drafts according to templates'
51 __version__ = '0.1'
52 __author__ = 'martin f. krafft <madduck@madduck.net>'
53 __copyright__ = 'Copyright © ' + __author__
54 __licence__ = 'Artistic Licence 2.0'
55
56 import email
57 import os
58 import posix
59 import re
60 import sys
61 import subprocess
62 import ConfigParser
63 from optparse import OptionParser
64
65 ###
66 ### CONSTANTS
67 ###
68
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
74
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
78
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
84
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                }
90
91 SIG_DELIM='\n-- \n'
92
93 ###
94 ### HELPER FUNCTION DEFINITIONS
95 ###
96
97 def err(s):
98     sys.stderr.write('E: ' + s + '\n')
99
100 def warn(s):
101     sys.stderr.write('W: ' + s + '\n')
102
103 def info(s):
104     if not options.verbose: return
105     sys.stderr.write('I: ' + s + '\n')
106
107 # obtain a regexp from a line, run it, return score/dict if matched
108 def exec_regexp(line, rawmsg, name):
109     p, r = line[1:].strip().split(' ', 1)
110     m = re.compile(r, re.M | re.I | re.U).search(rawmsg)
111     if m is not None:
112         return (int(p), m.groupdict())
113     return (0, {})
114
115 # obtain a command from a line, run it, return score if matched
116 def exec_command(line, rawmsg, name):
117     p, r = line[1:].strip().split(' ', 1)
118     s = subprocess.Popen(r, shell=True, stdin=subprocess.PIPE,
119             stdout=subprocess.PIPE, stderr=subprocess.PIPE)
120     try:
121         stdout, stderr = s.communicate(rawmsg)
122         if s.returncode == 0:
123             return int(p)
124         else:
125             return 0
126     except OSError:
127         warn("command '%s' (template '%s') failed to run." % (r, name))
128         return 0
129
130 def interpolate_helpers(s):
131     while True:
132         helper_begin = s.find(HELPER_SLOT_LEADER)
133         if helper_begin < 0: break
134         helper_end = s.find(HELPER_SLOT_TRAILER, helper_begin)
135         helper = s[helper_begin + len(HELPER_SLOT_LEADER):helper_end]
136         try:
137             proc = subprocess.Popen(helpers[helper], shell=True,
138                     stdout=subprocess.PIPE, stderr=sys.stderr)
139             out = proc.communicate()[0]
140             s = s[:helper_begin] + out.strip() + s[helper_end+1:]
141         except KeyError:
142             err('unknown helper: ' + helper)
143             sys.exit(posix.EX_DATAERR)
144     return s
145
146 def interpolate_env(s):
147     while True:
148         envvar_begin = s.find(ENV_SLOT_LEADER)
149         if envvar_begin < 0: break
150         envvar_end = s.find(ENV_SLOT_TRAILER, envvar_begin)
151         envvar = s[envvar_begin + len(ENV_SLOT_LEADER):envvar_end]
152         value = os.getenv(envvar) or ''
153         s = s[:envvar_begin] + value + s[envvar_end+1:]
154     return s
155
156 def interpolate_vars(s):
157     return s % vars
158
159 def interpolate(s):
160     return interpolate_helpers(interpolate_env(interpolate_vars(s)))
161
162 # sentinel to use as dict value for preserved headers
163 class _keep_header: pass
164
165 ###
166 ### VARIABLE INITIALISATION
167 ###
168
169 infname = None
170 inf = sys.stdin
171 outfname = None
172 outf = sys.stdout
173 templname = None
174 templ = None
175 vars = {}
176 headers = {}
177 payload = None
178
179 ###
180 ### COMMAND LINE PARSING
181 ###
182
183 parser = OptionParser()
184 parser.prog = __name__
185 parser.version = __version__
186 parser.description = __description__
187 parser.usage = '%prog [options] <message>'
188 parser.add_option('-a', '--auto', dest='auto',
189         default=False, action='store_true',
190         help='turn on template auto-discovery')
191 parser.add_option('-m', '--menu', dest='menu',
192         default=False, action='store_true',
193         help='choose from a list of templates (not yet implemented)')
194 parser.add_option('-n', '--new', dest='new',
195         default=False, action='store_true',
196         help='create a new message')
197 parser.add_option('-e', '--editor', dest='edit',
198         default=False, action='store_true',
199         help='spawn editor once template is applied')
200 parser.add_option('-k', '--keep-unknown', dest='keep_unknown',
201         default=False, action='store_true',
202         help='preserve mail headers not specified in template')
203 parser.add_option('-v', '--verbose', dest='verbose',
204         default=False, action='store_true',
205         help='write informational messages to stderr')
206 parser.add_option('-d', '--debug', dest='debug',
207         default=False, action='store_true',
208         help='start a debugger after initialisation')
209 parser.add_option('-V', '--version', dest='version',
210         default=False, action='store_true',
211         help='display version information')
212
213 options, args = parser.parse_args()
214
215 if options.version:
216     print __name__, __version__ + ' — ' + __description__
217     print
218     print 'Written by ' + __author__
219     print __copyright__
220     print 'Released under the ' + __licence__
221     sys.exit(posix.EX_OK)
222
223 ###
224 ### CONFIGURATION FILE PARSING
225 ###
226
227 CONFFILE = os.path.expanduser(CONFFILE)
228 MAILPLATEDIR = os.path.expanduser(MAILPLATEDIR)
229
230 # defaults
231 config = { 'default_template' : 'default'
232          , 'template_path' : TEMPLATEDIR
233          }
234 helpers = { 'get_quote' : 'fortune -s' }
235
236 if not os.path.exists(CONFFILE):
237     # conffile does not exist, let's create it with defaults.
238
239     if not os.path.isdir(MAILPLATEDIR):
240         info('configuration directory not found, creating: ' + MAILPLATEDIR)
241         os.mkdir(MAILPLATEDIR, 0700)
242
243     if not os.path.isfile(CONFFILE):
244         info('creating a default configuration file: ' + CONFFILE)
245         f = file(CONFFILE, 'w')
246         f.write('# mailplate configuration\n[%s]\n' % SECTION_GENERAL)
247         for kvpair in config.iteritems():
248             if len(kvpair[1]) > 0:
249                 f.write('%s = %s\n' % kvpair)
250
251         if len(helpers) > 0:
252             f.write('\n[%s]\n' % SECTION_HELPERS)
253             for kvpair in helpers.iteritems():
254                 f.write('%s = %s\n' % kvpair)
255
256         f.close()
257
258 if not os.access(CONFFILE, os.R_OK):
259     err('cannot read configuration file: %s' % CONFFILE)
260     sys.exit(posix.EX_OSFILE)
261
262 # now parse
263 parser = ConfigParser.SafeConfigParser()
264 parser.read(CONFFILE)
265
266 # first the GENERAL section into the config dict for all keys with defaults
267 for key in config.keys():
268     try:
269         config[key] = parser.get(SECTION_GENERAL, key)
270     except ConfigParser.NoSectionError, ConfigParser.MissingSectionHeaderError:
271         err("no section '%s' in %s" % (SECTION_GENERAL, CONFFILE))
272         sys.exit(posix.EX_CONFIG)
273     except ConfigParser.NoOptionError:
274         continue
275     except ConfigParser.DuplicateSectionError, ConfigParser.ParseError:
276         err('parse error on %s' % CONFFILE)
277         sys.exit(posix.EX_CONFIG)
278
279 # all HELPERS into the helpers dict
280 helpers.update(parser.items(SECTION_HELPERS))
281
282 TPATH = os.path.expanduser(config['template_path'])
283 if not os.path.isdir(TPATH):
284     info('creating template directory: ' + TPATH)
285     os.mkdir(TPATH, 0700)
286
287 default_templname = config['default_template']
288 if default_templname is not None:
289     default_templpath = os.path.join(TPATH, default_templname)
290     if not os.path.isfile(default_templpath):
291         info('creating the default template: ' + default_templpath)
292         f = file(default_templpath, 'w')
293         f.write('@KEEP_STD_HEADERS\n\n@KEEP_BODY\n')
294         f.close()
295
296 if options.debug:
297     import pdb
298     pdb.set_trace()
299
300 # parse the arguments
301 for arg in args:
302     if arg == '-':
303         # filename is -, so do nothing, since stdin/stdout are default
304         continue
305     elif os.path.isfile(arg):
306         # the file exists, so use it as in/out if read/writeable
307         if os.access(arg, os.R_OK):
308             infname = arg
309         if os.access(arg, os.W_OK):
310             outfname = arg
311     elif os.access(os.path.join(TPATH, arg), os.R_OK):
312         # argument referenced an existing template
313         templname = arg
314     else:
315         err('unknown argument, and cannot find a template by this name: %s' % arg)
316         sys.exit(posix.EX_USAGE)
317
318 # sanity checks
319 if options.auto and options.menu:
320     err('cannot combine --auto and --menu')
321     sys.exit(posix.EX_USAGE)
322
323 elif (options.auto or options.menu) and templname:
324     err('cannot specify a template with --auto or --menu')
325     sys.exit(posix.EX_USAGE)
326
327 elif not templname and not (options.auto or options.menu):
328     if default_templname is not None:
329         templname = default_templname
330     else:
331         err('no template specified')
332         sys.exit(posix.EX_USAGE)
333
334 elif options.menu:
335     err('--menu mode not yet implemented')
336     sys.exit(posix.EX_USAGE)
337
338 ###
339 ### MAIL PROCESSING
340 ###
341
342 # read in the message from a file, if a filename is given.
343 if infname is not None:
344     inf = file(infname, 'r', 1)
345
346 # read message into buffer, or preinitialise the buffer if --new is given
347 if options.new:
348     rawmsg = '\n'.join((header + ': ' for header in STD_HEADERS)) + '\n'
349 else:
350     rawmsg = ''.join(inf.readlines())
351
352 if options.auto:
353     best_score = (0, default_templname, {})
354     for tf in os.listdir(TPATH):
355         tp = os.path.join(TPATH, tf)
356         if not os.path.isfile(tp) or not os.access(tp, os.R_OK): continue
357
358         # we're iterating all files in the template directory
359         # for each file, obtain and run regexps and commands and accumulate
360         # the score (and variables)
361         score = 0
362         vars = {}
363         f = open(tp, 'r')
364         for line in f:
365             if line[0] == REGEXPCHAR:
366                 r = exec_regexp(line, rawmsg, tf)
367                 score += r[0]
368                 vars.update(r[1])
369
370             elif line[0] == COMMANDCHAR:
371                 score += exec_command(line, rawmsg, tf)
372
373         # do we top the currently best score, if so then raise the bar
374         if score > best_score[0]:
375             best_score = (score, tf, vars)
376
377     templname = best_score[1]
378
379     if templname is None:
380         err('could not determine a template to use and no default is set')
381         sys.exit(posix.EX_CONFIG)
382
383     info('chose profile %s with score %d.' % (templname, best_score[0]))
384     vars = best_score[2]
385
386 # now read in the template
387 templpath = os.path.join(TPATH, templname)
388
389 if not os.path.isfile(templpath):
390     err('not a template: ' + templpath)
391     sys.exit(posix.EX_OSFILE)
392
393 elif not os.access(templpath, os.R_OK):
394     err('template ' + templpath + ' could not be read.')
395     sys.exit(posix.EX_OSFILE)
396
397 templ = file(templpath, 'r', 1)
398
399 for line in templ:
400     if not options.auto and line[0] == REGEXPCHAR:
401         # obtain variables from the regexps
402         vars.update(exec_regexp(line, rawmsg, templname)[1])
403
404     if line[0] in (COMMENTCHAR, REGEXPCHAR, COMMANDCHAR):
405         continue
406
407     elif payload is not None:
408         # we're past the headers, so accumulate the payload
409         payload += line
410
411     else:
412         #TODO multiline headers
413         l = line[:-1]
414         if len(l) == 0:
415             payload = '' # end of headers
416         elif l[0] == KEEP_SLOT_LEADER and KEEP_HEADERS.has_key(l[1:]):
417             # found predefined header slot keyword
418             for header in KEEP_HEADERS[l[1:]]:
419                 headers[header.lower()] = (header, _keep_header)
420         else:
421             header, content = l.split(':', 1)
422             content = content.strip()
423             if content == KEEP_SLOT_LEADER + 'KEEP':
424                 # record header to be preserved
425                 content = _keep_header
426             else:
427                 content = interpolate(content)
428             headers[header.lower()] = (header, content)
429
430 msg = email.message_from_string(rawmsg)
431
432 for header, content in msg.items():
433     # iterate all existing mail headers
434     lheader = header.lower()
435     if headers.has_key(lheader):
436         # the template defines this header
437         if headers[lheader][1] == _keep_header:
438             # it's marked as keep, thus use content from email message
439             headers[lheader] = (header, content)
440     elif options.keep_unknown:
441         # the template does not define the header, but --keep-unknown was
442         # given, thus preserve the entire header field
443         headers[lheader] = (header, content)
444
445 # open the output file
446 if outfname is not None:
447     outf = file(outfname, 'w', 0)
448
449 # print the headers, starting with the standard headers in order
450 for header in STD_HEADERS:
451     lheader = header.lower()
452     if headers.get(lheader, (None, _keep_header))[1] is not _keep_header:
453         # the template header contains mandatory data, let's print it.
454         hpair = headers[lheader]
455         print >>outf, ': '.join(hpair)
456         # and remove it from the dict
457         del headers[lheader]
458
459 for i, (header, content) in headers.iteritems():
460     # print all remaining headers
461     if content == _keep_header: continue
462     print >>outf, ': '.join((header, content))
463
464 # print empty line to indicate end of headers.
465 print >>outf
466
467 # split payload of existing message into body and signature
468 body = msg.get_payload().rsplit(SIG_DELIM, 1)
469 signature = ''
470 if len(body) == 1:
471     body = body[0]
472 elif len(body) > 1:
473     signature = body[-1]
474     body = SIG_DELIM.join(body[:-1]).strip()
475 # signature may now be ''
476
477 # interpolate the template payload
478 payload = interpolate(payload)
479 # determine whether to interpolate the signature *before* inserting the body
480 # to prevent text in the body from being interpolated
481 keep_sig = payload.find('@KEEP_SIGNATURE') >= 0
482 # interpolate body and signature
483 payload = payload.replace('@KEEP_BODY', body, 1)
484 if keep_sig:
485     payload = payload.replace('@KEEP_SIGNATURE', signature, 1)
486
487 print >>outf, payload.rstrip()
488 outf.close()
489
490 if options.edit:
491     # finally, spawn the editor, if we wrote into a file
492     if outfname is None:
493         err('cannot use --edit without an output file.')
494         sys.exit(posix.EX_USAGE)
495
496     os.execlp('sensible-editor', 'sensible-editor', outfname)