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