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) |