Apply 62_new_list_bad_pending_requests.patch
[mspang/vmailman.git] / Mailman / ListAdmin.py
1 # Copyright (C) 1998-2004 by the Free Software Foundation, Inc.
2 #
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.
7 #
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.
12 #
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., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16
17 """Mixin class for MailList which handles administrative requests.
18
19 Two types of admin requests are currently supported: adding members to a
20 closed or semi-closed list, and moderated posts.
21
22 Pending subscriptions which are requiring a user's confirmation are handled
23 elsewhere.
24 """
25
26 import os
27 import time
28 import errno
29 import cPickle
30 import marshal
31 from cStringIO import StringIO
32
33 import email
34 from email.MIMEMessage import MIMEMessage
35 from email.Generator import Generator
36 from email.Utils import getaddresses
37
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
46
47 _ = i18n._
48
49 # Request types requiring admin approval
50 IGN = 0
51 HELDMSG = 1
52 SUBSCRIPTION = 2
53 UNSUBSCRIPTION = 3
54
55 # Return status from __handlepost()
56 DEFER = 0
57 REMOVE = 1
58 LOST = 2
59
60 DASH = '-'
61 NL = '\n'
62
63 try:
64     True, False
65 except NameError:
66     True = 1
67     False = 0
68
69
70 \f
71 class ListAdmin:
72     def InitVars(self):
73         # non-configurable data
74         self.next_request_id = 1
75
76     def InitTempVars(self):
77         self.__db = None
78         self.__filename = os.path.join(self.fullpath(), 'request.pck')
79
80     def __opendb(self):
81         if self.__db is None:
82             assert self.Locked()
83             try:
84                 fp = open(self.__filename)
85                 try:
86                     self.__db = cPickle.load(fp)
87                 finally:
88                     fp.close()
89             except IOError, e:
90                 if e.errno <> errno.ENOENT: raise
91                 self.__db = {}
92                 # put version number in new database
93                 self.__db['version'] = IGN, mm_cfg.REQUESTS_FILE_SCHEMA_VERSION
94
95     def __closedb(self):
96         if self.__db is not None:
97             assert self.Locked()
98             # Save the version number
99             self.__db['version'] = IGN, mm_cfg.REQUESTS_FILE_SCHEMA_VERSION
100             # Now save a temp file and do the tmpfile->real file dance.  BAW:
101             # should we be as paranoid as for the config.pck file?  Should we
102             # use pickle?
103             tmpfile = self.__filename + '.tmp'
104             omask = os.umask(002)
105             try:
106                 fp = open(tmpfile, 'w')
107                 try:
108                     cPickle.dump(self.__db, fp, 1)
109                     fp.flush()
110                     os.fsync(fp.fileno())
111                 finally:
112                     fp.close()
113             finally:
114                 os.umask(omask)
115             self.__db = None
116             # Do the dance
117             os.rename(tmpfile, self.__filename)
118
119     def __nextid(self):
120         assert self.Locked()
121         while True:
122             next = self.next_request_id
123             self.next_request_id += 1
124             if not self.__db.has_key(next):
125                 break
126         return next
127
128     def SaveRequestsDb(self):
129         self.__closedb()
130
131     def NumRequestsPending(self):
132         self.__opendb()
133         if self.__db.has_key('version'):
134             # Subtract one for the version pseudo-entry
135             return len(self.__db) - 1
136         else:
137             return len(self.__db)
138
139     def __getmsgids(self, rtype):
140         self.__opendb()
141         ids = [k for k, (op, data) in self.__db.items() if op == rtype]
142         ids.sort()
143         return ids
144
145     def GetHeldMessageIds(self):
146         return self.__getmsgids(HELDMSG)
147
148     def GetSubscriptionIds(self):
149         return self.__getmsgids(SUBSCRIPTION)
150
151     def GetUnsubscriptionIds(self):
152         return self.__getmsgids(UNSUBSCRIPTION)
153
154     def GetRecord(self, id):
155         self.__opendb()
156         type, data = self.__db[id]
157         return data
158
159     def GetRecordType(self, id):
160         self.__opendb()
161         type, data = self.__db[id]
162         return type
163
164     def HandleRequest(self, id, value, comment=None, preserve=None,
165                       forward=None, addr=None):
166         self.__opendb()
167         rtype, data = self.__db[id]
168         if rtype == HELDMSG:
169             status = self.__handlepost(data, value, comment, preserve,
170                                        forward, addr)
171         elif rtype == UNSUBSCRIPTION:
172             status = self.__handleunsubscription(data, value, comment)
173         else:
174             assert rtype == SUBSCRIPTION
175             status = self.__handlesubscription(data, value, comment)
176         if status <> DEFER:
177             # BAW: Held message ids are linked to Pending cookies, allowing
178             # the user to cancel their post before the moderator has approved
179             # it.  We should probably remove the cookie associated with this
180             # id, but we have no way currently of correlating them. :(
181             del self.__db[id]
182
183     def HoldMessage(self, msg, reason, msgdata={}):
184         # Make a copy of msgdata so that subsequent changes won't corrupt the
185         # request database.  TBD: remove the `filebase' key since this will
186         # not be relevant when the message is resurrected.
187         msgdata = msgdata.copy()
188         # assure that the database is open for writing
189         self.__opendb()
190         # get the next unique id
191         id = self.__nextid()
192         # get the message sender
193         sender = msg.get_sender()
194         # calculate the file name for the message text and write it to disk
195         if mm_cfg.HOLD_MESSAGES_AS_PICKLES:
196             ext = 'pck'
197         else:
198             ext = 'txt'
199         filename = 'heldmsg-%s-%d.%s' % (self.internal_name(), id, ext)
200         omask = os.umask(002)
201         try:
202             fp = open(os.path.join(mm_cfg.DATA_DIR, filename), 'w')
203             try:
204                 if mm_cfg.HOLD_MESSAGES_AS_PICKLES:
205                     cPickle.dump(msg, fp, 1)
206                 else:
207                     g = Generator(fp)
208                     g(msg, 1)
209                 fp.flush()
210                 os.fsync(fp.fileno())
211             finally:
212                 fp.close()
213         finally:
214             os.umask(omask)
215         # save the information to the request database.  for held message
216         # entries, each record in the database will be of the following
217         # format:
218         #
219         # the time the message was received
220         # the sender of the message
221         # the message's subject
222         # a string description of the problem
223         # name of the file in $PREFIX/data containing the msg text
224         # an additional dictionary of message metadata
225         #
226         msgsubject = msg.get('subject', _('(no subject)'))
227         data = time.time(), sender, msgsubject, reason, filename, msgdata
228         self.__db[id] = (HELDMSG, data)
229         return id
230
231     def __handlepost(self, record, value, comment, preserve, forward, addr):
232         # For backwards compatibility with pre 2.0beta3
233         ptime, sender, subject, reason, filename, msgdata = record
234         path = os.path.join(mm_cfg.DATA_DIR, filename)
235         # Handle message preservation
236         if preserve:
237             parts = os.path.split(path)[1].split(DASH)
238             parts[0] = 'spam'
239             spamfile = DASH.join(parts)
240             # Preserve the message as plain text, not as a pickle
241             try:
242                 fp = open(path)
243             except IOError, e:
244                 if e.errno <> errno.ENOENT: raise
245                 return LOST
246             try:
247                 msg = cPickle.load(fp)
248             finally:
249                 fp.close()
250             # Save the plain text to a .msg file, not a .pck file
251             outpath = os.path.join(mm_cfg.SPAM_DIR, spamfile)
252             head, ext = os.path.splitext(outpath)
253             outpath = head + '.msg'
254             outfp = open(outpath, 'w')
255             try:
256                 g = Generator(outfp)
257                 g(msg, 1)
258             finally:
259                 outfp.close()
260         # Now handle updates to the database
261         rejection = None
262         fp = None
263         msg = None
264         status = REMOVE
265         if value == mm_cfg.DEFER:
266             # Defer
267             status = DEFER
268         elif value == mm_cfg.APPROVE:
269             # Approved.
270             try:
271                 msg = readMessage(path)
272             except IOError, e:
273                 if e.errno <> errno.ENOENT: raise
274                 return LOST
275             msg = readMessage(path)
276             msgdata['approved'] = 1
277             # adminapproved is used by the Emergency handler
278             msgdata['adminapproved'] = 1
279             # Calculate a new filebase for the approved message, otherwise
280             # delivery errors will cause duplicates.
281             try:
282                 del msgdata['filebase']
283             except KeyError:
284                 pass
285             # Queue the file for delivery by qrunner.  Trying to deliver the
286             # message directly here can lead to a huge delay in web
287             # turnaround.  Log the moderation and add a header.
288             msg['X-Mailman-Approved-At'] = email.Utils.formatdate(localtime=1)
289             syslog('vette', 'held message approved, message-id: %s',
290                    msg.get('message-id', 'n/a'))
291             # Stick the message back in the incoming queue for further
292             # processing.
293             inq = get_switchboard(mm_cfg.INQUEUE_DIR)
294             inq.enqueue(msg, _metadata=msgdata)
295         elif value == mm_cfg.REJECT:
296             # Rejected
297             rejection = 'Refused'
298             self.__refuse(_('Posting of your message titled "%(subject)s"'),
299                           sender, comment or _('[No reason given]'),
300                           lang=self.getMemberLanguage(sender))
301         else:
302             assert value == mm_cfg.DISCARD
303             # Discarded
304             rejection = 'Discarded'
305         # Forward the message
306         if forward and addr:
307             # If we've approved the message, we need to be sure to craft a
308             # completely unique second message for the forwarding operation,
309             # since we don't want to share any state or information with the
310             # normal delivery.
311             try:
312                 copy = readMessage(path)
313             except IOError, e:
314                 if e.errno <> errno.ENOENT: raise
315                 raise Errors.LostHeldMessage(path)
316             # It's possible the addr is a comma separated list of addresses.
317             addrs = getaddresses([addr])
318             if len(addrs) == 1:
319                 realname, addr = addrs[0]
320                 # If the address getting the forwarded message is a member of
321                 # the list, we want the headers of the outer message to be
322                 # encoded in their language.  Otherwise it'll be the preferred
323                 # language of the mailing list.
324                 lang = self.getMemberLanguage(addr)
325             else:
326                 # Throw away the realnames
327                 addr = [a for realname, a in addrs]
328                 # Which member language do we attempt to use?  We could use
329                 # the first match or the first address, but in the face of
330                 # ambiguity, let's just use the list's preferred language
331                 lang = self.preferred_language
332             otrans = i18n.get_translation()
333             i18n.set_language(lang)
334             try:
335                 fmsg = Message.UserNotification(
336                     addr, self.GetBouncesEmail(),
337                     _('Forward of moderated message'),
338                     lang=lang)
339             finally:
340                 i18n.set_translation(otrans)
341             fmsg.set_type('message/rfc822')
342             fmsg.attach(copy)
343             fmsg.send(self)
344         # Log the rejection
345         if rejection:
346             note = '''%(listname)s: %(rejection)s posting:
347 \tFrom: %(sender)s
348 \tSubject: %(subject)s''' % {
349                 'listname' : self.internal_name(),
350                 'rejection': rejection,
351                 'sender'   : str(sender).replace('%', '%%'),
352                 'subject'  : str(subject).replace('%', '%%'),
353                 }
354             if comment:
355                 note += '\n\tReason: ' + comment.replace('%', '%%')
356             syslog('vette', note)
357         # Always unlink the file containing the message text.  It's not
358         # necessary anymore, regardless of the disposition of the message.
359         if status <> DEFER:
360             try:
361                 os.unlink(path)
362             except OSError, e:
363                 if e.errno <> errno.ENOENT: raise
364                 # We lost the message text file.  Clean up our housekeeping
365                 # and inform of this status.
366                 return LOST
367         return status
368
369     def HoldSubscription(self, addr, fullname, password, digest, lang):
370         # Assure that the database is open for writing
371         self.__opendb()
372         # Get the next unique id
373         id = self.__nextid()
374         # Save the information to the request database. for held subscription
375         # entries, each record in the database will be one of the following
376         # format:
377         #
378         # the time the subscription request was received
379         # the subscriber's address
380         # the subscriber's selected password (TBD: is this safe???)
381         # the digest flag
382         # the user's preferred language
383         data = time.time(), addr, fullname, password, digest, lang
384         self.__db[id] = (SUBSCRIPTION, data)
385         #
386         # TBD: this really shouldn't go here but I'm not sure where else is
387         # appropriate.
388         syslog('vette', '%s: held subscription request from %s',
389                self.internal_name(), addr)
390         # Possibly notify the administrator in default list language
391         if self.admin_immed_notify:
392             realname = self.real_name
393             subject = _(
394                 'New subscription request to list %(realname)s from %(addr)s')
395             text = Utils.maketext(
396                 'subauth.txt',
397                 {'username'   : addr,
398                  'listname'   : self.internal_name(),
399                  'hostname'   : self.host_name,
400                  'admindb_url': self.GetScriptURL('admindb', absolute=1),
401                  }, mlist=self)
402             # This message should appear to come from the <list>-owner so as
403             # to avoid any useless bounce processing.
404             owneraddr = self.GetOwnerEmail()
405             msg = Message.UserNotification(owneraddr, owneraddr, subject, text,
406                                            self.preferred_language)
407             msg.send(self, **{'tomoderators': 1})
408
409     def __handlesubscription(self, record, value, comment):
410         stime, addr, fullname, password, digest, lang = record
411         if value == mm_cfg.DEFER:
412             return DEFER
413         elif value == mm_cfg.DISCARD:
414             pass
415         elif value == mm_cfg.REJECT:
416             self.__refuse(_('Subscription request'), addr,
417                           comment or _('[No reason given]'),
418                           lang=lang)
419         else:
420             # subscribe
421             assert value == mm_cfg.SUBSCRIBE
422             try:
423                 userdesc = UserDesc(addr, fullname, password, digest, lang)
424                 self.ApprovedAddMember(userdesc, whence='via admin approval')
425             except Errors.MMAlreadyAMember:
426                 # User has already been subscribed, after sending the request
427                 pass
428             # TBD: disgusting hack: ApprovedAddMember() can end up closing
429             # the request database.
430             self.__opendb()
431         return REMOVE
432
433     def HoldUnsubscription(self, addr):
434         # Assure the database is open for writing
435         self.__opendb()
436         # Get the next unique id
437         id = self.__nextid()
438         # All we need to do is save the unsubscribing address
439         self.__db[id] = (UNSUBSCRIPTION, addr)
440         syslog('vette', '%s: held unsubscription request from %s',
441                self.internal_name(), addr)
442         # Possibly notify the administrator of the hold
443         if self.admin_immed_notify:
444             realname = self.real_name
445             subject = _(
446                 'New unsubscription request from %(realname)s by %(addr)s')
447             text = Utils.maketext(
448                 'unsubauth.txt',
449                 {'username'   : addr,
450                  'listname'   : self.internal_name(),
451                  'hostname'   : self.host_name,
452                  'admindb_url': self.GetScriptURL('admindb', absolute=1),
453                  }, mlist=self)
454             # This message should appear to come from the <list>-owner so as
455             # to avoid any useless bounce processing.
456             owneraddr = self.GetOwnerEmail()
457             msg = Message.UserNotification(owneraddr, owneraddr, subject, text,
458                                            self.preferred_language)
459             msg.send(self, **{'tomoderators': 1})
460
461     def __handleunsubscription(self, record, value, comment):
462         addr = record
463         if value == mm_cfg.DEFER:
464             return DEFER
465         elif value == mm_cfg.DISCARD:
466             pass
467         elif value == mm_cfg.REJECT:
468             self.__refuse(_('Unsubscription request'), addr, comment)
469         else:
470             assert value == mm_cfg.UNSUBSCRIBE
471             try:
472                 self.ApprovedDeleteMember(addr)
473             except Errors.NotAMemberError:
474                 # User has already been unsubscribed
475                 pass
476         return REMOVE
477
478     def __refuse(self, request, recip, comment, origmsg=None, lang=None):
479         # As this message is going to the requestor, try to set the language
480         # to his/her language choice, if they are a member.  Otherwise use the
481         # list's preferred language.
482         realname = self.real_name
483         if lang is None:
484             lang = self.getMemberLanguage(recip)
485         text = Utils.maketext(
486             'refuse.txt',
487             {'listname' : realname,
488              'request'  : request,
489              'reason'   : comment,
490              'adminaddr': self.GetOwnerEmail(),
491             }, lang=lang, mlist=self)
492         otrans = i18n.get_translation()
493         i18n.set_language(lang)
494         try:
495             # add in original message, but not wrap/filled
496             if origmsg:
497                 text = NL.join(
498                     [text,
499                      '---------- ' + _('Original Message') + ' ----------',
500                      str(origmsg)
501                      ])
502             subject = _('Request to mailing list %(realname)s rejected')
503         finally:
504             i18n.set_translation(otrans)
505         msg = Message.UserNotification(recip, self.GetBouncesEmail(),
506                                        subject, text, lang)
507         msg.send(self)
508
509     def _UpdateRecords(self):
510         # Subscription records have changed since MM2.0.x.  In that family,
511         # the records were of length 4, containing the request time, the
512         # address, the password, and the digest flag.  In MM2.1a2, they grew
513         # an additional language parameter at the end.  In MM2.1a4, they grew
514         # a fullname slot after the address.  This semi-public method is used
515         # by the update script to coerce all subscription records to the
516         # latest MM2.1 format.
517         #
518         # Held message records have historically either 5 or 6 items too.
519         # These always include the requests time, the sender, subject, default
520         # rejection reason, and message text.  When of length 6, it also
521         # includes the message metadata dictionary on the end of the tuple.
522         #
523         # In Mailman 2.1.5 we converted these files to pickles.
524         filename = os.path.join(self.fullpath(), 'request.db')
525         try:
526             fp = open(filename)
527             try:
528                 self.__db = marshal.load(fp)
529             finally:
530                 fp.close()
531             os.unlink(filename)
532         except IOError, e:
533             if e.errno <> errno.ENOENT: raise
534             filename = os.path.join(self.fullpath(), 'request.pck')
535             try:
536                 fp = open(filename)
537                 try:
538                     self.__db = cPickle.load(fp)
539                 finally:
540                     fp.close()
541             except IOError, e:
542                 if e.errno <> errno.ENOENT: raise
543                 self.__db = {}
544         for id, (op, info) in self.__db.items():
545             if op == SUBSCRIPTION:
546                 if len(info) == 4:
547                     # pre-2.1a2 compatibility
548                     when, addr, passwd, digest = info
549                     fullname = ''
550                     lang = self.preferred_language
551                 elif len(info) == 5:
552                     # pre-2.1a4 compatibility
553                     when, addr, passwd, digest, lang = info
554                     fullname = ''
555                 else:
556                     assert len(info) == 6, 'Unknown subscription record layout'
557                     continue
558                 # Here's the new layout
559                 self.__db[id] = when, addr, fullname, passwd, digest, lang
560             elif op == HELDMSG:
561                 if len(info) == 5:
562                     when, sender, subject, reason, text = info
563                     msgdata = {}
564                 else:
565                     assert len(info) == 6, 'Unknown held msg record layout'
566                     continue
567                 # Here's the new layout
568                 self.__db[id] = when, sender, subject, reason, text, msgdata
569         # All done
570         self.__closedb()
571
572
573 \f
574 def readMessage(path):
575     # For backwards compatibility, we must be able to read either a flat text
576     # file or a pickle.
577     ext = os.path.splitext(path)[1]
578     fp = open(path)
579     try:
580         if ext == '.txt':
581             msg = email.message_from_file(fp, Message.Message)
582         else:
583             assert ext == '.pck'
584             msg = cPickle.load(fp)
585     finally:
586         fp.close()
587     return msg