#!/usr/bin/env python3


import os, sys, logging, time, pprint, warnings, argparse, re, configparser, shutil, imaplib, email, email.header, email.utils, string, subprocess, uuid
import pprint
from logging.handlers import SysLogHandler

warnings.filterwarnings('ignore')
ME = os.path.basename(sys.argv[0])
loggingFormat='%(asctime)s %(filename)s: %(message)s'
logging.basicConfig(stream=sys.stderr, level=logging.WARNING, format=loggingFormat)
logger = logging.getLogger(ME)
start_time = time.time()
configIni = {}
config = {}
imapErrorSleepSeconds = 15
imapCheckSleepSeconds = 2
maxAccountLoopCounter = -1  # Default, run forever

configFileHelp = \
"""

Configuration File Format
=========================

Configuration takes the form of a "ini" style file.  
Each ini section represents an account to process.  The section name must be unique.
If the USER setting is missing the section name is used.

INI File Values
===============

USER   - IMAP Authentication User, defaults to INI SECTION NAME if missing
HOST   - IMAP Server
PASS   - IMAP Password
FOLDER - IMAP Folder to read from
FLAG   - A flag file, if defined and existing will cause this entry to be checked
         if INI line is mising, entry runs always
SAVEATT- Save all attachments in this (existing) directory
          For each message with attachments, a subdir uuid1 will be created.
          attachments will be saved in that dir
          Using /tmp here, for example, is safe.
          You are responsible for cleaning up.

PIPE   - Email Body is piped to STDIN of this program
    or
EXEC   - Program is executed, but nothing is sent to STDIN
          Note, $BODY and $BODYLINE are provided below to pass message body 
          as argument.

ARGS   - Multi-line, each line is passed as a argument to the PIPE or EXEC program. 
          Additional lines are indented.
          Each line is properly quoted when passed to exec() or popen(). 
          You may pass the following variable substitutions in arguments:

    $TO         To Header, Cleaned to just email addresses
    $FROM       From Header, Cleaned to just email addresses
    $CC         CC Header, Cleaned to just email addresses
    $SUBJECT    Subject header
    $REPLYALL   All email addresses from $FROM and $CC combined
    $BODY       The entire message body, including newlines
    $BODYLINE   The entire message body, all in single line. All whitespace
                 Including newlines are replaced by single spaces.
    $ATTDIR     The directory where attachments were stored, null if none.
    $ATTFILES   Replaced with properly quoted list of files. Note, this special
                 variable can not be combined with any other text in the same
                 INI ARGS line.  It should be used on a ARGS line on its own.
                 It is replaced with a list of files, passed directly to exec() or 
                 popen(), Most useful as last argument since it is variable length.

Example Configuration:
======================

[blah@gmail.com]
HOST    = imap.gmail.com
PASS    = blahBLAHblahPASSWORD
SAVEATT = /tmp
FLAG    = /tmp/ok-to-check-blah.FLAG
PIPE    = /sage/platform/scripts/sysmatt.epson.note
ARGS    = -s
    $SUBJECT
    -f
    $FROM

[blah@gmail.com/SpecialLabel]
USER   = blah@gmail.com
HOST   = imap.gmail.com
PASS   = blahBLAHblahPASSWORD
PIPE   = /sage/platform/scripts/sysmatt.epson.note
FOLDER = MySpecialLabel
ARGS   = -s
    Additional Text $SUBJECT
    -f
    $FROM
    -r
    $REPLY

[blah@gmail.com/ExecExample]
USER   = blah@gmail.com
HOST   = imap.gmail.com
PASS   = blahBLAHblahPASSWORD
EXEC   = /sage/platform/scripts/sysmatt.epson.note
ARGS   = -s
    Additional Text $SUBJECT
    -f
    $FROM
    -r
    $REPLY
    $BODYLINE
    $ATTFILES


"""

parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter, epilog=configFileHelp)
parser.add_argument("-v", "--verbose",  help="increase output verbosity", action="store_true")
parser.add_argument("-d", "--debug",    help="enable debugging output", action="store_true")
parser.add_argument("-C", "--clear",    help="clear out pending messages upon startup before processing", action="store_true")
parser.add_argument("-o", "--oneshot",  help="process account(s) only once then exit", action="store_true")
parser.add_argument("-n", "--numloops", help="process account(s) this many times then exit", action="store")
parser.add_argument("-S", "--syslog",   help="enable logging to syslog also", action="store_true")
parser.add_argument("configFile",       help="provide a ini configuration file", action="store")
args = parser.parse_args()

# Holder of imap connection objects
imap = {}

if args.verbose:
    logger.setLevel(logging.INFO)
if args.debug:
    logger.setLevel(logging.DEBUG)
if args.oneshot:
    maxAccountLoopCounter = 1
    logger.debug("Oneshot mode")
if args.numloops:
    maxAccountLoopCounter = int(args.numloops)
    logger.debug("Limit to [{}] account loops".format(maxAccountLoopCounter))

