Mercurial > dotfiles
comparison unixSoft/bin/smailq @ 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 | |
| children |
comparison
equal
deleted
inserted
replaced
| 438:13c11127a79e | 439:2325dea339ca |
|---|---|
| 1 #!/usr/bin/env python3 | |
| 2 """A mail queue for lightweight SMTP clients (MSAs) like msmtp.""" | |
| 3 | |
| 4 __author__ = "Stefan Huber" | |
| 5 __copyright__ = "Copyright 2013" | |
| 6 | |
| 7 __license__ = "LGPL-3" | |
| 8 __version__ = "1.2" | |
| 9 | |
| 10 | |
| 11 from contextlib import contextmanager | |
| 12 import configparser | |
| 13 import fcntl | |
| 14 import getopt | |
| 15 import os | |
| 16 import pickle | |
| 17 import random | |
| 18 import shlex | |
| 19 import subprocess | |
| 20 import sys | |
| 21 import time | |
| 22 import socket | |
| 23 import syslog | |
| 24 | |
| 25 | |
| 26 verbose = False | |
| 27 quiet = False | |
| 28 | |
| 29 | |
| 30 class Config: | |
| 31 """Configuration read from a config file""" | |
| 32 | |
| 33 class ConfigError(RuntimeError): | |
| 34 """Error when reading config file""" | |
| 35 def __init__(self, value): | |
| 36 self.value = value | |
| 37 self.message = value | |
| 38 | |
| 39 def __init__(self, conffn): | |
| 40 self.logdir = None | |
| 41 self.datadir = None | |
| 42 self.nwtesthost = None | |
| 43 self.nwtestport = None | |
| 44 self.nwtesttimeout = None | |
| 45 self.msacmd = None | |
| 46 | |
| 47 self.__nwtest = None | |
| 48 | |
| 49 self.__read(conffn) | |
| 50 | |
| 51 def __read(self, conffn): | |
| 52 conf = configparser.RawConfigParser() | |
| 53 conf.read(conffn) | |
| 54 | |
| 55 self.logdir = "~/.smailq/log" | |
| 56 self.datadir = "~/.smailq/data" | |
| 57 self.nwtesthost = "www.google.com" | |
| 58 self.nwtestport = 80 | |
| 59 self.nwtesttimeout = 8 | |
| 60 | |
| 61 self.logdir = conf.get("general", "logdir", fallback=self.logdir) | |
| 62 self.datadir = conf.get("general", "datadir", fallback=self.datadir) | |
| 63 self.nwtesthost = conf.get("nwtest", "host", fallback=self.nwtesthost) | |
| 64 self.nwtestport = conf.getint("nwtest", "port", | |
| 65 fallback=self.nwtestport) | |
| 66 self.nwtesttimeout = conf.getint("nwtest", "timeout", | |
| 67 fallback=self.nwtesttimeout) | |
| 68 | |
| 69 if not conf.has_option("msa", "cmd"): | |
| 70 raise Config.ConfigError("Section 'msa' contains no 'cmd' option.") | |
| 71 self.msacmd = conf.get("msa", "cmd") | |
| 72 | |
| 73 def getdatadir(self): | |
| 74 """Returns the directory for the mail data""" | |
| 75 return os.path.expanduser(self.datadir) | |
| 76 | |
| 77 def getlogdir(self): | |
| 78 """Returns the directory for the log data""" | |
| 79 return os.path.expanduser(self.logdir) | |
| 80 | |
| 81 def getlockfn(self): | |
| 82 """Get a lock filename of the data directory""" | |
| 83 return self.getdatadir() + "/.lock" | |
| 84 | |
| 85 def getmailfn(self, id): | |
| 86 return self.getdatadir() + "/" + id + ".eml" | |
| 87 | |
| 88 def getmsaargsfn(self, id): | |
| 89 return self.getdatadir() + "/" + id + ".msaargs" | |
| 90 | |
| 91 @contextmanager | |
| 92 def aquiredatalock(self): | |
| 93 """Get a lock on the data directory""" | |
| 94 fn = self.getlockfn() | |
| 95 | |
| 96 # If lock file exists, wait until it disappears | |
| 97 while os.path.exists(fn): | |
| 98 time.sleep(0.05) | |
| 99 | |
| 100 # Use lockf to get exclusive access to file | |
| 101 fp = open(fn, 'w') | |
| 102 fcntl.lockf(fp, fcntl.LOCK_EX) | |
| 103 try: | |
| 104 yield | |
| 105 finally: | |
| 106 fcntl.lockf(fp, fcntl.LOCK_UN) | |
| 107 fp.close() | |
| 108 os.remove(self.getlockfn()) | |
| 109 | |
| 110 def networktest(self): | |
| 111 """Test if we have connection to the internet.""" | |
| 112 | |
| 113 if self.__nwtest is None: | |
| 114 self.__nwtest = False | |
| 115 try: | |
| 116 host = (self.nwtesthost, self.nwtestport) | |
| 117 to = self.nwtesttimeout | |
| 118 with socket.create_connection(host, timeout=to): | |
| 119 self.__nwtest = True | |
| 120 except OSError as e: | |
| 121 pass | |
| 122 except Exception as e: | |
| 123 printerr(e) | |
| 124 | |
| 125 return self.__nwtest | |
| 126 | |
| 127 | |
| 128 class MailQueue: | |
| 129 | |
| 130 def __init__(self, conf): | |
| 131 self.conf = conf | |
| 132 self.__mailids = None | |
| 133 | |
| 134 def get_mail_ids(self): | |
| 135 """Return a list of all mail IDs""" | |
| 136 | |
| 137 # Get mail and msaargs files in datadir | |
| 138 listdir = os.listdir(self.conf.getdatadir()) | |
| 139 mailfiles = [f for f in listdir if f.endswith(".eml")] | |
| 140 msaargsfiles = [f for f in listdir if f.endswith(".msaargs")] | |
| 141 | |
| 142 # Strip of file endings | |
| 143 mailfiles = [f[:-4] for f in mailfiles] | |
| 144 msaargsfiles = [f[:-8] for f in msaargsfiles] | |
| 145 | |
| 146 # Check if symmetric difference is zero | |
| 147 for f in set(mailfiles) - set(msaargsfiles): | |
| 148 printerr("For ID %s an eml file but no msaargs file exists." % f) | |
| 149 for f in set(msaargsfiles) - set(mailfiles): | |
| 150 printerr("For ID %s a msaargs file but no eml file exists." % f) | |
| 151 | |
| 152 # Get mail IDs | |
| 153 return set(mailfiles) & set(msaargsfiles) | |
| 154 | |
| 155 def getmailinfo(self, id): | |
| 156 """Get some properties of mail with given ID""" | |
| 157 assert(id in self.get_mail_ids()) | |
| 158 | |
| 159 mailfn = self.conf.getmailfn(id) | |
| 160 | |
| 161 info = {} | |
| 162 info['ctime'] = time.ctime(os.path.getctime(mailfn)) | |
| 163 info['size'] = os.path.getsize(mailfn) | |
| 164 info['to'] = "" | |
| 165 info['subject'] = "" | |
| 166 | |
| 167 with open(mailfn, "rb") as f: | |
| 168 mail = f.read().decode('utf8', 'replace').splitlines() | |
| 169 | |
| 170 for l in mail: | |
| 171 if l.startswith("Subject:"): | |
| 172 info['subject'] = l[8:].strip() | |
| 173 break | |
| 174 | |
| 175 for l in mail: | |
| 176 if l.startswith("To:"): | |
| 177 info['to'] = l[3:].strip() | |
| 178 break | |
| 179 if l.startswith("Cc:"): | |
| 180 info['to'] = l[3:].strip() | |
| 181 | |
| 182 return info | |
| 183 | |
| 184 def printmailinfo(self, id): | |
| 185 """Print some info on the mail with given ID""" | |
| 186 | |
| 187 print("ID %s:" % id) | |
| 188 | |
| 189 if not id in self.get_mail_ids(): | |
| 190 printerr("ID %s is not in the queue!" % id) | |
| 191 return | |
| 192 | |
| 193 info = self.getmailinfo(id) | |
| 194 | |
| 195 print(" Time: %s" % info['ctime']) | |
| 196 print(" Size: %s Bytes" % info['size']) | |
| 197 print(" To: %s" % info['to']) | |
| 198 print(" Subject: %s" % info['subject']) | |
| 199 | |
| 200 def listqueue(self): | |
| 201 """Print a list of mails in the mail queue""" | |
| 202 | |
| 203 ids = self.get_mail_ids() | |
| 204 print("%d mails in the queue.\n" % len(ids)) | |
| 205 for id in ids: | |
| 206 self.printmailinfo(id) | |
| 207 print() | |
| 208 | |
| 209 def deletemail(self, id): | |
| 210 """Attempt to deliver mail with given ID""" | |
| 211 printinfo("Removing mail with ID " + id) | |
| 212 | |
| 213 if not id in self.get_mail_ids(): | |
| 214 printerr("ID %s is not in the queue!" % id) | |
| 215 return | |
| 216 | |
| 217 os.remove(conf.getmailfn(id)) | |
| 218 os.remove(conf.getmsaargsfn(id)) | |
| 219 log(conf, "Removed from queue.", id=id) | |
| 220 | |
| 221 def delivermail(self, id): | |
| 222 """Attempt to deliver mail with given ID""" | |
| 223 printinfo("Deliver mail with ID " + id) | |
| 224 | |
| 225 if not id in self.get_mail_ids(): | |
| 226 printerr("ID %s is not in the queue!" % id) | |
| 227 return | |
| 228 | |
| 229 if not self.conf.networktest(): | |
| 230 printinfo("Network down. Do not deliver mail.") | |
| 231 return | |
| 232 | |
| 233 info = self.getmailinfo(id) | |
| 234 log(conf, "Attempting to deliver mail. To=%s" % info['to'], id=id) | |
| 235 | |
| 236 # Read the mail | |
| 237 mailfn = self.conf.getmailfn(id) | |
| 238 mailf = open(mailfn, "rb") | |
| 239 | |
| 240 # Read the options | |
| 241 msaargsfn = self.conf.getmsaargsfn(id) | |
| 242 msaargs = None | |
| 243 with open(msaargsfn, "rb") as f: | |
| 244 msaargs = pickle.load(f) | |
| 245 | |
| 246 # Build argv for the MSA | |
| 247 msacmd = self.conf.msacmd | |
| 248 msaargv = shlex.split(msacmd) | |
| 249 msaargv += msaargs | |
| 250 | |
| 251 # Call the MSA and give it the mail | |
| 252 printinfo("Calling " + " ".join([shlex.quote(m) for m in msaargv])) | |
| 253 ret = subprocess.call(msaargv, stdin=mailf) | |
| 254 | |
| 255 if ret == 0: | |
| 256 log(conf, "Delivery successful.", id=id) | |
| 257 self.deletemail(id) | |
| 258 else: | |
| 259 log(conf, "Delivery failed with exit code %d." % ret, id=id) | |
| 260 | |
| 261 def delivermails(self): | |
| 262 """Attempt to deliver all mails in the mail queue""" | |
| 263 printinfo("Deliver mails in the queue.") | |
| 264 | |
| 265 if not self.conf.networktest(): | |
| 266 printinfo("Network down. Do not deliver mails.") | |
| 267 return | |
| 268 | |
| 269 for id in self.get_mail_ids(): | |
| 270 self.delivermail(id) | |
| 271 | |
| 272 def enqueuemail(self, mail, msaargs): | |
| 273 """Insert the given mail into the mail queue""" | |
| 274 # Creeate a new ID | |
| 275 id = None | |
| 276 while True: | |
| 277 nibbles = 8 | |
| 278 id = hex(random.getrandbits(4*nibbles))[2:].upper() | |
| 279 while len(id) < nibbles: | |
| 280 id = '0' + id | |
| 281 if not os.path.exists(self.conf.getmailfn(id)): | |
| 282 break | |
| 283 | |
| 284 log(conf, "Insert into queue.", id=id) | |
| 285 | |
| 286 # Write the mail | |
| 287 mailfn = self.conf.getmailfn(id) | |
| 288 with open(mailfn, "wb") as f: | |
| 289 f.write(mail) | |
| 290 | |
| 291 # Write the options | |
| 292 msaargsfn = self.conf.getmsaargsfn(id) | |
| 293 with open(msaargsfn, "wb") as f: | |
| 294 pickle.dump(msaargs, f) | |
| 295 | |
| 296 return id | |
| 297 | |
| 298 def sendmail(self, mail, msaargs): | |
| 299 """Insert a mail in the mail queue, and attempt to deliver mails""" | |
| 300 self.enqueuemail(mail, msaargs) | |
| 301 self.delivermails() | |
| 302 | |
| 303 | |
| 304 def log(conf, msg, id=None): | |
| 305 """Write message to log file""" | |
| 306 # Prepend ID to msg | |
| 307 if id is not None: | |
| 308 msg = ("ID %s: " % id) + msg | |
| 309 | |
| 310 if conf.getlogdir() == 'syslog': | |
| 311 syslog.syslog(msg) | |
| 312 return | |
| 313 | |
| 314 fn = conf.getlogdir() + "/smailq.log" | |
| 315 | |
| 316 with open(fn, 'a') as f: | |
| 317 fcntl.lockf(f, fcntl.LOCK_EX) | |
| 318 | |
| 319 # Prepend time to msg | |
| 320 msg = time.strftime("%Y-%m-%d %H:%M:%S: ", time.localtime()) + msg | |
| 321 | |
| 322 # Write msg line | |
| 323 f.write(msg + "\n") | |
| 324 if not quiet: | |
| 325 print(msg) | |
| 326 | |
| 327 fcntl.lockf(f, fcntl.LOCK_UN) | |
| 328 | |
| 329 | |
| 330 def printerr(msg): | |
| 331 """Print an error message""" | |
| 332 print(msg, file=sys.stderr) | |
| 333 | |
| 334 | |
| 335 def printinfo(msg): | |
| 336 if verbose: | |
| 337 print(msg) | |
| 338 | |
| 339 | |
| 340 def version(): | |
| 341 """Show version info""" | |
| 342 | |
| 343 print("smailq " + __version__) | |
| 344 print("Copyright (C) 2013 Stefan Huber") | |
| 345 | |
| 346 | |
| 347 def usage(): | |
| 348 """Print usage text of this program""" | |
| 349 | |
| 350 print(""" | |
| 351 smailq is a mail queue for lightweight SMTP clients (MSAs) like msmtp that do | |
| 352 not provide a queue. It basically provides the functionality of sendmail and | |
| 353 mailq. | |
| 354 | |
| 355 USAGE: | |
| 356 | |
| 357 {0} --send [recipient ...] -- [MSA options ...] | |
| 358 {0} --list | |
| 359 {0} --deliver-all | |
| 360 {0} --deliver [ID ...] | |
| 361 {0} --delete [ID ...] | |
| 362 {0} --help | |
| 363 {0} --version | |
| 364 | |
| 365 COMMANDS: | |
| 366 | |
| 367 --delete | |
| 368 Remove the mails with given IDs from the queue. | |
| 369 | |
| 370 --deliver | |
| 371 Attempt to deliver the mails with given IDs only. | |
| 372 | |
| 373 --deliver-all | |
| 374 Attempt to deliver all mails in the queue. | |
| 375 | |
| 376 -h, --help | |
| 377 Print this usage text. | |
| 378 | |
| 379 --list | |
| 380 List all mails in the queue. This is the default | |
| 381 | |
| 382 --send | |
| 383 Read a mail from stdin, insert it into the queue, and attempt to | |
| 384 deliver all mails in the queue. Options after "--" are passed forward | |
| 385 to the MSA for this particular mail. | |
| 386 | |
| 387 -V, --version | |
| 388 Show version info. | |
| 389 | |
| 390 OPTIONS: | |
| 391 | |
| 392 -C, --config=FILE | |
| 393 Use the given configuration file. | |
| 394 | |
| 395 -q, --quiet | |
| 396 Do not print info messages. | |
| 397 | |
| 398 -v, --verbose | |
| 399 Increase output verbosity. | |
| 400 """.format(sys.argv[0])) | |
| 401 | |
| 402 | |
| 403 if __name__ == "__main__": | |
| 404 | |
| 405 conffn_list = [os.path.expanduser("~/.smailq.conf"), "/etc/smailq.conf"] | |
| 406 cmd = "--list" | |
| 407 nooptargs = [] | |
| 408 | |
| 409 try: | |
| 410 | |
| 411 longopts = ["config=", "delete", "deliver-all", "deliver", "help", | |
| 412 "list", "send", "verbose", "version", "quiet"] | |
| 413 opts, nooptargs = getopt.gnu_getopt(sys.argv[1:], "hC:vVq", longopts) | |
| 414 | |
| 415 for opt, arg in opts: | |
| 416 if opt in ['-h', '--help']: | |
| 417 usage() | |
| 418 sys.exit(os.EX_OK) | |
| 419 elif opt in ['-V', '--version']: | |
| 420 version() | |
| 421 sys.exit(os.EX_OK) | |
| 422 elif opt in ['--list', '--send', '--delete', '--deliver-all', | |
| 423 '--deliver']: | |
| 424 cmd = opt | |
| 425 elif opt in ['-C', '--config']: | |
| 426 conffn_list = [arg] | |
| 427 elif opt in ['-v', '--verbose']: | |
| 428 verbose = True | |
| 429 quiet = False | |
| 430 elif opt in ['-q', '--quiet']: | |
| 431 quiet = True | |
| 432 verbose = False | |
| 433 else: | |
| 434 assert(False) | |
| 435 | |
| 436 except getopt.GetoptError as e: | |
| 437 printerr("Error parsing arguments: " + str(e)) | |
| 438 usage() | |
| 439 sys.exit(os.EX_USAGE) | |
| 440 | |
| 441 # Reading config file | |
| 442 conffn = next((f for f in conffn_list if os.path.isfile(f)), None) | |
| 443 if conffn is None: | |
| 444 printerr("No config file found: " + str(conffn_list)) | |
| 445 sys.exit(os.EX_IOERR) | |
| 446 conf = None | |
| 447 try: | |
| 448 conf = Config(conffn) | |
| 449 | |
| 450 if not os.path.isdir(conf.getdatadir()): | |
| 451 printerr("Data directory does not exist: " + conf.getdatadir()) | |
| 452 sys.exit(os.EX_IOERR) | |
| 453 | |
| 454 if conf.getlogdir() == 'syslog': | |
| 455 syslog.openlog('smailq', 0, syslog.LOG_MAIL) | |
| 456 elif not os.path.isdir(conf.getlogdir()): | |
| 457 printinfo('Creating logdir: ' + conf.getlogdir()) | |
| 458 os.mkdir(conf.getlogdir()) | |
| 459 | |
| 460 except Exception as e: | |
| 461 printerr("Error reading config file: " + str(e)) | |
| 462 sys.exit(os.EX_IOERR) | |
| 463 | |
| 464 try: | |
| 465 with conf.aquiredatalock(): | |
| 466 | |
| 467 printinfo("Aquired the lock.") | |
| 468 | |
| 469 mq = MailQueue(conf) | |
| 470 if cmd == "--send": | |
| 471 mail = sys.stdin.buffer.read() | |
| 472 mq.sendmail(mail, nooptargs) | |
| 473 elif cmd == "--list": | |
| 474 mq.listqueue() | |
| 475 elif cmd == "--deliver-all": | |
| 476 mq.delivermails() | |
| 477 elif cmd == "--deliver": | |
| 478 for id in nooptargs: | |
| 479 mq.delivermail(id) | |
| 480 elif cmd == "--delete": | |
| 481 for id in nooptargs: | |
| 482 mq.deletemail(id) | |
| 483 | |
| 484 except OSError as e: | |
| 485 printerr(e) | |
| 486 sys.exit(os.EX_IOERR) |
