]> 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 up manpage
[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         infname = arg
273         outfname = arg
274     elif arg.find(os.path.sep) == -1 and os.access(os.path.join(TPATH, arg), os.R_OK):
275         if templname is not None:
276             err("template already specified (%s), unsure what to do with '%s'"
277                 % (templname, arg))
278             sys.exit(posix.EX_USAGE)
279         # argument references an existing template
280         templname = arg
281     elif os.path.isfile(arg):
282         if infname is not None:
283             err("input file already specified (%s), unsure what to do with '%s'"
284                 % (infname, arg))
285             sys.exit(posix.EX_USAGE)
286         # the file exists, so use it as in/out if read/writeable
287         if os.access(arg, os.R_OK):
288             infname = arg
289         if os.access(arg, os.W_OK):
290             outfname = arg
291     else:
292         err('unknown argument: %s' % arg)
293         sys.exit(posix.EX_USAGE)
294
295 # sanity checks
296 if options.auto and options.menu:
297     err('cannot combine --auto and --menu')
298     sys.exit(posix.EX_USAGE)
299
300 elif (options.auto or options.menu) and templname:
301     err('cannot specify a template with --auto or --menu')
302     sys.exit(posix.EX_USAGE)
303
304 elif not templname and not (options.auto or options.menu):
305     if default_templname is not None:
306         templname = default_templname
307     else:
308         err('no template specified')
309         sys.exit(posix.EX_USAGE)
310
311 elif options.menu:
312     err('--menu mode not yet implemented')
313     sys.exit(posix.EX_USAGE)
314
315 ###
316 ### MAIL PROCESSING
317 ###
318
319 # read in the message from a file, if a filename is given.
320 if infname is not None:
321     inf = file(infname, 'r', 1)
322
323 # read message into buffer, or preinitialise the buffer if --new is given
324 if options.new:
325     rawmsg = '\n'.join((header + ': ' for header in STD_HEADERS)) + '\n'
326 else:
327     rawmsg = ''.join(inf.readlines())
328
329 if options.auto:
330     best_score = (0, default_templname, {})
331     for tf in os.listdir(TPATH):
332         tp = os.path.join(TPATH, tf)
333         if not os.path.isfile(tp) or not os.access(tp, os.R_OK): continue
334
335         # we're iterating all files in the template directory
336         # for each file, obtain and run regexps and commands and accumulate
337         # the score (and variables)
338         score = 0
339         vars = {}
340         f = open(tp, 'r')
341         for line in f:
342             if line[0] == REGEXPCHAR:
343                 r = exec_regexp(line, rawmsg, tf)
344                 score += r[0]
345                 vars.update(r[1])
346
347             elif line[0] == COMMANDCHAR:
348                 score += exec_command(line, rawmsg, tf)
349
350         # do we top the currently best score, if so then raise the bar
351         if score > best_score[0]:
352             best_score = (score, tf, vars)
353
354     templname = best_score[1]
355
356     if templname is None:
357         err('could not determine a template to use and no default is set')
358         sys.exit(posix.EX_CONFIG)
359
360     info('chose profile %s with score %d.' % (templname, best_score[0]))
361     vars = best_score[2]
362
363 # now read in the template
364 templpath = os.path.join(TPATH, templname)
365
366 if not os.path.isfile(templpath):
367     err('not a template: ' + templpath)
368     sys.exit(posix.EX_OSFILE)
369
370 elif not os.access(templpath, os.R_OK):
371     err('template ' + templpath + ' could not be read.')
372     sys.exit(posix.EX_OSFILE)
373
374 templ = file(templpath, 'r', 1)
375
376 for line in templ:
377     if not options.auto and line[0] == REGEXPCHAR:
378         # obtain variables from the regexps
379         vars.update(exec_regexp(line, rawmsg, templname)[1])
380
381     if line[0] in (COMMENTCHAR, REGEXPCHAR, COMMANDCHAR):
382         continue
383
384     elif payload is not None:
385         # we're past the headers, so accumulate the payload
386         payload += line
387
388     else:
389         #TODO multiline headers
390         l = line[:-1]
391         if len(l) == 0:
392             payload = '' # end of headers
393         elif l[0] == KEEP_SLOT_LEADER:
394             if KEEP_HEADERS.has_key(l[1:]):
395                 # found predefined header slot keyword
396                 for header in KEEP_HEADERS[l[1:]]:
397                     headers[header.lower()] = (header, _keep_header)
398             else:
399                 err('unknown header slot ' + l + ' found')
400                 sys.exit(posix.EX_CONFIG)
401         else:
402             header, content = l.split(':', 1)
403             content = content.strip()
404             if content == KEEP_SLOT_LEADER + 'KEEP':
405                 # record header to be preserved
406                 content = _keep_header
407             else:
408                 content = interpolate(content)
409             headers[header.lower()] = (header, content)
410
411 msg = email.message_from_string(rawmsg)
412
413 for header, content in msg.items():
414     # iterate all existing mail headers
415     lheader = header.lower()
416     if headers.has_key(lheader):
417         # the template defines this header
418         if headers[lheader][1] == _keep_header:
419             # it's marked as keep, thus use content from email message
420             headers[lheader] = (header, content)
421     elif options.keep_unknown:
422         # the template does not define the header, but --keep-unknown was
423         # given, thus preserve the entire header field
424         headers[lheader] = (header, content)
425
426 # open the output file
427 if outfname is not None:
428     outf = file(outfname, 'w', 0)
429
430 # print the headers, starting with the standard headers in order
431 for header in STD_HEADERS:
432     lheader = header.lower()
433     if headers.get(lheader, (None, _keep_header))[1] is not _keep_header:
434         # the template header contains mandatory data, let's print it.
435         hpair = headers[lheader]
436         print >>outf, ': '.join(hpair)
437         # and remove it from the dict
438         del headers[lheader]
439
440 for i, (header, content) in headers.iteritems():
441     # print all remaining headers
442     if content == _keep_header: continue
443     print >>outf, ': '.join((header, content))
444
445 # print empty line to indicate end of headers.
446 print >>outf
447
448 # split payload of existing message into body and signature
449 body = msg.get_payload().rsplit(SIG_DELIM, 1)
450 signature = ''
451 if len(body) == 1:
452     body = body[0]
453 elif len(body) > 1:
454     signature = body[-1]
455     body = SIG_DELIM.join(body[:-1]).strip()
456 # signature may now be ''
457
458 # interpolate the template payload
459 payload = interpolate(payload)
460 # determine whether to interpolate the signature *before* inserting the body
461 # to prevent text in the body from being interpolated
462 keep_sig = payload.find('@KEEP_SIGNATURE') >= 0
463 # interpolate body and signature
464 payload = payload.replace('@KEEP_BODY', body, 1)
465 if keep_sig:
466     payload = payload.replace('@KEEP_SIGNATURE', signature, 1)
467
468 print >>outf, payload.rstrip()
469 outf.close()
470
471 if options.edit:
472     # finally, spawn the editor, if we wrote into a file
473     if outfname is None:
474         err('cannot use --edit without an output file.')
475         sys.exit(posix.EX_USAGE)
476
477     os.execlp('sensible-editor', 'sensible-editor', outfname)