if args.syslog:
    shandler = SysLogHandler(address='/dev/log')
    shandler.setLevel(logging.INFO)
    if args.debug:
        shandler.setLevel(logging.DEBUG)
    #shandler.setFormatter(Formatter(fmt=fmt))
    syslog_formatter = logging.Formatter('%(name)s: %(levelname)s %(message)s')
    shandler.setFormatter(syslog_formatter)
    logger.addHandler(shandler)
    logger.info("Syslog Logging Enabled")



def bomb(chunk):
	logger.error("%s",chunk)
	sys.exit(1)

def imapConnect(thisAccount):
    global imap
    logger.debug("Opening [{}] IMAP to host [{}]".format(thisAccount, config[thisAccount]['HOST']))
    try:
        imap[thisAccount]  = imaplib.IMAP4_SSL(config[thisAccount]['HOST'])
    except Exception as error:
        bomb("Unable to connect to HOST [{}]".format(error))

    logger.debug("Authenticating [{}] IMAP to host [{}]".format(thisAccount, config[thisAccount]['HOST']))
    try:
        logger.debug("IMAP Login for USER [{}]".format(config[thisAccount].get('USER',thisAccount)))
        imap[thisAccount].login(config[thisAccount].get('USER',thisAccount), config[thisAccount]['PASS'])
    except Exception as error:
        bomb("Authentication error [{}]".format(error))


config = configparser.ConfigParser()

try:
    logger.debug("Reading configFile [{}]".format(args.configFile))
    config.read_file(open(args.configFile))
except Exception as error:
    bomb("Unable to read configFile [{}]".format(error))


def getCleanAddrHeader(msg,header):
    out = []
    for ht in email.utils.getaddresses(msg.get_all(header,[])):
        if ht:
            displayName,cleanAddr = ht
            logger.debug("ht[{}] displayName[{}] cleanAddr[{}]".format(ht,displayName,cleanAddr))
            out.append(cleanAddr)
    if out:
        logger.debug("getCleanAddrHeader header[{}] ret[{}]".format(header,",".join(out)))
        return(",".join(out))
    else:
        logger.debug("getCleanAddrHeader header[{}] empty".format(header))
        return("")

def emailDataSubst(emailData,templateIn):
    out=string.Template(templateIn).safe_substitute(emailData)
    logger.debug("templateIn[{}] out[{}]".format(templateIn,out))
    return(out)

for thisAccount in  (config.sections()):
    logger.debug("Opening initial connection for IMAP account [{}]".format(thisAccount))
    imapConnect(thisAccount)


