1 """Mailing list manager.
3 This is a simple mailing list manager that mimicks the ezmlm-idx mail
4 address commands. See manual page for more information.
8 PLUGIN_INTERFACE_VERSION = "1"
29 # The following values will be overriden by "make install".
30 TEMPLATE_DIRS = ["./templates"]
34 class EocException(Exception):
36 def __init__(self, arg=None):
42 class UnknownList(EocException):
43 def __init__(self, list_name):
44 self.msg = "%s is not a known mailing list" % list_name
46 class BadCommandAddress(EocException):
47 def __init__(self, address):
48 self.msg = "%s is not a valid command address" % address
50 class BadSignature(EocException):
51 def __init__(self, address):
52 self.msg = "address %s has an invalid digital signature" % address
54 class ListExists(EocException):
55 def __init__(self, list_name):
56 self.msg = "Mailing list %s alreadys exists" % list_name
58 class ListDoesNotExist(EocException):
59 def __init__(self, list_name):
60 self.msg = "Mailing list %s does not exist" % list_name
62 class MissingEnvironmentVariable(EocException):
63 def __init__(self, name):
64 self.msg = "Environment variable %s does not exist" % name
66 class MissingTemplate(EocException):
67 def __init__(self, template):
68 self.msg = "Template %s does not exit" % template
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",
77 COMMANDS = SIMPLE_COMMANDS + SUB_COMMANDS + HASH_COMMANDS
81 return hashlib.md5(s).hexdigest()
83 def forkexec(argv, text):
84 """Run a command (given as argv array) and write text to its stdin"""
88 raise Exception("fork failed")
93 fd = os.open("/dev/null", os.O_RDWR)
96 os.execvp(argv[0], argv)
102 (pid2, exit) = os.waitpid(pid, 0)
104 raise Exception("os.waitpid for %d returned for %d" % (pid, pid2))
106 raise Exception("subprocess failed, exit=0x%x" % exit)
112 def set_environ(new_environ):
114 environ = new_environ
116 def get_from_environ(key):
123 return env[key].lower()
124 raise MissingEnvironmentVariable(key)
128 """A parser for incoming e-mail addresses."""
130 def __init__(self, lists):
131 self.set_lists(lists)
132 self.set_skip_prefix(None)
133 self.set_forced_domain(None)
135 def set_lists(self, lists):
136 """Set the list of canonical list names we should know about."""
139 def set_skip_prefix(self, skip_prefix):
140 """Set the prefix to be removed from an address."""
141 self.skip_prefix = skip_prefix
143 def set_forced_domain(self, forced_domain):
144 """Set the domain part we should force the address to have."""
145 self.forced_domain = forced_domain
147 def clean(self, address):
148 """Remove cruft from the address and convert the rest to lower case."""
150 n = self.skip_prefix and len(self.skip_prefix)
151 if address[:n] == self.skip_prefix:
152 address = address[n:]
153 if self.forced_domain:
154 parts = address.split("@", 1)
155 address = "%s@%s" % (parts[0], self.forced_domain)
156 return address.lower()
158 def split_address(self, address):
159 """Split an address to a local part and a domain."""
160 parts = address.lower().split("@", 1)
166 # Does an address refer to a list? If not, return None, else return a list
167 # of additional parts (separated by hyphens) in the address. Note that []
168 # is not the same as None.
170 def additional_address_parts(self, address, listname):
171 addr_local, addr_domain = self.split_address(address)
172 list_local, list_domain = self.split_address(listname)
174 if addr_domain != list_domain:
177 if addr_local.lower() == list_local.lower():
181 if addr_local[:n] != list_local or addr_local[n] != "-":
184 return addr_local[n+1:].split("-")
187 # Parse an address we have received that identifies a list we manage.
188 # The address may contain command and signature parts. Return the name
189 # of the list, and a sequence of the additional parts (split at hyphens).
190 # Raise exceptions for errors. Note that the command will be valid, but
191 # cryptographic signatures in the address is not checked.
193 def parse(self, address):
194 address = self.clean(address)
195 for listname in self.lists:
196 parts = self.additional_address_parts(address, listname)
200 return listname, parts
201 elif parts[0] in HASH_COMMANDS:
203 raise BadCommandAddress(address)
204 return listname, parts
205 elif parts[0] in COMMANDS:
206 return listname, parts
208 raise UnknownList(address)
211 class MailingListManager:
213 def __init__(self, dotdir, sendmail="/usr/sbin/sendmail", lists=[],
214 smtp_server=None, qmqp_server=None):
216 self.sendmail = sendmail
217 self.smtp_server = smtp_server
218 self.qmqp_server = qmqp_server
221 self.secret = self.make_and_read_secret()
224 lists = filter(lambda s: "@" in s, os.listdir(dotdir))
225 self.set_lists(lists)
227 self.simple_commands = ["help", "list", "owner", "setlist",
228 "setlistsilently", "ignore"]
229 self.sub_commands = ["subscribe", "unsubscribe"]
230 self.hash_commands = ["subyes", "subapprove", "subreject", "unsubyes",
231 "bounce", "probe", "approve", "reject",
232 "setlistyes", "setlistsilentyes"]
233 self.commands = self.simple_commands + self.sub_commands + \
240 # Create the dot directory for us, if it doesn't exist already.
241 def make_dotdir(self):
242 if not os.path.isdir(self.dotdir):
243 os.makedirs(self.dotdir, 0700)
245 # Create the "secret" file, with a random value used as cookie for
246 # verification addresses.
247 def make_and_read_secret(self):
248 secret_name = os.path.join(self.dotdir, "secret")
249 if not os.path.isfile(secret_name):
250 f = open("/dev/urandom", "r")
253 f = open(secret_name, "w")
257 f = open(secret_name, "r")
262 # Load the plugins from DOTDIR/plugins/*.py.
263 def load_plugins(self):
266 dirname = os.path.join(DOTDIR, "plugins")
268 plugins = os.listdir(dirname)
273 plugins = map(os.path.splitext, plugins)
274 plugins = filter(lambda p: p[1] == ".py", plugins)
275 plugins = map(lambda p: p[0], plugins)
277 pathname = os.path.join(dirname, name + ".py")
278 f = open(pathname, "r")
279 module = imp.load_module(name, f, pathname,
280 (".py", "r", imp.PY_SOURCE))
282 if module.PLUGIN_INTERFACE_VERSION == PLUGIN_INTERFACE_VERSION:
283 self.plugins.append(module)
285 # Call function named funcname (a string) in all plugins, giving as
286 # arguments all the remaining arguments preceded by ml. Return value
287 # of each function is the new list of arguments to the next function.
288 # Return value of this function is the return value of the last function.
289 def call_plugins(self, funcname, list, *args):
290 for plugin in self.plugins:
291 if plugin.__dict__.has_key(funcname):
292 args = apply(plugin.__dict__[funcname], (list,) + args)
293 if type(args) != type((0,)):
297 # Set the list of listnames. The list of lists needs to be sorted in
298 # length order so that test@example.com is matched before
299 # test-list@example.com
300 def set_lists(self, lists):
301 temp = map(lambda s: (len(s), s), lists)
303 self.lists = map(lambda t: t[1], temp)
305 # Return the list of listnames.
309 # Decode an address that has been encoded to be part of a local part.
310 def decode_address(self, parts):
311 return string.join(string.join(parts, "-").split("="), "@")
313 # Is local_part@domain an existing list?
314 def is_list_name(self, local_part, domain):
315 return ("%s@%s" % (local_part, domain)) in self.lists
317 # Compute the verification checksum for an address.
318 def compute_hash(self, address):
319 return md5sum_as_hex(address + self.secret)
321 # Is the verification signature in a parsed address bad? If so, return true,
322 # otherwise return false.
323 def signature_is_bad(self, dict, hash):
324 local_part, domain = dict["name"].split("@")
325 address = "%s-%s-%s@%s" % (local_part, dict["command"], dict["id"],
327 correct = self.compute_hash(address)
328 return correct != hash
330 # Parse a command address we have received and check its validity
331 # (including signature, if any). Return a dictionary with keys
332 # "command", "sender" (address that was encoded into address, if
333 # any), "id" (group ID).
335 def parse_recipient_address(self, address, skip_prefix, forced_domain):
336 ap = AddressParser(self.get_lists())
337 ap.set_lists(self.get_lists())
338 ap.set_skip_prefix(skip_prefix)
339 ap.set_forced_domain(forced_domain)
340 listname, parts = ap.parse(address)
342 dict = { "name": listname }
345 dict["command"] = "post"
347 command, args = parts[0], parts[1:]
348 dict["command"] = command
349 if command in SUB_COMMANDS:
350 dict["sender"] = self.decode_address(args)
351 elif command in HASH_COMMANDS:
354 if self.signature_is_bad(dict, hash):
355 raise BadSignature(address)
359 # Does an address refer to a mailing list?
360 def is_list(self, name, skip_prefix=None, domain=None):
362 self.parse_recipient_address(name, skip_prefix, domain)
363 except BadCommandAddress:
371 # Create a new list and return it.
372 def create_list(self, name):
373 if self.is_list(name):
374 raise ListExists(name)
375 self.set_lists(self.lists + [name])
376 return MailingList(self, name)
378 # Open an existing list.
379 def open_list(self, name):
380 if self.is_list(name):
381 return self.open_list_exact(name)
384 for list in self.lists:
385 if list[:len(x)] == x:
386 return self.open_list_exact(list)
387 raise ListDoesNotExist(name)
389 def open_list_exact(self, name):
390 for list in self.get_lists():
391 if list.lower() == name.lower():
392 return MailingList(self, list)
393 raise ListDoesNotExist(name)
395 # Process an incoming message.
396 def incoming_message(self, skip_prefix, domain, moderate, post):
397 debug("Processing incoming message.")
398 debug("$SENDER = <%s>" % get_from_environ("SENDER"))
399 debug("$RECIPIENT = <%s>" % get_from_environ("RECIPIENT"))
400 dict = self.parse_recipient_address(get_from_environ("RECIPIENT"),
403 dict["force-moderation"] = moderate
404 dict["force-posting"] = post
405 debug("List is <%(name)s>, command is <%(command)s>." % dict)
406 list = self.open_list_exact(dict["name"])
409 # Clean up bouncing address and do other janitorial work for all lists.
410 def cleaning_woman(self, send_mail=None):
412 for listname in self.lists:
413 list = self.open_list_exact(listname)
415 list.send_mail = send_mail
416 list.cleaning_woman(now)
418 # Send a mail to the desired recipients.
419 def send_mail(self, envelope_sender, recipients, text):
420 debug("send_mail:\n sender=%s\n recipients=%s\n text=\n %s" %
421 (envelope_sender, str(recipients),
422 "\n ".join(text[:text.find("\n\n")].split("\n"))))
426 smtp = smtplib.SMTP(self.smtp_server)
427 smtp.sendmail(envelope_sender, recipients, text)
430 error("Error sending SMTP mail, mail probably not sent")
432 elif self.qmqp_server:
434 q = qmqp.QMQP(self.qmqp_server)
435 q.sendmail(envelope_sender, recipients, text)
438 error("Error sending QMQP mail, mail probably not sent")
441 status = forkexec([self.sendmail, "-oi", "-f",
442 envelope_sender] + recipients, text)
444 error("%s returned %s, mail sending probably failed" %
445 (self.sendmail, status))
446 sys.exit((status >> 8) & 0xff)
448 debug("send_mail: no recipients, not sending")
454 posting_opts = ["auto", "free", "moderated"]
456 def __init__(self, mlm, name):
460 self.cp = ConfigParser.ConfigParser()
461 self.cp.add_section("list")
462 self.cp.set("list", "owners", "")
463 self.cp.set("list", "moderators", "")
464 self.cp.set("list", "subscription", "free")
465 self.cp.set("list", "posting", "free")
466 self.cp.set("list", "archived", "no")
467 self.cp.set("list", "mail-on-subscription-changes", "no")
468 self.cp.set("list", "mail-on-forced-unsubscribe", "no")
469 self.cp.set("list", "ignore-bounce", "no")
470 self.cp.set("list", "language", "")
471 self.cp.set("list", "pristine-headers", "")
472 self.cp.set("list", "subject-prefix", "")
474 self.dirname = os.path.join(self.mlm.dotdir, name)
476 self.cp.read(self.mkname("config"))
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")
483 def make_listdir(self):
484 if not os.path.isdir(self.dirname):
485 os.mkdir(self.dirname, 0700)
487 f = open(self.mkname("subscribers"), "w")
490 def mkname(self, relative):
491 return os.path.join(self.dirname, relative)
493 def save_config(self):
494 f = open(self.mkname("config"), "w")
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]
507 def invent_boundary(self):
508 return "%s/%s" % (md5sum_as_hex(str(time.time())),
509 md5sum_as_hex(self.name))
511 def command_address(self, command):
512 local_part, domain = self.name.split("@")
513 return "%s-%s@%s" % (local_part, command, domain)
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))
521 return self.command_address("ignore")
523 def nice_7bit(self, str):
525 if (ord(c) < 32 and not c.isspace()) or ord(c) >= 127:
529 def mime_encode_headers(self, text):
531 headers, body = text.split("\n\n", 1)
534 for line in headers.split("\n"):
535 if line[0].isspace():
542 if self.nice_7bit(header):
543 headers.append(header)
546 name, content = header.split(": ", 1)
548 name, content = header.split(":", 1)
549 hdr = email.Header.Header(content, "utf-8")
550 headers.append(name + ": " + hdr.encode())
552 return "\n".join(headers) + "\n\n" + body
554 info("Cannot MIME encode header, using original ones, sorry")
557 def template(self, template_name, dict):
558 lang = self.cp.get("list", "language")
560 template_name_lang = template_name + "." + lang
562 template_name_lang = template_name
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
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")
580 raise MissingTemplate(template_name)
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)
589 if self.cp.get("list", "pristine-headers") != "yes":
590 text = self.mime_encode_headers(text)
591 text = self.add_subject_prefix(text)
592 self.mlm.send_mail(envelope_sender, recipients, text)
594 def send_info_message(self, recipients, template_name, dict):
595 self.send_template(self.command_address("ignore"),
596 self.command_address("help"),
602 return self.cp.get("list", "owners").split()
604 def moderators(self):
605 return self.cp.get("list", "moderators").split()
607 def is_list_owner(self, address):
608 return address in self.owners()
611 self.send_info_message([get_from_environ("SENDER")], "help", {})
614 recipient = get_from_environ("SENDER")
615 if self.is_list_owner(recipient):
616 addr_list = self.subscribers.get_all()
617 addr_text = string.join(addr_list, "\n")
618 self.send_info_message([recipient], "list",
620 "addresses": addr_text,
621 "count": len(addr_list),
624 self.send_info_message([recipient], "list-sorry", {})
626 def obey_setlist(self, origmail):
627 recipient = get_from_environ("SENDER")
628 if self.is_list_owner(recipient):
629 id = self.moderation_box.add(recipient, origmail)
630 if self.parse_setlist_addresses(origmail) == None:
631 self.send_bad_addresses_in_setlist(id)
632 self.moderation_box.remove(id)
634 confirm = self.signed_address("setlistyes", id)
635 self.send_info_message(self.owners(), "setlist-confirm",
638 "origmail": origmail,
639 "boundary": self.invent_boundary(),
643 self.send_info_message([recipient], "setlist-sorry", {})
645 def obey_setlistsilently(self, origmail):
646 recipient = get_from_environ("SENDER")
647 if self.is_list_owner(recipient):
648 id = self.moderation_box.add(recipient, origmail)
649 if self.parse_setlist_addresses(origmail) == None:
650 self.send_bad_addresses_in_setlist(id)
651 self.moderation_box.remove(id)
653 confirm = self.signed_address("setlistsilentyes", id)
654 self.send_info_message(self.owners(), "setlist-confirm",
657 "origmail": origmail,
658 "boundary": self.invent_boundary(),
661 self.send_info_message([recipient], "setlist-sorry", {})
663 def parse_setlist_addresses(self, text):
664 body = text.split("\n\n", 1)[1]
665 lines = body.split("\n")
666 lines = filter(lambda line: line != "", lines)
667 badlines = filter(lambda line: "@" not in line, lines)
673 def send_bad_addresses_in_setlist(self, id):
674 addr = self.moderation_box.get_address(id)
675 origmail = self.moderation_box.get(id)
676 self.send_info_message([addr], "setlist-badlist",
678 "origmail": origmail,
679 "boundary": self.invent_boundary(),
683 def obey_setlistyes(self, dict):
684 if self.moderation_box.has(dict["id"]):
685 text = self.moderation_box.get(dict["id"])
686 addresses = self.parse_setlist_addresses(text)
687 if addresses == None:
688 self.send_bad_addresses_in_setlist(id)
690 removed_subscribers = []
691 self.subscribers.lock()
692 old = self.subscribers.get_all()
694 if address.lower() not in map(string.lower, addresses):
695 self.subscribers.remove(address)
696 removed_subscribers.append(address)
699 if x.lower() == address.lower():
701 self.subscribers.add_many(addresses)
702 self.subscribers.save()
704 for recipient in addresses:
705 self.send_info_message([recipient], "sub-welcome", {})
706 for recipient in removed_subscribers:
707 self.send_info_message([recipient], "unsub-goodbye", {})
708 self.send_info_message(self.owners(), "setlist-done", {})
710 self.moderation_box.remove(dict["id"])
712 def obey_setlistsilentyes(self, dict):
713 if self.moderation_box.has(dict["id"]):
714 text = self.moderation_box.get(dict["id"])
715 addresses = self.parse_setlist_addresses(text)
716 if addresses == None:
717 self.send_bad_addresses_in_setlist(id)
719 self.subscribers.lock()
720 old = self.subscribers.get_all()
722 if address not in addresses:
723 self.subscribers.remove(address)
725 addresses.remove(address)
726 self.subscribers.add_many(addresses)
727 self.subscribers.save()
728 self.send_info_message(self.owners(), "setlist-done", {})
730 self.moderation_box.remove(dict["id"])
732 def obey_owner(self, text):
733 sender = get_from_environ("SENDER")
734 recipients = self.cp.get("list", "owners").split()
735 text = self.add_subject_prefix(text)
736 self.mlm.send_mail(sender, recipients, text)
738 def obey_subscribe_or_unsubscribe(self, dict, template_name, command,
741 requester = get_from_environ("SENDER")
742 subscriber = dict["sender"]
744 subscriber = requester
745 if subscriber.find("@") == -1:
746 info("Trying to (un)subscribe address without @: %s" % subscriber)
748 if self.cp.get("list", "ignore-bounce") == "yes":
749 info("Will not (un)subscribe address: %s from static list" %subscriber)
751 if requester in self.owners():
752 confirmers = self.owners()
754 confirmers = [subscriber]
756 id = self.subscription_box.add(subscriber, origmail)
757 confirm = self.signed_address(command, id)
758 self.send_info_message(confirmers, template_name,
761 "origmail": origmail,
762 "boundary": self.invent_boundary(),
765 def obey_subscribe(self, dict, origmail):
766 self.obey_subscribe_or_unsubscribe(dict, "sub-confirm", "subyes",
769 def obey_unsubscribe(self, dict, origmail):
770 self.obey_subscribe_or_unsubscribe(dict, "unsub-confirm", "unsubyes",
773 def obey_subyes(self, dict):
774 if self.subscription_box.has(dict["id"]):
775 if self.cp.get("list", "subscription") == "free":
776 recipient = self.subscription_box.get_address(dict["id"])
777 self.subscribers.lock()
778 self.subscribers.add(recipient)
779 self.subscribers.save()
780 sender = self.command_address("help")
781 self.send_template(self.ignore(), sender, [recipient],
783 self.subscription_box.remove(dict["id"])
784 if self.cp.get("list", "mail-on-subscription-changes")=="yes":
785 self.send_info_message(self.owners(),
786 "sub-owner-notification",
788 "address": recipient,
791 recipients = self.cp.get("list", "owners").split()
792 confirm = self.signed_address("subapprove", dict["id"])
793 deny = self.signed_address("subreject", dict["id"])
794 subscriber = self.subscription_box.get_address(dict["id"])
795 origmail = self.subscription_box.get(dict["id"])
796 self.send_template(self.ignore(), deny, recipients,
801 "subscriber": subscriber,
802 "origmail": origmail,
803 "boundary": self.invent_boundary(),
805 recipient = self.subscription_box.get_address(dict["id"])
806 self.send_info_message([recipient], "sub-wait", {})
808 def obey_subapprove(self, dict):
809 if self.subscription_box.has(dict["id"]):
810 recipient = self.subscription_box.get_address(dict["id"])
811 self.subscribers.lock()
812 self.subscribers.add(recipient)
813 self.subscribers.save()
814 self.send_info_message([recipient], "sub-welcome", {})
815 self.subscription_box.remove(dict["id"])
816 if self.cp.get("list", "mail-on-subscription-changes")=="yes":
817 self.send_info_message(self.owners(), "sub-owner-notification",
819 "address": recipient,
822 def obey_subreject(self, dict):
823 if self.subscription_box.has(dict["id"]):
824 recipient = self.subscription_box.get_address(dict["id"])
825 self.send_info_message([recipient], "sub-reject", {})
826 self.subscription_box.remove(dict["id"])
828 def obey_unsubyes(self, dict):
829 if self.subscription_box.has(dict["id"]):
830 recipient = self.subscription_box.get_address(dict["id"])
831 self.subscribers.lock()
832 self.subscribers.remove(recipient)
833 self.subscribers.save()
834 self.send_info_message([recipient], "unsub-goodbye", {})
835 self.subscription_box.remove(dict["id"])
836 if self.cp.get("list", "mail-on-subscription-changes")=="yes":
837 self.send_info_message(self.owners(),
838 "unsub-owner-notification",
840 "address": recipient,
843 def store_into_archive(self, text):
844 if self.cp.get("list", "archived") == "yes":
845 archdir = os.path.join(self.dirname, "archive")
846 if not os.path.exists(archdir):
847 os.mkdir(archdir, 0700)
848 id = md5sum_as_hex(text)
849 f = open(os.path.join(archdir, id), "w")
853 def list_headers(self):
854 local, domain = self.name.split("@")
856 list.append("List-Id: <%s.%s>" % (local, domain))
857 list.append("List-Help: <mailto:%s-help@%s>" % (local, domain))
858 list.append("List-Unsubscribe: <mailto:%s-unsubscribe@%s>" %
860 list.append("List-Subscribe: <mailto:%s-subscribe@%s>" %
862 list.append("List-Post: <mailto:%s@%s>" % (local, domain))
863 list.append("List-Owner: <mailto:%s-owner@%s>" % (local, domain))
864 list.append("Precedence: bulk");
865 return string.join(list, "\n") + "\n"
867 def read_file(self, basename):
869 f = open(os.path.join(self.dirname, basename), "r")
876 def headers_to_add(self):
877 headers_to_add = self.read_file("headers-to-add").rstrip()
879 return headers_to_add + "\n"
883 def headers_and_body(self, mail):
884 endpos = mail.find("\n\n")
886 endpos = mail.find("\n\r\n")
889 headers = mail[:endpos].split("\n")
891 return (headers, body)
893 def remove_some_headers(self, mail, headers_to_remove):
894 headers, body = self.headers_and_body(mail)
896 headers_to_remove = [x.lower() for x in headers_to_remove]
899 add_continuation_lines = 0
901 for header in headers:
902 if header[0] in [' ','\t']:
903 # this is a continuation line
904 if add_continuation_lines:
905 remaining.append(header)
907 pos = header.find(":")
909 # malformed message, try to remove the junk
910 add_continuation_lines = 0
912 name = header[:pos].lower()
913 if name in headers_to_remove:
914 add_continuation_lines = 0
916 add_continuation_lines = 1
917 remaining.append(header)
919 return "\n".join(remaining) + body
921 def headers_to_remove(self, text):
922 headers_to_remove = self.read_file("headers-to-remove").split("\n")
923 headers_to_remove = map(lambda s: s.strip().lower(),
925 return self.remove_some_headers(text, headers_to_remove)
927 def append_footer(self, text):
928 if "base64" in text or "BASE64" in text:
930 for line in StringIO.StringIO(text):
931 if line.lower().startswith("content-transfer-encoding:") and \
932 "base64" in line.lower():
934 return text + self.template("footer", {})
936 def send_mail_to_subscribers(self, text):
937 text = self.remove_some_headers(text, ["list-id", "list-help",
939 "list-subscribe", "list-post",
940 "list-owner", "precedence"])
941 text = self.headers_to_add() + self.list_headers() + \
942 self.headers_to_remove(text)
943 text = self.add_subject_prefix(text)
944 text = self.append_footer(text)
945 text, = self.mlm.call_plugins("send_mail_to_subscribers_hook",
947 if have_email_module and \
948 self.cp.get("list", "pristine-headers") != "yes":
949 text = self.mime_encode_headers(text)
950 self.store_into_archive(text)
951 for group in self.subscribers.groups():
952 bounce = self.signed_address("bounce", group)
953 addresses = self.subscribers.in_group(group)
954 self.mlm.send_mail(bounce, addresses, text)
956 def add_subject_prefix(self, text):
957 """Given a full-text mail, munge its subject header with the configured
958 subject prefix (if any) and return the updated mail text.
960 headers, body = self.headers_and_body(text)
962 prefix = self.cp.get("list", "subject-prefix")
964 # We don't need to do anything special to deal with multi-line
965 # subjects since adding the prefix to the first line of the subject
966 # and leaving the later lines untouched is sufficient.
969 for header in headers:
970 if header.startswith('Subject:'):
972 if prefix not in header:
973 text = text.replace(header,
974 header[:9] + prefix + " " + header[9:], 1)
976 # deal with the case where there was no Subject in the original
977 # mail (broken mailer?)
979 text = text.replace("\n\n", "Subject: " + prefix + "\n\n", 1)
983 def post_into_moderate(self, poster, dict, text):
984 id = self.moderation_box.add(poster, text)
985 recipients = self.moderators()
987 recipients = self.owners()
989 confirm = self.signed_address("approve", id)
990 deny = self.signed_address("reject", id)
991 self.send_template(self.ignore(), deny, recipients, "msg-moderate",
996 "boundary": self.invent_boundary(),
998 self.send_info_message([poster], "msg-wait", {})
1000 def should_be_moderated(self, posting, poster):
1001 if posting == "moderated":
1003 if posting == "auto":
1004 if poster.lower() not in \
1005 map(string.lower, self.subscribers.get_all()):
1009 def obey_post(self, dict, text):
1010 if dict.has_key("force-moderation") and dict["force-moderation"]:
1011 force_moderation = 1
1013 force_moderation = 0
1014 if dict.has_key("force-posting") and dict["force-posting"]:
1018 posting = self.cp.get("list", "posting")
1019 if posting not in self.posting_opts:
1020 error("You have a weird 'posting' config. Please, review it")
1021 poster = get_from_environ("SENDER")
1022 if force_moderation:
1023 self.post_into_moderate(poster, dict, text)
1025 self.send_mail_to_subscribers(text)
1026 elif self.should_be_moderated(posting, poster):
1027 self.post_into_moderate(poster, dict, text)
1029 self.send_mail_to_subscribers(text)
1031 def obey_approve(self, dict):
1032 if self.moderation_box.lock(dict["id"]):
1033 if self.moderation_box.has(dict["id"]):
1034 text = self.moderation_box.get(dict["id"])
1035 self.send_mail_to_subscribers(text)
1036 self.moderation_box.remove(dict["id"])
1037 self.moderation_box.unlock(dict["id"])
1039 def obey_reject(self, dict):
1040 if self.moderation_box.lock(dict["id"]):
1041 if self.moderation_box.has(dict["id"]):
1042 self.moderation_box.remove(dict["id"])
1043 self.moderation_box.unlock(dict["id"])
1045 def split_address_list(self, addrs):
1048 userpart, domain = addr.split("@")
1049 if domains.has_key(domain):
1050 domains[domain].append(addr)
1052 domains[domain] = [addr]
1054 if len(domains.keys()) == 1:
1056 result.append([addr])
1058 result = domains.values()
1061 def obey_bounce(self, dict, text):
1062 if self.subscribers.has_group(dict["id"]):
1063 self.subscribers.lock()
1064 addrs = self.subscribers.in_group(dict["id"])
1066 if self.cp.get("list", "ignore-bounce") == "yes":
1067 info("Address <%s> bounced, ignoring bounce as configured." %
1069 self.subscribers.unlock()
1071 debug("Address <%s> bounced, setting state to bounce." %
1073 bounce_id = self.bounce_box.add(addrs[0], text[:4096])
1074 self.subscribers.set(dict["id"], "status", "bounced")
1075 self.subscribers.set(dict["id"], "timestamp-bounced",
1077 self.subscribers.set(dict["id"], "bounce-id",
1080 debug("Group %s bounced, splitting." % dict["id"])
1081 for new_addrs in self.split_address_list(addrs):
1082 self.subscribers.add_many(new_addrs)
1083 self.subscribers.remove_group(dict["id"])
1084 self.subscribers.save()
1086 debug("Ignoring bounce, group %s doesn't exist (anymore?)." %
1089 def obey_probe(self, dict, text):
1091 if self.subscribers.has_group(id):
1092 self.subscribers.lock()
1093 if self.subscribers.get(id, "status") == "probed":
1094 self.subscribers.set(id, "status", "probebounced")
1095 self.subscribers.save()
1097 def obey(self, dict):
1098 text = self.read_stdin()
1100 if dict["command"] in ["help", "list", "subscribe", "unsubscribe",
1101 "subyes", "subapprove", "subreject",
1102 "unsubyes", "post", "approve"]:
1103 sender = get_from_environ("SENDER")
1105 debug("Ignoring bounce message for %s command." %
1109 if dict["command"] == "help":
1111 elif dict["command"] == "list":
1113 elif dict["command"] == "owner":
1114 self.obey_owner(text)
1115 elif dict["command"] == "subscribe":
1116 self.obey_subscribe(dict, text)
1117 elif dict["command"] == "unsubscribe":
1118 self.obey_unsubscribe(dict, text)
1119 elif dict["command"] == "subyes":
1120 self.obey_subyes(dict)
1121 elif dict["command"] == "subapprove":
1122 self.obey_subapprove(dict)
1123 elif dict["command"] == "subreject":
1124 self.obey_subreject(dict)
1125 elif dict["command"] == "unsubyes":
1126 self.obey_unsubyes(dict)
1127 elif dict["command"] == "post":
1128 self.obey_post(dict, text)
1129 elif dict["command"] == "approve":
1130 self.obey_approve(dict)
1131 elif dict["command"] == "reject":
1132 self.obey_reject(dict)
1133 elif dict["command"] == "bounce":
1134 self.obey_bounce(dict, text)
1135 elif dict["command"] == "probe":
1136 self.obey_probe(dict, text)
1137 elif dict["command"] == "setlist":
1138 self.obey_setlist(text)
1139 elif dict["command"] == "setlistsilently":
1140 self.obey_setlistsilently(text)
1141 elif dict["command"] == "setlistyes":
1142 self.obey_setlistyes(dict)
1143 elif dict["command"] == "setlistsilentyes":
1144 self.obey_setlistsilentyes(dict)
1145 elif dict["command"] == "ignore":
1148 def get_bounce_text(self, id):
1149 bounce_id = self.subscribers.get(id, "bounce-id")
1150 if self.bounce_box.has(bounce_id):
1151 bounce_text = self.bounce_box.get(bounce_id)
1152 bounce_text = string.join(map(lambda s: "> " + s + "\n",
1153 bounce_text.split("\n")), "")
1155 bounce_text = "Bounce message not available."
1158 one_week = 7.0 * 24.0 * 60.0 * 60.0
1160 def handle_bounced_groups(self, now):
1161 for id in self.subscribers.groups():
1162 status = self.subscribers.get(id, "status")
1163 t = float(self.subscribers.get(id, "timestamp-bounced"))
1164 if status == "bounced":
1165 if now - t > self.one_week:
1166 sender = self.signed_address("probe", id)
1167 recipients = self.subscribers.in_group(id)
1168 self.send_template(sender, sender, recipients,
1170 "bounce": self.get_bounce_text(id),
1171 "boundary": self.invent_boundary(),
1173 self.subscribers.set(id, "status", "probed")
1174 elif status == "probed":
1175 if now - t > 2 * self.one_week:
1176 debug(("Cleaning woman: probe didn't bounce " +
1177 "for group <%s>, setting status to ok.") % id)
1178 self.subscribers.set(id, "status", "ok")
1179 self.bounce_box.remove(
1180 self.subscribers.get(id, "bounce-id"))
1181 elif status == "probebounced":
1182 sender = self.command_address("help")
1183 for address in self.subscribers.in_group(id):
1184 if self.cp.get("list", "mail-on-forced-unsubscribe") \
1186 self.send_template(sender, sender,
1188 "bounce-owner-notification",
1191 "bounce": self.get_bounce_text(id),
1192 "boundary": self.invent_boundary(),
1195 self.bounce_box.remove(
1196 self.subscribers.get(id, "bounce-id"))
1197 self.subscribers.remove(address)
1198 debug("Cleaning woman: removing <%s>." % address)
1199 self.send_template(sender, sender, [address],
1200 "bounce-goodbye", {})
1202 def join_nonbouncing_groups(self, now):
1204 for id in self.subscribers.groups():
1205 status = self.subscribers.get(id, "status")
1206 age1 = now - float(self.subscribers.get(id, "timestamp-bounced"))
1207 age2 = now - float(self.subscribers.get(id, "timestamp-created"))
1209 if age1 > self.one_week and age2 > self.one_week:
1210 to_be_joined.append(id)
1213 for id in to_be_joined:
1214 addrs = addrs + self.subscribers.in_group(id)
1215 self.subscribers.add_many(addrs)
1216 for id in to_be_joined:
1217 self.bounce_box.remove(self.subscribers.get(id, "bounce-id"))
1218 self.subscribers.remove_group(id)
1220 def remove_empty_groups(self):
1221 for id in self.subscribers.groups()[:]:
1222 if len(self.subscribers.in_group(id)) == 0:
1223 self.subscribers.remove_group(id)
1225 def cleaning_woman(self, now):
1226 if self.subscribers.lock():
1227 self.handle_bounced_groups(now)
1228 self.join_nonbouncing_groups(now)
1229 self.subscribers.save()
1231 class SubscriberDatabase:
1233 def __init__(self, dirname, name):
1235 self.filename = os.path.join(dirname, name)
1236 self.lockname = os.path.join(dirname, "lock")
1241 if os.system("lockfile -l 60 %s" % self.lockname) == 0:
1247 os.remove(self.lockname)
1251 if not self.loaded and not self.dict:
1252 f = open(self.filename, "r")
1253 for line in f.xreadlines():
1254 parts = line.split()
1255 self.dict[parts[0]] = {
1257 "timestamp-created": parts[2],
1258 "timestamp-bounced": parts[3],
1259 "bounce-id": parts[4],
1260 "addresses": parts[5:],
1268 f = open(self.filename + ".new", "w")
1269 for id in self.dict.keys():
1271 f.write("%s " % self.dict[id]["status"])
1272 f.write("%s " % self.dict[id]["timestamp-created"])
1273 f.write("%s " % self.dict[id]["timestamp-bounced"])
1274 f.write("%s " % self.dict[id]["bounce-id"])
1275 f.write("%s\n" % string.join(self.dict[id]["addresses"], " "))
1277 os.remove(self.filename)
1278 os.rename(self.filename + ".new", self.filename)
1281 def get(self, id, attribute):
1283 if self.dict.has_key(id) and self.dict[id].has_key(attribute):
1284 return self.dict[id][attribute]
1287 def set(self, id, attribute, value):
1290 if self.dict.has_key(id) and self.dict[id].has_key(attribute):
1291 self.dict[id][attribute] = value
1293 def add(self, address):
1294 return self.add_many([address])
1296 def add_many(self, addresses):
1299 for addr in addresses[:]:
1300 if addr.find("@") == -1:
1301 info("Address '%s' does not contain an @, ignoring it." % addr)
1302 addresses.remove(addr)
1303 for id in self.dict.keys():
1304 old_ones = self.dict[id]["addresses"]
1305 for addr in addresses:
1307 if x.lower() == addr.lower():
1309 self.dict[id]["addresses"] = old_ones
1310 id = self.new_group()
1313 "timestamp-created": self.timestamp(),
1314 "timestamp-bounced": "0",
1315 "bounce-id": "..notexist..",
1316 "addresses": addresses,
1320 def new_group(self):
1321 keys = self.dict.keys()
1323 keys = map(lambda x: int(x), keys)
1325 return "%d" % (keys[-1] + 1)
1329 def timestamp(self):
1330 return "%.0f" % time.time()
1335 for values in self.dict.values():
1336 list = list + values["addresses"]
1341 return self.dict.keys()
1343 def has_group(self, id):
1345 return self.dict.has_key(id)
1347 def in_group(self, id):
1349 return self.dict[id]["addresses"]
1351 def remove(self, address):
1354 for id in self.dict.keys():
1355 group = self.dict[id]
1356 for x in group["addresses"][:]:
1357 if x.lower() == address.lower():
1358 group["addresses"].remove(x)
1359 if len(group["addresses"]) == 0:
1362 def remove_group(self, id):
1370 def __init__(self, dirname, boxname):
1371 self.boxdir = os.path.join(dirname, boxname)
1372 if not os.path.isdir(self.boxdir):
1373 os.mkdir(self.boxdir, 0700)
1375 def filename(self, id):
1376 return os.path.join(self.boxdir, id)
1378 def add(self, address, message_text):
1379 id = self.make_id(message_text)
1380 filename = self.filename(id)
1381 f = open(filename + ".address", "w")
1384 f = open(filename + ".new", "w")
1385 f.write(message_text)
1387 os.rename(filename + ".new", filename)
1390 def make_id(self, message_text):
1391 return md5sum_as_hex(message_text)
1392 # XXX this might be unnecessarily long
1394 def remove(self, id):
1395 filename = self.filename(id)
1396 if os.path.isfile(filename):
1398 os.remove(filename + ".address")
1401 return os.path.isfile(self.filename(id))
1403 def get_address(self, id):
1404 f = open(self.filename(id) + ".address", "r")
1410 f = open(self.filename(id), "r")
1415 def lockname(self, id):
1416 return self.filename(id) + ".lock"
1419 if os.system("lockfile -l 600 %s" % self.lockname(id)) == 0:
1424 def unlock(self, id):
1426 os.remove(self.lockname(id))
1434 def write(self, str):
1438 log_file_handle = None
1440 global log_file_handle
1441 if log_file_handle is None:
1443 log_file_handle = open(os.path.join(DOTDIR, "logfile.txt"), "a")
1445 log_file_handle = DevNull()
1446 return log_file_handle
1449 tuple = time.localtime(time.time())
1450 return time.strftime("%Y-%m-%d %H:%M:%S", tuple) + " [%d]" % os.getpid()
1456 # No logging to stderr of debug messages. Some MTAs have a limit on how
1457 # much data they accept via stderr and debug logs will fill that quickly.
1459 log_file().write(timestamp() + " " + msg + "\n")
1462 # Log to log file first, in case MTA's stderr buffer fills up and we lose
1465 log_file().write(timestamp() + " " + msg + "\n")
1466 sys.stderr.write(msg + "\n")
1475 sys.stdout.write("""\
1476 Usage: enemies-of-carlotta [options] command
1477 Mailing list manager.
1480 --name=listname@domain
1481 --owner=address@domain
1482 --moderator=address@domain
1483 --subscription=free/moderated
1484 --posting=free/moderated/auto
1486 --ignore-bounce=yes/no
1487 --language=language code or empty
1488 --mail-on-forced-unsubscribe=yes/no
1489 --mail-on-subscription-changes=yes/no
1490 --skip-prefix=string
1491 --domain=domain.name
1492 --smtp-server=domain.name
1508 For more detailed information, please read the enemies-of-carlotta(1)
1514 def no_act_send_mail(sender, recipients, text):
1515 print "NOT SENDING MAIL FOR REAL!"
1516 print "Sender:", sender
1517 print "Recipients:", recipients
1519 print "\n".join(map(lambda s: " " + s, text.split("\n")))
1522 def set_list_options(list, owners, moderators, subscription, posting,
1523 archived, language, ignore_bounce,
1524 mail_on_sub_changes, mail_on_forced_unsub):
1526 list.cp.set("list", "owners", string.join(owners, " "))
1528 list.cp.set("list", "moderators", string.join(moderators, " "))
1529 if subscription != None:
1530 list.cp.set("list", "subscription", subscription)
1532 list.cp.set("list", "posting", posting)
1533 if archived != None:
1534 list.cp.set("list", "archived", archived)
1535 if language != None:
1536 list.cp.set("list", "language", language)
1537 if ignore_bounce != None:
1538 list.cp.set("list", "ignore-bounce", ignore_bounce)
1539 if mail_on_sub_changes != None:
1540 list.cp.set("list", "mail-on-subscription-changes",
1541 mail_on_sub_changes)
1542 if mail_on_forced_unsub != None:
1543 list.cp.set("list", "mail-on-forced-unsubscribe",
1544 mail_on_forced_unsub)
1549 opts, args = getopt.getopt(args, "h",
1558 "mail-on-forced-unsubscribe=",
1559 "mail-on-subscription-changes=",
1587 except getopt.GetoptError, detail:
1588 error("Error parsing command line options (see --help):\n%s" %
1598 ignore_bounce = None
1601 sendmail = "/usr/sbin/sendmail"
1609 mail_on_forced_unsub = None
1610 mail_on_sub_changes = None
1614 for opt, arg in opts:
1617 elif opt == "--owner":
1619 elif opt == "--moderator":
1620 moderators.append(arg)
1621 elif opt == "--subscription":
1623 elif opt == "--posting":
1625 elif opt == "--archived":
1627 elif opt == "--ignore-bounce":
1629 elif opt == "--skip-prefix":
1631 elif opt == "--domain":
1633 elif opt == "--sendmail":
1635 elif opt == "--smtp-server":
1637 elif opt == "--qmqp-server":
1639 elif opt == "--sender":
1641 elif opt == "--recipient":
1643 elif opt == "--language":
1645 elif opt == "--mail-on-forced-unsubscribe":
1646 mail_on_forced_unsub = arg
1647 elif opt == "--mail-on-subscription-changes":
1648 mail_on_sub_changes = arg
1649 elif opt == "--moderate":
1651 elif opt == "--post":
1653 elif opt == "--quiet":
1655 elif opt == "--no-act":
1660 if operation is None:
1661 error("No operation specified, see --help.")
1663 if list_name is None and operation not in ["--incoming", "--help", "-h",
1667 error("%s requires a list name specified with --name" % operation)
1669 if operation in ["--help", "-h"]:
1672 if sender or recipient:
1673 environ = os.environ.copy()
1675 environ["SENDER"] = sender
1677 environ["RECIPIENT"] = recipient
1678 set_environ(environ)
1680 mlm = MailingListManager(DOTDIR, sendmail=sendmail,
1681 smtp_server=smtp_server,
1682 qmqp_server=qmqp_server)
1684 mlm.send_mail = no_act_send_mail
1686 if operation == "--create":
1688 error("You must give at least one list owner with --owner.")
1689 list = mlm.create_list(list_name)
1690 set_list_options(list, owners, moderators, subscription, posting,
1691 archived, language, ignore_bounce,
1692 mail_on_sub_changes, mail_on_forced_unsub)
1694 debug("Created list %s." % list_name)
1695 elif operation == "--destroy":
1696 shutil.rmtree(os.path.join(DOTDIR, list_name))
1697 debug("Removed list %s." % list_name)
1698 elif operation == "--edit":
1699 list = mlm.open_list(list_name)
1700 set_list_options(list, owners, moderators, subscription, posting,
1701 archived, language, ignore_bounce,
1702 mail_on_sub_changes, mail_on_forced_unsub)
1704 elif operation == "--subscribe":
1705 list = mlm.open_list(list_name)
1706 list.subscribers.lock()
1707 for address in args:
1708 if address.find("@") == -1:
1709 error("Address '%s' does not contain an @." % address)
1710 list.subscribers.add(address)
1711 debug("Added subscriber <%s>." % address)
1712 list.subscribers.save()
1713 elif operation == "--unsubscribe":
1714 list = mlm.open_list(list_name)
1715 list.subscribers.lock()
1716 for address in args:
1717 list.subscribers.remove(address)
1718 debug("Removed subscriber <%s>." % address)
1719 list.subscribers.save()
1720 elif operation == "--list":
1721 list = mlm.open_list(list_name)
1722 for address in list.subscribers.get_all():
1724 elif operation == "--is-list":
1725 if mlm.is_list(list_name, skip_prefix, domain):
1726 debug("Indeed a mailing list: <%s>" % list_name)
1728 debug("Not a mailing list: <%s>" % list_name)
1730 elif operation == "--incoming":
1731 mlm.incoming_message(skip_prefix, domain, moderate, post)
1732 elif operation == "--cleaning-woman":
1733 mlm.cleaning_woman()
1734 elif operation == "--show-lists":
1735 listnames = mlm.get_lists()
1737 for listname in listnames:
1739 elif operation == "--get":
1740 list = mlm.open_list(list_name)
1742 print list.cp.get("list", name)
1743 elif operation == "--set":
1744 list = mlm.open_list(list_name)
1747 error("Error: --set arguments must be of form name=value")
1748 name, value = arg.split("=", 1)
1749 list.cp.set("list", name, value)
1751 elif operation == "--version":
1752 print "EoC, version %s" % VERSION
1753 print "Home page: http://liw.iki.fi/liw/eoc/"
1755 error("Internal error: unimplemented option <%s>." % operation)
1757 if __name__ == "__main__":
1760 except EocException, detail:
1761 error("Error: %s" % detail)