~mailman-coders/mailman/2.1

1779 by Mark Sapiro
Bump copyright dates.
1
# Copyright (C) 1998-2018 by the Free Software Foundation, Inc.
1 by
This commit was manufactured by cvs2svn to create branch
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
830 by bwarsaw
A cleansing pass, almost entirely cosmetic. Such things as whitespace
15
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
16
# USA.
1 by
This commit was manufactured by cvs2svn to create branch
17
564 by bwarsaw
admin.py:
18
"""Process and produce the list-administration options forms."""
1 by
This commit was manufactured by cvs2svn to create branch
19
20
# For Python 2.1.x compatibility
21
from __future__ import nested_scopes
22
23
import sys
24
import os
25
import re
26
import cgi
27
import urllib
28
import signal
29
from types import *
30
31
from email.Utils import unquote, parseaddr, formataddr
32
33
from Mailman import mm_cfg
34
from Mailman import Utils
1551 by Mark Sapiro
Implemented member address change via the admin GUI.
35
from Mailman import Message
1 by
This commit was manufactured by cvs2svn to create branch
36
from Mailman import MailList
37
from Mailman import Errors
38
from Mailman import MemberAdaptor
39
from Mailman import i18n
40
from Mailman.UserDesc import UserDesc
41
from Mailman.htmlformat import *
42
from Mailman.Cgi import Auth
43
from Mailman.Logging.Syslog import syslog
1135 by Barry Warsaw
Apply Heiko Rommel's patch for hashlib deprecation warnings for bug 293178.
44
from Mailman.Utils import sha_new
1337 by Mark Sapiro
Added Tokio Kikuchi's Cross-site Request Forgery hardening to the admin UI.
45
from Mailman.CSRFcheck import csrf_check
1 by
This commit was manufactured by cvs2svn to create branch
46
47
# Set up i18n
48
_ = i18n._
49
i18n.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE)
1776 by Mark Sapiro
I18n for new whence reasons in admin (un)subscribe notices.
50
def D_(s):
51
    return s
1 by
This commit was manufactured by cvs2svn to create branch
52
53
NL = '\n'
54
OPTCOLUMNS = 11
55
205 by bwarsaw
get_item_gui_value(): Added a new widget HeaderFilter and associated code to
56
try:
57
    True, False
58
except NameError:
59
    True = 1
60
    False = 0
61
1337 by Mark Sapiro
Added Tokio Kikuchi's Cross-site Request Forgery hardening to the admin UI.
62
AUTH_CONTEXTS = (mm_cfg.AuthListAdmin, mm_cfg.AuthSiteAdmin)
63
1 by
This commit was manufactured by cvs2svn to create branch
64
65

66
def main():
67
    # Try to find out which list is being administered
68
    parts = Utils.GetPathPieces()
69
    if not parts:
70
        # None, so just do the admin overview and be done with it
71
        admin_overview()
72
        return
73
    # Get the list object
74
    listname = parts[0].lower()
75
    try:
76
        mlist = MailList.MailList(listname, lock=0)
77
    except Errors.MMListError, e:
78
        # Avoid cross-site scripting attacks
79
        safelistname = Utils.websafe(listname)
1231 by Mark Sapiro
Added roster to the CGIs that return HTTP 401 status for an authentication
80
        # Send this with a 404 status.
81
        print 'Status: 404 Not Found'
1 by
This commit was manufactured by cvs2svn to create branch
82
        admin_overview(_('No such list <em>%(safelistname)s</em>'))
1451 by Mark Sapiro
- Added the list name to the vette log "held message approved" entry.
83
        syslog('error', 'admin: No such list "%s": %s\n',
84
               listname, e)
1 by
This commit was manufactured by cvs2svn to create branch
85
        return
86
    # Now that we know what list has been requested, all subsequent admin
87
    # pages are shown in that list's preferred language.
88
    i18n.set_language(mlist.preferred_language)
89
    # If the user is not authenticated, we're done.
90
    cgidata = cgi.FieldStorage(keep_blank_values=1)
1663 by Mark Sapiro
Catch TypeError from certain defective crafted POST requests.
91
    try:
1712 by Mark Sapiro
Defend against CGI requests with multiple values for the same parameter.
92
        cgidata.getfirst('csrf_token', '')
1663 by Mark Sapiro
Catch TypeError from certain defective crafted POST requests.
93
    except TypeError:
94
        # Someone crafted a POST with a bad Content-Type:.
95
        doc = Document()
96
        doc.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE)
97
        doc.AddItem(Header(2, _("Error")))
98
        doc.AddItem(Bold(_('Invalid options to CGI script.')))
99
        # Send this with a 400 status.
100
        print 'Status: 400 Bad Request'
101
        print doc.Format()
102
        return
1 by
This commit was manufactured by cvs2svn to create branch
103
1337 by Mark Sapiro
Added Tokio Kikuchi's Cross-site Request Forgery hardening to the admin UI.
104
    # CSRF check
1340 by Mark Sapiro
Added a few more safe_params to the CSRF check.
105
    safe_params = ['VARHELP', 'adminpw', 'admlogin',
1366 by Mark Sapiro
Added 'legend' to the list of CSRF safe parameters for the admin CGI.
106
                   'letter', 'chunk', 'findmember',
107
                   'legend']
1337 by Mark Sapiro
Added Tokio Kikuchi's Cross-site Request Forgery hardening to the admin UI.
108
    params = cgidata.keys()
109
    if set(params) - set(safe_params):
1882 by Mark Sapiro
Block CSRF attack against admin or admindb pages.
110
        csrf_checked = csrf_check(mlist, cgidata.getfirst('csrf_token'),
111
                                  'admin')
1337 by Mark Sapiro
Added Tokio Kikuchi's Cross-site Request Forgery hardening to the admin UI.
112
    else:
113
        csrf_checked = True
114
    # if password is present, void cookie to force password authentication.
1712 by Mark Sapiro
Defend against CGI requests with multiple values for the same parameter.
115
    if cgidata.getfirst('adminpw'):
1337 by Mark Sapiro
Added Tokio Kikuchi's Cross-site Request Forgery hardening to the admin UI.
116
        os.environ['HTTP_COOKIE'] = ''
117
        csrf_checked = True
118
1 by
This commit was manufactured by cvs2svn to create branch
119
    if not mlist.WebAuthenticate((mm_cfg.AuthListAdmin,
120
                                  mm_cfg.AuthSiteAdmin),
1712 by Mark Sapiro
Defend against CGI requests with multiple values for the same parameter.
121
                                 cgidata.getfirst('adminpw', '')):
1 by
This commit was manufactured by cvs2svn to create branch
122
        if cgidata.has_key('adminpw'):
123
            # This is a re-authorization attempt
124
            msg = Bold(FontSize('+1', _('Authorization failed.'))).Format()
1766.1.1 by Jim Popovitch
Improved logging of security related events
125
            remote = os.environ.get('HTTP_FORWARDED_FOR',
126
                     os.environ.get('HTTP_X_FORWARDED_FOR',
127
                     os.environ.get('REMOTE_ADDR',
128
                                    'unidentified origin')))
1768 by Mark Sapiro
Implement security log.
129
            syslog('security',
130
                   'Authorization failed (admin): list=%s: remote=%s',
131
                   listname, remote)
1 by
This commit was manufactured by cvs2svn to create branch
132
        else:
133
            msg = ''
134
        Auth.loginpage(mlist, 'admin', msg=msg)
135
        return
136
137
    # Which subcategory was requested?  Default is `general'
138
    if len(parts) == 1:
139
        category = 'general'
140
        subcat = None
141
    elif len(parts) == 2:
142
        category = parts[1]
143
        subcat = None
144
    else:
145
        category = parts[1]
146
        subcat = parts[2]
147
148
    # Is this a log-out request?
149
    if category == 'logout':
1293 by Mark Sapiro
Added a logout link to the admindb interface and made both admin and
150
        # site-wide admin should also be able to logout.
151
        if mlist.AuthContextInfo(mm_cfg.AuthSiteAdmin)[0] == 'site':
152
            print mlist.ZapCookie(mm_cfg.AuthSiteAdmin)
1 by
This commit was manufactured by cvs2svn to create branch
153
        print mlist.ZapCookie(mm_cfg.AuthListAdmin)
154
        Auth.loginpage(mlist, 'admin', frontpage=1)
155
        return
156
157
    # Sanity check
158
    if category not in mlist.GetConfigCategories().keys():
159
        category = 'general'
160
161
    # Is the request for variable details?
162
    varhelp = None
163
    qsenviron = os.environ.get('QUERY_STRING')
164
    parsedqs = None
165
    if qsenviron:
166
        parsedqs = cgi.parse_qs(qsenviron)
167
    if cgidata.has_key('VARHELP'):
1712 by Mark Sapiro
Defend against CGI requests with multiple values for the same parameter.
168
        varhelp = cgidata.getfirst('VARHELP')
1 by
This commit was manufactured by cvs2svn to create branch
169
    elif parsedqs:
170
        # POST methods, even if their actions have a query string, don't get
171
        # put into FieldStorage's keys :-(
172
        qs = parsedqs.get('VARHELP')
173
        if qs and isinstance(qs, ListType):
174
            varhelp = qs[0]
175
    if varhelp:
176
        option_help(mlist, varhelp)
177
        return
178
179
    # The html page document
180
    doc = Document()
181
    doc.set_language(mlist.preferred_language)
182
183
    # From this point on, the MailList object must be locked.  However, we
184
    # must release the lock no matter how we exit.  try/finally isn't enough,
185
    # because of this scenario: user hits the admin page which may take a long
186
    # time to render; user gets bored and hits the browser's STOP button;
187
    # browser shuts down socket; server tries to write to broken socket and
188
    # gets a SIGPIPE.  Under Apache 1.3/mod_cgi, Apache catches this SIGPIPE
189
    # (I presume it is buffering output from the cgi script), then turns
190
    # around and SIGTERMs the cgi process.  Apache waits three seconds and
191
    # then SIGKILLs the cgi process.  We /must/ catch the SIGTERM and do the
192
    # most reasonable thing we can in as short a time period as possible.  If
193
    # we get the SIGKILL we're screwed (because it's uncatchable and we'll
194
    # have no opportunity to clean up after ourselves).
195
    #
196
    # This signal handler catches the SIGTERM, unlocks the list, and then
197
    # exits the process.  The effect of this is that the changes made to the
198
    # MailList object will be aborted, which seems like the only sensible
199
    # semantics.
200
    #
201
    # BAW: This may not be portable to other web servers or cgi execution
202
    # models.
203
    def sigterm_handler(signum, frame, mlist=mlist):
204
        # Make sure the list gets unlocked...
205
        mlist.Unlock()
206
        # ...and ensure we exit, otherwise race conditions could cause us to
207
        # enter MailList.Save() while we're in the unlocked state, and that
208
        # could be bad!
209
        sys.exit(0)
210
211
    mlist.Lock()
212
    try:
213
        # Install the emergency shutdown signal handler
214
        signal.signal(signal.SIGTERM, sigterm_handler)
215
216
        if cgidata.keys():
1337 by Mark Sapiro
Added Tokio Kikuchi's Cross-site Request Forgery hardening to the admin UI.
217
            if csrf_checked:
218
                # There are options to change
219
                change_options(mlist, category, subcat, cgidata, doc)
220
            else:
221
                doc.addError(
222
                  _('The form lifetime has expired. (request forgery check)'))
1 by
This commit was manufactured by cvs2svn to create branch
223
            # Let the list sanity check the changed values
224
            mlist.CheckValues()
225
        # Additional sanity checks
226
        if not mlist.digestable and not mlist.nondigestable:
227
            doc.addError(
228
                _('''You have turned off delivery of both digest and
229
                non-digest messages.  This is an incompatible state of
230
                affairs.  You must turn on either digest delivery or
231
                non-digest delivery or your mailing list will basically be
232
                unusable.'''), tag=_('Warning: '))
233
1289 by Mark Sapiro
Refactor last change for i18n.
234
        dm = mlist.getDigestMemberKeys()
235
        if not mlist.digestable and dm:
1 by
This commit was manufactured by cvs2svn to create branch
236
            doc.addError(
237
                _('''You have digest members, but digests are turned
1288 by Mark Sapiro
Added a report of the affected members to the warnings issued when
238
                off. Those people will not receive mail.
1289 by Mark Sapiro
Refactor last change for i18n.
239
                Affected member(s) %(dm)r.'''),
1 by
This commit was manufactured by cvs2svn to create branch
240
                tag=_('Warning: '))
