view unixSoft/bin/smailq @ 513:ad9ca178e094

hg: add crevert alias for curses-revert
author Augie Fackler <raf@durin42.com>
date Thu, 03 Jun 2021 14:25:21 -0400
parents 2325dea339ca
children
line wrap: on
line source

#!/usr/bin/env python3
"""A mail queue for lightweight SMTP clients (MSAs) like msmtp."""

__author__ = "Stefan Huber"
__copyright__ = "Copyright 2013"

__license__ = "LGPL-3"
__version__ = "1.2"


from contextlib import contextmanager
import configparser
import fcntl
import getopt
import os
import pickle
import random
import shlex
import subprocess
import sys
import time
import socket
import syslog


verbose = False
quiet = False


class Config:
    """Configuration read from a config file"""

    class ConfigError(RuntimeError):
        """Error when reading config file"""
        def __init__(self, value):
            self.value = value
            self.message = value

    def __init__(self, conffn):
        self.logdir = None
        self.datadir = None
        self.nwtesthost = None
        self.nwtestport = None
        self.nwtesttimeout = None
        self.msacmd = None

        self.__nwtest = None

        self.__read(conffn)

    def __read(self, conffn):
        conf = configparser.RawConfigParser()
        conf.read(conffn)

        self.logdir = "~/.smailq/log"
        self.datadir = "~/.smailq/data"
        self.nwtesthost = "www.google.com"
        self.nwtestport = 80
        self.nwtesttimeout = 8

        self.logdir = conf.get("general", "logdir", fallback=self.logdir)
        self.datadir = conf.get("general", "datadir", fallback=self.datadir)
        self.nwtesthost = conf.get("nwtest", "host", fallback=self.nwtesthost)
        self.nwtestport = conf.getint("nwtest", "port",
                                      fallback=self.nwtestport)
        self.nwtesttimeout = conf.getint("nwtest", "timeout",
                                         fallback=self.nwtesttimeout)

        if not conf.has_option("msa", "cmd"):
            raise Config.ConfigError("Section 'msa' contains no 'cmd' option.")
        self.msacmd = conf.get("msa", "cmd")

    def getdatadir(self):
        """Returns the directory for the mail data"""
        return os.path.expanduser(self.datadir)

    def getlogdir(self):
        """Returns the directory for the log data"""
        return os.path.expanduser(self.logdir)

    def getlockfn(self):
        """Get a lock filename of the data directory"""
        return self.getdatadir() + "/.lock"

    def getmailfn(self, id):
        return self.getdatadir() + "/" + id + ".eml"

    def getmsaargsfn(self, id):
        return self.getdatadir() + "/" + id + ".msaargs"

    @contextmanager
    def aquiredatalock(self):
        """Get a lock on the data directory"""
        fn = self.getlockfn()

        # If lock file exists, wait until it disappears
        while os.path.exists(fn):
            time.sleep(0.05)

        # Use lockf to get exclusive access to file
        fp = open(fn, 'w')
        fcntl.lockf(fp, fcntl.LOCK_EX)
        try:
            yield
        finally:
            fcntl.lockf(fp, fcntl.LOCK_UN)
            fp.close()
            os.remove(self.getlockfn())

    def networktest(self):
        """Test if we have connection to the internet."""

        if self.__nwtest is None:
            self.__nwtest = False
            try:
                host = (self.nwtesthost, self.nwtestport)
                to = self.nwtesttimeout
                with socket.create_connection(host, timeout=to):
                    self.__nwtest = True
            except OSError as e:
                pass
            except Exception as e:
                printerr(e)

        return self.__nwtest


