1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
|
# Copyright (C) 2009-2014 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman 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 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman 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
# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
"""Digest runner."""
from __future__ import absolute_import, print_function, unicode_literals
__metaclass__ = type
__all__ = [
'DigestRunner',
]
import re
import logging
from copy import deepcopy
from email.header import Header
from email.message import Message
from email.mime.message import MIMEMessage
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formatdate, getaddresses, make_msgid
from mailman.config import config
from mailman.core.i18n import _
from mailman.core.runner import Runner
from mailman.handlers.decorate import decorate
from mailman.interfaces.member import DeliveryMode, DeliveryStatus
from mailman.utilities.i18n import make
from mailman.utilities.mailbox import Mailbox
from mailman.utilities.string import oneline, wrap
from six.moves import cStringIO as StringIO
from six.moves.urllib_error import URLError
log = logging.getLogger('mailman.error')
class Digester:
"""Base digester class."""
def __init__(self, mlist, volume, digest_number):
self._mlist = mlist
self._charset = mlist.preferred_language.charset
# This will be used in the Subject, so use $-strings.
self._digest_id = _(
'$mlist.display_name Digest, Vol $volume, Issue $digest_number')
self._subject = Header(self._digest_id,
self._charset,
header_name='Subject')
self._message = self._make_message()
self._message['From'] = mlist.request_address
self._message['Subject'] = self._subject
self._message['To'] = mlist.posting_address
self._message['Reply-To'] = mlist.posting_address
self._message['Date'] = formatdate(localtime=True)
self._message['Message-ID'] = make_msgid()
# In the rfc1153 digest, the masthead contains the digest boilerplate
# plus any digest header. In the MIME digests, the masthead and
# digest header are separate MIME subobjects. In either case, it's
# the first thing in the digest, and we can calculate it now, so go
# ahead and add it now.
self._masthead = make('masthead.txt',
mailing_list=mlist,
display_name=mlist.display_name,
got_list_email=mlist.posting_address,
got_listinfo_url=mlist.script_url('listinfo'),
got_request_email=mlist.request_address,
got_owner_email=mlist.owner_address,
)
# Set things up for the table of contents.
if mlist.digest_header_uri is not None:
try:
self._header = decorate(mlist, mlist.digest_header_uri)
except URLError:
log.exception(
'Digest header decorator URI not found ({0}): {1}'.format(
mlist.fqdn_listname, mlist.digest_header_uri))
self._header = ''
self._toc = StringIO()
print(_("Today's Topics:\n"), file=self._toc)
def add_to_toc(self, msg, count):
"""Add a message to the table of contents."""
subject = msg.get('subject', _('(no subject)'))
subject = oneline(subject, in_unicode=True)
# Don't include the redundant subject prefix in the toc
mo = re.match('(re:? *)?({0})'.format(
re.escape(self._mlist.subject_prefix)),
subject, re.IGNORECASE)
if mo:
subject = subject[:mo.start(2)] + subject[mo.end(2):]
# Take only the first author we find.
username = ''
addresses = getaddresses(
[oneline(msg.get('from', ''), in_unicode=True)])
if addresses:
username = addresses[0][0]
if not username:
username = addresses[0][1]
if username:
username = ' ({0})'.format(username)
lines = wrap('{0:2}. {1}'. format(count, subject), 65).split('\n')
# See if the user's name can fit on the last line
if len(lines[-1]) + len(username) > 70:
lines.append(username)
else:
lines[-1] += username
# Add this subject to the accumulating topics
first = True
for line in lines:
if first:
print(' ', line, file=self._toc)
first = False
else:
print(' ', line.lstrip(), file=self._toc)
def add_message(self, msg, count):
"""Add the message to the digest."""
# We do not want all the headers of the original message to leak
# through in the digest messages.
keepers = {}
for header in self._keepers:
keepers[header] = msg.get_all(header, [])
# Remove all the unkempt <wink> headers. Use .keys() to allow for
# destructive iteration...
for header in msg.keys():
del msg[header]
# ... and add them in the designated order.
for header in self._keepers:
for value in keepers[header]:
msg[header] = value
# Add some useful extra stuff.
msg['Message'] = count.decode('utf-8')
class MIMEDigester(Digester):
"""A MIME digester."""
def __init__(self, mlist, volume, digest_number):
super(MIMEDigester, self).__init__(mlist, volume, digest_number)
masthead = MIMEText(self._masthead.encode(self._charset),
_charset=self._charset)
masthead['Content-Description'] = self._subject
self._message.attach(masthead)
# Add the optional digest header.
if mlist.digest_header_uri is not None:
header = MIMEText(self._header.encode(self._charset),
_charset=self._charset)
header['Content-Description'] = _('Digest Header')
self._message.attach(header)
# Calculate the set of headers we're to keep in the MIME digest.
self._keepers = set(config.digests.mime_digest_keep_headers.split())
def _make_message(self):
return MIMEMultipart('mixed')
def add_toc(self, count):
"""Add the table of contents."""
toc_text = self._toc.getvalue()
try:
toc_part = MIMEText(toc_text.encode(self._charset),
_charset=self._charset)
except UnicodeError:
toc_part = MIMEText(toc_text.encode('utf-8'), _charset='utf-8')
toc_part['Content-Description']= _("Today's Topics ($count messages)")
self._message.attach(toc_part)
def add_message(self, msg, count):
"""Add the message to the digest."""
# Make a copy of the message object, since the RFC 1153 processing
# scrubs out attachments.
self._message.attach(MIMEMessage(deepcopy(msg)))
def finish(self):
"""Finish up the digest, producing the email-ready copy."""
if self._mlist.digest_footer_uri is not None:
try:
footer_text = decorate(
self._mlist, self._mlist.digest_footer_uri)
except URLError:
log.exception(
'Digest footer decorator URI not found ({0}): {1}'.format(
self._mlist.fqdn_listname,
self._mlist.digest_footer_uri))
footer_text = ''
footer = MIMEText(footer_text.encode(self._charset),
_charset=self._charset)
footer['Content-Description'] = _('Digest Footer')
self._message.attach(footer)
# This stuff is outside the normal MIME goo, and it's what the old
# MIME digester did. No one seemed to complain, probably because you
# won't see it in an MUA that can't display the raw message. We've
# never got complaints before, but if we do, just wax this. It's
# primarily included for (marginally useful) backwards compatibility.
self._message.postamble = _('End of ') + self._digest_id
return self._message
class RFC1153Digester(Digester):
"""A digester of the format specified by RFC 1153."""
def __init__(self, mlist, volume, digest_number):
super(RFC1153Digester, self).__init__(mlist, volume, digest_number)
self._separator70 = '-' * 70
self._separator30 = '-' * 30
self._text = StringIO()
print(self._masthead, file=self._text)
print(file=self._text)
# Add the optional digest header.
if mlist.digest_header_uri is not None:
print(self._header, file=self._text)
print(file=self._text)
# Calculate the set of headers we're to keep in the RFC1153 digest.
self._keepers = set(config.digests.plain_digest_keep_headers.split())
def _make_message(self):
return Message()
def add_toc(self, count):
"""Add the table of contents."""
print(self._toc.getvalue(), file=self._text)
print(file=self._text)
print(self._separator70, file=self._text)
print(file=self._text)
def add_message(self, msg, count):
"""Add the message to the digest."""
if count > 1:
print(self._separator30, file=self._text)
print(file=self._text)
# Each message section contains a few headers.
for header in config.digests.plain_digest_keep_headers.split():
if header in msg:
value = oneline(msg[header], in_unicode=True)
value = wrap('{0}: {1}'.format(header, value))
value = '\n\t'.join(value.split('\n'))
print(value, file=self._text)
print(file=self._text)
# Add the payload. If the decoded payload is empty, this may be a
# multipart message. In that case, just stringify it.
payload = msg.get_payload(decode=True)
if not payload:
payload = msg.as_string().split('\n\n', 1)[1]
if isinstance(payload, bytes):
try:
# Do the decoding inside the try/except so that if the charset
# conversion fails, we'll just drop back to ascii.
charset = msg.get_content_charset('us-ascii')
payload = payload.decode(charset, 'replace')
except (LookupError, TypeError):
# Unknown or empty charset.
payload = payload.decode('us-ascii', 'replace')
print(payload, file=self._text)
if not payload.endswith('\n'):
print(file=self._text)
def finish(self):
"""Finish up the digest, producing the email-ready copy."""
if self._mlist.digest_footer_uri is not None:
try:
footer_text = decorate(
self._mlist, self._mlist.digest_footer_uri)
except URLError:
log.exception(
'Digest footer decorator URI not found ({0}): {1}'.format(
self._mlist.fqdn_listname,
self._mlist.digest_footer_uri))
footer_text = ''
# MAS: There is no real place for the digest_footer in an RFC 1153
# compliant digest, so add it as an additional message with
# Subject: Digest Footer
print(self._separator30, file=self._text)
print(file=self._text)
print('Subject: ' + _('Digest Footer'), file=self._text)
print(file=self._text)
print(footer_text, file=self._text)
print(file=self._text)
print(self._separator30, file=self._text)
print(file=self._text)
# Add the sign-off.
sign_off = _('End of ') + self._digest_id
print(sign_off, file=self._text)
print('*' * len(sign_off), file=self._text)
# If the digest message can't be encoded by the list character set,
# fall back to utf-8.
text = self._text.getvalue()
try:
self._message.set_payload(text.encode(self._charset),
charset=self._charset)
except UnicodeError:
self._message.set_payload(text.encode('utf-8'), charset='utf-8')
return self._message
class DigestRunner(Runner):
"""The digest runner."""
def _dispose(self, mlist, msg, msgdata):
"""See `IRunner`."""
volume = msgdata['volume']
digest_number = msgdata['digest_number']
# Backslashes make me cry.
code = mlist.preferred_language.code
with Mailbox(msgdata['digest_path']) as mailbox, _.using(code):
# Create the digesters.
mime_digest = MIMEDigester(mlist, volume, digest_number)
rfc1153_digest = RFC1153Digester(mlist, volume, digest_number)
# Cruise through all the messages in the mailbox, first building
# the table of contents and accumulating Subject: headers and
# authors. The question really is whether it's better from a1
# performance and memory footprint to go through the mailbox once
# and cache the messages in a list, or to cruise through the
# mailbox twice. We'll do the latter, but it's a complete guess.
count = None
for count, (key, message) in enumerate(mailbox.iteritems(), 1):
mime_digest.add_to_toc(message, count)
rfc1153_digest.add_to_toc(message, count)
assert count is not None, 'No digest messages?'
# Add the table of contents.
mime_digest.add_toc(count)
rfc1153_digest.add_toc(count)
# Cruise through the set of messages a second time, adding them to
# the actual digest.
for count, (key, message) in enumerate(mailbox.iteritems(), 1):
mime_digest.add_message(message, count)
rfc1153_digest.add_message(message, count)
# Finish up the digests.
mime = mime_digest.finish()
rfc1153 = rfc1153_digest.finish()
# Calculate the recipients lists
mime_recipients = set()
rfc1153_recipients = set()
# When someone turns off digest delivery, they will get one last
# digest to ensure that there will be no gaps in the messages they
# receive.
digest_members = set(mlist.digest_members.members)
for member in digest_members:
if member.delivery_status is not DeliveryStatus.enabled:
continue
# Send the digest to the case-preserved address of the digest
# members.
email_address = member.address.original_email
if member.delivery_mode == DeliveryMode.plaintext_digests:
rfc1153_recipients.add(email_address)
elif member.delivery_mode == DeliveryMode.mime_digests:
mime_recipients.add(email_address)
else:
raise AssertionError(
'Digest member "{0}" unexpected delivery mode: {1}'.format(
email_address, member.delivery_mode))
# Add also the folks who are receiving one last digest.
for address, delivery_mode in mlist.last_digest_recipients:
if delivery_mode == DeliveryMode.plaintext_digests:
rfc1153_recipients.add(address.original_email)
elif delivery_mode == DeliveryMode.mime_digests:
mime_recipients.add(address.original_email)
else:
raise AssertionError(
'OLD recipient "{0}" unexpected delivery mode: {1}'.format(
address, delivery_mode))
# Send the digests to the virgin queue for final delivery.
queue = config.switchboards['virgin']
queue.enqueue(mime,
recipients=mime_recipients,
listid=mlist.list_id,
isdigest=True)
queue.enqueue(rfc1153,
recipients=rfc1153_recipients,
listid=mlist.list_id,
isdigest=True)
|