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

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