class MailQueue:

    def __init__(self, conf):
        self.conf = conf
        self.__mailids = None

    def get_mail_ids(self):
        """Return a list of all mail IDs"""

        # Get mail and msaargs files in datadir
        listdir = os.listdir(self.conf.getdatadir())
        mailfiles = [f for f in listdir if f.endswith(".eml")]
        msaargsfiles = [f for f in listdir if f.endswith(".msaargs")]

        # Strip of file endings
        mailfiles = [f[:-4] for f in mailfiles]
        msaargsfiles = [f[:-8] for f in msaargsfiles]

        # Check if symmetric difference is zero
        for f in set(mailfiles) - set(msaargsfiles):
            printerr("For ID %s an eml file but no msaargs file exists." % f)
        for f in set(msaargsfiles) - set(mailfiles):
            printerr("For ID %s a msaargs file but no eml file exists." % f)

        # Get mail IDs
        return set(mailfiles) & set(msaargsfiles)

    def getmailinfo(self, id):
        """Get some properties of mail with given ID"""
        assert(id in self.get_mail_ids())

        mailfn = self.conf.getmailfn(id)

        info = {}
        info['ctime'] = time.ctime(os.path.getctime(mailfn))
        info['size'] = os.path.getsize(mailfn)
        info['to'] = ""
        info['subject'] = ""

        with open(mailfn, "rb") as f:
            mail = f.read().decode('utf8', 'replace').splitlines()

            for l in mail:
                if l.startswith("Subject:"):
                    info['subject'] = l[8:].strip()
                    break

            for l in mail:
                if l.startswith("To:"):
                    info['to'] = l[3:].strip()
                    break
                if l.startswith("Cc:"):
                    info['to'] = l[3:].strip()

        return info

    def printmailinfo(self, id):
        """Print some info on the mail with given ID"""

        print("ID %s:" % id)

        if not id in self.get_mail_ids():
            printerr("ID %s is not in the queue!" % id)
            return

        info = self.getmailinfo(id)

        print("  Time: %s" % info['ctime'])
        print("  Size: %s Bytes" % info['size'])
        print("  To: %s" % info['to'])
        print("  Subject: %s" % info['subject'])

    def listqueue(self):
        """Print a list of mails in the mail queue"""

        ids = self.get_mail_ids()
        print("%d mails in the queue.\n" % len(ids))
        for id in ids:
            self.printmailinfo(id)
            print()

    def deletemail(self, id):
        """Attempt to deliver mail with given ID"""
        printinfo("Removing mail with ID " + id)

        if not id in self.get_mail_ids():
            printerr("ID %s is not in the queue!" % id)
            return

        os.remove(conf.getmailfn(id))
        os.remove(conf.getmsaargsfn(id))
        log(conf, "Removed from queue.", id=id)

    def delivermail(self, id):
        """Attempt to deliver mail with given ID"""
        printinfo("Deliver mail with ID " + id)

        if not id in self.get_mail_ids():
            printerr("ID %s is not in the queue!" % id)
            return

        if not self.conf.networktest():
            printinfo("Network down. Do not deliver mail.")
            return

        info = self.getmailinfo(id)
        log(conf, "Attempting to deliver mail. To=%s" % info['to'], id=id)

        # Read the mail
        mailfn = self.conf.getmailfn(id)
        mailf = open(mailfn, "rb")

        # Read the options
        msaargsfn = self.conf.getmsaargsfn(id)
        msaargs = None
        with open(msaargsfn, "rb") as f:
            msaargs = pickle.load(f)

        # Build argv for the MSA
        msacmd = self.conf.msacmd
        msaargv = shlex.split(msacmd)
        msaargv += msaargs

        # Call the MSA and give it the mail
        printinfo("Calling " + " ".join([shlex.quote(m) for m in msaargv]))
        ret = subprocess.call(msaargv, stdin=mailf)

        if ret == 0:
            log(conf, "Delivery successful.", id=id)
            self.deletemail(id)
        else:
            log(conf, "Delivery failed with exit code %d." % ret, id=id)

    def delivermails(self):
        """Attempt to deliver all mails in the mail queue"""
        printinfo("Deliver mails in the queue.")

        if not self.conf.networktest():
            printinfo("Network down. Do not deliver mails.")
            return

        for id in self.get_mail_ids():
            self.delivermail(id)

    def enqueuemail(self, mail, msaargs):
        """Insert the given mail into the mail queue"""
        # Creeate a new ID
        id = None
        while True:
            nibbles = 8
            id = hex(random.getrandbits(4*nibbles))[2:].upper()
            while len(id) < nibbles:
                id = '0' + id
            if not os.path.exists(self.conf.getmailfn(id)):
                break

        log(conf, "Insert into queue.", id=id)

        # Write the mail
        mailfn = self.conf.getmailfn(id)
        with open(mailfn, "wb") as f:
            f.write(mail)

        # Write the options
        msaargsfn = self.conf.getmsaargsfn(id)
        with open(msaargsfn, "wb") as f:
            pickle.dump(msaargs, f)

        return id

    def sendmail(self, mail, msaargs):
        """Insert a mail in the mail queue, and attempt to deliver mails"""
        self.enqueuemail(mail, msaargs)
        self.delivermails()