1289 by Mark Sapiro
Refactor last change for i18n.
241
        rm = mlist.getRegularMemberKeys()
242
        if not mlist.nondigestable and rm:
1 by
This commit was manufactured by cvs2svn to create branch
243
            doc.addError(
244
                _('''You have regular list members but non-digestified mail is
1264 by Mark Sapiro
Made minor wording improvements and typo corrections in some messages.
245
                turned off.  They will receive non-digestified mail until you
1289 by Mark Sapiro
Refactor last change for i18n.
246
                fix this problem. Affected member(s) %(rm)r.'''),
247
                tag=_('Warning: '))
1 by
This commit was manufactured by cvs2svn to create branch
248
        # Glom up the results page and print it out
249
        show_results(mlist, doc, category, subcat, cgidata)
250
        print doc.Format()
251
        mlist.Save()
252
    finally:
253
        # Now be sure to unlock the list.  It's okay if we get a signal here
254
        # because essentially, the signal handler will do the same thing.  And
255
        # unlocking is unconditional, so it's not an error if we unlock while
256
        # we're already unlocked.
257
        mlist.Unlock()
258
259
260

261
def admin_overview(msg=''):
262
    # Show the administrative overview page, with the list of all the lists on
263
    # this host.  msg is an optional error message to display at the top of
264
    # the page.
265
    #
266
    # This page should be displayed in the server's default language, which
267
    # should have already been set.
268
    hostname = Utils.get_domain()
269
    legend = _('%(hostname)s mailing lists - Admin Links')
270
    # The html `document'
271
    doc = Document()
272
    doc.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE)
273
    doc.SetTitle(legend)
274
    # The table that will hold everything
275
    table = Table(border=0, width="100%")
276
    table.AddRow([Center(Header(2, legend))])
277
    table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2,
278
                      bgcolor=mm_cfg.WEB_HEADER_COLOR)
279
    # Skip any mailing list that isn't advertised.
280
    advertised = []
281
    listnames = Utils.list_names()
282
    listnames.sort()
283
284
    for name in listnames:
1651 by Mark Sapiro
Catch MMUnknownListError in case list is removed after listing names.
285
        try:
286
            mlist = MailList.MailList(name, lock=0)
287
        except Errors.MMUnknownListError:
288
            # The list could have been deleted by another process.
289
            continue
1 by
This commit was manufactured by cvs2svn to create branch
290
        if mlist.advertised:
1241 by Mark Sapiro
Fixed a bug which would fail to show a list on the admin and listinfo
291
            if mm_cfg.VIRTUAL_HOST_OVERVIEW and (
292
                   mlist.web_page_url.find('/%s/' % hostname) == -1 and
293
                   mlist.web_page_url.find('/%s:' % hostname) == -1):
1 by
This commit was manufactured by cvs2svn to create branch
294
                # List is for different identity of this host - skip it.
295
                continue
296
            else:
247 by bwarsaw
adminy_overview(): Richard Barrett's patch # 828811 to reduce listinfo
297
                advertised.append((mlist.GetScriptURL('admin'),
298
                                   mlist.real_name,
1781.1.2 by Yasuhito FUTATSUKI at POEM
* apply Utils.websafe() to description string in admin.py
299
                                   Utils.websafe(mlist.GetDescription())))
1 by
This commit was manufactured by cvs2svn to create branch
300
    # Greeting depends on whether there was an error or not
301
    if msg:
302
        greeting = FontAttr(msg, color="ff5060", size="+1")
303
    else:
1243 by Mark Sapiro
Increased the font size of 'Welcome!'on admin overview for consistency with listinfo.
304
        greeting = FontAttr(_('Welcome!'), size='+2')
1 by
This commit was manufactured by cvs2svn to create branch
305
306
    welcome = []
307
    mailmanlink = Link(mm_cfg.MAILMAN_URL, _('Mailman')).Format()
308
    if not advertised:
309
        welcome.extend([
310
            greeting,
311
            _('''<p>There currently are no publicly-advertised %(mailmanlink)s
312
            mailing lists on %(hostname)s.'''),
313
            ])
314
    else:
315
        welcome.extend([
316
            greeting,
317
            _('''<p>Below is the collection of publicly-advertised
318
            %(mailmanlink)s mailing lists on %(hostname)s.  Click on a list
319
            name to visit the configuration pages for that list.'''),
320
            ])
321
322
    creatorurl = Utils.ScriptURL('create')
323
    mailman_owner = Utils.get_site_email()
324
    extra = msg and _('right ') or ''
325
    welcome.extend([
326
        _('''To visit the administrators configuration page for an
327
        unadvertised list, open a URL similar to this one, but with a '/' and
328
        the %(extra)slist name appended.  If you have the proper authority,
329
        you can also <a href="%(creatorurl)s">create a new mailing list</a>.
330
331
        <p>General list information can be found at '''),
332
        Link(Utils.ScriptURL('listinfo'),
333
             _('the mailing list overview page')),
334
        '.',
335
        _('<p>(Send questions and comments to '),
336
        Link('mailto:%s' % mailman_owner, mailman_owner),
337
        '.)<p>',
338
        ])
339
340
    table.AddRow([Container(*welcome)])
341
    table.AddCellInfo(max(table.GetCurrentRowIndex(), 0), 0, colspan=2)
342
343
    if advertised:
344
        table.AddRow(['&nbsp;', '&nbsp;'])
345
        table.AddRow([Bold(FontAttr(_('List'), size='+2')),
346
                      Bold(FontAttr(_('Description'), size='+2'))
347
                      ])
348
        highlight = 1
247 by bwarsaw
adminy_overview(): Richard Barrett's patch # 828811 to reduce listinfo
349
        for url, real_name, description in advertised:
1 by
This commit was manufactured by cvs2svn to create branch
350
            table.AddRow(
247 by bwarsaw
adminy_overview(): Richard Barrett's patch # 828811 to reduce listinfo
351
                [Link(url, Bold(real_name)),
352
                      description or Italic(_('[no description available]'))])
1 by
This commit was manufactured by cvs2svn to create branch
353
            if highlight and mm_cfg.WEB_HIGHLIGHT_COLOR:
354
                table.AddRowInfo(table.GetCurrentRowIndex(),
355
                                 bgcolor=mm_cfg.WEB_HIGHLIGHT_COLOR)
356
            highlight = not highlight
357
358
    doc.AddItem(table)
359
    doc.AddItem('<hr>')
360
    doc.AddItem(MailmanLogo())
361
    print doc.Format()
362
363
364

365
def option_help(mlist, varhelp):
366
    # The html page document
367
    doc = Document()
368
    doc.set_language(mlist.preferred_language)
369
    # Find out which category and variable help is being requested for.
370
    item = None
371
    reflist = varhelp.split('/')
372
    if len(reflist) >= 2:
373
        category = subcat = None
374
        if len(reflist) == 2:
375
            category, varname = reflist
376
        elif len(reflist) == 3:
377
            category, subcat, varname = reflist
378
        options = mlist.GetConfigInfo(category, subcat)
951 by msapiro
Fixed admin.py so null VARHELP category is handled (1573393).
379
        if options:
380
            for i in options:
381
                if i and i[0] == varname:
382
                    item = i
383
                    break
1 by
This commit was manufactured by cvs2svn to create branch
384
    # Print an error message if we couldn't find a valid one
385
    if not item:
386
        bad = _('No valid variable name found.')
387
        doc.addError(bad)
388
        doc.AddItem(mlist.GetMailmanFooter())
389
        print doc.Format()
390
        return
391
    # Get the details about the variable
392
    varname, kind, params, dependancies, description, elaboration = \
393
             get_item_characteristics(item)
394
    # Set up the document
395
    realname = mlist.real_name
396
    legend = _("""%(realname)s Mailing list Configuration Help
397
    <br><em>%(varname)s</em> Option""")
398
399
    header = Table(width='100%')
400
    header.AddRow([Center(Header(3, legend))])
401
    header.AddCellInfo(header.GetCurrentRowIndex(), 0, colspan=2,
402
                       bgcolor=mm_cfg.WEB_HEADER_COLOR)
403
    doc.SetTitle(_("Mailman %(varname)s List Option Help"))
404
    doc.AddItem(header)
405
    doc.AddItem("<b>%s</b> (%s): %s<p>" % (varname, category, description))
406
    if elaboration:
407
        doc.AddItem("%s<p>" % elaboration)
408
409
    if subcat:
410
        url = '%s/%s/%s' % (mlist.GetScriptURL('admin'), category, subcat)
411
    else:
412
        url = '%s/%s' % (mlist.GetScriptURL('admin'), category)
1337 by Mark Sapiro
Added Tokio Kikuchi's Cross-site Request Forgery hardening to the admin UI.
413
    form = Form(url, mlist=mlist, contexts=AUTH_CONTEXTS)
1 by
This commit was manufactured by cvs2svn to create branch
414
    valtab = Table(cellspacing=3, cellpadding=4, width='100%')
415
    add_options_table_item(mlist, category, subcat, valtab, item, detailsp=0)
416
    form.AddItem(valtab)
417
    form.AddItem('<p>')
418
    form.AddItem(Center(submit_button()))
419
    doc.AddItem(Center(form))
420
421
    doc.AddItem(_("""<em><strong>Warning:</strong> changing this option here
422
    could cause other screens to be out-of-sync.  Be sure to reload any other
423
    pages that are displaying this option for this mailing list.  You can also
424
    """))
425
426
    adminurl = mlist.GetScriptURL('admin')
427
    if subcat:
428
        url = '%s/%s/%s' % (adminurl, category, subcat)
429
    else:
430
        url = '%s/%s' % (adminurl, category)
431
    categoryname = mlist.GetConfigCategories()[category][0]
432
    doc.AddItem(Link(url, _('return to the %(categoryname)s options page.')))
433
    doc.AddItem('</em>')
434
    doc.AddItem(mlist.GetMailmanFooter())
435
    print doc.Format()
436
437
438

439
def show_results(mlist, doc, category, subcat, cgidata):
440
    # Produce the results page
441
    adminurl = mlist.GetScriptURL('admin')
442
    categories = mlist.GetConfigCategories()
443
    label = _(categories[category][0])
444
445
    # Set up the document's headers
446
    realname = mlist.real_name
447
    doc.SetTitle(_('%(realname)s Administration (%(label)s)'))
448
    doc.AddItem(Center(Header(2, _(
449
        '%(realname)s mailing list administration<br>%(label)s Section'))))
450
    doc.AddItem('<hr>')
451
    # Now we need to craft the form that will be submitted, which will contain
452
    # all the variable settings, etc.  This is a bit of a kludge because we
453
    # know that the autoreply and members categories supports file uploads.
454
    encoding = None
455
    if category in ('autoreply', 'members'):
456
        encoding = 'multipart/form-data'
457
    if subcat:
458
        form = Form('%s/%s/%s' % (adminurl, category, subcat),
1337 by Mark Sapiro
Added Tokio Kikuchi's Cross-site Request Forgery hardening to the admin UI.
459
                    encoding=encoding, mlist=mlist, contexts=AUTH_CONTEXTS)
1 by
This commit was manufactured by cvs2svn to create branch
460
    else:
1820 by Mark Sapiro
Implemented web admin sync members.
461
        form = Form('%s/%s' % (adminurl, category),
1337 by Mark Sapiro
Added Tokio Kikuchi's Cross-site Request Forgery hardening to the admin UI.
462
                    encoding=encoding, mlist=mlist, contexts=AUTH_CONTEXTS)
1 by
This commit was manufactured by cvs2svn to create branch
463
    # This holds the two columns of links
464
    linktable = Table(valign='top', width='100%')
465
    linktable.AddRow([Center(Bold(_("Configuration Categories"))),
466
                      Center(Bold(_("Other Administrative Activities")))])
467
    # The `other links' are stuff in the right column.
468
    otherlinks = UnorderedList()
469
    otherlinks.AddItem(Link(mlist.GetScriptURL('admindb'),
470
                            _('Tend to pending moderator requests')))
471
    otherlinks.AddItem(Link(mlist.GetScriptURL('listinfo'),
472
                            _('Go to the general list information page')))
