smailq: Update help on reading conf file
[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 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)