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