473
    otherlinks.AddItem(Link(mlist.GetScriptURL('edithtml'),
564 by bwarsaw
admin.py:
474
                            _('Edit the public HTML pages and text files')))
1 by
This commit was manufactured by cvs2svn to create branch
475
    otherlinks.AddItem(Link(mlist.GetBaseArchiveURL(),
476
                            _('Go to list archives')).Format() +
477
                       '<br>&nbsp;<br>')
478
    # We do not allow through-the-web deletion of the site list!
479
    if mm_cfg.OWNERS_CAN_DELETE_THEIR_OWN_LISTS and \
480
           mlist.internal_name() <> mm_cfg.MAILMAN_SITE_LIST:
481
        otherlinks.AddItem(Link(mlist.GetScriptURL('rmlist'),
482
                                _('Delete this mailing list')).Format() +
483
                           _(' (requires confirmation)<br>&nbsp;<br>'))
484
    otherlinks.AddItem(Link('%s/logout' % adminurl,
485
                            # BAW: What I really want is a blank line, but
486
                            # adding an &nbsp; won't do it because of the
487
                            # bullet added to the list item.
488
                            '<FONT SIZE="+2"><b>%s</b></FONT>' %
489
                            _('Logout')))
490
    # These are links to other categories and live in the left column
491
    categorylinks_1 = categorylinks = UnorderedList()
492
    categorylinks_2 = ''
493
    categorykeys = categories.keys()
494
    half = len(categorykeys) / 2
495
    counter = 0
496
    subcat = None
497
    for k in categorykeys:
498
        label = _(categories[k][0])
499
        url = '%s/%s' % (adminurl, k)
500
        if k == category:
501
            # Handle subcategories
502
            subcats = mlist.GetConfigSubCategories(k)
503
            if subcats:
504
                subcat = Utils.GetPathPieces()[-1]
505
                for k, v in subcats:
506
                    if k == subcat:
507
                        break
508
                else:
509
                    # The first subcategory in the list is the default
510
                    subcat = subcats[0][0]
511
                subcat_items = []
512
                for sub, text in subcats:
513
                    if sub == subcat:
514
                        text = Bold('[%s]' % text).Format()
515
                    subcat_items.append(Link(url + '/' + sub, text))
516
                categorylinks.AddItem(
517
                    Bold(label).Format() +
518
                    UnorderedList(*subcat_items).Format())
519
            else:
520
                categorylinks.AddItem(Link(url, Bold('[%s]' % label)))
521
        else:
522
            categorylinks.AddItem(Link(url, label))
523
        counter += 1
524
        if counter >= half:
525
            categorylinks_2 = categorylinks = UnorderedList()
526
            counter = -len(categorykeys)
527
    # Make the emergency stop switch a rude solo light
528
    etable = Table()
529
    # Add all the links to the links table...
530
    etable.AddRow([categorylinks_1, categorylinks_2])
531
    etable.AddRowInfo(etable.GetCurrentRowIndex(), valign='top')
532
    if mlist.emergency:
533
        label = _('Emergency moderation of all list traffic is enabled')
534
        etable.AddRow([Center(
535
            Link('?VARHELP=general/emergency', Bold(label)))])
536
        color = mm_cfg.WEB_ERROR_COLOR
537
        etable.AddCellInfo(etable.GetCurrentRowIndex(), 0,
538
                           colspan=2, bgcolor=color)
539
    linktable.AddRow([etable, otherlinks])
540
    # ...and add the links table to the document.
541
    form.AddItem(linktable)
542
    form.AddItem('<hr>')
543
    form.AddItem(
544
        _('''Make your changes in the following section, then submit them
545
        using the <em>Submit Your Changes</em> button below.''')
546
        + '<p>')
547
548
    # The members and passwords categories are special in that they aren't
549
    # defined in terms of gui elements.  Create those pages here.
550
    if category == 'members':
551
        # Figure out which subcategory we should display
552
        subcat = Utils.GetPathPieces()[-1]
1820 by Mark Sapiro
Implemented web admin sync members.
553
        if subcat not in ('list', 'add', 'remove', 'change', 'sync'):
1 by
This commit was manufactured by cvs2svn to create branch
554
            subcat = 'list'
555
        # Add member category specific tables
556
        form.AddItem(membership_options(mlist, subcat, cgidata, doc, form))
557
        form.AddItem(Center(submit_button('setmemberopts_btn')))
558
        # In "list" subcategory, we can also search for members
559
        if subcat == 'list':
560
            form.AddItem('<hr>\n')
561
            table = Table(width='100%')
562
            table.AddRow([Center(Header(2, _('Additional Member Tasks')))])
563
            table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2,
564
                              bgcolor=mm_cfg.WEB_HEADER_COLOR)
565
            # Add a blank separator row
566
            table.AddRow(['&nbsp;', '&nbsp;'])
567
            # Add a section to set the moderation bit for all members
568
            table.AddRow([_("""<li>Set everyone's moderation bit, including
569
            those members not currently visible""")])
570
            table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
571
            table.AddRow([RadioButtonArray('allmodbit_val',
572
                                           (_('Off'), _('On')),
573
                                           mlist.default_member_moderation),
574
                          SubmitButton('allmodbit_btn', _('Set'))])
575
            form.AddItem(table)
576
    elif category == 'passwords':
577
        form.AddItem(Center(password_inputs(mlist)))
578
        form.AddItem(Center(submit_button()))
579
    else:
580
        form.AddItem(show_variables(mlist, category, subcat, cgidata, doc))
581
        form.AddItem(Center(submit_button()))
582
    # And add the form
583
    doc.AddItem(form)
584
    doc.AddItem(mlist.GetMailmanFooter())
585
586
587

588
def show_variables(mlist, category, subcat, cgidata, doc):
589
    options = mlist.GetConfigInfo(category, subcat)
590
591
    # The table containing the results
592
    table = Table(cellspacing=3, cellpadding=4, width='100%')
593
594
    # Get and portray the text label for the category.
595
    categories = mlist.GetConfigCategories()
596
    label = _(categories[category][0])
597
598
    table.AddRow([Center(Header(2, label))])
599
    table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2,
600
                      bgcolor=mm_cfg.WEB_HEADER_COLOR)
601
602
    # The very first item in the config info will be treated as a general
603
    # description if it is a string
604
    description = options[0]
605
    if isinstance(description, StringType):
606
        table.AddRow([description])
607
        table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
608
        options = options[1:]
609
610
    if not options:
611
        return table
612
613
    # Add the global column headers
614
    table.AddRow([Center(Bold(_('Description'))),
615
                  Center(Bold(_('Value')))])
616
    table.AddCellInfo(max(table.GetCurrentRowIndex(), 0), 0,
617
                      width='15%')
618
    table.AddCellInfo(max(table.GetCurrentRowIndex(), 0), 1,
619
                      width='85%')
620
621
    for item in options:
622
        if type(item) == StringType:
623
            # The very first banner option (string in an options list) is
624
            # treated as a general description, while any others are
625
            # treated as section headers - centered and italicized...
626
            table.AddRow([Center(Italic(item))])
627
            table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
628
        else:
629
            add_options_table_item(mlist, category, subcat, table, item)
630
    table.AddRow(['<br>'])
631
    table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
632
    return table
633
634
635

636
def add_options_table_item(mlist, category, subcat, table, item, detailsp=1):
637
    # Add a row to an options table with the item description and value.
638
    varname, kind, params, extra, descr, elaboration = \
639
             get_item_characteristics(item)
640
    if elaboration is None:
641
        elaboration = descr
642
    descr = get_item_gui_description(mlist, category, subcat,
643
                                     varname, descr, elaboration, detailsp)
644
    val = get_item_gui_value(mlist, category, kind, varname, params, extra)
645
    table.AddRow([descr, val])
646
    table.AddCellInfo(table.GetCurrentRowIndex(), 0,
647
                      bgcolor=mm_cfg.WEB_ADMINITEM_COLOR)
648
    table.AddCellInfo(table.GetCurrentRowIndex(), 1,
649
                      bgcolor=mm_cfg.WEB_ADMINITEM_COLOR)
650
651
652

653
def get_item_characteristics(record):
654
    # Break out the components of an item description from its description
655
    # record:
656
    #
657
    # 0 -- option-var name
658
    # 1 -- type
659
    # 2 -- entry size
660
    # 3 -- ?dependancies?
661
    # 4 -- Brief description
662
    # 5 -- Optional description elaboration
663
    if len(record) == 5:
664
        elaboration = None
665
        varname, kind, params, dependancies, descr = record
666
    elif len(record) == 6:
667
        varname, kind, params, dependancies, descr, elaboration = record
668
    else:
669
        raise ValueError, _('Badly formed options entry:\n %(record)s')
670
    return varname, kind, params, dependancies, descr, elaboration
671
672
673

674
def get_item_gui_value(mlist, category, kind, varname, params, extra):
675
    """Return a representation of an item's settings."""
676
    # Give the category a chance to return the value for the variable
677
    value = None
678
    label, gui = mlist.GetConfigCategories()[category]
679
    if hasattr(gui, 'getValue'):
680
        value = gui.getValue(mlist, kind, varname, params)
681
    # Filter out None, and volatile attributes
682
    if value is None and not varname.startswith('_'):
683
        value = getattr(mlist, varname)
684
    # Now create the widget for this value
685
    if kind == mm_cfg.Radio or kind == mm_cfg.Toggle:
686
        # If we are returning the option for subscribe policy and this site
687
        # doesn't allow open subscribes, then we have to alter the value of
688
        # mlist.subscribe_policy as passed to RadioButtonArray in order to
689
        # compensate for the fact that there is one fewer option.
690
        # Correspondingly, we alter the value back in the change options
691
        # function -scott
692
        #
693
        # TBD: this is an ugly ugly hack.
694
        if varname.startswith('_'):
695
            checked = 0
696
        else:
697
            checked = value
698
        if varname == 'subscribe_policy' and not mm_cfg.ALLOW_OPEN_SUBSCRIBE:
699
            checked = checked - 1
700
        # For Radio buttons, we're going to interpret the extra stuff as a
701
        # horizontal/vertical flag.  For backwards compatibility, the value 0
702
        # means horizontal, so we use "not extra" to get the parity right.
703
        return RadioButtonArray(varname, params, checked, not extra)
704
    elif (kind == mm_cfg.String or kind == mm_cfg.Email or
705
          kind == mm_cfg.Host or kind == mm_cfg.Number):
706
        return TextBox(varname, value, params)
707
    elif kind == mm_cfg.Text:
708
        if params:
709
            r, c = params
710
        else:
711
            r, c = None, None
712
        return TextArea(varname, value or '', r, c)
713
    elif kind in (mm_cfg.EmailList, mm_cfg.EmailListEx):
714
        if params:
715
            r, c = params
716
        else:
717
            r, c = None, None
718
        res = NL.join(value)
719
        return TextArea(varname, res, r, c, wrap='off')
720
    elif kind == mm_cfg.FileUpload:
721
        # like a text area, but also with uploading
722
        if params:
723
            r, c = params
724
        else:
725
            r, c = None, None
726
        container = Container()
727
        container.AddItem(_('<em>Enter the text below, or...</em><br>'))
728
        container.AddItem(TextArea(varname, value or '', r, c))
729
        container.AddItem(_('<br><em>...specify a file to upload</em><br>'))
730
        container.AddItem(FileUpload(varname+'_upload', r, c))
731
        return container
732
    elif kind == mm_cfg.Select:
733
        if params:
734
           values, legend, selected = params
735
        else:
736
           values = mlist.GetAvailableLanguages()
737
           legend = map(_, map(Utils.GetLanguageDescr, values))
738
           selected = values.index(mlist.preferred_language)
739
        return SelectOptions(varname, values, legend, selected)
740
    elif kind == mm_cfg.Topics:
741
        # A complex and specialized widget type that allows for setting of a
742
        # topic name, a mark button, a regexp text box, an "add after mark",
743
        # and a delete button.  Yeesh!  params are ignored.
744
        table = Table(border=0)
745
        # This adds the html for the entry widget
205 by bwarsaw
get_item_gui_value(): Added a new widget HeaderFilter and associated code to
746
        def makebox(i, name, pattern, desc, empty=False, table=table):
1 by
This commit was manufactured by cvs2svn to create branch
747
            deltag   = 'topic_delete_%02d' % i
748
            boxtag   = 'topic_box_%02d' % i
