././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1672813221.3691359
mailman-3.3.10/COPYING 0000644 0000000 0000000 00000104514 14355215245 011210 0 ustar 00 GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc.
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
Copyright (C)
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 3 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, see .
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
Copyright (C)
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
. ././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1726994523.940187
mailman-3.3.10/README.md 0000644 0000000 0000000 00000000753 14673754134 011444 0 ustar 00 This is GNU Mailman, a mailing list management system distributed under the
terms of the GNU General Public License (GPL) version 3 or later. The name of
this software is spelled ‘Mailman’ with a leading capital ‘M’ but with a lower
case second ‘m’. Any other spelling is incorrect.
Useful Links
============
* Documentation is available at https://docs.mailman3.org/en/latest/.
* Report bugs at https://gitlab.com/mailman/mailman/issues.
* Home page is https://www.list.org/.
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1672838243.9033947
mailman-3.3.10/_ext/__init__.py 0000644 0000000 0000000 00000000000 14355276144 013213 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5049217
mailman-3.3.10/_ext/configplugin.py 0000644 0000000 0000000 00000015225 14542770442 014155 0 ustar 00 # Copyright (C) 2020-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Sphinx plugin to render Mailman Core configuration file schema.cfg."""
import re
import configparser
from docutils import nodes
from docutils.parsers.rst import Directive, directives
from docutils.statemachine import ViewList
from importlib.resources import files
from sphinx.util.nodes import nested_parse_with_titles
def get_config_text():
"""Get Mailman's schema.cfg as str"""
return files('mailman.config').joinpath('schema.cfg').read_text()
def get_section_text(section, schema_text):
"""Get the text of a give ini section.
This includes the region between two `[section]` headers in the ini file.
:param section: The name of the section.
:param schema_text: The whole config file contents.
:returns: The str of all the value in the section.
:raises ValueError: If the name of the section can't be found.
."""
# Split the whole file at the boundary of [sections].
sections = re.split(r'^\[(?P[^]]+)\]',
schema_text, flags=re.MULTILINE)
if not f'{section}' in sections:
raise ValueError('Invalid section name {}'.format(section))
section_index = sections.index(section)
section_text = sections[section_index + 1]
return section_text
def is_comment(para):
"""Check if a paragraph is comment without any options.
:param para: The paragraph text.
:returns: True if all lines start with '#', False otherwise.
"""
para = para.strip()
for line in para.splitlines():
if not line.startswith('#'):
return False
return True
def get_options(section_text):
"""Parse the key:value pairs from it along with comments.
Given the text of a section, split the whole text with empty lines
('\n\n'). For each part get the (key: value) pairs by letting configparser
parse the text. The remaining lines of text, which ends up being the
comments in the file serve as the documentation for those key: value pairs.
Note: We append a `[dummy]` section name to the section_text since
configparser will refuse to parse a section text that doesn't include a
`[section]` header. There is no real significance of that since we
immidiately discard the section name.
If the section starts off with a block of just comment, it is called
"section_doc".
The return format looks something like:
([{'key': 'value', 'key2': 'value2', 'doc': 'Comments'}], "Section Doc")
The first item is a list of dictionaries, each of which represents a
paragraph in the ini text. All (key: value) pairs are in the dictionary
and the comments as a part of the 'doc'. When there are no comments, 'doc'
option is omitted.
:param section_text: The whole text of the section, not include the header
`[section]` itself.
:returns: Parsed options, docs and section docs.
"""
options = []
opts_list = section_text.split('\n\n')
section_doc = None
# We check for a section leve doc by looking if the
# first *two* paragraphs are both comments and *only* comments.
if is_comment(opts_list[0]) and is_comment(opts_list[1]):
section_doc = opts_list.pop(0)
for each in opts_list:
if each.strip() == '':
continue
# Configparser will refuse to parse section without it's name.
each = '[dummy]\n' + each
config = configparser.ConfigParser()
config.read_string(each)
data = {}
for key in config['dummy']:
data[key] = config['dummy'][key]
doc = '\n'.join(line for line in each.splitlines() if line.startswith('#'))
data['doc'] = doc.replace('#', '')
options.append(data)
return options, section_doc
def get_section_rst(section, section_doc, opts):
"""Convert the section text into formatted ReST.
A section from ini file that looks like this:
[section]
# This is a section level documentation.
# This documentation if for immediately following key:value
key: value
Is converted to ReST that looks something like:
``[section]``
=============
key
~~~
**default**: value
This documentation is for the immediately following key:value
"""
rst = '``[{}]``\n{}\n'.format(section, '='*(len(section) + 6))
if section_doc:
rst += section_doc.replace('#', '')
for each in opts:
doc = '\n'
if 'doc' in each:
doc = each.pop('doc')
for opt, value in each.items():
rst += '{}\n{}\n'.format(opt, '~'*len(opt))
if value:
rst += '**default**: {}\n\n'.format(value)
rst += doc.replace('#', '')
rst += '\n\n'
return rst
class ConfigSectionDirective(Directive):
"""Sphinx plugin that renders Mailman's ini configuration as ReST."""
required_arguments = 1
final_argument_whitespace = True
option_spec = {}
has_content = False
def run(self):
"""Split the arguments as a list of sections and render as ReST."""
sections = self.arguments[0].split()
child_nodes = []
lineno = 1
for section in sections:
rst = ViewList()
config_text = get_config_text()
section_text = get_section_text(section, config_text)
section_opts, section_doc = get_options(section_text)
section_rst = get_section_rst(section, section_doc, section_opts)
for line in section_rst.splitlines():
rst.append(line, 'fakefile.rst', lineno)
lineno += 1
node = nodes.section()
node.document = self.state.document
nested_parse_with_titles(self.state, rst, node)
child_nodes.extend(node.children)
return child_nodes
def setup(app):
app.add_directive('configsection', ConfigSectionDirective)
return {
'version': '0.1',
'parallel_read_safe': True,
'parallel_write_safe': True,
}
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1727843171.9472253
mailman-3.3.10/conf.py 0000644 0000000 0000000 00000020212 14677145544 011457 0 ustar 00 # -*- coding: utf-8 -*-
#
# GNU Mailman documentation build configuration file, created by
# sphinx-quickstart on Fri Sep 23 21:30:41 2011.
#
# This file is execfile()d with the current directory set to its containing dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.
import os
import sys
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#sys.path.insert(0, os.path.abspath('.'))
# -- General configuration -----------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
#needs_sphinx = '1.0'
sys.path.append(os.path.abspath('_ext'))
# Add any Sphinx extension module names here, as strings. They can be extensions
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = ['sphinx.ext.autodoc',
'sphinx.ext.viewcode',
'sphinx.ext.graphviz',
'sphinxcontrib.zopeext.autointerface',
# This is custom plugin in the `_ext/` directory in the top level
# directory of Mailman Core. This is primarily used to render the
# configuration files like schema.cfg file which uses zope.configuration
# syntax.
'configplugin',
]
# Add any paths that contain templates here, relative to this directory.
# templates_path = ['_templates']
# The suffix of source filenames.
source_suffix = '.rst'
# The encoding of source files.
#source_encoding = 'utf-8-sig'
# The master toctree document.
master_doc = 'index'
# General information about the project.
project = u'GNU Mailman'
copyright = u'1998-2018 by the Free Software Foundation, Inc.'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
import sys; sys.path.append('src')
from mailman.version import VERSION
version = '.'.join(VERSION.split('.')[0:2])
# The full version, including alpha/beta/rc tags.
release = VERSION
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#language = None
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
#today = ''
# Else, today_fmt is used as the format for a strftime call.
#today_fmt = '%B %d, %Y'
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
exclude_patterns = ['_build', 'eggs', '.tox', '.pc']
# The reST default role (used for this markup: `text`) to use for all documents.
#default_role = None
# If true, '()' will be appended to :func: etc. cross-reference text.
#add_function_parentheses = True
# If true, the current module name will be prepended to all description
# unit titles (such as .. function::).
#add_module_names = True
# If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default.
#show_authors = False
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
# A list of ignored prefixes for module index sorting.
#modindex_common_prefix = []
# -- Options for HTML output ---------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
html_theme = 'sphinx_rtd_theme'
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#html_theme_options = {}
# Add any paths that contain custom themes here, relative to this directory.
#html_theme_path = []
# The name for this set of Sphinx documents. If None, it defaults to
# " v documentation".
#html_title = None
# A shorter title for the navigation bar. Default is the same as html_title.
#html_short_title = None
# The name of an image file (relative to this directory) to place at the top
# of the sidebar.
html_logo = 'logo2010-2.jpg'
# The name of an image file (within the static path) to use as favicon of the
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large.
#html_favicon = None
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
# html_static_path = ['_static']
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format.
#html_last_updated_fmt = '%b %d, %Y'
# If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities.
#html_use_smartypants = True
# Custom sidebar templates, maps document names to template names.
#html_sidebars = {}
# Additional templates that should be rendered to pages, maps page names to
# template names.
#html_additional_pages = {}
# If false, no module index is generated.
#html_domain_indices = True
# If false, no index is generated.
#html_use_index = True
# If true, the index is split into individual pages for each letter.
#html_split_index = False
# If true, links to the reST sources are added to the pages.
#html_show_sourcelink = True
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
#html_show_sphinx = True
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
#html_show_copyright = True
# If true, an OpenSearch description file will be output, and all pages will
# contain a tag referring to it. The value of this option must be the
# base URL from which the finished HTML is served.
#html_use_opensearch = ''
# This is the file name suffix for HTML files (e.g. ".xhtml").
#html_file_suffix = None
# Output file base name for HTML help builder.
htmlhelp_basename = 'GNUMailmandoc'
# -- Options for LaTeX output --------------------------------------------------
# The paper size ('letter' or 'a4').
#latex_paper_size = 'letter'
# The font size ('10pt', '11pt' or '12pt').
#latex_font_size = '10pt'
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title, author, documentclass [howto/manual]).
latex_documents = [
('README', 'GNUMailman.tex', u'GNU Mailman Documentation',
u'Barry Warsaw', 'manual'),
]
# The name of an image file (relative to this directory) to place at the top of
# the title page.
#latex_logo = None
# For "manual" documents, if this is true, then toplevel headings are parts,
# not chapters.
#latex_use_parts = False
# If true, show page references after internal links.
#latex_show_pagerefs = False
# If true, show URL addresses after external links.
#latex_show_urls = False
# Additional stuff for the LaTeX preamble.
#latex_preamble = ''
# Documents to append as an appendix to all manuals.
#latex_appendices = []
# If false, no module index is generated.
#latex_domain_indices = True
autodoc_mock_imports = [
"mailman.testing.helpers"
]
# -- Options for manual page output --------------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
('README', 'gnumailman', u'GNU Mailman Documentation',
[u'Barry Warsaw'], 1)
]
# def index_html():
# import errno
# cwd = os.getcwd()
# try:
# try:
# os.makedirs('build/sphinx/html')
# except OSError as error:
# if error.errno != errno.EEXIST:
# raise
# os.chdir('build/sphinx/html')
# try:
# os.symlink('README.html', 'index.html')
# print('index.html -> README.html')
# except OSError as error:
# if error.errno != errno.EEXIST:
# raise
# finally:
# os.chdir(cwd)
# import atexit
# atexit.register(index_html)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1672813221.3681335
mailman-3.3.10/copybump.py 0000755 0000000 0000000 00000005342 14355215245 012367 0 ustar 00 #! /usr/bin/env python3
import os
import re
import sys
import stat
import datetime
FSF = 'by the Free Software Foundation, Inc.'
this_year = datetime.date.today().year
pyre_c = re.compile(r'# Copyright \(C\) ((?P\d{4})-)?(?P\d{4})')
pyre_n = re.compile(r'# Copyright ((?P\d{4})-)?(?P\d{4})')
new_c = '# Copyright (C) {}-{} {}'
new_n = '# Copyright {}-{} {}'
MODE = (stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO)
if '--noc' in sys.argv:
pyre = pyre_n
new = new_n
sys.argv.remove('--noc')
else:
pyre = pyre_c
new = new_c
def do_file(path, owner):
permissions = os.stat(path).st_mode & MODE
with open(path) as in_file, open(path + '.out', 'w') as out_file:
try:
for line in in_file:
mo_c = pyre_c.match(line)
mo_n = pyre_n.match(line)
if mo_c is None and mo_n is None:
out_file.write(line)
continue
mo = (mo_n if mo_c is None else mo_c)
start = (mo.group('end')
if mo.group('start') is None
else mo.group('start'))
if int(start) == this_year:
out_file.write(line)
continue
print(new.format(start, this_year, owner), file=out_file)
print('=>', path)
for line in in_file:
out_file.write(line)
except UnicodeDecodeError:
print('Cannot convert path:', path)
os.remove(path + '.out')
return
os.rename(path + '.out', path)
os.chmod(path, permissions)
def remove(dirs, path):
try:
dirs.remove(path)
except ValueError:
pass
def do_walk():
try:
owner = sys.argv[1]
except IndexError:
owner = FSF
for root, dirs, files in os.walk('.'):
if root == '.':
remove(dirs, '.git')
remove(dirs, '.tox')
remove(dirs, 'bin')
remove(dirs, 'contrib')
remove(dirs, 'develop-eggs')
remove(dirs, 'eggs')
remove(dirs, 'parts')
remove(dirs, 'gnu-COPYING-GPL')
remove(dirs, '.installed.cfg')
remove(dirs, '.bzrignore')
remove(dirs, 'distribute_setup.py')
if root == './src':
remove(dirs, 'mailman.egg-info')
if root == './src/mailman':
remove(dirs, 'messages')
for file_name in files:
if os.path.splitext(file_name)[1] in ('.pyc', '.gz', '.egg'):
continue
path = os.path.join(root, file_name)
if os.path.isfile(path):
do_file(path, owner)
if __name__ == '__main__':
do_walk()
././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1672813221.366098
mailman-3.3.10/coverage.ini 0000644 0000000 0000000 00000000665 14355215245 012453 0 ustar 00 [run]
branch = true
parallel = true
omit =
setup*
*/showme.py
.tox/*/lib/python3.*/site-packages/*
.tox/*/lib/python3.*/site-packages/*
*/test_*.py
/tmp/*
/private/var/folders/*
*/testing/*.py
[report]
exclude_lines =
pragma: nocover
pragma: missed
raise NotImplementedError
raise AssertionError
assert\s
[paths]
source =
mailman
.tox/*/lib/python3.*/site-packages/mailman
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1672838243.9043314
mailman-3.3.10/generate_mo.sh 0000755 0000000 0000000 00000000351 14355276144 013000 0 ustar 00 #!/bin/bash
# This script generates .mo files from all the .po files in the source.
echo 'Generating mo files for GNU Mailman ...'
for file in `find . -name 'mailman.po'`
do
echo $file
msgfmt $file -o ${file/po/mo} -v
done
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1726994523.9406424
mailman-3.3.10/index.rst 0000644 0000000 0000000 00000006324 14673754134 012026 0 ustar 00 ================================================
Mailman - The GNU Mailing List Management System
================================================
.. image:: https://gitlab.com/mailman/mailman/badges/master/pipeline.svg
:target: https://gitlab.com/mailman/mailman/commits/master
.. image:: https://readthedocs.org/projects/mailman/badge
:target: https://mailman.readthedocs.io
.. image:: https://img.shields.io/pypi/v/mailman.svg
:target: https://pypi.org/project/mailman/
.. image:: https://img.shields.io/pypi/dm/mailman.svg
:target: https://pypi.org/project/mailman/
Copyright (C) 1998-2022 by the Free Software Foundation, Inc.
This is GNU Mailman, a mailing list management system distributed under the
terms of the GNU General Public License (GPL) version 3 or later. The name of
this software is spelled "Mailman" with a leading capital 'M' but with a lower
case second 'm'. Any other spelling is incorrect.
Technically speaking, you are reading the documentation for Mailman Core. The
full `Mailman 3 suite `_ includes a web user
interface called Postorius, a web archiver called HyperKitty, and a few other
components. If you're looking for instructions on installing the full suite,
read that documentation.
Mailman is written in Python which is available for all platforms that Python
is supported on, including GNU/Linux and most other Unix-like operating
systems (e.g. Solaris, \*BSD, MacOSX, etc.). Mailman is not supported on
Windows, although web and mail clients on any platform should be able to
interact with Mailman just fine.
The Mailman home page is:
http://www.list.org
and there is a community driven wiki at
http://wiki.list.org
For more information on Mailman, see the above web sites, or the
:ref:`documentation provided with this software `.
Table of Contents
=================
.. toctree::
:glob:
:maxdepth: 1
src/mailman/docs/introduction
src/mailman/docs/release-notes
src/mailman/docs/install
src/mailman/config/docs/config
src/mailman/docs/database
src/mailman/docs/mta
src/mailman/docs/postorius
src/mailman/docs/hyperkitty
src/mailman/docs/documentation
src/mailman/plugins/docs/intro
src/mailman/docs/contribute
src/mailman/docs/STYLEGUIDE
src/mailman/docs/internationalization
src/mailman/docs/architecture
src/mailman/docs/8-miles-high
src/mailman/docs/NEWS
src/mailman/docs/ACKNOWLEDGMENTS
REST API
--------
.. toctree::
:maxdepth: 2
:caption: REST API
src/mailman/rest/docs/rest
Mailman modules
---------------
These documents are generated from the internal module documentation.
.. toctree::
:maxdepth: 1
:caption: Mailman Modules
src/mailman/model/docs/model
src/mailman/runners/docs/runners
src/mailman/chains/docs/chains
src/mailman/rules/docs/rules
src/mailman/handlers/docs/handlers
src/mailman/core/docs/core
src/mailman/app/docs/app
src/mailman/styles/docs/styles
src/mailman/archiving/docs/common
src/mailman/mta/docs/mta
src/mailman/bin/docs/master
src/mailman/commands/docs/commands
contrib/README
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1727843222.8970885
mailman-3.3.10/pyproject.toml 0000644 0000000 0000000 00000004260 14677145627 013103 0 ustar 00 [tool.pdm]
package-dir = "src"
[tool.pdm.version]
source = "file"
path = "src/mailman/version.py"
[tool.pdm.build]
source-includes = [
"*.rst",
"*.md",
"*.ini",
"*.txt",
"*.cfg",
"COPYING",
"*.py",
"*.sh",
".coveragerc",
"_ext/*",
]
[project]
name = "mailman"
dynamic = []
description = "Mailman -- the GNU mailing list manager"
keywords = [
"email",
]
readme = "README.md"
authors = [
{ name = "The Mailman Developers", email = "mailman-developers@python.org" },
]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: System Administrators",
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
"Operating System :: POSIX",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.9",
"Topic :: Communications :: Email :: Mailing List Servers",
"Topic :: Communications :: Usenet News",
"Topic :: Internet :: WWW/HTTP :: WSGI :: Application",
]
dependencies = [
"aiosmtpd>=1.4.3",
"alembic>=1.6.2,!=1.7.0",
"atpublic",
"authheaders>=0.16",
"authres>=1.0.1",
"click>=8.0.0",
"dnspython>=1.14.0",
"falcon>=3.1.3",
"flufl.bounce>=4.0",
"flufl.i18n>=3.2",
"flufl.lock>=5.1",
"gunicorn",
"lazr.config",
"nntplib; python_version>=\"3.13\"",
"passlib",
"python-dateutil>=2.0",
"requests",
"sqlalchemy>=1.4.0",
"zope.component",
"zope.configuration",
"zope.event",
"zope.interface>=5.0",
]
requires-python = ">=3.9"
version = "3.3.10"
[project.license]
text = "GPLv3"
[project.optional-dependencies]
setproctitle = [
"setproctitle",
]
[project.urls]
Homepage = "https://www.list.org"
Documentation = "https://docs.mailman3.org/projects/mailman/en/latest/index.html"
Source = "https://gitlab.com/mailman/mailman.git"
Tracker = "https://gitlab.com/mailman/mailman/-/issues"
[project.scripts]
mailman = "mailman.bin.mailman:main"
master = "mailman.bin.master:main"
runner = "mailman.bin.runner:main"
[build-system]
requires = [
"pdm-backend",
]
build-backend = "pdm.backend"
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5069075
mailman-3.3.10/requirements-docs.txt 0000644 0000000 0000000 00000000126 14542770442 014364 0 ustar 00 sphinx>=3.2,!=5.2.0.post0
sphinx_rtd_theme
docutils<0.18,>=0.14
sphinxcontrib-zopeext
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5071557
mailman-3.3.10/src/mailman/__init__.py 0000644 0000000 0000000 00000002633 14542770442 014475 0 ustar 00 # Copyright (C) 2009-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""The `mailman` package."""
import sys
import pkgutil
# This is a namespace package.
__path__ = pkgutil.extend_path(__path__, __name__) # noqa: F821
# We have to initialize the i18n subsystem before anything else happens,
# however, we'll initialize it differently for tests. We have to do it this
# early so that module contents is set up before anything that needs it is
# imported.
#
# Do *not* do this if we're building the documentation.
if 'build_sphinx' not in sys.argv: # pragma: nocover
if any('nose2' in arg for arg in sys.argv):
from mailman.testing.i18n import initialize
else:
from mailman.core.i18n import initialize
initialize()
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1672813222.7114954
mailman-3.3.10/src/mailman/app/__init__.py 0000644 0000000 0000000 00000000000 14355215247 015236 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1703670049.507666
mailman-3.3.10/src/mailman/app/bounces.py 0000644 0000000 0000000 00000025612 14542770442 015156 0 ustar 00 # Copyright (C) 2007-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Application level bounce handling."""
import re
import uuid
import logging
from email.mime.message import MIMEMessage
from email.mime.text import MIMEText
from email.utils import parseaddr
from lazr.config import as_timedelta
from mailman.config import config
from mailman.core.i18n import _
from mailman.email.message import OwnerNotification, UserNotification
from mailman.interfaces.bounce import UnrecognizedBounceDisposition
from mailman.interfaces.listmanager import IListManager
from mailman.interfaces.pending import IPendable, IPendings
from mailman.interfaces.subscriptions import ISubscriptionService
from mailman.interfaces.template import ITemplateLoader
from mailman.utilities.email import split_email
from mailman.utilities.string import expand, oneline, wrap
from public import public
from string import Template
from zope.component import getUtility
from zope.interface import implementer
log = logging.getLogger('mailman.config')
elog = logging.getLogger('mailman.error')
blog = logging.getLogger('mailman.bounce')
DOT = '.'
NL = '\n'
# Lifetime for pended probe tokens
PENDABLE_LIFETIME = '10d'
@public
def bounce_message(mlist, msg, error=None):
"""Bounce the message back to the original author.
:param mlist: The mailing list that the message was posted to.
:type mlist: `IMailingList`
:param msg: The original message.
:type msg: `email.message.Message`
:param error: Optional exception causing the bounce. The exception
instance must have a `.message` attribute. The exception *may* have a
non-None `.reasons` attribute which would be a list of reasons for the
rejection, and it may have a non-None `.substitutions` attribute. The
latter, along with the formatted reasons will be interpolated into the
message (`.reasons` gets put into the `$reasons` placeholder).
:type error: RejectMessage
"""
# Bounce a message back to the sender, with an error message if provided
# in the exception argument. .sender might be None or the empty string.
if not msg.sender:
# We can't bounce the message if we don't know who it's supposed to go
# to.
return
subject = msg.get('subject', _('(no subject)'))
subject = oneline(subject, mlist.preferred_language.charset)
notice = (_('[No bounce details are available]')
if error is None
else str(error))
# Currently we always craft bounces as MIME messages.
bmsg = UserNotification(msg.sender, mlist.owner_address, subject,
lang=mlist.preferred_language)
# BAW: Be sure you set the type before trying to attach, or you'll get
# a MultipartConversionError.
bmsg.set_type('multipart/mixed')
txt = MIMEText(notice, _charset=mlist.preferred_language.charset)
bmsg.attach(txt)
bmsg.attach(MIMEMessage(msg))
bmsg.send(mlist)
class _BaseVERPParser:
"""Base class for parsing VERP messages.
Sadly not every MTA bounces VERP messages correctly, or consistently.
First, the To: header is checked, then Delivered-To: (Postfix),
Envelope-To: (Exim) and Apparently-To:. Note that there can be multiple
headers so we need to search them all
"""
def __init__(self, pattern):
self._pattern = pattern
self._cre = re.compile(pattern, re.IGNORECASE)
def get_verp(self, mlist, msg):
"""Extract a set of VERP bounce addresses.
:param mlist: The mailing list being checked.
:type mlist: `IMailingList`
:param msg: The message being parsed.
:type msg: `email.message.Message`
:return: The set of addresses extracted from the VERP headers.
:rtype: set of strings
"""
blocal, bdomain = split_email(mlist.bounces_address)
values = set()
verp_matches = set()
for header in ('to', 'delivered-to', 'envelope-to', 'apparently-to'):
values.update(msg.get_all(header, []))
for field in values:
address = parseaddr(field)[1]
if not address:
# This header was empty.
continue
mo = self._cre.search(address)
if not mo:
# This did not match the VERP regexp.
continue
try:
if blocal != mo.group('bounces'):
# This was not a bounce to our mailing list.
continue
original_address = self._get_address(mo)
except IndexError:
elog.error('Bad VERP pattern: {0}'.format(self._pattern))
return set()
else:
if original_address is not None:
verp_matches.add(original_address)
return verp_matches
@public
class StandardVERP(_BaseVERPParser):
def __init__(self):
super().__init__(config.mta.verp_regexp)
def _get_address(self, match_object):
return '{0}@{1}'.format(*match_object.group('local', 'domain'))
@public
class ProbeVERP(_BaseVERPParser):
def __init__(self):
super().__init__(config.mta.verp_probe_regexp)
def _get_address(self, match_object):
# Extract the token and get the matching address.
token = match_object.group('token')
pendable = getUtility(IPendings).confirm(token)
if pendable is None:
# The token must have already been confirmed, or it may have been
# evicted from the database already.
return None
# We had to pend the uuid as a unicode.
member_id = uuid.UUID(hex=pendable['member_id'])
member = getUtility(ISubscriptionService).get_member(member_id)
if member is None:
return None
return member.address.email
@implementer(IPendable)
class _ProbePendable(dict):
"""The pendable dictionary for probe messages."""
PEND_TYPE = 'probe'
@public
def send_probe(member, msg=None, message_id=None):
"""Send a VERP probe to the member.
:param member: The member to send the probe to. From this object, both
the user and the mailing list can be determined.
:type member: IMember
:param msg: The bouncing message that caused the probe to be sent.
:type msg:
:param message_id: MessageID of the bouncing message.
:type message_id: str
:return: The token representing this probe in the pendings database.
:rtype: string
"""
if (message_id or msg) is None:
raise ValueError('Required at least one of "message_id" and "msg".')
mlist = getUtility(IListManager).get_by_list_id(
member.mailing_list.list_id)
template = getUtility(ITemplateLoader).get(
'list:user:notice:probe', mlist,
language=member.preferred_language.code,
# For backward compatibility.
code=member.preferred_language.code,
)
text = wrap(expand(template, mlist, dict(
sender_email=member.address.email,
# For backward compatibility.
address=member.address.email,
email=member.address.email,
owneraddr=mlist.owner_address,
)))
if message_id is None:
message_id = msg['message-id']
if isinstance(message_id, bytes):
message_id = message_id.decode('ascii')
pendable = _ProbePendable(
# We can only pend unicodes.
member_id=member.member_id.hex,
message_id=message_id,
)
token = getUtility(IPendings).add(
pendable, lifetime=as_timedelta(PENDABLE_LIFETIME))
mailbox, domain_parts = split_email(mlist.bounces_address)
probe_sender = Template(config.mta.verp_probe_format).safe_substitute(
bounces=mailbox,
token=token,
domain=DOT.join(domain_parts),
)
# Calculate the Subject header, in the member's preferred language.
with _.using(member.preferred_language.code):
subject = _('${mlist.display_name} mailing list probe message')
# Craft the probe message. This will be a multipart where the first part
# is the probe text and the second part is the message that caused this
# probe to be sent, if it provied.
probe = UserNotification(member.address.email, probe_sender,
subject, lang=member.preferred_language)
probe.set_type('multipart/mixed')
notice = MIMEText(text, _charset=member.preferred_language.charset)
probe.attach(notice)
if msg is not None:
probe.attach(MIMEMessage(msg))
# Probes should not have the Precedence: bulk header.
probe.send(mlist, sender=probe_sender, verp=False, probe_token=token,
add_precedence=False)
# When we send a probe, we reset the score.
member.bounce_score = 0
return token
@public
def maybe_forward(mlist, msg):
"""Possibly forward bounce messages with no recognizable addresses.
:param mlist: The mailing list.
:type mlist: `IMailingList`
:param msg: The bounce message to scan.
:type msg: `Message`
"""
message_id = msg['message-id']
if (mlist.forward_unrecognized_bounces_to
is UnrecognizedBounceDisposition.discard):
blog.error('Discarding unrecognized bounce: {0}'.format(message_id))
return
# The notification is either going to go to the list's administrators
# (owners and moderators), or to the site administrators. Most of the
# notification is exactly the same in either case.
subject = _('Uncaught bounce notification')
template = getUtility(ITemplateLoader).get(
'list:admin:notice:unrecognized', mlist)
text = expand(template, mlist)
text_part = MIMEText(text, _charset=mlist.preferred_language.charset)
attachment = MIMEMessage(msg)
if (mlist.forward_unrecognized_bounces_to
is UnrecognizedBounceDisposition.administrators):
keywords = dict(roster=mlist.administrators)
elif (mlist.forward_unrecognized_bounces_to
is UnrecognizedBounceDisposition.site_owner):
keywords = {}
else:
raise AssertionError('Invalid forwarding disposition: {0}'.format(
mlist.forward_unrecognized_bounces_to))
# Create the notification and send it.
notice = OwnerNotification(mlist, subject, **keywords)
notice.set_type('multipart/mixed')
notice.attach(text_part)
notice.attach(attachment)
notice.send(mlist)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5079393
mailman-3.3.10/src/mailman/app/commands.py 0000644 0000000 0000000 00000002061 14542770442 015312 0 ustar 00 # Copyright (C) 2008-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Initialize the email commands."""
from mailman.config import config
from mailman.interfaces.command import IEmailCommand
from mailman.utilities.modules import add_components
from public import public
@public
def initialize():
"""Initialize the email commands."""
add_components('commands', IEmailCommand, config.commands)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1726290746.6777334
mailman-3.3.10/src/mailman/app/digests.py 0000644 0000000 0000000 00000011140 14671215473 015152 0 ustar 00 # Copyright (C) 2015-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Digest functions."""
import os
from mailman.config import config
from mailman.email.message import Message
from mailman.interfaces.digests import DigestFrequency
from mailman.utilities.datetime import now as right_now
from public import public
@public
def bump_digest_number_and_volume(mlist):
"""Bump the digest number and volume."""
now = right_now()
if mlist.digest_last_sent_at is None:
# There has been no previous digest.
bump = False
elif mlist.digest_volume_frequency == DigestFrequency.yearly:
bump = (now.year > mlist.digest_last_sent_at.year)
elif mlist.digest_volume_frequency == DigestFrequency.monthly:
# Monthly.
this_month = now.year * 100 + now.month
digest_month = (mlist.digest_last_sent_at.year * 100 +
mlist.digest_last_sent_at.month)
bump = (this_month > digest_month)
elif mlist.digest_volume_frequency == DigestFrequency.quarterly:
# Quarterly.
this_quarter = now.year * 100 + (now.month - 1) // 4
digest_quarter = (mlist.digest_last_sent_at.year * 100 +
(mlist.digest_last_sent_at.month - 1) // 4)
bump = (this_quarter > digest_quarter)
elif mlist.digest_volume_frequency == DigestFrequency.weekly:
this_week = now.year * 100 + now.isocalendar()[1]
digest_week = (mlist.digest_last_sent_at.year * 100 +
mlist.digest_last_sent_at.isocalendar()[1])
bump = (this_week > digest_week)
elif mlist.digest_volume_frequency == DigestFrequency.daily:
bump = (now.toordinal() > mlist.digest_last_sent_at.toordinal())
else:
raise AssertionError(
'Bad DigestFrequency: {}'.format(mlist.digest_volume_frequency))
if bump:
mlist.volume += 1
mlist.next_digest_number = 1
else:
# Just bump the digest number.
mlist.next_digest_number += 1
mlist.digest_last_sent_at = now
@public
def maybe_send_digest_now(mlist, *, force=False):
"""Send this mailing list's digest now.
If there are any messages in this mailing list's digest, the
digest is sent immediately, regardless of whether the size
threshold has been met. When called through the subcommand
`mailman send_digest` the value of .digest_send_periodic is
consulted.
:param mlist: The mailing list whose digest should be sent.
:type mlist: IMailingList
:param force: Should the digest be sent even if the size threshold hasn't
been met?
:type force: boolean
"""
mailbox_path = os.path.join(mlist.data_path, 'digest.mmdf')
# Calculate the current size of the mailbox file. This will not tell
# us exactly how big the resulting MIME and rfc1153 digest will
# actually be, but it's the most easily available metric to decide
# whether the size threshold has been reached.
try:
size = os.path.getsize(mailbox_path)
except FileNotFoundError:
size = 0
if ((mlist.digest_size_threshold > 0 and
size >= mlist.digest_size_threshold * 1024.0) or
(force and size > 0)):
# Send the digest. Because we don't want to hold up this process
# with crafting the digest, we're going to move the digest file to
# a safe place, then craft a fake message for the DigestRunner as
# a trigger for it to build and send the digest.
mailbox_dest = os.path.join(
mlist.data_path,
'digest.{0.volume}.{0.next_digest_number}.mmdf'.format(
mlist))
volume = mlist.volume
digest_number = mlist.next_digest_number
bump_digest_number_and_volume(mlist)
os.rename(mailbox_path, mailbox_dest)
config.switchboards['digest'].enqueue(
Message(),
listid=mlist.list_id,
digest_path=mailbox_dest,
volume=volume,
digest_number=digest_number)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1672813222.7178698
mailman-3.3.10/src/mailman/app/docs/__init__.py 0000644 0000000 0000000 00000000000 14355215247 016166 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1672813222.7175891
mailman-3.3.10/src/mailman/app/docs/app.rst 0000644 0000000 0000000 00000000054 14355215247 015400 0 ustar 00 ===
App
===
.. toctree::
:glob:
./*
././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1672838243.905703
mailman-3.3.10/src/mailman/app/docs/bans.rst 0000644 0000000 0000000 00000011527 14355276144 015555 0 ustar 00 =======================
Banning email addresses
=======================
Email addresses can be banned from ever subscribing, either to a specific
mailing list or globally within the Mailman system. Both explicit email
addresses and email address patterns can be banned.
Bans are managed through the `Ban Manager`. There are ban managers for
specific lists, and there is a global ban manager. To get access to the
global ban manager, adapt ``None``.
>>> from mailman.interfaces.bans import IBanManager
>>> global_bans = IBanManager(None)
At first, no email addresses are banned globally.
>>> global_bans.is_banned('anne@example.com')
False
To get a list-specific ban manager, adapt the mailing list object.
>>> from mailman.app.lifecycle import create_list
>>> mlist = create_list('test@example.com')
>>> test_bans = IBanManager(mlist)
There are no bans for this particular list.
>>> test_bans.is_banned('bart@example.com')
False
Specific bans
=============
An email address can be banned from a specific mailing list by adding a ban to
the list's ban manager.
>>> test_bans.ban('cris@example.com')
>>> test_bans.is_banned('cris@example.com')
True
>>> test_bans.is_banned('bart@example.com')
False
However, this is not a global ban.
>>> global_bans.is_banned('cris@example.com')
False
Global bans
===========
An email address can be banned globally, so that it cannot be subscribed to
any mailing list.
>>> global_bans.ban('dave@example.com')
Because there is a global ban, Dave is also banned from the mailing list.
>>> test_bans.is_banned('dave@example.com')
True
Even when a new mailing list is created, Dave is still banned from this list
because of his global ban.
>>> sample = create_list('sample@example.com')
>>> sample_bans = IBanManager(sample)
>>> sample_bans.is_banned('dave@example.com')
True
Dave is of course banned globally.
>>> global_bans.is_banned('dave@example.com')
True
Cris however is not banned globally.
>>> global_bans.is_banned('cris@example.com')
False
Even though Cris is not banned globally, we can add a global ban for her.
>>> global_bans.ban('cris@example.com')
>>> global_bans.is_banned('cris@example.com')
True
Cris is now banned from all mailing lists.
>>> test_bans.is_banned('cris@example.com')
True
>>> sample_bans.is_banned('cris@example.com')
True
We can remove the global ban to once again just ban her address from just the
test list.
>>> global_bans.unban('cris@example.com')
>>> global_bans.is_banned('cris@example.com')
False
>>> test_bans.is_banned('cris@example.com')
True
>>> sample_bans.is_banned('cris@example.com')
False
Regular expression bans
=======================
Entire email address patterns can be banned, both for a specific mailing list
and globally, just as specific addresses can be banned. Use this for example,
when an entire domain is a spam faucet. When using a pattern, the email
address must start with a caret (^).
>>> test_bans.ban('^.*@example.org')
Now, no one from example.org can subscribe to the test mailing list.
>>> test_bans.is_banned('elle@example.org')
True
>>> test_bans.is_banned('eperson@example.org')
True
example.com addresses are not banned.
>>> test_bans.is_banned('elle@example.com')
False
example.org addresses are not banned globally, nor for any other mailing
list.
>>> sample_bans.is_banned('elle@example.org')
False
>>> global_bans.is_banned('elle@example.org')
False
Of course, we can ban everyone from example.org globally too.
>>> global_bans.ban('^.*@example.org')
>>> sample_bans.is_banned('elle@example.org')
True
>>> global_bans.is_banned('elle@example.org')
True
We can remove the mailing list ban on the pattern, though the global ban will
still be in place.
>>> test_bans.unban('^.*@example.org')
>>> test_bans.is_banned('elle@example.org')
True
>>> sample_bans.is_banned('elle@example.org')
True
>>> global_bans.is_banned('elle@example.org')
True
But once the global ban is removed, everyone from example.org can subscribe to
the mailing lists.
>>> global_bans.unban('^.*@example.org')
>>> test_bans.is_banned('elle@example.org')
False
>>> sample_bans.is_banned('elle@example.org')
False
>>> global_bans.is_banned('elle@example.org')
False
Adding and removing bans
========================
It is not an error to add a ban more than once. These are just ignored.
>>> test_bans.ban('fred@example.com')
>>> test_bans.ban('fred@example.com')
>>> test_bans.is_banned('fred@example.com')
True
Nor is it an error to remove a ban more than once.
>>> test_bans.unban('fred@example.com')
>>> test_bans.unban('fred@example.com')
>>> test_bans.is_banned('fred@example.com')
False
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1672838243.9058194
mailman-3.3.10/src/mailman/app/docs/bounces.rst 0000644 0000000 0000000 00000010353 14355276144 016264 0 ustar 00 =======
Bounces
=======
An important feature of Mailman is automatic bounce processing.
Bounces, or message rejection
=============================
Mailman can bounce messages back to the original sender. This is essentially
equivalent to rejecting the message with notification. Mailing lists can
bounce a message with an optional error message.
>>> from mailman.app.lifecycle import create_list
>>> mlist = create_list('ant@example.com')
Any message can be bounced.
>>> from mailman.testing.helpers import (specialized_message_from_string
... as message_from_string)
>>> msg = message_from_string("""\
... To: ant@example.com
... From: aperson@example.com
... Subject: Something important
...
... I sometimes say something important.
... """)
Bounce a message by passing in the original message, and an optional error
message. The bounced message ends up in the virgin queue, awaiting sending
to the original message author.
>>> from mailman.app.bounces import bounce_message
>>> bounce_message(mlist, msg)
>>> from mailman.testing.helpers import get_queue_messages
>>> items = get_queue_messages('virgin')
>>> len(items)
1
>>> print(items[0].msg.as_string())
Subject: Something important
From: ant-owner@example.com
To: aperson@example.com
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="..."
Message-ID: ...
Date: ...
Precedence: bulk
--...
Content-Type: text/plain; charset="us-ascii"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
[No bounce details are available]
--...
Content-Type: message/rfc822
MIME-Version: 1.0
To: ant@example.com
From: aperson@example.com
Subject: Something important
I sometimes say something important.
--...--
An error message can be given when the message is bounced, and this will be
included in the payload of the ``text/plain`` part. The error message must be
passed in as an instance of a ``RejectMessage`` exception.
>>> from mailman.interfaces.pipeline import RejectMessage
>>> error = RejectMessage("This wasn't very important after all.")
>>> bounce_message(mlist, msg, error)
>>> items = get_queue_messages('virgin', expected_count=1)
>>> print(items[0].msg.as_string())
Subject: Something important
From: ant-owner@example.com
To: aperson@example.com
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="..."
Message-ID: ...
Date: ...
Precedence: bulk
--...
Content-Type: text/plain; charset="us-ascii"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
This wasn't very important after all.
--...
Content-Type: message/rfc822
MIME-Version: 1.0
To: ant@example.com
From: aperson@example.com
Subject: Something important
I sometimes say something important.
--...--
The ``RejectMessage`` exception can also include a set of reasons, which will
be interpolated into the message using the ``{reasons}`` placeholder.
>>> error = RejectMessage("""This message is rejected because:
...
... $reasons
... """, [
... 'I am not happy',
... 'You are not happy',
... 'We are not happy'])
>>> bounce_message(mlist, msg, error)
>>> items = get_queue_messages('virgin', expected_count=1)
>>> print(items[0].msg.as_string())
Subject: Something important
From: ant-owner@example.com
To: aperson@example.com
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="..."
Message-ID: ...
Date: ...
Precedence: bulk
--...
Content-Type: text/plain; charset="us-ascii"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
This message is rejected because:
I am not happy
You are not happy
We are not happy
--...
Content-Type: message/rfc822
MIME-Version: 1.0
To: ant@example.com
From: aperson@example.com
Subject: Something important
I sometimes say something important.
--...
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1672838243.9059863
mailman-3.3.10/src/mailman/app/docs/lifecycle.rst 0000644 0000000 0000000 00000006202 14355276144 016563 0 ustar 00 =================================
Application level list life cycle
=================================
The low-level way to create and delete a mailing list is to use the
``IListManager`` interface. This interface simply adds or removes the
appropriate database entries to record the list's creation.
There is a higher level interface for creating and deleting mailing lists
which performs additional tasks such as:
* validating the list's posting address (which also serves as the list's
fully qualified name);
* ensuring that the list's domain is registered;
* :ref:`applying a list style ` to the new list;
* creating and assigning list owners;
* notifying watchers of list creation;
* creating ancillary artifacts (such as the list's on-disk directory)
Creating a list with owners
===========================
You can also specify a list of owner email addresses. If these addresses are
not yet known, they will be registered, and new users will be linked to them.
::
>>> owners = [
... 'aperson@example.com',
... 'bperson@example.com',
... 'cperson@example.com',
... 'dperson@example.com',
... ]
>>> from mailman.app.lifecycle import create_list
>>> ant = create_list('ant@example.com', owners)
>>> from mailman.testing.documentation import dump_list
>>> dump_list(address.email for address in ant.owners.addresses)
aperson@example.com
bperson@example.com
cperson@example.com
dperson@example.com
None of the owner addresses are verified.
>>> any(address.verified_on is not None
... for address in ant.owners.addresses)
False
However, all addresses are linked to users.
>>> from mailman.interfaces.usermanager import IUserManager
>>> from zope.component import getUtility
>>> user_manager = getUtility(IUserManager)
>>> for address in owners:
... user = user_manager.get_user(address)
... print(int(user.user_id.int), list(user.addresses)[0])
1 aperson@example.com
2 bperson@example.com
3 cperson@example.com
4 dperson@example.com
If you create a mailing list with owner addresses that are already known to
the system, they won't be created again.
>>> bee = create_list('bee@example.com', owners)
>>> from operator import attrgetter
>>> for user in sorted(bee.owners.users, key=attrgetter('user_id')):
... print(int(user.user_id.int), list(user.addresses)[0])
1 aperson@example.com
2 bperson@example.com
3 cperson@example.com
4 dperson@example.com
Deleting a list
===============
Removing a mailing list deletes the list, all its subscribers, and any related
artifacts.
::
>>> from mailman.app.lifecycle import remove_list
>>> remove_list(bee)
>>> from mailman.interfaces.listmanager import IListManager
>>> print(getUtility(IListManager).get('bee@example.com'))
None
We should now be able to completely recreate the mailing list.
>>> buzz = create_list('bee@example.com', owners)
>>> dump_list(address.email for address in bee.owners.addresses)
aperson@example.com
bperson@example.com
cperson@example.com
dperson@example.com
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1672838243.9061043
mailman-3.3.10/src/mailman/app/docs/message.rst 0000644 0000000 0000000 00000005166 14355276144 016260 0 ustar 00 ========
Messages
========
Mailman has its own `Message` classes, derived from the standard
``email.message.Message`` class, but providing additional useful methods.
User notifications
==================
When Mailman needs to send a message to a user, it creates a
``UserNotification`` instance, and then calls the ``.send()`` method on this
object. This method requires a mailing list instance.
>>> from mailman.app.lifecycle import create_list
>>> mlist = create_list('test@example.com')
The ``UserNotification`` constructor takes the recipient address, the sender
address, an optional subject, optional body text, and optional language.
>>> from mailman.email.message import UserNotification
>>> msg = UserNotification(
... 'aperson@example.com',
... 'test@example.com',
... 'Something you need to know',
... 'I needed to tell you this.')
>>> msg.send(mlist)
The message will end up in the `virgin` queue.
>>> from mailman.testing.helpers import get_queue_messages
>>> messages = get_queue_messages('virgin')
>>> len(messages)
1
>>> print(messages[0].msg.as_string())
MIME-Version: 1.0
Content-Type: text/plain; charset="us-ascii"
Content-Transfer-Encoding: 7bit
Subject: Something you need to know
From: test@example.com
To: aperson@example.com
Message-ID: ...
Date: ...
Precedence: bulk
I needed to tell you this.
The message above got a `Precedence: bulk` header added by default. If the
message we're sending already has a `Precedence:` header, it shouldn't be
changed.
>>> del msg['precedence']
>>> msg['Precedence'] = 'list'
>>> msg.send(mlist)
Again, the message will end up in the `virgin` queue but with the original
`Precedence:` header.
>>> messages = get_queue_messages('virgin')
>>> len(messages)
1
>>> print(messages[0].msg['precedence'])
list
Sometimes we want to send the message without a `Precedence:` header such as
when we send a probe message.
>>> del msg['precedence']
>>> msg.send(mlist, add_precedence=False)
Again, the message will end up in the `virgin` queue but without the
`Precedence:` header.
>>> messages = get_queue_messages('virgin')
>>> len(messages)
1
>>> print(messages[0].msg['precedence'])
None
However, if the message already has a `Precedence:` header, setting the
`precedence=False` argument will have no effect.
>>> msg['Precedence'] = 'junk'
>>> msg.send(mlist, add_precedence=False)
>>> messages = get_queue_messages('virgin')
>>> len(messages)
1
>>> print(messages[0].msg['precedence'])
junk
././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1672838243.906243
mailman-3.3.10/src/mailman/app/docs/moderator.rst 0000644 0000000 0000000 00000033356 14355276144 016632 0 ustar 00 .. _app-moderator:
============================
Application level moderation
============================
At an application level, moderation involves holding messages and membership
changes for moderator approval. This utilizes the :ref:`lower level interface
` for list-centric moderation requests.
Moderation is always mailing list-centric.
>>> from mailman.app.lifecycle import create_list
>>> mlist = create_list('ant@example.com')
>>> mlist.preferred_language = 'en'
>>> mlist.display_name = 'A Test List'
>>> mlist.admin_immed_notify = False
We'll use the lower level API for diagnostic purposes.
>>> from mailman.interfaces.requests import IListRequests
>>> requests = IListRequests(mlist)
Message moderation
==================
Holding messages
----------------
Anne posts a message to the mailing list, but she is not a member of the list,
so the message is held for moderator approval.
>>> from mailman.testing.helpers import (specialized_message_from_string
... as message_from_string)
>>> msg = message_from_string("""\
... From: anne@example.org
... To: ant@example.com
... Subject: Something important
... Message-ID:
...
... Here's something important about our mailing list.
... """)
*Holding a message* means keeping a copy of it that a moderator must approve
before the message is posted to the mailing list. To hold the message, the
message, its metadata, and a reason for the hold must be provided. In this
case, we won't include any additional metadata.
>>> from mailman.app.moderator import hold_message
>>> hold_message(mlist, msg, {}, 'Needs approval')
1
We can also hold a message with some additional metadata.
::
>>> msg = message_from_string("""\
... From: bart@example.org
... To: ant@example.com
... Subject: Something important
... Message-ID:
...
... Here's something important about our mailing list.
... """)
>>> msgdata = dict(sender='anne@example.com', approved=True)
>>> hold_message(mlist, msg, msgdata, 'Feeling ornery')
2
Disposing of messages
---------------------
The moderator can select one of several dispositions:
* discard - throw the message away.
* reject - bounces the message back to the original author.
* defer - defer any action on the message (continue to hold it)
* accept - accept the message for posting.
The most trivial is to simply defer a decision for now.
>>> from mailman.interfaces.action import Action
>>> from mailman.app.moderator import handle_message
>>> handle_message(mlist, 1, Action.defer)
This leaves the message in the requests database.
>>> key, data = requests.get_request(1)
>>> print(key)
The moderator can also discard the message.
>>> handle_message(mlist, 1, Action.discard)
>>> print(requests.get_request(1))
None
The message can be rejected, which bounces the message back to the original
sender.
>>> handle_message(mlist, 2, Action.reject, 'Off topic')
The message is no longer available in the requests database.
>>> print(requests.get_request(2))
None
And there is one message in the *virgin* queue - the rejection notice.
>>> from mailman.testing.helpers import get_queue_messages
>>> messages = get_queue_messages('virgin')
>>> len(messages)
1
>>> print(messages[0].msg.as_string())
MIME-Version: 1.0
...
Subject: Request to mailing list "A Test List" rejected
From: ant-bounces@example.com
To: bart@example.org
...
Your request to the ant@example.com mailing list
Posting of your message titled "Something important"
has been rejected by the list moderator. The moderator gave the
following reason for rejecting your request:
"Off topic"
Any questions or comments should be directed to the list administrator
at:
ant-owner@example.com
The bounce gets sent to the original sender.
>>> for recipient in sorted(messages[0].msgdata['recipients']):
... print(recipient)
bart@example.org
Or the message can be approved.
>>> msg = message_from_string("""\
... From: cris@example.org
... To: ant@example.com
... Subject: Something important
... Message-ID:
...
... Here's something important about our mailing list.
... """)
>>> id = hold_message(mlist, msg, {}, 'Needs approval')
>>> handle_message(mlist, id, Action.accept)
This places the message back into the incoming queue for further processing,
however the message metadata indicates that the message has been approved.
::
>>> messages = get_queue_messages('pipeline')
>>> len(messages)
1
>>> print(messages[0].msg.as_string())
From: cris@example.org
To: ant@example.com
Subject: Something important
...
>>> from mailman.testing.documentation import dump_msgdata
>>> dump_msgdata(messages[0].msgdata)
_parsemsg : False
approved : True
moderator_approved: True
type : data
version : 3
Forwarding the message
----------------------
The message can be forwarded to another address. This is helpful for getting
the message into the inbox of one of the moderators.
::
>>> msg = message_from_string("""\
... From: elly@example.org
... To: ant@example.com
... Subject: Something important
... Message-ID:
...
... Here's something important about our mailing list.
... """)
>>> req_id = hold_message(mlist, msg, {}, 'Needs approval')
>>> handle_message(mlist, req_id, Action.discard,
... forward=['zack@example.com'])
The forwarded message is in the virgin queue, destined for the moderator.
::
>>> messages = get_queue_messages('virgin')
>>> len(messages)
1
>>> print(messages[0].msg.as_string())
Subject: Forward of moderated message
From: ant-bounces@example.com
To: zack@example.com
...
>>> for recipient in sorted(messages[0].msgdata['recipients']):
... print(recipient)
zack@example.com
Holding unsubscription requests
===============================
Some lists require moderator approval for unsubscriptions. In this case, only
the unsubscribing address is required.
Fred is a member of the mailing list...
>>> from mailman.interfaces.usermanager import IUserManager
>>> from zope.component import getUtility
>>> mlist.send_welcome_message = False
>>> fred = getUtility(IUserManager).create_address(
... 'fred@example.com', 'Fred Person')
>>> from mailman.interfaces.subscriptions import ISubscriptionManager
>>> registrar = ISubscriptionManager(mlist)
>>> token, token_owner, member = registrar.register(
... fred, pre_verified=True, pre_confirmed=True, pre_approved=True)
>>> member
on ant@example.com
as MemberRole.member>
...but now that he wants to leave the mailing list, his request must be
approved.
>>> from mailman.app.moderator import hold_unsubscription
>>> req_id = hold_unsubscription(mlist, 'fred@example.com')
As with subscription requests, the unsubscription request can be deferred.
>>> from mailman.app.moderator import handle_unsubscription
>>> handle_unsubscription(mlist, req_id, Action.defer)
>>> print(mlist.members.get_member('fred@example.com').address)
Fred Person
The held unsubscription can also be discarded, and the member will remain
subscribed.
>>> handle_unsubscription(mlist, req_id, Action.discard)
>>> print(mlist.members.get_member('fred@example.com').address)
Fred Person
The request can be rejected, in which case a message is sent to the member,
and the person remains a member of the mailing list.
>>> req_id = hold_unsubscription(mlist, 'fred@example.com')
>>> handle_unsubscription(mlist, req_id, Action.reject, 'No can do')
>>> print(mlist.members.get_member('fred@example.com').address)
Fred Person
Fred gets a rejection notice.
::
>>> messages = get_queue_messages('virgin')
>>> len(messages)
1
>>> print(messages[0].msg.as_string())
MIME-Version: 1.0
...
Subject: Request to mailing list "A Test List" rejected
From: ant-bounces@example.com
To: fred@example.com
...
Your request to the ant@example.com mailing list
Unsubscription request
has been rejected by the list moderator. The moderator gave the
following reason for rejecting your request:
"No can do"
...
The unsubscription request can also be accepted. This removes the member from
the mailing list.
>>> req_id = hold_unsubscription(mlist, 'fred@example.com')
>>> mlist.send_goodbye_message = False
>>> handle_unsubscription(mlist, req_id, Action.accept)
>>> print(mlist.members.get_member('fred@example.com'))
None
Notifications
=============
Membership change requests
--------------------------
Usually, the list administrators want to be notified when there are membership
change requests they need to moderate. These notifications are sent when the
list is configured to send them.
>>> from mailman.interfaces.mailinglist import SubscriptionPolicy
>>> mlist.admin_immed_notify = True
>>> mlist.subscription_policy = SubscriptionPolicy.moderate
Gwen tries to subscribe to the mailing list.
>>> gwen = getUtility(IUserManager).create_address(
... 'gwen@example.com', 'Gwen Person')
>>> token, token_owner, member = registrar.register(
... gwen, pre_verified=True, pre_confirmed=True)
Her subscription must be approved by the list administrator, so she is not yet
a member of the mailing list.
>>> print(member)
None
>>> print(mlist.members.get_member('gwen@example.com'))
None
There's now a message in the virgin queue, destined for the list owner.
>>> messages = get_queue_messages('virgin')
>>> len(messages)
1
>>> print(messages[0].msg.as_string())
MIME-Version: 1.0
...
Subject: New subscription request to A Test List from gwen@example.com
From: ant-owner@example.com
To: ant-owner@example.com
...
Your authorization is required for a mailing list subscription request
approval:
For: Gwen Person
List: ant@example.com
Similarly, the administrator gets notifications on unsubscription requests.
Jeff is a member of the mailing list, and chooses to unsubscribe.
>>> unsub_req_id = hold_unsubscription(mlist, 'jeff@example.org')
>>> messages = get_queue_messages('virgin')
>>> len(messages)
1
>>> print(messages[0].msg.as_string())
MIME-Version: 1.0
...
Subject: New unsubscription request from A Test List by jeff@example.org
From: ant-owner@example.com
To: ant-owner@example.com
...
Your authorization is required for a mailing list unsubscription
request approval:
For: jeff@example.org
List: ant@example.com
Membership changes
------------------
When a new member request is accepted, the mailing list administrators can
receive a membership change notice.
>>> mlist.admin_notify_mchanges = True
>>> mlist.admin_immed_notify = False
>>> token, token_owner, member = registrar.confirm(token)
>>> member
on ant@example.com
as MemberRole.member>
>>> messages = get_queue_messages('virgin')
>>> len(messages)
1
>>> print(messages[0].msg.as_string())
MIME-Version: 1.0
...
Subject: A Test List subscription notification
From: noreply@example.com
To: ant-owner@example.com
...
Gwen Person has been successfully subscribed to A
Test List.
Similarly when an unsubscription request is accepted, the administrators can
get a notification.
>>> req_id = hold_unsubscription(mlist, 'gwen@example.com')
>>> handle_unsubscription(mlist, req_id, Action.accept)
>>> messages = get_queue_messages('virgin')
>>> len(messages)
1
>>> print(messages[0].msg.as_string())
MIME-Version: 1.0
...
Subject: A Test List unsubscription notification
From: noreply@example.com
To: ant-owner@example.com
...
Gwen Person has been removed from A Test List.
Welcome messages
----------------
When a member is subscribed to the mailing list, they can get a welcome
message.
>>> mlist.admin_notify_mchanges = False
>>> mlist.send_welcome_message = True
>>> herb = getUtility(IUserManager).create_address(
... 'herb@example.com', 'Herb Person')
>>> token, token_owner, member = registrar.register(
... herb, pre_verified=True, pre_confirmed=True, pre_approved=True)
>>> messages = get_queue_messages('virgin')
>>> len(messages)
1
>>> print(messages[0].msg.as_string())
MIME-Version: 1.0
...
Subject: Welcome to the "A Test List" mailing list
From: ant-request@example.com
To: Herb Person
...
Welcome to the "A Test List" mailing list!
...
Goodbye messages
----------------
Similarly, when the member's unsubscription request is approved, she'll get a
goodbye message.
>>> mlist.send_goodbye_message = True
>>> req_id = hold_unsubscription(mlist, 'herb@example.com')
>>> handle_unsubscription(mlist, req_id, Action.accept)
>>> messages = get_queue_messages('virgin')
>>> len(messages)
1
>>> print(messages[0].msg.as_string())
MIME-Version: 1.0
...
Subject: You have been unsubscribed from the A Test List mailing list
From: ant-bounces@example.com
To: herb@example.com
...
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1672838243.9063656
mailman-3.3.10/src/mailman/app/docs/pipelines.rst 0000644 0000000 0000000 00000011646 14355276144 016624 0 ustar 00 =========
Pipelines
=========
Pipelines process messages that have been accepted for posting, applying any
modifications and also sending copies of the message to the archives, digests,
NNTP, and outgoing queues. Pipelines are named and consist of a sequence of
handlers, each of which is applied in turn. Unlike rules and chains, there is
no way to stop a pipeline from processing the message once it's started.
>>> from mailman.app.lifecycle import create_list
>>> mlist = create_list('test@example.com')
>>> print(mlist.posting_pipeline)
default-posting-pipeline
>>> from mailman.core.pipelines import process
For the purposes of these examples, we'll enable just one archiver.
>>> from mailman.interfaces.mailinglist import IListArchiverSet
>>> for archiver in IListArchiverSet(mlist).archivers:
... archiver.is_enabled = (archiver.name == 'mhonarc')
Processing a message
====================
Messages hit the pipeline after they've been accepted for posting.
>>> from mailman.testing.helpers import (specialized_message_from_string
... as message_from_string)
>>> msg = message_from_string("""\
... From: aperson@example.com
... To: test@example.com
... Subject: My first post
... Message-ID:
... X-Message-ID-Hash: 4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB
...
... First post!
... """)
>>> msgdata = {}
>>> process(mlist, msg, msgdata, mlist.posting_pipeline)
The message has been modified with additional headers (footer decorations
come later during delivery).
>>> print(msg.as_string())
From: aperson@example.com
To: test@example.com
Message-ID:
X-Message-ID-Hash: 4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB
X-Mailman-Version: ...
Precedence: list
Subject: [Test] My first post
List-Id:
Archived-At:
List-Archive:
List-Help:
List-Owner:
List-Post:
List-Subscribe:
List-Unsubscribe:
First post!
The message metadata has information about recipients and other stuff.
However there are currently no recipients for this message.
>>> from mailman.testing.documentation import dump_msgdata
>>> dump_msgdata(msgdata)
original_sender : aperson@example.com
original_subject: My first post
recipients : set()
stripped_subject: My first post
After pipeline processing, the message is now sitting in various other
processing queues.
::
>>> from mailman.testing.helpers import get_queue_messages
>>> messages = get_queue_messages('archive')
>>> len(messages)
1
>>> print(messages[0].msg.as_string())
From: aperson@example.com
To: test@example.com
Message-ID:
X-Message-ID-Hash: 4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB
X-Mailman-Version: ...
Precedence: list
Subject: [Test] My first post
List-Id:
...
First post!
>>> dump_msgdata(messages[0].msgdata)
_parsemsg : False
original_sender : aperson@example.com
original_subject: My first post
recipients : set()
stripped_subject: My first post
version : 3
This mailing list is not linked to an NNTP newsgroup, so there's nothing in
the outgoing nntp queue.
>>> messages = get_queue_messages('nntp')
>>> len(messages)
0
The outgoing queue will hold the copy of the message that will actually get
delivered to end recipients.
::
>>> messages = get_queue_messages('out')
>>> len(messages)
1
>>> print(messages[0].msg.as_string())
From: aperson@example.com
To: test@example.com
Message-ID:
X-Message-ID-Hash: 4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB
X-Mailman-Version: ...
Precedence: list
Subject: [Test] My first post
List-Id:
...
First post!
>>> dump_msgdata(messages[0].msgdata)
_parsemsg : False
listid : test.example.com
original_sender : aperson@example.com
original_subject: My first post
recipients : set()
stripped_subject: My first post
version : 3
There's now one message in the digest mailbox, getting ready to be sent.
::
>>> from mailman.testing.helpers import digest_mbox
>>> digest = digest_mbox(mlist)
>>> sum(1 for mboxmsg in digest)
1
>>> print(list(digest)[0].as_string())
From: aperson@example.com
To: test@example.com
Message-ID:
X-Message-ID-Hash: 4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB
X-Mailman-Version: ...
Precedence: list
Subject: [Test] My first post
List-Id:
...
First post!
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1672813222.7177505
mailman-3.3.10/src/mailman/app/docs/system.rst 0000644 0000000 0000000 00000001360 14355215247 016145 0 ustar 00 ===============
System versions
===============
Mailman system information is available through the ``system`` object, which
implements the ``ISystem`` interface.
::
>>> from mailman.interfaces.system import ISystem
>>> from mailman.core.system import system
>>> from zope.interface.verify import verifyObject
>>> verifyObject(ISystem, system)
True
The Mailman version is also available via the ``system`` object.
>>> print(system.mailman_version)
GNU Mailman ...
The Python version running underneath is also available via the ``system``
object.
::
# The entire python_version string is variable, so this is the best test
# we can do.
>>> import sys
>>> system.python_version == sys.version
True
././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1703670049.508501
mailman-3.3.10/src/mailman/app/domain.py 0000644 0000000 0000000 00000002405 14542770442 014762 0 ustar 00 # Copyright (C) 2011-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Application level domain support."""
from mailman.interfaces.domain import DomainDeletingEvent
from mailman.interfaces.listmanager import IListManager
from public import public
from zope.component import getUtility
@public
def handle_DomainDeletingEvent(event):
"""Delete all mailing lists in a domain when the domain is deleted."""
if not isinstance(event, DomainDeletingEvent):
return
list_manager = getUtility(IListManager)
for mailing_list in event.domain.mailing_lists:
list_manager.delete(mailing_list)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5086827
mailman-3.3.10/src/mailman/app/events.py 0000644 0000000 0000000 00000003451 14542770442 015021 0 ustar 00 # Copyright (C) 2011-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Global events."""
from mailman.app import domain, membership, moderator, subscriptions
from mailman.core import i18n, switchboard
from mailman.languages import manager as language_manager
from mailman.styles import manager as style_manager
from mailman.utilities import passwords
from public import public
from zope import event
@public
def initialize():
"""Initialize global event subscribers."""
event.subscribers.extend([
domain.handle_DomainDeletingEvent,
i18n.handle_ConfigurationUpdatedEvent,
language_manager.handle_ConfigurationUpdatedEvent,
membership.handle_SubscriptionEvent,
moderator.handle_ListDeletingEvent,
passwords.handle_ConfigurationUpdatedEvent,
style_manager.handle_ConfigurationUpdatedEvent,
subscriptions.handle_ListDeletingEvent,
subscriptions.handle_SubscriptionConfirmationNeededEvent,
subscriptions.handle_SubscriptionInvitationNeededEvent,
subscriptions.handle_UnsubscriptionConfirmationNeededEvent,
switchboard.handle_ConfigurationUpdatedEvent,
])
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1726290746.6778636
mailman-3.3.10/src/mailman/app/inject.py 0000644 0000000 0000000 00000007314 14671215473 014774 0 ustar 00 # Copyright (C) 2001-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Inject a message into a queue."""
from email import message_from_bytes
from email.utils import formatdate, make_msgid
from mailman.config import config
from mailman.email.message import Message
from mailman.utilities.email import add_message_hash
from public import public
@public
def inject_message(mlist, msg, recipients=None, switchboard=None, **kws):
"""Inject a message into a queue.
If the message does not have a Message-ID header, one is added. An
Message-ID-Hash header is also always added.
:param mlist: The mailing list this message is destined for.
:type mlist: IMailingList
:param msg: The Message object to inject.
:type msg: a Message object
:param recipients: Optional set of recipients to put into the message's
metadata.
:type recipients: sequence of strings
:param switchboard: Optional name of switchboard to inject this message
into. If not given, the 'in' switchboard is used.
:type switchboard: string
:param kws: Additional values for the message metadata.
:type kws: dictionary
:return: filebase of enqueued message
:rtype: string
"""
if switchboard is None:
switchboard = 'in'
# Since we're crafting the message from whole cloth, let's make sure this
# message has a Message-ID.
if 'message-id' not in msg:
msg['Message-ID'] = make_msgid()
add_message_hash(msg)
# Ditto for Date: as required by RFC 2822.
if 'date' not in msg:
msg['Date'] = formatdate(localtime=True)
msg.original_size = len(msg.as_string())
msgdata = dict(
listid=mlist.list_id,
original_size=msg.original_size,
)
msgdata.update(kws)
if recipients is not None:
msgdata['recipients'] = recipients
return config.switchboards[switchboard].enqueue(msg, **msgdata)
@public
def inject_text(mlist, text, recipients=None, switchboard=None, **kws):
"""Turn text into a message and inject that into a queue.
If the text does not have a Message-ID header, one is added. A
Message-ID-Hash header is also always added.
:param mlist: The mailing list this message is destined for.
:type mlist: IMailingList
:param text: The text of the message to inject. This will be parsed into
a Message object.
:type text: byte string
:param recipients: Optional set of recipients to put into the message's
metadata.
:type recipients: sequence of strings
:param switchboard: Optional name of switchboard to inject this message
into. If not given, the 'in' switchboard is used.
:type switchboard: string
:param kws: Additional values for the message metadata.
:type kws: dictionary
:return: filebase of enqueued message
:rtype: string
"""
if isinstance(text, str):
text = text.encode('utf-8')
assert isinstance(text, bytes), 'Bad text input to inject_text'
message = message_from_bytes(text, Message)
return inject_message(mlist, message, recipients, switchboard, **kws)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5089421
mailman-3.3.10/src/mailman/app/lifecycle.py 0000644 0000000 0000000 00000012017 14542770442 015452 0 ustar 00 # Copyright (C) 2007-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Application level list creation."""
import re
import shutil
import logging
from contextlib import suppress
from mailman.config import config
from mailman.interfaces.address import IEmailValidator
from mailman.interfaces.domain import (
BadDomainSpecificationError,
IDomainManager,
)
from mailman.interfaces.listmanager import IListManager
from mailman.interfaces.mailinglist import InvalidListNameError
from mailman.interfaces.member import MemberRole
from mailman.interfaces.styles import IStyleManager
from mailman.interfaces.usermanager import IUserManager
from mailman.utilities.modules import call_name
from public import public
from zope.component import getUtility
log = logging.getLogger('mailman.error')
# These are the only characters allowed in list names. A more restrictive
# class can be specified in config.mailman.listname_chars.
_listname_chars = re.compile('[-_.+=!$*{}~0-9a-z]', re.IGNORECASE)
@public
def create_list(fqdn_listname, owners=None, style_name=None):
"""Create the named list and apply styles.
The mailing may not exist yet, but the domain specified in `fqdn_listname`
must exist.
:param fqdn_listname: The fully qualified name for the new mailing list.
:type fqdn_listname: string
:param owners: The mailing list owners.
:type owners: list of string email addresses
:param style_name: The name of the style to apply to the newly created
list. If not given, the default is taken from the configuration file.
:type style_name: string
:return: The new mailing list.
:rtype: `IMailingList`
:raises BadDomainSpecificationError: when the hostname part of
`fqdn_listname` does not exist.
:raises ListAlreadyExistsError: when the mailing list already exists.
:raises InvalidEmailAddressError: when the fqdn email address is invalid.
:raises InvalidListNameError: when the fqdn email address is valid but the
listname contains disallowed characters.
"""
if owners is None:
owners = []
# This raises InvalidEmailAddressError if the address is not a valid
# posting address. Let these percolate up.
getUtility(IEmailValidator).validate(fqdn_listname)
listname, domain = fqdn_listname.split('@', 1)
# We need to be fussier than just validating the posting address. Various
# legal local-part characters will cause problems in list names.
# First we check our maximally allowed set.
if len(_listname_chars.sub('', listname)) > 0:
raise InvalidListNameError(listname)
# Then if another set is configured, check that.
if config.mailman.listname_chars:
try:
cre = re.compile(config.mailman.listname_chars, re.IGNORECASE)
except re.error as error:
log.error(
'Bad config.mailman.listname_chars setting: %s: %s',
config.mailman.listname_chars,
getattr(error, 'msg', str(error))
)
else:
if len(cre.sub('', listname)) > 0:
raise InvalidListNameError(listname)
if domain not in getUtility(IDomainManager):
raise BadDomainSpecificationError(domain)
mlist = getUtility(IListManager).create(fqdn_listname)
style = getUtility(IStyleManager).get(
config.styles.default if style_name is None else style_name)
if style is not None:
style.apply(mlist)
# Coordinate with the MTA, as defined in the configuration file.
call_name(config.mta.incoming).create(mlist)
# Create any owners that don't yet exist, and subscribe all addresses as
# owners of the mailing list.
user_manager = getUtility(IUserManager)
for owner_address in owners:
address = user_manager.get_address(owner_address)
if address is None:
user = user_manager.create_user(owner_address)
address = list(user.addresses)[0]
mlist.subscribe(address, MemberRole.owner)
return mlist
@public
def remove_list(mlist):
"""Remove the list and all associated artifacts and subscriptions."""
# Remove the list's data directory, if it exists.
with suppress(FileNotFoundError):
shutil.rmtree(mlist.data_path)
# Delete the mailing list from the database.
getUtility(IListManager).delete(mlist)
# Do the MTA-specific list deletion tasks
call_name(config.mta.incoming).delete(mlist)
././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1703670049.509101
mailman-3.3.10/src/mailman/app/membership.py 0000644 0000000 0000000 00000017057 14542770442 015657 0 ustar 00 # Copyright (C) 2007-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Application support for membership management."""
from email.utils import formataddr
from mailman.app.notifications import (
send_admin_subscription_notice,
send_goodbye_message,
send_welcome_message,
)
from mailman.core.i18n import _
from mailman.email.message import OwnerNotification
from mailman.interfaces.address import IAddress, InvalidEmailAddressError
from mailman.interfaces.bans import IBanManager
from mailman.interfaces.member import (
AlreadySubscribedError,
MemberRole,
MembershipIsBannedError,
NotAMemberError,
SubscriptionEvent,
)
from mailman.interfaces.template import ITemplateLoader
from mailman.interfaces.user import IUser
from mailman.interfaces.usermanager import IUserManager
from mailman.utilities.string import expand
from public import public
from zope.component import getUtility
@public
def add_member(mlist, record, role=MemberRole.member):
"""Add a member right now.
The member's subscription must be approved by whatever policy the list
enforces.
:param mlist: The mailing list to add the member to.
:type mlist: `IMailingList`
:param record: a subscription request record.
:type record: RequestRecord
:param role: The membership role for this subscription.
:type role: `MemberRole`
:return: The just created member.
:rtype: `IMember`
:raises AlreadySubscribedError: if the user is already subscribed to
the mailing list.
:raises InvalidEmailAddressError: if the email address is not valid or
is the list posting address.
:raises MembershipIsBannedError: if the membership is not allowed.
"""
# Check to see if the email address is banned.
if IBanManager(mlist).is_banned(record.email):
raise MembershipIsBannedError(mlist, record.email)
# Check for list posting address, but allow for nonmember.
if (record.email.lower() == mlist.posting_address and
role != MemberRole.nonmember):
raise InvalidEmailAddressError('List posting address not allowed')
# Make sure there is a user linked with the given address.
user_manager = getUtility(IUserManager)
user = user_manager.make_user(record.email, record.display_name)
user.preferences.preferred_language = record.language
# Subscribe the address, not the user.
# We're looking for two versions of the email address, the case
# preserved version and the case insensitive version. We'll
# subscribe the version with matching case if it exists, otherwise
# we'll use one of the matching case-insensitively ones. It's
# undefined which one we pick.
case_preserved = None
case_insensitive = None
for address in user.addresses:
if address.original_email == record.email:
case_preserved = address
if address.email == record.email.lower(): # pragma: no branch
case_insensitive = address
assert case_preserved is not None or case_insensitive is not None, (
'Could not find a linked address for: {}'.format(record.email))
address = (case_preserved if case_preserved is not None
else case_insensitive)
# Create the member and set the appropriate preferences. It's
# possible we're subscribing the lower cased version of the address;
# if that's already subscribed re-issue the exception with the correct
# email address (i.e. the one passed in here).
try:
member = mlist.subscribe(address, role)
except AlreadySubscribedError as error:
raise AlreadySubscribedError(
error.fqdn_listname, record.email, error.role)
member.preferences.preferred_language = record.language
member.preferences.delivery_mode = record.delivery_mode
member.preferences.delivery_status = record.delivery_status
# Check for and remove nonmember subscriptions of the user to this list.
if role is MemberRole.member:
for address in user.addresses:
nonmember = mlist.nonmembers.get_member(address.email)
if nonmember is not None:
nonmember.unsubscribe()
return member
@public
def delete_member(mlist, email, admin_notif=None, userack=None):
"""Delete a member right now.
:param mlist: The mailing list to remove the member from.
:type mlist: `IMailingList`
:param email: The email address to unsubscribe.
:type email: string
:param admin_notif: Whether the list administrator should be notified that
this member was deleted.
:type admin_notif: bool, or None to let the mailing list's
`admin_notify_mchange` attribute decide.
:raises NotAMemberError: if the address is not a member of the
mailing list.
"""
if userack is None:
userack = mlist.send_goodbye_message
if admin_notif is None:
admin_notif = mlist.admin_notify_mchanges
# Delete a member, for which we know the approval has been made.
member = mlist.members.get_member(email)
if member is None:
raise NotAMemberError(mlist, email)
language = member.preferred_language
member.unsubscribe()
# And send an acknowledgement to the user...
if userack:
send_goodbye_message(mlist, email, language)
# ...and to the administrator.
if admin_notif:
user = getUtility(IUserManager).get_user(email)
display_name = user.display_name
subject = _('${mlist.display_name} unsubscription notification')
text = expand(getUtility(ITemplateLoader).get(
'list:admin:notice:unsubscribe', mlist),
mlist, dict(
member=formataddr((display_name, email)),
))
msg = OwnerNotification(mlist, subject, text,
roster=mlist.administrators)
msg.send(mlist)
@public
def handle_SubscriptionEvent(event):
if not isinstance(event, SubscriptionEvent):
return
member = event.member
# Only send notifications if a member (as opposed to a moderator,
# non-member, or owner) is being subscribed.
if member.role is not MemberRole.member:
return
mlist = member.mailing_list
# Maybe send the list administrators a notification.
if mlist.admin_notify_mchanges:
subscriber = member.subscriber
if IAddress.providedBy(subscriber):
address = subscriber.email
display_name = subscriber.display_name
else:
assert IUser.providedBy(subscriber)
address = subscriber.preferred_address.email
display_name = subscriber.display_name
send_admin_subscription_notice(mlist, address, display_name)
# Maybe send a welcome message to the new member. The event's flag
# overrides the mailinglist's configuration, iff it is non-None.
if ((event.send_welcome_message is None and mlist.send_welcome_message)
or event.send_welcome_message):
send_welcome_message(mlist, member, member.preferred_language)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1726290746.6780522
mailman-3.3.10/src/mailman/app/moderator.py 0000644 0000000 0000000 00000032371 14671215473 015515 0 ustar 00 # Copyright (C) 2007-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Application support for moderators."""
import time
import logging
from dateutil.tz import tzlocal
from email.utils import formatdate, getaddresses, make_msgid
from mailman.app.membership import delete_member
from mailman.config import config
from mailman.core.i18n import _
from mailman.email.message import UserNotification
from mailman.interfaces.action import Action
from mailman.interfaces.listmanager import ListDeletingEvent
from mailman.interfaces.member import NotAMemberError
from mailman.interfaces.messages import IMessageStore
from mailman.interfaces.pending import IPendings
from mailman.interfaces.requests import IListRequests, RequestType
from mailman.interfaces.template import ITemplateLoader
from mailman.utilities.datetime import now
from mailman.utilities.string import expand, oneline, wrap
from public import public
from zope.component import getUtility
NL = '\n'
vlog = logging.getLogger('mailman.vette')
slog = logging.getLogger('mailman.subscribe')
@public
def hold_message(mlist, msg, msgdata=None, reason=None):
"""Hold a message for moderator approval.
The message is added to the mailing list's request database.
:param mlist: The mailing list to hold the message on.
:param msg: The message to hold.
:param msgdata: Optional message metadata to hold. If not given, a new
metadata dictionary is created and held with the message.
:param reason: Optional string reason why the message is being held. If
not given, the empty string is used.
:return: An id used to handle the held message later.
"""
if msgdata is None:
msgdata = {}
else:
# Make a copy of msgdata so that subsequent changes won't corrupt the
# request database. TBD: remove the `filebase' key since this will
# not be relevant when the message is resurrected.
msgdata = msgdata.copy()
if reason is None:
reason = ''
# Add the message to the message store. It is required to have a
# Message-ID header.
message_id = msg.get('message-id')
if message_id is None:
msg['Message-ID'] = message_id = make_msgid()
elif isinstance(message_id, bytes):
message_id = message_id.decode('ascii')
getUtility(IMessageStore).add(msg)
# Prepare the message metadata with some extra information needed only by
# the moderation interface.
msgdata['_mod_message_id'] = message_id
msgdata['_mod_listid'] = mlist.list_id
msgdata['_mod_sender'] = msg.sender
# The subject can sometimes be a Header instance. Stringify it.
msgdata['_mod_subject'] = str(msg.get('subject', _('(no subject)')))
msgdata['_mod_reason'] = reason
msgdata['_mod_hold_date'] = now().isoformat()
# Now hold this request. We'll use the message_id as the key.
requestsdb = IListRequests(mlist)
request_id = requestsdb.hold_request(
RequestType.held_message, message_id, msgdata)
return request_id
def _lost_message(mlist, subject, sender, message_id):
# Create a substitute for a message not found in the message store.
text = _("""\
The content of this message was lost. It was probably cross-posted to
multiple lists and previously handled on another list.
""")
msg = UserNotification(
mlist.posting_address, sender, subject, text, mlist.preferred_language)
del msg['message-id']
msg['Message-ID'] = message_id
return msg
@public
def handle_message(mlist, id, action, comment=None, forward=None):
message_store = getUtility(IMessageStore)
requestdb = IListRequests(mlist)
key, msgdata = requestdb.get_request(id)
# Handle the action.
rejection = None
message_id = msgdata['_mod_message_id']
sender = msgdata['_mod_sender']
# Decode the Subject: if necessary.
subject = oneline(msgdata['_mod_subject'], in_unicode=True)
keep = False
if action in (Action.defer, Action.hold):
# Nothing to do, but preserve the message for later.
keep = True
elif action is Action.discard:
rejection = 'Discarded'
elif action is Action.reject:
rejection = 'Refused'
member = mlist.members.get_member(sender)
if member:
language = member.preferred_language
else:
language = None
send_rejection(
mlist, _('Posting of your message titled "${subject}"'),
sender, comment or _('[No reason given]'), lang=language)
elif action is Action.accept:
# Start by getting the message from the message store.
msg = message_store.get_message_by_id(message_id)
if msg is None:
msg = _lost_message(mlist, subject, sender, message_id)
# Delete moderation-specific entries from the message metadata.
for key in list(msgdata):
if key.startswith('_mod_'):
del msgdata[key]
# Add some metadata to indicate this message has now been approved.
msgdata['approved'] = True
msgdata['moderator_approved'] = True
# Calculate a new filebase for the approved message, otherwise
# delivery errors will cause duplicates.
if 'filebase' in msgdata:
del msgdata['filebase']
# Queue the file for delivery. Trying to deliver the message directly
# here can lead to a huge delay in web turnaround. Log the moderation
# and add a header.
msg['X-Mailman-Approved-At'] = formatdate(
time.mktime(now(tz=tzlocal()).timetuple()), localtime=True)
vlog.info('held message approved, message-id: %s',
msg.get('message-id', 'n/a').strip())
# Stick the message back in the incoming queue for further
# processing.
config.switchboards['pipeline'].enqueue(msg, _metadata=msgdata)
else:
raise AssertionError('Unexpected action: {0}'.format(action))
# Forward the message.
if forward:
# Get a copy of the original message from the message store.
msg = message_store.get_message_by_id(message_id)
if msg is None:
msg = _lost_message(mlist, subject, sender, message_id)
# It's possible the forwarding address list is a comma separated list
# of display_name/address pairs.
addresses = [addr[1] for addr in getaddresses(forward)]
language = mlist.preferred_language
if len(addresses) == 1:
# If the address getting the forwarded message is a member of
# the list, we want the headers of the outer message to be
# encoded in their language. Otherwise it'll be the preferred
# language of the mailing list. This is better than sending a
# separate message per recipient.
member = mlist.members.get_member(addresses[0])
if member:
language = member.preferred_language
with _.using(language.code):
fmsg = UserNotification(
addresses, mlist.bounces_address,
_('Forward of moderated message'),
lang=language)
fmsg.set_type('message/rfc822')
fmsg.attach(msg)
fmsg.send(mlist)
# Delete the request and message if it's not being kept.
if not keep:
# There are two pended tokens. The request id has the moderator
# token, but we need to delete the user token too.
user_token = None
pendings = getUtility(IPendings)
# We call list() over the generator to determine if it will return any
# actual values or not with len().
all_held_pendings = list(
pendings.find(pend_type='held message', mlist=mlist))
if len(all_held_pendings) == 0:
# This is mostly meant to handle old held message pendings
# that don't have the list_id pendedkeyvalue, which makes it
# very expensive to find the right pending to delete.
all_held_pendings = pendings.find(pend_type='held message')
for token, data in all_held_pendings:
# This can return None if there is a concurrent deletion.
if data and data['id'] == id:
user_token = token
break
if user_token is not None:
pendings.confirm(user_token, expunge=True)
requestdb.delete_request(id)
# Only delete the message from the message store if there's no other
# request for it.
delete = True
for token, data in pendings.find(
pend_type='data', held_msgid=message_id):
if data and data.get('_mod_message_id') == message_id:
delete = False
break
if delete:
message_store.delete_message(message_id)
# Log the rejection
if rejection:
note = """%s: %s posting:
\tFrom: %s
\tSubject: %s"""
if comment:
note += '\n\tReason: ' + comment
vlog.info(note, mlist.fqdn_listname, rejection, sender, subject)
@public
def hold_unsubscription(mlist, email):
data = dict(email=email)
requestsdb = IListRequests(mlist)
request_id = requestsdb.hold_request(
RequestType.unsubscription, email, data)
vlog.info('%s: held unsubscription request from %s',
mlist.fqdn_listname, email)
# Possibly notify the administrator of the hold
if mlist.admin_immed_notify:
subject = _(
'New unsubscription request from ${mlist.display_name} by ${email}'
)
template = getUtility(ITemplateLoader).get(
'list:admin:action:unsubscribe', mlist)
text = wrap(expand(template, mlist, dict(
# For backward compatibility.
mailing_list=mlist,
member=email,
email=email,
)))
# This message should appear to come from the -owner so as
# to avoid any useless bounce processing.
msg = UserNotification(
mlist.owner_address, mlist.owner_address,
subject, text, mlist.preferred_language)
msg.send(mlist)
return request_id
@public
def handle_unsubscription(mlist, id, action, comment=None):
requestdb = IListRequests(mlist)
key, data = requestdb.get_request(id)
email = data['email']
if action is Action.defer:
# Nothing to do.
return
elif action is Action.discard:
# Nothing to do except delete the request from the database.
pass
elif action is Action.reject:
key, data = requestdb.get_request(id)
send_rejection(
mlist, _('Unsubscription request'), email,
comment or _('[No reason given]'))
elif action is Action.accept:
key, data = requestdb.get_request(id)
try:
delete_member(mlist, email)
except NotAMemberError:
# User has already been unsubscribed.
pass
slog.info('%s: deleted %s', mlist.fqdn_listname, email)
else:
raise AssertionError('Unexpected action: {}'.format(action))
# Delete the request from the database.
requestdb.delete_request(id)
@public
def send_rejection(mlist, request, recip, comment, origmsg=None, lang=None):
# As this message is going to the requester, try to set the language to
# his/her language choice, if they are a member. Otherwise use the list's
# preferred language.
display_name = mlist.display_name # noqa: F841
if lang is None:
member = mlist.members.get_member(recip)
lang = (mlist.preferred_language
if member is None
else member.preferred_language)
template = getUtility(ITemplateLoader).get(
'list:user:notice:refuse', mlist)
text = wrap(expand(template, mlist, dict(
language=lang.code,
reason=comment,
# For backward compatibility.
request=request,
adminaddr=mlist.owner_address,
)))
with _.using(lang.code):
# add in original message, but not wrap/filled
if origmsg:
text = NL.join(
[text,
'---------- ' + _('Original Message') + ' ----------',
str(origmsg)
])
subject = _('Request to mailing list "${display_name}" rejected')
msg = UserNotification(recip, mlist.bounces_address, subject, text, lang)
msg.send(mlist)
@public
def handle_ListDeletingEvent(event):
if not isinstance(event, ListDeletingEvent):
return
# Get the held requests database for the mailing list. Since the mailing
# list is about to get deleted, we can delete all associated requests.
requestsdb = IListRequests(event.mailing_list)
for request in requestsdb.held_requests:
requestsdb.delete_request(request.id)
././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1703670049.509623
mailman-3.3.10/src/mailman/app/notifications.py 0000644 0000000 0000000 00000022522 14542770442 016366 0 ustar 00 # Copyright (C) 2007-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Sending notifications."""
import logging
from email.mime.message import MIMEMessage
from email.mime.text import MIMEText
from email.utils import formataddr
from lazr.config import as_boolean
from mailman.config import config
from mailman.core.i18n import _
from mailman.email.message import OwnerNotification, UserNotification
from mailman.interfaces.member import DeliveryMode
from mailman.interfaces.messages import IMessageStore
from mailman.interfaces.template import ITemplateLoader
from mailman.utilities.string import expand, wrap
from public import public
from zope.component import getUtility
log = logging.getLogger('mailman.error')
def _get_dsn(message_id):
# Get the DSN from the message store. Don't delete it as it may still be
# needed.
messagestore = getUtility(IMessageStore)
return messagestore.get_message_by_id(message_id)
def _make_multipart(msg):
"""Turn an OwnerNotification into a multipart/mixed with a text/plain
payload. This is all a Kludge due to messages being email.Message objects
and not email.EmailMessage objects. If they were the latter we could just
use the message's add_content method in a straight forward way.
"""
text = MIMEText(msg.get_payload(decode=True).decode(
msg.get_content_charset('utf-8')))
del msg['content-type']
del msg['content-transfer-encoding']
msg['Content-Type'] = 'multipart/mixed'
msg.set_payload(None)
msg.attach(text)
return msg
@public
def send_welcome_message(mlist, member, language, text=''):
"""Send a welcome message to a subscriber.
Prepending to the standard welcome message template is the mailing list's
welcome message, if there is one.
:param mlist: The mailing list.
:type mlist: IMailingList
:param member: The member to send the welcome message to.
:param address: IMember
:param language: The language of the response.
:type language: ILanguage
"""
welcome_message = wrap(getUtility(ITemplateLoader).get(
'list:user:notice:welcome', mlist, language=language.code))
display_name = member.display_name
# Get the text from the template.
text = expand(welcome_message, mlist, dict(
user_name=display_name,
user_email=member.address.email,
# For backward compatibility.
user_address=member.address.email,
fqdn_listname=mlist.fqdn_listname,
list_name=mlist.display_name,
list_requests=mlist.request_address,
))
with _.using(language.code):
digmode = ('' # noqa: F841
if member.delivery_mode is DeliveryMode.regular
else _(' (Digest mode)'))
msg = UserNotification(
formataddr((display_name, member.address.email)),
mlist.request_address,
_('Welcome to the "${mlist.display_name}" mailing list${digmode}'),
text, language)
msg['X-No-Archive'] = 'yes'
msg.send(mlist, verp=as_boolean(config.mta.verp_personalized_deliveries))
@public
def send_goodbye_message(mlist, address, language):
"""Send a goodbye message to a subscriber.
Prepending to the standard goodbye message template is the mailing list's
goodbye message, if there is one.
:param mlist: the mailing list
:type mlist: IMailingList
:param address: The address to respond to
:type address: string
:param language: the language of the response
:type language: ILanguage
"""
goodbye_message = wrap(expand(getUtility(ITemplateLoader).get(
'list:user:notice:goodbye', mlist, language=language.code),
mlist, dict(
user_email=address,
# For backward compatibility.
user_address=address,
fqdn_listname=mlist.fqdn_listname,
list_name=mlist.display_name,
list_requests=mlist.request_address,
)))
with _.using(language.code):
msg = UserNotification(
address, mlist.bounces_address,
_('You have been unsubscribed from the ${mlist.display_name} '
'mailing list'),
goodbye_message, language)
msg.send(mlist, verp=as_boolean(config.mta.verp_personalized_deliveries))
@public
def send_admin_subscription_notice(mlist, address, display_name):
"""Send the list administrators a subscription notice.
:param mlist: The mailing list.
:type mlist: IMailingList
:param address: The address being subscribed.
:type address: string
:param display_name: The name of the subscriber.
:type display_name: string
"""
with _.using(mlist.preferred_language.code):
subject = _('${mlist.display_name} subscription notification')
text = expand(
getUtility(ITemplateLoader).get('list:admin:notice:subscribe', mlist),
mlist, dict(
member=formataddr((display_name, address)),
))
msg = OwnerNotification(mlist, subject, text, roster=mlist.administrators)
msg.send(mlist)
@public
def send_admin_disable_notice(mlist, event, display_name):
"""Send the list administrators a membership disabled by-bounce notice.
:param mlist: The mailing list
:type mlist: IMailingList
:param event: The BounceEvent that triggered this notice.
:type event: A mailman.model.bounce.BounceEvent instance.
:param display_name: The name of the subscriber
:type display_name: string
"""
member = formataddr((display_name, event.email))
data = {'member': member}
with _.using(mlist.preferred_language.code):
subject = _(
'${member}\'s subscription disabled on ${mlist.display_name}')
text = expand(
getUtility(ITemplateLoader).get('list:admin:notice:disable', mlist),
mlist, data)
msg = OwnerNotification(mlist, subject, text, roster=mlist.administrators)
dsn = _get_dsn(event.message_id)
if dsn:
msg = _make_multipart(msg)
att = MIMEMessage(dsn)
msg.attach(att)
msg.send(mlist)
@public
def send_admin_increment_notice(mlist, event, display_name):
"""Send the list administrators a bounce score incremented notice.
:param mlist: The mailing list
:type mlist: IMailingList
:param event: The BounceEvent that triggered this notice.
:type event: A mailman.model.bounce.BounceEvent instance.
:param display_name: The name of the subscriber
:type display_name: string
"""
member = formataddr((display_name, event.email))
data = {'member': member}
with _.using(mlist.preferred_language.code):
subject = _(
'${member}\'s bounce score incremented on ${mlist.display_name}')
text = expand(
getUtility(ITemplateLoader).get('list:admin:notice:increment', mlist),
mlist, data)
msg = OwnerNotification(mlist, subject, text, roster=mlist.administrators)
dsn = _get_dsn(event.message_id)
if dsn:
msg = _make_multipart(msg)
att = MIMEMessage(dsn)
msg.attach(att)
msg.send(mlist)
def send_admin_removal_notice(mlist, address, display_name):
"""Send the list administrators a membership removed due to bounce notice.
:param mlist: The mailing list.
:type mlist: IMailingList
:param address: The address of the member
:type address: string
:param display_name: The name of the subscriber
:type display_name: string
"""
member = formataddr((display_name, address))
data = {'member': member, 'mlist': mlist.display_name}
with _.using(mlist.preferred_language.code):
subject = _('${member} unsubscribed from ${mlist.display_name} '
'mailing list due to bounces')
text = expand(
getUtility(ITemplateLoader).get('list:admin:notice:removal', mlist),
mlist, data)
msg = OwnerNotification(mlist, subject, text, roster=mlist.administrators)
msg.send(mlist)
@public
def send_user_disable_warning(mlist, address, language):
"""Sends a warning mail to the user reminding the person to
reenable its DeliveryStatus.
:param mlist: The mailing list
:type mlist: IMailingList
:param address: The address of the member
:type address: string.
:param language: member's preferred language
:type language: ILanguage
"""
warning_message = wrap(getUtility(ITemplateLoader).get(
'list:user:notice:warning', mlist, language=language.code))
warning_message_text = expand(
warning_message, mlist, dict(sender_email=address))
msg = UserNotification(
address, mlist.bounces_address,
_('Your subscription for ${mlist.display_name} mailing list'
' has been disabled'),
warning_message_text, language)
msg.send(mlist, verp=as_boolean(config.mta.verp_personalized_deliveries))
././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1703670049.509794
mailman-3.3.10/src/mailman/app/replybot.py 0000644 0000000 0000000 00000004062 14542770442 015354 0 ustar 00 # Copyright (C) 2007-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Application level auto-reply code."""
from public import public
@public
def can_acknowledge(msg):
"""A boolean specifying whether this message can be acknowledged.
There are several reasons why a message should not be acknowledged, mostly
related to competing standards or common practices. These include:
* The message has a X-No-Ack header with any value
* The message has an X-Ack header with a 'no' value
* The message has a Precedence header
* The message has an Auto-Submitted header and that header does not have a
value of 'no'
* The message has an empty Return-Path header, e.g. <>
* The message has any RFC 2369 headers (i.e. List-* headers)
:param msg: a Message object.
:return: Boolean specifying whether the message can be acknowledged or not
(which is different from whether it will be acknowledged).
"""
# I wrote it this way for clarity and consistency with the docstring.
for header in msg.keys():
if header in ('x-no-ack', 'precedence'):
return False
if header.lower().startswith('list-'):
return False
if msg.get('x-ack', '').lower() == 'no':
return False
if msg.get('auto-submitted', 'no').lower() != 'no':
return False
if msg.get('return-path') == '<>':
return False
return True
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5099509
mailman-3.3.10/src/mailman/app/subscriptions.py 0000644 0000000 0000000 00000066673 14542770442 016443 0 ustar 00 # Copyright (C) 2009-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Handle subscriptions."""
import uuid
import logging
from enum import Enum
from lazr.config import as_boolean
from mailman.app.membership import delete_member
from mailman.app.workflow import Workflow
from mailman.config import config
from mailman.core.i18n import _
from mailman.database.transaction import flush
from mailman.email.message import UserNotification
from mailman.interfaces.address import IAddress, InvalidEmailAddressError
from mailman.interfaces.bans import IBanManager
from mailman.interfaces.listmanager import ListDeletingEvent
from mailman.interfaces.mailinglist import SubscriptionPolicy
from mailman.interfaces.member import (
AlreadySubscribedError,
DeliveryMode,
DeliveryStatus,
MemberRole,
MembershipIsBannedError,
NotAMemberError,
)
from mailman.interfaces.pending import IPendable, IPendings
from mailman.interfaces.subscriptions import (
ISubscriptionManager,
ISubscriptionService,
SubscriptionConfirmationNeededEvent,
SubscriptionInvitationNeededEvent,
SubscriptionPendingError,
TokenOwner,
UnsubscriptionConfirmationNeededEvent,
)
from mailman.interfaces.template import ITemplateLoader
from mailman.interfaces.user import IUser
from mailman.interfaces.usermanager import IUserManager
from mailman.interfaces.workflow import IWorkflowStateManager
from mailman.utilities.datetime import now
from mailman.utilities.string import expand, wrap
from public import public
from zope.component import getUtility
from zope.event import notify
from zope.interface import implementer
log = logging.getLogger('mailman.subscribe')
class WhichSubscriber(Enum):
address = 1
user = 2
@implementer(IPendable)
class PendableSubscription(dict):
PEND_TYPE = 'subscription'
@implementer(IPendable)
class PendableUnsubscription(dict):
PEND_TYPE = 'unsubscription'
class _SubscriptionWorkflowCommon(Workflow):
"""Common support between subscription and unsubscription."""
PENDABLE_CLASS = None
def __init__(self, mlist, subscriber):
super().__init__()
self.mlist = mlist
self.address = None
self.user = None
self.which = None
self.member = None
self._set_token(TokenOwner.no_one)
# The subscriber must be either an IUser or IAddress.
if IAddress.providedBy(subscriber):
self.address = subscriber
self.user = self.address.user
self.which = WhichSubscriber.address
elif IUser.providedBy(subscriber):
self.address = subscriber.preferred_address
self.user = subscriber
self.which = WhichSubscriber.user
self.subscriber = subscriber
@property
def user_key(self):
# For save.
return self.user.user_id.hex
@user_key.setter
def user_key(self, hex_key):
# For restore.
uid = uuid.UUID(hex_key)
self.user = getUtility(IUserManager).get_user_by_id(uid)
if self.user is None:
self.user = self.address.user
@property
def address_key(self):
# For save.
return self.address.email
@address_key.setter
def address_key(self, email):
# For restore.
self.address = getUtility(IUserManager).get_address(email)
assert self.address is not None
@property
def subscriber_key(self):
return self.which.value
@subscriber_key.setter
def subscriber_key(self, key):
self.which = WhichSubscriber(key)
@property
def token_owner_key(self):
return self.token_owner.value
@token_owner_key.setter
def token_owner_key(self, value):
self.token_owner = TokenOwner(value)
def _set_token(self, token_owner):
assert isinstance(token_owner, TokenOwner)
pendings = getUtility(IPendings)
# Clear out the previous pending token if there is one.
if self.token is not None:
pendings.confirm(self.token)
# Create a new token to prevent replay attacks. It seems like this
# would produce the same token, but it won't because the pending adds a
# bit of randomization.
self.token_owner = token_owner
if token_owner is TokenOwner.no_one:
self.token = None
return
pendable = self.PENDABLE_CLASS(
list_id=self.mlist.list_id,
email=self.address.email,
display_name=self.address.display_name,
when=now().replace(microsecond=0).isoformat(),
token_owner=token_owner.name,
)
# MAS pendings.add will set lifetime based on token_owner.
self.token = pendings.add(pendable)
@public
class SubscriptionWorkflow(_SubscriptionWorkflowCommon):
"""Workflow of a subscription request."""
PENDABLE_CLASS = PendableSubscription
INITIAL_STATE = 'sanity_checks'
SAVE_ATTRIBUTES = (
'pre_approved',
'pre_confirmed',
'pre_verified',
'invitation',
'address_key',
'subscriber_key',
'user_key',
'token_owner_key',
'send_welcome_message',
'delivery_mode',
'delivery_status',
)
def __init__(self, mlist, subscriber=None, *,
pre_verified=False, pre_confirmed=False, pre_approved=False,
invitation=False, send_welcome_message=None,
delivery_mode=None, delivery_status=None):
super().__init__(mlist, subscriber)
# An invitation is already supposed to be "pre_approved" and by
# confirming the invite a user will "confirm" and "verify" their
# subscription, hence these values are set to `True` if this is
# an invitation.
self.pre_verified = invitation or pre_verified
self.pre_confirmed = invitation or pre_confirmed
self.pre_approved = invitation or pre_approved
self.invitation = invitation
self.send_welcome_message = send_welcome_message
# For enum types, we use the string values here instead of the enum
# objects because they are easier to serialize when the workflow is
# saved in the database. When setting the values, we restore back to
# Enum types.
if delivery_status is not None:
self.delivery_status = delivery_status.name
else:
self.delivery_status = None
if delivery_mode is not None:
self.delivery_mode = delivery_mode.name
else:
self.delivery_mode = None
def _step_sanity_checks(self):
# Ensure that we have both an address and a user, even if the address
# is not verified. We can't set the preferred address until it is
# verified.
if self.user is None:
# The address has no linked user so create one, link it, and set
# the user's preferred address.
assert self.address is not None, 'No address or user'
self.user = getUtility(IUserManager).make_user(self.address.email)
if self.address is None:
assert self.user.preferred_address is None, (
"Preferred address exists, but wasn't used in constructor")
addresses = list(self.user.addresses)
if len(addresses) == 0:
raise AssertionError('User has no addresses: {}'.format(
self.user))
# This is rather arbitrary, but we have no choice.
self.address = addresses[0]
assert self.user is not None and self.address is not None, (
'Insane sanity check results')
# Is this subscriber already a member?
if ((self.user.preferred_address == self.address and
self.mlist.is_subscribed(self.user)) or
self.mlist.is_subscribed(self.address)):
raise AlreadySubscribedError(
self.mlist.fqdn_listname,
self.address.email,
MemberRole.member)
# Is this email address banned?
if IBanManager(self.mlist).is_banned(self.address.email):
raise MembershipIsBannedError(self.mlist, self.address.email)
# Don't allow the list posting address.
if self.address.email.lower() == self.mlist.posting_address:
raise InvalidEmailAddressError('List posting address not allowed')
# Check if there is already a subscription request for this email.
pendings = getUtility(IPendings).find(
mlist=self.mlist,
pend_type='subscription')
for token, pendable in pendings:
if pendable['email'] == self.address.email:
raise SubscriptionPendingError(self.mlist, self.address.email)
# Start out with the subscriber being the token owner.
self.push('verification_checks')
def _step_verification_checks(self):
# If this is an invitation, send it now.
if self.invitation:
self.push('send_invitation')
return
# Is the address already verified, or is the pre-verified flag set?
if self.address.verified_on is None:
if self.pre_verified:
self.address.verified_on = now()
else:
# The address being subscribed is not yet verified, so we need
# to send a validation email that will also confirm that the
# user wants to be subscribed to this mailing list.
self.push('send_confirmation')
return
self.push('confirmation_checks')
def _step_confirmation_checks(self):
# If the list's subscription policy is open, then the user can be
# subscribed right here and now.
if self.mlist.subscription_policy is SubscriptionPolicy.open:
self.push('do_subscription')
return
# If we do not need the user's confirmation, then skip to the
# moderation checks.
if self.mlist.subscription_policy is SubscriptionPolicy.moderate:
self.push('moderation_checks')
return
# If the subscription has been pre-confirmed, then we can skip the
# confirmation check can be skipped. If moderator approval is
# required we need to check that, otherwise we can go straight to
# subscription.
if self.pre_confirmed:
next_step = (
'moderation_checks'
if self.mlist.subscription_policy is
SubscriptionPolicy.confirm_then_moderate # noqa: E131
else 'do_subscription')
self.push(next_step)
return
# The user must confirm their subscription.
self.push('send_confirmation')
def _step_moderation_checks(self):
# Does the moderator need to approve the subscription request?
assert self.mlist.subscription_policy in (
SubscriptionPolicy.moderate,
SubscriptionPolicy.confirm_then_moderate,
), self.mlist.subscription_policy
if self.pre_approved:
self.push('do_subscription')
else:
self.push('get_moderator_approval')
def _step_get_moderator_approval(self):
# Here's the next step in the workflow, assuming the moderator
# approves of the subscription. If they don't, the workflow and
# subscription request will just be thrown away.
self._set_token(TokenOwner.moderator)
self.push('subscribe_from_restored')
self.save()
log.info('{}: held subscription request from {}'.format(
self.mlist.fqdn_listname, self.address.email))
# Possibly send a notification to the list moderators.
if self.mlist.admin_immed_notify:
subject = _(
'New subscription request to ${self.mlist.display_name} '
'from ${self.address.email}')
username =\
f'{self.subscriber.display_name} <{self.address.email}>'\
if self.subscriber.display_name else self.address.email
template = getUtility(ITemplateLoader).get(
'list:admin:action:subscribe', self.mlist)
text = wrap(expand(template, self.mlist, dict(
member=username,
)))
# This message should appear to come from the -owner so as
# to avoid any useless bounce processing.
msg = UserNotification(
self.mlist.owner_address, self.mlist.owner_address,
subject, text, self.mlist.preferred_language)
msg.send(self.mlist)
# The workflow must stop running here.
raise StopIteration
def _step_subscribe_from_restored(self):
# Prevent replay attacks.
self._set_token(TokenOwner.no_one)
# Restore a little extra state that can't be stored in the database
# (because the order of setattr() on restore is indeterminate), then
# subscribe the user.
if self.which is WhichSubscriber.address:
self.subscriber = self.address
else:
assert self.which is WhichSubscriber.user
self.subscriber = self.user
self.push('do_subscription')
def _step_do_subscription(self):
# We can immediately subscribe the user to the mailing list.
self.member = self.mlist.subscribe(
self.subscriber, send_welcome_message=self.send_welcome_message)
# Set member attributes.
if self.delivery_mode:
self.member.preferences.delivery_mode = DeliveryMode[
self.delivery_mode]
if self.delivery_status:
self.member.preferences.delivery_status = DeliveryStatus[
self.delivery_status]
assert self.token is None and self.token_owner is TokenOwner.no_one, (
'Unexpected active token at end of subscription workflow')
def _step_send_confirmation(self):
self._set_token(TokenOwner.subscriber)
self.push('do_confirm_verify')
self.save()
# Triggering this event causes the confirmation message to be sent.
notify(SubscriptionConfirmationNeededEvent(
self.mlist, self.token, self.address.email))
# Now we wait for the confirmation.
raise StopIteration
def _step_send_invitation(self):
self._set_token(TokenOwner.subscriber)
self.push('do_confirm_verify')
self.save()
# Triggering this event causes the confirmation message to be sent.
notify(SubscriptionInvitationNeededEvent(
self.mlist, self.token, self.address.email))
# Now we wait for the confirmation.
raise StopIteration
def _step_do_confirm_verify(self):
# Restore a little extra state that can't be stored in the database
# (because the order of setattr() on restore is indeterminate), then
# continue with the confirmation/verification step.
if self.which is WhichSubscriber.address:
self.subscriber = self.address
else:
assert self.which is WhichSubscriber.user
self.subscriber = self.user
# Reset the token so it can't be used in a replay attack.
self._set_token(TokenOwner.no_one)
# The user has confirmed their subscription request, and also verified
# their email address if necessary. This latter needs to be set on the
# IAddress, but there's nothing more to do about the confirmation step.
# We just continue along with the workflow.
if self.address.verified_on is None:
self.address.verified_on = now()
# The next step depends on the mailing list's subscription policy.
next_step = ('moderation_checks'
if self.mlist.subscription_policy in (
SubscriptionPolicy.moderate,
SubscriptionPolicy.confirm_then_moderate,
) and not self.invitation
else 'do_subscription')
self.push(next_step)
@public
class UnSubscriptionWorkflow(_SubscriptionWorkflowCommon):
"""Workflow of a unsubscription request."""
PENDABLE_CLASS = PendableUnsubscription
INITIAL_STATE = 'subscription_checks'
SAVE_ATTRIBUTES = (
'pre_approved',
'pre_confirmed',
'address_key',
'user_key',
'subscriber_key',
'token_owner_key',
)
def __init__(self, mlist, subscriber=None, *,
pre_approved=False, pre_confirmed=False):
super().__init__(mlist, subscriber)
if IAddress.providedBy(subscriber) or IUser.providedBy(subscriber):
self.member = self.mlist.regular_members.get_member(
self.address.email)
self.pre_confirmed = pre_confirmed
self.pre_approved = pre_approved
def _step_subscription_checks(self):
# XXX assert self.member is not None is sufficient, but ...
assert (self.member is not None and
self.mlist.is_subscribed(self.member.subscriber))
self.push('confirmation_checks')
def _step_confirmation_checks(self):
# If list's unsubscription policy is open, the user can unsubscribe
# right now.
if self.mlist.unsubscription_policy is SubscriptionPolicy.open:
self.push('do_unsubscription')
return
# If we don't need the user's confirmation, then skip to the moderation
# checks.
if self.mlist.unsubscription_policy is SubscriptionPolicy.moderate:
self.push('moderation_checks')
return
# If the request is pre-confirmed, then the user can unsubscribe right
# now.
if self.pre_confirmed:
self.push('do_unsubscription')
return
# The user must confirm their un-subsbcription.
self.push('send_confirmation')
def _step_send_confirmation(self):
self._set_token(TokenOwner.subscriber)
self.push('do_confirm_verify')
self.save()
notify(UnsubscriptionConfirmationNeededEvent(
self.mlist, self.token, self.address.email))
raise StopIteration
def _step_moderation_checks(self):
# Does the moderator need to approve the unsubscription request?
assert self.mlist.unsubscription_policy in (
SubscriptionPolicy.moderate,
SubscriptionPolicy.confirm_then_moderate,
), self.mlist.unsubscription_policy
if self.pre_approved:
self.push('do_unsubscription')
else:
self.push('get_moderator_approval')
def _step_get_moderator_approval(self):
self._set_token(TokenOwner.moderator)
self.push('unsubscribe_from_restored')
self.save()
log.info('{}: held unsubscription request from {}'.format(
self.mlist.fqdn_listname, self.address.email))
if self.mlist.admin_immed_notify:
subject = _(
'New unsubscription request to ${self.mlist.display_name} '
'from ${self.address.email}')
username =\
f'{self.subscriber.display_name} <{self.address.email}>'\
if self.subscriber.display_name else self.address.email
template = getUtility(ITemplateLoader).get(
'list:admin:action:unsubscribe', self.mlist)
text = wrap(expand(template, self.mlist, dict(
member=username,
)))
# This message should appear to come from the -owner so as
# to avoid any useless bounce processing.
msg = UserNotification(
self.mlist.owner_address, self.mlist.owner_address,
subject, text, self.mlist.preferred_language)
msg.send(self.mlist)
# The workflow must stop running here
raise StopIteration
def _step_do_confirm_verify(self):
# Restore a little extra state that can't be stored in the database
# (because the order of setattr() on restore is indeterminate), then
# continue with the confirmation/verification step.
if self.which is WhichSubscriber.address:
self.subscriber = self.address
else:
assert self.which is WhichSubscriber.user
self.subscriber = self.user
# Reset the token so it can't be used in a replay attack.
self._set_token(TokenOwner.no_one)
# Restore the member object.
self.member = self.mlist.regular_members.get_member(self.address.email)
# It's possible the member was already unsubscribed while we were
# waiting for the confirmation.
if self.member is None:
return
# The user has confirmed their unsubscription request
next_step = ('moderation_checks'
if self.mlist.unsubscription_policy in (
SubscriptionPolicy.moderate,
SubscriptionPolicy.confirm_then_moderate,
)
else 'do_unsubscription')
self.push(next_step)
def _step_do_unsubscription(self):
try:
delete_member(self.mlist, self.address.email)
except NotAMemberError:
# The member has already been unsubscribed.
pass
self.member = None
assert self.token is None and self.token_owner is TokenOwner.no_one, (
'Unexpected active token at end of subscription workflow')
def _step_unsubscribe_from_restored(self):
# Prevent replay attacks.
self._set_token(TokenOwner.no_one)
if self.which is WhichSubscriber.address:
self.subscriber = self.address
else:
assert self.which is WhichSubscriber.user
self.subscriber = self.user
self.push('do_unsubscription')
@public
@implementer(ISubscriptionManager)
class SubscriptionManager:
def __init__(self, mlist):
self._mlist = mlist
def register(self, subscriber=None, *,
pre_verified=False, pre_confirmed=False, pre_approved=False,
invitation=False, send_welcome_message=None,
delivery_mode=None, delivery_status=None):
"""See `ISubscriptionManager`."""
workflow = SubscriptionWorkflow(
self._mlist, subscriber,
pre_verified=pre_verified,
pre_confirmed=pre_confirmed,
pre_approved=pre_approved,
invitation=invitation,
send_welcome_message=send_welcome_message,
delivery_mode=delivery_mode,
delivery_status=delivery_status,
)
list(workflow)
return workflow.token, workflow.token_owner, workflow.member
def unregister(self, subscriber=None, *,
pre_confirmed=False, pre_approved=False):
workflow = UnSubscriptionWorkflow(
self._mlist, subscriber,
pre_confirmed=pre_confirmed,
pre_approved=pre_approved)
list(workflow)
return workflow.token, workflow.token_owner, workflow.member
def confirm(self, token):
if token is None:
raise LookupError
pendable = getUtility(IPendings).confirm(token, expunge=False)
if pendable is None:
raise LookupError
workflow_type = pendable.get('type')
assert workflow_type in (PendableSubscription.PEND_TYPE,
PendableUnsubscription.PEND_TYPE)
workflow = (SubscriptionWorkflow
if workflow_type == PendableSubscription.PEND_TYPE
else UnSubscriptionWorkflow)(self._mlist)
workflow.token = token
workflow.restore()
# In order to just run the whole workflow, all we need to do
# is iterate over the workflow object. On calling the __next__
# over the workflow iterator it automatically executes the steps
# that needs to be done.
list(workflow)
return workflow.token, workflow.token_owner, workflow.member
def discard(self, token):
with flush():
getUtility(IPendings).confirm(token)
getUtility(IWorkflowStateManager).discard(token)
def _handle_confirmation_needed_events(event, template_name):
# This function handles sending the confirmation email to the user
# for both subscriptions requiring confirmation and invitations
# requiring acceptance.
with _.using(event.mlist.preferred_language.code):
if template_name.endswith(':invite'):
subject = _('You have been invited to join the '
'${event.mlist.fqdn_listname} mailing list.')
elif template_name.endswith(':unsubscribe'):
subject = _('Your confirmation is needed to leave the '
'${event.mlist.fqdn_listname} mailing list.')
else:
assert template_name.endswith(':subscribe')
subject = _('Your confirmation is needed to join the '
'${event.mlist.fqdn_listname} mailing list.')
confirm_address = event.mlist.confirm_address(event.token)
email_address = event.email
# Send a verification email to the address.
template = getUtility(ITemplateLoader).get(template_name, event.mlist)
text = expand(template, event.mlist, dict(
token=event.token,
subject=subject,
confirm_email=confirm_address,
user_email=email_address,
# For backward compatibility.
confirm_address=confirm_address,
email_address=email_address,
domain_name=event.mlist.domain.mail_host,
contact_address=event.mlist.owner_address,
))
if ('verp_confirmations' in config.mta and not
as_boolean(config.mta.verp_confirmations)):
subject = 'confirm {}'.format(event.token)
confirm_address = event.mlist.request_address
msg = UserNotification(
email_address, confirm_address, subject, text,
event.mlist.preferred_language)
del msg['auto-submitted']
msg['Auto-Submitted'] = 'auto-generated'
msg.send(event.mlist, add_precedence=False)
@public
def handle_SubscriptionConfirmationNeededEvent(event):
if not isinstance(event, SubscriptionConfirmationNeededEvent):
return
_handle_confirmation_needed_events(event, 'list:user:action:subscribe')
@public
def handle_SubscriptionInvitationNeededEvent(event):
if not isinstance(event, SubscriptionInvitationNeededEvent):
return
_handle_confirmation_needed_events(event, 'list:user:action:invite')
@public
def handle_UnsubscriptionConfirmationNeededEvent(event):
if not isinstance(event, UnsubscriptionConfirmationNeededEvent):
return
_handle_confirmation_needed_events(event, 'list:user:action:unsubscribe')
@public
def handle_ListDeletingEvent(event):
"""Delete a mailing list's members when the list is being deleted."""
if not isinstance(event, ListDeletingEvent):
return
# Find all the members still associated with the mailing list.
members = getUtility(ISubscriptionService).find_members(
list_id=event.mailing_list.list_id)
for member in members:
member.unsubscribe()
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1672813222.7155628
mailman-3.3.10/src/mailman/app/tests/__init__.py 0000644 0000000 0000000 00000000000 14355215247 016400 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5101042
mailman-3.3.10/src/mailman/app/tests/test_bounces.py 0000644 0000000 0000000 00000052444 14542770442 017362 0 ustar 00 # Copyright (C) 2011-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Testing app.bounces functions."""
import os
import uuid
import shutil
import tempfile
import unittest
from mailman.app.bounces import (
bounce_message,
maybe_forward,
ProbeVERP,
send_probe,
StandardVERP,
)
from mailman.app.lifecycle import create_list
from mailman.config import config
from mailman.interfaces.bounce import UnrecognizedBounceDisposition
from mailman.interfaces.languages import ILanguageManager
from mailman.interfaces.member import MemberRole
from mailman.interfaces.pending import IPendings
from mailman.interfaces.usermanager import IUserManager
from mailman.testing.helpers import (
get_queue_messages,
LogFileMark,
specialized_message_from_string as mfs,
subscribe,
)
from mailman.testing.layers import ConfigLayer
from zope.component import getUtility
class TestVERP(unittest.TestCase):
"""Test header VERP detection."""
layer = ConfigLayer
def setUp(self):
self._mlist = create_list('test@example.com')
self._verper = StandardVERP()
def test_no_verp(self):
# The empty set is returned when there is no VERP headers.
msg = mfs("""\
From: postmaster@example.com
To: mailman-bounces@example.com
""")
self.assertEqual(self._verper.get_verp(self._mlist, msg), set())
def test_verp_in_to(self):
# A VERP address is found in the To header.
msg = mfs("""\
From: postmaster@example.com
To: test-bounces+anne=example.org@example.com
""")
self.assertEqual(self._verper.get_verp(self._mlist, msg),
set(['anne@example.org']))
def test_verp_in_delivered_to(self):
# A VERP address is found in the Delivered-To header.
msg = mfs("""\
From: postmaster@example.com
Delivered-To: test-bounces+anne=example.org@example.com
""")
self.assertEqual(self._verper.get_verp(self._mlist, msg),
set(['anne@example.org']))
def test_verp_in_envelope_to(self):
# A VERP address is found in the Envelope-To header.
msg = mfs("""\
From: postmaster@example.com
Envelope-To: test-bounces+anne=example.org@example.com
""")
self.assertEqual(self._verper.get_verp(self._mlist, msg),
set(['anne@example.org']))
def test_verp_in_apparently_to(self):
# A VERP address is found in the Apparently-To header.
msg = mfs("""\
From: postmaster@example.com
Apparently-To: test-bounces+anne=example.org@example.com
""")
self.assertEqual(self._verper.get_verp(self._mlist, msg),
set(['anne@example.org']))
def test_verp_with_empty_header(self):
# A VERP address is found, but there's an empty header.
msg = mfs("""\
From: postmaster@example.com
To: test-bounces+anne=example.org@example.com
To:
""")
self.assertEqual(self._verper.get_verp(self._mlist, msg),
set(['anne@example.org']))
def test_no_verp_with_empty_header(self):
# There's an empty header, and no VERP address is found.
msg = mfs("""\
From: postmaster@example.com
To:
""")
self.assertEqual(self._verper.get_verp(self._mlist, msg), set())
def test_verp_with_non_match(self):
# A VERP address is found, but a header had a non-matching pattern.
msg = mfs("""\
From: postmaster@example.com
To: test-bounces+anne=example.org@example.com
To: test-bounces@example.com
""")
self.assertEqual(self._verper.get_verp(self._mlist, msg),
set(['anne@example.org']))
def test_no_verp_with_non_match(self):
# No VERP address is found, and a header had a non-matching pattern.
msg = mfs("""\
From: postmaster@example.com
To: test-bounces@example.com
""")
self.assertEqual(self._verper.get_verp(self._mlist, msg), set())
def test_multiple_verps(self):
# More than one VERP address was found in the same header.
msg = mfs("""\
From: postmaster@example.com
To: test-bounces+anne=example.org@example.com
To: test-bounces+anne=example.org@example.com
""")
self.assertEqual(self._verper.get_verp(self._mlist, msg),
set(['anne@example.org']))
def test_multiple_verps_different_values(self):
# More than one VERP address was found in the same header with
# different values.
msg = mfs("""\
From: postmaster@example.com
To: test-bounces+anne=example.org@example.com
To: test-bounces+bart=example.org@example.com
""")
self.assertEqual(self._verper.get_verp(self._mlist, msg),
set(['anne@example.org', 'bart@example.org']))
def test_multiple_verps_different_values_different_headers(self):
# More than one VERP address was found in different headers with
# different values.
msg = mfs("""\
From: postmaster@example.com
To: test-bounces+anne=example.org@example.com
Apparently-To: test-bounces+bart=example.org@example.com
""")
self.assertEqual(self._verper.get_verp(self._mlist, msg),
set(['anne@example.org', 'bart@example.org']))
class TestSendProbe(unittest.TestCase):
"""Test sending of the probe message."""
layer = ConfigLayer
maxDiff = None
def setUp(self):
self._mlist = create_list('test@example.com')
self._mlist.send_welcome_message = False
self._member = subscribe(self._mlist, 'Anne', email='anne@example.com')
self._msg = mfs("""\
From: bouncer@example.com
To: anne@example.com
Subject: You bounced
Message-ID:
""")
def test_send_probe_missing_required_params(self):
with self.assertRaises(ValueError):
send_probe(self._member)
def test_token(self):
# Show that send_probe() returns a proper token, and that the token
# corresponds to a record in the pending database.
token = send_probe(self._member, self._msg)
pendable = getUtility(IPendings).confirm(token)
self.assertEqual(len(pendable.items()), 3)
self.assertEqual(set(pendable.keys()),
set(['member_id', 'message_id', 'type']))
# member_ids are pended as unicodes.
self.assertEqual(uuid.UUID(hex=pendable['member_id']),
self._member.member_id)
self.assertEqual(pendable['message_id'], '')
def test_probe_is_multipart(self):
# The probe is a multipart/mixed with two subparts.
send_probe(self._member, self._msg)
items = get_queue_messages('virgin', expected_count=1)
message = items[0].msg
self.assertEqual(message.get_content_type(), 'multipart/mixed')
self.assertTrue(message.is_multipart())
self.assertEqual(len(message.get_payload()), 2)
# Check that the second part is the DSN
part_content = message.get_payload(1).get_payload(0).as_string()
self.assertEqual(part_content, self._msg.as_string())
def test_probe_sends_one_message(self):
# send_probe() places one message in the virgin queue. We start out
# with no messages in the queue.
get_queue_messages('virgin', expected_count=0)
send_probe(self._member, self._msg)
get_queue_messages('virgin', expected_count=1)
def test_probe_contains_original(self):
# Show that send_probe() places a properly formatted message in the
# virgin queue.
send_probe(self._member, self._msg)
items = get_queue_messages('virgin', expected_count=1)
rfc822 = items[0].msg.get_payload(1)
self.assertEqual(rfc822.get_content_type(), 'message/rfc822')
self.assertTrue(rfc822.is_multipart())
self.assertEqual(len(rfc822.get_payload()), 1)
self.assertEqual(rfc822.get_payload(0).as_string(),
self._msg.as_string())
def test_notice(self):
# Test that the notice in the first subpart is correct.
send_probe(self._member, self._msg)
items = get_queue_messages('virgin', expected_count=1)
message = items[0].msg
notice = message.get_payload(0)
self.assertEqual(notice.get_content_type(), 'text/plain')
# The interesting bits are the parts that have been interpolated into
# the message. For now the best we can do is know that the
# interpolation values appear in the message.
self.assertMultiLineEqual(notice.get_payload(), """\
This is a probe message. You can ignore this message.
The test@example.com mailing list has received a number of bounces
from you, indicating that there may be a problem delivering messages
to anne@example.com. A sample is attached below. Please examine this
message to make sure there are no problems with your email address.
You may want to check with your mail administrator for more help.
You don't need to do anything to remain an enabled member of the
mailing list.
If you have any questions or problems, you can contact the mailing
list owner at
test-owner@example.com
""")
def test_headers(self):
# Check the headers of the outer message.
token = send_probe(self._member, self._msg)
items = get_queue_messages('virgin', expected_count=1)
message = items[0].msg
self.assertEqual(message['from'],
'test-bounces+{0}@example.com'.format(token))
self.assertEqual(message['to'], 'anne@example.com')
self.assertEqual(message['subject'], 'Test mailing list probe message')
def test_send_probe_to_user(self):
# Can we send probe to member subscribed as a user.
self._member = subscribe(self._mlist, 'Bart', email='bart@example.com',
as_user=True)
token = send_probe(self._member, self._msg)
items = get_queue_messages('virgin', expected_count=1)
message = items[0].msg
self.assertEqual(message['from'],
'test-bounces+{0}@example.com'.format(token))
self.assertEqual(message['to'], 'bart@example.com')
self.assertEqual(message['subject'], 'Test mailing list probe message')
def test_no_precedence_header(self):
# Probe messages should not have a Precedence header (LP: #808821).
send_probe(self._member, self._msg)
items = get_queue_messages('virgin', expected_count=1)
self.assertIsNone(items[0].msg['precedence'])
def test_send_probe_resets_bounce_score(self):
# Sending a probe should reset bounce_score so every subsequent bounce
# doesn't send another probe.
self._member.bounce_score = 5
send_probe(self._member, self._msg)
self.assertEqual(self._member.bounce_score, 0)
class TestSendProbeNonEnglish(unittest.TestCase):
"""Test sending of the probe message to a non-English speaker."""
layer = ConfigLayer
maxDiff = None
def setUp(self):
self._mlist = create_list('test@example.com')
self._member = subscribe(self._mlist, 'Anne', email='anne@example.com')
self._msg = mfs("""\
From: bouncer@example.com
To: anne@example.com
Subject: You bounced
Message-ID:
""")
# Set up the translation context.
self._var_dir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self._var_dir)
xx_template_path = os.path.join(
self._var_dir, 'templates', 'site', 'xx',
'list:user:notice:probe.txt')
os.makedirs(os.path.dirname(xx_template_path))
config.push('xx template dir', """\
[paths.testing]
var_dir: {}
""".format(self._var_dir))
self.addCleanup(config.pop, 'xx template dir')
language_manager = getUtility(ILanguageManager)
language_manager.add('xx', 'utf-8', 'Freedonia')
self._member.preferences.preferred_language = 'xx'
with open(xx_template_path, 'w') as fp:
print("""\
blah blah blah
$listname
$address
$owneraddr
""", file=fp)
def test_subject_with_member_nonenglish(self):
# Test that members with non-English preferred language get a Subject
# header in the expected language.
send_probe(self._member, self._msg)
items = get_queue_messages('virgin', expected_count=1)
self.assertEqual(
items[0].msg['subject'].encode(),
'=?utf-8?q?ailing-may_ist-lay_Test_obe-pray_essage-may?=')
def test_probe_notice_with_member_nonenglish(self):
# Test that a member with non-English preferred language gets the
# probe message in their language.
send_probe(self._member, self._msg)
items = get_queue_messages('virgin', expected_count=1)
message = items[0].msg
notice = message.get_payload(0).get_payload(decode=True)
notice = notice.decode(self._member.preferred_language.charset)
self.assertMultiLineEqual(notice, """\
blah blah blah test@example.com anne@example.com
test-owner@example.com
""")
class TestProbe(unittest.TestCase):
"""Test VERP probe parsing."""
layer = ConfigLayer
def setUp(self):
self._mlist = create_list('test@example.com')
self._mlist.send_welcome_message = False
self._member = subscribe(self._mlist, 'Anne', email='anne@example.com')
self._msg = mfs("""\
From: bouncer@example.com
To: anne@example.com
Subject: You bounced
Message-ID:
""")
def test_get_addresses(self):
# Be able to extract the probed address from the pending database
# based on the token in a probe bounce.
token = send_probe(self._member, self._msg)
# Simulate a bounce of the message in the virgin queue.
items = get_queue_messages('virgin', expected_count=1)
message = items[0].msg
bounce = mfs("""\
To: {0}
From: mail-daemon@example.com
""".format(message['From']))
addresses = ProbeVERP().get_verp(self._mlist, bounce)
self.assertEqual(addresses, set(['anne@example.com']))
# The pendable is no longer in the database.
self.assertIsNone(getUtility(IPendings).confirm(token))
class TestMaybeForward(unittest.TestCase):
"""Test forwarding of unrecognized bounces."""
layer = ConfigLayer
maxDiff = None
def setUp(self):
config.push('test config', """
[mailman]
site_owner: postmaster@example.com
""")
self.addCleanup(config.pop, 'test config')
self._mlist = create_list('test@example.com')
self._mlist.send_welcome_message = False
self._msg = mfs("""\
From: bouncer@example.com
To: test-bounces@example.com
Subject: You bounced
Message-ID:
""")
def test_maybe_forward_discard(self):
# When forward_unrecognized_bounces_to is set to discard, no bounce
# messages are forwarded.
self._mlist.forward_unrecognized_bounces_to = (
UnrecognizedBounceDisposition.discard)
# The only artifact of this call is a log file entry.
mark = LogFileMark('mailman.bounce')
maybe_forward(self._mlist, self._msg)
get_queue_messages('virgin', expected_count=0)
line = mark.readline()
self.assertEqual(
line[-40:-1],
'Discarding unrecognized bounce: ')
def test_maybe_forward_list_owner(self):
# Set up some owner and moderator addresses.
user_manager = getUtility(IUserManager)
anne = user_manager.create_address('anne@example.com')
bart = user_manager.create_address('bart@example.com')
cris = user_manager.create_address('cris@example.com')
dave = user_manager.create_address('dave@example.com')
# Regular members.
elle = user_manager.create_address('elle@example.com')
fred = user_manager.create_address('fred@example.com')
self._mlist.subscribe(anne, MemberRole.owner)
self._mlist.subscribe(bart, MemberRole.owner)
self._mlist.subscribe(cris, MemberRole.moderator)
self._mlist.subscribe(dave, MemberRole.moderator)
self._mlist.subscribe(elle, MemberRole.member)
self._mlist.subscribe(fred, MemberRole.member)
# When forward_unrecognized_bounces_to is set to owners, the
# bounce is forwarded to the list owners and moderators.
self._mlist.forward_unrecognized_bounces_to = (
UnrecognizedBounceDisposition.administrators)
maybe_forward(self._mlist, self._msg)
items = get_queue_messages('virgin', expected_count=1)
msg = items[0].msg
self.assertEqual(msg['subject'], 'Uncaught bounce notification')
self.assertEqual(msg['from'], 'postmaster@example.com')
self.assertEqual(msg['to'], 'test-owner@example.com')
# The first attachment is a notification message with a url.
payload = msg.get_payload(0)
self.assertEqual(payload.get_content_type(), 'text/plain')
body = payload.get_payload()
self.assertMultiLineEqual(body, """\
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).
""")
# The second attachment should be a message/rfc822 containing the
# original bounce message.
payload = msg.get_payload(1)
self.assertEqual(payload.get_content_type(), 'message/rfc822')
bounce = payload.get_payload(0)
self.assertEqual(bounce.as_string(), self._msg.as_string())
# All of the owners and moderators, but none of the members, should be
# recipients of this message.
self.assertEqual(items[0].msgdata['recipients'],
set(['anne@example.com', 'bart@example.com',
'cris@example.com', 'dave@example.com']))
def test_maybe_forward_site_owner(self):
# Set up some owner and moderator addresses.
user_manager = getUtility(IUserManager)
anne = user_manager.create_address('anne@example.com')
bart = user_manager.create_address('bart@example.com')
cris = user_manager.create_address('cris@example.com')
dave = user_manager.create_address('dave@example.com')
# Regular members.
elle = user_manager.create_address('elle@example.com')
fred = user_manager.create_address('fred@example.com')
self._mlist.subscribe(anne, MemberRole.owner)
self._mlist.subscribe(bart, MemberRole.owner)
self._mlist.subscribe(cris, MemberRole.moderator)
self._mlist.subscribe(dave, MemberRole.moderator)
self._mlist.subscribe(elle, MemberRole.member)
self._mlist.subscribe(fred, MemberRole.member)
# When forward_unrecognized_bounces_to is set to owners, the
# bounce is forwarded to the list owners and moderators.
self._mlist.forward_unrecognized_bounces_to = (
UnrecognizedBounceDisposition.site_owner)
maybe_forward(self._mlist, self._msg)
items = get_queue_messages('virgin', expected_count=1)
msg = items[0].msg
self.assertEqual(msg['subject'], 'Uncaught bounce notification')
self.assertEqual(msg['from'], 'postmaster@example.com')
self.assertEqual(msg['to'], 'postmaster@example.com')
# The first attachment is a notification message with a url.
payload = msg.get_payload(0)
self.assertEqual(payload.get_content_type(), 'text/plain')
body = payload.get_payload()
self.assertMultiLineEqual(body, """\
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).
""")
# The second attachment should be a message/rfc822 containing the
# original bounce message.
payload = msg.get_payload(1)
self.assertEqual(payload.get_content_type(), 'message/rfc822')
bounce = payload.get_payload(0)
self.assertEqual(bounce.as_string(), self._msg.as_string())
# All of the owners and moderators, but none of the members, should be
# recipients of this message.
self.assertEqual(items[0].msgdata['recipients'],
set(['postmaster@example.com']))
class TestBounceMessage(unittest.TestCase):
"""Test the `mailman.app.bounces.bounce_message()` function."""
layer = ConfigLayer
def setUp(self):
self._mlist = create_list('test@example.com')
self._msg = mfs("""\
From: anne@example.com
To: test@example.com
Subject: Ignore
""")
def test_no_sender(self):
# The message won't be bounced if it has no discernible sender.
del self._msg['from']
bounce_message(self._mlist, self._msg)
# Nothing in the virgin queue means nothing's been bounced.
get_queue_messages('virgin', expected_count=0)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1726290746.6782107
mailman-3.3.10/src/mailman/app/tests/test_digests.py 0000644 0000000 0000000 00000026203 14671215473 017361 0 ustar 00 # Copyright (C) 2015-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Digest helper tests."""
import os
import unittest
from datetime import timedelta
from mailman.app.digests import (
bump_digest_number_and_volume,
maybe_send_digest_now,
)
from mailman.app.lifecycle import create_list
from mailman.config import config
from mailman.interfaces.digests import DigestFrequency
from mailman.interfaces.member import DeliveryMode
from mailman.runners.digest import DigestRunner
from mailman.testing.helpers import (
get_queue_messages,
make_testable_runner,
specialized_message_from_string as mfs,
subscribe,
)
from mailman.testing.layers import ConfigLayer
from mailman.utilities.datetime import factory, now as right_now
class TestBumpDigest(unittest.TestCase):
layer = ConfigLayer
def setUp(self):
self._mlist = create_list('ant@example.com')
self._mlist.volume = 7
self._mlist.next_digest_number = 4
self.right_now = right_now()
def test_bump_no_previous_digest(self):
self._mlist.digest_last_sent_at = None
bump_digest_number_and_volume(self._mlist)
self.assertEqual(self._mlist.volume, 7)
self.assertEqual(self._mlist.next_digest_number, 5)
self.assertEqual(self._mlist.digest_last_sent_at, self.right_now)
def test_bump_yearly(self):
self._mlist.digest_last_sent_at = self.right_now + timedelta(
days=-370)
self._mlist.digest_volume_frequency = DigestFrequency.yearly
bump_digest_number_and_volume(self._mlist)
self.assertEqual(self._mlist.volume, 8)
self.assertEqual(self._mlist.next_digest_number, 1)
self.assertEqual(self._mlist.digest_last_sent_at, self.right_now)
def test_bump_yearly_not_yet(self):
self._mlist.digest_last_sent_at = self.right_now + timedelta(
days=-200)
self._mlist.digest_volume_frequency = DigestFrequency.yearly
bump_digest_number_and_volume(self._mlist)
self.assertEqual(self._mlist.volume, 7)
self.assertEqual(self._mlist.next_digest_number, 5)
self.assertEqual(self._mlist.digest_last_sent_at, self.right_now)
def test_bump_monthly(self):
self._mlist.digest_last_sent_at = self.right_now + timedelta(
days=-32)
self._mlist.digest_volume_frequency = DigestFrequency.monthly
bump_digest_number_and_volume(self._mlist)
self.assertEqual(self._mlist.volume, 8)
self.assertEqual(self._mlist.next_digest_number, 1)
self.assertEqual(self._mlist.digest_last_sent_at, self.right_now)
def test_bump_monthly_not_yet(self):
# The normal test date starts on the first day of the month, so let's
# fast forward it a few days so we can set the digest last sent time
# to earlier in the same month.
self._mlist.digest_last_sent_at = self.right_now
factory.fast_forward(days=26)
self._mlist.digest_volume_frequency = DigestFrequency.monthly
bump_digest_number_and_volume(self._mlist)
self.assertEqual(self._mlist.volume, 7)
self.assertEqual(self._mlist.next_digest_number, 5)
self.assertEqual(self._mlist.digest_last_sent_at, right_now())
def test_bump_quarterly(self):
self._mlist.digest_last_sent_at = self.right_now + timedelta(
days=-93)
self._mlist.digest_volume_frequency = DigestFrequency.quarterly
bump_digest_number_and_volume(self._mlist)
self.assertEqual(self._mlist.volume, 8)
self.assertEqual(self._mlist.next_digest_number, 1)
self.assertEqual(self._mlist.digest_last_sent_at, self.right_now)
def test_bump_quarterly_not_yet(self):
self._mlist.digest_last_sent_at = self.right_now + timedelta(
days=-88)
self._mlist.digest_volume_frequency = DigestFrequency.quarterly
bump_digest_number_and_volume(self._mlist)
self.assertEqual(self._mlist.volume, 7)
self.assertEqual(self._mlist.next_digest_number, 5)
self.assertEqual(self._mlist.digest_last_sent_at, self.right_now)
def test_bump_weekly(self):
self._mlist.digest_last_sent_at = self.right_now + timedelta(
days=-8)
self._mlist.digest_volume_frequency = DigestFrequency.weekly
bump_digest_number_and_volume(self._mlist)
self.assertEqual(self._mlist.volume, 8)
self.assertEqual(self._mlist.next_digest_number, 1)
self.assertEqual(self._mlist.digest_last_sent_at, self.right_now)
def test_bump_weekly_not_yet(self):
# The normal test date starts on the first day of the week, so let's
# fast forward it a few days so we can set the digest last sent time
# to earlier in the same week.
self._mlist.digest_last_sent_at = self.right_now
factory.fast_forward(days=3)
self._mlist.digest_volume_frequency = DigestFrequency.weekly
bump_digest_number_and_volume(self._mlist)
self.assertEqual(self._mlist.volume, 7)
self.assertEqual(self._mlist.next_digest_number, 5)
self.assertEqual(self._mlist.digest_last_sent_at, right_now())
def test_bump_daily(self):
self._mlist.digest_last_sent_at = self.right_now + timedelta(
hours=-27)
self._mlist.digest_volume_frequency = DigestFrequency.daily
bump_digest_number_and_volume(self._mlist)
self.assertEqual(self._mlist.volume, 8)
self.assertEqual(self._mlist.next_digest_number, 1)
self.assertEqual(self._mlist.digest_last_sent_at, self.right_now)
def test_bump_daily_not_yet(self):
self._mlist.digest_last_sent_at = self.right_now + timedelta(
hours=-5)
self._mlist.digest_volume_frequency = DigestFrequency.daily
bump_digest_number_and_volume(self._mlist)
self.assertEqual(self._mlist.volume, 7)
self.assertEqual(self._mlist.next_digest_number, 5)
self.assertEqual(self._mlist.digest_last_sent_at, self.right_now)
def test_bump_bad_frequency(self):
self._mlist.digest_last_sent_at = self.right_now + timedelta(
hours=-22)
self._mlist.digest_volume_frequency = -10
self.assertRaises(AssertionError,
bump_digest_number_and_volume, self._mlist)
class TestMaybeSendDigest(unittest.TestCase):
layer = ConfigLayer
def setUp(self):
self._mlist = create_list('ant@example.com')
self._mlist.send_welcome_message = False
self._mailbox_path = os.path.join(self._mlist.data_path, 'digest.mmdf')
# The mailing list needs at least one digest recipient.
member = subscribe(self._mlist, 'Anne')
member.preferences.delivery_mode = DeliveryMode.plaintext_digests
self._subject_number = 1
self._runner = make_testable_runner(DigestRunner, 'digest')
def _to_digest(self, count=1):
for i in range(count):
msg = mfs("""\
To: ant@example.com
From: anne@example.com
Subject: message {}
""".format(self._subject_number))
self._subject_number += 1
config.handlers['to-digest'].process(self._mlist, msg, {})
def test_send_digest_over_threshold(self):
# Put a few messages in the digest.
self._to_digest(3)
# Set the size threshold low enough to trigger a send.
self._mlist.digest_size_threshold = 0.1
maybe_send_digest_now(self._mlist)
self._runner.run()
# There are no digests in flight now, and a single digest message has
# been sent.
get_queue_messages('digest', expected_count=0)
self.assertFalse(os.path.exists(self._mailbox_path))
items = get_queue_messages('virgin', expected_count=1)
digest_contents = str(items[0].msg)
self.assertIn('Subject: message 1', digest_contents)
self.assertIn('Subject: message 2', digest_contents)
def test_dont_send_digest_under_threshold(self):
# Put a few messages in the digest.
self._to_digest(3)
# Set the size threshold high enough to not trigger a send.
self._mlist.digest_size_threshold = 100
maybe_send_digest_now(self._mlist)
self._runner.run()
# A digest is still being collected, but none have been sent.
get_queue_messages('digest', expected_count=0)
self.assertGreater(os.path.getsize(self._mailbox_path), 0)
self.assertLess(os.path.getsize(self._mailbox_path), 100 * 1024.0)
get_queue_messages('virgin', expected_count=0)
def test_dont_send_digest_threshold_zero(self):
# Put a few messages in the digest.
self._to_digest(3)
# Set the size threshold to zero which should not trigger a send.
self._mlist.digest_size_threshold = 0
maybe_send_digest_now(self._mlist)
self._runner.run()
# A digest is still being collected, but none have been sent.
get_queue_messages('digest', expected_count=0)
self.assertGreater(os.path.getsize(self._mailbox_path), 0)
get_queue_messages('virgin', expected_count=0)
def test_force_send_digest_under_threshold(self):
# Put a few messages in the digest.
self._to_digest(3)
# Set the size threshold high enough to not trigger a send.
self._mlist.digest_size_threshold = 100
# Force sending a digest anyway.
maybe_send_digest_now(self._mlist, force=True)
self._runner.run()
# There are no digests in flight now, and a single digest message has
# been sent.
get_queue_messages('digest', expected_count=0)
self.assertFalse(os.path.exists(self._mailbox_path))
items = get_queue_messages('virgin', expected_count=1)
digest_contents = str(items[0].msg)
self.assertIn('Subject: message 1', digest_contents)
self.assertIn('Subject: message 2', digest_contents)
def test_force_and_threshold_zero(self):
# Put a few messages in the digest.
self._to_digest(3)
# Set the size threshold to zero (unlimited).
self._mlist.digest_size_threshold = 0
# Force sending a digest anyway.
maybe_send_digest_now(self._mlist, force=True)
self._runner.run()
# There are no digests in flight now, and a single digest message has
# been sent.
get_queue_messages('digest', expected_count=0)
self.assertFalse(os.path.exists(self._mailbox_path))
items = get_queue_messages('virgin', expected_count=1)
digest_contents = str(items[0].msg)
self.assertIn('Subject: message 1', digest_contents)
self.assertIn('Subject: message 2', digest_contents)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5107143
mailman-3.3.10/src/mailman/app/tests/test_inject.py 0000644 0000000 0000000 00000032273 14542770442 017176 0 ustar 00 # Copyright (C) 2011-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Testing app.inject functions."""
import unittest
from email import message_from_bytes
from mailman.app.inject import inject_message, inject_text
from mailman.app.lifecycle import create_list
from mailman.email.message import Message
from mailman.testing.helpers import (
get_queue_messages,
specialized_message_from_string as message_from_string,
)
from mailman.testing.layers import ConfigLayer
NL = '\n'
class TestInjectMessage(unittest.TestCase):
"""Test message injection."""
layer = ConfigLayer
def setUp(self):
self.mlist = create_list('test@example.com')
self.msg = message_from_string("""\
From: anne@example.com
To: test@example.com
Subject: A test message
Message-ID:
Date: Tue, 14 Jun 2011 21:12:00 -0400
Nothing.
""")
self.msg2 = message_from_bytes("""
From: anne@example.com
To: test@example.com
Subject: A test message
Message-ID:
Date: Tue, 14 Jun 2011 21:12:00 -0400
MIME-Version: 1.0
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: 8bit
Here's some text.
Throw in some specials “fancy quoted”
""".encode('utf-8'), Message)
# Let assertMultiLineEqual work without bounds.
def test_inject_message(self):
# Test basic inject_message() call.
inject_message(self.mlist, self.msg)
items = get_queue_messages('in', expected_count=1)
self.assertMultiLineEqual(items[0].msg.as_string(),
self.msg.as_string())
self.assertEqual(items[0].msgdata['listid'], 'test.example.com')
self.assertEqual(items[0].msgdata['original_size'],
len(self.msg.as_string()))
def test_inject_message_with_recipients(self):
# Explicit recipients end up in the metadata.
recipients = ['bart@example.com', 'cris@example.com']
inject_message(self.mlist, self.msg, recipients)
items = get_queue_messages('in', expected_count=1)
self.assertEqual(items[0].msgdata['recipients'], recipients)
def test_inject_message_to_queue(self):
# Explicitly use a different queue.
inject_message(self.mlist, self.msg, switchboard='virgin')
get_queue_messages('in', expected_count=0)
items = get_queue_messages('virgin', expected_count=1)
self.assertMultiLineEqual(items[0].msg.as_string(),
self.msg.as_string())
self.assertEqual(items[0].msgdata['listid'], 'test.example.com')
self.assertEqual(items[0].msgdata['original_size'],
len(self.msg.as_string()))
def test_inject_message_without_message_id(self):
# inject_message() adds a Message-ID header if it's missing.
del self.msg['message-id']
self.assertNotIn('message-id', self.msg)
inject_message(self.mlist, self.msg)
self.assertIn('message-id', self.msg)
items = get_queue_messages('in', expected_count=1)
self.assertIn('message-id', items[0].msg)
self.assertEqual(items[0].msg['message-id'], self.msg['message-id'])
def test_inject_message_without_date(self):
# inject_message() adds a Date header if it's missing.
del self.msg['date']
self.assertNotIn('date', self.msg)
inject_message(self.mlist, self.msg)
self.assertIn('date', self.msg)
items = get_queue_messages('in', expected_count=1)
self.assertIn('date', items[0].msg)
self.assertEqual(items[0].msg['date'], self.msg['date'])
def test_inject_message_with_keywords(self):
# Keyword arguments are copied into the metadata.
inject_message(self.mlist, self.msg, foo='yes', bar='no')
items = get_queue_messages('in', expected_count=1)
self.assertEqual(items[0].msgdata['foo'], 'yes')
self.assertEqual(items[0].msgdata['bar'], 'no')
def test_inject_message_id_hash(self):
# When the injected message has a Message-ID header, the injected
# message will also get a Message-ID-Hash header.
inject_message(self.mlist, self.msg)
items = get_queue_messages('in', expected_count=1)
self.assertEqual(items[0].msg['message-id-hash'],
'4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB')
def test_inject_message_id_hash_without_message_id(self):
# When the injected message does not have a Message-ID header, a
# Message-ID header will be added, and the injected message will also
# get a Message-ID-Hash header.
del self.msg['message-id']
self.assertNotIn('message-id', self.msg)
self.assertNotIn('message-id-hash', self.msg)
inject_message(self.mlist, self.msg)
items = get_queue_messages('in', expected_count=1)
self.assertIn('message-id', items[0].msg)
self.assertIn('message-id-hash', items[0].msg)
def test_inject_non_ascii_message(self):
# Test basic inject_message() call with a non-ascii message.
inject_message(self.mlist, self.msg2)
items = get_queue_messages('in', expected_count=1)
self.assertMultiLineEqual(items[0].msg.as_string(),
self.msg2.as_string())
self.assertEqual(items[0].msgdata['listid'], 'test.example.com')
self.assertEqual(items[0].msgdata['original_size'],
len(self.msg2.as_string()))
class TestInjectText(unittest.TestCase):
"""Test text injection."""
layer = ConfigLayer
maxDiff = None
def setUp(self):
self.mlist = create_list('test@example.com')
self.text = """\
From: bart@example.com
To: test@example.com
Subject: A test message
Message-ID:
Date: Tue, 14 Jun 2011 21:12:00 -0400
Nothing.
"""
self.text2 = """\
From: bart@example.com
To: test@example.com
Subject: A test message
Message-ID:
Date: Tue, 14 Jun 2011 21:12:00 -0400
MIME-Version: 1.0
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: 8bit
Here's some text.
Throw in some specials “fancy quoted”
"""
def _remove_line(self, header):
return NL.join(line for line in self.text.splitlines()
if not line.lower().startswith(header))
def test_inject_text(self):
# Test basic inject_text() call.
inject_text(self.mlist, self.text)
items = get_queue_messages('in', expected_count=1)
self.assertIsInstance(items[0].msg, Message)
self.assertEqual(items[0].msg['message-id-hash'],
'GUXXQKNCHBFQAHGBFMGCME6HKZCUUH3K')
# Delete these headers because they don't exist in the original text.
del items[0].msg['message-id-hash']
del items[0].msg['x-message-id-hash']
self.assertMultiLineEqual(items[0].msg.as_string(), self.text)
self.assertEqual(items[0].msgdata['listid'], 'test.example.com')
self.assertEqual(items[0].msgdata['original_size'],
# Add back the Message-ID-Hash and X-Message-ID-Hash
# headers which wer in the message contributing to the
# original_size, but weren't in the original text.
# Don't forget the space, delimeter, and newline!
len(self.text) + 50 + 52)
def test_inject_text_with_recipients(self):
# Explicit recipients end up in the metadata.
recipients = ['bart@example.com', 'cris@example.com']
inject_text(self.mlist, self.text, recipients)
items = get_queue_messages('in', expected_count=1)
self.assertEqual(items[0].msgdata['recipients'], recipients)
def test_inject_text_to_queue(self):
# Explicitly use a different queue.
inject_text(self.mlist, self.text, switchboard='virgin')
get_queue_messages('in', expected_count=0)
items = get_queue_messages('virgin', expected_count=1)
# Remove the Message-ID-Hash header which isn't in the original text.
del items[0].msg['message-id-hash']
del items[0].msg['x-message-id-hash']
self.assertMultiLineEqual(items[0].msg.as_string(), self.text)
self.assertEqual(items[0].msgdata['listid'], 'test.example.com')
self.assertEqual(items[0].msgdata['original_size'],
# Add back the Message-ID-Hash and X-Message-ID-Hash
# headers which wer in the message contributing to the
# original_size, but weren't in the original text.
# Don't forget the space, delimeter, and newline!
len(self.text) + 50 + 52)
def test_inject_text_without_message_id(self):
# inject_text() adds a Message-ID header if it's missing.
filtered = self._remove_line('message-id')
self.assertNotIn('Message-ID', filtered)
inject_text(self.mlist, filtered)
items = get_queue_messages('in', expected_count=1)
self.assertIn('message-id', items[0].msg)
def test_inject_text_without_date(self):
# inject_text() adds a Date header if it's missing.
filtered = self._remove_line('date')
self.assertNotIn('date', filtered)
inject_text(self.mlist, self.text)
items = get_queue_messages('in', expected_count=1)
self.assertIn('date', items[0].msg)
def test_inject_text_adds_original_size(self):
# The metadata gets an original_size attribute that is the length of
# the injected text.
inject_text(self.mlist, self.text)
items = get_queue_messages('in', expected_count=1)
self.assertEqual(items[0].msgdata['original_size'],
# Add back the Message-ID-Hash and X-Message-ID-Hash
# headers which wer in the message contributing to the
# original_size, but weren't in the original text.
# Don't forget the space, delimeter, and newline!
len(self.text) + 50 + 52)
def test_inject_text_with_keywords(self):
# Keyword arguments are copied into the metadata.
inject_text(self.mlist, self.text, foo='yes', bar='no')
items = get_queue_messages('in', expected_count=1)
self.assertEqual(items[0].msgdata['foo'], 'yes')
self.assertEqual(items[0].msgdata['bar'], 'no')
def test_inject_message_id_hash(self):
# When the injected message has a Message-ID header, the injected
# message will also get a Message-ID-Hash header.
inject_text(self.mlist, self.text)
items = get_queue_messages('in', expected_count=1)
self.assertEqual(items[0].msg['message-id-hash'],
'GUXXQKNCHBFQAHGBFMGCME6HKZCUUH3K')
def test_inject_message_id_hash_without_message_id(self):
# When the injected message does not have a Message-ID header, a
# Message-ID header will be added, and the injected message will also
# get a Message-ID-Hash header.
filtered = self._remove_line('message-id')
self.assertNotIn('Message-ID', filtered)
self.assertNotIn('Message-ID-Hash', filtered)
inject_text(self.mlist, filtered)
items = get_queue_messages('in', expected_count=1)
self.assertIn('message-id', items[0].msg)
self.assertIn('message-id-hash', items[0].msg)
def test_inject_text_non_ascii_string(self):
# Test basic inject_text() call with a non-ascii string.
inject_text(self.mlist, self.text2)
items = get_queue_messages('in', expected_count=1)
self.assertIsInstance(items[0].msg, Message)
self.assertEqual(items[0].msg['message-id-hash'],
'GUXXQKNCHBFQAHGBFMGCME6HKZCUUH3K')
# Remove the Message-ID-Hash header which isn't in the original text.
del items[0].msg['message-id-hash']
del items[0].msg['x-message-id-hash']
self.assertMultiLineEqual(items[0].msg.as_bytes().decode('utf-8'),
self.text2)
def test_inject_text_non_ascii_bytes(self):
# Test basic inject_text() call with non-ascii bytes.
inject_text(self.mlist, self.text2.encode('utf-8'))
items = get_queue_messages('in', expected_count=1)
self.assertIsInstance(items[0].msg, Message)
self.assertEqual(items[0].msg['message-id-hash'],
'GUXXQKNCHBFQAHGBFMGCME6HKZCUUH3K')
# Remove the Message-ID-Hash header which isn't in the original text.
del items[0].msg['message-id-hash']
del items[0].msg['x-message-id-hash']
self.assertMultiLineEqual(items[0].msg.as_bytes().decode('utf-8'),
self.text2)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5109107
mailman-3.3.10/src/mailman/app/tests/test_lifecycle.py 0000644 0000000 0000000 00000007774 14542770442 017671 0 ustar 00 # Copyright (C) 2012-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Test the high level list lifecycle API."""
import os
import shutil
import unittest
from mailman.app.lifecycle import (
create_list,
InvalidListNameError,
remove_list,
)
from mailman.interfaces.address import InvalidEmailAddressError
from mailman.interfaces.domain import BadDomainSpecificationError
from mailman.interfaces.listmanager import IListManager
from mailman.testing.helpers import configuration, LogFileMark
from mailman.testing.layers import ConfigLayer
from zope.component import getUtility
class TestLifecycle(unittest.TestCase):
"""Test the high level list lifecycle API."""
layer = ConfigLayer
def test_posting_address_validation(self):
# Creating a mailing list with a bogus address raises an exception.
self.assertRaises(InvalidEmailAddressError,
create_list, 'bogus address')
def test_listname_validation(self):
# Creating a mailing list with invalid characters in the listname
# raises an exception.
self.assertRaises(InvalidListNameError,
create_list, 'my/list@example.com')
@configuration('mailman', listname_chars=r'[a-z0-9-+\]')
def test_bad_config_listname_chars(self):
mark = LogFileMark('mailman.error')
# This list create should succeed but log an error
mlist = create_list('test@example.com')
# Check the error log.
self.assertRegex(
mark.readline(),
r'^.*Bad config\.mailman\.listname_chars setting: '
r'\[a-z0-9-\+\\]: '
'(unterminated character set|'
'unexpected end of regular expression)$'
)
# Check that the list was actually created.
self.assertIs(os.path.isdir(mlist.data_path), True)
@configuration('mailman', listname_chars='[a-z]')
def test_listname_with_minimal_listname_chars(self):
# This only allows letters in the listname. A listname with digits
# Raises an exception.
self.assertRaises(InvalidListNameError,
create_list, 'list1@example.com')
def test_unregistered_domain(self):
# Creating a list with an unregistered domain raises an exception.
self.assertRaises(BadDomainSpecificationError,
create_list, 'test@nodomain.example.org')
@unittest.skipIf(os.getuid() == 0, 'Cannot run as root')
def test_remove_list_error(self):
# An error occurs while deleting the list's data directory.
mlist = create_list('test@example.com')
os.chmod(mlist.data_path, 0)
self.addCleanup(shutil.rmtree, mlist.data_path)
self.assertRaises(OSError, remove_list, mlist)
os.chmod(mlist.data_path, 0o777)
def test_create_no_such_style(self):
mlist = create_list('ant@example.com', style_name='bogus')
# The MailmanList._preferred_language column isn't set so there's no
# valid mapping to an ILanguage. Therefore this call will produce a
# KeyError.
self.assertRaises(KeyError, getattr, mlist, 'preferred_language')
def test_remove_list_without_data_path(self):
mlist = create_list('ant@example.com')
shutil.rmtree(mlist.data_path)
remove_list(mlist)
self.assertIsNone(getUtility(IListManager).get('ant@example.com'))
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5111067
mailman-3.3.10/src/mailman/app/tests/test_membership.py 0000644 0000000 0000000 00000041543 14542770442 020055 0 ustar 00 # Copyright (C) 2011-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Tests of application level membership functions."""
import unittest
from contextlib import ExitStack
from mailman.app.lifecycle import create_list
from mailman.app.membership import (
add_member,
delete_member,
handle_SubscriptionEvent,
)
from mailman.core.constants import system_preferences
from mailman.interfaces.address import InvalidEmailAddressError
from mailman.interfaces.bans import IBanManager
from mailman.interfaces.member import (
AlreadySubscribedError,
DeliveryMode,
MemberRole,
MembershipIsBannedError,
NotAMemberError,
SubscriptionEvent,
)
from mailman.interfaces.subscriptions import RequestRecord
from mailman.interfaces.usermanager import IUserManager
from mailman.testing.layers import ConfigLayer
from mailman.utilities.datetime import now
from unittest.mock import patch
from zope.component import getUtility
class TestAddMember(unittest.TestCase):
layer = ConfigLayer
def setUp(self):
self._mlist = create_list('test@example.com')
def test_add_member_new_user(self):
# Test subscribing a user to a mailing list when the email address has
# not yet been associated with a user.
member = add_member(
self._mlist,
RequestRecord('aperson@example.com', 'Anne Person',
DeliveryMode.regular,
system_preferences.preferred_language))
self.assertEqual(member.address.email, 'aperson@example.com')
self.assertEqual(member.list_id, 'test.example.com')
self.assertEqual(member.role, MemberRole.member)
def test_add_member_existing_user(self):
# Test subscribing a user to a mailing list when the email address has
# already been associated with a user.
user_manager = getUtility(IUserManager)
user_manager.create_user('aperson@example.com', 'Anne Person')
member = add_member(
self._mlist,
RequestRecord('aperson@example.com', 'Anne Person',
DeliveryMode.regular,
system_preferences.preferred_language))
self.assertEqual(member.address.email, 'aperson@example.com')
self.assertEqual(member.list_id, 'test.example.com')
def test_add_member_banned(self):
# Test that members who are banned by specific address cannot
# subscribe to the mailing list.
IBanManager(self._mlist).ban('anne@example.com')
with self.assertRaises(MembershipIsBannedError) as cm:
add_member(
self._mlist,
RequestRecord('anne@example.com', 'Anne Person',
DeliveryMode.regular,
system_preferences.preferred_language))
self.assertEqual(
str(cm.exception),
'anne@example.com is not allowed to subscribe to test@example.com')
def test_add_member_globally_banned(self):
# Test that members who are banned by specific address cannot
# subscribe to the mailing list.
IBanManager(None).ban('anne@example.com')
self.assertRaises(
MembershipIsBannedError,
add_member, self._mlist,
RequestRecord('anne@example.com', 'Anne Person',
DeliveryMode.regular,
system_preferences.preferred_language))
def test_add_posting_address(self):
# Test that we can't add the list posting address.
self.assertRaises(
InvalidEmailAddressError,
add_member, self._mlist,
RequestRecord(self._mlist.posting_address, 'The List',
DeliveryMode.regular,
system_preferences.preferred_language))
def test_add_posting_address_moderator(self):
# Test that we can't add the list posting address.
self.assertRaises(
InvalidEmailAddressError,
add_member, self._mlist,
RequestRecord(self._mlist.posting_address, 'The List',
DeliveryMode.regular,
system_preferences.preferred_language),
MemberRole.moderator)
def test_add_posting_address_owner(self):
# Test that we can't add the list posting address.
self.assertRaises(
InvalidEmailAddressError,
add_member, self._mlist,
RequestRecord(self._mlist.posting_address, 'The List',
DeliveryMode.regular,
system_preferences.preferred_language),
MemberRole.owner)
def test_add_posting_address_nonmember(self):
# Test that we can add the list posting address as a nonmember.
request_record = RequestRecord(self._mlist.posting_address,
'The List',
DeliveryMode.regular,
system_preferences.preferred_language)
member = add_member(self._mlist, request_record, MemberRole.nonmember)
self.assertEqual(member.address.email, self._mlist.posting_address)
self.assertEqual(member.list_id, 'test.example.com')
self.assertEqual(member.role, MemberRole.nonmember)
def test_add_member_banned_from_different_list(self):
# Test that members who are banned by on a different list can still be
# subscribed to other mlists.
sample_list = create_list('sample@example.com')
IBanManager(sample_list).ban('anne@example.com')
member = add_member(
self._mlist,
RequestRecord('anne@example.com', 'Anne Person',
DeliveryMode.regular,
system_preferences.preferred_language))
self.assertEqual(member.address.email, 'anne@example.com')
def test_add_member_banned_by_pattern(self):
# Addresses matching regexp ban patterns cannot subscribe.
IBanManager(self._mlist).ban('^.*@example.com')
self.assertRaises(
MembershipIsBannedError,
add_member, self._mlist,
RequestRecord('anne@example.com', 'Anne Person',
DeliveryMode.regular,
system_preferences.preferred_language))
def test_add_member_globally_banned_by_pattern(self):
# Addresses matching global regexp ban patterns cannot subscribe.
IBanManager(None).ban('^.*@example.com')
self.assertRaises(
MembershipIsBannedError,
add_member, self._mlist,
RequestRecord('anne@example.com', 'Anne Person',
DeliveryMode.regular,
system_preferences.preferred_language))
def test_add_member_banned_from_different_list_by_pattern(self):
# Addresses matching regexp ban patterns on one list can still
# subscribe to other mailing lists.
sample_list = create_list('sample@example.com')
IBanManager(sample_list).ban('^.*@example.com')
member = add_member(
self._mlist,
RequestRecord('anne@example.com', 'Anne Person',
DeliveryMode.regular,
system_preferences.preferred_language))
self.assertEqual(member.address.email, 'anne@example.com')
def test_add_member_moderator(self):
# Test adding a moderator to a mailing list.
member = add_member(
self._mlist,
RequestRecord('aperson@example.com', 'Anne Person',
DeliveryMode.regular,
system_preferences.preferred_language),
MemberRole.moderator)
self.assertEqual(member.address.email, 'aperson@example.com')
self.assertEqual(member.list_id, 'test.example.com')
self.assertEqual(member.role, MemberRole.moderator)
def test_add_member_twice(self):
# Adding a member with the same role twice causes an
# AlreadySubscribedError to be raised.
add_member(
self._mlist,
RequestRecord('aperson@example.com', 'Anne Person',
DeliveryMode.regular,
system_preferences.preferred_language),
MemberRole.member)
with self.assertRaises(AlreadySubscribedError) as cm:
add_member(
self._mlist,
RequestRecord('aperson@example.com', 'Anne Person',
DeliveryMode.regular,
system_preferences.preferred_language),
MemberRole.member)
self.assertEqual(cm.exception.fqdn_listname, 'test@example.com')
self.assertEqual(cm.exception.email, 'aperson@example.com')
self.assertEqual(cm.exception.role, MemberRole.member)
def test_add_member_with_different_roles(self):
# Adding a member twice with different roles is okay.
member_1 = add_member(
self._mlist,
RequestRecord('aperson@example.com', 'Anne Person',
DeliveryMode.regular,
system_preferences.preferred_language),
MemberRole.member)
member_2 = add_member(
self._mlist,
RequestRecord('aperson@example.com', 'Anne Person',
DeliveryMode.regular,
system_preferences.preferred_language),
MemberRole.owner)
self.assertEqual(member_1.list_id, member_2.list_id)
self.assertEqual(member_1.address, member_2.address)
self.assertEqual(member_1.user, member_2.user)
self.assertNotEqual(member_1.member_id, member_2.member_id)
self.assertEqual(member_1.role, MemberRole.member)
self.assertEqual(member_2.role, MemberRole.owner)
def test_add_member_with_mixed_case_email(self):
# LP: #1425359 - Mailman is case-perserving, case-insensitive. This
# test subscribes the lower case address and ensures the original
# mixed case address can't be subscribed.
email = 'APerson@example.com'
add_member(
self._mlist,
RequestRecord(email.lower(), 'Ann Person',
DeliveryMode.regular,
system_preferences.preferred_language))
with self.assertRaises(AlreadySubscribedError) as cm:
add_member(
self._mlist,
RequestRecord(email, 'Ann Person',
DeliveryMode.regular,
system_preferences.preferred_language))
self.assertEqual(cm.exception.email, email)
def test_add_member_with_lower_case_email(self):
# LP: #1425359 - Mailman is case-perserving, case-insensitive. This
# test subscribes the mixed case address and ensures the lower cased
# address can't be added.
email = 'APerson@example.com'
add_member(
self._mlist,
RequestRecord(email, 'Ann Person',
DeliveryMode.regular,
system_preferences.preferred_language))
with self.assertRaises(AlreadySubscribedError) as cm:
add_member(
self._mlist,
RequestRecord(email.lower(), 'Ann Person',
DeliveryMode.regular,
system_preferences.preferred_language))
self.assertEqual(cm.exception.email, email.lower())
def test_delete_nonmembers_on_adding_member(self):
# GL: #237 - When a new address is subscribed, any existing nonmember
# subscriptions for this address; or any addresses also controlled by
# this user, are deleted.
anne_nonmember = add_member(
self._mlist,
RequestRecord('aperson@example.com', 'Anne Person',
DeliveryMode.regular,
system_preferences.preferred_language),
MemberRole.nonmember)
# Add a few other validated addresses to this user, and subscribe them
# as nonmembers.
for email in ('anne.person@example.com', 'a.person@example.com'):
address = anne_nonmember.user.register(email)
address.verified_on = now()
self._mlist.subscribe(address, MemberRole.nonmember)
# There are now three nonmembers.
self.assertEqual(
{address.email for address in self._mlist.nonmembers.addresses},
{'aperson@example.com',
'anne.person@example.com',
'a.person@example.com',
})
# Let's now add one of Anne's addresses as a member. This deletes all
# of Anne's nonmember memberships.
anne_member = add_member(
self._mlist,
RequestRecord('a.person@example.com', 'Anne Person',
DeliveryMode.regular,
system_preferences.preferred_language),
MemberRole.member)
self.assertEqual(self._mlist.nonmembers.member_count, 0)
members = list(self._mlist.members.members)
self.assertEqual(len(members), 1)
self.assertEqual(members[0], anne_member)
class TestDeleteMember(unittest.TestCase):
layer = ConfigLayer
def setUp(self):
self._mlist = create_list('test@example.com')
def test_delete_member_not_a_member(self):
# Try to delete an address which is not a member of the mailing list.
with self.assertRaises(NotAMemberError) as cm:
delete_member(self._mlist, 'noperson@example.com')
self.assertEqual(
str(cm.exception),
'noperson@example.com is not a member of test@example.com')
class TestHandleSubscriptionEvent(unittest.TestCase):
layer = ConfigLayer
def setUp(self):
self._mlist = create_list('test@example.com')
self._user_manager = getUtility(IUserManager)
self._handler = handle_SubscriptionEvent
def test_sending_welcome_message(self):
# Test that welcome message is sent when the mlist is configured to do
# so.
self._mlist.admin_notify_mchanges = False
self._mlist.send_welcome_message = True
with ExitStack() as stack:
mocked_send_admin = stack.enter_context(
patch('mailman.app.membership.send_admin_subscription_notice'))
mocked_send_user = stack.enter_context(
patch('mailman.app.membership.send_welcome_message'))
anne = self._user_manager.create_address('anne@example.com')
member = self._mlist.subscribe(anne)
self._handler(SubscriptionEvent(
self._mlist, member, send_welcome_message=None))
self.assertTrue(mocked_send_user.called)
mocked_send_user.assert_called_with(
self._mlist, member, member.preferred_language)
self.assertFalse(mocked_send_admin.called)
# Now, let's disable sending of the message.
mocked_send_user.reset_mock()
self._handler(SubscriptionEvent(
self._mlist, member, send_welcome_message=False))
self.assertFalse(mocked_send_user.called)
# Now, if mlist is configured not to but event says yes.
mocked_send_user.reset_mock()
self._mlist.send_welcome_message = False
self._handler(SubscriptionEvent(
self._mlist, member, send_welcome_message=True))
self.assertTrue(mocked_send_user.called)
def test_admin_subscription_notice(self):
# Test admins are notified of the subscription.
self._mlist.admin_notify_mchanges = True
self._mlist.send_welcome_message = False
with ExitStack() as stack:
mocked_send_admin = stack.enter_context(
patch('mailman.app.membership.send_admin_subscription_notice'))
stack.enter_context(
patch('mailman.app.membership.send_welcome_message'))
anne = self._user_manager.create_address('anne@example.com')
member = self._mlist.subscribe(anne)
self._handler(SubscriptionEvent(
self._mlist, member, send_welcome_message=None))
self.assertTrue(mocked_send_admin.called)
mocked_send_admin.assert_called_with(
self._mlist, anne.email, anne.display_name)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5113754
mailman-3.3.10/src/mailman/app/tests/test_moderation.py 0000644 0000000 0000000 00000036235 14542770442 020065 0 ustar 00 # Copyright (C) 2011-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Moderation tests."""
import unittest
from mailman.app.lifecycle import create_list
from mailman.app.moderator import (
handle_message,
handle_unsubscription,
hold_message,
hold_unsubscription,
)
from mailman.chains.hold import HeldMessagePendable
from mailman.interfaces.action import Action
from mailman.interfaces.member import MemberRole
from mailman.interfaces.messages import IMessageStore
from mailman.interfaces.pending import IPendings
from mailman.interfaces.requests import IListRequests
from mailman.interfaces.subscriptions import ISubscriptionManager
from mailman.interfaces.usermanager import IUserManager
from mailman.runners.incoming import IncomingRunner
from mailman.runners.outgoing import OutgoingRunner
from mailman.runners.pipeline import PipelineRunner
from mailman.testing.helpers import (
get_queue_messages,
make_testable_runner,
set_preferred,
specialized_message_from_string as mfs,
)
from mailman.testing.layers import SMTPLayer
from mailman.utilities.datetime import now
from zope.component import getUtility
class TestModeration(unittest.TestCase):
"""Test moderation functionality."""
layer = SMTPLayer
def setUp(self):
self._mlist = create_list('test@example.com')
self._request_db = IListRequests(self._mlist)
self._msg = mfs("""\
From: anne@example.com
To: test@example.com
Subject: hold me
Message-ID:
""")
self._in = make_testable_runner(IncomingRunner, 'in')
self._pipeline = make_testable_runner(PipelineRunner, 'pipeline')
self._out = make_testable_runner(OutgoingRunner, 'out')
def test_accepted_message_gets_posted(self):
# A message that is accepted by the moderator should get posted to the
# mailing list. LP: #827697
msgdata = dict(listname='test@example.com',
recipients=['bart@example.com'])
request_id = hold_message(self._mlist, self._msg, msgdata)
handle_message(self._mlist, request_id, Action.accept)
self._in.run()
self._pipeline.run()
self._out.run()
messages = list(SMTPLayer.smtpd.messages)
self.assertEqual(len(messages), 1)
message = messages[0]
# We don't need to test the entire posted message, just the bits that
# prove it got sent out.
self.assertIn('x-mailman-version', message)
self.assertIn('x-peer', message)
# The X-Mailman-Approved-At header has local timezone information in
# it, so test that separately.
self.assertEqual(message['x-mailman-approved-at'][:-5],
'Mon, 01 Aug 2005 07:49:23 ')
del message['x-mailman-approved-at']
# The Message-ID matches the original.
self.assertEqual(message['message-id'], '')
# Anne sent the message and the mailing list received it.
self.assertEqual(message['from'], 'anne@example.com')
self.assertEqual(message['to'], 'test@example.com')
# The Subject header has the list's prefix.
self.assertEqual(message['subject'], '[Test] hold me')
# The list's -bounce address is the actual sender, and Bart is the
# only actual recipient. These headers are added by the testing
# framework and don't show up in production. They match the RFC 5321
# envelope.
self.assertEqual(message['x-mailfrom'], 'test-bounces@example.com')
self.assertEqual(message['x-rcptto'], 'bart@example.com')
def test_missing_accepted_message_gets_posted(self):
# A message that is accepted by the moderator should get posted to the
# mailing list. LP: #827697
msgdata = dict(listname='test@example.com',
recipients=['bart@example.com'])
request_id = hold_message(self._mlist, self._msg, msgdata)
message_store = getUtility(IMessageStore)
message_store.delete_message('')
handle_message(self._mlist, request_id, Action.accept)
self._in.run()
self._pipeline.run()
self._out.run()
messages = list(SMTPLayer.smtpd.messages)
self.assertEqual(len(messages), 1)
message = messages[0]
# We don't need to test the entire posted message, just the bits that
# prove it got sent out.
self.assertIn('x-mailman-version', message)
self.assertIn('x-peer', message)
# The X-Mailman-Approved-At header has local timezone information in
# it, so test that separately.
self.assertEqual(message['x-mailman-approved-at'][:-5],
'Mon, 01 Aug 2005 07:49:23 ')
del message['x-mailman-approved-at']
# The Message-ID matches the original.
self.assertEqual(message['message-id'], '')
# Anne sent the message and the mailing list received it.
self.assertEqual(message['from'], 'anne@example.com')
self.assertEqual(message['to'], 'test@example.com')
# The Subject header has the list's prefix.
self.assertEqual(message['subject'], '[Test] hold me')
# The list's -bounce address is the actual sender, and Bart is the
# only actual recipient. These headers are added by the testing
# framework and don't show up in production. They match the RFC 5321
# envelope.
self.assertEqual(message['x-mailfrom'], 'test-bounces@example.com')
self.assertEqual(message['x-rcptto'], 'bart@example.com')
def test_hold_action_alias_for_defer(self):
# In handle_message(), the 'hold' action is the same as 'defer' for
# purposes of this API.
request_id = hold_message(self._mlist, self._msg)
handle_message(self._mlist, request_id, Action.defer)
# The message is still in the pending requests.
key, data = self._request_db.get_request(request_id)
self.assertEqual(key, '')
handle_message(self._mlist, request_id, Action.hold)
key, data = self._request_db.get_request(request_id)
self.assertEqual(key, '')
def test_lp_1031391(self):
# LP: #1031391 msgdata['received_time'] gets added by the LMTP server.
# The value is a datetime. If this message gets held, it will break
# pending requests since they require string keys and values.
received_time = now()
msgdata = dict(received_time=received_time)
request_id = hold_message(self._mlist, self._msg, msgdata)
key, data = self._request_db.get_request(request_id)
self.assertEqual(data['received_time'], received_time)
def test_forward(self):
# We can forward the message to an email address.
request_id = hold_message(self._mlist, self._msg)
handle_message(self._mlist, request_id, Action.discard,
forward=['zack@example.com'])
# The forwarded message lives in the virgin queue.
items = get_queue_messages('virgin', expected_count=1)
self.assertEqual(str(items[0].msg['subject']),
'Forward of moderated message')
self.assertEqual(list(items[0].msgdata['recipients']),
['zack@example.com'])
def test_missing_forward(self):
# We can forward the message to an email address.
request_id = hold_message(self._mlist, self._msg)
message_store = getUtility(IMessageStore)
message_store.delete_message('')
handle_message(self._mlist, request_id, Action.discard,
forward=['zack@example.com'])
# The forwarded message lives in the virgin queue.
items = get_queue_messages('virgin', expected_count=1)
self.assertEqual(str(items[0].msg['subject']),
'Forward of moderated message')
self.assertEqual(list(items[0].msgdata['recipients']),
['zack@example.com'])
payload = items[0].msg.get_payload()[0]
self.assertIsNotNone(payload)
self.assertEqual('', payload['message-id'])
def test_survive_a_deleted_message(self):
# When the message that should be deleted is not found in the store,
# no error is raised.
request_id = hold_message(self._mlist, self._msg)
message_store = getUtility(IMessageStore)
message_store.delete_message('')
handle_message(self._mlist, request_id, Action.discard)
self.assertEqual(self._request_db.count, 0)
def test_handled_message_removed_from_store(self):
# The message is removed from the store and the pendings db when it's
# been disposed of.
request_id = hold_message(self._mlist, self._msg)
# Get the hash for this pending request.
hash = list(self._request_db.held_requests)[0].data_hash
handle_message(self._mlist, request_id, Action.discard)
self.assertEqual(self._request_db.count, 0)
message = getUtility(IMessageStore).get_message_by_id('')
self.assertIsNone(message)
self.assertIsNone(getUtility(IPendings).confirm(hash))
def test_handled_cross_posted_message_not_removed(self):
# A cross posted message is not removed when handled on the first list.
mlist2 = create_list('test2@example.com')
request_db2 = IListRequests(mlist2)
request_id = hold_message(self._mlist, self._msg)
request_id2 = hold_message(mlist2, self._msg)
# Get the hashes for these pending requests.
hash0 = list(self._request_db.held_requests)[0].data_hash
hash1 = list(request_db2.held_requests)[0].data_hash
# Handle the first list's message.
handle_message(self._mlist, request_id, Action.discard)
# There's now only the request for list2.
self.assertEqual(self._request_db.count, 0)
self.assertEqual(request_db2.count, 1)
message = getUtility(IMessageStore).get_message_by_id('')
self.assertIsNotNone(message)
self.assertIsNone(getUtility(IPendings).confirm(hash0))
self.assertIsNotNone(getUtility(IPendings).confirm(hash1,
expunge=False))
# Handle the second list's message.
handle_message(mlist2, request_id2, Action.discard)
# Now the request and message are gone.
self.assertEqual(request_db2.count, 0)
message = getUtility(IMessageStore).get_message_by_id('')
self.assertIsNone(message)
self.assertIsNone(getUtility(IPendings).confirm(hash1))
def test_all_pendings_removed(self):
# A held message pends two tokens, One for the moderator and one for
# the user. Ensure both are removed when message is handled.
request_id = hold_message(self._mlist, self._msg)
# The hold chain does more.
pendings = getUtility(IPendings)
user_hash = pendings.add(HeldMessagePendable(
id=request_id, list_id=self._mlist.list_id))
# Get the hash for this pending request.
hash = list(self._request_db.held_requests)[0].data_hash
handle_message(self._mlist, request_id, Action.discard)
self.assertEqual(self._request_db.count, 0)
self.assertIsNone(pendings.confirm(hash))
self.assertIsNone(pendings.confirm(user_hash))
def test_all_pendings_removed_old(self):
# This is different from the testcase above in that it
# handles the old style HeldMessagePendable where the
# list_id is not added to pendedkeyvalue. It is only
# meant so that new versions can handle old pendables.
request_id = hold_message(self._mlist, self._msg)
# The hold chain does more.
pendings = getUtility(IPendings)
user_hash = pendings.add(HeldMessagePendable(id=request_id))
# Get the hash for this pending request.
hash = list(self._request_db.held_requests)[0].data_hash
handle_message(self._mlist, request_id, Action.discard)
self.assertEqual(self._request_db.count, 0)
self.assertIsNone(pendings.confirm(hash))
self.assertIsNone(pendings.confirm(user_hash))
def test_rejection_subject_decoded(self):
# Test that a rejected message with an RFC 2047 encoded Subject: has
# the Subject: decoded for the rejection
del self._msg['subject']
self._msg['Subject'] = '=?utf-8?q?hold_me?='
request_id = hold_message(self._mlist, self._msg)
handle_message(self._mlist, request_id, Action.reject)
# The rejected message lives in the virgin queue.
items = get_queue_messages('virgin', expected_count=1)
rejection = items[0].msg
self.assertEqual(str(rejection['subject']),
'Request to mailing list "Test" rejected')
self.assertIn('Posting of your message titled "hold me"',
rejection.get_payload())
class TestUnsubscription(unittest.TestCase):
"""Test unsubscription requests."""
layer = SMTPLayer
def setUp(self):
self._mlist = create_list('test@example.com')
self._manager = ISubscriptionManager(self._mlist)
def test_unsubscribe_defer(self):
# When unsubscriptions must be approved by the moderator, but the
# moderator defers this decision.
user_manager = getUtility(IUserManager)
anne = user_manager.create_address('anne@example.org', 'Anne Person')
token, token_owner, member = self._manager.register(
anne, pre_verified=True, pre_confirmed=True, pre_approved=True)
self.assertIsNone(token)
self.assertEqual(member.address.email, 'anne@example.org')
bart = user_manager.create_user('bart@example.com', 'Bart User')
address = set_preferred(bart)
self._mlist.subscribe(address, MemberRole.moderator)
# Now hold and handle an unsubscription request.
token = hold_unsubscription(self._mlist, 'anne@example.org')
handle_unsubscription(self._mlist, token, Action.defer)
items = get_queue_messages('virgin', expected_count=2)
# Find the moderator message.
for item in items:
if item.msg['to'] == 'test-owner@example.com':
break
else:
raise AssertionError('No moderator email found')
self.assertEqual(
item.msgdata['recipients'], {'test-owner@example.com'})
self.assertEqual(
item.msg['subject'],
'New unsubscription request from Test by anne@example.org')
def test_bogus_token(self):
# Try to handle an unsubscription with a bogus token.
self.assertRaises(LookupError, self._manager.confirm, None)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5116527
mailman-3.3.10/src/mailman/app/tests/test_notifications.py 0000644 0000000 0000000 00000037757 14542770442 020607 0 ustar 00
# Copyright (C) 2012-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Test notifications."""
import os
import re
import unittest
from contextlib import ExitStack
from mailman.app.lifecycle import create_list
from mailman.app.notifications import send_goodbye_message
from mailman.config import config
from mailman.interfaces.languages import ILanguageManager
from mailman.interfaces.member import MemberRole
from mailman.interfaces.subscriptions import ISubscriptionManager
from mailman.interfaces.template import ITemplateManager
from mailman.interfaces.usermanager import IUserManager
from mailman.testing.helpers import (
get_queue_messages,
set_preferred,
subscribe,
)
from mailman.testing.layers import ConfigLayer
from mailman.utilities.datetime import now
from tempfile import TemporaryDirectory
from zope.component import getUtility
class TestNotifications(unittest.TestCase):
"""Test notifications."""
layer = ConfigLayer
maxDiff = None
def setUp(self):
resources = ExitStack()
self.addCleanup(resources.close)
self.var_dir = resources.enter_context(TemporaryDirectory())
self._mlist = create_list('test@example.com')
self._mlist.display_name = 'Test List'
getUtility(ITemplateManager).set(
'list:user:notice:welcome', self._mlist.list_id,
'mailman:///welcome.txt')
config.push('template config', """\
[paths.testing]
template_dir: {}/templates
""".format(self.var_dir))
resources.callback(config.pop, 'template config')
# Populate the template directories with a few fake templates.
path = os.path.join(self.var_dir, 'templates', 'site', 'en')
os.makedirs(path)
full_path = os.path.join(path, 'list:user:notice:welcome.txt')
with open(full_path, 'w', encoding='utf-8') as fp:
print("""\
Welcome to the $list_name mailing list.
Posting address: $fqdn_listname
Help and other requests: $list_requests
Your name: $user_name
Your address: $user_address""", file=fp)
# Write a goodbye message.
full_path = os.path.join(path, 'list:user:notice:goodbye.txt')
with open(full_path, 'w', encoding='utf-8') as fp:
print('$user_email just left the $list_name mailing list!',
file=fp)
# Write a list-specific welcome message.
path = os.path.join(self.var_dir, 'templates', 'lists',
'test@example.com', 'xx')
os.makedirs(path)
full_path = os.path.join(path, 'list:user:notice:welcome.txt')
with open(full_path, 'w', encoding='utf-8') as fp:
print('You just joined the $list_name mailing list!', file=fp)
# Write a list-specific welcome message with non-ascii.
path = os.path.join(self.var_dir, 'templates', 'lists',
'test@example.com', 'yy')
os.makedirs(path)
full_path = os.path.join(path, 'list:user:notice:welcome.txt')
with open(full_path, 'w', encoding='utf-8') as fp:
print('Yöu just joined the $list_name mailing list!', file=fp)
# Write a list-specific address confirmation message with non-ascii.
full_path = os.path.join(path, 'list:user:action:subscribe.txt')
with open(full_path, 'w', encoding='utf-8') as fp:
print('Wé need your confirmation', file=fp)
def test_welcome_message(self):
subscribe(self._mlist, 'Anne', email='anne@example.com')
# Now there's one message in the virgin queue.
items = get_queue_messages('virgin', expected_count=1)
message = items[0].msg
self.assertEqual(str(message['subject']),
'Welcome to the "Test List" mailing list')
self.assertMultiLineEqual(message.get_payload(), """\
Welcome to the Test List mailing list.
Posting address: test@example.com
Help and other requests: test-request@example.com
Your name: Anne Person
Your address: anne@example.com
""")
def test_more_specific_welcome_message_nonenglish(self):
# The welcome message url can contain placeholders for the fqdn list
# name and language.
getUtility(ITemplateManager).set(
'list:user:notice:welcome', self._mlist.list_id,
'mailman:///$listname/$language/welcome.txt')
# Add the xx language and subscribe Anne using it.
manager = getUtility(ILanguageManager)
manager.add('xx', 'us-ascii', 'Xlandia')
# We can't use the subscribe() helper because that would send the
# welcome message before we set the member's preferred language.
address = getUtility(IUserManager).create_address(
'anne@example.com', 'Anne Person')
address.preferences.preferred_language = 'xx'
self._mlist.subscribe(address)
# Now there's one message in the virgin queue.
items = get_queue_messages('virgin', expected_count=1)
message = items[0].msg
self.assertEqual(str(message['subject']),
'Welcome to the "Test List" mailing list')
self.assertMultiLineEqual(
message.get_payload(),
'You just joined the Test List mailing list!')
def test_more_specific_messages_nonascii(self):
# The welcome message url can contain placeholders for the fqdn list
# name and language.
getUtility(ITemplateManager).set(
'list:user:notice:welcome', self._mlist.list_id,
'mailman:///$listname/$language/welcome.txt')
# Add the yy language and subscribe Anne using it.
getUtility(ILanguageManager).add('yy', 'utf-8', 'Ylandia')
# We can't use the subscribe() helper because that would send the
# welcome message before we set the member's preferred language.
address = getUtility(IUserManager).create_address(
'anne@example.com', 'Anné Person')
address.preferences.preferred_language = 'yy'
# Get the admin notice too.
self._mlist.admin_notify_mchanges = True
# Make another non-ascii replacement.
self._mlist.display_name = 'Tést List'
# And set the list's language.
self._mlist.preferred_language = 'yy'
self._mlist.subscribe(address)
# Now there are two messages in the virgin queue.
items = get_queue_messages('virgin', expected_count=2)
if str(items[0].msg['subject']).startswith('Welcome'):
welcome = items[0].msg
admin_notice = items[1].msg
else:
welcome = items[1].msg
admin_notice = items[0].msg
self.assertEqual(str(welcome['subject']),
'Welcome to the "Tést List" mailing list')
self.assertMultiLineEqual(
welcome.get_payload(decode=True).decode('utf-8'),
'Yöu just joined the Tést List mailing list!')
# Ensure the message is single part and properly encoded.
raw_payload = welcome.get_payload()
self.assertEqual(
raw_payload.encode('us-ascii', 'replace').decode('us-ascii'),
raw_payload)
self.assertEqual(str(admin_notice['subject']),
'Tést List subscription notification')
self.assertMultiLineEqual(
admin_notice.get_payload(decode=True).decode('utf-8'),
'=?utf-8?q?Ann=C3=A9_Person?= has been'
' successfully subscribed to Tést List.\n')
# Ensure the message is single part and properly encoded.
raw_payload = admin_notice.get_payload()
self.assertEqual(
raw_payload.encode('us-ascii', 'replace').decode('us-ascii'),
raw_payload)
def test_confirmation_message(self):
# Create an address to subscribe.
address = getUtility(IUserManager).create_address(
'anne@example.com', 'Anne Person')
# Register the address with the list to create a confirmation notice.
ISubscriptionManager(self._mlist).register(address)
# Now there's one message in the virgin queue.
items = get_queue_messages('virgin', expected_count=1)
message = items[0].msg
self.assertTrue(str(message['subject']).startswith('Your confirm'))
token = re.sub(r'^.*\+([^+@]*)@.*$', r'\1', str(message['from']))
self.assertMultiLineEqual(
message.get_payload(), """\
Email Address Registration Confirmation
Hello, this is the GNU Mailman server at example.com.
We have received a registration request for the email address
anne@example.com
Before you can start using GNU Mailman at this site, you must first confirm
that this is your email address. You can do this by replying to this message.
Or you should include the following line -- and only the following
line -- in a message to test-request@example.com:
confirm {}
Note that simply sending a `reply' to this message should work from
most mail readers.
If you do not wish to register this email address, simply disregard this
message. If you think you are being maliciously subscribed to the list, or
have any other questions, you may contact
test-owner@example.com
""".format(token))
def test_nonascii_confirmation_message(self):
# Add the 'yy' language and set it
getUtility(ILanguageManager).add('yy', 'utf-8', 'Ylandia')
self._mlist.preferred_language = 'yy'
# Create an address to subscribe.
address = getUtility(IUserManager).create_address(
'anne@example.com', 'Anne Person')
# Register the address with the list to create a confirmation notice.
ISubscriptionManager(self._mlist).register(address)
# Now there's one message in the virgin queue.
items = get_queue_messages('virgin', expected_count=1)
message = items[0].msg
self.assertTrue(str(message['subject']).startswith('Your confirm'))
self.assertMultiLineEqual(
message.get_payload(decode=True).decode('utf-8'),
'Wé need your confirmation\n')
def test_goodbye_message(self):
member = subscribe(self._mlist, 'Anne', email='anne@example.com')
# Now there's one message in the virgin queue; get it and clear it.
items = get_queue_messages('virgin', expected_count=1)
# Send anne an unsubscribe message.
language = getUtility(ILanguageManager).get('en')
send_goodbye_message(self._mlist, member.address.email, language)
# There's a new message in the virgin queue.
items = get_queue_messages('virgin', expected_count=1)
message = items[0].msg
self.assertEqual(str(message['subject']),
'You have been unsubscribed from the Test List '
'mailing list')
self.assertMultiLineEqual(
message.get_payload(),
'anne@example.com just left the Test List mailing list!')
def test_no_welcome_message_to_owners(self):
# Welcome messages go only to mailing list members, not to owners.
subscribe(self._mlist, 'Anne', MemberRole.owner, 'anne@example.com')
# There is no welcome message in the virgin queue.
get_queue_messages('virgin', expected_count=0)
def test_no_welcome_message_to_nonmembers(self):
# Welcome messages go only to mailing list members, not to nonmembers.
subscribe(self._mlist, 'Anne', MemberRole.nonmember,
'anne@example.com')
# There is no welcome message in the virgin queue.
get_queue_messages('virgin', expected_count=0)
def test_no_welcome_message_to_moderators(self):
# Welcome messages go only to mailing list members, not to moderators.
subscribe(self._mlist, 'Anne', MemberRole.moderator,
'anne@example.com')
# There is no welcome message in the virgin queue.
get_queue_messages('virgin', expected_count=0)
def test_member_susbcribed_address_has_display_name(self):
address = getUtility(IUserManager).create_address(
'anne@example.com', 'Anne Person')
address.verified_on = now()
self._mlist.subscribe(address)
items = get_queue_messages('virgin', expected_count=1)
message = items[0].msg
self.assertEqual(message['to'], 'Anne Person ')
def test_member_susbcribed_address_has_display_name_not_msgdata(self):
address = getUtility(IUserManager).create_address(
'anne@example.com', 'Anne Person')
address.verified_on = now()
self._mlist.subscribe(address)
items = get_queue_messages('virgin', expected_count=1)
message = items[0].msg
msgdata = items[0].msgdata
self.assertEqual(message['to'], 'Anne Person ')
self.assertEqual(list(msgdata['recipients']), ['anne@example.com'])
def test_member_subscribed_address_has_no_display_name(self):
address = getUtility(IUserManager).create_address('anne@example.com')
address.verified_on = now()
self._mlist.subscribe(address)
items = get_queue_messages('virgin', expected_count=1)
message = items[0].msg
self.assertEqual(message['to'], 'anne@example.com')
def test_member_is_user_and_has_display_name(self):
user = getUtility(IUserManager).create_user(
'anne@example.com', 'Anne Person')
set_preferred(user)
self._mlist.subscribe(user)
items = get_queue_messages('virgin', expected_count=1)
message = items[0].msg
self.assertEqual(message['to'], 'Anne Person ')
def test_member_is_user_and_has_no_display_name(self):
user = getUtility(IUserManager).create_user('anne@example.com')
set_preferred(user)
self._mlist.subscribe(user)
items = get_queue_messages('virgin', expected_count=1)
message = items[0].msg
self.assertEqual(message['to'], 'anne@example.com')
def test_member_has_linked_user_display_name(self):
user = getUtility(IUserManager).create_user(
'anne@example.com', 'Anne Person')
set_preferred(user)
address = getUtility(IUserManager).create_address('anne2@example.com')
address.verified_on = now()
user.link(address)
self._mlist.subscribe(address)
items = get_queue_messages('virgin', expected_count=1)
message = items[0].msg
self.assertEqual(message['to'], 'Anne Person ')
def test_member_has_no_linked_display_name(self):
user = getUtility(IUserManager).create_user('anne@example.com')
set_preferred(user)
address = getUtility(IUserManager).create_address('anne2@example.com')
address.verified_on = now()
user.link(address)
self._mlist.subscribe(address)
items = get_queue_messages('virgin', expected_count=1)
message = items[0].msg
self.assertEqual(message['to'], 'anne2@example.com')
def test_member_has_address_and_user_display_name(self):
user = getUtility(IUserManager).create_user(
'anne@example.com', 'Anne Person')
set_preferred(user)
address = getUtility(IUserManager).create_address(
'anne2@example.com', 'Anne X Person')
address.verified_on = now()
user.link(address)
self._mlist.subscribe(address)
items = get_queue_messages('virgin', expected_count=1)
message = items[0].msg
self.assertEqual(message['to'], 'Anne X Person ')
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5119853
mailman-3.3.10/src/mailman/app/tests/test_subscriptions.py 0000644 0000000 0000000 00000136533 14542770442 020635 0 ustar 00 # Copyright (C) 2011-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Tests for the subscription service."""
import sys
import unittest
from contextlib import suppress
from datetime import datetime
from lazr.config import as_timedelta
from mailman.app.lifecycle import create_list
from mailman.app.membership import delete_member
from mailman.app.subscriptions import SubscriptionWorkflow
from mailman.config import config
from mailman.interfaces.address import InvalidEmailAddressError
from mailman.interfaces.bans import IBanManager
from mailman.interfaces.mailinglist import SubscriptionPolicy
from mailman.interfaces.member import (
AlreadySubscribedError,
DeliveryMode,
DeliveryStatus,
MemberRole,
MembershipIsBannedError,
)
from mailman.interfaces.pending import IPendings
from mailman.interfaces.subscriptions import ISubscriptionManager, TokenOwner
from mailman.interfaces.usermanager import IUserManager
from mailman.testing.helpers import (
configuration,
get_queue_messages,
LogFileMark,
set_preferred,
)
from mailman.testing.layers import ConfigLayer
from mailman.utilities.datetime import now
from sqlalchemy import text
from unittest.mock import patch
from zope.component import getUtility
class TestSubscriptionWorkflow(unittest.TestCase):
layer = ConfigLayer
maxDiff = None
def setUp(self):
self._mlist = create_list('test@example.com')
self._mlist.admin_immed_notify = False
self._anne = 'anne@example.com'
self._user_manager = getUtility(IUserManager)
self._expected_pendings_count = 0
def tearDown(self):
# There usually should be no pending after all is said and done, but
# some tests don't complete the workflow.
self.assertEqual(getUtility(IPendings).count(),
self._expected_pendings_count)
def test_start_state(self):
# The workflow starts with no tokens or member.
workflow = SubscriptionWorkflow(self._mlist)
self.assertIsNone(workflow.token)
self.assertEqual(workflow.token_owner, TokenOwner.no_one)
self.assertIsNone(workflow.member)
def test_pended_data(self):
# There is a Pendable associated with the held request, and it has
# some data associated with it.
anne = self._user_manager.create_address(self._anne)
workflow = SubscriptionWorkflow(self._mlist, anne)
with suppress(StopIteration):
workflow.run_thru('send_confirmation')
self.assertIsNotNone(workflow.token)
pendable = getUtility(IPendings).confirm(workflow.token, expunge=False)
self.assertEqual(pendable['list_id'], 'test.example.com')
self.assertEqual(pendable['email'], 'anne@example.com')
self.assertEqual(pendable['display_name'], '')
self.assertEqual(pendable['when'], '2005-08-01T07:49:23')
self.assertEqual(pendable['token_owner'], 'subscriber')
# The token is still in the database.
self._expected_pendings_count = 1
@unittest.skipIf(sys.hexversion < 0x30700a0, 'No datetime.fromisoformat')
def test_pended_expiration_user(self):
# As test_pended_data, but here we're interested in the expiration
# which we need to get at a low level.
anne = self._user_manager.create_address(self._anne)
workflow = SubscriptionWorkflow(self._mlist, anne)
with suppress(StopIteration):
workflow.run_thru('send_confirmation')
self.assertIsNotNone(workflow.token)
pendable = getUtility(IPendings).confirm(workflow.token, expunge=False)
self.assertEqual(pendable['when'], '2005-08-01T07:49:23')
self.assertEqual(pendable['token_owner'], 'subscriber')
# Get the expiration datetime and verify.
expiration = config.db.store.execute(text(
"select expiration_date from pended where token = '{}';".format(
workflow.token))).fetchone()[0]
if isinstance(expiration, str):
expiration = datetime.fromisoformat(expiration)
expected = datetime.fromisoformat(pendable['when']) + as_timedelta(
config.mailman.pending_request_life)
self.assertEqual(expiration, expected)
# The token is still in the database.
self._expected_pendings_count = 1
@unittest.skipIf(sys.hexversion < 0x30700a0, 'No datetime.fromisoformat')
def test_pended_expiration_moderator(self):
# Test that a Pendable for the moderator has the right expiration.
self._mlist.subscription_policy = SubscriptionPolicy.moderate
anne = self._user_manager.create_address(self._anne)
anne.verified_on = datetime.now()
workflow = SubscriptionWorkflow(self._mlist, anne)
with suppress(StopIteration):
workflow.run_thru('get_moderator_approval')
self.assertIsNotNone(workflow.token)
pendable = getUtility(IPendings).confirm(workflow.token, expunge=False)
self.assertEqual(pendable['when'], '2005-08-01T07:49:23')
self.assertEqual(pendable['token_owner'], 'moderator')
# Get the expiration datetime and verify.
expiration = config.db.store.execute(text(
"SELECT expiration_date FROM pended WHERE token = '{}';".format(
workflow.token))).fetchone()[0]
if isinstance(expiration, str):
expiration = datetime.fromisoformat(expiration)
expected = datetime.fromisoformat(pendable['when']) + as_timedelta(
config.mailman.moderator_request_life)
self.assertEqual(expiration, expected)
# The token is still in the database.
self._expected_pendings_count = 1
def test_user_or_address_required(self):
# The `subscriber` attribute must be a user or address.
workflow = SubscriptionWorkflow(self._mlist)
self.assertRaises(AssertionError, list, workflow)
def test_sanity_checks_address(self):
# Ensure that the sanity check phase, when given an IAddress, ends up
# with a linked user.
anne = self._user_manager.create_address(self._anne)
workflow = SubscriptionWorkflow(self._mlist, anne)
self.assertIsNotNone(workflow.address)
self.assertIsNone(workflow.user)
workflow.run_thru('sanity_checks')
self.assertIsNotNone(workflow.address)
self.assertIsNotNone(workflow.user)
self.assertEqual(list(workflow.user.addresses)[0].email, self._anne)
def test_sanity_checks_user_with_preferred_address(self):
# Ensure that the sanity check phase, when given an IUser with a
# preferred address, ends up with an address.
anne = self._user_manager.make_user(self._anne)
address = set_preferred(anne)
workflow = SubscriptionWorkflow(self._mlist, anne)
# The constructor sets workflow.address because the user has a
# preferred address.
self.assertEqual(workflow.address, address)
self.assertEqual(workflow.user, anne)
workflow.run_thru('sanity_checks')
self.assertEqual(workflow.address, address)
self.assertEqual(workflow.user, anne)
def test_sanity_checks_user_without_preferred_address(self):
# Ensure that the sanity check phase, when given a user without a
# preferred address, but with at least one linked address, gets an
# address.
anne = self._user_manager.make_user(self._anne)
workflow = SubscriptionWorkflow(self._mlist, anne)
self.assertIsNone(workflow.address)
self.assertEqual(workflow.user, anne)
workflow.run_thru('sanity_checks')
self.assertIsNotNone(workflow.address)
self.assertEqual(workflow.user, anne)
def test_sanity_checks_user_with_multiple_linked_addresses(self):
# Ensure that the santiy check phase, when given a user without a
# preferred address, but with multiple linked addresses, gets of of
# those addresses (exactly which one is undefined).
anne = self._user_manager.make_user(self._anne)
anne.link(self._user_manager.create_address('anne@example.net'))
anne.link(self._user_manager.create_address('anne@example.org'))
workflow = SubscriptionWorkflow(self._mlist, anne)
self.assertIsNone(workflow.address)
self.assertEqual(workflow.user, anne)
workflow.run_thru('sanity_checks')
self.assertIn(workflow.address.email, ['anne@example.com',
'anne@example.net',
'anne@example.org'])
self.assertEqual(workflow.user, anne)
def test_sanity_checks_user_without_addresses(self):
# It is an error to try to subscribe a user with no linked addresses.
user = self._user_manager.create_user()
workflow = SubscriptionWorkflow(self._mlist, user)
self.assertRaises(AssertionError, workflow.run_thru, 'sanity_checks')
def test_sanity_checks_finds_address_for_user(self):
# Test raises AlreadySubscribedError for User when member is Address.
anne = self._user_manager.make_user(self._anne)
anne.addresses[0].verified_on = anne.created_on
anne.preferred_address = anne.addresses[0]
self._mlist.subscription_policy = SubscriptionPolicy.open
# Subscribe Address.
ISubscriptionManager(self._mlist).register(
anne.preferred_address,
pre_verified=True,
pre_confirmed=True,
pre_approved=True,
send_welcome_message=False
)
workflow = SubscriptionWorkflow(self._mlist, anne)
self.assertRaises(
AlreadySubscribedError, workflow.run_thru, 'sanity_checks')
def test_sanity_checks_finds_user_for_address(self):
# Test raises AlreadySubscribedError for Address when member is User.
anne = self._user_manager.make_user(self._anne)
anne.addresses[0].verified_on = anne.created_on
anne.preferred_address = anne.addresses[0]
self._mlist.subscription_policy = SubscriptionPolicy.open
# Subscribe User.
ISubscriptionManager(self._mlist).register(
anne,
pre_verified=True,
pre_confirmed=True,
pre_approved=True,
send_welcome_message=False
)
workflow = SubscriptionWorkflow(self._mlist, anne.preferred_address)
self.assertRaises(
AlreadySubscribedError, workflow.run_thru, 'sanity_checks')
def test_sanity_checks_globally_banned_address(self):
# An exception is raised if the address is globally banned.
anne = self._user_manager.create_address(self._anne)
IBanManager(None).ban(self._anne)
workflow = SubscriptionWorkflow(self._mlist, anne)
self.assertRaises(MembershipIsBannedError, list, workflow)
def test_sanity_checks_banned_address(self):
# An exception is raised if the address is banned by the mailing list.
anne = self._user_manager.create_address(self._anne)
IBanManager(self._mlist).ban(self._anne)
workflow = SubscriptionWorkflow(self._mlist, anne)
self.assertRaises(MembershipIsBannedError, list, workflow)
def test_sanity_checks_list_posting_address(self):
# An exception is raised if the address is the list posting address.
anne = self._user_manager.create_address(self._mlist.posting_address)
workflow = SubscriptionWorkflow(self._mlist, anne)
self.assertRaises(InvalidEmailAddressError, list, workflow)
def test_verification_checks_with_verified_address(self):
# When the address is already verified, we skip straight to the
# confirmation checks.
anne = self._user_manager.create_address(self._anne)
anne.verified_on = now()
workflow = SubscriptionWorkflow(self._mlist, anne)
workflow.run_thru('verification_checks')
with patch.object(workflow, '_step_confirmation_checks') as step:
next(workflow)
step.assert_called_once_with()
def test_verification_checks_with_pre_verified_address(self):
# When the address is not yet verified, but the pre-verified flag is
# passed to the workflow, we skip to the confirmation checks.
anne = self._user_manager.create_address(self._anne)
workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True)
workflow.run_thru('verification_checks')
with patch.object(workflow, '_step_confirmation_checks') as step:
next(workflow)
step.assert_called_once_with()
# And now the address is verified.
self.assertIsNotNone(anne.verified_on)
def test_verification_checks_confirmation_needed(self):
# The address is neither verified, nor is the pre-verified flag set.
# A confirmation message must be sent to the user which will also
# verify their address.
anne = self._user_manager.create_address(self._anne)
workflow = SubscriptionWorkflow(self._mlist, anne)
workflow.run_thru('verification_checks')
with patch.object(workflow, '_step_send_confirmation') as step:
next(workflow)
step.assert_called_once_with()
# The address still hasn't been verified.
self.assertIsNone(anne.verified_on)
def test_confirmation_checks_open_list(self):
# A subscription to an open list does not need to be confirmed or
# moderated.
self._mlist.subscription_policy = SubscriptionPolicy.open
anne = self._user_manager.create_address(self._anne)
workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True)
workflow.run_thru('confirmation_checks')
with patch.object(workflow, '_step_do_subscription') as step:
next(workflow)
step.assert_called_once_with()
def test_confirmation_checks_no_user_confirmation_needed(self):
# A subscription to a list which does not need user confirmation skips
# to the moderation checks.
self._mlist.subscription_policy = SubscriptionPolicy.moderate
anne = self._user_manager.create_address(self._anne)
workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True)
workflow.run_thru('confirmation_checks')
with patch.object(workflow, '_step_moderation_checks') as step:
next(workflow)
step.assert_called_once_with()
def test_confirmation_checks_confirm_pre_confirmed(self):
# The subscription policy requires user confirmation, but their
# subscription is pre-confirmed. Since moderation is not required,
# the user will be immediately subscribed.
self._mlist.subscription_policy = SubscriptionPolicy.confirm
anne = self._user_manager.create_address(self._anne)
workflow = SubscriptionWorkflow(self._mlist, anne,
pre_verified=True,
pre_confirmed=True)
workflow.run_thru('confirmation_checks')
with patch.object(workflow, '_step_do_subscription') as step:
next(workflow)
step.assert_called_once_with()
def test_confirmation_checks_confirm_then_moderate_pre_confirmed(self):
# The subscription policy requires user confirmation, but their
# subscription is pre-confirmed. Since moderation is required, that
# check will be performed.
self._mlist.subscription_policy = (
SubscriptionPolicy.confirm_then_moderate)
anne = self._user_manager.create_address(self._anne)
workflow = SubscriptionWorkflow(self._mlist, anne,
pre_verified=True,
pre_confirmed=True)
workflow.run_thru('confirmation_checks')
with patch.object(workflow, '_step_moderation_checks') as step:
next(workflow)
step.assert_called_once_with()
def test_confirmation_checks_confirm_and_moderate_pre_confirmed(self):
# The subscription policy requires user confirmation and moderation,
# but their subscription is pre-confirmed.
self._mlist.subscription_policy = (
SubscriptionPolicy.confirm_then_moderate)
anne = self._user_manager.create_address(self._anne)
workflow = SubscriptionWorkflow(self._mlist, anne,
pre_verified=True,
pre_confirmed=True)
workflow.run_thru('confirmation_checks')
with patch.object(workflow, '_step_moderation_checks') as step:
next(workflow)
step.assert_called_once_with()
def test_confirmation_checks_confirmation_needed(self):
# The subscription policy requires confirmation and the subscription
# is not pre-confirmed.
self._mlist.subscription_policy = SubscriptionPolicy.confirm
anne = self._user_manager.create_address(self._anne)
workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True)
workflow.run_thru('confirmation_checks')
with patch.object(workflow, '_step_send_confirmation') as step:
next(workflow)
step.assert_called_once_with()
def test_confirmation_checks_moderate_confirmation_needed(self):
# The subscription policy requires confirmation and moderation, and the
# subscription is not pre-confirmed.
self._mlist.subscription_policy = (
SubscriptionPolicy.confirm_then_moderate)
anne = self._user_manager.create_address(self._anne)
workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True)
workflow.run_thru('confirmation_checks')
with patch.object(workflow, '_step_send_confirmation') as step:
next(workflow)
step.assert_called_once_with()
def test_moderation_checks_pre_approved(self):
# The subscription is pre-approved by the moderator.
self._mlist.subscription_policy = SubscriptionPolicy.moderate
anne = self._user_manager.create_address(self._anne)
workflow = SubscriptionWorkflow(self._mlist, anne,
pre_verified=True,
pre_approved=True)
workflow.run_thru('moderation_checks')
with patch.object(workflow, '_step_do_subscription') as step:
next(workflow)
step.assert_called_once_with()
def test_moderation_checks_approval_required(self):
# The moderator must approve the subscription.
self._mlist.subscription_policy = SubscriptionPolicy.moderate
anne = self._user_manager.create_address(self._anne)
workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True)
workflow.run_thru('moderation_checks')
with patch.object(workflow, '_step_get_moderator_approval') as step:
next(workflow)
step.assert_called_once_with()
def test_do_subscription(self):
# An open subscription policy plus a pre-verified address means the
# user gets subscribed to the mailing list without any further
# confirmations or approvals.
self._mlist.subscription_policy = SubscriptionPolicy.open
anne = self._user_manager.create_address(self._anne)
workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True)
# Consume the entire state machine.
list(workflow)
# Anne is now a member of the mailing list.
member = self._mlist.regular_members.get_member(self._anne)
self.assertEqual(member.address, anne)
self.assertEqual(workflow.member, member)
# No further token is needed.
self.assertIsNone(workflow.token)
self.assertEqual(workflow.token_owner, TokenOwner.no_one)
def test_do_subscription_pre_approved(self):
# A moderation-requiring subscription policy plus a pre-verified and
# pre-approved address means the user gets subscribed to the mailing
# list without any further confirmations or approvals.
self._mlist.subscription_policy = SubscriptionPolicy.moderate
anne = self._user_manager.create_address(self._anne)
workflow = SubscriptionWorkflow(self._mlist, anne,
pre_verified=True,
pre_approved=True)
# Consume the entire state machine.
list(workflow)
# Anne is now a member of the mailing list.
member = self._mlist.regular_members.get_member(self._anne)
self.assertEqual(member.address, anne)
self.assertEqual(workflow.member, member)
# No further token is needed.
self.assertIsNone(workflow.token)
self.assertEqual(workflow.token_owner, TokenOwner.no_one)
def test_do_subscription_pre_approved_pre_confirmed(self):
# A moderation-requiring subscription policy plus a pre-verified and
# pre-approved address means the user gets subscribed to the mailing
# list without any further confirmations or approvals.
self._mlist.subscription_policy = (
SubscriptionPolicy.confirm_then_moderate)
anne = self._user_manager.create_address(self._anne)
workflow = SubscriptionWorkflow(self._mlist, anne,
pre_verified=True,
pre_confirmed=True,
pre_approved=True)
# Consume the entire state machine.
list(workflow)
# Anne is now a member of the mailing list.
member = self._mlist.regular_members.get_member(self._anne)
self.assertEqual(member.address, anne)
self.assertEqual(workflow.member, member)
# No further token is needed.
self.assertIsNone(workflow.token)
self.assertEqual(workflow.token_owner, TokenOwner.no_one)
def test_do_subscription_cleanups(self):
# Once the user is subscribed, the token, and its associated pending
# database record will be removed from the database.
self._mlist.subscription_policy = SubscriptionPolicy.open
anne = self._user_manager.create_address(self._anne)
workflow = SubscriptionWorkflow(self._mlist, anne,
pre_verified=True,
pre_confirmed=True,
pre_approved=True)
# Consume the entire state machine.
list(workflow)
# Anne is now a member of the mailing list.
member = self._mlist.regular_members.get_member(self._anne)
self.assertEqual(member.address, anne)
self.assertEqual(workflow.member, member)
# The workflow is done, so it has no token.
self.assertIsNone(workflow.token)
self.assertEqual(workflow.token_owner, TokenOwner.no_one)
def test_moderator_approves(self):
# The workflow runs until moderator approval is required, at which
# point the workflow is saved. Once the moderator approves, the
# workflow resumes and the user is subscribed.
self._mlist.subscription_policy = SubscriptionPolicy.moderate
anne = self._user_manager.create_address(self._anne)
workflow = SubscriptionWorkflow(self._mlist, anne,
pre_verified=True,
pre_confirmed=True)
# Consume the entire state machine.
list(workflow)
# The user is not currently subscribed to the mailing list.
member = self._mlist.regular_members.get_member(self._anne)
self.assertIsNone(member)
self.assertIsNone(workflow.member)
# The token is owned by the moderator.
self.assertIsNotNone(workflow.token)
self.assertEqual(workflow.token_owner, TokenOwner.moderator)
# Create a new workflow with the previous workflow's save token, and
# restore its state. This models an approved subscription and should
# result in the user getting subscribed.
approved_workflow = SubscriptionWorkflow(self._mlist)
approved_workflow.token = workflow.token
approved_workflow.restore()
list(approved_workflow)
# Now the user is subscribed to the mailing list.
member = self._mlist.regular_members.get_member(self._anne)
self.assertEqual(member.address, anne)
self.assertEqual(approved_workflow.member, member)
# No further token is needed.
self.assertIsNone(approved_workflow.token)
self.assertEqual(approved_workflow.token_owner, TokenOwner.no_one)
def test_get_moderator_approval_log_on_hold(self):
# When the subscription is held for moderator approval, a message is
# logged.
mark = LogFileMark('mailman.subscribe')
self._mlist.subscription_policy = SubscriptionPolicy.moderate
anne = self._user_manager.create_address(self._anne)
workflow = SubscriptionWorkflow(self._mlist, anne,
pre_verified=True,
pre_confirmed=True)
# Consume the entire state machine.
list(workflow)
self.assertIn(
'test@example.com: held subscription request from anne@example.com',
mark.readline()
)
# The state machine stopped at the moderator approval so there will be
# one token still in the database.
self._expected_pendings_count = 1
def test_get_moderator_approval_notifies_moderators(self):
# When the subscription is held for moderator approval, and the list
# is so configured, a notification is sent to the list moderators.
self._mlist.admin_immed_notify = True
self._mlist.subscription_policy = SubscriptionPolicy.moderate
anne = self._user_manager.create_user(self._anne, 'Ане')
set_preferred(anne)
bart = self._user_manager.create_user('bart@example.com', 'Bart User')
address = set_preferred(bart)
self._mlist.subscribe(address, MemberRole.moderator)
workflow = SubscriptionWorkflow(self._mlist, anne,
pre_verified=True,
pre_confirmed=True)
# Consume the entire state machine.
list(workflow)
# Find the moderator message.
items = get_queue_messages('virgin', expected_count=1)
for item in items:
if item.msg['to'] == 'test-owner@example.com':
break
else:
raise AssertionError('No moderator email found')
self.assertEqual(
item.msgdata['recipients'], {'test-owner@example.com'})
message = items[0].msg
self.assertEqual(message['From'], 'test-owner@example.com')
self.assertEqual(message['To'], 'test-owner@example.com')
self.assertEqual(
message['Subject'],
'New subscription request to Test from anne@example.com')
self.assertEqual(message.get_payload(decode=True).decode('utf-8'), """\
Your authorization is required for a mailing list subscription request
approval:
For: Ане
List: test@example.com
""")
# The state machine stopped at the moderator approval so there will be
# one token still in the database.
self._expected_pendings_count = 1
def test_get_moderator_approval_no_notifications(self):
# When the subscription is held for moderator approval, and the list
# is so configured, a notification is sent to the list moderators.
self._mlist.admin_immed_notify = False
self._mlist.subscription_policy = SubscriptionPolicy.moderate
anne = self._user_manager.create_address(self._anne)
workflow = SubscriptionWorkflow(self._mlist, anne,
pre_verified=True,
pre_confirmed=True)
# Consume the entire state machine.
list(workflow)
get_queue_messages('virgin', expected_count=0)
# The state machine stopped at the moderator approval so there will be
# one token still in the database.
self._expected_pendings_count = 1
def test_send_confirmation(self):
# A confirmation message gets sent when the address is not verified.
anne = self._user_manager.create_address(self._anne)
self.assertIsNone(anne.verified_on)
# Run the workflow to model the confirmation step.
workflow = SubscriptionWorkflow(self._mlist, anne)
list(workflow)
items = get_queue_messages('virgin', expected_count=1)
message = items[0].msg
token = workflow.token
self.assertTrue(str(message['Subject']).startswith('Your confirm'))
self.assertEqual(
message['From'], 'test-confirm+{}@example.com'.format(token))
# The confirmation message is not `Precedence: bulk`.
self.assertIsNone(message['precedence'])
# The confirmation message is `Auto-Submitted: auto-generated`.
self.assertEqual(message['auto-submitted'], 'auto-generated')
# The state machine stopped at the moderator approval so there will be
# one token still in the database.
self._expected_pendings_count = 1
def test_send_invitation(self):
# Test that an invitation is sent when requested.
anne = self._user_manager.create_address(self._anne)
self.assertIsNone(anne.verified_on)
# Run the workflow to model the confirmation step.
workflow = SubscriptionWorkflow(self._mlist, anne, invitation=True)
list(workflow)
items = get_queue_messages('virgin', expected_count=1)
message = items[0].msg
token = workflow.token
self.assertIn('You have been invited to join', str(message['Subject']))
self.assertEqual(
message['From'], 'test-confirm+{}@example.com'.format(token))
# The confirmation message is not `Precedence: bulk`.
self.assertIsNone(message['precedence'])
# The confirmation message is `Auto-Submitted: auto-generated`.
self.assertEqual(message['auto-submitted'], 'auto-generated')
# The state machine stopped at sending the invitation so there will be
# one token still in the database.
self._expected_pendings_count = 1
def test_invitation_verp_confirmations_no(self):
# Test From: and Subject: with verp_confirmations equal no.
anne = self._user_manager.create_address(self._anne)
self.assertIsNone(anne.verified_on)
# Run the workflow to model the confirmation step.
workflow = SubscriptionWorkflow(self._mlist, anne, invitation=True)
with configuration('mta', verp_confirmations='no'):
list(workflow)
items = get_queue_messages('virgin', expected_count=1)
message = items[0].msg
token = workflow.token
self.assertEqual(f'confirm {token}', str(message['Subject']))
self.assertEqual('test-request@example.com', message['From'])
self._expected_pendings_count = 1
def test_send_confirmation_pre_confirmed(self):
# A confirmation message gets sent when the address is not verified
# but the subscription is pre-confirmed.
anne = self._user_manager.create_address(self._anne)
self.assertIsNone(anne.verified_on)
# Run the workflow to model the confirmation step.
workflow = SubscriptionWorkflow(self._mlist, anne, pre_confirmed=True)
list(workflow)
items = get_queue_messages('virgin', expected_count=1)
message = items[0].msg
token = workflow.token
self.assertTrue(
str(message['Subject']).startswith('Your confirmation is '
'needed to join the '))
self.assertEqual(
message['From'], 'test-confirm+{}@example.com'.format(token))
# The state machine stopped at the moderator approval so there will be
# one token still in the database.
self._expected_pendings_count = 1
def test_confirmation_subscribe_verp_confirmations_no(self):
# Test From: and Subject: with verp_confirmations equal no.
anne = self._user_manager.create_address(self._anne)
self.assertIsNone(anne.verified_on)
# Run the workflow to model the confirmation step.
workflow = SubscriptionWorkflow(self._mlist, anne, pre_confirmed=True)
with configuration('mta', verp_confirmations='no'):
list(workflow)
items = get_queue_messages('virgin', expected_count=1)
message = items[0].msg
token = workflow.token
self.assertEqual(f'confirm {token}', str(message['Subject']))
self.assertEqual('test-request@example.com', message['From'])
self._expected_pendings_count = 1
def test_send_confirmation_pre_verified(self):
# A confirmation message gets sent even when the address is verified
# when the subscription must be confirmed.
self._mlist.subscription_policy = SubscriptionPolicy.confirm
anne = self._user_manager.create_address(self._anne)
self.assertIsNone(anne.verified_on)
# Run the workflow to model the confirmation step.
workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True)
list(workflow)
items = get_queue_messages('virgin', expected_count=1)
message = items[0].msg
token = workflow.token
self.assertTrue(
str(message['Subject']).startswith('Your confirmation is '
'needed to join the '))
self.assertEqual(
message['From'], 'test-confirm+{}@example.com'.format(token))
# The state machine stopped at the moderator approval so there will be
# one token still in the database.
self._expected_pendings_count = 1
def test_do_confirm_verify_address(self):
# The address is not yet verified, nor are we pre-verifying. A
# confirmation message will be sent. When the user confirms their
# subscription request, the address will end up being verified.
anne = self._user_manager.create_address(self._anne)
self.assertIsNone(anne.verified_on)
# Run the workflow to model the confirmation step.
workflow = SubscriptionWorkflow(self._mlist, anne)
list(workflow)
# The address is still not verified.
self.assertIsNone(anne.verified_on)
confirm_workflow = SubscriptionWorkflow(self._mlist)
confirm_workflow.token = workflow.token
confirm_workflow.restore()
confirm_workflow.run_thru('do_confirm_verify')
# The address is now verified.
self.assertIsNotNone(anne.verified_on)
def test_do_confirm_verify_user(self):
# A confirmation step is necessary when a user subscribes with their
# preferred address, and we are not pre-confirming.
anne = self._user_manager.create_user(self._anne)
set_preferred(anne)
# Run the workflow to model the confirmation step. There is no
# subscriber attribute yet.
workflow = SubscriptionWorkflow(self._mlist, anne)
list(workflow)
self.assertEqual(workflow.subscriber, anne)
# Do a confirmation workflow, which should now set the subscriber.
confirm_workflow = SubscriptionWorkflow(self._mlist)
confirm_workflow.token = workflow.token
confirm_workflow.restore()
confirm_workflow.run_thru('do_confirm_verify')
# The address is now verified.
self.assertEqual(confirm_workflow.subscriber, anne)
def test_do_confirmation_subscribes_user(self):
# Subscriptions to the mailing list must be confirmed. Once that's
# done, the user's address (which is not initially verified) gets
# subscribed to the mailing list.
self._mlist.subscription_policy = SubscriptionPolicy.confirm
anne = self._user_manager.create_address(self._anne)
self.assertIsNone(anne.verified_on)
workflow = SubscriptionWorkflow(self._mlist, anne)
list(workflow)
# Anne is not yet a member.
member = self._mlist.regular_members.get_member(self._anne)
self.assertIsNone(member)
self.assertIsNone(workflow.member)
# The token is owned by the subscriber.
self.assertIsNotNone(workflow.token)
self.assertEqual(workflow.token_owner, TokenOwner.subscriber)
# Confirm.
confirm_workflow = SubscriptionWorkflow(self._mlist)
confirm_workflow.token = workflow.token
confirm_workflow.restore()
list(confirm_workflow)
self.assertIsNotNone(anne.verified_on)
# Anne is now a member.
member = self._mlist.regular_members.get_member(self._anne)
self.assertEqual(member.address, anne)
self.assertEqual(confirm_workflow.member, member)
# No further token is needed.
self.assertIsNone(confirm_workflow.token)
self.assertEqual(confirm_workflow.token_owner, TokenOwner.no_one)
def test_do_confirmation_subscribes_invited_user(self):
# Invitations to the mailing list must be confirmed. Once that's
# done, the user's address (which is not initially verified) gets
# subscribed to the mailing list.
anne = self._user_manager.create_address(self._anne)
self.assertIsNone(anne.verified_on)
workflow = SubscriptionWorkflow(self._mlist, anne)
list(workflow)
# Anne is not yet a member.
member = self._mlist.regular_members.get_member(self._anne)
self.assertIsNone(member)
self.assertIsNone(workflow.member)
# The token is owned by the subscriber.
self.assertIsNotNone(workflow.token)
self.assertEqual(workflow.token_owner, TokenOwner.subscriber)
# Confirm.
confirm_workflow = SubscriptionWorkflow(self._mlist)
confirm_workflow.token = workflow.token
confirm_workflow.restore()
list(confirm_workflow)
self.assertIsNotNone(anne.verified_on)
# Anne is now a member.
member = self._mlist.regular_members.get_member(self._anne)
self.assertEqual(member.address, anne)
self.assertEqual(confirm_workflow.member, member)
# No further token is needed.
self.assertIsNone(confirm_workflow.token)
self.assertEqual(confirm_workflow.token_owner, TokenOwner.no_one)
def test_prevent_confirmation_replay_attacks(self):
# Ensure that if the workflow requires two confirmations, e.g. first
# the user confirming their subscription, and then the moderator
# approving it, that different tokens are used in these two cases.
self._mlist.subscription_policy = (
SubscriptionPolicy.confirm_then_moderate)
anne = self._user_manager.create_address(self._anne)
workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True)
# Run the state machine up to the first confirmation, and cache the
# confirmation token.
list(workflow)
token = workflow.token
# Anne is not yet a member of the mailing list.
member = self._mlist.regular_members.get_member(self._anne)
self.assertIsNone(member)
self.assertIsNone(workflow.member)
# The token is owned by the subscriber.
self.assertIsNotNone(workflow.token)
self.assertEqual(workflow.token_owner, TokenOwner.subscriber)
# The old token will not work for moderator approval.
moderator_workflow = SubscriptionWorkflow(self._mlist)
moderator_workflow.token = token
moderator_workflow.restore()
list(moderator_workflow)
# The token is owned by the moderator.
self.assertIsNotNone(moderator_workflow.token)
self.assertEqual(moderator_workflow.token_owner, TokenOwner.moderator)
# While we wait for the moderator to approve the subscription, note
# that there's a new token for the next steps.
self.assertNotEqual(token, moderator_workflow.token)
# The old token won't work.
final_workflow = SubscriptionWorkflow(self._mlist)
final_workflow.token = token
self.assertRaises(LookupError, final_workflow.restore)
# Running this workflow will fail.
self.assertRaises(AssertionError, list, final_workflow)
# Anne is still not subscribed.
member = self._mlist.regular_members.get_member(self._anne)
self.assertIsNone(member)
self.assertIsNone(final_workflow.member)
# However, if we use the new token, her subscription request will be
# approved by the moderator.
final_workflow.token = moderator_workflow.token
final_workflow.restore()
list(final_workflow)
# And now Anne is a member.
member = self._mlist.regular_members.get_member(self._anne)
self.assertEqual(member.address.email, self._anne)
self.assertEqual(final_workflow.member, member)
# No further token is needed.
self.assertIsNone(final_workflow.token)
self.assertEqual(final_workflow.token_owner, TokenOwner.no_one)
def test_confirmation_needed_and_pre_confirmed(self):
# The subscription policy is 'confirm' but the subscription is
# pre-confirmed so the moderation checks can be skipped.
self._mlist.subscription_policy = SubscriptionPolicy.confirm
anne = self._user_manager.create_address(self._anne)
workflow = SubscriptionWorkflow(
self._mlist, anne,
pre_verified=True, pre_confirmed=True, pre_approved=True)
list(workflow)
# Anne was subscribed.
self.assertIsNone(workflow.token)
self.assertEqual(workflow.token_owner, TokenOwner.no_one)
self.assertEqual(workflow.member.address, anne)
def test_restore_user_absorbed(self):
# The subscribing user is absorbed (and thus deleted) before the
# moderator approves the subscription.
self._mlist.subscription_policy = SubscriptionPolicy.moderate
anne = self._user_manager.create_user(self._anne)
bill = self._user_manager.create_user('bill@example.com')
set_preferred(bill)
# anne subscribes.
workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True)
list(workflow)
# bill absorbs anne.
bill.absorb(anne)
# anne's subscription request is approved.
approved_workflow = SubscriptionWorkflow(self._mlist)
approved_workflow.token = workflow.token
approved_workflow.restore()
self.assertEqual(approved_workflow.user, bill)
# Run the workflow through.
list(approved_workflow)
def test_restore_address_absorbed(self):
# The subscribing user is absorbed (and thus deleted) before the
# moderator approves the subscription.
self._mlist.subscription_policy = SubscriptionPolicy.moderate
anne = self._user_manager.create_user(self._anne)
anne_address = anne.addresses[0]
bill = self._user_manager.create_user('bill@example.com')
# anne subscribes.
workflow = SubscriptionWorkflow(
self._mlist, anne_address, pre_verified=True)
list(workflow)
# bill absorbs anne.
bill.absorb(anne)
self.assertIn(anne_address, bill.addresses)
# anne's subscription request is approved.
approved_workflow = SubscriptionWorkflow(self._mlist)
approved_workflow.token = workflow.token
approved_workflow.restore()
self.assertEqual(approved_workflow.user, bill)
# Run the workflow through.
list(approved_workflow)
def test_set_member_prefs_and_subscribe(self):
# Test that we can set member's delivery_mode and delivery_status
# after subscribing them.
anne = self._user_manager.create_user(self._anne)
anne_address = anne.addresses[0]
workflow = SubscriptionWorkflow(
self._mlist, anne_address,
pre_verified=True, delivery_mode=DeliveryMode.plaintext_digests,
delivery_status=DeliveryStatus.by_user)
list(workflow)
# Anne's subcription is pending confirmation.
confirm_workflow = SubscriptionWorkflow(self._mlist)
confirm_workflow.token = workflow.token
confirm_workflow.restore()
list(confirm_workflow)
# Anne should be subscribed with the desired membership preferences.
anne_member = self._mlist.members.get_member(anne_address.email)
self.assertEqual(
anne_member.delivery_mode, DeliveryMode.plaintext_digests)
self.assertEqual(anne_member.delivery_status, DeliveryStatus.by_user)
def test_translated_subject_your_confirmation_is_needed(self):
anne = self._user_manager.create_user(self._anne)
old_language = self._mlist.preferred_language.code
self._mlist.preferred_language.code = 'fr'
anne_address = anne.addresses[0]
workflow = SubscriptionWorkflow(
self._mlist, anne_address, pre_verified=True,
send_welcome_message=False, delivery_status=DeliveryStatus.by_user)
list(workflow)
confirm_workflow = SubscriptionWorkflow(self._mlist)
confirm_workflow.token = workflow.token
confirm_workflow.restore()
list(confirm_workflow)
self._mlist.preferred_language.code = old_language
items = get_queue_messages('virgin', expected_count=1)
self.assertEqual(items[0].msg['Subject'], "Votre "
"confirmation est nécessaire pour vous abonner à "
"la liste de diffusion test@example.com.")
def test_translated_subject_unsubscribe(self):
anne = self._user_manager.create_user(self._anne)
set_preferred(anne)
old_code = anne.preferences.preferred_language
anne.preferences.preferred_language = 'fr'
self._mlist.subscribe(anne, send_welcome_message=False)
delete_member(self._mlist, self._anne, False, True)
anne.preferences.preferred_language = old_code
items = get_queue_messages('virgin', expected_count=1)
self.assertEqual(str(items[0].msg['Subject']), "Vous avez"
" été désabonné de la liste de diffusion Test")
def test_mutltiple_subscriptions_for_one_user(self):
anne = self._user_manager.create_user(self._anne)
set_preferred(anne)
email_2 = self._user_manager.create_address('second@example.com')
anne.link(email_2)
# Subscribe the user first.
workflow = SubscriptionWorkflow(
self._mlist, anne,
pre_verified=True, pre_confirmed=True, pre_approved=True
)
list(workflow)
# Assert the user was subscribed.
member = self._mlist.regular_members.get_member(self._anne)
self.assertIsNotNone(member)
# Now, try to subscribe the 2nd address of the same user.
workflow = SubscriptionWorkflow(
self._mlist, email_2,
pre_verified=True, pre_confirmed=True, pre_approved=True
)
list(workflow)
# And assert it succeeded.
member = self._mlist.regular_members.get_member(email_2.email)
self.assertIsNotNone(member)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5123053
mailman-3.3.10/src/mailman/app/tests/test_unsubscriptions.py 0000644 0000000 0000000 00000062733 14542770442 021200 0 ustar 00 # Copyright (C) 2016-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Test for unsubscription service."""
import unittest
from contextlib import suppress
from mailman.app.lifecycle import create_list
from mailman.app.subscriptions import UnSubscriptionWorkflow
from mailman.interfaces.mailinglist import SubscriptionPolicy
from mailman.interfaces.pending import IPendings
from mailman.interfaces.subscriptions import TokenOwner
from mailman.interfaces.usermanager import IUserManager
from mailman.testing.helpers import (
get_queue_messages,
LogFileMark,
set_preferred,
subscribe,
)
from mailman.testing.layers import ConfigLayer
from mailman.utilities.datetime import now
from unittest.mock import patch
from zope.component import getUtility
class TestUnSubscriptionWorkflow(unittest.TestCase):
layer = ConfigLayer
maxDiff = None
def setUp(self):
self._mlist = create_list('test@example.com')
self._mlist.admin_immed_notify = False
self._mlist.unsubscription_policy = SubscriptionPolicy.open
self._mlist.send_welcome_message = False
self._anne = 'anne@example.com'
self._user_manager = getUtility(IUserManager)
self.anne = self._user_manager.create_user(self._anne, 'Ане')
self.anne.addresses[0].verified_on = now()
self.anne.preferred_address = self.anne.addresses[0]
self._mlist.subscribe(self.anne)
self._expected_pendings_count = 0
def tearDown(self):
# There usually should be no pending after all is said and done, but
# some tests don't complete the workflow.
self.assertEqual(getUtility(IPendings).count(),
self._expected_pendings_count)
def test_start_state(self):
# Test the workflow starts with no tokens or members.
workflow = UnSubscriptionWorkflow(self._mlist)
self.assertEqual(workflow.token_owner, TokenOwner.no_one)
self.assertIsNone(workflow.token)
self.assertIsNone(workflow.member)
def test_pended_data(self):
# Test there is a Pendable object associated with a held
# unsubscription request and it has some valid data associated with
# it.
self._mlist.unsubscription_policy = SubscriptionPolicy.confirm
workflow = UnSubscriptionWorkflow(self._mlist, self.anne)
with suppress(StopIteration):
workflow.run_thru('send_confirmation')
self.assertIsNotNone(workflow.token)
pendable = getUtility(IPendings).confirm(workflow.token, expunge=False)
self.assertEqual(pendable['list_id'], 'test.example.com')
self.assertEqual(pendable['email'], 'anne@example.com')
self.assertEqual(pendable['display_name'], 'Ане')
self.assertEqual(pendable['when'], '2005-08-01T07:49:23')
self.assertEqual(pendable['token_owner'], 'subscriber')
# The token is still in the database.
self._expected_pendings_count = 1
def test_user_or_address_required(self):
# The `subscriber` attribute must be a user or address that is provided
# to the workflow.
workflow = UnSubscriptionWorkflow(self._mlist)
self.assertRaises(AssertionError, list, workflow)
def test_user_is_subscribed_to_unsubscribe(self):
# A user must be subscribed to a list when trying to unsubscribe.
addr = self._user_manager.create_address('aperson@example.org')
addr.verfied_on = now()
workflow = UnSubscriptionWorkflow(self._mlist, addr)
self.assertRaises(AssertionError,
workflow.run_thru, 'subscription_checks')
def test_subscription_checks_for_user(self):
# subscription_checks must pass for IUser subscribed as IAddress.
member = subscribe(self._mlist, 'Bart')
set_preferred(member.user)
workflow = UnSubscriptionWorkflow(self._mlist, member.user)
workflow.run_thru('subscription_checks')
def test_subscription_checks_for_address(self):
# subscription_checks must pass for IAddress subscribed as IUser.
workflow = UnSubscriptionWorkflow(self._mlist,
self.anne.preferred_address)
workflow.run_thru('subscription_checks')
def test_confirmation_checks_open_list(self):
# An unsubscription from an open list does not need to be confirmed or
# moderated.
self._mlist.unsubscription_policy = SubscriptionPolicy.open
workflow = UnSubscriptionWorkflow(self._mlist, self.anne)
workflow.run_thru('confirmation_checks')
with patch.object(workflow, '_step_do_unsubscription') as step:
next(workflow)
step.assert_called_once_with()
def test_confirmation_checks_no_user_confirmation_needed(self):
# An unsubscription from a list which does not need user confirmation
# skips to the moderation checks.
self._mlist.unsubscription_policy = SubscriptionPolicy.moderate
workflow = UnSubscriptionWorkflow(self._mlist, self.anne,
pre_confirmed=True)
workflow.run_thru('confirmation_checks')
with patch.object(workflow, '_step_moderation_checks') as step:
next(workflow)
step.assert_called_once_with()
def test_confirmation_checks_confirm_pre_confirmed(self):
# The unsubscription policy requires user-confirmation, but their
# unsubscription is pre-confirmed. Since moderation is not reuqired,
# the user will be immediately unsubscribed.
self._mlist.unsubscription_policy = SubscriptionPolicy.confirm
workflow = UnSubscriptionWorkflow(
self._mlist, self.anne, pre_confirmed=True)
workflow.run_thru('confirmation_checks')
with patch.object(workflow, '_step_do_unsubscription') as step:
next(workflow)
step.assert_called_once_with()
def test_confirmation_checks_confirm_then_moderate_pre_confirmed(self):
# The unsubscription policy requires user confirmation, but their
# unsubscription is pre-confirmed. Since moderation is required, that
# check will be performed.
self._mlist.unsubscription_policy = (
SubscriptionPolicy.confirm_then_moderate)
workflow = UnSubscriptionWorkflow(
self._mlist, self.anne, pre_confirmed=True)
workflow.run_thru('confirmation_checks')
with patch.object(workflow, '_step_do_unsubscription') as step:
next(workflow)
step.assert_called_once_with()
def test_send_confirmation_checks_confirm_list(self):
# The unsubscription policy requires user confirmation and the
# unsubscription is not pre-confirmed.
self._mlist.unsubscription_policy = SubscriptionPolicy.confirm
workflow = UnSubscriptionWorkflow(self._mlist, self.anne)
workflow.run_thru('confirmation_checks')
with patch.object(workflow, '_step_send_confirmation') as step:
next(workflow)
step.assert_called_once_with()
def test_moderation_checks_moderated_list(self):
# The unsubscription policy requires moderation.
self._mlist.unsubscription_policy = SubscriptionPolicy.moderate
workflow = UnSubscriptionWorkflow(self._mlist, self.anne)
workflow.run_thru('confirmation_checks')
with patch.object(workflow, '_step_moderation_checks') as step:
next(workflow)
step.assert_called_once_with()
def test_moderation_checks_approval_required(self):
# The moderator must approve the subscription request.
self._mlist.unsubscription_policy = SubscriptionPolicy.moderate
workflow = UnSubscriptionWorkflow(self._mlist, self.anne)
workflow.run_thru('moderation_checks')
with patch.object(workflow, '_step_get_moderator_approval') as step:
next(workflow)
step.assert_called_once_with()
def test_do_unsusbcription(self):
# An open unsubscription policy means the user gets unsubscribed to
# the mailing list without any further confirmations or approvals.
self._mlist.unsubscription_policy = SubscriptionPolicy.open
workflow = UnSubscriptionWorkflow(self._mlist, self.anne)
list(workflow)
member = self._mlist.regular_members.get_member(self._anne)
self.assertIsNone(member)
def test_do_unsubscription_pre_approved(self):
# A moderation-requiring subscription policy plus a pre-approved
# address means the user gets unsubscribed from the mailing list
# without any further confirmation or approvals.
self._mlist.unsubscription_policy = SubscriptionPolicy.moderate
workflow = UnSubscriptionWorkflow(self._mlist, self.anne,
pre_approved=True)
list(workflow)
# Anne is now unsubscribed form the mailing list.
member = self._mlist.regular_members.get_member(self._anne)
self.assertIsNone(member)
# No further token is needed.
self.assertIsNone(workflow.token)
self.assertEqual(workflow.token_owner, TokenOwner.no_one)
def test_do_unsubscription_pre_approved_pre_confirmed(self):
# A moderation-requiring unsubscription policy plus a pre-appvoed
# address means the user gets unsubscribed to the mailing list without
# any further confirmations or approvals.
self._mlist.unsubscription_policy = (
SubscriptionPolicy.confirm_then_moderate)
workflow = UnSubscriptionWorkflow(self._mlist, self.anne,
pre_approved=True,
pre_confirmed=True)
list(workflow)
member = self._mlist.regular_members.get_member(self._anne)
self.assertIsNone(member)
# No further token is needed.
self.assertIsNone(workflow.token)
self.assertEqual(workflow.token_owner, TokenOwner.no_one)
def test_do_unsubscription_cleanups(self):
# Once the user is unsubscribed, the token and its associated pending
# database record will be removed from the database.
self._mlist.unsubscription_policy = SubscriptionPolicy.open
workflow = UnSubscriptionWorkflow(self._mlist, self.anne,
pre_approved=True,
pre_confirmed=True)
# Run the workflow.
list(workflow)
# Anne is now unsubscribed from the list.
member = self._mlist.regular_members.get_member(self._anne)
self.assertIsNone(member)
# Workflow is done, so it has no token.
self.assertIsNone(workflow.token)
self.assertEqual(workflow.token_owner, TokenOwner.no_one)
def test_moderator_approves(self):
# The workflow runs until moderator approval is required, at which
# point the workflow is saved. Once the moderator approves, the
# workflow resumes and the user is unsubscribed.
self._mlist.unsubscription_policy = SubscriptionPolicy.moderate
workflow = UnSubscriptionWorkflow(
self._mlist, self.anne, pre_confirmed=True)
# Run the entire workflow.
list(workflow)
# The user is currently subscribed to the mailing list.
member = self._mlist.regular_members.get_member(self._anne)
self.assertIsNotNone(member)
self.assertIsNotNone(workflow.member)
# The token is owned by the moderator.
self.assertIsNotNone(workflow.token)
self.assertEqual(workflow.token_owner, TokenOwner.moderator)
# Create a new workflow with the previous workflow's save token, and
# restore its state. This models an approved un-sunscription request
# and should result in the user getting subscribed.
approved_workflow = UnSubscriptionWorkflow(self._mlist)
approved_workflow.token = workflow.token
approved_workflow.restore()
list(approved_workflow)
# Now the user is unsubscribed from the mailing list.
member = self._mlist.regular_members.get_member(self._anne)
self.assertIsNone(member)
self.assertEqual(approved_workflow.member, member)
# No further token is needed.
self.assertIsNone(approved_workflow.token)
self.assertEqual(approved_workflow.token_owner, TokenOwner.no_one)
def test_get_moderator_approval_log_on_hold(self):
# When the unsubscription is held for moderator approval, a message is
# logged.
mark = LogFileMark('mailman.subscribe')
self._mlist.unsubscription_policy = SubscriptionPolicy.moderate
workflow = UnSubscriptionWorkflow(
self._mlist, self.anne, pre_confirmed=True)
# Run the entire workflow.
list(workflow)
self.assertIn(
'test@example.com: held unsubscription request from anne@example.com',
mark.readline()
)
# The state machine stopped at the moderator approval step so there
# will be one token still in the database.
self._expected_pendings_count = 1
def test_get_moderator_approval_notifies_moderators(self):
# When the unsubscription is held for moderator approval, and the list
# is so configured, a notification is sent to the list moderators.
self._mlist.admin_immed_notify = True
self._mlist.unsubscription_policy = SubscriptionPolicy.moderate
workflow = UnSubscriptionWorkflow(
self._mlist, self.anne, pre_confirmed=True)
# Consume the entire state machine.
list(workflow)
items = get_queue_messages('virgin', expected_count=1)
message = items[0].msg
self.assertEqual(message['From'], 'test-owner@example.com')
self.assertEqual(message['To'], 'test-owner@example.com')
self.assertEqual(
message['Subject'],
'New unsubscription request to Test from anne@example.com')
self.assertEqual(message.get_payload(decode=True).decode('utf-8'), """\
Your authorization is required for a mailing list unsubscription
request approval:
For: Ане
List: test@example.com
""")
# The state machine stopped at the moderator approval so there will be
# one token still in the database.
self._expected_pendings_count = 1
def test_get_moderator_approval_no_notifications(self):
# When the unsubscription request is held for moderator approval, and
# the list is so configured, a notification is sent to the list
# moderators.
self._mlist.admin_immed_notify = False
self._mlist.unsubscription_policy = SubscriptionPolicy.moderate
workflow = UnSubscriptionWorkflow(
self._mlist, self.anne, pre_confirmed=True)
# Consume the entire state machine.
list(workflow)
get_queue_messages('virgin', expected_count=0)
# The state machine stopped at the moderator approval so there will be
# one token still in the database.
self._expected_pendings_count = 1
def test_send_confirmation(self):
# A confirmation message gets sent when the unsubscription must be
# confirmed.
self._mlist.unsubscription_policy = SubscriptionPolicy.confirm
# Run the workflow to model the confirmation step.
workflow = UnSubscriptionWorkflow(self._mlist, self.anne)
list(workflow)
items = get_queue_messages('virgin', expected_count=1)
message = items[0].msg
token = workflow.token
self.assertTrue(
str(message['Subject']).startswith('Your confirmation is '
'needed to leave the '))
self.assertEqual(
message['From'], 'test-confirm+{}@example.com'.format(token))
# The confirmation message is not `Precedence: bulk`.
self.assertIsNone(message['precedence'])
# The confirmation message is `Auto-Submitted: auto-generated`.
self.assertEqual(message['auto-submitted'], 'auto-generated')
# The state machine stopped at the member confirmation step so there
# will be one token still in the database.
self._expected_pendings_count = 1
def test_do_confirmation_unsubscribes_user(self):
# Unsubscriptions to the mailing list must be confirmed. Once that's
# done, the user's address is unsubscribed.
self._mlist.unsubscription_policy = SubscriptionPolicy.confirm
workflow = UnSubscriptionWorkflow(self._mlist, self.anne)
list(workflow)
# Anne is a member.
member = self._mlist.regular_members.get_member(self._anne)
self.assertIsNotNone(member)
self.assertEqual(member, workflow.member)
# The token is owned by the subscriber.
self.assertIsNotNone(workflow.token)
self.assertEqual(workflow.token_owner, TokenOwner.subscriber)
# Confirm.
confirm_workflow = UnSubscriptionWorkflow(self._mlist)
confirm_workflow.token = workflow.token
confirm_workflow.restore()
list(confirm_workflow)
# Anne is now unsubscribed.
member = self._mlist.regular_members.get_member(self._anne)
self.assertIsNone(member)
# No further token is needed.
self.assertIsNone(confirm_workflow.token)
self.assertEqual(confirm_workflow.token_owner, TokenOwner.no_one)
def test_do_confirmation_unsubscribes_address(self):
# Unsubscriptions to the mailing list must be confirmed. Once that's
# done, the address is unsubscribed.
address = self.anne.register('anne.person@example.com')
self._mlist.subscribe(address)
self._mlist.unsubscription_policy = SubscriptionPolicy.confirm
workflow = UnSubscriptionWorkflow(self._mlist, address)
list(workflow)
# Bart is a member.
member = self._mlist.regular_members.get_member(
'anne.person@example.com')
self.assertIsNotNone(member)
self.assertEqual(member, workflow.member)
# The token is owned by the subscriber.
self.assertIsNotNone(workflow.token)
self.assertEqual(workflow.token_owner, TokenOwner.subscriber)
# Confirm.
confirm_workflow = UnSubscriptionWorkflow(self._mlist)
confirm_workflow.token = workflow.token
confirm_workflow.restore()
list(confirm_workflow)
# Bart is now unsubscribed.
member = self._mlist.regular_members.get_member(
'anne.person@example.com')
self.assertIsNone(member)
# No further token is needed.
self.assertIsNone(confirm_workflow.token)
self.assertEqual(confirm_workflow.token_owner, TokenOwner.no_one)
def test_do_confirmation_nonmember(self):
# Attempt to confirm the unsubscription of a member who has already
# been unsubscribed.
self._mlist.unsubscription_policy = SubscriptionPolicy.confirm
workflow = UnSubscriptionWorkflow(self._mlist, self.anne)
list(workflow)
# Anne is a member.
member = self._mlist.regular_members.get_member(self._anne)
self.assertIsNotNone(member)
self.assertEqual(member, workflow.member)
# The token is owned by the subscriber.
self.assertIsNotNone(workflow.token)
self.assertEqual(workflow.token_owner, TokenOwner.subscriber)
# Unsubscribe Anne out of band.
member.unsubscribe()
# Confirm.
confirm_workflow = UnSubscriptionWorkflow(self._mlist)
confirm_workflow.token = workflow.token
confirm_workflow.restore()
list(confirm_workflow)
# No further token is needed.
self.assertIsNone(confirm_workflow.token)
self.assertEqual(confirm_workflow.token_owner, TokenOwner.no_one)
def test_do_confirmation_nonmember_final_step(self):
# Attempt to confirm the unsubscription of a member who has already
# been unsubscribed.
self._mlist.unsubscription_policy = SubscriptionPolicy.confirm
workflow = UnSubscriptionWorkflow(self._mlist, self.anne)
list(workflow)
# Anne is a member.
member = self._mlist.regular_members.get_member(self._anne)
self.assertIsNotNone(member)
self.assertEqual(member, workflow.member)
# The token is owned by the subscriber.
self.assertIsNotNone(workflow.token)
self.assertEqual(workflow.token_owner, TokenOwner.subscriber)
# Confirm.
confirm_workflow = UnSubscriptionWorkflow(self._mlist)
confirm_workflow.token = workflow.token
confirm_workflow.restore()
confirm_workflow.run_until('do_unsubscription')
self.assertEqual(member, confirm_workflow.member)
# Unsubscribe Anne out of band.
member.unsubscribe()
list(confirm_workflow)
self.assertIsNone(confirm_workflow.member)
# No further token is needed.
self.assertIsNone(confirm_workflow.token)
self.assertEqual(confirm_workflow.token_owner, TokenOwner.no_one)
def test_prevent_confirmation_replay_attacks(self):
# Ensure that if the workflow requires two confirmations, e.g. first
# the user confirming their subscription, and then the moderator
# approving it, that different tokens are used in these two cases.
self._mlist.unsubscription_policy = (
SubscriptionPolicy.confirm_then_moderate)
workflow = UnSubscriptionWorkflow(self._mlist, self.anne)
# Run the state machine up to the first confirmation, and cache the
# confirmation token.
list(workflow)
token = workflow.token
# Anne is still a member of the mailing list.
member = self._mlist.regular_members.get_member(self._anne)
self.assertIsNotNone(member)
self.assertIsNotNone(workflow.member)
# The token is owned by the subscriber.
self.assertIsNotNone(workflow.token)
self.assertEqual(workflow.token_owner, TokenOwner.subscriber)
# The old token will not work for moderator approval.
moderator_workflow = UnSubscriptionWorkflow(self._mlist)
moderator_workflow.token = token
moderator_workflow.restore()
list(moderator_workflow)
# The token is owned by the moderator.
self.assertIsNotNone(moderator_workflow.token)
self.assertEqual(moderator_workflow.token_owner, TokenOwner.moderator)
# While we wait for the moderator to approve the subscription, note
# that there's a new token for the next steps.
self.assertNotEqual(token, moderator_workflow.token)
# The old token won't work.
final_workflow = UnSubscriptionWorkflow(self._mlist)
final_workflow.token = token
self.assertRaises(LookupError, final_workflow.restore)
# Running this workflow will fail.
self.assertRaises(AssertionError, list, final_workflow)
# Anne is still not unsubscribed.
member = self._mlist.regular_members.get_member(self._anne)
self.assertIsNotNone(member)
self.assertIsNone(final_workflow.member)
# However, if we use the new token, her unsubscription request will be
# approved by the moderator.
final_workflow.token = moderator_workflow.token
final_workflow.restore()
list(final_workflow)
# And now Anne is unsubscribed.
member = self._mlist.regular_members.get_member(self._anne)
self.assertIsNone(member)
# No further token is needed.
self.assertIsNone(final_workflow.token)
self.assertEqual(final_workflow.token_owner, TokenOwner.no_one)
def test_confirmation_needed_and_pre_confirmed(self):
# The subscription policy is 'confirm' but the subscription is
# pre-confirmed so the moderation checks can be skipped.
self._mlist.unsubscription_policy = SubscriptionPolicy.confirm
workflow = UnSubscriptionWorkflow(
self._mlist, self.anne, pre_confirmed=True, pre_approved=True)
list(workflow)
# Anne was unsubscribed.
self.assertIsNone(workflow.token)
self.assertEqual(workflow.token_owner, TokenOwner.no_one)
self.assertIsNone(workflow.member)
def test_confirmation_needed_moderator_address(self):
address = self.anne.register('anne.person@example.com')
self._mlist.subscribe(address)
self._mlist.unsubscription_policy = SubscriptionPolicy.moderate
workflow = UnSubscriptionWorkflow(self._mlist, address)
# Get moderator approval.
list(workflow)
approved_workflow = UnSubscriptionWorkflow(self._mlist)
approved_workflow.token = workflow.token
approved_workflow.restore()
list(approved_workflow)
self.assertEqual(approved_workflow.subscriber, address)
# Anne was unsubscribed.
self.assertIsNone(approved_workflow.token)
self.assertEqual(approved_workflow.token_owner, TokenOwner.no_one)
self.assertIsNone(approved_workflow.member)
member = self._mlist.regular_members.get_member(
'anne.person@example.com')
self.assertIsNone(member)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5125763
mailman-3.3.10/src/mailman/app/tests/test_workflow.py 0000644 0000000 0000000 00000014110 14542770442 017562 0 ustar 00 # Copyright (C) 2015-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""App-level workflow tests."""
import json
import unittest
from mailman.app.workflow import Workflow
from mailman.interfaces.workflow import IWorkflowStateManager
from mailman.testing.layers import ConfigLayer
from zope.component import getUtility
class MyWorkflow(Workflow):
INITIAL_STATE = 'first'
SAVE_ATTRIBUTES = ('ant', 'bee', 'cat')
def __init__(self):
super().__init__()
self.token = 'test-workflow'
self.ant = 1
self.bee = 2
self.cat = 3
self.dog = 4
def _step_first(self):
self.push('second')
return 'one'
def _step_second(self):
self.push('third')
return 'two'
def _step_third(self):
return 'three'
class DependentWorkflow(MyWorkflow):
SAVE_ATTRIBUTES = ('ant', 'bee', 'cat', 'elf')
def __init__(self):
super().__init__()
self._elf = 5
@property
def elf(self):
return self._elf
@elf.setter
def elf(self, value):
# This attribute depends on other attributes.
assert self.ant is not None
assert self.bee is not None
assert self.cat is not None
self._elf = value
class TestWorkflow(unittest.TestCase):
layer = ConfigLayer
def setUp(self):
self._workflow = iter(MyWorkflow())
def test_basic_workflow(self):
# The work flows from one state to the next.
results = list(self._workflow)
self.assertEqual(results, ['one', 'two', 'three'])
def test_partial_workflow(self):
# You don't have to flow through every step.
results = next(self._workflow)
self.assertEqual(results, 'one')
def test_exhaust_workflow(self):
# Manually flow through a few steps, then consume the whole thing.
results = [next(self._workflow)]
results.extend(self._workflow)
self.assertEqual(results, ['one', 'two', 'three'])
def test_save_and_restore_workflow(self):
# Without running any steps, save and restore the workflow. Then
# consume the restored workflow.
self._workflow.save()
new_workflow = MyWorkflow()
new_workflow.restore()
results = list(new_workflow)
self.assertEqual(results, ['one', 'two', 'three'])
def test_save_and_restore_partial_workflow(self):
# After running a few steps, save and restore the workflow. Then
# consume the restored workflow.
next(self._workflow)
self._workflow.save()
new_workflow = MyWorkflow()
new_workflow.restore()
results = list(new_workflow)
self.assertEqual(results, ['two', 'three'])
def test_save_and_restore_exhausted_workflow(self):
# After consuming the entire workflow, save and restore it.
list(self._workflow)
self._workflow.save()
new_workflow = MyWorkflow()
new_workflow.restore()
results = list(new_workflow)
self.assertEqual(len(results), 0)
def test_save_and_restore_attributes(self):
# Saved attributes are restored.
self._workflow.ant = 9
self._workflow.bee = 8
self._workflow.cat = 7
# Don't save .dog.
self._workflow.save()
new_workflow = MyWorkflow()
new_workflow.restore()
self.assertEqual(new_workflow.ant, 9)
self.assertEqual(new_workflow.bee, 8)
self.assertEqual(new_workflow.cat, 7)
self.assertEqual(new_workflow.dog, 4)
def test_save_and_restore_dependant_attributes(self):
# Attributes must be restored in the order they are declared in
# SAVE_ATTRIBUTES.
workflow = iter(DependentWorkflow())
workflow.elf = 6
workflow.save()
new_workflow = DependentWorkflow()
# The elf attribute must be restored last, set triggering values for
# attributes it depends on.
new_workflow.ant = new_workflow.bee = new_workflow.cat = None
new_workflow.restore()
self.assertEqual(new_workflow.elf, 6)
def test_save_and_restore_obsolete_attributes(self):
# Obsolete saved attributes are ignored.
state_manager = getUtility(IWorkflowStateManager)
# Save the state of an old version of the workflow that would not have
# the cat attribute.
state_manager.save(
self._workflow.token, 'first',
json.dumps({'ant': 1, 'bee': 2}))
# Restore in the current version that needs the cat attribute.
new_workflow = MyWorkflow()
try:
new_workflow.restore()
except KeyError:
self.fail('Restore does not handle obsolete attributes')
# Restoring must not raise an exception, the default value is kept.
self.assertEqual(new_workflow.cat, 3)
def test_run_thru(self):
# Run all steps through the given one.
results = self._workflow.run_thru('second')
self.assertEqual(results, ['one', 'two'])
def test_run_thru_completes(self):
results = self._workflow.run_thru('all of them')
self.assertEqual(results, ['one', 'two', 'three'])
def test_run_until(self):
# Run until (but not including) the given step.
results = self._workflow.run_until('second')
self.assertEqual(results, ['one'])
def test_run_until_completes(self):
results = self._workflow.run_until('all of them')
self.assertEqual(results, ['one', 'two', 'three'])
././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1703670049.512988
mailman-3.3.10/src/mailman/app/tests/test_workflowmanager.py 0000644 0000000 0000000 00000032324 14542770442 021124 0 ustar 00 # Copyright (C) 2012-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Test email address registration."""
import unittest
from mailman.app.lifecycle import create_list
from mailman.interfaces.mailinglist import SubscriptionPolicy
from mailman.interfaces.member import MemberRole
from mailman.interfaces.pending import IPendings
from mailman.interfaces.subscriptions import ISubscriptionManager, TokenOwner
from mailman.interfaces.usermanager import IUserManager
from mailman.testing.helpers import get_queue_messages
from mailman.testing.layers import ConfigLayer
from mailman.utilities.datetime import now
from zope.component import getUtility
class TestRegistrar(unittest.TestCase):
"""Test registration."""
layer = ConfigLayer
def setUp(self):
self._mlist = create_list('ant@example.com')
self._registrar = ISubscriptionManager(self._mlist)
self._pendings = getUtility(IPendings)
self._anne = getUtility(IUserManager).create_address(
'anne@example.com')
def test_initial_conditions(self):
# Registering a subscription request provides a unique token associated
# with a pendable, and the owner of the token.
self.assertEqual(self._pendings.count(), 0)
token, token_owner, member = self._registrar.register(self._anne)
self.assertIsNotNone(token)
self.assertEqual(token_owner, TokenOwner.subscriber)
self.assertIsNone(member)
self.assertEqual(self._pendings.count(), 1)
record = self._pendings.confirm(token, expunge=False)
self.assertEqual(record['list_id'], self._mlist.list_id)
self.assertEqual(record['email'], 'anne@example.com')
def test_subscribe(self):
# Registering a subscription request where no confirmation or
# moderation steps are needed, leaves us with no token or owner, since
# there's nothing more to do.
self._mlist.subscription_policy = SubscriptionPolicy.open
self._anne.verified_on = now()
token, token_owner, rmember = self._registrar.register(self._anne)
self.assertIsNone(token)
self.assertEqual(token_owner, TokenOwner.no_one)
member = self._mlist.regular_members.get_member('anne@example.com')
self.assertEqual(rmember, member)
self.assertEqual(member.address, self._anne)
# There's nothing to confirm.
record = self._pendings.confirm(token, expunge=False)
self.assertIsNone(record)
def test_no_such_token(self):
# Given a token which is not in the database, a LookupError is raised.
self._registrar.register(self._anne)
self.assertRaises(LookupError, self._registrar.confirm, 'not-a-token')
def test_confirm_because_verify(self):
# We have a subscription request which requires the user to confirm
# (because she does not have a verified address), but not the moderator
# to approve. Running the workflow gives us a token. Confirming the
# token subscribes the user.
self._mlist.subscription_policy = SubscriptionPolicy.open
token, token_owner, rmember = self._registrar.register(self._anne)
self.assertIsNotNone(token)
self.assertEqual(token_owner, TokenOwner.subscriber)
self.assertIsNone(rmember)
member = self._mlist.regular_members.get_member('anne@example.com')
self.assertIsNone(member)
# Now confirm the subscription.
token, token_owner, rmember = self._registrar.confirm(token)
self.assertIsNone(token)
self.assertEqual(token_owner, TokenOwner.no_one)
member = self._mlist.regular_members.get_member('anne@example.com')
self.assertEqual(rmember, member)
self.assertEqual(member.address, self._anne)
def test_confirm_because_confirm(self):
# We have a subscription request which requires the user to confirm
# (because of list policy), but not the moderator to approve. Running
# the workflow gives us a token. Confirming the token subscribes the
# user.
self._mlist.subscription_policy = SubscriptionPolicy.confirm
self._anne.verified_on = now()
token, token_owner, rmember = self._registrar.register(self._anne)
self.assertIsNotNone(token)
self.assertEqual(token_owner, TokenOwner.subscriber)
self.assertIsNone(rmember)
member = self._mlist.regular_members.get_member('anne@example.com')
self.assertIsNone(member)
# Now confirm the subscription.
token, token_owner, rmember = self._registrar.confirm(token)
self.assertIsNone(token)
self.assertEqual(token_owner, TokenOwner.no_one)
member = self._mlist.regular_members.get_member('anne@example.com')
self.assertEqual(rmember, member)
self.assertEqual(member.address, self._anne)
def test_confirm_because_moderation(self):
# We have a subscription request which requires the moderator to
# approve. Running the workflow gives us a token. Confirming the
# token subscribes the user.
self._mlist.subscription_policy = SubscriptionPolicy.moderate
self._anne.verified_on = now()
token, token_owner, rmember = self._registrar.register(self._anne)
self.assertIsNotNone(token)
self.assertEqual(token_owner, TokenOwner.moderator)
self.assertIsNone(rmember)
member = self._mlist.regular_members.get_member('anne@example.com')
self.assertIsNone(member)
# Now confirm the subscription.
token, token_owner, rmember = self._registrar.confirm(token)
self.assertIsNone(token)
self.assertEqual(token_owner, TokenOwner.no_one)
member = self._mlist.regular_members.get_member('anne@example.com')
self.assertEqual(rmember, member)
self.assertEqual(member.address, self._anne)
def test_confirm_because_confirm_then_moderation(self):
# We have a subscription request which requires the user to confirm
# (because she does not have a verified address) and the moderator to
# approve. Running the workflow gives us a token. Confirming the
# token runs the workflow a little farther, but still gives us a
# token. Confirming again subscribes the user.
self._mlist.subscription_policy = (
SubscriptionPolicy.confirm_then_moderate)
self._anne.verified_on = now()
# Runs until subscription confirmation.
token, token_owner, rmember = self._registrar.register(self._anne)
self.assertIsNotNone(token)
self.assertEqual(token_owner, TokenOwner.subscriber)
self.assertIsNone(rmember)
member = self._mlist.regular_members.get_member('anne@example.com')
self.assertIsNone(member)
# Now confirm the subscription, and wait for the moderator to approve
# the subscription. She is still not subscribed.
new_token, token_owner, rmember = self._registrar.confirm(token)
# The new token, used for the moderator to approve the message, is not
# the same as the old token.
self.assertNotEqual(new_token, token)
self.assertIsNotNone(new_token)
self.assertEqual(token_owner, TokenOwner.moderator)
self.assertIsNone(rmember)
member = self._mlist.regular_members.get_member('anne@example.com')
self.assertIsNone(member)
# Confirm once more, this time as the moderator approving the
# subscription. Now she's a member.
token, token_owner, rmember = self._registrar.confirm(new_token)
self.assertIsNone(token)
self.assertEqual(token_owner, TokenOwner.no_one)
member = self._mlist.regular_members.get_member('anne@example.com')
self.assertEqual(rmember, member)
self.assertEqual(member.address, self._anne)
def test_confirm_then_moderate_with_different_tokens(self):
# Ensure that the confirmation token the user sees when they have to
# confirm their subscription is different than the token the moderator
# sees when they approve the subscription. This prevents the user
# from using a replay attack to subvert moderator approval.
self._mlist.subscription_policy = (
SubscriptionPolicy.confirm_then_moderate)
self._anne.verified_on = now()
# Runs until subscription confirmation.
token, token_owner, rmember = self._registrar.register(self._anne)
self.assertIsNotNone(token)
self.assertEqual(token_owner, TokenOwner.subscriber)
self.assertIsNone(rmember)
member = self._mlist.regular_members.get_member('anne@example.com')
self.assertIsNone(member)
# Now confirm the subscription, and wait for the moderator to approve
# the subscription. She is still not subscribed.
new_token, token_owner, rmember = self._registrar.confirm(token)
# The status is not true because the user has not yet been subscribed
# to the mailing list.
self.assertIsNotNone(new_token)
self.assertEqual(token_owner, TokenOwner.moderator)
self.assertIsNone(rmember)
member = self._mlist.regular_members.get_member('anne@example.com')
self.assertIsNone(member)
# The new token is different than the old token.
self.assertNotEqual(token, new_token)
# Trying to confirm with the old token does not work.
self.assertRaises(LookupError, self._registrar.confirm, token)
# Confirm once more, this time with the new token, as the moderator
# approving the subscription. Now she's a member.
done_token, token_owner, rmember = self._registrar.confirm(new_token)
# The token is None, signifying that the member has been subscribed.
self.assertIsNone(done_token)
self.assertEqual(token_owner, TokenOwner.no_one)
member = self._mlist.regular_members.get_member('anne@example.com')
self.assertEqual(rmember, member)
self.assertEqual(member.address, self._anne)
def test_discard_waiting_for_confirmation(self):
# While waiting for a user to confirm their subscription, we discard
# the workflow.
self._mlist.subscription_policy = SubscriptionPolicy.confirm
self._anne.verified_on = now()
# Runs until subscription confirmation.
token, token_owner, rmember = self._registrar.register(self._anne)
self.assertIsNotNone(token)
self.assertEqual(token_owner, TokenOwner.subscriber)
self.assertIsNone(rmember)
member = self._mlist.regular_members.get_member('anne@example.com')
self.assertIsNone(member)
# Now discard the subscription request.
self._registrar.discard(token)
# Trying to confirm the token now results in an exception.
self.assertRaises(LookupError, self._registrar.confirm, token)
def test_admin_notify_mchanges(self):
# When a user gets subscribed via the subscription policy workflow,
# the list administrators get an email notification.
self._mlist.subscription_policy = SubscriptionPolicy.open
self._mlist.admin_notify_mchanges = True
self._mlist.send_welcome_message = False
token, token_owner, member = self._registrar.register(
self._anne, pre_verified=True)
# Anne is now a member.
self.assertEqual(member.address.email, 'anne@example.com')
# And there's a notification email waiting for Bart.
items = get_queue_messages('virgin', expected_count=1)
message = items[0].msg
self.assertEqual(message['To'], 'ant-owner@example.com')
self.assertEqual(message['Subject'], 'Ant subscription notification')
self.assertEqual(message.get_payload(), """\
anne@example.com has been successfully subscribed to Ant.
""")
def test_no_admin_notify_mchanges(self):
# Even when a user gets subscribed via the subscription policy
# workflow, the list administrators won't get an email notification if
# they don't want one.
self._mlist.subscription_policy = SubscriptionPolicy.open
self._mlist.admin_notify_mchanges = False
self._mlist.send_welcome_message = False
# Bart is an administrator of the mailing list.
bart = getUtility(IUserManager).create_address(
'bart@example.com', 'Bart Person')
self._mlist.subscribe(bart, MemberRole.owner)
token, token_owner, member = self._registrar.register(
self._anne, pre_verified=True)
# Anne is now a member.
self.assertEqual(member.address.email, 'anne@example.com')
# There's no notification email waiting for Bart.
get_queue_messages('virgin', expected_count=0)
././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1703670049.513328
mailman-3.3.10/src/mailman/app/workflow.py 0000644 0000000 0000000 00000011772 14542770442 015374 0 ustar 00 # Copyright (C) 2015-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Generic workflow."""
import sys
import json
import logging
from collections import deque
from mailman.interfaces.workflow import IWorkflowStateManager
from public import public
from zope.component import getUtility
COMMASPACE = ', '
log = logging.getLogger('mailman.error')
@public
class Workflow:
"""Generic workflow."""
SAVE_ATTRIBUTES = ()
INITIAL_STATE = None
def __init__(self):
self.token = None
self._next = deque()
self.push(self.INITIAL_STATE)
self.debug = False
self._count = 0
@property
def name(self):
return self.__class__.__name__
def __iter__(self):
return self
def push(self, step):
self._next.append(step)
def _pop(self):
name = self._next.popleft()
step = getattr(self, '_step_{}'.format(name))
self._count += 1
if self.debug: # pragma: nocover
print('[{:02d}] -> {}'.format(self._count, name), file=sys.stderr)
return name, step
def __next__(self):
try:
name, step = self._pop()
return step()
except (IndexError, StopIteration):
# Catch StopIteration so it's not logged. See #1059.
raise StopIteration
except: # noqa: E722
log.exception('deque: {}'.format(COMMASPACE.join(self._next)))
raise
def run_thru(self, stop_after):
"""Run the state machine through and including the given step.
:param stop_after: Name of method, sans prefix to run the
state machine through. In other words, the state machine runs
until the named method completes.
"""
results = []
while True:
try:
name, step = self._pop()
except (StopIteration, IndexError):
# We're done.
break
results.append(step())
if name == stop_after:
break
return results
def run_until(self, stop_before):
"""Trun the state machine until (not including) the given step.
:param stop_before: Name of method, sans prefix that the
state machine is run until the method is reached. Unlike
`run_thru()` the named method is not run.
"""
results = []
while True:
try:
name, step = self._pop()
except (StopIteration, IndexError):
# We're done.
break
if name == stop_before:
# Stop executing, but not before we push the last state back
# onto the deque. Otherwise, resuming the state machine would
# skip this step.
self._next.appendleft(name)
break
results.append(step())
return results
def save(self):
assert self.token, 'Workflow token must be set'
state_manager = getUtility(IWorkflowStateManager)
data = {attr: getattr(self, attr) for attr in self.SAVE_ATTRIBUTES}
# Note: only the next step is saved, not the whole stack. This is not
# an issue in practice, since there's never more than a single step in
# the queue anyway. If we want to support more than a single step in
# the queue *and* want to support state saving/restoring, change this
# method and the restore() method.
if len(self._next) == 0:
step = None
elif len(self._next) == 1:
step = self._next[0]
else:
raise AssertionError(
"Can't save a workflow state with more than one step "
"in the queue")
state_manager.save(self.token, step, json.dumps(data))
def restore(self):
state_manager = getUtility(IWorkflowStateManager)
state = state_manager.restore(self.token)
if state is None:
# The token doesn't exist in the database.
raise LookupError(self.token)
self._next.clear()
if state.step:
self._next.append(state.step)
data = json.loads(state.data)
for attr in self.SAVE_ATTRIBUTES:
try:
setattr(self, attr, data[attr])
except KeyError:
pass
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1672813222.7615185
mailman-3.3.10/src/mailman/archiving/__init__.py 0000644 0000000 0000000 00000000000 14355215247 016430 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1672813222.7628546
mailman-3.3.10/src/mailman/archiving/docs/__init__.py 0000644 0000000 0000000 00000000000 14355215247 017360 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1672838243.9099164
mailman-3.3.10/src/mailman/archiving/docs/common.rst 0000644 0000000 0000000 00000013655 14355276144 017320 0 ustar 00 =========
Archivers
=========
Mailman supports pluggable archivers, and it comes with several default
archivers.
>>> from mailman.app.lifecycle import create_list
>>> mlist = create_list('test@example.com')
>>> from mailman.testing.helpers import (specialized_message_from_string
... as message_from_string)
>>> msg = message_from_string("""\
... From: aperson@example.org
... To: test@example.com
... Subject: An archived message
... Message-ID: <12345>
... X-Message-ID-Hash: RSZCG7IGPHFIRW3EMTVMMDNJMNCVCOLE
...
... Here is an archived message.
... """)
Archivers support an interface which provides the RFC 2369 ``List-Archive:``
header, and one that provides a *permalink* to the specific message object in
the archive. This latter is appropriate for the message footer or for the RFC
5064 ``Archived-At:`` header.
If the archiver is not network-accessible, it will return ``None`` and the
headers will not be added.
Mailman defines a draft spec for how list servers and archivers can
interoperate.
>>> archivers = {}
>>> from operator import attrgetter
>>> from mailman.config import config
>>> for archiver in sorted(config.archivers, key=attrgetter('name')):
... print(archiver.name)
... print(' ', archiver.list_url(mlist))
... print(' ', archiver.permalink(mlist, msg))
... archivers[archiver.name] = archiver
mail-archive
http://go.mail-archive.dev/test%40example.com
http://go.mail-archive.dev/RSZCG7IGPHFIRW3EMTVMMDNJMNCVCOLE
mhonarc
http://example.com/.../test@example.com
http://example.com/.../RSZCG7IGPHFIRW3EMTVMMDNJMNCVCOLE
prototype
None
None
Sending the message to the archiver
===================================
The `prototype` archiver archives messages to a maildir.
>>> import os
>>> archivers['prototype'].archive_message(mlist, msg)
>>> archive_path = os.path.join(
... config.ARCHIVE_DIR, 'prototype', mlist.fqdn_listname, 'new')
>>> len(os.listdir(archive_path))
1
The Mail-Archive.com
====================
`The Mail Archive`_ is a public archiver that can be used to archive message
for free. Mailman comes with a plugin for this archiver; by enabling it
messages to public lists will get sent there automatically.
>>> archiver = archivers['mail-archive']
>>> print(archiver.list_url(mlist))
http://go.mail-archive.dev/test%40example.com
>>> print(archiver.permalink(mlist, msg))
http://go.mail-archive.dev/RSZCG7IGPHFIRW3EMTVMMDNJMNCVCOLE
To archive the message, the archiver actually mails the message to a special
address at The Mail Archive. The message gets no header or footer decoration.
::
>>> from mailman.interfaces.archiver import ArchivePolicy
>>> mlist.archive_policy = ArchivePolicy.public
>>> archiver.archive_message(mlist, msg)
>>> from mailman.runners.outgoing import OutgoingRunner
>>> from mailman.testing.helpers import make_testable_runner
>>> outgoing = make_testable_runner(OutgoingRunner, 'out')
>>> outgoing.run()
>>> from operator import itemgetter
>>> messages = list(smtpd.messages)
>>> len(messages)
1
>>> print(messages[0].as_string())
From: aperson@example.org
To: test@example.com
Subject: An archived message
Message-ID: <12345>
X-Message-ID-Hash: RSZCG7IGPHFIRW3EMTVMMDNJMNCVCOLE
X-Peer: ...
X-MailFrom: test-bounces@example.com
X-RcptTo: archive@mail-archive.dev
Here is an archived message.
>>> smtpd.clear()
However, if the mailing list is not public, the message will never be archived
at this service.
>>> mlist.archive_policy = ArchivePolicy.private
>>> print(archiver.list_url(mlist))
None
>>> print(archiver.permalink(mlist, msg))
None
>>> archiver.archive_message(mlist, msg)
>>> list(smtpd.messages)
[]
Additionally, this archiver can handle malformed ``Message-IDs``.
::
>>> from mailman.utilities.email import add_message_hash
>>> mlist.archive_policy = ArchivePolicy.public
>>> del msg['message-id']
>>> del msg['x-message-id-hash']
>>> msg['Message-ID'] = '12345>'
>>> add_message_hash(msg)
'YJIGBYRWZFG5LZEBQ7NR25B5HBR2BVD6'
>>> print(archiver.permalink(mlist, msg))
http://go.mail-archive.dev/YJIGBYRWZFG5LZEBQ7NR25B5HBR2BVD6
>>> del msg['message-id']
>>> del msg['x-message-id-hash']
>>> msg['Message-ID'] = '<12345'
>>> add_message_hash(msg)
'XUFFJNJ2P2WC4NDPQRZFDJMV24POP64B'
>>> print(archiver.permalink(mlist, msg))
http://go.mail-archive.dev/XUFFJNJ2P2WC4NDPQRZFDJMV24POP64B
>>> del msg['message-id']
>>> del msg['x-message-id-hash']
>>> msg['Message-ID'] = '12345'
>>> add_message_hash(msg)
'RSZCG7IGPHFIRW3EMTVMMDNJMNCVCOLE'
>>> print(archiver.permalink(mlist, msg))
http://go.mail-archive.dev/RSZCG7IGPHFIRW3EMTVMMDNJMNCVCOLE
>>> del msg['message-id']
>>> del msg['x-message-id-hash']
>>> add_message_hash(msg)
>>> msg['Message-ID'] = ' 12345 '
>>> add_message_hash(msg)
'RSZCG7IGPHFIRW3EMTVMMDNJMNCVCOLE'
>>> print(archiver.permalink(mlist, msg))
http://go.mail-archive.dev/RSZCG7IGPHFIRW3EMTVMMDNJMNCVCOLE
MHonArc
=======
A MHonArc_ archiver is also available.
>>> archiver = archivers['mhonarc']
>>> print(archiver.name)
mhonarc
Messages sent to a local MHonArc instance are added to its archive via a
subprocess call.
>>> from mailman.testing.helpers import LogFileMark
>>> mark = LogFileMark('mailman.archiver')
>>> archiver.archive_message(mlist, msg)
>>> print('LOG:', mark.readline())
LOG: ... /usr/bin/mhonarc
-add
-dbfile .../test@example.com.mbox/mhonarc.db
-outdir .../mhonarc/test@example.com
-stderr .../logs/mhonarc
-stdout .../logs/mhonarc -spammode -umask 022
.. _`The Mail Archive`: https://www.mail-archive.com
.. _MHonArc: https://www.mhonarc.org
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5135276
mailman-3.3.10/src/mailman/archiving/mailarchive.py 0000644 0000000 0000000 00000005473 14542770442 017201 0 ustar 00 # Copyright (C) 2008-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""The Mail-Archive.com archiver."""
from mailman.config import config
from mailman.config.config import external_configuration
from mailman.interfaces.archiver import ArchivePolicy, IArchiver
from public import public
from urllib.parse import quote, urljoin
from zope.interface import implementer
@public
@implementer(IArchiver)
class MailArchive:
"""Public archiver at the Mail-Archive.com.
Messages get archived at http://go.mail-archive.com.
"""
name = 'mail-archive'
is_enabled = False
def __init__(self):
# Read our specific configuration file
archiver_config = external_configuration(
config.archiver.mail_archive.configuration)
self.base_url = archiver_config.get('general', 'base_url')
self.recipient = archiver_config.get('general', 'recipient')
def list_url(self, mlist):
"""See `IArchiver`."""
if mlist.archive_policy is ArchivePolicy.public:
return urljoin(self.base_url, quote(mlist.posting_address))
return None
def permalink(self, mlist, msg):
"""See `IArchiver`."""
if mlist.archive_policy is not ArchivePolicy.public:
return None
# It is the LMTP server's responsibility to ensure that the message has
# a Message-ID-Hash header. For backward compatibility, fallback to
# searching for X-Message-ID-Hash. If the message has neither, then
# there's no permalink.
message_id_hash = msg.get('message-id-hash')
if message_id_hash is None:
message_id_hash = msg.get('x-message-id-hash')
if message_id_hash is None:
return None
if isinstance(message_id_hash, bytes):
message_id_hash = message_id_hash.decode('ascii')
return urljoin(self.base_url, message_id_hash)
def archive_message(self, mlist, msg):
"""See `IArchiver`."""
if mlist.archive_policy is ArchivePolicy.public:
config.switchboards['out'].enqueue(
msg,
listid=mlist.list_id,
recipients=[self.recipient])
return None
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5137088
mailman-3.3.10/src/mailman/archiving/mhonarc.py 0000644 0000000 0000000 00000006561 14542770442 016343 0 ustar 00 # Copyright (C) 2008-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""MHonArc archiver."""
import logging
from mailman.config import config
from mailman.config.config import external_configuration
from mailman.interfaces.archiver import IArchiver
from mailman.utilities.string import expand
from public import public
from subprocess import PIPE, Popen
from urllib.parse import urljoin
from zope.interface import implementer
log = logging.getLogger('mailman.archiver')
@public
@implementer(IArchiver)
class MHonArc:
"""Local MHonArc archiver."""
name = 'mhonarc'
is_enabled = False
def __init__(self):
# Read our specific configuration file
archiver_config = external_configuration(
config.archiver.mhonarc.configuration)
self.base_url = archiver_config.get('general', 'base_url')
self.command = archiver_config.get('general', 'command')
def list_url(self, mlist):
"""See `IArchiver`."""
# XXX What about private MHonArc archives?
return expand(self.base_url, mlist, dict(
# For backward compatibility.
hostname=mlist.domain.mail_host,
fqdn_listname=mlist.fqdn_listname,
))
def permalink(self, mlist, msg):
"""See `IArchiver`."""
# XXX What about private MHonArc archives?
#
# It is the LMTP server's responsibility to ensure that the message has
# a Message-ID-Hash header. For backward compatibility, fall back to
# X-Message-ID-Hash. If the message has neither, then there's no
# permalink.
message_id_hash = msg.get('message-id-hash')
if message_id_hash is None:
message_id_hash = msg.get('x-message-id-hash')
if message_id_hash is None:
return None
if isinstance(message_id_hash, bytes):
message_id_hash = message_id_hash.decode('ascii')
return urljoin(self.list_url(mlist), message_id_hash)
def archive_message(self, mlist, msg):
"""See `IArchiver`."""
substitutions = config.__dict__.copy()
substitutions['listname'] = mlist.fqdn_listname
command = expand(self.command, mlist, substitutions)
proc = Popen(
command,
stdin=PIPE, stdout=PIPE, stderr=PIPE,
universal_newlines=True, shell=True)
stdout, stderr = proc.communicate(msg.as_string())
if proc.returncode != 0:
log.error('%s: mhonarc subprocess had non-zero exit code: %s' %
(msg['message-id'], proc.returncode))
log.info(stdout)
log.error(stderr)
# Can we get more information, such as the url to the message just
# archived, out of MHonArc?
return None
././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1703670049.513914
mailman-3.3.10/src/mailman/archiving/prototype.py 0000644 0000000 0000000 00000007462 14542770442 016762 0 ustar 00 # Copyright (C) 2008-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Prototypical permalinking archiver."""
import os
import logging
from contextlib import suppress
from datetime import timedelta
from flufl.lock import Lock, TimeOutError
from mailbox import Maildir
from mailman.config import config
from mailman.interfaces.archiver import IArchiver
from public import public
from zope.interface import implementer
log = logging.getLogger('mailman.error')
@public
@implementer(IArchiver)
class Prototype:
"""A prototype of a third party archiver.
Mailman proposes a draft specification for interoperability between list
servers and archivers: .
"""
name = 'prototype'
is_enabled = False
@staticmethod
def list_url(mlist):
"""See `IArchiver`."""
# This archiver is not web-accessible, therefore no URL is returned.
return None
@staticmethod
def permalink(mlist, msg):
"""See `IArchiver`."""
# This archiver is not web-accessible, therefore no URL is returned.
return None
@staticmethod
def archive_message(mlist, message):
"""See `IArchiver`.
This archiver saves messages into a maildir.
"""
archive_dir = os.path.join(config.ARCHIVE_DIR, 'prototype')
with suppress(FileExistsError):
os.makedirs(archive_dir, 0o775)
# Maildir will throw an error if the directories are partially created
# (for instance the toplevel exists but cur, new, or tmp do not)
# therefore we don't create the toplevel as we did above.
list_dir = os.path.join(archive_dir, mlist.fqdn_listname)
mailbox = Maildir(list_dir, create=True, factory=None)
lock_file = os.path.join(
config.LOCK_DIR, '{0}-maildir.lock'.format(mlist.fqdn_listname))
# Lock the maildir as Maildir.add() is not threadsafe. Don't use the
# context manager because it's not an error if we can't acquire the
# archiver lock. We'll just log the problem and continue.
#
# XXX 2012-03-14 BAW: When we extend the chain/pipeline architecture
# to other runners, e.g. the archive runner, it would be better to let
# any TimeOutError propagate up. That would cause the message to be
# re-queued and tried again later, rather than being discarded as
# happens now below.
lock = Lock(lock_file)
try:
lock.lock(timeout=timedelta(seconds=1))
# Add the message to the maildir. The return value could be used
# to construct the file path if necessary. E.g.
#
# os.path.join(archive_dir, mlist.fqdn_listname, 'new',
# message_key)
mailbox.add(message)
except TimeOutError:
# Log the error and go on.
log.error('Unable to acquire prototype archiver lock for {0}, '
'discarding: {1}'.format(
mlist.fqdn_listname,
message.get('message-id', 'n/a')))
finally:
lock.unlock(unconditionally=True)
return None
././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1672813222.762455
mailman-3.3.10/src/mailman/archiving/tests/__init__.py 0000644 0000000 0000000 00000000000 14355215247 017572 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5140972
mailman-3.3.10/src/mailman/archiving/tests/fake_mhonarc.py 0000644 0000000 0000000 00000002173 14542770442 020466 0 ustar 00 # Copyright (C) 2015-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""A fake MHonArc process that reads stdin and writes stdout."""
import sys
from email import message_from_string
def main():
text = sys.stdin.read()
msg = message_from_string(text)
output_file = sys.argv[1]
with open(output_file, 'w', encoding='utf-8') as fp:
print(msg['message-id'], file=fp)
print(msg['message-id-hash'], file=fp)
if __name__ == '__main__':
main()
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5143666
mailman-3.3.10/src/mailman/archiving/tests/test_mhonarc.py 0000644 0000000 0000000 00000005766 14542770442 020552 0 ustar 00 # Copyright (C) 2015-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Test the MHonArc archiver."""
import os
import sys
import shutil
import tempfile
import unittest
from importlib.resources import path
from mailman.app.lifecycle import create_list
from mailman.archiving.mhonarc import MHonArc
from mailman.database.transaction import transaction
from mailman.testing.helpers import (
configuration,
specialized_message_from_string as mfs,
)
from mailman.testing.layers import ConfigLayer
class TestMhonarc(unittest.TestCase):
"""Test the MHonArc archiver."""
layer = ConfigLayer
def setUp(self):
# Create a fake mailing list and message object.
self._msg = mfs("""\
To: test@example.com
From: anne@example.com
Subject: Testing the test list
Message-ID:
Message-ID-Hash: MS6QLWERIJLGCRF44J7USBFDELMNT2BW
Tests are better than no tests
but the water deserves to be swum.
""")
with transaction():
self._mlist = create_list('test@example.com')
tempdir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, tempdir)
# Here's the command to execute our fake MHonArc process.
with path('mailman.archiving.tests', 'fake_mhonarc.py') as source:
shutil.copy(str(source), tempdir)
self._output_file = os.path.join(tempdir, 'output.txt')
command = '{} {} {}'.format(
sys.executable,
os.path.join(tempdir, 'fake_mhonarc.py'),
self._output_file)
# Write an external configuration file which points the command at our
# fake MHonArc process.
self._cfg = os.path.join(tempdir, 'mhonarc.cfg')
with open(self._cfg, 'w', encoding='utf-8') as fp:
print("""\
[general]
base_url: http://$hostname/archives/$fqdn_listname
command: {command}
""".format(command=command), file=fp)
def test_mhonarc(self):
# The archiver properly sends stdin to the subprocess.
with configuration('archiver.mhonarc',
configuration=self._cfg,
enable='yes'):
MHonArc().archive_message(self._mlist, self._msg)
with open(self._output_file, 'r', encoding='utf-8') as fp:
results = fp.read().splitlines()
self.assertEqual(results[0], '')
self.assertEqual(results[1], 'MS6QLWERIJLGCRF44J7USBFDELMNT2BW')
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5145926
mailman-3.3.10/src/mailman/archiving/tests/test_prototype.py 0000644 0000000 0000000 00000015675 14542770442 021170 0 ustar 00 # Copyright (C) 2012-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Test the prototype archiver."""
import os
import shutil
import tempfile
import unittest
import threading
from email import message_from_file
from flufl.lock import Lock
from mailman.app.lifecycle import create_list
from mailman.archiving.prototype import Prototype
from mailman.config import config
from mailman.database.transaction import transaction
from mailman.testing.helpers import (
LogFileMark,
specialized_message_from_string as mfs,
)
from mailman.testing.layers import ConfigLayer
from mailman.utilities.email import add_message_hash
class TestPrototypeArchiver(unittest.TestCase):
"""Test the prototype archiver."""
layer = ConfigLayer
def setUp(self):
# Create a fake mailing list and message object.
self._msg = mfs("""\
To: test@example.com
From: anne@example.com
Subject: Testing the test list
Message-ID:
Message-ID-Hash: MS6QLWERIJLGCRF44J7USBFDELMNT2BW
Tests are better than no tests
but the water deserves to be swum.
""")
with transaction():
self._mlist = create_list('test@example.com')
# Set up a temporary directory for the prototype archiver so that it's
# easier to clean up.
self._tempdir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self._tempdir)
config.push('prototype', """
[paths.testing]
archive_dir: {}
""".format(self._tempdir))
self.addCleanup(config.pop, 'prototype')
# Capture the structure of a maildir.
self._expected_dir_structure = set(
(os.path.join(config.ARCHIVE_DIR, path) for path in (
'prototype',
os.path.join('prototype', self._mlist.fqdn_listname),
os.path.join('prototype', self._mlist.fqdn_listname, 'cur'),
os.path.join('prototype', self._mlist.fqdn_listname, 'new'),
os.path.join('prototype', self._mlist.fqdn_listname, 'tmp'),
)))
self._expected_dir_structure.add(config.ARCHIVE_DIR)
def _find(self, path):
all_filenames = set()
for dirpath, dirnames, filenames in os.walk(path):
if isinstance(dirpath, bytes):
dirpath = dirpath.decode('utf-8')
all_filenames.add(dirpath)
for filename in filenames:
new_filename = filename
if isinstance(filename, bytes):
new_filename = filename.decode('utf-8')
all_filenames.add(os.path.join(dirpath, new_filename))
return all_filenames
def test_archive_maildir_created(self):
# Archiving a message to the prototype archiver should create the
# expected directory structure.
Prototype.archive_message(self._mlist, self._msg)
all_filenames = self._find(config.ARCHIVE_DIR)
# Check that the directory structure has been created and we have one
# more file (the archived message) than expected directories.
archived_messages = all_filenames - self._expected_dir_structure
self.assertEqual(len(archived_messages), 1)
self.assertTrue(
archived_messages.pop().startswith(
os.path.join(config.ARCHIVE_DIR, 'prototype',
self._mlist.fqdn_listname, 'new')))
def test_archive_maildir_existence_does_not_raise(self):
# Archiving a second message does not cause an EEXIST to be raised
# when a second message is archived.
new_dir = None
Prototype.archive_message(self._mlist, self._msg)
for directory in ('cur', 'new', 'tmp'):
path = os.path.join(config.ARCHIVE_DIR, 'prototype',
self._mlist.fqdn_listname, directory)
if directory == 'new':
new_dir = path
self.assertTrue(os.path.isdir(path))
# There should be one message in the 'new' directory.
self.assertEqual(len(os.listdir(new_dir)), 1)
# Archive a second message. If an exception occurs, let it fail the
# test. Afterward, two messages should be in the 'new' directory.
del self._msg['message-id']
del self._msg['message-id-hash']
self._msg['Message-ID'] = ''
add_message_hash(self._msg)
Prototype.archive_message(self._mlist, self._msg)
self.assertEqual(len(os.listdir(new_dir)), 2)
def test_archive_lock_used(self):
# Test that locking the maildir when adding works as a failure here
# could mean we lose mail.
lock_file = os.path.join(
config.LOCK_DIR, '{0}-maildir.lock'.format(
self._mlist.fqdn_listname))
with Lock(lock_file):
# Acquire the archiver lock, then make sure the archiver logs the
# fact that it could not acquire the lock.
archive_thread = threading.Thread(
target=Prototype.archive_message,
args=(self._mlist, self._msg))
mark = LogFileMark('mailman.error')
archive_thread.run()
# Test that the archiver output the correct error.
line = mark.readline()
# XXX 2012-03-15 BAW: we really should remove timestamp prefixes
# from the loggers when under test.
self.assertTrue(line.endswith(
'Unable to acquire prototype archiver lock for {0}, '
'discarding: {1}\n'.format(
self._mlist.fqdn_listname,
self._msg.get('message-id'))))
# Check that the message didn't get archived.
created_files = self._find(config.ARCHIVE_DIR)
self.assertEqual(self._expected_dir_structure, created_files)
def test_prototype_archiver_good_path(self):
# Verify the good path; the message gets archived.
Prototype.archive_message(self._mlist, self._msg)
new_path = os.path.join(
config.ARCHIVE_DIR, 'prototype', self._mlist.fqdn_listname, 'new')
archived_messages = list(os.listdir(new_path))
self.assertEqual(len(archived_messages), 1)
# Check that the email has been added.
with open(os.path.join(new_path, archived_messages[0])) as fp:
archived_message = message_from_file(fp)
self.assertEqual(self._msg.as_string(), archived_message.as_string())
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1672813222.7192805
mailman-3.3.10/src/mailman/bin/__init__.py 0000644 0000000 0000000 00000000000 14355215247 015226 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1672813222.7205336
mailman-3.3.10/src/mailman/bin/docs/master.rst 0000644 0000000 0000000 00000002752 14355215247 016112 0 ustar 00 ======================
Mailman runner control
======================
Mailman has a number of *runner subprocesses* which perform long-running tasks
such as listening on an LMTP port, processing REST API requests, or processing
messages in a queue directory. In normal operation, the ``mailman`` command
is used to start, stop and manage the runners. This is just a wrapper around
the real master watcher, which handles runner starting, stopping, exiting, and
log file reopening.
>>> from mailman.testing.helpers import TestableMaster
Start the master in a sub-thread.
>>> master = TestableMaster()
>>> master.start()
There should be a process id for every runner that claims to be startable.
>>> from lazr.config import as_boolean
>>> startable_runners = [conf for conf in config.runner_configs
... if as_boolean(conf.start)]
>>> len(list(master.runner_pids)) == len(startable_runners)
True
Now verify that all the runners are running.
::
>>> import os
# This should produce no output.
>>> for pid in master.runner_pids:
... os.kill(pid, 0)
Stop the master process, which should also kill (and not restart) the child
runner processes.
>>> master.stop()
None of the children are running now.
>>> import errno
>>> from contextlib import suppress
>>> for pid in master.runner_pids:
... with suppress(ProcessLookupError):
... os.kill(pid, 0)
... print('Process did not exit:', pid)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5148418
mailman-3.3.10/src/mailman/bin/mailman.py 0000644 0000000 0000000 00000012030 14542770442 015114 0 ustar 00 # Copyright (C) 2009-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""The 'mailman' command dispatcher."""
import os
import click
from mailman.commands.cli_help import help as help_command
from mailman.config import config
from mailman.core.i18n import _
from mailman.core.initialize import initialize
from mailman.database.transaction import transaction
from mailman.interfaces.command import ICLISubCommand
from mailman.utilities.modules import add_components
from mailman.version import MAILMAN_VERSION_FULL
from public import public
class Subcommands(click.MultiCommand):
# Handle dynamic listing and loading of `mailman` subcommands.
def __init__(self, *args, **kws):
super().__init__(*args, **kws)
self._commands = {}
self._loaded = False
def _load(self):
# Load commands lazily as commands in plugins can only be found after
# the configuration file is loaded.
if not self._loaded:
add_components('commands', ICLISubCommand, self._commands)
self._loaded = True
def list_commands(self, ctx): # pragma: nocover
self._load()
return sorted(self._commands)
def get_command(self, ctx, name):
self._load()
try:
return self._commands[name].command
except KeyError:
# Returning None here signals click to report usage information
# and a "No such command" error message.
return None
# This is here to hook command parsing into the Mailman database
# transaction system. If the subcommand succeeds, the transaction is
# committed, otherwise it's aborted.
# See https://github.com/pallets/click/issues/1134
def invoke(self, ctx):
# If given a bogus subcommand, the database won't have been
# initialized so there's no transaction to commit.
if config.db is not None:
with transaction():
return super().invoke(ctx)
return super().invoke(ctx) # pragma: missed
# https://github.com/pallets/click/issues/834
#
# Note that this only handles the case for the `mailman --help` output.
# To handle `mailman --help` we create a custom click.Command
# subclass and override this method there too. See
# src/mailman/utilities/options.py
def format_options(self, ctx, formatter):
"""Writes all the options into the formatter if they exist."""
opts = []
for param in self.get_params(ctx):
rv = param.get_help_record(ctx)
if rv is not None:
part_a, part_b = rv
opts.append((part_a, part_b.replace('\n', ' ')))
if opts:
with formatter.section('Options'):
formatter.write_dl(opts)
# Print the list of available commands.
super().format_commands(ctx, formatter)
def initialize_config(ctx, param, value):
if not ctx.resilient_parsing:
initialize(value)
@click.option(
'-C', '--config', 'config_file',
envvar='MAILMAN_CONFIG_FILE',
type=click.Path(exists=True, dir_okay=False, resolve_path=True),
help=_("""\
Configuration file to use. If not given, the environment variable
MAILMAN_CONFIG_FILE is consulted and used if set. If neither are given, a
default configuration file is loaded."""),
is_eager=True, callback=initialize_config)
@click.option(
'--run-as-root',
is_flag=True, default=False,
help=_("""\
Running mailman commands as root is not recommended and mailman will
refuse to run as root unless this option is specified."""))
@click.group(
cls=Subcommands,
context_settings=dict(help_option_names=['-h', '--help']),
invoke_without_command=True)
@click.pass_context
@click.version_option(MAILMAN_VERSION_FULL, message='%(version)s')
@public
def main(ctx, config_file, run_as_root):
# XXX https://github.com/pallets/click/issues/303
"""\
The GNU Mailman mailing list management system
Copyright 1998-2018 by the Free Software Foundation, Inc.
http://www.list.org
"""
# Only run as root if allowed.
if os.geteuid() == 0 and not run_as_root:
raise click.UsageError(_("""\
If you are sure you want to run as root, specify --run-as-root."""))
# click handles dispatching to the subcommand via the Subcommands class.
if ctx.invoked_subcommand is None:
ctx.invoke(help_command)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1726990313.9028306
mailman-3.3.10/src/mailman/bin/master.py 0000644 0000000 0000000 00000062701 14673743752 015014 0 ustar 00 # Copyright (C) 2001-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Master subprocess watcher."""
import os
import sys
import click
import signal
import socket
import logging
from datetime import timedelta
from enum import Enum
from flufl.lock import Lock, NotLockedError, TimeOutError
from lazr.config import as_boolean
from mailman.config import config
from mailman.core.i18n import _
from mailman.core.initialize import initialize
from mailman.core.logging import reopen
from mailman.utilities.options import I18nCommand, validate_runner_spec
from mailman.version import MAILMAN_VERSION_FULL
from public import public
DOT = '.'
LOCK_LIFETIME = timedelta(days=1, hours=6)
SECONDS_IN_A_DAY = 86400
SUBPROC_START_WAIT = timedelta(seconds=20)
# Environment variables to forward into subprocesses.
#
# This is used by the test framework, but also to make sure spawned
# Python interpreters behave the same way as ours.
PRESERVE_ENVS = {
'COVERAGE_PROCESS_START',
'LANG',
'LANGUAGE',
'LC_ADDRESS',
'LC_ALL',
'LC_COLLATE',
'LC_CTYPE',
'LC_IDENTIFICATION',
'LC_MEASUREMENT',
'LC_MESSAGES',
'LC_MONETARY',
'LC_NAME',
'LC_NUMERIC',
'LC_PAPER',
'LC_TELEPHONE',
'LC_TIME',
'LOCALE_ARCHIVE',
'MAILMAN_EXTRA_TESTING_CFG',
'MAILMAN_VAR_DIR',
# These variables tweak the behavior of the Python interpreter.
# If the user specifies them for the master process, they should
# also be applied to the started runners.
'PYTHONASYNCIODEBUG',
'PYTHONBREAKPOINT',
'PYTHONDEBUG',
'PYTHONDONTWRITEBYTECODE',
'PYTHONHOME',
'PYTHONIOENCODING',
'PYTHONMALLOC',
'PYTHONMALLOCSTATS',
'PYTHONNOUSERSITE',
'PYTHONOPTIMIZE',
'PYTHONPATH',
'PYTHONPLATLIBDIR',
'PYTHONPROFILEIMPORTTIME',
'PYTHONSAFEPATH',
'PYTHONTRACEMALLOC',
'PYTHONUNBUFFERED',
'PYTHONUSERBASE',
'PYTHONVERBOSE',
'PYTHONWARNINGS',
}
@public
class WatcherState(Enum):
"""Enum for the state of the master process watcher."""
# No lock has been acquired by any process.
none = 0
# Another master watcher is running.
conflict = 1
# No conflicting process exists.
stale_lock = 2
# Hostname from lock file doesn't match.
host_mismatch = 3
@public
def master_state(lock_file=None):
"""Get the state of the master watcher.
:param lock_file: Path to the lock file, otherwise `config.LOCK_FILE`.
:type lock_file: str
:return: 2-tuple of the WatcherState describing the state of the lock
file, and the lock object.
"""
if lock_file is None:
lock_file = config.LOCK_FILE
# We'll never acquire the lock, so the lifetime doesn't matter.
lock = Lock(lock_file)
try:
hostname, pid, tempfile = lock.details
except NotLockedError:
return WatcherState.none, lock
if hostname != socket.getfqdn():
return WatcherState.host_mismatch, lock
# Find out if the process exists by calling kill with a signal 0.
try:
os.kill(pid, 0)
return WatcherState.conflict, lock
except (ProcessLookupError, PermissionError):
# No matching process id or pid not Mailman's.
return WatcherState.stale_lock, lock
def acquire_lock_1(force, lock_file=None):
"""Try to acquire the master lock.
:param force: Flag that controls whether to force acquisition of the lock.
:type force: bool
:param lock_file: Path to the lock file, otherwise `config.LOCK_FILE`.
:type lock_file: str
:return: The master lock.
:raises: `TimeOutError` if the lock could not be acquired.
"""
if lock_file is None:
lock_file = config.LOCK_FILE
lock = Lock(lock_file, LOCK_LIFETIME)
try:
lock.lock(timedelta(seconds=0.1))
return lock
except TimeOutError:
if not force:
raise
# Force removal of lock first.
hostname, pid, tempfile = lock.details
os.unlink(lock_file)
# Also remove any stale claim files.
dname = os.path.dirname(lock_file)
for fname in os.listdir(dname):
fpath = os.path.join(dname, fname)
if fpath.startswith(lock_file):
os.unlink(fpath)
return acquire_lock_1(force=False)
def acquire_lock(force):
"""Acquire the master lock.
:param force: Flag that controls whether to force acquisition of the lock.
:type force: bool
:return: The master runner lock or None if the lock couldn't be acquired.
In that case, an error messages is also printed to standard error.
"""
try:
lock = acquire_lock_1(force)
return lock
except TimeOutError:
status, lock = master_state()
if status is WatcherState.conflict:
# Hostname matches and process exists.
message = _("""\
The master lock could not be acquired because it appears as though another
master is already running.""")
elif status is WatcherState.stale_lock:
# Hostname matches but the process does not exist.
program = sys.argv[0] # noqa: F841
message = _("""\
The master lock could not be acquired. It appears as though there is a stale
master lock. Try re-running ${program} with the --force flag.""")
elif status is WatcherState.host_mismatch:
# Hostname doesn't even match.
hostname, pid, tempfile = lock.details
message = _("""\
The master lock could not be acquired, because it appears as if some process
on some other host may have acquired it. We can't test for stale locks across
host boundaries, so you'll have to clean this up manually.
Lock file: ${config.LOCK_FILE}
Lock host: ${hostname}
Exiting.""")
else:
assert status is WatcherState.none, (
'Invalid enum value: {}'.format(status))
hostname, pid, tempfile = lock.details
message = _("""\
For unknown reasons, the master lock could not be acquired.
Lock file: ${config.LOCK_FILE}
Lock host: ${hostname}
Exiting.""")
print(message, file=sys.stderr)
sys.exit(1)
class PIDWatcher:
"""A class which safely manages child process ids."""
def __init__(self):
self._pids = {}
def __contains__(self, pid):
return pid in self._pids.keys()
def __iter__(self):
# Safely iterate over all the keys in the dictionary. Because
# asynchronous signals are involved, the dictionary's size could
# change during iteration. Iterate over a copy of the keys to avoid
# that.
for pid in list(self._pids):
yield pid
def add(self, pid, info):
"""Add process information.
:param pid: The process id. The watcher must not already be tracking
this process id.
:type pid: int
:param info: The process information.
:type info: 4-tuple consisting of
(runner-name, slice-number, slice-count, restart-count)
"""
old_info = self._pids.get(pid)
assert old_info is None, (
'Duplicate process id {0} with existing info: {1}'.format(
pid, old_info))
self._pids[pid] = info
def pop(self, pid):
"""Remove and return existing process information.
:param pid: The process id. The watcher must already be tracking this
process id.
:type pid: int
:return: The process information.
:rtype: 4-tuple consisting of
(runner-name, slice-number, slice-count, restart-count)
:raise KeyError: if the process id is not being tracked.
"""
return self._pids.pop(pid)
def drop(self, pid):
"""Remove and return existing process information.
This is like `pop()` except that no `KeyError` is raised if the
process id is not being tracked.
:param pid: The process id.
:type pid: int
:return: The process information, or None if the process id is not
being tracked.
:rtype: 4-tuple consisting of
(runner-name, slice-number, slice-count, restart-count)
"""
return self._pids.pop(pid, None)
@public
class Loop:
"""Main control loop class."""
def __init__(self, lock=None, restartable=None, config_file=None):
self._log = logging.getLogger('mailman.runner')
self._lock = lock
self._restartable = restartable
self._config_file = config_file
self._kids = PIDWatcher()
def install_signal_handlers(self):
"""Install various signals handlers for control from the master."""
# Set up our signal handlers. Also set up a SIGALRM handler to
# refresh the lock once per day. The lock lifetime is 1 day + 6 hours
# so this should be plenty.
def sigalrm_handler(signum, frame): # noqa: E306
self._lock.refresh()
signal.alarm(SECONDS_IN_A_DAY)
signal.signal(signal.SIGALRM, sigalrm_handler)
signal.alarm(SECONDS_IN_A_DAY)
signal.signal(signal.SIGHUP, self._sighup_handler)
signal.signal(signal.SIGUSR1, self._sigusr1_handler)
signal.signal(signal.SIGTERM, self._sigterm_handler)
signal.signal(signal.SIGINT, self._sigint_handler)
def _sighup_handler(self, signum, frame):
"""Handles SIGHUP.
SIGHUP tells the runners to close and reopen their log files.
"""
reopen()
for pid in self._kids:
try:
os.kill(pid, signal.SIGHUP)
except ProcessLookupError: # pragma: nocover
pass
self._log.info('Master watcher caught SIGHUP. Re-opening log files.')
def _sigusr1_handler(self, signum, frame):
"""Handles SIGUSR1.
SIGUSR1 is used by 'mailman restart'.
"""
for pid in self._kids:
try:
os.kill(pid, signal.SIGUSR1)
except ProcessLookupError: # pragma: nocover
pass
self._log.info('Master watcher caught SIGUSR1. Restarting.')
def _sigterm_handler(self, signum, frame):
"""Handles SIGTERM.
SIGTERM is what init will kill this process with when changing
run levels. It's also the signal 'mailman stop' uses.
"""
# Clear the flag so that we won't try to restart the runners.
self._restartable = False
for pid in self._kids:
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError: # pragma: nocover
pass
self._log.info('Master watcher caught SIGTERM. Exiting.')
def _sigint_handler(self, signum, frame):
"""Handles SIGINT.
SIGINT is what control-C gives.
"""
# Clear the flag so that we won't try to restart the runners.
self._restartable = False
for pid in self._kids:
try:
os.kill(pid, signal.SIGINT)
except ProcessLookupError: # pragma: nocover
pass
self._log.info('Master watcher caught SIGINT. Exiting.')
def _start_runner(self, spec):
"""Start a runner.
All arguments are passed to the process.
:param spec: A runner spec, in a format acceptable to
bin/runner's --runner argument, e.g. name:slice:count
:type spec: string
:return: The process id of the child runner.
:rtype: int
"""
pid = os.fork()
if pid:
# Parent.
return pid
# Child.
#
# Preserve some environment variables.
env = {k: v for k, v in os.environ.items() # pragma: nocover
if k in PRESERVE_ENVS}
# Set the environment variable which tells the runner that it's
# running under bin/master control. This subtly changes the error
# behavior of bin/runner.
env['MAILMAN_UNDER_MASTER_CONTROL'] = '1' # pragma: nocover
# Craft the command line arguments for the exec() call.
rswitch = '--runner=' + spec
# Always pass the explicit path to the configuration file to the
# sub-runners. This avoids any debate about which cfg file is used.
config_file = (config.filename if self._config_file is None
else self._config_file)
# Wherever master lives, so too must live the runner script.
exe = os.path.join(config.BIN_DIR, 'runner') # pragma: nocover
# config.PYTHON, which is the absolute path to the Python interpreter,
# must be given as argv[0] due to Python's library search algorithm.
args = [sys.executable, sys.executable, exe, # pragma: nocover
'-C', config_file, rswitch]
log = logging.getLogger('mailman.runner')
log.debug('starting: %s', args)
args.append(env)
os.execle(*args)
# We should never get here.
raise RuntimeError('os.execle() failed')
def start_runners(self, runner_names=None):
"""Start all the configured runners.
:param runners: If given, a sequence of runner names to start. If not
given, this sequence is taken from the configuration file.
:type runners: a sequence of strings
"""
if not runner_names:
runner_names = []
for runner_config in config.runner_configs:
# Strip off the 'runner.' prefix.
assert runner_config.name.startswith('runner.'), (
'Unexpected runner configuration section name: {}'.format(
runner_config.name))
runner_names.append(runner_config.name[7:])
# For each runner we want to start, find their config section, which
# will tell us the name of the class to instantiate, along with the
# number of hash space slices to manage.
for name in runner_names:
section_name = 'runner.' + name
# Let AttributeError propagate.
runner_config = getattr(config, section_name)
if not as_boolean(runner_config.start):
continue
# Find out how many runners to instantiate. This must be a power
# of 2.
count = int(runner_config.instances)
assert (count & (count - 1)) == 0, (
'Runner "{0}", not a power of 2: {1}'.format(name, count))
for slice_number in range(count):
# runner name, slice #, # of slices, restart count
info = (name, slice_number, count, 0)
spec = '{0}:{1:d}:{2:d}'.format(name, slice_number, count)
pid = self._start_runner(spec)
log = logging.getLogger('mailman.runner')
log.debug('[{0:d}] {1}'.format(pid, spec))
self._kids.add(pid, info)
def _pause(self):
"""Sleep until a signal is received."""
# Sleep until a signal is received. This prevents the master from
# exiting immediately even if there are no runners (as happens in the
# test suite). We install a handler for SIGCHLD so that pause(2)
# will return control to us.
signal.signal(signal.SIGCHLD,
lambda sig, stack: None) # pragma: nocover
signal.pause()
# Note: We cannot set the signal handler to SIG_IGN again,
# because since POSIX.1-2001 this changes the semantics of
# wait(2) to not return until all children have exited.
def loop(self):
"""Main loop.
Wait until all the runner subprocesses have exited, restarting them if
necessary and configured to do so.
"""
log = logging.getLogger('mailman.runner')
log.info('Master started')
self._pause()
while True:
try:
pid, status = os.wait()
except ChildProcessError:
# No children? We're done.
break
except InterruptedError: # pragma: nocover
# If the system call got interrupted, just restart it.
continue
if pid not in self._kids: # pragma: nocover
# This is not a runner subprocess that we own. E.g. maybe a
# plugin started it.
continue
# Find out why the subprocess exited by getting the signal
# received or exit status.
if os.WIFSIGNALED(status):
why = os.WTERMSIG(status) # pragma: nocover
sig_or_exit = 'SIGNAL ' # pragma: nocover
elif os.WIFEXITED(status):
why = os.WEXITSTATUS(status)
sig_or_exit = 'EXIT '
else: # pragma: nocover
why = None
sig_or_exit = 'UNKNOWN'
# We'll restart the subprocess if it exited with a SIGUSR1 or
# because of a failure (i.e. no exit signal), and the no-restart
# command line switch was not given. This lets us better handle
# runaway restarts (e.g. if the subprocess had a syntax error!)
rname, slice_number, count, restarts = self._kids.pop(pid)
config_name = 'runner.' + rname
restart = self._restartable
# Don't restart if the runner explicitly asks not to be
# restarted.
if why == signal.SIGTERM:
# Note: See bin/runner.py and core/runner.py where
# this signaling is used.
restart = False
# Have we hit the maximum number of restarts?
if not why == signal.SIGUSR1:
# Note: Explicit restarts using SIGUSR1 should not be
# counted here, because max_restarts only limits the
# number of automated restarts (see
# mailman/config/schema.cfg).
restarts += 1
max_restarts = int(getattr(config, config_name).max_restarts)
if restarts > max_restarts:
restart = False
# Are we permanently non-restartable?
log.debug("""\
Master detected subprocess exit
(pid: {0:d}, why: {1}{2}, class: {3}, slice: {4:d}/{5:d}) {6}""".format(
pid, sig_or_exit, why, rname, slice_number + 1, count,
('[restarting]' if restart else '')))
# See if we've reached the maximum number of allowable restarts.
if restarts > max_restarts:
log.info("""\
Runner {0} reached maximum restart limit of {1:d}, not restarting.""".format(
rname, max_restarts))
# Now perhaps restart the process unless it exited with a
# SIGTERM or we aren't restarting.
if restart:
spec = '{0}:{1:d}:{2:d}'.format(rname, slice_number, count)
new_pid = self._start_runner(spec)
new_info = (rname, slice_number, count, restarts)
self._kids.add(new_pid, new_info)
log.info('Master stopped')
def cleanup(self):
"""Ensure that all children have exited."""
log = logging.getLogger('mailman.runner')
# Send SIGTERMs to all the child processes and wait for them all to
# exit.
for pid in self._kids:
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError: # pragma: nocover
# The child has already exited.
log.info('ESRCH on pid: %d', pid)
except OSError: # pragma: nocover
# XXX I'm not so sure about this. It preserves the semantics
# before conversion to PEP 3151 exceptions. But is it right?
pass
# Wait for all the children to go away.
while self._kids:
try:
pid, status = os.wait()
self._kids.drop(pid)
except ChildProcessError:
break
except InterruptedError: # pragma: nocover
continue
@click.command(
cls=I18nCommand,
context_settings=dict(help_option_names=['-h', '--help']),
help=_("""\
Master subprocess watcher.
Start and watch the configured runners, ensuring that they stay alive and
kicking. Each runner is forked and exec'd in turn, with the master waiting
on their process ids. When it detects a child runner has exited, it may
restart it.
The runners respond to SIGINT, SIGTERM, SIGUSR1 and SIGHUP. SIGINT,
SIGTERM and SIGUSR1 all cause a runner to exit cleanly. The master will
restart runners that have exited due to a SIGUSR1 or some kind of other
exit condition (say because of an uncaught exception). SIGHUP causes the
master and the runners to close their log files, and reopen then upon the
next printed message.
The master also responds to SIGINT, SIGTERM, SIGUSR1 and SIGHUP, which it
simply passes on to the runners. Note that the master will close and
reopen its own log files on receipt of a SIGHUP. The master also leaves
its own process id in the file specified in the configuration file but you
normally don't need to use this PID directly."""))
@click.option(
'-C', '--config', 'config_file',
envvar='MAILMAN_CONFIG_FILE',
type=click.Path(exists=True, dir_okay=False, resolve_path=True),
help=_("""\
Configuration file to use. If not given, the environment variable
MAILMAN_CONFIG_FILE is consulted and used if set. If neither are given, a
default configuration file is loaded."""))
@click.option(
'--no-restart', '-n', 'restartable',
is_flag=True, default=True,
help=_("""\
Don't restart the runners when they exit because of an error or a SIGUSR1.
Use this only for debugging."""))
@click.option(
'--force', '-f',
is_flag=True, default=False,
help=_("""\
If the master watcher finds an existing master lock, it will normally exit
with an error message. With this option,the master will perform an extra
level of checking. If a process matching the host/pid described in the
lock file is running, the master will still exit, requiring you to manually
clean up the lock. But if no matching process is found, the master will
remove the apparently stale lock and make another attempt to claim the
master lock."""))
@click.option(
'--runners', '-r',
metavar='runner[:slice:range]',
callback=validate_runner_spec, default=None,
multiple=True,
help=_("""\
Override the default set of runners that the master will invoke, which is
typically defined in the configuration file. Multiple -r options may be
given. The values for -r are passed straight through to bin/runner."""))
@click.option(
'-v', '--verbose',
is_flag=True, default=False,
help=_('Display more debugging information to the log file.'))
@click.version_option(MAILMAN_VERSION_FULL)
@public
def main(config_file, restartable, force, runners, verbose):
# XXX https://github.com/pallets/click/issues/303
"""Master subprocess watcher.
Start and watch the configured runners and ensure that they stay
alive and kicking. Each runner is forked and exec'd in turn, with
the master waiting on their process ids. When it detects a child
runner has exited, it may restart it.
The runners respond to SIGINT, SIGTERM, SIGUSR1 and SIGHUP. SIGINT,
SIGTERM and SIGUSR1 all cause a runner to exit cleanly. The master
will restart runners that have exited due to a SIGUSR1 or some kind
of other exit condition (say because of an uncaught exception).
SIGHUP causes the master and the runners to close their log files,
and reopen then upon the next printed message.
The master also responds to SIGINT, SIGTERM, SIGUSR1 and SIGHUP,
which it simply passes on to the runners. Note that the master will
close and reopen its own log files on receipt of a SIGHUP. The
master also leaves its own process id in the file `data/master.pid`
but you normally don't need to use this pid directly.
"""
try:
import setproctitle
setproctitle.setproctitle('mailman: master') # pragma: nocover
except ImportError: # pragma: nocover
pass
initialize(config_file, verbose)
# Acquire the master lock, exiting if we can't. We'll let the caller
# handle any clean up or lock breaking. No `with` statement here because
# Lock's constructor doesn't support a timeout.
lock = acquire_lock(force)
try:
with open(config.PID_FILE, 'w') as fp:
print(os.getpid(), file=fp)
loop = Loop(lock, restartable, config.filename)
loop.install_signal_handlers()
try:
loop.start_runners(runners)
loop.loop()
finally:
loop.cleanup()
os.remove(config.PID_FILE)
finally:
lock.unlock()
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1726290746.6785855
mailman-3.3.10/src/mailman/bin/runner.py 0000644 0000000 0000000 00000016507 14671215473 015025 0 ustar 00 # Copyright (C) 2001-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""The runner process."""
import os
import sys
import click
import signal
import logging
import traceback
from mailman.config import config
from mailman.core.i18n import _
from mailman.core.initialize import initialize
from mailman.utilities.modules import find_name
from mailman.utilities.options import I18nCommand, validate_runner_spec
from mailman.version import MAILMAN_VERSION_FULL
from public import public
log = None
# Enable coverage if run under the appropriate test suite.
if os.environ.get('COVERAGE_PROCESS_START') is not None:
import coverage
coverage.process_startup()
def make_runner(name, slice, range, once=False):
# The runner name must be defined in the configuration. Only runner short
# names are supported.
runner_config = getattr(config, 'runner.' + name, None)
if runner_config is None:
print(_('Undefined runner name: ${name}'), file=sys.stderr)
# Exit with SIGTERM exit code so the master won't try to restart us.
sys.exit(signal.SIGTERM)
class_path = runner_config['class']
try:
runner_class = find_name(class_path)
except ImportError:
if os.environ.get('MAILMAN_UNDER_MASTER_CONTROL') is not None:
print(_('Cannot import runner module: ${class_path}'),
file=sys.stderr)
traceback.print_exc()
sys.exit(signal.SIGTERM)
else:
raise
if once:
# Subclass to hack in the setting of the stop flag in _do_periodic()
class Once(runner_class):
def _do_periodic(self):
self.stop()
return Once(name, slice)
return runner_class(name, slice)
@click.command(
cls=I18nCommand,
context_settings=dict(help_option_names=['-h', '--help']),
help=_("""\
Start a runner.
The runner named on the command line is started, and it can either run
through its main loop once (for those runners that support this) or
continuously. The latter is how the master runner starts all its
subprocesses.
-r is required unless -l or -h is given, and its argument must be one of
the names displayed by the -l switch.
Normally, this script should be started from `mailman start`. Running it
separately or with -o is generally useful only for debugging. When run
this way, the environment variable $MAILMAN_UNDER_MASTER_CONTROL will be
set which subtly changes some error handling behavior.
"""))
@click.option(
'-C', '--config', 'config_file',
envvar='MAILMAN_CONFIG_FILE',
type=click.Path(exists=True, dir_okay=False, resolve_path=True),
help=_("""\
Configuration file to use. If not given, the environment variable
MAILMAN_CONFIG_FILE is consulted and used if set. If neither are given, a
default configuration file is loaded."""))
@click.option(
'-l', '--list', 'list_runners',
is_flag=True, is_eager=True, default=False,
help=_('List the available runner names and exit.'))
@click.option(
'-o', '--once',
is_flag=True, default=False,
help=_("""\
Run the named runner exactly once through its main loop. Otherwise, the
runner runs indefinitely until the process receives a signal. This is not
compatible with runners that cannot be run once."""))
@click.option(
'-r', '--runner', 'runner_spec',
metavar='runner[:slice:range]',
callback=validate_runner_spec, default=None,
help=_("""\
Start the named runner, which must be one of the strings returned by the -l
option.
For runners that manage a queue directory, optional `slice:range` if given
is used to assign multiple runner processes to that queue. range is the
total number of runners for the queue while slice is the number of this
runner from [0..range). For runners that do not manage a queue, slice and
range are ignored.
When using the `slice:range` form, you must ensure that each runner for the
queue is given the same range value. If `slice:runner` is not given, then
1:1 is used.
"""))
@click.option(
'-v', '--verbose',
is_flag=True, default=False,
help=_('Display more debugging information to the log file.'))
@click.version_option(MAILMAN_VERSION_FULL)
@click.pass_context
@public
def main(ctx, config_file, verbose, list_runners, once, runner_spec):
# XXX https://github.com/pallets/click/issues/303
"""Start a runner.
The runner named on the command line is started, and it can either run
through its main loop once (for those runners that support this) or
continuously. The latter is how the master runner starts all its
subprocesses.
-r is required unless -l or -h is given, and its argument must be one
of the names displayed by the -l switch.
Normally, this script should be started from 'mailman start'. Running
it separately or with -o is generally useful only for debugging. When
run this way, the environment variable $MAILMAN_UNDER_MASTER_CONTROL
will be set which subtly changes some error handling behavior.
"""
global log
if runner_spec is None and not list_runners:
ctx.fail(_('No runner name given.'))
try:
import setproctitle
setproctitle.setproctitle(
f'mailman: runner [{runner_spec[0]}]'
) # pragma: nocover
except ImportError: # pragma: nocover
pass
# Initialize the system. Honor the -C flag if given.
initialize(config_file, verbose)
log = logging.getLogger('mailman.runner')
if verbose:
console = logging.StreamHandler(sys.stderr)
formatter = logging.Formatter(config.logging.root.format,
config.logging.root.datefmt)
console.setFormatter(formatter)
logging.getLogger().addHandler(console)
logging.getLogger().setLevel(logging.DEBUG)
if list_runners:
descriptions = {}
for section in config.runner_configs:
ignore, dot, shortname = section.name.rpartition('.')
ignore, dot, classname = getattr(section, 'class').rpartition('.')
descriptions[shortname] = classname
longest = max(len(name) for name in descriptions)
for shortname in sorted(descriptions):
classname = descriptions[shortname]
spaces = longest - len(shortname)
name = (' ' * spaces) + shortname # noqa: F841
print(_('${name} runs ${classname}'))
sys.exit(0)
runner = make_runner(*runner_spec, once=once)
runner.set_signals()
# Now start up the main loop
log.info('{} runner started.'.format(runner.name))
runner.run()
log.info('{} runner exiting.'.format(runner.name))
sys.exit(runner.status)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1672813222.7200713
mailman-3.3.10/src/mailman/bin/tests/__init__.py 0000644 0000000 0000000 00000000000 14355215247 016370 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5156233
mailman-3.3.10/src/mailman/bin/tests/test_mailman.py 0000644 0000000 0000000 00000013277 14542770442 017333 0 ustar 00 # Copyright (C) 2015-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Test mailman command utilities."""
import unittest
from click.testing import CliRunner
from datetime import timedelta
from importlib.resources import path
from mailman.app.lifecycle import create_list
from mailman.bin.mailman import main
from mailman.config import config
from mailman.database.transaction import transaction
from mailman.interfaces.command import ICLISubCommand
from mailman.testing.layers import ConfigLayer
from mailman.utilities.datetime import now
from mailman.utilities.modules import add_components
from unittest.mock import patch
def mock_euid():
return 0
class TestMailmanCommand(unittest.TestCase):
layer = ConfigLayer
def setUp(self):
self._command = CliRunner()
def test_mailman_command_config(self):
with path('mailman.testing', 'testing.cfg') as config_path:
with patch('mailman.bin.mailman.initialize') as init:
self._command.invoke(main, ('-C', str(config_path), 'info'))
init.assert_called_once_with(str(config_path))
def test_mailman_command_no_config(self):
with patch('mailman.bin.mailman.initialize') as init:
self._command.invoke(main, ('info',))
init.assert_called_once_with(None)
@patch('mailman.bin.mailman.os')
@patch('mailman.bin.mailman.initialize')
def test_mailman_command_without_subcommand_prints_help(
self, mock_os, mock):
mock_os.geteuid.return_value = 1000
# Issue #137: Running `mailman` without a subcommand raises an
# AttributeError.
result = self._command.invoke(main, [])
lines = result.output.splitlines()
# "main" instead of "mailman" because of the way the click runner
# works. It does actually show the correct program when run from the
# command line.
self.assertEqual(lines[0], 'Usage: main [OPTIONS] COMMAND [ARGS]...')
# The help output includes a list of subcommands, in sorted order.
commands = {}
add_components('commands', ICLISubCommand, commands)
help_commands = list(
line.split()[0].strip()
for line in lines[-len(commands):]
)
self.assertEqual(sorted(commands), help_commands)
@patch('mailman.bin.mailman.initialize')
def test_mailman_command_with_bad_subcommand_prints_help(self, mock):
# Issue #137: Running `mailman` without a subcommand raises an
# AttributeError.
result = self._command.invoke(main, ('not-a-subcommand',))
lines = result.output.splitlines()
# "main" instead of "mailman" because of the way the click runner
# works. It does actually show the correct program when run from the
# command line.
self.assertEqual(lines[0], 'Usage: main [OPTIONS] COMMAND [ARGS]...')
@patch('mailman.bin.mailman.os')
@patch('mailman.bin.mailman.initialize')
def test_transaction_commit_after_successful_subcommand(
self, mock_os, mock):
mock_os.geteuid.return_value = 0
# Issue #223: Subcommands which change the database need to commit or
# abort the transaction.
with transaction():
mlist = create_list('ant@example.com')
mlist.volume = 5
mlist.next_digest_number = 3
mlist.digest_last_sent_at = now() - timedelta(days=60)
self._command.invoke(main, ('digests', '-b', '-l', 'ant@example.com'))
# Clear the current transaction to force a database reload.
config.db.abort()
self.assertEqual(mlist.volume, 6)
self.assertEqual(mlist.next_digest_number, 1)
@patch('mailman.bin.mailman.initialize')
@patch('mailman.commands.cli_digests.maybe_send_digest_now',
side_effect=RuntimeError)
def test_transaction_abort_after_failing_subcommand(self, mock1, mock2):
with transaction():
mlist = create_list('ant@example.com')
mlist.volume = 5
mlist.next_digest_number = 3
mlist.digest_last_sent_at = now() - timedelta(days=60)
self._command.invoke(
main, ('digests', '-b', '-l', 'ant@example.com', '--send'))
# Clear the current transaction to force a database reload.
config.db.abort()
# The volume and number haven't changed.
self.assertEqual(mlist.volume, 5)
self.assertEqual(mlist.next_digest_number, 3)
@patch('mailman.bin.mailman.initialize')
@patch('os.geteuid', mock_euid)
def test_wont_run_as_root(self, mock):
result = self._command.invoke(main)
self.assertIn(
'Error: If you are sure you want to run as root, '
'specify --run-as-root.',
result.output)
self.assertNotEqual(result.exit_code, 0)
@patch('mailman.bin.mailman.initialize')
@patch('os.geteuid', mock_euid)
def test_will_run_as_root_with_option(self, mock):
result = self._command.invoke(main, ('--run-as-root'))
self.assertNotIn('Error:', result.output)
self.assertEqual(result.exit_code, 0)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1726990313.9032514
mailman-3.3.10/src/mailman/bin/tests/test_master.py 0000644 0000000 0000000 00000031745 14673743752 017221 0 ustar 00 # Copyright (C) 2010-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Test master watcher utilities."""
import os
import time
import signal
import socket
import tempfile
import unittest
from click.testing import CliRunner
from contextlib import ExitStack, suppress
from datetime import timedelta
from flufl.lock import Lock, TimeOutError
from importlib.resources import path
from io import StringIO
from mailman.bin import master
from mailman.config import config
from mailman.testing.helpers import LogFileMark, TestableMaster
from mailman.testing.layers import ConfigLayer
from unittest.mock import patch
class FakeLock:
details = ('host.example.com', 9999, '/tmp/whatever')
def unlock(self):
pass
class TestMaster(unittest.TestCase):
layer = ConfigLayer
def setUp(self):
fd, self.lock_file = tempfile.mkstemp()
os.close(fd)
# The lock file should not exist before we try to acquire it.
os.remove(self.lock_file)
def tearDown(self):
# Unlocking removes the lock file, but just to be safe (i.e. in case
# of errors).
with suppress(FileNotFoundError):
os.remove(self.lock_file)
def test_acquire_lock_1(self):
lock = master.acquire_lock_1(False, self.lock_file)
is_locked = lock.is_locked
lock.unlock()
self.assertTrue(is_locked)
def test_acquire_lock_1_force(self):
# Create the lock and lock it.
my_lock = Lock(self.lock_file)
my_lock.lock(timedelta(seconds=60))
# Try to aquire it again with force.
lock = master.acquire_lock_1(True, self.lock_file)
self.assertTrue(lock.is_locked)
lock.unlock()
def test_master_state(self):
my_lock = Lock(self.lock_file)
# Mailman is not running.
state, lock = master.master_state(self.lock_file)
self.assertEqual(state, master.WatcherState.none)
# Acquire the lock as if another process had already started the
# master. Use a timeout to avoid this test deadlocking.
my_lock.lock(timedelta(seconds=60))
try:
state, lock = master.master_state(self.lock_file)
finally:
my_lock.unlock()
self.assertEqual(state, master.WatcherState.conflict)
def test_master_state_stale(self):
# Create a lock file with non-existent pid.
with open(self.lock_file, 'w') as fp:
fp.write(f'{self.lock_file}|{socket.getfqdn()}|9999999|junk')
# Try to acquire the lock.
Lock(self.lock_file)
state, lock = master.master_state(self.lock_file)
self.assertEqual(state, master.WatcherState.stale_lock)
def test_acquire_lock_timeout_reason_unknown(self):
stderr = StringIO()
with ExitStack() as resources:
resources.enter_context(patch(
'mailman.bin.master.acquire_lock_1',
side_effect=TimeOutError))
resources.enter_context(patch(
'mailman.bin.master.master_state',
return_value=(master.WatcherState.none, FakeLock())))
resources.enter_context(patch(
'mailman.bin.master.sys.stderr', stderr))
with self.assertRaises(SystemExit) as cm:
master.acquire_lock(False)
self.assertEqual(cm.exception.code, 1)
self.assertEqual(stderr.getvalue(), """\
For unknown reasons, the master lock could not be acquired.
Lock file: {}
Lock host: host.example.com
Exiting.
""".format(config.LOCK_FILE))
def test_main_cli(self):
command = CliRunner()
fake_lock = FakeLock()
with ExitStack() as resources:
config_file = str(resources.enter_context(
path('mailman.testing', 'testing.cfg')))
init_mock = resources.enter_context(patch(
'mailman.bin.master.initialize'))
lock_mock = resources.enter_context(patch(
'mailman.bin.master.acquire_lock',
return_value=fake_lock))
start_mock = resources.enter_context(patch.object(
master.Loop, 'start_runners'))
loop_mock = resources.enter_context(patch.object(
master.Loop, 'loop'))
command.invoke(
master.main,
('-C', config_file,
'--no-restart', '--force',
'-r', 'in:1:1', '--verbose'))
# We got initialized with the custom configuration file and the
# verbose flag.
init_mock.assert_called_once_with(config_file, True)
# We returned a lock that was force-acquired.
lock_mock.assert_called_once_with(True)
# We created a non-restartable loop.
start_mock.assert_called_once_with([('in', 1, 1)])
loop_mock.assert_called_once_with()
def test_sighup_handler(self):
"""Invokes the SIGHUP handler.
This sends SIGHUPs to the runners. Unfortunately, there is no
easy way to test whether that actually happens. We'd need a
specialized runner for that.
"""
m = TestableMaster()
mark = LogFileMark('mailman.runner')
m.start('command')
self.assertEqual(len(list(m._kids)), 1)
# We need to give the runner some time to install the signal
# handler. If we send SIGHUP before it does, the default
# action is to terminate the process. We wait until we see
# that the runner has done so by inspecting the log file.
# This is race free, and bounded in time.
start = time.time()
while ("runner started." not in mark.read()
and time.time() - start < 10):
time.sleep(0.1)
mark = LogFileMark('mailman.runner')
m._sighup_handler(None, None)
# Check if the runner reopened the log.
start = time.time()
needle = "command runner caught SIGHUP. Reopening logs."
while (needle not in mark.read()
and time.time() - start < 10):
time.sleep(0.1)
self.assertIn(needle, mark.read())
# Just to make sure it didn't die.
self.assertEqual(len(list(m._kids)), 1)
m.stop()
def test_sigusr1_handler(self):
"""Invokes the SIGUSR1 handler.
We then check whether the runners restart.
"""
m = TestableMaster()
m._restartable = True
mark = LogFileMark('mailman.runner')
m.start('command')
kids = list(m._kids)
self.assertEqual(len(kids), 1)
old_kid = kids[0]
# We need to give the runner some time to install the signal
# handler. If we send SIGUSR1 before it does, the default
# action is to terminate the process. We wait until we see
# that the runner has done so by inspecting the log file.
# This is race free, and bounded in time.
start = time.time()
while ("runner started." not in mark.read()
and time.time() - start < 10):
time.sleep(0.1)
# Invoke the handler in a loop. This is race free, and
# bounded in time.
start = time.time()
mark = LogFileMark('mailman.runner')
while old_kid in set(m._kids) and time.time() - start < 10:
# We must not send signals in rapid succession, because
# the behavior of signals arriving while the process is in
# the signal handler varies. Linux implements System V
# semantics, which means the default signal action is
# restored for the duration of the signal handler. In
# this case it means to terminate the process.
time.sleep(1)
m._sigusr1_handler(None, None)
# Check if the runner got the signal.
start = time.time()
needle = "command runner caught SIGUSR1. Stopping."
while (needle not in mark.read()
and time.time() - start < 10):
time.sleep(0.1)
self.assertIn(needle, mark.read())
new_kids = list(m._kids)
self.assertEqual(len(new_kids), 1)
self.assertTrue(kids[0] != new_kids[0])
m._restartable = False
for pid in new_kids:
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
pass
m.thread.join()
m.cleanup()
def test_sigterm_handler(self):
"""Invokes the SIGTERM handler.
We then check whether the runners are actually stopped.
"""
m = TestableMaster()
mark = LogFileMark('mailman.runner')
m.start('command')
kids = list(m._kids)
self.assertEqual(len(kids), 1)
old_kid = kids[0]
# We need to give the runner some time to install the signal
# handler. If we send SIGTERM before it does, the default
# action is to terminate the process and will return a
# slightly different status code. We wait until we see that
# the runner has done so by inspecting the log file. This is
# race free, and bounded in time.
start = time.time()
while ("runner started." not in mark.read()
and time.time() - start < 10):
time.sleep(0.1)
# Invoke the handler in a loop. This is race free, and
# bounded in time.
start = time.time()
mark = LogFileMark('mailman.runner')
while old_kid in set(m._kids) and time.time() - start < 10:
time.sleep(0.1)
m._sigterm_handler(None, None)
# Check if the runner got the signal.
start = time.time()
needle = "command runner caught SIGTERM. Stopping."
while (needle not in mark.read()
and time.time() - start < 10):
time.sleep(0.1)
self.assertIn(needle, mark.read())
m.thread.join()
self.assertEqual(len(list(m._kids)), 0)
m.cleanup()
def test_sigint_handler(self):
"""Invokes the SIGINT handler.
We then check whether the runners are actually stopped.
"""
m = TestableMaster()
mark = LogFileMark('mailman.runner')
m.start('command')
self.assertEqual(len(list(m._kids)), 1)
kids = list(m._kids)
self.assertEqual(len(kids), 1)
old_kid = kids[0]
# We need to give the runner some time to install the signal
# handler. If we send SIGINT before it does, the default
# action is to terminate the process and will return a
# slightly different status code. We wait until we see that
# the runner has done so by inspecting the log file. This is
# race free, and bounded in time.
start = time.time()
while ("runner started." not in mark.read()
and time.time() - start < 10):
time.sleep(0.1)
# Invoke the handler in a loop. This is race free, and
# bounded in time.
start = time.time()
mark = LogFileMark('mailman.runner')
while old_kid in set(m._kids) and time.time() - start < 10:
time.sleep(0.1)
m._sigint_handler(None, None)
# Check if the runner got the signal.
start = time.time()
needle = "command runner caught SIGINT. Stopping."
while (needle not in mark.read()
and time.time() - start < 10):
time.sleep(0.1)
self.assertIn(needle, mark.read())
m.thread.join()
self.assertEqual(len(list(m._kids)), 0)
m.cleanup()
def test_runner_restart_on_sigkill(self):
"""Kills a runner with SIGKILL and see if it restarted."""
m = TestableMaster()
m._restartable = True
m.start('command')
kids = list(m._kids)
self.assertEqual(len(kids), 1)
old_kid = kids[0]
# Kill it. No need to wait for anything as this cannot be
# caught anyway.
os.kill(old_kid, signal.SIGKILL)
# But, we need to wait for the master to collect it.
start = time.time()
while old_kid in set(m._kids) and time.time() - start < 10:
time.sleep(0.1)
new_kids = list(m._kids)
self.assertEqual(len(new_kids), 1)
self.assertTrue(old_kid != new_kids[0])
m._restartable = False
for pid in new_kids:
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
pass
m.thread.join()
m.cleanup()
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1672813222.8990724
mailman-3.3.10/src/mailman/chains/__init__.py 0000644 0000000 0000000 00000000000 14355215247 015723 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1726290746.6787348
mailman-3.3.10/src/mailman/chains/accept.py 0000644 0000000 0000000 00000003633 14671215473 015444 0 ustar 00 # Copyright (C) 2007-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""The terminal 'accept' chain."""
import logging
from mailman.chains.base import TerminalChainBase
from mailman.config import config
from mailman.core.i18n import _
from mailman.interfaces.chain import AcceptEvent
from public import public
from zope.event import notify
log = logging.getLogger('mailman.vette')
SEMISPACE = '; '
@public
class AcceptChain(TerminalChainBase):
"""Accept the message for posting."""
name = 'accept'
description = _('Accept a message.')
def _process(self, mlist, msg, msgdata):
"""See `TerminalChainBase`."""
# Start by decorating the message with a header that contains a list
# of all the rules that matched. These metadata could be None or an
# empty list.
rule_hits = msgdata.get('rule_hits')
if rule_hits:
msg['X-Mailman-Rule-Hits'] = SEMISPACE.join(rule_hits)
rule_misses = msgdata.get('rule_misses')
if rule_misses:
msg['X-Mailman-Rule-Misses'] = SEMISPACE.join(rule_misses)
config.switchboards['pipeline'].enqueue(msg, msgdata)
log.info('ACCEPT: %s', msg.get('message-id', 'n/a').strip())
notify(AcceptEvent(mlist, msg, msgdata, self))
././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1703670049.516372
mailman-3.3.10/src/mailman/chains/base.py 0000644 0000000 0000000 00000011457 14542770442 015121 0 ustar 00 # Copyright (C) 2008-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Base class for terminal chains."""
from mailman.config import config
from mailman.interfaces.chain import (
IChain,
IChainIterator,
IChainLink,
IMutableChain,
LinkAction,
)
from mailman.interfaces.rules import IRule
from mailman.utilities.modules import abstract_component
from public import public
from zope.interface import implementer
@public
@implementer(IChainLink)
class Link:
"""A chain link."""
def __init__(self, rule, action=None, chain=None, function=None):
self.rule = (rule
if IRule.providedBy(rule)
else config.rules[rule])
self.action = (LinkAction.defer if action is None else action)
self.chain = (chain
if chain is None or IChain.providedBy(chain)
else config.chains[chain])
self.function = function
def __repr__(self):
message = ''
return message.format(self)
@public
@abstract_component
@implementer(IChain, IChainIterator)
class TerminalChainBase:
"""A base chain that always matches and executes a method.
The method is called '_process()' and must be provided by the subclass.
"""
def _process(self, mlist, msg, msgdata):
"""Process the message for the given mailing list.
This must be overridden by subclasses.
:param mlist: The mailing list.
:param msg: The message.
:param msgdata: The message metadata.
"""
raise NotImplementedError
def get_links(self, mlist, msg, msgdata):
"""See `IChain`."""
return iter(self)
def __iter__(self):
"""See `IChainIterator`."""
# First, yield a link that always runs the process method.
yield Link('truth', LinkAction.run, function=self._process)
# Now yield a rule that stops all processing.
yield Link('truth', LinkAction.stop)
@public
@abstract_component
@implementer(IChain)
class JumpChainBase:
"""A base chain that simplifies jumping to another chain."""
def jump_to(self, mlist, msg, msgsdata):
"""Return the chain to jump to.
This must be overridden by subclasses.
:param mlist: The mailing list.
:param msg: The message.
:param msgdata: The message metadata.
:return: The name of the chain to jump to.
:rtype: str
"""
raise NotImplementedError
def get_links(self, mlist, msg, msgdata):
jump_chain = self.jump_to(mlist, msg, msgdata)
return iter([
Link('truth', LinkAction.jump, jump_chain),
])
@public
@abstract_component
@implementer(IMutableChain)
class Chain:
"""Generic chain base class."""
def __init__(self, name, description):
assert name not in config.chains, (
'Duplicate chain name: {}'.format(name))
self.name = name
self.description = description
self._links = []
def append_link(self, link):
"""See `IMutableChain`."""
self._links.append(link)
def flush(self):
"""See `IMutableChain`."""
self._links = []
def get_links(self, mlist, msg, msgdata):
"""See `IChain`."""
return iter(ChainIterator(self))
def get_iterator(self):
"""Return an iterator over the links."""
# We do it this way in order to preserve a separation of interfaces,
# and allows .get_links() to be overridden.
yield from self._links
@public
@implementer(IChainIterator)
class ChainIterator:
"""Generic chain iterator."""
def __init__(self, chain):
self._chain = chain
def __iter__(self):
"""See `IChainIterator`."""
return self._chain.get_iterator()
././@PaxHeader 0000000 0000000 0000000 00000000032 00000000000 010210 x ustar 00 26 mtime=1726290746.67886
mailman-3.3.10/src/mailman/chains/builtin.py 0000644 0000000 0000000 00000006325 14671215473 015654 0 ustar 00 # Copyright (C) 2007-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""The default built-in starting chain."""
import logging
from mailman.chains.base import Link
from mailman.core.i18n import _
from mailman.interfaces.chain import IChain, LinkAction
from public import public
from zope.interface import implementer
log = logging.getLogger('mailman.vette')
@public
@implementer(IChain)
class BuiltInChain:
"""Default built-in chain."""
name = 'default-posting-chain'
description = _('The built-in moderation chain.')
_link_descriptions = (
# First check DMARC. For a reject or discard, the rule hits and we
# jump to the moderation chain to do the action. Otherwise, the rule
# misses buts sets msgdata['dmarc'] for the handler.
('dmarc-mitigation', LinkAction.jump, 'dmarc'),
# Discard emails with no valid senders.
('no-senders', LinkAction.jump, 'discard'),
('approved', LinkAction.jump, 'accept'),
('loop', LinkAction.jump, 'discard'),
# Discard emails from banned addresses.
('banned-address', LinkAction.jump, 'discard'),
# Take a detour through the header matching chain.
('truth', LinkAction.detour, 'header-match'),
('emergency', LinkAction.jump, 'hold'),
# Determine whether the member or nonmember has an action shortcut.
('member-moderation', LinkAction.jump, 'moderation'),
# Check for nonmember moderation.
('nonmember-moderation', LinkAction.jump, 'moderation'),
# Do all of the following before deciding whether to hold the message.
('administrivia', LinkAction.defer, None),
('implicit-dest', LinkAction.defer, None),
('max-recipients', LinkAction.defer, None),
('max-size', LinkAction.defer, None),
('news-moderation', LinkAction.defer, None),
('no-subject', LinkAction.defer, None),
('digests', LinkAction.defer, None),
('suspicious-header', LinkAction.defer, None),
# Now if any of the above hit, jump to the hold chain.
('any', LinkAction.jump, 'hold'),
# Finally, the builtin chain jumps to acceptance.
('truth', LinkAction.jump, 'accept'),
)
def __init__(self):
self._cached_links = None
def get_links(self, mlist, msg, msgdata):
"""See `IChain`."""
if self._cached_links is None:
self._cached_links = links = []
for rule, action, chain in self._link_descriptions:
links.append(Link(rule, action, chain))
return iter(self._cached_links)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1726290746.6789844
mailman-3.3.10/src/mailman/chains/discard.py 0000644 0000000 0000000 00000003105 14671215473 015610 0 ustar 00 # Copyright (C) 2007-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""The terminal 'discard' chain."""
import logging
from mailman.chains.base import TerminalChainBase
from mailman.core.i18n import _
from mailman.interfaces.chain import DiscardEvent
from public import public
from zope.event import notify
log = logging.getLogger('mailman.vette')
@public
class DiscardChain(TerminalChainBase):
"""Discard a message."""
name = 'discard'
description = _('Discard a message and stop processing.')
def _process(self, mlist, msg, msgdata):
"""See `TerminalChainBase`.
This writes a log message, fires a Zope event and then throws the
message away.
"""
log.info('DISCARD: %s; %s', msg.get('message-id', 'n/a').strip(),
msgdata.get('moderation_reasons', '[n/a]'))
notify(DiscardEvent(mlist, msg, msgdata, self))
# Nothing more needs to happen.
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5168886
mailman-3.3.10/src/mailman/chains/dmarc.py 0000644 0000000 0000000 00000002627 14542770442 015274 0 ustar 00 # Copyright (C) 2017-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""DMARC mitigation chain."""
from mailman.chains.base import JumpChainBase
from mailman.core.i18n import _
from public import public
@public
class DMARCMitigationChain(JumpChainBase):
"""Perform DMARC mitigation."""
name = 'dmarc'
description = _('Process DMARC reject or discard mitigations')
def jump_to(self, mlist, msg, msgdata):
# Which action should be taken?
jump_chain = msgdata['dmarc_action']
assert jump_chain in ('discard', 'reject'), (
'{}: Invalid DMARC action: {} for sender: {}'.format(
mlist.list_id, jump_chain,
msgdata.get('moderation_sender', '(unknown)')))
return jump_chain
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1672813222.9041843
mailman-3.3.10/src/mailman/chains/docs/__init__.py 0000644 0000000 0000000 00000000000 14355215247 016653 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1672813222.9043553
mailman-3.3.10/src/mailman/chains/docs/chains.rst 0000644 0000000 0000000 00000000065 14355215247 016554 0 ustar 00 ======
Chains
======
.. toctree::
:glob:
./*
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1726290746.6793294
mailman-3.3.10/src/mailman/chains/docs/moderation.rst 0000644 0000000 0000000 00000016603 14671215473 017457 0 ustar 00 ==========
Moderation
==========
Posts by members and nonmembers are subject to moderation checks during
incoming processing. Different situations can cause such posts to be held for
moderator approval.
>>> from mailman.app.lifecycle import create_list
>>> mlist = create_list('test@example.com')
Members and nonmembers have a *moderation action* which can shortcut the
normal moderation checks. The built-in chain does just a few checks first,
such as seeing if the message has a matching `Approved:` header, or if the
emergency flag has been set on the mailing list, or whether a mail loop has
been detected.
Mailing lists have a default moderation action, one for members and another
for nonmembers. If a member's moderation action is ``None``, then the member
moderation check falls back to the appropriate list default.
A moderation action of `defer` means that no explicit moderation check is
performed and the rest of the rule chain processing proceeds as normal. But
it is also common for first-time posters to have a `hold` action, meaning that
their messages are held for moderator approval for a while.
Nonmembers almost always have a `hold` action, though some mailing lists may
choose to set this default action to `discard`, meaning their posts would be
immediately thrown away.
Member moderation
=================
Posts by list members are moderated if the member's moderation action is not
deferred. The default setting for the moderation action of new members is
determined by the mailing list's settings. By default, a mailing list is not
set to moderate new member postings.
>>> print(mlist.default_member_action)
Action.defer
In order to find out whether the message is held or accepted, we can subscribe
to internal events that are triggered on each case.
>>> from mailman.interfaces.chain import ChainEvent
>>> def on_chain(event):
... if isinstance(event, ChainEvent):
... print(event)
... print(event.chain)
... print('Subject:', event.msg['subject'])
... print('Hits:')
... for hit in event.msgdata.get('rule_hits', []):
... print(' ', hit)
... print('Misses:')
... for miss in event.msgdata.get('rule_misses', []):
... print(' ', miss)
Anne is a list member with moderation action of ``None`` so that moderation
will fall back to the mailing list's ``default_member_action``.
>>> from mailman.testing.helpers import subscribe
>>> member = subscribe(mlist, 'Anne', email='anne@example.com')
>>> member
on test@example.com
as MemberRole.member>
>>> print(member.moderation_action)
None
Anne's post to the mailing list runs through the incoming runner's default
built-in chain. No rules hit and so the message is accepted.
::
>>> from mailman.testing.helpers import (specialized_message_from_string
... as message_from_string)
>>> msg = message_from_string("""\
... From: anne@example.com
... To: test@example.com
... Subject: aardvark
...
... This is a test.
... """)
>>> from mailman.core.chains import process
>>> from mailman.testing.helpers import event_subscribers
>>> with event_subscribers(on_chain):
... process(mlist, msg, {}, 'default-posting-chain')
Subject: aardvark
Hits:
Misses:
dmarc-mitigation
no-senders
approved
loop
banned-address
emergency
member-moderation
nonmember-moderation
administrivia
implicit-dest
max-recipients
max-size
news-moderation
no-subject
digests
suspicious-header
However, when Anne's moderation action is set to `hold`, her post is held for
moderator approval.
::
>>> from mailman.interfaces.action import Action
>>> member.moderation_action = Action.hold
>>> msg = message_from_string("""\
... From: anne@example.com
... To: test@example.com
... Subject: badger
...
... This is a test.
... """)
>>> with event_subscribers(on_chain):
... process(mlist, msg, {}, 'default-posting-chain')
Subject: badger
Hits:
member-moderation
Misses:
dmarc-mitigation
no-senders
approved
loop
banned-address
emergency
Anne's moderation action can also be set to `discard`...
::
>>> member.moderation_action = Action.discard
>>> msg = message_from_string("""\
... From: anne@example.com
... To: test@example.com
... Subject: cougar
...
... This is a test.
... """)
>>> with event_subscribers(on_chain):
... process(mlist, msg, {}, 'default-posting-chain')
Subject: cougar
Hits:
member-moderation
Misses:
dmarc-mitigation
no-senders
approved
loop
banned-address
emergency
... or `reject`.
>>> member.moderation_action = Action.reject
>>> msg = message_from_string("""\
... From: anne@example.com
... To: test@example.com
... Subject: dingo
...
... This is a test.
... """)
>>> with event_subscribers(on_chain):
... process(mlist, msg, {}, 'default-posting-chain')
Subject: dingo
Hits:
member-moderation
Misses:
dmarc-mitigation
no-senders
approved
loop
banned-address
emergency
Nonmembers
==========
Registered nonmembers are handled very similarly to members, except that a
different list default setting is used when moderating nonmemberds. This is
how the incoming runner adds sender addresses as nonmembers.
>>> from zope.component import getUtility
>>> from mailman.interfaces.usermanager import IUserManager
>>> user_manager = getUtility(IUserManager)
>>> address = user_manager.create_address('bart@example.com')
>>> address
When the moderation rule runs on a message from this sender, this address will
be registered as a nonmember of the mailing list, and it will be held for
moderator approval.
::
>>> msg = message_from_string("""\
... From: bart@example.com
... To: test@example.com
... Subject: elephant
...
... """)
>>> with event_subscribers(on_chain):
... process(mlist, msg, {}, 'default-posting-chain')
Subject: elephant
Hits:
nonmember-moderation
Misses:
dmarc-mitigation
no-senders
approved
loop
banned-address
emergency
member-moderation
>>> nonmember = mlist.nonmembers.get_member('bart@example.com')
>>> nonmember
When a nonmember's default moderation action is ``None``, the rule will use
the mailing list's ``default_nonmember_action``.
>>> print(nonmember.moderation_action)
None
>>> print(mlist.default_nonmember_action)
Action.hold
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5171077
mailman-3.3.10/src/mailman/chains/headers.py 0000644 0000000 0000000 00000017027 14542770442 015621 0 ustar 00 # Copyright (C) 2007-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""The header-matching chain."""
import re
import logging
from email.header import decode_header, Header, make_header
from itertools import count
from mailman.chains.base import Chain, Link
from mailman.config import config
from mailman.core.i18n import _
from mailman.interfaces.chain import LinkAction
from mailman.interfaces.rules import IRule
from public import public
from zope.interface import implementer
log = logging.getLogger('mailman.error')
_RULE_COUNTER = count(1)
def _make_rule_name(suffix):
# suffix may be None, since it comes from the 'name' parameter given in
# the HeaderMatchRule constructor.
if suffix is None:
suffix = '{0:02}'.format(next(_RULE_COUNTER))
return 'header-match-{}'.format(suffix)
def make_link(header, pattern, chain=None, suffix=None):
"""Create a Link object.
The link action is to defer by default, since at the end of all the
header checks, we'll jump to the chain defined in the configuration
file, should any of them have matched. However, it is possible to
create a link which jumps to a specific chain.
:param header: The email header name to check, e.g. X-Spam.
:type header: string
:param pattern: A regular expression for matching the header value.
:type pattern: string
:param chain: When given, this is the name of the chain to jump to if the
pattern matches the header.
:type chain: string
:param suffix: An optional name suffix for the rule.
:type suffix: string
:return: The link representing this rule check.
:rtype: `ILink`
"""
rule_name = _make_rule_name(suffix)
if rule_name in config.rules:
del config.rules[rule_name]
rule = HeaderMatchRule(header, pattern, suffix)
if chain is None:
return Link(rule)
return Link(rule, LinkAction.jump, chain)
@implementer(IRule)
class HeaderMatchRule:
"""Header matching rule used by header-match chain."""
def __init__(self, header, pattern, suffix=None):
self.header = header
self.pattern = pattern
self.name = _make_rule_name(suffix)
self.description = '{}: {}'.format(header, pattern)
# XXX I think we should do better here, somehow recording that a
# particular header matched a particular pattern, but that gets ugly
# with RFC 2822 headers. It also doesn't match well with the rule
# name concept. For now, we just record the rather useless numeric
# rule name. I suppose we could do the better hit recording in the
# check() method, and set self.record = False.
self.record = True
# Register this rule so that other parts of the system can query it.
assert self.name not in config.rules, (
'Duplicate HeaderMatchRule: {} [{}: {}]'.format(
self.name, self.header, self.pattern))
config.rules[self.name] = self
def check(self, mlist, msg, msgdata):
"""See `IRule`."""
# Collect all the headers in all subparts.
headers = []
for p in msg.walk():
headers.extend(p.get_all(self.header, []))
for value in headers:
if isinstance(value, Header):
value = value.encode()
# RFC2047 decode, but don't change value as it affects the msg.
new_value = str(make_header(decode_header(value)))
try:
mo = re.search(self.pattern, new_value, re.IGNORECASE)
except re.error as error:
log.error(
"Invalid regexp '{}' in header_matches for {}: {}".format(
self.pattern, mlist.list_id, error.msg))
return False
else:
if mo:
msgdata['moderation_sender'] = msg.sender
with _.defer_translation():
# This will be translated at the point of use.
msgdata.setdefault('moderation_reasons', []).append(
(_('Header "{}" matched a header rule'),
str(self.header) + ": " + str(value)))
return True
return False
@public
class HeaderMatchChain(Chain):
"""Default header matching chain.
This could be extended by header match rules in the database.
"""
def __init__(self):
super().__init__(
'header-match', _('The built-in header matching chain'))
# This chain will dynamically calculate the links from the
# configuration file, the database, and any explicitly added header
# checks (via the .extend() method).
self._extended_links = []
def extend(self, header, pattern):
"""Extend the existing header matches.
:param header: The case-insensitive header field name.
:param pattern: The pattern to match the header's value again. The
match is not anchored and is done case-insensitively.
"""
self._extended_links.append(make_link(header, pattern))
def flush(self):
"""See `IMutableChain`."""
# Remove all dynamically created rules. Use the keys so we can mutate
# the dictionary inside the loop.
for rule_name in list(config.rules):
if rule_name.startswith('header-match-'):
del config.rules[rule_name]
self._extended_links = []
def get_links(self, mlist, msg, msgdata):
"""See `IChain`."""
# First return all the configuration file links.
for index, line in enumerate(
config.antispam.header_checks.splitlines()):
if len(line.strip()) == 0:
continue
parts = line.split(':', 1)
if len(parts) != 2:
log.error('Configuration error: [antispam]header_checks '
'contains bogus line: {}'.format(line))
continue
rule_name = 'config-{}'.format(index)
yield make_link(parts[0], parts[1].lstrip(), suffix=rule_name)
# Then return all the explicitly added links.
yield from self._extended_links
# If any of the above rules matched, they will have deferred their
# action until now, so jump to the chain defined in the configuration
# file. For security considerations, this takes precedence over
# list-specific matches.
yield Link('any', LinkAction.jump, config.antispam.jump_chain)
# Then return all the list-specific header matches.
for index, entry in enumerate(mlist.header_matches):
# Jump to the default antispam chain if the entry chain is None.
chain = (config.antispam.jump_chain
if entry.chain is None
else entry.chain)
rule_name = '{}-{}'.format(mlist.list_id, index)
yield make_link(entry.header, entry.pattern, chain, rule_name)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1726290746.6795182
mailman-3.3.10/src/mailman/chains/hold.py 0000644 0000000 0000000 00000030257 14671215473 015135 0 ustar 00 # Copyright (C) 2007-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""The terminal 'hold' chain."""
import logging
from email.mime.message import MIMEMessage
from email.mime.text import MIMEText
from email.utils import formatdate, make_msgid
from mailman.app.moderator import hold_message
from mailman.app.replybot import can_acknowledge
from mailman.chains.base import TerminalChainBase
from mailman.config import config
from mailman.core.i18n import _, format_reasons
from mailman.email.message import UserNotification
from mailman.interfaces.autorespond import IAutoResponseSet, Response
from mailman.interfaces.chain import HoldEvent
from mailman.interfaces.languages import ILanguageManager
from mailman.interfaces.pending import IPendable, IPendings
from mailman.interfaces.template import ITemplateLoader
from mailman.interfaces.usermanager import IUserManager
from mailman.utilities.string import expand, oneline, wrap
from public import public
from zope.component import getUtility
from zope.event import notify
from zope.interface import implementer
SEMISPACE = '; '
SPACE = ' '
NL = '\n'
log = logging.getLogger('mailman.vette')
@implementer(IPendable)
class HeldMessagePendable(dict):
PEND_TYPE = 'held message'
def _compose_reasons(msgdata, column=66):
# Rules can add reasons to the metadata.
reasons = msgdata.get('moderation_reasons', [_('N/A')])
return NL.join(
[(SPACE * 4) + wrap(reason, column=column)
for reason in format_reasons(reasons)])
def autorespond_to_sender(mlist, sender, language=None):
"""Should Mailman automatically respond to this sender?
:param mlist: The mailing list.
:type mlist: `IMailingList`.
:param sender: The sender's email address.
:type sender: string
:param language: Optional language.
:type language: `ILanguage` or None
:return: True if an automatic response should be sent, otherwise False.
If an automatic response is not sent, a message is sent indicating
that, er no more will be sent today.
:rtype: bool
"""
if language is None:
language = mlist.preferred_language
max_autoresponses_per_day = int(config.mta.max_autoresponses_per_day)
if max_autoresponses_per_day == 0:
# Unlimited.
return True
# Get an IAddress from an email address.
user_manager = getUtility(IUserManager)
address = user_manager.get_address(sender)
if address is None:
address = user_manager.create_address(sender)
response_set = IAutoResponseSet(mlist)
todays_count = response_set.todays_count(address, Response.hold)
if todays_count < max_autoresponses_per_day:
# This person has not reached their automatic response limit, so it's
# okay to send a response.
response_set.response_sent(address, Response.hold)
return True
elif todays_count == max_autoresponses_per_day:
# The last one we sent was the last one we should send today. Instead
# of sending an automatic response, send them the "no more today"
# message.
log.info('hold autoresponse limit hit: %s', sender)
response_set.response_sent(address, Response.hold)
# Send this notification message instead.
template = getUtility(ITemplateLoader).get(
'list:user:notice:no-more-today', mlist,
language=language.code)
text = wrap(expand(template, mlist, dict(
language=language.code,
count=todays_count,
sender_email=sender,
# For backward compatibility.
sender=sender,
owneremail=mlist.owner_address,
)))
with _.using(language.code):
msg = UserNotification(
sender, mlist.owner_address,
_('Last autoresponse notification for today'),
text, lang=language)
msg.send(mlist)
return False
else:
# We've sent them everything we're going to send them today.
log.info('Automatic response limit discard: %s', sender)
return False
@public
class HoldChain(TerminalChainBase):
"""Hold a message."""
name = 'hold'
description = _('Hold a message and stop processing.')
def _process(self, mlist, msg, msgdata):
"""See `TerminalChainBase`."""
# Start by decorating the message with a header that contains a list
# of all the rules that matched. These metadata could be None or an
# empty list.
rule_hits = msgdata.get('rule_hits')
if rule_hits:
msg['X-Mailman-Rule-Hits'] = SEMISPACE.join(rule_hits)
rule_misses = msgdata.get('rule_misses')
if rule_misses:
msg['X-Mailman-Rule-Misses'] = SEMISPACE.join(rule_misses)
# We need to ensure these are in the list's preferred language.
with _.using(mlist.preferred_language.code):
reasons = format_reasons(msgdata.get('moderation_reasons',
['n/a']))
# Hold the message by adding it to the list's request database.
request_id = hold_message(mlist, msg, msgdata, SEMISPACE.join(reasons))
# Calculate a confirmation token to send to the author of the
# message.
pendable = HeldMessagePendable(id=request_id, list_id=mlist.list_id)
token = getUtility(IPendings).add(pendable)
# Get the language to send the response in. If the sender is a
# member, then send it in the member's language, otherwise send it in
# the mailing list's preferred language.
member = mlist.members.get_member(msg.sender)
language = (member.preferred_language
if member else mlist.preferred_language)
# A substitution dictionary for the email templates.
charset = mlist.preferred_language.charset
original_subject = msg.get('subject')
if original_subject is None:
original_subject = _('(no subject)')
else:
# We try to encode to the mailing list's perferred charset,
# but if we can't, we leave it as is because notificatuins can now
# be sent as utf-8 if necessary.
oneline_subject = oneline(original_subject, in_unicode=True)
try:
bytes_subject = oneline_subject.encode(charset)
original_subject = bytes_subject.decode(charset)
except UnicodeError:
original_subject = oneline_subject
substitutions = dict(
subject=original_subject,
sender_email=msg.sender,
reasons=_compose_reasons(msgdata),
# For backward compatibility.
sender=msg.sender,
)
# At this point the message is held, but now we have to craft at least
# two responses. The first will go to the original author of the
# message and it will contain the token allowing them to approve or
# discard the message. The second one will go to the moderators of
# the mailing list, if the list is so configured.
#
# Start by possibly sending a response to the message author. There
# are several reasons why we might not go through with this. If the
# message was gated from NNTP, the author may not even know about this
# list, so don't spam them. If the author specifically requested that
# acknowledgments not be sent, or if the message was bulk email, then
# we do not send the response. It's also possible that either the
# mailing list, or the author (if they are a member) have been
# configured to not send such responses.
if (not msgdata.get('fromusenet') and
can_acknowledge(msg) and
mlist.respond_to_post_requests and
autorespond_to_sender(mlist, msg.sender, language)):
# We can respond to the sender with a message indicating their
# posting was held.
subject = _(
'Your message to ${mlist.fqdn_listname} awaits moderator '
'approval')
send_language_code = msgdata.get('lang', language.code)
template = getUtility(ITemplateLoader).get(
'list:user:notice:hold', mlist,
language=send_language_code)
text = wrap(expand(template, mlist, dict(
language=send_language_code,
**substitutions)))
adminaddr = mlist.bounces_address
nmsg = UserNotification(
msg.sender, adminaddr, subject, text,
getUtility(ILanguageManager)[send_language_code])
nmsg.send(mlist)
# Now the message for the list moderators. This one should appear to
# come from -owner since we really don't need to do bounce
# processing on it.
if mlist.admin_immed_notify:
# Now let's temporarily set the language context to that which the
# administrators are expecting.
with _.using(mlist.preferred_language.code):
language = mlist.preferred_language
charset = language.charset
substitutions['subject'] = original_subject
# We need to regenerate or re-translate a few values in the
# substitution dictionary.
substitutions['reasons'] = _compose_reasons(msgdata, 55)
# craft the admin notification message and deliver it
subject = _(
'${mlist.fqdn_listname} post from ${msg.sender} requires '
'approval')
nmsg = UserNotification(mlist.owner_address,
mlist.owner_address,
subject, lang=language)
nmsg.set_type('multipart/mixed')
template = getUtility(ITemplateLoader).get(
'list:admin:action:post', mlist)
try:
text = MIMEText(expand(template, mlist, substitutions),
_charset=charset)
except UnicodeError:
# Fall back to utf-8 if necessary.
text = MIMEText(expand(template, mlist, substitutions),
_charset='utf-8')
dmsg = MIMEText(wrap(_("""\
If you reply to this message, keeping the Subject: header intact, Mailman will
discard the held message. Do this if the message is spam. If you reply to
this message and include an Approved: header with the list password in it, the
message will be approved for posting to the list. The Approved: header can
also appear in the first line of the body of the reply.""")),
_charset=language.charset)
dmsg['Subject'] = 'confirm ' + token
dmsg['From'] = mlist.request_address
dmsg['Date'] = formatdate(localtime=True)
dmsg['Message-ID'] = make_msgid()
nmsg.attach(text)
nmsg.attach(MIMEMessage(msg))
nmsg.attach(MIMEMessage(dmsg))
nmsg.send(mlist)
# Log the held message. Log messages are not translated, so recast
# the reasons in the English.
with _.using('en'):
reasons = format_reasons(
msgdata.get('moderation_reasons', ['N/A']))
log.info('HOLD: %s post from %s held, message-id=%s: %s',
mlist.fqdn_listname, msg.sender,
msg.get('message-id', 'n/a').strip(),
SEMISPACE.join(reasons))
notify(HoldEvent(mlist, msg, msgdata, self))
././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1703670049.517584
mailman-3.3.10/src/mailman/chains/moderation.py 0000644 0000000 0000000 00000005474 14542770442 016352 0 ustar 00 # Copyright (C) 2010-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Moderation chain.
When a member or nonmember posting to the mailing list has a moderation action
that is not `defer`, the built-in chain jumps to this chain. This chain then
determines the disposition of the message based on the member's or nonmember's
moderation action.
For example, these actions jump to the appropriate terminal chain:
* accept - the message is immediately accepted
* hold - the message is held for moderator approval
* reject - the message is bounced
* discard - the message is immediately thrown away
Note that if the moderation action is `defer` then the normal decisions are
made as to the disposition of the message. `defer` is the default for
members, while `hold` is the default for nonmembers.
"""
from mailman.chains.base import JumpChainBase
from mailman.core.i18n import _
from mailman.interfaces.action import Action
from public import public
@public
class ModerationChain(JumpChainBase):
"""Dynamically produce a link jumping to the appropriate terminal chain.
The terminal chain will be one of the Accept, Hold, Discard, or Reject
chains, based on the member's or nonmember's moderation action setting.
"""
name = 'moderation'
description = _('Moderation chain')
def jump_to(self, mlist, msg, msgdata):
# Get the moderation action from the message metadata. It can only be
# one of the expected values (i.e. not Action.defer). See the
# moderation.py rule for details. This is stored in the metadata as a
# string so that it can be stored in the pending table.
action = Action[msgdata.get('member_moderation_action')]
# defer is not a valid moderation action.
jump_chain = {
Action.accept: 'accept',
Action.discard: 'discard',
Action.hold: 'hold',
Action.reject: 'reject',
}.get(action)
assert jump_chain is not None, (
'{}: Invalid moderation action: {} for sender: {}'.format(
mlist.list_id, action,
msgdata.get('moderation_sender', '(unknown)')))
return jump_chain
././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1726290746.679637
mailman-3.3.10/src/mailman/chains/owner.py 0000644 0000000 0000000 00000003036 14671215473 015334 0 ustar 00 # Copyright (C) 2012-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""The standard -owner posting chain."""
import logging
from mailman.chains.base import TerminalChainBase
from mailman.config import config
from mailman.core.i18n import _
from mailman.interfaces.chain import AcceptOwnerEvent
from public import public
from zope.event import notify
log = logging.getLogger('mailman.vette')
@public
class BuiltInOwnerChain(TerminalChainBase):
"""Default built-in -owner address chain."""
name = 'default-owner-chain'
description = _('The built-in -owner posting chain.')
def _process(self, mlist, msg, msgdata):
# At least for now, everything posted to -owners goes through.
config.switchboards['pipeline'].enqueue(msg, msgdata)
log.info('OWNER: %s', msg.get('message-id', 'n/a').strip())
notify(AcceptOwnerEvent(mlist, msg, msgdata, self))
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1726290746.6797812
mailman-3.3.10/src/mailman/chains/reject.py 0000644 0000000 0000000 00000004630 14671215473 015457 0 ustar 00 # Copyright (C) 2007-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""The terminal 'reject' chain."""
import logging
from mailman.app.bounces import bounce_message
from mailman.chains.base import TerminalChainBase
from mailman.core.i18n import _
from mailman.interfaces.chain import RejectEvent
from mailman.interfaces.pipeline import RejectMessage
from mailman.interfaces.template import ITemplateLoader
from public import public
from zope.component import getUtility
from zope.event import notify
log = logging.getLogger('mailman.vette')
NEWLINE = '\n'
SEMISPACE = '; '
@public
class RejectChain(TerminalChainBase):
"""Reject/bounce a message."""
name = 'reject'
description = _('Reject/bounce a message and stop processing.')
def _process(self, mlist, msg, msgdata):
"""See `TerminalChainBase`."""
# Start by decorating the message with a header that contains a list
# of all the rules that matched. These metadata could be None or an
# empty list.
rule_hits = msgdata.get('rule_hits')
if rule_hits:
msg['X-Mailman-Rule-Hits'] = SEMISPACE.join(rule_hits)
rule_misses = msgdata.get('rule_misses')
if rule_misses:
msg['X-Mailman-Rule-Misses'] = SEMISPACE.join(rule_misses)
reasons = msgdata.get('moderation_reasons')
if reasons is None:
error = None
else:
template = getUtility(ITemplateLoader).get(
'list:user:notice:rejected', mlist)
error = RejectMessage(
template, reasons, dict(listname=mlist.display_name))
bounce_message(mlist, msg, error)
log.info('REJECT: %s', msg.get('message-id', 'n/a').strip())
notify(RejectEvent(mlist, msg, msgdata, self))
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1672813222.9024556
mailman-3.3.10/src/mailman/chains/tests/__init__.py 0000644 0000000 0000000 00000000000 14355215247 017065 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1672813222.9026682
mailman-3.3.10/src/mailman/chains/tests/issue144.eml 0000644 0000000 0000000 00000000236 14355215247 017047 0 ustar 00 To: infrastructure@lists.example.org
Subject: =?UTF-8?B?VmnFoWVuYW1qZW5za2kgcGnFoXRvbGogemEgdm9kdSA4LzE=?=
Message-ID:
From:
Ignore
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5181108
mailman-3.3.10/src/mailman/chains/tests/test_accept.py 0000644 0000000 0000000 00000004472 14542770442 017646 0 ustar 00 # Copyright (C) 2016-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Test the accept chain."""
import unittest
from mailman.app.lifecycle import create_list
from mailman.chains.base import Link
from mailman.config import config
from mailman.core.chains import process as process_chain
from mailman.interfaces.chain import AcceptEvent, IChain, LinkAction
from mailman.testing.helpers import (
event_subscribers,
specialized_message_from_string as mfs,
)
from mailman.testing.layers import ConfigLayer
from zope.interface import implementer
@implementer(IChain)
class MyChain:
name = 'mine'
description = 'A test chain'
def get_links(self, mlist, msg, msgdata):
def set_hits(mlist, msg, msgdata):
msgdata['rule_hits'] = ['first', 'second', 'third']
yield Link('truth', LinkAction.run, function=set_hits)
yield Link('truth', LinkAction.jump, 'accept')
class TestAccept(unittest.TestCase):
"""Test the accept chain."""
layer = ConfigLayer
def setUp(self):
self._mlist = create_list('ant@example.com')
self._msg = mfs("""\
From: anne@example.com
To: ant@example.com
Subject: Ignore
""")
def test_rule_hits(self):
config.chains['mine'] = MyChain()
self.addCleanup(config.chains.pop, 'mine')
hits = None
def handler(event): # noqa: E306
nonlocal hits
if isinstance(event, AcceptEvent):
hits = event.msg['x-mailman-rule-hits']
with event_subscribers(handler):
process_chain(self._mlist, self._msg, {}, start_chain='mine')
self.assertEqual(hits, 'first; second; third')
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5182884
mailman-3.3.10/src/mailman/chains/tests/test_base.py 0000644 0000000 0000000 00000005714 14542770442 017321 0 ustar 00 # Copyright (C) 2014-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Test the base chain stuff."""
import unittest
from mailman.chains.accept import AcceptChain
from mailman.chains.base import Chain, Link, TerminalChainBase
from mailman.interfaces.chain import LinkAction
from mailman.rules.any import Any
from mailman.testing.layers import ConfigLayer
class SimpleChain(TerminalChainBase):
def _process(self, mlist, msg, msgdata):
pass
class TestMiscellaneous(unittest.TestCase):
"""Reach additional code coverage."""
def test_link_repr(self):
self.assertEqual(
repr(Link(Any())), '')
def test_link_repr_function(self):
def function():
pass
self.assertEqual(
repr(Link(Any(), function=function)),
'')
def test_link_repr_chain(self):
self.assertEqual(
repr(Link(Any(), chain=AcceptChain())),
'')
def test_link_repr_chain_and_function(self):
def function():
pass
self.assertEqual(
repr(Link(Any(), chain=AcceptChain(), function=function)),
'')
def test_link_repr_chain_all(self):
def function():
pass
self.assertEqual(
repr(Link(Any(), LinkAction.stop, AcceptChain(), function)),
'')
def test_flush(self):
# Test that we can flush the links of a chain.
chain = Chain('test', 'just a testing chain')
chain.append_link(Link(Any()))
# Iterate over the links of the chain to prove there are some.
count = sum(1 for link in chain.get_iterator())
self.assertEqual(count, 1)
# Flush the chain; then there will be no links.
chain.flush()
count = sum(1 for link in chain.get_iterator())
self.assertEqual(count, 0)
class TestTerminalChainBase(unittest.TestCase):
layer = ConfigLayer
def test_terminal_chain_iterator(self):
chain = SimpleChain()
self.assertEqual([link.action for link in chain],
[LinkAction.run, LinkAction.stop])
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5184681
mailman-3.3.10/src/mailman/chains/tests/test_discard.py 0000644 0000000 0000000 00000004270 14542770442 020014 0 ustar 00 # Copyright (C) 2015-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Test the discard chain."""
import unittest
from mailman.app.lifecycle import create_list
from mailman.core.chains import process as process_chain
from mailman.testing.helpers import (
LogFileMark,
specialized_message_from_string as mfs,
)
from mailman.testing.layers import ConfigLayer
class TestDiscard(unittest.TestCase):
"""Test the discard chain."""
layer = ConfigLayer
def setUp(self):
self._mlist = create_list('test@example.com')
self._msg = mfs("""\
From: anne@example.com
To: test@example.com
Subject: Ignore
Message-Id:
""")
def test_discard_reasons(self):
# The log message must contain the moderation reasons.
msgdata = dict(moderation_reasons=['TEST-REASON-1', 'TEST-REASON-2'])
log_file = LogFileMark('mailman.vette')
process_chain(self._mlist, self._msg, msgdata, start_chain='discard')
log_entry = log_file.read()
self.assertIn('DISCARD: ', log_entry)
self.assertIn('TEST-REASON-1', log_entry)
self.assertIn('TEST-REASON-2', log_entry)
def test_discard_no_reasons(self):
# The log message contains n/a if no moderation reasons.
msgdata = {}
log_file = LogFileMark('mailman.vette')
process_chain(self._mlist, self._msg, msgdata, start_chain='discard')
log_entry = log_file.read()
self.assertIn('DISCARD: ', log_entry)
self.assertIn('[n/a]', log_entry)
././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1703670049.518661
mailman-3.3.10/src/mailman/chains/tests/test_dmarc.py 0000644 0000000 0000000 00000005642 14542770442 017475 0 ustar 00 # Copyright (C) 2017-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Test for the DMARC chain."""
import unittest
from mailman.app.lifecycle import create_list
from mailman.core.chains import process as process_chain
from mailman.interfaces.chain import DiscardEvent, RejectEvent
from mailman.testing.helpers import (
event_subscribers,
get_queue_messages,
specialized_message_from_string as mfs,
)
from mailman.testing.layers import ConfigLayer
class TestDMARC(unittest.TestCase):
layer = ConfigLayer
maxDiff = None
def setUp(self):
self._mlist = create_list('ant@example.com')
self._msg = mfs("""\
From: anne@example.com
To: test@example.com
Subject: Ignore
""")
def test_discard(self):
msgdata = dict(dmarc_action='discard')
# When a message is discarded, the only artifacts are a log message
# and an event. Catch the event to prove it happened.
events = []
def handler(event): # noqa: E306
if isinstance(event, DiscardEvent):
events.append(event)
with event_subscribers(handler):
process_chain(self._mlist, self._msg, msgdata, start_chain='dmarc')
self.assertEqual(len(events), 1)
self.assertIs(events[0].msg, self._msg)
def test_reject(self):
msgdata = dict(
dmarc_action='reject',
moderation_reasons=['DMARC violation'],
)
# When a message is reject, an event will be triggered and the message
# will be bounced.
events = []
def handler(event): # noqa: E306
if isinstance(event, RejectEvent):
events.append(event)
with event_subscribers(handler):
process_chain(self._mlist, self._msg, msgdata, start_chain='dmarc')
self.assertEqual(len(events), 1)
self.assertIs(events[0].msg, self._msg)
items = get_queue_messages('virgin', expected_count=1)
# Unpack the rejection message.
rejection = items[0].msg.get_payload(0).get_payload()
self.assertEqual(rejection, """\
Your message to the Ant mailing-list was rejected for the following
reasons:
DMARC violation
The original message as received by Mailman is attached.
""")
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5189543
mailman-3.3.10/src/mailman/chains/tests/test_headers.py 0000644 0000000 0000000 00000051044 14542770442 020017 0 ustar 00 # Copyright (C) 2012-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Test the header chain."""
import unittest
from email import message_from_bytes
from mailman.app.lifecycle import create_list
from mailman.chains.headers import HeaderMatchRule, make_link
from mailman.config import config
from mailman.core.chains import process
from mailman.email.message import Message
from mailman.interfaces.chain import (
DiscardEvent,
HoldEvent,
LinkAction,
RejectEvent,
)
from mailman.interfaces.mailinglist import IHeaderMatchList
from mailman.testing.helpers import (
configuration,
event_subscribers,
LogFileMark,
specialized_message_from_string as mfs,
)
from mailman.testing.layers import ConfigLayer
class TestHeaderChain(unittest.TestCase):
"""Test the header chain code."""
layer = ConfigLayer
def setUp(self):
self._mlist = create_list('test@example.com')
def test_make_link(self):
# Test that make_link() with no given chain creates a Link with a
# deferred link action.
link = make_link('Subject', '[tT]esting')
self.assertEqual(link.rule.header, 'Subject')
self.assertEqual(link.rule.pattern, '[tT]esting')
self.assertEqual(link.action, LinkAction.defer)
self.assertIsNone(link.chain)
def test_make_link_with_chain(self):
# Test that make_link() with a given chain creates a Link with a jump
# action to the chain.
link = make_link('Subject', '[tT]esting', 'accept')
self.assertEqual(link.rule.header, 'Subject')
self.assertEqual(link.rule.pattern, '[tT]esting')
self.assertEqual(link.action, LinkAction.jump)
self.assertEqual(link.chain, config.chains['accept'])
@configuration('antispam', header_checks="""
Foo: a+
Bar: bb?
""")
def test_config_checks(self):
# Test that the header-match chain has the header checks from the
# configuration file.
chain = config.chains['header-match']
# The links are created dynamically; the rule names will all start
# with the same prefix, but have a variable suffix. The actions will
# all be to jump to the named chain. Do these checks now, while we
# collect other useful information.
post_checks = []
saw_any_rule = False
for link in chain.get_links(self._mlist, Message(), {}):
if link.rule.name == 'any':
saw_any_rule = True
self.assertEqual(link.action, LinkAction.jump)
elif saw_any_rule:
raise AssertionError("'any' rule was not last")
else:
self.assertEqual(link.rule.name[:13], 'header-match-')
self.assertEqual(link.action, LinkAction.defer)
post_checks.append((link.rule.header, link.rule.pattern))
self.assertListEqual(post_checks, [
('Foo', 'a+'),
('Bar', 'bb?'),
])
@configuration('antispam', header_checks="""
Foo: foo
A-bad-line
Bar: bar
""")
def test_bad_configuration_line(self):
# Take a mark on the error log file.
mark = LogFileMark('mailman.error')
# A bad value in [antispam]header_checks should just get ignored, but
# with an error message logged.
chain = config.chains['header-match']
# The links are created dynamically; the rule names will all start
# with the same prefix, but have a variable suffix. The actions will
# all be to jump to the named chain. Do these checks now, while we
# collect other useful information.
post_checks = []
saw_any_rule = False
for link in chain.get_links(self._mlist, Message(), {}):
if link.rule.name == 'any':
saw_any_rule = True
self.assertEqual(link.action, LinkAction.jump)
elif saw_any_rule:
raise AssertionError("'any' rule was not last")
else:
self.assertEqual(link.rule.name[:13], 'header-match-')
self.assertEqual(link.action, LinkAction.defer)
post_checks.append((link.rule.header, link.rule.pattern))
self.assertListEqual(post_checks, [
('Foo', 'foo'),
('Bar', 'bar'),
])
# Check the error log.
self.assertEqual(mark.readline()[-77:-1],
'Configuration error: [antispam]header_checks '
'contains bogus line: A-bad-line')
def test_bad_regexp(self):
# Take a mark on the error log file.
mark = LogFileMark('mailman.error')
# A bad regexp in header_checks should just get ignored, but
# with an error message logged.
header_matches = IHeaderMatchList(self._mlist)
header_matches.append('Foo', '+a bad regexp', 'reject')
msg = mfs("""\
From: anne@example.com
To: test@example.com
Subject: A message
Message-ID:
Foo: foo
MIME-Version: 1.0
A message body.
""")
msgdata = {}
# This event subscriber records the event that occurs when the message
# is processed by the header-match chain.
events = []
with event_subscribers(events.append):
process(self._mlist, msg, msgdata, start_chain='header-match')
self.assertEqual(len(events), 0)
# Check the error log.
self.assertEqual(mark.readline()[-89:-1],
"Invalid regexp '+a bad regexp' in header_matches "
'for test.example.com: nothing to repeat')
def test_duplicate_header_match_rule(self):
# 100% coverage: test an assertion in a corner case.
#
# Save the existing rules so they can be restored later.
saved_rules = config.rules.copy()
self.addCleanup(setattr, config, 'rules', saved_rules)
HeaderMatchRule('x-spam-score', '*', suffix='100')
self.assertRaises(AssertionError,
HeaderMatchRule, 'x-spam-score', '.*', suffix='100')
def test_list_rule(self):
# Test that the header-match chain has the header checks from the
# mailing-list configuration.
chain = config.chains['header-match']
header_matches = IHeaderMatchList(self._mlist)
header_matches.append('Foo', 'a+')
links = [link for link in chain.get_links(self._mlist, Message(), {})
if link.rule.name != 'any']
self.assertEqual(len(links), 1)
self.assertEqual(links[0].action, LinkAction.jump)
self.assertEqual(links[0].chain.name, config.antispam.jump_chain)
self.assertEqual(links[0].rule.header, 'foo')
self.assertEqual(links[0].rule.pattern, 'a+')
self.assertTrue(links[0].rule.name.startswith(
'header-match-test.example.com-'))
def test_list_complex_rule(self):
# Test that the mailing-list header-match complex rules are read
# properly.
chain = config.chains['header-match']
header_matches = IHeaderMatchList(self._mlist)
header_matches.append('Foo', 'a+', 'reject')
header_matches.append('Bar', 'b+', 'discard')
header_matches.append('Baz', 'z+', 'accept')
links = [link for link in chain.get_links(self._mlist, Message(), {})
if link.rule.name != 'any']
self.assertEqual(len(links), 3)
self.assertEqual([
(link.rule.header, link.rule.pattern, link.action, link.chain.name)
for link in links
],
[('foo', 'a+', LinkAction.jump, 'reject'),
('bar', 'b+', LinkAction.jump, 'discard'),
('baz', 'z+', LinkAction.jump, 'accept'),
]) # noqa: E124
def test_list_complex_rule_deletion(self):
# Test that the mailing-list header-match complex rules are read
# properly after deletion.
chain = config.chains['header-match']
header_matches = IHeaderMatchList(self._mlist)
header_matches.append('Foo', 'a+', 'reject')
header_matches.append('Bar', 'b+', 'discard')
header_matches.append('Baz', 'z+', 'accept')
links = [link for link in chain.get_links(self._mlist, Message(), {})
if link.rule.name != 'any']
self.assertEqual(len(links), 3)
self.assertEqual([
(link.rule.header, link.rule.pattern, link.action, link.chain.name)
for link in links
],
[('foo', 'a+', LinkAction.jump, 'reject'),
('bar', 'b+', LinkAction.jump, 'discard'),
('baz', 'z+', LinkAction.jump, 'accept'),
]) # noqa: E124
del header_matches[0]
links = [link for link in chain.get_links(self._mlist, Message(), {})
if link.rule.name != 'any']
self.assertEqual(len(links), 2)
self.assertEqual([
(link.rule.header, link.rule.pattern, link.action, link.chain.name)
for link in links
],
[('bar', 'b+', LinkAction.jump, 'discard'),
('baz', 'z+', LinkAction.jump, 'accept'),
]) # noqa: E124
def test_list_complex_rule_reorder(self):
# Test that the mailing-list header-match complex rules are read
# properly after reordering.
chain = config.chains['header-match']
header_matches = IHeaderMatchList(self._mlist)
header_matches.append('Foo', 'a+', 'reject')
header_matches.append('Bar', 'b+', 'discard')
header_matches.append('Baz', 'z+', 'accept')
links = [link for link in chain.get_links(self._mlist, Message(), {})
if link.rule.name != 'any']
self.assertEqual(len(links), 3)
self.assertEqual([
(link.rule.header, link.rule.pattern, link.action, link.chain.name)
for link in links
],
[('foo', 'a+', LinkAction.jump, 'reject'),
('bar', 'b+', LinkAction.jump, 'discard'),
('baz', 'z+', LinkAction.jump, 'accept'),
]) # noqa: E124
del header_matches[0]
header_matches.append('Foo', 'a+', 'reject')
links = [link for link in chain.get_links(self._mlist, Message(), {})
if link.rule.name != 'any']
self.assertEqual(len(links), 3)
self.assertEqual([
(link.rule.header, link.rule.pattern, link.action, link.chain.name)
for link in links
],
[('bar', 'b+', LinkAction.jump, 'discard'),
('baz', 'z+', LinkAction.jump, 'accept'),
('foo', 'a+', LinkAction.jump, 'reject'),
]) # noqa: E124
def test_header_in_subpart(self):
# Test that headers in sub-parts are also matched.
msg = mfs("""\
From: anne@example.com
To: test@example.com
Subject: A message
Message-ID:
Foo: foo
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="================12345=="
--================12345==
Content-Type: text/plain; charset="us-ascii"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
A message body.
--================12345==
Content-Type: application/junk
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
This is junk
--================12345==--
""")
msgdata = {}
header_matches = IHeaderMatchList(self._mlist)
header_matches.append('Content-Type', 'application/junk', 'hold')
# This event subscriber records the event that occurs when the message
# is processed by the owner chain.
events = []
with event_subscribers(events.append):
process(self._mlist, msg, msgdata, start_chain='header-match')
self.assertEqual(len(events), 1)
event = events[0]
self.assertIsInstance(event, HoldEvent)
self.assertEqual(event.chain, config.chains['hold'])
def test_rfc2047_encodedheader(self):
# Test case where msg.get_all() returns raw rfc2047 encoded string.
msg = message_from_bytes(b"""\
From: anne@example.com
To: test@example.com
Subject: =?utf-8?b?SSBsaWtlIElrZQo=?=
Message-ID:
body
""", Message)
msgdata = {}
header_matches = IHeaderMatchList(self._mlist)
header_matches.append('Subject', 'I Like Ike', 'hold')
# This event subscriber records the event that occurs when the message
# is processed by the owner chain.
events = []
with event_subscribers(events.append):
process(self._mlist, msg, msgdata, start_chain='header-match')
self.assertEqual(len(events), 1)
event = events[0]
self.assertIsInstance(event, HoldEvent)
self.assertEqual(event.chain, config.chains['hold'])
def test_get_all_returns_non_string(self):
# Test case where msg.get_all() returns header instance.
msg = message_from_bytes(b"""\
From: anne@example.com
To: test@example.com
Subject: Bad \x96 subject
Message-ID:
body
""", Message)
msgdata = {}
header_matches = IHeaderMatchList(self._mlist)
header_matches.append('Subject', 'Bad', 'hold')
# This event subscriber records the event that occurs when the message
# is processed by the owner chain.
events = []
with event_subscribers(events.append):
process(self._mlist, msg, msgdata, start_chain='header-match')
self.assertEqual(len(events), 1)
event = events[0]
self.assertIsInstance(event, HoldEvent)
self.assertEqual(event.chain, config.chains['hold'])
@configuration('antispam', header_checks="""
Foo: foo
""", jump_chain='hold')
def test_priority_site_over_list(self):
# Test that the site-wide checks take precedence over the list-specific
# checks.
msg = mfs("""\
From: anne@example.com
To: test@example.com
Subject: A message
Message-ID:
Foo: foo
MIME-Version: 1.0
A message body.
""")
msgdata = {}
header_matches = IHeaderMatchList(self._mlist)
header_matches.append('Foo', 'foo', 'accept')
# This event subscriber records the event that occurs when the message
# is processed by the owner chain.
events = []
with event_subscribers(events.append):
process(self._mlist, msg, msgdata, start_chain='header-match')
self.assertEqual(len(events), 1)
event = events[0]
# Site-wide wants to hold the message, the list wants to accept it.
self.assertIsInstance(event, HoldEvent)
self.assertEqual(event.chain, config.chains['hold'])
def test_no_action_defaults_to_site_wide_action(self):
# If the list-specific header check matches, but there is no defined
# action, the site-wide antispam action is used.
msg = mfs("""\
From: anne@example.com
To: test@example.com
Subject: A message
Message-ID:
Foo: foo
MIME-Version: 1.0
A message body.
""")
header_matches = IHeaderMatchList(self._mlist)
header_matches.append('Foo', 'foo')
# This event subscriber records the event that occurs when the message
# is processed by the owner chain, which holds its for approval.
events = []
def record_holds(event): # noqa: E301
if not isinstance(event, HoldEvent):
return
events.append(event)
with event_subscribers(record_holds):
# Set the site-wide antispam action to hold the message.
with configuration('antispam', header_checks="""
Spam: [*]{3,}
""", jump_chain='hold'): # noqa: E125
process(self._mlist, msg, {}, start_chain='header-match')
self.assertEqual(len(events), 1)
event = events[0]
self.assertIsInstance(event, HoldEvent)
self.assertEqual(event.chain, config.chains['hold'])
self.assertEqual(event.mlist, self._mlist)
self.assertEqual(event.msg, msg)
events = []
def record_discards(event): # noqa: E301
if not isinstance(event, DiscardEvent):
return
events.append(event)
with event_subscribers(record_discards):
# Set the site-wide default to discard the message.
msg.replace_header('Message-Id', '')
with configuration('antispam', header_checks="""
Spam: [*]{3,}
""", jump_chain='discard'): # noqa: E125
process(self._mlist, msg, {}, start_chain='header-match')
self.assertEqual(len(events), 1)
event = events[0]
self.assertIsInstance(event, DiscardEvent)
self.assertEqual(event.chain, config.chains['discard'])
self.assertEqual(event.mlist, self._mlist)
self.assertEqual(event.msg, msg)
@unittest.expectedFailure
@configuration('antispam', header_checks="""
Header1: a+
""", jump_chain='hold')
def test_reuse_rules(self):
# Test that existing header-match rules are used instead of creating
# new ones.
# MAS Reusing existing rules is problematic. If the rule with say
# position = 0 is deleted the following rule should become
# header-match--0 but that rule has the old values for
# header and pattern.
# See https://gitlab.com/mailman/mailman/-/issues/818
chain = config.chains['header-match']
header_matches = IHeaderMatchList(self._mlist)
header_matches.append('Header2', 'b+')
header_matches.append('Header3', 'c+')
def get_links(): # noqa: E306
return [
link for link in chain.get_links(self._mlist, Message(), {})
if link.rule.name != 'any'
]
links_1 = get_links()
self.assertEqual(len(links_1), 3)
links_2 = get_links()
# The link rules both have the same name...
self.assertEqual(
[link.rule.name for link in links_1],
[link.rule.name for link in links_2],
)
# ...and are actually the identical objects.
for link1, link2 in zip(links_1, links_2):
self.assertIs(link1.rule, link2.rule)
def test_hold_returns_reason(self):
# Test that a match with hold action returns a reason
msg = mfs("""\
From: anne@example.com
To: test@example.com
Subject: Bad subject
Message-ID:
body
""")
msgdata = {}
header_matches = IHeaderMatchList(self._mlist)
header_matches.append('Subject', 'Bad', 'hold')
# This event subscriber records the event that occurs when the message
# is processed by the owner chain.
events = []
with event_subscribers(events.append):
process(self._mlist, msg, msgdata, start_chain='header-match')
self.assertEqual(len(events), 1)
event = events[0]
self.assertIsInstance(event, HoldEvent)
self.assertEqual(msgdata['moderation_reasons'],
[('Header "{}" matched a header rule',
'subject: Bad subject')])
def test_reject_returns_reason(self):
# Test that a match with reject action returns a reason
msg = mfs("""\
From: anne@example.com
To: test@example.com
Subject: Bad subject
Message-ID:
body
""")
msgdata = {}
header_matches = IHeaderMatchList(self._mlist)
header_matches.append('Subject', 'Bad', 'reject')
# This event subscriber records the event that occurs when the message
# is processed by the owner chain.
events = []
with event_subscribers(events.append):
process(self._mlist, msg, msgdata, start_chain='header-match')
self.assertEqual(len(events), 1)
event = events[0]
self.assertIsInstance(event, RejectEvent)
self.assertEqual(msgdata['moderation_reasons'],
[('Header "{}" matched a header rule',
'subject: Bad subject')])
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1726290746.6799994
mailman-3.3.10/src/mailman/chains/tests/test_hold.py 0000644 0000000 0000000 00000032310 14671215473 017326 0 ustar 00 # Copyright (C) 2011-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Additional tests for the hold chain."""
import unittest
from email import message_from_bytes as mfb
from importlib.resources import read_binary
from mailman.app.lifecycle import create_list
from mailman.chains.builtin import BuiltInChain
from mailman.chains.hold import autorespond_to_sender, HoldChain
from mailman.core.chains import process as process_chain
from mailman.core.i18n import _
from mailman.interfaces.autorespond import IAutoResponseSet, Response
from mailman.interfaces.languages import ILanguageManager
from mailman.interfaces.member import MemberRole
from mailman.interfaces.messages import IMessageStore
from mailman.interfaces.requests import IListRequests, RequestType
from mailman.interfaces.usermanager import IUserManager
from mailman.testing.helpers import (
configuration,
get_queue_messages,
LogFileMark,
set_preferred,
specialized_message_from_string as mfs,
subscribe,
)
from mailman.testing.layers import ConfigLayer
from zope.component import getUtility
SEMISPACE = '; '
class TestAutorespond(unittest.TestCase):
"""Test autorespond_to_sender()"""
layer = ConfigLayer
maxDiff = None
def setUp(self):
self._mlist = create_list('test@example.com')
@configuration('mta', max_autoresponses_per_day=1)
def test_max_autoresponses_per_day(self):
# The last one we sent was the last one we should send today. Instead
# of sending an automatic response, send them the "no more today"
# message. Start by simulating a response having been sent to an
# address already.
anne = getUtility(IUserManager).create_address('anne@example.com')
response_set = IAutoResponseSet(self._mlist)
response_set.response_sent(anne, Response.hold)
# Trigger the sending of a "last response for today" using the default
# language (i.e. the mailing list's preferred language).
autorespond_to_sender(self._mlist, 'anne@example.com')
# So first, there should be one more hold response sent to the user.
self.assertEqual(response_set.todays_count(anne, Response.hold), 2)
# And the virgin queue should have the message in it.
messages = get_queue_messages('virgin')
self.assertEqual(len(messages), 1)
# Remove the variable headers.
message = messages[0].msg
self.assertIn('message-id', message)
del message['message-id']
self.assertIn('date', message)
del message['date']
self.assertMultiLineEqual(messages[0].msg.as_string(), """\
MIME-Version: 1.0
Content-Type: text/plain; charset="us-ascii"
Content-Transfer-Encoding: 7bit
Subject: Last autoresponse notification for today
From: test-owner@example.com
To: anne@example.com
Precedence: bulk
We have received a message from your address
requesting an automated response from the test@example.com mailing
list.
The number we have seen today: 1. In order to avoid problems such as
mail loops between email robots, we will not be sending you any
further responses today. Please try again tomorrow.
If you believe this message is in error, or if you have any questions,
please contact the list owner at test-owner@example.com.""")
class TestHoldChain(unittest.TestCase):
"""Test the hold chain code."""
layer = ConfigLayer
def setUp(self):
self._mlist = create_list('test@example.com')
self._user_manager = getUtility(IUserManager)
def test_hold_chain(self):
msg = mfs("""\
From: anne@example.com
To: test@example.com
Subject: A message
Message-ID:
MIME-Version: 1.0
A message body.
""")
msgdata = dict(moderation_reasons=[
'TEST-REASON-1',
'TEST-REASON-2',
('TEST-{}-REASON-{}', 'FORMAT', 3),
])
logfile = LogFileMark('mailman.vette')
process_chain(self._mlist, msg, msgdata, start_chain='hold')
messages = get_queue_messages('virgin', expected_count=2)
payloads = {}
for item in messages:
if item.msg['to'] == 'test-owner@example.com':
part = item.msg.get_payload(0)
payloads['owner'] = part.get_payload().splitlines()
elif item.msg['To'] == 'anne@example.com':
payloads['sender'] = item.msg.get_payload().splitlines()
else:
self.fail('Unexpected message: %s' % item.msg)
self.assertIn(' TEST-REASON-1', payloads['owner'])
self.assertIn(' TEST-REASON-2', payloads['owner'])
self.assertIn(' TEST-FORMAT-REASON-3', payloads['owner'])
self.assertIn(' TEST-REASON-1', payloads['sender'])
self.assertIn(' TEST-REASON-2', payloads['sender'])
self.assertIn(' TEST-FORMAT-REASON-3', payloads['sender'])
logged = logfile.read()
self.assertIn('TEST-REASON-1', logged)
self.assertIn('TEST-REASON-2', logged)
self.assertIn('TEST-FORMAT-REASON-3', logged)
# Check the reason passed to hold_message().
requests = IListRequests(self._mlist)
self.assertEqual(requests.count_of(RequestType.held_message), 1)
request = requests.of_type(RequestType.held_message)[0]
key, data = requests.get_request(request.id)
self.assertEqual(
data.get('_mod_reason'),
'TEST-REASON-1; TEST-REASON-2; TEST-FORMAT-REASON-3')
def test_hold_chain_reason_language(self):
msg = mfs("""\
From: anne@example.com
To: test@example.com
Subject: A message
Message-ID:
MIME-Version: 1.0
A message body.
""")
# Subscribe Anne to the list.
anne = subscribe(self._mlist, 'Anne', email='anne@example.com')
# Clear the welcome message.
get_queue_messages('virgin', expected_count=1)
# Set Anne's preferred language.
french = getUtility(ILanguageManager).get('fr')
anne.preferences.preferred_language = french
_.push('fr')
# Reason could be anything that will be translated. There are only a
# few in the testing french catalog.
msgdata = dict(moderation_reasons=[
'[Message discarded by content filter]'])
process_chain(self._mlist, msg, msgdata, start_chain='hold')
# Check the reason passed to hold_message().
requests = IListRequests(self._mlist)
self.assertEqual(requests.count_of(RequestType.held_message), 1)
request = requests.of_type(RequestType.held_message)[0]
key, data = requests.get_request(request.id)
self.assertEqual(
data.get('_mod_reason'),
'[Message discarded by content filter]')
# Get the notices to user and admin.
messages = get_queue_messages('virgin', expected_count=2)
# Only interested in the admin message.
msg = messages[1].msg
self.assertIn('[Message discarded by content filter]',
msg.get_payload(0).get_payload())
def test_hold_chain_no_reasons_given(self):
msg = mfs("""\
From: anne@example.com
To: test@example.com
Subject: A message
Message-ID:
MIME-Version: 1.0
A message body.
""")
process_chain(self._mlist, msg, {}, start_chain='hold')
# No reason was given, so a default is used.
requests = IListRequests(self._mlist)
self.assertEqual(requests.count_of(RequestType.held_message), 1)
request = requests.of_type(RequestType.held_message)[0]
key, data = requests.get_request(request.id)
self.assertEqual(data.get('_mod_reason'), 'n/a')
def test_hold_chain_charset(self):
# Issue #144 - UnicodeEncodeError in the hold chain.
# Also, issue # 673 use UTF-8 rather than replace.
self._mlist.admin_immed_notify = True
self._mlist.respond_to_post_requests = False
bart = self._user_manager.create_user('bart@example.com', 'Bart User')
address = set_preferred(bart)
self._mlist.subscribe(address, MemberRole.moderator)
msg = mfb(read_binary('mailman.chains.tests', 'issue144.eml'))
msg.sender = 'anne@example.com'
process_chain(self._mlist, msg, {}, start_chain='hold')
# The postauth.txt message is now in the virgin queue awaiting
# delivery to the moderators.
items = get_queue_messages('virgin', expected_count=1)
msgdata = items[0].msgdata
# Should get sent to -owner address.
self.assertEqual(msgdata['recipients'], {'test-owner@example.com'})
# Ensure that the subject looks correct in the postauth.txt.
msg = items[0].msg
value = None
payload = msg.get_payload(0).get_payload(decode=True).decode('utf-8')
for line in payload.splitlines():
if line.strip().startswith('Subject:'):
header, colon, value = line.partition(':')
break
self.assertEqual(value.lstrip(), 'Višenamjenski pištolj za vodu 8/1')
self.assertEqual(
msg['Subject'],
'test@example.com post from anne@example.com requires approval')
def test_hold_chain_charset_user(self):
# Issue # 673 use UTF-8 rather than replace in user notice.
self._mlist.admin_immed_notify = False
self._mlist.respond_to_post_requests = True
msg = mfb(read_binary('mailman.chains.tests', 'issue144.eml'))
msg.sender = 'anne@example.com'
process_chain(self._mlist, msg, {}, start_chain='hold')
# The user notice message is now in the virgin queue awaiting
# delivery to the user.
items = get_queue_messages('virgin', expected_count=1)
msgdata = items[0].msgdata
# Should get sent to -owner address.
self.assertEqual(msgdata['recipients'], {'anne@example.com'})
# Ensure that the subject looks correct in the postauth.txt.
msg = items[0].msg
payload = msg.get_payload(decode=True).decode('utf-8')
self.assertIn('Višenamjenski pištolj za vodu 8/1', payload)
self.assertEqual(
msg['Subject'],
'Your message to test@example.com awaits moderator approval')
def test_hold_chain_crosspost(self):
mlist2 = create_list('test2@example.com')
msg = mfs("""\
From: anne@example.com
To: test@example.com, test2@example.com
Subject: A message
Message-ID:
MIME-Version: 1.0
A message body.
""")
process_chain(self._mlist, msg, {}, start_chain='hold')
process_chain(mlist2, msg, {}, start_chain='hold')
# There are four items in the virgin queue. Two of them are for the
# list owners who need to moderate the held message, and the other is
# for anne telling her that her message was held for approval.
items = get_queue_messages('virgin', expected_count=4)
anne_froms = set()
owner_tos = set()
for item in items:
if item.msg['to'] == 'anne@example.com':
anne_froms.add(item.msg['from'])
else:
owner_tos.add(item.msg['to'])
self.assertEqual(anne_froms, set(['test-bounces@example.com',
'test2-bounces@example.com']))
self.assertEqual(owner_tos, set(['test-owner@example.com',
'test2-owner@example.com']))
# And the message appears in the store.
messages = list(getUtility(IMessageStore).messages)
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0]['message-id'], '')
def test_hold_with_long_rule_misses(self):
msg = mfs("""\
From: anne@example.com
To: test@example.com
Subject: A message
Message-ID:
MIME-Version: 1.0
A message body.
""")
rule_misses = [x[0] for x in BuiltInChain._link_descriptions
if x[0] not in ('truth', 'any')]
for i in range(20):
rule_misses.append('header-match-test.example.com-{}'.format(i))
msgdata = dict(rule_misses=rule_misses)
msgdata['rule_hits'] = []
msgdata['moderation_reasons'] = ['something']
# We can't use process_chain because it clears rule hits and misses.
HoldChain()._process(self._mlist, msg, msgdata)
messages = get_queue_messages('virgin', expected_count=2)
for item in messages:
if item.msg['to'] == 'test-owner@example.com':
held_message = item.msg.get_payload(1).get_payload(0)
elif item.msg['To'] == 'anne@example.com':
pass
else:
self.fail('Unexpected message: %s' % item.msg)
self.assertEqual(held_message['x-mailman-rule-misses'],
SEMISPACE.join(rule_misses))
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5193832
mailman-3.3.10/src/mailman/chains/tests/test_owner.py 0000644 0000000 0000000 00000004645 14542770442 017543 0 ustar 00 # Copyright (C) 2012-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Test the owner chain."""
import unittest
from mailman.app.lifecycle import create_list
from mailman.chains.owner import BuiltInOwnerChain
from mailman.core.chains import process
from mailman.interfaces.chain import AcceptOwnerEvent
from mailman.testing.helpers import (
event_subscribers,
get_queue_messages,
specialized_message_from_string as mfs,
)
from mailman.testing.layers import ConfigLayer
class TestOwnerChain(unittest.TestCase):
"""Test the owner chain."""
layer = ConfigLayer
def setUp(self):
self._mlist = create_list('test@example.com')
self._msg = mfs("""\
From: anne@example.com
To: test@example.com
Message-ID:
""")
def test_owner_pipeline(self):
# Messages processed through the default owners chain end up in the
# pipeline queue, and an event gets sent.
#
# This event subscriber records the event that occurs when the message
# is processed by the owner chain.
events = []
def catch_event(event): # noqa: E306
if isinstance(event, AcceptOwnerEvent):
events.append(event)
with event_subscribers(catch_event):
process(self._mlist, self._msg, {}, 'default-owner-chain')
self.assertEqual(len(events), 1)
event = events[0]
self.assertIsInstance(event, AcceptOwnerEvent)
self.assertEqual(event.mlist, self._mlist)
self.assertEqual(event.msg['message-id'], '')
self.assertIsInstance(event.chain, BuiltInOwnerChain)
items = get_queue_messages('pipeline', expected_count=1)
message = items[0].msg
self.assertEqual(message['message-id'], '')
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5195787
mailman-3.3.10/src/mailman/chains/tests/test_reject.py 0000644 0000000 0000000 00000004336 14542770442 017662 0 ustar 00 # Copyright (C) 2015-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Test the reject chain."""
import unittest
from mailman.app.lifecycle import create_list
from mailman.core.chains import process as process_chain
from mailman.testing.helpers import (
get_queue_messages,
specialized_message_from_string as mfs,
)
from mailman.testing.layers import ConfigLayer
class TestReject(unittest.TestCase):
"""Test the reject chain."""
layer = ConfigLayer
def setUp(self):
self._mlist = create_list('test@example.com')
self._msg = mfs("""\
From: anne@example.com
To: test@example.com
Subject: Ignore
""")
def test_reject_reasons(self):
# The bounce message must contain the moderation reasons.
msgdata = dict(moderation_reasons=[
'TEST-REASON-1',
'TEST-REASON-2',
('TEST-{}-REASON-{}', 'FORMAT', 3),
])
process_chain(self._mlist, self._msg, msgdata, start_chain='reject')
bounces = get_queue_messages('virgin', expected_count=1)
payload = bounces[0].msg.get_payload(0).as_string()
self.assertIn('TEST-REASON-1', payload)
self.assertIn('TEST-REASON-2', payload)
self.assertIn('TEST-FORMAT-REASON-3', payload)
def test_no_reason(self):
# There may be no moderation reasons.
process_chain(self._mlist, self._msg, {}, start_chain='reject')
bounces = get_queue_messages('virgin', expected_count=1)
payload = bounces[0].msg.get_payload(0).as_string()
self.assertIn('No bounce details are available', payload)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1672813222.8821702
mailman-3.3.10/src/mailman/commands/__init__.py 0000644 0000000 0000000 00000000000 14355215247 016257 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5197575
mailman-3.3.10/src/mailman/commands/cli_addmembers.py 0000644 0000000 0000000 00000013703 14542770442 017471 0 ustar 00 # Copyright (C) 2009-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""The 'addmembers' subcommand."""
import sys
import click
from email.utils import formataddr, parseaddr
from mailman.core.i18n import _
from mailman.database.transaction import transactional
from mailman.interfaces.address import IEmailValidator
from mailman.interfaces.command import ICLISubCommand
from mailman.interfaces.listmanager import IListManager
from mailman.interfaces.member import (
AlreadySubscribedError,
DeliveryMode,
DeliveryStatus,
MembershipIsBannedError,
)
from mailman.interfaces.subscriptions import (
ISubscriptionManager,
SubscriptionPendingError,
)
from mailman.interfaces.usermanager import IUserManager
from mailman.utilities.options import I18nCommand
from public import public
from zope.component import getUtility
from zope.interface import implementer
def get_addr(display_name, email, user_manager):
"""Return an existing address record if available, otherwise make one."""
addr = user_manager.get_address(email)
if addr is not None:
# We have an address with this email. Return that.
return addr
# Unknown email. Create an address for this.
# XXX Should we be making a user instead?
return user_manager.create_address(email, display_name)
@transactional
def add_members(mlist, in_fp, delivery, invite, welcome_msg):
"""Add members to a mailing list."""
user_manager = getUtility(IUserManager)
registrar = ISubscriptionManager(mlist)
email_validator = getUtility(IEmailValidator)
for line in in_fp:
# Ignore blank lines and lines that start with a '#'.
if line.startswith('#') or len(line.strip()) == 0:
continue
# Parse the line and ensure that the values are unicodes.
display_name, email = parseaddr(line)
# parseaddr can return invalid emails. E.g. parseaddr('foobar@')
# returns ('', 'foobar@') in python 3.6.7 and 3.7.1 so check validity.
if not email_validator.is_valid(email):
line = line.strip()
print(_('Cannot parse as valid email address (skipping): ${line}'),
file=sys.stderr)
continue
subscriber = get_addr(display_name, email, user_manager)
# For error messages.
email = formataddr((display_name, email))
delivery_status = DeliveryStatus.enabled
if delivery is None or delivery == 'regular' or delivery == 'disabled':
delivery_mode = DeliveryMode.regular
if delivery == 'disabled':
delivery_status = DeliveryStatus.by_moderator
elif delivery == 'mime':
delivery_mode = DeliveryMode.mime_digests
elif delivery == 'plain':
delivery_mode = DeliveryMode.plaintext_digests
elif delivery == 'summary':
delivery_mode = DeliveryMode.summary_digests
try:
member = registrar.register(
subscriber,
pre_verified=True,
pre_approved=True,
pre_confirmed=True,
invitation=invite,
send_welcome_message=welcome_msg)[2]
if member is not None:
member.preferences.delivery_status = delivery_status
member.preferences.delivery_mode = delivery_mode
except AlreadySubscribedError:
# It's okay if the address is already subscribed, just print a
# warning and continue.
print(_('Already subscribed (skipping): ${email}'),
file=sys.stderr)
except MembershipIsBannedError:
print(_('Membership is banned (skipping): ${email}'),
file=sys.stderr)
except SubscriptionPendingError:
print(_('Subscription already pending (skipping): ${email}'),
file=sys.stderr)
@click.command(
cls=I18nCommand,
help=_("""\
Add all member addresses in FILENAME with delivery mode as specified
with -d/--delivery. FILENAME can be '-' to indicate standard input.
Blank lines and lines that start with a '#' are ignored.
"""))
@click.option(
'--delivery', '-d',
type=click.Choice(('regular', 'mime', 'plain', 'summary', 'disabled')),
help=_("""\
Set the added members delivery mode to 'regular', 'mime', 'plain',
'summary' or 'disabled'. I.e., one of regular, three modes of digest
or no delivery. If not given, the default is regular. Ignored for invited
members."""))
@click.option(
'--invite', '-i',
is_flag=True, default=False,
help=_("""\
Send the added members an invitation rather than immediately adding them.
"""))
@click.option(
'--welcome-msg/--no-welcome-msg', '-w/-W', 'welcome_msg', default=None,
help=_("""\
Override the list's setting for send_welcome_message."""))
@click.argument('in_fp', metavar='FILENAME', type=click.File(encoding='utf-8'))
@click.argument('listspec')
@click.pass_context
def addmembers(ctx, in_fp, delivery, invite, welcome_msg, listspec):
"""Add members to a mailing list."""
mlist = getUtility(IListManager).get(listspec)
if mlist is None:
ctx.fail(_('No such list: ${listspec}'))
add_members(mlist, in_fp, delivery, invite, welcome_msg)
@public
@implementer(ICLISubCommand)
class AddMembers:
name = 'addmembers'
command = addmembers
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1726290746.6801183
mailman-3.3.10/src/mailman/commands/cli_admins.py 0000644 0000000 0000000 00000010102 14671215473 016630 0 ustar 00 # Copyright (C) 2009-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""The 'admins' subcommand."""
import sys
import click
from email.utils import parseaddr
from mailman.app.membership import add_member
from mailman.core.i18n import _
from mailman.interfaces.address import IEmailValidator
from mailman.interfaces.command import ICLISubCommand
from mailman.interfaces.listmanager import IListManager
from mailman.interfaces.member import AlreadySubscribedError, MemberRole
from mailman.interfaces.subscriptions import RequestRecord
from mailman.interfaces.usermanager import IUserManager
from mailman.utilities.options import I18nCommand
from public import public
from zope.component import getUtility
from zope.interface import implementer
def update_admins(ctx, add, delete, role, mlist):
user_manager = getUtility(IUserManager)
for email in add:
name, addr = parseaddr(email)
# Ensure we have user and address records.
if name == '':
name = None
user_manager.make_user(addr, name)
try:
if name is None:
name = ''
add_member(mlist, RequestRecord(addr, name), role)
except AlreadySubscribedError:
print(_('${email} is already a ${role.name} of '
'${mlist.fqdn_listname}'), file=sys.stderr)
for email in delete:
if role.name == 'owner':
member = mlist.owners.get_member(email)
else:
member = mlist.moderators.get_member(email)
if not member:
print(_('${email} is not a ${role.name} of '
'${mlist.fqdn_listname}'), file=sys.stderr)
continue
member.unsubscribe()
@click.command(
cls=I18nCommand,
help=_("""\
Add and/or delete owners and moderators of a list.
"""))
@click.option(
'--add', '-a', multiple=True,
help=_("""\
User to add with the given role. This may be an email address or, if
quoted, any display name and email address parseable by
email.utils.parseaddr. E.g., 'Jane Doe '. May be repeated
to add multiple users.
"""))
@click.option(
'--delete', '-d', multiple=True,
help=_("""\
Email address of the user to be removed from the given role.
May be repeated to delete multiple users.
"""))
@click.option(
'--role', '-r', default='owner',
type=click.Choice(('owner', 'moderator'), case_sensitive=False),
help=_("""\
The role to add/delete. This may be 'owner' or 'moderator'.
If not given, then 'owner' role is assumed.
"""))
@click.argument('listspec')
@click.pass_context
def admins(ctx, add, delete, role, listspec):
email_validator = getUtility(IEmailValidator)
mlist = getUtility(IListManager).get(listspec)
if mlist is None:
ctx.fail(_('No such list: ${listspec}'))
if not add and not delete:
ctx.fail(_('Nothing to add or delete.'))
for email in add:
name, addr = parseaddr(email)
if not (addr and email_validator.is_valid(addr)):
ctx.fail(_('Invalid email address: ${email}'))
for email in delete:
if not email_validator.is_valid(email):
ctx.fail(_('Invalid email address: ${email}'))
if role == 'owner':
role = MemberRole.owner
else:
role = MemberRole.moderator
update_admins(ctx, add, delete, role, mlist)
@public
@implementer(ICLISubCommand)
class Members:
name = 'admins'
command = admins
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5199306
mailman-3.3.10/src/mailman/commands/cli_aliases.py 0000644 0000000 0000000 00000003062 14542770442 017004 0 ustar 00 # Copyright (C) 2009-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Generate Mailman alias files for your MTA."""
import click
from mailman.config import config
from mailman.core.i18n import _
from mailman.interfaces.command import ICLISubCommand
from mailman.utilities.modules import call_name
from mailman.utilities.options import I18nCommand
from public import public
from zope.interface import implementer
@click.command(
cls=I18nCommand,
help=_('Regenerate the aliases appropriate for your MTA.'))
@click.option(
'--directory', '-d',
type=click.Path(exists=True, file_okay=False, resolve_path=True,
writable=True),
help=_('An alternative directory to output the various MTA files to.'))
def aliases(directory):
call_name(config.mta.incoming).regenerate(directory)
@public
@implementer(ICLISubCommand)
class Aliases:
name = 'aliases'
command = aliases
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5201015
mailman-3.3.10/src/mailman/commands/cli_changeaddress.py 0000644 0000000 0000000 00000004615 14542770442 020163 0 ustar 00 # Copyright (C) 2009-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""The 'changeaddress' subcommand."""
import click
from mailman.core.i18n import _
from mailman.interfaces.address import IEmailValidator
from mailman.interfaces.command import ICLISubCommand
from mailman.interfaces.usermanager import IUserManager
from mailman.utilities.options import I18nCommand
from public import public
from zope.component import getUtility
from zope.interface import implementer
@click.command(
cls=I18nCommand,
help=_("""\
Change a user's email address from old_address to possibly case-preserved
new_address.
"""))
@click.argument('old_address')
@click.argument('new_address')
@click.pass_context
def changeaddress(ctx, old_address, new_address):
user_manager = getUtility(IUserManager)
address = user_manager.get_address(old_address)
if address is None:
ctx.fail(_('Address {} not found.').format(old_address))
if not getUtility(IEmailValidator).is_valid(new_address):
ctx.fail(_('Address {} is not a valid email address.').format(
new_address))
if new_address == old_address:
ctx.fail(_('Addresses are not different. Nothing to change.'))
if (user_manager.get_address(new_address) is not None and
new_address.lower() != old_address.lower()):
ctx.fail(_("Address {} already exists; can't change.").format(
new_address))
address.email = new_address.lower()
address._original = (None if new_address.lower() == new_address
else new_address)
print(_('Address changed from {} to {}.').format(old_address, new_address))
@public
@implementer(ICLISubCommand)
class ChangeAddress:
name = 'changeaddress'
command = changeaddress
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5203092
mailman-3.3.10/src/mailman/commands/cli_conf.py 0000644 0000000 0000000 00000010416 14542770442 016311 0 ustar 00 # Copyright (C) 2013-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Print the mailman configuration."""
import click
from lazr.config._config import Section
from mailman.config import config
from mailman.core.i18n import _
from mailman.interfaces.command import ICLISubCommand
from mailman.utilities.options import I18nCommand
from public import public
from zope.interface import implementer
def _section_exists(section):
# Not all of the attributes in config are actual sections, so we have to
# check the section's type.
return (
hasattr(config, section) and
isinstance(getattr(config, section), Section)
)
def _get_value(section, key):
return getattr(getattr(config, section), key)
def _print_values_for_section(section, output):
current_section = sorted(getattr(config, section))
for key in current_section:
value = _get_value(section, key)
print('[{}] {}: {}'.format(section, key, value), file=output)
@click.command(
cls=I18nCommand,
help=_('Print the Mailman configuration.'))
@click.option(
'--output', '-o',
type=click.File(mode='w', encoding='utf-8', atomic=True),
help=_("""\
File to send the output to. If not given, or if '-' is given, standard
output is used."""))
@click.option(
'--section', '-s',
help=_("""\
Section to use for the lookup. If no key is given, all the key-value pairs
of the given section will be displayed."""))
@click.option(
'--key', '-k',
help=_("""\
Key to use for the lookup. If no section is given, all the key-values pair
from any section matching the given key will be displayed."""))
@click.pass_context
def conf(ctx, output, section, key):
# Case 1: Both section and key are given, so we can look the value up
# directly.
if section is not None and key is not None:
if not _section_exists(section):
ctx.fail('No such section: {}'.format(section))
elif not hasattr(getattr(config, section), key):
ctx.fail('Section {}: No such key: {}'.format(section, key))
else:
print(_get_value(section, key), file=output)
# Case 2: Section is given, key is not given.
elif section is not None and key is None:
if _section_exists(section):
_print_values_for_section(section, output)
else:
ctx.fail('No such section: {}'.format(section))
# Case 3: Section is not given, key is given.
elif section is None and key is not None:
for current_section in sorted([section.name for section in config]):
# We have to ensure that the current section actually exists
# and that it contains the given key.
if (_section_exists(current_section) and
hasattr(getattr(config, current_section), key)):
value = _get_value(current_section, key)
print('[{}] {}: {}'.format(
current_section, key, value), file=output)
# Case 4: Neither section nor key are given, just display all the
# sections and their corresponding key/value pairs.
elif section is None and key is None:
for current_section in sorted([section.name for section in config]):
# However, we have to make sure that the current sections and
# key which are being looked up actually exist before trying
# to print them.
if _section_exists(current_section):
_print_values_for_section(current_section, output)
else:
raise AssertionError('Unexpected combination')
@public
@implementer(ICLISubCommand)
class Conf:
name = 'conf'
command = conf
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5205305
mailman-3.3.10/src/mailman/commands/cli_control.py 0000644 0000000 0000000 00000017345 14542770442 017054 0 ustar 00 # Copyright (C) 2009-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Start/stop/reopen/restart commands."""
import os
import sys
import click
import errno
import signal
import logging
from mailman.bin.master import master_state, WatcherState
from mailman.config import config
from mailman.core.i18n import _
from mailman.interfaces.command import ICLISubCommand
from mailman.utilities.modules import call_name
from mailman.utilities.options import I18nCommand
from public import public
from zope.interface import implementer
qlog = logging.getLogger('mailman.runner')
@click.command(
cls=I18nCommand,
help=_('Start the Mailman master and runner processes.'))
@click.option(
'--force', '-f',
is_flag=True, default=False,
help=_("""\
If the master watcher finds an existing master lock, it will normally exit
with an error message. With this option, the master will perform an extra
level of checking. If a process matching the host/pid described in the
lock file is running, the master will still exit, requiring you to manually
clean up the lock. But if no matching process is found, the master will
remove the apparently stale lock and make another attempt to claim the
master lock."""))
@click.option(
'--generate-alias-file', '-g',
is_flag=True, default=True,
help=_("""\
Generate the MTA alias files upon startup. Some MTA, like postfix, can't
deliver email if alias files mentioned in its configuration are not
present. In some situations, this could lead to a deadlock at the first
start of mailman3 server. Setting this option to true will make this
script create the files and thus allow the MTA to operate smoothly."""))
@click.option(
'--run-as-user', '-u',
is_flag=True, default=True,
help=_("""\
Normally, this script will refuse to run if the user id and group id are
not set to the 'mailman' user and group (as defined when you configured
Mailman). If run as root, this script will change to this user and group
before the check is made.
This can be inconvenient for testing and debugging purposes, so the -u flag
means that the step that sets and checks the uid/gid is skipped, and the
program is run as the current user and group. This flag is not recommended
for normal production environments.
Note though, that if you run with -u and are not in the mailman group, you
may have permission problems, such as being unable to delete a list's
archives through the web. Tough luck!"""))
@click.option(
'--quiet', '-q',
is_flag=True, default=False,
help=_("""\
Don't print status messages. Error messages are still printed to standard
error."""))
@click.pass_context
def start(ctx, force, generate_alias_file, run_as_user, quiet):
# Although there's a potential race condition here, it's a better user
# experience for the parent process to refuse to start twice, rather than
# having it try to start the master, which will error exit.
status, lock = master_state()
if status is WatcherState.conflict:
ctx.fail(_('GNU Mailman is already running'))
elif status in (WatcherState.stale_lock, WatcherState.host_mismatch):
if not force:
ctx.fail(
_('A previous run of GNU Mailman did not exit '
'cleanly ({}). Try using --force'.format(status.name)))
# Daemon process startup according to Stevens, Advanced Programming in the
# UNIX Environment, Chapter 13.
pid = os.fork()
if pid:
# parent
if not quiet:
print(_("Starting Mailman's master runner"))
if generate_alias_file:
if not quiet:
print(_("Generating MTA alias maps"))
call_name(config.mta.incoming).regenerate()
return
# child: Create a new session and become the session leader, but since we
# won't be opening any terminal devices, don't do the ultra-paranoid
# suggestion of doing a second fork after the setsid() call.
os.setsid()
# Instead of cd'ing to root, cd to the Mailman runtime directory. However,
# before we do that, set an environment variable used by the subprocesses
# to calculate their path to the $VAR_DIR.
os.environ['MAILMAN_VAR_DIR'] = config.VAR_DIR
os.chdir(config.VAR_DIR)
# Exec the master watcher.
execl_args = [
sys.executable, sys.executable,
os.path.join(config.BIN_DIR, 'master'),
]
if force:
execl_args.append('--force')
# Always pass the configuration file path to the master process, so there's
# no confusion about which one is being used.
execl_args.extend(['-C', config.filename])
qlog.debug('starting: %s', execl_args)
os.execl(*execl_args)
# We should never get here.
raise RuntimeError('os.execl() failed')
@public
@implementer(ICLISubCommand)
class Start:
name = 'start'
command = start
def kill_watcher(sig):
try:
with open(config.PID_FILE) as fp:
pid = int(fp.read().strip())
except (IOError, ValueError) as error: # pragma: nocover
# For i18n convenience
print(_('PID unreadable in: ${config.PID_FILE}'), file=sys.stderr)
print(error, file=sys.stderr)
print(_('Is the master even running?'), file=sys.stderr)
return
try:
os.kill(pid, sig)
except OSError as error: # pragma: nocover
if error.errno != errno.ESRCH:
raise
print(_('No child with pid: ${pid}'), file=sys.stderr)
print(error, file=sys.stderr)
print(_('Stale pid file removed.'), file=sys.stderr)
os.unlink(config.PID_FILE)
@click.command(
cls=I18nCommand,
help=_('Stop the Mailman master and runner processes.'))
@click.option(
'--quiet', '-q',
is_flag=True, default=False,
help=_("""\
Don't print status messages. Error messages are still printed to standard
error."""))
def stop(quiet):
if not quiet:
print(_("Shutting down Mailman's master runner"))
kill_watcher(signal.SIGTERM)
@public
@implementer(ICLISubCommand)
class Stop:
name = 'stop'
command = stop
@click.command(
cls=I18nCommand,
help=_('Signal the Mailman processes to re-open their log files.'))
@click.option(
'--quiet', '-q',
is_flag=True, default=False,
help=_("""\
Don't print status messages. Error messages are still printed to standard
error."""))
def reopen(quiet):
if not quiet:
print(_('Reopening the Mailman runners'))
kill_watcher(signal.SIGHUP)
@public
@implementer(ICLISubCommand)
class Reopen:
name = 'reopen'
command = reopen
@click.command(
cls=I18nCommand,
help=_('Stop and restart the Mailman runner subprocesses.'))
@click.option(
'--quiet', '-q',
is_flag=True, default=False,
help=_("""\
Don't print status messages. Error messages are still printed to standard
error."""))
def restart(quiet):
if not quiet:
print(_('Restarting the Mailman runners'))
kill_watcher(signal.SIGUSR1)
@public
@implementer(ICLISubCommand)
class Restart:
name = 'restart'
command = restart
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5206888
mailman-3.3.10/src/mailman/commands/cli_delmembers.py 0000644 0000000 0000000 00000012337 14542770442 017507 0 ustar 00 # Copyright (C) 2009-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""The 'delmembers' subcommand."""
import sys
import click
from email.utils import formataddr, parseaddr
from mailman.app.membership import delete_member
from mailman.core.i18n import _
from mailman.database.transaction import transactional
from mailman.interfaces.command import ICLISubCommand
from mailman.interfaces.listmanager import IListManager
from mailman.interfaces.member import NotAMemberError
from mailman.utilities.options import I18nCommand
from public import public
from zope.component import getUtility
from zope.interface import implementer
@transactional
def delete_members(mlists, memb_list, goodbye_msg, admin_notify):
"""Delete one or more members from one or more mailing lists."""
mlists = list(mlists)
for mlist in mlists:
for display_name, email in memb_list:
try:
delete_member(mlist, email, admin_notif=admin_notify,
userack=goodbye_msg)
except NotAMemberError:
email = formataddr((display_name, email))
if len(mlists) == 1:
print(_('Member not subscribed (skipping): ${email}'),
file=sys.stderr)
@click.command(
cls=I18nCommand,
help=_("""\
Delete members from a mailing list."""))
@click.option(
'--list', '-l', '_list', metavar='LISTSPEC',
help=_("""\
The list to operate on. Required unless --fromall is specified.
"""))
@click.option(
'--file', '-f', 'in_fp', metavar='FILENAME',
type=click.File(encoding='utf-8'),
help=_("""\
Delete list members whose addresses are in FILENAME in addition to those
specified with -m/--member if any. FILENAME can be '-' to indicate
standard input. Blank lines and lines that start with a '#' are ignored.
"""))
@click.option(
'--member', '-m', metavar='ADDRESS', multiple=True,
help=_("""\
Delete the list member whose address is ADDRESS in addition to those
specified with -f/--file if any. This option may be repeated for
multiple addresses.
"""))
@click.option(
'--all', '-a', '_all',
is_flag=True, default=False,
help=_("""\
Delete all the members of the list. If specified, none of -f/--file,
-m/--member or --fromall may be specified.
"""))
@click.option(
'--fromall',
is_flag=True, default=False,
help=_("""\
Delete the member(s) specified by -m/--member and/or -f/--file from all
lists in the installation. This may not be specified together with
-a/--all or -l/--list.
"""))
@click.option(
'--goodbye-msg/--no-goodbye-msg', '-g/-G', 'goodbye_msg', default=None,
help=_("""\
Override the list's setting for send_goodbye_message to
deleted members."""))
@click.option(
'--admin-notify/--no-admin-notify', '-n/-N', 'admin_notify', default=None,
help=_("""\
Override the list's setting for admin_notify_mchanges."""))
@click.pass_context
def delmembers(ctx, _list, in_fp, member, _all, fromall, goodbye_msg,
admin_notify):
"""Delete members from mailing lists."""
if fromall:
if _list is not None or _all:
ctx.fail('--fromall may not be specified with -l/--list, '
'or -a/--all')
elif _all:
if in_fp is not None or len(member) != 0:
ctx.fail('-a/--all must not be specified with '
'-f/--file or -m/--member.')
if _list is None and not fromall:
ctx.fail('Without --fromall, -l/--list is required.')
if not _all and in_fp is None and len(member) == 0:
ctx.fail('At least one of -a/--all, -f/--file or -m/--member '
'is required.')
list_manager = getUtility(IListManager)
if fromall:
mlists = list_manager.mailing_lists
else:
mlist = list_manager.get(_list)
if mlist is None:
ctx.fail(_('No such list: ${_list}'))
mlists = [mlist]
if _all:
memb_list = [(address.display_name, address.email) for address in
mlist.members.addresses]
else:
memb_list = []
memb_list.extend([parseaddr(x) for x in member])
if in_fp:
for line in in_fp:
# Ignore blank lines and lines that start with a '#'.
if line.startswith('#') or len(line.strip()) == 0:
continue
memb_list.append(parseaddr(line))
delete_members(mlists, memb_list, goodbye_msg, admin_notify)
@public
@implementer(ICLISubCommand)
class DelMembers:
name = 'delmembers'
command = delmembers
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5209053
mailman-3.3.10/src/mailman/commands/cli_digests.py 0000644 0000000 0000000 00000010610 14542770442 017022 0 ustar 00 # Copyright (C) 2015-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""The `send_digests` subcommand."""
import sys
import click
from mailman.app.digests import (
bump_digest_number_and_volume,
maybe_send_digest_now,
)
from mailman.core.i18n import _
from mailman.interfaces.command import ICLISubCommand
from mailman.interfaces.listmanager import IListManager
from mailman.utilities.options import I18nCommand
from public import public
from zope.component import getUtility
from zope.interface import implementer
@click.command(
cls=I18nCommand,
help=_('Operate on digests.'))
@click.option(
'--list', '-l', 'list_ids', metavar='list',
multiple=True, help=_("""\
Operate on this mailing list. Multiple --list options can be given. The
argument can either be a List-ID or a fully qualified list name. Without
this option, operate on the digests for all mailing lists."""))
@click.option(
'--send', '-s',
is_flag=True, default=False,
help=_("""\
Send any collected digests right now, even if the size threshold has not
yet been met."""))
@click.option(
'--bump', '-b',
is_flag=True, default=False,
help=_("""\
Increment the digest volume number and reset the digest number to one. If
given with --send, the volume number is incremented before any current
digests are sent."""))
@click.option(
'--dry-run', '-n',
is_flag=True, default=False,
help=_("""\
Don't actually do anything, but in conjunction with --verbose, show what
would happen."""))
@click.option(
'--verbose', '-v',
is_flag=True, default=False,
help=_('Print some additional status.'))
@click.option(
'--periodic', '-p',
is_flag=True, default=False,
help=_("""\
Send any collected digests for the List only if their digest_send_periodic
is set to True."""))
@click.pass_context
def digests(ctx, list_ids, send, bump, dry_run, verbose, periodic):
# send and periodic options are mutually exclusive, if they both are
# specified, exit.
if send and periodic:
print(_('--send and --periodic flags cannot be used together'),
file=sys.stderr)
exit(1)
list_manager = getUtility(IListManager)
if list_ids:
lists = []
for spec in list_ids:
# We'll accept list-ids or fqdn list names.
if '@' in spec:
mlist = list_manager.get(spec)
else:
mlist = list_manager.get_by_list_id(spec)
if mlist is None:
print(_('No such list found: ${spec}'), file=sys.stderr)
else:
lists.append(mlist)
else:
lists = list(list_manager.mailing_lists)
if bump:
for mlist in lists:
if verbose:
print(_('\
${mlist.list_id} is at volume ${mlist.volume}, number \
${mlist.next_digest_number}'))
if not dry_run:
bump_digest_number_and_volume(mlist)
if verbose:
print(_('\
${mlist.list_id} bumped to volume ${mlist.volume}, number \
${mlist.next_digest_number}'))
if send:
for mlist in lists:
if verbose:
print(_('\
${mlist.list_id} sent volume ${mlist.volume}, number \
${mlist.next_digest_number}'))
if not dry_run:
maybe_send_digest_now(mlist, force=True)
if periodic:
for mlist in lists:
if mlist.digest_send_periodic:
if verbose:
print(_('\
${mlist.list_id} sent volume ${mlist.volume}, number \
${mlist.next_digest_number}'))
if not dry_run:
maybe_send_digest_now(mlist, force=True)
@public
@implementer(ICLISubCommand)
class Digests:
name = 'digests'
command = digests
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1726290746.6802666
mailman-3.3.10/src/mailman/commands/cli_findmember.py 0000644 0000000 0000000 00000010353 14671215473 017475 0 ustar 00 # Copyright (C) 2009-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""The 'findmember' subcommand."""
import re
import click
from mailman.core.i18n import _
from mailman.interfaces.command import ICLISubCommand
from mailman.interfaces.member import MemberRole, SubscriptionMode
from mailman.interfaces.usermanager import IUserManager
from mailman.utilities.options import I18nCommand
from public import public
from zope.component import getUtility
from zope.interface import implementer
def _filter_role(member, role):
role_list = dict(
administrator=[MemberRole.owner, MemberRole.moderator],
owner=[MemberRole.owner],
moderator=[MemberRole.moderator],
member=[MemberRole.member],
nonmember=[MemberRole.nonmember],
)
if role is None or role == 'all':
return member
if member.role in role_list[role]:
return member
return None
def _get_member_email(member):
if member.subscription_mode is SubscriptionMode.as_user:
email = member.subscriber.preferred_address.email
else:
assert member.subscription_mode is SubscriptionMode.as_address
email = member.subscriber.email
return email
def _sort_key(member):
list_id = member.mailing_list.list_id
email = _get_member_email(member)
role = str(member.role)
return (email, list_id, role)
def _sql_pattern(pattern):
pattern = re.sub(r'\.\*', '%', pattern)
pattern = re.sub(r'(^|[^\\])\.', r'\1_', pattern)
pattern = re.sub(r'\\\.', '.', pattern)
if pattern.endswith('$'):
pattern = pattern[:-1]
else:
pattern = pattern + '%'
if pattern.startswith('^'):
pattern = pattern[1:]
elif not pattern.startswith('%'):
pattern = '%' + pattern
return pattern
@click.command(
cls=I18nCommand,
help=_("""\
Display all memberships for a user or users with address matching a
pattern. Because part of the process involves converting the pattern
to a SQL query with wildcards, the pattern should be simple. A simple
string works best.
"""))
@click.option(
'--role', '-r',
type=click.Choice(('all', 'owner', 'moderator', 'nonmember', 'member',
'administrator')),
help=_("""\
Display only memberships with the given role. If not given, 'all' role,
i.e. all roles, is the default."""))
@click.argument('pattern')
@click.pass_context
def findmember(ctx, role, pattern):
result = list()
user_manager = getUtility(IUserManager)
for user in user_manager.find_users(_sql_pattern(pattern)):
emails = [address.email for address in user.addresses]
for email in emails:
if re.search(pattern, email, re.I):
for member in user.memberships.members:
if _filter_role(member, role):
result.append(member)
break
if len(result) == 0:
return
result.sort(key=_sort_key)
last_email = last_list_id = last_role = ''
for member in result:
email = _get_member_email(member)
if email != last_email:
last_list_id = last_role = ''
print(_('Email: {}').format(email))
last_email = email
if member.list_id != last_list_id:
last_role = ''
print(' '*4 + _('List: {}').format(member.list_id))
last_list_id = member.list_id
if member.role != last_role:
print(' '*8 + _('{}').format(str(member.role)))
last_role = member.role
@public
@implementer(ICLISubCommand)
class FindMember:
name = 'findmember'
command = findmember
././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1703670049.521293
mailman-3.3.10/src/mailman/commands/cli_gatenews.py 0000644 0000000 0000000 00000017647 14542770442 017216 0 ustar 00 # Copyright (C) 1998-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""The `gatenews` subcommand."""
import os
import click
import socket
import logging
import nntplib
import datetime
from email import errors, message_from_bytes
from flufl.lock import Lock, TimeOutError
from mailman.config import config
from mailman.core.i18n import _
from mailman.email import message
from mailman.interfaces.command import ICLISubCommand
from mailman.interfaces.listmanager import IListManager
from mailman.utilities.options import I18nCommand
from public import public
from zope.component import getUtility
from zope.interface import implementer
NL = b'\n'
log = None
conn = None
def open_newsgroup(mlist):
global conn
nntp_host = config.nntp.host or 'localhost'
nntp_port = int(config.nntp.port) if config.nntp.port else 119
# Open up a "mode reader" connection to nntp server. This will be shared
# for all the gated lists having the same nntp_host.
if conn is None:
try:
conn = nntplib.NNTP(nntp_host, nntp_port,
readermode=True,
user=config.nntp.user,
password=config.nntp.password)
except (socket.error, nntplib.NNTPError, IOError) as e:
log.error('error opening connection to nntp_host: %s\n%s',
nntp_host, e)
raise
# Get the GROUP information for the list, but we're only really interested
# in the first article number and the last article number
r, c, f, l, n = conn.group(mlist.linked_newsgroup)
return conn, int(f), int(l)
def poll_newsgroup(mlist, conn, first, last, glock):
listname = mlist.fqdn_listname
# NEWNEWS is not portable and has synchronization issues.
for num in range(first, last):
glock.refresh()
try:
headers = conn.head(num)[1].lines
found_to = False
beenthere = False
unfolded = [b'Dummy:']
for header in headers:
if header.startswith((b' ', b'\t')):
unfolded[-1] += header
else:
unfolded.append(header)
for header in unfolded:
i = header.find(b':')
value = header[:i].lower()
if i > 0 and value == b'to':
found_to = True
if value != b'list-id':
continue
our_list_id = '<{}>'.format(mlist.list_id)
if header.endswith(our_list_id.encode('us-ascii')):
beenthere = True
break
if not beenthere:
lines = conn.article(num)[1].lines
try:
msg = message_from_bytes(NL.join(lines), message.Message)
except errors.MessageError as e:
log.error('email package exception for %s:%d\n%s',
mlist.linked_newsgroup, num, e)
continue
if found_to:
del msg['X-Originally-To']
msg['X-Originally-To'] = msg['To']
del msg['To']
msg['To'] = mlist.posting_address
# Post the message to the list
inq = config.switchboards['in']
# original_size is both a message attribute and a key in
# msgdata.
msg.original_size = len(msg.as_bytes())
inq.enqueue(msg,
listid=mlist.list_id,
original_size=msg.original_size,
fromusenet=True)
log.info('posted to list %s: %7d', listname, num)
except nntplib.NNTPError as e:
log.error('NNTP error for list %s: %7d\n%s', listname, num, e)
# Even if we don't post the message because it was seen on the
# list already, update the watermark
mlist.usenet_watermark = num
def process_lists(glock):
list_manager = getUtility(IListManager)
for mlist in list_manager.mailing_lists:
glock.refresh()
listname = mlist.fqdn_listname
if not mlist.gateway_to_mail:
continue
# Get the list's watermark, i.e. the last article number that we gated
# from news to mail. None means that this list has never polled its
# newsgroup and that we should do a catch up.
watermark = getattr(mlist, 'usenet_watermark', None)
# Open the newsgroup, but let most exceptions percolate up.
try:
conn, first, last = open_newsgroup(mlist)
except (socket.error, nntplib.NNTPError, IOError) as e:
log.error('NNTP error for list %s:\n%s', listname, e)
break
log.info('%s: [%d..%d]', listname, first, last)
if watermark is None:
# This is the first time we've tried to gate this
# newsgroup. We essentially do a mass catch-up, otherwise
# we'd flood the mailing list.
mlist.usenet_watermark = last
log.info('%s caught up to article %d', listname, last)
else:
# The list has been polled previously, so now we simply
# grab all the messages on the newsgroup that have not
# been seen by the mailing list. The first such article
# is the maximum of the lowest article available in the
# newsgroup and the watermark. It's possible that some
# articles have been expired since the last time gatenews
# has run. Not much we can do about that.
start = max(watermark + 1, first)
if start > last:
log.info('nothing new for list %s', listname)
else:
log.info('gating %s articles [%d..%d]',
listname, start, last)
# Use last+1 because poll_newsgroup() employes a for
# loop over range, and this will not include the last
# element in the list.
poll_newsgroup(mlist, conn, start, last + 1, glock)
log.info('%s watermark: %d', listname, mlist.usenet_watermark)
@click.command(
cls=I18nCommand,
help=_("""\
Poll the NNTP server for messages to be gatewayed to mailing lists."""))
@click.pass_context
def gatenews(ctx):
global conn, log
missing = object()
if os.getenv('_MAILMAN_GATENEWS_NNTP', missing) is missing:
raise click.UsageError(_("""\
The gatenews command is run periodically by the nntp runner.
If you are running it via cron, you should remove it from the crontab.
If you want to run it manually, set _MAILMAN_GATENEWS_NNTP in the
environment."""))
GATENEWS_LOCK_FILE = os.path.join(config.LOCK_DIR, 'gatenews.lock')
LOCK_LIFETIME = datetime.timedelta(hours=2)
log = logging.getLogger('mailman.fromusenet')
try:
with Lock(GATENEWS_LOCK_FILE,
# It's okay to hijack this
lifetime=LOCK_LIFETIME) as lock:
process_lists(lock)
if conn:
conn.quit()
conn = None
except TimeOutError: # pragma: nocover
log.error('Could not acquire gatenews lock')
@public
@implementer(ICLISubCommand)
class GateNews:
name = 'gatenews'
command = gatenews
././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1703670049.521469
mailman-3.3.10/src/mailman/commands/cli_help.py 0000644 0000000 0000000 00000002536 14542770442 016320 0 ustar 00 # Copyright (C) 2009-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""The 'help' subcommand."""
import sys
import click
from mailman.core.i18n import _
from mailman.interfaces.command import ICLISubCommand
from mailman.utilities.options import I18nCommand
from public import public
from zope.interface import implementer
@click.command(
cls=I18nCommand,
help=_('Show this help message and exit.'))
@click.pass_context
# https://github.com/pallets/click/issues/832
def help(ctx): # pragma: nocover
click.echo(ctx.parent.get_help(), color=ctx.color)
sys.exit()
@public
@implementer(ICLISubCommand)
class Help:
name = 'help'
command = help
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5216434
mailman-3.3.10/src/mailman/commands/cli_import.py 0000644 0000000 0000000 00000006660 14542770442 016704 0 ustar 00 # Copyright (C) 2010-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Importing list data into Mailman 3."""
import sys
import click
import pickle
from contextlib import ExitStack
from mailman.core.i18n import _
from mailman.database.transaction import transaction
from mailman.interfaces.command import ICLISubCommand
from mailman.interfaces.listmanager import IListManager
from mailman.utilities.importer import Import21Error, import_config_pck
from mailman.utilities.modules import hacked_sys_modules
from mailman.utilities.options import I18nCommand
from public import public
from zope.component import getUtility
from zope.interface import implementer
# A fake module to go with `Bouncer`.
class _Mailman:
__path__ = 'src/mailman/commands/cli_import.py'
# A fake Mailman object with Bouncer class from Mailman 2.1, we don't use it
# but there are instances in the .pck files.
class _Bouncer:
class _BounceInfo:
pass
@click.command(
cls=I18nCommand,
help=_("""\
Import Mailman 2.1 list data. Requires the fully-qualified name of the
list to import and the path to the Mailman 2.1 pickle file."""))
@click.option(
'--charset', '-c', default='utf-8',
help=_("""\
Specify the encoding of strings in PICKLE_FILE if not utf-8 or a subset
thereof. This will normally be the Mailman 2.1 charset of the list's
preferred_language."""))
@click.argument('listspec')
@click.argument(
'pickle_file', metavar='PICKLE_FILE',
type=click.File(mode='rb'))
@click.pass_context
def import21(ctx, charset, listspec, pickle_file):
mlist = getUtility(IListManager).get(listspec)
if mlist is None:
ctx.fail(_('No such list: ${listspec}'))
with ExitStack() as resources:
resources.enter_context(hacked_sys_modules('Mailman', _Mailman))
resources.enter_context(
hacked_sys_modules('Mailman.Bouncer', _Bouncer))
resources.enter_context(transaction())
while True:
try:
config_dict = pickle.load(
pickle_file, encoding=charset, errors='ignore')
except EOFError:
break
except pickle.UnpicklingError:
ctx.fail(
_('Not a Mailman 2.1 configuration file: ${pickle_file}'))
else:
if not isinstance(config_dict, dict):
print(_('Ignoring non-dictionary: {0!r}').format(
config_dict), file=sys.stderr)
continue
try:
import_config_pck(mlist, config_dict)
except Import21Error as error:
print(error, file=sys.stderr)
sys.exit(1)
@public
@implementer(ICLISubCommand)
class Import21:
name = 'import21'
command = import21
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5218134
mailman-3.3.10/src/mailman/commands/cli_info.py 0000644 0000000 0000000 00000005420 14542770442 016316 0 ustar 00 # Copyright (C) 2009-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Information about this Mailman instance."""
import sys
import click
from lazr.config import as_boolean
from mailman.config import config
from mailman.core.api import API30, API31
from mailman.core.i18n import _
from mailman.interfaces.command import ICLISubCommand
from mailman.utilities.options import I18nCommand
from mailman.version import MAILMAN_VERSION_FULL
from public import public
from zope.interface import implementer
@click.command(
cls=I18nCommand,
help=_('Information about this Mailman instance.'))
@click.option(
'--output', '-o',
type=click.File(mode='w', encoding='utf-8', atomic=True),
help=_("""\
File to send the output to. If not given, standard output is used."""))
@click.option(
'--verbose', '-v',
is_flag=True, default=False,
help=_("""\
A more verbose output including the file system paths that Mailman is
using."""))
def info(output, verbose):
"""See `ICLISubCommand`."""
print(MAILMAN_VERSION_FULL, file=output)
print('Python', sys.version, file=output)
print('config file:', config.filename, file=output)
print('db url:', config.db.url, file=output)
print('devmode:',
'ENABLED' if as_boolean(config.devmode.enabled) else 'DISABLED',
file=output)
api = (API30 if config.webservice.api_version == '3.0' else API31)
print('REST root url:', api.path_to('/'), file=output)
print('REST credentials: {}:{}'.format(
config.webservice.admin_user, config.webservice.admin_pass),
file=output)
if verbose:
print('File system paths:', file=output)
longest = 0
paths = {}
for attribute in dir(config):
if attribute.endswith('_DIR') or attribute.endswith('_FILE'):
paths[attribute] = getattr(config, attribute)
longest = max(longest, len(attribute))
for attribute in sorted(paths):
print(' {0:{2}} = {1}'.format(
attribute, paths[attribute], longest))
@public
@implementer(ICLISubCommand)
class Info:
name = 'info'
command = info
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5220132
mailman-3.3.10/src/mailman/commands/cli_inject.py 0000644 0000000 0000000 00000006360 14542770442 016643 0 ustar 00 # Copyright (C) 2009-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""The `mailman inject` subcommand."""
import sys
import click
from mailman.app.inject import inject_text
from mailman.config import config
from mailman.core.i18n import _
from mailman.interfaces.command import ICLISubCommand
from mailman.interfaces.listmanager import IListManager
from mailman.utilities.options import I18nCommand
from public import public
from zope.component import getUtility
from zope.interface import implementer
def show_queues(ctx, param, value):
if value:
print('Available queues:')
for switchboard in sorted(config.switchboards):
print(' ', switchboard)
sys.exit(0)
# Returning None tells click to process the rest of the command line.
@click.command(
cls=I18nCommand,
help=_("Inject a message from a file into a mailing list's queue."))
@click.option(
'--queue', '-q',
help=_("""\
The name of the queue to inject the message to. QUEUE must be one of the
directories inside the queue directory. If omitted, the incoming queue is
used."""))
@click.option(
'--show', '-s',
is_flag=True, default=False, is_eager=True, expose_value=False,
callback=show_queues,
help=_('Show a list of all available queue names and exit.'))
@click.option(
'--filename', '-f', 'message_file',
default='-', type=click.File(encoding='utf-8'),
help=_("""\
Name of file containing the message to inject. If not given, or
'-' (without the quotes) standard input is used."""))
@click.option(
'--metadata', '-m', 'keywords',
multiple=True, metavar='KEY=VALUE',
help=_("""\
Additional metadata key/value pairs to add to the message metadata
dictionary. Use the format key=value. Multiple -m options are
allowed."""))
@click.argument('listspec')
@click.pass_context
def inject(ctx, queue, message_file, keywords, listspec):
mlist = getUtility(IListManager).get(listspec)
if mlist is None:
ctx.fail(_('No such list: ${listspec}'))
queue_name = ('in' if queue is None else queue)
switchboard = config.switchboards.get(queue_name)
if switchboard is None:
ctx.fail(_('No such queue: ${queue}'))
try:
message_text = message_file.read()
except KeyboardInterrupt:
print('Interrupted')
sys.exit(1)
kws = {}
for keyvalue in keywords:
key, equals, value = keyvalue.partition('=')
kws[key] = value
inject_text(mlist, message_text, switchboard=queue, **kws)
@public
@implementer(ICLISubCommand)
class Inject:
name = 'inject'
command = inject
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1726290746.6804209
mailman-3.3.10/src/mailman/commands/cli_lists.py 0000644 0000000 0000000 00000022676 14671215473 016536 0 ustar 00 # Copyright (C) 2009-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""The 'lists' subcommand."""
import sys
import click
from mailman.app.lifecycle import create_list, remove_list
from mailman.core.constants import system_preferences
from mailman.core.i18n import _
from mailman.database.transaction import transaction
from mailman.email.message import UserNotification
from mailman.interfaces.address import (
IEmailValidator,
InvalidEmailAddressError,
)
from mailman.interfaces.command import ICLISubCommand
from mailman.interfaces.domain import (
BadDomainSpecificationError,
IDomainManager,
)
from mailman.interfaces.languages import ILanguageManager
from mailman.interfaces.listmanager import IListManager, ListAlreadyExistsError
from mailman.interfaces.styles import IStyleManager
from mailman.interfaces.template import ITemplateLoader
from mailman.utilities.options import I18nCommand
from mailman.utilities.string import expand, wrap
from operator import attrgetter
from public import public
from zope.component import getUtility
from zope.interface import implementer
COMMASPACE = ', '
@click.command(
cls=I18nCommand,
help=_('List all mailing lists.'))
@click.option(
'--advertised', '-a',
is_flag=True, default=False,
help=_('List only those mailing lists that are publicly advertised'))
@click.option(
'--names/--no-names', '-n/-N',
is_flag=True, default=False,
help=_('Show also the list names'))
@click.option(
'--descriptions/--no-descriptions', '-d/-D',
is_flag=True, default=False,
help=_('Show also the list descriptions'))
@click.option(
'--quiet', '-q',
is_flag=True, default=False,
help=_('Less verbosity'))
@click.option(
'--domain', 'domains',
multiple=True, metavar='DOMAIN',
help=_("""\
List only those mailing lists hosted on the given domain, which
must be the email host name. Multiple -d options may be given.
"""))
@click.pass_context
def lists(ctx, advertised, names, descriptions, quiet, domains):
mailing_lists = set()
list_manager = getUtility(IListManager)
# Gather the matching mailing lists.
for mlist in list_manager.mailing_lists:
if advertised and not mlist.advertised:
continue
if len(domains) > 0 and mlist.mail_host not in domains:
continue
mailing_lists.add(mlist)
# Maybe no mailing lists matched.
if len(mailing_lists) == 0:
if not quiet:
print(_('No matching mailing lists found'))
sys.exit()
count = len(mailing_lists) # noqa: F841
if not quiet:
print(_('${count} matching mailing lists found:'))
# Calculate the longest identifier.
longest = 0
output = []
for mlist in sorted(mailing_lists, key=attrgetter('list_id')):
if names:
identifier = '{} [{}]'.format(
mlist.fqdn_listname, mlist.display_name)
else:
identifier = mlist.fqdn_listname
longest = max(len(identifier), longest)
output.append((identifier, mlist.description))
# Print it out.
for identifier, description in output:
if descriptions and description:
print('{0:{1}}'.format(identifier, longest) + ' - ' + description)
# to truncate use description[:70-longest]
elif descriptions:
print('{0:{1}}'.format(identifier, longest) + ' -')
else:
print(identifier)
@public
@implementer(ICLISubCommand)
class Lists:
name = 'lists'
command = lists
@click.command(
cls=I18nCommand,
help=_("""\
Create a mailing list.
The 'fully qualified list name', i.e. the posting address of the mailing
list is required. It must be a valid email address and the domain must be
registered with Mailman. List names are forced to lower case."""))
@click.option(
'--language', metavar='CODE',
help=_("""\
Set the list's preferred language to CODE, which must be a registered two
letter language code."""))
@click.option(
'--owner', '-o', 'owners',
multiple=True, metavar='OWNER',
help=_("""\
Specify a list owner email address. If the address is not currently
registered with Mailman, the address is registered and linked to a user.
Mailman will send a confirmation message to the address, but it will also
send a list creation notice to the address. More than one owner can be
specified."""))
@click.option(
'--style-name', metavar='NAME',
help=_("""\
Specify a list style name."""))
@click.option(
'--notify/-no-notify', '-n/-N',
default=False,
help=_("""\
Notify the list owner by email that their mailing list has been
created."""))
@click.option(
'--quiet', '-q',
is_flag=True, default=False,
help=_('Print less output.'))
@click.option(
'--domain/--no-domain', '-d/-D', 'create_domain',
default=True,
help=_("""\
Register the mailing list's domain if not yet registered. This is
the default behavior, but these options are provided for backward
compatibility. With -D do not register the mailing list's domain."""))
@click.argument('fqdn_listname', metavar='LISTNAME')
@click.pass_context
def create(ctx, language, owners, style_name, notify, quiet, create_domain,
fqdn_listname):
language_code = (language if language is not None
else system_preferences.preferred_language.code)
# Make sure that the selected language code is known.
if language_code not in getUtility(ILanguageManager).codes:
ctx.fail(_('Invalid language code: ${language_code}'))
# Check to see if the domain exists or not.
listname, at, domain = fqdn_listname.partition('@')
domain_manager = getUtility(IDomainManager)
if domain_manager.get(domain) is None and create_domain:
domain_manager.add(domain)
# Validate the owner email addresses. The problem with doing this check in
# create_list() is that you wouldn't be able to distinguish between an
# InvalidEmailAddressError for the list name or the owners. I suppose we
# could subclass that exception though.
if len(owners) > 0:
validator = getUtility(IEmailValidator)
invalid_owners = [owner for owner in owners
if not validator.is_valid(owner)]
if invalid_owners:
invalid = COMMASPACE.join(sorted(invalid_owners)) # noqa: F841
ctx.fail(_('Illegal owner addresses: ${invalid}'))
if style_name is not None:
if getUtility(IStyleManager).get(style_name) is None:
ctx.fail(_('Unknown list style name: ${style_name}'))
try:
mlist = create_list(fqdn_listname, owners, style_name)
except InvalidEmailAddressError:
ctx.fail(_('Illegal list name: ${fqdn_listname}'))
except ListAlreadyExistsError:
ctx.fail(_('List already exists: ${fqdn_listname}'))
except BadDomainSpecificationError as domain: # noqa: F841
ctx.fail(_('Undefined domain: ${domain}'))
# Find the language associated with the code, then set the mailing list's
# preferred language to that.
language_manager = getUtility(ILanguageManager)
with transaction():
mlist.preferred_language = language_manager[language_code]
# Do the notification.
if not quiet:
print(_('Created mailing list: ${mlist.fqdn_listname}'))
if notify:
template = getUtility(ITemplateLoader).get(
'domain:admin:notice:new-list', mlist)
text = wrap(expand(template, mlist, dict(
# For backward compatibility.
requestaddr=mlist.request_address,
siteowner=mlist.no_reply_address,
)))
# Set the I18N language to the list's preferred language so the header
# will match the template language. Stashing and restoring the old
# translation context is just (healthy? :) paranoia.
with _.using(mlist.preferred_language.code):
msg = UserNotification(
owners, mlist.no_reply_address,
_('Your new mailing list: ${fqdn_listname}'),
text, mlist.preferred_language)
msg.send(mlist)
@public
@implementer(ICLISubCommand)
class Create:
name = 'create'
command = create
@click.command(
cls=I18nCommand,
help=_('Remove a mailing list.'))
@click.option(
'--quiet', '-q',
is_flag=True, default=False,
help=_('Suppress status messages'))
@click.argument('listspec')
def remove(quiet, listspec):
mlist = getUtility(IListManager).get(listspec)
if mlist is None:
if not quiet:
print(_('No such list matching spec: ${listspec}'))
sys.exit(0)
with transaction():
remove_list(mlist)
if not quiet:
print(_('Removed list: ${listspec}'))
@public
@implementer(ICLISubCommand)
class Remove:
name = 'remove'
command = remove
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1726290746.6807642
mailman-3.3.10/src/mailman/commands/cli_members.py 0000644 0000000 0000000 00000021204 14671215473 017014 0 ustar 00 # Copyright (C) 2009-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""The 'members' subcommand."""
import click
from mailman.core.i18n import _
from mailman.interfaces.command import ICLISubCommand
from mailman.interfaces.listmanager import IListManager
from mailman.interfaces.member import DeliveryMode, DeliveryStatus, MemberRole
from mailman.utilities.options import I18nCommand
from operator import attrgetter
from public import public
from zope.component import getUtility
from zope.interface import implementer
def display_members(ctx, mlist, role, regular, digest,
nomail, outfp, email_only, count_only):
# Which type of digest recipients should we display?
if digest == 'any':
digest_types = [
DeliveryMode.plaintext_digests,
DeliveryMode.mime_digests,
DeliveryMode.summary_digests,
]
elif digest == 'mime':
# Include summary with mime as they are currently treated alike.
digest_types = [
DeliveryMode.mime_digests,
DeliveryMode.summary_digests,
]
elif digest is not None:
digest_types = [DeliveryMode[digest + '_digests']]
else:
# Don't filter on digest type.
pass
# Which members with delivery disabled should we display?
if nomail is None:
# Don't filter on delivery status.
pass
elif nomail == 'byadmin':
status_types = [DeliveryStatus.by_moderator]
elif nomail.startswith('by'):
status_types = [DeliveryStatus['by_' + nomail[2:]]]
elif nomail == 'enabled':
status_types = [DeliveryStatus.enabled]
elif nomail == 'unknown':
status_types = [DeliveryStatus.unknown]
elif nomail == 'any':
status_types = [
DeliveryStatus.by_user,
DeliveryStatus.by_bounces,
DeliveryStatus.by_moderator,
DeliveryStatus.unknown,
]
else: # pragma: nocover
# click should enforce a valid nomail option.
raise AssertionError(nomail)
# Which roles should we display?
if role is None:
# By default, filter on members.
roster = mlist.members
elif role == 'administrator':
roster = mlist.administrators
elif role == 'any':
roster = mlist.subscribers
else:
# click should enforce a valid member role.
roster = mlist.get_roster(MemberRole[role])
# Print; outfp will be either the file or stdout to print to.
addresses = list(roster.addresses)
if len(addresses) == 0:
print(0 if count_only else _('${mlist.list_id} has no members'),
file=outfp)
return
if count_only:
print(roster.member_count, file=outfp)
return
for address in sorted(addresses, key=attrgetter('email')):
member = roster.get_member(address.email)
if regular:
if member.delivery_mode != DeliveryMode.regular:
continue
if digest is not None:
if member.delivery_mode not in digest_types:
continue
if nomail is not None:
if member.delivery_status not in status_types:
continue
dn = address.display_name or member.user.display_name
if email_only or not dn:
print(address.original_email, file=outfp)
else:
print(f'{dn} <{address.original_email}>',
file=outfp)
@click.command(
cls=I18nCommand,
help=_("""\
Display a mailing list's members.
Filtering along various criteria can be done when displaying.
With no options given, displaying mailing list members
to stdout is the default mode.
"""))
@click.option(
'--add', '-a', 'add_infp', metavar='FILENAME',
type=click.File(encoding='utf-8'),
help=_("""\
[MODE] Add all member addresses in FILENAME. This option is removed.
Use 'mailman addmembers' instead."""))
@click.option(
'--delete', '-x', 'del_infp', metavar='FILENAME',
type=click.File(encoding='utf-8'),
help=_("""\
[MODE] Delete all member addresses found in FILENAME.
This option is removed. Use 'mailman delmembers' instead."""))
@click.option(
'--sync', '-s', 'sync_infp', metavar='FILENAME',
type=click.File(encoding='utf-8'),
help=_("""\
[MODE] Synchronize all member addresses of the specified mailing list
with the member addresses found in FILENAME.
This option is removed. Use 'mailman syncmembers' instead."""))
@click.option(
'--output', '-o', 'outfp', metavar='FILENAME',
type=click.File(mode='w', encoding='utf-8', atomic=True),
help=_("""\
Display output to FILENAME instead of stdout. FILENAME
can be '-' to indicate standard output."""))
@click.option(
'--role', '-R',
type=click.Choice(('any', 'owner', 'moderator', 'nonmember', 'member',
'administrator')),
help=_("""\
Display only members with a given ROLE.
The role may be 'any', 'member', 'nonmember', 'owner', 'moderator',
or 'administrator' (i.e. owners and moderators).
If not given, then 'member' role is assumed."""))
@click.option(
'--regular', '-r',
is_flag=True, default=False,
help=_("""\
Display only regular delivery members."""))
@click.option(
'--email-only', '-e', 'email_only',
is_flag=True, default=False,
help=("""\
Display member addresses only, without the display name.
"""))
@click.option(
'--count-only', '-c', 'count_only',
is_flag=True, default=False,
help=("""\
Display members count only.
"""))
@click.option(
'--no-change', '-N', 'no_change',
is_flag=True, default=False,
help=_("""\
This option has no effect. It exists for backwards compatibility only."""))
@click.option(
'--digest', '-d', metavar='kind',
# baw 2010-01-23 summary digests are not really supported yet.
type=click.Choice(('any', 'plaintext', 'mime')),
help=_("""\
Display only digest members of kind.
'any' means any digest type, 'plaintext' means only plain text (rfc 1153)
type digests, 'mime' means MIME type digests."""))
@click.option(
'--nomail', '-n', metavar='WHY',
type=click.Choice(('enabled', 'any', 'unknown',
'byadmin', 'byuser', 'bybounces')),
help=_("""\
Display only members with a given delivery status.
'enabled' means all members whose delivery is enabled, 'any' means
members whose delivery is disabled for any reason, 'byuser' means
that the member disabled their own delivery, 'bybounces' means that
delivery was disabled by the automated bounce processor,
'byadmin' means delivery was disabled by the list
administrator or moderator, and 'unknown' means that delivery was disabled
for unknown (legacy) reasons."""))
@click.argument('listspec')
@click.pass_context
def members(ctx, add_infp, del_infp, sync_infp, outfp,
role, regular, no_change, digest, nomail, listspec,
email_only, count_only):
mlist = getUtility(IListManager).get(listspec)
if mlist is None:
ctx.fail(_('No such list: ${listspec}'))
if add_infp is not None:
ctx.fail('The --add option is removed. '
'Use `mailman addmembers` instead.')
elif del_infp is not None:
ctx.fail('The --delete option is removed. '
'Use `mailman delmembers` instead.')
elif sync_infp is not None:
ctx.fail('The --sync option is removed. '
'Use `mailman syncmembers` instead.')
elif role == 'any' and (regular or digest or nomail):
ctx.fail('The --regular, --digest and --nomail options are '
'incompatible with role=any.')
elif email_only and count_only:
ctx.fail('The --email_only and --count_only options are '
'mutually exclusive.')
else:
display_members(ctx, mlist, role, regular,
digest, nomail, outfp, email_only, count_only)
@public
@implementer(ICLISubCommand)
class Members:
name = 'members'
command = members
././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1703670049.522772
mailman-3.3.10/src/mailman/commands/cli_notify.py 0000644 0000000 0000000 00000013475 14542770442 016704 0 ustar 00 # Copyright (C) 2015-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""The `admin_notify` subcommand."""
import sys
import click
from email.header import decode_header, make_header
from mailman.core.i18n import _
from mailman.email.message import OwnerNotification
from mailman.interfaces.command import ICLISubCommand
from mailman.interfaces.listmanager import IListManager
from mailman.interfaces.pending import IPendings
from mailman.interfaces.requests import IListRequests, RequestType
from mailman.interfaces.template import ITemplateLoader
from mailman.utilities.options import I18nCommand
from mailman.utilities.string import expand, wrap
from public import public
from zope.component import getUtility
from zope.interface import implementer
@click.command(
cls=I18nCommand,
help=_('Notify list owners/moderators of pending requests.'))
@click.option(
'--list', '-l', 'list_ids', metavar='list',
multiple=True, help=_("""\
Operate on this mailing list. Multiple --list options can be given. The
argument can either be a List-ID or a fully qualified list name. Without
this option, operate on the requests for all mailing lists."""))
@click.option(
'--dry-run', '-n',
is_flag=True, default=False,
help=_("""\
Don't actually do anything, but in conjunction with --verbose, show what
would happen."""))
@click.option(
'--verbose', '-v',
is_flag=True, default=False,
help=_('Print some additional status.'))
@click.pass_context
def notify(ctx, list_ids, dry_run, verbose):
list_manager = getUtility(IListManager)
if list_ids:
lists = []
for spec in list_ids:
# We'll accept list-ids or fqdn list names.
if '@' in spec:
mlist = list_manager.get(spec)
else:
mlist = list_manager.get_by_list_id(spec)
if mlist is None:
print(_('No such list found: ${spec}'), file=sys.stderr)
else:
lists.append(mlist)
else:
lists = list_manager.mailing_lists
for mlist in lists:
requestdb = IListRequests(mlist)
count = requestdb.count
subs, unsubs = _get_subs(mlist)
count += len(subs) + len(unsubs)
if verbose:
print(_('The {} list has {} moderation requests waiting.').format(
mlist.fqdn_listname, count))
if count > 0 and not dry_run:
detail = _build_detail(requestdb, subs, unsubs)
_send_notice(mlist, count, detail)
def _get_subs(mlist):
"""Gets the pending subscriptions and unsubscriptions waiting moderator
approval for a list.
Returns a 2-tuple of lists of email addresses pending subscription and
unsubscription.
"""
pendingsdb = getUtility(IPendings)
subs = []
unsubs = []
for token, data in pendingsdb.find(mlist):
if data.get('token_owner') == 'moderator':
if data['type'] == 'subscription':
subs.append(data['email'])
elif data['type'] == 'unsubscription':
unsubs.append(data['email'])
return (subs, unsubs)
def _build_detail(requestdb, subs, unsubs):
"""Builds the detail of held messages and pending subscriptions and
unsubscriptions for the body of the notification email.
"""
detail = ''
if len(subs) > 0:
detail += _('\nHeld Subscriptions:\n')
for sub in subs:
detail += ' ' + _('User: {}\n').format(sub)
if len(unsubs) > 0:
detail += _('\nHeld Unsubscriptions:\n')
for unsub in unsubs:
detail += ' ' + _('User: {}\n').format(unsub)
if requestdb.count_of(RequestType.held_message) > 0:
detail += _('\nHeld Messages:\n')
for rq in requestdb.of_type(RequestType.held_message):
if requestdb.get_request(rq.id):
key, data = requestdb.get_request(rq.id)
sender = data['_mod_sender']
subject = data['_mod_subject']
reason = data['_mod_reason']
detail += ' ' + _('Sender: {}\n').format(sender)
try:
detail += ' ' + _('Subject: {}\n').format(
str(make_header(decode_header(subject))))
except UnicodeDecodeError:
detail += ' ' + _('Subject: {}\n').format(subject)
detail += ' ' + _('Reason: {}\n\n').format(reason)
else:
detail += ' ' + _( # pragma: nocover
'Missing data for request {}\n\n').format(rq.id)
return detail
def _send_notice(mlist, count, detail):
"""Creates and sends the notice to the list administrators."""
subject = _('The {} list has {} moderation requests waiting.').format(
mlist.fqdn_listname, count)
template = getUtility(ITemplateLoader).get(
'list:admin:notice:pending', mlist)
text = wrap(expand(template, mlist, dict(
count=count,
data=detail,
)))
msg = OwnerNotification(mlist, subject, text, mlist.administrators)
msg.send(mlist)
@public
@implementer(ICLISubCommand)
class Notify:
name = 'notify'
command = notify
././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1703670049.522937
mailman-3.3.10/src/mailman/commands/cli_qfile.py 0000644 0000000 0000000 00000005564 14542770442 016474 0 ustar 00 # Copyright (C) 2009-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Getting information out of a qfile."""
import click
import pickle
from mailman.core.i18n import _
from mailman.interfaces.command import ICLISubCommand
from mailman.utilities.interact import interact
from mailman.utilities.options import I18nCommand
from pprint import PrettyPrinter
from public import public
from zope.interface import implementer
# This is deliberately called 'm' for use with --interactive.
m = None
@click.command(
cls=I18nCommand,
help=_('Get information out of a queue file.'))
@click.option(
'--print/--no-print', '-p/-n', 'doprint',
default=True,
help=_("""\
Don't attempt to pretty print the object. This is useful if there is some
problem with the object and you just want to get an unpickled
representation. Useful with 'mailman qfile -i '. In that case, the
list of unpickled objects will be left in a variable called 'm'."""))
@click.option(
'--interactive', '-i',
is_flag=True, default=False,
help=_("""\
Start an interactive Python session, with a variable called 'm'
containing the list of unpickled objects."""))
@click.argument('qfile')
def qfile(doprint, interactive, qfile):
global m
# Reinitialize 'm' every time this command is run. This isn't normally
# needed for command line use, but is important for the test suite.
m = []
printer = PrettyPrinter(indent=4)
with open(qfile, 'rb') as fp:
while True:
try:
m.append(pickle.load(fp))
except EOFError:
break
if doprint:
print(_('[----- start pickle -----]'))
for i, obj in enumerate(m):
count = i + 1
print(_('<----- start object ${count} ----->'))
if isinstance(obj, (bytes, str)):
print(obj)
else:
printer.pprint(obj)
print(_('[----- end pickle -----]'))
count = len(m) # noqa: F841
banner = _("Number of objects found (see the variable 'm'): ${count}")
if interactive:
interact(banner=banner)
@public
@implementer(ICLISubCommand)
class QFile:
name = 'qfile'
command = qfile
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5231676
mailman-3.3.10/src/mailman/commands/cli_status.py 0000644 0000000 0000000 00000004106 14542770442 016706 0 ustar 00 # Copyright (C) 2010-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""The `mailman status` subcommand."""
import sys
import click
import socket
from mailman.bin.master import master_state, WatcherState
from mailman.core.i18n import _
from mailman.interfaces.command import ICLISubCommand
from mailman.utilities.options import I18nCommand
from public import public
from zope.interface import implementer
@click.command(
cls=I18nCommand,
help=_('Show the current running status of the Mailman system.'))
def status():
status, lock = master_state()
if status is WatcherState.none:
message = _('GNU Mailman is not running')
elif status is WatcherState.conflict:
hostname, pid, tempfile = lock.details
message = _('GNU Mailman is running (master pid: ${pid})')
elif status is WatcherState.stale_lock:
hostname, pid, tempfile = lock.details
message = _('GNU Mailman is stopped (stale pid: ${pid})')
else:
hostname, pid, tempfile = lock.details
fqdn_name = socket.getfqdn() # noqa: F841
assert status is WatcherState.host_mismatch, (
'Invalid enum value: %s' % status)
message = _('GNU Mailman is in an unexpected state '
'(${hostname} != ${fqdn_name})')
print(message)
sys.exit(status.value)
@public
@implementer(ICLISubCommand)
class Status:
name = 'status'
command = status
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5233386
mailman-3.3.10/src/mailman/commands/cli_syncmembers.py 0000644 0000000 0000000 00000021117 14542770442 017713 0 ustar 00 # Copyright (C) 2009-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""The 'syncmembers' subcommand."""
import sys
import click
from email.utils import formataddr, parseaddr
from mailman.app.membership import delete_member
from mailman.core.i18n import _
from mailman.database.transaction import transactional
from mailman.interfaces.address import IEmailValidator
from mailman.interfaces.command import ICLISubCommand
from mailman.interfaces.listmanager import IListManager
from mailman.interfaces.member import (
DeliveryMode,
DeliveryStatus,
MembershipIsBannedError,
)
from mailman.interfaces.subscriptions import (
ISubscriptionManager,
SubscriptionPendingError,
)
from mailman.interfaces.usermanager import IUserManager
from mailman.utilities.options import I18nCommand
from public import public
from zope.component import getUtility
from zope.interface import implementer
def get_addr(display_name, email):
"""Return an existing address record if available, otherwise make one."""
global user_manager
addr = user_manager.get_address(email)
if addr is not None:
# We have an address with this email. Return that.
return addr
# Unknown email. Create an address for this.
# XXX Should we be making a user instead?`
return user_manager.create_address(email, display_name)
@transactional
def add_members(mlist, member, delivery, welcome_msg):
"""Add members to a mailing list."""
global registrar
display_name, email = parseaddr(member)
subscriber = get_addr(display_name, email)
# For error messages.
email = formataddr((display_name, email))
delivery_status = DeliveryStatus.enabled
if delivery is None or delivery == 'regular' or delivery == 'disabled':
delivery_mode = DeliveryMode.regular
if delivery == 'disabled':
delivery_status = DeliveryStatus.by_moderator
elif delivery == 'mime':
delivery_mode = DeliveryMode.mime_digests
elif delivery == 'plain':
delivery_mode = DeliveryMode.plaintext_digests
elif delivery == 'summary':
delivery_mode = DeliveryMode.summary_digests
try:
member = registrar.register(
subscriber,
pre_verified=True,
pre_approved=True,
pre_confirmed=True,
send_welcome_message=welcome_msg)[2]
member.preferences.delivery_status = delivery_status
member.preferences.delivery_mode = delivery_mode
except MembershipIsBannedError:
print(_('Membership is banned (skipping): ${email}'),
file=sys.stderr)
except SubscriptionPendingError:
print(_('Subscription already pending (skipping): ${email}'),
file=sys.stderr)
@transactional
def sync_members(mlist, in_fp, delivery, welcome_msg, goodbye_msg,
admin_notify, no_change):
"""Add and delete mailing list members to match an input file."""
global email_validator
subscribers = mlist.members
addresses = list(subscribers.addresses)
# Variable that shows if something was done to the original mailing list
ml_changed = False
# A list (set) of the members currently subscribed.
members_of_list = set([address.email.lower()
for address in addresses])
# A list (set) of all valid email addresses in a file.
file_emails = set()
# A list (dict) of (display name + address) for a members address.
formatted_addresses = {}
for line in in_fp:
# Don't include newlines or whitespaces at the start or end
line = line.strip()
# Ignore blank lines and lines that start with a '#'.
if line.startswith('#') or len(line) == 0:
continue
# Parse the line to a tuple.
parsed_addr = parseaddr(line)
# parseaddr can return invalid emails. E.g. parseaddr('foobar@')
# returns ('', 'foobar@') in python 3.6.7 and 3.7.1 so check validity.
if not email_validator.is_valid(parsed_addr[1]):
print(_('Cannot parse as valid email address (skipping): ${line}'),
file=sys.stderr)
continue
new_display_name, new_email = parsed_addr
# Address to lowercase
lc_email = new_email.lower()
# Format output with display name if available
formatted_addr = formataddr((new_display_name, new_email))
# Add the 'outputable' version to a dict
formatted_addresses[lc_email] = formatted_addr
file_emails.add(lc_email)
addresses_to_add = file_emails - members_of_list
addresses_to_delete = members_of_list - file_emails
for email in sorted(addresses_to_add):
# Add to mailing list if not dryrun.
print(_("[ADD] %s") % formatted_addresses[email])
if not no_change:
add_members(mlist, formatted_addresses[email], delivery,
welcome_msg)
# Indicate that we done something to the mailing list.
ml_changed = True
continue
for email in sorted(addresses_to_delete):
# Delete from mailing list if not dryrun.
member = str(subscribers.get_member(email).address)
print(_("[DEL] %s") % member)
if not no_change:
delete_member(mlist, email, admin_notif=admin_notify,
userack=goodbye_msg)
# Indicate that we done something to the mailing list.
ml_changed = True
continue
# We did nothing to the mailing list -> We had nothing to do.
if not ml_changed:
print(_("Nothing to do"))
@click.command(
cls=I18nCommand,
help=_("""\
Add and delete members as necessary to syncronize a list's membership
with an input file. FILENAME is the file containing the new membership,
one member per line. Blank lines and lines that start with a '#' are
ignored. Addresses in FILENAME which are not current list members
will be added to the list with delivery mode as specified with
-d/--delivery. List members whose addresses are not in FILENAME will
be removed from the list. FILENAME can be '-' to indicate standard input.
"""))
@click.option(
'--delivery', '-d',
type=click.Choice(('regular', 'mime', 'plain', 'summary', 'disabled')),
help=_("""\
Set the added members delivery mode to 'regular', 'mime', 'plain',
'summary' or 'disabled'. I.e., one of regular, three modes of digest
or no delivery. If not given, the default is regular."""))
@click.option(
'--welcome-msg/--no-welcome-msg', '-w/-W', 'welcome_msg', default=None,
help=_("""\
Override the list's setting for send_welcome_message to added members."""))
@click.option(
'--goodbye-msg/--no-goodbye-msg', '-g/-G', 'goodbye_msg', default=None,
help=_("""\
Override the list's setting for send_goodbye_message to
deleted members."""))
@click.option(
'--admin-notify/--no-admin-notify', '-a/-A', 'admin_notify', default=None,
help=_("""\
Override the list's setting for admin_notify_mchanges."""))
@click.option(
'--no-change', '-n', 'no_change',
is_flag=True, default=False,
help=_("""\
Don't actually make the changes. Instead, print out what would be
done to the list."""))
@click.argument('in_fp', metavar='FILENAME', type=click.File(encoding='utf-8'))
@click.argument('listspec')
@click.pass_context
def syncmembers(ctx, in_fp, delivery, welcome_msg, goodbye_msg,
admin_notify, no_change, listspec):
"""Add and delete mailing list members to match an input file."""
global email_validator, registrar, user_manager
mlist = getUtility(IListManager).get(listspec)
if mlist is None:
ctx.fail(_('No such list: ${listspec}'))
email_validator = getUtility(IEmailValidator)
registrar = ISubscriptionManager(mlist)
user_manager = getUtility(IUserManager)
sync_members(mlist, in_fp, delivery, welcome_msg, goodbye_msg,
admin_notify, no_change)
@public
@implementer(ICLISubCommand)
class SyncMembers:
name = 'syncmembers'
command = syncmembers
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5235193
mailman-3.3.10/src/mailman/commands/cli_unshunt.py 0000644 0000000 0000000 00000003777 14542770442 017104 0 ustar 00 # Copyright (C) 2009-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""The 'unshunt' command."""
import sys
import click
from mailman.config import config
from mailman.core.i18n import _
from mailman.interfaces.command import ICLISubCommand
from mailman.utilities.options import I18nCommand
from public import public
from zope.interface import implementer
@click.command(
cls=I18nCommand,
help=_('Unshunt messages.'))
@click.option(
'--discard', '-d',
is_flag=True, default=False,
help=_("""\
Discard all shunted messages instead of moving them back to their original
queue."""))
def unshunt(discard):
shunt_queue = config.switchboards['shunt']
shunt_queue.recover_backup_files()
for filebase in shunt_queue.files:
try:
msg, msgdata = shunt_queue.dequeue(filebase)
which_queue = msgdata.get('whichq', 'in')
if not discard:
config.switchboards[which_queue].enqueue(msg, msgdata)
except Exception as error: # noqa: F841
print(_('Cannot unshunt message ${filebase}, skipping:\n${error}'),
file=sys.stderr)
else:
# Unlink the .bak file left by dequeue()
shunt_queue.finish(filebase)
@public
@implementer(ICLISubCommand)
class Unshunt:
name = 'unshunt'
command = unshunt
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5236902
mailman-3.3.10/src/mailman/commands/cli_version.py 0000644 0000000 0000000 00000002346 14542770442 017054 0 ustar 00 # Copyright (C) 2009-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""The Mailman version."""
import click
from mailman.core.i18n import _
from mailman.interfaces.command import ICLISubCommand
from mailman.utilities.options import I18nCommand
from mailman.version import MAILMAN_VERSION_FULL
from public import public
from zope.interface import implementer
@click.command(
cls=I18nCommand,
help=_("Display Mailman's version."))
def version():
print(MAILMAN_VERSION_FULL)
@public
@implementer(ICLISubCommand)
class Version:
name = 'version'
command = version
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5240161
mailman-3.3.10/src/mailman/commands/cli_withlist.py 0000644 0000000 0000000 00000025534 14542770442 017242 0 ustar 00 # Copyright (C) 2009-2023 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""The `mailman shell` subcommand."""
import re
import sys
import click
from contextlib import ExitStack, suppress
from lazr.config import as_boolean
from mailman.config import config
from mailman.core.i18n import _
from mailman.interfaces.command import ICLISubCommand
from mailman.interfaces.listmanager import IListManager
from mailman.utilities.interact import DEFAULT_BANNER, interact
from mailman.utilities.modules import call_name
from mailman.utilities.options import I18nCommand
from public import public
from string import Template
from traceback import print_exc
from zope.component import getUtility
from zope.interface import implementer
# Global holding onto the open mailing list.
m = None
# Global holding the results of --run.
r = None
def start_ipython1(overrides, banner, *, debug=False):
try:
from IPython.frontend.terminal.embed import InteractiveShellEmbed
except ImportError:
if debug:
print_exc()
return None
return InteractiveShellEmbed.instance(banner1=banner, user_ns=overrides)
def start_ipython4(overrides, banner, *, debug=False):
try:
from IPython.terminal.embed import InteractiveShellEmbed
except ImportError:
if debug:
print_exc()
return None
return InteractiveShellEmbed.instance(banner1=banner, user_ns=overrides)
def start_ipython(overrides, banner, debug):
shell = None
for starter in (start_ipython4, start_ipython1):
shell = starter(overrides, banner, debug=debug)
if shell is not None:
shell()
break
else:
print(_('ipython is not available, set use_ipython to no'))
def start_python(overrides, banner):
# Set the tab completion.
with ExitStack() as resources:
try: # pragma: nocover
import readline # noqa: F401,E401
import rlcompleter # noqa: F401
except ImportError: # pragma: nocover
print(_('readline not available'), file=sys.stderr)
pass
else:
readline.parse_and_bind('tab: complete')
history_file_template = config.shell.history_file.strip()
if len(history_file_template) > 0:
# Expand substitutions.
substitutions = {
key.lower(): value
for key, value in config.paths.items()
}
history_file = Template(
history_file_template).safe_substitute(substitutions)
with suppress(FileNotFoundError):
readline.read_history_file(history_file)
resources.callback(
readline.write_history_file,
history_file)
sys.ps1 = config.shell.prompt + ' '
interact(upframe=False, banner=banner, overrides=overrides)
def do_interactive(ctx, banner):
global m, r
overrides = dict(
m=m,
commit=config.db.commit,
abort=config.db.abort,
config=config,
getUtility=getUtility
)
# Bootstrap some useful names into the namespace, mostly to make
# the component architecture and interfaces easily available.
for module_name in sys.modules:
if not module_name.startswith('mailman.interfaces.'):
continue
module = sys.modules[module_name]
for name in module.__all__:
overrides[name] = getattr(module, name)
banner = config.shell.banner + '\n' + (
banner if isinstance(banner, str) else '')
try:
use_ipython = as_boolean(config.shell.use_ipython)
except ValueError:
if config.shell.use_ipython == 'debug':
use_ipython = True
debug = True
else:
print(_('Invalid value for [shell]use_python: {}').format(
config.shell.use_ipython), file=sys.stderr)
return
else:
debug = False
if use_ipython:
start_ipython(overrides, banner, debug)
else:
start_python(overrides, banner)
def show_detailed_help(ctx, param, value):
if not value:
# Returning None tells click to process the rest of the command line.
return
# Split this up into paragraphs for easier translation.
print(_("""\
This script provides you with a general framework for interacting with a
mailing list."""))
print()
print(_("""\
There are two ways to use this script: interactively or programmatically.
Using it interactively allows you to play with, examine and modify a mailing
list from Python's interactive interpreter. When running interactively, the
variable 'm' will be available in the global namespace. It will reference the
mailing list object."""))
print()
print(_("""\
Programmatically, you can write a function to operate on a mailing list, and
this script will take care of the housekeeping (see below for examples). In
that case, the general usage syntax is:
% mailman withlist [options] -l listspec [args ...]
where `listspec` is either the posting address of the mailing list
(e.g. ant@example.com), or the List-ID (e.g. ant.example.com)."""))
print()
print(_("""\
Here's an example of how to use the --run option. Say you have a file in the
Mailman installation directory called 'listaddr.py', with the following two
functions:
def listaddr(mlist):
print(mlist.posting_address)
def requestaddr(mlist):
print(mlist.request_address)
Run methods take at least one argument, the mailing list object to operate
on. Any additional arguments given on the command line are passed as
positional arguments to the callable.
If -l is not given then you can run a function that takes no arguments.
"""))
print()
print(_("""\
You can print the list's posting address by running the following from the
command line:
% mailman withlist -r listaddr -l ant@example.com
Importing listaddr ...
Running listaddr.listaddr() ...
ant@example.com"""))
print()
print(_("""\
And you can print the list's request address by running:
% mailman withlist -r listaddr.requestaddr -l ant@example.com
Importing listaddr ...
Running listaddr.requestaddr() ...
ant-request@example.com"""))
print()
print(_("""\
As another example, say you wanted to change the display name for a particular
mailing list. You could put the following function in a file called
`change.py`:
def change(mlist, display_name):
mlist.display_name = display_name
and run this from the command line:
% mailman withlist -r change -l ant@example.com 'My List'
Note that you do not have to explicitly commit any database transactions, as
Mailman will do this for you (assuming no errors occured)."""))
sys.exit(0)
@click.command(
cls=I18nCommand,
help=_("""\
Operate on a mailing list.
For detailed help, see --details
"""))
@click.option(
'--interactive', '-i',
is_flag=True, default=None,
help=_("""\
Leaves you at an interactive prompt after all other processing is complete.
This is the default unless the --run option is given."""))
@click.option(
'--run', '-r',
help=_("""\
Run a script. The argument is the module path to a callable. This
callable will be imported and then, if --listspec/-l is also given, is
called with the mailing list as the first argument. If additional
arguments are given at the end of the command line, they are passed as
subsequent positional arguments to the callable. For additional help, see
--details.
If no --listspec/-l argument is given, the script function being called is
called with no arguments.
"""))
@click.option(
'--details',
is_flag=True, default=False, is_eager=True, expose_value=False,
callback=show_detailed_help,
help=_('Print detailed instructions and exit.'))
# Optional positional argument.
@click.option(
'--listspec', '-l',
help=_("""\
A specification of the mailing list to operate on. This may be the posting
address of the list, or its List-ID. The argument can also be a Python
regular expression, in which case it is matched against both the posting
address and List-ID of all mailing lists. To use a regular expression,
LISTSPEC must start with a ^ (and the matching is done with re.match().
LISTSPEC cannot be a regular expression unless --run is given."""))
@click.argument('run_args', nargs=-1)
@click.pass_context
def shell(ctx, interactive, run, listspec, run_args):
global m, r
banner = DEFAULT_BANNER
# Interactive is the default unless --run was given.
interactive = (run is None) if interactive is None else interactive
# List name cannot be a regular expression if --run is not given.
if listspec and listspec.startswith('^') and not run:
ctx.fail(_('Regular expression requires --run'))
# Handle --run.
list_manager = getUtility(IListManager)
if run:
# When the module and the callable have the same name, a shorthand
# without the dot is allowed.
dotted_name = (run if '.' in run else '{0}.{0}'.format(run))
if listspec is None:
r = call_name(dotted_name, *run_args)
elif listspec.startswith('^'):
r = {}
cre = re.compile(listspec, re.IGNORECASE)
for mlist in list_manager.mailing_lists:
if cre.match(mlist.fqdn_listname) or cre.match(mlist.list_id):
results = call_name(dotted_name, mlist, *run_args)
r[mlist.list_id] = results
else:
m = list_manager.get(listspec)
if m is None:
ctx.fail(_('No such list: ${listspec}'))
r = call_name(dotted_name, m, *run_args)
else:
# Not --run.
if listspec is not None:
m = list_manager.get(listspec)
if m is None:
ctx.fail(_('No such list: ${listspec}'))
banner = _("The variable 'm' is the ${listspec} mailing list")
# All other processing is finished; maybe go into interactive mode.
if interactive:
do_interactive(ctx, banner)
@public
@implementer(ICLISubCommand)
class Withlist:
name = 'withlist'
command = shell
@public
class Shell(Withlist):
name = 'shell'
command = shell
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1672813222.8949492
mailman-3.3.10/src/mailman/commands/docs/__init__.py 0000644 0000000 0000000 00000000000 14355215247 017207 0 ustar 00 ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1672838243.9176955
mailman-3.3.10/src/mailman/commands/docs/addmembers.rst 0000644 0000000 0000000 00000011053 14355276144 017750 0 ustar 00 ==============
Adding members
==============
The ``mailman addmembers`` command allows a site administrator to add members
to a mailing list.
>>> from mailman.testing.documentation import cli
>>> command = cli('mailman.commands.cli_addmembers.addmembers')
Usage
-----
Here is the complete usage for the command.
::
>>> command('mailman addmembers --help')
Usage: addmembers [OPTIONS] FILENAME LISTSPEC
Add all member addresses in FILENAME with delivery mode as specified with
-d/--delivery. FILENAME can be '-' to indicate standard input. Blank lines
and lines that start with a '#' are ignored.
Options:
-d, --delivery [regular|mime|plain|summary|disabled]
Set the added members delivery mode to
'regular', 'mime', 'plain', 'summary' or
'disabled'. I.e., one of regular, three modes
of digest or no delivery. If not given, the
default is regular. Ignored for invited
members.
-i, --invite Send the added members an invitation rather
than immediately adding them.
-w, --welcome-msg / -W, --no-welcome-msg
Override the list's setting for
send_welcome_message.
--help Show this message and exit.
Examples
--------
You can add members to a mailing list from the command line. To do so, you
need a file containing email addresses and optional display names that can be
parsed by ``email.utils.parseaddr()``.
::
>>> from tempfile import NamedTemporaryFile
>>> filename = cleanups.enter_context(NamedTemporaryFile()).name
>>> from mailman.app.lifecycle import create_list
>>> bee = create_list('bee@example.com')
>>> with open(filename, 'w', encoding='utf-8') as fp:
... print("""\
... aperson@example.com
... Bart Person
... cperson@example.com (Cate Person)
... """, file=fp)
>>> command('mailman addmembers ' + filename + ' bee.example.com')
>>> from mailman.testing.documentation import dump_list
>>> from operator import attrgetter
>>> dump_list(bee.members.addresses, key=attrgetter('email'))
aperson@example.com
Bart Person
Cate Person
You can also specify ``-`` as the filename, in which case the addresses are
taken from standard input.
::
>>> stdin = """\
... dperson@example.com
... Elly Person
... fperson@example.com (Fred Person)
... """
>>> command('mailman addmembers - bee.example.com', input=stdin)
>>> dump_list(bee.members.addresses, key=attrgetter('email'))
aperson@example.com
Bart Person
Cate Person
dperson@example.com
Elly Person
Fred Person
Blank lines and lines that begin with '#' are ignored.
::
>>> with open(filename, 'w', encoding='utf-8') as fp:
... print("""\
... gperson@example.com
... # hperson@example.com
...
... iperson@example.com
... """, file=fp)
>>> command('mailman addmembers ' + filename + ' bee.example.com')
>>> dump_list(bee.members.addresses, key=attrgetter('email'))
aperson@example.com
Bart Person
Cate Person
dperson@example.com
Elly Person
Fred Person
gperson@example.com
iperson@example.com
Addresses which are already subscribed are ignored, although a warning is
printed.
::
>>> with open(filename, 'w', encoding='utf-8') as fp:
... print("""\
... gperson@example.com
... aperson@example.com
... jperson@example.com
... """, file=fp)
>>> command('mailman addmembers ' + filename + ' bee.example.com')
Already subscribed (skipping): gperson@example.com
Already subscribed (skipping): aperson@example.com
>>> dump_list(bee.members.addresses, key=attrgetter('email'))
aperson@example.com
Bart Person
Cate Person
dperson@example.com
Elly Person
Fred Person
gperson@example.com
iperson@example.com
jperson@example.com
././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1726290746.680862
mailman-3.3.10/src/mailman/commands/docs/admins.rst 0000644 0000000 0000000 00000006274 14671215473 017130 0 ustar 00 ==============================
Managing Owners and Moderators
==============================
The ``mailman admins`` command allows a site administrator to add and/or
delete owners and moderators of a mailing list.
>>> from mailman.testing.documentation import cli
>>> command = cli('mailman.commands.cli_admins.admins')
Usage
-----
Here is the complete usage for the command.
::
>>> command('mailman admins --help')
Usage: admins [OPTIONS] LISTSPEC
Add and/or delete owners and moderators of a list.
Options:
-a, --add TEXT User to add with the given role. This may be an
email address or, if quoted, any display name
and email address parseable by
email.utils.parseaddr. E.g., 'Jane Doe
'. May be repeated to add
multiple users.
-d, --delete TEXT Email address of the user to be removed from the
given role. May be repeated to delete multiple
users.
-r, --role [owner|moderator] The role to add/delete. This may be 'owner' or
'moderator'. If not given, then 'owner' role is
assumed.
--help Show this message and exit.
Examples
--------
You can add owners or moderators, optionally with a display name, to a mailing
list from the command line.
::
>>> from mailman.app.lifecycle import create_list
>>> bee = create_list('bee@example.com')
>>> command('mailman admins --add "Anne " '
... 'bee.example.com')
>>> from mailman.testing.documentation import dump_list
>>> from operator import attrgetter
>>> dump_list(bee.owners.addresses, key=attrgetter('email'))
Anne
>>> command('mailman admins --add bperson@example.com '
... '--role moderator bee.example.com')
>>> dump_list(bee.moderators.addresses, key=attrgetter('email'))
bperson@example.com
You can delete owners or moderators from a mailing list from the command line.
::
>>> command('mailman admins --delete aperson@example.com bee.example.com')
>>> dump_list(bee.owners.addresses, key=attrgetter('email'))
*Empty*
You can add and delete in one command.
::
>>> command('mailman admins --delete bperson@example.com '
... '--add cperson@example.com --role moderator bee.example.com')
>>> dump_list(bee.moderators.addresses, key=attrgetter('email'))
cperson@example.com
Adding addesses which already have that role just results in a warning being
printed.
::
>>> command('mailman admins --add cperson@example.com '
... '--role moderator bee.example.com')
cperson@example.com is already a moderator of bee@example.com
Likewise, removing an address which doesn't have that role just results in a
warning being printed.
::
>>> command('mailman admins --delete aperson@example.com bee.example.com')
aperson@example.com is not a owner of bee@example.com
././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1672838243.917815
mailman-3.3.10/src/mailman/commands/docs/aliases.rst 0000644 0000000 0000000 00000005444 14355276144 017275 0 ustar 00 ==================
Generating aliases
==================
For some mail servers, Mailman must generate data files that are used to hook
Mailman up to the mail server. The details of this differ for each mail
server. Generally these files are automatically kept up-to-date when mailing
lists are created or removed, but you might occasionally need to manually
regenerate the file. The ``mailman aliases`` command does this.
>>> from mailman.testing.documentation import cli
>>> command = cli('mailman.commands.cli_aliases.aliases')
For example, connecting Mailman to Postfix is generally done through the LMTP
protocol. Mailman starts an LMTP server and Postfix delivers messages to
Mailman as an LMTP client. By default this is done through Postfix transport
maps.
Selecting Postfix as the source of incoming messages enables transport map
generation.
>>> from mailman.config import config
>>> config.push('postfix', """
... [mta]
... incoming: mailman.mta.postfix.LMTP
... lmtp_host: lmtp.example.com
... lmtp_port: 24
... """)
..
Clean up.
>>> ignore = cleanups.callback(config.pop, 'postfix')
Let's create a mailing list and then display the transport map for it. We'll
write the appropriate files to a temporary directory.
::
>>> from mailman.app.lifecycle import create_list
>>> mlist = create_list('ant@example.com')
>>> import os, shutil, tempfile
>>> output_directory = tempfile.mkdtemp()
>>> ignore = cleanups.callback(shutil.rmtree, output_directory)
>>> command('mailman aliases --directory ' + output_directory)
For Postfix, there are two files in the output directory.
>>> files = sorted(os.listdir(output_directory))
>>> for file in files:
... print(file)
postfix_domains
postfix_lmtp
The transport map file contains all the aliases for the mailing list.
>>> with open(os.path.join(output_directory, 'postfix_lmtp')) as fp:
... print(fp.read())
# AUTOMATICALLY GENERATED BY MAILMAN ON ...
...
ant@example.com lmtp:[lmtp.example.com]:24
ant-bounces@example.com lmtp:[lmtp.example.com]:24
ant-confirm@example.com lmtp:[lmtp.example.com]:24
ant-join@example.com lmtp:[lmtp.example.com]:24
ant-leave@example.com lmtp:[lmtp.example.com]:24
ant-owner@example.com lmtp:[lmtp.example.com]:24
ant-request@example.com lmtp:[lmtp.example.com]:24
ant-subscribe@example.com lmtp:[lmtp.example.com]:24
ant-unsubscribe@example.com lmtp:[lmtp.example.com]:24
The relay domains file contains a list of all the domains.
>>> with open(os.path.join(output_directory, 'postfix_domains')) as fp:
... print(fp.read())
# AUTOMATICALLY GENERATED BY MAILMAN ON ...
...
example.com example.com
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1672838243.9178817
mailman-3.3.10/src/mailman/commands/docs/changeaddress.rst 0000644 0000000 0000000 00000003215 14355276144 020441 0 ustar 00 ==================
Changing addresses
==================
The ``mailman changeaddress`` command is used to change an email address for
a user.
>>> from mailman.testing.documentation import cli
>>> command = cli('mailman.commands.cli_changeaddress.changeaddress')
Usage
-----
Here is the complete usage for the command.
::
>>> command('mailman changeaddress --help')
Usage: changeaddress [OPTIONS] OLD_ADDRESS NEW_ADDRESS
Change a user's email address from old_address to possibly case-preserved
new_address.
Options:
--help Show this message and exit.
Examples
--------
First we create an address.
::
>>> from mailman.interfaces.usermanager import IUserManager
>>> from zope.component import getUtility
>>> user_manager = getUtility(IUserManager)
>>> user_manager.create_address('anne@example.com', 'Anne Person')
[not verified] at ...
Now we can change the email address.
::
>>> command('mailman changeaddress anne@example.com anne@example.net')
Address changed from anne@example.com to anne@example.net.
>>> print(user_manager.get_address('anne@example.com'))
None
>>> user_manager.get_address('anne@example.net')
[not verified] at ...
We can also change only the case of an address
::
>>> command('mailman changeaddress anne@example.net Anne@example.net')
Address changed from anne@example.net to Anne@example.net.
>>> user_manager.get_address('anne@example.net')
[not verified] key: anne@example.net at ...
././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1726290746.681074
mailman-3.3.10/src/mailman/commands/docs/commands.rst 0000644 0000000 0000000 00000001043 14671215473 017443 0 ustar 00 ========
Commands
========
All of the following docs begin with a Python snippet that imports the ``cli``
function from ``mailman.testing.documentation`` and sets ``command`` to an
invocation of that function. Then they invoke example commands by calling
``command`` with an argument of the command line.
This is done to facilitate the testing framework for doc tests which actually
runs all the Python snippets in the docs to verify they work as expected. In
practice, one just runs the command line directly.
.. toctree::
:glob:
./*
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1703670049.5242774
mailman-3.3.10/src/mailman/commands/docs/conf.rst 0000644 0000000 0000000 00000005571 14542770442 016600 0 ustar 00 ============================
Display configuration values
============================
Just like the `Postfix command postconf(1)`_, the ``mailman conf`` command
lets you dump one or more Mailman configuration variables to standard output
or a file.
Mailman's configuration is divided in multiple sections which contain multiple
key-value pairs. The ``mailman conf`` command allows you to display a
specific key-value pair, or several key-value pairs.
>>> from mailman.testing.documentation import cli
>>> command = cli('mailman.commands.cli_conf.conf')
To get a list of all key-value pairs of any section, you need to call the
command without any options.
>>> command('mailman conf')
[ARC] authserv_id:
...
[logging.bounce] level: info
...
[mailman] site_owner: noreply@example.com
...
You can list all the key-value pairs of a specific section.
>>> command('mailman conf --section shell')
[shell] banner: Welcome to the GNU Mailman shell
Use commit() to commit changes.
Use abort() to discard changes since the last commit.
Exit with ctrl+D does an implicit commit() but exit() does not.
[shell] history_file:
[shell] prompt: >>>
[shell] use_ipython: no
You can also pass a key and display all key-value pairs matching the given
key, along with the names of the corresponding sections.
>>> command('mailman conf --key path')
[logging.archiver] path: mailman.log
[logging.bounce] path: bounce.log
[logging.config] path: mailman.log
[logging.database] path: mailman.log
[logging.debug] path: debug.log
[logging.error] path: mailman.log
[logging.fromusenet] path: mailman.log
[logging.gunicorn] path: mailman.log
[logging.http] path: mailman.log
[logging.locks] path: mailman.log
[logging.mischief] path: mailman.log
[logging.plugins] path: plugins.log
[logging.root] path: mailman.log
[logging.runner] path: mailman.log
[logging.smtp] path: smtp.log
[logging.subscribe] path: mailman.log
[logging.task] path: mailman.log
[logging.vette] path: mailman.log
[runner.archive] path: $QUEUE_DIR/$name
[runner.bad] path: $QUEUE_DIR/$name
[runner.bounces] path: $QUEUE_DIR/$name
[runner.command] path: $QUEUE_DIR/$name
[runner.digest] path: $QUEUE_DIR/$name
[runner.in] path: $QUEUE_DIR/$name
[runner.lmtp] path:
[runner.nntp] path: $QUEUE_DIR/$name
[runner.out] path: $QUEUE_DIR/$name
[runner.pipeline] path: $QUEUE_DIR/$name
[runner.rest] path:
[runner.retry] path: $QUEUE_DIR/$name
[runner.shunt] path: $QUEUE_DIR/$name
[runner.task] path:
[runner.virgin] path: $QUEUE_DIR/$name
If you specify both a section and a key, you will get the corresponding value.
>>> command('mailman conf --section mailman --key site_owner')
noreply@example.com
.. _`Postfix command postconf(1)`: http://www.postfix.org/postconf.1.html
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1672838243.9181983
mailman-3.3.10/src/mailman/commands/docs/control.rst 0000644 0000000 0000000 00000002742 14355276144 017332 0 ustar 00 =============================
Starting and stopping Mailman
=============================
The Mailman daemon processes can be started and stopped from the command
line.
Set up
======
All we care about is the master process; normally it starts a bunch of
runners, but we don't care about any of them, so write a test configuration
file for the master that disables all the runners.
>>> from mailman.commands.tests.test_cli_control import make_config
>>> make_config(cleanups)
Starting
========
>>> from mailman.testing.documentation import cli
>>> command = cli('mailman.commands.cli_control.start')
Starting the daemons prints a useful message and starts the master watcher
process in the background.
>>> command('mailman start')
Starting Mailman's master runner
Generating MTA alias maps
>>> from mailman.commands.tests.test_cli_control import find_master
The process exists, and its pid is available in a run time file.
>>> pid = find_master()
>>> pid is not None
True
Stopping
========
You can also stop the master watcher process from the command line, which
stops all the child processes too.
::
>>> command = cli('mailman.commands.cli_control.stop')
>>> command('mailman stop')
Shutting down Mailman's master runner
..
# Clean up.
>>> from mailman.commands.tests.test_cli_control import (
... kill_with_extreme_prejudice, clean_stale_locks)
>>> kill_with_extreme_prejudice(pid)
>>> clean_stale_locks()
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1726290746.6812243
mailman-3.3.10/src/mailman/commands/docs/create.rst 0000644 0000000 0000000 00000011403 14671215473 017106 0 ustar 00 ==========================
Command line list creation
==========================
A system administrator can create mailing lists by the command line.
>>> from mailman.testing.documentation import cli
>>> command = cli('mailman.commands.cli_lists.create')
You can prevent creation of a mailing list in an unknown domain.
>>> command('mailman create --no-domain ant@example.xx')
Usage: create [OPTIONS] LISTNAME
Try 'create --help' for help.
Error: Undefined domain: example.xx
By default, Mailman will create the domain if it doesn't exist.
>>> command('mailman create ant@example.xx')
Created mailing list: ant@example.xx
Now both the domain and the mailing list exist in the database.
::
>>> from mailman.interfaces.listmanager import IListManager
>>> from zope.component import getUtility
>>> list_manager = getUtility(IListManager)
>>> list_manager.get('ant@example.xx')
>>> from mailman.interfaces.domain import IDomainManager
>>> getUtility(IDomainManager).get('example.xx')
The command can also operate quietly.
::
>>> command('mailman create --quiet bee@example.com')
>>> mlist = list_manager.get('bee@example.com')
>>> mlist
Setting the owner
=================
By default, no list owners are specified.
>>> from mailman.testing.documentation import dump_list
>>> dump_list(mlist.owners.addresses)
*Empty*
But you can specify an owner address on the command line when you create the
mailing list.
::
>>> command('mailman create --owner anne@example.com cat@example.com')
Created mailing list: cat@example.com
>>> mlist = list_manager.get('cat@example.com')
>>> dump_list(repr(address) for address in mlist.owners.addresses)
You can even specify more than one address for the owners.
::
>>> command('mailman create '
... '--owner anne@example.com '
... '--owner bart@example.com '
... '--owner cate@example.com '
... 'dog@example.com')
Created mailing list: dog@example.com
>>> mlist = list_manager.get('dog@example.com')
>>> from operator import attrgetter
>>> dump_list(repr(address) for address in mlist.owners.addresses)
You can also set owners and moderators on an existing list with the
`mailman admins`_ command.
Setting the language
====================
You can set the default language for the new mailing list when you create it.
The language must be known to Mailman.
::
>>> command('mailman create --language xx ewe@example.com')
Usage: create [OPTIONS] LISTNAME
Try 'create --help' for help.
Error: Invalid language code: xx
>>> from mailman.interfaces.languages import ILanguageManager
>>> getUtility(ILanguageManager).add('xx', 'iso-8859-1', 'Freedonian')
>>> command('mailman create --language xx ewe@example.com')
Created mailing list: ewe@example.com
>>> mlist = list_manager.get('ewe@example.com')
>>> print(mlist.preferred_language)
Notifications
=============
When told to, Mailman will notify the list owners of their new mailing list.
>>> command('mailman create '
... '--notify '
... '--owner anne@example.com '
... '--owner bart@example.com '
... '--owner cate@example.com '
... 'fly@example.com')
Created mailing list: fly@example.com
The notification message is in the virgin queue.
::
>>> from mailman.testing.helpers import get_queue_messages
>>> messages = get_queue_messages('virgin')
>>> len(messages)
1
>>> for message in messages:
... print(message.msg.as_string())
MIME-Version: 1.0
...
Subject: Your new mailing list: fly@example.com
From: noreply@example.com
To: anne@example.com, bart@example.com, cate@example.com
...
The mailing list 'fly@example.com' has just been created for you.
The following is some basic information about your mailing list.
There is an email-based interface for users (not administrators) of
your list; you can get info about using it by sending a message with
just the word 'help' as subject or in the body, to:
fly-request@example.com
Please address all questions to noreply@example.com.
.. _`mailman admins`: https://docs.mailman3.org/projects/mailman/en/latest/src/mailman/commands/docs/admins.html
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1672838243.9184303
mailman-3.3.10/src/mailman/commands/docs/delmembers.rst 0000644 0000000 0000000 00000022543 14355276144 017772 0 ustar 00 ================
Deleting members
================
The ``mailman delmembers`` command allows a site administrator to delete members
from a mailing list.
>>> from mailman.testing.documentation import cli
>>> command = cli('mailman.commands.cli_delmembers.delmembers')
Usage
-----
Here is the complete usage for the command.
::
>>> command('mailman delmembers --help')
Usage: delmembers [OPTIONS]
Delete members from a mailing list.
Options:
-l, --list LISTSPEC The list to operate on. Required unless
--fromall is specified.
-f, --file FILENAME Delete list members whose addresses are in
FILENAME in addition to those specified with
-m/--member if any. FILENAME can be '-' to
indicate standard input. Blank lines and
lines that start with a '#' are ignored.
-m, --member ADDRESS Delete the list member whose address is
ADDRESS in addition to those specified with
-f/--file if any. This option may be repeated
for multiple addresses.
-a, --all Delete all the members of the list. If
specified, none of -f/--file, -m/--member or
--fromall may be specified.
--fromall Delete the member(s) specified by -m/--member
and/or -f/--file from all lists in the
installation. This may not be specified
together with -a/--all or -l/--list.
-g, --goodbye-msg / -G, --no-goodbye-msg
Override the list's setting for
send_goodbye_message to deleted members.
-n, --admin-notify / -N, --no-admin-notify
Override the list's setting for
admin_notify_mchanges.
--help Show this message and exit.
Examples
--------
You can delete members from a mailing list from the command line. To do so, you
need a file containing email addresses and optional display names that can be
parsed by ``email.utils.parseaddr()``. All mail addresses in the file will be
deleted from the mailing list. You can also specify members with command
options on the command line.
First we need a list with some members.
::
>>> from mailman.app.lifecycle import create_list
>>> bee = create_list('bee@example.com')
>>> from mailman.testing.helpers import subscribe
>>> subscribe(bee, 'Anne')
on bee@example.com
as MemberRole.member>
>>> subscribe(bee, 'Bart')
on bee@example.com
as MemberRole.member>
>>> subscribe(bee, 'Cate')
on bee@example.com
as MemberRole.member>
>>> subscribe(bee, 'Doug')
on bee@example.com
as MemberRole.member>
>>> subscribe(bee, 'Elly')
on bee@example.com
as MemberRole.member>
>>> subscribe(bee, 'Fred')
on bee@example.com
as MemberRole.member>
>>> subscribe(bee, 'Greg')
on bee@example.com
as MemberRole.member>
>>> subscribe(bee, 'Irma')
on bee@example.com
as MemberRole.member>
>>> subscribe(bee, 'Jeff')
on bee@example.com
as MemberRole.member>
Now we can delete some members.
::
>>> from tempfile import NamedTemporaryFile
>>> filename = cleanups.enter_context(NamedTemporaryFile()).name
>>> with open(filename, 'w', encoding='utf-8') as fp:
... print("""\
... aperson@example.com
... cperson@example.com (Cate Person)
... """, file=fp)
>>> command('mailman delmembers -f ' + filename + ' -l bee.example.com')
>>> from operator import attrgetter
>>> from mailman.testing.documentation import dump_list
>>> dump_list(bee.members.addresses, key=attrgetter('email'))
Bart Person
Doug Person
Elly Person
Fred Person
Greg Person
Irma Person
Jeff Person
You can also specify ``-`` as the filename, in which case the addresses are
taken from standard input.
::
>>> stdin = """\
... dperson@example.com
... Elly Person
... """
>>> command('mailman delmembers -f - -l bee.example.com', input=stdin)
>>> from mailman.testing.documentation import dump_list
>>> dump_list(bee.members.addresses, key=attrgetter('email'))
Bart Person
Fred Person
Greg Person
Irma Person
Jeff Person
Blank lines and lines that begin with '#' are ignored.
::
>>> with open(filename, 'w', encoding='utf-8') as fp:
... print("""\
... # cperson@example.com
...
... bperson@example.com
... """, file=fp)
>>> command('mailman delmembers -f ' + filename + ' -l bee.example.com')
>>> dump_list(bee.members.addresses, key=attrgetter('email'))
Fred Person
Greg Person
Irma Person
Jeff Person
Addresses which are not subscribed are ignored, although a warning is
printed.
::
>>> with open(filename, 'w', encoding='utf-8') as fp:
... print("""\
... kperson@example.com
... iperson@example.com
... """, file=fp)
>>> command('mailman delmembers -f ' + filename + ' -l bee.example.com')
Member not subscribed (skipping): kperson@example.com
>>> dump_list(bee.members.addresses, key=attrgetter('email'))
Fred Person
Greg Person