#!/usr/bin/python
# -*- coding: utf-8 -*-
#
# mailplate — reformat mail drafts according to templates
#
# Please see the mailplate(1) manpage or the homepage for more information:
#   http://madduck.net/code/mailplate/
#
# TODO: if headers like From are absent from the mail, they should not be kept
# but replaced with a default.
#
# Copyright © martin f. krafft <madduck@madduck.net>
# Released under the terms of the Artistic Licence 2.0
#

__name__ = 'mailplate'
__description__ = 'reformat mail drafts according to templates'
__version__ = '0.1'
__author__ = 'martin f. krafft <madduck@madduck.net>'
__copyright__ = 'Copyright © ' + __author__
__licence__ = 'Artistic Licence 2.0'

import email
import os
import posix
import re
import sys
import subprocess
import ConfigParser
from optparse import OptionParser

###
### CONSTANTS
###

MAILPLATEDIR = '~/.mailplate'               # settings directory
CONFFILE = MAILPLATEDIR + '/config'         # configuration file
SECTION_GENERAL = 'general'                 # name of general config section
SECTION_HELPERS = 'helpers'                 # name of helpers config section
TEMPLATEDIR = MAILPLATEDIR + '/templates'   # location of templates

COMMENTCHAR = '#'   # character commencing a comment line in the template
REGEXPCHAR = '*'    # character commencing a regexp line in the template
COMMANDCHAR = '!'   # character commencing a command line in the template

KEEP_SLOT_LEADER = '@'      # character commencing a keep slot
ENV_SLOT_LEADER = '${'      # character commencing an environment variable slot
ENV_SLOT_TRAILER = '}'      # character ending an environment variable slot
HELPER_SLOT_LEADER = '$('   # character commencing a helper slot
HELPER_SLOT_TRAILER = ')'   # character ending a helper slot

# headers we want to preserve most of the time, and their order
STD_HEADERS = ('From', 'To', 'Cc', 'Bcc', 'Subject', 'Reply-To', 'In-Reply-To')
KEEP_HEADERS = { 'KEEP_FROM_HEADER' : STD_HEADERS[:1]
               , 'KEEP_STD_HEADERS' : STD_HEADERS[1:]
               , 'KEEP_ALL_HEADERS' : STD_HEADERS
               }

SIG_DELIM='\n-- \n'

###
### HELPER FUNCTION DEFINITIONS
###

def err(s):
    sys.stderr.write('E: ' + s + '\n')

def warn(s):
    sys.stderr.write('W: ' + s + '\n')

def info(s):
    if not options.verbose: return
    sys.stderr.write('I: ' + s + '\n')

# obtain a regexp from a line, run it, return score/dict if matched
def exec_regexp(line, rawmsg, name):
    p, r = line[1:].strip().split(' ', 1)
    m = re.compile(r, re.M | re.I | re.U).search(rawmsg)
    if m is not None:
        return (int(p), m.groupdict())
    return (0, {})