749
            reboxtag = 'topic_rebox_%02d' % i
750
            desctag  = 'topic_desc_%02d' % i
751
            wheretag = 'topic_where_%02d' % i
752
            addtag   = 'topic_add_%02d' % i
753
            newtag   = 'topic_new_%02d' % i
754
            if empty:
755
                table.AddRow([Center(Bold(_('Topic %(i)d'))),
756
                              Hidden(newtag)])
757
            else:
758
                table.AddRow([Center(Bold(_('Topic %(i)d'))),
759
                              SubmitButton(deltag, _('Delete'))])
760
            table.AddRow([Label(_('Topic name:')),
761
                          TextBox(boxtag, value=name, size=30)])
762
            table.AddRow([Label(_('Regexp:')),
763
                          TextArea(reboxtag, text=pattern,
764
                                   rows=4, cols=30, wrap='off')])
765
            table.AddRow([Label(_('Description:')),
766
                          TextArea(desctag, text=desc,
767
                                   rows=4, cols=30, wrap='soft')])
768
            if not empty:
769
                table.AddRow([SubmitButton(addtag, _('Add new item...')),
770
                              SelectOptions(wheretag, ('before', 'after'),
771
                                            (_('...before this one.'),
772
                                             _('...after this one.')),
773
                                            selected=1),
774
                              ])
775
            table.AddRow(['<hr>'])
776
            table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
777
        # Now for each element in the existing data, create a widget
778
        i = 1
779
        data = getattr(mlist, varname)
780
        for name, pattern, desc, empty in data:
781
            makebox(i, name, pattern, desc, empty)
782
            i += 1
783
        # Add one more non-deleteable widget as the first blank entry, but
784
        # only if there are no real entries.
785
        if i == 1:
205 by bwarsaw
get_item_gui_value(): Added a new widget HeaderFilter and associated code to
786
            makebox(i, '', '', '', empty=True)
787
        return table
788
    elif kind == mm_cfg.HeaderFilter:
789
        # A complex and specialized widget type that allows for setting of a
790
        # spam filter rule including, a mark button, a regexp text box, an
791
        # "add after mark", up and down buttons, and a delete button.  Yeesh!
792
        # params are ignored.
793
        table = Table(border=0)
794
        # This adds the html for the entry widget
795
        def makebox(i, pattern, action, empty=False, table=table):
796
            deltag    = 'hdrfilter_delete_%02d' % i
797
            reboxtag  = 'hdrfilter_rebox_%02d' % i
798
            actiontag = 'hdrfilter_action_%02d' % i
799
            wheretag  = 'hdrfilter_where_%02d' % i
800
            addtag    = 'hdrfilter_add_%02d' % i
801
            newtag    = 'hdrfilter_new_%02d' % i
802
            uptag     = 'hdrfilter_up_%02d' % i
803
            downtag   = 'hdrfilter_down_%02d' % i
804
            if empty:
805
                table.AddRow([Center(Bold(_('Spam Filter Rule %(i)d'))),
806
                              Hidden(newtag)])
807
            else:
808
                table.AddRow([Center(Bold(_('Spam Filter Rule %(i)d'))),
809
                              SubmitButton(deltag, _('Delete'))])
810
            table.AddRow([Label(_('Spam Filter Regexp:')),
811
                          TextArea(reboxtag, text=pattern,
812
                                   rows=4, cols=30, wrap='off')])
813
            values = [mm_cfg.DEFER, mm_cfg.HOLD, mm_cfg.REJECT,
814
                      mm_cfg.DISCARD, mm_cfg.ACCEPT]
815
            try:
816
                checked = values.index(action)
817
            except ValueError:
818
                checked = 0
819
            radio = RadioButtonArray(
820
                actiontag,
821
                (_('Defer'), _('Hold'), _('Reject'),
822
                 _('Discard'), _('Accept')),
823
                values=values,
824
                checked=checked).Format()
825
            table.AddRow([Label(_('Action:')), radio])
826
            if not empty:
827
                table.AddRow([SubmitButton(addtag, _('Add new item...')),
828
                              SelectOptions(wheretag, ('before', 'after'),
829
                                            (_('...before this one.'),
830
                                             _('...after this one.')),
831
                                            selected=1),
832
                              ])
833
                # BAW: IWBNI we could disable the up and down buttons for the
834
                # first and last item respectively, but it's not easy to know
835
                # which is the last item, so let's not worry about that for
836
                # now.
837
                table.AddRow([SubmitButton(uptag, _('Move rule up')),
838
                              SubmitButton(downtag, _('Move rule down'))])
839
            table.AddRow(['<hr>'])
840
            table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
841
        # Now for each element in the existing data, create a widget
842
        i = 1
843
        data = getattr(mlist, varname)
844
        for pattern, action, empty in data:
845
            makebox(i, pattern, action, empty)
846
            i += 1
847
        # Add one more non-deleteable widget as the first blank entry, but
848
        # only if there are no real entries.
849
        if i == 1:
850
            makebox(i, '', mm_cfg.DEFER, empty=True)
1 by
This commit was manufactured by cvs2svn to create branch
851
        return table
852
    elif kind == mm_cfg.Checkbox:
853
        return CheckBoxArray(varname, *params)
854
    else:
855
        assert 0, 'Bad gui widget type: %s' % kind
856
857
858

859
def get_item_gui_description(mlist, category, subcat,
860
                             varname, descr, elaboration, detailsp):
861
    # Return the item's description, with link to details.
862
    #
863
    # Details are not included if this is a VARHELP page, because that /is/
864
    # the details page!
865
    if detailsp:
866
        if subcat:
867
            varhelp = '/?VARHELP=%s/%s/%s' % (category, subcat, varname)
868
        else:
869
            varhelp = '/?VARHELP=%s/%s' % (category, varname)
870
        if descr == elaboration:
871
            linktext = _('<br>(Edit <b>%(varname)s</b>)')
872
        else:
873
            linktext = _('<br>(Details for <b>%(varname)s</b>)')
874
        link = Link(mlist.GetScriptURL('admin') + varhelp,
875
                    linktext).Format()
876
        text = Label('%s %s' % (descr, link)).Format()
877
    else:
878
        text = Label(descr).Format()
879
    if varname[0] == '_':
880
        text += Label(_('''<br><em><strong>Note:</strong>
881
        setting this value performs an immediate action but does not modify
882
        permanent state.</em>''')).Format()
883
    return text
884
885
886

887
def membership_options(mlist, subcat, cgidata, doc, form):
888
    # Show the main stuff
889
    adminurl = mlist.GetScriptURL('admin', absolute=1)
890
    container = Container()
891
    header = Table(width="100%")
892
    # If we're in the list subcategory, show the membership list
893
    if subcat == 'add':
894
        header.AddRow([Center(Header(2, _('Mass Subscriptions')))])
895
        header.AddCellInfo(header.GetCurrentRowIndex(), 0, colspan=2,
896
                           bgcolor=mm_cfg.WEB_HEADER_COLOR)
897
        container.AddItem(header)
898
        mass_subscribe(mlist, container)
899
        return container
900
    if subcat == 'remove':
901
        header.AddRow([Center(Header(2, _('Mass Removals')))])
902
        header.AddCellInfo(header.GetCurrentRowIndex(), 0, colspan=2,
903
                           bgcolor=mm_cfg.WEB_HEADER_COLOR)
904
        container.AddItem(header)
905
        mass_remove(mlist, container)
906
        return container
1551 by Mark Sapiro
Implemented member address change via the admin GUI.
907
    if subcat == 'change':
908
        header.AddRow([Center(Header(2, _('Address Change')))])
909
        header.AddCellInfo(header.GetCurrentRowIndex(), 0, colspan=2,
910
                           bgcolor=mm_cfg.WEB_HEADER_COLOR)
911
        container.AddItem(header)
912
        address_change(mlist, container)
913
        return container
1820 by Mark Sapiro
Implemented web admin sync members.
914
    if subcat == 'sync':
915
        header.AddRow([Center(Header(2, _('Sync Membership List')))])
916
        header.AddCellInfo(header.GetCurrentRowIndex(), 0, colspan=2,
917
                           bgcolor=mm_cfg.WEB_HEADER_COLOR)
918
        container.AddItem(header)
919
        mass_sync(mlist, container)
920
        return container
1 by
This commit was manufactured by cvs2svn to create branch
921
    # Otherwise...
922
    header.AddRow([Center(Header(2, _('Membership List')))])
923
    header.AddCellInfo(header.GetCurrentRowIndex(), 0, colspan=2,
924
                       bgcolor=mm_cfg.WEB_HEADER_COLOR)
925
    container.AddItem(header)
926
    # Add a "search for member" button
927
    table = Table(width='100%')
1792.1.1 by Yasuhito FUTATSUKI at POEM
fix python doc urls
928
    link = Link('https://docs.python.org/2/library/re.html'
1156 by Mark Sapiro
Updated links to Python documentation.
929
                '#regular-expression-syntax',
1 by
This commit was manufactured by cvs2svn to create branch
930
                _('(help)')).Format()
931
    table.AddRow([Label(_('Find member %(link)s:')),
932
                  TextBox('findmember',
1712 by Mark Sapiro
Defend against CGI requests with multiple values for the same parameter.
933
                          value=cgidata.getfirst('findmember', '')),
1 by
This commit was manufactured by cvs2svn to create branch
934
                  SubmitButton('findmember_btn', _('Search...'))])
935
    container.AddItem(table)
936
    container.AddItem('<hr><p>')
937
    usertable = Table(width="90%", border='2')
938
    # If there are more members than allowed by chunksize, then we split the
939
    # membership up alphabetically.  Otherwise just display them all.
940
    chunksz = mlist.admin_member_chunksize
86 by bwarsaw
Backporting from the HEAD -- updated cgi's
941
    # The email addresses had /better/ be ASCII, but might be encoded in the
942
    # database as Unicodes.
943
    all = [_m.encode() for _m in mlist.getMembers()]
1 by
This commit was manufactured by cvs2svn to create branch
944
    all.sort(lambda x, y: cmp(x.lower(), y.lower()))
945
    # See if the query has a regular expression
1712 by Mark Sapiro
Defend against CGI requests with multiple values for the same parameter.
946
    regexp = cgidata.getfirst('findmember', '').strip()
1558 by Mark Sapiro
Improved search in admin UI Membership List.
947
    try:
948
        regexp = regexp.decode(Utils.GetCharSet(mlist.preferred_language))
949
    except UnicodeDecodeError:
950
        # This is probably a non-ascii character and an English language
951
        # (ascii) list.  Even if we didn't throw the UnicodeDecodeError,
952
        # the input may have contained mnemonic or numeric HTML entites mixed
953
        # with other characters.  Trying to grok the real meaning out of that
954
        # is complex and error prone, so we don't try.
955
        pass
1 by
This commit was manufactured by cvs2svn to create branch
956
    if regexp:
957
        try:
958
            cre = re.compile(regexp, re.IGNORECASE)
959
        except re.error:
960
            doc.addError(_('Bad regular expression: ') + regexp)
961
        else:
962
            # BAW: There's got to be a more efficient way of doing this!
963
            names = [mlist.getMemberName(s) or '' for s in all]
964
            all = [a for n, a in zip(names, all)
965
                   if cre.search(n) or cre.search(a)]
966
    chunkindex = None
967
    bucket = None
968
    actionurl = None
969
    if len(all) < chunksz:
970
        members = all
971
    else:
972
        # Split them up alphabetically, and then split the alphabetical
973
        # listing by chunks
974
        buckets = {}
975
        for addr in all:
976
            members = buckets.setdefault(addr[0].lower(), [])
977
            members.append(addr)
978
        # Now figure out which bucket we want
979
        bucket = None
980
        qs = {}
981
        # POST methods, even if their actions have a query string, don't get
982
        # put into FieldStorage's keys :-(
983
        qsenviron = os.environ.get('QUERY_STRING')
984
        if qsenviron:
985
            qs = cgi.parse_qs(qsenviron)
1123 by Mark Sapiro
Fixed a bug in admin.py which would result in chunked pages of the membership
986
            bucket = qs.get('letter', '0')[0].lower()
987
        keys = buckets.keys()
988
        keys.sort()
1 by
This commit was manufactured by cvs2svn to create branch
989
        if not bucket or not buckets.has_key(bucket):