def log(conf, msg, id=None):
    """Write message to log file"""
    # Prepend ID to msg
    if id is not None:
        msg = ("ID %s: " % id) + msg

    if conf.getlogdir() == 'syslog':
        syslog.syslog(msg)
        return

    fn = conf.getlogdir() + "/smailq.log"

    with open(fn, 'a') as f:
        fcntl.lockf(f, fcntl.LOCK_EX)

        # Prepend time to msg
        msg = time.strftime("%Y-%m-%d %H:%M:%S: ", time.localtime()) + msg

        # Write msg line
        f.write(msg + "\n")
        if not quiet:
            print(msg)

        fcntl.lockf(f, fcntl.LOCK_UN)


def printerr(msg):
    """Print an error message"""
    print(msg, file=sys.stderr)


def printinfo(msg):
    if verbose:
        print(msg)


def version():
    """Show version info"""

    print("smailq " + __version__)
    print("Copyright (C) 2013 Stefan Huber")


def usage():
    """Print usage text of this program"""

    print("""
smailq is a mail queue for lightweight SMTP clients (MSAs) like msmtp that do
not provide a queue. It basically provides the functionality of sendmail and
mailq.

USAGE:

  {0} --send [recipient ...] -- [MSA options ...]
  {0} --list
  {0} --deliver-all
  {0} --deliver [ID ...]
  {0} --delete [ID ...]
  {0} --help
  {0} --version

COMMANDS:

      --delete
          Remove the mails with given IDs from the queue.

      --deliver
          Attempt to deliver the mails with given IDs only.

      --deliver-all
          Attempt to deliver all mails in the queue.

  -h, --help
          Print this usage text.

      --list
          List all mails in the queue. This is the default

      --send
          Read a mail from stdin, insert it into the queue, and attempt to
          deliver all mails in the queue. Options after "--" are passed forward
          to the MSA for this particular mail.

  -V, --version
          Show version info.

OPTIONS:

  -C, --config=FILE
          Use the given configuration file.

  -q, --quiet
          Do not print info messages.

  -v, --verbose
          Increase output verbosity.
""".format(sys.argv[0]))


if __name__ == "__main__":

    conffn_list = [os.path.expanduser("~/.smailq.conf"), "/etc/smailq.conf"]
    cmd = "--list"
    nooptargs = []

    try:

        longopts = ["config=", "delete", "deliver-all", "deliver", "help",
                    "list", "send", "verbose", "version", "quiet"]
        opts, nooptargs = getopt.gnu_getopt(sys.argv[1:], "hC:vVq", longopts)

        for opt, arg in opts:
            if opt in ['-h', '--help']:
                usage()
                sys.exit(os.EX_OK)
            elif opt in ['-V', '--version']:
                version()
                sys.exit(os.EX_OK)
            elif opt in ['--list', '--send', '--delete', '--deliver-all',
                         '--deliver']:
                cmd = opt
            elif opt in ['-C', '--config']:
                conffn_list = [arg]
            elif opt in ['-v', '--verbose']:
                verbose = True
                quiet = False
            elif opt in ['-q', '--quiet']:
                quiet = True
                verbose = False
            else:
                assert(False)

    except getopt.GetoptError as e:
        printerr("Error parsing arguments: " + str(e))
        usage()
        sys.exit(os.EX_USAGE)

    # Reading config file
    conffn = next((f for f in conffn_list if os.path.isfile(f)), None)
    if conffn is None:
        printerr("No config file found: " + str(conffn_list))
        sys.exit(os.EX_IOERR)
    conf = None
    try:
        conf = Config(conffn)

        if not os.path.isdir(conf.getdatadir()):
            printerr("Data directory does not exist: " + conf.getdatadir())
            sys.exit(os.EX_IOERR)

        if conf.getlogdir() == 'syslog':
            syslog.openlog('smailq', 0, syslog.LOG_MAIL)
        elif not os.path.isdir(conf.getlogdir()):
            printinfo('Creating logdir: ' + conf.getlogdir())
            os.mkdir(conf.getlogdir())

    except Exception as e:
        printerr("Error reading config file: " + str(e))
        sys.exit(os.EX_IOERR)

    try:
        with conf.aquiredatalock():

            printinfo("Aquired the lock.")

            mq = MailQueue(conf)
            if cmd == "--send":
                mail = sys.stdin.buffer.read()
                mq.sendmail(mail, nooptargs)
            elif cmd == "--list":
                mq.listqueue()
            elif cmd == "--deliver-all":
                mq.delivermails()
            elif cmd == "--deliver":
                for id in nooptargs:
                    mq.delivermail(id)
            elif cmd == "--delete":
                for id in nooptargs:
                    mq.deletemail(id)

    except OSError as e:
        printerr(e)
        sys.exit(os.EX_IOERR)