accountLoopCounter = 0
while accountLoopCounter != maxAccountLoopCounter:
    logger.debug("ACCOUNT LOOP ctr[{}] max[{}]".format(accountLoopCounter,maxAccountLoopCounter))
    for thisAccount in  (config.sections()):
        thisImap = imap[thisAccount]
        thisFolder = config[thisAccount].get('FOLDER','INBOX')
        if config[thisAccount].get('FLAG',''):
            logger.debug("FLAG [{}] config defined on [{}] account entry, checking".format(config[thisAccount].get('FLAG',''), thisAccount))
            if os.path.exists(config[thisAccount].get('FLAG','')):
                logger.debug("FLAG [{}] Exists. Running [{}] account entry".format(config[thisAccount].get('FLAG',''), thisAccount))
            else:
                logger.info("FLAG [{}] does not exist. Skipping [{}] account entry".format(config[thisAccount].get('FLAG',''), thisAccount))
                time.sleep(imapCheckSleepSeconds)
                continue

        # attempt checking this account
        try:
            logger.debug("Select folder [{}]".format(thisFolder))
            thisImap.select(thisFolder)
            rc, uidList = thisImap.uid('search',None,'ALL')
            msgQty = len(uidList[0].split())
            logger.debug("IMAP UID Search rc[{}] msgQty[{}]".format(rc,msgQty))
            for msgNum in range(msgQty):
                thisEmailUUID = str(uuid.uuid1())  # Used for saves
                thisItemUid = uidList[0].split()[msgNum]
                if args.clear:
                    if accountLoopCounter == 0:
                        # We are asked to clear the FOLDER on startup
                        logger.info("CLEAR on STARTUP, delete UID [{}]".format(thisItemUid))
                        thisImap.uid('store',thisItemUid,'+FLAGS','\\Deleted')
                        continue
                rc, emailData = thisImap.uid('fetch', thisItemUid, '(RFC822)')
                logger.info("IMAP UID Fetch rc[{}] msgNum[{}] thisItemUid[{}]".format(rc,msgNum,str(thisItemUid.decode('utf-8'))))
                emailRaw = emailData[0][1]
                emailMessage = email.message_from_string(emailRaw.decode('utf-8'))
                emailData = {}
                emailData['BODY'] = ""
                emailData['BODYLINE'] = ""
                emailData['ATTDIR'] = ""    # Assigned firname IF attachments were saved
                emailData['ATTFILES'] = []  # List !!!
                for part in emailMessage.walk():
                    logger.debug("PART WHATSIT: Content-Disposition[{}] get_filename[{}] get_content_type[{}]".format(part.get('Content-Disposition'),part.get_filename(),part.get_content_type()))
                    if part.get('Content-Disposition') is None:
                        if part.get_content_type() == "text/plain": # only text please
                            body = str(part.get_payload(decode=True).decode('utf-8'))
                            logger.debug("Body [\n{}\n]".format(body))
                            emailData['BODY'] += body
                        else:
                            logger.debug("Skipping part. not text/plain")
                            continue
                    else:   # Content-Disposition not None, maybe attachment
                        fileName = part.get_filename()
                        if bool(fileName):
                            if config[thisAccount].get('SAVEATT',None):
                                savedir = os.path.join(config[thisAccount]['SAVEATT'], thisEmailUUID)
                                if not os.path.exists(savedir):
                                    os.mkdir(savedir)
                                filePath = os.path.join(savedir, fileName)
                                if not os.path.isfile(filePath) :
                                    emailData['ATTDIR'] = savedir # This triggers handling in popen/exec
                                    emailData['ATTFILES'].append(filePath) # Special, this is a list!
                                    fp = open(filePath, 'wb')
                                    fp.write(part.get_payload(decode=True))
                                    fp.close()
                # Assign all the data we will pass to actions
                emailData['TO']         = getCleanAddrHeader(emailMessage,"to")
                emailData['FROM']       = getCleanAddrHeader(emailMessage,"from")
                emailData['CC']         = getCleanAddrHeader(emailMessage,"cc")
                emailData['SUBJECT']    = emailMessage['subject']
                emailData['REPLYALL']   = emailData['FROM']
                emailData['BODYLINE']   = " ".join(emailData['BODY'].split()) # convert entire email text into a single space separated line, better for use in ARGS
                if emailData['CC']:
                    emailData['REPLYALL'] = ",".join([emailData['REPLYALL'],emailData['CC']])
                logger.info(emailDataSubst(emailData,"Processing Message: TO[$TO] FROM[$FROM] CC[$CC] SUBJECT[$SUBJECT] REPLYALL[$REPLYALL] BODY[\n$BODY\n]"))
                thisArgs = []
                if config[thisAccount].get('EXEC',None):
                        thisArgs.append(config[thisAccount]['EXEC'])
                        procInput=None
                elif config[thisAccount].get('PIPE',None):
                        thisArgs.append(config[thisAccount]['PIPE'])
                        procInput=str(emailData['BODY'])
                else:
                    bomb("No action specified for [{}]".format(thisAccount))
                logger.info(" === BEGIN ACTION ===")
                if config[thisAccount].get('ARGS',None):
                    # process args into list
                    for line in config[thisAccount]['ARGS'].splitlines():
                        if '$ATTFILES' in line:
                            # special, for attachment file list, append list directly
                            thisArgs.extend(emailData['ATTFILES'])
                            logger.debug("Arg processing: line[{}] to att list [{}]".format(line,",".join(emailData['ATTFILES'])))
                        else:
                            lineOut = emailDataSubst(emailData,line)
                            logger.debug("Arg processing: line[{}] to [{}]".format(line,lineOut))
                            thisArgs.append(lineOut)
                logger.info("FINAL SUBPROCESS: [{}]".format("],[".join(thisArgs)))
                with subprocess.Popen(thisArgs, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True) as proc:
                    try:
                        thisStdout,thisStderr = proc.communicate(input=procInput)
                    except TypeError:
                        thisStdout,thisStderr = proc.communicate(input=procInput.encode("ascii","replace"))
                    logger.info("PROC STDOUT: [{}]".format(thisStdout))
                    logger.info("PROC STDERR: [{}]".format(thisStderr))
                    logger.info("exit rc[{}]".format(proc.returncode))
                    if proc.returncode == 0:
                        logger.info("Processing complete, deleteing message uid [{}]".format(thisItemUid))
                        thisImap.uid('store',thisItemUid,'+FLAGS','\\Deleted')
                    else:
                        logger.info("Processing failed, NOT deleteing message uid [{}]".format(thisItemUid))
            logger.debug("Expunge [{}]".format(thisAccount))
            thisImap.expunge() 
            time.sleep(imapCheckSleepSeconds)
            accountLoopCounter += 1


        #except Exception as error:
        except AssertionError as error:
            # If we run into any issues with this connection we re-try connecting with a delay
            logger.error("IMAP Exception [{}], sleeping {} seconds and reconnecting to {}".format(error,imapErrorSleepSeconds,thisAccount))
            time.sleep(imapErrorSleepSeconds)
            imapConnect(thisAccount)
            #bomb("Unable to select IMAP folder [{}] got error [{}]".format(thisFolder, error))


