2 """A mail queue for lightweight SMTP clients (MSAs) like msmtp."""
4 __author__
= "Stefan Huber"
5 __copyright__
= "Copyright 2013"
11 from contextlib
import contextmanager
30 """Configuration read from a config file"""
32 class ConfigError(RuntimeError):
33 """Error when reading config file"""
34 def __init__(self
, value
):
38 def __init__(self
, conffn
):
41 self
.nwtesthost
= None
42 self
.nwtestport
= None
43 self
.nwtesttimeout
= None
50 def __read(self
, conffn
):
51 conf
= configparser
.RawConfigParser()
54 self
.logdir
= "~/.smailq/log"
55 self
.datadir
= "~/.smailq/data"
56 self
.nwtesthost
= "www.google.com"
58 self
.nwtesttimeout
= 8
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
)
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")
73 """Returns the directory for the mail data"""
74 return os
.path
.expanduser(self
.datadir
)
77 """Returns the directory for the log data"""
78 return os
.path
.expanduser(self
.logdir
)
81 """Get a lock filename of the data directory"""
82 return self
.getdatadir() + "/.lock"
84 def getmailfn(self
, id):
85 return self
.getdatadir() + "/" + id + ".eml"
87 def getmsaargsfn(self
, id):
88 return self
.getdatadir() + "/" + id + ".msaargs"
91 def aquiredatalock(self
):
92 """Get a lock on the data directory"""
95 # If lock file exists, wait until it disappears
96 while os
.path
.exists(fn
):
99 # Use lockf to get exclusive access to file
101 fcntl
.lockf(fp
, fcntl
.LOCK_EX
)
105 fcntl
.lockf(fp
, fcntl
.LOCK_UN
)
107 os
.remove(self
.getlockfn())
109 def networktest(self
):
110 """Test if we have connection to the internet."""
112 if self
.__nwtest
is None:
113 self
.__nwtest
= False
115 host
= (self
.nwtesthost
, self
.nwtestport
)
116 to
= self
.nwtesttimeout
117 with socket
.create_connection(host
, timeout
=to
):
121 except Exception as e
:
129 def __init__(self
, conf
):
131 self
.__mailids
= None
133 def get_mail_ids(self
):
134 """Return a list of all mail IDs"""
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")]
141 # Strip of file endings
142 mailfiles
= [f
[:-4] for f
in mailfiles
]
143 msaargsfiles
= [f
[:-8] for f
in msaargsfiles
]
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
)
152 return set(mailfiles
) & set(msaargsfiles
)
154 def getmailinfo(self
, id):
155 """Get some properties of mail with given ID"""
156 assert(id in self
.get_mail_ids())
158 mailfn
= self
.conf
.getmailfn(id)
161 info
['ctime'] = time
.ctime(os
.path
.getctime(mailfn
))
162 info
['size'] = os
.path
.getsize(mailfn
)
166 with
open(mailfn
, "r") as f
:
170 if l
.startswith("Subject:"):
171 info
['subject'] = l
[8:].strip()
175 if l
.startswith("To:"):
176 info
['to'] = l
[3:].strip()
178 if l
.startswith("Cc:"):
179 info
['to'] = l
[3:].strip()
183 def printmailinfo(self
, id):
184 """Print some info on the mail with given ID"""
188 if not id in self
.get_mail_ids():
189 printerr("ID %s is not in the queue!" % id)
192 info
= self
.getmailinfo(id)
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'])
200 """Print a list of mails in the mail queue"""
202 ids
= self
.get_mail_ids()
203 print("%d mails in the queue.\n" % len(ids
))
205 self
.printmailinfo(id)
208 def deletemail(self
, id):
209 """Attempt to deliver mail with given ID"""
210 printinfo("Removing mail with ID " + id)
212 if not id in self
.get_mail_ids():
213 printerr("ID %s is not in the queue!" % id)
216 os
.remove(conf
.getmailfn(id))
217 os
.remove(conf
.getmsaargsfn(id))
218 log(conf
, "Removed from queue.", id=id)
220 def delivermail(self
, id):
221 """Attempt to deliver mail with given ID"""
222 printinfo("Deliver mail with ID " + id)
224 if not id in self
.get_mail_ids():
225 printerr("ID %s is not in the queue!" % id)
228 if not self
.conf
.networktest():
229 printinfo("Network down. Do not deliver mail.")
232 info
= self
.getmailinfo(id)
233 log(conf
, "Attempting to deliver mail. To=%s" % info
['to'], id=id)
236 mailfn
= self
.conf
.getmailfn(id)
237 mailf
= open(mailfn
, "r")
240 msaargsfn
= self
.conf
.getmsaargsfn(id)
242 with
open(msaargsfn
, "rb") as f
:
243 msaargs
= pickle
.load(f
)
245 # Build argv for the MSA
246 msacmd
= self
.conf
.msacmd
247 msaargv
= shlex
.split(msacmd
)
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
)
255 log(conf
, "Delivery successful.", id=id)
258 log(conf
, "Delivery failed with exit code %d." % ret
, id=id)
260 def delivermails(self
):
261 """Attempt to deliver all mails in the mail queue"""
262 printinfo("Deliver mails in the queue.")
264 if not self
.conf
.networktest():
265 printinfo("Network down. Do not deliver mails.")
268 for id in self
.get_mail_ids():
271 def enqueuemail(self
, mail
, msaargs
):
272 """Insert the given mail into the mail queue"""
277 id = hex(random
.getrandbits(4*nibbles
))[2:].upper()
278 while len(id) < nibbles
:
280 if not os
.path
.exists(self
.conf
.getmailfn(id)):
283 log(conf
, "Insert into queue.", id=id)
286 mailfn
= self
.conf
.getmailfn(id)
287 with
open(mailfn
, "w") as f
:
291 msaargsfn
= self
.conf
.getmsaargsfn(id)
292 with
open(msaargsfn
, "wb") as f
:
293 pickle
.dump(msaargs
, f
)
297 def sendmail(self
, mail
, msaargs
):
298 """Insert a mail in the mail queue, and attempt to deliver mails"""
299 self
.enqueuemail(mail
, msaargs
)
303 def log(conf
, msg
, id=None):
304 """Write message to log file"""
305 fn
= conf
.getlogdir() + "/smailq.log"
307 with
open(fn
, 'a') as f
:
308 fcntl
.lockf(f
, fcntl
.LOCK_EX
)
311 msg
= ("ID %s: " % id) + msg
312 # Prepend time to msg
313 msg
= time
.strftime("%Y-%m-%d %H:%M:%S: ", time
.localtime()) + msg
320 fcntl
.lockf(f
, fcntl
.LOCK_UN
)
324 """Print an error message"""
325 print(msg
, file=sys
.stderr
)
334 """Show version info"""
336 print("smailq " + __version__
)
337 print("Copyright (C) 2013 Stefan Huber")
341 """Print usage text of this program"""
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
350 {0} --send [recipient ...] -- [MSA options ...]
353 {0} --deliver [ID ...]
354 {0} --delete [ID ...]
361 Remove the mails with given IDs from the queue.
364 Attempt to deliver the mails with given IDs only.
367 Attempt to deliver all mails in the queue.
370 Print this usage text.
373 List all mails in the queue. This is the default
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.
386 Use the given configuration file instead of "$HOME/.smailq.conf".
389 Do not print info messages.
392 Increase output verbosity.
393 """.format(sys
.argv
[0]))
396 if __name__
== "__main__":
398 conffn
= os
.path
.expanduser("~/.smailq.conf")
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
)
408 for opt
, arg
in opts
:
409 if opt
in ['-h', '--help']:
412 elif opt
in ['-V', '--version']:
415 elif opt
in ['--list', '--send', '--delete', '--deliver-all',
418 elif opt
in ['-C', '--config']:
420 elif opt
in ['-v', '--verbose']:
423 elif opt
in ['-q', '--quiet']:
429 except getopt
.GetoptError
as e
:
430 printerr("Error parsing arguments:", e
)
432 sys
.exit(os
.EX_USAGE
)
434 # Reading config file
435 if not os
.path
.isfile(conffn
):
436 printerr("No such config file:", conffn
)
437 sys
.exit(os
.EX_IOERR
)
440 conf
= Config(conffn
)
442 if not os
.path
.isdir(conf
.getdatadir()):
443 printerr("Data directory does not exist: " + conf
.getdatadir())
444 sys
.exit(os
.EX_IOERR
)
446 if not os
.path
.isdir(conf
.getlogdir()):
447 printerr("Log directory does not exist: " + conf
.getlogdir())
448 sys
.exit(os
.EX_IOERR
)
450 except Exception as e
:
451 printerr("Error reading config file:", e
)
452 sys
.exit(os
.EX_IOERR
)
455 with conf
.aquiredatalock():
457 printinfo("Aquired the lock.")
461 mail
= sys
.stdin
.read()
462 mq
.sendmail(mail
, nooptargs
)
463 elif cmd
== "--list":
465 elif cmd
== "--deliver-all":
467 elif cmd
== "--deliver":
470 elif cmd
== "--delete":
476 sys
.exit(os
.EX_IOERR
)