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

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