990
            bucket = keys[0]
991
        members = buckets[bucket]
992
        action = adminurl + '/members?letter=%s' % bucket
993
        if len(members) <= chunksz:
994
            form.set_action(action)
995
        else:
996
            i, r = divmod(len(members), chunksz)
997
            numchunks = i + (not not r * 1)
998
            # Now chunk them up
999
            chunkindex = 0
1000
            if qs.has_key('chunk'):
1001
                try:
1002
                    chunkindex = int(qs['chunk'][0])
1003
                except ValueError:
1004
                    chunkindex = 0
1005
                if chunkindex < 0 or chunkindex > numchunks:
1006
                    chunkindex = 0
1007
            members = members[chunkindex*chunksz:(chunkindex+1)*chunksz]
1008
            # And set the action URL
1009
            form.set_action(action + '&chunk=%s' % chunkindex)
1010
    # So now members holds all the addresses we're going to display
1011
    allcnt = len(all)
1012
    if bucket:
1013
        membercnt = len(members)
1014
        usertable.AddRow([Center(Italic(_(
1015
            '%(allcnt)s members total, %(membercnt)s shown')))])
1016
    else:
1017
        usertable.AddRow([Center(Italic(_('%(allcnt)s members total')))])
1018
    usertable.AddCellInfo(usertable.GetCurrentRowIndex(),
1019
                          usertable.GetCurrentCellIndex(),
1020
                          colspan=OPTCOLUMNS,
1021
                          bgcolor=mm_cfg.WEB_ADMINITEM_COLOR)
1022
    # Add the alphabetical links
1023
    if bucket:
1024
        cells = []
1123 by Mark Sapiro
Fixed a bug in admin.py which would result in chunked pages of the membership
1025
        for letter in keys:
1155 by Mark Sapiro
- Fixed the admin Membership List Find member function so the 'letter'
1026
            findfrag = ''
1027
            if regexp:
1028
                findfrag = '&findmember=' + urllib.quote(regexp)
1029
            url = adminurl + '/members?letter=' + letter + findfrag
1665 by Mark Sapiro
Membership List letter links could be incorrectly rendered as Unicode.
1030
            if isinstance(url, unicode):
1031
                url = url.encode(Utils.GetCharSet(mlist.preferred_language),
1032
                                 errors='ignore')
1 by
This commit was manufactured by cvs2svn to create branch
1033
            if letter == bucket:
1034
                show = Bold('[%s]' % letter.upper()).Format()
1035
            else:
1036
                show = letter.upper()
1037
            cells.append(Link(url, show).Format())
1038
        joiner = '&nbsp;'*2 + '\n'
1039
        usertable.AddRow([Center(joiner.join(cells))])
1040
    usertable.AddCellInfo(usertable.GetCurrentRowIndex(),
1041
                          usertable.GetCurrentCellIndex(),
1042
                          colspan=OPTCOLUMNS,
1043
                          bgcolor=mm_cfg.WEB_ADMINITEM_COLOR)
1044
    usertable.AddRow([Center(h) for h in (_('unsub'),
1045
                                          _('member address<br>member name'),
1046
                                          _('mod'), _('hide'),
1047
                                          _('nomail<br>[reason]'),
1048
                                          _('ack'), _('not metoo'),
1049
                                          _('nodupes'),
1050
                                          _('digest'), _('plain'),
1051
                                          _('language'))])
1052
    rowindex = usertable.GetCurrentRowIndex()
1053
    for i in range(OPTCOLUMNS):
1054
        usertable.AddCellInfo(rowindex, i, bgcolor=mm_cfg.WEB_ADMINITEM_COLOR)
1055
    # Find the longest name in the list
1056
    longest = 0
1057
    if members:
1058
        names = filter(None, [mlist.getMemberName(s) for s in members])
1059
        # Make the name field at least as long as the longest email address
1060
        longest = max([len(s) for s in names + members])
1061
    # Abbreviations for delivery status details
1062
    ds_abbrevs = {MemberAdaptor.UNKNOWN : _('?'),
1063
                  MemberAdaptor.BYUSER  : _('U'),
1064
                  MemberAdaptor.BYADMIN : _('A'),
1065
                  MemberAdaptor.BYBOUNCE: _('B'),
1066
                  }
1067
    # Now populate the rows
1068
    for addr in members:
972 by msapiro
- CGI/admin.py
1069
        qaddr = urllib.quote(addr)
1 by
This commit was manufactured by cvs2svn to create branch
1070
        link = Link(mlist.GetOptionsURL(addr, obscure=1),
1071
                    mlist.getMemberCPAddress(addr))
1072
        fullname = Utils.uncanonstr(mlist.getMemberName(addr),
1073
                                    mlist.preferred_language)
972 by msapiro
- CGI/admin.py
1074
        name = TextBox(qaddr + '_realname', fullname, size=longest).Format()
1718 by Mark Sapiro
Added text for screen readers only to checkboxes on admin Membership List.
1075
        cells = [Center(CheckBox(qaddr + '_unsub', 'off', 0).Format()
1076
                        + '<div class="hidden">' + _('unsub') + '</div>'),
1 by
This commit was manufactured by cvs2svn to create branch
1077
                 link.Format() + '<br>' +
1078
                 name +
972 by msapiro
- CGI/admin.py
1079
                 Hidden('user', qaddr).Format(),
1 by
This commit was manufactured by cvs2svn to create branch
1080
                 ]
1081
        # Do the `mod' option
1082
        if mlist.getMemberOption(addr, mm_cfg.Moderate):
1083
            value = 'on'
1084
            checked = 1
1085
        else:
1086
            value = 'off'
1087
            checked = 0
972 by msapiro
- CGI/admin.py
1088
        box = CheckBox('%s_mod' % qaddr, value, checked)
1718 by Mark Sapiro
Added text for screen readers only to checkboxes on admin Membership List.
1089
        cells.append(Center(box.Format()
1090
            + '<div class="hidden">' + _('mod') + '</div>'))
1091
        # Kluge, get these translated.
1092
        (_('hide'), _('nomail'), _('ack'), _('notmetoo'), _('nodupes'))
1 by
This commit was manufactured by cvs2svn to create branch
1093
        for opt in ('hide', 'nomail', 'ack', 'notmetoo', 'nodupes'):
1718 by Mark Sapiro
Added text for screen readers only to checkboxes on admin Membership List.
1094
            extra = '<div class="hidden">' + _(opt) + '</div>'
1 by
This commit was manufactured by cvs2svn to create branch
1095
            if opt == 'nomail':
1096
                status = mlist.getDeliveryStatus(addr)
1097
                if status == MemberAdaptor.ENABLED:
1098
                    value = 'off'
1099
                    checked = 0
1100
                else:
1101
                    value = 'on'
1102
                    checked = 1
1718 by Mark Sapiro
Added text for screen readers only to checkboxes on admin Membership List.
1103
                    extra = '[%s]' % ds_abbrevs[status] + extra
1 by
This commit was manufactured by cvs2svn to create branch
1104
            elif mlist.getMemberOption(addr, mm_cfg.OPTINFO[opt]):
1105
                value = 'on'
1106
                checked = 1
1107
            else:
1108
                value = 'off'
1109
                checked = 0
972 by msapiro
- CGI/admin.py
1110
            box = CheckBox('%s_%s' % (qaddr, opt), value, checked)
1 by
This commit was manufactured by cvs2svn to create branch
1111
            cells.append(Center(box.Format() + extra))
1112
        # This code is less efficient than the original which did a has_key on
1113
        # the underlying dictionary attribute.  This version is slower and
1114
        # less memory efficient.  It points to a new MemberAdaptor interface
1115
        # method.
1718 by Mark Sapiro
Added text for screen readers only to checkboxes on admin Membership List.
1116
        extra = '<div class="hidden">' + _('digest') + '</div>'
1 by
This commit was manufactured by cvs2svn to create branch
1117
        if addr in mlist.getRegularMemberKeys():
1718 by Mark Sapiro
Added text for screen readers only to checkboxes on admin Membership List.
1118
            cells.append(Center(CheckBox(qaddr + '_digest', 'off', 0).Format()
1119
                                + extra))
1 by
This commit was manufactured by cvs2svn to create branch
1120
        else:
1718 by Mark Sapiro
Added text for screen readers only to checkboxes on admin Membership List.
1121
            cells.append(Center(CheckBox(qaddr + '_digest', 'on', 1).Format()
1122
                                + extra))
1 by
This commit was manufactured by cvs2svn to create branch
1123
        if mlist.getMemberOption(addr, mm_cfg.OPTINFO['plain']):
1124
            value = 'on'
1125
            checked = 1
1126
        else:
1127
            value = 'off'
1128
            checked = 0
1718 by Mark Sapiro
Added text for screen readers only to checkboxes on admin Membership List.
1129
        cells.append(Center(CheckBox(
1130
                            '%s_plain' % qaddr, value, checked).Format()
1131
                            + '<div class="hidden">' + _('plain') + '</div>'))
1 by
This commit was manufactured by cvs2svn to create branch
1132
        # User's preferred language
1133
        langpref = mlist.getMemberLanguage(addr)
1134
        langs = mlist.GetAvailableLanguages()
1135
        langdescs = [_(Utils.GetLanguageDescr(lang)) for lang in langs]
1136
        try:
1137
            selected = langs.index(langpref)
1138
        except ValueError:
1139
            selected = 0
972 by msapiro
- CGI/admin.py
1140
        cells.append(Center(SelectOptions(qaddr + '_language', langs,
1 by
This commit was manufactured by cvs2svn to create branch
1141
                                          langdescs, selected)).Format())
1142
        usertable.AddRow(cells)
1143
    # Add the usertable and a legend
1144
    legend = UnorderedList()
1145
    legend.AddItem(
1146
        _('<b>unsub</b> -- Click on this to unsubscribe the member.'))
1147
    legend.AddItem(
1148
        _("""<b>mod</b> -- The user's personal moderation flag.  If this is
1149
        set, postings from them will be moderated, otherwise they will be
1150
        approved."""))
1151
    legend.AddItem(
1152
        _("""<b>hide</b> -- Is the member's address concealed on
1153
        the list of subscribers?"""))
1154
    legend.AddItem(_(
1155
        """<b>nomail</b> -- Is delivery to the member disabled?  If so, an
1156
        abbreviation will be given describing the reason for the disabled
1157
        delivery:
1158
            <ul><li><b>U</b> -- Delivery was disabled by the user via their
1159
                    personal options page.
1160
                <li><b>A</b> -- Delivery was disabled by the list
1161
                    administrators.
1162
                <li><b>B</b> -- Delivery was disabled by the system due to
1163
                    excessive bouncing from the member's address.
1164
                <li><b>?</b> -- The reason for disabled delivery isn't known.
1165
                    This is the case for all memberships which were disabled
1166
                    in older versions of Mailman.
1167
            </ul>"""))
1168
    legend.AddItem(
1169
        _('''<b>ack</b> -- Does the member get acknowledgements of their
1170
        posts?'''))
1171
    legend.AddItem(
1172
        _('''<b>not metoo</b> -- Does the member want to avoid copies of their
1173
        own postings?'''))
1174
    legend.AddItem(
1175
        _('''<b>nodupes</b> -- Does the member want to avoid duplicates of the
1176
        same message?'''))
1177
    legend.AddItem(
1178
        _('''<b>digest</b> -- Does the member get messages in digests?
1179
        (otherwise, individual messages)'''))
1180
    legend.AddItem(
1181
        _('''<b>plain</b> -- If getting digests, does the member get plain
1182
        text digests?  (otherwise, MIME)'''))
1183
    legend.AddItem(_("<b>language</b> -- Language preferred by the user"))
1184
    addlegend = ''
1185
    parsedqs = 0
1186
    qsenviron = os.environ.get('QUERY_STRING')
1187
    if qsenviron:
1188
        qs = cgi.parse_qs(qsenviron).get('legend')
1189
        if qs and isinstance(qs, ListType):
1190
            qs = qs[0]
1191
        if qs == 'yes':
1192
            addlegend = 'legend=yes&'
1193
    if addlegend:
1194
        container.AddItem(legend.Format() + '<p>')
1195
        container.AddItem(
1196
            Link(adminurl + '/members/list',
1197
                 _('Click here to hide the legend for this table.')))
1198
    else:
