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

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