diff options
Diffstat (limited to 'src/mailman/web')
32 files changed, 9725 insertions, 0 deletions
diff --git a/src/mailman/web/Cgi/Auth.py b/src/mailman/web/Cgi/Auth.py new file mode 100644 index 000000000..825d972f4 --- /dev/null +++ b/src/mailman/web/Cgi/Auth.py @@ -0,0 +1,60 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Common routines for logging in and logging out of the list administrator +and list moderator interface. +""" + +from Mailman import Utils +from Mailman.htmlformat import FontAttr +from Mailman.i18n import _ + + + +class NotLoggedInError(Exception): + """Exception raised when no matching admin cookie was found.""" + def __init__(self, message): + Exception.__init__(self, message) + self.message = message + + + +def loginpage(mlist, scriptname, msg='', frontpage=False): + url = mlist.GetScriptURL(scriptname) + if frontpage: + actionurl = url + else: + request = Utils.GetRequestURI(url).lstrip('/') + up = '../' * request.count('/') + actionurl = up + request + if msg: + msg = FontAttr(msg, color='#ff0000', size='+1').Format() + if scriptname == 'admindb': + who = _('Moderator') + else: + who = _('Administrator') + # Language stuff + charset = Utils.GetCharSet(mlist.preferred_language) + print 'Content-type: text/html; charset=' + charset + '\n\n' + print Utils.maketext( + 'admlogin.html', + {'listname': mlist.real_name, + 'path' : actionurl, + 'message' : msg, + 'who' : who, + }, mlist=mlist).encode(charset) + print mlist.GetMailmanFooter().encode(charset) diff --git a/src/mailman/web/Cgi/__init__.py b/src/mailman/web/Cgi/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/mailman/web/Cgi/__init__.py diff --git a/src/mailman/web/Cgi/admin.py b/src/mailman/web/Cgi/admin.py new file mode 100644 index 000000000..e5c6ee14b --- /dev/null +++ b/src/mailman/web/Cgi/admin.py @@ -0,0 +1,1433 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Process and produce the list-administration options forms.""" + +import os +import re +import cgi +import sha +import sys +import urllib +import logging + +from email.Utils import unquote, parseaddr, formataddr +from string import lowercase, digits + +from Mailman import Errors +from Mailman import MailList +from Mailman import MemberAdaptor +from Mailman import Utils +from Mailman import i18n +from Mailman import passwords +from Mailman.Cgi import Auth +from Mailman.UserDesc import UserDesc +from Mailman.configuration import config +from Mailman.htmlformat import * + +# Set up i18n +_ = i18n._ +i18n.set_language(config.DEFAULT_SERVER_LANGUAGE) + +NL = '\n' +OPTCOLUMNS = 11 + +log = logging.getLogger('mailman.error') + + + +def main(): + # Try to find out which list is being administered + parts = Utils.GetPathPieces() + if not parts: + # None, so just do the admin overview and be done with it + admin_overview() + return + # Get the list object + listname = parts[0].lower() + try: + mlist = MailList.MailList(listname, lock=False) + except Errors.MMListError, e: + # Avoid cross-site scripting attacks + safelistname = Utils.websafe(listname) + admin_overview(_('No such list <em>%(safelistname)s</em>')) + log.error('admin.py access for non-existent list: %s', listname) + return + # Now that we know what list has been requested, all subsequent admin + # pages are shown in that list's preferred language. + i18n.set_language(mlist.preferred_language) + # If the user is not authenticated, we're done. + cgidata = cgi.FieldStorage(keep_blank_values=1) + + if not mlist.WebAuthenticate((config.AuthListAdmin, + config.AuthSiteAdmin), + cgidata.getvalue('adminpw', '')): + if cgidata.has_key('adminpw'): + # This is a re-authorization attempt + msg = Bold(FontSize('+1', _('Authorization failed.'))).Format() + else: + msg = '' + Auth.loginpage(mlist, 'admin', msg=msg) + return + + # Which subcategory was requested? Default is `general' + if len(parts) == 1: + category = 'general' + subcat = None + elif len(parts) == 2: + category = parts[1] + subcat = None + else: + category = parts[1] + subcat = parts[2] + + # Is this a log-out request? + if category == 'logout': + print mlist.ZapCookie(config.AuthListAdmin) + Auth.loginpage(mlist, 'admin', frontpage=True) + return + + # Sanity check + if category not in mlist.GetConfigCategories().keys(): + category = 'general' + + # Is the request for variable details? + varhelp = None + qsenviron = os.environ.get('QUERY_STRING') + parsedqs = None + if qsenviron: + parsedqs = cgi.parse_qs(qsenviron) + if cgidata.has_key('VARHELP'): + varhelp = cgidata.getvalue('VARHELP') + elif parsedqs: + # POST methods, even if their actions have a query string, don't get + # put into FieldStorage's keys :-( + qs = parsedqs.get('VARHELP') + if qs and isinstance(qs, list): + varhelp = qs[0] + if varhelp: + option_help(mlist, varhelp) + return + + # The html page document + doc = Document() + doc.set_language(mlist.preferred_language) + mlist.Lock() + try: + if cgidata.keys(): + # There are options to change + change_options(mlist, category, subcat, cgidata, doc) + # Let the list sanity check the changed values + mlist.CheckValues() + # Additional sanity checks + if not mlist.digestable and not mlist.nondigestable: + doc.addError( + _('''You have turned off delivery of both digest and + non-digest messages. This is an incompatible state of + affairs. You must turn on either digest delivery or + non-digest delivery or your mailing list will basically be + unusable.'''), tag=_('Warning: ')) + + if not mlist.digestable and mlist.getDigestMemberKeys(): + doc.addError( + _('''You have digest members, but digests are turned + off. Those people will not receive mail.'''), + tag=_('Warning: ')) + if not mlist.nondigestable and mlist.getRegularMemberKeys(): + doc.addError( + _('''You have regular list members but non-digestified mail is + turned off. They will receive mail until you fix this + problem.'''), tag=_('Warning: ')) + # Glom up the results page and print it out + show_results(mlist, doc, category, subcat, cgidata) + print doc.Format() + mlist.Save() + finally: + mlist.Unlock() + + + +def admin_overview(msg=''): + # Show the administrative overview page, with the list of all the lists on + # this host. msg is an optional error message to display at the top of + # the page. + # + # This page should be displayed in the server's default language, which + # should have already been set. + hostname = Utils.get_request_domain() + legend = _('%(hostname)s mailing lists - Admin Links') + # The html `document' + doc = Document() + doc.set_language(config.DEFAULT_SERVER_LANGUAGE) + doc.SetTitle(legend) + # The table that will hold everything + table = Table(border=0, width="100%") + table.AddRow([Center(Header(2, legend))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2, + bgcolor=config.WEB_HEADER_COLOR) + # Skip any mailing list that isn't advertised. + advertised = [] + for name in sorted(config.list_manager.names): + mlist = MailList.MailList(name, lock=False) + if mlist.advertised: + if hostname not in mlist.web_page_url: + # This list is situated in a different virtual domain + continue + else: + advertised.append((mlist.GetScriptURL('admin'), + mlist.real_name, + mlist.description)) + # Greeting depends on whether there was an error or not + if msg: + greeting = FontAttr(msg, color="ff5060", size="+1") + else: + greeting = _("Welcome!") + + welcome = [] + mailmanlink = Link(config.MAILMAN_URL, _('Mailman')).Format() + if not advertised: + welcome.extend([ + greeting, + _('''<p>There currently are no publicly-advertised %(mailmanlink)s + mailing lists on %(hostname)s.'''), + ]) + else: + welcome.extend([ + greeting, + _('''<p>Below is the collection of publicly-advertised + %(mailmanlink)s mailing lists on %(hostname)s. Click on a list + name to visit the configuration pages for that list.'''), + ]) + + creatorurl = Utils.ScriptURL('create') + mailman_owner = Utils.get_site_noreply() + extra = msg and _('right ') or '' + welcome.extend([ + _('''To visit the administrators configuration page for an + unadvertised list, open a URL similar to this one, but with a '/' and + the %(extra)slist name appended. If you have the proper authority, + you can also <a href="%(creatorurl)s">create a new mailing list</a>. + + <p>General list information can be found at '''), + Link(Utils.ScriptURL('listinfo'), + _('the mailing list overview page')), + '.', + _('<p>(Send questions and comments to '), + Link('mailto:%s' % mailman_owner, mailman_owner), + '.)<p>', + ]) + + table.AddRow([Container(*welcome)]) + table.AddCellInfo(max(table.GetCurrentRowIndex(), 0), 0, colspan=2) + + if advertised: + table.AddRow([' ', ' ']) + table.AddRow([Bold(FontAttr(_('List'), size='+2')), + Bold(FontAttr(_('Description'), size='+2')) + ]) + highlight = 1 + for url, real_name, description in advertised: + table.AddRow( + [Link(url, Bold(real_name)), + description or Italic(_('[no description available]'))]) + if highlight and config.WEB_HIGHLIGHT_COLOR: + table.AddRowInfo(table.GetCurrentRowIndex(), + bgcolor=config.WEB_HIGHLIGHT_COLOR) + highlight = not highlight + + doc.AddItem(table) + doc.AddItem('<hr>') + doc.AddItem(MailmanLogo()) + print doc.Format() + + + +def option_help(mlist, varhelp): + # The html page document + doc = Document() + doc.set_language(mlist.preferred_language) + # Find out which category and variable help is being requested for. + item = None + reflist = varhelp.split('/') + if len(reflist) >= 2: + category = subcat = None + if len(reflist) == 2: + category, varname = reflist + elif len(reflist) == 3: + category, subcat, varname = reflist + options = mlist.GetConfigInfo(category, subcat) + if options: + for i in options: + if i and i[0] == varname: + item = i + break + # Print an error message if we couldn't find a valid one + if not item: + bad = _('No valid variable name found.') + doc.addError(bad) + doc.AddItem(mlist.GetMailmanFooter()) + print doc.Format() + return + # Get the details about the variable + varname, kind, params, dependancies, description, elaboration = \ + get_item_characteristics(item) + # Set up the document + realname = mlist.real_name + legend = _("""%(realname)s Mailing list Configuration Help + <br><em>%(varname)s</em> Option""") + + header = Table(width='100%') + header.AddRow([Center(Header(3, legend))]) + header.AddCellInfo(header.GetCurrentRowIndex(), 0, colspan=2, + bgcolor=config.WEB_HEADER_COLOR) + doc.SetTitle(_("Mailman %(varname)s List Option Help")) + doc.AddItem(header) + doc.AddItem("<b>%s</b> (%s): %s<p>" % (varname, category, description)) + if elaboration: + doc.AddItem("%s<p>" % elaboration) + + if subcat: + url = '%s/%s/%s' % (mlist.GetScriptURL('admin'), category, subcat) + else: + url = '%s/%s' % (mlist.GetScriptURL('admin'), category) + form = Form(url) + valtab = Table(cellspacing=3, cellpadding=4, width='100%') + add_options_table_item(mlist, category, subcat, valtab, item, detailsp=0) + form.AddItem(valtab) + form.AddItem('<p>') + form.AddItem(Center(submit_button())) + doc.AddItem(Center(form)) + + doc.AddItem(_("""<em><strong>Warning:</strong> changing this option here + could cause other screens to be out-of-sync. Be sure to reload any other + pages that are displaying this option for this mailing list. You can also + """)) + + adminurl = mlist.GetScriptURL('admin') + if subcat: + url = '%s/%s/%s' % (adminurl, category, subcat) + else: + url = '%s/%s' % (adminurl, category) + categoryname = mlist.GetConfigCategories()[category][0] + doc.AddItem(Link(url, _('return to the %(categoryname)s options page.'))) + doc.AddItem('</em>') + doc.AddItem(mlist.GetMailmanFooter()) + print doc.Format() + + + +def show_results(mlist, doc, category, subcat, cgidata): + # Produce the results page + adminurl = mlist.GetScriptURL('admin') + categories = mlist.GetConfigCategories() + label = _(categories[category][0]) + + # Set up the document's headers + realname = mlist.real_name + doc.SetTitle(_('%(realname)s Administration (%(label)s)')) + doc.AddItem(Center(Header(2, _( + '%(realname)s mailing list administration<br>%(label)s Section')))) + doc.AddItem('<hr>') + # Now we need to craft the form that will be submitted, which will contain + # all the variable settings, etc. This is a bit of a kludge because we + # know that the autoreply and members categories supports file uploads. + encoding = None + if category in ('autoreply', 'members'): + encoding = 'multipart/form-data' + if subcat: + form = Form('%s/%s/%s' % (adminurl, category, subcat), + encoding=encoding) + else: + form = Form('%s/%s' % (adminurl, category), encoding=encoding) + # This holds the two columns of links + linktable = Table(valign='top', width='100%') + linktable.AddRow([Center(Bold(_("Configuration Categories"))), + Center(Bold(_("Other Administrative Activities")))]) + # The `other links' are stuff in the right column. + otherlinks = UnorderedList() + otherlinks.AddItem(Link(mlist.GetScriptURL('admindb'), + _('Tend to pending moderator requests'))) + otherlinks.AddItem(Link(mlist.GetScriptURL('listinfo'), + _('Go to the general list information page'))) + otherlinks.AddItem(Link(mlist.GetScriptURL('edithtml'), + _('Edit the public HTML pages and text files'))) + otherlinks.AddItem(Link(mlist.GetBaseArchiveURL(), + _('Go to list archives')).Format() + + '<br> <br>') + if config.OWNERS_CAN_DELETE_THEIR_OWN_LISTS: + otherlinks.AddItem(Link(mlist.GetScriptURL('rmlist'), + _('Delete this mailing list')).Format() + + _(' (requires confirmation)<br> <br>')) + otherlinks.AddItem(Link('%s/logout' % adminurl, + # BAW: What I really want is a blank line, but + # adding an won't do it because of the + # bullet added to the list item. + '<FONT SIZE="+2"><b>%s</b></FONT>' % + _('Logout'))) + # These are links to other categories and live in the left column + categorylinks_1 = categorylinks = UnorderedList() + categorylinks_2 = '' + categorykeys = categories.keys() + half = len(categorykeys) / 2 + counter = 0 + subcat = None + for k in categorykeys: + label = _(categories[k][0]) + url = '%s/%s' % (adminurl, k) + if k == category: + # Handle subcategories + subcats = mlist.GetConfigSubCategories(k) + if subcats: + subcat = Utils.GetPathPieces()[-1] + for k, v in subcats: + if k == subcat: + break + else: + # The first subcategory in the list is the default + subcat = subcats[0][0] + subcat_items = [] + for sub, text in subcats: + if sub == subcat: + text = Bold('[%s]' % text).Format() + subcat_items.append(Link(url + '/' + sub, text)) + categorylinks.AddItem( + Bold(label).Format() + + UnorderedList(*subcat_items).Format()) + else: + categorylinks.AddItem(Link(url, Bold('[%s]' % label))) + else: + categorylinks.AddItem(Link(url, label)) + counter += 1 + if counter >= half: + categorylinks_2 = categorylinks = UnorderedList() + counter = -len(categorykeys) + # Make the emergency stop switch a rude solo light + etable = Table() + # Add all the links to the links table... + etable.AddRow([categorylinks_1, categorylinks_2]) + etable.AddRowInfo(etable.GetCurrentRowIndex(), valign='top') + if mlist.emergency: + label = _('Emergency moderation of all list traffic is enabled') + etable.AddRow([Center( + Link('?VARHELP=general/emergency', Bold(label)))]) + color = config.WEB_ERROR_COLOR + etable.AddCellInfo(etable.GetCurrentRowIndex(), 0, + colspan=2, bgcolor=color) + linktable.AddRow([etable, otherlinks]) + # ...and add the links table to the document. + form.AddItem(linktable) + form.AddItem('<hr>') + form.AddItem( + _('''Make your changes in the following section, then submit them + using the <em>Submit Your Changes</em> button below.''') + + '<p>') + + # The members and passwords categories are special in that they aren't + # defined in terms of gui elements. Create those pages here. + if category == 'members': + # Figure out which subcategory we should display + subcat = Utils.GetPathPieces()[-1] + if subcat not in ('list', 'add', 'remove'): + subcat = 'list' + # Add member category specific tables + form.AddItem(membership_options(mlist, subcat, cgidata, doc, form)) + form.AddItem(Center(submit_button('setmemberopts_btn'))) + # In "list" subcategory, we can also search for members + if subcat == 'list': + form.AddItem('<hr>\n') + table = Table(width='100%') + table.AddRow([Center(Header(2, _('Additional Member Tasks')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2, + bgcolor=config.WEB_HEADER_COLOR) + # Add a blank separator row + table.AddRow([' ', ' ']) + # Add a section to set the moderation bit for all members + table.AddRow([_("""<li>Set everyone's moderation bit, including + those members not currently visible""")]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([RadioButtonArray('allmodbit_val', + (_('Off'), _('On')), + mlist.default_member_moderation), + SubmitButton('allmodbit_btn', _('Set'))]) + form.AddItem(table) + elif category == 'passwords': + form.AddItem(Center(password_inputs(mlist))) + form.AddItem(Center(submit_button())) + else: + form.AddItem(show_variables(mlist, category, subcat, cgidata, doc)) + form.AddItem(Center(submit_button())) + # And add the form + doc.AddItem(form) + doc.AddItem(mlist.GetMailmanFooter()) + + + +def show_variables(mlist, category, subcat, cgidata, doc): + options = mlist.GetConfigInfo(category, subcat) + + # The table containing the results + table = Table(cellspacing=3, cellpadding=4, width='100%') + + # Get and portray the text label for the category. + categories = mlist.GetConfigCategories() + label = _(categories[category][0]) + + table.AddRow([Center(Header(2, label))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2, + bgcolor=config.WEB_HEADER_COLOR) + + # The very first item in the config info will be treated as a general + # description if it is a string + description = options[0] + if isinstance(description, basestring): + table.AddRow([description]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + options = options[1:] + + if not options: + return table + + # Add the global column headers + table.AddRow([Center(Bold(_('Description'))), + Center(Bold(_('Value')))]) + table.AddCellInfo(max(table.GetCurrentRowIndex(), 0), 0, + width='15%') + table.AddCellInfo(max(table.GetCurrentRowIndex(), 0), 1, + width='85%') + + for item in options: + if isinstance(item, basestring): + # The very first banner option (string in an options list) is + # treated as a general description, while any others are + # treated as section headers - centered and italicized... + table.AddRow([Center(Italic(item))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + else: + add_options_table_item(mlist, category, subcat, table, item) + table.AddRow(['<br>']) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + return table + + + +def add_options_table_item(mlist, category, subcat, table, item, detailsp=1): + # Add a row to an options table with the item description and value. + varname, kind, params, extra, descr, elaboration = \ + get_item_characteristics(item) + if elaboration is None: + elaboration = descr + descr = get_item_gui_description(mlist, category, subcat, + varname, descr, elaboration, detailsp) + val = get_item_gui_value(mlist, category, kind, varname, params, extra) + table.AddRow([descr, val]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, + bgcolor=config.WEB_ADMINITEM_COLOR) + table.AddCellInfo(table.GetCurrentRowIndex(), 1, + bgcolor=config.WEB_ADMINITEM_COLOR) + + + +def get_item_characteristics(record): + # Break out the components of an item description from its description + # record: + # + # 0 -- option-var name + # 1 -- type + # 2 -- entry size + # 3 -- ?dependancies? + # 4 -- Brief description + # 5 -- Optional description elaboration + if len(record) == 5: + elaboration = None + varname, kind, params, dependancies, descr = record + elif len(record) == 6: + varname, kind, params, dependancies, descr, elaboration = record + else: + raise ValueError, _('Badly formed options entry:\n %(record)s') + return varname, kind, params, dependancies, descr, elaboration + + + +def get_item_gui_value(mlist, category, kind, varname, params, extra): + """Return a representation of an item's settings.""" + # Give the category a chance to return the value for the variable + value = None + label, gui = mlist.GetConfigCategories()[category] + if hasattr(gui, 'getValue'): + value = gui.getValue(mlist, kind, varname, params) + # Filter out None, and volatile attributes + if value is None and not varname.startswith('_'): + value = getattr(mlist, varname) + # Now create the widget for this value + if kind == config.Radio or kind == config.Toggle: + # If we are returning the option for subscribe policy and this site + # doesn't allow open subscribes, then we have to alter the value of + # mlist.subscribe_policy as passed to RadioButtonArray in order to + # compensate for the fact that there is one fewer option. + # Correspondingly, we alter the value back in the change options + # function -scott + # + # TBD: this is an ugly ugly hack. + if varname.startswith('_'): + checked = 0 + else: + checked = value + if varname == 'subscribe_policy' and not config.ALLOW_OPEN_SUBSCRIBE: + checked = checked - 1 + # For Radio buttons, we're going to interpret the extra stuff as a + # horizontal/vertical flag. For backwards compatibility, the value 0 + # means horizontal, so we use "not extra" to get the parity right. + return RadioButtonArray(varname, params, checked, not extra) + elif (kind == config.String or kind == config.Email or + kind == config.Host or kind == config.Number): + return TextBox(varname, value, params) + elif kind == config.Text: + if params: + r, c = params + else: + r, c = None, None + return TextArea(varname, value or '', r, c) + elif kind in (config.EmailList, config.EmailListEx): + if params: + r, c = params + else: + r, c = None, None + res = NL.join(value) + return TextArea(varname, res, r, c, wrap='off') + elif kind == config.FileUpload: + # like a text area, but also with uploading + if params: + r, c = params + else: + r, c = None, None + container = Container() + container.AddItem(_('<em>Enter the text below, or...</em><br>')) + container.AddItem(TextArea(varname, value or '', r, c)) + container.AddItem(_('<br><em>...specify a file to upload</em><br>')) + container.AddItem(FileUpload(varname+'_upload', r, c)) + return container + elif kind == config.Select: + if params: + values, legend, selected = params + else: + codes = mlist.language_codes + legend = [config.languages.get_description(code) for code in codes] + selected = codes.index(mlist.preferred_language) + return SelectOptions(varname, values, legend, selected) + elif kind == config.Topics: + # A complex and specialized widget type that allows for setting of a + # topic name, a mark button, a regexp text box, an "add after mark", + # and a delete button. Yeesh! params are ignored. + table = Table(border=0) + # This adds the html for the entry widget + def makebox(i, name, pattern, desc, empty=False, table=table): + deltag = 'topic_delete_%02d' % i + boxtag = 'topic_box_%02d' % i + reboxtag = 'topic_rebox_%02d' % i + desctag = 'topic_desc_%02d' % i + wheretag = 'topic_where_%02d' % i + addtag = 'topic_add_%02d' % i + newtag = 'topic_new_%02d' % i + if empty: + table.AddRow([Center(Bold(_('Topic %(i)d'))), + Hidden(newtag)]) + else: + table.AddRow([Center(Bold(_('Topic %(i)d'))), + SubmitButton(deltag, _('Delete'))]) + table.AddRow([Label(_('Topic name:')), + TextBox(boxtag, value=name, size=30)]) + table.AddRow([Label(_('Regexp:')), + TextArea(reboxtag, text=pattern, + rows=4, cols=30, wrap='off')]) + table.AddRow([Label(_('Description:')), + TextArea(desctag, text=desc, + rows=4, cols=30, wrap='soft')]) + if not empty: + table.AddRow([SubmitButton(addtag, _('Add new item...')), + SelectOptions(wheretag, ('before', 'after'), + (_('...before this one.'), + _('...after this one.')), + selected=1), + ]) + table.AddRow(['<hr>']) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + # Now for each element in the existing data, create a widget + i = 1 + data = getattr(mlist, varname) + for name, pattern, desc, empty in data: + makebox(i, name, pattern, desc, empty) + i += 1 + # Add one more non-deleteable widget as the first blank entry, but + # only if there are no real entries. + if i == 1: + makebox(i, '', '', '', empty=True) + return table + elif kind == config.HeaderFilter: + # A complex and specialized widget type that allows for setting of a + # spam filter rule including, a mark button, a regexp text box, an + # "add after mark", up and down buttons, and a delete button. Yeesh! + # params are ignored. + table = Table(border=0) + # This adds the html for the entry widget + def makebox(i, pattern, action, empty=False, table=table): + deltag = 'hdrfilter_delete_%02d' % i + reboxtag = 'hdrfilter_rebox_%02d' % i + actiontag = 'hdrfilter_action_%02d' % i + wheretag = 'hdrfilter_where_%02d' % i + addtag = 'hdrfilter_add_%02d' % i + newtag = 'hdrfilter_new_%02d' % i + uptag = 'hdrfilter_up_%02d' % i + downtag = 'hdrfilter_down_%02d' % i + if empty: + table.AddRow([Center(Bold(_('Spam Filter Rule %(i)d'))), + Hidden(newtag)]) + else: + table.AddRow([Center(Bold(_('Spam Filter Rule %(i)d'))), + SubmitButton(deltag, _('Delete'))]) + table.AddRow([Label(_('Spam Filter Regexp:')), + TextArea(reboxtag, text=pattern, + rows=4, cols=30, wrap='off')]) + values = [config.DEFER, config.HOLD, config.REJECT, + config.DISCARD, config.ACCEPT] + try: + checked = values.index(action) + except ValueError: + checked = 0 + radio = RadioButtonArray( + actiontag, + (_('Defer'), _('Hold'), _('Reject'), + _('Discard'), _('Accept')), + values=values, + checked=checked).Format() + table.AddRow([Label(_('Action:')), radio]) + if not empty: + table.AddRow([SubmitButton(addtag, _('Add new item...')), + SelectOptions(wheretag, ('before', 'after'), + (_('...before this one.'), + _('...after this one.')), + selected=1), + ]) + # BAW: IWBNI we could disable the up and down buttons for the + # first and last item respectively, but it's not easy to know + # which is the last item, so let's not worry about that for + # now. + table.AddRow([SubmitButton(uptag, _('Move rule up')), + SubmitButton(downtag, _('Move rule down'))]) + table.AddRow(['<hr>']) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + # Now for each element in the existing data, create a widget + i = 1 + data = getattr(mlist, varname) + for pattern, action, empty in data: + makebox(i, pattern, action, empty) + i += 1 + # Add one more non-deleteable widget as the first blank entry, but + # only if there are no real entries. + if i == 1: + makebox(i, '', config.DEFER, empty=True) + return table + elif kind == config.Checkbox: + return CheckBoxArray(varname, *params) + else: + assert 0, 'Bad gui widget type: %s' % kind + + + +def get_item_gui_description(mlist, category, subcat, + varname, descr, elaboration, detailsp): + # Return the item's description, with link to details. + # + # Details are not included if this is a VARHELP page, because that /is/ + # the details page! + if detailsp: + if subcat: + varhelp = '?VARHELP=%s/%s/%s' % (category, subcat, varname) + else: + varhelp = '?VARHELP=%s/%s' % (category, varname) + if descr == elaboration: + linktext = _('<br>(Edit <b>%(varname)s</b>)') + else: + linktext = _('<br>(Details for <b>%(varname)s</b>)') + link = Link(mlist.GetScriptURL('admin') + varhelp, + linktext).Format() + text = Label('%s %s' % (descr, link)).Format() + else: + text = Label(descr).Format() + if varname[0] == '_': + text += Label(_('''<br><em><strong>Note:</strong> + setting this value performs an immediate action but does not modify + permanent state.</em>''')).Format() + return text + + + +def membership_options(mlist, subcat, cgidata, doc, form): + # Show the main stuff + adminurl = mlist.GetScriptURL('admin') + container = Container() + header = Table(width="100%") + # If we're in the list subcategory, show the membership list + if subcat == 'add': + header.AddRow([Center(Header(2, _('Mass Subscriptions')))]) + header.AddCellInfo(header.GetCurrentRowIndex(), 0, colspan=2, + bgcolor=config.WEB_HEADER_COLOR) + container.AddItem(header) + mass_subscribe(mlist, container) + return container + if subcat == 'remove': + header.AddRow([Center(Header(2, _('Mass Removals')))]) + header.AddCellInfo(header.GetCurrentRowIndex(), 0, colspan=2, + bgcolor=config.WEB_HEADER_COLOR) + container.AddItem(header) + mass_remove(mlist, container) + return container + # Otherwise... + header.AddRow([Center(Header(2, _('Membership List')))]) + header.AddCellInfo(header.GetCurrentRowIndex(), 0, colspan=2, + bgcolor=config.WEB_HEADER_COLOR) + container.AddItem(header) + # Add a "search for member" button + table = Table(width='100%') + link = Link('http://www.python.org/doc/current/lib/re-syntax.html', + _('(help)')).Format() + table.AddRow([Label(_('Find member %(link)s:')), + TextBox('findmember', + value=cgidata.getvalue('findmember', '')), + SubmitButton('findmember_btn', _('Search...'))]) + container.AddItem(table) + container.AddItem('<hr><p>') + usertable = Table(width="90%", border='2') + # If there are more members than allowed by chunksize, then we split the + # membership up alphabetically. Otherwise just display them all. + chunksz = mlist.admin_member_chunksize + # The email addresses had /better/ be ASCII, but might be encoded in the + # database as Unicodes. + all = [_m.encode() for _m in mlist.getMembers()] + all.sort(lambda x, y: cmp(x.lower(), y.lower())) + # See if the query has a regular expression + regexp = cgidata.getvalue('findmember', '').strip() + if regexp: + try: + cre = re.compile(regexp, re.IGNORECASE) + except re.error: + doc.addError(_('Bad regular expression: ') + regexp) + else: + # BAW: There's got to be a more efficient way of doing this! + names = [mlist.getMemberName(s) or '' for s in all] + all = [a for n, a in zip(names, all) + if cre.search(n) or cre.search(a)] + chunkindex = None + bucket = None + actionurl = None + if len(all) < chunksz: + members = all + else: + # Split them up alphabetically, and then split the alphabetical + # listing by chunks + buckets = {} + for addr in all: + members = buckets.setdefault(addr[0].lower(), []) + members.append(addr) + # Now figure out which bucket we want + bucket = None + qs = {} + # POST methods, even if their actions have a query string, don't get + # put into FieldStorage's keys :-( + qsenviron = os.environ.get('QUERY_STRING') + if qsenviron: + qs = cgi.parse_qs(qsenviron) + bucket = qs.get('letter', 'a')[0].lower() + if bucket not in digits + lowercase: + bucket = None + if not bucket or not buckets.has_key(bucket): + keys = buckets.keys() + keys.sort() + bucket = keys[0] + members = buckets[bucket] + action = adminurl + '/members?letter=%s' % bucket + if len(members) <= chunksz: + form.set_action(action) + else: + i, r = divmod(len(members), chunksz) + numchunks = i + (not not r * 1) + # Now chunk them up + chunkindex = 0 + if qs.has_key('chunk'): + try: + chunkindex = int(qs['chunk'][0]) + except ValueError: + chunkindex = 0 + if chunkindex < 0 or chunkindex > numchunks: + chunkindex = 0 + members = members[chunkindex*chunksz:(chunkindex+1)*chunksz] + # And set the action URL + form.set_action(action + '&chunk=%s' % chunkindex) + # So now members holds all the addresses we're going to display + allcnt = len(all) + if bucket: + membercnt = len(members) + usertable.AddRow([Center(Italic(_( + '%(allcnt)s members total, %(membercnt)s shown')))]) + else: + usertable.AddRow([Center(Italic(_('%(allcnt)s members total')))]) + usertable.AddCellInfo(usertable.GetCurrentRowIndex(), + usertable.GetCurrentCellIndex(), + colspan=OPTCOLUMNS, + bgcolor=config.WEB_ADMINITEM_COLOR) + # Add the alphabetical links + if bucket: + cells = [] + for letter in digits + lowercase: + if not buckets.get(letter): + continue + url = adminurl + '/members?letter=%s' % letter + if letter == bucket: + show = Bold('[%s]' % letter.upper()).Format() + else: + show = letter.upper() + cells.append(Link(url, show).Format()) + joiner = ' '*2 + '\n' + usertable.AddRow([Center(joiner.join(cells))]) + usertable.AddCellInfo(usertable.GetCurrentRowIndex(), + usertable.GetCurrentCellIndex(), + colspan=OPTCOLUMNS, + bgcolor=config.WEB_ADMINITEM_COLOR) + usertable.AddRow([Center(h) for h in (_('unsub'), + _('member address<br>member name'), + _('mod'), _('hide'), + _('nomail<br>[reason]'), + _('ack'), _('not metoo'), + _('nodupes'), + _('digest'), _('plain'), + _('language'))]) + rowindex = usertable.GetCurrentRowIndex() + for i in range(OPTCOLUMNS): + usertable.AddCellInfo(rowindex, i, bgcolor=config.WEB_ADMINITEM_COLOR) + # Find the longest name in the list + longest = 0 + if members: + names = filter(None, [mlist.getMemberName(s) for s in members]) + # Make the name field at least as long as the longest email address + longest = max([len(s) for s in names + members]) + # Abbreviations for delivery status details + ds_abbrevs = {MemberAdaptor.UNKNOWN : _('?'), + MemberAdaptor.BYUSER : _('U'), + MemberAdaptor.BYADMIN : _('A'), + MemberAdaptor.BYBOUNCE: _('B'), + } + # Now populate the rows + for addr in members: + link = Link(mlist.GetOptionsURL(addr, obscure=1), + mlist.getMemberCPAddress(addr)) + fullname = mlist.getMemberName(addr) + name = TextBox(addr + '_realname', fullname, size=longest).Format() + cells = [Center(CheckBox(addr + '_unsub', 'off', 0).Format()), + link.Format() + '<br>' + + name + + Hidden('user', urllib.quote(addr)).Format(), + ] + # Do the `mod' option + if mlist.getMemberOption(addr, config.Moderate): + value = 'on' + checked = 1 + else: + value = 'off' + checked = 0 + box = CheckBox('%s_mod' % addr, value, checked) + cells.append(Center(box).Format()) + for opt in ('hide', 'nomail', 'ack', 'notmetoo', 'nodupes'): + extra = '' + if opt == 'nomail': + status = mlist.getDeliveryStatus(addr) + if status == MemberAdaptor.ENABLED: + value = 'off' + checked = 0 + else: + value = 'on' + checked = 1 + extra = '[%s]' % ds_abbrevs[status] + elif mlist.getMemberOption(addr, config.OPTINFO[opt]): + value = 'on' + checked = 1 + else: + value = 'off' + checked = 0 + box = CheckBox('%s_%s' % (addr, opt), value, checked) + cells.append(Center(box.Format() + extra)) + # This code is less efficient than the original which did a has_key on + # the underlying dictionary attribute. This version is slower and + # less memory efficient. It points to a new MemberAdaptor interface + # method. + if addr in mlist.getRegularMemberKeys(): + cells.append(Center(CheckBox(addr + '_digest', 'off', 0).Format())) + else: + cells.append(Center(CheckBox(addr + '_digest', 'on', 1).Format())) + if mlist.getMemberOption(addr, config.OPTINFO['plain']): + value = 'on' + checked = 1 + else: + value = 'off' + checked = 0 + cells.append(Center(CheckBox('%s_plain' % addr, value, checked))) + # User's preferred language + langpref = mlist.getMemberLanguage(addr) + langs = mlist.language_codes + langdescs = [_(config.languges.get_description(code)) + for code in langs] + try: + selected = langs.index(langpref) + except ValueError: + selected = 0 + cells.append(Center(SelectOptions(addr + '_language', langs, + langdescs, selected)).Format()) + usertable.AddRow(cells) + # Add the usertable and a legend + legend = UnorderedList() + legend.AddItem( + _('<b>unsub</b> -- Click on this to unsubscribe the member.')) + legend.AddItem( + _("""<b>mod</b> -- The user's personal moderation flag. If this is + set, postings from them will be moderated, otherwise they will be + approved.""")) + legend.AddItem( + _("""<b>hide</b> -- Is the member's address concealed on + the list of subscribers?""")) + legend.AddItem(_( + """<b>nomail</b> -- Is delivery to the member disabled? If so, an + abbreviation will be given describing the reason for the disabled + delivery: + <ul><li><b>U</b> -- Delivery was disabled by the user via their + personal options page. + <li><b>A</b> -- Delivery was disabled by the list + administrators. + <li><b>B</b> -- Delivery was disabled by the system due to + excessive bouncing from the member's address. + <li><b>?</b> -- The reason for disabled delivery isn't known. + This is the case for all memberships which were disabled + in older versions of Mailman. + </ul>""")) + legend.AddItem( + _('''<b>ack</b> -- Does the member get acknowledgements of their + posts?''')) + legend.AddItem( + _('''<b>not metoo</b> -- Does the member want to avoid copies of their + own postings?''')) + legend.AddItem( + _('''<b>nodupes</b> -- Does the member want to avoid duplicates of the + same message?''')) + legend.AddItem( + _('''<b>digest</b> -- Does the member get messages in digests? + (otherwise, individual messages)''')) + legend.AddItem( + _('''<b>plain</b> -- If getting digests, does the member get plain + text digests? (otherwise, MIME)''')) + legend.AddItem(_("<b>language</b> -- Language preferred by the user")) + addlegend = '' + parsedqs = 0 + qsenviron = os.environ.get('QUERY_STRING') + if qsenviron: + qs = cgi.parse_qs(qsenviron).get('legend') + if qs and isinstance(qs, list): + qs = qs[0] + if qs == 'yes': + addlegend = 'legend=yes&' + if addlegend: + container.AddItem(legend.Format() + '<p>') + container.AddItem( + Link(adminurl + '/members/list', + _('Click here to hide the legend for this table.'))) + else: + container.AddItem( + Link(adminurl + '/members/list?legend=yes', + _('Click here to include the legend for this table.'))) + container.AddItem(Center(usertable)) + + # There may be additional chunks + if chunkindex is not None: + buttons = [] + url = adminurl + '/members?%sletter=%s&' % (addlegend, bucket) + footer = _('''<p><em>To view more members, click on the appropriate + range listed below:</em>''') + chunkmembers = buckets[bucket] + last = len(chunkmembers) + for i in range(numchunks): + if i == chunkindex: + continue + start = chunkmembers[i*chunksz] + end = chunkmembers[min((i+1)*chunksz, last)-1] + link = Link(url + 'chunk=%d' % i, _('from %(start)s to %(end)s')) + buttons.append(link) + buttons = UnorderedList(*buttons) + container.AddItem(footer + buttons.Format() + '<p>') + return container + + + +def mass_subscribe(mlist, container): + # MASS SUBSCRIBE + GREY = config.WEB_ADMINITEM_COLOR + table = Table(width='90%') + table.AddRow([ + Label(_('Subscribe these users now or invite them?')), + RadioButtonArray('subscribe_or_invite', + (_('Subscribe'), _('Invite')), + 0, values=(0, 1)) + ]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, bgcolor=GREY) + table.AddCellInfo(table.GetCurrentRowIndex(), 1, bgcolor=GREY) + table.AddRow([ + Label(_('Send welcome messages to new subscribees?')), + RadioButtonArray('send_welcome_msg_to_this_batch', + (_('No'), _('Yes')), + mlist.send_welcome_msg, + values=(0, 1)) + ]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, bgcolor=GREY) + table.AddCellInfo(table.GetCurrentRowIndex(), 1, bgcolor=GREY) + table.AddRow([ + Label(_('Send notifications of new subscriptions to the list owner?')), + RadioButtonArray('send_notifications_to_list_owner', + (_('No'), _('Yes')), + mlist.admin_notify_mchanges, + values=(0,1)) + ]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, bgcolor=GREY) + table.AddCellInfo(table.GetCurrentRowIndex(), 1, bgcolor=GREY) + table.AddRow([Italic(_('Enter one address per line below...'))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([Center(TextArea(name='subscribees', + rows=10, cols='70%', wrap=None))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([Italic(Label(_('...or specify a file to upload:'))), + FileUpload('subscribees_upload', cols='50')]) + container.AddItem(Center(table)) + # Invitation text + table.AddRow([' ', ' ']) + table.AddRow([Italic(_("""Below, enter additional text to be added to the + top of your invitation or the subscription notification. Include at least + one blank line at the end..."""))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([Center(TextArea(name='invitation', + rows=10, cols='70%', wrap=None))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + + + +def mass_remove(mlist, container): + # MASS UNSUBSCRIBE + GREY = config.WEB_ADMINITEM_COLOR + table = Table(width='90%') + table.AddRow([ + Label(_('Send unsubscription acknowledgement to the user?')), + RadioButtonArray('send_unsub_ack_to_this_batch', + (_('No'), _('Yes')), + 0, values=(0, 1)) + ]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, bgcolor=GREY) + table.AddCellInfo(table.GetCurrentRowIndex(), 1, bgcolor=GREY) + table.AddRow([ + Label(_('Send notifications to the list owner?')), + RadioButtonArray('send_unsub_notifications_to_list_owner', + (_('No'), _('Yes')), + mlist.admin_notify_mchanges, + values=(0, 1)) + ]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, bgcolor=GREY) + table.AddCellInfo(table.GetCurrentRowIndex(), 1, bgcolor=GREY) + table.AddRow([Italic(_('Enter one address per line below...'))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([Center(TextArea(name='unsubscribees', + rows=10, cols='70%', wrap=None))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([Italic(Label(_('...or specify a file to upload:'))), + FileUpload('unsubscribees_upload', cols='50')]) + container.AddItem(Center(table)) + + + +def password_inputs(mlist): + adminurl = mlist.GetScriptURL('admin') + table = Table(cellspacing=3, cellpadding=4) + table.AddRow([Center(Header(2, _('Change list ownership passwords')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2, + bgcolor=config.WEB_HEADER_COLOR) + table.AddRow([_("""\ +The <em>list administrators</em> are the people who have ultimate control over +all parameters of this mailing list. They are able to change any list +configuration variable available through these administration web pages. + +<p>The <em>list moderators</em> have more limited permissions; they are not +able to change any list configuration variable, but they are allowed to tend +to pending administration requests, including approving or rejecting held +subscription requests, and disposing of held postings. Of course, the +<em>list administrators</em> can also tend to pending requests. + +<p>In order to split the list ownership duties into administrators and +moderators, you must set a separate moderator password in the fields below, +and also provide the email addresses of the list moderators in the +<a href="%(adminurl)s/general">general options section</a>.""")]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + # Set up the admin password table on the left + atable = Table(border=0, cellspacing=3, cellpadding=4, + bgcolor=config.WEB_ADMINPW_COLOR) + atable.AddRow([Label(_('Enter new administrator password:')), + PasswordBox('newpw', size=20)]) + atable.AddRow([Label(_('Confirm administrator password:')), + PasswordBox('confirmpw', size=20)]) + # Set up the moderator password table on the right + mtable = Table(border=0, cellspacing=3, cellpadding=4, + bgcolor=config.WEB_ADMINPW_COLOR) + mtable.AddRow([Label(_('Enter new moderator password:')), + PasswordBox('newmodpw', size=20)]) + mtable.AddRow([Label(_('Confirm moderator password:')), + PasswordBox('confirmmodpw', size=20)]) + # Add these tables to the overall password table + table.AddRow([atable, mtable]) + return table + + + +def submit_button(name='submit'): + table = Table(border=0, cellspacing=0, cellpadding=2) + table.AddRow([Bold(SubmitButton(name, _('Submit Your Changes')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, align='middle') + return table + + + +def change_options(mlist, category, subcat, cgidata, doc): + def safeint(formvar, defaultval=None): + try: + return int(cgidata.getvalue(formvar)) + except (ValueError, TypeError): + return defaultval + confirmed = 0 + # Handle changes to the list moderator password. Do this before checking + # the new admin password, since the latter will force a reauthentication. + new = cgidata.getvalue('newmodpw', '').strip() + confirm = cgidata.getvalue('confirmmodpw', '').strip() + if new or confirm: + if new == confirm: + mlist.mod_password = passwords.make_secret( + new, config.PASSWORD_SCHEME) + # No re-authentication necessary because the moderator's + # password doesn't get you into these pages. + else: + doc.addError(_('Moderator passwords did not match')) + # Handle changes to the list administrator password + new = cgidata.getvalue('newpw', '').strip() + confirm = cgidata.getvalue('confirmpw', '').strip() + if new or confirm: + if new == confirm: + mlist.password = passwords.make_secret(new, config.PASSWORD_SCHEME) + # Set new cookie + print mlist.MakeCookie(config.AuthListAdmin) + else: + doc.addError(_('Administrator passwords did not match')) + # Give the individual gui item a chance to process the form data + categories = mlist.GetConfigCategories() + label, gui = categories[category] + # BAW: We handle the membership page special... for now. + if category <> 'members': + gui.handleForm(mlist, category, subcat, cgidata, doc) + # mass subscription, removal processing for members category + subscribers = '' + subscribers += cgidata.getvalue('subscribees', '') + subscribers += cgidata.getvalue('subscribees_upload', '') + if subscribers: + entries = filter(None, [n.strip() for n in subscribers.splitlines()]) + send_welcome_msg = safeint('send_welcome_msg_to_this_batch', + mlist.send_welcome_msg) + send_admin_notif = safeint('send_notifications_to_list_owner', + mlist.admin_notify_mchanges) + # Default is to subscribe + subscribe_or_invite = safeint('subscribe_or_invite', 0) + invitation = cgidata.getvalue('invitation', '') + digest = mlist.digest_is_default + if not mlist.digestable: + digest = 0 + if not mlist.nondigestable: + digest = 1 + subscribe_errors = [] + subscribe_success = [] + # Now cruise through all the subscribees and do the deed. BAW: we + # should limit the number of "Successfully subscribed" status messages + # we display. Try uploading a file with 10k names -- it takes a while + # to render the status page. + for entry in entries: + fullname, address = parseaddr(entry) + # Canonicalize the full name + fullname = Utils.canonstr(fullname, mlist.preferred_language) + userdesc = UserDesc(address, fullname, + Utils.MakeRandomPassword(), + digest, mlist.preferred_language) + try: + if subscribe_or_invite: + if mlist.isMember(address): + raise Errors.MMAlreadyAMember + else: + mlist.InviteNewMember(userdesc, invitation) + else: + mlist.ApprovedAddMember(userdesc, send_welcome_msg, + send_admin_notif, invitation, + whence='admin mass sub') + except Errors.MMAlreadyAMember: + subscribe_errors.append((entry, _('Already a member'))) + except Errors.InvalidEmailAddress: + if userdesc.address == '': + subscribe_errors.append((_('<blank line>'), + _('Bad/Invalid email address'))) + else: + subscribe_errors.append((entry, + _('Bad/Invalid email address'))) + except Errors.MembershipIsBanned, pattern: + subscribe_errors.append( + (entry, _('Banned address (matched %(pattern)s)'))) + else: + member = Utils.uncanonstr(formataddr((fullname, address))) + subscribe_success.append(Utils.websafe(member)) + if subscribe_success: + if subscribe_or_invite: + doc.AddItem(Header(5, _('Successfully invited:'))) + else: + doc.AddItem(Header(5, _('Successfully subscribed:'))) + doc.AddItem(UnorderedList(*subscribe_success)) + doc.AddItem('<p>') + if subscribe_errors: + if subscribe_or_invite: + doc.AddItem(Header(5, _('Error inviting:'))) + else: + doc.AddItem(Header(5, _('Error subscribing:'))) + items = ['%s -- %s' % (x0, x1) for x0, x1 in subscribe_errors] + doc.AddItem(UnorderedList(*items)) + doc.AddItem('<p>') + # Unsubscriptions + removals = '' + if cgidata.has_key('unsubscribees'): + removals += cgidata['unsubscribees'].value + if cgidata.has_key('unsubscribees_upload') and \ + cgidata['unsubscribees_upload'].value: + removals += cgidata['unsubscribees_upload'].value + if removals: + names = filter(None, [n.strip() for n in removals.splitlines()]) + send_unsub_notifications = int( + cgidata['send_unsub_notifications_to_list_owner'].value) + userack = int( + cgidata['send_unsub_ack_to_this_batch'].value) + unsubscribe_errors = [] + unsubscribe_success = [] + for addr in names: + try: + mlist.ApprovedDeleteMember( + addr, whence='admin mass unsub', + admin_notif=send_unsub_notifications, + userack=userack) + unsubscribe_success.append(addr) + except Errors.NotAMemberError: + unsubscribe_errors.append(addr) + if unsubscribe_success: + doc.AddItem(Header(5, _('Successfully Unsubscribed:'))) + doc.AddItem(UnorderedList(*unsubscribe_success)) + doc.AddItem('<p>') + if unsubscribe_errors: + doc.AddItem(Header(3, Bold(FontAttr( + _('Cannot unsubscribe non-members:'), + color='#ff0000', size='+2')).Format())) + doc.AddItem(UnorderedList(*unsubscribe_errors)) + doc.AddItem('<p>') + # See if this was a moderation bit operation + if cgidata.has_key('allmodbit_btn'): + val = cgidata.getvalue('allmodbit_val') + try: + val = int(val) + except VallueError: + val = None + if val not in (0, 1): + doc.addError(_('Bad moderation flag value')) + else: + for member in mlist.getMembers(): + mlist.setMemberOption(member, config.Moderate, val) + # do the user options for members category + if cgidata.has_key('setmemberopts_btn') and cgidata.has_key('user'): + user = cgidata['user'] + if isinstance(user, list): + users = [] + for ui in range(len(user)): + users.append(urllib.unquote(user[ui].value)) + else: + users = [urllib.unquote(user.value)] + errors = [] + removes = [] + for user in users: + if cgidata.has_key('%s_unsub' % user): + try: + mlist.ApprovedDeleteMember(user, whence='member mgt page') + removes.append(user) + except Errors.NotAMemberError: + errors.append((user, _('Not subscribed'))) + continue + if not mlist.isMember(user): + doc.addError(_('Ignoring changes to deleted member: %(user)s'), + tag=_('Warning: ')) + continue + value = cgidata.has_key('%s_digest' % user) + try: + mlist.setMemberOption(user, config.Digests, value) + except (Errors.AlreadyReceivingDigests, + Errors.AlreadyReceivingRegularDeliveries, + Errors.CantDigestError, + Errors.MustDigestError): + # BAW: Hmm... + pass + + newname = cgidata.getvalue(user+'_realname', '') + newname = Utils.canonstr(newname, mlist.preferred_language) + mlist.setMemberName(user, newname) + + newlang = cgidata.getvalue(user+'_language') + oldlang = mlist.getMemberLanguage(user) + if (newlang not in config.languages.enabled_codes + and newlang <> oldlang): + # Then + mlist.setMemberLanguage(user, newlang) + + moderate = not not cgidata.getvalue(user+'_mod') + mlist.setMemberOption(user, config.Moderate, moderate) + + # Set the `nomail' flag, but only if the user isn't already + # disabled (otherwise we might change BYUSER into BYADMIN). + if cgidata.has_key('%s_nomail' % user): + if mlist.getDeliveryStatus(user) == MemberAdaptor.ENABLED: + mlist.setDeliveryStatus(user, MemberAdaptor.BYADMIN) + else: + mlist.setDeliveryStatus(user, MemberAdaptor.ENABLED) + for opt in ('hide', 'ack', 'notmetoo', 'nodupes', 'plain'): + opt_code = config.OPTINFO[opt] + if cgidata.has_key('%s_%s' % (user, opt)): + mlist.setMemberOption(user, opt_code, 1) + else: + mlist.setMemberOption(user, opt_code, 0) + # Give some feedback on who's been removed + if removes: + doc.AddItem(Header(5, _('Successfully Removed:'))) + doc.AddItem(UnorderedList(*removes)) + doc.AddItem('<p>') + if errors: + doc.AddItem(Header(5, _("Error Unsubscribing:"))) + items = ['%s -- %s' % (x[0], x[1]) for x in errors] + doc.AddItem(apply(UnorderedList, tuple((items)))) + doc.AddItem("<p>") diff --git a/src/mailman/web/Cgi/admindb.py b/src/mailman/web/Cgi/admindb.py new file mode 100644 index 000000000..5d756fc78 --- /dev/null +++ b/src/mailman/web/Cgi/admindb.py @@ -0,0 +1,813 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Produce and process the pending-approval items for a list.""" + +import os +import cgi +import sys +import time +import email +import errno +import logging + +from urllib import quote_plus, unquote_plus + +from Mailman import Errors +from Mailman import MailList +from Mailman import Message +from Mailman import Utils +from Mailman import i18n +from Mailman.Cgi import Auth +from Mailman.Handlers.Moderate import ModeratedMemberPost +from Mailman.ListAdmin import readMessage +from Mailman.configuration import config +from Mailman.htmlformat import * +from Mailman.interfaces import RequestType + +EMPTYSTRING = '' +NL = '\n' + +# Set up i18n. Until we know which list is being requested, we use the +# server's default. +_ = i18n._ +i18n.set_language(config.DEFAULT_SERVER_LANGUAGE) + +EXCERPT_HEIGHT = 10 +EXCERPT_WIDTH = 76 + +log = logging.getLogger('mailman.error') + + + +def helds_by_sender(mlist): + bysender = {} + requests = config.db.get_list_requests(mlist) + for request in requests.of_type(RequestType.held_message): + key, data = requests.get_request(request.id) + sender = data.get('sender') + assert sender is not None, ( + 'No sender for held message: %s' % request.id) + bysender.setdefault(sender, []).append(request.id) + return bysender + + +def hacky_radio_buttons(btnname, labels, values, defaults, spacing=3): + # We can't use a RadioButtonArray here because horizontal placement can be + # confusing to the user and vertical placement takes up too much + # real-estate. This is a hack! + space = ' ' * spacing + btns = Table(cellspacing='5', cellpadding='0') + btns.AddRow([space + text + space for text in labels]) + btns.AddRow([Center(RadioButton(btnname, value, default)) + for value, default in zip(values, defaults)]) + return btns + + + +def main(): + # Figure out which list is being requested + parts = Utils.GetPathPieces() + if not parts: + handle_no_list() + return + + listname = parts[0].lower() + try: + mlist = MailList.MailList(listname, lock=0) + except Errors.MMListError, e: + # Avoid cross-site scripting attacks + safelistname = Utils.websafe(listname) + handle_no_list(_('No such list <em>%(safelistname)s</em>')) + log.error('No such list "%s": %s\n', listname, e) + return + + # Now that we know which list to use, set the system's language to it. + i18n.set_language(mlist.preferred_language) + + # Make sure the user is authorized to see this page. + cgidata = cgi.FieldStorage(keep_blank_values=1) + + if not mlist.WebAuthenticate((config.AuthListAdmin, + config.AuthListModerator, + config.AuthSiteAdmin), + cgidata.getvalue('adminpw', '')): + if cgidata.has_key('adminpw'): + # This is a re-authorization attempt + msg = Bold(FontSize('+1', _('Authorization failed.'))).Format() + else: + msg = '' + Auth.loginpage(mlist, 'admindb', msg=msg) + return + + # Set up the results document + doc = Document() + doc.set_language(mlist.preferred_language) + + # See if we're requesting all the messages for a particular sender, or if + # we want a specific held message. + sender = None + msgid = None + details = None + envar = os.environ.get('QUERY_STRING') + if envar: + # POST methods, even if their actions have a query string, don't get + # put into FieldStorage's keys :-( + qs = cgi.parse_qs(envar).get('sender') + if qs and isinstance(qs, list): + sender = qs[0] + qs = cgi.parse_qs(envar).get('msgid') + if qs and isinstance(qs, list): + msgid = qs[0] + qs = cgi.parse_qs(envar).get('details') + if qs and isinstance(qs, list): + details = qs[0] + + mlist.Lock() + try: + realname = mlist.real_name + if not cgidata.keys() or cgidata.has_key('admlogin'): + # If this is not a form submission (i.e. there are no keys in the + # form) or it's a login, then we don't need to do much special. + doc.SetTitle(_('%(realname)s Administrative Database')) + elif not details: + # This is a form submission + doc.SetTitle(_('%(realname)s Administrative Database Results')) + process_form(mlist, doc, cgidata) + # Now print the results and we're done. Short circuit for when there + # are no pending requests, but be sure to save the results! + if config.db.requests.get_list_requests(mlist).count == 0: + title = _('%(realname)s Administrative Database') + doc.SetTitle(title) + doc.AddItem(Header(2, title)) + doc.AddItem(_('There are no pending requests.')) + doc.AddItem(' ') + doc.AddItem(Link(mlist.GetScriptURL('admindb'), + _('Click here to reload this page.'))) + doc.AddItem(mlist.GetMailmanFooter()) + print doc.Format() + mlist.Save() + return + + admindburl = mlist.GetScriptURL('admindb') + form = Form(admindburl) + # Add the instructions template + if details == 'instructions': + doc.AddItem(Header( + 2, _('Detailed instructions for the administrative database'))) + else: + doc.AddItem(Header( + 2, + _('Administrative requests for mailing list:') + + ' <em>%s</em>' % mlist.real_name)) + if details <> 'instructions': + form.AddItem(Center(SubmitButton('submit', _('Submit All Data')))) + requestsdb = config.db.get_list_requests(mlist) + message_count = requestsdb.count_of(RequestType.held_message) + if not (details or sender or msgid or message_count == 0): + form.AddItem(Center( + CheckBox('discardalldefersp', 0).Format() + + ' ' + + _('Discard all messages marked <em>Defer</em>') + )) + # Add a link back to the overview, if we're not viewing the overview! + adminurl = mlist.GetScriptURL('admin') + d = {'listname' : mlist.real_name, + 'detailsurl': admindburl + '?details=instructions', + 'summaryurl': admindburl, + 'viewallurl': admindburl + '?details=all', + 'adminurl' : adminurl, + 'filterurl' : adminurl + '/privacy/sender', + } + addform = 1 + if sender: + esender = Utils.websafe(sender) + d['description'] = _("all of %(esender)s's held messages.") + doc.AddItem(Utils.maketext('admindbpreamble.html', d, + raw=1, mlist=mlist)) + show_sender_requests(mlist, form, sender) + elif msgid: + d['description'] = _('a single held message.') + doc.AddItem(Utils.maketext('admindbpreamble.html', d, + raw=1, mlist=mlist)) + show_message_requests(mlist, form, msgid) + elif details == 'all': + d['description'] = _('all held messages.') + doc.AddItem(Utils.maketext('admindbpreamble.html', d, + raw=1, mlist=mlist)) + show_detailed_requests(mlist, form) + elif details == 'instructions': + doc.AddItem(Utils.maketext('admindbdetails.html', d, + raw=1, mlist=mlist)) + addform = 0 + else: + # Show a summary of all requests + doc.AddItem(Utils.maketext('admindbsummary.html', d, + raw=1, mlist=mlist)) + num = show_pending_subs(mlist, form) + num += show_pending_unsubs(mlist, form) + num += show_helds_overview(mlist, form) + addform = num > 0 + # Finish up the document, adding buttons to the form + if addform: + doc.AddItem(form) + form.AddItem('<hr>') + if not (details or sender or msgid or nomessages): + form.AddItem(Center( + CheckBox('discardalldefersp', 0).Format() + + ' ' + + _('Discard all messages marked <em>Defer</em>') + )) + form.AddItem(Center(SubmitButton('submit', _('Submit All Data')))) + doc.AddItem(mlist.GetMailmanFooter()) + print doc.Format() + # Commit all changes + mlist.Save() + finally: + mlist.Unlock() + + + +def handle_no_list(msg=''): + # Print something useful if no list was given. + doc = Document() + doc.set_language(config.DEFAULT_SERVER_LANGUAGE) + + header = _('Mailman Administrative Database Error') + doc.SetTitle(header) + doc.AddItem(Header(2, header)) + doc.AddItem(msg) + url = Utils.ScriptURL('admin') + link = Link(url, _('list of available mailing lists.')).Format() + doc.AddItem(_('You must specify a list name. Here is the %(link)s')) + doc.AddItem('<hr>') + doc.AddItem(MailmanLogo()) + print doc.Format() + + + +def show_pending_subs(mlist, form): + # Add the subscription request section + requestsdb = config.db.get_list_requests(mlist) + if requestsdb.count_of(RequestType.subscription) == 0: + return 0 + form.AddItem('<hr>') + form.AddItem(Center(Header(2, _('Subscription Requests')))) + table = Table(border=2) + table.AddRow([Center(Bold(_('Address/name'))), + Center(Bold(_('Your decision'))), + Center(Bold(_('Reason for refusal'))) + ]) + # Alphabetical order by email address + byaddrs = {} + for request in requestsdb.of_type(RequestType.subscription): + key, data = requestsdb.get_request(requst.id) + addr = data['addr'] + byaddrs.setdefault(addr, []).append(request.id) + addrs = sorted(byaddrs) + num = 0 + for addr, ids in byaddrs.items(): + # Eliminate duplicates + for id in ids[1:]: + mlist.HandleRequest(id, config.DISCARD) + id = ids[0] + key, data = requestsdb.get_request(id) + time = data['time'] + addr = data['addr'] + fullname = data['fullname'] + passwd = data['passwd'] + digest = data['digest'] + lang = data['lang'] + fullname = Utils.uncanonstr(fullname, mlist.preferred_language) + radio = RadioButtonArray(id, (_('Defer'), + _('Approve'), + _('Reject'), + _('Discard')), + values=(config.DEFER, + config.SUBSCRIBE, + config.REJECT, + config.DISCARD), + checked=0).Format() + if addr not in mlist.ban_list: + radio += '<br>' + CheckBox('ban-%d' % id, 1).Format() + \ + ' ' + _('Permanently ban from this list') + # While the address may be a unicode, it must be ascii + paddr = addr.encode('us-ascii', 'replace') + table.AddRow(['%s<br><em>%s</em>' % (paddr, fullname), + radio, + TextBox('comment-%d' % id, size=40) + ]) + num += 1 + if num > 0: + form.AddItem(table) + return num + + + +def show_pending_unsubs(mlist, form): + # Add the pending unsubscription request section + lang = mlist.preferred_language + requestsdb = config.db.get_list_requests(mlist) + if requestsdb.count_of(RequestType.unsubscription) == 0: + return 0 + table = Table(border=2) + table.AddRow([Center(Bold(_('User address/name'))), + Center(Bold(_('Your decision'))), + Center(Bold(_('Reason for refusal'))) + ]) + # Alphabetical order by email address + byaddrs = {} + for request in requestsdb.of_type(RequestType.unsubscription): + key, data = requestsdb.get_request(request.id) + addr = data['addr'] + byaddrs.setdefault(addr, []).append(request.id) + addrs = sorted(byaddrs) + num = 0 + for addr, ids in byaddrs.items(): + # Eliminate duplicates + for id in ids[1:]: + mlist.HandleRequest(id, config.DISCARD) + id = ids[0] + key, data = requestsdb.get_record(id) + addr = data['addr'] + try: + fullname = Utils.uncanonstr(mlist.getMemberName(addr), lang) + except Errors.NotAMemberError: + # They must have been unsubscribed elsewhere, so we can just + # discard this record. + mlist.HandleRequest(id, config.DISCARD) + continue + num += 1 + table.AddRow(['%s<br><em>%s</em>' % (addr, fullname), + RadioButtonArray(id, (_('Defer'), + _('Approve'), + _('Reject'), + _('Discard')), + values=(config.DEFER, + config.UNSUBSCRIBE, + config.REJECT, + config.DISCARD), + checked=0), + TextBox('comment-%d' % id, size=45) + ]) + if num > 0: + form.AddItem('<hr>') + form.AddItem(Center(Header(2, _('Unsubscription Requests')))) + form.AddItem(table) + return num + + + +def show_helds_overview(mlist, form): + # Sort the held messages by sender + bysender = helds_by_sender(mlist) + if not bysender: + return 0 + form.AddItem('<hr>') + form.AddItem(Center(Header(2, _('Held Messages')))) + # Add the by-sender overview tables + admindburl = mlist.GetScriptURL('admindb') + table = Table(border=0) + form.AddItem(table) + senders = bysender.keys() + senders.sort() + for sender in senders: + qsender = quote_plus(sender) + esender = Utils.websafe(sender) + senderurl = admindburl + '?sender=' + qsender + # The encompassing sender table + stable = Table(border=1) + stable.AddRow([Center(Bold(_('From:')).Format() + esender)]) + stable.AddCellInfo(stable.GetCurrentRowIndex(), 0, colspan=2) + left = Table(border=0) + left.AddRow([_('Action to take on all these held messages:')]) + left.AddCellInfo(left.GetCurrentRowIndex(), 0, colspan=2) + btns = hacky_radio_buttons( + 'senderaction-' + qsender, + (_('Defer'), _('Accept'), _('Reject'), _('Discard')), + (config.DEFER, config.APPROVE, config.REJECT, config.DISCARD), + (1, 0, 0, 0)) + left.AddRow([btns]) + left.AddCellInfo(left.GetCurrentRowIndex(), 0, colspan=2) + left.AddRow([ + CheckBox('senderpreserve-' + qsender, 1).Format() + + ' ' + + _('Preserve messages for the site administrator') + ]) + left.AddCellInfo(left.GetCurrentRowIndex(), 0, colspan=2) + left.AddRow([ + CheckBox('senderforward-' + qsender, 1).Format() + + ' ' + + _('Forward messages (individually) to:') + ]) + left.AddCellInfo(left.GetCurrentRowIndex(), 0, colspan=2) + left.AddRow([ + TextBox('senderforwardto-' + qsender, + value=mlist.GetOwnerEmail()) + ]) + left.AddCellInfo(left.GetCurrentRowIndex(), 0, colspan=2) + # If the sender is a member and the message is being held due to a + # moderation bit, give the admin a chance to clear the member's mod + # bit. If this sender is not a member and is not already on one of + # the sender filters, then give the admin a chance to add this sender + # to one of the filters. + if mlist.isMember(sender): + if mlist.getMemberOption(sender, config.Moderate): + left.AddRow([ + CheckBox('senderclearmodp-' + qsender, 1).Format() + + ' ' + + _("Clear this member's <em>moderate</em> flag") + ]) + else: + left.AddRow( + [_('<em>The sender is now a member of this list</em>')]) + left.AddCellInfo(left.GetCurrentRowIndex(), 0, colspan=2) + elif sender not in (mlist.accept_these_nonmembers + + mlist.hold_these_nonmembers + + mlist.reject_these_nonmembers + + mlist.discard_these_nonmembers): + left.AddRow([ + CheckBox('senderfilterp-' + qsender, 1).Format() + + ' ' + + _('Add <b>%(esender)s</b> to one of these sender filters:') + ]) + left.AddCellInfo(left.GetCurrentRowIndex(), 0, colspan=2) + btns = hacky_radio_buttons( + 'senderfilter-' + qsender, + (_('Accepts'), _('Holds'), _('Rejects'), _('Discards')), + (config.ACCEPT, config.HOLD, config.REJECT, config.DISCARD), + (0, 0, 0, 1)) + left.AddRow([btns]) + left.AddCellInfo(left.GetCurrentRowIndex(), 0, colspan=2) + if sender not in mlist.ban_list: + left.AddRow([ + CheckBox('senderbanp-' + qsender, 1).Format() + + ' ' + + _("""Ban <b>%(esender)s</b> from ever subscribing to this + mailing list""")]) + left.AddCellInfo(left.GetCurrentRowIndex(), 0, colspan=2) + right = Table(border=0) + right.AddRow([ + _("""Click on the message number to view the individual + message, or you can """) + + Link(senderurl, _('view all messages from %(esender)s')).Format() + ]) + right.AddCellInfo(right.GetCurrentRowIndex(), 0, colspan=2) + right.AddRow([' ', ' ']) + counter = 1 + for id in bysender[sender]: + key, data = requestsdb.get_record(id) + ptime = data['ptime'] + sender = data['sender'] + subject = data['subject'] + reason = data['reason'] + filename = data['filename'] + msgdata = data['msgdata'] + # BAW: This is really the size of the message pickle, which should + # be close, but won't be exact. Sigh, good enough. + try: + size = os.path.getsize(os.path.join(config.DATA_DIR, filename)) + except OSError, e: + if e.errno <> errno.ENOENT: raise + # This message must have gotten lost, i.e. it's already been + # handled by the time we got here. + mlist.HandleRequest(id, config.DISCARD) + continue + dispsubj = Utils.oneline( + subject, Utils.GetCharSet(mlist.preferred_language)) + t = Table(border=0) + t.AddRow([Link(admindburl + '?msgid=%d' % id, '[%d]' % counter), + Bold(_('Subject:')), + Utils.websafe(dispsubj) + ]) + t.AddRow([' ', Bold(_('Size:')), str(size) + _(' bytes')]) + if reason: + reason = _(reason) + else: + reason = _('not available') + t.AddRow([' ', Bold(_('Reason:')), reason]) + # Include the date we received the message, if available + when = msgdata.get('received_time') + if when: + t.AddRow([' ', Bold(_('Received:')), + time.ctime(when)]) + counter += 1 + right.AddRow([t]) + stable.AddRow([left, right]) + table.AddRow([stable]) + return 1 + + + +def show_sender_requests(mlist, form, sender): + bysender = helds_by_sender(mlist) + if not bysender: + return + sender_ids = bysender.get(sender) + if sender_ids is None: + # BAW: should we print an error message? + return + total = len(sender_ids) + requestsdb = config.db.get_list_requests(mlist) + for i, id in enumerate(sender_ids): + key, data = requestsdb.get_record(id) + show_post_requests(mlist, id, data, total, count + 1, form) + + + +def show_message_requests(mlist, form, id): + requestdb = config.db.get_list_requests(mlist) + try: + id = int(id) + info = requestdb.get_record(id) + except (ValueError, KeyError): + # BAW: print an error message? + return + show_post_requests(mlist, id, info, 1, 1, form) + + + +def show_detailed_requests(mlist, form): + requestsdb = config.db.get_list_requests(mlist) + total = requestsdb.count_of(RequestType.held_message) + all = requestsdb.of_type(RequestType.held_message) + for i, request in enumerate(all): + key, data = requestdb.get_request(request.id) + show_post_requests(mlist, request.id, data, total, i + 1, form) + + + +def show_post_requests(mlist, id, info, total, count, form): + # For backwards compatibility with pre 2.0beta3 + if len(info) == 5: + ptime, sender, subject, reason, filename = info + msgdata = {} + else: + ptime, sender, subject, reason, filename, msgdata = info + form.AddItem('<hr>') + # Header shown on each held posting (including count of total) + msg = _('Posting Held for Approval') + if total <> 1: + msg += _(' (%(count)d of %(total)d)') + form.AddItem(Center(Header(2, msg))) + # We need to get the headers and part of the textual body of the message + # being held. The best way to do this is to use the email Parser to get + # an actual object, which will be easier to deal with. We probably could + # just do raw reads on the file. + try: + msg = readMessage(os.path.join(config.DATA_DIR, filename)) + except IOError, e: + if e.errno <> errno.ENOENT: + raise + form.AddItem(_('<em>Message with id #%(id)d was lost.')) + form.AddItem('<p>') + # BAW: kludge to remove id from requests.db. + try: + mlist.HandleRequest(id, config.DISCARD) + except Errors.LostHeldMessage: + pass + return + except email.Errors.MessageParseError: + form.AddItem(_('<em>Message with id #%(id)d is corrupted.')) + # BAW: Should we really delete this, or shuttle it off for site admin + # to look more closely at? + form.AddItem('<p>') + # BAW: kludge to remove id from requests.db. + try: + mlist.HandleRequest(id, config.DISCARD) + except Errors.LostHeldMessage: + pass + return + # Get the header text and the message body excerpt + lines = [] + chars = 0 + # A negative value means, include the entire message regardless of size + limit = config.ADMINDB_PAGE_TEXT_LIMIT + for line in email.Iterators.body_line_iterator(msg): + lines.append(line) + chars += len(line) + if chars > limit > 0: + break + # Negative values mean display the entire message, regardless of size + if limit > 0: + body = EMPTYSTRING.join(lines)[:config.ADMINDB_PAGE_TEXT_LIMIT] + else: + body = EMPTYSTRING.join(lines) + # Get message charset and try encode in list charset + mcset = msg.get_param('charset', 'us-ascii').lower() + lcset = Utils.GetCharSet(mlist.preferred_language) + if mcset <> lcset: + try: + body = unicode(body, mcset).encode(lcset) + except (LookupError, UnicodeError, ValueError): + pass + hdrtxt = NL.join(['%s: %s' % (k, v) for k, v in msg.items()]) + hdrtxt = Utils.websafe(hdrtxt) + # Okay, we've reconstituted the message just fine. Now for the fun part! + t = Table(cellspacing=0, cellpadding=0, width='100%') + t.AddRow([Bold(_('From:')), sender]) + row, col = t.GetCurrentRowIndex(), t.GetCurrentCellIndex() + t.AddCellInfo(row, col-1, align='right') + t.AddRow([Bold(_('Subject:')), + Utils.websafe(Utils.oneline(subject, lcset))]) + t.AddCellInfo(row+1, col-1, align='right') + t.AddRow([Bold(_('Reason:')), _(reason)]) + t.AddCellInfo(row+2, col-1, align='right') + when = msgdata.get('received_time') + if when: + t.AddRow([Bold(_('Received:')), time.ctime(when)]) + t.AddCellInfo(row+2, col-1, align='right') + # We can't use a RadioButtonArray here because horizontal placement can be + # confusing to the user and vertical placement takes up too much + # real-estate. This is a hack! + buttons = Table(cellspacing="5", cellpadding="0") + buttons.AddRow(map(lambda x, s=' '*5: s+x+s, + (_('Defer'), _('Approve'), _('Reject'), _('Discard')))) + buttons.AddRow([Center(RadioButton(id, config.DEFER, 1)), + Center(RadioButton(id, config.APPROVE, 0)), + Center(RadioButton(id, config.REJECT, 0)), + Center(RadioButton(id, config.DISCARD, 0)), + ]) + t.AddRow([Bold(_('Action:')), buttons]) + t.AddCellInfo(row+3, col-1, align='right') + t.AddRow([' ', + CheckBox('preserve-%d' % id, 'on', 0).Format() + + ' ' + _('Preserve message for site administrator') + ]) + t.AddRow([' ', + CheckBox('forward-%d' % id, 'on', 0).Format() + + ' ' + _('Additionally, forward this message to: ') + + TextBox('forward-addr-%d' % id, size=47, + value=mlist.GetOwnerEmail()).Format() + ]) + notice = msgdata.get('rejection_notice', _('[No explanation given]')) + t.AddRow([ + Bold(_('If you reject this post,<br>please explain (optional):')), + TextArea('comment-%d' % id, rows=4, cols=EXCERPT_WIDTH, + text = Utils.wrap(_(notice), column=80)) + ]) + row, col = t.GetCurrentRowIndex(), t.GetCurrentCellIndex() + t.AddCellInfo(row, col-1, align='right') + t.AddRow([Bold(_('Message Headers:')), + TextArea('headers-%d' % id, hdrtxt, + rows=EXCERPT_HEIGHT, cols=EXCERPT_WIDTH, readonly=1)]) + row, col = t.GetCurrentRowIndex(), t.GetCurrentCellIndex() + t.AddCellInfo(row, col-1, align='right') + t.AddRow([Bold(_('Message Excerpt:')), + TextArea('fulltext-%d' % id, Utils.websafe(body), + rows=EXCERPT_HEIGHT, cols=EXCERPT_WIDTH, readonly=1)]) + t.AddCellInfo(row+1, col-1, align='right') + form.AddItem(t) + form.AddItem('<p>') + + + +def process_form(mlist, doc, cgidata): + senderactions = {} + # Sender-centric actions + for k in cgidata.keys(): + for prefix in ('senderaction-', 'senderpreserve-', 'senderforward-', + 'senderforwardto-', 'senderfilterp-', 'senderfilter-', + 'senderclearmodp-', 'senderbanp-'): + if k.startswith(prefix): + action = k[:len(prefix)-1] + sender = unquote_plus(k[len(prefix):]) + value = cgidata.getvalue(k) + senderactions.setdefault(sender, {})[action] = value + # discard-all-defers + try: + discardalldefersp = cgidata.getvalue('discardalldefersp', 0) + except ValueError: + discardalldefersp = 0 + for sender in senderactions.keys(): + actions = senderactions[sender] + # Handle what to do about all this sender's held messages + try: + action = int(actions.get('senderaction', config.DEFER)) + except ValueError: + action = config.DEFER + if action == config.DEFER and discardalldefersp: + action = config.DISCARD + if action in (config.DEFER, config.APPROVE, + config.REJECT, config.DISCARD): + preserve = actions.get('senderpreserve', 0) + forward = actions.get('senderforward', 0) + forwardaddr = actions.get('senderforwardto', '') + comment = _('No reason given') + bysender = helds_by_sender(mlist) + for id in bysender.get(sender, []): + try: + mlist.HandleRequest(id, action, comment, preserve, + forward, forwardaddr) + except (KeyError, Errors.LostHeldMessage): + # That's okay, it just means someone else has already + # updated the database while we were staring at the page, + # so just ignore it + continue + # Now see if this sender should be added to one of the nonmember + # sender filters. + if actions.get('senderfilterp', 0): + try: + which = int(actions.get('senderfilter')) + except ValueError: + # Bogus form + which = 'ignore' + if which == config.ACCEPT: + mlist.accept_these_nonmembers.append(sender) + elif which == config.HOLD: + mlist.hold_these_nonmembers.append(sender) + elif which == config.REJECT: + mlist.reject_these_nonmembers.append(sender) + elif which == config.DISCARD: + mlist.discard_these_nonmembers.append(sender) + # Otherwise, it's a bogus form, so ignore it + # And now see if we're to clear the member's moderation flag. + if actions.get('senderclearmodp', 0): + try: + mlist.setMemberOption(sender, config.Moderate, 0) + except Errors.NotAMemberError: + # This person's not a member any more. Oh well. + pass + # And should this address be banned? + if actions.get('senderbanp', 0): + if sender not in mlist.ban_list: + mlist.ban_list.append(sender) + # Now, do message specific actions + banaddrs = [] + erroraddrs = [] + for k in cgidata.keys(): + formv = cgidata[k] + if isinstance(formv, list): + continue + try: + v = int(formv.value) + request_id = int(k) + except ValueError: + continue + if v not in (config.DEFER, config.APPROVE, config.REJECT, + config.DISCARD, config.SUBSCRIBE, config.UNSUBSCRIBE, + config.ACCEPT, config.HOLD): + continue + # Get the action comment and reasons if present. + commentkey = 'comment-%d' % request_id + preservekey = 'preserve-%d' % request_id + forwardkey = 'forward-%d' % request_id + forwardaddrkey = 'forward-addr-%d' % request_id + bankey = 'ban-%d' % request_id + # Defaults + comment = _('[No reason given]') + preserve = 0 + forward = 0 + forwardaddr = '' + if cgidata.has_key(commentkey): + comment = cgidata[commentkey].value + if cgidata.has_key(preservekey): + preserve = cgidata[preservekey].value + if cgidata.has_key(forwardkey): + forward = cgidata[forwardkey].value + if cgidata.has_key(forwardaddrkey): + forwardaddr = cgidata[forwardaddrkey].value + # Should we ban this address? Do this check before handling the + # request id because that will evict the record. + requestsdb = config.db.get_list_requests(mlist) + if cgidata.getvalue(bankey): + key, data = requestsdb.get_record(request_id) + sender = data['sender'] + if sender not in mlist.ban_list: + mlist.ban_list.append(sender) + # Handle the request id + try: + mlist.HandleRequest(request_id, v, comment, + preserve, forward, forwardaddr) + except (KeyError, Errors.LostHeldMessage): + # That's okay, it just means someone else has already updated the + # database while we were staring at the page, so just ignore it + continue + except Errors.MMAlreadyAMember, v: + erroraddrs.append(v) + except Errors.MembershipIsBanned, pattern: + data = requestsdb.get_record(request_id) + sender = data['sender'] + banaddrs.append((sender, pattern)) + # save the list and print the results + doc.AddItem(Header(2, _('Database Updated...'))) + if erroraddrs: + for addr in erroraddrs: + doc.AddItem(`addr` + _(' is already a member') + '<br>') + if banaddrs: + for addr, patt in banaddrs: + doc.AddItem(_('%(addr)s is banned (matched: %(patt)s)') + '<br>') diff --git a/src/mailman/web/Cgi/confirm.py b/src/mailman/web/Cgi/confirm.py new file mode 100644 index 000000000..188e43068 --- /dev/null +++ b/src/mailman/web/Cgi/confirm.py @@ -0,0 +1,834 @@ +# Copyright (C) 2001-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Confirm a pending action via URL.""" + +import cgi +import time +import signal +import logging + +from Mailman import Errors +from Mailman import MailList +from Mailman import Pending +from Mailman import i18n +from Mailman.UserDesc import UserDesc +from Mailman.configuration import config +from Mailman.htmlformat import * + +# Set up i18n +_ = i18n._ +i18n.set_language(config.DEFAULT_SERVER_LANGUAGE) + +log = logging.getLogger('mailman.error') + + + +def main(): + doc = Document() + doc.set_language(config.DEFAULT_SERVER_LANGUAGE) + + parts = Utils.GetPathPieces() + if not parts or len(parts) < 1: + bad_confirmation(doc) + doc.AddItem(MailmanLogo()) + print doc.Format() + return + + listname = parts[0].lower() + try: + mlist = MailList.MailList(listname, lock=0) + except Errors.MMListError, e: + # Avoid cross-site scripting attacks + safelistname = Utils.websafe(listname) + bad_confirmation(doc, _('No such list <em>%(safelistname)s</em>')) + doc.AddItem(MailmanLogo()) + print doc.Format() + log.error('No such list "%s": %s', listname, e) + return + + # Set the language for the list + i18n.set_language(mlist.preferred_language) + doc.set_language(mlist.preferred_language) + + # Get the form data to see if this is a second-step confirmation + cgidata = cgi.FieldStorage(keep_blank_values=1) + cookie = cgidata.getvalue('cookie') + if cookie == '': + ask_for_cookie(mlist, doc, _('Confirmation string was empty.')) + return + + if not cookie and len(parts) == 2: + cookie = parts[1] + + if len(parts) > 2: + bad_confirmation(doc) + doc.AddItem(mlist.GetMailmanFooter()) + print doc.Format() + return + + if not cookie: + ask_for_cookie(mlist, doc) + return + + days = int(config.PENDING_REQUEST_LIFE / config.days(1) + 0.5) + confirmurl = mlist.GetScriptURL('confirm') + # Avoid cross-site scripting attacks + safecookie = Utils.websafe(cookie) + badconfirmstr = _('''<b>Invalid confirmation string:</b> + %(safecookie)s. + + <p>Note that confirmation strings expire approximately + %(days)s days after the initial subscription request. If your + confirmation has expired, please try to re-submit your subscription. + Otherwise, <a href="%(confirmurl)s">re-enter</a> your confirmation + string.''') + + content = mlist.pend_confirm(cookie, expunge=False) + if content is None: + bad_confirmation(doc, badconfirmstr) + doc.AddItem(mlist.GetMailmanFooter()) + print doc.Format() + return + + try: + if content[0] == Pending.SUBSCRIPTION: + if cgidata.getvalue('cancel'): + subscription_cancel(mlist, doc, cookie) + elif cgidata.getvalue('submit'): + subscription_confirm(mlist, doc, cookie, cgidata) + else: + subscription_prompt(mlist, doc, cookie, content[1]) + elif content[0] == Pending.UNSUBSCRIPTION: + try: + if cgidata.getvalue('cancel'): + unsubscription_cancel(mlist, doc, cookie) + elif cgidata.getvalue('submit'): + unsubscription_confirm(mlist, doc, cookie) + else: + unsubscription_prompt(mlist, doc, cookie, *content[1:]) + except Errors.NotAMemberError: + doc.addError(_("""The address requesting unsubscription is not + a member of the mailing list. Perhaps you have already been + unsubscribed, e.g. by the list administrator?""")) + # Expunge this record from the pending database. + expunge(mlist, cookie) + elif content[0] == Pending.CHANGE_OF_ADDRESS: + if cgidata.getvalue('cancel'): + addrchange_cancel(mlist, doc, cookie) + elif cgidata.getvalue('submit'): + addrchange_confirm(mlist, doc, cookie) + else: + # Watch out for users who have unsubscribed themselves in the + # meantime! + try: + addrchange_prompt(mlist, doc, cookie, *content[1:]) + except Errors.NotAMemberError: + doc.addError(_("""The address requesting to be changed has + been subsequently unsubscribed. This request has been + cancelled.""")) + # Expunge this record from the pending database. + expunge(mlist, cookie) + elif content[0] == Pending.HELD_MESSAGE: + if cgidata.getvalue('cancel'): + heldmsg_cancel(mlist, doc, cookie) + elif cgidata.getvalue('submit'): + heldmsg_confirm(mlist, doc, cookie) + else: + heldmsg_prompt(mlist, doc, cookie, *content[1:]) + elif content[0] == Pending.RE_ENABLE: + if cgidata.getvalue('cancel'): + reenable_cancel(mlist, doc, cookie) + elif cgidata.getvalue('submit'): + reenable_confirm(mlist, doc, cookie) + else: + reenable_prompt(mlist, doc, cookie, *content[1:]) + else: + bad_confirmation(doc, _('System error, bad content: %(content)s')) + except Errors.MMBadConfirmation: + bad_confirmation(doc, badconfirmstr) + + doc.AddItem(mlist.GetMailmanFooter()) + print doc.Format() + + + +def bad_confirmation(doc, extra=''): + title = _('Bad confirmation string') + doc.SetTitle(title) + doc.AddItem(Header(3, Bold(FontAttr(title, color='#ff0000', size='+2')))) + doc.AddItem(extra) + + +def expunge(mlist, cookie): + # Expunge this record from the list's pending database. This requires + # that the list lock be acquired, however the list doesn't need to be + # saved because this operation doesn't touch the config.pck file. + mlist.Lock() + try: + mlist.pend_confirm(cookie, expunge=True) + finally: + mlist.Unlock() + + + +def ask_for_cookie(mlist, doc, extra=''): + title = _('Enter confirmation cookie') + doc.SetTitle(title) + form = Form(mlist.GetScriptURL('confirm')) + table = Table(border=0, width='100%') + table.AddRow([Center(Bold(FontAttr(title, size='+1')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, + colspan=2, bgcolor=config.WEB_HEADER_COLOR) + + if extra: + table.AddRow([Bold(FontAttr(extra, size='+1'))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + + # Add cookie entry box + table.AddRow([_("""Please enter the confirmation string + (i.e. <em>cookie</em>) that you received in your email message, in the box + below. Then hit the <em>Submit</em> button to proceed to the next + confirmation step.""")]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([Label(_('Confirmation string:')), + TextBox('cookie')]) + table.AddRow([Center(SubmitButton('submit_cookie', _('Submit')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + form.AddItem(table) + doc.AddItem(form) + doc.AddItem(mlist.GetMailmanFooter()) + print doc.Format() + + + +def subscription_prompt(mlist, doc, cookie, userdesc): + email = userdesc.address + password = userdesc.password + digest = userdesc.digest + lang = userdesc.language + name = Utils.uncanonstr(userdesc.fullname, lang) + i18n.set_language(lang) + doc.set_language(lang) + title = _('Confirm subscription request') + doc.SetTitle(title) + + form = Form(mlist.GetScriptURL('confirm')) + table = Table(border=0, width='100%') + table.AddRow([Center(Bold(FontAttr(title, size='+1')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, + colspan=2, bgcolor=config.WEB_HEADER_COLOR) + + listname = mlist.real_name + # This is the normal, no-confirmation required results text. + # + # We do things this way so we don't have to reformat this paragraph, which + # would mess up translations. If you modify this text for other reasons, + # please refill the paragraph, and clean up the logic. + result = _("""Your confirmation is required in order to complete the + subscription request to the mailing list <em>%(listname)s</em>. Your + subscription settings are shown below; make any necessary changes and hit + <em>Subscribe</em> to complete the confirmation process. Once you've + confirmed your subscription request, you will be shown your account + options page which you can use to further customize your membership + options. + + <p>Note: your password will be emailed to you once your subscription is + confirmed. You can change it by visiting your personal options page. + + <p>Or hit <em>Cancel my subscription request</em> if you no longer want to + subscribe to this list.""") + '<p><hr>' + if mlist.subscribe_policy in (2, 3): + # Confirmation is required + result = _("""Your confirmation is required in order to continue with + the subscription request to the mailing list <em>%(listname)s</em>. + Your subscription settings are shown below; make any necessary changes + and hit <em>Subscribe to list ...</em> to complete the confirmation + process. Once you've confirmed your subscription request, the + moderator must approve or reject your membership request. You will + receive notice of their decision. + + <p>Note: your password will be emailed to you once your subscription + is confirmed. You can change it by visiting your personal options + page. + + <p>Or, if you've changed your mind and do not want to subscribe to + this mailing list, you can hit <em>Cancel my subscription + request</em>.""") + '<p><hr>' + table.AddRow([result]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + + table.AddRow([Label(_('Your email address:')), email]) + table.AddRow([Label(_('Your real name:')), + TextBox('realname', name)]) +## table.AddRow([Label(_('Password:')), +## PasswordBox('password', password)]) +## table.AddRow([Label(_('Password (confirm):')), +## PasswordBox('pwconfirm', password)]) + # Only give them a choice to receive digests if they actually have a + # choice <wink>. + if mlist.nondigestable and mlist.digestable: + table.AddRow([Label(_('Receive digests?')), + RadioButtonArray('digests', (_('No'), _('Yes')), + checked=digest, values=(0, 1))]) + langs = mlist.language_codes + values = [_(config.languages.get_description(code)) for code in langs] + try: + selected = langs.index(lang) + except ValueError: + selected = lang.index(mlist.preferred_language) + table.AddRow([Label(_('Preferred language:')), + SelectOptions('language', langs, values, selected)]) + table.AddRow([Hidden('cookie', cookie)]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([ + Label(SubmitButton('cancel', _('Cancel my subscription request'))), + SubmitButton('submit', _('Subscribe to list %(listname)s')) + ]) + form.AddItem(table) + doc.AddItem(form) + + + +def subscription_cancel(mlist, doc, cookie): + mlist.Lock() + try: + # Discard this cookie + userdesc = mlist.pend_confirm(cookie)[1] + finally: + mlist.Unlock() + lang = userdesc.language + i18n.set_language(lang) + doc.set_language(lang) + doc.AddItem(_('You have canceled your subscription request.')) + + + +def subscription_confirm(mlist, doc, cookie, cgidata): + # See the comment in admin.py about the need for the signal + # handler. + def sigterm_handler(signum, frame, mlist=mlist): + mlist.Unlock() + sys.exit(0) + + listname = mlist.real_name + mlist.Lock() + try: + try: + # Some pending values may be overridden in the form. email of + # course is hardcoded. ;) + lang = cgidata.getvalue('language') + if lang not in config.languages.enabled_codes: + lang = mlist.preferred_language + i18n.set_language(lang) + doc.set_language(lang) + if cgidata.has_key('digests'): + try: + digest = int(cgidata.getvalue('digests')) + except ValueError: + digest = None + else: + digest = None + userdesc = mlist.pend_confirm(cookie, expunge=False)[1] + fullname = cgidata.getvalue('realname', None) + if fullname is not None: + fullname = Utils.canonstr(fullname, lang) + overrides = UserDesc(fullname=fullname, digest=digest, lang=lang) + userdesc += overrides + op, addr, pw, digest, lang = mlist.ProcessConfirmation( + cookie, userdesc) + except Errors.MMNeedApproval: + title = _('Awaiting moderator approval') + doc.SetTitle(title) + doc.AddItem(Header(3, Bold(FontAttr(title, size='+2')))) + doc.AddItem(_("""\ + You have successfully confirmed your subscription request to the + mailing list %(listname)s, however final approval is required from + the list moderator before you will be subscribed. Your request + has been forwarded to the list moderator, and you will be notified + of the moderator's decision.""")) + except Errors.NotAMemberError: + bad_confirmation(doc, _('''Invalid confirmation string. It is + possible that you are attempting to confirm a request for an + address that has already been unsubscribed.''')) + except Errors.MMAlreadyAMember: + doc.addError(_("You are already a member of this mailing list!")) + except Errors.MembershipIsBanned: + owneraddr = mlist.GetOwnerEmail() + doc.addError(_("""You are currently banned from subscribing to + this list. If you think this restriction is erroneous, please + contact the list owners at %(owneraddr)s.""")) + except Errors.HostileSubscriptionError: + doc.addError(_("""\ + You were not invited to this mailing list. The invitation has + been discarded, and both list administrators have been + alerted.""")) + else: + # Use the user's preferred language + i18n.set_language(lang) + doc.set_language(lang) + # The response + listname = mlist.real_name + title = _('Subscription request confirmed') + optionsurl = mlist.GetOptionsURL(addr) + doc.SetTitle(title) + doc.AddItem(Header(3, Bold(FontAttr(title, size='+2')))) + doc.AddItem(_('''\ + You have successfully confirmed your subscription request for + "%(addr)s" to the %(listname)s mailing list. A separate + confirmation message will be sent to your email address, along + with your password, and other useful information and links. + + <p>You can now + <a href="%(optionsurl)s">proceed to your membership login + page</a>.''')) + mlist.Save() + finally: + mlist.Unlock() + + + +def unsubscription_cancel(mlist, doc, cookie): + # Expunge this record from the pending database + expunge(mlist, cookie) + doc.AddItem(_('You have canceled your unsubscription request.')) + + + +def unsubscription_confirm(mlist, doc, cookie): + # See the comment in admin.py about the need for the signal + # handler. + def sigterm_handler(signum, frame, mlist=mlist): + mlist.Unlock() + sys.exit(0) + + mlist.Lock() + try: + try: + # Do this in two steps so we can get the preferred language for + # the user who is unsubscribing. + op, addr = mlist.pend_confirm(cookie, expunge=False) + lang = mlist.getMemberLanguage(addr) + i18n.set_language(lang) + doc.set_language(lang) + op, addr = mlist.ProcessConfirmation(cookie) + except Errors.NotAMemberError: + bad_confirmation(doc, _('''Invalid confirmation string. It is + possible that you are attempting to confirm a request for an + address that has already been unsubscribed.''')) + else: + # The response + listname = mlist.real_name + title = _('Unsubscription request confirmed') + listinfourl = mlist.GetScriptURL('listinfo') + doc.SetTitle(title) + doc.AddItem(Header(3, Bold(FontAttr(title, size='+2')))) + doc.AddItem(_("""\ + You have successfully unsubscribed from the %(listname)s mailing + list. You can now <a href="%(listinfourl)s">visit the list's main + information page</a>.""")) + mlist.Save() + finally: + mlist.Unlock() + + + +def unsubscription_prompt(mlist, doc, cookie, addr): + title = _('Confirm unsubscription request') + doc.SetTitle(title) + lang = mlist.getMemberLanguage(addr) + i18n.set_language(lang) + doc.set_language(lang) + + form = Form(mlist.GetScriptURL('confirm')) + table = Table(border=0, width='100%') + table.AddRow([Center(Bold(FontAttr(title, size='+1')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, + colspan=2, bgcolor=config.WEB_HEADER_COLOR) + + listname = mlist.real_name + fullname = mlist.getMemberName(addr) + if fullname is None: + fullname = _('<em>Not available</em>') + else: + fullname = Utils.uncanonstr(fullname, lang) + table.AddRow([_("""Your confirmation is required in order to complete the + unsubscription request from the mailing list <em>%(listname)s</em>. You + are currently subscribed with + + <ul><li><b>Real name:</b> %(fullname)s + <li><b>Email address:</b> %(addr)s + </ul> + + Hit the <em>Unsubscribe</em> button below to complete the confirmation + process. + + <p>Or hit <em>Cancel and discard</em> to cancel this unsubscription + request.""") + '<p><hr>']) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([Hidden('cookie', cookie)]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([SubmitButton('submit', _('Unsubscribe')), + SubmitButton('cancel', _('Cancel and discard'))]) + + form.AddItem(table) + doc.AddItem(form) + + + +def addrchange_cancel(mlist, doc, cookie): + # Expunge this record from the pending database + expunge(mlist, cookie) + doc.AddItem(_('You have canceled your change of address request.')) + + + +def addrchange_confirm(mlist, doc, cookie): + # See the comment in admin.py about the need for the signal + # handler. + def sigterm_handler(signum, frame, mlist=mlist): + mlist.Unlock() + sys.exit(0) + + mlist.Lock() + try: + try: + # Do this in two steps so we can get the preferred language for + # the user who is unsubscribing. + op, oldaddr, newaddr, globally = mlist.pend_confirm( + cookie, expunge=False) + lang = mlist.getMemberLanguage(oldaddr) + i18n.set_language(lang) + doc.set_language(lang) + op, oldaddr, newaddr = mlist.ProcessConfirmation(cookie) + except Errors.NotAMemberError: + bad_confirmation(doc, _('''Invalid confirmation string. It is + possible that you are attempting to confirm a request for an + address that has already been unsubscribed.''')) + except Errors.MembershipIsBanned: + owneraddr = mlist.GetOwnerEmail() + realname = mlist.real_name + doc.addError(_("""%(newaddr)s is banned from subscribing to the + %(realname)s list. If you think this restriction is erroneous, + please contact the list owners at %(owneraddr)s.""")) + else: + # The response + listname = mlist.real_name + title = _('Change of address request confirmed') + optionsurl = mlist.GetOptionsURL(newaddr) + doc.SetTitle(title) + doc.AddItem(Header(3, Bold(FontAttr(title, size='+2')))) + doc.AddItem(_("""\ + You have successfully changed your address on the %(listname)s + mailing list from <b>%(oldaddr)s</b> to <b>%(newaddr)s</b>. You + can now <a href="%(optionsurl)s">proceed to your membership + login page</a>.""")) + mlist.Save() + finally: + mlist.Unlock() + + + +def addrchange_prompt(mlist, doc, cookie, oldaddr, newaddr, globally): + title = _('Confirm change of address request') + doc.SetTitle(title) + lang = mlist.getMemberLanguage(oldaddr) + i18n.set_language(lang) + doc.set_language(lang) + + form = Form(mlist.GetScriptURL('confirm')) + table = Table(border=0, width='100%') + table.AddRow([Center(Bold(FontAttr(title, size='+1')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, + colspan=2, bgcolor=config.WEB_HEADER_COLOR) + + listname = mlist.real_name + fullname = mlist.getMemberName(oldaddr) + if fullname is None: + fullname = _('<em>Not available</em>') + else: + fullname = Utils.uncanonstr(fullname, lang) + if globally: + globallys = _('globally') + else: + globallys = '' + table.AddRow([_("""Your confirmation is required in order to complete the + change of address request for the mailing list <em>%(listname)s</em>. You + are currently subscribed with + + <ul><li><b>Real name:</b> %(fullname)s + <li><b>Old email address:</b> %(oldaddr)s + </ul> + + and you have requested to %(globallys)s change your email address to + + <ul><li><b>New email address:</b> %(newaddr)s + </ul> + + Hit the <em>Change address</em> button below to complete the confirmation + process. + + <p>Or hit <em>Cancel and discard</em> to cancel this change of address + request.""") + '<p><hr>']) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([Hidden('cookie', cookie)]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([SubmitButton('submit', _('Change address')), + SubmitButton('cancel', _('Cancel and discard'))]) + + form.AddItem(table) + doc.AddItem(form) + + + +def heldmsg_cancel(mlist, doc, cookie): + title = _('Continue awaiting approval') + doc.SetTitle(title) + table = Table(border=0, width='100%') + table.AddRow([Center(Bold(FontAttr(title, size='+1')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, + bgcolor=config.WEB_HEADER_COLOR) + # Expunge this record from the pending database. + expunge(mlist, cookie) + table.AddRow([_('''Okay, the list moderator will still have the + opportunity to approve or reject this message.''')]) + doc.AddItem(table) + + + +def heldmsg_confirm(mlist, doc, cookie): + # See the comment in admin.py about the need for the signal + # handler. + def sigterm_handler(signum, frame, mlist=mlist): + mlist.Unlock() + sys.exit(0) + + mlist.Lock() + try: + try: + # Do this in two steps so we can get the preferred language for + # the user who posted the message. + op, id = mlist.pend_confirm(cookie) + requestsdb = config.db.get_list_requests(mlist) + key, data = requestsdb.get_record(id) + sender = data['sender'] + msgsubject = data['msgsubject'] + subject = Utils.websafe(msgsubject) + lang = mlist.getMemberLanguage(sender) + i18n.set_language(lang) + doc.set_language(lang) + # Discard the message + mlist.HandleRequest(id, config.DISCARD, + _('Sender discarded message via web.')) + except Errors.LostHeldMessage: + bad_confirmation(doc, _('''The held message with the Subject: + header <em>%(subject)s</em> could not be found. The most likely + reason for this is that the list moderator has already approved or + rejected the message. You were not able to cancel it in + time.''')) + else: + # The response + listname = mlist.real_name + title = _('Posted message canceled') + doc.SetTitle(title) + doc.AddItem(Header(3, Bold(FontAttr(title, size='+2')))) + doc.AddItem(_('''\ + You have successfully canceled the posting of your message with + the Subject: header <em>%(subject)s</em> to the mailing list + %(listname)s.''')) + mlist.Save() + finally: + mlist.Unlock() + + + +def heldmsg_prompt(mlist, doc, cookie, id): + title = _('Cancel held message posting') + doc.SetTitle(title) + form = Form(mlist.GetScriptURL('confirm')) + table = Table(border=0, width='100%') + table.AddRow([Center(Bold(FontAttr(title, size='+1')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, + colspan=2, bgcolor=config.WEB_HEADER_COLOR) + # Blarg. The list must be locked in order to interact with the ListAdmin + # database, even for read-only. See the comment in admin.py about the + # need for the signal handler. + def sigterm_handler(signum, frame, mlist=mlist): + mlist.Unlock() + sys.exit(0) + # Get the record, but watch for KeyErrors which mean the admin has already + # disposed of this message. + mlist.Lock() + requestdb = config.db.get_list_requests(mlist) + try: + try: + key, data = requestdb.get_record(id) + except KeyError: + data = None + finally: + mlist.Unlock() + + if data is None: + bad_confirmation(doc, _("""The held message you were referred to has + already been handled by the list administrator.""")) + return + + # Unpack the data and present the confirmation message + ign, sender, msgsubject, givenreason, ign, ign = data + # Now set the language to the sender's preferred. + lang = mlist.getMemberLanguage(sender) + i18n.set_language(lang) + doc.set_language(lang) + + subject = Utils.websafe(msgsubject) + reason = Utils.websafe(_(givenreason)) + listname = mlist.real_name + table.AddRow([_('''Your confirmation is required in order to cancel the + posting of your message to the mailing list <em>%(listname)s</em>: + + <ul><li><b>Sender:</b> %(sender)s + <li><b>Subject:</b> %(subject)s + <li><b>Reason:</b> %(reason)s + </ul> + + Hit the <em>Cancel posting</em> button to discard the posting. + + <p>Or hit the <em>Continue awaiting approval</em> button to continue to + allow the list moderator to approve or reject the message.''') + + '<p><hr>']) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([Hidden('cookie', cookie)]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([SubmitButton('submit', _('Cancel posting')), + SubmitButton('cancel', _('Continue awaiting approval'))]) + + form.AddItem(table) + doc.AddItem(form) + + + +def reenable_cancel(mlist, doc, cookie): + # Don't actually discard this cookie, since the user may decide to + # re-enable their membership at a future time, and we may be sending out + # future notifications with this cookie value. + doc.AddItem(_("""You have canceled the re-enabling of your membership. If + we continue to receive bounces from your address, it could be deleted from + this mailing list.""")) + + + +def reenable_confirm(mlist, doc, cookie): + # See the comment in admin.py about the need for the signal + # handler. + def sigterm_handler(signum, frame, mlist=mlist): + mlist.Unlock() + sys.exit(0) + + mlist.Lock() + try: + try: + # Do this in two steps so we can get the preferred language for + # the user who is unsubscribing. + op, listname, addr = mlist.pend_confirm(cookie, expunge=False) + lang = mlist.getMemberLanguage(addr) + i18n.set_language(lang) + doc.set_language(lang) + op, addr = mlist.ProcessConfirmation(cookie) + except Errors.NotAMemberError: + bad_confirmation(doc, _('''Invalid confirmation string. It is + possible that you are attempting to confirm a request for an + address that has already been unsubscribed.''')) + else: + # The response + listname = mlist.real_name + title = _('Membership re-enabled.') + optionsurl = mlist.GetOptionsURL(addr) + doc.SetTitle(title) + doc.AddItem(Header(3, Bold(FontAttr(title, size='+2')))) + doc.AddItem(_("""\ + You have successfully re-enabled your membership in the + %(listname)s mailing list. You can now <a + href="%(optionsurl)s">visit your member options page</a>. + """)) + mlist.Save() + finally: + mlist.Unlock() + + + +def reenable_prompt(mlist, doc, cookie, list, member): + title = _('Re-enable mailing list membership') + doc.SetTitle(title) + form = Form(mlist.GetScriptURL('confirm')) + table = Table(border=0, width='100%') + table.AddRow([Center(Bold(FontAttr(title, size='+1')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, + colspan=2, bgcolor=config.WEB_HEADER_COLOR) + + lang = mlist.getMemberLanguage(member) + i18n.set_language(lang) + doc.set_language(lang) + + realname = mlist.real_name + info = mlist.getBounceInfo(member) + if not info: + listinfourl = mlist.GetScriptURL('listinfo') + # They've already be unsubscribed + table.AddRow([_("""We're sorry, but you have already been unsubscribed + from this mailing list. To re-subscribe, please visit the + <a href="%(listinfourl)s">list information page</a>.""")]) + return + + date = time.strftime('%A, %B %d, %Y', + time.localtime(time.mktime(info.date + (0,)*6))) + daysleft = int(info.noticesleft * + mlist.bounce_you_are_disabled_warnings_interval / + config.days(1)) + # BAW: for consistency this should be changed to 'fullname' or the above + # 'fullname's should be changed to 'username'. Don't want to muck with + # the i18n catalogs though. + username = mlist.getMemberName(member) + if username is None: + username = _('<em>not available</em>') + else: + username = Utils.uncanonstr(username, lang) + + table.AddRow([_("""Your membership in the %(realname)s mailing list is + currently disabled due to excessive bounces. Your confirmation is + required in order to re-enable delivery to your address. We have the + following information on file: + + <ul><li><b>Member address:</b> %(member)s + <li><b>Member name:</b> %(username)s + <li><b>Last bounce received on:</b> %(date)s + <li><b>Approximate number of days before you are permanently removed + from this list:</b> %(daysleft)s + </ul> + + Hit the <em>Re-enable membership</em> button to resume receiving postings + from the mailing list. Or hit the <em>Cancel</em> button to defer + re-enabling your membership. + """)]) + + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([Hidden('cookie', cookie)]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([SubmitButton('submit', _('Re-enable membership')), + SubmitButton('cancel', _('Cancel'))]) + + form.AddItem(table) + doc.AddItem(form) diff --git a/src/mailman/web/Cgi/create.py b/src/mailman/web/Cgi/create.py new file mode 100644 index 000000000..4bb650957 --- /dev/null +++ b/src/mailman/web/Cgi/create.py @@ -0,0 +1,400 @@ +# Copyright (C) 2001-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Create mailing lists through the web.""" + +import cgi +import sha +import sys +import logging + +from Mailman import Errors +from Mailman import MailList +from Mailman import Message +from Mailman import i18n +from Mailman import passwords +from Mailman.configuration import config +from Mailman.htmlformat import * + +# Set up i18n +_ = i18n._ +i18n.set_language(config.DEFAULT_SERVER_LANGUAGE) +__i18n_templates__ = True + +log = logging.getLogger('mailman.error') + + + +def main(): + doc = Document() + doc.set_language(config.DEFAULT_SERVER_LANGUAGE) + + cgidata = cgi.FieldStorage() + parts = Utils.GetPathPieces() + if parts: + # Bad URL specification + title = _('Bad URL specification') + doc.SetTitle(title) + doc.AddItem( + Header(3, Bold(FontAttr(title, color='#ff0000', size='+2')))) + log.error('Bad URL specification: %s', parts) + elif cgidata.has_key('doit'): + # We must be processing the list creation request + process_request(doc, cgidata) + elif cgidata.has_key('clear'): + request_creation(doc) + else: + # Put up the list creation request form + request_creation(doc) + doc.AddItem('<hr>') + # Always add the footer and print the document + doc.AddItem(_('Return to the ') + + Link(Utils.ScriptURL('listinfo'), + _('general list overview')).Format()) + doc.AddItem(_('<br>Return to the ') + + Link(Utils.ScriptURL('admin'), + _('administrative list overview')).Format()) + doc.AddItem(MailmanLogo()) + print doc.Format() + + + +def process_request(doc, cgidata): + # Lowercase the listname since this is treated as the 'internal' name. + listname = cgidata.getvalue('listname', '').strip().lower() + owner = cgidata.getvalue('owner', '').strip() + try: + autogen = bool(int(cgidata.getvalue('autogen', '0'))) + except ValueError: + autogen = False + try: + notify = bool(int(cgidata.getvalue('notify', '0'))) + except ValueError: + notify = False + try: + moderate = bool(int(cgidata.getvalue('moderate', + config.DEFAULT_DEFAULT_MEMBER_MODERATION))) + except ValueError: + moderate = config.DEFAULT_DEFAULT_MEMBER_MODERATION + + password = cgidata.getvalue('password', '').strip() + confirm = cgidata.getvalue('confirm', '').strip() + auth = cgidata.getvalue('auth', '').strip() + langs = cgidata.getvalue('langs', [config.DEFAULT_SERVER_LANGUAGE]) + + if not isinstance(langs, list): + langs = [langs] + # Sanity checks + safelistname = Utils.websafe(listname) + if '@' in listname: + request_creation(doc, cgidata, + _('List name must not include "@": $safelistname')) + return + if not listname: + request_creation(doc, cgidata, + _('You forgot to enter the list name')) + return + if not owner: + request_creation(doc, cgidata, + _('You forgot to specify the list owner')) + return + if autogen: + if password or confirm: + request_creation( + doc, cgidata, + _("""Leave the initial password (and confirmation) fields + blank if you want Mailman to autogenerate the list + passwords.""")) + return + password = confirm = Utils.MakeRandomPassword( + config.ADMIN_PASSWORD_LENGTH) + else: + if password <> confirm: + request_creation(doc, cgidata, + _('Initial list passwords do not match')) + return + if not password: + request_creation( + doc, cgidata, + # The little <!-- ignore --> tag is used so that this string + # differs from the one in bin/newlist. The former is destined + # for the web while the latter is destined for email, so they + # must be different entries in the message catalog. + _('The list password cannot be empty<!-- ignore -->')) + return + # The authorization password must be non-empty, and it must match either + # the list creation password or the site admin password + ok = False + if auth: + ok = Utils.check_global_password(auth, False) + if not ok: + ok = Utils.check_global_password(auth) + if not ok: + request_creation( + doc, cgidata, + _('You are not authorized to create new mailing lists')) + return + # Make sure the url host name matches one of our virtual domains. Then + # calculate the list's posting address. + url_host = Utils.get_request_domain() + # Find the IDomain matching this url_host if there is one. + for email_host, domain in config.domains: + if domain.url_host == url_host: + email_host = domain.email_host + break + else: + safehostname = Utils.websafe(url_host) + request_creation(doc, cgidata, + _('Unknown virtual host: $safehostname')) + return + fqdn_listname = '%s@%s' % (listname, email_host) + # We've got all the data we need, so go ahead and try to create the list + mlist = MailList.MailList() + try: + scheme = passwords.lookup_scheme(config.PASSWORD_SCHEME.lower()) + pw = passwords.make_secret(password, scheme) + try: + mlist.Create(fqdn_listname, owner, pw, langs) + except Errors.EmailAddressError, s: + request_creation(doc, cgidata, _('Bad owner email address: $s')) + return + except Errors.MMListAlreadyExistsError: + safelistname = Utils.websafe(listname) + request_creation(doc, cgidata, + _('List already exists: $safelistname')) + return + except Errors.InvalidEmailAddress, s: + request_creation(doc, cgidata, _('Illegal list name: $s')) + return + except Errors.MMListError: + request_creation( + doc, cgidata, + _("""Some unknown error occurred while creating the list. + Please contact the site administrator for assistance.""")) + return + # Initialize the host_name and web_page_url attributes, based on + # virtual hosting settings and the request environment variables. + mlist.default_member_moderation = moderate + mlist.Save() + finally: + mlist.Unlock() + # Now do the MTA-specific list creation tasks + if config.MTA: + modname = 'Mailman.MTA.' + config.MTA + __import__(modname) + sys.modules[modname].create(mlist, cgi=True) + # And send the notice to the list owner. + if notify: + text = Utils.maketext( + 'newlist.txt', + {'listname' : listname, + 'password' : password, + 'admin_url' : mlist.GetScriptURL('admin', absolute=True), + 'listinfo_url': mlist.GetScriptURL('listinfo', absolute=True), + 'requestaddr' : mlist.GetRequestEmail(), + 'siteowner' : mlist.no_reply_address, + }, mlist=mlist) + msg = Message.UserNotification( + owner, mlist.no_reply_address, + _('Your new mailing list: $listname'), + text, mlist.preferred_language) + msg.send(mlist) + # Success! + listinfo_url = mlist.GetScriptURL('listinfo') + admin_url = mlist.GetScriptURL('admin') + create_url = Utils.ScriptURL('create') + + title = _('Mailing list creation results') + doc.SetTitle(title) + table = Table(border=0, width='100%') + table.AddRow([Center(Bold(FontAttr(title, size='+1')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, + bgcolor=config.WEB_HEADER_COLOR) + table.AddRow([_("""You have successfully created the mailing list + <b>$listname</b> and notification has been sent to the list owner + <b>$owner</b>. You can now:""")]) + ullist = UnorderedList() + ullist.AddItem(Link(listinfo_url, _("Visit the list's info page"))) + ullist.AddItem(Link(admin_url, _("Visit the list's admin page"))) + ullist.AddItem(Link(create_url, _('Create another list'))) + table.AddRow([ullist]) + doc.AddItem(table) + + + +# Because the cgi module blows +class Dummy: + def getvalue(self, name, default): + return default +dummy = Dummy() + + + +def request_creation(doc, cgidata=dummy, errmsg=None): + # What virtual domain are we using? + hostname = Utils.get_request_domain() + # Set up the document + title = _('Create a $hostname Mailing List') + doc.SetTitle(title) + table = Table(border=0, width='100%') + table.AddRow([Center(Bold(FontAttr(title, size='+1')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, + bgcolor=config.WEB_HEADER_COLOR) + # Add any error message + if errmsg: + table.AddRow([Header(3, Bold( + FontAttr(_('Error: '), color='#ff0000', size='+2').Format() + + Italic(errmsg).Format()))]) + table.AddRow([_("""You can create a new mailing list by entering the + relevant information into the form below. The name of the mailing list + will be used as the primary address for posting messages to the list, so + it should be lowercased. You will not be able to change this once the + list is created. + + <p>You also need to enter the email address of the initial list owner. + Once the list is created, the list owner will be given notification, along + with the initial list password. The list owner will then be able to + modify the password and add or remove additional list owners. + + <p>If you want Mailman to automatically generate the initial list admin + password, click on `Yes' in the autogenerate field below, and leave the + initial list password fields empty. + + <p>You must have the proper authorization to create new mailing lists. + Each site should have a <em>list creator's</em> password, which you can + enter in the field at the bottom. Note that the site administrator's + password can also be used for authentication. + """)]) + # Build the form for the necessary input + GREY = config.WEB_ADMINITEM_COLOR + form = Form(Utils.ScriptURL('create')) + ftable = Table(border=0, cols='2', width='100%', + cellspacing=3, cellpadding=4) + + ftable.AddRow([Center(Italic(_('List Identity')))]) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, colspan=2) + + safelistname = Utils.websafe(cgidata.getvalue('listname', '')) + ftable.AddRow([Label(_('Name of list:')), + TextBox('listname', safelistname)]) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY) + + safeowner = Utils.websafe(cgidata.getvalue('owner', '')) + ftable.AddRow([Label(_('Initial list owner address:')), + TextBox('owner', safeowner)]) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY) + + try: + autogen = bool(int(cgidata.getvalue('autogen', '0'))) + except ValueError: + autogen = False + ftable.AddRow([Label(_('Auto-generate initial list password?')), + RadioButtonArray('autogen', (_('No'), _('Yes')), + checked=autogen, + values=(0, 1))]) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY) + + safepasswd = Utils.websafe(cgidata.getvalue('password', '')) + ftable.AddRow([Label(_('Initial list password:')), + PasswordBox('password', safepasswd)]) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY) + + safeconfirm = Utils.websafe(cgidata.getvalue('confirm', '')) + ftable.AddRow([Label(_('Confirm initial password:')), + PasswordBox('confirm', safeconfirm)]) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY) + + try: + notify = bool(int(cgidata.getvalue('notify', '1'))) + except ValueError: + notify = True + try: + moderate = bool(int(cgidata.getvalue('moderate', + config.DEFAULT_DEFAULT_MEMBER_MODERATION))) + except ValueError: + moderate = config.DEFAULT_DEFAULT_MEMBER_MODERATION + + ftable.AddRow([Center(Italic(_('List Characteristics')))]) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, colspan=2) + + ftable.AddRow([ + Label(_("""Should new members be quarantined before they + are allowed to post unmoderated to this list? Answer <em>Yes</em> to hold + new member postings for moderator approval by default.""")), + RadioButtonArray('moderate', (_('No'), _('Yes')), + checked=moderate, + values=(0,1))]) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY) + # Create the table of initially supported languages, sorted on the long + # name of the language. + revmap = {} + for key, (name, charset) in config.LC_DESCRIPTIONS.items(): + revmap[_(name)] = key + langnames = revmap.keys() + langnames.sort() + langs = [] + for name in langnames: + langs.append(revmap[name]) + try: + langi = langs.index(config.DEFAULT_SERVER_LANGUAGE) + except ValueError: + # Someone must have deleted the servers's preferred language. Could + # be other trouble lurking! + langi = 0 + # BAW: we should preserve the list of checked languages across form + # invocations. + checked = [0] * len(langs) + checked[langi] = 1 + deflang = _( + config.languages.get_description(config.DEFAULT_SERVER_LANGUAGE)) + ftable.AddRow([Label(_( + """Initial list of supported languages. <p>Note that if you do not + select at least one initial language, the list will use the server + default language of $deflang""")), + CheckBoxArray('langs', + [_(config.languges.get_description(code)) + for code in langs], + checked=checked, + values=langs)]) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY) + + ftable.AddRow([Label(_('Send "list created" email to list owner?')), + RadioButtonArray('notify', (_('No'), _('Yes')), + checked=notify, + values=(0, 1))]) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY) + + ftable.AddRow(['<hr>']) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, colspan=2) + ftable.AddRow([Label(_("List creator's (authentication) password:")), + PasswordBox('auth')]) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY) + + ftable.AddRow([Center(SubmitButton('doit', _('Create List'))), + Center(SubmitButton('clear', _('Clear Form')))]) + form.AddItem(ftable) + table.AddRow([form]) + doc.AddItem(table) diff --git a/src/mailman/web/Cgi/edithtml.py b/src/mailman/web/Cgi/edithtml.py new file mode 100644 index 000000000..dfc871ec1 --- /dev/null +++ b/src/mailman/web/Cgi/edithtml.py @@ -0,0 +1,175 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Script which implements admin editing of the list's html templates.""" + +import os +import re +import cgi +import errno +import logging + +from Mailman import Defaults +from Mailman import Errors +from Mailman import MailList +from Mailman import Utils +from Mailman import i18n +from Mailman.Cgi import Auth +from Mailman.HTMLFormatter import HTMLFormatter +from Mailman.configuration import config +from Mailman.htmlformat import * + +_ = i18n._ + +log = logging.getLogger('mailman.error') + + + +def main(): + # Trick out pygettext since we want to mark template_data as translatable, + # but we don't want to actually translate it here. + def _(s): + return s + + template_data = ( + ('listinfo.html', _('General list information page')), + ('subscribe.html', _('Subscribe results page')), + ('options.html', _('User specific options page')), + ('subscribeack.txt', _('Welcome email text file')), + ) + + _ = i18n._ + doc = Document() + + # Set up the system default language + i18n.set_language(config.DEFAULT_SERVER_LANGUAGE) + doc.set_language(config.DEFAULT_SERVER_LANGUAGE) + + parts = Utils.GetPathPieces() + if not parts: + doc.AddItem(Header(2, _("List name is required."))) + print doc.Format() + return + + listname = parts[0].lower() + try: + mlist = MailList.MailList(listname, lock=0) + except Errors.MMListError, e: + # Avoid cross-site scripting attacks + safelistname = Utils.websafe(listname) + doc.AddItem(Header(2, _('No such list <em>%(safelistname)s</em>'))) + print doc.Format() + log.error('No such list "%s": %s', listname, e) + return + + # Now that we have a valid list, set the language to its default + i18n.set_language(mlist.preferred_language) + doc.set_language(mlist.preferred_language) + + # Must be authenticated to get any farther + cgidata = cgi.FieldStorage() + + # Editing the html for a list is limited to the list admin and site admin. + if not mlist.WebAuthenticate((Defaults.AuthListAdmin, + Defaults.AuthSiteAdmin), + cgidata.getvalue('adminpw', '')): + if cgidata.has_key('admlogin'): + # This is a re-authorization attempt + msg = Bold(FontSize('+1', _('Authorization failed.'))).Format() + else: + msg = '' + Auth.loginpage(mlist, 'admin', msg=msg) + return + + realname = mlist.real_name + if len(parts) > 1: + template_name = parts[1] + for (template, info) in template_data: + if template == template_name: + template_info = _(info) + doc.SetTitle(_( + '%(realname)s -- Edit html for %(template_info)s')) + break + else: + # Avoid cross-site scripting attacks + safetemplatename = Utils.websafe(template_name) + doc.SetTitle(_('Edit HTML : Error')) + doc.AddItem(Header(2, _("%(safetemplatename)s: Invalid template"))) + doc.AddItem(mlist.GetMailmanFooter()) + print doc.Format() + return + else: + doc.SetTitle(_('%(realname)s -- HTML Page Editing')) + doc.AddItem(Header(1, _('%(realname)s -- HTML Page Editing'))) + doc.AddItem(Header(2, _('Select page to edit:'))) + template_list = UnorderedList() + for (template, info) in template_data: + l = Link(mlist.GetScriptURL('edithtml') + '/' + template, _(info)) + template_list.AddItem(l) + doc.AddItem(FontSize("+2", template_list)) + doc.AddItem(mlist.GetMailmanFooter()) + print doc.Format() + return + + try: + if cgidata.keys(): + ChangeHTML(mlist, cgidata, template_name, doc) + FormatHTML(mlist, doc, template_name, template_info) + finally: + doc.AddItem(mlist.GetMailmanFooter()) + print doc.Format() + + + +def FormatHTML(mlist, doc, template_name, template_info): + doc.AddItem(Header(1,'%s:' % mlist.real_name)) + doc.AddItem(Header(1, template_info)) + doc.AddItem('<hr>') + + link = Link(mlist.GetScriptURL('admin'), + _('View or edit the list configuration information.')) + + doc.AddItem(FontSize("+1", link)) + doc.AddItem('<p>') + doc.AddItem('<hr>') + form = Form(mlist.GetScriptURL('edithtml') + '/' + template_name) + text = Utils.websafe(Utils.maketext(template_name, raw=1, mlist=mlist)) + form.AddItem(TextArea('html_code', text, rows=40, cols=75)) + form.AddItem('<p>' + _('When you are done making changes...')) + form.AddItem(SubmitButton('submit', _('Submit Changes'))) + doc.AddItem(form) + + + +def ChangeHTML(mlist, cgi_info, template_name, doc): + if not cgi_info.has_key('html_code'): + doc.AddItem(Header(3,_("Can't have empty html page."))) + doc.AddItem(Header(3,_("HTML Unchanged."))) + doc.AddItem('<hr>') + return + code = cgi_info['html_code'].value + code = re.sub(r'<([/]?script.*?)>', r'<\1>', code) + langdir = os.path.join(mlist.fullpath(), mlist.preferred_language) + # Make sure the directory exists + Utils.makedirs(langdir) + fp = open(os.path.join(langdir, template_name), 'w') + try: + fp.write(code) + finally: + fp.close() + doc.AddItem(Header(3, _('HTML successfully updated.'))) + doc.AddItem('<hr>') diff --git a/src/mailman/web/Cgi/listinfo.py b/src/mailman/web/Cgi/listinfo.py new file mode 100644 index 000000000..149be715f --- /dev/null +++ b/src/mailman/web/Cgi/listinfo.py @@ -0,0 +1,207 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Produce listinfo page, primary web entry-point to mailing lists.""" + +# No lock needed in this script, because we don't change data. + +import os +import cgi +import logging + +from Mailman import Errors +from Mailman import MailList +from Mailman import Utils +from Mailman import i18n +from Mailman.configuration import config +from Mailman.htmlformat import * + +# Set up i18n +_ = i18n._ +i18n.set_language(config.DEFAULT_SERVER_LANGUAGE) + +log = logging.getLogger('mailman.error') + + + +def main(): + parts = Utils.GetPathPieces() + if not parts: + listinfo_overview() + return + + listname = parts[0].lower() + try: + mlist = MailList.MailList(listname, lock=0) + except Errors.MMListError, e: + # Avoid cross-site scripting attacks + safelistname = Utils.websafe(listname) + listinfo_overview(_('No such list <em>%(safelistname)s</em>')) + log.error('No such list "%s": %s', listname, e) + return + + # See if the user want to see this page in other language + cgidata = cgi.FieldStorage() + language = cgidata.getvalue('language') + if language not in config.languages.enabled_codes: + language = mlist.preferred_language + i18n.set_language(language) + list_listinfo(mlist, language) + + + +def listinfo_overview(msg=''): + # Present the general listinfo overview + hostname = Utils.get_request_domain() + # Set up the document and assign it the correct language. The only one we + # know about at the moment is the server's default. + doc = Document() + doc.set_language(config.DEFAULT_SERVER_LANGUAGE) + + legend = _("%(hostname)s Mailing Lists") + doc.SetTitle(legend) + + table = Table(border=0, width="100%") + table.AddRow([Center(Header(2, legend))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2, + bgcolor=config.WEB_HEADER_COLOR) + + # Skip any mailing lists that isn't advertised. + advertised = [] + for name in sorted(config.list_manager.names): + mlist = MailList.MailList(name, lock=False) + if mlist.advertised: + if hostname not in mlist.web_page_url: + # This list is situated in a different virtual domain + continue + else: + advertised.append((mlist.GetScriptURL('listinfo'), + mlist.real_name, + mlist.description)) + if msg: + greeting = FontAttr(msg, color="ff5060", size="+1") + else: + greeting = FontAttr(_('Welcome!'), size='+2') + + welcome = [greeting] + mailmanlink = Link(config.MAILMAN_URL, _('Mailman')).Format() + if not advertised: + welcome.extend( + _('''<p>There currently are no publicly-advertised + %(mailmanlink)s mailing lists on %(hostname)s.''')) + else: + welcome.append( + _('''<p>Below is a listing of all the public mailing lists on + %(hostname)s. Click on a list name to get more information about + the list, or to subscribe, unsubscribe, and change the preferences + on your subscription.''')) + + # set up some local variables + adj = msg and _('right') or '' + siteowner = Utils.get_site_noreply() + welcome.extend( + (_(''' To visit the general information page for an unadvertised list, + open a URL similar to this one, but with a '/' and the %(adj)s + list name appended. + <p>List administrators, you can visit '''), + Link(Utils.ScriptURL('admin'), + _('the list admin overview page')), + _(''' to find the management interface for your list. + <p>If you are having trouble using the lists, please contact '''), + Link('mailto:' + siteowner, siteowner), + '.<p>')) + + table.AddRow([apply(Container, welcome)]) + table.AddCellInfo(max(table.GetCurrentRowIndex(), 0), 0, colspan=2) + + if advertised: + table.AddRow([' ', ' ']) + table.AddRow([Bold(FontAttr(_('List'), size='+2')), + Bold(FontAttr(_('Description'), size='+2')) + ]) + highlight = 1 + for url, real_name, description in advertised: + table.AddRow( + [Link(url, Bold(real_name)), + description or Italic(_('[no description available]'))]) + if highlight and config.WEB_HIGHLIGHT_COLOR: + table.AddRowInfo(table.GetCurrentRowIndex(), + bgcolor=config.WEB_HIGHLIGHT_COLOR) + highlight = not highlight + + doc.AddItem(table) + doc.AddItem('<hr>') + doc.AddItem(MailmanLogo()) + print doc.Format() + + + +def list_listinfo(mlist, lang): + # Generate list specific listinfo + doc = HeadlessDocument() + doc.set_language(lang) + + replacements = mlist.GetStandardReplacements(lang) + + if not mlist.digestable or not mlist.nondigestable: + replacements['<mm-digest-radio-button>'] = "" + replacements['<mm-undigest-radio-button>'] = "" + replacements['<mm-digest-question-start>'] = '<!-- ' + replacements['<mm-digest-question-end>'] = ' -->' + else: + replacements['<mm-digest-radio-button>'] = mlist.FormatDigestButton() + replacements['<mm-undigest-radio-button>'] = \ + mlist.FormatUndigestButton() + replacements['<mm-digest-question-start>'] = '' + replacements['<mm-digest-question-end>'] = '' + replacements['<mm-plain-digests-button>'] = \ + mlist.FormatPlainDigestsButton() + replacements['<mm-mime-digests-button>'] = mlist.FormatMimeDigestsButton() + replacements['<mm-subscribe-box>'] = mlist.FormatBox('email', size=30) + replacements['<mm-subscribe-button>'] = mlist.FormatButton( + 'email-button', text=_('Subscribe')) + replacements['<mm-new-password-box>'] = mlist.FormatSecureBox('pw') + replacements['<mm-confirm-password>'] = mlist.FormatSecureBox('pw-conf') + replacements['<mm-subscribe-form-start>'] = mlist.FormatFormStart( + 'subscribe') + # Roster form substitutions + replacements['<mm-roster-form-start>'] = mlist.FormatFormStart('roster') + replacements['<mm-roster-option>'] = mlist.FormatRosterOptionForUser(lang) + # Options form substitutions + replacements['<mm-options-form-start>'] = mlist.FormatFormStart('options') + replacements['<mm-editing-options>'] = mlist.FormatEditingOption(lang) + replacements['<mm-info-button>'] = SubmitButton('UserOptions', + _('Edit Options')).Format() + # If only one language is enabled for this mailing list, omit the choice + # buttons. + if len(mlist.language_codes) == 1: + displang = '' + else: + displang = mlist.FormatButton('displang-button', + text = _("View this page in")) + replacements['<mm-displang-box>'] = displang + replacements['<mm-lang-form-start>'] = mlist.FormatFormStart('listinfo') + replacements['<mm-fullname-box>'] = mlist.FormatBox('fullname', size=30) + + # Do the expansion. + doc.AddItem(mlist.ParseTags('listinfo.html', replacements, lang)) + print doc.Format() + + + +if __name__ == "__main__": + main() diff --git a/src/mailman/web/Cgi/options.py b/src/mailman/web/Cgi/options.py new file mode 100644 index 000000000..c529a3ea4 --- /dev/null +++ b/src/mailman/web/Cgi/options.py @@ -0,0 +1,1000 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Produce and handle the member options.""" + +import os +import cgi +import sys +import urllib +import logging + +from Mailman import Errors +from Mailman import MailList +from Mailman import MemberAdaptor +from Mailman import Utils +from Mailman import i18n +from Mailman import passwords +from Mailman.configuration import config +from Mailman.htmlformat import * + + +OR = '|' +SLASH = '/' +SETLANGUAGE = -1 + +# Set up i18n +_ = i18n._ +i18n.set_language(config.DEFAULT_SERVER_LANGUAGE) + +log = logging.getLogger('mailman.error') +mlog = logging.getLogger('mailman.mischief') + + + +def main(): + doc = Document() + doc.set_language(config.DEFAULT_SERVER_LANGUAGE) + + parts = Utils.GetPathPieces() + lenparts = parts and len(parts) + if not parts or lenparts < 1: + title = _('CGI script error') + doc.SetTitle(title) + doc.AddItem(Header(2, title)) + doc.addError(_('Invalid options to CGI script.')) + doc.AddItem('<hr>') + doc.AddItem(MailmanLogo()) + print doc.Format() + return + + # get the list and user's name + listname = parts[0].lower() + # open list + try: + mlist = MailList.MailList(listname, lock=0) + except Errors.MMListError, e: + # Avoid cross-site scripting attacks + safelistname = Utils.websafe(listname) + title = _('CGI script error') + doc.SetTitle(title) + doc.AddItem(Header(2, title)) + doc.addError(_('No such list <em>%(safelistname)s</em>')) + doc.AddItem('<hr>') + doc.AddItem(MailmanLogo()) + print doc.Format() + log.error('No such list "%s": %s\n', listname, e) + return + + # The total contents of the user's response + cgidata = cgi.FieldStorage(keep_blank_values=1) + + # Set the language for the page. If we're coming from the listinfo cgi, + # we might have a 'language' key in the cgi data. That was an explicit + # preference to view the page in, so we should honor that here. If that's + # not available, use the list's default language. + language = cgidata.getvalue('language') + if language not in config.languages.enabled_codes: + language = mlist.preferred_language + i18n.set_language(language) + doc.set_language(language) + + if lenparts < 2: + user = cgidata.getvalue('email') + if not user: + # If we're coming from the listinfo page and we left the email + # address field blank, it's not an error. listinfo.html names the + # button UserOptions; we can use that as the descriminator. + if not cgidata.getvalue('UserOptions'): + doc.addError(_('No address given')) + loginpage(mlist, doc, None, language) + print doc.Format() + return + else: + user = Utils.LCDomain(Utils.UnobscureEmail(SLASH.join(parts[1:]))) + + # Avoid cross-site scripting attacks + safeuser = Utils.websafe(user) + try: + Utils.ValidateEmail(user) + except Errors.EmailAddressError: + doc.addError(_('Illegal Email Address: %(safeuser)s')) + loginpage(mlist, doc, None, language) + print doc.Format() + return + # Sanity check the user, but only give the "no such member" error when + # using public rosters, otherwise, we'll leak membership information. + if not mlist.isMember(user) and mlist.private_roster == 0: + doc.addError(_('No such member: %(safeuser)s.')) + loginpage(mlist, doc, None, language) + print doc.Format() + return + + # Find the case preserved email address (the one the user subscribed with) + lcuser = user.lower() + try: + cpuser = mlist.getMemberCPAddress(lcuser) + except Errors.NotAMemberError: + # This happens if the user isn't a member but we've got private rosters + cpuser = None + if lcuser == cpuser: + cpuser = None + + # And now we know the user making the request, so set things up to for the + # user's stored preferred language, overridden by any form settings for + # their new language preference. + userlang = cgidata.getvalue('language') + if userlang not in config.languages.enabled_codes: + userlang = mlist.getMemberLanguage(user) + doc.set_language(userlang) + i18n.set_language(userlang) + + # See if this is VARHELP on topics. + varhelp = None + if cgidata.has_key('VARHELP'): + varhelp = cgidata['VARHELP'].value + elif os.environ.get('QUERY_STRING'): + # POST methods, even if their actions have a query string, don't get + # put into FieldStorage's keys :-( + qs = cgi.parse_qs(os.environ['QUERY_STRING']).get('VARHELP') + if qs and isinstance(qs, list): + varhelp = qs[0] + if varhelp: + topic_details(mlist, doc, user, cpuser, userlang, varhelp) + return + + # Are we processing an unsubscription request from the login screen? + if cgidata.has_key('login-unsub'): + # Because they can't supply a password for unsubscribing, we'll need + # to do the confirmation dance. + if mlist.isMember(user): + # We must acquire the list lock in order to pend a request. + try: + mlist.Lock() + # If unsubs require admin approval, then this request has to + # be held. Otherwise, send a confirmation. + if mlist.unsubscribe_policy: + mlist.HoldUnsubscription(user) + doc.addError(_("""Your unsubscription request has been + forwarded to the list administrator for approval."""), + tag='') + else: + mlist.ConfirmUnsubscription(user, userlang) + doc.addError(_('The confirmation email has been sent.'), + tag='') + mlist.Save() + finally: + mlist.Unlock() + else: + # Not a member + if mlist.private_roster == 0: + # Public rosters + doc.addError(_('No such member: %(safeuser)s.')) + else: + mlog.error('Unsub attempt of non-member w/ private rosters: %s', + user) + doc.addError(_('The confirmation email has been sent.'), + tag='') + loginpage(mlist, doc, user, language) + print doc.Format() + return + + # Are we processing a password reminder from the login screen? + if cgidata.has_key('login-remind'): + if mlist.isMember(user): + mlist.MailUserPassword(user) + doc.addError( + _('A reminder of your password has been emailed to you.'), + tag='') + else: + # Not a member + if mlist.private_roster == 0: + # Public rosters + doc.addError(_('No such member: %(safeuser)s.')) + else: + mlog.error( + 'Reminder attempt of non-member w/ private rosters: %s', + user) + doc.addError( + _('A reminder of your password has been emailed to you.'), + tag='') + loginpage(mlist, doc, user, language) + print doc.Format() + return + + # Get the password from the form. + password = cgidata.getvalue('password', '').strip() + # Check authentication. We need to know if the credentials match the user + # or the site admin, because they are the only ones who are allowed to + # change things globally. Specifically, the list admin may not change + # values globally. + if config.ALLOW_SITE_ADMIN_COOKIES: + user_or_siteadmin_context = (config.AuthUser, config.AuthSiteAdmin) + else: + # Site and list admins are treated equal so that list admin can pass + # site admin test. :-( + user_or_siteadmin_context = (config.AuthUser,) + is_user_or_siteadmin = mlist.WebAuthenticate( + user_or_siteadmin_context, password, user) + # Authenticate, possibly using the password supplied in the login page + if not is_user_or_siteadmin and \ + not mlist.WebAuthenticate((config.AuthListAdmin, + config.AuthSiteAdmin), + password, user): + # Not authenticated, so throw up the login page again. If they tried + # to authenticate via cgi (instead of cookie), then print an error + # message. + if cgidata.has_key('password'): + doc.addError(_('Authentication failed.')) + # So as not to allow membership leakage, prompt for the email + # address and the password here. + if mlist.private_roster <> 0: + mlog.error('Login failure with private rosters: %s', user) + user = None + loginpage(mlist, doc, user, language) + print doc.Format() + return + + # From here on out, the user is okay to view and modify their membership + # options. The first set of checks does not require the list to be + # locked. + + if cgidata.has_key('logout'): + print mlist.ZapCookie(config.AuthUser, user) + loginpage(mlist, doc, user, language) + print doc.Format() + return + + if cgidata.has_key('emailpw'): + mlist.MailUserPassword(user) + options_page( + mlist, doc, user, cpuser, userlang, + _('A reminder of your password has been emailed to you.')) + print doc.Format() + return + + if cgidata.has_key('othersubs'): + # Only the user or site administrator can view all subscriptions. + if not is_user_or_siteadmin: + doc.addError(_("""The list administrator may not view the other + subscriptions for this user."""), _('Note: ')) + options_page(mlist, doc, user, cpuser, userlang) + print doc.Format() + return + hostname = mlist.host_name + title = _('List subscriptions for %(safeuser)s on %(hostname)s') + doc.SetTitle(title) + doc.AddItem(Header(2, title)) + doc.AddItem(_('''Click on a link to visit your options page for the + requested mailing list.''')) + + # Troll through all the mailing lists that match host_name and see if + # the user is a member. If so, add it to the list. + onlists = [] + for gmlist in lists_of_member(mlist, user) + [mlist]: + url = gmlist.GetOptionsURL(user) + link = Link(url, gmlist.real_name) + onlists.append((gmlist.real_name, link)) + onlists.sort() + items = OrderedList(*[link for name, link in onlists]) + doc.AddItem(items) + print doc.Format() + return + + if cgidata.has_key('change-of-address'): + # We could be changing the user's full name, email address, or both. + # Watch out for non-ASCII characters in the member's name. + membername = cgidata.getvalue('fullname') + # Canonicalize the member's name + membername = Utils.canonstr(membername, language) + newaddr = cgidata.getvalue('new-address') + confirmaddr = cgidata.getvalue('confirm-address') + + oldname = mlist.getMemberName(user) + set_address = set_membername = 0 + + # See if the user wants to change their email address globally. The + # list admin is /not/ allowed to make global changes. + globally = cgidata.getvalue('changeaddr-globally') + if globally and not is_user_or_siteadmin: + doc.addError(_("""The list administrator may not change the names + or addresses for this user's other subscriptions. However, the + subscription for this mailing list has been changed."""), + _('Note: ')) + globally = False + # We will change the member's name under the following conditions: + # - membername has a value + # - membername has no value, but they /used/ to have a membername + if membername and membername <> oldname: + # Setting it to a new value + set_membername = 1 + if not membername and oldname: + # Unsetting it + set_membername = 1 + # We will change the user's address if both newaddr and confirmaddr + # are non-blank, have the same value, and aren't the currently + # subscribed email address (when compared case-sensitively). If both + # are blank, but membername is set, we ignore it, otherwise we print + # an error. + msg = '' + if newaddr and confirmaddr: + if newaddr <> confirmaddr: + options_page(mlist, doc, user, cpuser, userlang, + _('Addresses did not match!')) + print doc.Format() + return + if newaddr == cpuser: + options_page(mlist, doc, user, cpuser, userlang, + _('You are already using that email address')) + print doc.Format() + return + # If they're requesting to subscribe an address which is already a + # member, and they're /not/ doing it globally, then refuse. + # Otherwise, we'll agree to do it globally (with a warning + # message) and let ApprovedChangeMemberAddress() handle already a + # member issues. + if mlist.isMember(newaddr): + safenewaddr = Utils.websafe(newaddr) + if globally: + listname = mlist.real_name + msg += _("""\ +The new address you requested %(newaddr)s is already a member of the +%(listname)s mailing list, however you have also requested a global change of +address. Upon confirmation, any other mailing list containing the address +%(safeuser)s will be changed. """) + # Don't return + else: + options_page( + mlist, doc, user, cpuser, userlang, + _('The new address is already a member: %(newaddr)s')) + print doc.Format() + return + set_address = 1 + elif (newaddr or confirmaddr) and not set_membername: + options_page(mlist, doc, user, cpuser, userlang, + _('Addresses may not be blank')) + print doc.Format() + return + if set_address: + if cpuser is None: + cpuser = user + # Register the pending change after the list is locked + msg += _('A confirmation message has been sent to %(newaddr)s. ') + mlist.Lock() + try: + try: + mlist.ChangeMemberAddress(cpuser, newaddr, globally) + mlist.Save() + finally: + mlist.Unlock() + except Errors.InvalidEmailAddress: + msg = _('Invalid email address provided') + except Errors.MMAlreadyAMember: + msg = _('%(newaddr)s is already a member of the list.') + except Errors.MembershipIsBanned: + owneraddr = mlist.GetOwnerEmail() + msg = _("""%(newaddr)s is banned from this list. If you + think this restriction is erroneous, please contact + the list owners at %(owneraddr)s.""") + + if set_membername: + mlist.Lock() + try: + mlist.ChangeMemberName(user, membername, globally) + mlist.Save() + finally: + mlist.Unlock() + msg += _('Member name successfully changed. ') + + options_page(mlist, doc, user, cpuser, userlang, msg) + print doc.Format() + return + + if cgidata.has_key('changepw'): + newpw = cgidata.getvalue('newpw') + confirmpw = cgidata.getvalue('confpw') + if not newpw or not confirmpw: + options_page(mlist, doc, user, cpuser, userlang, + _('Passwords may not be blank')) + print doc.Format() + return + if newpw <> confirmpw: + options_page(mlist, doc, user, cpuser, userlang, + _('Passwords did not match!')) + print doc.Format() + return + + # See if the user wants to change their passwords globally, however + # the list admin is /not/ allowed to change passwords globally. + pw_globally = cgidata.getvalue('pw-globally') + if pw_globally and not is_user_or_siteadmin: + doc.addError(_("""The list administrator may not change the + password for this user's other subscriptions. However, the + password for this mailing list has been changed."""), + _('Note: ')) + pw_globally = False + + mlists = [mlist] + + if pw_globally: + mlists.extend(lists_of_member(mlist, user)) + + pw = passwords.make_secret(newpw, config.PASSWORD_SCHEME) + for gmlist in mlists: + change_password(gmlist, user, pw) + + # Regenerate the cookie so a re-authorization isn't necessary + print mlist.MakeCookie(config.AuthUser, user) + options_page(mlist, doc, user, cpuser, userlang, + _('Password successfully changed.')) + print doc.Format() + return + + if cgidata.has_key('unsub'): + # Was the confirming check box turned on? + if not cgidata.getvalue('unsubconfirm'): + options_page( + mlist, doc, user, cpuser, userlang, + _('''You must confirm your unsubscription request by turning + on the checkbox below the <em>Unsubscribe</em> button. You + have not been unsubscribed!''')) + print doc.Format() + return + + mlist.Lock() + needapproval = False + try: + try: + mlist.DeleteMember( + user, 'via the member options page', userack=1) + except Errors.MMNeedApproval: + needapproval = True + mlist.Save() + finally: + mlist.Unlock() + # Now throw up some results page, with appropriate links. We can't + # drop them back into their options page, because that's gone now! + fqdn_listname = mlist.GetListEmail() + owneraddr = mlist.GetOwnerEmail() + url = mlist.GetScriptURL('listinfo') + + title = _('Unsubscription results') + doc.SetTitle(title) + doc.AddItem(Header(2, title)) + if needapproval: + doc.AddItem(_("""Your unsubscription request has been received and + forwarded on to the list moderators for approval. You will + receive notification once the list moderators have made their + decision.""")) + else: + doc.AddItem(_("""You have been successfully unsubscribed from the + mailing list %(fqdn_listname)s. If you were receiving digest + deliveries you may get one more digest. If you have any questions + about your unsubscription, please contact the list owners at + %(owneraddr)s.""")) + doc.AddItem(mlist.GetMailmanFooter()) + print doc.Format() + return + + if cgidata.has_key('options-submit'): + # Digest action flags + digestwarn = 0 + cantdigest = 0 + mustdigest = 0 + + newvals = [] + # First figure out which options have changed. The item names come + # from FormatOptionButton() in HTMLFormatter.py + for item, flag in (('digest', config.Digests), + ('mime', config.DisableMime), + ('dontreceive', config.DontReceiveOwnPosts), + ('ackposts', config.AcknowledgePosts), + ('disablemail', config.DisableDelivery), + ('conceal', config.ConcealSubscription), + ('remind', config.SuppressPasswordReminder), + ('rcvtopic', config.ReceiveNonmatchingTopics), + ('nodupes', config.DontReceiveDuplicates), + ): + try: + newval = int(cgidata.getvalue(item)) + except (TypeError, ValueError): + newval = None + + # Skip this option if there was a problem or it wasn't changed. + # Note that delivery status is handled separate from the options + # flags. + if newval is None: + continue + elif flag == config.DisableDelivery: + status = mlist.getDeliveryStatus(user) + # Here, newval == 0 means enable, newval == 1 means disable + if not newval and status <> MemberAdaptor.ENABLED: + newval = MemberAdaptor.ENABLED + elif newval and status == MemberAdaptor.ENABLED: + newval = MemberAdaptor.BYUSER + else: + continue + elif newval == mlist.getMemberOption(user, flag): + continue + # Should we warn about one more digest? + if flag == config.Digests and \ + newval == 0 and mlist.getMemberOption(user, flag): + digestwarn = 1 + + newvals.append((flag, newval)) + + # The user language is handled a little differently + if userlang not in mlist.language_codes: + newvals.append((SETLANGUAGE, mlist.preferred_language)) + else: + newvals.append((SETLANGUAGE, userlang)) + + # Process user selected topics, but don't make the changes to the + # MailList object; we must do that down below when the list is + # locked. + topicnames = cgidata.getvalue('usertopic') + if topicnames: + # Some topics were selected. topicnames can actually be a string + # or a list of strings depending on whether more than one topic + # was selected or not. + if not isinstance(topicnames, list): + # Assume it was a bare string, so listify it + topicnames = [topicnames] + # unquote the topic names + topicnames = [urllib.unquote_plus(n) for n in topicnames] + + # The standard sigterm handler (see above) + def sigterm_handler(signum, frame, mlist=mlist): + mlist.Unlock() + sys.exit(0) + + # Now, lock the list and perform the changes + mlist.Lock() + try: + # `values' is a tuple of flags and the web values + for flag, newval in newvals: + # Handle language settings differently + if flag == SETLANGUAGE: + mlist.setMemberLanguage(user, newval) + # Handle delivery status separately + elif flag == config.DisableDelivery: + mlist.setDeliveryStatus(user, newval) + else: + try: + mlist.setMemberOption(user, flag, newval) + except Errors.CantDigestError: + cantdigest = 1 + except Errors.MustDigestError: + mustdigest = 1 + # Set the topics information. + mlist.setMemberTopics(user, topicnames) + mlist.Save() + finally: + mlist.Unlock() + + # A bag of attributes for the global options + class Global: + enable = None + remind = None + nodupes = None + mime = None + def __nonzero__(self): + return len(self.__dict__.keys()) > 0 + + globalopts = Global() + + # The enable/disable option and the password remind option may have + # their global flags sets. + if cgidata.getvalue('deliver-globally'): + # Yes, this is inefficient, but the list is so small it shouldn't + # make much of a difference. + for flag, newval in newvals: + if flag == config.DisableDelivery: + globalopts.enable = newval + break + + if cgidata.getvalue('remind-globally'): + for flag, newval in newvals: + if flag == config.SuppressPasswordReminder: + globalopts.remind = newval + break + + if cgidata.getvalue('nodupes-globally'): + for flag, newval in newvals: + if flag == config.DontReceiveDuplicates: + globalopts.nodupes = newval + break + + if cgidata.getvalue('mime-globally'): + for flag, newval in newvals: + if flag == config.DisableMime: + globalopts.mime = newval + break + + # Change options globally, but only if this is the user or site admin, + # /not/ if this is the list admin. + if globalopts: + if not is_user_or_siteadmin: + doc.addError(_("""The list administrator may not change the + options for this user's other subscriptions. However the + options for this mailing list subscription has been + changed."""), _('Note: ')) + else: + for gmlist in lists_of_member(mlist, user): + global_options(gmlist, user, globalopts) + + # Now print the results + if cantdigest: + msg = _('''The list administrator has disabled digest delivery for + this list, so your delivery option has not been set. However your + other options have been set successfully.''') + elif mustdigest: + msg = _('''The list administrator has disabled non-digest delivery + for this list, so your delivery option has not been set. However + your other options have been set successfully.''') + else: + msg = _('You have successfully set your options.') + + if digestwarn: + msg += _('You may get one last digest.') + + options_page(mlist, doc, user, cpuser, userlang, msg) + print doc.Format() + return + + if mlist.isMember(user): + options_page(mlist, doc, user, cpuser, userlang) + else: + loginpage(mlist, doc, user, userlang) + print doc.Format() + + + +def options_page(mlist, doc, user, cpuser, userlang, message=''): + # The bulk of the document will come from the options.html template, which + # includes it's own html armor (head tags, etc.). Suppress the head that + # Document() derived pages get automatically. + doc.suppress_head = 1 + + if mlist.obscure_addresses: + presentable_user = Utils.ObscureEmail(user, for_text=1) + if cpuser is not None: + cpuser = Utils.ObscureEmail(cpuser, for_text=1) + else: + presentable_user = user + + fullname = Utils.uncanonstr(mlist.getMemberName(user), userlang) + if fullname: + presentable_user += ', %s' % fullname + + # Do replacements + replacements = mlist.GetStandardReplacements(userlang) + replacements['<mm-results>'] = Bold(FontSize('+1', message)).Format() + replacements['<mm-digest-radio-button>'] = mlist.FormatOptionButton( + config.Digests, 1, user) + replacements['<mm-undigest-radio-button>'] = mlist.FormatOptionButton( + config.Digests, 0, user) + replacements['<mm-plain-digests-button>'] = mlist.FormatOptionButton( + config.DisableMime, 1, user) + replacements['<mm-mime-digests-button>'] = mlist.FormatOptionButton( + config.DisableMime, 0, user) + replacements['<mm-global-mime-button>'] = ( + CheckBox('mime-globally', 1, checked=0).Format()) + replacements['<mm-delivery-enable-button>'] = mlist.FormatOptionButton( + config.DisableDelivery, 0, user) + replacements['<mm-delivery-disable-button>'] = mlist.FormatOptionButton( + config.DisableDelivery, 1, user) + replacements['<mm-disabled-notice>'] = mlist.FormatDisabledNotice(user) + replacements['<mm-dont-ack-posts-button>'] = mlist.FormatOptionButton( + config.AcknowledgePosts, 0, user) + replacements['<mm-ack-posts-button>'] = mlist.FormatOptionButton( + config.AcknowledgePosts, 1, user) + replacements['<mm-receive-own-mail-button>'] = mlist.FormatOptionButton( + config.DontReceiveOwnPosts, 0, user) + replacements['<mm-dont-receive-own-mail-button>'] = ( + mlist.FormatOptionButton(config.DontReceiveOwnPosts, 1, user)) + replacements['<mm-dont-get-password-reminder-button>'] = ( + mlist.FormatOptionButton(config.SuppressPasswordReminder, 1, user)) + replacements['<mm-get-password-reminder-button>'] = ( + mlist.FormatOptionButton(config.SuppressPasswordReminder, 0, user)) + replacements['<mm-public-subscription-button>'] = ( + mlist.FormatOptionButton(config.ConcealSubscription, 0, user)) + replacements['<mm-hide-subscription-button>'] = mlist.FormatOptionButton( + config.ConcealSubscription, 1, user) + replacements['<mm-dont-receive-duplicates-button>'] = ( + mlist.FormatOptionButton(config.DontReceiveDuplicates, 1, user)) + replacements['<mm-receive-duplicates-button>'] = ( + mlist.FormatOptionButton(config.DontReceiveDuplicates, 0, user)) + replacements['<mm-unsubscribe-button>'] = ( + mlist.FormatButton('unsub', _('Unsubscribe')) + '<br>' + + CheckBox('unsubconfirm', 1, checked=0).Format() + + _('<em>Yes, I really want to unsubscribe</em>')) + replacements['<mm-new-pass-box>'] = mlist.FormatSecureBox('newpw') + replacements['<mm-confirm-pass-box>'] = mlist.FormatSecureBox('confpw') + replacements['<mm-change-pass-button>'] = ( + mlist.FormatButton('changepw', _("Change My Password"))) + replacements['<mm-other-subscriptions-submit>'] = ( + mlist.FormatButton('othersubs', + _('List my other subscriptions'))) + replacements['<mm-form-start>'] = ( + mlist.FormatFormStart('options', user)) + replacements['<mm-user>'] = user + replacements['<mm-presentable-user>'] = presentable_user + replacements['<mm-email-my-pw>'] = mlist.FormatButton( + 'emailpw', (_('Email My Password To Me'))) + replacements['<mm-umbrella-notice>'] = ( + mlist.FormatUmbrellaNotice(user, _("password"))) + replacements['<mm-logout-button>'] = ( + mlist.FormatButton('logout', _('Log out'))) + replacements['<mm-options-submit-button>'] = mlist.FormatButton( + 'options-submit', _('Submit My Changes')) + replacements['<mm-global-pw-changes-button>'] = ( + CheckBox('pw-globally', 1, checked=0).Format()) + replacements['<mm-global-deliver-button>'] = ( + CheckBox('deliver-globally', 1, checked=0).Format()) + replacements['<mm-global-remind-button>'] = ( + CheckBox('remind-globally', 1, checked=0).Format()) + replacements['<mm-global-nodupes-button>'] = ( + CheckBox('nodupes-globally', 1, checked=0).Format()) + + days = int(config.PENDING_REQUEST_LIFE / config.days(1)) + if days > 1: + units = _('days') + else: + units = _('day') + replacements['<mm-pending-days>'] = _('%(days)d %(units)s') + + replacements['<mm-new-address-box>'] = mlist.FormatBox('new-address') + replacements['<mm-confirm-address-box>'] = mlist.FormatBox( + 'confirm-address') + replacements['<mm-change-address-button>'] = mlist.FormatButton( + 'change-of-address', _('Change My Address and Name')) + replacements['<mm-global-change-of-address>'] = CheckBox( + 'changeaddr-globally', 1, checked=0).Format() + replacements['<mm-fullname-box>'] = mlist.FormatBox( + 'fullname', value=fullname) + + # Create the topics radios. BAW: what if the list admin deletes a topic, + # but the user still wants to get that topic message? + usertopics = mlist.getMemberTopics(user) + if mlist.topics: + table = Table(border="0") + for name, pattern, description, emptyflag in mlist.topics: + if emptyflag: + continue + quotedname = urllib.quote_plus(name) + details = Link(mlist.GetScriptURL('options') + + '/%s/?VARHELP=%s' % (user, quotedname), + ' (Details)') + if name in usertopics: + checked = 1 + else: + checked = 0 + table.AddRow([CheckBox('usertopic', quotedname, checked=checked), + name + details.Format()]) + topicsfield = table.Format() + else: + topicsfield = _('<em>No topics defined</em>') + replacements['<mm-topics>'] = topicsfield + replacements['<mm-suppress-nonmatching-topics>'] = ( + mlist.FormatOptionButton(config.ReceiveNonmatchingTopics, 0, user)) + replacements['<mm-receive-nonmatching-topics>'] = ( + mlist.FormatOptionButton(config.ReceiveNonmatchingTopics, 1, user)) + + if cpuser is not None: + replacements['<mm-case-preserved-user>'] = _(''' +You are subscribed to this list with the case-preserved address +<em>%(cpuser)s</em>.''') + else: + replacements['<mm-case-preserved-user>'] = '' + + doc.AddItem(mlist.ParseTags('options.html', replacements, userlang)) + + + +def loginpage(mlist, doc, user, lang): + realname = mlist.real_name + actionurl = mlist.GetScriptURL('options') + if user is None: + title = _('%(realname)s list: member options login page') + extra = _('email address and ') + else: + safeuser = Utils.websafe(user) + title = _('%(realname)s list: member options for user %(safeuser)s') + obuser = Utils.ObscureEmail(user) + extra = '' + # Set up the title + doc.SetTitle(title) + # We use a subtable here so we can put a language selection box in + table = Table(width='100%', border=0, cellspacing=4, cellpadding=5) + # If only one language is enabled for this mailing list, omit the choice + # buttons. + table.AddRow([Center(Header(2, title))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, + bgcolor=config.WEB_HEADER_COLOR) + if len(mlist.language_codes) > 1: + langform = Form(actionurl) + langform.AddItem(SubmitButton('displang-button', + _('View this page in'))) + langform.AddItem(mlist.GetLangSelectBox(lang)) + if user: + langform.AddItem(Hidden('email', user)) + table.AddRow([Center(langform)]) + doc.AddItem(table) + # Preamble + # Set up the login page + form = Form(actionurl) + table = Table(width='100%', border=0, cellspacing=4, cellpadding=5) + table.AddRow([_("""In order to change your membership option, you must + first log in by giving your %(extra)smembership password in the section + below. If you don't remember your membership password, you can have it + emailed to you by clicking on the button below. If you just want to + unsubscribe from this list, click on the <em>Unsubscribe</em> button and a + confirmation message will be sent to you. + + <p><strong><em>Important:</em></strong> From this point on, you must have + cookies enabled in your browser, otherwise none of your changes will take + effect. + """)]) + # Password and login button + ptable = Table(width='50%', border=0, cellspacing=4, cellpadding=5) + if user is None: + ptable.AddRow([Label(_('Email address:')), + TextBox('email', size=20)]) + else: + ptable.AddRow([Hidden('email', user)]) + ptable.AddRow([Label(_('Password:')), + PasswordBox('password', size=20)]) + ptable.AddRow([Center(SubmitButton('login', _('Log in')))]) + ptable.AddCellInfo(ptable.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([Center(ptable)]) + # Unsubscribe section + table.AddRow([Center(Header(2, _('Unsubscribe')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, + bgcolor=config.WEB_HEADER_COLOR) + + table.AddRow([_("""By clicking on the <em>Unsubscribe</em> button, a + confirmation message will be emailed to you. This message will have a + link that you should click on to complete the removal process (you can + also confirm by email; see the instructions in the confirmation + message).""")]) + + table.AddRow([Center(SubmitButton('login-unsub', _('Unsubscribe')))]) + # Password reminder section + table.AddRow([Center(Header(2, _('Password reminder')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, + bgcolor=config.WEB_HEADER_COLOR) + + table.AddRow([_("""By clicking on the <em>Remind</em> button, your + password will be emailed to you.""")]) + + table.AddRow([Center(SubmitButton('login-remind', _('Remind')))]) + # Finish up glomming together the login page + form.AddItem(table) + doc.AddItem(form) + doc.AddItem(mlist.GetMailmanFooter()) + + + +def lists_of_member(mlist, user): + hostname = mlist.host_name + onlists = [] + for listname in config.list_manager.names: + # The current list will always handle things in the mainline + if listname == mlist.internal_name(): + continue + glist = MailList.MailList(listname, lock=0) + if glist.host_name <> hostname: + continue + if not glist.isMember(user): + continue + onlists.append(glist) + return onlists + + + +def change_password(mlist, user, newpw): + # Must own the list lock! + mlist.Lock() + try: + # Change the user's password. The password must already have been + # compared to the confirmpw and otherwise been vetted for + # acceptability. + mlist.setMemberPassword(user, newpw) + mlist.Save() + finally: + mlist.Unlock() + + + +def global_options(mlist, user, globalopts): + # Is there anything to do? + for attr in dir(globalopts): + if attr.startswith('_'): + continue + if getattr(globalopts, attr) is not None: + break + else: + return + + def sigterm_handler(signum, frame, mlist=mlist): + # Make sure the list gets unlocked... + mlist.Unlock() + # ...and ensure we exit, otherwise race conditions could cause us to + # enter MailList.Save() while we're in the unlocked state, and that + # could be bad! + sys.exit(0) + + # Must own the list lock! + mlist.Lock() + try: + if globalopts.enable is not None: + mlist.setDeliveryStatus(user, globalopts.enable) + + if globalopts.remind is not None: + mlist.setMemberOption(user, config.SuppressPasswordReminder, + globalopts.remind) + + if globalopts.nodupes is not None: + mlist.setMemberOption(user, config.DontReceiveDuplicates, + globalopts.nodupes) + + if globalopts.mime is not None: + mlist.setMemberOption(user, config.DisableMime, globalopts.mime) + + mlist.Save() + finally: + mlist.Unlock() + + + +def topic_details(mlist, doc, user, cpuser, userlang, varhelp): + # Find out which topic the user wants to get details of + reflist = varhelp.split('/') + name = None + topicname = _('<missing>') + if len(reflist) == 1: + topicname = urllib.unquote_plus(reflist[0]) + for name, pattern, description, emptyflag in mlist.topics: + if name == topicname: + break + else: + name = None + + if not name: + options_page(mlist, doc, user, cpuser, userlang, + _('Requested topic is not valid: %(topicname)s')) + print doc.Format() + return + + table = Table(border=3, width='100%') + table.AddRow([Center(Bold(_('Topic filter details')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2, + bgcolor=config.WEB_SUBHEADER_COLOR) + table.AddRow([Bold(Label(_('Name:'))), + Utils.websafe(name)]) + table.AddRow([Bold(Label(_('Pattern (as regexp):'))), + '<pre>' + Utils.websafe(OR.join(pattern.splitlines())) + + '</pre>']) + table.AddRow([Bold(Label(_('Description:'))), + Utils.websafe(description)]) + # Make colors look nice + for row in range(1, 4): + table.AddCellInfo(row, 0, bgcolor=config.WEB_ADMINITEM_COLOR) + + options_page(mlist, doc, user, cpuser, userlang, table.Format()) + print doc.Format() diff --git a/src/mailman/web/Cgi/private.py b/src/mailman/web/Cgi/private.py new file mode 100644 index 000000000..87cad7966 --- /dev/null +++ b/src/mailman/web/Cgi/private.py @@ -0,0 +1,190 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Provide a password-interface wrapper around private archives.""" + +import os +import sys +import cgi +import logging +import mimetypes + +from Mailman import Errors +from Mailman import MailList +from Mailman import Utils +from Mailman import i18n +from Mailman.configuration import config +from Mailman.htmlformat import * + +# Set up i18n. Until we know which list is being requested, we use the +# server's default. +_ = i18n._ +i18n.set_language(config.DEFAULT_SERVER_LANGUAGE) + +SLASH = '/' + +log = logging.getLogger('mailman.error') +mlog = logging.getLogger('mailman.mischief') + + + +def true_path(path): + "Ensure that the path is safe by removing .." + # Workaround for path traverse vulnerability. Unsuccessful attempts will + # be logged in logs/error. + parts = [x for x in path.split(SLASH) if x not in ('.', '..')] + return SLASH.join(parts)[1:] + + + +def guess_type(url, strict): + if hasattr(mimetypes, 'common_types'): + return mimetypes.guess_type(url, strict) + return mimetypes.guess_type(url) + + + +def main(): + doc = Document() + doc.set_language(config.DEFAULT_SERVER_LANGUAGE) + + parts = Utils.GetPathPieces() + if not parts: + doc.SetTitle(_("Private Archive Error")) + doc.AddItem(Header(3, _("You must specify a list."))) + print doc.Format() + return + + path = os.environ.get('PATH_INFO') + tpath = true_path(path) + if tpath <> path[1:]: + msg = _('Private archive - "./" and "../" not allowed in URL.') + doc.SetTitle(msg) + doc.AddItem(Header(2, msg)) + print doc.Format() + mlog.error('Private archive hostile path: %s', path) + return + # BAW: This needs to be converted to the Site module abstraction + true_filename = os.path.join( + config.PRIVATE_ARCHIVE_FILE_DIR, tpath) + + listname = parts[0].lower() + mboxfile = '' + if len(parts) > 1: + mboxfile = parts[1] + + # See if it's the list's mbox file is being requested + if listname.endswith('.mbox') and mboxfile.endswith('.mbox') and \ + listname[:-5] == mboxfile[:-5]: + listname = listname[:-5] + else: + mboxfile = '' + + # If it's a directory, we have to append index.html in this script. We + # must also check for a gzipped file, because the text archives are + # usually stored in compressed form. + if os.path.isdir(true_filename): + true_filename = true_filename + '/index.html' + if not os.path.exists(true_filename) and \ + os.path.exists(true_filename + '.gz'): + true_filename = true_filename + '.gz' + + try: + mlist = MailList.MailList(listname, lock=0) + except Errors.MMListError, e: + # Avoid cross-site scripting attacks + safelistname = Utils.websafe(listname) + msg = _('No such list <em>%(safelistname)s</em>') + doc.SetTitle(_("Private Archive Error - %(msg)s")) + doc.AddItem(Header(2, msg)) + print doc.Format() + log.error('No such list "%s": %s\n', listname, e) + return + + i18n.set_language(mlist.preferred_language) + doc.set_language(mlist.preferred_language) + + cgidata = cgi.FieldStorage() + username = cgidata.getvalue('username', '') + password = cgidata.getvalue('password', '') + + is_auth = 0 + realname = mlist.real_name + message = '' + + if not mlist.WebAuthenticate((config.AuthUser, + config.AuthListModerator, + config.AuthListAdmin, + config.AuthSiteAdmin), + password, username): + if cgidata.has_key('submit'): + # This is a re-authorization attempt + message = Bold(FontSize('+1', _('Authorization failed.'))).Format() + # Output the password form + charset = Utils.GetCharSet(mlist.preferred_language) + print 'Content-type: text/html; charset=' + charset + '\n\n' + # Put the original full path in the authorization form, but avoid + # trailing slash if we're not adding parts. We add it below. + action = mlist.GetScriptURL('private') + if parts[1:]: + action = os.path.join(action, SLASH.join(parts[1:])) + # If we added '/index.html' to true_filename, add a slash to the URL. + # We need this because we no longer add the trailing slash in the + # private.html template. It's always OK to test parts[-1] since we've + # already verified parts[0] is listname. The basic rule is if the + # post URL (action) is a directory, it must be slash terminated, but + # not if it's a file. Otherwise, relative links in the target archive + # page don't work. + if true_filename.endswith('/index.html') and parts[-1] <> 'index.html': + action += SLASH + # Escape web input parameter to avoid cross-site scripting. + print Utils.maketext( + 'private.html', + {'action' : Utils.websafe(action), + 'realname': mlist.real_name, + 'message' : message, + }, mlist=mlist) + return + + lang = mlist.getMemberLanguage(username) + i18n.set_language(lang) + doc.set_language(lang) + + # Authorization confirmed... output the desired file + try: + ctype, enc = guess_type(path, strict=0) + if ctype is None: + ctype = 'text/html' + if mboxfile: + f = open(os.path.join(mlist.archive_dir() + '.mbox', + mlist.internal_name() + '.mbox')) + ctype = 'text/plain' + elif true_filename.endswith('.gz'): + import gzip + f = gzip.open(true_filename, 'r') + else: + f = open(true_filename, 'r') + except IOError: + msg = _('Private archive file not found') + doc.SetTitle(msg) + doc.AddItem(Header(2, msg)) + print doc.Format() + log.error('Private archive file not found: %s', true_filename) + else: + print 'Content-type: %s\n' % ctype + sys.stdout.write(f.read()) + f.close() diff --git a/src/mailman/web/Cgi/rmlist.py b/src/mailman/web/Cgi/rmlist.py new file mode 100644 index 000000000..4b62f09fb --- /dev/null +++ b/src/mailman/web/Cgi/rmlist.py @@ -0,0 +1,243 @@ +# Copyright (C) 2001-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Remove/delete mailing lists through the web.""" + +import os +import cgi +import sys +import errno +import shutil +import logging + +from Mailman import Errors +from Mailman import MailList +from Mailman import Utils +from Mailman import i18n +from Mailman.configuration import config +from Mailman.htmlformat import * + +# Set up i18n +_ = i18n._ +i18n.set_language(config.DEFAULT_SERVER_LANGUAGE) + +log = logging.getLogger('mailman.error') +mlog = logging.getLogger('mailman.mischief') + + + +def main(): + doc = Document() + doc.set_language(config.DEFAULT_SERVER_LANGUAGE) + + cgidata = cgi.FieldStorage() + parts = Utils.GetPathPieces() + + if not parts: + # Bad URL specification + title = _('Bad URL specification') + doc.SetTitle(title) + doc.AddItem( + Header(3, Bold(FontAttr(title, color='#ff0000', size='+2')))) + doc.AddItem('<hr>') + doc.AddItem(MailmanLogo()) + print doc.Format() + log.error('Bad URL specification: %s', parts) + return + + listname = parts[0].lower() + try: + mlist = MailList.MailList(listname, lock=0) + except Errors.MMListError, e: + # Avoid cross-site scripting attacks + safelistname = Utils.websafe(listname) + title = _('No such list <em>%(safelistname)s</em>') + doc.SetTitle(title) + doc.AddItem( + Header(3, + Bold(FontAttr(title, color='#ff0000', size='+2')))) + doc.AddItem('<hr>') + doc.AddItem(MailmanLogo()) + print doc.Format() + log.error('No such list "%s": %s\n', listname, e) + return + + # Now that we have a valid mailing list, set the language + i18n.set_language(mlist.preferred_language) + doc.set_language(mlist.preferred_language) + + # Be sure the list owners are not sneaking around! + if not config.OWNERS_CAN_DELETE_THEIR_OWN_LISTS: + title = _("You're being a sneaky list owner!") + doc.SetTitle(title) + doc.AddItem( + Header(3, Bold(FontAttr(title, color='#ff0000', size='+2')))) + doc.AddItem(mlist.GetMailmanFooter()) + print doc.Format() + mlog.error('Attempt to sneakily delete a list: %s', listname) + return + + if cgidata.has_key('doit'): + process_request(doc, cgidata, mlist) + print doc.Format() + return + + request_deletion(doc, mlist) + # Always add the footer and print the document + doc.AddItem(mlist.GetMailmanFooter()) + print doc.Format() + + + +def process_request(doc, cgidata, mlist): + password = cgidata.getvalue('password', '').strip() + try: + delarchives = int(cgidata.getvalue('delarchives', '0')) + except ValueError: + delarchives = 0 + + # Removing a list is limited to the list-creator (a.k.a. list-destroyer), + # the list-admin, or the site-admin. Don't use WebAuthenticate here + # because we want to be sure the actual typed password is valid, not some + # password sitting in a cookie. + if mlist.Authenticate((config.AuthCreator, + config.AuthListAdmin, + config.AuthSiteAdmin), + password) == config.UnAuthorized: + request_deletion( + doc, mlist, + _('You are not authorized to delete this mailing list')) + return + + # Do the MTA-specific list deletion tasks + if config.MTA: + modname = 'Mailman.MTA.' + config.MTA + __import__(modname) + sys.modules[modname].remove(mlist, cgi=1) + + REMOVABLES = ['lists/%s'] + + if delarchives: + REMOVABLES.extend(['archives/private/%s', + 'archives/private/%s.mbox', + 'archives/public/%s', + 'archives/public/%s.mbox', + ]) + + problems = 0 + listname = mlist.internal_name() + for dirtmpl in REMOVABLES: + dir = os.path.join(config.VAR_DIR, dirtmpl % listname) + if os.path.islink(dir): + try: + os.unlink(dir) + except OSError, e: + if e.errno not in (errno.EACCES, errno.EPERM): raise + problems += 1 + log.error('link %s not deleted due to permission problems', dir) + elif os.path.isdir(dir): + try: + shutil.rmtree(dir) + except OSError, e: + if e.errno not in (errno.EACCES, errno.EPERM): raise + problems += 1 + log.error('directory %s not deleted due to permission problems', + dir) + + title = _('Mailing list deletion results') + doc.SetTitle(title) + table = Table(border=0, width='100%') + table.AddRow([Center(Bold(FontAttr(title, size='+1')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, + bgcolor=config.WEB_HEADER_COLOR) + if not problems: + table.AddRow([_('''You have successfully deleted the mailing list + <b>%(listname)s</b>.''')]) + else: + sitelist = mlist.no_reply_address + table.AddRow([_('''There were some problems deleting the mailing list + <b>%(listname)s</b>. Contact your site administrator at %(sitelist)s + for details.''')]) + doc.AddItem(table) + doc.AddItem('<hr>') + doc.AddItem(_('Return to the ') + + Link(Utils.ScriptURL('listinfo'), + _('general list overview')).Format()) + doc.AddItem(_('<br>Return to the ') + + Link(Utils.ScriptURL('admin'), + _('administrative list overview')).Format()) + doc.AddItem(MailmanLogo()) + + + +def request_deletion(doc, mlist, errmsg=None): + realname = mlist.real_name + title = _('Permanently remove mailing list <em>%(realname)s</em>') + doc.SetTitle(title) + + table = Table(border=0, width='100%') + table.AddRow([Center(Bold(FontAttr(title, size='+1')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, + bgcolor=config.WEB_HEADER_COLOR) + + # Add any error message + if errmsg: + table.AddRow([Header(3, Bold( + FontAttr(_('Error: '), color='#ff0000', size='+2').Format() + + Italic(errmsg).Format()))]) + + table.AddRow([_("""This page allows you as the list owner, to permanent + remove this mailing list from the system. <strong>This action is not + undoable</strong> so you should undertake it only if you are absolutely + sure this mailing list has served its purpose and is no longer necessary. + + <p>Note that no warning will be sent to your list members and after this + action, any subsequent messages sent to the mailing list, or any of its + administrative addreses will bounce. + + <p>You also have the option of removing the archives for this mailing list + at this time. It is almost always recommended that you do + <strong>not</strong> remove the archives, since they serve as the + historical record of your mailing list. + + <p>For your safety, you will be asked to reconfirm the list password. + """)]) + GREY = config.WEB_ADMINITEM_COLOR + form = Form(mlist.GetScriptURL('rmlist')) + ftable = Table(border=0, cols='2', width='100%', + cellspacing=3, cellpadding=4) + + ftable.AddRow([Label(_('List password:')), PasswordBox('password')]) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY) + + ftable.AddRow([Label(_('Also delete archives?')), + RadioButtonArray('delarchives', (_('No'), _('Yes')), + checked=0, values=(0, 1))]) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY) + + ftable.AddRow([Center(Link( + mlist.GetScriptURL('admin'), + _('<b>Cancel</b> and return to list administration')))]) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, colspan=2) + + ftable.AddRow([Center(SubmitButton('doit', _('Delete this list')))]) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, colspan=2) + form.AddItem(ftable) + table.AddRow([form]) + doc.AddItem(table) diff --git a/src/mailman/web/Cgi/roster.py b/src/mailman/web/Cgi/roster.py new file mode 100644 index 000000000..2351d6915 --- /dev/null +++ b/src/mailman/web/Cgi/roster.py @@ -0,0 +1,130 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Produce subscriber roster, using listinfo form data, roster.html template. + +Takes listname in PATH_INFO. +""" + + +# We don't need to lock in this script, because we're never going to change +# data. + +import os +import cgi +import sys +import urllib +import logging + +from Mailman import Errors +from Mailman import MailList +from Mailman import Utils +from Mailman import i18n +from Mailman.configuration import config +from Mailman.htmlformat import * + +# Set up i18n +_ = i18n._ +i18n.set_language(config.DEFAULT_SERVER_LANGUAGE) + +log = logging.getLogger('mailman.error') + + + +def main(): + parts = Utils.GetPathPieces() + if not parts: + error_page(_('Invalid options to CGI script')) + return + + listname = parts[0].lower() + try: + mlist = MailList.MailList(listname, lock=0) + except Errors.MMListError, e: + # Avoid cross-site scripting attacks + safelistname = Utils.websafe(listname) + error_page(_('No such list <em>%(safelistname)s</em>')) + log.error('roster: no such list "%s": %s', listname, e) + return + + cgidata = cgi.FieldStorage() + + # messages in form should go in selected language (if any...) + lang = cgidata.getvalue('language') + if lang not in config.languages.enabled_codes: + lang = mlist.preferred_language + i18n.set_language(lang) + + # Perform authentication for protected rosters. If the roster isn't + # protected, then anybody can see the pages. If members-only or + # "admin"-only, then we try to cookie authenticate the user, and failing + # that, we check roster-email and roster-pw fields for a valid password. + # (also allowed: the list moderator, the list admin, and the site admin). + if mlist.private_roster == 0: + # No privacy + ok = 1 + elif mlist.private_roster == 1: + # Members only + addr = cgidata.getvalue('roster-email', '') + password = cgidata.getvalue('roster-pw', '') + ok = mlist.WebAuthenticate((config.AuthUser, + config.AuthListModerator, + config.AuthListAdmin, + config.AuthSiteAdmin), + password, addr) + else: + # Admin only, so we can ignore the address field + password = cgidata.getvalue('roster-pw', '') + ok = mlist.WebAuthenticate((config.AuthListModerator, + config.AuthListAdmin, + config.AuthSiteAdmin), + password) + if not ok: + realname = mlist.real_name + doc = Document() + doc.set_language(lang) + error_page_doc(doc, _('%(realname)s roster authentication failed.')) + doc.AddItem(mlist.GetMailmanFooter()) + print doc.Format() + return + + # The document and its language + doc = HeadlessDocument() + doc.set_language(lang) + + replacements = mlist.GetAllReplacements(lang) + replacements['<mm-displang-box>'] = mlist.FormatButton( + 'displang-button', + text = _('View this page in')) + replacements['<mm-lang-form-start>'] = mlist.FormatFormStart('roster') + doc.AddItem(mlist.ParseTags('roster.html', replacements, lang)) + print doc.Format() + + + +def error_page(errmsg): + doc = Document() + doc.set_language(config.DEFAULT_SERVER_LANGUAGE) + error_page_doc(doc, errmsg) + print doc.Format() + + +def error_page_doc(doc, errmsg, *args): + # Produce a simple error-message page on stdout and exit. + doc.SetTitle(_("Error")) + doc.AddItem(Header(2, _("Error"))) + doc.AddItem(Bold(errmsg % args)) diff --git a/src/mailman/web/Cgi/subscribe.py b/src/mailman/web/Cgi/subscribe.py new file mode 100644 index 000000000..0eefd0111 --- /dev/null +++ b/src/mailman/web/Cgi/subscribe.py @@ -0,0 +1,252 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Process subscription or roster requests from listinfo form.""" + +from __future__ import with_statement + +import os +import cgi +import sys +import logging + +from Mailman import Errors +from Mailman import MailList +from Mailman import Message +from Mailman import Utils +from Mailman import i18n +from Mailman.UserDesc import UserDesc +from Mailman.configuration import config +from Mailman.htmlformat import * + +SLASH = '/' +ERRORSEP = '\n\n<p>' + +# Set up i18n +_ = i18n._ +i18n.set_language(config.DEFAULT_SERVER_LANGUAGE) + +log = logging.getLogger('mailman.error') +mlog = logging.getLogger('mailman.mischief') + + + +def main(): + doc = Document() + doc.set_language(config.DEFAULT_SERVER_LANGUAGE) + + parts = Utils.GetPathPieces() + if not parts: + doc.AddItem(Header(2, _("Error"))) + doc.AddItem(Bold(_('Invalid options to CGI script'))) + print doc.Format() + return + + listname = parts[0].lower() + try: + mlist = MailList.MailList(listname, lock=0) + except Errors.MMListError, e: + # Avoid cross-site scripting attacks + safelistname = Utils.websafe(listname) + doc.AddItem(Header(2, _("Error"))) + doc.AddItem(Bold(_('No such list <em>%(safelistname)s</em>'))) + print doc.Format() + log.error('No such list "%s": %s\n', listname, e) + return + + # See if the form data has a preferred language set, in which case, use it + # for the results. If not, use the list's preferred language. + cgidata = cgi.FieldStorage() + language = cgidata.getvalue('language') + if language not in config.languages.enabled_codes: + language = mlist.preferred_language + i18n.set_language(language) + doc.set_language(language) + + mlist.Lock() + try: + process_form(mlist, doc, cgidata, language) + mlist.Save() + finally: + mlist.Unlock() + + + +def process_form(mlist, doc, cgidata, lang): + listowner = mlist.GetOwnerEmail() + realname = mlist.real_name + results = [] + + # The email address being subscribed, required + email = cgidata.getvalue('email', '') + if not email: + results.append(_('You must supply a valid email address.')) + + fullname = cgidata.getvalue('fullname', '') + # Canonicalize the full name + fullname = Utils.canonstr(fullname, lang) + # Who was doing the subscribing? + remote = os.environ.get('REMOTE_HOST', + os.environ.get('REMOTE_ADDR', + 'unidentified origin')) + # Was an attempt made to subscribe the list to itself? + if email == mlist.GetListEmail(): + mlog.error('Attempt to self subscribe %s: %s', email, remote) + results.append(_('You may not subscribe a list to itself!')) + # If the user did not supply a password, generate one for him + password = cgidata.getvalue('pw') + confirmed = cgidata.getvalue('pw-conf') + + if password is None and confirmed is None: + password = Utils.MakeRandomPassword() + elif password is None or confirmed is None: + results.append(_('If you supply a password, you must confirm it.')) + elif password <> confirmed: + results.append(_('Your passwords did not match.')) + + # Get the digest option for the subscription. + digestflag = cgidata.getvalue('digest') + if digestflag: + try: + digest = int(digestflag) + except ValueError: + digest = 0 + else: + digest = mlist.digest_is_default + + # Sanity check based on list configuration. BAW: It's actually bogus that + # the page allows you to set the digest flag if you don't really get the + # choice. :/ + if not mlist.digestable: + digest = 0 + elif not mlist.nondigestable: + digest = 1 + + if results: + print_results(mlist, ERRORSEP.join(results), doc, lang) + return + + # If this list has private rosters, we have to be careful about the + # message that gets printed, otherwise the subscription process can be + # used to mine for list members. It may be inefficient, but it's still + # possible, and that kind of defeats the purpose of private rosters. + # We'll use this string for all successful or unsuccessful subscription + # results. + if mlist.private_roster == 0: + # Public rosters + privacy_results = '' + else: + privacy_results = _("""\ +Your subscription request has been received, and will soon be acted upon. +Depending on the configuration of this mailing list, your subscription request +may have to be first confirmed by you via email, or approved by the list +moderator. If confirmation is required, you will soon get a confirmation +email which contains further instructions.""") + + try: + userdesc = UserDesc(email, fullname, password, digest, lang) + mlist.AddMember(userdesc, remote) + results = '' + # Check for all the errors that mlist.AddMember can throw options on the + # web page for this cgi + except Errors.MembershipIsBanned: + results = _("""The email address you supplied is banned from this + mailing list. If you think this restriction is erroneous, please + contact the list owners at %(listowner)s.""") + except Errors.InvalidEmailAddress: + results = _('The email address you supplied is not valid.') + except Errors.MMSubscribeNeedsConfirmation: + # Results string depends on whether we have private rosters or not + if privacy_results: + results = privacy_results + else: + results = _("""\ +Confirmation from your email address is required, to prevent anyone from +subscribing you without permission. Instructions are being sent to you at +%(email)s. Please note your subscription will not start until you confirm +your subscription.""") + except Errors.MMNeedApproval, x: + # Results string depends on whether we have private rosters or not + if privacy_results: + results = privacy_results + else: + # We need to interpolate into x + x = _(x) + results = _("""\ +Your subscription request was deferred because %(x)s. Your request has been +forwarded to the list moderator. You will receive email informing you of the +moderator's decision when they get to your request.""") + except Errors.MMAlreadyAMember: + # Results string depends on whether we have private rosters or not + if not privacy_results: + results = _('You are already subscribed.') + else: + results = privacy_results + # This could be a membership probe. For safety, let the user know + # a probe occurred. BAW: should we inform the list moderator? + listaddr = mlist.GetListEmail() + # Set the language for this email message to the member's language. + mlang = mlist.getMemberLanguage(email) + with i18n.using_language(mlang): + msg = Message.UserNotification( + mlist.getMemberCPAddress(email), + mlist.GetBouncesEmail(), + _('Mailman privacy alert'), + _("""\ +An attempt was made to subscribe your address to the mailing list +%(listaddr)s. You are already subscribed to this mailing list. + +Note that the list membership is not public, so it is possible that a bad +person was trying to probe the list for its membership. This would be a +privacy violation if we let them do this, but we didn't. + +If you submitted the subscription request and forgot that you were already +subscribed to the list, then you can ignore this message. If you suspect that +an attempt is being made to covertly discover whether you are a member of this +list, and you are worried about your privacy, then feel free to send a message +to the list administrator at %(listowner)s. +"""), lang=mlang) + msg.send(mlist) + # These shouldn't happen unless someone's tampering with the form + except Errors.MMCantDigestError: + results = _('This list does not support digest delivery.') + except Errors.MMMustDigestError: + results = _('This list only supports digest delivery.') + else: + # Everything's cool. Our return string actually depends on whether + # this list has private rosters or not + if privacy_results: + results = privacy_results + else: + results = _("""\ +You have been successfully subscribed to the %(realname)s mailing list.""") + # Show the results + print_results(mlist, results, doc, lang) + + + +def print_results(mlist, results, doc, lang): + # The bulk of the document will come from the options.html template, which + # includes its own html armor (head tags, etc.). Suppress the head that + # Document() derived pages get automatically. + doc.suppress_head = 1 + + replacements = mlist.GetStandardReplacements(lang) + replacements['<mm-results>'] = results + output = mlist.ParseTags('subscribe.html', replacements, lang) + doc.AddItem(output) + print doc.Format() diff --git a/src/mailman/web/Cgi/wsgi_app.py b/src/mailman/web/Cgi/wsgi_app.py new file mode 100644 index 000000000..b22dbf452 --- /dev/null +++ b/src/mailman/web/Cgi/wsgi_app.py @@ -0,0 +1,286 @@ +# Copyright (C) 2006-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +import os +import re +import sys + +from cStringIO import StringIO +from email import message_from_string +from urlparse import urlparse + +from Mailman.configuration import config + +# XXX Should this be configurable in Defaults.py? +STEALTH_MODE = False +MOVED_RESPONSE = '302 Found' +# Above is for debugging convenience. We should use: +# MOVED_RESPONSE = '301 Moved Permanently' + + + +def websafe(s): + return s + + +SCRIPTS = ['admin', 'admindb', 'confirm', 'create', + 'edithtml', 'listinfo', 'options', 'private', + 'rmlist', 'roster', 'subscribe'] +ARCHVIEW = ['private'] + +SLASH = '/' +NL2 = '\n\n' +CRLF2 = '\r\n\r\n' + +dotonly = re.compile(r'^\.+$') + + + +# WSGI to CGI wrapper. Mostly copied from scripts/driver. +def mailman_app(environ, start_response): + """Wrapper to *.py CGI commands""" + global STEALTH_MODE, websafe + try: + try: + if not STEALTH_MODE: + from Mailman.Utils import websafe + except: + STEALTH_MODE = True + raise + + import logging + log = logging.getLogger('mailman.error') + + from Mailman import i18n + i18n.set_language(config.DEFAULT_SERVER_LANGUAGE) + + path = environ['PATH_INFO'] + paths = path.split(SLASH) + # sanity check for paths + spaths = [ i for i in paths[1:] if i and not dotonly.match(i) ] + if spaths and spaths != paths[1:]: + newpath = SLASH + SLASH.join(spaths) + start_response(MOVED_RESPONSE, [('Location', newpath)]) + return 'Location: ' + newpath + # find script name + for script in SCRIPTS: + if script in spaths: + # Get script position in spaths and break. + scrpos = spaths.index(script) + break + else: + # Can't find valid script. + start_response('404 Not Found', []) + return '404 Not Found' + # Compose CGI SCRIPT_NAME and PATH_INFO from WSGI path. + script_name = SLASH + SLASH.join(spaths[:scrpos+1]) + environ['SCRIPT_NAME'] = script_name + if len(paths) > scrpos+2: + path_info = SLASH + SLASH.join(paths[scrpos+2:]) + if script in ARCHVIEW \ + and path_info.count('/') in (1,2) \ + and not paths[-1].split('.')[-1] in ('html', 'txt', 'gz'): + # Add index.html if /private/listname or + # /private/listname/YYYYmm is requested. + newpath = script_name + path_info + '/index.html' + start_response(MOVED_RESPONSE, [('Location', newpath)]) + return 'Location: ' + newpath + environ['PATH_INFO'] = path_info + else: + environ['PATH_INFO'] = '' + # Reverse proxy environment. + if environ.has_key('HTTP_X_FORWARDED_HOST'): + environ['HTTP_HOST'] = environ['HTTP_X_FORWARDED_HOST'] + if environ.has_key('HTTP_X_FORWARDED_FOR'): + environ['REMOTE_HOST'] = environ['HTTP_X_FORWARDED_FOR'] + modname = 'Mailman.Cgi.' + script + # Clear previous cookie before setting new one. + os.environ['HTTP_COOKIE'] = '' + for k, v in environ.items(): + os.environ[k] = str(v) + # Prepare for redirection + save_stdin = sys.stdin + # CGI writes its output to sys.stdout, while wsgi app should + # return (list of) strings. + save_stdout = sys.stdout + save_stderr = sys.stderr + tmpstdout = StringIO() + tmpstderr = StringIO() + response = '' + try: + try: + sys.stdin = environ['wsgi.input'] + sys.stdout = tmpstdout + sys.stderr = tmpstderr + __import__(modname) + sys.modules[modname].main() + response = sys.stdout.getvalue() + finally: + sys.stdin = save_stdin + sys.stdout = save_stdout + sys.stderr = save_stderr + except SystemExit: + sys.stdout.write(tmpstdout.getvalue()) + if response: + try: + head, content = response.split(NL2, 1) + except ValueError: + head, content = response.split(CRLF2, 1) + m = message_from_string(head + CRLF2) + start_response('200 OK', m.items()) + return [content] + else: + # TBD: Error Code/Message + start_response('500 Server Error', []) + return '500 Internal Server Error' + except: + start_response('200 OK', [('Content-Type', 'text/html')]) + retstring = print_traceback(log) + retstring += print_environment(log) + return retstring + + + +# These functions are extracted and modified from scripts/driver. +# +# If possible, we print the error to two places. One will always be stdout +# and the other will be the log file if a log file was created. It is assumed +# that stdout is an HTML sink. +def print_traceback(log=None): + try: + import traceback + except ImportError: + traceback = None + try: + from mailman.version import VERSION + except ImportError: + VERSION = '<undetermined>' + + # Write to the log file first. + if log: + outfp = StringIO() + + print >> outfp, '@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@' + print >> outfp, '[----- Mailman Version: %s -----]' % VERSION + print >> outfp, '[----- Traceback ------]' + if traceback: + traceback.print_exc(file=outfp) + else: + print >> outfp, '[failed to import module traceback]' + print >> outfp, '[exc: %s, var: %s]' % sys.exc_info()[0:2] + # Don't use .exception() since that'll give us the exception twice. + # IWBNI we could print directly to the log's stream, or treat a log + # like an output stream. + log.error('%s', outfp.getvalue()) + + # return HTML sink. + htfp = StringIO() + print >> htfp, """\ +<head><title>Bug in Mailman version %(VERSION)s</title></head> +<body bgcolor=#ffffff><h2>Bug in Mailman version %(VERSION)s</h2> +<p><h3>We're sorry, we hit a bug!</h3> +""" % locals() + if not STEALTH_MODE: + print >> htfp, '''<p>If you would like to help us identify the problem, +please email a copy of this page to the webmaster for this site with +a description of what happened. Thanks! + +<h4>Traceback:</h4><p><pre>''' + exc_info = sys.exc_info() + if traceback: + for line in traceback.format_exception(*exc_info): + print >> htfp, websafe(line) + else: + print >> htfp, '[failed to import module traceback]' + print >> htfp, '[exc: %s, var: %s]' %\ + [websafe(x) for x in exc_info[0:2]] + print >> htfp, '\n\n</pre></body>' + else: + print >> htfp, '''<p>Please inform the webmaster for this site of this +problem. Printing of traceback and other system information has been +explicitly inhibited, but the webmaster can find this information in the +Mailman error logs.''' + return htfp.getvalue() + + + +def print_environment(log=None): + try: + import os + except ImportError: + os = None + + if log: + outfp = StringIO() + + # Write some information about our Python executable to the log file. + print >> outfp, '[----- Python Information -----]' + print >> outfp, 'sys.version =', sys.version + print >> outfp, 'sys.executable =', sys.executable + print >> outfp, 'sys.prefix =', sys.prefix + print >> outfp, 'sys.exec_prefix =', sys.exec_prefix + print >> outfp, 'sys.path =', sys.exec_prefix + print >> outfp, 'sys.platform =', sys.platform + + # Write the same information to the HTML sink. + htfp = StringIO() + if not STEALTH_MODE: + print >> htfp, """\ +<p><hr><h4>Python information:</h4> + +<p><table> +<tr><th>Variable</th><th>Value</th></tr> +<tr><td><tt>sys.version</tt></td><td> %s </td></tr> +<tr><td><tt>sys.executable</tt></td><td> %s </td></tr> +<tr><td><tt>sys.prefix</tt></td><td> %s </td></tr> +<tr><td><tt>sys.exec_prefix</tt></td><td> %s </td></tr> +<tr><td><tt>sys.path</tt></td><td> %s </td></tr> +<tr><td><tt>sys.platform</tt></td><td> %s </td></tr> +</table>""" % (sys.version, sys.executable, sys.prefix, + sys.exec_prefix, sys.path, sys.platform) + + # Write environment variables to the log file. + if log: + print >> outfp, '[----- Environment Variables -----]' + if os: + for k, v in os.environ.items(): + print >> outfp, '\t%s: %s' % (k, v) + else: + print >> outfp, '[failed to import module os]' + + # Write environment variables to the HTML sink. + if not STEALTH_MODE: + print >> htfp, """\ +<p><hr><h4>Environment variables:</h4> + +<p><table> +<tr><th>Variable</th><th>Value</th></tr> +""" + if os: + for k, v in os.environ.items(): + print >> htfp, '<tr><td><tt>' + websafe(k) + \ + '</tt></td><td>' + websafe(v) + \ + '</td></tr>' + print >> htfp, '</table>' + else: + print >> htfp, '<p><hr>[failed to import module os]' + + # Dump the log output + if log: + log.error('%s', outfp.getvalue()) + + return htfp.getvalue() diff --git a/src/mailman/web/Gui/Archive.py b/src/mailman/web/Gui/Archive.py new file mode 100644 index 000000000..4090e74e9 --- /dev/null +++ b/src/mailman/web/Gui/Archive.py @@ -0,0 +1,45 @@ +# Copyright (C) 2001-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +from Mailman.Gui.GUIBase import GUIBase +from Mailman.configuration import config +from Mailman.i18n import _ + + + +class Archive(GUIBase): + def GetConfigCategory(self): + return 'archive', _('Archiving Options') + + def GetConfigInfo(self, mlist, category, subcat=None): + if category <> 'archive': + return None + return [ + _("List traffic archival policies."), + + ('archive', config.Toggle, (_('No'), _('Yes')), 0, + _('Archive messages?')), + + ('archive_private', config.Radio, (_('public'), _('private')), 0, + _('Is archive file source for public or private archival?')), + + ('archive_volume_frequency', config.Radio, + (_('Yearly'), _('Monthly'), _('Quarterly'), + _('Weekly'), _('Daily')), + 0, + _('How often should a new archive volume be started?')), + ] diff --git a/src/mailman/web/Gui/Autoresponse.py b/src/mailman/web/Gui/Autoresponse.py new file mode 100644 index 000000000..72ef42cad --- /dev/null +++ b/src/mailman/web/Gui/Autoresponse.py @@ -0,0 +1,99 @@ +# Copyright (C) 2001-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Administrative GUI for the autoresponder.""" + +from Mailman import Utils +from Mailman.Gui.GUIBase import GUIBase +from Mailman.configuration import config +from Mailman.i18n import _ + +# These are the allowable string substitution variables +ALLOWEDS = ('listname', 'listurl', 'requestemail', 'adminemail', 'owneremail') + + + +class Autoresponse(GUIBase): + def GetConfigCategory(self): + return 'autoreply', _('Auto-responder') + + def GetConfigInfo(self, mlist, category, subcat=None): + if category <> 'autoreply': + return None + WIDTH = config.TEXTFIELDWIDTH + + return [ + _("""\ +Auto-responder characteristics.<p> + +In the text fields below, string interpolation is performed with +the following key/value substitutions: +<p><ul> + <li><b>listname</b> - <em>gets the name of the mailing list</em> + <li><b>listurl</b> - <em>gets the list's listinfo URL</em> + <li><b>requestemail</b> - <em>gets the list's -request address</em> + <li><b>owneremail</b> - <em>gets the list's -owner address</em> +</ul> + +<p>For each text field, you can either enter the text directly into the text +box, or you can specify a file on your local system to upload as the text."""), + + ('autorespond_postings', config.Toggle, (_('No'), _('Yes')), 0, + _('''Should Mailman send an auto-response to mailing list + posters?''')), + + ('autoresponse_postings_text', config.FileUpload, + (6, WIDTH), 0, + _('Auto-response text to send to mailing list posters.')), + + ('autorespond_admin', config.Toggle, (_('No'), _('Yes')), 0, + _('''Should Mailman send an auto-response to emails sent to the + -owner address?''')), + + ('autoresponse_admin_text', config.FileUpload, + (6, WIDTH), 0, + _('Auto-response text to send to -owner emails.')), + + ('autorespond_requests', config.Radio, + (_('No'), _('Yes, w/discard'), _('Yes, w/forward')), 0, + _('''Should Mailman send an auto-response to emails sent to the + -request address? If you choose yes, decide whether you want + Mailman to discard the original email, or forward it on to the + system as a normal mail command.''')), + + ('autoresponse_request_text', config.FileUpload, + (6, WIDTH), 0, + _('Auto-response text to send to -request emails.')), + + ('autoresponse_graceperiod', config.Number, 3, 0, + _('''Number of days between auto-responses to either the mailing + list or -request/-owner address from the same poster. Set to + zero (or negative) for no grace period (i.e. auto-respond to + every message).''')), + ] + + def _setValue(self, mlist, property, val, doc): + # Handle these specially because we may need to convert to/from + # external $-string representation. + if property in ('autoresponse_postings_text', + 'autoresponse_admin_text', + 'autoresponse_request_text'): + val = self._convertString(mlist, property, ALLOWEDS, val, doc) + if val is None: + # There was a problem, so don't set it + return + GUIBase._setValue(self, mlist, property, val, doc) diff --git a/src/mailman/web/Gui/Bounce.py b/src/mailman/web/Gui/Bounce.py new file mode 100644 index 000000000..a2f6f887a --- /dev/null +++ b/src/mailman/web/Gui/Bounce.py @@ -0,0 +1,195 @@ +# Copyright (C) 2001-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +from Mailman.Gui.GUIBase import GUIBase +from Mailman.configuration import config +from Mailman.i18n import _ + + + +class Bounce(GUIBase): + def GetConfigCategory(self): + return 'bounce', _('Bounce processing') + + def GetConfigInfo(self, mlist, category, subcat=None): + if category <> 'bounce': + return None + return [ + _("""These policies control the automatic bounce processing system + in Mailman. Here's an overview of how it works. + + <p>When a bounce is received, Mailman tries to extract two pieces + of information from the message: the address of the member the + message was intended for, and the severity of the problem causing + the bounce. The severity can be either <em>hard</em> or + <em>soft</em> meaning either a fatal error occurred, or a + transient error occurred. When in doubt, a hard severity is used. + + <p>If no member address can be extracted from the bounce, then the + bounce is usually discarded. Otherwise, each member is assigned a + <em>bounce score</em> and every time we encounter a bounce from + this member we increment the score. Hard bounces increment by 1 + while soft bounces increment by 0.5. We only increment the bounce + score once per day, so even if we receive ten hard bounces from a + member per day, their score will increase by only 1 for that day. + + <p>When a member's bounce score is greater than the + <a href="?VARHELP=bounce/bounce_score_threshold">bounce score + threshold</a>, the subscription is disabled. Once disabled, the + member will not receive any postings from the list until their + membership is explicitly re-enabled (either by the list + administrator or the user). However, they will receive occasional + reminders that their membership has been disabled, and these + reminders will include information about how to re-enable their + membership. + + <p>You can control both the + <a href="?VARHELP=bounce/bounce_you_are_disabled_warnings">number + of reminders</a> the member will receive and the + <a href="?VARHELP=bounce/bounce_you_are_disabled_warnings_interval" + >frequency</a> with which these reminders are sent. + + <p>There is one other important configuration variable; after a + certain period of time -- during which no bounces from the member + are received -- the bounce information is + <a href="?VARHELP=bounce/bounce_info_stale_after">considered + stale</a> and discarded. Thus by adjusting this value, and the + score threshold, you can control how quickly bouncing members are + disabled. You should tune both of these to the frequency and + traffic volume of your list."""), + + _('Bounce detection sensitivity'), + + ('bounce_processing', config.Toggle, (_('No'), _('Yes')), 0, + _('Should Mailman perform automatic bounce processing?'), + _("""By setting this value to <em>No</em>, you disable all + automatic bounce processing for this list, however bounce + messages will still be discarded so that the list administrator + isn't inundated with them.""")), + + ('bounce_score_threshold', config.Number, 5, 0, + _("""The maximum member bounce score before the member's + subscription is disabled. This value can be a floating point + number."""), + _("""Each subscriber is assigned a bounce score, as a floating + point number. Whenever Mailman receives a bounce from a list + member, that member's score is incremented. Hard bounces (fatal + errors) increase the score by 1, while soft bounces (temporary + errors) increase the score by 0.5. Only one bounce per day + counts against a member's score, so even if 10 bounces are + received for a member on the same day, their score will increase + by just 1. + + This variable describes the upper limit for a member's bounce + score, above which they are automatically disabled, but not + removed from the mailing list.""")), + + ('bounce_info_stale_after', config.Number, 5, 0, + _("""The number of days after which a member's bounce information + is discarded, if no new bounces have been received in the + interim. This value must be an integer.""")), + + ('bounce_you_are_disabled_warnings', config.Number, 5, 0, + _("""How many <em>Your Membership Is Disabled</em> warnings a + disabled member should get before their address is removed from + the mailing list. Set to 0 to immediately remove an address from + the list once their bounce score exceeds the threshold. This + value must be an integer.""")), + + ('bounce_you_are_disabled_warnings_interval', config.Number, 5, 0, + _("""The number of days between sending the <em>Your Membership + Is Disabled</em> warnings. This value must be an integer.""")), + + _('Notifications'), + + ('bounce_unrecognized_goes_to_list_owner', config.Toggle, + (_('No'), _('Yes')), 0, + _('''Should Mailman send you, the list owner, any bounce messages + that failed to be detected by the bounce processor? <em>Yes</em> + is recommended.'''), + _("""While Mailman's bounce detector is fairly robust, it's + impossible to detect every bounce format in the world. You + should keep this variable set to <em>Yes</em> for two reasons: 1) + If this really is a permanent bounce from one of your members, + you should probably manually remove them from your list, and 2) + you might want to send the message on to the Mailman developers + so that this new format can be added to its known set. + + <p>If you really can't be bothered, then set this variable to + <em>No</em> and all non-detected bounces will be discarded + without further processing. + + <p><b>Note:</b> This setting will also affect all messages sent + to your list's -admin address. This address is deprecated and + should never be used, but some people may still send mail to this + address. If this happens, and this variable is set to + <em>No</em> those messages too will get discarded. You may want + to set up an + <a href="?VARHELP=autoreply/autoresponse_admin_text">autoresponse + message</a> for email to the -owner and -admin address.""")), + + ('bounce_notify_owner_on_disable', config.Toggle, + (_('No'), _('Yes')), 0, + _("""Should Mailman notify you, the list owner, when bounces + cause a member's subscription to be disabled?"""), + _("""By setting this value to <em>No</em>, you turn off + notification messages that are normally sent to the list owners + when a member's delivery is disabled due to excessive bounces. + An attempt to notify the member will always be made.""")), + + ('bounce_notify_owner_on_removal', config.Toggle, + (_('No'), _('Yes')), 0, + _("""Should Mailman notify you, the list owner, when bounces + cause a member to be unsubscribed?"""), + _("""By setting this value to <em>No</em>, you turn off + notification messages that are normally sent to the list owners + when a member is unsubscribed due to excessive bounces. An + attempt to notify the member will always be made.""")), + + ] + + def _setValue(self, mlist, property, val, doc): + # Do value conversion from web representation to internal + # representation. + try: + if property == 'bounce_processing': + val = int(val) + elif property == 'bounce_score_threshold': + val = float(val) + elif property == 'bounce_info_stale_after': + val = config.days(int(val)) + elif property == 'bounce_you_are_disabled_warnings': + val = int(val) + elif property == 'bounce_you_are_disabled_warnings_interval': + val = config.days(int(val)) + elif property == 'bounce_notify_owner_on_disable': + val = int(val) + elif property == 'bounce_notify_owner_on_removal': + val = int(val) + except ValueError: + doc.addError( + _("""Bad value for <a href="?VARHELP=bounce/%(property)s" + >%(property)s</a>: %(val)s"""), + tag = _('Error: ')) + return + GUIBase._setValue(self, mlist, property, val, doc) + + def getValue(self, mlist, kind, varname, params): + if varname not in ('bounce_info_stale_after', + 'bounce_you_are_disabled_warnings_interval'): + return None + return int(getattr(mlist, varname) / config.days(1)) diff --git a/src/mailman/web/Gui/ContentFilter.py b/src/mailman/web/Gui/ContentFilter.py new file mode 100644 index 000000000..09817f4aa --- /dev/null +++ b/src/mailman/web/Gui/ContentFilter.py @@ -0,0 +1,199 @@ +# Copyright (C) 2002-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""GUI component managing the content filtering options.""" + +from Mailman.Gui.GUIBase import GUIBase +from Mailman.configuration import config +from Mailman.i18n import _ + +NL = '\n' + + + +class ContentFilter(GUIBase): + def GetConfigCategory(self): + return 'contentfilter', _('Content filtering') + + def GetConfigInfo(self, mlist, category, subcat=None): + if category <> 'contentfilter': + return None + WIDTH = config.TEXTFIELDWIDTH + + actions = [_('Discard'), _('Reject'), _('Forward to List Owner')] + if config.OWNERS_CAN_PRESERVE_FILTERED_MESSAGES: + actions.append(_('Preserve')) + + return [ + _("""Policies concerning the content of list traffic. + + <p>Content filtering works like this: when a message is + received by the list and you have enabled content filtering, the + individual attachments are first compared to the + <a href="?VARHELP=contentfilter/filter_mime_types">filter + types</a>. If the attachment type matches an entry in the filter + types, it is discarded. + + <p>Then, if there are <a + href="?VARHELP=contentfilter/pass_mime_types">pass types</a> + defined, any attachment type that does <em>not</em> match a + pass type is also discarded. If there are no pass types defined, + this check is skipped. + + <p>After this initial filtering, any <tt>multipart</tt> + attachments that are empty are removed. If the outer message is + left empty after this filtering, then the whole message is + discarded. + + <p> Then, each <tt>multipart/alternative</tt> section will + be replaced by just the first alternative that is non-empty after + filtering if + <a href="?VARHELP=contentfilter/collapse_alternatives" + >collapse_alternatives</a> is enabled. + + <p>Finally, any <tt>text/html</tt> parts that are left in the + message may be converted to <tt>text/plain</tt> if + <a href="?VARHELP=contentfilter/convert_html_to_plaintext" + >convert_html_to_plaintext</a> is enabled and the site is + configured to allow these conversions."""), + + ('filter_content', config.Radio, (_('No'), _('Yes')), 0, + _("""Should Mailman filter the content of list traffic according + to the settings below?""")), + + ('filter_mime_types', config.Text, (10, WIDTH), 0, + _("""Remove message attachments that have a matching content + type."""), + + _("""Use this option to remove each message attachment that + matches one of these content types. Each line should contain a + string naming a MIME <tt>type/subtype</tt>, + e.g. <tt>image/gif</tt>. Leave off the subtype to remove all + parts with a matching major content type, e.g. <tt>image</tt>. + + <p>Blank lines are ignored. + + <p>See also <a href="?VARHELP=contentfilter/pass_mime_types" + >pass_mime_types</a> for a content type whitelist.""")), + + ('pass_mime_types', config.Text, (10, WIDTH), 0, + _("""Remove message attachments that don't have a matching + content type. Leave this field blank to skip this filter + test."""), + + _("""Use this option to remove each message attachment that does + not have a matching content type. Requirements and formats are + exactly like <a href="?VARHELP=contentfilter/filter_mime_types" + >filter_mime_types</a>. + + <p><b>Note:</b> if you add entries to this list but don't add + <tt>multipart</tt> to this list, any messages with attachments + will be rejected by the pass filter.""")), + + ('filter_filename_extensions', config.Text, (10, WIDTH), 0, + _("""Remove message attachments that have a matching filename + extension."""),), + + ('pass_filename_extensions', config.Text, (10, WIDTH), 0, + _("""Remove message attachments that don't have a matching + filename extension. Leave this field blank to skip this filter + test."""),), + + ('collapse_alternatives', config.Radio, (_('No'), _('Yes')), 0, + _("""Should Mailman collapse multipart/alternative to its + first part content?""")), + + ('convert_html_to_plaintext', config.Radio, (_('No'), _('Yes')), 0, + _("""Should Mailman convert <tt>text/html</tt> parts to plain + text? This conversion happens after MIME attachments have been + stripped.""")), + + ('filter_action', config.Radio, tuple(actions), 0, + + _("""Action to take when a message matches the content filtering + rules."""), + + _("""One of these actions is take when the message matches one of + the content filtering rules, meaning, the top-level + content type matches one of the <a + href="?VARHELP=contentfilter/filter_mime_types" + >filter_mime_types</a>, or the top-level content type does + <strong>not</strong> match one of the + <a href="?VARHELP=contentfilter/pass_mime_types" + >pass_mime_types</a>, or if after filtering the subparts of the + message, the message ends up empty. + + <p>Note this action is not taken if after filtering the message + still contains content. In that case the message is always + forwarded on to the list membership. + + <p>When messages are discarded, a log entry is written + containing the Message-ID of the discarded message. When + messages are rejected or forwarded to the list owner, a reason + for the rejection is included in the bounce message to the + original author. When messages are preserved, they are saved in + a special queue directory on disk for the site administrator to + view (and possibly rescue) but otherwise discarded. This last + option is only available if enabled by the site + administrator.""")), + ] + + def _setValue(self, mlist, property, val, doc): + if property in ('filter_mime_types', 'pass_mime_types'): + types = [] + for spectype in [s.strip() for s in val.splitlines()]: + ok = 1 + slashes = spectype.count('/') + if slashes == 0 and not spectype: + ok = 0 + elif slashes == 1: + maintype, subtype = [s.strip().lower() + for s in spectype.split('/')] + if not maintype or not subtype: + ok = 0 + elif slashes > 1: + ok = 0 + if not ok: + doc.addError(_('Bad MIME type ignored: %(spectype)s')) + else: + types.append(spectype.strip().lower()) + if property == 'filter_mime_types': + mlist.filter_mime_types = types + elif property == 'pass_mime_types': + mlist.pass_mime_types = types + elif property in ('filter_filename_extensions', + 'pass_filename_extensions'): + fexts = [] + for ext in [s.strip() for s in val.splitlines()]: + fexts.append(ext.lower()) + if property == 'filter_filename_extensions': + mlist.filter_filename_extensions = fexts + elif property == 'pass_filename_extensions': + mlist.pass_filename_extensions = fexts + else: + GUIBase._setValue(self, mlist, property, val, doc) + + def getValue(self, mlist, kind, property, params): + if property == 'filter_mime_types': + return NL.join(mlist.filter_mime_types) + if property == 'pass_mime_types': + return NL.join(mlist.pass_mime_types) + if property == 'filter_filename_extensions': + return NL.join(mlist.filter_filename_extensions) + if property == 'pass_filename_extensions': + return NL.join(mlist.pass_filename_extensions) + return None diff --git a/src/mailman/web/Gui/Digest.py b/src/mailman/web/Gui/Digest.py new file mode 100644 index 000000000..821d0684f --- /dev/null +++ b/src/mailman/web/Gui/Digest.py @@ -0,0 +1,161 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Administrative GUI for digest deliveries.""" + +from Mailman import Utils +from Mailman.configuration import config +from Mailman.i18n import _ + +# Intra-package import +from Mailman.Gui.GUIBase import GUIBase + +# Common b/w nondigest and digest headers & footers. Personalizations may add +# to this. +ALLOWEDS = ('real_name', 'list_name', 'host_name', 'web_page_url', + 'description', 'info', 'cgiext', '_internal_name', + ) + + + +class Digest(GUIBase): + def GetConfigCategory(self): + return 'digest', _('Digest options') + + def GetConfigInfo(self, mlist, category, subcat=None): + if category <> 'digest': + return None + WIDTH = config.TEXTFIELDWIDTH + + info = [ + _("Batched-delivery digest characteristics."), + + ('digestable', config.Toggle, (_('No'), _('Yes')), 1, + _('Can list members choose to receive list traffic ' + 'bunched in digests?')), + + ('digest_is_default', config.Radio, + (_('Regular'), _('Digest')), 0, + _('Which delivery mode is the default for new users?')), + + ('mime_is_default_digest', config.Radio, + (_('Plain'), _('MIME')), 0, + _('When receiving digests, which format is default?')), + + ('digest_size_threshhold', config.Number, 3, 0, + _('How big in Kb should a digest be before it gets sent out?')), + # Should offer a 'set to 0' for no size threshhold. + + ('digest_send_periodic', config.Radio, (_('No'), _('Yes')), 1, + _('Should a digest be dispatched daily when the size threshold ' + "isn't reached?")), + + ('digest_header', config.Text, (4, WIDTH), 0, + _('Header added to every digest'), + _("Text attached (as an initial message, before the table" + " of contents) to the top of digests. ") + + Utils.maketext('headfoot.html', raw=1, mlist=mlist)), + + ('digest_footer', config.Text, (4, WIDTH), 0, + _('Footer added to every digest'), + _("Text attached (as a final message) to the bottom of digests. ") + + Utils.maketext('headfoot.html', raw=1, mlist=mlist)), + + ('digest_volume_frequency', config.Radio, + (_('Yearly'), _('Monthly'), _('Quarterly'), + _('Weekly'), _('Daily')), 0, + _('How often should a new digest volume be started?'), + _('''When a new digest volume is started, the volume number is + incremented and the issue number is reset to 1.''')), + + ('_new_volume', config.Toggle, (_('No'), _('Yes')), 0, + _('Should Mailman start a new digest volume?'), + _('''Setting this option instructs Mailman to start a new volume + with the next digest sent out.''')), + + ('_send_digest_now', config.Toggle, (_('No'), _('Yes')), 0, + _('''Should Mailman send the next digest right now, if it is not + empty?''')), + ] + +## if config.OWNERS_CAN_ENABLE_PERSONALIZATION: +## info.extend([ +## ('digest_personalize', config.Toggle, (_('No'), _('Yes')), 1, + +## _('''Should Mailman personalize each digest delivery? +## This is often useful for announce-only lists, but <a +## href="?VARHELP=digest/digest_personalize">read the details</a> +## section for a discussion of important performance +## issues.'''), + +## _("""Normally, Mailman sends the digest messages to +## the mail server in batches. This is much more efficent +## because it reduces the amount of traffic between Mailman and +## the mail server. + +## <p>However, some lists can benefit from a more personalized +## approach. In this case, Mailman crafts a new message for +## each member on the digest delivery list. Turning this on +## adds a few more expansion variables that can be included in +## the <a href="?VARHELP=digest/digest_header">message header</a> +## and <a href="?VARHELP=digest/digest_footer">message footer</a> +## but it may degrade the performance of your site as +## a whole. + +## <p>You need to carefully consider whether the trade-off is +## worth it, or whether there are other ways to accomplish what +## you want. You should also carefully monitor your system load +## to make sure it is acceptable. + +## <p>These additional substitution variables will be available +## for your headers and footers, when this feature is enabled: + +## <ul><li><b>user_address</b> - The address of the user, +## coerced to lower case. +## <li><b>user_delivered_to</b> - The case-preserved address +## that the user is subscribed with. +## <li><b>user_password</b> - The user's password. +## <li><b>user_name</b> - The user's full name. +## <li><b>user_optionsurl</b> - The url to the user's option +## page. +## """)) +## ]) + + return info + + def _setValue(self, mlist, property, val, doc): + # Watch for the special, immediate action attributes + if property == '_new_volume' and val: + mlist.bump_digest_volume() + volume = mlist.volume + number = mlist.next_digest_number + doc.AddItem(_("""The next digest will be sent as volume + %(volume)s, number %(number)s""")) + elif property == '_send_digest_now' and val: + status = mlist.send_digest_now() + if status: + doc.AddItem(_("""A digest has been sent.""")) + else: + doc.AddItem(_("""There was no digest to send.""")) + else: + # Everything else... + if property in ('digest_header', 'digest_footer'): + val = self._convertString(mlist, property, ALLOWEDS, val, doc) + if val is None: + # There was a problem, so don't set it + return + GUIBase._setValue(self, mlist, property, val, doc) diff --git a/src/mailman/web/Gui/GUIBase.py b/src/mailman/web/Gui/GUIBase.py new file mode 100644 index 000000000..b2846eb7b --- /dev/null +++ b/src/mailman/web/Gui/GUIBase.py @@ -0,0 +1,209 @@ +# Copyright (C) 2002-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Base class for all web GUI components.""" + +import re + +from Mailman import Defaults +from Mailman import Errors +from Mailman import Utils +from Mailman.i18n import _ + +NL = '\n' +BADJOINER = '</code>, <code>' + + + +class GUIBase: + # Providing a common interface for GUI component form processing. Most + # GUI components won't need to override anything, but some may want to + # override _setValue() to provide some specialized processing for some + # attributes. + def _getValidValue(self, mlist, property, wtype, val): + # Coerce and validate the new value. + # + # Radio buttons and boolean toggles both have integral type + if wtype in (Defaults.Radio, Defaults.Toggle): + # Let ValueErrors propagate + return int(val) + # String and Text widgets both just return their values verbatim + # but convert into unicode (for 2.2) + if wtype in (Defaults.String, Defaults.Text): + return unicode(val, Utils.GetCharSet(mlist.preferred_language)) + # This widget contains a single email address + if wtype == Defaults.Email: + # BAW: We must allow blank values otherwise reply_to_address can't + # be cleared. This is currently the only Defaults.Email type + # widget in the interface, so watch out if we ever add any new + # ones. + if val: + # Let InvalidEmailAddress propagate. + Utils.ValidateEmail(val) + return val + # These widget types contain lists of email addresses, one per line. + # The EmailListEx allows each line to contain either an email address + # or a regular expression + if wtype in (Defaults.EmailList, Defaults.EmailListEx): + # BAW: value might already be a list, if this is coming from + # config_list input. Sigh. + if isinstance(val, list): + return val + addrs = [] + for addr in [s.strip() for s in val.split(NL)]: + # Discard empty lines + if not addr: + continue + try: + # This throws an exception if the address is invalid + Utils.ValidateEmail(addr) + except Errors.EmailAddressError: + # See if this is a context that accepts regular + # expressions, and that the re is legal + if wtype == Defaults.EmailListEx and addr.startswith('^'): + try: + re.compile(addr) + except re.error: + raise ValueError + else: + raise + addrs.append(addr) + return addrs + # This is a host name, i.e. verbatim + if wtype == Defaults.Host: + return val + # This is a number, either a float or an integer + if wtype == Defaults.Number: + num = -1 + try: + num = int(val) + except ValueError: + # Let ValueErrors percolate up + num = float(val) + if num < 0: + return getattr(mlist, property) + return num + # This widget is a select box, i.e. verbatim + if wtype == Defaults.Select: + return val + # Checkboxes return a list of the selected items, even if only one is + # selected. + if wtype == Defaults.Checkbox: + if isinstance(val, list): + return val + return [val] + if wtype == Defaults.FileUpload: + return val + if wtype == Defaults.Topics: + return val + if wtype == Defaults.HeaderFilter: + return val + # Should never get here + assert 0, 'Bad gui widget type: %s' % wtype + + def _setValue(self, mlist, property, val, doc): + # Set the value, or override to take special action on the property + if not property.startswith('_') and getattr(mlist, property) <> val: + setattr(mlist, property, val) + + def _postValidate(self, mlist, doc): + # Validate all the attributes for this category + pass + + def _escape(self, property, value): + value = value.replace('<', '<') + return value + + def handleForm(self, mlist, category, subcat, cgidata, doc): + for item in self.GetConfigInfo(mlist, category, subcat): + # Skip descriptions and legacy non-attributes + if not isinstance(item, tuple) or len(item) < 5: + continue + # Unpack the gui item description + property, wtype, args, deps, desc = item[0:5] + # BAW: I know this code is a little crufty but I wanted to + # reproduce the semantics of the original code in admin.py as + # closely as possible, for now. We can clean it up later. + # + # The property may be uploadable... + uploadprop = property + '_upload' + if cgidata.has_key(uploadprop) and cgidata[uploadprop].value: + val = cgidata[uploadprop].value + elif not cgidata.has_key(property): + continue + elif isinstance(cgidata[property], list): + val = [self._escape(property, x.value) + for x in cgidata[property]] + else: + val = self._escape(property, cgidata[property].value) + # Coerce the value to the expected type, raising exceptions if the + # value is invalid. + try: + val = self._getValidValue(mlist, property, wtype, val) + except ValueError: + doc.addError(_('Invalid value for variable: %(property)s')) + # This is the parent of InvalidEmailAddress + except Errors.EmailAddressError: + doc.addError( + _('Bad email address for option %(property)s: %(val)s')) + else: + # Set the attribute, which will normally delegate to the mlist + self._setValue(mlist, property, val, doc) + # Do a final sweep once all the attributes have been set. This is how + # we can do cross-attribute assertions + self._postValidate(mlist, doc) + + # Convenience method for handling $-string attributes + def _convertString(self, mlist, property, alloweds, val, doc): + # Is the list using $-strings? + dollarp = getattr(mlist, 'use_dollar_strings', 0) + if dollarp: + ids = Utils.dollar_identifiers(val) + else: + # %-strings + ids = Utils.percent_identifiers(val) + # Here's the list of allowable interpolations + for allowed in alloweds: + if ids.has_key(allowed): + del ids[allowed] + if ids: + # What's left are not allowed + badkeys = ids.keys() + badkeys.sort() + bad = BADJOINER.join(badkeys) + doc.addError(_( + """The following illegal substitution variables were + found in the <code>%(property)s</code> string: + <code>%(bad)s</code> + <p>Your list may not operate properly until you correct this + problem."""), tag=_('Warning: ')) + return val + # Now if we're still using %-strings, do a roundtrip conversion and + # see if the converted value is the same as the new value. If not, + # then they probably left off a trailing `s'. We'll warn them and use + # the corrected string. + if not dollarp: + fixed = Utils.to_percent(Utils.to_dollar(val)) + if fixed <> val: + doc.addError(_( + """Your <code>%(property)s</code> string appeared to + have some correctable problems in its new value. + The fixed value will be used instead. Please + double check that this is what you intended. + """)) + return fixed + return val diff --git a/src/mailman/web/Gui/General.py b/src/mailman/web/Gui/General.py new file mode 100644 index 000000000..27ef354be --- /dev/null +++ b/src/mailman/web/Gui/General.py @@ -0,0 +1,464 @@ +# Copyright (C) 2001-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""MailList mixin class managing the general options.""" + +import re + +from Mailman import Errors +from Mailman import Utils +from Mailman.Gui.GUIBase import GUIBase +from Mailman.configuration import config +from Mailman.i18n import _ + +OPTIONS = ('hide', 'ack', 'notmetoo', 'nodupes') + + + +class General(GUIBase): + def GetConfigCategory(self): + return 'general', _('General Options') + + def GetConfigInfo(self, mlist, category, subcat): + if category <> 'general': + return None + WIDTH = config.TEXTFIELDWIDTH + + # These are for the default_options checkboxes below. + bitfields = {'hide' : config.ConcealSubscription, + 'ack' : config.AcknowledgePosts, + 'notmetoo' : config.DontReceiveOwnPosts, + 'nodupes' : config.DontReceiveDuplicates + } + bitdescrs = { + 'hide' : _("Conceal the member's address"), + 'ack' : _("Acknowledge the member's posting"), + 'notmetoo' : _("Do not send a copy of a member's own post"), + 'nodupes' : + _('Filter out duplicate messages to list members (if possible)'), + } + + optvals = [mlist.new_member_options & bitfields[o] for o in OPTIONS] + opttext = [bitdescrs[o] for o in OPTIONS] + + rtn = [ + _('''Fundamental list characteristics, including descriptive + info and basic behaviors.'''), + + _('General list personality'), + + ('real_name', config.String, WIDTH, 0, + _('The public name of this list (make case-changes only).'), + _('''The capitalization of this name can be changed to make it + presentable in polite company as a proper noun, or to make an + acronym part all upper case, etc. However, the name will be + advertised as the email address (e.g., in subscribe confirmation + notices), so it should <em>not</em> be otherwise altered. (Email + addresses are not case sensitive, but they are sensitive to + almost everything else :-)''')), + + ('owner', config.EmailList, (3, WIDTH), 0, + _("""The list administrator email addresses. Multiple + administrator addresses, each on separate line is okay."""), + + _('''There are two ownership roles associated with each mailing + list. The <em>list administrators</em> are the people who have + ultimate control over all parameters of this mailing list. They + are able to change any list configuration variable available + through these administration web pages. + + <p>The <em>list moderators</em> have more limited permissions; + they are not able to change any list configuration variable, but + they are allowed to tend to pending administration requests, + including approving or rejecting held subscription requests, and + disposing of held postings. Of course, the <em>list + administrators</em> can also tend to pending requests. + + <p>In order to split the list ownership duties into + administrators and moderators, you must + <a href="passwords">set a separate moderator password</a>, + and also provide the <a href="?VARHELP=general/moderator">email + addresses of the list moderators</a>. Note that the field you + are changing here specifies the list administrators.''')), + + ('moderator', config.EmailList, (3, WIDTH), 0, + _("""The list moderator email addresses. Multiple + moderator addresses, each on separate line is okay."""), + + _('''There are two ownership roles associated with each mailing + list. The <em>list administrators</em> are the people who have + ultimate control over all parameters of this mailing list. They + are able to change any list configuration variable available + through these administration web pages. + + <p>The <em>list moderators</em> have more limited permissions; + they are not able to change any list configuration variable, but + they are allowed to tend to pending administration requests, + including approving or rejecting held subscription requests, and + disposing of held postings. Of course, the <em>list + administrators</em> can also tend to pending requests. + + <p>In order to split the list ownership duties into + administrators and moderators, you must + <a href="passwords">set a separate moderator password</a>, + and also provide the email addresses of the list moderators in + this section. Note that the field you are changing here + specifies the list moderators.''')), + + ('description', config.String, WIDTH, 0, + _('A terse phrase identifying this list.'), + + _('''This description is used when the mailing list is listed with + other mailing lists, or in headers, and so forth. It should + be as succinct as you can get it, while still identifying what + the list is.''')), + + ('info', config.Text, (7, WIDTH), 0, + _('''An introductory description - a few paragraphs - about the + list. It will be included, as html, at the top of the listinfo + page. Carriage returns will end a paragraph - see the details + for more info.'''), + _("""The text will be treated as html <em>except</em> that + newlines will be translated to <br> - so you can use links, + preformatted text, etc, but don't put in carriage returns except + where you mean to separate paragraphs. And review your changes - + bad html (like some unterminated HTML constructs) can prevent + display of the entire listinfo page.""")), + + ('subject_prefix', config.String, WIDTH, 0, + _('Prefix for subject line of list postings.'), + _("""This text will be prepended to subject lines of messages + posted to the list, to distinguish mailing list messages in in + mailbox summaries. Brevity is premium here, it's ok to shorten + long mailing list names to something more concise, as long as it + still identifies the mailing list. + You can also add a sequencial number by %%d substitution + directive. eg.; [listname %%d] -> [listname 123] + (listname %%05d) -> (listname 00123) + """)), + + ('anonymous_list', config.Radio, (_('No'), _('Yes')), 0, + _("""Hide the sender of a message, replacing it with the list + address (Removes From, Sender and Reply-To fields)""")), + + _('''<tt>Reply-To:</tt> header munging'''), + + ('first_strip_reply_to', config.Radio, (_('No'), _('Yes')), 0, + _('''Should any existing <tt>Reply-To:</tt> header found in the + original message be stripped? If so, this will be done + regardless of whether an explict <tt>Reply-To:</tt> header is + added by Mailman or not.''')), + + ('reply_goes_to_list', config.Radio, + (_('Poster'), _('This list'), _('Explicit address')), 0, + _('''Where are replies to list messages directed? + <tt>Poster</tt> is <em>strongly</em> recommended for most mailing + lists.'''), + + # Details for reply_goes_to_list + _("""This option controls what Mailman does to the + <tt>Reply-To:</tt> header in messages flowing through this + mailing list. When set to <em>Poster</em>, no <tt>Reply-To:</tt> + header is added by Mailman, although if one is present in the + original message, it is not stripped. Setting this value to + either <em>This list</em> or <em>Explicit address</em> causes + Mailman to insert a specific <tt>Reply-To:</tt> header in all + messages, overriding the header in the original message if + necessary (<em>Explicit address</em> inserts the value of <a + href="?VARHELP=general/reply_to_address">reply_to_address</a>). + + <p>There are many reasons not to introduce or override the + <tt>Reply-To:</tt> header. One is that some posters depend on + their own <tt>Reply-To:</tt> settings to convey their valid + return address. Another is that modifying <tt>Reply-To:</tt> + makes it much more difficult to send private replies. See <a + href="http://www.unicom.com/pw/reply-to-harmful.html">`Reply-To' + Munging Considered Harmful</a> for a general discussion of this + issue. See <a + href="http://www.metasystema.net/essays/reply-to.mhtml">Reply-To + Munging Considered Useful</a> for a dissenting opinion. + + <p>Some mailing lists have restricted posting privileges, with a + parallel list devoted to discussions. Examples are `patches' or + `checkin' lists, where software changes are posted by a revision + control system, but discussion about the changes occurs on a + developers mailing list. To support these types of mailing + lists, select <tt>Explicit address</tt> and set the + <tt>Reply-To:</tt> address below to point to the parallel + list.""")), + + ('reply_to_address', config.Email, WIDTH, 0, + _('Explicit <tt>Reply-To:</tt> header.'), + # Details for reply_to_address + _("""This is the address set in the <tt>Reply-To:</tt> header + when the <a + href="?VARHELP=general/reply_goes_to_list">reply_goes_to_list</a> + option is set to <em>Explicit address</em>. + + <p>There are many reasons not to introduce or override the + <tt>Reply-To:</tt> header. One is that some posters depend on + their own <tt>Reply-To:</tt> settings to convey their valid + return address. Another is that modifying <tt>Reply-To:</tt> + makes it much more difficult to send private replies. See <a + href="http://www.unicom.com/pw/reply-to-harmful.html">`Reply-To' + Munging Considered Harmful</a> for a general discussion of this + issue. See <a + href="http://www.metasystema.net/essays/reply-to.mhtml">Reply-To + Munging Considered Useful</a> for a dissenting opinion. + + <p>Some mailing lists have restricted posting privileges, with a + parallel list devoted to discussions. Examples are `patches' or + `checkin' lists, where software changes are posted by a revision + control system, but discussion about the changes occurs on a + developers mailing list. To support these types of mailing + lists, specify the explicit <tt>Reply-To:</tt> address here. You + must also specify <tt>Explicit address</tt> in the + <tt>reply_goes_to_list</tt> + variable. + + <p>Note that if the original message contains a + <tt>Reply-To:</tt> header, it will not be changed.""")), + + _('Umbrella list settings'), + + ('umbrella_list', config.Radio, (_('No'), _('Yes')), 0, + _('''Send password reminders to, eg, "-owner" address instead of + directly to user.'''), + + _("""Set this to yes when this list is intended to cascade only + to other mailing lists. When set, meta notices like + confirmations and password reminders will be directed to an + address derived from the member\'s address - it will have the + value of "umbrella_member_suffix" appended to the member's + account name.""")), + + ('umbrella_member_suffix', config.String, WIDTH, 0, + _('''Suffix for use when this list is an umbrella for other + lists, according to setting of previous "umbrella_list" + setting.'''), + + _("""When "umbrella_list" is set to indicate that this list has + other mailing lists as members, then administrative notices like + confirmations and password reminders need to not be sent to the + member list addresses, but rather to the owner of those member + lists. In that case, the value of this setting is appended to + the member's account name for such notices. `-owner' is the + typical choice. This setting has no effect when "umbrella_list" + is "No".""")), + + _('Notifications'), + + ('send_reminders', config.Radio, (_('No'), _('Yes')), 0, + _('''Send monthly password reminders?'''), + + _('''Turn this on if you want password reminders to be sent once + per month to your members. Note that members may disable their + own individual password reminders.''')), + + ('welcome_msg', config.Text, (4, WIDTH), 0, + _('''List-specific text prepended to new-subscriber welcome + message'''), + + _("""This value, if any, will be added to the front of the + new-subscriber welcome message. The rest of the welcome message + already describes the important addresses and URLs for the + mailing list, so you don't need to include any of that kind of + stuff here. This should just contain mission-specific kinds of + things, like etiquette policies or team orientation, or that kind + of thing. + + <p>Note that this text will be wrapped, according to the + following rules: + <ul><li>Each paragraph is filled so that no line is longer than + 70 characters. + <li>Any line that begins with whitespace is not filled. + <li>A blank line separates paragraphs. + </ul>""")), + + ('send_welcome_msg', config.Radio, (_('No'), _('Yes')), 0, + _('Send welcome message to newly subscribed members?'), + _("""Turn this off only if you plan on subscribing people manually + and don't want them to know that you did so. This option is most + useful for transparently migrating lists from some other mailing + list manager to Mailman.""")), + + ('goodbye_msg', config.Text, (4, WIDTH), 0, + _('''Text sent to people leaving the list. If empty, no special + text will be added to the unsubscribe message.''')), + + ('send_goodbye_msg', config.Radio, (_('No'), _('Yes')), 0, + _('Send goodbye message to members when they are unsubscribed?')), + + ('admin_immed_notify', config.Radio, (_('No'), _('Yes')), 0, + _('''Should the list moderators get immediate notice of new + requests, as well as daily notices about collected ones?'''), + + _('''List moderators (and list administrators) are sent daily + reminders of requests pending approval, like subscriptions to a + moderated list, or postings that are being held for one reason or + another. Setting this option causes notices to be sent + immediately on the arrival of new requests as well.''')), + + ('admin_notify_mchanges', config.Radio, (_('No'), _('Yes')), 0, + _('''Should administrator get notices of subscribes and + unsubscribes?''')), + + ('respond_to_post_requests', config.Radio, + (_('No'), _('Yes')), 0, + _('Send mail to poster when their posting is held for approval?') + ), + + _('Additional settings'), + + ('emergency', config.Toggle, (_('No'), _('Yes')), 0, + _('Emergency moderation of all list traffic.'), + _("""When this option is enabled, all list traffic is emergency + moderated, i.e. held for moderation. Turn this option on when + your list is experiencing a flamewar and you want a cooling off + period.""")), + + ('new_member_options', config.Checkbox, + (opttext, optvals, 0, OPTIONS), + # The description for new_member_options includes a kludge where + # we add a hidden field so that even when all the checkboxes are + # deselected, the form data will still have a new_member_options + # key (it will always be a list). Otherwise, we'd never be able + # to tell if all were deselected! + 0, _('''Default options for new members joining this list.<input + type="hidden" name="new_member_options" value="ignore">'''), + + _("""When a new member is subscribed to this list, their initial + set of options is taken from the this variable's setting.""")), + + ('administrivia', config.Radio, (_('No'), _('Yes')), 0, + _('''(Administrivia filter) Check postings and intercept ones + that seem to be administrative requests?'''), + + _("""Administrivia tests will check postings to see whether it's + really meant as an administrative request (like subscribe, + unsubscribe, etc), and will add it to the the administrative + requests queue, notifying the administrator of the new request, + in the process.""")), + + ('max_message_size', config.Number, 7, 0, + _('''Maximum length in kilobytes (KB) of a message body. Use 0 + for no limit.''')), + + ('host_name', config.Host, WIDTH, 0, + _('Host name this list prefers for email.'), + + _("""The "host_name" is the preferred name for email to + mailman-related addresses on this host, and generally should be + the mail host's exchanger address, if any. This setting can be + useful for selecting among alternative names of a host that has + multiple addresses.""")), + + ] + + if config.ALLOW_RFC2369_OVERRIDES: + rtn.append( + ('include_rfc2369_headers', config.Radio, + (_('No'), _('Yes')), 0, + _("""Should messages from this mailing list include the + <a href="http://www.faqs.org/rfcs/rfc2369.html">RFC 2369</a> + (i.e. <tt>List-*</tt>) headers? <em>Yes</em> is highly + recommended."""), + + _("""RFC 2369 defines a set of List-* headers that are + normally added to every message sent to the list membership. + These greatly aid end-users who are using standards compliant + mail readers. They should normally always be enabled. + + <p>However, not all mail readers are standards compliant yet, + and if you have a large number of members who are using + non-compliant mail readers, they may be annoyed at these + headers. You should first try to educate your members as to + why these headers exist, and how to hide them in their mail + clients. As a last resort you can disable these headers, but + this is not recommended (and in fact, your ability to disable + these headers may eventually go away).""")) + ) + # Suppression of List-Post: headers + rtn.append( + ('include_list_post_header', config.Radio, + (_('No'), _('Yes')), 0, + _('Should postings include the <tt>List-Post:</tt> header?'), + _("""The <tt>List-Post:</tt> header is one of the headers + recommended by + <a href="http://www.faqs.org/rfcs/rfc2369.html">RFC 2369</a>. + However for some <em>announce-only</em> mailing lists, only a + very select group of people are allowed to post to the list; the + general membership is usually not allowed to post. For lists of + this nature, the <tt>List-Post:</tt> header is misleading. + Select <em>No</em> to disable the inclusion of this header. (This + does not affect the inclusion of the other <tt>List-*:</tt> + headers.)""")) + ) + + # Discard held messages after this number of days + rtn.append( + ('max_days_to_hold', config.Number, 7, 0, + _("""Discard held messages older than this number of days. + Use 0 for no automatic discarding.""")) + ) + + return rtn + + def _setValue(self, mlist, property, val, doc): + if property == 'real_name' and \ + val.lower() <> mlist.internal_name().lower(): + # These values can't differ by other than case + doc.addError(_("""<b>real_name</b> attribute not + changed! It must differ from the list's name by case + only.""")) + elif property == 'new_member_options': + newopts = 0 + for opt in OPTIONS: + bitfield = config.OPTINFO[opt] + if opt in val: + newopts |= bitfield + mlist.new_member_options = newopts + elif property == 'subject_prefix': + # Convert any html entities to Unicode + mlist.subject_prefix = Utils.canonstr( + val, mlist.preferred_language) + else: + GUIBase._setValue(self, mlist, property, val, doc) + + def _escape(self, property, value): + # The 'info' property allows HTML, but lets sanitize it to avoid XSS + # exploits. Everything else should be fully escaped. + if property <> 'info': + return GUIBase._escape(self, property, value) + # Sanitize <script> and </script> tags but nothing else. Not the best + # solution, but expedient. + return re.sub(r'<([/]?script.*?)>', r'<\1>', value) + + def _postValidate(self, mlist, doc): + if not mlist.reply_to_address.strip() and \ + mlist.reply_goes_to_list == 2: + # You can't go to an explicit address that is blank + doc.addError(_("""You cannot add a Reply-To: to an explicit + address if that address is blank. Resetting these values.""")) + mlist.reply_to_address = '' + mlist.reply_goes_to_list = 0 + + def getValue(self, mlist, kind, varname, params): + if varname <> 'subject_prefix': + return None + # The subject_prefix may be Unicode + return Utils.uncanonstr(mlist.subject_prefix, mlist.preferred_language) diff --git a/src/mailman/web/Gui/Language.py b/src/mailman/web/Gui/Language.py new file mode 100644 index 000000000..05824be5e --- /dev/null +++ b/src/mailman/web/Gui/Language.py @@ -0,0 +1,128 @@ +# Copyright (C) 2001-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""MailList mixin class managing the language options.""" + +import codecs + +from Mailman import Utils +from Mailman import i18n +from Mailman.Gui.GUIBase import GUIBase +from Mailman.configuration import config + +_ = i18n._ + + + +class Language(GUIBase): + def GetConfigCategory(self): + return 'language', _('Language options') + + def GetConfigInfo(self, mlist, category, subcat=None): + if category <> 'language': + return None + # Set things up for the language choices + langs = mlist.language_codes + langnames = [_(description) for description in config.enabled_names] + try: + langi = langs.index(mlist.preferred_language) + except ValueError: + # Someone must have deleted the list's preferred language. Could + # be other trouble lurking! + langi = 0 + # Only allow the admin to choose a language if the system has a + # charset for it. I think this is the best way to test for that. + def checkcodec(charset): + try: + codecs.lookup(charset) + return 1 + except LookupError: + return 0 + + all = sorted(code for code in config.languages.enabled_codes + if checkcodec(Utils.GetCharSet(code))) + checked = [L in langs for L in all] + allnames = [_(config.languages.get_description(code)) for code in all] + return [ + _('Natural language (internationalization) options.'), + + ('preferred_language', config.Select, + (langs, langnames, langi), + 0, + _('Default language for this list.'), + _('''This is the default natural language for this mailing list. + If <a href="?VARHELP=language/available_languages">more than one + language</a> is supported then users will be able to select their + own preferences for when they interact with the list. All other + interactions will be conducted in the default language. This + applies to both web-based and email-based messages, but not to + email posted by list members.''')), + + ('available_languages', config.Checkbox, + (allnames, checked, 0, all), 0, + _('Languages supported by this list.'), + + _('''These are all the natural languages supported by this list. + Note that the + <a href="?VARHELP=language/preferred_language">default + language</a> must be included.''')), + + ('encode_ascii_prefixes', config.Radio, + (_('Never'), _('Always'), _('As needed')), 0, + _("""Encode the + <a href="?VARHELP=general/subject_prefix">subject + prefix</a> even when it consists of only ASCII characters?"""), + + _("""If your mailing list's default language uses a non-ASCII + character set and the prefix contains non-ASCII characters, the + prefix will always be encoded according to the relevant + standards. However, if your prefix contains only ASCII + characters, you may want to set this option to <em>Never</em> to + disable prefix encoding. This can make the subject headers + slightly more readable for users with mail readers that don't + properly handle non-ASCII encodings. + + <p>Note however, that if your mailing list receives both encoded + and unencoded subject headers, you might want to choose <em>As + needed</em>. Using this setting, Mailman will not encode ASCII + prefixes when the rest of the header contains only ASCII + characters, but if the original header contains non-ASCII + characters, it will encode the prefix. This avoids an ambiguity + in the standards which could cause some mail readers to display + extra, or missing spaces between the prefix and the original + header.""")), + + ] + + def _setValue(self, mlist, prop, val, doc): + # If we're changing the list's preferred language, change the I18N + # context as well + if prop == 'preferred_language': + i18n.set_language(val) + doc.set_language(val) + # Language codes must be wrapped + if prop == 'available_languages': + mlist.set_languages(*val) + else: + GUIBase._setValue(self, mlist, prop, val, doc) + + def getValue(self, mlist, kind, varname, params): + if varname == 'available_languages': + # Unwrap Language instances, to return just the code + return [language.code for language in mlist.available_languages] + # Returning None tells the infrastructure to use getattr + return None diff --git a/src/mailman/web/Gui/Membership.py b/src/mailman/web/Gui/Membership.py new file mode 100644 index 000000000..bbfdb438b --- /dev/null +++ b/src/mailman/web/Gui/Membership.py @@ -0,0 +1,34 @@ +# Copyright (C) 2001-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""MailList mixin class managing the membership pseudo-options.""" + +from Mailman.i18n import _ + + + +class Membership: + def GetConfigCategory(self): + return 'members', _('Membership Management...') + + def GetConfigSubCategories(self, category): + if category == 'members': + return [('list', _('Membership List')), + ('add', _('Mass Subscription')), + ('remove', _('Mass Removal')), + ] + return None diff --git a/src/mailman/web/Gui/NonDigest.py b/src/mailman/web/Gui/NonDigest.py new file mode 100644 index 000000000..92fb768ad --- /dev/null +++ b/src/mailman/web/Gui/NonDigest.py @@ -0,0 +1,158 @@ +# Copyright (C) 2001-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""GUI component for managing the non-digest delivery options.""" + +from Mailman import Utils +from Mailman import Defaults +from Mailman.i18n import _ +from Mailman.configuration import config +from Mailman.Gui.GUIBase import GUIBase + +from Mailman.Gui.Digest import ALLOWEDS +PERSONALIZED_ALLOWEDS = ('user_address', 'user_delivered_to', 'user_password', + 'user_name', 'user_optionsurl', + ) + + + +class NonDigest(GUIBase): + def GetConfigCategory(self): + return 'nondigest', _('Non-digest options') + + def GetConfigInfo(self, mlist, category, subcat=None): + if category <> 'nondigest': + return None + WIDTH = config.TEXTFIELDWIDTH + + info = [ + _("Policies concerning immediately delivered list traffic."), + + ('nondigestable', Defaults.Toggle, (_('No'), _('Yes')), 1, + _("""Can subscribers choose to receive mail immediately, rather + than in batched digests?""")), + ] + + if config.OWNERS_CAN_ENABLE_PERSONALIZATION: + info.extend([ + ('personalize', Defaults.Radio, + (_('No'), _('Yes'), _('Full Personalization')), 1, + + _('''Should Mailman personalize each non-digest delivery? + This is often useful for announce-only lists, but <a + href="?VARHELP=nondigest/personalize">read the details</a> + section for a discussion of important performance + issues.'''), + + _("""Normally, Mailman sends the regular delivery messages to + the mail server in batches. This is much more efficent + because it reduces the amount of traffic between Mailman and + the mail server. + + <p>However, some lists can benefit from a more personalized + approach. In this case, Mailman crafts a new message for + each member on the regular delivery list. Turning this + feature on may degrade the performance of your site, so you + need to carefully consider whether the trade-off is worth it, + or whether there are other ways to accomplish what you want. + You should also carefully monitor your system load to make + sure it is acceptable. + + <p>Select <em>No</em> to disable personalization and send + messages to the members in batches. Select <em>Yes</em> to + personalize deliveries and allow additional substitution + variables in message headers and footers (see below). In + addition, by selecting <em>Full Personalization</em>, the + <code>To</code> header of posted messages will be modified to + include the member's address instead of the list's posting + address. + + <p>When personalization is enabled, a few more expansion + variables that can be included in the <a + href="?VARHELP=nondigest/msg_header">message header</a> and + <a href="?VARHELP=nondigest/msg_footer">message footer</a>. + + <p>These additional substitution variables will be available + for your headers and footers, when this feature is enabled: + + <ul><li><b>user_address</b> - The address of the user, + coerced to lower case. + <li><b>user_delivered_to</b> - The case-preserved address + that the user is subscribed with. + <li><b>user_password</b> - The user's password. + <li><b>user_name</b> - The user's full name. + <li><b>user_optionsurl</b> - The url to the user's option + page. + </ul> + """)) + ]) + # BAW: for very dumb reasons, we want the `personalize' attribute to + # show up before the msg_header and msg_footer attrs, otherwise we'll + # get a bogus warning if the header/footer contains a personalization + # substitution variable, and we're transitioning from no + # personalization to personalization enabled. + headfoot = Utils.maketext('headfoot.html', mlist=mlist, raw=1) + if config.OWNERS_CAN_ENABLE_PERSONALIZATION: + extra = _("""\ +When <a href="?VARHELP=nondigest/personalize">personalization</a> is enabled +for this list, additional substitution variables are allowed in your headers +and footers: + +<ul><li><b>user_address</b> - The address of the user, + coerced to lower case. + <li><b>user_delivered_to</b> - The case-preserved address + that the user is subscribed with. + <li><b>user_password</b> - The user's password. + <li><b>user_name</b> - The user's full name. + <li><b>user_optionsurl</b> - The url to the user's option + page. +</ul> +""") + else: + extra = '' + + info.extend([('msg_header', Defaults.Text, (10, WIDTH), 0, + _('Header added to mail sent to regular list members'), + _('''Text prepended to the top of every immediately-delivery + message. ''') + headfoot + extra), + + ('msg_footer', Defaults.Text, (10, WIDTH), 0, + _('Footer added to mail sent to regular list members'), + _('''Text appended to the bottom of every immediately-delivery + message. ''') + headfoot + extra), + ]) + + info.extend([ + ('scrub_nondigest', Defaults.Toggle, (_('No'), _('Yes')), 0, + _('Scrub attachments of regular delivery message?'), + _('''When you scrub attachments, they are stored in archive + area and links are made in the message so that the member can + access via web browser. If you want the attachments totally + disappear, you can use content filter options.''')), + ]) + return info + + def _setValue(self, mlist, property, val, doc): + alloweds = list(ALLOWEDS) + if mlist.personalize: + alloweds.extend(PERSONALIZED_ALLOWEDS) + if property in ('msg_header', 'msg_footer'): + val = self._convertString(mlist, property, alloweds, val, doc) + if val is None: + # There was a problem, so don't set it + return + GUIBase._setValue(self, mlist, property, val, doc) diff --git a/src/mailman/web/Gui/Passwords.py b/src/mailman/web/Gui/Passwords.py new file mode 100644 index 000000000..b2fea0fb5 --- /dev/null +++ b/src/mailman/web/Gui/Passwords.py @@ -0,0 +1,31 @@ +# Copyright (C) 2001-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""MailList mixin class managing the password pseudo-options.""" + +from Mailman.i18n import _ +from Mailman.Gui.GUIBase import GUIBase + + + +class Passwords(GUIBase): + def GetConfigCategory(self): + return 'passwords', _('Passwords') + + def handleForm(self, mlist, category, subcat, cgidata, doc): + # Nothing more needs to be done + pass diff --git a/src/mailman/web/Gui/Privacy.py b/src/mailman/web/Gui/Privacy.py new file mode 100644 index 000000000..8d60f9203 --- /dev/null +++ b/src/mailman/web/Gui/Privacy.py @@ -0,0 +1,537 @@ +# Copyright (C) 2001-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""MailList mixin class managing the privacy options.""" + +import re + +from Mailman import Utils +from Mailman.Gui.GUIBase import GUIBase +from Mailman.configuration import config +from Mailman.i18n import _ + + + +class Privacy(GUIBase): + def GetConfigCategory(self): + return 'privacy', _('Privacy options...') + + def GetConfigSubCategories(self, category): + if category == 'privacy': + return [('subscribing', _('Subscription rules')), + ('sender', _('Sender filters')), + ('recipient', _('Recipient filters')), + ('spam', _('Spam filters')), + ] + return None + + def GetConfigInfo(self, mlist, category, subcat=None): + if category <> 'privacy': + return None + # Pre-calculate some stuff. Technically, we shouldn't do the + # sub_cfentry calculation here, but it's too ugly to indent it any + # further, and besides, that'll mess up i18n catalogs. + WIDTH = config.TEXTFIELDWIDTH + if config.ALLOW_OPEN_SUBSCRIBE: + sub_cfentry = ('subscribe_policy', config.Radio, + # choices + (_('None'), + _('Confirm'), + _('Require approval'), + _('Confirm and approve')), + 0, + _('What steps are required for subscription?<br>'), + _('''None - no verification steps (<em>Not + Recommended </em>)<br> + Confirm (*) - email confirmation step required <br> + Require approval - require list administrator + Approval for subscriptions <br> + Confirm and approve - both confirm and approve + + <p>(*) when someone requests a subscription, + Mailman sends them a notice with a unique + subscription request number that they must reply to + in order to subscribe.<br> + + This prevents mischievous (or malicious) people + from creating subscriptions for others without + their consent.''')) + else: + sub_cfentry = ('subscribe_policy', config.Radio, + # choices + (_('Confirm'), + _('Require approval'), + _('Confirm and approve')), + 1, + _('What steps are required for subscription?<br>'), + _('''Confirm (*) - email confirmation required <br> + Require approval - require list administrator + approval for subscriptions <br> + Confirm and approve - both confirm and approve + + <p>(*) when someone requests a subscription, + Mailman sends them a notice with a unique + subscription request number that they must reply to + in order to subscribe.<br> This prevents + mischievous (or malicious) people from creating + subscriptions for others without their consent.''')) + + # some helpful values + admin = mlist.GetScriptURL('admin') + + subscribing_rtn = [ + _("""This section allows you to configure subscription and + membership exposure policy. You can also control whether this + list is public or not. See also the + <a href="%(admin)s/archive">Archival Options</a> section for + separate archive-related privacy settings."""), + + _('Subscribing'), + ('advertised', config.Radio, (_('No'), _('Yes')), 0, + _('''Advertise this list when people ask what lists are on this + machine?''')), + + sub_cfentry, + + ('subscribe_auto_approval', config.EmailListEx, (10, WIDTH), 1, + _("""List of addresses (or regexps) whose subscriptions do not + require approval."""), + + _("""When subscription requires approval, addresses in this list + are allowed to subscribe without administrator approval. Add + addresses one per line. You may begin a line with a ^ character + to designate a (case insensitive) regular expression match.""")), + + ('unsubscribe_policy', config.Radio, (_('No'), _('Yes')), 0, + _("""Is the list moderator's approval required for unsubscription + requests? (<em>No</em> is recommended)"""), + + _("""When members want to leave a list, they will make an + unsubscription request, either via the web or via email. + Normally it is best for you to allow open unsubscriptions so that + users can easily remove themselves from mailing lists (they get + really upset if they can't get off lists!). + + <p>For some lists though, you may want to impose moderator + approval before an unsubscription request is processed. Examples + of such lists include a corporate mailing list that all employees + are required to be members of.""")), + + _('Ban list'), + ('ban_list', config.EmailListEx, (10, WIDTH), 1, + _("""List of addresses which are banned from membership in this + mailing list."""), + + _("""Addresses in this list are banned outright from subscribing + to this mailing list, with no further moderation required. Add + addresses one per line; start the line with a ^ character to + designate a regular expression match.""")), + + _("Membership exposure"), + ('private_roster', config.Radio, + (_('Anyone'), _('List members'), _('List admin only')), 0, + _('Who can view subscription list?'), + + _('''When set, the list of subscribers is protected by member or + admin password authentication.''')), + + ('obscure_addresses', config.Radio, (_('No'), _('Yes')), 0, + _("""Show member addresses so they're not directly recognizable + as email addresses?"""), + _("""Setting this option causes member email addresses to be + transformed when they are presented on list web pages (both in + text and as links), so they're not trivially recognizable as + email addresses. The intention is to prevent the addresses + from being snarfed up by automated web scanners for use by + spammers.""")), + ] + + adminurl = mlist.GetScriptURL('admin') + sender_rtn = [ + _("""When a message is posted to the list, a series of + moderation steps are take to decide whether the a moderator must + first approve the message or not. This section contains the + controls for moderation of both member and non-member postings. + + <p>Member postings are held for moderation if their + <b>moderation flag</b> is turned on. You can control whether + member postings are moderated by default or not. + + <p>Non-member postings can be automatically + <a href="?VARHELP=privacy/sender/accept_these_nonmembers" + >accepted</a>, + <a href="?VARHELP=privacy/sender/hold_these_nonmembers">held for + moderation</a>, + <a href="?VARHELP=privacy/sender/reject_these_nonmembers" + >rejected</a> (bounced), or + <a href="?VARHELP=privacy/sender/discard_these_nonmembers" + >discarded</a>, + either individually or as a group. Any + posting from a non-member who is not explicitly accepted, + rejected, or discarded, will have their posting filtered by the + <a href="?VARHELP=privacy/sender/generic_nonmember_action">general + non-member rules</a>. + + <p>In the text boxes below, add one address per line; start the + line with a ^ character to designate a <a href= + "http://www.python.org/doc/current/lib/module-re.html" + >Python regular expression</a>. When entering backslashes, do so + as if you were using Python raw strings (i.e. you generally just + use a single backslash). + + <p>Note that non-regexp matches are always done first."""), + + _('Member filters'), + + ('default_member_moderation', config.Radio, (_('No'), _('Yes')), + 0, _('By default, should new list member postings be moderated?'), + + _("""Each list member has a <em>moderation flag</em> which says + whether messages from the list member can be posted directly to + the list, or must first be approved by the list moderator. When + the moderation flag is turned on, list member postings must be + approved first. You, the list administrator can decide whether a + specific individual's postings will be moderated or not. + + <p>When a new member is subscribed, their initial moderation flag + takes its value from this option. Turn this option off to accept + member postings by default. Turn this option on to, by default, + moderate member postings first. You can always manually set an + individual member's moderation bit by using the + <a href="%(adminurl)s/members">membership management + screens</a>.""")), + + ('member_moderation_action', config.Radio, + (_('Hold'), _('Reject'), _('Discard')), 0, + _("""Action to take when a moderated member posts to the + list."""), + _("""<ul><li><b>Hold</b> -- this holds the message for approval + by the list moderators. + + <p><li><b>Reject</b> -- this automatically rejects the message by + sending a bounce notice to the post's author. The text of the + bounce notice can be <a + href="?VARHELP=privacy/sender/member_moderation_notice" + >configured by you</a>. + + <p><li><b>Discard</b> -- this simply discards the message, with + no notice sent to the post's author. + </ul>""")), + + ('member_moderation_notice', config.Text, (10, WIDTH), 1, + _("""Text to include in any + <a href="?VARHELP/privacy/sender/member_moderation_action" + >rejection notice</a> to + be sent to moderated members who post to this list.""")), + + _('Non-member filters'), + + ('accept_these_nonmembers', config.EmailListEx, (10, WIDTH), 1, + _("""List of non-member addresses whose postings should be + automatically accepted."""), + + _("""Postings from any of these non-members will be automatically + accepted with no further moderation applied. Add member + addresses one per line; start the line with a ^ character to + designate a regular expression match.""")), + + ('hold_these_nonmembers', config.EmailListEx, (10, WIDTH), 1, + _("""List of non-member addresses whose postings will be + immediately held for moderation."""), + + _("""Postings from any of these non-members will be immediately + and automatically held for moderation by the list moderators. + The sender will receive a notification message which will allow + them to cancel their held message. Add member addresses one per + line; start the line with a ^ character to designate a regular + expression match.""")), + + ('reject_these_nonmembers', config.EmailListEx, (10, WIDTH), 1, + _("""List of non-member addresses whose postings will be + automatically rejected."""), + + _("""Postings from any of these non-members will be automatically + rejected. In other words, their messages will be bounced back to + the sender with a notification of automatic rejection. This + option is not appropriate for known spam senders; their messages + should be + <a href="?VARHELP=privacy/sender/discard_these_nonmembers" + >automatically discarded</a>. + + <p>Add member addresses one per line; start the line with a ^ + character to designate a regular expression match.""")), + + ('discard_these_nonmembers', config.EmailListEx, (10, WIDTH), 1, + _("""List of non-member addresses whose postings will be + automatically discarded."""), + + _("""Postings from any of these non-members will be automatically + discarded. That is, the message will be thrown away with no + further processing or notification. The sender will not receive + a notification or a bounce, however the list moderators can + optionally <a href="?VARHELP=privacy/sender/forward_auto_discards" + >receive copies of auto-discarded messages.</a>. + + <p>Add member addresses one per line; start the line with a ^ + character to designate a regular expression match.""")), + + ('generic_nonmember_action', config.Radio, + (_('Accept'), _('Hold'), _('Reject'), _('Discard')), 0, + _("""Action to take for postings from non-members for which no + explicit action is defined."""), + + _("""When a post from a non-member is received, the message's + sender is matched against the list of explicitly + <a href="?VARHELP=privacy/sender/accept_these_nonmembers" + >accepted</a>, + <a href="?VARHELP=privacy/sender/hold_these_nonmembers">held</a>, + <a href="?VARHELP=privacy/sender/reject_these_nonmembers" + >rejected</a> (bounced), and + <a href="?VARHELP=privacy/sender/discard_these_nonmembers" + >discarded</a> addresses. If no match is found, then this action + is taken.""")), + + ('forward_auto_discards', config.Radio, (_('No'), _('Yes')), 0, + _("""Should messages from non-members, which are automatically + discarded, be forwarded to the list moderator?""")), + + ('nonmember_rejection_notice', config.Text, (10, WIDTH), 1, + _("""Text to include in any rejection notice to be sent to + non-members who post to this list. This notice can include + the list's owner address by %%(listowner)s and replaces the + internally crafted default message.""")), + + ] + + recip_rtn = [ + _("""This section allows you to configure various filters based on + the recipient of the message."""), + + _('Recipient filters'), + + ('require_explicit_destination', config.Radio, + (_('No'), _('Yes')), 0, + _("""Must posts have list named in destination (to, cc) field + (or be among the acceptable alias names, specified below)?"""), + + _("""Many (in fact, most) spams do not explicitly name their + myriad destinations in the explicit destination addresses - in + fact often the To: field has a totally bogus address for + obfuscation. The constraint applies only to the stuff in the + address before the '@' sign, but still catches all such spams. + + <p>The cost is that the list will not accept unhindered any + postings relayed from other addresses, unless + + <ol> + <li>The relaying address has the same name, or + + <li>The relaying address name is included on the options that + specifies acceptable aliases for the list. + + </ol>""")), + + ('acceptable_aliases', config.Text, (4, WIDTH), 0, + _("""Alias names (regexps) which qualify as explicit to or cc + destination names for this list."""), + + _("""Alternate addresses that are acceptable when + `require_explicit_destination' is enabled. This option takes a + list of regular expressions, one per line, which is matched + against every recipient address in the message. The matching is + performed with Python's re.match() function, meaning they are + anchored to the start of the string. + + <p>For backwards compatibility with Mailman 1.1, if the regexp + does not contain an `@', then the pattern is matched against just + the local part of the recipient address. If that match fails, or + if the pattern does contain an `@', then the pattern is matched + against the entire recipient address. + + <p>Matching against the local part is deprecated; in a future + release, the pattern will always be matched against the entire + recipient address.""")), + + ('max_num_recipients', config.Number, 5, 0, + _('Ceiling on acceptable number of recipients for a posting.'), + + _('''If a posting has this number, or more, of recipients, it is + held for admin approval. Use 0 for no ceiling.''')), + ] + + spam_rtn = [ + _("""This section allows you to configure various anti-spam + filters posting filters, which can help reduce the amount of spam + your list members end up receiving. + """), + + _('Header filters'), + + ('header_filter_rules', config.HeaderFilter, 0, 0, + _('Filter rules to match against the headers of a message.'), + + _("""Each header filter rule has two parts, a list of regular + expressions, one per line, and an action to take. Mailman + matches the message's headers against every regular expression in + the rule and if any match, the message is rejected, held, or + discarded based on the action you specify. Use <em>Defer</em> to + temporarily disable a rule. + + You can have more than one filter rule for your list. In that + case, each rule is matched in turn, with processing stopped after + the first match. + + Note that headers are collected from all the attachments + (except for the mailman administrivia message) and + matched against the regular expressions. With this feature, + you can effectively sort out messages with dangerous file + types or file name extensions.""")), + + _('Legacy anti-spam filters'), + + ('bounce_matching_headers', config.Text, (6, WIDTH), 0, + _('Hold posts with header value matching a specified regexp.'), + _("""Use this option to prohibit posts according to specific + header values. The target value is a regular-expression for + matching against the specified header. The match is done + disregarding letter case. Lines beginning with '#' are ignored + as comments. + + <p>For example:<pre>to: .*@public.com </pre> says to hold all + postings with a <em>To:</em> mail header containing '@public.com' + anywhere among the addresses. + + <p>Note that leading whitespace is trimmed from the regexp. This + can be circumvented in a number of ways, e.g. by escaping or + bracketing it.""")), + ] + + if subcat == 'sender': + return sender_rtn + elif subcat == 'recipient': + return recip_rtn + elif subcat == 'spam': + return spam_rtn + else: + return subscribing_rtn + + def _setValue(self, mlist, property, val, doc): + # Ignore any hdrfilter_* form variables + if property.startswith('hdrfilter_'): + return + # For subscribe_policy when ALLOW_OPEN_SUBSCRIBE is true, we need to + # add one to the value because the page didn't present an open list as + # an option. + if property == 'subscribe_policy' and not config.ALLOW_OPEN_SUBSCRIBE: + val += 1 + setattr(mlist, property, val) + + # We need to handle the header_filter_rules widgets specially, but + # everything else can be done by the base class's handleForm() method. + # However, to do this we need an awful hack. _setValue() and + # _getValidValue() will essentially ignore any hdrfilter_* form variables. + # TK: we should call this function only in subcat == 'spam' + def _handleForm(self, mlist, category, subcat, cgidata, doc): + # TK: If there is no hdrfilter_* in cgidata, we should not touch + # the header filter rules. + if not cgidata.has_key('hdrfilter_rebox_01'): + return + # First deal with + rules = [] + # We start i at 1 and keep going until we no longer find items keyed + # with the marked tags. + i = 1 + downi = None + while True: + deltag = 'hdrfilter_delete_%02d' % i + reboxtag = 'hdrfilter_rebox_%02d' % i + actiontag = 'hdrfilter_action_%02d' % i + wheretag = 'hdrfilter_where_%02d' % i + addtag = 'hdrfilter_add_%02d' % i + newtag = 'hdrfilter_new_%02d' % i + uptag = 'hdrfilter_up_%02d' % i + downtag = 'hdrfilter_down_%02d' % i + i += 1 + # Was this a delete? If so, we can just ignore this entry + if cgidata.has_key(deltag): + continue + # Get the data for the current box + pattern = cgidata.getvalue(reboxtag) + try: + action = int(cgidata.getvalue(actiontag)) + # We'll get a TypeError when the actiontag is missing and the + # .getvalue() call returns None. + except (ValueError, TypeError): + action = config.DEFER + if pattern is None: + # We came to the end of the boxes + break + if cgidata.has_key(newtag) and not pattern: + # This new entry is incomplete. + if i == 2: + # OK it is the first. + continue + doc.addError(_("""Header filter rules require a pattern. + Incomplete filter rules will be ignored.""")) + continue + # Make sure the pattern was a legal regular expression + try: + re.compile(pattern) + except (re.error, TypeError): + safepattern = Utils.websafe(pattern) + doc.addError(_("""The header filter rule pattern + '%(safepattern)s' is not a legal regular expression. This + rule will be ignored.""")) + continue + # Was this an add item? + if cgidata.has_key(addtag): + # Where should the new one be added? + where = cgidata.getvalue(wheretag) + if where == 'before': + # Add a new empty rule box before the current one + rules.append(('', config.DEFER, True)) + rules.append((pattern, action, False)) + # Default is to add it after... + else: + rules.append((pattern, action, False)) + rules.append(('', config.DEFER, True)) + # Was this an up movement? + elif cgidata.has_key(uptag): + # As long as this one isn't the first rule, move it up + if rules: + rules.insert(-1, (pattern, action, False)) + else: + rules.append((pattern, action, False)) + # Was this the down movement? + elif cgidata.has_key(downtag): + downi = i - 2 + rules.append((pattern, action, False)) + # Otherwise, just retain this one in the list + else: + rules.append((pattern, action, False)) + # Move any down button filter rule + if downi is not None: + rule = rules[downi] + del rules[downi] + rules.insert(downi+1, rule) + mlist.header_filter_rules = rules + + def handleForm(self, mlist, category, subcat, cgidata, doc): + if subcat == 'spam': + self._handleForm(mlist, category, subcat, cgidata, doc) + # Everything else is dealt with by the base handler + GUIBase.handleForm(self, mlist, category, subcat, cgidata, doc) diff --git a/src/mailman/web/Gui/Topics.py b/src/mailman/web/Gui/Topics.py new file mode 100644 index 000000000..00df988be --- /dev/null +++ b/src/mailman/web/Gui/Topics.py @@ -0,0 +1,162 @@ +# Copyright (C) 2001-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +import re + +from Mailman import Utils +from Mailman.Gui.GUIBase import GUIBase +from Mailman.configuration import config +from Mailman.i18n import _ + +OR = '|' + + + +class Topics(GUIBase): + def GetConfigCategory(self): + return 'topics', _('Topics') + + def GetConfigInfo(self, mlist, category, subcat=None): + if category <> 'topics': + return None + WIDTH = config.TEXTFIELDWIDTH + + return [ + _('List topic keywords'), + + ('topics_enabled', config.Radio, (_('Disabled'), _('Enabled')), 0, + _('''Should the topic filter be enabled or disabled?'''), + + _("""The topic filter categorizes each incoming email message + according to <a + href="http://www.python.org/doc/current/lib/module-re.html">regular + expression filters</a> you specify below. If the message's + <code>Subject:</code> or <code>Keywords:</code> header contains a + match against a topic filter, the message is logically placed + into a topic <em>bucket</em>. Each user can then choose to only + receive messages from the mailing list for a particular topic + bucket (or buckets). Any message not categorized in a topic + bucket registered with the user is not delivered to the list. + + <p>Note that this feature only works with regular delivery, not + digest delivery. + + <p>The body of the message can also be optionally scanned for + <code>Subject:</code> and <code>Keywords:</code> headers, as + specified by the <a + href="?VARHELP=topics/topics_bodylines_limit">topics_bodylines_limit</a> + configuration variable.""")), + + ('topics_bodylines_limit', config.Number, 5, 0, + _('How many body lines should the topic matcher scan?'), + + _("""The topic matcher will scan this many lines of the message + body looking for topic keyword matches. Body scanning stops when + either this many lines have been looked at, or a non-header-like + body line is encountered. By setting this value to zero, no body + lines will be scanned (i.e. only the <code>Keywords:</code> and + <code>Subject:</code> headers will be scanned). By setting this + value to a negative number, then all body lines will be scanned + until a non-header-like line is encountered. + """)), + + ('topics', config.Topics, 0, 0, + _('Topic keywords, one per line, to match against each message.'), + + _("""Each topic keyword is actually a regular expression, which is + matched against certain parts of a mail message, specifically the + <code>Keywords:</code> and <code>Subject:</code> message headers. + Note that the first few lines of the body of the message can also + contain a <code>Keywords:</code> and <code>Subject:</code> + "header" on which matching is also performed.""")), + + ] + + def handleForm(self, mlist, category, subcat, cgidata, doc): + # MAS: Did we come from the authentication page? + if not cgidata.has_key('topic_box_01'): + return + topics = [] + # We start i at 1 and keep going until we no longer find items keyed + # with the marked tags. + i = 1 + while True: + deltag = 'topic_delete_%02d' % i + boxtag = 'topic_box_%02d' % i + reboxtag = 'topic_rebox_%02d' % i + desctag = 'topic_desc_%02d' % i + wheretag = 'topic_where_%02d' % i + addtag = 'topic_add_%02d' % i + newtag = 'topic_new_%02d' % i + i += 1 + # Was this a delete? If so, we can just ignore this entry + if cgidata.has_key(deltag): + continue + # Get the data for the current box + name = cgidata.getvalue(boxtag) + pattern = cgidata.getvalue(reboxtag) + desc = cgidata.getvalue(desctag) + if name is None: + # We came to the end of the boxes + break + if cgidata.has_key(newtag) and (not name or not pattern): + # This new entry is incomplete. + doc.addError(_("""Topic specifications require both a name and + a pattern. Incomplete topics will be ignored.""")) + continue + # Make sure the pattern was a legal regular expression + name = Utils.websafe(name) + try: + orpattern = OR.join(pattern.splitlines()) + re.compile(orpattern) + except (re.error, TypeError): + safepattern = Utils.websafe(orpattern) + doc.addError(_("""The topic pattern '%(safepattern)s' is not a + legal regular expression. It will be discarded.""")) + continue + # Was this an add item? + if cgidata.has_key(addtag): + # Where should the new one be added? + where = cgidata.getvalue(wheretag) + if where == 'before': + # Add a new empty topics box before the current one + topics.append(('', '', '', True)) + topics.append((name, pattern, desc, False)) + # Default is to add it after... + else: + topics.append((name, pattern, desc, False)) + topics.append(('', '', '', True)) + # Otherwise, just retain this one in the list + else: + topics.append((name, pattern, desc, False)) + # Add these topics to the mailing list object, and deal with other + # options. + mlist.topics = topics + try: + mlist.topics_enabled = int(cgidata.getvalue( + 'topics_enabled', + mlist.topics_enabled)) + except ValueError: + # BAW: should really print a warning + pass + try: + mlist.topics_bodylines_limit = int(cgidata.getvalue( + 'topics_bodylines_limit', + mlist.topics_bodylines_limit)) + except ValueError: + # BAW: should really print a warning + pass diff --git a/src/mailman/web/Gui/Usenet.py b/src/mailman/web/Gui/Usenet.py new file mode 100644 index 000000000..9c1b50809 --- /dev/null +++ b/src/mailman/web/Gui/Usenet.py @@ -0,0 +1,140 @@ +# Copyright (C) 2001-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +from Mailman.Gui.GUIBase import GUIBase +from Mailman.configuration import config +from Mailman.i18n import _ + + + +class Usenet(GUIBase): + def GetConfigCategory(self): + return 'gateway', _('Mail<->News gateways') + + def GetConfigInfo(self, mlist, category, subcat=None): + if category <> 'gateway': + return None + + WIDTH = config.TEXTFIELDWIDTH + VERTICAL = 1 + + return [ + _('Mail-to-News and News-to-Mail gateway services.'), + + _('News server settings'), + + ('nntp_host', config.String, WIDTH, 0, + _('The hostname of the machine your news server is running on.'), + _('''This value may be either the name of your news server, or + optionally of the format name:port, where port is a port number. + + The news server is not part of Mailman proper. You have to + already have access to an NNTP server, and that NNTP server must + recognize the machine this mailing list runs on as a machine + capable of reading and posting news.''')), + + ('linked_newsgroup', config.String, WIDTH, 0, + _('The name of the Usenet group to gateway to and/or from.')), + + ('gateway_to_news', config.Toggle, (_('No'), _('Yes')), 0, + _('''Should new posts to the mailing list be sent to the + newsgroup?''')), + + ('gateway_to_mail', config.Toggle, (_('No'), _('Yes')), 0, + _('''Should new posts to the newsgroup be sent to the mailing + list?''')), + + _('Forwarding options'), + + ('news_moderation', config.Radio, + (_('None'), _('Open list, moderated group'), _('Moderated')), + VERTICAL, + + _("""The moderation policy of the newsgroup."""), + + _("""This setting determines the moderation policy of the + newsgroup and its interaction with the moderation policy of the + mailing list. This only applies to the newsgroup that you are + gatewaying <em>to</em>, so if you are only gatewaying from + Usenet, or the newsgroup you are gatewaying to is not moderated, + set this option to <em>None</em>. + + <p>If the newsgroup is moderated, you can set this mailing list + up to be the moderation address for the newsgroup. By selecting + <em>Moderated</em>, an additional posting hold will be placed in + the approval process. All messages posted to the mailing list + will have to be approved before being sent on to the newsgroup, + or to the mailing list membership. + + <p><em>Note that if the message has an <tt>Approved</tt> header + with the list's administrative password in it, this hold test + will be bypassed, allowing privileged posters to send messages + directly to the list and the newsgroup.</em> + + <p>Finally, if the newsgroup is moderated, but you want to have + an open posting policy anyway, you should select <em>Open list, + moderated group</em>. The effect of this is to use the normal + Mailman moderation facilities, but to add an <tt>Approved</tt> + header to all messages that are gatewayed to Usenet.""")), + + ('news_prefix_subject_too', config.Toggle, (_('No'), _('Yes')), 0, + _('Prefix <tt>Subject:</tt> headers on postings gated to news?'), + _("""Mailman prefixes <tt>Subject:</tt> headers with + <a href="?VARHELP=general/subject_prefix">text you can + customize</a> and normally, this prefix shows up in messages + gatewayed to Usenet. You can set this option to <em>No</em> to + disable the prefix on gated messages. Of course, if you turn off + normal <tt>Subject:</tt> prefixes, they won't be prefixed for + gated messages either.""")), + + _('Mass catch up'), + + ('_mass_catchup', config.Toggle, (_('No'), _('Yes')), 0, + _('Should Mailman perform a <em>catchup</em> on the newsgroup?'), + _('''When you tell Mailman to perform a catchup on the newsgroup, + this means that you want to start gating messages to the mailing + list with the next new message found. All earlier messages on + the newsgroup will be ignored. This is as if you were reading + the newsgroup yourself, and you marked all current messages as + <em>read</em>. By catching up, your mailing list members will + not see any of the earlier messages.''')), + + ] + + def _setValue(self, mlist, property, val, doc): + # Watch for the special, immediate action attributes + if property == '_mass_catchup' and val: + mlist.usenet_watermark = None + doc.AddItem(_('Mass catchup completed')) + else: + GUIBase._setValue(self, mlist, property, val, doc) + + def _postValidate(self, mlist, doc): + # Make sure that if we're gating, that the newsgroups and host + # information are not blank. + if mlist.gateway_to_news or mlist.gateway_to_mail: + # BAW: It's too expensive and annoying to ensure that both the + # host is valid and that the newsgroup is a valid n.g. on the + # server. This should be good enough. + if not mlist.nntp_host or not mlist.linked_newsgroup: + doc.addError(_("""You cannot enable gatewaying unless both the + <a href="?VARHELP=gateway/nntp_host">news server field</a> and + the <a href="?VARHELP=gateway/linked_newsgroup">linked + newsgroup</a> fields are filled in.""")) + # And reset these values + mlist.gateway_to_news = 0 + mlist.gateway_to_mail = 0 diff --git a/src/mailman/web/Gui/__init__.py b/src/mailman/web/Gui/__init__.py new file mode 100644 index 000000000..2e12526c0 --- /dev/null +++ b/src/mailman/web/Gui/__init__.py @@ -0,0 +1,33 @@ +# Copyright (C) 2001-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +from Archive import Archive +from Autoresponse import Autoresponse +from Bounce import Bounce +from Digest import Digest +from General import General +from Membership import Membership +from NonDigest import NonDigest +from Passwords import Passwords +from Privacy import Privacy +from Topics import Topics +from Usenet import Usenet +from Language import Language +from ContentFilter import ContentFilter + +# Don't export this symbol outside the package +del GUIBase diff --git a/src/mailman/web/HTMLFormatter.py b/src/mailman/web/HTMLFormatter.py new file mode 100644 index 000000000..594f4adea --- /dev/null +++ b/src/mailman/web/HTMLFormatter.py @@ -0,0 +1,437 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Routines for presentation of list-specific HTML text.""" + +import re +import time + +from mailman import Defaults +from mailman import MemberAdaptor +from mailman import Utils +from mailman.configuration import config +from mailman.htmlformat import * +from mailman.i18n import _ + + +EMPTYSTRING = '' +BR = '<br>' +NL = '\n' +COMMASPACE = ', ' + + + +class HTMLFormatter: + def GetMailmanFooter(self): + ownertext = COMMASPACE.join([Utils.ObscureEmail(a, 1) + for a in self.owner]) + # Remove the .Format() when htmlformat conversion is done. + realname = self.real_name + hostname = self.host_name + listinfo_link = Link(self.GetScriptURL('listinfo'), + realname).Format() + owner_link = Link('mailto:' + self.GetOwnerEmail(), ownertext).Format() + innertext = _('%(listinfo_link)s list run by %(owner_link)s') + return Container( + '<hr>', + Address( + Container( + innertext, + '<br>', + Link(self.GetScriptURL('admin'), + _('%(realname)s administrative interface')), + _(' (requires authorization)'), + '<br>', + Link(Utils.ScriptURL('listinfo'), + _('Overview of all %(hostname)s mailing lists')), + '<p>', MailmanLogo()))).Format() + + def FormatUsers(self, digest, lang=None): + if lang is None: + lang = self.preferred_language + conceal_sub = Defaults.ConcealSubscription + people = [] + if digest: + digestmembers = self.getDigestMemberKeys() + for dm in digestmembers: + if not self.getMemberOption(dm, conceal_sub): + people.append(dm) + num_concealed = len(digestmembers) - len(people) + else: + members = self.getRegularMemberKeys() + for m in members: + if not self.getMemberOption(m, conceal_sub): + people.append(m) + num_concealed = len(members) - len(people) + if num_concealed == 1: + concealed = _('<em>(1 private member not shown)</em>') + elif num_concealed > 1: + concealed = _( + '<em>(%(num_concealed)d private members not shown)</em>') + else: + concealed = '' + items = [] + people.sort() + obscure = self.obscure_addresses + for person in people: + id = Utils.ObscureEmail(person) + url = self.GetOptionsURL(person, obscure=obscure) + if obscure: + showing = Utils.ObscureEmail(person, for_text=1) + else: + showing = person + got = Link(url, showing) + if self.getDeliveryStatus(person) <> MemberAdaptor.ENABLED: + got = Italic('(', got, ')') + items.append(got) + # Just return the .Format() so this works until I finish + # converting everything to htmlformat... + return concealed + UnorderedList(*tuple(items)).Format() + + def FormatOptionButton(self, option, value, user): + if option == Defaults.DisableDelivery: + optval = self.getDeliveryStatus(user) <> MemberAdaptor.ENABLED + else: + optval = self.getMemberOption(user, option) + if optval == value: + checked = ' CHECKED' + else: + checked = '' + name = { + Defaults.DontReceiveOwnPosts : 'dontreceive', + Defaults.DisableDelivery : 'disablemail', + Defaults.DisableMime : 'mime', + Defaults.AcknowledgePosts : 'ackposts', + Defaults.Digests : 'digest', + Defaults.ConcealSubscription : 'conceal', + Defaults.SuppressPasswordReminder : 'remind', + Defaults.ReceiveNonmatchingTopics : 'rcvtopic', + Defaults.DontReceiveDuplicates : 'nodupes', + }[option] + return '<input type=radio name="%s" value="%d"%s>' % ( + name, value, checked) + + def FormatDigestButton(self): + if self.digest_is_default: + checked = ' CHECKED' + else: + checked = '' + return '<input type=radio name="digest" value="1"%s>' % checked + + def FormatDisabledNotice(self, user): + status = self.getDeliveryStatus(user) + reason = None + info = self.getBounceInfo(user) + if status == MemberAdaptor.BYUSER: + reason = _('; it was disabled by you') + elif status == MemberAdaptor.BYADMIN: + reason = _('; it was disabled by the list administrator') + elif status == MemberAdaptor.BYBOUNCE: + date = time.strftime('%d-%b-%Y', + time.localtime(Utils.midnight(info.date))) + reason = _('''; it was disabled due to excessive bounces. The + last bounce was received on %(date)s''') + elif status == MemberAdaptor.UNKNOWN: + reason = _('; it was disabled for unknown reasons') + if reason: + note = FontSize('+1', _( + 'Note: your list delivery is currently disabled%(reason)s.' + )).Format() + link = Link('#disable', _('Mail delivery')).Format() + mailto = Link('mailto:' + self.GetOwnerEmail(), + _('the list administrator')).Format() + return _('''<p>%(note)s + + <p>You may have disabled list delivery intentionally, + or it may have been triggered by bounces from your email + address. In either case, to re-enable delivery, change the + %(link)s option below. Contact %(mailto)s if you have any + questions or need assistance.''') + elif info and info.score > 0: + # Provide information about their current bounce score. We know + # their membership is currently enabled. + score = info.score + total = self.bounce_score_threshold + return _('''<p>We have received some recent bounces from your + address. Your current <em>bounce score</em> is %(score)s out of a + maximum of %(total)s. Please double check that your subscribed + address is correct and that there are no problems with delivery to + this address. Your bounce score will be automatically reset if + the problems are corrected soon.''') + else: + return '' + + def FormatUmbrellaNotice(self, user, type): + addr = self.GetMemberAdminEmail(user) + if self.umbrella_list: + return _("(Note - you are subscribing to a list of mailing lists, " + "so the %(type)s notice will be sent to the admin address" + " for your membership, %(addr)s.)<p>") + else: + return "" + + def FormatSubscriptionMsg(self): + msg = '' + also = '' + if self.subscribe_policy == 1: + msg += _('''You will be sent email requesting confirmation, to + prevent others from gratuitously subscribing you.''') + elif self.subscribe_policy == 2: + msg += _("""This is a closed list, which means your subscription + will be held for approval. You will be notified of the list + moderator's decision by email.""") + also = _('also ') + elif self.subscribe_policy == 3: + msg += _("""You will be sent email requesting confirmation, to + prevent others from gratuitously subscribing you. Once + confirmation is received, your request will be held for approval + by the list moderator. You will be notified of the moderator's + decision by email.""") + also = _("also ") + if msg: + msg += ' ' + if self.private_roster == 1: + msg += _('''This is %(also)sa private list, which means that the + list of members is not available to non-members.''') + elif self.private_roster: + msg += _('''This is %(also)sa hidden list, which means that the + list of members is available only to the list administrator.''') + else: + msg += _('''This is %(also)sa public list, which means that the + list of members list is available to everyone.''') + if self.obscure_addresses: + msg += _(''' (but we obscure the addresses so they are not + easily recognizable by spammers).''') + + if self.umbrella_list: + sfx = self.umbrella_member_suffix + msg += _("""<p>(Note that this is an umbrella list, intended to + have only other mailing lists as members. Among other things, + this means that your confirmation request will be sent to the + `%(sfx)s' account for your address.)""") + return msg + + def FormatUndigestButton(self): + if self.digest_is_default: + checked = '' + else: + checked = ' CHECKED' + return '<input type=radio name="digest" value="0"%s>' % checked + + def FormatMimeDigestsButton(self): + if self.mime_is_default_digest: + checked = ' CHECKED' + else: + checked = '' + return '<input type=radio name="mime" value="1"%s>' % checked + + def FormatPlainDigestsButton(self): + if self.mime_is_default_digest: + checked = '' + else: + checked = ' CHECKED' + return '<input type=radio name="plain" value="1"%s>' % checked + + def FormatEditingOption(self, lang): + if self.private_roster == 0: + either = _('<b><i>either</i></b> ') + else: + either = '' + realname = self.real_name + + text = (_('''To unsubscribe from %(realname)s, get a password reminder, + or change your subscription options %(either)senter your subscription + email address: + <p><center> ''') + + TextBox('email', size=30).Format() + + ' ' + + SubmitButton('UserOptions', + _('Unsubscribe or edit options')).Format() + + Hidden('language', lang).Format() + + '</center>') + if self.private_roster == 0: + text += _('''<p>... <b><i>or</i></b> select your entry from + the subscribers list (see above).''') + text += _(''' If you leave the field blank, you will be prompted for + your email address''') + return text + + def RestrictedListMessage(self, which, restriction): + if not restriction: + return '' + elif restriction == 1: + return _( + '''(<i>%(which)s is only available to the list + members.</i>)''') + else: + return _('''(<i>%(which)s is only available to the list + administrator.</i>)''') + + def FormatRosterOptionForUser(self, lang): + return self.RosterOption(lang).Format() + + def RosterOption(self, lang): + container = Container() + container.AddItem(Hidden('language', lang)) + if not self.private_roster: + container.AddItem(_("Click here for the list of ") + + self.real_name + + _(" subscribers: ")) + container.AddItem(SubmitButton('SubscriberRoster', + _("Visit Subscriber list"))) + else: + if self.private_roster == 1: + only = _('members') + whom = _('Address:') + else: + only = _('the list administrator') + whom = _('Admin address:') + # Solicit the user and password. + container.AddItem( + self.RestrictedListMessage(_('The subscribers list'), + self.private_roster) + + _(" <p>Enter your ") + + whom[:-1].lower() + + _(" and password to visit" + " the subscribers list: <p><center> ") + + whom + + " ") + container.AddItem(self.FormatBox('roster-email')) + container.AddItem(_("Password: ") + + self.FormatSecureBox('roster-pw') + + " ") + container.AddItem(SubmitButton('SubscriberRoster', + _('Visit Subscriber List'))) + container.AddItem("</center>") + return container + + def FormatFormStart(self, name, extra=''): + base_url = self.GetScriptURL(name) + if extra: + full_url = "%s/%s" % (base_url, extra) + else: + full_url = base_url + return ('<FORM Method=POST ACTION="%s">' % full_url) + + def FormatArchiveAnchor(self): + return '<a href="%s">' % self.GetBaseArchiveURL() + + def FormatFormEnd(self): + return '</FORM>' + + def FormatBox(self, name, size=20, value=''): + return '<INPUT type="Text" name="%s" size="%d" value="%s">' % ( + name, size, value) + + def FormatSecureBox(self, name): + return '<INPUT type="Password" name="%s" size="15">' % name + + def FormatButton(self, name, text='Submit'): + return '<INPUT type="Submit" name="%s" value="%s">' % (name, text) + + def FormatReminder(self, lang): + if self.send_reminders: + return _('Once a month, your password will be emailed to you as' + ' a reminder.') + return '' + + def ParseTags(self, template, replacements, lang=None): + if lang is None: + charset = 'us-ascii' + else: + charset = Utils.GetCharSet(lang) + text = Utils.maketext(template, raw=1, lang=lang, mlist=self) + parts = re.split('(</?[Mm][Mm]-[^>]*>)', text) + i = 1 + while i < len(parts): + tag = parts[i].lower() + if replacements.has_key(tag): + repl = replacements[tag] + if isinstance(repl, str): + repl = unicode(repl, charset, 'replace') + parts[i] = repl + else: + parts[i] = '' + i = i + 2 + return EMPTYSTRING.join(parts) + + # This needs to wait until after the list is inited, so let's build it + # when it's needed only. + def GetStandardReplacements(self, lang=None): + dmember_len = len(self.getDigestMemberKeys()) + member_len = len(self.getRegularMemberKeys()) + # If only one language is enabled for this mailing list, omit the + # language choice buttons. + if len(self.language_codes) == 1: + listlangs = _( + config.languages.get_description(self.preferred_language)) + else: + listlangs = self.GetLangSelectBox(lang).Format() + d = { + '<mm-mailman-footer>' : self.GetMailmanFooter(), + '<mm-list-name>' : self.real_name, + '<mm-email-user>' : self._internal_name, + '<mm-list-description>' : self.description, + '<mm-list-info>' : BR.join(self.info.split(NL)), + '<mm-form-end>' : self.FormatFormEnd(), + '<mm-archive>' : self.FormatArchiveAnchor(), + '</mm-archive>' : '</a>', + '<mm-list-subscription-msg>' : self.FormatSubscriptionMsg(), + '<mm-restricted-list-message>' : \ + self.RestrictedListMessage(_('The current archive'), + self.archive_private), + '<mm-num-reg-users>' : `member_len`, + '<mm-num-digesters>' : `dmember_len`, + '<mm-num-members>' : (`member_len + dmember_len`), + '<mm-posting-addr>' : '%s' % self.GetListEmail(), + '<mm-request-addr>' : '%s' % self.GetRequestEmail(), + '<mm-owner>' : self.GetOwnerEmail(), + '<mm-reminder>' : self.FormatReminder(self.preferred_language), + '<mm-host>' : self.host_name, + '<mm-list-langs>' : listlangs, + } + if config.IMAGE_LOGOS: + d['<mm-favicon>'] = config.IMAGE_LOGOS + config.SHORTCUT_ICON + return d + + def GetAllReplacements(self, lang=None): + """ + returns standard replaces plus formatted user lists in + a dict just like GetStandardReplacements. + """ + if lang is None: + lang = self.preferred_language + d = self.GetStandardReplacements(lang) + d.update({"<mm-regular-users>": self.FormatUsers(0, lang), + "<mm-digest-users>": self.FormatUsers(1, lang)}) + return d + + def GetLangSelectBox(self, lang=None, varname='language'): + if lang is None: + lang = self.preferred_language + # Figure out the available languages + values = self.language_codes + legend = [config.languages.get_description(code) for code in values] + try: + selected = values.index(lang) + except ValueError: + try: + selected = values.index(self.preferred_language) + except ValueError: + selected = config.DEFAULT_SERVER_LANGUAGE + # Return the widget + return SelectOptions(varname, values, legend, selected) diff --git a/src/mailman/web/__init__.py b/src/mailman/web/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/mailman/web/__init__.py diff --git a/src/mailman/web/htmlformat.py b/src/mailman/web/htmlformat.py new file mode 100644 index 000000000..608d0e647 --- /dev/null +++ b/src/mailman/web/htmlformat.py @@ -0,0 +1,670 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Library for program-based construction of an HTML documents. + +Encapsulate HTML formatting directives in classes that act as containers +for python and, recursively, for nested HTML formatting objects. +""" + +from Mailman import Defaults +from Mailman import Utils +from Mailman import version +from Mailman.configuration import config +from Mailman.i18n import _ + +SPACE = ' ' +EMPTYSTRING = '' +NL = '\n' + + + +# Format an arbitrary object. +def HTMLFormatObject(item, indent): + "Return a presentation of an object, invoking their Format method if any." + if hasattr(item, 'Format'): + return item.Format(indent) + if isinstance(item, basestring): + return item + return str(item) + +def CaseInsensitiveKeyedDict(d): + result = {} + for (k,v) in d.items(): + result[k.lower()] = v + return result + +# Given references to two dictionaries, copy the second dictionary into the +# first one. +def DictMerge(destination, fresh_dict): + for (key, value) in fresh_dict.items(): + destination[key] = value + +class Table: + def __init__(self, **table_opts): + self.cells = [] + self.cell_info = {} + self.row_info = {} + self.opts = table_opts + + def AddOptions(self, opts): + DictMerge(self.opts, opts) + + # Sets all of the cells. It writes over whatever cells you had there + # previously. + + def SetAllCells(self, cells): + self.cells = cells + + # Add a new blank row at the end + def NewRow(self): + self.cells.append([]) + + # Add a new blank cell at the end + def NewCell(self): + self.cells[-1].append('') + + def AddRow(self, row): + self.cells.append(row) + + def AddCell(self, cell): + self.cells[-1].append(cell) + + def AddCellInfo(self, row, col, **kws): + kws = CaseInsensitiveKeyedDict(kws) + if not self.cell_info.has_key(row): + self.cell_info[row] = { col : kws } + elif self.cell_info[row].has_key(col): + DictMerge(self.cell_info[row], kws) + else: + self.cell_info[row][col] = kws + + def AddRowInfo(self, row, **kws): + kws = CaseInsensitiveKeyedDict(kws) + if not self.row_info.has_key(row): + self.row_info[row] = kws + else: + DictMerge(self.row_info[row], kws) + + # What's the index for the row we just put in? + def GetCurrentRowIndex(self): + return len(self.cells)-1 + + # What's the index for the col we just put in? + def GetCurrentCellIndex(self): + return len(self.cells[-1])-1 + + def ExtractCellInfo(self, info): + valid_mods = ['align', 'valign', 'nowrap', 'rowspan', 'colspan', + 'bgcolor'] + output = '' + + for (key, val) in info.items(): + if not key in valid_mods: + continue + if key == 'nowrap': + output = output + ' NOWRAP' + continue + else: + output = output + ' %s="%s"' % (key.upper(), val) + + return output + + def ExtractRowInfo(self, info): + valid_mods = ['align', 'valign', 'bgcolor'] + output = '' + + for (key, val) in info.items(): + if not key in valid_mods: + continue + output = output + ' %s="%s"' % (key.upper(), val) + + return output + + def ExtractTableInfo(self, info): + valid_mods = ['align', 'width', 'border', 'cellspacing', 'cellpadding', + 'bgcolor'] + + output = '' + + for (key, val) in info.items(): + if not key in valid_mods: + continue + if key == 'border' and val == None: + output = output + ' BORDER' + continue + else: + output = output + ' %s="%s"' % (key.upper(), val) + + return output + + def FormatCell(self, row, col, indent): + try: + my_info = self.cell_info[row][col] + except: + my_info = None + + output = '\n' + ' '*indent + '<td' + if my_info: + output = output + self.ExtractCellInfo(my_info) + item = self.cells[row][col] + item_format = HTMLFormatObject(item, indent+4) + output = '%s>%s</td>' % (output, item_format) + return output + + def FormatRow(self, row, indent): + try: + my_info = self.row_info[row] + except: + my_info = None + + output = '\n' + ' '*indent + '<tr' + if my_info: + output = output + self.ExtractRowInfo(my_info) + output = output + '>' + + for i in range(len(self.cells[row])): + output = output + self.FormatCell(row, i, indent + 2) + + output = output + '\n' + ' '*indent + '</tr>' + + return output + + def Format(self, indent=0): + output = '\n' + ' '*indent + '<table' + output = output + self.ExtractTableInfo(self.opts) + output = output + '>' + + for i in range(len(self.cells)): + output = output + self.FormatRow(i, indent + 2) + + output = output + '\n' + ' '*indent + '</table>\n' + + return output + + +class Link: + def __init__(self, href, text, target=None): + self.href = href + self.text = text + self.target = target + + def Format(self, indent=0): + texpr = "" + if self.target != None: + texpr = ' target="%s"' % self.target + return '<a href="%s"%s>%s</a>' % (HTMLFormatObject(self.href, indent), + texpr, + HTMLFormatObject(self.text, indent)) + +class FontSize: + """FontSize is being deprecated - use FontAttr(..., size="...") instead.""" + def __init__(self, size, *items): + self.items = list(items) + self.size = size + + def Format(self, indent=0): + output = '<font size="%s">' % self.size + for item in self.items: + output = output + HTMLFormatObject(item, indent) + output = output + '</font>' + return output + +class FontAttr: + """Present arbitrary font attributes.""" + def __init__(self, *items, **kw): + self.items = list(items) + self.attrs = kw + + def Format(self, indent=0): + seq = [] + for k, v in self.attrs.items(): + seq.append('%s="%s"' % (k, v)) + output = '<font %s>' % SPACE.join(seq) + for item in self.items: + output = output + HTMLFormatObject(item, indent) + output = output + '</font>' + return output + + +class Container: + def __init__(self, *items): + if not items: + self.items = [] + else: + self.items = items + + def AddItem(self, obj): + self.items.append(obj) + + def Format(self, indent=0): + output = [] + for item in self.items: + output.append(HTMLFormatObject(item, indent)) + return EMPTYSTRING.join(output) + + +class Label(Container): + align = 'right' + + def __init__(self, *items): + Container.__init__(self, *items) + + def Format(self, indent=0): + return ('<div align="%s">' % self.align) + \ + Container.Format(self, indent) + \ + '</div>' + + +# My own standard document template. YMMV. +# something more abstract would be more work to use... + +class Document(Container): + title = None + language = None + bgcolor = Defaults.WEB_BG_COLOR + suppress_head = 0 + + def set_language(self, lang=None): + self.language = lang + + def set_bgcolor(self, color): + self.bgcolor = color + + def SetTitle(self, title): + self.title = title + + def Format(self, indent=0, **kws): + charset = 'us-ascii' + if self.language: + charset = Utils.GetCharSet(self.language) + output = ['Content-Type: text/html; charset=%s\n' % charset] + if not self.suppress_head: + kws.setdefault('bgcolor', self.bgcolor) + tab = ' ' * indent + output.extend([tab, + '<HTML>', + '<HEAD>' + ]) + if config.IMAGE_LOGOS: + output.append('<LINK REL="SHORTCUT ICON" HREF="%s">' % + (config.IMAGE_LOGOS + config.SHORTCUT_ICON)) + # Hit all the bases + output.append('<META http-equiv="Content-Type" ' + 'content="text/html; charset=%s">' % charset) + if self.title: + output.append('%s<TITLE>%s</TITLE>' % (tab, self.title)) + output.append('%s</HEAD>' % tab) + quals = [] + # Default link colors + if config.WEB_VLINK_COLOR: + kws.setdefault('vlink', config.WEB_VLINK_COLOR) + if config.WEB_ALINK_COLOR: + kws.setdefault('alink', config.WEB_ALINK_COLOR) + if config.WEB_LINK_COLOR: + kws.setdefault('link', config.WEB_LINK_COLOR) + for k, v in kws.items(): + quals.append('%s="%s"' % (k, v)) + output.append('%s<BODY %s>' % (tab, SPACE.join(quals))) + # Always do this... + output.append(Container.Format(self, indent)) + if not self.suppress_head: + output.append('%s</BODY>' % tab) + output.append('%s</HTML>' % tab) + return NL.join(output).encode(charset, 'replace') + + def addError(self, errmsg, tag=None): + if tag is None: + tag = _('Error: ') + self.AddItem(Header(3, Bold(FontAttr( + _(tag), color=config.WEB_ERROR_COLOR, size='+2')).Format() + + Italic(errmsg).Format())) + + +class HeadlessDocument(Document): + """Document without head section, for templates that provide their own.""" + suppress_head = 1 + + +class StdContainer(Container): + def Format(self, indent=0): + # If I don't start a new I ignore indent + output = '<%s>' % self.tag + output = output + Container.Format(self, indent) + output = '%s</%s>' % (output, self.tag) + return output + + +class QuotedContainer(Container): + def Format(self, indent=0): + # If I don't start a new I ignore indent + output = '<%s>%s</%s>' % ( + self.tag, + Utils.websafe(Container.Format(self, indent)), + self.tag) + return output + +class Header(StdContainer): + def __init__(self, num, *items): + self.items = items + self.tag = 'h%d' % num + +class Address(StdContainer): + tag = 'address' + +class Underline(StdContainer): + tag = 'u' + +class Bold(StdContainer): + tag = 'strong' + +class Italic(StdContainer): + tag = 'em' + +class Preformatted(QuotedContainer): + tag = 'pre' + +class Subscript(StdContainer): + tag = 'sub' + +class Superscript(StdContainer): + tag = 'sup' + +class Strikeout(StdContainer): + tag = 'strike' + +class Center(StdContainer): + tag = 'center' + +class Form(Container): + def __init__(self, action='', method='POST', encoding=None, *items): + apply(Container.__init__, (self,) + items) + self.action = action + self.method = method + self.encoding = encoding + + def set_action(self, action): + self.action = action + + def Format(self, indent=0): + spaces = ' ' * indent + encoding = '' + if self.encoding: + encoding = 'enctype="%s"' % self.encoding + output = '\n%s<FORM action="%s" method="%s" %s>\n' % ( + spaces, self.action, self.method, encoding) + output = output + Container.Format(self, indent+2) + output = '%s\n%s</FORM>\n' % (output, spaces) + return output + + +class InputObj: + def __init__(self, name, ty, value, checked, **kws): + self.name = name + self.type = ty + self.value = value + self.checked = checked + self.kws = kws + + def Format(self, indent=0): + output = ['<INPUT name="%s" type="%s" value="%s"' % + (self.name, self.type, self.value)] + for item in self.kws.items(): + output.append('%s="%s"' % item) + if self.checked: + output.append('CHECKED') + output.append('>') + return SPACE.join(output) + + +class SubmitButton(InputObj): + def __init__(self, name, button_text): + InputObj.__init__(self, name, "SUBMIT", button_text, checked=0) + +class PasswordBox(InputObj): + def __init__(self, name, value='', size=Defaults.TEXTFIELDWIDTH): + InputObj.__init__(self, name, "PASSWORD", value, checked=0, size=size) + +class TextBox(InputObj): + def __init__(self, name, value='', size=Defaults.TEXTFIELDWIDTH): + InputObj.__init__(self, name, "TEXT", value, checked=0, size=size) + +class Hidden(InputObj): + def __init__(self, name, value=''): + InputObj.__init__(self, name, 'HIDDEN', value, checked=0) + +class TextArea: + def __init__(self, name, text='', rows=None, cols=None, wrap='soft', + readonly=0): + self.name = name + self.text = text + self.rows = rows + self.cols = cols + self.wrap = wrap + self.readonly = readonly + + def Format(self, indent=0): + output = '<TEXTAREA NAME=%s' % self.name + if self.rows: + output += ' ROWS=%s' % self.rows + if self.cols: + output += ' COLS=%s' % self.cols + if self.wrap: + output += ' WRAP=%s' % self.wrap + if self.readonly: + output += ' READONLY' + output += '>%s</TEXTAREA>' % self.text + return output + +class FileUpload(InputObj): + def __init__(self, name, rows=None, cols=None, **kws): + apply(InputObj.__init__, (self, name, 'FILE', '', 0), kws) + +class RadioButton(InputObj): + def __init__(self, name, value, checked=0, **kws): + apply(InputObj.__init__, (self, name, 'RADIO', value, checked), kws) + +class CheckBox(InputObj): + def __init__(self, name, value, checked=0, **kws): + apply(InputObj.__init__, (self, name, "CHECKBOX", value, checked), kws) + +class VerticalSpacer: + def __init__(self, size=10): + self.size = size + def Format(self, indent=0): + output = '<spacer type="vertical" height="%d">' % self.size + return output + +class WidgetArray: + Widget = None + + def __init__(self, name, button_names, checked, horizontal, values): + self.name = name + self.button_names = button_names + self.checked = checked + self.horizontal = horizontal + self.values = values + assert len(values) == len(button_names) + # Don't assert `checked' because for RadioButtons it is a scalar while + # for CheckedBoxes it is a vector. Subclasses will assert length. + + def ischecked(self, i): + raise NotImplemented + + def Format(self, indent=0): + t = Table(cellspacing=5) + items = [] + for i, name, value in zip(range(len(self.button_names)), + self.button_names, + self.values): + ischecked = (self.ischecked(i)) + item = self.Widget(self.name, value, ischecked).Format() + name + items.append(item) + if not self.horizontal: + t.AddRow(items) + items = [] + if self.horizontal: + t.AddRow(items) + return t.Format(indent) + +class RadioButtonArray(WidgetArray): + Widget = RadioButton + + def __init__(self, name, button_names, checked=None, horizontal=1, + values=None): + if values is None: + values = range(len(button_names)) + # BAW: assert checked is a scalar... + WidgetArray.__init__(self, name, button_names, checked, horizontal, + values) + + def ischecked(self, i): + return self.checked == i + +class CheckBoxArray(WidgetArray): + Widget = CheckBox + + def __init__(self, name, button_names, checked=None, horizontal=0, + values=None): + if checked is None: + checked = [0] * len(button_names) + else: + assert len(checked) == len(button_names) + if values is None: + values = range(len(button_names)) + WidgetArray.__init__(self, name, button_names, checked, horizontal, + values) + + def ischecked(self, i): + return self.checked[i] + +class UnorderedList(Container): + def Format(self, indent=0): + spaces = ' ' * indent + output = '\n%s<ul>\n' % spaces + for item in self.items: + output = output + '%s<li>%s\n' % \ + (spaces, HTMLFormatObject(item, indent + 2)) + output = output + '%s</ul>\n' % spaces + return output + +class OrderedList(Container): + def Format(self, indent=0): + spaces = ' ' * indent + output = '\n%s<ol>\n' % spaces + for item in self.items: + output = output + '%s<li>%s\n' % \ + (spaces, HTMLFormatObject(item, indent + 2)) + output = output + '%s</ol>\n' % spaces + return output + +class DefinitionList(Container): + def Format(self, indent=0): + spaces = ' ' * indent + output = '\n%s<dl>\n' % spaces + for dt, dd in self.items: + output = output + '%s<dt>%s\n<dd>%s\n' % \ + (spaces, HTMLFormatObject(dt, indent+2), + HTMLFormatObject(dd, indent+2)) + output = output + '%s</dl>\n' % spaces + return output + + + +# Logo constants +# +# These are the URLs which the image logos link to. The Mailman home page now +# points at the gnu.org site instead of the www.list.org mirror. + +PYTHON_URL = 'http://www.python.org/' +GNU_URL = 'http://www.gnu.org/' + +# The names of the image logo files. These are concatentated onto +# config.IMAGE_LOGOS (not urljoined). +DELIVERED_BY = 'mailman.jpg' +PYTHON_POWERED = 'PythonPowered.png' +GNU_HEAD = 'gnu-head-tiny.jpg' + + +def MailmanLogo(): + t = Table(border=0, width='100%') + if config.IMAGE_LOGOS: + def logo(file): + return config.IMAGE_LOGOS + file + mmlink = '<img src="%s" alt="Delivered by Mailman" border=0>' \ + '<br>version %s' % (logo(DELIVERED_BY), version.VERSION) + pylink = '<img src="%s" alt="Python Powered" border=0>' % \ + logo(PYTHON_POWERED) + gnulink = '<img src="%s" alt="GNU\'s Not Unix" border=0>' % \ + logo(GNU_HEAD) + t.AddRow([mmlink, pylink, gnulink]) + else: + # use only textual links + version = version.VERSION + mmlink = Link(config.MAILMAN_URL, + _('Delivered by Mailman<br>version %(version)s')) + pylink = Link(PYTHON_URL, _('Python Powered')) + gnulink = Link(GNU_URL, _("Gnu's Not Unix")) + t.AddRow([mmlink, pylink, gnulink]) + return t + + +class SelectOptions: + def __init__(self, varname, values, legend, + selected=0, size=1, multiple=None): + self.varname = varname + self.values = values + self.legend = legend + self.size = size + self.multiple = multiple + # we convert any type to tuple, commas are needed + if not multiple: + if isinstance(selected, int): + self.selected = (selected,) + elif isinstance(selected, tuple): + self.selected = (selected[0],) + elif isinstance(selected, list): + self.selected = (selected[0],) + else: + self.selected = (0,) + + def Format(self, indent=0): + spaces = " " * indent + items = min( len(self.values), len(self.legend) ) + + # jcrey: If there is no argument, we return nothing to avoid errors + if items == 0: + return "" + + text = "\n" + spaces + "<Select name=\"%s\"" % self.varname + if self.size > 1: + text = text + " size=%d" % self.size + if self.multiple: + text = text + " multiple" + text = text + ">\n" + + for i in range(items): + if i in self.selected: + checked = " Selected" + else: + checked = "" + + opt = " <option value=\"%s\"%s> %s </option>" % ( + self.values[i], checked, self.legend[i]) + text = text + spaces + opt + "\n" + + return text + spaces + '</Select>' |
