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 md5.new(s).hexdigest()
85 def set_environ(new_environ):
89 def get_from_environ(key):
96 return env[key].lower()
97 raise MissingEnvironmentVariable(key)
101 """A parser for incoming e-mail addresses."""
103 def __init__(self, lists):
104 self.set_lists(lists)
105 self.set_skip_prefix(None)
106 self.set_forced_domain(None)
108 def set_lists(self, lists):
109 """Set the list of canonical list names we should know about."""
112 def set_skip_prefix(self, skip_prefix):
113 """Set the prefix to be removed from an address."""
114 self.skip_prefix = skip_prefix
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
120 def clean(self, address):
121 """Remove cruft from the address and convert the rest to lower case."""
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()
131 def split_address(self, address):
132 """Split an address to a local part and a domain."""
133 parts = address.lower().split("@", 1)
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.
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)
147 if addr_domain != list_domain:
150 if addr_local.lower() == list_local.lower():
154 if addr_local[:n] != list_local or addr_local[n] != "-":
157 return addr_local[n+1:].split("-")
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.
166 def parse(self, address):
167 address = self.clean(address)
168 for listname in self.lists:
169 parts = self.additional_address_parts(address, listname)
173 return listname, parts
174 elif parts[0] in HASH_COMMANDS:
176 raise BadCommandAddress(address)
177 return listname, parts
178 elif parts[0] in COMMANDS:
179 return listname, parts
181 raise UnknownList(address)
184 class MailingListManager:
186 def __init__(self, dotdir, sendmail="/usr/sbin/sendmail", lists=[],
187 smtp_server=None, qmqp_server=None):
189 self.sendmail = sendmail
190 self.smtp_server = smtp_server
191 self.qmqp_server = qmqp_server
194 self.secret = self.make_and_read_secret()
197 lists = filter(lambda s: "@" in s, os.listdir(dotdir))
198 self.set_lists(lists)
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 + \
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)
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")
226 f = open(secret_name, "w")
230 f = open(secret_name, "r")
235 # Load the plugins from DOTDIR/plugins/*.py.
236 def load_plugins(self):
239 dirname = os.path.join(DOTDIR, "plugins")
241 plugins = os.listdir(dirname)
246 plugins = map(os.path.splitext, plugins)
247 plugins = filter(lambda p: p[1] == ".py", plugins)
248 plugins = map(lambda p: p[0], 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))
255 if module.PLUGIN_INTERFACE_VERSION == PLUGIN_INTERFACE_VERSION:
256 self.plugins.append(module)
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,)):
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)
276 self.lists = map(lambda t: t[1], temp)
278 # Return the list of listnames.
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("="), "@")
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
290 # Compute the verification checksum for an address.
291 def compute_hash(self, address):
292 return md5sum_as_hex(address + self.secret)
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"],
300 correct = self.compute_hash(address)
301 return correct != hash
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).
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)
315 dict = { "name": listname }
318 dict["command"] = "post"
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:
327 if self.signature_is_bad(dict, hash):
328 raise BadSignature(address)
332 # Does an address refer to a mailing list?
333 def is_list(self, name, skip_prefix=None, domain=None):
335 self.parse_recipient_address(name, skip_prefix, domain)
336 except BadCommandAddress:
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)
351 # Open an existing list.
352 def open_list(self, name):
353 if self.is_list(name):
354 return self.open_list_exact(name)
357 for list in self.lists:
358 if list[:len(x)] == x:
359 return self.open_list_exact(list)
360 raise ListDoesNotExist(name)
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)
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"),
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"])
382 # Clean up bouncing address and do other janitorial work for all lists.
383 def cleaning_woman(self, send_mail=None):
385 for listname in self.lists:
386 list = self.open_list_exact(listname)
388 list.send_mail = send_mail
389 list.cleaning_woman(now)
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"))))
398 smtp = smtplib.SMTP(self.smtp_server)
399 smtp.sendmail(envelope_sender, recipients, text)
401 elif self.qmqp_server:
402 q = qmqp.QMQP(self.qmqp_server)
403 q.sendmail(envelope_sender, recipients, text)
406 recipients = string.join(recipients, " ")
407 f = os.popen("%s -oi -f '%s' %s" %
415 debug("send_mail: no recipients, not sending")
421 posting_opts = ["auto", "free", "moderated"]
423 def __init__(self, mlm, name):
427 self.cp = ConfigParser.ConfigParser()
428 self.cp.add_section("list")
429 self.cp.set("list", "owners", "")
430 self.cp.set("list", "moderators", "")
431 self.cp.set("list", "subscription", "free")
432 self.cp.set("list", "posting", "free")
433 self.cp.set("list", "archived", "no")
434 self.cp.set("list", "mail-on-subscription-changes", "no")
435 self.cp.set("list", "mail-on-forced-unsubscribe", "no")
436 self.cp.set("list", "ignore-bounce", "no")
437 self.cp.set("list", "language", "")
438 self.cp.set("list", "pristine-headers", "")
440 self.dirname = os.path.join(self.mlm.dotdir, name)
442 self.cp.read(self.mkname("config"))
444 self.subscribers = SubscriberDatabase(self.dirname, "subscribers")
445 self.moderation_box = MessageBox(self.dirname, "moderation-box")
446 self.subscription_box = MessageBox(self.dirname, "subscription-box")
447 self.bounce_box = MessageBox(self.dirname, "bounce-box")
449 def make_listdir(self):
450 if not os.path.isdir(self.dirname):
451 os.mkdir(self.dirname, 0700)
453 f = open(self.mkname("subscribers"), "w")
456 def mkname(self, relative):
457 return os.path.join(self.dirname, relative)
459 def save_config(self):
460 f = open(self.mkname("config"), "w")
464 def read_stdin(self):
465 data = sys.stdin.read()
466 # Skip Unix mbox "From " mail start indicator
467 if data[:5] == "From ":
468 data = string.split(data, "\n", 1)[1]
471 def invent_boundary(self):
472 return "%s/%s" % (md5sum_as_hex(str(time.time())),
473 md5sum_as_hex(self.name))
475 def command_address(self, command):
476 local_part, domain = self.name.split("@")
477 return "%s-%s@%s" % (local_part, command, domain)
479 def signed_address(self, command, id):
480 unsigned = self.command_address("%s-%s" % (command, id))
481 hash = self.mlm.compute_hash(unsigned)
482 return self.command_address("%s-%s-%s" % (command, id, hash))
485 return self.command_address("ignore")
487 def nice_7bit(self, str):
489 if (ord(c) < 32 and not c.isspace()) or ord(c) >= 127:
493 def mime_encode_headers(self, text):
495 headers, body = text.split("\n\n", 1)
498 for line in headers.split("\n"):
499 if line[0].isspace():
506 if self.nice_7bit(header):
507 headers.append(header)
510 name, content = header.split(": ", 1)
512 name, content = header.split(":", 1)
513 hdr = email.Header.Header(content, "utf-8")
514 headers.append(name + ": " + hdr.encode())
516 return "\n".join(headers) + "\n\n" + body
518 error("Cannot MIME encode header, using original ones, sorry")
521 def template(self, template_name, dict):
522 lang = self.cp.get("list", "language")
524 template_name_lang = template_name + "." + lang
526 template_name_lang = template_name
528 if not dict.has_key("list"):
529 dict["list"] = self.name
530 dict["local"], dict["domain"] = self.name.split("@")
531 if not dict.has_key("list"):
532 dict["list"] = self.name
534 for dir in [os.path.join(self.dirname, "templates")] + TEMPLATE_DIRS:
535 pathname = os.path.join(dir, template_name_lang)
536 if not os.path.exists(pathname):
537 pathname = os.path.join(dir, template_name)
538 if os.path.exists(pathname):
539 f = open(pathname, "r")
544 raise MissingTemplate(template_name)
546 def send_template(self, envelope_sender, sender, recipients,
547 template_name, dict):
548 dict["From"] = "EoC <%s>" % sender
549 dict["To"] = string.join(recipients, ", ")
550 text = self.template(template_name, dict)
553 if self.cp.get("list", "pristine-headers") != "yes":
554 text = self.mime_encode_headers(text)
555 self.mlm.send_mail(envelope_sender, recipients, text)
557 def send_info_message(self, recipients, template_name, dict):
558 self.send_template(self.command_address("ignore"),
559 self.command_address("help"),
565 return self.cp.get("list", "owners").split()
567 def moderators(self):
568 return self.cp.get("list", "moderators").split()
570 def is_list_owner(self, address):
571 return address in self.owners()
574 self.send_info_message([get_from_environ("SENDER")], "help", {})
577 recipient = get_from_environ("SENDER")
578 if self.is_list_owner(recipient):
579 addr_list = self.subscribers.get_all()
580 addr_text = string.join(addr_list, "\n")
581 self.send_info_message([recipient], "list",
583 "addresses": addr_text,
584 "count": len(addr_list),
587 self.send_info_message([recipient], "list-sorry", {})
589 def obey_setlist(self, origmail):
590 recipient = get_from_environ("SENDER")
591 if self.is_list_owner(recipient):
592 id = self.moderation_box.add(recipient, origmail)
593 if self.parse_setlist_addresses(origmail) == None:
594 self.send_bad_addresses_in_setlist(id)
595 self.moderation_box.remove(id)
597 confirm = self.signed_address("setlistyes", id)
598 self.send_info_message(self.owners(), "setlist-confirm",
601 "origmail": origmail,
602 "boundary": self.invent_boundary(),
606 self.send_info_message([recipient], "setlist-sorry", {})
608 def obey_setlistsilently(self, origmail):
609 recipient = get_from_environ("SENDER")
610 if self.is_list_owner(recipient):
611 id = self.moderation_box.add(recipient, origmail)
612 if self.parse_setlist_addresses(origmail) == None:
613 self.send_bad_addresses_in_setlist(id)
614 self.moderation_box.remove(id)
616 confirm = self.signed_address("setlistsilentyes", id)
617 self.send_info_message(self.owners(), "setlist-confirm",
620 "origmail": origmail,
621 "boundary": self.invent_boundary(),
624 self.info_message([recipient], "setlist-sorry", {})
626 def parse_setlist_addresses(self, text):
627 body = text.split("\n\n", 1)[1]
628 lines = body.split("\n")
629 lines = filter(lambda line: line != "", lines)
630 badlines = filter(lambda line: "@" not in line, lines)
636 def send_bad_addresses_in_setlist(self, id):
637 addr = self.moderation_box.get_address(id)
638 origmail = self.moderation_box.get(id)
639 self.send_info_message([addr], "setlist-badlist",
641 "origmail": origmail,
642 "boundary": self.invent_boundary(),
646 def obey_setlistyes(self, dict):
647 if self.moderation_box.has(dict["id"]):
648 text = self.moderation_box.get(dict["id"])
649 addresses = self.parse_setlist_addresses(text)
650 if addresses == None:
651 self.send_bad_addresses_in_setlist(id)
653 removed_subscribers = []
654 self.subscribers.lock()
655 old = self.subscribers.get_all()
657 if address.lower() not in map(string.lower, addresses):
658 self.subscribers.remove(address)
659 removed_subscribers.append(address)
662 if x.lower() == address.lower():
664 self.subscribers.add_many(addresses)
665 self.subscribers.save()
667 for recipient in addresses:
668 self.send_info_message([recipient], "sub-welcome", {})
669 for recipient in removed_subscribers:
670 self.send_info_message([recipient], "unsub-goodbye", {})
671 self.send_info_message(self.owners(), "setlist-done", {})
673 self.moderation_box.remove(dict["id"])
675 def obey_setlistsilentyes(self, dict):
676 if self.moderation_box.has(dict["id"]):
677 text = self.moderation_box.get(dict["id"])
678 addresses = self.parse_setlist_addresses(text)
679 if addresses == None:
680 self.send_bad_addresses_in_setlist(id)
682 self.subscribers.lock()
683 old = self.subscribers.get_all()
685 if address not in addresses:
686 self.subscribers.remove(address)
688 addresses.remove(address)
689 self.subscribers.add_many(addresses)
690 self.subscribers.save()
691 self.send_info_message(self.owners(), "setlist-done", {})
693 self.moderation_box.remove(dict["id"])
695 def obey_owner(self, text):
696 sender = get_from_environ("SENDER")
697 recipients = self.cp.get("list", "owners").split()
698 self.mlm.send_mail(sender, recipients, text)
700 def obey_subscribe_or_unsubscribe(self, dict, template_name, command,
703 requester = get_from_environ("SENDER")
704 subscriber = dict["sender"]
706 subscriber = requester
707 if subscriber.find("@") == -1:
708 info("Trying to (un)subscribe address without @: %s" % subscriber)
710 if self.cp.get("list", "ignore-bounce") == "yes":
711 info("Will not (un)subscribe address: %s from static list" %subscriber)
713 if requester in self.owners():
714 confirmers = self.owners()
716 confirmers = [subscriber]
718 id = self.subscription_box.add(subscriber, origmail)
719 confirm = self.signed_address(command, id)
720 self.send_info_message(confirmers, template_name,
723 "origmail": origmail,
724 "boundary": self.invent_boundary(),
727 def obey_subscribe(self, dict, origmail):
728 self.obey_subscribe_or_unsubscribe(dict, "sub-confirm", "subyes",
731 def obey_unsubscribe(self, dict, origmail):
732 self.obey_subscribe_or_unsubscribe(dict, "unsub-confirm", "unsubyes",
735 def obey_subyes(self, dict):
736 if self.subscription_box.has(dict["id"]):
737 if self.cp.get("list", "subscription") == "free":
738 recipient = self.subscription_box.get_address(dict["id"])
739 self.subscribers.lock()
740 self.subscribers.add(recipient)
741 self.subscribers.save()
742 sender = self.command_address("help")
743 self.send_template(self.ignore(), sender, [recipient],
745 self.subscription_box.remove(dict["id"])
746 if self.cp.get("list", "mail-on-subscription-changes")=="yes":
747 self.send_info_message(self.owners(),
748 "sub-owner-notification",
750 "address": recipient,
753 recipients = self.cp.get("list", "owners").split()
754 confirm = self.signed_address("subapprove", dict["id"])
755 deny = self.signed_address("subreject", dict["id"])
756 subscriber = self.subscription_box.get_address(dict["id"])
757 origmail = self.subscription_box.get(dict["id"])
758 self.send_template(self.ignore(), deny, recipients,
763 "subscriber": subscriber,
764 "origmail": origmail,
765 "boundary": self.invent_boundary(),
767 recipient = self.subscription_box.get_address(dict["id"])
768 self.send_info_message([recipient], "sub-wait", {})
770 def obey_subapprove(self, dict):
771 if self.subscription_box.has(dict["id"]):
772 recipient = self.subscription_box.get_address(dict["id"])
773 self.subscribers.lock()
774 self.subscribers.add(recipient)
775 self.subscribers.save()
776 self.send_info_message([recipient], "sub-welcome", {})
777 self.subscription_box.remove(dict["id"])
778 if self.cp.get("list", "mail-on-subscription-changes")=="yes":
779 self.send_info_message(self.owners(), "sub-owner-notification",
781 "address": recipient,
784 def obey_subreject(self, dict):
785 if self.subscription_box.has(dict["id"]):
786 recipient = self.subscription_box.get_address(dict["id"])
787 self.send_info_message([recipient], "sub-reject", {})
788 self.subscription_box.remove(dict["id"])
790 def obey_unsubyes(self, dict):
791 if self.subscription_box.has(dict["id"]):
792 recipient = self.subscription_box.get_address(dict["id"])
793 self.subscribers.lock()
794 self.subscribers.remove(recipient)
795 self.subscribers.save()
796 self.send_info_message([recipient], "unsub-goodbye", {})
797 self.subscription_box.remove(dict["id"])
798 if self.cp.get("list", "mail-on-subscription-changes")=="yes":
799 self.send_info_message(self.owners(),
800 "unsub-owner-notification",
802 "address": recipient,
805 def store_into_archive(self, text):
806 if self.cp.get("list", "archived") == "yes":
807 archdir = os.path.join(self.dirname, "archive")
808 if not os.path.exists(archdir):
809 os.mkdir(archdir, 0700)
810 id = md5sum_as_hex(text)
811 f = open(os.path.join(archdir, id), "w")
815 def list_headers(self):
816 local, domain = self.name.split("@")
818 list.append("List-Id: <%s.%s>" % (local, domain))
819 list.append("List-Help: <mailto:%s-help@%s>" % (local, domain))
820 list.append("List-Unsubscribe: <mailto:%s-unsubscribe@%s>" %
822 list.append("List-Subscribe: <mailto:%s-subscribe@%s>" %
824 list.append("List-Post: <mailto:%s@%s>" % (local, domain))
825 list.append("List-Owner: <mailto:%s-owner@%s>" % (local, domain))
826 list.append("Precedence: bulk");
827 return string.join(list, "\n") + "\n"
829 def read_file(self, basename):
831 f = open(os.path.join(self.dirname, basename), "r")
838 def headers_to_add(self):
839 headers_to_add = self.read_file("headers-to-add").rstrip()
841 return headers_to_add + "\n"
845 def remove_some_headers(self, mail, headers_to_remove):
846 endpos = mail.find("\n\n")
848 endpos = mail.find("\n\r\n")
851 headers = mail[:endpos].split("\n")
854 headers_to_remove = [x.lower() for x in headers_to_remove]
857 add_continuation_lines = 0
859 for header in headers:
860 if header[0] in [' ','\t']:
861 # this is a continuation line
862 if add_continuation_lines:
863 remaining.append(header)
865 pos = header.find(":")
867 # malformed message, try to remove the junk
868 add_continuation_lines = 0
870 name = header[:pos].lower()
871 if name in headers_to_remove:
872 add_continuation_lines = 0
874 add_continuation_lines = 1
875 remaining.append(header)
877 return "\n".join(remaining) + body
879 def headers_to_remove(self, text):
880 headers_to_remove = self.read_file("headers-to-remove").split("\n")
881 headers_to_remove = map(lambda s: s.strip().lower(),
883 return self.remove_some_headers(text, headers_to_remove)
885 def append_footer(self, text):
886 if "base64" in text or "BASE64" in text:
888 for line in StringIO.StringIO(text):
889 if line.lower().startswith("content-transfer-encoding:") and \
890 "base64" in line.lower():
892 return text + self.template("footer", {})
894 def send_mail_to_subscribers(self, text):
895 text = self.headers_to_add() + self.list_headers() + \
896 self.headers_to_remove(text)
897 text = self.append_footer(text)
898 text, = self.mlm.call_plugins("send_mail_to_subscribers_hook",
900 if have_email_module and \
901 self.cp.get("list", "pristine-headers") != "yes":
902 text = self.mime_encode_headers(text)
903 self.store_into_archive(text)
904 for group in self.subscribers.groups():
905 bounce = self.signed_address("bounce", group)
906 addresses = self.subscribers.in_group(group)
907 self.mlm.send_mail(bounce, addresses, text)
909 def post_into_moderate(self, poster, dict, text):
910 id = self.moderation_box.add(poster, text)
911 recipients = self.moderators()
913 recipients = self.owners()
915 confirm = self.signed_address("approve", id)
916 deny = self.signed_address("reject", id)
917 self.send_template(self.ignore(), deny, recipients, "msg-moderate",
922 "boundary": self.invent_boundary(),
924 self.send_info_message([poster], "msg-wait", {})
926 def should_be_moderated(self, posting, poster):
927 if posting == "moderated":
929 if posting == "auto":
930 if poster.lower() not in \
931 map(string.lower, self.subscribers.get_all()):
935 def obey_post(self, dict, text):
936 if dict.has_key("force-moderation") and dict["force-moderation"]:
940 if dict.has_key("force-posting") and dict["force-posting"]:
944 posting = self.cp.get("list", "posting")
945 if posting not in self.posting_opts:
946 error("You have a weird 'posting' config. Please, review it")
947 poster = get_from_environ("SENDER")
949 self.post_into_moderate(poster, dict, text)
951 self.send_mail_to_subscribers(text)
952 elif self.should_be_moderated(posting, poster):
953 self.post_into_moderate(poster, dict, text)
955 self.send_mail_to_subscribers(text)
957 def obey_approve(self, dict):
958 if self.moderation_box.lock(dict["id"]):
959 if self.moderation_box.has(dict["id"]):
960 text = self.moderation_box.get(dict["id"])
961 self.send_mail_to_subscribers(text)
962 self.moderation_box.remove(dict["id"])
963 self.moderation_box.unlock(dict["id"])
965 def obey_reject(self, dict):
966 if self.moderation_box.lock(dict["id"]):
967 if self.moderation_box.has(dict["id"]):
968 self.moderation_box.remove(dict["id"])
969 self.moderation_box.unlock(dict["id"])
971 def split_address_list(self, addrs):
974 userpart, domain = addr.split("@")
975 if domains.has_key(domain):
976 domains[domain].append(addr)
978 domains[domain] = [addr]
980 if len(domains.keys()) == 1:
982 result.append([addr])
984 result = domains.values()
987 def obey_bounce(self, dict, text):
988 if self.subscribers.has_group(dict["id"]):
989 self.subscribers.lock()
990 addrs = self.subscribers.in_group(dict["id"])
992 if self.cp.get("list", "ignore-bounce") == "yes":
993 info("Address <%s> bounced, ignoring bounce as configured." %
995 self.subscribers.unlock()
997 debug("Address <%s> bounced, setting state to bounce." %
999 bounce_id = self.bounce_box.add(addrs[0], text[:4096])
1000 self.subscribers.set(dict["id"], "status", "bounced")
1001 self.subscribers.set(dict["id"], "timestamp-bounced",
1003 self.subscribers.set(dict["id"], "bounce-id",
1006 debug("Group %s bounced, splitting." % dict["id"])
1007 for new_addrs in self.split_address_list(addrs):
1008 self.subscribers.add_many(new_addrs)
1009 self.subscribers.remove_group(dict["id"])
1010 self.subscribers.save()
1012 debug("Ignoring bounce, group %s doesn't exist (anymore?)." %
1015 def obey_probe(self, dict, text):
1017 if self.subscribers.has_group(id):
1018 self.subscribers.lock()
1019 if self.subscribers.get(id, "status") == "probed":
1020 self.subscribers.set(id, "status", "probebounced")
1021 self.subscribers.save()
1023 def obey(self, dict):
1024 text = self.read_stdin()
1026 if dict["command"] in ["help", "list", "subscribe", "unsubscribe",
1027 "subyes", "subapprove", "subreject",
1028 "unsubyes", "post", "approve"]:
1029 sender = get_from_environ("SENDER")
1031 debug("Ignoring bounce message for %s command." %
1035 if dict["command"] == "help":
1037 elif dict["command"] == "list":
1039 elif dict["command"] == "owner":
1040 self.obey_owner(text)
1041 elif dict["command"] == "subscribe":
1042 self.obey_subscribe(dict, text)
1043 elif dict["command"] == "unsubscribe":
1044 self.obey_unsubscribe(dict, text)
1045 elif dict["command"] == "subyes":
1046 self.obey_subyes(dict)
1047 elif dict["command"] == "subapprove":
1048 self.obey_subapprove(dict)
1049 elif dict["command"] == "subreject":
1050 self.obey_subreject(dict)
1051 elif dict["command"] == "unsubyes":
1052 self.obey_unsubyes(dict)
1053 elif dict["command"] == "post":
1054 self.obey_post(dict, text)
1055 elif dict["command"] == "approve":
1056 self.obey_approve(dict)
1057 elif dict["command"] == "reject":
1058 self.obey_reject(dict)
1059 elif dict["command"] == "bounce":
1060 self.obey_bounce(dict, text)
1061 elif dict["command"] == "probe":
1062 self.obey_probe(dict, text)
1063 elif dict["command"] == "setlist":
1064 self.obey_setlist(text)
1065 elif dict["command"] == "setlistsilently":
1066 self.obey_setlistsilently(text)
1067 elif dict["command"] == "setlistyes":
1068 self.obey_setlistyes(dict)
1069 elif dict["command"] == "setlistsilentyes":
1070 self.obey_setlistsilentyes(dict)
1071 elif dict["command"] == "ignore":
1074 def get_bounce_text(self, id):
1075 bounce_id = self.subscribers.get(id, "bounce-id")
1076 if self.bounce_box.has(bounce_id):
1077 bounce_text = self.bounce_box.get(bounce_id)
1078 bounce_text = string.join(map(lambda s: "> " + s + "\n",
1079 bounce_text.split("\n")), "")
1081 bounce_text = "Bounce message not available."
1084 one_week = 7.0 * 24.0 * 60.0 * 60.0
1086 def handle_bounced_groups(self, now):
1087 for id in self.subscribers.groups():
1088 status = self.subscribers.get(id, "status")
1089 t = float(self.subscribers.get(id, "timestamp-bounced"))
1090 if status == "bounced":
1091 if now - t > self.one_week:
1092 sender = self.signed_address("probe", id)
1093 recipients = self.subscribers.in_group(id)
1094 self.send_template(sender, sender, recipients,
1096 "bounce": self.get_bounce_text(id),
1097 "boundary": self.invent_boundary(),
1099 self.subscribers.set(id, "status", "probed")
1100 elif status == "probed":
1101 if now - t > 2 * self.one_week:
1102 debug(("Cleaning woman: probe didn't bounce " +
1103 "for group <%s>, setting status to ok.") % id)
1104 self.subscribers.set(id, "status", "ok")
1105 self.bounce_box.remove(
1106 self.subscribers.get(id, "bounce-id"))
1107 elif status == "probebounced":
1108 sender = self.command_address("help")
1109 for address in self.subscribers.in_group(id):
1110 if self.cp.get("list", "mail-on-forced-unsubscribe") \
1112 self.send_template(sender, sender,
1114 "bounce-owner-notification",
1117 "bounce": self.get_bounce_text(id),
1118 "boundary": self.invent_boundary(),
1121 self.bounce_box.remove(
1122 self.subscribers.get(id, "bounce-id"))
1123 self.subscribers.remove(address)
1124 debug("Cleaning woman: removing <%s>." % address)
1125 self.send_template(sender, sender, [address],
1126 "bounce-goodbye", {})
1128 def join_nonbouncing_groups(self, now):
1130 for id in self.subscribers.groups():
1131 status = self.subscribers.get(id, "status")
1132 age1 = now - float(self.subscribers.get(id, "timestamp-bounced"))
1133 age2 = now - float(self.subscribers.get(id, "timestamp-created"))
1135 if age1 > self.one_week and age2 > self.one_week:
1136 to_be_joined.append(id)
1139 for id in to_be_joined:
1140 addrs = addrs + self.subscribers.in_group(id)
1141 self.subscribers.add_many(addrs)
1142 for id in to_be_joined:
1143 self.bounce_box.remove(self.subscribers.get(id, "bounce-id"))
1144 self.subscribers.remove_group(id)
1146 def remove_empty_groups(self):
1147 for id in self.subscribers.groups()[:]:
1148 if len(self.subscribers.in_group(id)) == 0:
1149 self.subscribers.remove_group(id)
1151 def cleaning_woman(self, now):
1152 if self.subscribers.lock():
1153 self.handle_bounced_groups(now)
1154 self.join_nonbouncing_groups(now)
1155 self.subscribers.save()
1157 class SubscriberDatabase:
1159 def __init__(self, dirname, name):
1161 self.filename = os.path.join(dirname, name)
1162 self.lockname = os.path.join(dirname, "lock")
1167 if os.system("lockfile -l 60 %s" % self.lockname) == 0:
1173 os.remove(self.lockname)
1177 if not self.loaded and not self.dict:
1178 f = open(self.filename, "r")
1179 for line in f.xreadlines():
1180 parts = line.split()
1181 self.dict[parts[0]] = {
1183 "timestamp-created": parts[2],
1184 "timestamp-bounced": parts[3],
1185 "bounce-id": parts[4],
1186 "addresses": parts[5:],
1194 f = open(self.filename + ".new", "w")
1195 for id in self.dict.keys():
1197 f.write("%s " % self.dict[id]["status"])
1198 f.write("%s " % self.dict[id]["timestamp-created"])
1199 f.write("%s " % self.dict[id]["timestamp-bounced"])
1200 f.write("%s " % self.dict[id]["bounce-id"])
1201 f.write("%s\n" % string.join(self.dict[id]["addresses"], " "))
1203 os.remove(self.filename)
1204 os.rename(self.filename + ".new", self.filename)
1207 def get(self, id, attribute):
1209 if self.dict.has_key(id) and self.dict[id].has_key(attribute):
1210 return self.dict[id][attribute]
1213 def set(self, id, attribute, value):
1216 if self.dict.has_key(id) and self.dict[id].has_key(attribute):
1217 self.dict[id][attribute] = value
1219 def add(self, address):
1220 return self.add_many([address])
1222 def add_many(self, addresses):
1225 for addr in addresses[:]:
1226 if addr.find("@") == -1:
1227 info("Address '%s' does not contain an @, ignoring it." % addr)
1228 addresses.remove(addr)
1229 for id in self.dict.keys():
1230 old_ones = self.dict[id]["addresses"]
1231 for addr in addresses:
1233 if x.lower() == addr.lower():
1235 self.dict[id]["addresses"] = old_ones
1236 id = self.new_group()
1239 "timestamp-created": self.timestamp(),
1240 "timestamp-bounced": "0",
1241 "bounce-id": "..notexist..",
1242 "addresses": addresses,
1246 def new_group(self):
1247 keys = self.dict.keys()
1249 keys = map(lambda x: int(x), keys)
1251 return "%d" % (keys[-1] + 1)
1255 def timestamp(self):
1256 return "%.0f" % time.time()
1261 for values in self.dict.values():
1262 list = list + values["addresses"]
1267 return self.dict.keys()
1269 def has_group(self, id):
1271 return self.dict.has_key(id)
1273 def in_group(self, id):
1275 return self.dict[id]["addresses"]
1277 def remove(self, address):
1280 for id in self.dict.keys():
1281 group = self.dict[id]
1282 for x in group["addresses"][:]:
1283 if x.lower() == address.lower():
1284 group["addresses"].remove(x)
1285 if len(group["addresses"]) == 0:
1288 def remove_group(self, id):
1296 def __init__(self, dirname, boxname):
1297 self.boxdir = os.path.join(dirname, boxname)
1298 if not os.path.isdir(self.boxdir):
1299 os.mkdir(self.boxdir, 0700)
1301 def filename(self, id):
1302 return os.path.join(self.boxdir, id)
1304 def add(self, address, message_text):
1305 id = self.make_id(message_text)
1306 filename = self.filename(id)
1307 f = open(filename + ".address", "w")
1310 f = open(filename + ".new", "w")
1311 f.write(message_text)
1313 os.rename(filename + ".new", filename)
1316 def make_id(self, message_text):
1317 return md5sum_as_hex(message_text)
1318 # XXX this might be unnecessarily long
1320 def remove(self, id):
1321 filename = self.filename(id)
1322 if os.path.isfile(filename):
1324 os.remove(filename + ".address")
1327 return os.path.isfile(self.filename(id))
1329 def get_address(self, id):
1330 f = open(self.filename(id) + ".address", "r")
1336 f = open(self.filename(id), "r")
1341 def lockname(self, id):
1342 return self.filename(id) + ".lock"
1345 if os.system("lockfile -l 600 %s" % self.lockname(id)) == 0:
1350 def unlock(self, id):
1352 os.remove(self.lockname(id))
1360 def write(self, str):
1364 log_file_handle = None
1366 global log_file_handle
1367 if log_file_handle is None:
1369 log_file_handle = open(os.path.join(DOTDIR, "logfile.txt"), "a")
1371 log_file_handle = DevNull()
1372 return log_file_handle
1375 tuple = time.localtime(time.time())
1376 return time.strftime("%Y-%m-%d %H:%M:%S", tuple) + " [%d]" % os.getpid()
1382 # No logging to stderr of debug messages. Some MTAs have a limit on how
1383 # much data they accept via stderr and debug logs will fill that quickly.
1385 log_file().write(timestamp() + " " + msg + "\n")
1388 # Log to log file first, in case MTA's stderr buffer fills up and we lose
1391 log_file().write(timestamp() + " " + msg + "\n")
1392 sys.stderr.write(msg + "\n")
1401 sys.stdout.write("""\
1402 Usage: enemies-of-carlotta [options] command
1403 Mailing list manager.
1406 --name=listname@domain
1407 --owner=address@domain
1408 --moderator=address@domain
1409 --subscription=free/moderated
1410 --posting=free/moderated/auto
1412 --ignore-bounce=yes/no
1413 --language=language code or empty
1414 --mail-on-forced-unsubscribe=yes/no
1415 --mail-on-subscription-changes=yes/no
1416 --skip-prefix=string
1417 --domain=domain.name
1418 --smtp-server=domain.name
1434 For more detailed information, please read the enemies-of-carlotta(1)
1440 def no_act_send_mail(sender, recipients, text):
1441 print "NOT SENDING MAIL FOR REAL!"
1442 print "Sender:", sender
1443 print "Recipients:", recipients
1445 print "\n".join(map(lambda s: " " + s, text.split("\n")))
1448 def set_list_options(list, owners, moderators, subscription, posting,
1449 archived, language, ignore_bounce,
1450 mail_on_sub_changes, mail_on_forced_unsub):
1452 list.cp.set("list", "owners", string.join(owners, " "))
1454 list.cp.set("list", "moderators", string.join(moderators, " "))
1455 if subscription != None:
1456 list.cp.set("list", "subscription", subscription)
1458 list.cp.set("list", "posting", posting)
1459 if archived != None:
1460 list.cp.set("list", "archived", archived)
1461 if language != None:
1462 list.cp.set("list", "language", language)
1463 if ignore_bounce != None:
1464 list.cp.set("list", "ignore-bounce", ignore_bounce)
1465 if mail_on_sub_changes != None:
1466 list.cp.set("list", "mail-on-subscription-changes",
1467 mail_on_sub_changes)
1468 if mail_on_forced_unsub != None:
1469 list.cp.set("list", "mail-on-forced-unsubscribe",
1470 mail_on_forced_unsub)
1475 opts, args = getopt.getopt(args, "h",
1484 "mail-on-forced-unsubscribe=",
1485 "mail-on-subscription-changes=",
1513 except getopt.GetoptError, detail:
1514 error("Error parsing command line options (see --help):\n%s" %
1524 ignore_bounce = None
1527 sendmail = "/usr/sbin/sendmail"
1535 mail_on_forced_unsub = None
1536 mail_on_sub_changes = None
1540 for opt, arg in opts:
1543 elif opt == "--owner":
1545 elif opt == "--moderator":
1546 moderators.append(arg)
1547 elif opt == "--subscription":
1549 elif opt == "--posting":
1551 elif opt == "--archived":
1553 elif opt == "--ignore-bounce":
1555 elif opt == "--skip-prefix":
1557 elif opt == "--domain":
1559 elif opt == "--sendmail":
1561 elif opt == "--smtp-server":
1563 elif opt == "--qmqp-server":
1565 elif opt == "--sender":
1567 elif opt == "--recipient":
1569 elif opt == "--language":
1571 elif opt == "--mail-on-forced-unsubscribe":
1572 mail_on_forced_unsub = arg
1573 elif opt == "--mail-on-subscription-changes":
1574 mail_on_sub_changes = arg
1575 elif opt == "--moderate":
1577 elif opt == "--post":
1579 elif opt == "--quiet":
1581 elif opt == "--no-act":
1586 if operation is None:
1587 error("No operation specified, see --help.")
1589 if list_name is None and operation not in ["--incoming", "--help", "-h",
1593 error("%s requires a list name specified with --name" % operation)
1595 if operation in ["--help", "-h"]:
1598 if sender or recipient:
1599 environ = os.environ.copy()
1601 environ["SENDER"] = sender
1603 environ["RECIPIENT"] = recipient
1604 set_environ(environ)
1606 mlm = MailingListManager(DOTDIR, sendmail=sendmail,
1607 smtp_server=smtp_server,
1608 qmqp_server=qmqp_server)
1610 mlm.send_mail = no_act_send_mail
1612 if operation == "--create":
1614 error("You must give at least one list owner with --owner.")
1615 list = mlm.create_list(list_name)
1616 set_list_options(list, owners, moderators, subscription, posting,
1617 archived, language, ignore_bounce,
1618 mail_on_sub_changes, mail_on_forced_unsub)
1620 debug("Created list %s." % list_name)
1621 elif operation == "--destroy":
1622 shutil.rmtree(os.path.join(DOTDIR, list_name))
1623 debug("Removed list %s." % list_name)
1624 elif operation == "--edit":
1625 list = mlm.open_list(list_name)
1626 set_list_options(list, owners, moderators, subscription, posting,
1627 archived, language, ignore_bounce,
1628 mail_on_sub_changes, mail_on_forced_unsub)
1630 elif operation == "--subscribe":
1631 list = mlm.open_list(list_name)
1632 list.subscribers.lock()
1633 for address in args:
1634 if address.find("@") == -1:
1635 error("Address '%s' does not contain an @." % address)
1636 list.subscribers.add(address)
1637 debug("Added subscriber <%s>." % address)
1638 list.subscribers.save()
1639 elif operation == "--unsubscribe":
1640 list = mlm.open_list(list_name)
1641 list.subscribers.lock()
1642 for address in args:
1643 list.subscribers.remove(address)
1644 debug("Removed subscriber <%s>." % address)
1645 list.subscribers.save()
1646 elif operation == "--list":
1647 list = mlm.open_list(list_name)
1648 for address in list.subscribers.get_all():
1650 elif operation == "--is-list":
1651 if mlm.is_list(list_name, skip_prefix, domain):
1652 debug("Indeed a mailing list: <%s>" % list_name)
1654 debug("Not a mailing list: <%s>" % list_name)
1656 elif operation == "--incoming":
1657 mlm.incoming_message(skip_prefix, domain, moderate, post)
1658 elif operation == "--cleaning-woman":
1659 mlm.cleaning_woman()
1660 elif operation == "--show-lists":
1661 listnames = mlm.get_lists()
1663 for listname in listnames:
1665 elif operation == "--get":
1666 list = mlm.open_list(list_name)
1668 print list.cp.get("list", name)
1669 elif operation == "--set":
1670 list = mlm.open_list(list_name)
1673 error("Error: --set arguments must be of form name=value")
1674 name, value = arg.split("=", 1)
1675 list.cp.set("list", name, value)
1677 elif operation == "--version":
1678 print "EoC, version %s" % VERSION
1679 print "Home page: http://liw.iki.fi/liw/eoc/"
1681 error("Internal error: unimplemented option <%s>." % operation)
1683 if __name__ == "__main__":
1686 except EocException, detail:
1687 error("Error: %s" % detail)