smailq: Look for global configuration under /etc
[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 info['to'] = ""
164 info['subject'] = ""
165
166 with open(mailfn, "rb") as f:
167 mail = f.read().decode('utf8', 'replace').splitlines()
168
169 for l in mail:
170 if l.startswith("Subject:"):
171 info['subject'] = l[8:].strip()
172 break
173
174 for l in mail:
175 if l.startswith("To:"):
176 info['to'] = l[3:].strip()
177 break
178 if l.startswith("Cc:"):
179 info['to'] = l[3:].strip()
180
181 return info
182
183 def printmailinfo(self, id):
184 """Print some info on the mail with given ID"""
185
186 print("ID %s:" % id)
187
188 if not id in self.get_mail_ids():
189 printerr("ID %s is not in the queue!" % id)
190 return
191
192 info = self.getmailinfo(id)
193
194 print(" Time: %s" % info['ctime'])
195 print(" Size: %s Bytes" % info['size'])
196 print(" To: %s" % info['to'])
197 print(" Subject: %s" % info['subject'])
198
199 def listqueue(self):
200 """Print a list of mails in the mail queue"""
201
202 ids = self.get_mail_ids()
203 print("%d mails in the queue.\n" % len(ids))
204 for id in ids:
205 self.printmailinfo(id)
206 print()
207
208 def deletemail(self, id):
209 """Attempt to deliver mail with given ID"""
210 printinfo("Removing mail with ID " + id)
211
212 if not id in self.get_mail_ids():
213 printerr("ID %s is not in the queue!" % id)
214 return
215
216 os.remove(conf.getmailfn(id))
217 os.remove(conf.getmsaargsfn(id))
218 log(conf, "Removed from queue.", id=id)
219
220 def delivermail(self, id):
221 """Attempt to deliver mail with given ID"""
222 printinfo("Deliver mail with ID " + id)
223
224 if not id in self.get_mail_ids():
225 printerr("ID %s is not in the queue!" % id)
226 return
227
228 if not self.conf.networktest():
229 printinfo("Network down. Do not deliver mail.")
230 return
231
232 info = self.getmailinfo(id)
233 log(conf, "Attempting to deliver mail. To=%s" % info['to'], id=id)
234
235 # Read the mail
236 mailfn = self.conf.getmailfn(id)
237 mailf = open(mailfn, "rb")
238
239 # Read the options
240 msaargsfn = self.conf.getmsaargsfn(id)
241 msaargs = None
242 with open(msaargsfn, "rb") as f:
243 msaargs = pickle.load(f)
244
245 # Build argv for the MSA
246 msacmd = self.conf.msacmd
247 msaargv = shlex.split(msacmd)
248 msaargv += msaargs
249
250 # Call the MSA and give it the mail
251 printinfo("Calling " + " ".join([shlex.quote(m) for m in msaargv]))
252 ret = subprocess.call(msaargv, stdin=mailf)
253
254 if ret == 0:
255 log(conf, "Delivery successful.", id=id)
256 self.deletemail(id)
257 else:
258 log(conf, "Delivery failed with exit code %d." % ret, id=id)
259
260 def delivermails(self):
261 """Attempt to deliver all mails in the mail queue"""
262 printinfo("Deliver mails in the queue.")
263
264 if not self.conf.networktest():
265 printinfo("Network down. Do not deliver mails.")
266 return
267
268 for id in self.get_mail_ids():
269 self.delivermail(id)
270
271 def enqueuemail(self, mail, msaargs):
272 """Insert the given mail into the mail queue"""
273 # Creeate a new ID
274 id = None
275 while True:
276 nibbles = 8
277 id = hex(random.getrandbits(4*nibbles))[2:].upper()
278 while len(id) < nibbles:
279 id = '0' + id
280 if not os.path.exists(self.conf.getmailfn(id)):
281 break
282
283 log(conf, "Insert into queue.", id=id)
284
285 # Write the mail
286 mailfn = self.conf.getmailfn(id)
287 with open(mailfn, "wb") as f:
288 f.write(mail)
289
290 # Write the options
291 msaargsfn = self.conf.getmsaargsfn(id)
292 with open(msaargsfn, "wb") as f:
293 pickle.dump(msaargs, f)
294
295 return id
296
297 def sendmail(self, mail, msaargs):
298 """Insert a mail in the mail queue, and attempt to deliver mails"""
299 self.enqueuemail(mail, msaargs)
300 self.delivermails()
301
302
303 def log(conf, msg, id=None):
304 """Write message to log file"""
305 fn = conf.getlogdir() + "/smailq.log"
306
307 with open(fn, 'a') as f:
308 fcntl.lockf(f, fcntl.LOCK_EX)
309 # Prepend ID to msg
310 if id is not None:
311 msg = ("ID %s: " % id) + msg
312 # Prepend time to msg
313 msg = time.strftime("%Y-%m-%d %H:%M:%S: ", time.localtime()) + msg
314
315 # Write msg line
316 f.write(msg + "\n")
317 if not quiet:
318 print(msg)
319
320 fcntl.lockf(f, fcntl.LOCK_UN)
321
322
323 def printerr(msg):
324 """Print an error message"""
325 print(msg, file=sys.stderr)
326
327
328 def printinfo(msg):
329 if verbose:
330 print(msg)
331
332
333 def version():
334 """Show version info"""
335
336 print("smailq " + __version__)
337 print("Copyright (C) 2013 Stefan Huber")
338
339
340 def usage():
341 """Print usage text of this program"""
342
343 print("""
344 smailq is a mail queue for lightweight SMTP clients (MSAs) like msmtp that do
345 not provide a queue. It basically provides the functionality of sendmail and
346 mailq.
347
348 USAGE:
349
350 {0} --send [recipient ...] -- [MSA options ...]
351 {0} --list
352 {0} --deliver-all
353 {0} --deliver [ID ...]
354 {0} --delete [ID ...]
355 {0} --help
356 {0} --version
357
358 COMMANDS:
359
360 --delete
361 Remove the mails with given IDs from the queue.
362
363 --deliver
364 Attempt to deliver the mails with given IDs only.
365
366 --deliver-all
367 Attempt to deliver all mails in the queue.
368
369 -h, --help
370 Print this usage text.
371
372 --list
373 List all mails in the queue. This is the default
374
375 --send
376 Read a mail from stdin, insert it into the queue, and attempt to
377 deliver all mails in the queue. Options after "--" are passed forward
378 to the MSA for this particular mail.
379
380 -V, --version
381 Show version info.
382
383 OPTIONS:
384
385 -C, --config=FILE
386 Use the given configuration file instead of "$HOME/.smailq.conf".
387
388 -q, --quiet
389 Do not print info messages.
390
391 -v, --verbose
392 Increase output verbosity.
393 """.format(sys.argv[0]))
394
395
396 if __name__ == "__main__":
397
398 conffn_list = [os.path.expanduser("~/.smailq.conf"), "/etc/smailq.conf"]
399 cmd = "--list"
400 nooptargs = []
401
402 try:
403
404 longopts = ["config=", "delete", "deliver-all", "deliver", "help",
405 "list", "send", "verbose", "version", "quiet"]
406 opts, nooptargs = getopt.gnu_getopt(sys.argv[1:], "hC:vVq", longopts)
407
408 for opt, arg in opts:
409 if opt in ['-h', '--help']:
410 usage()
411 sys.exit(os.EX_OK)
412 elif opt in ['-V', '--version']:
413 version()
414 sys.exit(os.EX_OK)
415 elif opt in ['--list', '--send', '--delete', '--deliver-all',
416 '--deliver']:
417 cmd = opt
418 elif opt in ['-C', '--config']:
419 conffn_list = [arg]
420 elif opt in ['-v', '--verbose']:
421 verbose = True
422 quiet = False
423 elif opt in ['-q', '--quiet']:
424 quiet = True
425 verbose = False
426 else:
427 assert(False)
428
429 except getopt.GetoptError as e:
430 printerr("Error parsing arguments:", e)
431 usage()
432 sys.exit(os.EX_USAGE)
433
434 # Reading config file
435 conffn = next((f for f in conffn_list if os.path.isfile(f)), None)
436 if conffn is None:
437 printerr("No config file found: " + str(conffn_list))
438 sys.exit(os.EX_IOERR)
439 conf = None
440 try:
441 conf = Config(conffn)
442
443 if not os.path.isdir(conf.getdatadir()):
444 printerr("Data directory does not exist: " + conf.getdatadir())
445 sys.exit(os.EX_IOERR)
446
447 if not os.path.isdir(conf.getlogdir()):
448 printerr("Log directory does not exist: " + conf.getlogdir())
449 sys.exit(os.EX_IOERR)
450
451 except Exception as e:
452 printerr("Error reading config file:", e)
453 sys.exit(os.EX_IOERR)
454
455 try:
456 with conf.aquiredatalock():
457
458 printinfo("Aquired the lock.")
459
460 mq = MailQueue(conf)
461 if cmd == "--send":
462 mail = sys.stdin.buffer.read()
463 mq.sendmail(mail, nooptargs)
464 elif cmd == "--list":
465 mq.listqueue()
466 elif cmd == "--deliver-all":
467 mq.delivermails()
468 elif cmd == "--deliver":
469 for id in nooptargs:
470 mq.delivermail(id)
471 elif cmd == "--delete":
472 for id in nooptargs:
473 mq.deletemail(id)
474
475 except OSError as e:
476 printerr(e)
477 sys.exit(os.EX_IOERR)