1
# Copyright (C) 1998,1999,2000,2001,2002 by the Free Software Foundation, Inc.
3
# This program is free software; you can redistribute it and/or
4
# modify it under the terms of the GNU General Public License
5
# as published by the Free Software Foundation; either version 2
6
# of the License, or (at your option) any later version.
8
# This program is distributed in the hope that it will be useful,
9
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
# GNU General Public License for more details.
13
# You should have received a copy of the GNU General Public License
14
# along with this program; if not, write to the Free Software
15
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
17
"""Mixin class for MailList which handles administrative requests.
19
Two types of admin requests are currently supported: adding members to a
20
closed or semi-closed list, and moderated posts.
22
Pending subscriptions which are requiring a user's confirmation are handled
31
from cStringIO import StringIO
34
from email.MIMEMessage import MIMEMessage
35
from email.Generator import Generator
36
from email.Utils import getaddresses
38
from Mailman import mm_cfg
39
from Mailman import Utils
40
from Mailman import Message
41
from Mailman import Errors
42
from Mailman.UserDesc import UserDesc
43
from Mailman.Queue.sbcache import get_switchboard
44
from Mailman.Logging.Syslog import syslog
45
from Mailman import i18n
49
# Request types requiring admin approval
55
# Return status from __handlepost()
67
# non-configurable data
68
self.next_request_id = 1
70
def InitTempVars(self):
74
return os.path.join(self.fullpath(), 'request.db')
77
filename = self.__filename()
82
self.__db = marshal.load(fp)
85
if e.errno <> errno.ENOENT: raise
88
# The unmarshalling failed, which means the file is corrupt.
91
'request.db file corrupt for list %s, blowing it away.',
94
# Migrate pre-2.1a3 held subscription records to include the
95
# fullname data field.
96
type, version = self.__db.get('version', (IGN, None))
98
# No previous revisiont number, must be upgrading to 2.1a3 or
99
# beyond from some unknown earlier version.
100
for id, (type, data) in self.__db.items():
103
elif id == HELDMSG and len(data) == 5:
104
# tack on a msgdata dictionary
105
self.__db[id] = data + ({},)
106
elif id == SUBSCRIPTION and len(data) == 5:
107
# a fullname field was added
108
stime, addr, password, digest, lang = data
109
self.__db[id] = stime, addr, '', password, digest, lang
113
if self.__db is not None:
115
# Save the version number
116
self.__db['version'] = IGN, mm_cfg.REQUESTS_FILE_SCHEMA_VERSION
117
# Now save a temp file and do the tmpfile->real file dance. BAW:
118
# should we be as paranoid as for the config.pck file? Should we
120
tmpfile = self.__filename() + '.tmp'
121
omask = os.umask(002)
123
fp = open(tmpfile, 'w')
124
marshal.dump(self.__db, fp)
130
os.rename(tmpfile, self.__filename())
132
def __request_id(self):
133
id = self.next_request_id
134
self.next_request_id += 1
137
def SaveRequestsDb(self):
140
def NumRequestsPending(self):
142
# Subtrace one for the version pseudo-entry
143
if self.__db.has_key('version'):
144
return len(self.__db) - 1
145
return len(self.__db)
147
def __getmsgids(self, rtype):
149
ids = [k for k, (type, data) in self.__db.items() if type == rtype]
153
def GetHeldMessageIds(self):
154
return self.__getmsgids(HELDMSG)
156
def GetSubscriptionIds(self):
157
return self.__getmsgids(SUBSCRIPTION)
159
def GetUnsubscriptionIds(self):
160
return self.__getmsgids(UNSUBSCRIPTION)
162
def GetRecord(self, id):
164
type, data = self.__db[id]
167
def GetRecordType(self, id):
169
type, data = self.__db[id]
172
def HandleRequest(self, id, value, comment=None, preserve=None,
173
forward=None, addr=None):
175
rtype, data = self.__db[id]
177
status = self.__handlepost(data, value, comment, preserve,
179
elif rtype == UNSUBSCRIPTION:
180
status = self.__handleunsubscription(data, value, comment)
182
assert rtype == SUBSCRIPTION
183
status = self.__handlesubscription(data, value, comment)
185
# BAW: Held message ids are linked to Pending cookies, allowing
186
# the user to cancel their post before the moderator has approved
187
# it. We should probably remove the cookie associated with this
188
# id, but we have no way currently of correlating them. :(
191
def HoldMessage(self, msg, reason, msgdata={}):
192
# Make a copy of msgdata so that subsequent changes won't corrupt the
193
# request database. TBD: remove the `filebase' key since this will
194
# not be relevant when the message is resurrected.
196
newmsgdata.update(msgdata)
198
# assure that the database is open for writing
200
# get the next unique id
201
id = self.__request_id()
202
assert not self.__db.has_key(id)
203
# get the message sender
204
sender = msg.get_sender()
205
# calculate the file name for the message text and write it to disk
206
if mm_cfg.HOLD_MESSAGES_AS_PICKLES:
210
filename = 'heldmsg-%s-%d.%s' % (self.internal_name(), id, ext)
211
omask = os.umask(002)
214
fp = open(os.path.join(mm_cfg.DATA_DIR, filename), 'w')
215
if mm_cfg.HOLD_MESSAGES_AS_PICKLES:
216
cPickle.dump(msg, fp, 1)
224
# save the information to the request database. for held message
225
# entries, each record in the database will be of the following
228
# the time the message was received
229
# the sender of the message
230
# the message's subject
231
# a string description of the problem
232
# name of the file in $PREFIX/data containing the msg text
233
# an additional dictionary of message metadata
235
msgsubject = msg.get('subject', _('(no subject)'))
236
data = time.time(), sender, msgsubject, reason, filename, msgdata
237
self.__db[id] = (HELDMSG, data)
240
def __handlepost(self, record, value, comment, preserve, forward, addr):
241
# For backwards compatibility with pre 2.0beta3
242
ptime, sender, subject, reason, filename, msgdata = record
243
path = os.path.join(mm_cfg.DATA_DIR, filename)
244
# Handle message preservation
246
parts = os.path.split(path)[1].split(DASH)
248
spamfile = DASH.join(parts)
249
# Preserve the message as plain text, not as a pickle
253
if e.errno <> errno.ENOENT: raise
256
msg = cPickle.load(fp)
259
# Save the plain text to a .msg file, not a .pck file
260
outpath = os.path.join(mm_cfg.SPAM_DIR, spamfile)
261
head, ext = os.path.splitext(outpath)
262
outpath = head + '.msg'
263
outfp = open(outpath, 'w')
269
# Now handle updates to the database
274
if value == mm_cfg.DEFER:
277
elif value == mm_cfg.APPROVE:
280
msg = readMessage(path)
282
if e.errno <> errno.ENOENT: raise
284
msg = readMessage(path)
285
msgdata['approved'] = 1
286
# adminapproved is used by the Emergency handler
287
msgdata['adminapproved'] = 1
288
# Calculate a new filebase for the approved message, otherwise
289
# delivery errors will cause duplicates.
291
del msgdata['filebase']
294
# Queue the file for delivery by qrunner. Trying to deliver the
295
# message directly here can lead to a huge delay in web
296
# turnaround. Log the moderation and add a header.
297
msg['X-Mailman-Approved-At'] = email.Utils.formatdate(localtime=1)
298
syslog('vette', 'held message approved, message-id: %s',
299
msg.get('message-id', 'n/a'))
300
# Stick the message back in the incoming queue for further
302
inq = get_switchboard(mm_cfg.INQUEUE_DIR)
303
inq.enqueue(msg, _metadata=msgdata)
304
elif value == mm_cfg.REJECT:
306
rejection = 'Refused'
307
self.__refuse(_('Posting of your message titled "%(subject)s"'),
308
sender, comment or _('[No reason given]'),
309
lang=self.getMemberLanguage(sender))
311
assert value == mm_cfg.DISCARD
313
rejection = 'Discarded'
314
# Forward the message
316
# If we've approved the message, we need to be sure to craft a
317
# completely unique second message for the forwarding operation,
318
# since we don't want to share any state or information with the
321
copy = readMessage(path)
323
if e.errno <> errno.ENOENT: raise
324
raise Errors.LostHeldMessage(path)
325
# It's possible the addr is a comma separated list of addresses.
326
addrs = getaddresses([addr])
328
realname, addr = addrs[0]
329
# If the address getting the forwarded message is a member of
330
# the list, we want the headers of the outer message to be
331
# encoded in their language. Otherwise it'll be the preferred
332
# language of the mailing list.
333
lang = self.getMemberLanguage(addr)
335
# Throw away the realnames
336
addr = [a for realname, a in addrs]
337
# Which member language do we attempt to use? We could use
338
# the first match or the first address, but in the face of
339
# ambiguity, let's just use the list's preferred language
340
lang = self.preferred_language
341
otrans = i18n.get_translation()
342
i18n.set_language(lang)
344
fmsg = Message.UserNotification(
345
addr, self.GetBouncesEmail(),
346
_('Forward of moderated message'),
349
i18n.set_translation(otrans)
350
fmsg.set_type('message/rfc822')
355
note = '''%(listname)s: %(rejection)s posting:
357
\tSubject: %(subject)s''' % {
358
'listname' : self.internal_name(),
359
'rejection': rejection,
360
'sender' : sender.replace('%', '%%'),
361
'subject' : subject.replace('%', '%%'),
364
note += '\n\tReason: ' + comment.replace('%', '%%')
365
syslog('vette', note)
366
# Always unlink the file containing the message text. It's not
367
# necessary anymore, regardless of the disposition of the message.
372
if e.errno <> errno.ENOENT: raise
373
# We lost the message text file. Clean up our housekeeping
374
# and inform of this status.
378
def HoldSubscription(self, addr, fullname, password, digest, lang):
379
# Assure that the database is open for writing
381
# Get the next unique id
382
id = self.__request_id()
383
assert not self.__db.has_key(id)
385
# Save the information to the request database. for held subscription
386
# entries, each record in the database will be one of the following
389
# the time the subscription request was received
390
# the subscriber's address
391
# the subscriber's selected password (TBD: is this safe???)
393
# the user's preferred language
395
data = time.time(), addr, fullname, password, digest, lang
396
self.__db[id] = (SUBSCRIPTION, data)
398
# TBD: this really shouldn't go here but I'm not sure where else is
400
syslog('vette', '%s: held subscription request from %s',
401
self.internal_name(), addr)
402
# Possibly notify the administrator in default list language
403
if self.admin_immed_notify:
404
realname = self.real_name
406
'New subscription request to list %(realname)s from %(addr)s')
407
text = Utils.maketext(
410
'listname' : self.internal_name(),
411
'hostname' : self.host_name,
412
'admindb_url': self.GetScriptURL('admindb', absolute=1),
414
# This message should appear to come from the <list>-owner so as
415
# to avoid any useless bounce processing.
416
owneraddr = self.GetOwnerEmail()
417
msg = Message.UserNotification(owneraddr, owneraddr, subject, text,
418
self.preferred_language)
419
msg.send(self, **{'tomoderators': 1})
421
def __handlesubscription(self, record, value, comment):
422
stime, addr, fullname, password, digest, lang = record
423
if value == mm_cfg.DEFER:
425
elif value == mm_cfg.DISCARD:
427
elif value == mm_cfg.REJECT:
428
self.__refuse(_('Subscription request'), addr,
429
comment or _('[No reason given]'),
433
assert value == mm_cfg.SUBSCRIBE
435
userdesc = UserDesc(addr, fullname, password, digest, lang)
436
self.ApprovedAddMember(userdesc)
437
except Errors.MMAlreadyAMember:
438
# User has already been subscribed, after sending the request
440
# TBD: disgusting hack: ApprovedAddMember() can end up closing
441
# the request database.
445
def HoldUnsubscription(self, addr):
446
# Assure the database is open for writing
448
# Get the next unique id
449
id = self.__request_id()
450
assert not self.__db.has_key(id)
451
# All we need to do is save the unsubscribing address
452
self.__db[id] = (UNSUBSCRIPTION, addr)
453
syslog('vette', '%s: held unsubscription request from %s',
454
self.internal_name(), addr)
455
# Possibly notify the administrator of the hold
456
if self.admin_immed_notify:
457
realname = self.real_name
459
'New unsubscription request from %(realname)s by %(addr)s')
460
text = Utils.maketext(
463
'listname' : self.internal_name(),
464
'hostname' : self.host_name,
465
'admindb_url': self.GetScriptURL('admindb', absolute=1),
467
# This message should appear to come from the <list>-owner so as
468
# to avoid any useless bounce processing.
469
owneraddr = self.GetOwnerEmail()
470
msg = Message.UserNotification(owneraddr, owneraddr, subject, text,
471
self.preferred_language)
472
msg.send(self, **{'tomoderators': 1})
474
def __handleunsubscription(self, record, value, comment):
476
if value == mm_cfg.DEFER:
478
elif value == mm_cfg.DISCARD:
480
elif value == mm_cfg.REJECT:
481
self.__refuse(_('Unsubscription request'), addr, comment)
483
assert value == mm_cfg.UNSUBSCRIBE
485
self.ApprovedDeleteMember(addr)
486
except Errors.NotAMemberError:
487
# User has already been unsubscribed
491
def __refuse(self, request, recip, comment, origmsg=None, lang=None):
492
# As this message is going to the requestor, try to set the language
493
# to his/her language choice, if they are a member. Otherwise use the
494
# list's preferred language.
495
realname = self.real_name
497
lang = self.getMemberLanguage(recip)
498
text = Utils.maketext(
500
{'listname' : realname,
503
'adminaddr': self.GetOwnerEmail(),
504
}, lang=lang, mlist=self)
505
otrans = i18n.get_translation()
506
i18n.set_language(lang)
508
# add in original message, but not wrap/filled
512
'---------- ' + _('Original Message') + ' ----------',
515
subject = _('Request to mailing list %(realname)s rejected')
517
i18n.set_translation(otrans)
518
msg = Message.UserNotification(recip, self.GetBouncesEmail(),
522
def _UpdateRecords(self):
523
# Subscription records have changed since MM2.0.x. In that family,
524
# the records were of length 4, containing the request time, the
525
# address, the password, and the digest flag. In MM2.1a2, they grew
526
# an additional language parameter at the end. In MM2.1a4, they grew
527
# a fullname slot after the address. This semi-public method is used
528
# by the update script to coerce all subscription records to the
529
# latest MM2.1 format.
531
# Held message records have historically either 5 or 6 items too.
532
# These always include the requests time, the sender, subject, default
533
# rejection reason, and message text. When of length 6, it also
534
# includes the message metadata dictionary on the end of the tuple.
536
for id, (type, info) in self.__db.items():
537
if type == SUBSCRIPTION:
539
# pre-2.1a2 compatibility
540
when, addr, passwd, digest = info
542
lang = self.preferred_language
544
# pre-2.1a4 compatibility
545
when, addr, passwd, digest, lang = info
548
assert len(info) == 6, 'Unknown subscription record layout'
550
# Here's the new layout
551
self.__db[id] = when, addr, fullname, passwd, digest, lang
552
elif type == HELDMSG:
554
when, sender, subject, reason, text = info
557
assert len(info) == 6, 'Unknown held msg record layout'
559
# Here's the new layout
560
self.__db[id] = when, sender, subject, reason, text, msgdata
566
def readMessage(path):
567
# For backwards compatibility, we must be able to read either a flat text
569
ext = os.path.splitext(path)[1]
573
msg = email.message_from_file(fp, Message.Message)
576
msg = cPickle.load(fp)