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

add verbose mode and cleanup warnings/errors
[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     err('no template specified')
329     sys.exit(posix.EX_USAGE)
330
331 elif options.menu:
332     err('--menu mode not yet implemented')
333     sys.exit(posix.EX_USAGE)
334
335 ###
336 ### MAIL PROCESSING
337 ###
338
339 # read in the message from a file, if a filename is given.
340 if infname is not None:
341     inf = file(infname, 'r', 1)
342
343 # read message into buffer, or preinitialise the buffer if --new is given
344 if options.new:
345     rawmsg = '\n'.join((header + ': ' for header in STD_HEADERS)) + '\n'
346 else:
347     rawmsg = ''.join(inf.readlines())
348
349 if options.auto:
350     best_score = (0, default_templname, {})
351     for tf in os.listdir(TPATH):
352         tp = os.path.join(TPATH, tf)
353         if not os.path.isfile(tp) or not os.access(tp, os.R_OK): continue
354
355         # we're iterating all files in the template directory
356         # for each file, obtain and run regexps and commands and accumulate
357         # the score (and variables)
358         score = 0
359         vars = {}
360         f = open(tp, 'r')
361         for line in f:
362             if line[0] == REGEXPCHAR:
363                 r = exec_regexp(line, rawmsg, tf)
364                 score += r[0]
365                 vars.update(r[1])
366
367             elif line[0] == COMMANDCHAR:
368                 score += exec_command(line, rawmsg, tf)
369
370         # do we top the currently best score, if so then raise the bar
371         if score > best_score[0]:
372             best_score = (score, tf, vars)
373
374     templname = best_score[1]
375
376     if templname is None:
377         err('could not determine a template to use and no default is set')
378         sys.exit(posix.EX_CONFIG)
379
380     info('chose profile %s with score %d.' % (templname, best_score[0]))
381     vars = best_score[2]
382
383 # now read in the template
384 templpath = os.path.join(TPATH, templname)
385
386 if not os.path.isfile(templpath):
387     err('not a template: ' + templpath)
388     sys.exit(posix.EX_OSFILE)
389
390 elif not os.access(templpath, os.R_OK):
391     err('template ' + templpath + ' could not be read.')
392     sys.exit(posix.EX_OSFILE)
393
394 templ = file(templpath, 'r', 1)
395
396 for line in templ:
397     if not options.auto and line[0] == REGEXPCHAR:
398         # obtain variables from the regexps
399         vars.update(exec_regexp(line, rawmsg, templname)[1])
400
401     if line[0] in (COMMENTCHAR, REGEXPCHAR, COMMANDCHAR):
402         continue
403
404     elif payload is not None:
405         # we're past the headers, so accumulate the payload
406         payload += line
407
408     else:
409         #TODO multiline headers
410         l = line[:-1]
411         if len(l) == 0:
412             payload = '' # end of headers
413         elif l[0] == KEEP_SLOT_LEADER and KEEP_HEADERS.has_key(l[1:]):
414             # found predefined header slot keyword
415             for header in KEEP_HEADERS[l[1:]]:
416                 headers[header.lower()] = (header, _keep_header)
417         else:
418             header, content = l.split(':', 1)
419             content = content.strip()
420             if content == KEEP_SLOT_LEADER + 'KEEP':
421                 # record header to be preserved
422                 content = _keep_header
423             else:
424                 content = interpolate(content)
425             headers[header.lower()] = (header, content)
426
427 msg = email.message_from_string(rawmsg)
428
429 for header, content in msg.items():
430     # iterate all existing mail headers
431     lheader = header.lower()
432     if headers.has_key(lheader):
433         # the template defines this header
434         if headers[lheader][1] == _keep_header:
435             # it's marked as keep, thus use content from email message
436             headers[lheader] = (header, content)
437     elif options.keep_unknown:
438         # the template does not define the header, but --keep-unknown was
439         # given, thus preserve the entire header field
440         headers[lheader] = (header, content)
441
442 # open the output file
443 if outfname is not None:
444     outf = file(outfname, 'w', 0)
445
446 # print the headers, starting with the standard headers in order
447 for header in STD_HEADERS:
448     lheader = header.lower()
449     if headers.get(lheader, (None, _keep_header))[1] is not _keep_header:
450         # the template header contains mandatory data, let's print it.
451         hpair = headers[lheader]
452         print >>outf, ': '.join(hpair)
453         # and remove it from the dict
454         del headers[lheader]
455
456 for i, (header, content) in headers.iteritems():
457     # print all remaining headers
458     if content == _keep_header: continue
459     print >>outf, ': '.join((header, content))
460
461 # print empty line to indicate end of headers.
462 print >>outf
463
464 # split payload of existing message into body and signature
465 body = msg.get_payload().rsplit(SIG_DELIM, 1)
466 signature = ''
467 if len(body) == 1:
468     body = body[0]
469 elif len(body) > 1:
470     signature = body[-1]
471     body = SIG_DELIM.join(body[:-1]).strip()
472 # signature may now be ''
473
474 # interpolate the template payload
475 payload = interpolate(payload)
476 # determine whether to interpolate the signature *before* inserting the body
477 # to prevent text in the body from being interpolated
478 keep_sig = payload.find('@KEEP_SIGNATURE') >= 0
479 # interpolate body and signature
480 payload = payload.replace('@KEEP_BODY', body, 1)
481 if keep_sig:
482     payload = payload.replace('@KEEP_SIGNATURE', signature, 1)
483
484 print >>outf, payload.rstrip()
485 outf.close()
486
487 if options.edit:
488     # finally, spawn the editor, if we wrote into a file
489     if outfname is None:
490         err('cannot use --edit without an output file.')
491         sys.exit(posix.EX_USAGE)
492
493     os.execlp('sensible-editor', 'sensible-editor', outfname)