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