]> 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 manpage
[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                , 'KEEP_ALL_HEADERS' : STD_HEADERS
90                }
91
92 SIG_DELIM='\n-- \n'
93
94 ###
95 ### HELPER FUNCTION DEFINITIONS
96 ###
97
98 def err(s):
99     sys.stderr.write('E: ' + s + '\n')
100
101 def warn(s):
102     sys.stderr.write('W: ' + s + '\n')
103
104 def info(s):
105     if not options.verbose: return
106     sys.stderr.write('I: ' + s + '\n')
107
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)
112     if m is not None:
113         return (int(p), m.groupdict())
114     return (0, {})
115
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)
121     try:
122         stdout, stderr = s.communicate(rawmsg)
123         if s.returncode == 0:
124             return int(p)
125         else:
126             return 0
127     except OSError:
128         warn("command '%s' (template '%s') failed to run." % (r, name))
129         return 0
130
131 def interpolate_helpers(s):
132     while True:
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]
137         try:
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:]
142         except KeyError:
143             err('unknown helper: ' + helper)
144             sys.exit(posix.EX_DATAERR)
145     return s
146
147 def interpolate_env(s):
148     while True:
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:]
155     return s
156
157 def interpolate_vars(s):
158     return s % vars
159
160 def interpolate(s):
161     return interpolate_helpers(interpolate_env(interpolate_vars(s)))
162
163 # sentinel to use as dict value for preserved headers
164 class _keep_header: pass
165
166 ###
167 ### VARIABLE INITIALISATION
168 ###
169
170 infname = None
171 inf = sys.stdin
172 outfname = None
173 outf = sys.stdout
174 templname = None
175 templ = None
176 vars = {}
177 headers = {}
178 payload = None
179
180 ###
181 ### COMMAND LINE PARSING
182 ###
183
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')
213
214 options, args = parser.parse_args()
215
216 if options.version:
217     print __name__, __version__ + ' — ' + __description__
218     print
219     print 'Written by ' + __author__
220     print __copyright__
221     print 'Released under the ' + __licence__
222     sys.exit(posix.EX_OK)
223
224 ###
225 ### CONFIGURATION FILE PARSING
226 ###
227
228 CONFFILE = os.path.expanduser(CONFFILE)
229 MAILPLATEDIR = os.path.expanduser(MAILPLATEDIR)
230
231 # defaults
232 config = { 'default_template' : 'default'
233          , 'template_path' : TEMPLATEDIR
234          }
235 helpers = { 'get_quote' : 'fortune -s' }
236
237 if not os.path.exists(CONFFILE):
238     # conffile does not exist, let's create it with defaults.
239     options.verbose = True
240
241     if not os.path.isdir(MAILPLATEDIR):
242         info('configuration directory not found, creating: ' + MAILPLATEDIR)
243         os.mkdir(MAILPLATEDIR, 0700)
244
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)
252
253         if len(helpers) > 0:
254             f.write('\n[%s]\n' % SECTION_HELPERS)
255             for kvpair in helpers.iteritems():
256                 f.write('%s = %s\n' % kvpair)
257
258         f.close()
259
260 if not os.access(CONFFILE, os.R_OK):
261     err('cannot read configuration file: %s' % CONFFILE)
262     sys.exit(posix.EX_OSFILE)
263
264 # now parse
265 parser = ConfigParser.SafeConfigParser()
266 parser.read(CONFFILE)
267
268 # first the GENERAL section into the config dict for all keys with defaults
269 for key in config.keys():
270     try:
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:
276         continue
277     except ConfigParser.DuplicateSectionError, ConfigParser.ParseError:
278         err('parse error on %s' % CONFFILE)
279         sys.exit(posix.EX_CONFIG)
280
281 # all HELPERS into the helpers dict
282 helpers.update(parser.items(SECTION_HELPERS))
283
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)
288
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')
296         f.close()
297
298 if options.debug:
299     import pdb
300     pdb.set_trace()
301
302 # parse the arguments
303 for arg in args:
304     if arg == '-':
305         # filename is -, so do nothing, since stdin/stdout are default
306         continue
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):
310             infname = arg
311         if os.access(arg, os.W_OK):
312             outfname = arg
313     elif os.access(os.path.join(TPATH, arg), os.R_OK):
314         # argument referenced an existing template
315         templname = arg
316     else:
317         err('unknown argument, and cannot find a template by this name: %s' % arg)
318         sys.exit(posix.EX_USAGE)
319
320 # sanity checks
321 if options.auto and options.menu:
322     err('cannot combine --auto and --menu')
323     sys.exit(posix.EX_USAGE)
324
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)
328
329 elif not templname and not (options.auto or options.menu):
330     if default_templname is not None:
331         templname = default_templname
332     else:
333         err('no template specified')
334         sys.exit(posix.EX_USAGE)
335
336 elif options.menu:
337     err('--menu mode not yet implemented')
338     sys.exit(posix.EX_USAGE)
339
340 ###
341 ### MAIL PROCESSING
342 ###
343
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)
347
348 # read message into buffer, or preinitialise the buffer if --new is given
349 if options.new:
350     rawmsg = '\n'.join((header + ': ' for header in STD_HEADERS)) + '\n'
351 else:
352     rawmsg = ''.join(inf.readlines())
353
354 if options.auto:
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
359
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)
363         score = 0
364         vars = {}
365         f = open(tp, 'r')
366         for line in f:
367             if line[0] == REGEXPCHAR:
368                 r = exec_regexp(line, rawmsg, tf)
369                 score += r[0]
370                 vars.update(r[1])
371
372             elif line[0] == COMMANDCHAR:
373                 score += exec_command(line, rawmsg, tf)
374
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)
378
379     templname = best_score[1]
380
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)
384
385     info('chose profile %s with score %d.' % (templname, best_score[0]))
386     vars = best_score[2]
387
388 # now read in the template
389 templpath = os.path.join(TPATH, templname)
390
391 if not os.path.isfile(templpath):
392     err('not a template: ' + templpath)
393     sys.exit(posix.EX_OSFILE)
394
395 elif not os.access(templpath, os.R_OK):
396     err('template ' + templpath + ' could not be read.')
397     sys.exit(posix.EX_OSFILE)
398
399 templ = file(templpath, 'r', 1)
400
401 for line in templ:
402     if not options.auto and line[0] == REGEXPCHAR:
403         # obtain variables from the regexps
404         vars.update(exec_regexp(line, rawmsg, templname)[1])
405
406     if line[0] in (COMMENTCHAR, REGEXPCHAR, COMMANDCHAR):
407         continue
408
409     elif payload is not None:
410         # we're past the headers, so accumulate the payload
411         payload += line
412
413     else:
414         #TODO multiline headers
415         l = line[:-1]
416         if len(l) == 0:
417             payload = '' # end of headers
418         elif l[0] == KEEP_SLOT_LEADER:
419             if KEEP_HEADERS.has_key(l[1:]):
420                 # found predefined header slot keyword
421                 for header in KEEP_HEADERS[l[1:]]:
422                     headers[header.lower()] = (header, _keep_header)
423             else:
424                 err('unknown header slot ' + l + ' found')
425                 sys.exit(posix.EX_CONFIG)
426         else:
427             header, content = l.split(':', 1)
428             content = content.strip()
429             if content == KEEP_SLOT_LEADER + 'KEEP':
430                 # record header to be preserved
431                 content = _keep_header
432             else:
433                 content = interpolate(content)
434             headers[header.lower()] = (header, content)
435
436 msg = email.message_from_string(rawmsg)
437
438 for header, content in msg.items():
439     # iterate all existing mail headers
440     lheader = header.lower()
441     if headers.has_key(lheader):
442         # the template defines this header
443         if headers[lheader][1] == _keep_header:
444             # it's marked as keep, thus use content from email message
445             headers[lheader] = (header, content)
446     elif options.keep_unknown:
447         # the template does not define the header, but --keep-unknown was
448         # given, thus preserve the entire header field
449         headers[lheader] = (header, content)
450
451 # open the output file
452 if outfname is not None:
453     outf = file(outfname, 'w', 0)
454
455 # print the headers, starting with the standard headers in order
456 for header in STD_HEADERS:
457     lheader = header.lower()
458     if headers.get(lheader, (None, _keep_header))[1] is not _keep_header:
459         # the template header contains mandatory data, let's print it.
460         hpair = headers[lheader]
461         print >>outf, ': '.join(hpair)
462         # and remove it from the dict
463         del headers[lheader]
464
465 for i, (header, content) in headers.iteritems():
466     # print all remaining headers
467     if content == _keep_header: continue
468     print >>outf, ': '.join((header, content))
469
470 # print empty line to indicate end of headers.
471 print >>outf
472
473 # split payload of existing message into body and signature
474 body = msg.get_payload().rsplit(SIG_DELIM, 1)
475 signature = ''
476 if len(body) == 1:
477     body = body[0]
478 elif len(body) > 1:
479     signature = body[-1]
480     body = SIG_DELIM.join(body[:-1]).strip()
481 # signature may now be ''
482
483 # interpolate the template payload
484 payload = interpolate(payload)
485 # determine whether to interpolate the signature *before* inserting the body
486 # to prevent text in the body from being interpolated
487 keep_sig = payload.find('@KEEP_SIGNATURE') >= 0
488 # interpolate body and signature
489 payload = payload.replace('@KEEP_BODY', body, 1)
490 if keep_sig:
491     payload = payload.replace('@KEEP_SIGNATURE', signature, 1)
492
493 print >>outf, payload.rstrip()
494 outf.close()
495
496 if options.edit:
497     # finally, spawn the editor, if we wrote into a file
498     if outfname is None:
499         err('cannot use --edit without an output file.')
500         sys.exit(posix.EX_USAGE)
501
502     os.execlp('sensible-editor', 'sensible-editor', outfname)
503