]> 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:

set verbose on first invocation
[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     options.verbose = True
239
240     if not os.path.isdir(MAILPLATEDIR):
241         info('configuration directory not found, creating: ' + MAILPLATEDIR)
242         os.mkdir(MAILPLATEDIR, 0700)
243
244     if not os.path.isfile(CONFFILE):
245         info('creating a default configuration file: ' + CONFFILE)
246         f = file(CONFFILE, 'w')
247         f.write('# mailplate configuration\n[%s]\n' % SECTION_GENERAL)
248         for kvpair in config.iteritems():
249             if len(kvpair[1]) > 0:
250                 f.write('%s = %s\n' % kvpair)
251
252         if len(helpers) > 0:
253             f.write('\n[%s]\n' % SECTION_HELPERS)
254             for kvpair in helpers.iteritems():
255                 f.write('%s = %s\n' % kvpair)
256
257         f.close()
258
259 if not os.access(CONFFILE, os.R_OK):
260     err('cannot read configuration file: %s' % CONFFILE)
261     sys.exit(posix.EX_OSFILE)
262
263 # now parse
264 parser = ConfigParser.SafeConfigParser()
265 parser.read(CONFFILE)
266
267 # first the GENERAL section into the config dict for all keys with defaults
268 for key in config.keys():
269     try:
270         config[key] = parser.get(SECTION_GENERAL, key)
271     except ConfigParser.NoSectionError, ConfigParser.MissingSectionHeaderError:
272         err("no section '%s' in %s" % (SECTION_GENERAL, CONFFILE))
273         sys.exit(posix.EX_CONFIG)
274     except ConfigParser.NoOptionError:
275         continue
276     except ConfigParser.DuplicateSectionError, ConfigParser.ParseError:
277         err('parse error on %s' % CONFFILE)
278         sys.exit(posix.EX_CONFIG)
279
280 # all HELPERS into the helpers dict
281 helpers.update(parser.items(SECTION_HELPERS))
282
283 TPATH = os.path.expanduser(config['template_path'])
284 if not os.path.isdir(TPATH):
285     info('creating template directory: ' + TPATH)
286     os.mkdir(TPATH, 0700)
287
288 default_templname = config['default_template']
289 if default_templname is not None:
290     default_templpath = os.path.join(TPATH, default_templname)
291     if not os.path.isfile(default_templpath):
292         info('creating the default template: ' + default_templpath)
293         f = file(default_templpath, 'w')
294         f.write('@KEEP_STD_HEADERS\n\n@KEEP_BODY\n')
295         f.close()
296
297 if options.debug:
298     import pdb
299     pdb.set_trace()
300
301 # parse the arguments
302 for arg in args:
303     if arg == '-':
304         # filename is -, so do nothing, since stdin/stdout are default
305         continue
306     elif os.path.isfile(arg):
307         # the file exists, so use it as in/out if read/writeable
308         if os.access(arg, os.R_OK):
309             infname = arg
310         if os.access(arg, os.W_OK):
311             outfname = arg
312     elif os.access(os.path.join(TPATH, arg), os.R_OK):
313         # argument referenced an existing template
314         templname = arg
315     else:
316         err('unknown argument, and cannot find a template by this name: %s' % arg)
317         sys.exit(posix.EX_USAGE)
318
319 # sanity checks
320 if options.auto and options.menu:
321     err('cannot combine --auto and --menu')
322     sys.exit(posix.EX_USAGE)
323
324 elif (options.auto or options.menu) and templname:
325     err('cannot specify a template with --auto or --menu')
326     sys.exit(posix.EX_USAGE)
327
328 elif not templname and not (options.auto or options.menu):
329     if default_templname is not None:
330         templname = default_templname
331     else:
332         err('no template specified')
333         sys.exit(posix.EX_USAGE)
334
335 elif options.menu:
336     err('--menu mode not yet implemented')
337     sys.exit(posix.EX_USAGE)
338
339 ###
340 ### MAIL PROCESSING
341 ###
342
343 # read in the message from a file, if a filename is given.
344 if infname is not None:
345     inf = file(infname, 'r', 1)
346
347 # read message into buffer, or preinitialise the buffer if --new is given
348 if options.new:
349     rawmsg = '\n'.join((header + ': ' for header in STD_HEADERS)) + '\n'
350 else:
351     rawmsg = ''.join(inf.readlines())
352
353 if options.auto:
354     best_score = (0, default_templname, {})
355     for tf in os.listdir(TPATH):
356         tp = os.path.join(TPATH, tf)
357         if not os.path.isfile(tp) or not os.access(tp, os.R_OK): continue
358
359         # we're iterating all files in the template directory
360         # for each file, obtain and run regexps and commands and accumulate
361         # the score (and variables)
362         score = 0
363         vars = {}
364         f = open(tp, 'r')
365         for line in f:
366             if line[0] == REGEXPCHAR:
367                 r = exec_regexp(line, rawmsg, tf)
368                 score += r[0]
369                 vars.update(r[1])
370
371             elif line[0] == COMMANDCHAR:
372                 score += exec_command(line, rawmsg, tf)
373
374         # do we top the currently best score, if so then raise the bar
375         if score > best_score[0]:
376             best_score = (score, tf, vars)
377
378     templname = best_score[1]
379
380     if templname is None:
381         err('could not determine a template to use and no default is set')
382         sys.exit(posix.EX_CONFIG)
383
384     info('chose profile %s with score %d.' % (templname, best_score[0]))
385     vars = best_score[2]
386
387 # now read in the template
388 templpath = os.path.join(TPATH, templname)
389
390 if not os.path.isfile(templpath):
391     err('not a template: ' + templpath)
392     sys.exit(posix.EX_OSFILE)
393
394 elif not os.access(templpath, os.R_OK):
395     err('template ' + templpath + ' could not be read.')
396     sys.exit(posix.EX_OSFILE)
397
398 templ = file(templpath, 'r', 1)
399
400 for line in templ:
401     if not options.auto and line[0] == REGEXPCHAR:
402         # obtain variables from the regexps
403         vars.update(exec_regexp(line, rawmsg, templname)[1])
404
405     if line[0] in (COMMENTCHAR, REGEXPCHAR, COMMANDCHAR):
406         continue
407
408     elif payload is not None:
409         # we're past the headers, so accumulate the payload
410         payload += line
411
412     else:
413         #TODO multiline headers
414         l = line[:-1]
415         if len(l) == 0:
416             payload = '' # end of headers
417         elif l[0] == KEEP_SLOT_LEADER and KEEP_HEADERS.has_key(l[1:]):
418             # found predefined header slot keyword
419             for header in KEEP_HEADERS[l[1:]]:
420                 headers[header.lower()] = (header, _keep_header)
421         else:
422             header, content = l.split(':', 1)
423             content = content.strip()
424             if content == KEEP_SLOT_LEADER + 'KEEP':
425                 # record header to be preserved
426                 content = _keep_header
427             else:
428                 content = interpolate(content)
429             headers[header.lower()] = (header, content)
430
431 msg = email.message_from_string(rawmsg)
432
433 for header, content in msg.items():
434     # iterate all existing mail headers
435     lheader = header.lower()
436     if headers.has_key(lheader):
437         # the template defines this header
438         if headers[lheader][1] == _keep_header:
439             # it's marked as keep, thus use content from email message
440             headers[lheader] = (header, content)
441     elif options.keep_unknown:
442         # the template does not define the header, but --keep-unknown was
443         # given, thus preserve the entire header field
444         headers[lheader] = (header, content)
445
446 # open the output file
447 if outfname is not None:
448     outf = file(outfname, 'w', 0)
449
450 # print the headers, starting with the standard headers in order
451 for header in STD_HEADERS:
452     lheader = header.lower()
453     if headers.get(lheader, (None, _keep_header))[1] is not _keep_header:
454         # the template header contains mandatory data, let's print it.
455         hpair = headers[lheader]
456         print >>outf, ': '.join(hpair)
457         # and remove it from the dict
458         del headers[lheader]
459
460 for i, (header, content) in headers.iteritems():
461     # print all remaining headers
462     if content == _keep_header: continue
463     print >>outf, ': '.join((header, content))
464
465 # print empty line to indicate end of headers.
466 print >>outf
467
468 # split payload of existing message into body and signature
469 body = msg.get_payload().rsplit(SIG_DELIM, 1)
470 signature = ''
471 if len(body) == 1:
472     body = body[0]
473 elif len(body) > 1:
474     signature = body[-1]
475     body = SIG_DELIM.join(body[:-1]).strip()
476 # signature may now be ''
477
478 # interpolate the template payload
479 payload = interpolate(payload)
480 # determine whether to interpolate the signature *before* inserting the body
481 # to prevent text in the body from being interpolated
482 keep_sig = payload.find('@KEEP_SIGNATURE') >= 0
483 # interpolate body and signature
484 payload = payload.replace('@KEEP_BODY', body, 1)
485 if keep_sig:
486     payload = payload.replace('@KEEP_SIGNATURE', signature, 1)
487
488 print >>outf, payload.rstrip()
489 outf.close()
490
491 if options.edit:
492     # finally, spawn the editor, if we wrote into a file
493     if outfname is None:
494         err('cannot use --edit without an output file.')
495         sys.exit(posix.EX_USAGE)
496
497     os.execlp('sensible-editor', 'sensible-editor', outfname)