1199
        container.AddItem(
1200
            Link(adminurl + '/members/list?legend=yes',
1201
                 _('Click here to include the legend for this table.')))
1202
    container.AddItem(Center(usertable))
1203
1204
    # There may be additional chunks
1205
    if chunkindex is not None:
1206
        buttons = []
1207
        url = adminurl + '/members?%sletter=%s&' % (addlegend, bucket)
1208
        footer = _('''<p><em>To view more members, click on the appropriate
1209
        range listed below:</em>''')
1210
        chunkmembers = buckets[bucket]
1211
        last = len(chunkmembers)
1212
        for i in range(numchunks):
1213
            if i == chunkindex:
1214
                continue
1215
            start = chunkmembers[i*chunksz]
1216
            end = chunkmembers[min((i+1)*chunksz, last)-1]
1679 by Mark Sapiro
Fixed incorrect "view more members" links at the bottom of the admin
1217
            thisurl = url + 'chunk=%d' % i + findfrag
1218
            if isinstance(thisurl, unicode):
1219
                thisurl = thisurl.encode(
1220
                                 Utils.GetCharSet(mlist.preferred_language),
1677 by Mark Sapiro
Fix unicode links in multi-page admin Membership list search results.
1221
                                 errors='ignore')
1679 by Mark Sapiro
Fixed incorrect "view more members" links at the bottom of the admin
1222
            link = Link(thisurl, _('from %(start)s to %(end)s'))
1 by
This commit was manufactured by cvs2svn to create branch
1223
            buttons.append(link)
1224
        buttons = UnorderedList(*buttons)
1225
        container.AddItem(footer + buttons.Format() + '<p>')
1226
    return container
1227
1228
1229

1230
def mass_subscribe(mlist, container):
1231
    # MASS SUBSCRIBE
1232
    GREY = mm_cfg.WEB_ADMINITEM_COLOR
1233
    table = Table(width='90%')
1234
    table.AddRow([
1235
        Label(_('Subscribe these users now or invite them?')),
1236
        RadioButtonArray('subscribe_or_invite',
1237
                         (_('Subscribe'), _('Invite')),
1509 by Mark Sapiro
Implement a new DEFAULT_SUBSCRIBE_OR_INVITE setting to control the default
1238
                         mm_cfg.DEFAULT_SUBSCRIBE_OR_INVITE,
1239
                         values=(0, 1))
1 by
This commit was manufactured by cvs2svn to create branch
1240
        ])
1241
    table.AddCellInfo(table.GetCurrentRowIndex(), 0, bgcolor=GREY)
1242
    table.AddCellInfo(table.GetCurrentRowIndex(), 1, bgcolor=GREY)
1243
    table.AddRow([
1699 by Mark Sapiro
Change 'subscribees' to 'subscribers' on admin mass subscribe page.
1244
        Label(_('Send welcome messages to new subscribers?')),
1 by
This commit was manufactured by cvs2svn to create branch
1245
        RadioButtonArray('send_welcome_msg_to_this_batch',
1246
                         (_('No'), _('Yes')),
1247
                         mlist.send_welcome_msg,
1248
                         values=(0, 1))
1249
        ])
1250
    table.AddCellInfo(table.GetCurrentRowIndex(), 0, bgcolor=GREY)
1251
    table.AddCellInfo(table.GetCurrentRowIndex(), 1, bgcolor=GREY)
1252
    table.AddRow([
1253
        Label(_('Send notifications of new subscriptions to the list owner?')),
1254
        RadioButtonArray('send_notifications_to_list_owner',
1255
                         (_('No'), _('Yes')),
1256
                         mlist.admin_notify_mchanges,
1257
                         values=(0,1))
1258
        ])
1259
    table.AddCellInfo(table.GetCurrentRowIndex(), 0, bgcolor=GREY)
1260
    table.AddCellInfo(table.GetCurrentRowIndex(), 1, bgcolor=GREY)
1261
    table.AddRow([Italic(_('Enter one address per line below...'))])
1262
    table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
1263
    table.AddRow([Center(TextArea(name='subscribees',
1264
                                  rows=10, cols='70%', wrap=None))])
1265
    table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
1266
    table.AddRow([Italic(Label(_('...or specify a file to upload:'))),
1267
                  FileUpload('subscribees_upload', cols='50')])
1268
    container.AddItem(Center(table))
1269
    # Invitation text
1270
    table.AddRow(['&nbsp;', '&nbsp;'])
1271
    table.AddRow([Italic(_("""Below, enter additional text to be added to the
1272
    top of your invitation or the subscription notification.  Include at least
1273
    one blank line at the end..."""))])
1274
    table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
1275
    table.AddRow([Center(TextArea(name='invitation',
1276
                                  rows=10, cols='70%', wrap=None))])
1277
    table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
1278
1279
1280

1281
def mass_remove(mlist, container):
1282
    # MASS UNSUBSCRIBE
1283
    GREY = mm_cfg.WEB_ADMINITEM_COLOR
1284
    table = Table(width='90%')
1285
    table.AddRow([
1286
        Label(_('Send unsubscription acknowledgement to the user?')),
1287
        RadioButtonArray('send_unsub_ack_to_this_batch',
1288
                         (_('No'), _('Yes')),
1289
                         0, values=(0, 1))
1290
        ])
1291
    table.AddCellInfo(table.GetCurrentRowIndex(), 0, bgcolor=GREY)
1292
    table.AddCellInfo(table.GetCurrentRowIndex(), 1, bgcolor=GREY)
1293
    table.AddRow([
1294
        Label(_('Send notifications to the list owner?')),
1295
        RadioButtonArray('send_unsub_notifications_to_list_owner',
1296
                         (_('No'), _('Yes')),
1297
                         mlist.admin_notify_mchanges,
1298
                         values=(0, 1))
1299
        ])
1300
    table.AddCellInfo(table.GetCurrentRowIndex(), 0, bgcolor=GREY)
1301
    table.AddCellInfo(table.GetCurrentRowIndex(), 1, bgcolor=GREY)
1302
    table.AddRow([Italic(_('Enter one address per line below...'))])
1303
    table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
1304
    table.AddRow([Center(TextArea(name='unsubscribees',
1305
                                  rows=10, cols='70%', wrap=None))])
1306
    table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
1307
    table.AddRow([Italic(Label(_('...or specify a file to upload:'))),
1308
                  FileUpload('unsubscribees_upload', cols='50')])
1309
    container.AddItem(Center(table))
1310
1311
1312

1551 by Mark Sapiro
Implemented member address change via the admin GUI.
1313
def address_change(mlist, container):
1314
    # ADDRESS CHANGE
1315
    GREY = mm_cfg.WEB_ADMINITEM_COLOR
1316
    table = Table(width='90%')
1317
    table.AddRow([Italic(_("""To change a list member's address, enter the
1318
    member's current and new addresses below. Use the check boxes to send
1319
    notice of the change to the old and/or new address(es)."""))])
1320
    table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=3)
1321
    table.AddRow([
1322
        Label(_("Member's current address")),
1323
        TextBox(name='change_from'),
1324
        CheckBox('notice_old', 'yes', 0).Format() +
1325
            '&nbsp;' +
1326
            _('Send notice')
1327
        ])
1328
    table.AddCellInfo(table.GetCurrentRowIndex(), 0, bgcolor=GREY)
1329
    table.AddCellInfo(table.GetCurrentRowIndex(), 1, bgcolor=GREY)
1330
    table.AddCellInfo(table.GetCurrentRowIndex(), 2, bgcolor=GREY)
1331
    table.AddRow([
1332
        Label(_('Address to change to')),
1333
        TextBox(name='change_to'),
1334
        CheckBox('notice_new', 'yes', 0).Format() +
1335
            '&nbsp;' +
1336
            _('Send notice')
1337
        ])
1338
    table.AddCellInfo(table.GetCurrentRowIndex(), 0, bgcolor=GREY)
1339
    table.AddCellInfo(table.GetCurrentRowIndex(), 1, bgcolor=GREY)
1340
    table.AddCellInfo(table.GetCurrentRowIndex(), 2, bgcolor=GREY)
1341
    container.AddItem(Center(table))
1342
1343
1344

1820 by Mark Sapiro
Implemented web admin sync members.
1345
def mass_sync(mlist, container):
1346
    # MASS SYNC
1347
    table = Table(width='90%')
1348
    table.AddRow([Italic(_('Enter one address per line below...'))])
1349
    table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
1350
    table.AddRow([Center(TextArea(name='memberlist',
1351
                                  rows=10, cols='70%', wrap=None))])
1352
    table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
1353
    table.AddRow([Italic(Label(_('...or specify a file to upload:'))),
1354
                  FileUpload('memberlist_upload', cols='50')])
1355
    container.AddItem(Center(table))
1356
1357
1358

1 by
This commit was manufactured by cvs2svn to create branch
1359
def password_inputs(mlist):
1360
    adminurl = mlist.GetScriptURL('admin', absolute=1)
1361
    table = Table(cellspacing=3, cellpadding=4)
1362
    table.AddRow([Center(Header(2, _('Change list ownership passwords')))])
1363
    table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2,
1364
                      bgcolor=mm_cfg.WEB_HEADER_COLOR)
1365
    table.AddRow([_("""\
1366
The <em>list administrators</em> are the people who have ultimate control over
1367
all parameters of this mailing list.  They are able to change any list
1368
configuration variable available through these administration web pages.
1369
1370
<p>The <em>list moderators</em> have more limited permissions; they are not
1371
able to change any list configuration variable, but they are allowed to tend
1372
to pending administration requests, including approving or rejecting held
1373
subscription requests, and disposing of held postings.  Of course, the
1374
<em>list administrators</em> can also tend to pending requests.
1375
1376
<p>In order to split the list ownership duties into administrators and
1377
moderators, you must set a separate moderator password in the fields below,
1378
and also provide the email addresses of the list moderators in the
1379
<a href="%(adminurl)s/general">general options section</a>.""")])
1380
    table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
1381
    # Set up the admin password table on the left
1382
    atable = Table(border=0, cellspacing=3, cellpadding=4,
1383
                   bgcolor=mm_cfg.WEB_ADMINPW_COLOR)
1384
    atable.AddRow([Label(_('Enter new administrator password:')),
1385
                   PasswordBox('newpw', size=20)])
1386
    atable.AddRow([Label(_('Confirm administrator password:')),
1387
                   PasswordBox('confirmpw', size=20)])
1388
    # Set up the moderator password table on the right
1389
    mtable = Table(border=0, cellspacing=3, cellpadding=4,
1390
                   bgcolor=mm_cfg.WEB_ADMINPW_COLOR)
1391
    mtable.AddRow([Label(_('Enter new moderator password:')),
1392
                   PasswordBox('newmodpw', size=20)])
1393
    mtable.AddRow([Label(_('Confirm moderator password:')),
1394
                   PasswordBox('confirmmodpw', size=20)])
1395
    # Add these tables to the overall password table
1396
    table.AddRow([atable, mtable])
1297 by Mark Sapiro
A new list poster password has been implemented. This password may only
1397
    table.AddRow([_("""\
1398
In addition to the above passwords you may specify a password for
1399
pre-approving posts to the list. Either of the above two passwords can
1400
be used in an Approved: header or first body line pseudo-header to
1401
pre-approve a post that would otherwise be held for moderation. In
1402
addition, the password below, if set, can be used for that purpose and
1403
no other.""")])
1404
    table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
1405
    # Set up the post password table
1406
    ptable = Table(border=0, cellspacing=3, cellpadding=4,
1407
                   bgcolor=mm_cfg.WEB_ADMINPW_COLOR)
1408
    ptable.AddRow([Label(_('Enter new poster password:')),
1409
                   PasswordBox('newpostpw', size=20)])
1410
    ptable.AddRow([Label(_('Confirm poster password:')),
1411
                   PasswordBox('confirmpostpw', size=20)])
1412
    table.AddRow([ptable])
1 by
This commit was manufactured by cvs2svn to create branch
1413
    return table
1414
1415
1416

1417
def submit_button(name='submit'):
1418
    table = Table(border=0, cellspacing=0, cellpadding=2)
1419
    table.AddRow([Bold(SubmitButton(name, _('Submit Your Changes')))])
1420
    table.AddCellInfo(table.GetCurrentRowIndex(), 0, align='middle')
1421
    return table
1422
1423
1424

