From eefd06f1b88b8ecbb23a9013cd223b72ca85c20d Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Sun, 25 Jan 2009 13:01:41 -0500 Subject: Push the source directory into a 'src' subdirectory so that zc.buildout works correctly regardless of how it's used. --- src/mailman/Archiver/Archiver.py | 230 + src/mailman/Archiver/HyperArch.py | 1237 ++ src/mailman/Archiver/HyperDatabase.py | 339 + src/mailman/Archiver/__init__.py | 18 + src/mailman/Archiver/pipermail.py | 874 + src/mailman/Bouncers/BouncerAPI.py | 64 + src/mailman/Bouncers/Caiwireless.py | 45 + src/mailman/Bouncers/Compuserve.py | 46 + src/mailman/Bouncers/DSN.py | 99 + src/mailman/Bouncers/Exchange.py | 48 + src/mailman/Bouncers/Exim.py | 31 + src/mailman/Bouncers/GroupWise.py | 71 + src/mailman/Bouncers/LLNL.py | 32 + src/mailman/Bouncers/Microsoft.py | 53 + src/mailman/Bouncers/Netscape.py | 89 + src/mailman/Bouncers/Postfix.py | 86 + src/mailman/Bouncers/Qmail.py | 72 + src/mailman/Bouncers/SMTP32.py | 60 + src/mailman/Bouncers/SimpleMatch.py | 204 + src/mailman/Bouncers/SimpleWarning.py | 62 + src/mailman/Bouncers/Sina.py | 48 + src/mailman/Bouncers/Yahoo.py | 54 + src/mailman/Bouncers/Yale.py | 80 + src/mailman/Bouncers/__init__.py | 0 src/mailman/Mailbox.py | 106 + src/mailman/Message.py | 297 + src/mailman/Utils.py | 702 + src/mailman/__init__.py | 0 src/mailman/app/__init__.py | 0 src/mailman/app/bounces.py | 63 + src/mailman/app/commands.py | 44 + src/mailman/app/lifecycle.py | 114 + src/mailman/app/membership.py | 137 + src/mailman/app/moderator.py | 351 + src/mailman/app/notifications.py | 136 + src/mailman/app/registrar.py | 163 + src/mailman/app/replybot.py | 125 + src/mailman/archiving/__init__.py | 0 src/mailman/archiving/mailarchive.py | 87 + src/mailman/archiving/mhonarc.py | 97 + src/mailman/archiving/pipermail.py | 121 + src/mailman/archiving/prototype.py | 77 + src/mailman/attic/Bouncer.py | 250 + src/mailman/attic/Defaults.py | 1324 ++ src/mailman/attic/Deliverer.py | 174 + src/mailman/attic/Digester.py | 57 + src/mailman/attic/MailList.py | 731 + src/mailman/attic/SecurityManager.py | 306 + src/mailman/attic/bin/clone_member | 219 + src/mailman/attic/bin/discard | 120 + src/mailman/attic/bin/fix_url.py | 93 + src/mailman/attic/bin/list_admins | 101 + src/mailman/attic/bin/msgfmt.py | 203 + src/mailman/attic/bin/po2templ.py | 90 + src/mailman/attic/bin/pygettext.py | 545 + src/mailman/attic/bin/remove_members | 186 + src/mailman/attic/bin/reset_pw.py | 83 + src/mailman/attic/bin/sync_members | 286 + src/mailman/attic/bin/templ2pot.py | 120 + src/mailman/attic/bin/transcheck | 412 + src/mailman/bin/__init__.py | 61 + src/mailman/bin/add_members.py | 186 + src/mailman/bin/arch.py | 151 + src/mailman/bin/bumpdigests.py | 74 + src/mailman/bin/check_perms.py | 408 + src/mailman/bin/checkdbs.py | 199 + src/mailman/bin/cleanarch.py | 133 + src/mailman/bin/config_list.py | 332 + src/mailman/bin/create_list.py | 129 + src/mailman/bin/disabled.py | 201 + src/mailman/bin/docs/master.txt | 49 + src/mailman/bin/dumpdb.py | 88 + src/mailman/bin/export.py | 310 + src/mailman/bin/find_member.py | 135 + src/mailman/bin/gate_news.py | 243 + src/mailman/bin/genaliases.py | 64 + src/mailman/bin/import.py | 315 + src/mailman/bin/inject.py | 89 + src/mailman/bin/list_lists.py | 104 + src/mailman/bin/list_members.py | 201 + src/mailman/bin/list_owners.py | 88 + src/mailman/bin/mailmanctl.py | 232 + src/mailman/bin/master.py | 452 + src/mailman/bin/mmsitepass.py | 113 + src/mailman/bin/nightly_gzip.py | 117 + src/mailman/bin/qrunner.py | 269 + src/mailman/bin/remove_list.py | 83 + src/mailman/bin/senddigests.py | 83 + src/mailman/bin/set_members.py | 189 + src/mailman/bin/show_config.py | 97 + src/mailman/bin/show_qfiles.py | 91 + src/mailman/bin/unshunt.py | 51 + src/mailman/bin/update.py | 660 + src/mailman/bin/version.py | 46 + src/mailman/bin/withlist.py | 220 + src/mailman/chains/__init__.py | 0 src/mailman/chains/accept.py | 58 + src/mailman/chains/base.py | 122 + src/mailman/chains/builtin.py | 86 + src/mailman/chains/discard.py | 47 + src/mailman/chains/headers.py | 156 + src/mailman/chains/hold.py | 178 + src/mailman/chains/reject.py | 59 + src/mailman/commands/__init__.py | 22 + src/mailman/commands/cmd_confirm.py | 98 + src/mailman/commands/cmd_help.py | 93 + src/mailman/commands/cmd_info.py | 50 + src/mailman/commands/cmd_leave.py | 21 + src/mailman/commands/cmd_lists.py | 65 + src/mailman/commands/cmd_password.py | 123 + src/mailman/commands/cmd_remove.py | 21 + src/mailman/commands/cmd_set.py | 360 + src/mailman/commands/cmd_unsubscribe.py | 88 + src/mailman/commands/cmd_who.py | 152 + src/mailman/commands/docs/echo.txt | 30 + src/mailman/commands/docs/end.txt | 37 + src/mailman/commands/docs/join.txt | 170 + src/mailman/commands/echo.py | 48 + src/mailman/commands/end.py | 51 + src/mailman/commands/join.py | 126 + src/mailman/config/__init__.py | 30 + src/mailman/config/config.py | 206 + src/mailman/config/mailman.cfg | 69 + src/mailman/config/schema.cfg | 589 + src/mailman/constants.py | 44 + src/mailman/core/__init__.py | 0 src/mailman/core/chains.py | 118 + src/mailman/core/errors.py | 172 + src/mailman/core/initialize.py | 125 + src/mailman/core/logging.py | 163 + src/mailman/core/pipelines.py | 125 + src/mailman/core/plugins.py | 74 + src/mailman/core/rules.py | 46 + src/mailman/database/__init__.py | 153 + src/mailman/database/address.py | 96 + src/mailman/database/language.py | 40 + src/mailman/database/listmanager.py | 82 + src/mailman/database/mailinglist.py | 272 + src/mailman/database/mailman.sql | 208 + src/mailman/database/member.py | 105 + src/mailman/database/message.py | 53 + src/mailman/database/messagestore.py | 137 + src/mailman/database/model.py | 56 + src/mailman/database/pending.py | 177 + src/mailman/database/preferences.py | 50 + src/mailman/database/requests.py | 138 + src/mailman/database/roster.py | 270 + src/mailman/database/transaction.py | 53 + src/mailman/database/types.py | 64 + src/mailman/database/user.py | 94 + src/mailman/database/usermanager.py | 103 + src/mailman/database/version.py | 40 + src/mailman/docs/__init__.py | 0 src/mailman/docs/addresses.txt | 231 + src/mailman/docs/archivers.txt | 184 + src/mailman/docs/bounces.txt | 107 + src/mailman/docs/chains.txt | 345 + src/mailman/docs/domains.txt | 46 + src/mailman/docs/languages.txt | 104 + src/mailman/docs/lifecycle.txt | 136 + src/mailman/docs/listmanager.txt | 88 + src/mailman/docs/membership.txt | 230 + src/mailman/docs/message.txt | 48 + src/mailman/docs/messagestore.txt | 113 + src/mailman/docs/mlist-addresses.txt | 76 + src/mailman/docs/pending.txt | 94 + src/mailman/docs/pipelines.txt | 186 + src/mailman/docs/registration.txt | 362 + src/mailman/docs/requests.txt | 883 + src/mailman/docs/styles.txt | 156 + src/mailman/docs/usermanager.txt | 124 + src/mailman/docs/users.txt | 195 + src/mailman/domain.py | 70 + src/mailman/extras/__init__.py | 0 src/mailman/extras/mailman.cfg.in | 46 + src/mailman/i18n.py | 198 + src/mailman/inject.py | 86 + src/mailman/interact.py | 75 + src/mailman/interfaces/__init__.py | 44 + src/mailman/interfaces/address.py | 101 + src/mailman/interfaces/archiver.py | 78 + src/mailman/interfaces/chain.py | 110 + src/mailman/interfaces/command.py | 68 + src/mailman/interfaces/database.py | 97 + src/mailman/interfaces/domain.py | 85 + src/mailman/interfaces/errors.py | 35 + src/mailman/interfaces/handler.py | 45 + src/mailman/interfaces/languages.py | 89 + src/mailman/interfaces/listmanager.py | 84 + src/mailman/interfaces/mailinglist.py | 273 + src/mailman/interfaces/member.py | 187 + src/mailman/interfaces/messages.py | 111 + src/mailman/interfaces/mlistrequest.py | 37 + src/mailman/interfaces/mta.py | 42 + src/mailman/interfaces/pending.py | 99 + src/mailman/interfaces/permissions.py | 36 + src/mailman/interfaces/pipeline.py | 40 + src/mailman/interfaces/preferences.py | 77 + src/mailman/interfaces/registrar.py | 83 + src/mailman/interfaces/requests.py | 115 + src/mailman/interfaces/roster.py | 61 + src/mailman/interfaces/rules.py | 53 + src/mailman/interfaces/runner.py | 119 + src/mailman/interfaces/styles.py | 120 + src/mailman/interfaces/switchboard.py | 90 + src/mailman/interfaces/user.py | 84 + src/mailman/interfaces/usermanager.py | 96 + src/mailman/languages.py | 67 + src/mailman/messages/__init__.py | 0 src/mailman/messages/ar/LC_MESSAGES/mailman.po | 15702 +++++++++++++++++ src/mailman/messages/ca/LC_MESSAGES/mailman.po | 15421 +++++++++++++++++ src/mailman/messages/cs/LC_MESSAGES/mailman.po | 13393 +++++++++++++++ src/mailman/messages/da/LC_MESSAGES/mailman.po | 15972 +++++++++++++++++ src/mailman/messages/de/LC_MESSAGES/mailman.po | 15318 +++++++++++++++++ src/mailman/messages/de/README.de | 21 + src/mailman/messages/docstring.files | 59 + src/mailman/messages/es/LC_MESSAGES/mailman.po | 16349 ++++++++++++++++++ src/mailman/messages/es/README.es | 82 + src/mailman/messages/et/LC_MESSAGES/mailman.po | 14338 ++++++++++++++++ src/mailman/messages/eu/LC_MESSAGES/mailman.po | 14145 +++++++++++++++ src/mailman/messages/eu/README.eu | 103 + src/mailman/messages/fi/LC_MESSAGES/mailman.po | 13862 +++++++++++++++ src/mailman/messages/fi/README.fi | 13 + src/mailman/messages/fr/LC_MESSAGES/mailman.po | 15420 +++++++++++++++++ src/mailman/messages/fr/README.fr | 7 + src/mailman/messages/hr/LC_MESSAGES/mailman.po | 13737 +++++++++++++++ src/mailman/messages/hu/FAQ.hu | 464 + src/mailman/messages/hu/INSTALL.hu | 640 + src/mailman/messages/hu/LC_MESSAGES/mailman.po | 14882 ++++++++++++++++ src/mailman/messages/hu/README.BSD.hu | 28 + src/mailman/messages/hu/README.CONTRIB.hu | 17 + src/mailman/messages/hu/README.EXIM.hu | 359 + src/mailman/messages/hu/README.LINUX.hu | 59 + src/mailman/messages/hu/README.MACOSX.hu | 31 + src/mailman/messages/hu/README.NETSCAPE.hu | 57 + src/mailman/messages/hu/README.POSTFIX.hu | 239 + src/mailman/messages/hu/README.QMAIL.hu | 186 + src/mailman/messages/hu/README.SENDMAIL.hu | 86 + src/mailman/messages/hu/README.USERAGENT.hu | 49 + src/mailman/messages/hu/README.hu | 271 + src/mailman/messages/hu/UPGRADING.hu | 391 + src/mailman/messages/ia/LC_MESSAGES/mailman.po | 14426 ++++++++++++++++ src/mailman/messages/it/LC_MESSAGES/mailman.po | 15623 +++++++++++++++++ src/mailman/messages/it/README.it | 32 + src/mailman/messages/ja/INSTALL | 615 + src/mailman/messages/ja/LC_MESSAGES/mailman.po | 14328 ++++++++++++++++ src/mailman/messages/ja/README | 214 + src/mailman/messages/ja/README.ja | 109 + src/mailman/messages/ja/UPGRADING | 215 + src/mailman/messages/ja/doc/Defaults.py.in | 1442 ++ src/mailman/messages/ja/doc/mailman-install.tex | 1933 +++ src/mailman/messages/ja/doc/mailman-member.tex | 1787 ++ src/mailman/messages/ko/LC_MESSAGES/mailman.po | 12970 ++++++++++++++ src/mailman/messages/ko/README.ko | 26 + src/mailman/messages/lt/LC_MESSAGES/mailman.po | 12116 +++++++++++++ src/mailman/messages/mailman.pot | 10031 +++++++++++ src/mailman/messages/marked.files | 130 + src/mailman/messages/nl/LC_MESSAGES/mailman.po | 13629 +++++++++++++++ src/mailman/messages/no/LC_MESSAGES/mailman.po | 15503 +++++++++++++++++ src/mailman/messages/pl/LC_MESSAGES/mailman.po | 13120 ++++++++++++++ src/mailman/messages/pl/README.pl | 28 + src/mailman/messages/pt/LC_MESSAGES/mailman.po | 14924 ++++++++++++++++ src/mailman/messages/pt_BR/LC_MESSAGES/mailman.po | 15033 ++++++++++++++++ src/mailman/messages/ro/LC_MESSAGES/mailman.po | 14633 ++++++++++++++++ src/mailman/messages/ru/LC_MESSAGES/mailman.po | 14862 ++++++++++++++++ src/mailman/messages/ru/README.ru | 17 + src/mailman/messages/sl/LC_MESSAGES/mailman.po | 17650 +++++++++++++++++++ src/mailman/messages/sr/LC_MESSAGES/mailman.po | 11873 +++++++++++++ src/mailman/messages/sr/readme.sr | 6 + src/mailman/messages/sv/LC_MESSAGES/mailman.po | 18097 ++++++++++++++++++++ src/mailman/messages/sv/README.sv | 30 + src/mailman/messages/tr/LC_MESSAGES/mailman.po | 13641 +++++++++++++++ src/mailman/messages/uk/LC_MESSAGES/mailman.po | 14897 ++++++++++++++++ src/mailman/messages/vi/LC_MESSAGES/mailman.po | 14584 ++++++++++++++++ src/mailman/messages/zh_CN/LC_MESSAGES/mailman.po | 14142 +++++++++++++++ src/mailman/messages/zh_TW/LC_MESSAGES/mailman.po | 12964 ++++++++++++++ src/mailman/mta/__init__.py | 0 src/mailman/mta/null.py | 51 + src/mailman/mta/postfix.py | 123 + src/mailman/mta/smtp_direct.py | 417 + src/mailman/options.py | 143 + src/mailman/passwords.py | 253 + src/mailman/pipeline/__init__.py | 54 + src/mailman/pipeline/acknowledge.py | 80 + src/mailman/pipeline/after_delivery.py | 48 + src/mailman/pipeline/avoid_duplicates.py | 116 + src/mailman/pipeline/calculate_recipients.py | 148 + src/mailman/pipeline/cleanse.py | 75 + src/mailman/pipeline/cleanse_dkim.py | 58 + src/mailman/pipeline/cook_headers.py | 357 + src/mailman/pipeline/decorate.py | 231 + src/mailman/pipeline/docs/ack-headers.txt | 40 + src/mailman/pipeline/docs/acknowledge.txt | 159 + src/mailman/pipeline/docs/after-delivery.txt | 27 + src/mailman/pipeline/docs/archives.txt | 133 + src/mailman/pipeline/docs/avoid-duplicates.txt | 168 + src/mailman/pipeline/docs/calc-recips.txt | 100 + src/mailman/pipeline/docs/cleanse.txt | 94 + src/mailman/pipeline/docs/cook-headers.txt | 326 + src/mailman/pipeline/docs/decorate.txt | 317 + src/mailman/pipeline/docs/digests.txt | 535 + src/mailman/pipeline/docs/file-recips.txt | 96 + src/mailman/pipeline/docs/filtering.txt | 340 + src/mailman/pipeline/docs/nntp.txt | 65 + src/mailman/pipeline/docs/reply-to.txt | 127 + src/mailman/pipeline/docs/replybot.txt | 213 + src/mailman/pipeline/docs/scrubber.txt | 225 + src/mailman/pipeline/docs/subject-munging.txt | 244 + src/mailman/pipeline/docs/tagger.txt | 235 + src/mailman/pipeline/docs/to-outgoing.txt | 173 + src/mailman/pipeline/file_recipients.py | 65 + src/mailman/pipeline/mime_delete.py | 285 + src/mailman/pipeline/moderate.py | 175 + src/mailman/pipeline/owner_recipients.py | 34 + src/mailman/pipeline/replybot.py | 134 + src/mailman/pipeline/scrubber.py | 509 + src/mailman/pipeline/tagger.py | 187 + src/mailman/pipeline/to_archive.py | 55 + src/mailman/pipeline/to_digest.py | 440 + src/mailman/pipeline/to_outgoing.py | 78 + src/mailman/pipeline/to_usenet.py | 69 + src/mailman/queue/__init__.py | 466 + src/mailman/queue/archive.py | 91 + src/mailman/queue/bounce.py | 316 + src/mailman/queue/command.py | 214 + src/mailman/queue/docs/OVERVIEW.txt | 78 + src/mailman/queue/docs/archiver.txt | 34 + src/mailman/queue/docs/command.txt | 170 + src/mailman/queue/docs/incoming.txt | 200 + src/mailman/queue/docs/lmtp.txt | 103 + src/mailman/queue/docs/news.txt | 157 + src/mailman/queue/docs/outgoing.txt | 75 + src/mailman/queue/docs/runner.txt | 72 + src/mailman/queue/docs/switchboard.txt | 182 + src/mailman/queue/http.py | 73 + src/mailman/queue/incoming.py | 43 + src/mailman/queue/lmtp.py | 218 + src/mailman/queue/maildir.py | 190 + src/mailman/queue/news.py | 166 + src/mailman/queue/outgoing.py | 130 + src/mailman/queue/pipeline.py | 35 + src/mailman/queue/retry.py | 37 + src/mailman/queue/virgin.py | 39 + src/mailman/rules/__init__.py | 54 + src/mailman/rules/administrivia.py | 102 + src/mailman/rules/any.py | 45 + src/mailman/rules/approved.py | 121 + src/mailman/rules/docs/administrivia.txt | 99 + src/mailman/rules/docs/approve.txt | 472 + src/mailman/rules/docs/emergency.txt | 72 + src/mailman/rules/docs/header-matching.txt | 144 + src/mailman/rules/docs/implicit-dest.txt | 75 + src/mailman/rules/docs/loop.txt | 48 + src/mailman/rules/docs/max-size.txt | 39 + src/mailman/rules/docs/moderation.txt | 69 + src/mailman/rules/docs/news-moderation.txt | 36 + src/mailman/rules/docs/no-subject.txt | 33 + src/mailman/rules/docs/recipients.txt | 40 + src/mailman/rules/docs/rules.txt | 69 + src/mailman/rules/docs/suspicious.txt | 35 + src/mailman/rules/docs/truth.txt | 9 + src/mailman/rules/emergency.py | 50 + src/mailman/rules/implicit_dest.py | 99 + src/mailman/rules/loop.py | 48 + src/mailman/rules/max_recipients.py | 52 + src/mailman/rules/max_size.py | 50 + src/mailman/rules/moderation.py | 68 + src/mailman/rules/news_moderation.py | 49 + src/mailman/rules/no_subject.py | 46 + src/mailman/rules/suspicious.py | 100 + src/mailman/rules/truth.py | 45 + src/mailman/styles/__init__.py | 0 src/mailman/styles/default.py | 250 + src/mailman/styles/manager.py | 94 + src/mailman/templates/__init__.py | 0 src/mailman/templates/en/__init__.py | 0 src/mailman/templates/en/adminaddrchgack.txt | 4 + src/mailman/templates/en/admindbdetails.html | 65 + src/mailman/templates/en/admindbpreamble.html | 10 + src/mailman/templates/en/admindbsummary.html | 14 + src/mailman/templates/en/adminsubscribeack.txt | 1 + src/mailman/templates/en/adminunsubscribeack.txt | 1 + src/mailman/templates/en/admlogin.html | 39 + src/mailman/templates/en/approve.txt | 15 + src/mailman/templates/en/archidxentry.html | 4 + src/mailman/templates/en/archidxfoot.html | 21 + src/mailman/templates/en/archidxhead.html | 24 + src/mailman/templates/en/archlistend.html | 1 + src/mailman/templates/en/archliststart.html | 4 + src/mailman/templates/en/archtoc.html | 20 + src/mailman/templates/en/archtocentry.html | 12 + src/mailman/templates/en/archtocnombox.html | 18 + src/mailman/templates/en/article.html | 50 + src/mailman/templates/en/bounce.txt | 13 + src/mailman/templates/en/checkdbs.txt | 7 + src/mailman/templates/en/convert.txt | 34 + src/mailman/templates/en/cronpass.txt | 19 + src/mailman/templates/en/disabled.txt | 25 + src/mailman/templates/en/emptyarchive.html | 15 + src/mailman/templates/en/headfoot.html | 28 + src/mailman/templates/en/help.txt | 33 + src/mailman/templates/en/invite.txt | 20 + src/mailman/templates/en/listinfo.html | 143 + src/mailman/templates/en/masthead.txt | 13 + src/mailman/templates/en/newlist.txt | 35 + src/mailman/templates/en/nomoretoday.txt | 8 + src/mailman/templates/en/options.html | 316 + src/mailman/templates/en/postack.txt | 8 + src/mailman/templates/en/postauth.txt | 13 + src/mailman/templates/en/postheld.txt | 15 + src/mailman/templates/en/private.html | 43 + src/mailman/templates/en/probe.txt | 25 + src/mailman/templates/en/refuse.txt | 13 + src/mailman/templates/en/roster.html | 52 + src/mailman/templates/en/subauth.txt | 11 + src/mailman/templates/en/subscribe.html | 8 + src/mailman/templates/en/subscribeack.txt | 25 + src/mailman/templates/en/unsub.txt | 23 + src/mailman/templates/en/unsubauth.txt | 11 + src/mailman/templates/en/userpass.txt | 24 + src/mailman/templates/en/verify.txt | 19 + src/mailman/testing/__init__.py | 0 src/mailman/testing/helpers.py | 248 + src/mailman/testing/layers.py | 204 + src/mailman/testing/mta.py | 46 + src/mailman/testing/smtplistener.py | 97 + src/mailman/testing/testing.cfg | 87 + src/mailman/tests/__init__.py | 0 src/mailman/tests/bounces/__init__.py | 0 src/mailman/tests/bounces/bounce_01.txt | 95 + src/mailman/tests/bounces/bounce_02.txt | 36 + src/mailman/tests/bounces/bounce_03.txt | 109 + src/mailman/tests/bounces/dsn_01.txt | 217 + src/mailman/tests/bounces/dsn_02.txt | 187 + src/mailman/tests/bounces/dsn_03.txt | 144 + src/mailman/tests/bounces/dsn_04.txt | 202 + src/mailman/tests/bounces/dsn_05.txt | 125 + src/mailman/tests/bounces/dsn_06.txt | 122 + src/mailman/tests/bounces/dsn_07.txt | 121 + src/mailman/tests/bounces/dsn_08.txt | 131 + src/mailman/tests/bounces/dsn_09.txt | 85 + src/mailman/tests/bounces/dsn_10.txt | 66 + src/mailman/tests/bounces/dsn_11.txt | 176 + src/mailman/tests/bounces/dsn_12.txt | 40 + src/mailman/tests/bounces/dsn_13.txt | 311 + src/mailman/tests/bounces/dsn_14.txt | 149 + src/mailman/tests/bounces/dsn_15.txt | 278 + src/mailman/tests/bounces/dumbass_01.txt | 109 + src/mailman/tests/bounces/exim_01.txt | 58 + src/mailman/tests/bounces/groupwise_01.txt | 151 + src/mailman/tests/bounces/groupwise_02.txt | 186 + src/mailman/tests/bounces/hotpop_01.txt | 180 + src/mailman/tests/bounces/llnl_01.txt | 203 + src/mailman/tests/bounces/microsoft_01.txt | 108 + src/mailman/tests/bounces/microsoft_02.txt | 119 + src/mailman/tests/bounces/microsoft_03.txt | 65 + src/mailman/tests/bounces/netscape_01.txt | 123 + src/mailman/tests/bounces/newmailru_01.txt | 112 + src/mailman/tests/bounces/postfix_01.txt | 123 + src/mailman/tests/bounces/postfix_02.txt | 60 + src/mailman/tests/bounces/postfix_03.txt | 145 + src/mailman/tests/bounces/postfix_04.txt | 240 + src/mailman/tests/bounces/postfix_05.txt | 231 + src/mailman/tests/bounces/qmail_01.txt | 103 + src/mailman/tests/bounces/qmail_02.txt | 73 + src/mailman/tests/bounces/qmail_03.txt | 245 + src/mailman/tests/bounces/qmail_04.txt | 81 + src/mailman/tests/bounces/qmail_05.txt | 121 + src/mailman/tests/bounces/sendmail_01.txt | 146 + src/mailman/tests/bounces/simple_01.txt | 153 + src/mailman/tests/bounces/simple_02.txt | 118 + src/mailman/tests/bounces/simple_03.txt | 68 + src/mailman/tests/bounces/simple_04.txt | 105 + src/mailman/tests/bounces/simple_05.txt | 81 + src/mailman/tests/bounces/simple_06.txt | 77 + src/mailman/tests/bounces/simple_07.txt | 21 + src/mailman/tests/bounces/simple_08.txt | 81 + src/mailman/tests/bounces/simple_09.txt | 27 + src/mailman/tests/bounces/simple_10.txt | 45 + src/mailman/tests/bounces/simple_11.txt | 68 + src/mailman/tests/bounces/simple_12.txt | 81 + src/mailman/tests/bounces/simple_13.txt | 60 + src/mailman/tests/bounces/simple_14.txt | 122 + src/mailman/tests/bounces/simple_15.txt | 259 + src/mailman/tests/bounces/simple_16.txt | 78 + src/mailman/tests/bounces/simple_17.txt | 76 + src/mailman/tests/bounces/simple_18.txt | 73 + src/mailman/tests/bounces/simple_19.txt | 77 + src/mailman/tests/bounces/simple_20.txt | 27 + src/mailman/tests/bounces/simple_21.txt | 46 + src/mailman/tests/bounces/simple_22.txt | 25 + src/mailman/tests/bounces/simple_23.txt | 152 + src/mailman/tests/bounces/simple_24.txt | 33 + src/mailman/tests/bounces/simple_25.txt | 379 + src/mailman/tests/bounces/simple_26.txt | 74 + src/mailman/tests/bounces/simple_27.txt | 279 + src/mailman/tests/bounces/sina_01.txt | 128 + src/mailman/tests/bounces/smtp32_01.txt | 97 + src/mailman/tests/bounces/smtp32_02.txt | 96 + src/mailman/tests/bounces/smtp32_03.txt | 92 + src/mailman/tests/bounces/smtp32_04.txt | 47 + src/mailman/tests/bounces/smtp32_05.txt | 63 + src/mailman/tests/bounces/smtp32_06.txt | 41 + src/mailman/tests/bounces/smtp32_07.txt | 81 + src/mailman/tests/bounces/yahoo_01.txt | 47 + src/mailman/tests/bounces/yahoo_02.txt | Bin 0 -> 2212 bytes src/mailman/tests/bounces/yahoo_03.txt | 98 + src/mailman/tests/bounces/yahoo_04.txt | 150 + src/mailman/tests/bounces/yahoo_05.txt | 150 + src/mailman/tests/bounces/yahoo_06.txt | 105 + src/mailman/tests/bounces/yahoo_07.txt | 112 + src/mailman/tests/bounces/yahoo_08.txt | 129 + src/mailman/tests/bounces/yahoo_09.txt | 165 + src/mailman/tests/bounces/yahoo_10.txt | 83 + src/mailman/tests/bounces/yale_01.txt | 422 + src/mailman/tests/test_bounces.py | 229 + src/mailman/tests/test_documentation.py | 150 + src/mailman/tests/test_membership.py | 392 + src/mailman/tests/test_passwords.py | 150 + src/mailman/tests/test_security_mgr.py | 241 + src/mailman/utilities/__init__.py | 0 src/mailman/utilities/filesystem.py | 78 + src/mailman/utilities/string.py | 59 + src/mailman/version.py | 48 + src/mailman/web/Cgi/Auth.py | 60 + src/mailman/web/Cgi/__init__.py | 0 src/mailman/web/Cgi/admin.py | 1433 ++ src/mailman/web/Cgi/admindb.py | 813 + src/mailman/web/Cgi/confirm.py | 834 + src/mailman/web/Cgi/create.py | 400 + src/mailman/web/Cgi/edithtml.py | 175 + src/mailman/web/Cgi/listinfo.py | 207 + src/mailman/web/Cgi/options.py | 1000 ++ src/mailman/web/Cgi/private.py | 190 + src/mailman/web/Cgi/rmlist.py | 243 + src/mailman/web/Cgi/roster.py | 130 + src/mailman/web/Cgi/subscribe.py | 252 + src/mailman/web/Cgi/wsgi_app.py | 286 + src/mailman/web/Gui/Archive.py | 45 + src/mailman/web/Gui/Autoresponse.py | 99 + src/mailman/web/Gui/Bounce.py | 195 + src/mailman/web/Gui/ContentFilter.py | 199 + src/mailman/web/Gui/Digest.py | 161 + src/mailman/web/Gui/GUIBase.py | 209 + src/mailman/web/Gui/General.py | 464 + src/mailman/web/Gui/Language.py | 128 + src/mailman/web/Gui/Membership.py | 34 + src/mailman/web/Gui/NonDigest.py | 158 + src/mailman/web/Gui/Passwords.py | 31 + src/mailman/web/Gui/Privacy.py | 537 + src/mailman/web/Gui/Topics.py | 162 + src/mailman/web/Gui/Usenet.py | 140 + src/mailman/web/Gui/__init__.py | 33 + src/mailman/web/HTMLFormatter.py | 437 + src/mailman/web/__init__.py | 0 src/mailman/web/htmlformat.py | 670 + 556 files changed, 555305 insertions(+) create mode 100644 src/mailman/Archiver/Archiver.py create mode 100644 src/mailman/Archiver/HyperArch.py create mode 100644 src/mailman/Archiver/HyperDatabase.py create mode 100644 src/mailman/Archiver/__init__.py create mode 100644 src/mailman/Archiver/pipermail.py create mode 100644 src/mailman/Bouncers/BouncerAPI.py create mode 100644 src/mailman/Bouncers/Caiwireless.py create mode 100644 src/mailman/Bouncers/Compuserve.py create mode 100644 src/mailman/Bouncers/DSN.py create mode 100644 src/mailman/Bouncers/Exchange.py create mode 100644 src/mailman/Bouncers/Exim.py create mode 100644 src/mailman/Bouncers/GroupWise.py create mode 100644 src/mailman/Bouncers/LLNL.py create mode 100644 src/mailman/Bouncers/Microsoft.py create mode 100644 src/mailman/Bouncers/Netscape.py create mode 100644 src/mailman/Bouncers/Postfix.py create mode 100644 src/mailman/Bouncers/Qmail.py create mode 100644 src/mailman/Bouncers/SMTP32.py create mode 100644 src/mailman/Bouncers/SimpleMatch.py create mode 100644 src/mailman/Bouncers/SimpleWarning.py create mode 100644 src/mailman/Bouncers/Sina.py create mode 100644 src/mailman/Bouncers/Yahoo.py create mode 100644 src/mailman/Bouncers/Yale.py create mode 100644 src/mailman/Bouncers/__init__.py create mode 100644 src/mailman/Mailbox.py create mode 100644 src/mailman/Message.py create mode 100644 src/mailman/Utils.py create mode 100644 src/mailman/__init__.py create mode 100644 src/mailman/app/__init__.py create mode 100644 src/mailman/app/bounces.py create mode 100644 src/mailman/app/commands.py create mode 100644 src/mailman/app/lifecycle.py create mode 100644 src/mailman/app/membership.py create mode 100644 src/mailman/app/moderator.py create mode 100644 src/mailman/app/notifications.py create mode 100644 src/mailman/app/registrar.py create mode 100644 src/mailman/app/replybot.py create mode 100644 src/mailman/archiving/__init__.py create mode 100644 src/mailman/archiving/mailarchive.py create mode 100644 src/mailman/archiving/mhonarc.py create mode 100644 src/mailman/archiving/pipermail.py create mode 100644 src/mailman/archiving/prototype.py create mode 100644 src/mailman/attic/Bouncer.py create mode 100644 src/mailman/attic/Defaults.py create mode 100644 src/mailman/attic/Deliverer.py create mode 100644 src/mailman/attic/Digester.py create mode 100644 src/mailman/attic/MailList.py create mode 100644 src/mailman/attic/SecurityManager.py create mode 100755 src/mailman/attic/bin/clone_member create mode 100644 src/mailman/attic/bin/discard create mode 100644 src/mailman/attic/bin/fix_url.py create mode 100644 src/mailman/attic/bin/list_admins create mode 100644 src/mailman/attic/bin/msgfmt.py create mode 100644 src/mailman/attic/bin/po2templ.py create mode 100644 src/mailman/attic/bin/pygettext.py create mode 100755 src/mailman/attic/bin/remove_members create mode 100644 src/mailman/attic/bin/reset_pw.py create mode 100755 src/mailman/attic/bin/sync_members create mode 100644 src/mailman/attic/bin/templ2pot.py create mode 100755 src/mailman/attic/bin/transcheck create mode 100644 src/mailman/bin/__init__.py create mode 100644 src/mailman/bin/add_members.py create mode 100644 src/mailman/bin/arch.py create mode 100644 src/mailman/bin/bumpdigests.py create mode 100644 src/mailman/bin/check_perms.py create mode 100644 src/mailman/bin/checkdbs.py create mode 100644 src/mailman/bin/cleanarch.py create mode 100644 src/mailman/bin/config_list.py create mode 100644 src/mailman/bin/create_list.py create mode 100644 src/mailman/bin/disabled.py create mode 100644 src/mailman/bin/docs/master.txt create mode 100644 src/mailman/bin/dumpdb.py create mode 100644 src/mailman/bin/export.py create mode 100644 src/mailman/bin/find_member.py create mode 100644 src/mailman/bin/gate_news.py create mode 100644 src/mailman/bin/genaliases.py create mode 100644 src/mailman/bin/import.py create mode 100644 src/mailman/bin/inject.py create mode 100644 src/mailman/bin/list_lists.py create mode 100644 src/mailman/bin/list_members.py create mode 100644 src/mailman/bin/list_owners.py create mode 100644 src/mailman/bin/mailmanctl.py create mode 100644 src/mailman/bin/master.py create mode 100644 src/mailman/bin/mmsitepass.py create mode 100644 src/mailman/bin/nightly_gzip.py create mode 100644 src/mailman/bin/qrunner.py create mode 100644 src/mailman/bin/remove_list.py create mode 100644 src/mailman/bin/senddigests.py create mode 100644 src/mailman/bin/set_members.py create mode 100644 src/mailman/bin/show_config.py create mode 100644 src/mailman/bin/show_qfiles.py create mode 100644 src/mailman/bin/unshunt.py create mode 100644 src/mailman/bin/update.py create mode 100644 src/mailman/bin/version.py create mode 100644 src/mailman/bin/withlist.py create mode 100644 src/mailman/chains/__init__.py create mode 100644 src/mailman/chains/accept.py create mode 100644 src/mailman/chains/base.py create mode 100644 src/mailman/chains/builtin.py create mode 100644 src/mailman/chains/discard.py create mode 100644 src/mailman/chains/headers.py create mode 100644 src/mailman/chains/hold.py create mode 100644 src/mailman/chains/reject.py create mode 100644 src/mailman/commands/__init__.py create mode 100644 src/mailman/commands/cmd_confirm.py create mode 100644 src/mailman/commands/cmd_help.py create mode 100644 src/mailman/commands/cmd_info.py create mode 100644 src/mailman/commands/cmd_leave.py create mode 100644 src/mailman/commands/cmd_lists.py create mode 100644 src/mailman/commands/cmd_password.py create mode 100644 src/mailman/commands/cmd_remove.py create mode 100644 src/mailman/commands/cmd_set.py create mode 100644 src/mailman/commands/cmd_unsubscribe.py create mode 100644 src/mailman/commands/cmd_who.py create mode 100644 src/mailman/commands/docs/echo.txt create mode 100644 src/mailman/commands/docs/end.txt create mode 100644 src/mailman/commands/docs/join.txt create mode 100644 src/mailman/commands/echo.py create mode 100644 src/mailman/commands/end.py create mode 100644 src/mailman/commands/join.py create mode 100644 src/mailman/config/__init__.py create mode 100644 src/mailman/config/config.py create mode 100644 src/mailman/config/mailman.cfg create mode 100644 src/mailman/config/schema.cfg create mode 100644 src/mailman/constants.py create mode 100644 src/mailman/core/__init__.py create mode 100644 src/mailman/core/chains.py create mode 100644 src/mailman/core/errors.py create mode 100644 src/mailman/core/initialize.py create mode 100644 src/mailman/core/logging.py create mode 100644 src/mailman/core/pipelines.py create mode 100644 src/mailman/core/plugins.py create mode 100644 src/mailman/core/rules.py create mode 100644 src/mailman/database/__init__.py create mode 100644 src/mailman/database/address.py create mode 100644 src/mailman/database/language.py create mode 100644 src/mailman/database/listmanager.py create mode 100644 src/mailman/database/mailinglist.py create mode 100644 src/mailman/database/mailman.sql create mode 100644 src/mailman/database/member.py create mode 100644 src/mailman/database/message.py create mode 100644 src/mailman/database/messagestore.py create mode 100644 src/mailman/database/model.py create mode 100644 src/mailman/database/pending.py create mode 100644 src/mailman/database/preferences.py create mode 100644 src/mailman/database/requests.py create mode 100644 src/mailman/database/roster.py create mode 100644 src/mailman/database/transaction.py create mode 100644 src/mailman/database/types.py create mode 100644 src/mailman/database/user.py create mode 100644 src/mailman/database/usermanager.py create mode 100644 src/mailman/database/version.py create mode 100644 src/mailman/docs/__init__.py create mode 100644 src/mailman/docs/addresses.txt create mode 100644 src/mailman/docs/archivers.txt create mode 100644 src/mailman/docs/bounces.txt create mode 100644 src/mailman/docs/chains.txt create mode 100644 src/mailman/docs/domains.txt create mode 100644 src/mailman/docs/languages.txt create mode 100644 src/mailman/docs/lifecycle.txt create mode 100644 src/mailman/docs/listmanager.txt create mode 100644 src/mailman/docs/membership.txt create mode 100644 src/mailman/docs/message.txt create mode 100644 src/mailman/docs/messagestore.txt create mode 100644 src/mailman/docs/mlist-addresses.txt create mode 100644 src/mailman/docs/pending.txt create mode 100644 src/mailman/docs/pipelines.txt create mode 100644 src/mailman/docs/registration.txt create mode 100644 src/mailman/docs/requests.txt create mode 100644 src/mailman/docs/styles.txt create mode 100644 src/mailman/docs/usermanager.txt create mode 100644 src/mailman/docs/users.txt create mode 100644 src/mailman/domain.py create mode 100644 src/mailman/extras/__init__.py create mode 100644 src/mailman/extras/mailman.cfg.in create mode 100644 src/mailman/i18n.py create mode 100644 src/mailman/inject.py create mode 100644 src/mailman/interact.py create mode 100644 src/mailman/interfaces/__init__.py create mode 100644 src/mailman/interfaces/address.py create mode 100644 src/mailman/interfaces/archiver.py create mode 100644 src/mailman/interfaces/chain.py create mode 100644 src/mailman/interfaces/command.py create mode 100644 src/mailman/interfaces/database.py create mode 100644 src/mailman/interfaces/domain.py create mode 100644 src/mailman/interfaces/errors.py create mode 100644 src/mailman/interfaces/handler.py create mode 100644 src/mailman/interfaces/languages.py create mode 100644 src/mailman/interfaces/listmanager.py create mode 100644 src/mailman/interfaces/mailinglist.py create mode 100644 src/mailman/interfaces/member.py create mode 100644 src/mailman/interfaces/messages.py create mode 100644 src/mailman/interfaces/mlistrequest.py create mode 100644 src/mailman/interfaces/mta.py create mode 100644 src/mailman/interfaces/pending.py create mode 100644 src/mailman/interfaces/permissions.py create mode 100644 src/mailman/interfaces/pipeline.py create mode 100644 src/mailman/interfaces/preferences.py create mode 100644 src/mailman/interfaces/registrar.py create mode 100644 src/mailman/interfaces/requests.py create mode 100644 src/mailman/interfaces/roster.py create mode 100644 src/mailman/interfaces/rules.py create mode 100644 src/mailman/interfaces/runner.py create mode 100644 src/mailman/interfaces/styles.py create mode 100644 src/mailman/interfaces/switchboard.py create mode 100644 src/mailman/interfaces/user.py create mode 100644 src/mailman/interfaces/usermanager.py create mode 100644 src/mailman/languages.py create mode 100644 src/mailman/messages/__init__.py create mode 100644 src/mailman/messages/ar/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/ca/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/cs/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/da/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/de/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/de/README.de create mode 100644 src/mailman/messages/docstring.files create mode 100644 src/mailman/messages/es/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/es/README.es create mode 100644 src/mailman/messages/et/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/eu/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/eu/README.eu create mode 100644 src/mailman/messages/fi/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/fi/README.fi create mode 100644 src/mailman/messages/fr/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/fr/README.fr create mode 100644 src/mailman/messages/hr/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/hu/FAQ.hu create mode 100644 src/mailman/messages/hu/INSTALL.hu create mode 100644 src/mailman/messages/hu/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/hu/README.BSD.hu create mode 100644 src/mailman/messages/hu/README.CONTRIB.hu create mode 100644 src/mailman/messages/hu/README.EXIM.hu create mode 100644 src/mailman/messages/hu/README.LINUX.hu create mode 100644 src/mailman/messages/hu/README.MACOSX.hu create mode 100644 src/mailman/messages/hu/README.NETSCAPE.hu create mode 100644 src/mailman/messages/hu/README.POSTFIX.hu create mode 100644 src/mailman/messages/hu/README.QMAIL.hu create mode 100644 src/mailman/messages/hu/README.SENDMAIL.hu create mode 100644 src/mailman/messages/hu/README.USERAGENT.hu create mode 100644 src/mailman/messages/hu/README.hu create mode 100644 src/mailman/messages/hu/UPGRADING.hu create mode 100644 src/mailman/messages/ia/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/it/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/it/README.it create mode 100644 src/mailman/messages/ja/INSTALL create mode 100644 src/mailman/messages/ja/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/ja/README create mode 100644 src/mailman/messages/ja/README.ja create mode 100644 src/mailman/messages/ja/UPGRADING create mode 100644 src/mailman/messages/ja/doc/Defaults.py.in create mode 100644 src/mailman/messages/ja/doc/mailman-install.tex create mode 100644 src/mailman/messages/ja/doc/mailman-member.tex create mode 100644 src/mailman/messages/ko/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/ko/README.ko create mode 100644 src/mailman/messages/lt/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/mailman.pot create mode 100644 src/mailman/messages/marked.files create mode 100644 src/mailman/messages/nl/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/no/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/pl/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/pl/README.pl create mode 100644 src/mailman/messages/pt/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/pt_BR/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/ro/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/ru/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/ru/README.ru create mode 100644 src/mailman/messages/sl/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/sr/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/sr/readme.sr create mode 100644 src/mailman/messages/sv/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/sv/README.sv create mode 100644 src/mailman/messages/tr/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/uk/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/vi/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/zh_CN/LC_MESSAGES/mailman.po create mode 100644 src/mailman/messages/zh_TW/LC_MESSAGES/mailman.po create mode 100644 src/mailman/mta/__init__.py create mode 100644 src/mailman/mta/null.py create mode 100644 src/mailman/mta/postfix.py create mode 100644 src/mailman/mta/smtp_direct.py create mode 100644 src/mailman/options.py create mode 100644 src/mailman/passwords.py create mode 100644 src/mailman/pipeline/__init__.py create mode 100644 src/mailman/pipeline/acknowledge.py create mode 100644 src/mailman/pipeline/after_delivery.py create mode 100644 src/mailman/pipeline/avoid_duplicates.py create mode 100644 src/mailman/pipeline/calculate_recipients.py create mode 100644 src/mailman/pipeline/cleanse.py create mode 100644 src/mailman/pipeline/cleanse_dkim.py create mode 100644 src/mailman/pipeline/cook_headers.py create mode 100644 src/mailman/pipeline/decorate.py create mode 100644 src/mailman/pipeline/docs/ack-headers.txt create mode 100644 src/mailman/pipeline/docs/acknowledge.txt create mode 100644 src/mailman/pipeline/docs/after-delivery.txt create mode 100644 src/mailman/pipeline/docs/archives.txt create mode 100644 src/mailman/pipeline/docs/avoid-duplicates.txt create mode 100644 src/mailman/pipeline/docs/calc-recips.txt create mode 100644 src/mailman/pipeline/docs/cleanse.txt create mode 100644 src/mailman/pipeline/docs/cook-headers.txt create mode 100644 src/mailman/pipeline/docs/decorate.txt create mode 100644 src/mailman/pipeline/docs/digests.txt create mode 100644 src/mailman/pipeline/docs/file-recips.txt create mode 100644 src/mailman/pipeline/docs/filtering.txt create mode 100644 src/mailman/pipeline/docs/nntp.txt create mode 100644 src/mailman/pipeline/docs/reply-to.txt create mode 100644 src/mailman/pipeline/docs/replybot.txt create mode 100644 src/mailman/pipeline/docs/scrubber.txt create mode 100644 src/mailman/pipeline/docs/subject-munging.txt create mode 100644 src/mailman/pipeline/docs/tagger.txt create mode 100644 src/mailman/pipeline/docs/to-outgoing.txt create mode 100644 src/mailman/pipeline/file_recipients.py create mode 100644 src/mailman/pipeline/mime_delete.py create mode 100644 src/mailman/pipeline/moderate.py create mode 100644 src/mailman/pipeline/owner_recipients.py create mode 100644 src/mailman/pipeline/replybot.py create mode 100644 src/mailman/pipeline/scrubber.py create mode 100644 src/mailman/pipeline/tagger.py create mode 100644 src/mailman/pipeline/to_archive.py create mode 100644 src/mailman/pipeline/to_digest.py create mode 100644 src/mailman/pipeline/to_outgoing.py create mode 100644 src/mailman/pipeline/to_usenet.py create mode 100644 src/mailman/queue/__init__.py create mode 100644 src/mailman/queue/archive.py create mode 100644 src/mailman/queue/bounce.py create mode 100644 src/mailman/queue/command.py create mode 100644 src/mailman/queue/docs/OVERVIEW.txt create mode 100644 src/mailman/queue/docs/archiver.txt create mode 100644 src/mailman/queue/docs/command.txt create mode 100644 src/mailman/queue/docs/incoming.txt create mode 100644 src/mailman/queue/docs/lmtp.txt create mode 100644 src/mailman/queue/docs/news.txt create mode 100644 src/mailman/queue/docs/outgoing.txt create mode 100644 src/mailman/queue/docs/runner.txt create mode 100644 src/mailman/queue/docs/switchboard.txt create mode 100644 src/mailman/queue/http.py create mode 100644 src/mailman/queue/incoming.py create mode 100644 src/mailman/queue/lmtp.py create mode 100644 src/mailman/queue/maildir.py create mode 100644 src/mailman/queue/news.py create mode 100644 src/mailman/queue/outgoing.py create mode 100644 src/mailman/queue/pipeline.py create mode 100644 src/mailman/queue/retry.py create mode 100644 src/mailman/queue/virgin.py create mode 100644 src/mailman/rules/__init__.py create mode 100644 src/mailman/rules/administrivia.py create mode 100644 src/mailman/rules/any.py create mode 100644 src/mailman/rules/approved.py create mode 100644 src/mailman/rules/docs/administrivia.txt create mode 100644 src/mailman/rules/docs/approve.txt create mode 100644 src/mailman/rules/docs/emergency.txt create mode 100644 src/mailman/rules/docs/header-matching.txt create mode 100644 src/mailman/rules/docs/implicit-dest.txt create mode 100644 src/mailman/rules/docs/loop.txt create mode 100644 src/mailman/rules/docs/max-size.txt create mode 100644 src/mailman/rules/docs/moderation.txt create mode 100644 src/mailman/rules/docs/news-moderation.txt create mode 100644 src/mailman/rules/docs/no-subject.txt create mode 100644 src/mailman/rules/docs/recipients.txt create mode 100644 src/mailman/rules/docs/rules.txt create mode 100644 src/mailman/rules/docs/suspicious.txt create mode 100644 src/mailman/rules/docs/truth.txt create mode 100644 src/mailman/rules/emergency.py create mode 100644 src/mailman/rules/implicit_dest.py create mode 100644 src/mailman/rules/loop.py create mode 100644 src/mailman/rules/max_recipients.py create mode 100644 src/mailman/rules/max_size.py create mode 100644 src/mailman/rules/moderation.py create mode 100644 src/mailman/rules/news_moderation.py create mode 100644 src/mailman/rules/no_subject.py create mode 100644 src/mailman/rules/suspicious.py create mode 100644 src/mailman/rules/truth.py create mode 100644 src/mailman/styles/__init__.py create mode 100644 src/mailman/styles/default.py create mode 100644 src/mailman/styles/manager.py create mode 100644 src/mailman/templates/__init__.py create mode 100644 src/mailman/templates/en/__init__.py create mode 100644 src/mailman/templates/en/adminaddrchgack.txt create mode 100644 src/mailman/templates/en/admindbdetails.html create mode 100644 src/mailman/templates/en/admindbpreamble.html create mode 100644 src/mailman/templates/en/admindbsummary.html create mode 100644 src/mailman/templates/en/adminsubscribeack.txt create mode 100644 src/mailman/templates/en/adminunsubscribeack.txt create mode 100644 src/mailman/templates/en/admlogin.html create mode 100644 src/mailman/templates/en/approve.txt create mode 100644 src/mailman/templates/en/archidxentry.html create mode 100644 src/mailman/templates/en/archidxfoot.html create mode 100644 src/mailman/templates/en/archidxhead.html create mode 100644 src/mailman/templates/en/archlistend.html create mode 100644 src/mailman/templates/en/archliststart.html create mode 100644 src/mailman/templates/en/archtoc.html create mode 100644 src/mailman/templates/en/archtocentry.html create mode 100644 src/mailman/templates/en/archtocnombox.html create mode 100644 src/mailman/templates/en/article.html create mode 100644 src/mailman/templates/en/bounce.txt create mode 100644 src/mailman/templates/en/checkdbs.txt create mode 100644 src/mailman/templates/en/convert.txt create mode 100644 src/mailman/templates/en/cronpass.txt create mode 100644 src/mailman/templates/en/disabled.txt create mode 100644 src/mailman/templates/en/emptyarchive.html create mode 100644 src/mailman/templates/en/headfoot.html create mode 100644 src/mailman/templates/en/help.txt create mode 100644 src/mailman/templates/en/invite.txt create mode 100644 src/mailman/templates/en/listinfo.html create mode 100644 src/mailman/templates/en/masthead.txt create mode 100644 src/mailman/templates/en/newlist.txt create mode 100644 src/mailman/templates/en/nomoretoday.txt create mode 100644 src/mailman/templates/en/options.html create mode 100644 src/mailman/templates/en/postack.txt create mode 100644 src/mailman/templates/en/postauth.txt create mode 100644 src/mailman/templates/en/postheld.txt create mode 100644 src/mailman/templates/en/private.html create mode 100644 src/mailman/templates/en/probe.txt create mode 100644 src/mailman/templates/en/refuse.txt create mode 100644 src/mailman/templates/en/roster.html create mode 100644 src/mailman/templates/en/subauth.txt create mode 100644 src/mailman/templates/en/subscribe.html create mode 100644 src/mailman/templates/en/subscribeack.txt create mode 100644 src/mailman/templates/en/unsub.txt create mode 100644 src/mailman/templates/en/unsubauth.txt create mode 100644 src/mailman/templates/en/userpass.txt create mode 100644 src/mailman/templates/en/verify.txt create mode 100644 src/mailman/testing/__init__.py create mode 100644 src/mailman/testing/helpers.py create mode 100644 src/mailman/testing/layers.py create mode 100644 src/mailman/testing/mta.py create mode 100644 src/mailman/testing/smtplistener.py create mode 100644 src/mailman/testing/testing.cfg create mode 100644 src/mailman/tests/__init__.py create mode 100644 src/mailman/tests/bounces/__init__.py create mode 100644 src/mailman/tests/bounces/bounce_01.txt create mode 100644 src/mailman/tests/bounces/bounce_02.txt create mode 100644 src/mailman/tests/bounces/bounce_03.txt create mode 100644 src/mailman/tests/bounces/dsn_01.txt create mode 100644 src/mailman/tests/bounces/dsn_02.txt create mode 100644 src/mailman/tests/bounces/dsn_03.txt create mode 100644 src/mailman/tests/bounces/dsn_04.txt create mode 100644 src/mailman/tests/bounces/dsn_05.txt create mode 100644 src/mailman/tests/bounces/dsn_06.txt create mode 100644 src/mailman/tests/bounces/dsn_07.txt create mode 100644 src/mailman/tests/bounces/dsn_08.txt create mode 100644 src/mailman/tests/bounces/dsn_09.txt create mode 100644 src/mailman/tests/bounces/dsn_10.txt create mode 100644 src/mailman/tests/bounces/dsn_11.txt create mode 100644 src/mailman/tests/bounces/dsn_12.txt create mode 100644 src/mailman/tests/bounces/dsn_13.txt create mode 100644 src/mailman/tests/bounces/dsn_14.txt create mode 100644 src/mailman/tests/bounces/dsn_15.txt create mode 100644 src/mailman/tests/bounces/dumbass_01.txt create mode 100644 src/mailman/tests/bounces/exim_01.txt create mode 100644 src/mailman/tests/bounces/groupwise_01.txt create mode 100644 src/mailman/tests/bounces/groupwise_02.txt create mode 100644 src/mailman/tests/bounces/hotpop_01.txt create mode 100644 src/mailman/tests/bounces/llnl_01.txt create mode 100644 src/mailman/tests/bounces/microsoft_01.txt create mode 100644 src/mailman/tests/bounces/microsoft_02.txt create mode 100644 src/mailman/tests/bounces/microsoft_03.txt create mode 100644 src/mailman/tests/bounces/netscape_01.txt create mode 100644 src/mailman/tests/bounces/newmailru_01.txt create mode 100644 src/mailman/tests/bounces/postfix_01.txt create mode 100644 src/mailman/tests/bounces/postfix_02.txt create mode 100644 src/mailman/tests/bounces/postfix_03.txt create mode 100644 src/mailman/tests/bounces/postfix_04.txt create mode 100644 src/mailman/tests/bounces/postfix_05.txt create mode 100644 src/mailman/tests/bounces/qmail_01.txt create mode 100644 src/mailman/tests/bounces/qmail_02.txt create mode 100644 src/mailman/tests/bounces/qmail_03.txt create mode 100644 src/mailman/tests/bounces/qmail_04.txt create mode 100644 src/mailman/tests/bounces/qmail_05.txt create mode 100644 src/mailman/tests/bounces/sendmail_01.txt create mode 100644 src/mailman/tests/bounces/simple_01.txt create mode 100644 src/mailman/tests/bounces/simple_02.txt create mode 100644 src/mailman/tests/bounces/simple_03.txt create mode 100644 src/mailman/tests/bounces/simple_04.txt create mode 100644 src/mailman/tests/bounces/simple_05.txt create mode 100644 src/mailman/tests/bounces/simple_06.txt create mode 100644 src/mailman/tests/bounces/simple_07.txt create mode 100644 src/mailman/tests/bounces/simple_08.txt create mode 100644 src/mailman/tests/bounces/simple_09.txt create mode 100644 src/mailman/tests/bounces/simple_10.txt create mode 100644 src/mailman/tests/bounces/simple_11.txt create mode 100644 src/mailman/tests/bounces/simple_12.txt create mode 100644 src/mailman/tests/bounces/simple_13.txt create mode 100644 src/mailman/tests/bounces/simple_14.txt create mode 100644 src/mailman/tests/bounces/simple_15.txt create mode 100644 src/mailman/tests/bounces/simple_16.txt create mode 100644 src/mailman/tests/bounces/simple_17.txt create mode 100644 src/mailman/tests/bounces/simple_18.txt create mode 100644 src/mailman/tests/bounces/simple_19.txt create mode 100644 src/mailman/tests/bounces/simple_20.txt create mode 100644 src/mailman/tests/bounces/simple_21.txt create mode 100644 src/mailman/tests/bounces/simple_22.txt create mode 100644 src/mailman/tests/bounces/simple_23.txt create mode 100644 src/mailman/tests/bounces/simple_24.txt create mode 100644 src/mailman/tests/bounces/simple_25.txt create mode 100644 src/mailman/tests/bounces/simple_26.txt create mode 100644 src/mailman/tests/bounces/simple_27.txt create mode 100644 src/mailman/tests/bounces/sina_01.txt create mode 100644 src/mailman/tests/bounces/smtp32_01.txt create mode 100644 src/mailman/tests/bounces/smtp32_02.txt create mode 100644 src/mailman/tests/bounces/smtp32_03.txt create mode 100644 src/mailman/tests/bounces/smtp32_04.txt create mode 100644 src/mailman/tests/bounces/smtp32_05.txt create mode 100644 src/mailman/tests/bounces/smtp32_06.txt create mode 100755 src/mailman/tests/bounces/smtp32_07.txt create mode 100644 src/mailman/tests/bounces/yahoo_01.txt create mode 100644 src/mailman/tests/bounces/yahoo_02.txt create mode 100644 src/mailman/tests/bounces/yahoo_03.txt create mode 100644 src/mailman/tests/bounces/yahoo_04.txt create mode 100644 src/mailman/tests/bounces/yahoo_05.txt create mode 100644 src/mailman/tests/bounces/yahoo_06.txt create mode 100644 src/mailman/tests/bounces/yahoo_07.txt create mode 100644 src/mailman/tests/bounces/yahoo_08.txt create mode 100644 src/mailman/tests/bounces/yahoo_09.txt create mode 100644 src/mailman/tests/bounces/yahoo_10.txt create mode 100644 src/mailman/tests/bounces/yale_01.txt create mode 100644 src/mailman/tests/test_bounces.py create mode 100644 src/mailman/tests/test_documentation.py create mode 100644 src/mailman/tests/test_membership.py create mode 100644 src/mailman/tests/test_passwords.py create mode 100644 src/mailman/tests/test_security_mgr.py create mode 100644 src/mailman/utilities/__init__.py create mode 100644 src/mailman/utilities/filesystem.py create mode 100644 src/mailman/utilities/string.py create mode 100644 src/mailman/version.py create mode 100644 src/mailman/web/Cgi/Auth.py create mode 100644 src/mailman/web/Cgi/__init__.py create mode 100644 src/mailman/web/Cgi/admin.py create mode 100644 src/mailman/web/Cgi/admindb.py create mode 100644 src/mailman/web/Cgi/confirm.py create mode 100644 src/mailman/web/Cgi/create.py create mode 100644 src/mailman/web/Cgi/edithtml.py create mode 100644 src/mailman/web/Cgi/listinfo.py create mode 100644 src/mailman/web/Cgi/options.py create mode 100644 src/mailman/web/Cgi/private.py create mode 100644 src/mailman/web/Cgi/rmlist.py create mode 100644 src/mailman/web/Cgi/roster.py create mode 100644 src/mailman/web/Cgi/subscribe.py create mode 100644 src/mailman/web/Cgi/wsgi_app.py create mode 100644 src/mailman/web/Gui/Archive.py create mode 100644 src/mailman/web/Gui/Autoresponse.py create mode 100644 src/mailman/web/Gui/Bounce.py create mode 100644 src/mailman/web/Gui/ContentFilter.py create mode 100644 src/mailman/web/Gui/Digest.py create mode 100644 src/mailman/web/Gui/GUIBase.py create mode 100644 src/mailman/web/Gui/General.py create mode 100644 src/mailman/web/Gui/Language.py create mode 100644 src/mailman/web/Gui/Membership.py create mode 100644 src/mailman/web/Gui/NonDigest.py create mode 100644 src/mailman/web/Gui/Passwords.py create mode 100644 src/mailman/web/Gui/Privacy.py create mode 100644 src/mailman/web/Gui/Topics.py create mode 100644 src/mailman/web/Gui/Usenet.py create mode 100644 src/mailman/web/Gui/__init__.py create mode 100644 src/mailman/web/HTMLFormatter.py create mode 100644 src/mailman/web/__init__.py create mode 100644 src/mailman/web/htmlformat.py (limited to 'src') diff --git a/src/mailman/Archiver/Archiver.py b/src/mailman/Archiver/Archiver.py new file mode 100644 index 000000000..d0b9fbd1b --- /dev/null +++ b/src/mailman/Archiver/Archiver.py @@ -0,0 +1,230 @@ +# 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 . + # USA. + +"""Mixin class for putting new messages in the right place for archival. + +Public archives are separated from private ones. An external archival +mechanism (eg, pipermail) should be pointed to the right places, to do the +archival. +""" + +import os +import errno +import logging + +from cStringIO import StringIO +from string import Template + +from mailman import Mailbox +from mailman import Utils +from mailman.config import config + +log = logging.getLogger('mailman.error') + + + +def makelink(old, new): + try: + os.symlink(old, new) + except OSError, e: + if e.errno <> errno.EEXIST: + raise + +def breaklink(link): + try: + os.unlink(link) + except OSError, e: + if e.errno <> errno.ENOENT: + raise + + + +class Archiver: + # + # Interface to Pipermail. HyperArch.py uses this method to get the + # archive directory for the mailing list + # + def InitVars(self): + # The archive file structure by default is: + # + # archives/ + # private/ + # listname.mbox/ + # listname.mbox + # listname/ + # lots-of-pipermail-stuff + # public/ + # listname.mbox@ -> ../private/listname.mbox + # listname@ -> ../private/listname + # + # IOW, the mbox and pipermail archives are always stored in the + # private archive for the list. This is safe because archives/private + # is always set to o-rx. Public archives have a symlink to get around + # the private directory, pointing directly to the private/listname + # which has o+rx permissions. Private archives do not have the + # symbolic links. + archdir = self.archive_dir(self.fqdn_listname) + omask = os.umask(0) + try: + try: + os.mkdir(archdir+'.mbox', 02775) + except OSError, e: + if e.errno <> errno.EEXIST: + raise + # We also create an empty pipermail archive directory into + # which we'll drop an empty index.html file into. This is so + # that lists that have not yet received a posting have + # /something/ as their index.html, and don't just get a 404. + try: + os.mkdir(archdir, 02775) + except OSError, e: + if e.errno <> errno.EEXIST: + raise + # See if there's an index.html file there already and if not, + # write in the empty archive notice. + indexfile = os.path.join(archdir, 'index.html') + fp = None + try: + fp = open(indexfile) + except IOError, e: + if e.errno <> errno.ENOENT: + raise + omask = os.umask(002) + try: + fp = open(indexfile, 'w') + finally: + os.umask(omask) + fp.write(Utils.maketext( + 'emptyarchive.html', + {'listname': self.real_name, + 'listinfo': self.GetScriptURL('listinfo'), + }, mlist=self)) + if fp: + fp.close() + finally: + os.umask(omask) + + def ArchiveFileName(self): + """The mbox name where messages are left for archive construction.""" + return os.path.join(self.archive_dir() + '.mbox', + self.fqdn_listname + '.mbox') + + def GetBaseArchiveURL(self): + if self.archive_private: + url = self.GetScriptURL('private') + '/index.html' + else: + web_host = config.domains.get(self.host_name, self.host_name) + url = Template(config.PUBLIC_ARCHIVE_URL).safe_substitute( + listname=self.fqdn_listname, + hostname=web_host, + fqdn_listname=self.fqdn_listname, + ) + return url + + def __archive_file(self, afn): + """Open (creating, if necessary) the named archive file.""" + omask = os.umask(002) + try: + return Mailbox.Mailbox(open(afn, 'a+')) + finally: + os.umask(omask) + + # + # old ArchiveMail function, retained under a new name + # for optional archiving to an mbox + # + def __archive_to_mbox(self, post): + """Retain a text copy of the message in an mbox file.""" + try: + afn = self.ArchiveFileName() + mbox = self.__archive_file(afn) + mbox.AppendMessage(post) + mbox.fp.close() + except IOError, msg: + log.error('Archive file access failure:\n\t%s %s', afn, msg) + raise + + def ExternalArchive(self, ar, txt): + cmd = Template(ar).safe_substitute( + listname=self.fqdn_listname, + hostname=self.host_name) + extarch = os.popen(cmd, 'w') + extarch.write(txt) + status = extarch.close() + if status: + log.error('external archiver non-zero exit status: %d\n', + (status & 0xff00) >> 8) + + # + # archiving in real time this is called from list.post(msg) + # + def ArchiveMail(self, msg): + """Store postings in mbox and/or pipermail archive, depending.""" + # Fork so archival errors won't disrupt normal list delivery + if config.ARCHIVE_TO_MBOX == -1: + return + # + # We don't need an extra archiver lock here because we know the list + # itself must be locked. + if config.ARCHIVE_TO_MBOX in (1, 2): + self.__archive_to_mbox(msg) + if config.ARCHIVE_TO_MBOX == 1: + # Archive to mbox only. + return + txt = str(msg) + # should we use the internal or external archiver? + private_p = self.archive_private + if config.PUBLIC_EXTERNAL_ARCHIVER and not private_p: + self.ExternalArchive(config.PUBLIC_EXTERNAL_ARCHIVER, txt) + elif config.PRIVATE_EXTERNAL_ARCHIVER and private_p: + self.ExternalArchive(config.PRIVATE_EXTERNAL_ARCHIVER, txt) + else: + # use the internal archiver + f = StringIO(txt) + import HyperArch + h = HyperArch.HyperArchive(self) + h.processUnixMailbox(f) + h.close() + f.close() + + # + # called from MailList.MailList.Save() + # + def CheckHTMLArchiveDir(self): + # We need to make sure that the archive directory has the right perms + # for public vs private. If it doesn't exist, or some weird + # permissions errors prevent us from stating the directory, it's + # pointless to try to fix the perms, so we just return -scott + if config.ARCHIVE_TO_MBOX == -1: + # Archiving is completely disabled, don't require the skeleton. + return + pubdir = os.path.join(config.PUBLIC_ARCHIVE_FILE_DIR, + self.fqdn_listname) + privdir = self.archive_dir() + pubmbox = pubdir + '.mbox' + privmbox = privdir + '.mbox' + if self.archive_private: + breaklink(pubdir) + breaklink(pubmbox) + else: + # BAW: privdir or privmbox could be nonexistant. We'd get an + # OSError, ENOENT which should be caught and reported properly. + makelink(privdir, pubdir) + # Only make this link if the site has enabled public mbox files + if config.PUBLIC_MBOX: + makelink(privmbox, pubmbox) diff --git a/src/mailman/Archiver/HyperArch.py b/src/mailman/Archiver/HyperArch.py new file mode 100644 index 000000000..d9477cc3f --- /dev/null +++ b/src/mailman/Archiver/HyperArch.py @@ -0,0 +1,1237 @@ +# 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 . + +"""HyperArch: Pipermail archiving for Mailman + + - The Dragon De Monsyne + + TODO: + - Should be able to force all HTML to be regenerated next time the + archive is run, in case a template is changed. + - Run a command to generate tarball of html archives for downloading + (probably in the 'update_dirty_archives' method). +""" + +import os +import re +import sys +import gzip +import time +import errno +import urllib +import logging +import weakref +import binascii + +from email.Charset import Charset +from email.Errors import HeaderParseError +from email.Header import decode_header, make_header +from lazr.config import as_boolean +from locknix.lockfile import Lock +from string import Template + +from mailman import Utils +from mailman import i18n +from mailman.Archiver import HyperDatabase +from mailman.Archiver import pipermail +from mailman.Mailbox import ArchiverMailbox +from mailman.config import config + + +log = logging.getLogger('mailman.error') + +# Set up i18n. Assume the current language has already been set in the caller. +_ = i18n._ + +EMPTYSTRING = '' +NL = '\n' + +# MacOSX has a default stack size that is too small for deeply recursive +# regular expressions. We see this as crashes in the Python test suite when +# running test_re.py and test_sre.py. The fix is to set the stack limit to +# 2048; the general recommendation is to do in the shell before running the +# test suite. But that's inconvenient for a daemon like the qrunner. +# +# AFAIK, this problem only affects the archiver, so we're adding this work +# around to this file (it'll get imported by the bundled pipermail or by the +# bin/arch script. We also only do this on darwin, a.k.a. MacOSX. +if sys.platform == 'darwin': + try: + import resource + except ImportError: + pass + else: + soft, hard = resource.getrlimit(resource.RLIMIT_STACK) + newsoft = min(hard, max(soft, 1024*2048)) + resource.setrlimit(resource.RLIMIT_STACK, (newsoft, hard)) + + + +def html_quote(s, lang=None): + repls = ( ('&', '&'), + ("<", '<'), + (">", '>'), + ('"', '"')) + for thing, repl in repls: + s = s.replace(thing, repl) + return Utils.uncanonstr(s, lang) + + +def url_quote(s): + return urllib.quote(s) + + +def null_to_space(s): + return s.replace('\000', ' ') + + +def sizeof(filename, lang): + try: + size = os.path.getsize(filename) + except OSError, e: + # ENOENT can happen if the .mbox file was moved away or deleted, and + # an explicit mbox file name was given to bin/arch. + if e.errno <> errno.ENOENT: raise + return _('size not available') + if size < 1000: + with i18n.using_language(lang): + out = _(' %(size)i bytes ') + return out + elif size < 1000000: + return ' %d KB ' % (size / 1000) + # GB?? :-) + return ' %d MB ' % (size / 1000000) + + +html_charset = '' + +def CGIescape(arg, lang=None): + if isinstance(arg, unicode): + s = Utils.websafe(arg) + else: + s = Utils.websafe(str(arg)) + return Utils.uncanonstr(s.replace('"', '"'), lang) + +# Parenthesized human name +paren_name_pat = re.compile(r'([(].*[)])') + +# Subject lines preceded with 'Re:' +REpat = re.compile( r"\s*RE\s*(\[\d+\]\s*)?:\s*", re.IGNORECASE) + +# E-mail addresses and URLs in text +emailpat = re.compile(r'([-+,.\w]+@[-+.\w]+)') + +# Argh! This pattern is buggy, and will choke on URLs with GET parameters. +urlpat = re.compile(r'(\w+://[^>)\s]+)') # URLs in text + +# Blank lines +blankpat = re.compile(r'^\s*$') + +# Starting directive +htmlpat = re.compile(r'^\s*\s*$', re.IGNORECASE) +# Ending directive +nohtmlpat = re.compile(r'^\s*\s*$', re.IGNORECASE) +# Match quoted text +quotedpat = re.compile(r'^([>|:]|>)+') + + + +# Like Utils.maketext() but with caching to improve performance. +# +# _templatefilepathcache is used to associate a (templatefile, lang, listname) +# key with the file system path to a template file. This path is the one that +# the Utils.findtext() function has computed is the one to match the values in +# the key tuple. +# +# _templatecache associate a file system path as key with the text +# returned after processing the contents of that file by Utils.findtext() +# +# We keep two caches to reduce the amount of template text kept in memory, +# since the _templatefilepathcache is a many->one mapping and _templatecache +# is a one->one mapping. Imagine 1000 lists all using the same default +# English template. + +_templatefilepathcache = {} +_templatecache = {} + +def quick_maketext(templatefile, dict=None, lang=None, mlist=None): + if mlist is None: + listname = '' + else: + listname = mlist.fqdn_listname + if lang is None: + if mlist is None: + lang = config.mailman.default_language + else: + lang = mlist.preferred_language + cachekey = (templatefile, lang, listname) + filepath = _templatefilepathcache.get(cachekey) + if filepath: + template = _templatecache.get(filepath) + if filepath is None or template is None: + # Use the basic maketext, with defaults to get the raw template + template, filepath = Utils.findtext(templatefile, lang=lang, + raw=True, mlist=mlist) + _templatefilepathcache[cachekey] = filepath + _templatecache[filepath] = template + # Copied from Utils.maketext() + text = template + if dict is not None: + try: + try: + text = Template(template).safe_substitute(**dict) + except UnicodeError: + # Try again after coercing the template to unicode + utemplate = unicode(template, + Utils.GetCharSet(lang), + 'replace') + text = Template(utemplate).safe_substitute(**dict) + except (TypeError, ValueError): + # The template is really screwed up + pass + # Make sure the text is in the given character set, or html-ify any bogus + # characters. + return Utils.uncanonstr(text, lang) + + + +# Note: I'm overriding most, if not all of the pipermail Article class +# here -ddm +# The Article class encapsulates a single posting. The attributes are: +# +# sequence : Sequence number, unique for each article in a set of archives +# subject : Subject +# datestr : The posting date, in human-readable format +# date : The posting date, in purely numeric format +# fromdate : The posting date, in `unixfrom' format +# headers : Any other headers of interest +# author : The author's name (and possibly organization) +# email : The author's e-mail address +# msgid : A unique message ID +# in_reply_to : If !="", this is the msgid of the article being replied to +# references: A (possibly empty) list of msgid's of earlier articles in +# the thread +# body : A list of strings making up the message body + +class Article(pipermail.Article): + __super_init = pipermail.Article.__init__ + __super_set_date = pipermail.Article._set_date + + _last_article_time = time.time() + + def __init__(self, message=None, sequence=0, keepHeaders=[], + lang=config.mailman.default_language, mlist=None): + self.__super_init(message, sequence, keepHeaders) + self.prev = None + self.next = None + # Trim Re: from the subject line + i = 0 + while i != -1: + result = REpat.match(self.subject) + if result: + i = result.end(0) + self.subject = self.subject[i:] + else: + i = -1 + # Useful to keep around + self._lang = lang + self._mlist = mlist + + if as_boolean(config.archiver.pipermail.obscure_email_addresses): + # Avoid i18n side-effects. Note that the language for this + # article (for this list) could be different from the site-wide + # preferred language, so we need to ensure no side-effects will + # occur. Think what happens when executing bin/arch. + with i18n.using_language(lang): + if self.author == self.email: + self.author = self.email = re.sub('@', _(' at '), + self.email) + else: + self.email = re.sub('@', _(' at '), self.email) + # Snag the content-* headers. RFC 1521 states that their values are + # case insensitive. + ctype = message.get('Content-Type', 'text/plain') + cenc = message.get('Content-Transfer-Encoding', '') + self.ctype = ctype.lower() + self.cenc = cenc.lower() + self.decoded = {} + cset = Utils.GetCharSet(mlist.preferred_language) + cset_out = Charset(cset).output_charset or cset + charset = message.get_content_charset(cset_out) + if charset: + charset = charset.lower().strip() + if charset[0]=='"' and charset[-1]=='"': + charset = charset[1:-1] + if charset[0]=="'" and charset[-1]=="'": + charset = charset[1:-1] + try: + body = message.get_payload(decode=True) + except binascii.Error: + body = None + if body and charset != Utils.GetCharSet(self._lang): + # decode body + try: + body = unicode(body, charset) + except (UnicodeError, LookupError): + body = None + if body: + self.body = [l + "\n" for l in body.splitlines()] + + self.decode_headers() + + def __getstate__(self): + d = self.__dict__.copy() + # We definitely don't want to pickle the MailList instance, so just + # pickle a reference to it. + if d.has_key('_mlist'): + mlist = d['_mlist'] + del d['_mlist'] + else: + mlist = None + if mlist: + d['__listname'] = self._mlist.fqdn_listname + else: + d['__listname'] = None + # Delete a few other things we don't want in the pickle + for attr in ('prev', 'next', 'body'): + if d.has_key(attr): + del d[attr] + d['body'] = [] + return d + + def __setstate__(self, d): + # For loading older Articles via pickle. All this stuff was added + # when Simone Piunni and Tokio Kikuchi i18n'ified Pipermail. See SF + # patch #594771. + self.__dict__ = d + listname = d.get('__listname') + if listname: + del d['__listname'] + d['_mlist'] = config.db.list_manager.get(listname) + if not d.has_key('_lang'): + if hasattr(self, '_mlist'): + self._lang = self._mlist.preferred_language + else: + self._lang = config.mailman.default_language + if not d.has_key('cenc'): + self.cenc = None + if not d.has_key('decoded'): + self.decoded = {} + + def setListIfUnset(self, mlist): + if getattr(self, '_mlist', None) is None: + self._mlist = mlist + + def quote(self, buf): + return html_quote(buf, self._lang) + + def decode_headers(self): + """MIME-decode headers. + + If the email, subject, or author attributes contain non-ASCII + characters using the encoded-word syntax of RFC 2047, decoded versions + of those attributes are placed in the self.decoded (a dictionary). + + If the list's charset differs from the header charset, an attempt is + made to decode the headers as Unicode. If that fails, they are left + undecoded. + """ + author = self.decode_charset(self.author) + subject = self.decode_charset(self.subject) + if author: + self.decoded['author'] = author + email = self.decode_charset(self.email) + if email: + self.decoded['email'] = email + if subject: + if as_boolean(config.archiver.pipermail.obscure_email_addresses): + with i18n.using_language(self._lang): + atmark = _(' at ') + subject = re.sub(r'([-+,.\w]+)@([-+.\w]+)', + '\g<1>' + atmark + '\g<2>', subject) + self.decoded['subject'] = subject + self.decoded['stripped'] = self.strip_subject(subject or self.subject) + + def strip_subject(self, subject): + # Strip subject_prefix and Re: for subject sorting + # This part was taken from CookHeaders.py (TK) + prefix = self._mlist.subject_prefix.strip() + if prefix: + prefix_pat = re.escape(prefix) + prefix_pat = '%'.join(prefix_pat.split(r'\%')) + prefix_pat = re.sub(r'%\d*d', r'\s*\d+\s*', prefix_pat) + subject = re.sub(prefix_pat, '', subject) + subject = subject.lstrip() + strip_pat = re.compile('^((RE|AW|SV|VS)(\[\d+\])?:\s*)+', re.I) + stripped = strip_pat.sub('', subject) + return stripped + + def decode_charset(self, field): + # TK: This function was rewritten for unifying to Unicode. + # Convert 'field' into Unicode one line string. + try: + pairs = decode_header(field) + ustr = make_header(pairs).__unicode__() + except (LookupError, UnicodeError, ValueError, HeaderParseError): + # assume list's language + cset = Utils.GetCharSet(self._mlist.preferred_language) + if cset == 'us-ascii': + cset = 'iso-8859-1' # assume this for English list + ustr = unicode(field, cset, 'replace') + return u''.join(ustr.splitlines()) + + def as_html(self): + d = self.__dict__.copy() + # avoid i18n side-effects + with i18n.using_language(self._lang): + d["prev"], d["prev_wsubj"] = self._get_prev() + d["next"], d["next_wsubj"] = self._get_next() + + d["email_html"] = self.quote(self.email) + d["title"] = self.quote(self.subject) + d["subject_html"] = self.quote(self.subject) + d["subject_url"] = url_quote(self.subject) + d["in_reply_to_url"] = url_quote(self.in_reply_to) + if as_boolean(config.archiver.pipermail.obscure_email_addresses): + # Point the mailto url back to the list + author = re.sub('@', _(' at '), self.author) + emailurl = self._mlist.posting_address + else: + author = self.author + emailurl = self.email + d["author_html"] = self.quote(author) + d["email_url"] = url_quote(emailurl) + d["datestr_html"] = self.quote(i18n.ctime(int(self.date))) + d["body"] = self._get_body() + d['listurl'] = self._mlist.script_url('listinfo') + d['listname'] = self._mlist.real_name + d['encoding'] = '' + charset = Utils.GetCharSet(self._lang) + d["encoding"] = html_charset % charset + + self._add_decoded(d) + return quick_maketext( + 'article.html', d, + lang=self._lang, mlist=self._mlist) + + def _get_prev(self): + """Return the href and subject for the previous message""" + if self.prev: + subject = self._get_subject_enc(self.prev) + prev = ('' + % (url_quote(self.prev.filename))) + prev_wsubj = ('
  • ' + _('Previous message (by thread):') + + ' %s\n
  • ' + % (url_quote(self.prev.filename), + self.quote(subject))) + else: + prev = prev_wsubj = "" + return prev, prev_wsubj + + def _get_subject_enc(self, art): + """Return the subject of art, decoded if possible. + + If the charset of the current message and art match and the + article's subject is encoded, decode it. + """ + return art.decoded.get('subject', art.subject) + + def _get_next(self): + """Return the href and subject for the previous message""" + if self.next: + subject = self._get_subject_enc(self.next) + next = ('' + % (url_quote(self.next.filename))) + next_wsubj = ('
  • ' + _('Next message (by thread):') + + ' %s\n
  • ' + % (url_quote(self.next.filename), + self.quote(subject))) + else: + next = next_wsubj = "" + return next, next_wsubj + + _rx_quote = re.compile('=([A-F0-9][A-F0-9])') + _rx_softline = re.compile('=[ \t]*$') + + def _get_body(self): + """Return the message body ready for HTML, decoded if necessary""" + try: + body = self.html_body + except AttributeError: + body = self.body + return null_to_space(EMPTYSTRING.join(body)) + + def _add_decoded(self, d): + """Add encoded-word keys to HTML output""" + for src, dst in (('author', 'author_html'), + ('email', 'email_html'), + ('subject', 'subject_html'), + ('subject', 'title')): + if self.decoded.has_key(src): + d[dst] = self.quote(self.decoded[src]) + + def as_text(self): + d = self.__dict__.copy() + # We need to guarantee a valid From_ line, even if there are + # bososities in the headers. + if not d.get('fromdate', '').strip(): + d['fromdate'] = time.ctime(time.time()) + if not d.get('email', '').strip(): + d['email'] = 'bogus@does.not.exist.com' + if not d.get('datestr', '').strip(): + d['datestr'] = time.ctime(time.time()) + # + headers = ['From %(email)s %(fromdate)s', + 'From: %(email)s (%(author)s)', + 'Date: %(datestr)s', + 'Subject: %(subject)s'] + if d['_in_reply_to']: + headers.append('In-Reply-To: %(_in_reply_to)s') + if d['_references']: + headers.append('References: %(_references)s') + if d['_message_id']: + headers.append('Message-ID: %(_message_id)s') + body = EMPTYSTRING.join(self.body) + cset = Utils.GetCharSet(self._lang) + # Coerce the body to Unicode and replace any invalid characters. + if not isinstance(body, unicode): + body = unicode(body, cset, 'replace') + if as_boolean(config.archiver.pipermail.obscure_email_addresses): + with i18n.using_language(self._lang): + atmark = _(' at ') + body = re.sub(r'([-+,.\w]+)@([-+.\w]+)', + '\g<1>' + atmark + '\g<2>', body) + # Return body to character set of article. + body = body.encode(cset, 'replace') + return NL.join(headers) % d + '\n\n' + body + '\n' + + def _set_date(self, message): + self.__super_set_date(message) + self.fromdate = time.ctime(int(self.date)) + + def loadbody_fromHTML(self,fileobj): + self.body = [] + begin = 0 + while 1: + line = fileobj.readline() + if not line: + break + if not begin: + if line.strip() == '': + begin = 1 + continue + if line.strip() == '': + break + self.body.append(line) + + def finished_update_article(self): + self.body = [] + try: + del self.html_body + except AttributeError: + pass + + +class HyperArchive(pipermail.T): + __super_init = pipermail.T.__init__ + __super_update_archive = pipermail.T.update_archive + __super_update_dirty_archives = pipermail.T.update_dirty_archives + __super_add_article = pipermail.T.add_article + + # some defaults + DIRMODE = 02775 + FILEMODE = 0660 + + VERBOSE = 0 + DEFAULTINDEX = 'thread' + ARCHIVE_PERIOD = 'month' + + THREADLAZY = 0 + THREADLEVELS = 3 + + ALLOWHTML = 1 # "Lines between " handled as is. + SHOWHTML = 0 # Eg, nuke leading whitespace in html manner. + IQUOTES = 1 # Italicize quoted text. + SHOWBR = 0 # Add
    onto every line + + def __init__(self, maillist): + # can't init the database while other processes are writing to it! + dir = maillist.archive_dir() + db = HyperDatabase.HyperDatabase(dir, maillist) + self.__super_init(dir, reload=1, database=db) + + self.maillist = maillist + self._lock_file = None + self.lang = maillist.preferred_language + self.charset = Utils.GetCharSet(maillist.preferred_language) + + if hasattr(self.maillist,'archive_volume_frequency'): + if self.maillist.archive_volume_frequency == 0: + self.ARCHIVE_PERIOD='year' + elif self.maillist.archive_volume_frequency == 2: + self.ARCHIVE_PERIOD='quarter' + elif self.maillist.archive_volume_frequency == 3: + self.ARCHIVE_PERIOD='week' + elif self.maillist.archive_volume_frequency == 4: + self.ARCHIVE_PERIOD='day' + else: + self.ARCHIVE_PERIOD='month' + + yre = r'(?P[0-9]{4,4})' + mre = r'(?P[01][0-9])' + dre = r'(?P[0123][0-9])' + self._volre = { + 'year': '^' + yre + '$', + 'quarter': '^' + yre + r'q(?P[1234])$', + 'month': '^' + yre + r'-(?P[a-zA-Z]+)$', + 'week': r'^Week-of-Mon-' + yre + mre + dre, + 'day': '^' + yre + mre + dre + '$' + } + + def _makeArticle(self, msg, sequence): + return Article(msg, sequence, + lang=self.maillist.preferred_language, + mlist=self.maillist) + + def html_foot(self): + # avoid i18n side-effects + mlist = self.maillist + # Convenience + def quotetime(s): + return html_quote(i18n.ctime(s), self.lang) + with i18n.using_language(mlist.preferred_language): + d = {"lastdate": quotetime(self.lastdate), + "archivedate": quotetime(self.archivedate), + "listinfo": mlist.script_url('listinfo'), + "version": self.version, + } + i = {"thread": _("thread"), + "subject": _("subject"), + "author": _("author"), + "date": _("date") + } + for t in i.keys(): + cap = t[0].upper() + t[1:] + if self.type == cap: + d["%s_ref" % (t)] = "" + else: + d["%s_ref" % (t)] = ('[ %s ]' + % (t, i[t])) + return quick_maketext( + 'archidxfoot.html', d, + mlist=mlist) + + def html_head(self): + # avoid i18n side-effects + mlist = self.maillist + # Convenience + def quotetime(s): + return html_quote(i18n.ctime(s), self.lang) + with i18n.using_language(mlist.preferred_language): + d = {"listname": html_quote(mlist.real_name, self.lang), + "archtype": self.type, + "archive": self.volNameToDesc(self.archive), + "listinfo": mlist.script_url('listinfo'), + "firstdate": quotetime(self.firstdate), + "lastdate": quotetime(self.lastdate), + "size": self.size, + } + i = {"thread": _("thread"), + "subject": _("subject"), + "author": _("author"), + "date": _("date"), + } + for t in i.keys(): + cap = t[0].upper() + t[1:] + if self.type == cap: + d["%s_ref" % (t)] = "" + d["archtype"] = i[t] + else: + d["%s_ref" % (t)] = ('[ %s ]' + % (t, i[t])) + if self.charset: + d["encoding"] = html_charset % self.charset + else: + d["encoding"] = "" + return quick_maketext( + 'archidxhead.html', d, + mlist=mlist) + + def html_TOC(self): + mlist = self.maillist + listname = mlist.fqdn_listname + mbox = os.path.join(mlist.archive_dir()+'.mbox', listname+'.mbox') + d = {"listname": mlist.real_name, + "listinfo": mlist.script_url('listinfo'), + "fullarch": '../%s.mbox/%s.mbox' % (listname, listname), + "size": sizeof(mbox, mlist.preferred_language), + 'meta': '', + } + # Avoid i18n side-effects + with i18n.using_language(mlist.preferred_language): + if not self.archives: + d["noarchive_msg"] = _( + '

    Currently, there are no archives.

    ') + d["archive_listing_start"] = "" + d["archive_listing_end"] = "" + d["archive_listing"] = "" + else: + d["noarchive_msg"] = "" + d["archive_listing_start"] = quick_maketext( + 'archliststart.html', + lang=mlist.preferred_language, + mlist=mlist) + d["archive_listing_end"] = quick_maketext( + 'archlistend.html', + mlist=mlist) + + accum = [] + for a in self.archives: + accum.append(self.html_TOC_entry(a)) + d["archive_listing"] = EMPTYSTRING.join(accum) + # The TOC is always in the charset of the list's preferred language + d['meta'] += html_charset % Utils.GetCharSet(mlist.preferred_language) + # The site can disable public access to the mbox file. + if as_boolean(config.archiver.pipermail.public_mbox): + template = 'archtoc.html' + else: + template = 'archtocnombox.html' + return quick_maketext(template, d, mlist=mlist) + + def html_TOC_entry(self, arch): + # Check to see if the archive is gzip'd or not + txtfile = os.path.join(self.maillist.archive_dir(), arch + '.txt') + gzfile = txtfile + '.gz' + # which exists? .txt.gz first, then .txt + if os.path.exists(gzfile): + file = gzfile + url = arch + '.txt.gz' + templ = '[ ' + _('Gzip\'d Text%(sz)s') \ + + ']' + elif os.path.exists(txtfile): + file = txtfile + url = arch + '.txt' + templ = '[ ' + _('Text%(sz)s') + ']' + else: + # neither found? + file = None + # in Python 1.5.2 we have an easy way to get the size + if file: + textlink = templ % { + 'url': url, + 'sz' : sizeof(file, self.maillist.preferred_language) + } + else: + # there's no archive file at all... hmmm. + textlink = '' + return quick_maketext( + 'archtocentry.html', + {'archive': arch, + 'archivelabel': self.volNameToDesc(arch), + 'textlink': textlink + }, + mlist=self.maillist) + + def GetArchLock(self): + if self._lock_file: + return 1 + self._lock_file = Lock( + os.path.join(config.LOCK_DIR, + self.maillist.fqdn_listname + '-arch.lock')) + try: + self._lock_file.lock(timeout=0.5) + except lockfile.TimeOutError: + return 0 + return 1 + + def DropArchLock(self): + if self._lock_file: + self._lock_file.unlock(unconditionally=1) + self._lock_file = None + + def processListArch(self): + name = self.maillist.ArchiveFileName() + wname= name+'.working' + ename= name+'.err_unarchived' + try: + os.stat(name) + except (IOError,os.error): + #no archive file, nothin to do -ddm + return + + #see if arch is locked here -ddm + if not self.GetArchLock(): + #another archiver is running, nothing to do. -ddm + return + + #if the working file is still here, the archiver may have + # crashed during archiving. Save it, log an error, and move on. + try: + wf = open(wname) + log.error('Archive working file %s present. ' + 'Check %s for possibly unarchived msgs', + wname, ename) + omask = os.umask(007) + try: + ef = open(ename, 'a+') + finally: + os.umask(omask) + ef.seek(1,2) + if ef.read(1) <> '\n': + ef.write('\n') + ef.write(wf.read()) + ef.close() + wf.close() + os.unlink(wname) + except IOError: + pass + os.rename(name,wname) + archfile = open(wname) + self.processUnixMailbox(archfile) + archfile.close() + os.unlink(wname) + self.DropArchLock() + + def get_filename(self, article): + return '%06i.html' % (article.sequence,) + + def get_archives(self, article): + """Return a list of indexes where the article should be filed. + A string can be returned if the list only contains one entry, + and the empty list is legal.""" + res = self.dateToVolName(float(article.date)) + self.message(_("figuring article archives\n")) + self.message(res + "\n") + return res + + def volNameToDesc(self, volname): + volname = volname.strip() + # Don't make these module global constants since we have to runtime + # translate them anyway. + monthdict = [ + '', + _('January'), _('February'), _('March'), _('April'), + _('May'), _('June'), _('July'), _('August'), + _('September'), _('October'), _('November'), _('December') + ] + for each in self._volre.keys(): + match = re.match(self._volre[each], volname) + # Let ValueErrors percolate up + if match: + year = int(match.group('year')) + if each == 'quarter': + d =["", _("First"), _("Second"), _("Third"), _("Fourth") ] + ord = d[int(match.group('quarter'))] + return _("%(ord)s quarter %(year)i") + elif each == 'month': + monthstr = match.group('month').lower() + for i in range(1, 13): + monthname = time.strftime("%B", (1999,i,1,0,0,0,0,1,0)) + if monthstr.lower() == monthname.lower(): + month = monthdict[i] + return _("%(month)s %(year)i") + raise ValueError, "%s is not a month!" % monthstr + elif each == 'week': + month = monthdict[int(match.group("month"))] + day = int(match.group("day")) + return _("The Week Of Monday %(day)i %(month)s %(year)i") + elif each == 'day': + month = monthdict[int(match.group("month"))] + day = int(match.group("day")) + return _("%(day)i %(month)s %(year)i") + else: + return match.group('year') + raise ValueError, "%s is not a valid volname" % volname + +# The following two methods should be inverses of each other. -ddm + + def dateToVolName(self,date): + datetuple=time.localtime(date) + if self.ARCHIVE_PERIOD=='year': + return time.strftime("%Y",datetuple) + elif self.ARCHIVE_PERIOD=='quarter': + if datetuple[1] in [1,2,3]: + return time.strftime("%Yq1",datetuple) + elif datetuple[1] in [4,5,6]: + return time.strftime("%Yq2",datetuple) + elif datetuple[1] in [7,8,9]: + return time.strftime("%Yq3",datetuple) + else: + return time.strftime("%Yq4",datetuple) + elif self.ARCHIVE_PERIOD == 'day': + return time.strftime("%Y%m%d", datetuple) + elif self.ARCHIVE_PERIOD == 'week': + # Reconstruct "seconds since epoch", and subtract weekday + # multiplied by the number of seconds in a day. + monday = time.mktime(datetuple) - datetuple[6] * 24 * 60 * 60 + # Build a new datetuple from this "seconds since epoch" value + datetuple = time.localtime(monday) + return time.strftime("Week-of-Mon-%Y%m%d", datetuple) + # month. -ddm + else: + return time.strftime("%Y-%B",datetuple) + + + def volNameToDate(self, volname): + volname = volname.strip() + for each in self._volre.keys(): + match = re.match(self._volre[each],volname) + if match: + year = int(match.group('year')) + month = 1 + day = 1 + if each == 'quarter': + q = int(match.group('quarter')) + month = (q * 3) - 2 + elif each == 'month': + monthstr = match.group('month').lower() + m = [] + for i in range(1,13): + m.append( + time.strftime("%B",(1999,i,1,0,0,0,0,1,0)).lower()) + try: + month = m.index(monthstr) + 1 + except ValueError: + pass + elif each == 'week' or each == 'day': + month = int(match.group("month")) + day = int(match.group("day")) + try: + return time.mktime((year,month,1,0,0,0,0,1,-1)) + except OverflowError: + return 0.0 + return 0.0 + + def sortarchives(self): + def sf(a, b): + al = self.volNameToDate(a) + bl = self.volNameToDate(b) + if al > bl: + return 1 + elif al < bl: + return -1 + else: + return 0 + if self.ARCHIVE_PERIOD in ('month','year','quarter'): + self.archives.sort(sf) + else: + self.archives.sort() + self.archives.reverse() + + def message(self, msg): + if self.VERBOSE: + f = sys.stderr + f.write(msg) + if msg[-1:] != '\n': + f.write('\n') + f.flush() + + def open_new_archive(self, archive, archivedir): + index_html = os.path.join(archivedir, 'index.html') + try: + os.unlink(index_html) + except: + pass + os.symlink(self.DEFAULTINDEX+'.html',index_html) + + def write_index_header(self): + self.depth=0 + print self.html_head() + if not self.THREADLAZY and self.type=='Thread': + self.message(_("Computing threaded index\n")) + self.updateThreadedIndex() + + def write_index_footer(self): + for i in range(self.depth): + print '' + print self.html_foot() + + def write_index_entry(self, article): + subject = self.get_header("subject", article) + author = self.get_header("author", article) + if as_boolean(config.archiver.pipermail.obscure_email_addresses): + try: + author = re.sub('@', _(' at '), author) + except UnicodeError: + # Non-ASCII author contains '@' ... no valid email anyway + pass + subject = CGIescape(subject, self.lang) + author = CGIescape(author, self.lang) + + d = { + 'filename': urllib.quote(article.filename), + 'subject': subject, + 'sequence': article.sequence, + 'author': author + } + print quick_maketext( + 'archidxentry.html', d, + mlist=self.maillist) + + def get_header(self, field, article): + # if we have no decoded header, return the encoded one + result = article.decoded.get(field) + if result is None: + return getattr(article, field) + # otherwise, the decoded one will be Unicode + return result + + def write_threadindex_entry(self, article, depth): + if depth < 0: + self.message('depth<0') + depth = 0 + if depth > self.THREADLEVELS: + depth = self.THREADLEVELS + if depth < self.depth: + for i in range(self.depth-depth): + print '' + elif depth > self.depth: + for i in range(depth-self.depth): + print '
      ' + print '' % (depth, article.threadKey) + self.depth = depth + self.write_index_entry(article) + + def write_TOC(self): + self.sortarchives() + omask = os.umask(002) + try: + toc = open(os.path.join(self.basedir, 'index.html'), 'w') + finally: + os.umask(omask) + toc.write(self.html_TOC()) + toc.close() + + def write_article(self, index, article, path): + # called by add_article + omask = os.umask(002) + try: + f = open(path, 'w') + finally: + os.umask(omask) + f.write(article.as_html()) + f.close() + + # Write the text article to the text archive. + path = os.path.join(self.basedir, "%s.txt" % index) + omask = os.umask(002) + try: + f = open(path, 'a+') + finally: + os.umask(omask) + f.write(article.as_text()) + f.close() + + def update_archive(self, archive): + self.__super_update_archive(archive) + # only do this if the gzip module was imported globally, and + # gzip'ing was enabled via Defaults.GZIP_ARCHIVE_TXT_FILES. See + # above. + if gzip: + archz = None + archt = None + txtfile = os.path.join(self.basedir, '%s.txt' % archive) + gzipfile = os.path.join(self.basedir, '%s.txt.gz' % archive) + oldgzip = os.path.join(self.basedir, '%s.old.txt.gz' % archive) + try: + # open the plain text file + archt = open(txtfile) + except IOError: + return + try: + os.rename(gzipfile, oldgzip) + archz = gzip.open(oldgzip) + except (IOError, RuntimeError, os.error): + pass + try: + ou = os.umask(002) + newz = gzip.open(gzipfile, 'w') + finally: + # XXX why is this a finally? + os.umask(ou) + if archz: + newz.write(archz.read()) + archz.close() + os.unlink(oldgzip) + # XXX do we really need all this in a try/except? + try: + newz.write(archt.read()) + newz.close() + archt.close() + except IOError: + pass + os.unlink(txtfile) + + _skip_attrs = ('maillist', '_lock_file', 'charset') + + def getstate(self): + d={} + for each in self.__dict__.keys(): + if not (each in self._skip_attrs + or each.upper() == each): + d[each] = self.__dict__[each] + return d + + # Add tags around URLs and e-mail addresses. + + def __processbody_URLquote(self, lines): + # XXX a lot to do here: + # 1. use lines directly, rather than source and dest + # 2. make it clearer + # 3. make it faster + # TK: Prepare for unicode obscure. + atmark = _(' at ') + if lines and isinstance(lines[0], unicode): + atmark = unicode(atmark, Utils.GetCharSet(self.lang), 'replace') + source = lines[:] + dest = lines + last_line_was_quoted = 0 + for i in xrange(0, len(source)): + Lorig = L = source[i] + prefix = suffix = "" + if L is None: + continue + # Italicise quoted text + if self.IQUOTES: + quoted = quotedpat.match(L) + if quoted is None: + last_line_was_quoted = 0 + else: + quoted = quoted.end(0) + prefix = CGIescape(L[:quoted], self.lang) + '' + suffix = '' + if self.SHOWHTML: + suffix += '
      ' + if not last_line_was_quoted: + prefix = '
      ' + prefix + L = L[quoted:] + last_line_was_quoted = 1 + # Check for an e-mail address + L2 = "" + jr = emailpat.search(L) + kr = urlpat.search(L) + while jr is not None or kr is not None: + if jr == None: + j = -1 + else: + j = jr.start(0) + if kr is None: + k = -1 + else: + k = kr.start(0) + if j != -1 and (j < k or k == -1): + text = jr.group(1) + length = len(text) + if as_boolean( + config.archiver.pipermail.obscure_email_addresses): + text = re.sub('@', atmark, text) + URL = self.maillist.script_url('listinfo') + else: + URL = 'mailto:' + text + pos = j + elif k != -1 and (j > k or j == -1): + text = URL = kr.group(1) + length = len(text) + pos = k + else: # j==k + raise ValueError, "j==k: This can't happen!" + #length = len(text) + #self.message("URL: %s %s %s \n" + # % (CGIescape(L[:pos]), URL, CGIescape(text))) + L2 += '%s
      %s' % ( + CGIescape(L[:pos], self.lang), + html_quote(URL), CGIescape(text, self.lang)) + L = L[pos+length:] + jr = emailpat.search(L) + kr = urlpat.search(L) + if jr is None and kr is None: + L = CGIescape(L, self.lang) + L = prefix + L2 + L + suffix + source[i] = None + dest[i] = L + + # Perform Hypermail-style processing of directives + # in message bodies. Lines between and will be written + # out precisely as they are; other lines will be passed to func2 + # for further processing . + + def __processbody_HTML(self, lines): + # XXX need to make this method modify in place + source = lines[:] + dest = lines + l = len(source) + i = 0 + while i < l: + while i < l and htmlpat.match(source[i]) is None: + i = i + 1 + if i < l: + source[i] = None + i = i + 1 + while i < l and nohtmlpat.match(source[i]) is None: + dest[i], source[i] = source[i], None + i = i + 1 + if i < l: + source[i] = None + i = i + 1 + + def format_article(self, article): + # called from add_article + # TBD: Why do the HTML formatting here and keep it in the + # pipermail database? It makes more sense to do the html + # formatting as the article is being written as html and toss + # the data after it has been written to the archive file. + lines = filter(None, article.body) + # Handle directives + if self.ALLOWHTML: + self.__processbody_HTML(lines) + self.__processbody_URLquote(lines) + if not self.SHOWHTML and lines: + lines.insert(0, '
      ')
      +            lines.append('
      ') + else: + # Do fancy formatting here + if self.SHOWBR: + lines = map(lambda x:x + "
      ", lines) + else: + for i in range(0, len(lines)): + s = lines[i] + if s[0:1] in ' \t\n': + lines[i] = '

      ' + s + article.html_body = lines + return article + + def update_article(self, arcdir, article, prev, next): + seq = article.sequence + filename = os.path.join(arcdir, article.filename) + self.message(_('Updating HTML for article %(seq)s')) + try: + f = open(filename) + article.loadbody_fromHTML(f) + f.close() + except IOError, e: + if e.errno <> errno.ENOENT: raise + self.message(_('article file %(filename)s is missing!')) + article.prev = prev + article.next = next + omask = os.umask(002) + try: + f = open(filename, 'w') + finally: + os.umask(omask) + f.write(article.as_html()) + f.close() diff --git a/src/mailman/Archiver/HyperDatabase.py b/src/mailman/Archiver/HyperDatabase.py new file mode 100644 index 000000000..49928d7b3 --- /dev/null +++ b/src/mailman/Archiver/HyperDatabase.py @@ -0,0 +1,339 @@ +# 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 . + +# +# site modules +# +import os +import marshal +import time +import errno + +# +# package/project modules +# +import pipermail +from locknix import lockfile + +CACHESIZE = pipermail.CACHESIZE + +try: + import cPickle + pickle = cPickle +except ImportError: + import pickle + +# +# we're using a python dict in place of +# of bsddb.btree database. only defining +# the parts of the interface used by class HyperDatabase +# only one thing can access this at a time. +# +class DumbBTree: + """Stores pickles of Article objects + + This dictionary-like object stores pickles of all the Article + objects. The object itself is stored using marshal. It would be + much simpler, and probably faster, to store the actual objects in + the DumbBTree and pickle it. + + TBD: Also needs a more sensible name, like IteratableDictionary or + SortedDictionary. + """ + + def __init__(self, path): + self.current_index = 0 + self.path = path + self.lockfile = lockfile.Lock(self.path + ".lock") + self.lock() + self.__dirty = 0 + self.dict = {} + self.sorted = [] + self.load() + + def __repr__(self): + return "DumbBTree(%s)" % self.path + + def __sort(self, dirty=None): + if self.__dirty == 1 or dirty: + self.sorted = self.dict.keys() + self.sorted.sort() + self.__dirty = 0 + + def lock(self): + self.lockfile.lock() + + def unlock(self): + try: + self.lockfile.unlock() + except lockfile.NotLockedError: + pass + + def __delitem__(self, item): + # if first hasn't been called, we can skip the sort + if self.current_index == 0: + del self.dict[item] + self.__dirty = 1 + return + try: + ci = self.sorted[self.current_index] + except IndexError: + ci = None + if ci == item: + try: + ci = self.sorted[self.current_index + 1] + except IndexError: + ci = None + del self.dict[item] + self.__sort(dirty=1) + if ci is not None: + self.current_index = self.sorted.index(ci) + else: + self.current_index = self.current_index + 1 + + def clear(self): + # bulk clearing much faster than deleting each item, esp. with the + # implementation of __delitem__() above :( + self.dict = {} + + def first(self): + self.__sort() # guarantee that the list is sorted + if not self.sorted: + raise KeyError + else: + key = self.sorted[0] + self.current_index = 1 + return key, self.dict[key] + + def last(self): + if not self.sorted: + raise KeyError + else: + key = self.sorted[-1] + self.current_index = len(self.sorted) - 1 + return key, self.dict[key] + + def next(self): + try: + key = self.sorted[self.current_index] + except IndexError: + raise KeyError + self.current_index = self.current_index + 1 + return key, self.dict[key] + + def has_key(self, key): + return self.dict.has_key(key) + + def set_location(self, loc): + if not self.dict.has_key(loc): + raise KeyError + self.current_index = self.sorted.index(loc) + + def __getitem__(self, item): + return self.dict[item] + + def __setitem__(self, item, val): + # if first hasn't been called, then we don't need to worry + # about sorting again + if self.current_index == 0: + self.dict[item] = val + self.__dirty = 1 + return + try: + current_item = self.sorted[self.current_index] + except IndexError: + current_item = item + self.dict[item] = val + self.__sort(dirty=1) + self.current_index = self.sorted.index(current_item) + + def __len__(self): + return len(self.sorted) + + def load(self): + try: + fp = open(self.path) + try: + self.dict = marshal.load(fp) + finally: + fp.close() + except IOError, e: + if e.errno <> errno.ENOENT: raise + pass + except EOFError: + pass + else: + self.__sort(dirty=1) + + def close(self): + omask = os.umask(007) + try: + fp = open(self.path, 'w') + finally: + os.umask(omask) + fp.write(marshal.dumps(self.dict)) + fp.close() + self.unlock() + + +# this is lifted straight out of pipermail with +# the bsddb.btree replaced with above class. +# didn't use inheritance because of all the +# __internal stuff that needs to be here -scott +# +class HyperDatabase(pipermail.Database): + __super_addArticle = pipermail.Database.addArticle + + def __init__(self, basedir, mlist): + self.__cache = {} + self.__currentOpenArchive = None # The currently open indices + self._mlist = mlist + self.basedir = os.path.expanduser(basedir) + # Recently added articles, indexed only by message ID + self.changed={} + + def firstdate(self, archive): + self.__openIndices(archive) + date = 'None' + try: + datekey, msgid = self.dateIndex.first() + date = time.asctime(time.localtime(float(datekey[0]))) + except KeyError: + pass + return date + + def lastdate(self, archive): + self.__openIndices(archive) + date = 'None' + try: + datekey, msgid = self.dateIndex.last() + date = time.asctime(time.localtime(float(datekey[0]))) + except KeyError: + pass + return date + + def numArticles(self, archive): + self.__openIndices(archive) + return len(self.dateIndex) + + def addArticle(self, archive, article, subject=None, author=None, + date=None): + self.__openIndices(archive) + self.__super_addArticle(archive, article, subject, author, date) + + def __openIndices(self, archive): + if self.__currentOpenArchive == archive: + return + self.__closeIndices() + arcdir = os.path.join(self.basedir, 'database') + omask = os.umask(0) + try: + try: + os.mkdir(arcdir, 02770) + except OSError, e: + if e.errno <> errno.EEXIST: raise + finally: + os.umask(omask) + for i in ('date', 'author', 'subject', 'article', 'thread'): + t = DumbBTree(os.path.join(arcdir, archive + '-' + i)) + setattr(self, i + 'Index', t) + self.__currentOpenArchive = archive + + def __closeIndices(self): + for i in ('date', 'author', 'subject', 'thread', 'article'): + attr = i + 'Index' + if hasattr(self, attr): + index = getattr(self, attr) + if i == 'article': + if not hasattr(self, 'archive_length'): + self.archive_length = {} + l = len(index) + self.archive_length[self.__currentOpenArchive] = l + index.close() + delattr(self, attr) + self.__currentOpenArchive = None + + def close(self): + self.__closeIndices() + + def hasArticle(self, archive, msgid): + self.__openIndices(archive) + return self.articleIndex.has_key(msgid) + + def setThreadKey(self, archive, key, msgid): + self.__openIndices(archive) + self.threadIndex[key]=msgid + + def getArticle(self, archive, msgid): + self.__openIndices(archive) + if not self.__cache.has_key(msgid): + # get the pickled object out of the DumbBTree + buf = self.articleIndex[msgid] + article = self.__cache[msgid] = pickle.loads(buf) + # For upgrading older archives + article.setListIfUnset(self._mlist) + else: + article = self.__cache[msgid] + return article + + def first(self, archive, index): + self.__openIndices(archive) + index = getattr(self, index + 'Index') + try: + key, msgid = index.first() + return msgid + except KeyError: + return None + + def next(self, archive, index): + self.__openIndices(archive) + index = getattr(self, index + 'Index') + try: + key, msgid = index.next() + return msgid + except KeyError: + return None + + def getOldestArticle(self, archive, subject): + self.__openIndices(archive) + subject = subject.lower() + try: + key, tempid=self.subjectIndex.set_location(subject) + self.subjectIndex.next() + [subject2, date]= key.split('\0') + if subject!=subject2: return None + return tempid + except KeyError: + return None + + def newArchive(self, archive): + pass + + def clearIndex(self, archive, index): + self.__openIndices(archive) + if hasattr(self.threadIndex, 'clear'): + self.threadIndex.clear() + return + finished=0 + try: + key, msgid=self.threadIndex.first() + except KeyError: finished=1 + while not finished: + del self.threadIndex[key] + try: + key, msgid=self.threadIndex.next() + except KeyError: finished=1 diff --git a/src/mailman/Archiver/__init__.py b/src/mailman/Archiver/__init__.py new file mode 100644 index 000000000..322010acb --- /dev/null +++ b/src/mailman/Archiver/__init__.py @@ -0,0 +1,18 @@ +# 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 . + +from Archiver import * diff --git a/src/mailman/Archiver/pipermail.py b/src/mailman/Archiver/pipermail.py new file mode 100644 index 000000000..19bc05c3f --- /dev/null +++ b/src/mailman/Archiver/pipermail.py @@ -0,0 +1,874 @@ +#! /usr/bin/env python + +import os +import re +import sys +import time +import logging +import mailbox + +import cPickle as pickle + +from cStringIO import StringIO +from email.Utils import parseaddr, parsedate_tz, mktime_tz, formatdate +from string import lowercase + +__version__ = '0.11 (Mailman edition)' +VERSION = __version__ +CACHESIZE = 100 # Number of slots in the cache + +from mailman.Mailbox import ArchiverMailbox +from mailman.core import errors +from mailman.i18n import _ + +SPACE = ' ' + +log = logging.getLogger('mailman.error') + + + +msgid_pat = re.compile(r'(<.*>)') +def strip_separators(s): + "Remove quotes or parenthesization from a Message-ID string" + if not s: + return "" + if s[0] in '"<([' and s[-1] in '">)]': + s = s[1:-1] + return s + +smallNameParts = ['van', 'von', 'der', 'de'] + +def fixAuthor(author): + "Canonicalize a name into Last, First format" + # If there's a comma, guess that it's already in "Last, First" format + if ',' in author: + return author + L = author.split() + i = len(L) - 1 + if i == 0: + return author # The string's one word--forget it + if author.upper() == author or author.lower() == author: + # Damn, the name is all upper- or lower-case. + while i > 0 and L[i-1].lower() in smallNameParts: + i = i - 1 + else: + # Mixed case; assume that small parts of the last name will be + # in lowercase, and check them against the list. + while i>0 and (L[i-1][0] in lowercase or + L[i-1].lower() in smallNameParts): + i = i - 1 + author = SPACE.join(L[-1:] + L[i:-1]) + ', ' + SPACE.join(L[:i]) + return author + +# Abstract class for databases + +class DatabaseInterface: + def __init__(self): pass + def close(self): pass + def getArticle(self, archive, msgid): pass + def hasArticle(self, archive, msgid): pass + def addArticle(self, archive, article, subject=None, author=None, + date=None): pass + def firstdate(self, archive): pass + def lastdate(self, archive): pass + def first(self, archive, index): pass + def next(self, archive, index): pass + def numArticles(self, archive): pass + def newArchive(self, archive): pass + def setThreadKey(self, archive, key, msgid): pass + def getOldestArticle(self, subject): pass + +class Database(DatabaseInterface): + """Define the basic sorting logic for a database + + Assumes that the database internally uses dateIndex, authorIndex, + etc. + """ + + # TBD Factor out more of the logic shared between BSDDBDatabase + # and HyperDatabase and place it in this class. + + def __init__(self): + # This method need not be called by subclasses that do their + # own initialization. + self.dateIndex = {} + self.authorIndex = {} + self.subjectIndex = {} + self.articleIndex = {} + self.changed = {} + + def addArticle(self, archive, article, subject=None, author=None, + date=None): + # create the keys; always end w/ msgid which will be unique + authorkey = (author or article.author, article.date, + article.msgid) + subjectkey = (subject or article.subject, article.date, + article.msgid) + datekey = date or article.date, article.msgid + + # Add the new article + self.dateIndex[datekey] = article.msgid + self.authorIndex[authorkey] = article.msgid + self.subjectIndex[subjectkey] = article.msgid + + self.store_article(article) + self.changed[archive, article.msgid] = None + + parentID = article.parentID + if parentID is not None and self.articleIndex.has_key(parentID): + parent = self.getArticle(archive, parentID) + myThreadKey = parent.threadKey + article.date + '-' + else: + myThreadKey = article.date + '-' + article.threadKey = myThreadKey + key = myThreadKey, article.msgid + self.setThreadKey(archive, key, article.msgid) + + def store_article(self, article): + """Store article without message body to save space""" + # TBD this is not thread safe! + temp = article.body + temp2 = article.html_body + article.body = [] + del article.html_body + self.articleIndex[article.msgid] = pickle.dumps(article) + article.body = temp + article.html_body = temp2 + + +# The Article class encapsulates a single posting. The attributes +# are: +# +# sequence : Sequence number, unique for each article in a set of archives +# subject : Subject +# datestr : The posting date, in human-readable format +# date : The posting date, in purely numeric format +# headers : Any other headers of interest +# author : The author's name (and possibly organization) +# email : The author's e-mail address +# msgid : A unique message ID +# in_reply_to: If != "", this is the msgid of the article being replied to +# references : A (possibly empty) list of msgid's of earlier articles +# in the thread +# body : A list of strings making up the message body + +class Article: + _last_article_time = time.time() + + def __init__(self, message = None, sequence = 0, keepHeaders = []): + if message is None: + return + self.sequence = sequence + + self.parentID = None + self.threadKey = None + # otherwise the current sequence number is used. + id = strip_separators(message['Message-Id']) + if id == "": + self.msgid = str(self.sequence) + else: self.msgid = id + + if message.has_key('Subject'): + self.subject = str(message['Subject']) + else: + self.subject = _('No subject') + if self.subject == "": self.subject = _('No subject') + + self._set_date(message) + + # Figure out the e-mail address and poster's name. Use the From: + # field first, followed by Reply-To: + self.author, self.email = parseaddr(message.get('From', '')) + e = message['Reply-To'] + if not self.email and e is not None: + ignoreauthor, self.email = parseaddr(e) + self.email = strip_separators(self.email) + self.author = strip_separators(self.author) + + if self.author == "": + self.author = self.email + + # Save the In-Reply-To:, References:, and Message-ID: lines + # + # TBD: The original code does some munging on these fields, which + # shouldn't be necessary, but changing this may break code. For + # safety, I save the original headers on different attributes for use + # in writing the plain text periodic flat files. + self._in_reply_to = message['in-reply-to'] + self._references = message['references'] + self._message_id = message['message-id'] + + i_r_t = message['In-Reply-To'] + if i_r_t is None: + self.in_reply_to = '' + else: + match = msgid_pat.search(i_r_t) + if match is None: self.in_reply_to = '' + else: self.in_reply_to = strip_separators(match.group(1)) + + references = message['References'] + if references is None: + self.references = [] + else: + self.references = map(strip_separators, references.split()) + + # Save any other interesting headers + self.headers = {} + for i in keepHeaders: + if message.has_key(i): + self.headers[i] = message[i] + + # Read the message body + s = StringIO(message.get_payload(decode=True)\ + or message.as_string().split('\n\n',1)[1]) + self.body = s.readlines() + + def _set_date(self, message): + def floatdate(header): + missing = [] + datestr = message.get(header, missing) + if datestr is missing: + return None + date = parsedate_tz(datestr) + try: + return mktime_tz(date) + except (TypeError, ValueError, OverflowError): + return None + date = floatdate('date') + if date is None: + date = floatdate('x-list-received-date') + if date is None: + # What's left to try? + date = self._last_article_time + 1 + self._last_article_time = date + self.date = '%011i' % date + self.datestr = message.get('date') \ + or message.get('x-list-received-date') \ + or formatdate(date) + + def __repr__(self): + return '

      ' + + def finished_update_article(self): + pass + +# Pipermail formatter class + +class T: + DIRMODE = 0755 # Mode to give to created directories + FILEMODE = 0644 # Mode to give to created files + INDEX_EXT = ".html" # Extension for indexes + + def __init__(self, basedir = None, reload = 1, database = None): + # If basedir isn't provided, assume the current directory + if basedir is None: + self.basedir = os.getcwd() + else: + basedir = os.path.expanduser(basedir) + self.basedir = basedir + self.database = database + + # If the directory doesn't exist, create it. This code shouldn't get + # run anymore, we create the directory in Archiver.py. It should only + # get used by legacy lists created that are only receiving their first + # message in the HTML archive now -- Marc + try: + os.stat(self.basedir) + except os.error, errdata: + errno, errmsg = errdata + if errno != 2: + raise os.error, errdata + else: + self.message(_('Creating archive directory ') + self.basedir) + omask = os.umask(0) + try: + os.mkdir(self.basedir, self.DIRMODE) + finally: + os.umask(omask) + + # Try to load previously pickled state + try: + if not reload: + raise IOError + f = open(os.path.join(self.basedir, 'pipermail.pck'), 'r') + self.message(_('Reloading pickled archive state')) + d = pickle.load(f) + f.close() + for key, value in d.items(): + setattr(self, key, value) + except (IOError, EOFError): + # No pickled version, so initialize various attributes + self.archives = [] # Archives + self._dirty_archives = [] # Archives that will have to be updated + self.sequence = 0 # Sequence variable used for + # numbering articles + self.update_TOC = 0 # Does the TOC need updating? + # + # make the basedir variable work when passed in as an __init__ arg + # and different from the one in the pickle. Let the one passed in + # as an __init__ arg take precedence if it's stated. This way, an + # archive can be moved from one place to another and still work. + # + if basedir != self.basedir: + self.basedir = basedir + + def close(self): + "Close an archive, save its state, and update any changed archives." + self.update_dirty_archives() + self.update_TOC = 0 + self.write_TOC() + # Save the collective state + self.message(_('Pickling archive state into ') + + os.path.join(self.basedir, 'pipermail.pck')) + self.database.close() + del self.database + + omask = os.umask(007) + try: + f = open(os.path.join(self.basedir, 'pipermail.pck'), 'w') + finally: + os.umask(omask) + pickle.dump(self.getstate(), f) + f.close() + + def getstate(self): + # can override this in subclass + return self.__dict__ + + # + # Private methods + # + # These will be neither overridden nor called by custom archivers. + # + + + # Create a dictionary of various parameters that will be passed + # to the write_index_{header,footer} functions + def __set_parameters(self, archive): + # Determine the earliest and latest date in the archive + firstdate = self.database.firstdate(archive) + lastdate = self.database.lastdate(archive) + + # Get the current time + now = time.asctime(time.localtime(time.time())) + self.firstdate = firstdate + self.lastdate = lastdate + self.archivedate = now + self.size = self.database.numArticles(archive) + self.archive = archive + self.version = __version__ + + # Find the message ID of an article's parent, or return None + # if no parent can be found. + + def __findParent(self, article, children = []): + parentID = None + if article.in_reply_to: + parentID = article.in_reply_to + elif article.references: + # Remove article IDs that aren't in the archive + refs = filter(self.articleIndex.has_key, article.references) + if not refs: + return None + maxdate = self.database.getArticle(self.archive, + refs[0]) + for ref in refs[1:]: + a = self.database.getArticle(self.archive, ref) + if a.date > maxdate.date: + maxdate = a + parentID = maxdate.msgid + else: + # Look for the oldest matching subject + try: + key, tempid = \ + self.subjectIndex.set_location(article.subject) + print key, tempid + self.subjectIndex.next() + [subject, date] = key.split('\0') + print article.subject, subject, date + if subject == article.subject and tempid not in children: + parentID = tempid + except KeyError: + pass + return parentID + + # Update the threaded index completely + def updateThreadedIndex(self): + # Erase the threaded index + self.database.clearIndex(self.archive, 'thread') + + # Loop over all the articles + msgid = self.database.first(self.archive, 'date') + while msgid is not None: + try: + article = self.database.getArticle(self.archive, msgid) + except KeyError: + pass + else: + if article.parentID is None or \ + not self.database.hasArticle(self.archive, + article.parentID): + # then + pass + else: + parent = self.database.getArticle(self.archive, + article.parentID) + article.threadKey = parent.threadKey+article.date+'-' + self.database.setThreadKey(self.archive, + (article.threadKey, article.msgid), + msgid) + msgid = self.database.next(self.archive, 'date') + + # + # Public methods: + # + # These are part of the public interface of the T class, but will + # never be overridden (unless you're trying to do something very new). + + # Update a single archive's indices, whether the archive's been + # dirtied or not. + def update_archive(self, archive): + self.archive = archive + self.message(_("Updating index files for archive [%(archive)s]")) + arcdir = os.path.join(self.basedir, archive) + self.__set_parameters(archive) + + for hdr in ('Date', 'Subject', 'Author'): + self._update_simple_index(hdr, archive, arcdir) + + self._update_thread_index(archive, arcdir) + + def _update_simple_index(self, hdr, archive, arcdir): + self.message(" " + hdr) + self.type = hdr + hdr = hdr.lower() + + self._open_index_file_as_stdout(arcdir, hdr) + self.write_index_header() + count = 0 + # Loop over the index entries + msgid = self.database.first(archive, hdr) + while msgid is not None: + try: + article = self.database.getArticle(self.archive, msgid) + except KeyError: + pass + else: + count = count + 1 + self.write_index_entry(article) + msgid = self.database.next(archive, hdr) + # Finish up this index + self.write_index_footer() + self._restore_stdout() + + def _update_thread_index(self, archive, arcdir): + self.message(_(" Thread")) + self._open_index_file_as_stdout(arcdir, "thread") + self.type = 'Thread' + self.write_index_header() + + # To handle the prev./next in thread pointers, we need to + # track articles 5 at a time. + + # Get the first 5 articles + L = [None] * 5 + i = 2 + msgid = self.database.first(self.archive, 'thread') + + while msgid is not None and i < 5: + L[i] = self.database.getArticle(self.archive, msgid) + i = i + 1 + msgid = self.database.next(self.archive, 'thread') + + while L[2] is not None: + article = L[2] + artkey = None + if article is not None: + artkey = article.threadKey + if artkey is not None: + self.write_threadindex_entry(article, artkey.count('-') - 1) + if self.database.changed.has_key((archive,article.msgid)): + a1 = L[1] + a3 = L[3] + self.update_article(arcdir, article, a1, a3) + if a3 is not None: + self.database.changed[(archive, a3.msgid)] = None + if a1 is not None: + key = archive, a1.msgid + if not self.database.changed.has_key(key): + self.update_article(arcdir, a1, L[0], L[2]) + else: + del self.database.changed[key] + if L[0]: + L[0].finished_update_article() + L = L[1:] # Rotate the list + if msgid is None: + L.append(msgid) + else: + L.append(self.database.getArticle(self.archive, msgid)) + msgid = self.database.next(self.archive, 'thread') + + self.write_index_footer() + self._restore_stdout() + + def _open_index_file_as_stdout(self, arcdir, index_name): + path = os.path.join(arcdir, index_name + self.INDEX_EXT) + omask = os.umask(002) + try: + self.__f = open(path, 'w') + finally: + os.umask(omask) + self.__stdout = sys.stdout + sys.stdout = self.__f + + def _restore_stdout(self): + sys.stdout = self.__stdout + self.__f.close() + del self.__f + del self.__stdout + + # Update only archives that have been marked as "changed". + def update_dirty_archives(self): + for i in self._dirty_archives: + self.update_archive(i) + self._dirty_archives = [] + + # Read a Unix mailbox file from the file object , + # and create a series of Article objects. Each article + # object will then be archived. + + def _makeArticle(self, msg, sequence): + return Article(msg, sequence) + + def processUnixMailbox(self, input, start=None, end=None): + mbox = ArchiverMailbox(input, self.maillist) + if start is None: + start = 0 + counter = 0 + while counter < start: + try: + m = mbox.next() + except errors.DiscardMessage: + continue + if m is None: + return + counter += 1 + while 1: + try: + pos = input.tell() + m = mbox.next() + except errors.DiscardMessage: + continue + except Exception: + log.error('uncaught archiver exception at filepos: %s', pos) + raise + if m is None: + break + if m == '': + # It was an unparseable message + continue + msgid = m.get('message-id', 'n/a') + self.message(_('#%(counter)05d %(msgid)s')) + a = self._makeArticle(m, self.sequence) + self.sequence += 1 + self.add_article(a) + if end is not None and counter >= end: + break + counter += 1 + + def new_archive(self, archive, archivedir): + self.archives.append(archive) + self.update_TOC = 1 + self.database.newArchive(archive) + # If the archive directory doesn't exist, create it + try: + os.stat(archivedir) + except os.error, errdata: + errno, errmsg = errdata + if errno == 2: + omask = os.umask(0) + try: + os.mkdir(archivedir, self.DIRMODE) + finally: + os.umask(omask) + else: + raise os.error, errdata + self.open_new_archive(archive, archivedir) + + def add_article(self, article): + archives = self.get_archives(article) + if not archives: + return + if type(archives) == type(''): + archives = [archives] + + article.filename = filename = self.get_filename(article) + temp = self.format_article(article) + for arch in archives: + self.archive = arch # why do this??? + archivedir = os.path.join(self.basedir, arch) + if arch not in self.archives: + self.new_archive(arch, archivedir) + + # Write the HTML-ized article + self.write_article(arch, temp, os.path.join(archivedir, + filename)) + + if article.decoded.has_key('author'): + author = fixAuthor(article.decoded['author']) + else: + author = fixAuthor(article.author) + if article.decoded.has_key('stripped'): + subject = article.decoded['stripped'].lower() + else: + subject = article.subject.lower() + + article.parentID = parentID = self.get_parent_info(arch, article) + if parentID: + parent = self.database.getArticle(arch, parentID) + article.threadKey = parent.threadKey + article.date + '-' + else: + article.threadKey = article.date + '-' + key = article.threadKey, article.msgid + + self.database.setThreadKey(arch, key, article.msgid) + self.database.addArticle(arch, temp, author=author, + subject=subject) + + if arch not in self._dirty_archives: + self._dirty_archives.append(arch) + + def get_parent_info(self, archive, article): + parentID = None + if article.in_reply_to: + parentID = article.in_reply_to + elif article.references: + refs = self._remove_external_references(article.references) + if refs: + maxdate = self.database.getArticle(archive, refs[0]) + for ref in refs[1:]: + a = self.database.getArticle(archive, ref) + if a.date > maxdate.date: + maxdate = a + parentID = maxdate.msgid + else: + # Get the oldest article with a matching subject, and + # assume this is a follow-up to that article + parentID = self.database.getOldestArticle(archive, + article.subject) + + if parentID and not self.database.hasArticle(archive, parentID): + parentID = None + return parentID + + def write_article(self, index, article, path): + omask = os.umask(002) + try: + f = open(path, 'w') + finally: + os.umask(omask) + temp_stdout, sys.stdout = sys.stdout, f + self.write_article_header(article) + sys.stdout.writelines(article.body) + self.write_article_footer(article) + sys.stdout = temp_stdout + f.close() + + def _remove_external_references(self, refs): + keep = [] + for ref in refs: + if self.database.hasArticle(self.archive, ref): + keep.append(ref) + return keep + + # Abstract methods: these will need to be overridden by subclasses + # before anything useful can be done. + + def get_filename(self, article): + pass + def get_archives(self, article): + """Return a list of indexes where the article should be filed. + A string can be returned if the list only contains one entry, + and the empty list is legal.""" + pass + def format_article(self, article): + pass + def write_index_header(self): + pass + def write_index_footer(self): + pass + def write_index_entry(self, article): + pass + def write_threadindex_entry(self, article, depth): + pass + def write_article_header(self, article): + pass + def write_article_footer(self, article): + pass + def write_article_entry(self, article): + pass + def update_article(self, archivedir, article, prev, next): + pass + def write_TOC(self): + pass + def open_new_archive(self, archive, dir): + pass + def message(self, msg): + pass + + +class BSDDBdatabase(Database): + __super_addArticle = Database.addArticle + + def __init__(self, basedir): + self.__cachekeys = [] + self.__cachedict = {} + self.__currentOpenArchive = None # The currently open indices + self.basedir = os.path.expanduser(basedir) + self.changed = {} # Recently added articles, indexed only by + # message ID + + def firstdate(self, archive): + self.__openIndices(archive) + date = 'None' + try: + date, msgid = self.dateIndex.first() + date = time.asctime(time.localtime(float(date))) + except KeyError: + pass + return date + + def lastdate(self, archive): + self.__openIndices(archive) + date = 'None' + try: + date, msgid = self.dateIndex.last() + date = time.asctime(time.localtime(float(date))) + except KeyError: + pass + return date + + def numArticles(self, archive): + self.__openIndices(archive) + return len(self.dateIndex) + + def addArticle(self, archive, article, subject=None, author=None, + date=None): + self.__openIndices(archive) + self.__super_addArticle(archive, article, subject, author, date) + + # Open the BSDDB files that are being used as indices + # (dateIndex, authorIndex, subjectIndex, articleIndex) + def __openIndices(self, archive): + if self.__currentOpenArchive == archive: + return + + import bsddb + self.__closeIndices() + arcdir = os.path.join(self.basedir, 'database') + omask = os.umask(0) + try: + try: + os.mkdir(arcdir, 02775) + except OSError: + # BAW: Hmm... + pass + finally: + os.umask(omask) + for hdr in ('date', 'author', 'subject', 'article', 'thread'): + path = os.path.join(arcdir, archive + '-' + hdr) + t = bsddb.btopen(path, 'c') + setattr(self, hdr + 'Index', t) + self.__currentOpenArchive = archive + + # Close the BSDDB files that are being used as indices (if they're + # open--this is safe to call if they're already closed) + def __closeIndices(self): + if self.__currentOpenArchive is not None: + pass + for hdr in ('date', 'author', 'subject', 'thread', 'article'): + attr = hdr + 'Index' + if hasattr(self, attr): + index = getattr(self, attr) + if hdr == 'article': + if not hasattr(self, 'archive_length'): + self.archive_length = {} + self.archive_length[self.__currentOpenArchive] = len(index) + index.close() + delattr(self,attr) + self.__currentOpenArchive = None + + def close(self): + self.__closeIndices() + def hasArticle(self, archive, msgid): + self.__openIndices(archive) + return self.articleIndex.has_key(msgid) + def setThreadKey(self, archive, key, msgid): + self.__openIndices(archive) + self.threadIndex[key] = msgid + def getArticle(self, archive, msgid): + self.__openIndices(archive) + if self.__cachedict.has_key(msgid): + self.__cachekeys.remove(msgid) + self.__cachekeys.append(msgid) + return self.__cachedict[msgid] + if len(self.__cachekeys) == CACHESIZE: + delkey, self.__cachekeys = (self.__cachekeys[0], + self.__cachekeys[1:]) + del self.__cachedict[delkey] + s = self.articleIndex[msgid] + article = pickle.loads(s) + self.__cachekeys.append(msgid) + self.__cachedict[msgid] = article + return article + + def first(self, archive, index): + self.__openIndices(archive) + index = getattr(self, index+'Index') + try: + key, msgid = index.first() + return msgid + except KeyError: + return None + def next(self, archive, index): + self.__openIndices(archive) + index = getattr(self, index+'Index') + try: + key, msgid = index.next() + except KeyError: + return None + else: + return msgid + + def getOldestArticle(self, archive, subject): + self.__openIndices(archive) + subject = subject.lower() + try: + key, tempid = self.subjectIndex.set_location(subject) + self.subjectIndex.next() + [subject2, date] = key.split('\0') + if subject != subject2: + return None + return tempid + except KeyError: # XXX what line raises the KeyError? + return None + + def newArchive(self, archive): + pass + + def clearIndex(self, archive, index): + self.__openIndices(archive) + index = getattr(self, index+'Index') + finished = 0 + try: + key, msgid = self.threadIndex.first() + except KeyError: + finished = 1 + while not finished: + del self.threadIndex[key] + try: + key, msgid = self.threadIndex.next() + except KeyError: + finished = 1 + + diff --git a/src/mailman/Bouncers/BouncerAPI.py b/src/mailman/Bouncers/BouncerAPI.py new file mode 100644 index 000000000..f4712ec20 --- /dev/null +++ b/src/mailman/Bouncers/BouncerAPI.py @@ -0,0 +1,64 @@ +# 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 . + +"""Contains all the common functionality for msg bounce scanning API. + +This module can also be used as the basis for a bounce detection testing +framework. When run as a script, it expects two arguments, the listname and +the filename containing the bounce message. +""" + +import sys + +# If a bounce detector returns Stop, that means to just discard the message. +# An example is warning messages for temporary delivery problems. These +# shouldn't trigger a bounce notification, but we also don't want to send them +# on to the list administrator. +Stop = object() + + +BOUNCE_PIPELINE = [ + 'DSN', + 'Qmail', + 'Postfix', + 'Yahoo', + 'Caiwireless', + 'Exchange', + 'Exim', + 'Netscape', + 'Compuserve', + 'Microsoft', + 'GroupWise', + 'SMTP32', + 'SimpleMatch', + 'SimpleWarning', + 'Yale', + 'LLNL', + ] + + + +# msg must be a mimetools.Message +def ScanMessages(mlist, msg): + for module in BOUNCE_PIPELINE: + modname = 'mailman.Bouncers.' + module + __import__(modname) + addrs = sys.modules[modname].process(msg) + if addrs: + # Return addrs even if it is Stop. BounceRunner needs this info. + return addrs + return [] diff --git a/src/mailman/Bouncers/Caiwireless.py b/src/mailman/Bouncers/Caiwireless.py new file mode 100644 index 000000000..3bf03cc62 --- /dev/null +++ b/src/mailman/Bouncers/Caiwireless.py @@ -0,0 +1,45 @@ +# 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 . + +"""Parse mystery style generated by MTA at caiwireless.net.""" + +import re +import email + +tcre = re.compile(r'the following recipients did not receive this message:', + re.IGNORECASE) +acre = re.compile(r'<(?P[^>]*)>') + + + +def process(msg): + if msg.get_content_type() <> 'multipart/mixed': + return None + # simple state machine + # 0 == nothing seen + # 1 == tag line seen + state = 0 + # This format thinks it's a MIME, but it really isn't + for line in email.Iterators.body_line_iterator(msg): + line = line.strip() + if state == 0 and tcre.match(line): + state = 1 + elif state == 1 and line: + mo = acre.match(line) + if not mo: + return None + return [mo.group('addr')] diff --git a/src/mailman/Bouncers/Compuserve.py b/src/mailman/Bouncers/Compuserve.py new file mode 100644 index 000000000..2297a72a9 --- /dev/null +++ b/src/mailman/Bouncers/Compuserve.py @@ -0,0 +1,46 @@ +# 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 . + +"""Compuserve has its own weird format for bounces.""" + +import re +import email + +dcre = re.compile(r'your message could not be delivered', re.IGNORECASE) +acre = re.compile(r'Invalid receiver address: (?P.*)') + + + +def process(msg): + # simple state machine + # 0 = nothing seen yet + # 1 = intro line seen + state = 0 + addrs = [] + for line in email.Iterators.body_line_iterator(msg): + if state == 0: + mo = dcre.search(line) + if mo: + state = 1 + elif state == 1: + mo = dcre.search(line) + if mo: + break + mo = acre.search(line) + if mo: + addrs.append(mo.group('addr')) + return addrs diff --git a/src/mailman/Bouncers/DSN.py b/src/mailman/Bouncers/DSN.py new file mode 100644 index 000000000..37e5bcb83 --- /dev/null +++ b/src/mailman/Bouncers/DSN.py @@ -0,0 +1,99 @@ +# 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 . + +"""Parse RFC 3464 (i.e. DSN) bounce formats. + +RFC 3464 obsoletes 1894 which was the old DSN standard. This module has not +been audited for differences between the two. +""" + +from email.Iterators import typed_subpart_iterator +from email.Utils import parseaddr + +from mailman.Bouncers.BouncerAPI import Stop + + + +def check(msg): + # Iterate over each message/delivery-status subpart + addrs = [] + for part in typed_subpart_iterator(msg, 'message', 'delivery-status'): + if not part.is_multipart(): + # Huh? + continue + # Each message/delivery-status contains a list of Message objects + # which are the header blocks. Iterate over those too. + for msgblock in part.get_payload(): + # We try to dig out the Original-Recipient (which is optional) and + # Final-Recipient (which is mandatory, but may not exactly match + # an address on our list). Some MTA's also use X-Actual-Recipient + # as a synonym for Original-Recipient, but some apparently use + # that for other purposes :( + # + # Also grok out Action so we can do something with that too. + action = msgblock.get('action', '').lower() + # Some MTAs have been observed that put comments on the action. + if action.startswith('delayed'): + return Stop + if not action.startswith('fail'): + # Some non-permanent failure, so ignore this block + continue + params = [] + foundp = False + for header in ('original-recipient', 'final-recipient'): + for k, v in msgblock.get_params([], header): + if k.lower() == 'rfc822': + foundp = True + else: + params.append(k) + if foundp: + # Note that params should already be unquoted. + addrs.extend(params) + break + else: + # MAS: This is a kludge, but SMTP-GATEWAY01.intra.home.dk + # has a final-recipient with an angle-addr and no + # address-type parameter at all. Non-compliant, but ... + for param in params: + if param.startswith('<') and param.endswith('>'): + addrs.append(param[1:-1]) + # Uniquify + rtnaddrs = {} + for a in addrs: + if a is not None: + realname, a = parseaddr(a) + rtnaddrs[a] = True + return rtnaddrs.keys() + + + +def process(msg): + # A DSN has been seen wrapped with a "legal disclaimer" by an outgoing MTA + # in a multipart/mixed outer part. + if msg.is_multipart() and msg.get_content_subtype() == 'mixed': + msg = msg.get_payload()[0] + # The above will suffice if the original message 'parts' were wrapped with + # the disclaimer added, but the original DSN can be wrapped as a + # message/rfc822 part. We need to test that too. + if msg.is_multipart() and msg.get_content_type() == 'message/rfc822': + msg = msg.get_payload()[0] + # The report-type parameter should be "delivery-status", but it seems that + # some DSN generating MTAs don't include this on the Content-Type: header, + # so let's relax the test a bit. + if not msg.is_multipart() or msg.get_content_subtype() <> 'report': + return None + return check(msg) diff --git a/src/mailman/Bouncers/Exchange.py b/src/mailman/Bouncers/Exchange.py new file mode 100644 index 000000000..cf8beefce --- /dev/null +++ b/src/mailman/Bouncers/Exchange.py @@ -0,0 +1,48 @@ +# 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 . + +"""Recognizes (some) Microsoft Exchange formats.""" + +import re +import email.Iterators + +scre = re.compile('did not reach the following recipient') +ecre = re.compile('MSEXCH:') +a1cre = re.compile('SMTP=(?P[^;]+); on ') +a2cre = re.compile('(?P[^ ]+) on ') + + + +def process(msg): + addrs = {} + it = email.Iterators.body_line_iterator(msg) + # Find the start line + for line in it: + if scre.search(line): + break + else: + return [] + # Search each line until we hit the end line + for line in it: + if ecre.search(line): + break + mo = a1cre.search(line) + if not mo: + mo = a2cre.search(line) + if mo: + addrs[mo.group('addr')] = 1 + return addrs.keys() diff --git a/src/mailman/Bouncers/Exim.py b/src/mailman/Bouncers/Exim.py new file mode 100644 index 000000000..0f4e7f4cf --- /dev/null +++ b/src/mailman/Bouncers/Exim.py @@ -0,0 +1,31 @@ +# 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 . + +"""Parse bounce messages generated by Exim. + +Exim adds an X-Failed-Recipients: header to bounce messages containing +an `addresslist' of failed addresses. + +""" + +from email.Utils import getaddresses + + + +def process(msg): + all = msg.get_all('x-failed-recipients', []) + return [a for n, a in getaddresses(all)] diff --git a/src/mailman/Bouncers/GroupWise.py b/src/mailman/Bouncers/GroupWise.py new file mode 100644 index 000000000..e74291217 --- /dev/null +++ b/src/mailman/Bouncers/GroupWise.py @@ -0,0 +1,71 @@ +# 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 . + +"""This appears to be the format for Novell GroupWise and NTMail + +X-Mailer: Novell GroupWise Internet Agent 5.5.3.1 +X-Mailer: NTMail v4.30.0012 +X-Mailer: Internet Mail Service (5.5.2653.19) +""" + +import re +from email.Message import Message +from cStringIO import StringIO + +acre = re.compile(r'<(?P[^>]*)>') + + + +def find_textplain(msg): + if msg.get_content_type() == 'text/plain': + return msg + if msg.is_multipart: + for part in msg.get_payload(): + if not isinstance(part, Message): + continue + ret = find_textplain(part) + if ret: + return ret + return None + + + +def process(msg): + if msg.get_content_type() <> 'multipart/mixed' or not msg['x-mailer']: + return None + addrs = {} + # find the first text/plain part in the message + textplain = find_textplain(msg) + if not textplain: + return None + body = StringIO(textplain.get_payload()) + while 1: + line = body.readline() + if not line: + break + mo = acre.search(line) + if mo: + addrs[mo.group('addr')] = 1 + elif '@' in line: + i = line.find(' ') + if i == 0: + continue + if i < 0: + addrs[line] = 1 + else: + addrs[line[:i]] = 1 + return addrs.keys() diff --git a/src/mailman/Bouncers/LLNL.py b/src/mailman/Bouncers/LLNL.py new file mode 100644 index 000000000..cc1a08542 --- /dev/null +++ b/src/mailman/Bouncers/LLNL.py @@ -0,0 +1,32 @@ +# 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 . + +"""LLNL's custom Sendmail bounce message.""" + +import re +import email + +acre = re.compile(r',\s*(?P\S+@[^,]+),', re.IGNORECASE) + + + +def process(msg): + for line in email.Iterators.body_line_iterator(msg): + mo = acre.search(line) + if mo: + return [mo.group('addr')] + return [] diff --git a/src/mailman/Bouncers/Microsoft.py b/src/mailman/Bouncers/Microsoft.py new file mode 100644 index 000000000..98d27d4ee --- /dev/null +++ b/src/mailman/Bouncers/Microsoft.py @@ -0,0 +1,53 @@ +# 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 . + +"""Microsoft's `SMTPSVC' nears I kin tell.""" + +import re +from cStringIO import StringIO + +scre = re.compile(r'transcript of session follows', re.IGNORECASE) + + + +def process(msg): + if msg.get_content_type() <> 'multipart/mixed': + return None + # Find the first subpart, which has no MIME type + try: + subpart = msg.get_payload(0) + except IndexError: + # The message *looked* like a multipart but wasn't + return None + data = subpart.get_payload() + if isinstance(data, list): + # The message is a multi-multipart, so not a matching bounce + return None + body = StringIO(data) + state = 0 + addrs = [] + while 1: + line = body.readline() + if not line: + break + if state == 0: + if scre.search(line): + state = 1 + if state == 1: + if '@' in line: + addrs.append(line) + return addrs diff --git a/src/mailman/Bouncers/Netscape.py b/src/mailman/Bouncers/Netscape.py new file mode 100644 index 000000000..319329e84 --- /dev/null +++ b/src/mailman/Bouncers/Netscape.py @@ -0,0 +1,89 @@ +# 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 . + +"""Netscape Messaging Server bounce formats. + +I've seen at least one NMS server version 3.6 (envy.gmp.usyd.edu.au) bounce +messages of this format. Bounces come in DSN MIME format, but don't include +any -Recipient: headers. Gotta just parse the text :( + +NMS 4.1 (dfw-smtpin1.email.verio.net) seems even worse, but we'll try to +decipher the format here too. + +""" + +import re +from cStringIO import StringIO + +pcre = re.compile( + r'This Message was undeliverable due to the following reason:', + re.IGNORECASE) + +acre = re.compile( + r'(?Pplease reply to)?.*<(?P[^>]*)>', + re.IGNORECASE) + + + +def flatten(msg, leaves): + # give us all the leaf (non-multipart) subparts + if msg.is_multipart(): + for part in msg.get_payload(): + flatten(part, leaves) + else: + leaves.append(msg) + + + +def process(msg): + # Sigh. Some show NMS 3.6's show + # multipart/report; report-type=delivery-status + # and some show + # multipart/mixed; + if not msg.is_multipart(): + return None + # We're looking for a text/plain subpart occuring before a + # message/delivery-status subpart. + plainmsg = None + leaves = [] + flatten(msg, leaves) + for i, subpart in zip(range(len(leaves)-1), leaves): + if subpart.get_content_type() == 'text/plain': + plainmsg = subpart + break + if not plainmsg: + return None + # Total guesswork, based on captured examples... + body = StringIO(plainmsg.get_payload()) + addrs = [] + while 1: + line = body.readline() + if not line: + break + mo = pcre.search(line) + if mo: + # We found a bounce section, but I have no idea what the official + # format inside here is. :( We'll just search for + # strings. + while 1: + line = body.readline() + if not line: + break + mo = acre.search(line) + if mo and not mo.group('reply'): + addrs.append(mo.group('addr')) + return addrs diff --git a/src/mailman/Bouncers/Postfix.py b/src/mailman/Bouncers/Postfix.py new file mode 100644 index 000000000..cfc97a05e --- /dev/null +++ b/src/mailman/Bouncers/Postfix.py @@ -0,0 +1,86 @@ +# 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 . + +"""Parse bounce messages generated by Postfix. + +This also matches something called `Keftamail' which looks just like Postfix +bounces with the word Postfix scratched out and the word `Keftamail' written +in in crayon. + +It also matches something claiming to be `The BNS Postfix program', and +`SMTP_Gateway'. Everybody's gotta be different, huh? +""" + +import re +from cStringIO import StringIO + + + +def flatten(msg, leaves): + # give us all the leaf (non-multipart) subparts + if msg.is_multipart(): + for part in msg.get_payload(): + flatten(part, leaves) + else: + leaves.append(msg) + + + +# are these heuristics correct or guaranteed? +pcre = re.compile(r'[ \t]*the\s*(bns)?\s*(postfix|keftamail|smtp_gateway)', + re.IGNORECASE) +rcre = re.compile(r'failure reason:$', re.IGNORECASE) +acre = re.compile(r'<(?P[^>]*)>:') + +def findaddr(msg): + addrs = [] + body = StringIO(msg.get_payload()) + # simple state machine + # 0 == nothing found + # 1 == salutation found + state = 0 + while 1: + line = body.readline() + if not line: + break + # preserve leading whitespace + line = line.rstrip() + # yes use match to match at beginning of string + if state == 0 and (pcre.match(line) or rcre.match(line)): + state = 1 + elif state == 1 and line: + mo = acre.search(line) + if mo: + addrs.append(mo.group('addr')) + # probably a continuation line + return addrs + + + +def process(msg): + if msg.get_content_type() not in ('multipart/mixed', 'multipart/report'): + return None + # We're looking for the plain/text subpart with a Content-Description: of + # `notification'. + leaves = [] + flatten(msg, leaves) + for subpart in leaves: + if subpart.get_content_type() == 'text/plain' and \ + subpart.get('content-description', '').lower() == 'notification': + # then... + return findaddr(subpart) + return None diff --git a/src/mailman/Bouncers/Qmail.py b/src/mailman/Bouncers/Qmail.py new file mode 100644 index 000000000..2431da653 --- /dev/null +++ b/src/mailman/Bouncers/Qmail.py @@ -0,0 +1,72 @@ +# 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 . + +"""Parse bounce messages generated by qmail. + +Qmail actually has a standard, called QSBMF (qmail-send bounce message +format), as described in + + http://cr.yp.to/proto/qsbmf.txt + +This module should be conformant. + +""" + +import re +import email.Iterators + +# Other (non-standard?) intros have been observed in the wild. +introtags = [ + 'Hi. This is the', + "We're sorry. There's a problem", + 'Check your send e-mail address.', + 'This is the mail delivery agent at', + 'Unfortunately, your mail was not delivered' + ] +acre = re.compile(r'<(?P[^>]*)>:') + + + +def process(msg): + addrs = [] + # simple state machine + # 0 = nothing seen yet + # 1 = intro paragraph seen + # 2 = recip paragraphs seen + state = 0 + for line in email.Iterators.body_line_iterator(msg): + line = line.strip() + if state == 0: + for introtag in introtags: + if line.startswith(introtag): + state = 1 + break + elif state == 1 and not line: + # Looking for the end of the intro paragraph + state = 2 + elif state == 2: + if line.startswith('-'): + # We're looking at the break paragraph, so we're done + break + # At this point we know we must be looking at a recipient + # paragraph + mo = acre.match(line) + if mo: + addrs.append(mo.group('addr')) + # Otherwise, it must be a continuation line, so just ignore it + # Not looking at anything in particular + return addrs diff --git a/src/mailman/Bouncers/SMTP32.py b/src/mailman/Bouncers/SMTP32.py new file mode 100644 index 000000000..a7fff2ed3 --- /dev/null +++ b/src/mailman/Bouncers/SMTP32.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 . + +"""Something which claims +X-Mailer: + +What the heck is this thing? Here's a recent host: + +% telnet 207.51.255.218 smtp +Trying 207.51.255.218... +Connected to 207.51.255.218. +Escape character is '^]'. +220 X1 NT-ESMTP Server 208.24.118.205 (IMail 6.00 45595-15) + +""" + +import re +import email + +ecre = re.compile('original message follows', re.IGNORECASE) +acre = re.compile(r''' + ( # several different prefixes + user\ mailbox[^:]*: # have been spotted in the + |delivery\ failed[^:]*: # wild... + |unknown\ user[^:]*: + |undeliverable\ +to + |delivery\ userid[^:]*: + ) + \s* # space separator + (?P[^\s]*) # and finally, the address + ''', re.IGNORECASE | re.VERBOSE) + + + +def process(msg): + mailer = msg.get('x-mailer', '') + if not mailer.startswith('. + +"""Recognizes simple heuristically delimited bounces.""" + +import re +import email.Iterators + + + +def _c(pattern): + return re.compile(pattern, re.IGNORECASE) + +# This is a list of tuples of the form +# +# (start cre, end cre, address cre) +# +# where `cre' means compiled regular expression, start is the line just before +# the bouncing address block, end is the line just after the bouncing address +# block, and address cre is the regexp that will recognize the addresses. It +# must have a group called `addr' which will contain exactly and only the +# address that bounced. +PATTERNS = [ + # sdm.de + (_c('here is your list of failed recipients'), + _c('here is your returned mail'), + _c(r'<(?P[^>]*)>')), + # sz-sb.de, corridor.com, nfg.nl + (_c('the following addresses had'), + _c('transcript of session follows'), + _c(r'<(?P[^>]*)>|\(expanded from: [^>)]*)>?\)')), + # robanal.demon.co.uk + (_c('this message was created automatically by mail delivery software'), + _c('original message follows'), + _c('rcpt to:\s*<(?P[^>]*)>')), + # s1.com (InterScan E-Mail VirusWall NT ???) + (_c('message from interscan e-mail viruswall nt'), + _c('end of message'), + _c('rcpt to:\s*<(?P[^>]*)>')), + # Smail + (_c('failed addresses follow:'), + _c('message text follows:'), + _c(r'\s*(?P\S+@\S+)')), + # newmail.ru + (_c('This is the machine generated message from mail service.'), + _c('--- Below the next line is a copy of the message.'), + _c('<(?P[^>]*)>')), + # turbosport.com runs something called `MDaemon 3.5.2' ??? + (_c('The following addresses did NOT receive a copy of your message:'), + _c('--- Session Transcript ---'), + _c('[>]\s*(?P.*)$')), + # usa.net + (_c('Intended recipient:\s*(?P.*)$'), + _c('--------RETURNED MAIL FOLLOWS--------'), + _c('Intended recipient:\s*(?P.*)$')), + # hotpop.com + (_c('Undeliverable Address:\s*(?P.*)$'), + _c('Original message attached'), + _c('Undeliverable Address:\s*(?P.*)$')), + # Another demon.co.uk format + (_c('This message was created automatically by mail delivery'), + _c('^---- START OF RETURNED MESSAGE ----'), + _c("addressed to '(?P[^']*)'")), + # Prodigy.net full mailbox + (_c("User's mailbox is full:"), + _c('Unable to deliver mail.'), + _c("User's mailbox is full:\s*<(?P[^>]*)>")), + # Microsoft SMTPSVC + (_c('The email below could not be delivered to the following user:'), + _c('Old message:'), + _c('<(?P[^>]*)>')), + # Yahoo on behalf of other domains like sbcglobal.net + (_c('Unable to deliver message to the following address\(es\)\.'), + _c('--- Original message follows\.'), + _c('<(?P[^>]*)>:')), + # googlemail.com + (_c('Delivery to the following recipient failed'), + _c('----- Original message -----'), + _c('^\s*(?P[^\s@]+@[^\s@]+)\s*$')), + # kundenserver.de + (_c('A message that you sent could not be delivered'), + _c('^---'), + _c('<(?P[^>]*)>')), + # another kundenserver.de + (_c('A message that you sent could not be delivered'), + _c('^---'), + _c('^(?P[^\s@]+@[^\s@:]+):')), + # thehartford.com + (_c('Delivery to the following recipients failed'), + # this one may or may not have the original message, but there's nothing + # unique to stop on, so stop on the first line of at least 3 characters + # that doesn't start with 'D' (to not stop immediately) and has no '@'. + _c('^[^D][^@]{2,}$'), + _c('^\s*(?P[^\s@]+@[^\s@]+)\s*$')), + # and another thehartfod.com/hartfordlife.com + (_c('^Your message\s*$'), + _c('^because:'), + _c('^\s*(?P[^\s@]+@[^\s@]+)\s*$')), + # kviv.be (InterScan NT) + (_c('^Unable to deliver message to'), + _c(r'\*+\s+End of message\s+\*+'), + _c('<(?P[^>]*)>')), + # earthlink.net supported domains + (_c('^Sorry, unable to deliver your message to'), + _c('^A copy of the original message'), + _c('\s*(?P[^\s@]+@[^\s@]+)\s+')), + # ademe.fr + (_c('^A message could not be delivered to:'), + _c('^Subject:'), + _c('^\s*(?P[^\s@]+@[^\s@]+)\s*$')), + # andrew.ac.jp + (_c('^Invalid final delivery userid:'), + _c('^Original message follows.'), + _c('\s*(?P[^\s@]+@[^\s@]+)\s*$')), + # E500_SMTP_Mail_Service@lerctr.org + (_c('------ Failed Recipients ------'), + _c('-------- Returned Mail --------'), + _c('<(?P[^>]*)>')), + # cynergycom.net + (_c('A message that you sent could not be delivered'), + _c('^---'), + _c('(?P[^\s@]+@[^\s@)]+)')), + # LSMTP for Windows + (_c('^--> Error description:\s*$'), + _c('^Error-End:'), + _c('^Error-for:\s+(?P[^\s@]+@[^\s@]+)')), + # Qmail with a tri-language intro beginning in spanish + (_c('Your message could not be delivered'), + _c('^-'), + _c('<(?P[^>]*)>:')), + # socgen.com + (_c('Your message could not be delivered to'), + _c('^\s*$'), + _c('(?P[^\s@]+@[^\s@]+)')), + # dadoservice.it + (_c('Your message has encountered delivery problems'), + _c('Your message reads'), + _c('addressed to\s*(?P[^\s@]+@[^\s@)]+)')), + # gomaps.com + (_c('Did not reach the following recipient'), + _c('^\s*$'), + _c('\s(?P[^\s@]+@[^\s@]+)')), + # EYOU MTA SYSTEM + (_c('This is the deliver program at'), + _c('^-'), + _c('^(?P[^\s@]+@[^\s@<>]+)')), + # A non-standard qmail at ieo.it + (_c('this is the email server at'), + _c('^-'), + _c('\s(?P[^\s@]+@[^\s@]+)[\s,]')), + # pla.net.py (MDaemon.PRO ?) + (_c('- no such user here'), + _c('There is no user'), + _c('^(?P[^\s@]+@[^\s@]+)\s')), + # Next one goes here... + ] + + + +def process(msg, patterns=None): + if patterns is None: + patterns = PATTERNS + # simple state machine + # 0 = nothing seen yet + # 1 = intro seen + addrs = {} + # MAS: This is a mess. The outer loop used to be over the message + # so we only looped through the message once. Looping through the + # message for each set of patterns is obviously way more work, but + # if we don't do it, problems arise because scre from the wrong + # pattern set matches first and then acre doesn't match. The + # alternative is to split things into separate modules, but then + # we process the message multiple times anyway. + for scre, ecre, acre in patterns: + state = 0 + for line in email.Iterators.body_line_iterator(msg): + if state == 0: + if scre.search(line): + state = 1 + if state == 1: + mo = acre.search(line) + if mo: + addr = mo.group('addr') + if addr: + addrs[mo.group('addr')] = 1 + elif ecre.search(line): + break + if addrs: + break + return addrs.keys() diff --git a/src/mailman/Bouncers/SimpleWarning.py b/src/mailman/Bouncers/SimpleWarning.py new file mode 100644 index 000000000..ab18d2530 --- /dev/null +++ b/src/mailman/Bouncers/SimpleWarning.py @@ -0,0 +1,62 @@ +# 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 . + +"""Recognizes simple heuristically delimited warnings.""" + +from mailman.Bouncers.BouncerAPI import Stop +from mailman.Bouncers.SimpleMatch import _c +from mailman.Bouncers.SimpleMatch import process as _process + + + +# This is a list of tuples of the form +# +# (start cre, end cre, address cre) +# +# where `cre' means compiled regular expression, start is the line just before +# the bouncing address block, end is the line just after the bouncing address +# block, and address cre is the regexp that will recognize the addresses. It +# must have a group called `addr' which will contain exactly and only the +# address that bounced. +patterns = [ + # pop3.pta.lia.net + (_c('The address to which the message has not yet been delivered is'), + _c('No action is required on your part'), + _c(r'\s*(?P\S+@\S+)\s*')), + # This is from MessageSwitch. It is a kludge because the text that + # identifies it as a warning only comes after the address. We can't + # use ecre, because it really isn't significant, so we fake it. Once + # we see the start, we know it's a warning, and we're going to return + # Stop anyway, so we match anything for the address and end. + (_c('This is just a warning, you do not need to take any action'), + _c('.+'), + _c('(?P.+)')), + # Symantec_AntiVirus_for_SMTP_Gateways - see comments for MessageSwitch + (_c('Delivery attempts will continue to be made'), + _c('.+'), + _c('(?P.+)')), + # Next one goes here... + ] + + + +def process(msg): + if _process(msg, patterns): + # It's a recognized warning so stop now + return Stop + else: + return [] diff --git a/src/mailman/Bouncers/Sina.py b/src/mailman/Bouncers/Sina.py new file mode 100644 index 000000000..a6b8e0911 --- /dev/null +++ b/src/mailman/Bouncers/Sina.py @@ -0,0 +1,48 @@ +# 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 . + +"""sina.com bounces""" + +import re +from email import Iterators + +acre = re.compile(r'<(?P[^>]*)>') + + + +def process(msg): + if msg.get('from', '').lower() <> 'mailer-daemon@sina.com': + print 'out 1' + return [] + if not msg.is_multipart(): + print 'out 2' + return [] + # The interesting bits are in the first text/plain multipart + part = None + try: + part = msg.get_payload(0) + except IndexError: + pass + if not part: + print 'out 3' + return [] + addrs = {} + for line in Iterators.body_line_iterator(part): + mo = acre.match(line) + if mo: + addrs[mo.group('addr')] = 1 + return addrs.keys() diff --git a/src/mailman/Bouncers/Yahoo.py b/src/mailman/Bouncers/Yahoo.py new file mode 100644 index 000000000..b0480b818 --- /dev/null +++ b/src/mailman/Bouncers/Yahoo.py @@ -0,0 +1,54 @@ +# 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 . + +"""Yahoo! has its own weird format for bounces.""" + +import re +import email +from email.Utils import parseaddr + +tcre = re.compile(r'message\s+from\s+yahoo\.\S+', re.IGNORECASE) +acre = re.compile(r'<(?P[^>]*)>:') +ecre = re.compile(r'--- Original message follows') + + + +def process(msg): + # Yahoo! bounces seem to have a known subject value and something called + # an x-uidl: header, the value of which seems unimportant. + sender = parseaddr(msg.get('from', '').lower())[1] or '' + if not sender.startswith('mailer-daemon@yahoo'): + return None + addrs = [] + # simple state machine + # 0 == nothing seen + # 1 == tag line seen + state = 0 + for line in email.Iterators.body_line_iterator(msg): + line = line.strip() + if state == 0 and tcre.match(line): + state = 1 + elif state == 1: + mo = acre.match(line) + if mo: + addrs.append(mo.group('addr')) + continue + mo = ecre.match(line) + if mo: + # we're at the end of the error response + break + return addrs diff --git a/src/mailman/Bouncers/Yale.py b/src/mailman/Bouncers/Yale.py new file mode 100644 index 000000000..956dfb838 --- /dev/null +++ b/src/mailman/Bouncers/Yale.py @@ -0,0 +1,80 @@ +# Copyright (C) 2000-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 . + +"""Yale's mail server is pretty dumb. + +Its reports include the end user's name, but not the full domain. I think we +can usually guess it right anyway. This is completely based on examination of +the corpse, and is subject to failure whenever Yale even slightly changes +their MTA. :( + +""" + +import re +from cStringIO import StringIO +from email.Utils import getaddresses + +scre = re.compile(r'Message not delivered to the following', re.IGNORECASE) +ecre = re.compile(r'Error Detail', re.IGNORECASE) +acre = re.compile(r'\s+(?P\S+)\s+') + + + +def process(msg): + if msg.is_multipart(): + return None + try: + whofrom = getaddresses([msg.get('from', '')])[0][1] + if not whofrom: + return None + username, domain = whofrom.split('@', 1) + except (IndexError, ValueError): + return None + if username.lower() <> 'mailer-daemon': + return None + parts = domain.split('.') + parts.reverse() + for part1, part2 in zip(parts, ('edu', 'yale')): + if part1 <> part2: + return None + # Okay, we've established that the bounce came from the mailer-daemon at + # yale.edu. Let's look for a name, and then guess the relevant domains. + names = {} + body = StringIO(msg.get_payload()) + state = 0 + # simple state machine + # 0 == init + # 1 == intro found + while 1: + line = body.readline() + if not line: + break + if state == 0 and scre.search(line): + state = 1 + elif state == 1 and ecre.search(line): + break + elif state == 1: + mo = acre.search(line) + if mo: + names[mo.group('addr')] = 1 + # Now we have a bunch of names, these are either @yale.edu or + # @cs.yale.edu. Add them both. + addrs = [] + for name in names.keys(): + addrs.append(name + '@yale.edu') + addrs.append(name + '@cs.yale.edu') + return addrs diff --git a/src/mailman/Bouncers/__init__.py b/src/mailman/Bouncers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/mailman/Mailbox.py b/src/mailman/Mailbox.py new file mode 100644 index 000000000..3a2f079c4 --- /dev/null +++ b/src/mailman/Mailbox.py @@ -0,0 +1,106 @@ +# 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 . + +"""Extend mailbox.UnixMailbox. +""" + +import sys +import email +import mailbox + +from email.errors import MessageParseError +from email.generator import Generator + +from mailman.Message import Message +from mailman.config import config + + + +def _safeparser(fp): + try: + return email.message_from_file(fp, Message) + except MessageParseError: + # Don't return None since that will stop a mailbox iterator + return '' + + + +class Mailbox(mailbox.PortableUnixMailbox): + def __init__(self, fp): + mailbox.PortableUnixMailbox.__init__(self, fp, _safeparser) + + # msg should be an rfc822 message or a subclass. + def AppendMessage(self, msg): + # Check the last character of the file and write a newline if it isn't + # a newline (but not at the beginning of an empty file). + try: + self.fp.seek(-1, 2) + except IOError, e: + # Assume the file is empty. We can't portably test the error code + # returned, since it differs per platform. + pass + else: + if self.fp.read(1) <> '\n': + self.fp.write('\n') + # Seek to the last char of the mailbox + self.fp.seek(1, 2) + # Create a Generator instance to write the message to the file + g = Generator(self.fp) + g.flatten(msg, unixfrom=True) + # Add one more trailing newline for separation with the next message + # to be appended to the mbox. + print >> self.fp + + + +# This stuff is used by pipermail.py:processUnixMailbox(). It provides an +# opportunity for the built-in archiver to scrub archived messages of nasty +# things like attachments and such... +def _archfactory(mailbox): + # The factory gets a file object, but it also needs to have a MailList + # object, so the clearest way to do this is to build a factory + # function that has a reference to the mailbox object, which in turn holds + # a reference to the mailing list. Nested scopes would help here, BTW, + # but we can't rely on them being around (e.g. Python 2.0). + def scrubber(fp, mailbox=mailbox): + msg = _safeparser(fp) + if msg == '': + return msg + return mailbox.scrub(msg) + return scrubber + + +class ArchiverMailbox(Mailbox): + # This is a derived class which is instantiated with a reference to the + # MailList object. It is build such that the factory calls back into its + # scrub() method, giving the scrubber module a chance to do its thing + # before the message is archived. + def __init__(self, fp, mlist): + scrubber_module = config.scrubber.archive_scrubber + if scrubber_module: + __import__(scrubber_module) + self._scrubber = sys.modules[scrubber_module].process + else: + self._scrubber = None + self._mlist = mlist + mailbox.PortableUnixMailbox.__init__(self, fp, _archfactory(self)) + + def scrub(self, msg): + if self._scrubber: + return self._scrubber(self._mlist, msg) + else: + return msg diff --git a/src/mailman/Message.py b/src/mailman/Message.py new file mode 100644 index 000000000..ac41a758c --- /dev/null +++ b/src/mailman/Message.py @@ -0,0 +1,297 @@ +# 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 . + +"""Standard Mailman message object. + +This is a subclass of email.message.Message but provides a slightly extended +interface which is more convenient for use inside Mailman. +""" + +import re +import email +import email.message +import email.utils + +from email.charset import Charset +from email.header import Header +from lazr.config import as_boolean + +from mailman import Utils +from mailman.config import config + +COMMASPACE = ', ' + +mo = re.match(r'([\d.]+)', email.__version__) +VERSION = tuple(int(s) for s in mo.group().split('.')) + + + +class Message(email.message.Message): + def __init__(self): + # We need a version number so that we can optimize __setstate__() + self.__version__ = VERSION + email.message.Message.__init__(self) + + def __getitem__(self, key): + value = email.message.Message.__getitem__(self, key) + if isinstance(value, str): + return unicode(value, 'ascii') + return value + + def get(self, name, failobj=None): + value = email.message.Message.get(self, name, failobj) + if isinstance(value, str): + return unicode(value, 'ascii') + return value + + def get_all(self, name, failobj=None): + missing = object() + all_values = email.message.Message.get_all(self, name, missing) + if all_values is missing: + return failobj + return [(unicode(value, 'ascii') if isinstance(value, str) else value) + for value in all_values] + + # BAW: For debugging w/ bin/dumpdb. Apparently pprint uses repr. + def __repr__(self): + return self.__str__() + + def __setstate__(self, d): + # The base class attributes have changed over time. Which could + # affect Mailman if messages are sitting in the queue at the time of + # upgrading the email package. We shouldn't burden email with this, + # so we handle schema updates here. + self.__dict__ = d + # We know that email 2.4.3 is up-to-date + version = d.get('__version__', (0, 0, 0)) + d['__version__'] = VERSION + if version >= VERSION: + return + # Messages grew a _charset attribute between email version 0.97 and 1.1 + if not d.has_key('_charset'): + self._charset = None + # Messages grew a _default_type attribute between v2.1 and v2.2 + if not d.has_key('_default_type'): + # We really have no idea whether this message object is contained + # inside a multipart/digest or not, so I think this is the best we + # can do. + self._default_type = 'text/plain' + # Header instances used to allow both strings and Charsets in their + # _chunks, but by email 2.4.3 now it's just Charsets. + headers = [] + hchanged = 0 + for k, v in self._headers: + if isinstance(v, Header): + chunks = [] + cchanged = 0 + for s, charset in v._chunks: + if isinstance(charset, str): + charset = Charset(charset) + cchanged = 1 + chunks.append((s, charset)) + if cchanged: + v._chunks = chunks + hchanged = 1 + headers.append((k, v)) + if hchanged: + self._headers = headers + + # I think this method ought to eventually be deprecated + def get_sender(self): + """Return the address considered to be the author of the email. + + This can return either the From: header, the Sender: header or the + envelope header (a.k.a. the unixfrom header). The first non-empty + header value found is returned. However the search order is + determined by the following: + + - If config.mailman.use_envelope_sender is true, then the search order + is Sender:, From:, unixfrom + + - Otherwise, the search order is From:, Sender:, unixfrom + + unixfrom should never be empty. The return address is always + lower cased. + + This method differs from get_senders() in that it returns one and only + one address, and uses a different search order. + """ + senderfirst = as_boolean(config.mailman.use_envelope_sender) + if senderfirst: + headers = ('sender', 'from') + else: + headers = ('from', 'sender') + for h in headers: + # Use only the first occurrance of Sender: or From:, although it's + # not likely there will be more than one. + fieldval = self[h] + if not fieldval: + continue + addrs = email.utils.getaddresses([fieldval]) + try: + realname, address = addrs[0] + except IndexError: + continue + if address: + break + else: + # We didn't find a non-empty header, so let's fall back to the + # unixfrom address. This should never be empty, but if it ever + # is, it's probably a Really Bad Thing. Further, we just assume + # that if the unixfrom exists, the second field is the address. + unixfrom = self.get_unixfrom() + if unixfrom: + address = unixfrom.split()[1] + else: + # TBD: now what?! + address = '' + return address.lower() + + def get_senders(self): + """Return a list of addresses representing the author of the email. + + The list will contain the following addresses (in order) + depending on availability: + + 1. From: + 2. unixfrom (From_) + 3. Reply-To: + 4. Sender: + + The return addresses are always lower cased. + """ + pairs = [] + for header in config.mailman.sender_headers.split(): + header = header.lower() + if header == 'from_': + # get_unixfrom() returns None if there's no envelope + unix_from = self.get_unixfrom() + fieldval = (unix_from if unix_from is not None else '') + try: + pairs.append(('', fieldval.split()[1])) + except IndexError: + # Ignore badly formatted unixfroms + pass + else: + fieldvals = self.get_all(header) + if fieldvals: + pairs.extend(email.utils.getaddresses(fieldvals)) + authors = [] + for pair in pairs: + address = pair[1] + if address is not None: + address = address.lower() + authors.append(address) + return authors + + def get_filename(self, failobj=None): + """Some MUA have bugs in RFC2231 filename encoding and cause + Mailman to stop delivery in Scrubber.py (called from ToDigest.py). + """ + try: + filename = email.message.Message.get_filename(self, failobj) + return filename + except (UnicodeError, LookupError, ValueError): + return failobj + + + +class UserNotification(Message): + """Class for internally crafted messages.""" + + def __init__(self, recip, sender, subject=None, text=None, lang=None): + Message.__init__(self) + charset = 'us-ascii' + if lang is not None: + charset = Utils.GetCharSet(lang) + if text is not None: + self.set_payload(text.encode(charset), charset) + if subject is None: + subject = '(no subject)' + self['Subject'] = Header(subject.encode(charset), charset, + header_name='Subject', errors='replace') + self['From'] = sender + if isinstance(recip, list): + self['To'] = COMMASPACE.join(recip) + self.recips = recip + else: + self['To'] = recip + self.recips = [recip] + + def send(self, mlist, **_kws): + """Sends the message by enqueuing it to the 'virgin' queue. + + This is used for all internally crafted messages. + """ + # Since we're crafting the message from whole cloth, let's make sure + # this message has a Message-ID. + if 'message-id' not in self: + self['Message-ID'] = email.utils.make_msgid() + # Ditto for Date: as required by RFC 2822. + if 'date' not in self: + self['Date'] = email.utils.formatdate(localtime=True) + # UserNotifications are typically for admin messages, and for messages + # other than list explosions. Send these out as Precedence: bulk, but + # don't override an existing Precedence: header. + if 'precedence' not in self: + self['Precedence'] = 'bulk' + self._enqueue(mlist, **_kws) + + def _enqueue(self, mlist, **_kws): + # Not imported at module scope to avoid import loop + virginq = config.switchboards['virgin'] + # The message metadata better have a 'recip' attribute. + enqueue_kws = dict( + recips=self.recips, + nodecorate=True, + reduced_list_headers=True, + ) + if mlist is not None: + enqueue_kws['listname'] = mlist.fqdn_listname + enqueue_kws.update(_kws) + virginq.enqueue(self, **enqueue_kws) + + + +class OwnerNotification(UserNotification): + """Like user notifications, but this message goes to the list owners.""" + + def __init__(self, mlist, subject=None, text=None, tomoderators=True): + if tomoderators: + roster = mlist.moderators + else: + roster = mlist.owners + recips = [address.address for address in roster.addresses] + sender = config.mailman.site_owner + lang = mlist.preferred_language + UserNotification.__init__(self, recips, sender, subject, text, lang) + # Hack the To header to look like it's going to the -owner address + del self['to'] + self['To'] = mlist.owner_address + self._sender = sender + + def _enqueue(self, mlist, **_kws): + # Not imported at module scope to avoid import loop + virginq = config.switchboards['virgin'] + # The message metadata better have a `recip' attribute + virginq.enqueue(self, + listname=mlist.fqdn_listname, + recips=self.recips, + nodecorate=True, + reduced_list_headers=True, + envsender=self._sender, + **_kws) diff --git a/src/mailman/Utils.py b/src/mailman/Utils.py new file mode 100644 index 000000000..9946273c9 --- /dev/null +++ b/src/mailman/Utils.py @@ -0,0 +1,702 @@ +# 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 . + +"""Miscellaneous essential routines. + +This includes actual message transmission routines, address checking and +message and address munging, a handy-dandy routine to map a function on all +the mailing lists, and whatever else doesn't belong elsewhere. +""" + +import os +import re +import cgi +import time +import errno +import base64 +import random +import logging +import htmlentitydefs +import email.Header +import email.Iterators + +from email.Errors import HeaderParseError +from lazr.config import as_boolean +from string import ascii_letters, digits, whitespace + +import mailman.templates + +from mailman import passwords +from mailman.config import config +from mailman.core import errors +from mailman.utilities.string import expand + + +AT = '@' +CR = '\r' +DOT = '.' +EMPTYSTRING = '' +IDENTCHARS = ascii_letters + digits + '_' +NL = '\n' +UEMPTYSTRING = u'' +TEMPLATE_DIR = os.path.dirname(mailman.templates.__file__) + +# Search for $(identifier)s strings, except that the trailing s is optional, +# since that's a common mistake +cre = re.compile(r'%\(([_a-z]\w*?)\)s?', re.IGNORECASE) +# Search for $$, $identifier, or ${identifier} +dre = re.compile(r'(\${2})|\$([_a-z]\w*)|\${([_a-z]\w*)}', re.IGNORECASE) + +log = logging.getLogger('mailman.error') + + + +# a much more naive implementation than say, Emacs's fill-paragraph! +def wrap(text, column=70, honor_leading_ws=True): + """Wrap and fill the text to the specified column. + + Wrapping is always in effect, although if it is not possible to wrap a + line (because some word is longer than `column' characters) the line is + broken at the next available whitespace boundary. Paragraphs are also + always filled, unless honor_leading_ws is true and the line begins with + whitespace. This is the algorithm that the Python FAQ wizard uses, and + seems like a good compromise. + + """ + wrapped = '' + # first split the text into paragraphs, defined as a blank line + paras = re.split('\n\n', text) + for para in paras: + # fill + lines = [] + fillprev = False + for line in para.split(NL): + if not line: + lines.append(line) + continue + if honor_leading_ws and line[0] in whitespace: + fillthis = False + else: + fillthis = True + if fillprev and fillthis: + # if the previous line should be filled, then just append a + # single space, and the rest of the current line + lines[-1] = lines[-1].rstrip() + ' ' + line + else: + # no fill, i.e. retain newline + lines.append(line) + fillprev = fillthis + # wrap each line + for text in lines: + while text: + if len(text) <= column: + line = text + text = '' + else: + bol = column + # find the last whitespace character + while bol > 0 and text[bol] not in whitespace: + bol -= 1 + # now find the last non-whitespace character + eol = bol + while eol > 0 and text[eol] in whitespace: + eol -= 1 + # watch out for text that's longer than the column width + if eol == 0: + # break on whitespace after column + eol = column + while eol < len(text) and text[eol] not in whitespace: + eol += 1 + bol = eol + while bol < len(text) and text[bol] in whitespace: + bol += 1 + bol -= 1 + line = text[:eol+1] + '\n' + # find the next non-whitespace character + bol += 1 + while bol < len(text) and text[bol] in whitespace: + bol += 1 + text = text[bol:] + wrapped += line + wrapped += '\n' + # end while text + wrapped += '\n' + # end for text in lines + # the last two newlines are bogus + return wrapped[:-2] + + + +def QuotePeriods(text): + JOINER = '\n .\n' + SEP = '\n.\n' + return JOINER.join(text.split(SEP)) + + +# This takes an email address, and returns a tuple containing (user,host) +def ParseEmail(email): + user = None + domain = None + email = email.lower() + at_sign = email.find('@') + if at_sign < 1: + return email, None + user = email[:at_sign] + rest = email[at_sign+1:] + domain = rest.split('.') + return user, domain + + +def LCDomain(addr): + "returns the address with the domain part lowercased" + atind = addr.find('@') + if atind == -1: # no domain part + return addr + return addr[:atind] + '@' + addr[atind+1:].lower() + + +# TBD: what other characters should be disallowed? +_badchars = re.compile(r'[][()<>|;^,\000-\037\177-\377]') + +def ValidateEmail(s): + """Verify that the an email address isn't grossly evil.""" + # Pretty minimal, cheesy check. We could do better... + if not s or ' ' in s: + raise errors.InvalidEmailAddress(repr(s)) + if _badchars.search(s) or s[0] == '-': + raise errors.InvalidEmailAddress(repr(s)) + user, domain_parts = ParseEmail(s) + # Local, unqualified addresses are not allowed. + if not domain_parts: + raise errors.InvalidEmailAddress(repr(s)) + if len(domain_parts) < 2: + raise errors.InvalidEmailAddress(repr(s)) + + + +# Patterns which may be used to form malicious path to inject a new +# line in the mailman error log. (TK: advisory by Moritz Naumann) +CRNLpat = re.compile(r'[^\x21-\x7e]') + +def GetPathPieces(envar='PATH_INFO'): + path = os.environ.get(envar) + if path: + if CRNLpat.search(path): + path = CRNLpat.split(path)[0] + log.error('Warning: Possible malformed path attack.') + return [p for p in path.split('/') if p] + return [] + + + +def ScriptURL(target): + up = '../' * len(GetPathPieces()) + return '%s%s' % (up, target + config.CGIEXT) + + + +def GetPossibleMatchingAddrs(name): + """returns a sorted list of addresses that could possibly match + a given name. + + For Example, given scott@pobox.com, return ['scott@pobox.com'], + given scott@blackbox.pobox.com return ['scott@blackbox.pobox.com', + 'scott@pobox.com']""" + + name = name.lower() + user, domain = ParseEmail(name) + res = [name] + if domain: + domain = domain[1:] + while len(domain) >= 2: + res.append("%s@%s" % (user, DOT.join(domain))) + domain = domain[1:] + return res + + + +def List2Dict(L, foldcase=False): + """Return a dict keyed by the entries in the list passed to it.""" + d = {} + if foldcase: + for i in L: + d[i.lower()] = True + else: + for i in L: + d[i] = True + return d + + + +_vowels = ('a', 'e', 'i', 'o', 'u') +_consonants = ('b', 'c', 'd', 'f', 'g', 'h', 'k', 'm', 'n', + 'p', 'r', 's', 't', 'v', 'w', 'x', 'z') +_syllables = [] + +for v in _vowels: + for c in _consonants: + _syllables.append(c+v) + _syllables.append(v+c) +del c, v + +def UserFriendly_MakeRandomPassword(length): + syls = [] + while len(syls) * 2 < length: + syls.append(random.choice(_syllables)) + return EMPTYSTRING.join(syls)[:length] + + +def Secure_MakeRandomPassword(length): + bytesread = 0 + bytes = [] + fd = None + try: + while bytesread < length: + try: + # Python 2.4 has this on available systems. + newbytes = os.urandom(length - bytesread) + except (AttributeError, NotImplementedError): + if fd is None: + try: + fd = os.open('/dev/urandom', os.O_RDONLY) + except OSError, e: + if e.errno <> errno.ENOENT: + raise + # We have no available source of cryptographically + # secure random characters. Log an error and fallback + # to the user friendly passwords. + log.error( + 'urandom not available, passwords not secure') + return UserFriendly_MakeRandomPassword(length) + newbytes = os.read(fd, length - bytesread) + bytes.append(newbytes) + bytesread += len(newbytes) + s = base64.encodestring(EMPTYSTRING.join(bytes)) + # base64 will expand the string by 4/3rds + return s.replace('\n', '')[:length] + finally: + if fd is not None: + os.close(fd) + + +def MakeRandomPassword(length=None): + if length is None: + length = int(config.passwords.member_password_length) + if as_boolean(config.passwords.user_friendly_passwords): + password = UserFriendly_MakeRandomPassword(length) + else: + password = Secure_MakeRandomPassword(length) + return password.decode('ascii') + + +def GetRandomSeed(): + chr1 = int(random.random() * 52) + chr2 = int(random.random() * 52) + def mkletter(c): + if 0 <= c < 26: + c += 65 + if 26 <= c < 52: + #c = c - 26 + 97 + c += 71 + return c + return "%c%c" % tuple(map(mkletter, (chr1, chr2))) + + + +def set_global_password(pw, siteadmin=True, scheme=None): + if scheme is None: + scheme = passwords.Schemes.ssha + if siteadmin: + filename = config.SITE_PW_FILE + else: + filename = config.LISTCREATOR_PW_FILE + try: + fp = open(filename, 'w') + print >> fp, passwords.make_secret(pw, scheme) + finally: + fp.close() + + +def get_global_password(siteadmin=True): + if siteadmin: + filename = config.SITE_PW_FILE + else: + filename = config.LISTCREATOR_PW_FILE + try: + fp = open(filename) + challenge = fp.read()[:-1] # strip off trailing nl + fp.close() + except IOError, e: + if e.errno <> errno.ENOENT: + raise + # It's okay not to have a site admin password + return None + return challenge + + +def check_global_password(response, siteadmin=True): + challenge = get_global_password(siteadmin) + if challenge is None: + return False + return passwords.check_response(challenge, response) + + + +def websafe(s): + return cgi.escape(s, quote=True) + + +def nntpsplit(s): + parts = s.split(':', 1) + if len(parts) == 2: + try: + return parts[0], int(parts[1]) + except ValueError: + pass + # Use the defaults + return s, 119 + + + +# Just changing these two functions should be enough to control the way +# that email address obscuring is handled. +def ObscureEmail(addr, for_text=False): + """Make email address unrecognizable to web spiders, but invertable. + + When for_text option is set (not default), make a sentence fragment + instead of a token.""" + if for_text: + return addr.replace('@', ' at ') + else: + return addr.replace('@', '--at--') + +def UnobscureEmail(addr): + """Invert ObscureEmail() conversion.""" + # Contrived to act as an identity operation on already-unobscured + # emails, so routines expecting obscured ones will accept both. + return addr.replace('--at--', '@') + + + +class OuterExit(Exception): + pass + +def findtext(templatefile, raw_dict=None, raw=False, lang=None, mlist=None): + # Make some text from a template file. The order of searches depends on + # whether mlist and lang are provided. Once the templatefile is found, + # string substitution is performed by interpolation in `dict'. If `raw' + # is false, the resulting text is wrapped/filled by calling wrap(). + # + # When looking for a template in a specific language, there are 4 places + # that are searched, in this order: + # + # 1. the list-specific language directory + # lists// + # + # 2. the domain-specific language directory + # templates// + # + # 3. the site-wide language directory + # templates/site/ + # + # 4. the global default language directory + # templates/ + # + # The first match found stops the search. In this way, you can specialize + # templates at the desired level, or, if you use only the default + # templates, you don't need to change anything. You should never modify + # files in the templates/ subdirectory, since Mailman will + # overwrite these when you upgrade. That's what the templates/site + # language directories are for. + # + # A further complication is that the language to search for is determined + # by both the `lang' and `mlist' arguments. The search order there is + # that if lang is given, then the 4 locations above are searched, + # substituting lang for . If no match is found, and mlist is + # given, then the 4 locations are searched using the list's preferred + # language. After that, the server default language is used for + # . If that still doesn't yield a template, then the standard + # distribution's English language template is used as an ultimate + # fallback, and when lang is not 'en', the resulting template is passed + # through the translation service. If this template is missing you've got + # big problems. ;) + # + # A word on backwards compatibility: Mailman versions prior to 2.1 stored + # templates in templates/*.{html,txt} and lists//*.{html,txt}. + # Those directories are no longer searched so if you've got customizations + # in those files, you should move them to the appropriate directory based + # on the above description. Mailman's upgrade script cannot do this for + # you. + # + # The function has been revised and renamed as it now returns both the + # template text and the path from which it retrieved the template. The + # original function is now a wrapper which just returns the template text + # as before, by calling this renamed function and discarding the second + # item returned. + # + # Calculate the languages to scan + languages = set() + if lang is not None: + languages.add(lang) + if mlist is not None: + languages.add(mlist.preferred_language) + languages.add(config.mailman.default_language) + assert None not in languages, 'None in languages' + # Calculate the locations to scan + searchdirs = [] + if mlist is not None: + searchdirs.append(mlist.data_path) + searchdirs.append(os.path.join(TEMPLATE_DIR, mlist.host_name)) + searchdirs.append(os.path.join(TEMPLATE_DIR, 'site')) + searchdirs.append(TEMPLATE_DIR) + # Start scanning + fp = None + try: + for lang in languages: + for dir in searchdirs: + filename = os.path.join(dir, lang, templatefile) + try: + fp = open(filename) + raise OuterExit + except IOError, e: + if e.errno <> errno.ENOENT: + raise + # Okay, it doesn't exist, keep looping + fp = None + except OuterExit: + pass + if fp is None: + # Try one last time with the distro English template, which, unless + # you've got a really broken installation, must be there. + try: + filename = os.path.join(TEMPLATE_DIR, 'en', templatefile) + fp = open(filename) + except IOError, e: + if e.errno <> errno.ENOENT: + raise + # We never found the template. BAD! + raise IOError(errno.ENOENT, 'No template file found', templatefile) + else: + from mailman.i18n import get_translation + # XXX BROKEN HACK + data = fp.read()[:-1] + template = get_translation().ugettext(data) + fp.close() + else: + template = fp.read() + fp.close() + template = unicode(template, GetCharSet(lang), 'replace') + text = template + if raw_dict is not None: + text = expand(template, raw_dict) + if raw: + return text, filename + return wrap(text), filename + + +def maketext(templatefile, dict=None, raw=False, lang=None, mlist=None): + return findtext(templatefile, dict, raw, lang, mlist)[0] + + + +def GetRequestURI(fallback=None, escape=True): + """Return the full virtual path this CGI script was invoked with. + + Newer web servers seems to supply this info in the REQUEST_URI + environment variable -- which isn't part of the CGI/1.1 spec. + Thus, if REQUEST_URI isn't available, we concatenate SCRIPT_NAME + and PATH_INFO, both of which are part of CGI/1.1. + + Optional argument `fallback' (default `None') is returned if both of + the above methods fail. + + The url will be cgi escaped to prevent cross-site scripting attacks, + unless `escape' is set to 0. + """ + url = fallback + if 'REQUEST_URI' in os.environ: + url = os.environ['REQUEST_URI'] + elif 'SCRIPT_NAME' in os.environ and 'PATH_INFO' in os.environ: + url = os.environ['SCRIPT_NAME'] + os.environ['PATH_INFO'] + if escape: + return websafe(url) + return url + + + +# XXX Replace this with direct calls. For now, existing uses of GetCharSet() +# are too numerous to change. +def GetCharSet(lang): + return config.languages.get_charset(lang) + + + +def get_request_domain(): + host = os.environ.get('HTTP_HOST', os.environ.get('SERVER_NAME')) + port = os.environ.get('SERVER_PORT') + # Strip off the port if there is one + if port and host.endswith(':' + port): + host = host[:-len(port)-1] + return host.lower() + + +def get_site_noreply(): + return '%s@%s' % (config.NO_REPLY_ADDRESS, config.DEFAULT_EMAIL_HOST) + + + +# Figure out epoch seconds of midnight at the start of today (or the given +# 3-tuple date of (year, month, day). +def midnight(date=None): + if date is None: + date = time.localtime()[:3] + # -1 for dst flag tells the library to figure it out + return time.mktime(date + (0,)*5 + (-1,)) + + + +# The opposite of canonstr() -- sorta. I.e. it attempts to encode s in the +# charset of the given language, which is the character set that the page will +# be rendered in, and failing that, replaces non-ASCII characters with their +# html references. It always returns a byte string. +def uncanonstr(s, lang=None): + if s is None: + s = u'' + if lang is None: + charset = 'us-ascii' + else: + charset = GetCharSet(lang) + # See if the string contains characters only in the desired character + # set. If so, return it unchanged, except for coercing it to a byte + # string. + try: + if isinstance(s, unicode): + return s.encode(charset) + else: + u = unicode(s, charset) + return s + except UnicodeError: + # Nope, it contains funny characters, so html-ref it + return uquote(s) + + +def uquote(s): + a = [] + for c in s: + o = ord(c) + if o > 127: + a.append('&#%3d;' % o) + else: + a.append(c) + # Join characters together and coerce to byte string + return str(EMPTYSTRING.join(a)) + + +def oneline(s, cset='us-ascii', in_unicode=False): + # Decode header string in one line and convert into specified charset + try: + h = email.Header.make_header(email.Header.decode_header(s)) + ustr = h.__unicode__() + line = UEMPTYSTRING.join(ustr.splitlines()) + if in_unicode: + return line + else: + return line.encode(cset, 'replace') + except (LookupError, UnicodeError, ValueError, HeaderParseError): + # possibly charset problem. return with undecoded string in one line. + return EMPTYSTRING.join(s.splitlines()) + + +def strip_verbose_pattern(pattern): + # Remove white space and comments from a verbose pattern and return a + # non-verbose, equivalent pattern. Replace CR and NL in the result + # with '\\r' and '\\n' respectively to avoid multi-line results. + if not isinstance(pattern, str): + return pattern + newpattern = '' + i = 0 + inclass = False + skiptoeol = False + copynext = False + while i < len(pattern): + c = pattern[i] + if copynext: + if c == NL: + newpattern += '\\n' + elif c == CR: + newpattern += '\\r' + else: + newpattern += c + copynext = False + elif skiptoeol: + if c == NL: + skiptoeol = False + elif c == '#' and not inclass: + skiptoeol = True + elif c == '[' and not inclass: + inclass = True + newpattern += c + copynext = True + elif c == ']' and inclass: + inclass = False + newpattern += c + elif re.search('\s', c): + if inclass: + if c == NL: + newpattern += '\\n' + elif c == CR: + newpattern += '\\r' + else: + newpattern += c + elif c == '\\' and not inclass: + newpattern += c + copynext = True + else: + if c == NL: + newpattern += '\\n' + elif c == CR: + newpattern += '\\r' + else: + newpattern += c + i += 1 + return newpattern + + + +def get_pattern(email, pattern_list): + """Returns matched entry in pattern_list if email matches. + Otherwise returns None. + """ + if not pattern_list: + return None + matched = None + for pattern in pattern_list: + if pattern.startswith('^'): + # This is a regular expression match + try: + if re.search(pattern, email, re.IGNORECASE): + matched = pattern + break + except re.error: + # BAW: we should probably remove this pattern + pass + else: + # Do the comparison case insensitively + if pattern.lower() == email.lower(): + matched = pattern + break + return matched diff --git a/src/mailman/__init__.py b/src/mailman/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/mailman/app/__init__.py b/src/mailman/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/mailman/app/bounces.py b/src/mailman/app/bounces.py new file mode 100644 index 000000000..875f615a5 --- /dev/null +++ b/src/mailman/app/bounces.py @@ -0,0 +1,63 @@ +# Copyright (C) 2007-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 . + +"""Application level bounce handling.""" + +from __future__ import unicode_literals + +__metaclass__ = type +__all__ = [ + 'bounce_message', + ] + +import logging + +from email.mime.message import MIMEMessage +from email.mime.text import MIMEText + +from mailman import Message +from mailman import Utils +from mailman.i18n import _ + +log = logging.getLogger('mailman.config') + + + +def bounce_message(mlist, msg, e=None): + # Bounce a message back to the sender, with an error message if provided + # in the exception argument. + sender = msg.get_sender() + subject = msg.get('subject', _('(no subject)')) + subject = Utils.oneline(subject, + Utils.GetCharSet(mlist.preferred_language)) + if e is None: + notice = _('[No bounce details are available]') + else: + notice = _(e.notice) + # Currently we always craft bounces as MIME messages. + bmsg = Message.UserNotification(msg.get_sender(), + mlist.owner_address, + subject, + lang=mlist.preferred_language) + # BAW: Be sure you set the type before trying to attach, or you'll get + # a MultipartConversionError. + bmsg.set_type('multipart/mixed') + txt = MIMEText(notice, + _charset=Utils.GetCharSet(mlist.preferred_language)) + bmsg.attach(txt) + bmsg.attach(MIMEMessage(msg)) + bmsg.send(mlist) diff --git a/src/mailman/app/commands.py b/src/mailman/app/commands.py new file mode 100644 index 000000000..d7676af9c --- /dev/null +++ b/src/mailman/app/commands.py @@ -0,0 +1,44 @@ +# Copyright (C) 2008-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 . + +"""Initialize the email commands.""" + +from __future__ import unicode_literals + +__metaclass__ = type +__all__ = [ + 'initialize', + ] + + +from mailman.config import config +from mailman.core.plugins import get_plugins +from mailman.interfaces.command import IEmailCommand + + + +def initialize(): + """Initialize the email commands.""" + for module in get_plugins('mailman.commands'): + for name in module.__all__: + command_class = getattr(module, name) + if not IEmailCommand.implementedBy(command_class): + continue + assert command_class.name not in config.commands, ( + 'Duplicate email command "{0}" found in {1}'.format( + command_class.name, module)) + config.commands[command_class.name] = command_class() diff --git a/src/mailman/app/lifecycle.py b/src/mailman/app/lifecycle.py new file mode 100644 index 000000000..eec00dc86 --- /dev/null +++ b/src/mailman/app/lifecycle.py @@ -0,0 +1,114 @@ +# Copyright (C) 2007-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 . + +"""Application level list creation.""" + +from __future__ import unicode_literals + +__metaclass__ = type +__all__ = [ + 'create_list', + 'remove_list', + ] + + +import os +import sys +import shutil +import logging + +from mailman import Utils +from mailman.Utils import ValidateEmail +from mailman.config import config +from mailman.core import errors +from mailman.interfaces.member import MemberRole + + +log = logging.getLogger('mailman.error') + + + +def create_list(fqdn_listname, owners=None): + """Create the named list and apply styles.""" + if owners is None: + owners = [] + ValidateEmail(fqdn_listname) + listname, domain = fqdn_listname.split('@', 1) + if domain not in config.domains: + raise errors.BadDomainSpecificationError(domain) + mlist = config.db.list_manager.create(fqdn_listname) + for style in config.style_manager.lookup(mlist): + style.apply(mlist) + # Coordinate with the MTA, as defined in the configuration file. + module_name, class_name = config.mta.incoming.rsplit('.', 1) + __import__(module_name) + getattr(sys.modules[module_name], class_name)().create(mlist) + # Create any owners that don't yet exist, and subscribe all addresses as + # owners of the mailing list. + usermgr = config.db.user_manager + for owner_address in owners: + addr = usermgr.get_address(owner_address) + if addr is None: + # XXX Make this use an IRegistrar instead, but that requires + # sussing out the IDomain stuff. For now, fake it. + user = usermgr.create_user(owner_address) + addr = list(user.addresses)[0] + addr.subscribe(mlist, MemberRole.owner) + return mlist + + + +def remove_list(fqdn_listname, mailing_list=None, archives=True): + """Remove the list and all associated artifacts and subscriptions.""" + removeables = [] + # mailing_list will be None when only residual archives are being removed. + if mailing_list: + # Remove all subscriptions, regardless of role. + for member in mailing_list.subscribers.members: + member.unsubscribe() + # Delete the mailing list from the database. + config.db.list_manager.delete(mailing_list) + # Do the MTA-specific list deletion tasks + module_name, class_name = config.mta.incoming.rsplit('.', 1) + __import__(module_name) + getattr(sys.modules[module_name], class_name)().create(mailing_list) + # Remove the list directory. + removeables.append(os.path.join(config.LIST_DATA_DIR, fqdn_listname)) + # Remove any stale locks associated with the list. + for filename in os.listdir(config.LOCK_DIR): + fn_listname = filename.split('.')[0] + if fn_listname == fqdn_listname: + removeables.append(os.path.join(config.LOCK_DIR, filename)) + if archives: + private_dir = config.PRIVATE_ARCHIVE_FILE_DIR + public_dir = config.PUBLIC_ARCHIVE_FILE_DIR + removeables.extend([ + os.path.join(private_dir, fqdn_listname), + os.path.join(private_dir, fqdn_listname + '.mbox'), + os.path.join(public_dir, fqdn_listname), + os.path.join(public_dir, fqdn_listname + '.mbox'), + ]) + # Now that we know what files and directories to delete, delete them. + for target in removeables: + if os.path.islink(target): + os.unlink(target) + elif os.path.isdir(target): + shutil.rmtree(target) + elif os.path.isfile(target): + os.unlink(target) + else: + log.error('Could not delete list artifact: %s', target) diff --git a/src/mailman/app/membership.py b/src/mailman/app/membership.py new file mode 100644 index 000000000..4b9609469 --- /dev/null +++ b/src/mailman/app/membership.py @@ -0,0 +1,137 @@ +# Copyright (C) 2007-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 . + +"""Application support for membership management.""" + +from __future__ import unicode_literals + +__metaclass__ = type +__all__ = [ + 'add_member', + 'delete_member', + ] + + +from email.utils import formataddr + +from mailman import Message +from mailman import Utils +from mailman import i18n +from mailman.app.notifications import send_goodbye_message +from mailman.config import config +from mailman.core import errors +from mailman.interfaces.member import AlreadySubscribedError, MemberRole + +_ = i18n._ + + + +def add_member(mlist, address, realname, password, delivery_mode, language): + """Add a member right now. + + The member's subscription must be approved by whatever policy the list + enforces. + + :param mlist: the mailing list to add the member to + :type mlist: IMailingList + :param address: the address to subscribe + :type address: string + :param realname: the subscriber's full name + :type realname: string + :param password: the subscriber's password + :type password: string + :param delivery_mode: the delivery mode the subscriber has chosen + :type delivery_mode: DeliveryMode + :param language: the language that the subscriber is going to use + :type language: string + """ + # Let's be extra cautious. + Utils.ValidateEmail(address) + if mlist.members.get_member(address) is not None: + raise AlreadySubscribedError( + mlist.fqdn_listname, address, MemberRole.member) + # Check for banned address here too for admin mass subscribes and + # confirmations. + pattern = Utils.get_pattern(address, mlist.ban_list) + if pattern: + raise errors.MembershipIsBanned(pattern) + # Do the actual addition. First, see if there's already a user linked + # with the given address. + user = config.db.user_manager.get_user(address) + if user is None: + # A user linked to this address does not yet exist. Is the address + # itself known but just not linked to a user? + address_obj = config.db.user_manager.get_address(address) + if address_obj is None: + # Nope, we don't even know about this address, so create both the + # user and address now. + user = config.db.user_manager.create_user(address, realname) + # Do it this way so we don't have to flush the previous change. + address_obj = list(user.addresses)[0] + else: + # The address object exists, but it's not linked to a user. + # Create the user and link it now. + user = config.db.user_manager.create_user() + user.real_name = (realname if realname else address_obj.real_name) + user.link(address_obj) + # Since created the user, then the member, and set preferences on the + # appropriate object. + user.password = password + user.preferences.preferred_language = language + member = address_obj.subscribe(mlist, MemberRole.member) + member.preferences.delivery_mode = delivery_mode + else: + # The user exists and is linked to the address. + for address_obj in user.addresses: + if address_obj.address == address: + break + else: + raise AssertionError( + 'User should have had linked address: {0}'.format(address)) + # Create the member and set the appropriate preferences. + member = address_obj.subscribe(mlist, MemberRole.member) + member.preferences.preferred_language = language + member.preferences.delivery_mode = delivery_mode +## mlist.setMemberOption(email, config.Moderate, +## mlist.default_member_moderation) + + + +def delete_member(mlist, address, admin_notif=None, userack=None): + if userack is None: + userack = mlist.send_goodbye_msg + if admin_notif is None: + admin_notif = mlist.admin_notify_mchanges + # Delete a member, for which we know the approval has been made + member = mlist.members.get_member(address) + language = member.preferred_language + member.unsubscribe() + # And send an acknowledgement to the user... + if userack: + send_goodbye_message(mlist, address, language) + # ...and to the administrator. + if admin_notif: + user = config.db.user_manager.get_user(address) + realname = user.real_name + subject = _('$mlist.real_name unsubscription notification') + text = Utils.maketext( + 'adminunsubscribeack.txt', + {'listname': mlist.real_name, + 'member' : formataddr((realname, address)), + }, mlist=mlist) + msg = Message.OwnerNotification(mlist, subject, text) + msg.send(mlist) diff --git a/src/mailman/app/moderator.py b/src/mailman/app/moderator.py new file mode 100644 index 000000000..b40a34344 --- /dev/null +++ b/src/mailman/app/moderator.py @@ -0,0 +1,351 @@ +# Copyright (C) 2007-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 . + +"""Application support for moderators.""" + +from __future__ import unicode_literals + +__metaclass__ = type +__all__ = [ + 'handle_message', + 'handle_subscription', + 'handle_unsubscription', + 'hold_message', + 'hold_subscription', + 'hold_unsubscription', + ] + +import logging + +from datetime import datetime +from email.utils import formataddr, formatdate, getaddresses, make_msgid + +from mailman import Message +from mailman import Utils +from mailman import i18n +from mailman.app.membership import add_member, delete_member +from mailman.app.notifications import ( + send_admin_subscription_notice, send_welcome_message) +from mailman.config import config +from mailman.core import errors +from mailman.interfaces import Action +from mailman.interfaces.member import AlreadySubscribedError, DeliveryMode +from mailman.interfaces.requests import RequestType + +_ = i18n._ + +vlog = logging.getLogger('mailman.vette') +slog = logging.getLogger('mailman.subscribe') + + + +def hold_message(mlist, msg, msgdata=None, reason=None): + """Hold a message for moderator approval. + + The message is added to the mailing list's request database. + + :param mlist: The mailing list to hold the message on. + :param msg: The message to hold. + :param msgdata: Optional message metadata to hold. If not given, a new + metadata dictionary is created and held with the message. + :param reason: Optional string reason why the message is being held. If + not given, the empty string is used. + :return: An id used to handle the held message later. + """ + if msgdata is None: + msgdata = {} + else: + # Make a copy of msgdata so that subsequent changes won't corrupt the + # request database. TBD: remove the `filebase' key since this will + # not be relevant when the message is resurrected. + msgdata = msgdata.copy() + if reason is None: + reason = '' + # Add the message to the message store. It is required to have a + # Message-ID header. + message_id = msg.get('message-id') + if message_id is None: + msg['Message-ID'] = message_id = unicode(make_msgid()) + assert isinstance(message_id, unicode), ( + 'Message-ID is not a unicode: %s' % message_id) + config.db.message_store.add(msg) + # Prepare the message metadata with some extra information needed only by + # the moderation interface. + msgdata['_mod_message_id'] = message_id + msgdata['_mod_fqdn_listname'] = mlist.fqdn_listname + msgdata['_mod_sender'] = msg.get_sender() + msgdata['_mod_subject'] = msg.get('subject', _('(no subject)')) + msgdata['_mod_reason'] = reason + msgdata['_mod_hold_date'] = datetime.now().isoformat() + # Now hold this request. We'll use the message_id as the key. + requestsdb = config.db.requests.get_list_requests(mlist) + request_id = requestsdb.hold_request( + RequestType.held_message, message_id, msgdata) + return request_id + + + +def handle_message(mlist, id, action, + comment=None, preserve=False, forward=None): + requestdb = config.db.requests.get_list_requests(mlist) + key, msgdata = requestdb.get_request(id) + # Handle the action. + rejection = None + message_id = msgdata['_mod_message_id'] + sender = msgdata['_mod_sender'] + subject = msgdata['_mod_subject'] + if action is Action.defer: + # Nothing to do, but preserve the message for later. + preserve = True + elif action is Action.discard: + rejection = 'Discarded' + elif action is Action.reject: + rejection = 'Refused' + member = mlist.members.get_member(sender) + if member: + language = member.preferred_language + else: + language = None + _refuse(mlist, _('Posting of your message titled "$subject"'), + sender, comment or _('[No reason given]'), language) + elif action is Action.accept: + # Start by getting the message from the message store. + msg = config.db.message_store.get_message_by_id(message_id) + # Delete moderation-specific entries from the message metadata. + for key in msgdata.keys(): + if key.startswith('_mod_'): + del msgdata[key] + # Add some metadata to indicate this message has now been approved. + msgdata['approved'] = True + msgdata['moderator_approved'] = True + # Calculate a new filebase for the approved message, otherwise + # delivery errors will cause duplicates. + if 'filebase' in msgdata: + del msgdata['filebase'] + # Queue the file for delivery by qrunner. Trying to deliver the + # message directly here can lead to a huge delay in web turnaround. + # Log the moderation and add a header. + msg['X-Mailman-Approved-At'] = formatdate(localtime=True) + vlog.info('held message approved, message-id: %s', + msg.get('message-id', 'n/a')) + # Stick the message back in the incoming queue for further + # processing. + config.switchboards['in'].enqueue(msg, _metadata=msgdata) + else: + raise AssertionError('Unexpected action: {0}'.format(action)) + # Forward the message. + if forward: + # Get a copy of the original message from the message store. + msg = config.db.message_store.get_message_by_id(message_id) + # It's possible the forwarding address list is a comma separated list + # of realname/address pairs. + addresses = [addr[1] for addr in getaddresses(forward)] + language = mlist.preferred_language + if len(addresses) == 1: + # If the address getting the forwarded message is a member of + # the list, we want the headers of the outer message to be + # encoded in their language. Otherwise it'll be the preferred + # language of the mailing list. This is better than sending a + # separate message per recipient. + member = mlist.members.get_member(addresses[0]) + if member: + language = member.preferred_language + with i18n.using_language(language): + fmsg = Message.UserNotification( + addresses, mlist.bounces_address, + _('Forward of moderated message'), + lang=language) + fmsg.set_type('message/rfc822') + fmsg.attach(msg) + fmsg.send(mlist) + # Delete the message from the message store if it is not being preserved. + if not preserve: + config.db.message_store.delete_message(message_id) + requestdb.delete_request(id) + # Log the rejection + if rejection: + note = """%s: %s posting: +\tFrom: %s +\tSubject: %s""" + if comment: + note += '\n\tReason: ' + comment + vlog.info(note, mlist.fqdn_listname, rejection, sender, subject) + + + +def hold_subscription(mlist, address, realname, password, mode, language): + data = dict(when=datetime.now().isoformat(), + address=address, + realname=realname, + password=password, + delivery_mode=str(mode), + language=language) + # Now hold this request. We'll use the address as the key. + requestsdb = config.db.requests.get_list_requests(mlist) + request_id = requestsdb.hold_request( + RequestType.subscription, address, data) + vlog.info('%s: held subscription request from %s', + mlist.fqdn_listname, address) + # Possibly notify the administrator in default list language + if mlist.admin_immed_notify: + subject = _( + 'New subscription request to list $mlist.real_name from $address') + text = Utils.maketext( + 'subauth.txt', + {'username' : address, + 'listname' : mlist.fqdn_listname, + 'admindb_url': mlist.script_url('admindb'), + }, mlist=mlist) + # This message should appear to come from the -owner so as + # to avoid any useless bounce processing. + msg = Message.UserNotification( + mlist.owner_address, mlist.owner_address, + subject, text, mlist.preferred_language) + msg.send(mlist, tomoderators=True) + return request_id + + + +def handle_subscription(mlist, id, action, comment=None): + requestdb = config.db.requests.get_list_requests(mlist) + if action is Action.defer: + # Nothing to do. + return + elif action is Action.discard: + # Nothing to do except delete the request from the database. + pass + elif action is Action.reject: + key, data = requestdb.get_request(id) + _refuse(mlist, _('Subscription request'), + data['address'], + comment or _('[No reason given]'), + lang=data['language']) + elif action is Action.accept: + key, data = requestdb.get_request(id) + enum_value = data['delivery_mode'].split('.')[-1] + delivery_mode = DeliveryMode(enum_value) + address = data['address'] + realname = data['realname'] + language = data['language'] + password = data['password'] + try: + add_member(mlist, address, realname, password, + delivery_mode, language) + except AlreadySubscribedError: + # The address got subscribed in some other way after the original + # request was made and accepted. + pass + else: + if mlist.send_welcome_msg: + send_welcome_message(mlist, address, language, delivery_mode) + if mlist.admin_notify_mchanges: + send_admin_subscription_notice( + mlist, address, realname, language) + slog.info('%s: new %s, %s %s', mlist.fqdn_listname, + delivery_mode, formataddr((realname, address)), + 'via admin approval') + else: + raise AssertionError('Unexpected action: {0}'.format(action)) + # Delete the request from the database. + requestdb.delete_request(id) + + + +def hold_unsubscription(mlist, address): + data = dict(address=address) + requestsdb = config.db.requests.get_list_requests(mlist) + request_id = requestsdb.hold_request( + RequestType.unsubscription, address, data) + vlog.info('%s: held unsubscription request from %s', + mlist.fqdn_listname, address) + # Possibly notify the administrator of the hold + if mlist.admin_immed_notify: + subject = _( + 'New unsubscription request from $mlist.real_name by $address') + text = Utils.maketext( + 'unsubauth.txt', + {'address' : address, + 'listname' : mlist.fqdn_listname, + 'admindb_url': mlist.script_url('admindb'), + }, mlist=mlist) + # This message should appear to come from the -owner so as + # to avoid any useless bounce processing. + msg = Message.UserNotification( + mlist.owner_address, mlist.owner_address, + subject, text, mlist.preferred_language) + msg.send(mlist, tomoderators=True) + return request_id + + + +def handle_unsubscription(mlist, id, action, comment=None): + requestdb = config.db.requests.get_list_requests(mlist) + key, data = requestdb.get_request(id) + address = data['address'] + if action is Action.defer: + # Nothing to do. + return + elif action is Action.discard: + # Nothing to do except delete the request from the database. + pass + elif action is Action.reject: + key, data = requestdb.get_request(id) + _refuse(mlist, _('Unsubscription request'), address, + comment or _('[No reason given]')) + elif action is Action.accept: + key, data = requestdb.get_request(id) + try: + delete_member(mlist, address) + except errors.NotAMemberError: + # User has already been unsubscribed. + pass + slog.info('%s: deleted %s', mlist.fqdn_listname, address) + else: + raise AssertionError('Unexpected action: {0}'.format(action)) + # Delete the request from the database. + requestdb.delete_request(id) + + + +def _refuse(mlist, request, recip, comment, origmsg=None, lang=None): + # As this message is going to the requester, try to set the language to + # his/her language choice, if they are a member. Otherwise use the list's + # preferred language. + realname = mlist.real_name + if lang is None: + member = mlist.members.get_member(recip) + if member: + lang = member.preferred_language + text = Utils.maketext( + 'refuse.txt', + {'listname' : mlist.fqdn_listname, + 'request' : request, + 'reason' : comment, + 'adminaddr': mlist.owner_address, + }, lang=lang, mlist=mlist) + with i18n.using_language(lang): + # add in original message, but not wrap/filled + if origmsg: + text = NL.join( + [text, + '---------- ' + _('Original Message') + ' ----------', + str(origmsg) + ]) + subject = _('Request to mailing list "$realname" rejected') + msg = Message.UserNotification(recip, mlist.bounces_address, + subject, text, lang) + msg.send(mlist) diff --git a/src/mailman/app/notifications.py b/src/mailman/app/notifications.py new file mode 100644 index 000000000..9bef9998b --- /dev/null +++ b/src/mailman/app/notifications.py @@ -0,0 +1,136 @@ +# Copyright (C) 2007-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 . + +"""Sending notifications.""" + +from __future__ import unicode_literals + +__metaclass__ = type +__all__ = [ + 'send_admin_subscription_notice', + 'send_goodbye_message', + 'send_welcome_message', + ] + + +from email.utils import formataddr +from lazr.config import as_boolean + +from mailman import Message +from mailman import Utils +from mailman import i18n +from mailman.config import config +from mailman.interfaces.member import DeliveryMode + + +_ = i18n._ + + + +def send_welcome_message(mlist, address, language, delivery_mode, text=''): + """Send a welcome message to a subscriber. + + Prepending to the standard welcome message template is the mailing list's + welcome message, if there is one. + + :param mlist: the mailing list + :type mlist: IMailingList + :param address: The address to respond to + :type address: string + :param language: the language of the response + :type language: string + :param delivery_mode: the type of delivery the subscriber is getting + :type delivery_mode: DeliveryMode + """ + if mlist.welcome_msg: + welcome = Utils.wrap(mlist.welcome_msg) + '\n' + else: + welcome = '' + # Find the IMember object which is subscribed to the mailing list, because + # from there, we can get the member's options url. + member = mlist.members.get_member(address) + options_url = member.options_url + # Get the text from the template. + text += Utils.maketext( + 'subscribeack.txt', { + 'real_name' : mlist.real_name, + 'posting_address' : mlist.fqdn_listname, + 'listinfo_url' : mlist.script_url('listinfo'), + 'optionsurl' : options_url, + 'request_address' : mlist.request_address, + 'welcome' : welcome, + }, lang=language, mlist=mlist) + if delivery_mode is not DeliveryMode.regular: + digmode = _(' (Digest mode)') + else: + digmode = '' + msg = Message.UserNotification( + address, mlist.request_address, + _('Welcome to the "$mlist.real_name" mailing list${digmode}'), + text, language) + msg['X-No-Archive'] = 'yes' + msg.send(mlist, verp=as_boolean(config.mta.verp_personalized_deliveries)) + + + +def send_goodbye_message(mlist, address, language): + """Send a goodbye message to a subscriber. + + Prepending to the standard goodbye message template is the mailing list's + goodbye message, if there is one. + + :param mlist: the mailing list + :type mlist: IMailingList + :param address: The address to respond to + :type address: string + :param language: the language of the response + :type language: string + """ + if mlist.goodbye_msg: + goodbye = Utils.wrap(mlist.goodbye_msg) + '\n' + else: + goodbye = '' + msg = Message.UserNotification( + address, mlist.bounces_address, + _('You have been unsubscribed from the $mlist.real_name mailing list'), + goodbye, language) + msg.send(mlist, verp=as_boolean(config.mta.verp_personalized_deliveries)) + + + +def send_admin_subscription_notice(mlist, address, full_name, language): + """Send the list administrators a subscription notice. + + :param mlist: the mailing list + :type mlist: IMailingList + :param address: the address being subscribed + :type address: string + :param full_name: the name of the subscriber + :type full_name: string + :param language: the language of the address's realname + :type language: string + """ + with i18n.using_language(mlist.preferred_language): + subject = _('$mlist.real_name subscription notification') + full_name = full_name.encode(Utils.GetCharSet(language), 'replace') + text = Utils.maketext( + 'adminsubscribeack.txt', + {'listname' : mlist.real_name, + 'member' : formataddr((full_name, address)), + }, mlist=mlist) + msg = Message.OwnerNotification(mlist, subject, text) + msg.send(mlist) diff --git a/src/mailman/app/registrar.py b/src/mailman/app/registrar.py new file mode 100644 index 000000000..6a2abeba9 --- /dev/null +++ b/src/mailman/app/registrar.py @@ -0,0 +1,163 @@ +# Copyright (C) 2007-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 . + +"""Implementation of the IUserRegistrar interface.""" + +from __future__ import unicode_literals + +__metaclass__ = type +__all__ = [ + 'Registrar', + 'adapt_domain_to_registrar', + ] + + +import datetime + +from pkg_resources import resource_string +from zope.interface import implements + +from mailman.Message import UserNotification +from mailman.Utils import ValidateEmail +from mailman.config import config +from mailman.i18n import _ +from mailman.interfaces.domain import IDomain +from mailman.interfaces.member import MemberRole +from mailman.interfaces.pending import IPendable +from mailman.interfaces.registrar import IRegistrar + + + +class PendableRegistration(dict): + implements(IPendable) + PEND_KEY = 'registration' + + + +class Registrar: + implements(IRegistrar) + + def __init__(self, context): + self._context = context + + def register(self, address, real_name=None, mlist=None): + """See `IUserRegistrar`.""" + # First, do validation on the email address. If the address is + # invalid, it will raise an exception, otherwise it just returns. + ValidateEmail(address) + # Create a pendable for the registration. + pendable = PendableRegistration( + type=PendableRegistration.PEND_KEY, + address=address, + real_name=real_name) + if mlist is not None: + pendable['list_name'] = mlist.fqdn_listname + token = config.db.pendings.add(pendable) + # Set up some local variables for translation interpolation. + domain = IDomain(self._context) + domain_name = _(domain.email_host) + contact_address = domain.contact_address + confirm_url = domain.confirm_url(token) + confirm_address = domain.confirm_address(token) + email_address = address + # Calculate the message's Subject header. XXX Have to deal with + # translating this subject header properly. XXX Must deal with + # VERP_CONFIRMATIONS as well. + subject = 'confirm ' + token + # Send a verification email to the address. + text = _(resource_string('mailman.templates.en', 'verify.txt')) + msg = UserNotification(address, confirm_address, subject, text) + msg.send(mlist=None) + return token + + def confirm(self, token): + """See `IUserRegistrar`.""" + # For convenience + pendable = config.db.pendings.confirm(token) + if pendable is None: + return False + missing = object() + address = pendable.get('address', missing) + real_name = pendable.get('real_name', missing) + list_name = pendable.get('list_name', missing) + if pendable.get('type') != PendableRegistration.PEND_KEY: + # It seems like it would be very difficult to accurately guess + # tokens, or brute force an attack on the SHA1 hash, so we'll just + # throw the pendable away in that case. It's possible we'll need + # to repend the event or adjust the API to handle this case + # better, but for now, the simpler the better. + return False + # We are going to end up with an IAddress for the verified address + # and an IUser linked to this IAddress. See if any of these objects + # currently exist in our database. + usermgr = config.db.user_manager + addr = (usermgr.get_address(address) + if address is not missing else None) + user = (usermgr.get_user(address) + if address is not missing else None) + # If there is neither an address nor a user matching the confirmed + # record, then create the user, which will in turn create the address + # and link the two together + if addr is None: + assert user is None, 'How did we get a user but not an address?' + user = usermgr.create_user(address, real_name) + # Because the database changes haven't been flushed, we can't use + # IUserManager.get_address() to find the IAddress just created + # under the hood. Instead, iterate through the IUser's addresses, + # of which really there should be only one. + for addr in user.addresses: + if addr.address == address: + break + else: + raise AssertionError('Could not find expected IAddress') + elif user is None: + user = usermgr.create_user() + user.real_name = real_name + user.link(addr) + else: + # The IAddress and linked IUser already exist, so all we need to + # do is verify the address. + pass + addr.verified_on = datetime.datetime.now() + # If this registration is tied to a mailing list, subscribe the person + # to the list right now. + list_name = pendable.get('list_name') + if list_name is not None: + mlist = config.db.list_manager.get(list_name) + if mlist: + addr.subscribe(mlist, MemberRole.member) + return True + + def discard(self, token): + # Throw the record away. + config.db.pendings.confirm(token) + + + +def adapt_domain_to_registrar(iface, obj): + """Adapt `IDomain` to `IRegistrar`. + + :param iface: The interface to adapt to. + :type iface: `zope.interface.Interface` + :param obj: The object being adapted. + :type obj: `IDomain` + :return: An `IRegistrar` instance if adaptation succeeded or None if it + didn't. + """ + return (Registrar(obj) + if IDomain.providedBy(obj) and iface is IRegistrar + else None) diff --git a/src/mailman/app/replybot.py b/src/mailman/app/replybot.py new file mode 100644 index 000000000..0537f6645 --- /dev/null +++ b/src/mailman/app/replybot.py @@ -0,0 +1,125 @@ +# Copyright (C) 2007-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 . + +"""Application level auto-reply code.""" + +# XXX This should undergo a rewrite to move this functionality off of the +# mailing list. The reply governor should really apply site-wide per +# recipient (I think). + +from __future__ import unicode_literals + +__metaclass__ = type +__all__ = [ + 'autorespond_to_sender', + 'can_acknowledge', + ] + +import logging +import datetime + +from mailman import Utils +from mailman import i18n +from mailman.config import config + + +log = logging.getLogger('mailman.vette') +_ = i18n._ + + + +def autorespond_to_sender(mlist, sender, lang=None): + """Return True if Mailman should auto-respond to this sender. + + This is only consulted for messages sent to the -request address, or + for posting hold notifications, and serves only as a safety value for + mail loops with email 'bots. + """ + if lang is None: + lang = mlist.preferred_language + max_autoresponses_per_day = int(config.mta.max_autoresponses_per_day) + if max_autoresponses_per_day == 0: + # Unlimited. + return True + today = datetime.date.today() + info = mlist.hold_and_cmd_autoresponses.get(sender) + if info is None or info[0] <> today: + # This is the first time we've seen a -request/post-hold for this + # sender today. + mlist.hold_and_cmd_autoresponses[sender] = (today, 1) + return True + date, count = info + if count < 0: + # They've already hit the limit for today, and we've already notified + # them of this fact, so there's nothing more to do. + log.info('-request/hold autoresponse discarded for: %s', sender) + return False + if count >= max_autoresponses_per_day: + log.info('-request/hold autoresponse limit hit for: %s', sender) + mlist.hold_and_cmd_autoresponses[sender] = (today, -1) + # Send this notification message instead. + text = Utils.maketext( + 'nomoretoday.txt', + {'sender' : sender, + 'listname': mlist.fqdn_listname, + 'num' : count, + 'owneremail': mlist.owner_address, + }, + lang=lang) + with i18n.using_language(lang): + msg = Message.UserNotification( + sender, mlist.owner_address, + _('Last autoresponse notification for today'), + text, lang=lang) + msg.send(mlist) + return False + mlist.hold_and_cmd_autoresponses[sender] = (today, count + 1) + return True + + + +def can_acknowledge(msg): + """A boolean specifying whether this message can be acknowledged. + + There are several reasons why a message should not be acknowledged, mostly + related to competing standards or common practices. These include: + + * The message has a X-No-Ack header with any value + * The message has an X-Ack header with a 'no' value + * The message has a Precedence header + * The message has an Auto-Submitted header and that header does not have a + value of 'no' + * The message has an empty Return-Path header, e.g. <> + * The message has any RFC 2369 headers (i.e. List-* headers) + + :param msg: a Message object. + :return: Boolean specifying whether the message can be acknowledged or not + (which is different from whether it will be acknowledged). + """ + # I wrote it this way for clarity and consistency with the docstring. + for header in msg.keys(): + if header in ('x-no-ack', 'precedence'): + return False + if header.lower().startswith('list-'): + return False + if msg.get('x-ack', '').lower() == 'no': + return False + if msg.get('auto-submitted', 'no').lower() <> 'no': + return False + if msg.get('return-path') == '<>': + return False + return True diff --git a/src/mailman/archiving/__init__.py b/src/mailman/archiving/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/mailman/archiving/mailarchive.py b/src/mailman/archiving/mailarchive.py new file mode 100644 index 000000000..a5eb27db0 --- /dev/null +++ b/src/mailman/archiving/mailarchive.py @@ -0,0 +1,87 @@ +# Copyright (C) 2008-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 . + +"""The Mail-Archive.com archiver.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'MailArchive', + ] + + +import hashlib + +from base64 import urlsafe_b64encode +from urllib import quote +from urlparse import urljoin +from zope.interface import implements + +from mailman.config import config +from mailman.interfaces.archiver import IArchiver + + + +class MailArchive: + """Public archiver at the Mail-Archive.com. + + Messages get archived at http://go.mail-archive.com. + """ + + implements(IArchiver) + + name = 'mail-archive' + + @staticmethod + def list_url(mlist): + """See `IArchiver`.""" + if mlist.archive_private: + return None + return urljoin(config.archiver.mail_archive.base_url, + quote(mlist.posting_address)) + + @staticmethod + def permalink(mlist, msg): + """See `IArchiver`.""" + if mlist.archive_private: + return None + message_id = msg.get('message-id') + # It is not the archiver's job to ensure the message has a Message-ID. + # If no Message-ID is available, there is no permalink. + if message_id is None: + return None + # The angle brackets are not part of the Message-ID. See RFC 2822. + if message_id.startswith('<') and message_id.endswith('>'): + message_id = message_id[1:-1] + else: + message_id = message_id.strip() + sha = hashlib.sha1(message_id) + sha.update(str(mlist.posting_address)) + message_id_hash = urlsafe_b64encode(sha.digest()) + del msg['x-message-id-hash'] + msg['X-Message-ID-Hash'] = message_id_hash + return urljoin(config.archiver.mail_archive.base_url, message_id_hash) + + @staticmethod + def archive_message(mlist, msg): + """See `IArchiver`.""" + if not mlist.archive_private: + config.switchboards['out'].enqueue( + msg, + listname=mlist.fqdn_listname, + recips=[config.archiver.mail_archive.recipient]) diff --git a/src/mailman/archiving/mhonarc.py b/src/mailman/archiving/mhonarc.py new file mode 100644 index 000000000..949a79144 --- /dev/null +++ b/src/mailman/archiving/mhonarc.py @@ -0,0 +1,97 @@ +# Copyright (C) 2008-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 . + +"""MHonArc archiver.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'MHonArc', + ] + + +import hashlib +import logging +import subprocess + +from base64 import b32encode +from urlparse import urljoin +from zope.interface import implements + +from mailman.config import config +from mailman.interfaces.archiver import IArchiver +from mailman.utilities.string import expand + + +log = logging.getLogger('mailman.archiver') + + + +class MHonArc: + """Local MHonArc archiver.""" + + implements(IArchiver) + + name = 'mhonarc' + + @staticmethod + def list_url(mlist): + """See `IArchiver`.""" + # XXX What about private MHonArc archives? + web_host = config.domains[mlist.host_name].url_host + return expand(config.archiver.mhonarc.base_url, + dict(listname=mlist.fqdn_listname, + hostname=web_host, + fqdn_listname=mlist.fqdn_listname, + )) + + @staticmethod + def permalink(mlist, msg): + """See `IArchiver`.""" + # XXX What about private MHonArc archives? + message_id = msg.get('message-id') + # It is not the archiver's job to ensure the message has a Message-ID. + # If no Message-ID is available, there is no permalink. + if message_id is None: + return None + # The angle brackets are not part of the Message-ID. See RFC 2822. + if message_id.startswith('<') and message_id.endswith('>'): + message_id = message_id[1:-1] + else: + message_id = message_id.strip() + sha = hashlib.sha1(message_id) + message_id_hash = b32encode(sha.digest()) + del msg['x-message-id-hash'] + msg['X-Message-ID-Hash'] = message_id_hash + return urljoin(MHonArc.list_url(mlist), message_id_hash) + + @staticmethod + def archive_message(mlist, msg): + """See `IArchiver`.""" + substitutions = config.__dict__.copy() + substitutions['listname'] = mlist.fqdn_listname + command = expand(config.archiver.mhonarc.command, substitutions) + proc = subprocess.Popen( + command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + shell=True) + stdout, stderr = proc.communicate(msg.as_string()) + if proc.returncode <> 0: + log.error('%s: mhonarc subprocess had non-zero exit code: %s' % + (msg['message-id'], proc.returncode)) + log.info(stdout) + log.error(stderr) diff --git a/src/mailman/archiving/pipermail.py b/src/mailman/archiving/pipermail.py new file mode 100644 index 000000000..377f4ab53 --- /dev/null +++ b/src/mailman/archiving/pipermail.py @@ -0,0 +1,121 @@ +# Copyright (C) 2007-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 . + +"""Pipermail archiver.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Pipermail', + ] + + +import os + +from cStringIO import StringIO +from zope.interface import implements +from zope.interface.interface import adapter_hooks + +from mailman.config import config +from mailman.interfaces.archiver import IArchiver, IPipermailMailingList +from mailman.interfaces.mailinglist import IMailingList +from mailman.utilities.filesystem import makedirs +from mailman.utilities.string import expand + +from mailman.Archiver.HyperArch import HyperArchive + + + +class PipermailMailingListAdapter: + """An adapter for MailingList objects to work with Pipermail.""" + + implements(IPipermailMailingList) + + def __init__(self, mlist): + self._mlist = mlist + + def __getattr__(self, name): + return getattr(self._mlist, name) + + def archive_dir(self): + """See `IPipermailMailingList`.""" + if self._mlist.archive_private: + basedir = config.PRIVATE_ARCHIVE_FILE_DIR + else: + basedir = config.PUBLIC_ARCHIVE_FILE_DIR + # Make sure the archive directory exists. + archive_dir = os.path.join(basedir, self._mlist.fqdn_listname) + makedirs(archive_dir) + return archive_dir + + +def adapt_mailing_list_for_pipermail(iface, obj): + """Adapt `IMailingLists` to `IPipermailMailingList`. + + :param iface: The interface to adapt to. + :type iface: `zope.interface.Interface` + :param obj: The object being adapted. + :type obj: any object + :return: An `IPipermailMailingList` instance if adaptation succeeded or + None if it didn't. + """ + return (PipermailMailingListAdapter(obj) + if IMailingList.providedBy(obj) and iface is IPipermailMailingList + else None) + +adapter_hooks.append(adapt_mailing_list_for_pipermail) + + + +class Pipermail: + """The stock Pipermail archiver.""" + + implements(IArchiver) + + name = 'pipermail' + + @staticmethod + def list_url(mlist): + """See `IArchiver`.""" + if mlist.archive_private: + url = mlist.script_url('private') + '/index.html' + else: + web_host = config.domains[mlist.host_name].url_host + return expand(config.archiver.pipermail.base_url, + dict(listname=mlist.fqdn_listname, + hostname=web_host, + fqdn_listname=mlist.fqdn_listname, + )) + + @staticmethod + def permalink(mlist, message): + """See `IArchiver`.""" + # Not currently implemented. + return None + + @staticmethod + def archive_message(mlist, message): + """See `IArchiver`.""" + text = str(message) + fileobj = StringIO(text) + h = HyperArchive(IPipermailMailingList(mlist)) + h.processUnixMailbox(fileobj) + h.close() + fileobj.close() + # There's no good way to know the url for the archived message. + return None diff --git a/src/mailman/archiving/prototype.py b/src/mailman/archiving/prototype.py new file mode 100644 index 000000000..81163e184 --- /dev/null +++ b/src/mailman/archiving/prototype.py @@ -0,0 +1,77 @@ +# Copyright (C) 2008-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 . + +"""Prototypical permalinking archiver.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Prototype', + ] + + +import hashlib + +from base64 import b32encode +from urlparse import urljoin +from zope.interface import implements + +from mailman.config import config +from mailman.interfaces.archiver import IArchiver + + + +class Prototype: + """A prototype of a third party archiver. + + Mailman proposes a draft specification for interoperability between list + servers and archivers: . + """ + + implements(IArchiver) + + name = 'prototype' + + @staticmethod + def list_url(mlist): + """See `IArchiver`.""" + return config.domains[mlist.host_name].base_url + + @staticmethod + def permalink(mlist, msg): + """See `IArchiver`.""" + message_id = msg.get('message-id') + # It is not the archiver's job to ensure the message has a Message-ID. + # If this header is missing, there is no permalink. + if message_id is None: + return None + # The angle brackets are not part of the Message-ID. See RFC 2822. + if message_id.startswith('<') and message_id.endswith('>'): + message_id = message_id[1:-1] + else: + message_id = message_id.strip() + digest = hashlib.sha1(message_id).digest() + message_id_hash = b32encode(digest) + del msg['x-message-id-hash'] + msg['X-Message-ID-Hash'] = message_id_hash + return urljoin(Prototype.list_url(mlist), message_id_hash) + + @staticmethod + def archive_message(mlist, message): + """See `IArchiver`.""" + raise NotImplementedError diff --git a/src/mailman/attic/Bouncer.py b/src/mailman/attic/Bouncer.py new file mode 100644 index 000000000..e2de3c915 --- /dev/null +++ b/src/mailman/attic/Bouncer.py @@ -0,0 +1,250 @@ +# 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 . + +"""Handle delivery bounces.""" + +import sys +import time +import logging + +from email.MIMEMessage import MIMEMessage +from email.MIMEText import MIMEText + +from mailman import Defaults +from mailman import Message +from mailman import Utils +from mailman import i18n +from mailman.configuration import config +from mailman.interfaces import DeliveryStatus + +EMPTYSTRING = '' + +# This constant is supposed to represent the day containing the first midnight +# after the epoch. We'll add (0,)*6 to this tuple to get a value appropriate +# for time.mktime(). +ZEROHOUR_PLUSONEDAY = time.localtime(60 * 60 * 24)[:3] + +def _(s): return s + +REASONS = { + DeliveryStatus.by_bounces : _('due to excessive bounces'), + DeliveryStatus.by_user : _('by yourself'), + DeliveryStatus.by_moderator : _('by the list administrator'), + DeliveryStatus.unknown : _('for unknown reasons'), + } + +_ = i18n._ + +log = logging.getLogger('mailman.bounce') +slog = logging.getLogger('mailman.subscribe') + + + +class _BounceInfo: + def __init__(self, member, score, date, noticesleft): + self.member = member + self.cookie = None + self.reset(score, date, noticesleft) + + def reset(self, score, date, noticesleft): + self.score = score + self.date = date + self.noticesleft = noticesleft + self.lastnotice = ZEROHOUR_PLUSONEDAY + + def __repr__(self): + # For debugging + return """\ +""" % self.__dict__ + + + +class Bouncer: + def registerBounce(self, member, msg, weight=1.0, day=None): + if not self.isMember(member): + return + info = self.getBounceInfo(member) + if day is None: + # Use today's date + day = time.localtime()[:3] + if not isinstance(info, _BounceInfo): + # This is the first bounce we've seen from this member + info = _BounceInfo(member, weight, day, + self.bounce_you_are_disabled_warnings) + self.setBounceInfo(member, info) + log.info('%s: %s bounce score: %s', self.internal_name(), + member, info.score) + # Continue to the check phase below + elif self.getDeliveryStatus(member) <> DeliveryStatus.enabled: + # The user is already disabled, so we can just ignore subsequent + # bounces. These are likely due to residual messages that were + # sent before disabling the member, but took a while to bounce. + log.info('%s: %s residual bounce received', + self.internal_name(), member) + return + elif info.date == day: + # We've already scored any bounces for this day, so ignore it. + log.info('%s: %s already scored a bounce for date %s', + self.internal_name(), member, + time.strftime('%d-%b-%Y', day + (0,0,0,0,1,0))) + # Continue to check phase below + else: + # See if this member's bounce information is stale. + now = Utils.midnight(day) + lastbounce = Utils.midnight(info.date) + if lastbounce + self.bounce_info_stale_after < now: + # Information is stale, so simply reset it + info.reset(weight, day, self.bounce_you_are_disabled_warnings) + log.info('%s: %s has stale bounce info, resetting', + self.internal_name(), member) + else: + # Nope, the information isn't stale, so add to the bounce + # score and take any necessary action. + info.score += weight + info.date = day + log.info('%s: %s current bounce score: %s', + self.internal_name(), member, info.score) + # Continue to the check phase below + # + # Now that we've adjusted the bounce score for this bounce, let's + # check to see if the disable-by-bounce threshold has been reached. + if info.score >= self.bounce_score_threshold: + if config.VERP_PROBES: + log.info('sending %s list probe to: %s (score %s >= %s)', + self.internal_name(), member, info.score, + self.bounce_score_threshold) + self.sendProbe(member, msg) + info.reset(0, info.date, info.noticesleft) + else: + self.disableBouncingMember(member, info, msg) + + def disableBouncingMember(self, member, info, msg): + # Initialize their confirmation cookie. If we do it when we get the + # first bounce, it'll expire by the time we get the disabling bounce. + cookie = self.pend_new(Pending.RE_ENABLE, self.internal_name(), member) + info.cookie = cookie + # Disable them + if config.VERP_PROBES: + log.info('%s: %s disabling due to probe bounce received', + self.internal_name(), member) + else: + log.info('%s: %s disabling due to bounce score %s >= %s', + self.internal_name(), member, + info.score, self.bounce_score_threshold) + self.setDeliveryStatus(member, DeliveryStatus.by_bounces) + self.sendNextNotification(member) + if self.bounce_notify_owner_on_disable: + self.__sendAdminBounceNotice(member, msg) + + def __sendAdminBounceNotice(self, member, msg): + # BAW: This is a bit kludgey, but we're not providing as much + # information in the new admin bounce notices as we used to (some of + # it was of dubious value). However, we'll provide empty, strange, or + # meaningless strings for the unused %()s fields so that the language + # translators don't have to provide new templates. + text = Utils.maketext( + 'bounce.txt', + {'listname' : self.real_name, + 'addr' : member, + 'negative' : '', + 'did' : _('disabled'), + 'but' : '', + 'reenable' : '', + 'owneraddr': self.no_reply_address, + }, mlist=self) + subject = _('Bounce action notification') + umsg = Message.UserNotification(self.GetOwnerEmail(), + self.no_reply_address, + subject, + lang=self.preferred_language) + # BAW: Be sure you set the type before trying to attach, or you'll get + # a MultipartConversionError. + umsg.set_type('multipart/mixed') + umsg.attach( + MIMEText(text, _charset=Utils.GetCharSet(self.preferred_language))) + if isinstance(msg, str): + umsg.attach(MIMEText(msg)) + else: + umsg.attach(MIMEMessage(msg)) + umsg.send(self) + + def sendNextNotification(self, member): + info = self.getBounceInfo(member) + if info is None: + return + reason = self.getDeliveryStatus(member) + if info.noticesleft <= 0: + # BAW: Remove them now, with a notification message + self.ApprovedDeleteMember( + member, 'disabled address', + admin_notif=self.bounce_notify_owner_on_removal, + userack=1) + # Expunge the pending cookie for the user. We throw away the + # returned data. + self.pend_confirm(info.cookie) + if reason == DeliveryStatus.by_bounces: + log.info('%s: %s deleted after exhausting notices', + self.internal_name(), member) + slog.info('%s: %s auto-unsubscribed [reason: %s]', + self.internal_name(), member, + {DeliveryStatus.by_bounces: 'BYBOUNCE', + DeliveryStatus.by_user: 'BYUSER', + DeliveryStatus.by_moderator: 'BYADMIN', + DeliveryStatus.unknown: 'UNKNOWN'}.get( + reason, 'invalid value')) + return + # Send the next notification + confirmurl = '%s/%s' % (self.GetScriptURL('confirm', absolute=1), + info.cookie) + optionsurl = self.GetOptionsURL(member, absolute=1) + reqaddr = self.GetRequestEmail() + lang = self.getMemberLanguage(member) + txtreason = REASONS.get(reason) + if txtreason is None: + txtreason = _('for unknown reasons') + else: + txtreason = _(txtreason) + # Give a little bit more detail on bounce disables + if reason == DeliveryStatus.by_bounces: + date = time.strftime('%d-%b-%Y', + time.localtime(Utils.midnight(info.date))) + extra = _(' The last bounce received from you was dated %(date)s') + txtreason += extra + text = Utils.maketext( + 'disabled.txt', + {'listname' : self.real_name, + 'noticesleft': info.noticesleft, + 'confirmurl' : confirmurl, + 'optionsurl' : optionsurl, + 'password' : self.getMemberPassword(member), + 'owneraddr' : self.GetOwnerEmail(), + 'reason' : txtreason, + }, lang=lang, mlist=self) + msg = Message.UserNotification(member, reqaddr, text=text, lang=lang) + # BAW: See the comment in MailList.py ChangeMemberAddress() for why we + # set the Subject this way. + del msg['subject'] + msg['Subject'] = 'confirm ' + info.cookie + msg.send(self) + info.noticesleft -= 1 + info.lastnotice = time.localtime()[:3] diff --git a/src/mailman/attic/Defaults.py b/src/mailman/attic/Defaults.py new file mode 100644 index 000000000..6f72ed535 --- /dev/null +++ b/src/mailman/attic/Defaults.py @@ -0,0 +1,1324 @@ +# 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 . + +"""Distributed default settings for significant Mailman config variables.""" + +from datetime import timedelta + +from mailman.interfaces.mailinglist import ReplyToMunging + + + +class CompatibleTimeDelta(timedelta): + def __float__(self): + # Convert to float seconds. + return (self.days * 24 * 60 * 60 + + self.seconds + self.microseconds / 1.0e6) + + def __int__(self): + return int(float(self)) + + +def seconds(s): + return CompatibleTimeDelta(seconds=s) + +def minutes(m): + return CompatibleTimeDelta(minutes=m) + +def hours(h): + return CompatibleTimeDelta(hours=h) + +def days(d): + return CompatibleTimeDelta(days=d) + + +# Some convenient constants +Yes = yes = On = on = True +No = no = Off = off = False + + + +##### +# General system-wide defaults +##### + +# Should image logos be used? Set this to 0 to disable image logos from "our +# sponsors" and just use textual links instead (this will also disable the +# shortcut "favicon"). Otherwise, this should contain the URL base path to +# the logo images (and must contain the trailing slash).. If you want to +# disable Mailman's logo footer altogther, hack +# mailman/htmlformat.py:MailmanLogo(), which also contains the hardcoded links +# and image names. +IMAGE_LOGOS = '/icons/' + +# The name of the Mailman favicon +SHORTCUT_ICON = 'mm-icon.png' + +# Don't change MAILMAN_URL, unless you want to point it at one of the mirrors. +MAILMAN_URL = 'http://www.gnu.org/software/mailman/index.html' +#MAILMAN_URL = 'http://www.list.org/' +#MAILMAN_URL = 'http://mailman.sf.net/' + +DEFAULT_URL_PATTERN = 'http://%s/mailman/' + +# This address is used as the from address whenever a message comes from some +# entity to which there is no natural reply recipient. Set this to a real +# human or to /dev/null. It will be appended with the hostname of the list +# involved or the DEFAULT_EMAIL_HOST if none is available. Address must not +# bounce and it must not point to a Mailman process. +NO_REPLY_ADDRESS = 'noreply' + +# This address is the "site owner" address. Certain messages which must be +# delivered to a human, but which can't be delivered to a list owner (e.g. a +# bounce from a list owner), will be sent to this address. It should point to +# a human. +SITE_OWNER_ADDRESS = 'changeme@example.com' + +# Normally when a site administrator authenticates to a web page with the site +# password, they get a cookie which authorizes them as the list admin. It +# makes me nervous to hand out site auth cookies because if this cookie is +# cracked or intercepted, the intruder will have access to every list on the +# site. OTOH, it's dang handy to not have to re-authenticate to every list on +# the site. Set this value to Yes to allow site admin cookies. +ALLOW_SITE_ADMIN_COOKIES = No + +# Command that is used to convert text/html parts into plain text. This +# should output results to standard output. %(filename)s will contain the +# name of the temporary file that the program should operate on. +HTML_TO_PLAIN_TEXT_COMMAND = '/usr/bin/lynx -dump %(filename)s' + +# Default password hashing scheme. See 'bin/mmsitepass -P' for a list of +# available schemes. +PASSWORD_SCHEME = 'ssha' + +# Default run-time directory. +DEFAULT_VAR_DIRECTORY = '/var/mailman' + + + +##### +# Database options +##### + +# Use this to set the SQLAlchemy database engine URL. You generally have one +# primary database connection for all of Mailman. List data and most rosters +# will store their data in this database, although external rosters may access +# other databases in their own way. This string support substitutions using +# any variable in the Configuration object. +DEFAULT_DATABASE_URL = 'sqlite:///$DATA_DIR/mailman.db' + + + +##### +# Spam avoidance defaults +##### + +# This variable contains a list of tuple of the format: +# +# (header, pattern[, chain]) +# +# which is used to match against the current message's headers. If the +# pattern matches the given header in the current message, then the named +# chain is jumped to. header is case-insensitive and should not include the +# trailing colon. pattern is always matched with re.IGNORECASE. chain is +# optional; if not given the 'hold' chain is used, but if given it may be any +# existing chain, such as 'discard', 'reject', or 'accept'. +# +# Note that the more searching done, the slower the whole process gets. +# Header matching is run against all messages coming to either the list, or +# the -owners address, unless the message is explicitly approved. +HEADER_MATCHES = [] + + + +##### +# Web UI defaults +##### + +# Almost all the colors used in Mailman's web interface are parameterized via +# the following variables. This lets you easily change the color schemes for +# your preferences without having to do major surgery on the source code. +# Note that in general, the template colors are not included here since it is +# easy enough to override the default template colors via site-wide, +# vdomain-wide, or list-wide specializations. + +WEB_BG_COLOR = 'white' # Page background +WEB_HEADER_COLOR = '#99ccff' # Major section headers +WEB_SUBHEADER_COLOR = '#fff0d0' # Minor section headers +WEB_ADMINITEM_COLOR = '#dddddd' # Option field background +WEB_ADMINPW_COLOR = '#99cccc' # Password box color +WEB_ERROR_COLOR = 'red' # Error message foreground +WEB_LINK_COLOR = '' # If true, forces LINK= +WEB_ALINK_COLOR = '' # If true, forces ALINK= +WEB_VLINK_COLOR = '' # If true, forces VLINK= +WEB_HIGHLIGHT_COLOR = '#dddddd' # If true, alternating rows + # in listinfo & admin display +# CGI file extension. +CGIEXT = '' + + + +##### +# Archive defaults +##### + +# The url template for the public archives. This will be used in several +# places, including the List-Archive: header, links to the archive on the +# list's listinfo page, and on the list's admin page. +# +# This variable supports several substitution variables +# - $hostname -- the host on which the archive resides +# - $listname -- the short name of the list being accessed +# - $fqdn_listname -- the long name of the list being accessed +PUBLIC_ARCHIVE_URL = 'http://$hostname/pipermail/$fqdn_listname' + +# The public Mail-Archive.com service's base url. +MAIL_ARCHIVE_BASEURL = 'http://go.mail-archive.com/' +# The posting address for the Mail-Archive.com service +MAIL_ARCHIVE_RECIPIENT = 'archive@mail-archive.com' + +# The command for archiving to a local MHonArc instance. +MHONARC_COMMAND = """\ +/usr/bin/mhonarc \ +-add \ +-dbfile $PRIVATE_ARCHIVE_FILE_DIR/${listname}.mbox/mhonarc.db \ +-outdir $VAR_DIR/mhonarc/${listname} \ +-stderr $LOG_DIR/mhonarc \ +-stdout $LOG_DIR/mhonarc \ +-spammode \ +-umask 022""" + +# Are archives on or off by default? +DEFAULT_ARCHIVE = On + +# Are archives public or private by default? +# 0=public, 1=private +DEFAULT_ARCHIVE_PRIVATE = 0 + +# ARCHIVE_TO_MBOX +#-1 - do not do any archiving +# 0 - do not archive to mbox, use builtin mailman html archiving only +# 1 - do not use builtin mailman html archiving, archive to mbox only +# 2 - archive to both mbox and builtin mailman html archiving. +# See the settings below for PUBLIC_EXTERNAL_ARCHIVER and +# PRIVATE_EXTERNAL_ARCHIVER which can be used to replace mailman's +# builtin html archiving with an external archiver. The flat mail +# mbox file can be useful for searching, and is another way to +# interface external archivers, etc. +ARCHIVE_TO_MBOX = 2 + +# 0 - yearly +# 1 - monthly +# 2 - quarterly +# 3 - weekly +# 4 - daily +DEFAULT_ARCHIVE_VOLUME_FREQUENCY = 1 +DEFAULT_DIGEST_VOLUME_FREQUENCY = 1 + +# These variables control the use of an external archiver. Normally if +# archiving is turned on (see ARCHIVE_TO_MBOX above and the list's archive* +# attributes) the internal Pipermail archiver is used. This is the default if +# both of these variables are set to No. When either is set, the value should +# be a shell command string which will get passed to os.popen(). This string +# can contain the following substitution strings: +# +# $listname -- gets the internal name of the list +# $hostname -- gets the email hostname for the list +# +# being archived will be substituted for this. Please note that os.popen() is +# used. +# +# Note that if you set one of these variables, you should set both of them +# (they can be the same string). This will mean your external archiver will +# be used regardless of whether public or private archives are selected. +PUBLIC_EXTERNAL_ARCHIVER = No +PRIVATE_EXTERNAL_ARCHIVER = No + +# A filter module that converts from multipart messages to "flat" messages +# (i.e. containing a single payload). This is required for Pipermail, and you +# may want to set it to 0 for external archivers. You can also replace it +# with your own module as long as it contains a process() function that takes +# a MailList object and a Message object. It should raise +# Errors.DiscardMessage if it wants to throw the message away. Otherwise it +# should modify the Message object as necessary. +ARCHIVE_SCRUBBER = 'mailman.pipeline.scrubber' + +# Control parameter whether mailman.Handlers.Scrubber should use message +# attachment's filename as is indicated by the filename parameter or use +# 'attachement-xxx' instead. The default is set True because the applications +# on PC and Mac begin to use longer non-ascii filenames. Historically, it +# was set False in 2.1.6 for backward compatiblity but it was reset to True +# for safer operation in mailman-2.1.7. +SCRUBBER_DONT_USE_ATTACHMENT_FILENAME = True + +# Use of attachment filename extension per se is may be dangerous because +# virus fakes it. You can set this True if you filter the attachment by +# filename extension +SCRUBBER_USE_ATTACHMENT_FILENAME_EXTENSION = False + +# This variable defines what happens to text/html subparts. They can be +# stripped completely, escaped, or filtered through an external program. The +# legal values are: +# 0 - Strip out text/html parts completely, leaving a notice of the removal in +# the message. If the outer part is text/html, the entire message is +# discarded. +# 1 - Remove any embedded text/html parts, leaving them as HTML-escaped +# attachments which can be separately viewed. Outer text/html parts are +# simply HTML-escaped. +# 2 - Leave it inline, but HTML-escape it +# 3 - Remove text/html as attachments but don't HTML-escape them. Note: this +# is very dangerous because it essentially means anybody can send an HTML +# email to your site containing evil JavaScript or web bugs, or other +# nasty things, and folks viewing your archives will be susceptible. You +# should only consider this option if you do heavy moderation of your list +# postings. +# +# Note: given the current archiving code, it is not possible to leave +# text/html parts inline and un-escaped. I wouldn't think it'd be a good idea +# to do anyway. +# +# The value can also be a string, in which case it is the name of a command to +# filter the HTML page through. The resulting output is left in an attachment +# or as the entirety of the message when the outer part is text/html. The +# format of the string must include a "%(filename)s" which will contain the +# name of the temporary file that the program should operate on. It should +# write the processed message to stdout. Set this to +# HTML_TO_PLAIN_TEXT_COMMAND to specify an HTML to plain text conversion +# program. +ARCHIVE_HTML_SANITIZER = 1 + +# Set this to Yes to enable gzipping of the downloadable archive .txt file. +# Note that this is /extremely/ inefficient, so an alternative is to just +# collect the messages in the associated .txt file and run a cron job every +# night to generate the txt.gz file. See cron/nightly_gzip for details. +GZIP_ARCHIVE_TXT_FILES = No + +# This sets the default `clobber date' policy for the archiver. When a +# message is to be archived either by Pipermail or an external archiver, +# Mailman can modify the Date: header to be the date the message was received +# instead of the Date: in the original message. This is useful if you +# typically receive messages with outrageous dates. Set this to 0 to retain +# the date of the original message, or to 1 to always clobber the date. Set +# it to 2 to perform `smart overrides' on the date; when the date is outside +# ARCHIVER_ALLOWABLE_SANE_DATE_SKEW (either too early or too late), then the +# received date is substituted instead. +ARCHIVER_CLOBBER_DATE_POLICY = 2 +ARCHIVER_ALLOWABLE_SANE_DATE_SKEW = days(15) + +# Pipermail archives contain the raw email addresses of the posting authors. +# Some view this as a goldmine for spam harvesters. Set this to Yes to +# moderately obscure email addresses, but note that this breaks mailto: URLs +# in the archives too. +ARCHIVER_OBSCURES_EMAILADDRS = Yes + +# Pipermail assumes that messages bodies contain US-ASCII text. +# Change this option to define a different character set to be used as +# the default character set for the archive. The term "character set" +# is used in MIME to refer to a method of converting a sequence of +# octets into a sequence of characters. If you change the default +# charset, you might need to add it to VERBATIM_ENCODING below. +DEFAULT_CHARSET = None + +# Most character set encodings require special HTML entity characters to be +# quoted, otherwise they won't look right in the Pipermail archives. However +# some character sets must not quote these characters so that they can be +# rendered properly in the browsers. The primary issue is multi-byte +# encodings where the octet 0x26 does not always represent the & character. +# This variable contains a list of such characters sets which are not +# HTML-quoted in the archives. +VERBATIM_ENCODING = ['iso-2022-jp'] + +# When the archive is public, should Mailman also make the raw Unix mbox file +# publically available? +PUBLIC_MBOX = No + + + +##### +# Delivery defaults +##### + +# Final delivery module for outgoing mail. This handler is used for message +# delivery to the list via the smtpd, and to an individual user. This value +# must be a string naming an IHandler. +DELIVERY_MODULE = 'smtp-direct' + +# MTA should name a module in mailman/MTA which provides the MTA specific +# functionality for creating and removing lists. Some MTAs like Exim can be +# configured to automatically recognize new lists, in which case the MTA +# variable should be set to None. Use 'Manual' to print new aliases to +# standard out (or send an email to the site list owner) for manual twiddling +# of an /etc/aliases style file. Use 'Postfix' if you are using the Postfix +# MTA -- but then also see POSTFIX_STYLE_VIRTUAL_DOMAINS. +MTA = 'Manual' + +# If you set MTA='Postfix', then you also want to set the following variable, +# depending on whether you're using virtual domains in Postfix, and which +# style of virtual domain you're using. Set this to the empty list if you're +# not using virtual domains in Postfix, or if you're using Sendmail-style +# virtual domains (where all addresses are visible in all domains). If you're +# using Postfix-style virtual domains, where aliases should only show up in +# the virtual domain, set this variable to the list of host_name values to +# write separate virtual entries for. I.e. if you run dom1.ain, dom2.ain, and +# dom3.ain, but only dom2 and dom3 are virtual, set this variable to the list +# ['dom2.ain', 'dom3.ain']. Matches are done against the host_name attribute +# of the mailing lists. See the Postfix section of the installation manual +# for details. +POSTFIX_STYLE_VIRTUAL_DOMAINS = [] + +# We should use a separator in place of '@' for list-etc@dom2.ain in both +# aliases and mailman-virtual files. +POSTFIX_VIRTUAL_SEPARATOR = '_at_' + +# These variables describe the program to use for regenerating the aliases.db +# and virtual-mailman.db files, respectively, from the associated plain text +# files. The file being updated will be appended to this string (with a +# separating space), so it must be appropriate for os.system(). +POSTFIX_ALIAS_CMD = '/usr/sbin/postalias' +POSTFIX_MAP_CMD = '/usr/sbin/postmap' + +# Ceiling on the number of recipients that can be specified in a single SMTP +# transaction. Set to 0 to submit the entire recipient list in one +# transaction. Only used with the SMTPDirect DELIVERY_MODULE. +SMTP_MAX_RCPTS = 500 + +# Ceiling on the number of SMTP sessions to perform on a single socket +# connection. Some MTAs have limits. Set this to 0 to do as many as we like +# (i.e. your MTA has no limits). Set this to some number great than 0 and +# Mailman will close the SMTP connection and re-open it after this number of +# consecutive sessions. +SMTP_MAX_SESSIONS_PER_CONNECTION = 0 + +# Maximum number of simultaneous subthreads that will be used for SMTP +# delivery. After the recipients list is chunked according to SMTP_MAX_RCPTS, +# each chunk is handed off to the smptd by a separate such thread. If your +# Python interpreter was not built for threads, this feature is disabled. You +# can explicitly disable it in all cases by setting MAX_DELIVERY_THREADS to +# 0. This feature is only supported with the SMTPDirect DELIVERY_MODULE. +# +# NOTE: This is an experimental feature and limited testing shows that it may +# in fact degrade performance, possibly due to Python's global interpreter +# lock. Use with caution. +MAX_DELIVERY_THREADS = 0 + +# SMTP host and port, when DELIVERY_MODULE is 'SMTPDirect'. Make sure the +# host exists and is resolvable (i.e., if it's the default of "localhost" be +# sure there's a localhost entry in your /etc/hosts file!) +SMTPHOST = 'localhost' +SMTPPORT = 0 # default from smtplib + +# Command for direct command pipe delivery to sendmail compatible program, +# when DELIVERY_MODULE is 'Sendmail'. +SENDMAIL_CMD = '/usr/lib/sendmail' + +# Set these variables if you need to authenticate to your NNTP server for +# Usenet posting or reading. If no authentication is necessary, specify None +# for both variables. +NNTP_USERNAME = None +NNTP_PASSWORD = None + +# Set this if you have an NNTP server you prefer gatewayed lists to use. +DEFAULT_NNTP_HOST = u'' + +# These variables controls how headers must be cleansed in order to be +# accepted by your NNTP server. Some servers like INN reject messages +# containing prohibited headers, or duplicate headers. The NNTP server may +# reject the message for other reasons, but there's little that can be +# programmatically done about that. See mailman/Queue/NewsRunner.py +# +# First, these headers (case ignored) are removed from the original message. +NNTP_REMOVE_HEADERS = ['nntp-posting-host', 'nntp-posting-date', 'x-trace', + 'x-complaints-to', 'xref', 'date-received', 'posted', + 'posting-version', 'relay-version', 'received'] + +# Next, these headers are left alone, unless there are duplicates in the +# original message. Any second and subsequent headers are rewritten to the +# second named header (case preserved). +NNTP_REWRITE_DUPLICATE_HEADERS = [ + ('To', 'X-Original-To'), + ('CC', 'X-Original-CC'), + ('Content-Transfer-Encoding', 'X-Original-Content-Transfer-Encoding'), + ('MIME-Version', 'X-MIME-Version'), + ] + +# Some list posts and mail to the -owner address may contain DomainKey or +# DomainKeys Identified Mail (DKIM) signature headers . +# Various list transformations to the message such as adding a list header or +# footer or scrubbing attachments or even reply-to munging can break these +# signatures. It is generally felt that these signatures have value, even if +# broken and even if the outgoing message is resigned. However, some sites +# may wish to remove these headers by setting this to Yes. +REMOVE_DKIM_HEADERS = No + +# This is the pipeline which messages sent to the -owner address go through +OWNER_PIPELINE = [ + 'SpamDetect', + 'Replybot', + 'CleanseDKIM', + 'OwnerRecips', + 'ToOutgoing', + ] + + +# This defines a logging subsystem confirmation file, which overrides the +# default log settings. This is a ConfigParser formatted file which can +# contain sections named after the logger name (without the leading 'mailman.' +# common prefix). Each section may contain the following options: +# +# - level -- Overrides the default level; this may be any of the +# standard Python logging levels, case insensitive. +# - format -- Overrides the default format string; see below. +# - datefmt -- Overrides the default date format string; see below. +# - path -- Overrides the default logger path. This may be a relative +# path name, in which case it is relative to Mailman's LOG_DIR, +# or it may be an absolute path name. You cannot change the +# handler class that will be used. +# - propagate -- Boolean specifying whether to propagate log message from this +# logger to the root "mailman" logger. You cannot override +# settings for the root logger. +# +# The file name may be absolute, or relative to Mailman's etc directory. +LOG_CONFIG_FILE = None + +# This defines log format strings for the SMTPDirect delivery module (see +# DELIVERY_MODULE above). Valid %()s string substitutions include: +# +# time -- the time in float seconds that it took to complete the smtp +# hand-off of the message from Mailman to your smtpd. +# +# size -- the size of the entire message, in bytes +# +# #recips -- the number of actual recipients for this message. +# +# #refused -- the number of smtp refused recipients (use this only in +# SMTP_LOG_REFUSED). +# +# listname -- the `internal' name of the mailing list for this posting +# +# msg_
      -- the value of the delivered message's given header. If +# the message had no such header, then "n/a" will be used. Note though +# that if the message had multiple such headers, then it is undefined +# which will be used. +# +# allmsg_
      - Same as msg_
      above, but if there are multiple +# such headers in the message, they will all be printed, separated by +# comma-space. +# +# sender -- the "sender" of the messages, which will be the From: or +# envelope-sender as determeined by the USE_ENVELOPE_SENDER variable +# below. +# +# The format of the entries is a 2-tuple with the first element naming the +# logger (as a child of the root 'mailman' logger) to print the message to, +# and the second being a format string appropriate for Python's %-style string +# interpolation. The file name is arbitrary; qfiles/ will be created +# automatically if it does not exist. + +# The format of the message printed for every delivered message, regardless of +# whether the delivery was successful or not. Set to None to disable the +# printing of this log message. +SMTP_LOG_EVERY_MESSAGE = ( + 'smtp', + ('${message-id} smtp to $listname for ${#recips} recips, ' + 'completed in $time seconds')) + +# This will only be printed if there were no immediate smtp failures. +# Mutually exclusive with SMTP_LOG_REFUSED. +SMTP_LOG_SUCCESS = ( + 'post', + '${message-id} post to $listname from $sender, size=$size, success') + +# This will only be printed if there were any addresses which encountered an +# immediate smtp failure. Mutually exclusive with SMTP_LOG_SUCCESS. +SMTP_LOG_REFUSED = ( + 'post', + ('${message-id} post to $listname from $sender, size=$size, ' + '${#refused} failures')) + +# This will be logged for each specific recipient failure. Additional %()s +# keys are: +# +# recipient -- the failing recipient address +# failcode -- the smtp failure code +# failmsg -- the actual smtp message, if available +SMTP_LOG_EACH_FAILURE = ( + 'smtp-failure', + ('${message-id} delivery to $recipient failed with code $failcode: ' + '$failmsg')) + +# These variables control the format and frequency of VERP-like delivery for +# better bounce detection. VERP is Variable Envelope Return Path, defined +# here: +# +# http://cr.yp.to/proto/verp.txt +# +# This involves encoding the address of the recipient as we (Mailman) know it +# into the envelope sender address (i.e. the SMTP `MAIL FROM:' address). +# Thus, no matter what kind of forwarding the recipient has in place, should +# it eventually bounce, we will receive an unambiguous notice of the bouncing +# address. +# +# However, we're technically only "VERP-like" because we're doing the envelope +# sender encoding in Mailman, not in the MTA. We do require cooperation from +# the MTA, so you must be sure your MTA can be configured for extended address +# semantics. +# +# The first variable describes how to encode VERP envelopes. It must contain +# these three string interpolations: +# +# %(bounces)s -- the list-bounces mailbox will be set here +# %(mailbox)s -- the recipient's mailbox will be set here +# %(host)s -- the recipient's host name will be set here +# +# This example uses the default below. +# +# FQDN list address is: mylist@dom.ain +# Recipient is: aperson@a.nother.dom +# +# The envelope sender will be mylist-bounces+aperson=a.nother.dom@dom.ain +# +# Note that your MTA /must/ be configured to deliver such an addressed message +# to mylist-bounces! +VERP_DELIMITER = '+' +VERP_FORMAT = '%(bounces)s+%(mailbox)s=%(host)s' + +# The second describes a regular expression to unambiguously decode such an +# address, which will be placed in the To: header of the bounce message by the +# bouncing MTA. Getting this right is critical -- and tricky. Learn your +# Python regular expressions. It must define exactly three named groups, +# bounces, mailbox and host, with the same definition as above. It will be +# compiled case-insensitively. +VERP_REGEXP = r'^(?P[^+]+?)\+(?P[^=]+)=(?P[^@]+)@.*$' + +# VERP format and regexp for probe messages +VERP_PROBE_FORMAT = '%(bounces)s+%(token)s' +VERP_PROBE_REGEXP = r'^(?P[^+]+?)\+(?P[^@]+)@.*$' +# Set this Yes to activate VERP probe for disabling by bounce +VERP_PROBES = No + +# A perfect opportunity for doing VERP is the password reminders, which are +# already addressed individually to each recipient. Set this to Yes to enable +# VERPs on all password reminders. +VERP_PASSWORD_REMINDERS = No + +# Another good opportunity is when regular delivery is personalized. Here +# again, we're already incurring the performance hit for addressing each +# individual recipient. Set this to Yes to enable VERPs on all personalized +# regular deliveries (personalized digests aren't supported yet). +VERP_PERSONALIZED_DELIVERIES = No + +# And finally, we can VERP normal, non-personalized deliveries. However, +# because it can be a significant performance hit, we allow you to decide how +# often to VERP regular deliveries. This is the interval, in number of +# messages, to do a VERP recipient address. The same variable controls both +# regular and digest deliveries. Set to 0 to disable occasional VERPs, set to +# 1 to VERP every delivery, or to some number > 1 for only occasional VERPs. +VERP_DELIVERY_INTERVAL = 0 + +# For nicer confirmation emails, use a VERP-like format which encodes the +# confirmation cookie in the reply address. This lets us put a more user +# friendly Subject: on the message, but requires cooperation from the MTA. +# Format is like VERP_FORMAT above, but with the following substitutions: +# +# $address -- the list-confirm address +# $cookie -- the confirmation cookie +VERP_CONFIRM_FORMAT = '$address+$cookie' + +# This is analogous to VERP_REGEXP, but for splitting apart the +# VERP_CONFIRM_FORMAT. MUAs have been observed that mung +# From: local_part@host +# into +# To: "local_part" +# when replying, so we skip everything up to '<' if any. +VERP_CONFIRM_REGEXP = r'^(.*<)?(?P[^+]+?)\+(?P[^@]+)@.*$' + +# Set this to Yes to enable VERP-like (more user friendly) confirmations +VERP_CONFIRMATIONS = No + +# This is the maximum number of automatic responses sent to an address because +# of -request messages or posting hold messages. This limit prevents response +# loops between Mailman and misconfigured remote email robots. Mailman +# already inhibits automatic replies to any message labeled with a header +# "Precendence: bulk|list|junk". This is a fallback safety valve so it should +# be set fairly high. Set to 0 for no limit (probably useful only for +# debugging). +MAX_AUTORESPONSES_PER_DAY = 10 + + + +##### +# Qrunner defaults +##### + +# Which queues should the qrunner master watchdog spawn? add_qrunner() takes +# one required argument, which is the name of the qrunner to start +# (capitalized and without the 'Runner' suffix). Optional second argument +# specifies the number of parallel processes to fork for each qrunner. If +# more than one process is used, each will take an equal subdivision of the +# hash space, so the number must be a power of 2. +# +# del_qrunners() takes one argument which is the name of the qrunner not to +# start. This is used because by default, Mailman starts the Arch, Bounce, +# Command, Incoming, News, Outgoing, Retry, and Virgin queues. +# +# Set this to Yes to use the `Maildir' delivery option. If you change this +# you will need to re-run bin/genaliases for MTAs that don't use list +# auto-detection. +# +# WARNING: If you want to use Maildir delivery, you /must/ start Mailman's +# qrunner as root, or you will get permission problems. +USE_MAILDIR = No + +# Set this to Yes to use the `LMTP' delivery option. If you change this +# you will need to re-run bin/genaliases for MTAs that don't use list +# auto-detection. +# +# You have to set following line in postfix main.cf: +# transport_maps = hash:/data/transport +# Also needed is following line if your list is in $mydestination: +# alias_maps = hash:/etc/aliases, hash:/data/aliases +USE_LMTP = No + +# Name of the domains which operate on LMTP Mailman only. Currently valid +# only for Postfix alias generation. +LMTP_ONLY_DOMAINS = [] + +# If the list is not present in LMTP_ONLY_DOMAINS, LMTPRunner would return +# 550 response to the master SMTP agent. This may cause 'bounce spam relay' +# in that a spammer expects to deliver the message as bounce info to the +# 'From:' address. You can override this behavior by setting +# LMTP_ERR_550 = '250 Ok. But, blackholed because mailbox unavailable'. +LMTP_ERR_550 = '550 Requested action not taken: mailbox unavailable' + +# WSGI Server. +# +# You must enable PROXY of Apache httpd server and configure to pass Mailman +# CGI requests to this WSGI Server: +# +# ProxyPass /mailman/ http://localhost:2580/mailman/ +# +# Note that local URI part should be the same. +# XXX If you are running Apache 2.2, you will probably also want to set +# ProxyPassReverseCookiePath +# +# Also you have to add following line to /etc/mailman.cfg +# add_qrunner('HTTP') +HTTP_HOST = 'localhost' +HTTP_PORT = 2580 + +# After processing every file in the qrunner's slice, how long should the +# runner sleep for before checking the queue directory again for new files? +# This can be a fraction of a second, or zero to check immediately +# (essentially busy-loop as fast as possible). +QRUNNER_SLEEP_TIME = seconds(1) + +# When a message that is unparsable (by the email package) is received, what +# should we do with it? The most common cause of unparsable messages is +# broken MIME encapsulation, and the most common cause of that is viruses like +# Nimda. Set this variable to No to discard such messages, or to Yes to store +# them in qfiles/bad subdirectory. +QRUNNER_SAVE_BAD_MESSAGES = Yes + +# This flag causes Mailman to fsync() its data files after writing and +# flushing its contents. While this ensures the data is written to disk, +# avoiding data loss, it may be a performance killer. Note that this flag +# affects both message pickles and MailList config.pck files. +SYNC_AFTER_WRITE = No + +# The maximum number of times that the mailmanctl watcher will try to restart +# a qrunner that exits uncleanly. +MAX_RESTARTS = 10 + + + +##### +# General defaults +##### + +# The default language for this server. Whenever we can't figure out the list +# context or user context, we'll fall back to using this language. This code +# must be in the list of available language codes. +DEFAULT_SERVER_LANGUAGE = u'en' + +# When allowing only members to post to a mailing list, how is the sender of +# the message determined? If this variable is set to Yes, then first the +# message's envelope sender is used, with a fallback to the sender if there is +# no envelope sender. Set this variable to No to always use the sender. +# +# The envelope sender is set by the SMTP delivery and is thus less easily +# spoofed than the sender, which is typically just taken from the From: header +# and thus easily spoofed by the end-user. However, sometimes the envelope +# sender isn't set correctly and this will manifest itself by postings being +# held for approval even if they appear to come from a list member. If you +# are having this problem, set this variable to No, but understand that some +# spoofed messages may get through. +USE_ENVELOPE_SENDER = No + +# Membership tests for posting purposes are usually performed by looking at a +# set of headers, passing the test if any of their values match a member of +# the list. Headers are checked in the order given in this variable. The +# value None means use the From_ (envelope sender) header. Field names are +# case insensitive. +SENDER_HEADERS = ('from', None, 'reply-to', 'sender') + +# How many members to display at a time on the admin cgi to unsubscribe them +# or change their options? +DEFAULT_ADMIN_MEMBER_CHUNKSIZE = 30 + +# how many bytes of a held message post should be displayed in the admindb web +# page? Use a negative number to indicate the entire message, regardless of +# size (though this will slow down rendering those pages). +ADMINDB_PAGE_TEXT_LIMIT = 4096 + +# Set this variable to Yes to allow list owners to delete their own mailing +# lists. You may not want to give them this power, in which case, setting +# this variable to No instead requires list removal to be done by the site +# administrator, via the command line script bin/rmlist. +OWNERS_CAN_DELETE_THEIR_OWN_LISTS = No + +# Set this variable to Yes to allow list owners to set the "personalized" +# flags on their mailing lists. Turning these on tells Mailman to send +# separate email messages to each user instead of batching them together for +# delivery to the MTA. This gives each member a more personalized message, +# but can have a heavy impact on the performance of your system. +OWNERS_CAN_ENABLE_PERSONALIZATION = No + +# Should held messages be saved on disk as Python pickles or as plain text? +# The former is more efficient since we don't need to go through the +# parse/generate roundtrip each time, but the latter might be preferred if you +# want to edit the held message on disk. +HOLD_MESSAGES_AS_PICKLES = Yes + +# This variable controls the order in which list-specific category options are +# presented in the admin cgi page. +ADMIN_CATEGORIES = [ + # First column + 'general', 'passwords', 'language', 'members', 'nondigest', 'digest', + # Second column + 'privacy', 'bounce', 'archive', 'gateway', 'autoreply', + 'contentfilter', 'topics', + ] + +# See "Bitfield for user options" below; make this a sum of those options, to +# make all new members of lists start with those options flagged. We assume +# by default that people don't want to receive two copies of posts. Note +# however that the member moderation flag's initial value is controlled by the +# list's config variable default_member_moderation. +DEFAULT_NEW_MEMBER_OPTIONS = 256 + +# Specify the type of passwords to use, when Mailman generates the passwords +# itself, as would be the case for membership requests where the user did not +# fill in a password, or during list creation, when auto-generation of admin +# passwords was selected. +# +# Set this value to Yes for classic Mailman user-friendly(er) passwords. +# These generate semi-pronounceable passwords which are easier to remember. +# Set this value to No to use more cryptographically secure, but harder to +# remember, passwords -- if your operating system and Python version support +# the necessary feature (specifically that /dev/urandom be available). +USER_FRIENDLY_PASSWORDS = Yes +# This value specifies the default lengths of member and list admin passwords +MEMBER_PASSWORD_LENGTH = 8 +ADMIN_PASSWORD_LENGTH = 10 + + + +##### +# List defaults. NOTE: Changing these values does NOT change the +# configuration of an existing list. It only defines the default for new +# lists you subsequently create. +##### + +# Should a list, by default be advertised? What is the default maximum number +# of explicit recipients allowed? What is the default maximum message size +# allowed? +DEFAULT_LIST_ADVERTISED = Yes +DEFAULT_MAX_NUM_RECIPIENTS = 10 +DEFAULT_MAX_MESSAGE_SIZE = 40 # KB + +# These format strings will be expanded w.r.t. the dictionary for the +# mailing list instance. +DEFAULT_SUBJECT_PREFIX = u'[$mlist.real_name] ' +# DEFAULT_SUBJECT_PREFIX = "[$mlist.real_name %%d]" # for numbering +DEFAULT_MSG_HEADER = u'' +DEFAULT_MSG_FOOTER = u"""\ +_______________________________________________ +$real_name mailing list +$fqdn_listname +${listinfo_page} +""" + +# Scrub regular delivery +DEFAULT_SCRUB_NONDIGEST = False + +# Mail command processor will ignore mail command lines after designated max. +EMAIL_COMMANDS_MAX_LINES = 10 + +# Is the list owner notified of admin requests immediately by mail, as well as +# by daily pending-request reminder? +DEFAULT_ADMIN_IMMED_NOTIFY = Yes + +# Is the list owner notified of subscribes/unsubscribes? +DEFAULT_ADMIN_NOTIFY_MCHANGES = No + +# Discard held messages after this days +DEFAULT_MAX_DAYS_TO_HOLD = 0 + +# Should list members, by default, have their posts be moderated? +DEFAULT_DEFAULT_MEMBER_MODERATION = No + +# Should non-member posts which are auto-discarded also be forwarded to the +# moderators? +DEFAULT_FORWARD_AUTO_DISCARDS = Yes + +# What shold happen to non-member posts which are do not match explicit +# non-member actions? +# 0 = Accept +# 1 = Hold +# 2 = Reject +# 3 = Discard +DEFAULT_GENERIC_NONMEMBER_ACTION = 1 + +# Bounce if 'To:', 'Cc:', or 'Resent-To:' fields don't explicitly name list? +# This is an anti-spam measure +DEFAULT_REQUIRE_EXPLICIT_DESTINATION = Yes + +# Alternate names acceptable as explicit destinations for this list. +DEFAULT_ACCEPTABLE_ALIASES = """ +""" +# For mailing lists that have only other mailing lists for members: +DEFAULT_UMBRELLA_LIST = No + +# For umbrella lists, the suffix for the account part of address for +# administrative notices (subscription confirmations, password reminders): +DEFAULT_UMBRELLA_MEMBER_ADMIN_SUFFIX = "-owner" + +# This variable controls whether monthly password reminders are sent. +DEFAULT_SEND_REMINDERS = Yes + +# Send welcome messages to new users? +DEFAULT_SEND_WELCOME_MSG = Yes + +# Send goodbye messages to unsubscribed members? +DEFAULT_SEND_GOODBYE_MSG = Yes + +# Wipe sender information, and make it look like the list-admin +# address sends all messages +DEFAULT_ANONYMOUS_LIST = No + +# {header-name: regexp} spam filtering - we include some for example sake. +DEFAULT_BOUNCE_MATCHING_HEADERS = u""" +# Lines that *start* with a '#' are comments. +to: friend@public.com +message-id: relay.comanche.denmark.eu +from: list@listme.com +from: .*@uplinkpro.com +""" + +# Mailman can be configured to "munge" Reply-To: headers for any passing +# messages. One the one hand, there are a lot of good reasons not to munge +# Reply-To: but on the other, people really seem to want this feature. See +# the help for reply_goes_to_list in the web UI for links discussing the +# issue. +# 0 - Reply-To: not munged +# 1 - Reply-To: set back to the list +# 2 - Reply-To: set to an explicit value (reply_to_address) +DEFAULT_REPLY_GOES_TO_LIST = ReplyToMunging.no_munging + +# Mailman can be configured to strip any existing Reply-To: header, or simply +# extend any existing Reply-To: with one based on the above setting. +DEFAULT_FIRST_STRIP_REPLY_TO = No + +# SUBSCRIBE POLICY +# 0 - open list (only when ALLOW_OPEN_SUBSCRIBE is set to 1) ** +# 1 - confirmation required for subscribes +# 2 - admin approval required for subscribes +# 3 - both confirmation and admin approval required +# +# ** please do not choose option 0 if you are not allowing open +# subscribes (next variable) +DEFAULT_SUBSCRIBE_POLICY = 1 + +# Does this site allow completely unchecked subscriptions? +ALLOW_OPEN_SUBSCRIBE = No + +# This is the default list of addresses and regular expressions (beginning +# with ^) that are exempt from approval if SUBSCRIBE_POLICY is 2 or 3. +DEFAULT_SUBSCRIBE_AUTO_APPROVAL = [] + +# The default policy for unsubscriptions. 0 (unmoderated unsubscribes) is +# highly recommended! +# 0 - unmoderated unsubscribes +# 1 - unsubscribes require approval +DEFAULT_UNSUBSCRIBE_POLICY = 0 + +# Private_roster == 0: anyone can see, 1: members only, 2: admin only. +DEFAULT_PRIVATE_ROSTER = 1 + +# When exposing members, make them unrecognizable as email addrs, so +# web-spiders can't pick up addrs for spam purposes. +DEFAULT_OBSCURE_ADDRESSES = Yes + +# RFC 2369 defines List-* headers which are added to every message sent +# through to the mailing list membership. These are a very useful aid to end +# users and should always be added. However, not all MUAs are compliant and +# if a list's membership has many such users, they may clamor for these +# headers to be suppressed. By setting this variable to Yes, list owners will +# be given the option to suppress these headers. By setting it to No, list +# owners will not be given the option to suppress these headers (although some +# header suppression may still take place, i.e. for announce-only lists, or +# lists with no archives). +ALLOW_RFC2369_OVERRIDES = Yes + +# Defaults for content filtering on mailing lists. DEFAULT_FILTER_CONTENT is +# a flag which if set to true, turns on content filtering. +DEFAULT_FILTER_CONTENT = No + +# DEFAULT_FILTER_MIME_TYPES is a list of MIME types to be removed. This is a +# list of strings of the format "maintype/subtype" or simply "maintype". +# E.g. "text/html" strips all html attachments while "image" strips all image +# types regardless of subtype (jpeg, gif, etc.). +DEFAULT_FILTER_MIME_TYPES = [] + +# DEFAULT_PASS_MIME_TYPES is a list of MIME types to be passed through. +# Format is the same as DEFAULT_FILTER_MIME_TYPES +DEFAULT_PASS_MIME_TYPES = ['multipart/mixed', + 'multipart/alternative', + 'text/plain'] + +# DEFAULT_FILTER_FILENAME_EXTENSIONS is a list of filename extensions to be +# removed. It is useful because many viruses fake their content-type as +# harmless ones while keep their extension as executable and expect to be +# executed when victims 'open' them. +DEFAULT_FILTER_FILENAME_EXTENSIONS = [ + 'exe', 'bat', 'cmd', 'com', 'pif', 'scr', 'vbs', 'cpl' + ] + +# DEFAULT_PASS_FILENAME_EXTENSIONS is a list of filename extensions to be +# passed through. Format is the same as DEFAULT_FILTER_FILENAME_EXTENSIONS. +DEFAULT_PASS_FILENAME_EXTENSIONS = [] + +# Replace multipart/alternative with its first alternative. +DEFAULT_COLLAPSE_ALTERNATIVES = Yes + +# Whether text/html should be converted to text/plain after content filtering +# is performed. Conversion is done according to HTML_TO_PLAIN_TEXT_COMMAND +DEFAULT_CONVERT_HTML_TO_PLAINTEXT = Yes + +# Default action to take on filtered messages. +# 0 = Discard, 1 = Reject, 2 = Forward, 3 = Preserve +DEFAULT_FILTER_ACTION = 0 + +# Whether to allow list owners to preserve content filtered messages to a +# special queue on the disk. +OWNERS_CAN_PRESERVE_FILTERED_MESSAGES = Yes + +# Check for administrivia in messages sent to the main list? +DEFAULT_ADMINISTRIVIA = Yes + + + +##### +# Digestification defaults. Same caveat applies here as with list defaults. +##### + +# Will list be available in non-digested form? +DEFAULT_NONDIGESTABLE = Yes + +# Will list be available in digested form? +DEFAULT_DIGESTABLE = Yes +DEFAULT_DIGEST_HEADER = u'' +DEFAULT_DIGEST_FOOTER = DEFAULT_MSG_FOOTER + +DEFAULT_DIGEST_IS_DEFAULT = No +DEFAULT_MIME_IS_DEFAULT_DIGEST = No +DEFAULT_DIGEST_SIZE_THRESHOLD = 30 # KB +DEFAULT_DIGEST_SEND_PERIODIC = Yes + +# Headers which should be kept in both RFC 1153 (plain) and MIME digests. RFC +# 1153 also specifies these headers in this exact order, so order matters. +MIME_DIGEST_KEEP_HEADERS = [ + 'Date', 'From', 'To', 'Cc', 'Subject', 'Message-ID', 'Keywords', + # I believe we should also keep these headers though. + 'In-Reply-To', 'References', 'Content-Type', 'MIME-Version', + 'Content-Transfer-Encoding', 'Precedence', 'Reply-To', + # Mailman 2.0 adds these headers + 'Message', + ] + +PLAIN_DIGEST_KEEP_HEADERS = [ + 'Message', 'Date', 'From', + 'Subject', 'To', 'Cc', + 'Message-ID', 'Keywords', + 'Content-Type', + ] + + + +##### +# Bounce processing defaults. Same caveat applies here as with list defaults. +##### + +# Should we do any bounced mail response at all? +DEFAULT_BOUNCE_PROCESSING = Yes + +# How often should the bounce qrunner process queued detected bounces? +REGISTER_BOUNCES_EVERY = minutes(15) + +# Bounce processing works like this: when a bounce from a member is received, +# we look up the `bounce info' for this member. If there is no bounce info, +# this is the first bounce we've received from this member. In that case, we +# record today's date, and initialize the bounce score (see below for initial +# value). +# +# If there is existing bounce info for this member, we look at the last bounce +# receive date. If this date is farther away from today than the `bounce +# expiration interval', we throw away all the old data and initialize the +# bounce score as if this were the first bounce from the member. +# +# Otherwise, we increment the bounce score. If we can determine whether the +# bounce was soft or hard (i.e. transient or fatal), then we use a score value +# of 0.5 for soft bounces and 1.0 for hard bounces. Note that we only score +# one bounce per day. If the bounce score is then greater than the `bounce +# threshold' we disable the member's address. +# +# After disabling the address, we can send warning messages to the member, +# providing a confirmation cookie/url for them to use to re-enable their +# delivery. After a configurable period of time, we'll delete the address. +# When we delete the address due to bouncing, we'll send one last message to +# the member. + +# Bounce scores greater than this value get disabled. +DEFAULT_BOUNCE_SCORE_THRESHOLD = 5.0 + +# Bounce information older than this interval is considered stale, and is +# discarded. +DEFAULT_BOUNCE_INFO_STALE_AFTER = days(7) + +# The number of notifications to send to the disabled/removed member before we +# remove them from the list. A value of 0 means we remove the address +# immediately (with one last notification). Note that the first one is sent +# upon change of status to disabled. +DEFAULT_BOUNCE_YOU_ARE_DISABLED_WARNINGS = 3 + +# The interval of time between disabled warnings. +DEFAULT_BOUNCE_YOU_ARE_DISABLED_WARNINGS_INTERVAL = days(7) + +# Does the list owner get messages to the -bounces (and -admin) address that +# failed to match by the bounce detector? +DEFAULT_BOUNCE_UNRECOGNIZED_GOES_TO_LIST_OWNER = Yes + +# Notifications on bounce actions. The first specifies whether the list owner +# should get a notification when a member is disabled due to bouncing, while +# the second specifies whether the owner should get one when the member is +# removed due to bouncing. +DEFAULT_BOUNCE_NOTIFY_OWNER_ON_DISABLE = Yes +DEFAULT_BOUNCE_NOTIFY_OWNER_ON_REMOVAL = Yes + + + +##### +# General time limits +##### + +# Default length of time a pending request is live before it is evicted from +# the pending database. +PENDING_REQUEST_LIFE = days(3) + +# How long should messages which have delivery failures continue to be +# retried? After this period of time, a message that has failed recipients +# will be dequeued and those recipients will never receive the message. +DELIVERY_RETRY_PERIOD = days(5) + +# How long should we wait before we retry a temporary delivery failure? +DELIVERY_RETRY_WAIT = hours(1) + + + +##### +# Lock management defaults +##### + +# These variables control certain aspects of lock acquisition and retention. +# They should be tuned as appropriate for your environment. All variables are +# specified in units of floating point seconds. YOU MAY NEED TO TUNE THESE +# VARIABLES DEPENDING ON THE SIZE OF YOUR LISTS, THE PERFORMANCE OF YOUR +# HARDWARE, NETWORK AND GENERAL MAIL HANDLING CAPABILITIES, ETC. + +# This variable specifies how long the lock will be retained for a specific +# operation on a mailing list. Watch your logs/lock file and if you see a lot +# of lock breakages, you might need to bump this up. However if you set this +# too high, a faulty script (or incorrect use of bin/withlist) can prevent the +# list from being used until the lifetime expires. This is probably one of +# the most crucial tuning variables in the system. +LIST_LOCK_LIFETIME = hours(5) + +# This variable specifies how long an attempt will be made to acquire a list +# lock by the incoming qrunner process. If the lock acquisition times out, +# the message will be re-queued for later delivery. +LIST_LOCK_TIMEOUT = seconds(10) + +# Set this to On to turn on lock debugging messages for the pending requests +# database, which will be written to logs/locks. If you think you're having +# lock problems, or just want to tune the locks for your system, turn on lock +# debugging. +PENDINGDB_LOCK_DEBUGGING = Off + + + +##### +# Nothing below here is user configurable. Most of these values are in this +# file for internal system convenience. Don't change any of them or override +# any of them in your mailman.cfg file! +##### + +# Enumeration for Mailman cgi widget types +Toggle = 1 +Radio = 2 +String = 3 +Text = 4 +Email = 5 +EmailList = 6 +Host = 7 +Number = 8 +FileUpload = 9 +Select = 10 +Topics = 11 +Checkbox = 12 +# An "extended email list". Contents must be an email address or a ^-prefixed +# regular expression. Used in the sender moderation text boxes. +EmailListEx = 13 +# Extended spam filter widget +HeaderFilter = 14 + +# Actions +DEFER = 0 +APPROVE = 1 +REJECT = 2 +DISCARD = 3 +SUBSCRIBE = 4 +UNSUBSCRIBE = 5 +ACCEPT = 6 +HOLD = 7 + +# Standard text field width +TEXTFIELDWIDTH = 40 + +# Bitfield for user options. See DEFAULT_NEW_MEMBER_OPTIONS above to set +# defaults for all new lists. +Digests = 0 # handled by other mechanism, doesn't need a flag. +DisableDelivery = 1 # Obsolete; use set/getDeliveryStatus() +DontReceiveOwnPosts = 2 # Non-digesters only +AcknowledgePosts = 4 +DisableMime = 8 # Digesters only +ConcealSubscription = 16 +SuppressPasswordReminder = 32 +ReceiveNonmatchingTopics = 64 +Moderate = 128 +DontReceiveDuplicates = 256 + + +# A mapping between short option tags and their flag +OPTINFO = {'hide' : ConcealSubscription, + 'nomail' : DisableDelivery, + 'ack' : AcknowledgePosts, + 'notmetoo': DontReceiveOwnPosts, + 'digest' : 0, + 'plain' : DisableMime, + 'nodupes' : DontReceiveDuplicates + } + +# Authentication contexts. +# +# Mailman defines the following roles: + +# - User, a normal user who has no permissions except to change their personal +# option settings +# - List creator, someone who can create and delete lists, but cannot +# (necessarily) configure the list. +# - List moderator, someone who can tend to pending requests such as +# subscription requests, or held messages +# - List administrator, someone who has total control over a list, can +# configure it, modify user options for members of the list, subscribe and +# unsubscribe members, etc. +# - Site administrator, someone who has total control over the entire site and +# can do any of the tasks mentioned above. This person usually also has +# command line access. + +UnAuthorized = 0 +AuthUser = 1 # Joe Shmoe User +AuthCreator = 2 # List Creator / Destroyer +AuthListAdmin = 3 # List Administrator (total control over list) +AuthListModerator = 4 # List Moderator (can only handle held requests) +AuthSiteAdmin = 5 # Site Administrator (total control over everything) + + + +# Vgg: Language descriptions and charsets dictionary, any new supported +# language must have a corresponding entry here. Key is the name of the +# directories that hold the localized texts. Data are tuples with first +# element being the description, as described in the catalogs, and second +# element is the language charset. I have chosen code from /usr/share/locale +# in my GNU/Linux. :-) +# +# TK: Now the site admin can select languages for the installation from those +# in the distribution tarball. We don't touch add_language() function for +# backward compatibility. You may have to add your own language in your +# mailman.cfg file, if it is not included in the distribution even if you had +# put language files in source directory and configured by `--with-languages' +# option. +def _(s): + return s + +_DEFAULT_LANGUAGE_DATA = { + 'ar': (_('Arabic'), 'utf-8'), + 'ca': (_('Catalan'), 'iso-8859-1'), + 'cs': (_('Czech'), 'iso-8859-2'), + 'da': (_('Danish'), 'iso-8859-1'), + 'de': (_('German'), 'iso-8859-1'), + 'en': (_('English (USA)'), 'us-ascii'), + 'es': (_('Spanish (Spain)'), 'iso-8859-1'), + 'et': (_('Estonian'), 'iso-8859-15'), + 'eu': (_('Euskara'), 'iso-8859-15'), # Basque + 'fi': (_('Finnish'), 'iso-8859-1'), + 'fr': (_('French'), 'iso-8859-1'), + 'hr': (_('Croatian'), 'iso-8859-2'), + 'hu': (_('Hungarian'), 'iso-8859-2'), + 'ia': (_('Interlingua'), 'iso-8859-15'), + 'it': (_('Italian'), 'iso-8859-1'), + 'ja': (_('Japanese'), 'euc-jp'), + 'ko': (_('Korean'), 'euc-kr'), + 'lt': (_('Lithuanian'), 'iso-8859-13'), + 'nl': (_('Dutch'), 'iso-8859-1'), + 'no': (_('Norwegian'), 'iso-8859-1'), + 'pl': (_('Polish'), 'iso-8859-2'), + 'pt': (_('Portuguese'), 'iso-8859-1'), + 'pt_BR': (_('Portuguese (Brazil)'), 'iso-8859-1'), + 'ro': (_('Romanian'), 'iso-8859-2'), + 'ru': (_('Russian'), 'koi8-r'), + 'sr': (_('Serbian'), 'utf-8'), + 'sl': (_('Slovenian'), 'iso-8859-2'), + 'sv': (_('Swedish'), 'iso-8859-1'), + 'tr': (_('Turkish'), 'iso-8859-9'), + 'uk': (_('Ukrainian'), 'utf-8'), + 'vi': (_('Vietnamese'), 'utf-8'), + 'zh_CN': (_('Chinese (China)'), 'utf-8'), + 'zh_TW': (_('Chinese (Taiwan)'), 'utf-8'), +} + + +del _ diff --git a/src/mailman/attic/Deliverer.py b/src/mailman/attic/Deliverer.py new file mode 100644 index 000000000..0ba3a01bb --- /dev/null +++ b/src/mailman/attic/Deliverer.py @@ -0,0 +1,174 @@ +# 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 . + + +"""Mixin class with message delivery routines.""" + +from __future__ import with_statement + +import logging + +from email.MIMEMessage import MIMEMessage +from email.MIMEText import MIMEText + +from mailman import Errors +from mailman import Message +from mailman import Utils +from mailman import i18n +from mailman.configuration import config + +_ = i18n._ + +log = logging.getLogger('mailman.error') +mlog = logging.getLogger('mailman.mischief') + + + +class Deliverer: + def MailUserPassword(self, user): + listfullname = self.fqdn_listname + requestaddr = self.GetRequestEmail() + # find the lowercased version of the user's address + adminaddr = self.GetBouncesEmail() + assert self.isMember(user) + if not self.getMemberPassword(user): + # The user's password somehow got corrupted. Generate a new one + # for him, after logging this bogosity. + log.error('User %s had a false password for list %s', + user, self.internal_name()) + waslocked = self.Locked() + if not waslocked: + self.Lock() + try: + self.setMemberPassword(user, Utils.MakeRandomPassword()) + self.Save() + finally: + if not waslocked: + self.Unlock() + # Now send the user his password + cpuser = self.getMemberCPAddress(user) + recipient = self.GetMemberAdminEmail(cpuser) + subject = _('%(listfullname)s mailing list reminder') + # Get user's language and charset + lang = self.getMemberLanguage(user) + cset = Utils.GetCharSet(lang) + password = self.getMemberPassword(user) + # TK: Make unprintables to ? + # The list owner should allow users to set language options if they + # want to use non-us-ascii characters in password and send it back. + password = unicode(password, cset, 'replace').encode(cset, 'replace') + # get the text from the template + text = Utils.maketext( + 'userpass.txt', + {'user' : cpuser, + 'listname' : self.real_name, + 'fqdn_lname' : self.GetListEmail(), + 'password' : password, + 'options_url': self.GetOptionsURL(user, absolute=True), + 'requestaddr': requestaddr, + 'owneraddr' : self.GetOwnerEmail(), + }, lang=lang, mlist=self) + msg = Message.UserNotification(recipient, adminaddr, subject, text, + lang) + msg['X-No-Archive'] = 'yes' + msg.send(self, verp=config.VERP_PERSONALIZED_DELIVERIES) + + def ForwardMessage(self, msg, text=None, subject=None, tomoderators=True): + # Wrap the message as an attachment + if text is None: + text = _('No reason given') + if subject is None: + text = _('(no subject)') + text = MIMEText(Utils.wrap(text), + _charset=Utils.GetCharSet(self.preferred_language)) + attachment = MIMEMessage(msg) + notice = Message.OwnerNotification( + self, subject, tomoderators=tomoderators) + # Make it look like the message is going to the -owner address + notice.set_type('multipart/mixed') + notice.attach(text) + notice.attach(attachment) + notice.send(self) + + def SendHostileSubscriptionNotice(self, listname, address): + # Some one was invited to one list but tried to confirm to a different + # list. We inform both list owners of the bogosity, but be careful + # not to reveal too much information. + selfname = self.internal_name() + mlog.error('%s was invited to %s but confirmed to %s', + address, listname, selfname) + # First send a notice to the attacked list + msg = Message.OwnerNotification( + self, + _('Hostile subscription attempt detected'), + Utils.wrap(_("""%(address)s was invited to a different mailing +list, but in a deliberate malicious attempt they tried to confirm the +invitation to your list. We just thought you'd like to know. No further +action by you is required."""))) + msg.send(self) + # Now send a notice to the invitee list + try: + # Avoid import loops + from mailman.MailList import MailList + mlist = MailList(listname, lock=False) + except Errors.MMListError: + # Oh well + return + with i18n.using_language(mlist.preferred_language): + msg = Message.OwnerNotification( + mlist, + _('Hostile subscription attempt detected'), + Utils.wrap(_("""You invited %(address)s to your list, but in a +deliberate malicious attempt, they tried to confirm the invitation to a +different list. We just thought you'd like to know. No further action by you +is required."""))) + msg.send(mlist) + + def sendProbe(self, member, msg): + listname = self.real_name + # Put together the substitution dictionary. + d = {'listname': listname, + 'address': member, + 'optionsurl': self.GetOptionsURL(member, absolute=True), + 'owneraddr': self.GetOwnerEmail(), + } + text = Utils.maketext('probe.txt', d, + lang=self.getMemberLanguage(member), + mlist=self) + # Calculate the VERP'd sender address for bounce processing of the + # probe message. + token = self.pend_new(Pending.PROBE_BOUNCE, member, msg) + probedict = { + 'bounces': self.internal_name() + '-bounces', + 'token': token, + } + probeaddr = '%s@%s' % ((config.VERP_PROBE_FORMAT % probedict), + self.host_name) + # Calculate the Subject header, in the member's preferred language + ulang = self.getMemberLanguage(member) + with i18n.using_language(ulang): + subject = _('%(listname)s mailing list probe message') + outer = Message.UserNotification(member, probeaddr, subject, + lang=ulang) + outer.set_type('multipart/mixed') + text = MIMEText(text, _charset=Utils.GetCharSet(ulang)) + outer.attach(text) + outer.attach(MIMEMessage(msg)) + # Turn off further VERP'ing in the final delivery step. We set + # probe_token for the OutgoingRunner to more easily handling local + # rejects of probe messages. + outer.send(self, envsender=probeaddr, verp=False, probe_token=token) diff --git a/src/mailman/attic/Digester.py b/src/mailman/attic/Digester.py new file mode 100644 index 000000000..a88d08abc --- /dev/null +++ b/src/mailman/attic/Digester.py @@ -0,0 +1,57 @@ +# 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 . + + +"""Mixin class with list-digest handling methods and settings.""" + +import os +import errno + +from mailman import Errors +from mailman import Utils +from mailman.Handlers import ToDigest +from mailman.configuration import config +from mailman.i18n import _ + + + +class Digester: + def send_digest_now(self): + # Note: Handler.ToDigest.send_digests() handles bumping the digest + # volume and issue number. + digestmbox = os.path.join(self.fullpath(), 'digest.mbox') + try: + try: + mboxfp = None + # See if there's a digest pending for this mailing list + if os.stat(digestmbox).st_size > 0: + mboxfp = open(digestmbox) + ToDigest.send_digests(self, mboxfp) + os.unlink(digestmbox) + finally: + if mboxfp: + mboxfp.close() + except OSError, e: + if e.errno <> errno.ENOENT: + raise + # List has no outstanding digests + return False + return True + + def bump_digest_volume(self): + self.volume += 1 + self.next_digest_number = 1 diff --git a/src/mailman/attic/MailList.py b/src/mailman/attic/MailList.py new file mode 100644 index 000000000..2d538f026 --- /dev/null +++ b/src/mailman/attic/MailList.py @@ -0,0 +1,731 @@ +# 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 . + + +"""The class representing a Mailman mailing list. + +Mixes in many task-specific classes. +""" + +from __future__ import with_statement + +import os +import re +import sys +import time +import errno +import shutil +import socket +import urllib +import cPickle +import logging +import marshal +import email.Iterators + +from UserDict import UserDict +from cStringIO import StringIO +from string import Template +from types import MethodType +from urlparse import urlparse +from zope.interface import implements + +from email.Header import Header +from email.Utils import getaddresses, formataddr, parseaddr + +from Mailman import Errors +from Mailman import Utils +from Mailman import Version +from Mailman import database +from Mailman.UserDesc import UserDesc +from Mailman.configuration import config +from Mailman.interfaces import * + +# Base classes +from Mailman.Archiver import Archiver +from Mailman.Bouncer import Bouncer +from Mailman.Digester import Digester +from Mailman.SecurityManager import SecurityManager + +# GUI components package +from Mailman import Gui + +# Other useful classes +from Mailman import i18n +from Mailman import MemberAdaptor +from Mailman import Message + +_ = i18n._ + +DOT = '.' +EMPTYSTRING = '' +OR = '|' + +clog = logging.getLogger('mailman.config') +elog = logging.getLogger('mailman.error') +vlog = logging.getLogger('mailman.vette') +slog = logging.getLogger('mailman.subscribe') + + + +# Use mixins here just to avoid having any one chunk be too large. +class MailList(object, Archiver, Digester, SecurityManager, Bouncer): + + implements( + IMailingList, + IMailingListAddresses, + IMailingListIdentity, + IMailingListRosters, + ) + + def __init__(self, data): + self._data = data + # Only one level of mixin inheritance allowed. + for baseclass in self.__class__.__bases__: + if hasattr(baseclass, '__init__'): + baseclass.__init__(self) + # Initialize the web u/i components. + self._gui = [] + for component in dir(Gui): + if component.startswith('_'): + continue + self._gui.append(getattr(Gui, component)()) + # Give the extension mechanism a chance to process this list. + try: + from Mailman.ext import init_mlist + except ImportError: + pass + else: + init_mlist(self) + + def __getattr__(self, name): + missing = object() + if name.startswith('_'): + return getattr(super(MailList, self), name) + # Delegate to the database model object if it has the attribute. + obj = getattr(self._data, name, missing) + if obj is not missing: + return obj + # Finally, delegate to one of the gui components. + for guicomponent in self._gui: + obj = getattr(guicomponent, name, missing) + if obj is not missing: + return obj + # Nothing left to delegate to, so it's got to be an error. + raise AttributeError(name) + + def __repr__(self): + return '' % (self.fqdn_listname, id(self)) + + + def GetConfirmJoinSubject(self, listname, cookie): + if config.VERP_CONFIRMATIONS and cookie: + cset = i18n.get_translation().charset() or \ + Utils.GetCharSet(self.preferred_language) + subj = Header( + _('Your confirmation is required to join the %(listname)s mailing list'), + cset, header_name='subject') + return subj + else: + return 'confirm ' + cookie + + def GetConfirmLeaveSubject(self, listname, cookie): + if config.VERP_CONFIRMATIONS and cookie: + cset = i18n.get_translation().charset() or \ + Utils.GetCharSet(self.preferred_language) + subj = Header( + _('Your confirmation is required to leave the %(listname)s mailing list'), + cset, header_name='subject') + return subj + else: + return 'confirm ' + cookie + + def GetMemberAdminEmail(self, member): + """Usually the member addr, but modified for umbrella lists. + + Umbrella lists have other mailing lists as members, and so admin stuff + like confirmation requests and passwords must not be sent to the + member addresses - the sublists - but rather to the administrators of + the sublists. This routine picks the right address, considering + regular member address to be their own administrative addresses. + + """ + if not self.umbrella_list: + return member + else: + acct, host = tuple(member.split('@')) + return "%s%s@%s" % (acct, self.umbrella_member_suffix, host) + + def GetScriptURL(self, target, absolute=False): + if absolute: + return self.web_page_url + target + '/' + self.fqdn_listname + else: + return Utils.ScriptURL(target) + '/' + self.fqdn_listname + + def GetOptionsURL(self, user, obscure=False, absolute=False): + url = self.GetScriptURL('options', absolute) + if obscure: + user = Utils.ObscureEmail(user) + return '%s/%s' % (url, urllib.quote(user.lower())) + + + # + # Web API support via administrative categories + # + def GetConfigCategories(self): + class CategoryDict(UserDict): + def __init__(self): + UserDict.__init__(self) + self.keysinorder = config.ADMIN_CATEGORIES[:] + def keys(self): + return self.keysinorder + def items(self): + items = [] + for k in config.ADMIN_CATEGORIES: + items.append((k, self.data[k])) + return items + def values(self): + values = [] + for k in config.ADMIN_CATEGORIES: + values.append(self.data[k]) + return values + + categories = CategoryDict() + # Only one level of mixin inheritance allowed + for gui in self._gui: + k, v = gui.GetConfigCategory() + categories[k] = (v, gui) + return categories + + def GetConfigSubCategories(self, category): + for gui in self._gui: + if hasattr(gui, 'GetConfigSubCategories'): + # Return the first one that knows about the given subcategory + subcat = gui.GetConfigSubCategories(category) + if subcat is not None: + return subcat + return None + + def GetConfigInfo(self, category, subcat=None): + for gui in self._gui: + if hasattr(gui, 'GetConfigInfo'): + value = gui.GetConfigInfo(self, category, subcat) + if value: + return value + + + # + # Membership management front-ends and assertion checks + # + def InviteNewMember(self, userdesc, text=''): + """Invite a new member to the list. + + This is done by creating a subscription pending for the user, and then + crafting a message to the member informing them of the invitation. + """ + invitee = userdesc.address + Utils.ValidateEmail(invitee) + # check for banned address + pattern = Utils.get_pattern(invitee, self.ban_list) + if pattern: + raise Errors.MembershipIsBanned(pattern) + # Hack alert! Squirrel away a flag that only invitations have, so + # that we can do something slightly different when an invitation + # subscription is confirmed. In those cases, we don't need further + # admin approval, even if the list is so configured. The flag is the + # list name to prevent invitees from cross-subscribing. + userdesc.invitation = self.internal_name() + cookie = self.pend_new(Pending.SUBSCRIPTION, userdesc) + requestaddr = self.getListAddress('request') + confirmurl = '%s/%s' % (self.GetScriptURL('confirm', absolute=1), + cookie) + listname = self.real_name + text += Utils.maketext( + 'invite.txt', + {'email' : invitee, + 'listname' : listname, + 'hostname' : self.host_name, + 'confirmurl' : confirmurl, + 'requestaddr': requestaddr, + 'cookie' : cookie, + 'listowner' : self.GetOwnerEmail(), + }, mlist=self) + sender = self.GetRequestEmail(cookie) + msg = Message.UserNotification( + invitee, sender, + text=text, lang=self.preferred_language) + subj = self.GetConfirmJoinSubject(listname, cookie) + del msg['subject'] + msg['Subject'] = subj + msg.send(self) + + def AddMember(self, userdesc, remote=None): + """Front end to member subscription. + + This method enforces subscription policy, validates values, sends + notifications, and any other grunt work involved in subscribing a + user. It eventually calls ApprovedAddMember() to do the actual work + of subscribing the user. + + userdesc is an instance with the following public attributes: + + address -- the unvalidated email address of the member + fullname -- the member's full name (i.e. John Smith) + digest -- a flag indicating whether the user wants digests or not + language -- the requested default language for the user + password -- the user's password + + Other attributes may be defined later. Only address is required; the + others all have defaults (fullname='', digests=0, language=list's + preferred language, password=generated). + + remote is a string which describes where this add request came from. + """ + assert self.Locked() + # Suck values out of userdesc, apply defaults, and reset the userdesc + # attributes (for passing on to ApprovedAddMember()). Lowercase the + # addr's domain part. + email = Utils.LCDomain(userdesc.address) + name = getattr(userdesc, 'fullname', '') + lang = getattr(userdesc, 'language', self.preferred_language) + digest = getattr(userdesc, 'digest', None) + password = getattr(userdesc, 'password', Utils.MakeRandomPassword()) + if digest is None: + if self.nondigestable: + digest = 0 + else: + digest = 1 + # Validate the e-mail address to some degree. + Utils.ValidateEmail(email) + if self.isMember(email): + raise Errors.MMAlreadyAMember, email + if email.lower() == self.GetListEmail().lower(): + # Trying to subscribe the list to itself! + raise Errors.InvalidEmailAddress + realname = self.real_name + # Is the subscribing address banned from this list? + pattern = Utils.get_pattern(email, self.ban_list) + if pattern: + vlog.error('%s banned subscription: %s (matched: %s)', + realname, email, pattern) + raise Errors.MembershipIsBanned, pattern + # Sanity check the digest flag + if digest and not self.digestable: + raise Errors.MMCantDigestError + elif not digest and not self.nondigestable: + raise Errors.MMMustDigestError + + userdesc.address = email + userdesc.fullname = name + userdesc.digest = digest + userdesc.language = lang + userdesc.password = password + + # Apply the list's subscription policy. 0 means open subscriptions; 1 + # means the user must confirm; 2 means the admin must approve; 3 means + # the user must confirm and then the admin must approve + if self.subscribe_policy == 0: + self.ApprovedAddMember(userdesc, whence=remote or '') + elif self.subscribe_policy == 1 or self.subscribe_policy == 3: + # User confirmation required. BAW: this should probably just + # accept a userdesc instance. + cookie = self.pend_new(Pending.SUBSCRIPTION, userdesc) + # Send the user the confirmation mailback + if remote is None: + by = remote = '' + else: + by = ' ' + remote + remote = _(' from %(remote)s') + + recipient = self.GetMemberAdminEmail(email) + confirmurl = '%s/%s' % (self.GetScriptURL('confirm', absolute=1), + cookie) + text = Utils.maketext( + 'verify.txt', + {'email' : email, + 'listaddr' : self.GetListEmail(), + 'listname' : realname, + 'cookie' : cookie, + 'requestaddr' : self.getListAddress('request'), + 'remote' : remote, + 'listadmin' : self.GetOwnerEmail(), + 'confirmurl' : confirmurl, + }, lang=lang, mlist=self) + msg = Message.UserNotification( + recipient, self.GetRequestEmail(cookie), + text=text, lang=lang) + # BAW: See ChangeMemberAddress() for why we do it this way... + del msg['subject'] + msg['Subject'] = self.GetConfirmJoinSubject(realname, cookie) + msg['Reply-To'] = self.GetRequestEmail(cookie) + msg.send(self) + who = formataddr((name, email)) + slog.info('%s: pending %s %s', self.internal_name(), who, by) + raise Errors.MMSubscribeNeedsConfirmation + elif self.HasAutoApprovedSender(email): + # no approval necessary: + self.ApprovedAddMember(userdesc) + else: + # Subscription approval is required. Add this entry to the admin + # requests database. BAW: this should probably take a userdesc + # just like above. + self.HoldSubscription(email, name, password, digest, lang) + raise Errors.MMNeedApproval, _( + 'subscriptions to %(realname)s require moderator approval') + + def DeleteMember(self, name, whence=None, admin_notif=None, userack=True): + realname, email = parseaddr(name) + if self.unsubscribe_policy == 0: + self.ApprovedDeleteMember(name, whence, admin_notif, userack) + else: + self.HoldUnsubscription(email) + raise Errors.MMNeedApproval, _( + 'unsubscriptions require moderator approval') + + def ChangeMemberAddress(self, oldaddr, newaddr, globally): + # Changing a member address consists of verifying the new address, + # making sure the new address isn't already a member, and optionally + # going through the confirmation process. + # + # Most of these checks are copied from AddMember + newaddr = Utils.LCDomain(newaddr) + Utils.ValidateEmail(newaddr) + # Raise an exception if this email address is already a member of the + # list, but only if the new address is the same case-wise as the old + # address and we're not doing a global change. + if not globally and newaddr == oldaddr and self.isMember(newaddr): + raise Errors.MMAlreadyAMember + if newaddr == self.GetListEmail().lower(): + raise Errors.InvalidEmailAddress + realname = self.real_name + # Don't allow changing to a banned address. MAS: maybe we should + # unsubscribe the oldaddr too just for trying, but that's probably + # too harsh. + pattern = Utils.get_pattern(newaddr, self.ban_list) + if pattern: + vlog.error('%s banned address change: %s -> %s (matched: %s)', + realname, oldaddr, newaddr, pattern) + raise Errors.MembershipIsBanned, pattern + # Pend the subscription change + cookie = self.pend_new(Pending.CHANGE_OF_ADDRESS, + oldaddr, newaddr, globally) + confirmurl = '%s/%s' % (self.GetScriptURL('confirm', absolute=1), + cookie) + lang = self.getMemberLanguage(oldaddr) + text = Utils.maketext( + 'verify.txt', + {'email' : newaddr, + 'listaddr' : self.GetListEmail(), + 'listname' : realname, + 'cookie' : cookie, + 'requestaddr': self.getListAddress('request'), + 'remote' : '', + 'listadmin' : self.GetOwnerEmail(), + 'confirmurl' : confirmurl, + }, lang=lang, mlist=self) + # BAW: We don't pass the Subject: into the UserNotification + # constructor because it will encode it in the charset of the language + # being used. For non-us-ascii charsets, this means it will probably + # quopri quote it, and thus replies will also be quopri encoded. But + # CommandRunner doesn't yet grok such headers. So, just set the + # Subject: in a separate step, although we have to delete the one + # UserNotification adds. + msg = Message.UserNotification( + newaddr, self.GetRequestEmail(cookie), + text=text, lang=lang) + del msg['subject'] + msg['Subject'] = self.GetConfirmJoinSubject(realname, cookie) + msg['Reply-To'] = self.GetRequestEmail(cookie) + msg.send(self) + + def ApprovedChangeMemberAddress(self, oldaddr, newaddr, globally): + # Check here for banned address in case address was banned after + # confirmation was mailed. MAS: If it's global change should we just + # skip this list and proceed to the others? For now we'll throw the + # exception. + pattern = Utils.get_pattern(newaddr, self.ban_list) + if pattern: + raise Errors.MembershipIsBanned, pattern + # It's possible they were a member of this list, but choose to change + # their membership globally. In that case, we simply remove the old + # address. + if self.getMemberCPAddress(oldaddr) == newaddr: + self.removeMember(oldaddr) + else: + self.changeMemberAddress(oldaddr, newaddr) + self.log_and_notify_admin(oldaddr, newaddr) + # If globally is true, then we also include every list for which + # oldaddr is a member. + if not globally: + return + for listname in config.list_manager.names: + # Don't bother with ourselves + if listname == self.internal_name(): + continue + mlist = MailList(listname, lock=0) + if mlist.host_name <> self.host_name: + continue + if not mlist.isMember(oldaddr): + continue + # If new address is banned from this list, just skip it. + if Utils.get_pattern(newaddr, mlist.ban_list): + continue + mlist.Lock() + try: + # Same logic as above, re newaddr is already a member + if mlist.getMemberCPAddress(oldaddr) == newaddr: + mlist.removeMember(oldaddr) + else: + mlist.changeMemberAddress(oldaddr, newaddr) + mlist.log_and_notify_admin(oldaddr, newaddr) + mlist.Save() + finally: + mlist.Unlock() + + def log_and_notify_admin(self, oldaddr, newaddr): + """Log member address change and notify admin if requested.""" + slog.info('%s: changed member address from %s to %s', + self.internal_name(), oldaddr, newaddr) + if self.admin_notify_mchanges: + with i18n.using_language(self.preferred_language): + realname = self.real_name + subject = _('%(realname)s address change notification') + name = self.getMemberName(newaddr) + if name is None: + name = '' + if isinstance(name, unicode): + name = name.encode(Utils.GetCharSet(self.preferred_language), + 'replace') + text = Utils.maketext( + 'adminaddrchgack.txt', + {'name' : name, + 'oldaddr' : oldaddr, + 'newaddr' : newaddr, + 'listname': self.real_name, + }, mlist=self) + msg = Message.OwnerNotification(self, subject, text) + msg.send(self) + + + # + # Confirmation processing + # + def ProcessConfirmation(self, cookie, context=None): + rec = self.pend_confirm(cookie) + if rec is None: + raise Errors.MMBadConfirmation, 'No cookie record for %s' % cookie + try: + op = rec[0] + data = rec[1:] + except ValueError: + raise Errors.MMBadConfirmation, 'op-less data %s' % (rec,) + if op == Pending.SUBSCRIPTION: + whence = 'via email confirmation' + try: + userdesc = data[0] + # If confirmation comes from the web, context should be a + # UserDesc instance which contains overrides of the original + # subscription information. If it comes from email, then + # context is a Message and isn't relevant, so ignore it. + if isinstance(context, UserDesc): + userdesc += context + whence = 'via web confirmation' + addr = userdesc.address + fullname = userdesc.fullname + password = userdesc.password + digest = userdesc.digest + lang = userdesc.language + except ValueError: + raise Errors.MMBadConfirmation, 'bad subscr data %s' % (data,) + # Hack alert! Was this a confirmation of an invitation? + invitation = getattr(userdesc, 'invitation', False) + # We check for both 2 (approval required) and 3 (confirm + + # approval) because the policy could have been changed in the + # middle of the confirmation dance. + if invitation: + if invitation <> self.internal_name(): + # Not cool. The invitee was trying to subscribe to a + # different list than they were invited to. Alert both + # list administrators. + self.SendHostileSubscriptionNotice(invitation, addr) + raise Errors.HostileSubscriptionError + elif self.subscribe_policy in (2, 3) and \ + not self.HasAutoApprovedSender(addr): + self.HoldSubscription(addr, fullname, password, digest, lang) + name = self.real_name + raise Errors.MMNeedApproval, _( + 'subscriptions to %(name)s require administrator approval') + self.ApprovedAddMember(userdesc, whence=whence) + return op, addr, password, digest, lang + elif op == Pending.UNSUBSCRIPTION: + addr = data[0] + # Log file messages don't need to be i18n'd + if isinstance(context, Message.Message): + whence = 'email confirmation' + else: + whence = 'web confirmation' + # Can raise NotAMemberError if they unsub'd via other means + self.ApprovedDeleteMember(addr, whence=whence) + return op, addr + elif op == Pending.CHANGE_OF_ADDRESS: + oldaddr, newaddr, globally = data + self.ApprovedChangeMemberAddress(oldaddr, newaddr, globally) + return op, oldaddr, newaddr + elif op == Pending.HELD_MESSAGE: + id = data[0] + approved = None + # Confirmation should be coming from email, where context should + # be the confirming message. If the message does not have an + # Approved: header, this is a discard. If it has an Approved: + # header that does not match the list password, then we'll notify + # the list administrator that they used the wrong password. + # Otherwise it's an approval. + if isinstance(context, Message.Message): + # See if it's got an Approved: header, either in the headers, + # or in the first text/plain section of the response. For + # robustness, we'll accept Approve: as well. + approved = context.get('Approved', context.get('Approve')) + if not approved: + try: + subpart = list(email.Iterators.typed_subpart_iterator( + context, 'text', 'plain'))[0] + except IndexError: + subpart = None + if subpart: + s = StringIO(subpart.get_payload()) + while True: + line = s.readline() + if not line: + break + if not line.strip(): + continue + i = line.find(':') + if i > 0: + if (line[:i].lower() == 'approve' or + line[:i].lower() == 'approved'): + # then + approved = line[i+1:].strip() + break + # Is there an approved header? + if approved is not None: + # Does it match the list password? Note that we purposefully + # do not allow the site password here. + if self.Authenticate([config.AuthListAdmin, + config.AuthListModerator], + approved) <> config.UnAuthorized: + action = config.APPROVE + else: + # The password didn't match. Re-pend the message and + # inform the list moderators about the problem. + self.pend_repend(cookie, rec) + raise Errors.MMBadPasswordError + else: + action = config.DISCARD + try: + self.HandleRequest(id, action) + except KeyError: + # Most likely because the message has already been disposed of + # via the admindb page. + elog.error('Could not process HELD_MESSAGE: %s', id) + return (op,) + elif op == Pending.RE_ENABLE: + member = data[1] + self.setDeliveryStatus(member, MemberAdaptor.ENABLED) + return op, member + else: + assert 0, 'Bad op: %s' % op + + def ConfirmUnsubscription(self, addr, lang=None, remote=None): + if lang is None: + lang = self.getMemberLanguage(addr) + cookie = self.pend_new(Pending.UNSUBSCRIPTION, addr) + confirmurl = '%s/%s' % (self.GetScriptURL('confirm', absolute=1), + cookie) + realname = self.real_name + if remote is not None: + by = " " + remote + remote = _(" from %(remote)s") + else: + by = "" + remote = "" + text = Utils.maketext( + 'unsub.txt', + {'email' : addr, + 'listaddr' : self.GetListEmail(), + 'listname' : realname, + 'cookie' : cookie, + 'requestaddr' : self.getListAddress('request'), + 'remote' : remote, + 'listadmin' : self.GetOwnerEmail(), + 'confirmurl' : confirmurl, + }, lang=lang, mlist=self) + msg = Message.UserNotification( + addr, self.GetRequestEmail(cookie), + text=text, lang=lang) + # BAW: See ChangeMemberAddress() for why we do it this way... + del msg['subject'] + msg['Subject'] = self.GetConfirmLeaveSubject(realname, cookie) + msg['Reply-To'] = self.GetRequestEmail(cookie) + msg.send(self) + + + # + # Miscellaneous stuff + # + + def HasAutoApprovedSender(self, sender): + """Returns True and logs if sender matches address or pattern + in subscribe_auto_approval. Otherwise returns False. + """ + auto_approve = False + if Utils.get_pattern(sender, self.subscribe_auto_approval): + auto_approve = True + vlog.info('%s: auto approved subscribe from %s', + self.internal_name(), sender) + return auto_approve + + + # + # Multilingual (i18n) support + # + def set_languages(self, *language_codes): + # XXX FIXME not to use a database entity directly. + from Mailman.database.model import Language + # Don't use the language_codes property because that will add the + # default server language. The effect would be that the default + # server language would never get added to the list's list of + # languages. + requested_codes = set(language_codes) + enabled_codes = set(config.languages.enabled_codes) + self.available_languages = [ + Language(code) for code in requested_codes & enabled_codes] + + def add_language(self, language_code): + self.available_languages.append(Language(language_code)) + + @property + def language_codes(self): + # Callers of this method expect a list of language codes + available_codes = set(self.available_languages) + enabled_codes = set(config.languages.enabled_codes) + codes = available_codes & enabled_codes + # If we don't add this, and the site admin has never added any + # language support to the list, then the general admin page may have a + # blank field where the list owner is supposed to chose the list's + # preferred language. + if config.DEFAULT_SERVER_LANGUAGE not in codes: + codes.add(config.DEFAULT_SERVER_LANGUAGE) + return list(codes) diff --git a/src/mailman/attic/SecurityManager.py b/src/mailman/attic/SecurityManager.py new file mode 100644 index 000000000..8d4a30592 --- /dev/null +++ b/src/mailman/attic/SecurityManager.py @@ -0,0 +1,306 @@ +# 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 . + +"""Handle passwords and sanitize approved messages.""" + +# There are current 5 roles defined in Mailman, as codified in Defaults.py: +# user, list-creator, list-moderator, list-admin, site-admin. +# +# Here's how we do cookie based authentication. +# +# Each role (see above) has an associated password, which is currently the +# only way to authenticate a role (in the future, we'll authenticate a +# user and assign users to roles). +# +# Each cookie has the following ingredients: the authorization context's +# secret (i.e. the password, and a timestamp. We generate an SHA1 hex +# digest of these ingredients, which we call the 'mac'. We then marshal +# up a tuple of the timestamp and the mac, hexlify that and return that as +# a cookie keyed off the authcontext. Note that authenticating the user +# also requires the user's email address to be included in the cookie. +# +# The verification process is done in CheckCookie() below. It extracts +# the cookie, unhexlifies and unmarshals the tuple, extracting the +# timestamp. Using this, and the shared secret, the mac is calculated, +# and it must match the mac passed in the cookie. If so, they're golden, +# otherwise, access is denied. +# +# It is still possible for an adversary to attempt to brute force crack +# the password if they obtain the cookie, since they can extract the +# timestamp and create macs based on password guesses. They never get a +# cleartext version of the password though, so security rests on the +# difficulty and expense of retrying the cgi dialog for each attempt. It +# also relies on the security of SHA1. + +import os +import re +import sha +import time +import urllib +import Cookie +import logging +import marshal +import binascii + +from urlparse import urlparse + +from Mailman import Defaults +from Mailman import Errors +from Mailman import Utils +from Mailman import passwords +from Mailman.configuration import config + +log = logging.getLogger('mailman.error') +dlog = logging.getLogger('mailman.debug') + +SLASH = '/' + + + +class SecurityManager: + def AuthContextInfo(self, authcontext, user=None): + # authcontext may be one of AuthUser, AuthListModerator, + # AuthListAdmin, AuthSiteAdmin. Not supported is the AuthCreator + # context. + # + # user is ignored unless authcontext is AuthUser + # + # Return the authcontext's secret and cookie key. If the authcontext + # doesn't exist, return the tuple (None, None). If authcontext is + # AuthUser, but the user isn't a member of this mailing list, a + # NotAMemberError will be raised. If the user's secret is None, raise + # a MMBadUserError. + key = urllib.quote(self.fqdn_listname) + '+' + if authcontext == Defaults.AuthUser: + if user is None: + # A bad system error + raise TypeError('No user supplied for AuthUser context') + secret = self.getMemberPassword(user) + userdata = urllib.quote(Utils.ObscureEmail(user), safe='') + key += 'user+%s' % userdata + elif authcontext == Defaults.AuthListModerator: + secret = self.mod_password + key += 'moderator' + elif authcontext == Defaults.AuthListAdmin: + secret = self.password + key += 'admin' + # BAW: AuthCreator + elif authcontext == Defaults.AuthSiteAdmin: + sitepass = Utils.get_global_password() + if config.ALLOW_SITE_ADMIN_COOKIES and sitepass: + secret = sitepass + key = 'site' + else: + # BAW: this should probably hand out a site password based + # cookie, but that makes me a bit nervous, so just treat site + # admin as a list admin since there is currently no site + # admin-only functionality. + secret = self.password + key += 'admin' + else: + return None, None + return key, secret + + def Authenticate(self, authcontexts, response, user=None): + # Given a list of authentication contexts, check to see if the + # response matches one of the passwords. authcontexts must be a + # sequence, and if it contains the context AuthUser, then the user + # argument must not be None. + # + # Return the authcontext from the argument sequence that matches the + # response, or UnAuthorized. + for ac in authcontexts: + if ac == Defaults.AuthCreator: + ok = Utils.check_global_password(response, siteadmin=False) + if ok: + return Defaults.AuthCreator + elif ac == Defaults.AuthSiteAdmin: + ok = Utils.check_global_password(response) + if ok: + return Defaults.AuthSiteAdmin + elif ac == Defaults.AuthListAdmin: + # The password for the list admin and list moderator are not + # kept as plain text, but instead as an sha hexdigest. The + # response being passed in is plain text, so we need to + # digestify it first. + key, secret = self.AuthContextInfo(ac) + if secret is None: + continue + if passwords.check_response(secret, response): + return ac + elif ac == Defaults.AuthListModerator: + # The list moderator password must be sha'd + key, secret = self.AuthContextInfo(ac) + if secret and passwords.check_response(secret, response): + return ac + elif ac == Defaults.AuthUser: + if user is not None: + try: + if self.authenticateMember(user, response): + return ac + except Errors.NotAMemberError: + pass + else: + # What is this context??? + log.error('Bad authcontext: %s', ac) + raise ValueError('Bad authcontext: %s' % ac) + return Defaults.UnAuthorized + + def WebAuthenticate(self, authcontexts, response, user=None): + # Given a list of authentication contexts, check to see if the cookie + # contains a matching authorization, falling back to checking whether + # the response matches one of the passwords. authcontexts must be a + # sequence, and if it contains the context AuthUser, then the user + # argument should not be None. + # + # Returns a flag indicating whether authentication succeeded or not. + for ac in authcontexts: + ok = self.CheckCookie(ac, user) + if ok: + return True + # Check passwords + ac = self.Authenticate(authcontexts, response, user) + if ac: + print self.MakeCookie(ac, user) + return True + return False + + def _cookie_path(self): + script_name = os.environ.get('SCRIPT_NAME', '') + return SLASH.join(script_name.split(SLASH)[:-1]) + SLASH + + def MakeCookie(self, authcontext, user=None): + key, secret = self.AuthContextInfo(authcontext, user) + if key is None or secret is None or not isinstance(secret, basestring): + raise ValueError + # Timestamp + issued = int(time.time()) + # Get a digest of the secret, plus other information. + mac = sha.new(secret + repr(issued)).hexdigest() + # Create the cookie object. + c = Cookie.SimpleCookie() + c[key] = binascii.hexlify(marshal.dumps((issued, mac))) + c[key]['path'] = self._cookie_path() + # We use session cookies, so don't set 'expires' or 'max-age' keys. + # Set the RFC 2109 required header. + c[key]['version'] = 1 + return c + + def ZapCookie(self, authcontext, user=None): + # We can throw away the secret. + key, secret = self.AuthContextInfo(authcontext, user) + # Logout of the session by zapping the cookie. For safety both set + # max-age=0 (as per RFC2109) and set the cookie data to the empty + # string. + c = Cookie.SimpleCookie() + c[key] = '' + c[key]['path'] = self._cookie_path() + c[key]['max-age'] = 0 + # Don't set expires=0 here otherwise it'll force a persistent cookie + c[key]['version'] = 1 + return c + + def CheckCookie(self, authcontext, user=None): + # Two results can occur: we return 1 meaning the cookie authentication + # succeeded for the authorization context, we return 0 meaning the + # authentication failed. + # + # Dig out the cookie data, which better be passed on this cgi + # environment variable. If there's no cookie data, we reject the + # authentication. + cookiedata = os.environ.get('HTTP_COOKIE') + if not cookiedata: + return False + # We can't use the Cookie module here because it isn't liberal in what + # it accepts. Feed it a MM2.0 cookie along with a MM2.1 cookie and + # you get a CookieError. :(. All we care about is accessing the + # cookie data via getitem, so we'll use our own parser, which returns + # a dictionary. + c = parsecookie(cookiedata) + # If the user was not supplied, but the authcontext is AuthUser, we + # can try to glean the user address from the cookie key. There may be + # more than one matching key (if the user has multiple accounts + # subscribed to this list), but any are okay. + if authcontext == Defaults.AuthUser: + if user: + usernames = [user] + else: + usernames = [] + prefix = urllib.quote(self.fqdn_listname) + '+user+' + for k in c.keys(): + if k.startswith(prefix): + usernames.append(k[len(prefix):]) + # If any check out, we're golden. Note: '@'s are no longer legal + # values in cookie keys. + for user in [Utils.UnobscureEmail(u) for u in usernames]: + ok = self.__checkone(c, authcontext, user) + if ok: + return True + return False + else: + return self.__checkone(c, authcontext, user) + + def __checkone(self, c, authcontext, user): + # Do the guts of the cookie check, for one authcontext/user + # combination. + try: + key, secret = self.AuthContextInfo(authcontext, user) + except Errors.NotAMemberError: + return False + if key not in c or not isinstance(secret, basestring): + return False + # Undo the encoding we performed in MakeCookie() above. BAW: I + # believe this is safe from exploit because marshal can't be forced to + # load recursive data structures, and it can't be forced to execute + # any unexpected code. The worst that can happen is that either the + # client will have provided us bogus data, in which case we'll get one + # of the caught exceptions, or marshal format will have changed, in + # which case, the cookie decoding will fail. In either case, we'll + # simply request reauthorization, resulting in a new cookie being + # returned to the client. + try: + data = marshal.loads(binascii.unhexlify(c[key])) + issued, received_mac = data + except (EOFError, ValueError, TypeError, KeyError): + return False + # Make sure the issued timestamp makes sense + now = time.time() + if now < issued: + return False + # Calculate what the mac ought to be based on the cookie's timestamp + # and the shared secret. + mac = sha.new(secret + repr(issued)).hexdigest() + if mac <> received_mac: + return False + # Authenticated! + return True + + + +splitter = re.compile(';\s*') + +def parsecookie(s): + c = {} + for line in s.splitlines(): + for p in splitter.split(line): + try: + k, v = p.split('=', 1) + except ValueError: + pass + else: + c[k] = v + return c diff --git a/src/mailman/attic/bin/clone_member b/src/mailman/attic/bin/clone_member new file mode 100755 index 000000000..1f2a03aca --- /dev/null +++ b/src/mailman/attic/bin/clone_member @@ -0,0 +1,219 @@ +#! @PYTHON@ +# +# Copyright (C) 1998,1999,2000,2001,2002 by the Free Software Foundation, Inc. +# +# This program 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 2 +# of the License, or (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +"""Clone a member address. + +Cloning a member address means that a new member will be added who has all the +same options and passwords as the original member address. Note that this +operation is fairly trusting of the user who runs it -- it does no +verification to the new address, it does not send out a welcome message, etc. + +The existing member's subscription is usually not modified in any way. If you +want to remove the old address, use the -r flag. If you also want to change +any list admin addresses, use the -a flag. + +Usage: + clone_member [options] fromoldaddr tonewaddr + +Where: + + --listname=listname + -l listname + Check and modify only the named mailing lists. If -l is not given, + then all mailing lists are scanned from the address. Multiple -l + options can be supplied. + + --remove + -r + Remove the old address from the mailing list after it's been cloned. + + --admin + -a + Scan the list admin addresses for the old address, and clone or change + them too. + + --quiet + -q + Do the modifications quietly. + + --nomodify + -n + Print what would be done, but don't actually do it. Inhibits the + --quiet flag. + + --help + -h + Print this help message and exit. + + fromoldaddr (`from old address') is the old address of the user. tonewaddr + (`to new address') is the new address of the user. + +""" + +import sys +import getopt + +import paths +from Mailman import MailList +from Mailman import Utils +from Mailman import Errors +from Mailman.i18n import _ + + + +def usage(code, msg=''): + if code: + fd = sys.stderr + else: + fd = sys.stdout + print >> fd, _(__doc__) + if msg: + print >> fd, msg + sys.exit(code) + + + +def dolist(mlist, options): + SPACE = ' ' + if not options.quiet: + print _('processing mailing list:'), mlist.internal_name() + + # scan the list owners. TBD: mlist.owner keys should be lowercase? + oldowners = mlist.owner[:] + oldowners.sort() + if options.admintoo: + if not options.quiet: + print _(' scanning list owners:'), SPACE.join(oldowners) + newowners = {} + foundp = 0 + for owner in mlist.owner: + if options.lfromaddr == owner.lower(): + foundp = 1 + if options.remove: + continue + newowners[owner] = 1 + if foundp: + newowners[options.toaddr] = 1 + newowners = newowners.keys() + newowners.sort() + if options.modify: + mlist.owner = newowners + if not options.quiet: + if newowners <> oldowners: + print + print _(' new list owners:'), SPACE.join(newowners) + else: + print _('(no change)') + + # see if the fromaddr is a digest member or regular member + if options.lfromaddr in mlist.getDigestMemberKeys(): + digest = 1 + elif options.lfromaddr in mlist.getRegularMemberKeys(): + digest = 0 + else: + if not options.quiet: + print _(' address not found:'), options.fromaddr + return + + # Now change the membership address + try: + if options.modify: + mlist.changeMemberAddress(options.fromaddr, options.toaddr, + not options.remove) + if not options.quiet: + print _(' clone address added:'), options.toaddr + except Errors.MMAlreadyAMember: + if not options.quiet: + print _(' clone address is already a member:'), options.toaddr + + if options.remove: + print _(' original address removed:'), options.fromaddr + + + +def main(): + # default options + class Options: + listnames = None + remove = 0 + admintoo = 0 + quiet = 0 + modify = 1 + + # scan sysargs + try: + opts, args = getopt.getopt( + sys.argv[1:], 'arl:qnh', + ['admin', 'remove', 'listname=', 'quiet', 'nomodify', 'help']) + except getopt.error, msg: + usage(1, msg) + + options = Options() + for opt, arg in opts: + if opt in ('-h', '--help'): + usage(0) + elif opt in ('-q', '--quiet'): + options.quiet = 1 + elif opt in ('-n', '--nomodify'): + options.modify = 0 + elif opt in ('-a', '--admin'): + options.admintoo = 1 + elif opt in ('-r', '--remove'): + options.remove = 1 + elif opt in ('-l', '--listname'): + if options.listnames is None: + options.listnames = [] + options.listnames.append(arg.lower()) + + # further options and argument processing + if not options.modify: + options.quiet = 0 + + if len(args) <> 2: + usage(1) + fromaddr = args[0] + toaddr = args[1] + + # validate and normalize the target address + try: + Utils.ValidateEmail(toaddr) + except Errors.EmailAddressError: + usage(1, _('Not a valid email address: %(toaddr)s')) + lfromaddr = fromaddr.lower() + options.toaddr = toaddr + options.fromaddr = fromaddr + options.lfromaddr = lfromaddr + + if options.listnames is None: + options.listnames = Utils.list_names() + + for listname in options.listnames: + try: + mlist = MailList.MailList(listname) + except Errors.MMListError, e: + print _('Error opening list "%(listname)s", skipping.\n%(e)s') + continue + try: + dolist(mlist, options) + finally: + mlist.Save() + mlist.Unlock() + + +if __name__ == '__main__': + main() diff --git a/src/mailman/attic/bin/discard b/src/mailman/attic/bin/discard new file mode 100644 index 000000000..c30198441 --- /dev/null +++ b/src/mailman/attic/bin/discard @@ -0,0 +1,120 @@ +#! @PYTHON@ +# +# Copyright (C) 2003 by the Free Software Foundation, Inc. +# +# This program 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 2 +# of the License, or (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +"""Discard held messages. + +Usage: + discard [options] file ... + +Options: + --help / -h + Print this help message and exit. + + --quiet / -q + Don't print status messages. +""" + +# TODO: add command line arguments for specifying other actions than DISCARD, +# and also for specifying other __handlepost() arguments, i.e. comment, +# preserve, forward, addr + +import os +import re +import sys +import getopt + +import paths +from Mailman import mm_cfg +from Mailman.MailList import MailList +from Mailman.i18n import _ + +try: + True, False +except NameError: + True = 1 + False = 0 + +cre = re.compile(r'heldmsg-(?P.*)-(?P[0-9]+)\.(pck|txt)$') + + + +def usage(code, msg=''): + if code: + fd = sys.stderr + else: + fd = sys.stdout + print >> fd, _(__doc__) + if msg: + print >> fd, msg + sys.exit(code) + + + +def main(): + try: + opts, args = getopt.getopt(sys.argv[1:], 'hq', ['help', 'quiet']) + except getopt.error, msg: + usage(1, msg) + + quiet = False + for opt, arg in opts: + if opt in ('-h', '--help'): + usage(0) + elif opt in ('-q', '--quiet'): + quiet = True + + files = args + if not files: + print _('Nothing to do.') + + # Mapping from listnames to sequence of request ids + discards = {} + + # Cruise through all the named files, collating by mailing list. We'll + # lock the list once, process all holds for that list and move on. + for f in files: + basename = os.path.basename(f) + mo = cre.match(basename) + if not mo: + print >> sys.stderr, _('Ignoring non-held message: %(f)s') + continue + listname, id = mo.group('listname', 'id') + try: + id = int(id) + except (ValueError, TypeError): + print >> sys.stderr, _('Ignoring held msg w/bad id: %(f)s') + continue + discards.setdefault(listname, []).append(id) + + # Now do the discards + for listname, ids in discards.items(): + mlist = MailList(listname) + try: + for id in ids: + # No comment, no preserve, no forward, no forwarding address + mlist.HandleRequest(id, mm_cfg.DISCARD, '', False, False, '') + if not quiet: + print _('Discarded held msg #%(id)s for list %(listname)s') + mlist.Save() + finally: + mlist.Unlock() + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/attic/bin/fix_url.py b/src/mailman/attic/bin/fix_url.py new file mode 100644 index 000000000..30618a1a3 --- /dev/null +++ b/src/mailman/attic/bin/fix_url.py @@ -0,0 +1,93 @@ +#! @PYTHON@ +# +# Copyright (C) 2001-2009 by the Free Software Foundation, Inc. +# +# This program 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 2 +# of the License, or (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Reset a list's web_page_url attribute to the default setting. + +This script is intended to be run as a bin/withlist script, i.e. + +% bin/withlist -l -r fix_url listname [options] + +Options: + -u urlhost + --urlhost=urlhost + Look up urlhost in the virtual host table and set the web_page_url and + host_name attributes of the list to the values found. This + essentially moves the list from one virtual domain to another. + + Without this option, the default web_page_url and host_name values are + used. + + -v / --verbose + Print what the script is doing. + +If run standalone, it prints this help text and exits. +""" + +import sys +import getopt + +import paths +from Mailman.configuration import config +from Mailman.i18n import _ + + + +def usage(code, msg=''): + print _(__doc__.replace('%', '%%')) + if msg: + print msg + sys.exit(code) + + + +def fix_url(mlist, *args): + try: + opts, args = getopt.getopt(args, 'u:v', ['urlhost=', 'verbose']) + except getopt.error, msg: + usage(1, msg) + + verbose = 0 + urlhost = mailhost = None + for opt, arg in opts: + if opt in ('-u', '--urlhost'): + urlhost = arg + elif opt in ('-v', '--verbose'): + verbose = 1 + + if urlhost: + web_page_url = config.DEFAULT_URL_PATTERN % urlhost + mailhost = config.VIRTUAL_HOSTS.get(urlhost.lower(), urlhost) + else: + web_page_url = config.DEFAULT_URL_PATTERN % config.DEFAULT_URL_HOST + mailhost = config.DEFAULT_EMAIL_HOST + + if verbose: + print _('Setting web_page_url to: %(web_page_url)s') + mlist.web_page_url = web_page_url + if verbose: + print _('Setting host_name to: %(mailhost)s') + mlist.host_name = mailhost + print _('Saving list') + mlist.Save() + mlist.Unlock() + + + +if __name__ == '__main__': + usage(0) diff --git a/src/mailman/attic/bin/list_admins b/src/mailman/attic/bin/list_admins new file mode 100644 index 000000000..c628a42dc --- /dev/null +++ b/src/mailman/attic/bin/list_admins @@ -0,0 +1,101 @@ +#! @PYTHON@ +# +# Copyright (C) 2001,2002 by the Free Software Foundation, Inc. +# +# This program 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 2 +# of the License, or (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +"""List all the owners of a mailing list. + +Usage: %(program)s [options] listname ... + +Where: + + --all-vhost=vhost + -v=vhost + List the owners of all the mailing lists for the given virtual host. + + --all + -a + List the owners of all the mailing lists on this system. + + --help + -h + Print this help message and exit. + +`listname' is the name of the mailing list to print the owners of. You can +have more than one named list on the command line. +""" + +import sys +import getopt + +import paths +from Mailman import MailList, Utils +from Mailman import Errors +from Mailman.i18n import _ + +COMMASPACE = ', ' + +program = sys.argv[0] + + + +def usage(code, msg=''): + if code: + fd = sys.stderr + else: + fd = sys.stdout + print >> fd, _(__doc__) + if msg: + print >> fd, msg + sys.exit(code) + + + +def main(): + try: + opts, args = getopt.getopt(sys.argv[1:], 'hv:a', + ['help', 'all-vhost=', 'all']) + except getopt.error, msg: + usage(1, msg) + + listnames = args + vhost = None + for opt, arg in opts: + if opt in ('-h', '--help'): + usage(0) + elif opt in ('-a', '--all'): + listnames = Utils.list_names() + elif opt in ('-v', '--all-vhost'): + listnames = Utils.list_names() + vhost = arg + + for listname in listnames: + try: + mlist = MailList.MailList(listname, lock=0) + except Errors.MMListError, e: + print _('No such list: %(listname)s') + continue + + if vhost and vhost <> mlist.host_name: + continue + + owners = COMMASPACE.join(mlist.owner) + print _('List: %(listname)s, \tOwners: %(owners)s') + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/attic/bin/msgfmt.py b/src/mailman/attic/bin/msgfmt.py new file mode 100644 index 000000000..8a2d4e66e --- /dev/null +++ b/src/mailman/attic/bin/msgfmt.py @@ -0,0 +1,203 @@ +#! /usr/bin/env python +# -*- coding: iso-8859-1 -*- +# Written by Martin v. Löwis + +"""Generate binary message catalog from textual translation description. + +This program converts a textual Uniforum-style message catalog (.po file) into +a binary GNU catalog (.mo file). This is essentially the same function as the +GNU msgfmt program, however, it is a simpler implementation. + +Usage: msgfmt.py [OPTIONS] filename.po + +Options: + -o file + --output-file=file + Specify the output file to write to. If omitted, output will go to a + file named filename.mo (based off the input file name). + + -h + --help + Print this message and exit. + + -V + --version + Display version information and exit. +""" + +import sys +import os +import getopt +import struct +import array + +__version__ = "1.1" + +MESSAGES = {} + + + +def usage(code, msg=''): + print >> sys.stderr, __doc__ + if msg: + print >> sys.stderr, msg + sys.exit(code) + + + +def add(id, str, fuzzy): + "Add a non-fuzzy translation to the dictionary." + global MESSAGES + if not fuzzy and str: + MESSAGES[id] = str + + + +def generate(): + "Return the generated output." + global MESSAGES + keys = MESSAGES.keys() + # the keys are sorted in the .mo file + keys.sort() + offsets = [] + ids = strs = '' + for id in keys: + # For each string, we need size and file offset. Each string is NUL + # terminated; the NUL does not count into the size. + offsets.append((len(ids), len(id), len(strs), len(MESSAGES[id]))) + ids += id + '\0' + strs += MESSAGES[id] + '\0' + output = '' + # The header is 7 32-bit unsigned integers. We don't use hash tables, so + # the keys start right after the index tables. + # translated string. + keystart = 7*4+16*len(keys) + # and the values start after the keys + valuestart = keystart + len(ids) + koffsets = [] + voffsets = [] + # The string table first has the list of keys, then the list of values. + # Each entry has first the size of the string, then the file offset. + for o1, l1, o2, l2 in offsets: + koffsets += [l1, o1+keystart] + voffsets += [l2, o2+valuestart] + offsets = koffsets + voffsets + output = struct.pack("Iiiiiii", + 0x950412deL, # Magic + 0, # Version + len(keys), # # of entries + 7*4, # start of key index + 7*4+len(keys)*8, # start of value index + 0, 0) # size and offset of hash table + output += array.array("i", offsets).tostring() + output += ids + output += strs + return output + + + +def make(filename, outfile): + ID = 1 + STR = 2 + + # Compute .mo name from .po name and arguments + if filename.endswith('.po'): + infile = filename + else: + infile = filename + '.po' + if outfile is None: + outfile = os.path.splitext(infile)[0] + '.mo' + + try: + lines = open(infile).readlines() + except IOError, msg: + print >> sys.stderr, msg + sys.exit(1) + + section = None + fuzzy = 0 + + # Parse the catalog + lno = 0 + for l in lines: + lno += 1 + # If we get a comment line after a msgstr, this is a new entry + if l[0] == '#' and section == STR: + add(msgid, msgstr, fuzzy) + section = None + fuzzy = 0 + # Record a fuzzy mark + if l[:2] == '#,' and l.find('fuzzy'): + fuzzy = 1 + # Skip comments + if l[0] == '#': + continue + # Now we are in a msgid section, output previous section + if l.startswith('msgid'): + if section == STR: + add(msgid, msgstr, fuzzy) + section = ID + l = l[5:] + msgid = msgstr = '' + # Now we are in a msgstr section + elif l.startswith('msgstr'): + section = STR + l = l[6:] + # Skip empty lines + l = l.strip() + if not l: + continue + # XXX: Does this always follow Python escape semantics? + l = eval(l) + if section == ID: + msgid += l + elif section == STR: + msgstr += l + else: + print >> sys.stderr, 'Syntax error on %s:%d' % (infile, lno), \ + 'before:' + print >> sys.stderr, l + sys.exit(1) + # Add last entry + if section == STR: + add(msgid, msgstr, fuzzy) + + # Compute output + output = generate() + + try: + open(outfile,"wb").write(output) + except IOError,msg: + print >> sys.stderr, msg + + + +def main(): + try: + opts, args = getopt.getopt(sys.argv[1:], 'hVo:', + ['help', 'version', 'output-file=']) + except getopt.error, msg: + usage(1, msg) + + outfile = None + # parse options + for opt, arg in opts: + if opt in ('-h', '--help'): + usage(0) + elif opt in ('-V', '--version'): + print >> sys.stderr, "msgfmt.py", __version__ + sys.exit(0) + elif opt in ('-o', '--output-file'): + outfile = arg + # do it + if not args: + print >> sys.stderr, 'No input file given' + print >> sys.stderr, "Try `msgfmt --help' for more information." + return + + for filename in args: + make(filename, outfile) + + +if __name__ == '__main__': + main() diff --git a/src/mailman/attic/bin/po2templ.py b/src/mailman/attic/bin/po2templ.py new file mode 100644 index 000000000..86eae96b9 --- /dev/null +++ b/src/mailman/attic/bin/po2templ.py @@ -0,0 +1,90 @@ +#! @PYTHON@ +# +# Copyright (C) 2005-2009 by the Free Software Foundation, Inc. +# +# This program 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 2 +# of the License, or (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +# Author: Tokio Kikuchi + + +"""po2templ.py + +Extract templates from language po file. + +Usage: po2templ.py languages +""" + +import re +import sys + +cre = re.compile('^#:\s*templates/en/(?P.*?):1') + + + +def do_lang(lang): + in_template = False + in_msg = False + msgstr = '' + fp = file('messages/%s/LC_MESSAGES/mailman.po' % lang) + try: + for line in fp: + m = cre.search(line) + if m: + in_template = True + in_msg = False + filename = m.group('filename') + outfilename = 'templates/%s/%s' % (lang, filename) + continue + if in_template and line.startswith('#,'): + if line.strip() == '#, fuzzy': + in_template = False + continue + if in_template and line.startswith('msgstr'): + line = line[7:] + in_msg = True + if in_msg: + if not line.strip(): + in_template = False + in_msg = False + if len(msgstr) > 1 and outfilename: + # exclude no translation ... 1 is for LF only + outfile = file(outfilename, 'w') + try: + outfile.write(msgstr) + outfile.write('\n') + finally: + outfile.close() + outfilename = '' + msgstr = '' + continue + msgstr += eval(line) + finally: + fp.close() + if len(msgstr) > 1 and outfilename: + # flush remaining msgstr (last template file) + outfile = file(outfilename, 'w') + try: + outfile.write(msgstr) + outfile.write('\n') + finally: + outfile.close() + + + +if __name__ == '__main__': + langs = sys.argv[1:] + for lang in langs: + do_lang(lang) diff --git a/src/mailman/attic/bin/pygettext.py b/src/mailman/attic/bin/pygettext.py new file mode 100644 index 000000000..84421ee8c --- /dev/null +++ b/src/mailman/attic/bin/pygettext.py @@ -0,0 +1,545 @@ +#! @PYTHON@ +# Originally written by Barry Warsaw +# +# Minimally patched to make it even more xgettext compatible +# by Peter Funk + +"""pygettext -- Python equivalent of xgettext(1) + +Many systems (Solaris, Linux, Gnu) provide extensive tools that ease the +internationalization of C programs. Most of these tools are independent of +the programming language and can be used from within Python programs. Martin +von Loewis' work[1] helps considerably in this regard. + +There's one problem though; xgettext is the program that scans source code +looking for message strings, but it groks only C (or C++). Python introduces +a few wrinkles, such as dual quoting characters, triple quoted strings, and +raw strings. xgettext understands none of this. + +Enter pygettext, which uses Python's standard tokenize module to scan Python +source code, generating .pot files identical to what GNU xgettext[2] generates +for C and C++ code. From there, the standard GNU tools can be used. + +A word about marking Python strings as candidates for translation. GNU +xgettext recognizes the following keywords: gettext, dgettext, dcgettext, and +gettext_noop. But those can be a lot of text to include all over your code. +C and C++ have a trick: they use the C preprocessor. Most internationalized C +source includes a #define for gettext() to _() so that what has to be written +in the source is much less. Thus these are both translatable strings: + + gettext("Translatable String") + _("Translatable String") + +Python of course has no preprocessor so this doesn't work so well. Thus, +pygettext searches only for _() by default, but see the -k/--keyword flag +below for how to augment this. + + [1] http://www.python.org/workshops/1997-10/proceedings/loewis.html + [2] http://www.gnu.org/software/gettext/gettext.html + +NOTE: pygettext attempts to be option and feature compatible with GNU xgettext +where ever possible. However some options are still missing or are not fully +implemented. Also, xgettext's use of command line switches with option +arguments is broken, and in these cases, pygettext just defines additional +switches. + +Usage: pygettext [options] inputfile ... + +Options: + + -a + --extract-all + Extract all strings. + + -d name + --default-domain=name + Rename the default output file from messages.pot to name.pot. + + -E + --escape + Replace non-ASCII characters with octal escape sequences. + + -D + --docstrings + Extract module, class, method, and function docstrings. These do not + need to be wrapped in _() markers, and in fact cannot be for Python to + consider them docstrings. (See also the -X option). + + -h + --help + Print this help message and exit. + + -k word + --keyword=word + Keywords to look for in addition to the default set, which are: + %(DEFAULTKEYWORDS)s + + You can have multiple -k flags on the command line. + + -K + --no-default-keywords + Disable the default set of keywords (see above). Any keywords + explicitly added with the -k/--keyword option are still recognized. + + --no-location + Do not write filename/lineno location comments. + + -n + --add-location + Write filename/lineno location comments indicating where each + extracted string is found in the source. These lines appear before + each msgid. The style of comments is controlled by the -S/--style + option. This is the default. + + -o filename + --output=filename + Rename the default output file from messages.pot to filename. If + filename is `-' then the output is sent to standard out. + + -p dir + --output-dir=dir + Output files will be placed in directory dir. + + -S stylename + --style stylename + Specify which style to use for location comments. Two styles are + supported: + + Solaris # File: filename, line: line-number + GNU #: filename:line + + The style name is case insensitive. GNU style is the default. + + -v + --verbose + Print the names of the files being processed. + + -V + --version + Print the version of pygettext and exit. + + -w columns + --width=columns + Set width of output to columns. + + -x filename + --exclude-file=filename + Specify a file that contains a list of strings that are not be + extracted from the input files. Each string to be excluded must + appear on a line by itself in the file. + + -X filename + --no-docstrings=filename + Specify a file that contains a list of files (one per line) that + should not have their docstrings extracted. This is only useful in + conjunction with the -D option above. + +If `inputfile' is -, standard input is read. +""" + +import os +import sys +import time +import getopt +import tokenize +import operator + +# for selftesting +try: + import fintl + _ = fintl.gettext +except ImportError: + def _(s): return s + +__version__ = '1.4' + +default_keywords = ['_'] +DEFAULTKEYWORDS = ', '.join(default_keywords) + +EMPTYSTRING = '' + + + +# The normal pot-file header. msgmerge and Emacs's po-mode work better if it's +# there. +pot_header = _('''\ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\\n" +"POT-Creation-Date: %(time)s\\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n" +"Last-Translator: FULL NAME \\n" +"Language-Team: LANGUAGE \\n" +"MIME-Version: 1.0\\n" +"Content-Type: text/plain; charset=CHARSET\\n" +"Content-Transfer-Encoding: ENCODING\\n" +"Generated-By: pygettext.py %(version)s\\n" + +''') + + +def usage(code, msg=''): + if code: + fd = sys.stderr + else: + fd = sys.stdout + print >> fd, _(__doc__) % globals() + if msg: + print >> fd, msg + sys.exit(code) + + + +escapes = [] + +def make_escapes(pass_iso8859): + global escapes + if pass_iso8859: + # Allow iso-8859 characters to pass through so that e.g. 'msgid + # "H[o-umlaut]he"' would result not result in 'msgid "H\366he"'. + # Otherwise we escape any character outside the 32..126 range. + mod = 128 + else: + mod = 256 + for i in range(256): + if 32 <= (i % mod) <= 126: + escapes.append(chr(i)) + else: + escapes.append("\\%03o" % i) + escapes[ord('\\')] = '\\\\' + escapes[ord('\t')] = '\\t' + escapes[ord('\r')] = '\\r' + escapes[ord('\n')] = '\\n' + escapes[ord('\"')] = '\\"' + + +def escape(s): + global escapes + s = list(s) + for i in range(len(s)): + s[i] = escapes[ord(s[i])] + return EMPTYSTRING.join(s) + + +def safe_eval(s): + # unwrap quotes, safely + return eval(s, {'__builtins__':{}}, {}) + + +def normalize(s): + # This converts the various Python string types into a format that is + # appropriate for .po files, namely much closer to C style. + lines = s.split('\n') + if len(lines) == 1: + s = '"' + escape(s) + '"' + else: + if not lines[-1]: + del lines[-1] + lines[-1] = lines[-1] + '\n' + for i in range(len(lines)): + lines[i] = escape(lines[i]) + lineterm = '\\n"\n"' + s = '""\n"' + lineterm.join(lines) + '"' + return s + + + +class TokenEater: + def __init__(self, options): + self.__options = options + self.__messages = {} + self.__state = self.__waiting + self.__data = [] + self.__lineno = -1 + self.__freshmodule = 1 + self.__curfile = None + + def __call__(self, ttype, tstring, stup, etup, line): + # dispatch +## import token +## print >> sys.stderr, 'ttype:', token.tok_name[ttype], \ +## 'tstring:', tstring + self.__state(ttype, tstring, stup[0]) + + def __waiting(self, ttype, tstring, lineno): + opts = self.__options + # Do docstring extractions, if enabled + if opts.docstrings and not opts.nodocstrings.get(self.__curfile): + # module docstring? + if self.__freshmodule: + if ttype == tokenize.STRING: + self.__addentry(safe_eval(tstring), lineno, isdocstring=1) + self.__freshmodule = 0 + elif ttype not in (tokenize.COMMENT, tokenize.NL): + self.__freshmodule = 0 + return + # class docstring? + if ttype == tokenize.NAME and tstring in ('class', 'def'): + self.__state = self.__suiteseen + return + if ttype == tokenize.NAME and tstring in opts.keywords: + self.__state = self.__keywordseen + + def __suiteseen(self, ttype, tstring, lineno): + # ignore anything until we see the colon + if ttype == tokenize.OP and tstring == ':': + self.__state = self.__suitedocstring + + def __suitedocstring(self, ttype, tstring, lineno): + # ignore any intervening noise + if ttype == tokenize.STRING: + self.__addentry(safe_eval(tstring), lineno, isdocstring=1) + self.__state = self.__waiting + elif ttype not in (tokenize.NEWLINE, tokenize.INDENT, + tokenize.COMMENT): + # there was no class docstring + self.__state = self.__waiting + + def __keywordseen(self, ttype, tstring, lineno): + if ttype == tokenize.OP and tstring == '(': + self.__data = [] + self.__lineno = lineno + self.__state = self.__openseen + else: + self.__state = self.__waiting + + def __openseen(self, ttype, tstring, lineno): + if ttype == tokenize.OP and tstring == ')': + # We've seen the last of the translatable strings. Record the + # line number of the first line of the strings and update the list + # of messages seen. Reset state for the next batch. If there + # were no strings inside _(), then just ignore this entry. + if self.__data: + self.__addentry(EMPTYSTRING.join(self.__data)) + self.__state = self.__waiting + elif ttype == tokenize.STRING: + self.__data.append(safe_eval(tstring)) + # TBD: should we warn if we seen anything else? + + def __addentry(self, msg, lineno=None, isdocstring=0): + if lineno is None: + lineno = self.__lineno + if not msg in self.__options.toexclude: + entry = (self.__curfile, lineno) + self.__messages.setdefault(msg, {})[entry] = isdocstring + + def set_filename(self, filename): + self.__curfile = filename + self.__freshmodule = 1 + + def write(self, fp): + options = self.__options + timestamp = time.ctime(time.time()) + # The time stamp in the header doesn't have the same format as that + # generated by xgettext... + print >> fp, pot_header % {'time': timestamp, 'version': __version__} + # Sort the entries. First sort each particular entry's keys, then + # sort all the entries by their first item. + reverse = {} + for k, v in self.__messages.items(): + keys = v.keys() + keys.sort() + reverse.setdefault(tuple(keys), []).append((k, v)) + rkeys = reverse.keys() + rkeys.sort() + for rkey in rkeys: + rentries = reverse[rkey] + rentries.sort() + for k, v in rentries: + isdocstring = 0 + # If the entry was gleaned out of a docstring, then add a + # comment stating so. This is to aid translators who may wish + # to skip translating some unimportant docstrings. + if reduce(operator.__add__, v.values()): + isdocstring = 1 + # k is the message string, v is a dictionary-set of (filename, + # lineno) tuples. We want to sort the entries in v first by + # file name and then by line number. + v = v.keys() + v.sort() + if not options.writelocations: + pass + # location comments are different b/w Solaris and GNU: + elif options.locationstyle == options.SOLARIS: + for filename, lineno in v: + d = {'filename': filename, 'lineno': lineno} + print >>fp, _( + '# File: %(filename)s, line: %(lineno)d') % d + elif options.locationstyle == options.GNU: + # fit as many locations on one line, as long as the + # resulting line length doesn't exceeds 'options.width' + locline = '#:' + for filename, lineno in v: + d = {'filename': filename, 'lineno': lineno} + s = _(' %(filename)s:%(lineno)d') % d + if len(locline) + len(s) <= options.width: + locline = locline + s + else: + print >> fp, locline + locline = "#:" + s + if len(locline) > 2: + print >> fp, locline + if isdocstring: + print >> fp, '#, docstring' + print >> fp, 'msgid', normalize(k) + print >> fp, 'msgstr ""\n' + + + +def main(): + global default_keywords + try: + opts, args = getopt.getopt( + sys.argv[1:], + 'ad:DEhk:Kno:p:S:Vvw:x:X:', + ['extract-all', 'default-domain=', 'escape', 'help', + 'keyword=', 'no-default-keywords', + 'add-location', 'no-location', 'output=', 'output-dir=', + 'style=', 'verbose', 'version', 'width=', 'exclude-file=', + 'docstrings', 'no-docstrings', + ]) + except getopt.error, msg: + usage(1, msg) + + # for holding option values + class Options: + # constants + GNU = 1 + SOLARIS = 2 + # defaults + extractall = 0 # FIXME: currently this option has no effect at all. + escape = 0 + keywords = [] + outpath = '' + outfile = 'messages.pot' + writelocations = 1 + locationstyle = GNU + verbose = 0 + width = 78 + excludefilename = '' + docstrings = 0 + nodocstrings = {} + + options = Options() + locations = {'gnu' : options.GNU, + 'solaris' : options.SOLARIS, + } + + # parse options + for opt, arg in opts: + if opt in ('-h', '--help'): + usage(0) + elif opt in ('-a', '--extract-all'): + options.extractall = 1 + elif opt in ('-d', '--default-domain'): + options.outfile = arg + '.pot' + elif opt in ('-E', '--escape'): + options.escape = 1 + elif opt in ('-D', '--docstrings'): + options.docstrings = 1 + elif opt in ('-k', '--keyword'): + options.keywords.append(arg) + elif opt in ('-K', '--no-default-keywords'): + default_keywords = [] + elif opt in ('-n', '--add-location'): + options.writelocations = 1 + elif opt in ('--no-location',): + options.writelocations = 0 + elif opt in ('-S', '--style'): + options.locationstyle = locations.get(arg.lower()) + if options.locationstyle is None: + usage(1, _('Invalid value for --style: %s') % arg) + elif opt in ('-o', '--output'): + options.outfile = arg + elif opt in ('-p', '--output-dir'): + options.outpath = arg + elif opt in ('-v', '--verbose'): + options.verbose = 1 + elif opt in ('-V', '--version'): + print _('pygettext.py (xgettext for Python) %s') % __version__ + sys.exit(0) + elif opt in ('-w', '--width'): + try: + options.width = int(arg) + except ValueError: + usage(1, _('--width argument must be an integer: %s') % arg) + elif opt in ('-x', '--exclude-file'): + options.excludefilename = arg + elif opt in ('-X', '--no-docstrings'): + fp = open(arg) + try: + while 1: + line = fp.readline() + if not line: + break + options.nodocstrings[line[:-1]] = 1 + finally: + fp.close() + + # calculate escapes + make_escapes(options.escape) + + # calculate all keywords + options.keywords.extend(default_keywords) + + # initialize list of strings to exclude + if options.excludefilename: + try: + fp = open(options.excludefilename) + options.toexclude = fp.readlines() + fp.close() + except IOError: + print >> sys.stderr, _( + "Can't read --exclude-file: %s") % options.excludefilename + sys.exit(1) + else: + options.toexclude = [] + + # slurp through all the files + eater = TokenEater(options) + for filename in args: + if filename == '-': + if options.verbose: + print _('Reading standard input') + fp = sys.stdin + closep = 0 + else: + if options.verbose: + print _('Working on %s') % filename + fp = open(filename) + closep = 1 + try: + eater.set_filename(filename) + try: + tokenize.tokenize(fp.readline, eater) + except tokenize.TokenError, e: + print >> sys.stderr, '%s: %s, line %d, column %d' % ( + e[0], filename, e[1][0], e[1][1]) + finally: + if closep: + fp.close() + + # write the output + if options.outfile == '-': + fp = sys.stdout + closep = 0 + else: + if options.outpath: + options.outfile = os.path.join(options.outpath, options.outfile) + fp = open(options.outfile, 'w') + closep = 1 + try: + eater.write(fp) + finally: + if closep: + fp.close() + + +if __name__ == '__main__': + main() + # some more test strings + _(u'a unicode string') diff --git a/src/mailman/attic/bin/remove_members b/src/mailman/attic/bin/remove_members new file mode 100755 index 000000000..a7b4ebb47 --- /dev/null +++ b/src/mailman/attic/bin/remove_members @@ -0,0 +1,186 @@ +#! @PYTHON@ +# +# Copyright (C) 1998-2005 by the Free Software Foundation, Inc. +# +# This program 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 2 +# of the License, or (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Remove members from a list. + +Usage: + remove_members [options] [listname] [addr1 ...] + +Options: + + --file=file + -f file + Remove member addresses found in the given file. If file is + `-', read stdin. + + --all + -a + Remove all members of the mailing list. + (mutually exclusive with --fromall) + + --fromall + Removes the given addresses from all the lists on this system + regardless of virtual domains if you have any. This option cannot be + used -a/--all. Also, you should not specify a listname when using + this option. + + --nouserack + -n + Don't send the user acknowledgements. If not specified, the list + default value is used. + + --noadminack + -N + Don't send the admin acknowledgements. If not specified, the list + default value is used. + + --help + -h + Print this help message and exit. + + listname is the name of the mailing list to use. + + addr1 ... are additional addresses to remove. +""" + +import sys +import getopt + +import paths +from Mailman import MailList +from Mailman import Utils +from Mailman import Errors +from Mailman.i18n import _ + +try: + True, False +except NameError: + True = 1 + False = 0 + + + +def usage(code, msg=''): + if code: + fd = sys.stderr + else: + fd = sys.stdout + print >> fd, _(__doc__) + if msg: + print >> fd, msg + sys.exit(code) + + +def ReadFile(filename): + lines = [] + if filename == "-": + fp = sys.stdin + closep = False + else: + fp = open(filename) + closep = True + lines = filter(None, [line.strip() for line in fp.readlines()]) + if closep: + fp.close() + return lines + + + +def main(): + try: + opts, args = getopt.getopt( + sys.argv[1:], 'naf:hN', + ['all', 'fromall', 'file=', 'help', 'nouserack', 'noadminack']) + except getopt.error, msg: + usage(1, msg) + + filename = None + all = False + alllists = False + # None means use list default + userack = None + admin_notif = None + + for opt, arg in opts: + if opt in ('-h', '--help'): + usage(0) + elif opt in ('-f', '--file'): + filename = arg + elif opt in ('-a', '--all'): + all = True + elif opt == '--fromall': + alllists = True + elif opt in ('-n', '--nouserack'): + userack = False + elif opt in ('-N', '--noadminack'): + admin_notif = False + + if len(args) < 1 and not (filename and alllists): + usage(1) + + # You probably don't want to delete all the users of all the lists -- Marc + if all and alllists: + usage(1) + + if alllists: + addresses = args + else: + listname = args[0].lower().strip() + addresses = args[1:] + + if alllists: + listnames = Utils.list_names() + else: + listnames = [listname] + + if filename: + try: + addresses = addresses + ReadFile(filename) + except IOError: + print _('Could not open file for reading: %(filename)s.') + + for listname in listnames: + try: + # open locked + mlist = MailList.MailList(listname) + except Errors.MMListError: + print _('Error opening list %(listname)s... skipping.') + continue + + if all: + addresses = mlist.getMembers() + + try: + for addr in addresses: + if not mlist.isMember(addr): + if not alllists: + print _('No such member: %(addr)s') + continue + mlist.ApprovedDeleteMember(addr, 'bin/remove_members', + admin_notif, userack) + if alllists: + print _("User `%(addr)s' removed from list: %(listname)s.") + mlist.Save() + finally: + mlist.Unlock() + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/attic/bin/reset_pw.py b/src/mailman/attic/bin/reset_pw.py new file mode 100644 index 000000000..453c8b849 --- /dev/null +++ b/src/mailman/attic/bin/reset_pw.py @@ -0,0 +1,83 @@ +#! @PYTHON@ +# +# Copyright (C) 2004-2009 by the Free Software Foundation, Inc. +# +# This program 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 2 +# of the License, or (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +# Inspired by Florian Weimer. + +"""Reset the passwords for members of a mailing list. + +This script resets all the passwords of a mailing list's members. It can also +be used to reset the lists of all members of all mailing lists, but it is your +responsibility to let the users know that their passwords have been changed. + +This script is intended to be run as a bin/withlist script, i.e. + +% bin/withlist -l -r reset_pw listname [options] + +Options: + -v / --verbose + Print what the script is doing. +""" + +import sys +import getopt + +import paths +from Mailman import Utils +from Mailman.i18n import _ + + + +def usage(code, msg=''): + if code: + fd = sys.stderr + else: + fd = sys.stdout + print >> fd, _(__doc__.replace('%', '%%')) + if msg: + print >> fd, msg + sys.exit(code) + + + +def reset_pw(mlist, *args): + try: + opts, args = getopt.getopt(args, 'v', ['verbose']) + except getopt.error, msg: + usage(1, msg) + + verbose = False + for opt, args in opts: + if opt in ('-v', '--verbose'): + verbose = True + + listname = mlist.internal_name() + if verbose: + print _('Changing passwords for list: %(listname)s') + + for member in mlist.getMembers(): + randompw = Utils.MakeRandomPassword() + mlist.setMemberPassword(member, randompw) + if verbose: + print _('New password for member %(member)40s: %(randompw)s') + + mlist.Save() + + + +if __name__ == '__main__': + usage(0) diff --git a/src/mailman/attic/bin/sync_members b/src/mailman/attic/bin/sync_members new file mode 100755 index 000000000..4a21624c1 --- /dev/null +++ b/src/mailman/attic/bin/sync_members @@ -0,0 +1,286 @@ +#! @PYTHON@ +# +# Copyright (C) 1998-2003 by the Free Software Foundation, Inc. +# +# This program 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 2 +# of the License, or (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +"""Synchronize a mailing list's membership with a flat file. + +This script is useful if you have a Mailman mailing list and a sendmail +:include: style list of addresses (also as is used in Majordomo). For every +address in the file that does not appear in the mailing list, the address is +added. For every address in the mailing list that does not appear in the +file, the address is removed. Other options control what happens when an +address is added or removed. + +Usage: %(PROGRAM)s [options] -f file listname + +Where `options' are: + + --no-change + -n + Don't actually make the changes. Instead, print out what would be + done to the list. + + --welcome-msg[=] + -w[=] + Sets whether or not to send the newly added members a welcome + message, overriding whatever the list's `send_welcome_msg' setting + is. With -w=yes or -w, the welcome message is sent. With -w=no, no + message is sent. + + --goodbye-msg[=] + -g[=] + Sets whether or not to send the goodbye message to removed members, + overriding whatever the list's `send_goodbye_msg' setting is. With + -g=yes or -g, the goodbye message is sent. With -g=no, no message is + sent. + + --digest[=] + -d[=] + Selects whether to make newly added members receive messages in + digests. With -d=yes or -d, they become digest members. With -d=no + (or if no -d option given) they are added as regular members. + + --notifyadmin[=] + -a[=] + Specifies whether the admin should be notified for each subscription + or unsubscription. If you're adding a lot of addresses, you + definitely want to turn this off! With -a=yes or -a, the admin is + notified. With -a=no, the admin is not notified. With no -a option, + the default for the list is used. + + --file + -f + This option is required. It specifies the flat file to synchronize + against. Email addresses must appear one per line. If filename is + `-' then stdin is used. + + --help + -h + Print this message. + + listname + Required. This specifies the list to synchronize. +""" + +import sys + +import paths +# Import this /after/ paths so that the sys.path is properly hacked +import email.Utils + +from Mailman import MailList +from Mailman import Errors +from Mailman import Utils +from Mailman.UserDesc import UserDesc +from Mailman.i18n import _ + + + +PROGRAM = sys.argv[0] + +def usage(code, msg=''): + if code: + fd = sys.stderr + else: + fd = sys.stdout + print >> fd, _(__doc__) + if msg: + print >> fd, msg + sys.exit(code) + + + +def yesno(opt): + i = opt.find('=') + yesno = opt[i+1:].lower() + if yesno in ('y', 'yes'): + return 1 + elif yesno in ('n', 'no'): + return 0 + else: + usage(1, _('Bad choice: %(yesno)s')) + # no return + + +def main(): + dryrun = 0 + digest = 0 + welcome = None + goodbye = None + filename = None + listname = None + notifyadmin = None + + # TBD: can't use getopt with this command line syntax, which is broken and + # should be changed to be getopt compatible. + i = 1 + while i < len(sys.argv): + opt = sys.argv[i] + if opt in ('-h', '--help'): + usage(0) + elif opt in ('-n', '--no-change'): + dryrun = 1 + i += 1 + print _('Dry run mode') + elif opt in ('-d', '--digest'): + digest = 1 + i += 1 + elif opt.startswith('-d=') or opt.startswith('--digest='): + digest = yesno(opt) + i += 1 + elif opt in ('-w', '--welcome-msg'): + welcome = 1 + i += 1 + elif opt.startswith('-w=') or opt.startswith('--welcome-msg='): + welcome = yesno(opt) + i += 1 + elif opt in ('-g', '--goodbye-msg'): + goodbye = 1 + i += 1 + elif opt.startswith('-g=') or opt.startswith('--goodbye-msg='): + goodbye = yesno(opt) + i += 1 + elif opt in ('-f', '--file'): + if filename is not None: + usage(1, _('Only one -f switch allowed')) + try: + filename = sys.argv[i+1] + except IndexError: + usage(1, _('No argument to -f given')) + i += 2 + elif opt in ('-a', '--notifyadmin'): + notifyadmin = 1 + i += 1 + elif opt.startswith('-a=') or opt.startswith('--notifyadmin='): + notifyadmin = yesno(opt) + i += 1 + elif opt[0] == '-': + usage(1, _('Illegal option: %(opt)s')) + else: + try: + listname = sys.argv[i].lower() + i += 1 + except IndexError: + usage(1, _('No listname given')) + break + + if listname is None or filename is None: + usage(1, _('Must have a listname and a filename')) + + # read the list of addresses to sync to from the file + if filename == '-': + filemembers = sys.stdin.readlines() + else: + try: + fp = open(filename) + except IOError, (code, msg): + usage(1, _('Cannot read address file: %(filename)s: %(msg)s')) + try: + filemembers = fp.readlines() + finally: + fp.close() + + # strip out lines we don't care about, they are comments (# in first + # non-whitespace) or are blank + for i in range(len(filemembers)-1, -1, -1): + addr = filemembers[i].strip() + if addr == '' or addr[:1] == '#': + del filemembers[i] + print _('Ignore : %(addr)30s') + + # first filter out any invalid addresses + filemembers = email.Utils.getaddresses(filemembers) + invalid = 0 + for name, addr in filemembers: + try: + Utils.ValidateEmail(addr) + except Errors.EmailAddressError: + print _('Invalid : %(addr)30s') + invalid = 1 + if invalid: + print _('You must fix the preceding invalid addresses first.') + sys.exit(1) + + # get the locked list object + try: + mlist = MailList.MailList(listname) + except Errors.MMListError, e: + print _('No such list: %(listname)s') + sys.exit(1) + + try: + # Get the list of addresses currently subscribed + addrs = {} + needsadding = {} + matches = {} + for addr in mlist.getMemberCPAddresses(mlist.getMembers()): + addrs[addr.lower()] = addr + + for name, addr in filemembers: + # Any address found in the file that is also in the list can be + # ignored. If not found in the list, it must be added later. + laddr = addr.lower() + if addrs.has_key(laddr): + del addrs[laddr] + matches[laddr] = 1 + elif not matches.has_key(laddr): + needsadding[laddr] = (name, addr) + + if not needsadding and not addrs: + print _('Nothing to do.') + sys.exit(0) + + enc = sys.getdefaultencoding() + # addrs contains now all the addresses that need removing + for laddr, (name, addr) in needsadding.items(): + pw = Utils.MakeRandomPassword() + # should not already be subscribed, otherwise our test above is + # broken. Bogosity is if the address is listed in the file more + # than once. Second and subsequent ones trigger an + # MMAlreadyAMember error. Just catch it and go on. + userdesc = UserDesc(addr, name, pw, digest) + try: + if not dryrun: + mlist.ApprovedAddMember(userdesc, welcome, notifyadmin) + s = email.Utils.formataddr((name, addr)).encode(enc, 'replace') + print _('Added : %(s)s') + except Errors.MMAlreadyAMember: + pass + + for laddr, addr in addrs.items(): + # Should be a member, otherwise our test above is broken + name = mlist.getMemberName(laddr) or '' + if not dryrun: + try: + mlist.ApprovedDeleteMember(addr, admin_notif=notifyadmin, + userack=goodbye) + except Errors.NotAMemberError: + # This can happen if the address is illegal (i.e. can't be + # parsed by email.Utils.parseaddr()) but for legacy + # reasons is in the database. Use a lower level remove to + # get rid of this member's entry + mlist.removeMember(addr) + s = email.Utils.formataddr((name, addr)).encode(enc, 'replace') + print _('Removed: %(s)s') + + mlist.Save() + finally: + mlist.Unlock() + + +if __name__ == '__main__': + main() diff --git a/src/mailman/attic/bin/templ2pot.py b/src/mailman/attic/bin/templ2pot.py new file mode 100644 index 000000000..0253cc2cd --- /dev/null +++ b/src/mailman/attic/bin/templ2pot.py @@ -0,0 +1,120 @@ +#! @PYTHON@ +# Code stolen from pygettext.py +# by Tokio Kikuchi + +"""templ2pot.py -- convert mailman template (en) to pot format. + +Usage: templ2pot.py inputfile ... + +Options: + + -h, --help + +Inputfiles are english templates. Outputs are written to stdout. +""" + +import sys +import getopt + + + +try: + import paths + from Mailman.i18n import _ +except ImportError: + def _(s): return s + +EMPTYSTRING = '' + + + +def usage(code, msg=''): + if code: + fd = sys.stderr + else: + fd = sys.stdout + print >> fd, _(__doc__) % globals() + if msg: + print >> fd, msg + sys.exit(code) + + + +escapes = [] + +def make_escapes(pass_iso8859): + global escapes + if pass_iso8859: + # Allow iso-8859 characters to pass through so that e.g. 'msgid + # "H[o-umlaut]he"' would result not result in 'msgid "H\366he"'. + # Otherwise we escape any character outside the 32..126 range. + mod = 128 + else: + mod = 256 + for i in range(256): + if 32 <= (i % mod) <= 126: + escapes.append(chr(i)) + else: + escapes.append("\\%03o" % i) + escapes[ord('\\')] = '\\\\' + escapes[ord('\t')] = '\\t' + escapes[ord('\r')] = '\\r' + escapes[ord('\n')] = '\\n' + escapes[ord('\"')] = '\\"' + + +def escape(s): + global escapes + s = list(s) + for i in range(len(s)): + s[i] = escapes[ord(s[i])] + return EMPTYSTRING.join(s) + + +def normalize(s): + # This converts the various Python string types into a format that is + # appropriate for .po files, namely much closer to C style. + lines = s.splitlines() + if len(lines) == 1: + s = '"' + escape(s) + '"' + else: + if not lines[-1]: + del lines[-1] + lines[-1] = lines[-1] + '\n' + for i in range(len(lines)): + lines[i] = escape(lines[i]) + lineterm = '\\n"\n"' + s = '""\n"' + lineterm.join(lines) + '"' + return s + + + +def main(): + try: + opts, args = getopt.getopt( + sys.argv[1:], + 'h', + ['help',] + ) + except getopt.error, msg: + usage(1, msg) + + # parse options + for opt, arg in opts: + if opt in ('-h', '--help'): + usage(0) + + # calculate escapes + make_escapes(0) + + for filename in args: + print '#: %s:1' % filename + s = file(filename).read() + print '#, template' + print 'msgid', normalize(s) + print 'msgstr ""\n' + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/attic/bin/transcheck b/src/mailman/attic/bin/transcheck new file mode 100755 index 000000000..73910e771 --- /dev/null +++ b/src/mailman/attic/bin/transcheck @@ -0,0 +1,412 @@ +#! @PYTHON@ +# +# transcheck - (c) 2002 by Simone Piunno +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of the version 2.0 of the GNU General Public License as +# published by the Free Software Foundation. +# +# This program 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 this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +""" +Check a given Mailman translation, making sure that variables and +tags referenced in translation are the same variables and tags in +the original templates and catalog. + +Usage: + +cd $MAILMAN_DIR +%(program)s [-q] + +Where is your country code (e.g. 'it' for Italy) and -q is +to ask for a brief summary. +""" + +import sys +import re +import os +import getopt + +import paths +from Mailman.i18n import _ + +program = sys.argv[0] + + + +def usage(code, msg=''): + if code: + fd = sys.stderr + else: + fd = sys.stdout + print >> fd, _(__doc__) + if msg: + print >> fd, msg + sys.exit(code) + + + +class TransChecker: + "check a translation comparing with the original string" + def __init__(self, regexp, escaped=None): + self.dict = {} + self.errs = [] + self.regexp = re.compile(regexp) + self.escaped = None + if escaped: + self.escaped = re.compile(escaped) + + def checkin(self, string): + "scan a string from the original file" + for key in self.regexp.findall(string): + if self.escaped and self.escaped.match(key): + continue + if self.dict.has_key(key): + self.dict[key] += 1 + else: + self.dict[key] = 1 + + def checkout(self, string): + "scan a translated string" + for key in self.regexp.findall(string): + if self.escaped and self.escaped.match(key): + continue + if self.dict.has_key(key): + self.dict[key] -= 1 + else: + self.errs.append( + "%(key)s was not found" % + { 'key' : key } + ) + + def computeErrors(self): + "check for differences between checked in and checked out" + for key in self.dict.keys(): + if self.dict[key] < 0: + self.errs.append( + "Too much %(key)s" % + { 'key' : key } + ) + if self.dict[key] > 0: + self.errs.append( + "Too few %(key)s" % + { 'key' : key } + ) + return self.errs + + def status(self): + if self.errs: + return "FAILED" + else: + return "OK" + + def errorsAsString(self): + msg = "" + for err in self.errs: + msg += " - %(err)s" % { 'err': err } + return msg + + def reset(self): + self.dict = {} + self.errs = [] + + + +class POParser: + "parse a .po file extracting msgids and msgstrs" + def __init__(self, filename=""): + self.status = 0 + self.files = [] + self.msgid = "" + self.msgstr = "" + self.line = 1 + self.f = None + self.esc = { "n": "\n", "r": "\r", "t": "\t" } + if filename: + self.f = open(filename) + + def open(self, filename): + self.f = open(filename) + + def close(self): + self.f.close() + + def parse(self): + """States table for the finite-states-machine parser: + 0 idle + 1 filename-or-comment + 2 msgid + 3 msgstr + 4 end + """ + # each time we can safely re-initialize those vars + self.files = [] + self.msgid = "" + self.msgstr = "" + + + # can't continue if status == 4, this is a dead status + if self.status == 4: + return 0 + + while 1: + # continue scanning, char-by-char + c = self.f.read(1) + if not c: + # EOF -> maybe we have a msgstr to save? + self.status = 4 + if self.msgstr: + return 1 + else: + return 0 + + # keep the line count up-to-date + if c == "\n": + self.line += 1 + + # a pound was detected the previous char... + if self.status == 1: + if c == ":": + # was a line of filenames + row = self.f.readline() + self.files += row.split() + self.line += 1 + elif c == "\n": + # was a single pount on the line + pass + else: + # was a comment... discard + self.f.readline() + self.line += 1 + # in every case, we switch to idle status + self.status = 0; + continue + + # in idle status we search for a '#' or for a 'm' + if self.status == 0: + if c == "#": + # this could be a comment or a filename + self.status = 1; + continue + elif c == "m": + # this should be a msgid start... + s = self.f.read(4) + assert s == "sgid" + # so now we search for a '"' + self.status = 2 + continue + # in idle only those other chars are possibile + assert c in [ "\n", " ", "\t" ] + + # searching for the msgid string + if self.status == 2: + if c == "\n": + # a double LF is not possible here + c = self.f.read(1) + assert c != "\n" + if c == "\"": + # ok, this is the start of the string, + # now search for the end + while 1: + c = self.f.read(1) + if not c: + # EOF, bailout + self.status = 4 + return 0 + if c == "\\": + # a quoted char... + c = self.f.read(1) + if self.esc.has_key(c): + self.msgid += self.esc[c] + else: + self.msgid += c + continue + if c == "\"": + # end of string found + break + # a normal char, add it + self.msgid += c + if c == "m": + # this should be a msgstr identifier + s = self.f.read(5) + assert s == "sgstr" + # ok, now search for the msgstr string + self.status = 3 + + # searching for the msgstr string + if self.status == 3: + if c == "\n": + # a double LF is the end of the msgstr! + c = self.f.read(1) + if c == "\n": + # ok, time to go idle and return + self.status = 0 + self.line += 1 + return 1 + if c == "\"": + # start of string found + while 1: + c = self.f.read(1) + if not c: + # EOF, bail out + self.status = 4 + return 1 + if c == "\\": + # a quoted char... + c = self.f.read(1) + if self.esc.has_key(c): + self.msgid += self.esc[c] + else: + self.msgid += c + continue + if c == "\"": + # end of string + break + # a normal char, add it + self.msgstr += c + + + + +def check_file(translatedFile, originalFile, html=0, quiet=0): + """check a translated template against the original one + search also tags if html is not zero""" + + if html: + c = TransChecker("(%%|%\([^)]+\)[0-9]*[sd]|]+>)", "^%%$") + else: + c = TransChecker("(%%|%\([^)]+\)[0-9]*[sd])", "^%%$") + + try: + f = open(originalFile) + except IOError: + if not quiet: + print " - Can'open original file " + originalFile + return 1 + + while 1: + line = f.readline() + if not line: break + c.checkin(line) + + f.close() + + try: + f = open(translatedFile) + except IOError: + if not quiet: + print " - Can'open translated file " + translatedFile + return 1 + + while 1: + line = f.readline() + if not line: break + c.checkout(line) + + f.close() + + n = 0 + msg = "" + for desc in c.computeErrors(): + n +=1 + if not quiet: + print " - %(desc)s" % { 'desc': desc } + return n + + + +def check_po(file, quiet=0): + "scan the po file comparing msgids with msgstrs" + n = 0 + p = POParser(file) + c = TransChecker("(%%|%\([^)]+\)[0-9]*[sdu]|%[0-9]*[sdu])", "^%%$") + while p.parse(): + if p.msgstr: + c.reset() + c.checkin(p.msgid) + c.checkout(p.msgstr) + for desc in c.computeErrors(): + n += 1 + if not quiet: + print " - near line %(line)d %(file)s: %(desc)s" % { + 'line': p.line, + 'file': p.files, + 'desc': desc + } + p.close() + return n + + +def main(): + try: + opts, args = getopt.getopt(sys.argv[1:], 'qh', ['quiet', 'help']) + except getopt.error, msg: + usage(1, msg) + + quiet = 0 + for opt, arg in opts: + if opt in ('-h', '--help'): + usage(0) + elif opt in ('-q', '--quiet'): + quiet = 1 + + if len(args) <> 1: + usage(1) + + lang = args[0] + + isHtml = re.compile("\.html$"); + isTxt = re.compile("\.txt$"); + + numerrors = 0 + numfiles = 0 + try: + files = os.listdir("templates/" + lang + "/") + except: + print "can't open templates/%s/" % lang + for file in files: + fileEN = "templates/en/" + file + fileIT = "templates/" + lang + "/" + file + errlist = [] + if isHtml.search(file): + if not quiet: + print "HTML checking " + fileIT + "... " + n = check_file(fileIT, fileEN, html=1, quiet=quiet) + if n: + numerrors += n + numfiles += 1 + elif isTxt.search(file): + if not quiet: + print "TXT checking " + fileIT + "... " + n = check_file(fileIT, fileEN, html=0, quiet=quiet) + if n: + numerrors += n + numfiles += 1 + + else: + continue + + file = "messages/" + lang + "/LC_MESSAGES/mailman.po" + if not quiet: + print "PO checking " + file + "... " + n = check_po(file, quiet=quiet) + if n: + numerrors += n + numfiles += 1 + + if quiet: + print "%(errs)u warnings in %(files)u files" % { + 'errs': numerrors, + 'files': numfiles + } + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/__init__.py b/src/mailman/bin/__init__.py new file mode 100644 index 000000000..d61693c5e --- /dev/null +++ b/src/mailman/bin/__init__.py @@ -0,0 +1,61 @@ +# Copyright (C) 2007-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 . + +__all__ = [ + 'add_members', + 'arch', + 'bounces', + 'bumpdigests', + 'check_perms', + 'checkdbs', + 'cleanarch', + 'config_list', + 'confirm', + 'create_list', + 'disabled', + 'dumpdb', + 'export', + 'find_member', + 'gate_news', + 'genaliases', + 'import', + 'inject', + 'join', + 'leave', + 'list_lists', + 'list_members', + 'list_owners', + 'mailmanctl', + 'make_instance', + 'master', + 'mmsitepass', + 'nightly_gzip', + 'owner', + 'post', + 'qrunner', + 'remove_list', + 'request', + 'senddigests', + 'set_members', + 'show_config', + 'show_qfiles', + 'testall', + 'unshunt', + 'update', + 'version', + 'withlist', + ] diff --git a/src/mailman/bin/add_members.py b/src/mailman/bin/add_members.py new file mode 100644 index 000000000..9c87f4af9 --- /dev/null +++ b/src/mailman/bin/add_members.py @@ -0,0 +1,186 @@ +# 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 . + +import os +import sys +import codecs + +from cStringIO import StringIO +from email.utils import parseaddr + +from mailman import Message +from mailman import Utils +from mailman import i18n +from mailman.app.membership import add_member +from mailman.config import config +from mailman.core import errors +from mailman.interfaces.member import AlreadySubscribedError, DeliveryMode +from mailman.options import SingleMailingListOptions + +_ = i18n._ + + + +class ScriptOptions(SingleMailingListOptions): + usage=_("""\ +%prog [options] + +Add members to a list. 'listname' is the name of the Mailman list you are +adding members to; the list must already exist. + +You must supply at least one of -r and -d options. At most one of the +files can be '-'. +""") + + def add_options(self): + super(ScriptOptions, self).add_options() + self.parser.add_option( + '-r', '--regular-members-file', + type='string', dest='regular', help=_("""\ +A file containing addresses of the members to be added, one address per line. +This list of people become non-digest members. If file is '-', read addresses +from stdin.""")) + self.parser.add_option( + '-d', '--digest-members-file', + type='string', dest='digest', help=_("""\ +Similar to -r, but these people become digest members.""")) + self.parser.add_option( + '-w', '--welcome-msg', + type='yesno', metavar='', help=_("""\ +Set whether or not to send the list members a welcome message, overriding +whatever the list's 'send_welcome_msg' setting is.""")) + self.parser.add_option( + '-a', '--admin-notify', + type='yesno', metavar='', help=_("""\ +Set whether or not to send the list administrators a notification on the +success/failure of these subscriptions, overriding whatever the list's +'admin_notify_mchanges' setting is.""")) + + def sanity_check(self): + if not self.options.listname: + self.parser.error(_('Missing listname')) + if len(self.arguments) > 0: + self.parser.print_error(_('Unexpected arguments')) + if self.options.regular is None and self.options.digest is None: + parser.error(_('At least one of -r or -d is required')) + if self.options.regular == '-' and self.options.digest == '-': + parser.error(_("-r and -d cannot both be '-'")) + + + +def readfile(filename): + if filename == '-': + fp = sys.stdin + else: + # XXX Need to specify other encodings. + fp = codecs.open(filename, encoding='utf-8') + # Strip all the lines of whitespace and discard blank lines + try: + return set(line.strip() for line in fp if line) + finally: + if fp is not sys.stdin: + fp.close() + + + +class Tee: + def __init__(self, outfp): + self._outfp = outfp + + def write(self, msg): + sys.stdout.write(msg) + self._outfp.write(msg) + + + +def addall(mlist, subscribers, delivery_mode, ack, admin_notify, outfp): + tee = Tee(outfp) + for subscriber in subscribers: + try: + fullname, address = parseaddr(subscriber) + # Watch out for the empty 8-bit string. + if not fullname: + fullname = u'' + password = Utils.MakeRandomPassword() + add_member(mlist, address, fullname, password, delivery_mode, + unicode(config.mailman.default_language)) + # XXX Support ack and admin_notify + except AlreadySubscribedError: + print >> tee, _('Already a member: $subscriber') + except errors.InvalidEmailAddress: + if not address: + print >> tee, _('Bad/Invalid email address: blank line') + else: + print >> tee, _('Bad/Invalid email address: $subscriber') + else: + print >> tee, _('Subscribing: $subscriber') + + + +def main(): + options = ScriptOptions() + options.initialize() + + fqdn_listname = options.options.listname + mlist = config.db.list_manager.get(fqdn_listname) + if mlist is None: + parser.error(_('No such list: $fqdn_listname')) + + # Set up defaults. + send_welcome_msg = (options.options.welcome_msg + if options.options.welcome_msg is not None + else mlist.send_welcome_msg) + admin_notify = (options.options.admin_notify + if options.options.admin_notify is not None + else mlist.admin_notify) + + with i18n.using_language(mlist.preferred_language): + if options.options.digest: + dmembers = readfile(options.options.digest) + else: + dmembers = set() + if options.options.regular: + nmembers = readfile(options.options.regular) + else: + nmembers = set() + + if not dmembers and not nmembers: + print _('Nothing to do.') + sys.exit(0) + + outfp = StringIO() + if nmembers: + addall(mlist, nmembers, DeliveryMode.regular, + send_welcome_msg, admin_notify, outfp) + + if dmembers: + addall(mlist, dmembers, DeliveryMode.mime_digests, + send_welcome_msg, admin_notify, outfp) + + config.db.commit() + + if admin_notify: + subject = _('$mlist.real_name subscription notification') + msg = Message.UserNotification( + mlist.owner, mlist.no_reply_address, subject, + outfp.getvalue(), mlist.preferred_language) + msg.send(mlist) + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/arch.py b/src/mailman/bin/arch.py new file mode 100644 index 000000000..a27fa8d7f --- /dev/null +++ b/src/mailman/bin/arch.py @@ -0,0 +1,151 @@ +# 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 . + +import os +import sys +import errno +import shutil +import optparse + +from locknix.lockfile import Lock + +from mailman import i18n +from mailman.Archiver.HyperArch import HyperArchive +from mailman.Defaults import hours +from mailman.configuration import config +from mailman.initialize import initialize +from mailman.version import MAILMAN_VERSION + +_ = i18n._ + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%%prog [options] listname [mbox] + +Rebuild a list's archive. + +Use this command to rebuild the archives for a mailing list. You may want to +do this if you edit some messages in an archive, or remove some messages from +an archive. + +Where 'mbox' is the path to a list's complete mbox archive. Usually this will +be some path in the archives/private directory. For example: + +% bin/arch mylist archives/private/mylist.mbox/mylist.mbox + +'mbox' is optional. If it is missing, it is calculated from the listname. +""")) + parser.add_option('-q', '--quiet', + dest='verbose', default=True, action='store_false', + help=_('Make the archiver output less verbose')) + parser.add_option('--wipe', + default=False, action='store_true', + help=_("""\ +First wipe out the original archive before regenerating. You usually want to +specify this argument unless you're generating the archive in chunks.""")) + parser.add_option('-s', '--start', + default=None, type='int', metavar='N', + help=_("""\ +Start indexing at article N, where article 0 is the first in the mbox. +Defaults to 0.""")) + parser.add_option('-e', '--end', + default=None, type='int', metavar='M', + help=_("""\ +End indexing at article M. This script is not very efficient with respect to +memory management, and for large archives, it may not be possible to index the +mbox entirely. For that reason, you can specify the start and end article +numbers.""")) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + if len(args) < 1: + parser.print_help() + print >> sys.stderr, _('listname is required') + sys.exit(1) + if len(args) > 2: + parser.print_help() + print >> sys.stderr, _('Unexpected arguments') + sys.exit(1) + return parser, opts, args + + + +def main(): + parser, opts, args = parseargs() + initialize(opts.config) + + i18n.set_language(config.DEFAULT_SERVER_LANGUAGE) + + listname = args[0].lower().strip() + if len(args) < 2: + mbox = None + else: + mbox = args[1] + + # Open the mailing list object + mlist = config.list_manager.get(listname) + if mlist is None: + parser.error(_('No such list: $listname')) + if mbox is None: + mbox = mlist.ArchiveFileName() + + i18n.set_language(mlist.preferred_language) + # Lay claim to the archive's lock file. This is so no other post can + # mess up the archive while we're processing it. Try to pick a + # suitably long period of time for the lock lifetime even though we + # really don't know how long it will take. + # + # XXX processUnixMailbox() should refresh the lock. + lock_path = os.path.join(mlist.data_path, '.archiver.lck') + with Lock(lock_path, lifetime=int(hours(3))): + # Try to open mbox before wiping old archive. + try: + fp = open(mbox) + except IOError, e: + if e.errno == errno.ENOENT: + print >> sys.stderr, _('Cannot open mbox file: $mbox') + else: + print >> sys.stderr, e + sys.exit(1) + # Maybe wipe the old archives + if opts.wipe: + if mlist.scrub_nondigest: + # TK: save the attachments dir because they are not in mbox + saved = False + atchdir = os.path.join(mlist.archive_dir(), 'attachments') + savedir = os.path.join(mlist.archive_dir() + '.mbox', + 'attachments') + try: + os.rename(atchdir, savedir) + saved = True + except OSError, e: + if e.errno <> errno.ENOENT: + raise + shutil.rmtree(mlist.archive_dir()) + if mlist.scrub_nondigest and saved: + os.renames(savedir, atchdir) + + archiver = HyperArchive(mlist) + archiver.VERBOSE = opts.verbose + try: + archiver.processUnixMailbox(fp, opts.start, opts.end) + finally: + archiver.close() + fp.close() diff --git a/src/mailman/bin/bumpdigests.py b/src/mailman/bin/bumpdigests.py new file mode 100644 index 000000000..b1ed37a21 --- /dev/null +++ b/src/mailman/bin/bumpdigests.py @@ -0,0 +1,74 @@ +# 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 . + +import sys +import optparse + +from mailman import errors +from mailman import MailList +from mailman.configuration import config +from mailman.i18n import _ +from mailman.version import MAILMAN_VERSION + +# Work around known problems with some RedHat cron daemons +import signal +signal.signal(signal.SIGCHLD, signal.SIG_DFL) + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] [listname ...] + +Increment the digest volume number and reset the digest number to one. All +the lists named on the command line are bumped. If no list names are given, +all lists are bumped.""")) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + return opts, args, parser + + + +def main(): + opts, args, parser = parseargs() + config.load(opts.config) + + listnames = set(args or config.list_manager.names) + if not listnames: + print _('Nothing to do.') + sys.exit(0) + + for listname in listnames: + try: + # Be sure the list is locked + mlist = MailList.MailList(listname) + except errors.MMListError, e: + parser.print_help() + print >> sys.stderr, _('No such list: $listname') + sys.exit(1) + try: + mlist.bump_digest_volume() + finally: + mlist.Save() + mlist.Unlock() + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/check_perms.py b/src/mailman/bin/check_perms.py new file mode 100644 index 000000000..4b75aa9f6 --- /dev/null +++ b/src/mailman/bin/check_perms.py @@ -0,0 +1,408 @@ +# 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 . + +import os +import sys +import pwd +import grp +import errno +import optparse + +from stat import * + +from mailman.configuration import config +from mailman.i18n import _ +from mailman.version import MAILMAN_VERSION + + +# XXX Need to check the archives/private/*/database/* files + + + +class State: + FIX = False + VERBOSE = False + ERRORS = 0 + +STATE = State() + +DIRPERMS = S_ISGID | S_IRWXU | S_IRWXG | S_IROTH | S_IXOTH +QFILEPERMS = S_ISGID | S_IRWXU | S_IRWXG +PYFILEPERMS = S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH +ARTICLEFILEPERMS = S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP +MBOXPERMS = S_IRGRP | S_IWGRP | S_IRUSR | S_IWUSR +PRIVATEPERMS = QFILEPERMS + + + +def statmode(path): + return os.stat(path).st_mode + + +def statgidmode(path): + stat = os.stat(path) + return stat.st_mode, stat.st_gid + + +seen = {} + +# libc's getgrgid re-opens /etc/group each time :( +_gidcache = {} + +def getgrgid(gid): + data = _gidcache.get(gid) + if data is None: + data = grp.getgrgid(gid) + _gidcache[gid] = data + return data + + + +def checkwalk(arg, dirname, names): + # Short-circuit duplicates + if seen.has_key(dirname): + return + seen[dirname] = True + for name in names: + path = os.path.join(dirname, name) + if arg.VERBOSE: + print _(' checking gid and mode for $path') + try: + mode, gid = statgidmode(path) + except OSError, e: + if e.errno <> errno.ENOENT: raise + continue + if gid <> MAILMAN_GID: + try: + groupname = getgrgid(gid)[0] + except KeyError: + groupname = '' % gid + arg.ERRORS += 1 + print _( + '$path bad group (has: $groupname, expected $MAILMAN_GROUP)'), + if STATE.FIX: + print _('(fixing)') + os.chown(path, -1, MAILMAN_GID) + else: + print + # Most directories must be at least rwxrwsr-x. + # The private archive directory and database directory must be at + # least rwxrws---. Their 'other' permissions are checked in + # checkarchives() and checkarchivedbs() below. Their 'user' and + # 'group' permissions are checked here. + # The directories under qfiles should be rwxrws---. Their 'user' and + # 'group' permissions are checked here. Their 'other' permissions + # aren't checked. + private = config.PRIVATE_ARCHIVE_FILE_DIR + if path == private or ( + os.path.commonprefix((path, private)) == private + and os.path.split(path)[1] == 'database'): + # then... + targetperms = PRIVATEPERMS + elif (os.path.commonprefix((path, config.QUEUE_DIR)) + == config.QUEUE_DIR): + targetperms = QFILEPERMS + else: + targetperms = DIRPERMS + octperms = oct(targetperms) + if S_ISDIR(mode) and (mode & targetperms) <> targetperms: + arg.ERRORS += 1 + print _('directory permissions must be $octperms: $path'), + if STATE.FIX: + print _('(fixing)') + os.chmod(path, mode | targetperms) + else: + print + elif os.path.splitext(path)[1] in ('.py', '.pyc', '.pyo'): + octperms = oct(PYFILEPERMS) + if mode & PYFILEPERMS <> PYFILEPERMS: + print _('source perms must be $octperms: $path'), + arg.ERRORS += 1 + if STATE.FIX: + print _('(fixing)') + os.chmod(path, mode | PYFILEPERMS) + else: + print + elif path.endswith('-article'): + # Article files must be group writeable + octperms = oct(ARTICLEFILEPERMS) + if mode & ARTICLEFILEPERMS <> ARTICLEFILEPERMS: + print _('article db files must be $octperms: $path'), + arg.ERRORS += 1 + if STATE.FIX: + print _('(fixing)') + os.chmod(path, mode | ARTICLEFILEPERMS) + else: + print + + + +def checkall(): + # first check PREFIX + if STATE.VERBOSE: + prefix = config.PREFIX + print _('checking mode for $prefix') + dirs = {} + for d in (config.PREFIX, config.EXEC_PREFIX, config.VAR_PREFIX, + config.LOG_DIR): + dirs[d] = True + for d in dirs.keys(): + try: + mode = statmode(d) + except OSError, e: + if e.errno <> errno.ENOENT: raise + print _('WARNING: directory does not exist: $d') + continue + if (mode & DIRPERMS) <> DIRPERMS: + STATE.ERRORS += 1 + print _('directory must be at least 02775: $d'), + if STATE.FIX: + print _('(fixing)') + os.chmod(d, mode | DIRPERMS) + else: + print + # check all subdirs + os.path.walk(d, checkwalk, STATE) + + + +def checkarchives(): + private = config.PRIVATE_ARCHIVE_FILE_DIR + if STATE.VERBOSE: + print _('checking perms on $private') + # private archives must not be other readable + mode = statmode(private) + if mode & S_IROTH: + STATE.ERRORS += 1 + print _('$private must not be other-readable'), + if STATE.FIX: + print _('(fixing)') + os.chmod(private, mode & ~S_IROTH) + else: + print + # In addition, on a multiuser system you may want to hide the private + # archives so other users can't read them. + if mode & S_IXOTH: + print _("""\ +Warning: Private archive directory is other-executable (o+x). + This could allow other users on your system to read private archives. + If you're on a shared multiuser system, you should consult the + installation manual on how to fix this.""") + + + +def checkmboxfile(mboxdir): + absdir = os.path.join(config.PRIVATE_ARCHIVE_FILE_DIR, mboxdir) + for f in os.listdir(absdir): + if not f.endswith('.mbox'): + continue + mboxfile = os.path.join(absdir, f) + mode = statmode(mboxfile) + if (mode & MBOXPERMS) <> MBOXPERMS: + STATE.ERRORS = STATE.ERRORS + 1 + print _('mbox file must be at least 0660:'), mboxfile + if STATE.FIX: + print _('(fixing)') + os.chmod(mboxfile, mode | MBOXPERMS) + else: + print + + + +def checkarchivedbs(): + # The archives/private/listname/database file must not be other readable + # or executable otherwise those files will be accessible when the archives + # are public. That may not be a horrible breach, but let's close this off + # anyway. + for dir in os.listdir(config.PRIVATE_ARCHIVE_FILE_DIR): + if dir.endswith('.mbox'): + checkmboxfile(dir) + dbdir = os.path.join(config.PRIVATE_ARCHIVE_FILE_DIR, dir, 'database') + try: + mode = statmode(dbdir) + except OSError, e: + if e.errno not in (errno.ENOENT, errno.ENOTDIR): raise + continue + if mode & S_IRWXO: + STATE.ERRORS += 1 + print _('$dbdir "other" perms must be 000'), + if STATE.FIX: + print _('(fixing)') + os.chmod(dbdir, mode & ~S_IRWXO) + else: + print + + + +def checkcgi(): + cgidir = os.path.join(config.EXEC_PREFIX, 'cgi-bin') + if STATE.VERBOSE: + print _('checking cgi-bin permissions') + exes = os.listdir(cgidir) + for f in exes: + path = os.path.join(cgidir, f) + if STATE.VERBOSE: + print _(' checking set-gid for $path') + mode = statmode(path) + if mode & S_IXGRP and not mode & S_ISGID: + STATE.ERRORS += 1 + print _('$path must be set-gid'), + if STATE.FIX: + print _('(fixing)') + os.chmod(path, mode | S_ISGID) + else: + print + + + +def checkmail(): + wrapper = os.path.join(config.WRAPPER_DIR, 'mailman') + if STATE.VERBOSE: + print _('checking set-gid for $wrapper') + mode = statmode(wrapper) + if not mode & S_ISGID: + STATE.ERRORS += 1 + print _('$wrapper must be set-gid'), + if STATE.FIX: + print _('(fixing)') + os.chmod(wrapper, mode | S_ISGID) + + + +def checkadminpw(): + for pwfile in (os.path.join(config.DATA_DIR, 'adm.pw'), + os.path.join(config.DATA_DIR, 'creator.pw')): + targetmode = S_IFREG | S_IRUSR | S_IWUSR | S_IRGRP + if STATE.VERBOSE: + print _('checking permissions on $pwfile') + try: + mode = statmode(pwfile) + except OSError, e: + if e.errno <> errno.ENOENT: + raise + return + if mode <> targetmode: + STATE.ERRORS += 1 + octmode = oct(mode) + print _('$pwfile permissions must be exactly 0640 (got $octmode)'), + if STATE.FIX: + print _('(fixing)') + os.chmod(pwfile, targetmode) + else: + print + + +def checkmta(): + if config.MTA: + modname = 'mailman.MTA.' + config.MTA + __import__(modname) + try: + sys.modules[modname].checkperms(STATE) + except AttributeError: + pass + + + +def checkdata(): + targetmode = S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP + checkfiles = ('config.pck', 'config.pck.last', + 'config.db', 'config.db.last', + 'next-digest', 'next-digest-topics', + 'digest.mbox', 'pending.pck', + 'request.db', 'request.db.tmp') + if STATE.VERBOSE: + print _('checking permissions on list data') + for dir in os.listdir(config.LIST_DATA_DIR): + for file in checkfiles: + path = os.path.join(config.LIST_DATA_DIR, dir, file) + if STATE.VERBOSE: + print _(' checking permissions on: $path') + try: + mode = statmode(path) + except OSError, e: + if e.errno <> errno.ENOENT: + raise + continue + if (mode & targetmode) <> targetmode: + STATE.ERRORS += 1 + print _('file permissions must be at least 660: $path'), + if STATE.FIX: + print _('(fixing)') + os.chmod(path, mode | targetmode) + else: + print + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] + +Check the permissions of all Mailman files. With no options, just report the +permission and ownership problems found.""")) + parser.add_option('-f', '--fix', + default=False, action='store_true', help=_("""\ +Fix all permission and ownership problems found. With this option, you must +run check_perms as root.""")) + parser.add_option('-v', '--verbose', + default=False, action='store_true', + help=_('Produce more verbose output')) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + if args: + parser.print_help() + print >> sys.stderr, _('Unexpected arguments') + sys.exit(1) + return parser, opts, args + + + +def main(): + global MAILMAN_USER, MAILMAN_GROUP, MAILMAN_UID, MAILMAN_GID + + parser, opts, args = parseargs() + STATE.FIX = opts.fix + STATE.VERBOSE = opts.verbose + + config.load(opts.config) + + MAILMAN_USER = config.MAILMAN_USER + MAILMAN_GROUP = config.MAILMAN_GROUP + # Let KeyErrors percolate + MAILMAN_GID = grp.getgrnam(MAILMAN_GROUP).gr_gid + MAILMAN_UID = pwd.getpwnam(MAILMAN_USER).pw_uid + + checkall() + checkarchives() + checkarchivedbs() + checkcgi() + checkmail() + checkdata() + checkadminpw() + checkmta() + + if not STATE.ERRORS: + print _('No problems found') + else: + print _('Problems found:'), STATE.ERRORS + print _('Re-run as $MAILMAN_USER (or root) with -f flag to fix') + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/checkdbs.py b/src/mailman/bin/checkdbs.py new file mode 100644 index 000000000..2ce08aab7 --- /dev/null +++ b/src/mailman/bin/checkdbs.py @@ -0,0 +1,199 @@ +# 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 . + +import sys +import time +import optparse + +from email.Charset import Charset + +from mailman import MailList +from mailman import Message +from mailman import Utils +from mailman import i18n +from mailman.app.requests import handle_request +from mailman.configuration import config +from mailman.version import MAILMAN_VERSION + +_ = i18n._ + +# Work around known problems with some RedHat cron daemons +import signal +signal.signal(signal.SIGCHLD, signal.SIG_DFL) + +NL = u'\n' +now = time.time() + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] + +Check for pending admin requests and mail the list owners if necessary.""")) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + if args: + parser.print_help() + print >> sys.stderr, _('Unexpected arguments') + sys.exit(1) + return opts, args, parser + + + +def pending_requests(mlist): + # Must return a byte string + lcset = Utils.GetCharSet(mlist.preferred_language) + pending = [] + first = True + requestsdb = config.db.get_list_requests(mlist) + for request in requestsdb.of_type(RequestType.subscription): + if first: + pending.append(_('Pending subscriptions:')) + first = False + key, data = requestsdb.get_request(request.id) + when = data['when'] + addr = data['addr'] + fullname = data['fullname'] + passwd = data['passwd'] + digest = data['digest'] + lang = data['lang'] + if fullname: + if isinstance(fullname, unicode): + fullname = fullname.encode(lcset, 'replace') + fullname = ' (%s)' % fullname + pending.append(' %s%s %s' % (addr, fullname, time.ctime(when))) + first = True + for request in requestsdb.of_type(RequestType.held_message): + if first: + pending.append(_('\nPending posts:')) + first = False + key, data = requestsdb.get_request(request.id) + when = data['when'] + sender = data['sender'] + subject = data['subject'] + reason = data['reason'] + text = data['text'] + msgdata = data['msgdata'] + subject = Utils.oneline(subject, lcset) + date = time.ctime(when) + reason = _(reason) + pending.append(_("""\ +From: $sender on $date +Subject: $subject +Cause: $reason""")) + pending.append('') + # Coerce all items in pending to a Unicode so we can join them + upending = [] + charset = Utils.GetCharSet(mlist.preferred_language) + for s in pending: + if isinstance(s, unicode): + upending.append(s) + else: + upending.append(unicode(s, charset, 'replace')) + # Make sure that the text we return from here can be encoded to a byte + # string in the charset of the list's language. This could fail if for + # example, the request was pended while the list's language was French, + # but then it was changed to English before checkdbs ran. + text = NL.join(upending) + charset = Charset(Utils.GetCharSet(mlist.preferred_language)) + incodec = charset.input_codec or 'ascii' + outcodec = charset.output_codec or 'ascii' + if isinstance(text, unicode): + return text.encode(outcodec, 'replace') + # Be sure this is a byte string encodeable in the list's charset + utext = unicode(text, incodec, 'replace') + return utext.encode(outcodec, 'replace') + + + +def auto_discard(mlist): + # Discard old held messages + discard_count = 0 + expire = config.days(mlist.max_days_to_hold) + requestsdb = config.db.get_list_requests(mlist) + heldmsgs = list(requestsdb.of_type(RequestType.held_message)) + if expire and heldmsgs: + for request in heldmsgs: + key, data = requestsdb.get_request(request.id) + if now - data['date'] > expire: + handle_request(mlist, request.id, config.DISCARD) + discard_count += 1 + mlist.Save() + return discard_count + + + +def main(): + opts, args, parser = parseargs() + config.load(opts.config) + + i18n.set_language(config.DEFAULT_SERVER_LANGUAGE) + + for name in config.list_manager.names: + # The list must be locked in order to open the requests database + mlist = MailList.MailList(name) + try: + count = config.db.requests.get_list_requests(mlist).count + # While we're at it, let's evict yesterday's autoresponse data + midnight_today = Utils.midnight() + evictions = [] + for sender in mlist.hold_and_cmd_autoresponses.keys(): + date, respcount = mlist.hold_and_cmd_autoresponses[sender] + if Utils.midnight(date) < midnight_today: + evictions.append(sender) + if evictions: + for sender in evictions: + del mlist.hold_and_cmd_autoresponses[sender] + # This is the only place we've changed the list's database + mlist.Save() + if count: + i18n.set_language(mlist.preferred_language) + realname = mlist.real_name + discarded = auto_discard(mlist) + if discarded: + count = count - discarded + text = _( + 'Notice: $discarded old request(s) automatically expired.\n\n') + else: + text = '' + if count: + text += Utils.maketext( + 'checkdbs.txt', + {'count' : count, + 'host_name': mlist.host_name, + 'adminDB' : mlist.GetScriptURL('admindb', absolute=1), + 'real_name': realname, + }, mlist=mlist) + text += '\n' + pending_requests(mlist) + subject = _('$count $realname moderator request(s) waiting') + else: + subject = _('$realname moderator request check result') + msg = Message.UserNotification(mlist.GetOwnerEmail(), + mlist.GetBouncesEmail(), + subject, text, + mlist.preferred_language) + msg.send(mlist, **{'tomoderators': True}) + finally: + mlist.Unlock() + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/cleanarch.py b/src/mailman/bin/cleanarch.py new file mode 100644 index 000000000..325fad91a --- /dev/null +++ b/src/mailman/bin/cleanarch.py @@ -0,0 +1,133 @@ +# 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 . + +"""Clean up an .mbox archive file.""" + +import re +import sys +import mailbox +import optparse + +from mailman.i18n import _ +from mailman.version import MAILMAN_VERSION + + +cre = re.compile(mailbox.UnixMailbox._fromlinepattern) +# From RFC 2822, a header field name must contain only characters from 33-126 +# inclusive, excluding colon. I.e. from oct 41 to oct 176 less oct 072. Must +# use re.match() so that it's anchored at the beginning of the line. +fre = re.compile(r'[\041-\071\073-\176]+') + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] < inputfile > outputfile + +The archiver looks for Unix-From lines separating messages in an mbox archive +file. For compatibility, it specifically looks for lines that start with +'From ' -- i.e. the letters capital-F, lowercase-r, o, m, space, ignoring +everything else on the line. + +Normally, any lines that start 'From ' in the body of a message should be +escaped such that a > character is actually the first on a line. It is +possible though that body lines are not actually escaped. This script +attempts to fix these by doing a stricter test of the Unix-From lines. Any +lines that start From ' but do not pass this stricter test are escaped with a +'>' character.""")) + parser.add_option('-q', '--quiet', + default=False, action='store_true', help=_("""\ +Don't print changed line information to standard error.""")) + parser.add_option('-s', '--status', + default=-1, type='int', help=_("""\ +Print a '#' character for every n lines processed. With a number less than or +equal to zero, suppress the '#' characters.""")) + parser.add_option('-n', '--dry-run', + default=False, action='store_true', help=_("""\ +Don't actually output anything.""")) + opts, args = parser.parser_args() + if args: + parser.print_error(_('Unexpected arguments')) + return parser, opts, args + + + +def escape_line(line, lineno, quiet, output): + if output: + sys.stdout.write('>' + line) + if not quiet: + print >> sys.stderr, _('Unix-From line changed: $lineno') + print >> sys.stderr, line[:-1] + + + +def main(): + parser, opts, args = parseargs() + + lineno = 0 + statuscnt = 0 + messages = 0 + prevline = None + while True: + lineno += 1 + line = sys.stdin.readline() + if not line: + break + if line.startswith('From '): + if cre.match(line): + # This is a real Unix-From line. But it could be a message + # /about/ Unix-From lines, so as a second order test, make + # sure there's at least one RFC 2822 header following + nextline = sys.stdin.readline() + lineno += 1 + if not nextline: + # It was the last line of the mbox, so it couldn't have + # been a Unix-From + escape_line(line, lineno, quiet, output) + break + fieldname = nextline.split(':', 1) + if len(fieldname) < 2 or not fre.match(nextline): + # The following line was not a header, so this wasn't a + # valid Unix-From + escape_line(line, lineno, quiet, output) + if output: + sys.stdout.write(nextline) + else: + # It's a valid Unix-From line + messages += 1 + if output: + # Before we spit out the From_ line, make sure the + # previous line was blank. + if prevline is not None and prevline <> '\n': + sys.stdout.write('\n') + sys.stdout.write(line) + sys.stdout.write(nextline) + else: + # This is a bogus Unix-From line + escape_line(line, lineno, quiet, output) + elif output: + # Any old line + sys.stdout.write(line) + if status > 0 and (lineno % status) == 0: + sys.stderr.write('#') + statuscnt += 1 + if statuscnt > 50: + print >> sys.stderr + statuscnt = 0 + prevline = line + print >> sys.stderr, _('%(messages)d messages found') diff --git a/src/mailman/bin/config_list.py b/src/mailman/bin/config_list.py new file mode 100644 index 000000000..a5cec9480 --- /dev/null +++ b/src/mailman/bin/config_list.py @@ -0,0 +1,332 @@ +# 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 . + +import re +import sys +import time +import optparse + +from mailman import errors +from mailman import MailList +from mailman import Utils +from mailman import i18n +from mailman.configuration import config +from mailman.version import MAILMAN_VERSION + +_ = i18n._ + +NL = '\n' +nonasciipat = re.compile(r'[\x80-\xff]') + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] listname + +Configure a list from a text file description, or dump a list's configuration +settings.""")) + parser.add_option('-i', '--inputfile', + metavar='FILENAME', default=None, type='string', + help=_("""\ +Configure the list by assigning each module-global variable in the file to an +attribute on the mailing list object, then save the list. The named file is +loaded with execfile() and must be legal Python code. Any variable that isn't +already an attribute of the list object is ignored (a warning message is +printed). See also the -c option. + +A special variable named 'mlist' is put into the globals during the execfile, +which is bound to the actual MailList object. This lets you do all manner of +bizarre thing to the list object, but BEWARE! Using this can severely (and +possibly irreparably) damage your mailing list! + +The may not be used with the -o option.""")) + parser.add_option('-o', '--outputfile', + metavar='FILENAME', default=None, type='string', + help=_("""\ +Instead of configuring the list, print out a mailing list's configuration +variables in a format suitable for input using this script. In this way, you +can easily capture the configuration settings for a particular list and +imprint those settings on another list. FILENAME is the file to output the +settings to. If FILENAME is `-', standard out is used. + +This may not be used with the -i option.""")) + parser.add_option('-c', '--checkonly', + default=False, action='store_true', help=_("""\ +With this option, the modified list is not actually changed. This is only +useful with the -i option.""")) + parser.add_option('-v', '--verbose', + default=False, action='store_true', help=_("""\ +Print the name of each attribute as it is being changed. This is only useful +with the -i option.""")) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + if len(args) > 1: + parser.print_help() + parser.error(_('Unexpected arguments')) + if not args: + parser.error(_('List name is required')) + return parser, opts, args + + + +def do_output(listname, outfile, parser): + closep = False + try: + if outfile == '-': + outfp = sys.stdout + else: + outfp = open(outfile, 'w') + closep = True + # Open the specified list unlocked, since we're only reading it. + try: + mlist = MailList.MailList(listname, lock=False) + except errors.MMListError: + parser.error(_('No such list: $listname')) + # Preamble for the config info. PEP 263 charset and capture time. + language = mlist.preferred_language + charset = Utils.GetCharSet(language) + i18n.set_language(language) + if not charset: + charset = 'us-ascii' + when = time.ctime(time.time()) + print >> outfp, _('''\ +# -*- python -*- +# -*- coding: $charset -*- +## "$listname" mailing list configuration settings +## captured on $when +''') + # Get all the list config info. All this stuff is accessible via the + # web interface. + for k in config.ADMIN_CATEGORIES: + subcats = mlist.GetConfigSubCategories(k) + if subcats is None: + do_list_categories(mlist, k, None, outfp) + else: + for subcat in [t[0] for t in subcats]: + do_list_categories(mlist, k, subcat, outfp) + finally: + if closep: + outfp.close() + + + +def do_list_categories(mlist, k, subcat, outfp): + info = mlist.GetConfigInfo(k, subcat) + label, gui = mlist.GetConfigCategories()[k] + if info is None: + return + charset = Utils.GetCharSet(mlist.preferred_language) + print >> outfp, '##', k.capitalize(), _('options') + print >> outfp, '#' + # First, massage the descripton text, which could have obnoxious + # leading whitespace on second and subsequent lines due to + # triple-quoted string nonsense in the source code. + desc = NL.join([s.lstrip() for s in info[0].splitlines()]) + # Print out the category description + desc = Utils.wrap(desc) + for line in desc.splitlines(): + print >> outfp, '#', line + print >> outfp + for data in info[1:]: + if not isinstance(data, tuple): + continue + varname = data[0] + # Variable could be volatile + if varname[0] == '_': + continue + vtype = data[1] + # First, massage the descripton text, which could have + # obnoxious leading whitespace on second and subsequent lines + # due to triple-quoted string nonsense in the source code. + desc = NL.join([s.lstrip() for s in data[-1].splitlines()]) + # Now strip out all HTML tags + desc = re.sub('<.*?>', '', desc) + # And convert </> to <> + desc = re.sub('<', '<', desc) + desc = re.sub('>', '>', desc) + # Print out the variable description. + desc = Utils.wrap(desc) + for line in desc.split('\n'): + print >> outfp, '#', line + # munge the value based on its type + value = None + if hasattr(gui, 'getValue'): + value = gui.getValue(mlist, vtype, varname, data[2]) + if value is None and not varname.startswith('_'): + value = getattr(mlist, varname) + if vtype in (config.String, config.Text, config.FileUpload): + print >> outfp, varname, '=', + lines = value.splitlines() + if not lines: + print >> outfp, "''" + elif len(lines) == 1: + if charset <> 'us-ascii' and nonasciipat.search(lines[0]): + # This is more readable for non-english list. + print >> outfp, '"' + lines[0].replace('"', '\\"') + '"' + else: + print >> outfp, repr(lines[0]) + else: + if charset == 'us-ascii' and nonasciipat.search(value): + # Normally, an english list should not have non-ascii char. + print >> outfp, repr(NL.join(lines)) + else: + outfp.write(' """') + outfp.write(NL.join(lines).replace('"', '\\"')) + outfp.write('"""\n') + elif vtype in (config.Radio, config.Toggle): + print >> outfp, '#' + print >> outfp, '#', _('legal values are:') + # TBD: This is disgusting, but it's special cased + # everywhere else anyway... + if varname == 'subscribe_policy' and \ + not config.ALLOW_OPEN_SUBSCRIBE: + i = 1 + else: + i = 0 + for choice in data[2]: + print >> outfp, '# ', i, '= "%s"' % choice + i += 1 + print >> outfp, varname, '=', repr(value) + else: + print >> outfp, varname, '=', repr(value) + print >> outfp + + + +def getPropertyMap(mlist): + guibyprop = {} + categories = mlist.GetConfigCategories() + for category, (label, gui) in categories.items(): + if not hasattr(gui, 'GetConfigInfo'): + continue + subcats = mlist.GetConfigSubCategories(category) + if subcats is None: + subcats = [(None, None)] + for subcat, sclabel in subcats: + for element in gui.GetConfigInfo(mlist, category, subcat): + if not isinstance(element, tuple): + continue + propname = element[0] + wtype = element[1] + guibyprop[propname] = (gui, wtype) + return guibyprop + + +class FakeDoc: + # Fake the error reporting API for the htmlformat.Document class + def addError(self, s, tag=None, *args): + if tag: + print >> sys.stderr, tag + print >> sys.stderr, s % args + + def set_language(self, val): + pass + + + +def do_input(listname, infile, checkonly, verbose, parser): + fakedoc = FakeDoc() + # Open the specified list locked, unless checkonly is set + try: + mlist = MailList.MailList(listname, lock=not checkonly) + except errors.MMListError, e: + parser.error(_('No such list "$listname"\n$e')) + savelist = False + guibyprop = getPropertyMap(mlist) + try: + globals = {'mlist': mlist} + # Any exception that occurs in execfile() will cause the list to not + # be saved, but any other problems are not save-fatal. + execfile(infile, globals) + savelist = True + for k, v in globals.items(): + if k in ('mlist', '__builtins__'): + continue + if not hasattr(mlist, k): + print >> sys.stderr, _('attribute "$k" ignored') + continue + if verbose: + print >> sys.stderr, _('attribute "$k" changed') + missing = [] + gui, wtype = guibyprop.get(k, (missing, missing)) + if gui is missing: + # This isn't an official property of the list, but that's + # okay, we'll just restore it the old fashioned way + print >> sys.stderr, _('Non-standard property restored: $k') + setattr(mlist, k, v) + else: + # BAW: This uses non-public methods. This logic taken from + # the guts of GUIBase.handleForm(). + try: + validval = gui._getValidValue(mlist, k, wtype, v) + except ValueError: + print >> sys.stderr, _('Invalid value for property: $k') + except errors.EmailAddressError: + print >> sys.stderr, _( + 'Bad email address for option $k: $v') + else: + # BAW: Horrible hack, but then this is special cased + # everywhere anyway. :( Privacy._setValue() knows that + # when ALLOW_OPEN_SUBSCRIBE is false, the web values are + # 0, 1, 2 but these really should be 1, 2, 3, so it adds + # one. But we really do provide [0..3] so we need to undo + # the hack that _setValue adds. :( :( + if k == 'subscribe_policy' and \ + not config.ALLOW_OPEN_SUBSCRIBE: + validval -= 1 + # BAW: Another horrible hack. This one is just too hard + # to fix in a principled way in Mailman 2.1 + elif k == 'new_member_options': + # Because this is a Checkbox, _getValidValue() + # transforms the value into a list of one item. + validval = validval[0] + validval = [bitfield for bitfield, bitval + in config.OPTINFO.items() + if validval & bitval] + gui._setValue(mlist, k, validval, fakedoc) + # BAW: when to do gui._postValidate()??? + finally: + if savelist and not checkonly: + mlist.Save() + mlist.Unlock() + + + +def main(): + parser, opts, args = parseargs() + config.load(opts.config) + listname = args[0] + + # Sanity check + if opts.inputfile and opts.outputfile: + parser.error(_('Only one of -i or -o is allowed')) + if not opts.inputfile and not opts.outputfile: + parser.error(_('One of -i or -o is required')) + + if opts.outputfile: + do_output(listname, opts.outputfile, parser) + else: + do_input(listname, opts.inputfile, opts.checkonly, + opts.verbose, parser) + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/create_list.py b/src/mailman/bin/create_list.py new file mode 100644 index 000000000..8058a7d67 --- /dev/null +++ b/src/mailman/bin/create_list.py @@ -0,0 +1,129 @@ +# 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 . + +import sys + +from mailman import Message +from mailman import Utils +from mailman import i18n +from mailman.app.lifecycle import create_list +from mailman.config import config +from mailman.core import errors +from mailman.interfaces.listmanager import ListAlreadyExistsError +from mailman.options import SingleMailingListOptions + + +_ = i18n._ + + + +class ScriptOptions(SingleMailingListOptions): + usage = _("""\ +%prog [options] + +Create a new mailing list. + +fqdn_listname is the 'fully qualified list name', basically the posting +address of the list. It must be a valid email address and the domain must be +registered with Mailman. + +Note that listnames are forced to lowercase.""") + + def add_options(self): + super(ScriptOptions, self).add_options() + self.parser.add_option( + '--language', + type='unicode', action='store', + help=_("""\ +Make the list's preferred language LANGUAGE, which must be a two letter +language code.""")) + self.parser.add_option( + '-o', '--owner', + type='unicode', action='append', default=[], + dest='owners', help=_("""\ +Specific a listowner email address. If the address is not currently +registered with Mailman, the address is registered and linked to a user. +Mailman will send a confirmation message to the address, but it will also send +a list creation notice to the address. More than one owner can be +specified.""")) + self.parser.add_option( + '-q', '--quiet', + default=False, action='store_true', + help=_("""\ +Normally the administrator is notified by email (after a prompt) that their +list has been created. This option suppresses the prompt and +notification.""")) + self.parser.add_option( + '-a', '--automate', + default=False, action='store_true', + help=_("""\ +This option suppresses the prompt prior to administrator notification but +still sends the notification. It can be used to make newlist totally +non-interactive but still send the notification, assuming at least one list +owner is specified with the -o option..""")) + + def sanity_check(self): + """Set up some defaults we couldn't set up earlier.""" + if self.options.language is None: + self.options.language = unicode(config.mailman.default_language) + # Is the language known? + if self.options.language not in config.languages.enabled_codes: + self.parser.error(_('Unknown language: $opts.language')) + # Handle variable number of positional arguments + if len(self.arguments) > 0: + parser.error(_('Unexpected arguments')) + + + +def main(): + options = ScriptOptions() + options.initialize() + + # Create the mailing list, applying styles as appropriate. + fqdn_listname = options.options.listname + if fqdn_listname is None: + options.parser.error(_('--listname is required')) + try: + mlist = create_list(fqdn_listname, options.options.owners) + mlist.preferred_language = options.options.language + except errors.InvalidEmailAddress: + options.parser.error(_('Illegal list name: $fqdn_listname')) + except ListAlreadyExistsError: + options.parser.error(_('List already exists: $fqdn_listname')) + except errors.BadDomainSpecificationError, domain: + options.parser.error(_('Undefined domain: $domain')) + + config.db.commit() + + if not options.options.quiet: + d = dict( + listname = mlist.fqdn_listname, + admin_url = mlist.script_url('admin'), + listinfo_url = mlist.script_url('listinfo'), + requestaddr = mlist.request_address, + siteowner = mlist.no_reply_address, + ) + text = Utils.maketext('newlist.txt', d, mlist=mlist) + # Set the I18N language to the list's preferred language so the header + # will match the template language. Stashing and restoring the old + # translation context is just (healthy? :) paranoia. + with i18n.using_language(mlist.preferred_language): + msg = Message.UserNotification( + owner_mail, mlist.no_reply_address, + _('Your new mailing list: $fqdn_listname'), + text, mlist.preferred_language) + msg.send(mlist) diff --git a/src/mailman/bin/disabled.py b/src/mailman/bin/disabled.py new file mode 100644 index 000000000..cc8eb2c69 --- /dev/null +++ b/src/mailman/bin/disabled.py @@ -0,0 +1,201 @@ +# 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 . + +import time +import logging +import optparse + +from mailman import errors +from mailman import MailList +from mailman import MemberAdaptor +from mailman import Pending +from mailman import loginit +from mailman.Bouncer import _BounceInfo +from mailman.configuration import config +from mailman.i18n import _ +from mailman.version import MAILMAN_VERSION + + +# Work around known problems with some RedHat cron daemons +import signal +signal.signal(signal.SIGCHLD, signal.SIG_DFL) + +ALL = (MemberAdaptor.BYBOUNCE, + MemberAdaptor.BYADMIN, + MemberAdaptor.BYUSER, + MemberAdaptor.UNKNOWN, + ) + + + +def who_callback(option, opt, value, parser): + dest = getattr(parser.values, option.dest) + if opt in ('-o', '--byadmin'): + dest.add(MemberAdaptor.BYADMIN) + elif opt in ('-m', '--byuser'): + dest.add(MemberAdaptor.BYUSER) + elif opt in ('-u', '--unknown'): + dest.add(MemberAdaptor.UNKNOWN) + elif opt in ('-b', '--notbybounce'): + dest.discard(MemberAdaptor.BYBOUNCE) + elif opt in ('-a', '--all'): + dest.update(ALL) + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] + +Process disabled members, recommended once per day. + +This script iterates through every mailing list looking for members whose +delivery is disabled. If they have been disabled due to bounces, they will +receive another notification, or they may be removed if they've received the +maximum number of notifications. + +Use the --byadmin, --byuser, and --unknown flags to also send notifications to +members whose accounts have been disabled for those reasons. Use --all to +send the notification to all disabled members.""")) + # This is the set of working flags for who to send notifications to. By + # default, we notify anybody who has been disable due to bounces. + parser.set_defaults(who=set([MemberAdaptor.BYBOUNCE])) + parser.add_option('-o', '--byadmin', + callback=who_callback, action='callback', dest='who', + help=_("""\ +Also send notifications to any member disabled by the list +owner/administrator.""")) + parser.add_option('-m', '--byuser', + callback=who_callback, action='callback', dest='who', + help=_("""\ +Also send notifications to any member who has disabled themself.""")) + parser.add_option('-u', '--unknown', + callback=who_callback, action='callback', dest='who', + help=_("""\ +Also send notifications to any member disabled for unknown reasons +(usually a legacy disabled address).""")) + parser.add_option('-b', '--notbybounce', + callback=who_callback, action='callback', dest='who', + help=_("""\ +Don't send notifications to members disabled because of bounces (the +default is to notify bounce disabled members).""")) + parser.add_option('-a', '--all', + callback=who_callback, action='callback', dest='who', + help=_('Send notifications to all disabled members')) + parser.add_option('-f', '--force', + default=False, action='store_true', + help=_("""\ +Send notifications to disabled members even if they're not due a new +notification yet.""")) + parser.add_option('-l', '--listname', + dest='listnames', action='append', default=[], + type='string', help=_("""\ +Process only the given list, otherwise do all lists.""")) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + return opts, args, parser + + + +def main(): + opts, args, parser = parseargs() + config.load(opts.config) + + loginit.initialize(propagate=True) + elog = logging.getLogger('mailman.error') + blog = logging.getLogger('mailman.bounce') + + listnames = set(opts.listnames or config.list_manager.names) + who = tuple(opts.who) + + msg = _('[disabled by periodic sweep and cull, no message available]') + today = time.mktime(time.localtime()[:3] + (0,) * 6) + for listname in listnames: + # List of members to notify + notify = [] + mlist = MailList.MailList(listname) + try: + interval = mlist.bounce_you_are_disabled_warnings_interval + # Find all the members who are currently bouncing and see if + # they've reached the disable threshold but haven't yet been + # disabled. This is a sweep through the membership catching + # situations where they've bounced a bunch, then the list admin + # lowered the threshold, but we haven't (yet) seen more bounces + # from the member. Note: we won't worry about stale information + # or anything else since the normal bounce processing code will + # handle that. + disables = [] + for member in mlist.getBouncingMembers(): + if mlist.getDeliveryStatus(member) <> MemberAdaptor.ENABLED: + continue + info = mlist.getBounceInfo(member) + if info.score >= mlist.bounce_score_threshold: + disables.append((member, info)) + if disables: + for member, info in disables: + mlist.disableBouncingMember(member, info, msg) + # Go through all the members who have delivery disabled, and find + # those that are due to have another notification. If they are + # disabled for another reason than bouncing, and we're processing + # them (because of the command line switch) then they won't have a + # bounce info record. We can piggyback on that for all disable + # purposes. + members = mlist.getDeliveryStatusMembers(who) + for member in members: + info = mlist.getBounceInfo(member) + if not info: + # See if they are bounce disabled, or disabled for some + # other reason. + status = mlist.getDeliveryStatus(member) + if status == MemberAdaptor.BYBOUNCE: + elog.error( + '%s disabled BYBOUNCE lacks bounce info, list: %s', + member, mlist.internal_name()) + continue + info = _BounceInfo( + member, 0, today, + mlist.bounce_you_are_disabled_warnings, + mlist.pend_new(Pending.RE_ENABLE, + mlist.internal_name(), + member)) + mlist.setBounceInfo(member, info) + lastnotice = time.mktime(info.lastnotice + (0,) * 6) + if opts.force or today >= lastnotice + interval: + notify.append(member) + # Now, send notifications to anyone who is due + for member in notify: + blog.info('Notifying disabled member %s for list: %s', + member, mlist.internal_name()) + try: + mlist.sendNextNotification(member) + except errors.NotAMemberError: + # There must have been some problem with the data we have + # on this member. Most likely it's that they don't have a + # password assigned. Log this and delete the member. + blog.info( + 'NotAMemberError when sending disabled notice: %s', + member) + mlist.ApprovedDeleteMember(member, 'cron/disabled') + mlist.Save() + finally: + mlist.Unlock() + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/docs/master.txt b/src/mailman/bin/docs/master.txt new file mode 100644 index 000000000..0d3cade77 --- /dev/null +++ b/src/mailman/bin/docs/master.txt @@ -0,0 +1,49 @@ +Mailman queue runner control +============================ + +Mailman has a number of queue runners which process messages in its queue file +directories. In normal operation, a command line script called 'mailmanctl' +is used to start, stop and manage the queue runners. mailmanctl actually is +just a wrapper around the real queue runner watcher script called master.py. + + >>> from mailman.testing.helpers import TestableMaster + +Start the master in a subthread. + + >>> master = TestableMaster() + >>> master.start() + +There should be a process id for every qrunner that claims to be startable. + + >>> from lazr.config import as_boolean + >>> startable_qrunners = [qconf for qconf in config.qrunner_configs + ... if as_boolean(qconf.start)] + >>> len(list(master.qrunner_pids)) == len(startable_qrunners) + True + +Now verify that all the qrunners are running. + + >>> import os + + # This should produce no output. + >>> for pid in master.qrunner_pids: + ... os.kill(pid, 0) + +Stop the master process, which should also kill (and not restart) the child +queue runner processes. + + >>> master.stop() + +None of the children are running now. + + >>> import errno + >>> for pid in master.qrunner_pids: + ... try: + ... os.kill(pid, 0) + ... print 'Process did not exit:', pid + ... except OSError, error: + ... if error.errno == errno.ESRCH: + ... # The child process exited. + ... pass + ... else: + ... raise diff --git a/src/mailman/bin/dumpdb.py b/src/mailman/bin/dumpdb.py new file mode 100644 index 000000000..6657602e4 --- /dev/null +++ b/src/mailman/bin/dumpdb.py @@ -0,0 +1,88 @@ +# 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 . + +import pprint +import cPickle + +from mailman.config import config +from mailman.i18n import _ +from mailman.interact import interact +from mailman.options import Options + + +COMMASPACE = ', ' +m = [] + + + +class ScriptOptions(Options): + usage=_("""\ +%prog [options] filename + +Dump the contents of any Mailman queue file. The queue file is a data file +with multiple Python pickles in it.""") + + def add_options(self): + super(ScriptOptions, self).add_options() + self.parser.add_option( + '-n', '--noprint', + dest='doprint', default=True, action='store_false', + help=_("""\ +Don't attempt to pretty print the object. This is useful if there is some +problem with the object and you just want to get an unpickled representation. +Useful with 'bin/dumpdb -i '. In that case, the list of +unpickled objects will be left in a variable called 'm'.""")) + self.parser.add_option( + '-i', '--interact', + default=False, action='store_true', + help=_("""\ +Start an interactive Python session, with a variable called 'm' containing the +list of unpickled objects.""")) + + def sanity_check(self): + if len(self.arguments) < 1: + self.parser.error(_('No filename given.')) + elif len(self.arguments) > 1: + self.parser.error(_('Unexpected arguments')) + else: + self.filename = self.arguments[0] + + + +def main(): + options = ScriptOptions() + options.initialize() + + pp = pprint.PrettyPrinter(indent=4) + with open(options.filename) as fp: + while True: + try: + m.append(cPickle.load(fp)) + except EOFError: + break + if options.options.doprint: + print _('[----- start pickle -----]') + for i, obj in enumerate(m): + count = i + 1 + print _('<----- start object $count ----->') + if isinstance(obj, basestring): + print obj + else: + pp.pprint(obj) + print _('[----- end pickle -----]') + if options.options.interact: + interact() diff --git a/src/mailman/bin/export.py b/src/mailman/bin/export.py new file mode 100644 index 000000000..d1992b4b4 --- /dev/null +++ b/src/mailman/bin/export.py @@ -0,0 +1,310 @@ +# 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 . + +"""Export an XML representation of a mailing list.""" + +import sys +import codecs +import datetime +import optparse + +from xml.sax.saxutils import escape + +from mailman import Defaults +from mailman import errors +from mailman import MemberAdaptor +from mailman.MailList import MailList +from mailman.configuration import config +from mailman.i18n import _ +from mailman.initialize import initialize +from mailman.version import MAILMAN_VERSION + + +SPACE = ' ' + +TYPES = { + Defaults.Toggle : 'bool', + Defaults.Radio : 'radio', + Defaults.String : 'string', + Defaults.Text : 'text', + Defaults.Email : 'email', + Defaults.EmailList : 'email_list', + Defaults.Host : 'host', + Defaults.Number : 'number', + Defaults.FileUpload : 'upload', + Defaults.Select : 'select', + Defaults.Topics : 'topics', + Defaults.Checkbox : 'checkbox', + Defaults.EmailListEx : 'email_list_ex', + Defaults.HeaderFilter : 'header_filter', + } + + + +class Indenter: + def __init__(self, fp, indentwidth=4): + self._fp = fp + self._indent = 0 + self._width = indentwidth + + def indent(self): + self._indent += 1 + + def dedent(self): + self._indent -= 1 + assert self._indent >= 0 + + def write(self, s): + if s <> '\n': + self._fp.write(self._indent * self._width * ' ') + self._fp.write(s) + + + +class XMLDumper(object): + def __init__(self, fp): + self._fp = Indenter(fp) + self._tagbuffer = None + self._stack = [] + + def _makeattrs(self, tagattrs): + # The attribute values might contain angle brackets. They might also + # be None. + attrs = [] + for k, v in tagattrs.items(): + if v is None: + v = '' + else: + v = escape(str(v)) + attrs.append('%s="%s"' % (k, v)) + return SPACE.join(attrs) + + def _flush(self, more=True): + if not self._tagbuffer: + return + name, attributes = self._tagbuffer + self._tagbuffer = None + if attributes: + attrstr = ' ' + self._makeattrs(attributes) + else: + attrstr = '' + if more: + print >> self._fp, '<%s%s>' % (name, attrstr) + self._fp.indent() + self._stack.append(name) + else: + print >> self._fp, '<%s%s/>' % (name, attrstr) + + # Use this method when you know you have sub-elements. + def _push_element(self, _name, **_tagattrs): + self._flush() + self._tagbuffer = (_name, _tagattrs) + + def _pop_element(self, _name): + buffered = bool(self._tagbuffer) + self._flush(more=False) + if not buffered: + name = self._stack.pop() + assert name == _name, 'got: %s, expected: %s' % (_name, name) + self._fp.dedent() + print >> self._fp, '' % name + + # Use this method when you do not have sub-elements + def _element(self, _name, _value=None, **_attributes): + self._flush() + if _attributes: + attrs = ' ' + self._makeattrs(_attributes) + else: + attrs = '' + if _value is None: + print >> self._fp, '<%s%s/>' % (_name, attrs) + else: + # The value might contain angle brackets. + value = escape(unicode(_value)) + print >> self._fp, '<%s%s>%s' % (_name, attrs, value, _name) + + def _do_list_categories(self, mlist, k, subcat=None): + info = mlist.GetConfigInfo(k, subcat) + label, gui = mlist.GetConfigCategories()[k] + if info is None: + return + for data in info[1:]: + if not isinstance(data, tuple): + continue + varname = data[0] + # Variable could be volatile + if varname.startswith('_'): + continue + vtype = data[1] + # Munge the value based on its type + value = None + if hasattr(gui, 'getValue'): + value = gui.getValue(mlist, vtype, varname, data[2]) + if value is None: + value = getattr(mlist, varname) + widget_type = TYPES[vtype] + if isinstance(value, list): + self._push_element('option', name=varname, type=widget_type) + for v in value: + self._element('value', v) + self._pop_element('option') + else: + self._element('option', value, name=varname, type=widget_type) + + def _dump_list(self, mlist): + # Write list configuration values + self._push_element('list', name=mlist.fqdn_listname) + self._push_element('configuration') + self._element('option', + mlist.preferred_language, + name='preferred_language') + for k in config.ADMIN_CATEGORIES: + subcats = mlist.GetConfigSubCategories(k) + if subcats is None: + self._do_list_categories(mlist, k) + else: + for subcat in [t[0] for t in subcats]: + self._do_list_categories(mlist, k, subcat) + self._pop_element('configuration') + # Write membership + self._push_element('roster') + digesters = set(mlist.getDigestMemberKeys()) + for member in sorted(mlist.getMembers()): + attrs = dict(id=member) + cased = mlist.getMemberCPAddress(member) + if cased <> member: + attrs['original'] = cased + self._push_element('member', **attrs) + self._element('realname', mlist.getMemberName(member)) + self._element('password', mlist.getMemberPassword(member)) + self._element('language', mlist.getMemberLanguage(member)) + # Delivery status, combined with the type of delivery + attrs = {} + status = mlist.getDeliveryStatus(member) + if status == MemberAdaptor.ENABLED: + attrs['status'] = 'enabled' + else: + attrs['status'] = 'disabled' + attrs['reason'] = {MemberAdaptor.BYUSER : 'byuser', + MemberAdaptor.BYADMIN : 'byadmin', + MemberAdaptor.BYBOUNCE : 'bybounce', + }.get(mlist.getDeliveryStatus(member), + 'unknown') + if member in digesters: + if mlist.getMemberOption(member, Defaults.DisableMime): + attrs['delivery'] = 'plain' + else: + attrs['delivery'] = 'mime' + else: + attrs['delivery'] = 'regular' + changed = mlist.getDeliveryStatusChangeTime(member) + if changed: + when = datetime.datetime.fromtimestamp(changed) + attrs['changed'] = when.isoformat() + self._element('delivery', **attrs) + for option, flag in Defaults.OPTINFO.items(): + # Digest/Regular delivery flag must be handled separately + if option in ('digest', 'plain'): + continue + value = mlist.getMemberOption(member, flag) + self._element(option, value) + topics = mlist.getMemberTopics(member) + if not topics: + self._element('topics') + else: + self._push_element('topics') + for topic in topics: + self._element('topic', topic) + self._pop_element('topics') + self._pop_element('member') + self._pop_element('roster') + self._pop_element('list') + + def dump(self, listnames): + print >> self._fp, '' + self._push_element('mailman', **{ + 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', + 'xsi:noNamespaceSchemaLocation': 'ssi-1.0.xsd', + }) + for listname in sorted(listnames): + try: + mlist = MailList(listname, lock=False) + except errors.MMUnknownListError: + print >> sys.stderr, _('No such list: $listname') + continue + self._dump_list(mlist) + self._pop_element('mailman') + + def close(self): + while self._stack: + self._pop_element() + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] + +Export the configuration and members of a mailing list in XML format.""")) + parser.add_option('-o', '--outputfile', + metavar='FILENAME', default=None, type='string', + help=_("""\ +Output XML to FILENAME. If not given, or if FILENAME is '-', standard out is +used.""")) + parser.add_option('-l', '--listname', + default=[], action='append', type='string', + metavar='LISTNAME', dest='listnames', help=_("""\ +The list to include in the output. If not given, then all mailing lists are +included in the XML output. Multiple -l flags may be given.""")) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + if args: + parser.print_help() + parser.error(_('Unexpected arguments')) + return parser, opts, args + + + +def main(): + parser, opts, args = parseargs() + initialize(opts.config) + + close = False + if opts.outputfile in (None, '-'): + writer = codecs.getwriter('utf-8') + fp = writer(sys.stdout) + else: + fp = codecs.open(opts.outputfile, 'w', 'utf-8') + close = True + + try: + dumper = XMLDumper(fp) + if opts.listnames: + listnames = [] + for listname in opts.listnames: + if '@' not in listname: + listname = '%s@%s' % (listname, config.DEFAULT_EMAIL_HOST) + listnames.append(listname) + else: + listnames = config.list_manager.names + dumper.dump(listnames) + dumper.close() + finally: + if close: + fp.close() diff --git a/src/mailman/bin/find_member.py b/src/mailman/bin/find_member.py new file mode 100644 index 000000000..0982724a0 --- /dev/null +++ b/src/mailman/bin/find_member.py @@ -0,0 +1,135 @@ +# 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 . + +import re +import sys +import optparse + +from mailman import errors +from mailman import MailList +from mailman.configuration import config +from mailman.i18n import _ +from mailman.version import MAILMAN_VERSION + + +AS_MEMBER = 0x01 +AS_OWNER = 0x02 + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] regex [regex ...] + +Find all lists that a member's address is on. + +The interaction between -l and -x (see below) is as follows. If any -l option +is given then only the named list will be included in the search. If any -x +option is given but no -l option is given, then all lists will be search +except those specifically excluded. + +Regular expression syntax uses the Python 're' module. Complete +specifications are at: + +http://www.python.org/doc/current/lib/module-re.html + +Address matches are case-insensitive, but case-preserved addresses are +displayed.""")) + parser.add_option('-l', '--listname', + type='string', default=[], action='append', + dest='listnames', + help=_('Include only the named list in the search')) + parser.add_option('-x', '--exclude', + type='string', default=[], action='append', + dest='excludes', + help=_('Exclude the named list from the search')) + parser.add_option('-w', '--owners', + default=False, action='store_true', + help=_('Search list owners as well as members')) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + if not args: + parser.print_help() + print >> sys.stderr, _('Search regular expression required') + sys.exit(1) + return parser, opts, args + + + +def main(): + parser, opts, args = parseargs() + config.load(opts.config) + + listnames = opts.listnames or config.list_manager.names + includes = set(listname.lower() for listname in listnames) + excludes = set(listname.lower() for listname in opts.excludes) + listnames = includes - excludes + + if not listnames: + print _('No lists to search') + return + + cres = [] + for r in args: + cres.append(re.compile(r, re.IGNORECASE)) + # dictionary of {address, (listname, ownerp)} + matches = {} + for listname in listnames: + try: + mlist = MailList.MailList(listname, lock=False) + except errors.MMListError: + print _('No such list: $listname') + continue + if opts.owners: + owners = mlist.owner + else: + owners = [] + for cre in cres: + for member in mlist.getMembers(): + if cre.search(member): + addr = mlist.getMemberCPAddress(member) + entries = matches.get(addr, {}) + aswhat = entries.get(listname, 0) + aswhat |= AS_MEMBER + entries[listname] = aswhat + matches[addr] = entries + for owner in owners: + if cre.search(owner): + entries = matches.get(owner, {}) + aswhat = entries.get(listname, 0) + aswhat |= AS_OWNER + entries[listname] = aswhat + matches[owner] = entries + addrs = matches.keys() + addrs.sort() + for k in addrs: + hits = matches[k] + lists = hits.keys() + print k, _('found in:') + for name in lists: + aswhat = hits[name] + if aswhat & AS_MEMBER: + print ' ', name + if aswhat & AS_OWNER: + print ' ', name, _('(as owner)') + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/gate_news.py b/src/mailman/bin/gate_news.py new file mode 100644 index 000000000..eac30422d --- /dev/null +++ b/src/mailman/bin/gate_news.py @@ -0,0 +1,243 @@ +# 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 . + +import os +import sys +import time +import socket +import logging +import nntplib +import optparse +import email.Errors + +from email.Parser import Parser +from locknix import lockfile + +from mailman import MailList +from mailman import Message +from mailman import Utils +from mailman import loginit +from mailman.configuration import config +from mailman.i18n import _ +from mailman.queue import Switchboard +from mailman.version import MAILMAN_VERSION + +# Work around known problems with some RedHat cron daemons +import signal +signal.signal(signal.SIGCHLD, signal.SIG_DFL) + +NL = '\n' + +log = None + +class _ContinueLoop(Exception): + pass + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] + +Poll the NNTP servers for messages to be gatewayed to mailing lists.""")) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + if args: + parser.print_help() + print >> sys.stderr, _('Unexpected arguments') + sys.exit(1) + return opts, args, parser + + + +_hostcache = {} + +def open_newsgroup(mlist): + # Split host:port if given + nntp_host, nntp_port = Utils.nntpsplit(mlist.nntp_host) + # Open up a "mode reader" connection to nntp server. This will be shared + # for all the gated lists having the same nntp_host. + conn = _hostcache.get(mlist.nntp_host) + if conn is None: + try: + conn = nntplib.NNTP(nntp_host, nntp_port, + readermode=True, + user=config.NNTP_USERNAME, + password=config.NNTP_PASSWORD) + except (socket.error, nntplib.NNTPError, IOError), e: + log.error('error opening connection to nntp_host: %s\n%s', + mlist.nntp_host, e) + raise + _hostcache[mlist.nntp_host] = conn + # Get the GROUP information for the list, but we're only really interested + # in the first article number and the last article number + r, c, f, l, n = conn.group(mlist.linked_newsgroup) + return conn, int(f), int(l) + + +def clearcache(): + for conn in set(_hostcache.values()): + conn.quit() + _hostcache.clear() + + + +# This function requires the list to be locked. +def poll_newsgroup(mlist, conn, first, last, glock): + listname = mlist.internal_name() + # NEWNEWS is not portable and has synchronization issues. + for num in range(first, last): + glock.refresh() + try: + headers = conn.head(repr(num))[3] + found_to = False + beenthere = False + for header in headers: + i = header.find(':') + value = header[:i].lower() + if i > 0 and value == 'to': + found_to = True + if value <> 'x-beenthere': + continue + if header[i:] == ': %s' % mlist.posting_address: + beenthere = True + break + if not beenthere: + body = conn.body(repr(num))[3] + # Usenet originated messages will not have a Unix envelope + # (i.e. "From " header). This breaks Pipermail archiving, so + # we will synthesize one. Be sure to use the format searched + # for by mailbox.UnixMailbox._isrealfromline(). BAW: We use + # the -bounces address here in case any downstream clients use + # the envelope sender for bounces; I'm not sure about this, + # but it's the closest to the old semantics. + lines = ['From %s %s' % (mlist.GetBouncesEmail(), + time.ctime(time.time()))] + lines.extend(headers) + lines.append('') + lines.extend(body) + lines.append('') + p = Parser(Message.Message) + try: + msg = p.parsestr(NL.join(lines)) + except email.Errors.MessageError, e: + log.error('email package exception for %s:%d\n%s', + mlist.linked_newsgroup, num, e) + raise _ContinueLoop + if found_to: + del msg['X-Originally-To'] + msg['X-Originally-To'] = msg['To'] + del msg['To'] + msg['To'] = mlist.posting_address + # Post the message to the locked list + inq = Switchboard(config.INQUEUE_DIR) + inq.enqueue(msg, + listname=mlist.internal_name(), + fromusenet=True) + log.info('posted to list %s: %7d', listname, num) + except nntplib.NNTPError, e: + log.exception('NNTP error for list %s: %7d', listname, num) + except _ContinueLoop: + continue + # Even if we don't post the message because it was seen on the + # list already, update the watermark + mlist.usenet_watermark = num + + + +def process_lists(glock): + for listname in config.list_manager.names: + glock.refresh() + # Open the list unlocked just to check to see if it is gating news to + # mail. If not, we're done with the list. Otherwise, lock the list + # and gate the group. + mlist = MailList.MailList(listname, lock=False) + if not mlist.gateway_to_mail: + continue + # Get the list's watermark, i.e. the last article number that we gated + # from news to mail. None means that this list has never polled its + # newsgroup and that we should do a catch up. + watermark = getattr(mlist, 'usenet_watermark', None) + # Open the newsgroup, but let most exceptions percolate up. + try: + conn, first, last = open_newsgroup(mlist) + except (socket.error, nntplib.NNTPError): + break + log.info('%s: [%d..%d]', listname, first, last) + try: + try: + if watermark is None: + mlist.Lock(timeout=config.LIST_LOCK_TIMEOUT) + # This is the first time we've tried to gate this + # newsgroup. We essentially do a mass catch-up, otherwise + # we'd flood the mailing list. + mlist.usenet_watermark = last + log.info('%s caught up to article %d', listname, last) + else: + # The list has been polled previously, so now we simply + # grab all the messages on the newsgroup that have not + # been seen by the mailing list. The first such article + # is the maximum of the lowest article available in the + # newsgroup and the watermark. It's possible that some + # articles have been expired since the last time gate_news + # has run. Not much we can do about that. + start = max(watermark + 1, first) + if start > last: + log.info('nothing new for list %s', listname) + else: + mlist.Lock(timeout=config.LIST_LOCK_TIMEOUT) + log.info('gating %s articles [%d..%d]', + listname, start, last) + # Use last+1 because poll_newsgroup() employes a for + # loop over range, and this will not include the last + # element in the list. + poll_newsgroup(mlist, conn, start, last + 1, glock) + except lockfile.TimeOutError: + log.error('Could not acquire list lock: %s', listname) + finally: + if mlist.Locked(): + mlist.Save() + mlist.Unlock() + log.info('%s watermark: %d', listname, mlist.usenet_watermark) + + + +def main(): + opts, args, parser = parseargs() + config.load(opts.config) + + GATENEWS_LOCK_FILE = os.path.join(config.LOCK_DIR, 'gate_news.lock') + LOCK_LIFETIME = config.hours(2) + + loginit.initialize(propagate=True) + log = logging.getLogger('mailman.fromusenet') + + try: + with lockfile.Lock(GATENEWS_LOCK_FILE, + # It's okay to hijack this + lifetime=LOCK_LIFETIME) as lock: + process_lists(lock) + clearcache() + except lockfile.TimeOutError: + log.error('Could not acquire gate_news lock') + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/genaliases.py b/src/mailman/bin/genaliases.py new file mode 100644 index 000000000..e8916d030 --- /dev/null +++ b/src/mailman/bin/genaliases.py @@ -0,0 +1,64 @@ +# 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 . + +__metaclass__ = type +__all__ = [ + 'main', + ] + + +import sys + +from mailman.config import config +from mailman.i18n import _ +from mailman.options import Options + + + +class ScriptOptions(Options): + """Options for the genaliases script.""" + + usage = _("""\ +%prog [options] + +Regenerate the Mailman specific MTA aliases from scratch. The actual output +depends on the value of the 'MTA' variable in your etc/mailman.cfg file.""") + + def add_options(self): + super(ScriptOptions, self).add_options() + self.parser.add_option( + '-q', '--quiet', + default=False, action='store_true', help=_("""\ +Some MTA output can include more verbose help text. Use this to tone down the +verbosity.""")) + + + + +def main(): + options = ScriptOptions() + options.initialize() + + # Get the MTA-specific module. + module_path, class_path = config.mta.incoming.rsplit('.', 1) + __import__(module_path) + getattr(sys.modules[module_path], class_path)().regenerate() + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/import.py b/src/mailman/bin/import.py new file mode 100644 index 000000000..d2361e808 --- /dev/null +++ b/src/mailman/bin/import.py @@ -0,0 +1,315 @@ +# 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 . + +"""Import the XML representation of a mailing list.""" + +import sys +import codecs +import optparse +import traceback + +from xml.dom import minidom +from xml.parsers.expat import ExpatError + +from mailman import Defaults +from mailman import errors +from mailman import MemberAdaptor +from mailman import Utils +from mailman import passwords +from mailman.MailList import MailList +from mailman.i18n import _ +from mailman.initialize import initialize +from mailman.version import MAILMAN_VERSION + + +OPTS = None + + + +def nodetext(node): + # Expect only one TEXT_NODE in the list of children + for child in node.childNodes: + if child.nodeType == node.TEXT_NODE: + return child.data + return u'' + + +def nodegen(node, *elements): + for child in node.childNodes: + if child.nodeType <> minidom.Node.ELEMENT_NODE: + continue + if elements and child.tagName not in elements: + print _('Ignoring unexpected element: $node.tagName') + else: + yield child + + + +def parse_config(node): + config = dict() + for child in nodegen(node, 'option'): + name = child.getAttribute('name') + if not name: + print _('Skipping unnamed option') + continue + vtype = child.getAttribute('type') or 'string' + if vtype in ('email_list', 'email_list_ex', 'checkbox'): + value = [] + for subnode in nodegen(child): + value.append(nodetext(subnode)) + elif vtype == 'bool': + value = nodetext(child) + try: + value = bool(int(value)) + except ValueError: + value = {'true' : True, + 'false': False, + }.get(value.lower()) + if value is None: + print _('Skipping bad boolean value: $value') + continue + elif vtype == 'radio': + value = nodetext(child).lower() + boolval = {'true' : True, + 'false': False, + }.get(value) + if boolval is None: + value = int(value) + else: + value = boolval + elif vtype == 'number': + value = nodetext(child) + # First try int then float + try: + value = int(value) + except ValueError: + value = float(value) + elif vtype in ('header_filter', 'topics'): + value = [] + fakebltins = dict(__builtins__ = dict(True=True, False=False)) + for subnode in nodegen(child): + reprstr = nodetext(subnode) + # Turn the reprs back into tuples, in a safe way + tupleval = eval(reprstr, fakebltins) + value.append(tupleval) + else: + value = nodetext(child) + # And now some special casing :( + if name == 'new_member_options': + value = int(nodetext(child)) + config[name] = value + return config + + + + +def parse_roster(node): + members = [] + for child in nodegen(node, 'member'): + member = dict() + member['id'] = mid = child.getAttribute('id') + if not mid: + print _('Skipping member with no id') + continue + if OPTS.verbose: + print _('* Processing member: $mid') + for subnode in nodegen(child): + attr = subnode.tagName + if attr == 'delivery': + value = (subnode.getAttribute('status'), + subnode.getAttribute('delivery')) + elif attr in ('hide', 'ack', 'notmetoo', 'nodupes', 'nomail'): + value = {'true' : True, + 'false': False, + }.get(nodetext(subnode).lower(), False) + elif attr == 'topics': + value = [] + for subsubnode in nodegen(subnode): + value.append(nodetext(subsubnode)) + elif attr == 'password': + value = nodetext(subnode) + if OPTS.reset_passwords or value == '{NONE}' or not value: + value = passwords.make_secret(Utils.MakeRandomPassword()) + else: + value = nodetext(subnode) + member[attr] = value + members.append(member) + return members + + + +def load(fp): + try: + doc = minidom.parse(fp) + except ExpatError: + print _('Expat error in file: $fp.name') + traceback.print_exc() + sys.exit(1) + doc.normalize() + # Make sure there's only one top-level node + gen = nodegen(doc, 'mailman') + top = gen.next() + try: + gen.next() + except StopIteration: + pass + else: + print _('Malformed XML; duplicate nodes') + sys.exit(1) + all_listdata = [] + for listnode in nodegen(top, 'list'): + listdata = dict() + name = listnode.getAttribute('name') + if OPTS.verbose: + print _('Processing list: $name') + if not name: + print _('Ignoring malformed node') + continue + for child in nodegen(listnode, 'configuration', 'roster'): + if child.tagName == 'configuration': + list_config = parse_config(child) + else: + assert(child.tagName == 'roster') + list_roster = parse_roster(child) + all_listdata.append((name, list_config, list_roster)) + return all_listdata + + + +def create(all_listdata): + for name, list_config, list_roster in all_listdata: + fqdn_listname = '%s@%s' % (name, list_config['host_name']) + if Utils.list_exists(fqdn_listname): + print _('Skipping already existing list: $fqdn_listname') + continue + mlist = MailList() + try: + if OPTS.verbose: + print _('Creating mailing list: $fqdn_listname') + mlist.Create(fqdn_listname, list_config['owner'][0], + list_config['password']) + except errors.BadDomainSpecificationError: + print _('List is not in a supported domain: $fqdn_listname') + continue + # Save the list creation, then unlock and relock the list. This is so + # that we use normal SQLAlchemy transactions to manage all the + # attribute and membership updates. Without this, no transaction will + # get committed in the second Save() below and we'll lose all our + # updates. + mlist.Save() + mlist.Unlock() + mlist.Lock() + try: + for option, value in list_config.items(): + # XXX Here's what sucks. Some properties need to have + # _setValue() called on the gui component, because those + # methods do some pre-processing on the values before they're + # applied to the MailList instance. But we don't have a good + # way to find a category and sub-category that a particular + # property belongs to. Plus this will probably change. So + # for now, we'll just hard code the extra post-processing + # here. The good news is that not all _setValue() munging + # needs to be done -- for example, we've already converted + # everything to dollar strings. + if option in ('filter_mime_types', 'pass_mime_types', + 'filter_filename_extensions', + 'pass_filename_extensions'): + value = value.splitlines() + if option == 'available_languages': + mlist.set_languages(*value) + else: + setattr(mlist, option, value) + for member in list_roster: + mid = member['id'] + if OPTS.verbose: + print _('* Adding member: $mid') + status, delivery = member['delivery'] + kws = {'password' : member['password'], + 'language' : member['language'], + 'realname' : member['realname'], + 'digest' : delivery <> 'regular', + } + mlist.addNewMember(mid, **kws) + status = {'enabled' : MemberAdaptor.ENABLED, + 'byuser' : MemberAdaptor.BYUSER, + 'byadmin' : MemberAdaptor.BYADMIN, + 'bybounce' : MemberAdaptor.BYBOUNCE, + }.get(status, MemberAdaptor.UNKNOWN) + mlist.setDeliveryStatus(mid, status) + for opt in ('hide', 'ack', 'notmetoo', 'nodupes', 'nomail'): + mlist.setMemberOption(mid, + Defaults.OPTINFO[opt], + member[opt]) + topics = member.get('topics') + if topics: + mlist.setMemberTopics(mid, topics) + mlist.Save() + finally: + mlist.Unlock() + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] + +Import the configuration and/or members of a mailing list in XML format. The +imported mailing list must not already exist. All mailing lists named in the +XML file are imported, but those that already exist are skipped unless --error +is given.""")) + parser.add_option('-i', '--inputfile', + metavar='FILENAME', default=None, type='string', + help=_("""\ +Input XML from FILENAME. If not given, or if FILENAME is '-', standard input +is used.""")) + parser.add_option('-p', '--reset-passwords', + default=False, action='store_true', help=_("""\ +With this option, user passwords in the XML are ignored and are reset to a +random password. If the generated passwords were not included in the input +XML, they will always be randomly generated.""")) + parser.add_option('-v', '--verbose', + default=False, action='store_true', + help=_('Produce more verbose output')) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + if args: + parser.print_help() + parser.error(_('Unexpected arguments')) + return parser, opts, args + + + +def main(): + global OPTS + + parser, opts, args = parseargs() + initialize(opts.config) + OPTS = opts + + if opts.inputfile in (None, '-'): + fp = sys.stdin + else: + fp = open(opts.inputfile, 'r') + + try: + listbags = load(fp) + create(listbags) + finally: + if fp is not sys.stdin: + fp.close() diff --git a/src/mailman/bin/inject.py b/src/mailman/bin/inject.py new file mode 100644 index 000000000..2bc8a49e3 --- /dev/null +++ b/src/mailman/bin/inject.py @@ -0,0 +1,89 @@ +# 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 . + +import os +import sys + +from email import message_from_string + +from mailman import Utils +from mailman.Message import Message +from mailman.configuration import config +from mailman.i18n import _ +from mailman.inject import inject_text +from mailman.options import SingleMailingListOptions + + + +class ScriptOptions(SingleMailingListOptions): + usage=_("""\ +%prog [options] [filename] + +Inject a message from a file into Mailman's incoming queue. 'filename' is the +name of the plaintext message file to inject. If omitted, or the string '-', +standard input is used. +""") + + def add_options(self): + super(ScriptOptions, self).add_options() + self.parser.add_option( + '-q', '--queue', + type='string', help=_("""\ +The name of the queue to inject the message to. The queuename must be one of +the directories inside the qfiles directory. If omitted, the incoming queue +is used.""")) + + def sanity_check(self): + if not self.options.listname: + self.parser.error(_('Missing listname')) + if len(self.arguments) == 0: + self.filename = '-' + elif len(self.arguments) > 1: + self.parser.print_error(_('Unexpected arguments')) + else: + self.filename = self.arguments[0] + + + +def main(): + options = ScriptOptions() + options.initialize() + + if options.options.queue is None: + qdir = config.INQUEUE_DIR + else: + qdir = os.path.join(config.QUEUE_DIR, options.options.queue) + if not os.path.isdir(qdir): + options.parser.error(_('Bad queue directory: $qdir')) + + fqdn_listname = options.options.listname + mlist = config.db.list_manager.get(fqdn_listname) + if mlist is None: + options.parser.error(_('No such list: $fqdn_listname')) + + if options.filename == '-': + message_text = sys.stdin.read() + else: + with open(options.filename) as fp: + message_text = fp.read() + + inject_text(mlist, message_text, qdir=qdir) + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/list_lists.py b/src/mailman/bin/list_lists.py new file mode 100644 index 000000000..ea1640910 --- /dev/null +++ b/src/mailman/bin/list_lists.py @@ -0,0 +1,104 @@ +# 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 . + +from mailman.config import config +from mailman.i18n import _ +from mailman.options import Options + + + +class ScriptOptions(Options): + usage = _("""\ +%prog [options] + +List all mailing lists.""") + + def add_options(self): + super(ScriptOptions, self).add_options() + self.parser.add_option( + '-a', '--advertised', + default=False, action='store_true', + help=_("""\ +List only those mailing lists that are publicly advertised""")) + self.parser.add_option( + '-b', '--bare', + default=False, action='store_true', + help=_("""\ +Displays only the list name, with no description.""")) + self.parser.add_option( + '-d', '--domain', + default=[], type='string', action='append', + dest='domains', help=_("""\ +List only those mailing lists that match the given virtual domain, which may +be either the email host or the url host name. Multiple -d options may be +given.""")) + self.parser.add_option( + '-f', '--full', + default=False, action='store_true', + help=_("""\ +Print the full list name, including the posting address.""")) + + def sanity_check(self): + if len(self.arguments) > 0: + self.parser.error(_('Unexpected arguments')) + + + +def main(): + options = ScriptOptions() + options.initialize() + + mlists = [] + longest = 0 + + listmgr = config.db.list_manager + for fqdn_name in sorted(listmgr.names): + mlist = listmgr.get(fqdn_name) + if options.options.advertised and not mlist.advertised: + continue + if options.options.domains: + for domain in options.options.domains: + if domain in mlist.web_page_url or domain == mlist.host_name: + mlists.append(mlist) + break + else: + mlists.append(mlist) + if options.options.full: + name = mlist.fqdn_listname + else: + name = mlist.real_name + longest = max(len(name), longest) + + if not mlists and not options.options.bare: + print _('No matching mailing lists found') + return + + if not options.options.bare: + num_mlists = len(mlists) + print _('$num_mlists matching mailing lists found:') + + format = '%%%ds - %%.%ds' % (longest, 77 - longest) + for mlist in mlists: + if options.options.full: + name = mlist.fqdn_listname + else: + name = mlist.real_name + if options.options.bare: + print name + else: + description = mlist.description or _('[no description available]') + print ' ', format % (name, description) diff --git a/src/mailman/bin/list_members.py b/src/mailman/bin/list_members.py new file mode 100644 index 000000000..443f764d6 --- /dev/null +++ b/src/mailman/bin/list_members.py @@ -0,0 +1,201 @@ +# 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 . + +import sys + +from email.Utils import formataddr + +from mailman import Utils +from mailman.config import config +from mailman.core import errors +from mailman.i18n import _ +from mailman.interfaces import DeliveryStatus +from mailman.options import SingleMailingListOptions + + +COMMASPACE = ', ' + +WHYCHOICES = { + 'enabled' : DeliveryStatus.enabled, + 'byuser' : DeliveryStatus.by_user, + 'byadmin' : DeliveryStatus.by_moderator, + 'bybounce': DeliveryStatus.by_bounces, + } + +KINDCHOICES = set(('mime', 'plain', 'any')) + + + +class ScriptOptions(SingleMailingListOptions): + usage = _("""\ +%prog [options] + +List all the members of a mailing list. Note that with the options below, if +neither -r or -d is supplied, regular members are printed first, followed by +digest members, but no indication is given as to address status. + +listname is the name of the mailing list to use.""") + + def add_options(self): + super(ScriptOptions, self).add_options() + self.parser.add_option( + '-o', '--output', + type='string', help=_("""\ +Write output to specified file instead of standard out.""")) + self.parser.add_option( + '-r', '--regular', + default=None, action='store_true', + help=_('Print just the regular (non-digest) members.')) + self.parser.add_option( + '-d', '--digest', + default=None, type='string', metavar='KIND', + help=_("""\ +Print just the digest members. KIND can be 'mime', 'plain', or +'any'. 'mime' prints just the members receiving MIME digests, while 'plain' +prints just the members receiving plain text digests. 'any' prints all +members receiving any kind of digest.""")) + self.parser.add_option( + '-n', '--nomail', + type='string', metavar='WHY', help=_("""\ +Print the members that have delivery disabled. WHY selects just the subset of +members with delivery disabled for a particular reason, where 'any' prints all +disabled members. 'byadmin', 'byuser', 'bybounce', and 'unknown' prints just +the users who are disabled for that particular reason. WHY can also be +'enabled' which prints just those members for whom delivery is enabled.""")) + self.parser.add_option( + '-f', '--fullnames', + default=False, action='store_true', + help=_('Include the full names in the output')) + self.parser.add_option( + '-i', '--invalid', + default=False, action='store_true', help=_("""\ +Print only the addresses in the membership list that are invalid. Ignores -r, +-d, -n.""")) + + def sanity_check(self): + if not self.options.listname: + self.parser.error(_('Missing listname')) + if len(self.arguments) > 0: + self.parser.print_error(_('Unexpected arguments')) + if self.options.digest is not None: + self.options.kind = self.options.digest.lower() + if self.options.kind not in KINDCHOICES: + self.parser.error( + _('Invalid value for -d: $self.options.digest')) + if self.options.nomail is not None: + why = self.options.nomail.lower() + if why == 'any': + self.options.why = 'any' + elif why not in WHYCHOICES: + self.parser.error( + _('Invalid value for -n: $self.options.nomail')) + self.options.why = why + if self.options.regular is None and self.options.digest is None: + self.options.regular = self.options.digest = True + self.options.kind = 'any' + + + +def safe(string): + if not string: + return '' + return string.encode(sys.getdefaultencoding(), 'replace') + + +def isinvalid(addr): + try: + Utils.ValidateEmail(addr) + return False + except errors.EmailAddressError: + return True + + + +def whymatches(mlist, addr, why): + # Return true if the `why' matches the reason the address is enabled, or + # in the case of why is None, that they are disabled for any reason + # (i.e. not enabled). + status = mlist.getDeliveryStatus(addr) + if why in (None, 'any'): + return status <> DeliveryStatus.enabled + return status == WHYCHOICES[why] + + + +def main(): + options = ScriptOptions() + options.initialize() + + fqdn_listname = options.options.listname + if options.options.output: + try: + fp = open(options.output, 'w') + except IOError: + options.parser.error( + _('Could not open file for writing: $options.options.output')) + else: + fp = sys.stdout + + mlist = config.db.list_manager.get(fqdn_listname) + if mlist is None: + options.parser.error(_('No such list: $fqdn_listname')) + + # The regular delivery and digest members. + rmembers = set(mlist.regular_members.members) + dmembers = set(mlist.digest_members.members) + + fullnames = options.options.fullnames + if options.options.invalid: + all = sorted(member.address.address for member in rmembers + dmembers) + for address in all: + user = config.db.user_manager.get_user(address) + name = (user.real_name if fullnames and user else u'') + if options.options.invalid and isinvalid(address): + print >> fp, formataddr((safe(name), address)) + return + if options.options.regular: + for address in sorted(member.address.address for member in rmembers): + user = config.db.user_manager.get_user(address) + name = (user.real_name if fullnames and user else u'') + # Filter out nomails + if (options.options.nomail and + not whymatches(mlist, address, options.options.why)): + continue + print >> fp, formataddr((safe(name), address)) + if options.options.digest: + for address in sorted(member.address.address for member in dmembers): + user = config.db.user_manager.get_user(address) + name = (user.real_name if fullnames and user else u'') + # Filter out nomails + if (options.options.nomail and + not whymatches(mlist, address, options.options.why)): + continue + # Filter out digest kinds +## if mlist.getMemberOption(addr, config.DisableMime): +## # They're getting plain text digests +## if opts.kind == 'mime': +## continue +## else: +## # They're getting MIME digests +## if opts.kind == 'plain': +## continue + print >> fp, formataddr((safe(name), address)) + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/list_owners.py b/src/mailman/bin/list_owners.py new file mode 100644 index 000000000..953fb8941 --- /dev/null +++ b/src/mailman/bin/list_owners.py @@ -0,0 +1,88 @@ +# 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 . + +import sys +import optparse + +from mailman.MailList import MailList +from mailman.configuration import config +from mailman.i18n import _ +from mailman.initialize import initialize +from mailman.version import MAILMAN_VERSION + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] [listname ...] + +List the owners of a mailing list, or all mailing lists if no list names are +given.""")) + parser.add_option('-w', '--with-listnames', + default=False, action='store_true', + help=_("""\ +Group the owners by list names and include the list names in the output. +Otherwise, the owners will be sorted and uniquified based on the email +address.""")) + parser.add_option('-m', '--moderators', + default=False, action='store_true', + help=_('Include the list moderators in the output.')) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + return parser, opts, args + + + +def main(): + parser, opts, args = parseargs() + initialize(opts.config) + + listmgr = config.db.list_manager + listnames = set(args or listmgr.names) + bylist = {} + + for listname in listnames: + mlist = listmgr.get(listname) + addrs = [addr.address for addr in mlist.owners.addresses] + if opts.moderators: + addrs.extend([addr.address for addr in mlist.moderators.addresses]) + bylist[listname] = addrs + + if opts.with_listnames: + for listname in listnames: + unique = set() + for addr in bylist[listname]: + unique.add(addr) + keys = list(unique) + keys.sort() + print listname + for k in keys: + print '\t', k + else: + unique = set() + for listname in listnames: + for addr in bylist[listname]: + unique.add(addr) + for k in sorted(unique): + print k + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/mailmanctl.py b/src/mailman/bin/mailmanctl.py new file mode 100644 index 000000000..667a46a70 --- /dev/null +++ b/src/mailman/bin/mailmanctl.py @@ -0,0 +1,232 @@ +# 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 . + +"""Mailman start/stop script.""" + +import os +import grp +import pwd +import sys +import errno +import signal +import logging + +from optparse import OptionParser + +from mailman.config import config +from mailman.core.initialize import initialize +from mailman.i18n import _ +from mailman.version import MAILMAN_VERSION + + +COMMASPACE = ', ' + +log = None +parser = None + + + +def parseargs(): + parser = OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +Primary start-up and shutdown script for Mailman's qrunner daemon. + +This script starts, stops, and restarts the main Mailman queue runners, making +sure that the various long-running qrunners are still alive and kicking. It +does this by forking and exec'ing the qrunners and waiting on their pids. +When it detects a subprocess has exited, it may restart it. + +The qrunners respond to SIGINT, SIGTERM, SIGUSR1 and SIGHUP. SIGINT, SIGTERM +and SIGUSR1 all cause the qrunners to exit cleanly, but the master will only +restart qrunners that have exited due to a SIGUSR1. SIGHUP causes the master +and the qrunners to close their log files, and reopen then upon the next +printed message. + +The master also responds to SIGINT, SIGTERM, SIGUSR1 and SIGHUP, which it +simply passes on to the qrunners (note that the master will close and reopen +its own log files on receipt of a SIGHUP). The master also leaves its own +process id in the file data/master-qrunner.pid but you normally don't need to +use this pid directly. The `start', `stop', `restart', and `reopen' commands +handle everything for you. + +Commands: + + start - Start the master daemon and all qrunners. Prints a message and + exits if the master daemon is already running. + + stop - Stops the master daemon and all qrunners. After stopping, no + more messages will be processed. + + restart - Restarts the qrunners, but not the master process. Use this + whenever you upgrade or update Mailman so that the qrunners will + use the newly installed code. + + reopen - This will close all log files, causing them to be re-opened the + next time a message is written to them + +Usage: %prog [options] [ start | stop | restart | reopen ]""")) + parser.add_option('-u', '--run-as-user', + default=True, action='store_false', + help=_("""\ +Normally, this script will refuse to run if the user id and group id are not +set to the `mailman' user and group (as defined when you configured Mailman). +If run as root, this script will change to this user and group before the +check is made. + +This can be inconvenient for testing and debugging purposes, so the -u flag +means that the step that sets and checks the uid/gid is skipped, and the +program is run as the current user and group. This flag is not recommended +for normal production environments. + +Note though, that if you run with -u and are not in the mailman group, you may +have permission problems, such as begin unable to delete a list's archives +through the web. Tough luck!""")) + parser.add_option('-f', '--force', + default=False, action='store_true', + help=_("""\ +If the master watcher finds an existing master lock, it will normally exit +with an error message. With this option,the master will perform an extra +level of checking. If a process matching the host/pid described in the lock +file is running, the master will still exit, requiring you to manually clean +up the lock. But if no matching process is found, the master will remove the +apparently stale lock and make another attempt to claim the master lock.""")) + parser.add_option('-q', '--quiet', + default=False, action='store_true', + help=_("""\ +Don't print status messages. Error messages are still printed to standard +error.""")) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + options, arguments = parser.parse_args() + if not arguments: + parser.error(_('No command given.')) + if len(arguments) > 1: + commands = COMMASPACE.join(arguments) + parser.error(_('Bad command: $commands')) + parser.options = options + parser.arguments = arguments + return parser + + + +def kill_watcher(sig): + try: + with open(config.PIDFILE) as f: + pid = int(f.read().strip()) + except (IOError, ValueError), e: + # For i18n convenience + print >> sys.stderr, _('PID unreadable in: $config.PIDFILE') + print >> sys.stderr, e + print >> sys.stderr, _('Is qrunner even running?') + return + try: + os.kill(pid, sig) + except OSError, error: + if e.errno <> errno.ESRCH: + raise + print >> sys.stderr, _('No child with pid: $pid') + print >> sys.stderr, e + print >> sys.stderr, _('Stale pid file removed.') + os.unlink(config.PIDFILE) + + + +def check_privileges(): + # If we're running as root (uid == 0), coerce the uid and gid to that + # which Mailman was configured for, and refuse to run if we didn't coerce + # the uid/gid. + gid = grp.getgrnam(config.MAILMAN_GROUP).gr_gid + uid = pwd.getpwnam(config.MAILMAN_USER).pw_uid + myuid = os.getuid() + if myuid == 0: + # Set the process's supplimental groups. + groups = [group.gr_gid for group in grp.getgrall() + if config.MAILMAN_USER in group.gr_mem] + groups.append(gid) + os.setgroups(groups) + os.setgid(gid) + os.setuid(uid) + elif myuid <> uid: + name = config.MAILMAN_USER + parser.error( + _('Run this program as root or as the $name user, or use -u.')) + + + +def main(): + global log, parser + + parser = parseargs() + initialize(parser.options.config) + + log = logging.getLogger('mailman.qrunner') + + if not parser.options.run_as_user: + check_privileges() + else: + if not parser.options.quiet: + print _('Warning! You may encounter permission problems.') + + # Handle the commands + command = parser.arguments[0].lower() + if command == 'stop': + if not parser.options.quiet: + print _("Shutting down Mailman's master qrunner") + kill_watcher(signal.SIGTERM) + elif command == 'restart': + if not parser.options.quiet: + print _("Restarting Mailman's master qrunner") + kill_watcher(signal.SIGUSR1) + elif command == 'reopen': + if not parser.options.quiet: + print _('Re-opening all log files') + kill_watcher(signal.SIGHUP) + elif command == 'start': + # Start the master qrunner watcher process. + # + # Daemon process startup according to Stevens, Advanced Programming in + # the UNIX Environment, Chapter 13. + pid = os.fork() + if pid: + # parent + if not parser.options.quiet: + print _("Starting Mailman's master qrunner.") + return + # child + # + # Create a new session and become the session leader, but since we + # won't be opening any terminal devices, don't do the ultra-paranoid + # suggestion of doing a second fork after the setsid() call. + os.setsid() + # Instead of cd'ing to root, cd to the Mailman runtime directory. + os.chdir(config.VAR_DIR) + # Exec the master watcher. + args = [sys.executable, sys.executable, + os.path.join(config.BIN_DIR, 'master')] + if parser.options.force: + args.append('--force') + if parser.options.config: + args.extend(['-C', parser.options.config]) + log.debug('starting: %s', args) + os.execl(*args) + # We should never get here. + raise RuntimeError('os.execl() failed') + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/master.py b/src/mailman/bin/master.py new file mode 100644 index 000000000..d954bc865 --- /dev/null +++ b/src/mailman/bin/master.py @@ -0,0 +1,452 @@ +# 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 . + +"""Master sub-process watcher.""" + +__metaclass__ = type +__all__ = [ + 'Loop', + 'get_lock_data', + ] + + +import os +import sys +import errno +import signal +import socket +import logging + +from datetime import timedelta +from lazr.config import as_boolean +from locknix import lockfile +from munepy import Enum + +from mailman.config import config +from mailman.core.logging import reopen +from mailman.i18n import _ +from mailman.options import Options + + +DOT = '.' +LOCK_LIFETIME = timedelta(days=1, hours=6) +SECONDS_IN_A_DAY = 86400 + + + +class ScriptOptions(Options): + """Options for the master watcher.""" + + usage = _("""\ +Master sub-process watcher. + +Start and watch the configured queue runners and ensure that they stay alive +and kicking. Each are fork and exec'd in turn, with the master waiting on +their process ids. When it detects a child queue runner has exited, it may +restart it. + +The queue runners respond to SIGINT, SIGTERM, SIGUSR1 and SIGHUP. SIGINT, +SIGTERM and SIGUSR1 all cause the qrunners to exit cleanly. The master will +restart qrunners that have exited due to a SIGUSR1 or some kind of other exit +condition (say because of an exception). SIGHUP causes the master and the +qrunners to close their log files, and reopen then upon the next printed +message. + +The master also responds to SIGINT, SIGTERM, SIGUSR1 and SIGHUP, which it +simply passes on to the qrunners. Note that the master will close and reopen +its own log files on receipt of a SIGHUP. The master also leaves its own +process id in the file `data/master-qrunner.pid` but you normally don't need +to use this pid directly. + +Usage: %prog [options]""") + + def add_options(self): + self.parser.add_option( + '-n', '--no-restart', + dest='restartable', default=True, action='store_false', + help=_("""\ +Don't restart the qrunners when they exit because of an error or a SIGUSR1. +Use this only for debugging.""")) + self.parser.add_option( + '-f', '--force', + default=False, action='store_true', + help=_("""\ +If the master watcher finds an existing master lock, it will normally exit +with an error message. With this option,the master will perform an extra +level of checking. If a process matching the host/pid described in the lock +file is running, the master will still exit, requiring you to manually clean +up the lock. But if no matching process is found, the master will remove the +apparently stale lock and make another attempt to claim the master lock.""")) + self.parser.add_option( + '-r', '--runner', + dest='runners', action='append', default=[], + help=_("""\ +Override the default set of queue runners that the master watch will invoke +instead of the default set. Multiple -r options may be given. The values for +-r are passed straight through to bin/qrunner.""")) + + def sanity_check(self): + if len(self.arguments) > 0: + self.parser.error(_('Too many arguments')) + + + +def get_lock_data(): + """Get information from the master lock file. + + :return: A 3-tuple of the hostname, integer process id, and file name of + the lock file. + """ + with open(config.LOCK_FILE) as fp: + filename = os.path.split(fp.read().strip())[1] + parts = filename.split('.') + hostname = DOT.join(parts[1:-2]) + pid = int(parts[-2]) + return hostname, int(pid), filename + + +class WatcherState(Enum): + # Another master watcher is running. + conflict = 1 + # No conflicting process exists. + stale_lock = 2 + # Hostname from lock file doesn't match. + host_mismatch = 3 + + +def master_state(): + """Get the state of the master watcher. + + :return: WatcherState describing the state of the lock file. + """ + + # 1 if proc exists on host (but is it qrunner? ;) + # 0 if host matches but no proc + # hostname if hostname doesn't match + hostname, pid, tempfile = get_lock_data() + if hostname <> socket.gethostname(): + return WatcherState.host_mismatch + # Find out if the process exists by calling kill with a signal 0. + try: + os.kill(pid, 0) + return WatcherState.conflict + except OSError, e: + if e.errno == errno.ESRCH: + # No matching process id. + return WatcherState.stale_lock + # Some other error occurred. + raise + + +def acquire_lock_1(force): + """Try to acquire the master queue runner lock. + + :param force: Flag that controls whether to force acquisition of the lock. + :return: The master queue runner lock. + :raises: `TimeOutError` if the lock could not be acquired. + """ + lock = lockfile.Lock(config.LOCK_FILE, LOCK_LIFETIME) + try: + lock.lock(timedelta(seconds=0.1)) + return lock + except lockfile.TimeOutError: + if not force: + raise + # Force removal of lock first. + lock.disown() + hostname, pid, tempfile = get_lock_data() + os.unlink(config.LOCK_FILE) + os.unlink(os.path.join(config.LOCK_DIR, tempfile)) + return acquire_lock_1(force=False) + + +def acquire_lock(force): + """Acquire the master queue runner lock. + + :return: The master queue runner lock or None if the lock couldn't be + acquired. In that case, an error messages is also printed to standard + error. + """ + try: + lock = acquire_lock_1(force) + return lock + except lockfile.TimeOutError: + status = master_state() + if status == WatcherState.conflict: + # Hostname matches and process exists. + message = _("""\ +The master qrunner lock could not be acquired because it appears +as though another master qrunner is already running. +""") + elif status == WatcherState.stale_lock: + # Hostname matches but the process does not exist. + message = _("""\ +The master qrunner lock could not be acquired. It appears as though there is +a stale master qrunner lock. Try re-running mailmanctl with the -s flag. +""") + else: + assert status == WatcherState.host_mismatch, ( + 'Invalid enum value: %s' % status) + # Hostname doesn't even match. + hostname, pid, tempfile = get_lock_data() + message = _("""\ +The master qrunner lock could not be acquired, because it appears as if some +process on some other host may have acquired it. We can't test for stale +locks across host boundaries, so you'll have to clean this up manually. + +Lock file: $config.LOCK_FILE +Lock host: $hostname + +Exiting.""") + config.options.parser.error(message) + + + +class Loop: + """Main control loop class.""" + + def __init__(self, lock=None, restartable=None, config_file=None): + self._lock = lock + self._restartable = restartable + self._config_file = config_file + self._kids = {} + + def install_signal_handlers(self): + """Install various signals handlers for control from mailmanctl.""" + log = logging.getLogger('mailman.qrunner') + # Set up our signal handlers. Also set up a SIGALRM handler to + # refresh the lock once per day. The lock lifetime is 1 day + 6 hours + # so this should be plenty. + def sigalrm_handler(signum, frame): + self._lock.refresh() + signal.alarm(SECONDS_IN_A_DAY) + signal.signal(signal.SIGALRM, sigalrm_handler) + signal.alarm(SECONDS_IN_A_DAY) + # SIGHUP tells the qrunners to close and reopen their log files. + def sighup_handler(signum, frame): + reopen() + for pid in self._kids: + os.kill(pid, signal.SIGHUP) + log.info('Master watcher caught SIGHUP. Re-opening log files.') + signal.signal(signal.SIGHUP, sighup_handler) + # SIGUSR1 is used by 'mailman restart'. + def sigusr1_handler(signum, frame): + for pid in self._kids: + os.kill(pid, signal.SIGUSR1) + log.info('Master watcher caught SIGUSR1. Exiting.') + signal.signal(signal.SIGUSR1, sigusr1_handler) + # SIGTERM is what init will kill this process with when changing run + # levels. It's also the signal 'mailmanctl stop' uses. + def sigterm_handler(signum, frame): + for pid in self._kids: + os.kill(pid, signal.SIGTERM) + log.info('Master watcher caught SIGTERM. Exiting.') + signal.signal(signal.SIGTERM, sigterm_handler) + # SIGINT is what control-C gives. + def sigint_handler(signum, frame): + for pid in self._kids: + os.kill(pid, signal.SIGINT) + log.info('Master watcher caught SIGINT. Restarting.') + signal.signal(signal.SIGINT, sigint_handler) + + def _start_runner(self, spec): + """Start a queue runner. + + All arguments are passed to the qrunner process. + + :param spec: A queue runner spec, in a format acceptable to + bin/qrunner's --runner argument, e.g. name:slice:count + :type spec: string + :return: The process id of the child queue runner. + :rtype: int + """ + pid = os.fork() + if pid: + # Parent. + return pid + # Child. + # + # Craft the command line arguments for the exec() call. + rswitch = '--runner=' + spec + # Wherever mailmanctl lives, so too must live the qrunner script. + exe = os.path.join(config.BIN_DIR, 'qrunner') + # config.PYTHON, which is the absolute path to the Python interpreter, + # must be given as argv[0] due to Python's library search algorithm. + args = [sys.executable, sys.executable, exe, rswitch, '-s'] + if self._config_file is not None: + args.extend(['-C', self._config_file]) + log = logging.getLogger('mailman.qrunner') + log.debug('starting: %s', args) + os.execl(*args) + # We should never get here. + raise RuntimeError('os.execl() failed') + + def start_qrunners(self, qrunner_names=None): + """Start all the configured qrunners. + + :param qrunners: If given, a sequence of queue runner names to start. + If not given, this sequence is taken from the configuration file. + :type qrunners: a sequence of strings + """ + if not qrunner_names: + qrunner_names = [] + for qrunner_config in config.qrunner_configs: + # Strip off the 'qrunner.' prefix. + assert qrunner_config.name.startswith('qrunner.'), ( + 'Unexpected qrunner configuration section name: %s', + qrunner_config.name) + qrunner_names.append(qrunner_config.name[8:]) + # For each qrunner we want to start, find their config section, which + # will tell us the name of the class to instantiate, along with the + # number of hash space slices to manage. + for name in qrunner_names: + section_name = 'qrunner.' + name + # Let AttributeError propagate. + qrunner_config = getattr(config, section_name) + if not as_boolean(qrunner_config.start): + continue + package, class_name = qrunner_config['class'].rsplit(DOT, 1) + __import__(package) + # Let AttributeError propagate. + class_ = getattr(sys.modules[package], class_name) + # Find out how many qrunners to instantiate. This must be a power + # of 2. + count = int(qrunner_config.instances) + assert (count & (count - 1)) == 0, ( + 'Queue runner "%s", not a power of 2: %s', name, count) + for slice_number in range(count): + # qrunner name, slice #, # of slices, restart count + info = (name, slice_number, count, 0) + spec = '%s:%d:%d' % (name, slice_number, count) + pid = self._start_runner(spec) + log = logging.getLogger('mailman.qrunner') + log.debug('[%d] %s', pid, spec) + self._kids[pid] = info + + def loop(self): + """Main loop. + + Wait until all the qrunners have exited, restarting them if necessary + and configured to do so. + """ + log = logging.getLogger('mailman.qrunner') + while True: + try: + pid, status = os.wait() + except OSError, error: + # No children? We're done. + if error.errno == errno.ECHILD: + break + # If the system call got interrupted, just restart it. + elif error.errno == errno.EINTR: + continue + else: + raise + # Find out why the subprocess exited by getting the signal + # received or exit status. + if os.WIFSIGNALED(status): + why = os.WTERMSIG(status) + elif os.WIFEXITED(status): + why = os.WEXITSTATUS(status) + else: + why = None + # We'll restart the subprocess if it exited with a SIGUSR1 or + # because of a failure (i.e. no exit signal), and the no-restart + # command line switch was not given. This lets us better handle + # runaway restarts (e.g. if the subprocess had a syntax error!) + qrname, slice_number, count, restarts = self._kids.pop(pid) + config_name = 'qrunner.' + qrname + restart = False + if why == signal.SIGUSR1 and self._restartable: + restart = True + # Have we hit the maximum number of restarts? + restarts += 1 + max_restarts = int(getattr(config, config_name).max_restarts) + if restarts > max_restarts: + restart = False + # Are we permanently non-restartable? + log.debug("""\ +Master detected subprocess exit +(pid: %d, why: %s, class: %s, slice: %d/%d) %s""", + pid, why, qrname, slice_number + 1, count, + ('[restarting]' if restart else '')) + # See if we've reached the maximum number of allowable restarts + if restarts > max_restarts: + log.info("""\ +qrunner %s reached maximum restart limit of %d, not restarting.""", + qrname, max_restarts) + # Now perhaps restart the process unless it exited with a + # SIGTERM or we aren't restarting. + if restart: + spec = '%s:%d:%d' % (qrname, slice_number, count) + newpid = self._start_runner(spec) + self._kids[newpid] = (qrname, slice_number, count, restarts) + + def cleanup(self): + """Ensure that all children have exited.""" + log = logging.getLogger('mailman.qrunner') + # Send SIGTERMs to all the child processes and wait for them all to + # exit. + for pid in self._kids: + try: + os.kill(pid, signal.SIGTERM) + except OSError, error: + if error.errno == errno.ESRCH: + # The child has already exited. + log.info('ESRCH on pid: %d', pid) + # Wait for all the children to go away. + while self._kids: + try: + pid, status = os.wait() + del self._kids[pid] + except OSError, e: + if e.errno == errno.ECHILD: + break + elif e.errno == errno.EINTR: + continue + raise + + + +def main(): + """Main process.""" + + options = ScriptOptions() + options.initialize() + + # Acquire the master lock, exiting if we can't acquire it. We'll let the + # caller handle any clean up or lock breaking. No with statement here + # because Lock's constructor doesn't support a timeout. + lock = acquire_lock(options.options.force) + try: + with open(config.PIDFILE, 'w') as fp: + print >> fp, os.getpid() + loop = Loop(lock, options.options.restartable, options.options.config) + loop.install_signal_handlers() + try: + loop.start_qrunners(options.options.runners) + loop.loop() + finally: + loop.cleanup() + os.remove(config.PIDFILE) + finally: + lock.unlock() + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/mmsitepass.py b/src/mailman/bin/mmsitepass.py new file mode 100644 index 000000000..132803fc9 --- /dev/null +++ b/src/mailman/bin/mmsitepass.py @@ -0,0 +1,113 @@ +# 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 . + +import sys +import getpass +import optparse + +from mailman import Utils +from mailman import passwords +from mailman.configuration import config +from mailman.i18n import _ +from mailman.initialize import initialize +from mailman.version import MAILMAN_VERSION + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] [password] + +Set the site or list creator password. + +The site password can be used in most if not all places that the list +administrator's password can be used, which in turn can be used in most places +that a list user's password can be used. The list creator password is a +separate password that can be given to non-site administrators to delegate the +ability to create new mailing lists. + +If password is not given on the command line, it will be prompted for. +""")) + parser.add_option('-c', '--listcreator', + default=False, action='store_true', + help=_("""\ +Set the list creator password instead of the site password. The list +creator is authorized to create and remove lists, but does not have +the total power of the site administrator.""")) + parser.add_option('-p', '--password-scheme', + default='', type='string', + help=_("""\ +Specify the RFC 2307 style hashing scheme for passwords included in the +output. Use -P to get a list of supported schemes, which are +case-insensitive.""")) + parser.add_option('-P', '--list-hash-schemes', + default=False, action='store_true', help=_("""\ +List the supported password hashing schemes and exit. The scheme labels are +case-insensitive.""")) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + if len(args) > 1: + parser.error(_('Unexpected arguments')) + if opts.list_hash_schemes: + for label in passwords.Schemes: + print str(label).upper() + sys.exit(0) + return parser, opts, args + + +def check_password_scheme(parser, password_scheme): + # shoule be checked after config is loaded. + if password_scheme == '': + password_scheme = config.PASSWORD_SCHEME + scheme = passwords.lookup_scheme(password_scheme.lower()) + if not scheme: + parser.error(_('Invalid password scheme')) + return scheme + + + +def main(): + parser, opts, args = parseargs() + initialize(opts.config) + opts.password_scheme = check_password_scheme(parser, opts.password_scheme) + if args: + password = args[0] + else: + # Prompt for the password + if opts.listcreator: + prompt_1 = _('New list creator password: ') + else: + prompt_1 = _('New site administrator password: ') + pw1 = getpass.getpass(prompt_1) + pw2 = getpass.getpass(_('Enter password again to confirm: ')) + if pw1 <> pw2: + print _('Passwords do not match; no changes made.') + sys.exit(1) + password = pw1 + Utils.set_global_password(password, + not opts.listcreator, opts.password_scheme) + if Utils.check_global_password(password, not opts.listcreator): + print _('Password changed.') + else: + print _('Password change failed.') + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/nightly_gzip.py b/src/mailman/bin/nightly_gzip.py new file mode 100644 index 000000000..f886e5801 --- /dev/null +++ b/src/mailman/bin/nightly_gzip.py @@ -0,0 +1,117 @@ +# 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 . + +import os +import sys +import optparse + +try: + import gzip +except ImportError: + sys.exit(0) + +from mailman import MailList +from mailman.configuration import config +from mailman.i18n import _ +from mailman.initialize import initialize +from mailman.version import MAILMAN_VERSION + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] [listname ...] + +Re-generate the Pipermail gzip'd archive flat files.""")) + parser.add_option('-v', '--verbose', + default=False, action='store_true', + help=_("Print each file as it's being gzip'd")) + parser.add_option('-z', '--level', + default=6, type='int', + help=_('Specifies the compression level')) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + if opts.level < 1 or opts.level > 9: + parser.print_help() + print >> sys.stderr, _('Illegal compression level: $opts.level') + sys.exit(1) + return opts, args, parser + + + +def compress(txtfile, opts): + if opts.verbose: + print _("gzip'ing: $txtfile") + infp = outfp = None + try: + infp = open(txtfile) + outfp = gzip.open(txtfile + '.gz', 'wb', opts.level) + outfp.write(infp.read()) + finally: + if outfp: + outfp.close() + if infp: + infp.close() + + + +def main(): + opts, args, parser = parseargs() + initialize(opts.config) + + if config.ARCHIVE_TO_MBOX not in (1, 2) or config.GZIP_ARCHIVE_TXT_FILES: + # We're only going to run the nightly archiver if messages are + # archived to the mbox, and the gzip file is not created on demand + # (i.e. for every individual post). This is the normal mode of + # operation. + return + + # Process all the specified lists + for listname in set(args or config.list_manager.names): + mlist = MailList.MailList(listname, lock=False) + if not mlist.archive: + continue + dir = mlist.archive_dir() + try: + allfiles = os.listdir(dir) + except OSError: + # Has the list received any messages? If not, last_post_time will + # be zero, so it's not really a bogus archive dir. + if mlist.last_post_time > 0: + print _('List $listname has a bogus archive_directory: $dir') + continue + if opts.verbose: + print _('Processing list: $listname') + files = [] + for f in allfiles: + if os.path.splitext(f)[1] <> '.txt': + continue + # stat both the .txt and .txt.gz files and append them only if + # the former is newer than the latter. + txtfile = os.path.join(dir, f) + gzpfile = txtfile + '.gz' + txt_mtime = os.path.getmtime(txtfile) + try: + gzp_mtime = os.path.getmtime(gzpfile) + except OSError: + gzp_mtime = -1 + if txt_mtime > gzp_mtime: + files.append(txtfile) + for f in files: + compress(f, opts) diff --git a/src/mailman/bin/qrunner.py b/src/mailman/bin/qrunner.py new file mode 100644 index 000000000..62e943aad --- /dev/null +++ b/src/mailman/bin/qrunner.py @@ -0,0 +1,269 @@ +# 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 . + +import sys +import signal +import logging + +from mailman.config import config +from mailman.core.logging import reopen +from mailman.i18n import _ +from mailman.options import Options + + +COMMASPACE = ', ' +log = None + + + +def r_callback(option, opt, value, parser): + dest = getattr(parser.values, option.dest) + parts = value.split(':') + if len(parts) == 1: + runner = parts[0] + rslice = rrange = 1 + elif len(parts) == 3: + runner = parts[0] + try: + rslice = int(parts[1]) + rrange = int(parts[2]) + except ValueError: + parser.print_help() + print >> sys.stderr, _('Bad runner specification: $value') + sys.exit(1) + else: + parser.print_help() + print >> sys.stderr, _('Bad runner specification: $value') + sys.exit(1) + dest.append((runner, rslice, rrange)) + + + +class ScriptOptions(Options): + + usage = _("""\ +Run one or more qrunners, once or repeatedly. + +Each named runner class is run in round-robin fashion. In other words, the +first named runner is run to consume all the files currently in its +directory. When that qrunner is done, the next one is run to consume all the +files in /its/ directory, and so on. The number of total iterations can be +given on the command line. + +Usage: %prog [options] + +-r is required unless -l or -h is given, and its argument must be one of the +names displayed by the -l switch. + +Normally, this script should be started from mailmanctl. Running it +separately or with -o is generally useful only for debugging. +""") + + def add_options(self): + self.parser.add_option( + '-r', '--runner', + metavar='runner[:slice:range]', dest='runners', + type='string', default=[], + action='callback', callback=r_callback, + help=_("""\ +Run the named qrunner, which must be one of the strings returned by the -l +option. Optional slice:range if given, is used to assign multiple qrunner +processes to a queue. range is the total number of qrunners for this queue +while slice is the number of this qrunner from [0..range). + +When using the slice:range form, you must ensure that each qrunner for the +queue is given the same range value. If slice:runner is not given, then 1:1 +is used. + +Multiple -r options may be given, in which case each qrunner will run once in +round-robin fashion. The special runner `All' is shorthand for a qrunner for +each listed by the -l option.""")) + self.parser.add_option( + '-o', '--once', + default=False, action='store_true', help=_("""\ +Run each named qrunner exactly once through its main loop. Otherwise, each +qrunner runs indefinitely, until the process receives signal.""")) + self.parser.add_option( + '-l', '--list', + default=False, action='store_true', + help=_('List the available qrunner names and exit.')) + self.parser.add_option( + '-v', '--verbose', + default=0, action='count', help=_("""\ +Display more debugging information to the logs/qrunner log file.""")) + self.parser.add_option( + '-s', '--subproc', + default=False, action='store_true', help=_("""\ +This should only be used when running qrunner as a subprocess of the +mailmanctl startup script. It changes some of the exit-on-error behavior to +work better with that framework.""")) + + def sanity_check(self): + if self.arguments: + self.parser.error(_('Unexpected arguments')) + if not self.options.runners and not self.options.list: + self.parser.error(_('No runner name given.')) + + + +def make_qrunner(name, slice, range, once=False): + # Several conventions for specifying the runner name are supported. It + # could be one of the shortcut names. If the name is a full module path, + # use it explicitly. If the name starts with a dot, it's a class name + # relative to the Mailman.queue package. + qrunner_config = getattr(config, 'qrunner.' + name, None) + if qrunner_config is not None: + # It was a shortcut name. + class_path = qrunner_config['class'] + elif name.startswith('.'): + class_path = 'mailman.queue' + name + else: + class_path = name + module_name, class_name = class_path.rsplit('.', 1) + try: + __import__(module_name) + except ImportError, e: + if config.options.options.subproc: + # Exit with SIGTERM exit code so the master watcher won't try to + # restart us. + print >> sys.stderr, _('Cannot import runner module: $module_name') + print >> sys.stderr, e + sys.exit(signal.SIGTERM) + else: + raise + qrclass = getattr(sys.modules[module_name], class_name) + if once: + # Subclass to hack in the setting of the stop flag in _do_periodic() + class Once(qrclass): + def _do_periodic(self): + self.stop() + qrunner = Once(name, slice) + else: + qrunner = qrclass(name, slice) + return qrunner + + + +def set_signals(loop): + """Set up the signal handlers. + + Signals caught are: SIGTERM, SIGINT, SIGUSR1 and SIGHUP. The latter is + used to re-open the log files. SIGTERM and SIGINT are treated exactly the + same -- they cause qrunner to exit with no restart from the master. + SIGUSR1 also causes qrunner to exit, but the master watcher will restart + it in that case. + + :param loop: A loop queue runner instance. + """ + def sigterm_handler(signum, frame): + # Exit the qrunner cleanly + loop.stop() + loop.status = signal.SIGTERM + log.info('%s qrunner caught SIGTERM. Stopping.', loop.name()) + signal.signal(signal.SIGTERM, sigterm_handler) + def sigint_handler(signum, frame): + # Exit the qrunner cleanly + loop.stop() + loop.status = signal.SIGINT + log.info('%s qrunner caught SIGINT. Stopping.', loop.name()) + signal.signal(signal.SIGINT, sigint_handler) + def sigusr1_handler(signum, frame): + # Exit the qrunner cleanly + loop.stop() + loop.status = signal.SIGUSR1 + log.info('%s qrunner caught SIGUSR1. Stopping.', loop.name()) + signal.signal(signal.SIGUSR1, sigusr1_handler) + # SIGHUP just tells us to rotate our log files. + def sighup_handler(signum, frame): + reopen() + log.info('%s qrunner caught SIGHUP. Reopening logs.', loop.name()) + signal.signal(signal.SIGHUP, sighup_handler) + + + +def main(): + global log + + options = ScriptOptions() + options.initialize() + + if options.options.list: + prefixlen = max(len(shortname) + for shortname in config.qrunner_shortcuts) + for shortname in sorted(config.qrunner_shortcuts): + runnername = config.qrunner_shortcuts[shortname] + shortname = (' ' * (prefixlen - len(shortname))) + shortname + print _('$shortname runs $runnername') + sys.exit(0) + + # Fast track for one infinite runner + if len(options.options.runners) == 1 and not options.options.once: + qrunner = make_qrunner(*options.options.runners[0]) + class Loop: + status = 0 + def __init__(self, qrunner): + self._qrunner = qrunner + def name(self): + return self._qrunner.__class__.__name__ + def stop(self): + self._qrunner.stop() + loop = Loop(qrunner) + set_signals(loop) + # Now start up the main loop + log = logging.getLogger('mailman.qrunner') + log.info('%s qrunner started.', loop.name()) + qrunner.run() + log.info('%s qrunner exiting.', loop.name()) + else: + # Anything else we have to handle a bit more specially + qrunners = [] + for runner, rslice, rrange in options.options.runners: + qrunner = make_qrunner(runner, rslice, rrange, once=True) + qrunners.append(qrunner) + # This class is used to manage the main loop + class Loop: + status = 0 + def __init__(self): + self._isdone = False + def name(self): + return 'Main loop' + def stop(self): + self._isdone = True + def isdone(self): + return self._isdone + loop = Loop() + set_signals(loop) + log.info('Main qrunner loop started.') + while not loop.isdone(): + for qrunner in qrunners: + # In case the SIGTERM came in the middle of this iteration + if loop.isdone(): + break + if options.options.verbose: + log.info('Now doing a %s qrunner iteration', + qrunner.__class__.__bases__[0].__name__) + qrunner.run() + if options.options.once: + break + log.info('Main qrunner loop exiting.') + # All done + sys.exit(loop.status) + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/remove_list.py b/src/mailman/bin/remove_list.py new file mode 100644 index 000000000..05211b200 --- /dev/null +++ b/src/mailman/bin/remove_list.py @@ -0,0 +1,83 @@ +# 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 . + +import sys + +from mailman.app.lifecycle import remove_list +from mailman.config import config +from mailman.i18n import _ +from mailman.options import MultipleMailingListOptions + + + +class ScriptOptions(MultipleMailingListOptions): + usage = _("""\ +%prog [options] + +Remove the components of a mailing list with impunity - beware! + +This removes (almost) all traces of a mailing list. By default, the lists +archives are not removed, which is very handy for retiring old lists. +""") + + def add_options(self): + super(ScriptOptions, self).add_options() + self.parser.add_option( + '-a', '--archives', + default=False, action='store_true', + help=_("""\ +Remove the list's archives too, or if the list has already been deleted, +remove any residual archives.""")) + self.parser.add_option( + '-q', '--quiet', + default=False, action='store_true', + help=_('Suppress status messages')) + + def sanity_check(self): + if len(self.options.listnames) == 0: + self.parser.error(_('Nothing to do')) + if len(self.arguments) > 0: + self.parser.error(_('Unexpected arguments')) + + + +def main(): + options = ScriptOptions() + options.initialize() + + for fqdn_listname in options.options.listnames: + if not options.options.quiet: + print _('Removing list: $fqdn_listname') + mlist = config.db.list_manager.get(fqdn_listname) + if mlist is None: + if options.options.archives: + print _("""\ +No such list: ${fqdn_listname}. Removing its residual archives.""") + else: + print >> sys.stderr, _( + 'No such list (or list already deleted): $fqdn_listname') + + if not options.options.archives: + print _('Not removing archives. Reinvoke with -a to remove them.') + + remove_list(fqdn_listname, mlist, options.options.archives) + config.db.commit() + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/senddigests.py b/src/mailman/bin/senddigests.py new file mode 100644 index 000000000..fb057d6b9 --- /dev/null +++ b/src/mailman/bin/senddigests.py @@ -0,0 +1,83 @@ +# 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 . + +import os +import sys +import optparse + +from mailman import MailList +from mailman.i18n import _ +from mailman.initialize import initialize +from mailman.version import MAILMAN_VERSION + +# Work around known problems with some RedHat cron daemons +import signal +signal.signal(signal.SIGCHLD, signal.SIG_DFL) + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] + +Dispatch digests for lists w/pending messages and digest_send_periodic +set.""")) + parser.add_option('-l', '--listname', + type='string', default=[], action='append', + dest='listnames', help=_("""\ +Send the digest for the given list only, otherwise the digests for all +lists are sent out. Multiple -l options may be given.""")) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + if args: + parser.print_help() + print >> sys.stderr, _('Unexpected arguments') + sys.exit(1) + return opts, args, parser + + + +def main(): + opts, args, parser = parseargs() + initialize(opts.config) + + for listname in set(opts.listnames or config.list_manager.names): + mlist = MailList.MailList(listname, lock=False) + if mlist.digest_send_periodic: + mlist.Lock() + try: + try: + mlist.send_digest_now() + mlist.Save() + # We are unable to predict what exception may occur in digest + # processing and we don't want to lose the other digests, so + # we catch everything. + except Exception, errmsg: + print >> sys.stderr, \ + 'List: %s: problem processing %s:\n%s' % \ + (listname, + os.path.join(mlist.data_path, 'digest.mbox'), + errmsg) + finally: + mlist.Unlock() + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/set_members.py b/src/mailman/bin/set_members.py new file mode 100644 index 000000000..cdd11c56f --- /dev/null +++ b/src/mailman/bin/set_members.py @@ -0,0 +1,189 @@ +# Copyright (C) 2007-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 . + +import csv +import optparse + +from mailman import Message +from mailman import Utils +from mailman import i18n +from mailman import passwords +from mailman.app.membership import add_member +from mailman.app.notifications import ( + send_admin_subscription_notice, send_welcome_message) +from mailman.configuration import config +from mailman.initialize import initialize +from mailman.interfaces import DeliveryMode +from mailman.version import MAILMAN_VERSION + + +_ = i18n._ + +DELIVERY_MODES = { + 'regular': DeliveryMode.regular, + 'plain': DeliveryMode.plaintext_digests, + 'mime': DeliveryMode.mime_digests, + } + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] csv-file + +Set the membership of a mailing list to that described in a CSV file. Each +row of the CSV file has the following format. Only the address column is +required. + + - email address + - full name (default: the empty string) + - delivery mode (default: regular delivery) [1] + +[1] The delivery mode is a case insensitive string of the following values: + + regular - regular, i.e. immediate delivery + mime - MIME digest delivery + plain - plain text (RFC 1153) digest delivery + +Any address not included in the CSV file is removed from the list membership. +""")) + parser.add_option('-l', '--listname', + type='string', help=_("""\ +Mailng list to set the membership for.""")) + parser.add_option('-w', '--welcome-msg', + type='string', metavar='', help=_("""\ +Set whether or not to send the list members a welcome message, overriding +whatever the list's 'send_welcome_msg' setting is.""")) + parser.add_option('-a', '--admin-notify', + type='string', metavar='', help=_("""\ +Set whether or not to send the list administrators a notification on the +success/failure of these subscriptions, overriding whatever the list's +'admin_notify_mchanges' setting is.""")) + parser.add_option('-v', '--verbose', action='store_true', + help=_('Increase verbosity')) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + if opts.welcome_msg is not None: + ch = opts.welcome_msg[0].lower() + if ch == 'y': + opts.welcome_msg = True + elif ch == 'n': + opts.welcome_msg = False + else: + parser.error(_('Illegal value for -w: $opts.welcome_msg')) + if opts.admin_notify is not None: + ch = opts.admin_notify[0].lower() + if ch == 'y': + opts.admin_notify = True + elif ch == 'n': + opts.admin_notify = False + else: + parser.error(_('Illegal value for -a: $opts.admin_notify')) + return parser, opts, args + + + +def parse_file(filename): + members = {} + with open(filename) as fp: + for row in csv.reader(fp): + if len(row) == 0: + continue + elif len(row) == 1: + address = row[0] + real_name = None + delivery_mode = DeliveryMode.regular + elif len(row) == 2: + address, real_name = row + delivery_mode = DeliveryMode.regular + else: + # Ignore extra columns + address, real_name = row[0:2] + delivery_mode = DELIVERY_MODES.get(row[2].lower()) + if delivery_mode is None: + delivery_mode = DeliveryMode.regular + members[address] = real_name, delivery_mode + return members + + + +def main(): + parser, opts, args = parseargs() + initialize(opts.config) + + mlist = config.db.list_manager.get(opts.listname) + if mlist is None: + parser.error(_('No such list: $opts.listname')) + + # Set up defaults. + if opts.welcome_msg is None: + send_welcome_msg = mlist.send_welcome_msg + else: + send_welcome_msg = opts.welcome_msg + if opts.admin_notify is None: + admin_notify = mlist.admin_notify_mchanges + else: + admin_notify = opts.admin_notify + + # Parse the csv files. + member_data = {} + for filename in args: + member_data.update(parse_file(filename)) + + future_members = set(member_data) + current_members = set(obj.address for obj in mlist.members.addresses) + add_members = future_members - current_members + delete_members = current_members - future_members + change_members = current_members & future_members + + with i18n.using_language(mlist.preferred_language): + # Start by removing all the delete members. + for address in delete_members: + print _('deleting address: $address') + member = mlist.members.get_member(address) + member.unsubscribe() + # For all members that are in both lists, update their full name and + # delivery mode. + for address in change_members: + print _('updating address: $address') + real_name, delivery_mode = member_data[address] + member = mlist.members.get_member(address) + member.preferences.delivery_mode = delivery_mode + user = config.db.user_manager.get_user(address) + user.real_name = real_name + for address in add_members: + print _('adding address: $address') + real_name, delivery_mode = member_data[address] + password = passwords.make_secret( + Utils.MakeRandomPassword(), + passwords.lookup_scheme(config.PASSWORD_SCHEME)) + add_member(mlist, address, real_name, password, delivery_mode, + mlist.preferred_language, send_welcome_msg, + admin_notify) + if send_welcome_msg: + send_welcome_message(mlist, address, language, delivery_mode) + if admin_notify: + send_admin_subscription_notice(mlist, address, real_name) + + config.db.flush() + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/show_config.py b/src/mailman/bin/show_config.py new file mode 100644 index 000000000..8d26c5c97 --- /dev/null +++ b/src/mailman/bin/show_config.py @@ -0,0 +1,97 @@ +# 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 . + +import re +import sys +import pprint +import optparse + +from mailman.configuration import config +from mailman.i18n import _ +from mailman.version import MAILMAN_VERSION + + +# List of names never to show even if --verbose +NEVER_SHOW = ['__builtins__', '__doc__'] + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%%prog [options] [pattern ...] + +Show the values of various Defaults.py/mailman.cfg variables. +If one or more patterns are given, show only those variables +whose names match a pattern""")) + parser.add_option('-v', '--verbose', + default=False, action='store_true', + help=_( +"Show all configuration names, not just 'settings'.")) + parser.add_option('-i', '--ignorecase', + default=False, action='store_true', + help=_("Match patterns case-insensitively.")) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + return parser, opts, args + + + +def main(): + parser, opts, args = parseargs() + + patterns = [] + if opts.ignorecase: + flag = re.IGNORECASE + else: + flag = 0 + for pattern in args: + patterns.append(re.compile(pattern, flag)) + + pp = pprint.PrettyPrinter(indent=4) + config.load(opts.config) + names = config.__dict__.keys() + names.sort() + for name in names: + if name in NEVER_SHOW: + continue + if not opts.verbose: + if name.startswith('_') or re.search('[a-z]', name): + continue + if patterns: + hit = False + for pattern in patterns: + if pattern.search(name): + hit = True + break + if not hit: + continue + value = config.__dict__[name] + if isinstance(value, str): + if re.search('\n', value): + print '%s = """%s"""' %(name, value) + else: + print "%s = '%s'" % (name, value) + else: + print '%s = ' % name, + pp.pprint(value) + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/show_qfiles.py b/src/mailman/bin/show_qfiles.py new file mode 100644 index 000000000..e4b64e0cd --- /dev/null +++ b/src/mailman/bin/show_qfiles.py @@ -0,0 +1,91 @@ +# 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 . + +import os +import sys + +from cPickle import load + +from mailman.config import config +from mailman.i18n import _ +from mailman.options import Options + + + +class ScriptOptions(Options): + usage = _(""" +%%prog [options] qfiles ... + +Show the contents of one or more Mailman queue files.""") + + def add_options(self): + super(ScriptOptions, self).add_options() + self.parser.add_option( + '-q', '--quiet', + default=False, action='store_true', + help=_("Don't print 'helpful' message delimiters.")) + self.parser.add_option( + '-s', '--summary', + default=False, action='store_true', + help=_('Show a summary of queue files.')) + + + +def main(): + options = ScriptOptions() + options.initialize() + + if options.options.summary: + queue_totals = {} + files_by_queue = {} + for switchboard in config.switchboards.values(): + total = 0 + file_mappings = {} + for filename in os.listdir(switchboard.queue_directory): + base, ext = os.path.splitext(filename) + file_mappings[ext] = file_mappings.get(ext, 0) + 1 + total += 1 + files_by_queue[switchboard.queue_directory] = file_mappings + queue_totals[switchboard.queue_directory] = total + # Sort by queue name. + for queue_directory in sorted(files_by_queue): + total = queue_totals[queue_directory] + print queue_directory + print _('\tfile count: $total') + file_mappings = files_by_queue[queue_directory] + for ext in sorted(file_mappings): + print '\t{0}: {1}'.format(ext, file_mappings[ext]) + return + # No summary. + for filename in options.arguments: + if not options.options.quiet: + print '====================>', filename + with open(filename) as fp: + if filename.endswith('.pck'): + msg = load(fp) + data = load(fp) + if data.get('_parsemsg'): + sys.stdout.write(msg) + else: + sys.stdout.write(msg.as_string()) + else: + sys.stdout.write(fp.read()) + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/unshunt.py b/src/mailman/bin/unshunt.py new file mode 100644 index 000000000..fc889377c --- /dev/null +++ b/src/mailman/bin/unshunt.py @@ -0,0 +1,51 @@ +# 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 . + +__metaclass__ = type +__all__ = [ + 'main', + ] + + +import sys + +from mailman.config import config +from mailman.i18n import _ +from mailman.options import Options + + + +def main(): + options = Options() + options.initialize() + + switchboard = config.switchboards['shunt'] + switchboard.recover_backup_files() + + for filebase in switchboard.files: + try: + msg, msgdata = switchboard.dequeue(filebase) + whichq = msgdata.get('whichq', 'in') + config.switchboards[whichq].enqueue(msg, msgdata) + except Exception, e: + # If there are any unshunting errors, log them and continue trying + # other shunted messages. + print >> sys.stderr, _( + 'Cannot unshunt message $filebase, skipping:\n$e') + else: + # Unlink the .bak file left by dequeue() + switchboard.finish(filebase) diff --git a/src/mailman/bin/update.py b/src/mailman/bin/update.py new file mode 100644 index 000000000..34ea6cda3 --- /dev/null +++ b/src/mailman/bin/update.py @@ -0,0 +1,660 @@ +# 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 . + +import os +import md5 +import sys +import time +import email +import errno +import shutil +import cPickle +import marshal +import optparse + +from locknix.lockfile import TimeOutError + +from mailman import MailList +from mailman import Message +from mailman import Pending +from mailman import Utils +from mailman import version +from mailman.MemberAdaptor import BYBOUNCE, ENABLED +from mailman.OldStyleMemberships import OldStyleMemberships +from mailman.Queue.Switchboard import Switchboard +from mailman.configuration import config +from mailman.i18n import _ +from mailman.initialize import initialize +from mailman.utilities.filesystem import makedirs + + +FRESH = 0 +NOTFRESH = -1 + + + +def parseargs(): + parser = optparse.OptionParser(version=version.MAILMAN_VERSION, + usage=_("""\ +Perform all necessary upgrades. + +%prog [options]""")) + parser.add_option('-f', '--force', + default=False, action='store_true', help=_("""\ +Force running the upgrade procedures. Normally, if the version number of the +installed Mailman matches the current version number (or a 'downgrade' is +detected), nothing will be done.""")) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + if args: + parser.print_help() + print >> sys.stderr, _('Unexpected arguments') + sys.exit(1) + return parser, opts, args + + + +def calcversions(): + # Returns a tuple of (lastversion, thisversion). If the last version + # could not be determined, lastversion will be FRESH or NOTFRESH, + # depending on whether this installation appears to be fresh or not. The + # determining factor is whether there are files in the $var_prefix/logs + # subdir or not. The version numbers are HEX_VERSIONs. + # + # See if we stored the last updated version + lastversion = None + thisversion = version.HEX_VERSION + try: + fp = open(os.path.join(config.DATA_DIR, 'last_mailman_version')) + data = fp.read() + fp.close() + lastversion = int(data, 16) + except (IOError, ValueError): + pass + # + # try to figure out if this is a fresh install + if lastversion is None: + lastversion = FRESH + try: + if os.listdir(config.LOG_DIR): + lastversion = NOTFRESH + except OSError: + pass + return (lastversion, thisversion) + + + +def makeabs(relpath): + return os.path.join(config.PREFIX, relpath) + + +def make_varabs(relpath): + return os.path.join(config.VAR_PREFIX, relpath) + + + +def move_language_templates(mlist): + listname = mlist.internal_name() + print _('Fixing language templates: $listname') + # Mailman 2.1 has a new cascading search for its templates, defined and + # described in Utils.py:maketext(). Putting templates in the top level + # templates/ subdir or the lists/ subdir is deprecated and no + # longer searched.. + # + # What this means is that most templates can live in the global templates/ + # subdirectory, and only needs to be copied into the list-, vhost-, or + # site-specific language directories when needed. + # + # Also, by default all standard (i.e. English) templates must now live in + # the templates/en directory. This update cleans up all the templates, + # deleting more-specific duplicates (as calculated by md5 checksums) in + # favor of more-global locations. + # + # First, get rid of any lists/ template or lists//en template + # that is identical to the global templates/* default. + for gtemplate in os.listdir(os.path.join(config.TEMPLATE_DIR, 'en')): + # BAW: get rid of old templates, e.g. admlogin.txt and + # handle_opts.html + try: + fp = open(os.path.join(config.TEMPLATE_DIR, gtemplate)) + except IOError, e: + if e.errno <> errno.ENOENT: + raise + # No global template + continue + gcksum = md5.new(fp.read()).digest() + fp.close() + # Match against the lists//* template + try: + fp = open(os.path.join(mlist.fullpath(), gtemplate)) + except IOError, e: + if e.errno <> errno.ENOENT: + raise + else: + tcksum = md5.new(fp.read()).digest() + fp.close() + if gcksum == tcksum: + os.unlink(os.path.join(mlist.fullpath(), gtemplate)) + # Match against the lists//*.prev template + try: + fp = open(os.path.join(mlist.fullpath(), gtemplate + '.prev')) + except IOError, e: + if e.errno <> errno.ENOENT: + raise + else: + tcksum = md5.new(fp.read()).digest() + fp.close() + if gcksum == tcksum: + os.unlink(os.path.join(mlist.fullpath(), gtemplate + '.prev')) + # Match against the lists//en/* templates + try: + fp = open(os.path.join(mlist.fullpath(), 'en', gtemplate)) + except IOError, e: + if e.errno <> errno.ENOENT: + raise + else: + tcksum = md5.new(fp.read()).digest() + fp.close() + if gcksum == tcksum: + os.unlink(os.path.join(mlist.fullpath(), 'en', gtemplate)) + # Match against the templates/* template + try: + fp = open(os.path.join(config.TEMPLATE_DIR, gtemplate)) + except IOError, e: + if e.errno <> errno.ENOENT: + raise + else: + tcksum = md5.new(fp.read()).digest() + fp.close() + if gcksum == tcksum: + os.unlink(os.path.join(config.TEMPLATE_DIR, gtemplate)) + # Match against the templates/*.prev template + try: + fp = open(os.path.join(config.TEMPLATE_DIR, gtemplate + '.prev')) + except IOError, e: + if e.errno <> errno.ENOENT: + raise + else: + tcksum = md5.new(fp.read()).digest() + fp.close() + if gcksum == tcksum: + os.unlink(os.path.join(config.TEMPLATE_DIR, + gtemplate + '.prev')) + + + +def situate_list(listname): + # This turns the directory called 'listname' into a directory called + # 'listname@domain'. Start by finding out what the domain should be. + # A list's domain is its email host. + mlist = MailList.MailList(listname, lock=False, check_version=False) + fullname = mlist.fqdn_listname + oldpath = os.path.join(config.VAR_PREFIX, 'lists', listname) + newpath = os.path.join(config.VAR_PREFIX, 'lists', fullname) + if os.path.exists(newpath): + print >> sys.stderr, _('WARNING: could not situate list: $listname') + else: + os.rename(oldpath, newpath) + print _('situated list $listname to $fullname') + return fullname + + + +def dolist(listname): + mlist = MailList.MailList(listname, lock=False) + try: + mlist.Lock(0.5) + except TimeOutError: + print >> sys.stderr, _( + 'WARNING: could not acquire lock for list: $listname') + return 1 + # Sanity check the invariant that every BYBOUNCE disabled member must have + # bounce information. Some earlier betas broke this. BAW: we're + # submerging below the MemberAdaptor interface, so skip this if we're not + # using OldStyleMemberships. + if isinstance(mlist._memberadaptor, OldStyleMemberships): + noinfo = {} + for addr, (reason, when) in mlist.delivery_status.items(): + if reason == BYBOUNCE and not mlist.bounce_info.has_key(addr): + noinfo[addr] = reason, when + # What to do about these folks with a BYBOUNCE delivery status and no + # bounce info? This number should be very small, and I think it's + # fine to simple re-enable them and let the bounce machinery + # re-disable them if necessary. + n = len(noinfo) + if n > 0: + print _( + 'Resetting $n BYBOUNCEs disabled addrs with no bounce info') + for addr in noinfo.keys(): + mlist.setDeliveryStatus(addr, ENABLED) + + mbox_dir = make_varabs('archives/private/%s.mbox' % (listname)) + mbox_file = make_varabs('archives/private/%s.mbox/%s' % (listname, + listname)) + o_pub_mbox_file = make_varabs('archives/public/%s' % (listname)) + o_pri_mbox_file = make_varabs('archives/private/%s' % (listname)) + html_dir = o_pri_mbox_file + o_html_dir = makeabs('public_html/archives/%s' % (listname)) + # Make the mbox directory if it's not there. + if not os.path.exists(mbox_dir): + makedirs(mbox_dir) + else: + # This shouldn't happen, but hey, just in case + if not os.path.isdir(mbox_dir): + print _("""\ +For some reason, $mbox_dir exists as a file. This won't work with b6, so I'm +renaming it to ${mbox_dir}.tmp and proceeding.""") + os.rename(mbox_dir, "%s.tmp" % (mbox_dir)) + makedirs(mbox_dir) + # Move any existing mboxes around, but watch out for both a public and a + # private one existing + if os.path.isfile(o_pri_mbox_file) and os.path.isfile(o_pub_mbox_file): + if mlist.archive_private: + print _("""\ + +$listname has both public and private mbox archives. Since this list +currently uses private archiving, I'm installing the private mbox archive -- +$o_pri_mbox_file -- as the active archive, and renaming + $o_pub_mbox_file +to + ${o_pub_mbox_file}.preb6 + +You can integrate that into the archives if you want by using the 'arch' +script. +""") % (mlist._internal_name, o_pri_mbox_file, o_pub_mbox_file, + o_pub_mbox_file) + os.rename(o_pub_mbox_file, "%s.preb6" % (o_pub_mbox_file)) + else: + print _("""\ +$mlist._internal_name has both public and private mbox archives. Since this +list currently uses public archiving, I'm installing the public mbox file +archive file ($o_pub_mbox_file) as the active one, and renaming +$o_pri_mbox_file to ${o_pri_mbox_file}.preb6 + +You can integrate that into the archives if you want by using the 'arch' +script. +""") + os.rename(o_pri_mbox_file, "%s.preb6" % (o_pri_mbox_file)) + # Move private archive mbox there if it's around + # and take into account all sorts of absurdities + print _('- updating old private mbox file') + if os.path.exists(o_pri_mbox_file): + if os.path.isfile(o_pri_mbox_file): + os.rename(o_pri_mbox_file, mbox_file) + elif not os.path.isdir(o_pri_mbox_file): + newname = "%s.mm_install-dunno_what_this_was_but_its_in_the_way" \ + % o_pri_mbox_file + os.rename(o_pri_mbox_file, newname) + print _("""\ + unknown file in the way, moving + $o_pri_mbox_file + to + $newname""") + else: + # directory + print _("""\ + looks like you have a really recent development installation... + you're either one brave soul, or you already ran me""") + # Move public archive mbox there if it's around + # and take into account all sorts of absurdities. + print _('- updating old public mbox file') + if os.path.exists(o_pub_mbox_file): + if os.path.isfile(o_pub_mbox_file): + os.rename(o_pub_mbox_file, mbox_file) + elif not os.path.isdir(o_pub_mbox_file): + newname = "%s.mm_install-dunno_what_this_was_but_its_in_the_way" \ + % o_pub_mbox_file + os.rename(o_pub_mbox_file, newname) + print _("""\ + unknown file in the way, moving + $o_pub_mbox_file + to + $newname""") + else: # directory + print _("""\ + looks like you have a really recent development installation... + you're either one brave soul, or you already ran me""") + # Move the html archives there + if os.path.isdir(o_html_dir): + os.rename(o_html_dir, html_dir) + # chmod the html archives + os.chmod(html_dir, 02775) + # BAW: Is this still necessary?! + mlist.Save() + # Check to see if pre-b4 list-specific templates are around + # and move them to the new place if there's not already + # a new one there + tmpl_dir = os.path.join(config.PREFIX, "templates") + list_dir = os.path.join(config.PREFIX, "lists") + b4_tmpl_dir = os.path.join(tmpl_dir, mlist._internal_name) + new_tmpl_dir = os.path.join(list_dir, mlist._internal_name) + if os.path.exists(b4_tmpl_dir): + print _("""\ +- This list looks like it might have <= b4 list templates around""") + for f in os.listdir(b4_tmpl_dir): + o_tmpl = os.path.join(b4_tmpl_dir, f) + n_tmpl = os.path.join(new_tmpl_dir, f) + if os.path.exists(o_tmpl): + if not os.path.exists(n_tmpl): + os.rename(o_tmpl, n_tmpl) + print _('- moved $o_tmpl to $n_tmpl') + else: + print _("""\ +- both $o_tmpl and $n_tmpl exist, leaving untouched""") + else: + print _("""\ +- $o_tmpl doesn't exist, leaving untouched""") + # Move all the templates to the en language subdirectory as required for + # Mailman 2.1 + move_language_templates(mlist) + # Avoid eating filehandles with the list lockfiles + mlist.Unlock() + return 0 + + + +def archive_path_fixer(unused_arg, dir, files): + # Passed to os.path.walk to fix the perms on old html archives. + for f in files: + abs = os.path.join(dir, f) + if os.path.isdir(abs): + if f == "database": + os.chmod(abs, 02770) + else: + os.chmod(abs, 02775) + elif os.path.isfile(abs): + os.chmod(abs, 0664) + + +def remove_old_sources(module): + # Also removes old directories. + src = '%s/%s' % (config.PREFIX, module) + pyc = src + "c" + if os.path.isdir(src): + print _('removing directory $src and everything underneath') + shutil.rmtree(src) + elif os.path.exists(src): + print _('removing $src') + try: + os.unlink(src) + except os.error, rest: + print _("Warning: couldn't remove $src -- $rest") + if module.endswith('.py') and os.path.exists(pyc): + try: + os.unlink(pyc) + except OSError, rest: + print _("couldn't remove old file $pyc -- $rest") + + + +def update_qfiles(): + print _('updating old qfiles') + prefix = `time.time()` + '+' + # Be sure the qfiles/in directory exists (we don't really need the + # switchboard object, but it's convenient for creating the directory). + sb = Switchboard(config.INQUEUE_DIR) + for filename in os.listdir(config.QUEUE_DIR): + # Updating means just moving the .db and .msg files to qfiles/in where + # it should be dequeued, converted, and processed normally. + if os.path.splitext(filename) == '.msg': + oldmsgfile = os.path.join(config.QUEUE_DIR, filename) + newmsgfile = os.path.join(config.INQUEUE_DIR, prefix + filename) + os.rename(oldmsgfile, newmsgfile) + elif os.path.splitext(filename) == '.db': + olddbfile = os.path.join(config.QUEUE_DIR, filename) + newdbfile = os.path.join(config.INQUEUE_DIR, prefix + filename) + os.rename(olddbfile, newdbfile) + # Now update for the Mailman 2.1.5 qfile format. For every filebase in + # the qfiles/* directories that has both a .pck and a .db file, pull the + # data out and re-queue them. + for dirname in os.listdir(config.QUEUE_DIR): + dirpath = os.path.join(config.QUEUE_DIR, dirname) + if dirpath == config.BADQUEUE_DIR: + # The files in qfiles/bad can't possibly be pickles + continue + sb = Switchboard(dirpath) + try: + for filename in os.listdir(dirpath): + filepath = os.path.join(dirpath, filename) + filebase, ext = os.path.splitext(filepath) + # Handle the .db metadata files as part of the handling of the + # .pck or .msg message files. + if ext not in ('.pck', '.msg'): + continue + msg, data = dequeue(filebase) + if msg is not None and data is not None: + sb.enqueue(msg, data) + except EnvironmentError, e: + if e.errno <> errno.ENOTDIR: + raise + print _('Warning! Not a directory: $dirpath') + + + +# Implementations taken from the pre-2.1.5 Switchboard +def ext_read(filename): + fp = open(filename) + d = marshal.load(fp) + # Update from version 2 files + if d.get('version', 0) == 2: + del d['filebase'] + # Do the reverse conversion (repr -> float) + for attr in ['received_time']: + try: + sval = d[attr] + except KeyError: + pass + else: + # Do a safe eval by setting up a restricted execution + # environment. This may not be strictly necessary since we + # know they are floats, but it can't hurt. + d[attr] = eval(sval, {'__builtins__': {}}) + fp.close() + return d + + +def dequeue(filebase): + # Calculate the .db and .msg filenames from the given filebase. + msgfile = os.path.join(filebase + '.msg') + pckfile = os.path.join(filebase + '.pck') + dbfile = os.path.join(filebase + '.db') + # Now we are going to read the message and metadata for the given + # filebase. We want to read things in this order: first, the metadata + # file to find out whether the message is stored as a pickle or as + # plain text. Second, the actual message file. However, we want to + # first unlink the message file and then the .db file, because the + # qrunner only cues off of the .db file + msg = None + try: + data = ext_read(dbfile) + os.unlink(dbfile) + except EnvironmentError, e: + if e.errno <> errno.ENOENT: + raise + data = {} + # Between 2.1b4 and 2.1b5, the `rejection-notice' key in the metadata + # was renamed to `rejection_notice', since dashes in the keys are not + # supported in METAFMT_ASCII. + if data.has_key('rejection-notice'): + data['rejection_notice'] = data['rejection-notice'] + del data['rejection-notice'] + msgfp = None + try: + try: + msgfp = open(pckfile) + msg = cPickle.load(msgfp) + os.unlink(pckfile) + except EnvironmentError, e: + if e.errno <> errno.ENOENT: raise + msgfp = None + try: + msgfp = open(msgfile) + msg = email.message_from_file(msgfp, Message.Message) + os.unlink(msgfile) + except EnvironmentError, e: + if e.errno <> errno.ENOENT: raise + except (email.Errors.MessageParseError, ValueError), e: + # This message was unparsable, most likely because its + # MIME encapsulation was broken. For now, there's not + # much we can do about it. + print _('message is unparsable: $filebase') + msgfp.close() + msgfp = None + if config.QRUNNER_SAVE_BAD_MESSAGES: + # Cheapo way to ensure the directory exists w/ the + # proper permissions. + sb = Switchboard(config.BADQUEUE_DIR) + os.rename(msgfile, os.path.join( + config.BADQUEUE_DIR, filebase + '.txt')) + else: + os.unlink(msgfile) + msg = data = None + except EOFError: + # For some reason the pckfile was empty. Just delete it. + print _('Warning! Deleting empty .pck file: $pckfile') + os.unlink(pckfile) + finally: + if msgfp: + msgfp.close() + return msg, data + + + +def main(): + parser, opts, args = parseargs() + initialize(opts.config) + + # calculate the versions + lastversion, thisversion = calcversions() + hexlversion = hex(lastversion) + hextversion = hex(thisversion) + if lastversion == thisversion and not opts.force: + # nothing to do + print _('No updates are necessary.') + sys.exit(0) + if lastversion > thisversion and not opts.force: + print _("""\ +Downgrade detected, from version $hexlversion to version $hextversion +This is probably not safe. +Exiting.""") + sys.exit(1) + print _('Upgrading from version $hexlversion to $hextversion') + errors = 0 + # get rid of old stuff + print _('getting rid of old source files') + for mod in ('mailman/Archiver.py', 'mailman/HyperArch.py', + 'mailman/HyperDatabase.py', 'mailman/pipermail.py', + 'mailman/smtplib.py', 'mailman/Cookie.py', + 'bin/update_to_10b6', 'scripts/mailcmd', + 'scripts/mailowner', 'mail/wrapper', 'mailman/pythonlib', + 'cgi-bin/archives', 'mailman/MailCommandHandler'): + remove_old_sources(mod) + if not config.list_manager.names: + print _('no lists == nothing to do, exiting') + return + # For people with web archiving, make sure the directories + # in the archiving are set with proper perms for b6. + if os.path.isdir("%s/public_html/archives" % config.PREFIX): + print _("""\ +fixing all the perms on your old html archives to work with b6 +If your archives are big, this could take a minute or two...""") + os.path.walk("%s/public_html/archives" % config.PREFIX, + archive_path_fixer, "") + print _('done') + for listname in config.list_manager.names: + # With 2.2.0a0, all list names grew an @domain suffix. If you find a + # list without that, move it now. + if not '@' in listname: + listname = situate_list(listname) + print _('Updating mailing list: $listname') + errors += dolist(listname) + print + print _('Updating Usenet watermarks') + wmfile = os.path.join(config.DATA_DIR, 'gate_watermarks') + try: + fp = open(wmfile) + except IOError: + print _('- nothing to update here') + else: + d = marshal.load(fp) + fp.close() + for listname in d.keys(): + if listname not in listnames: + # this list no longer exists + continue + mlist = MailList.MailList(listname, lock=0) + try: + mlist.Lock(0.5) + except TimeOutError: + print >> sys.stderr, _( + 'WARNING: could not acquire lock for list: $listname') + errors = errors + 1 + else: + # Pre 1.0b7 stored 0 in the gate_watermarks file to indicate + # that no gating had been done yet. Without coercing this to + # None, the list could now suddenly get flooded. + mlist.usenet_watermark = d[listname] or None + mlist.Save() + mlist.Unlock() + os.unlink(wmfile) + print _('- usenet watermarks updated and gate_watermarks removed') + # In Mailman 2.1, the qfiles directory has a different structure and a + # different content. Also, in Mailman 2.1.5 we collapsed the message + # files from separate .msg (pickled Message objects) and .db (marshalled + # dictionaries) to a shared .pck file containing two pickles. + update_qfiles() + # This warning was necessary for the upgrade from 1.0b9 to 1.0b10. + # There's no good way of figuring this out for releases prior to 2.0beta2 + # :( + if lastversion == NOTFRESH: + print _(""" + +NOTE NOTE NOTE NOTE NOTE + + You are upgrading an existing Mailman installation, but I can't tell what + version you were previously running. + + If you are upgrading from Mailman 1.0b9 or earlier you will need to + manually update your mailing lists. For each mailing list you need to + copy the file templates/options.html lists//options.html. + + However, if you have edited this file via the Web interface, you will have + to merge your changes into this file, otherwise you will lose your + changes. + +NOTE NOTE NOTE NOTE NOTE + +""") + if not errors: + # Record the version we just upgraded to + fp = open(os.path.join(config.DATA_DIR, 'last_mailman_version'), 'w') + fp.write(hex(config.HEX_VERSION) + '\n') + fp.close() + else: + lockdir = config.LOCK_DIR + print _('''\ + +ERROR: + +The locks for some lists could not be acquired. This means that either +Mailman was still active when you upgraded, or there were stale locks in the +$lockdir directory. + +You must put Mailman into a quiescent state and remove all stale locks, then +re-run "make update" manually. See the INSTALL and UPGRADE files for details. +''') diff --git a/src/mailman/bin/version.py b/src/mailman/bin/version.py new file mode 100644 index 000000000..0fb2c5a5b --- /dev/null +++ b/src/mailman/bin/version.py @@ -0,0 +1,46 @@ +# 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 . + +import optparse + +from mailman import version +from mailman.i18n import _ + + + +def parseargs(): + parser = optparse.OptionParser(version=version.MAILMAN_VERSION, + usage=_("""\ +%prog + +Print the Mailman version and exit.""")) + opts, args = parser.parse_args() + if args: + parser.error(_('Unexpected arguments')) + return parser, opts, args + + + +def main(): + parser, opts, args = parseargs() + # Yes, this is kind of silly + print _('Using $version.MAILMAN_VERSION ($version.CODENAME)') + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/withlist.py b/src/mailman/bin/withlist.py new file mode 100644 index 000000000..8f2d8a2b5 --- /dev/null +++ b/src/mailman/bin/withlist.py @@ -0,0 +1,220 @@ +# 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 . + +import os +import sys +import optparse + +from mailman import interact +from mailman.config import config +from mailman.core.initialize import initialize +from mailman.i18n import _ +from mailman.version import MAILMAN_VERSION + + +LAST_MLIST = None +VERBOSE = True + + + +def do_list(listname, args, func): + global LAST_MLIST + + if '@' not in listname: + listname += '@' + config.DEFAULT_EMAIL_HOST + + # XXX FIXME Remove this when this script is converted to + # MultipleMailingListOptions. + listname = listname.decode(sys.getdefaultencoding()) + mlist = config.db.list_manager.get(listname) + if mlist is None: + print >> sys.stderr, _('Unknown list: $listname') + else: + if VERBOSE: + print >> sys.stderr, _('Loaded list: $listname') + LAST_MLIST = mlist + # Try to import the module and run the callable. + if func: + return func(mlist, *args) + return None + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] listname [args ...] + +General framework for interacting with a mailing list object. + +There are two ways to use this script: interactively or programmatically. +Using it interactively allows you to play with, examine and modify a +IMailinglist object from Python's interactive interpreter. When running +interactively, a IMailingList object called 'm' will be available in the +global namespace. + +Programmatically, you can write a function to operate on a IMailingList +object, and this script will take care of the housekeeping (see below for +examples). In that case, the general usage syntax is: + + % bin/withlist [options] listname [args ...] + +Here's an example of how to use the -r option. Say you have a file in the +Mailman installation directory called 'listaddr.py', with the following +two functions: + + def listaddr(mlist): + print mlist.posting_address + + def requestaddr(mlist): + print mlist.request_address + +Now, from the command line you can print the list's posting address by running +the following from the command line: + + % bin/withlist -r listaddr mylist + Loading list: mylist + Importing listaddr ... + Running listaddr.listaddr() ... + mylist@myhost.com + +And you can print the list's request address by running: + + % bin/withlist -r listaddr.requestaddr mylist + Loading list: mylist + Importing listaddr ... + Running listaddr.requestaddr() ... + mylist-request@myhost.com + +As another example, say you wanted to change the password for a particular +user on a particular list. You could put the following function in a file +called 'changepw.py': + + from mailman.errors import NotAMemberError + + def changepw(mlist, addr, newpasswd): + try: + mlist.setMemberPassword(addr, newpasswd) + mlist.Save() + except NotAMemberError: + print 'No address matched:', addr + +and run this from the command line: + + % bin/withlist -l -r changepw mylist somebody@somewhere.org foobar""")) + parser.add_option('-i', '--interactive', + default=None, action='store_true', help=_("""\ +Leaves you at an interactive prompt after all other processing is complete. +This is the default unless the -r option is given.""")) + parser.add_option('-r', '--run', + type='string', help=_("""\ +This can be used to run a script with the opened IMailingList object. This +works by attempting to import'module' (which must be in the directory +containing withlist, or already be accessible on your sys.path), and then +calling 'callable' from the module. callable can be a class or function; it +is called with the IMailingList object as the first argument. If additional +args are given on the command line, they are passed as subsequent positional +args to the callable. + +Note that 'module.' is optional; if it is omitted then a module with the name +'callable' will be imported. + +The global variable 'r' will be set to the results of this call.""")) + parser.add_option('-a', '--all', + default=False, action='store_true', help=_("""\ +This option only works with the -r option. Use this if you want to execute +the script on all mailing lists. When you use -a you should not include a +listname argument on the command line. The variable 'r' will be a list of all +the results.""")) + parser.add_option('-q', '--quiet', + default=False, action='store_true', + help=_('Suppress all status messages.')) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + return parser, opts, args + + + +def main(): + global VERBOSE + + parser, opts, args = parseargs() + config_file = (os.getenv('MAILMAN_CONFIG_FILE') + if opts.config is None + else opts.config) + initialize(config_file, not opts.quiet) + + VERBOSE = not opts.quiet + # The default for interact is true unless -r was given + if opts.interactive is None: + if not opts.run: + opts.interactive = True + else: + opts.interactive = False + + dolist = True + if len(args) < 1 and not opts.all: + warning = _('No list name supplied.') + if opts.interactive: + # Let them keep going + print >> sys.stderr, warning + dolist = False + else: + parser.error(warning) + + if opts.all and not opts.run: + parser.error(_('--all requires --run')) + + # Try to import the module for the callable + func = None + if opts.run: + i = opts.run.rfind('.') + if i < 0: + module = opts.run + callable = opts.run + else: + module = opts.run[:i] + callable = opts.run[i+1:] + if VERBOSE: + print >> sys.stderr, _('Importing $module ...') + __import__(module) + mod = sys.modules[module] + if VERBOSE: + print >> sys.stderr, _('Running ${module}.${callable}() ...') + func = getattr(mod, callable) + + r = None + if opts.all: + r = [do_list(listname, args, func) + for listname in config.list_manager.names] + elif dolist: + listname = args.pop(0).lower().strip() + r = do_list(listname, args, func) + + # Now go to interactive mode, perhaps + if opts.interactive: + if dolist: + banner = _( + "The variable 'm' is the $listname mailing list") + else: + banner = interact.DEFAULT_BANNER + overrides = dict(m=LAST_MLIST, r=r, + commit=config.db.commit, + abort=config.db.abort, + config=config) + interact.interact(upframe=False, banner=banner, overrides=overrides) diff --git a/src/mailman/chains/__init__.py b/src/mailman/chains/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/mailman/chains/accept.py b/src/mailman/chains/accept.py new file mode 100644 index 000000000..bd47f42c8 --- /dev/null +++ b/src/mailman/chains/accept.py @@ -0,0 +1,58 @@ +# Copyright (C) 2007-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 . + +"""The terminal 'accept' chain.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'AcceptChain', + ] + +import logging + +from mailman.chains.base import TerminalChainBase +from mailman.config import config +from mailman.i18n import _ + + +log = logging.getLogger('mailman.vette') +SEMISPACE = '; ' + + + +class AcceptChain(TerminalChainBase): + """Accept the message for posting.""" + + name = 'accept' + description = _('Accept a message.') + + def _process(self, mlist, msg, msgdata): + """See `TerminalChainBase`.""" + # Start by decorating the message with a header that contains a list + # of all the rules that matched. These metadata could be None or an + # empty list. + rule_hits = msgdata.get('rule_hits') + if rule_hits: + msg['X-Mailman-Rule-Hits'] = SEMISPACE.join(rule_hits) + rule_misses = msgdata.get('rule_misses') + if rule_misses: + msg['X-Mailman-Rule-Misses'] = SEMISPACE.join(rule_misses) + accept_queue = config.switchboards['pipeline'] + accept_queue.enqueue(msg, msgdata) + log.info('ACCEPT: %s', msg.get('message-id', 'n/a')) diff --git a/src/mailman/chains/base.py b/src/mailman/chains/base.py new file mode 100644 index 000000000..bcd946b40 --- /dev/null +++ b/src/mailman/chains/base.py @@ -0,0 +1,122 @@ +# Copyright (C) 2008-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 . + +"""Base class for terminal chains.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Chain', + 'Link', + 'TerminalChainBase', + ] + + +from zope.interface import implements + +from mailman.config import config +from mailman.interfaces.chain import ( + IChain, IChainIterator, IChainLink, IMutableChain, LinkAction) + + + +class Link: + """A chain link.""" + implements(IChainLink) + + def __init__(self, rule, action=None, chain=None, function=None): + self.rule = rule + self.action = (LinkAction.defer if action is None else action) + self.chain = chain + self.function = function + + + +class TerminalChainBase: + """A base chain that always matches and executes a method. + + The method is called 'process' and must be provided by the subclass. + """ + implements(IChain, IChainIterator) + + def _process(self, mlist, msg, msgdata): + """Process the message for the given mailing list. + + This must be overridden by subclasses. + """ + raise NotImplementedError + + def get_links(self, mlist, msg, msgdata): + """See `IChain`.""" + return iter(self) + + def __iter__(self): + """See `IChainIterator`.""" + truth = config.rules['truth'] + # First, yield a link that always runs the process method. + yield Link(truth, LinkAction.run, function=self._process) + # Now yield a rule that stops all processing. + yield Link(truth, LinkAction.stop) + + + +class Chain: + """Generic chain base class.""" + implements(IMutableChain) + + def __init__(self, name, description): + assert name not in config.chains, ( + 'Duplicate chain name: {0}'.format(name)) + self.name = name + self.description = description + self._links = [] + # Register the chain. + config.chains[name] = self + + def append_link(self, link): + """See `IMutableChain`.""" + self._links.append(link) + + def flush(self): + """See `IMutableChain`.""" + self._links = [] + + def get_links(self, mlist, msg, msgdata): + """See `IChain`.""" + return iter(ChainIterator(self)) + + def get_iterator(self): + """Return an iterator over the links.""" + # We do it this way in order to preserve a separation of interfaces, + # and allows .get_links() to be overridden. + for link in self._links: + yield link + + + +class ChainIterator: + """Generic chain iterator.""" + + implements(IChainIterator) + + def __init__(self, chain): + self._chain = chain + + def __iter__(self): + """See `IChainIterator`.""" + return self._chain.get_iterator() diff --git a/src/mailman/chains/builtin.py b/src/mailman/chains/builtin.py new file mode 100644 index 000000000..05912a2f2 --- /dev/null +++ b/src/mailman/chains/builtin.py @@ -0,0 +1,86 @@ +# Copyright (C) 2007-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 . + +"""The default built-in starting chain.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'BuiltInChain', + ] + + +import logging + +from zope.interface import implements + +from mailman.chains.base import Link +from mailman.config import config +from mailman.i18n import _ +from mailman.interfaces.chain import IChain, LinkAction + + +log = logging.getLogger('mailman.vette') + + + +class BuiltInChain: + """Default built-in chain.""" + + implements(IChain) + + name = 'built-in' + description = _('The built-in moderation chain.') + + _link_descriptions = ( + ('approved', LinkAction.jump, 'accept'), + ('emergency', LinkAction.jump, 'hold'), + ('loop', LinkAction.jump, 'discard'), + # Do all of the following before deciding whether to hold the message + # for moderation. + ('administrivia', LinkAction.defer, None), + ('implicit-dest', LinkAction.defer, None), + ('max-recipients', LinkAction.defer, None), + ('max-size', LinkAction.defer, None), + ('news-moderation', LinkAction.defer, None), + ('no-subject', LinkAction.defer, None), + ('suspicious-header', LinkAction.defer, None), + # Now if any of the above hit, jump to the hold chain. + ('any', LinkAction.jump, 'hold'), + # Take a detour through the self header matching chain, which we'll + # create later. + ('truth', LinkAction.detour, 'header-match'), + # Finally, the builtin chain selfs to acceptance. + ('truth', LinkAction.jump, 'accept'), + ) + + def __init__(self): + self._cached_links = None + + def get_links(self, mlist, msg, msgdata): + """See `IChain`.""" + if self._cached_links is None: + self._cached_links = links = [] + for rule_name, action, chain_name in self._link_descriptions: + # Get the named rule. + rule = config.rules[rule_name] + # Get the chain, if one is defined. + chain = (None if chain_name is None + else config.chains[chain_name]) + links.append(Link(rule, action, chain)) + return iter(self._cached_links) diff --git a/src/mailman/chains/discard.py b/src/mailman/chains/discard.py new file mode 100644 index 000000000..1899e0340 --- /dev/null +++ b/src/mailman/chains/discard.py @@ -0,0 +1,47 @@ +# Copyright (C) 2007-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 . + +"""The terminal 'discard' chain.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'DiscardChain', + ] + + +import logging + +from mailman.chains.base import TerminalChainBase +from mailman.i18n import _ + + +log = logging.getLogger('mailman.vette') + + + +class DiscardChain(TerminalChainBase): + """Discard a message.""" + + name = 'discard' + description = _('Discard a message and stop processing.') + + def _process(self, mlist, msg, msgdata): + """See `TerminalChainBase`.""" + log.info('DISCARD: %s', msg.get('message-id', 'n/a')) + # Nothing more needs to happen. diff --git a/src/mailman/chains/headers.py b/src/mailman/chains/headers.py new file mode 100644 index 000000000..2f85d78d0 --- /dev/null +++ b/src/mailman/chains/headers.py @@ -0,0 +1,156 @@ +# Copyright (C) 2007-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 . + +"""The header-matching chain.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'HeaderMatchChain', + ] + + +import re +import logging +import itertools + +from zope.interface import implements + +from mailman.chains.base import Chain, Link +from mailman.config import config +from mailman.i18n import _ +from mailman.interfaces.chain import IChainIterator, LinkAction +from mailman.interfaces.rules import IRule + + +log = logging.getLogger('mailman.vette') + + + +def make_link(entry): + """Create a Link object. + + :param entry: a 2- or 3-tuple describing a link. If a 2-tuple, it is a + header and a pattern, and a default chain of 'hold' will be used. If + a 3-tuple, the third item is the chain name to use. + :return: an ILink. + """ + if len(entry) == 2: + header, pattern = entry + chain_name = 'hold' + elif len(entry) == 3: + header, pattern, chain_name = entry + # We don't assert that the chain exists here because the jump + # chain may not yet have been created. + else: + raise AssertionError('Bad link description: {0}'.format(entry)) + rule = HeaderMatchRule(header, pattern) + chain = config.chains[chain_name] + return Link(rule, LinkAction.jump, chain) + + + +class HeaderMatchRule: + """Header matching rule used by header-match chain.""" + implements(IRule) + + # Sequential rule counter. + _count = 1 + + def __init__(self, header, pattern): + self._header = header + self._pattern = pattern + self.name = 'header-match-{0:02}'.format(HeaderMatchRule._count) + HeaderMatchRule._count += 1 + self.description = '{0}: {1}'.format(header, pattern) + # XXX I think we should do better here, somehow recording that a + # particular header matched a particular pattern, but that gets ugly + # with RFC 2822 headers. It also doesn't match well with the rule + # name concept. For now, we just record the rather useless numeric + # rule name. I suppose we could do the better hit recording in the + # check() method, and set self.record = False. + self.record = True + + def check(self, mlist, msg, msgdata): + """See `IRule`.""" + for value in msg.get_all(self._header, []): + if re.search(self._pattern, value, re.IGNORECASE): + return True + return False + + + +class HeaderMatchChain(Chain): + """Default header matching chain. + + This could be extended by header match rules in the database. + """ + + def __init__(self): + super(HeaderMatchChain, self).__init__( + 'header-match', _('The built-in header matching chain')) + # The header match rules are not global, so don't register them. + # These are the only rules that the header match chain can execute. + self._links = [] + # Initialize header check rules with those from the global + # HEADER_MATCHES variable. + for entry in config.header_matches: + self._links.append(make_link(entry)) + # Keep track of how many global header matching rules we've seen. + # This is so the flush() method will only delete those that were added + # via extend() or append_link(). + self._permanent_link_count = len(self._links) + + def extend(self, header, pattern, chain_name='hold'): + """Extend the existing header matches. + + :param header: The case-insensitive header field name. + :param pattern: The pattern to match the header's value again. The + match is not anchored and is done case-insensitively. + :param chain: Option chain to jump to if the pattern matches any of + the named header values. If not given, the 'hold' chain is used. + """ + self._links.append(make_link((header, pattern, chain_name))) + + def flush(self): + """See `IMutableChain`.""" + del self._links[self._permanent_link_count:] + + def get_links(self, mlist, msg, msgdata): + """See `IChain`.""" + list_iterator = HeaderMatchIterator(mlist) + return itertools.chain(iter(self._links), iter(list_iterator)) + + def __iter__(self): + for link in self._links: + yield link + + + +class HeaderMatchIterator: + """An iterator of both the global and list-specific chain links.""" + + implements(IChainIterator) + + def __init__(self, mlist): + self._mlist = mlist + + def __iter__(self): + """See `IChainIterator`.""" + for entry in self._mlist.header_matches: + yield make_link(entry) diff --git a/src/mailman/chains/hold.py b/src/mailman/chains/hold.py new file mode 100644 index 000000000..16238a541 --- /dev/null +++ b/src/mailman/chains/hold.py @@ -0,0 +1,178 @@ +# Copyright (C) 2007-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 . + +"""The terminal 'hold' chain.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'HoldChain', + ] + + +import logging + +from email.mime.message import MIMEMessage +from email.mime.text import MIMEText +from email.utils import formatdate, make_msgid +from zope.interface import implements + +from mailman import i18n +from mailman.Message import UserNotification +from mailman.Utils import maketext, oneline, wrap, GetCharSet +from mailman.app.moderator import hold_message +from mailman.app.replybot import autorespond_to_sender, can_acknowledge +from mailman.chains.base import TerminalChainBase +from mailman.config import config +from mailman.interfaces.pending import IPendable + + +log = logging.getLogger('mailman.vette') +SEMISPACE = '; ' +_ = i18n._ + + + +class HeldMessagePendable(dict): + implements(IPendable) + PEND_KEY = 'held message' + + + +class HoldChain(TerminalChainBase): + """Hold a message.""" + + name = 'hold' + description = _('Hold a message and stop processing.') + + def _process(self, mlist, msg, msgdata): + """See `TerminalChainBase`.""" + # Start by decorating the message with a header that contains a list + # of all the rules that matched. These metadata could be None or an + # empty list. + rule_hits = msgdata.get('rule_hits') + if rule_hits: + msg['X-Mailman-Rule-Hits'] = SEMISPACE.join(rule_hits) + rule_misses = msgdata.get('rule_misses') + if rule_misses: + msg['X-Mailman-Rule-Misses'] = SEMISPACE.join(rule_misses) + # Hold the message by adding it to the list's request database. + # XXX How to calculate the reason? + request_id = hold_message(mlist, msg, msgdata, None) + # Calculate a confirmation token to send to the author of the + # message. + pendable = HeldMessagePendable(type=HeldMessagePendable.PEND_KEY, + id=request_id) + token = config.db.pendings.add(pendable) + # Get the language to send the response in. If the sender is a + # member, then send it in the member's language, otherwise send it in + # the mailing list's preferred language. + sender = msg.get_sender() + member = mlist.members.get_member(sender) + language = (member.preferred_language + if member else mlist.preferred_language) + # A substitution dictionary for the email templates. + charset = GetCharSet(mlist.preferred_language) + original_subject = msg.get('subject') + if original_subject is None: + original_subject = _('(no subject)') + else: + original_subject = oneline(original_subject, charset) + substitutions = dict( + listname = mlist.fqdn_listname, + subject = original_subject, + sender = sender, + reason = 'XXX', #reason, + confirmurl = '{0}/{1}'.format(mlist.script_url('confirm'), token), + admindb_url = mlist.script_url('admindb'), + ) + # At this point the message is held, but now we have to craft at least + # two responses. The first will go to the original author of the + # message and it will contain the token allowing them to approve or + # discard the message. The second one will go to the moderators of + # the mailing list, if the list is so configured. + # + # Start by possibly sending a response to the message author. There + # are several reasons why we might not go through with this. If the + # message was gated from NNTP, the author may not even know about this + # list, so don't spam them. If the author specifically requested that + # acknowledgments not be sent, or if the message was bulk email, then + # we do not send the response. It's also possible that either the + # mailing list, or the author (if they are a member) have been + # configured to not send such responses. + if (not msgdata.get('fromusenet') and + can_acknowledge(msg) and + mlist.respond_to_post_requests and + autorespond_to_sender(mlist, sender, language)): + # We can respond to the sender with a message indicating their + # posting was held. + subject = _( + 'Your message to $mlist.fqdn_listname awaits moderator approval') + send_language = msgdata.get('lang', language) + text = maketext('postheld.txt', substitutions, + lang=send_language, mlist=mlist) + adminaddr = mlist.bounces_address + nmsg = UserNotification(sender, adminaddr, subject, text, + send_language) + nmsg.send(mlist) + # Now the message for the list moderators. This one should appear to + # come from -owner since we really don't need to do bounce + # processing on it. + if mlist.admin_immed_notify: + # Now let's temporarily set the language context to that which the + # administrators are expecting. + with i18n.using_language(mlist.preferred_language): + language = mlist.preferred_language + charset = GetCharSet(language) + # We need to regenerate or re-translate a few values in the + # substitution dictionary. + #d['reason'] = _(reason) # XXX reason + substitutions['subject'] = original_subject + # craft the admin notification message and deliver it + subject = _( + '$mlist.fqdn_listname post from $sender requires approval') + nmsg = UserNotification(mlist.owner_address, + mlist.owner_address, + subject, lang=language) + nmsg.set_type('multipart/mixed') + text = MIMEText( + maketext('postauth.txt', substitutions, + raw=True, mlist=mlist), + _charset=charset) + dmsg = MIMEText(wrap(_("""\ +If you reply to this message, keeping the Subject: header intact, Mailman will +discard the held message. Do this if the message is spam. If you reply to +this message and include an Approved: header with the list password in it, the +message will be approved for posting to the list. The Approved: header can +also appear in the first line of the body of the reply.""")), + _charset=GetCharSet(language)) + dmsg['Subject'] = 'confirm ' + token + dmsg['Sender'] = mlist.request_address + dmsg['From'] = mlist.request_address + dmsg['Date'] = formatdate(localtime=True) + dmsg['Message-ID'] = make_msgid() + nmsg.attach(text) + nmsg.attach(MIMEMessage(msg)) + nmsg.attach(MIMEMessage(dmsg)) + nmsg.send(mlist, **dict(tomoderators=True)) + # Log the held message + # XXX reason + reason = 'n/a' + log.info('HOLD: %s post from %s held, message-id=%s: %s', + mlist.fqdn_listname, sender, + msg.get('message-id', 'n/a'), reason) diff --git a/src/mailman/chains/reject.py b/src/mailman/chains/reject.py new file mode 100644 index 000000000..3faf563da --- /dev/null +++ b/src/mailman/chains/reject.py @@ -0,0 +1,59 @@ +# Copyright (C) 2007-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 . + +"""The terminal 'reject' chain.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'RejectChain', + ] + + +import logging + +from mailman.app.bounces import bounce_message +from mailman.chains.base import TerminalChainBase +from mailman.i18n import _ + + +log = logging.getLogger('mailman.vette') +SEMISPACE = '; ' + + + +class RejectChain(TerminalChainBase): + """Reject/bounce a message.""" + + name = 'reject' + description = _('Reject/bounce a message and stop processing.') + + def _process(self, mlist, msg, msgdata): + """See `TerminalChainBase`.""" + # Start by decorating the message with a header that contains a list + # of all the rules that matched. These metadata could be None or an + # empty list. + rule_hits = msgdata.get('rule_hits') + if rule_hits: + msg['X-Mailman-Rule-Hits'] = SEMISPACE.join(rule_hits) + rule_misses = msgdata.get('rule_misses') + if rule_misses: + msg['X-Mailman-Rule-Misses'] = SEMISPACE.join(rule_misses) + # XXX Exception/reason + bounce_message(mlist, msg) + log.info('REJECT: %s', msg.get('message-id', 'n/a')) diff --git a/src/mailman/commands/__init__.py b/src/mailman/commands/__init__.py new file mode 100644 index 000000000..6e89bc6da --- /dev/null +++ b/src/mailman/commands/__init__.py @@ -0,0 +1,22 @@ +# Copyright (C) 2008-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 . + +__all__ = [ + 'echo', + 'end', + 'join', + ] diff --git a/src/mailman/commands/cmd_confirm.py b/src/mailman/commands/cmd_confirm.py new file mode 100644 index 000000000..b5e4182bd --- /dev/null +++ b/src/mailman/commands/cmd_confirm.py @@ -0,0 +1,98 @@ +# 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 . + +""" + confirm + Confirm an action. The confirmation-string is required and should be + supplied by a mailback confirmation notice. +""" + +from mailman import Errors +from mailman import Pending +from mailman.config import config +from mailman.i18n import _ + +STOP = 1 + + + +def gethelp(mlist): + return _(__doc__) + + + +def process(res, args): + mlist = res.mlist + if len(args) <> 1: + res.results.append(_('Usage:')) + res.results.append(gethelp(mlist)) + return STOP + cookie = args[0] + try: + results = mlist.ProcessConfirmation(cookie, res.msg) + except Errors.MMBadConfirmation, e: + # Express in approximate days + days = int(config.PENDING_REQUEST_LIFE / config.days(1) + 0.5) + res.results.append(_("""\ +Invalid confirmation string. 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 original request or +message.""")) + except Errors.MMNeedApproval: + res.results.append(_("""\ +Your request has been forwarded to the list moderator for approval.""")) + except Errors.MMAlreadyAMember: + # Some other subscription request for this address has + # already succeeded. + res.results.append(_('You are already subscribed.')) + except Errors.NotAMemberError: + # They've already been unsubscribed + res.results.append(_("""\ +You are not currently a member. Have you already unsubscribed or changed +your email address?""")) + except Errors.MembershipIsBanned: + owneraddr = mlist.GetOwnerEmail() + res.results.append(_("""\ +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: + res.results.append(_("""\ +You were not invited to this mailing list. The invitation has been discarded, +and both list administrators have been alerted.""")) + except Errors.MMBadPasswordError: + res.results.append(_("""\ +Bad approval password given. Held message is still being held.""")) + else: + if ((results[0] == Pending.SUBSCRIPTION and mlist.send_welcome_msg) + or + (results[0] == Pending.UNSUBSCRIPTION and mlist.send_goodbye_msg)): + # We don't also need to send a confirmation succeeded message + res.respond = 0 + else: + res.results.append(_('Confirmation succeeded')) + # Consume any other confirmation strings with the same cookie so + # the user doesn't get a misleading "unprocessed" message. + match = 'confirm ' + cookie + unprocessed = [] + for line in res.commands: + if line.lstrip() == match: + continue + unprocessed.append(line) + res.commands = unprocessed + # Process just one confirmation string per message + return STOP diff --git a/src/mailman/commands/cmd_help.py b/src/mailman/commands/cmd_help.py new file mode 100644 index 000000000..eeee33ca7 --- /dev/null +++ b/src/mailman/commands/cmd_help.py @@ -0,0 +1,93 @@ +# 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 . + +""" + help + Print this help message. +""" + +import os +import sys + +from mailman import Utils +from mailman.config import config +from mailman.i18n import _ + +EMPTYSTRING = '' + + + +def gethelp(mlist): + return _(__doc__) + + + +def process(res, args): + # Get the help text introduction + mlist = res.mlist + # Since this message is personalized, add some useful information if the + # address requesting help is a member of the list. + msg = res.msg + for sender in msg.get_senders(): + if mlist.isMember(sender): + memberurl = mlist.GetOptionsURL(sender, absolute=1) + urlhelp = _( + 'You can access your personal options via the following url:') + res.results.append(urlhelp) + res.results.append(memberurl) + # Get a blank line in the output. + res.results.append('') + break + # build the specific command helps from the module docstrings + modhelps = {} + import mailman.Commands + path = os.path.dirname(os.path.abspath(mailman.Commands.__file__)) + for file in os.listdir(path): + if not file.startswith('cmd_') or not file.endswith('.py'): + continue + module = os.path.splitext(file)[0] + modname = 'mailman.Commands.' + module + try: + __import__(modname) + except ImportError: + continue + cmdname = module[4:] + help = None + if hasattr(sys.modules[modname], 'gethelp'): + help = sys.modules[modname].gethelp(mlist) + if help: + modhelps[cmdname] = help + # Now sort the command helps + helptext = [] + keys = modhelps.keys() + keys.sort() + for cmd in keys: + helptext.append(modhelps[cmd]) + commands = EMPTYSTRING.join(helptext) + # Now craft the response + helptext = Utils.maketext( + 'help.txt', + {'listname' : mlist.real_name, + 'version' : config.VERSION, + 'listinfo_url': mlist.GetScriptURL('listinfo', absolute=1), + 'requestaddr' : mlist.GetRequestEmail(), + 'adminaddr' : mlist.GetOwnerEmail(), + 'commands' : commands, + }, mlist=mlist, lang=res.msgdata['lang'], raw=1) + # Now add to the response + res.results.append('help') + res.results.append(helptext) diff --git a/src/mailman/commands/cmd_info.py b/src/mailman/commands/cmd_info.py new file mode 100644 index 000000000..3bdea178f --- /dev/null +++ b/src/mailman/commands/cmd_info.py @@ -0,0 +1,50 @@ +# 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 . + +""" + info + Get information about this mailing list. +""" + +from mailman.i18n import _ + +STOP = 1 + + + +def gethelp(mlist): + return _(__doc__) + + + +def process(res, args): + mlist = res.mlist + if args: + res.results.append(gethelp(mlist)) + return STOP + listname = mlist.real_name + description = mlist.description or _('n/a') + postaddr = mlist.posting_address + requestaddr = mlist.request_address + owneraddr = mlist.owner_address + listurl = mlist.script_url('listinfo') + res.results.append(_('List name: %(listname)s')) + res.results.append(_('Description: %(description)s')) + res.results.append(_('Postings to: %(postaddr)s')) + res.results.append(_('List Helpbot: %(requestaddr)s')) + res.results.append(_('List Owners: %(owneraddr)s')) + res.results.append(_('More information: %(listurl)s')) diff --git a/src/mailman/commands/cmd_leave.py b/src/mailman/commands/cmd_leave.py new file mode 100644 index 000000000..5844824f7 --- /dev/null +++ b/src/mailman/commands/cmd_leave.py @@ -0,0 +1,21 @@ +# 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 . + +"""The `leave' command is synonymous with `unsubscribe'. +""" + +from mailman.Commands.cmd_unsubscribe import process diff --git a/src/mailman/commands/cmd_lists.py b/src/mailman/commands/cmd_lists.py new file mode 100644 index 000000000..234ef46fc --- /dev/null +++ b/src/mailman/commands/cmd_lists.py @@ -0,0 +1,65 @@ +# 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 . + +""" + lists + See a list of the public mailing lists on this GNU Mailman server. +""" + +from mailman.MailList import MailList +from mailman.config import config +from mailman.i18n import _ + + +STOP = 1 + + + +def gethelp(mlist): + return _(__doc__) + + + +def process(res, args): + mlist = res.mlist + if args: + res.results.append(_('Usage:')) + res.results.append(gethelp(mlist)) + return STOP + hostname = mlist.host_name + res.results.append(_('Public mailing lists at %(hostname)s:')) + i = 1 + for listname in sorted(config.list_manager.names): + if listname == mlist.internal_name(): + xlist = mlist + else: + xlist = MailList(listname, lock=0) + # We can mention this list if you already know about it + if not xlist.advertised and xlist is not mlist: + continue + # Skip the list if it isn't in the same virtual domain. + if xlist.host_name <> mlist.host_name: + continue + realname = xlist.real_name + description = xlist.description or _('n/a') + requestaddr = xlist.GetRequestEmail() + if i > 1: + res.results.append('') + res.results.append(_('%(i)3d. List name: %(realname)s')) + res.results.append(_(' Description: %(description)s')) + res.results.append(_(' Requests to: %(requestaddr)s')) + i += 1 diff --git a/src/mailman/commands/cmd_password.py b/src/mailman/commands/cmd_password.py new file mode 100644 index 000000000..545da0cb5 --- /dev/null +++ b/src/mailman/commands/cmd_password.py @@ -0,0 +1,123 @@ +# 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 . + +""" + password [ ] [address=
      ] + Retrieve or change your password. With no arguments, this returns + your current password. With arguments and + you can change your password. + + If you're posting from an address other than your membership address, + specify your membership address with `address=
      ' (no brackets + around the email address, and no quotes!). Note that in this case the + response is always sent to the subscribed address. +""" + +from email.Utils import parseaddr + +from mailman.config import config +from mailman.i18n import _ + +STOP = 1 + + + +def gethelp(mlist): + return _(__doc__) + + + +def process(res, args): + mlist = res.mlist + address = None + if not args: + # They just want to get their existing password + realname, address = parseaddr(res.msg['from']) + if mlist.isMember(address): + password = mlist.getMemberPassword(address) + res.results.append(_('Your password is: %(password)s')) + # Prohibit multiple password retrievals. + return STOP + else: + listname = mlist.real_name + res.results.append( + _('You are not a member of the %(listname)s mailing list')) + return STOP + elif len(args) == 1 and args[0].startswith('address='): + # They want their password, but they're posting from a different + # address. We /must/ return the password to the subscribed address. + address = args[0][8:] + res.returnaddr = address + if mlist.isMember(address): + password = mlist.getMemberPassword(address) + res.results.append(_('Your password is: %(password)s')) + # Prohibit multiple password retrievals. + return STOP + else: + listname = mlist.real_name + res.results.append( + _('You are not a member of the %(listname)s mailing list')) + return STOP + elif len(args) == 2: + # They are changing their password + oldpasswd = args[0] + newpasswd = args[1] + realname, address = parseaddr(res.msg['from']) + if mlist.isMember(address): + if mlist.Authenticate((config.AuthUser, config.AuthListAdmin), + oldpasswd, address): + mlist.setMemberPassword(address, newpasswd) + res.results.append(_('Password successfully changed.')) + else: + res.results.append(_("""\ +You did not give the correct old password, so your password has not been +changed. Use the no argument version of the password command to retrieve your +current password, then try again.""")) + res.results.append(_('\nUsage:')) + res.results.append(gethelp(mlist)) + return STOP + else: + listname = mlist.real_name + res.results.append( + _('You are not a member of the %(listname)s mailing list')) + return STOP + elif len(args) == 3 and args[2].startswith('address='): + # They want to change their password, and they're sending this from a + # different address than what they're subscribed with. Be sure the + # response goes to the subscribed address. + oldpasswd = args[0] + newpasswd = args[1] + address = args[2][8:] + res.returnaddr = address + if mlist.isMember(address): + if mlist.Authenticate((config.AuthUser, config.AuthListAdmin), + oldpasswd, address): + mlist.setMemberPassword(address, newpasswd) + res.results.append(_('Password successfully changed.')) + else: + res.results.append(_("""\ +You did not give the correct old password, so your password has not been +changed. Use the no argument version of the password command to retrieve your +current password, then try again.""")) + res.results.append(_('\nUsage:')) + res.results.append(gethelp(mlist)) + return STOP + else: + listname = mlist.real_name + res.results.append( + _('You are not a member of the %(listname)s mailing list')) + return STOP diff --git a/src/mailman/commands/cmd_remove.py b/src/mailman/commands/cmd_remove.py new file mode 100644 index 000000000..8f3ce9669 --- /dev/null +++ b/src/mailman/commands/cmd_remove.py @@ -0,0 +1,21 @@ +# 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 . + +"""The `remove' command is synonymous with `unsubscribe'. +""" + +from mailman.Commands.cmd_unsubscribe import process diff --git a/src/mailman/commands/cmd_set.py b/src/mailman/commands/cmd_set.py new file mode 100644 index 000000000..020bc3636 --- /dev/null +++ b/src/mailman/commands/cmd_set.py @@ -0,0 +1,360 @@ +# 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 . + +from email.Utils import parseaddr, formatdate + +from mailman import Errors +from mailman import MemberAdaptor +from mailman import i18n +from mailman.config import config + +def _(s): return s + +OVERVIEW = _(""" + set ... + Set or view your membership options. + + Use `set help' (without the quotes) to get a more detailed list of the + options you can change. + + Use `set show' (without the quotes) to view your current option + settings. +""") + +DETAILS = _(""" + set help + Show this detailed help. + + set show [address=
      ] + View your current option settings. If you're posting from an address + other than your membership address, specify your membership address + with `address=
      ' (no brackets around the email address, and no + quotes!). + + set authenticate [address=
      ] + To set any of your options, you must include this command first, along + with your membership password. If you're posting from an address + other than your membership address, specify your membership address + with `address=
      ' (no brackets around the email address, and no + quotes!). + + set ack on + set ack off + When the `ack' option is turned on, you will receive an + acknowledgement message whenever you post a message to the list. + + set digest plain + set digest mime + set digest off + When the `digest' option is turned off, you will receive postings + immediately when they are posted. Use `set digest plain' if instead + you want to receive postings bundled into a plain text digest + (i.e. RFC 1153 digest). Use `set digest mime' if instead you want to + receive postings bundled together into a MIME digest. + + set delivery on + set delivery off + Turn delivery on or off. This does not unsubscribe you, but instead + tells Mailman not to deliver messages to you for now. This is useful + if you're going on vacation. Be sure to use `set delivery on' when + you return from vacation! + + set myposts on + set myposts off + Use `set myposts off' to not receive copies of messages you post to + the list. This has no effect if you're receiving digests. + + set hide on + set hide off + Use `set hide on' to conceal your email address when people request + the membership list. + + set duplicates on + set duplicates off + Use `set duplicates off' if you want Mailman to not send you messages + if your address is explicitly mentioned in the To: or Cc: fields of + the message. This can reduce the number of duplicate postings you + will receive. + + set reminders on + set reminders off + Use `set reminders off' if you want to disable the monthly password + reminder for this mailing list. +""") + +_ = i18n._ + +STOP = 1 + + + +def gethelp(mlist): + return _(OVERVIEW) + + + +class SetCommands: + def __init__(self): + self.__address = None + self.__authok = 0 + + def process(self, res, args): + if not args: + res.results.append(_(DETAILS)) + return STOP + subcmd = args.pop(0) + methname = 'set_' + subcmd + method = getattr(self, methname, None) + if method is None: + res.results.append(_('Bad set command: %(subcmd)s')) + res.results.append(_(DETAILS)) + return STOP + return method(res, args) + + def set_help(self, res, args=1): + res.results.append(_(DETAILS)) + if args: + return STOP + + def _usage(self, res): + res.results.append(_('Usage:')) + return self.set_help(res) + + def set_show(self, res, args): + mlist = res.mlist + if not args: + realname, address = parseaddr(res.msg['from']) + elif len(args) == 1 and args[0].startswith('address='): + # Send the results to the address, not the From: dude + address = args[0][8:] + res.returnaddr = address + else: + return self._usage(res) + if not mlist.isMember(address): + listname = mlist.real_name + res.results.append( + _('You are not a member of the %(listname)s mailing list')) + return STOP + res.results.append(_('Your current option settings:')) + opt = mlist.getMemberOption(address, config.AcknowledgePosts) + onoff = opt and _('on') or _('off') + res.results.append(_(' ack %(onoff)s')) + # Digests are a special ternary value + digestsp = mlist.getMemberOption(address, config.Digests) + if digestsp: + plainp = mlist.getMemberOption(address, config.DisableMime) + if plainp: + res.results.append(_(' digest plain')) + else: + res.results.append(_(' digest mime')) + else: + res.results.append(_(' digest off')) + # If their membership is disabled, let them know why + status = mlist.getDeliveryStatus(address) + how = None + if status == MemberAdaptor.ENABLED: + status = _('delivery on') + elif status == MemberAdaptor.BYUSER: + status = _('delivery off') + how = _('by you') + elif status == MemberAdaptor.BYADMIN: + status = _('delivery off') + how = _('by the admin') + elif status == MemberAdaptor.BYBOUNCE: + status = _('delivery off') + how = _('due to bounces') + else: + assert status == MemberAdaptor.UNKNOWN + status = _('delivery off') + how = _('for unknown reasons') + changetime = mlist.getDeliveryStatusChangeTime(address) + if how and changetime > 0: + date = formatdate(changetime) + res.results.append(_(' %(status)s (%(how)s on %(date)s)')) + else: + res.results.append(' ' + status) + opt = mlist.getMemberOption(address, config.DontReceiveOwnPosts) + # sense is reversed + onoff = (not opt) and _('on') or _('off') + res.results.append(_(' myposts %(onoff)s')) + opt = mlist.getMemberOption(address, config.ConcealSubscription) + onoff = opt and _('on') or _('off') + res.results.append(_(' hide %(onoff)s')) + opt = mlist.getMemberOption(address, config.DontReceiveDuplicates) + # sense is reversed + onoff = (not opt) and _('on') or _('off') + res.results.append(_(' duplicates %(onoff)s')) + opt = mlist.getMemberOption(address, config.SuppressPasswordReminder) + # sense is reversed + onoff = (not opt) and _('on') or _('off') + res.results.append(_(' reminders %(onoff)s')) + + def set_authenticate(self, res, args): + mlist = res.mlist + if len(args) == 1: + realname, address = parseaddr(res.msg['from']) + password = args[0] + elif len(args) == 2 and args[1].startswith('address='): + password = args[0] + address = args[1][8:] + else: + return self._usage(res) + # See if the password matches + if not mlist.isMember(address): + listname = mlist.real_name + res.results.append( + _('You are not a member of the %(listname)s mailing list')) + return STOP + if not mlist.Authenticate((config.AuthUser, + config.AuthListAdmin), + password, address): + res.results.append(_('You did not give the correct password')) + return STOP + self.__authok = 1 + self.__address = address + + def _status(self, res, arg): + status = arg.lower() + if status == 'on': + flag = 1 + elif status == 'off': + flag = 0 + else: + res.results.append(_('Bad argument: %(arg)s')) + self._usage(res) + return -1 + # See if we're authenticated + if not self.__authok: + res.results.append(_('Not authenticated')) + self._usage(res) + return -1 + return flag + + def set_ack(self, res, args): + mlist = res.mlist + if len(args) <> 1: + return self._usage(res) + status = self._status(res, args[0]) + if status < 0: + return STOP + mlist.setMemberOption(self.__address, config.AcknowledgePosts, status) + res.results.append(_('ack option set')) + + def set_digest(self, res, args): + mlist = res.mlist + if len(args) <> 1: + return self._usage(res) + if not self.__authok: + res.results.append(_('Not authenticated')) + self._usage(res) + return STOP + arg = args[0].lower() + if arg == 'off': + try: + mlist.setMemberOption(self.__address, config.Digests, 0) + except Errors.AlreadyReceivingRegularDeliveries: + pass + elif arg == 'plain': + try: + mlist.setMemberOption(self.__address, config.Digests, 1) + except Errors.AlreadyReceivingDigests: + pass + mlist.setMemberOption(self.__address, config.DisableMime, 1) + elif arg == 'mime': + try: + mlist.setMemberOption(self.__address, config.Digests, 1) + except Errors.AlreadyReceivingDigests: + pass + mlist.setMemberOption(self.__address, config.DisableMime, 0) + else: + res.results.append(_('Bad argument: %(arg)s')) + self._usage(res) + return STOP + res.results.append(_('digest option set')) + + def set_delivery(self, res, args): + mlist = res.mlist + if len(args) <> 1: + return self._usage(res) + status = self._status(res, args[0]) + if status < 0: + return STOP + # Delivery status is handled differently than other options. If + # status is true (set delivery on), then we enable delivery. + # Otherwise, we have to use the setDeliveryStatus() interface to + # specify that delivery was disabled by the user. + if status: + mlist.setDeliveryStatus(self.__address, MemberAdaptor.ENABLED) + res.results.append(_('delivery enabled')) + else: + mlist.setDeliveryStatus(self.__address, MemberAdaptor.BYUSER) + res.results.append(_('delivery disabled by user')) + + def set_myposts(self, res, args): + mlist = res.mlist + if len(args) <> 1: + return self._usage(res) + status = self._status(res, args[0]) + if status < 0: + return STOP + # sense is reversed + mlist.setMemberOption(self.__address, config.DontReceiveOwnPosts, + not status) + res.results.append(_('myposts option set')) + + def set_hide(self, res, args): + mlist = res.mlist + if len(args) <> 1: + return self._usage(res) + status = self._status(res, args[0]) + if status < 0: + return STOP + mlist.setMemberOption(self.__address, config.ConcealSubscription, + status) + res.results.append(_('hide option set')) + + def set_duplicates(self, res, args): + mlist = res.mlist + if len(args) <> 1: + return self._usage(res) + status = self._status(res, args[0]) + if status < 0: + return STOP + # sense is reversed + mlist.setMemberOption(self.__address, config.DontReceiveDuplicates, + not status) + res.results.append(_('duplicates option set')) + + def set_reminders(self, res, args): + mlist = res.mlist + if len(args) <> 1: + return self._usage(res) + status = self._status(res, args[0]) + if status < 0: + return STOP + # sense is reversed + mlist.setMemberOption(self.__address, config.SuppressPasswordReminder, + not status) + res.results.append(_('reminder option set')) + + + +def process(res, args): + # We need to keep some state between set commands + if not getattr(res, 'setstate', None): + res.setstate = SetCommands() + res.setstate.process(res, args) diff --git a/src/mailman/commands/cmd_unsubscribe.py b/src/mailman/commands/cmd_unsubscribe.py new file mode 100644 index 000000000..456b8089d --- /dev/null +++ b/src/mailman/commands/cmd_unsubscribe.py @@ -0,0 +1,88 @@ +# 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 . + +""" + unsubscribe [password] [address=
      ] + Unsubscribe from the mailing list. If given, your password must match + your current password. If omitted, a confirmation email will be sent + to the unsubscribing address. If you wish to unsubscribe an address + other than the address you sent this request from, you may specify + `address=
      ' (no brackets around the email address, and no + quotes!) +""" + +from email.Utils import parseaddr + +from mailman import Errors +from mailman.i18n import _ + +STOP = 1 + + + +def gethelp(mlist): + return _(__doc__) + + + +def process(res, args): + mlist = res.mlist + password = None + address = None + argnum = 0 + for arg in args: + if arg.startswith('address='): + address = arg[8:] + elif argnum == 0: + password = arg + else: + res.results.append(_('Usage:')) + res.results.append(gethelp(mlist)) + return STOP + argnum += 1 + # Fill in empty defaults + if address is None: + realname, address = parseaddr(res.msg['from']) + if not mlist.isMember(address): + listname = mlist.real_name + res.results.append( + _('%(address)s is not a member of the %(listname)s mailing list')) + return STOP + # If we're doing admin-approved unsubs, don't worry about the password + if mlist.unsubscribe_policy: + try: + mlist.DeleteMember(address, 'mailcmd') + except Errors.MMNeedApproval: + res.results.append(_("""\ +Your unsubscription request has been forwarded to the list administrator for +approval.""")) + elif password is None: + # No password was given, so we need to do a mailback confirmation + # instead of unsubscribing them here. + cpaddr = mlist.getMemberCPAddress(address) + mlist.ConfirmUnsubscription(cpaddr) + # We don't also need to send a confirmation to this command + res.respond = 0 + else: + # No admin approval is necessary, so we can just delete them if the + # passwords match. + oldpw = mlist.getMemberPassword(address) + if oldpw <> password: + res.results.append(_('You gave the wrong password')) + return STOP + mlist.ApprovedDeleteMember(address, 'mailcmd') + res.results.append(_('Unsubscription request succeeded.')) diff --git a/src/mailman/commands/cmd_who.py b/src/mailman/commands/cmd_who.py new file mode 100644 index 000000000..6c66610b3 --- /dev/null +++ b/src/mailman/commands/cmd_who.py @@ -0,0 +1,152 @@ +# 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 . + +from email.Utils import parseaddr + +from mailman import i18n +from mailman.config import config + +STOP = 1 + +def _(s): return s + +PUBLICHELP = _(""" + who + See the non-hidden members of this mailing list. + who password + See everyone who is on this mailing list. The password is the + list's admin or moderator password. +""") + +MEMBERSONLYHELP = _(""" + who password [address=
      ] + See the non-hidden members of this mailing list. The roster is + limited to list members only, and you must supply your membership + password to retrieve it. If you're posting from an address other + than your membership address, specify your membership address with + `address=
      ' (no brackets around the email address, and no + quotes!). If you provide the list's admin or moderator password, + hidden members will be included. +""") + +ADMINONLYHELP = _(""" + who password + See everyone who is on this mailing list. The roster is limited to + list administrators and moderators only; you must supply the list + admin or moderator password to retrieve the roster. +""") + +_ = i18n._ + + + +def gethelp(mlist): + if mlist.private_roster == 0: + return _(PUBLICHELP) + elif mlist.private_roster == 1: + return _(MEMBERSONLYHELP) + elif mlist.private_roster == 2: + return _(ADMINONLYHELP) + + +def usage(res): + res.results.append(_('Usage:')) + res.results.append(gethelp(res.mlist)) + + + +def process(res, args): + mlist = res.mlist + address = None + password = None + ok = False + full = False + if mlist.private_roster == 0: + # Public rosters + if args: + if len(args) == 1: + if mlist.Authenticate((config.AuthListModerator, + config.AuthListAdmin), + args[0]): + full = True + else: + usage(res) + return STOP + else: + usage(res) + return STOP + ok = True + elif mlist.private_roster == 1: + # List members only + if len(args) == 1: + password = args[0] + realname, address = parseaddr(res.msg['from']) + elif len(args) == 2 and args[1].startswith('address='): + password = args[0] + address = args[1][8:] + else: + usage(res) + return STOP + if mlist.isMember(address) and mlist.Authenticate( + (config.AuthUser, + config.AuthListModerator, + config.AuthListAdmin), + password, address): + # Then + ok = True + if mlist.Authenticate( + (config.AuthListModerator, + config.AuthListAdmin), + password): + # Then + ok = full = True + else: + # Admin only + if len(args) <> 1: + usage(res) + return STOP + if mlist.Authenticate((config.AuthListModerator, + config.AuthListAdmin), + args[0]): + ok = full = True + if not ok: + res.results.append( + _('You are not allowed to retrieve the list membership.')) + return STOP + # It's okay for this person to see the list membership + dmembers = mlist.getDigestMemberKeys() + rmembers = mlist.getRegularMemberKeys() + if not dmembers and not rmembers: + res.results.append(_('This list has no members.')) + return + # Convenience function + def addmembers(members): + for member in members: + if not full and mlist.getMemberOption(member, + config.ConcealSubscription): + continue + realname = mlist.getMemberName(member) + if realname: + res.results.append(' %s (%s)' % (member, realname)) + else: + res.results.append(' %s' % member) + if rmembers: + res.results.append(_('Non-digest (regular) members:')) + addmembers(rmembers) + if dmembers: + res.results.append(_('Digest members:')) + addmembers(dmembers) diff --git a/src/mailman/commands/docs/echo.txt b/src/mailman/commands/docs/echo.txt new file mode 100644 index 000000000..181cc58c8 --- /dev/null +++ b/src/mailman/commands/docs/echo.txt @@ -0,0 +1,30 @@ +The 'echo' command +================== + +The mail command 'echo' simply replies with the original command and arguments +to the sender. + + >>> command = config.commands['echo'] + >>> command.name + 'echo' + >>> command.argument_description + '[args]' + >>> command.description + u'Echo an acknowledgement. Arguments are return unchanged.' + +The original message is ignored, but the results receive the echoed command. + + >>> from mailman.app.lifecycle import create_list + >>> mlist = create_list(u'test@example.com') + + >>> from mailman.queue.command import Results + >>> results = Results() + + >>> from mailman.Message import Message + >>> print command.process(mlist, Message(), {}, ('foo', 'bar'), results) + ContinueProcessing.yes + >>> print unicode(results) + The results of your email command are provided below. + + echo foo bar + diff --git a/src/mailman/commands/docs/end.txt b/src/mailman/commands/docs/end.txt new file mode 100644 index 000000000..4f6af26cb --- /dev/null +++ b/src/mailman/commands/docs/end.txt @@ -0,0 +1,37 @@ +The 'end' command +================= + +The mail command processor recognized an 'end' command which tells it to stop +processing email messages. + + >>> command = config.commands['end'] + >>> command.name + 'end' + >>> command.description + u'Stop processing commands.' + +The 'end' command takes no arguments. + + >>> command.argument_description + '' + +The command itself is fairly simple; it just stops command processing, and the +message isn't even looked at. + + >>> from mailman.app.lifecycle import create_list + >>> mlist = create_list(u'test@example.com') + >>> from mailman.Message import Message + >>> print command.process(mlist, Message(), {}, (), None) + ContinueProcessing.no + +The 'stop' command is a synonym for 'end'. + + >>> command = config.commands['stop'] + >>> command.name + 'stop' + >>> command.description + u'Stop processing commands.' + >>> command.argument_description + '' + >>> print command.process(mlist, Message(), {}, (), None) + ContinueProcessing.no diff --git a/src/mailman/commands/docs/join.txt b/src/mailman/commands/docs/join.txt new file mode 100644 index 000000000..9b85e816c --- /dev/null +++ b/src/mailman/commands/docs/join.txt @@ -0,0 +1,170 @@ +The 'join' command +================== + +The mail command 'join' subscribes an email address to the mailing list. +'subscribe' is an alias for 'join'. + + >>> command = config.commands['join'] + >>> print command.name + join + >>> print command.description + Join this mailing list. You will be asked to confirm your subscription + request and you may be issued a provisional password. + + By using the 'digest' option, you can specify whether you want digest + delivery or not. If not specified, the mailing list's default will be + used. You can also subscribe an alternative address by using the + 'address' option. For example: + + join address=myotheraddress@example.com + + >>> print command.argument_description + [digest=] [address=
      ] + + +No address to join +------------------ + + >>> from mailman.Message import Message + >>> from mailman.app.lifecycle import create_list + >>> from mailman.queue.command import Results + >>> mlist = create_list(u'alpha@example.com') + +When no address argument is given, the message's From address will be used. +If that's missing though, then an error is returned. + + >>> results = Results() + >>> print command.process(mlist, Message(), {}, (), results) + ContinueProcessing.no + >>> print unicode(results) + The results of your email command are provided below. + + join: No valid address found to subscribe + + +The 'subscribe' command is an alias. + + >>> subscribe = config.commands['subscribe'] + >>> print subscribe.name + subscribe + >>> results = Results() + >>> print subscribe.process(mlist, Message(), {}, (), results) + ContinueProcessing.no + >>> print unicode(results) + The results of your email command are provided below. + + subscribe: No valid address found to subscribe + + + +Joining the sender +------------------ + +When the message has a From field, that address will be subscribed. + + >>> msg = message_from_string("""\ + ... From: Anne Person + ... + ... """) + >>> results = Results() + >>> print command.process(mlist, msg, {}, (), results) + ContinueProcessing.yes + >>> print unicode(results) + The results of your email command are provided below. + + Confirmation email sent to Anne Person + + +Anne is not yet a member because she must confirm her subscription request +first. + + >>> print config.db.user_manager.get_user(u'anne@example.com') + None + +Mailman has sent her the confirmation message. + + >>> virginq = config.switchboards['virgin'] + >>> qmsg, qdata = virginq.dequeue(virginq.files[0]) + >>> print qmsg.as_string() + MIME-Version: 1.0 + ... + Subject: confirm ... + From: confirm-...@example.com + To: anne@example.com + ... + + Email Address Registration Confirmation + + Hello, this is the GNU Mailman server at example.com. + + We have received a registration request for the email address + + anne@example.com + + Before you can start using GNU Mailman at this site, you must first + confirm that this is your email address. You can do this by replying to + this message, keeping the Subject header intact. Or you can visit this + web page + + http://lists.example.com/confirm/... + + If you do not wish to register this email address simply disregard this + message. If you think you are being maliciously subscribed to the list, or + have any other questions, you may contact + + postmaster@example.com + + +Once Anne confirms her registration, she will be made a member of the mailing +list. + + >>> token = str(qmsg['subject']).split()[1].strip() + >>> from mailman.interfaces.registrar import IRegistrar + >>> registrar = IRegistrar(config.domains['example.com']) + >>> registrar.confirm(token) + True + + >>> user = config.db.user_manager.get_user(u'anne@example.com') + >>> print user.real_name + Anne Person + >>> list(user.addresses) + [ [verified] at ...>] + +Anne is also now a member of the mailing list. + + >>> mlist.members.get_member(u'anne@example.com') + + on alpha@example.com as MemberRole.member> + + +Joining a second list +--------------------- + + >>> mlist_2 = create_list(u'baker@example.com') + >>> msg = message_from_string("""\ + ... From: Anne Person + ... + ... """) + >>> print command.process(mlist_2, msg, {}, (), Results()) + ContinueProcessing.yes + +Anne of course, is still registered. + + >>> print config.db.user_manager.get_user(u'anne@example.com') + + +But she is not a member of the mailing list. + + >>> print mlist_2.members.get_member(u'anne@example.com') + None + +One Anne confirms this subscription, she becomes a member of the mailing list. + + >>> qmsg, qdata = virginq.dequeue(virginq.files[0]) + >>> token = str(qmsg['subject']).split()[1].strip() + >>> registrar.confirm(token) + True + + >>> print mlist_2.members.get_member(u'anne@example.com') + + on baker@example.com as MemberRole.member> diff --git a/src/mailman/commands/echo.py b/src/mailman/commands/echo.py new file mode 100644 index 000000000..30590acf8 --- /dev/null +++ b/src/mailman/commands/echo.py @@ -0,0 +1,48 @@ +# 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 . + +"""The email command 'echo'.""" + +__metaclass__ = type +__all__ = [ + 'Echo', + ] + + +from zope.interface import implements + +from mailman.i18n import _ +from mailman.interfaces.command import ContinueProcessing, IEmailCommand + + +SPACE = ' ' + + + +class Echo: + """The email 'echo' command.""" + implements(IEmailCommand) + + name = 'echo' + argument_description = '[args]' + description = _( + 'Echo an acknowledgement. Arguments are return unchanged.') + + def process(self, mlist, msg, msgdata, arguments, results): + """See `IEmailCommand`.""" + print >> results, 'echo', SPACE.join(arguments) + return ContinueProcessing.yes diff --git a/src/mailman/commands/end.py b/src/mailman/commands/end.py new file mode 100644 index 000000000..a9298bc92 --- /dev/null +++ b/src/mailman/commands/end.py @@ -0,0 +1,51 @@ +# 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 . + +"""The email commands 'end' and 'stop'.""" + +__metaclass__ = type +__all__ = [ + 'End', + 'Stop', + ] + + +from zope.interface import implements + +from mailman.i18n import _ +from mailman.interfaces.command import ContinueProcessing, IEmailCommand + + + +class End: + """The email 'end' command.""" + implements(IEmailCommand) + + name = 'end' + argument_description = '' + description = _('Stop processing commands.') + + def process(self, mlist, msg, msgdata, arguments, results): + """See `IEmailCommand`.""" + # Ignore all arguments. + return ContinueProcessing.no + + +class Stop(End): + """The email 'stop' command (an alias for 'end').""" + + name = 'stop' diff --git a/src/mailman/commands/join.py b/src/mailman/commands/join.py new file mode 100644 index 000000000..c14f3142b --- /dev/null +++ b/src/mailman/commands/join.py @@ -0,0 +1,126 @@ +# 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 . + +"""The email commands 'join' and 'subscribe'.""" + +__metaclass__ = type +__all__ = [ + 'Join', + 'Subscribe', + ] + + +from email.utils import formataddr, parseaddr +from zope.interface import implements + +from mailman.config import config +from mailman.i18n import _ +from mailman.interfaces.command import ContinueProcessing, IEmailCommand +from mailman.interfaces.member import DeliveryMode +from mailman.interfaces.registrar import IRegistrar + + + +class Join: + """The email 'join' command.""" + implements(IEmailCommand) + + name = 'join' + argument_description = '[digest=] [address=
      ]' + description = _("""\ +Join this mailing list. You will be asked to confirm your subscription +request and you may be issued a provisional password. + +By using the 'digest' option, you can specify whether you want digest delivery +or not. If not specified, the mailing list's default will be used. You can +also subscribe an alternative address by using the 'address' option. For +example: + + join address=myotheraddress@example.com +""") + + def process(self, mlist, msg, msgdata, arguments, results): + """See `IEmailCommand`.""" + # Parse the arguments. + address, delivery_mode = self._parse_arguments(arguments) + if address is None: + real_name, address = parseaddr(msg['from']) + # Address could be None or the empty string. + if not address: + address = msg.get_sender() + if not address: + print >> results, _( + '$self.name: No valid address found to subscribe') + return ContinueProcessing.no + domain = config.domains[mlist.host_name] + registrar = IRegistrar(domain) + registrar.register(address, real_name, mlist) + person = formataddr((real_name, address)) + print >> results, _('Confirmation email sent to $person') + return ContinueProcessing.yes + + def _parse_arguments(self, arguments): + """Parse command arguments. + + :param arguments: The sequences of arguments as given to the + `process()` method. + :return: address, delivery_mode + """ + address = None + delivery_mode = None + for argument in arguments: + parts = argument.split('=', 1) + if parts[0].lower() == 'digest': + if digest is not None: + print >> results, self.name, \ + _('duplicate argument: $argument') + return ContinueProcessing.no + if len(parts) == 0: + # We treat just plain 'digest' as 'digest=yes'. We don't + # yet support the other types of digest delivery. + delivery_mode = DeliveryMode.mime_digests + else: + if parts[1].lower() == 'yes': + delivery_mode = DeliveryMode.mime_digests + elif parts[1].lower() == 'no': + delivery_mode = DeliveryMode.regular + else: + print >> results, self.name, \ + _('bad argument: $argument') + return ContinueProcessing.no + elif parts[0].lower() == 'address': + if address is not None: + print >> results, self.name, \ + _('duplicate argument $argument') + return ContinueProcessing.no + if len(parts) == 0: + print >> results, self.name, \ + _('missing argument value: $argument') + return ContinueProcessing.no + if len(parts) > 1: + print >> results, self.name, \ + _('too many argument values: $argument') + return ContinueProcessing.no + address = parts[1] + return address, delivery_mode + + + +class Subscribe(Join): + """The email 'subscribe' command (an alias for 'join').""" + + name = 'subscribe' diff --git a/src/mailman/config/__init__.py b/src/mailman/config/__init__.py new file mode 100644 index 000000000..11f4f0c80 --- /dev/null +++ b/src/mailman/config/__init__.py @@ -0,0 +1,30 @@ +# Copyright (C) 2008-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 . + +"""Mailman configuration package.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'config', + ] + + +from mailman.config.config import Configuration + +config = Configuration() diff --git a/src/mailman/config/config.py b/src/mailman/config/config.py new file mode 100644 index 000000000..fa359a6f5 --- /dev/null +++ b/src/mailman/config/config.py @@ -0,0 +1,206 @@ +# 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 . + +"""Configuration file loading and management.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Configuration', + ] + + +import os +import sys +import errno +import logging + +from StringIO import StringIO +from lazr.config import ConfigSchema, as_boolean +from pkg_resources import resource_string + +from mailman import version +from mailman.core import errors +from mailman.domain import Domain +from mailman.languages import LanguageManager +from mailman.styles.manager import StyleManager +from mailman.utilities.filesystem import makedirs + + +SPACE = ' ' + + + +class Configuration(object): + """The core global configuration object.""" + + def __init__(self): + self.domains = {} # email host -> IDomain + self.switchboards = {} + self.languages = LanguageManager() + self.style_manager = StyleManager() + self.QFILE_SCHEMA_VERSION = version.QFILE_SCHEMA_VERSION + self._config = None + self.filename = None + # Create various registries. + self.chains = {} + self.rules = {} + self.handlers = {} + self.pipelines = {} + self.commands = {} + + def _clear(self): + """Clear the cached configuration variables.""" + self.domains.clear() + self.switchboards.clear() + self.languages = LanguageManager() + + def __getattr__(self, name): + """Delegate to the configuration object.""" + return getattr(self._config, name) + + def load(self, filename=None): + """Load the configuration from the schema and config files.""" + schema_string = resource_string('mailman.config', 'schema.cfg') + schema = ConfigSchema('schema.cfg', StringIO(schema_string)) + # If a configuration file was given, load it now too. First, load the + # absolute minimum default configuration, then if a configuration + # filename was given by the user, push it. + config_string = resource_string('mailman.config', 'mailman.cfg') + self._config = schema.loadFile(StringIO(config_string), 'mailman.cfg') + if filename is not None: + self.filename = filename + with open(filename) as user_config: + self._config.push(filename, user_config.read()) + self._post_process() + + def push(self, config_name, config_string): + """Push a new configuration onto the stack.""" + self._clear() + self._config.push(config_name, config_string) + self._post_process() + + def pop(self, config_name): + """Pop a configuration from the stack.""" + self._clear() + self._config.pop(config_name) + self._post_process() + + def _post_process(self): + """Perform post-processing after loading the configuration files.""" + # Set up the domains. + domains = self._config.getByCategory('domain', []) + for section in domains: + domain = Domain(section.email_host, section.base_url, + section.description, section.contact_address) + if domain.email_host in self.domains: + raise errors.BadDomainSpecificationError( + 'Duplicate email host: %s' % domain.email_host) + # Make sure there's only one mapping for the url_host + if domain.url_host in self.domains.values(): + raise errors.BadDomainSpecificationError( + 'Duplicate url host: %s' % domain.url_host) + # We'll do the reverse mappings on-demand. There shouldn't be too + # many virtual hosts that it will really matter that much. + self.domains[domain.email_host] = domain + # Set up directories. + self.BIN_DIR = os.path.abspath(os.path.dirname(sys.argv[0])) + self.VAR_DIR = var_dir = self._config.mailman.var_dir + # Now that we've loaded all the configuration files we're going to + # load, set up some useful directories. + join = os.path.join + self.LIST_DATA_DIR = join(var_dir, 'lists') + self.LOG_DIR = join(var_dir, 'logs') + self.LOCK_DIR = lockdir = join(var_dir, 'locks') + self.DATA_DIR = datadir = join(var_dir, 'data') + self.ETC_DIR = etcdir = join(var_dir, 'etc') + self.SPAM_DIR = join(var_dir, 'spam') + self.EXT_DIR = join(var_dir, 'ext') + self.QUEUE_DIR = join(var_dir, 'qfiles') + self.MESSAGES_DIR = join(var_dir, 'messages') + self.PUBLIC_ARCHIVE_FILE_DIR = join(var_dir, 'archives', 'public') + self.PRIVATE_ARCHIVE_FILE_DIR = join(var_dir, 'archives', 'private') + # Other useful files + self.PIDFILE = join(datadir, 'master-qrunner.pid') + self.SITE_PW_FILE = join(datadir, 'adm.pw') + self.LISTCREATOR_PW_FILE = join(datadir, 'creator.pw') + self.CONFIG_FILE = join(etcdir, 'mailman.cfg') + self.LOCK_FILE = join(lockdir, 'master-qrunner') + # Set up the switchboards. + from mailman.queue import Switchboard + Switchboard.initialize() + # Set up all the languages. + languages = self._config.getByCategory('language', []) + for language in languages: + code = language.name.split('.')[1] + self.languages.add_language(code, language.description, + language.charset, language.enabled) + # Always enable the server default language, which must be defined. + self.languages.enable_language(self._config.mailman.default_language) + self.ensure_directories_exist() + self.style_manager.populate() + + @property + def logger_configs(self): + """Return all log config sections.""" + return self._config.getByCategory('logging', []) + + @property + def paths(self): + """Return a substitution dictionary of all path variables.""" + return dict((k, self.__dict__[k]) + for k in self.__dict__ + if k.endswith('_DIR')) + + def ensure_directories_exist(self): + """Create all path directories if they do not exist.""" + for variable, directory in self.paths.items(): + makedirs(directory) + + @property + def qrunner_configs(self): + """Iterate over all the qrunner configuration sections.""" + for section in self._config.getByCategory('qrunner', []): + yield section + + @property + def archivers(self): + """Iterate over all the enabled archivers.""" + for section in self._config.getByCategory('archiver', []): + if not as_boolean(section.enable): + continue + class_path = section['class'] + module_name, class_name = class_path.rsplit('.', 1) + __import__(module_name) + yield getattr(sys.modules[module_name], class_name)() + + @property + def style_configs(self): + """Iterate over all the style configuration sections.""" + for section in self._config.getByCategory('style', []): + yield section + + @property + def header_matches(self): + """Iterate over all spam matching headers. + + Values are 3-tuples of (header, pattern, chain) + """ + matches = self._config.getByCategory('spam.headers', []) + for match in matches: + yield (matches.header, matches.pattern, matches.chain) diff --git a/src/mailman/config/mailman.cfg b/src/mailman/config/mailman.cfg new file mode 100644 index 000000000..2bf528bea --- /dev/null +++ b/src/mailman/config/mailman.cfg @@ -0,0 +1,69 @@ +# Copyright (C) 2008-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 . + +# This is the absolute bare minimum base configuration file. User supplied +# configurations are pushed onto this. + +[language.en] + +[qrunner.archive] +class: mailman.queue.archive.ArchiveRunner + +[qrunner.bad] +class: mailman.queue.fake.BadRunner +# The shunt runner is just a placeholder for its switchboard. +start: no + +[qrunner.bounces] +class: mailman.queue.bounce.BounceRunner + +[qrunner.command] +class: mailman.queue.command.CommandRunner + +[qrunner.in] +class: mailman.queue.incoming.IncomingRunner + +[qrunner.lmtp] +class: mailman.queue.lmtp.LMTPRunner + +[qrunner.maildir] +class: mailman.queue.maildir.MaildirRunner +# This is still experimental. +start: no + +[qrunner.news] +class: mailman.queue.news.NewsRunner + +[qrunner.out] +class: mailman.queue.outgoing.OutgoingRunner + +[qrunner.pipeline] +class: mailman.queue.pipeline.PipelineRunner + +[qrunner.retry] +class: mailman.queue.retry.RetryRunner +sleep_time: 15m + +[qrunner.shunt] +class: mailman.queue.fake.ShuntRunner +# The shunt runner is just a placeholder for its switchboard. +start: no + +[qrunner.virgin] +class: mailman.queue.virgin.VirginRunner + +[style.default] diff --git a/src/mailman/config/schema.cfg b/src/mailman/config/schema.cfg new file mode 100644 index 000000000..df20a7370 --- /dev/null +++ b/src/mailman/config/schema.cfg @@ -0,0 +1,589 @@ +# Copyright (C) 2008-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 . + +# This is the GNU Mailman configuration schema. It defines the default +# configuration options for the core system and plugins. It uses ini-style +# formats under the lazr.config regime to define all system configuration +# options. See for details. + +[mailman] +# This address is the "site owner" address. Certain messages which must be +# delivered to a human, but which can't be delivered to a list owner (e.g. a +# bounce from a list owner), will be sent to this address. It should point to +# a human. +site_owner: changeme@example.com + +# This address is used as the from address whenever a message comes from some +# entity to which there is no natural reply recipient. Set this to a real +# human or to /dev/null. It will be appended with the host name of the list +# involved. This address must not bounce and it must not point to a Mailman +# process. +noreply_address: noreply + +# Where all the runtime data will be kept. This directory must exist. +var_dir: /tmp/mailman + +# The default language for this server. +default_language: en + +# When allowing only members to post to a mailing list, how is the sender of +# the message determined? If this variable is set to Yes, then first the +# message's envelope sender is used, with a fallback to the sender if there is +# no envelope sender. Set this variable to No to always use the sender. +# +# The envelope sender is set by the SMTP delivery and is thus less easily +# spoofed than the sender, which is typically just taken from the From: header +# and thus easily spoofed by the end-user. However, sometimes the envelope +# sender isn't set correctly and this will manifest itself by postings being +# held for approval even if they appear to come from a list member. If you +# are having this problem, set this variable to No, but understand that some +# spoofed messages may get through. +use_envelope_sender: no + +# Membership tests for posting purposes are usually performed by looking at a +# set of headers, passing the test if any of their values match a member of +# the list. Headers are checked in the order given in this variable. The +# value From_ means to use the envelope sender. Field names are case +# insensitive. This is a space separate list of headers. +sender_headers: from from_ reply-to sender + +# Mail command processor will ignore mail command lines after designated max. +email_commands_max_lines: 10 + +# Default length of time a pending request is live before it is evicted from +# the pending database. +pending_request_life: 3d + + +[passwords] +# When Mailman generates them, this is the default length of member passwords. +member_password_length: 8 + +# Specify the type of passwords to use, when Mailman generates the passwords +# itself, as would be the case for membership requests where the user did not +# fill in a password, or during list creation, when auto-generation of admin +# passwords was selected. +# +# Set this value to 'yes' for classic Mailman user-friendly(er) passwords. +# These generate semi-pronounceable passwords which are easier to remember. +# Set this value to 'no' to use more cryptographically secure, but harder to +# remember, passwords -- if your operating system and Python version support +# the necessary feature (specifically that /dev/urandom be available). +user_friendly_passwords: yes + + +[qrunner.master] +# Define which process queue runners, and how many of them, to start. + +# The full import path to the class for this queue runner. +class: mailman.queue.runner.Runner + +# The directory path that this queue runner scans. +path: $VAR_DIR/qfiles/$name + +# The number of parallel queue runners. This must be a power of 2. +instances: 1 + +# Whether to start this queue runner or not. +start: yes + +# The maximum number of restarts for this queue runner. When the runner exits +# because of an error or other unexpected problem, it is automatically +# restarted, until the maximum number of restarts has been reached. +max_restarts: 10 + +# The sleep interval for the queue runner. It wakes up once every interval to +# process the files in its slice of the queue directory. +sleep_time: 1s + +[database] +# Use this to set the Storm database engine URL. You generally have one +# primary database connection for all of Mailman. List data and most rosters +# will store their data in this database, although external rosters may access +# other databases in their own way. This string supports standard +# 'configuration' substitutions. +url: sqlite:///$DATA_DIR/mailman.db +debug: no + +[logging.template] +# This defines various log settings. The options available are: +# +# - level -- Overrides the default level; this may be any of the +# standard Python logging levels, case insensitive. +# - format -- Overrides the default format string +# - datefmt -- Overrides the default date format string +# - path -- Overrides the default logger path. This may be a relative +# path name, in which case it is relative to Mailman's LOG_DIR, +# or it may be an absolute path name. You cannot change the +# handler class that will be used. +# - propagate -- Boolean specifying whether to propagate log message from this +# logger to the root "mailman" logger. You cannot override +# settings for the root logger. +# +# In this section, you can define defaults for all loggers, which will be +# prefixed by 'mailman.'. Use subsections to override settings for specific +# loggers. The names of the available loggers are: +# +# - archiver -- All archiver output +# - bounce -- All bounce processing logs go here +# - config -- Configuration issues +# - debug -- Only used for development +# - error -- All exceptions go to this log +# - fromusenet -- Information related to the Usenet to Mailman gateway +# - http -- Internal wsgi-based web interface +# - locks -- Lock state changes +# - mischief -- Various types of hostile activity +# - post -- Information about messages posted to mailing lists +# - qrunner -- qrunner start/stops +# - smtp -- Successful SMTP activity +# - smtp-failure -- Unsuccessful SMTP activity +# - subscribe -- Information about leaves/joins +# - vette -- Information related to admindb activity +format: %(asctime)s (%(process)d) %(message)s +datefmt: %b %d %H:%M:%S %Y +propagate: no +level: info +path: mailman + +[logging.root] + +[logging.archiver] + +[logging.bounce] +path: bounce + +[logging.config] + +[logging.debug] +path: debug +level: debug + +[logging.error] + +[logging.fromusenet] + +[logging.http] + +[logging.locks] + +[logging.mischief] + +[logging.qrunner] + +[logging.smtp] +path: smtp + +# The smtp logger defines additional options for handling the logging of each +# attempted delivery. These format strings specify what information is logged +# for every message, every successful delivery, every refused delivery and +# every recipient failure. To disable a status message, set the value to 'no' +# (without the quotes). +# +# These template strings accept the following set of substitution +# placeholders, if available. +# +# msgid -- the Message-ID of the message in question +# listname -- the fully-qualified list name +# sender -- the sender if available +# recip -- the recipient address if available, or the number of +# recipients being delivered to +# size -- the approximate size of the message in bytes +# seconds -- the number of seconds the operation took +# refused -- the number of refused recipients +# smtpcode -- the SMTP success or failure code +# smtpmsg -- the SMTP success or failure message + +every: $msgid smtp to $listname for $recip recips, completed in $time seconds +success: $msgid post to $listname from $sender, $size bytes +refused: $msgid post to $listname from $sender, $size bytes, $refused failures +failure: $msgid delivery to $recip failed with code $smtpcode, $smtpmsg + + +[logging.subscribe] + +[logging.vette] + + +[domain.master] +# Site-wide domain defaults. To configure an individual +# domain, add a [domain.example_com] section with the overrides. + +# This is the host name for the email interface. +email_host: example.com +# This is the base url for the domain's web interface. It must include the +# url scheme. +base_url: http://example.com +# The contact address for this domain. This is advertised as the human to +# contact when users have problems with the lists in this domain. +contact_address: postmaster@example.com +# A short description of this domain. +description: An example domain. + + +[language.master] +# Template for language definitions. The section name must be [language.xx] +# where xx is the 2-character ISO code for the language. + +# The English name for the language. +description: English (USA) +# And the default character set for the language. +charset: us-ascii +# Whether the language is enabled or not. +enabled: yes + + +[spam.headers.template] +# This section defines basic header matching actions. Each spam.header +# section names a header to match (case-insensitively), a pattern to match +# against the header's value, and the chain to jump to when the match +# succeeds. +# +# The header value should not include the trailing colon. +header: X-Spam +# The pattern is always matched with re.IGNORECASE. +pattern: xyz +# The chain to jump to if the pattern matches. Maybe be any existing chain +# such as 'discard', 'reject', 'hold', or 'accept'. +chain: hold + + +[mta] +# The class defining the interface to the incoming mail transport agent. +incoming: mailman.mta.postfix.LMTP + +# The class defining the interface to the outgoing mail transport agent. +outgoing: mailman.mta.smtp_direct.process + +# How to connect to the outgoing MTA. +smtp_host: localhost +smtp_port: 25 + +# Where the LMTP server listens for connections. +lmtp_host: localhost +lmtp_port: 8025 + +# Ceiling on the number of recipients that can be specified in a single SMTP +# transaction. Set to 0 to submit the entire recipient list in one +# transaction. +max_recipients: 500 + +# Ceiling on the number of SMTP sessions to perform on a single socket +# connection. Some MTAs have limits. Set this to 0 to do as many as we like +# (i.e. your MTA has no limits). Set this to some number great than 0 and +# Mailman will close the SMTP connection and re-open it after this number of +# consecutive sessions. +max_sessions_per_connection: 0 + +# Maximum number of simultaneous subthreads that will be used for SMTP +# delivery. After the recipients list is chunked according to max_recipients, +# each chunk is handed off to the SMTP server by a separate such thread. If +# your Python interpreter was not built for threads, this feature is disabled. +# You can explicitly disable it in all cases by setting max_delivery_threads +# to 0. +max_delivery_threads: 0 + +# How long should messages which have delivery failures continue to be +# retried? After this period of time, a message that has failed recipients +# will be dequeued and those recipients will never receive the message. +delivery_retry_period: 5d + +# These variables control the format and frequency of VERP-like delivery for +# better bounce detection. VERP is Variable Envelope Return Path, defined +# here: +# +# http://cr.yp.to/proto/verp.txt +# +# This involves encoding the address of the recipient as we (Mailman) know it +# into the envelope sender address (i.e. the SMTP `MAIL FROM:' address). +# Thus, no matter what kind of forwarding the recipient has in place, should +# it eventually bounce, we will receive an unambiguous notice of the bouncing +# address. +# +# However, we're technically only "VERP-like" because we're doing the envelope +# sender encoding in Mailman, not in the MTA. We do require cooperation from +# the MTA, so you must be sure your MTA can be configured for extended address +# semantics. +# +# The first variable describes how to encode VERP envelopes. It must contain +# these three string interpolations: +# +# $bounces -- the list-bounces mailbox will be set here +# $mailbox -- the recipient's mailbox will be set here +# $host -- the recipient's host name will be set here +# +# This example uses the default below. +# +# FQDN list address is: mylist@dom.ain +# Recipient is: aperson@a.nother.dom +# +# The envelope sender will be mylist-bounces+aperson=a.nother.dom@dom.ain +# +# Note that your MTA /must/ be configured to deliver such an addressed message +# to mylist-bounces! +verp_delimiter: + +verp_format: ${bounces}+${mailbox}=${host} + +# For nicer confirmation emails, use a VERP-like format which encodes the +# confirmation cookie in the reply address. This lets us put a more user +# friendly Subject: on the message, but requires cooperation from the MTA. +# Format is like verp_format, but with the following substitutions: +# +# $address -- the list-confirm address +# $cookie -- the confirmation cookie +verp_confirm_format: $address+$cookie + +# This is analogous to verp_regexp, but for splitting apart the +# verp_confirm_format. MUAs have been observed that mung +# +# From: local_part@host +# +# into +# +# To: "local_part" +# +# when replying, so we skip everything up to '<' if any. +verp_confirm_regexp: ^(.*<)?(?P[^+]+?)\+(?P[^@]+)@.*$ + +# Set this to 'yes' to enable VERP-like (more user friendly) confirmations. +verp_confirmations: no + +# Another good opportunity is when regular delivery is personalized. Here +# again, we're already incurring the performance hit for addressing each +# individual recipient. Set this to 'yes' to enable VERPs on all personalized +# regular deliveries (personalized digests aren't supported yet). +verp_personalized_deliveries: no + +# And finally, we can VERP normal, non-personalized deliveries. However, +# because it can be a significant performance hit, we allow you to decide how +# often to VERP regular deliveries. This is the interval, in number of +# messages, to do a VERP recipient address. The same variable controls both +# regular and digest deliveries. Set to 0 to disable occasional VERPs, set to +# 1 to VERP every delivery, or to some number > 1 for only occasional VERPs. +verp_delivery_interval: 0 + +# VERP format and regexp for probe messages. +verp_probe_format: %(bounces)s+%(token)s +verp_probe_regexp: ^(?P[^+]+?)\+(?P[^@]+)@.*$ +# Set this 'yes' to activate VERP probe for disabling by bounce. +verp_probes: no + +# This is the maximum number of automatic responses sent to an address because +# of -request messages or posting hold messages. This limit prevents response +# loops between Mailman and misconfigured remote email robots. Mailman +# already inhibits automatic replies to any message labeled with a header +# "Precendence: bulk|list|junk". This is a fallback safety valve so it should +# be set fairly high. Set to 0 for no limit (probably useful only for +# debugging). +max_autoresponses_per_day: 10 + +# Some list posts and mail to the -owner address may contain DomainKey or +# DomainKeys Identified Mail (DKIM) signature headers . +# Various list transformations to the message such as adding a list header or +# footer or scrubbing attachments or even reply-to munging can break these +# signatures. It is generally felt that these signatures have value, even if +# broken and even if the outgoing message is resigned. However, some sites +# may wish to remove these headers by setting this to 'yes'. +remove_dkim_headers: no + +# This variable describe the program to use for regenerating the transport map +# db file, from the associated plain text files. The file being updated will +# be appended to this string (with a separating space), so it must be +# appropriate for os.system(). +postfix_map_cmd: /usr/sbin/postmap + + +[bounces] +# How often should the bounce qrunner process queued detected bounces? +register_bounces_every: 15m + + +[archiver.master] +# To add new archivers, define a new section based on this one, overriding the +# following values. + +# The class implementing the IArchiver interface. +class: mailman.archiving.prototype.Prototype + +# Set this to 'yes' to enable the archiver. +enable: no + +# The base url for the archiver. This is used to to calculate links to +# individual messages in the archive. +base_url: http://archive.example.com/ + +# If the archiver works by getting a copy of the message, this is the address +# to send the copy to. +recipient: archive@archive.example.com + +# If the archiver works by calling a command on the local machine, this is the +# command to call. +command: /bin/echo + + +[archiver.mhonarc] +# This is the stock MHonArc archiver. +class: mailman.archiving.mhonarc.MHonArc + +base_url: http://$hostname/archives/$fqdn_listname + + +[archiver.mail_archive] +# This is the stock mail-archive.com archiver. +class: mailman.archiving.mailarchive.MailArchive + +[archiver.pipermail] +# This is the stock Pipermail archiver. +class: mailman.archiving.pipermail.Pipermail + +# This sets the default `clobber date' policy for the archiver. When a +# message is to be archived either by Pipermail or an external archiver, +# Mailman can modify the Date: header to be the date the message was received +# instead of the Date: in the original message. This is useful if you +# typically receive messages with outrageous dates. Set this to 0 to retain +# the date of the original message, or to 1 to always clobber the date. Set +# it to 2 to perform `smart overrides' on the date; when the date is outside +# allowable_sane_date_skew (either too early or too late), then the received +# date is substituted instead. +clobber_date_policy: 2 +allowable_sane_date_skew: 15d + +# Pipermail archives contain the raw email addresses of the posting authors. +# Some view this as a goldmine for spam harvesters. Set this to 'yes' to +# moderately obscure email addresses, but note that this breaks mailto: URLs +# in the archives too. +obscure_email_addresses: yes + +# When the archive is public, should Pipermail also make the raw Unix mbox +# file publically available? +public_mbox: no + + +[archiver.prototype] +# This is a prototypical sample archiver. +class: mailman.archiving.prototype.Prototype + + +[style.master] +# The style's priority, with 0 being the lowest priority. +priority: 0 + +# The class implementing the IStyle interface, which applies the style. +class: mailman.styles.default.DefaultStyle + + +[scrubber] +# A filter module that converts from multipart messages to "flat" messages +# (i.e. containing a single payload). This is required for Pipermail, and you +# may want to set it to 0 for external archivers. You can also replace it +# with your own module as long as it contains a process() function that takes +# a MailList object and a Message object. It should raise +# Errors.DiscardMessage if it wants to throw the message away. Otherwise it +# should modify the Message object as necessary. +archive_scrubber: mailman.pipeline.scrubber + +# This variable defines what happens to text/html subparts. They can be +# stripped completely, escaped, or filtered through an external program. The +# legal values are: +# 0 - Strip out text/html parts completely, leaving a notice of the removal in +# the message. If the outer part is text/html, the entire message is +# discarded. +# 1 - Remove any embedded text/html parts, leaving them as HTML-escaped +# attachments which can be separately viewed. Outer text/html parts are +# simply HTML-escaped. +# 2 - Leave it inline, but HTML-escape it +# 3 - Remove text/html as attachments but don't HTML-escape them. Note: this +# is very dangerous because it essentially means anybody can send an HTML +# email to your site containing evil JavaScript or web bugs, or other +# nasty things, and folks viewing your archives will be susceptible. You +# should only consider this option if you do heavy moderation of your list +# postings. +# +# Note: given the current archiving code, it is not possible to leave +# text/html parts inline and un-escaped. I wouldn't think it'd be a good idea +# to do anyway. +# +# The value can also be a string, in which case it is the name of a command to +# filter the HTML page through. The resulting output is left in an attachment +# or as the entirety of the message when the outer part is text/html. The +# format of the string must include a $filename substitution variable which +# will contain the name of the temporary file that the program should operate +# on. It should write the processed message to stdout. Set this to +# HTML_TO_PLAIN_TEXT_COMMAND to specify an HTML to plain text conversion +# program. +archive_html_sanitizer: 1 + +# Control parameter whether the scrubber should use the message attachment's +# filename as is indicated by the filename parameter or use 'attachement-xxx' +# instead. The default is set 'no' because the applications on PC and Mac +# begin to use longer non-ascii filenames. +use_attachment_filename: no + +# Use of attachment filename extension per se is may be dangerous because +# viruses fakes it. You can set this 'yes' if you filter the attachment by +# filename extension. +use_attachment_filename_extension: no + + +[digests] +# Headers which should be kept in both RFC 1153 (plain) and MIME digests. RFC +# 1153 also specifies these headers in this exact order, so order matters. +# These are space separated and case insensitive. +mime_digest_keep_headers: + Date From To Cc Subject Message-ID Keywords + In-Reply-To References Content-Type MIME-Version + Content-Transfer-Encoding Precedence Reply-To + Message + +plain_digest_keep_headers: + Message Date From + Subject To Cc + Message-ID Keywords + Content-Type + + +[nntp] +# Set these variables if you need to authenticate to your NNTP server for +# Usenet posting or reading. If no authentication is necessary, specify None +# for both variables. +username: +password: + +# Set this if you have an NNTP server you prefer gatewayed lists to use. +host: + +# This controls how headers must be cleansed in order to be accepted by your +# NNTP server. Some servers like INN reject messages containing prohibited +# headers, or duplicate headers. The NNTP server may reject the message for +# other reasons, but there's little that can be programmatically done about +# that. +# +# These headers (case ignored) are removed from the original message. This is +# a whitespace separate list of headers. +remove_headers: + nntp-posting-host nntp-posting-date x-trace + x-complaints-to xref date-received posted + posting-version relay-version received + +# These headers are left alone, unless there are duplicates in the original +# message. Any second and subsequent headers are rewritten to the second +# named header (case preserved). This is a list of header pairs, one pair per +# line. +rewrite_duplicate_headers: + To X-Original-To + CC X-Original-CC + Content-Transfer-Encoding X-Original-Content-Transfer-Encoding + MIME-Version X-MIME-Version diff --git a/src/mailman/constants.py b/src/mailman/constants.py new file mode 100644 index 000000000..39c4547f8 --- /dev/null +++ b/src/mailman/constants.py @@ -0,0 +1,44 @@ +# 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 . + +"""Various constants and enumerations.""" + +from __future__ import unicode_literals + +__metaclass__ = type +__all__ = [ + 'SystemDefaultPreferences', + ] + + +from zope.interface import implements + +from mailman.interfaces.member import DeliveryMode, DeliveryStatus +from mailman.interfaces.preferences import IPreferences + + + +class SystemDefaultPreferences: + implements(IPreferences) + + acknowledge_posts = False + hide_address = True + preferred_language = 'en' + receive_list_copy = True + receive_own_postings = True + delivery_mode = DeliveryMode.regular + delivery_status = DeliveryStatus.enabled diff --git a/src/mailman/core/__init__.py b/src/mailman/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/mailman/core/chains.py b/src/mailman/core/chains.py new file mode 100644 index 000000000..40b8c779f --- /dev/null +++ b/src/mailman/core/chains.py @@ -0,0 +1,118 @@ +# Copyright (C) 2007-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 . + +"""Application support for chain processing.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'initialize', + 'process', + ] + + +from mailman.chains.accept import AcceptChain +from mailman.chains.builtin import BuiltInChain +from mailman.chains.discard import DiscardChain +from mailman.chains.headers import HeaderMatchChain +from mailman.chains.hold import HoldChain +from mailman.chains.reject import RejectChain +from mailman.config import config +from mailman.interfaces.chain import LinkAction + + + +def process(mlist, msg, msgdata, start_chain='built-in'): + """Process the message through a chain. + + :param mlist: the IMailingList for this message. + :param msg: The Message object. + :param msgdata: The message metadata dictionary. + :param start_chain: The name of the chain to start the processing with. + """ + # Set up some bookkeeping. + chain_stack = [] + msgdata['rule_hits'] = hits = [] + msgdata['rule_misses'] = misses = [] + # Find the starting chain and begin iterating through its links. + chain = config.chains[start_chain] + chain_iter = chain.get_links(mlist, msg, msgdata) + # Loop until we've reached the end of all processing chains. + while chain: + # Iterate over all links in the chain. Do this outside a for-loop so + # we can capture a chain's link iterator in mid-flight. This supports + # the 'detour' link action + try: + link = next(chain_iter) + except StopIteration: + # This chain is exhausted. Pop the last chain on the stack and + # continue iterating through it. If there's nothing left on the + # chain stack then we're completely finished processing. + if len(chain_stack) == 0: + return + chain, chain_iter = chain_stack.pop() + continue + # Process this link. + if link.rule.check(mlist, msg, msgdata): + if link.rule.record: + hits.append(link.rule.name) + # The rule matched so run its action. + if link.action is LinkAction.jump: + chain = link.chain + chain_iter = chain.get_links(mlist, msg, msgdata) + continue + elif link.action is LinkAction.detour: + # Push the current chain so that we can return to it when + # the next chain is finished. + chain_stack.append((chain, chain_iter)) + chain = link.chain + chain_iter = chain.get_links(mlist, msg, msgdata) + continue + elif link.action is LinkAction.stop: + # Stop all processing. + return + elif link.action is LinkAction.defer: + # Just process the next link in the chain. + pass + elif link.action is LinkAction.run: + link.function(mlist, msg, msgdata) + else: + raise AssertionError( + 'Bad link action: {0}'.format(link.action)) + else: + # The rule did not match; keep going. + if link.rule.record: + misses.append(link.rule.name) + + + +def initialize(): + """Set up chains, both built-in and from the database.""" + for chain_class in (DiscardChain, HoldChain, RejectChain, AcceptChain): + chain = chain_class() + assert chain.name not in config.chains, ( + 'Duplicate chain name: {0}'.format(chain.name)) + config.chains[chain.name] = chain + # Set up a couple of other default chains. + chain = BuiltInChain() + config.chains[chain.name] = chain + # Create and initialize the header matching chain. + chain = HeaderMatchChain() + config.chains[chain.name] = chain + # XXX Read chains from the database and initialize them. + pass diff --git a/src/mailman/core/errors.py b/src/mailman/core/errors.py new file mode 100644 index 000000000..39401127e --- /dev/null +++ b/src/mailman/core/errors.py @@ -0,0 +1,172 @@ +# 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 . + +"""Mailman errors.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'AlreadyReceivingDigests', + 'AlreadyReceivingRegularDeliveries', + 'BadDomainSpecificationError', + 'BadPasswordSchemeError', + 'CantDigestError', + 'DiscardMessage', + 'EmailAddressError', + 'HandlerError', + 'HoldMessage', + 'HostileSubscriptionError', + 'InvalidEmailAddress', + 'LostHeldMessage', + 'MailmanError', + 'MailmanException', + 'MemberError', + 'MembershipIsBanned', + 'MustDigestError', + 'NotAMemberError', + 'PasswordError', + 'RejectMessage', + 'SomeRecipientsFailed', + 'SubscriptionError', + ] + + + +# Base class for all exceptions raised in Mailman (XXX except legacy string +# exceptions). +class MailmanException(Exception): + pass + + + +# "New" style membership exceptions (new w/ MM2.1) +class MemberError(MailmanException): pass +class NotAMemberError(MemberError): pass +class AlreadyReceivingDigests(MemberError): pass +class AlreadyReceivingRegularDeliveries(MemberError): pass +class CantDigestError(MemberError): pass +class MustDigestError(MemberError): pass +class MembershipIsBanned(MemberError): pass + + + +# New style class based exceptions. All the above errors should eventually be +# converted. + +class MailmanError(MailmanException): + """Base class for all Mailman errors.""" + pass + +class BadDomainSpecificationError(MailmanError): + """The specification of a virtual domain is invalid or duplicated.""" + + + +# Exception hierarchy for bad email address errors that can be raised from +# Utils.ValidateEmail() +class EmailAddressError(MailmanError): + """Base class for email address validation errors.""" + + +class InvalidEmailAddress(EmailAddressError): + """Email address is invalid.""" + + + +# Exceptions for admin request database +class LostHeldMessage(MailmanError): + """Held message was lost.""" + pass + + + +def _(s): + return s + +# Exceptions for the Handler subsystem +class HandlerError(MailmanError): + """Base class for all handler errors.""" + +class HoldMessage(HandlerError): + """Base class for all message-being-held short circuits.""" + + # funky spelling is necessary to break import loops + reason = _('For some unknown reason') + + def reason_notice(self): + return self.reason + + # funky spelling is necessary to break import loops + rejection = _('Your message was rejected') + + def rejection_notice(self, mlist): + return self.rejection + +class DiscardMessage(HandlerError): + """The message can be discarded with no further action""" + +class SomeRecipientsFailed(HandlerError): + """Delivery to some or all recipients failed""" + def __init__(self, tempfailures, permfailures): + HandlerError.__init__(self) + self.tempfailures = tempfailures + self.permfailures = permfailures + +class RejectMessage(HandlerError): + """The message will be bounced back to the sender""" + def __init__(self, notice=None): + super(RejectMessage, self).__init__() + if notice is None: + notice = _('Your message was rejected') + if notice.endswith('\n\n'): + pass + elif notice.endswith('\n'): + notice += '\n' + else: + notice += '\n\n' + self.notice = notice + + + +# Subscription exceptions +class SubscriptionError(MailmanError): + """Subscription errors base class.""" + + +class HostileSubscriptionError(SubscriptionError): + """A cross-subscription attempt was made. + + This exception gets raised when an invitee attempts to use the + invitation to cross-subscribe to some other mailing list. + """ + + + +class PasswordError(MailmanError): + """A password related error.""" + + +class BadPasswordSchemeError(PasswordError): + """A bad password scheme was given.""" + + def __init__(self, scheme_name='unknown'): + super(BadPasswordSchemeError, self).__init__() + self.scheme_name = scheme_name + + def __str__(self): + return 'A bad password scheme was given: %s' % self.scheme_name diff --git a/src/mailman/core/initialize.py b/src/mailman/core/initialize.py new file mode 100644 index 000000000..bb16f0036 --- /dev/null +++ b/src/mailman/core/initialize.py @@ -0,0 +1,125 @@ +# 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 . + +"""Initialize all global state. + +Every entrance into the Mailman system, be it by command line, mail program, +or cgi, must call the initialize function here in order for the system's +global state to be set up properly. Typically this is called after command +line argument parsing, since some of the initialization behavior is controlled +by the command line arguments. +""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'initialize', + 'initialize_1', + 'initialize_2', + 'initialize_3', + ] + + +import os + +from zope.interface.interface import adapter_hooks +from zope.interface.verify import verifyObject + +import mailman.config.config +import mailman.core.logging + +from mailman.core.plugins import get_plugin +from mailman.interfaces.database import IDatabase + + + +# These initialization calls are separated for the testing framework, which +# needs to do some internal calculations after config file loading and log +# initialization, but before database initialization. Generally all other +# code will just call initialize(). + +def initialize_1(config_path=None, propagate_logs=None): + """First initialization step. + + * The configuration system + * Run-time directories + * The logging subsystem + + :param config_path: The path to the configuration file. + :type config_path: string + :param propagate_logs: Should the log output propagate to stderr? + :type propagate_logs: boolean or None + """ + # By default, set the umask so that only owner and group can read and + # write our files. Specifically we must have g+rw and we probably want + # o-rwx although I think in most cases it doesn't hurt if other can read + # or write the files. Note that the Pipermail archive has more + # restrictive permissions in order to handle private archives, but it + # handles that correctly. + os.umask(007) + mailman.config.config.load(config_path) + # Create the queue and log directories if they don't already exist. + mailman.config.config.ensure_directories_exist() + mailman.core.logging.initialize(propagate_logs) + + +def initialize_2(debug=False): + """Second initialization step. + + * Rules + * Chains + * Pipelines + * Commands + + :param debug: Should the database layer be put in debug mode? + :type debug: boolean + """ + database_plugin = get_plugin('mailman.database') + # Instantiate the database plugin, ensure that it's of the right type, and + # initialize it. Then stash the object on our configuration object. + database = database_plugin() + verifyObject(IDatabase, database) + database.initialize(debug) + mailman.config.config.db = database + # Initialize the rules and chains. Do the imports here so as to avoid + # circular imports. + from mailman.app.commands import initialize as initialize_commands + from mailman.core.chains import initialize as initialize_chains + from mailman.core.pipelines import initialize as initialize_pipelines + from mailman.core.rules import initialize as initialize_rules + # Order here is somewhat important. + initialize_rules() + initialize_chains() + initialize_pipelines() + initialize_commands() + + +def initialize_3(): + """Third initialization step. + + * Adapters + """ + from mailman.app.registrar import adapt_domain_to_registrar + adapter_hooks.append(adapt_domain_to_registrar) + + + +def initialize(config_path=None, propagate_logs=None): + initialize_1(config_path, propagate_logs) + initialize_2() + initialize_3() diff --git a/src/mailman/core/logging.py b/src/mailman/core/logging.py new file mode 100644 index 000000000..a18065965 --- /dev/null +++ b/src/mailman/core/logging.py @@ -0,0 +1,163 @@ +# 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 . + +"""Logging initialization, using Python's standard logging package.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'initialize', + 'reopen', + ] + + +import os +import sys +import codecs +import logging + +from lazr.config import as_boolean, as_log_level + +from mailman.config import config + + +_handlers = {} + + + +# XXX I would love to simplify things and use Python 2.6's WatchedFileHandler, +# but there are two problems. First, it's more difficult to handle the test +# suite's need to reopen the file handler to a different path. Does +# zope.testing's logger support fix this? +# +# The other problem is that WatchedFileHandler doesn't really easily support +# HUPing the process to reopen the log file. Now, maybe that's not a big deal +# because the standard logging module would already handle things correctly if +# the file is moved, but still that's not an interface I'm ready to give up on +# yet. For now, keep our hack. + +class ReopenableFileHandler(logging.Handler): + """A file handler that supports reopening.""" + + def __init__(self, name, filename): + self.name = name + self._filename = filename + self._stream = self._open() + logging.Handler.__init__(self) + + def _open(self): + return codecs.open(self._filename, 'a', 'utf-8') + + def flush(self): + if self._stream: + self._stream.flush() + + def emit(self, record): + # It's possible for the stream to have been closed by the time we get + # here, due to the shut down semantics. This mostly happens in the + # test suite, but be defensive anyway. + stream = (self._stream if self._stream else sys.stderr) + try: + msg = self.format(record) + try: + stream.write('{0}'.format(msg)) + except UnicodeError: + stream.write('{0}'.format(msg.encode('string-escape'))) + self.flush() + except: + self.handleError(record) + + def close(self): + self.flush() + self._stream.close() + self._stream = None + logging.Handler.close(self) + + def reopen(self, filename=None): + """Reopen the output stream. + + :param filename: If given, this reopens the output stream to a new + file. This is used in the test suite. + :type filename: string + """ + if filename is not None: + self._filename = filename + self._stream.close() + self._stream = self._open() + + + +def initialize(propagate=None): + """Initialize all logs. + + :param propagate: Flag specifying whether logs should propagate their + messages to the root logger. If omitted, propagation is determined + from the configuration files. + :type propagate: bool or None + """ + # First, find the root logger and configure the logging subsystem. + # Initialize the root logger, then create a formatter for all the + # sublogs. The root logger should log to stderr. + logging.basicConfig(format=config.logging.root.format, + datefmt=config.logging.root.datefmt, + level=as_log_level(config.logging.root.level), + stream=sys.stderr) + # Create the subloggers. + for logger_config in config.logger_configs: + sub_name = logger_config.name.split('.')[-1] + if sub_name == 'root': + continue + logger_name = 'mailman.' + sub_name + log = logging.getLogger(logger_name) + # Get settings from log configuration file (or defaults). + log_format = logger_config.format + log_datefmt = logger_config.datefmt + # Propagation to the root logger is how we handle logging to stderr + # when the qrunners are not run as a subprocess of mailmanctl. + log.propagate = (as_boolean(logger_config.propagate) + if propagate is None else propagate) + # Set the logger's level. + log.setLevel(as_log_level(logger_config.level)) + # Create a formatter for this logger, then a handler, and link the + # formatter to the handler. + formatter = logging.Formatter(fmt=log_format, datefmt=log_datefmt) + path_str = logger_config.path + path_abs = os.path.normpath(os.path.join(config.LOG_DIR, path_str)) + handler = ReopenableFileHandler(sub_name, path_abs) + _handlers[sub_name] = handler + handler.setFormatter(formatter) + log.addHandler(handler) + + + +def reopen(): + """Re-open all log files.""" + for handler in _handlers.values(): + handler.reopen() + + + +def get_handler(sub_name): + """Return the handler associated with a named logger. + + :param sub_name: The logger name, sans the 'mailman.' prefix. + :type sub_name: string + :return: The file handler associated with the named logger. + :rtype: `ReopenableFileHandler` + """ + return _handlers[sub_name] diff --git a/src/mailman/core/pipelines.py b/src/mailman/core/pipelines.py new file mode 100644 index 000000000..8aae5cc25 --- /dev/null +++ b/src/mailman/core/pipelines.py @@ -0,0 +1,125 @@ +# Copyright (C) 2008-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 . + +"""Pipeline processor.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'initialize', + 'process', + ] + + +from zope.interface import implements +from zope.interface.verify import verifyObject + +from mailman.config import config +from mailman.core.plugins import get_plugins +from mailman.i18n import _ +from mailman.interfaces.handler import IHandler +from mailman.interfaces.pipeline import IPipeline + + + +def process(mlist, msg, msgdata, pipeline_name='built-in'): + """Process the message through the given pipeline. + + :param mlist: the IMailingList for this message. + :param msg: The Message object. + :param msgdata: The message metadata dictionary. + :param pipeline_name: The name of the pipeline to process through. + """ + pipeline = config.pipelines[pipeline_name] + for handler in pipeline: + handler.process(mlist, msg, msgdata) + + + +class BasePipeline: + """Base pipeline implementation.""" + + implements(IPipeline) + + _default_handlers = () + + def __init__(self): + self._handlers = [] + for handler_name in self._default_handlers: + self._handlers.append(config.handlers[handler_name]) + + def __iter__(self): + """See `IPipeline`.""" + for handler in self._handlers: + yield handler + + +class BuiltInPipeline(BasePipeline): + """The built-in pipeline.""" + + name = 'built-in' + description = _('The built-in pipeline.') + + _default_handlers = ( + 'mime-delete', + 'scrubber', + 'tagger', + 'calculate-recipients', + 'avoid-duplicates', + 'cleanse', + 'cleanse-dkim', + 'cook-headers', + 'to-digest', + 'to-archive', + 'to-usenet', + 'after-delivery', + 'acknowledge', + 'to-outgoing', + ) + + +class VirginPipeline(BasePipeline): + """The processing pipeline for virgin messages. + + Virgin messages are those that are crafted internally by Mailman. + """ + name = 'virgin' + description = _('The virgin queue pipeline.') + + _default_handlers = ( + 'cook-headers', + 'to-outgoing', + ) + + + +def initialize(): + """Initialize the pipelines.""" + # Find all handlers in the registered plugins. + for handler_finder in get_plugins('mailman.handlers'): + for handler_class in handler_finder(): + handler = handler_class() + verifyObject(IHandler, handler) + assert handler.name not in config.handlers, ( + 'Duplicate handler "{0}" found in {1}'.format( + handler.name, handler_finder)) + config.handlers[handler.name] = handler + # Set up some pipelines. + for pipeline_class in (BuiltInPipeline, VirginPipeline): + pipeline = pipeline_class() + config.pipelines[pipeline.name] = pipeline diff --git a/src/mailman/core/plugins.py b/src/mailman/core/plugins.py new file mode 100644 index 000000000..e9ba26571 --- /dev/null +++ b/src/mailman/core/plugins.py @@ -0,0 +1,74 @@ +# Copyright (C) 2007-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 . + +"""Get a requested plugin.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + ] + + +import pkg_resources + + + +def get_plugin(group): + """Get the named plugin. + + In general, this returns exactly one plugin. If no plugins have been + added to the named group, the 'stock' plugin will be used. If more than + one plugin -- other than the stock one -- exists, an exception will be + raised. + + :param group: The plugin group name. + :return: The loaded plugin. + :raises RuntimeError: If more than one plugin overrides the stock plugin + for the named group. + """ + entry_points = list(pkg_resources.iter_entry_points(group)) + if len(entry_points) == 0: + raise RuntimeError( + 'No entry points found for group: {0}'.format(group)) + elif len(entry_points) == 1: + # Okay, this is the one to use. + return entry_points[0].load() + elif len(entry_points) == 2: + # Find the one /not/ named 'stock'. + entry_points = [ep for ep in entry_points if ep.name <> 'stock'] + if len(entry_points) == 0: + raise RuntimeError( + 'No stock plugin found for group: {0}'.format(group)) + elif len(entry_points) == 2: + raise RuntimeError('Too many stock plugins defined') + else: + raise AssertionError('Insanity') + return entry_points[0].load() + else: + raise RuntimeError('Too many plugins for group: {0}'.format(group)) + + + +def get_plugins(group): + """Get and return all plugins in the named group. + + :param group: Plugin group name. + :return: The loaded plugin. + """ + for entry_point in pkg_resources.iter_entry_points(group): + yield entry_point.load() diff --git a/src/mailman/core/rules.py b/src/mailman/core/rules.py new file mode 100644 index 000000000..83e24dfa2 --- /dev/null +++ b/src/mailman/core/rules.py @@ -0,0 +1,46 @@ +# Copyright (C) 2007-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 . + +"""Various rule helpers""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'initialize', + ] + + +from zope.interface.verify import verifyObject + +from mailman.config import config +from mailman.core.plugins import get_plugins +from mailman.interfaces.rules import IRule + + + +def initialize(): + """Find and register all rules in all plugins.""" + # Find rules in plugins. + for rule_finder in get_plugins('mailman.rules'): + for rule_class in rule_finder(): + rule = rule_class() + verifyObject(IRule, rule) + assert rule.name not in config.rules, ( + 'Duplicate rule "{0}" found in {1}'.format( + rule.name, rule_finder)) + config.rules[rule.name] = rule diff --git a/src/mailman/database/__init__.py b/src/mailman/database/__init__.py new file mode 100644 index 000000000..8b7f584c2 --- /dev/null +++ b/src/mailman/database/__init__.py @@ -0,0 +1,153 @@ +# 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 . + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'StockDatabase', + ] + +import os +import logging + +from locknix.lockfile import Lock +from lazr.config import as_boolean +from pkg_resources import resource_string +from storm.locals import create_database, Store +from urlparse import urlparse +from zope.interface import implements + +import mailman.version + +from mailman.config import config +from mailman.database.listmanager import ListManager +from mailman.database.messagestore import MessageStore +from mailman.database.pending import Pendings +from mailman.database.requests import Requests +from mailman.database.usermanager import UserManager +from mailman.database.version import Version +from mailman.interfaces.database import IDatabase, SchemaVersionMismatchError +from mailman.utilities.string import expand + +log = logging.getLogger('mailman.config') + + + +class StockDatabase: + """The standard database, using Storm on top of SQLite.""" + + implements(IDatabase) + + def __init__(self): + self.list_manager = None + self.user_manager = None + self.message_store = None + self.pendings = None + self.requests = None + self._store = None + + def initialize(self, debug=None): + """See `IDatabase`.""" + # Serialize this so we don't get multiple processes trying to create + # the database at the same time. + with Lock(os.path.join(config.LOCK_DIR, 'dbcreate.lck')): + self._create(debug) + self.list_manager = ListManager() + self.user_manager = UserManager() + self.message_store = MessageStore() + self.pendings = Pendings() + self.requests = Requests() + + def begin(self): + """See `IDatabase`.""" + # Storm takes care of this for us. + pass + + def commit(self): + """See `IDatabase`.""" + self.store.commit() + + def abort(self): + """See `IDatabase`.""" + self.store.rollback() + + def _create(self, debug): + # Calculate the engine url. + url = expand(config.database.url, config.paths) + log.debug('Database url: %s', url) + # XXX By design of SQLite, database file creation does not honor + # umask. See their ticket #1193: + # http://www.sqlite.org/cvstrac/tktview?tn=1193,31 + # + # This sucks for us because the mailman.db file /must/ be group + # writable, however even though we guarantee our umask is 002 here, it + # still gets created without the necessary g+w permission, due to + # SQLite's policy. This should only affect SQLite engines because its + # the only one that creates a little file on the local file system. + # This kludges around their bug by "touch"ing the database file before + # SQLite has any chance to create it, thus honoring the umask and + # ensuring the right permissions. We only try to do this for SQLite + # engines, and yes, we could have chmod'd the file after the fact, but + # half dozen and all... + touch(url) + database = create_database(url) + store = Store(database) + database.DEBUG = (as_boolean(config.database.debug) + if debug is None else debug) + # Check the sqlite master database to see if the version file exists. + # If so, then we assume the database schema is correctly initialized. + # Storm does not currently have schema creation. This is not an ideal + # way to handle creating the database, but it's cheap and easy for + # now. + table_names = [item[0] for item in + store.execute('select tbl_name from sqlite_master;')] + if 'version' not in table_names: + # Initialize the database. + sql = resource_string('mailman.database', 'mailman.sql') + for statement in sql.split(';'): + store.execute(statement + ';') + # Validate schema version. + v = store.find(Version, component=u'schema').one() + if not v: + # Database has not yet been initialized + v = Version(component='schema', + version=mailman.version.DATABASE_SCHEMA_VERSION) + store.add(v) + elif v.version <> mailman.version.DATABASE_SCHEMA_VERSION: + # XXX Update schema + raise SchemaVersionMismatchError(v.version) + self.store = store + store.commit() + + def _reset(self): + """See `IDatabase`.""" + from mailman.database.model import ModelMeta + self.store.rollback() + ModelMeta._reset(self.store) + + + +def touch(url): + parts = urlparse(url) + if parts.scheme <> 'sqlite': + return + path = os.path.normpath(parts.path) + fd = os.open(path, os.O_WRONLY | os.O_NONBLOCK | os.O_CREAT, 0666) + # Ignore errors + if fd > 0: + os.close(fd) diff --git a/src/mailman/database/address.py b/src/mailman/database/address.py new file mode 100644 index 000000000..528d3af51 --- /dev/null +++ b/src/mailman/database/address.py @@ -0,0 +1,96 @@ +# 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 . + +"""Model for addresses.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Address', + ] + + +from email.utils import formataddr +from storm.locals import * +from zope.interface import implements + +from mailman.config import config +from mailman.database.member import Member +from mailman.database.model import Model +from mailman.database.preferences import Preferences +from mailman.interfaces.member import AlreadySubscribedError +from mailman.interfaces.address import IAddress + + + +class Address(Model): + implements(IAddress) + + id = Int(primary=True) + address = Unicode() + _original = Unicode() + real_name = Unicode() + verified_on = DateTime() + registered_on = DateTime() + + user_id = Int() + user = Reference(user_id, 'User.id') + preferences_id = Int() + preferences = Reference(preferences_id, 'Preferences.id') + + def __init__(self, address, real_name): + super(Address, self).__init__() + lower_case = address.lower() + self.address = lower_case + self.real_name = real_name + self._original = (None if lower_case == address else address) + + def __str__(self): + addr = (self.address if self._original is None else self._original) + return formataddr((self.real_name, addr)) + + def __repr__(self): + verified = ('verified' if self.verified_on else 'not verified') + address_str = str(self) + if self._original is None: + return ''.format( + address_str, verified, id(self)) + else: + return ''.format( + address_str, verified, self.address, id(self)) + + def subscribe(self, mailing_list, role): + # This member has no preferences by default. + member = config.db.store.find( + Member, + Member.role == role, + Member.mailing_list == mailing_list.fqdn_listname, + Member.address == self).one() + if member: + raise AlreadySubscribedError( + mailing_list.fqdn_listname, self.address, role) + member = Member(role=role, + mailing_list=mailing_list.fqdn_listname, + address=self) + member.preferences = Preferences() + config.db.store.add(member) + return member + + @property + def original_address(self): + return (self.address if self._original is None else self._original) diff --git a/src/mailman/database/language.py b/src/mailman/database/language.py new file mode 100644 index 000000000..8adc5c4a5 --- /dev/null +++ b/src/mailman/database/language.py @@ -0,0 +1,40 @@ +# 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 . + +"""Model for languages.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Language', + ] + + +from storm.locals import * +from zope.interface import implements + +from mailman.database import Model +from mailman.interfaces import ILanguage + + + +class Language(Model): + implements(ILanguage) + + id = Int(primary=True) + code = Unicode() diff --git a/src/mailman/database/listmanager.py b/src/mailman/database/listmanager.py new file mode 100644 index 000000000..790a2509a --- /dev/null +++ b/src/mailman/database/listmanager.py @@ -0,0 +1,82 @@ +# Copyright (C) 2007-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 . + +"""A mailing list manager.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'ListManager', + ] + + +import datetime + +from zope.interface import implements + +from mailman.config import config +from mailman.database.mailinglist import MailingList +from mailman.interfaces.listmanager import IListManager, ListAlreadyExistsError + + + +class ListManager(object): + """An implementation of the `IListManager` interface.""" + + implements(IListManager) + + def create(self, fqdn_listname): + """See `IListManager`.""" + listname, hostname = fqdn_listname.split('@', 1) + mlist = config.db.store.find( + MailingList, + MailingList.list_name == listname, + MailingList.host_name == hostname).one() + if mlist: + raise ListAlreadyExistsError(fqdn_listname) + mlist = MailingList(fqdn_listname) + mlist.created_at = datetime.datetime.now() + config.db.store.add(mlist) + return mlist + + def get(self, fqdn_listname): + """See `IListManager`.""" + listname, hostname = fqdn_listname.split('@', 1) + mlist = config.db.store.find(MailingList, + list_name=listname, + host_name=hostname).one() + if mlist is not None: + # XXX Fixme + mlist._restore() + return mlist + + def delete(self, mlist): + """See `IListManager`.""" + config.db.store.remove(mlist) + + @property + def mailing_lists(self): + """See `IListManager`.""" + for fqdn_listname in self.names: + yield self.get(fqdn_listname) + + @property + def names(self): + """See `IListManager`.""" + for mlist in config.db.store.find(MailingList): + yield '{0}@{1}'.format(mlist.list_name, mlist.host_name) diff --git a/src/mailman/database/mailinglist.py b/src/mailman/database/mailinglist.py new file mode 100644 index 000000000..8803a5fa4 --- /dev/null +++ b/src/mailman/database/mailinglist.py @@ -0,0 +1,272 @@ +# 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 . + +"""Model for mailing lists.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'MailingList', + ] + + +import os +import string + +from storm.locals import * +from urlparse import urljoin +from zope.interface import implements + +from mailman.config import config +from mailman.database import roster +from mailman.database.model import Model +from mailman.database.types import Enum +from mailman.interfaces.mailinglist import IMailingList, Personalization +from mailman.utilities.filesystem import makedirs +from mailman.utilities.string import expand + + +SPACE = ' ' +UNDERSCORE = '_' + + + +class MailingList(Model): + implements(IMailingList) + + id = Int(primary=True) + + # List identity + list_name = Unicode() + host_name = Unicode() + # Attributes not directly modifiable via the web u/i + created_at = DateTime() + admin_member_chunksize = Int() + hold_and_cmd_autoresponses = Pickle() + # Attributes which are directly modifiable via the web u/i. The more + # complicated attributes are currently stored as pickles, though that + # will change as the schema and implementation is developed. + next_request_id = Int() + next_digest_number = Int() + admin_responses = Pickle() + postings_responses = Pickle() + request_responses = Pickle() + digest_last_sent_at = Float() + one_last_digest = Pickle() + volume = Int() + last_post_time = DateTime() + # Attributes which are directly modifiable via the web u/i. The more + # complicated attributes are currently stored as pickles, though that + # will change as the schema and implementation is developed. + accept_these_nonmembers = Pickle() + acceptable_aliases = Pickle() + admin_immed_notify = Bool() + admin_notify_mchanges = Bool() + administrivia = Bool() + advertised = Bool() + anonymous_list = Bool() + archive = Bool() + archive_private = Bool() + archive_volume_frequency = Int() + autorespond_admin = Bool() + autorespond_postings = Bool() + autorespond_requests = Int() + autoresponse_admin_text = Unicode() + autoresponse_graceperiod = TimeDelta() + autoresponse_postings_text = Unicode() + autoresponse_request_text = Unicode() + ban_list = Pickle() + bounce_info_stale_after = TimeDelta() + bounce_matching_headers = Unicode() + bounce_notify_owner_on_disable = Bool() + bounce_notify_owner_on_removal = Bool() + bounce_processing = Bool() + bounce_score_threshold = Int() + bounce_unrecognized_goes_to_list_owner = Bool() + bounce_you_are_disabled_warnings = Int() + bounce_you_are_disabled_warnings_interval = TimeDelta() + collapse_alternatives = Bool() + convert_html_to_plaintext = Bool() + default_member_moderation = Bool() + description = Unicode() + digest_footer = Unicode() + digest_header = Unicode() + digest_is_default = Bool() + digest_send_periodic = Bool() + digest_size_threshold = Int() + digest_volume_frequency = Int() + digestable = Bool() + discard_these_nonmembers = Pickle() + emergency = Bool() + encode_ascii_prefixes = Bool() + filter_action = Int() + filter_content = Bool() + filter_filename_extensions = Pickle() + filter_mime_types = Pickle() + first_strip_reply_to = Bool() + forward_auto_discards = Bool() + gateway_to_mail = Bool() + gateway_to_news = Bool() + generic_nonmember_action = Int() + goodbye_msg = Unicode() + header_matches = Pickle() + hold_these_nonmembers = Pickle() + include_list_post_header = Bool() + include_rfc2369_headers = Bool() + info = Unicode() + linked_newsgroup = Unicode() + max_days_to_hold = Int() + max_message_size = Int() + max_num_recipients = Int() + member_moderation_action = Enum() + member_moderation_notice = Unicode() + mime_is_default_digest = Bool() + moderator_password = Unicode() + msg_footer = Unicode() + msg_header = Unicode() + new_member_options = Int() + news_moderation = Enum() + news_prefix_subject_too = Bool() + nntp_host = Unicode() + nondigestable = Bool() + nonmember_rejection_notice = Unicode() + obscure_addresses = Bool() + pass_filename_extensions = Pickle() + pass_mime_types = Pickle() + personalize = Enum() + pipeline = Unicode() + post_id = Int() + preferred_language = Unicode() + private_roster = Bool() + real_name = Unicode() + reject_these_nonmembers = Pickle() + reply_goes_to_list = Enum() + reply_to_address = Unicode() + require_explicit_destination = Bool() + respond_to_post_requests = Bool() + scrub_nondigest = Bool() + send_goodbye_msg = Bool() + send_reminders = Bool() + send_welcome_msg = Bool() + start_chain = Unicode() + subject_prefix = Unicode() + subscribe_auto_approval = Pickle() + subscribe_policy = Int() + topics = Pickle() + topics_bodylines_limit = Int() + topics_enabled = Bool() + unsubscribe_policy = Int() + welcome_msg = Unicode() + + def __init__(self, fqdn_listname): + super(MailingList, self).__init__() + listname, hostname = fqdn_listname.split('@', 1) + self.list_name = listname + self.host_name = hostname + # For the pending database + self.next_request_id = 1 + self._restore() + # Max autoresponses per day. A mapping between addresses and a + # 2-tuple of the date of the last autoresponse and the number of + # autoresponses sent on that date. + self.hold_and_cmd_autoresponses = {} + self.personalization = Personalization.none + self.real_name = string.capwords( + SPACE.join(listname.split(UNDERSCORE))) + makedirs(self.data_path) + + # XXX FIXME + def _restore(self): + self.owners = roster.OwnerRoster(self) + self.moderators = roster.ModeratorRoster(self) + self.administrators = roster.AdministratorRoster(self) + self.members = roster.MemberRoster(self) + self.regular_members = roster.RegularMemberRoster(self) + self.digest_members = roster.DigestMemberRoster(self) + self.subscribers = roster.Subscribers(self) + + @property + def fqdn_listname(self): + """See `IMailingList`.""" + return '{0}@{1}'.format(self.list_name, self.host_name) + + @property + def web_host(self): + """See `IMailingList`.""" + return config.domains[self.host_name] + + def script_url(self, target, context=None): + """See `IMailingList`.""" + # Find the domain for this mailing list. + domain = config.domains[self.host_name] + # XXX Handle the case for when context is not None; those would be + # relative URLs. + return urljoin(domain.base_url, target + '/' + self.fqdn_listname) + + @property + def data_path(self): + """See `IMailingList`.""" + return os.path.join(config.LIST_DATA_DIR, self.fqdn_listname) + + # IMailingListAddresses + + @property + def posting_address(self): + return self.fqdn_listname + + @property + def no_reply_address(self): + return '{0}@{1}'.format(config.mailman.noreply_address, self.host_name) + + @property + def owner_address(self): + return '{0}-owner@{1}'.format(self.list_name, self.host_name) + + @property + def request_address(self): + return '{0}-request@{1}'.format(self.list_name, self.host_name) + + @property + def bounces_address(self): + return '{0}-bounces@{1}'.format(self.list_name, self.host_name) + + @property + def join_address(self): + return '{0}-join@{1}'.format(self.list_name, self.host_name) + + @property + def leave_address(self): + return '{0}-leave@{1}'.format(self.list_name, self.host_name) + + @property + def subscribe_address(self): + return '{0}-subscribe@{1}'.format(self.list_name, self.host_name) + + @property + def unsubscribe_address(self): + return '{0}-unsubscribe@{1}'.format(self.list_name, self.host_name) + + def confirm_address(self, cookie): + local_part = expand(config.mta.verp_confirm_format, dict( + address = '{0}-confirm'.format(self.list_name), + cookie = cookie)) + return '{0}@{1}'.format(local_part, self.host_name) + + def __repr__(self): + return ''.format( + self.fqdn_listname, id(self)) diff --git a/src/mailman/database/mailman.sql b/src/mailman/database/mailman.sql new file mode 100644 index 000000000..b098ed13b --- /dev/null +++ b/src/mailman/database/mailman.sql @@ -0,0 +1,208 @@ +CREATE TABLE _request ( + id INTEGER NOT NULL, + "key" TEXT, + request_type TEXT, + data_hash TEXT, + mailing_list_id INTEGER, + PRIMARY KEY (id), + CONSTRAINT _request_mailing_list_id_fk FOREIGN KEY(mailing_list_id) REFERENCES mailinglist (id) +); +CREATE TABLE address ( + id INTEGER NOT NULL, + address TEXT, + _original TEXT, + real_name TEXT, + verified_on TIMESTAMP, + registered_on TIMESTAMP, + user_id INTEGER, + preferences_id INTEGER, + PRIMARY KEY (id), + CONSTRAINT address_user_id_fk FOREIGN KEY(user_id) REFERENCES user (id), + CONSTRAINT address_preferences_id_fk FOREIGN KEY(preferences_id) REFERENCES preferences (id) +); +CREATE TABLE language ( + id INTEGER NOT NULL, + code TEXT, + PRIMARY KEY (id) +); +CREATE TABLE mailinglist ( + id INTEGER NOT NULL, + list_name TEXT, + host_name TEXT, + created_at TIMESTAMP, + admin_member_chunksize INTEGER, + hold_and_cmd_autoresponses BLOB, + next_request_id INTEGER, + next_digest_number INTEGER, + admin_responses BLOB, + postings_responses BLOB, + request_responses BLOB, + digest_last_sent_at NUMERIC(10, 2), + one_last_digest BLOB, + volume INTEGER, + last_post_time TIMESTAMP, + accept_these_nonmembers BLOB, + acceptable_aliases BLOB, + admin_immed_notify BOOLEAN, + admin_notify_mchanges BOOLEAN, + administrivia BOOLEAN, + advertised BOOLEAN, + anonymous_list BOOLEAN, + archive BOOLEAN, + archive_private BOOLEAN, + archive_volume_frequency INTEGER, + autorespond_admin BOOLEAN, + autorespond_postings BOOLEAN, + autorespond_requests INTEGER, + autoresponse_admin_text TEXT, + autoresponse_graceperiod TEXT, + autoresponse_postings_text TEXT, + autoresponse_request_text TEXT, + ban_list BLOB, + bounce_info_stale_after TEXT, + bounce_matching_headers TEXT, + bounce_notify_owner_on_disable BOOLEAN, + bounce_notify_owner_on_removal BOOLEAN, + bounce_processing BOOLEAN, + bounce_score_threshold INTEGER, + bounce_unrecognized_goes_to_list_owner BOOLEAN, + bounce_you_are_disabled_warnings INTEGER, + bounce_you_are_disabled_warnings_interval TEXT, + collapse_alternatives BOOLEAN, + convert_html_to_plaintext BOOLEAN, + default_member_moderation BOOLEAN, + description TEXT, + digest_footer TEXT, + digest_header TEXT, + digest_is_default BOOLEAN, + digest_send_periodic BOOLEAN, + digest_size_threshold INTEGER, + digest_volume_frequency INTEGER, + digestable BOOLEAN, + discard_these_nonmembers BLOB, + emergency BOOLEAN, + encode_ascii_prefixes BOOLEAN, + filter_action INTEGER, + filter_content BOOLEAN, + filter_filename_extensions BLOB, + filter_mime_types BLOB, + first_strip_reply_to BOOLEAN, + forward_auto_discards BOOLEAN, + gateway_to_mail BOOLEAN, + gateway_to_news BOOLEAN, + generic_nonmember_action INTEGER, + goodbye_msg TEXT, + header_matches BLOB, + hold_these_nonmembers BLOB, + include_list_post_header BOOLEAN, + include_rfc2369_headers BOOLEAN, + info TEXT, + linked_newsgroup TEXT, + max_days_to_hold INTEGER, + max_message_size INTEGER, + max_num_recipients INTEGER, + member_moderation_action BOOLEAN, + member_moderation_notice TEXT, + mime_is_default_digest BOOLEAN, + moderator_password TEXT, + msg_footer TEXT, + msg_header TEXT, + new_member_options INTEGER, + news_moderation TEXT, + news_prefix_subject_too BOOLEAN, + nntp_host TEXT, + nondigestable BOOLEAN, + nonmember_rejection_notice TEXT, + obscure_addresses BOOLEAN, + pass_filename_extensions BLOB, + pass_mime_types BLOB, + personalize TEXT, + pipeline TEXT, + post_id INTEGER, + preferred_language TEXT, + private_roster BOOLEAN, + real_name TEXT, + reject_these_nonmembers BLOB, + reply_goes_to_list TEXT, + reply_to_address TEXT, + require_explicit_destination BOOLEAN, + respond_to_post_requests BOOLEAN, + scrub_nondigest BOOLEAN, + send_goodbye_msg BOOLEAN, + send_reminders BOOLEAN, + send_welcome_msg BOOLEAN, + start_chain TEXT, + subject_prefix TEXT, + subscribe_auto_approval BLOB, + subscribe_policy INTEGER, + topics BLOB, + topics_bodylines_limit INTEGER, + topics_enabled BOOLEAN, + unsubscribe_policy INTEGER, + welcome_msg TEXT, + PRIMARY KEY (id) +); +CREATE TABLE member ( + id INTEGER NOT NULL, + role TEXT, + mailing_list TEXT, + is_moderated BOOLEAN, + address_id INTEGER, + preferences_id INTEGER, + PRIMARY KEY (id), + CONSTRAINT member_address_id_fk FOREIGN KEY(address_id) REFERENCES address (id), + CONSTRAINT member_preferences_id_fk FOREIGN KEY(preferences_id) REFERENCES preferences (id) +); +CREATE TABLE message ( + id INTEGER NOT NULL, + message_id_hash TEXT, + path TEXT, + message_id TEXT, + PRIMARY KEY (id) +); +CREATE TABLE pended ( + id INTEGER NOT NULL, + token TEXT, + expiration_date TIMESTAMP, + PRIMARY KEY (id) +); +CREATE TABLE pendedkeyvalue ( + id INTEGER NOT NULL, + "key" TEXT, + value TEXT, + pended_id INTEGER, + PRIMARY KEY (id), + CONSTRAINT pendedkeyvalue_pended_id_fk FOREIGN KEY(pended_id) REFERENCES pended (id) +); +CREATE TABLE preferences ( + id INTEGER NOT NULL, + acknowledge_posts BOOLEAN, + hide_address BOOLEAN, + preferred_language TEXT, + receive_list_copy BOOLEAN, + receive_own_postings BOOLEAN, + delivery_mode TEXT, + delivery_status TEXT, + PRIMARY KEY (id) +); +CREATE TABLE user ( + id INTEGER NOT NULL, + real_name TEXT, + password TEXT, + preferences_id INTEGER, + PRIMARY KEY (id), + CONSTRAINT user_preferences_id_fk FOREIGN KEY(preferences_id) REFERENCES preferences (id) +); +CREATE TABLE version ( + id INTEGER NOT NULL, + component TEXT, + version INTEGER, + PRIMARY KEY (id) +); +CREATE INDEX ix__request_mailing_list_id ON _request (mailing_list_id); +CREATE INDEX ix_address_preferences_id ON address (preferences_id); +CREATE INDEX ix_address_user_id ON address (user_id); +CREATE INDEX ix_member_address_id ON member (address_id); +CREATE INDEX ix_member_preferences_id ON member (preferences_id); +CREATE INDEX ix_pendedkeyvalue_pended_id ON pendedkeyvalue (pended_id); +CREATE INDEX ix_user_preferences_id ON user (preferences_id); diff --git a/src/mailman/database/member.py b/src/mailman/database/member.py new file mode 100644 index 000000000..22bf042f6 --- /dev/null +++ b/src/mailman/database/member.py @@ -0,0 +1,105 @@ +# Copyright (C) 2007-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 . + +"""Model for members.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Member', + ] + +from storm.locals import * +from zope.interface import implements + +from mailman.config import config +from mailman.constants import SystemDefaultPreferences +from mailman.database.model import Model +from mailman.database.types import Enum +from mailman.interfaces.member import IMember + + + +class Member(Model): + implements(IMember) + + id = Int(primary=True) + role = Enum() + mailing_list = Unicode() + is_moderated = Bool() + + address_id = Int() + address = Reference(address_id, 'Address.id') + preferences_id = Int() + preferences = Reference(preferences_id, 'Preferences.id') + + def __init__(self, role, mailing_list, address): + self.role = role + self.mailing_list = mailing_list + self.address = address + self.is_moderated = False + + def __repr__(self): + return ''.format( + self.address, self.mailing_list, self.role) + + def _lookup(self, preference): + pref = getattr(self.preferences, preference) + if pref is not None: + return pref + pref = getattr(self.address.preferences, preference) + if pref is not None: + return pref + if self.address.user: + pref = getattr(self.address.user.preferences, preference) + if pref is not None: + return pref + return getattr(SystemDefaultPreferences, preference) + + @property + def acknowledge_posts(self): + return self._lookup('acknowledge_posts') + + @property + def preferred_language(self): + return self._lookup('preferred_language') + + @property + def receive_list_copy(self): + return self._lookup('receive_list_copy') + + @property + def receive_own_postings(self): + return self._lookup('receive_own_postings') + + @property + def delivery_mode(self): + return self._lookup('delivery_mode') + + @property + def delivery_status(self): + return self._lookup('delivery_status') + + @property + def options_url(self): + # XXX Um, this is definitely wrong + return 'http://example.com/' + self.address.address + + def unsubscribe(self): + config.db.store.remove(self.preferences) + config.db.store.remove(self) diff --git a/src/mailman/database/message.py b/src/mailman/database/message.py new file mode 100644 index 000000000..e77e11429 --- /dev/null +++ b/src/mailman/database/message.py @@ -0,0 +1,53 @@ +# Copyright (C) 2007-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 . + +"""Model for messages.""" + + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Message', + ] + +from storm.locals import * +from zope.interface import implements + +from mailman.config import config +from mailman.database.model import Model +from mailman.interfaces.messages import IMessage + + + +class Message(Model): + """A message in the message store.""" + + implements(IMessage) + + id = Int(primary=True, default=AutoReload) + message_id = Unicode() + message_id_hash = RawStr() + path = RawStr() + # This is a Messge-ID field representation, not a database row id. + + def __init__(self, message_id, message_id_hash, path): + super(Message, self).__init__() + self.message_id = message_id + self.message_id_hash = message_id_hash + self.path = path + config.db.store.add(self) diff --git a/src/mailman/database/messagestore.py b/src/mailman/database/messagestore.py new file mode 100644 index 000000000..a129f47ec --- /dev/null +++ b/src/mailman/database/messagestore.py @@ -0,0 +1,137 @@ +# Copyright (C) 2007-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 . + +"""Model for message stores.""" + + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'MessageStore', + ] + +import os +import errno +import base64 +import hashlib +import cPickle as pickle + +from zope.interface import implements + +from mailman.config import config +from mailman.database.message import Message +from mailman.interfaces.messages import IMessageStore +from mailman.utilities.filesystem import makedirs + + +# It could be very bad if you have already stored files and you change this +# value. We'd need a script to reshuffle and resplit. +MAX_SPLITS = 2 +EMPTYSTRING = '' + + + +class MessageStore: + implements(IMessageStore) + + def add(self, message): + # Ensure that the message has the requisite headers. + message_ids = message.get_all('message-id', []) + if len(message_ids) <> 1: + raise ValueError('Exactly one Message-ID header required') + # Calculate and insert the X-Message-ID-Hash. + message_id = message_ids[0] + # Complain if the Message-ID already exists in the storage. + existing = config.db.store.find(Message, + Message.message_id == message_id).one() + if existing is not None: + raise ValueError( + 'Message ID already exists in message store: {0}'.format( + message_id)) + shaobj = hashlib.sha1(message_id) + hash32 = base64.b32encode(shaobj.digest()) + del message['X-Message-ID-Hash'] + message['X-Message-ID-Hash'] = hash32 + # Calculate the path on disk where we're going to store this message + # object, in pickled format. + parts = [] + split = list(hash32) + while split and len(parts) < MAX_SPLITS: + parts.append(split.pop(0) + split.pop(0)) + parts.append(hash32) + relpath = os.path.join(*parts) + # Store the message in the database. This relies on the database + # providing a unique serial number, but to get this information, we + # have to use a straight insert instead of relying on Elixir to create + # the object. + row = Message(message_id=message_id, + message_id_hash=hash32, + path=relpath) + # Now calculate the full file system path. + path = os.path.join(config.MESSAGES_DIR, relpath) + # Write the file to the path, but catch the appropriate exception in + # case the parent directories don't yet exist. In that case, create + # them and try again. + while True: + try: + with open(path, 'w') as fp: + # -1 says to use the highest protocol available. + pickle.dump(message, fp, -1) + break + except IOError as error: + if error.errno <> errno.ENOENT: + raise + makedirs(os.path.dirname(path)) + return hash32 + + def _get_message(self, row): + path = os.path.join(config.MESSAGES_DIR, row.path) + with open(path) as fp: + return pickle.load(fp) + + def get_message_by_id(self, message_id): + row = config.db.store.find(Message, message_id=message_id).one() + if row is None: + return None + return self._get_message(row) + + def get_message_by_hash(self, message_id_hash): + # It's possible the hash came from a message header, in which case it + # will be a Unicode. However when coming from source code, it may be + # an 8-string. Coerce to the latter if necessary; it must be + # US-ASCII. + if isinstance(message_id_hash, unicode): + message_id_hash = message_id_hash.encode('ascii') + row = config.db.store.find(Message, + message_id_hash=message_id_hash).one() + if row is None: + return None + return self._get_message(row) + + @property + def messages(self): + for row in config.db.store.find(Message): + yield self._get_message(row) + + def delete_message(self, message_id): + row = config.db.store.find(Message, message_id=message_id).one() + if row is None: + raise LookupError(message_id) + path = os.path.join(config.MESSAGES_DIR, row.path) + os.remove(path) + config.db.store.remove(row) diff --git a/src/mailman/database/model.py b/src/mailman/database/model.py new file mode 100644 index 000000000..85fa033c7 --- /dev/null +++ b/src/mailman/database/model.py @@ -0,0 +1,56 @@ +# 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 . + +"""Base class for all database classes.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Model', + ] + +from storm.properties import PropertyPublisherMeta + + + +class ModelMeta(PropertyPublisherMeta): + """Do more magic on table classes.""" + + _class_registry = set() + + def __init__(self, name, bases, dict): + # Before we let the base class do it's thing, force an __storm_table__ + # property to enforce our table naming convention. + self.__storm_table__ = name.lower() + super(ModelMeta, self).__init__(name, bases, dict) + # Register the model class so that it can be more easily cleared. + # This is required by the test framework. + if name == 'Model': + return + ModelMeta._class_registry.add(self) + + @staticmethod + def _reset(store): + for model_class in ModelMeta._class_registry: + store.find(model_class).remove() + + + +class Model: + """Like Storm's `Storm` subclass, but with a bit extra.""" + __metaclass__ = ModelMeta diff --git a/src/mailman/database/pending.py b/src/mailman/database/pending.py new file mode 100644 index 000000000..f4c2057e0 --- /dev/null +++ b/src/mailman/database/pending.py @@ -0,0 +1,177 @@ +# Copyright (C) 2007-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 . + +"""Implementations of the IPendable and IPending interfaces.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Pended', + 'Pendings', + ] + +import sys +import time +import random +import hashlib +import datetime + +from lazr.config import as_timedelta +from storm.locals import * +from zope.interface import implements +from zope.interface.verify import verifyObject + +from mailman.config import config +from mailman.database.model import Model +from mailman.interfaces.pending import ( + IPendable, IPended, IPendedKeyValue, IPendings) + + + +class PendedKeyValue(Model): + """A pended key/value pair, tied to a token.""" + + implements(IPendedKeyValue) + + def __init__(self, key, value): + self.key = key + self.value = value + + id = Int(primary=True) + key = Unicode() + value = Unicode() + pended_id = Int() + + +class Pended(Model): + """A pended event, tied to a token.""" + + implements(IPended) + + def __init__(self, token, expiration_date): + super(Pended, self).__init__() + self.token = token + self.expiration_date = expiration_date + + id = Int(primary=True) + token = RawStr() + expiration_date = DateTime() + key_values = ReferenceSet(id, PendedKeyValue.pended_id) + + + +class UnpendedPendable(dict): + implements(IPendable) + + + +class Pendings: + """Implementation of the IPending interface.""" + + implements(IPendings) + + def add(self, pendable, lifetime=None): + verifyObject(IPendable, pendable) + # Calculate the token and the lifetime. + if lifetime is None: + lifetime = as_timedelta(config.mailman.pending_request_life) + # Calculate a unique token. Algorithm vetted by the Timbot. time() + # has high resolution on Linux, clock() on Windows. random gives us + # about 45 bits in Python 2.2, 53 bits on Python 2.3. The time and + # clock values basically help obscure the random number generator, as + # does the hash calculation. The integral parts of the time values + # are discarded because they're the most predictable bits. + for attempts in range(3): + now = time.time() + x = random.random() + now % 1.0 + time.clock() % 1.0 + # Use sha1 because it produces shorter strings. + token = hashlib.sha1(repr(x)).hexdigest() + # In practice, we'll never get a duplicate, but we'll be anal + # about checking anyway. + if config.db.store.find(Pended, token=token).count() == 0: + break + else: + raise AssertionError('Could not find a valid pendings token') + # Create the record, and then the individual key/value pairs. + pending = Pended( + token=token, + expiration_date=datetime.datetime.now() + lifetime) + for key, value in pendable.items(): + if isinstance(key, str): + key = unicode(key, 'utf-8') + if isinstance(value, str): + value = unicode(value, 'utf-8') + elif type(value) is int: + value = '__builtin__.int\1%s' % value + elif type(value) is float: + value = '__builtin__.float\1%s' % value + elif type(value) is bool: + value = '__builtin__.bool\1%s' % value + elif type(value) is list: + # We expect this to be a list of strings. + value = ('mailman.database.pending.unpack_list\1' + + '\2'.join(value)) + keyval = PendedKeyValue(key=key, value=value) + pending.key_values.add(keyval) + config.db.store.add(pending) + return token + + def confirm(self, token, expunge=True): + store = config.db.store + pendings = store.find(Pended, token=token) + if pendings.count() == 0: + return None + assert pendings.count() == 1, ( + 'Unexpected token count: {0}'.format(pendings.count())) + pending = pendings[0] + pendable = UnpendedPendable() + # Find all PendedKeyValue entries that are associated with the pending + # object's ID. Watch out for type conversions. + for keyvalue in store.find(PendedKeyValue, + PendedKeyValue.pended_id == pending.id): + if keyvalue.value is not None and '\1' in keyvalue.value: + typename, value = keyvalue.value.split('\1', 1) + package, classname = typename.rsplit('.', 1) + __import__(package) + module = sys.modules[package] + pendable[keyvalue.key] = getattr(module, classname)(value) + else: + pendable[keyvalue.key] = keyvalue.value + if expunge: + store.remove(keyvalue) + if expunge: + store.remove(pending) + return pendable + + def evict(self): + store = config.db.store + now = datetime.datetime.now() + for pending in store.find(Pended): + if pending.expiration_date < now: + # Find all PendedKeyValue entries that are associated with the + # pending object's ID. + q = store.find(PendedKeyValue, + PendedKeyValue.pended_id == pending.id) + for keyvalue in q: + store.remove(keyvalue) + store.remove(pending) + + + +def unpack_list(value): + return value.split('\2') diff --git a/src/mailman/database/preferences.py b/src/mailman/database/preferences.py new file mode 100644 index 000000000..f3ee55673 --- /dev/null +++ b/src/mailman/database/preferences.py @@ -0,0 +1,50 @@ +# 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 . + +"""Model for preferences.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Preferences', + ] + + +from storm.locals import * +from zope.interface import implements + +from mailman.database.model import Model +from mailman.database.types import Enum +from mailman.interfaces.preferences import IPreferences + + + +class Preferences(Model): + implements(IPreferences) + + id = Int(primary=True) + acknowledge_posts = Bool() + hide_address = Bool() + preferred_language = Unicode() + receive_list_copy = Bool() + receive_own_postings = Bool() + delivery_mode = Enum() + delivery_status = Enum() + + def __repr__(self): + return ''.format(id(self)) diff --git a/src/mailman/database/requests.py b/src/mailman/database/requests.py new file mode 100644 index 000000000..249feb6b6 --- /dev/null +++ b/src/mailman/database/requests.py @@ -0,0 +1,138 @@ +# Copyright (C) 2007-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 . + +"""Implementations of the IRequests and IListRequests interfaces.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Requests', + ] + + +from datetime import timedelta +from storm.locals import * +from zope.interface import implements + +from mailman.config import config +from mailman.database.model import Model +from mailman.database.types import Enum +from mailman.interfaces.pending import IPendable +from mailman.interfaces.requests import IListRequests, IRequests, RequestType + + + +class DataPendable(dict): + implements(IPendable) + + + +class ListRequests: + implements(IListRequests) + + def __init__(self, mailing_list): + self.mailing_list = mailing_list + + @property + def count(self): + return config.db.store.find( + _Request, mailing_list=self.mailing_list).count() + + def count_of(self, request_type): + return config.db.store.find( + _Request, + mailing_list=self.mailing_list, request_type=request_type).count() + + @property + def held_requests(self): + results = config.db.store.find( + _Request, mailing_list=self.mailing_list) + for request in results: + yield request + + def of_type(self, request_type): + results = config.db.store.find( + _Request, + mailing_list=self.mailing_list, request_type=request_type) + for request in results: + yield request + + def hold_request(self, request_type, key, data=None): + if request_type not in RequestType: + raise TypeError(request_type) + if data is None: + data_hash = None + else: + # We're abusing the pending database as a way of storing arbitrary + # key/value pairs, where both are strings. This isn't ideal but + # it lets us get auxiliary data almost for free. We may need to + # lock this down more later. + pendable = DataPendable() + pendable.update(data) + token = config.db.pendings.add(pendable, timedelta(days=5000)) + data_hash = token + request = _Request(key, request_type, self.mailing_list, data_hash) + config.db.store.add(request) + return request.id + + def get_request(self, request_id): + result = config.db.store.get(_Request, request_id) + if result is None: + return None + if result.data_hash is None: + return result.key, result.data_hash + pendable = config.db.pendings.confirm(result.data_hash, expunge=False) + data = dict() + data.update(pendable) + return result.key, data + + def delete_request(self, request_id): + request = config.db.store.get(_Request, request_id) + if request is None: + raise KeyError(request_id) + # Throw away the pended data. + config.db.pendings.confirm(request.data_hash) + config.db.store.remove(request) + + + +class Requests: + implements(IRequests) + + def get_list_requests(self, mailing_list): + return ListRequests(mailing_list) + + + +class _Request(Model): + """Table for mailing list hold requests.""" + + id = Int(primary=True, default=AutoReload) + key = Unicode() + request_type = Enum() + data_hash = RawStr() + + mailing_list_id = Int() + mailing_list = Reference(mailing_list_id, 'MailingList.id') + + def __init__(self, key, request_type, mailing_list, data_hash): + super(_Request, self).__init__() + self.key = key + self.request_type = request_type + self.mailing_list = mailing_list + self.data_hash = data_hash diff --git a/src/mailman/database/roster.py b/src/mailman/database/roster.py new file mode 100644 index 000000000..fc0a24c7d --- /dev/null +++ b/src/mailman/database/roster.py @@ -0,0 +1,270 @@ +# Copyright (C) 2007-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 . + +"""An implementation of an IRoster. + +These are hard-coded rosters which know how to filter a set of members to find +the ones that fit a particular role. These are used as the member, owner, +moderator, and administrator roster filters. +""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'AdministratorRoster', + 'DigestMemberRoster', + 'MemberRoster', + 'Memberships', + 'ModeratorRoster', + 'OwnerRoster', + 'RegularMemberRoster', + 'Subscribers', + ] + + +from storm.locals import * +from zope.interface import implements + +from mailman.config import config +from mailman.database.address import Address +from mailman.database.member import Member +from mailman.interfaces.member import DeliveryMode, MemberRole +from mailman.interfaces.roster import IRoster + + + +class AbstractRoster: + """An abstract IRoster class. + + This class takes the simple approach of implemented the 'users' and + 'addresses' properties in terms of the 'members' property. This may not + be the most efficient way, but it works. + + This requires that subclasses implement the 'members' property. + """ + implements(IRoster) + + role = None + + def __init__(self, mlist): + self._mlist = mlist + + @property + def members(self): + for member in config.db.store.find( + Member, + mailing_list=self._mlist.fqdn_listname, + role=self.role): + yield member + + @property + def users(self): + # Members are linked to addresses, which in turn are linked to users. + # So while the 'members' attribute does most of the work, we have to + # keep a set of unique users. It's possible for the same user to be + # subscribed to a mailing list multiple times with different + # addresses. + users = set(member.address.user for member in self.members) + for user in users: + yield user + + @property + def addresses(self): + # Every Member is linked to exactly one address so the 'members' + # attribute does most of the work. + for member in self.members: + yield member.address + + def get_member(self, address): + results = config.db.store.find( + Member, + Member.mailing_list == self._mlist.fqdn_listname, + Member.role == self.role, + Address.address == address, + Member.address_id == Address.id) + if results.count() == 0: + return None + elif results.count() == 1: + return results[0] + else: + raise AssertionError( + 'Too many matching member results: {0}'.format( + results.count())) + + + +class MemberRoster(AbstractRoster): + """Return all the members of a list.""" + + name = 'member' + role = MemberRole.member + + + +class OwnerRoster(AbstractRoster): + """Return all the owners of a list.""" + + name = 'owner' + role = MemberRole.owner + + + +class ModeratorRoster(AbstractRoster): + """Return all the owners of a list.""" + + name = 'moderator' + role = MemberRole.moderator + + + +class AdministratorRoster(AbstractRoster): + """Return all the administrators of a list.""" + + name = 'administrator' + + @property + def members(self): + # Administrators are defined as the union of the owners and the + # moderators. + members = config.db.store.find( + Member, + Member.mailing_list == self._mlist.fqdn_listname, + Or(Member.role == MemberRole.owner, + Member.role == MemberRole.moderator)) + for member in members: + yield member + + def get_member(self, address): + results = config.db.store.find( + Member, + Member.mailing_list == self._mlist.fqdn_listname, + Or(Member.role == MemberRole.moderator, + Member.role == MemberRole.owner), + Address.address == address, + Member.address_id == Address.id) + if results.count() == 0: + return None + elif results.count() == 1: + return results[0] + else: + raise AssertionError( + 'Too many matching member results: {0}'.format(results)) + + + +class RegularMemberRoster(AbstractRoster): + """Return all the regular delivery members of a list.""" + + name = 'regular_members' + + @property + def members(self): + # Query for all the Members which have a role of MemberRole.member and + # are subscribed to this mailing list. Then return only those members + # that have a regular delivery mode. + for member in config.db.store.find( + Member, + mailing_list=self._mlist.fqdn_listname, + role=MemberRole.member): + if member.delivery_mode == DeliveryMode.regular: + yield member + + + +_digest_modes = ( + DeliveryMode.mime_digests, + DeliveryMode.plaintext_digests, + DeliveryMode.summary_digests, + ) + + + +class DigestMemberRoster(AbstractRoster): + """Return all the regular delivery members of a list.""" + + name = 'digest_members' + + @property + def members(self): + # Query for all the Members which have a role of MemberRole.member and + # are subscribed to this mailing list. Then return only those members + # that have one of the digest delivery modes. + for member in config.db.store.find( + Member, + mailing_list=self._mlist.fqdn_listname, + role=MemberRole.member): + if member.delivery_mode in _digest_modes: + yield member + + + +class Subscribers(AbstractRoster): + """Return all subscribed members regardless of their role.""" + + name = 'subscribers' + + @property + def members(self): + for member in config.db.store.find( + Member, + mailing_list=self._mlist.fqdn_listname): + yield member + + + +class Memberships: + """A roster of a single user's memberships.""" + + implements(IRoster) + + name = 'memberships' + + def __init__(self, user): + self._user = user + + @property + def members(self): + results = config.db.store.find( + Member, + Address.user_id == self._user.id, + Member.address_id == Address.id) + for member in results: + yield member + + @property + def users(self): + yield self._user + + @property + def addresses(self): + for address in self._user.addresses: + yield address + + def get_member(self, address): + results = config.db.store.find( + Member, + Member.address_id == Address.id, + Address.user_id == self._user.id) + if results.count() == 0: + return None + elif results.count() == 1: + return results[0] + else: + raise AssertionError( + 'Too many matching member results: {0}'.format( + results.count())) diff --git a/src/mailman/database/transaction.py b/src/mailman/database/transaction.py new file mode 100644 index 000000000..d42562389 --- /dev/null +++ b/src/mailman/database/transaction.py @@ -0,0 +1,53 @@ +# 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 . + +"""Transactional support.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'txn', + ] + + +from mailman.config import config + + + +class txn(object): + """Decorator for transactional support. + + When the function this decorator wraps exits cleanly, the current + transaction is committed. When it exits uncleanly (i.e. because of an + exception, the transaction is aborted. + + Either way, the current transaction is completed. + """ + def __init__(self, function): + self._function = function + + def __get__(self, obj, type=None): + def wrapper(*args, **kws): + try: + rtn = self._function(obj, *args, **kws) + config.db.commit() + return rtn + except: + config.db.abort() + raise + return wrapper diff --git a/src/mailman/database/types.py b/src/mailman/database/types.py new file mode 100644 index 000000000..2f901fe49 --- /dev/null +++ b/src/mailman/database/types.py @@ -0,0 +1,64 @@ +# Copyright (C) 2007-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 . + +"""Storm type conversions.""" + + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Enum', + ] + + +import sys + +from storm.properties import SimpleProperty +from storm.variables import Variable + + + +class _EnumVariable(Variable): + """Storm variable.""" + + def parse_set(self, value, from_db): + if value is None: + return None + if not from_db: + return value + path, intvalue = value.rsplit(':', 1) + modulename, classname = path.rsplit('.', 1) + __import__(modulename) + cls = getattr(sys.modules[modulename], classname) + return cls[int(intvalue)] + + def parse_get(self, value, to_db): + if value is None: + return None + if not to_db: + return value + return '{0}.{1}:{2}'.format( + value.enumclass.__module__, + value.enumclass.__name__, + int(value)) + + +class Enum(SimpleProperty): + """Custom munepy.Enum type for Storm.""" + + variable_class = _EnumVariable diff --git a/src/mailman/database/user.py b/src/mailman/database/user.py new file mode 100644 index 000000000..23701686b --- /dev/null +++ b/src/mailman/database/user.py @@ -0,0 +1,94 @@ +# Copyright (C) 2007-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 . + +"""Model for users.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'User', + ] + +from storm.locals import * +from zope.interface import implements + +from mailman.config import config +from mailman.database.model import Model +from mailman.database.address import Address +from mailman.database.preferences import Preferences +from mailman.database.roster import Memberships +from mailman.interfaces.address import ( + AddressAlreadyLinkedError, AddressNotLinkedError) +from mailman.interfaces.user import IUser + + + +class User(Model): + """Mailman users.""" + + implements(IUser) + + id = Int(primary=True) + real_name = Unicode() + password = Unicode() + + addresses = ReferenceSet(id, 'Address.user_id') + preferences_id = Int() + preferences = Reference(preferences_id, 'Preferences.id') + + def __repr__(self): + return ''.format(self.real_name, id(self)) + + def link(self, address): + """See `IUser`.""" + if address.user is not None: + raise AddressAlreadyLinkedError(address) + address.user = self + + def unlink(self, address): + """See `IUser`.""" + if address.user is None: + raise AddressNotLinkedError(address) + address.user = None + + def controls(self, address): + """See `IUser`.""" + found = config.db.store.find(Address, address=address) + if found.count() == 0: + return False + assert found.count() == 1, 'Unexpected count' + return found[0].user is self + + def register(self, address, real_name=None): + """See `IUser`.""" + # First, see if the address already exists + addrobj = config.db.store.find(Address, address=address).one() + if addrobj is None: + if real_name is None: + real_name = '' + addrobj = Address(address=address, real_name=real_name) + addrobj.preferences = Preferences() + # Link the address to the user if it is not already linked. + if addrobj.user is not None: + raise AddressAlreadyLinkedError(addrobj) + addrobj.user = self + return addrobj + + @property + def memberships(self): + return Memberships(self) diff --git a/src/mailman/database/usermanager.py b/src/mailman/database/usermanager.py new file mode 100644 index 000000000..3b0c8b534 --- /dev/null +++ b/src/mailman/database/usermanager.py @@ -0,0 +1,103 @@ +# Copyright (C) 2007-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 . + +"""A user manager.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'UserManager', + ] + +from zope.interface import implements + +from mailman.config import config +from mailman.database.address import Address +from mailman.database.preferences import Preferences +from mailman.database.user import User +from mailman.interfaces.address import ExistingAddressError +from mailman.interfaces.usermanager import IUserManager + + + +class UserManager(object): + implements(IUserManager) + + def create_user(self, address=None, real_name=None): + user = User() + user.real_name = ('' if real_name is None else real_name) + if address: + addrobj = Address(address, user.real_name) + addrobj.preferences = Preferences() + user.link(addrobj) + user.preferences = Preferences() + config.db.store.add(user) + return user + + def delete_user(self, user): + config.db.store.remove(user) + + @property + def users(self): + for user in config.db.store.find(User): + yield user + + def get_user(self, address): + addresses = config.db.store.find(Address, address=address.lower()) + if addresses.count() == 0: + return None + elif addresses.count() == 1: + return addresses[0].user + else: + raise AssertionError('Unexpected query count') + + def create_address(self, address, real_name=None): + addresses = config.db.store.find(Address, address=address.lower()) + if addresses.count() == 1: + found = addresses[0] + raise ExistingAddressError(found.original_address) + assert addresses.count() == 0, 'Unexpected results' + if real_name is None: + real_name = '' + # It's okay not to lower case the 'address' argument because the + # constructor will do the right thing. + address = Address(address, real_name) + address.preferences = Preferences() + config.db.store.add(address) + return address + + def delete_address(self, address): + # If there's a user controlling this address, it has to first be + # unlinked before the address can be deleted. + if address.user: + address.user.unlink(address) + config.db.store.remove(address) + + def get_address(self, address): + addresses = config.db.store.find(Address, address=address.lower()) + if addresses.count() == 0: + return None + elif addresses.count() == 1: + return addresses[0] + else: + raise AssertionError('Unexpected query count') + + @property + def addresses(self): + for address in config.db.store.find(Address): + yield address diff --git a/src/mailman/database/version.py b/src/mailman/database/version.py new file mode 100644 index 000000000..d15065395 --- /dev/null +++ b/src/mailman/database/version.py @@ -0,0 +1,40 @@ +# Copyright (C) 2007-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 . + +"""Model class for version numbers.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Version', + ] + +from storm.locals import * +from mailman.database.model import Model + + + +class Version(Model): + id = Int(primary=True) + component = Unicode() + version = Int() + + def __init__(self, component, version): + super(Version, self).__init__() + self.component = component + self.version = version diff --git a/src/mailman/docs/__init__.py b/src/mailman/docs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/mailman/docs/addresses.txt b/src/mailman/docs/addresses.txt new file mode 100644 index 000000000..9eccb2673 --- /dev/null +++ b/src/mailman/docs/addresses.txt @@ -0,0 +1,231 @@ +Email addresses +=============== + +Addresses represent a text email address, along with some meta data about +those addresses, such as their registration date, and whether and when they've +been validated. Addresses may be linked to the users that Mailman knows +about. Addresses are subscribed to mailing lists though members. + + >>> usermgr = config.db.user_manager + + +Creating addresses +------------------ + +Addresses are created directly through the user manager, which starts out with +no addresses. + + >>> sorted(address.address for address in usermgr.addresses) + [] + +Creating an unlinked email address is straightforward. + + >>> address_1 = usermgr.create_address(u'aperson@example.com') + >>> sorted(address.address for address in usermgr.addresses) + [u'aperson@example.com'] + +However, such addresses have no real name. + + >>> address_1.real_name + u'' + +You can also create an email address object with a real name. + + >>> address_2 = usermgr.create_address( + ... u'bperson@example.com', u'Ben Person') + >>> sorted(address.address for address in usermgr.addresses) + [u'aperson@example.com', u'bperson@example.com'] + >>> sorted(address.real_name for address in usermgr.addresses) + [u'', u'Ben Person'] + +The str() of the address is the RFC 2822 preferred originator format, while +the repr() carries more information. + + >>> str(address_2) + 'Ben Person ' + >>> repr(address_2) + ' [not verified] at 0x...>' + +You can assign real names to existing addresses. + + >>> address_1.real_name = u'Anne Person' + >>> sorted(address.real_name for address in usermgr.addresses) + [u'Anne Person', u'Ben Person'] + +These addresses are not linked to users, and can be seen by searching the user +manager for an associated user. + + >>> print usermgr.get_user(u'aperson@example.com') + None + >>> print usermgr.get_user(u'bperson@example.com') + None + +You can create email addresses that are linked to users by using a different +interface. + + >>> user_1 = usermgr.create_user(u'cperson@example.com', u'Claire Person') + >>> sorted(address.address for address in user_1.addresses) + [u'cperson@example.com'] + >>> sorted(address.address for address in usermgr.addresses) + [u'aperson@example.com', u'bperson@example.com', u'cperson@example.com'] + >>> sorted(address.real_name for address in usermgr.addresses) + [u'Anne Person', u'Ben Person', u'Claire Person'] + +And now you can find the associated user. + + >>> print usermgr.get_user(u'aperson@example.com') + None + >>> print usermgr.get_user(u'bperson@example.com') + None + >>> usermgr.get_user(u'cperson@example.com') + + + +Deleting addresses +------------------ + +You can remove an unlinked address from the user manager. + + >>> usermgr.delete_address(address_1) + >>> sorted(address.address for address in usermgr.addresses) + [u'bperson@example.com', u'cperson@example.com'] + >>> sorted(address.real_name for address in usermgr.addresses) + [u'Ben Person', u'Claire Person'] + +Deleting a linked address does not delete the user, but it does unlink the +address from the user. + + >>> sorted(address.address for address in user_1.addresses) + [u'cperson@example.com'] + >>> user_1.controls(u'cperson@example.com') + True + >>> address_3 = list(user_1.addresses)[0] + >>> usermgr.delete_address(address_3) + >>> sorted(address.address for address in user_1.addresses) + [] + >>> user_1.controls(u'cperson@example.com') + False + >>> sorted(address.address for address in usermgr.addresses) + [u'bperson@example.com'] + + +Registration and validation +--------------------------- + +Addresses have two dates, the date the address was registered on and the date +the address was validated on. Neither date is set by default. + + >>> address_4 = usermgr.create_address( + ... u'dperson@example.com', u'Dan Person') + >>> print address_4.registered_on + None + >>> print address_4.verified_on + None + +The registered date takes a Python datetime object. + + >>> from datetime import datetime + >>> address_4.registered_on = datetime(2007, 5, 8, 22, 54, 1) + >>> print address_4.registered_on + 2007-05-08 22:54:01 + >>> print address_4.verified_on + None + +And of course, you can also set the validation date. + + >>> address_4.verified_on = datetime(2007, 5, 13, 22, 54, 1) + >>> print address_4.registered_on + 2007-05-08 22:54:01 + >>> print address_4.verified_on + 2007-05-13 22:54:01 + + +Subscriptions +------------- + +Addresses get subscribed to mailing lists, not users. When the address is +subscribed, a role is specified. + + >>> address_5 = usermgr.create_address( + ... u'eperson@example.com', u'Elly Person') + >>> mlist = config.db.list_manager.create(u'_xtext@example.com') + >>> from mailman.interfaces.member import MemberRole + >>> address_5.subscribe(mlist, MemberRole.owner) + on + _xtext@example.com as MemberRole.owner> + >>> address_5.subscribe(mlist, MemberRole.member) + on + _xtext@example.com as MemberRole.member> + +Now Elly is both an owner and a member of the mailing list. + + >>> sorted(mlist.owners.members) + [ on + _xtext@example.com as MemberRole.owner>] + >>> sorted(mlist.moderators.members) + [] + >>> sorted(mlist.administrators.members) + [ on + _xtext@example.com as MemberRole.owner>] + >>> sorted(mlist.members.members) + [ on + _xtext@example.com as MemberRole.member>] + >>> sorted(mlist.regular_members.members) + [ on + _xtext@example.com as MemberRole.member>] + >>> sorted(mlist.digest_members.members) + [] + + +Case-preserved addresses +------------------------ + +Technically speaking, email addresses are case sensitive in the local part. +Mailman preserves the case of addresses and uses the case preserved version +when sending the user a message, but it treats addresses that are different in +case equivalently in all other situations. + + >>> address_6 = usermgr.create_address( + ... u'FPERSON@example.com', u'Frank Person') + +The str() of such an address prints the RFC 2822 preferred originator format +with the original case-preserved address. The repr() contains all the gory +details. + + >>> str(address_6) + 'Frank Person ' + >>> repr(address_6) + ' [not verified] + key: fperson@example.com at 0x...>' + +Both the case-insensitive version of the address and the original +case-preserved version are available on attributes of the IAddress object. + + >>> address_6.address + u'fperson@example.com' + >>> address_6.original_address + u'FPERSON@example.com' + +Because addresses are case-insensitive for all other purposes, you cannot +create an address that differs only in case. + + >>> usermgr.create_address(u'fperson@example.com') + Traceback (most recent call last): + ... + ExistingAddressError: FPERSON@example.com + >>> usermgr.create_address(u'fperson@EXAMPLE.COM') + Traceback (most recent call last): + ... + ExistingAddressError: FPERSON@example.com + >>> usermgr.create_address(u'FPERSON@example.com') + Traceback (most recent call last): + ... + ExistingAddressError: FPERSON@example.com + +You can get the address using either the lower cased version or case-preserved +version. In fact, searching for an address is case insensitive. + + >>> usermgr.get_address(u'fperson@example.com').address + u'fperson@example.com' + >>> usermgr.get_address(u'FPERSON@example.com').address + u'fperson@example.com' diff --git a/src/mailman/docs/archivers.txt b/src/mailman/docs/archivers.txt new file mode 100644 index 000000000..ef36a25ac --- /dev/null +++ b/src/mailman/docs/archivers.txt @@ -0,0 +1,184 @@ +Archivers +========= + +Mailman supports pluggable archivers, and it comes with several default +archivers. + + >>> from mailman.app.lifecycle import create_list + >>> mlist = create_list(u'test@example.com') + >>> msg = message_from_string("""\ + ... From: aperson@example.org + ... To: test@example.com + ... Subject: An archived message + ... Message-ID: <12345> + ... + ... Here is an archived message. + ... """) + +Archivers support an interface which provides the RFC 2369 List-Archive +header, and one that provides a 'permalink' to the specific message object in +the archive. This latter is appropriate for the message footer or for the RFC +5064 Archived-At header. + +Pipermail does not support a permalink, so that interface returns None. +Mailman defines a draft spec for how list servers and archivers can +interoperate. + + >>> archivers = {} + >>> from operator import attrgetter + >>> for archiver in sorted(config.archivers, key=attrgetter('name')): + ... print archiver.name + ... print ' ', archiver.list_url(mlist) + ... print ' ', archiver.permalink(mlist, msg) + ... archivers[archiver.name] = archiver + mail-archive + http://go.mail-archive.dev/test%40example.com + http://go.mail-archive.dev/ZaXPPxRMM9_hFZL4vTRlQlBx8pc= + mhonarc + http://lists.example.com/.../test@example.com + http://lists.example.com/.../RSZCG7IGPHFIRW3EMTVMMDNJMNCVCOLE + pipermail + http://www.example.com/pipermail/test@example.com + None + prototype + http://lists.example.com + http://lists.example.com/RSZCG7IGPHFIRW3EMTVMMDNJMNCVCOLE + + +Sending the message to the archiver +----------------------------------- + +The archiver is also able to archive the message. + + >>> archivers['pipermail'].archive_message(mlist, msg) + + >>> import os + >>> from mailman.interfaces.archiver import IPipermailMailingList + >>> pckpath = os.path.join( + ... IPipermailMailingList(mlist).archive_dir(), + ... 'pipermail.pck') + >>> os.path.exists(pckpath) + True + +Note however that the prototype archiver can't archive messages. + + >>> archivers['prototype'].archive_message(mlist, msg) + Traceback (most recent call last): + ... + NotImplementedError + + +The Mail-Archive.com +-------------------- + +The Mail-Archive is a public archiver that can +be used to archive message for free. Mailman comes with a plugin for this +archiver; by enabling it messages to public lists will get sent there +automatically. + + >>> archiver = archivers['mail-archive'] + >>> print archiver.list_url(mlist) + http://go.mail-archive.dev/test%40example.com + >>> print archiver.permalink(mlist, msg) + http://go.mail-archive.dev/ZaXPPxRMM9_hFZL4vTRlQlBx8pc= + +To archive the message, the archiver actually mails the message to a special +address at the Mail-Archive. + + >>> archiver.archive_message(mlist, msg) + + >>> from mailman.queue.outgoing import OutgoingRunner + >>> from mailman.testing.helpers import make_testable_runner + >>> outgoing = make_testable_runner(OutgoingRunner, 'out') + >>> outgoing.run() + + >>> from operator import itemgetter + >>> messages = list(smtpd.messages) + >>> len(messages) + 1 + + >>> print messages[0].as_string() + From: aperson@example.org + To: test@example.com + Subject: An archived message + Message-ID: <12345> + X-Message-ID-Hash: ZaXPPxRMM9_hFZL4vTRlQlBx8pc= + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + Content-Transfer-Encoding: 7bit + Sender: test-bounces@example.com + Errors-To: test-bounces@example.com + X-Peer: 127.0.0.1:... + X-MailFrom: test-bounces@example.com + X-RcptTo: archive@mail-archive.dev + + Here is an archived message. + _______________________________________________ + Test mailing list + test@example.com + http://lists.example.com/listinfo/test@example.com + + >>> smtpd.clear() + +However, if the mailing list is not public, the message will never be archived +at this service. + + >>> mlist.archive_private = True + >>> print archiver.list_url(mlist) + None + >>> print archiver.permalink(mlist, msg) + None + >>> archiver.archive_message(mlist, msg) + >>> list(smtpd.messages) + [] + +Additionally, this archiver can handle malformed Message-IDs. + + >>> mlist.archive_private = False + >>> del msg['message-id'] + >>> msg['Message-ID'] = '12345>' + >>> print archiver.permalink(mlist, msg) + http://go.mail-archive.dev/bXvG32YzcDEIVDaDLaUSVQekfo8= + + >>> del msg['message-id'] + >>> msg['Message-ID'] = '<12345' + >>> print archiver.permalink(mlist, msg) + http://go.mail-archive.dev/9rockPrT1Mm-jOsLWS6_hseR_OY= + + >>> del msg['message-id'] + >>> msg['Message-ID'] = '12345' + >>> print archiver.permalink(mlist, msg) + http://go.mail-archive.dev/ZaXPPxRMM9_hFZL4vTRlQlBx8pc= + + >>> del msg['message-id'] + >>> msg['Message-ID'] = ' 12345 ' + >>> print archiver.permalink(mlist, msg) + http://go.mail-archive.dev/ZaXPPxRMM9_hFZL4vTRlQlBx8pc= + + +MHonArc +------- + +The MHonArc archiver is also available. + + >>> archiver = archivers['mhonarc'] + >>> print archiver.name + mhonarc + +Messages sent to a local MHonArc instance are added to its archive via a +subprocess call. + + >>> archiver.archive_message(mlist, msg) + >>> archive_log = open(os.path.join(config.LOG_DIR, 'archiver')) + >>> try: + ... contents = archive_log.read() + ... finally: + ... archive_log.close() + >>> print 'LOG:', contents + LOG: ... /usr/bin/mhonarc -add + -dbfile /.../private/test@example.com.mbox/mhonarc.db + -outdir /.../mhonarc/test@example.com + -stderr /.../logs/mhonarc + -stdout /.../logs/mhonarc + -spammode -umask 022 + ... diff --git a/src/mailman/docs/bounces.txt b/src/mailman/docs/bounces.txt new file mode 100644 index 000000000..9e8bcd23b --- /dev/null +++ b/src/mailman/docs/bounces.txt @@ -0,0 +1,107 @@ +Bounces +======= + +An important feature of Mailman is automatic bounce process. + +XXX Many more converted tests go here. + + +Bounces, or message rejection +----------------------------- + +Mailman can also bounce messages back to the original sender. This is +essentially equivalent to rejecting the message with notification. Mailing +lists can bounce a message with an optional error message. + + >>> mlist = config.db.list_manager.create(u'_xtest@example.com') + >>> mlist.preferred_language = u'en' + +Any message can be bounced. + + >>> msg = message_from_string("""\ + ... To: _xtest@example.com + ... From: aperson@example.com + ... Subject: Something important + ... + ... I sometimes say something important. + ... """) + +Bounce a message by passing in the original message, and an optional error +message. The bounced message ends up in the virgin queue, awaiting sending +to the original messageauthor. + + >>> switchboard = config.switchboards['virgin'] + >>> from mailman.app.bounces import bounce_message + >>> bounce_message(mlist, msg) + >>> len(switchboard.files) + 1 + >>> filebase = switchboard.files[0] + >>> qmsg, qmsgdata = switchboard.dequeue(filebase) + >>> switchboard.finish(filebase) + >>> print qmsg.as_string() + Subject: Something important + From: _xtest-owner@example.com + To: aperson@example.com + MIME-Version: 1.0 + Content-Type: multipart/mixed; boundary="..." + Message-ID: ... + Date: ... + Precedence: bulk + + --... + Content-Type: text/plain; charset="us-ascii" + MIME-Version: 1.0 + Content-Transfer-Encoding: 7bit + + [No bounce details are available] + --... + Content-Type: message/rfc822 + MIME-Version: 1.0 + + To: _xtest@example.com + From: aperson@example.com + Subject: Something important + + I sometimes say something important. + + --...-- + +An error message can be given when the message is bounced, and this will be +included in the payload of the text/plain part. The error message must be +passed in as an instance of a RejectMessage exception. + + >>> from mailman.core.errors import RejectMessage + >>> error = RejectMessage("This wasn't very important after all.") + >>> bounce_message(mlist, msg, error) + >>> len(switchboard.files) + 1 + >>> filebase = switchboard.files[0] + >>> qmsg, qmsgdata = switchboard.dequeue(filebase) + >>> switchboard.finish(filebase) + >>> print qmsg.as_string() + Subject: Something important + From: _xtest-owner@example.com + To: aperson@example.com + MIME-Version: 1.0 + Content-Type: multipart/mixed; boundary="..." + Message-ID: ... + Date: ... + Precedence: bulk + + --... + Content-Type: text/plain; charset="us-ascii" + MIME-Version: 1.0 + Content-Transfer-Encoding: 7bit + + This wasn't very important after all. + --... + Content-Type: message/rfc822 + MIME-Version: 1.0 + + To: _xtest@example.com + From: aperson@example.com + Subject: Something important + + I sometimes say something important. + + --...-- diff --git a/src/mailman/docs/chains.txt b/src/mailman/docs/chains.txt new file mode 100644 index 000000000..b6e75e6e1 --- /dev/null +++ b/src/mailman/docs/chains.txt @@ -0,0 +1,345 @@ +Chains +====== + +When a new message comes into the system, Mailman uses a set of rule chains to +decide whether the message gets posted to the list, rejected, discarded, or +held for moderator approval. + +There are a number of built-in chains available that act as end-points in the +processing of messages. + + +The Discard chain +----------------- + +The Discard chain simply throws the message away. + + >>> from zope.interface.verify import verifyObject + >>> from mailman.interfaces.chain import IChain + >>> chain = config.chains['discard'] + >>> verifyObject(IChain, chain) + True + >>> print chain.name + discard + >>> print chain.description + Discard a message and stop processing. + + >>> from mailman.app.lifecycle import create_list + >>> mlist = create_list(u'_xtest@example.com') + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... To: _xtest@example.com + ... Subject: My first post + ... Message-ID: + ... + ... An important message. + ... """) + + >>> from mailman.core.chains import process + + # XXX This checks the vette log file because there is no other evidence + # that this chain has done anything. + >>> import os + >>> fp = open(os.path.join(config.LOG_DIR, 'vette')) + >>> file_pos = fp.tell() + >>> process(mlist, msg, {}, 'discard') + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: ... DISCARD: + + + +The Reject chain +---------------- + +The Reject chain bounces the message back to the original sender, and logs +this action. + + >>> chain = config.chains['reject'] + >>> verifyObject(IChain, chain) + True + >>> print chain.name + reject + >>> print chain.description + Reject/bounce a message and stop processing. + >>> file_pos = fp.tell() + >>> process(mlist, msg, {}, 'reject') + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: ... REJECT: + +The bounce message is now sitting in the Virgin queue. + + >>> virginq = config.switchboards['virgin'] + >>> len(virginq.files) + 1 + >>> qmsg, qdata = virginq.dequeue(virginq.files[0]) + >>> print qmsg.as_string() + Subject: My first post + From: _xtest-owner@example.com + To: aperson@example.com + ... + [No bounce details are available] + ... + Content-Type: message/rfc822 + MIME-Version: 1.0 + + From: aperson@example.com + To: _xtest@example.com + Subject: My first post + Message-ID: + + An important message. + + ... + + +The Hold Chain +-------------- + +The Hold chain places the message into the admin request database and +depending on the list's settings, sends a notification to both the original +sender and the list moderators. + + >>> chain = config.chains['hold'] + >>> verifyObject(IChain, chain) + True + >>> print chain.name + hold + >>> print chain.description + Hold a message and stop processing. + + >>> file_pos = fp.tell() + >>> process(mlist, msg, {}, 'hold') + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: ... HOLD: _xtest@example.com post from aperson@example.com held, + message-id=: n/a + + +There are now two messages in the Virgin queue, one to the list moderators and +one to the original author. + + >>> len(virginq.files) + 2 + >>> qfiles = [] + >>> for filebase in virginq.files: + ... qmsg, qdata = virginq.dequeue(filebase) + ... virginq.finish(filebase) + ... qfiles.append(qmsg) + >>> from operator import itemgetter + >>> qfiles.sort(key=itemgetter('to')) + +This message is addressed to the mailing list moderators. + + >>> print qfiles[0].as_string() + Subject: _xtest@example.com post from aperson@example.com requires approval + From: _xtest-owner@example.com + To: _xtest-owner@example.com + MIME-Version: 1.0 + ... + As list administrator, your authorization is requested for the + following mailing list posting: + + List: _xtest@example.com + From: aperson@example.com + Subject: My first post + Reason: XXX + + At your convenience, visit: + + http://lists.example.com/admindb/_xtest@example.com + + to approve or deny the request. + + ... + Content-Type: message/rfc822 + MIME-Version: 1.0 + + From: aperson@example.com + To: _xtest@example.com + Subject: My first post + Message-ID: + X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW + + An important message. + + ... + Content-Type: message/rfc822 + MIME-Version: 1.0 + + Content-Type: text/plain; charset="us-ascii" + MIME-Version: 1.0 + Content-Transfer-Encoding: 7bit + Subject: confirm ... + Sender: _xtest-request@example.com + From: _xtest-request@example.com + ... + + If you reply to this message, keeping the Subject: header intact, + Mailman will discard the held message. Do this if the message is + spam. If you reply to this message and include an Approved: header + with the list password in it, the message will be approved for posting + to the list. The Approved: header can also appear in the first line + of the body of the reply. + ... + +This message is addressed to the sender of the message. + + >>> print qfiles[1].as_string() + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + Content-Transfer-Encoding: 7bit + Subject: Your message to _xtest@example.com awaits moderator approval + From: _xtest-bounces@example.com + To: aperson@example.com + ... + Your mail to '_xtest@example.com' with the subject + + My first post + + Is being held until the list moderator can review it for approval. + + The reason it is being held: + + XXX + + Either the message will get posted to the list, or you will receive + notification of the moderator's decision. If you would like to cancel + this posting, please visit the following URL: + + http://lists.example.com/confirm/_xtest@example.com/... + + + +In addition, the pending database is holding the original messages, waiting +for them to be disposed of by the original author or the list moderators. The +database is essentially a dictionary, with the keys being the randomly +selected tokens included in the urls and the values being a 2-tuple where the +first item is a type code and the second item is a message id. + + >>> import re + >>> cookie = None + >>> for line in qfiles[1].get_payload().splitlines(): + ... mo = re.search('confirm/[^/]+/(?P.*)$', line) + ... if mo: + ... cookie = mo.group('cookie') + ... break + >>> assert cookie is not None, 'No confirmation token found' + >>> data = config.db.pendings.confirm(cookie) + >>> sorted(data.items()) + [(u'id', ...), (u'type', u'held message')] + +The message itself is held in the message store. + + >>> rkey, rdata = config.db.requests.get_list_requests(mlist).get_request( + ... data['id']) + >>> msg = config.db.message_store.get_message_by_id( + ... rdata['_mod_message_id']) + >>> print msg.as_string() + From: aperson@example.com + To: _xtest@example.com + Subject: My first post + Message-ID: + X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW + + An important message. + + + +The Accept chain +---------------- + +The Accept chain sends the message on the 'prep' queue, where it will be +processed and sent on to the list membership. + + >>> chain = config.chains['accept'] + >>> verifyObject(IChain, chain) + True + >>> print chain.name + accept + >>> print chain.description + Accept a message. + >>> file_pos = fp.tell() + >>> process(mlist, msg, {}, 'accept') + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: ... ACCEPT: + + >>> pipelineq = config.switchboards['pipeline'] + >>> len(pipelineq.files) + 1 + >>> qmsg, qdata = pipelineq.dequeue(pipelineq.files[0]) + >>> print qmsg.as_string() + From: aperson@example.com + To: _xtest@example.com + Subject: My first post + Message-ID: + X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW + + An important message. + + + +Run-time chains +--------------- + +We can also define chains at run time, and these chains can be mutated. +Run-time chains are made up of links where each link associates both a rule +and a 'jump'. The rule is really a rule name, which is looked up when +needed. The jump names a chain which is jumped to if the rule matches. + +There is one built-in run-time chain, called appropriately 'built-in'. This +is the default chain to use when no other input chain is defined for a mailing +list. It runs through the default rules, providing functionality similar to +the Hold handler from previous versions of Mailman. + + >>> chain = config.chains['built-in'] + >>> verifyObject(IChain, chain) + True + >>> print chain.name + built-in + >>> print chain.description + The built-in moderation chain. + +The previously created message is innocuous enough that it should pass through +all default rules. This message will end up in the pipeline queue. + + >>> file_pos = fp.tell() + >>> process(mlist, msg, {}) + >>> fp.seek(file_pos) + >>> print 'LOG:', fp.read() + LOG: ... ACCEPT: + + >>> qmsg, qdata = pipelineq.dequeue(pipelineq.files[0]) + >>> print qmsg.as_string() + From: aperson@example.com + To: _xtest@example.com + Subject: My first post + Message-ID: + X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW + X-Mailman-Rule-Misses: approved; emergency; loop; administrivia; + implicit-dest; + max-recipients; max-size; news-moderation; no-subject; + suspicious-header + + An important message. + + +In addition, the message metadata now contains lists of all rules that have +hit and all rules that have missed. + + >>> sorted(qdata['rule_hits']) + [] + >>> for rule_name in sorted(qdata['rule_misses']): + ... print rule_name + administrivia + approved + emergency + implicit-dest + loop + max-recipients + max-size + news-moderation + no-subject + suspicious-header diff --git a/src/mailman/docs/domains.txt b/src/mailman/docs/domains.txt new file mode 100644 index 000000000..b71689520 --- /dev/null +++ b/src/mailman/docs/domains.txt @@ -0,0 +1,46 @@ +Domains +======= + +Domains are how Mailman interacts with email host names and web host names. +Generally, new domains are registered in the mailman.cfg configuration file. +We simulate that here by pushing new configurations. + + >>> config.push('example.org', """ + ... [domain.example_dot_org] + ... email_host: example.org + ... base_url: https://mail.example.org + ... description: The example domain + ... contact_address: postmaster@mail.example.org + ... """) + + >>> domain = config.domains['example.org'] + >>> print domain.email_host + example.org + >>> print domain.base_url + https://mail.example.org + >>> print domain.description + The example domain + >>> print domain.contact_address + postmaster@mail.example.org + >>> print domain.url_host + mail.example.org + + +Confirmation tokens +------------------- + +Confirmation tokens can be added to either the email confirmation address... + + >>> print domain.confirm_address('xyz') + confirm-xyz@example.org + +...or the confirmation url. + + >>> print domain.confirm_url('abc') + https://mail.example.org/confirm/abc + + +Clean up +-------- + + >>> config.pop('example.org') diff --git a/src/mailman/docs/languages.txt b/src/mailman/docs/languages.txt new file mode 100644 index 000000000..775b933e8 --- /dev/null +++ b/src/mailman/docs/languages.txt @@ -0,0 +1,104 @@ +Languages +========= + +Mailman is multilingual. A language manager handles the known set of +languages at run time, as well as enabling those languages for use in a +running Mailman instance. + + >>> from zope.interface.verify import verifyObject + >>> from mailman.interfaces.languages import ILanguageManager + >>> from mailman.languages import LanguageManager + >>> mgr = LanguageManager() + >>> verifyObject(ILanguageManager, mgr) + True + +A language manager keeps track of the languages it knows about as well as the +languages which are enabled. By default, none are known or enabled. + + >>> sorted(mgr.known_codes) + [] + >>> sorted(mgr.enabled_codes) + [] + +The language manager also keeps track of information for each known language, +but you obviously can't get information for an unknown language. + + >>> mgr.get_description('en') + Traceback (most recent call last): + ... + KeyError: 'en' + >>> mgr.get_charset('en') + Traceback (most recent call last): + ... + KeyError: 'en' + + +Adding languages +---------------- + +Adding a new language requires three pieces of information, the 2-character +language code, the English description of the language, and the character set +used by the language. + + >>> mgr.add_language('en', 'English', 'us-ascii') + >>> mgr.add_language('it', 'Italian', 'iso-8859-1') + +By default, added languages are also enabled. + + >>> sorted(mgr.known_codes) + ['en', 'it'] + >>> sorted(mgr.enabled_codes) + ['en', 'it'] + +And you can get information for all known languages. + + >>> mgr.get_description('en') + 'English' + >>> mgr.get_charset('en') + 'us-ascii' + >>> mgr.get_description('it') + 'Italian' + >>> mgr.get_charset('it') + 'iso-8859-1' + +You can also add a language without enabling it. + + >>> mgr.add_language('pl', 'Polish', 'iso-8859-2', enable=False) + >>> sorted(mgr.known_codes) + ['en', 'it', 'pl'] + >>> sorted(mgr.enabled_codes) + ['en', 'it'] + +You can get language data for disabled languages. + + >>> mgr.get_description('pl') + 'Polish' + >>> mgr.get_charset('pl') + 'iso-8859-2' + +And of course you can enable a known language. + + >>> mgr.enable_language('pl') + >>> sorted(mgr.enabled_codes) + ['en', 'it', 'pl'] + +But you cannot enable languages that the manager does not know about. + + >>> mgr.enable_language('xx') + Traceback (most recent call last): + ... + KeyError: 'xx' + + +Other iterations +---------------- + +You can iterate over the descriptions (names) of all enabled languages. + + >>> sorted(mgr.enabled_names) + ['English', 'Italian', 'Polish'] + +You can ask whether a particular language code is enabled. + + >>> 'it' in mgr.enabled_codes + True diff --git a/src/mailman/docs/lifecycle.txt b/src/mailman/docs/lifecycle.txt new file mode 100644 index 000000000..c6c0c0671 --- /dev/null +++ b/src/mailman/docs/lifecycle.txt @@ -0,0 +1,136 @@ +Application level list lifecycle +-------------------------------- + +The low-level way to create and delete a mailing list is to use the +IListManager interface. This interface simply adds or removes the appropriate +database entries to record the list's creation. + +There is a higher level interface for creating and deleting mailing lists +which performs additional tasks such as: + + * validating the list's posting address (which also serves as the list's + fully qualified name); + * ensuring that the list's domain is registered; + * applying all matching styles to the new list; + * creating and assigning list owners; + * notifying watchers of list creation; + * creating ancillary artifacts (such as the list's on-disk directory) + + >>> from mailman.app.lifecycle import create_list + + +Posting address validation +-------------------------- + +If you try to use the higher-level interface to create a mailing list with a +bogus posting address, you get an exception. + + >>> create_list('not a valid address') + Traceback (most recent call last): + ... + InvalidEmailAddress: 'not a valid address' + +If the posting address is valid, but the domain has not been registered with +Mailman yet, you get an exception. + + >>> create_list('test@example.org') + Traceback (most recent call last): + ... + BadDomainSpecificationError: example.org + + +Creating a list applies its styles +---------------------------------- + +Start by registering a test style. + + >>> from zope.interface import implements + >>> from mailman.interfaces.styles import IStyle + >>> class TestStyle(object): + ... implements(IStyle) + ... name = 'test' + ... priority = 10 + ... def apply(self, mailing_list): + ... # Just does something very simple. + ... mailing_list.msg_footer = u'test footer' + ... def match(self, mailing_list, styles): + ... # Applies to any test list + ... if 'test' in mailing_list.fqdn_listname: + ... styles.append(self) + + >>> config.style_manager.register(TestStyle()) + +Using the higher level interface for creating a list, applies all matching +list styles. + + >>> mlist_1 = create_list(u'test_1@example.com') + >>> mlist_1.fqdn_listname + u'test_1@example.com' + >>> mlist_1.msg_footer + u'test footer' + + +Creating a list with owners +--------------------------- + +You can also specify a list of owner email addresses. If these addresses are +not yet known, they will be registered, and new users will be linked to them. +However the addresses are not verified. + + >>> owners = [u'aperson@example.com', u'bperson@example.com', + ... u'cperson@example.com', u'dperson@example.com'] + >>> mlist_2 = create_list(u'test_2@example.com', owners) + >>> mlist_2.fqdn_listname + u'test_2@example.com' + >>> mlist_2.msg_footer + u'test footer' + >>> sorted(addr.address for addr in mlist_2.owners.addresses) + [u'aperson@example.com', u'bperson@example.com', + u'cperson@example.com', u'dperson@example.com'] + +None of the owner addresses are verified. + + >>> any(addr.verified_on is not None for addr in mlist_2.owners.addresses) + False + +However, all addresses are linked to users. + + >>> # The owners have no names yet + >>> len(list(mlist_2.owners.users)) + 4 + +If you create a mailing list with owner addresses that are already known to +the system, they won't be created again. + + >>> usermgr = config.db.user_manager + >>> user_a = usermgr.get_user(u'aperson@example.com') + >>> user_b = usermgr.get_user(u'bperson@example.com') + >>> user_c = usermgr.get_user(u'cperson@example.com') + >>> user_d = usermgr.get_user(u'dperson@example.com') + >>> user_a.real_name = u'Anne Person' + >>> user_b.real_name = u'Bart Person' + >>> user_c.real_name = u'Caty Person' + >>> user_d.real_name = u'Dirk Person' + + >>> mlist_3 = create_list(u'test_3@example.com', owners) + >>> sorted(user.real_name for user in mlist_3.owners.users) + [u'Anne Person', u'Bart Person', u'Caty Person', u'Dirk Person'] + + +Removing a list +--------------- + +Removing a mailing list deletes the list, all its subscribers, and any related +artifacts. + + >>> from mailman.app.lifecycle import remove_list + >>> remove_list(mlist_2.fqdn_listname, mlist_2, True) + >>> print config.db.list_manager.get('test_2@example.com') + None + +We should now be able to completely recreate the mailing list. + + >>> mlist_2a = create_list(u'test_2@example.com', owners) + >>> sorted(addr.address for addr in mlist_2a.owners.addresses) + [u'aperson@example.com', u'bperson@example.com', + u'cperson@example.com', u'dperson@example.com'] diff --git a/src/mailman/docs/listmanager.txt b/src/mailman/docs/listmanager.txt new file mode 100644 index 000000000..830f6d962 --- /dev/null +++ b/src/mailman/docs/listmanager.txt @@ -0,0 +1,88 @@ +Using the IListManager interface +================================ + +The IListManager is how you create, delete, and retrieve mailing list +objects. The Mailman system instantiates an IListManager for you based on the +configuration variable MANAGERS_INIT_FUNCTION. The instance is accessible +on the global config object. + + >>> from mailman.interfaces.listmanager import IListManager + >>> listmgr = config.db.list_manager + >>> IListManager.providedBy(listmgr) + True + + +Creating a mailing list +----------------------- + +Creating the list returns the newly created IMailList object. + + >>> from mailman.interfaces.mailinglist import IMailingList + >>> mlist = listmgr.create(u'_xtest@example.com') + >>> IMailingList.providedBy(mlist) + True + +All lists with identities have a short name, a host name, and a fully +qualified listname. This latter is what uniquely distinguishes the mailing +list to the system. + + >>> mlist.list_name + u'_xtest' + >>> mlist.host_name + u'example.com' + >>> mlist.fqdn_listname + u'_xtest@example.com' + +If you try to create a mailing list with the same name as an existing list, +you will get an exception. + + >>> mlist_dup = listmgr.create(u'_xtest@example.com') + Traceback (most recent call last): + ... + ListAlreadyExistsError: _xtest@example.com + + +Deleting a mailing list +----------------------- + +Use the list manager to delete a mailing list. + + >>> listmgr.delete(mlist) + >>> sorted(listmgr.names) + [] + +After deleting the list, you can create it again. + + >>> mlist = listmgr.create(u'_xtest@example.com') + >>> mlist.fqdn_listname + u'_xtest@example.com' + + +Retrieving a mailing list +------------------------- + +When a mailing list exists, you can ask the list manager for it and you will +always get the same object back. + + >>> mlist_2 = listmgr.get(u'_xtest@example.com') + >>> mlist_2 is mlist + True + +If you try to get a list that doesn't existing yet, you get None. + + >>> print listmgr.get(u'_xtest_2@example.com') + None + + +Iterating over all mailing lists +-------------------------------- + +Once you've created a bunch of mailing lists, you can use the list manager to +iterate over either the list objects, or the list names. + + >>> mlist_3 = listmgr.create(u'_xtest_3@example.com') + >>> mlist_4 = listmgr.create(u'_xtest_4@example.com') + >>> sorted(listmgr.names) + [u'_xtest@example.com', u'_xtest_3@example.com', u'_xtest_4@example.com'] + >>> sorted(m.fqdn_listname for m in listmgr.mailing_lists) + [u'_xtest@example.com', u'_xtest_3@example.com', u'_xtest_4@example.com'] diff --git a/src/mailman/docs/membership.txt b/src/mailman/docs/membership.txt new file mode 100644 index 000000000..7f9f16738 --- /dev/null +++ b/src/mailman/docs/membership.txt @@ -0,0 +1,230 @@ +List memberships +================ + +Users represent people in Mailman. Users control email addresses, and rosters +are collectons of members. A member gives an email address a role, such as +'member', 'administrator', or 'moderator'. Roster sets are collections of +rosters and a mailing list has a single roster set that contains all its +members, regardless of that member's role. + +Mailing lists and roster sets have an indirect relationship, through the +roster set's name. Roster also have names, but are related to roster sets +by a more direct containment relationship. This is because it is possible to +store mailing list data in a different database than user data. + +When we create a mailing list, it starts out with no members... + + >>> mlist = config.db.list_manager.create(u'_xtest@example.com') + >>> mlist + + >>> sorted(member.address.address for member in mlist.members.members) + [] + >>> sorted(user.real_name for user in mlist.members.users) + [] + >>> sorted(address.address for member in mlist.members.addresses) + [] + +...no owners... + + >>> sorted(member.address.address for member in mlist.owners.members) + [] + >>> sorted(user.real_name for user in mlist.owners.users) + [] + >>> sorted(address.address for member in mlist.owners.addresses) + [] + +...no moderators... + + >>> sorted(member.address.address for member in mlist.moderators.members) + [] + >>> sorted(user.real_name for user in mlist.moderators.users) + [] + >>> sorted(address.address for member in mlist.moderators.addresses) + [] + +...and no administrators. + + >>> sorted(member.address.address + ... for member in mlist.administrators.members) + [] + >>> sorted(user.real_name for user in mlist.administrators.users) + [] + >>> sorted(address.address for member in mlist.administrators.addresses) + [] + + + +Administrators +-------------- + +A mailing list's administrators are defined as union of the list's owners and +the list's moderators. We can add new owners or moderators to this list by +assigning roles to users. First we have to create the user, because there are +no users in the user database yet. + + >>> usermgr = config.db.user_manager + >>> user_1 = usermgr.create_user(u'aperson@example.com', u'Anne Person') + >>> user_1.real_name + u'Anne Person' + >>> sorted(address.address for address in user_1.addresses) + [u'aperson@example.com'] + +We can add Anne as an owner of the mailing list, by creating a member role for +her. + + >>> from mailman.interfaces.member import MemberRole + >>> address_1 = list(user_1.addresses)[0] + >>> address_1.address + u'aperson@example.com' + >>> address_1.subscribe(mlist, MemberRole.owner) + on + _xtest@example.com as MemberRole.owner> + >>> sorted(member.address.address for member in mlist.owners.members) + [u'aperson@example.com'] + >>> sorted(user.real_name for user in mlist.owners.users) + [u'Anne Person'] + >>> sorted(address.address for address in mlist.owners.addresses) + [u'aperson@example.com'] + +Adding Anne as a list owner also makes her an administrator, but does not make +her a moderator. Nor does it make her a member of the list. + + >>> sorted(user.real_name for user in mlist.administrators.users) + [u'Anne Person'] + >>> sorted(user.real_name for user in mlist.moderators.users) + [] + >>> sorted(user.real_name for user in mlist.members.users) + [] + +We can add Ben as a moderator of the list, by creating a different member role +for him. + + >>> user_2 = usermgr.create_user(u'bperson@example.com', u'Ben Person') + >>> user_2.real_name + u'Ben Person' + >>> address_2 = list(user_2.addresses)[0] + >>> address_2.address + u'bperson@example.com' + >>> address_2.subscribe(mlist, MemberRole.moderator) + + on _xtest@example.com as MemberRole.moderator> + >>> sorted(member.address.address for member in mlist.moderators.members) + [u'bperson@example.com'] + >>> sorted(user.real_name for user in mlist.moderators.users) + [u'Ben Person'] + >>> sorted(address.address for address in mlist.moderators.addresses) + [u'bperson@example.com'] + +Now, both Anne and Ben are list administrators. + + >>> sorted(member.address.address + ... for member in mlist.administrators.members) + [u'aperson@example.com', u'bperson@example.com'] + >>> sorted(user.real_name for user in mlist.administrators.users) + [u'Anne Person', u'Ben Person'] + >>> sorted(address.address for address in mlist.administrators.addresses) + [u'aperson@example.com', u'bperson@example.com'] + + +Members +------- + +Similarly, list members are born of users being given the proper role. It's +more interesting here because these roles should have a preference which can +be used to decide whether the member is to get regular delivery or digest +delivery. Without a preference, Mailman will fall back first to the address's +preference, then the user's preference, then the list's preference. Start +without any member preference to see the system defaults. + + >>> user_3 = usermgr.create_user(u'cperson@example.com', u'Claire Person') + >>> user_3.real_name + u'Claire Person' + >>> address_3 = list(user_3.addresses)[0] + >>> address_3.address + u'cperson@example.com' + >>> address_3.subscribe(mlist, MemberRole.member) + + on _xtest@example.com as MemberRole.member> + +Claire will be a regular delivery member but not a digest member. + + >>> sorted(address.address for address in mlist.members.addresses) + [u'cperson@example.com'] + >>> sorted(address.address for address in mlist.regular_members.addresses) + [u'cperson@example.com'] + >>> sorted(address.address for address in mlist.digest_members.addresses) + [] + +It's easy to make the list administrators members of the mailing list too. + + >>> members = [] + >>> for address in mlist.administrators.addresses: + ... member = address.subscribe(mlist, MemberRole.member) + ... members.append(member) + >>> sorted(members, key=lambda m: m.address.address) + [ on + _xtest@example.com as MemberRole.member>, + on + _xtest@example.com as MemberRole.member>] + >>> sorted(address.address for address in mlist.members.addresses) + [u'aperson@example.com', u'bperson@example.com', u'cperson@example.com'] + >>> sorted(address.address for address in mlist.regular_members.addresses) + [u'aperson@example.com', u'bperson@example.com', u'cperson@example.com'] + >>> sorted(address.address for address in mlist.digest_members.addresses) + [] + + +Finding members +--------------- + +You can find the IMember object that is a member of a roster for a given text +email address by using an IRoster's .get_member() method. + + >>> mlist.owners.get_member(u'aperson@example.com') + on + _xtest@example.com as MemberRole.owner> + >>> mlist.administrators.get_member(u'aperson@example.com') + on + _xtest@example.com as MemberRole.owner> + >>> mlist.members.get_member(u'aperson@example.com') + on + _xtest@example.com as MemberRole.member> + +However, if the address is not subscribed with the appropriate role, then None +is returned. + + >>> print mlist.administrators.get_member(u'zperson@example.com') + None + >>> print mlist.moderators.get_member(u'aperson@example.com') + None + >>> print mlist.members.get_member(u'zperson@example.com') + None + + +All subscribers +--------------- + +There is also a roster containing all the subscribers of a mailing list, +regardless of their role. + + >>> def sortkey(member): + ... return (member.address.address, int(member.role)) + >>> [(member.address.address, str(member.role)) + ... for member in sorted(mlist.subscribers.members, key=sortkey)] + [(u'aperson@example.com', 'MemberRole.member'), + (u'aperson@example.com', 'MemberRole.owner'), + (u'bperson@example.com', 'MemberRole.member'), + (u'bperson@example.com', 'MemberRole.moderator'), + (u'cperson@example.com', 'MemberRole.member')] + + +Double subscriptions +-------------------- + +It is an error to subscribe someone to a list with the same role twice. + + >>> address_1.subscribe(mlist, MemberRole.owner) + Traceback (most recent call last): + ... + AlreadySubscribedError: aperson@example.com is already a MemberRole.owner + of mailing list _xtest@example.com diff --git a/src/mailman/docs/message.txt b/src/mailman/docs/message.txt new file mode 100644 index 000000000..dab9ddf0e --- /dev/null +++ b/src/mailman/docs/message.txt @@ -0,0 +1,48 @@ +Messages +======== + +Mailman has its own Message classes, derived from the standard +email.message.Message class, but providing additional useful methods. + + +User notifications +------------------ + +When Mailman needs to send a message to a user, it creates a UserNotification +instance, and then calls the .send() method on this object. This method +requires a mailing list instance. + + >>> mlist = config.db.list_manager.create(u'_xtest@example.com') + >>> mlist.preferred_language = u'en' + +The UserNotification constructor takes the recipient address, the sender +address, an optional subject, optional body text, and optional language. + + >>> from mailman.Message import UserNotification + >>> msg = UserNotification( + ... 'aperson@example.com', + ... '_xtest@example.com', + ... 'Something you need to know', + ... 'I needed to tell you this.') + >>> msg.send(mlist) + +The message will end up in the virgin queue. + + >>> switchboard = config.switchboards['virgin'] + >>> len(switchboard.files) + 1 + >>> filebase = switchboard.files[0] + >>> qmsg, qmsgdata = switchboard.dequeue(filebase) + >>> switchboard.finish(filebase) + >>> print qmsg.as_string() + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + Content-Transfer-Encoding: 7bit + Subject: Something you need to know + From: _xtest@example.com + To: aperson@example.com + Message-ID: ... + Date: ... + Precedence: bulk + + I needed to tell you this. diff --git a/src/mailman/docs/messagestore.txt b/src/mailman/docs/messagestore.txt new file mode 100644 index 000000000..6e04568c5 --- /dev/null +++ b/src/mailman/docs/messagestore.txt @@ -0,0 +1,113 @@ +The message store +================= + +The message store is a collection of messages keyed off of Message-ID and +X-Message-ID-Hash headers. Either of these values can be combined with the +message's List-Archive header to create a globally unique URI to the message +object in the internet facing interface of the message store. The +X-Message-ID-Hash is the Base32 SHA1 hash of the Message-ID. + + >>> store = config.db.message_store + +If you try to add a message to the store which is missing the Message-ID +header, you will get an exception. + + >>> msg = message_from_string("""\ + ... Subject: An important message + ... + ... This message is very important. + ... """) + >>> store.add(msg) + Traceback (most recent call last): + ... + ValueError: Exactly one Message-ID header required + +However, if the message has a Message-ID header, it can be stored. + + >>> msg['Message-ID'] = '<87myycy5eh.fsf@uwakimon.sk.tsukuba.ac.jp>' + >>> store.add(msg) + 'AGDWSNXXKCWEILKKNYTBOHRDQGOX3Y35' + >>> print msg.as_string() + Subject: An important message + Message-ID: <87myycy5eh.fsf@uwakimon.sk.tsukuba.ac.jp> + X-Message-ID-Hash: AGDWSNXXKCWEILKKNYTBOHRDQGOX3Y35 + + This message is very important. + + + +Finding messages +---------------- + +There are several ways to find a message given either the Message-ID or +X-Message-ID-Hash headers. In either case, if no matching message is found, +None is returned. + + >>> print store.get_message_by_id(u'nothing') + None + >>> print store.get_message_by_hash(u'nothing') + None + +Given an existing Message-ID, the message can be found. + + >>> message = store.get_message_by_id(msg['message-id']) + >>> print message.as_string() + Subject: An important message + Message-ID: <87myycy5eh.fsf@uwakimon.sk.tsukuba.ac.jp> + X-Message-ID-Hash: AGDWSNXXKCWEILKKNYTBOHRDQGOX3Y35 + + This message is very important. + + +Similarly, we can find messages by the X-Message-ID-Hash: + + >>> message = store.get_message_by_hash(msg['x-message-id-hash']) + >>> print message.as_string() + Subject: An important message + Message-ID: <87myycy5eh.fsf@uwakimon.sk.tsukuba.ac.jp> + X-Message-ID-Hash: AGDWSNXXKCWEILKKNYTBOHRDQGOX3Y35 + + This message is very important. + + + +Iterating over all messages +--------------------------- + +The message store provides a means to iterate over all the messages it +contains. + + >>> messages = list(store.messages) + >>> len(messages) + 1 + >>> print messages[0].as_string() + Subject: An important message + Message-ID: <87myycy5eh.fsf@uwakimon.sk.tsukuba.ac.jp> + X-Message-ID-Hash: AGDWSNXXKCWEILKKNYTBOHRDQGOX3Y35 + + This message is very important. + + + +Deleting messages from the store +-------------------------------- + +You delete a message from the storage service by providing the Message-ID for +the message you want to delete. If you try to delete a Message-ID that isn't +in the store, you get an exception. + + >>> store.delete_message(u'nothing') + Traceback (most recent call last): + ... + LookupError: nothing + +But if you delete an existing message, it really gets deleted. + + >>> message_id = message['message-id'] + >>> store.delete_message(message_id) + >>> list(store.messages) + [] + >>> print store.get_message_by_id(message_id) + None + >>> print store.get_message_by_hash(message['x-message-id-hash']) + None diff --git a/src/mailman/docs/mlist-addresses.txt b/src/mailman/docs/mlist-addresses.txt new file mode 100644 index 000000000..75ec3df37 --- /dev/null +++ b/src/mailman/docs/mlist-addresses.txt @@ -0,0 +1,76 @@ +Mailing list addresses +====================== + +Every mailing list has a number of addresses which are publicly available. +These are defined in the IMailingListAddresses interface. + + >>> mlist = config.db.list_manager.create(u'_xtest@example.com') + +The posting address is where people send messages to be posted to the mailing +list. This is exactly the same as the fully qualified list name. + + >>> mlist.fqdn_listname + u'_xtest@example.com' + >>> mlist.posting_address + u'_xtest@example.com' + +Messages to the mailing list's 'no reply' address always get discarded without +prejudice. + + >>> mlist.no_reply_address + u'noreply@example.com' + +The mailing list's owner address reaches the human moderators. + + >>> mlist.owner_address + u'_xtest-owner@example.com' + +The request address goes to the list's email command robot. + + >>> mlist.request_address + u'_xtest-request@example.com' + +The bounces address accepts and processes all potential bounces. + + >>> mlist.bounces_address + u'_xtest-bounces@example.com' + +The join (a.k.a. subscribe) address is where someone can email to get added to +the mailing list. The subscribe alias is a synonym for join, but it's +deprecated. + + >>> mlist.join_address + u'_xtest-join@example.com' + >>> mlist.subscribe_address + u'_xtest-subscribe@example.com' + +The leave (a.k.a. unsubscribe) address is where someone can email to get added +to the mailing list. The unsubscribe alias is a synonym for leave, but it's +deprecated. + + >>> mlist.leave_address + u'_xtest-leave@example.com' + >>> mlist.unsubscribe_address + u'_xtest-unsubscribe@example.com' + + +Email confirmations +------------------- + +Email confirmation messages are sent when actions such as subscriptions need +to be confirmed. It requires that a cookie be provided, which will be +included in the local part of the email address. The exact format of this is +dependent on the VERP_CONFIRM_FORMAT configuration variable. + + >>> mlist.confirm_address('cookie') + u'_xtest-confirm+cookie@example.com' + >>> mlist.confirm_address('wookie') + u'_xtest-confirm+wookie@example.com' + + >>> config.push('test config', """ + ... [mta] + ... verp_confirm_format: $address---$cookie + ... """) + >>> mlist.confirm_address('cookie') + u'_xtest-confirm---cookie@example.com' + >>> config.pop('test config') diff --git a/src/mailman/docs/pending.txt b/src/mailman/docs/pending.txt new file mode 100644 index 000000000..abfba4885 --- /dev/null +++ b/src/mailman/docs/pending.txt @@ -0,0 +1,94 @@ +The pending database +==================== + +The pending database is where various types of events which need confirmation +are stored. These can include email address registration events, held +messages (but only for user confirmation), auto-approvals, and probe bounces. +This is not where messages held for administrator approval are kept. + + >>> from zope.interface import implements + >>> from zope.interface.verify import verifyObject + +In order to pend an event, you first need a pending database, which is +available by adapting the list manager. + + >>> from mailman.interfaces.pending import IPendings + >>> pendingdb = config.db.pendings + >>> verifyObject(IPendings, pendingdb) + True + +The pending database can add any IPendable to the database, returning a token +that can be used in urls and such. + + >>> from mailman.interfaces.pending import IPendable + >>> class SimplePendable(dict): + ... implements(IPendable) + >>> subscription = SimplePendable( + ... type='subscription', + ... address='aperson@example.com', + ... realname='Anne Person', + ... language='en', + ... password='xyz') + >>> token = pendingdb.add(subscription) + >>> len(token) + 40 + +There's not much you can do with tokens except to 'confirm' them, which +basically means returning the IPendable structure (as a dict) from the +database that matches the token. If the token isn't in the database, None is +returned. + + >>> pendable = pendingdb.confirm('missing') + >>> print pendable + None + >>> pendable = pendingdb.confirm(token) + >>> sorted(pendable.items()) + [(u'address', u'aperson@example.com'), + (u'language', u'en'), + (u'password', u'xyz'), + (u'realname', u'Anne Person'), + (u'type', u'subscription')] + +After confirmation, the token is no longer in the database. + + >>> pendable = pendingdb.confirm(token) + >>> print pendable + None + +There are a few other things you can do with the pending database. When you +confirm a token, you can leave it in the database, or in otherwords, not +expunge it. + + >>> event_1 = SimplePendable(type='one') + >>> token_1 = pendingdb.add(event_1) + >>> event_2 = SimplePendable(type='two') + >>> token_2 = pendingdb.add(event_2) + >>> event_3 = SimplePendable(type='three') + >>> token_3 = pendingdb.add(event_3) + >>> pendable = pendingdb.confirm(token_1, expunge=False) + >>> pendable.items() + [(u'type', u'one')] + >>> pendable = pendingdb.confirm(token_1, expunge=True) + >>> pendable.items() + [(u'type', u'one')] + >>> pendable = pendingdb.confirm(token_1) + >>> print pendable + None + +An event can be given a lifetime when it is pended, otherwise it just uses a +default lifetime. + + >>> from datetime import timedelta + >>> yesterday = timedelta(days=-1) + >>> event_4 = SimplePendable(type='four') + >>> token_4 = pendingdb.add(event_4, lifetime=yesterday) + +Every once in a while the pending database is cleared of old records. + + >>> pendingdb.evict() + >>> pendable = pendingdb.confirm(token_4) + >>> print pendable + None + >>> pendable = pendingdb.confirm(token_2) + >>> pendable.items() + [(u'type', u'two')] diff --git a/src/mailman/docs/pipelines.txt b/src/mailman/docs/pipelines.txt new file mode 100644 index 000000000..0e6dad8e8 --- /dev/null +++ b/src/mailman/docs/pipelines.txt @@ -0,0 +1,186 @@ +Pipelines +========= + +This runner's purpose in life is to process messages that have been accepted +for posting, applying any modifications and also sending copies of the message +to the archives, digests, nntp, and outgoing queues. Pipelines are named and +consist of a sequence of handlers, each of which is applied in turn. Unlike +rules and chains, there is no way to stop a pipeline from processing the +message once it's started. + + >>> from mailman.app.lifecycle import create_list + >>> mlist = create_list(u'xtest@example.com') + >>> print mlist.pipeline + built-in + >>> from mailman.core.pipelines import process + + +Processing a message +-------------------- + +Messages hit the pipeline after they've been accepted for posting. + + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... To: xtest@example.com + ... Subject: My first post + ... Message-ID: + ... + ... First post! + ... """) + >>> msgdata = {} + >>> process(mlist, msg, msgdata, mlist.pipeline) + +The message has been modified with additional headers, footer decorations, +etc. + + >>> print msg.as_string() + From: aperson@example.com + To: xtest@example.com + Message-ID: + Subject: [Xtest] My first post + X-BeenThere: xtest@example.com + X-Mailman-Version: ... + Precedence: list + List-Id: + X-Message-ID-Hash: 4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB + List-Post: + List-Subscribe: + , + + Archived-At: + http://lists.example.com/archives/4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB + List-Unsubscribe: + , + + List-Archive: + List-Help: + + First post! + + +And the message metadata has information about recipients and other stuff. +However there are currently no recipients for this message. + + >>> dump_msgdata(msgdata) + original_sender : aperson@example.com + origsubj : My first post + recips : set([]) + stripped_subject: My first post + +And the message is now sitting in various other processing queues. + + >>> from mailman.testing.helpers import get_queue_messages + >>> messages = get_queue_messages('archive') + >>> len(messages) + 1 + >>> print messages[0].msg.as_string() + From: aperson@example.com + To: xtest@example.com + Message-ID: + Subject: [Xtest] My first post + X-BeenThere: xtest@example.com + X-Mailman-Version: ... + Precedence: list + List-Id: + X-Message-ID-Hash: 4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB + List-Post: + List-Subscribe: + , + + Archived-At: + http://lists.example.com/archives/4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB + List-Unsubscribe: + , + + List-Archive: + List-Help: + + First post! + + >>> dump_msgdata(messages[0].msgdata) + _parsemsg : False + original_sender : aperson@example.com + origsubj : My first post + recips : set([]) + stripped_subject: My first post + version : 3 + +This mailing list is not linked to an NNTP newsgroup, so there's nothing in +the outgoing nntp queue. + + >>> messages = get_queue_messages('news') + >>> len(messages) + 0 + +This is the message that will actually get delivered to end recipients. + + >>> messages = get_queue_messages('out') + >>> len(messages) + 1 + >>> print messages[0].msg.as_string() + From: aperson@example.com + To: xtest@example.com + Message-ID: + Subject: [Xtest] My first post + X-BeenThere: xtest@example.com + X-Mailman-Version: ... + Precedence: list + List-Id: + X-Message-ID-Hash: 4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB + List-Post: + List-Subscribe: + , + + Archived-At: + http://lists.example.com/archives/4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB + List-Unsubscribe: + , + + List-Archive: + List-Help: + + First post! + + >>> dump_msgdata(messages[0].msgdata) + _parsemsg : False + listname : xtest@example.com + original_sender : aperson@example.com + origsubj : My first post + recips : set([]) + stripped_subject: My first post + version : 3 + +There's now one message in the digest mailbox, getting ready to be sent. + + >>> from mailman.testing.helpers import digest_mbox + >>> digest = digest_mbox(mlist) + >>> sum(1 for mboxmsg in digest) + 1 + >>> print list(digest)[0].as_string() + From: aperson@example.com + To: xtest@example.com + Message-ID: + Subject: [Xtest] My first post + X-BeenThere: xtest@example.com + X-Mailman-Version: ... + Precedence: list + List-Id: + X-Message-ID-Hash: 4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB + List-Post: + List-Subscribe: + , + + Archived-At: + http://lists.example.com/archives/4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB + List-Unsubscribe: + , + + List-Archive: + List-Help: + + First post! + + + + >>> digest.clear() diff --git a/src/mailman/docs/registration.txt b/src/mailman/docs/registration.txt new file mode 100644 index 000000000..d243188bc --- /dev/null +++ b/src/mailman/docs/registration.txt @@ -0,0 +1,362 @@ +Address registration +==================== + +When a user wants to join a mailing list -- any mailing list -- in the running +instance, he or she must first register with Mailman. The only thing they +must supply is an email address, although there is additional information they +may supply. All registered email addresses must be verified before Mailman +will send them any list traffic. + + >>> from mailman.app.registrar import Registrar + >>> from mailman.interfaces.registrar import IRegistrar + +The IUserManager manages users, but it does so at a fairly low level. +Specifically, it does not handle verifications, email address syntax validity +checks, etc. The IRegistrar is the interface to the object handling all this +stuff. + +Add a domain, which will provide the context for the verification email +message. + + >>> config.push('mail', """ + ... [domain.mail_example_dot_com] + ... email_host: mail.example.com + ... base_url: http://mail.example.com + ... contact_address: postmaster@mail.example.com + ... """) + + >>> domain = config.domains['mail.example.com'] + +Get a registrar by adapting a context to the interface. + + >>> from zope.interface.verify import verifyObject + >>> registrar = IRegistrar(domain) + >>> verifyObject(IRegistrar, registrar) + True + +Here is a helper function to check the token strings. + + >>> def check_token(token): + ... assert isinstance(token, basestring), 'Not a string' + ... assert len(token) == 40, 'Unexpected length: %d' % len(token) + ... assert token.isalnum(), 'Not alphanumeric' + ... print 'ok' + +Here is a helper function to extract tokens from confirmation messages. + + >>> import re + >>> cre = re.compile('http://mail.example.com/confirm/(.*)') + >>> def extract_token(msg): + ... mo = cre.search(qmsg.get_payload()) + ... return mo.group(1) + + +Invalid email addresses +----------------------- + +The only piece of information you need to register is the email address. +Some amount of sanity checks are performed on the email address, although +honestly, not as much as probably should be done. Still, some patently bad +addresses are rejected outright. + + >>> registrar.register('') + Traceback (most recent call last): + ... + InvalidEmailAddress: '' + >>> registrar.register('some name@example.com') + Traceback (most recent call last): + ... + InvalidEmailAddress: 'some name@example.com' + >>> registrar.register(' 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 . + +"""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 more than one + language 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 + default + language must be included.''')), + + ('encode_ascii_prefixes', config.Radio, + (_('Never'), _('Always'), _('As needed')), 0, + _("""Encode the + subject + prefix 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 Never 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. + +

      Note however, that if your mailing list receives both encoded + and unencoded subject headers, you might want to choose As + needed. 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 . + +"""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 . + +"""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 read the details + 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. + +

      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. + +

      Select No to disable personalization and send + messages to the members in batches. Select Yes to + personalize deliveries and allow additional substitution + variables in message headers and footers (see below). In + addition, by selecting Full Personalization, the + To header of posted messages will be modified to + include the member's address instead of the list's posting + address. + +

      When personalization is enabled, a few more expansion + variables that can be included in the message header and + message footer. + +

      These additional substitution variables will be available + for your headers and footers, when this feature is enabled: + +

      • user_address - The address of the user, + coerced to lower case. +
      • user_delivered_to - The case-preserved address + that the user is subscribed with. +
      • user_password - The user's password. +
      • user_name - The user's full name. +
      • user_optionsurl - The url to the user's option + page. +
      + """)) + ]) + # 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 personalization is enabled +for this list, additional substitution variables are allowed in your headers +and footers: + +
      • user_address - The address of the user, + coerced to lower case. +
      • user_delivered_to - The case-preserved address + that the user is subscribed with. +
      • user_password - The user's password. +
      • user_name - The user's full name. +
      • user_optionsurl - The url to the user's option + page. +
      +""") + 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 . + +"""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 . + +"""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?
      '), + _('''None - no verification steps (Not + Recommended )
      + Confirm (*) - email confirmation step required
      + Require approval - require list administrator + Approval for subscriptions
      + Confirm and approve - both confirm and approve + +

      (*) 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.
      + + 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?
      '), + _('''Confirm (*) - email confirmation required
      + Require approval - require list administrator + approval for subscriptions
      + Confirm and approve - both confirm and approve + +

      (*) 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.
      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 + Archival Options 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? (No 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!). + +

      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. + +

      Member postings are held for moderation if their + moderation flag is turned on. You can control whether + member postings are moderated by default or not. + +

      Non-member postings can be automatically + accepted, + held for + moderation, + rejected (bounced), or + discarded, + 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 + general + non-member rules. + +

      In the text boxes below, add one address per line; start the + line with a ^ character to designate a Python regular expression. When entering backslashes, do so + as if you were using Python raw strings (i.e. you generally just + use a single backslash). + +

      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 moderation flag 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. + +

      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 + membership management + screens.""")), + + ('member_moderation_action', config.Radio, + (_('Hold'), _('Reject'), _('Discard')), 0, + _("""Action to take when a moderated member posts to the + list."""), + _("""

      • Hold -- this holds the message for approval + by the list moderators. + +

      • Reject -- this automatically rejects the message by + sending a bounce notice to the post's author. The text of the + bounce notice can be configured by you. + +

      • Discard -- this simply discards the message, with + no notice sent to the post's author. +
      """)), + + ('member_moderation_notice', config.Text, (10, WIDTH), 1, + _("""Text to include in any + rejection notice 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 + automatically discarded. + +

      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 receive copies of auto-discarded messages.. + +

      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 + accepted, + held, + rejected (bounced), and + discarded 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. + +

      The cost is that the list will not accept unhindered any + postings relayed from other addresses, unless + +

        +
      1. The relaying address has the same name, or + +
      2. The relaying address name is included on the options that + specifies acceptable aliases for the list. + +
      """)), + + ('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. + +

      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. + +

      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 Defer 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. + +

      For example:

      to: .*@public.com 
      says to hold all + postings with a To: mail header containing '@public.com' + anywhere among the addresses. + +

      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 . + +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 regular + expression filters you specify below. If the message's + Subject: or Keywords: header contains a + match against a topic filter, the message is logically placed + into a topic bucket. 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. + +

      Note that this feature only works with regular delivery, not + digest delivery. + +

      The body of the message can also be optionally scanned for + Subject: and Keywords: headers, as + specified by the topics_bodylines_limit + 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 Keywords: and + Subject: 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 + Keywords: and Subject: message headers. + Note that the first few lines of the body of the message can also + contain a Keywords: and Subject: + "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 . + +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 to, so if you are only gatewaying from + Usenet, or the newsgroup you are gatewaying to is not moderated, + set this option to None. + +

      If the newsgroup is moderated, you can set this mailing list + up to be the moderation address for the newsgroup. By selecting + Moderated, 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. + +

      Note that if the message has an Approved 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. + +

      Finally, if the newsgroup is moderated, but you want to have + an open posting policy anyway, you should select Open list, + moderated group. The effect of this is to use the normal + Mailman moderation facilities, but to add an Approved + header to all messages that are gatewayed to Usenet.""")), + + ('news_prefix_subject_too', config.Toggle, (_('No'), _('Yes')), 0, + _('Prefix Subject: headers on postings gated to news?'), + _("""Mailman prefixes Subject: headers with + text you can + customize and normally, this prefix shows up in messages + gatewayed to Usenet. You can set this option to No to + disable the prefix on gated messages. Of course, if you turn off + normal Subject: prefixes, they won't be prefixed for + gated messages either.""")), + + _('Mass catch up'), + + ('_mass_catchup', config.Toggle, (_('No'), _('Yes')), 0, + _('Should Mailman perform a catchup 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 + read. 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 + news server field and + the linked + newsgroup 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 . + +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 . + +"""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 = '
      ' +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( + '


      ', + Address( + Container( + innertext, + '
      ', + Link(self.GetScriptURL('admin'), + _('%(realname)s administrative interface')), + _(' (requires authorization)'), + '
      ', + Link(Utils.ScriptURL('listinfo'), + _('Overview of all %(hostname)s mailing lists')), + '

      ', 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 = _('(1 private member not shown)') + elif num_concealed > 1: + concealed = _( + '(%(num_concealed)d private members not shown)') + 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 '' % ( + name, value, checked) + + def FormatDigestButton(self): + if self.digest_is_default: + checked = ' CHECKED' + else: + checked = '' + return '' % 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 _('''

      %(note)s + +

      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 _('''

      We have received some recent bounces from your + address. Your current bounce score 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.)

      ") + 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 += _("""

      (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 '' % checked + + def FormatMimeDigestsButton(self): + if self.mime_is_default_digest: + checked = ' CHECKED' + else: + checked = '' + return '' % checked + + def FormatPlainDigestsButton(self): + if self.mime_is_default_digest: + checked = '' + else: + checked = ' CHECKED' + return '' % checked + + def FormatEditingOption(self, lang): + if self.private_roster == 0: + either = _('either ') + 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: +

      ''') + + TextBox('email', size=30).Format() + + ' ' + + SubmitButton('UserOptions', + _('Unsubscribe or edit options')).Format() + + Hidden('language', lang).Format() + + '
      ') + if self.private_roster == 0: + text += _('''

      ... or 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 _( + '''(%(which)s is only available to the list + members.)''') + else: + return _('''(%(which)s is only available to the list + administrator.)''') + + 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) + + _("

      Enter your ") + + whom[:-1].lower() + + _(" and password to visit" + " the subscribers list:

      ") + + whom + + " ") + container.AddItem(self.FormatBox('roster-email')) + container.AddItem(_("Password: ") + + self.FormatSecureBox('roster-pw') + + "  ") + container.AddItem(SubmitButton('SubscriberRoster', + _('Visit Subscriber List'))) + container.AddItem("
      ") + 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 ('
      ' % full_url) + + def FormatArchiveAnchor(self): + return '' % self.GetBaseArchiveURL() + + def FormatFormEnd(self): + return '' + + def FormatBox(self, name, size=20, value=''): + return '' % ( + name, size, value) + + def FormatSecureBox(self, name): + return '' % name + + def FormatButton(self, name, text='Submit'): + return '' % (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('(]*>)', 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 = { + '' : self.GetMailmanFooter(), + '' : self.real_name, + '' : self._internal_name, + '' : self.description, + '' : BR.join(self.info.split(NL)), + '' : self.FormatFormEnd(), + '' : self.FormatArchiveAnchor(), + '' : '
      ', + '' : self.FormatSubscriptionMsg(), + '' : \ + self.RestrictedListMessage(_('The current archive'), + self.archive_private), + '' : `member_len`, + '' : `dmember_len`, + '' : (`member_len + dmember_len`), + '' : '%s' % self.GetListEmail(), + '' : '%s' % self.GetRequestEmail(), + '' : self.GetOwnerEmail(), + '' : self.FormatReminder(self.preferred_language), + '' : self.host_name, + '' : listlangs, + } + if config.IMAGE_LOGOS: + d[''] = 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({"": self.FormatUsers(0, lang), + "": 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 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 . + +"""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 + '' + + for i in range(len(self.cells[row])): + output = output + self.FormatCell(row, i, indent + 2) + + output = output + '\n' + ' '*indent + '' + + return output + + def Format(self, indent=0): + output = '\n' + ' '*indent + '' + + for i in range(len(self.cells)): + output = output + self.FormatRow(i, indent + 2) + + output = output + '\n' + ' '*indent + '\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 '%s' % (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 = '' % self.size + for item in self.items: + output = output + HTMLFormatObject(item, indent) + output = output + '' + 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 = '' % SPACE.join(seq) + for item in self.items: + output = output + HTMLFormatObject(item, indent) + output = output + '' + 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 ('
      ' % self.align) + \ + Container.Format(self, indent) + \ + '
      ' + + +# 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, + '', + '' + ]) + if config.IMAGE_LOGOS: + output.append('' % + (config.IMAGE_LOGOS + config.SHORTCUT_ICON)) + # Hit all the bases + output.append('' % charset) + if self.title: + output.append('%s%s' % (tab, self.title)) + output.append('%s' % 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' % (tab, SPACE.join(quals))) + # Always do this... + output.append(Container.Format(self, indent)) + if not self.suppress_head: + output.append('%s' % tab) + output.append('%s' % 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' % (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' % ( + 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
      \n' % ( + spaces, self.action, self.method, encoding) + output = output + Container.Format(self, indent+2) + output = '%s\n%s
      \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 = ['') + 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 = '