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
)
164 with
open(mailfn
, "r") as f
:
168 if l
.startswith("Subject:"):
169 info
['subject'] = l
[8:].strip()
173 if l
.startswith("To:"):
174 info
['to'] = l
[3:].strip()
179 def printmailinfo(self
, id):
180 """Print some info on the mail with given ID"""
184 if not id in self
.get_mail_ids():
185 printerr("ID %s is not in the queue!" % id)
188 info
= self
.getmailinfo(id)
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'])
196 """Print a list of mails in the mail queue"""
198 ids
= self
.get_mail_ids()
199 print("%d mails in the queue.\n" % len(ids
))
201 self
.printmailinfo(id)
204 def deletemail(self
, id):
205 """Attempt to deliver mail with given ID"""
206 printinfo("Removing mail with ID " + id)
208 if not id in self
.get_mail_ids():
209 printerr("ID %s is not in the queue!" % id)
212 os
.remove(conf
.getmailfn(id))
213 os
.remove(conf
.getmsaargsfn(id))
214 log(conf
, "Removed from queue.", id=id)
216 def delivermail(self
, id):
217 """Attempt to deliver mail with given ID"""
218 printinfo("Deliver mail with ID " + id)
220 if not id in self
.get_mail_ids():
221 printerr("ID %s is not in the queue!" % id)
224 if not self
.conf
.networktest():
225 printinfo("Network down. Do not deliver mail.")
228 info
= self
.getmailinfo(id)
229 log(conf
, "Attempting to deliver mail. To=%s" % info
['to'], id=id)
232 mailfn
= self
.conf
.getmailfn(id)
233 mailf
= open(mailfn
, "r")
236 msaargsfn
= self
.conf
.getmsaargsfn(id)
238 with
open(msaargsfn
, "rb") as f
:
239 msaargs
= pickle
.load(f
)
241 # Build argv for the MSA
242 msacmd
= self
.conf
.msacmd
243 msaargv
= shlex
.split(msacmd
)
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
)
251 log(conf
, "Delivery successful.", id=id)
254 log(conf
, "Delivery failed with exit code %d." % ret
, id=id)
256 def delivermails(self
):
257 """Attempt to deliver all mails in the mail queue"""
258 printinfo("Deliver mails in the queue.")
260 if not self
.conf
.networktest():
261 printinfo("Network down. Do not deliver mails.")
264 for id in self
.get_mail_ids():
267 def enqueuemail(self
, mail
, msaargs
):
268 """Insert the given mail into the mail queue"""
273 id = hex(random
.getrandbits(4*nibbles
))[2:].upper()
274 while len(id) < nibbles
:
276 if not os
.path
.exists(self
.conf
.getmailfn(id)):
279 log(conf
, "Insert into queue.", id=id)
282 mailfn
= self
.conf
.getmailfn(id)
283 with
open(mailfn
, "w") as f
:
287 msaargsfn
= self
.conf
.getmsaargsfn(id)
288 with
open(msaargsfn
, "wb") as f
:
289 pickle
.dump(msaargs
, f
)
293 def sendmail(self
, mail
, msaargs
):
294 """Insert a mail in the mail queue, and attempt to deliver mails"""
295 self
.enqueuemail(mail
, msaargs
)
299 def log(conf
, msg
, id=None):
300 """Write message to log file"""
301 fn
= conf
.getlogdir() + "/smailq.log"
303 with
open(fn
, 'a') as f
:
304 fcntl
.lockf(f
, fcntl
.LOCK_EX
)
307 msg
= ("ID %s: " % id) + msg
308 # Prepend time to msg
309 msg
= time
.strftime("%Y-%m-%d %H:%M:%S: ", time
.localtime()) + msg
316 fcntl
.lockf(f
, fcntl
.LOCK_UN
)
320 """Print an error message"""
321 print(msg
, file=sys
.stderr
)
330 """Show version info"""
332 print("smailq " + __version__
)
333 print("Copyright (C) 2013 Stefan Huber")
337 """Print usage text of this program"""
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
346 {0} --send [recipient ...] -- [MSA options ...]
349 {0} --deliver [ID ...]
350 {0} --delete [ID ...]
357 Remove the mails with given IDs from the queue.
360 Attempt to deliver the mails with given IDs only.
363 Attempt to deliver all mails in the queue.
366 Print this usage text.
369 List all mails in the queue. This is the default
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.
382 Use the given configuration file instead of "$HOME/.smailq.conf".
385 Do not print info messages.
388 Increase output verbosity.
389 """.format(sys
.argv
[0]))
392 if __name__
== "__main__":
394 conffn
= os
.path
.expanduser("~/.smailq.conf")
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
)
404 for opt
, arg
in opts
:
405 if opt
in ['-h', '--help']:
408 elif opt
in ['-V', '--version']:
411 elif opt
in ['--list', '--send', '--delete', '--deliver-all',
414 elif opt
in ['-C', '--config']:
416 elif opt
in ['-v', '--verbose']:
419 elif opt
in ['-q', '--quiet']:
425 except getopt
.GetoptError
as e
:
426 printerr("Error parsing arguments:", e
)
428 sys
.exit(os
.EX_USAGE
)
430 # Reading config file
431 if not os
.path
.isfile(conffn
):
432 printerr("No such config file:", conffn
)
433 sys
.exit(os
.EX_IOERR
)
436 conf
= Config(conffn
)
438 if not os
.path
.isdir(conf
.getdatadir()):
439 printerr("Data directory does not exist: " + conf
.getdatadir())
440 sys
.exit(os
.EX_IOERR
)
442 if not os
.path
.isdir(conf
.getlogdir()):
443 printerr("Log directory does not exist: " + conf
.getlogdir())
444 sys
.exit(os
.EX_IOERR
)
446 except Exception as e
:
447 printerr("Error reading config file:", e
)
448 sys
.exit(os
.EX_IOERR
)
451 with conf
.aquiredatalock():
453 printinfo("Aquired the lock.")
457 mail
= sys
.stdin
.read()
458 mq
.sendmail(mail
, nooptargs
)
459 elif cmd
== "--list":
461 elif cmd
== "--deliver-all":
463 elif cmd
== "--deliver":
466 elif cmd
== "--delete":
472 sys
.exit(os
.EX_IOERR
)