1425
def change_options(mlist, category, subcat, cgidata, doc):
1780 by Mark Sapiro
Added global _ where needed.
1426
    global _
1 by
This commit was manufactured by cvs2svn to create branch
1427
    def safeint(formvar, defaultval=None):
1428
        try:
1712 by Mark Sapiro
Defend against CGI requests with multiple values for the same parameter.
1429
            return int(cgidata.getfirst(formvar))
1 by
This commit was manufactured by cvs2svn to create branch
1430
        except (ValueError, TypeError):
1431
            return defaultval
1432
    confirmed = 0
1433
    # Handle changes to the list moderator password.  Do this before checking
1434
    # the new admin password, since the latter will force a reauthentication.
1712 by Mark Sapiro
Defend against CGI requests with multiple values for the same parameter.
1435
    new = cgidata.getfirst('newmodpw', '').strip()
1436
    confirm = cgidata.getfirst('confirmmodpw', '').strip()
1 by
This commit was manufactured by cvs2svn to create branch
1437
    if new or confirm:
1438
        if new == confirm:
1135 by Barry Warsaw
Apply Heiko Rommel's patch for hashlib deprecation warnings for bug 293178.
1439
            mlist.mod_password = sha_new(new).hexdigest()
1 by
This commit was manufactured by cvs2svn to create branch
1440
            # No re-authentication necessary because the moderator's
1441
            # password doesn't get you into these pages.
1442
        else:
1443
            doc.addError(_('Moderator passwords did not match'))
1297 by Mark Sapiro
A new list poster password has been implemented. This password may only
1444
    # Handle changes to the list poster password.  Do this before checking
1445
    # the new admin password, since the latter will force a reauthentication.
1712 by Mark Sapiro
Defend against CGI requests with multiple values for the same parameter.
1446
    new = cgidata.getfirst('newpostpw', '').strip()
1447
    confirm = cgidata.getfirst('confirmpostpw', '').strip()
1297 by Mark Sapiro
A new list poster password has been implemented. This password may only
1448
    if new or confirm:
1449
        if new == confirm:
1450
            mlist.post_password = sha_new(new).hexdigest()
1451
            # No re-authentication necessary because the poster's
1452
            # password doesn't get you into these pages.
1453
        else:
1454
            doc.addError(_('Poster passwords did not match'))
1 by
This commit was manufactured by cvs2svn to create branch
1455
    # Handle changes to the list administrator password
1712 by Mark Sapiro
Defend against CGI requests with multiple values for the same parameter.
1456
    new = cgidata.getfirst('newpw', '').strip()
1457
    confirm = cgidata.getfirst('confirmpw', '').strip()
1 by
This commit was manufactured by cvs2svn to create branch
1458
    if new or confirm:
1459
        if new == confirm:
1135 by Barry Warsaw
Apply Heiko Rommel's patch for hashlib deprecation warnings for bug 293178.
1460
            mlist.password = sha_new(new).hexdigest()
1 by
This commit was manufactured by cvs2svn to create branch
1461
            # Set new cookie
1462
            print mlist.MakeCookie(mm_cfg.AuthListAdmin)
1463
        else:
1464
            doc.addError(_('Administrator passwords did not match'))
1465
    # Give the individual gui item a chance to process the form data
1466
    categories = mlist.GetConfigCategories()
1467
    label, gui = categories[category]
1468
    # BAW: We handle the membership page special... for now.
1469
    if category <> 'members':
1470
        gui.handleForm(mlist, category, subcat, cgidata, doc)
1471
    # mass subscription, removal processing for members category
1472
    subscribers = ''
1712 by Mark Sapiro
Defend against CGI requests with multiple values for the same parameter.
1473
    subscribers += cgidata.getfirst('subscribees', '')
1474
    subscribers += cgidata.getfirst('subscribees_upload', '')
1 by
This commit was manufactured by cvs2svn to create branch
1475
    if subscribers:
1476
        entries = filter(None, [n.strip() for n in subscribers.splitlines()])
1477
        send_welcome_msg = safeint('send_welcome_msg_to_this_batch',
1478
                                   mlist.send_welcome_msg)
1479
        send_admin_notif = safeint('send_notifications_to_list_owner',
1480
                                   mlist.admin_notify_mchanges)
1481
        # Default is to subscribe
1482
        subscribe_or_invite = safeint('subscribe_or_invite', 0)
1712 by Mark Sapiro
Defend against CGI requests with multiple values for the same parameter.
1483
        invitation = cgidata.getfirst('invitation', '')
32 by bwarsaw
Backporting from trunk
1484
        digest = mlist.digest_is_default
1 by
This commit was manufactured by cvs2svn to create branch
1485
        if not mlist.digestable:
1486
            digest = 0
1487
        if not mlist.nondigestable:
1488
            digest = 1
1489
        subscribe_errors = []
1490
        subscribe_success = []
1491
        # Now cruise through all the subscribees and do the deed.  BAW: we
1492
        # should limit the number of "Successfully subscribed" status messages
1493
        # we display.  Try uploading a file with 10k names -- it takes a while
1494
        # to render the status page.
1495
        for entry in entries:
930 by bwarsaw
CVE-2006-3636. Fixes for various cross-site scripting issues. Discovery by
1496
            safeentry = Utils.websafe(entry)
1 by
This commit was manufactured by cvs2svn to create branch
1497
            fullname, address = parseaddr(entry)
1498
            # Canonicalize the full name
1499
            fullname = Utils.canonstr(fullname, mlist.preferred_language)
1500
            userdesc = UserDesc(address, fullname,
1501
                                Utils.MakeRandomPassword(),
1502
                                digest, mlist.preferred_language)
1503
            try:
1504
                if subscribe_or_invite:
1505
                    if mlist.isMember(address):
1506
                        raise Errors.MMAlreadyAMember
1507
                    else:
1508
                        mlist.InviteNewMember(userdesc, invitation)
1509
                else:
1776 by Mark Sapiro
I18n for new whence reasons in admin (un)subscribe notices.
1510
                    _ = D_
1511
                    whence = _('admin mass sub')
1512
                    _ = i18n._
1 by
This commit was manufactured by cvs2svn to create branch
1513
                    mlist.ApprovedAddMember(userdesc, send_welcome_msg,
171 by bwarsaw
change_options(): When calling ApprovedAddMember(), pass a meaningful
1514
                                            send_admin_notif, invitation,
1776 by Mark Sapiro
I18n for new whence reasons in admin (un)subscribe notices.
1515
                                            whence=whence)
1 by
This commit was manufactured by cvs2svn to create branch
1516
            except Errors.MMAlreadyAMember:
930 by bwarsaw
CVE-2006-3636. Fixes for various cross-site scripting issues. Discovery by
1517
                subscribe_errors.append((safeentry, _('Already a member')))
1 by
This commit was manufactured by cvs2svn to create branch
1518
            except Errors.MMBadEmailError:
1519
                if userdesc.address == '':
1520
                    subscribe_errors.append((_('&lt;blank line&gt;'),
1521
                                             _('Bad/Invalid email address')))
1522
                else:
930 by bwarsaw
CVE-2006-3636. Fixes for various cross-site scripting issues. Discovery by
1523
                    subscribe_errors.append((safeentry,
1 by
This commit was manufactured by cvs2svn to create branch
1524
                                             _('Bad/Invalid email address')))
1525
            except Errors.MMHostileAddress:
1526
                subscribe_errors.append(
930 by bwarsaw
CVE-2006-3636. Fixes for various cross-site scripting issues. Discovery by
1527
                    (safeentry, _('Hostile address (illegal characters)')))
791 by msapiro
Improving banned subscription logic to cover all invites, subscribes, address changes and confirmations of same.
1528
            except Errors.MembershipIsBanned, pattern:
1529
                subscribe_errors.append(
930 by bwarsaw
CVE-2006-3636. Fixes for various cross-site scripting issues. Discovery by
1530
                    (safeentry, _('Banned address (matched %(pattern)s)')))
1 by
This commit was manufactured by cvs2svn to create branch
1531
            else:
1532
                member = Utils.uncanonstr(formataddr((fullname, address)))
1533
                subscribe_success.append(Utils.websafe(member))
1534
        if subscribe_success:
1535
            if subscribe_or_invite:
1536
                doc.AddItem(Header(5, _('Successfully invited:')))
1537
            else:
1538
                doc.AddItem(Header(5, _('Successfully subscribed:')))
1539
            doc.AddItem(UnorderedList(*subscribe_success))
1540
            doc.AddItem('<p>')
1541
        if subscribe_errors:
1542
            if subscribe_or_invite:
1543
                doc.AddItem(Header(5, _('Error inviting:')))
1544
            else:
1545
                doc.AddItem(Header(5, _('Error subscribing:')))
1546
            items = ['%s -- %s' % (x0, x1) for x0, x1 in subscribe_errors]
1547
            doc.AddItem(UnorderedList(*items))
1548
            doc.AddItem('<p>')
1549
    # Unsubscriptions
1550
    removals = ''
1551
    if cgidata.has_key('unsubscribees'):
1552
        removals += cgidata['unsubscribees'].value
1553
    if cgidata.has_key('unsubscribees_upload') and \
1554
           cgidata['unsubscribees_upload'].value:
1555
        removals += cgidata['unsubscribees_upload'].value
1556
    if removals:
1557
        names = filter(None, [n.strip() for n in removals.splitlines()])
1360 by Mark Sapiro
The query fragments send_unsub_notifications_to_list_owner and
1558
        send_unsub_notifications = safeint(
1559
            'send_unsub_notifications_to_list_owner',
1560
            mlist.admin_notify_mchanges)
1561
        userack = safeint(
1562
            'send_unsub_ack_to_this_batch',
1563
            mlist.send_goodbye_msg)
1 by
This commit was manufactured by cvs2svn to create branch
1564
        unsubscribe_errors = []
1565
        unsubscribe_success = []
1566
        for addr in names:
1567
            try:
1776 by Mark Sapiro
I18n for new whence reasons in admin (un)subscribe notices.
1568
                _ = D_
1569
                whence = _('admin mass unsub')
1570
                _ = i18n._
1 by
This commit was manufactured by cvs2svn to create branch
1571
                mlist.ApprovedDeleteMember(
1776 by Mark Sapiro
I18n for new whence reasons in admin (un)subscribe notices.
1572
                    addr, whence=whence,
1 by
This commit was manufactured by cvs2svn to create branch
1573
                    admin_notif=send_unsub_notifications,
1574
                    userack=userack)
930 by bwarsaw
CVE-2006-3636. Fixes for various cross-site scripting issues. Discovery by
1575
                unsubscribe_success.append(Utils.websafe(addr))
1 by
This commit was manufactured by cvs2svn to create branch
1576
            except Errors.NotAMemberError:
930 by bwarsaw
CVE-2006-3636. Fixes for various cross-site scripting issues. Discovery by
1577
                unsubscribe_errors.append(Utils.websafe(addr))
1 by
This commit was manufactured by cvs2svn to create branch
1578
        if unsubscribe_success:
1579
            doc.AddItem(Header(5, _('Successfully Unsubscribed:')))
1580
            doc.AddItem(UnorderedList(*unsubscribe_success))
1581
            doc.AddItem('<p>')
1582
        if unsubscribe_errors:
1583
            doc.AddItem(Header(3, Bold(FontAttr(
1584
                _('Cannot unsubscribe non-members:'),
1585
                color='#ff0000', size='+2')).Format()))
1586
            doc.AddItem(UnorderedList(*unsubscribe_errors))
1587
            doc.AddItem('<p>')
1551 by Mark Sapiro
Implemented member address change via the admin GUI.
1588
    # Address Changes
1589
    if cgidata.has_key('change_from'):
1712 by Mark Sapiro
Defend against CGI requests with multiple values for the same parameter.
1590
        change_from = cgidata.getfirst('change_from', '')
1591
        change_to = cgidata.getfirst('change_to', '')
1551 by Mark Sapiro
Implemented member address change via the admin GUI.
1592
        schange_from = Utils.websafe(change_from)
1593
        schange_to = Utils.websafe(change_to)
1594
        success = False
1595
        msg = None
1596
        if not (change_from and change_to):
1597
            msg = _('You must provide both current and new addresses.')
1598
        elif change_from == change_to:
1599
            msg = _('Current and new addresses must be different.')
1600
        elif mlist.isMember(change_to):
1601
            # ApprovedChangeMemberAddress will just delete the old address
