changeset 439:2325dea339ca

smailq: vendor script to ease handling outgoing mail From commit 5b83ca873f1dc9117a9b3590f0aa07fe2806fce9 of http://git.sthu.org/repos/smailq.git - documented at https://www.sthu.org/code/smailq.html.
author Augie Fackler <raf@durin42.com>
date Sat, 15 Jul 2017 12:57:17 -0400
parents 13c11127a79e
children d706030e7c90
files unixSoft/bin/smailq
diffstat 1 files changed, 486 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
new file mode 100644
--- /dev/null
+++ b/unixSoft/bin/smailq
@@ -0,0 +1,486 @@
+#!/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)