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

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