]>
git.sthu.org Git - smailq.git/blob - smailq
2 """A mail queue for lightweight SMTP clients (MSAs) like msmtp."""
4 __author__
= "Stefan Huber"
5 __copyright__
= "Copyright 2013"
11 from contextlib
import contextmanager
31 """Configuration read from a config file"""
33 class ConfigError(RuntimeError):
34 """Error when reading config file"""
35 def __init__(self
, value
):
39 def __init__(self
, conffn
):
42 self
.nwtesthost
= None
43 self
.nwtestport
= None
44 self
.nwtesttimeout
= None
51 def __read(self
, conffn
):
52 conf
= configparser
.RawConfigParser()
55 self
.logdir
= "~/.smailq/log"
56 self
.datadir
= "~/.smailq/data"
57 self
.nwtesthost
= "www.google.com"
59 self
.nwtesttimeout
= 8
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
)
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")
74 """Returns the directory for the mail data"""
75 return os
.path
.expanduser(self
.datadir
)
78 """Returns the directory for the log data"""
79 return os
.path
.expanduser(self
.logdir
)
82 """Get a lock filename of the data directory"""
83 return self
.getdatadir() + "/.lock"
85 def getmailfn(self
, id):
86 return self
.getdatadir() + "/" + id + ".eml"
88 def getmsaargsfn(self
, id):
89 return self
.getdatadir() + "/" + id + ".msaargs"
92 def aquiredatalock(self
):
93 """Get a lock on the data directory"""
96 # If lock file exists, wait until it disappears
97 while os
.path
.exists(fn
):
100 # Use lockf to get exclusive access to file
102 fcntl
.lockf(fp
, fcntl
.LOCK_EX
)
106 fcntl
.lockf(fp
, fcntl
.LOCK_UN
)
108 os
.remove(self
.getlockfn())
110 def networktest(self
):
111 """Test if we have connection to the internet."""
113 if self
.__nwtest
is None:
114 self
.__nwtest
= False
116 host
= (self
.nwtesthost
, self
.nwtestport
)
117 to
= self
.nwtesttimeout
118 with socket
.create_connection(host
, timeout
=to
):
122 except Exception as e
:
130 def __init__(self
, conf
):
132 self
.__mailids
= None
134 def get_mail_ids(self
):
135 """Return a list of all mail IDs"""
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")]
142 # Strip of file endings
143 mailfiles
= [f
[:-4] for f
in mailfiles
]
144 msaargsfiles
= [f
[:-8] for f
in msaargsfiles
]
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
)
153 return set(mailfiles
) & set(msaargsfiles
)
155 def getmailinfo(self
, id):
156 """Get some properties of mail with given ID"""
157 assert(id in self
.get_mail_ids())
159 mailfn
= self
.conf
.getmailfn(id)
162 info
['ctime'] = time
.ctime(os
.path
.getctime(mailfn
))
163 info
['size'] = os
.path
.getsize(mailfn
)
167 with
open(mailfn
, "rb") as f
:
168 mail
= f
.read().decode('utf8', 'replace').splitlines()
171 if l
.startswith("Subject:"):
172 info
['subject'] = l
[8:].strip()
176 if l
.startswith("To:"):
177 info
['to'] = l
[3:].strip()
179 if l
.startswith("Cc:"):
180 info
['to'] = l
[3:].strip()
184 def printmailinfo(self
, id):
185 """Print some info on the mail with given ID"""
189 if not id in self
.get_mail_ids():
190 printerr("ID %s is not in the queue!" % id)
193 info
= self
.getmailinfo(id)
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'])
201 """Print a list of mails in the mail queue"""
203 ids
= self
.get_mail_ids()
204 print("%d mails in the queue.\n" % len(ids
))
206 self
.printmailinfo(id)
209 def deletemail(self
, id):
210 """Attempt to deliver mail with given ID"""
211 printinfo("Removing mail with ID " + id)
213 if not id in self
.get_mail_ids():
214 printerr("ID %s is not in the queue!" % id)
217 os
.remove(conf
.getmailfn(id))
218 os
.remove(conf
.getmsaargsfn(id))
219 log(conf
, "Removed from queue.", id=id)
221 def delivermail(self
, id):
222 """Attempt to deliver mail with given ID"""
223 printinfo("Deliver mail with ID " + id)
225 if not id in self
.get_mail_ids():
226 printerr("ID %s is not in the queue!" % id)
229 if not self
.conf
.networktest():
230 printinfo("Network down. Do not deliver mail.")
233 info
= self
.getmailinfo(id)
234 log(conf
, "Attempting to deliver mail. To=%s" % info
['to'], id=id)
237 mailfn
= self
.conf
.getmailfn(id)
238 mailf
= open(mailfn
, "rb")
241 msaargsfn
= self
.conf
.getmsaargsfn(id)
243 with
open(msaargsfn
, "rb") as f
:
244 msaargs
= pickle
.load(f
)
246 # Build argv for the MSA
247 msacmd
= self
.conf
.msacmd
248 msaargv
= shlex
.split(msacmd
)
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
)
256 log(conf
, "Delivery successful.", id=id)
259 log(conf
, "Delivery failed with exit code %d." % ret
, id=id)
261 def delivermails(self
):
262 """Attempt to deliver all mails in the mail queue"""
263 printinfo("Deliver mails in the queue.")
265 if not self
.conf
.networktest():
266 printinfo("Network down. Do not deliver mails.")
269 for id in self
.get_mail_ids():
272 def enqueuemail(self
, mail
, msaargs
):
273 """Insert the given mail into the mail queue"""
278 id = hex(random
.getrandbits(4*nibbles
))[2:].upper()
279 while len(id) < nibbles
:
281 if not os
.path
.exists(self
.conf
.getmailfn(id)):
284 log(conf
, "Insert into queue.", id=id)
287 mailfn
= self
.conf
.getmailfn(id)
288 with
open(mailfn
, "wb") as f
:
292 msaargsfn
= self
.conf
.getmsaargsfn(id)
293 with
open(msaargsfn
, "wb") as f
:
294 pickle
.dump(msaargs
, f
)
298 def sendmail(self
, mail
, msaargs
):
299 """Insert a mail in the mail queue, and attempt to deliver mails"""
300 self
.enqueuemail(mail
, msaargs
)
304 def log(conf
, msg
, id=None):
305 """Write message to log file"""
308 msg
= ("ID %s: " % id) + msg
310 if conf
.getlogdir() == 'syslog':
314 fn
= conf
.getlogdir() + "/smailq.log"
316 with
open(fn
, 'a') as f
:
317 fcntl
.lockf(f
, fcntl
.LOCK_EX
)
319 # Prepend time to msg
320 msg
= time
.strftime("%Y-%m-%d %H:%M:%S: ", time
.localtime()) + msg
327 fcntl
.lockf(f
, fcntl
.LOCK_UN
)
331 """Print an error message"""
332 print(msg
, file=sys
.stderr
)
341 """Show version info"""
343 print("smailq " + __version__
)
344 print("Copyright (C) 2013 Stefan Huber")
348 """Print usage text of this program"""
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
357 {0} --send [recipient ...] -- [MSA options ...]
360 {0} --deliver [ID ...]
361 {0} --delete [ID ...]
368 Remove the mails with given IDs from the queue.
371 Attempt to deliver the mails with given IDs only.
374 Attempt to deliver all mails in the queue.
377 Print this usage text.
380 List all mails in the queue. This is the default
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.
393 Use the given configuration file instead of "$HOME/.smailq.conf".
396 Do not print info messages.
399 Increase output verbosity.
400 """.format(sys
.argv
[0]))
403 if __name__
== "__main__":
405 conffn_list
= [os
.path
.expanduser("~/.smailq.conf"), "/etc/smailq.conf"]
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
)
415 for opt
, arg
in opts
:
416 if opt
in ['-h', '--help']:
419 elif opt
in ['-V', '--version']:
422 elif opt
in ['--list', '--send', '--delete', '--deliver-all',
425 elif opt
in ['-C', '--config']:
427 elif opt
in ['-v', '--verbose']:
430 elif opt
in ['-q', '--quiet']:
436 except getopt
.GetoptError
as e
:
437 printerr("Error parsing arguments: " + str(e
))
439 sys
.exit(os
.EX_USAGE
)
441 # Reading config file
442 conffn
= next((f
for f
in conffn_list
if os
.path
.isfile(f
)), None)
444 printerr("No config file found: " + str(conffn_list
))
445 sys
.exit(os
.EX_IOERR
)
448 conf
= Config(conffn
)
450 if not os
.path
.isdir(conf
.getdatadir()):
451 printerr("Data directory does not exist: " + conf
.getdatadir())
452 sys
.exit(os
.EX_IOERR
)
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())
460 except Exception as e
:
461 printerr("Error reading config file: " + str(e
))
462 sys
.exit(os
.EX_IOERR
)
465 with conf
.aquiredatalock():
467 printinfo("Aquired the lock.")
471 mail
= sys
.stdin
.buffer.read()
472 mq
.sendmail(mail
, nooptargs
)
473 elif cmd
== "--list":
475 elif cmd
== "--deliver-all":
477 elif cmd
== "--deliver":
480 elif cmd
== "--delete":
486 sys
.exit(os
.EX_IOERR
)