1602
            # and we don't want that here.
1603
            msg = _('%(schange_to)s is already a list member.')
1604
        else:
1605
            try:
1606
                Utils.ValidateEmail(change_to)
1607
            except (Errors.MMBadEmailError, Errors.MMHostileAddress):
1608
                msg = _('%(schange_to)s is not a valid email address.')
1609
        if msg:
1610
            doc.AddItem(Header(3, msg))
1611
            doc.AddItem('<p>')
1612
            return
1613
        try:
1614
            mlist.ApprovedChangeMemberAddress(change_from, change_to, False)
1615
        except Errors.NotAMemberError:
1616
            msg = _('%(schange_from)s is not a member')
1617
        except Errors.MMAlreadyAMember:
1618
            msg = _('%(schange_to)s is already a member')
1619
        except Errors.MembershipIsBanned, pat:
1620
            spat = Utils.websafe(str(pat))
1621
            msg = _('%(schange_to)s matches banned pattern %(spat)s')
1622
        else:
1623
            msg = _('Address %(schange_from)s changed to %(schange_to)s')
1624
            success = True
1625
        doc.AddItem(Header(3, msg))
1626
        lang = mlist.getMemberLanguage(change_to)
1627
        otrans = i18n.get_translation()
1628
        i18n.set_language(lang)
1629
        list_name = mlist.getListAddress()
1630
        text = Utils.wrap(_("""The member address %(change_from)s on the
1631
%(list_name)s list has been changed to %(change_to)s.
1632
"""))
1633
        subject = _('%(list_name)s address change notice.')
1634
        i18n.set_translation(otrans)
1712 by Mark Sapiro
Defend against CGI requests with multiple values for the same parameter.
1635
        if success and cgidata.getfirst('notice_old', '') == 'yes':
1551 by Mark Sapiro
Implemented member address change via the admin GUI.
1636
            # Send notice to old address.
1637
            msg = Message.UserNotification(change_from,
1638
                mlist.GetOwnerEmail(),
1639
                text=text,
1640
                subject=subject,
1641
                lang=lang
1642
                )
1643
            msg.send(mlist)
1644
            doc.AddItem(Header(3, _('Notification sent to %(schange_from)s.')))
1712 by Mark Sapiro
Defend against CGI requests with multiple values for the same parameter.
1645
        if success and cgidata.getfirst('notice_new', '') == 'yes':
1551 by Mark Sapiro
Implemented member address change via the admin GUI.
1646
            # Send notice to new address.
1647
            msg = Message.UserNotification(change_to,
1648
                mlist.GetOwnerEmail(),
1649
                text=text,
1650
                subject=subject,
1651
                lang=lang
1652
                )
1653
            msg.send(mlist)
1654
            doc.AddItem(Header(3, _('Notification sent to %(schange_to)s.')))
1655
        doc.AddItem('<p>')
1820 by Mark Sapiro
Implemented web admin sync members.
1656
1657
    # sync operation
1658
    memberlist = ''
1659
    memberlist += cgidata.getvalue('memberlist', '')
1660
    memberlist += cgidata.getvalue('memberlist_upload', '')
1661
    if memberlist:
1662
        # Browsers will convert special characters in the text box to HTML
1663
        # entities. We need to fix those.
1664
        def i_to_c(mo):
1665
            # Convert a matched string of digits to the corresponding unicode.
1666
            return unichr(int(mo.group(1)))
1667
        def clean_input(x):
1668
            # Strip leading/trailing whitespace and convert numeric HTML
1669
            # entities.
1670
            return re.sub(r'&#(\d+);', i_to_c, x.strip())
1671
        entries = filter(None,
1672
                         [clean_input(n) for n in memberlist.splitlines()])
1673
        lc_addresses = [parseaddr(x)[1].lower() for x in entries
1674
                        if parseaddr(x)[1]]
1675
        subscribe_errors = []
1676
        subscribe_success = []
1677
        # First we add all the addresses that should be added to the list.
1678
        for entry in entries:
1679
            safeentry = Utils.websafe(entry)
1680
            fullname, address = parseaddr(entry)
1681
            if mlist.isMember(address):
1682
                continue
1683
            # Canonicalize the full name.
1684
            fullname = Utils.canonstr(fullname, mlist.preferred_language)
1685
            userdesc = UserDesc(address, fullname,
1686
                                Utils.MakeRandomPassword(),
1687
                                0, mlist.preferred_language)
1688
            try:
1689
                # Add a member if not yet member.
1690
                    mlist.ApprovedAddMember(userdesc, 0, 0, 0,
1691
                                            whence='admin sync members')
1692
            except Errors.MMBadEmailError:
1693
                if userdesc.address == '':
1694
                    subscribe_errors.append((_('&lt;blank line&gt;'),
1695
                                             _('Bad/Invalid email address')))
1696
                else:
1697
                    subscribe_errors.append((safeentry,
1698
                                             _('Bad/Invalid email address')))
1699
            except Errors.MMHostileAddress:
1700
                subscribe_errors.append(
1701
                    (safeentry, _('Hostile address (illegal characters)')))
1702
            except Errors.MembershipIsBanned, pattern:
1703
                subscribe_errors.append(
1704
                    (safeentry, _('Banned address (matched %(pattern)s)')))
1705
            else:
1706
                member = Utils.uncanonstr(formataddr((fullname, address)))
1707
                subscribe_success.append(Utils.websafe(member))
1708
1709
        # Then we remove the addresses not in our list.
1710
        unsubscribe_errors = []
1711
        unsubscribe_success = []
1712
1713
        for entry in mlist.getMembers():
1714
            # If an entry is not found in the uploaded "entries" list, then
1715
            # remove the member.
1716
            if not(entry in lc_addresses):
1717
                try:
1718
                    mlist.ApprovedDeleteMember(entry, 0, 0)
1719
                except Errors.NotAMemberError:
1720
                    # This can happen if the address is illegal (i.e. can't be
1721
                    # parsed by email.Utils.parseaddr()) but for legacy
1722
                    # reasons is in the database.  Use a lower level remove to
1723
                    # get rid of this member's entry
1724
                    mlist.removeMember(entry)
1725
                else:
1726
                    unsubscribe_success.append(Utils.websafe(entry))
1727
1728
        if subscribe_success:
1729
            doc.AddItem(Header(5, _('Successfully subscribed:')))
1730
            doc.AddItem(UnorderedList(*subscribe_success))
1731
            doc.AddItem('<p>')
1732
        if subscribe_errors:
1733
            doc.AddItem(Header(5, _('Error subscribing:')))
1734
            items = ['%s -- %s' % (x0, x1) for x0, x1 in subscribe_errors]
1735
            doc.AddItem(UnorderedList(*items))
1736
            doc.AddItem('<p>')
1737
        if unsubscribe_success:
1823 by Mark Sapiro
Changed new 'Successfully unsubscribed:' to existing
1738
            doc.AddItem(Header(5, _('Successfully Unsubscribed:')))
1820 by Mark Sapiro
Implemented web admin sync members.
1739
            doc.AddItem(UnorderedList(*unsubscribe_success))
1740
            doc.AddItem('<p>')
1741
1 by
This commit was manufactured by cvs2svn to create branch
1742
    # See if this was a moderation bit operation
1743
    if cgidata.has_key('allmodbit_btn'):
1360 by Mark Sapiro
The query fragments send_unsub_notifications_to_list_owner and
1744
        val = safeint('allmodbit_val')
1 by
This commit was manufactured by cvs2svn to create branch
1745
        if val not in (0, 1):
1746
            doc.addError(_('Bad moderation flag value'))
1747
        else:
1748
            for member in mlist.getMembers():
1749
                mlist.setMemberOption(member, mm_cfg.Moderate, val)
1750
    # do the user options for members category
1751
    if cgidata.has_key('setmemberopts_btn') and cgidata.has_key('user'):
1752
        user = cgidata['user']
1753
        if type(user) is ListType:
1754
            users = []
1755
            for ui in range(len(user)):
1756
                users.append(urllib.unquote(user[ui].value))
1757
        else:
1758
            users = [urllib.unquote(user.value)]
1759
        errors = []
1760
        removes = []
1761
        for user in users:
972 by msapiro
- CGI/admin.py
1762
            quser = urllib.quote(user)
1763
            if cgidata.has_key('%s_unsub' % quser):
1 by
This commit was manufactured by cvs2svn to create branch
1764
                try:
1776 by Mark Sapiro
I18n for new whence reasons in admin (un)subscribe notices.
1765
                    _ = D_
1766
                    whence=_('member mgt page')
1767
                    _ = i18n._
1768
                    mlist.ApprovedDeleteMember(user, whence=whence)
1 by
This commit was manufactured by cvs2svn to create branch
1769
                    removes.append(user)
1770
                except Errors.NotAMemberError:
1771
                    errors.append((user, _('Not subscribed')))
1772
                continue
1773
            if not mlist.isMember(user):
1774
                doc.addError(_('Ignoring changes to deleted member: %(user)s'),
1775
                             tag=_('Warning: '))
1776
                continue
972 by msapiro
- CGI/admin.py
1777
            value = cgidata.has_key('%s_digest' % quser)
1 by
This commit was manufactured by cvs2svn to create branch
1778
            try:
1779
                mlist.setMemberOption(user, mm_cfg.Digests, value)
1780
            except (Errors.AlreadyReceivingDigests,
1781
                    Errors.AlreadyReceivingRegularDeliveries,
1782
                    Errors.CantDigestError,
1783
                    Errors.MustDigestError):
1784
                # BAW: Hmm...
1785
                pass
1786
1712 by Mark Sapiro
Defend against CGI requests with multiple values for the same parameter.
1787
            newname = cgidata.getfirst(quser+'_realname', '')
1 by
This commit was manufactured by cvs2svn to create branch
1788
            newname = Utils.canonstr(newname, mlist.preferred_language)
1789
            mlist.setMemberName(user, newname)
1790
1712 by Mark Sapiro
Defend against CGI requests with multiple values for the same parameter.
1791
            newlang = cgidata.getfirst(quser+'_language')
1 by
This commit was manufactured by cvs2svn to create branch
1792
            oldlang = mlist.getMemberLanguage(user)
20 by bwarsaw
Backporting from the trunk.
1793
            if Utils.IsLanguage(newlang) and newlang <> oldlang:
1 by
This commit was manufactured by cvs2svn to create branch
1794
                mlist.setMemberLanguage(user, newlang)
1795
1712 by Mark Sapiro
Defend against CGI requests with multiple values for the same parameter.
1796
            moderate = not not cgidata.getfirst(quser+'_mod')
1 by
This commit was manufactured by cvs2svn to create branch
1797
            mlist.setMemberOption(user, mm_cfg.Moderate, moderate)
1798
1799
            # Set the `nomail' flag, but only if the user isn't already
1800
            # disabled (otherwise we might change BYUSER into BYADMIN).
972 by msapiro
- CGI/admin.py
1801
            if cgidata.has_key('%s_nomail' % quser):
1 by
This commit was manufactured by cvs2svn to create branch
1802
                if mlist.getDeliveryStatus(user) == MemberAdaptor.ENABLED:
1803
                    mlist.setDeliveryStatus(user, MemberAdaptor.BYADMIN)
1804
            else:
1805
                mlist.setDeliveryStatus(user, MemberAdaptor.ENABLED)
1806
            for opt in ('hide', 'ack', 'notmetoo', 'nodupes', 'plain'):
1807
                opt_code = mm_cfg.OPTINFO[opt]
972 by msapiro
- CGI/admin.py
1808
                if cgidata.has_key('%s_%s' % (quser, opt)):
1 by
This commit was manufactured by cvs2svn to create branch
1809
                    mlist.setMemberOption(user, opt_code, 1)
1810
                else:
1811
                    mlist.setMemberOption(user, opt_code, 0)
1812
        # Give some feedback on who's been removed
1813
        if removes:
1814
            doc.AddItem(Header(5, _('Successfully Removed:')))
1815
            doc.AddItem(UnorderedList(*removes))
1816
            doc.AddItem('<p>')
1817
        if errors:
1818
            doc.AddItem(Header(5, _("Error Unsubscribing:")))
1819
            items = ['%s -- %s' % (x[0], x[1]) for x in errors]
1820
            doc.AddItem(apply(UnorderedList, tuple((items))))
1821
            doc.AddItem("<p>")