# obtain a command from a line, run it, return score if matched
def exec_command(line, rawmsg, name):
    p, r = line[1:].strip().split(' ', 1)
    s = subprocess.Popen(r, shell=True, stdin=subprocess.PIPE,
            stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    try:
        stdout, stderr = s.communicate(rawmsg)
        if s.returncode == 0:
            return int(p)
        else:
            return 0
    except OSError:
        warn("command '%s' (template '%s') failed to run." % (r, name))
        return 0

def interpolate_helpers(s):
    while True:
        helper_begin = s.find(HELPER_SLOT_LEADER)
        if helper_begin < 0: break
        helper_end = s.find(HELPER_SLOT_TRAILER, helper_begin)
        helper = s[helper_begin + len(HELPER_SLOT_LEADER):helper_end]
        try:
            proc = subprocess.Popen(helpers[helper], shell=True,
                    stdout=subprocess.PIPE, stderr=sys.stderr)
            out = proc.communicate()[0]
            s = s[:helper_begin] + out.strip() + s[helper_end+1:]
        except KeyError:
            err('unknown helper: ' + helper)
            sys.exit(posix.EX_DATAERR)
    return s

def interpolate_env(s):
    while True:
        envvar_begin = s.find(ENV_SLOT_LEADER)
        if envvar_begin < 0: break
        envvar_end = s.find(ENV_SLOT_TRAILER, envvar_begin)
        envvar = s[envvar_begin + len(ENV_SLOT_LEADER):envvar_end]
        value = os.getenv(envvar) or ''
        s = s[:envvar_begin] + value + s[envvar_end+1:]
    return s

def interpolate_vars(s):
    return s % vars

def interpolate(s):
    return interpolate_helpers(interpolate_env(interpolate_vars(s)))

# sentinel to use as dict value for preserved headers
class _keep_header: pass

###
### VARIABLE INITIALISATION
###

infname = None
inf = sys.stdin
outfname = None
outf = sys.stdout
templname = None
templ = None
vars = {}
headers = {}
payload = None

###
### COMMAND LINE PARSING
###

parser = OptionParser()
parser.prog = __name__
parser.version = __version__
parser.description = __description__
parser.usage = '%prog [options] <message>'
parser.add_option('-a', '--auto', dest='auto',
        default=False, action='store_true',
        help='turn on template auto-discovery')
parser.add_option('-m', '--menu', dest='menu',
        default=False, action='store_true',
        help='choose from a list of templates (not yet implemented)')
parser.add_option('-n', '--new', dest='new',
        default=False, action='store_true',
        help='create a new message')
parser.add_option('-e', '--editor', dest='edit',
        default=False, action='store_true',
        help='spawn editor once template is applied')
parser.add_option('-k', '--keep-unknown', dest='keep_unknown',
        default=False, action='store_true',
        help='preserve mail headers not specified in template')
parser.add_option('-v', '--verbose', dest='verbose',
        default=False, action='store_true',
        help='write informational messages to stderr')
parser.add_option('-d', '--debug', dest='debug',
        default=False, action='store_true',
        help='start a debugger after initialisation')
parser.add_option('-V', '--version', dest='version',
        default=False, action='store_true',
        help='display version information')

options, args = parser.parse_args()

if options.version:
    print __name__, __version__ + ' — ' + __description__
    print
    print 'Written by ' + __author__
    print __copyright__
    print 'Released under the ' + __licence__
    sys.exit(posix.EX_OK)

###
### CONFIGURATION FILE PARSING
###

CONFFILE = os.path.expanduser(CONFFILE)
MAILPLATEDIR = os.path.expanduser(MAILPLATEDIR)

# defaults
config = { 'default_template' : 'default'
         , 'template_path' : TEMPLATEDIR
         }
helpers = { 'get_quote' : 'fortune -s' }

if not os.path.exists(CONFFILE):
    # conffile does not exist, let's create it with defaults.
    options.verbose = True

    if not os.path.isdir(MAILPLATEDIR):
        info('configuration directory not found, creating: ' + MAILPLATEDIR)
        os.mkdir(MAILPLATEDIR, 0700)

    if not os.path.isfile(CONFFILE):
        info('creating a default configuration file: ' + CONFFILE)
        f = file(CONFFILE, 'w')
        f.write('# mailplate configuration\n[%s]\n' % SECTION_GENERAL)
        for kvpair in config.iteritems():
            if len(kvpair[1]) > 0:
                f.write('%s = %s\n' % kvpair)

        if len(helpers) > 0:
            f.write('\n[%s]\n' % SECTION_HELPERS)
            for kvpair in helpers.iteritems():
                f.write('%s = %s\n' % kvpair)

        f.close()

if not os.access(CONFFILE, os.R_OK):
    err('cannot read configuration file: %s' % CONFFILE)
    sys.exit(posix.EX_OSFILE)

# now parse
parser = ConfigParser.SafeConfigParser()
parser.read(CONFFILE)

# first the GENERAL section into the config dict for all keys with defaults
for key in config.keys():
    try:
        config[key] = parser.get(SECTION_GENERAL, key)
    except ConfigParser.NoSectionError, ConfigParser.MissingSectionHeaderError:
        err("no section '%s' in %s" % (SECTION_GENERAL, CONFFILE))
        sys.exit(posix.EX_CONFIG)
    except ConfigParser.NoOptionError:
        continue
    except ConfigParser.DuplicateSectionError, ConfigParser.ParseError:
        err('parse error on %s' % CONFFILE)
        sys.exit(posix.EX_CONFIG)

# all HELPERS into the helpers dict
helpers.update(parser.items(SECTION_HELPERS))

TPATH = os.path.expanduser(config['template_path'])
if not os.path.isdir(TPATH):
    info('creating template directory: ' + TPATH)
    os.mkdir(TPATH, 0700)

default_templname = config['default_template']
if default_templname is not None:
    default_templpath = os.path.join(TPATH, default_templname)
    if not os.path.isfile(default_templpath):
        info('creating the default template: ' + default_templpath)
        f = file(default_templpath, 'w')
        f.write('@KEEP_STD_HEADERS\n\n@KEEP_BODY\n')
        f.close()

if options.debug:
    import pdb
    pdb.set_trace()

# parse the arguments
for arg in args:
    if arg == '-':
        # filename is -, so do nothing, since stdin/stdout are default
        continue
    elif os.path.isfile(arg):
        # the file exists, so use it as in/out if read/writeable
        if os.access(arg, os.R_OK):
            infname = arg
        if os.access(arg, os.W_OK):
            outfname = arg
    elif os.access(os.path.join(TPATH, arg), os.R_OK):
        # argument referenced an existing template
        templname = arg
    else:
        err('unknown argument, and cannot find a template by this name: %s' % arg)
        sys.exit(posix.EX_USAGE)

# sanity checks
if options.auto and options.menu:
    err('cannot combine --auto and --menu')
    sys.exit(posix.EX_USAGE)

elif (options.auto or options.menu) and templname:
    err('cannot specify a template with --auto or --menu')
    sys.exit(posix.EX_USAGE)

elif not templname and not (options.auto or options.menu):
    if default_templname is not None:
        templname = default_templname
    else:
        err('no template specified')
        sys.exit(posix.EX_USAGE)

elif options.menu:
    err('--menu mode not yet implemented')
    sys.exit(posix.EX_USAGE)

###
### MAIL PROCESSING
###

# read in the message from a file, if a filename is given.
if infname is not None:
    inf = file(infname, 'r', 1)

# read message into buffer, or preinitialise the buffer if --new is given
if options.new:
    rawmsg = '\n'.join((header + ': ' for header in STD_HEADERS)) + '\n'
else:
    rawmsg = ''.join(inf.readlines())

if options.auto:
    best_score = (0, default_templname, {})
    for tf in os.listdir(TPATH):
        tp = os.path.join(TPATH, tf)
        if not os.path.isfile(tp) or not os.access(tp, os.R_OK): continue

        # we're iterating all files in the template directory
        # for each file, obtain and run regexps and commands and accumulate
        # the score (and variables)
        score = 0
        vars = {}
        f = open(tp, 'r')
        for line in f:
            if line[0] == REGEXPCHAR:
                r = exec_regexp(line, rawmsg, tf)
                score += r[0]
                vars.update(r[1])

            elif line[0] == COMMANDCHAR:
                score += exec_command(line, rawmsg, tf)

        # do we top the currently best score, if so then raise the bar
        if score > best_score[0]:
            best_score = (score, tf, vars)

    templname = best_score[1]

    if templname is None:
        err('could not determine a template to use and no default is set')
        sys.exit(posix.EX_CONFIG)

    info('chose profile %s with score %d.' % (templname, best_score[0]))
    vars = best_score[2]

# now read in the template
templpath = os.path.join(TPATH, templname)

if not os.path.isfile(templpath):
    err('not a template: ' + templpath)
    sys.exit(posix.EX_OSFILE)

elif not os.access(templpath, os.R_OK):
    err('template ' + templpath + ' could not be read.')
    sys.exit(posix.EX_OSFILE)

templ = file(templpath, 'r', 1)

for line in templ:
    if not options.auto and line[0] == REGEXPCHAR:
        # obtain variables from the regexps
        vars.update(exec_regexp(line, rawmsg, templname)[1])

    if line[0] in (COMMENTCHAR, REGEXPCHAR, COMMANDCHAR):
        continue

    elif payload is not None:
        # we're past the headers, so accumulate the payload
        payload += line

    else:
        #TODO multiline headers
        l = line[:-1]
        if len(l) == 0:
            payload = '' # end of headers
        elif l[0] == KEEP_SLOT_LEADER:
            if KEEP_HEADERS.has_key(l[1:]):
                # found predefined header slot keyword
                for header in KEEP_HEADERS[l[1:]]:
                    headers[header.lower()] = (header, _keep_header)
            else:
                err('unknown header slot ' + l + ' found')
                sys.exit(posix.EX_CONFIG)
        else:
            header, content = l.split(':', 1)
            content = content.strip()
            if content == KEEP_SLOT_LEADER + 'KEEP':
                # record header to be preserved
                content = _keep_header
            else:
                content = interpolate(content)
            headers[header.lower()] = (header, content)

msg = email.message_from_string(rawmsg)

for header, content in msg.items():
    # iterate all existing mail headers
    lheader = header.lower()
    if headers.has_key(lheader):
        # the template defines this header
        if headers[lheader][1] == _keep_header:
            # it's marked as keep, thus use content from email message
            headers[lheader] = (header, content)
    elif options.keep_unknown:
        # the template does not define the header, but --keep-unknown was
        # given, thus preserve the entire header field
        headers[lheader] = (header, content)

# open the output file
if outfname is not None:
    outf = file(outfname, 'w', 0)

# print the headers, starting with the standard headers in order
for header in STD_HEADERS:
    lheader = header.lower()
    if headers.get(lheader, (None, _keep_header))[1] is not _keep_header:
        # the template header contains mandatory data, let's print it.
        hpair = headers[lheader]
        print >>outf, ': '.join(hpair)
        # and remove it from the dict
        del headers[lheader]

for i, (header, content) in headers.iteritems():
    # print all remaining headers
    if content == _keep_header: continue
    print >>outf, ': '.join((header, content))

# print empty line to indicate end of headers.
print >>outf

# split payload of existing message into body and signature
body = msg.get_payload().rsplit(SIG_DELIM, 1)
signature = ''
if len(body) == 1:
    body = body[0]
elif len(body) > 1:
    signature = body[-1]
    body = SIG_DELIM.join(body[:-1]).strip()
# signature may now be ''

# interpolate the template payload
payload = interpolate(payload)
# determine whether to interpolate the signature *before* inserting the body
# to prevent text in the body from being interpolated
keep_sig = payload.find('@KEEP_SIGNATURE') >= 0
# interpolate body and signature
payload = payload.replace('@KEEP_BODY', body, 1)
if keep_sig:
    payload = payload.replace('@KEEP_SIGNATURE', signature, 1)

print >>outf, payload.rstrip()
outf.close()

if options.edit:
    # finally, spawn the editor, if we wrote into a file
    if outfname is None:
        err('cannot use --edit without an output file.')
        sys.exit(posix.EX_USAGE)

    os.execlp('sensible-editor', 'sensible-editor', outfname)
