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

7e1a4d498f34ad88519a720c16ea41cd902ea893
[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' : ''
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 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.DuplicateSectionError, ConfigParser.ParseError:
214         err('E: parse error on %s' % CONFFILE)
215         sys.exit(posix.EX_CONFIG)
216
217 # all HELPERS into the helpers dict
218 helpers.update(parser.items(SECTION_HELPERS))
219
220 TPATH = os.path.expanduser(config['template_path'])
221
222 ###
223 ### COMMAND LINE PARSING
224 ###
225
226 parser = OptionParser()
227 parser.usage = '%prog [options] <message>'
228 parser.add_option('-a', '--auto', dest='auto',
229         default=False, action='store_true',
230         help='turn on template auto-discovery')
231 parser.add_option('-m', '--menu', dest='menu',
232         default=False, action='store_true',
233         help='choose from a list of template')
234 parser.add_option('-n', '--new', dest='new',
235         default=False, action='store_true',
236         help='create a new message')
237 parser.add_option('-e', '--editor', dest='edit',
238         default=False, action='store_true',
239         help='spawn editor once template is applied')
240 parser.add_option('-k', '--keep-unknown', dest='keep_unknown',
241         default=False, action='store_true',
242         help='preserve mail headers not specified in template')
243 parser.add_option('-d', '--debug', dest='debug',
244         default=False, action='store_true',
245         help='start a debugger after initialisation')
246
247 options, args = parser.parse_args()
248
249 if options.debug:
250     import pdb
251     pdb.set_trace()
252
253 # parse the arguments
254 for arg in args:
255     if arg == '-':
256         # filename is -, so do nothing, since stdin/stdout are default
257         continue
258     elif os.path.isfile(arg):
259         # the file exists, so use it as in/out if read/writeable
260         if os.access(arg, os.R_OK):
261             infname = arg
262         if os.access(arg, os.W_OK):
263             outfname = arg
264     elif os.access(os.path.join(TPATH, arg), os.R_OK):
265         # argument referenced an existing template
266         templname = arg
267     else:
268         err('E: unknown argument: %s' % arg)
269         sys.exit(posix.EX_USAGE)
270
271 # sanity checks
272 if options.auto and options.menu:
273     err('E: cannot combine --auto and --menu')
274     sys.exit(posix.EX_USAGE)
275
276 elif (options.auto or options.menu) and templname:
277     err('E: cannot specify a template with --auto or --menu')
278     sys.exit(posix.EX_USAGE)
279
280 elif not templname and not (options.auto or options.menu):
281     err('E: no template specified')
282     sys.exit(posix.EX_USAGE)
283
284 elif options.menu:
285     err('E: --menu mode not yet implemented')
286     sys.exit(posix.EX_USAGE)
287
288 ###
289 ### MAIL PROCESSING
290 ###
291
292 # read in the message from a file, if a filename is given.
293 if infname is not None:
294     inf = file(infname, 'r', 1)
295
296 # read message into buffer, or preinitialise the buffer if --new is given
297 if options.new:
298     rawmsg = '\n'.join((header + ': ' for k in STD_HEADERS)) + '\n'
299 else:
300     rawmsg = ''.join(inf.readlines())
301
302 if options.auto:
303     best_score = (0, config['default_template'], {})
304     for tf in os.listdir(TPATH):
305         tp = os.path.join(TPATH, tf)
306         if not os.path.isfile(tp) or not os.access(tp, os.R_OK): continue
307
308         # we're iterating all files in the template directory
309         # for each file, obtain and run regexps and commands and accumulate
310         # the score (and variables)
311         score = 0
312         vars = {}
313         f = open(tp, 'r')
314         for line in f:
315             if line[0] == REGEXPCHAR:
316                 r = exec_regexp(line, rawmsg, tf)
317                 score += r[0]
318                 vars.update(r[1])
319
320             elif line[0] == COMMANDCHAR:
321                 score += exec_command(line, rawmsg, tf)
322
323         # do we top the currently best score, if so then raise the bar
324         if score > best_score[0]:
325             best_score = (score, tf, vars)
326
327     templname = best_score[1]
328     print >>sys.stderr, \
329             'I: Chose profile %s with score %d.' % (templname, best_score[0])
330     vars = best_score[2]
331
332 # now read in the template
333 templ = file(os.path.join(TPATH, templname), 'r', 1)
334 for line in templ:
335     if not options.auto and line[0] == REGEXPCHAR:
336         # obtain variables from the regexps
337         vars.update(exec_regexp(line, rawmsg, templname)[1])
338
339     if line[0] in (COMMENTCHAR, REGEXPCHAR, COMMANDCHAR):
340         continue
341
342     elif payload is not None:
343         # we're past the headers, so accumulate the payload
344         payload += line
345
346     else:
347         #TODO multiline headers
348         l = line[:-1]
349         if len(l) == 0:
350             payload = '' # end of headers
351         elif l[0] == KEEP_SLOT_LEADER and KEEP_HEADERS.has_key(l[1:]):
352             # found predefined header slot keyword
353             for header in KEEP_HEADERS[l[1:]]:
354                 headers[header.lower()] = (header, _keep_header)
355         else:
356             header, content = l.split(':', 1)
357             content = content.strip()
358             if content == KEEP_SLOT_LEADER + 'KEEP':
359                 # record header to be preserved
360                 content = _keep_header
361             else:
362                 content = interpolate(content)
363             headers[header.lower()] = (header, content)
364
365 msg = email.message_from_string(rawmsg)
366
367 for header, content in msg.items():
368     # iterate all existing mail headers
369     lheader = header.lower()
370     if headers.has_key(lheader):
371         # the template defines this header
372         if headers[lheader][1] == _keep_header:
373             # it's marked as keep, thus use content from email message
374             headers[lheader] = (header, content)
375     elif options.keep_unknown:
376         # the template does not define the header, but --keep-unknown was
377         # given, thus preserve the entire header field
378         headers[lheader] = (header, content)
379
380 # open the output file
381 if outfname is not None:
382     outf = file(outfname, 'w', 0)
383
384 # print the headers, starting with the standard headers in order
385 for header in STD_HEADERS:
386     lheader = header.lower()
387     if headers.get(lheader, (None, _keep_header))[1] is not _keep_header:
388         # the template header contains mandatory data, let's print it.
389         hpair = headers[lheader]
390         print >>outf, ': '.join(hpair)
391         # and remove it from the dict
392         del headers[lheader]
393
394 for i, (header, content) in headers.iteritems():
395     # print all remaining headers
396     if content == _keep_header: continue
397     print >>outf, ': '.join((header, content))
398
399 # print empty line to indicate end of headers.
400 print >>outf
401
402 # split payload of existing message into body and signature
403 body = msg.get_payload().rsplit(SIG_DELIM, 1)
404 signature = ''
405 if len(body) == 1:
406     body = body[0]
407 elif len(body) > 1:
408     signature = body[-1]
409     body = SIG_DELIM.join(body[:-1]).strip()
410 # signature may now be ''
411
412 # interpolate the template payload
413 payload = interpolate(payload)
414 # determine whether to interpolate the signature *before* inserting the body
415 # to prevent text in the body from being interpolated
416 keep_sig = payload.find('@KEEP_SIGNATURE') >= 0
417 # interpolate body and signature
418 payload = payload.replace('@KEEP_BODY', body, 1)
419 if keep_sig:
420     payload = payload.replace('@KEEP_SIGNATURE', signature, 1)
421
422 print >>outf, payload.rstrip()
423 outf.close()
424
425 if options.edit:
426     # finally, spawn the editor, if we wrote into a file
427     if outfname is None:
428         err('E: cannot use --edit without an output file.')
429         sys.exit(posix.EX_USAGE)
430
431     os.execlp('sensible-editor', 'sensible-editor', outfname)