summaryrefslogtreecommitdiff
path: root/Mailman/Handlers/ToDigest.py
diff options
context:
space:
mode:
Diffstat (limited to 'Mailman/Handlers/ToDigest.py')
-rw-r--r--Mailman/Handlers/ToDigest.py346
1 files changed, 346 insertions, 0 deletions
diff --git a/Mailman/Handlers/ToDigest.py b/Mailman/Handlers/ToDigest.py
new file mode 100644
index 000000000..f4647bee9
--- /dev/null
+++ b/Mailman/Handlers/ToDigest.py
@@ -0,0 +1,346 @@
+# Copyright (C) 1998 by the Free Software Foundation, Inc.
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Add the message to the list's current digest and possibly send it.
+
+This handler will add the current message to the list's currently accumulating
+digest. If the digest has reached its size threshold, it is delivered by
+creating an OutgoingMessage of the digest, setting the `isdigest' attribute,
+and injecting it into the pipeline.
+"""
+
+import os
+import string
+import re
+from Mailman import Utils
+from Mailman import Message
+from Mailman import mm_cfg
+
+from stat import ST_SIZE
+from errno import ENOENT
+
+MIME_SEPARATOR = '__--__--'
+MIME_NONSEPARATOR = ' %s ' % MIME_SEPARATOR
+EXCLUDE_HEADERS = ('received', 'errors-to')
+
+
+
+def process(mlist, msg):
+ # short circuit non-digestable lists, or for messages that are already
+ # digests
+ if not mlist.digestable or getattr(msg, 'isdigest', 0):
+ return
+ digestfile = os.path.join(mlist.fullpath(), 'next-digest')
+ topicsfile = os.path.join(mlist.fullpath(), 'next-digest-topics')
+ omask = os.umask(002)
+ try:
+ digestfp = open(digestfile, 'a+')
+ topicsfp = open(topicsfile, 'a+')
+ finally:
+ os.umask(omask)
+ sender = quotemime(msg.GetSenderName())
+ fromline = quotemime(msg.getheader('from'))
+ date = quotemime(msg.getheader('date'))
+ body = quotemime(msg.body)
+ subject = quotemime(msg.getheader('subject'))
+ # don't include the redundant subject prefix in the TOC entries
+ mo = re.match('(re:? *)?(%s)' % re.escape(mlist.subject_prefix),
+ subject, re.IGNORECASE)
+ if mo:
+ subject = subject[:mo.start(2)] + subject[mo.end(2):]
+ topicsfp.write(' %d. %s (%s)\n' % (mlist.next_post_number,
+ subject, sender))
+ # We exclude specified headers and all X-* headers
+ kept_headers = []
+ keeping = 0
+ have_content_type = 0
+ have_content_description = 0
+ # speed up the inner loop
+ lower, split, excludes = string.lower, string.split, EXCLUDE_HEADERS
+ for h in msg.headers:
+ if lower(h[:2]) == 'x-' or lower(split(h, ':')[0]) in excludes:
+ keeping = 0
+ elif h and h[0] in (' ', '\t'):
+ if keeping and kept_headers:
+ # continuation of something we're keeping
+ kept_headers[-1] = kept_headers[-1] + h
+ else:
+ keeping = 1
+ if lower(h[:7]) == 'content-':
+ kept_headers.append(h)
+ if lower(h[:12]) == 'content-type':
+ have_content_type = 1
+ elif lower(h[:19]) == 'content-description':
+ have_content_description = 1
+ else:
+ kept_headers.append(quotemime(h))
+ # after processing the headers
+ if have_content_type and not have_content_description:
+ kept_headers.append('Content-Description: %s\n' % subject)
+ # TBD: reply-to munging happens elsewhere in the pipeline
+ digestfp.write('--%s\n\nMessage: %d\n%s\n%s' %
+ (MIME_SEPARATOR, mlist.next_post_number,
+ string.join(kept_headers, ''),
+ body))
+ mlist.next_post_number = mlist.next_post_number + 1
+ topicsfp.close()
+ digestfp.close()
+ # if the current digest size exceeds the threshold, send the digest by
+ # injection into the list's message pipeline
+ try:
+ size = os.stat(digestfile)[ST_SIZE]
+ if size/1024.0 >= mlist.digest_size_threshhold:
+ injectdigest(mlist, digestfile, topicsfile)
+ except os.error, e:
+ code, msg = e
+ if code == ENOENT:
+ mlist.LogMsg('error', 'Lost digest file: %s' % digestfile)
+ mlist.LogMsg('error', str(e))
+
+
+
+def inject_digest(mlist, digestfile, topicsfile):
+ fp = open(topicsfile, 'r+')
+ topicsdata = fp.read()
+ fp.close()
+ topicscount = string.count(topicsdata, '\n')
+ fp = open(digestfile)
+ #
+ # filters for recipient calculation
+ def delivery_enabled_p(x, s=self, v=mm_cfg.DisableDelivery):
+ return not s.GetUserOption(x, v)
+ def likes_mime_p(x, s=self, v=mm_cfg.DisableMime):
+ return not s.GetUserOption(x, v)
+ def hates_mime_p(x, s=self, v=mm_cfg.DisableMime):
+ return s.GetUserOption(x, v)
+ #
+ # these people have switched their options from digest delivery to
+ # non-digest delivery. they need to get one last digest...
+ try:
+ final_digesters = mlist.one_last_digest.keys()
+ mlist.one_last_digest = {}
+ except AttributeError:
+ final_digesters = []
+ #
+ # calculate various recipient lists
+ digestmembers = mlist.GetDigestMembers() + final_digesters
+ recipients = filter(delivery_enabled_p, digestmembers)
+ mime_recips = filter(likes_mime_p, recipients)
+ text_recips = filter(hates_mime_p, recipients)
+ #
+ # log this digest injection
+ mlist.LogMsg('digest',
+ '%s v %d - %d msgs, %d recips (%d mime, %d text, %d disabled)'
+ % (mlist.real_name, mlist.next_digest_number, topicscount,
+ len(digestmembers), len(mime_recips), len(text_recips),
+ len(digestmembers) - len(recipients)))
+ # do any deliveries
+ if mime_recips or text_recips:
+ digest = Digest(mlist, topicsdata, fp.read())
+ mimemsg = digest.asMIME()
+ mimemsg.recips = mime_recips
+ mlist.inject(mimemsg)
+ textmsg = digest.asText()
+ textmsg.recips = text_recips
+ mlist.inject(textmsg)
+ # zap accumulated digest information for the next round
+ os.unlink(digestfile)
+ os.unlink(topicsfile)
+ mlist.next_digest_number = mlist.next_digest_number + 1
+ mlist.next_post_number = 1
+
+
+
+def quotemime(text):
+ if not text:
+ return text
+ return string.join(string.split(text, MIME_SEPARATOR), MIME_NONSEPARATOR)
+
+
+class Digest:
+ """A digest, representable as either a MIME or plain text message."""
+ def __init__(self, mlist, toc, body):
+ self.__mlist = mlist
+ self.__toc = toc
+ self.__body = body
+ self.__volume = 'Vol %d #%d' % (mlist.volume, mlist.next_digest_number)
+ numtopics = string.count(self.__toc, '\n')
+ self.__numinfo = '%d msg%s' % (numtopics, numtopics <> 1 and 's' or '')
+
+ def ComposeBaseHeaders(self, msg):
+ """Populate the message with the presentation-independent headers."""
+ mlist = self.__mlist
+ msg['From'] = mlist.GetAdminEmail()
+ msg['Subject'] = ('%s digest, %s - %s' %
+ (mlist.real_name, self.__volume, self.__numinfo))
+ msg['Reply-to'] = mlist.GetListEmail()
+ msg['X-Mailer'] = "Mailman v%s" % mm_cfg.VERSION
+ msg['MIME-version'] = '1.0'
+
+ def TemplateRefs(self):
+ """Resolve references in a format string against list settings.
+
+ The resolution is done against a copy of the lists attribute
+ dictionary, with the addition of some of settings for computed
+ items - got_listinfo_url, got_request_email, got_list_email, and
+ got_owner_email.
+
+ """
+ # Collect the substitutions:
+ if hasattr(self, 'substitutions'):
+ return Utils.SafeDict(self.substitutions)
+ mlist = self.__mlist
+ substs = Utils.SafeDict()
+ substs.update(mlist.__dict__)
+ substs.update(
+ {'got_listinfo_url' : mlist.GetAbsoluteScriptURL('listinfo'),
+ 'got_request_email': mlist.GetRequestEmail(),
+ 'got_list_email' : mlist.GetListEmail(),
+ 'got_owner_email' : mlist.GetAdminEmail(),
+ 'cgiext' : mm_cfg.CGIEXT,
+ })
+ return substs
+
+ def asMIME(self):
+ return self.Present(mime=1)
+
+ def asText(self):
+ return self.Present(mime=0)
+
+ def Present(self, mime):
+ """Produce a rendering of the digest, as an OutgoingMessage."""
+ msg = Message.OutgoingMessage()
+ self.ComposeBaseHeaders(msg)
+ digestboundary = MIME_SEPARATOR
+ if mime:
+ import mimetools
+ envboundary = mimetools.choose_boundary()
+ msg['Content-type'] = 'multipart/mixed; boundary=' + envboundary
+ else:
+ envboundary = MIME_SEPARATOR
+ msg['Content-type'] = 'text/plain'
+ dashbound = "--" + envboundary
+ # holds lines of the message
+ lines = []
+ # Masthead:
+ if mime:
+ lines.append(dashbound)
+ lines.append("Content-type: text/plain; charset=us-ascii")
+ lines.append("Content-description: Masthead (%s digest, %s)"
+ % (self.__mlist.real_name, self.__volume))
+ lines.append(Utils.maketext('masthead.txt', self.TemplateRefs()))
+ # List-specific header:
+ if self.__mlist.digest_header:
+ lines.append('')
+ if mime:
+ lines.append(dashbound)
+ lines.append("Content-type: text/plain; charset=us-ascii")
+ lines.append("Content-description: Digest Header")
+ lines.append('')
+ lines.append(self.__mlist.digest_header % self.TemplateRefs())
+ # Table of contents:
+ lines.append('')
+ if mime:
+ lines.append(dashbound)
+ lines.append("Content-type: text/plain; charset=us-ascii")
+ lines.append("Content-description: Today's Topics (%s)" %
+ self.__numinfo)
+ lines.append('')
+ lines.append("Today's Topics:")
+ lines.append('')
+ lines.append(self.__toc)
+ # Digest text:
+ if mime:
+ lines.append(dashbound)
+ lines.append('Content-type: multipart/digest; boundary="%s"'
+ % digestboundary)
+ lines.append('')
+ lines.append(self.__body)
+ # End multipart digest text part
+ lines.append('')
+ lines.append("--" + digestboundary + "--")
+ else:
+ lines.append(
+ filterDigestHeaders(self.__body,
+ mm_cfg.DEFAULT_PLAIN_DIGEST_KEEP_HEADERS,
+ digestboundary))
+ # List-specific footer:
+ if self.__mlist.digest_footer:
+ lines.append('')
+ lines.append(dashbound)
+ if mime:
+ lines.append("Content-type: text/plain; charset=us-ascii")
+ lines.append("Content-description: Digest Footer")
+ lines.append('')
+ lines.append(self.__mlist.digest_footer % self.TemplateRefs())
+ # Close:
+ if mime:
+ # Close encompassing mime envelope.
+ lines.append('')
+ lines.append(dashbound + "--")
+ lines.append('')
+ lines.append("End of %s Digest" % self.__mlist.real_name)
+ msg.SetBody(string.join(lines, "\n"))
+ return msg
+
+
+
+def filterDigestHeaders(body, keep_headers, mimesep):
+ """Return copy of body that omits non-crucial headers."""
+ state = "sep" # "sep", "head", or "body"
+ lines = string.split(body, "\n")
+ at = 1
+ text = [lines[0]]
+ kept_last = 0
+ while at < len(lines):
+ l, at = lines[at], at + 1
+ if state == "body":
+ # Snarf the body up to, and including, the next separator:
+ text.append(l)
+ if string.strip(l) == '--' + mimesep:
+ state = "sep"
+ continue
+ elif state == "sep":
+ state = "head"
+ # Keep the one (blank) line between separator and headers.
+ text.append(l)
+ kept_last = 0
+ continue
+ elif state == "head":
+ l = string.strip(l)
+ if l == '':
+ state = "body"
+ text.append(l)
+ continue
+ elif l[0] in [' ', '\t']:
+ # Continuation line - keep if the prior line was kept.
+ if kept_last:
+ text.append(l)
+ continue
+ else:
+ where = string.find(l, ':')
+ if where == -1:
+ # Malformed header line - interesting, keep it.
+ text.append(l)
+ kept_last = 1
+ else:
+ field = l[:where]
+ if string.lower(field) in keep_headers:
+ text.append(l)
+ kept_last = 1
+ else:
+ kept_last = 0
+ return string.join(text, '\n')