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.
2 # -*- coding: utf-8 -*-
4 # mailplate — reformat mail drafts according to templates
6 # Please see the mailplate(1) manpage or the homepage for more information:
7 # http://madduck.net/code/mailplate/
9 # TODO: if headers like From are absent from the mail, they should not be kept
10 # but replaced with a default.
12 # Copyright © martin f. krafft <madduck@madduck.net>
13 # Released under the terms of the Artistic Licence 2.0
16 __name__ = 'mailplate'
17 __description__ = 'reformat mail drafts according to templates'
19 __author__ = 'martin f. krafft <madduck@madduck.net>'
20 __copyright__ = 'Copyright © ' + __author__
21 __licence__ = 'Artistic Licence 2.0'
30 from optparse import OptionParser
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
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
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
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
62 ### HELPER FUNCTION DEFINITIONS
66 sys.stderr.write('E: ' + s + '\n')
69 sys.stderr.write('W: ' + s + '\n')
72 if not options.verbose: return
73 sys.stderr.write('I: ' + s + '\n')
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)
80 return (int(p), m.groupdict())
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)
89 stdout, stderr = s.communicate(rawmsg)
95 warn("command '%s' (template '%s') failed to run." % (r, name))
98 def interpolate_helpers(s):
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]
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:]
110 err('unknown helper: ' + helper)
111 sys.exit(posix.EX_DATAERR)
114 def interpolate_env(s):
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:]
124 def interpolate_vars(s):
128 return interpolate_helpers(interpolate_env(interpolate_vars(s)))
130 # sentinel to use as dict value for preserved headers
131 class _keep_header: pass
134 ### VARIABLE INITIALISATION
148 ### COMMAND LINE PARSING
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')
181 options, args = parser.parse_args()
184 print(__name__, __version__ + ' — ' + __description__)
186 print('Written by ' + __author__)
188 print('Released under the ' + __licence__)
189 sys.exit(posix.EX_OK)
192 ### CONFIGURATION FILE PARSING
195 CONFFILE = os.path.expanduser(CONFFILE)
196 MAILPLATEDIR = os.path.expanduser(MAILPLATEDIR)
199 config = { 'default_template' : 'default'
200 , 'template_path' : TEMPLATEDIR
202 helpers = { 'get_quote' : 'fortune -s' }
204 if not os.path.exists(CONFFILE):
205 # conffile does not exist, let's create it with defaults.
206 options.verbose = True
208 if not os.path.isdir(MAILPLATEDIR):
209 info('configuration directory not found, creating: ' + MAILPLATEDIR)
210 os.mkdir(MAILPLATEDIR, o0700)
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)
221 f.write('\n[%s]\n' % SECTION_HELPERS)
222 for kvpair in helpers.iteritems():
223 f.write('%s = %s\n' % kvpair)
227 if not os.access(CONFFILE, os.R_OK):
228 err('cannot read configuration file: %s' % CONFFILE)
229 sys.exit(posix.EX_OSFILE)
232 parser = configparser.ConfigParser()
233 parser.read(CONFFILE)
235 # first the GENERAL section into the config dict for all keys with defaults
236 for key in config.keys():
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:
245 except (configparser.DuplicateSectionError,
246 configparser.ParseError):
247 err('parse error on %s' % CONFFILE)
248 sys.exit(posix.EX_CONFIG)
250 # all HELPERS into the helpers dict
251 helpers.update(parser.items(SECTION_HELPERS))
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)
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')
271 # parse the arguments
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'"
280 sys.exit(posix.EX_USAGE)
281 # argument references an existing template
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'"
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):
291 if os.access(arg, os.W_OK):
294 err('unknown argument: %s' % arg)
295 sys.exit(posix.EX_USAGE)
298 if options.auto and options.menu:
299 err('cannot combine --auto and --menu')
300 sys.exit(posix.EX_USAGE)
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)
306 elif not templname and not (options.auto or options.menu):
307 if default_templname is not None:
308 templname = default_templname
310 err('no template specified')
311 sys.exit(posix.EX_USAGE)
314 err('--menu mode not yet implemented')
315 sys.exit(posix.EX_USAGE)
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)
325 # read message into buffer, or preinitialise the buffer if --new is given
327 rawmsg = '\n'.join((header + ': ' for header in STD_HEADERS)) + '\n'
329 rawmsg = ''.join(inf.readlines())
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
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)
344 if line[0] == REGEXPCHAR:
345 r = exec_regexp(line, rawmsg, tf)
349 elif line[0] == COMMANDCHAR:
350 score += exec_command(line, rawmsg, tf)
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)
356 templname = best_score[1]
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)
362 info('chose profile %s with score %d.' % (templname, best_score[0]))
365 # now read in the template
366 templpath = os.path.join(TPATH, templname)
368 if not os.path.isfile(templpath):
369 err('not a template: ' + templpath)
370 sys.exit(posix.EX_OSFILE)
372 elif not os.access(templpath, os.R_OK):
373 err('template ' + templpath + ' could not be read.')
374 sys.exit(posix.EX_OSFILE)
376 templ = open(templpath, 'r', 1)
379 if not options.auto and line[0] == REGEXPCHAR:
380 # obtain variables from the regexps
381 vars.update(exec_regexp(line, rawmsg, templname)[1])
383 if line[0] in (COMMENTCHAR, REGEXPCHAR, COMMANDCHAR):
386 elif payload is not None:
387 # we're past the headers, so accumulate the payload
391 #TODO multiline headers
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)
401 err('unknown header slot ' + l + ' found')
402 sys.exit(posix.EX_CONFIG)
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
410 content = interpolate(content)
411 headers[header.lower()] = (header, content)
413 msg = email.message_from_string(rawmsg)
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)
428 # open the output file
429 if outfname is not None:
430 outf = open(outfname, 'w')
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
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)
447 # print empty line to indicate end of headers.
450 # split payload of existing message into body and signature
451 body = msg.get_payload().rsplit(SIG_DELIM, 1)
457 body = SIG_DELIM.join(body[:-1]).strip()
458 # signature may now be ''
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)
468 payload = payload.replace('@KEEP_SIGNATURE', signature, 1)
470 print(payload.rstrip(), file=outf)
474 # finally, spawn the editor, if we wrote into a file
476 err('cannot use --edit without an output file.')
477 sys.exit(posix.EX_USAGE)
479 os.execlp('sensible-editor', 'sensible-editor', outfname)