Merged subject prefix patch from Christine Spang.
[eoc.git] / eoc.py
1 """Mailing list manager.
2
3 This is a simple mailing list manager that mimicks the ezmlm-idx mail
4 address commands. See manual page for more information.
5 """
6
7 VERSION = "1.2.6"
8 PLUGIN_INTERFACE_VERSION = "1"
9
10 import getopt
11 import hashlib
12 import os
13 import shutil
14 import smtplib
15 import string
16 import sys
17 import time
18 import ConfigParser
19 try:
20     import email.Header
21     have_email_module = 1
22 except ImportError:
23     have_email_module = 0
24 import imp
25
26 import qmqp
27
28
29 # The following values will be overriden by "make install".
30 TEMPLATE_DIRS = ["./templates"]
31 DOTDIR = "dot-eoc"
32
33
34 class EocException(Exception):
35
36     def __init__(self, arg=None):
37         self.msg = repr(arg)
38
39     def __str__(self):
40         return self.msg
41
42 class UnknownList(EocException):
43     def __init__(self, list_name):
44         self.msg = "%s is not a known mailing list" % list_name
45
46 class BadCommandAddress(EocException):
47     def __init__(self, address):
48         self.msg = "%s is not a valid command address" % address
49
50 class BadSignature(EocException):
51     def __init__(self, address):
52         self.msg = "address %s has an invalid digital signature" % address
53
54 class ListExists(EocException):
55     def __init__(self, list_name):
56         self.msg = "Mailing list %s alreadys exists" % list_name
57
58 class ListDoesNotExist(EocException):
59     def __init__(self, list_name):
60         self.msg = "Mailing list %s does not exist" % list_name
61
62 class MissingEnvironmentVariable(EocException):
63     def __init__(self, name):
64         self.msg = "Environment variable %s does not exist" % name
65
66 class MissingTemplate(EocException):
67     def __init__(self, template):
68         self.msg = "Template %s does not exit" % template
69
70
71 # Names of commands EoC recognizes in e-mail addresses.
72 SIMPLE_COMMANDS = ["help", "list", "owner", "setlist", "setlistsilently", "ignore"]
73 SUB_COMMANDS = ["subscribe", "unsubscribe"]
74 HASH_COMMANDS = ["subyes", "subapprove", "subreject", "unsubyes",
75                  "bounce", "probe", "approve", "reject", "setlistyes",
76                  "setlistsilentyes"]
77 COMMANDS = SIMPLE_COMMANDS + SUB_COMMANDS + HASH_COMMANDS
78
79
80 def md5sum_as_hex(s):
81     return hashlib.md5(s).hexdigest()
82
83 def forkexec(argv, text):
84     """Run a command (given as argv array) and write text to its stdin"""
85     (r, w) = os.pipe()
86     pid = os.fork()
87     if pid == -1:
88         raise Exception("fork failed")
89     elif pid == 0:
90         os.dup2(r, 0)
91         os.close(r)
92         os.close(w)
93         fd = os.open("/dev/null", os.O_RDWR)
94         os.dup2(fd, 1)
95         os.dup2(fd, 2)
96         os.execvp(argv[0], argv)
97         sys.exit(1)
98     else:
99         os.close(r)
100         os.write(w, text)
101         os.close(w)
102         (pid2, exit) = os.waitpid(pid, 0)
103         if pid != pid2:
104             raise Exception("os.waitpid for %d returned for %d" % (pid, pid2))
105         if exit != 0:
106             raise Exception("subprocess failed, exit=0x%x" % exit)
107         return exit
108
109
110 environ = None
111
112 def set_environ(new_environ):
113     global environ
114     environ = new_environ
115
116 def get_from_environ(key):
117     global environ
118     if environ:
119         env = environ
120     else:
121         env = os.environ
122     if env.has_key(key):
123         return env[key].lower()
124     raise MissingEnvironmentVariable(key)
125
126 class AddressParser:
127
128     """A parser for incoming e-mail addresses."""
129
130     def __init__(self, lists):
131         self.set_lists(lists)
132         self.set_skip_prefix(None)
133         self.set_forced_domain(None)
134
135     def set_lists(self, lists):
136         """Set the list of canonical list names we should know about."""
137         self.lists = lists
138
139     def set_skip_prefix(self, skip_prefix):
140         """Set the prefix to be removed from an address."""
141         self.skip_prefix = skip_prefix
142         
143     def set_forced_domain(self, forced_domain):
144         """Set the domain part we should force the address to have."""
145         self.forced_domain = forced_domain
146
147     def clean(self, address):
148         """Remove cruft from the address and convert the rest to lower case."""
149         if self.skip_prefix:
150             n = self.skip_prefix and len(self.skip_prefix)
151             if address[:n] == self.skip_prefix:
152                 address = address[n:]
153         if self.forced_domain:
154             parts = address.split("@", 1)
155             address = "%s@%s" % (parts[0], self.forced_domain)
156         return address.lower()
157
158     def split_address(self, address):
159         """Split an address to a local part and a domain."""
160         parts = address.lower().split("@", 1)
161         if len(parts) != 2:
162             return (address, "")
163         else:
164             return parts
165
166     # Does an address refer to a list? If not, return None, else return a list
167     # of additional parts (separated by hyphens) in the address. Note that []
168     # is not the same as None.
169     
170     def additional_address_parts(self, address, listname):
171         addr_local, addr_domain = self.split_address(address)
172         list_local, list_domain = self.split_address(listname)
173         
174         if addr_domain != list_domain:
175             return None
176         
177         if addr_local.lower() == list_local.lower():
178             return []
179         
180         n = len(list_local)
181         if addr_local[:n] != list_local or addr_local[n] != "-":
182             return None
183             
184         return addr_local[n+1:].split("-")
185         
186
187     # Parse an address we have received that identifies a list we manage.
188     # The address may contain command and signature parts. Return the name
189     # of the list, and a sequence of the additional parts (split at hyphens).
190     # Raise exceptions for errors. Note that the command will be valid, but
191     # cryptographic signatures in the address is not checked.
192     
193     def parse(self, address):
194         address = self.clean(address)
195         for listname in self.lists:
196             parts = self.additional_address_parts(address, listname)
197             if parts == None:
198                 pass
199             elif parts == []:
200                 return listname, parts
201             elif parts[0] in HASH_COMMANDS:
202                 if len(parts) != 3:
203                     raise BadCommandAddress(address)
204                 return listname, parts
205             elif parts[0] in COMMANDS:
206                 return listname, parts
207
208         raise UnknownList(address)
209
210
211 class MailingListManager:
212
213     def __init__(self, dotdir, sendmail="/usr/sbin/sendmail", lists=[],
214                  smtp_server=None, qmqp_server=None):
215         self.dotdir = dotdir
216         self.sendmail = sendmail
217         self.smtp_server = smtp_server
218         self.qmqp_server = qmqp_server
219
220         self.make_dotdir()
221         self.secret = self.make_and_read_secret()
222
223         if not lists:
224             lists = filter(lambda s: "@" in s, os.listdir(dotdir))
225         self.set_lists(lists)
226
227         self.simple_commands = ["help", "list", "owner", "setlist",
228                                 "setlistsilently", "ignore"]
229         self.sub_commands = ["subscribe", "unsubscribe"]
230         self.hash_commands = ["subyes", "subapprove", "subreject", "unsubyes",
231                               "bounce", "probe", "approve", "reject",
232                               "setlistyes", "setlistsilentyes"]
233         self.commands = self.simple_commands + self.sub_commands + \
234                         self.hash_commands
235
236         self.environ = None
237
238         self.load_plugins()
239         
240     # Create the dot directory for us, if it doesn't exist already.
241     def make_dotdir(self):
242         if not os.path.isdir(self.dotdir):
243             os.makedirs(self.dotdir, 0700)
244
245     # Create the "secret" file, with a random value used as cookie for
246     # verification addresses.
247     def make_and_read_secret(self):
248         secret_name = os.path.join(self.dotdir, "secret")
249         if not os.path.isfile(secret_name):
250             f = open("/dev/urandom", "r")
251             secret = f.read(32)
252             f.close()
253             f = open(secret_name, "w")
254             f.write(secret)
255             f.close()
256         else:
257             f = open(secret_name, "r")
258             secret = f.read()
259             f.close()
260         return secret
261
262     # Load the plugins from DOTDIR/plugins/*.py.
263     def load_plugins(self):
264         self.plugins = []
265
266         dirname = os.path.join(DOTDIR, "plugins")
267         try:
268             plugins = os.listdir(dirname)
269         except OSError:
270             return
271             
272         plugins.sort()
273         plugins = map(os.path.splitext, plugins)
274         plugins = filter(lambda p: p[1] == ".py", plugins)
275         plugins = map(lambda p: p[0], plugins)
276         for name in plugins:
277             pathname = os.path.join(dirname, name + ".py")
278             f = open(pathname, "r")
279             module = imp.load_module(name, f, pathname, 
280                                      (".py", "r", imp.PY_SOURCE))
281             f.close()
282             if module.PLUGIN_INTERFACE_VERSION == PLUGIN_INTERFACE_VERSION:
283                 self.plugins.append(module)
284
285     # Call function named funcname (a string) in all plugins, giving as
286     # arguments all the remaining arguments preceded by ml. Return value
287     # of each function is the new list of arguments to the next function.
288     # Return value of this function is the return value of the last function.
289     def call_plugins(self, funcname, list, *args):
290         for plugin in self.plugins:
291             if plugin.__dict__.has_key(funcname):
292                 args = apply(plugin.__dict__[funcname], (list,) + args)
293                 if type(args) != type((0,)):
294                     args = (args,)
295         return args
296
297     # Set the list of listnames. The list of lists needs to be sorted in
298     # length order so that test@example.com is matched before
299     # test-list@example.com
300     def set_lists(self, lists):
301         temp = map(lambda s: (len(s), s), lists)
302         temp.sort()
303         self.lists = map(lambda t: t[1], temp)
304
305     # Return the list of listnames.
306     def get_lists(self):
307         return self.lists
308
309     # Decode an address that has been encoded to be part of a local part.
310     def decode_address(self, parts):
311         return string.join(string.join(parts, "-").split("="), "@")
312
313     # Is local_part@domain an existing list?
314     def is_list_name(self, local_part, domain):
315         return ("%s@%s" % (local_part, domain)) in self.lists
316
317     # Compute the verification checksum for an address.
318     def compute_hash(self, address):
319         return md5sum_as_hex(address + self.secret)
320
321     # Is the verification signature in a parsed address bad? If so, return true,
322     # otherwise return false.
323     def signature_is_bad(self, dict, hash):
324         local_part, domain = dict["name"].split("@")
325         address = "%s-%s-%s@%s" % (local_part, dict["command"], dict["id"], 
326                                    domain)
327         correct = self.compute_hash(address)
328         return correct != hash
329
330     # Parse a command address we have received and check its validity
331     # (including signature, if any). Return a dictionary with keys
332     # "command", "sender" (address that was encoded into address, if
333     # any), "id" (group ID).
334
335     def parse_recipient_address(self, address, skip_prefix, forced_domain):
336         ap = AddressParser(self.get_lists())
337         ap.set_lists(self.get_lists())
338         ap.set_skip_prefix(skip_prefix)
339         ap.set_forced_domain(forced_domain)
340         listname, parts = ap.parse(address)
341
342         dict = { "name": listname }
343
344         if parts == []:
345             dict["command"] = "post"
346         else:
347             command, args = parts[0], parts[1:]
348             dict["command"] = command
349             if command in SUB_COMMANDS:
350                 dict["sender"] = self.decode_address(args)
351             elif command in HASH_COMMANDS:
352                 dict["id"] = args[0]
353                 hash = args[1]
354                 if self.signature_is_bad(dict, hash):
355                     raise BadSignature(address)
356
357         return dict
358
359     # Does an address refer to a mailing list?
360     def is_list(self, name, skip_prefix=None, domain=None):
361         try:
362             self.parse_recipient_address(name, skip_prefix, domain)
363         except BadCommandAddress:
364             return 0
365         except BadSignature:
366             return 0
367         except UnknownList:
368             return 0
369         return 1
370
371     # Create a new list and return it.
372     def create_list(self, name):
373         if self.is_list(name):
374             raise ListExists(name)
375         self.set_lists(self.lists + [name])
376         return MailingList(self, name)
377
378     # Open an existing list.
379     def open_list(self, name):
380         if self.is_list(name):
381             return self.open_list_exact(name)
382         else:
383             x = name + "@"
384             for list in self.lists:
385                 if list[:len(x)] == x:
386                     return self.open_list_exact(list)
387             raise ListDoesNotExist(name)
388
389     def open_list_exact(self, name):
390         for list in self.get_lists():
391             if list.lower() == name.lower():
392                 return MailingList(self, list)
393         raise ListDoesNotExist(name)
394
395     # Process an incoming message.
396     def incoming_message(self, skip_prefix, domain, moderate, post):
397         debug("Processing incoming message.")
398         debug("$SENDER = <%s>" % get_from_environ("SENDER"))
399         debug("$RECIPIENT = <%s>" % get_from_environ("RECIPIENT"))
400         dict = self.parse_recipient_address(get_from_environ("RECIPIENT"),
401                                                              skip_prefix, 
402                                                              domain)
403         dict["force-moderation"] = moderate
404         dict["force-posting"] = post
405         debug("List is <%(name)s>, command is <%(command)s>." % dict)
406         list = self.open_list_exact(dict["name"])
407         list.obey(dict)
408
409     # Clean up bouncing address and do other janitorial work for all lists.
410     def cleaning_woman(self, send_mail=None):
411         now = time.time()
412         for listname in self.lists:
413             list = self.open_list_exact(listname)
414             if send_mail:
415                 list.send_mail = send_mail
416             list.cleaning_woman(now)
417
418     # Send a mail to the desired recipients.
419     def send_mail(self, envelope_sender, recipients, text):
420         debug("send_mail:\n  sender=%s\n  recipients=%s\n  text=\n    %s" % 
421               (envelope_sender, str(recipients), 
422                "\n    ".join(text[:text.find("\n\n")].split("\n"))))
423         if recipients:
424             if self.smtp_server:
425                 try:
426                     smtp = smtplib.SMTP(self.smtp_server)
427                     smtp.sendmail(envelope_sender, recipients, text)
428                     smtp.quit()
429                 except:
430                     error("Error sending SMTP mail, mail probably not sent")
431                     sys.exit(1)
432             elif self.qmqp_server:
433                 try:
434                     q = qmqp.QMQP(self.qmqp_server)
435                     q.sendmail(envelope_sender, recipients, text)
436                     q.quit()
437                 except:
438                     error("Error sending QMQP mail, mail probably not sent")
439                     sys.exit(1)
440             else:
441                 status = forkexec([self.sendmail, "-oi", "-f", 
442                                    envelope_sender] + recipients, text)
443                 if status:
444                     error("%s returned %s, mail sending probably failed" %
445                            (self.sendmail, status))
446                     sys.exit((status >> 8) & 0xff)
447         else:
448             debug("send_mail: no recipients, not sending")
449
450
451
452 class MailingList:
453
454     posting_opts = ["auto", "free", "moderated"]
455
456     def __init__(self, mlm, name):
457         self.mlm = mlm
458         self.name = name
459
460         self.cp = ConfigParser.ConfigParser()
461         self.cp.add_section("list")
462         self.cp.set("list", "owners", "")
463         self.cp.set("list", "moderators", "")
464         self.cp.set("list", "subscription", "free")
465         self.cp.set("list", "posting", "free")
466         self.cp.set("list", "archived", "no")
467         self.cp.set("list", "mail-on-subscription-changes", "no")
468         self.cp.set("list", "mail-on-forced-unsubscribe", "no")
469         self.cp.set("list", "ignore-bounce", "no")
470         self.cp.set("list", "language", "")
471         self.cp.set("list", "pristine-headers", "")
472         self.cp.set("list", "subject-prefix", "")
473
474         self.dirname = os.path.join(self.mlm.dotdir, name)
475         self.make_listdir()
476         self.cp.read(self.mkname("config"))
477
478         self.subscribers = SubscriberDatabase(self.dirname, "subscribers")
479         self.moderation_box = MessageBox(self.dirname, "moderation-box")
480         self.subscription_box = MessageBox(self.dirname, "subscription-box")
481         self.bounce_box = MessageBox(self.dirname, "bounce-box")
482
483     def make_listdir(self):
484         if not os.path.isdir(self.dirname):
485             os.mkdir(self.dirname, 0700)
486             self.save_config()
487             f = open(self.mkname("subscribers"), "w")
488             f.close()
489
490     def mkname(self, relative):
491         return os.path.join(self.dirname, relative)
492
493     def save_config(self):
494         f = open(self.mkname("config"), "w")
495         self.cp.write(f)
496         f.close()
497
498     def read_stdin(self):
499         data = sys.stdin.read()
500         # Convert CRLF to plain LF
501         data = "\n".join(data.split("\r\n"))
502         # Skip Unix mbox "From " mail start indicator
503         if data[:5] == "From ":
504             data = string.split(data, "\n", 1)[1]
505         return data
506
507     def invent_boundary(self):
508         return "%s/%s" % (md5sum_as_hex(str(time.time())),
509                           md5sum_as_hex(self.name))
510
511     def command_address(self, command):
512         local_part, domain = self.name.split("@")
513         return "%s-%s@%s" % (local_part, command, domain)
514
515     def signed_address(self, command, id):
516         unsigned = self.command_address("%s-%s" % (command, id))
517         hash = self.mlm.compute_hash(unsigned)
518         return self.command_address("%s-%s-%s" % (command, id, hash))
519
520     def ignore(self):
521         return self.command_address("ignore")
522
523     def nice_7bit(self, str):
524         for c in str:
525             if (ord(c) < 32 and not c.isspace()) or ord(c) >= 127:
526                 return False
527         return True
528     
529     def mime_encode_headers(self, text):
530         try:
531             headers, body = text.split("\n\n", 1)
532         
533             list = []
534             for line in headers.split("\n"):
535                 if line[0].isspace():
536                     list[-1] += line
537                 else:
538                     list.append(line)
539         
540             headers = []
541             for header in list:
542                 if self.nice_7bit(header):
543                     headers.append(header)
544                 else:
545                     if ": " in header:
546                         name, content = header.split(": ", 1)
547                     else:
548                         name, content = header.split(":", 1)
549                     hdr = email.Header.Header(content, "utf-8")
550                     headers.append(name + ": " + hdr.encode())
551         
552             return "\n".join(headers) + "\n\n" + body
553         except:
554             info("Cannot MIME encode header, using original ones, sorry")
555             return text
556
557     def template(self, template_name, dict):
558         lang = self.cp.get("list", "language")
559         if lang:
560             template_name_lang = template_name + "." + lang
561         else:
562             template_name_lang = template_name
563
564         if not dict.has_key("list"):
565             dict["list"] = self.name
566             dict["local"], dict["domain"] = self.name.split("@")
567         if not dict.has_key("list"):
568             dict["list"] = self.name
569
570         for dir in [os.path.join(self.dirname, "templates")] + TEMPLATE_DIRS:
571             pathname = os.path.join(dir, template_name_lang)
572             if not os.path.exists(pathname):
573                 pathname = os.path.join(dir, template_name)
574             if os.path.exists(pathname):
575                 f = open(pathname, "r")
576                 data = f.read()
577                 f.close()
578                 return data % dict
579
580         raise MissingTemplate(template_name)
581
582     def send_template(self, envelope_sender, sender, recipients,
583                       template_name, dict):
584         dict["From"] = "EoC <%s>" % sender
585         dict["To"] = string.join(recipients, ", ")
586         text = self.template(template_name, dict)
587         if not text:
588             return
589         if self.cp.get("list", "pristine-headers") != "yes":
590             text = self.mime_encode_headers(text)
591         text = self.add_subject_prefix(text)
592         self.mlm.send_mail(envelope_sender, recipients, text)
593
594     def send_info_message(self, recipients, template_name, dict):
595         self.send_template(self.command_address("ignore"),
596                            self.command_address("help"),
597                            recipients,
598                            template_name,
599                            dict)
600
601     def owners(self):
602         return self.cp.get("list", "owners").split()
603
604     def moderators(self):
605         return self.cp.get("list", "moderators").split()
606
607     def is_list_owner(self, address):
608         return address in self.owners()
609
610     def obey_help(self):
611         self.send_info_message([get_from_environ("SENDER")], "help", {})
612
613     def obey_list(self):
614         recipient = get_from_environ("SENDER")
615         if self.is_list_owner(recipient):
616             addr_list = self.subscribers.get_all()
617             addr_text = string.join(addr_list, "\n")
618             self.send_info_message([recipient], "list",
619                                    {
620                                      "addresses": addr_text,
621                                      "count": len(addr_list),
622                                    })
623         else:
624             self.send_info_message([recipient], "list-sorry", {})
625
626     def obey_setlist(self, origmail):
627         recipient = get_from_environ("SENDER")
628         if self.is_list_owner(recipient):
629             id = self.moderation_box.add(recipient, origmail)
630             if self.parse_setlist_addresses(origmail) == None:
631                 self.send_bad_addresses_in_setlist(id)
632                 self.moderation_box.remove(id)
633             else:
634                 confirm = self.signed_address("setlistyes", id)
635                 self.send_info_message(self.owners(), "setlist-confirm",
636                                        {
637                                           "confirm": confirm,
638                                           "origmail": origmail,
639                                           "boundary": self.invent_boundary(),
640                                        })
641                 
642         else:
643             self.send_info_message([recipient], "setlist-sorry", {})
644
645     def obey_setlistsilently(self, origmail):
646         recipient = get_from_environ("SENDER")
647         if self.is_list_owner(recipient):
648             id = self.moderation_box.add(recipient, origmail)
649             if self.parse_setlist_addresses(origmail) == None:
650                 self.send_bad_addresses_in_setlist(id)
651                 self.moderation_box.remove(id)
652             else:
653                 confirm = self.signed_address("setlistsilentyes", id)
654                 self.send_info_message(self.owners(), "setlist-confirm",
655                                        {
656                                           "confirm": confirm,
657                                           "origmail": origmail,
658                                           "boundary": self.invent_boundary(),
659                                        })
660         else:
661             self.send_info_message([recipient], "setlist-sorry", {})
662
663     def parse_setlist_addresses(self, text):
664         body = text.split("\n\n", 1)[1]
665         lines = body.split("\n")
666         lines = filter(lambda line: line != "", lines)
667         badlines = filter(lambda line: "@" not in line, lines)
668         if badlines:
669             return None
670         else:
671             return lines
672
673     def send_bad_addresses_in_setlist(self, id):
674         addr = self.moderation_box.get_address(id)
675         origmail = self.moderation_box.get(id)
676         self.send_info_message([addr], "setlist-badlist",
677                                {
678                                 "origmail": origmail,
679                                 "boundary": self.invent_boundary(),
680                                })
681
682
683     def obey_setlistyes(self, dict):
684         if self.moderation_box.has(dict["id"]):
685             text = self.moderation_box.get(dict["id"])
686             addresses = self.parse_setlist_addresses(text)
687             if addresses == None:
688                 self.send_bad_addresses_in_setlist(id)
689             else:
690                 removed_subscribers = []
691                 self.subscribers.lock()
692                 old = self.subscribers.get_all()
693                 for address in old:
694                     if address.lower() not in map(string.lower, addresses):
695                         self.subscribers.remove(address)
696                         removed_subscribers.append(address)
697                     else:
698                         for x in addresses:
699                             if x.lower() == address.lower():
700                                 addresses.remove(x)
701                 self.subscribers.add_many(addresses)
702                 self.subscribers.save()
703                 
704                 for recipient in addresses:
705                     self.send_info_message([recipient], "sub-welcome", {})
706                 for recipient in removed_subscribers:
707                     self.send_info_message([recipient], "unsub-goodbye", {})
708                 self.send_info_message(self.owners(), "setlist-done", {})
709
710             self.moderation_box.remove(dict["id"])
711
712     def obey_setlistsilentyes(self, dict):
713         if self.moderation_box.has(dict["id"]):
714             text = self.moderation_box.get(dict["id"])
715             addresses = self.parse_setlist_addresses(text)
716             if addresses == None:
717                 self.send_bad_addresses_in_setlist(id)
718             else:
719                 self.subscribers.lock()
720                 old = self.subscribers.get_all()
721                 for address in old:
722                     if address not in addresses:
723                         self.subscribers.remove(address)
724                     else:
725                         addresses.remove(address)
726                 self.subscribers.add_many(addresses)
727                 self.subscribers.save()
728                 self.send_info_message(self.owners(), "setlist-done", {})
729
730             self.moderation_box.remove(dict["id"])
731
732     def obey_owner(self, text):
733         sender = get_from_environ("SENDER")
734         recipients = self.cp.get("list", "owners").split()
735         text = self.add_subject_prefix(text)
736         self.mlm.send_mail(sender, recipients, text)
737
738     def obey_subscribe_or_unsubscribe(self, dict, template_name, command, 
739                                       origmail):
740
741         requester  = get_from_environ("SENDER")
742         subscriber = dict["sender"]
743         if not subscriber:
744             subscriber = requester
745         if subscriber.find("@") == -1:
746             info("Trying to (un)subscribe address without @: %s" % subscriber)
747             return
748         if self.cp.get("list", "ignore-bounce") == "yes":
749             info("Will not (un)subscribe address: %s from static list" %subscriber)
750             return
751         if requester in self.owners():
752             confirmers = self.owners()
753         else:
754             confirmers = [subscriber]
755
756         id = self.subscription_box.add(subscriber, origmail)
757         confirm = self.signed_address(command, id)
758         self.send_info_message(confirmers, template_name,
759                                {
760                                     "confirm": confirm,
761                                     "origmail": origmail,
762                                     "boundary": self.invent_boundary(),
763                                })
764
765     def obey_subscribe(self, dict, origmail):
766         self.obey_subscribe_or_unsubscribe(dict, "sub-confirm", "subyes", 
767                                            origmail)
768
769     def obey_unsubscribe(self, dict, origmail):
770         self.obey_subscribe_or_unsubscribe(dict, "unsub-confirm", "unsubyes",
771                                            origmail)
772
773     def obey_subyes(self, dict):
774         if self.subscription_box.has(dict["id"]):
775             if self.cp.get("list", "subscription") == "free":
776                 recipient = self.subscription_box.get_address(dict["id"])
777                 self.subscribers.lock()
778                 self.subscribers.add(recipient)
779                 self.subscribers.save()
780                 sender = self.command_address("help")
781                 self.send_template(self.ignore(), sender, [recipient], 
782                                    "sub-welcome", {})
783                 self.subscription_box.remove(dict["id"])
784                 if self.cp.get("list", "mail-on-subscription-changes")=="yes":
785                     self.send_info_message(self.owners(), 
786                                            "sub-owner-notification",
787                                            {
788                                             "address": recipient,
789                                            })
790             else:
791                 recipients = self.cp.get("list", "owners").split()
792                 confirm = self.signed_address("subapprove", dict["id"])
793                 deny = self.signed_address("subreject", dict["id"])
794                 subscriber = self.subscription_box.get_address(dict["id"])
795                 origmail = self.subscription_box.get(dict["id"])
796                 self.send_template(self.ignore(), deny, recipients, 
797                                    "sub-moderate", 
798                                    {
799                                        "confirm": confirm,
800                                        "deny": deny,
801                                        "subscriber": subscriber,
802                                        "origmail": origmail,
803                                        "boundary": self.invent_boundary(),
804                                    })
805                 recipient = self.subscription_box.get_address(dict["id"])
806                 self.send_info_message([recipient], "sub-wait", {})
807
808     def obey_subapprove(self, dict):
809         if self.subscription_box.has(dict["id"]):
810             recipient = self.subscription_box.get_address(dict["id"])
811             self.subscribers.lock()
812             self.subscribers.add(recipient)
813             self.subscribers.save()
814             self.send_info_message([recipient], "sub-welcome", {})
815             self.subscription_box.remove(dict["id"])
816             if self.cp.get("list", "mail-on-subscription-changes")=="yes":
817                 self.send_info_message(self.owners(), "sub-owner-notification",
818                                        {
819                                         "address": recipient,
820                                        })
821
822     def obey_subreject(self, dict):
823         if self.subscription_box.has(dict["id"]):
824             recipient = self.subscription_box.get_address(dict["id"])
825             self.send_info_message([recipient], "sub-reject", {})
826             self.subscription_box.remove(dict["id"])
827
828     def obey_unsubyes(self, dict):
829         if self.subscription_box.has(dict["id"]):
830             recipient = self.subscription_box.get_address(dict["id"])
831             self.subscribers.lock()
832             self.subscribers.remove(recipient)
833             self.subscribers.save()
834             self.send_info_message([recipient], "unsub-goodbye", {})
835             self.subscription_box.remove(dict["id"])
836             if self.cp.get("list", "mail-on-subscription-changes")=="yes":
837                 self.send_info_message(self.owners(),
838                                        "unsub-owner-notification",
839                                        {
840                                         "address": recipient,
841                                        })
842
843     def store_into_archive(self, text):
844         if self.cp.get("list", "archived") == "yes":
845             archdir = os.path.join(self.dirname, "archive")
846             if not os.path.exists(archdir):
847                 os.mkdir(archdir, 0700)
848             id = md5sum_as_hex(text)
849             f = open(os.path.join(archdir, id), "w")
850             f.write(text)
851             f.close()
852
853     def list_headers(self):
854         local, domain = self.name.split("@")
855         list = []
856         list.append("List-Id: <%s.%s>" % (local, domain))
857         list.append("List-Help: <mailto:%s-help@%s>" % (local, domain))
858         list.append("List-Unsubscribe: <mailto:%s-unsubscribe@%s>" % 
859                     (local, domain))
860         list.append("List-Subscribe: <mailto:%s-subscribe@%s>" % 
861                     (local, domain))
862         list.append("List-Post: <mailto:%s@%s>" % (local, domain))
863         list.append("List-Owner: <mailto:%s-owner@%s>" % (local, domain))
864         list.append("Precedence: bulk");
865         return string.join(list, "\n") + "\n"
866
867     def read_file(self, basename):
868         try:
869             f = open(os.path.join(self.dirname, basename), "r")
870             data = f.read()
871             f.close()
872             return data
873         except IOError:
874             return ""
875
876     def headers_to_add(self):
877         headers_to_add = self.read_file("headers-to-add").rstrip()
878         if headers_to_add:
879             return headers_to_add + "\n"
880         else:
881             return ""
882
883     def headers_and_body(self, mail):
884         endpos = mail.find("\n\n")
885         if endpos == -1:
886             endpos = mail.find("\n\r\n")
887             if endpos == -1:
888                 return mail
889         headers = mail[:endpos].split("\n")
890         body = mail[endpos:]
891         return (headers, body)
892
893     def remove_some_headers(self, mail, headers_to_remove):
894         headers, body = self.headers_and_body(mail)
895
896         headers_to_remove = [x.lower() for x in headers_to_remove]
897     
898         remaining = []
899         add_continuation_lines = 0
900
901         for header in headers:
902             if header[0] in [' ','\t']:
903                 # this is a continuation line
904                 if add_continuation_lines:
905                     remaining.append(header)
906             else:
907                 pos = header.find(":")
908                 if pos == -1:
909                     # malformed message, try to remove the junk
910                     add_continuation_lines = 0
911                     continue
912                 name = header[:pos].lower()
913                 if name in headers_to_remove:
914                     add_continuation_lines = 0
915                 else:
916                     add_continuation_lines = 1
917                     remaining.append(header)
918         
919         return "\n".join(remaining) + body
920
921     def headers_to_remove(self, text):
922         headers_to_remove = self.read_file("headers-to-remove").split("\n")
923         headers_to_remove = map(lambda s: s.strip().lower(), 
924                                 headers_to_remove)
925         return self.remove_some_headers(text, headers_to_remove)
926
927     def append_footer(self, text):
928         if "base64" in text or "BASE64" in text:
929             import StringIO
930             for line in StringIO.StringIO(text):
931                 if line.lower().startswith("content-transfer-encoding:") and \
932                    "base64" in line.lower():
933                     return text
934         return text + self.template("footer", {})
935
936     def send_mail_to_subscribers(self, text):
937         text = self.remove_some_headers(text, ["list-id", "list-help",
938                                                "list-unsubscribe",
939                                                "list-subscribe", "list-post",
940                                                "list-owner", "precedence"])
941         text = self.headers_to_add() + self.list_headers() + \
942                self.headers_to_remove(text)
943         text = self.add_subject_prefix(text)
944         text = self.append_footer(text)
945         text, = self.mlm.call_plugins("send_mail_to_subscribers_hook",
946                                      self, text)
947         if have_email_module and \
948            self.cp.get("list", "pristine-headers") != "yes":
949             text = self.mime_encode_headers(text)
950         self.store_into_archive(text)
951         for group in self.subscribers.groups():
952             bounce = self.signed_address("bounce", group)
953             addresses = self.subscribers.in_group(group)
954             self.mlm.send_mail(bounce, addresses, text)
955
956     def add_subject_prefix(self, text):
957         """Given a full-text mail, munge its subject header with the configured
958            subject prefix (if any) and return the updated mail text.
959         """
960         headers, body = self.headers_and_body(text)
961
962         prefix = self.cp.get("list", "subject-prefix")
963
964         # We don't need to do anything special to deal with multi-line
965         # subjects since adding the prefix to the first line of the subject
966         # and leaving the later lines untouched is sufficient.
967         if prefix:
968             has_subject = False
969             for header in headers:
970                 if header.startswith('Subject:'):
971                     has_subject = True
972                     if prefix not in header:
973                         text = text.replace(header,
974                           header[:9] + prefix + " " + header[9:], 1)
975                         break
976             # deal with the case where there was no Subject in the original
977             # mail (broken mailer?)
978             if not has_subject:
979                 text = text.replace("\n\n", "Subject: " + prefix + "\n\n", 1)
980
981         return text
982
983     def post_into_moderate(self, poster, dict, text):
984         id = self.moderation_box.add(poster, text)
985         recipients = self.moderators()
986         if recipients == []:
987             recipients = self.owners()
988
989         confirm = self.signed_address("approve", id)
990         deny = self.signed_address("reject", id)
991         self.send_template(self.ignore(), deny, recipients, "msg-moderate",
992                            {
993                             "confirm": confirm,
994                             "deny": deny,
995                             "origmail": text,
996                             "boundary": self.invent_boundary(),
997                            })
998         self.send_info_message([poster], "msg-wait", {})
999     
1000     def should_be_moderated(self, posting, poster):
1001         if posting == "moderated":
1002             return 1
1003         if posting == "auto":
1004             if poster.lower() not in \
1005                 map(string.lower, self.subscribers.get_all()):
1006                 return 1
1007         return 0
1008
1009     def obey_post(self, dict, text):
1010         if dict.has_key("force-moderation") and dict["force-moderation"]:
1011             force_moderation = 1
1012         else:
1013             force_moderation = 0
1014         if dict.has_key("force-posting") and dict["force-posting"]:
1015             force_posting = 1
1016         else:
1017             force_posting = 0
1018         posting = self.cp.get("list", "posting")
1019         if posting not in self.posting_opts:
1020             error("You have a weird 'posting' config. Please, review it")
1021         poster = get_from_environ("SENDER")
1022         if force_moderation:
1023             self.post_into_moderate(poster, dict, text)
1024         elif force_posting:
1025             self.send_mail_to_subscribers(text)
1026         elif self.should_be_moderated(posting, poster):
1027             self.post_into_moderate(poster, dict, text)
1028         else:
1029             self.send_mail_to_subscribers(text)
1030  
1031     def obey_approve(self, dict):
1032         if self.moderation_box.lock(dict["id"]):
1033             if self.moderation_box.has(dict["id"]):
1034                 text = self.moderation_box.get(dict["id"])
1035                 self.send_mail_to_subscribers(text)
1036                 self.moderation_box.remove(dict["id"])
1037             self.moderation_box.unlock(dict["id"])
1038
1039     def obey_reject(self, dict):
1040         if self.moderation_box.lock(dict["id"]):
1041             if self.moderation_box.has(dict["id"]):
1042                 self.moderation_box.remove(dict["id"])
1043             self.moderation_box.unlock(dict["id"])
1044
1045     def split_address_list(self, addrs):
1046         domains = {}
1047         for addr in addrs:
1048             userpart, domain = addr.split("@")
1049             if domains.has_key(domain):
1050                 domains[domain].append(addr)
1051             else:
1052                 domains[domain] = [addr]
1053         result = []
1054         if len(domains.keys()) == 1:
1055             for addr in addrs:
1056                 result.append([addr])
1057         else:
1058             result = domains.values()
1059         return result
1060
1061     def obey_bounce(self, dict, text):
1062         if self.subscribers.has_group(dict["id"]):
1063             self.subscribers.lock()
1064             addrs = self.subscribers.in_group(dict["id"])
1065             if len(addrs) == 1:
1066                 if self.cp.get("list", "ignore-bounce") == "yes":
1067                     info("Address <%s> bounced, ignoring bounce as configured." %
1068                          addrs[0])
1069                     self.subscribers.unlock()
1070                     return
1071                 debug("Address <%s> bounced, setting state to bounce." %
1072                       addrs[0])
1073                 bounce_id = self.bounce_box.add(addrs[0], text[:4096])
1074                 self.subscribers.set(dict["id"], "status", "bounced")
1075                 self.subscribers.set(dict["id"], "timestamp-bounced", 
1076                                      "%f" % time.time())
1077                 self.subscribers.set(dict["id"], "bounce-id",
1078                                      bounce_id)
1079             else:
1080                 debug("Group %s bounced, splitting." % dict["id"])
1081                 for new_addrs in self.split_address_list(addrs):
1082                     self.subscribers.add_many(new_addrs)
1083                 self.subscribers.remove_group(dict["id"])
1084             self.subscribers.save()
1085         else:
1086             debug("Ignoring bounce, group %s doesn't exist (anymore?)." %
1087                   dict["id"])
1088
1089     def obey_probe(self, dict, text):
1090         id = dict["id"]
1091         if self.subscribers.has_group(id):
1092             self.subscribers.lock()
1093             if self.subscribers.get(id, "status") == "probed":
1094                 self.subscribers.set(id, "status", "probebounced")
1095             self.subscribers.save()
1096
1097     def obey(self, dict):
1098         text = self.read_stdin()
1099
1100         if dict["command"] in ["help", "list", "subscribe", "unsubscribe",
1101                                "subyes", "subapprove", "subreject",
1102                                "unsubyes", "post", "approve"]:
1103             sender = get_from_environ("SENDER")
1104             if not sender:
1105                 debug("Ignoring bounce message for %s command." % 
1106                         dict["command"])
1107                 return
1108
1109         if dict["command"] == "help":
1110             self.obey_help()
1111         elif dict["command"] == "list":
1112             self.obey_list()
1113         elif dict["command"] == "owner":
1114             self.obey_owner(text)
1115         elif dict["command"] == "subscribe":
1116             self.obey_subscribe(dict, text)
1117         elif dict["command"] == "unsubscribe":
1118             self.obey_unsubscribe(dict, text)
1119         elif dict["command"] == "subyes":
1120             self.obey_subyes(dict)
1121         elif dict["command"] == "subapprove":
1122             self.obey_subapprove(dict)
1123         elif dict["command"] == "subreject":
1124             self.obey_subreject(dict)
1125         elif dict["command"] == "unsubyes":
1126             self.obey_unsubyes(dict)
1127         elif dict["command"] == "post":
1128             self.obey_post(dict, text)
1129         elif dict["command"] == "approve":
1130             self.obey_approve(dict)
1131         elif dict["command"] == "reject":
1132             self.obey_reject(dict)
1133         elif dict["command"] == "bounce":
1134             self.obey_bounce(dict, text)
1135         elif dict["command"] == "probe":
1136             self.obey_probe(dict, text)
1137         elif dict["command"] == "setlist":
1138             self.obey_setlist(text)
1139         elif dict["command"] == "setlistsilently":
1140             self.obey_setlistsilently(text)
1141         elif dict["command"] == "setlistyes":
1142             self.obey_setlistyes(dict)
1143         elif dict["command"] == "setlistsilentyes":
1144             self.obey_setlistsilentyes(dict)
1145         elif dict["command"] == "ignore":
1146             pass
1147
1148     def get_bounce_text(self, id):
1149         bounce_id = self.subscribers.get(id, "bounce-id")
1150         if self.bounce_box.has(bounce_id):
1151             bounce_text = self.bounce_box.get(bounce_id)
1152             bounce_text = string.join(map(lambda s: "> " + s + "\n",
1153                                           bounce_text.split("\n")), "")
1154         else:
1155             bounce_text = "Bounce message not available."
1156         return bounce_text
1157
1158     one_week = 7.0 * 24.0 * 60.0 * 60.0
1159
1160     def handle_bounced_groups(self, now):
1161         for id in self.subscribers.groups():
1162             status = self.subscribers.get(id, "status") 
1163             t = float(self.subscribers.get(id, "timestamp-bounced")) 
1164             if status == "bounced":
1165                 if now - t > self.one_week:
1166                     sender = self.signed_address("probe", id) 
1167                     recipients = self.subscribers.in_group(id) 
1168                     self.send_template(sender, sender, recipients,
1169                                        "bounce-warning", {
1170                                         "bounce": self.get_bounce_text(id),
1171                                         "boundary": self.invent_boundary(),
1172                                        })
1173                     self.subscribers.set(id, "status", "probed")
1174             elif status == "probed":
1175                 if now - t > 2 * self.one_week:
1176                     debug(("Cleaning woman: probe didn't bounce " + 
1177                           "for group <%s>, setting status to ok.") % id)
1178                     self.subscribers.set(id, "status", "ok")
1179                     self.bounce_box.remove(
1180                             self.subscribers.get(id, "bounce-id"))
1181             elif status == "probebounced":
1182                 sender = self.command_address("help") 
1183                 for address in self.subscribers.in_group(id):
1184                     if self.cp.get("list", "mail-on-forced-unsubscribe") \
1185                         == "yes":
1186                         self.send_template(sender, sender,
1187                                        self.owners(),
1188                                        "bounce-owner-notification",
1189                                        {
1190                                         "address": address,
1191                                         "bounce": self.get_bounce_text(id),
1192                                         "boundary": self.invent_boundary(),
1193                                        })
1194
1195                     self.bounce_box.remove(
1196                             self.subscribers.get(id, "bounce-id"))
1197                     self.subscribers.remove(address) 
1198                     debug("Cleaning woman: removing <%s>." % address)
1199                     self.send_template(sender, sender, [address],
1200                                        "bounce-goodbye", {})
1201
1202     def join_nonbouncing_groups(self, now):
1203         to_be_joined = []
1204         for id in self.subscribers.groups():
1205             status = self.subscribers.get(id, "status")
1206             age1 = now - float(self.subscribers.get(id, "timestamp-bounced"))
1207             age2 = now - float(self.subscribers.get(id, "timestamp-created"))
1208             if status == "ok":
1209                 if age1 > self.one_week and age2 > self.one_week:
1210                     to_be_joined.append(id)
1211         if to_be_joined:
1212             addrs = []
1213             for id in to_be_joined:
1214                 addrs = addrs + self.subscribers.in_group(id)
1215             self.subscribers.add_many(addrs)
1216             for id in to_be_joined:
1217                 self.bounce_box.remove(self.subscribers.get(id, "bounce-id"))
1218                 self.subscribers.remove_group(id)
1219
1220     def remove_empty_groups(self):
1221         for id in self.subscribers.groups()[:]:
1222             if len(self.subscribers.in_group(id)) == 0:
1223                 self.subscribers.remove_group(id)
1224
1225     def cleaning_woman(self, now):
1226         if self.subscribers.lock():
1227             self.handle_bounced_groups(now)
1228             self.join_nonbouncing_groups(now)
1229             self.subscribers.save()
1230
1231 class SubscriberDatabase:
1232
1233     def __init__(self, dirname, name):
1234         self.dict = {}
1235         self.filename = os.path.join(dirname, name)
1236         self.lockname = os.path.join(dirname, "lock")
1237         self.loaded = 0
1238         self.locked = 0
1239
1240     def lock(self):
1241         if os.system("lockfile -l 60 %s" % self.lockname) == 0:
1242             self.locked = 1
1243             self.load()
1244         return self.locked
1245     
1246     def unlock(self):
1247         os.remove(self.lockname)
1248         self.locked = 0
1249     
1250     def load(self):
1251         if not self.loaded and not self.dict:
1252             f = open(self.filename, "r")
1253             for line in f.xreadlines():
1254                 parts = line.split()
1255                 self.dict[parts[0]] = {
1256                     "status": parts[1],
1257                     "timestamp-created": parts[2],
1258                     "timestamp-bounced": parts[3],
1259                     "bounce-id": parts[4],
1260                     "addresses": parts[5:],
1261                 }
1262             f.close()
1263             self.loaded = 1
1264
1265     def save(self):
1266         assert self.locked
1267         assert self.loaded
1268         f = open(self.filename + ".new", "w")
1269         for id in self.dict.keys():
1270             f.write("%s " % id)
1271             f.write("%s " % self.dict[id]["status"])
1272             f.write("%s " % self.dict[id]["timestamp-created"])
1273             f.write("%s " % self.dict[id]["timestamp-bounced"])
1274             f.write("%s " % self.dict[id]["bounce-id"])
1275             f.write("%s\n" % string.join(self.dict[id]["addresses"], " "))
1276         f.close()
1277         os.remove(self.filename)
1278         os.rename(self.filename + ".new", self.filename)
1279         self.unlock()
1280
1281     def get(self, id, attribute):
1282         self.load()
1283         if self.dict.has_key(id) and self.dict[id].has_key(attribute):
1284             return self.dict[id][attribute]
1285         return None
1286
1287     def set(self, id, attribute, value):
1288         assert self.locked
1289         self.load()
1290         if self.dict.has_key(id) and self.dict[id].has_key(attribute):
1291             self.dict[id][attribute] = value
1292
1293     def add(self, address):
1294         return self.add_many([address])
1295
1296     def add_many(self, addresses):
1297         assert self.locked
1298         assert self.loaded
1299         for addr in addresses[:]:
1300             if addr.find("@") == -1:
1301                 info("Address '%s' does not contain an @, ignoring it." % addr)
1302                 addresses.remove(addr)
1303         for id in self.dict.keys():
1304             old_ones = self.dict[id]["addresses"]
1305             for addr in addresses:
1306                 for x in old_ones:
1307                     if x.lower() == addr.lower():
1308                         old_ones.remove(x)
1309             self.dict[id]["addresses"] = old_ones
1310         id = self.new_group()
1311         self.dict[id] = {
1312             "status": "ok",
1313             "timestamp-created": self.timestamp(),
1314             "timestamp-bounced": "0",
1315             "bounce-id": "..notexist..",
1316             "addresses": addresses,
1317         }
1318         return id
1319
1320     def new_group(self):
1321         keys = self.dict.keys()
1322         if keys:
1323             keys = map(lambda x: int(x), keys)
1324             keys.sort()
1325             return "%d" % (keys[-1] + 1)
1326         else:
1327             return "0"
1328
1329     def timestamp(self):
1330         return "%.0f" % time.time()
1331
1332     def get_all(self):
1333         self.load()
1334         list = []
1335         for values in self.dict.values():
1336             list = list + values["addresses"]
1337         return list
1338
1339     def groups(self):
1340         self.load()
1341         return self.dict.keys()
1342
1343     def has_group(self, id):
1344         self.load()
1345         return self.dict.has_key(id)
1346
1347     def in_group(self, id):
1348         self.load()
1349         return self.dict[id]["addresses"]
1350
1351     def remove(self, address):
1352         assert self.locked
1353         self.load()
1354         for id in self.dict.keys():
1355             group = self.dict[id]
1356             for x in group["addresses"][:]:
1357                 if x.lower() == address.lower():
1358                     group["addresses"].remove(x)
1359                     if len(group["addresses"]) == 0:
1360                         del self.dict[id]
1361
1362     def remove_group(self, id):
1363         assert self.locked
1364         self.load()
1365         del self.dict[id]
1366
1367
1368 class MessageBox:
1369
1370     def __init__(self, dirname, boxname):
1371         self.boxdir = os.path.join(dirname, boxname)
1372         if not os.path.isdir(self.boxdir):
1373             os.mkdir(self.boxdir, 0700)
1374
1375     def filename(self, id):
1376         return os.path.join(self.boxdir, id)
1377
1378     def add(self, address, message_text):
1379         id = self.make_id(message_text)
1380         filename = self.filename(id)
1381         f = open(filename + ".address", "w")
1382         f.write(address)
1383         f.close()
1384         f = open(filename + ".new", "w")
1385         f.write(message_text)
1386         f.close()
1387         os.rename(filename + ".new", filename)
1388         return id
1389
1390     def make_id(self, message_text):
1391         return md5sum_as_hex(message_text)
1392         # XXX this might be unnecessarily long
1393
1394     def remove(self, id):
1395         filename = self.filename(id)
1396         if os.path.isfile(filename):
1397             os.remove(filename)
1398             os.remove(filename + ".address")
1399
1400     def has(self, id):
1401         return os.path.isfile(self.filename(id))
1402
1403     def get_address(self, id):
1404         f = open(self.filename(id) + ".address", "r")
1405         data = f.read()
1406         f.close()
1407         return data.strip()
1408
1409     def get(self, id):
1410         f = open(self.filename(id), "r")
1411         data = f.read()
1412         f.close()
1413         return data
1414
1415     def lockname(self, id):
1416         return self.filename(id) + ".lock"
1417
1418     def lock(self, id):
1419         if os.system("lockfile -l 600 %s" % self.lockname(id)) == 0:
1420             return 1
1421         else:
1422             return 0
1423     
1424     def unlock(self, id):
1425         try:
1426             os.remove(self.lockname(id))
1427         except os.error:
1428             pass
1429     
1430
1431
1432 class DevNull:
1433
1434     def write(self, str):
1435         pass
1436
1437
1438 log_file_handle = None
1439 def log_file():
1440     global log_file_handle
1441     if log_file_handle is None:
1442         try:
1443             log_file_handle = open(os.path.join(DOTDIR, "logfile.txt"), "a")
1444         except:
1445             log_file_handle = DevNull()
1446     return log_file_handle
1447
1448 def timestamp():
1449     tuple = time.localtime(time.time())
1450     return time.strftime("%Y-%m-%d %H:%M:%S", tuple) + " [%d]" % os.getpid()
1451
1452
1453 quiet = 0
1454
1455
1456 # No logging to stderr of debug messages. Some MTAs have a limit on how
1457 # much data they accept via stderr and debug logs will fill that quickly.
1458 def debug(msg):
1459     log_file().write(timestamp() + " " + msg + "\n")
1460
1461
1462 # Log to log file first, in case MTA's stderr buffer fills up and we lose
1463 # logs.
1464 def info(msg):
1465     log_file().write(timestamp() + " " + msg + "\n")
1466     sys.stderr.write(msg + "\n")
1467
1468
1469 def error(msg):
1470     info(msg)
1471     sys.exit(1)
1472
1473
1474 def usage():
1475     sys.stdout.write("""\
1476 Usage: enemies-of-carlotta [options] command
1477 Mailing list manager.
1478
1479 Options:
1480   --name=listname@domain
1481   --owner=address@domain
1482   --moderator=address@domain
1483   --subscription=free/moderated
1484   --posting=free/moderated/auto
1485   --archived=yes/no
1486   --ignore-bounce=yes/no
1487   --language=language code or empty
1488   --mail-on-forced-unsubscribe=yes/no
1489   --mail-on-subscription-changes=yes/no
1490   --skip-prefix=string
1491   --domain=domain.name
1492   --smtp-server=domain.name
1493   --quiet
1494   --moderate
1495
1496 Commands:
1497   --help
1498   --create
1499   --subscribe
1500   --unsubscribe
1501   --list
1502   --is-list
1503   --edit
1504   --incoming
1505   --cleaning-woman
1506   --show-lists
1507
1508 For more detailed information, please read the enemies-of-carlotta(1)
1509 manual page.
1510 """)
1511     sys.exit(0)
1512
1513
1514 def no_act_send_mail(sender, recipients, text):
1515     print "NOT SENDING MAIL FOR REAL!"
1516     print "Sender:", sender
1517     print "Recipients:", recipients
1518     print "Mail:"
1519     print "\n".join(map(lambda s: "   " + s, text.split("\n")))
1520
1521
1522 def set_list_options(list, owners, moderators, subscription, posting, 
1523                      archived, language, ignore_bounce,
1524                      mail_on_sub_changes, mail_on_forced_unsub):
1525     if owners:
1526         list.cp.set("list", "owners", string.join(owners, " "))
1527     if moderators:
1528         list.cp.set("list", "moderators", string.join(moderators, " "))
1529     if subscription != None:
1530         list.cp.set("list", "subscription", subscription)
1531     if posting != None:
1532         list.cp.set("list", "posting", posting)
1533     if archived != None:
1534         list.cp.set("list", "archived", archived)
1535     if language != None:
1536         list.cp.set("list", "language", language)
1537     if ignore_bounce != None:
1538         list.cp.set("list", "ignore-bounce", ignore_bounce)
1539     if mail_on_sub_changes != None:
1540         list.cp.set("list", "mail-on-subscription-changes", 
1541                             mail_on_sub_changes)
1542     if mail_on_forced_unsub != None:
1543         list.cp.set("list", "mail-on-forced-unsubscribe",
1544                             mail_on_forced_unsub)
1545
1546
1547 def main(args):
1548     try:
1549         opts, args = getopt.getopt(args, "h",
1550                                    ["name=",
1551                                     "owner=",
1552                                     "moderator=",
1553                                     "subscription=",
1554                                     "posting=",
1555                                     "archived=",
1556                                     "language=",
1557                                     "ignore-bounce=",
1558                                     "mail-on-forced-unsubscribe=",
1559                                     "mail-on-subscription-changes=",
1560                                     "skip-prefix=",
1561                                     "domain=",
1562                                     "sendmail=",
1563                                     "smtp-server=",
1564                                     "qmqp-server=",
1565                                     "quiet",
1566                                     "moderate",
1567                                     "post",
1568                                     "sender=",
1569                                     "recipient=",
1570                                     "no-act",
1571                                     
1572                                     "set",
1573                                     "get",
1574                                     "help",
1575                                     "create",
1576                                     "destroy",
1577                                     "subscribe",
1578                                     "unsubscribe",
1579                                     "list",
1580                                     "is-list",
1581                                     "edit",
1582                                     "incoming",
1583                                     "cleaning-woman",
1584                                     "show-lists",
1585                                     "version",
1586                                    ])
1587     except getopt.GetoptError, detail:
1588         error("Error parsing command line options (see --help):\n%s" % 
1589               detail)
1590
1591     operation = None
1592     list_name = None
1593     owners = []
1594     moderators = []
1595     subscription = None
1596     posting = None
1597     archived = None
1598     ignore_bounce = None
1599     skip_prefix = None
1600     domain = None
1601     sendmail = "/usr/sbin/sendmail"
1602     smtp_server = None
1603     qmqp_server = None
1604     moderate = 0
1605     post = 0
1606     sender = None
1607     recipient = None
1608     language = None
1609     mail_on_forced_unsub = None
1610     mail_on_sub_changes = None
1611     no_act = 0
1612     global quiet
1613
1614     for opt, arg in opts:
1615         if opt == "--name":
1616             list_name = arg
1617         elif opt == "--owner":
1618             owners.append(arg)
1619         elif opt == "--moderator":
1620             moderators.append(arg)
1621         elif opt == "--subscription":
1622             subscription = arg
1623         elif opt == "--posting":
1624             posting = arg
1625         elif opt == "--archived":
1626             archived = arg
1627         elif opt == "--ignore-bounce":
1628             ignore_bounce = arg
1629         elif opt == "--skip-prefix":
1630             skip_prefix = arg
1631         elif opt == "--domain":
1632             domain = arg
1633         elif opt == "--sendmail":
1634             sendmail = arg
1635         elif opt == "--smtp-server":
1636             smtp_server = arg
1637         elif opt == "--qmqp-server":
1638             qmqp_server = arg
1639         elif opt == "--sender":
1640             sender = arg
1641         elif opt == "--recipient":
1642             recipient = arg
1643         elif opt == "--language":
1644             language = arg
1645         elif opt == "--mail-on-forced-unsubscribe":
1646             mail_on_forced_unsub = arg
1647         elif opt == "--mail-on-subscription-changes":
1648             mail_on_sub_changes = arg
1649         elif opt == "--moderate":
1650             moderate = 1
1651         elif opt == "--post":
1652             post = 1
1653         elif opt == "--quiet":
1654             quiet = 1
1655         elif opt == "--no-act":
1656             no_act = 1
1657         else:
1658             operation = opt
1659
1660     if operation is None:
1661         error("No operation specified, see --help.")
1662
1663     if list_name is None and operation not in ["--incoming", "--help", "-h",
1664                                                "--cleaning-woman",
1665                                                "--show-lists",
1666                                                "--version"]:
1667         error("%s requires a list name specified with --name" % operation)
1668
1669     if operation in ["--help", "-h"]:
1670         usage()
1671
1672     if sender or recipient:
1673         environ = os.environ.copy()
1674         if sender:
1675             environ["SENDER"] = sender
1676         if recipient:
1677             environ["RECIPIENT"] = recipient
1678         set_environ(environ)
1679
1680     mlm = MailingListManager(DOTDIR, sendmail=sendmail, 
1681                              smtp_server=smtp_server,
1682                              qmqp_server=qmqp_server)
1683     if no_act:
1684         mlm.send_mail = no_act_send_mail
1685
1686     if operation == "--create":
1687         if not owners:
1688             error("You must give at least one list owner with --owner.")
1689         list = mlm.create_list(list_name)
1690         set_list_options(list, owners, moderators, subscription, posting, 
1691                          archived, language, ignore_bounce,
1692                          mail_on_sub_changes, mail_on_forced_unsub)
1693         list.save_config()
1694         debug("Created list %s." % list_name)
1695     elif operation == "--destroy":
1696         shutil.rmtree(os.path.join(DOTDIR, list_name))
1697         debug("Removed list %s." % list_name)
1698     elif operation == "--edit":
1699         list = mlm.open_list(list_name)
1700         set_list_options(list, owners, moderators, subscription, posting, 
1701                          archived, language, ignore_bounce,
1702                          mail_on_sub_changes, mail_on_forced_unsub)
1703         list.save_config()
1704     elif operation == "--subscribe":
1705         list = mlm.open_list(list_name)
1706         list.subscribers.lock()
1707         for address in args:
1708             if address.find("@") == -1:
1709                 error("Address '%s' does not contain an @." % address)
1710             list.subscribers.add(address)
1711             debug("Added subscriber <%s>." % address)
1712         list.subscribers.save()
1713     elif operation == "--unsubscribe":
1714         list = mlm.open_list(list_name)
1715         list.subscribers.lock()
1716         for address in args:
1717             list.subscribers.remove(address)
1718             debug("Removed subscriber <%s>." % address)
1719         list.subscribers.save()
1720     elif operation == "--list":
1721         list = mlm.open_list(list_name)
1722         for address in list.subscribers.get_all():
1723             print address
1724     elif operation == "--is-list":
1725         if mlm.is_list(list_name, skip_prefix, domain):
1726             debug("Indeed a mailing list: <%s>" % list_name)
1727         else:
1728             debug("Not a mailing list: <%s>" % list_name)
1729             sys.exit(1)
1730     elif operation == "--incoming":
1731         mlm.incoming_message(skip_prefix, domain, moderate, post)
1732     elif operation == "--cleaning-woman":
1733         mlm.cleaning_woman()
1734     elif operation == "--show-lists":
1735         listnames = mlm.get_lists()
1736         listnames.sort()
1737         for listname in listnames:
1738             print listname
1739     elif operation == "--get":
1740         list = mlm.open_list(list_name)
1741         for name in args:
1742             print list.cp.get("list", name)
1743     elif operation == "--set":
1744         list = mlm.open_list(list_name)
1745         for arg in args:
1746             if "=" not in arg:
1747                 error("Error: --set arguments must be of form name=value")
1748             name, value = arg.split("=", 1)
1749             list.cp.set("list", name, value)
1750         list.save_config()
1751     elif operation == "--version":
1752         print "EoC, version %s" % VERSION
1753         print "Home page: http://liw.iki.fi/liw/eoc/"
1754     else:
1755         error("Internal error: unimplemented option <%s>." % operation)
1756
1757 if __name__ == "__main__":
1758     try:
1759         main(sys.argv[1:])
1760     except EocException, detail:
1761         error("Error: %s" % detail)