From b132a73f15e432eaf43310fce9196ca0c0651465 Mon Sep 17 00:00:00 2001 From: <> Date: Thu, 2 Jan 2003 05:25:50 +0000 Subject: This commit was manufactured by cvs2svn to create branch 'Release_2_1-maint'. --- .cvsignore | 7 + ACKNOWLEDGMENTS | 176 + BUGS | 24 + FAQ | 394 + INSTALL | 576 ++ Mailman/.cvsignore | 3 + Mailman/Archiver/.cvsignore | 1 + Mailman/Archiver/Archiver.py | 232 + Mailman/Archiver/HyperArch.py | 1224 +++ Mailman/Archiver/HyperDatabase.py | 338 + Mailman/Archiver/Makefile.in | 72 + Mailman/Archiver/__init__.py | 17 + Mailman/Archiver/pipermail.py | 854 ++ Mailman/Autoresponder.py | 43 + Mailman/Bouncer.py | 281 + Mailman/Bouncers/.cvsignore | 1 + Mailman/Bouncers/BouncerAPI.py | 71 + Mailman/Bouncers/Caiwireless.py | 45 + Mailman/Bouncers/Compuserve.py | 45 + Mailman/Bouncers/DSN.py | 79 + Mailman/Bouncers/Exchange.py | 47 + Mailman/Bouncers/Exim.py | 30 + Mailman/Bouncers/GroupWise.py | 70 + Mailman/Bouncers/LLNL.py | 31 + Mailman/Bouncers/Makefile.in | 74 + Mailman/Bouncers/Microsoft.py | 48 + Mailman/Bouncers/Netscape.py | 88 + Mailman/Bouncers/Postfix.py | 86 + Mailman/Bouncers/Qmail.py | 61 + Mailman/Bouncers/SMTP32.py | 57 + Mailman/Bouncers/SimpleMatch.py | 100 + Mailman/Bouncers/SimpleWarning.py | 44 + Mailman/Bouncers/Sina.py | 47 + Mailman/Bouncers/Yahoo.py | 53 + Mailman/Bouncers/Yale.py | 79 + Mailman/Bouncers/__init__.py | 15 + Mailman/Cgi/.cvsignore | 1 + Mailman/Cgi/Auth.py | 59 + Mailman/Cgi/Makefile.in | 71 + Mailman/Cgi/__init__.py | 15 + Mailman/Cgi/admin.py | 1407 +++ Mailman/Cgi/admindb.py | 769 ++ Mailman/Cgi/confirm.py | 791 ++ Mailman/Cgi/create.py | 410 + Mailman/Cgi/edithtml.py | 170 + Mailman/Cgi/listinfo.py | 206 + Mailman/Cgi/options.py | 950 ++ Mailman/Cgi/private.py | 162 + Mailman/Cgi/rmlist.py | 242 + Mailman/Cgi/roster.py | 129 + Mailman/Cgi/subscribe.py | 276 + Mailman/Commands/.cvsignore | 1 + Mailman/Commands/Makefile.in | 69 + Mailman/Commands/__init__.py | 15 + Mailman/Commands/cmd_confirm.py | 84 + Mailman/Commands/cmd_echo.py | 26 + Mailman/Commands/cmd_end.py | 33 + Mailman/Commands/cmd_help.py | 92 + Mailman/Commands/cmd_info.py | 49 + Mailman/Commands/cmd_join.py | 20 + Mailman/Commands/cmd_leave.py | 20 + Mailman/Commands/cmd_lists.py | 69 + Mailman/Commands/cmd_password.py | 118 + Mailman/Commands/cmd_remove.py | 20 + Mailman/Commands/cmd_set.py | 353 + Mailman/Commands/cmd_stop.py | 20 + Mailman/Commands/cmd_subscribe.py | 136 + Mailman/Commands/cmd_unsubscribe.py | 87 + Mailman/Commands/cmd_who.py | 133 + Mailman/Defaults.py.in | 1224 +++ Mailman/Deliverer.py | 136 + Mailman/Digester.py | 73 + Mailman/Errors.py | 147 + Mailman/GatewayManager.py | 38 + Mailman/Gui/.cvsignore | 1 + Mailman/Gui/Archive.py | 44 + Mailman/Gui/Autoresponse.py | 98 + Mailman/Gui/Bounce.py | 183 + Mailman/Gui/ContentFilter.py | 169 + Mailman/Gui/Digest.py | 160 + Mailman/Gui/GUIBase.py | 200 + Mailman/Gui/General.py | 446 + Mailman/Gui/Language.py | 122 + Mailman/Gui/Makefile.in | 69 + Mailman/Gui/Membership.py | 34 + Mailman/Gui/NonDigest.py | 130 + Mailman/Gui/Passwords.py | 31 + Mailman/Gui/Privacy.py | 398 + Mailman/Gui/Topics.py | 160 + Mailman/Gui/Usenet.py | 137 + Mailman/Gui/__init__.py | 32 + Mailman/HTMLFormatter.py | 433 + Mailman/Handlers/.cvsignore | 1 + Mailman/Handlers/Acknowledge.py | 62 + Mailman/Handlers/AfterDelivery.py | 28 + Mailman/Handlers/Approve.py | 82 + Mailman/Handlers/AvoidDuplicates.py | 88 + Mailman/Handlers/CalcRecips.py | 133 + Mailman/Handlers/Cleanse.py | 39 + Mailman/Handlers/CookHeaders.py | 254 + Mailman/Handlers/Decorate.py | 183 + Mailman/Handlers/Emergency.py | 37 + Mailman/Handlers/FileRecips.py | 49 + Mailman/Handlers/Hold.py | 280 + Mailman/Handlers/Makefile.in | 69 + Mailman/Handlers/MimeDel.py | 220 + Mailman/Handlers/Moderate.py | 164 + Mailman/Handlers/OwnerRecips.py | 27 + Mailman/Handlers/Replybot.py | 120 + Mailman/Handlers/SMTPDirect.py | 349 + Mailman/Handlers/Scrubber.py | 400 + Mailman/Handlers/Sendmail.py | 116 + Mailman/Handlers/SpamDetect.py | 50 + Mailman/Handlers/Tagger.py | 156 + Mailman/Handlers/ToArchive.py | 39 + Mailman/Handlers/ToDigest.py | 351 + Mailman/Handlers/ToOutgoing.py | 55 + Mailman/Handlers/ToUsenet.py | 44 + Mailman/Handlers/__init__.py | 15 + Mailman/ListAdmin.py | 579 ++ Mailman/LockFile.py | 596 ++ Mailman/Logging/.cvsignore | 1 + Mailman/Logging/Logger.py | 103 + Mailman/Logging/Makefile.in | 69 + Mailman/Logging/MultiLogger.py | 76 + Mailman/Logging/StampedLogger.py | 89 + Mailman/Logging/Syslog.py | 69 + Mailman/Logging/Utils.py | 52 + Mailman/Logging/__init__.py | 15 + Mailman/MTA/.cvsignore | 1 + Mailman/MTA/Makefile.in | 69 + Mailman/MTA/Manual.py | 135 + Mailman/MTA/Postfix.py | 344 + Mailman/MTA/Utils.py | 79 + Mailman/MTA/__init__.py | 15 + Mailman/MailList.py | 1346 +++ Mailman/Mailbox.py | 101 + Mailman/Makefile.in | 99 + Mailman/MemberAdaptor.py | 350 + Mailman/Message.py | 274 + Mailman/OldStyleMemberships.py | 353 + Mailman/Pending.py | 204 + Mailman/Post.py | 61 + Mailman/Queue/.cvsignore | 1 + Mailman/Queue/ArchRunner.py | 76 + Mailman/Queue/BounceRunner.py | 195 + Mailman/Queue/CommandRunner.py | 220 + Mailman/Queue/IncomingRunner.py | 170 + Mailman/Queue/MaildirRunner.py | 184 + Mailman/Queue/Makefile.in | 69 + Mailman/Queue/NewsRunner.py | 158 + Mailman/Queue/OutgoingRunner.py | 139 + Mailman/Queue/Runner.py | 245 + Mailman/Queue/Switchboard.py | 340 + Mailman/Queue/VirginRunner.py | 43 + Mailman/Queue/__init__.py | 15 + Mailman/Queue/sbcache.py | 26 + Mailman/SafeDict.py | 70 + Mailman/SecurityManager.py | 333 + Mailman/Site.py | 107 + Mailman/TopicMgr.py | 61 + Mailman/UserDesc.py | 57 + Mailman/Utils.py | 773 ++ Mailman/Version.py | 48 + Mailman/__init__.py | 15 + Mailman/htmlformat.py | 678 ++ Mailman/i18n.py | 129 + Mailman/mm_cfg.py.dist.in | 44 + Mailman/versions.py | 495 + Makefile.in | 147 + NEWS | 2096 +++++ README | 247 + README-I18N.en | 166 + README.BSD | 27 + README.CONTRIB | 17 + README.EXIM | 353 + README.LINUX | 49 + README.MACOSX | 31 + README.NETSCAPE | 57 + README.POSTFIX | 198 + README.QMAIL | 133 + README.SENDMAIL | 73 + README.USERAGENT | 49 + TODO | 174 + UPGRADING | 373 + admin/bin/Release.py | 229 + admin/bin/faq2ht.py | 102 + admin/bin/mm2do | 76 + admin/www/MMGenerator.py | 110 + admin/www/Makefile | 28 + admin/www/admins.ht | 8 + admin/www/admins.html | 134 + admin/www/bugs.ht | 25 + admin/www/bugs.html | 166 + admin/www/devs.ht | 93 + admin/www/devs.html | 212 + admin/www/doco-links.h | 7 + admin/www/docs.ht | 66 + admin/www/docs.html | 200 + admin/www/download-links.h | 6 + admin/www/download.ht | 38 + admin/www/download.html | 161 + admin/www/faq.ht | 301 + admin/www/faq.html | 433 + admin/www/features.ht | 80 + admin/www/features.html | 217 + admin/www/help.ht | 24 + admin/www/help.html | 165 + admin/www/i18n.ht | 167 + admin/www/i18n.html | 293 + admin/www/images/PythonPoweredSmall.png | Bin 0 -> 415 bytes admin/www/images/dragonlogo.jpg | Bin 0 -> 6150 bytes admin/www/images/logo-70.jpg | Bin 0 -> 3734 bytes admin/www/images/logo-lg.jpg | Bin 0 -> 6150 bytes admin/www/images/logo-sm.jpg | Bin 0 -> 2022 bytes admin/www/images/mailman.jpg | Bin 0 -> 28962 bytes admin/www/index.ht | 59 + admin/www/index.html | 204 + admin/www/install-links.h | 11 + admin/www/install.ht | 17 + admin/www/install.html | 140 + admin/www/inthenews.ht | 504 ++ admin/www/inthenews.html | 637 ++ admin/www/links.h | 9 + admin/www/lists.ht | 52 + admin/www/lists.html | 185 + admin/www/mailman.html | 146 + admin/www/mgrs.ht | 8 + admin/www/mgrs.html | 159 + admin/www/mirrors.ht | 12 + admin/www/mirrors.html | 145 + admin/www/otherstuff.ht | 23 + admin/www/otherstuff.html | 156 + admin/www/prev.ht | 20 + admin/www/prev.html | 153 + admin/www/requirements.ht | 56 + admin/www/requirements.html | 196 + admin/www/site.ht | 240 + admin/www/site.html | 366 + admin/www/todo.ht | 190 + admin/www/todo.html | 323 + admin/www/users.ht | 8 + admin/www/users.html | 134 + admin/www/version.ht | 12 + admin/www/version.html | 135 + bin/.cvsignore | 2 + bin/Makefile.in | 78 + bin/add_members | 297 + bin/arch | 187 + bin/b4b5-archfix | 96 + bin/change_pw | 209 + bin/check_db | 153 + bin/check_perms | 362 + bin/cleanarch | 165 + bin/clone_member | 219 + bin/config_list | 339 + bin/convert.py | 44 + bin/dumpdb | 134 + bin/find_member | 184 + bin/fix_url.py | 92 + bin/genaliases | 102 + bin/inject | 107 + bin/list_admins | 101 + bin/list_lists | 122 + bin/list_members | 232 + bin/list_owners | 120 + bin/mailmanctl | 524 ++ bin/mmsitepass | 105 + bin/newlist | 219 + bin/pygettext.py | 545 ++ bin/qrunner | 270 + bin/remove_members | 179 + bin/rmlist | 138 + bin/sync_members | 286 + bin/transcheck | 405 + bin/unshunt | 87 + bin/update | 588 ++ bin/version | 26 + bin/withlist | 275 + configure | 2415 +++++ configure.in | 645 ++ contrib/README | 4 + contrib/README.check_perms_grsecurity | 14 + contrib/auto | 116 + contrib/check_perms_grsecurity.py | 180 + contrib/mailman.mc | 143 + contrib/majordomo2mailman.pl | 691 ++ contrib/mm-handler | 236 + contrib/mm-handler.readme | 151 + contrib/qmail-to-mailman.py | 115 + contrib/rotatelogs.py | 103 + contrib/virtusertable | 37 + cron/.cvsignore | 2 + cron/Makefile.in | 75 + cron/bumpdigests | 96 + cron/checkdbs | 136 + cron/crontab.in.in | 24 + cron/disabled | 209 + cron/gate_news | 274 + cron/mailpasswds | 216 + cron/nightly_gzip | 156 + cron/senddigests | 94 + doc/IPC7/README | 8 + doc/IPC7/ipc7.doc.gz | Bin 0 -> 87676 bytes doc/IPC7/ipc7.ppt.gz | Bin 0 -> 147911 bytes doc/LISA-98/README | 13 + doc/LISA-98/published.ps.gz | Bin 0 -> 351185 bytes doc/mailman-admin.tex | 1352 +++ doc/posting-flow-chart.ps | 735 ++ gnu-COPYING-GPL | 339 + install-sh | 250 + messages/.cvsignore | 2 + messages/Makefile.in | 144 + messages/big5/LC_MESSAGES/mailman.mo | Bin 0 -> 13729 bytes messages/big5/LC_MESSAGES/mailman.po | 8621 ++++++++++++++++++ messages/cs/LC_MESSAGES/mailman.mo | Bin 0 -> 221613 bytes messages/cs/LC_MESSAGES/mailman.po | 10238 +++++++++++++++++++++ messages/de/LC_MESSAGES/mailman.mo | Bin 0 -> 290997 bytes messages/de/LC_MESSAGES/mailman.po | 12706 ++++++++++++++++++++++++++ messages/de/README.de | 23 + messages/es/LC_MESSAGES/mailman.mo | Bin 0 -> 325790 bytes messages/es/LC_MESSAGES/mailman.po | 11837 ++++++++++++++++++++++++ messages/es/README.es | 82 + messages/et/LC_MESSAGES/mailman.mo | Bin 0 -> 105537 bytes messages/et/LC_MESSAGES/mailman.po | 8701 ++++++++++++++++++ messages/fi/LC_MESSAGES/mailman.mo | Bin 0 -> 158955 bytes messages/fi/LC_MESSAGES/mailman.po | 9765 ++++++++++++++++++++ messages/fi/README.fi | 13 + messages/fr/LC_MESSAGES/mailman.mo | Bin 0 -> 330226 bytes messages/fr/LC_MESSAGES/mailman.po | 11371 +++++++++++++++++++++++ messages/fr/README.fr | 21 + messages/hu/FAQ.hu | 464 + messages/hu/INSTALL.hu | 597 ++ messages/hu/LC_MESSAGES/mailman.mo | Bin 0 -> 319335 bytes messages/hu/LC_MESSAGES/mailman.po | 11063 +++++++++++++++++++++++ messages/hu/README.CONTRIB.hu | 17 + messages/hu/README.EXIM.hu | 365 + messages/hu/README.LINUX.hu | 59 + messages/hu/README.MACOSX.hu | 35 + messages/hu/README.NETSCAPE.hu | 57 + messages/hu/README.POSTFIX.hu | 212 + messages/hu/README.SENDMAIL.hu | 76 + messages/hu/README.USERAGENT.hu | 49 + messages/hu/README.hu | 267 + messages/hu/UPGRADING.hu | 245 + messages/it/LC_MESSAGES/mailman.mo | Bin 0 -> 340894 bytes messages/it/LC_MESSAGES/mailman.po | 11800 ++++++++++++++++++++++++ messages/it/README.it | 30 + messages/ja/INSTALL | 576 ++ messages/ja/LC_MESSAGES/mailman.mo | Bin 0 -> 300672 bytes messages/ja/LC_MESSAGES/mailman.po | 10724 ++++++++++++++++++++++ messages/ja/README | 212 + messages/ja/README.ja | 78 + messages/ja/UPGRADING | 195 + messages/ko/LC_MESSAGES/mailman.mo | Bin 0 -> 148433 bytes messages/ko/LC_MESSAGES/mailman.po | 9528 ++++++++++++++++++++ messages/ko/README.ko | 26 + messages/lt/LC_MESSAGES/mailman.mo | Bin 0 -> 38344 bytes messages/lt/LC_MESSAGES/mailman.po | 8096 +++++++++++++++++ messages/mailman.pot | 7861 ++++++++++++++++ messages/nl/LC_MESSAGES/mailman.mo | Bin 0 -> 61094 bytes messages/nl/LC_MESSAGES/mailman.po | 8465 ++++++++++++++++++ messages/no/LC_MESSAGES/mailman.mo | Bin 0 -> 304308 bytes messages/no/LC_MESSAGES/mailman.po | 11778 ++++++++++++++++++++++++ messages/pt_BR/LC_MESSAGES/mailman.mo | Bin 0 -> 334389 bytes messages/pt_BR/LC_MESSAGES/mailman.po | 11781 ++++++++++++++++++++++++ messages/ru/LC_MESSAGES/mailman.mo | Bin 0 -> 74139 bytes messages/ru/LC_MESSAGES/mailman.po | 8742 ++++++++++++++++++ messages/ru/README.ru | 17 + messages/sv/LC_MESSAGES/mailman.mo | Bin 0 -> 249221 bytes messages/sv/LC_MESSAGES/mailman.po | 14400 ++++++++++++++++++++++++++++++ messages/sv/README.sv | 30 + misc/.cvsignore | 3 + misc/JapaneseCodecs-1.4.9.tar.gz | Bin 0 -> 288276 bytes misc/KoreanCodecs-2.0.5.tar.gz | Bin 0 -> 260762 bytes misc/Makefile.in | 99 + misc/PythonPowered.png | Bin 0 -> 945 bytes misc/email-2.4.3.tar.gz | Bin 0 -> 1108079 bytes misc/gnu-head-tiny.jpg | Bin 0 -> 3049 bytes misc/mailman-large.jpg | Bin 0 -> 6150 bytes misc/mailman.in | 54 + misc/mailman.jpg | Bin 0 -> 2022 bytes misc/mm-icon.png | Bin 0 -> 281 bytes misc/paths.py.in | 60 + mkinstalldirs | 40 + scripts/.cvsignore | 1 + scripts/Makefile.in | 75 + scripts/bounces | 61 + scripts/confirm | 62 + scripts/driver | 243 + scripts/join | 61 + scripts/leave | 61 + scripts/owner | 67 + scripts/post | 69 + scripts/request | 64 + src/.cvsignore | 15 + src/Makefile.in | 133 + src/cgi-wrapper.c | 68 + src/common.c | 299 + src/common.h | 61 + src/mail-wrapper.c | 91 + src/vsnprintf.c | 125 + templates/.cvsignore | 1 + templates/Makefile.in | 74 + templates/big5/admindbpreamble.html | 22 + templates/big5/adminsubscribeack.txt | 2 + templates/big5/adminunsubscribeack.txt | 3 + templates/big5/admlogin.html | 31 + templates/big5/approve.txt | 12 + templates/big5/bounce.txt | 12 + templates/big5/checkdbs.txt | 8 + templates/big5/convert.txt | 40 + templates/big5/cronpass.txt | 16 + templates/big5/handle_opts.html | 9 + templates/big5/headfoot.html | 23 + templates/big5/help.txt | 33 + templates/big5/listinfo.html | 127 + templates/big5/masthead.txt | 18 + templates/big5/newlist.txt | 32 + templates/big5/options.html | 150 + templates/big5/postack.txt | 7 + templates/big5/postauth.txt | 12 + templates/big5/postheld.txt | 11 + templates/big5/refuse.txt | 11 + templates/big5/roster.html | 50 + templates/big5/subauth.txt | 10 + templates/big5/subscribe.html | 9 + templates/big5/subscribeack.txt | 34 + templates/big5/userpass.txt | 17 + templates/big5/verify.txt | 17 + templates/cs/admindbdetails.html | 66 + templates/cs/admindbpreamble.html | 14 + templates/cs/admindbsummary.html | 18 + templates/cs/adminsubscribeack.txt | 3 + templates/cs/adminunsubscribeack.txt | 2 + templates/cs/admlogin.html | 36 + templates/cs/approve.txt | 17 + templates/cs/article.html | 48 + templates/cs/bounce.txt | 16 + templates/cs/checkdbs.txt | 8 + templates/cs/convert.txt | 42 + templates/cs/cronpass.txt | 21 + templates/cs/disabled.txt | 24 + templates/cs/emptyarchive.html | 15 + templates/cs/headfoot.html | 30 + templates/cs/help.txt | 41 + templates/cs/listinfo.html | 141 + templates/cs/masthead.txt | 16 + templates/cs/newlist.txt | 40 + templates/cs/options.html | 293 + templates/cs/postack.txt | 8 + templates/cs/postauth.txt | 13 + templates/cs/postheld.txt | 16 + templates/cs/private.html | 41 + templates/cs/refuse.txt | 12 + templates/cs/roster.html | 54 + templates/cs/subauth.txt | 10 + templates/cs/subscribe.html | 9 + templates/cs/subscribeack.txt | 34 + templates/cs/unsub.txt | 19 + templates/cs/unsubauth.txt | 12 + templates/cs/userpass.txt | 18 + templates/cs/verify.txt | 26 + templates/de/admindbdetails.html | 78 + templates/de/admindbpreamble.html | 32 + templates/de/admindbsummary.html | 15 + templates/de/adminsubscribeack.txt | 1 + templates/de/adminunsubscribeack.txt | 2 + templates/de/admlogin.html | 39 + templates/de/approve.txt | 15 + templates/de/article.html | 48 + templates/de/bounce.txt | 13 + templates/de/checkdbs.txt | 9 + templates/de/convert.txt | 34 + templates/de/cronpass.txt | 35 + templates/de/disabled.txt | 26 + templates/de/headfoot.html | 32 + templates/de/help.txt | 38 + templates/de/invite.txt | 20 + templates/de/listinfo.html | 142 + templates/de/masthead.txt | 16 + templates/de/newlist.txt | 38 + templates/de/options.html | 287 + templates/de/postack.txt | 7 + templates/de/postauth.txt | 13 + templates/de/postheld.txt | 15 + templates/de/private.html | 42 + templates/de/refuse.txt | 11 + templates/de/roster.html | 51 + templates/de/subauth.txt | 10 + templates/de/subscribe.html | 9 + templates/de/subscribeack.txt | 36 + templates/de/unsub.txt | 27 + templates/de/userpass.txt | 24 + templates/de/verify.txt | 25 + templates/en/admindbdetails.html | 65 + templates/en/admindbpreamble.html | 10 + templates/en/admindbsummary.html | 14 + templates/en/adminsubscribeack.txt | 3 + templates/en/adminunsubscribeack.txt | 2 + templates/en/admlogin.html | 39 + templates/en/approve.txt | 15 + templates/en/archidxentry.html | 4 + templates/en/archidxfoot.html | 21 + templates/en/archidxhead.html | 24 + templates/en/archlistend.html | 1 + templates/en/archliststart.html | 4 + templates/en/archtoc.html | 20 + templates/en/archtocentry.html | 12 + templates/en/article.html | 50 + templates/en/bounce.txt | 13 + templates/en/checkdbs.txt | 7 + templates/en/convert.txt | 34 + templates/en/cronpass.txt | 19 + templates/en/disabled.txt | 25 + templates/en/emptyarchive.html | 15 + templates/en/headfoot.html | 29 + templates/en/help.txt | 33 + templates/en/invite.txt | 20 + templates/en/listinfo.html | 144 + templates/en/masthead.txt | 13 + templates/en/newlist.txt | 35 + templates/en/nomoretoday.txt | 8 + templates/en/options.html | 317 + templates/en/postack.txt | 8 + templates/en/postauth.txt | 13 + templates/en/postheld.txt | 15 + templates/en/private.html | 43 + templates/en/refuse.txt | 13 + templates/en/roster.html | 53 + templates/en/subauth.txt | 11 + templates/en/subscribe.html | 9 + templates/en/subscribeack.txt | 33 + templates/en/unsub.txt | 23 + templates/en/unsubauth.txt | 11 + templates/en/userpass.txt | 24 + templates/en/verify.txt | 22 + templates/es/admindbdetails.html | 63 + templates/es/admindbpreamble.html | 10 + templates/es/admindbsummary.html | 14 + templates/es/adminsubscribeack.txt | 3 + templates/es/adminunsubscribeack.txt | 3 + templates/es/admlogin.html | 37 + templates/es/approve.txt | 16 + templates/es/article.html | 48 + templates/es/bounce.txt | 12 + templates/es/checkdbs.txt | 8 + templates/es/convert.txt | 38 + templates/es/cronpass.txt | 23 + templates/es/disabled.txt | 27 + templates/es/emptyarchive.html | 15 + templates/es/handle_opts.html | 9 + templates/es/headfoot.html | 33 + templates/es/help.txt | 37 + templates/es/invite.txt | 21 + templates/es/listinfo.html | 144 + templates/es/masthead.txt | 18 + templates/es/newlist.txt | 37 + templates/es/nomoretoday.txt | 10 + templates/es/options.html | 326 + templates/es/postack.txt | 7 + templates/es/postauth.txt | 13 + templates/es/postheld.txt | 16 + templates/es/private.html | 37 + templates/es/refuse.txt | 12 + templates/es/roster.html | 52 + templates/es/subauth.txt | 11 + templates/es/subscribe.html | 9 + templates/es/subscribeack.txt | 37 + templates/es/unsub.txt | 24 + templates/es/unsubauth.txt | 11 + templates/es/userpass.txt | 28 + templates/es/verify.txt | 26 + templates/et/admindbdetails.html | 63 + templates/et/admindbpreamble.html | 9 + templates/et/admindbsummary.html | 13 + templates/et/adminsubscribeack.txt | 3 + templates/et/adminunsubscribeack.txt | 2 + templates/et/admlogin.html | 36 + templates/et/approve.txt | 14 + templates/et/article.html | 51 + templates/et/bounce.txt | 13 + templates/et/checkdbs.txt | 9 + templates/et/convert.txt | 32 + templates/et/cronpass.txt | 19 + templates/et/disabled.txt | 26 + templates/et/emptyarchive.html | 14 + templates/et/headfoot.html | 25 + templates/et/help.txt | 33 + templates/et/invite.txt | 22 + templates/et/listinfo.html | 137 + templates/et/masthead.txt | 16 + templates/et/newlist.txt | 36 + templates/et/options.html | 303 + templates/et/postack.txt | 8 + templates/et/postauth.txt | 12 + templates/et/postheld.txt | 15 + templates/et/private.html | 40 + templates/et/refuse.txt | 12 + templates/et/roster.html | 53 + templates/et/subauth.txt | 6 + templates/et/subscribe.html | 9 + templates/et/subscribeack.txt | 38 + templates/et/unsub.txt | 29 + templates/et/unsubauth.txt | 7 + templates/et/userpass.txt | 26 + templates/et/verify.txt | 30 + templates/fi/admindbdetails.html | 63 + templates/fi/admindbpreamble.html | 57 + templates/fi/admindbsummary.html | 17 + templates/fi/adminsubscribeack.txt | 3 + templates/fi/adminunsubscribeack.txt | 2 + templates/fi/admlogin.html | 41 + templates/fi/approve.txt | 17 + templates/fi/article.html | 49 + templates/fi/bounce.txt | 12 + templates/fi/checkdbs.txt | 6 + templates/fi/convert.txt | 41 + templates/fi/cronpass.txt | 19 + templates/fi/disabled.txt | 25 + templates/fi/headfoot.html | 44 + templates/fi/help.txt | 107 + templates/fi/listinfo.html | 147 + templates/fi/masthead.txt | 18 + templates/fi/newlist.txt | 42 + templates/fi/options.html | 288 + templates/fi/postack.txt | 7 + templates/fi/postauth.txt | 14 + templates/fi/postheld.txt | 16 + templates/fi/private.html | 43 + templates/fi/reenable.txt | 4 + templates/fi/refuse.txt | 13 + templates/fi/roster.html | 53 + templates/fi/subauth.txt | 8 + templates/fi/subscribe.html | 9 + templates/fi/subscribeack.txt | 41 + templates/fi/unsub.txt | 25 + templates/fi/unsubauth.txt | 9 + templates/fi/userpass.txt | 26 + templates/fi/verify.txt | 26 + templates/fr/admindbdetails.html | 64 + templates/fr/admindbpreamble.html | 12 + templates/fr/admindbsummary.html | 11 + templates/fr/adminsubscribeack.txt | 3 + templates/fr/adminunsubscribeack.txt | 2 + templates/fr/admlogin.html | 40 + templates/fr/approve.txt | 16 + templates/fr/archidxentry.html | 4 + templates/fr/archidxfoot.html | 22 + templates/fr/archidxhead.html | 26 + templates/fr/archlistend.html | 1 + templates/fr/archliststart.html | 6 + templates/fr/archtoc.html | 19 + templates/fr/archtocentry.html | 10 + templates/fr/article.html | 52 + templates/fr/bounce.txt | 13 + templates/fr/checkdbs.txt | 7 + templates/fr/convert.txt | 37 + templates/fr/cronpass.txt | 21 + templates/fr/disabled.txt | 29 + templates/fr/emptyarchive.html | 18 + templates/fr/handle_opts.html | 9 + templates/fr/headfoot.html | 31 + templates/fr/help.txt | 35 + templates/fr/invite.txt | 20 + templates/fr/listinfo.html | 149 + templates/fr/masthead.txt | 15 + templates/fr/newlist.txt | 40 + templates/fr/nomoretoday.txt | 10 + templates/fr/options.html | 339 + templates/fr/postack.txt | 8 + templates/fr/postauth.txt | 13 + templates/fr/postheld.txt | 16 + templates/fr/private.html | 45 + templates/fr/refuse.txt | 13 + templates/fr/roster.html | 52 + templates/fr/subauth.txt | 10 + templates/fr/subscribe.html | 9 + templates/fr/subscribeack.txt | 36 + templates/fr/unsub.txt | 22 + templates/fr/unsubauth.txt | 11 + templates/fr/userpass.txt | 24 + templates/fr/verify.txt | 21 + templates/gb/admindbpreamble.html | 22 + templates/gb/adminsubscribeack.txt | 2 + templates/gb/adminunsubscribeack.txt | 3 + templates/gb/admlogin.html | 31 + templates/gb/approve.txt | 12 + templates/gb/bounce.txt | 12 + templates/gb/checkdbs.txt | 8 + templates/gb/convert.txt | 40 + templates/gb/cronpass.txt | 16 + templates/gb/handle_opts.html | 9 + templates/gb/headfoot.html | 23 + templates/gb/help.txt | 33 + templates/gb/listinfo.html | 127 + templates/gb/masthead.txt | 18 + templates/gb/newlist.txt | 32 + templates/gb/options.html | 150 + templates/gb/postack.txt | 7 + templates/gb/postauth.txt | 12 + templates/gb/postheld.txt | 11 + templates/gb/refuse.txt | 11 + templates/gb/roster.html | 50 + templates/gb/subauth.txt | 10 + templates/gb/subscribe.html | 9 + templates/gb/subscribeack.txt | 34 + templates/gb/userpass.txt | 17 + templates/gb/verify.txt | 17 + templates/hu/admindbdetails.html | 68 + templates/hu/admindbpreamble.html | 11 + templates/hu/admindbsummary.html | 13 + templates/hu/adminsubscribeack.txt | 1 + templates/hu/adminunsubscribeack.txt | 1 + templates/hu/admlogin.html | 32 + templates/hu/approve.txt | 15 + templates/hu/archidxentry.html | 4 + templates/hu/archidxfoot.html | 19 + templates/hu/archidxhead.html | 23 + templates/hu/archlistend.html | 1 + templates/hu/archliststart.html | 4 + templates/hu/archtoc.html | 20 + templates/hu/archtocentry.html | 12 + templates/hu/article.html | 49 + templates/hu/bounce.txt | 13 + templates/hu/checkdbs.txt | 7 + templates/hu/convert.txt | 31 + templates/hu/cronpass.txt | 20 + templates/hu/disabled.txt | 24 + templates/hu/emptyarchive.html | 14 + templates/hu/headfoot.html | 25 + templates/hu/help.txt | 34 + templates/hu/illik.html | 1542 ++++ templates/hu/invite.txt | 21 + templates/hu/listinfo.html | 139 + templates/hu/masthead.txt | 14 + templates/hu/newlist.txt | 35 + templates/hu/nomoretoday.txt | 8 + templates/hu/options.html | 306 + templates/hu/postack.txt | 8 + templates/hu/postauth.txt | 13 + templates/hu/postheld.txt | 15 + templates/hu/private.html | 37 + templates/hu/refuse.txt | 13 + templates/hu/roster.html | 51 + templates/hu/subauth.txt | 11 + templates/hu/subscribe.html | 10 + templates/hu/subscribeack.txt | 34 + templates/hu/unsub.txt | 24 + templates/hu/unsubauth.txt | 11 + templates/hu/userpass.txt | 23 + templates/hu/verify.txt | 25 + templates/it/admindbdetails.html | 81 + templates/it/admindbpreamble.html | 11 + templates/it/admindbsummary.html | 14 + templates/it/adminsubscribeack.txt | 1 + templates/it/adminunsubscribeack.txt | 1 + templates/it/admlogin.html | 39 + templates/it/approve.txt | 16 + templates/it/archidxentry.html | 4 + templates/it/archidxfoot.html | 21 + templates/it/archidxhead.html | 24 + templates/it/archlistend.html | 1 + templates/it/archliststart.html | 4 + templates/it/archtoc.html | 20 + templates/it/archtocentry.html | 12 + templates/it/article.html | 49 + templates/it/bounce.txt | 15 + templates/it/checkdbs.txt | 10 + templates/it/convert.txt | 38 + templates/it/cronpass.txt | 22 + templates/it/disabled.txt | 26 + templates/it/emptyarchive.html | 16 + templates/it/headfoot.html | 31 + templates/it/help.txt | 33 + templates/it/invite.txt | 21 + templates/it/listinfo.html | 152 + templates/it/masthead.txt | 16 + templates/it/newlist.txt | 39 + templates/it/nomoretoday.txt | 9 + templates/it/options.html | 317 + templates/it/postack.txt | 8 + templates/it/postauth.txt | 13 + templates/it/postheld.txt | 18 + templates/it/private.html | 44 + templates/it/refuse.txt | 13 + templates/it/roster.html | 52 + templates/it/subauth.txt | 11 + templates/it/subscribe.html | 8 + templates/it/subscribeack.txt | 34 + templates/it/unsub.txt | 24 + templates/it/unsubauth.txt | 11 + templates/it/userpass.txt | 26 + templates/it/verify.txt | 24 + templates/ja/admindbdetails.html | 61 + templates/ja/admindbpreamble.html | 12 + templates/ja/admindbsummary.html | 13 + templates/ja/adminsubscribeack.txt | 3 + templates/ja/adminunsubscribeack.txt | 2 + templates/ja/admlogin.html | 39 + templates/ja/approve.txt | 17 + templates/ja/archidxentry.html | 4 + templates/ja/archidxfoot.html | 21 + templates/ja/archidxhead.html | 24 + templates/ja/archlistend.html | 1 + templates/ja/archliststart.html | 4 + templates/ja/archtoc.html | 20 + templates/ja/archtocentry.html | 12 + templates/ja/article.html | 50 + templates/ja/bounce.txt | 12 + templates/ja/checkdbs.txt | 9 + templates/ja/convert.txt | 33 + templates/ja/cronpass.txt | 16 + templates/ja/disabled.txt | 25 + templates/ja/emptyarchive.html | 16 + templates/ja/headfoot.html | 28 + templates/ja/help.txt | 37 + templates/ja/invite.txt | 22 + templates/ja/listinfo.html | 149 + templates/ja/masthead.txt | 14 + templates/ja/newlist.txt | 35 + templates/ja/nomoretoday.txt | 10 + templates/ja/options.html | 303 + templates/ja/postack.txt | 8 + templates/ja/postauth.txt | 10 + templates/ja/postheld.txt | 18 + templates/ja/private.html | 41 + templates/ja/refuse.txt | 12 + templates/ja/roster.html | 54 + templates/ja/subauth.txt | 9 + templates/ja/subscribe.html | 9 + templates/ja/subscribeack.txt | 36 + templates/ja/unsub.txt | 25 + templates/ja/unsubauth.txt | 9 + templates/ja/userpass.txt | 24 + templates/ja/verify.txt | 24 + templates/ko/admindbdetails.html | 56 + templates/ko/admindbpreamble.html | 9 + templates/ko/admindbsummary.html | 11 + templates/ko/adminsubscribeack.txt | 3 + templates/ko/adminunsubscribeack.txt | 2 + templates/ko/admlogin.html | 33 + templates/ko/approve.txt | 15 + templates/ko/article.html | 48 + templates/ko/bounce.txt | 12 + templates/ko/checkdbs.txt | 8 + templates/ko/convert.txt | 37 + templates/ko/cronpass.txt | 16 + templates/ko/disabled.txt | 25 + templates/ko/emptyarchive.html | 14 + templates/ko/headfoot.html | 22 + templates/ko/help.txt | 32 + templates/ko/invite.txt | 20 + templates/ko/listinfo.html | 139 + templates/ko/masthead.txt | 18 + templates/ko/newlist.txt | 35 + templates/ko/options.html | 306 + templates/ko/postack.txt | 7 + templates/ko/postauth.txt | 12 + templates/ko/postheld.txt | 15 + templates/ko/private.html | 42 + templates/ko/refuse.txt | 13 + templates/ko/roster.html | 50 + templates/ko/subauth.txt | 9 + templates/ko/subscribe.html | 9 + templates/ko/subscribeack.txt | 33 + templates/ko/unsub.txt | 20 + templates/ko/unsubauth.txt | 10 + templates/ko/userpass.txt | 20 + templates/ko/verify.txt | 23 + templates/lt/admindbdetails.html | 65 + templates/lt/admindbpreamble.html | 13 + templates/lt/admindbsummary.html | 14 + templates/lt/adminsubscribeack.txt | 3 + templates/lt/adminunsubscribeack.txt | 1 + templates/lt/admlogin.html | 37 + templates/lt/approve.txt | 13 + templates/lt/archidxentry.html | 4 + templates/lt/archidxfoot.html | 20 + templates/lt/archidxhead.html | 23 + templates/lt/archlistend.html | 1 + templates/lt/archliststart.html | 4 + templates/lt/archtoc.html | 19 + templates/lt/archtocentry.html | 12 + templates/lt/article.html | 49 + templates/lt/bounce.txt | 13 + templates/lt/checkdbs.txt | 9 + templates/lt/convert.txt | 34 + templates/lt/cronpass.txt | 12 + templates/lt/disabled.txt | 28 + templates/lt/emptyarchive.html | 14 + templates/lt/headfoot.html | 27 + templates/lt/help.txt | 36 + templates/lt/invite.txt | 21 + templates/lt/listinfo.html | 141 + templates/lt/masthead.txt | 16 + templates/lt/newlist.txt | 35 + templates/lt/nomoretoday.txt | 12 + templates/lt/options.html | 276 + templates/lt/postack.txt | 5 + templates/lt/postauth.txt | 10 + templates/lt/postheld.txt | 10 + templates/lt/private.html | 42 + templates/lt/refuse.txt | 12 + templates/lt/roster.html | 53 + templates/lt/subauth.txt | 8 + templates/lt/subscribe.html | 9 + templates/lt/subscribeack.txt | 34 + templates/lt/unsub.txt | 26 + templates/lt/unsubauth.txt | 8 + templates/lt/userpass.txt | 26 + templates/lt/verify.txt | 25 + templates/nl/admindbdetails.html | 65 + templates/nl/admindbpreamble.html | 8 + templates/nl/admindbsummary.html | 7 + templates/nl/adminsubscribeack.txt | 3 + templates/nl/adminunsubscribeack.txt | 2 + templates/nl/admlogin.html | 30 + templates/nl/approve.txt | 14 + templates/nl/article.html | 53 + templates/nl/bounce.txt | 12 + templates/nl/checkdbs.txt | 7 + templates/nl/convert.txt | 32 + templates/nl/cronpass.txt | 19 + templates/nl/disabled.txt | 24 + templates/nl/emptyarchive.html | 13 + templates/nl/headfoot.html | 43 + templates/nl/help.txt | 24 + templates/nl/invite.txt | 20 + templates/nl/listinfo.html | 126 + templates/nl/masthead.txt | 13 + templates/nl/newlist.txt | 35 + templates/nl/options.html | 280 + templates/nl/postack.txt | 7 + templates/nl/postauth.txt | 13 + templates/nl/postheld.txt | 15 + templates/nl/private.html | 34 + templates/nl/refuse.txt | 12 + templates/nl/roster.html | 50 + templates/nl/subauth.txt | 11 + templates/nl/subscribe.html | 9 + templates/nl/subscribeack.txt | 33 + templates/nl/unsub.txt | 21 + templates/nl/unsubauth.txt | 10 + templates/nl/userpass.txt | 24 + templates/nl/verify.txt | 21 + templates/no/admindbdetails.html | 62 + templates/no/admindbpreamble.html | 8 + templates/no/admindbsummary.html | 10 + templates/no/adminsubscribeack.txt | 1 + templates/no/adminunsubscribeack.txt | 2 + templates/no/admlogin.html | 38 + templates/no/approve.txt | 14 + templates/no/archidxfoot.html | 21 + templates/no/archidxhead.html | 24 + templates/no/archliststart.html | 4 + templates/no/archtoc.html | 20 + templates/no/archtocentry.html | 12 + templates/no/article.html | 49 + templates/no/bounce.txt | 13 + templates/no/checkdbs.txt | 7 + templates/no/convert.txt | 33 + templates/no/cronpass.txt | 19 + templates/no/disabled.txt | 25 + templates/no/emptyarchive.html | 14 + templates/no/headfoot.html | 26 + templates/no/help.txt | 33 + templates/no/invite.txt | 19 + templates/no/listinfo.html | 137 + templates/no/masthead.txt | 15 + templates/no/newlist.txt | 38 + templates/no/nomoretoday.txt | 7 + templates/no/options.html | 300 + templates/no/postack.txt | 8 + templates/no/postauth.txt | 13 + templates/no/postheld.txt | 16 + templates/no/private.html | 41 + templates/no/refuse.txt | 13 + templates/no/roster.html | 52 + templates/no/subauth.txt | 10 + templates/no/subscribe.html | 9 + templates/no/subscribeack.txt | 37 + templates/no/unsub.txt | 21 + templates/no/unsubauth.txt | 10 + templates/no/userpass.txt | 26 + templates/no/verify.txt | 21 + templates/pt_BR/admindbdetails.html | 71 + templates/pt_BR/admindbpreamble.html | 11 + templates/pt_BR/admindbsummary.html | 14 + templates/pt_BR/adminsubscribeack.txt | 3 + templates/pt_BR/adminunsubscribeack.txt | 2 + templates/pt_BR/admlogin.html | 39 + templates/pt_BR/admlogin.txt | 38 + templates/pt_BR/approve.txt | 16 + templates/pt_BR/article.html | 50 + templates/pt_BR/bounce.txt | 14 + templates/pt_BR/checkdbs.txt | 6 + templates/pt_BR/convert.txt | 37 + templates/pt_BR/cronpass.txt | 21 + templates/pt_BR/disabled.txt | 27 + templates/pt_BR/emptyarchive.html | 15 + templates/pt_BR/headfoot.html | 31 + templates/pt_BR/help.txt | 34 + templates/pt_BR/invite.txt | 21 + templates/pt_BR/listinfo.html | 144 + templates/pt_BR/masthead.txt | 16 + templates/pt_BR/newlist.txt | 41 + templates/pt_BR/options.html | 324 + templates/pt_BR/postack.txt | 8 + templates/pt_BR/postauth.txt | 13 + templates/pt_BR/postheld.txt | 15 + templates/pt_BR/private.html | 43 + templates/pt_BR/refuse.txt | 13 + templates/pt_BR/roster.html | 53 + templates/pt_BR/subauth.txt | 11 + templates/pt_BR/subscribe.html | 9 + templates/pt_BR/subscribeack.txt | 32 + templates/pt_BR/unsub.txt | 23 + templates/pt_BR/unsubauth.txt | 12 + templates/pt_BR/userpass.txt | 25 + templates/pt_BR/verify.txt | 23 + templates/ru/Makefile | 50 + templates/ru/admindbdetails.html | 60 + templates/ru/admindbpreamble.html | 11 + templates/ru/admindbsummary.html | 11 + templates/ru/adminsubscribeack.txt | 2 + templates/ru/adminunsubscribeack.txt | 2 + templates/ru/admlogin.html | 37 + templates/ru/approve.txt | 15 + templates/ru/archidxentry.html | 1 + templates/ru/archidxfoot.html | 19 + templates/ru/archidxhead.html | 23 + templates/ru/archlistend.html | 1 + templates/ru/archliststart.html | 4 + templates/ru/archtoc.html | 19 + templates/ru/archtocentry.html | 12 + templates/ru/article.html | 48 + templates/ru/bounce.txt | 13 + templates/ru/checkdbs.txt | 9 + templates/ru/convert.txt | 33 + templates/ru/cronpass.txt | 19 + templates/ru/disabled.txt | 23 + templates/ru/emptyarchive.html | 14 + templates/ru/headfoot.html | 23 + templates/ru/help.txt | 33 + templates/ru/invite.txt | 22 + templates/ru/listinfo.html | 132 + templates/ru/masthead.txt | 18 + templates/ru/newlist.txt | 40 + templates/ru/nomoretoday.txt | 9 + templates/ru/options.html | 306 + templates/ru/postack.txt | 8 + templates/ru/postauth.txt | 12 + templates/ru/postheld.txt | 12 + templates/ru/private.html | 40 + templates/ru/refuse.txt | 13 + templates/ru/roster.html | 48 + templates/ru/s2s.py | 66 + templates/ru/status | 42 + templates/ru/subauth.txt | 9 + templates/ru/subscribe.html | 8 + templates/ru/subscribeack.txt | 41 + templates/ru/unsub.txt | 24 + templates/ru/unsubauth.txt | 9 + templates/ru/userpass.txt | 22 + templates/ru/verify.txt | 27 + templates/sv/admindbdetails.html | 21 + templates/sv/admindbpreamble.html | 4 + templates/sv/admindbsummary.html | 3 + templates/sv/adminsubscribeack.txt | 1 + templates/sv/adminunsubscribeack.txt | 2 + templates/sv/admlogin.html | 28 + templates/sv/approve.txt | 14 + templates/sv/archtoc.html | 20 + templates/sv/archtocentry.html | 12 + templates/sv/article.html | 51 + templates/sv/bounce.txt | 13 + templates/sv/checkdbs.txt | 7 + templates/sv/convert.txt | 33 + templates/sv/cronpass.txt | 19 + templates/sv/disabled.txt | 25 + templates/sv/emptyarchive.html | 11 + templates/sv/headfoot.html | 9 + templates/sv/help.txt | 33 + templates/sv/invite.txt | 19 + templates/sv/listinfo.html | 124 + templates/sv/masthead.txt | 15 + templates/sv/newlist.txt | 39 + templates/sv/nomoretoday.txt | 7 + templates/sv/options.html | 251 + templates/sv/postack.txt | 7 + templates/sv/postauth.txt | 13 + templates/sv/postheld.txt | 16 + templates/sv/private.html | 32 + templates/sv/refuse.txt | 13 + templates/sv/roster.html | 48 + templates/sv/subauth.txt | 10 + templates/sv/subscribe.html | 9 + templates/sv/subscribeack.txt | 35 + templates/sv/unsub.txt | 19 + templates/sv/unsubauth.txt | 10 + templates/sv/userpass.txt | 26 + templates/sv/verify.txt | 20 + tests/.cvsignore | 1 + tests/EmailBase.py | 75 + tests/Makefile.in | 83 + tests/TestBase.py | 53 + tests/bounces/.cvsignore | 1 + tests/bounces/Makefile.in | 68 + tests/bounces/bounce_01.txt | 95 + tests/bounces/bounce_02.txt | 36 + tests/bounces/bounce_03.txt | 109 + tests/bounces/dsn_01.txt | 217 + tests/bounces/dsn_02.txt | 187 + tests/bounces/dsn_03.txt | 144 + tests/bounces/dsn_04.txt | 202 + tests/bounces/dsn_05.txt | 125 + tests/bounces/dsn_06.txt | 122 + tests/bounces/dsn_07.txt | 121 + tests/bounces/dsn_08.txt | 131 + tests/bounces/dsn_09.txt | 89 + tests/bounces/dsn_10.txt | 66 + tests/bounces/dsn_11.txt | 176 + tests/bounces/dumbass_01.txt | 109 + tests/bounces/exim_01.txt | 58 + tests/bounces/groupwise_01.txt | 151 + tests/bounces/groupwise_02.txt | 186 + tests/bounces/hotpop_01.txt | 181 + tests/bounces/llnl_01.txt | 203 + tests/bounces/microsoft_01.txt | 108 + tests/bounces/microsoft_02.txt | 119 + tests/bounces/netscape_01.txt | 123 + tests/bounces/newmailru_01.txt | 112 + tests/bounces/postfix_01.txt | 123 + tests/bounces/postfix_02.txt | 60 + tests/bounces/postfix_03.txt | 145 + tests/bounces/postfix_04.txt | 240 + tests/bounces/postfix_05.txt | 231 + tests/bounces/qmail_01.txt | 103 + tests/bounces/sendmail_01.txt | 146 + tests/bounces/simple_01.txt | 153 + tests/bounces/simple_02.txt | 118 + tests/bounces/simple_03.txt | 68 + tests/bounces/simple_04.txt | 105 + tests/bounces/sina_01.txt | 128 + tests/bounces/smtp32_01.txt | 97 + tests/bounces/smtp32_02.txt | 96 + tests/bounces/smtp32_03.txt | 92 + tests/bounces/yahoo_01.txt | 47 + tests/bounces/yahoo_02.txt | Bin 0 -> 2212 bytes tests/bounces/yahoo_03.txt | 98 + tests/bounces/yahoo_04.txt | 150 + tests/bounces/yahoo_05.txt | 150 + tests/bounces/yahoo_06.txt | 105 + tests/bounces/yahoo_07.txt | 112 + tests/bounces/yahoo_08.txt | 129 + tests/bounces/yahoo_09.txt | 165 + tests/bounces/yale_01.txt | 422 + tests/fblast.py | 60 + tests/msgs/.cvsignore | 1 + tests/msgs/Makefile.in | 68 + tests/msgs/bad_01.txt | 62 + tests/msgs/nimda.txt | 43 + tests/onebounce.py | 94 + tests/test_bounces.py | 175 + tests/test_handlers.py | 1741 ++++ tests/test_lockfile.py | 45 + tests/test_membership.py | 380 + tests/test_message.py | 102 + tests/test_runners.py | 120 + tests/test_safedict.py | 103 + tests/test_security_mgr.py | 261 + tests/testall.py | 40 + 1173 files changed, 284795 insertions(+) create mode 100644 .cvsignore create mode 100644 ACKNOWLEDGMENTS create mode 100644 BUGS create mode 100644 FAQ create mode 100644 INSTALL create mode 100644 Mailman/.cvsignore create mode 100644 Mailman/Archiver/.cvsignore create mode 100644 Mailman/Archiver/Archiver.py create mode 100644 Mailman/Archiver/HyperArch.py create mode 100644 Mailman/Archiver/HyperDatabase.py create mode 100644 Mailman/Archiver/Makefile.in create mode 100644 Mailman/Archiver/__init__.py create mode 100644 Mailman/Archiver/pipermail.py create mode 100644 Mailman/Autoresponder.py create mode 100644 Mailman/Bouncer.py create mode 100644 Mailman/Bouncers/.cvsignore create mode 100644 Mailman/Bouncers/BouncerAPI.py create mode 100644 Mailman/Bouncers/Caiwireless.py create mode 100644 Mailman/Bouncers/Compuserve.py create mode 100644 Mailman/Bouncers/DSN.py create mode 100644 Mailman/Bouncers/Exchange.py create mode 100644 Mailman/Bouncers/Exim.py create mode 100644 Mailman/Bouncers/GroupWise.py create mode 100644 Mailman/Bouncers/LLNL.py create mode 100644 Mailman/Bouncers/Makefile.in create mode 100644 Mailman/Bouncers/Microsoft.py create mode 100644 Mailman/Bouncers/Netscape.py create mode 100644 Mailman/Bouncers/Postfix.py create mode 100644 Mailman/Bouncers/Qmail.py create mode 100644 Mailman/Bouncers/SMTP32.py create mode 100644 Mailman/Bouncers/SimpleMatch.py create mode 100644 Mailman/Bouncers/SimpleWarning.py create mode 100644 Mailman/Bouncers/Sina.py create mode 100644 Mailman/Bouncers/Yahoo.py create mode 100644 Mailman/Bouncers/Yale.py create mode 100644 Mailman/Bouncers/__init__.py create mode 100644 Mailman/Cgi/.cvsignore create mode 100644 Mailman/Cgi/Auth.py create mode 100644 Mailman/Cgi/Makefile.in create mode 100644 Mailman/Cgi/__init__.py create mode 100644 Mailman/Cgi/admin.py create mode 100644 Mailman/Cgi/admindb.py create mode 100644 Mailman/Cgi/confirm.py create mode 100644 Mailman/Cgi/create.py create mode 100644 Mailman/Cgi/edithtml.py create mode 100644 Mailman/Cgi/listinfo.py create mode 100644 Mailman/Cgi/options.py create mode 100644 Mailman/Cgi/private.py create mode 100644 Mailman/Cgi/rmlist.py create mode 100644 Mailman/Cgi/roster.py create mode 100644 Mailman/Cgi/subscribe.py create mode 100644 Mailman/Commands/.cvsignore create mode 100644 Mailman/Commands/Makefile.in create mode 100644 Mailman/Commands/__init__.py create mode 100644 Mailman/Commands/cmd_confirm.py create mode 100644 Mailman/Commands/cmd_echo.py create mode 100644 Mailman/Commands/cmd_end.py create mode 100644 Mailman/Commands/cmd_help.py create mode 100644 Mailman/Commands/cmd_info.py create mode 100644 Mailman/Commands/cmd_join.py create mode 100644 Mailman/Commands/cmd_leave.py create mode 100644 Mailman/Commands/cmd_lists.py create mode 100644 Mailman/Commands/cmd_password.py create mode 100644 Mailman/Commands/cmd_remove.py create mode 100644 Mailman/Commands/cmd_set.py create mode 100644 Mailman/Commands/cmd_stop.py create mode 100644 Mailman/Commands/cmd_subscribe.py create mode 100644 Mailman/Commands/cmd_unsubscribe.py create mode 100644 Mailman/Commands/cmd_who.py create mode 100644 Mailman/Defaults.py.in create mode 100644 Mailman/Deliverer.py create mode 100644 Mailman/Digester.py create mode 100644 Mailman/Errors.py create mode 100644 Mailman/GatewayManager.py create mode 100644 Mailman/Gui/.cvsignore create mode 100644 Mailman/Gui/Archive.py create mode 100644 Mailman/Gui/Autoresponse.py create mode 100644 Mailman/Gui/Bounce.py create mode 100644 Mailman/Gui/ContentFilter.py create mode 100644 Mailman/Gui/Digest.py create mode 100644 Mailman/Gui/GUIBase.py create mode 100644 Mailman/Gui/General.py create mode 100644 Mailman/Gui/Language.py create mode 100644 Mailman/Gui/Makefile.in create mode 100644 Mailman/Gui/Membership.py create mode 100644 Mailman/Gui/NonDigest.py create mode 100644 Mailman/Gui/Passwords.py create mode 100644 Mailman/Gui/Privacy.py create mode 100644 Mailman/Gui/Topics.py create mode 100644 Mailman/Gui/Usenet.py create mode 100644 Mailman/Gui/__init__.py create mode 100644 Mailman/HTMLFormatter.py create mode 100644 Mailman/Handlers/.cvsignore create mode 100644 Mailman/Handlers/Acknowledge.py create mode 100644 Mailman/Handlers/AfterDelivery.py create mode 100644 Mailman/Handlers/Approve.py create mode 100644 Mailman/Handlers/AvoidDuplicates.py create mode 100644 Mailman/Handlers/CalcRecips.py create mode 100644 Mailman/Handlers/Cleanse.py create mode 100644 Mailman/Handlers/CookHeaders.py create mode 100644 Mailman/Handlers/Decorate.py create mode 100644 Mailman/Handlers/Emergency.py create mode 100644 Mailman/Handlers/FileRecips.py create mode 100644 Mailman/Handlers/Hold.py create mode 100644 Mailman/Handlers/Makefile.in create mode 100644 Mailman/Handlers/MimeDel.py create mode 100644 Mailman/Handlers/Moderate.py create mode 100644 Mailman/Handlers/OwnerRecips.py create mode 100644 Mailman/Handlers/Replybot.py create mode 100644 Mailman/Handlers/SMTPDirect.py create mode 100644 Mailman/Handlers/Scrubber.py create mode 100644 Mailman/Handlers/Sendmail.py create mode 100644 Mailman/Handlers/SpamDetect.py create mode 100644 Mailman/Handlers/Tagger.py create mode 100644 Mailman/Handlers/ToArchive.py create mode 100644 Mailman/Handlers/ToDigest.py create mode 100644 Mailman/Handlers/ToOutgoing.py create mode 100644 Mailman/Handlers/ToUsenet.py create mode 100644 Mailman/Handlers/__init__.py create mode 100644 Mailman/ListAdmin.py create mode 100644 Mailman/LockFile.py create mode 100644 Mailman/Logging/.cvsignore create mode 100644 Mailman/Logging/Logger.py create mode 100644 Mailman/Logging/Makefile.in create mode 100644 Mailman/Logging/MultiLogger.py create mode 100644 Mailman/Logging/StampedLogger.py create mode 100644 Mailman/Logging/Syslog.py create mode 100644 Mailman/Logging/Utils.py create mode 100644 Mailman/Logging/__init__.py create mode 100644 Mailman/MTA/.cvsignore create mode 100644 Mailman/MTA/Makefile.in create mode 100644 Mailman/MTA/Manual.py create mode 100644 Mailman/MTA/Postfix.py create mode 100644 Mailman/MTA/Utils.py create mode 100644 Mailman/MTA/__init__.py create mode 100644 Mailman/MailList.py create mode 100644 Mailman/Mailbox.py create mode 100644 Mailman/Makefile.in create mode 100644 Mailman/MemberAdaptor.py create mode 100644 Mailman/Message.py create mode 100644 Mailman/OldStyleMemberships.py create mode 100644 Mailman/Pending.py create mode 100644 Mailman/Post.py create mode 100644 Mailman/Queue/.cvsignore create mode 100644 Mailman/Queue/ArchRunner.py create mode 100644 Mailman/Queue/BounceRunner.py create mode 100644 Mailman/Queue/CommandRunner.py create mode 100644 Mailman/Queue/IncomingRunner.py create mode 100644 Mailman/Queue/MaildirRunner.py create mode 100644 Mailman/Queue/Makefile.in create mode 100644 Mailman/Queue/NewsRunner.py create mode 100644 Mailman/Queue/OutgoingRunner.py create mode 100644 Mailman/Queue/Runner.py create mode 100644 Mailman/Queue/Switchboard.py create mode 100644 Mailman/Queue/VirginRunner.py create mode 100644 Mailman/Queue/__init__.py create mode 100644 Mailman/Queue/sbcache.py create mode 100644 Mailman/SafeDict.py create mode 100644 Mailman/SecurityManager.py create mode 100644 Mailman/Site.py create mode 100644 Mailman/TopicMgr.py create mode 100644 Mailman/UserDesc.py create mode 100644 Mailman/Utils.py create mode 100644 Mailman/Version.py create mode 100644 Mailman/__init__.py create mode 100644 Mailman/htmlformat.py create mode 100644 Mailman/i18n.py create mode 100644 Mailman/mm_cfg.py.dist.in create mode 100644 Mailman/versions.py create mode 100644 Makefile.in create mode 100644 NEWS create mode 100644 README create mode 100644 README-I18N.en create mode 100644 README.BSD create mode 100644 README.CONTRIB create mode 100644 README.EXIM create mode 100644 README.LINUX create mode 100644 README.MACOSX create mode 100644 README.NETSCAPE create mode 100644 README.POSTFIX create mode 100644 README.QMAIL create mode 100644 README.SENDMAIL create mode 100644 README.USERAGENT create mode 100644 TODO create mode 100644 UPGRADING create mode 100755 admin/bin/Release.py create mode 100755 admin/bin/faq2ht.py create mode 100755 admin/bin/mm2do create mode 100644 admin/www/MMGenerator.py create mode 100644 admin/www/Makefile create mode 100644 admin/www/admins.ht create mode 100644 admin/www/admins.html create mode 100644 admin/www/bugs.ht create mode 100644 admin/www/bugs.html create mode 100644 admin/www/devs.ht create mode 100644 admin/www/devs.html create mode 100644 admin/www/doco-links.h create mode 100644 admin/www/docs.ht create mode 100644 admin/www/docs.html create mode 100644 admin/www/download-links.h create mode 100644 admin/www/download.ht create mode 100644 admin/www/download.html create mode 100644 admin/www/faq.ht create mode 100644 admin/www/faq.html create mode 100644 admin/www/features.ht create mode 100644 admin/www/features.html create mode 100644 admin/www/help.ht create mode 100644 admin/www/help.html create mode 100644 admin/www/i18n.ht create mode 100644 admin/www/i18n.html create mode 100644 admin/www/images/PythonPoweredSmall.png create mode 100644 admin/www/images/dragonlogo.jpg create mode 100644 admin/www/images/logo-70.jpg create mode 100644 admin/www/images/logo-lg.jpg create mode 100644 admin/www/images/logo-sm.jpg create mode 100644 admin/www/images/mailman.jpg create mode 100644 admin/www/index.ht create mode 100644 admin/www/index.html create mode 100644 admin/www/install-links.h create mode 100644 admin/www/install.ht create mode 100644 admin/www/install.html create mode 100644 admin/www/inthenews.ht create mode 100644 admin/www/inthenews.html create mode 100644 admin/www/links.h create mode 100644 admin/www/lists.ht create mode 100644 admin/www/lists.html create mode 100644 admin/www/mailman.html create mode 100644 admin/www/mgrs.ht create mode 100644 admin/www/mgrs.html create mode 100644 admin/www/mirrors.ht create mode 100644 admin/www/mirrors.html create mode 100644 admin/www/otherstuff.ht create mode 100644 admin/www/otherstuff.html create mode 100644 admin/www/prev.ht create mode 100644 admin/www/prev.html create mode 100644 admin/www/requirements.ht create mode 100644 admin/www/requirements.html create mode 100644 admin/www/site.ht create mode 100644 admin/www/site.html create mode 100644 admin/www/todo.ht create mode 100644 admin/www/todo.html create mode 100644 admin/www/users.ht create mode 100644 admin/www/users.html create mode 100644 admin/www/version.ht create mode 100644 admin/www/version.html create mode 100644 bin/.cvsignore create mode 100644 bin/Makefile.in create mode 100755 bin/add_members create mode 100644 bin/arch create mode 100644 bin/b4b5-archfix create mode 100644 bin/change_pw create mode 100755 bin/check_db create mode 100755 bin/check_perms create mode 100644 bin/cleanarch create mode 100755 bin/clone_member create mode 100644 bin/config_list create mode 100644 bin/convert.py create mode 100644 bin/dumpdb create mode 100755 bin/find_member create mode 100644 bin/fix_url.py create mode 100644 bin/genaliases create mode 100644 bin/inject create mode 100644 bin/list_admins create mode 100644 bin/list_lists create mode 100755 bin/list_members create mode 100644 bin/list_owners create mode 100644 bin/mailmanctl create mode 100755 bin/mmsitepass create mode 100755 bin/newlist create mode 100755 bin/pygettext.py create mode 100644 bin/qrunner create mode 100755 bin/remove_members create mode 100755 bin/rmlist create mode 100755 bin/sync_members create mode 100755 bin/transcheck create mode 100644 bin/unshunt create mode 100755 bin/update create mode 100644 bin/version create mode 100644 bin/withlist create mode 100755 configure create mode 100644 configure.in create mode 100644 contrib/README create mode 100644 contrib/README.check_perms_grsecurity create mode 100644 contrib/auto create mode 100644 contrib/check_perms_grsecurity.py create mode 100644 contrib/mailman.mc create mode 100644 contrib/majordomo2mailman.pl create mode 100644 contrib/mm-handler create mode 100644 contrib/mm-handler.readme create mode 100644 contrib/qmail-to-mailman.py create mode 100644 contrib/rotatelogs.py create mode 100644 contrib/virtusertable create mode 100644 cron/.cvsignore create mode 100644 cron/Makefile.in create mode 100644 cron/bumpdigests create mode 100755 cron/checkdbs create mode 100755 cron/crontab.in.in create mode 100644 cron/disabled create mode 100755 cron/gate_news create mode 100755 cron/mailpasswds create mode 100644 cron/nightly_gzip create mode 100755 cron/senddigests create mode 100644 doc/IPC7/README create mode 100755 doc/IPC7/ipc7.doc.gz create mode 100755 doc/IPC7/ipc7.ppt.gz create mode 100644 doc/LISA-98/README create mode 100644 doc/LISA-98/published.ps.gz create mode 100644 doc/mailman-admin.tex create mode 100644 doc/posting-flow-chart.ps create mode 100644 gnu-COPYING-GPL create mode 100755 install-sh create mode 100644 messages/.cvsignore create mode 100644 messages/Makefile.in create mode 100644 messages/big5/LC_MESSAGES/mailman.mo create mode 100644 messages/big5/LC_MESSAGES/mailman.po create mode 100644 messages/cs/LC_MESSAGES/mailman.mo create mode 100644 messages/cs/LC_MESSAGES/mailman.po create mode 100644 messages/de/LC_MESSAGES/mailman.mo create mode 100644 messages/de/LC_MESSAGES/mailman.po create mode 100644 messages/de/README.de create mode 100644 messages/es/LC_MESSAGES/mailman.mo create mode 100644 messages/es/LC_MESSAGES/mailman.po create mode 100644 messages/es/README.es create mode 100644 messages/et/LC_MESSAGES/mailman.mo create mode 100644 messages/et/LC_MESSAGES/mailman.po create mode 100644 messages/fi/LC_MESSAGES/mailman.mo create mode 100644 messages/fi/LC_MESSAGES/mailman.po create mode 100644 messages/fi/README.fi create mode 100644 messages/fr/LC_MESSAGES/mailman.mo create mode 100644 messages/fr/LC_MESSAGES/mailman.po create mode 100644 messages/fr/README.fr create mode 100644 messages/hu/FAQ.hu create mode 100644 messages/hu/INSTALL.hu create mode 100644 messages/hu/LC_MESSAGES/mailman.mo create mode 100644 messages/hu/LC_MESSAGES/mailman.po create mode 100644 messages/hu/README.CONTRIB.hu create mode 100644 messages/hu/README.EXIM.hu create mode 100644 messages/hu/README.LINUX.hu create mode 100644 messages/hu/README.MACOSX.hu create mode 100644 messages/hu/README.NETSCAPE.hu create mode 100644 messages/hu/README.POSTFIX.hu create mode 100644 messages/hu/README.SENDMAIL.hu create mode 100644 messages/hu/README.USERAGENT.hu create mode 100644 messages/hu/README.hu create mode 100644 messages/hu/UPGRADING.hu create mode 100644 messages/it/LC_MESSAGES/mailman.mo create mode 100644 messages/it/LC_MESSAGES/mailman.po create mode 100644 messages/it/README.it create mode 100644 messages/ja/INSTALL create mode 100644 messages/ja/LC_MESSAGES/mailman.mo create mode 100644 messages/ja/LC_MESSAGES/mailman.po create mode 100644 messages/ja/README create mode 100644 messages/ja/README.ja create mode 100644 messages/ja/UPGRADING create mode 100644 messages/ko/LC_MESSAGES/mailman.mo create mode 100644 messages/ko/LC_MESSAGES/mailman.po create mode 100644 messages/ko/README.ko create mode 100644 messages/lt/LC_MESSAGES/mailman.mo create mode 100644 messages/lt/LC_MESSAGES/mailman.po create mode 100644 messages/mailman.pot create mode 100644 messages/nl/LC_MESSAGES/mailman.mo create mode 100644 messages/nl/LC_MESSAGES/mailman.po create mode 100644 messages/no/LC_MESSAGES/mailman.mo create mode 100644 messages/no/LC_MESSAGES/mailman.po create mode 100644 messages/pt_BR/LC_MESSAGES/mailman.mo create mode 100644 messages/pt_BR/LC_MESSAGES/mailman.po create mode 100644 messages/ru/LC_MESSAGES/mailman.mo create mode 100644 messages/ru/LC_MESSAGES/mailman.po create mode 100644 messages/ru/README.ru create mode 100644 messages/sv/LC_MESSAGES/mailman.mo create mode 100644 messages/sv/LC_MESSAGES/mailman.po create mode 100644 messages/sv/README.sv create mode 100644 misc/.cvsignore create mode 100644 misc/JapaneseCodecs-1.4.9.tar.gz create mode 100644 misc/KoreanCodecs-2.0.5.tar.gz create mode 100644 misc/Makefile.in create mode 100644 misc/PythonPowered.png create mode 100644 misc/email-2.4.3.tar.gz create mode 100644 misc/gnu-head-tiny.jpg create mode 100644 misc/mailman-large.jpg create mode 100644 misc/mailman.in create mode 100644 misc/mailman.jpg create mode 100644 misc/mm-icon.png create mode 100644 misc/paths.py.in create mode 100755 mkinstalldirs create mode 100644 scripts/.cvsignore create mode 100644 scripts/Makefile.in create mode 100644 scripts/bounces create mode 100755 scripts/confirm create mode 100644 scripts/driver create mode 100755 scripts/join create mode 100755 scripts/leave create mode 100755 scripts/owner create mode 100755 scripts/post create mode 100755 scripts/request create mode 100644 src/.cvsignore create mode 100644 src/Makefile.in create mode 100644 src/cgi-wrapper.c create mode 100644 src/common.c create mode 100644 src/common.h create mode 100644 src/mail-wrapper.c create mode 100644 src/vsnprintf.c create mode 100644 templates/.cvsignore create mode 100644 templates/Makefile.in create mode 100644 templates/big5/admindbpreamble.html create mode 100644 templates/big5/adminsubscribeack.txt create mode 100644 templates/big5/adminunsubscribeack.txt create mode 100644 templates/big5/admlogin.html create mode 100644 templates/big5/approve.txt create mode 100644 templates/big5/bounce.txt create mode 100644 templates/big5/checkdbs.txt create mode 100644 templates/big5/convert.txt create mode 100644 templates/big5/cronpass.txt create mode 100644 templates/big5/handle_opts.html create mode 100644 templates/big5/headfoot.html create mode 100644 templates/big5/help.txt create mode 100644 templates/big5/listinfo.html create mode 100644 templates/big5/masthead.txt create mode 100644 templates/big5/newlist.txt create mode 100644 templates/big5/options.html create mode 100644 templates/big5/postack.txt create mode 100644 templates/big5/postauth.txt create mode 100644 templates/big5/postheld.txt create mode 100644 templates/big5/refuse.txt create mode 100644 templates/big5/roster.html create mode 100644 templates/big5/subauth.txt create mode 100644 templates/big5/subscribe.html create mode 100644 templates/big5/subscribeack.txt create mode 100644 templates/big5/userpass.txt create mode 100644 templates/big5/verify.txt create mode 100644 templates/cs/admindbdetails.html create mode 100644 templates/cs/admindbpreamble.html create mode 100644 templates/cs/admindbsummary.html create mode 100644 templates/cs/adminsubscribeack.txt create mode 100644 templates/cs/adminunsubscribeack.txt create mode 100644 templates/cs/admlogin.html create mode 100644 templates/cs/approve.txt create mode 100644 templates/cs/article.html create mode 100644 templates/cs/bounce.txt create mode 100644 templates/cs/checkdbs.txt create mode 100644 templates/cs/convert.txt create mode 100644 templates/cs/cronpass.txt create mode 100644 templates/cs/disabled.txt create mode 100644 templates/cs/emptyarchive.html create mode 100644 templates/cs/headfoot.html create mode 100644 templates/cs/help.txt create mode 100644 templates/cs/listinfo.html create mode 100644 templates/cs/masthead.txt create mode 100644 templates/cs/newlist.txt create mode 100644 templates/cs/options.html create mode 100644 templates/cs/postack.txt create mode 100644 templates/cs/postauth.txt create mode 100644 templates/cs/postheld.txt create mode 100644 templates/cs/private.html create mode 100644 templates/cs/refuse.txt create mode 100644 templates/cs/roster.html create mode 100644 templates/cs/subauth.txt create mode 100644 templates/cs/subscribe.html create mode 100644 templates/cs/subscribeack.txt create mode 100644 templates/cs/unsub.txt create mode 100644 templates/cs/unsubauth.txt create mode 100644 templates/cs/userpass.txt create mode 100644 templates/cs/verify.txt create mode 100644 templates/de/admindbdetails.html create mode 100644 templates/de/admindbpreamble.html create mode 100644 templates/de/admindbsummary.html create mode 100644 templates/de/adminsubscribeack.txt create mode 100644 templates/de/adminunsubscribeack.txt create mode 100644 templates/de/admlogin.html create mode 100644 templates/de/approve.txt create mode 100644 templates/de/article.html create mode 100644 templates/de/bounce.txt create mode 100644 templates/de/checkdbs.txt create mode 100644 templates/de/convert.txt create mode 100644 templates/de/cronpass.txt create mode 100644 templates/de/disabled.txt create mode 100644 templates/de/headfoot.html create mode 100644 templates/de/help.txt create mode 100644 templates/de/invite.txt create mode 100644 templates/de/listinfo.html create mode 100644 templates/de/masthead.txt create mode 100644 templates/de/newlist.txt create mode 100644 templates/de/options.html create mode 100644 templates/de/postack.txt create mode 100644 templates/de/postauth.txt create mode 100644 templates/de/postheld.txt create mode 100644 templates/de/private.html create mode 100644 templates/de/refuse.txt create mode 100644 templates/de/roster.html create mode 100644 templates/de/subauth.txt create mode 100644 templates/de/subscribe.html create mode 100644 templates/de/subscribeack.txt create mode 100644 templates/de/unsub.txt create mode 100644 templates/de/userpass.txt create mode 100644 templates/de/verify.txt create mode 100644 templates/en/admindbdetails.html create mode 100644 templates/en/admindbpreamble.html create mode 100644 templates/en/admindbsummary.html create mode 100644 templates/en/adminsubscribeack.txt create mode 100644 templates/en/adminunsubscribeack.txt create mode 100644 templates/en/admlogin.html create mode 100644 templates/en/approve.txt create mode 100644 templates/en/archidxentry.html create mode 100644 templates/en/archidxfoot.html create mode 100644 templates/en/archidxhead.html create mode 100644 templates/en/archlistend.html create mode 100644 templates/en/archliststart.html create mode 100644 templates/en/archtoc.html create mode 100644 templates/en/archtocentry.html create mode 100644 templates/en/article.html create mode 100644 templates/en/bounce.txt create mode 100644 templates/en/checkdbs.txt create mode 100644 templates/en/convert.txt create mode 100644 templates/en/cronpass.txt create mode 100644 templates/en/disabled.txt create mode 100644 templates/en/emptyarchive.html create mode 100644 templates/en/headfoot.html create mode 100644 templates/en/help.txt create mode 100644 templates/en/invite.txt create mode 100644 templates/en/listinfo.html create mode 100644 templates/en/masthead.txt create mode 100644 templates/en/newlist.txt create mode 100644 templates/en/nomoretoday.txt create mode 100644 templates/en/options.html create mode 100644 templates/en/postack.txt create mode 100644 templates/en/postauth.txt create mode 100644 templates/en/postheld.txt create mode 100644 templates/en/private.html create mode 100644 templates/en/refuse.txt create mode 100644 templates/en/roster.html create mode 100644 templates/en/subauth.txt create mode 100644 templates/en/subscribe.html create mode 100644 templates/en/subscribeack.txt create mode 100644 templates/en/unsub.txt create mode 100644 templates/en/unsubauth.txt create mode 100644 templates/en/userpass.txt create mode 100644 templates/en/verify.txt create mode 100644 templates/es/admindbdetails.html create mode 100644 templates/es/admindbpreamble.html create mode 100644 templates/es/admindbsummary.html create mode 100644 templates/es/adminsubscribeack.txt create mode 100644 templates/es/adminunsubscribeack.txt create mode 100644 templates/es/admlogin.html create mode 100644 templates/es/approve.txt create mode 100644 templates/es/article.html create mode 100644 templates/es/bounce.txt create mode 100644 templates/es/checkdbs.txt create mode 100644 templates/es/convert.txt create mode 100644 templates/es/cronpass.txt create mode 100644 templates/es/disabled.txt create mode 100644 templates/es/emptyarchive.html create mode 100644 templates/es/handle_opts.html create mode 100644 templates/es/headfoot.html create mode 100644 templates/es/help.txt create mode 100644 templates/es/invite.txt create mode 100644 templates/es/listinfo.html create mode 100644 templates/es/masthead.txt create mode 100644 templates/es/newlist.txt create mode 100644 templates/es/nomoretoday.txt create mode 100644 templates/es/options.html create mode 100644 templates/es/postack.txt create mode 100644 templates/es/postauth.txt create mode 100644 templates/es/postheld.txt create mode 100644 templates/es/private.html create mode 100644 templates/es/refuse.txt create mode 100644 templates/es/roster.html create mode 100644 templates/es/subauth.txt create mode 100644 templates/es/subscribe.html create mode 100644 templates/es/subscribeack.txt create mode 100644 templates/es/unsub.txt create mode 100644 templates/es/unsubauth.txt create mode 100644 templates/es/userpass.txt create mode 100644 templates/es/verify.txt create mode 100644 templates/et/admindbdetails.html create mode 100644 templates/et/admindbpreamble.html create mode 100644 templates/et/admindbsummary.html create mode 100644 templates/et/adminsubscribeack.txt create mode 100644 templates/et/adminunsubscribeack.txt create mode 100644 templates/et/admlogin.html create mode 100644 templates/et/approve.txt create mode 100644 templates/et/article.html create mode 100644 templates/et/bounce.txt create mode 100644 templates/et/checkdbs.txt create mode 100644 templates/et/convert.txt create mode 100644 templates/et/cronpass.txt create mode 100644 templates/et/disabled.txt create mode 100644 templates/et/emptyarchive.html create mode 100644 templates/et/headfoot.html create mode 100644 templates/et/help.txt create mode 100644 templates/et/invite.txt create mode 100644 templates/et/listinfo.html create mode 100644 templates/et/masthead.txt create mode 100644 templates/et/newlist.txt create mode 100644 templates/et/options.html create mode 100644 templates/et/postack.txt create mode 100644 templates/et/postauth.txt create mode 100644 templates/et/postheld.txt create mode 100644 templates/et/private.html create mode 100644 templates/et/refuse.txt create mode 100644 templates/et/roster.html create mode 100644 templates/et/subauth.txt create mode 100644 templates/et/subscribe.html create mode 100644 templates/et/subscribeack.txt create mode 100644 templates/et/unsub.txt create mode 100644 templates/et/unsubauth.txt create mode 100644 templates/et/userpass.txt create mode 100644 templates/et/verify.txt create mode 100644 templates/fi/admindbdetails.html create mode 100644 templates/fi/admindbpreamble.html create mode 100644 templates/fi/admindbsummary.html create mode 100644 templates/fi/adminsubscribeack.txt create mode 100644 templates/fi/adminunsubscribeack.txt create mode 100644 templates/fi/admlogin.html create mode 100644 templates/fi/approve.txt create mode 100644 templates/fi/article.html create mode 100644 templates/fi/bounce.txt create mode 100644 templates/fi/checkdbs.txt create mode 100644 templates/fi/convert.txt create mode 100644 templates/fi/cronpass.txt create mode 100644 templates/fi/disabled.txt create mode 100644 templates/fi/headfoot.html create mode 100644 templates/fi/help.txt create mode 100644 templates/fi/listinfo.html create mode 100644 templates/fi/masthead.txt create mode 100644 templates/fi/newlist.txt create mode 100644 templates/fi/options.html create mode 100644 templates/fi/postack.txt create mode 100644 templates/fi/postauth.txt create mode 100644 templates/fi/postheld.txt create mode 100644 templates/fi/private.html create mode 100644 templates/fi/reenable.txt create mode 100644 templates/fi/refuse.txt create mode 100644 templates/fi/roster.html create mode 100644 templates/fi/subauth.txt create mode 100644 templates/fi/subscribe.html create mode 100644 templates/fi/subscribeack.txt create mode 100644 templates/fi/unsub.txt create mode 100644 templates/fi/unsubauth.txt create mode 100644 templates/fi/userpass.txt create mode 100644 templates/fi/verify.txt create mode 100644 templates/fr/admindbdetails.html create mode 100644 templates/fr/admindbpreamble.html create mode 100644 templates/fr/admindbsummary.html create mode 100644 templates/fr/adminsubscribeack.txt create mode 100644 templates/fr/adminunsubscribeack.txt create mode 100644 templates/fr/admlogin.html create mode 100644 templates/fr/approve.txt create mode 100644 templates/fr/archidxentry.html create mode 100644 templates/fr/archidxfoot.html create mode 100644 templates/fr/archidxhead.html create mode 100644 templates/fr/archlistend.html create mode 100644 templates/fr/archliststart.html create mode 100644 templates/fr/archtoc.html create mode 100644 templates/fr/archtocentry.html create mode 100644 templates/fr/article.html create mode 100644 templates/fr/bounce.txt create mode 100644 templates/fr/checkdbs.txt create mode 100644 templates/fr/convert.txt create mode 100644 templates/fr/cronpass.txt create mode 100644 templates/fr/disabled.txt create mode 100644 templates/fr/emptyarchive.html create mode 100644 templates/fr/handle_opts.html create mode 100644 templates/fr/headfoot.html create mode 100644 templates/fr/help.txt create mode 100644 templates/fr/invite.txt create mode 100644 templates/fr/listinfo.html create mode 100644 templates/fr/masthead.txt create mode 100644 templates/fr/newlist.txt create mode 100644 templates/fr/nomoretoday.txt create mode 100644 templates/fr/options.html create mode 100644 templates/fr/postack.txt create mode 100644 templates/fr/postauth.txt create mode 100644 templates/fr/postheld.txt create mode 100644 templates/fr/private.html create mode 100644 templates/fr/refuse.txt create mode 100644 templates/fr/roster.html create mode 100644 templates/fr/subauth.txt create mode 100644 templates/fr/subscribe.html create mode 100644 templates/fr/subscribeack.txt create mode 100644 templates/fr/unsub.txt create mode 100644 templates/fr/unsubauth.txt create mode 100644 templates/fr/userpass.txt create mode 100644 templates/fr/verify.txt create mode 100644 templates/gb/admindbpreamble.html create mode 100644 templates/gb/adminsubscribeack.txt create mode 100644 templates/gb/adminunsubscribeack.txt create mode 100644 templates/gb/admlogin.html create mode 100644 templates/gb/approve.txt create mode 100644 templates/gb/bounce.txt create mode 100644 templates/gb/checkdbs.txt create mode 100644 templates/gb/convert.txt create mode 100644 templates/gb/cronpass.txt create mode 100644 templates/gb/handle_opts.html create mode 100644 templates/gb/headfoot.html create mode 100644 templates/gb/help.txt create mode 100644 templates/gb/listinfo.html create mode 100644 templates/gb/masthead.txt create mode 100644 templates/gb/newlist.txt create mode 100644 templates/gb/options.html create mode 100644 templates/gb/postack.txt create mode 100644 templates/gb/postauth.txt create mode 100644 templates/gb/postheld.txt create mode 100644 templates/gb/refuse.txt create mode 100644 templates/gb/roster.html create mode 100644 templates/gb/subauth.txt create mode 100644 templates/gb/subscribe.html create mode 100644 templates/gb/subscribeack.txt create mode 100644 templates/gb/userpass.txt create mode 100644 templates/gb/verify.txt create mode 100644 templates/hu/admindbdetails.html create mode 100644 templates/hu/admindbpreamble.html create mode 100644 templates/hu/admindbsummary.html create mode 100644 templates/hu/adminsubscribeack.txt create mode 100644 templates/hu/adminunsubscribeack.txt create mode 100644 templates/hu/admlogin.html create mode 100644 templates/hu/approve.txt create mode 100644 templates/hu/archidxentry.html create mode 100644 templates/hu/archidxfoot.html create mode 100644 templates/hu/archidxhead.html create mode 100644 templates/hu/archlistend.html create mode 100644 templates/hu/archliststart.html create mode 100644 templates/hu/archtoc.html create mode 100644 templates/hu/archtocentry.html create mode 100644 templates/hu/article.html create mode 100644 templates/hu/bounce.txt create mode 100644 templates/hu/checkdbs.txt create mode 100644 templates/hu/convert.txt create mode 100644 templates/hu/cronpass.txt create mode 100644 templates/hu/disabled.txt create mode 100644 templates/hu/emptyarchive.html create mode 100644 templates/hu/headfoot.html create mode 100644 templates/hu/help.txt create mode 100644 templates/hu/illik.html create mode 100644 templates/hu/invite.txt create mode 100644 templates/hu/listinfo.html create mode 100644 templates/hu/masthead.txt create mode 100644 templates/hu/newlist.txt create mode 100644 templates/hu/nomoretoday.txt create mode 100644 templates/hu/options.html create mode 100644 templates/hu/postack.txt create mode 100644 templates/hu/postauth.txt create mode 100644 templates/hu/postheld.txt create mode 100644 templates/hu/private.html create mode 100644 templates/hu/refuse.txt create mode 100644 templates/hu/roster.html create mode 100644 templates/hu/subauth.txt create mode 100644 templates/hu/subscribe.html create mode 100644 templates/hu/subscribeack.txt create mode 100644 templates/hu/unsub.txt create mode 100644 templates/hu/unsubauth.txt create mode 100644 templates/hu/userpass.txt create mode 100644 templates/hu/verify.txt create mode 100644 templates/it/admindbdetails.html create mode 100644 templates/it/admindbpreamble.html create mode 100644 templates/it/admindbsummary.html create mode 100644 templates/it/adminsubscribeack.txt create mode 100644 templates/it/adminunsubscribeack.txt create mode 100644 templates/it/admlogin.html create mode 100644 templates/it/approve.txt create mode 100644 templates/it/archidxentry.html create mode 100644 templates/it/archidxfoot.html create mode 100644 templates/it/archidxhead.html create mode 100644 templates/it/archlistend.html create mode 100644 templates/it/archliststart.html create mode 100644 templates/it/archtoc.html create mode 100644 templates/it/archtocentry.html create mode 100644 templates/it/article.html create mode 100644 templates/it/bounce.txt create mode 100644 templates/it/checkdbs.txt create mode 100644 templates/it/convert.txt create mode 100644 templates/it/cronpass.txt create mode 100644 templates/it/disabled.txt create mode 100644 templates/it/emptyarchive.html create mode 100644 templates/it/headfoot.html create mode 100644 templates/it/help.txt create mode 100644 templates/it/invite.txt create mode 100644 templates/it/listinfo.html create mode 100644 templates/it/masthead.txt create mode 100644 templates/it/newlist.txt create mode 100644 templates/it/nomoretoday.txt create mode 100644 templates/it/options.html create mode 100644 templates/it/postack.txt create mode 100644 templates/it/postauth.txt create mode 100644 templates/it/postheld.txt create mode 100644 templates/it/private.html create mode 100644 templates/it/refuse.txt create mode 100644 templates/it/roster.html create mode 100644 templates/it/subauth.txt create mode 100644 templates/it/subscribe.html create mode 100644 templates/it/subscribeack.txt create mode 100644 templates/it/unsub.txt create mode 100644 templates/it/unsubauth.txt create mode 100644 templates/it/userpass.txt create mode 100644 templates/it/verify.txt create mode 100644 templates/ja/admindbdetails.html create mode 100644 templates/ja/admindbpreamble.html create mode 100644 templates/ja/admindbsummary.html create mode 100644 templates/ja/adminsubscribeack.txt create mode 100644 templates/ja/adminunsubscribeack.txt create mode 100644 templates/ja/admlogin.html create mode 100644 templates/ja/approve.txt create mode 100644 templates/ja/archidxentry.html create mode 100644 templates/ja/archidxfoot.html create mode 100644 templates/ja/archidxhead.html create mode 100644 templates/ja/archlistend.html create mode 100644 templates/ja/archliststart.html create mode 100644 templates/ja/archtoc.html create mode 100644 templates/ja/archtocentry.html create mode 100644 templates/ja/article.html create mode 100644 templates/ja/bounce.txt create mode 100644 templates/ja/checkdbs.txt create mode 100644 templates/ja/convert.txt create mode 100644 templates/ja/cronpass.txt create mode 100644 templates/ja/disabled.txt create mode 100644 templates/ja/emptyarchive.html create mode 100644 templates/ja/headfoot.html create mode 100644 templates/ja/help.txt create mode 100644 templates/ja/invite.txt create mode 100644 templates/ja/listinfo.html create mode 100644 templates/ja/masthead.txt create mode 100644 templates/ja/newlist.txt create mode 100644 templates/ja/nomoretoday.txt create mode 100644 templates/ja/options.html create mode 100644 templates/ja/postack.txt create mode 100644 templates/ja/postauth.txt create mode 100644 templates/ja/postheld.txt create mode 100644 templates/ja/private.html create mode 100644 templates/ja/refuse.txt create mode 100644 templates/ja/roster.html create mode 100644 templates/ja/subauth.txt create mode 100644 templates/ja/subscribe.html create mode 100644 templates/ja/subscribeack.txt create mode 100644 templates/ja/unsub.txt create mode 100644 templates/ja/unsubauth.txt create mode 100644 templates/ja/userpass.txt create mode 100644 templates/ja/verify.txt create mode 100644 templates/ko/admindbdetails.html create mode 100644 templates/ko/admindbpreamble.html create mode 100644 templates/ko/admindbsummary.html create mode 100644 templates/ko/adminsubscribeack.txt create mode 100644 templates/ko/adminunsubscribeack.txt create mode 100644 templates/ko/admlogin.html create mode 100644 templates/ko/approve.txt create mode 100644 templates/ko/article.html create mode 100644 templates/ko/bounce.txt create mode 100644 templates/ko/checkdbs.txt create mode 100644 templates/ko/convert.txt create mode 100644 templates/ko/cronpass.txt create mode 100644 templates/ko/disabled.txt create mode 100644 templates/ko/emptyarchive.html create mode 100644 templates/ko/headfoot.html create mode 100644 templates/ko/help.txt create mode 100644 templates/ko/invite.txt create mode 100644 templates/ko/listinfo.html create mode 100644 templates/ko/masthead.txt create mode 100644 templates/ko/newlist.txt create mode 100644 templates/ko/options.html create mode 100644 templates/ko/postack.txt create mode 100644 templates/ko/postauth.txt create mode 100644 templates/ko/postheld.txt create mode 100644 templates/ko/private.html create mode 100644 templates/ko/refuse.txt create mode 100644 templates/ko/roster.html create mode 100644 templates/ko/subauth.txt create mode 100644 templates/ko/subscribe.html create mode 100644 templates/ko/subscribeack.txt create mode 100644 templates/ko/unsub.txt create mode 100644 templates/ko/unsubauth.txt create mode 100644 templates/ko/userpass.txt create mode 100644 templates/ko/verify.txt create mode 100644 templates/lt/admindbdetails.html create mode 100644 templates/lt/admindbpreamble.html create mode 100644 templates/lt/admindbsummary.html create mode 100644 templates/lt/adminsubscribeack.txt create mode 100644 templates/lt/adminunsubscribeack.txt create mode 100644 templates/lt/admlogin.html create mode 100644 templates/lt/approve.txt create mode 100644 templates/lt/archidxentry.html create mode 100644 templates/lt/archidxfoot.html create mode 100644 templates/lt/archidxhead.html create mode 100644 templates/lt/archlistend.html create mode 100644 templates/lt/archliststart.html create mode 100644 templates/lt/archtoc.html create mode 100644 templates/lt/archtocentry.html create mode 100644 templates/lt/article.html create mode 100644 templates/lt/bounce.txt create mode 100644 templates/lt/checkdbs.txt create mode 100644 templates/lt/convert.txt create mode 100644 templates/lt/cronpass.txt create mode 100644 templates/lt/disabled.txt create mode 100644 templates/lt/emptyarchive.html create mode 100644 templates/lt/headfoot.html create mode 100644 templates/lt/help.txt create mode 100644 templates/lt/invite.txt create mode 100644 templates/lt/listinfo.html create mode 100644 templates/lt/masthead.txt create mode 100644 templates/lt/newlist.txt create mode 100644 templates/lt/nomoretoday.txt create mode 100644 templates/lt/options.html create mode 100644 templates/lt/postack.txt create mode 100644 templates/lt/postauth.txt create mode 100644 templates/lt/postheld.txt create mode 100644 templates/lt/private.html create mode 100644 templates/lt/refuse.txt create mode 100644 templates/lt/roster.html create mode 100644 templates/lt/subauth.txt create mode 100644 templates/lt/subscribe.html create mode 100644 templates/lt/subscribeack.txt create mode 100644 templates/lt/unsub.txt create mode 100644 templates/lt/unsubauth.txt create mode 100644 templates/lt/userpass.txt create mode 100644 templates/lt/verify.txt create mode 100644 templates/nl/admindbdetails.html create mode 100644 templates/nl/admindbpreamble.html create mode 100644 templates/nl/admindbsummary.html create mode 100644 templates/nl/adminsubscribeack.txt create mode 100644 templates/nl/adminunsubscribeack.txt create mode 100644 templates/nl/admlogin.html create mode 100644 templates/nl/approve.txt create mode 100644 templates/nl/article.html create mode 100644 templates/nl/bounce.txt create mode 100644 templates/nl/checkdbs.txt create mode 100644 templates/nl/convert.txt create mode 100644 templates/nl/cronpass.txt create mode 100644 templates/nl/disabled.txt create mode 100644 templates/nl/emptyarchive.html create mode 100644 templates/nl/headfoot.html create mode 100644 templates/nl/help.txt create mode 100644 templates/nl/invite.txt create mode 100644 templates/nl/listinfo.html create mode 100644 templates/nl/masthead.txt create mode 100644 templates/nl/newlist.txt create mode 100644 templates/nl/options.html create mode 100644 templates/nl/postack.txt create mode 100644 templates/nl/postauth.txt create mode 100644 templates/nl/postheld.txt create mode 100644 templates/nl/private.html create mode 100644 templates/nl/refuse.txt create mode 100644 templates/nl/roster.html create mode 100644 templates/nl/subauth.txt create mode 100644 templates/nl/subscribe.html create mode 100644 templates/nl/subscribeack.txt create mode 100644 templates/nl/unsub.txt create mode 100644 templates/nl/unsubauth.txt create mode 100644 templates/nl/userpass.txt create mode 100644 templates/nl/verify.txt create mode 100644 templates/no/admindbdetails.html create mode 100644 templates/no/admindbpreamble.html create mode 100644 templates/no/admindbsummary.html create mode 100644 templates/no/adminsubscribeack.txt create mode 100644 templates/no/adminunsubscribeack.txt create mode 100644 templates/no/admlogin.html create mode 100644 templates/no/approve.txt create mode 100644 templates/no/archidxfoot.html create mode 100644 templates/no/archidxhead.html create mode 100644 templates/no/archliststart.html create mode 100644 templates/no/archtoc.html create mode 100644 templates/no/archtocentry.html create mode 100644 templates/no/article.html create mode 100644 templates/no/bounce.txt create mode 100644 templates/no/checkdbs.txt create mode 100644 templates/no/convert.txt create mode 100644 templates/no/cronpass.txt create mode 100644 templates/no/disabled.txt create mode 100644 templates/no/emptyarchive.html create mode 100644 templates/no/headfoot.html create mode 100644 templates/no/help.txt create mode 100644 templates/no/invite.txt create mode 100644 templates/no/listinfo.html create mode 100644 templates/no/masthead.txt create mode 100644 templates/no/newlist.txt create mode 100644 templates/no/nomoretoday.txt create mode 100644 templates/no/options.html create mode 100644 templates/no/postack.txt create mode 100644 templates/no/postauth.txt create mode 100644 templates/no/postheld.txt create mode 100644 templates/no/private.html create mode 100644 templates/no/refuse.txt create mode 100644 templates/no/roster.html create mode 100644 templates/no/subauth.txt create mode 100644 templates/no/subscribe.html create mode 100644 templates/no/subscribeack.txt create mode 100644 templates/no/unsub.txt create mode 100644 templates/no/unsubauth.txt create mode 100644 templates/no/userpass.txt create mode 100644 templates/no/verify.txt create mode 100644 templates/pt_BR/admindbdetails.html create mode 100644 templates/pt_BR/admindbpreamble.html create mode 100644 templates/pt_BR/admindbsummary.html create mode 100644 templates/pt_BR/adminsubscribeack.txt create mode 100644 templates/pt_BR/adminunsubscribeack.txt create mode 100644 templates/pt_BR/admlogin.html create mode 100644 templates/pt_BR/admlogin.txt create mode 100644 templates/pt_BR/approve.txt create mode 100644 templates/pt_BR/article.html create mode 100644 templates/pt_BR/bounce.txt create mode 100644 templates/pt_BR/checkdbs.txt create mode 100644 templates/pt_BR/convert.txt create mode 100644 templates/pt_BR/cronpass.txt create mode 100644 templates/pt_BR/disabled.txt create mode 100644 templates/pt_BR/emptyarchive.html create mode 100644 templates/pt_BR/headfoot.html create mode 100644 templates/pt_BR/help.txt create mode 100644 templates/pt_BR/invite.txt create mode 100644 templates/pt_BR/listinfo.html create mode 100644 templates/pt_BR/masthead.txt create mode 100644 templates/pt_BR/newlist.txt create mode 100644 templates/pt_BR/options.html create mode 100644 templates/pt_BR/postack.txt create mode 100644 templates/pt_BR/postauth.txt create mode 100644 templates/pt_BR/postheld.txt create mode 100644 templates/pt_BR/private.html create mode 100644 templates/pt_BR/refuse.txt create mode 100644 templates/pt_BR/roster.html create mode 100644 templates/pt_BR/subauth.txt create mode 100644 templates/pt_BR/subscribe.html create mode 100644 templates/pt_BR/subscribeack.txt create mode 100644 templates/pt_BR/unsub.txt create mode 100644 templates/pt_BR/unsubauth.txt create mode 100644 templates/pt_BR/userpass.txt create mode 100644 templates/pt_BR/verify.txt create mode 100644 templates/ru/Makefile create mode 100644 templates/ru/admindbdetails.html create mode 100644 templates/ru/admindbpreamble.html create mode 100644 templates/ru/admindbsummary.html create mode 100644 templates/ru/adminsubscribeack.txt create mode 100644 templates/ru/adminunsubscribeack.txt create mode 100644 templates/ru/admlogin.html create mode 100644 templates/ru/approve.txt create mode 100644 templates/ru/archidxentry.html create mode 100644 templates/ru/archidxfoot.html create mode 100644 templates/ru/archidxhead.html create mode 100644 templates/ru/archlistend.html create mode 100644 templates/ru/archliststart.html create mode 100644 templates/ru/archtoc.html create mode 100644 templates/ru/archtocentry.html create mode 100644 templates/ru/article.html create mode 100644 templates/ru/bounce.txt create mode 100644 templates/ru/checkdbs.txt create mode 100644 templates/ru/convert.txt create mode 100644 templates/ru/cronpass.txt create mode 100644 templates/ru/disabled.txt create mode 100644 templates/ru/emptyarchive.html create mode 100644 templates/ru/headfoot.html create mode 100644 templates/ru/help.txt create mode 100644 templates/ru/invite.txt create mode 100644 templates/ru/listinfo.html create mode 100644 templates/ru/masthead.txt create mode 100644 templates/ru/newlist.txt create mode 100644 templates/ru/nomoretoday.txt create mode 100644 templates/ru/options.html create mode 100644 templates/ru/postack.txt create mode 100644 templates/ru/postauth.txt create mode 100644 templates/ru/postheld.txt create mode 100644 templates/ru/private.html create mode 100644 templates/ru/refuse.txt create mode 100644 templates/ru/roster.html create mode 100755 templates/ru/s2s.py create mode 100644 templates/ru/status create mode 100644 templates/ru/subauth.txt create mode 100644 templates/ru/subscribe.html create mode 100644 templates/ru/subscribeack.txt create mode 100644 templates/ru/unsub.txt create mode 100644 templates/ru/unsubauth.txt create mode 100644 templates/ru/userpass.txt create mode 100644 templates/ru/verify.txt create mode 100644 templates/sv/admindbdetails.html create mode 100644 templates/sv/admindbpreamble.html create mode 100644 templates/sv/admindbsummary.html create mode 100644 templates/sv/adminsubscribeack.txt create mode 100644 templates/sv/adminunsubscribeack.txt create mode 100644 templates/sv/admlogin.html create mode 100644 templates/sv/approve.txt create mode 100644 templates/sv/archtoc.html create mode 100644 templates/sv/archtocentry.html create mode 100644 templates/sv/article.html create mode 100644 templates/sv/bounce.txt create mode 100644 templates/sv/checkdbs.txt create mode 100644 templates/sv/convert.txt create mode 100644 templates/sv/cronpass.txt create mode 100644 templates/sv/disabled.txt create mode 100644 templates/sv/emptyarchive.html create mode 100644 templates/sv/headfoot.html create mode 100644 templates/sv/help.txt create mode 100644 templates/sv/invite.txt create mode 100644 templates/sv/listinfo.html create mode 100644 templates/sv/masthead.txt create mode 100644 templates/sv/newlist.txt create mode 100644 templates/sv/nomoretoday.txt create mode 100644 templates/sv/options.html create mode 100644 templates/sv/postack.txt create mode 100644 templates/sv/postauth.txt create mode 100644 templates/sv/postheld.txt create mode 100644 templates/sv/private.html create mode 100644 templates/sv/refuse.txt create mode 100644 templates/sv/roster.html create mode 100644 templates/sv/subauth.txt create mode 100644 templates/sv/subscribe.html create mode 100644 templates/sv/subscribeack.txt create mode 100644 templates/sv/unsub.txt create mode 100644 templates/sv/unsubauth.txt create mode 100644 templates/sv/userpass.txt create mode 100644 templates/sv/verify.txt create mode 100644 tests/.cvsignore create mode 100644 tests/EmailBase.py create mode 100644 tests/Makefile.in create mode 100644 tests/TestBase.py create mode 100644 tests/bounces/.cvsignore create mode 100644 tests/bounces/Makefile.in create mode 100644 tests/bounces/bounce_01.txt create mode 100644 tests/bounces/bounce_02.txt create mode 100644 tests/bounces/bounce_03.txt create mode 100644 tests/bounces/dsn_01.txt create mode 100644 tests/bounces/dsn_02.txt create mode 100644 tests/bounces/dsn_03.txt create mode 100644 tests/bounces/dsn_04.txt create mode 100644 tests/bounces/dsn_05.txt create mode 100644 tests/bounces/dsn_06.txt create mode 100644 tests/bounces/dsn_07.txt create mode 100644 tests/bounces/dsn_08.txt create mode 100644 tests/bounces/dsn_09.txt create mode 100644 tests/bounces/dsn_10.txt create mode 100644 tests/bounces/dsn_11.txt create mode 100644 tests/bounces/dumbass_01.txt create mode 100644 tests/bounces/exim_01.txt create mode 100644 tests/bounces/groupwise_01.txt create mode 100644 tests/bounces/groupwise_02.txt create mode 100644 tests/bounces/hotpop_01.txt create mode 100644 tests/bounces/llnl_01.txt create mode 100644 tests/bounces/microsoft_01.txt create mode 100644 tests/bounces/microsoft_02.txt create mode 100644 tests/bounces/netscape_01.txt create mode 100644 tests/bounces/newmailru_01.txt create mode 100644 tests/bounces/postfix_01.txt create mode 100644 tests/bounces/postfix_02.txt create mode 100644 tests/bounces/postfix_03.txt create mode 100644 tests/bounces/postfix_04.txt create mode 100644 tests/bounces/postfix_05.txt create mode 100644 tests/bounces/qmail_01.txt create mode 100644 tests/bounces/sendmail_01.txt create mode 100644 tests/bounces/simple_01.txt create mode 100644 tests/bounces/simple_02.txt create mode 100644 tests/bounces/simple_03.txt create mode 100644 tests/bounces/simple_04.txt create mode 100644 tests/bounces/sina_01.txt create mode 100644 tests/bounces/smtp32_01.txt create mode 100644 tests/bounces/smtp32_02.txt create mode 100644 tests/bounces/smtp32_03.txt create mode 100644 tests/bounces/yahoo_01.txt create mode 100644 tests/bounces/yahoo_02.txt create mode 100644 tests/bounces/yahoo_03.txt create mode 100644 tests/bounces/yahoo_04.txt create mode 100644 tests/bounces/yahoo_05.txt create mode 100644 tests/bounces/yahoo_06.txt create mode 100644 tests/bounces/yahoo_07.txt create mode 100644 tests/bounces/yahoo_08.txt create mode 100644 tests/bounces/yahoo_09.txt create mode 100644 tests/bounces/yale_01.txt create mode 100644 tests/fblast.py create mode 100644 tests/msgs/.cvsignore create mode 100644 tests/msgs/Makefile.in create mode 100644 tests/msgs/bad_01.txt create mode 100644 tests/msgs/nimda.txt create mode 100755 tests/onebounce.py create mode 100644 tests/test_bounces.py create mode 100644 tests/test_handlers.py create mode 100644 tests/test_lockfile.py create mode 100644 tests/test_membership.py create mode 100644 tests/test_message.py create mode 100644 tests/test_runners.py create mode 100644 tests/test_safedict.py create mode 100644 tests/test_security_mgr.py create mode 100644 tests/testall.py diff --git a/.cvsignore b/.cvsignore new file mode 100644 index 00000000..d55a6906 --- /dev/null +++ b/.cvsignore @@ -0,0 +1,7 @@ +adm.pw +config.log +config.cache +config.status +Makefile +update.log +build diff --git a/ACKNOWLEDGMENTS b/ACKNOWLEDGMENTS new file mode 100644 index 00000000..d9faed5d --- /dev/null +++ b/ACKNOWLEDGMENTS @@ -0,0 +1,176 @@ +Mailman - The GNU Mailing List Management System +Copyright (C) 1998,1999,2000,2001,2002 by the Free Software Foundation, Inc. +59 Temple Place - Suite 330, Boston, MA 02111-1307, USA + +The following folks are or have been the primary maintainers of Mailman: + + Barry Warsaw, Mailman's yappy guard dog + Thomas Wouters, Mailman's Dutch treat + John Viega, Mailman's inventor + Harald Meland, Norse Mailman + Ken Manheimer, Mailman's savior + Scott Cotton, Cookie-Monster + +They can be contacted directly via mailman-cabal@python.org. + +Here is the list of other contributors who have donated large bits of +code, and have assigned copyright for contributions to the FSF: + + Juan Carlos Rey Anaya + Norbert Bollow + Ben Gertzfield + Victoriano Giralt + Mads Kiilerich + The Dragon De Monsyne + Les Niles + Terri Oda + Simone Piunno + +Thanks also to Dragon for his winning Mailman logo contribution, and +to Terri Oda for the neat shortcut icon. + +Control.com sponsored development of several Mailman 2.1 features, +including topics filters, external membership sources, and initial +virtual mailing list support. My thanks especially to Dan Pierson and +Ken Crater from Control.com. + +Here is the list of other people who have contributed useful ideas, +suggestions, bug fixes, testing, etc., or who have been very helpful +in answering questions on mailman-users. + + David Abrahams + William Ahern + Sven Anderson + Matthias Andree + Stonewall Ballard + Richard Barrett + Jeff Berliner + Alessio Bragadini + J. D. Bronson + Stan Bubrouski + Daniel Buchmann + Ben Burnett + Ted Cabeen + Mentor Cana + John Carnes + Donn Cave + David Champion + Hye-Shik Chang + Paul Cox + Emilio Delgado + Stefan Divjak + Maximillian Dornseif + Fred Drake + Rob Ellis + Fil + Patrick Finnerty + Erik Forsberg + Darrell Fuhriman + Carson Gaspar + Pascal GEORGE + David Gibbs + Terry Grace + Federico Grau + Pekka Haavisto + David Habben + Stig Hackvan + Jeff Hahn + Terry Hardie + Paul Hebble + Tollef Fog Heen + Peer Heinlein + Henny Huisman + Jeremy Hylton + Ron Jarrell + Matthias Juchem + Tamito KAJIYAMA + Tokio Kikuchi + Ashley M. Kirchner + Matthias Klose + Harald Koch + Chris Kolar + Andrew Kuchling + Ricardo Kustner + L'homme Moderne + J C Lawrence + Greg Lindahl + Christopher P. Lindsey + Martin von Loewis + Dario Lopez-Kästen + Tanner Lovelace + Jay Luker + Gergely Madarasz + Luca Maranzano + John A. Martin + Michael Mclay + Michael Meltzer + Marc MERLIN + Nigel Metheringham + Dan Mick + Garey Mills + Erik Myllymaki + Balazs Nagy + Dale Newfield + Hrvoje Niksic + Les Niles + Mike Noyes + David B. O'Donnell + Timothy O'Malley + Jason R. Mastaler + Michael Meltzer + "office" + Dan Ohnesorg + Gerald Oskoboiny + Eva Österlind + Jon Parise + Tim Peters + PieterB + Rodolfo Pilas + Skye Poier + Don Porter + Bob Puff + John Read + Sean Reifschneider + Christian Reis + Bernhard Reiter + Stephan Richter + Tristan Roddis + Heiko Rommel + Guido van Rossum + Nicholas Russo + Chris Ryan + Cabel Sasser + Gleydson Mazioli da Silva + Chris Snell + Mikhail Sobolev + Greg Stein + Dale Stimson + Szabolcs Szigeti + Vizi Szilard + David T-G + Owen Taylor + Danny Terweij + Jim Tittsler + Chuq Von Rospach + Jens Vagelpohl + Anti Veeranna + Todd Vierling + Bill Wagner + Greg Ward + Mark Weaver + Kathleen Webb + Ousmane Wilane + Dan Wilder + Michael Yount + Blair Zajac + Noam Zeilberger + +And everyone else on mailman-developers@python.org and +mailman-users@python.org! Thank you, all. + + + +Local Variables: +mode: indented-text +indent-tabs-mode: nil +End: diff --git a/BUGS b/BUGS new file mode 100644 index 00000000..7a22d4c2 --- /dev/null +++ b/BUGS @@ -0,0 +1,24 @@ +Mailman - The GNU Mailing List Management System +Copyright (C) 1998,1999,2000,2001,2002 by the Free Software Foundation, Inc. +59 Temple Place - Suite 330, Boston, MA 02111-1307, USA + +The Mailman project is being managed on SourceForge at + + http://sf.net/projects/mailman + +You should submit bugs to the SourceForge bug manager at + + http://sf.net/bugs/?group_id=103 + +If you're able, please also submit a patch to the SourceForge patch +manager, taking care to reference your bug report (which should be +submitted first). The patch manager is at + + http://sf.net/patch/?group_id=103 + + + +Local Variables: +mode: indented-text +indent-tabs-mode: nil +End: diff --git a/FAQ b/FAQ new file mode 100644 index 00000000..291977b9 --- /dev/null +++ b/FAQ @@ -0,0 +1,394 @@ +Mailman - The GNU Mailing List Management System +Copyright (C) 1998,1999,2000,2001,2002 by the Free Software Foundation, Inc. +59 Temple Place - Suite 330, Boston, MA 02111-1307, USA + +Note: We're migrating the FAQ to an on-line interactive system called + "FAQ Wizard". To see the Mailman FAQ Wizard in action, go to + http://www.python.org/cgi-bin/faqw-mm.py + +FREQUENTLY ASKED QUESTIONS + +Q. How do you spell this program? + +A. You spell it "Mailman", with a leading capital "M" and a lowercase + second "m". It is incorrect to spell it "MailMan" (i.e. you should + not use StudlyCaps). + +Q. I'm getting really terrible performance for outgoing messages. It + seems that if the MTA has trouble resolving DNS for any recipients, + qrunner just gets really slow clearing the queue. Any ideas? + +A. What's likely happening is that your MTA is doing DNS resolution on + recipients for messages delivered locally (i.e. from Mailman to + your MTA via SMTPDirect.py). This is a Bad Thing. You need to + turn off synchronous DNS resolution for messages originating from + the local host. + + In Exim, the value to edit is receiver_verify_hosts. See + README.EXIM for details. Other MTAs have (of course) different + parameters and defaults that control this. First check the README + file for your MTA and then consult your MTA's own documentation. + +Q. My list members are complaining about Mailman's List-* headers! + What can I do about this? + +A. These headers are described in RFC 2369 and are added by Mailman + for the long-term benefit of end-users. While discouraged, the + list admin can disable these via the General Options page. See + also README.USERAGENT for more information. + +Q. Can I put the user's address in the footer that Mailman adds to + each message? + +A. Yes, in Mailman 2.1. The site admin needs to enable + personalization by setting the following variables in the mm_cfg.py + file: + + VERP_PASSWORD_REMINDERS = 1 + VERP_PERSONALIZED_DELIVERIES = 1 + VERP_DELIVERY_INTERVAL = 1 + VERP_CONFIRMATIONS = 1 + + Once this is done, list admins can enable personalization for + regular delivery members (digest deliveries can't be + personalized currently). A personalized list can include the + user's address in the footer. + +Q. My users hate HTML in their email and for security reasons, I want + to strip out all MIME attachments. How can I do this? + +A. Mailman 2.1 has this feature built-in. See the Content Filtering + Options page in the admin interface. + +Q. What if I get "document contains no data" from the web server, or + mail isn't getting delivered, or I see "Premature end of script + headers" or "Mailman CGI error!!!" + +A. The most likely cause of this is that the GID that is compiled into + the C wrappers does not match the GID that your Web server invokes + CGI scripts with. Note that a similar error could occur if your + mail system invokes filter programs under a GID that does not match + the one compiled into the C mail wrapper. + + To fix this you will need to re-configure Mailman using the + --with-cgi-gid and --with-mail-gid options. See the INSTALL file + for details. + + These errors are logged to syslog and they do not show up in the + Mailman log files. Problems with the CGI wrapper do get reported + in the web browser though (unless STEALTH_MODE is enabled), and + include the expected GID, so that should help a lot. + + You may want to have syslog running and configured to log the + mail.error log class somewhere; on Solaris systems, the line + + mail.debug /var/log/syslog + + causes the messages to go to them in /var/log/syslog, for example. + (The distributed syslog.conf forwards the message to the loghost, + when present. See the syslog man page for more details.) + + If your system is set like this, and you get a failure trying to + visit the mailman/listinfo web page, and it's due to a UID or GID + mismatch, then you should get an entry at the end of + /var/log/syslog identifying the expected and received values. + + If you are not getting any log messages in syslog, or in Mailman's + own log files, but messages are still not being delivered, then it + is likely that qrunner is not running (qrunner is the process that + handles all mail in the system). In Mailman 2.0, qrunner was + invoked from cron so make sure your crontab entries for the + `mailman' user have been installed. In Mailman 2.1, qrunner is + started with the bin/mailmanctl script, which can be invoked + manually, or merged with your OS's init scripts. + +Q. What should I check periodically? + +A. Many of the scripts have their standard error logged to + $prefix/logs/error, and some of the modules write caught errors + there, as well, so you should check there at least occasionally to + look for bugs in the code and problems in your setup. + + You may want to periodically check the other log files in the logs/ + directory, perhaps occasionally rotating them with something like + the Linux logrotate script. + +Q. I can't access the public archives. Why? + +A. If you are using Apache, you must make sure that FollowSymLinks is + enabled for the path to the public archives. Note that the actual + archives always reside in the private tree, and only when archives + are public, is the symlink followed. See this archive message for + more details: + + http://mail.python.org/pipermail/mailman-users/1998-November/000150.html + +Q. Still having problems? Running QMail? + +A. Make sure that you are using "preline" before calling the "mailman" + wrapper: + + |preline /home/mailman/mail/mailman post listname + + "preline" adds a Unix-style "From " header which the archiver requires. + You can fix the archive mbox files by adding: + + From somebody Mon Oct 9 12:27:34 MDT 2000 + + before every message and re-running the archive command + "bin/arch listname". The archives should now exist. See README.QMAIL + for more information. + +Q. Still having problems? Running on GNU/Linux? + +A. See the README.LINUX file. + +Q. I want to get rid of some messages in my archive. How do I do + this? + +A. David Rocher posts the following recipe: + + * remove $prefix/archives/private/ + * edit $prefix/archives/private/.mbox/.mbox [optional] + * run $prefix/bin/arch + +Q. How secure are the authentication mechanisms used in Mailman's web + interface? + +A. If your Mailman installation run on an SSL-enabled web server + (i.e. you access the Mailman web pages with "https://..." URLs), + you should be as safe as SSL itself is. + + However, most Mailman installation run under standard, + encryption-unaware servers. There's nothing wrong with that for + most applications, but a sufficiently determined cracker *could* + get unauthorized access by: + + * Packet sniffing: The password used to do the initial + authentication for any non-public Mailman page is sent as clear + text over the net. If you consider this to be a big problem, you + really should use an SSL-enabled server. + + * Stealing a valid cookie: After successful password + authentication, Mailman sends a "cookie" back to the user's + browser. This cookie will be used for "automatic" authentication + when browsing further within the list's protected pages. Mailman + employs "session cookies" which are set until you quit your + browser or explicitly log out. + + Gaining access to the user's cookie (e.g. by being able to read + the user's browser cookie database, or by means of packet + sniffing, or maybe even by some broken browser offering all it's + cookies to any and all sites the user accesses), and at the same + time being able to fulfill the other criteria for using the + cookie could result in unauthorized access. + + Note that this problem is more easily exploited when users browse + the web via proxies -- in that case, the cookie would be valid + for any connections made through that proxy, and not just for + connections made from the particular machine the user happens to + be accessing the proxy from. + + * Getting access to the user's terminal: This is really just + another kind of cookie stealing. The short cookie expiration + time is supposed to help defeat this problem. It can be + considered the price to pay for the convenience of not having to + type the password in every time. + +Q. I want to backup my lists. What do I need to save? + +A. See this FAQ wizard entry: + http://www.python.org/cgi-bin/faqw-mm.py?req=show&file=faq04.006.htp + +Q. How do I rename a list? + +A. Renaming a list is currently a bit of a pain to do completely + correctly, especially if you want to make sure that the old list + contacts are automatically forwarded to the new list. This ought + to be easier. :( + + The biggest problem you have is how to stop mail and web traffic to + your list during the transition, and what to do about any mail + undelivered to the old list after the move. I don't think there + are any foolproof steps, but here's how you can reduce the risk: + + - Temporarily disable qrunner. To do this, you need to edit the + user `mailman's crontab entry. Execute the following command, + commenting out the qrunner line when you're dropped into your + editor. Then save the file and quit the editor. + + % crontab -u mailman -e + + - Turn off your mail server. This is mostly harmless since remote + MTAs will just keep retrying until you turn it back on, and it's + not going to be off for very long. + + - Next turn off your web server if possible. This of course means + your entire site will be off-line while you make the switch and + this may not be acceptable to you. The next best suggestion is + to set up your permanent redirects now for the list you're + moving. This means that anybody looking for the list under its + old name will be redirected to the new name, but they'll get + errors until you've completed the move. + + Let's say the old name is "oldname" and the new name is + "newname". Here are some Apache directives that will do the + trick, though YMMV: + + RedirectMatch permanent /mailman/(.*)/oldname(.*) http://www.dom.ain/mailman/$1/newname$2 + RedirectMatch permanent /pipermail/oldname(.*) http://www.dom.ain/pipermail/newname$1 + + Add these to your httpd.conf file and restart Apache. + + - Now cd to the directory where you've installed Mailman. Let's + say it's /usr/local/mailman: + + % cd /usr/local/mailman + + and cd to the `lists' subdirectory: + + % cd lists + + You should now see the directory `oldname'. Move this to + `newname': + + % mv oldname newname + + - Now cd to the private archives directory: + + % cd ../archives/private + + You will need to move the oldname's .mbox directory, and the + .mbox file within that directory. Don't worry about the public + archives; the next few steps will take care of them without + requiring you to fiddle around in the file system: + + % mv oldname.mbox newname.mbox + % mv newname.mbox/oldname.mbox newname.mbox/newname.mbox + + - You now need to run the `bin/move_list' script to update some of + the internal archiver paths. IMPORTANT: Skip this step if you + are using Mailman 2.1! + + % cd ../.. + % bin/move_list newname + + - You should now regenerate the public archives: + + % bin/arch newname + + - You'll likely need to change some of your list's configuration + options, especially if you want to accept postings addressed to + the old list on the new list. Visit the admin interface for your + new list: + + o Go to the General options + + o Change the "real_name" option to reflect the new list's name, + e.g. "Newname" + + o Change the subject prefix to reflect the new list's name, + e.g. "[Newname] " (yes, that's a trailing space character). + + o Optionally, update other configuration fields like info, + description, or welcome_msg. YMMV. + + o Save your changes + + o Go to the Privacy options + + o Add the old list's address to acceptable_aliases. + E.g. "oldname@dom.ain". This way, (after the /etc/aliases + changes described below) messages posted to the old list will + not be held by the new list for "implicit destination" + approval. + + o Save your changes + + - Now you want to update your /etc/aliases file to include the + aliases for the new list, and forwards for the old list to the + new list. Note that these instructions are for Sendmail style + alias files, adjust to the specifics of how your MTA is set up. + + o Find the lines defining the aliases for your old list's name + + o Copy and paste them just below the originals. + + o Change all the references of "oldname" to "newname" in the + pasted stanza. + + o Now change the targets of the original aliases to forward to + the new aliases. When you're done, you will end up with + /etc/aliases entries like the following (YMMV): + + XXX This needs updating for MM2.1! + + # Forward the oldname list to the newname list + oldname: newname@dom.ain + oldname-request: newname-request@dom.ain + oldname-admin: newname-admin@dom.ain + oldname-owner: newname-owner@dom.ain + + newname: "|/usr/local/mailman/mail/mailman post newname" + newname-admin: "|/usr/local/mailman/mail/mailman mailowner newname" + newname-request: "|/usr/local/mailman/mail/mailman mailcmd newname" + newname-owner: newname-admin + + o Run newaliases + + - Before you restart everything, you want to make one last check. + You're looking for files in the qfiles/ directory that may have + been addressed to the old list but weren't delivered before you + renamed the list. Do something like the following: + + % cd /usr/local/mailman/qfiles + % grep oldname *.msg + + If you get no hits, skip to the next step, you've got nothing to + worry about. + + If you did get hits, then things get complicated. I warn you + that the rest of this step is untested. :( + + For each of the .msg files that were destined for the old list, + you need to change the corresponding .db file. Unfortunately + there's no easy way to do this. Anyway... + + Save the following Python code in a file called 'hackdb.py': + + -------------------------hackdb.py + import sys + import marshal + fp = open(sys.argv[1]) + d = marshal.load(fp) + fp.close() + d['listname'] = sys.argv[2] + fp = open(sys.argv[1], 'w') + marshal.dump(d, fp) + fp.close() + ------------------------- + + And then for each file that matched your grep above, do the + following: + + % python hackdb.py reallylonghexfilenamematch1.db newname + + - It's now safe to turn your MTA back on. + + - Turn your qrunner back on by running + + % crontab -u mailman -e + + again and this time uncommenting the qrunner line. Save the file + and quit your editor. + + - Rejoice, you're done. Send $100,000 in shiny new pennies to the + Mailman cabal as your downpayment toward making this easier for + the next list you have to rename. :) + + + +Local Variables: +mode: text +indent-tabs-mode: nil +End: diff --git a/INSTALL b/INSTALL new file mode 100644 index 00000000..7becfb8a --- /dev/null +++ b/INSTALL @@ -0,0 +1,576 @@ +Mailman - The GNU Mailing List Management System +Copyright (C) 1998,1999,2000,2001,2002 Free Software Foundation, Inc. +59 Temple Place - Suite 330, Boston, MA 02111-1307, USA + +This file contains installation instructions for GNU Mailman, which is +configured using the standard GNU autoconf software. You first need +to prepare your system as outlined in the sections below, and then +configure and install the Mailman software. + +UPGRADING: Upgrading is usually as easy as just installing the new +version over the existing installation. However, you should read the +notes in the file UPGRADING for important information before you +upgrade. + + +0. Installation requirements + + You must have a mail server (MTA) that you can send messages to, + and a web server that supports the CGI/1.1 API. Apache makes a + fine choice for web server, and MTAs such as Postfix, Exim, + Sendmail, and qmail should work just fine. + + You will need an ANSI C compiler to build Mailman's security + wrappers. The GNU C compiler gcc 2.8.1 or later is known to work + well. For more information about obtaining gcc, see + + http://www.gnu.org + + You must have the Python interpreter installed somewhere on your + system. Currently Python 2.1.3 or Python 2.2.1 is recommended. + For information about obtaining Python source code, RPM packages, + or pre-compiled binaries please see: + + http://www.python.org + + If you are building Python from source, you should be fine with + the standard "./configure ; make install" for most Unix-like + OSes. If you run "make test", you'll see a bunch of tests skipped + -- don't worry, you probably won't need them. Mailman tries to + stick to the basics that compile on most systems. + + If there is a README. file that describes your mail + server (MTA), read it now. Some MTAs can be integrated more + seamlessly with Mailman for support of some advanced features + (like creation and removal of lists through-the-web). Examples + are Exim and Postfix. Setup instructions for specific MTAs are + contained in these README files. + + +1. System setup + + You will need to be root to perform the steps in this section. + + Before installing the Mailman software, you need to prepare your + system by adding certain users and groups. + + - Add a new user called `mailman'. Typically this is added to + your /etc/passwd file. If username `mailman' is already in use, + choose something else unique and see the --with-username flag + below. + + - Add a new group called `mailman'. Typically this is added to + your /etc/group file. The Mailman files will be installed under + the `mailman' group, with the set-group-id bit. Mailman's + security is based on group-ownership permissions, so it is + important to get this step right. If groupname `mailman' is + already in use, choose something else unique and see the + --with-groupname below. + + - Create an installation directory (called $prefix in the + documentation that follows). All of the Mailman files will be + installed under $prefix. Run "configure --help" for ways to + split the installation based on read-only vs. read/write files. + + The default installation directory for Mailman 2.1 is + /usr/local/mailman. It used to be /home/mailman for all + versions prior to Mailman 2.1alpha2. You can override the + default by using the --prefix option to configure (see below). + If you're upgrading from a version previous to Mailman 2.1, you + will need to use --prefix unless you move your mailing lists + (this can be a wise upgrade strategy). + + Watch out if your site does something like mount /usr/local with + the nosuid option. This will break Mailman, which relies on + set-gid programs for its security. If this describes your + environment, simply install Mailman in a location that allows + setgid programs. + + Make sure the install directory is set to group `mailman' (or + whatever you're going to specify as --with-groupname) and has + the setgid bit set (but see README.BSD if you're on a BSD + system). You probably also want to guarantee that this + directory is readable and executable by everyone. For example, + these shell commands will accomplish this: + + % cd $prefix + % chgrp mailman . + % chmod a+rx,g+ws . + + You are now ready to configure and install the Mailman software. + + +2. Running configure + + TAKE SPECIAL NOTE OF THE --with-mail-gid AND --with-cgi-gid + OPTIONS BELOW. YOU WILL PROBABLY NEED TO USE THESE! + + You should not be root while performing the steps in this section. + Do them under your own login, or whatever account you typically + use to install software. You do not need to do these steps as + user mailman, but you could. However, make sure that the login + used is a member of the mailman group as that that group has write + permissions to the $prefix directory made in the previous step. + + Make sure that you have write permissions to the target + installation directory, and permission to create a setgid file in + the file system where it resides (NFS and other mounts can be + configured to inhibit setgid settings). + + If you've installed other GNU software, you should be familiar + with the configure script. Usually you can just cd to the + directory you unpacked the Mailman source tarball into, and run + configure with no arguments: + + % cd mailman- + % ./configure + % make install + + The following options allow you to customize your Mailman + installation. + + --prefix= + Standard GNU configure option which changes the base + directory that Mailman is installed into. By default + $prefix is /usr/local/mailman. This directory must + already exist, and be set up as described in section 1 + above. + + --exec-prefix= + Standard GNU configure option which lets you specify a + different installation directory for architecture + dependent binaries. + + --with-var-prefix= + Store mutable data under instead of under the prefix + or exec_prefix. + + --with-python= + Specify an alternative Python interpreter to use for the + wrapper programs. The default is to use the interpreter + found first on your shell's $PATH. Note that when running + the scripts from the command line, the first Python + interpreter found on $PATH is always used. + + --with-username= + Specify a different username than `mailman' to use as a + default. Use this only if the username `mailman' is + already in use by somebody (e.g. Mark Ailman's login + name). This switch can take an integer user id or a user + name. Be sure your $prefix directory is owned by this + user. + + --with-groupname= + Specify a different groupname than `mailman' to use as a + default. Use this only if the groupname `mailman' is + already in use. This switch can take an integer group id + or a group name. Be sure your $prefix directory is + group-owned by this group. + + --with-mail-gid= + Specify an alternative group for running scripts via the + mail wrapper. can be a list of one or + more integer group ids or symbolic group names. The first + value in the list that resolves to an existing group is + used. By default, the value is the list + `mailman other mail daemon'. + + This is highly system dependent and you must get this + right, because the group id is compiled into the mail + wrapper program for added security. On systems using + sendmail, the sendmail.cf configuration file designates + the group id of sendmail processes using the "DefaultUser" + option. (If commented out, it still may be indicating the + default...) + + Check your MTA's documentation and configuration files to + find the right value for this switch. + + --with-cgi-gid= + Specify an alternative group for running scripts via the + CGI wrapper. can be a list of one or + more integer group ids or symbolic group names. The first + value in the list that resolves to an existing group is + used. By default, the value is the the list + `www www-data nobody'. + + The proper value for this is dependent on your web server + configuration. You must get this right, because the group + id is compiled into the CGI wrapper program for added + security, and no Mailman CGI scripts will run if this is + incorrect. + + If you're using Apache, check the values for the `Group' + option in your httpd.conf file. + + --with-cgi-ext= + Specify an extension for cgi-bin programs. The CGI + wrappers placed in $PREFIX/cgi-bin will have this + extension (some web servers require an extension). + must include the dot. + + --with-gcc=no + Don't use gcc, even if it is found. In this case, `cc' + must be found on your $PATH. + + +3. Check your installation + + After you've run "make install", you can check that your + installation has all the correct permissions and group ownerships + by running the check_perms script: + + - cd to $prefix + + - Run bin/check_perms + + Don't try to run bin/check_perms from the source directory; it + will only run from the install (i.e. $prefix) directory. + + If this reports no problems, then it's very likely that + your installation is set up correctly. If it reports problems, + then you can either fix them manually, re-run the installation, or + use check_perms to fix the problems (probably the easiest + solution): + + - You need to become the user that did the installation (and that + owns all the files in $prefix), or root. + + - Run bin/check_perms -f + + - Repeat previous step until no more errors are reported! + + +4. Final system set-up + + Congratulations! You've installed the Mailman software. To get + everything running you need to hook Mailman up to both your web + server and your mail system. + + - If you plan on running your MTA and web server on different + machines, sharing Mailman installations via NFS, be sure that + the clocks on those two machines are synchronized closely. You + might take a look at the file Mailman/LockFile.py; the constant + CLOCK_SLOP helps the locking mechanism compensate for clock skew + in this type of environment. + + - Configure your web server to give $prefix/cgi-bin permission to + run CGI scripts. You probably need to be root to do this. + + The line you should add might look something like the following + (with the real absolute directory substituted for $prefix, of + course): + + Exec /mailman/* $prefix/cgi-bin/* + or: + ScriptAlias /mailman/ $prefix/cgi-bin/ + + Consult your web server's documentation for details. + + - You want to be very sure that the user id under which your CGI + scripts run is *not* in the `mailman' group you created above, + otherwise private archives will be accessible to anyone. + + - Copy the Mailman, Python, and GNU logos to a location accessible + to your web server. E.g. with Apache, you've usually got an + `icons' directory that you can drop the images into. For + example: + + % cp $prefix/icons/*.{jpg,png} /path/to/apache/icons + + You then want to add a line to your $prefix/Mailman/mm_cfg.py + file which sets the base URL for the logos. For example: + + IMAGE_LOGOS = '/images/' + + The default value for IMAGE_LOGOS is '/icons/'. Read the + comment in Defaults.py.in for details. + + - Configure your web server to point to the Pipermail public + mailing list archives: + + For example, in Apache: + + Alias /pipermail/ $varprefix/archives/public/ + + where $varprefix is usually $prefix unless you've used the + --with-var-prefix option to configure. + + Consult your web server's documentation for details. Also be + sure to configure your web server to follow symbolic links in + this directory, otherwise public Pipermail archives won't be + accessible. For Apache users, consult the FollowSymLinks + option. + + Also, if you're going to be supporting internationalized public + archives, you will probably want to turn off any default charset + directive for the Pipermail directory, otherwise your + multilingual archive pages won't show up correctly. Here's an + example for Apache, based on the standard installation + directories: + + + AddDefaultCharset Off + + + Now restart your web server. + + - Set up the crontab entries. Mailman runs a number of cron jobs + for its basic functionality. Note that if you're upgrading from + a previous version of Mailman, you'll want to install the new + crontab, but be careful if you're running multiple Mailman + installations on your site! Changing the crontab could mess + with other parallel Mailman installations. + + If your version of crontab supports the -u option, you must be + root to do this next step. Add $prefix/cron/crontab.in as a + crontab entry by executing these commands: + + % cd $prefix/cron + % crontab -u mailman crontab.in + + If you used the --with-username option, use that user name + instead of mailman for the -u argument value. If your crontab + does not support the -u option, try these commands: + + % cd $prefix/cron + % su - mailman + % crontab crontab.in + + - Start the Mailman qrunner daemon, by executing the following + from the $prefix directory: + + % bin/mailmanctl start + + If you want to start Mailman every time you reboot your system, + and your OS supports the chkconfig command (e.g. RedHat and + Mandrake Linuxes) you can simply do the following (as root, from + the Mailman install directory): + + % cp scripts/mailman /etc/init.d/mailman + % chkconfig --add mailman + + (Note that /etc/init.d may be /etc/rc.d/init.d on some systems.) + + On Debian, you probably want to use + + % update-rc.d mailman defaults + + instead of chkconfig. + + For Unixes that don't support chkconfig, simply copy + scripts/mailman as above: + + % cp scripts/mailman /etc/init.d/mailman + + then set up the following symbolic links, again as root: + + % cp misc/mailman /etc/init.d + % cd /etc/rc.d/rc0.d + % ln -s ../init.d/mailman K12mailman + % cd ../rc1.d + % ln -s ../init.d/mailman K12mailman + % cd ../rc2.d + % ln -s ../init.d/mailman S98mailman + % cd ../rc3.d + % ln -s ../init.d/mailman S98mailman + % cd ../rc4.d + % ln -s ../init.d/mailman S98mailman + % cd ../rc5.d + % ln -s ../init.d/mailman S98mailman + % cd ../rc6.d + % ln -s ../init.d/mailman K12mailman + + - Check the values for DEFAULT_EMAIL_HOST and DEFAULT_URL_HOST in + Defaults.py. Make any necessary changes in the mm_cfg.py file. + Note that if you change either of these two values, you'll want + to add the following afterwards in the mm_cfg.py file: + + add_virtualhost(DEFAULT_URL_HOST, DEFAULT_EMAIL_HOST) + + - Create a "site-wide" mailing list. This is the one that + password reminders will appear to come from. Usually this + should be the "mailman" mailing list, but if you need to change + this, be sure to change the MAILMAN_SITE_LIST variable in + mm_cfg.py (see below). + + % bin/newlist mailman + + Follow the prompts, and see the README file for more + information. + + - You should then subscribe yourself to the mailman list. + + +5. Customize Mailman + + You should do these steps using the account you installed Mailman + under in section 2 above. + + - The file $prefix/Mailman/Defaults.py contains a number of + defaults for your installation. If any of these are incorrect, + override them in $prefix/Mailman/mm_cfg.py, NOT IN Defaults.py! + See the comments in Defaults.py for details. Once a list is + created, editing many of these variables will have no effect. + At that point, you'll need to configure your lists through the + web admin interface or through the command line script + bin/withlist or bin/config_list. + + The install process will not overwrite an existing mm_cfg.py + file so you can freely make changes to this file. + + Note: Do *not* change HOME_DIR or MAILMAN_DIR. These are set + automatically by the configure script. + + - Create the site password using: + + % $prefix/bin/mmsitepass + + This password can be used anywhere that individual user or + mailing list administrator passwords are required, giving the + mailman site administrator the ability to adjust these things + when necessary. + + You may also want to create a password for the site-wide "list + creator" role (someone other than the site administrator who as + privileges to create and remove lists through the web). Use the + -c option to mmsitepass to set this. + + +6. Getting started + + See the README file under the section "CREATE YOUR FIRST LIST" for + a quick introduction to creating an initial test list. + + +7. Troubleshooting + + If you encounter problems with running Mailman, first check the + "Common Problems" section, below. If your problem is not covered + there, check both the FAQ file and the online FAQ Wizard. Also + check for errors your syslog files and in the $prefix/logs/error + file. + + Where syslog lives on your particular machine may vary. It may be + in /var/log/maillog. It may also be in /var/log/syslog. On many + machines, syslog files live in /adm/log/ instead of /var/log. + + If you encounter an error, send an error report to + mailman-users@python.org. Include a description of what you're + doing to cause the problem, and the relevant lines from your + syslog. Also include information on your operating system, which + version of Python you're using, and which version of Mailman + you're installing. + + +8. Common Problems + + Problem: All Mailman web pages give a 404 File not found error. + + Solution: Your web server has not been set up properly for handling + Mailman's cgi commands. Make sure you've: + + 1) Configured the web server to give permissions to + $prefix/cgi-bin + 2) Restarted the web server properly. + + Consult your web server's documentation for instructions + on how to do these things. + + + Problem: All Mailman web pages give an "Internal Server Error". + + Solution: The likely problem is that you are using the wrong GID or + UID for CGI scripts. Check your syslog. If you see, for + example, a line like: + + Attempt to exec script with invalid gid 51, expected 99 + + You need to reinstall Mailman, and specify $CGI_GID to be 51, + as described in the installation instructions. + + + Problem: I send mail to the list, and get back mail saying the + list is not found! + + Solution: You probably didn't add the necessary aliases to the system + alias database, given to you when you ran the newlist + command. If you did add them, you likely did not update + the alias database, or your system requires you to run + newaliases explicitly. Refer to section 5 above for + more information. + + + Problem: I send mail to the list, and get back mail saying, + "unknown mailer error". + + Solution: The likely problem is that you are using the wrong GID or + UID for mail. Check your syslog. If you see, for + example, a line like: + + Attempt to exec script with invalid gid 51, expected 99 + + You need to reinstall Mailman, and specify $MAIL_GID to + be 51, as described in the installation + instructions. see notes on Postfix below, as by default + it will create these problems on installation. + + + Problem: I use Postfix for my MTA and the mail wrapper programs + are logging complaints about the wrong GID. + + Solution: Create a separate aliases file for Postfix in its + main.cf config file under the variable "alias_maps". Put + the file somewhere in Mailman's home directory, or + somewhere else where the user mailman has write access + to it; *as user mailman* call Postfix's "postalias" on the + alias file. + + % postalias + + Also as user mailman, run + + % python -c'import os; print os.getgid()' + + This should print out the group id that Mailman should + be configured to expect when the mail wrapper programs + are run. Call it "thegid". Rebuild Mailman with + + % ./configure --with-mail-gid=thegid + + See also the README.POSTFIX file for more information on + connecting Postfix and Mailman. + + + Problem: I send mail to the list, and get back mail saying, + "sh: mailman not available for sendmail programs" + + Solution: Your system uses sendmail restricted shell (smrsh). You + need to configure smrsh by creating a symbolic link from + the mail wrapper ($prefix/mail/mailman) to the directory + identifying executables allowed to run under smrsh. + + Some common names for this directory are + /var/admin/sm.bin, /usr/admin/sm.bin or /etc/smrsh. + + Note that on Debian Linux, the system makes + /usr/lib/sm.bin, which is wrong, you will need to create + the directory /usr/admin/sm.bin and add the link there. + Note further any aliases newaliases spits out will need + to be adjusted to point to the secure link to the + wrapper. + + + Problem: I messed up when I called configure. How do I clean + things up and re-install? + + Solution: % make clean + % ./configure --with-the-right-options + % make install + + + +Local Variables: +mode: indented-text +indent-tabs-mode: nil +End: diff --git a/Mailman/.cvsignore b/Mailman/.cvsignore new file mode 100644 index 00000000..4ef7207b --- /dev/null +++ b/Mailman/.cvsignore @@ -0,0 +1,3 @@ +Makefile +mm_cfg.py.dist +Defaults.py diff --git a/Mailman/Archiver/.cvsignore b/Mailman/Archiver/.cvsignore new file mode 100644 index 00000000..f3c7a7c5 --- /dev/null +++ b/Mailman/Archiver/.cvsignore @@ -0,0 +1 @@ +Makefile diff --git a/Mailman/Archiver/Archiver.py b/Mailman/Archiver/Archiver.py new file mode 100644 index 00000000..903031cd --- /dev/null +++ b/Mailman/Archiver/Archiver.py @@ -0,0 +1,232 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, 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 traceback +from cStringIO import StringIO + +from Mailman import mm_cfg +from Mailman import Mailbox +from Mailman import Utils +from Mailman import Site +from Mailman.SafeDict import SafeDict +from Mailman.Logging.Syslog import syslog +from Mailman.i18n import _ + + + +def makelink(old, new): + try: + os.symlink(old, new) + except os.error, e: + code, msg = e + if code <> errno.EEXIST: + raise + +def breaklink(link): + try: + os.unlink(link) + except os.error, e: + code, msg = e + if code <> 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): + # Configurable + self.archive = mm_cfg.DEFAULT_ARCHIVE + # 0=public, 1=private: + self.archive_private = mm_cfg.DEFAULT_ARCHIVE_PRIVATE + self.archive_volume_frequency = \ + mm_cfg.DEFAULT_ARCHIVE_VOLUME_FREQUENCY + # 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. + omask = os.umask(0) + try: + try: + os.mkdir(self.archive_dir()+'.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(self.archive_dir(), 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(self.archive_dir(), 'index.html') + fp = None + try: + fp = open(indexfile) + except IOError, e: + if e.errno <> errno.ENOENT: raise + else: + fp = open(indexfile, 'w') + fp.write(Utils.maketext( + 'emptyarchive.html', + {'listname': self.real_name, + 'listinfo': self.GetScriptURL('listinfo', absolute=1), + }, mlist=self)) + if fp: + fp.close() + finally: + os.umask(omask) + + def archive_dir(self): + return Site.get_archpath(self.internal_name()) + + def ArchiveFileName(self): + """The mbox name where messages are left for archive construction.""" + return os.path.join(self.archive_dir() + '.mbox', + self.internal_name() + '.mbox') + + def GetBaseArchiveURL(self): + if self.archive_private: + return self.GetScriptURL('private', absolute=1) + '/' + else: + inv = {} + for k, v in mm_cfg.VIRTUAL_HOSTS.items(): + inv[v] = k + url = mm_cfg.PUBLIC_ARCHIVE_URL % { + 'listname': self.internal_name(), + 'hostname': inv.get(self.host_name, mm_cfg.DEFAULT_URL_HOST), + } + if not url.endswith('/'): + url += '/' + 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: + syslog('error', 'Archive file access failure:\n\t%s %s', afn, msg) + raise + + def ExternalArchive(self, ar, txt): + d = SafeDict({'listname': self.internal_name()}) + cmd = ar % d + extarch = os.popen(cmd, 'w') + extarch.write(txt) + status = extarch.close() + if status: + syslog('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 mm_cfg.ARCHIVE_TO_MBOX == -1: + return + # + # We don't need an extra archiver lock here because we know the list + # itself must be locked. + if mm_cfg.ARCHIVE_TO_MBOX in (1, 2): + self.__archive_to_mbox(msg) + if mm_cfg.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 mm_cfg.PUBLIC_EXTERNAL_ARCHIVER and not private_p: + self.ExternalArchive(mm_cfg.PUBLIC_EXTERNAL_ARCHIVER, txt) + elif mm_cfg.PRIVATE_EXTERNAL_ARCHIVER and private_p: + self.ExternalArchive(mm_cfg.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 mm_cfg.ARCHIVE_TO_MBOX == -1: + # Archiving is completely disabled, don't require the skeleton. + return + pubdir = Site.get_archpath(self.internal_name(), public=1) + 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) + makelink(privmbox, pubmbox) diff --git a/Mailman/Archiver/HyperArch.py b/Mailman/Archiver/HyperArch.py new file mode 100644 index 00000000..98fb5738 --- /dev/null +++ b/Mailman/Archiver/HyperArch.py @@ -0,0 +1,1224 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""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). +""" + +from __future__ import nested_scopes + +import sys +import re +import errno +import urllib +import time +import os +import types +import HyperDatabase +import pipermail +import weakref +import binascii + +from email.Header import decode_header, make_header + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import LockFile +from Mailman import MailList +from Mailman import i18n +from Mailman.SafeDict import SafeDict +from Mailman.Logging.Syslog import syslog +from Mailman.Mailbox import ArchiverMailbox + +# Set up i18n. Assume the current language has already been set in the caller. +_ = i18n._ + +gzip = None +if mm_cfg.GZIP_ARCHIVE_TXT_FILES: + try: + import gzip + except ImportError: + pass + +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: + # Avoid i18n side-effects + otrans = i18n.get_translation() + try: + i18n.set_language(lang) + out = _(' %(size)i bytes ') + finally: + i18n.set_translation(otrans) + 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, types.UnicodeType): + 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'^([>|:]|>)+') + + + +# This doesn't need to be a weakref instance because it's just storing +# strings. Keys are (templatefile, lang) tuples. +_templatecache = {} + +def quick_maketext(templatefile, dict=None, lang=None, mlist=None): + if lang is None: + if mlist is None: + lang = mm_cfg.DEFAULT_SERVER_LANGUAGE + else: + lang = mlist.preferred_language + template = _templatecache.get((templatefile, lang)) + if template is None: + # Use the basic maketext, with defaults to get the raw template + template = Utils.maketext(templatefile, lang=lang, raw=1) + _templatecache[(templatefile, lang)] = template + # Copied from Utils.maketext() + text = template + if dict is not None: + try: + sdict = SafeDict(dict) + try: + text = sdict.interpolate(template) + except UnicodeError: + # Try again after coercing the template to unicode + utemplate = unicode(template, + Utils.GetCharSet(lang), + 'replace') + text = sdict.interpolate(utemplate) + 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=mm_cfg.DEFAULT_SERVER_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 mm_cfg.ARCHIVER_OBSCURES_EMAILADDRS: + # 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. + otrans = i18n.get_translation() + try: + i18n.set_language(lang) + self.email = re.sub('@', _(' at '), self.email) + finally: + i18n.set_translation(otrans) + + # 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 = {} + charset = message.get_param('charset') + 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=1) + 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() + + # Mapping of listnames to MailList instances as a weak value dictionary. + # This code is copied from Runner.py but there's one important operational + # difference. In Runner.py, we always .Load() the MailList object for + # each _dispose() run, otherwise the object retrieved from the cache won't + # be up-to-date. Since we're creating a new HyperArchive instance for + # each message being archived, we don't need to worry about that -- but it + # does mean there are additional opportunities for optimization. + _listcache = weakref.WeakValueDictionary() + + def _open_list(self, listname): + # Cache the open list so that any use of the list within this process + # uses the same object. We use a WeakValueDictionary so that when the + # list is no longer necessary, its memory is freed. + mlist = self._listcache.get(listname) + if not mlist: + try: + mlist = MailList.MailList(listname, lock=0) + except Errors.MMListError, e: + syslog('error', 'error opening list: %s\n%s', listname, e) + return None + else: + self._listcache[listname] = mlist + return mlist + + 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.internal_name() + 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'] = self._open_list(listname) + if not d.has_key('_lang'): + if hasattr(self, '_mlist'): + self._lang = self._mlist.preferred_language + else: + self._lang = mm_cfg.DEFAULT_SERVER_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: + self.decoded['subject'] = subject + + def decode_charset(self, field): + if field.find("=?") == -1: + return None + # Get the decoded header as a list of (s, charset) tuples + pairs = decode_header(field) + # Use __unicode__() until we can guarantee Python 2.2 + try: + # Use a large number for maxlinelen so it won't get wrapped + h = make_header(pairs, 99999) + return h.__unicode__() + except (UnicodeError, LookupError): + # Unknown encoding + return None + # The last value for c will have the proper charset in it + return EMPTYSTRING.join([s for s, c in pairs]) + + def as_html(self): + d = self.__dict__.copy() + # avoid i18n side-effects + otrans = i18n.get_translation() + i18n.set_language(self._lang) + try: + 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 mm_cfg.ARCHIVER_OBSCURES_EMAILADDRS: + # Point the mailto url back to the list + author = re.sub('@', _(' at '), self.author) + emailurl = self._mlist.GetListEmail() + 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.GetScriptURL('listinfo', absolute=1) + d['listname'] = self._mlist.real_name + d['encoding'] = '' + finally: + i18n.set_translation(otrans) + + 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:') + + ' %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:') + + ' %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) + if isinstance(body, types.UnicodeType): + body = body.encode(Utils.GetCharSet(self._lang), 'replace') + return NL.join(headers) % d + '\n\n' + body + + 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) + + + +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! + # XXX TODO- implement native locking + # with mailman's LockFile module for HyperDatabase.HyperDatabase + # + 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 + otrans = i18n.get_translation() + i18n.set_language(mlist.preferred_language) + # Convenience + def quotetime(s): + return html_quote(i18n.ctime(s), self.lang) + try: + d = {"lastdate": quotetime(self.lastdate), + "archivedate": quotetime(self.archivedate), + "listinfo": mlist.GetScriptURL('listinfo', absolute=1), + "version": self.version, + } + i = {"thread": _("thread"), + "subject": _("subject"), + "author": _("author"), + "date": _("date") + } + finally: + i18n.set_translation(otrans) + + 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 + otrans = i18n.get_translation() + i18n.set_language(mlist.preferred_language) + # Convenience + def quotetime(s): + return html_quote(i18n.ctime(s), self.lang) + try: + d = {"listname": html_quote(mlist.real_name, self.lang), + "archtype": self.type, + "archive": self.volNameToDesc(self.archive), + "listinfo": mlist.GetScriptURL('listinfo', absolute=1), + "firstdate": quotetime(self.firstdate), + "lastdate": quotetime(self.lastdate), + "size": self.size, + } + i = {"thread": _("thread"), + "subject": _("subject"), + "author": _("author"), + "date": _("date"), + } + finally: + i18n.set_translation(otrans) + + 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.internal_name() + mbox = os.path.join(mlist.archive_dir()+'.mbox', listname+'.mbox') + d = {"listname": mlist.real_name, + "listinfo": mlist.GetScriptURL('listinfo', absolute=1), + "fullarch": '../%s.mbox/%s.mbox' % (listname, listname), + "size": sizeof(mbox, mlist.preferred_language), + 'meta': '', + } + # Avoid i18n side-effects + otrans = i18n.get_translation() + i18n.set_language(mlist.preferred_language) + try: + 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) + finally: + i18n.set_translation(otrans) + + # The TOC is always in the charset of the list's preferred language + d['meta'] += html_charset % Utils.GetCharSet(mlist.preferred_language) + + return quick_maketext( + 'archtoc.html', 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 = LockFile.LockFile( + os.path.join(mm_cfg.LOCK_DIR, + self.maillist.internal_name() + '-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) + syslog('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")) + return time.mktime((year,month,1,0,0,0,0,1,-1)) + return 0.0 + + def sortarchives(self): + def sf(a,b,s=self): + al=s.volNameToDate(a) + bl=s.volNameToDate(b) + if al>bl: + return 1 + elif al' + print self.html_foot() + + def write_index_entry(self, article): + subject = self.get_header("subject", article) + author = self.get_header("author", article) + if mm_cfg.ARCHIVER_OBSCURES_EMAILADDRS: + author = re.sub('@', _(' at '), author) + 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 mm_cfg.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 + 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 mm_cfg.ARCHIVER_OBSCURES_EMAILADDRS: + text = re.sub('@', _(' at '), text) + URL = self.maillist.GetScriptURL( + 'listinfo', absolute=1) + 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/Mailman/Archiver/HyperDatabase.py b/Mailman/Archiver/HyperDatabase.py new file mode 100644 index 00000000..ab41b824 --- /dev/null +++ b/Mailman/Archiver/HyperDatabase.py @@ -0,0 +1,338 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +# +# site modules +# +import os +import marshal +import time +import errno + +# +# package/project modules +# +import pipermail +from Mailman 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.LockFile(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/Mailman/Archiver/Makefile.in b/Mailman/Archiver/Makefile.in new file mode 100644 index 00000000..fe56149d --- /dev/null +++ b/Mailman/Archiver/Makefile.in @@ -0,0 +1,72 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +# NOTE: Makefile.in is converted into Makefile by the configure script +# in the parent directory. Once configure has run, you can recreate +# the Makefile by running just config.status. + +# Variables set by configure + +VPATH= @srcdir@ +srcdir= @srcdir@ +bindir= @bindir@ +prefix= @prefix@ +exec_prefix= @exec_prefix@ + +CC= @CC@ +CHMOD= @CHMOD@ +INSTALL= @INSTALL@ + +DEFS= @DEFS@ + +# Customizable but not set by configure + +OPT= @OPT@ +CFLAGS= $(OPT) $(DEFS) +PACKAGEDIR= $(prefix)/Mailman/Archiver +SHELL= /bin/sh + +MODULES= __init__.py Archiver.py HyperArch.py HyperDatabase.py \ +pipermail.py + + +# Modes for directories and executables created by the install +# process. Default to group-writable directories but +# user-only-writable for executables. +DIRMODE= 775 +EXEMODE= 755 +FILEMODE= 644 +INSTALL_PROGRAM=$(INSTALL) -m $(EXEMODE) + + +# Rules + +all: + +install: + for f in $(MODULES); \ + do \ + $(INSTALL) -m $(FILEMODE) $(srcdir)/$$f $(PACKAGEDIR); \ + done + +finish: + +clean: + +distclean: + -rm *.pyc + -rm Makefile + diff --git a/Mailman/Archiver/__init__.py b/Mailman/Archiver/__init__.py new file mode 100644 index 00000000..65ad7be7 --- /dev/null +++ b/Mailman/Archiver/__init__.py @@ -0,0 +1,17 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +from Archiver import * diff --git a/Mailman/Archiver/pipermail.py b/Mailman/Archiver/pipermail.py new file mode 100644 index 00000000..2e1b226d --- /dev/null +++ b/Mailman/Archiver/pipermail.py @@ -0,0 +1,854 @@ +#! /usr/bin/env python + +from __future__ import nested_scopes + +import mailbox +import os +import re +import sys +import time +from email.Utils import parseaddr, parsedate_tz +import cPickle as pickle +from cStringIO import StringIO +from string import lowercase + +__version__ = '0.09 (Mailman edition)' +VERSION = __version__ +CACHESIZE = 100 # Number of slots in the cache + +from Mailman import Errors +from Mailman.Mailbox import ArchiverMailbox +from Mailman.Logging.Syslog import syslog +from Mailman.i18n import _ + +SPACE = ' ' + + + +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 + article.body = [] + self.articleIndex[article.msgid] = pickle.dumps(article) + article.body = temp + +# 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()) + 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 time.mktime(date[:9]) + except (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 + + def __repr__(self): + return '

      ' + +# 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] + 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: + syslog('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)) + + author = fixAuthor(article.author) + 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/Mailman/Autoresponder.py b/Mailman/Autoresponder.py new file mode 100644 index 00000000..c568ec06 --- /dev/null +++ b/Mailman/Autoresponder.py @@ -0,0 +1,43 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""MailList mixin class managing the autoresponder. +""" + +from Mailman import mm_cfg +from Mailman.i18n import _ + + + +class Autoresponder: + def InitVars(self): + # configurable + self.autorespond_postings = 0 + self.autorespond_admin = 0 + # this value can be + # 0 - no autoresponse on the -request line + # 1 - autorespond, but discard the original message + # 2 - autorespond, and forward the message on to be processed + self.autorespond_requests = 0 + self.autoresponse_postings_text = '' + self.autoresponse_admin_text = '' + self.autoresponse_request_text = '' + self.autoresponse_graceperiod = 90 # days + # non-configurable + self.postings_responses = {} + self.admin_responses = {} + self.request_responses = {} + diff --git a/Mailman/Bouncer.py b/Mailman/Bouncer.py new file mode 100644 index 00000000..34bd21d4 --- /dev/null +++ b/Mailman/Bouncer.py @@ -0,0 +1,281 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Handle delivery bounces. +""" + +import sys +import time +from types import StringType + +from email.MIMEText import MIMEText +from email.MIMEMessage import MIMEMessage + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import Message +from Mailman import MemberAdaptor +from Mailman import Pending +from Mailman.Logging.Syslog import syslog +from Mailman import i18n + +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(mm_cfg.days(1))[:3] + +def _(s): return s + +REASONS = {MemberAdaptor.BYBOUNCE: _('due to excessive bounces'), + MemberAdaptor.BYUSER: _('by yourself'), + MemberAdaptor.BYADMIN: _('by the list administrator'), + MemberAdaptor.UNKNOWN: _('for unknown reasons'), + } + +_ = i18n._ + + + +class _BounceInfo: + def __init__(self, member, score, date, noticesleft, cookie): + self.member = member + self.cookie = cookie + 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 InitVars(self): + # Configurable... + self.bounce_processing = mm_cfg.DEFAULT_BOUNCE_PROCESSING + self.bounce_score_threshold = mm_cfg.DEFAULT_BOUNCE_SCORE_THRESHOLD + self.bounce_info_stale_after = mm_cfg.DEFAULT_BOUNCE_INFO_STALE_AFTER + self.bounce_you_are_disabled_warnings = \ + mm_cfg.DEFAULT_BOUNCE_YOU_ARE_DISABLED_WARNINGS + self.bounce_you_are_disabled_warnings_interval = \ + mm_cfg.DEFAULT_BOUNCE_YOU_ARE_DISABLED_WARNINGS_INTERVAL + self.bounce_unrecognized_goes_to_list_owner = \ + mm_cfg.DEFAULT_BOUNCE_UNRECOGNIZED_GOES_TO_LIST_OWNER + self.bounce_notify_owner_on_disable = \ + mm_cfg.DEFAULT_BOUNCE_NOTIFY_OWNER_ON_DISABLE + self.bounce_notify_owner_on_removal = \ + mm_cfg.DEFAULT_BOUNCE_NOTIFY_OWNER_ON_REMOVAL + # Not configurable... + # + # This holds legacy member related information. It's keyed by the + # member address, and the value is an object containing the bounce + # score, the date of the last received bounce, and a count of the + # notifications left to send. + self.bounce_info = {} + # New style delivery status + self.delivery_status = {} + + def registerBounce(self, member, msg, weight=1.0): + if not self.isMember(member): + return + info = self.getBounceInfo(member) + today = time.localtime()[:3] + if not isinstance(info, _BounceInfo): + # This is the first bounce we've seen from this member + cookie = Pending.new(Pending.RE_ENABLE, self.internal_name(), + member) + info = _BounceInfo(member, weight, today, + self.bounce_you_are_disabled_warnings, + cookie) + self.setBounceInfo(member, info) + syslog('bounce', '%s: %s bounce score: %s', self.internal_name(), + member, info.score) + # Continue to the check phase below + elif self.getDeliveryStatus(member) <> MemberAdaptor.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. + syslog('bounce', '%s: %s residual bounce received', + self.internal_name(), member) + return + elif info.date == today: + # We've already scored any bounces for today, so ignore this one. + syslog('bounce', '%s: %s already scored a bounce for today', + self.internal_name(), member) + # Continue to check phase below + else: + # See if this member's bounce information is stale. + now = Utils.midnight(today) + lastbounce = Utils.midnight(info.date) + if lastbounce + self.bounce_info_stale_after < now: + # Information is stale, so simply reset it + info.reset(weight, today, + self.bounce_you_are_disabled_warnings) + syslog('bounce', '%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 = today + syslog('bounce', '%s: %s current bounce score: %s', + member, self.internal_name(), 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: + self.disableBouncingMember(member, info, msg) + + def disableBouncingMember(self, member, info, msg): + # Disable them + syslog('bounce', '%s: %s disabling due to bounce score %s >= %s', + self.internal_name(), member, + info.score, self.bounce_score_threshold) + self.setDeliveryStatus(member, MemberAdaptor.BYBOUNCE) + 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. + siteowner = Utils.get_site_email(self.host_name) + text = Utils.maketext( + 'bounce.txt', + {'listname' : self.real_name, + 'addr' : member, + 'negative' : '', + 'did' : _('disabled'), + 'but' : '', + 'reenable' : '', + 'owneraddr': siteowner, + }, mlist=self) + subject = _('Bounce action notification') + umsg = Message.UserNotification(self.GetOwnerEmail(), + siteowner, 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, StringType): + 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. + Pending.confirm(info.cookie) + if reason == MemberAdaptor.BYBOUNCE: + syslog('bounce', '%s: %s deleted after exhausting notices', + self.internal_name(), member) + syslog('subscribe', '%s: %s auto-unsubscribed [reason: %s]', + self.internal_name(), member, + {MemberAdaptor.BYBOUNCE: 'BYBOUNCE', + MemberAdaptor.BYUSER: 'BYUSER', + MemberAdaptor.BYADMIN: 'BYADMIN', + MemberAdaptor.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 == MemberAdaptor.BYBOUNCE: + 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] + + def BounceMessage(self, msg, msgdata, 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)')) + 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(), + self.GetOwnerEmail(), + subject, + lang=self.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(self.preferred_language)) + bmsg.attach(txt) + bmsg.attach(MIMEMessage(msg)) + bmsg.send(self) diff --git a/Mailman/Bouncers/.cvsignore b/Mailman/Bouncers/.cvsignore new file mode 100644 index 00000000..f3c7a7c5 --- /dev/null +++ b/Mailman/Bouncers/.cvsignore @@ -0,0 +1 @@ +Makefile diff --git a/Mailman/Bouncers/BouncerAPI.py b/Mailman/Bouncers/BouncerAPI.py new file mode 100644 index 00000000..e8994145 --- /dev/null +++ b/Mailman/Bouncers/BouncerAPI.py @@ -0,0 +1,71 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""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 + +from Mailman.Logging.Syslog import syslog + +# 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. +class _Stop: + pass +Stop = _Stop() + + +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 is Stop: + # One of the detectors recognized the bounce, but there were no + # addresses to extract. Return the empty list. + return [] + elif addrs: + return addrs + return [] diff --git a/Mailman/Bouncers/Caiwireless.py b/Mailman/Bouncers/Caiwireless.py new file mode 100644 index 00000000..0e3e71fc --- /dev/null +++ b/Mailman/Bouncers/Caiwireless.py @@ -0,0 +1,45 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Parse mystery style generated by MTA at caiwireless.net.""" + +import re +import email +from cStringIO import StringIO + +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_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/Mailman/Bouncers/Compuserve.py b/Mailman/Bouncers/Compuserve.py new file mode 100644 index 00000000..516c2237 --- /dev/null +++ b/Mailman/Bouncers/Compuserve.py @@ -0,0 +1,45 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""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/Mailman/Bouncers/DSN.py b/Mailman/Bouncers/DSN.py new file mode 100644 index 00000000..3e040bef --- /dev/null +++ b/Mailman/Bouncers/DSN.py @@ -0,0 +1,79 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Parse RFC 1894 (i.e. DSN) bounce formats.""" + +from email.Iterators import typed_subpart_iterator +from email.Utils import parseaddr +from cStringIO import StringIO + + + +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', '') + # BAW: Should we treat delayed bounces the same? Yes, because if + # the transient problem clears up, they should get unbounced. The + # other problem is what to do about a DSN that has both delayed + # and failed actions in multiple header blocks? We're not + # architected to handle that. ;/ + if action.lower() not in ('failed', 'failure', 'delayed'): + # Some non-permanent failure, so ignore this block + continue + params = [] + foundp = 0 + for header in ('original-recipient', 'final-recipient'): + for k, v in msgblock.get_params([], header): + if k.lower() == 'rfc822': + foundp = 1 + else: + params.append(k) + if foundp: + # Note that params should already be unquoted. + addrs.extend(params) + break + # Uniquify + rtnaddrs = {} + for a in addrs: + if a is not None: + realname, a = parseaddr(a) + rtnaddrs[a] = 1 + return rtnaddrs.keys() + + + +def process(msg): + # 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_subtype() <> 'report': + return None + return check(msg) diff --git a/Mailman/Bouncers/Exchange.py b/Mailman/Bouncers/Exchange.py new file mode 100644 index 00000000..1f73aeb1 --- /dev/null +++ b/Mailman/Bouncers/Exchange.py @@ -0,0 +1,47 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""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/Mailman/Bouncers/Exim.py b/Mailman/Bouncers/Exim.py new file mode 100644 index 00000000..1f03df2d --- /dev/null +++ b/Mailman/Bouncers/Exim.py @@ -0,0 +1,30 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""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/Mailman/Bouncers/GroupWise.py b/Mailman/Bouncers/GroupWise.py new file mode 100644 index 00000000..8bde4405 --- /dev/null +++ b/Mailman/Bouncers/GroupWise.py @@ -0,0 +1,70 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""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_type(msg.get_default_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_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/Mailman/Bouncers/LLNL.py b/Mailman/Bouncers/LLNL.py new file mode 100644 index 00000000..faadb0b9 --- /dev/null +++ b/Mailman/Bouncers/LLNL.py @@ -0,0 +1,31 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""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/Mailman/Bouncers/Makefile.in b/Mailman/Bouncers/Makefile.in new file mode 100644 index 00000000..d4c9dfca --- /dev/null +++ b/Mailman/Bouncers/Makefile.in @@ -0,0 +1,74 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +# NOTE: Makefile.in is converted into Makefile by the configure script +# in the parent directory. Once configure has run, you can recreate +# the Makefile by running just config.status. + +# Variables set by configure + +VPATH= @srcdir@ +srcdir= @srcdir@ +bindir= @bindir@ +prefix= @prefix@ +exec_prefix= @exec_prefix@ + +CC= @CC@ +CHMOD= @CHMOD@ +INSTALL= @INSTALL@ + +DEFS= @DEFS@ + +# Customizable but not set by configure + +OPT= @OPT@ +CFLAGS= $(OPT) $(DEFS) +PACKAGEDIR= $(prefix)/Mailman/Bouncers +SHELL= /bin/sh + +MODULES= *.py + +# Modes for directories and executables created by the install +# process. Default to group-writable directories but +# user-only-writable for executables. +DIRMODE= 775 +EXEMODE= 755 +FILEMODE= 644 +INSTALL_PROGRAM=$(INSTALL) -m $(EXEMODE) + + +# Rules + +all: + +install: + for f in $(MODULES); \ + do \ + $(INSTALL) -m $(FILEMODE) $(srcdir)/$$f $(PACKAGEDIR); \ + done + +finish: + +clean: + +distclean: + -rm *.pyc + -rm Makefile + + +# Local Variables: +# indent-tabs-mode: t +# End: diff --git a/Mailman/Bouncers/Microsoft.py b/Mailman/Bouncers/Microsoft.py new file mode 100644 index 00000000..65d49cc1 --- /dev/null +++ b/Mailman/Bouncers/Microsoft.py @@ -0,0 +1,48 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""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_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 + body = StringIO(subpart.get_payload()) + 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/Mailman/Bouncers/Netscape.py b/Mailman/Bouncers/Netscape.py new file mode 100644 index 00000000..21aea7c5 --- /dev/null +++ b/Mailman/Bouncers/Netscape.py @@ -0,0 +1,88 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""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_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/Mailman/Bouncers/Postfix.py b/Mailman/Bouncers/Postfix.py new file mode 100644 index 00000000..fb1a1233 --- /dev/null +++ b/Mailman/Bouncers/Postfix.py @@ -0,0 +1,86 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""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'. +/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)', 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_type() <> 'multipart/mixed': + 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_type() == 'text/plain' and \ + subpart.get('content-description', '').lower() == 'notification': + # then... + return findaddr(subpart) + return None diff --git a/Mailman/Bouncers/Qmail.py b/Mailman/Bouncers/Qmail.py new file mode 100644 index 00000000..d6a3e3c3 --- /dev/null +++ b/Mailman/Bouncers/Qmail.py @@ -0,0 +1,61 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""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 + +introtag = 'Hi. This is the' +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 and line.startswith(introtag): + state = 1 + 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/Mailman/Bouncers/SMTP32.py b/Mailman/Bouncers/SMTP32.py new file mode 100644 index 00000000..62982461 --- /dev/null +++ b/Mailman/Bouncers/SMTP32.py @@ -0,0 +1,57 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""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... + |undeliverable\ to + ) + \s* # space separator + (?P.*) # and finally, the address + ''', re.IGNORECASE | re.VERBOSE) + + + +def process(msg): + mailer = msg.get('x-mailer', '') + if not mailer.startswith('[^>]*)>')), + # 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.*)$')), + # 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 = {} + state = 0 + for line in email.Iterators.body_line_iterator(msg): + if state == 0: + for scre, ecre, acre in patterns: + if scre.search(line): + state = 1 + break + 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 + return addrs.keys() diff --git a/Mailman/Bouncers/SimpleWarning.py b/Mailman/Bouncers/SimpleWarning.py new file mode 100644 index 00000000..bc515515 --- /dev/null +++ b/Mailman/Bouncers/SimpleWarning.py @@ -0,0 +1,44 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Recognizes simple heuristically delimited warnings.""" + +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*')), + # Next one goes here... + ] + + + +def process(msg): + return _process(msg, patterns) diff --git a/Mailman/Bouncers/Sina.py b/Mailman/Bouncers/Sina.py new file mode 100644 index 00000000..2cc2e69b --- /dev/null +++ b/Mailman/Bouncers/Sina.py @@ -0,0 +1,47 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""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/Mailman/Bouncers/Yahoo.py b/Mailman/Bouncers/Yahoo.py new file mode 100644 index 00000000..fd952915 --- /dev/null +++ b/Mailman/Bouncers/Yahoo.py @@ -0,0 +1,53 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""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/Mailman/Bouncers/Yale.py b/Mailman/Bouncers/Yale.py new file mode 100644 index 00000000..6afc4d97 --- /dev/null +++ b/Mailman/Bouncers/Yale.py @@ -0,0 +1,79 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""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/Mailman/Bouncers/__init__.py b/Mailman/Bouncers/__init__.py new file mode 100644 index 00000000..2cbbabb1 --- /dev/null +++ b/Mailman/Bouncers/__init__.py @@ -0,0 +1,15 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. diff --git a/Mailman/Cgi/.cvsignore b/Mailman/Cgi/.cvsignore new file mode 100644 index 00000000..f3c7a7c5 --- /dev/null +++ b/Mailman/Cgi/.cvsignore @@ -0,0 +1 @@ +Makefile diff --git a/Mailman/Cgi/Auth.py b/Mailman/Cgi/Auth.py new file mode 100644 index 00000000..58640663 --- /dev/null +++ b/Mailman/Cgi/Auth.py @@ -0,0 +1,59 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Common routines for logging in and logging out of the list administrator +and list moderator interface. +""" + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import Errors +from Mailman.htmlformat import FontAttr +from Mailman.i18n import _ + + + +class NotLoggedInError(Exception): + """Exception raised when no matching admin cookie was found.""" + def __init__(self, message): + Exception.__init__(self, message) + self.message = message + + + +def loginpage(mlist, scriptname, msg='', frontpage=None): + url = mlist.GetScriptURL(scriptname) + if frontpage: + actionurl = url + else: + actionurl = Utils.GetRequestURI(url) + if msg: + msg = FontAttr(msg, color='#ff0000', size='+1').Format() + if scriptname == 'admindb': + who = _('Moderator') + else: + who = _('Administrator') + # Language stuff + charset = Utils.GetCharSet(mlist.preferred_language) + print 'Content-type: text/html; charset=' + charset + '\n\n' + print Utils.maketext( + 'admlogin.html', + {'listname': mlist.real_name, + 'path' : actionurl, + 'message' : msg, + 'who' : who, + }, mlist=mlist) + print mlist.GetMailmanFooter() diff --git a/Mailman/Cgi/Makefile.in b/Mailman/Cgi/Makefile.in new file mode 100644 index 00000000..a613c2b0 --- /dev/null +++ b/Mailman/Cgi/Makefile.in @@ -0,0 +1,71 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +# NOTE: Makefile.in is converted into Makefile by the configure script +# in the parent directory. Once configure has run, you can recreate +# the Makefile by running just config.status. + +# Variables set by configure + +VPATH= @srcdir@ +srcdir= @srcdir@ +bindir= @bindir@ +prefix= @prefix@ +exec_prefix= @exec_prefix@ + +CC= @CC@ +CHMOD= @CHMOD@ +INSTALL= @INSTALL@ + +DEFS= @DEFS@ + +# Customizable but not set by configure + +OPT= @OPT@ +CFLAGS= $(OPT) $(DEFS) +PACKAGEDIR= $(prefix)/Mailman +CGIDIR= $(PACKAGEDIR)/Cgi +SHELL= /bin/sh + +CGI_MODULES= *.py + + +# Modes for directories and executables created by the install +# process. Default to group-writable directories but +# user-only-writable for executables. +DIRMODE= 775 +EXEMODE= 755 +FILEMODE= 644 +INSTALL_PROGRAM=$(INSTALL) -m $(EXEMODE) + + +# Rules + +all: + +install: + for f in $(CGI_MODULES); \ + do \ + $(INSTALL) -m $(FILEMODE) $(srcdir)/$$f $(CGIDIR); \ + done + +finish: + +clean: + +distclean: + -rm *.pyc + -rm Makefile diff --git a/Mailman/Cgi/__init__.py b/Mailman/Cgi/__init__.py new file mode 100644 index 00000000..2cbbabb1 --- /dev/null +++ b/Mailman/Cgi/__init__.py @@ -0,0 +1,15 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. diff --git a/Mailman/Cgi/admin.py b/Mailman/Cgi/admin.py new file mode 100644 index 00000000..49c6efbf --- /dev/null +++ b/Mailman/Cgi/admin.py @@ -0,0 +1,1407 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Process and produce the list-administration options forms. + +""" + +# For Python 2.1.x compatibility +from __future__ import nested_scopes + +import sys +import os +import re +import cgi +import sha +import urllib +import signal +from types import * +from string import lowercase, digits + +from email.Utils import unquote, parseaddr, formataddr + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import MailList +from Mailman import Errors +from Mailman import MemberAdaptor +from Mailman import i18n +from Mailman.UserDesc import UserDesc +from Mailman.htmlformat import * +from Mailman.Cgi import Auth +from Mailman.Logging.Syslog import syslog + +# Set up i18n +_ = i18n._ +i18n.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE) + +NL = '\n' +OPTCOLUMNS = 11 + + + +def main(): + # Try to find out which list is being administered + parts = Utils.GetPathPieces() + if not parts: + # None, so just do the admin overview and be done with it + admin_overview() + return + # Get the list object + listname = parts[0].lower() + try: + mlist = MailList.MailList(listname, lock=0) + except Errors.MMListError, e: + # Avoid cross-site scripting attacks + safelistname = Utils.websafe(listname) + admin_overview(_('No such list %(safelistname)s')) + syslog('error', 'admin.py access for non-existent list: %s', + listname) + return + # Now that we know what list has been requested, all subsequent admin + # pages are shown in that list's preferred language. + i18n.set_language(mlist.preferred_language) + # If the user is not authenticated, we're done. + cgidata = cgi.FieldStorage(keep_blank_values=1) + + if not mlist.WebAuthenticate((mm_cfg.AuthListAdmin, + mm_cfg.AuthSiteAdmin), + cgidata.getvalue('adminpw', '')): + if cgidata.has_key('adminpw'): + # This is a re-authorization attempt + msg = Bold(FontSize('+1', _('Authorization failed.'))).Format() + else: + msg = '' + Auth.loginpage(mlist, 'admin', msg=msg) + return + + # Which subcategory was requested? Default is `general' + if len(parts) == 1: + category = 'general' + subcat = None + elif len(parts) == 2: + category = parts[1] + subcat = None + else: + category = parts[1] + subcat = parts[2] + + # Is this a log-out request? + if category == 'logout': + print mlist.ZapCookie(mm_cfg.AuthListAdmin) + Auth.loginpage(mlist, 'admin', frontpage=1) + return + + # Sanity check + if category not in mlist.GetConfigCategories().keys(): + category = 'general' + + # Is the request for variable details? + varhelp = None + qsenviron = os.environ.get('QUERY_STRING') + parsedqs = None + if qsenviron: + parsedqs = cgi.parse_qs(qsenviron) + if cgidata.has_key('VARHELP'): + varhelp = cgidata.getvalue('VARHELP') + elif parsedqs: + # POST methods, even if their actions have a query string, don't get + # put into FieldStorage's keys :-( + qs = parsedqs.get('VARHELP') + if qs and isinstance(qs, ListType): + varhelp = qs[0] + if varhelp: + option_help(mlist, varhelp) + return + + # The html page document + doc = Document() + doc.set_language(mlist.preferred_language) + + # From this point on, the MailList object must be locked. However, we + # must release the lock no matter how we exit. try/finally isn't enough, + # because of this scenario: user hits the admin page which may take a long + # time to render; user gets bored and hits the browser's STOP button; + # browser shuts down socket; server tries to write to broken socket and + # gets a SIGPIPE. Under Apache 1.3/mod_cgi, Apache catches this SIGPIPE + # (I presume it is buffering output from the cgi script), then turns + # around and SIGTERMs the cgi process. Apache waits three seconds and + # then SIGKILLs the cgi process. We /must/ catch the SIGTERM and do the + # most reasonable thing we can in as short a time period as possible. If + # we get the SIGKILL we're screwed (because it's uncatchable and we'll + # have no opportunity to clean up after ourselves). + # + # This signal handler catches the SIGTERM, unlocks the list, and then + # exits the process. The effect of this is that the changes made to the + # MailList object will be aborted, which seems like the only sensible + # semantics. + # + # BAW: This may not be portable to other web servers or cgi execution + # models. + def sigterm_handler(signum, frame, mlist=mlist): + # Make sure the list gets unlocked... + mlist.Unlock() + # ...and ensure we exit, otherwise race conditions could cause us to + # enter MailList.Save() while we're in the unlocked state, and that + # could be bad! + sys.exit(0) + + mlist.Lock() + try: + # Install the emergency shutdown signal handler + signal.signal(signal.SIGTERM, sigterm_handler) + + if cgidata.keys(): + # There are options to change + change_options(mlist, category, subcat, cgidata, doc) + # Let the list sanity check the changed values + mlist.CheckValues() + # Additional sanity checks + if not mlist.digestable and not mlist.nondigestable: + doc.addError( + _('''You have turned off delivery of both digest and + non-digest messages. This is an incompatible state of + affairs. You must turn on either digest delivery or + non-digest delivery or your mailing list will basically be + unusable.'''), tag=_('Warning: ')) + + if not mlist.digestable and mlist.getDigestMemberKeys(): + doc.addError( + _('''You have digest members, but digests are turned + off. Those people will not receive mail.'''), + tag=_('Warning: ')) + if not mlist.nondigestable and mlist.getRegularMemberKeys(): + doc.addError( + _('''You have regular list members but non-digestified mail is + turned off. They will receive mail until you fix this + problem.'''), tag=_('Warning: ')) + # Glom up the results page and print it out + show_results(mlist, doc, category, subcat, cgidata) + print doc.Format() + mlist.Save() + finally: + # Now be sure to unlock the list. It's okay if we get a signal here + # because essentially, the signal handler will do the same thing. And + # unlocking is unconditional, so it's not an error if we unlock while + # we're already unlocked. + mlist.Unlock() + + + +def admin_overview(msg=''): + # Show the administrative overview page, with the list of all the lists on + # this host. msg is an optional error message to display at the top of + # the page. + # + # This page should be displayed in the server's default language, which + # should have already been set. + hostname = Utils.get_domain() + legend = _('%(hostname)s mailing lists - Admin Links') + # The html `document' + doc = Document() + doc.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE) + doc.SetTitle(legend) + # The table that will hold everything + table = Table(border=0, width="100%") + table.AddRow([Center(Header(2, legend))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2, + bgcolor=mm_cfg.WEB_HEADER_COLOR) + # Skip any mailing list that isn't advertised. + advertised = [] + listnames = Utils.list_names() + listnames.sort() + + for name in listnames: + mlist = MailList.MailList(name, lock=0) + if mlist.advertised: + if mm_cfg.VIRTUAL_HOST_OVERVIEW and \ + mlist.web_page_url.find(hostname) == -1: + # List is for different identity of this host - skip it. + continue + else: + advertised.append(mlist) + + # Greeting depends on whether there was an error or not + if msg: + greeting = FontAttr(msg, color="ff5060", size="+1") + else: + greeting = _("Welcome!") + + welcome = [] + mailmanlink = Link(mm_cfg.MAILMAN_URL, _('Mailman')).Format() + if not advertised: + welcome.extend([ + greeting, + _('''

      There currently are no publicly-advertised %(mailmanlink)s + mailing lists on %(hostname)s.'''), + ]) + else: + welcome.extend([ + greeting, + _('''

      Below is the collection of publicly-advertised + %(mailmanlink)s mailing lists on %(hostname)s. Click on a list + name to visit the configuration pages for that list.'''), + ]) + + creatorurl = Utils.ScriptURL('create') + mailman_owner = Utils.get_site_email() + extra = msg and _('right ') or '' + welcome.extend([ + _('''To visit the administrators configuration page for an + unadvertised list, open a URL similar to this one, but with a '/' and + the %(extra)slist name appended. If you have the proper authority, + you can also create a new mailing list. + +

      General list information can be found at '''), + Link(Utils.ScriptURL('listinfo'), + _('the mailing list overview page')), + '.', + _('

      (Send questions and comments to '), + Link('mailto:%s' % mailman_owner, mailman_owner), + '.)

      ', + ]) + + table.AddRow([Container(*welcome)]) + table.AddCellInfo(max(table.GetCurrentRowIndex(), 0), 0, colspan=2) + + if advertised: + table.AddRow([' ', ' ']) + table.AddRow([Bold(FontAttr(_('List'), size='+2')), + Bold(FontAttr(_('Description'), size='+2')) + ]) + highlight = 1 + for mlist in advertised: + table.AddRow( + [Link(mlist.GetScriptURL('admin'), Bold(mlist.real_name)), + mlist.description or Italic(_('[no description available]'))]) + if highlight and mm_cfg.WEB_HIGHLIGHT_COLOR: + table.AddRowInfo(table.GetCurrentRowIndex(), + bgcolor=mm_cfg.WEB_HIGHLIGHT_COLOR) + highlight = not highlight + + doc.AddItem(table) + doc.AddItem('


      ') + doc.AddItem(MailmanLogo()) + print doc.Format() + + + +def option_help(mlist, varhelp): + # The html page document + doc = Document() + doc.set_language(mlist.preferred_language) + # Find out which category and variable help is being requested for. + item = None + reflist = varhelp.split('/') + if len(reflist) >= 2: + category = subcat = None + if len(reflist) == 2: + category, varname = reflist + elif len(reflist) == 3: + category, subcat, varname = reflist + options = mlist.GetConfigInfo(category, subcat) + for i in options: + if i and i[0] == varname: + item = i + break + # Print an error message if we couldn't find a valid one + if not item: + bad = _('No valid variable name found.') + doc.addError(bad) + doc.AddItem(mlist.GetMailmanFooter()) + print doc.Format() + return + # Get the details about the variable + varname, kind, params, dependancies, description, elaboration = \ + get_item_characteristics(item) + # Set up the document + realname = mlist.real_name + legend = _("""%(realname)s Mailing list Configuration Help +
      %(varname)s Option""") + + header = Table(width='100%') + header.AddRow([Center(Header(3, legend))]) + header.AddCellInfo(header.GetCurrentRowIndex(), 0, colspan=2, + bgcolor=mm_cfg.WEB_HEADER_COLOR) + doc.SetTitle(_("Mailman %(varname)s List Option Help")) + doc.AddItem(header) + doc.AddItem("%s (%s): %s

      " % (varname, category, description)) + if elaboration: + doc.AddItem("%s

      " % elaboration) + + if subcat: + url = '%s/%s/%s' % (mlist.GetScriptURL('admin'), category, subcat) + else: + url = '%s/%s' % (mlist.GetScriptURL('admin'), category) + form = Form(url) + valtab = Table(cellspacing=3, cellpadding=4, width='100%') + add_options_table_item(mlist, category, subcat, valtab, item, detailsp=0) + form.AddItem(valtab) + form.AddItem('

      ') + form.AddItem(Center(submit_button())) + doc.AddItem(Center(form)) + + doc.AddItem(_("""Warning: changing this option here + could cause other screens to be out-of-sync. Be sure to reload any other + pages that are displaying this option for this mailing list. You can also + """)) + + adminurl = mlist.GetScriptURL('admin') + if subcat: + url = '%s/%s/%s' % (adminurl, category, subcat) + else: + url = '%s/%s' % (adminurl, category) + categoryname = mlist.GetConfigCategories()[category][0] + doc.AddItem(Link(url, _('return to the %(categoryname)s options page.'))) + doc.AddItem('') + doc.AddItem(mlist.GetMailmanFooter()) + print doc.Format() + + + +def show_results(mlist, doc, category, subcat, cgidata): + # Produce the results page + adminurl = mlist.GetScriptURL('admin') + categories = mlist.GetConfigCategories() + label = _(categories[category][0]) + + # Set up the document's headers + realname = mlist.real_name + doc.SetTitle(_('%(realname)s Administration (%(label)s)')) + doc.AddItem(Center(Header(2, _( + '%(realname)s mailing list administration
      %(label)s Section')))) + doc.AddItem('


      ') + # Now we need to craft the form that will be submitted, which will contain + # all the variable settings, etc. This is a bit of a kludge because we + # know that the autoreply and members categories supports file uploads. + encoding = None + if category in ('autoreply', 'members'): + encoding = 'multipart/form-data' + if subcat: + form = Form('%s/%s/%s' % (adminurl, category, subcat), + encoding=encoding) + else: + form = Form('%s/%s' % (adminurl, category), encoding=encoding) + # This holds the two columns of links + linktable = Table(valign='top', width='100%') + linktable.AddRow([Center(Bold(_("Configuration Categories"))), + Center(Bold(_("Other Administrative Activities")))]) + # The `other links' are stuff in the right column. + otherlinks = UnorderedList() + otherlinks.AddItem(Link(mlist.GetScriptURL('admindb'), + _('Tend to pending moderator requests'))) + otherlinks.AddItem(Link(mlist.GetScriptURL('listinfo'), + _('Go to the general list information page'))) + otherlinks.AddItem(Link(mlist.GetScriptURL('edithtml'), + _('Edit the public HTML pages'))) + otherlinks.AddItem(Link(mlist.GetBaseArchiveURL(), + _('Go to list archives')).Format() + + '
       
      ') + # We do not allow through-the-web deletion of the site list! + if mm_cfg.OWNERS_CAN_DELETE_THEIR_OWN_LISTS and \ + mlist.internal_name() <> mm_cfg.MAILMAN_SITE_LIST: + otherlinks.AddItem(Link(mlist.GetScriptURL('rmlist'), + _('Delete this mailing list')).Format() + + _(' (requires confirmation)
       
      ')) + otherlinks.AddItem(Link('%s/logout' % adminurl, + # BAW: What I really want is a blank line, but + # adding an   won't do it because of the + # bullet added to the list item. + '%s' % + _('Logout'))) + # These are links to other categories and live in the left column + categorylinks_1 = categorylinks = UnorderedList() + categorylinks_2 = '' + categorykeys = categories.keys() + half = len(categorykeys) / 2 + counter = 0 + subcat = None + for k in categorykeys: + label = _(categories[k][0]) + url = '%s/%s' % (adminurl, k) + if k == category: + # Handle subcategories + subcats = mlist.GetConfigSubCategories(k) + if subcats: + subcat = Utils.GetPathPieces()[-1] + for k, v in subcats: + if k == subcat: + break + else: + # The first subcategory in the list is the default + subcat = subcats[0][0] + subcat_items = [] + for sub, text in subcats: + if sub == subcat: + text = Bold('[%s]' % text).Format() + subcat_items.append(Link(url + '/' + sub, text)) + categorylinks.AddItem( + Bold(label).Format() + + UnorderedList(*subcat_items).Format()) + else: + categorylinks.AddItem(Link(url, Bold('[%s]' % label))) + else: + categorylinks.AddItem(Link(url, label)) + counter += 1 + if counter >= half: + categorylinks_2 = categorylinks = UnorderedList() + counter = -len(categorykeys) + # Make the emergency stop switch a rude solo light + etable = Table() + # Add all the links to the links table... + etable.AddRow([categorylinks_1, categorylinks_2]) + etable.AddRowInfo(etable.GetCurrentRowIndex(), valign='top') + if mlist.emergency: + label = _('Emergency moderation of all list traffic is enabled') + etable.AddRow([Center( + Link('?VARHELP=general/emergency', Bold(label)))]) + color = mm_cfg.WEB_ERROR_COLOR + etable.AddCellInfo(etable.GetCurrentRowIndex(), 0, + colspan=2, bgcolor=color) + linktable.AddRow([etable, otherlinks]) + # ...and add the links table to the document. + form.AddItem(linktable) + form.AddItem('
      ') + form.AddItem( + _('''Make your changes in the following section, then submit them + using the Submit Your Changes button below.''') + + '

      ') + + # The members and passwords categories are special in that they aren't + # defined in terms of gui elements. Create those pages here. + if category == 'members': + # Figure out which subcategory we should display + subcat = Utils.GetPathPieces()[-1] + if subcat not in ('list', 'add', 'remove'): + subcat = 'list' + # Add member category specific tables + form.AddItem(membership_options(mlist, subcat, cgidata, doc, form)) + form.AddItem(Center(submit_button('setmemberopts_btn'))) + # In "list" subcategory, we can also search for members + if subcat == 'list': + form.AddItem('


      \n') + table = Table(width='100%') + table.AddRow([Center(Header(2, _('Additional Member Tasks')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2, + bgcolor=mm_cfg.WEB_HEADER_COLOR) + # Add a blank separator row + table.AddRow([' ', ' ']) + # Add a section to set the moderation bit for all members + table.AddRow([_("""
    • Set everyone's moderation bit, including + those members not currently visible""")]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([RadioButtonArray('allmodbit_val', + (_('Off'), _('On')), + mlist.default_member_moderation), + SubmitButton('allmodbit_btn', _('Set'))]) + form.AddItem(table) + elif category == 'passwords': + form.AddItem(Center(password_inputs(mlist))) + form.AddItem(Center(submit_button())) + else: + form.AddItem(show_variables(mlist, category, subcat, cgidata, doc)) + form.AddItem(Center(submit_button())) + # And add the form + doc.AddItem(form) + doc.AddItem(mlist.GetMailmanFooter()) + + + +def show_variables(mlist, category, subcat, cgidata, doc): + options = mlist.GetConfigInfo(category, subcat) + + # The table containing the results + table = Table(cellspacing=3, cellpadding=4, width='100%') + + # Get and portray the text label for the category. + categories = mlist.GetConfigCategories() + label = _(categories[category][0]) + + table.AddRow([Center(Header(2, label))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2, + bgcolor=mm_cfg.WEB_HEADER_COLOR) + + # The very first item in the config info will be treated as a general + # description if it is a string + description = options[0] + if isinstance(description, StringType): + table.AddRow([description]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + options = options[1:] + + if not options: + return table + + # Add the global column headers + table.AddRow([Center(Bold(_('Description'))), + Center(Bold(_('Value')))]) + table.AddCellInfo(max(table.GetCurrentRowIndex(), 0), 0, + width='15%') + table.AddCellInfo(max(table.GetCurrentRowIndex(), 0), 1, + width='85%') + + for item in options: + if type(item) == StringType: + # The very first banner option (string in an options list) is + # treated as a general description, while any others are + # treated as section headers - centered and italicized... + table.AddRow([Center(Italic(item))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + else: + add_options_table_item(mlist, category, subcat, table, item) + table.AddRow(['
      ']) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + return table + + + +def add_options_table_item(mlist, category, subcat, table, item, detailsp=1): + # Add a row to an options table with the item description and value. + varname, kind, params, extra, descr, elaboration = \ + get_item_characteristics(item) + if elaboration is None: + elaboration = descr + descr = get_item_gui_description(mlist, category, subcat, + varname, descr, elaboration, detailsp) + val = get_item_gui_value(mlist, category, kind, varname, params, extra) + table.AddRow([descr, val]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, + bgcolor=mm_cfg.WEB_ADMINITEM_COLOR) + table.AddCellInfo(table.GetCurrentRowIndex(), 1, + bgcolor=mm_cfg.WEB_ADMINITEM_COLOR) + + + +def get_item_characteristics(record): + # Break out the components of an item description from its description + # record: + # + # 0 -- option-var name + # 1 -- type + # 2 -- entry size + # 3 -- ?dependancies? + # 4 -- Brief description + # 5 -- Optional description elaboration + if len(record) == 5: + elaboration = None + varname, kind, params, dependancies, descr = record + elif len(record) == 6: + varname, kind, params, dependancies, descr, elaboration = record + else: + raise ValueError, _('Badly formed options entry:\n %(record)s') + return varname, kind, params, dependancies, descr, elaboration + + + +def get_item_gui_value(mlist, category, kind, varname, params, extra): + """Return a representation of an item's settings.""" + # Give the category a chance to return the value for the variable + value = None + label, gui = mlist.GetConfigCategories()[category] + if hasattr(gui, 'getValue'): + value = gui.getValue(mlist, kind, varname, params) + # Filter out None, and volatile attributes + if value is None and not varname.startswith('_'): + value = getattr(mlist, varname) + # Now create the widget for this value + if kind == mm_cfg.Radio or kind == mm_cfg.Toggle: + # If we are returning the option for subscribe policy and this site + # doesn't allow open subscribes, then we have to alter the value of + # mlist.subscribe_policy as passed to RadioButtonArray in order to + # compensate for the fact that there is one fewer option. + # Correspondingly, we alter the value back in the change options + # function -scott + # + # TBD: this is an ugly ugly hack. + if varname.startswith('_'): + checked = 0 + else: + checked = value + if varname == 'subscribe_policy' and not mm_cfg.ALLOW_OPEN_SUBSCRIBE: + checked = checked - 1 + # For Radio buttons, we're going to interpret the extra stuff as a + # horizontal/vertical flag. For backwards compatibility, the value 0 + # means horizontal, so we use "not extra" to get the parity right. + return RadioButtonArray(varname, params, checked, not extra) + elif (kind == mm_cfg.String or kind == mm_cfg.Email or + kind == mm_cfg.Host or kind == mm_cfg.Number): + return TextBox(varname, value, params) + elif kind == mm_cfg.Text: + if params: + r, c = params + else: + r, c = None, None + return TextArea(varname, value or '', r, c) + elif kind in (mm_cfg.EmailList, mm_cfg.EmailListEx): + if params: + r, c = params + else: + r, c = None, None + res = NL.join(value) + return TextArea(varname, res, r, c, wrap='off') + elif kind == mm_cfg.FileUpload: + # like a text area, but also with uploading + if params: + r, c = params + else: + r, c = None, None + container = Container() + container.AddItem(_('Enter the text below, or...
      ')) + container.AddItem(TextArea(varname, value or '', r, c)) + container.AddItem(_('
      ...specify a file to upload
      ')) + container.AddItem(FileUpload(varname+'_upload', r, c)) + return container + elif kind == mm_cfg.Select: + if params: + values, legend, selected = params + else: + values = mlist.GetAvailableLanguages() + legend = map(_, map(Utils.GetLanguageDescr, values)) + selected = values.index(mlist.preferred_language) + return SelectOptions(varname, values, legend, selected) + elif kind == mm_cfg.Topics: + # A complex and specialized widget type that allows for setting of a + # topic name, a mark button, a regexp text box, an "add after mark", + # and a delete button. Yeesh! params are ignored. + table = Table(border=0) + # This adds the html for the entry widget + def makebox(i, name, pattern, desc, empty=0, table=table): + deltag = 'topic_delete_%02d' % i + boxtag = 'topic_box_%02d' % i + reboxtag = 'topic_rebox_%02d' % i + desctag = 'topic_desc_%02d' % i + wheretag = 'topic_where_%02d' % i + addtag = 'topic_add_%02d' % i + newtag = 'topic_new_%02d' % i + if empty: + table.AddRow([Center(Bold(_('Topic %(i)d'))), + Hidden(newtag)]) + else: + table.AddRow([Center(Bold(_('Topic %(i)d'))), + SubmitButton(deltag, _('Delete'))]) + table.AddRow([Label(_('Topic name:')), + TextBox(boxtag, value=name, size=30)]) + table.AddRow([Label(_('Regexp:')), + TextArea(reboxtag, text=pattern, + rows=4, cols=30, wrap='off')]) + table.AddRow([Label(_('Description:')), + TextArea(desctag, text=desc, + rows=4, cols=30, wrap='soft')]) + if not empty: + table.AddRow([SubmitButton(addtag, _('Add new item...')), + SelectOptions(wheretag, ('before', 'after'), + (_('...before this one.'), + _('...after this one.')), + selected=1), + ]) + table.AddRow(['
      ']) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + # Now for each element in the existing data, create a widget + i = 1 + data = getattr(mlist, varname) + for name, pattern, desc, empty in data: + makebox(i, name, pattern, desc, empty) + i += 1 + # Add one more non-deleteable widget as the first blank entry, but + # only if there are no real entries. + if i == 1: + makebox(i, '', '', '', empty=1) + return table + elif kind == mm_cfg.Checkbox: + return CheckBoxArray(varname, *params) + else: + assert 0, 'Bad gui widget type: %s' % kind + + + +def get_item_gui_description(mlist, category, subcat, + varname, descr, elaboration, detailsp): + # Return the item's description, with link to details. + # + # Details are not included if this is a VARHELP page, because that /is/ + # the details page! + if detailsp: + if subcat: + varhelp = '/?VARHELP=%s/%s/%s' % (category, subcat, varname) + else: + varhelp = '/?VARHELP=%s/%s' % (category, varname) + if descr == elaboration: + linktext = _('
      (Edit %(varname)s)') + else: + linktext = _('
      (Details for %(varname)s)') + link = Link(mlist.GetScriptURL('admin') + varhelp, + linktext).Format() + text = Label('%s %s' % (descr, link)).Format() + else: + text = Label(descr).Format() + if varname[0] == '_': + text += Label(_('''
      Note: + setting this value performs an immediate action but does not modify + permanent state.''')).Format() + return text + + + +def membership_options(mlist, subcat, cgidata, doc, form): + # Show the main stuff + adminurl = mlist.GetScriptURL('admin', absolute=1) + container = Container() + header = Table(width="100%") + # If we're in the list subcategory, show the membership list + if subcat == 'add': + header.AddRow([Center(Header(2, _('Mass Subscriptions')))]) + header.AddCellInfo(header.GetCurrentRowIndex(), 0, colspan=2, + bgcolor=mm_cfg.WEB_HEADER_COLOR) + container.AddItem(header) + mass_subscribe(mlist, container) + return container + if subcat == 'remove': + header.AddRow([Center(Header(2, _('Mass Removals')))]) + header.AddCellInfo(header.GetCurrentRowIndex(), 0, colspan=2, + bgcolor=mm_cfg.WEB_HEADER_COLOR) + container.AddItem(header) + mass_remove(mlist, container) + return container + # Otherwise... + header.AddRow([Center(Header(2, _('Membership List')))]) + header.AddCellInfo(header.GetCurrentRowIndex(), 0, colspan=2, + bgcolor=mm_cfg.WEB_HEADER_COLOR) + container.AddItem(header) + # Add a "search for member" button + table = Table(width='100%') + link = Link('http://www.python.org/doc/current/lib/re-syntax.html', + _('(help)')).Format() + table.AddRow([Label(_('Find member %(link)s:')), + TextBox('findmember', + value=cgidata.getvalue('findmember', '')), + SubmitButton('findmember_btn', _('Search...'))]) + container.AddItem(table) + container.AddItem('

      ') + usertable = Table(width="90%", border='2') + # If there are more members than allowed by chunksize, then we split the + # membership up alphabetically. Otherwise just display them all. + chunksz = mlist.admin_member_chunksize + all = mlist.getMembers() + all.sort(lambda x, y: cmp(x.lower(), y.lower())) + # See if the query has a regular expression + regexp = cgidata.getvalue('findmember', '').strip() + if regexp: + try: + cre = re.compile(regexp, re.IGNORECASE) + except re.error: + doc.addError(_('Bad regular expression: ') + regexp) + else: + # BAW: There's got to be a more efficient way of doing this! + names = [mlist.getMemberName(s) or '' for s in all] + all = [a for n, a in zip(names, all) + if cre.search(n) or cre.search(a)] + chunkindex = None + bucket = None + actionurl = None + if len(all) < chunksz: + members = all + else: + # Split them up alphabetically, and then split the alphabetical + # listing by chunks + buckets = {} + for addr in all: + members = buckets.setdefault(addr[0].lower(), []) + members.append(addr) + # Now figure out which bucket we want + bucket = None + qs = {} + # POST methods, even if their actions have a query string, don't get + # put into FieldStorage's keys :-( + qsenviron = os.environ.get('QUERY_STRING') + if qsenviron: + qs = cgi.parse_qs(qsenviron) + bucket = qs.get('letter', 'a')[0].lower() + if bucket not in digits + lowercase: + bucket = None + if not bucket or not buckets.has_key(bucket): + keys = buckets.keys() + keys.sort() + bucket = keys[0] + members = buckets[bucket] + action = adminurl + '/members?letter=%s' % bucket + if len(members) <= chunksz: + form.set_action(action) + else: + i, r = divmod(len(members), chunksz) + numchunks = i + (not not r * 1) + # Now chunk them up + chunkindex = 0 + if qs.has_key('chunk'): + try: + chunkindex = int(qs['chunk'][0]) + except ValueError: + chunkindex = 0 + if chunkindex < 0 or chunkindex > numchunks: + chunkindex = 0 + members = members[chunkindex*chunksz:(chunkindex+1)*chunksz] + # And set the action URL + form.set_action(action + '&chunk=%s' % chunkindex) + # So now members holds all the addresses we're going to display + allcnt = len(all) + if bucket: + membercnt = len(members) + usertable.AddRow([Center(Italic(_( + '%(allcnt)s members total, %(membercnt)s shown')))]) + else: + usertable.AddRow([Center(Italic(_('%(allcnt)s members total')))]) + usertable.AddCellInfo(usertable.GetCurrentRowIndex(), + usertable.GetCurrentCellIndex(), + colspan=OPTCOLUMNS, + bgcolor=mm_cfg.WEB_ADMINITEM_COLOR) + # Add the alphabetical links + if bucket: + cells = [] + for letter in digits + lowercase: + if not buckets.get(letter): + continue + url = adminurl + '/members?letter=%s' % letter + if letter == bucket: + show = Bold('[%s]' % letter.upper()).Format() + else: + show = letter.upper() + cells.append(Link(url, show).Format()) + joiner = ' '*2 + '\n' + usertable.AddRow([Center(joiner.join(cells))]) + usertable.AddCellInfo(usertable.GetCurrentRowIndex(), + usertable.GetCurrentCellIndex(), + colspan=OPTCOLUMNS, + bgcolor=mm_cfg.WEB_ADMINITEM_COLOR) + usertable.AddRow([Center(h) for h in (_('unsub'), + _('member address
      member name'), + _('mod'), _('hide'), + _('nomail
      [reason]'), + _('ack'), _('not metoo'), + _('nodupes'), + _('digest'), _('plain'), + _('language'))]) + rowindex = usertable.GetCurrentRowIndex() + for i in range(OPTCOLUMNS): + usertable.AddCellInfo(rowindex, i, bgcolor=mm_cfg.WEB_ADMINITEM_COLOR) + # Find the longest name in the list + longest = 0 + if members: + names = filter(None, [mlist.getMemberName(s) for s in members]) + # Make the name field at least as long as the longest email address + longest = max([len(s) for s in names + members]) + # Abbreviations for delivery status details + ds_abbrevs = {MemberAdaptor.UNKNOWN : _('?'), + MemberAdaptor.BYUSER : _('U'), + MemberAdaptor.BYADMIN : _('A'), + MemberAdaptor.BYBOUNCE: _('B'), + } + # Now populate the rows + for addr in members: + link = Link(mlist.GetOptionsURL(addr, obscure=1), + mlist.getMemberCPAddress(addr)) + fullname = Utils.uncanonstr(mlist.getMemberName(addr), + mlist.preferred_language) + name = TextBox(addr + '_realname', fullname, size=longest).Format() + cells = [Center(CheckBox(addr + '_unsub', 'off', 0).Format()), + link.Format() + '
      ' + + name + + Hidden('user', urllib.quote(addr)).Format(), + ] + # Do the `mod' option + if mlist.getMemberOption(addr, mm_cfg.Moderate): + value = 'on' + checked = 1 + else: + value = 'off' + checked = 0 + box = CheckBox('%s_mod' % addr, value, checked) + cells.append(Center(box).Format()) + for opt in ('hide', 'nomail', 'ack', 'notmetoo', 'nodupes'): + extra = '' + if opt == 'nomail': + status = mlist.getDeliveryStatus(addr) + if status == MemberAdaptor.ENABLED: + value = 'off' + checked = 0 + else: + value = 'on' + checked = 1 + extra = '[%s]' % ds_abbrevs[status] + elif mlist.getMemberOption(addr, mm_cfg.OPTINFO[opt]): + value = 'on' + checked = 1 + else: + value = 'off' + checked = 0 + box = CheckBox('%s_%s' % (addr, opt), value, checked) + cells.append(Center(box.Format() + extra)) + # This code is less efficient than the original which did a has_key on + # the underlying dictionary attribute. This version is slower and + # less memory efficient. It points to a new MemberAdaptor interface + # method. + if addr in mlist.getRegularMemberKeys(): + cells.append(Center(CheckBox(addr + '_digest', 'off', 0).Format())) + else: + cells.append(Center(CheckBox(addr + '_digest', 'on', 1).Format())) + if mlist.getMemberOption(addr, mm_cfg.OPTINFO['plain']): + value = 'on' + checked = 1 + else: + value = 'off' + checked = 0 + cells.append(Center(CheckBox('%s_plain' % addr, value, checked))) + # User's preferred language + langpref = mlist.getMemberLanguage(addr) + langs = mlist.GetAvailableLanguages() + langdescs = [_(Utils.GetLanguageDescr(lang)) for lang in langs] + try: + selected = langs.index(langpref) + except ValueError: + selected = 0 + cells.append(Center(SelectOptions(addr + '_language', langs, + langdescs, selected)).Format()) + usertable.AddRow(cells) + # Add the usertable and a legend + legend = UnorderedList() + legend.AddItem( + _('unsub -- Click on this to unsubscribe the member.')) + legend.AddItem( + _("""mod -- The user's personal moderation flag. If this is + set, postings from them will be moderated, otherwise they will be + approved.""")) + legend.AddItem( + _("""hide -- Is the member's address concealed on + the list of subscribers?""")) + legend.AddItem(_( + """nomail -- Is delivery to the member disabled? If so, an + abbreviation will be given describing the reason for the disabled + delivery: +

      • U -- Delivery was disabled by the user via their + personal options page. +
      • A -- Delivery was disabled by the list + administrators. +
      • B -- Delivery was disabled by the system due to + excessive bouncing from the member's address. +
      • ? -- The reason for disabled delivery isn't known. + This is the case for all memberships which were disabled + in older versions of Mailman. +
      """)) + legend.AddItem( + _('''ack -- Does the member get acknowledgements of their + posts?''')) + legend.AddItem( + _('''not metoo -- Does the member want to avoid copies of their + own postings?''')) + legend.AddItem( + _('''nodupes -- Does the member want to avoid duplicates of the + same message?''')) + legend.AddItem( + _('''digest -- Does the member get messages in digests? + (otherwise, individual messages)''')) + legend.AddItem( + _('''plain -- If getting digests, does the member get plain + text digests? (otherwise, MIME)''')) + legend.AddItem(_("language -- Language preferred by the user")) + addlegend = '' + parsedqs = 0 + qsenviron = os.environ.get('QUERY_STRING') + if qsenviron: + qs = cgi.parse_qs(qsenviron).get('legend') + if qs and isinstance(qs, ListType): + qs = qs[0] + if qs == 'yes': + addlegend = 'legend=yes&' + if addlegend: + container.AddItem(legend.Format() + '

      ') + container.AddItem( + Link(adminurl + '/members/list', + _('Click here to hide the legend for this table.'))) + else: + container.AddItem( + Link(adminurl + '/members/list?legend=yes', + _('Click here to include the legend for this table.'))) + container.AddItem(Center(usertable)) + + # There may be additional chunks + if chunkindex is not None: + buttons = [] + url = adminurl + '/members?%sletter=%s&' % (addlegend, bucket) + footer = _('''

      To view more members, click on the appropriate + range listed below:''') + chunkmembers = buckets[bucket] + last = len(chunkmembers) + for i in range(numchunks): + if i == chunkindex: + continue + start = chunkmembers[i*chunksz] + end = chunkmembers[min((i+1)*chunksz, last)-1] + link = Link(url + 'chunk=%d' % i, _('from %(start)s to %(end)s')) + buttons.append(link) + buttons = UnorderedList(*buttons) + container.AddItem(footer + buttons.Format() + '

      ') + return container + + + +def mass_subscribe(mlist, container): + # MASS SUBSCRIBE + GREY = mm_cfg.WEB_ADMINITEM_COLOR + table = Table(width='90%') + table.AddRow([ + Label(_('Subscribe these users now or invite them?')), + RadioButtonArray('subscribe_or_invite', + (_('Subscribe'), _('Invite')), + 0, values=(0, 1)) + ]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, bgcolor=GREY) + table.AddCellInfo(table.GetCurrentRowIndex(), 1, bgcolor=GREY) + table.AddRow([ + Label(_('Send welcome messages to new subscribees?')), + RadioButtonArray('send_welcome_msg_to_this_batch', + (_('No'), _('Yes')), + mlist.send_welcome_msg, + values=(0, 1)) + ]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, bgcolor=GREY) + table.AddCellInfo(table.GetCurrentRowIndex(), 1, bgcolor=GREY) + table.AddRow([ + Label(_('Send notifications of new subscriptions to the list owner?')), + RadioButtonArray('send_notifications_to_list_owner', + (_('No'), _('Yes')), + mlist.admin_notify_mchanges, + values=(0,1)) + ]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, bgcolor=GREY) + table.AddCellInfo(table.GetCurrentRowIndex(), 1, bgcolor=GREY) + table.AddRow([Italic(_('Enter one address per line below...'))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([Center(TextArea(name='subscribees', + rows=10, cols='70%', wrap=None))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([Italic(Label(_('...or specify a file to upload:'))), + FileUpload('subscribees_upload', cols='50')]) + container.AddItem(Center(table)) + # Invitation text + table.AddRow([' ', ' ']) + table.AddRow([Italic(_("""Below, enter additional text to be added to the + top of your invitation or the subscription notification. Include at least + one blank line at the end..."""))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([Center(TextArea(name='invitation', + rows=10, cols='70%', wrap=None))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + + + +def mass_remove(mlist, container): + # MASS UNSUBSCRIBE + GREY = mm_cfg.WEB_ADMINITEM_COLOR + table = Table(width='90%') + table.AddRow([ + Label(_('Send unsubscription acknowledgement to the user?')), + RadioButtonArray('send_unsub_ack_to_this_batch', + (_('No'), _('Yes')), + 0, values=(0, 1)) + ]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, bgcolor=GREY) + table.AddCellInfo(table.GetCurrentRowIndex(), 1, bgcolor=GREY) + table.AddRow([ + Label(_('Send notifications to the list owner?')), + RadioButtonArray('send_unsub_notifications_to_list_owner', + (_('No'), _('Yes')), + mlist.admin_notify_mchanges, + values=(0, 1)) + ]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, bgcolor=GREY) + table.AddCellInfo(table.GetCurrentRowIndex(), 1, bgcolor=GREY) + table.AddRow([Italic(_('Enter one address per line below...'))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([Center(TextArea(name='unsubscribees', + rows=10, cols='70%', wrap=None))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([Italic(Label(_('...or specify a file to upload:'))), + FileUpload('unsubscribees_upload', cols='50')]) + container.AddItem(Center(table)) + + + +def password_inputs(mlist): + adminurl = mlist.GetScriptURL('admin', absolute=1) + table = Table(cellspacing=3, cellpadding=4) + table.AddRow([Center(Header(2, _('Change list ownership passwords')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2, + bgcolor=mm_cfg.WEB_HEADER_COLOR) + table.AddRow([_("""\ +The list administrators are the people who have ultimate control over +all parameters of this mailing list. They are able to change any list +configuration variable available through these administration web pages. + +

      The list moderators have more limited permissions; they are not +able to change any list configuration variable, but they are allowed to tend +to pending administration requests, including approving or rejecting held +subscription requests, and disposing of held postings. Of course, the +list administrators can also tend to pending requests. + +

      In order to split the list ownership duties into administrators and +moderators, you must set a separate moderator password in the fields below, +and also provide the email addresses of the list moderators in the +general options section.""")]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + # Set up the admin password table on the left + atable = Table(border=0, cellspacing=3, cellpadding=4, + bgcolor=mm_cfg.WEB_ADMINPW_COLOR) + atable.AddRow([Label(_('Enter new administrator password:')), + PasswordBox('newpw', size=20)]) + atable.AddRow([Label(_('Confirm administrator password:')), + PasswordBox('confirmpw', size=20)]) + # Set up the moderator password table on the right + mtable = Table(border=0, cellspacing=3, cellpadding=4, + bgcolor=mm_cfg.WEB_ADMINPW_COLOR) + mtable.AddRow([Label(_('Enter new moderator password:')), + PasswordBox('newmodpw', size=20)]) + mtable.AddRow([Label(_('Confirm moderator password:')), + PasswordBox('confirmmodpw', size=20)]) + # Add these tables to the overall password table + table.AddRow([atable, mtable]) + return table + + + +def submit_button(name='submit'): + table = Table(border=0, cellspacing=0, cellpadding=2) + table.AddRow([Bold(SubmitButton(name, _('Submit Your Changes')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, align='middle') + return table + + + +def change_options(mlist, category, subcat, cgidata, doc): + def safeint(formvar, defaultval=None): + try: + return int(cgidata.getvalue(formvar)) + except (ValueError, TypeError): + return defaultval + confirmed = 0 + # Handle changes to the list moderator password. Do this before checking + # the new admin password, since the latter will force a reauthentication. + new = cgidata.getvalue('newmodpw', '').strip() + confirm = cgidata.getvalue('confirmmodpw', '').strip() + if new or confirm: + if new == confirm: + mlist.mod_password = sha.new(new).hexdigest() + # No re-authentication necessary because the moderator's + # password doesn't get you into these pages. + else: + doc.addError(_('Moderator passwords did not match')) + # Handle changes to the list administrator password + new = cgidata.getvalue('newpw', '').strip() + confirm = cgidata.getvalue('confirmpw', '').strip() + if new or confirm: + if new == confirm: + mlist.password = sha.new(new).hexdigest() + # Set new cookie + print mlist.MakeCookie(mm_cfg.AuthListAdmin) + else: + doc.addError(_('Administrator passwords did not match')) + # Give the individual gui item a chance to process the form data + categories = mlist.GetConfigCategories() + label, gui = categories[category] + # BAW: We handle the membership page special... for now. + if category <> 'members': + gui.handleForm(mlist, category, subcat, cgidata, doc) + # mass subscription, removal processing for members category + subscribers = '' + subscribers += cgidata.getvalue('subscribees', '') + subscribers += cgidata.getvalue('subscribees_upload', '') + if subscribers: + entries = filter(None, [n.strip() for n in subscribers.splitlines()]) + send_welcome_msg = safeint('send_welcome_msg_to_this_batch', + mlist.send_welcome_msg) + send_admin_notif = safeint('send_notifications_to_list_owner', + mlist.admin_notify_mchanges) + # Default is to subscribe + subscribe_or_invite = safeint('subscribe_or_invite', 0) + invitation = cgidata.getvalue('invitation', '') + digest = 0 + if not mlist.digestable: + digest = 0 + if not mlist.nondigestable: + digest = 1 + subscribe_errors = [] + subscribe_success = [] + # Now cruise through all the subscribees and do the deed. BAW: we + # should limit the number of "Successfully subscribed" status messages + # we display. Try uploading a file with 10k names -- it takes a while + # to render the status page. + for entry in entries: + fullname, address = parseaddr(entry) + # Canonicalize the full name + fullname = Utils.canonstr(fullname, mlist.preferred_language) + userdesc = UserDesc(address, fullname, + Utils.MakeRandomPassword(), + digest, mlist.preferred_language) + try: + if subscribe_or_invite: + if mlist.isMember(address): + raise Errors.MMAlreadyAMember + else: + mlist.InviteNewMember(userdesc, invitation) + else: + mlist.ApprovedAddMember(userdesc, send_welcome_msg, + send_admin_notif, invitation) + except Errors.MMAlreadyAMember: + subscribe_errors.append((entry, _('Already a member'))) + except Errors.MMBadEmailError: + if userdesc.address == '': + subscribe_errors.append((_('<blank line>'), + _('Bad/Invalid email address'))) + else: + subscribe_errors.append((entry, + _('Bad/Invalid email address'))) + except Errors.MMHostileAddress: + subscribe_errors.append( + (entry, _('Hostile address (illegal characters)'))) + else: + member = Utils.uncanonstr(formataddr((fullname, address))) + subscribe_success.append(Utils.websafe(member)) + if subscribe_success: + if subscribe_or_invite: + doc.AddItem(Header(5, _('Successfully invited:'))) + else: + doc.AddItem(Header(5, _('Successfully subscribed:'))) + doc.AddItem(UnorderedList(*subscribe_success)) + doc.AddItem('

      ') + if subscribe_errors: + if subscribe_or_invite: + doc.AddItem(Header(5, _('Error inviting:'))) + else: + doc.AddItem(Header(5, _('Error subscribing:'))) + items = ['%s -- %s' % (x0, x1) for x0, x1 in subscribe_errors] + doc.AddItem(UnorderedList(*items)) + doc.AddItem('

      ') + # Unsubscriptions + removals = '' + if cgidata.has_key('unsubscribees'): + removals += cgidata['unsubscribees'].value + if cgidata.has_key('unsubscribees_upload') and \ + cgidata['unsubscribees_upload'].value: + removals += cgidata['unsubscribees_upload'].value + if removals: + names = filter(None, [n.strip() for n in removals.splitlines()]) + send_unsub_notifications = int( + cgidata['send_unsub_notifications_to_list_owner'].value) + userack = int( + cgidata['send_unsub_ack_to_this_batch'].value) + unsubscribe_errors = [] + unsubscribe_success = [] + for addr in names: + try: + mlist.ApprovedDeleteMember( + addr, whence='admin mass unsub', + admin_notif=send_unsub_notifications, + userack=userack) + unsubscribe_success.append(addr) + except Errors.NotAMemberError: + unsubscribe_errors.append(addr) + if unsubscribe_success: + doc.AddItem(Header(5, _('Successfully Unsubscribed:'))) + doc.AddItem(UnorderedList(*unsubscribe_success)) + doc.AddItem('

      ') + if unsubscribe_errors: + doc.AddItem(Header(3, Bold(FontAttr( + _('Cannot unsubscribe non-members:'), + color='#ff0000', size='+2')).Format())) + doc.AddItem(UnorderedList(*unsubscribe_errors)) + doc.AddItem('

      ') + # See if this was a moderation bit operation + if cgidata.has_key('allmodbit_btn'): + val = cgidata.getvalue('allmodbit_val') + try: + val = int(val) + except VallueError: + val = None + if val not in (0, 1): + doc.addError(_('Bad moderation flag value')) + else: + for member in mlist.getMembers(): + mlist.setMemberOption(member, mm_cfg.Moderate, val) + # do the user options for members category + if cgidata.has_key('setmemberopts_btn') and cgidata.has_key('user'): + user = cgidata['user'] + if type(user) is ListType: + users = [] + for ui in range(len(user)): + users.append(urllib.unquote(user[ui].value)) + else: + users = [urllib.unquote(user.value)] + errors = [] + removes = [] + for user in users: + if cgidata.has_key('%s_unsub' % user): + try: + mlist.ApprovedDeleteMember(user) + removes.append(user) + except Errors.NotAMemberError: + errors.append((user, _('Not subscribed'))) + continue + if not mlist.isMember(user): + doc.addError(_('Ignoring changes to deleted member: %(user)s'), + tag=_('Warning: ')) + continue + value = cgidata.has_key('%s_digest' % user) + try: + mlist.setMemberOption(user, mm_cfg.Digests, value) + except (Errors.AlreadyReceivingDigests, + Errors.AlreadyReceivingRegularDeliveries, + Errors.CantDigestError, + Errors.MustDigestError): + # BAW: Hmm... + pass + + newname = cgidata.getvalue(user+'_realname', '') + newname = Utils.canonstr(newname, mlist.preferred_language) + mlist.setMemberName(user, newname) + + newlang = cgidata.getvalue(user+'_language') + oldlang = mlist.getMemberLanguage(user) + if newlang and newlang <> oldlang: + mlist.setMemberLanguage(user, newlang) + + moderate = not not cgidata.getvalue(user+'_mod') + mlist.setMemberOption(user, mm_cfg.Moderate, moderate) + + # Set the `nomail' flag, but only if the user isn't already + # disabled (otherwise we might change BYUSER into BYADMIN). + if cgidata.has_key('%s_nomail' % user): + if mlist.getDeliveryStatus(user) == MemberAdaptor.ENABLED: + mlist.setDeliveryStatus(user, MemberAdaptor.BYADMIN) + else: + mlist.setDeliveryStatus(user, MemberAdaptor.ENABLED) + for opt in ('hide', 'ack', 'notmetoo', 'nodupes', 'plain'): + opt_code = mm_cfg.OPTINFO[opt] + if cgidata.has_key('%s_%s' % (user, opt)): + mlist.setMemberOption(user, opt_code, 1) + else: + mlist.setMemberOption(user, opt_code, 0) + # Give some feedback on who's been removed + if removes: + doc.AddItem(Header(5, _('Successfully Removed:'))) + doc.AddItem(UnorderedList(*removes)) + doc.AddItem('

      ') + if errors: + doc.AddItem(Header(5, _("Error Unsubscribing:"))) + items = ['%s -- %s' % (x[0], x[1]) for x in errors] + doc.AddItem(apply(UnorderedList, tuple((items)))) + doc.AddItem("

      ") diff --git a/Mailman/Cgi/admindb.py b/Mailman/Cgi/admindb.py new file mode 100644 index 00000000..e6b71cda --- /dev/null +++ b/Mailman/Cgi/admindb.py @@ -0,0 +1,769 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Produce and process the pending-approval items for a list.""" + +import sys +import os +import cgi +import errno +import signal +import email +import time +from types import ListType +from urllib import quote_plus, unquote_plus + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import MailList +from Mailman import Errors +from Mailman import Message +from Mailman import i18n +from Mailman.Handlers.Moderate import ModeratedMemberPost +from Mailman.ListAdmin import readMessage +from Mailman.Cgi import Auth +from Mailman.htmlformat import * +from Mailman.Logging.Syslog import syslog + +EMPTYSTRING = '' +NL = '\n' + +# Set up i18n. Until we know which list is being requested, we use the +# server's default. +_ = i18n._ +i18n.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE) + +EXCERPT_HEIGHT = 10 +EXCERPT_WIDTH = 76 + + + +def helds_by_sender(mlist): + heldmsgs = mlist.GetHeldMessageIds() + bysender = {} + for id in heldmsgs: + sender = mlist.GetRecord(id)[1] + bysender.setdefault(sender, []).append(id) + return bysender + + +def hacky_radio_buttons(btnname, labels, values, defaults, spacing=3): + # We can't use a RadioButtonArray here because horizontal placement can be + # confusing to the user and vertical placement takes up too much + # real-estate. This is a hack! + space = ' ' * spacing + btns = Table(cellspacing='5', cellpadding='0') + btns.AddRow([space + text + space for text in labels]) + btns.AddRow([Center(RadioButton(btnname, value, default)) + for value, default in zip(values, defaults)]) + return btns + + + +def main(): + # Figure out which list is being requested + parts = Utils.GetPathPieces() + if not parts: + handle_no_list() + return + + listname = parts[0].lower() + try: + mlist = MailList.MailList(listname, lock=0) + except Errors.MMListError, e: + # Avoid cross-site scripting attacks + safelistname = Utils.websafe(listname) + handle_no_list(_('No such list %(safelistname)s')) + syslog('error', 'No such list "%s": %s\n', listname, e) + return + + # Now that we know which list to use, set the system's language to it. + i18n.set_language(mlist.preferred_language) + + # Make sure the user is authorized to see this page. + cgidata = cgi.FieldStorage(keep_blank_values=1) + + if not mlist.WebAuthenticate((mm_cfg.AuthListAdmin, + mm_cfg.AuthListModerator, + mm_cfg.AuthSiteAdmin), + cgidata.getvalue('adminpw', '')): + if cgidata.has_key('adminpw'): + # This is a re-authorization attempt + msg = Bold(FontSize('+1', _('Authorization failed.'))).Format() + else: + msg = '' + Auth.loginpage(mlist, 'admindb', msg=msg) + return + + # Set up the results document + doc = Document() + doc.set_language(mlist.preferred_language) + + # See if we're requesting all the messages for a particular sender, or if + # we want a specific held message. + sender = None + msgid = None + details = None + envar = os.environ.get('QUERY_STRING') + if envar: + # POST methods, even if their actions have a query string, don't get + # put into FieldStorage's keys :-( + qs = cgi.parse_qs(envar).get('sender') + if qs and type(qs) == ListType: + sender = qs[0] + qs = cgi.parse_qs(envar).get('msgid') + if qs and type(qs) == ListType: + msgid = qs[0] + qs = cgi.parse_qs(envar).get('details') + if qs and type(qs) == ListType: + details = qs[0] + + # We need a signal handler to catch the SIGTERM that can come from Apache + # when the user hits the browser's STOP button. See the comment in + # admin.py for details. + # + # BAW: Strictly speaking, the list should not need to be locked just to + # read the request database. However the request database asserts that + # the list is locked in order to load it and it's not worth complicating + # that logic. + def sigterm_handler(signum, frame, mlist=mlist): + # Make sure the list gets unlocked... + mlist.Unlock() + # ...and ensure we exit, otherwise race conditions could cause us to + # enter MailList.Save() while we're in the unlocked state, and that + # could be bad! + sys.exit(0) + + mlist.Lock() + try: + # Install the emergency shutdown signal handler + signal.signal(signal.SIGTERM, sigterm_handler) + + realname = mlist.real_name + if not cgidata.keys(): + # If this is not a form submission (i.e. there are no keys in the + # form), then all we don't need to do much special. + doc.SetTitle(_('%(realname)s Administrative Database')) + elif not details: + # This is a form submission + doc.SetTitle(_('%(realname)s Administrative Database Results')) + process_form(mlist, doc, cgidata) + # Now print the results and we're done. Short circuit for when there + # are no pending requests, but be sure to save the results! + if not mlist.NumRequestsPending(): + title = _('%(realname)s Administrative Database') + doc.SetTitle(title) + doc.AddItem(Header(2, title)) + doc.AddItem(_('There are no pending requests.')) + doc.AddItem(' ') + doc.AddItem(Link(mlist.GetScriptURL('admindb', absolute=1), + _('Click here to reload this page.'))) + doc.AddItem(mlist.GetMailmanFooter()) + print doc.Format() + mlist.Save() + return + + admindburl = mlist.GetScriptURL('admindb', absolute=1) + form = Form(admindburl) + # Add the instructions template + if details: + doc.AddItem(Header( + 2, _('Detailed instructions for the administrative database'))) + else: + doc.AddItem(Header( + 2, + _('Administrative requests for mailing list:') + + ' %s' % mlist.real_name)) + if not details: + form.AddItem(Center(SubmitButton('submit', _('Submit All Data')))) + # Add a link back to the overview, if we're not viewing the overview! + adminurl = mlist.GetScriptURL('admin', absolute=1) + d = {'listname' : mlist.real_name, + 'detailsurl': admindburl + '?details=instructions', + 'summaryurl': admindburl, + 'viewallurl': admindburl + '?details=all', + 'adminurl' : adminurl, + 'filterurl' : adminurl + '/privacy/sender', + } + addform = 1 + if sender: + esender = Utils.websafe(sender) + d['description'] = _("all of %(esender)s's held messages.") + doc.AddItem(Utils.maketext('admindbpreamble.html', d, + raw=1, mlist=mlist)) + show_sender_requests(mlist, form, sender) + elif msgid: + d['description'] = _('a single held message.') + doc.AddItem(Utils.maketext('admindbpreamble.html', d, + raw=1, mlist=mlist)) + show_message_requests(mlist, form, msgid) + elif details == 'all': + d['description'] = _('all held messages.') + doc.AddItem(Utils.maketext('admindbpreamble.html', d, + raw=1, mlist=mlist)) + show_detailed_requests(mlist, form) + elif details == 'instructions': + doc.AddItem(Utils.maketext('admindbdetails.html', d, + raw=1, mlist=mlist)) + addform = 0 + else: + # Show a summary of all requests + doc.AddItem(Utils.maketext('admindbsummary.html', d, + raw=1, mlist=mlist)) + num = show_pending_subs(mlist, form) + num += show_pending_unsubs(mlist, form) + num += show_helds_overview(mlist, form) + addform = num > 0 + # Finish up the document, adding buttons to the form + if addform: + doc.AddItem(form) + form.AddItem('


      ') + form.AddItem(Center(SubmitButton('submit', _('Submit All Data')))) + doc.AddItem(mlist.GetMailmanFooter()) + print doc.Format() + # Commit all changes + mlist.Save() + finally: + mlist.Unlock() + + + +def handle_no_list(msg=''): + # Print something useful if no list was given. + doc = Document() + doc.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE) + + header = _('Mailman Administrative Database Error') + doc.SetTitle(header) + doc.AddItem(Header(2, header)) + doc.AddItem(msg) + url = Utils.ScriptURL('admin', absolute=1) + link = Link(url, _('list of available mailing lists.')).Format() + doc.AddItem(_('You must specify a list name. Here is the %(link)s')) + doc.AddItem('
      ') + doc.AddItem(MailmanLogo()) + print doc.Format() + + + +def show_pending_subs(mlist, form): + # Add the subscription request section + pendingsubs = mlist.GetSubscriptionIds() + if not pendingsubs: + return 0 + form.AddItem('
      ') + form.AddItem(Center(Header(2, _('Subscription Requests')))) + table = Table(border=2) + table.AddRow([Center(Bold(_('Address/name'))), + Center(Bold(_('Your decision'))), + Center(Bold(_('Reason for refusal'))) + ]) + # Alphabetical order by email address + byaddrs = {} + for id in pendingsubs: + addr = mlist.GetRecord(id)[1] + byaddrs.setdefault(addr, []).append(id) + addrs = byaddrs.keys() + addrs.sort() + num = 0 + for addr, ids in byaddrs.items(): + # Eliminate duplicates + for id in ids[1:]: + mlist.HandleRequest(id, mm_cfg.DISCARD) + id = ids[0] + time, addr, fullname, passwd, digest, lang = mlist.GetRecord(id) + fullname = Utils.uncanonstr(fullname, mlist.preferred_language) + radio = RadioButtonArray(id, (_('Defer'), + _('Approve'), + _('Reject'), + _('Discard')), + values=(mm_cfg.DEFER, + mm_cfg.SUBSCRIBE, + mm_cfg.REJECT, + mm_cfg.DISCARD), + checked=0).Format() + if addr not in mlist.ban_list: + radio += '
      ' + CheckBox('ban-%d' % id, 1).Format() + \ + ' ' + _('Permanently ban from this list') + table.AddRow(['%s
      %s' % (addr, fullname), + radio, + TextBox('comment-%d' % id, size=40) + ]) + num += 1 + if num > 0: + form.AddItem(table) + return num + + + +def show_pending_unsubs(mlist, form): + # Add the pending unsubscription request section + lang = mlist.preferred_language + pendingunsubs = mlist.GetUnsubscriptionIds() + if not pendingunsubs: + return 0 + table = Table(border=2) + table.AddRow([Center(Bold(_('User address/name'))), + Center(Bold(_('Your decision'))), + Center(Bold(_('Reason for refusal'))) + ]) + # Alphabetical order by email address + byaddrs = {} + for id in pendingunsubs: + addr = mlist.GetRecord(id)[1] + byaddrs.setdefault(addr, []).append(id) + addrs = byaddrs.keys() + addrs.sort() + num = 0 + for addr, ids in byaddrs.items(): + # Eliminate duplicates + for id in ids[1:]: + mlist.HandleRequest(id, mm_cfg.DISCARD) + id = ids[0] + addr = mlist.GetRecord(id) + try: + fullname = Utils.uncanonstr(mlist.getMemberName(addr), lang) + except Errors.NotAMemberError: + # They must have been unsubscribed elsewhere, so we can just + # discard this record. + mlist.HandleRequest(id, mm_cfg.DISCARD) + continue + num += 1 + table.AddRow(['%s
      %s' % (addr, fullname), + RadioButtonArray(id, (_('Defer'), + _('Approve'), + _('Reject'), + _('Discard')), + values=(mm_cfg.DEFER, + mm_cfg.UNSUBSCRIBE, + mm_cfg.REJECT, + mm_cfg.DISCARD), + checked=0), + TextBox('comment-%d' % id, size=45) + ]) + if num > 0: + form.AddItem('
      ') + form.AddItem(Center(Header(2, _('Unsubscription Requests')))) + form.AddItem(table) + return num + + + +def show_helds_overview(mlist, form): + # Sort the held messages by sender + bysender = helds_by_sender(mlist) + if not bysender: + return 0 + # Add the by-sender overview tables + admindburl = mlist.GetScriptURL('admindb', absolute=1) + table = Table(border=0) + form.AddItem(table) + senders = bysender.keys() + senders.sort() + for sender in senders: + qsender = quote_plus(sender) + esender = Utils.websafe(sender) + senderurl = admindburl + '?sender=' + qsender + # The encompassing sender table + stable = Table(border=1) + stable.AddRow([Center(Bold(_('From:')).Format() + esender)]) + stable.AddCellInfo(stable.GetCurrentRowIndex(), 0, colspan=2) + left = Table(border=0) + left.AddRow([_('Action to take on all these held messages:')]) + left.AddCellInfo(left.GetCurrentRowIndex(), 0, colspan=2) + btns = hacky_radio_buttons( + 'senderaction-' + qsender, + (_('Defer'), _('Accept'), _('Reject'), _('Discard')), + (mm_cfg.DEFER, mm_cfg.APPROVE, mm_cfg.REJECT, mm_cfg.DISCARD), + (1, 0, 0, 0)) + left.AddRow([btns]) + left.AddCellInfo(left.GetCurrentRowIndex(), 0, colspan=2) + left.AddRow([ + CheckBox('senderpreserve-' + qsender, 1).Format() + + ' ' + + _('Preserve messages for the site administrator') + ]) + left.AddCellInfo(left.GetCurrentRowIndex(), 0, colspan=2) + left.AddRow([ + CheckBox('senderforward-' + qsender, 1).Format() + + ' ' + + _('Forward messages (individually) to:') + ]) + left.AddCellInfo(left.GetCurrentRowIndex(), 0, colspan=2) + left.AddRow([ + TextBox('senderforwardto-' + qsender, + value=mlist.GetOwnerEmail()) + ]) + left.AddCellInfo(left.GetCurrentRowIndex(), 0, colspan=2) + # If the sender is a member and the message is being held due to a + # moderation bit, give the admin a chance to clear the member's mod + # bit. If this sender is not a member and is not already on one of + # the sender filters, then give the admin a chance to add this sender + # to one of the filters. + if mlist.isMember(sender): + if mlist.getMemberOption(sender, mm_cfg.Moderate): + left.AddRow([ + CheckBox('senderclearmodp-' + qsender, 1).Format() + + ' ' + + _("Clear this member's moderate flag") + ]) + else: + left.AddRow( + [_('The sender is now a member of this list')]) + left.AddCellInfo(left.GetCurrentRowIndex(), 0, colspan=2) + elif sender not in (mlist.accept_these_nonmembers + + mlist.hold_these_nonmembers + + mlist.reject_these_nonmembers + + mlist.discard_these_nonmembers): + left.AddRow([ + CheckBox('senderfilterp-' + qsender, 1).Format() + + ' ' + + _('Add %(esender)s to a sender filter') + ]) + left.AddCellInfo(left.GetCurrentRowIndex(), 0, colspan=2) + btns = hacky_radio_buttons( + 'senderfilter-' + qsender, + (_('Accepts'), _('Holds'), _('Rejects'), _('Discards')), + (mm_cfg.ACCEPT, mm_cfg.HOLD, mm_cfg.REJECT, mm_cfg.DISCARD), + (0, 0, 0, 1)) + left.AddRow([btns]) + left.AddCellInfo(left.GetCurrentRowIndex(), 0, colspan=2) + if sender not in mlist.ban_list: + left.AddRow([ + CheckBox('senderbanp-' + qsender, 1).Format() + + ' ' + + _("""Ban %(esender)s from ever subscribing to this + mailing list""")]) + left.AddCellInfo(left.GetCurrentRowIndex(), 0, colspan=2) + right = Table(border=0) + right.AddRow([ + _("""Click on the message number to view the individual + message, or you can """) + + Link(senderurl, _('view all messages from %(esender)s')).Format() + ]) + right.AddCellInfo(right.GetCurrentRowIndex(), 0, colspan=2) + right.AddRow([' ', ' ']) + counter = 1 + for id in bysender[sender]: + info = mlist.GetRecord(id) + ptime, sender, subject, reason, filename, msgdata = info + # BAW: This is really the size of the message pickle, which should + # be close, but won't be exact. Sigh, good enough. + try: + size = os.path.getsize(os.path.join(mm_cfg.DATA_DIR, filename)) + except OSError, e: + if e.errno <> errno.ENOENT: raise + # This message must have gotten lost, i.e. it's already been + # handled by the time we got here. + mlist.HandleRequest(id, mm_cfg.DISCARD) + continue + t = Table(border=0) + t.AddRow([Link(admindburl + '?msgid=%d' % id, '[%d]' % counter), + Bold(_('Subject:')), + Utils.websafe(subject) + ]) + t.AddRow([' ', Bold(_('Size:')), str(size) + _(' bytes')]) + if reason: + reason = _(reason) + else: + reason = _('not available') + t.AddRow([' ', Bold(_('Reason:')), reason]) + # Include the date we received the message, if available + when = msgdata.get('received_time') + if when: + t.AddRow([' ', Bold(_('Received:')), + time.ctime(when)]) + counter += 1 + right.AddRow([t]) + stable.AddRow([left, right]) + table.AddRow([stable]) + return 1 + + + +def show_sender_requests(mlist, form, sender): + bysender = helds_by_sender(mlist) + if not bysender: + return + sender_ids = bysender.get(sender) + if sender_ids is None: + # BAW: should we print an error message? + return + total = len(sender_ids) + count = 1 + for id in sender_ids: + info = mlist.GetRecord(id) + show_post_requests(mlist, id, info, total, count, form) + count += 1 + + + +def show_message_requests(mlist, form, id): + try: + id = int(id) + info = mlist.GetRecord(id) + except (ValueError, KeyError): + # BAW: print an error message? + return + show_post_requests(mlist, id, info, 1, 1, form) + + + +def show_detailed_requests(mlist, form): + all = mlist.GetHeldMessageIds() + total = len(all) + count = 1 + for id in mlist.GetHeldMessageIds(): + info = mlist.GetRecord(id) + show_post_requests(mlist, id, info, total, count, form) + count += 1 + + + +def show_post_requests(mlist, id, info, total, count, form): + # For backwards compatibility with pre 2.0beta3 + if len(info) == 5: + ptime, sender, subject, reason, filename = info + msgdata = {} + else: + ptime, sender, subject, reason, filename, msgdata = info + form.AddItem('
      ') + # Header shown on each held posting (including count of total) + msg = _('Posting Held for Approval') + if total <> 1: + msg += _(' (%(count)d of %(total)d)') + form.AddItem(Center(Header(2, msg))) + # We need to get the headers and part of the textual body of the message + # being held. The best way to do this is to use the email Parser to get + # an actual object, which will be easier to deal with. We probably could + # just do raw reads on the file. + try: + msg = readMessage(os.path.join(mm_cfg.DATA_DIR, filename)) + except IOError, e: + if e.errno <> errno.ENOENT: + raise + form.AddItem(_('Message with id #%(id)d was lost.')) + form.AddItem('

      ') + # BAW: kludge to remove id from requests.db. + try: + mlist.HandleRequest(id, mm_cfg.DISCARD) + except Errors.LostHeldMessage: + pass + return + except email.Errors.MessageParseError: + form.AddItem(_('Message with id #%(id)d is corrupted.')) + # BAW: Should we really delete this, or shuttle it off for site admin + # to look more closely at? + form.AddItem('

      ') + # BAW: kludge to remove id from requests.db. + try: + mlist.HandleRequest(id, mm_cfg.DISCARD) + except Errors.LostHeldMessage: + pass + return + # Get the header text and the message body excerpt + lines = [] + chars = 0 + # A negative value means, include the entire message regardless of size + limit = mm_cfg.ADMINDB_PAGE_TEXT_LIMIT + for line in email.Iterators.body_line_iterator(msg): + lines.append(line) + chars += len(line) + if chars > limit > 0: + break + # Negative values mean display the entire message, regardless of size + if limit > 0: + body = EMPTYSTRING.join(lines)[:mm_cfg.ADMINDB_PAGE_TEXT_LIMIT] + else: + body = EMPTYSTRING.join(lines) + hdrtxt = NL.join(['%s: %s' % (k, v) for k, v in msg.items()]) + hdrtxt = Utils.websafe(hdrtxt) + # Okay, we've reconstituted the message just fine. Now for the fun part! + t = Table(cellspacing=0, cellpadding=0, width='100%') + t.AddRow([Bold(_('From:')), sender]) + row, col = t.GetCurrentRowIndex(), t.GetCurrentCellIndex() + t.AddCellInfo(row, col-1, align='right') + t.AddRow([Bold(_('Subject:')), Utils.websafe(subject)]) + t.AddCellInfo(row+1, col-1, align='right') + t.AddRow([Bold(_('Reason:')), _(reason)]) + t.AddCellInfo(row+2, col-1, align='right') + when = msgdata.get('received_time') + if when: + t.AddRow([Bold(_('Received:')), time.ctime(when)]) + t.AddCellInfo(row+2, col-1, align='right') + # We can't use a RadioButtonArray here because horizontal placement can be + # confusing to the user and vertical placement takes up too much + # real-estate. This is a hack! + buttons = Table(cellspacing="5", cellpadding="0") + buttons.AddRow(map(lambda x, s=' '*5: s+x+s, + (_('Defer'), _('Approve'), _('Reject'), _('Discard')))) + buttons.AddRow([Center(RadioButton(id, mm_cfg.DEFER, 1)), + Center(RadioButton(id, mm_cfg.APPROVE, 0)), + Center(RadioButton(id, mm_cfg.REJECT, 0)), + Center(RadioButton(id, mm_cfg.DISCARD, 0)), + ]) + t.AddRow([Bold(_('Action:')), buttons]) + t.AddCellInfo(row+3, col-1, align='right') + t.AddRow([' ', + CheckBox('preserve-%d' % id, 'on', 0).Format() + + ' ' + _('Preserve message for site administrator') + ]) + t.AddRow([' ', + CheckBox('forward-%d' % id, 'on', 0).Format() + + ' ' + _('Additionally, forward this message to: ') + + TextBox('forward-addr-%d' % id, size=47, + value=mlist.GetOwnerEmail()).Format() + ]) + notice = msgdata.get('rejection_notice', _('[No explanation given]')) + t.AddRow([ + Bold(_('If you reject this post,
      please explain (optional):')), + TextArea('comment-%d' % id, rows=4, cols=EXCERPT_WIDTH, + text = Utils.wrap(_(notice), column=80)) + ]) + row, col = t.GetCurrentRowIndex(), t.GetCurrentCellIndex() + t.AddCellInfo(row, col-1, align='right') + t.AddRow([Bold(_('Message Headers:')), + TextArea('headers-%d' % id, hdrtxt, + rows=EXCERPT_HEIGHT, cols=EXCERPT_WIDTH, readonly=1)]) + row, col = t.GetCurrentRowIndex(), t.GetCurrentCellIndex() + t.AddCellInfo(row, col-1, align='right') + t.AddRow([Bold(_('Message Excerpt:')), + TextArea('fulltext-%d' % id, Utils.websafe(body), + rows=EXCERPT_HEIGHT, cols=EXCERPT_WIDTH, readonly=1)]) + t.AddCellInfo(row+1, col-1, align='right') + form.AddItem(t) + form.AddItem('

      ') + + + +def process_form(mlist, doc, cgidata): + senderactions = {} + # Sender-centric actions + for k in cgidata.keys(): + for prefix in ('senderaction-', 'senderpreserve-', 'senderforward-', + 'senderforwardto-', 'senderfilterp-', 'senderfilter-', + 'senderclearmodp-', 'senderbanp-'): + if k.startswith(prefix): + action = k[:len(prefix)-1] + sender = unquote_plus(k[len(prefix):]) + value = cgidata.getvalue(k) + senderactions.setdefault(sender, {})[action] = value + for sender in senderactions.keys(): + actions = senderactions[sender] + # Handle what to do about all this sender's held messages + try: + action = int(actions.get('senderaction', mm_cfg.DEFER)) + except ValueError: + action = mm_cfg.DEFER + if action in (mm_cfg.DEFER, mm_cfg.APPROVE, + mm_cfg.REJECT, mm_cfg.DISCARD): + preserve = actions.get('senderpreserve', 0) + forward = actions.get('senderforward', 0) + forwardaddr = actions.get('senderforwardto', '') + comment = _('No reason given') + bysender = helds_by_sender(mlist) + for id in bysender.get(sender, []): + try: + mlist.HandleRequest(id, action, comment, preserve, + forward, forwardaddr) + except (KeyError, Errors.LostHeldMessage): + # That's okay, it just means someone else has already + # updated the database while we were staring at the page, + # so just ignore it + continue + # Now see if this sender should be added to one of the nonmember + # sender filters. + if actions.get('senderfilterp', 0): + try: + which = int(actions.get('senderfilter')) + except ValueError: + # Bogus form + which = 'ignore' + if which == mm_cfg.ACCEPT: + mlist.accept_these_nonmembers.append(sender) + elif which == mm_cfg.HOLD: + mlist.hold_these_nonmembers.append(sender) + elif which == mm_cfg.REJECT: + mlist.reject_these_nonmembers.append(sender) + elif which == mm_cfg.DISCARD: + mlist.discard_these_nonmembers.append(sender) + # Otherwise, it's a bogus form, so ignore it + # And now see if we're to clear the member's moderation flag. + if actions.get('senderclearmodp', 0): + try: + mlist.setMemberOption(sender, mm_cfg.Moderate, 0) + except Errors.NotAMemberError: + # This person's not a member any more. Oh well. + pass + # And should this address be banned? + if actions.get('senderbanp', 0): + if sender not in mlist.ban_list: + mlist.ban_list.append(sender) + # Now, do message specific actions + erroraddrs = [] + for k in cgidata.keys(): + formv = cgidata[k] + if type(formv) == ListType: + continue + try: + v = int(formv.value) + request_id = int(k) + except ValueError: + continue + if v not in (mm_cfg.DEFER, mm_cfg.APPROVE, mm_cfg.REJECT, + mm_cfg.DISCARD, mm_cfg.SUBSCRIBE, mm_cfg.UNSUBSCRIBE, + mm_cfg.ACCEPT, mm_cfg.HOLD): + continue + # Get the action comment and reasons if present. + commentkey = 'comment-%d' % request_id + preservekey = 'preserve-%d' % request_id + forwardkey = 'forward-%d' % request_id + forwardaddrkey = 'forward-addr-%d' % request_id + bankey = 'ban-%d' % request_id + # Defaults + comment = _('[No reason given]') + preserve = 0 + forward = 0 + forwardaddr = '' + if cgidata.has_key(commentkey): + comment = cgidata[commentkey].value + if cgidata.has_key(preservekey): + preserve = cgidata[preservekey].value + if cgidata.has_key(forwardkey): + forward = cgidata[forwardkey].value + if cgidata.has_key(forwardaddrkey): + forwardaddr = cgidata[forwardaddrkey].value + # Should we ban this address? Do this check before handling the + # request id because that will evict the record. + if cgidata.getvalue(bankey): + sender = mlist.GetRecord(request_id)[1] + if sender not in mlist.ban_list: + mlist.ban_list.append(sender) + # Handle the request id + try: + mlist.HandleRequest(request_id, v, comment, + preserve, forward, forwardaddr) + except (KeyError, Errors.LostHeldMessage): + # That's okay, it just means someone else has already updated the + # database while we were staring at the page, so just ignore it + continue + except Errors.MMAlreadyAMember, v: + erroraddrs.append(v) + # save the list and print the results + doc.AddItem(Header(2, _('Database Updated...'))) + if erroraddrs: + for addr in erroraddrs: + doc.AddItem(`addr` + _(' is already a member') + '
      ') diff --git a/Mailman/Cgi/confirm.py b/Mailman/Cgi/confirm.py new file mode 100644 index 00000000..2348b0b6 --- /dev/null +++ b/Mailman/Cgi/confirm.py @@ -0,0 +1,791 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Confirm a pending action via URL.""" + +import signal +import cgi +import time + +from Mailman import mm_cfg +from Mailman import Errors +from Mailman import i18n +from Mailman import MailList +from Mailman import Pending +from Mailman.UserDesc import UserDesc +from Mailman.htmlformat import * +from Mailman.Logging.Syslog import syslog + +# Set up i18n +_ = i18n._ +i18n.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE) + + + +def main(): + doc = Document() + doc.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE) + + parts = Utils.GetPathPieces() + if not parts or len(parts) < 1: + bad_confirmation(doc) + doc.AddItem(MailmanLogo()) + print doc.Format() + return + + listname = parts[0].lower() + try: + mlist = MailList.MailList(listname, lock=0) + except Errors.MMListError, e: + # Avoid cross-site scripting attacks + safelistname = Utils.websafe(listname) + bad_confirmation(doc, _('No such list %(safelistname)s')) + doc.AddItem(MailmanLogo()) + print doc.Format() + syslog('error', 'No such list "%s": %s', listname, e) + return + + # Set the language for the list + i18n.set_language(mlist.preferred_language) + doc.set_language(mlist.preferred_language) + + # Get the form data to see if this is a second-step confirmation + cgidata = cgi.FieldStorage(keep_blank_values=1) + cookie = cgidata.getvalue('cookie') + if cookie == '': + ask_for_cookie(mlist, doc, _('Confirmation string was empty.')) + return + + if not cookie and len(parts) == 2: + cookie = parts[1] + + if len(parts) > 2: + bad_confirmation(doc) + doc.AddItem(mlist.GetMailmanFooter()) + print doc.Format() + return + + if not cookie: + ask_for_cookie(mlist, doc) + return + + days = int(mm_cfg.PENDING_REQUEST_LIFE / mm_cfg.days(1) + 0.5) + confirmurl = mlist.GetScriptURL('confirm', absolute=1) + # Avoid cross-site scripting attacks + safecookie = Utils.websafe(cookie) + badconfirmstr = _('''Invalid confirmation string: + %(safecookie)s. + +

      Note that confirmation strings expire approximately + %(days)s days after the initial subscription request. If your + confirmation has expired, please try to re-submit your subscription. + Otherwise, re-enter your confirmation + string.''') + + content = Pending.confirm(cookie, expunge=0) + if content is None: + bad_confirmation(doc, badconfirmstr) + doc.AddItem(mlist.GetMailmanFooter()) + print doc.Format() + return + + try: + if content[0] == Pending.SUBSCRIPTION: + if cgidata.getvalue('cancel'): + subscription_cancel(mlist, doc, cookie) + elif cgidata.getvalue('submit'): + subscription_confirm(mlist, doc, cookie, cgidata) + else: + subscription_prompt(mlist, doc, cookie, content[1]) + elif content[0] == Pending.UNSUBSCRIPTION: + try: + if cgidata.getvalue('cancel'): + unsubscription_cancel(mlist, doc, cookie) + elif cgidata.getvalue('submit'): + unsubscription_confirm(mlist, doc, cookie) + else: + unsubscription_prompt(mlist, doc, cookie, *content[1:]) + except Errors.NotAMemberError: + doc.addError(_("""The address requesting unsubscription is not + a member of the mailing list. Perhaps you have already been + unsubscribed, e.g. by the list administrator?""")) + # And get rid of this confirmation cookie + Pending.confirm(cookie) + elif content[0] == Pending.CHANGE_OF_ADDRESS: + if cgidata.getvalue('cancel'): + addrchange_cancel(mlist, doc, cookie) + elif cgidata.getvalue('submit'): + addrchange_confirm(mlist, doc, cookie) + else: + # Watch out for users who have unsubscribed themselves in the + # meantime! + try: + addrchange_prompt(mlist, doc, cookie, *content[1:]) + except Errors.NotAMemberError: + doc.addError(_("""The address requesting to be changed has + been subsequently unsubscribed. This request has been + cancelled.""")) + Pending.confirm(cookie, expunge=1) + elif content[0] == Pending.HELD_MESSAGE: + if cgidata.getvalue('cancel'): + heldmsg_cancel(mlist, doc, cookie) + elif cgidata.getvalue('submit'): + heldmsg_confirm(mlist, doc, cookie) + else: + heldmsg_prompt(mlist, doc, cookie, *content[1:]) + elif content[0] == Pending.RE_ENABLE: + if cgidata.getvalue('cancel'): + reenable_cancel(mlist, doc, cookie) + elif cgidata.getvalue('submit'): + reenable_confirm(mlist, doc, cookie) + else: + reenable_prompt(mlist, doc, cookie, *content[1:]) + else: + bad_confirmation(doc, _('System error, bad content: %(content)s')) + except Errors.MMBadConfirmation: + bad_confirmation(doc, badconfirmstr) + + doc.AddItem(mlist.GetMailmanFooter()) + print doc.Format() + + + +def bad_confirmation(doc, extra=''): + title = _('Bad confirmation string') + doc.SetTitle(title) + doc.AddItem(Header(3, Bold(FontAttr(title, color='#ff0000', size='+2')))) + doc.AddItem(extra) + + + +def ask_for_cookie(mlist, doc, extra=''): + title = _('Enter confirmation cookie') + doc.SetTitle(title) + form = Form(mlist.GetScriptURL('confirm', 1)) + table = Table(border=0, width='100%') + table.AddRow([Center(Bold(FontAttr(title, size='+1')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, + colspan=2, bgcolor=mm_cfg.WEB_HEADER_COLOR) + + if extra: + table.AddRow([Bold(FontAttr(extra, size='+1'))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + + # Add cookie entry box + table.AddRow([_("""Please enter the confirmation string + (i.e. cookie) that you received in your email message, in the box + below. Then hit the Submit button to proceed to the next + confirmation step.""")]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([Label(_('Confirmation string:')), + TextBox('cookie')]) + table.AddRow([Center(SubmitButton('submit_cookie', _('Submit')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + form.AddItem(table) + doc.AddItem(form) + doc.AddItem(mlist.GetMailmanFooter()) + print doc.Format() + + + +def subscription_prompt(mlist, doc, cookie, userdesc): + email = userdesc.address + password = userdesc.password + digest = userdesc.digest + lang = userdesc.language + name = Utils.uncanonstr(userdesc.fullname, lang) + i18n.set_language(lang) + doc.set_language(lang) + title = _('Confirm subscription request') + doc.SetTitle(title) + + form = Form(mlist.GetScriptURL('confirm', 1)) + table = Table(border=0, width='100%') + table.AddRow([Center(Bold(FontAttr(title, size='+1')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, + colspan=2, bgcolor=mm_cfg.WEB_HEADER_COLOR) + + listname = mlist.real_name + # This is the normal, no-confirmation required results text. + # + # We do things this way so we don't have to reformat this paragraph, which + # would mess up translations. If you modify this text for other reasons, + # please refill the paragraph, and clean up the logic. + result = _("""Your confirmation is required in order to complete the + subscription request to the mailing list %(listname)s. Your + subscription settings are shown below; make any necessary changes and hit + Subscribe to complete the confirmation process. Once you've + confirmed your subscription request, you will be shown your account + options page which you can use to further customize your membership + options. + +

      Note: your password will be emailed to you once your subscription is + confirmed. You can change it by visiting your personal options page. + +

      Or hit Cancel and discard to cancel this subscription + request.""") + '


      ' + if mlist.subscribe_policy in (2, 3): + # Confirmation is required + result = _("""Your confirmation is required in order to continue with + the subscription request to the mailing list %(listname)s. + Your subscription settings are shown below; make any necessary changes + and hit Subscribe to list ... to complete the confirmation + process. Once you've confirmed your subscription request, the + moderator must approve or reject your membership request. You will + receive notice of their decision. + +

      Note: your password will be emailed to you once your subscription + is confirmed. You can change it by visiting your personal options + page. + +

      Or, if you've changed your mind and do not want to subscribe to + this mailing list, you can hit Cancel my subscription + request.""") + '


      ' + table.AddRow([result]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + + table.AddRow([Label(_('Your email address:')), email]) + table.AddRow([Label(_('Your real name:')), + TextBox('realname', name)]) +## table.AddRow([Label(_('Password:')), +## PasswordBox('password', password)]) +## table.AddRow([Label(_('Password (confirm):')), +## PasswordBox('pwconfirm', password)]) + # Only give them a choice to receive digests if they actually have a + # choice . + if mlist.nondigestable and mlist.digestable: + table.AddRow([Label(_('Receive digests?')), + RadioButtonArray('digests', (_('No'), _('Yes')), + checked=digest, values=(0, 1))]) + langs = mlist.GetAvailableLanguages() + values = [_(Utils.GetLanguageDescr(l)) for l in langs] + try: + selected = langs.index(lang) + except ValueError: + selected = lang.index(mlist.preferred_language) + table.AddRow([Label(_('Preferred language:')), + SelectOptions('language', langs, values, selected)]) + table.AddRow([Hidden('cookie', cookie)]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([ + Label(SubmitButton('cancel', _('Cancel my subscription request'))), + SubmitButton('submit', _('Subscribe to list %(listname)s')) + ]) + form.AddItem(table) + doc.AddItem(form) + + + +def subscription_cancel(mlist, doc, cookie): + # Discard this cookie + userdesc = Pending.confirm(cookie, expunge=1)[1] + lang = userdesc.language + i18n.set_language(lang) + doc.set_language(lang) + doc.AddItem(_('You have canceled your subscription request.')) + + + +def subscription_confirm(mlist, doc, cookie, cgidata): + # See the comment in admin.py about the need for the signal + # handler. + def sigterm_handler(signum, frame, mlist=mlist): + mlist.Unlock() + sys.exit(0) + + listname = mlist.real_name + mlist.Lock() + try: + try: + # Some pending values may be overridden in the form. email of + # course is hardcoded. ;) + lang = cgidata.getvalue('language') + i18n.set_language(lang) + doc.set_language(lang) + if cgidata.has_key('digests'): + try: + digest = int(cgidata.getvalue('digests')) + except ValueError: + digest = None + else: + digest = None + userdesc = Pending.confirm(cookie, expunge=0)[1] + fullname = cgidata.getvalue('realname', None) + if fullname is not None: + fullname = Utils.canonstr(fullname, lang) + overrides = UserDesc(fullname=fullname, digest=digest, lang=lang) + userdesc += overrides + op, addr, pw, digest, lang = mlist.ProcessConfirmation( + cookie, userdesc) + except Errors.MMNeedApproval: + title = _('Awaiting moderator approval') + doc.SetTitle(title) + doc.AddItem(Header(3, Bold(FontAttr(title, size='+2')))) + doc.AddItem(_("""\ + You have successfully confirmed your subscription request to the + mailing list %(listname)s, however final approval is required from + the list moderator before you will be subscribed. Your request + has been forwarded to the list moderator, and you will be notified + of the moderator's decision.""")) + except Errors.NotAMemberError: + bad_confirmation(doc, _('''Invalid confirmation string. It is + possible that you are attempting to confirm a request for an + address that has already been unsubscribed.''')) + except Errors.MMAlreadyAMember: + doc.addError(_("You are already a member of this mailing list!")) + else: + # Use the user's preferred language + i18n.set_language(lang) + doc.set_language(lang) + # The response + listname = mlist.real_name + title = _('Subscription request confirmed') + optionsurl = mlist.GetOptionsURL(addr, absolute=1) + doc.SetTitle(title) + doc.AddItem(Header(3, Bold(FontAttr(title, size='+2')))) + doc.AddItem(_('''\ + You have successfully confirmed your subscription request for + "%(addr)s" to the %(listname)s mailing list. A separate + confirmation message will be sent to your email address, along + with your password, and other useful information and links. + +

      You can now + proceed to your membership login + page.''')) + mlist.Save() + finally: + mlist.Unlock() + + + +def unsubscription_cancel(mlist, doc, cookie): + # Discard this cookie + Pending.confirm(cookie, expunge=1) + doc.AddItem(_('You have canceled your unsubscription request.')) + + + +def unsubscription_confirm(mlist, doc, cookie): + # See the comment in admin.py about the need for the signal + # handler. + def sigterm_handler(signum, frame, mlist=mlist): + mlist.Unlock() + sys.exit(0) + + mlist.Lock() + try: + try: + # Do this in two steps so we can get the preferred language for + # the user who is unsubscribing. + op, addr = Pending.confirm(cookie, expunge=0) + lang = mlist.getMemberLanguage(addr) + i18n.set_language(lang) + doc.set_language(lang) + op, addr = mlist.ProcessConfirmation(cookie) + except Errors.NotAMemberError: + bad_confirmation(doc, _('''Invalid confirmation string. It is + possible that you are attempting to confirm a request for an + address that has already been unsubscribed.''')) + else: + # The response + listname = mlist.real_name + title = _('Unsubscription request confirmed') + listinfourl = mlist.GetScriptURL('listinfo', absolute=1) + doc.SetTitle(title) + doc.AddItem(Header(3, Bold(FontAttr(title, size='+2')))) + doc.AddItem(_("""\ + You have successfully unsubscribed from the %(listname)s mailing + list. You can now visit the list's main + information page.""")) + mlist.Save() + finally: + mlist.Unlock() + + + +def unsubscription_prompt(mlist, doc, cookie, addr): + title = _('Confirm unsubscription request') + doc.SetTitle(title) + lang = mlist.getMemberLanguage(addr) + i18n.set_language(lang) + doc.set_language(lang) + + form = Form(mlist.GetScriptURL('confirm', 1)) + table = Table(border=0, width='100%') + table.AddRow([Center(Bold(FontAttr(title, size='+1')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, + colspan=2, bgcolor=mm_cfg.WEB_HEADER_COLOR) + + listname = mlist.real_name + fullname = mlist.getMemberName(addr) + if fullname is None: + fullname = _('Not available') + else: + fullname = Utils.uncanonstr(fullname, lang) + table.AddRow([_("""Your confirmation is required in order to complete the + unsubscription request from the mailing list %(listname)s. You + are currently subscribed with + +

      • Real name: %(fullname)s +
      • Email address: %(addr)s +
      + + Hit the Unsubscribe button below to complete the confirmation + process. + +

      Or hit Cancel and discard to cancel this unsubscription + request.""") + '


      ']) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([Hidden('cookie', cookie)]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([SubmitButton('submit', _('Unsubscribe')), + SubmitButton('cancel', _('Cancel and discard'))]) + + form.AddItem(table) + doc.AddItem(form) + + + +def addrchange_cancel(mlist, doc, cookie): + # Discard this cookie + Pending.confirm(cookie, expunge=1) + doc.AddItem(_('You have canceled your change of address request.')) + + + +def addrchange_confirm(mlist, doc, cookie): + # See the comment in admin.py about the need for the signal + # handler. + def sigterm_handler(signum, frame, mlist=mlist): + mlist.Unlock() + sys.exit(0) + + mlist.Lock() + try: + try: + # Do this in two steps so we can get the preferred language for + # the user who is unsubscribing. + op, oldaddr, newaddr, globally = Pending.confirm(cookie, expunge=0) + lang = mlist.getMemberLanguage(oldaddr) + i18n.set_language(lang) + doc.set_language(lang) + op, oldaddr, newaddr = mlist.ProcessConfirmation(cookie) + except Errors.NotAMemberError: + bad_confirmation(doc, _('''Invalid confirmation string. It is + possible that you are attempting to confirm a request for an + address that has already been unsubscribed.''')) + else: + # The response + listname = mlist.real_name + title = _('Change of address request confirmed') + optionsurl = mlist.GetOptionsURL(newaddr, absolute=1) + doc.SetTitle(title) + doc.AddItem(Header(3, Bold(FontAttr(title, size='+2')))) + doc.AddItem(_("""\ + You have successfully changed your address on the %(listname)s + mailing list from %(oldaddr)s to %(newaddr)s. You + can now proceed to your membership + login page.""")) + mlist.Save() + finally: + mlist.Unlock() + + + +def addrchange_prompt(mlist, doc, cookie, oldaddr, newaddr, globally): + title = _('Confirm change of address request') + doc.SetTitle(title) + lang = mlist.getMemberLanguage(oldaddr) + i18n.set_language(lang) + doc.set_language(lang) + + form = Form(mlist.GetScriptURL('confirm', 1)) + table = Table(border=0, width='100%') + table.AddRow([Center(Bold(FontAttr(title, size='+1')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, + colspan=2, bgcolor=mm_cfg.WEB_HEADER_COLOR) + + listname = mlist.real_name + fullname = mlist.getMemberName(oldaddr) + if fullname is None: + fullname = _('Not available') + else: + fullname = Utils.uncanonstr(fullname, lang) + if globally: + globallys = _('globally') + else: + globallys = '' + table.AddRow([_("""Your confirmation is required in order to complete the + change of address request for the mailing list %(listname)s. You + are currently subscribed with + +
      • Real name: %(fullname)s +
      • Old email address: %(oldaddr)s +
      + + and you have requested to %(globallys)s change your email address to + +
      • New email address: %(newaddr)s +
      + + Hit the Change address button below to complete the confirmation + process. + +

      Or hit Cancel and discard to cancel this change of address + request.""") + '


      ']) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([Hidden('cookie', cookie)]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([SubmitButton('submit', _('Change address')), + SubmitButton('cancel', _('Cancel and discard'))]) + + form.AddItem(table) + doc.AddItem(form) + + + +def heldmsg_cancel(mlist, doc, cookie): + # Discard this cookie + title = _('Continue awaiting approval') + doc.SetTitle(title) + table = Table(border=0, width='100%') + table.AddRow([Center(Bold(FontAttr(title, size='+1')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, + bgcolor=mm_cfg.WEB_HEADER_COLOR) + Pending.confirm(cookie, expunge=1) + table.AddRow([_('''Okay, the list moderator will still have the + opportunity to approve or reject this message.''')]) + doc.AddItem(table) + + + +def heldmsg_confirm(mlist, doc, cookie): + # See the comment in admin.py about the need for the signal + # handler. + def sigterm_handler(signum, frame, mlist=mlist): + mlist.Unlock() + sys.exit(0) + + mlist.Lock() + try: + try: + # Do this in two steps so we can get the preferred language for + # the user who posted the message. + op, id = Pending.confirm(cookie, expunge=1) + ign, sender, msgsubject, ign, ign, ign = mlist.GetRecord(id) + subject = Utils.websafe(msgsubject) + lang = mlist.getMemberLanguage(sender) + i18n.set_language(lang) + doc.set_language(lang) + # Discard the message + mlist.HandleRequest(id, mm_cfg.DISCARD, + _('Sender discarded message via web.')) + except Errors.LostHeldMessage: + bad_confirmation(doc, _('''The held message with the Subject: + header %(subject)s could not be found. The most likely + reason for this is that the list moderator has already approved or + rejected the message. You were not able to cancel it in + time.''')) + else: + # The response + listname = mlist.real_name + title = _('Posted message canceled') + doc.SetTitle(title) + doc.AddItem(Header(3, Bold(FontAttr(title, size='+2')))) + doc.AddItem(_('''\ + You have successfully canceled the posting of your message with + the Subject: header %(subject)s to the mailing list + %(listname)s.''')) + mlist.Save() + finally: + mlist.Unlock() + + + +def heldmsg_prompt(mlist, doc, cookie, id): + title = _('Cancel held message posting') + doc.SetTitle(title) + form = Form(mlist.GetScriptURL('confirm', 1)) + table = Table(border=0, width='100%') + table.AddRow([Center(Bold(FontAttr(title, size='+1')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, + colspan=2, bgcolor=mm_cfg.WEB_HEADER_COLOR) + # Blarg. The list must be locked in order to interact with the ListAdmin + # database, even for read-only. See the comment in admin.py about the + # need for the signal handler. + def sigterm_handler(signum, frame, mlist=mlist): + mlist.Unlock() + sys.exit(0) + # Get the record, but watch for KeyErrors which mean the admin has already + # disposed of this message. + mlist.Lock() + try: + try: + data = mlist.GetRecord(id) + except KeyError: + data = None + finally: + mlist.Unlock() + + if data is None: + bad_confirmation(doc, _("""The held message you were referred to has + already been handled by the list administrator.""")) + return + + # Unpack the data and present the confirmation message + ign, sender, msgsubject, givenreason, ign, ign = data + # Now set the language to the sender's preferred. + lang = mlist.getMemberLanguage(sender) + i18n.set_language(lang) + doc.set_language(lang) + + subject = Utils.websafe(msgsubject) + reason = Utils.websafe(_(givenreason)) + listname = mlist.real_name + table.AddRow([_('''Your confirmation is required in order to cancel the + posting of your message to the mailing list %(listname)s: + +
      • Sender: %(sender)s +
      • Subject: %(subject)s +
      • Reason: %(reason)s +
      + + Hit the Cancel posting button to discard the posting. + +

      Or hit the Continue awaiting approval button to continue to + allow the list moderator to approve or reject the message.''') + + '


      ']) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([Hidden('cookie', cookie)]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([SubmitButton('submit', _('Cancel posting')), + SubmitButton('cancel', _('Continue awaiting approval'))]) + + form.AddItem(table) + doc.AddItem(form) + + + +def reenable_cancel(mlist, doc, cookie): + # Don't actually discard this cookie, since the user may decide to + # re-enable their membership at a future time, and we may be sending out + # future notifications with this cookie value. + doc.AddItem(_("""You have canceled the re-enabling of your membership. If + we continue to receive bounces from your address, it could be deleted from + this mailing list.""")) + + + +def reenable_confirm(mlist, doc, cookie): + # See the comment in admin.py about the need for the signal + # handler. + def sigterm_handler(signum, frame, mlist=mlist): + mlist.Unlock() + sys.exit(0) + + mlist.Lock() + try: + try: + # Do this in two steps so we can get the preferred language for + # the user who is unsubscribing. + op, listname, addr = Pending.confirm(cookie, expunge=0) + lang = mlist.getMemberLanguage(addr) + i18n.set_language(lang) + doc.set_language(lang) + op, addr = mlist.ProcessConfirmation(cookie) + except Errors.NotAMemberError: + bad_confirmation(doc, _('''Invalid confirmation string. It is + possible that you are attempting to confirm a request for an + address that has already been unsubscribed.''')) + else: + # The response + listname = mlist.real_name + title = _('Membership re-enabled.') + optionsurl = mlist.GetOptionsURL(addr, absolute=1) + doc.SetTitle(title) + doc.AddItem(Header(3, Bold(FontAttr(title, size='+2')))) + doc.AddItem(_("""\ + You have successfully re-enabled your membership in the + %(listname)s mailing list. You can now visit your member options page. + """)) + mlist.Save() + finally: + mlist.Unlock() + + + +def reenable_prompt(mlist, doc, cookie, list, member): + title = _('Re-enable mailing list membership') + doc.SetTitle(title) + form = Form(mlist.GetScriptURL('confirm', 1)) + table = Table(border=0, width='100%') + table.AddRow([Center(Bold(FontAttr(title, size='+1')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, + colspan=2, bgcolor=mm_cfg.WEB_HEADER_COLOR) + + lang = mlist.getMemberLanguage(member) + i18n.set_language(lang) + doc.set_language(lang) + + realname = mlist.real_name + info = mlist.getBounceInfo(member) + if not info: + listinfourl = mlist.GetScriptURL('listinfo', absolute=1) + # They've already be unsubscribed + table.AddRow([_("""We're sorry, but you have already been unsubscribed + from this mailing list. To re-subscribe, please visit the + list information page.""")]) + return + + date = time.strftime('%A, %B %d, %Y', info.date + (0,) * 6) + daysleft = int(info.noticesleft * + mlist.bounce_you_are_disabled_warnings_interval / + mm_cfg.days(1)) + # BAW: for consistency this should be changed to 'fullname' or the above + # 'fullname's should be changed to 'username'. Don't want to muck with + # the i18n catalogs though. + username = mlist.getMemberName(member) + if username is None: + username = _('not available') + else: + username = Utils.uncanonstr(username, lang) + + table.AddRow([_("""Your membership in the %(realname)s mailing list is + currently disabled due to excessive bounces. Your confirmation is + required in order to re-enable delivery to your address. We have the + following information on file: + +
      • Member address: %(member)s +
      • Member name: %(username)s +
      • Last bounce received on: %(date)s +
      • Approximate number of days before you are permanently removed + from this list: %(daysleft)s +
      + + Hit the Re-enable membership button to resume receiving postings + from the mailing list. Or hit the Cancel button to defer + re-enabling your membership. + """)]) + + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([Hidden('cookie', cookie)]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([SubmitButton('submit', _('Re-enable membership')), + SubmitButton('cancel', _('Cancel'))]) + + form.AddItem(table) + doc.AddItem(form) diff --git a/Mailman/Cgi/create.py b/Mailman/Cgi/create.py new file mode 100644 index 00000000..31e16269 --- /dev/null +++ b/Mailman/Cgi/create.py @@ -0,0 +1,410 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Create mailing lists through the web.""" + +import sys +import os +import signal +import cgi +import sha +from types import ListType + +from Mailman import mm_cfg +from Mailman import MailList +from Mailman import Message +from Mailman import Errors +from Mailman import i18n +from Mailman.htmlformat import * +from Mailman.Logging.Syslog import syslog + +# Set up i18n +_ = i18n._ +i18n.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE) + + + +def main(): + doc = Document() + doc.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE) + + cgidata = cgi.FieldStorage() + parts = Utils.GetPathPieces() + if parts: + # Bad URL specification + title = _('Bad URL specification') + doc.SetTitle(title) + doc.AddItem( + Header(3, Bold(FontAttr(title, color='#ff0000', size='+2')))) + syslog('error', 'Bad URL specification: %s', parts) + elif cgidata.has_key('doit'): + # We must be processing the list creation request + process_request(doc, cgidata) + elif cgidata.has_key('clear'): + request_creation(doc) + else: + # Put up the list creation request form + request_creation(doc) + doc.AddItem('
      ') + # Always add the footer and print the document + doc.AddItem(_('Return to the ') + + Link(Utils.ScriptURL('listinfo'), + _('general list overview')).Format()) + doc.AddItem(_('
      Return to the ') + + Link(Utils.ScriptURL('admin'), + _('administrative list overview')).Format()) + doc.AddItem(MailmanLogo()) + print doc.Format() + + + +def process_request(doc, cgidata): + # Lowercase the listname since this is treated as the "internal" name. + listname = cgidata.getvalue('listname', '').strip().lower() + owner = cgidata.getvalue('owner', '').strip() + try: + autogen = int(cgidata.getvalue('autogen', '0')) + except ValueError: + autogen = 0 + try: + notify = int(cgidata.getvalue('notify', '0')) + except ValueError: + notify = 0 + try: + moderate = int(cgidata.getvalue('moderate', '0')) + except ValueError: + moderate = mm_cfg.DEFAULT_DEFAULT_MEMBER_MODERATION + + password = cgidata.getvalue('password', '').strip() + confirm = cgidata.getvalue('confirm', '').strip() + auth = cgidata.getvalue('auth', '').strip() + langs = cgidata.getvalue('langs', [mm_cfg.DEFAULT_SERVER_LANGUAGE]) + + if type(langs) <> ListType: + langs = [langs] + # Sanity check + if '@' in listname: + request_creation(doc, cgidata, + _('List name must not include "@": %(listname)s')) + return + if Utils.list_exists(listname): + # BAW: should we tell them the list already exists? This could be + # used to mine/guess the existance of non-advertised lists. Then + # again, that can be done in other ways already, so oh well. + request_creation(doc, cgidata, _('List already exists: %(listname)s')) + return + if not listname: + request_creation(doc, cgidata, + _('You forgot to enter the list name')) + return + if not owner: + request_creation(doc, cgidata, + _('You forgot to specify the list owner')) + return + + if autogen: + if password or confirm: + request_creation( + doc, cgidata, + _('''Leave the initial password (and confirmation) fields + blank if you want Mailman to autogenerate the list + passwords.''')) + return + password = confirm = Utils.MakeRandomPassword(length=8) + else: + if password <> confirm: + request_creation(doc, cgidata, + _('Initial list passwords do not match')) + return + if not password: + request_creation( + doc, cgidata, + # The little tag is used so that this string + # differs from the one in bin/newlist. The former is destined + # for the web while the latter is destined for email, so they + # must be different entries in the message catalog. + _('The list password cannot be empty')) + return + # The authorization password must be non-empty, and it must match either + # the list creation password or the site admin password + ok = 0 + if auth: + ok = Utils.check_global_password(auth, 0) + if not ok: + ok = Utils.check_global_password(auth) + if not ok: + request_creation( + doc, cgidata, + _('You are not authorized to create new mailing lists')) + return + # We've got all the data we need, so go ahead and try to create the list + # See admin.py for why we need to set up the signal handler. + mlist = MailList.MailList() + + def sigterm_handler(signum, frame, mlist=mlist): + # Make sure the list gets unlocked... + mlist.Unlock() + # ...and ensure we exit, otherwise race conditions could cause us to + # enter MailList.Save() while we're in the unlocked state, and that + # could be bad! + sys.exit(0) + + try: + # Install the emergency shutdown signal handler + signal.signal(signal.SIGTERM, sigterm_handler) + + pw = sha.new(password).hexdigest() + # Guarantee that all newly created files have the proper permission. + # proper group ownership should be assured by the autoconf script + # enforcing that all directories have the group sticky bit set + oldmask = os.umask(002) + try: + try: + mlist.Create(listname, owner, pw, langs) + finally: + os.umask(oldmask) + except Errors.MMBadEmailError, s: + request_creation(doc, cgidata, + _('Bad owner email address: %(s)s')) + return + except Errors.MMListAlreadyExistsError: + request_creation(doc, cgidata, + _('List already exists: %(listname)s')) + return + except Errors.BadListNameError, s: + request_creation(doc, cgidata, + _('Illegal list name: %(s)s')) + return + except Errors.MMListError: + request_creation( + doc, cgidata, + _('''Some unknown error occurred while creating the list. + Please contact the site administrator for assistance.''')) + return + + # Initialize the host_name and web_page_url attributes, based on + # virtual hosting settings and the request environment variables. + hostname = Utils.get_domain() + mlist.default_member_moderation = moderate + mlist.web_page_url = mm_cfg.DEFAULT_URL_PATTERN % hostname + mlist.host_name = mm_cfg.VIRTUAL_HOSTS.get( + hostname, mm_cfg.DEFAULT_EMAIL_HOST) + mlist.Save() + finally: + # Now be sure to unlock the list. It's okay if we get a signal here + # because essentially, the signal handler will do the same thing. And + # unlocking is unconditional, so it's not an error if we unlock while + # we're already unlocked. + mlist.Unlock() + + # Now do the MTA-specific list creation tasks + if mm_cfg.MTA: + modname = 'Mailman.MTA.' + mm_cfg.MTA + __import__(modname) + sys.modules[modname].create(mlist, cgi=1) + + # And send the notice to the list owner. + if notify: + siteadmin = Utils.get_site_email(mlist.host_name, 'admin') + text = Utils.maketext( + 'newlist.txt', + {'listname' : listname, + 'password' : password, + 'admin_url' : mlist.GetScriptURL('admin', absolute=1), + 'listinfo_url': mlist.GetScriptURL('listinfo', absolute=1), + 'requestaddr' : mlist.GetRequestEmail(), + 'siteowner' : siteadmin, + }, mlist=mlist) + msg = Message.UserNotification( + owner, siteadmin, + _('Your new mailing list: %(listname)s'), + text, mlist.preferred_language) + msg.send(mlist) + + # Success! + listinfo_url = mlist.GetScriptURL('listinfo', absolute=1) + admin_url = mlist.GetScriptURL('admin', absolute=1) + create_url = Utils.ScriptURL('create') + + title = _('Mailing list creation results') + doc.SetTitle(title) + table = Table(border=0, width='100%') + table.AddRow([Center(Bold(FontAttr(title, size='+1')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, + bgcolor=mm_cfg.WEB_HEADER_COLOR) + table.AddRow([_('''You have successfully created the mailing list + %(listname)s and notification has been sent to the list owner + %(owner)s. You can now:''')]) + ullist = UnorderedList() + ullist.AddItem(Link(listinfo_url, _("Visit the list's info page"))) + ullist.AddItem(Link(admin_url, _("Visit the list's admin page"))) + ullist.AddItem(Link(create_url, _('Create another list'))) + table.AddRow([ullist]) + doc.AddItem(table) + + + +# Because the cgi module blows +class Dummy: + def getvalue(self, name, default): + return default +dummy = Dummy() + + + +def request_creation(doc, cgidata=dummy, errmsg=None): + # What virtual domain are we using? + hostname = Utils.get_domain() + # Set up the document + title = _('Create a %(hostname)s Mailing List') + doc.SetTitle(title) + table = Table(border=0, width='100%') + table.AddRow([Center(Bold(FontAttr(title, size='+1')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, + bgcolor=mm_cfg.WEB_HEADER_COLOR) + # Add any error message + if errmsg: + table.AddRow([Header(3, Bold( + FontAttr(_('Error: '), color='#ff0000', size='+2').Format() + + Italic(errmsg).Format()))]) + table.AddRow([_("""You can create a new mailing list by entering the + relevant information into the form below. The name of the mailing list + will be used as the primary address for posting messages to the list, so + it should be lowercased. You will not be able to change this once the + list is created. + +

      You also need to enter the email address of the initial list owner. + Once the list is created, the list owner will be given notification, along + with the initial list password. The list owner will then be able to + modify the password and add or remove additional list owners. + +

      If you want Mailman to automatically generate the initial list admin + password, click on `Yes' in the autogenerate field below, and leave the + initial list password fields empty. + +

      You must have the proper authorization to create new mailing lists. + Each site should have a list creator's password, which you can + enter in the field at the bottom. Note that the site administrator's + password can also be used for authentication. + """)]) + # Build the form for the necessary input + GREY = mm_cfg.WEB_ADMINITEM_COLOR + form = Form(Utils.ScriptURL('create')) + ftable = Table(border=0, cols='2', width='100%', + cellspacing=3, cellpadding=4) + + ftable.AddRow([Center(Italic(_('List Identity')))]) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, colspan=2) + + ftable.AddRow([Label(_('Name of list:')), + TextBox('listname', cgidata.getvalue('listname', ''))]) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY) + + ftable.AddRow([Label(_('Initial list owner address:')), + TextBox('owner', cgidata.getvalue('owner', ''))]) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY) + + try: + autogen = int(cgidata.getvalue('autogen', '0')) + except ValueError: + autogen = 0 + ftable.AddRow([Label(_('Auto-generate initial list password?')), + RadioButtonArray('autogen', (_('No'), _('Yes')), + checked=autogen, + values=(0, 1))]) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY) + + ftable.AddRow([Label(_('Initial list password:')), + PasswordBox('password', cgidata.getvalue('password', ''))]) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY) + + ftable.AddRow([Label(_('Confirm initial password:')), + PasswordBox('confirm', cgidata.getvalue('confirm', ''))]) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY) + + try: + notify = int(cgidata.getvalue('notify', '1')) + except ValueError: + notify = 1 + + ftable.AddRow([Center(Italic(_('List Characteristics')))]) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, colspan=2) + + ftable.AddRow([ + Label(_("""Should new members be quarantined before they + are allowed to post unmoderated to this list? Answer Yes to hold + new member postings for moderator approval by default.""")), + RadioButtonArray('moderate', (_('No'), _('Yes')), + checked=mm_cfg.DEFAULT_DEFAULT_MEMBER_MODERATION, + values=(0,1))]) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY) + # Create the table of initially supported languages, sorted on the long + # name of the language. + revmap = {} + for key, (name, charset) in mm_cfg.LC_DESCRIPTIONS.items(): + revmap[_(name)] = key + langnames = revmap.keys() + langnames.sort() + langs = [] + for name in langnames: + langs.append(revmap[name]) + try: + langi = langs.index(mm_cfg.DEFAULT_SERVER_LANGUAGE) + except ValueError: + # Someone must have deleted the servers's preferred language. Could + # be other trouble lurking! + langi = 0 + # BAW: we should preserve the list of checked languages across form + # invocations. + checked = [0] * len(langs) + checked[langi] = 1 + deflang = _(Utils.GetLanguageDescr(mm_cfg.DEFAULT_SERVER_LANGUAGE)) + ftable.AddRow([Label(_( + '''Initial list of supported languages.

      Note that if you do not + select at least one initial language, the list will use the server + default language of %(deflang)s''')), + CheckBoxArray('langs', + [_(Utils.GetLanguageDescr(L)) for L in langs], + checked=checked, + values=langs)]) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY) + + ftable.AddRow([Label(_('Send "list created" email to list owner?')), + RadioButtonArray('notify', (_('No'), _('Yes')), + checked=notify, + values=(0, 1))]) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY) + + ftable.AddRow(['


      ']) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, colspan=2) + ftable.AddRow([Label(_("List creator's (authentication) password:")), + PasswordBox('auth')]) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY) + + ftable.AddRow([Center(SubmitButton('doit', _('Create List'))), + Center(SubmitButton('clear', _('Clear Form')))]) + form.AddItem(ftable) + table.AddRow([form]) + doc.AddItem(table) diff --git a/Mailman/Cgi/edithtml.py b/Mailman/Cgi/edithtml.py new file mode 100644 index 00000000..cd235162 --- /dev/null +++ b/Mailman/Cgi/edithtml.py @@ -0,0 +1,170 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Script which implements admin editing of the list's html templates.""" + +import os +import cgi +import errno + +from Mailman import Utils +from Mailman import MailList +from Mailman.htmlformat import * +from Mailman.HTMLFormatter import HTMLFormatter +from Mailman import Errors +from Mailman.Cgi import Auth +from Mailman.Logging.Syslog import syslog +from Mailman import i18n + +_ = i18n._ + + + +def main(): + # Trick out pygettext since we want to mark template_data as translatable, + # but we don't want to actually translate it here. + def _(s): + return s + + template_data = ( + ('listinfo.html', _('General list information page')), + ('subscribe.html', _('Subscribe results page')), + ('options.html', _('User specific options page')), + ) + + _ = i18n._ + doc = Document() + + # Set up the system default language + i18n.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE) + doc.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE) + + parts = Utils.GetPathPieces() + if not parts: + doc.AddItem(Header(2, _("List name is required."))) + print doc.Format() + return + + listname = parts[0].lower() + try: + mlist = MailList.MailList(listname, lock=0) + except Errors.MMListError, e: + # Avoid cross-site scripting attacks + safelistname = Utils.websafe(listname) + doc.AddItem(Header(2, _('No such list %(safelistname)s'))) + print doc.Format() + syslog('error', 'No such list "%s": %s', listname, e) + return + + # Now that we have a valid list, set the language to its default + i18n.set_language(mlist.preferred_language) + doc.set_language(mlist.preferred_language) + + # Must be authenticated to get any farther + cgidata = cgi.FieldStorage() + + # Editing the html for a list is limited to the list admin and site admin. + if not mlist.WebAuthenticate((mm_cfg.AuthListAdmin, + mm_cfg.AuthSiteAdmin), + cgidata.getvalue('adminpw', '')): + if cgidata.has_key('admlogin'): + # This is a re-authorization attempt + msg = Bold(FontSize('+1', _('Authorization failed.'))).Format() + else: + msg = '' + Auth.loginpage(mlist, 'admin', msg=msg) + return + + realname = mlist.real_name + if len(parts) > 1: + template_name = parts[1] + for (template, info) in template_data: + if template == template_name: + template_info = _(info) + doc.SetTitle(_( + '%(realname)s -- Edit html for %(template_info)s')) + break + else: + # Avoid cross-site scripting attacks + safetemplatename = Utils.websafe(template_name) + doc.SetTitle(_('Edit HTML : Error')) + doc.AddItem(Header(2, _("%(safetemplatename)s: Invalid template"))) + doc.AddItem(mlist.GetMailmanFooter()) + print doc.Format() + return + else: + doc.SetTitle(_('%(realname)s -- HTML Page Editing')) + doc.AddItem(Header(1, _('%(realname)s -- HTML Page Editing'))) + doc.AddItem(Header(2, _('Select page to edit:'))) + template_list = UnorderedList() + for (template, info) in template_data: + l = Link(mlist.GetScriptURL('edithtml') + '/' + template, _(info)) + template_list.AddItem(l) + doc.AddItem(FontSize("+2", template_list)) + doc.AddItem(mlist.GetMailmanFooter()) + print doc.Format() + return + + try: + if cgidata.keys(): + ChangeHTML(mlist, cgidata, template_name, doc) + FormatHTML(mlist, doc, template_name, template_info) + finally: + doc.AddItem(mlist.GetMailmanFooter()) + print doc.Format() + + + +def FormatHTML(mlist, doc, template_name, template_info): + doc.AddItem(Header(1,'%s:' % mlist.real_name)) + doc.AddItem(Header(1, template_info)) + doc.AddItem('
      ') + + link = Link(mlist.GetScriptURL('admin'), + _('View or edit the list configuration information.')) + + doc.AddItem(FontSize("+1", link)) + doc.AddItem('

      ') + doc.AddItem('


      ') + form = Form(mlist.GetScriptURL('edithtml') + '/' + template_name) + text = Utils.websafe(Utils.maketext(template_name, raw=1, mlist=mlist)) + form.AddItem(TextArea('html_code', text, rows=40, cols=75)) + form.AddItem('

      ' + _('When you are done making changes...')) + form.AddItem(SubmitButton('submit', _('Submit Changes'))) + doc.AddItem(form) + + + +def ChangeHTML(mlist, cgi_info, template_name, doc): + if not cgi_info.has_key('html_code'): + doc.AddItem(Header(3,_("Can't have empty html page."))) + doc.AddItem(Header(3,_("HTML Unchanged."))) + doc.AddItem('


      ') + return + code = cgi_info['html_code'].value + langdir = os.path.join(mlist.fullpath(), mlist.preferred_language) + # Make sure the directory exists + try: + os.mkdir(langdir, 02775) + except OSError, e: + if e.errno <> errno.EEXIST: raise + fp = open(os.path.join(langdir, template_name), 'w') + try: + fp.write(code) + finally: + fp.close() + doc.AddItem(Header(3, _('HTML successfully updated.'))) + doc.AddItem('
      ') diff --git a/Mailman/Cgi/listinfo.py b/Mailman/Cgi/listinfo.py new file mode 100644 index 00000000..d9e4d266 --- /dev/null +++ b/Mailman/Cgi/listinfo.py @@ -0,0 +1,206 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Produce listinfo page, primary web entry-point to mailing lists. +""" + +# No lock needed in this script, because we don't change data. + +import os +import cgi + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import MailList +from Mailman import Errors +from Mailman import i18n +from Mailman.htmlformat import * +from Mailman.Logging.Syslog import syslog + +# Set up i18n +_ = i18n._ +i18n.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE) + + + +def main(): + parts = Utils.GetPathPieces() + if not parts: + listinfo_overview() + return + + listname = parts[0].lower() + try: + mlist = MailList.MailList(listname, lock=0) + except Errors.MMListError, e: + # Avoid cross-site scripting attacks + safelistname = Utils.websafe(listname) + listinfo_overview(_('No such list %(safelistname)s')) + syslog('error', 'No such list "%s": %s', listname, e) + return + + # See if the user want to see this page in other language + cgidata = cgi.FieldStorage() + language = cgidata.getvalue('language', mlist.preferred_language) + i18n.set_language(language) + list_listinfo(mlist, language) + + + +def listinfo_overview(msg=''): + # Present the general listinfo overview + hostname = Utils.get_domain() + # Set up the document and assign it the correct language. The only one we + # know about at the moment is the server's default. + doc = Document() + doc.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE) + + legend = _("%(hostname)s Mailing Lists") + doc.SetTitle(legend) + + table = Table(border=0, width="100%") + table.AddRow([Center(Header(2, legend))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2, + bgcolor=mm_cfg.WEB_HEADER_COLOR) + + # Skip any mailing lists that isn't advertised. + advertised = [] + listnames = Utils.list_names() + listnames.sort() + + for name in listnames: + mlist = MailList.MailList(name, lock=0) + if mlist.advertised: + if mm_cfg.VIRTUAL_HOST_OVERVIEW and \ + mlist.web_page_url.find(hostname) == -1: + # List is for different identity of this host - skip it. + continue + else: + advertised.append(mlist) + + if msg: + greeting = FontAttr(msg, color="ff5060", size="+1") + else: + greeting = FontAttr(_('Welcome!'), size='+2') + + welcome = [greeting] + mailmanlink = Link(mm_cfg.MAILMAN_URL, _('Mailman')).Format() + if not advertised: + welcome.extend( + _('''

      There currently are no publicly-advertised + %(mailmanlink)s mailing lists on %(hostname)s.''')) + else: + welcome.append( + _('''

      Below is a listing of all the public mailing lists on + %(hostname)s. Click on a list name to get more information about + the list, or to subscribe, unsubscribe, and change the preferences + on your subscription.''')) + + # set up some local variables + adj = msg and _('right') or '' + siteowner = Utils.get_site_email() + welcome.extend( + (_(''' To visit the general information page for an unadvertised list, + open a URL similar to this one, but with a '/' and the %(adj)s + list name appended. +

      List administrators, you can visit '''), + Link(Utils.ScriptURL('admin'), + _('the list admin overview page')), + _(''' to find the management interface for your list. +

      Send questions or comments to '''), + Link('mailto:' + siteowner, siteowner), + '.

      ')) + + table.AddRow([apply(Container, welcome)]) + table.AddCellInfo(max(table.GetCurrentRowIndex(), 0), 0, colspan=2) + + if advertised: + table.AddRow([' ', ' ']) + table.AddRow([Bold(FontAttr(_('List'), size='+2')), + Bold(FontAttr(_('Description'), size='+2')) + ]) + highlight = 1 + for mlist in advertised: + table.AddRow( + [Link(mlist.GetScriptURL('listinfo'), Bold(mlist.real_name)), + mlist.description or Italic(_('[no description available]'))]) + if highlight and mm_cfg.WEB_HIGHLIGHT_COLOR: + table.AddRowInfo(table.GetCurrentRowIndex(), + bgcolor=mm_cfg.WEB_HIGHLIGHT_COLOR) + highlight = not highlight + + doc.AddItem(table) + doc.AddItem('


      ') + doc.AddItem(MailmanLogo()) + print doc.Format() + + + +def list_listinfo(mlist, lang): + # Generate list specific listinfo + doc = HeadlessDocument() + doc.set_language(lang) + + replacements = mlist.GetStandardReplacements(lang) + + if not mlist.digestable or not mlist.nondigestable: + replacements[''] = "" + replacements[''] = "" + replacements[''] = '' + else: + replacements[''] = mlist.FormatDigestButton() + replacements[''] = \ + mlist.FormatUndigestButton() + replacements[''] = '' + replacements[''] = '' + replacements[''] = \ + mlist.FormatPlainDigestsButton() + replacements[''] = mlist.FormatMimeDigestsButton() + replacements[''] = mlist.FormatBox('email', size=30) + replacements[''] = mlist.FormatButton( + 'email-button', text=_('Subscribe')) + replacements[''] = mlist.FormatSecureBox('pw') + replacements[''] = mlist.FormatSecureBox('pw-conf') + replacements[''] = mlist.FormatFormStart( + 'subscribe') + # Roster form substitutions + replacements[''] = mlist.FormatFormStart('roster') + replacements[''] = mlist.FormatRosterOptionForUser(lang) + # Options form substitutions + replacements[''] = mlist.FormatFormStart('options') + replacements[''] = mlist.FormatEditingOption(lang) + replacements[''] = SubmitButton('UserOptions', + _('Edit Options')).Format() + # If only one language is enabled for this mailing list, omit the choice + # buttons. + if len(mlist.GetAvailableLanguages()) == 1: + displang = '' + else: + displang = mlist.FormatButton('displang-button', + text = _("View this page in")) + replacements[''] = displang + replacements[''] = mlist.FormatFormStart('listinfo') + replacements[''] = mlist.FormatBox('fullname', size=30) + + # Do the expansion. + doc.AddItem(mlist.ParseTags('listinfo.html', replacements, lang)) + print doc.Format() + + + +if __name__ == "__main__": + main() diff --git a/Mailman/Cgi/options.py b/Mailman/Cgi/options.py new file mode 100644 index 00000000..da4562f7 --- /dev/null +++ b/Mailman/Cgi/options.py @@ -0,0 +1,950 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Produce and handle the member options.""" + +import sys +import os +import cgi +import signal +import urllib +from types import ListType + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import MailList +from Mailman import Errors +from Mailman import MemberAdaptor +from Mailman import i18n +from Mailman.htmlformat import * +from Mailman.Logging.Syslog import syslog + +SLASH = '/' +SETLANGUAGE = -1 + +# Set up i18n +_ = i18n._ +i18n.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE) + + + +def main(): + doc = Document() + doc.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE) + + parts = Utils.GetPathPieces() + lenparts = parts and len(parts) + if not parts or lenparts < 1: + title = _('CGI script error') + doc.SetTitle(title) + doc.AddItem(Header(2, title)) + doc.addError(_('Invalid options to CGI script.')) + doc.AddItem('
      ') + doc.AddItem(MailmanLogo()) + print doc.Format() + return + + # get the list and user's name + listname = parts[0].lower() + # open list + try: + mlist = MailList.MailList(listname, lock=0) + except Errors.MMListError, e: + # Avoid cross-site scripting attacks + safelistname = Utils.websafe(listname) + title = _('CGI script error') + doc.SetTitle(title) + doc.AddItem(Header(2, title)) + doc.addError(_('No such list %(safelistname)s')) + doc.AddItem('
      ') + doc.AddItem(MailmanLogo()) + print doc.Format() + syslog('error', 'No such list "%s": %s\n', listname, e) + return + + # The total contents of the user's response + cgidata = cgi.FieldStorage(keep_blank_values=1) + + # Set the language for the page. If we're coming from the listinfo cgi, + # we might have a 'language' key in the cgi data. That was an explicit + # preference to view the page in, so we should honor that here. If that's + # not available, use the list's default language. + language = cgidata.getvalue('language', mlist.preferred_language) + i18n.set_language(language) + doc.set_language(language) + + if lenparts < 2: + user = cgidata.getvalue('email') + if not user: + # If we're coming from the listinfo page and we left the email + # address field blank, it's not an error. listinfo.html names the + # button UserOptions; we can use that as the descriminator. + if not cgidata.getvalue('UserOptions'): + doc.addError(_('No address given')) + loginpage(mlist, doc, None, cgidata) + print doc.Format() + return + else: + user = Utils.LCDomain(Utils.UnobscureEmail(SLASH.join(parts[1:]))) + + # Avoid cross-site scripting attacks + safeuser = Utils.websafe(user) + # Sanity check the user, but be careful about leaking membership + # information when we're using private rosters. + if not mlist.isMember(user) and mlist.private_roster == 0: + doc.addError(_('No such member: %(safeuser)s.')) + loginpage(mlist, doc, None, cgidata) + print doc.Format() + return + + # Find the case preserved email address (the one the user subscribed with) + lcuser = user.lower() + try: + cpuser = mlist.getMemberCPAddress(lcuser) + except Errors.NotAMemberError: + # This happens if the user isn't a member but we've got private rosters + cpuser = None + if lcuser == cpuser: + cpuser = None + + # And now we know the user making the request, so set things up to for the + # user's stored preferred language, overridden by any form settings for + # their new language preference. + userlang = cgidata.getvalue('language', mlist.getMemberLanguage(user)) + doc.set_language(userlang) + i18n.set_language(userlang) + + # See if this is VARHELP on topics. + varhelp = None + if cgidata.has_key('VARHELP'): + varhelp = cgidata['VARHELP'].value + elif os.environ.get('QUERY_STRING'): + # POST methods, even if their actions have a query string, don't get + # put into FieldStorage's keys :-( + qs = cgi.parse_qs(os.environ['QUERY_STRING']).get('VARHELP') + if qs and type(qs) == types.ListType: + varhelp = qs[0] + if varhelp: + topic_details(mlist, doc, user, cpuser, userlang, varhelp) + return + + # Are we processing an unsubscription request from the login screen? + if cgidata.has_key('login-unsub'): + # Because they can't supply a password for unsubscribing, we'll need + # to do the confirmation dance. + if mlist.isMember(user): + mlist.ConfirmUnsubscription(user, userlang) + doc.addError(_('The confirmation email has been sent.'), tag='') + else: + # Not a member + if mlist.private_roster == 0: + # Public rosters + doc.addError(_('No such member: %(safeuser)s.')) + else: + syslog('mischief', + 'Unsub attempt of non-member w/ private rosters: %s', + user) + doc.addError(_('The confirmation email has been sent.'), + tag='') + loginpage(mlist, doc, user, cgidata) + print doc.Format() + return + + # Are we processing a password reminder from the login screen? + if cgidata.has_key('login-remind'): + if mlist.isMember(user): + mlist.MailUserPassword(user) + doc.addError( + _('A reminder of your password has been emailed to you.'), + tag='') + else: + # Not a member + if mlist.private_roster == 0: + # Public rosters + doc.addError(_('No such member: %(safeuser)s.')) + else: + syslog('mischief', + 'Reminder attempt of non-member w/ private rosters: %s', + user) + doc.addError( + _('A reminder of your password has been emailed to you.'), + tag='') + loginpage(mlist, doc, user, cgidata) + print doc.Format() + return + + # Authenticate, possibly using the password supplied in the login page + password = cgidata.getvalue('password', '').strip() + + if not mlist.WebAuthenticate((mm_cfg.AuthUser, + mm_cfg.AuthListAdmin, + mm_cfg.AuthSiteAdmin), + password, user): + # Not authenticated, so throw up the login page again. If they tried + # to authenticate via cgi (instead of cookie), then print an error + # message. + if cgidata.has_key('password'): + doc.addError(_('Authentication failed.')) + # So as not to allow membership leakage, prompt for the email + # address and the password here. + if mlist.private_roster <> 0: + syslog('mischief', + 'Login failure with private rosters: %s', + user) + user = None + loginpage(mlist, doc, user, cgidata) + print doc.Format() + return + + # From here on out, the user is okay to view and modify their membership + # options. The first set of checks does not require the list to be + # locked. + + if cgidata.has_key('logout'): + print mlist.ZapCookie(mm_cfg.AuthUser, user) + loginpage(mlist, doc, user, cgidata) + print doc.Format() + return + + if cgidata.has_key('emailpw'): + mlist.MailUserPassword(user) + options_page( + mlist, doc, user, cpuser, userlang, + _('A reminder of your password has been emailed to you.')) + print doc.Format() + return + + if cgidata.has_key('othersubs'): + hostname = mlist.host_name + title = _('List subscriptions for %(user)s on %(hostname)s') + doc.SetTitle(title) + doc.AddItem(Header(2, title)) + doc.AddItem(_('''Click on a link to visit your options page for the + requested mailing list.''')) + + # Troll through all the mailing lists that match host_name and see if + # the user is a member. If so, add it to the list. + onlists = [] + for gmlist in lists_of_member(mlist, user) + [mlist]: + url = gmlist.GetOptionsURL(user) + link = Link(url, gmlist.real_name) + onlists.append((gmlist.real_name, link)) + onlists.sort() + items = OrderedList(*[link for name, link in onlists]) + doc.AddItem(items) + print doc.Format() + return + + if cgidata.has_key('change-of-address'): + # We could be changing the user's full name, email address, or both. + # Watch out for non-ASCII characters in the member's name. + membername = cgidata.getvalue('fullname') + # Canonicalize the member's name + membername = Utils.canonstr(membername, language) + newaddr = cgidata.getvalue('new-address') + confirmaddr = cgidata.getvalue('confirm-address') + + oldname = mlist.getMemberName(user) + set_address = set_membername = 0 + + # See if the user wants to change their email address globally + globally = cgidata.getvalue('changeaddr-globally') + + # We will change the member's name under the following conditions: + # - membername has a value + # - membername has no value, but they /used/ to have a membername + if membername and membername <> oldname: + # Setting it to a new value + set_membername = 1 + if not membername and oldname: + # Unsetting it + set_membername = 1 + # We will change the user's address if both newaddr and confirmaddr + # are non-blank, have the same value, and aren't the currently + # subscribed email address (when compared case-sensitively). If both + # are blank, but membername is set, we ignore it, otherwise we print + # an error. + msg = '' + if newaddr and confirmaddr: + if newaddr <> confirmaddr: + options_page(mlist, doc, user, cpuser, userlang, + _('Addresses did not match!')) + print doc.Format() + return + if newaddr == user: + options_page(mlist, doc, user, cpuser, userlang, + _('You are already using that email address')) + print doc.Format() + return + # If they're requesting to subscribe an address which is already a + # member, and they're /not/ doing it globally, then refuse. + # Otherwise, we'll agree to do it globally (with a warning + # message) and let ApprovedChangeMemberAddress() handle already a + # member issues. + if mlist.isMember(newaddr): + safenewaddr = Utils.websafe(newaddr) + if globally: + listname = mlist.real_name + msg += _("""\ +The new address you requested %(newaddr)s is already a member of the +%(listname)s mailing list, however you have also requested a global change of +address. Upon confirmation, any other mailing list containing the address +%(user)s will be changed. """) + # Don't return + else: + options_page( + mlist, doc, user, cpuser, userlang, + _('The new address is already a member: %(newaddr)s')) + print doc.Format() + return + set_address = 1 + elif (newaddr or confirmaddr) and not set_membername: + options_page(mlist, doc, user, cpuser, userlang, + _('Addresses may not be blank')) + print doc.Format() + return + + # Standard sigterm handler. + def sigterm_handler(signum, frame, mlist=mlist): + mlist.Unlock() + sys.exit(0) + + signal.signal(signal.SIGTERM, sigterm_handler) + if set_address: + # Register the pending change after the list is locked + msg += _('A confirmation message has been sent to %(newaddr)s. ') + mlist.Lock() + try: + try: + mlist.ChangeMemberAddress(user, newaddr, globally) + mlist.Save() + finally: + mlist.Unlock() + except Errors.MMBadEmailError: + msg = _('Bad email address provided') + except Errors.MMHostileAddress: + msg = _('Illegal email address provided') + except Errors.MMAlreadyAMember: + msg = _('%(newaddr)s is already a member of the list.') + + if set_membername: + mlist.Lock() + try: + mlist.ChangeMemberName(user, membername, globally) + mlist.Save() + finally: + mlist.Unlock() + msg += _('Member name successfully changed. ') + + options_page(mlist, doc, user, cpuser, userlang, msg) + print doc.Format() + return + + if cgidata.has_key('changepw'): + newpw = cgidata.getvalue('newpw') + confirmpw = cgidata.getvalue('confpw') + if not newpw or not confirmpw: + options_page(mlist, doc, user, cpuser, userlang, + _('Passwords may not be blank')) + print doc.Format() + return + if newpw <> confirmpw: + options_page(mlist, doc, user, cpuser, userlang, + _('Passwords did not match!')) + print doc.Format() + return + + # See if the user wants to change their passwords globally + mlists = [mlist] + if cgidata.getvalue('pw-globally'): + mlists.extend(lists_of_member(mlist, user)) + + for gmlist in mlists: + change_password(gmlist, user, newpw, confirmpw) + + # Regenerate the cookie so a re-authorization isn't necessary + print mlist.MakeCookie(mm_cfg.AuthUser, user) + options_page(mlist, doc, user, cpuser, userlang, + _('Password successfully changed.')) + print doc.Format() + return + + if cgidata.has_key('unsub'): + # Was the confirming check box turned on? + if not cgidata.getvalue('unsubconfirm'): + options_page( + mlist, doc, user, cpuser, userlang, + _('''You must confirm your unsubscription request by turning + on the checkbox below the Unsubscribe button. You + have not been unsubscribed!''')) + print doc.Format() + return + + # Standard signal handler + def sigterm_handler(signum, frame, mlist=mlist): + mlist.Unlock() + sys.exit(0) + + # Okay, zap them. Leave them sitting at the list's listinfo page. We + # must own the list lock, and we want to make sure the user (BAW: and + # list admin?) is informed of the removal. + signal.signal(signal.SIGTERM, sigterm_handler) + mlist.Lock() + needapproval = 0 + try: + try: + mlist.DeleteMember( + user, 'via the member options page', userack=1) + except Errors.MMNeedApproval: + needapproval = 1 + mlist.Save() + finally: + mlist.Unlock() + # Now throw up some results page, with appropriate links. We can't + # drop them back into their options page, because that's gone now! + fqdn_listname = mlist.GetListEmail() + owneraddr = mlist.GetOwnerEmail() + url = mlist.GetScriptURL('listinfo', absolute=1) + + title = _('Unsubscription results') + doc.SetTitle(title) + doc.AddItem(Header(2, title)) + if needapproval: + doc.AddItem(_("""Your unsubscription request has been received and + forwarded on to the list moderators for approval. You will + receive notification once the list moderators have made their + decision.""")) + else: + doc.AddItem(_("""You have been successfully unsubscribed from the + mailing list %(fqdn_listname)s. If you were receiving digest + deliveries you may get one more digest. If you have any questions + about your unsubscription, please contact the list owners at + %(owneraddr)s.""")) + doc.AddItem(mlist.GetMailmanFooter()) + print doc.Format() + return + + if cgidata.has_key('options-submit'): + # Digest action flags + digestwarn = 0 + cantdigest = 0 + mustdigest = 0 + + newvals = [] + # First figure out which options have changed. The item names come + # from FormatOptionButton() in HTMLFormatter.py + for item, flag in (('digest', mm_cfg.Digests), + ('mime', mm_cfg.DisableMime), + ('dontreceive', mm_cfg.DontReceiveOwnPosts), + ('ackposts', mm_cfg.AcknowledgePosts), + ('disablemail', mm_cfg.DisableDelivery), + ('conceal', mm_cfg.ConcealSubscription), + ('remind', mm_cfg.SuppressPasswordReminder), + ('rcvtopic', mm_cfg.ReceiveNonmatchingTopics), + ('nodupes', mm_cfg.DontReceiveDuplicates), + ): + try: + newval = int(cgidata.getvalue(item)) + except (TypeError, ValueError): + newval = None + + # Skip this option if there was a problem or it wasn't changed. + # Note that delivery status is handled separate from the options + # flags. + if newval is None: + continue + elif flag == mm_cfg.DisableDelivery: + status = mlist.getDeliveryStatus(user) + # Here, newval == 0 means enable, newval == 1 means disable + if not newval and status <> MemberAdaptor.ENABLED: + newval = MemberAdaptor.ENABLED + elif newval and status == MemberAdaptor.ENABLED: + newval = MemberAdaptor.BYUSER + else: + continue + elif newval == mlist.getMemberOption(user, flag): + continue + # Should we warn about one more digest? + if flag == mm_cfg.Digests and \ + newval == 0 and mlist.getMemberOption(user, flag): + digestwarn = 1 + + newvals.append((flag, newval)) + + # The user language is handled a little differently + if userlang not in mlist.GetAvailableLanguages(): + newvals.append((SETLANGUAGE, mlist.preferred_language)) + else: + newvals.append((SETLANGUAGE, userlang)) + + # Process user selected topics, but don't make the changes to the + # MailList object; we must do that down below when the list is + # locked. + topicnames = cgidata.getvalue('usertopic') + if topicnames: + # Some topics were selected. topicnames can actually be a string + # or a list of strings depending on whether more than one topic + # was selected or not. + if not isinstance(topicnames, ListType): + # Assume it was a bare string, so listify it + topicnames = [topicnames] + # unquote the topic names + topicnames = [urllib.unquote_plus(n) for n in topicnames] + + # The standard sigterm handler (see above) + def sigterm_handler(signum, frame, mlist=mlist): + mlist.Unlock() + sys.exit(0) + + # Now, lock the list and perform the changes + mlist.Lock() + try: + signal.signal(signal.SIGTERM, sigterm_handler) + # `values' is a tuple of flags and the web values + for flag, newval in newvals: + # Handle language settings differently + if flag == SETLANGUAGE: + mlist.setMemberLanguage(user, newval) + # Handle delivery status separately + elif flag == mm_cfg.DisableDelivery: + mlist.setDeliveryStatus(user, newval) + else: + try: + mlist.setMemberOption(user, flag, newval) + except Errors.CantDigestError: + cantdigest = 1 + except Errors.MustDigestError: + mustdigest = 1 + # Set the topics information. + mlist.setMemberTopics(user, topicnames) + mlist.Save() + finally: + mlist.Unlock() + + # A bag of attributes for the global options + class Global: + enable = None + remind = None + nodupes = None + mime = None + def __nonzero__(self): + return len(self.__dict__.keys()) > 0 + + globalopts = Global() + + # The enable/disable option and the password remind option may have + # their global flags sets. + if cgidata.getvalue('deliver-globally'): + # Yes, this is inefficient, but the list is so small it shouldn't + # make much of a difference. + for flag, newval in newvals: + if flag == mm_cfg.DisableDelivery: + globalopts.enable = newval + break + + if cgidata.getvalue('remind-globally'): + for flag, newval in newvals: + if flag == mm_cfg.SuppressPasswordReminder: + globalopts.remind = newval + break + + if cgidata.getvalue('nodupes-globally'): + for flag, newval in newvals: + if flag == mm_cfg.DontReceiveDuplicates: + globalopts.nodupes = newval + break + + if cgidata.getvalue('mime-globally'): + for flag, newval in newvals: + if flag == mm_cfg.DisableMime: + globalopts.mime = newval + break + + if globalopts: + for gmlist in lists_of_member(mlist, user): + global_options(gmlist, user, globalopts) + + # Now print the results + if cantdigest: + msg = _('''The list administrator has disabled digest delivery for + this list, so your delivery option has not been set. However your + other options have been set successfully.''') + elif mustdigest: + msg = _('''The list administrator has disabled non-digest delivery + for this list, so your delivery option has not been set. However + your other options have been set successfully.''') + else: + msg = _('You have successfully set your options.') + + if digestwarn: + msg += _('You may get one last digest.') + + options_page(mlist, doc, user, cpuser, userlang, msg) + print doc.Format() + return + + options_page(mlist, doc, user, cpuser, userlang) + print doc.Format() + + + +def options_page(mlist, doc, user, cpuser, userlang, message=''): + # The bulk of the document will come from the options.html template, which + # includes it's own html armor (head tags, etc.). Suppress the head that + # Document() derived pages get automatically. + doc.suppress_head = 1 + + if mlist.obscure_addresses: + presentable_user = Utils.ObscureEmail(user, for_text=1) + if cpuser is not None: + cpuser = Utils.ObscureEmail(cpuser, for_text=1) + else: + presentable_user = user + + fullname = Utils.uncanonstr(mlist.getMemberName(user), userlang) + if fullname: + presentable_user += ', %s' % fullname + + # Do replacements + replacements = mlist.GetStandardReplacements(userlang) + replacements[''] = Bold(FontSize('+1', message)).Format() + replacements[''] = mlist.FormatOptionButton( + mm_cfg.Digests, 1, user) + replacements[''] = mlist.FormatOptionButton( + mm_cfg.Digests, 0, user) + replacements[''] = mlist.FormatOptionButton( + mm_cfg.DisableMime, 1, user) + replacements[''] = mlist.FormatOptionButton( + mm_cfg.DisableMime, 0, user) + replacements[''] = ( + CheckBox('mime-globally', 1, checked=0).Format()) + replacements[''] = mlist.FormatOptionButton( + mm_cfg.DisableDelivery, 0, user) + replacements[''] = mlist.FormatOptionButton( + mm_cfg.DisableDelivery, 1, user) + replacements[''] = mlist.FormatDisabledNotice(user) + replacements[''] = mlist.FormatOptionButton( + mm_cfg.AcknowledgePosts, 0, user) + replacements[''] = mlist.FormatOptionButton( + mm_cfg.AcknowledgePosts, 1, user) + replacements[''] = mlist.FormatOptionButton( + mm_cfg.DontReceiveOwnPosts, 0, user) + replacements[''] = ( + mlist.FormatOptionButton(mm_cfg.DontReceiveOwnPosts, 1, user)) + replacements[''] = ( + mlist.FormatOptionButton(mm_cfg.SuppressPasswordReminder, 1, user)) + replacements[''] = ( + mlist.FormatOptionButton(mm_cfg.SuppressPasswordReminder, 0, user)) + replacements[''] = ( + mlist.FormatOptionButton(mm_cfg.ConcealSubscription, 0, user)) + replacements[''] = mlist.FormatOptionButton( + mm_cfg.ConcealSubscription, 1, user) + replacements[''] = ( + mlist.FormatOptionButton(mm_cfg.DontReceiveDuplicates, 1, user)) + replacements[''] = ( + mlist.FormatOptionButton(mm_cfg.DontReceiveDuplicates, 0, user)) + replacements[''] = ( + mlist.FormatButton('unsub', _('Unsubscribe')) + '
      ' + + CheckBox('unsubconfirm', 1, checked=0).Format() + + _('Yes, I really want to unsubscribe')) + replacements[''] = mlist.FormatSecureBox('newpw') + replacements[''] = mlist.FormatSecureBox('confpw') + replacements[''] = ( + mlist.FormatButton('changepw', _("Change My Password"))) + replacements[''] = ( + mlist.FormatButton('othersubs', + _('List my other subscriptions'))) + replacements[''] = ( + mlist.FormatFormStart('options', user)) + replacements[''] = user + replacements[''] = presentable_user + replacements[''] = mlist.FormatButton( + 'emailpw', (_('Email My Password To Me'))) + replacements[''] = ( + mlist.FormatUmbrellaNotice(user, _("password"))) + replacements[''] = ( + mlist.FormatButton('logout', _('Log out'))) + replacements[''] = mlist.FormatButton( + 'options-submit', _('Submit My Changes')) + replacements[''] = ( + CheckBox('pw-globally', 1, checked=0).Format()) + replacements[''] = ( + CheckBox('deliver-globally', 1, checked=0).Format()) + replacements[''] = ( + CheckBox('remind-globally', 1, checked=0).Format()) + replacements[''] = ( + CheckBox('nodupes-globally', 1, checked=0).Format()) + + days = int(mm_cfg.PENDING_REQUEST_LIFE / mm_cfg.days(1)) + if days > 1: + units = _('days') + else: + units = _('day') + replacements[''] = _('%(days)d %(units)s') + + replacements[''] = mlist.FormatBox('new-address') + replacements[''] = mlist.FormatBox( + 'confirm-address') + replacements[''] = mlist.FormatButton( + 'change-of-address', _('Change My Address and Name')) + replacements[''] = CheckBox( + 'changeaddr-globally', 1, checked=0).Format() + replacements[''] = mlist.FormatBox( + 'fullname', value=fullname) + + # Create the topics radios. BAW: what if the list admin deletes a topic, + # but the user still wants to get that topic message? + usertopics = mlist.getMemberTopics(user) + if mlist.topics: + table = Table(border="0") + for name, pattern, description, emptyflag in mlist.topics: + quotedname = urllib.quote_plus(name) + details = Link(mlist.GetScriptURL('options') + + '/%s/?VARHELP=%s' % (user, quotedname), + ' (Details)') + if name in usertopics: + checked = 1 + else: + checked = 0 + table.AddRow([CheckBox('usertopic', quotedname, checked=checked), + name + details.Format()]) + topicsfield = table.Format() + else: + topicsfield = _('No topics defined') + replacements[''] = topicsfield + replacements[''] = ( + mlist.FormatOptionButton(mm_cfg.ReceiveNonmatchingTopics, 0, user)) + replacements[''] = ( + mlist.FormatOptionButton(mm_cfg.ReceiveNonmatchingTopics, 1, user)) + + if cpuser is not None: + replacements[''] = _(''' +You are subscribed to this list with the case-preserved address +%(cpuser)s.''') + else: + replacements[''] = '' + + doc.AddItem(mlist.ParseTags('options.html', replacements, userlang)) + + + +def loginpage(mlist, doc, user, cgidata): + realname = mlist.real_name + actionurl = mlist.GetScriptURL('options') + if user is None: + title = _('%(realname)s list: member options login page') + extra = _('email address and ') + else: + title = _('%(realname)s list: member options for user %(user)s') + obuser = Utils.ObscureEmail(user) + extra = '' + # Set up the title + doc.SetTitle(title) + # We use a subtable here so we can put a language selection box in + lang = cgidata.getvalue('language', mlist.preferred_language) + table = Table(width='100%', border=0, cellspacing=4, cellpadding=5) + # If only one language is enabled for this mailing list, omit the choice + # buttons. + table.AddRow([Center(Header(2, title))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, + bgcolor=mm_cfg.WEB_HEADER_COLOR) + if len(mlist.GetAvailableLanguages()) > 1: + langform = Form(actionurl) + langform.AddItem(SubmitButton('displang-button', + _('View this page in'))) + langform.AddItem(mlist.GetLangSelectBox(lang)) + if user: + langform.AddItem(Hidden('email', user)) + table.AddRow([Center(langform)]) + doc.AddItem(table) + # Preamble + # Set up the login page + form = Form(actionurl) + table = Table(width='100%', border=0, cellspacing=4, cellpadding=5) + table.AddRow([_("""In order to change your membership option, you must + first log in by giving your %(extra)smembership password in the section + below. If you don't remember your membership password, you can have it + emailed to you by clicking on the button below. If you just want to + unsubscribe from this list, click on the Unsubscribe button and a + confirmation message will be sent to you. + +

      Important: From this point on, you must have + cookies enabled in your browser, otherwise none of your changes will take + effect. + """)]) + # Password and login button + ptable = Table(width='50%', border=0, cellspacing=4, cellpadding=5) + if user is None: + ptable.AddRow([Label(_('Email address:')), + TextBox('email', size=20)]) + else: + ptable.AddRow([Hidden('email', user)]) + ptable.AddRow([Label(_('Password:')), + PasswordBox('password', size=20)]) + ptable.AddRow([Center(SubmitButton('login', _('Log in')))]) + ptable.AddCellInfo(ptable.GetCurrentRowIndex(), 0, colspan=2) + table.AddRow([Center(ptable)]) + # Unsubscribe section + table.AddRow([Center(Header(2, _('Unsubscribe')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, + bgcolor=mm_cfg.WEB_HEADER_COLOR) + + table.AddRow([_("""By clicking on the Unsubscribe button, a + confirmation message will be emailed to you. This message will have a + link that you should click on to complete the removal process (you can + also confirm by email; see the instructions in the confirmation + message).""")]) + + table.AddRow([Center(SubmitButton('login-unsub', _('Unsubscribe')))]) + # Password reminder section + table.AddRow([Center(Header(2, _('Password reminder')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, + bgcolor=mm_cfg.WEB_HEADER_COLOR) + + table.AddRow([_("""By clicking on the Remind button, your + password will be emailed to you.""")]) + + table.AddRow([Center(SubmitButton('login-remind', _('Remind')))]) + # Finish up glomming together the login page + form.AddItem(table) + doc.AddItem(form) + doc.AddItem(mlist.GetMailmanFooter()) + + + +def lists_of_member(mlist, user): + hostname = mlist.host_name + onlists = [] + for listname in Utils.list_names(): + # The current list will always handle things in the mainline + if listname == mlist.internal_name(): + continue + glist = MailList.MailList(listname, lock=0) + if glist.host_name <> hostname: + continue + if not glist.isMember(user): + continue + onlists.append(glist) + return onlists + + + +def change_password(mlist, user, newpw, confirmpw): + # This operation requires the list lock, so let's set up the signal + # handling so the list lock will get released when the user hits the + # browser stop button. + def sigterm_handler(signum, frame, mlist=mlist): + # Make sure the list gets unlocked... + mlist.Unlock() + # ...and ensure we exit, otherwise race conditions could cause us to + # enter MailList.Save() while we're in the unlocked state, and that + # could be bad! + sys.exit(0) + + # Must own the list lock! + mlist.Lock() + try: + # Install the emergency shutdown signal handler + signal.signal(signal.SIGTERM, sigterm_handler) + # change the user's password. The password must already have been + # compared to the confirmpw and otherwise been vetted for + # acceptability. + mlist.setMemberPassword(user, newpw) + mlist.Save() + finally: + mlist.Unlock() + + + +def global_options(mlist, user, globalopts): + # Is there anything to do? + for attr in dir(globalopts): + if attr.startswith('_'): + continue + if getattr(globalopts, attr) is not None: + break + else: + return + + def sigterm_handler(signum, frame, mlist=mlist): + # Make sure the list gets unlocked... + mlist.Unlock() + # ...and ensure we exit, otherwise race conditions could cause us to + # enter MailList.Save() while we're in the unlocked state, and that + # could be bad! + sys.exit(0) + + # Must own the list lock! + mlist.Lock() + try: + # Install the emergency shutdown signal handler + signal.signal(signal.SIGTERM, sigterm_handler) + + if globalopts.enable is not None: + mlist.setDeliveryStatus(user, globalopts.enable) + + if globalopts.remind is not None: + mlist.setMemberOption(user, mm_cfg.SuppressPasswordReminder, + globalopts.remind) + + if globalopts.nodupes is not None: + mlist.setMemberOption(user, mm_cfg.DontReceiveDuplicates, + globalopts.nodupes) + + if globalopts.mime is not None: + mlist.setMemberOption(user, mm_cfg.DisableMime, globalopts.mime) + + mlist.Save() + finally: + mlist.Unlock() + + + +def topic_details(mlist, doc, user, cpuser, userlang, varhelp): + # Find out which topic the user wants to get details of + reflist = varhelp.split('/') + name = None + topicname = _('') + if len(reflist) == 1: + topicname = urllib.unquote_plus(reflist[0]) + for name, pattern, description, emptyflag in mlist.topics: + if name == topicname: + break + else: + name = None + + if not name: + options_page(mlist, doc, user, cpuser, userlang, + _('Requested topic is not valid: %(topicname)s')) + print doc.Format() + return + + table = Table(border=3, width='100%') + table.AddRow([Center(Bold(_('Topic filter details')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2, + bgcolor=mm_cfg.WEB_SUBHEADER_COLOR) + table.AddRow([Bold(Label(_('Name:'))), + Utils.websafe(name)]) + table.AddRow([Bold(Label(_('Pattern (as regexp):'))), + '

      ' + Utils.websafe(pattern) + '
      ']) + table.AddRow([Bold(Label(_('Description:'))), + Utils.websafe(description)]) + # Make colors look nice + for row in range(1, 4): + table.AddCellInfo(row, 0, bgcolor=mm_cfg.WEB_ADMINITEM_COLOR) + + options_page(mlist, doc, user, cpuser, userlang, table.Format()) + print doc.Format() diff --git a/Mailman/Cgi/private.py b/Mailman/Cgi/private.py new file mode 100644 index 00000000..6b7af70a --- /dev/null +++ b/Mailman/Cgi/private.py @@ -0,0 +1,162 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Provide a password-interface wrapper around private archives. +""" + +import sys +import os +import cgi + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import MailList +from Mailman import Errors +from Mailman import i18n +from Mailman.htmlformat import * +from Mailman.Logging.Syslog import syslog + +# Set up i18n. Until we know which list is being requested, we use the +# server's default. +_ = i18n._ +i18n.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE) + + + +def true_path(path): + "Ensure that the path is safe by removing .." + path = path.replace('../', '') + path = path.replace('./', '') + return path[1:] + + +def content_type(path): + if path[-3:] == '.gz': + path = path[:-3] + if path[-4:] == '.txt': + return 'text/plain' + return 'text/html' + + + +def main(): + doc = Document() + doc.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE) + + parts = Utils.GetPathPieces() + if not parts: + doc.SetTitle(_("Private Archive Error")) + doc.AddItem(Header(3, _("You must specify a list."))) + print doc.Format() + return + + path = os.environ.get('PATH_INFO') + # BAW: This needs to be converted to the Site module abstraction + true_filename = os.path.join( + mm_cfg.PRIVATE_ARCHIVE_FILE_DIR, + true_path(path)) + + listname = parts[0].lower() + mboxfile = '' + if len(parts) > 1: + mboxfile = parts[1] + + # See if it's the list's mbox file is being requested + if listname.endswith('.mbox') and mboxfile.endswith('.mbox') and \ + listname[:-5] == mboxfile[:-5]: + listname = listname[:-5] + else: + mboxfile = '' + + # If it's a directory, we have to append index.html in this script. We + # must also check for a gzipped file, because the text archives are + # usually stored in compressed form. + if os.path.isdir(true_filename): + true_filename = true_filename + '/index.html' + if not os.path.exists(true_filename) and \ + os.path.exists(true_filename + '.gz'): + true_filename = true_filename + '.gz' + + try: + mlist = MailList.MailList(listname, lock=0) + except Errors.MMListError, e: + # Avoid cross-site scripting attacks + safelistname = Utils.websafe(listname) + msg = _('No such list %(safelistname)s') + doc.SetTitle(_("Private Archive Error - %(msg)s")) + doc.AddItem(Header(2, msg)) + print doc.Format() + syslog('error', 'No such list "%s": %s\n', listname, e) + return + + i18n.set_language(mlist.preferred_language) + doc.set_language(mlist.preferred_language) + + cgidata = cgi.FieldStorage() + username = cgidata.getvalue('username', '') + password = cgidata.getvalue('password', '') + + is_auth = 0 + realname = mlist.real_name + message = '' + + if not mlist.WebAuthenticate((mm_cfg.AuthUser, + mm_cfg.AuthListModerator, + mm_cfg.AuthListAdmin, + mm_cfg.AuthSiteAdmin), + password, username): + if cgidata.has_key('submit'): + # This is a re-authorization attempt + message = Bold(FontSize('+1', _('Authorization failed.'))).Format() + # Output the password form + charset = Utils.GetCharSet(mlist.preferred_language) + print 'Content-type: text/html; charset=' + charset + '\n\n' + while path and path[0] == '/': + path=path[1:] # Remove leading /'s + print Utils.maketext( + 'private.html', + {'action' : mlist.GetScriptURL('private', absolute=1), + 'realname': mlist.real_name, + 'message' : message, + }, mlist=mlist) + return + + lang = mlist.getMemberLanguage(username) + i18n.set_language(lang) + doc.set_language(lang) + + # Authorization confirmed... output the desired file + try: + ctype = content_type(path) + if mboxfile: + f = open(os.path.join(mlist.archive_dir() + '.mbox', + mlist.internal_name() + '.mbox')) + ctype = 'text/plain' + elif true_filename[-3:] == '.gz': + import gzip + f = gzip.open(true_filename, 'r') + else: + f = open(true_filename, 'r') + except IOError: + msg = _('Private archive file not found') + doc.SetTitle(msg) + doc.AddItem(Header(2, msg)) + print doc.Format() + syslog('error', 'Private archive file not found: %s', true_filename) + else: + print 'Content-type: %s\n' % ctype + sys.stdout.write(f.read()) + f.close() diff --git a/Mailman/Cgi/rmlist.py b/Mailman/Cgi/rmlist.py new file mode 100644 index 00000000..fab57edd --- /dev/null +++ b/Mailman/Cgi/rmlist.py @@ -0,0 +1,242 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Remove/delete mailing lists through the web.""" + +import os +import cgi +import sys +import errno +import shutil + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import MailList +from Mailman import Errors +from Mailman import i18n +from Mailman.htmlformat import * +from Mailman.Logging.Syslog import syslog + +# Set up i18n +_ = i18n._ +i18n.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE) + + + +def main(): + doc = Document() + doc.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE) + + cgidata = cgi.FieldStorage() + parts = Utils.GetPathPieces() + + if not parts: + # Bad URL specification + title = _('Bad URL specification') + doc.SetTitle(title) + doc.AddItem( + Header(3, Bold(FontAttr(title, color='#ff0000', size='+2')))) + doc.AddItem('
      ') + doc.AddItem(MailmanLogo()) + print doc.Format() + syslog('error', 'Bad URL specification: %s', parts) + return + + listname = parts[0].lower() + try: + mlist = MailList.MailList(listname, lock=0) + except Errors.MMListError, e: + # Avoid cross-site scripting attacks + safelistname = Utils.websafe(listname) + title = _('No such list %(safelistname)s') + doc.SetTitle(title) + doc.AddItem( + Header(3, + Bold(FontAttr(title, color='#ff0000', size='+2')))) + doc.AddItem('
      ') + doc.AddItem(MailmanLogo()) + print doc.Format() + syslog('error', 'No such list "%s": %s\n', listname, e) + return + + # Now that we have a valid mailing list, set the language + i18n.set_language(mlist.preferred_language) + doc.set_language(mlist.preferred_language) + + # Be sure the list owners are not sneaking around! + if not mm_cfg.OWNERS_CAN_DELETE_THEIR_OWN_LISTS: + title = _("You're being a sneaky list owner!") + doc.SetTitle(title) + doc.AddItem( + Header(3, Bold(FontAttr(title, color='#ff0000', size='+2')))) + doc.AddItem(mlist.GetMailmanFooter()) + print doc.Format() + syslog('mischief', 'Attempt to sneakily delete a list: %s', listname) + return + + if cgidata.has_key('doit'): + process_request(doc, cgidata, mlist) + print doc.Format() + return + + request_deletion(doc, mlist) + # Always add the footer and print the document + doc.AddItem(mlist.GetMailmanFooter()) + print doc.Format() + + + +def process_request(doc, cgidata, mlist): + password = cgidata.getvalue('password', '').strip() + try: + delarchives = int(cgidata.getvalue('delarchives', '0')) + except ValueError: + delarchives = 0 + + # Removing a list is limited to the list-creator (a.k.a. list-destroyer), + # the list-admin, or the site-admin. Don't use WebAuthenticate here + # because we want to be sure the actual typed password is valid, not some + # password sitting in a cookie. + if mlist.Authenticate((mm_cfg.AuthCreator, + mm_cfg.AuthListAdmin, + mm_cfg.AuthSiteAdmin), + password) == mm_cfg.UnAuthorized: + request_deletion( + doc, mlist, + _('You are not authorized to delete this mailing list')) + return + + # Do the MTA-specific list deletion tasks + if mm_cfg.MTA: + modname = 'Mailman.MTA.' + mm_cfg.MTA + __import__(modname) + sys.modules[modname].remove(mlist, cgi=1) + + REMOVABLES = ['lists/%s'] + + if delarchives: + REMOVABLES.extend(['archives/private/%s', + 'archives/private/%s.mbox', + 'archives/public/%s', + 'archives/public/%s.mbox', + ]) + + problems = 0 + listname = mlist.internal_name() + for dirtmpl in REMOVABLES: + dir = os.path.join(mm_cfg.VAR_PREFIX, dirtmpl % listname) + if os.path.islink(dir): + try: + os.unlink(dir) + except OSError, e: + if e.errno not in (errno.EACCES, errno.EPERM): raise + problems += 1 + syslog('error', + 'link %s not deleted due to permission problems', + dir) + elif os.path.isdir(dir): + try: + shutil.rmtree(dir) + except OSError, e: + if e.errno not in (errno.EACCES, errno.EPERM): raise + problems += 1 + syslog('error', + 'directory %s not deleted due to permission problems', + dir) + + title = _('Mailing list deletion results') + doc.SetTitle(title) + table = Table(border=0, width='100%') + table.AddRow([Center(Bold(FontAttr(title, size='+1')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, + bgcolor=mm_cfg.WEB_HEADER_COLOR) + if not problems: + table.AddRow([_('''You have successfully deleted the mailing list + %(listname)s.''')]) + else: + sitelist = Utils.get_site_email(mlist.host_name) + table.AddRow([_('''There were some problems deleting the mailing list + %(listname)s. Contact your site administrator at %(sitelist)s + for details.''')]) + doc.AddItem(table) + doc.AddItem('
      ') + doc.AddItem(_('Return to the ') + + Link(Utils.ScriptURL('listinfo'), + _('general list overview')).Format()) + doc.AddItem(_('
      Return to the ') + + Link(Utils.ScriptURL('admin'), + _('administrative list overview')).Format()) + doc.AddItem(MailmanLogo()) + + + +def request_deletion(doc, mlist, errmsg=None): + realname = mlist.real_name + title = _('Permanently remove mailing list %(realname)s') + doc.SetTitle(title) + + table = Table(border=0, width='100%') + table.AddRow([Center(Bold(FontAttr(title, size='+1')))]) + table.AddCellInfo(table.GetCurrentRowIndex(), 0, + bgcolor=mm_cfg.WEB_HEADER_COLOR) + + # Add any error message + if errmsg: + table.AddRow([Header(3, Bold( + FontAttr(_('Error: '), color='#ff0000', size='+2').Format() + + Italic(errmsg).Format()))]) + + table.AddRow([_("""This page allows you as the list owner, to permanent + remove this mailing list from the system. This action is not + undoable so you should undertake it only if you are absolutely + sure this mailing list has served its purpose and is no longer necessary. + +

      Note that no warning will be sent to your list members and after this + action, any subsequent messages sent to the mailing list, or any of its + administrative addreses will bounce. + +

      You also have the option of removing the archives for this mailing list + at this time. It is almost always recommended that you do + not remove the archives, since they serve as the + historical record of your mailing list. + +

      For your safety, you will be asked to reconfirm the list password. + """)]) + GREY = mm_cfg.WEB_ADMINITEM_COLOR + form = Form(mlist.GetScriptURL('rmlist')) + ftable = Table(border=0, cols='2', width='100%', + cellspacing=3, cellpadding=4) + + ftable.AddRow([Label(_('List password:')), PasswordBox('password')]) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY) + + ftable.AddRow([Label(_('Also delete archives?')), + RadioButtonArray('delarchives', (_('No'), _('Yes')), + checked=0, values=(0, 1))]) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY) + + ftable.AddRow([Center(Link( + mlist.GetScriptURL('admin'), + _('Cancel and return to list administration')))]) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, colspan=2) + + ftable.AddRow([Center(SubmitButton('doit', _('Delete this list')))]) + ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, colspan=2) + form.AddItem(ftable) + table.AddRow([form]) + doc.AddItem(table) diff --git a/Mailman/Cgi/roster.py b/Mailman/Cgi/roster.py new file mode 100644 index 00000000..71c06240 --- /dev/null +++ b/Mailman/Cgi/roster.py @@ -0,0 +1,129 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Produce subscriber roster, using listinfo form data, roster.html template. + +Takes listname in PATH_INFO. +""" + + +# We don't need to lock in this script, because we're never going to change +# data. + +import sys +import os +import cgi +import urllib + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import MailList +from Mailman import Errors +from Mailman import i18n +from Mailman.htmlformat import * +from Mailman.Logging.Syslog import syslog + +# Set up i18n +_ = i18n._ +i18n.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE) + + + +def main(): + parts = Utils.GetPathPieces() + if not parts: + error_page(_('Invalid options to CGI script')) + return + + listname = parts[0].lower() + try: + mlist = MailList.MailList(listname, lock=0) + except Errors.MMListError, e: + # Avoid cross-site scripting attacks + safelistname = Utils.websafe(listname) + error_page(_('No such list %(safelistname)s')) + syslog('error', 'roster: no such list "%s": %s', listname, e) + return + + cgidata = cgi.FieldStorage() + + # messages in form should go in selected language (if any...) + if cgidata.has_key('language'): + lang = cgidata['language'].value + else: + lang = mlist.preferred_language + + i18n.set_language(lang) + + # Perform authentication for protected rosters. If the roster isn't + # protected, then anybody can see the pages. If members-only or + # "admin"-only, then we try to cookie authenticate the user, and failing + # that, we check roster-email and roster-pw fields for a valid password. + # (also allowed: the list moderator, the list admin, and the site admin). + if mlist.private_roster == 0: + # No privacy + ok = 1 + elif mlist.private_roster == 1: + # Members only + addr = cgidata.getvalue('roster-email', '') + password = cgidata.getvalue('roster-pw', '') + ok = mlist.WebAuthenticate((mm_cfg.AuthUser, + mm_cfg.AuthListModerator, + mm_cfg.AuthListAdmin, + mm_cfg.AuthSiteAdmin), + password, addr) + else: + # Admin only, so we can ignore the address field + password = cgidata.getvalue('roster-pw', '') + ok = mlist.WebAuthenticate((mm_cfg.AuthListModerator, + mm_cfg.AuthListAdmin, + mm_cfg.AuthSiteAdmin), + password) + if not ok: + realname = mlist.real_name + doc = Document() + doc.set_language(lang) + error_page_doc(doc, _('%(realname)s roster authentication failed.')) + doc.AddItem(mlist.GetMailmanFooter()) + print doc.Format() + return + + # The document and its language + doc = HeadlessDocument() + doc.set_language(lang) + + replacements = mlist.GetAllReplacements(lang) + replacements[''] = mlist.FormatButton( + 'displang-button', + text = _('View this page in')) + replacements[''] = mlist.FormatFormStart('roster') + doc.AddItem(mlist.ParseTags('roster.html', replacements, lang)) + print doc.Format() + + + +def error_page(errmsg): + doc = Document() + doc.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE) + error_page_doc(doc, errmsg) + print doc.Format() + + +def error_page_doc(doc, errmsg, *args): + # Produce a simple error-message page on stdout and exit. + doc.SetTitle(_("Error")) + doc.AddItem(Header(2, _("Error"))) + doc.AddItem(Bold(errmsg % args)) diff --git a/Mailman/Cgi/subscribe.py b/Mailman/Cgi/subscribe.py new file mode 100644 index 00000000..c2dfe5cd --- /dev/null +++ b/Mailman/Cgi/subscribe.py @@ -0,0 +1,276 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Process subscription or roster requests from listinfo form.""" + +import sys +import os +import cgi +import signal + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import MailList +from Mailman import Errors +from Mailman import i18n +from Mailman import Message +from Mailman.UserDesc import UserDesc +from Mailman.htmlformat import * +from Mailman.Logging.Syslog import syslog + +SLASH = '/' +ERRORSEP = '\n\n

      ' + +# Set up i18n +_ = i18n._ +i18n.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE) + + + +def main(): + doc = Document() + doc.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE) + + parts = Utils.GetPathPieces() + if not parts: + doc.AddItem(Header(2, _("Error"))) + doc.AddItem(Bold(_('Invalid options to CGI script'))) + print doc.Format() + return + + listname = parts[0].lower() + try: + mlist = MailList.MailList(listname, lock=0) + except Errors.MMListError, e: + # Avoid cross-site scripting attacks + safelistname = Utils.websafe(listname) + doc.AddItem(Header(2, _("Error"))) + doc.AddItem(Bold(_('No such list %(safelistname)s'))) + print doc.Format() + syslog('error', 'No such list "%s": %s\n', listname, e) + return + + # See if the form data has a preferred language set, in which case, use it + # for the results. If not, use the list's preferred language. + cgidata = cgi.FieldStorage() + language = cgidata.getvalue('language', mlist.preferred_language) + i18n.set_language(language) + doc.set_language(language) + + # We need a signal handler to catch the SIGTERM that can come from Apache + # when the user hits the browser's STOP button. See the comment in + # admin.py for details. + # + # BAW: Strictly speaking, the list should not need to be locked just to + # read the request database. However the request database asserts that + # the list is locked in order to load it and it's not worth complicating + # that logic. + def sigterm_handler(signum, frame, mlist=mlist): + # Make sure the list gets unlocked... + mlist.Unlock() + # ...and ensure we exit, otherwise race conditions could cause us to + # enter MailList.Save() while we're in the unlocked state, and that + # could be bad! + sys.exit(0) + + mlist.Lock() + try: + # Install the emergency shutdown signal handler + signal.signal(signal.SIGTERM, sigterm_handler) + + process_form(mlist, doc, cgidata, language) + mlist.Save() + finally: + mlist.Unlock() + + + +def process_form(mlist, doc, cgidata, lang): + listowner = mlist.GetOwnerEmail() + realname = mlist.real_name + results = [] + + # The email address being subscribed, required + email = cgidata.getvalue('email', '') + if not email: + results.append(_('You must supply a valid email address.')) + + fullname = cgidata.getvalue('fullname', '') + # Canonicalize the full name + fullname = Utils.canonstr(fullname, lang) + # Who was doing the subscribing? + remote = os.environ.get('REMOTE_HOST', + os.environ.get('REMOTE_ADDR', + 'unidentified origin')) + + # Was an attempt made to subscribe the list to itself? + if email == mlist.GetListEmail(): + syslog('mischief', 'Attempt to self subscribe %s: %s', email, remote) + results.append(_('You may not subscribe a list to itself!')) + + # If the user did not supply a password, generate one for him + password = cgidata.getvalue('pw') + confirmed = cgidata.getvalue('pw-conf') + + if password is None and confirmed is None: + password = Utils.MakeRandomPassword() + elif password is None or confirmed is None: + results.append(_('If you supply a password, you must confirm it.')) + elif password <> confirmed: + results.append(_('Your passwords did not match.')) + + # Get the digest option for the subscription. + digestflag = cgidata.getvalue('digest') + if digestflag: + try: + digest = int(digestflag) + except ValueError: + digest = 0 + else: + digest = mlist.digest_is_default + + # Sanity check based on list configuration. BAW: It's actually bogus that + # the page allows you to set the digest flag if you don't really get the + # choice. :/ + if not mlist.digestable: + digest = 0 + elif not mlist.nondigestable: + digest = 1 + + if results: + print_results(mlist, ERRORSEP.join(results), doc, lang) + return + + # If this list has private rosters, we have to be careful about the + # message that gets printed, otherwise the subscription process can be + # used to mine for list members. It may be inefficient, but it's still + # possible, and that kind of defeats the purpose of private rosters. + # We'll use this string for all successful or unsuccessful subscription + # results. + if mlist.private_roster == 0: + # Public rosters + privacy_results = '' + else: + privacy_results = _("""\ +Your subscription request has been received, and will soon be acted upon. +Depending on the configuration of this mailing list, your subscription request +may have to be first confirmed by you via email, or approved by the list +moderator. If confirmation is required, you will soon get a confirmation +email which contains further instructions.""") + + try: + userdesc = UserDesc(email, fullname, password, digest, lang) + mlist.AddMember(userdesc, remote) + results = '' + # Check for all the errors that mlist.AddMember can throw options on the + # web page for this cgi + except Errors.MembershipIsBanned: + results = _("""The email address you supplied is banned from this + mailing list. If you think this restriction is erroneous, please + contact the list owners at %(listowner)s.""") + except Errors.MMBadEmailError: + results = _("""\ +The email address you supplied is not valid. (E.g. it must contain an +`@'.)""") + except Errors.MMHostileAddress: + results = _("""\ +Your subscription is not allowed because the email address you gave is +insecure.""") + except Errors.MMSubscribeNeedsConfirmation: + # Results string depends on whether we have private rosters or not + if privacy_results: + results = privacy_results + else: + results = _("""\ +Confirmation from your email address is required, to prevent anyone from +subscribing you without permission. Instructions are being sent to you at +%(email)s. Please note your subscription will not start until you confirm +your subscription.""") + except Errors.MMNeedApproval, x: + # Results string depends on whether we have private rosters or not + if privacy_results: + results = privacy_results + else: + # We need to interpolate into x + x = _(x) + results = _("""\ +Your subscription request was deferred because %(x)s. Your request has been +forwarded to the list moderator. You will receive email informing you of the +moderator's decision when they get to your request.""") + except Errors.MMAlreadyAMember: + # Results string depends on whether we have private rosters or not + if not privacy_results: + results = _('You are already subscribed.') + else: + results = privacy_results + # This could be a membership probe. For safety, let the user know + # a probe occurred. BAW: should we inform the list moderator? + listaddr = mlist.GetListEmail() + # Set the language for this email message to the member's language. + mlang = mlist.getMemberLanguage(email) + otrans = i18n.get_translation() + i18n.set_language(mlang) + try: + msg = Message.UserNotification( + mlist.getMemberCPAddress(email), + mlist.GetBouncesEmail(), + _('Mailman privacy alert'), + _("""\ +An attempt was made to subscribe your address to the mailing list +%(listaddr)s. You are already subscribed to this mailing list. + +Note that the list membership is not public, so it is possible that a bad +person was trying to probe the list for its membership. This would be a +privacy violation if we let them do this, but we didn't. + +If you submitted the subscription request and forgot that you were already +subscribed to the list, then you can ignore this message. If you suspect that +an attempt is being made to covertly discover whether you are a member of this +list, and you are worried about your privacy, then feel free to send a message +to the list administrator at %(listowner)s. +"""), lang=mlang) + finally: + i18n.set_translation(otrans) + msg.send(mlist) + # These shouldn't happen unless someone's tampering with the form + except Errors.MMCantDigestError: + results = _('This list does not support digest delivery.') + except Errors.MMMustDigestError: + results = _('This list only supports digest delivery.') + else: + # Everything's cool. Our return string actually depends on whether + # this list has private rosters or not + if privacy_results: + results = privacy_results + else: + results = _("""\ +You have been successfully subscribed to the %(realname)s mailing list.""") + # Show the results + print_results(mlist, results, doc, lang) + + + +def print_results(mlist, results, doc, lang): + # The bulk of the document will come from the options.html template, which + # includes its own html armor (head tags, etc.). Suppress the head that + # Document() derived pages get automatically. + doc.suppress_head = 1 + + replacements = mlist.GetStandardReplacements(lang) + replacements[''] = results + output = mlist.ParseTags('subscribe.html', replacements, lang) + doc.AddItem(output) + print doc.Format() diff --git a/Mailman/Commands/.cvsignore b/Mailman/Commands/.cvsignore new file mode 100644 index 00000000..f3c7a7c5 --- /dev/null +++ b/Mailman/Commands/.cvsignore @@ -0,0 +1 @@ +Makefile diff --git a/Mailman/Commands/Makefile.in b/Mailman/Commands/Makefile.in new file mode 100644 index 00000000..bacd9629 --- /dev/null +++ b/Mailman/Commands/Makefile.in @@ -0,0 +1,69 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +# NOTE: Makefile.in is converted into Makefile by the configure script +# in the parent directory. Once configure has run, you can recreate +# the Makefile by running just config.status. + +# Variables set by configure + +VPATH= @srcdir@ +srcdir= @srcdir@ +bindir= @bindir@ +prefix= @prefix@ +exec_prefix= @exec_prefix@ + +CC= @CC@ +CHMOD= @CHMOD@ +INSTALL= @INSTALL@ + +DEFS= @DEFS@ + +# Customizable but not set by configure + +OPT= @OPT@ +CFLAGS= $(OPT) $(DEFS) +PACKAGEDIR= $(prefix)/Mailman/Commands +SHELL= /bin/sh + +MODULES= *.py + +# Modes for directories and executables created by the install +# process. Default to group-writable directories but +# user-only-writable for executables. +DIRMODE= 775 +EXEMODE= 755 +FILEMODE= 644 +INSTALL_PROGRAM=$(INSTALL) -m $(EXEMODE) + + +# Rules + +all: + +install: + for f in $(MODULES); \ + do \ + $(INSTALL) -m $(FILEMODE) $(srcdir)/$$f $(PACKAGEDIR); \ + done + +finish: + +clean: + +distclean: + -rm *.pyc + -rm Makefile diff --git a/Mailman/Commands/__init__.py b/Mailman/Commands/__init__.py new file mode 100644 index 00000000..ac6d2391 --- /dev/null +++ b/Mailman/Commands/__init__.py @@ -0,0 +1,15 @@ +# Copyright (C) 2001 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. diff --git a/Mailman/Commands/cmd_confirm.py b/Mailman/Commands/cmd_confirm.py new file mode 100644 index 00000000..5e4fc701 --- /dev/null +++ b/Mailman/Commands/cmd_confirm.py @@ -0,0 +1,84 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +""" + confirm + Confirm an action. The confirmation-string is required and should be + supplied with in mailback confirmation notice. +""" + +from Mailman import mm_cfg +from Mailman import Errors +from Mailman import Pending +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(mm_cfg.PENDING_REQUEST_LIFE / mm_cfg.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 current a member. Have you already unsubscribed or changed +your email address?""")) + 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/Mailman/Commands/cmd_echo.py b/Mailman/Commands/cmd_echo.py new file mode 100644 index 00000000..1f8b5979 --- /dev/null +++ b/Mailman/Commands/cmd_echo.py @@ -0,0 +1,26 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +""" + echo [args] + Simply echo an acknowledgement. Args are echoed back unchanged. +""" + +SPACE = ' ' + +def process(res, args): + res.results.append('echo %s' % SPACE.join(args)) + return 1 diff --git a/Mailman/Commands/cmd_end.py b/Mailman/Commands/cmd_end.py new file mode 100644 index 00000000..aeec7936 --- /dev/null +++ b/Mailman/Commands/cmd_end.py @@ -0,0 +1,33 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +""" + end + Stop processing commands. Use this if your mail program automatically + adds a signature file. +""" + +from Mailman.i18n import _ + + + +def gethelp(mlist): + return _(__doc__) + + + +def process(res, args): + return 1 # STOP diff --git a/Mailman/Commands/cmd_help.py b/Mailman/Commands/cmd_help.py new file mode 100644 index 00000000..e2d865e8 --- /dev/null +++ b/Mailman/Commands/cmd_help.py @@ -0,0 +1,92 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +""" + help + Print this help message. +""" + +import sys +import os + +from Mailman import mm_cfg +from Mailman import Utils +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' : mm_cfg.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/Mailman/Commands/cmd_info.py b/Mailman/Commands/cmd_info.py new file mode 100644 index 00000000..1c28da70 --- /dev/null +++ b/Mailman/Commands/cmd_info.py @@ -0,0 +1,49 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +""" + 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.GetListEmail() + requestaddr = mlist.GetRequestEmail() + owneraddr = mlist.GetOwnerEmail() + listurl = mlist.GetScriptURL('listinfo', absolute=1) + 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/Mailman/Commands/cmd_join.py b/Mailman/Commands/cmd_join.py new file mode 100644 index 00000000..4daccc1a --- /dev/null +++ b/Mailman/Commands/cmd_join.py @@ -0,0 +1,20 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""The `join' command is synonymous with `subscribe'. +""" + +from Mailman.Commands.cmd_subscribe import process diff --git a/Mailman/Commands/cmd_leave.py b/Mailman/Commands/cmd_leave.py new file mode 100644 index 00000000..ed5ccc7b --- /dev/null +++ b/Mailman/Commands/cmd_leave.py @@ -0,0 +1,20 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""The `leave' command is synonymous with `unsubscribe'. +""" + +from Mailman.Commands.cmd_unsubscribe import process diff --git a/Mailman/Commands/cmd_lists.py b/Mailman/Commands/cmd_lists.py new file mode 100644 index 00000000..81b60e60 --- /dev/null +++ b/Mailman/Commands/cmd_lists.py @@ -0,0 +1,69 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +""" + lists + See a list of the public mailing lists on this GNU Mailman server. +""" + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman.MailList import MailList +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:')) + lists = Utils.list_names() + lists.sort() + i = 1 + for listname in lists: + 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. BAW: should a + # message to the site list include everything regardless of domain? + if mm_cfg.VIRTUAL_HOST_OVERVIEW and \ + 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/Mailman/Commands/cmd_password.py b/Mailman/Commands/cmd_password.py new file mode 100644 index 00000000..c2347be9 --- /dev/null +++ b/Mailman/Commands/cmd_password.py @@ -0,0 +1,118 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +""" + 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 import mm_cfg +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')) + 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')) + 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((mm_cfg.AuthUser, mm_cfg.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((mm_cfg.AuthUser, mm_cfg.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/Mailman/Commands/cmd_remove.py b/Mailman/Commands/cmd_remove.py new file mode 100644 index 00000000..55be1f3e --- /dev/null +++ b/Mailman/Commands/cmd_remove.py @@ -0,0 +1,20 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""The `remove' command is synonymous with `unsubscribe'. +""" + +from Mailman.Commands.cmd_unsubscribe import process diff --git a/Mailman/Commands/cmd_set.py b/Mailman/Commands/cmd_set.py new file mode 100644 index 00000000..c3eaa9a6 --- /dev/null +++ b/Mailman/Commands/cmd_set.py @@ -0,0 +1,353 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +from email.Utils import parseaddr, formatdate + +from Mailman import mm_cfg +from Mailman import Errors +from Mailman import MemberAdaptor +from Mailman import i18n + +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, mm_cfg.AcknowledgePosts) + onoff = opt and _('on') or _('off') + res.results.append(_(' ack %(onoff)s')) + # Digests are a special ternary value + digestsp = mlist.getMemberOption(address, mm_cfg.Digests) + if digestsp: + plainp = mlist.getMemberOption(address, mm_cfg.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, mm_cfg.DontReceiveOwnPosts) + # sense is reversed + onoff = (not opt) and _('on') or _('off') + res.results.append(_(' myposts %(onoff)s')) + opt = mlist.getMemberOption(address, mm_cfg.ConcealSubscription) + onoff = opt and _('on') or _('off') + res.results.append(_(' hide %(onoff)s')) + opt = mlist.getMemberOption(address, mm_cfg.DontReceiveDuplicates) + # sense is reversed + onoff = (not opt) and _('on') or _('off') + res.results.append(_(' duplicates %(onoff)s')) + opt = mlist.getMemberOption(address, mm_cfg.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((mm_cfg.AuthUser, + mm_cfg.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, mm_cfg.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, mm_cfg.Digests, 0) + except Errors.AlreadyReceivingRegularDeliveries: + pass + elif arg == 'plain': + try: + mlist.setMemberOption(self.__address, mm_cfg.Digests, 1) + except Errors.AlreadyReceivingDigests: + pass + mlist.setMemberOption(self.__address, mm_cfg.DisableMime, 1) + elif arg == 'mime': + try: + mlist.setMemberOption(self.__address, mm_cfg.Digests, 1) + except Errors.AlreadyReceivingDigests: + pass + mlist.setMemberOption(self.__address, mm_cfg.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 + # sense is reversed + mlist.setMemberOption(self.__address, mm_cfg.DisableDelivery, + not status) + res.results.append(_('delivery option set')) + + 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, mm_cfg.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, mm_cfg.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, mm_cfg.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, mm_cfg.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/Mailman/Commands/cmd_stop.py b/Mailman/Commands/cmd_stop.py new file mode 100644 index 00000000..defcf64d --- /dev/null +++ b/Mailman/Commands/cmd_stop.py @@ -0,0 +1,20 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""stop is synonymous with the end command. +""" + +from Mailman.Commands.cmd_end import process diff --git a/Mailman/Commands/cmd_subscribe.py b/Mailman/Commands/cmd_subscribe.py new file mode 100644 index 00000000..1a5048d6 --- /dev/null +++ b/Mailman/Commands/cmd_subscribe.py @@ -0,0 +1,136 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +""" + subscribe [password] [digest|nodigest] [address=
      ] + Subscribe to this mailing list. Your password must be given to + unsubscribe or change your options, but if you omit the password, one + will be generated for you. You may be periodically reminded of your + password. + + The next argument may be either: `nodigest' or `digest' (no quotes!). + If you wish to subscribe 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 email.Header import decode_header, make_header + +from Mailman import Utils +from Mailman import Errors +from Mailman.UserDesc import UserDesc +from Mailman.i18n import _ + +STOP = 1 + + + +def gethelp(mlist): + return _(__doc__) + + + +def process(res, args): + mlist = res.mlist + digest = None + password = None + address = None + realname = None + # Parse the args + argnum = 0 + for arg in args: + if arg.startswith('address='): + address = arg[8:] + elif argnum == 0: + password = arg + elif argnum == 1: + if arg.lower() not in ('digest', 'nodigest'): + res.results.append(_('Bad digest specifier: %(arg)s')) + return STOP + if arg.lower() == 'digest': + digest = 1 + else: + digest = 0 + else: + res.results.append(_('Usage:')) + res.results.append(gethelp(mlist)) + return STOP + argnum += 1 + # Fill in empty defaults + if digest is None: + digest = mlist.digest_is_default + if password is None: + password = Utils.MakeRandomPassword() + if address is None: + realname, address = parseaddr(res.msg['from']) + if not address: + # Fall back to the sender address + address = res.msg.get_sender() + if not address: + res.results.append(_('No valid address found to subscribe')) + return STOP + # Watch for encoded names + h = make_header(decode_header(realname)) + # BAW: in Python 2.2, use just unicode(h) + realname = h.__unicode__() + # Coerce to byte string if uh contains only ascii + try: + realname = realname.encode('us-ascii') + except UnicodeError: + pass + # Create the UserDesc record and do a non-approved subscription + listowner = mlist.GetOwnerEmail() + userdesc = UserDesc(address, realname, password, digest) + remote = res.msg.get_sender() + try: + mlist.AddMember(userdesc, remote) + except Errors.MembershipIsBanned: + res.results.append(_("""\ +The email address you supplied is banned from this mailing list. +If you think this restriction is erroneous, please contact the list +owners at %(listowner)s.""")) + return STOP + except Errors.MMBadEmailError: + res.results.append(_("""\ +Mailman won't accept the given email address as a valid address. +(E.g. it must have an @ in it.)""")) + return STOP + except Errors.MMHostileAddress: + res.results.append(_("""\ +Your subscription is not allowed because +the email address you gave is insecure.""")) + return STOP + except Errors.MMAlreadyAMember: + res.results.append(_('You are already subscribed!')) + return STOP + except Errors.MMCantDigestError: + res.results.append( + _('No one can subscribe to the digest of this list!')) + return STOP + except Errors.MMMustDigestError: + res.results.append(_('This list only supports digest subscriptions!')) + return STOP + except Errors.MMSubscribeNeedsConfirmation: + # We don't need to respond /and/ send a confirmation message. + res.respond = 0 + except Errors.MMNeedApproval: + res.results.append(_("""\ +Your subscription request has been forwarded to the list administrator +at %(listowner)s for review.""")) + else: + # Everything is a-ok + res.results.append(_('Subscription request succeeded.')) diff --git a/Mailman/Commands/cmd_unsubscribe.py b/Mailman/Commands/cmd_unsubscribe.py new file mode 100644 index 00000000..c574a80f --- /dev/null +++ b/Mailman/Commands/cmd_unsubscribe.py @@ -0,0 +1,87 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +""" + 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/Mailman/Commands/cmd_who.py b/Mailman/Commands/cmd_who.py new file mode 100644 index 00000000..62505b3d --- /dev/null +++ b/Mailman/Commands/cmd_who.py @@ -0,0 +1,133 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +# Remove this when base minimal compatibility is Python 2.2 +from __future__ import nested_scopes + +from email.Utils import parseaddr + +from Mailman import mm_cfg +from Mailman import i18n + +STOP = 1 + +def _(s): return s + +PUBLICHELP = _(""" + who + See everyone who is on this mailing list. +""") + +MEMBERSONLYHELP = _(""" + who password [address=
      ] + See everyone who is on 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!) +""") + +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 = 0 + if mlist.private_roster == 0: + # Public rosters + if args: + usage(res) + return STOP + ok = 1 + 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( + (mm_cfg.AuthUser, + mm_cfg.AuthListModerator, + mm_cfg.AuthListAdmin), + password, address): + # Then + ok = 1 + else: + # Admin only + if len(args) <> 1: + usage(res) + return STOP + if mlist.Authenticate((mm_cfg.AuthListModerator, + mm_cfg.AuthListAdmin), + args[0]): + ok = 1 + 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 mlist.getMemberOption(member, mm_cfg.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/Mailman/Defaults.py.in b/Mailman/Defaults.py.in new file mode 100644 index 00000000..a9d11f63 --- /dev/null +++ b/Mailman/Defaults.py.in @@ -0,0 +1,1224 @@ +# -*- 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Distributed default settings for significant Mailman config variables. +""" + +# NEVER make site configuration changes to this file. ALWAYS make them in +# mm_cfg.py instead, in the designated area. See the comments in that file +# for details. + + +import os + +def seconds(s): return s +def minutes(m): return m * 60 +def hours(h): return h * 60 * 60 +def days(d): return d * 60 * 60 * 24 + + + +##### +# 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/' + +# Mailman needs to know about (at least) two fully-qualified domain names +# (fqdn); 1) the hostname used in your urls, and 2) the hostname used in email +# addresses for your domain. For example, if people visit your Mailman system +# with "http://www.dom.ain/mailman" then your url fqdn is "www.dom.ain", and +# if people send mail to your system via "yourlist@dom.ain" then your email +# fqdn is "dom.ain". DEFAULT_URL_HOST controls the former, and +# DEFAULT_EMAIL_HOST controls the latter. Mailman also needs to know how to +# map from one to the other (this is especially important if you're running +# with virtual domains). You use "add_virtualhost(urlfqdn, emailfqdn)" to add +# new mappings. +# +# If you don't need to change DEFAULT_EMAIL_HOST and DEFAULT_URL_HOST in your +# mm_cfg.py, then you're done; the default mapping is added automatically. If +# however you change either variable in your mm_cfg.py, then be sure to also +# include the following: +# +# add_virtualhost(DEFAULT_URL_HOST, DEFAULT_EMAIL_HOST) +# +# because otherwise the default mappings won't be correct. +DEFAULT_EMAIL_HOST = '@MAILHOST@' +DEFAULT_URL_HOST = '@URLHOST@' +DEFAULT_URL_PATTERN = 'http://%s/mailman/' + +# DEFAULT_HOST_NAME has been replaced with DEFAULT_EMAIL_HOST, however some +# sites may have the former in their mm_cfg.py files. If so, we'll believe +# that, otherwise we'll believe DEFAULT_EMAIL_HOST. Same for DEFAULT_URL. +DEFAULT_HOST_NAME = None +DEFAULT_URL = None + +HOME_PAGE = 'index.html' +MAILMAN_SITE_LIST = 'mailman' + +# 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 1 to allow site admin cookies. +ALLOW_SITE_ADMIN_COOKIES = 0 + +# 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' + + + +##### +# Virtual domains +##### + +# Set up your virtual host mappings here. This is primarily used for the +# thru-the-web list creation, so its effects are currently fairly limited. +# Use add_virtualhost() call to add new mappings. The keys are strings as +# determined by Utils.get_domain(), the values are as appropriate for +# DEFAULT_HOST_NAME. +VIRTUAL_HOSTS = {} + +# When set, the listinfo and admin overviews of lists on the machine will be +# confined to only those lists whose web_page_url configuration option host is +# included within the URL by which the page is visited - only those "on the +# virtual host". If unset, then all advertised (i.e. public) lists are +# included in the overview. +VIRTUAL_HOST_OVERVIEW = 1 + + +# Helper function; use this in your mm_cfg.py files. If optional emailhost is +# omitted it defaults to urlhost with the first name stripped off, e.g. +# +# add_virtualhost('www.dom.ain') +# VIRTUAL_HOST['www.dom.ain'] +# ==> 'dom.ain' +# +def add_virtualhost(urlhost, emailhost=None): + DOT = '.' + if emailhost is None: + emailhost = DOT.join(urlhost.split(DOT)[1:]) + VIRTUAL_HOSTS[urlhost.lower()] = emailhost.lower() + +# And set the default +add_virtualhost(DEFAULT_URL_HOST, DEFAULT_EMAIL_HOST) + + + +##### +# Spam avoidance defaults +##### + +# This variable contains a list of 2-tuple of the format (header, regex) which +# the Mailman/Handlers/SpamDetect.py module uses to match against the current +# message. If the regex matches the given header in the current message, then +# it is flagged as spam. header is case-insensitive and should not include +# the trailing colon. regex is always matched with re.IGNORECASE. +# +# Note that the more searching done, the slower the whole process gets. Spam +# detection is run against all messages coming to either the list, or the +# -owners address, unless the message is explicitly approved. +KNOWN_SPAMMERS = [] + + + +##### +# 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 + + +##### +# 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 should be a string with "%(listname)s" somewhere in it. Mailman will +# interpolate the name of the list into this. You can also include a +# "%(hostname)s" in the string, into which Mailman will interpolate +# the host name (usually DEFAULT_URL_HOST). +PUBLIC_ARCHIVE_URL = 'http://%(hostname)s/pipermail/%(listname)s' + +# Are archives on or off by default? +DEFAULT_ARCHIVE = 1 # 0=Off, 1=On + +# Are archives public or private by default? +DEFAULT_ARCHIVE_PRIVATE = 0 # 0=public, 1=private + +# ARCHIVE_TO_MBOX +#-1 - do not do any archiving +# 0 - do not archive to mbox, use builtin mailman html archiving only +# 1 - archive to mbox to use an external archiving mechanism only +# 2 - archive to both mbox and builtin mailman html archiving - +# use this to make both external archiving mechanism work and +# mailman's builtin html archiving. the flat mail file can be +# useful for searching, 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 false. When either is set, the value +# should be a shell command string which will get passed to os.popen(). This +# string can contain %(listname)s for dictionary interpolation. The name of +# the list being archived will be substituted for this. +# +# 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 = 0 +PRIVATE_EXTERNAL_ARCHIVER = 0 + +# 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.Handlers.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)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 1 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 = 0 + +# 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 true to +# moderately obscure email addresses, but note that this breaks mailto: URLs +# in the archives too. +ARCHIVER_OBSCURES_EMAILADDRS = 1 + +# 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'] + + + +##### +# 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 a module in the Mailman.Handlers package. +# +# WARNING: Sendmail has security holes and should be avoided. In fact, you +# must read the Mailman/Handlers/Sendmail.py file before it will work for +# you. +# +#DELIVERY_MODULE = 'Sendmail' +DELIVERY_MODULE = 'SMTPDirect' + +# 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 flag to false 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 README.POSTFIX for details. +POSTFIX_STYLE_VIRTUAL_DOMAINS = [] + +# 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 = '' + +# 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'), + ] + +# All `normal' messages which are delivered to the entire list membership go +# through this pipeline of handler modules. Lists themselves can override the +# global pipeline by defining a `pipeline' attribute. +GLOBAL_PIPELINE = [ + # These are the modules that do tasks common to all delivery paths. + 'SpamDetect', + 'Approve', + 'Replybot', + 'Moderate', + 'Hold', + 'MimeDel', + 'Emergency', + 'Tagger', + 'CalcRecips', + 'AvoidDuplicates', + 'Cleanse', + 'CookHeaders', + # And now we send the message to the digest mbox file, and to the arch and + # news queues. Runners will provide further processing of the message, + # specific to those delivery paths. + 'ToDigest', + 'ToArchive', + 'ToUsenet', + # Now we'll do a few extra things specific to the member delivery + # (outgoing) path, finally leaving the message in the outgoing queue. + 'AfterDelivery', + 'Acknowledge', + 'ToOutgoing', + ] + +# This is the pipeline which messages sent to the -owner address go through +OWNER_PIPELINE = [ + 'SpamDetect', + 'Replybot', + 'OwnerRecips', + 'ToOutgoing', + ] + + +# This defines syslog() 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 +# file in logs/ 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', + '%(msg_message-id)s smtp for %(#recips)d recips, completed in %(time).3f seconds') + +# This will only be printed if there were no immediate smtp failures. +# Mutually exclusive with SMTP_LOG_REFUSED. +SMTP_LOG_SUCCESS = ( + 'post', + 'post to %(listname)s from %(sender)s, size=%(size)d, 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', + 'post to %(listname)s from %(sender)s, size=%(size)d, %(#refused)d 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', + 'delivery to %(recipient)s failed with code %(failcode)d: %(failmsg)s') + +# 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_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[^@]+)@.*$' + +# A perfect opportunity for doing VERP is the password reminders, which are +# already addressed individually to each recipient. This flag, if true, +# enables VERPs on all password reminders. +VERP_PASSWORD_REMINDERS = 0 + +# 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 true to enable VERPs on all personalized +# regular deliveries (personalized digests aren't supported yet). +VERP_PERSONALIZED_DELIVERIES = 0 + +# 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: +# +# %(confirm)s -- the list-confirm mailbox will be set here +# %(cookie)s -- the confirmation cookie will be set here +VERP_CONFIRM_FORMAT = '%(addr)s+%(cookie)s' + +# This is analogous to VERP_REGEXP, but for splitting apart the +# VERP_CONFIRM_FORMAT. +VERP_CONFIRM_REGEXP = r'^(?P[^+]+?)\+(?P[^@]+)@.*$' + +# Set this to true to enable VERP-like (more user friendly) confirmations +VERP_CONFIRMATIONS = 0 + +# 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? This is a list of +# 2-tuples containing the name of the qrunner class (which must live in a +# module of the same name within the Mailman.Queue package), and 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. + +# BAW: Eventually we may support weighted hash spaces. +# BAW: Although not enforced, the # of slices must be a power of 2 + +QRUNNERS = [ + ('ArchRunner', 1), # messages for the archiver + ('BounceRunner', 1), # for processing the qfile/bounces directory + ('CommandRunner', 1), # commands and bounces from the outside world + ('IncomingRunner', 1), # posts from the outside world + ('NewsRunner', 1), # outgoing messages to the nntpd + ('OutgoingRunner', 1), # outgoing messages to the smtpd + ('VirginRunner', 1), # internally crafted (virgin birth) messages + ] + +# Set this to true 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. Also, the line after USE_MAILDIR to your mm_cfg.py file. +# +# WARNING: If you want to use Maildir delivery, you /must/ start Mailman's +# qrunner as root, or you will get permission problems. +# +# NOTE: Maildir delivery is experimental for Mailman 2.1. +USE_MAILDIR = 0 +# QRUNNERS.append(('MaildirRunner', 1)) + +# 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 0 to discard such messages, or to 1 to store +# them in qfiles/bad subdirectory. +QRUNNER_SAVE_BAD_MESSAGES = 1 + + + +##### +# 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. See +# LC_DESCRIPTIONS below for legal values. +DEFAULT_SERVER_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 1, 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 0 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 0, but understand that some +# spoofed messages may get through. +USE_ENVELOPE_SENDER = 0 + +# 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 1 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 0 instead requires list removal to be done by the site +# administrator, via the command line script bin/rmlist. +OWNERS_CAN_DELETE_THEIR_OWN_LISTS = 0 + +# Set this variable to 1 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 = 0 + +# 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 = 1 + +# These define the available types of external message metadata formats, and +# the one to use by default. MARSHAL format uses Python's built-in marshal +# module. BSDDB_NATIVE uses the bsddb module compiled into Python, which +# links with whatever version of Berkeley db you've got on your system (in +# Python 2.0 this is included by default if configure can find it). ASCII +# format is a dumb repr()-based format with "key = value" Python assignments. +# It is human readable and editable (as Python source code) and is appropriate +# for execfile() food. +# +# Note! Make sure your queues are empty before you change this. +METAFMT_MARSHAL = 1 +METAFMT_BSDDB_NATIVE = 2 +METAFMT_ASCII = 3 + +METADATA_FORMAT = METAFMT_MARSHAL + +# 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 + + + +##### +# List defaults +##### + +# 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 = 1 +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 = "[%(real_name)s] " +DEFAULT_MSG_HEADER = "" +DEFAULT_MSG_FOOTER = """_______________________________________________ +%(real_name)s mailing list +%(real_name)s@%(host_name)s +%(web_page_url)slistinfo%(cgiext)s/%(_internal_name)s +""" + +# Mail command processor will ignore mail command lines after designated max. +DEFAULT_MAIL_COMMANDS_MAX_LINES = 25 + +# Is the list owner notified of admin requests immediately by mail, as well as +# by daily pending-request reminder? +DEFAULT_ADMIN_IMMED_NOTIFY = 1 + +# Is the list owner notified of subscribes/unsubscribes? +DEFAULT_ADMIN_NOTIFY_MCHANGES = 0 + +# Should list members, by default, have their posts be moderated? +DEFAULT_DEFAULT_MEMBER_MODERATION = 0 + +# Should non-member posts which are auto-discarded also be forwarded to the +# moderators? +DEFAULT_FORWARD_AUTO_DISCARDS = 1 + +# 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 = 1 + +# 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 = 0 + +# 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 = 1 + +# Send welcome messages to new users? Probably should keep this set to 1. +DEFAULT_SEND_WELCOME_MSG = 1 + +# Send goodbye messages to unsubscribed members? Probably should keep this +# set to 1. +DEFAULT_SEND_GOODBYE_MSG = 1 + +# Wipe sender information, and make it look like the list-admin +# address sends all messages +DEFAULT_ANONYMOUS_LIST = 0 + +# {header-name: regexp} spam filtering - we include some for example sake. +DEFAULT_BOUNCE_MATCHING_HEADERS = """ +# 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 = 0 + +# 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. This is +# a boolean variable. +DEFAULT_FIRST_STRIP_REPLY_TO = 0 + +# 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 = 0 + +# 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 = 1 + +# 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 1, list owners will +# be given the option to suppress these headers. By setting it to 0, 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 = 1 + +# 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 = 0 + +# 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'] + +# 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 = 1 + +# 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 = 1 + +# Check for administrivia in messages sent to the main list? +DEFAULT_ADMINISTRIVIA = 1 + + + +##### +# Digestification defaults +##### + +# Will list be available in non-digested form? +DEFAULT_NONDIGESTABLE = 1 + +# Will list be available in digested form? +DEFAULT_DIGESTABLE = 1 +DEFAULT_DIGEST_HEADER = "" +DEFAULT_DIGEST_FOOTER = DEFAULT_MSG_FOOTER + +DEFAULT_DIGEST_IS_DEFAULT = 0 +DEFAULT_MIME_IS_DEFAULT_DIGEST = 0 +DEFAULT_DIGEST_SIZE_THRESHHOLD = 30 # KB +DEFAULT_DIGEST_SEND_PERIODIC = 1 +DEFAULT_PLAIN_DIGEST_KEEP_HEADERS = ['message', 'date', 'from', + 'subject', 'to', 'cc', + 'reply-to', 'organization'] + + + +##### +# Bounce processing defaults +##### + +# Should we do any bounced mail response at all? +DEFAULT_BOUNCE_PROCESSING = 1 + +# 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 = 1 + +# 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 = 1 +DEFAULT_BOUNCE_NOTIFY_OWNER_ON_REMOVAL = 1 + + + +##### +# General time limits +##### + +# How long should subscriptions requests await confirmation before being +# dropped? +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) + + + +##### +# 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. + +# Set this to true to turn on MailList object lock debugging messages, 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. +LIST_LOCK_DEBUGGING = 0 + +# 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) + + + +##### +# Nothing below here is user configurable. Most of these values are in this +# file for convenience. Don't change any of them or override any of them in +# your mm_cfg.py file! +##### + +# These directories are used to find various important files in the Mailman +# installation. PREFIX and EXEC_PREFIX are set by configure and should point +# to the installation directory of the Mailman package. +PYTHON = '@PYTHON@' +PREFIX = '@prefix@' +EXEC_PREFIX = '@exec_prefix@' +VAR_PREFIX = '@VAR_PREFIX@' + +# Work around a bogus autoconf 2.12 bug +if EXEC_PREFIX == '${prefix}': + EXEC_PREFIX = PREFIX + +# CGI extension, change using configure script +CGIEXT = '@CGIEXT@' + +# Group id that group-owns the Mailman installation +MAILMAN_USER = '@MAILMAN_USER@' +MAILMAN_GROUP = '@MAILMAN_GROUP@' + +# 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 + +# Held message disposition actions, for use between admindb.py and +# ListAdmin.py. +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) + +# Useful directories +LIST_DATA_DIR = os.path.join(VAR_PREFIX, 'lists') +LOG_DIR = os.path.join(VAR_PREFIX, 'logs') +LOCK_DIR = os.path.join(VAR_PREFIX, 'locks') +DATA_DIR = os.path.join(VAR_PREFIX, 'data') +SPAM_DIR = os.path.join(VAR_PREFIX, 'spam') +WRAPPER_DIR = os.path.join(EXEC_PREFIX, 'mail') +BIN_DIR = os.path.join(PREFIX, 'bin') +SCRIPTS_DIR = os.path.join(PREFIX, 'scripts') +TEMPLATE_DIR = os.path.join(PREFIX, 'templates') +MESSAGES_DIR = os.path.join(PREFIX, 'messages') +PUBLIC_ARCHIVE_FILE_DIR = os.path.join(VAR_PREFIX, 'archives', 'public') +PRIVATE_ARCHIVE_FILE_DIR = os.path.join(VAR_PREFIX, 'archives', 'private') + +# Directories used by the qrunner subsystem +QUEUE_DIR = os.path.join(VAR_PREFIX, 'qfiles') +INQUEUE_DIR = os.path.join(QUEUE_DIR, 'in') +OUTQUEUE_DIR = os.path.join(QUEUE_DIR, 'out') +CMDQUEUE_DIR = os.path.join(QUEUE_DIR, 'commands') +BOUNCEQUEUE_DIR = os.path.join(QUEUE_DIR, 'bounces') +NEWSQUEUE_DIR = os.path.join(QUEUE_DIR, 'news') +ARCHQUEUE_DIR = os.path.join(QUEUE_DIR, 'archive') +SHUNTQUEUE_DIR = os.path.join(QUEUE_DIR, 'shunt') +VIRGINQUEUE_DIR = os.path.join(QUEUE_DIR, 'virgin') +BADQUEUE_DIR = os.path.join(QUEUE_DIR, 'bad') +MAILDIR_DIR = os.path.join(QUEUE_DIR, 'maildir') + +# Other useful files +PIDFILE = os.path.join(DATA_DIR, 'master-qrunner.pid') +SITE_PW_FILE = os.path.join(DATA_DIR, 'adm.pw') +LISTCREATOR_PW_FILE = os.path.join(DATA_DIR, 'creator.pw') + +# Import a bunch of version numbers +from Version import * + +# 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. :-) +def _(s): + return s + +LC_DESCRIPTIONS = {} + +def add_language(code, description, charset): + LC_DESCRIPTIONS[code] = (description, charset) + +add_language('big5', _('Traditional Chinese'), 'big5') +add_language('cs', _('Czech'), 'iso-8859-2') +add_language('de', _('German'), 'iso-8859-1') +add_language('en', _('English (USA)'), 'us-ascii') +add_language('es', _('Spanish (Spain)'), 'iso-8859-1') +add_language('et', _('Estonian'), 'iso-8859-15') +add_language('fi', _('Finnish'), 'iso-8859-1') +add_language('fr', _('French'), 'iso-8859-1') +add_language('gb', _('Simplified Chinese'), 'gb2312') +add_language('hu', _('Hungarian'), 'iso-8859-2') +add_language('it', _('Italian'), 'iso-8859-1') +add_language('ja', _('Japanese'), 'euc-jp') +add_language('ko', _('Korean'), 'euc-kr') +add_language('lt', _('Lithuanian'), 'iso-8859-13') +add_language('nl', _('Dutch'), 'iso-8859-1') +add_language('no', _('Norwegian'), 'iso-8859-1') +add_language('pt_BR', _('Portuguese (Brazil)'), 'iso-8859-1') +add_language('ru', _('Russian'), 'koi8-r') +add_language('sv', _('Swedish'), 'iso-8859-1') + +del _ diff --git a/Mailman/Deliverer.py b/Mailman/Deliverer.py new file mode 100644 index 00000000..983a67d5 --- /dev/null +++ b/Mailman/Deliverer.py @@ -0,0 +1,136 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + + +"""Mixin class with message delivery routines.""" + +from email.MIMEText import MIMEText +from email.MIMEMessage import MIMEMessage + +from Mailman import mm_cfg +from Mailman import Errors +from Mailman import Utils +from Mailman import Message +from Mailman.i18n import _ +from Mailman.Logging.Syslog import syslog + + + +class Deliverer: + def SendSubscribeAck(self, name, password, digest, text=''): + pluser = self.getMemberLanguage(name) + if not self.send_welcome_msg: + return + if self.welcome_msg: + welcome = Utils.wrap(self.welcome_msg) + '\n' + else: + welcome = '' + if self.umbrella_list: + addr = self.GetMemberAdminEmail(name) + umbrella = Utils.wrap(_('''\ +Note: Since this is a list of mailing lists, administrative +notices like the password reminder will be sent to +your membership administrative address, %(addr)s.''')) + else: + umbrella = '' + # get the text from the template + text += Utils.maketext( + 'subscribeack.txt', + {'real_name' : self.real_name, + 'host_name' : self.host_name, + 'welcome' : welcome, + 'umbrella' : umbrella, + 'emailaddr' : self.GetListEmail(), + 'listinfo_url': self.GetScriptURL('listinfo', absolute=1), + 'optionsurl' : self.GetOptionsURL(name, absolute=1), + 'password' : password, + }, lang=pluser, mlist=self) + if digest: + digmode = _(' (Digest mode)') + else: + digmode = '' + realname = self.real_name + msg = Message.UserNotification( + self.GetMemberAdminEmail(name), self.GetRequestEmail(), + _('Welcome to the "%(realname)s" mailing list%(digmode)s'), + text, pluser) + msg['X-No-Archive'] = 'yes' + msg.send(self, verp=mm_cfg.VERP_PERSONALIZED_DELIVERIES) + + def SendUnsubscribeAck(self, addr, lang): + realname = self.real_name + msg = Message.UserNotification( + self.GetMemberAdminEmail(addr), self.GetBouncesEmail(), + _('You have been unsubscribed from the %(realname)s mailing list'), + Utils.wrap(self.goodbye_msg), lang) + msg.send(self, verp=mm_cfg.VERP_PERSONALIZED_DELIVERIES) + + def MailUserPassword(self, user): + listfullname = '%s@%s' % (self.real_name, self.host_name) + 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. + syslog('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 the text from the template + text = Utils.maketext( + 'userpass.txt', + {'user' : cpuser, + 'listname' : self.real_name, + 'fqdn_lname' : self.GetListEmail(), + 'password' : self.getMemberPassword(user), + 'options_url': self.GetOptionsURL(user, absolute=1), + 'requestaddr': requestaddr, + 'owneraddr' : self.GetOwnerEmail(), + }, lang=self.getMemberLanguage(user), mlist=self) + msg = Message.UserNotification(recipient, adminaddr, subject, text, + self.getMemberLanguage(user)) + msg['X-No-Archive'] = 'yes' + msg.send(self, verp=mm_cfg.VERP_PERSONALIZED_DELIVERIES) + + def ForwardMessage(self, msg, text=None, subject=None, tomoderators=1): + # 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) diff --git a/Mailman/Digester.py b/Mailman/Digester.py new file mode 100644 index 00000000..94b3dfd5 --- /dev/null +++ b/Mailman/Digester.py @@ -0,0 +1,73 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + + +"""Mixin class with list-digest handling methods and settings.""" + +import os +from stat import ST_SIZE +import errno + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import Errors +from Mailman.Handlers import ToDigest +from Mailman.i18n import _ + + + +class Digester: + def InitVars(self): + # Configurable + self.digestable = mm_cfg.DEFAULT_DIGESTABLE + self.digest_is_default = mm_cfg.DEFAULT_DIGEST_IS_DEFAULT + self.mime_is_default_digest = mm_cfg.DEFAULT_MIME_IS_DEFAULT_DIGEST + self.digest_size_threshhold = mm_cfg.DEFAULT_DIGEST_SIZE_THRESHHOLD + self.digest_send_periodic = mm_cfg.DEFAULT_DIGEST_SEND_PERIODIC + self.next_post_number = 1 + self.digest_header = mm_cfg.DEFAULT_DIGEST_HEADER + self.digest_footer = mm_cfg.DEFAULT_DIGEST_FOOTER + self.digest_volume_frequency = mm_cfg.DEFAULT_DIGEST_VOLUME_FREQUENCY + # Non-configurable. + self.one_last_digest = {} + self.digest_members = {} + self.next_digest_number = 1 + self.digest_last_sent_at = 0 + + 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 0 + return 1 + + def bump_digest_volume(self): + self.volume += 1 + self.next_digest_number = 1 diff --git a/Mailman/Errors.py b/Mailman/Errors.py new file mode 100644 index 00000000..ce1868cc --- /dev/null +++ b/Mailman/Errors.py @@ -0,0 +1,147 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + + +"""Shared mailman errors and messages.""" + + +# exceptions for problems related to opening a list +class MMListError(Exception): pass +class MMUnknownListError(MMListError): pass +class MMCorruptListDatabaseError(MMListError): pass +class MMListNotReadyError(MMListError): pass +class MMListAlreadyExistsError(MMListError): pass +class BadListNameError(MMListError): pass + +# Membership exceptions +class MMMemberError(Exception): pass +class MMBadUserError(MMMemberError): pass +class MMAlreadyAMember(MMMemberError): pass + +# "New" style membership exceptions (new w/ MM2.1) +class MemberError(Exception): 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 + +# Exception hierarchy for various authentication failures, can be +# raised from functions in SecurityManager.py +class MMAuthenticationError(Exception): pass +class MMBadPasswordError(MMAuthenticationError): pass +class MMPasswordsMustMatch(MMAuthenticationError): pass +class MMCookieError(MMAuthenticationError): pass +class MMExpiredCookieError(MMCookieError): pass +class MMInvalidCookieError(MMCookieError): pass + +# BAW: these still need to be converted to classes. +MMMustDigestError = "MMMustDigestError" +MMCantDigestError = "MMCantDigestError" +MMNeedApproval = "MMNeedApproval" +MMSubscribeNeedsConfirmation = "MMSubscribeNeedsConfirmation" +MMBadConfirmation = "MMBadConfirmation" +MMAlreadyDigested = "MMAlreadyDigested" +MMAlreadyUndigested = "MMAlreadyUndigested" + +MODERATED_LIST_MSG = "Moderated list" +IMPLICIT_DEST_MSG = "Implicit destination" +SUSPICIOUS_HEADER_MSG = "Suspicious header" +FORBIDDEN_SENDER_MSG = "Forbidden sender" + + + +# New style class based exceptions. All the above errors should eventually be +# converted. + +class MailmanError(Exception): + """Base class for all Mailman exceptions.""" + pass + + +class MMLoopingPost(MailmanError): + """Post already went through this list!""" + pass + + +# Exception hierarchy for bad email address errors that can be raised from +# Utils.ValidateEmail() +class EmailAddressError(MailmanError): + """Base class for email address validation errors.""" + pass + +class MMBadEmailError(EmailAddressError): + """Email address is invalid (empty string or not fully qualified).""" + pass + +class MMHostileAddress(EmailAddressError): + """Email address has potentially hostile characters in it.""" + pass + + +# 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 + +# multiple inheritance for backwards compatibility +class LoopError(DiscardMessage, MMLoopingPost): + """We've seen this message before""" + +class RejectMessage(HandlerError): + """The message will be bounced back to the sender""" + def __init__(self, notice=None): + if notice is None: + notice = _('Your message was rejected') + self.__notice = notice + + def notice(self): + return self.__notice diff --git a/Mailman/GatewayManager.py b/Mailman/GatewayManager.py new file mode 100644 index 00000000..fa338cd4 --- /dev/null +++ b/Mailman/GatewayManager.py @@ -0,0 +1,38 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Mixin class for configuring Usenet gateway. + +All the actual functionality is in Handlers/ToUsenet.py for the mail->news +gateway and cron/gate_news for the news->mail gateway. + +""" + +from Mailman import mm_cfg +from Mailman.i18n import _ + + +class GatewayManager: + def InitVars(self): + # Configurable + self.nntp_host = mm_cfg.DEFAULT_NNTP_HOST + self.linked_newsgroup = '' + self.gateway_to_news = 0 + self.gateway_to_mail = 0 + self.news_prefix_subject_too = 1 + # In patch #401270, this was called newsgroup_is_moderated, but the + # semantics weren't quite the same. + self.news_moderation = 0 diff --git a/Mailman/Gui/.cvsignore b/Mailman/Gui/.cvsignore new file mode 100644 index 00000000..f3c7a7c5 --- /dev/null +++ b/Mailman/Gui/.cvsignore @@ -0,0 +1 @@ +Makefile diff --git a/Mailman/Gui/Archive.py b/Mailman/Gui/Archive.py new file mode 100644 index 00000000..59c2fd10 --- /dev/null +++ b/Mailman/Gui/Archive.py @@ -0,0 +1,44 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +from Mailman import mm_cfg +from Mailman.i18n import _ +from Mailman.Gui.GUIBase import GUIBase + + + +class Archive(GUIBase): + def GetConfigCategory(self): + return 'archive', _('Archiving Options') + + def GetConfigInfo(self, mlist, category, subcat=None): + if category <> 'archive': + return None + return [ + _("List traffic archival policies."), + + ('archive', mm_cfg.Toggle, (_('No'), _('Yes')), 0, + _('Archive messages?')), + + ('archive_private', mm_cfg.Radio, (_('public'), _('private')), 0, + _('Is archive file source for public or private archival?')), + + ('archive_volume_frequency', mm_cfg.Radio, + (_('Yearly'), _('Monthly'), _('Quarterly'), + _('Weekly'), _('Daily')), + 0, + _('How often should a new archive volume be started?')), + ] diff --git a/Mailman/Gui/Autoresponse.py b/Mailman/Gui/Autoresponse.py new file mode 100644 index 00000000..3c8a71e0 --- /dev/null +++ b/Mailman/Gui/Autoresponse.py @@ -0,0 +1,98 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Administrative GUI for the autoresponder.""" + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman.i18n import _ +from Mailman.Gui.GUIBase import GUIBase + +# These are the allowable string substitution variables +ALLOWEDS = ('listname', 'listurl', 'requestemail', 'adminemail', 'owneremail') + + + +class Autoresponse(GUIBase): + def GetConfigCategory(self): + return 'autoreply', _('Auto-responder') + + def GetConfigInfo(self, mlist, category, subcat=None): + if category <> 'autoreply': + return None + WIDTH = mm_cfg.TEXTFIELDWIDTH + + return [ + _("""\ +Auto-responder characteristics.

      + +In the text fields below, string interpolation is performed with +the following key/value substitutions: +

        +
      • listname - gets the name of the mailing list +
      • listurl - gets the list's listinfo URL +
      • requestemail - gets the list's -request address +
      • owneremail - gets the list's -owner address +
      + +

      For each text field, you can either enter the text directly into the text +box, or you can specify a file on your local system to upload as the text."""), + + ('autorespond_postings', mm_cfg.Toggle, (_('No'), _('Yes')), 0, + _('''Should Mailman send an auto-response to mailing list + posters?''')), + + ('autoresponse_postings_text', mm_cfg.FileUpload, + (6, WIDTH), 0, + _('Auto-response text to send to mailing list posters.')), + + ('autorespond_admin', mm_cfg.Toggle, (_('No'), _('Yes')), 0, + _('''Should Mailman send an auto-response to emails sent to the + -owner address?''')), + + ('autoresponse_admin_text', mm_cfg.FileUpload, + (6, WIDTH), 0, + _('Auto-response text to send to -owner emails.')), + + ('autorespond_requests', mm_cfg.Radio, + (_('No'), _('Yes, w/discard'), _('Yes, w/forward')), 0, + _('''Should Mailman send an auto-response to emails sent to the + -request address? If you choose yes, decide whether you want + Mailman to discard the original email, or forward it on to the + system as a normal mail command.''')), + + ('autoresponse_request_text', mm_cfg.FileUpload, + (6, WIDTH), 0, + _('Auto-response text to send to -request emails.')), + + ('autoresponse_graceperiod', mm_cfg.Number, 3, 0, + _('''Number of days between auto-responses to either the mailing + list or -request/-owner address from the same poster. Set to + zero (or negative) for no grace period (i.e. auto-respond to + every message).''')), + ] + + def _setValue(self, mlist, property, val, doc): + # Handle these specially because we may need to convert to/from + # external $-string representation. + if property in ('autoresponse_postings_text', + 'autoresponse_admin_text', + 'autoresponse_request_text'): + val = self._convertString(mlist, property, ALLOWEDS, val, doc) + if val is None: + # There was a problem, so don't set it + return + GUIBase._setValue(self, mlist, property, val, doc) diff --git a/Mailman/Gui/Bounce.py b/Mailman/Gui/Bounce.py new file mode 100644 index 00000000..4986cf28 --- /dev/null +++ b/Mailman/Gui/Bounce.py @@ -0,0 +1,183 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +from Mailman import mm_cfg +from Mailman.i18n import _ +from Mailman.mm_cfg import days +from Mailman.Gui.GUIBase import GUIBase + + + +class Bounce(GUIBase): + def GetConfigCategory(self): + return 'bounce', _('Bounce processing') + + def GetConfigInfo(self, mlist, category, subcat=None): + if category <> 'bounce': + return None + return [ + _("""These policies control the automatic bounce processing system + in Mailman. Here's an overview of how it works. + +

      When a bounce is received, Mailman tries to extract two pieces + of information from the message: the address of the member the + message was intended for, and the severity of the problem causing + the bounce. The severity can be either hard or + soft meaning either a fatal error occurred, or a + transient error occurred. When in doubt, a hard severity is used. + +

      If no member address can be extracted from the bounce, then the + bounce is usually discarded. Otherwise, each member is assigned a + bounce score and every time we encounter a bounce from + this member we increment the score. Hard bounces increment by 1 + while soft bounces increment by 0.5. We only increment the bounce + score once per day, so even if we receive ten hard bounces from a + member per day, their score will increase by only 1 for that day. + +

      When a member's bounce score is greater than the + bounce score + threshold, the subscription is disabled. Once disabled, the + member will not receive any postings from the list until their + membership is explicitly re-enabled (either by the list + administrator or the user). However, they will receive occasional + reminders that their membership has been disabled, and these + reminders will include information about how to re-enable their + membership. + +

      You can control both the + number + of reminders the member will receive and the + frequency with which these reminders are sent. + +

      There is one other important configuration variable; after a + certain period of time -- during which no bounces from the member + are received -- the bounce information is + considered + stale and discarded. Thus by adjusting this value, and the + score threshold, you can control how quickly bouncing members are + disabled. You should tune both of these to the frequency and + traffic volume of your list."""), + + _('Bounce detection sensitivity'), + + ('bounce_processing', mm_cfg.Toggle, (_('No'), _('Yes')), 0, + _('Should Mailman perform automatic bounce processing?'), + _("""By setting this value to No, you disable all + automatic bounce processing for this list, however bounce + messages will still be discarded so that the list administrator + isn't inundated with them.""")), + + ('bounce_score_threshold', mm_cfg.Number, 5, 0, + _("""The maximum member bounce score before the member's + subscription is disabled. This value can be a floating point + number.""")), + + ('bounce_info_stale_after', mm_cfg.Number, 5, 0, + _("""The number of days after which a member's bounce information + is discarded, if no new bounces have been received in the + interim. This value must be an integer.""")), + + ('bounce_you_are_disabled_warnings', mm_cfg.Number, 5, 0, + _("""How many Your Membership Is Disabled warnings a + disabled member should get before their address is removed from + the mailing list. Set to 0 to immediately remove an address from + the list once their bounce score exceeds the threshold. This + value must be an integer.""")), + + ('bounce_you_are_disabled_warnings_interval', mm_cfg.Number, 5, 0, + _("""The number of days between sending the Your Membership + Is Disabled warnings. This value must be an integer.""")), + + _('Notifications'), + + ('bounce_unrecognized_goes_to_list_owner', mm_cfg.Toggle, + (_('No'), _('Yes')), 0, + _('''Should Mailman send you, the list owner, any bounce messages + that failed to be detected by the bounce processor? Yes + is recommended.'''), + _("""While Mailman's bounce detector is fairly robust, it's + impossible to detect every bounce format in the world. You + should keep this variable set to Yes for two reasons: 1) + If this really is a permanent bounce from one of your members, + you should probably manually remove them from your list, and 2) + you might want to send the message on to the Mailman developers + so that this new format can be added to its known set. + +

      If you really can't be bothered, then set this variable to + No and all non-detected bounces will be discarded + without further processing. + +

      Note: This setting will also affect all messages sent + to your list's -admin address. This address is deprecated and + should never be used, but some people may still send mail to this + address. If this happens, and this variable is set to + No those messages too will get discarded. You may want + to set up an + autoresponse + message for email to the -owner and -admin address.""")), + + ('bounce_notify_owner_on_disable', mm_cfg.Toggle, + (_('No'), _('Yes')), 0, + _("""Should Mailman notify you, the list owner, when bounces + cause a member's subscription to be disabled?"""), + _("""By setting this value to No, you turn off + notification messages that are normally sent to the list owners + when a member's delivery is disabled due to excessive bounces. + An attempt to notify the member will always be made.""")), + + ('bounce_notify_owner_on_removal', mm_cfg.Toggle, + (_('No'), _('Yes')), 0, + _("""Should Mailman notify you, the list owner, when bounces + cause a member to be unsubscribed?"""), + _("""By setting this value to No, you turn off + notification messages that are normally sent to the list owners + when a member is unsubscribed due to excessive bounces. An + attempt to notify the member will always be made.""")), + + ] + + def _setValue(self, mlist, property, val, doc): + # Do value conversion from web representation to internal + # representation. + try: + if property == 'bounce_processing': + val = int(val) + elif property == 'bounce_score_threshold': + val = float(val) + elif property == 'bounce_info_stale_after': + val = days(int(val)) + elif property == 'bounce_you_are_disabled_warnings': + val = int(val) + elif property == 'bounce_you_are_disabled_warnings_interval': + val = days(int(val)) + elif property == 'bounce_notify_owner_on_disable': + val = int(val) + elif property == 'bounce_notify_owner_on_removal': + val = int(val) + except ValueError: + doc.addError( + _("""Bad value for %(property)s: %(val)s"""), + tag = _('Error: ')) + return + GUIBase._setValue(self, mlist, property, val, doc) + + def getValue(self, mlist, kind, varname, params): + if varname not in ('bounce_info_stale_after', + 'bounce_you_are_disabled_warnings_interval'): + return None + return int(getattr(mlist, varname) / days(1)) diff --git a/Mailman/Gui/ContentFilter.py b/Mailman/Gui/ContentFilter.py new file mode 100644 index 00000000..cb7ed95c --- /dev/null +++ b/Mailman/Gui/ContentFilter.py @@ -0,0 +1,169 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""GUI component managing the content filtering options. +""" + +from Mailman import mm_cfg +from Mailman.i18n import _ +from Mailman.Gui.GUIBase import GUIBase + +NL = '\n' + + + +class ContentFilter(GUIBase): + def GetConfigCategory(self): + return 'contentfilter', _('Content filtering') + + def GetConfigInfo(self, mlist, category, subcat=None): + if category <> 'contentfilter': + return None + WIDTH = mm_cfg.TEXTFIELDWIDTH + + actions = [_('Discard'), _('Reject'), _('Forward to List Owner')] + if mm_cfg.OWNERS_CAN_PRESERVE_FILTERED_MESSAGES: + actions.append(_('Preserve')) + + return [ + _("""Policies concerning the content of list traffic. + +

      Content filtering works like this: when a message is + received by the list and you have enabled content filtering, the + individual attachments are first compared to the + filter + types. If the attachment type matches an entry in the filter + types, it is discarded. + +

      Then, if there are pass types + defined, any attachment type that does not match a + pass type is also discarded. If there are no pass types defined, + this check is skipped. + +

      After this initial filtering, any multipart + attachments that are empty are removed. If the outer message is + left empty after this filtering, then the whole message is + discarded. Then, each multipart/alternative section will + be replaced by just the first alternative that is non-empty after + filtering. + +

      Finally, any text/html parts that are left in the + message may be converted to text/plain if + convert_html_to_plaintext is enabled and the site is + configured to allow these conversions."""), + + ('filter_content', mm_cfg.Radio, (_('No'), _('Yes')), 0, + _("""Should Mailman filter the content of list traffic according + to the settings below?""")), + + ('filter_mime_types', mm_cfg.Text, (10, WIDTH), 0, + _("""Remove message attachments that have a matching content + type."""), + + _("""Use this option to remove each message attachment that + matches one of these content types. Each line should contain a + string naming a MIME type/subtype, + e.g. image/gif. Leave off the subtype to remove all + parts with a matching major content type, e.g. image. + +

      Blank lines are ignored. + +

      See also pass_mime_types for a content type whitelist.""")), + + ('pass_mime_types', mm_cfg.Text, (10, WIDTH), 0, + _("""Remove message attachments that don't have a matching + content type. Leave this field blank to skip this filter + test."""), + + _("""Use this option to remove each message attachment that does + not have a matching content type. Requirements and formats are + exactly like filter_mime_types. + +

      Note: if you add entries to this list but don't add + multipart to this list, any messages with attachments + will be rejected by the pass filter.""")), + + ('convert_html_to_plaintext', mm_cfg.Radio, (_('No'), _('Yes')), 0, + _("""Should Mailman convert text/html parts to plain + text? This conversion happens after MIME attachments have been + stripped.""")), + + ('filter_action', mm_cfg.Radio, tuple(actions), 0, + + _("""Action to take when a message matches the content filtering + rules."""), + + _("""One of these actions is take when the message matches one of + the content filtering rules, meaning, the top-level + content type matches one of the filter_mime_types, or the top-level content type does + not match one of the + pass_mime_types, or if after filtering the subparts of the + message, the message ends up empty. + +

      Note this action is not taken if after filtering the message + still contains content. In that case the message is always + forwarded on to the list membership. + +

      When messages are discarded, a log entry is written + containing the Message-ID of the discarded message. When + messages are rejected or forwarded to the list owner, a reason + for the rejection is included in the bounce message to the + original author. When messages are preserved, they are saved in + a special queue directory on disk for the site administrator to + view (and possibly rescue) but otherwise discarded. This last + option is only available if enabled by the site + administrator.""")), + ] + + def _setValue(self, mlist, property, val, doc): + if property in ('filter_mime_types', 'pass_mime_types'): + types = [] + for spectype in [s.strip() for s in val.splitlines()]: + ok = 1 + slashes = spectype.count('/') + if slashes == 0 and not spectype: + ok = 0 + elif slashes == 1: + maintype, subtype = [s.strip().lower() + for s in spectype.split('/')] + if not maintype or not subtype: + ok = 0 + elif slashes > 1: + ok = 0 + if not ok: + doc.addError(_('Bad MIME type ignored: %(spectype)s')) + else: + types.append(spectype.strip().lower()) + if property == 'filter_mime_types': + mlist.filter_mime_types = types + elif property == 'pass_mime_types': + mlist.pass_mime_types = types + else: + GUIBase._setValue(self, mlist, property, val, doc) + + def getValue(self, mlist, kind, property, params): + if property == 'filter_mime_types': + return NL.join(mlist.filter_mime_types) + if property == 'pass_mime_types': + return NL.join(mlist.pass_mime_types) + return None diff --git a/Mailman/Gui/Digest.py b/Mailman/Gui/Digest.py new file mode 100644 index 00000000..7eb486c7 --- /dev/null +++ b/Mailman/Gui/Digest.py @@ -0,0 +1,160 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Administrative GUI for digest deliveries.""" + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman.i18n import _ + +# Intra-package import +from Mailman.Gui.GUIBase import GUIBase + +# Common b/w nondigest and digest headers & footers. Personalizations may add +# to this. +ALLOWEDS = ('real_name', 'list_name', 'host_name', 'web_page_url', + 'description', 'info', 'cgiext', '_internal_name', + ) + + + +class Digest(GUIBase): + def GetConfigCategory(self): + return 'digest', _('Digest options') + + def GetConfigInfo(self, mlist, category, subcat=None): + if category <> 'digest': + return None + WIDTH = mm_cfg.TEXTFIELDWIDTH + + info = [ + _("Batched-delivery digest characteristics."), + + ('digestable', mm_cfg.Toggle, (_('No'), _('Yes')), 1, + _('Can list members choose to receive list traffic ' + 'bunched in digests?')), + + ('digest_is_default', mm_cfg.Radio, + (_('Regular'), _('Digest')), 0, + _('Which delivery mode is the default for new users?')), + + ('mime_is_default_digest', mm_cfg.Radio, + (_('Plain'), _('MIME')), 0, + _('When receiving digests, which format is default?')), + + ('digest_size_threshhold', mm_cfg.Number, 3, 0, + _('How big in Kb should a digest be before it gets sent out?')), + # Should offer a 'set to 0' for no size threshhold. + + ('digest_send_periodic', mm_cfg.Radio, (_('No'), _('Yes')), 1, + _('Should a digest be dispatched daily when the size threshold ' + "isn't reached?")), + + ('digest_header', mm_cfg.Text, (4, WIDTH), 0, + _('Header added to every digest'), + _("Text attached (as an initial message, before the table" + " of contents) to the top of digests. ") + + Utils.maketext('headfoot.html', raw=1, mlist=mlist)), + + ('digest_footer', mm_cfg.Text, (4, WIDTH), 0, + _('Footer added to every digest'), + _("Text attached (as a final message) to the bottom of digests. ") + + Utils.maketext('headfoot.html', raw=1, mlist=mlist)), + + ('digest_volume_frequency', mm_cfg.Radio, + (_('Yearly'), _('Monthly'), _('Quarterly'), + _('Weekly'), _('Daily')), 0, + _('How often should a new digest volume be started?'), + _('''When a new digest volume is started, the volume number is + incremented and the issue number is reset to 1.''')), + + ('_new_volume', mm_cfg.Toggle, (_('No'), _('Yes')), 0, + _('Should Mailman start a new digest volume?'), + _('''Setting this option instructs Mailman to start a new volume + with the next digest sent out.''')), + + ('_send_digest_now', mm_cfg.Toggle, (_('No'), _('Yes')), 0, + _('''Should Mailman send the next digest right now, if it is not + empty?''')), + ] + +## if mm_cfg.OWNERS_CAN_ENABLE_PERSONALIZATION: +## info.extend([ +## ('digest_personalize', mm_cfg.Toggle, (_('No'), _('Yes')), 1, + +## _('''Should Mailman personalize each 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 digest messages to +## the mail server in batches. This is much more efficent +## because it reduces the amount of traffic between Mailman and +## the mail server. + +##

      However, some lists can benefit from a more personalized +## approach. In this case, Mailman crafts a new message for +## each member on the digest delivery list. Turning this on +## adds a few more expansion variables that can be included in +## the message header +## and message footer +## but it may degrade the performance of your site as +## a whole. + +##

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

      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. +## """)) +## ]) + + return info + + def _setValue(self, mlist, property, val, doc): + # Watch for the special, immediate action attributes + if property == '_new_volume' and val: + mlist.bump_digest_volume() + volume = mlist.volume + number = mlist.next_digest_number + doc.AddItem(_("""The next digest will be sent as volume + %(volume)s, number %(number)s""")) + elif property == '_send_digest_now' and val: + status = mlist.send_digest_now() + if status: + doc.AddItem(_("""A digest has been sent.""")) + else: + doc.AddItem(_("""There was no digest to send.""")) + else: + # Everything else... + if property in ('digest_header', 'digest_footer'): + val = self._convertString(mlist, property, ALLOWEDS, val, doc) + if val is None: + # There was a problem, so don't set it + return + GUIBase._setValue(self, mlist, property, val, doc) diff --git a/Mailman/Gui/GUIBase.py b/Mailman/Gui/GUIBase.py new file mode 100644 index 00000000..7062235e --- /dev/null +++ b/Mailman/Gui/GUIBase.py @@ -0,0 +1,200 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Base class for all web GUI components.""" + +import re +from types import TupleType, ListType + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import Errors +from Mailman.i18n import _ + +NL = '\n' +BADJOINER = ', ' + + + +class GUIBase: + # Providing a common interface for GUI component form processing. Most + # GUI components won't need to override anything, but some may want to + # override _setValue() to provide some specialized processing for some + # attributes. + def _getValidValue(self, mlist, property, wtype, val): + # Coerce and validate the new value. + # + # Radio buttons and boolean toggles both have integral type + if wtype in (mm_cfg.Radio, mm_cfg.Toggle): + # Let ValueErrors propagate + return int(val) + # String and Text widgets both just return their values verbatim + if wtype in (mm_cfg.String, mm_cfg.Text): + return val + # This widget contains a single email address + if wtype == mm_cfg.Email: + # BAW: We must allow blank values otherwise reply_to_address can't + # be cleared. This is currently the only mm_cfg.Email type widget + # in the interface, so watch out if we ever add any new ones. + if val: + # Let MMBadEmailError and MMHostileAddress propagate + Utils.ValidateEmail(val) + return val + # These widget types contain lists of email addresses, one per line. + # The EmailListEx allows each line to contain either an email address + # or a regular expression + if wtype in (mm_cfg.EmailList, mm_cfg.EmailListEx): + # BAW: value might already be a list, if this is coming from + # config_list input. Sigh. + if isinstance(val, ListType): + return val + addrs = [] + for addr in [s.strip() for s in val.split(NL)]: + # Discard empty lines + if not addr: + continue + try: + # This throws an exception if the address is invalid + Utils.ValidateEmail(addr) + except Errors.EmailAddressError: + # See if this is a context that accepts regular + # expressions, and that the re is legal + if wtype == mm_cfg.EmailListEx and addr.startswith('^'): + try: + re.compile(addr) + except re.error: + raise ValueError + else: + raise + addrs.append(addr) + return addrs + # This is a host name, i.e. verbatim + if wtype == mm_cfg.Host: + return val + # This is a number, either a float or an integer + if wtype == mm_cfg.Number: + num = -1 + try: + num = int(val) + except ValueError: + # Let ValueErrors percolate up + num = float(val) + if num < 0: + return getattr(mlist, property) + return num + # This widget is a select box, i.e. verbatim + if wtype == mm_cfg.Select: + return val + # Checkboxes return a list of the selected items, even if only one is + # selected. + if wtype == mm_cfg.Checkbox: + if isinstance(val, ListType): + return val + return [val] + if wtype == mm_cfg.FileUpload: + return val + if wtype == mm_cfg.Topics: + return val + # Should never get here + assert 0, 'Bad gui widget type: %s' % wtype + + def _setValue(self, mlist, property, val, doc): + # Set the value, or override to take special action on the property + if not property.startswith('_') and getattr(mlist, property) <> val: + setattr(mlist, property, val) + + def _postValidate(self, mlist, doc): + # Validate all the attributes for this category + pass + + def handleForm(self, mlist, category, subcat, cgidata, doc): + for item in self.GetConfigInfo(mlist, category, subcat): + # Skip descriptions and legacy non-attributes + if not isinstance(item, TupleType) or len(item) < 5: + continue + # Unpack the gui item description + property, wtype, args, deps, desc = item[0:5] + # BAW: I know this code is a little crufty but I wanted to + # reproduce the semantics of the original code in admin.py as + # closely as possible, for now. We can clean it up later. + # + # The property may be uploadable... + uploadprop = property + '_upload' + if cgidata.has_key(uploadprop) and cgidata[uploadprop].value: + val = cgidata[uploadprop].value + elif not cgidata.has_key(property): + continue + elif isinstance(cgidata[property], ListType): + val = [x.value for x in cgidata[property]] + else: + val = cgidata[property].value + # Coerce the value to the expected type, raising exceptions if the + # value is invalid + try: + val = self._getValidValue(mlist, property, wtype, val) + except ValueError: + doc.addError(_('Invalid value for variable: %(property)s')) + # This is the parent of MMBadEmailError and MMHostileAddress + except Errors.EmailAddressError: + doc.addError( + _('Bad email address for option %(property)s: %(val)s')) + else: + # Set the attribute, which will normally delegate to the mlist + self._setValue(mlist, property, val, doc) + # Do a final sweep once all the attributes have been set. This is how + # we can do cross-attribute assertions + self._postValidate(mlist, doc) + + # Convenience method for handling $-string attributes + def _convertString(self, mlist, property, alloweds, val, doc): + # Is the list using $-strings? + dollarp = getattr(mlist, 'use_dollar_strings', 0) + if dollarp: + ids = Utils.dollar_identifiers(val) + else: + # %-strings + ids = Utils.percent_identifiers(val) + # Here's the list of allowable interpolations + for allowed in alloweds: + if ids.has_key(allowed): + del ids[allowed] + if ids: + # What's left are not allowed + badkeys = ids.keys() + badkeys.sort() + bad = BADJOINER.join(badkeys) + doc.addError(_( + """The following illegal substitution variables were + found in the %(property)s string: + %(bad)s +

        Your list may not operate properly until you correct this + problem."""), tag=_('Warning: ')) + return val + # Now if we're still using %-strings, do a roundtrip conversion and + # see if the converted value is the same as the new value. If not, + # then they probably left off a trailing `s'. We'll warn them and use + # the corrected string. + if not dollarp: + fixed = Utils.to_percent(Utils.to_dollar(val)) + if fixed <> val: + doc.addError(_( + """Your %(property)s string appeared to + have some correctable problems in its new value. + The fixed value will be used instead. Please + double check that this is what you intended. + """)) + return fixed + return val diff --git a/Mailman/Gui/General.py b/Mailman/Gui/General.py new file mode 100644 index 00000000..a33d1004 --- /dev/null +++ b/Mailman/Gui/General.py @@ -0,0 +1,446 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""MailList mixin class managing the general options. +""" + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import Errors +from Mailman.i18n import _ +from Mailman.Gui.GUIBase import GUIBase + +OPTIONS = ('hide', 'ack', 'notmetoo', 'nodupes') + + + +class General(GUIBase): + def GetConfigCategory(self): + return 'general', _('General Options') + + def GetConfigInfo(self, mlist, category, subcat): + if category <> 'general': + return None + WIDTH = mm_cfg.TEXTFIELDWIDTH + + # These are for the default_options checkboxes below. + bitfields = {'hide' : mm_cfg.ConcealSubscription, + 'ack' : mm_cfg.AcknowledgePosts, + 'notmetoo' : mm_cfg.DontReceiveOwnPosts, + 'nodupes' : mm_cfg.DontReceiveDuplicates + } + bitdescrs = { + 'hide' : _("Conceal the member's address"), + 'ack' : _("Acknowledge the member's posting"), + 'notmetoo' : _("Do not send a copy of a member's own post"), + 'nodupes' : + _('Filter out duplicate messages to list members (if possible)'), + } + + optvals = [mlist.new_member_options & bitfields[o] for o in OPTIONS] + opttext = [bitdescrs[o] for o in OPTIONS] + + rtn = [ + _('''Fundamental list characteristics, including descriptive + info and basic behaviors.'''), + + _('General list personality'), + + ('real_name', mm_cfg.String, WIDTH, 0, + _('The public name of this list (make case-changes only).'), + _('''The capitalization of this name can be changed to make it + presentable in polite company as a proper noun, or to make an + acronym part all upper case, etc. However, the name will be + advertised as the email address (e.g., in subscribe confirmation + notices), so it should not be otherwise altered. (Email + addresses are not case sensitive, but they are sensitive to + almost everything else :-)''')), + + ('owner', mm_cfg.EmailList, (3, WIDTH), 0, + _("""The list administrator email addresses. Multiple + administrator addresses, each on separate line is okay."""), + + _('''There are two ownership roles associated with each mailing + list. The list administrators are the people who have + ultimate control over all parameters of this mailing list. They + are able to change any list configuration variable available + through these administration web pages. + +

        The list moderators have more limited permissions; + they are not able to change any list configuration variable, but + they are allowed to tend to pending administration requests, + including approving or rejecting held subscription requests, and + disposing of held postings. Of course, the list + administrators can also tend to pending requests. + +

        In order to split the list ownership duties into + administrators and moderators, you must + set a separate moderator password, + and also provide the email + addresses of the list moderators. Note that the field you + are changing here specifies the list administrators.''')), + + ('moderator', mm_cfg.EmailList, (3, WIDTH), 0, + _("""The list moderator email addresses. Multiple + moderator addresses, each on separate line is okay."""), + + _('''There are two ownership roles associated with each mailing + list. The list administrators are the people who have + ultimate control over all parameters of this mailing list. They + are able to change any list configuration variable available + through these administration web pages. + +

        The list moderators have more limited permissions; + they are not able to change any list configuration variable, but + they are allowed to tend to pending administration requests, + including approving or rejecting held subscription requests, and + disposing of held postings. Of course, the list + administrators can also tend to pending requests. + +

        In order to split the list ownership duties into + administrators and moderators, you must + set a separate moderator password, + and also provide the email addresses of the list moderators in + this section. Note that the field you are changing here + specifies the list moderators.''')), + + ('description', mm_cfg.String, WIDTH, 0, + _('A terse phrase identifying this list.'), + + _('''This description is used when the mailing list is listed with + other mailing lists, or in headers, and so forth. It should + be as succinct as you can get it, while still identifying what + the list is.''')), + + ('info', mm_cfg.Text, (7, WIDTH), 0, + _('''An introductory description - a few paragraphs - about the + list. It will be included, as html, at the top of the listinfo + page. Carriage returns will end a paragraph - see the details + for more info.'''), + _("""The text will be treated as html except that + newlines will be translated to <br> - so you can use links, + preformatted text, etc, but don't put in carriage returns except + where you mean to separate paragraphs. And review your changes - + bad html (like some unterminated HTML constructs) can prevent + display of the entire listinfo page.""")), + + ('subject_prefix', mm_cfg.String, WIDTH, 0, + _('Prefix for subject line of list postings.'), + _("""This text will be prepended to subject lines of messages + posted to the list, to distinguish mailing list messages in in + mailbox summaries. Brevity is premium here, it's ok to shorten + long mailing list names to something more concise, as long as it + still identifies the mailing list.""")), + + ('anonymous_list', mm_cfg.Radio, (_('No'), _('Yes')), 0, + _("""Hide the sender of a message, replacing it with the list + address (Removes From, Sender and Reply-To fields)""")), + + _('''Reply-To: header munging'''), + + ('first_strip_reply_to', mm_cfg.Radio, (_('No'), _('Yes')), 0, + _('''Should any existing Reply-To: header found in the + original message be stripped? If so, this will be done + regardless of whether an explict Reply-To: header is + added by Mailman or not.''')), + + ('reply_goes_to_list', mm_cfg.Radio, + (_('Poster'), _('This list'), _('Explicit address')), 0, + _('''Where are replies to list messages directed? + Poster is strongly recommended for most mailing + lists.'''), + + # Details for reply_goes_to_list + _("""This option controls what Mailman does to the + Reply-To: header in messages flowing through this + mailing list. When set to Poster, no Reply-To: + header is added by Mailman, although if one is present in the + original message, it is not stripped. Setting this value to + either This list or Explicit address causes + Mailman to insert a specific Reply-To: header in all + messages, overriding the header in the original message if + necessary (Explicit address inserts the value of reply_to_address). + +

        There are many reasons not to introduce or override the + Reply-To: header. One is that some posters depend on + their own Reply-To: settings to convey their valid + return address. Another is that modifying Reply-To: + makes it much more difficult to send private replies. See `Reply-To' + Munging Considered Harmful for a general discussion of this + issue. See Reply-To + Munging Considered Useful for a dissenting opinion. + +

        Some mailing lists have restricted posting privileges, with a + parallel list devoted to discussions. Examples are `patches' or + `checkin' lists, where software changes are posted by a revision + control system, but discussion about the changes occurs on a + developers mailing list. To support these types of mailing + lists, select Explicit address and set the + Reply-To: address below to point to the parallel + list.""")), + + ('reply_to_address', mm_cfg.Email, WIDTH, 0, + _('Explicit Reply-To: header.'), + # Details for reply_to_address + _("""This is the address set in the Reply-To: header + when the reply_goes_to_list + option is set to Explicit address. + +

        There are many reasons not to introduce or override the + Reply-To: header. One is that some posters depend on + their own Reply-To: settings to convey their valid + return address. Another is that modifying Reply-To: + makes it much more difficult to send private replies. See `Reply-To' + Munging Considered Harmful for a general discussion of this + issue. See Reply-To + Munging Considered Useful for a dissenting opinion. + +

        Some mailing lists have restricted posting privileges, with a + parallel list devoted to discussions. Examples are `patches' or + `checkin' lists, where software changes are posted by a revision + control system, but discussion about the changes occurs on a + developers mailing list. To support these types of mailing + lists, specify the explicit Reply-To: address here. You + must also specify Explicit address in the + reply_goes_to_list + variable. + +

        Note that if the original message contains a + Reply-To: header, it will not be changed.""")), + + _('Umbrella list settings'), + + ('umbrella_list', mm_cfg.Radio, (_('No'), _('Yes')), 0, + _('''Send password reminders to, eg, "-owner" address instead of + directly to user.'''), + + _("""Set this to yes when this list is intended to cascade only + to other mailing lists. When set, meta notices like + confirmations and password reminders will be directed to an + address derived from the member\'s address - it will have the + value of "umbrella_member_suffix" appended to the member's + account name.""")), + + ('umbrella_member_suffix', mm_cfg.String, WIDTH, 0, + _('''Suffix for use when this list is an umbrella for other + lists, according to setting of previous "umbrella_list" + setting.'''), + + _("""When "umbrella_list" is set to indicate that this list has + other mailing lists as members, then administrative notices like + confirmations and password reminders need to not be sent to the + member list addresses, but rather to the owner of those member + lists. In that case, the value of this setting is appended to + the member's account name for such notices. `-owner' is the + typical choice. This setting has no effect when "umbrella_list" + is "No".""")), + + _('Notifications'), + + ('send_reminders', mm_cfg.Radio, (_('No'), _('Yes')), 0, + _('''Send monthly password reminders?'''), + + _('''Turn this on if you want password reminders to be sent once + per month to your members. Note that members may disable their + own individual password reminders.''')), + + ('welcome_msg', mm_cfg.Text, (4, WIDTH), 0, + _('''List-specific text prepended to new-subscriber welcome + message'''), + + _("""This value, if any, will be added to the front of the + new-subscriber welcome message. The rest of the welcome message + already describes the important addresses and URLs for the + mailing list, so you don't need to include any of that kind of + stuff here. This should just contain mission-specific kinds of + things, like etiquette policies or team orientation, or that kind + of thing. + +

        Note that this text will be wrapped, according to the + following rules: +

        • Each paragraph is filled so that no line is longer than + 70 characters. +
        • Any line that begins with whitespace is not filled. +
        • A blank line separates paragraphs. +
        """)), + + ('send_welcome_msg', mm_cfg.Radio, (_('No'), _('Yes')), 0, + _('Send welcome message to newly subscribed members?'), + _("""Turn this off only if you plan on subscribing people manually + and don't want them to know that you did so. This option is most + useful for transparently migrating lists from some other mailing + list manager to Mailman.""")), + + ('goodbye_msg', mm_cfg.Text, (4, WIDTH), 0, + _('''Text sent to people leaving the list. If empty, no special + text will be added to the unsubscribe message.''')), + + ('send_goodbye_msg', mm_cfg.Radio, (_('No'), _('Yes')), 0, + _('Send goodbye message to members when they are unsubscribed?')), + + ('admin_immed_notify', mm_cfg.Radio, (_('No'), _('Yes')), 0, + _('''Should the list moderators get immediate notice of new + requests, as well as daily notices about collected ones?'''), + + _('''List moderators (and list administrators) are sent daily + reminders of requests pending approval, like subscriptions to a + moderated list, or postings that are being held for one reason or + another. Setting this option causes notices to be sent + immediately on the arrival of new requests as well.''')), + + ('admin_notify_mchanges', mm_cfg.Radio, (_('No'), _('Yes')), 0, + _('''Should administrator get notices of subscribes and + unsubscribes?''')), + + ('respond_to_post_requests', mm_cfg.Radio, + (_('No'), _('Yes')), 0, + _('Send mail to poster when their posting is held for approval?'), + + _("""Approval notices are sent when mail triggers certain of the + limits except routine list moderation and spam filters, + for which notices are not sent. This option overrides + ever sending the notice.""")), + + _('Additional settings'), + + ('emergency', mm_cfg.Toggle, (_('No'), _('Yes')), 0, + _('Emergency moderation of all list traffic.'), + _("""When this option is enabled, all list traffic is emergency + moderated, i.e. held for moderation. Turn this option on when + your list is experiencing a flamewar and you want a cooling off + period.""")), + + ('new_member_options', mm_cfg.Checkbox, + (opttext, optvals, 0, OPTIONS), + # The description for new_member_options includes a kludge where + # we add a hidden field so that even when all the checkboxes are + # deselected, the form data will still have a new_member_options + # key (it will always be a list). Otherwise, we'd never be able + # to tell if all were deselected! + 0, _('''Default options for new members joining this list.'''), + + _("""When a new member is subscribed to this list, their initial + set of options is taken from the this variable's setting.""")), + + ('administrivia', mm_cfg.Radio, (_('No'), _('Yes')), 0, + _('''(Administrivia filter) Check postings and intercept ones + that seem to be administrative requests?'''), + + _("""Administrivia tests will check postings to see whether it's + really meant as an administrative request (like subscribe, + unsubscribe, etc), and will add it to the the administrative + requests queue, notifying the administrator of the new request, + in the process.""")), + + ('max_message_size', mm_cfg.Number, 7, 0, + _('''Maximum length in kilobytes (KB) of a message body. Use 0 + for no limit.''')), + + ('host_name', mm_cfg.Host, WIDTH, 0, + _('Host name this list prefers for email.'), + + _("""The "host_name" is the preferred name for email to + mailman-related addresses on this host, and generally should be + the mail host's exchanger address, if any. This setting can be + useful for selecting among alternative names of a host that has + multiple addresses.""")), + + ] + + if mm_cfg.ALLOW_RFC2369_OVERRIDES: + rtn.append( + ('include_rfc2369_headers', mm_cfg.Radio, + (_('No'), _('Yes')), 0, + _("""Should messages from this mailing list include the + RFC 2369 + (i.e. List-*) headers? Yes is highly + recommended."""), + + _("""RFC 2369 defines a set of List-* headers that are + normally added to every message sent to the list membership. + These greatly aid end-users who are using standards compliant + mail readers. They should normally always be enabled. + +

        However, not all mail readers are standards compliant yet, + and if you have a large number of members who are using + non-compliant mail readers, they may be annoyed at these + headers. You should first try to educate your members as to + why these headers exist, and how to hide them in their mail + clients. As a last resort you can disable these headers, but + this is not recommended (and in fact, your ability to disable + these headers may eventually go away).""")) + ) + # Suppression of List-Post: headers + rtn.append( + ('include_list_post_header', mm_cfg.Radio, + (_('No'), _('Yes')), 0, + _('Should postings include the List-Post: header?'), + _("""The List-Post: header is one of the headers + recommended by + RFC 2369. + However for some announce-only mailing lists, only a + very select group of people are allowed to post to the list; the + general membership is usually not allowed to post. For lists of + this nature, the List-Post: header is misleading. + Select No to disable the inclusion of this header. (This + does not affect the inclusion of the other List-*: + headers.)""")) + ) + + return rtn + + def _setValue(self, mlist, property, val, doc): + if property == 'real_name' and \ + val.lower() <> mlist.internal_name().lower(): + # These values can't differ by other than case + doc.addError(_("""real_name attribute not + changed! It must differ from the list's name by case + only.""")) + elif property == 'new_member_options': + newopts = 0 + for opt in OPTIONS: + bitfield = mm_cfg.OPTINFO[opt] + if opt in val: + newopts |= bitfield + mlist.new_member_options = newopts + elif property == 'subject_prefix': + # Convert any html entities to Unicode + mlist.subject_prefix = Utils.canonstr( + val, mlist.preferred_language) + else: + GUIBase._setValue(self, mlist, property, val, doc) + + def _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/Mailman/Gui/Language.py b/Mailman/Gui/Language.py new file mode 100644 index 00000000..bfa5185f --- /dev/null +++ b/Mailman/Gui/Language.py @@ -0,0 +1,122 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""MailList mixin class managing the language options. +""" + +import codecs + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import i18n +from Mailman.Logging.Syslog import syslog +from Mailman.Gui.GUIBase import GUIBase + +_ = 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.GetAvailableLanguages() + langnames = [_(Utils.GetLanguageDescr(L)) for L in langs] + 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 = [key for key in mm_cfg.LC_DESCRIPTIONS.keys() + if checkcodec(Utils.GetCharSet(key))] + all.sort() + checked = [L in langs for L in all] + allnames = [_(Utils.GetLanguageDescr(L)) for L in all] + + return [ + _('Natural language (internationalization) options.'), + + ('preferred_language', mm_cfg.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', mm_cfg.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', mm_cfg.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, property, val, doc): + # If we're changing the list's preferred language, change the I18N + # context as well + if property == 'preferred_language': + i18n.set_language(val) + doc.set_language(val) + GUIBase._setValue(self, mlist, property, val, doc) diff --git a/Mailman/Gui/Makefile.in b/Mailman/Gui/Makefile.in new file mode 100644 index 00000000..ea219772 --- /dev/null +++ b/Mailman/Gui/Makefile.in @@ -0,0 +1,69 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +# NOTE: Makefile.in is converted into Makefile by the configure script +# in the parent directory. Once configure has run, you can recreate +# the Makefile by running just config.status. + +# Variables set by configure + +VPATH= @srcdir@ +srcdir= @srcdir@ +bindir= @bindir@ +prefix= @prefix@ +exec_prefix= @exec_prefix@ + +CC= @CC@ +CHMOD= @CHMOD@ +INSTALL= @INSTALL@ + +DEFS= @DEFS@ + +# Customizable but not set by configure + +OPT= @OPT@ +CFLAGS= $(OPT) $(DEFS) +PACKAGEDIR= $(prefix)/Mailman/Gui +SHELL= /bin/sh + +MODULES= *.py + +# Modes for directories and executables created by the install +# process. Default to group-writable directories but +# user-only-writable for executables. +DIRMODE= 775 +EXEMODE= 755 +FILEMODE= 644 +INSTALL_PROGRAM=$(INSTALL) -m $(EXEMODE) + + +# Rules + +all: + +install: + for f in $(MODULES); \ + do \ + $(INSTALL) -m $(FILEMODE) $(srcdir)/$$f $(PACKAGEDIR); \ + done + +finish: + +clean: + +distclean: + -rm *.pyc + -rm Makefile diff --git a/Mailman/Gui/Membership.py b/Mailman/Gui/Membership.py new file mode 100644 index 00000000..99e44a57 --- /dev/null +++ b/Mailman/Gui/Membership.py @@ -0,0 +1,34 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""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/Mailman/Gui/NonDigest.py b/Mailman/Gui/NonDigest.py new file mode 100644 index 00000000..900f865f --- /dev/null +++ b/Mailman/Gui/NonDigest.py @@ -0,0 +1,130 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""GUI component for managing the non-digest delivery options. +""" + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman.i18n import _ +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 = mm_cfg.TEXTFIELDWIDTH + + info = [ + _("Policies concerning immediately delivered list traffic."), + + ('nondigestable', mm_cfg.Toggle, (_('No'), _('Yes')), 1, + _("""Can subscribers choose to receive mail immediately, rather + than in batched digests?""")), + ] + + if mm_cfg.OWNERS_CAN_ENABLE_PERSONALIZATION: + info.extend([ + ('personalize', mm_cfg.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. + info.extend([('msg_header', mm_cfg.Text, (10, WIDTH), 0, + _('Header added to mail sent to regular list members'), + _('''Text prepended to the top of every immediately-delivery + message. ''') + Utils.maketext('headfoot.html', + mlist=mlist, raw=1)), + + ('msg_footer', mm_cfg.Text, (10, WIDTH), 0, + _('Footer added to mail sent to regular list members'), + _('''Text appended to the bottom of every immediately-delivery + message. ''') + Utils.maketext('headfoot.html', + mlist=mlist, raw=1)), + ]) + 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/Mailman/Gui/Passwords.py b/Mailman/Gui/Passwords.py new file mode 100644 index 00000000..a3cf6b8e --- /dev/null +++ b/Mailman/Gui/Passwords.py @@ -0,0 +1,31 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""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/Mailman/Gui/Privacy.py b/Mailman/Gui/Privacy.py new file mode 100644 index 00000000..7ef50375 --- /dev/null +++ b/Mailman/Gui/Privacy.py @@ -0,0 +1,398 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""MailList mixin class managing the privacy options. +""" + +from Mailman import mm_cfg +from Mailman.i18n import _ +from Mailman.Gui.GUIBase import GUIBase + + + +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 = mm_cfg.TEXTFIELDWIDTH + if mm_cfg.ALLOW_OPEN_SUBSCRIBE: + sub_cfentry = ('subscribe_policy', mm_cfg.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', mm_cfg.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', mm_cfg.Radio, (_('No'), _('Yes')), 0, + _('''Advertise this list when people ask what lists are on this + machine?''')), + + sub_cfentry, + + ('unsubscribe_policy', mm_cfg.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', mm_cfg.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', mm_cfg.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', mm_cfg.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', absolute=1) + 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', mm_cfg.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', mm_cfg.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', mm_cfg.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', mm_cfg.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', mm_cfg.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', mm_cfg.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', mm_cfg.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', mm_cfg.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', mm_cfg.Radio, (_('No'), _('Yes')), 0, + _("""Should messages from non-members, which are automatically + discarded, be forwarded to the list moderator?""")), + + ] + + recip_rtn = [ + _("""This section allows you to configure various filters based on + the recipient of the message."""), + + _('Recipient filters'), + + ('require_explicit_destination', mm_cfg.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', mm_cfg.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', mm_cfg.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. + """), + + _("Anti-Spam filters"), + + ('bounce_matching_headers', mm_cfg.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): + # 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 mm_cfg.ALLOW_OPEN_SUBSCRIBE: + val += 1 + setattr(mlist, property, val) diff --git a/Mailman/Gui/Topics.py b/Mailman/Gui/Topics.py new file mode 100644 index 00000000..310d876f --- /dev/null +++ b/Mailman/Gui/Topics.py @@ -0,0 +1,160 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +import re + +from Mailman import mm_cfg +from Mailman.i18n import _ +from Mailman.Logging.Syslog import syslog +from Mailman.Gui.GUIBase import GUIBase + + + +class Topics(GUIBase): + def GetConfigCategory(self): + return 'topics', _('Topics') + + def GetConfigInfo(self, mlist, category, subcat=None): + if category <> 'topics': + return None + WIDTH = mm_cfg.TEXTFIELDWIDTH + + return [ + _('List topic keywords'), + + ('topics_enabled', mm_cfg.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', mm_cfg.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', mm_cfg.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): + topics = [] + # We start i at 1 and keep going until we no longer find items keyed + # with the marked tags. + i = 1 + while 1: + 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 + try: + re.compile(pattern) + except (re.error, TypeError): + doc.addError(_("""The topic pattern `%(pattern)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(('', '', '', 1)) + topics.append((name, pattern, desc, 0)) + # Default is to add it after... + else: + topics.append((name, pattern, desc, 0)) + topics.append(('', '', '', 1)) + # Otherwise, just retain this one in the list + else: + topics.append((name, pattern, desc, 0)) + + # 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/Mailman/Gui/Usenet.py b/Mailman/Gui/Usenet.py new file mode 100644 index 00000000..9d6b65f4 --- /dev/null +++ b/Mailman/Gui/Usenet.py @@ -0,0 +1,137 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +from Mailman import mm_cfg +from Mailman.i18n import _ +from Mailman.Gui.GUIBase import GUIBase + + + +class Usenet(GUIBase): + def GetConfigCategory(self): + return 'gateway', _('Mail<->News gateways') + + def GetConfigInfo(self, mlist, category, subcat=None): + if category <> 'gateway': + return None + + WIDTH = mm_cfg.TEXTFIELDWIDTH + VERTICAL = 1 + + return [ + _('Mail-to-News and News-to-Mail gateway services.'), + + _('News server settings'), + + ('nntp_host', mm_cfg.String, WIDTH, 0, + _('''The Internet address of the machine your News server is + running on.'''), + _('''The News server is not part of Mailman proper. You have to + already have access to a NNTP server, and that NNTP server has to + recognize the machine this mailing list runs on as a machine + capable of reading and posting news.''')), + + ('linked_newsgroup', mm_cfg.String, WIDTH, 0, + _('The name of the Usenet group to gateway to and/or from.')), + + ('gateway_to_news', mm_cfg.Toggle, (_('No'), _('Yes')), 0, + _('''Should new posts to the mailing list be sent to the + newsgroup?''')), + + ('gateway_to_mail', mm_cfg.Toggle, (_('No'), _('Yes')), 0, + _('''Should new posts to the newsgroup be sent to the mailing + list?''')), + + _('Forwarding options'), + + ('news_moderation', mm_cfg.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', mm_cfg.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', mm_cfg.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/Mailman/Gui/__init__.py b/Mailman/Gui/__init__.py new file mode 100644 index 00000000..1e79b34a --- /dev/null +++ b/Mailman/Gui/__init__.py @@ -0,0 +1,32 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +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/Mailman/HTMLFormatter.py b/Mailman/HTMLFormatter.py new file mode 100644 index 00000000..397eb475 --- /dev/null +++ b/Mailman/HTMLFormatter.py @@ -0,0 +1,433 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + + +"""Routines for presentation of list-specific HTML text.""" + +import time +import re + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import MemberAdaptor +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 = mm_cfg.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() + for person in people: + id = Utils.ObscureEmail(person) + url = self.GetOptionsURL(person) + if self.obscure_addresses: + 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 == mm_cfg.DisableDelivery: + optval = self.getDeliveryStatus(user) <> MemberAdaptor.ENABLED + else: + optval = self.getMemberOption(user, option) + if optval == value: + checked = ' CHECKED' + else: + checked = '' + name = {mm_cfg.DontReceiveOwnPosts : 'dontreceive', + mm_cfg.DisableDelivery : 'disablemail', + mm_cfg.DisableMime : 'mime', + mm_cfg.AcknowledgePosts : 'ackposts', + mm_cfg.Digests : 'digest', + mm_cfg.ConcealSubscription : 'conceal', + mm_cfg.SuppressPasswordReminder : 'remind', + mm_cfg.ReceiveNonmatchingTopics : 'rcvtopic', + mm_cfg.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, type(u'')): + repl = repl.encode(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.GetAvailableLanguages()) == 1: + listlangs = _(Utils.GetLanguageDescr(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 mm_cfg.IMAGE_LOGOS: + d[''] = mm_cfg.IMAGE_LOGOS + mm_cfg.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.GetAvailableLanguages() + legend = map(_, map(Utils.GetLanguageDescr, values)) + try: + selected = values.index(lang) + except ValueError: + try: + selected = values.index(self.preferred_language) + except ValueError: + selected = mm_cfg.DEFAULT_SERVER_LANGUAGE + # Return the widget + return SelectOptions(varname, values, legend, selected) diff --git a/Mailman/Handlers/.cvsignore b/Mailman/Handlers/.cvsignore new file mode 100644 index 00000000..f3c7a7c5 --- /dev/null +++ b/Mailman/Handlers/.cvsignore @@ -0,0 +1 @@ +Makefile diff --git a/Mailman/Handlers/Acknowledge.py b/Mailman/Handlers/Acknowledge.py new file mode 100644 index 00000000..103448e1 --- /dev/null +++ b/Mailman/Handlers/Acknowledge.py @@ -0,0 +1,62 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Send an acknowledgement of the successful post to the sender. + +This only happens if the sender has set their AcknowledgePosts attribute. +This module must appear after the deliverer in the message pipeline in order +to send acks only after successful delivery. + +""" + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import Message +from Mailman import Errors +from Mailman.i18n import _ + + + +def process(mlist, msg, msgdata): + # Extract the sender's address and find them in the user database + sender = msgdata.get('original_sender', msg.get_sender()) + try: + ack = mlist.getMemberOption(sender, mm_cfg.AcknowledgePosts) + if not ack: + return + except Errors.NotAMemberError: + return + # Okay, they want acknowledgement of their post. Give them their original + # subject. BAW: do we want to use the decoded header? + origsubj = msgdata.get('origsubj', msg.get('subject', _('(no subject)'))) + # Get the user's preferred language + lang = msgdata.get('lang', mlist.getMemberLanguage(sender)) + # Now get the acknowledgement template + realname = mlist.real_name + text = Utils.maketext( + 'postack.txt', + {'subject' : origsubj, + 'listname' : realname, + 'listinfo_url': mlist.GetScriptURL('listinfo', absolute=1), + 'optionsurl' : mlist.GetOptionsURL(sender, absolute=1), + }, lang=lang, mlist=mlist, raw=1) + # Craft the outgoing message, with all headers and attributes + # necessary for general delivery. Then enqueue it to the outgoing + # queue. + subject = _('%(realname)s post acknowledgement') + usermsg = Message.UserNotification(sender, mlist.GetBouncesEmail(), + subject, text, lang) + usermsg.send(mlist) diff --git a/Mailman/Handlers/AfterDelivery.py b/Mailman/Handlers/AfterDelivery.py new file mode 100644 index 00000000..b6bb96c2 --- /dev/null +++ b/Mailman/Handlers/AfterDelivery.py @@ -0,0 +1,28 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Perform some bookkeeping after a successful post. + +This module must appear after the delivery module in the message pipeline. +""" + +import time + + + +def process(mlist, msg, msgdata): + mlist.last_post_time = time.time() + mlist.post_id += 1 diff --git a/Mailman/Handlers/Approve.py b/Mailman/Handlers/Approve.py new file mode 100644 index 00000000..d339a9b1 --- /dev/null +++ b/Mailman/Handlers/Approve.py @@ -0,0 +1,82 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Determine whether the message is approved for delivery. + +This module only tests for definitive approvals. IOW, this module only +determines whether the message is definitively approved or definitively +denied. Situations that could hold a message for approval or confirmation are +not tested by this module. + +""" + +from email.Iterators import typed_subpart_iterator + +from Mailman import mm_cfg +from Mailman import Errors + +NL = '\n' + + + +def process(mlist, msg, msgdata): + # Short circuits + if msgdata.get('approved'): + # Digests, Usenet postings, and some other messages come pre-approved. + # TBD: we may want to further filter Usenet messages, so the test + # above may not be entirely correct. + return + # See if the message has an Approved or Approve header with a valid + # list-moderator, list-admin. Also look at the first non-whitespace line + # in the file to see if it looks like an Approved header. We are + # specifically /not/ allowing the site admins password to work here + # because we want to discourage the practice of sending the site admin + # password through email in the clear. + missing = [] + passwd = msg.get('approved', msg.get('approve', missing)) + if passwd is missing: + # Find the first text/plain part in the message + part = None + for part in typed_subpart_iterator(msg, 'text', 'plain'): + break + if part is not None: + lines = part.get_payload().splitlines() + line = '' + for lineno, line in zip(range(len(lines)), lines): + if line.strip(): + break + i = line.find(':') + if i >= 0: + name = line[:i] + value = line[i+1:] + if name.lower() in ('approve', 'approved'): + passwd = value.lstrip() + # Now strip the first line from the payload so the + # password doesn't leak. + del lines[lineno] + part.set_payload(NL.join(lines[1:])) + if passwd is not missing and mlist.Authenticate((mm_cfg.AuthListModerator, + mm_cfg.AuthListAdmin), + passwd): + # BAW: should we definitely deny if the password exists but does not + # match? For now we'll let it percolate up for further determination. + msgdata['approved'] = 1 + # Used by the Emergency module + msgdata['adminapproved'] = 1 + # has this message already been posted to this list? + beentheres = [s.strip().lower() for s in msg.get_all('x-beenthere', [])] + if mlist.GetListEmail().lower() in beentheres: + raise Errors.LoopError diff --git a/Mailman/Handlers/AvoidDuplicates.py b/Mailman/Handlers/AvoidDuplicates.py new file mode 100644 index 00000000..af740da2 --- /dev/null +++ b/Mailman/Handlers/AvoidDuplicates.py @@ -0,0 +1,88 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""If the user wishes it, do not send duplicates of the same message. + +This module keeps an in-memory dictionary of Message-ID: and recipient pairs. +If a message with an identical Message-ID: is about to be sent to someone who +has already received a copy, we either drop the message, add a duplicate +warning header, or pass it through, depending on the user's preferences. +""" + +from Mailman import mm_cfg + +from email.Utils import getaddresses, formataddr + + + +def process(mlist, msg, msgdata): + recips = msgdata['recips'] + # Short circuit + if not recips: + return + # Seed this set with addresses we don't care about dup avoiding + explicit_recips = {} + listaddrs = [mlist.GetListEmail(), mlist.GetBouncesEmail(), + mlist.GetOwnerEmail(), mlist.GetRequestEmail()] + for addr in listaddrs: + explicit_recips[addr] = 1 + # Figure out the set of explicit recipients + ccaddrs = {} + for header in ('to', 'cc', 'resent-to', 'resent-cc'): + addrs = getaddresses(msg.get_all(header, [])) + if header == 'cc': + for name, addr in addrs: + ccaddrs[addr] = name, addr + for name, addr in addrs: + if not addr: + continue + # Ignore the list addresses for purposes of dup avoidance + explicit_recips[addr] = 1 + # Now strip out the list addresses + for addr in listaddrs: + del explicit_recips[addr] + if not explicit_recips: + # No one was explicitly addressed, so we can't do any dup collapsing + return + newrecips = [] + for r in recips: + # If this recipient is explicitly addressed... + if explicit_recips.has_key(r): + send_duplicate = 1 + # If the member wants to receive duplicates, or if the recipient + # is not a member at all, just flag the X-Mailman-Duplicate: yes + # header. + if mlist.isMember(r) and \ + mlist.getMemberOption(r, mm_cfg.DontReceiveDuplicates): + send_duplicate = 0 + # We'll send a duplicate unless the user doesn't wish it. If + # personalization is enabled, the add-dupe-header flag will add a + # X-Mailman-Duplicate: yes header for this user's message. + if send_duplicate: + msgdata.setdefault('add-dup-header', {})[r] = 1 + newrecips.append(r) + elif ccaddrs.has_key(r): + del ccaddrs[r] + else: + # Otherwise, this is the first time they've been in the recips + # list. Add them to the newrecips list and flag them as having + # received this message. + newrecips.append(r) + # Set the new list of recipients + msgdata['recips'] = newrecips + del msg['cc'] + for item in ccaddrs.values(): + msg['cc'] = formataddr(item) diff --git a/Mailman/Handlers/CalcRecips.py b/Mailman/Handlers/CalcRecips.py new file mode 100644 index 00000000..1b2a600b --- /dev/null +++ b/Mailman/Handlers/CalcRecips.py @@ -0,0 +1,133 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Calculate the regular (i.e. non-digest) recipients of the message. + +This module calculates the non-digest recipients for the message based on the +list's membership and configuration options. It places the list of recipients +on the `recips' attribute of the message. This attribute is used by the +SendmailDeliver and BulkDeliver modules. +""" + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import Message +from Mailman import Errors +from Mailman.MemberAdaptor import ENABLED +from Mailman.i18n import _ +from Mailman.Logging.Syslog import syslog + + + +def process(mlist, msg, msgdata): + # Short circuit if we've already calculated the recipients list, + # regardless of whether the list is empty or not. + if msgdata.has_key('recips'): + return + # Should the original sender should be included in the recipients list? + include_sender = 1 + sender = msg.get_sender() + try: + if mlist.getMemberOption(sender, mm_cfg.DontReceiveOwnPosts): + include_sender = 0 + except Errors.NotAMemberError: + pass + # Support for urgent messages, which bypasses digests and disabled + # delivery and forces an immediate delivery to all members Right Now. We + # are specifically /not/ allowing the site admins password to work here + # because we want to discourage the practice of sending the site admin + # password through email in the clear. (see also Approve.py) + missing = [] + password = msg.get('urgent', missing) + if password is not missing: + if mlist.Authenticate((mm_cfg.AuthListModerator, + mm_cfg.AuthListAdmin), + password): + recips = mlist.getMemberCPAddresses(mlist.getRegularMemberKeys() + + mlist.getDigestMemberKeys()) + msgdata['recips'] = recips + return + else: + # Bad Urgent: password, so reject it instead of passing it on. I + # think it's better that the sender know they screwed up than to + # deliver it normally. + realname = mlist.real_name + text = _("""\ +Your urgent message to the %(realname)s mailing list was not authorized for +delivery. The original message as received by Mailman is attached. +""") + raise Errors.RejectMessage, Utils.wrap(text) + # Calculate the regular recipients of the message + recips = [mlist.getMemberCPAddress(m) + for m in mlist.getRegularMemberKeys() + if mlist.getDeliveryStatus(m) == ENABLED] + # Remove the sender if they don't want to receive their own posts + if not include_sender: + try: + recips.remove(mlist.getMemberCPAddress(sender)) + except (Errors.NotAMemberError, ValueError): + # Sender does not want to get copies of their own messages (not + # metoo), but delivery to their address is disabled (nomail). Or + # the sender is not a member of the mailing list. + pass + # Handle topic classifications + do_topic_filters(mlist, msg, msgdata, recips) + # Bookkeeping + msgdata['recips'] = recips + + + +def do_topic_filters(mlist, msg, msgdata, recips): + hits = msgdata.get('topichits') + zaprecips = [] + if hits: + # The message hit some topics, so only deliver this message to those + # who are interested in one of the hit topics. + for user in recips: + utopics = mlist.getMemberTopics(user) + if not utopics: + # This user is not interested in any topics, so they get all + # postings. + continue + # BAW: Slow, first-match, set intersection! + for topic in utopics: + if topic in hits: + # The user wants this message + break + else: + # The user was interested in topics, but not any of the ones + # this message matched, so zap him. + zaprecips.append(user) + else: + # The semantics for a message that did not hit any of the pre-canned + # topics is to troll through the membership list, looking for users + # who selected at least one topic of interest, but turned on + # ReceiveNonmatchingTopics. + for user in recips: + if not mlist.getMemberTopics(user): + # The user did not select any topics of interest, so he gets + # this message by default. + continue + if not mlist.getMemberOption(user, + mm_cfg.ReceiveNonmatchingTopics): + # The user has interest in some topics, but elects not to + # receive message that match no topics, so zap him. + zaprecips.append(user) + # Otherwise, the user wants non-matching messages. + # Prune out the non-receiving users + for user in zaprecips: + recips.remove(user) + diff --git a/Mailman/Handlers/Cleanse.py b/Mailman/Handlers/Cleanse.py new file mode 100644 index 00000000..143f9500 --- /dev/null +++ b/Mailman/Handlers/Cleanse.py @@ -0,0 +1,39 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Cleanse certain headers from all messages.""" + + +def process(mlist, msg, msgdata): + # Always remove this header from any outgoing messages. Be sure to do + # this after the information on the header is actually used, but before a + # permanent record of the header is saved. + del msg['approved'] + # Also remove this header since it can contain a password + del msg['urgent'] + # We remove other headers from anonymous lists + if mlist.anonymous_list: + del msg['from'] + del msg['reply-to'] + del msg['sender'] + msg['From'] = mlist.GetListEmail() + msg['Reply-To'] = mlist.GetListEmail() + # Some headers can be used to fish for membership + del msg['return-receipt-to'] + del msg['disposition-notification-to'] + del msg['x-confirm-reading-to'] + # Pegasus mail uses this one... sigh + del msg['x-pmrqc'] diff --git a/Mailman/Handlers/CookHeaders.py b/Mailman/Handlers/CookHeaders.py new file mode 100644 index 00000000..40eddd66 --- /dev/null +++ b/Mailman/Handlers/CookHeaders.py @@ -0,0 +1,254 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Cook a message's Subject header. +""" + +from __future__ import nested_scopes +import re +from types import UnicodeType + +from email.Charset import Charset +from email.Header import Header, decode_header +from email.Utils import parseaddr, formataddr, getaddresses + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman.i18n import _ +from Mailman.Logging.Syslog import syslog + +CONTINUATION = ',\n\t' +COMMASPACE = ', ' +MAXLINELEN = 78 + + + +def _isunicode(s): + return isinstance(s, UnicodeType) + +def uheader(mlist, s, header_name=None): + # Get the charset to encode the string in. If this is us-ascii, we'll use + # iso-8859-1 instead, just to get a little extra coverage, and because the + # Header class tries us-ascii first anyway. + charset = Utils.GetCharSet(mlist.preferred_language) + if charset == 'us-ascii': + charset = 'iso-8859-1' + charset = Charset(charset) + # Convert the string to unicode so Header will do the 3-charset encoding. + # If s is a byte string and there are funky characters in it that don't + # match the charset, we might as well replace them now. + if not _isunicode(s): + codec = charset.input_codec or 'ascii' + s = unicode(s, codec, 'replace') + # We purposefully leave no space b/w prefix and subject! + return Header(s, charset, header_name=header_name) + + + +def process(mlist, msg, msgdata): + # Set the "X-Ack: no" header if noack flag is set. + if msgdata.get('noack'): + del msg['x-ack'] + msg['X-Ack'] = 'no' + # Because we're going to modify various important headers in the email + # message, we want to save some of the information in the msgdata + # dictionary for later. Specifically, the sender header will get waxed, + # but we need it for the Acknowledge module later. + msgdata['original_sender'] = msg.get_sender() + # VirginRunner sets _fasttrack for internally crafted messages. + fasttrack = msgdata.get('_fasttrack') + if not msgdata.get('isdigest') and not fasttrack: + prefix_subject(mlist, msg, msgdata) + # Mark message so we know we've been here, but leave any existing + # X-BeenThere's intact. + msg['X-BeenThere'] = mlist.GetListEmail() + # Add Precedence: and other useful headers. None of these are standard + # and finding information on some of them are fairly difficult. Some are + # just common practice, and we'll add more here as they become necessary. + # Good places to look are: + # + # http://www.dsv.su.se/~jpalme/ietf/jp-ietf-home.html + # http://www.faqs.org/rfcs/rfc2076.html + # + # None of these headers are added if they already exist. BAW: some + # consider the advertising of this a security breach. I.e. if there are + # known exploits in a particular version of Mailman and we know a site is + # using such an old version, they may be vulnerable. It's too easy to + # edit the code to add a configuration variable to handle this. + if not msg.has_key('x-mailman-version'): + msg['X-Mailman-Version'] = mm_cfg.VERSION + # We set "Precedence: list" because this is the recommendation from the + # sendmail docs, the most authoritative source of this header's semantics. + if not msg.has_key('precedence'): + msg['Precedence'] = 'list' + # Reply-To: munging. Do not do this if the message is "fast tracked", + # meaning it is internally crafted and delivered to a specific user. BAW: + # Yuck, I really hate this feature but I've caved under the sheer pressure + # of the (very vocal) folks want it. OTOH, RFC 2822 allows Reply-To: to + # be a list of addresses, so instead of replacing the original, simply + # augment it. RFC 2822 allows max one Reply-To: header so collapse them + # if we're adding a value, otherwise don't touch it. (Should we collapse + # in all cases?) + if not fasttrack: + # A convenience function, requires nested scopes. pair is (name, addr) + new = [] + d = {} + def add(pair): + lcaddr = pair[1].lower() + if d.has_key(lcaddr): + return + d[lcaddr] = pair + new.append(pair) + # List admin wants an explicit Reply-To: added + if mlist.reply_goes_to_list == 2: + add(parseaddr(mlist.reply_to_address)) + # If we're not first stripping existing Reply-To: then we need to add + # the original Reply-To:'s to the list we're building up. In both + # cases we'll zap the existing field because RFC 2822 says max one is + # allowed. + if not mlist.first_strip_reply_to: + orig = msg.get_all('reply-to', []) + for pair in getaddresses(orig): + add(pair) + # Set Reply-To: header to point back to this list. Add this last + # because some folks think that some MUAs make it easier to delete + # addresses from the right than from the left. + if mlist.reply_goes_to_list == 1: + i18ndesc = uheader(mlist, mlist.description) + add((str(i18ndesc), mlist.GetListEmail())) + del msg['reply-to'] + # Don't put Reply-To: back if there's nothing to add! + if new: + # Preserve order + msg['Reply-To'] = COMMASPACE.join( + [formataddr(pair) for pair in new]) + # The To field normally contains the list posting address. However + # when messages are fully personalized, that header will get + # overwritten with the address of the recipient. We need to get the + # posting address in one of the recipient headers or they won't be + # able to reply back to the list. It's possible the posting address + # was munged into the Reply-To header, but if not, we'll add it to a + # Cc header. BAW: should we force it into a Reply-To header in the + # above code? + if mlist.personalize == 2 and mlist.reply_goes_to_list <> 1: + # Watch out for existing Cc headers, merge, and remove dups. Note + # that RFC 2822 says only zero or one Cc header is allowed. + new = [] + d = {} + for pair in getaddresses(msg.get_all('cc', [])): + add(pair) + i18ndesc = uheader(mlist, mlist.description) + add((str(i18ndesc), mlist.GetListEmail())) + del msg['Cc'] + msg['Cc'] = COMMASPACE.join([formataddr(pair) for pair in new]) + # Add list-specific headers as defined in RFC 2369 and RFC 2919, but only + # if the message is being crafted for a specific list (e.g. not for the + # password reminders). + # + # BAW: Some people really hate the List-* headers. It seems that the free + # version of Eudora (possibly on for some platforms) does not hide these + # headers by default, pissing off their users. Too bad. Fix the MUAs. + if msgdata.get('_nolist') or not mlist.include_rfc2369_headers: + return + # Pre-calculate + listid = '<%s.%s>' % (mlist.internal_name(), mlist.host_name) + if mlist.description: + # Make sure description is properly i18n'd + listid_h = uheader(mlist, mlist.description, 'List-Id') + listid_h.append(' ' + listid, 'us-ascii') + else: + # For wrapping + listid_h = Header(listid, 'us-ascii', header_name='List-Id') + # We always add a List-ID: header. + del msg['list-id'] + msg['List-Id'] = listid_h + # For internally crafted messages, we + # also add a (nonstandard), "X-List-Administrivia: yes" header. For all + # others (i.e. those coming from list posts), we adda a bunch of other RFC + # 2369 headers. + requestaddr = mlist.GetRequestEmail() + subfieldfmt = '<%s>, ' + listinfo = mlist.GetScriptURL('listinfo', absolute=1) + headers = {} + if msgdata.get('reduced_list_headers'): + headers['X-List-Administrivia'] = 'yes' + else: + headers.update({ + 'List-Help' : '' % requestaddr, + 'List-Unsubscribe': subfieldfmt % (listinfo, requestaddr, 'un'), + 'List-Subscribe' : subfieldfmt % (listinfo, requestaddr, ''), + }) + # List-Post: is controlled by a separate attribute + if mlist.include_list_post_header: + headers['List-Post'] = '' % mlist.GetListEmail() + # Add this header if we're archiving + if mlist.archive: + archiveurl = mlist.GetBaseArchiveURL() + if archiveurl.endswith('/'): + archiveurl = archiveurl[:-1] + headers['List-Archive'] = '<%s>' % archiveurl + # First we delete any pre-existing headers because the RFC permits only + # one copy of each, and we want to be sure it's ours. + for h, v in headers.items(): + del msg[h] + # Wrap these lines if they are too long. 78 character width probably + # shouldn't be hardcoded, but is at least text-MUA friendly. The + # adding of 2 is for the colon-space separator. + if len(h) + 2 + len(v) > 78: + v = CONTINUATION.join(v.split(', ')) + msg[h] = v + + + +def prefix_subject(mlist, msg, msgdata): + # Add the subject prefix unless the message is a digest or is being fast + # tracked (e.g. internally crafted, delivered to a single user such as the + # list admin). + prefix = mlist.subject_prefix + subject = msg['subject'] + msgdata['origsubj'] = subject + # The header may be multilingual; decode it from base64/quopri and search + # each chunk for the prefix. BAW: Note that if the prefix contains spaces + # and each word of the prefix is encoded in a different chunk in the + # header, we won't find it. I think in practice that's unlikely though. + headerbits = decode_header(subject) + if prefix and subject: + pattern = re.escape(prefix.strip()) + for decodedsubj, charset in headerbits: + if re.search(pattern, decodedsubj, re.IGNORECASE): + # The subject's already got the prefix, so don't change it + return + del msg['subject'] + if not subject: + subject = _('(no subject)') + # Get the header as a Header instance, with proper unicode conversion + h = uheader(mlist, prefix, 'Subject') + for s, c in headerbits: + # Once again, convert the string to unicode. + if c is None: + c = Charset('iso-8859-1') + if not isinstance(c, Charset): + c = Charset(c) + if not _isunicode(s): + codec = c.input_codec or 'ascii' + try: + s = unicode(s, codec, 'replace') + except LookupError: + # Unknown codec, is this default reasonable? + s = unicode(s, Utils.GetCharSet(mlist.preferred_language), + 'replace') + h.append(s, c) + msg['Subject'] = h diff --git a/Mailman/Handlers/Decorate.py b/Mailman/Handlers/Decorate.py new file mode 100644 index 00000000..5605e321 --- /dev/null +++ b/Mailman/Handlers/Decorate.py @@ -0,0 +1,183 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Decorate a message by sticking the header and footer around it. +""" + +from types import ListType +from email.MIMEText import MIMEText + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import Errors +from Mailman.Message import Message +from Mailman.i18n import _ +from Mailman.SafeDict import SafeDict +from Mailman.Logging.Syslog import syslog + + + +def process(mlist, msg, msgdata): + # Digests and Mailman-craft messages should not get additional headers + if msgdata.get('isdigest') or msgdata.get('nodecorate'): + return + d = {} + if msgdata.get('personalize'): + # Calculate the extra personalization dictionary. Note that the + # length of the recips list better be exactly 1. + recips = msgdata.get('recips') + assert type(recips) == ListType and len(recips) == 1 + member = recips[0].lower() + d['user_address'] = member + try: + d['user_delivered_to'] = mlist.getMemberCPAddress(member) + # BAW: Hmm, should we allow this? + d['user_password'] = mlist.getMemberPassword(member) + d['user_language'] = mlist.getMemberLanguage(member) + d['user_name'] = mlist.getMemberName(member) or _('not available') + d['user_optionsurl'] = mlist.GetOptionsURL(member) + except Errors.NotAMemberError: + pass + # These strings are descriptive for the log file and shouldn't be i18n'd + header = decorate(mlist, mlist.msg_header, 'non-digest header', d) + footer = decorate(mlist, mlist.msg_footer, 'non-digest footer', d) + # Escape hatch if both the footer and header are empty + if not header and not footer: + return + # Be MIME smart here. We only attach the header and footer by + # concatenation when the message is a non-multipart of type text/plain. + # Otherwise, if it is not a multipart, we make it a multipart, and then we + # add the header and footer as text/plain parts. + # + # BJG: In addition, only add the footer if the message's character set + # matches the charset of the list's preferred language. This is a + # suboptimal solution, and should be solved by allowing a list to have + # multiple headers/footers, for each language the list supports. + # + # Also, if the list's preferred charset is us-ascii, we can always + # safely add the header/footer to a plain text message since all + # charsets Mailman supports are strict supersets of us-ascii -- + # no, UTF-16 emails are not supported yet. + mcset = msg.get_param('charset', 'us-ascii').lower() + lcset = Utils.GetCharSet(mlist.preferred_language) + msgtype = msg.get_type('text/plain') + # BAW: If the charsets don't match, should we add the header and footer by + # MIME multipart chroming the message? + wrap = 1 + if not msg.is_multipart() and msgtype == 'text/plain' and \ + msg.get('content-transfer-encoding', '').lower() <> 'base64' and \ + (lcset == 'us-ascii' or mcset == lcset): + oldpayload = msg.get_payload() + frontsep = endsep = '' + if header and not header.endswith('\n'): + frontsep = '\n' + if footer and not oldpayload.endswith('\n'): + endsep = '\n' + payload = header + frontsep + oldpayload + endsep + footer + msg.set_payload(payload) + wrap = 0 + elif msg.get_type() == 'multipart/mixed': + # The next easiest thing to do is just prepend the header and append + # the footer as additional subparts + mimehdr = MIMEText(header, 'plain', lcset) + mimeftr = MIMEText(footer, 'plain', lcset) + payload = msg.get_payload() + if not isinstance(payload, ListType): + payload = [payload] + if footer: + payload.append(mimeftr) + if header: + payload.insert(0, mimehdr) + msg.set_payload(payload) + wrap = 0 + # If we couldn't add the header or footer in a less intrusive way, we can + # at least do it by MIME encapsulation. We want to keep as much of the + # outer chrome as possible. + if not wrap: + return + # Because of the way Message objects are passed around to process(), we + # need to play tricks with the outer message -- i.e. the outer one must + # remain the same instance. So we're going to create a clone of the outer + # message, with all the header chrome intact, then copy the payload to it. + # This will give us a clone of the original message, and it will form the + # basis of the interior, wrapped Message. + inner = Message() + # Which headers to copy? Let's just do the Content-* headers + for h, v in msg.items(): + if h.lower().startswith('content-'): + inner[h] = v + inner.set_payload(msg.get_payload()) + # For completeness + inner.set_unixfrom(msg.get_unixfrom()) + inner.preamble = msg.preamble + inner.epilogue = msg.epilogue + # Don't copy get_charset, as this might be None, even if + # get_content_charset isn't. However, do make sure there is a default + # content-type, even if the original message was not MIME. + inner.set_default_type(msg.get_default_type()) + # BAW: HACK ALERT. + if hasattr(msg, '__version__'): + inner.__version__ = msg.__version__ + # Now, play games with the outer message to make it contain three + # subparts: the header (if any), the wrapped message, and the footer (if + # any). + payload = [inner] + if header: + mimehdr = MIMEText(header, 'plain', lcset) + payload.insert(0, mimehdr) + if footer: + mimeftr = MIMEText(footer, 'plain', lcset) + payload.append(mimeftr) + msg.set_payload(payload) + del msg['content-type'] + del msg['content-transfer-encoding'] + del msg['content-disposition'] + msg['Content-Type'] = 'multipart/mixed' + + + +def decorate(mlist, template, what, extradict={}): + # `what' is just a descriptive phrase used in the log message + # + # BAW: We've found too many situations where Python can be fooled into + # interpolating too much revealing data into a format string. For + # example, a footer of "% silly %(real_name)s" would give a header + # containing all list attributes. While we've previously removed such + # really bad ones like `password' and `passwords', it's much better to + # provide a whitelist of known good attributes, then to try to remove a + # blacklist of known bad ones. + d = SafeDict({'real_name' : mlist.real_name, + 'list_name' : mlist.internal_name(), + # For backwards compatibility + '_internal_name': mlist.internal_name(), + 'host_name' : mlist.host_name, + 'web_page_url' : mlist.web_page_url, + 'description' : mlist.description, + 'info' : mlist.info, + 'cgiext' : mm_cfg.CGIEXT, + }) + d.update(extradict) + # Using $-strings? + if getattr(mlist, 'use_dollar_strings', 0): + template = Utils.to_percent(template) + # Interpolate into the template + try: + text = (template % d).replace('\r\n', '\n') + except (ValueError, TypeError), e: + syslog('error', 'Exception while calculating %s:\n%s', what, e) + what = what.upper() + text = template + return text diff --git a/Mailman/Handlers/Emergency.py b/Mailman/Handlers/Emergency.py new file mode 100644 index 00000000..1833c9f7 --- /dev/null +++ b/Mailman/Handlers/Emergency.py @@ -0,0 +1,37 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Put an emergency hold on all messages otherwise approved. + +No notices are sent to either the sender or the list owner for emergency +holds. I think they'd be too obnoxious. +""" + +from Mailman import Errors +from Mailman.i18n import _ + + + +class EmergencyHold(Errors.HoldMessage): + reason = _('Emergency hold on all list traffic is in effect') + rejection = _('Your message was deemed inappropriate by the moderator.') + + + +def process(mlist, msg, msgdata): + if mlist.emergency and not msgdata.get('adminapproved'): + mlist.HoldMessage(msg, _(EmergencyHold.reason), msgdata) + raise EmergencyHold diff --git a/Mailman/Handlers/FileRecips.py b/Mailman/Handlers/FileRecips.py new file mode 100644 index 00000000..4ca4582e --- /dev/null +++ b/Mailman/Handlers/FileRecips.py @@ -0,0 +1,49 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Get the normal delivery recipients from a Sendmail style :include: file. +""" + +import os +import errno + +from Mailman import Errors + + + +def process(mlist, msg, msgdata): + if msgdata.has_key('recips'): + return + filename = os.path.join(mlist.fullpath(), 'members.txt') + try: + fp = open(filename) + except IOError, e: + if e.errno <> errno.ENOENT: + raise + # If the file didn't exist, just set an empty recipients list + msgdata['recips'] = [] + return + # Read all the lines out of the file, and strip them of the trailing nl + addrs = [line.strip() for line in fp.readlines()] + # If the sender is in that list, remove him + sender = msg.get_sender() + if mlist.isMember(sender): + try: + addrs.remove(mlist.getMemberCPAddress(sender)) + except ValueError: + # Don't worry if the sender isn't in the list + pass + msgdata['recips'] = addrs diff --git a/Mailman/Handlers/Hold.py b/Mailman/Handlers/Hold.py new file mode 100644 index 00000000..15223959 --- /dev/null +++ b/Mailman/Handlers/Hold.py @@ -0,0 +1,280 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Determine whether this message should be held for approval. + +This modules tests only for hold situations, such as messages that are too +large, messages that have potential administrivia, etc. Definitive approvals +or denials are handled by a different module. + +If no determination can be made (i.e. none of the hold criteria matches), then +we do nothing. If the message must be held for approval, then the hold +database is updated and any administrator notification messages are sent. +Finally an exception is raised to let the pipeline machinery know that further +message handling should stop. + +""" + +import email +from email.MIMEText import MIMEText +from email.MIMEMessage import MIMEMessage +import email.Utils +from types import ClassType + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import Errors +from Mailman import Message +from Mailman import i18n +from Mailman import Pending +from Mailman.Logging.Syslog import syslog + +# First, play footsie with _ so that the following are marked as translated, +# but aren't actually translated until we need the text later on. +def _(s): + return s + + + +class ForbiddenPoster(Errors.HoldMessage): + reason = _('Sender is explicitly forbidden') + rejection = _('You are forbidden from posting messages to this list.') + +class ModeratedPost(Errors.HoldMessage): + reason = _('Post to moderated list') + rejection = _('Your message was deemed inappropriate by the moderator.') + +class NonMemberPost(Errors.HoldMessage): + reason = _('Post by non-member to a members-only list') + rejection = _('Non-members are not allowed to post messages to this list.') + +class NotExplicitlyAllowed(Errors.HoldMessage): + reason = _('Posting to a restricted list by sender requires approval') + rejection = _('This list is restricted; your message was not approved.') + +class TooManyRecipients(Errors.HoldMessage): + reason = _('Too many recipients to the message') + rejection = _('Please trim the recipient list; it is too long.') + +class ImplicitDestination(Errors.HoldMessage): + reason = _('Message has implicit destination') + rejection = _('''Blind carbon copies or other implicit destinations are +not allowed. Try reposting your message by explicitly including the list +address in the To: or Cc: fields.''') + +class Administrivia(Errors.HoldMessage): + reason = _('Message may contain administrivia') + + def rejection_notice(self, mlist): + listurl = mlist.GetScriptURL('listinfo', absolute=1) + request = mlist.GetRequestEmail() + return _("""Please do *not* post administrative requests to the mailing +list. If you wish to subscribe, visit %(listurl)s or send a message with the +word `help' in it to the request address, %(request)s, for further +instructions.""") + +class SuspiciousHeaders(Errors.HoldMessage): + reason = _('Message has a suspicious header') + rejection = _('Your message had a suspicious header.') + +class MessageTooBig(Errors.HoldMessage): + def __init__(self, msgsize, limit): + self.__msgsize = msgsize + self.__limit = limit + + def reason_notice(self): + size = self.__msgsize + limit = self.__limit + return _('''Message body is too big: %(size)d bytes with a limit of +%(limit)d KB''') + + def rejection_notice(self, mlist): + kb = self.__limit + return _('''Your message was too big; please trim it to less than +%(kb)d KB in size.''') + +class ModeratedNewsgroup(ModeratedPost): + reason = _('Posting to a moderated newsgroup') + + + +# And reset the translator +_ = i18n._ + + + +def ackp(msg): + ack = msg.get('x-ack', '').lower() + precedence = msg.get('precedence', '').lower() + if ack <> 'yes' and precedence in ('bulk', 'junk', 'list'): + return 0 + return 1 + + + +def process(mlist, msg, msgdata): + if msgdata.get('approved'): + return + # Get the sender of the message + listname = mlist.internal_name() + adminaddr = listname + '-admin' + sender = msg.get_sender() + # Special case an ugly sendmail feature: If there exists an alias of the + # form "owner-foo: bar" and sendmail receives mail for address "foo", + # sendmail will change the envelope sender of the message to "bar" before + # delivering. This feature does not appear to be configurable. *Boggle*. + if not sender or sender[:len(listname)+6] == adminaddr: + sender = msg.get_sender(use_envelope=0) + # + # Possible administrivia? + if mlist.administrivia and Utils.is_administrivia(msg): + hold_for_approval(mlist, msg, msgdata, Administrivia) + # no return + # + # Are there too many recipients to the message? + if mlist.max_num_recipients > 0: + # figure out how many recipients there are + recips = email.Utils.getaddresses(msg.get_all('to', []) + + msg.get_all('cc', [])) + if len(recips) >= mlist.max_num_recipients: + hold_for_approval(mlist, msg, msgdata, TooManyRecipients) + # no return + # + # Implicit destination? Note that message originating from the Usenet + # side of the world should never be checked for implicit destination. + if mlist.require_explicit_destination and \ + not mlist.HasExplicitDest(msg) and \ + not msgdata.get('fromusenet'): + # then + hold_for_approval(mlist, msg, msgdata, ImplicitDestination) + # no return + # + # Suspicious headers? + if mlist.bounce_matching_headers: + triggered = mlist.hasMatchingHeader(msg) + if triggered: + # TBD: Darn - can't include the matching line for the admin + # message because the info would also go to the sender + hold_for_approval(mlist, msg, msgdata, SuspiciousHeaders) + # no return + # + # Is the message too big? + if mlist.max_message_size > 0: + bodylen = 0 + for line in email.Iterators.body_line_iterator(msg): + bodylen += len(line) + if bodylen/1024.0 > mlist.max_message_size: + hold_for_approval(mlist, msg, msgdata, + MessageTooBig(bodylen, mlist.max_message_size)) + # no return + # + # Are we gatewaying to a moderated newsgroup and is this list the + # moderator's address for the group? + if mlist.news_moderation == 2: + hold_for_approval(mlist, msg, msgdata, ModeratedNewsgroup) + + + +def hold_for_approval(mlist, msg, msgdata, exc): + # BAW: This should really be tied into the email confirmation system so + # that the message can be approved or denied via email as well as the + # web. + if type(exc) is ClassType: + # Go ahead and instantiate it now. + exc = exc() + listname = mlist.real_name + sender = msgdata.get('sender', msg.get_sender()) + owneraddr = mlist.GetOwnerEmail() + adminaddr = mlist.GetBouncesEmail() + requestaddr = mlist.GetRequestEmail() + # We need to send both the reason and the rejection notice through the + # translator again, because of the games we play above + reason = Utils.wrap(exc.reason_notice()) + msgdata['rejection_notice'] = Utils.wrap(exc.rejection_notice(mlist)) + id = mlist.HoldMessage(msg, reason, msgdata) + # Now we need to craft and send a message to the list admin so they can + # deal with the held message. + d = {'listname' : listname, + 'hostname' : mlist.host_name, + 'reason' : _(reason), + 'sender' : sender, + 'subject' : msg.get('subject', _('(no subject)')), + 'admindb_url': mlist.GetScriptURL('admindb', absolute=1), + } + # We may want to send a notification to the original sender too + fromusenet = msgdata.get('fromusenet') + # Since we're sending two messages, which may potentially be in different + # languages (the user's preferred and the list's preferred for the admin), + # we need to play some i18n games here. Since the current language + # context ought to be set up for the user, let's craft his message first. + # + # This message should appear to come from -admin so as to handle any + # bounce processing that might be needed. + cookie = Pending.new(Pending.HELD_MESSAGE, id) + if not fromusenet and ackp(msg) and mlist.respond_to_post_requests and \ + mlist.autorespondToSender(sender): + # Get a confirmation cookie + d['confirmurl'] = '%s/%s' % (mlist.GetScriptURL('confirm', absolute=1), + cookie) + lang = msgdata.get('lang', mlist.getMemberLanguage(sender)) + subject = _('Your message to %(listname)s awaits moderator approval') + text = Utils.maketext('postheld.txt', d, lang=lang, mlist=mlist) + nmsg = Message.UserNotification(sender, adminaddr, subject, text, lang) + nmsg.send(mlist) + # Now the message for the list owners. Be sure to include the list + # moderators in this message. 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 + # admin is expecting. + otranslation = i18n.get_translation() + i18n.set_language(mlist.preferred_language) + try: + lang = mlist.preferred_language + charset = Utils.GetCharSet(lang) + # We need to regenerate or re-translate a few values in d + usersubject = msg.get('subject', _('(no subject)')) + d['reason'] = _(reason) + d['subject'] = usersubject + # craft the admin notification message and deliver it + subject = _('%(listname)s post from %(sender)s requires approval') + nmsg = Message.UserNotification(owneraddr, owneraddr, subject, + lang=lang) + nmsg.set_type('multipart/mixed') + text = MIMEText( + Utils.maketext('postauth.txt', d, raw=1, mlist=mlist), + _charset=charset) + dmsg = MIMEText(Utils.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=Utils.GetCharSet(lang)) + dmsg['Subject'] = 'confirm ' + cookie + dmsg['Sender'] = requestaddr + dmsg['From'] = requestaddr + nmsg.attach(text) + nmsg.attach(MIMEMessage(msg)) + nmsg.attach(MIMEMessage(dmsg)) + nmsg.send(mlist, **{'tomoderators': 1}) + finally: + i18n.set_translation(otranslation) + # Log the held message + syslog('vette', '%s post from %s held: %s', listname, sender, reason) + # raise the specific MessageHeld exception to exit out of the message + # delivery pipeline + raise exc diff --git a/Mailman/Handlers/Makefile.in b/Mailman/Handlers/Makefile.in new file mode 100644 index 00000000..6123bdfb --- /dev/null +++ b/Mailman/Handlers/Makefile.in @@ -0,0 +1,69 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +# NOTE: Makefile.in is converted into Makefile by the configure script +# in the parent directory. Once configure has run, you can recreate +# the Makefile by running just config.status. + +# Variables set by configure + +VPATH= @srcdir@ +srcdir= @srcdir@ +bindir= @bindir@ +prefix= @prefix@ +exec_prefix= @exec_prefix@ + +CC= @CC@ +CHMOD= @CHMOD@ +INSTALL= @INSTALL@ + +DEFS= @DEFS@ + +# Customizable but not set by configure + +OPT= @OPT@ +CFLAGS= $(OPT) $(DEFS) +PACKAGEDIR= $(prefix)/Mailman/Handlers +SHELL= /bin/sh + +MODULES= *.py + +# Modes for directories and executables created by the install +# process. Default to group-writable directories but +# user-only-writable for executables. +DIRMODE= 775 +EXEMODE= 755 +FILEMODE= 644 +INSTALL_PROGRAM=$(INSTALL) -m $(EXEMODE) + + +# Rules + +all: + +install: + for f in $(MODULES); \ + do \ + $(INSTALL) -m $(FILEMODE) $(srcdir)/$$f $(PACKAGEDIR); \ + done + +finish: + +clean: + +distclean: + -rm *.pyc + -rm Makefile diff --git a/Mailman/Handlers/MimeDel.py b/Mailman/Handlers/MimeDel.py new file mode 100644 index 00000000..3bcdaffa --- /dev/null +++ b/Mailman/Handlers/MimeDel.py @@ -0,0 +1,220 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""MIME-stripping filter for Mailman. + +This module scans a message for MIME content, removing those sections whose +MIME types match one of a list of matches. multipart/alternative sections are +replaced by the first non-empty component, and multipart/mixed sections +wrapping only single sections after other processing are replaced by their +contents. +""" + +import os +import errno +import tempfile + +from email.Iterators import typed_subpart_iterator + +from Mailman import mm_cfg +from Mailman import Errors +from Mailman.Message import UserNotification +from Mailman.Queue.sbcache import get_switchboard +from Mailman.Logging.Syslog import syslog +from Mailman.Version import VERSION +from Mailman.i18n import _ + + + +def process(mlist, msg, msgdata): + # Short-circuits + if not mlist.filter_content: + return + if msgdata.get('isdigest'): + return + # We also don't care about our own digests or plaintext + ctype = msg.get_content_type() + mtype = msg.get_content_maintype() + # Check to see if the outer type matches one of the filter types + filtertypes = mlist.filter_mime_types + passtypes = mlist.pass_mime_types + if ctype in filtertypes or mtype in filtertypes: + dispose(mlist, msg, msgdata, + _("The message's content type was explicitly disallowed")) + # Check to see if there is a pass types and the outer type doesn't match + # one of these types + if passtypes and not (ctype in passtypes or mtype in passtypes): + dispose(mlist, msg, msgdata, + _("The message's content type was not explicitly allowed")) + numparts = len([subpart for subpart in msg.walk()]) + # If the message is a multipart, filter out matching subparts + if msg.is_multipart(): + # Recursively filter out any subparts that match the filter list + prelen = len(msg.get_payload()) + filter_parts(msg, filtertypes, passtypes) + # If the outer message is now an empty multipart (and it wasn't + # before!) then, again it gets discarded. + postlen = len(msg.get_payload()) + if postlen == 0 and prelen > 0: + dispose(mlist, msg, msgdata, + _("After content filtering, the message was empty")) + # Now replace all multipart/alternatives with just the first non-empty + # alternative. BAW: We have to special case when the outer part is a + # multipart/alternative because we need to retain most of the outer part's + # headers. For now we'll move the subpart's payload into the outer part, + # and then copy over its Content-Type: and Content-Transfer-Encoding: + # headers (any others?). + collapse_multipart_alternatives(msg) + if ctype == 'multipart/alternative': + firstalt = msg.get_payload(0) + reset_payload(msg, firstalt) + # If we removed some parts, make note of this + changedp = 0 + if numparts <> len([subpart for subpart in msg.walk()]): + changedp = 1 + # Now perhaps convert all text/html to text/plain + if mlist.convert_html_to_plaintext and mm_cfg.HTML_TO_PLAIN_TEXT_COMMAND: + changedp += to_plaintext(msg) + # If we're left with only two parts, an empty body and one attachment, + # recast the message to one of just that part + if msg.is_multipart() and len(msg.get_payload()) == 2: + if msg.get_payload(0).get_payload() == '': + useful = msg.get_payload(1) + reset_payload(msg, useful) + changedp = 1 + if changedp: + msg['X-Content-Filtered-By'] = 'Mailman/MimeDel %s' % VERSION + + + +def reset_payload(msg, subpart): + # Reset payload of msg to contents of subpart, and fix up content headers + payload = subpart.get_payload() + msg.set_payload(payload) + del msg['content-type'] + del msg['content-transfer-encoding'] + del msg['content-disposition'] + del msg['content-description'] + msg['Content-Type'] = subpart.get('content-type', 'text/plain') + cte = subpart.get('content-transfer-encoding') + if cte: + msg['Content-Transfer-Encoding'] = cte + cdisp = subpart.get('content-disposition') + if cdisp: + msg['Content-Disposition'] = cdisp + cdesc = subpart.get('content-description') + if cdesc: + msg['Content-Description'] = cdesc + + + +def filter_parts(msg, filtertypes, passtypes): + # Look at all the message's subparts, and recursively filter + if not msg.is_multipart(): + return 1 + payload = msg.get_payload() + prelen = len(payload) + newpayload = [] + for subpart in payload: + keep = filter_parts(subpart, filtertypes, passtypes) + if not keep: + continue + ctype = subpart.get_content_type() + mtype = subpart.get_content_maintype() + if ctype in filtertypes or mtype in filtertypes: + # Throw this subpart away + continue + if passtypes and not (ctype in passtypes or mtype in passtypes): + # Throw this subpart away + continue + newpayload.append(subpart) + # Check to see if we discarded all the subparts + postlen = len(newpayload) + msg.set_payload(newpayload) + if postlen == 0 and prelen > 0: + # We threw away everything + return 0 + return 1 + + + +def collapse_multipart_alternatives(msg): + if not msg.is_multipart(): + return + newpayload = [] + for subpart in msg.get_payload(): + if subpart.get_content_type() == 'multipart/alternative': + try: + firstalt = subpart.get_payload(0) + newpayload.append(firstalt) + except IndexError: + pass + else: + newpayload.append(subpart) + msg.set_payload(newpayload) + + + +def to_plaintext(msg): + changedp = 0 + for subpart in typed_subpart_iterator(msg, 'text', 'html'): + filename = tempfile.mktemp('.html') + fp = open(filename, 'w') + try: + fp.write(subpart.get_payload()) + fp.close() + cmd = os.popen(mm_cfg.HTML_TO_PLAIN_TEXT_COMMAND % + {'filename': filename}) + plaintext = cmd.read() + rtn = cmd.close() + if rtn: + syslog('error', 'HTML->text/plain error: %s', rtn) + finally: + try: + os.unlink(filename) + except OSError, e: + if e.errno <> errno.ENOENT: raise + # Now replace the payload of the subpart and twiddle the Content-Type: + subpart.set_payload(plaintext) + subpart.set_type('text/plain') + changedp = 1 + return changedp + + + +def dispose(mlist, msg, msgdata, why): + # filter_action == 0 just discards, see below + if mlist.filter_action == 1: + # Bounce the message to the original author + raise Errors.RejectMessage, why + if mlist.filter_action == 2: + # Forward it on to the list owner + listname = mlist.internal_name() + mlist.ForwardMessage( + msg, + text=_("""\ +The attached message matched the %(listname)s mailing list's content filtering +rules and was prevented from being forwarded on to the list membership. You +are receiving the only remaining copy of the discarded message. + +"""), + subject=_('Content filtered message notification')) + if mlist.filter_action == 3 and \ + mm_cfg.OWNERS_CAN_PRESERVE_FILTERED_MESSAGES: + badq = get_switchboard(mm_cfg.BADQUEUE_DIR) + badq.enqueue(msg, msgdata) + # Most cases also discard the message + raise Errors.DiscardMessage diff --git a/Mailman/Handlers/Moderate.py b/Mailman/Handlers/Moderate.py new file mode 100644 index 00000000..d44cb89b --- /dev/null +++ b/Mailman/Handlers/Moderate.py @@ -0,0 +1,164 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Posting moderation filter. +""" + +import re +from email.MIMEMessage import MIMEMessage +from email.MIMEText import MIMEText + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import Message +from Mailman import Errors +from Mailman.i18n import _ +from Mailman.Handlers import Hold +from Mailman.Logging.Syslog import syslog + + + +class ModeratedMemberPost(Hold.ModeratedPost): + # BAW: I wanted to use the reason below to differentiate between this + # situation and normal ModeratedPost reasons. Greg Ward and Stonewall + # Ballard thought the language was too harsh and mentioned offense taken + # by some list members. I'd still like this class's reason to be + # different than the base class's reason, but we'll use this until someone + # can come up with something more clever but inoffensive. + # + # reason = _('Posts by member are currently quarantined for moderation') + pass + + + +def process(mlist, msg, msgdata): + if msgdata.get('approved'): + return + # First of all, is the poster a member or not? + for sender in msg.get_senders(): + if mlist.isMember(sender): + break + else: + sender = None + if sender: + # If the member's moderation flag is on, then perform the moderation + # action. + if mlist.getMemberOption(sender, mm_cfg.Moderate): + # Note that for member_moderation_action, 0==Hold, 1=Reject, + # 2==Discard + if mlist.member_moderation_action == 0: + # Hold. BAW: WIBNI we could add the member_moderation_notice + # to the notice sent back to the sender? + msgdata['sender'] = sender + Hold.hold_for_approval(mlist, msg, msgdata, + ModeratedMemberPost) + elif mlist.member_moderation_action == 1: + # Reject + text = mlist.member_moderation_notice + if text: + text = Utils.wrap(text) + else: + # Use the default RejectMessage notice string + text = None + raise Errors.RejectMessage, text + elif mlist.member_moderation_action == 2: + # Discard. BAW: Again, it would be nice if we could send a + # discard notice to the sender + raise Errors.DiscardMessage + else: + assert 0, 'bad member_moderation_action' + # Should we do anything explict to mark this message as getting past + # this point? No, because further pipeline handlers will need to do + # their own thing. + return + else: + sender = msg.get_sender() + # From here on out, we're dealing with non-members. + if matches_p(sender, mlist.accept_these_nonmembers): + return + if matches_p(sender, mlist.hold_these_nonmembers): + Hold.hold_for_approval(mlist, msg, msgdata, Hold.NonMemberPost) + # No return + if matches_p(sender, mlist.reject_these_nonmembers): + do_reject(mlist) + # No return + if matches_p(sender, mlist.discard_these_nonmembers): + do_discard(mlist, msg) + # No return + # Okay, so the sender wasn't specified explicitly by any of the non-member + # moderation configuration variables. Handle by way of generic non-member + # action. + assert 0 <= mlist.generic_nonmember_action <= 4 + if mlist.generic_nonmember_action == 0: + # Accept + return + elif mlist.generic_nonmember_action == 1: + Hold.hold_for_approval(mlist, msg, msgdata, Hold.NonMemberPost) + elif mlist.generic_nonmember_action == 2: + do_reject(mlist) + elif mlist.generic_nonmember_action == 3: + do_discard(mlist, msg) + + + +def matches_p(sender, nonmembers): + # First strip out all the regular expressions + plainaddrs = [addr for addr in nonmembers if not addr.startswith('^')] + addrdict = Utils.List2Dict(plainaddrs, foldcase=1) + if addrdict.has_key(sender): + return 1 + # Now do the regular expression matches + for are in nonmembers: + if are.startswith('^'): + try: + cre = re.compile(are, re.IGNORECASE) + except re.error: + continue + if cre.search(sender): + return 1 + return 0 + + + +def do_reject(mlist): + listowner = mlist.GetOwnerEmail() + raise Errors.RejectMessage, Utils.wrap(_("""\ +You are not allowed to post to this mailing list, and your message has been +automatically rejected. If you think that your messages are being rejected in +error, contact the mailing list owner at %(listowner)s.""")) + + + +def do_discard(mlist, msg): + sender = msg.get_sender() + # Do we forward auto-discards to the list owners? + if mlist.forward_auto_discards: + lang = mlist.preferred_language + varhelp = '%s/?VARHELP=privacy/sender/discard_these_nonmembers' % \ + mlist.GetScriptURL('admin', absolute=1) + nmsg = Message.UserNotification(mlist.GetOwnerEmail(), + mlist.GetBouncesEmail(), + _('Auto-discard notification'), + lang=lang) + nmsg.set_type('multipart/mixed') + text = MIMEText(Utils.wrap(_( + 'The attached message has been automatically discarded.')), + _charset=Utils.GetCharSet(lang)) + nmsg.attach(text) + nmsg.attach(MIMEMessage(msg)) + nmsg.send(mlist) + # Discard this sucker + raise Errors.DiscardMessage diff --git a/Mailman/Handlers/OwnerRecips.py b/Mailman/Handlers/OwnerRecips.py new file mode 100644 index 00000000..c0a54f3d --- /dev/null +++ b/Mailman/Handlers/OwnerRecips.py @@ -0,0 +1,27 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Calculate the list owner recipients (includes moderators). +""" + + + +def process(mlist, msg, msgdata): + # The recipients are the owner and the moderator + msgdata['recips'] = mlist.owner + mlist.moderator + # Don't decorate these messages with the header/footers + msgdata['nodecorate'] = 1 + msgdata['personalize'] = 0 diff --git a/Mailman/Handlers/Replybot.py b/Mailman/Handlers/Replybot.py new file mode 100644 index 00000000..8a9be5cb --- /dev/null +++ b/Mailman/Handlers/Replybot.py @@ -0,0 +1,120 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Handler for auto-responses. +""" + +import time + +from Mailman import Utils +from Mailman import Message +from Mailman.i18n import _ +from Mailman.SafeDict import SafeDict +from Mailman.Logging.Syslog import syslog + + + +def process(mlist, msg, msgdata): + # Normally, the replybot should get a shot at this message, but there are + # some important short-circuits, mostly to suppress 'bot storms, at least + # for well behaved email bots (there are other governors for misbehaving + # 'bots). First, if the original message has an "X-Ack: No" header, we + # skip the replybot. Then, if the message has a Precedence header with + # values bulk, junk, or list, and there's no explicit "X-Ack: yes" header, + # we short-circuit. Finally, if the message metadata has a true 'noack' + # key, then we skip the replybot too. + ack = msg.get('x-ack', '').lower() + if ack == 'no' or msgdata.get('noack'): + return + precedence = msg.get('precedence', '').lower() + if ack <> 'yes' and precedence in ('bulk', 'junk', 'list'): + return + # Check to see if the list is even configured to autorespond to this email + # message. Note: the mailowner script sets the `toadmin' or `toowner' key + # (which for replybot purposes are equivalent), and the mailcmd script + # sets the `torequest' key. + toadmin = msgdata.get('toowner') + torequest = msgdata.get('torequest') + if ((toadmin and not mlist.autorespond_admin) or + (torequest and not mlist.autorespond_requests) or \ + (not toadmin and not torequest and not mlist.autorespond_postings)): + return + # Now see if we're in the grace period for this sender. graceperiod <= 0 + # means always autorespond, as does an "X-Ack: yes" header (useful for + # debugging). + sender = msg.get_sender() + now = time.time() + graceperiod = mlist.autoresponse_graceperiod + if graceperiod > 0 and ack <> 'yes': + if toadmin: + quiet_until = mlist.admin_responses.get(sender, 0) + elif torequest: + quiet_until = mlist.request_responses.get(sender, 0) + else: + quiet_until = mlist.postings_responses.get(sender, 0) + if quiet_until > now: + return + # + # Okay, we know we're going to auto-respond to this sender, craft the + # message, send it, and update the database. + realname = mlist.real_name + subject = _('Auto-response for your message to ') + \ + msg.get('to', _('the "%(realname)s" mailing list')) + # Do string interpolation + d = SafeDict({'listname' : realname, + 'listurl' : mlist.GetScriptURL('listinfo'), + 'requestemail': mlist.GetRequestEmail(), + # BAW: Deprecate adminemail; it's not advertised but still + # supported for backwards compatibility. + 'adminemail' : mlist.GetBouncesEmail(), + 'owneremail' : mlist.GetOwnerEmail(), + }) + # Just because we're using a SafeDict doesn't mean we can't get all sorts + # of other exceptions from the string interpolation. Let's be ultra + # conservative here. + if toadmin: + rtext = mlist.autoresponse_admin_text + elif torequest: + rtext = mlist.autoresponse_request_text + else: + rtext = mlist.autoresponse_postings_text + # Using $-strings? + if getattr(mlist, 'use_dollar_strings', 0): + rtext = Utils.to_percent(rtext) + try: + text = rtext % d + except Exception: + syslog('error', 'Bad autoreply text for list: %s\n%s', + mlist.internal_name(), rtext) + text = rtext + # Wrap the response. + text = Utils.wrap(text) + outmsg = Message.UserNotification(sender, mlist.GetBouncesEmail(), + subject, text, mlist.preferred_language) + outmsg['X-Mailer'] = _('The Mailman Replybot') + # prevent recursions and mail loops! + outmsg['X-Ack'] = 'No' + outmsg.send(mlist) + # update the grace period database + if graceperiod > 0: + # graceperiod is in days, we need # of seconds + quiet_until = now + graceperiod * 24 * 60 * 60 + if toadmin: + mlist.admin_responses[sender] = quiet_until + elif torequest: + mlist.request_responses[sender] = quiet_until + else: + mlist.postings_responses[sender] = quiet_until diff --git a/Mailman/Handlers/SMTPDirect.py b/Mailman/Handlers/SMTPDirect.py new file mode 100644 index 00000000..3033fdbd --- /dev/null +++ b/Mailman/Handlers/SMTPDirect.py @@ -0,0 +1,349 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Local SMTP direct drop-off. + +This module delivers messages via SMTP to a locally specified daemon. This +should be compatible with any modern SMTP server. It is expected that the MTA +handles all final delivery. We have to play tricks so that the list object +isn't locked while delivery occurs synchronously. + +Note: This file only handles single threaded delivery. See SMTPThreaded.py +for a threaded implementation. +""" + +import time +import socket +import smtplib +from types import UnicodeType + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import Errors +from Mailman.Handlers import Decorate +from Mailman.Logging.Syslog import syslog +from Mailman.SafeDict import MsgSafeDict + +import email +from email.Utils import formataddr +from email.Header import Header +from email.Charset import Charset + +DOT = '.' + + + +# Manage a connection to the SMTP server +class Connection: + def __init__(self): + self.__connect() + + def __connect(self): + self.__conn = smtplib.SMTP() + self.__conn.connect(mm_cfg.SMTPHOST, mm_cfg.SMTPPORT) + self.__numsessions = mm_cfg.SMTP_MAX_SESSIONS_PER_CONNECTION + + def sendmail(self, envsender, recips, msgtext): + try: + results = self.__conn.sendmail(envsender, recips, msgtext) + except smtplib.SMTPException: + # For safety, reconnect + self.__conn.quit() + self.__connect() + # Let exceptions percolate up + raise + # Decrement the session counter, reconnecting if necessary + self.__numsessions -= 1 + # By testing exactly for equality to 0, we automatically handle the + # case for SMTP_MAX_SESSIONS_PER_CONNECTION <= 0 meaning never close + # the connection. We won't worry about wraparound . + if self.__numsessions == 0: + self.__conn.quit() + self.__connect() + return results + + def quit(self): + self.__conn.quit() + + + +def process(mlist, msg, msgdata): + recips = msgdata.get('recips') + if not recips: + # Nobody to deliver to! + return + # Calculate the non-VERP envelope sender. + envsender = msgdata.get('envsender') + if envsender is None: + if mlist: + envsender = mlist.GetBouncesEmail() + else: + envsender = Utils.get_site_email(extra='bounces') + # Time to split up the recipient list. If we're personalizing or VERPing + # then each chunk will have exactly one recipient. We'll then hand craft + # an envelope sender and stitch a message together in memory for each one + # separately. If we're not VERPing, then we'll chunkify based on + # SMTP_MAX_RCPTS. Note that most MTAs have a limit on the number of + # recipients they'll swallow in a single transaction. + deliveryfunc = None + if (not msgdata.has_key('personalize') or msgdata['personalize']) and ( + msgdata.get('verp') or mlist.personalize): + chunks = [[recip] for recip in recips] + msgdata['personalize'] = 1 + deliveryfunc = verpdeliver + elif mm_cfg.SMTP_MAX_RCPTS <= 0: + chunks = [recips] + else: + chunks = chunkify(recips, mm_cfg.SMTP_MAX_RCPTS) + # See if this is an unshunted message for which some were undelivered + if msgdata.has_key('undelivered'): + chunks = msgdata['undelivered'] + # If we're doing bulk delivery, then we can stitch up the message now. + if deliveryfunc is None: + # Be sure never to decorate the message more than once! + if not msgdata.get('decorated'): + Decorate.process(mlist, msg, msgdata) + msgdata['decorated'] = 1 + deliveryfunc = bulkdeliver + refused = {} + t0 = time.time() + # Open the initial connection + origrecips = msgdata['recips'] + # `undelivered' is a copy of chunks that we pop from to do deliveries. + # This seems like a good tradeoff between robustness and resource + # utilization. If delivery really fails (i.e. qfiles/shunt type + # failures), then we'll pick up where we left off with `undelivered'. + # This means at worst, the last chunk for which delivery was attempted + # could get duplicates but not every one, and no recips should miss the + # message. + conn = Connection() + try: + msgdata['undelivered'] = chunks + while chunks: + chunk = chunks.pop() + msgdata['recips'] = chunk + try: + deliveryfunc(mlist, msg, msgdata, envsender, refused, conn) + except Exception: + # If /anything/ goes wrong, push the last chunk back on the + # undelivered list and re-raise the exception. We don't know + # how many of the last chunk might receive the message, so at + # worst, everyone in this chunk will get a duplicate. Sigh. + chunks.append(chunk) + raise + del msgdata['undelivered'] + finally: + conn.quit() + msgdata['recips'] = origrecips + # Log the successful post + t1 = time.time() + d = MsgSafeDict(msg, {'time' : t1-t0, + # BAW: Urg. This seems inefficient. + 'size' : len(msg.as_string()), + '#recips' : len(recips), + '#refused': len(refused), + 'listname': mlist.internal_name(), + 'sender' : msg.get_sender(), + }) + # We have to use the copy() method because extended call syntax requires a + # concrete dictionary object; it does not allow a generic mapping. It's + # still worthwhile doing the interpolation in syslog() because it'll catch + # any catastrophic exceptions due to bogus format strings. + if mm_cfg.SMTP_LOG_EVERY_MESSAGE: + syslog.write_ex(mm_cfg.SMTP_LOG_EVERY_MESSAGE[0], + mm_cfg.SMTP_LOG_EVERY_MESSAGE[1], kws=d) + + if refused: + if mm_cfg.SMTP_LOG_REFUSED: + syslog.write_ex(mm_cfg.SMTP_LOG_REFUSED[0], + mm_cfg.SMTP_LOG_REFUSED[1], kws=d) + + elif msgdata.get('tolist'): + # Log the successful post, but only if it really was a post to the + # mailing list. Don't log sends to the -owner, or -admin addrs. + # -request addrs should never get here. BAW: it may be useful to log + # the other messages, but in that case, we should probably have a + # separate configuration variable to control that. + if mm_cfg.SMTP_LOG_SUCCESS: + syslog.write_ex(mm_cfg.SMTP_LOG_SUCCESS[0], + mm_cfg.SMTP_LOG_SUCCESS[1], kws=d) + + # Process any failed deliveries. + tempfailures = [] + permfailures = [] + for recip, (code, smtpmsg) in refused.items(): + # DRUMS is an internet draft, but it says: + # + # [RFC-821] incorrectly listed the error where an SMTP server + # exhausts its implementation limit on the number of RCPT commands + # ("too many recipients") as having reply code 552. The correct + # reply code for this condition is 452. Clients SHOULD treat a 552 + # code in this case as a temporary, rather than permanent failure + # so the logic below works. + # + if code >= 500 and code <> 552: + # A permanent failure + permfailures.append(recip) + else: + # Deal with persistent transient failures by queuing them up for + # future delivery. TBD: this could generate lots of log entries! + tempfailures.append(recip) + if mm_cfg.SMTP_LOG_EACH_FAILURE: + d.update({'recipient': recip, + 'failcode' : code, + 'failmsg' : smtpmsg}) + syslog.write_ex(mm_cfg.SMTP_LOG_EACH_FAILURE[0], + mm_cfg.SMTP_LOG_EACH_FAILURE[1], kws=d) + # Return the results + if tempfailures or permfailures: + raise Errors.SomeRecipientsFailed(tempfailures, permfailures) + + + +def chunkify(recips, chunksize): + # First do a simple sort on top level domain. It probably doesn't buy us + # much to try to sort on MX record -- that's the MTA's job. We're just + # trying to avoid getting a max recips error. Split the chunks along + # these lines (as suggested originally by Chuq Von Rospach and slightly + # elaborated by BAW). + chunkmap = {'com': 1, + 'net': 2, + 'org': 2, + 'edu': 3, + 'us' : 3, + 'ca' : 3, + } + buckets = {} + for r in recips: + tld = None + i = r.rfind('.') + if i >= 0: + tld = r[i+1:] + bin = chunkmap.get(tld, 0) + bucket = buckets.get(bin, []) + bucket.append(r) + buckets[bin] = bucket + # Now start filling the chunks + chunks = [] + currentchunk = [] + chunklen = 0 + for bin in buckets.values(): + for r in bin: + currentchunk.append(r) + chunklen = chunklen + 1 + if chunklen >= chunksize: + chunks.append(currentchunk) + currentchunk = [] + chunklen = 0 + if currentchunk: + chunks.append(currentchunk) + currentchunk = [] + chunklen = 0 + return chunks + + + +def verpdeliver(mlist, msg, msgdata, envsender, failures, conn): + for recip in msgdata['recips']: + # We now need to stitch together the message with its header and + # footer. If we're VERPIng, we have to calculate the envelope sender + # for each recipient. Note that the list of recipients must be of + # length 1. + # + # BAW: ezmlm includes the message number in the envelope, used when + # sending a notification to the user telling her how many messages + # they missed due to bouncing. Neat idea. + msgdata['recips'] = [recip] + # Make a copy of the message and decorate + delivery that + msgcopy = email.message_from_string(msg.as_string()) + Decorate.process(mlist, msgcopy, msgdata) + # Calculate the envelope sender, which we may be VERPing + if msgdata.get('verp'): + bmailbox, bdomain = Utils.ParseEmail(envsender) + rmailbox, rdomain = Utils.ParseEmail(recip) + d = {'bounces': bmailbox, + 'mailbox': rmailbox, + 'host' : DOT.join(rdomain), + } + envsender = '%s@%s' % ((mm_cfg.VERP_FORMAT % d), DOT.join(bdomain)) + if mlist.personalize == 2: + # When fully personalizing, we want the To address to point to the + # recipient, not to the mailing list + del msgcopy['to'] + name = None + if mlist.isMember(recip): + name = mlist.getMemberName(recip) + if name: + # Convert the name to an email-safe representation. If the + # name is a byte string, convert it first to Unicode, given + # the character set of the member's language, replacing bad + # characters for which we can do nothing about. Once we have + # the name as Unicode, we can create a Header instance for it + # so that it's properly encoded for email transport. + charset = Utils.GetCharSet(mlist.getMemberLanguage(recip)) + if charset == 'us-ascii': + # Since Header already tries both us-ascii and utf-8, + # let's add something a bit more useful. + charset = 'iso-8859-1' + charset = Charset(charset) + codec = charset.input_codec or 'ascii' + if not isinstance(name, UnicodeType): + name = unicode(name, codec, 'replace') + name = Header(name, charset).encode() + msgcopy['To'] = formataddr((name, recip)) + else: + msgcopy['To'] = recip + # We can flag the mail as a duplicate for each member, if they've + # already received this message, as calculated by Message-ID. See + # AvoidDuplicates.py for details. + del msgcopy['x-mailman-copy'] + if msgdata.get('add-dup-header', {}).has_key(recip): + msgcopy['X-Mailman-Copy'] = 'yes' + # For the final delivery stage, we can just bulk deliver to a party of + # one. ;) + bulkdeliver(mlist, msgcopy, msgdata, envsender, failures, conn) + + + +def bulkdeliver(mlist, msg, msgdata, envsender, failures, conn): + # Do some final cleanup of the message header. Start by blowing away + # any the Sender: and Errors-To: headers so remote MTAs won't be + # tempted to delivery bounces there instead of our envelope sender + del msg['sender'] + del msg['errors-to'] + msg['Sender'] = envsender + msg['Errors-To'] = envsender + # Get the plain, flattened text of the message, sans unixfrom + msgtext = msg.as_string() + refused = {} + recips = msgdata['recips'] + try: + # Send the message + refused = conn.sendmail(envsender, recips, msgtext) + except smtplib.SMTPRecipientsRefused, e: + refused = e.recipients + # MTA not responding, or other socket problems, or any other kind of + # SMTPException. In that case, nothing got delivered + except (socket.error, smtplib.SMTPException), e: + # BAW: should this be configurable? + syslog('smtp', 'All recipients refused: %s', e) + # If the exception had an associated error code, use it, otherwise, + # fake it with a non-triggering exception code + errcode = getattr(e, 'smtp_code', -1) + errmsg = getattr(e, 'smtp_error', 'ignore') + for r in recips: + refused[r] = (errcode, errmsg) + failures.update(refused) diff --git a/Mailman/Handlers/Scrubber.py b/Mailman/Handlers/Scrubber.py new file mode 100644 index 00000000..5dabadf3 --- /dev/null +++ b/Mailman/Handlers/Scrubber.py @@ -0,0 +1,400 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Cleanse a message for archiving. +""" + +import os +import re +import sha +import time +import errno +import binascii +import tempfile +import mimetypes +from cStringIO import StringIO +from types import IntType + +from email.Utils import parsedate +from email.Parser import HeaderParser +from email.Generator import Generator + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import LockFile +from Mailman import Message +from Mailman.Errors import DiscardMessage +from Mailman.i18n import _ +from Mailman.Logging.Syslog import syslog + +# Path characters for common platforms +pre = re.compile(r'[/\\:]') +# All other characters to strip out of Content-Disposition: filenames +# (essentially anything that isn't an alphanum, dot, slash, or underscore. +sre = re.compile(r'[^-\w.]') +# Regexp to strip out leading dots +dre = re.compile(r'^\.*') + +BR = '
        \n' +SPACE = ' ' + + + +# We're using a subclass of the standard Generator because we want to suppress +# headers in the subparts of multiparts. We use a hack -- the ctor argument +# skipheaders to accomplish this. It's set to true for the outer Message +# object, but false for all internal objects. We recognize that +# sub-Generators will get created passing only mangle_from_ and maxheaderlen +# to the ctors. +# +# This isn't perfect because we still get stuff like the multipart boundaries, +# but see below for how we corrupt that to our nefarious goals. +class ScrubberGenerator(Generator): + def __init__(self, outfp, mangle_from_=1, maxheaderlen=78, skipheaders=1): + Generator.__init__(self, outfp, mangle_from_=0) + self.__skipheaders = skipheaders + + def _write_headers(self, msg): + if not self.__skipheaders: + Generator._write_headers(self, msg) + + +def safe_strftime(fmt, floatsecs): + try: + return time.strftime(fmt, floatsecs) + except ValueError: + return None + + +def calculate_attachments_dir(mlist, msg, msgdata): + # Calculate the directory that attachments for this message will go + # under. To avoid inode limitations, the scheme will be: + # archives/private//attachments/YYYYMMDD// + # Start by calculating the date-based and msgid-hash components. + fmt = '%Y%m%d' + datestr = msg.get('Date') + if datestr: + now = parsedate(datestr) + else: + now = time.gmtime(msgdata.get('received_time', time.time())) + datedir = safe_strftime(fmt, now) + if not datedir: + datestr = msgdata.get('X-List-Received-Date') + if datestr: + datedir = safe_strftime(fmt, datestr) + if not datedir: + # What next? Unixfrom, I guess. + parts = msg.get_unixfrom().split() + try: + month = {'Jan':1, 'Feb':2, 'Mar':3, 'Apr':4, 'May':5, 'Jun':6, + 'Jul':7, 'Aug':8, 'Sep':9, 'Oct':10, 'Nov':11, 'Dec':12, + }.get(parts[3], 0) + day = int(parts[4]) + year = int(parts[6]) + except (IndexError, ValueError): + # Best we can do I think + month = day = year = 0 + datedir = '%04d%02d%02d' % (year, month, day) + assert datedir + # As for the msgid hash, we'll base this part on the Message-ID: so that + # all attachments for the same message end up in the same directory (we'll + # uniquify the filenames in that directory as needed). We use the first 2 + # and last 2 bytes of the SHA1 hash of the message id as the basis of the + # directory name. Clashes here don't really matter too much, and that + # still gives us a 32-bit space to work with. + msgid = msg['message-id'] + if msgid is None: + msgid = msg['Message-ID'] = Utils.unique_message_id(mlist) + # We assume that the message id actually /is/ unique! + digest = sha.new(msgid).hexdigest() + return os.path.join('attachments', datedir, digest[:4] + digest[-4:]) + + + +def process(mlist, msg, msgdata=None): + sanitize = mm_cfg.ARCHIVE_HTML_SANITIZER + outer = 1 + if msgdata is None: + msgdata = {} + dir = calculate_attachments_dir(mlist, msg, msgdata) + charset = None + # Now walk over all subparts of this message and scrub out various types + for part in msg.walk(): + ctype = part.get_type(part.get_default_type()) + # If the part is text/plain, we leave it alone + if ctype == 'text/plain': + # We need to choose a charset for the scrubbed message, so we'll + # arbitrarily pick the charset of the first text/plain part in the + # message. + if charset is None: + charset = part.get_content_charset(charset) + elif ctype == 'text/html' and isinstance(sanitize, IntType): + if sanitize == 0: + if outer: + raise DiscardMessage + part.set_payload(_('HTML attachment scrubbed and removed')) + part.set_type('text/plain') + elif sanitize == 2: + # By leaving it alone, Pipermail will automatically escape it + pass + elif sanitize == 3: + # Pull it out as an attachment but leave it unescaped. This + # is dangerous, but perhaps useful for heavily moderated + # lists. + omask = os.umask(002) + try: + url = save_attachment(mlist, part, dir, filter_html=0) + finally: + os.umask(omask) + part.set_payload(_("""\ +An HTML attachment was scrubbed... +URL: %(url)s +""")) + part.set_type('text/plain') + else: + # HTML-escape it and store it as an attachment, but make it + # look a /little/ bit prettier. :( + payload = Utils.websafe(part.get_payload(decode=1)) + # For whitespace in the margin, change spaces into + # non-breaking spaces, and tabs into 8 of those. Then use a + # mono-space font. Still looks hideous to me, but then I'd + # just as soon discard them. + def doreplace(s): + return s.replace(' ', ' ').replace('\t', ' '*8) + lines = [doreplace(s) for s in payload.split('\n')] + payload = '\n' + BR.join(lines) + '\n\n' + part.set_payload(payload) + # We're replacing the payload with the decoded payload so this + # will just get in the way. + del part['content-transfer-encoding'] + omask = os.umask(002) + try: + url = save_attachment(mlist, part, dir, filter_html=0) + finally: + os.umask(omask) + part.set_payload(_("""\ +An HTML attachment was scrubbed... +URL: %(url)s +""")) + part.set_type('text/plain') + elif ctype == 'message/rfc822': + # This part contains a submessage, so it too needs scrubbing + submsg = part.get_payload(0) + omask = os.umask(002) + try: + url = save_attachment(mlist, part, dir) + finally: + os.umask(omask) + subject = submsg.get('subject', _('no subject')) + date = submsg.get('date', _('no date')) + who = submsg.get('from', _('unknown sender')) + size = len(str(submsg)) + part.set_payload(_("""\ +An embedded message was scrubbed... +From: %(who)s +Subject: %(subject)s +Date: %(date)s +Size: %(size)s +Url: %(url)s +""")) + part.set_type('text/plain') + # If the message isn't a multipart, then we'll strip it out as an + # attachment that would have to be separately downloaded. Pipermail + # will transform the url into a hyperlink. + elif not part.is_multipart(): + payload = part.get_payload() + ctype = part.get_type() + size = len(payload) + omask = os.umask(002) + try: + url = save_attachment(mlist, part, dir) + finally: + os.umask(omask) + desc = part.get('content-description', _('not available')) + filename = part.get_filename(_('not available')) + part.set_payload(_("""\ +A non-text attachment was scrubbed... +Name: %(filename)s +Type: %(ctype)s +Size: %(size)d bytes +Desc: %(desc)s +Url : %(url)s +""")) + part.set_type('text/plain') + outer = 0 + # We still have to sanitize multipart messages to flat text because + # Pipermail can't handle messages with list payloads. This is a kludge; + # def (n) clever hack ;). + if msg.is_multipart(): + # By default we take the charset of the first text/plain part in the + # message, but if there was none, we'll use the list's preferred + # language's charset. + if charset is None: + charset = Utils.GetCharSet(mlist.preferred_language) + # We now want to concatenate all the parts which have been scrubbed to + # text/plain, into a single text/plain payload. We need to make sure + # all the characters in the concatenated string are in the same + # encoding, so we'll use the 'replace' key in the coercion call. + # BAW: Martin's original patch suggested we might want to try + # generalizing to utf-8, and that's probably a good idea (eventually). + text = [] + for part in msg.get_payload(): + # All parts should be scrubbed to text/plain by now. + partctype = part.get_content_type() + if partctype <> 'text/plain': + text.append(_('Skipped content of type %(partctype)s')) + continue + try: + t = part.get_payload(decode=1) + except binascii.Error: + t = part.get_payload() + partcharset = part.get_charset() + if partcharset and partcharset <> charset: + try: + t = unicode(t, partcharset, 'replace') + # Should use HTML-Escape, or try generalizing to UTF-8 + t = t.encode(charset, 'replace') + except UnicodeError: + # Replace funny characters + t = unicode(t, 'ascii', 'replace').encode('ascii') + text.append(t) + # Now join the text and set the payload + sep = _('-------------- next part --------------\n') + msg.set_payload(sep.join(text), charset) + msg.set_type('text/plain') + del msg['content-transfer-encoding'] + msg.add_header('Content-Transfer-Encoding', '8bit') + return msg + + + +def makedirs(dir): + # Create all the directories to store this attachment in + try: + os.makedirs(dir, 02775) + except OSError, e: + if e.errno <> errno.EEXIST: raise + # Unfortunately, FreeBSD seems to be broken in that it doesn't honor the + # mode arg of mkdir(). + def twiddle(arg, dirname, names): + os.chmod(dirname, 02775) + os.path.walk(dir, twiddle, None) + + + +def save_attachment(mlist, msg, dir, filter_html=1): + fsdir = os.path.join(mlist.archive_dir(), dir) + makedirs(fsdir) + # Figure out the attachment type and get the decoded data + decodedpayload = msg.get_payload(decode=1) + # BAW: mimetypes ought to handle non-standard, but commonly found types, + # e.g. image/jpg (should be image/jpeg). For now we just store such + # things as application/octet-streams since that seems the safest. + ext = mimetypes.guess_extension(msg.get_type()) + if not ext: + # We don't know what it is, so assume it's just a shapeless + # application/octet-stream, unless the Content-Type: is + # message/rfc822, in which case we know we'll coerce the type to + # text/plain below. + if msg.get_type() == 'message/rfc822': + ext = '.txt' + else: + ext = '.bin' + path = None + # We need a lock to calculate the next attachment number + lockfile = os.path.join(fsdir, 'attachments.lock') + lock = LockFile.LockFile(lockfile) + lock.lock() + try: + # Now base the filename on what's in the attachment, uniquifying it if + # necessary. + filename = msg.get_filename() + if not filename: + filebase = 'attachment' + else: + # Sanitize the filename given in the message headers + parts = pre.split(filename) + filename = parts[-1] + # Strip off leading dots + filename = dre.sub('', filename) + # Allow only alphanumerics, dash, underscore, and dot + filename = sre.sub('', filename) + # If the filename's extension doesn't match the type we guessed, + # which one should we go with? For now, let's go with the one we + # guessed so attachments can't lie about their type. Also, if the + # filename /has/ no extension, then tack on the one we guessed. + filebase, ignore = os.path.splitext(filename) + # Now we're looking for a unique name for this file on the file + # system. If msgdir/filebase.ext isn't unique, we'll add a counter + # after filebase, e.g. msgdir/filebase-cnt.ext + counter = 0 + extra = '' + while 1: + path = os.path.join(fsdir, filebase + extra + ext) + # Generally it is not a good idea to test for file existance + # before just trying to create it, but the alternatives aren't + # wonderful (i.e. os.open(..., O_CREAT | O_EXCL) isn't + # NFS-safe). Besides, we have an exclusive lock now, so we're + # guaranteed that no other process will be racing with us. + if os.path.exists(path): + counter += 1 + extra = '-%04d' % counter + else: + break + finally: + lock.unlock() + # `path' now contains the unique filename for the attachment. There's + # just one more step we need to do. If the part is text/html and + # ARCHIVE_HTML_SANITIZER is a string (which it must be or we wouldn't be + # here), then send the attachment through the filter program for + # sanitization + if filter_html and msg.get_type() == 'text/html': + base, ext = os.path.splitext(path) + tmppath = base + '-tmp' + ext + fp = open(tmppath, 'w') + try: + fp.write(decodedpayload) + fp.close() + cmd = mm_cfg.ARCHIVE_HTML_SANITIZER % {'filename' : tmppath} + progfp = os.popen(cmd, 'r') + decodedpayload = progfp.read() + status = progfp.close() + if status: + syslog('error', + 'HTML sanitizer exited with non-zero status: %s', + status) + finally: + os.unlink(tmppath) + # BAW: Since we've now sanitized the document, it should be plain + # text. Blarg, we really want the sanitizer to tell us what the type + # if the return data is. :( + ext = '.txt' + path = base + '.txt' + # Is it a message/rfc822 attachment? + elif msg.get_type() == 'message/rfc822': + submsg = msg.get_payload() + # BAW: I'm sure we can eventually do better than this. :( + decodedpayload = Utils.websafe(str(submsg)) + fp = open(path, 'w') + fp.write(decodedpayload) + fp.close() + # Now calculate the url + baseurl = mlist.GetBaseArchiveURL() + # Private archives will likely have a trailing slash. Normalize. + if baseurl[-1] <> '/': + baseurl += '/' + url = baseurl + '%s/%s%s%s' % (dir, filebase, extra, ext) + return url diff --git a/Mailman/Handlers/Sendmail.py b/Mailman/Handlers/Sendmail.py new file mode 100644 index 00000000..8bd88697 --- /dev/null +++ b/Mailman/Handlers/Sendmail.py @@ -0,0 +1,116 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Deliver a message via command-line drop-off. + +WARNING WARNING WARNING: This module is provided for example purposes only. +It should not be used in a production environment for reasons described +below. Because of this, you must explicitly enable it with by editing the +code. See the WARN section in the process() function. + +This module delivers the message via the command line interface to the +sendmail program. It should work for sendmail clones like Postfix. It is +expected that sendmail handles final delivery, message queueing, etc. The +recipient list is only trivially split so that the command line is less than +about 3k in size. + +SECURITY WARNING: Because this module uses os.popen(), it goes through the +shell. This module does not scan the arguments for potential exploits and so +it should be considered unsafe for production use. For performance reasons, +it's not recommended either -- use the SMTPDirect delivery module instead, +even if you're using the sendmail MTA. + +DUPLICATES WARNING: Using this module can cause duplicates to be delivered to +your membership, depending on your MTA! E.g. It is known that if you're using +the sendmail MTA, and if a message contains a single dot on a line by itself, +your list members will receive many duplicates. +""" + +import string +import os + +from Mailman import mm_cfg +from Mailman import Errors +from Mailman.Logging.Syslog import syslog + +MAX_CMDLINE = 3000 + + + +def process(mlist, msg, msgdata): + """Process the message object for the given list. + + The message object is an instance of Mailman.Message and must be fully + prepared for delivery (i.e. all the appropriate headers must be set). The + message object can have the following attributes: + + recips - the list of recipients for the message (required) + + This function processes the message by handing off the delivery of the + message to a sendmail (or sendmail clone) program. It can raise a + SendmailHandlerError if an error status was returned by the sendmail + program. + + """ + # WARN: If you've read the warnings above and /still/ insist on using this + # module, you must comment out the following line. I still recommend you + # don't do this! + assert 0, 'Use of the Sendmail.py delivery module is highly discouraged' + recips = msgdata.get('recips') + if not recips: + # Nobody to deliver to! + return + # Use -f to set the envelope sender + cmd = mm_cfg.SENDMAIL_CMD + ' -f ' + mlist.GetBouncesEmail() + ' ' + # make sure the command line is of a manageable size + recipchunks = [] + currentchunk = [] + chunklen = 0 + for r in recips: + currentchunk.append(r) + chunklen = chunklen + len(r) + 1 + if chunklen > MAX_CMDLINE: + recipchunks.append(string.join(currentchunk)) + currentchunk = [] + chunklen = 0 + # pick up the last one + if chunklen: + recipchunks.append(string.join(currentchunk)) + # get all the lines of the message, since we're going to do this over and + # over again + msgtext = str(msg) + msglen = len(msgtext) + # cycle through all chunks + failedrecips = [] + for chunk in recipchunks: + # TBD: SECURITY ALERT. This invokes the shell! + fp = os.popen(cmd + chunk, 'w') + fp.write(msgtext) + status = fp.close() + if status: + errcode = (status & 0xff00) >> 8 + syslog('post', 'post to %s from %s, size=%d, failure=%d', + mlist.internal_name(), msg.get_sender(), + msglen, errcode) + # TBD: can we do better than this? What if only one recipient out + # of the entire chunk failed? + failedrecips.append(chunk) + # Log the successful post + syslog('post', 'post to %s from %s, size=%d, success', + mlist.internal_name(), msg.get_sender(), msglen) + if failedrecips: + msgdata['recips'] = failedrecips + raise Errors.SomeRecipientsFailed diff --git a/Mailman/Handlers/SpamDetect.py b/Mailman/Handlers/SpamDetect.py new file mode 100644 index 00000000..7597b9e9 --- /dev/null +++ b/Mailman/Handlers/SpamDetect.py @@ -0,0 +1,50 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Do more detailed spam detection. + +This module hard codes site wide spam detection. By hacking the +KNOWN_SPAMMERS variable, you can set up more regular expression matches +against message headers. If spam is detected the message is discarded +immediately. + +TBD: This needs to be made more configurable and robust. +""" + +import re + +from Mailman import mm_cfg +from Mailman import Errors + + + +class SpamDetected(Errors.DiscardMessage): + """The message contains known spam""" + + + +def process(mlist, msg, msgdata): + if msgdata.get('approved'): + return + for header, regex in mm_cfg.KNOWN_SPAMMERS: + cre = re.compile(regex, re.IGNORECASE) + value = msg[header] + if not value: + continue + mo = cre.search(value) + if mo: + # we've detected spam, so throw the message away + raise SpamDetected diff --git a/Mailman/Handlers/Tagger.py b/Mailman/Handlers/Tagger.py new file mode 100644 index 00000000..46a03f67 --- /dev/null +++ b/Mailman/Handlers/Tagger.py @@ -0,0 +1,156 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Extract topics from the original mail message. +""" + +import re +import email +import email.Errors +import email.Iterators +import email.Parser + +from Mailman.Logging.Syslog import syslog + +CRNL = '\r\n' +EMPTYSTRING = '' +NLTAB = '\n\t' + + + +def process(mlist, msg, msgdata): + if not mlist.topics_enabled: + return + # Extract the Subject:, Keywords:, and possibly body text + matchlines = [] + matchlines.append(msg.get('subject', None)) + matchlines.append(msg.get('keywords', None)) + if mlist.topics_bodylines_limit == 0: + # Don't scan any body lines + pass + elif mlist.topics_bodylines_limit < 0: + # Scan all body lines + matchlines.extend(scanbody(msg)) + else: + # Scan just some of the body lines + matchlines.extend(scanbody(msg, mlist.topics_bodylines_limit)) + matchlines = filter(None, matchlines) + # For each regular expression in the topics list, see if any of the lines + # of interest from the message match the regexp. If so, the message gets + # added to the specific topics bucket. + hits = {} + for name, pattern, desc, emptyflag in mlist.topics: + cre = re.compile(pattern, re.IGNORECASE | re.VERBOSE) + for line in matchlines: + if cre.search(line): + hits[name] = 1 + break + if hits: + msgdata['topichits'] = hits.keys() + msg['X-Topics'] = NLTAB.join(hits.keys()) + + + +def scanbody(msg, numlines=None): + # We only scan the body of the message if it is of MIME type text/plain, + # or if the outer type is multipart/alternative and there is a text/plain + # part. Anything else, and the body is ignored for header-scan purposes. + found = None + if msg.get_type('text/plain') == 'text/plain': + found = msg + elif msg.is_multipart() and msg.get_type() == 'multipart/alternative': + for found in msg.get_payload(): + if found.get_type('text/plain') == 'text/plain': + break + else: + found = None + if not found: + return [] + # Now that we have a Message object that meets our criteria, let's extract + # the first numlines of body text. + lines = [] + lineno = 0 + reader = list(email.Iterators.body_line_iterator(msg)) + while numlines is None or lineno < numlines: + try: + line = reader.pop(0) + except IndexError: + break + # Blank lines don't count + if not line.strip(): + continue + lineno += 1 + lines.append(line) + # Concatenate those body text lines with newlines, and then create a new + # message object from those lines. + p = _ForgivingParser() + msg = p.parsestr(EMPTYSTRING.join(lines)) + return msg.get_all('subject', []) + msg.get_all('keywords', []) + + + +class _ForgivingParser(email.Parser.HeaderParser): + # Be a little more forgiving about non-header/continuation lines, since + # we'll just read as much as we can from "header-like" lines in the body. + # + # BAW: WIBNI we didn't have to cut-n-paste this whole thing just to + # specialize the way it returns? + def _parseheaders(self, container, fp): + # Parse the headers, returning a list of header/value pairs. None as + # the header means the Unix-From header. + lastheader = '' + lastvalue = [] + lineno = 0 + while 1: + # Don't strip the line before we test for the end condition, + # because whitespace-only header lines are RFC compliant + # continuation lines. + line = fp.readline() + if not line: + break + line = line.splitlines()[0] + if not line: + break + # Ignore the trailing newline + lineno += 1 + # Check for initial Unix From_ line + if line.startswith('From '): + if lineno == 1: + container.set_unixfrom(line) + continue + else: + break + # Header continuation line + if line[0] in ' \t': + if not lastheader: + break + lastvalue.append(line) + continue + # Normal, non-continuation header. BAW: this should check to make + # sure it's a legal header, e.g. doesn't contain spaces. Also, we + # should expose the header matching algorithm in the API, and + # allow for a non-strict parsing mode (that ignores the line + # instead of raising the exception). + i = line.find(':') + if i < 0: + break + if lastheader: + container[lastheader] = NLTAB.join(lastvalue) + lastheader = line[:i] + lastvalue = [line[i+1:].lstrip()] + # Make sure we retain the last header + if lastheader: + container[lastheader] = NLTAB.join(lastvalue) diff --git a/Mailman/Handlers/ToArchive.py b/Mailman/Handlers/ToArchive.py new file mode 100644 index 00000000..dc19f963 --- /dev/null +++ b/Mailman/Handlers/ToArchive.py @@ -0,0 +1,39 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Add the message to the archives.""" + +import time +from cStringIO import StringIO + +from Mailman import mm_cfg +from Mailman.Queue.sbcache import get_switchboard + + + +def process(mlist, msg, msgdata): + # short circuits + if msgdata.get('isdigest') or not mlist.archive: + return + # Common practice seems to favor "X-No-Archive: yes". No other value for + # this header seems to make sense, so we'll just test for it's presence. + # I'm keeping "X-Archive: no" for backwards compatibility. + if msg.has_key('x-no-archive') or msg.get('x-archive', '').lower() == 'no': + return + # Send the message to the archiver queue + archq = get_switchboard(mm_cfg.ARCHQUEUE_DIR) + # Send the message to the queue + archq.enqueue(msg, msgdata) diff --git a/Mailman/Handlers/ToDigest.py b/Mailman/Handlers/ToDigest.py new file mode 100644 index 00000000..d735cd69 --- /dev/null +++ b/Mailman/Handlers/ToDigest.py @@ -0,0 +1,351 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Add the message to the list's current digest and possibly send it. +""" + +# Messages are accumulated to a Unix mailbox compatible file containing all +# the messages destined for the digest. This file must be parsable by the +# mailbox.UnixMailbox class (i.e. it must be ^From_ quoted). +# +# When the file reaches the size threshold, it is moved to the qfiles/digest +# directory and the DigestRunner will craft the MIME, rfc1153, and +# (eventually) URL-subject linked digests from the mbox. + +import os +import re +import time +from types import ListType +from cStringIO import StringIO + +from email.Parser import Parser +from email.Generator import Generator +from email.MIMEBase import MIMEBase +from email.MIMEText import MIMEText +from email.MIMEMessage import MIMEMessage +from email.Utils import getaddresses + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import Message +from Mailman import i18n +from Mailman.MemberAdaptor import ENABLED +from Mailman.Handlers.Decorate import decorate +from Mailman.Queue.sbcache import get_switchboard +from Mailman.Mailbox import Mailbox + +_ = i18n._ + + +# rfc1153 says we should keep only these headers, and present them in this +# exact order. +KEEP = ['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, but they don't need to be kept from + # the original message: Message + ] + + + +def process(mlist, msg, msgdata): + # Short circuit non-digestable lists. + if not mlist.digestable or msgdata.get('isdigest'): + return + mboxfile = os.path.join(mlist.fullpath(), 'digest.mbox') + omask = os.umask(007) + try: + mboxfp = open(mboxfile, 'a+') + finally: + os.umask(omask) + g = Generator(mboxfp) + g(msg, unixfrom=1) + # Calculate the current size of the accumulation file. This will not tell + # us exactly how big the MIME, rfc1153, or any other generated digest + # message will be, but it's the most easily available metric to decide + # whether the size threshold has been reached. + mboxfp.flush() + size = os.path.getsize(mboxfile) + if size / 1024.0 >= mlist.digest_size_threshhold: + # This is a bit of a kludge to get the mbox file moved to the digest + # queue directory. + mboxfp.seek(0) + send_digests(mlist, mboxfp) + os.unlink(mboxfile) + mboxfp.close() + + + +def send_digests(mlist, mboxfp): + # Set the digest volume and time + if mlist.digest_last_sent_at: + bump = 0 + # See if we should bump the digest volume number + timetup = time.localtime(mlist.digest_last_sent_at) + now = time.localtime(time.time()) + freq = mlist.digest_volume_frequency + if freq == 0 and timetup[0] < now[0]: + # Yearly + bump = 1 + elif freq == 1 and timetup[1] <> now[1]: + # Monthly, but we take a cheap way to calculate this. We assume + # that the clock isn't going to be reset backwards. + bump = 1 + elif freq == 2 and (timetup[1] % 4 <> now[1] % 4): + # Quarterly, same caveat + bump = 1 + elif freq == 3: + # Once again, take a cheap way of calculating this + weeknum_last = int(time.strftime('%W', timetup)) + weeknum_now = int(time.strftime('%W', now)) + if weeknum_now > weeknum_last or timetup[0] > now[0]: + bump = 1 + elif freq == 4 and timetup[7] <> now[7]: + # Daily + bump = 1 + if bump: + mlist.bump_digest_volume() + mlist.digest_last_sent_at = time.time() + # Wrapper around actually digest crafter to set up the language context + # properly. All digests are translated to the list's preferred language. + otranslation = i18n.get_translation() + i18n.set_language(mlist.preferred_language) + try: + send_i18n_digests(mlist, mboxfp) + finally: + i18n.set_translation(otranslation) + + + +def send_i18n_digests(mlist, mboxfp): + mbox = Mailbox(mboxfp) + # Prepare common information + lang = mlist.preferred_language + realname = mlist.real_name + volume = mlist.volume + issue = mlist.next_digest_number + digestid = _('%(realname)s Digest, Vol %(volume)d, Issue %(issue)d') + # Set things up for the MIME digest. Only headers not added by + # CookHeaders need be added here. + mimemsg = Message.Message() + mimemsg['Content-Type'] = 'multipart/mixed' + mimemsg['MIME-Version'] = '1.0' + mimemsg['From'] = mlist.GetRequestEmail() + mimemsg['Subject'] = digestid + mimemsg['To'] = mlist.GetListEmail() + mimemsg['Reply-To'] = mlist.GetListEmail() + # Set things up for the rfc1153 digest + plainmsg = StringIO() + rfc1153msg = Message.Message() + rfc1153msg['From'] = mlist.GetRequestEmail() + rfc1153msg['Subject'] = digestid + rfc1153msg['To'] = mlist.GetListEmail() + rfc1153msg['Reply-To'] = mlist.GetListEmail() + separator70 = '-' * 70 + separator30 = '-' * 30 + # In the rfc1153 digest, the masthead contains the digest boilerplate plus + # any digest header. In the MIME digests, the masthead and digest header + # are separate MIME subobjects. In either case, it's the first thing in + # the digest, and we can calculate it now, so go ahead and add it now. + mastheadtxt = Utils.maketext( + 'masthead.txt', + {'real_name' : mlist.real_name, + 'got_list_email': mlist.GetListEmail(), + 'got_listinfo_url': mlist.GetScriptURL('listinfo', absolute=1), + 'got_request_email': mlist.GetRequestEmail(), + 'got_owner_email': mlist.GetOwnerEmail(), + }, mlist=mlist) + # MIME + masthead = MIMEText(mastheadtxt, _charset=Utils.GetCharSet(lang)) + masthead['Content-Description'] = digestid + mimemsg.attach(masthead) + # rfc1153 + print >> plainmsg, mastheadtxt + print >> plainmsg + # Now add the optional digest header + if mlist.digest_header: + headertxt = decorate(mlist, mlist.digest_header, _('digest header')) + # MIME + header = MIMEText(headertxt) + header['Content-Description'] = _('Digest Header') + mimemsg.attach(header) + # rfc1153 + print >> plainmsg, headertxt + print >> plainmsg + # Now we have to cruise through all the messages accumulated in the + # mailbox file. We can't add these messages to the plainmsg and mimemsg + # yet, because we first have to calculate the table of contents + # (i.e. grok out all the Subjects). Store the messages in a list until + # we're ready for them. + # + # Meanwhile prepare things for the table of contents + toc = StringIO() + print >> toc, _("Today's Topics:\n") + # Now cruise through all the messages in the mailbox of digest messages, + # building the MIME payload and core of the rfc1153 digest. We'll also + # accumulate Subject: headers and authors for the table-of-contents. + messages = [] + msgcount = 0 + msg = mbox.next() + while msg is not None: + if msg == '': + # It was an unparseable message + msg = mbox.next() + msgcount += 1 + messages.append(msg) + # Get the Subject header + subject = msg.get('subject', _('(no subject)')) + # Don't include the redundant subject prefix in the toc + mo = re.match('(re:? *)?(%s)' % re.escape(mlist.subject_prefix), + subject, re.IGNORECASE) + if mo: + subject = subject[:mo.start(2)] + subject[mo.end(2):] + addresses = getaddresses([msg.get('From', '')]) + username = '' + # Take only the first author we find + if type(addresses) is ListType and len(addresses) > 0: + username = addresses[0][0] + if username: + username = ' (%s)' % username + # Wrap the toc subject line + wrapped = Utils.wrap('%2d. %s' % (msgcount, subject)) + # Split by lines and see if the username can fit on the last line + slines = wrapped.split('\n') + if len(slines[-1]) + len(username) > 70: + slines.append(username) + else: + slines[-1] += username + # Add this subject to the accumulating topics + first = 1 + for line in slines: + if first: + print >> toc, ' ', line + first = 0 + else: + print >> toc, ' ', line + # We do not want all the headers of the original message to leak + # through in the digest messages. For simplicity, we'll leave the + # same set of headers in both digests, i.e. those required in rfc1153 + # plus a couple of other useful ones. We also need to reorder the + # headers according to rfc1153. + keeper = {} + for keep in KEEP: + keeper[keep] = msg.get_all(keep, []) + # Now remove all unkempt headers :) + for header in msg.keys(): + del msg[header] + # And add back the kept header in the rfc1153 designated order + for keep in KEEP: + for field in keeper[keep]: + msg[keep] = field + # And a bit of extra stuff + msg['Message'] = `msgcount` + # Get the next message in the digest mailbox + msg = mbox.next() + # Now we're finished with all the messages in the digest. First do some + # sanity checking and then on to adding the toc. + if msgcount == 0: + # Why did we even get here? + return + toctext = toc.getvalue() + # MIME + tocpart = MIMEText(toctext) + tocpart['Content-Description']= _("Today's Topics (%(msgcount)d messages)") + mimemsg.attach(tocpart) + # rfc1153 + print >> plainmsg, toctext + print >> plainmsg + # For rfc1153 digests, we now need the standard separator + print >> plainmsg, separator70 + print >> plainmsg + # Now go through and add each message + mimedigest = MIMEBase('multipart', 'digest') + mimemsg.attach(mimedigest) + first = 1 + for msg in messages: + # MIME + mimedigest.attach(MIMEMessage(msg)) + # rfc1153 + if first: + first = 0 + else: + print >> plainmsg, separator30 + print >> plainmsg + g = Generator(plainmsg) + g(msg, unixfrom=0) + # Now add the footer + if mlist.digest_footer: + footertxt = decorate(mlist, mlist.digest_footer, _('digest footer')) + # MIME + footer = MIMEText(footertxt) + footer['Content-Description'] = _('Digest Footer') + mimemsg.attach(footer) + # rfc1153 + # BAW: This is not strictly conformant rfc1153. The trailer is only + # supposed to contain two lines, i.e. the "End of ... Digest" line and + # the row of asterisks. If this screws up MUAs, the solution is to + # add the footer as the last message in the rfc1153 digest. I just + # hate the way that VM does that and I think it's confusing to users, + # so don't do it unless there's a clamor. + print >> plainmsg, separator30 + print >> plainmsg + print >> plainmsg, footertxt + print >> plainmsg + # Do the last bit of stuff for each digest type + signoff = _('End of ') + digestid + # MIME + # BAW: This stuff is outside the normal MIME goo, and it's what the old + # MIME digester did. No one seemed to complain, probably because you + # won't see it in an MUA that can't display the raw message. We've never + # got complaints before, but if we do, just wax this. It's primarily + # included for (marginally useful) backwards compatibility. + mimemsg.postamble = signoff + # rfc1153 + print >> plainmsg, signoff + print >> plainmsg, '*' * len(signoff) + # Do our final bit of housekeeping, and then send each message to the + # outgoing queue for delivery. + mlist.next_digest_number += 1 + virginq = get_switchboard(mm_cfg.VIRGINQUEUE_DIR) + # Calculate the recipients lists + plainrecips = [] + mimerecips = [] + drecips = mlist.getDigestMemberKeys() + mlist.one_last_digest.keys() + for user in mlist.getMemberCPAddresses(drecips): + # user might be None if someone who toggled off digest delivery + # subsequently unsubscribed from the mailing list. Also, filter out + # folks who have disabled delivery. + if user is None or mlist.getDeliveryStatus(user) <> ENABLED: + continue + # Otherwise, decide whether they get MIME or RFC 1153 digests + if mlist.getMemberOption(user, mm_cfg.DisableMime): + plainrecips.append(user) + else: + mimerecips.append(user) + # Zap this since we're now delivering the last digest to these folks. + mlist.one_last_digest.clear() + # MIME + virginq.enqueue(mimemsg, + recips=mimerecips, + listname=mlist.internal_name(), + isdigest=1) + # rfc1153 + rfc1153msg.set_payload(plainmsg.getvalue()) + virginq.enqueue(rfc1153msg, + recips=plainrecips, + listname=mlist.internal_name(), + isdigest=1) diff --git a/Mailman/Handlers/ToOutgoing.py b/Mailman/Handlers/ToOutgoing.py new file mode 100644 index 00000000..4732b984 --- /dev/null +++ b/Mailman/Handlers/ToOutgoing.py @@ -0,0 +1,55 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Re-queue the message to the outgoing queue. + +This module is only for use by the IncomingRunner for delivering messages +posted to the list membership. Anything else that needs to go out to some +recipient should just be placed in the out queue directly. +""" + +from Mailman import mm_cfg +from Mailman.Queue.sbcache import get_switchboard + + + +def process(mlist, msg, msgdata): + interval = mm_cfg.VERP_DELIVERY_INTERVAL + # Should we VERP this message? If personalization is enabled for this + # list and VERP_PERSONALIZED_DELIVERIES is true, then yes we VERP it. + # Also, if personalization is /not/ enabled, but VERP_DELIVERY_INTERVAL is + # set (and we've hit this interval), then again, this message should be + # VERPed. Otherwise, no. + # + # Note that the verp flag may already be set, e.g. by mailpasswds using + # VERP_PASSWORD_REMINDERS. Preserve any existing verp flag. + if msgdata.has_key('verp'): + pass + elif mlist.personalize: + if mm_cfg.VERP_PERSONALIZED_DELIVERIES: + msgdata['verp'] = 1 + elif interval == 0: + # Never VERP + pass + elif interval == 1: + # VERP every time + msgdata['verp'] = 1 + else: + # VERP every `inteval' number of times + msgdata['verp'] = not int(mlist.post_id) % interval + # And now drop the message in qfiles/out + outq = get_switchboard(mm_cfg.OUTQUEUE_DIR) + outq.enqueue(msg, msgdata, listname=mlist.internal_name()) diff --git a/Mailman/Handlers/ToUsenet.py b/Mailman/Handlers/ToUsenet.py new file mode 100644 index 00000000..2d6755b7 --- /dev/null +++ b/Mailman/Handlers/ToUsenet.py @@ -0,0 +1,44 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Move the message to the mail->news queue.""" + +from Mailman import mm_cfg +from Mailman.Queue.sbcache import get_switchboard +from Mailman.Logging.Syslog import syslog + +COMMASPACE = ', ' + + +def process(mlist, msg, msgdata): + # short circuits + if not mlist.gateway_to_news or \ + msgdata.get('isdigest') or \ + msgdata.get('fromusenet'): + return + # sanity checks + error = [] + if not mlist.linked_newsgroup: + error.append('no newsgroup') + if not mlist.nntp_host: + error.append('no NNTP host') + if error: + syslog('error', 'NNTP gateway improperly configured: %s', + COMMASPACE.join(error)) + return + # Put the message in the news runner's queue + newsq = get_switchboard(mm_cfg.NEWSQUEUE_DIR) + newsq.enqueue(msg, msgdata, listname=mlist.internal_name()) diff --git a/Mailman/Handlers/__init__.py b/Mailman/Handlers/__init__.py new file mode 100644 index 00000000..2cbbabb1 --- /dev/null +++ b/Mailman/Handlers/__init__.py @@ -0,0 +1,15 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. diff --git a/Mailman/ListAdmin.py b/Mailman/ListAdmin.py new file mode 100644 index 00000000..82eedc80 --- /dev/null +++ b/Mailman/ListAdmin.py @@ -0,0 +1,579 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Mixin class for MailList which handles administrative requests. + +Two types of admin requests are currently supported: adding members to a +closed or semi-closed list, and moderated posts. + +Pending subscriptions which are requiring a user's confirmation are handled +elsewhere. +""" + +import os +import time +import marshal +import errno +import cPickle +from cStringIO import StringIO + +import email +from email.MIMEMessage import MIMEMessage +from email.Generator import Generator +from email.Utils import getaddresses + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import Message +from Mailman import Errors +from Mailman.UserDesc import UserDesc +from Mailman.Queue.sbcache import get_switchboard +from Mailman.Logging.Syslog import syslog +from Mailman import i18n + +_ = i18n._ + +# Request types requiring admin approval +IGN = 0 +HELDMSG = 1 +SUBSCRIPTION = 2 +UNSUBSCRIPTION = 3 + +# Return status from __handlepost() +DEFER = 0 +REMOVE = 1 +LOST = 2 + +DASH = '-' +NL = '\n' + + + +class ListAdmin: + def InitVars(self): + # non-configurable data + self.next_request_id = 1 + + def InitTempVars(self): + self.__db = None + + def __filename(self): + return os.path.join(self.fullpath(), 'request.db') + + def __opendb(self): + filename = self.__filename() + if self.__db is None: + assert self.Locked() + try: + fp = open(filename) + self.__db = marshal.load(fp) + fp.close() + except IOError, e: + if e.errno <> errno.ENOENT: raise + self.__db = {} + except EOFError, e: + # The unmarshalling failed, which means the file is corrupt. + # Sigh. Start over. + syslog('error', + 'request.db file corrupt for list %s, blowing it away.', + self.internal_name()) + self.__db = {} + # Migrate pre-2.1a3 held subscription records to include the + # fullname data field. + type, version = self.__db.get('version', (IGN, None)) + if version is None: + # No previous revisiont number, must be upgrading to 2.1a3 or + # beyond from some unknown earlier version. + for id, (type, data) in self.__db.items(): + if id == IGN: + pass + elif id == HELDMSG and len(data) == 5: + # tack on a msgdata dictionary + self.__db[id] = data + ({},) + elif id == SUBSCRIPTION and len(data) == 5: + # a fullname field was added + stime, addr, password, digest, lang = data + self.__db[id] = stime, addr, '', password, digest, lang + + + def __closedb(self): + if self.__db is not None: + assert self.Locked() + # Save the version number + self.__db['version'] = IGN, mm_cfg.REQUESTS_FILE_SCHEMA_VERSION + # Now save a temp file and do the tmpfile->real file dance. BAW: + # should we be as paranoid as for the config.pck file? Should we + # use pickle? + tmpfile = self.__filename() + '.tmp' + omask = os.umask(002) + try: + fp = open(tmpfile, 'w') + marshal.dump(self.__db, fp) + fp.close() + self.__db = None + finally: + os.umask(omask) + # Do the dance + os.rename(tmpfile, self.__filename()) + + def __request_id(self): + id = self.next_request_id + self.next_request_id += 1 + return id + + def SaveRequestsDb(self): + self.__closedb() + + def NumRequestsPending(self): + self.__opendb() + # Subtrace one for the version pseudo-entry + if self.__db.has_key('version'): + return len(self.__db) - 1 + return len(self.__db) + + def __getmsgids(self, rtype): + self.__opendb() + ids = [k for k, (type, data) in self.__db.items() if type == rtype] + ids.sort() + return ids + + def GetHeldMessageIds(self): + return self.__getmsgids(HELDMSG) + + def GetSubscriptionIds(self): + return self.__getmsgids(SUBSCRIPTION) + + def GetUnsubscriptionIds(self): + return self.__getmsgids(UNSUBSCRIPTION) + + def GetRecord(self, id): + self.__opendb() + type, data = self.__db[id] + return data + + def GetRecordType(self, id): + self.__opendb() + type, data = self.__db[id] + return type + + def HandleRequest(self, id, value, comment=None, preserve=None, + forward=None, addr=None): + self.__opendb() + rtype, data = self.__db[id] + if rtype == HELDMSG: + status = self.__handlepost(data, value, comment, preserve, + forward, addr) + elif rtype == UNSUBSCRIPTION: + status = self.__handleunsubscription(data, value, comment) + else: + assert rtype == SUBSCRIPTION + status = self.__handlesubscription(data, value, comment) + if status <> DEFER: + # BAW: Held message ids are linked to Pending cookies, allowing + # the user to cancel their post before the moderator has approved + # it. We should probably remove the cookie associated with this + # id, but we have no way currently of correlating them. :( + del self.__db[id] + + def HoldMessage(self, msg, reason, msgdata={}): + # 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. + newmsgdata = {} + newmsgdata.update(msgdata) + msgdata = newmsgdata + # assure that the database is open for writing + self.__opendb() + # get the next unique id + id = self.__request_id() + assert not self.__db.has_key(id) + # get the message sender + sender = msg.get_sender() + # calculate the file name for the message text and write it to disk + if mm_cfg.HOLD_MESSAGES_AS_PICKLES: + ext = 'pck' + else: + ext = 'txt' + filename = 'heldmsg-%s-%d.%s' % (self.internal_name(), id, ext) + omask = os.umask(002) + fp = None + try: + fp = open(os.path.join(mm_cfg.DATA_DIR, filename), 'w') + if mm_cfg.HOLD_MESSAGES_AS_PICKLES: + cPickle.dump(msg, fp, 1) + else: + g = Generator(fp) + g(msg, 1) + finally: + if fp: + fp.close() + os.umask(omask) + # save the information to the request database. for held message + # entries, each record in the database will be of the following + # format: + # + # the time the message was received + # the sender of the message + # the message's subject + # a string description of the problem + # name of the file in $PREFIX/data containing the msg text + # an additional dictionary of message metadata + # + msgsubject = msg.get('subject', _('(no subject)')) + data = time.time(), sender, msgsubject, reason, filename, msgdata + self.__db[id] = (HELDMSG, data) + return id + + def __handlepost(self, record, value, comment, preserve, forward, addr): + # For backwards compatibility with pre 2.0beta3 + ptime, sender, subject, reason, filename, msgdata = record + path = os.path.join(mm_cfg.DATA_DIR, filename) + # Handle message preservation + if preserve: + parts = os.path.split(path)[1].split(DASH) + parts[0] = 'spam' + spamfile = DASH.join(parts) + # Preserve the message as plain text, not as a pickle + try: + fp = open(path) + except IOError, e: + if e.errno <> errno.ENOENT: raise + return LOST + try: + msg = cPickle.load(fp) + finally: + fp.close() + # Save the plain text to a .msg file, not a .pck file + outpath = os.path.join(mm_cfg.SPAM_DIR, spamfile) + head, ext = os.path.splitext(outpath) + outpath = head + '.msg' + outfp = open(outpath, 'w') + try: + g = Generator(outfp) + g(msg, 1) + finally: + outfp.close() + # Now handle updates to the database + rejection = None + fp = None + msg = None + status = REMOVE + if value == mm_cfg.DEFER: + # Defer + status = DEFER + elif value == mm_cfg.APPROVE: + # Approved. + try: + msg = readMessage(path) + except IOError, e: + if e.errno <> errno.ENOENT: raise + return LOST + msg = readMessage(path) + msgdata['approved'] = 1 + # adminapproved is used by the Emergency handler + msgdata['adminapproved'] = 1 + # Calculate a new filebase for the approved message, otherwise + # delivery errors will cause duplicates. + try: + del msgdata['filebase'] + except KeyError: + pass + # 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'] = email.Utils.formatdate(localtime=1) + syslog('vette', 'held message approved, message-id: %s', + msg.get('message-id', 'n/a')) + # Stick the message back in the incoming queue for further + # processing. + inq = get_switchboard(mm_cfg.INQUEUE_DIR) + inq.enqueue(msg, _metadata=msgdata) + elif value == mm_cfg.REJECT: + # Rejected + rejection = 'Refused' + self.__refuse(_('Posting of your message titled "%(subject)s"'), + sender, comment or _('[No reason given]'), + lang=self.getMemberLanguage(sender)) + else: + assert value == mm_cfg.DISCARD + # Discarded + rejection = 'Discarded' + # Forward the message + if forward and addr: + # If we've approved the message, we need to be sure to craft a + # completely unique second message for the forwarding operation, + # since we don't want to share any state or information with the + # normal delivery. + try: + copy = readMessage(path) + except IOError, e: + if e.errno <> errno.ENOENT: raise + raise Errors.LostHeldMessage(path) + # It's possible the addr is a comma separated list of addresses. + addrs = getaddresses([addr]) + if len(addrs) == 1: + realname, addr = addrs[0] + # 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. + lang = self.getMemberLanguage(addr) + else: + # Throw away the realnames + addr = [a for realname, a in addrs] + # Which member language do we attempt to use? We could use + # the first match or the first address, but in the face of + # ambiguity, let's just use the list's preferred language + lang = self.preferred_language + otrans = i18n.get_translation() + i18n.set_language(lang) + try: + fmsg = Message.UserNotification( + addr, self.GetBouncesEmail(), + _('Forward of moderated message'), + lang=lang) + finally: + i18n.set_translation(otrans) + fmsg.set_type('message/rfc822') + fmsg.attach(copy) + fmsg.send(self) + # Log the rejection + if rejection: + note = '''%(listname)s: %(rejection)s posting: +\tFrom: %(sender)s +\tSubject: %(subject)s''' % { + 'listname' : self.internal_name(), + 'rejection': rejection, + 'sender' : sender.replace('%', '%%'), + 'subject' : subject.replace('%', '%%'), + } + if comment: + note += '\n\tReason: ' + comment.replace('%', '%%') + syslog('vette', note) + # Always unlink the file containing the message text. It's not + # necessary anymore, regardless of the disposition of the message. + if status <> DEFER: + try: + os.unlink(path) + except OSError, e: + if e.errno <> errno.ENOENT: raise + # We lost the message text file. Clean up our housekeeping + # and inform of this status. + return LOST + return status + + def HoldSubscription(self, addr, fullname, password, digest, lang): + # Assure that the database is open for writing + self.__opendb() + # Get the next unique id + id = self.__request_id() + assert not self.__db.has_key(id) + # + # Save the information to the request database. for held subscription + # entries, each record in the database will be one of the following + # format: + # + # the time the subscription request was received + # the subscriber's address + # the subscriber's selected password (TBD: is this safe???) + # the digest flag + # the user's preferred language + # + data = time.time(), addr, fullname, password, digest, lang + self.__db[id] = (SUBSCRIPTION, data) + # + # TBD: this really shouldn't go here but I'm not sure where else is + # appropriate. + syslog('vette', '%s: held subscription request from %s', + self.internal_name(), addr) + # Possibly notify the administrator in default list language + if self.admin_immed_notify: + realname = self.real_name + subject = _( + 'New subscription request to list %(realname)s from %(addr)s') + text = Utils.maketext( + 'subauth.txt', + {'username' : addr, + 'listname' : self.internal_name(), + 'hostname' : self.host_name, + 'admindb_url': self.GetScriptURL('admindb', absolute=1), + }, mlist=self) + # This message should appear to come from the -owner so as + # to avoid any useless bounce processing. + owneraddr = self.GetOwnerEmail() + msg = Message.UserNotification(owneraddr, owneraddr, subject, text, + self.preferred_language) + msg.send(self, **{'tomoderators': 1}) + + def __handlesubscription(self, record, value, comment): + stime, addr, fullname, password, digest, lang = record + if value == mm_cfg.DEFER: + return DEFER + elif value == mm_cfg.DISCARD: + pass + elif value == mm_cfg.REJECT: + self.__refuse(_('Subscription request'), addr, + comment or _('[No reason given]'), + lang=lang) + else: + # subscribe + assert value == mm_cfg.SUBSCRIBE + try: + userdesc = UserDesc(addr, fullname, password, digest, lang) + self.ApprovedAddMember(userdesc) + except Errors.MMAlreadyAMember: + # User has already been subscribed, after sending the request + pass + # TBD: disgusting hack: ApprovedAddMember() can end up closing + # the request database. + self.__opendb() + return REMOVE + + def HoldUnsubscription(self, addr): + # Assure the database is open for writing + self.__opendb() + # Get the next unique id + id = self.__request_id() + assert not self.__db.has_key(id) + # All we need to do is save the unsubscribing address + self.__db[id] = (UNSUBSCRIPTION, addr) + syslog('vette', '%s: held unsubscription request from %s', + self.internal_name(), addr) + # Possibly notify the administrator of the hold + if self.admin_immed_notify: + realname = self.real_name + subject = _( + 'New unsubscription request from %(realname)s by %(addr)s') + text = Utils.maketext( + 'unsubauth.txt', + {'username' : addr, + 'listname' : self.internal_name(), + 'hostname' : self.host_name, + 'admindb_url': self.GetScriptURL('admindb', absolute=1), + }, mlist=self) + # This message should appear to come from the -owner so as + # to avoid any useless bounce processing. + owneraddr = self.GetOwnerEmail() + msg = Message.UserNotification(owneraddr, owneraddr, subject, text, + self.preferred_language) + msg.send(self, **{'tomoderators': 1}) + + def __handleunsubscription(self, record, value, comment): + addr = record + if value == mm_cfg.DEFER: + return DEFER + elif value == mm_cfg.DISCARD: + pass + elif value == mm_cfg.REJECT: + self.__refuse(_('Unsubscription request'), addr, comment) + else: + assert value == mm_cfg.UNSUBSCRIBE + try: + self.ApprovedDeleteMember(addr) + except Errors.NotAMemberError: + # User has already been unsubscribed + pass + return REMOVE + + def __refuse(self, request, recip, comment, origmsg=None, lang=None): + # As this message is going to the requestor, try to set the language + # to his/her language choice, if they are a member. Otherwise use the + # list's preferred language. + realname = self.real_name + if lang is None: + lang = self.getMemberLanguage(recip) + text = Utils.maketext( + 'refuse.txt', + {'listname' : realname, + 'request' : request, + 'reason' : comment, + 'adminaddr': self.GetOwnerEmail(), + }, lang=lang, mlist=self) + otrans = i18n.get_translation() + i18n.set_language(lang) + try: + # add in original message, but not wrap/filled + if origmsg: + text = NL.join( + [text, + '---------- ' + _('Original Message') + ' ----------', + str(origmsg) + ]) + subject = _('Request to mailing list %(realname)s rejected') + finally: + i18n.set_translation(otrans) + msg = Message.UserNotification(recip, self.GetBouncesEmail(), + subject, text, lang) + msg.send(self) + + def _UpdateRecords(self): + # Subscription records have changed since MM2.0.x. In that family, + # the records were of length 4, containing the request time, the + # address, the password, and the digest flag. In MM2.1a2, they grew + # an additional language parameter at the end. In MM2.1a4, they grew + # a fullname slot after the address. This semi-public method is used + # by the update script to coerce all subscription records to the + # latest MM2.1 format. + # + # Held message records have historically either 5 or 6 items too. + # These always include the requests time, the sender, subject, default + # rejection reason, and message text. When of length 6, it also + # includes the message metadata dictionary on the end of the tuple. + self.__opendb() + for id, (type, info) in self.__db.items(): + if type == SUBSCRIPTION: + if len(info) == 4: + # pre-2.1a2 compatibility + when, addr, passwd, digest = info + fullname = '' + lang = self.preferred_language + elif len(info) == 5: + # pre-2.1a4 compatibility + when, addr, passwd, digest, lang = info + fullname = '' + else: + assert len(info) == 6, 'Unknown subscription record layout' + continue + # Here's the new layout + self.__db[id] = when, addr, fullname, passwd, digest, lang + elif type == HELDMSG: + if len(info) == 5: + when, sender, subject, reason, text = info + msgdata = {} + else: + assert len(info) == 6, 'Unknown held msg record layout' + continue + # Here's the new layout + self.__db[id] = when, sender, subject, reason, text, msgdata + # All done + self.__closedb() + + + +def readMessage(path): + # For backwards compatibility, we must be able to read either a flat text + # file or a pickle. + ext = os.path.splitext(path)[1] + fp = open(path) + try: + if ext == '.txt': + msg = email.message_from_file(fp, Message.Message) + else: + assert ext == '.pck' + msg = cPickle.load(fp) + finally: + fp.close() + return msg diff --git a/Mailman/LockFile.py b/Mailman/LockFile.py new file mode 100644 index 00000000..796a81eb --- /dev/null +++ b/Mailman/LockFile.py @@ -0,0 +1,596 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Portable, NFS-safe file locking with timeouts. + +This code implements an NFS-safe file-based locking algorithm influenced by +the GNU/Linux open(2) manpage, under the description of the O_EXCL option. +From RH6.1: + + [...] O_EXCL is broken on NFS file systems, programs which rely on it + for performing locking tasks will contain a race condition. The + solution for performing atomic file locking using a lockfile is to + create a unique file on the same fs (e.g., incorporating hostname and + pid), use link(2) to make a link to the lockfile. If link() returns + 0, the lock is successful. Otherwise, use stat(2) on the unique file + to check if its link count has increased to 2, in which case the lock + is also successful. + +The assumption made here is that there will be no `outside interference', +e.g. no agent external to this code will have access to link() to the affected +lock files. + +LockFile objects support lock-breaking so that you can't wedge a process +forever. This is especially helpful in a web environment, but may not be +appropriate for all applications. + +Locks have a `lifetime', which is the maximum length of time the process +expects to retain the lock. It is important to pick a good number here +because other processes will not break an existing lock until the expected +lifetime has expired. Too long and other processes will hang; too short and +you'll end up trampling on existing process locks -- and possibly corrupting +data. In a distributed (NFS) environment, you also need to make sure that +your clocks are properly synchronized. + +Locks can also log their state to a log file. When running under Mailman, the +log file is placed in a Mailman-specific location, otherwise, the log file is +called `LockFile.log' and placed in the temp directory (calculated from +tempfile.mktemp()). + +""" + +# This code has undergone several revisions, with contributions from Barry +# Warsaw, Thomas Wouters, Harald Meland, and John Viega. It should also work +# well outside of Mailman so it could be used for other Python projects +# requiring file locking. See the __main__ section at the bottom of the file +# for unit testing. + +import os +import socket +import time +import errno +import random +import traceback +from stat import ST_NLINK, ST_MTIME + +# Units are floating-point seconds. +DEFAULT_LOCK_LIFETIME = 15 +# Allowable a bit of clock skew +CLOCK_SLOP = 10 + + + +# Figure out what logfile to use. This is different depending on whether +# we're running in a Mailman context or not. +_logfile = None + +def _get_logfile(): + global _logfile + if _logfile is None: + try: + from Mailman.Logging.StampedLogger import StampedLogger + _logfile = StampedLogger('locks') + except ImportError: + # not running inside Mailman + import tempfile + dir = os.path.split(tempfile.mktemp())[0] + path = os.path.join(dir, 'LockFile.log') + # open in line-buffered mode + class SimpleUserFile: + def __init__(self, path): + self.__fp = open(path, 'a', 1) + self.__prefix = '(%d) ' % os.getpid() + def write(self, msg): + now = '%.3f' % time.time() + self.__fp.write(self.__prefix + now + ' ' + msg) + _logfile = SimpleUserFile(path) + return _logfile + + + +# Exceptions that can be raised by this module +class LockError(Exception): + """Base class for all exceptions in this module.""" + +class AlreadyLockedError(LockError): + """An attempt is made to lock an already locked object.""" + +class NotLockedError(LockError): + """An attempt is made to unlock an object that isn't locked.""" + +class TimeOutError(LockError): + """The timeout interval elapsed before the lock succeeded.""" + + + +class LockFile: + """A portable way to lock resources by way of the file system. + + This class supports the following methods: + + __init__(lockfile[, lifetime[, withlogging]]): + Create the resource lock using lockfile as the global lock file. Each + process laying claim to this resource lock will create their own + temporary lock files based on the path specified by lockfile. + Optional lifetime is the number of seconds the process expects to hold + the lock. Optional withlogging, when true, turns on lockfile logging + (see the module docstring for details). + + set_lifetime(lifetime): + Set a new lock lifetime. This takes affect the next time the file is + locked, but does not refresh a locked file. + + get_lifetime(): + Return the lock's lifetime. + + refresh([newlifetime[, unconditionally]]): + Refreshes the lifetime of a locked file. Use this if you realize that + you need to keep a resource locked longer than you thought. With + optional newlifetime, set the lock's lifetime. Raises NotLockedError + if the lock is not set, unless optional unconditionally flag is set to + true. + + lock([timeout]): + Acquire the lock. This blocks until the lock is acquired unless + optional timeout is greater than 0, in which case, a TimeOutError is + raised when timeout number of seconds (or possibly more) expires + without lock acquisition. Raises AlreadyLockedError if the lock is + already set. + + unlock([unconditionally]): + Relinquishes the lock. Raises a NotLockedError if the lock is not + set, unless optional unconditionally is true. + + locked(): + Return 1 if the lock is set, otherwise 0. To avoid race conditions, + this refreshes the lock (on set locks). + + """ + # BAW: We need to watch out for two lock objects in the same process + # pointing to the same lock file. Without this, if you lock lf1 and do + # not lock lf2, lf2.locked() will still return true. NOTE: this gimmick + # probably does /not/ work in a multithreaded world, but we don't have to + # worry about that, do we? <1 wink>. + COUNTER = 0 + + def __init__(self, lockfile, + lifetime=DEFAULT_LOCK_LIFETIME, + withlogging=0): + """Create the resource lock using lockfile as the global lock file. + + Each process laying claim to this resource lock will create their own + temporary lock files based on the path specified by lockfile. + Optional lifetime is the number of seconds the process expects to hold + the lock. Optional withlogging, when true, turns on lockfile logging + (see the module docstring for details). + + """ + self.__lockfile = lockfile + self.__lifetime = lifetime + # This works because we know we're single threaded + self.__counter = LockFile.COUNTER + LockFile.COUNTER += 1 + self.__tmpfname = '%s.%s.%d.%d' % ( + lockfile, socket.gethostname(), os.getpid(), self.__counter) + self.__withlogging = withlogging + self.__logprefix = os.path.split(self.__lockfile)[1] + # For transferring ownership across a fork. + self.__owned = 1 + + def __repr__(self): + return '' % ( + id(self), self.__lockfile, + self.locked() and 'locked' or 'unlocked', + self.__lifetime, os.getpid()) + + def set_lifetime(self, lifetime): + """Set a new lock lifetime. + + This takes affect the next time the file is locked, but does not + refresh a locked file. + """ + self.__lifetime = lifetime + + def get_lifetime(self): + """Return the lock's lifetime.""" + return self.__lifetime + + def refresh(self, newlifetime=None, unconditionally=0): + """Refreshes the lifetime of a locked file. + + Use this if you realize that you need to keep a resource locked longer + than you thought. With optional newlifetime, set the lock's lifetime. + Raises NotLockedError if the lock is not set, unless optional + unconditionally flag is set to true. + """ + if newlifetime is not None: + self.set_lifetime(newlifetime) + # Do we have the lock? As a side effect, this refreshes the lock! + if not self.locked() and not unconditionally: + raise NotLockedError, '%s: %s' % (repr(self), self.__read()) + + def lock(self, timeout=0): + """Acquire the lock. + + This blocks until the lock is acquired unless optional timeout is + greater than 0, in which case, a TimeOutError is raised when timeout + number of seconds (or possibly more) expires without lock acquisition. + Raises AlreadyLockedError if the lock is already set. + + """ + if timeout: + timeout_time = time.time() + timeout + # Make sure my temp lockfile exists, and that its contents are + # up-to-date (e.g. the temp file name, and the lock lifetime). + self.__write() + # TBD: This next call can fail with an EPERM. I have no idea why, but + # I'm nervous about wrapping this in a try/except. It seems to be a + # very rare occurence, only happens from cron, and (only?) on Solaris + # 2.6. + self.__touch() + self.__writelog('laying claim') + # for quieting the logging output + loopcount = -1 + while 1: + loopcount = loopcount + 1 + # Create the hard link and test for exactly 2 links to the file + try: + os.link(self.__tmpfname, self.__lockfile) + # If we got here, we know we know we got the lock, and never + # had it before, so we're done. Just touch it again for the + # fun of it. + self.__writelog('got the lock') + self.__touch() + break + except OSError, e: + # The link failed for some reason, possibly because someone + # else already has the lock (i.e. we got an EEXIST), or for + # some other bizarre reason. + if e.errno == errno.ENOENT: + # TBD: in some Linux environments, it is possible to get + # an ENOENT, which is truly strange, because this means + # that self.__tmpfname doesn't exist at the time of the + # os.link(), but self.__write() is supposed to guarantee + # that this happens! I don't honestly know why this + # happens, but for now we just say we didn't acquire the + # lock, and try again next time. + pass + elif e.errno <> errno.EEXIST: + # Something very bizarre happened. Clean up our state and + # pass the error on up. + self.__writelog('unexpected link error: %s' % e) + os.unlink(self.__tmpfname) + raise + elif self.__linkcount() <> 2: + # Somebody's messin' with us! Log this, and try again + # later. TBD: should we raise an exception? + self.__writelog('unexpected linkcount: %d' % + self.__linkcount()) + elif self.__read() == self.__tmpfname: + # It was us that already had the link. + self.__writelog('already locked') + raise AlreadyLockedError + # otherwise, someone else has the lock + pass + # We did not acquire the lock, because someone else already has + # it. Have we timed out in our quest for the lock? + if timeout and timeout_time < time.time(): + os.unlink(self.__tmpfname) + self.__writelog('timed out') + raise TimeOutError + # Okay, we haven't timed out, but we didn't get the lock. Let's + # find if the lock lifetime has expired. + if time.time() > self.__releasetime() + CLOCK_SLOP: + # Yes, so break the lock. + self.__break() + self.__writelog('lifetime has expired, breaking') + # Okay, someone else has the lock, our claim hasn't timed out yet, + # and the expected lock lifetime hasn't expired yet. So let's + # wait a while for the owner of the lock to give it up. + elif not loopcount % 100: + self.__writelog('waiting for claim') + self.__sleep() + + def unlock(self, unconditionally=0): + """Unlock the lock. + + If we don't already own the lock (either because of unbalanced unlock + calls, or because the lock was stolen out from under us), raise a + NotLockedError, unless optional `unconditionally' is true. + """ + islocked = self.locked() + if not islocked and not unconditionally: + raise NotLockedError + # If we owned the lock, remove the global file, relinquishing it. + if islocked: + try: + os.unlink(self.__lockfile) + except OSError, e: + if e.errno <> errno.ENOENT: raise + # Remove our tempfile + try: + os.unlink(self.__tmpfname) + except OSError, e: + if e.errno <> errno.ENOENT: raise + self.__writelog('unlocked') + + def locked(self): + """Returns 1 if we own the lock, 0 if we do not. + + Checking the status of the lockfile resets the lock's lifetime, which + helps avoid race conditions during the lock status test. + """ + # Discourage breaking the lock for a while. + try: + self.__touch() + except OSError, e: + if e.errno == errno.EPERM: + # We can't touch the file because we're not the owner. I + # don't see how we can own the lock if we're not the owner. + return 0 + else: + raise + # TBD: can the link count ever be > 2? + if self.__linkcount() <> 2: + return 0 + return self.__read() == self.__tmpfname + + def finalize(self): + self.unlock(unconditionally=1) + + def __del__(self): + if self.__owned: + self.finalize() + + # Use these only if you're transfering ownership to a child process across + # a fork. Use at your own risk, but it should be race-condition safe. + # _transfer_to() is called in the parent, passing in the pid of the + # child. _take_possession() is called in the child, and blocks until the + # parent has transferred possession to the child. _disown() is used to + # set the __owned flag to 0, and it is a disgusting wart necessary to make + # forced lock acquisition work in mailmanctl. :( + def _transfer_to(self, pid): + # First touch it so it won't get broken while we're fiddling about. + self.__touch() + # Find out current claim's temp filename + winner = self.__read() + # Now twiddle ours to the given pid + self.__tmpfname = '%s.%s.%d' % ( + self.__lockfile, socket.gethostname(), pid) + # Create a hard link from the global lock file to the temp file. This + # actually does things in reverse order of normal operation because we + # know that lockfile exists, and tmpfname better not! + os.link(self.__lockfile, self.__tmpfname) + # Now update the lock file to contain a reference to the new owner + self.__write() + # Toggle off our ownership of the file so we don't try to finalize it + # in our __del__() + self.__owned = 0 + # Unlink the old winner, completing the transfer + os.unlink(winner) + # And do some sanity checks + assert self.__linkcount() == 2 + assert self.locked() + self.__writelog('transferred the lock') + + def _take_possession(self): + self.__tmpfname = tmpfname = '%s.%s.%d' % ( + self.__lockfile, socket.gethostname(), os.getpid()) + # Wait until the linkcount is 2, indicating the parent has completed + # the transfer. + while self.__linkcount() <> 2 or self.__read() <> tmpfname: + time.sleep(0.25) + self.__writelog('took possession of the lock') + + def _disown(self): + self.__owned = 0 + + # + # Private interface + # + + def __writelog(self, msg): + if self.__withlogging: + logf = _get_logfile() + logf.write('%s %s\n' % (self.__logprefix, msg)) + traceback.print_stack(file=logf) + + def __write(self): + # Make sure it's group writable + oldmask = os.umask(002) + try: + fp = open(self.__tmpfname, 'w') + fp.write(self.__tmpfname) + fp.close() + finally: + os.umask(oldmask) + + def __read(self): + try: + fp = open(self.__lockfile) + filename = fp.read() + fp.close() + return filename + except EnvironmentError, e: + if e.errno <> errno.ENOENT: raise + return None + + def __touch(self, filename=None): + t = time.time() + self.__lifetime + try: + # TBD: We probably don't need to modify atime, but this is easier. + os.utime(filename or self.__tmpfname, (t, t)) + except OSError, e: + if e.errno <> errno.ENOENT: raise + + def __releasetime(self): + try: + return os.stat(self.__lockfile)[ST_MTIME] + except OSError, e: + if e.errno <> errno.ENOENT: raise + return -1 + + def __linkcount(self): + try: + return os.stat(self.__lockfile)[ST_NLINK] + except OSError, e: + if e.errno <> errno.ENOENT: raise + return -1 + + def __break(self): + # First, touch the global lock file. This reduces but does not + # eliminate the chance for a race condition during breaking. Two + # processes could both pass the test for lock expiry in lock() before + # one of them gets to touch the global lockfile. This shouldn't be + # too bad because all they'll do in this function is wax the lock + # files, not claim the lock, and we can be defensive for ENOENTs + # here. + # + # Touching the lock could fail if the process breaking the lock and + # the process that claimed the lock have different owners. We could + # solve this by set-uid'ing the CGI and mail wrappers, but I don't + # think it's that big a problem. + try: + self.__touch(self.__lockfile) + except OSError, e: + if e.errno <> errno.EPERM: raise + # Get the name of the old winner's temp file. + winner = self.__read() + # Remove the global lockfile, which actually breaks the lock. + try: + os.unlink(self.__lockfile) + except OSError, e: + if e.errno <> errno.ENOENT: raise + # Try to remove the old winner's temp file, since we're assuming the + # winner process has hung or died. Don't worry too much if we can't + # unlink their temp file -- this doesn't wreck the locking algorithm, + # but will leave temp file turds laying around, a minor inconvenience. + try: + if winner: + os.unlink(winner) + except OSError, e: + if e.errno <> errno.ENOENT: raise + + def __sleep(self): + interval = random.random() * 2.0 + 0.01 + time.sleep(interval) + + + +# Unit test framework +def _dochild(): + prefix = '[%d]' % os.getpid() + # Create somewhere between 1 and 1000 locks + lockfile = LockFile('/tmp/LockTest', withlogging=1, lifetime=120) + # Use a lock lifetime of between 1 and 15 seconds. Under normal + # situations, Mailman's usage patterns (untested) shouldn't be much longer + # than this. + workinterval = 5 * random.random() + hitwait = 20 * random.random() + print prefix, 'workinterval:', workinterval + islocked = 0 + t0 = 0 + t1 = 0 + t2 = 0 + try: + try: + t0 = time.time() + print prefix, 'acquiring...' + lockfile.lock() + print prefix, 'acquired...' + islocked = 1 + except TimeOutError: + print prefix, 'timed out' + else: + t1 = time.time() + print prefix, 'acquisition time:', t1-t0, 'seconds' + time.sleep(workinterval) + finally: + if islocked: + try: + lockfile.unlock() + t2 = time.time() + print prefix, 'lock hold time:', t2-t1, 'seconds' + except NotLockedError: + print prefix, 'lock was broken' + # wait for next web hit + print prefix, 'webhit sleep:', hitwait + time.sleep(hitwait) + + +def _seed(): + try: + fp = open('/dev/random') + d = fp.read(40) + fp.close() + except EnvironmentError, e: + if e.errno <> errno.ENOENT: + raise + import sha + d = sha.new(`os.getpid()`+`time.time()`).hexdigest() + random.seed(d) + + +def _onetest(): + loopcount = random.randint(1, 100) + for i in range(loopcount): + print 'Loop %d of %d' % (i+1, loopcount) + pid = os.fork() + if pid: + # parent, wait for child to exit + pid, status = os.waitpid(pid, 0) + else: + # child + _seed() + try: + _dochild() + except KeyboardInterrupt: + pass + os._exit(0) + + +def _reap(kids): + if not kids: + return + pid, status = os.waitpid(-1, os.WNOHANG) + if pid <> 0: + del kids[pid] + + +def _test(numtests): + kids = {} + for i in range(numtests): + pid = os.fork() + if pid: + # parent + kids[pid] = pid + else: + # child + _seed() + try: + _onetest() + except KeyboardInterrupt: + pass + os._exit(0) + # slightly randomize each kid's seed + while kids: + _reap(kids) + + +if __name__ == '__main__': + import sys + import random + _test(int(sys.argv[1])) diff --git a/Mailman/Logging/.cvsignore b/Mailman/Logging/.cvsignore new file mode 100644 index 00000000..f3c7a7c5 --- /dev/null +++ b/Mailman/Logging/.cvsignore @@ -0,0 +1 @@ +Makefile diff --git a/Mailman/Logging/Logger.py b/Mailman/Logging/Logger.py new file mode 100644 index 00000000..0cb7c6af --- /dev/null +++ b/Mailman/Logging/Logger.py @@ -0,0 +1,103 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""File-based logger, writes to named category files in mm_cfg.LOG_DIR.""" + +import sys +import os +import codecs +from types import StringType + +from Mailman import mm_cfg +from Mailman.Logging.Utils import _logexc + +# Set this to the encoding to be used for your log file output. If set to +# None, then it uses your system's default encoding. Otherwise, it must be an +# encoding string appropriate for codecs.open(). +LOG_ENCODING = 'iso-8859-1' + + + +class Logger: + def __init__(self, category, nofail=1, immediate=0): + """nofail says to fallback to sys.__stderr__ if write fails to + category file - a complaint message is emitted, but no exception is + raised. Set nofail=0 if you want to handle the error in your code, + instead. + + immediate=1 says to create the log file on instantiation. + Otherwise, the file is created only when there are writes pending. + """ + self.__filename = os.path.join(mm_cfg.LOG_DIR, category) + self.__fp = None + self.__nofail = nofail + self.__encoding = LOG_ENCODING or sys.getdefaultencoding() + if immediate: + self.__get_f() + + def __del__(self): + self.close() + + def __repr__(self): + return '<%s to %s>' % (self.__class__.__name__, `self.__filename`) + + def __get_f(self): + if self.__fp: + return self.__fp + else: + try: + ou = os.umask(002) + try: + try: + f = codecs.open( + self.__filename, 'a+', self.__encoding, 'replace', + 1) + except LookupError: + f = open(self.__filename, 'a+', 1) + self.__fp = f + finally: + os.umask(ou) + except IOError, e: + if self.__nofail: + _logexc(self, e) + f = self.__fp = sys.__stderr__ + else: + raise + return f + + def flush(self): + f = self.__get_f() + if hasattr(f, 'flush'): + f.flush() + + def write(self, msg): + if isinstance(msg, StringType): + msg = unicode(msg, self.__encoding) + f = self.__get_f() + try: + f.write(msg) + except IOError, msg: + _logexc(self, msg) + + def writelines(self, lines): + for l in lines: + self.write(l) + + def close(self): + if not self.__fp: + return + self.__get_f().close() + self.__fp = None diff --git a/Mailman/Logging/Makefile.in b/Mailman/Logging/Makefile.in new file mode 100644 index 00000000..407f39a9 --- /dev/null +++ b/Mailman/Logging/Makefile.in @@ -0,0 +1,69 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +# NOTE: Makefile.in is converted into Makefile by the configure script +# in the parent directory. Once configure has run, you can recreate +# the Makefile by running just config.status. + +# Variables set by configure + +VPATH= @srcdir@ +srcdir= @srcdir@ +bindir= @bindir@ +prefix= @prefix@ +exec_prefix= @exec_prefix@ + +CC= @CC@ +CHMOD= @CHMOD@ +INSTALL= @INSTALL@ + +DEFS= @DEFS@ + +# Customizable but not set by configure + +OPT= @OPT@ +CFLAGS= $(OPT) $(DEFS) +PACKAGEDIR= $(prefix)/Mailman/Logging +SHELL= /bin/sh + +MODULES= *.py + +# Modes for directories and executables created by the install +# process. Default to group-writable directories but +# user-only-writable for executables. +DIRMODE= 775 +EXEMODE= 755 +FILEMODE= 644 +INSTALL_PROGRAM=$(INSTALL) -m $(EXEMODE) + + +# Rules + +all: + +install: + for f in $(MODULES); \ + do \ + $(INSTALL) -m $(FILEMODE) $(srcdir)/$$f $(PACKAGEDIR); \ + done + +finish: + +clean: + +distclean: + -rm *.pyc + -rm Makefile diff --git a/Mailman/Logging/MultiLogger.py b/Mailman/Logging/MultiLogger.py new file mode 100644 index 00000000..3ff11d27 --- /dev/null +++ b/Mailman/Logging/MultiLogger.py @@ -0,0 +1,76 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""A mutiple sink logger. Any message written goes to all sub-loggers.""" + +import sys +from Mailman.Logging.Utils import _logexc + + + +class MultiLogger: + def __init__(self, *args): + self.__loggers = [] + for logger in args: + self.__loggers.append(logger) + + def add_logger(self, logger): + if logger not in self.__loggers: + self.__loggers.append(logger) + + def del_logger(self, logger): + if logger in self.__loggers: + self.__loggers.remove(logger) + + def write(self, msg): + for logger in self.__loggers: + # you want to be sure that a bug in one logger doesn't prevent + # logging to all the other loggers + try: + logger.write(msg) + except: + _logexc(logger, msg) + + def writelines(self, lines): + for line in lines: + self.write(line) + + def flush(self): + for logger in self.__loggers: + if hasattr(logger, 'flush'): + # you want to be sure that a bug in one logger doesn't prevent + # logging to all the other loggers + try: + logger.flush() + except: + _logexc(logger) + + def close(self): + for logger in self.__loggers: + # you want to be sure that a bug in one logger doesn't prevent + # logging to all the other loggers + try: + if logger <> sys.__stderr__ and logger <> sys.__stdout__: + logger.close() + except: + _logexc(logger) + + def reprime(self): + for logger in self.__loggers: + try: + logger.reprime() + except AttributeError: + pass diff --git a/Mailman/Logging/StampedLogger.py b/Mailman/Logging/StampedLogger.py new file mode 100644 index 00000000..370f1af8 --- /dev/null +++ b/Mailman/Logging/StampedLogger.py @@ -0,0 +1,89 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +import os +import time + +from Mailman.Logging.Logger import Logger + + + +class StampedLogger(Logger): + """Record messages in log files, including date stamp and optional label. + + If manual_reprime is on (off by default), then timestamp prefix will + included only on first .write() and on any write immediately following a + call to the .reprime() method. This is useful for when StampedLogger is + substituting for sys.stderr, where you'd like to see the grouping of + multiple writes under a single timestamp (and there is often is one group, + for uncaught exceptions where a script is bombing). + + In any case, the identifying prefix will only follow writes that start on + a new line. + + Nofail (by default) says to fallback to sys.stderr if write fails to + category file. A message is emitted, but the IOError is caught. + Initialize with nofail=0 if you want to handle the error in your code, + instead. + + """ + def __init__(self, category, label=None, manual_reprime=0, nofail=1, + immediate=1): + """If specified, optional label is included after timestamp. + Other options are passed to the Logger class initializer. + """ + self.__label = label + self.__manual_reprime = manual_reprime + self.__primed = 1 + self.__bol = 1 + Logger.__init__(self, category, nofail, immediate) + + def reprime(self): + """Reset so timestamp will be included with next write.""" + self.__primed = 1 + + def write(self, msg): + if not self.__bol: + prefix = "" + else: + if not self.__manual_reprime or self.__primed: + stamp = time.strftime("%b %d %H:%M:%S %Y ", + time.localtime(time.time())) + self.__primed = 0 + else: + stamp = "" + if self.__label is None: + label = "(%d)" % os.getpid() + else: + label = "%s(%d):" % (self.__label, os.getpid()) + prefix = stamp + label + Logger.write(self, "%s %s" % (prefix, msg)) + if msg and msg[-1] == '\n': + self.__bol = 1 + else: + self.__bol = 0 + + def writelines(self, lines): + first = 1 + for l in lines: + if first: + self.write(l) + first = 0 + else: + if l and l[0] not in [' ', '\t', '\n']: + Logger.write(self, ' ' + l) + else: + Logger.write(self, l) diff --git a/Mailman/Logging/Syslog.py b/Mailman/Logging/Syslog.py new file mode 100644 index 00000000..3e8d557d --- /dev/null +++ b/Mailman/Logging/Syslog.py @@ -0,0 +1,69 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Central logging class for the Mailman system. + +This might eventually be replaced by a syslog based logger, hence the name. +""" + +from Mailman.Logging.StampedLogger import StampedLogger + + + +# Global, shared logger instance. All clients should use this object. +syslog = None + + + +# Don't instantiate except below. +class _Syslog: + def __init__(self): + self._logfiles = {} + + def __del__(self): + self.close() + + def write(self, kind, msg, *args, **kws): + self.write_ex(kind, msg, args, kws) + + # We need this because SMTPDirect tries to pass in a special dict-like + # object, which is not a concrete dictionary. This is not allowed by + # Python's extended call syntax. :( + def write_ex(self, kind, msg, args=None, kws=None): + origmsg = msg + logf = self._logfiles.get(kind) + if not logf: + logf = self._logfiles[kind] = StampedLogger(kind) + try: + if args: + msg %= args + if kws: + msg %= kws + # It's really bad if exceptions in the syslogger cause other crashes + except Exception, e: + msg = 'Bad format "%s": %s: %s' % (origmsg, repr(e), e) + logf.write(msg + '\n') + + # For the ultimate in convenience + __call__ = write + + def close(self): + for kind, logger in self._logfiles.items(): + logger.close() + self._logfiles.clear() + + +syslog = _Syslog() diff --git a/Mailman/Logging/Utils.py b/Mailman/Logging/Utils.py new file mode 100644 index 00000000..ef119fb0 --- /dev/null +++ b/Mailman/Logging/Utils.py @@ -0,0 +1,52 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +import sys +import traceback + + +def _logexc(logger=None, msg=''): + sys.__stderr__.write('Logging error: %s\n' % logger) + traceback.print_exc(file=sys.__stderr__) + sys.__stderr__.write('Original log message:\n%s\n' % msg) + + +def LogStdErr(category, label, manual_reprime=1, tee_to_real_stderr=1): + """Establish a StampedLogger on sys.stderr if possible. + + If tee_to_real_stderr is true, then the real standard error also gets + output, via a MultiLogger. + + Returns the MultiLogger if successful, None otherwise. + """ + from StampedLogger import StampedLogger + from MultiLogger import MultiLogger + try: + logger = StampedLogger(category, + label=label, + manual_reprime=manual_reprime, + nofail=0) + if tee_to_real_stderr: + if hasattr(sys, '__stderr__'): + stderr = sys.__stderr__ + else: + stderr = sys.stderr + logger = MultiLogger(stderr, logger) + sys.stderr = logger + return sys.stderr + except IOError: + return None + diff --git a/Mailman/Logging/__init__.py b/Mailman/Logging/__init__.py new file mode 100644 index 00000000..2cbbabb1 --- /dev/null +++ b/Mailman/Logging/__init__.py @@ -0,0 +1,15 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. diff --git a/Mailman/MTA/.cvsignore b/Mailman/MTA/.cvsignore new file mode 100644 index 00000000..f3c7a7c5 --- /dev/null +++ b/Mailman/MTA/.cvsignore @@ -0,0 +1 @@ +Makefile diff --git a/Mailman/MTA/Makefile.in b/Mailman/MTA/Makefile.in new file mode 100644 index 00000000..42a6fcc5 --- /dev/null +++ b/Mailman/MTA/Makefile.in @@ -0,0 +1,69 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +# NOTE: Makefile.in is converted into Makefile by the configure script +# in the parent directory. Once configure has run, you can recreate +# the Makefile by running just config.status. + +# Variables set by configure + +VPATH= @srcdir@ +srcdir= @srcdir@ +bindir= @bindir@ +prefix= @prefix@ +exec_prefix= @exec_prefix@ + +CC= @CC@ +CHMOD= @CHMOD@ +INSTALL= @INSTALL@ + +DEFS= @DEFS@ + +# Customizable but not set by configure + +OPT= @OPT@ +CFLAGS= $(OPT) $(DEFS) +PACKAGEDIR= $(prefix)/Mailman/MTA +SHELL= /bin/sh + +MODULES= *.py + +# Modes for directories and executables created by the install +# process. Default to group-writable directories but +# user-only-writable for executables. +DIRMODE= 775 +EXEMODE= 755 +FILEMODE= 644 +INSTALL_PROGRAM=$(INSTALL) -m $(EXEMODE) + + +# Rules + +all: + +install: + for f in $(MODULES); \ + do \ + $(INSTALL) -m $(FILEMODE) $(srcdir)/$$f $(PACKAGEDIR); \ + done + +finish: + +clean: + +distclean: + -rm *.pyc + -rm Makefile diff --git a/Mailman/MTA/Manual.py b/Mailman/MTA/Manual.py new file mode 100644 index 00000000..dd9127cc --- /dev/null +++ b/Mailman/MTA/Manual.py @@ -0,0 +1,135 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Creation/deletion hooks for manual /etc/aliases files.""" + +import sys +from cStringIO import StringIO + +from Mailman import mm_cfg +from Mailman import Message +from Mailman import Utils +from Mailman.Queue.sbcache import get_switchboard +from Mailman.i18n import _ +from Mailman.MTA.Utils import makealiases + + + +# no-ops for interface compliance +def makelock(): + class Dummy: + def lock(self): + pass + def unlock(self, unconditionally=0): + pass + return Dummy() + + +def clear(): + pass + + + +# nolock argument is ignored, but exists for interface compliance +def create(mlist, cgi=0, nolock=0): + if mlist is None: + return + listname = mlist.internal_name() + fieldsz = len(listname) + len('-unsubscribe') + if cgi: + # If a list is being created via the CGI, the best we can do is send + # an email message to mailman-owner requesting that the proper aliases + # be installed. + sfp = StringIO() + print >> sfp, _("""\ +The mailing list `%(listname)s' has been created via the through-the-web +interface. In order to complete the activation of this mailing list, the +proper /etc/aliases (or equivalent) file must be updated. The program +`newaliases' may also have to be run. + +Here are the entries for the /etc/aliases file: +""") + outfp = sfp + else: + print _(""" +To finish creating your mailing list, you must edit your /etc/aliases (or +equivalent) file by adding the following lines, and possibly running the +`newaliases' program: + +## %(listname)s mailing list""") + outfp = sys.stdout + # Common path + for k, v in makealiases(listname): + print >> outfp, k + ':', ((fieldsz - len(k)) * ' '), v + # If we're using the command line interface, we're done. For ttw, we need + # to actually send the message to mailman-owner now. + if not cgi: + print >> outfp + return + # Send the message to the site -owner so someone can do something about + # this request. + siteowner = Utils.get_site_email(extra='owner') + # Should this be sent in the site list's preferred language? + msg = Message.UserNotification( + siteowner, siteowner, + _('Mailing list creation request for list %(listname)s'), + sfp.getvalue(), mm_cfg.DEFAULT_SERVER_LANGUAGE) + outq = get_switchboard(mm_cfg.OUTQUEUE_DIR) + outq.enqueue(msg, recips=[siteowner]) + + + +def remove(mlist, cgi=0): + listname = mlist.internal_name() + fieldsz = len(listname) + len('-unsubscribe') + if cgi: + # If a list is being removed via the CGI, the best we can do is send + # an email message to mailman-owner requesting that the appropriate + # aliases be deleted. + sfp = StringIO() + print >> sfp, _("""\ +The mailing list `%(listname)s' has been removed via the through-the-web +interface. In order to complete the de-activation of this mailing list, the +appropriate /etc/aliases (or equivalent) file must be updated. The program +`newaliases' may also have to be run. + +Here are the entries in the /etc/aliases file that should be removed: +""") + outfp = sfp + else: + print _(""" +To finish removing your mailing list, you must edit your /etc/aliases (or +equivalent) file by removing the following lines, and possibly running the +`newaliases' program: + +## %(listname)s mailing list""") + outfp = sys.stdout + # Common path + for k, v in makealiases(listname): + print >> outfp, k + ':', ((fieldsz - len(k)) * ' '), v + # If we're using the command line interface, we're done. For ttw, we need + # to actually send the message to mailman-owner now. + if not cgi: + print >> outfp + return + siteowner = Utils.get_site_email(extra='owner') + # Should this be sent in the site list's preferred language? + msg = Message.UserNotification( + siteowner, siteowner, + _('Mailing list removal request for list %(listname)s'), + sfp.getvalue(), mm_cfg.DEFAULT_SERVER_LANGUAGE) + outq = get_switchboard(mm_cfg.OUTQUEUE_DIR) + outq.enqueue(msg, recips=[siteowner]) diff --git a/Mailman/MTA/Postfix.py b/Mailman/MTA/Postfix.py new file mode 100644 index 00000000..24c1c7e5 --- /dev/null +++ b/Mailman/MTA/Postfix.py @@ -0,0 +1,344 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Creation/deletion hooks for the Postfix MTA. +""" + +import os +import time +import errno +import pwd +import grp +from stat import * + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import LockFile +from Mailman.i18n import _ +from Mailman.MTA.Utils import makealiases +from Mailman.Logging.Syslog import syslog + +LOCKFILE = os.path.join(mm_cfg.LOCK_DIR, 'creator') +ALIASFILE = os.path.join(mm_cfg.DATA_DIR, 'aliases') +VIRTFILE = os.path.join(mm_cfg.DATA_DIR, 'virtual-mailman') + + + +def _update_maps(): + msg = 'command failed: %s (status: %s, %s)' + acmd = mm_cfg.POSTFIX_ALIAS_CMD + ' ' + ALIASFILE + status = (os.system(acmd) >> 8) & 0xff + if status: + errstr = os.strerror(status) + syslog('error', msg, acmd, status, errstr) + raise RuntimeError, msg % (acmd, status, errstr) + if os.path.exists(VIRTFILE): + vcmd = mm_cfg.POSTFIX_MAP_CMD + ' ' + VIRTFILE + status = (os.system(vcmd) >> 8) & 0xff + if status: + errstr = os.strerror(status) + syslog('error', msg, vcmd, status, errstr) + raise RuntimeError, msg % (vcmd, status, errstr) + + + +def makelock(): + return LockFile.LockFile(LOCKFILE) + + +def _zapfile(filename): + # Truncate the file w/o messing with the file permissions, but only if it + # already exists. + if os.path.exists(filename): + fp = open(filename, 'w') + fp.close() + + +def clear(): + _zapfile(ALIASFILE) + _zapfile(VIRTFILE) + + + +def _addlist(mlist, fp): + # Set up the mailman-loop address + loopaddr = Utils.ParseEmail(Utils.get_site_email(extra='loop'))[0] + loopmbox = os.path.join(mm_cfg.DATA_DIR, 'owner-bounces.mbox') + # Seek to the end of the text file, but if it's empty write the standard + # disclaimer, and the loop catch address. + fp.seek(0, 2) + if not fp.tell(): + print >> fp, """\ +# This file is generated by Mailman, and is kept in sync with the +# binary hash file aliases.db. YOU SHOULD NOT MANUALLY EDIT THIS FILE +# unless you know what you're doing, and can keep the two files properly +# in sync. If you screw it up, you're on your own. +""" + print >> fp, '# The ultimate loop stopper address' + print >> fp, '%s: %s' % (loopaddr, loopmbox) + print >> fp + # Bootstrapping. bin/genaliases must be run before any lists are created, + # but if no lists exist yet then mlist is None. The whole point of the + # exercise is to get the minimal aliases.db file into existance. + if mlist is None: + return + listname = mlist.internal_name() + fieldsz = len(listname) + len('-unsubscribe') + # The text file entries get a little extra info + print >> fp, '# STANZA START:', listname + print >> fp, '# CREATED:', time.ctime(time.time()) + # Now add all the standard alias entries + for k, v in makealiases(listname): + # Format the text file nicely + print >> fp, k + ':', ((fieldsz - len(k)) * ' ') + v + # Finish the text file stanza + print >> fp, '# STANZA END:', listname + print >> fp + + + +def _addvirtual(mlist, fp): + listname = mlist.internal_name() + fieldsz = len(listname) + len('-unsubscribe') + hostname = mlist.host_name + # Set up the mailman-loop address + loopaddr = Utils.get_site_email(mlist.host_name, extra='loop') + loopdest = Utils.ParseEmail(loopaddr)[0] + # Seek to the end of the text file, but if it's empty write the standard + # disclaimer, and the loop catch address. + fp.seek(0, 2) + if not fp.tell(): + print >> fp, """\ +# This file is generated by Mailman, and is kept in sync with the binary hash +# file virtual-mailman.db. YOU SHOULD NOT MANUALLY EDIT THIS FILE unless you +# know what you're doing, and can keep the two files properly in sync. If you +# screw it up, you're on your own. +# +# Note that you should already have this virtual domain set up properly in +# your Postfix installation. See README.POSTFIX for details. + +# LOOP ADDRESSES START +%s\t%s +# LOOP ADDRESSES END +""" % (loopaddr, loopdest) + # The text file entries get a little extra info + print >> fp, '# STANZA START:', listname + print >> fp, '# CREATED:', time.ctime(time.time()) + # Now add all the standard alias entries + for k, v in makealiases(listname): + fqdnaddr = '%s@%s' % (k, hostname) + # Format the text file nicely + print >> fp, fqdnaddr, ((fieldsz - len(k)) * ' '), k + # Finish the text file stanza + print >> fp, '# STANZA END:', listname + print >> fp + + + +# Blech. +def _check_for_virtual_loopaddr(mlist, filename): + loopaddr = Utils.get_site_email(mlist.host_name, extra='loop') + loopdest = Utils.ParseEmail(loopaddr)[0] + infp = open(filename) + omask = os.umask(007) + try: + outfp = open(filename + '.tmp', 'w') + finally: + os.umask(omask) + try: + # Find the start of the loop address block + while 1: + line = infp.readline() + if not line: + break + outfp.write(line) + if line.startswith('# LOOP ADDRESSES START'): + break + # Now see if our domain has already been written + while 1: + line = infp.readline() + if not line: + break + if line.startswith('# LOOP ADDRESSES END'): + # It hasn't + print >> outfp, '%s\t%s' % (loopaddr, loopdest) + outfp.write(line) + break + elif line.startswith(loopaddr): + # We just found it + outfp.write(line) + break + else: + # This isn't our loop address, so spit it out and continue + outfp.write(line) + outfp.writelines(infp.readlines()) + finally: + infp.close() + outfp.close() + os.rename(filename + '.tmp', filename) + + + +def _do_create(mlist, textfile, func): + # Crack open the plain text file + try: + fp = open(textfile, 'r+') + except IOError, e: + if e.errno <> errno.ENOENT: raise + omask = os.umask(007) + try: + fp = open(textfile, 'w+') + finally: + os.umask(omask) + try: + func(mlist, fp) + finally: + fp.close() + # Now double check the virtual plain text file + if func is _addvirtual: + _check_for_virtual_loopaddr(mlist, textfile) + + +def create(mlist, cgi=0, nolock=0): + # Acquire the global list database lock + lock = None + if not nolock: + lock = makelock() + lock.lock() + # Do the aliases file, which need to be done in any case + try: + _do_create(mlist, ALIASFILE, _addlist) + if mlist and mlist.host_name in mm_cfg.POSTFIX_STYLE_VIRTUAL_DOMAINS: + _do_create(mlist, VIRTFILE, _addvirtual) + _update_maps() + finally: + if lock: + lock.unlock(unconditionally=1) + + + +def _do_remove(mlist, textfile, virtualp): + listname = mlist.internal_name() + # Now do our best to filter out the proper stanza from the text file. + # The text file better exist! + outfp = None + try: + infp = open(textfile) + except IOError, e: + if e.errno <> errno.ENOENT: raise + # Otherwise, there's no text file to filter so we're done. + return + try: + omask = os.umask(007) + try: + outfp = open(textfile + '.tmp', 'w') + finally: + os.umask(omask) + filteroutp = 0 + start = '# STANZA START: ' + listname + end = '# STANZA END: ' + listname + while 1: + line = infp.readline() + if not line: + break + # If we're filtering out a stanza, just look for the end marker and + # filter out everything in between. If we're not in the middle of + # filtering out a stanza, we're just looking for the proper begin + # marker. + if filteroutp: + if line.startswith(end): + filteroutp = 0 + # Discard the trailing blank line, but don't worry if + # we're at the end of the file. + infp.readline() + # Otherwise, ignore the line + else: + if line.startswith(start): + # Filter out this stanza + filteroutp = 1 + else: + outfp.write(line) + # Close up shop, and rotate the files + finally: + infp.close() + outfp.close() + os.rename(textfile+'.tmp', textfile) + + +def remove(mlist, cgi=0): + # Acquire the global list database lock + lock = makelock() + lock.lock() + try: + _do_remove(mlist, ALIASFILE, 0) + if mlist.host_name in mm_cfg.POSTFIX_STYLE_VIRTUAL_DOMAINS: + _do_remove(mlist, VIRTFILE, 1) + # Regenerate the alias and map files + _update_maps() + finally: + lock.unlock(unconditionally=1) + + + +def checkperms(state): + targetmode = S_IFREG | S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP + for file in ALIASFILE, VIRTFILE: + if state.VERBOSE: + print _('checking permissions on %(file)s') + stat = None + try: + stat = os.stat(file) + except OSError, e: + if e.errno <> errno.ENOENT: + raise + if stat and (stat[ST_MODE] & targetmode) <> targetmode: + state.ERRORS += 1 + octmode = oct(stat[ST_MODE]) + print _('%(file)s permissions must be 066x (got %(octmode)s)'), + if state.FIX: + print _('(fixing)') + os.chmod(file, stat[ST_MODE] | targetmode) + else: + print + # Make sure the corresponding .db files are owned by the Mailman user. + # We don't need to check the group ownership of the file, since + # check_perms checks this itself. + dbfile = file + '.db' + stat = None + try: + stat = os.stat(dbfile) + except OSError, e: + if e.errno <> errno.ENOENT: + raise + continue + if state.VERBOSE: + print _('checking ownership of %(dbfile)s') + user = mm_cfg.MAILMAN_USER + ownerok = stat[ST_UID] == pwd.getpwnam(user)[2] + if not ownerok: + try: + owner = pwd.getpwuid(stat[ST_UID])[0] + except KeyError: + owner = 'uid %d' % stat[ST_UID] + print _('%(dbfile)s owned by %(owner)s (must be owned by %(user)s') + state.ERRORS += 1 + if state.FIX: + print _('(fixing)') + uid = pwd.getpwnam(user)[2] + gid = grp.getgrnam(mm_cfg.MAILMAN_GROUP)[2] + os.chown(dbfile, uid, gid) + else: + print diff --git a/Mailman/MTA/Utils.py b/Mailman/MTA/Utils.py new file mode 100644 index 00000000..f55a1ed3 --- /dev/null +++ b/Mailman/MTA/Utils.py @@ -0,0 +1,79 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Utilities for list creation/deletion hooks.""" + +import os +import pwd + +from Mailman import mm_cfg + + + +def getusername(): + username = os.environ.get('USER') or os.environ.get('LOGNAME') + if not username: + import pwd + username = pwd.getpwuid(os.getuid())[0] + if not username: + username = '' + return username + + + +def _makealiases_mailprog(listname): + wrapper = os.path.join(mm_cfg.WRAPPER_DIR, 'mailman') + # Most of the list alias extensions are quite regular. I.e. if the + # message is delivered to listname-foobar, it will be filtered to a + # program called foobar. There are two exceptions: + # + # 1) Messages to listname (no extension) go to the post script. + # 2) Messages to listname-admin go to the bounces script. This is for + # backwards compatibility and may eventually go away (we really have no + # need for the -admin address anymore). + # + # Seed this with the special cases. + aliases = [(listname, '"|%s post %s"' % (wrapper, listname)), + ] + for ext in ('admin', 'bounces', 'confirm', 'join', 'leave', 'owner', + 'request', 'subscribe', 'unsubscribe'): + aliases.append(('%s-%s' % (listname, ext), + '"|%s %s %s"' % (wrapper, ext, listname))) + return aliases + + + +def _makealiases_maildir(listname): + maildir = mm_cfg.MAILDIR_DIR + if not maildir.endswith('/'): + maildir += '/' + # Deliver everything using maildir style. This way there's no mail + # program, no forking and no wrapper necessary! + # + # Note, don't use this unless your MTA leaves the envelope recipient in + # Delivered-To:, Envelope-To:, or Apparently-To: + aliases = [(listname, maildir)] + for ext in ('admin', 'bounces', 'confirm', 'join', 'leave', 'owner', + 'request', 'subscribe', 'unsubscribe'): + aliases.append(('%s-%s' % (listname, ext), maildir)) + return aliases + + + +if mm_cfg.USE_MAILDIR: + makealiases = _makealiases_maildir +else: + makealiases = _makealiases_mailprog diff --git a/Mailman/MTA/__init__.py b/Mailman/MTA/__init__.py new file mode 100644 index 00000000..55cd5826 --- /dev/null +++ b/Mailman/MTA/__init__.py @@ -0,0 +1,15 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. diff --git a/Mailman/MailList.py b/Mailman/MailList.py new file mode 100644 index 00000000..8cffef8c --- /dev/null +++ b/Mailman/MailList.py @@ -0,0 +1,1346 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + + +"""The class representing a Mailman mailing list. + +Mixes in many task-specific classes. +""" + +import sys +import os +import time +import marshal +import errno +import re +import shutil +import socket +import urllib +import cPickle + +from cStringIO import StringIO +from UserDict import UserDict +from urlparse import urlparse +from types import * + +import email.Iterators +from email.Utils import getaddresses, formataddr, parseaddr + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import Errors +from Mailman import LockFile +from Mailman.UserDesc import UserDesc + +# base classes +from Mailman.Archiver import Archiver +from Mailman.Autoresponder import Autoresponder +from Mailman.Bouncer import Bouncer +from Mailman.Deliverer import Deliverer +from Mailman.Digester import Digester +from Mailman.GatewayManager import GatewayManager +from Mailman.HTMLFormatter import HTMLFormatter +from Mailman.ListAdmin import ListAdmin +from Mailman.SecurityManager import SecurityManager +from Mailman.TopicMgr import TopicMgr + +# gui components package +from Mailman import Gui + +# other useful classes +from Mailman import MemberAdaptor +from Mailman.OldStyleMemberships import OldStyleMemberships +from Mailman import Message +from Mailman import Pending +from Mailman import Site +from Mailman.i18n import _ +from Mailman.Logging.Syslog import syslog + +EMPTYSTRING = '' + + + +# Use mixins here just to avoid having any one chunk be too large. +class MailList(HTMLFormatter, Deliverer, ListAdmin, + Archiver, Digester, SecurityManager, Bouncer, GatewayManager, + Autoresponder, TopicMgr): + + # + # A MailList object's basic Python object model support + # + def __init__(self, name=None, lock=1): + # No timeout by default. If you want to timeout, open the list + # unlocked, then lock explicitly. + # + # Only one level of mixin inheritance allowed + for baseclass in self.__class__.__bases__: + if hasattr(baseclass, '__init__'): + baseclass.__init__(self) + # Initialize volatile attributes + self.InitTempVars(name) + # Default membership adaptor class + self._memberadaptor = OldStyleMemberships(self) + if name: + if lock: + # This will load the database. + self.Lock() + else: + self.Load() + # This extension mechanism allows list-specific overrides of any + # method (well, except __init__(), InitTempVars(), and InitVars() + # I think). + filename = os.path.join(self.fullpath(), 'extend.py') + dict = {} + try: + execfile(filename, dict) + except IOError, e: + if e.errno <> errno.ENOENT: raise + else: + func = dict.get('extend') + if func: + func(self) + + def __getattr__(self, name): + # Because we're using delegation, we want to be sure that attribute + # access to a delegated member function gets passed to the + # sub-objects. This of course imposes a specific name resolution + # order. + try: + return getattr(self._memberadaptor, name) + except AttributeError: + for guicomponent in self._gui: + try: + return getattr(guicomponent, name) + except AttributeError: + pass + else: + raise AttributeError, name + + def __repr__(self): + if self.Locked(): + status = '(locked)' + else: + status = '(unlocked)' + return '' % ( + self.internal_name(), status, id(self)) + + + # + # Lock management + # + def Lock(self, timeout=0): + self.__lock.lock(timeout) + # Must reload our database for consistency. Watch out for lists that + # don't exist. + try: + self.Load() + except Exception: + self.Unlock() + raise + + def Unlock(self): + self.__lock.unlock(unconditionally=1) + + def Locked(self): + return self.__lock.locked() + + + + # + # Useful accessors + # + def internal_name(self): + return self._internal_name + + def fullpath(self): + return self._full_path + + def getListAddress(self, extra=None): + if extra is None: + return '%s@%s' % (self.internal_name(), self.host_name) + return '%s-%s@%s' % (self.internal_name(), extra, self.host_name) + + # For backwards compatibility + def GetBouncesEmail(self): + return self.getListAddress('bounces') + + def GetOwnerEmail(self): + return self.getListAddress('owner') + + def GetRequestEmail(self): + return self.getListAddress('request') + + def GetConfirmEmail(self, cookie): + return mm_cfg.VERP_CONFIRM_FORMAT % { + 'addr' : '%s-confirm' % self.internal_name(), + 'cookie': cookie, + } + '@' + self.host_name + + def GetListEmail(self): + return self.getListAddress() + + 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, scriptname, absolute=0): + return Utils.ScriptURL(scriptname, self.web_page_url, absolute) + \ + '/' + self.internal_name() + + def GetOptionsURL(self, user, obscure=0, absolute=0): + url = self.GetScriptURL('options', absolute) + if obscure: + user = Utils.ObscureEmail(user) + return '%s/%s' % (url, urllib.quote(user.lower())) + + + # + # Instance and subcomponent initialization + # + def InitTempVars(self, name): + """Set transient variables of this and inherited classes.""" + # The timestamp is set whenever we load the state from disk. If our + # timestamp is newer than the modtime of the config.pck file, we don't + # need to reload, otherwise... we do. + self.__timestamp = 0 + self.__lock = LockFile.LockFile( + os.path.join(mm_cfg.LOCK_DIR, name or '') + '.lock', + # TBD: is this a good choice of lifetime? + lifetime = mm_cfg.LIST_LOCK_LIFETIME, + withlogging = mm_cfg.LIST_LOCK_DEBUGGING) + self._internal_name = name + if name: + self._full_path = Site.get_listpath(name) + else: + self._full_path = None + # Only one level of mixin inheritance allowed + for baseclass in self.__class__.__bases__: + if hasattr(baseclass, 'InitTempVars'): + baseclass.InitTempVars(self) + # Now, initialize our gui components + self._gui = [] + for component in dir(Gui): + if component.startswith('_'): + continue + self._gui.append(getattr(Gui, component)()) + + def InitVars(self, name=None, admin='', crypted_password=''): + """Assign default values - some will be overriden by stored state.""" + # Non-configurable list info + if name: + self._internal_name = name + + # When was the list created? + self.created_at = time.time() + + # Must save this state, even though it isn't configurable + self.volume = 1 + self.members = {} # self.digest_members is initted in mm_digest + self.data_version = mm_cfg.DATA_FILE_VERSION + self.last_post_time = 0 + + self.post_id = 1. # A float so it never has a chance to overflow. + self.user_options = {} + self.language = {} + self.usernames = {} + self.passwords = {} + self.new_member_options = mm_cfg.DEFAULT_NEW_MEMBER_OPTIONS + + # This stuff is configurable + self.respond_to_post_requests = 1 + self.advertised = mm_cfg.DEFAULT_LIST_ADVERTISED + self.max_num_recipients = mm_cfg.DEFAULT_MAX_NUM_RECIPIENTS + self.max_message_size = mm_cfg.DEFAULT_MAX_MESSAGE_SIZE + # See the note in Defaults.py concerning DEFAULT_HOST_NAME + # vs. DEFAULT_EMAIL_HOST. + self.host_name = mm_cfg.DEFAULT_HOST_NAME or mm_cfg.DEFAULT_EMAIL_HOST + self.web_page_url = ( + mm_cfg.DEFAULT_URL or + mm_cfg.DEFAULT_URL_PATTERN % mm_cfg.DEFAULT_URL_HOST) + self.owner = [admin] + self.moderator = [] + self.reply_goes_to_list = mm_cfg.DEFAULT_REPLY_GOES_TO_LIST + self.reply_to_address = '' + self.first_strip_reply_to = mm_cfg.DEFAULT_FIRST_STRIP_REPLY_TO + self.admin_immed_notify = mm_cfg.DEFAULT_ADMIN_IMMED_NOTIFY + self.admin_notify_mchanges = \ + mm_cfg.DEFAULT_ADMIN_NOTIFY_MCHANGES + self.require_explicit_destination = \ + mm_cfg.DEFAULT_REQUIRE_EXPLICIT_DESTINATION + self.acceptable_aliases = mm_cfg.DEFAULT_ACCEPTABLE_ALIASES + self.umbrella_list = mm_cfg.DEFAULT_UMBRELLA_LIST + self.umbrella_member_suffix = \ + mm_cfg.DEFAULT_UMBRELLA_MEMBER_ADMIN_SUFFIX + self.send_reminders = mm_cfg.DEFAULT_SEND_REMINDERS + self.send_welcome_msg = mm_cfg.DEFAULT_SEND_WELCOME_MSG + self.send_goodbye_msg = mm_cfg.DEFAULT_SEND_GOODBYE_MSG + self.bounce_matching_headers = \ + mm_cfg.DEFAULT_BOUNCE_MATCHING_HEADERS + self.anonymous_list = mm_cfg.DEFAULT_ANONYMOUS_LIST + internalname = self.internal_name() + self.real_name = internalname[0].upper() + internalname[1:] + self.description = '' + self.info = '' + self.welcome_msg = '' + self.goodbye_msg = '' + self.subscribe_policy = mm_cfg.DEFAULT_SUBSCRIBE_POLICY + self.unsubscribe_policy = mm_cfg.DEFAULT_UNSUBSCRIBE_POLICY + self.private_roster = mm_cfg.DEFAULT_PRIVATE_ROSTER + self.obscure_addresses = mm_cfg.DEFAULT_OBSCURE_ADDRESSES + self.admin_member_chunksize = mm_cfg.DEFAULT_ADMIN_MEMBER_CHUNKSIZE + self.administrivia = mm_cfg.DEFAULT_ADMINISTRIVIA + self.preferred_language = mm_cfg.DEFAULT_SERVER_LANGUAGE + self.available_languages = [] + self.include_rfc2369_headers = 1 + self.include_list_post_header = 1 + self.filter_mime_types = mm_cfg.DEFAULT_FILTER_MIME_TYPES + self.pass_mime_types = mm_cfg.DEFAULT_PASS_MIME_TYPES + self.filter_content = mm_cfg.DEFAULT_FILTER_CONTENT + self.convert_html_to_plaintext = \ + mm_cfg.DEFAULT_CONVERT_HTML_TO_PLAINTEXT + self.filter_action = mm_cfg.DEFAULT_FILTER_ACTION + # Analogs to these are initted in Digester.InitVars + self.nondigestable = mm_cfg.DEFAULT_NONDIGESTABLE + self.personalize = 0 + # New sender-centric moderation (privacy) options + self.default_member_moderation = \ + mm_cfg.DEFAULT_DEFAULT_MEMBER_MODERATION + # Emergency moderation bit + self.emergency = 0 + # This really ought to default to mm_cfg.HOLD, but that doesn't work + # with the current GUI description model. So, 0==Hold, 1==Reject, + # 2==Discard + self.member_moderation_action = 0 + self.member_moderation_notice = '' + self.accept_these_nonmembers = [] + self.hold_these_nonmembers = [] + self.reject_these_nonmembers = [] + self.discard_these_nonmembers = [] + self.forward_auto_discards = mm_cfg.DEFAULT_FORWARD_AUTO_DISCARDS + self.generic_nonmember_action = mm_cfg.DEFAULT_GENERIC_NONMEMBER_ACTION + # Ban lists + self.ban_list = [] + # BAW: This should really be set in SecurityManager.InitVars() + self.password = crypted_password + # 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 = {} + + # Only one level of mixin inheritance allowed + for baseclass in self.__class__.__bases__: + if hasattr(baseclass, 'InitVars'): + baseclass.InitVars(self) + + # These need to come near the bottom because they're dependent on + # other settings. + self.subject_prefix = mm_cfg.DEFAULT_SUBJECT_PREFIX % self.__dict__ + self.msg_header = mm_cfg.DEFAULT_MSG_HEADER + self.msg_footer = mm_cfg.DEFAULT_MSG_FOOTER + # Set this to Never if the list's preferred language uses us-ascii, + # otherwise set it to As Needed + if Utils.GetCharSet(self.preferred_language) == 'us-ascii': + self.encode_ascii_prefixes = 0 + else: + self.encode_ascii_prefixes = 2 + + + # + # Web API support via administrative categories + # + def GetConfigCategories(self): + class CategoryDict(UserDict): + def __init__(self): + UserDict.__init__(self) + self.keysinorder = mm_cfg.ADMIN_CATEGORIES[:] + def keys(self): + return self.keysinorder + def items(self): + items = [] + for k in mm_cfg.ADMIN_CATEGORIES: + items.append((k, self.data[k])) + return items + def values(self): + values = [] + for k in mm_cfg.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 + + + # + # List creation + # + def Create(self, name, admin, crypted_password, langs=None): + if Utils.list_exists(name): + raise Errors.MMListAlreadyExistsError, name + # Validate what will be the list's posting address. If that's + # invalid, we don't want to create the mailing list. The hostname + # part doesn't really matter, since that better already be valid. + # However, most scripts already catch MMBadEmailError as exceptions on + # the admin's email address, so transform the exception. + postingaddr = '%s@%s' % (name, mm_cfg.DEFAULT_EMAIL_HOST) + try: + Utils.ValidateEmail(postingaddr) + except Errors.MMBadEmailError: + raise Errors.BadListNameError, postingaddr + # Validate the admin's email address + Utils.ValidateEmail(admin) + self._internal_name = name + self._full_path = Site.get_listpath(name, create=1) + # Don't use Lock() since that tries to load the non-existant config.pck + self.__lock.lock() + self.InitVars(name, admin, crypted_password) + self.CheckValues() + if langs is None: + self.available_languages = [self.preferred_language] + else: + self.available_languages = langs + + + + # + # Database and filesystem I/O + # + def __save(self, dict): + # Save the file as a binary pickle, and rotate the old version to a + # backup file. We must guarantee that config.pck is always valid so + # we never rotate unless the we've successfully written the temp file. + # We use pickle now because marshal is not guaranteed to be compatible + # between Python versions. + fname = os.path.join(self.fullpath(), 'config.pck') + fname_tmp = fname + '.tmp.%s.%d' % (socket.gethostname(), os.getpid()) + fname_last = fname + '.last' + fp = None + try: + fp = open(fname_tmp, 'w') + # Use a binary format... it's more efficient. + cPickle.dump(dict, fp, 1) + fp.close() + except IOError, e: + syslog('error', + 'Failed config.pck write, retaining old state.\n%s', e) + if fp is not None: + os.unlink(fname_tmp) + raise + # Now do config.pck.tmp.xxx -> config.pck -> config.pck.last rotation + # as safely as possible. + try: + # might not exist yet + os.unlink(fname_last) + except OSError, e: + if e.errno <> errno.ENOENT: raise + try: + # might not exist yet + os.link(fname, fname_last) + except OSError, e: + if e.errno <> errno.ENOENT: raise + os.rename(fname_tmp, fname) + # Reset the timestamp + self.__timestamp = os.path.getmtime(fname) + + def Save(self): + # Refresh the lock, just to let other processes know we're still + # interested in it. This will raise a NotLockedError if we don't have + # the lock (which is a serious problem!). TBD: do we need to be more + # defensive? + self.__lock.refresh() + # copy all public attributes to serializable dictionary + dict = {} + for key, value in self.__dict__.items(): + if key[0] == '_' or type(value) is MethodType: + continue + dict[key] = value + # Make config.pck unreadable by `other', as it contains all the + # list members' passwords (in clear text). + omask = os.umask(007) + try: + self.__save(dict) + finally: + os.umask(omask) + self.SaveRequestsDb() + self.CheckHTMLArchiveDir() + + def __load(self, dbfile): + # Attempt to load and unserialize the specified database file. This + # could actually be a config.db (for pre-2.1alpha3) or config.pck, + # i.e. a marshal or a binary pickle. Actually, it could also be a + # .last backup file if the primary storage file was corrupt. The + # decision on whether to unpickle or unmarshal is based on the file + # extension, but we always save it using pickle (since only it, and + # not marshal is guaranteed to be compatible across Python versions). + # + # On success return a 2-tuple of (dictionary, None). On error, return + # a 2-tuple of the form (None, errorobj). + if dbfile.endswith('.db') or dbfile.endswith('.db.last'): + loadfunc = marshal.load + elif dbfile.endswith('.pck') or dbfile.endswith('.pck.last'): + loadfunc = cPickle.load + else: + assert 0, 'Bad database file name' + try: + # Check the mod time of the file first. If it matches our + # timestamp, then the state hasn't change since the last time we + # loaded it. Otherwise open the file for loading, below. If the + # file doesn't exist, we'll get an EnvironmentError with errno set + # to ENOENT (EnvironmentError is the base class of IOError and + # OSError). + mtime = os.path.getmtime(dbfile) + if mtime <= self.__timestamp: + # File is not newer + return None, None + fp = open(dbfile) + except EnvironmentError, e: + if e.errno <> errno.ENOENT: raise + # The file doesn't exist yet + return None, e + try: + try: + dict = loadfunc(fp) + if type(dict) <> DictType: + return None, 'Load() expected to return a dictionary' + except (EOFError, ValueError, TypeError, MemoryError, + cPickle.PicklingError), e: + return None, e + finally: + fp.close() + # Update timestamp + self.__timestamp = mtime + return dict, None + + def Load(self, check_version=1): + if not Utils.list_exists(self.internal_name()): + raise Errors.MMUnknownListError + # We first try to load config.pck, which contains the up-to-date + # version of the database. If that fails, perhaps because it's + # corrupted or missing, we'll try to load the backup file + # config.pck.last. + # + # Should both of those fail, we'll look for config.db and + # config.db.last for backwards compatibility with pre-2.1alpha3 + pfile = os.path.join(self.fullpath(), 'config.pck') + plast = pfile + '.last' + dfile = os.path.join(self.fullpath(), 'config.db') + dlast = dfile + '.last' + for file in (pfile, plast, dfile, dlast): + dict, e = self.__load(file) + if dict is None: + if e is not None: + # Had problems with this file; log it and try the next one. + syslog('error', "couldn't load config file %s\n%s", + file, e) + else: + # We already have the most up-to-date state + return + else: + break + else: + # Nothing worked, so we have to give up + syslog('error', 'All %s fallbacks were corrupt, giving up', + self.internal_name()) + raise Errors.MMCorruptListDatabaseError, e + # Now, if we didn't end up using the primary database file, we want to + # copy the fallback into the primary so that the logic in Save() will + # still work. For giggles, we'll copy it to a safety backup. + if file == plast: + shutil.copy(file, pfile) + shutil.copy(file, pfile + '.safety') + elif file == dlast: + shutil.copy(file, dfile) + shutil.copy(file, pfile + '.safety') + # Copy the loaded dictionary into the attributes of the current + # mailing list object, then run sanity check on the data. + self.__dict__.update(dict) + if check_version: + self.CheckVersion(dict) + self.CheckValues() + + + # + # Sanity checks + # + def CheckVersion(self, stored_state): + """Auto-update schema if necessary.""" + if self.data_version >= mm_cfg.DATA_FILE_VERSION: + return + # Initialize any new variables + self.InitVars() + # Then reload the database (but don't recurse). Force a reload even + # if we have the most up-to-date state. + self.__timestamp = 0 + self.Load(check_version=0) + # We must hold the list lock in order to update the schema + waslocked = self.Locked() + if not waslocked: + self.Lock() + try: + from versions import Update + Update(self, stored_state) + self.data_version = mm_cfg.DATA_FILE_VERSION + self.Save() + finally: + if not waslocked: + self.Unlock() + + def CheckValues(self): + """Normalize selected values to known formats.""" + if '' in urlparse(self.web_page_url)[:2]: + # Either the "scheme" or the "network location" part of the parsed + # URL is empty; substitute faulty value with (hopefully sane) + # default. Note that DEFAULT_URL is obsolete. + self.web_page_url = ( + mm_cfg.DEFAULT_URL or + mm_cfg.DEFAULT_URL_PATTERN % mm_cfg.DEFAULT_URL_HOST) + if self.web_page_url and self.web_page_url[-1] <> '/': + self.web_page_url = self.web_page_url + '/' + # Legacy reply_to_address could be an illegal value. We now verify + # upon setting and don't check it at the point of use. + try: + if self.reply_to_address.strip() and self.reply_goes_to_list: + Utils.ValidateEmail(self.reply_to_address) + except Errors.EmailAddressError: + syslog('error', 'Bad reply_to_address "%s" cleared for list: %s', + self.reply_to_address, self.internal_name()) + self.reply_to_address = '' + self.reply_goes_to_list = 0 + # Legacy topics may have bad regular expressions in their patterns + goodtopics = [] + for name, pattern, desc, emptyflag in self.topics: + try: + re.compile(pattern) + except (re.error, TypeError): + syslog('error', 'Bad topic pattern "%s" for list: %s', + pattern, self.internal_name()) + else: + goodtopics.append((name, pattern, desc, emptyflag)) + self.topics = goodtopics + + + # + # 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 + requestaddr = self.GetRequestEmail() + # 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 + userdesc.invitation = 1 + cookie = Pending.new(Pending.SUBSCRIPTION, userdesc) + 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) + if mm_cfg.VERP_CONFIRMATIONS: + subj = _( + 'You have been invited to join the %(listname)s mailing list') + sender = self.GetConfirmEmail(cookie) + else: + # Do it the old fashioned way + subj = 'confirm ' + cookie + sender = requestaddr + msg = Message.UserNotification( + invitee, sender, subj, + text, lang=self.preferred_language) + 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.MMBadEmailError + + # Is the subscribing address banned from this list? + ban = 0 + for pattern in self.ban_list: + if pattern.startswith('^'): + # This is a regular expression match + try: + if re.search(pattern, email, re.IGNORECASE): + ban = 1 + break + except re.error: + # BAW: we should probably remove this pattern + pass + else: + # Do the comparison case insensitively + if pattern.lower() == email.lower(): + ban = 1 + break + if ban: + syslog('vette', 'banned subscription: %s (matched: %s)', + 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) + elif self.subscribe_policy == 1 or self.subscribe_policy == 3: + # User confirmation required. BAW: this should probably just + # accept a userdesc instance. + cookie = Pending.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) + realname = self.real_name + 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.GetRequestEmail(), + 'remote' : remote, + 'listadmin' : self.GetOwnerEmail(), + 'confirmurl' : confirmurl, + }, lang=lang, mlist=self) + msg = Message.UserNotification( + recipient, self.GetRequestEmail(), + text=text, lang=lang) + # BAW: See ChangeMemberAddress() for why we do it this way... + del msg['subject'] + msg['Subject'] = 'confirm ' + cookie + msg['Reply-To'] = self.GetRequestEmail() + msg.send(self) + who = formataddr((name, email)) + syslog('subscribe', '%s: pending %s %s', + self.internal_name(), who, by) + raise Errors.MMSubscribeNeedsConfirmation + 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 ApprovedAddMember(self, userdesc, ack=None, admin_notif=None, text=''): + """Add a member right now. + + The member's subscription must be approved by what ever policy the + list enforces. + + userdesc is as above in AddMember(). + + ack is a flag that specifies whether the user should get an + acknowledgement of their being subscribed. Default is to use the + list's default flag value. + + admin_notif is a flag that specifies whether the list owner should get + an acknowledgement of this subscription. Default is to use the list's + default flag value. + """ + assert self.Locked() + # Set up default flag values + if ack is None: + ack = self.send_welcome_msg + if admin_notif is None: + admin_notif = self.admin_notify_mchanges + # Suck values out of userdesc, and apply defaults. + 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 + # Let's be extra cautious + Utils.ValidateEmail(email) + if self.isMember(email): + raise Errors.MMAlreadyAMember, email + # Do the actual addition + self.addNewMember(email, realname=name, digest=digest, + password=password, language=lang) + self.setMemberOption(email, mm_cfg.DisableMime, + 1 - self.mime_is_default_digest) + self.setMemberOption(email, mm_cfg.Moderate, + self.default_member_moderation) + # Now send and log results + if digest: + kind = ' (digest)' + else: + kind = '' + syslog('subscribe', '%s: new%s %s', self.internal_name(), + kind, formataddr((email, name))) + if ack: + self.SendSubscribeAck(email, self.getMemberPassword(email), + digest, text) + if admin_notif: + realname = self.real_name + subject = _('%(realname)s subscription notification') + text = Utils.maketext( + "adminsubscribeack.txt", + {"listname" : self.real_name, + "member" : formataddr((name, email)), + }, mlist=self) + msg = Message.OwnerNotification(self, subject, text) + msg.send(self) + + def DeleteMember(self, name, whence=None, admin_notif=0, userack=1): + 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 ApprovedDeleteMember(self, name, whence=None, + admin_notif=None, userack=None): + if userack is None: + userack = self.send_goodbye_msg + if admin_notif is None: + admin_notif = self.admin_notify_mchanges + # Delete a member, for which we know the approval has been made + fullname, emailaddr = parseaddr(name) + userlang = self.getMemberLanguage(emailaddr) + # Remove the member + self.removeMember(emailaddr) + # And send an acknowledgement to the user... + if userack: + self.SendUnsubscribeAck(emailaddr, userlang) + # ...and to the administrator + if admin_notif: + realname = self.real_name + subject = _('%(realname)s unsubscribe notification') + text = Utils.maketext( + 'adminunsubscribeack.txt', + {'member' : name, + 'listname': self.real_name, + }, mlist=self) + msg = Message.OwnerNotification(self, subject, text) + msg.send(self) + if whence: + whence = "; %s" % whence + else: + whence = "" + syslog('subscribe', '%s: deleted %s%s', + self.internal_name(), name, whence) + + def ChangeMemberName(self, addr, name, globally): + self.setMemberName(addr, name) + if not globally: + return + for listname in Utils.list_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(addr): + continue + mlist.Lock() + try: + mlist.setMemberName(addr, name) + mlist.Save() + finally: + mlist.Unlock() + + 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. + if newaddr == oldaddr and self.isMember(newaddr): + raise Errors.MMAlreadyAMember + if newaddr == self.GetListEmail().lower(): + raise Errors.MMBadEmailError + # Pend the subscription change + cookie = Pending.new(Pending.CHANGE_OF_ADDRESS, + oldaddr, newaddr, globally) + confirmurl = '%s/%s' % (self.GetScriptURL('confirm', absolute=1), + cookie) + realname = self.real_name + lang = self.getMemberLanguage(oldaddr) + text = Utils.maketext( + 'verify.txt', + {'email' : newaddr, + 'listaddr' : self.GetListEmail(), + 'listname' : realname, + 'cookie' : cookie, + 'requestaddr': self.GetRequestEmail(), + '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(), + text=text, lang=lang) + del msg['subject'] + msg['Subject'] = 'confirm ' + cookie + msg['Reply-To'] = self.GetRequestEmail() + msg.send(self) + + def ApprovedChangeMemberAddress(self, oldaddr, newaddr, globally): + # Change the membership for the current list first. We don't lock and + # save ourself since we assume that the list is already locked. + if self.isMember(newaddr): + # Just delete the old address + if self.isMember(oldaddr): + self.ApprovedDeleteMember(oldaddr, admin_notif=1, userack=1) + else: + self.changeMemberAddress(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 Utils.list_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) or mlist.isMember(newaddr): + continue + mlist.Lock() + try: + mlist.changeMemberAddress(oldaddr, newaddr) + mlist.Save() + finally: + mlist.Unlock() + + + # + # Confirmation processing + # + def ProcessConfirmation(self, cookie, context=None): + data = Pending.confirm(cookie) + if data is None: + raise Errors.MMBadConfirmation, 'data is None' + try: + op = data[0] + data = data[1:] + except ValueError: + raise Errors.MMBadConfirmation, 'op-less data %s' % (data,) + if op == Pending.SUBSCRIPTION: + 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 + 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', 0) + # 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 not invitation and self.subscribe_policy in (2, 3): + self.HoldSubscription(addr, fullname, password, digest, lang) + name = self.real_name + raise Errors.MMNeedApproval, _( + 'subscriptions to %(name)s require administrator approval') + self.ApprovedAddMember(userdesc) + 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, otherwise it's an approval + # (if the passwords match). + 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 1: + 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 + # Okay, does the approved header match the list password? + if approved and self.Authenticate([mm_cfg.AuthListAdmin, + mm_cfg.AuthListModerator], + approved) <> mm_cfg.UnAuthorized: + action = mm_cfg.APPROVE + else: + action = mm_cfg.DISCARD + try: + self.HandleRequest(id, action) + except KeyError: + # Most likely because the message has already been disposed of + # via the admindb page. + syslog('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 + + def ConfirmUnsubscription(self, addr, lang=None, remote=None): + if lang is None: + lang = self.getMemberLanguage(addr) + cookie = Pending.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.GetRequestEmail(), + 'remote' : remote, + 'listadmin' : self.GetOwnerEmail(), + 'confirmurl' : confirmurl, + }, lang=lang, mlist=self) + msg = Message.UserNotification( + addr, self.GetRequestEmail(), + text=text, lang=lang) + # BAW: See ChangeMemberAddress() for why we do it this way... + del msg['subject'] + msg['Subject'] = 'confirm ' + cookie + msg['Reply-To'] = self.GetRequestEmail() + msg.send(self) + + + # + # Miscellaneous stuff + # + def HasExplicitDest(self, msg): + """True if list name or any acceptable_alias is included among the + to or cc addrs.""" + # BAW: fall back to Utils.ParseAddr if the first test fails. + # this is the list's full address + listfullname = '%s@%s' % (self.internal_name(), self.host_name) + recips = [] + # check all recipient addresses against the list's explicit addresses, + # specifically To: Cc: and Resent-to: + to = [] + for header in ('to', 'cc', 'resent-to', 'resent-cc'): + to.extend(getaddresses(msg.get_all(header, []))) + for fullname, addr in to: + # It's possible that if the header doesn't have a valid + # (i.e. RFC822) value, we'll get None for the address. So skip + # it. + if addr is None: + continue + addr = addr.lower() + localpart = addr.split('@')[0] + if (# TBD: backwards compatibility: deprecated + localpart == self.internal_name() or + # exact match against the complete list address + addr == listfullname): + return 1 + recips.append((addr, localpart)) + # + # helper function used to match a pattern against an address. Do it + def domatch(pattern, addr): + try: + if re.match(pattern, addr): + return 1 + except re.error: + # The pattern is a malformed regexp -- try matching safely, + # with all non-alphanumerics backslashed: + if re.match(re.escape(pattern), addr): + return 1 + # + # Here's the current algorithm for matching acceptable_aliases: + # + # 1. If the pattern does not have an `@' in it, we first try matching + # it against just the localpart. This was the behavior prior to + # 2.0beta3, and is kept for backwards compatibility. + # (deprecated). + # + # 2. If that match fails, or the pattern does have an `@' in it, we + # try matching against the entire recip address. + for addr, localpart in recips: + for alias in self.acceptable_aliases.split('\n'): + stripped = alias.strip() + if not stripped: + # ignore blank or empty lines + continue + if '@' not in stripped and domatch(stripped, localpart): + return 1 + if domatch(stripped, addr): + return 1 + return 0 + + def parse_matching_header_opt(self): + """Return a list of triples [(field name, regex, line), ...].""" + # - Blank lines and lines with '#' as first char are skipped. + # - Leading whitespace in the matchexp is trimmed - you can defeat + # that by, eg, containing it in gratuitous square brackets. + all = [] + for line in self.bounce_matching_headers.split('\n'): + line = line.strip() + # Skip blank lines and lines *starting* with a '#'. + if not line or line[0] == "#": + continue + i = line.find(':') + if i < 0: + # This didn't look like a header line. BAW: should do a + # better job of informing the list admin. + syslog('config', 'bad bounce_matching_header line: %s\n%s', + self.real_name, line) + else: + header = line[:i] + value = line[i+1:].lstrip() + try: + cre = re.compile(value, re.IGNORECASE) + except re.error, e: + # The regexp was malformed. BAW: should do a better + # job of informing the list admin. + syslog('config', '''\ +bad regexp in bounce_matching_header line: %s +\n%s (cause: %s)''', self.real_name, value, e) + else: + all.append((header, cre, line)) + return all + + def hasMatchingHeader(self, msg): + """Return true if named header field matches a regexp in the + bounce_matching_header list variable. + + Returns constraint line which matches or empty string for no + matches. + """ + for header, cre, line in self.parse_matching_header_opt(): + for value in msg.get_all(header, []): + if cre.search(value): + return line + return 0 + + def autorespondToSender(self, sender): + """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. + """ + # No limit + if mm_cfg.MAX_AUTORESPONSES_PER_DAY == 0: + return 1 + today = time.localtime()[:3] + info = self.hold_and_cmd_autoresponses.get(sender) + if info is None or info[0] <> today: + # First time we've seen a -request/post-hold for this sender + self.hold_and_cmd_autoresponses[sender] = (today, 1) + # BAW: no check for MAX_AUTORESPONSES_PER_DAY <= 1 + return 1 + date, count = info + if count < 0: + # They've already hit the limit for today. + syslog('vette', '-request/hold autoresponse discarded for: %s', + sender) + return 0 + if count >= mm_cfg.MAX_AUTORESPONSES_PER_DAY: + syslog('vette', '-request/hold autoresponse limit hit for: %s', + sender) + self.hold_and_cmd_autoresponses[sender] = (today, -1) + # Send this notification message instead + text = Utils.maketext( + 'nomoretoday.txt', + {'sender' : sender, + 'listname': '%s@%s' % (self.real_name, self.host_name), + 'num' : count, + 'owneremail': self.GetOwnerEmail(), + }) + msg = Message.UserNotification( + sender, self.GetOwnerEmail(), + _('Last autoresponse notification for today'), + text) + msg.send(self) + return 0 + self.hold_and_cmd_autoresponses[sender] = (today, count+1) + return 1 + + + + # + # Multilingual (i18n) support + # + def GetAvailableLanguages(self): + langs = self.available_languages + # 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 mm_cfg.DEFAULT_SERVER_LANGUAGE not in langs: + langs.append(mm_cfg.DEFAULT_SERVER_LANGUAGE) + return langs diff --git a/Mailman/Mailbox.py b/Mailman/Mailbox.py new file mode 100644 index 00000000..8ab085cc --- /dev/null +++ b/Mailman/Mailbox.py @@ -0,0 +1,101 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Extend mailbox.UnixMailbox. +""" + +import sys +import mailbox + +import email +from email.Generator import Generator +from email.Parser import Parser +from email.Errors import MessageParseError + +from Mailman import mm_cfg +from Mailman.Message import Message + + +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(msg, unixfrom=1) + + + +# 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): + if mm_cfg.ARCHIVE_SCRUBBER: + __import__(mm_cfg.ARCHIVE_SCRUBBER) + self._scrubber = sys.modules[mm_cfg.ARCHIVE_SCRUBBER].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/Mailman/Makefile.in b/Mailman/Makefile.in new file mode 100644 index 00000000..d6fec07b --- /dev/null +++ b/Mailman/Makefile.in @@ -0,0 +1,99 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +# NOTE: Makefile.in is converted into Makefile by the configure script +# in the parent directory. Once configure has run, you can recreate +# the Makefile by running just config.status. + +# Variables set by configure + +VERSION= @VERSION@ + +VPATH= @srcdir@ +srcdir= @srcdir@ +bindir= @bindir@ +prefix= @prefix@ +exec_prefix= @exec_prefix@ + +CC= @CC@ +CHMOD= @CHMOD@ +INSTALL= @INSTALL@ + +DEFS= @DEFS@ + +# Customizable but not set by configure + +OPT= @OPT@ +CFLAGS= $(OPT) $(DEFS) +PACKAGEDIR= $(prefix)/Mailman +SHELL= /bin/sh + +MODULES= $(srcdir)/*.py +SUBDIRS= Cgi Logging Archiver Handlers Bouncers Queue MTA Gui Commands + +# Modes for directories and executables created by the install +# process. Default to group-writable directories but +# user-only-writable for executables. +DIRMODE= 775 +EXEMODE= 755 +FILEMODE= 644 +INSTALL_PROGRAM=$(INSTALL) -m $(EXEMODE) + + +# Rules + +all: + for d in $(SUBDIRS); \ + do \ + (cd $$d; $(MAKE)); \ + done + +install-here: + for f in $(MODULES); \ + do \ + $(INSTALL) -m $(FILEMODE) $$f $(PACKAGEDIR); \ + done + $(INSTALL) -m $(FILEMODE) mm_cfg.py.dist $(PACKAGEDIR) + if [ ! -f $(PACKAGEDIR)/mm_cfg.py ]; \ + then \ + $(INSTALL) -m $(FILEMODE) mm_cfg.py.dist $(PACKAGEDIR)/mm_cfg.py; \ + fi + +install: install-here + for d in $(SUBDIRS); \ + do \ + (cd $$d; $(MAKE) install); \ + done + +finish: + @for d in $(SUBDIRS); \ + do \ + (cd $$d; $(MAKE) finish); \ + done + +clean: + for d in $(SUBDIRS); \ + do \ + (cd $$d; $(MAKE) clean); \ + done + +distclean: + -rm Makefile Defaults.py mm_cfg.py.dist + -rm *.pyc + for d in $(SUBDIRS); \ + do \ + (cd $$d; $(MAKE) distclean); \ + done diff --git a/Mailman/MemberAdaptor.py b/Mailman/MemberAdaptor.py new file mode 100644 index 00000000..dc24ea08 --- /dev/null +++ b/Mailman/MemberAdaptor.py @@ -0,0 +1,350 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""This is an interface to list-specific membership information. + +This class should not be instantiated directly, but instead, it should be +subclassed for specific adaptation to membership databases. The default +MM2.0.x style adaptor is in OldStyleMemberships.py. Through the extend.py +mechanism, you can instantiate different membership information adaptors to +get info out of LDAP, Zope, other, or any combination of the above. + +Members have three pieces of identifying information: a unique identifying +opaque key (KEY), a lower-cased email address (LCE), and a case-preserved +email (CPE) address. Adaptors must ensure that both member keys and lces can +uniquely identify a member, and that they can (usually) convert freely between +keys and lces. Most methods must accept either a key or an lce, unless +specifically documented otherwise. + +The CPE is always used to calculate the recipient address for a message. Some +remote MTAs make a distinction based on localpart case, so we always send +messages to the case-preserved address. Note that DNS is case insensitive so +it doesn't matter what the case is for the domain part of an email address, +although by default, we case-preserve that too. + +The adaptors must support the readable interface for getting information about +memberships, and may optionally support the writeable interface. If they do +not, then members cannot change their list attributes via Mailman's web or +email interfaces. Updating membership information in that case is the +backend's responsibility. Adaptors are allowed to support parts of the +writeable interface. + +For any writeable method not supported, a NotImplemented exception should be +raised. +""" + +# Delivery statuses +ENABLED = 0 # enabled +UNKNOWN = 1 # legacy disabled +BYUSER = 2 # disabled by user choice +BYADMIN = 3 # disabled by admin choice +BYBOUNCE = 4 # disabled by bounces + + + +class MemberAdaptor: + # + # The readable interface + # + def getMembers(self): + """Get the LCE for all the members of the mailing list.""" + raise NotImplemented + + def getRegularMemberKeys(self): + """Get the LCE for all regular delivery members (i.e. non-digest).""" + raise NotImplemented + + def getDigestMemberKeys(self): + """Get the LCE for all digest delivery members.""" + raise NotImplemented + + def isMember(self, member): + """Return 1 if member KEY/LCE is a valid member, otherwise 0.""" + + def getMemberKey(self, member): + """Return the KEY for the member KEY/LCE. + + If member does not refer to a valid member, raise NotAMemberError. + """ + raise NotImplemented + + def getMemberCPAddress(self, member): + """Return the CPE for the member KEY/LCE. + + If member does not refer to a valid member, raise NotAMemberError. + """ + raise NotImplemented + + def getMemberCPAddresses(self, members): + """Return a sequence of CPEs for the given sequence of members. + + The returned sequence will be the same length as members. If any of + the KEY/LCEs in members does not refer to a valid member, that entry + in the returned sequence will be None (i.e. NotAMemberError is never + raised). + """ + raise NotImplemented + + def authenticateMember(self, member, response): + """Authenticate the member KEY/LCE with the given response. + + If the response authenticates the member, return a secret that is + known only to the authenticated member. This need not be the member's + password, but it will be used to craft a session cookie, so it should + be persistent for the life of the session. + + If the authentication failed return 0. If member did not refer to a + valid member, raise NotAMemberError. + + Normally, the response will be the password typed into a web form or + given in an email command, but it needn't be. It is up to the adaptor + to compare the typed response to the user's authentication token. + """ + raise NotImplemented + + def getMemberPassword(self, member): + """Return the member's password. + + If the member KEY/LCE is not a member of the list, raise + NotAMemberError. + """ + raise NotImplemented + + def getMemberLanguage(self, member): + """Return the preferred language for the member KEY/LCE. + + The language returned must be a key in mm_cfg.LC_DESCRIPTIONS and the + mailing list must support that language. + + If member does not refer to a valid member, the list's default + language is returned instead of raising a NotAMemberError error. + """ + raise NotImplemented + + def getMemberOption(self, member, flag): + """Return the boolean state of the member option for member KEY/LCE. + + Option flags are defined in Defaults.py. + + If member does not refer to a valid member, raise NotAMemberError. + """ + raise NotImplemented + + def getMemberName(self, member): + """Return the full name of the member KEY/LCE. + + None is returned if the member has no registered full name. The + returned value may be a Unicode string if there are non-ASCII + characters in the name. NotAMemberError is raised if member does not + refer to a valid member. + """ + raise NotImplemented + + def getMemberTopics(self, member): + """Return the list of topics this member is interested in. + + The return value is a list of strings which name the topics. + """ + raise NotImplemented + + def getDeliveryStatus(self, member): + """Return the delivery status of this member. + + Value is one of the module constants: + + ENABLED - The deliveries to the user are not disabled + UNKNOWN - Deliveries are disabled for unknown reasons. The + primary reason for this to happen is that we've copied + their delivery status from a legacy version which didn't + keep track of disable reasons + BYUSER - The user explicitly disable deliveries + BYADMIN - The list administrator explicitly disabled deliveries + BYBOUNCE - The system disabled deliveries due to bouncing + + If member is not a member of the list, raise NotAMemberError. + """ + raise NotImplemented + + def getDeliveryStatusChangeTime(self, member): + """Return the time of the last disabled delivery status change. + + If the current delivery status is ENABLED, the status change time will + be zero. If member is not a member of the list, raise + NotAMemberError. + """ + raise NotImplemented + + def getDeliveryStatusMembers(self, + status=(UNKNOWN, BYUSER, BYADMIN, BYBOUNCE)): + """Return the list of members with a matching delivery status. + + Optional `status' if given, must be a sequence containing one or more + of ENABLED, UNKNOWN, BYUSER, BYADMIN, or BYBOUNCE. The members whose + delivery status is in this sequence are returned. + """ + raise NotImplemented + + def getBouncingMembers(self): + """Return the list of members who have outstanding bounce information. + + This list of members doesn't necessarily overlap with + getDeliveryStatusMembers() since getBouncingMembers() will return + member who have bounced but not yet reached the disable threshold. + """ + raise NotImplemented + + def getBounceInfo(self, member): + """Return the member's bounce information. + + A value of None means there is no bounce information registered for + the member. + + Bounce info is opaque to the MemberAdaptor. It is set by + setBounceInfo() and returned by this method without modification. + + If member is not a member of the list, raise NotAMemberError. + """ + raise NotImplemented + + + # + # The writeable interface + # + def addNewMember(self, member, **kws): + """Subscribes a new member to the mailing list. + + member is the case-preserved address to subscribe. The LCE is + calculated from this argument. Return the new member KEY. + + This method also takes a keyword dictionary which can be used to set + additional attributes on the member. The actual set of supported + keywords is adaptor specific, but should at least include: + + - digest == subscribing to digests instead of regular delivery + - password == user's password + - language == user's preferred language + - realname == user's full name (should be Unicode if there are + non-ASCII characters in the name) + + Any values not passed to **kws is set to the adaptor-specific + defaults. + + Raise AlreadyAMemberError it the member is already subscribed to the + list. Raises ValueError if **kws contains an invalid option. + """ + raise NotImplemented + + def removeMember(self, memberkey): + """Unsubscribes the member from the mailing list. + + Raise NotAMemberError if member is not subscribed to the list. + """ + raise NotImplemented + + def changeMemberAddress(self, memberkey, newaddress, nodelete=0): + """Change the address for the member KEY. + + memberkey will be a KEY, not an LCE. newaddress should be the + new case-preserved address for the member; the LCE will be calculated + from newaddress. + + If memberkey does not refer to a valid member, raise NotAMemberError. + No verification on the new address is done here (such assertions + should be performed by the caller). + + If nodelete flag is true, then the old membership is not removed. + """ + raise NotImplemented + + def setMemberPassword(self, member, password): + """Set the password for member LCE/KEY. + + If member does not refer to a valid member, raise NotAMemberError. + Also raise BadPasswordError if the password is illegal (e.g. too + short or easily guessed via a dictionary attack). + + """ + raise NotImplemented + + def setMemberLanguage(self, member, language): + """Set the language for the member LCE/KEY. + + If member does not refer to a valid member, raise NotAMemberError. + Also raise BadLanguageError if the language is invalid (e.g. the list + is not configured to support the given language). + """ + raise NotImplemented + + def setMemberOption(self, member, flag, value): + """Set the option for the given member to value. + + member is an LCE/KEY, flag is one of the option flags defined in + Default.py, and value is a boolean. + + If member does not refer to a valid member, raise NotAMemberError. + Also raise BadOptionError if the flag does not refer to a valid + option. + """ + raise NotImplemented + + def setMemberName(self, member, realname): + """Set the member's full name. + + member is an LCE/KEY and realname is an arbitrary string. It should + be a Unicode string if there are non-ASCII characters in the name. + NotAMemberError is raised if member does not refer to a valid member. + """ + raise NotImplemented + + def setMemberTopics(self, member, topics): + """Add list of topics to member's interest. + + member is an LCE/KEY and realname is an arbitrary string. + NotAMemberError is raised if member does not refer to a valid member. + topics must be a sequence of strings. + """ + raise NotImplemented + + def setDeliveryStatus(self, member, status): + """Set the delivery status of the member's address. + + Status must be one of the module constants: + + ENABLED - The deliveries to the user are not disabled + UNKNOWN - Deliveries are disabled for unknown reasons. The + primary reason for this to happen is that we've copied + their delivery status from a legacy version which didn't + keep track of disable reasons + BYUSER - The user explicitly disable deliveries + BYADMIN - The list administrator explicitly disabled deliveries + BYBOUNCE - The system disabled deliveries due to bouncing + + This method also records the time (in seconds since epoch) at which + the last status change was made. If the delivery status is changed to + ENABLED, then the change time information will be deleted. This value + is retrievable via getDeliveryStatusChangeTime(). + """ + raise NotImplemented + + def setBounceInfo(self, member, info): + """Set the member's bounce information. + + When info is None, any bounce info for the member is cleared. + + Bounce info is opaque to the MemberAdaptor. It is set by this method + and returned by getBounceInfo() without modification. + """ + raise NotImplemented diff --git a/Mailman/Message.py b/Mailman/Message.py new file mode 100644 index 00000000..b82ddf81 --- /dev/null +++ b/Mailman/Message.py @@ -0,0 +1,274 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Standard Mailman message object. + +This is a subclass of mimeo.Message but provides a slightly extended interface +which is more convenient for use inside Mailman. +""" + +import email +import email.Message +import email.Utils +from email.Charset import Charset +from email.Header import Header + +from types import ListType, StringType + +from Mailman import mm_cfg +from Mailman import Utils + +COMMASPACE = ', ' + +VERSION = tuple([int(s) for s in email.__version__.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) + + # 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, StringType): + 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, use_envelope=None, preserve_case=0): + """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 mm_cfg.USE_ENVELOPE_SENDER is true, then the search order is + Sender:, From:, unixfrom + + - Otherwise, the search order is From:, Sender:, unixfrom + + The optional argument use_envelope, if given overrides the + mm_cfg.USE_ENVELOPE_SENDER setting. It should be set to either 0 or 1 + (don't use None since that indicates no-override). + + unixfrom should never be empty. The return address is always + lowercased, unless preserve_case is true. + + This method differs from get_senders() in that it returns one and only + one address, and uses a different search order. + """ + senderfirst = mm_cfg.USE_ENVELOPE_SENDER + if use_envelope is not None: + senderfirst = use_envelope + 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 = '' + if not preserve_case: + return address.lower() + return address + + def get_senders(self, preserve_case=0, headers=None): + """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 + 3. Reply-To: + 4. Sender: + + The return addresses are always lower cased, unless `preserve_case' is + true. Optional `headers' gives an alternative search order, with None + meaning, search the unixfrom header. Items in `headers' are field + names without the trailing colon. + """ + if headers is None: + headers = mm_cfg.SENDER_HEADERS + pairs = [] + for h in headers: + if h is None: + # get_unixfrom() returns None if there's no envelope + fieldval = self.get_unixfrom() or '' + try: + pairs.append(('', fieldval.split()[1])) + except IndexError: + # Ignore badly formatted unixfroms + pass + else: + fieldvals = self.get_all(h) + if fieldvals: + pairs.extend(email.Utils.getaddresses(fieldvals)) + authors = [] + for pair in pairs: + address = pair[1] + if address is not None and not preserve_case: + address = address.lower() + authors.append(address) + return authors + + + +class UserNotification(Message): + """Class for internally crafted messages.""" + + def __init__(self, recip, sender, subject=None, text=None, lang=None): + Message.__init__(self) + charset = None + if lang is not None: + charset = Charset(Utils.GetCharSet(lang)) + if text is not None: + self.set_payload(text, charset) + if subject is None: + subject = '(no subject)' + self['Subject'] = Header(subject, charset, header_name='Subject') + self['From'] = sender + if isinstance(recip, ListType): + 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. Yes, the MTA would give us one, but + # this is useful for logging to logs/smtp. + if not self.has_key('message-id'): + self['Message-ID'] = Utils.unique_message_id(mlist) + # Ditto for Date: which is required by RFC 2822 + if not self.has_key('date'): + self['Date'] = email.Utils.formatdate(localtime=1) + # 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 not self.has_key('precedence'): + self['Precedence'] = 'bulk' + self._enqueue(mlist, **_kws) + + def _enqueue(self, mlist, **_kws): + # Not imported at module scope to avoid import loop + from Mailman.Queue.sbcache import get_switchboard + virginq = get_switchboard(mm_cfg.VIRGINQUEUE_DIR) + # The message metadata better have a `recip' attribute + virginq.enqueue(self, + listname = mlist.internal_name(), + recips = self.recips, + nodecorate = 1, + reduced_list_headers = 1, + **_kws) + + + +class OwnerNotification(UserNotification): + """Like user notifications, but this message goes to the list owners.""" + + def __init__(self, mlist, subject=None, text=None, tomoderators=1): + recips = mlist.owner[:] + if tomoderators: + recips.extend(mlist.moderator) + # We have to set the owner to the site's -bounces address, otherwise + # we'll get a mail loop if an owner's address bounces. + sender = Utils.get_site_email(mlist.host_name, 'bounces') + 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.GetOwnerEmail() + self._sender = sender + + def _enqueue(self, mlist, **_kws): + # Not imported at module scope to avoid import loop + from Mailman.Queue.sbcache import get_switchboard + virginq = get_switchboard(mm_cfg.VIRGINQUEUE_DIR) + # The message metadata better have a `recip' attribute + virginq.enqueue(self, + listname = mlist.internal_name(), + recips = self.recips, + nodecorate = 1, + reduced_list_headers = 1, + envsender = self._sender, + **_kws) diff --git a/Mailman/OldStyleMemberships.py b/Mailman/OldStyleMemberships.py new file mode 100644 index 00000000..cc42cb90 --- /dev/null +++ b/Mailman/OldStyleMemberships.py @@ -0,0 +1,353 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Old style Mailman membership adaptor. + +This adaptor gets and sets member information on the MailList object given to +the constructor. It also equates member keys and lower-cased email addresses, +i.e. KEY is LCE. + +This is the adaptor used by default in Mailman 2.1. +""" + +import time +from types import StringType + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import Errors +from Mailman import MemberAdaptor + +ISREGULAR = 1 +ISDIGEST = 2 + +# XXX check for bare access to mlist.members, mlist.digest_members, +# mlist.user_options, mlist.passwords, mlist.topics_userinterest + +# XXX Fix Errors.MMAlreadyAMember and Errors.NotAMember +# Actually, fix /all/ errors + + + +class OldStyleMemberships(MemberAdaptor.MemberAdaptor): + def __init__(self, mlist): + self.__mlist = mlist + + # + # Read interface + # + def getMembers(self): + return self.__mlist.members.keys() + self.__mlist.digest_members.keys() + + def getRegularMemberKeys(self): + return self.__mlist.members.keys() + + def getDigestMemberKeys(self): + return self.__mlist.digest_members.keys() + + def __get_cp_member(self, member): + lcmember = member.lower() + missing = [] + val = self.__mlist.members.get(lcmember, missing) + if val is not missing: + if isinstance(val, StringType): + return val, ISREGULAR + else: + return lcmember, ISREGULAR + val = self.__mlist.digest_members.get(lcmember, missing) + if val is not missing: + if isinstance(val, StringType): + return val, ISDIGEST + else: + return lcmember, ISDIGEST + return None, None + + def isMember(self, member): + cpaddr, where = self.__get_cp_member(member) + if cpaddr is not None: + return 1 + return 0 + + def getMemberKey(self, member): + cpaddr, where = self.__get_cp_member(member) + if cpaddr is None: + raise Errors.NotAMemberError, member + return member.lower() + + def getMemberCPAddress(self, member): + cpaddr, where = self.__get_cp_member(member) + if cpaddr is None: + raise Errors.NotAMemberError, member + return cpaddr + + def getMemberCPAddresses(self, members): + return [self.__get_cp_member(member)[0] for member in members] + + def getMemberPassword(self, member): + secret = self.__mlist.passwords.get(member.lower()) + if secret is None: + raise Errors.NotAMemberError, member + return secret + + def authenticateMember(self, member, response): + secret = self.getMemberPassword(member) + if secret == response: + return secret + return 0 + + def __assertIsMember(self, member): + if not self.isMember(member): + raise Errors.NotAMemberError, member + + def getMemberLanguage(self, member): + return self.__mlist.language.get(member.lower(), + self.__mlist.preferred_language) + + def getMemberOption(self, member, flag): + self.__assertIsMember(member) + if flag == mm_cfg.Digests: + cpaddr, where = self.__get_cp_member(member) + return where == ISDIGEST + option = self.__mlist.user_options.get(member.lower(), 0) + return not not (option & flag) + + def getMemberName(self, member): + self.__assertIsMember(member) + return self.__mlist.usernames.get(member.lower()) + + def getMemberTopics(self, member): + self.__assertIsMember(member) + return self.__mlist.topics_userinterest.get(member.lower(), []) + + def getDeliveryStatus(self, member): + self.__assertIsMember(member) + return self.__mlist.delivery_status.get( + member.lower(), + # Values are tuples, so the default should also be a tuple. The + # second item will be ignored. + (MemberAdaptor.ENABLED, 0))[0] + + def getDeliveryStatusChangeTime(self, member): + self.__assertIsMember(member) + return self.__mlist.delivery_status.get( + member.lower(), + # Values are tuples, so the default should also be a tuple. The + # second item will be ignored. + (MemberAdaptor.ENABLED, 0))[1] + + def getDeliveryStatusMembers(self, status=(MemberAdaptor.UNKNOWN, + MemberAdaptor.BYUSER, + MemberAdaptor.BYADMIN, + MemberAdaptor.BYBOUNCE)): + return [member for member in self.getMembers() + if self.getDeliveryStatus(member) in status] + + def getBouncingMembers(self): + return [member.lower() for member in self.__mlist.bounce_info.keys()] + + def getBounceInfo(self, member): + self.__assertIsMember(member) + return self.__mlist.bounce_info.get(member.lower()) + + # + # Write interface + # + def addNewMember(self, member, **kws): + assert self.__mlist.Locked() + # Make sure this address isn't already a member + if self.__mlist.isMember(member): + raise Errors.MMAlreadyAMember, member + # Parse the keywords + digest = 0 + password = Utils.MakeRandomPassword() + language = self.__mlist.preferred_language + realname = None + if kws.has_key('digest'): + digest = kws['digest'] + del kws['digest'] + if kws.has_key('password'): + password = kws['password'] + del kws['password'] + if kws.has_key('language'): + language = kws['language'] + del kws['language'] + if kws.has_key('realname'): + realname = kws['realname'] + del kws['realname'] + # Assert that no other keywords are present + if kws: + raise ValueError, kws.keys() + # If the localpart has uppercase letters in it, then the value in the + # members (or digest_members) dict is the case preserved address. + # Otherwise the value is 0. Note that the case of the domain part is + # of course ignored. + if Utils.LCDomain(member) == member.lower(): + value = 0 + else: + value = member + member = member.lower() + if digest: + self.__mlist.digest_members[member] = value + else: + self.__mlist.members[member] = value + self.setMemberPassword(member, password) + + self.setMemberLanguage(member, language) + if realname: + self.setMemberName(member, realname) + # Set the member's default set of options + if self.__mlist.new_member_options: + self.__mlist.user_options[member] = self.__mlist.new_member_options + + def removeMember(self, member): + assert self.__mlist.Locked() + self.__assertIsMember(member) + # Delete the appropriate entries from the various MailList attributes. + # Remember that not all of them will have an entry (only those with + # values different than the default). + memberkey = member.lower() + for attr in ('passwords', 'user_options', 'members', 'digest_members', + 'language', 'topics_userinterest', 'usernames', + 'bounce_info', 'delivery_status', + ): + dict = getattr(self.__mlist, attr) + if dict.has_key(memberkey): + del dict[memberkey] + + def changeMemberAddress(self, member, newaddress, nodelete=0): + assert self.__mlist.Locked() + # Make sure the old address is a member. Assertions that the new + # address is not already a member is done by addNewMember() below. + self.__assertIsMember(member) + # Get the old values + memberkey = member.lower() + fullname = self.getMemberName(memberkey) + flags = self.__mlist.user_options.get(memberkey, 0) + digestsp = self.getMemberOption(memberkey, mm_cfg.Digests) + password = self.__mlist.passwords.get(memberkey, + Utils.MakeRandomPassword()) + lang = self.getMemberLanguage(memberkey) + # Add the new member + self.addNewMember(newaddress, realname=fullname, digest=digestsp, + password=password, language=lang) + # Set the entire options bitfield + if flags: + self.__mlist.user_options[memberkey] = flags + # Delete the old memberkey + if not nodelete: + self.removeMember(memberkey) + + def setMemberPassword(self, memberkey, password): + assert self.__mlist.Locked() + self.__assertIsMember(memberkey) + self.__mlist.passwords[memberkey.lower()] = password + + def setMemberLanguage(self, memberkey, language): + assert self.__mlist.Locked() + self.__assertIsMember(memberkey) + self.__mlist.language[memberkey.lower()] = language + + def setMemberOption(self, member, flag, value): + assert self.__mlist.Locked() + self.__assertIsMember(member) + memberkey = member.lower() + # There's one extra gotcha we have to deal with. If the user is + # toggling the Digests flag, then we need to move their entry from + # mlist.members to mlist.digest_members or vice versa. Blarg. Do + # this before the flag setting below in case it fails. + if flag == mm_cfg.Digests: + if value: + # Be sure the list supports digest delivery + if not self.__mlist.digestable: + raise Errors.CantDigestError + # The user is turning on digest mode + if self.__mlist.digest_members.has_key(memberkey): + raise Errors.AlreadyReceivingDigests, member + cpuser = self.__mlist.members.get(memberkey) + if cpuser is None: + raise Errors.NotAMemberError, member + del self.__mlist.members[memberkey] + self.__mlist.digest_members[memberkey] = cpuser + else: + # Be sure the list supports regular delivery + if not self.__mlist.nondigestable: + raise Errors.MustDigestError + # The user is turning off digest mode + if self.__mlist.members.has_key(memberkey): + raise Errors.AlreadyReceivingRegularDeliveries, member + cpuser = self.__mlist.digest_members.get(memberkey) + if cpuser is None: + raise Errors.NotAMemberError, member + del self.__mlist.digest_members[memberkey] + self.__mlist.members[memberkey] = cpuser + # When toggling off digest delivery, we want to be sure to set + # things up so that the user receives one last digest, + # otherwise they may lose some email + self.__mlist.one_last_digest[memberkey] = cpuser + # We don't need to touch user_options because the digest state + # isn't kept as a bitfield flag. + return + # This is a bit kludgey because the semantics are that if the user has + # no options set (i.e. the value would be 0), then they have no entry + # in the user_options dict. We use setdefault() here, and then del + # the entry below just to make things (questionably) cleaner. + self.__mlist.user_options.setdefault(memberkey, 0) + if value: + self.__mlist.user_options[memberkey] |= flag + else: + self.__mlist.user_options[memberkey] &= ~flag + if not self.__mlist.user_options[memberkey]: + del self.__mlist.user_options[memberkey] + + def setMemberName(self, member, realname): + assert self.__mlist.Locked() + self.__assertIsMember(member) + self.__mlist.usernames[member.lower()] = realname + + def setMemberTopics(self, member, topics): + assert self.__mlist.Locked() + self.__assertIsMember(member) + memberkey = member.lower() + if topics: + self.__mlist.topics_userinterest[memberkey] = topics + # if topics is empty, then delete the entry in this dictionary + elif self.__mlist.topics_userinterest.has_key(memberkey): + del self.__mlist.topics_userinterest[memberkey] + + def setDeliveryStatus(self, member, status): + assert status in (MemberAdaptor.ENABLED, MemberAdaptor.UNKNOWN, + MemberAdaptor.BYUSER, MemberAdaptor.BYADMIN, + MemberAdaptor.BYBOUNCE) + assert self.__mlist.Locked() + self.__assertIsMember(member) + member = member.lower() + if status == MemberAdaptor.ENABLED: + self.setBounceInfo(member, None) + # Otherwise, nothing to do + else: + self.__mlist.delivery_status[member] = (status, time.time()) + + def setBounceInfo(self, member, info): + assert self.__mlist.Locked() + self.__assertIsMember(member) + member = member.lower() + if info is None: + if self.__mlist.bounce_info.has_key(member): + del self.__mlist.bounce_info[member] + if self.__mlist.delivery_status.has_key(member): + del self.__mlist.delivery_status[member] + else: + self.__mlist.bounce_info[member] = info diff --git a/Mailman/Pending.py b/Mailman/Pending.py new file mode 100644 index 00000000..be1c6cac --- /dev/null +++ b/Mailman/Pending.py @@ -0,0 +1,204 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +""" Track pending confirmation of subscriptions. + +new(stuff...) places an item's data in the db, returning its cookie. + +confirmed(cookie) returns a tuple for the data, removing the item +from the db. It returns None if the cookie is not registered. +""" + +import os +import time +import sha +import marshal +import cPickle +import random +import errno + +from Mailman import mm_cfg +from Mailman import LockFile + +DBFILE = os.path.join(mm_cfg.DATA_DIR, 'pending.db') +PCKFILE = os.path.join(mm_cfg.DATA_DIR, 'pending.pck') +LOCKFILE = os.path.join(mm_cfg.LOCK_DIR, 'pending.lock') + +# Types of pending records +SUBSCRIPTION = 'S' +UNSUBSCRIPTION = 'U' +CHANGE_OF_ADDRESS = 'C' +HELD_MESSAGE = 'H' +RE_ENABLE = 'E' + +_ALLKEYS = [(x,) for x in (SUBSCRIPTION, UNSUBSCRIPTION, + CHANGE_OF_ADDRESS, HELD_MESSAGE, + RE_ENABLE, + )] + + + +def new(*content): + """Create a new entry in the pending database, returning cookie for it.""" + # It's a programming error if this assertion fails! We do it this way so + # the assert test won't fail if the sequence is empty. + assert content[:1] in _ALLKEYS + # Acquire the pending database lock, letting TimeOutError percolate up. + lock = LockFile.LockFile(LOCKFILE) + lock.lock(timeout=30) + try: + # Load the current database + db = _load() + # Calculate a unique cookie + while 1: + n = random.random() + now = time.time() + hashfood = str(now) + str(n) + str(content) + cookie = sha.new(hashfood).hexdigest() + if not db.has_key(cookie): + break + # Store the content, plus the time in the future when this entry will + # be evicted from the database, due to staleness. + db[cookie] = content + evictions = db.setdefault('evictions', {}) + evictions[cookie] = now + mm_cfg.PENDING_REQUEST_LIFE + _save(db) + return cookie + finally: + lock.unlock() + + + +def confirm(cookie, expunge=1): + """Return data for cookie, or None if not found. + + If optional expunge is true (the default), the record is also removed from + the database. + """ + # Acquire the pending database lock, letting TimeOutError percolate up. + # BAW: we perhaps shouldn't acquire the lock if expunge==0. + lock = LockFile.LockFile(LOCKFILE) + lock.lock(timeout=30) + try: + # Load the database + db = _load() + missing = [] + content = db.get(cookie, missing) + if content is missing: + return None + # Remove the entry from the database + if expunge: + del db[cookie] + del db['evictions'][cookie] + _save(db) + return content + finally: + lock.unlock() + + + +def _load(): + # The list's lock must be acquired. + # + # First try to load the pickle file + fp = None + try: + try: + fp = open(PCKFILE) + return cPickle.load(fp) + except IOError, e: + if e.errno <> errno.ENOENT: raise + try: + # Try to load the old DBFILE + fp = open(DBFILE) + return marshal.load(fp) + except IOError, e: + if e.errno <> errno.ENOENT: raise + # Fresh pendings database + return {'evictions': {}} + finally: + if fp: + fp.close() + + +def _save(db): + # Lock must be acquired. + evictions = db['evictions'] + now = time.time() + for cookie, data in db.items(): + if cookie in ('evictions', 'version'): + continue + timestamp = evictions[cookie] + if now > timestamp: + # The entry is stale, so remove it. + del db[cookie] + del evictions[cookie] + # Clean out any bogus eviction entries. + for cookie in evictions.keys(): + if not db.has_key(cookie): + del evictions[cookie] + db['version'] = mm_cfg.PENDING_FILE_SCHEMA_VERSION + omask = os.umask(007) + # Always save this as a pickle (safely), and after that succeeds, blow + # away any old marshal file. + tmpfile = PCKFILE + '.tmp' + fp = None + try: + fp = open(tmpfile, 'w') + cPickle.dump(db, fp) + fp.close() + fp = None + os.rename(tmpfile, PCKFILE) + if os.path.exists(DBFILE): + os.remove(DBFILE) + finally: + if fp: + fp.close() + os.umask(omask) + + + +def _update(olddb): + # Update an old pending_subscriptions.db database to the new format + lock = LockFile.LockFile(LOCKFILE) + lock.lock(timeout=30) + try: + # We don't need this entry anymore + if olddb.has_key('lastculltime'): + del olddb['lastculltime'] + db = _load() + evictions = db.setdefault('evictions', {}) + for cookie, data in olddb.items(): + # The cookies used to be kept as a 6 digit integer. We now keep + # the cookies as a string (sha in our case, but it doesn't matter + # for cookie matching). + cookie = str(cookie) + # The old format kept the content as a tuple and tacked the + # timestamp on as the last element of the tuple. We keep the + # timestamps separate, but require the prepending of a record type + # indicator. We know that the only things that were kept in the + # old format were subscription requests. Also, the old request + # format didn't have the subscription language. Best we can do + # here is use the server default. + db[cookie] = (SUBSCRIPTION,) + data[:-1] + \ + (mm_cfg.DEFAULT_SERVER_LANGUAGE,) + # The old database format kept the timestamp as the time the + # request was made. The new format keeps it as the time the + # request should be evicted. + evictions[cookie] = data[-1] + mm_cfg.PENDING_REQUEST_LIFE + _save(db) + finally: + lock.unlock() diff --git a/Mailman/Post.py b/Mailman/Post.py new file mode 100644 index 00000000..a184f1cf --- /dev/null +++ b/Mailman/Post.py @@ -0,0 +1,61 @@ +#! /usr/bin/env 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +import sys + +from Mailman import mm_cfg +from Mailman.Queue.sbcache import get_switchboard + + + +def inject(listname, msg, recips=None, qdir=None): + if qdir is None: + qdir = mm_cfg.INQUEUE_DIR + queue = get_switchboard(qdir) + kws = {'listname' : listname, + 'tolist' : 1, + '_plaintext': 1, + } + if recips: + kws['recips'] = recips + queue.enqueue(msg, **kws) + + + +if __name__ == '__main__': + # When called as a command line script, standard input is read to get the + # list that this message is destined to, the list of explicit recipients, + # and the message to send (in its entirety). stdin must have the + # following format: + # + # line 1: the internal name of the mailing list + # line 2: the number of explicit recipients to follow. 0 means to use the + # list's membership to calculate recipients. + # line 3 - 3+recipnum: explicit recipients, one per line + # line 4+recipnum - end of file: the message in RFC 822 format (may + # include an initial Unix-from header) + listname = sys.stdin.readline().strip() + numrecips = int(sys.stdin.readline()) + if numrecips == 0: + recips = None + else: + recips = [] + for i in range(numrecips): + recips.append(sys.stdin.readline().strip()) + # If the message isn't parsable, we won't get an error here + inject(listname, sys.stdin.read(), recips) diff --git a/Mailman/Queue/.cvsignore b/Mailman/Queue/.cvsignore new file mode 100644 index 00000000..f3c7a7c5 --- /dev/null +++ b/Mailman/Queue/.cvsignore @@ -0,0 +1 @@ +Makefile diff --git a/Mailman/Queue/ArchRunner.py b/Mailman/Queue/ArchRunner.py new file mode 100644 index 00000000..14097332 --- /dev/null +++ b/Mailman/Queue/ArchRunner.py @@ -0,0 +1,76 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Outgoing queue runner.""" + +import time +from email.Utils import parsedate_tz, mktime_tz, formatdate + +from Mailman import mm_cfg +from Mailman import LockFile +from Mailman.Queue.Runner import Runner + + + +class ArchRunner(Runner): + QDIR = mm_cfg.ARCHQUEUE_DIR + + def _dispose(self, mlist, msg, msgdata): + # Support clobber_date, i.e. setting the date in the archive to the + # received date, not the (potentially bogus) Date: header of the + # original message. + clobber = 0 + originaldate = msg.get('date') + receivedtime = formatdate(msgdata['received_time']) + if not originaldate: + clobber = 1 + elif mm_cfg.ARCHIVER_CLOBBER_DATE_POLICY == 1: + clobber = 1 + elif mm_cfg.ARCHIVER_CLOBBER_DATE_POLICY == 2: + # what's the timestamp on the original message? + tup = parsedate_tz(originaldate) + now = time.time() + try: + if not tup: + clobber = 1 + elif abs(now - mktime_tz(tup)) > \ + mm_cfg.ARCHIVER_ALLOWABLE_SANE_DATE_SKEW: + clobber = 1 + except ValueError: + # The likely cause of this is that the year in the Date: field + # is horribly incorrect, e.g. (from SF bug # 571634): + # Date: Tue, 18 Jun 0102 05:12:09 +0500 + # Obviously clobber such dates. + clobber = 1 + if clobber: + del msg['date'] + del msg['x-original-date'] + msg['Date'] = receivedtime + if originaldate: + msg['X-Original-Date'] = originaldate + # Always put an indication of when we received the message. + msg['X-List-Received-Date'] = receivedtime + # Now try to get the list lock + try: + mlist.Lock(timeout=mm_cfg.LIST_LOCK_TIMEOUT) + except LockFile.TimeOutError: + # oh well, try again later + return 1 + try: + mlist.ArchiveMail(msg) + mlist.Save() + finally: + mlist.Unlock() diff --git a/Mailman/Queue/BounceRunner.py b/Mailman/Queue/BounceRunner.py new file mode 100644 index 00000000..e59ac47e --- /dev/null +++ b/Mailman/Queue/BounceRunner.py @@ -0,0 +1,195 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Bounce queue runner.""" + +import re +from email.MIMEText import MIMEText +from email.MIMEMessage import MIMEMessage +from email.Utils import parseaddr + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import LockFile +from Mailman.Message import UserNotification +from Mailman.Bouncers import BouncerAPI +from Mailman.Queue.Runner import Runner +from Mailman.Queue.sbcache import get_switchboard +from Mailman.Logging.Syslog import syslog +from Mailman.i18n import _ + +COMMASPACE = ', ' + + + +class BounceRunner(Runner): + QDIR = mm_cfg.BOUNCEQUEUE_DIR + # We only do bounce processing once per minute. + SLEEPTIME = mm_cfg.minutes(1) + + def _dispose(self, mlist, msg, msgdata): + # Make sure we have the most up-to-date state + mlist.Load() + outq = get_switchboard(mm_cfg.OUTQUEUE_DIR) + # There are a few possibilities here: + # + # - the message could have been VERP'd in which case, we know exactly + # who the message was destined for. That make our job easy. + # - the message could have been originally destined for a list owner, + # but a list owner address itself bounced. That's bad, and for now + # we'll simply log the problem and attempt to deliver the message to + # the site owner. + # + # All messages to list-owner@vdom.ain have their envelope sender set + # to site-owner@dom.ain (no virtual domain). Is this a bounce for a + # message to a list owner, coming to the site owner? + if msg.get('to', '') == Utils.get_site_email(extra='-owner'): + # Send it on to the site owners, but craft the envelope sender to + # be the -loop detection address, so if /they/ bounce, we won't + # get stuck in a bounce loop. + outq.enqueue(msg, msgdata, + recips=[Utils.get_site_email()], + envsender=Utils.get_site_email(extra='loop'), + ) + # List isn't doing bounce processing? + if not mlist.bounce_processing: + return + # Try VERP detection first, since it's quick and easy + addrs = verp_bounce(mlist, msg) + if not addrs: + # That didn't give us anything useful, so try the old fashion + # bounce matching modules + addrs = BouncerAPI.ScanMessages(mlist, msg) + # If that still didn't return us any useful addresses, then send it on + # or discard it. + if not addrs: + syslog('bounce', 'bounce message w/no discernable addresses: %s', + msg.get('message-id')) + maybe_forward(mlist, msg) + return + # BAW: It's possible that there are None's in the list of addresses, + # although I'm unsure how that could happen. Possibly ScanMessages() + # can let None's sneak through. In any event, this will kill them. + addrs = filter(None, addrs) + # Okay, we have some recognized addresses. We now need to register + # the bounces for each of these. If the bounce came to the site list, + # then we'll register the address on every list in the system, but + # note: this could be VERY resource intensive! + foundp = 0 + listname = mlist.internal_name() + if listname == mm_cfg.MAILMAN_SITE_LIST: + foundp = 1 + for listname in Utils.list_names(): + xlist = self._open_list(listname) + xlist.Load() + for addr in addrs: + if xlist.isMember(addr): + unlockp = 0 + if not xlist.Locked(): + try: + xlist.Lock(timeout=mm_cfg.LIST_LOCK_TIMEOUT) + except LockFile.TimeOutError: + # Oh well, forget aboutf this list + continue + unlockp = 1 + try: + xlist.registerBounce(addr, msg) + foundp = 1 + xlist.Save() + finally: + if unlockp: + xlist.Unlock() + else: + try: + mlist.Lock(timeout=mm_cfg.LIST_LOCK_TIMEOUT) + except LockFile.TimeOutError: + # Try again later + syslog('bounce', "%s: couldn't get list lock", listname) + return 1 + else: + try: + for addr in addrs: + if mlist.isMember(addr): + mlist.registerBounce(addr, msg) + foundp = 1 + mlist.Save() + finally: + mlist.Unlock() + if not foundp: + # It means an address was recognized but it wasn't an address + # that's on any mailing list at this site. BAW: don't forward + # these, but do log it. + syslog('bounce', 'bounce message with non-members of %s: %s', + listname, COMMASPACE.join(addrs)) + #maybe_forward(mlist, msg) + + + +def verp_bounce(mlist, msg): + bmailbox, bdomain = Utils.ParseEmail(mlist.GetBouncesEmail()) + # Sadly not every MTA bounces VERP messages correctly, or consistently. + # Fall back to Delivered-To: (Postfix), Envelope-To: (Exim) and + # Apparently-To:, and then short-circuit if we still don't have anything + # to work with. Note that there can be multiple Delivered-To: headers so + # we need to search them all (and we don't worry about false positives for + # forwarded email, because only one should match VERP_REGEXP). + vals = [] + for header in ('to', 'delivered-to', 'envelope-to', 'apparently-to'): + vals.extend(msg.get_all(header, [])) + for field in vals: + to = parseaddr(field)[1] + if not to: + continue # empty header + mo = re.search(mm_cfg.VERP_REGEXP, to) + if not mo: + continue # no match of regexp + try: + if bmailbox <> mo.group('bounces'): + continue # not a bounce to our list + # All is good + addr = '%s@%s' % mo.group('mailbox', 'host') + except IndexError: + syslog('error', + "VERP_REGEXP doesn't yield the right match groups: %s", + mm_cfg.VERP_REGEXP) + return [] + return [addr] + + + +def maybe_forward(mlist, msg): + # Does the list owner want to get non-matching bounce messages? + # If not, simply discard it. + if mlist.bounce_unrecognized_goes_to_list_owner: + adminurl = mlist.GetScriptURL('admin', absolute=1) + '/bounce' + mlist.ForwardMessage(msg, + text=_("""\ +The attached message was received as a bounce, but either the bounce format +was not recognized, or no member addresses could be extracted from it. This +mailing list has been configured to send all unrecognized bounce messages to +the list administrator(s). + +For more information see: +%(adminurl)s + +"""), + subject=_('Uncaught bounce notification'), + tomoderators=0) + syslog('bounce', 'forwarding unrecognized, message-id: %s', + msg.get('message-id', 'n/a')) + else: + syslog('bounce', 'discarding unrecognized, message-id: %s', + msg.get('message-id', 'n/a')) diff --git a/Mailman/Queue/CommandRunner.py b/Mailman/Queue/CommandRunner.py new file mode 100644 index 00000000..303d4c52 --- /dev/null +++ b/Mailman/Queue/CommandRunner.py @@ -0,0 +1,220 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""-request robot command queue runner.""" + +# See the delivery diagram in IncomingRunner.py. This module handles all +# email destined for mylist-request, -join, and -leave. It no longer handles +# bounce messages (i.e. -admin or -bounces), nor does it handle mail to +# -owner. + + + +# BAW: get rid of this when we Python 2.2 is a minimum requirement. +from __future__ import nested_scopes + +import sys +import re +from types import StringType, UnicodeType + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import Message +from Mailman.Handlers import Replybot +from Mailman.i18n import _ +from Mailman.Queue.Runner import Runner +from Mailman.Logging.Syslog import syslog +from Mailman import LockFile + +from email.MIMEText import MIMEText +from email.MIMEMessage import MIMEMessage +from email.Iterators import typed_subpart_iterator + +NL = '\n' + + + +class Results: + def __init__(self, mlist, msg, msgdata): + self.mlist = mlist + self.msg = msg + self.msgdata = msgdata + # Only set returnaddr if the response is to go to someone other than + # the address specified in the From: header (e.g. for the password + # command). + self.returnaddr = None + self.commands = [] + self.results = [] + self.ignored = [] + self.lineno = 0 + self.subjcmdretried = 0 + self.respond = 1 + # Always process the Subject: header first + self.commands.append(msg.get('subject', '')) + # Find the first text/plain part + part = None + for part in typed_subpart_iterator(msg, 'text', 'plain'): + break + if part is None or part is not msg: + # Either there was no text/plain part or we ignored some + # non-text/plain parts. + self.results.append(_('Ignoring non-text/plain MIME parts')) + if part is None: + # E.g the outer Content-Type: was text/html + return + body = part.get_payload() + # text/plain parts better have string payloads + assert isinstance(body, StringType) or isinstance(body, UnicodeType) + lines = body.splitlines() + # Use no more lines than specified + self.commands.extend(lines[:mm_cfg.DEFAULT_MAIL_COMMANDS_MAX_LINES]) + self.ignored.extend(lines[mm_cfg.DEFAULT_MAIL_COMMANDS_MAX_LINES:]) + + def process(self): + # Now, process each line until we find an error. The first + # non-command line found stops processing. + stop = 0 + for line in self.commands: + if line and line.strip(): + args = line.split() + cmd = args.pop(0).lower() + stop = self.do_command(cmd, args) + self.lineno += 1 + if stop: + break + + def do_command(self, cmd, args=None): + if args is None: + args = () + # Try to import a command handler module for this command + modname = 'Mailman.Commands.cmd_' + cmd + try: + __import__(modname) + handler = sys.modules[modname] + except ImportError: + # If we're on line zero, it was the Subject: header that didn't + # contain a command. It's possible there's a Re: prefix (or + # localized version thereof) on the Subject: line that's messing + # things up. Pop the prefix off and try again... once. + # + # If that still didn't work it isn't enough to stop processing. + # BAW: should we include a message that the Subject: was ignored? + if not self.subjcmdretried and args: + self.subjcmdretried += 1 + cmd = args.pop(0) + return self.do_command(cmd, args) + return self.lineno <> 0 + return handler.process(self, args) + + def send_response(self): + # Helper + def indent(lines): + return [' ' + line for line in lines] + # Quick exit for some commands which don't need a response + if not self.respond: + return + resp = [Utils.wrap(_("""\ +The results of your email command are provided below. +Attached is your original message. +"""))] + if self.results: + resp.append(_('- Results:')) + resp.extend(indent(self.results)) + # Ignore empty lines + unprocessed = [line for line in self.commands[self.lineno:] + if line and line.strip()] + if unprocessed: + resp.append(_('\n- Unprocessed:')) + resp.extend(indent(unprocessed)) + if self.ignored: + resp.append(_('\n- Ignored:')) + resp.extend(indent(self.ignored)) + resp.append(_('\n- Done.\n\n')) + results = MIMEText( + NL.join(resp), + _charset=Utils.GetCharSet(self.mlist.preferred_language)) + # Safety valve for mail loops with misconfigured email 'bots. We + # don't respond to commands sent with "Precedence: bulk|junk|list" + # unless they explicitly "X-Ack: yes", but not all mail 'bots are + # correctly configured, so we max out the number of responses we'll + # give to an address in a single day. + # + # BAW: We wait until now to make this decision since our sender may + # not be self.msg.get_sender(), but I'm not sure this is right. + recip = self.returnaddr or self.msg.get_sender() + if not self.mlist.autorespondToSender(recip): + return + msg = Message.UserNotification( + recip, + self.mlist.GetBouncesEmail(), + _('The results of your email commands'), + lang=self.mlist.preferred_language) + msg.set_type('multipart/mixed') + msg.attach(results) + orig = MIMEMessage(self.msg) + msg.attach(orig) + msg.send(self.mlist) + + + +class CommandRunner(Runner): + QDIR = mm_cfg.CMDQUEUE_DIR + + def _dispose(self, mlist, msg, msgdata): + # The policy here is similar to the Replybot policy. If a message has + # "Precedence: bulk|junk|list" and no "X-Ack: yes" header, we discard + # it to prevent replybot response storms. + precedence = msg.get('precedence', '').lower() + ack = msg.get('x-ack', '').lower() + if ack <> 'yes' and precedence in ('bulk', 'junk', 'list'): + syslog('vette', 'Precedence: %s message discarded by: %s', + precedence, mlist.GetRequestEmail()) + return 0 + # Do replybot for commands + mlist.Load() + Replybot.process(mlist, msg, msgdata) + if mlist.autorespond_requests == 1: + syslog('vette', 'replied and discard') + # w/discard + return 0 + # Now craft the response + res = Results(mlist, msg, msgdata) + # BAW: Not all the functions of this qrunner require the list to be + # locked. Still, it's more convenient to lock it here and now and + # deal with lock failures in one place. + try: + mlist.Lock(timeout=mm_cfg.LIST_LOCK_TIMEOUT) + except LockFile.TimeOutError: + # Oh well, try again later + return 1 + # This message will have been delivered to one of mylist-request, + # mylist-join, or mylist-leave, and the message metadata will contain + # a key to which one was used. + try: + if msgdata.get('torequest'): + res.process() + elif msgdata.get('tojoin'): + res.do_command('join') + elif msgdata.get('toleave'): + res.do_command('leave') + elif msgdata.get('toconfirm'): + mo = re.match(mm_cfg.VERP_CONFIRM_REGEXP, msg.get('to', '')) + if mo: + res.do_command('confirm', (mo.group('cookie'),)) + res.send_response() + mlist.Save() + finally: + mlist.Unlock() diff --git a/Mailman/Queue/IncomingRunner.py b/Mailman/Queue/IncomingRunner.py new file mode 100644 index 00000000..4a60ceb9 --- /dev/null +++ b/Mailman/Queue/IncomingRunner.py @@ -0,0 +1,170 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Incoming queue runner.""" + +# A typical Mailman list exposes nine aliases which point to seven different +# wrapped scripts. E.g. for a list named `mylist', you'd have: +# +# mylist-bounces -> bounces (-admin is a deprecated alias) +# mylist-confirm -> confirm +# mylist-join -> join (-subscribe is an alias) +# mylist-leave -> leave (-unsubscribe is an alias) +# mylist-owner -> owner +# mylist -> post +# mylist-request -> request +# +# -request, -join, and -leave are a robot addresses; their sole purpose is to +# process emailed commands in a Majordomo-like fashion (although the latter +# two are hardcoded to subscription and unsubscription requests). -bounces is +# the automated bounce processor, and all messages to list members have their +# return address set to -bounces. If the bounce processor fails to extract a +# bouncing member address, it can optionally forward the message on to the +# list owners. +# +# -owner is for reaching a human operator with minimal list interaction +# (i.e. no bounce processing). -confirm is another robot address which +# processes replies to VERP-like confirmation notices. +# +# So delivery flow of messages look like this: +# +# joerandom ---> mylist ---> list members +# | | +# | |[bounces] +# | mylist-bounces <---+ <-------------------------------+ +# | | | +# | +--->[internal bounce processing] | +# | ^ | | +# | | | [bounce found] | +# | [bounces *] +--->[register and discard] | +# | | | | | +# | | | |[*] | +# | [list owners] |[no bounce found] | | +# | ^ | | | +# | | | | | +# +-------> mylist-owner <--------+ | | +# | | | +# | data/owner-bounces.mbox <--[site list] <---+ | +# | | +# +-------> mylist-join--+ | +# | | | +# +------> mylist-leave--+ | +# | | | +# | v | +# +-------> mylist-request | +# | | | +# | +---> [command processor] | +# | | | +# +-----> mylist-confirm ----> +---> joerandom | +# | | +# |[bounces] | +# +----------------------+ +# +# A person can send an email to the list address (for posting), the -owner +# address (to reach the human operator), or the -confirm, -join, -leave, and +# -request mailbots. Message to the list address are then forwarded on to the +# list membership, with bounces directed to the -bounces address. +# +# [*] Messages sent to the -owner address are forwarded on to the list +# owner/moderators. All -owner destined messages have their bounces directed +# to the site list -bounces address, regardless of whether a human sent the +# message or the message was crafted internally. The intention here is that +# the site owners want to be notified when one of their list owners' addresses +# starts bouncing (yes, the will be automated in a future release). +# +# Any messages to site owners has their bounces directed to a special +# "loop-killer" address, which just dumps the message into +# data/owners-bounces.mbox. +# +# Finally, message to any of the mailbots causes the requested action to be +# performed. Results notifications are sent to the author of the message, +# which all bounces pointing back to the -bounces address. + + +import sys +import os +from cStringIO import StringIO + +from Mailman import mm_cfg +from Mailman import Errors +from Mailman import LockFile +from Mailman.Queue.Runner import Runner +from Mailman.Logging.Syslog import syslog + + + +class IncomingRunner(Runner): + QDIR = mm_cfg.INQUEUE_DIR + + def _dispose(self, mlist, msg, msgdata): + # Try to get the list lock. + try: + mlist.Lock(timeout=mm_cfg.LIST_LOCK_TIMEOUT) + except LockFile.TimeOutError: + # Oh well, try again later + return 1 + # Process the message through a handler pipeline. The handler + # pipeline can actually come from one of three places: the message + # metadata, the mlist, or the global pipeline. + # + # If a message was requeued due to an uncaught exception, its metadata + # will contain the retry pipeline. Use this above all else. + # Otherwise, if the mlist has a `pipeline' attribute, it should be + # used. Final fallback is the global pipeline. + try: + pipeline = self._get_pipeline(mlist, msg, msgdata) + status = self._dopipeline(mlist, msg, msgdata, pipeline) + if status: + msgdata['pipeline'] = pipeline + mlist.Save() + return status + finally: + mlist.Unlock() + + # Overridable + def _get_pipeline(self, mlist, msg, msgdata): + # We must return a copy of the list, otherwise, the first message that + # flows through the pipeline will empty it out! + return msgdata.get('pipeline', + getattr(mlist, 'pipeline', + mm_cfg.GLOBAL_PIPELINE))[:] + + def _dopipeline(self, mlist, msg, msgdata, pipeline): + while pipeline: + handler = pipeline.pop(0) + modname = 'Mailman.Handlers.' + handler + __import__(modname) + try: + pid = os.getpid() + sys.modules[modname].process(mlist, msg, msgdata) + # Failsafe -- a child may have leaked through. + if pid <> os.getpid(): + syslog('error', 'child process leaked thru: %s', modname) + os._exit(1) + except Errors.DiscardMessage: + # Throw the message away; we need do nothing else with it. + syslog('vette', 'Message discarded, msgid: %s', + msg.get('message-id', 'n/a')) + return 0 + except Errors.HoldMessage: + # Let the approval process take it from here. The message no + # longer needs to be queued. + return 0 + except Errors.RejectMessage, e: + mlist.BounceMessage(msg, msgdata, e) + return 0 + # We've successfully completed handling of this message + return 0 diff --git a/Mailman/Queue/MaildirRunner.py b/Mailman/Queue/MaildirRunner.py new file mode 100644 index 00000000..e14ab339 --- /dev/null +++ b/Mailman/Queue/MaildirRunner.py @@ -0,0 +1,184 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Maildir pre-queue runner. + +Most MTAs can be configured to deliver messages to a `Maildir'[1]. This +runner will read messages from a maildir's new/ directory and inject them into +Mailman's qfiles/in directory for processing in the normal pipeline. This +delivery mechanism contrasts with mail program delivery, where incoming +messages end up in qfiles/in via the MTA executing the scripts/post script +(and likewise for the other -aliases for each mailing list). + +The advantage to Maildir delivery is that it is more efficient; there's no +need to fork an intervening program just to take the message from the MTA's +standard output, to the qfiles/in directory. + +[1] http://cr.yp.to/proto/maildir.html + +We're going to use the :info flag == 1, experimental status flag for our own +purposes. The :1 can be followed by one of these letters: + +- P means that MaildirRunner's in the process of parsing and enqueuing the + message. If successful, it will delete the file. + +- X means something failed during the parse/enqueue phase. An error message + will be logged to log/error and the file will be renamed :1,X. + MaildirRunner will never automatically return to this file, but once the + problem is fixed, you can manually move the file back to the new/ directory + and MaildirRunner will attempt to re-process it. At some point we may do + this automatically. + +See the variable USE_MAILDIR in Defaults.py.in for enabling this delivery +mechanism. +""" + +# NOTE: Maildir delivery is experimental in Mailman 2.1. + +import os +import re +import errno + +from email.Parser import Parser +from email.Utils import parseaddr + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman.Message import Message +from Mailman.Queue.Runner import Runner +from Mailman.Queue.sbcache import get_switchboard +from Mailman.Logging.Syslog import syslog + +# We only care about the listname and the subq as in listname@ or +# listname-request@ +lre = re.compile(r""" + ^ # start of string + (?P[^-@]+) # listname@ or listname-subq@ + (?: # non-grouping + - # dash separator + (?P[^-+@]+) # everything up to + or - or @ + )? # if it exists + """, re.VERBOSE | re.IGNORECASE) + + + +class MaildirRunner(Runner): + # This class is much different than most runners because it pulls files + # of a different format than what scripts/post and friends leaves. The + # files this runner reads are just single message files as dropped into + # the directory by the MTA. This runner will read the file, and enqueue + # it in the expected qfiles directory for normal processing. + def __init__(self, slice=None, numslices=1): + # Don't call the base class constructor, but build enough of the + # underlying attributes to use the base class's implementation. + self._stop = 0 + self._dir = os.path.join(mm_cfg.MAILDIR_DIR, 'new') + self._cur = os.path.join(mm_cfg.MAILDIR_DIR, 'cur') + self._parser = Parser(Message) + + def _oneloop(self): + # Refresh this each time through the list. BAW: could be too + # expensive. + listnames = Utils.list_names() + # Cruise through all the files currently in the new/ directory + try: + files = os.listdir(self._dir) + except OSError, e: + if e.errno <> errno.ENOENT: raise + # Nothing's been delivered yet + return 0 + for file in files: + srcname = os.path.join(self._dir, file) + dstname = os.path.join(self._cur, file + ':1,P') + xdstname = os.path.join(self._cur, file + ':1,X') + try: + os.rename(srcname, dstname) + except OSError, e: + if e.errno == errno.ENOENT: + # Some other MaildirRunner beat us to it + continue + syslog('error', 'Could not rename maildir file: %s', srcname) + raise + # Now open, read, parse, and enqueue this message + try: + fp = open(dstname) + try: + msg = self._parser.parse(fp) + finally: + fp.close() + # Now we need to figure out which queue of which list this + # message was destined for. See verp_bounce() in + # BounceRunner.py for why we do things this way. + vals = [] + for header in ('delivered-to', 'envelope-to', 'apparently-to'): + vals.extend(msg.get_all(header, [])) + for field in vals: + to = parseaddr(field)[1] + if not to: + continue + mo = lre.match(to) + if not mo: + # This isn't an address we care about + continue + listname, subq = mo.group('listname', 'subq') + if listname in listnames: + break + else: + # As far as we can tell, this message isn't destined for + # any list on the system. What to do? + syslog('error', 'Message apparently not for any list: %s', + xdstname) + os.rename(dstname, xdstname) + continue + # BAW: blech, hardcoded + msgdata = {'listname': listname} + # -admin is deprecated + if subq in ('bounces', 'admin'): + queue = get_switchboard(mm_cfg.BOUNCEQUEUE_DIR) + elif subq == 'confirm': + msgdata['toconfirm'] = 1 + queue = get_switchboard(mm_cfg.CMDQUEUE_DIR) + elif subq in ('join', 'subscribe'): + msgdata['tojoin'] = 1 + queue = get_switchboard(mm_cfg.CMDQUEUE_DIR) + elif subq in ('leave', 'unsubscribe'): + msgdata['toleave'] = 1 + queue = get_switchboard(mm_cfg.CMDQUEUE_DIR) + elif subq == 'owner': + msgdata.update({ + 'toowner': 1, + 'envsender': Utils.get_site_email(extra='bounces'), + 'pipeline': mm_cfg.OWNER_PIPELINE, + }) + queue = get_switchboard(mm_cfg.INQUEUE_DIR) + elif subq is None: + msgdata['tolist'] = 1 + queue = get_switchboard(mm_cfg.INQUEUE_DIR) + elif subq == 'request': + msgdata['torequest'] = 1 + queue = get_switchboard(mm_cfg.CMDQUEUE_DIR) + else: + syslog('error', 'Unknown sub-queue: %s', subq) + os.rename(dstname, xdstname) + continue + queue.enqueue(msg, msgdata) + os.unlink(dstname) + except Exception, e: + os.rename(dstname, xdstname) + syslog('error', str(e)) + + def _cleanup(self): + pass diff --git a/Mailman/Queue/Makefile.in b/Mailman/Queue/Makefile.in new file mode 100644 index 00000000..a92ae67d --- /dev/null +++ b/Mailman/Queue/Makefile.in @@ -0,0 +1,69 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +# NOTE: Makefile.in is converted into Makefile by the configure script +# in the parent directory. Once configure has run, you can recreate +# the Makefile by running just config.status. + +# Variables set by configure + +VPATH= @srcdir@ +srcdir= @srcdir@ +bindir= @bindir@ +prefix= @prefix@ +exec_prefix= @exec_prefix@ + +CC= @CC@ +CHMOD= @CHMOD@ +INSTALL= @INSTALL@ + +DEFS= @DEFS@ + +# Customizable but not set by configure + +OPT= @OPT@ +CFLAGS= $(OPT) $(DEFS) +PACKAGEDIR= $(prefix)/Mailman/Queue +SHELL= /bin/sh + +MODULES= *.py + +# Modes for directories and executables created by the install +# process. Default to group-writable directories but +# user-only-writable for executables. +DIRMODE= 775 +EXEMODE= 755 +FILEMODE= 644 +INSTALL_PROGRAM=$(INSTALL) -m $(EXEMODE) + + +# Rules + +all: + +install: + for f in $(MODULES); \ + do \ + $(INSTALL) -m $(FILEMODE) $(srcdir)/$$f $(PACKAGEDIR); \ + done + +finish: + +clean: + +distclean: + -rm *.pyc + -rm Makefile diff --git a/Mailman/Queue/NewsRunner.py b/Mailman/Queue/NewsRunner.py new file mode 100644 index 00000000..0439f0e1 --- /dev/null +++ b/Mailman/Queue/NewsRunner.py @@ -0,0 +1,158 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""NNTP queue runner.""" + +import re +import socket +import nntplib +from cStringIO import StringIO + +import email +from email.Utils import getaddresses + +COMMASPACE = ', ' + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman.Queue.Runner import Runner +from Mailman.Logging.Syslog import syslog + + +# Matches our Mailman crafted Message-IDs. See Utils.unique_message_id() +mcre = re.compile(r""" + [^@]+) # list's internal_name() + @ # localpart@dom.ain + (?P[^>]+) # list's host_name + > # trailer + """, re.VERBOSE) + + + +class NewsRunner(Runner): + QDIR = mm_cfg.NEWSQUEUE_DIR + + def _dispose(self, mlist, msg, msgdata): + # Make sure we have the most up-to-date state + mlist.Load() + if not msgdata.get('prepped'): + prepare_message(mlist, msg, msgdata) + try: + # Flatten the message object, sticking it in a StringIO object + fp = StringIO(msg.as_string()) + conn = None + try: + try: + conn = nntplib.NNTP(mlist.nntp_host, readermode=1, + user=mm_cfg.NNTP_USERNAME, + password=mm_cfg.NNTP_PASSWORD) + conn.post(fp) + except nntplib.error_temp, e: + syslog('error', + '(NNTPDirect) NNTP error for list "%s": %s', + mlist.internal_name(), e) + except socket.error, e: + syslog('error', + '(NNTPDirect) socket error for list "%s": %s', + mlist.internal_name(), e) + finally: + if conn: + conn.quit() + except Exception, e: + # Some other exception occurred, which we definitely did not + # expect, so set this message up for requeuing. + self._log(e) + return 1 + return 0 + + + +def prepare_message(mlist, msg, msgdata): + # If the newsgroup is moderated, we need to add this header for the Usenet + # software to accept the posting, and not forward it on to the n.g.'s + # moderation address. The posting would not have gotten here if it hadn't + # already been approved. 1 == open list, mod n.g., 2 == moderated + if mlist.news_moderation in (1, 2): + del msg['approved'] + msg['Approved'] = mlist.GetListEmail() + # Should we restore the original, non-prefixed subject for gatewayed + # messages? + origsubj = msgdata.get('origsubj') + if not mlist.news_prefix_subject_too and origsubj is not None: + del msg['subject'] + msg['subject'] = origsubj + # Add the appropriate Newsgroups: header + ngheader = msg['newsgroups'] + if ngheader is not None: + # See if the Newsgroups: header already contains our linked_newsgroup. + # If so, don't add it again. If not, append our linked_newsgroup to + # the end of the header list + ngroups = [s.strip() for s in ngheader.split(',')] + if mlist.linked_newsgroup not in ngroups: + ngroups.append(mlist.linked_newsgroup) + # Subtitute our new header for the old one. + del msg['newsgroups'] + msg['Newsgroups'] = COMMASPACE.join(ngroups) + else: + # Newsgroups: isn't in the message + msg['Newsgroups'] = mlist.linked_newsgroup + # Note: We need to be sure two messages aren't ever sent to the same list + # in the same process, since message ids need to be unique. Further, if + # messages are crossposted to two Usenet-gated mailing lists, they each + # need to have unique message ids or the nntpd will only accept one of + # them. The solution here is to substitute any existing message-id that + # isn't ours with one of ours, so we need to parse it to be sure we're not + # looping. + # + # Our Message-ID format is + msgid = msg['message-id'] + hackmsgid = 1 + if msgid: + mo = mcre.search(msgid) + if mo: + lname, hname = mo.group('listname', 'hostname') + if lname == mlist.internal_name() and hname == mlist.host_name: + hackmsgid = 0 + if hackmsgid: + del msg['message-id'] + msg['Message-ID'] = Utils.unique_message_id(mlist) + # Lines: is useful + if msg['Lines'] is None: + # BAW: is there a better way? + count = len(list(email.Iterators.body_line_iterator(msg))) + msg['Lines'] = str(count) + # Massage the message headers by remove some and rewriting others. This + # woon't completely sanitize the message, but it will eliminate the bulk + # of the rejections based on message headers. The NNTP server may still + # reject the message because of other problems. + for header in mm_cfg.NNTP_REMOVE_HEADERS: + del msg[header] + for header, rewrite in mm_cfg.NNTP_REWRITE_DUPLICATE_HEADERS: + values = msg.get_all(header, []) + if len(values) < 2: + # We only care about duplicates + continue + del msg[header] + # But keep the first one... + msg[header] = values[0] + for v in values[1:]: + msg[rewrite] = v + # Mark this message as prepared in case it has to be requeued + msgdata['prepped'] = 1 diff --git a/Mailman/Queue/OutgoingRunner.py b/Mailman/Queue/OutgoingRunner.py new file mode 100644 index 00000000..aed8dcb9 --- /dev/null +++ b/Mailman/Queue/OutgoingRunner.py @@ -0,0 +1,139 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Outgoing queue runner.""" + +import sys +import os +import time +import socket + +import email + +from Mailman import mm_cfg +from Mailman import Message +from Mailman import Errors +from Mailman import LockFile +from Mailman.Queue.Runner import Runner +from Mailman.Logging.Syslog import syslog + +# This controls how often _doperiodic() will try to deal with deferred +# permanent failures. +DEAL_WITH_PERMFAILURES_EVERY = 1 + + + +class OutgoingRunner(Runner): + QDIR = mm_cfg.OUTQUEUE_DIR + + def __init__(self, slice=None, numslices=1): + Runner.__init__(self, slice, numslices) + # Maps mailing lists to (recip, msg) tuples + self._permfailures = {} + self._permfail_counter = 0 + # We look this function up only at startup time + modname = 'Mailman.Handlers.' + mm_cfg.DELIVERY_MODULE + mod = __import__(modname) + self._func = getattr(sys.modules[modname], 'process') + # This prevents smtp server connection problems from filling up the + # error log. It gets reset if the message was successfully sent, and + # set if there was a socket.error. + self.__logged = 0 + + def _dispose(self, mlist, msg, msgdata): + # Make sure we have the most up-to-date state + mlist.Load() + try: + pid = os.getpid() + self._func(mlist, msg, msgdata) + # Failsafe -- a child may have leaked through. + if pid <> os.getpid(): + syslog('error', 'child process leaked thru: %s', modname) + os._exit(1) + self.__logged = 0 + except socket.error: + # There was a problem connecting to the SMTP server. Log this + # once, but crank up our sleep time so we don't fill the error + # log. + port = mm_cfg.SMTPPORT + if port == 0: + port = 'smtp' + # Log this just once. + if not self.__logged: + syslog('error', 'Cannot connect to SMTP server %s on port %s', + mm_cfg.SMTPHOST, port) + self.__logged = 1 + return 1 + except Errors.SomeRecipientsFailed, e: + # The delivery module being used (SMTPDirect or Sendmail) failed + # to deliver the message to one or all of the recipients. + # Permanent failures should be registered (but registration + # requires the list lock), and temporary failures should be + # retried later. + # + # For permanent failures, make a copy of the message for bounce + # handling. I'm not sure this is necessary, or the right thing to + # do. + pcnt = len(e.permfailures) + copy = email.message_from_string(str(msg)) + self._permfailures.setdefault(mlist, []).extend( + zip(e.permfailures, [copy] * pcnt)) + # Temporary failures + if not e.tempfailures: + # Don't need to keep the message queued if there were only + # permanent failures. + return 0 + now = time.time() + recips = e.tempfailures + last_recip_count = msgdata.get('last_recip_count', 0) + deliver_until = msgdata.get('deliver_until', now) + if len(recips) == last_recip_count: + # We didn't make any progress, so don't attempt delivery any + # longer. BAW: is this the best disposition? + if now > deliver_until: + return 0 + else: + # Keep trying to delivery this for 3 days + deliver_until = now + mm_cfg.DELIVERY_RETRY_PERIOD + msgdata['last_recip_count'] = len(recips) + msgdata['deliver_until'] = deliver_until + msgdata['recips'] = recips + # Requeue + return 1 + # We've successfully completed handling of this message + return 0 + + def _doperiodic(self): + # Periodically try to acquire the list lock and clear out the + # permanent failures. + self._permfail_counter += 1 + if self._permfail_counter < DEAL_WITH_PERMFAILURES_EVERY: + return + # Reset the counter + self._permfail_counter = 0 + # And deal with the deferred permanent failures. + for mlist in self._permfailures.keys(): + try: + mlist.Lock(timeout=mm_cfg.LIST_LOCK_TIMEOUT) + except LockFile.TimeOutError: + return + try: + for recip, msg in self._permfailures[mlist]: + mlist.registerBounce(recip, msg) + del self._permfailures[mlist] + mlist.Save() + finally: + mlist.Unlock() diff --git a/Mailman/Queue/Runner.py b/Mailman/Queue/Runner.py new file mode 100644 index 00000000..134dac99 --- /dev/null +++ b/Mailman/Queue/Runner.py @@ -0,0 +1,245 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Generic queue runner class. +""" + +import time +import traceback +import weakref +from cStringIO import StringIO + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import Errors +from Mailman import MailList +from Mailman import i18n + +from Mailman.Queue.Switchboard import Switchboard +from Mailman.Logging.Syslog import syslog + + + +class Runner: + QDIR = None + SLEEPTIME = mm_cfg.QRUNNER_SLEEP_TIME + + def __init__(self, slice=None, numslices=1): + self._kids = {} + # Create our own switchboard. Don't use the switchboard cache because + # we want to provide slice and numslice arguments. + self._switchboard = Switchboard(self.QDIR, slice, numslices) + # Create the shunt switchboard + self._shunt = Switchboard(mm_cfg.SHUNTQUEUE_DIR) + self._stop = 0 + + def stop(self): + self._stop = 1 + + def run(self): + # Start the main loop for this queue runner. + try: + try: + while 1: + # Once through the loop that processes all the files in + # the queue directory. + filecnt = self._oneloop() + # Do the periodic work for the subclass. BAW: this + # shouldn't be called here. There should be one more + # _doperiodic() call at the end of the _oneloop() loop. + self._doperiodic() + # If the stop flag is set, we're done. + if self._stop: + break + # If there were no files to process, then we'll simply + # sleep for a little while and expect some to show up. + if not filecnt: + self._snooze() + except KeyboardInterrupt: + pass + finally: + # We've broken out of our main loop, so we want to reap all the + # subprocesses we've created and do any other necessary cleanups. + self._cleanup() + + def _oneloop(self): + # First, list all the files in our queue directory. + # Switchboard.files() is guaranteed to hand us the files in FIFO + # order. Return an integer count of the number of files that were + # available for this qrunner to process. A non-zero value tells run() + # not to snooze for a while. + files = self._switchboard.files() + for filebase in files: + # Ask the switchboard for the message and metadata objects + # associated with this filebase. + msg, msgdata = self._switchboard.dequeue(filebase) + # It's possible one or both files got lost. If so, just ignore + # this filebase entry. dequeue() will automatically unlink the + # other file, but we should log an error message for diagnostics. + if msg is None or msgdata is None: + syslog('error', 'lost data files for filebase: %s', filebase) + else: + # Now that we've dequeued the message, we want to be + # incredibly anal about making sure that no uncaught exception + # could cause us to lose the message. All runners that + # implement _dispose() must guarantee that exceptions are + # caught and dealt with properly. Still, there may be a bug + # in the infrastructure, and we do not want those to cause + # messages to be lost. Any uncaught exceptions will cause the + # message to be stored in the shunt queue for human + # intervention. + try: + self._onefile(msg, msgdata) + except Exception, e: + self._log(e) + syslog('error', 'SHUNTING: %s', filebase) + # Put a marker in the metadata for unshunting + msgdata['whichq'] = self._switchboard.whichq() + self._shunt.enqueue(msg, msgdata) + # Other work we want to do each time through the loop + Utils.reap(self._kids, once=1) + self._doperiodic() + if self._shortcircuit(): + break + return len(files) + + def _onefile(self, msg, msgdata): + # Do some common sanity checking on the message metadata. It's got to + # be destined for a particular mailing list. This switchboard is used + # to shunt off badly formatted messages. We don't want to just trash + # them because they may be fixable with human intervention. Just get + # them out of our site though. + # + # Find out which mailing list this message is destined for. + listname = msgdata.get('listname') + if not listname: + listname = mm_cfg.MAILMAN_SITE_LIST + mlist = self._open_list(listname) + if not mlist: + syslog('error', + 'Dequeuing message destined for missing list: %s', + listname) + self._shunt.enqueue(msg, msgdata) + return + # Now process this message, keeping track of any subprocesses that may + # have been spawned. We'll reap those later. + # + # We also want to set up the language context for this message. The + # context will be the preferred language for the user if a member of + # the list, or the list's preferred language. However, we must take + # special care to reset the defaults, otherwise subsequent messages + # may be translated incorrectly. BAW: I'm not sure I like this + # approach, but I can't think of anything better right now. + otranslation = i18n.get_translation() + sender = msg.get_sender() + if mlist: + lang = mlist.getMemberLanguage(sender) + else: + lang = mm_cfg.DEFAULT_SERVER_LANGUAGE + i18n.set_language(lang) + msgdata['lang'] = lang + try: + keepqueued = self._dispose(mlist, msg, msgdata) + finally: + i18n.set_translation(otranslation) + # Keep tabs on any child processes that got spawned. + kids = msgdata.get('_kids') + if kids: + self._kids.update(kids) + if keepqueued: + self._switchboard.enqueue(msg, msgdata) + + # Mapping of listnames to MailList instances as a weak value dictionary. + _listcache = weakref.WeakValueDictionary() + + def _open_list(self, listname): + # Cache the open list so that any use of the list within this process + # uses the same object. We use a WeakValueDictionary so that when the + # list is no longer necessary, its memory is freed. + mlist = self._listcache.get(listname) + if not mlist: + try: + mlist = MailList.MailList(listname, lock=0) + except Errors.MMListError, e: + syslog('error', 'error opening list: %s\n%s', listname, e) + return None + else: + self._listcache[listname] = mlist + return mlist + + def _log(self, exc): + syslog('error', 'Uncaught runner exception: %s', exc) + s = StringIO() + traceback.print_exc(file=s) + syslog('error', s.getvalue()) + + # + # Subclasses can override these methods. + # + def _cleanup(self): + """Clean up upon exit from the main processing loop. + + Called when the Runner's main loop is stopped, this should perform + any necessary resource deallocation. Its return value is irrelevant. + """ + Utils.reap(self._kids) + + def _dispose(self, mlist, msg, msgdata): + """Dispose of a single message destined for a mailing list. + + Called for each message that the Runner is responsible for, this is + the primary overridable method for processing each message. + Subclasses, must provide implementation for this method. + + mlist is the MailList instance this message is destined for. + + msg is the Message object representing the message. + + msgdata is a dictionary of message metadata. + """ + raise NotImplementedError + + def _doperiodic(self): + """Do some processing `every once in a while'. + + Called every once in a while both from the Runner's main loop, and + from the Runner's hash slice processing loop. You can do whatever + special periodic processing you want here, and the return value is + irrelevant. + + """ + pass + + def _snooze(self): + """Sleep for a little while, because there was nothing to do. + + This is called from the Runner's main loop, but only when the last + processing loop had no work to do (i.e. there were no messages in it's + little slice of hash space). + """ + if self.SLEEPTIME <= 0: + return + time.sleep(self.SLEEPTIME) + + def _shortcircuit(self): + """Return a true value if the individual file processing loop should + exit before it's finished processing each message in the current slice + of hash space. A false value tells _oneloop() to continue processing + until the current snapshot of hash space is exhausted. + + You could, for example, implement a throttling algorithm here. + """ + return self._stop diff --git a/Mailman/Queue/Switchboard.py b/Mailman/Queue/Switchboard.py new file mode 100644 index 00000000..530055ad --- /dev/null +++ b/Mailman/Queue/Switchboard.py @@ -0,0 +1,340 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Reading and writing message objects and message metadata. +""" + +# enqueue() and dequeue() are not symmetric. enqueue() takes a Message +# object. dequeue() returns a email.Message object tree. +# +# Message metadata is represented internally as a Python dictionary. Keys and +# values must be strings. When written to a queue directory, the metadata is +# written into an externally represented format, as defined here. Because +# components of the Mailman system may be written in something other than +# Python, the external interchange format should be chosen based on what those +# other components can read and write. +# +# Most efficient, and recommended if everything is Python, is Python marshal +# format. Also supported by default is Berkeley db format (using the default +# bsddb module compiled into your Python executable -- usually Berkeley db +# 2), and rfc822 style plain text. You can write your own if you have other +# needs. + +import os +import time +import sha +import marshal +import errno +import cPickle + +import email + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import Message +from Mailman.Logging.Syslog import syslog + +# 20 bytes of all bits set, maximum sha.digest() value +shamax = 0xffffffffffffffffffffffffffffffffffffffffL + +SAVE_MSGS_AS_PICKLES = 1 + + + +class _Switchboard: + def __init__(self, whichq, slice=None, numslices=1): + self.__whichq = whichq + # Create the directory if it doesn't yet exist. + # FIXME + omask = os.umask(0) # rwxrws--- + try: + try: + os.mkdir(self.__whichq, 0770) + except OSError, e: + if e.errno <> errno.EEXIST: raise + finally: + os.umask(omask) + # Fast track for no slices + self.__lower = None + self.__upper = None + # BAW: test performance and end-cases of this algorithm + if numslices <> 1: + self.__lower = (shamax * slice) / numslices + self.__upper = (shamax * (slice+1)) / numslices + + def whichq(self): + return self.__whichq + + def enqueue(self, _msg, _metadata={}, **_kws): + # Calculate the SHA hexdigest of the message to get a unique base + # filename. We're also going to use the digest as a hash into the set + # of parallel qrunner processes. + data = _metadata.copy() + data.update(_kws) + listname = data.get('listname', '--nolist--') + # Get some data for the input to the sha hash + now = time.time() + if SAVE_MSGS_AS_PICKLES and not data.get('_plaintext'): + msgsave = cPickle.dumps(_msg, 1) + ext = '.pck' + else: + msgsave = str(_msg) + ext = '.msg' + hashfood = msgsave + listname + `now` + # Encode the current time into the file name for FIFO sorting in + # files(). The file name consists of two parts separated by a `+': + # the received time for this message (i.e. when it first showed up on + # this system) and the sha hex digest. + #rcvtime = data.setdefault('received_time', now) + rcvtime = data.setdefault('received_time', now) + filebase = `rcvtime` + '+' + sha.new(hashfood).hexdigest() + # Figure out which queue files the message is to be written to. + msgfile = os.path.join(self.__whichq, filebase + ext) + dbfile = os.path.join(self.__whichq, filebase + '.db') + # Always add the metadata schema version number + data['version'] = mm_cfg.QFILE_SCHEMA_VERSION + # Filter out volatile entries + for k in data.keys(): + if k[0] == '_': + del data[k] + # Now write the message text to one file and the metadata to another + # file. The metadata is always written second to avoid race + # conditions with the various queue runners (which key off of the .db + # filename). + omask = os.umask(007) # -rw-rw---- + try: + msgfp = open(msgfile, 'w') + finally: + os.umask(omask) + msgfp.write(msgsave) + msgfp.close() + # Now write the metadata using the appropriate external metadata + # format. We play rename-switcheroo here to further plug the race + # condition holes. + tmpfile = dbfile + '.tmp' + self._ext_write(tmpfile, data) + os.rename(tmpfile, dbfile) + + def dequeue(self, filebase): + # Calculate the .db and .msg filenames from the given filebase. + msgfile = os.path.join(self.__whichq, filebase + '.msg') + pckfile = os.path.join(self.__whichq, filebase + '.pck') + dbfile = os.path.join(self.__whichq, 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 = data = None + try: + data = self._ext_read(dbfile) + os.unlink(dbfile) + except EnvironmentError, e: + if e.errno <> errno.ENOENT: raise + # 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, e: + # This message was unparsable, most likely because its + # MIME encapsulation was broken. For now, there's not + # much we can do about it. + syslog('error', 'message is unparsable: %s', filebase) + msgfp.close() + msgfp = None + if mm_cfg.QRUNNER_SAVE_BAD_MESSAGES: + # Cheapo way to ensure the directory exists w/ the + # proper permissions. + sb = Switchboard(mm_cfg.BADQUEUE_DIR) + os.rename(msgfile, os.path.join( + mm_cfg.BADQUEUE_DIR, filebase + '.txt')) + else: + os.unlink(msgfile) + msg = data = None + finally: + if msgfp: + msgfp.close() + return msg, data + + def files(self): + times = {} + lower = self.__lower + upper = self.__upper + for f in os.listdir(self.__whichq): + # We only care about the file's base name (i.e. no extension). + # Thus we'll ignore anything that doesn't end in .db. + if not f.endswith('.db'): + continue + filebase = os.path.splitext(f)[0] + when, digest = filebase.split('+') + # Throw out any files which don't match our bitrange. BAW: test + # performance and end-cases of this algorithm. + if not lower or (lower <= long(digest, 16) < upper): + times[float(when)] = filebase + # FIFO sort + keys = times.keys() + keys.sort() + return [times[k] for k in keys] + + def _ext_write(self, tmpfile, data): + raise NotImplementedError + + def _ext_read(self, dbfile): + raise NotImplementedError + + + +class MarshalSwitchboard(_Switchboard): + """Python marshal format.""" + FLOAT_ATTRIBUTES = ['received_time'] + + def _ext_write(self, filename, dict): + omask = os.umask(007) # -rw-rw---- + try: + fp = open(filename, 'w') + finally: + os.umask(omask) + # Python's marshal, up to and including in Python 2.1, has a bug where + # the full precision of floats was not stored. We work around this + # bug by hardcoding a list of float values we know about, repr()-izing + # them ourselves, and doing the reverse conversion on _ext_read(). + for attr in self.FLOAT_ATTRIBUTES: + # We use try/except because we expect a hitrate of nearly 100% + try: + fval = dict[attr] + except KeyError: + pass + else: + dict[attr] = repr(fval) + marshal.dump(dict, fp) + fp.close() + + def _ext_read(self, filename): + fp = open(filename) + dict = marshal.load(fp) + # Update from version 2 files + if dict.get('version', 0) == 2: + del dict['filebase'] + # Do the reverse conversion (repr -> float) + for attr in self.FLOAT_ATTRIBUTES: + try: + sval = dict[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. + dict[attr] = eval(sval, {'__builtins__': {}}) + fp.close() + return dict + + + +class BSDDBSwitchboard(_Switchboard): + """Native (i.e. compiled-in) Berkeley db format.""" + def _ext_write(self, filename, dict): + import bsddb + omask = os.umask(0) + try: + hashfile = bsddb.hashopen(filename, 'n', 0660) + finally: + os.umask(omask) + # values must be strings + for k, v in dict.items(): + hashfile[k] = marshal.dumps(v) + hashfile.sync() + hashfile.close() + + def _ext_read(self, filename): + import bsddb + dict = {} + hashfile = bsddb.hashopen(filename, 'r') + for k in hashfile.keys(): + dict[k] = marshal.loads(hashfile[k]) + hashfile.close() + return dict + + + +class ASCIISwitchboard(_Switchboard): + """Human readable .db file format. + + key/value pairs are written as + + key = value + + as real Python code which can be execfile'd. + """ + + def _ext_write(self, filename, dict): + omask = os.umask(007) # -rw-rw---- + try: + fp = open(filename, 'w') + finally: + os.umask(omask) + for k, v in dict.items(): + print >> fp, '%s = %s' % (k, repr(v)) + fp.close() + + def _ext_read(self, filename): + dict = {'__builtins__': {}} + execfile(filename, dict) + del dict['__builtins__'] + return dict + + + +# Here are the various types of external file formats available. The format +# chosen is given defined in the mm_cfg.py configuration file. +if mm_cfg.METADATA_FORMAT == mm_cfg.METAFMT_MARSHAL: + Switchboard = MarshalSwitchboard +elif mm_cfg.METADATA_FORMAT == mm_cfg.METAFMT_BSDDB_NATIVE: + Switchboard = BSDDBSwitchboard +elif mm_cfg.METADATA_FORMAT == mm_cfg.METAFMT_ASCII: + Switchboard = ASCIISwitchboard +else: + syslog('error', 'Undefined metadata format: %d (using marshals)', + mm_cfg.METADATA_FORMAT) + Switchboard = MarshalSwitchboard + + + +# For bin/dumpdb +class DumperSwitchboard(Switchboard): + def __init__(self): + pass + + def read(self, filename): + return self._ext_read(filename) diff --git a/Mailman/Queue/VirginRunner.py b/Mailman/Queue/VirginRunner.py new file mode 100644 index 00000000..720ecd25 --- /dev/null +++ b/Mailman/Queue/VirginRunner.py @@ -0,0 +1,43 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Virgin message queue runner. + +This qrunner handles messages that the Mailman system gives virgin birth to. +E.g. acknowledgement responses to user posts or Replybot messages. They need +to go through some minimal processing before they can be sent out to the +recipient. +""" + +from Mailman import mm_cfg +from Mailman.Queue.Runner import Runner +from Mailman.Queue.IncomingRunner import IncomingRunner + + + +class VirginRunner(IncomingRunner): + QDIR = mm_cfg.VIRGINQUEUE_DIR + + def _dispose(self, mlist, msg, msgdata): + # We need to fasttrack this message through any handlers that touch + # it. E.g. especially CookHeaders. + msgdata['_fasttrack'] = 1 + return IncomingRunner._dispose(self, mlist, msg, msgdata) + + def _get_pipeline(self, mlist, msg, msgdata): + # It's okay to hardcode this, since it'll be the same for all + # internally crafted messages. + return ['CookHeaders', 'ToOutgoing'] diff --git a/Mailman/Queue/__init__.py b/Mailman/Queue/__init__.py new file mode 100644 index 00000000..cdf93257 --- /dev/null +++ b/Mailman/Queue/__init__.py @@ -0,0 +1,15 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. diff --git a/Mailman/Queue/sbcache.py b/Mailman/Queue/sbcache.py new file mode 100644 index 00000000..9b918fc5 --- /dev/null +++ b/Mailman/Queue/sbcache.py @@ -0,0 +1,26 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""A factory of Switchboards with caching.""" + +from Mailman.Queue.Switchboard import Switchboard + +# a mapping from queue directory to Switchboard instance +_sbcache = {} + +def get_switchboard(qdir): + switchboard = _sbcache.setdefault(qdir, Switchboard(qdir)) + return switchboard diff --git a/Mailman/SafeDict.py b/Mailman/SafeDict.py new file mode 100644 index 00000000..37b5198a --- /dev/null +++ b/Mailman/SafeDict.py @@ -0,0 +1,70 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""A `safe' dictionary for string interpolation.""" + +from types import StringType +from UserDict import UserDict + +COMMASPACE = ', ' + + + +class SafeDict(UserDict): + """Dictionary which returns a default value for unknown keys. + + This is used in maketext so that editing templates is a bit more robust. + """ + def __getitem__(self, key): + try: + return self.data[key] + except KeyError: + if isinstance(key, StringType): + return '%('+key+')s' + else: + return '' % `key` + + def interpolate(self, template): + return template % self + + + +class MsgSafeDict(SafeDict): + def __init__(self, msg, dict=None): + self.__msg = msg + SafeDict.__init__(self, dict) + + def __getitem__(self, key): + if key.startswith('msg_'): + return self.__msg.get(key[4:], 'n/a') + elif key.startswith('allmsg_'): + missing = [] + all = self.__msg.get_all(key[7:], missing) + if all is missing: + return 'n/a' + return COMMASPACE.join(all) + else: + return SafeDict.__getitem__(self, key) + + def copy(self): + d = self.data.copy() + for k in self.__msg.keys(): + vals = self.__msg.get_all(k) + if len(vals) == 1: + d['msg_'+k.lower()] = vals[0] + else: + d['allmsg_'+k.lower()] = COMMASPACE.join(vals) + return d diff --git a/Mailman/SecurityManager.py b/Mailman/SecurityManager.py new file mode 100644 index 00000000..8b65738e --- /dev/null +++ b/Mailman/SecurityManager.py @@ -0,0 +1,333 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + + +"""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 time +import sha +import marshal +import binascii +import Cookie +from types import StringType, TupleType +from urlparse import urlparse + +try: + import crypt +except ImportError: + crypt = None +import md5 + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import Errors +from Mailman.Logging.Syslog import syslog + + + +class SecurityManager: + def InitVars(self): + # We used to set self.password here, from a crypted_password argument, + # but that's been removed when we generalized the mixin architecture. + # self.password is really a SecurityManager attribute, but it's set in + # MailList.InitVars(). + self.mod_password = None + # Non configurable + self.passwords = {} + + 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 = self.internal_name() + '+' + if authcontext == mm_cfg.AuthUser: + if user is None: + # A bad system error + raise TypeError, 'No user supplied for AuthUser context' + secret = self.getMemberPassword(user) + key += 'user+%s' % Utils.ObscureEmail(user) + elif authcontext == mm_cfg.AuthListModerator: + secret = self.mod_password + key += 'moderator' + elif authcontext == mm_cfg.AuthListAdmin: + secret = self.password + key += 'admin' + # BAW: AuthCreator + elif authcontext == mm_cfg.AuthSiteAdmin: + sitepass = Utils.get_global_password() + if mm_cfg.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 == mm_cfg.AuthCreator: + ok = Utils.check_global_password(response, siteadmin=0) + if ok: + return mm_cfg.AuthCreator + elif ac == mm_cfg.AuthSiteAdmin: + ok = Utils.check_global_password(response) + if ok: + return mm_cfg.AuthSiteAdmin + elif ac == mm_cfg.AuthListAdmin: + def cryptmatchp(response, secret): + try: + salt = secret[:2] + if crypt and crypt.crypt(response, salt) == secret: + return 1 + return 0 + except TypeError: + # BAW: Hard to say why we can get a TypeError here. + # SF bug report #585776 says crypt.crypt() can raise + # this if salt contains null bytes, although I don't + # know how that can happen (perhaps if a MM2.0 list + # with USE_CRYPT = 0 has been updated? Doubtful. + return 0 + # 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. Note however, that for backwards + # compatibility reasons, we'll also check the admin response + # against the crypted and md5'd passwords, and if they match, + # we'll auto-migrate the passwords to sha. + key, secret = self.AuthContextInfo(ac) + if secret is None: + continue + sharesponse = sha.new(response).hexdigest() + upgrade = ok = 0 + if sharesponse == secret: + ok = 1 + elif md5.new(response).digest() == secret: + ok = 1 + upgrade = 1 + elif cryptmatchp(response, secret): + ok = 1 + upgrade = 1 + if upgrade: + save_and_unlock = 0 + if not self.Locked(): + self.Lock() + save_and_unlock = 1 + try: + self.password = sharesponse + if save_and_unlock: + self.Save() + finally: + if save_and_unlock: + self.Unlock() + if ok: + return ac + elif ac == mm_cfg.AuthListModerator: + # The list moderator password must be sha'd + key, secret = self.AuthContextInfo(ac) + if secret and sha.new(response).hexdigest() == secret: + return ac + elif ac == mm_cfg.AuthUser: + if self.authenticateMember(user, response): + return ac + else: + # What is this context??? + syslog('error', 'Bad authcontext: %s', ac) + raise ValueError, 'Bad authcontext: %s' % ac + return mm_cfg.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 must not be None. + # + # Returns a flag indicating whether authentication succeeded or not. + try: + for ac in authcontexts: + ok = self.CheckCookie(ac, user) + if ok: + return 1 + # Check passwords + ac = self.Authenticate(authcontexts, response, user) + if ac: + print self.MakeCookie(ac, user) + return 1 + except Errors.NotAMemberError: + pass + return 0 + + def MakeCookie(self, authcontext, user=None): + key, secret = self.AuthContextInfo(authcontext, user) + if key is None or secret is None or not isinstance(secret, StringType): + raise ValueError + # Timestamp + issued = int(time.time()) + # Get a digest of the secret, plus other information. + mac = sha.new(secret + `issued`).hexdigest() + # Create the cookie object. + c = Cookie.SimpleCookie() + c[key] = binascii.hexlify(marshal.dumps((issued, mac))) + # The path to all Mailman stuff, minus the scheme and host, + # i.e. usually the string `/mailman' + path = urlparse(self.web_page_url)[2] + c[key]['path'] = 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] = '' + # The path to all Mailman stuff, minus the scheme and host, + # i.e. usually the string `/mailman' + path = urlparse(self.web_page_url)[2] + c[key]['path'] = 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 0 + # Treat the cookie data as simple strings, and do application level + # decoding as necessary. By using SimpleCookie, we prevent any kind + # of security breach due to untrusted cookie data being unpickled + # (which is quite unsafe). + try: + c = Cookie.SimpleCookie(cookiedata) + except Cookie.CookieError: + return 0 + # 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 == mm_cfg.AuthUser: + if user: + usernames = [user] + else: + usernames = [] + prefix = self.internal_name() + '+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 1 + return 0 + 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. + key, secret = self.AuthContextInfo(authcontext, user) + if not c.has_key(key) or not isinstance(secret, StringType): + return 0 + # 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].value)) + issued, received_mac = data + except (EOFError, ValueError, TypeError, KeyError): + return 0 + # Make sure the issued timestamp makes sense + now = time.time() + if now < issued: + return 0 + # Calculate what the mac ought to be based on the cookie's timestamp + # and the shared secret. + mac = sha.new(secret + `issued`).hexdigest() + if mac <> received_mac: + return 0 + # Authenticated! + return 1 diff --git a/Mailman/Site.py b/Mailman/Site.py new file mode 100644 index 00000000..d4360d2c --- /dev/null +++ b/Mailman/Site.py @@ -0,0 +1,107 @@ +# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Provide some customization for site-wide behavior. + +This should be considered experimental for Mailman 2.1. The default +implementation should work for standard Mailman. +""" + +import os +import errno + +from Mailman import mm_cfg + + + +def _makedir(path): + try: + omask = os.umask(0) + try: + os.makedirs(path, 02775) + finally: + os.umask(omask) + except OSError, e: + # Ignore the exceptions if the directory already exists + if e.errno <> errno.EEXIST: + raise + + + +# BAW: We don't really support domain<>None yet. This will be added in a +# future version. By default, Mailman will never pass in a domain argument. +def get_listpath(listname, domain=None, create=0): + """Return the file system path to the list directory for the named list. + + If domain is given, it is the virtual domain for the named list. The + default is to not distinguish list paths on the basis of virtual domains. + + If the create flag is true, then this method should create the path + hierarchy if necessary. If the create flag is false, then this function + should not attempt to create the path heirarchy (and in fact the absence + of the path might be significant). + """ + path = os.path.join(mm_cfg.LIST_DATA_DIR, listname) + if create: + _makedir(path) + return path + + + +# BAW: We don't really support domain<>None yet. This will be added in a +# future version. By default, Mailman will never pass in a domain argument. +def get_archpath(listname, domain=None, create=0, public=0): + """Return the file system path to the list's archive directory for the + named list in the named virtual domain. + + If domain is given, it is the virtual domain for the named list. The + default is to not distinguish list paths on the basis of virtual domains. + + If the create flag is true, then this method should create the path + hierarchy if necessary. If the create flag is false, then this function + should not attempt to create the path heirarchy (and in fact the absence + of the path might be significant). + + If public is true, then the path points to the public archive path (which + is usually a symlink instead of a directory). + """ + if public: + subdir = mm_cfg.PUBLIC_ARCHIVE_FILE_DIR + else: + subdir = mm_cfg.PRIVATE_ARCHIVE_FILE_DIR + path = os.path.join(subdir, listname) + if create: + _makedir(path) + return path + + + +# BAW: We don't really support domain<>None yet. This will be added in a +# future version. By default, Mailman will never pass in a domain argument. +def get_listnames(domain=None): + """Return the names of all the known lists for the given domain. + + If domain is given, it is the virtual domain for the named list. The + default is to not distinguish list paths on the basis of virtual domains. + """ + # Import this here to avoid circular imports + from Mailman.Utils import list_exists + # We don't currently support separate virtual domain directories + got = [] + for fn in os.listdir(mm_cfg.LIST_DATA_DIR): + if list_exists(fn): + got.append(fn) + return got diff --git a/Mailman/TopicMgr.py b/Mailman/TopicMgr.py new file mode 100644 index 00000000..09c10d9b --- /dev/null +++ b/Mailman/TopicMgr.py @@ -0,0 +1,61 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""This class mixes in topic feature configuration for mailing lists. +""" + +import re + +from Mailman import mm_cfg +from Mailman.i18n import _ + + + +class TopicMgr: + def InitVars(self): + # Configurable + # + # `topics' is a list of 4-tuples of the following form: + # + # (name, pattern, description, emptyflag) + # + # name is a required arbitrary string displayed to the user when they + # get to select their topics of interest + # + # pattern is a required verbose regular expression pattern which is + # used as IGNORECASE. + # + # description is an optional description of what this topic is + # supposed to match + # + # emptyflag is a boolean used internally in the admin interface to + # signal whether a topic entry is new or not (new ones which do not + # have a name or pattern are not saved when the submit button is + # pressed). + self.topics = [] + self.topics_enabled = 0 + self.topics_bodylines_limit = 5 + # Non-configurable + # + # This is a mapping between user "names" (i.e. addresses) and + # information about which topics that user is interested in. The + # values are a list of topic names that the user is interested in, + # which should match the topic names in self.topics above. + # + # If the user has not selected any topics of interest, then the rule + # is that they will get all messages, and they will not have an entry + # in this dictionary. + self.topics_userinterest = {} diff --git a/Mailman/UserDesc.py b/Mailman/UserDesc.py new file mode 100644 index 00000000..aa06639f --- /dev/null +++ b/Mailman/UserDesc.py @@ -0,0 +1,57 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""User description class/structure, for ApprovedAddMember and friends.""" + +class UserDesc: + def __init__(self, address=None, fullname=None, password=None, + digest=None, lang=None): + if address is not None: + self.address = address + if fullname is not None: + self.fullname = fullname + if password is not None: + self.password = password + if digest is not None: + self.digest = digest + if lang is not None: + self.language = lang + + def __iadd__(self, other): + if getattr(other, 'address', None) is not None: + self.address = other.address + if getattr(other, 'fullname', None) is not None: + self.fullname = other.fullname + if getattr(other, 'password', None) is not None: + self.password = other.password + if getattr(other, 'digest', None) is not None: + self.digest = other.digest + if getattr(other, 'language', None) is not None: + self.language = other.language + return self + + def __repr__(self): + address = getattr(self, 'address', 'n/a') + fullname = getattr(self, 'fullname', 'n/a') + password = getattr(self, 'password', 'n/a') + digest = getattr(self, 'digest', 'n/a') + if digest == 0: + digest = 'no' + elif digest == 1: + digest = 'yes' + language = getattr(self, 'language', 'n/a') + return '' % ( + address, fullname, password, digest, language) diff --git a/Mailman/Utils.py b/Mailman/Utils.py new file mode 100644 index 00000000..b814f3d0 --- /dev/null +++ b/Mailman/Utils.py @@ -0,0 +1,773 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + + +"""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. + +""" + +from __future__ import nested_scopes + +import os +import re +import random +import urlparse +import sha +import errno +import time +import cgi +import htmlentitydefs +import email.Iterators +from types import UnicodeType +from string import whitespace, digits +try: + # Python 2.2 + from string import ascii_letters +except ImportError: + # Older Pythons + _lower = 'abcdefghijklmnopqrstuvwxyz' + ascii_letters = _lower + _lower.upper() + +from Mailman import mm_cfg +from Mailman import Errors +from Mailman import Site +from Mailman.SafeDict import SafeDict + +EMPTYSTRING = '' +NL = '\n' +DOT = '.' +IDENTCHARS = ascii_letters + digits + '_' + +# 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) + + + +def list_exists(listname): + """Return true iff list `listname' exists.""" + # The existance of any of the following file proves the list exists + # : config.pck, config.pck.last, config.db, config.db.last + # + # The former two are for 2.1alpha3 and beyond, while the latter two are + # for all earlier versions. + basepath = Site.get_listpath(listname) + for ext in ('.pck', '.pck.last', '.db', '.db.last'): + dbfile = os.path.join(basepath, 'config' + ext) + if os.path.exists(dbfile): + return 1 + return 0 + + +def list_names(): + """Return the names of all lists in default list directory.""" + # We don't currently support separate listings of virtual domains + return Site.get_listnames() + + + +# a much more naive implementation than say, Emacs's fill-paragraph! +def wrap(text, column=70, honor_leading_ws=1): + """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 = 0 + for line in para.split(NL): + if not line: + lines.append(line) + continue + if honor_leading_ws and line[0] in whitespace: + fillthis = 0 + else: + fillthis = 1 + 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 = bol - 1 + # now find the last non-whitespace character + eol = bol + while eol > 0 and text[eol] in whitespace: + eol = 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 = eol + 1 + bol = eol + while bol < len(text) and \ + text[bol] in whitespace: + bol = bol + 1 + bol = bol - 1 + line = text[:eol+1] + '\n' + # find the next non-whitespace character + bol = bol + 1 + while bol < len(text) and text[bol] in whitespace: + bol = bol + 1 + text = text[bol:] + wrapped = wrapped + line + wrapped = wrapped + '\n' + # end while text + wrapped = 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('[][()<>|;^,/]') + +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 s.count(' ') > 0: + raise Errors.MMBadEmailError + if _badchars.search(s) or s[0] == '-': + raise Errors.MMHostileAddress, s + user, domain_parts = ParseEmail(s) + # This means local, unqualified addresses, are no allowed + if not domain_parts: + raise Errors.MMBadEmailError, s + if len(domain_parts) < 2: + raise Errors.MMBadEmailError, s + + + +def GetPathPieces(envar='PATH_INFO'): + path = os.environ.get(envar) + if path: + return [p for p in path.split('/') if p] + return None + + + +def ScriptURL(target, web_page_url=None, absolute=0): + """target - scriptname only, nothing extra + web_page_url - the list's configvar of the same name + absolute - a flag which if set, generates an absolute url + """ + if web_page_url is None: + web_page_url = mm_cfg.DEFAULT_URL_PATTERN % get_domain() + if web_page_url[-1] <> '/': + web_page_url = web_page_url + '/' + fullpath = os.environ.get('REQUEST_URI') + if fullpath is None: + fullpath = os.environ.get('SCRIPT_NAME', '') + \ + os.environ.get('PATH_INFO', '') + baseurl = urlparse.urlparse(web_page_url)[2] + if not absolute and fullpath[:len(baseurl)] == baseurl: + # Use relative addressing + fullpath = fullpath[len(baseurl):] + i = fullpath.find('?') + if i > 0: + count = fullpath.count('/', 0, i) + else: + count = fullpath.count('/') + path = ('../' * count) + target + else: + path = web_page_url + target + return path + mm_cfg.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(list, foldcase=0): + """Return a dict keyed by the entries in the list passed to it.""" + d = {} + if foldcase: + for i in list: + d[i.lower()] = 1 + else: + for i in list: + d[i] = 1 + 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 MakeRandomPassword(length=6): + syls = [] + while len(syls)*2 < length: + syls.append(random.choice(_syllables)) + return EMPTYSTRING.join(syls)[:length] + +def GetRandomSeed(): + chr1 = int(random.random() * 52) + chr2 = int(random.random() * 52) + def mkletter(c): + if 0 <= c < 26: + c = c + 65 + if 26 <= c < 52: + c = c - 26 + 97 + return c + return "%c%c" % tuple(map(mkletter, (chr1, chr2))) + + + +def set_global_password(pw, siteadmin=1): + if siteadmin: + filename = mm_cfg.SITE_PW_FILE + else: + filename = mm_cfg.LISTCREATOR_PW_FILE + omask = os.umask(026) # rw-r----- + try: + fp = open(filename, 'w') + fp.write(sha.new(pw).hexdigest() + '\n') + fp.close() + finally: + os.umask(omask) + + +def get_global_password(siteadmin=1): + if siteadmin: + filename = mm_cfg.SITE_PW_FILE + else: + filename = mm_cfg.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, just return false + return None + return challenge + + +def check_global_password(response, siteadmin=1): + challenge = get_global_password(siteadmin) + if challenge is None: + return None + return challenge == sha.new(response).hexdigest() + + + +def websafe(s): + return cgi.escape(s, quote=1) + + + +# Just changing these two functions should be enough to control the way +# that email address obscuring is handled. +def ObscureEmail(addr, for_text=0): + """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--', '@') + + + +def maketext(templatefile, dict=None, raw=0, 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. If that's 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. + # + # Calculate the languages to scan + languages = [] + if lang is not None: + languages.append(lang) + if mlist is not None: + languages.append(mlist.preferred_language) + languages.append(mm_cfg.DEFAULT_SERVER_LANGUAGE) + # Calculate the locations to scan + searchdirs = [] + if mlist is not None: + searchdirs.append(mlist.fullpath()) + searchdirs.append(os.path.join(mm_cfg.TEMPLATE_DIR, mlist.host_name)) + searchdirs.append(os.path.join(mm_cfg.TEMPLATE_DIR, 'site')) + searchdirs.append(mm_cfg.TEMPLATE_DIR) + # Start scanning + quickexit = 'quickexit' + fp = None + try: + for lang in languages: + for dir in searchdirs: + filename = os.path.join(dir, lang, templatefile) + try: + fp = open(filename) + raise quickexit + except IOError, e: + if e.errno <> errno.ENOENT: raise + # Okay, it doesn't exist, keep looping + fp = None + except quickexit: + 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: + fp = open(os.path.join(mm_cfg.TEMPLATE_DIR, 'en', templatefile)) + except IOError, e: + if e.errno <> errno.ENOENT: raise + # We never found the template. BAD! + raise IOError(errno.ENOENT, 'No template file found', templatefile) + template = fp.read() + fp.close() + text = template + if dict is not None: + try: + sdict = SafeDict(dict) + try: + text = sdict.interpolate(template) + except UnicodeError: + # Try again after coercing the template to unicode + utemplate = unicode(template, GetCharSet(lang), 'replace') + text = sdict.interpolate(utemplate) + except (TypeError, ValueError): + # The template is really screwed up + pass + if raw: + return text + return wrap(text) + + + +ADMINDATA = { + # admin keyword: (minimum #args, maximum #args) + 'confirm': (1, 1), + 'help': (0, 0), + 'info': (0, 0), + 'lists': (0, 0), + 'options': (0, 0), + 'password': (2, 2), + 'remove': (0, 0), + 'set': (3, 3), + 'subscribe': (0, 3), + 'unsubscribe': (0, 1), + 'who': (0, 0), + } + +# Given a Message.Message object, test for administrivia (eg subscribe, +# unsubscribe, etc). The test must be a good guess -- messages that return +# true get sent to the list admin instead of the entire list. +def is_administrivia(msg): + linecnt = 0 + lines = [] + for line in email.Iterators.body_line_iterator(msg): + # Strip out any signatures + if line == '-- ': + break + if line.strip(): + linecnt += 1 + if linecnt > mm_cfg.DEFAULT_MAIL_COMMANDS_MAX_LINES: + return 0 + lines.append(line) + bodytext = NL.join(lines) + # See if the body text has only one word, and that word is administrivia + if ADMINDATA.has_key(bodytext.strip().lower()): + return 1 + # Look at the first N lines and see if there is any administrivia on the + # line. BAW: N is currently hardcoded to 5. str-ify the Subject: header + # because it may be an email.Header.Header instance rather than a string. + bodylines = lines[:5] + subject = str(msg.get('subject', '')) + bodylines.append(subject) + for line in bodylines: + if not line.strip(): + continue + words = [word.lower() for word in line.split()] + minargs, maxargs = ADMINDATA.get(words[0], (None, None)) + if minargs is None and maxargs is None: + continue + if minargs <= len(words[1:]) <= maxargs: + # Special case the `set' keyword. BAW: I don't know why this is + # here. + if words[0] == 'set' and words[2] not in ('on', 'off'): + continue + return 1 + return 0 + + + +def GetRequestURI(fallback=None, escape=1): + """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 os.environ.has_key('REQUEST_URI'): + url = os.environ['REQUEST_URI'] + elif os.environ.has_key('SCRIPT_NAME') and os.environ.has_key('PATH_INFO'): + url = os.environ['SCRIPT_NAME'] + os.environ['PATH_INFO'] + if escape: + return websafe(url) + return url + + + +# Wait on a dictionary of child pids +def reap(kids, func=None, once=0): + while kids: + if func: + func() + try: + pid, status = os.waitpid(-1, os.WNOHANG) + except OSError, e: + # If the child procs had a bug we might have no children + if e.errno <> errno.ECHILD: + raise + kids.clear() + break + if pid <> 0: + try: + del kids[pid] + except KeyError: + # Huh? How can this happen? + pass + if once: + break + + +def GetLanguageDescr(lang): + return mm_cfg.LC_DESCRIPTIONS[lang][0] + + +def GetCharSet(lang): + return mm_cfg.LC_DESCRIPTIONS[lang][1] + + + +def get_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] + if mm_cfg.VIRTUAL_HOST_OVERVIEW and host: + return host.lower() + else: + # See the note in Defaults.py concerning DEFAULT_HOST_NAME + # vs. DEFAULT_EMAIL_HOST. + hostname = mm_cfg.DEFAULT_HOST_NAME or mm_cfg.DEFAULT_EMAIL_HOST + return hostname.lower() + + +def get_site_email(hostname=None, extra=None): + if hostname is None: + hostname = mm_cfg.VIRTUAL_HOSTS.get(get_domain(), get_domain()) + if extra is None: + return '%s@%s' % (mm_cfg.MAILMAN_SITE_LIST, hostname) + return '%s-%s@%s' % (mm_cfg.MAILMAN_SITE_LIST, extra, hostname) + + + +# This algorithm crafts a guaranteed unique message-id. The theory here is +# that pid+listname+host will distinguish the message-id for every process on +# the system, except when process ids wrap around. To further distinguish +# message-ids, we prepend the integral time in seconds since the epoch. It's +# still possible that we'll vend out more than one such message-id per second, +# so we prepend a monotonically incrementing serial number. It's highly +# unlikely that within a single second, there'll be a pid wraparound. +_serial = 0 +def unique_message_id(mlist): + global _serial + msgid = '' % ( + _serial, time.time(), os.getpid(), + mlist.internal_name(), mlist.host_name) + _serial += 1 + return msgid + + +# 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,)) + + + +# Utilities to convert from simplified $identifier substitutions to/from +# standard Python $(identifier)s substititions. The "Guido rules" for the +# former are: +# $$ -> $ +# $identifier -> $(identifier)s +# ${identifier} -> $(identifier)s + +def to_dollar(s): + """Convert from %-strings to $-strings.""" + s = s.replace('$', '$$').replace('%%', '%') + parts = cre.split(s) + for i in range(1, len(parts), 2): + if parts[i+1] and parts[i+1][0] in IDENTCHARS: + parts[i] = '${' + parts[i] + '}' + else: + parts[i] = '$' + parts[i] + return EMPTYSTRING.join(parts) + + +def to_percent(s): + """Convert from $-strings to %-strings.""" + s = s.replace('%', '%%').replace('$$', '$') + parts = dre.split(s) + for i in range(1, len(parts), 4): + if parts[i] is not None: + parts[i] = '$' + elif parts[i+1] is not None: + parts[i+1] = '%(' + parts[i+1] + ')s' + else: + parts[i+2] = '%(' + parts[i+2] + ')s' + return EMPTYSTRING.join(filter(None, parts)) + + +def dollar_identifiers(s): + """Return the set (dictionary) of identifiers found in a $-string.""" + d = {} + for name in filter(None, [b or c or None for a, b, c in dre.findall(s)]): + d[name] = 1 + return d + + +def percent_identifiers(s): + """Return the set (dictionary) of identifiers found in a %-string.""" + d = {} + for name in cre.findall(s): + d[name] = 1 + return d + + + +# Utilities to canonicalize a string, which means un-HTML-ifying the string to +# produce a Unicode string or an 8-bit string if all the characters are ASCII. +def canonstr(s, lang=None): + newparts = [] + parts = re.split(r'&(?P[^;]+);', s) + def appchr(i): + if i < 256: + newparts.append(chr(i)) + else: + newparts.append(unichr(i)) + while 1: + newparts.append(parts.pop(0)) + if not parts: + break + ref = parts.pop(0) + if ref.startswith('#'): + try: + appchr(int(ref[1:])) + except ValueError: + # Non-convertable, stick with what we got + newparts.append('&'+ref+';') + else: + c = htmlentitydefs.entitydefs.get(ref, '?') + if c.startswith('#') and c.endswith(';'): + appchr(int(ref[1:-1])) + else: + newparts.append(c) + newstr = EMPTYSTRING.join(newparts) + if isinstance(newstr, UnicodeType): + return newstr + # We want the default fallback to be iso-8859-1 even if the language is + # English (us-ascii). This seems like a practical compromise so that + # non-ASCII characters in names can be used in English lists w/o having to + # change the global charset for English from us-ascii (which I + # superstitiously think my have unintended consequences). + if lang is None: + charset = 'iso-8859-1' + else: + charset = GetCharSet(lang) + if charset == 'us-ascii': + charset = 'iso-8859-1' + return unicode(newstr, charset, 'replace') + + +# 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, UnicodeType): + 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)) diff --git a/Mailman/Version.py b/Mailman/Version.py new file mode 100644 index 00000000..11fb2c0a --- /dev/null +++ b/Mailman/Version.py @@ -0,0 +1,48 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +# Mailman version +VERSION = "2.1" + +# And as a hex number in the manner of PY_VERSION_HEX +ALPHA = 0xa +BETA = 0xb +GAMMA = 0xc +# release candidates +RC = GAMMA +FINAL = 0xf + +MAJOR_REV = 2 +MINOR_REV = 1 +MICRO_REV = 0 +REL_LEVEL = FINAL +# at most 15 beta releases! +REL_SERIAL = 0 + +HEX_VERSION = ((MAJOR_REV << 24) | (MINOR_REV << 16) | (MICRO_REV << 8) | + (REL_LEVEL << 4) | (REL_SERIAL << 0)) + +# config.pck schema version number +DATA_FILE_VERSION = 88 + +# qfile/*.db schema version number +QFILE_SCHEMA_VERSION = 3 + +# version number for the data/pending.db file schema +PENDING_FILE_SCHEMA_VERSION = 1 + +# version number for the lists//request.db file schema +REQUESTS_FILE_SCHEMA_VERSION = 1 diff --git a/Mailman/__init__.py b/Mailman/__init__.py new file mode 100644 index 00000000..2cbbabb1 --- /dev/null +++ b/Mailman/__init__.py @@ -0,0 +1,15 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. diff --git a/Mailman/htmlformat.py b/Mailman/htmlformat.py new file mode 100644 index 00000000..0175bccb --- /dev/null +++ b/Mailman/htmlformat.py @@ -0,0 +1,678 @@ +# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + + +"""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. +""" + + +# Eventually could abstract down to HtmlItem, which outputs an arbitrary html +# object given start / end tags, valid options, and a value. Ug, objects +# shouldn't be adding their own newlines. The next object should. + + +import types + +from Mailman import mm_cfg +from Mailman import Utils +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 type(item) == type(''): + return item + elif not hasattr(item, "Format"): + return `item` + else: + return item.Format(indent) + +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 = mm_cfg.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 mm_cfg.IMAGE_LOGOS: + output.append('' % + (mm_cfg.IMAGE_LOGOS + mm_cfg.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 mm_cfg.WEB_VLINK_COLOR: + kws.setdefault('vlink', mm_cfg.WEB_VLINK_COLOR) + if mm_cfg.WEB_ALINK_COLOR: + kws.setdefault('alink', mm_cfg.WEB_ALINK_COLOR) + if mm_cfg.WEB_LINK_COLOR: + kws.setdefault('alink', mm_cfg.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) + + def addError(self, errmsg, tag=None, *args): + if tag is None: + tag = _('Error: ') + self.AddItem(Header(3, Bold(FontAttr( + _(tag), color=mm_cfg.WEB_ERROR_COLOR, size='+2')).Format() + + Italic(errmsg % args).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=mm_cfg.TEXTFIELDWIDTH): + InputObj.__init__(self, name, "PASSWORD", value, checked=0, size=size) + +class TextBox(InputObj): + def __init__(self, name, value='', size=mm_cfg.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 = '