././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1672820293.400051 mailmanclient-3.3.5/0000755000076500000240000000000014355233105013721 5ustar00maxkingstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672813297.0 mailmanclient-3.3.5/.readthedocs.yaml0000644000076500000240000000057314355215361017161 0ustar00maxkingstaffversion: 2 # Build documentation in the docs/ directory with Sphinx sphinx: configuration: conf.py # Optionally build your docs in additional formats such as PDF formats: - pdf # Optionally set the version of Python and requirements required to build your docs python: version: 3.8 install: - method: pip path: . extra_requirements: - docs ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672813297.0 mailmanclient-3.3.5/COPYING.LESSER0000644000076500000240000001672514355215361015767 0ustar00maxkingstaff GNU LESSER 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. This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by the additional permissions listed below. 0. Additional Definitions. As used herein, "this License" refers to version 3 of the GNU Lesser General Public License, and the "GNU GPL" refers to version 3 of the GNU General Public License. "The Library" refers to a covered work governed by this License, other than an Application or a Combined Work as defined below. An "Application" is any work that makes use of an interface provided by the Library, but which is not otherwise based on the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by the Library. A "Combined Work" is a work produced by combining or linking an Application with the Library. The particular version of the Library with which the Combined Work was made is also called the "Linked Version". The "Minimal Corresponding Source" for a Combined Work means the Corresponding Source for the Combined Work, excluding any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not on the Linked Version. The "Corresponding Application Code" for a Combined Work means the object code and/or source code for the Application, including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the System Libraries of the Combined Work. 1. Exception to Section 3 of the GNU GPL. You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL. 2. Conveying Modified Versions. If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may convey a copy of the modified version: a) under this License, provided that you make a good faith effort to ensure that, in the event an Application does not supply the function or data, the facility still operates, and performs whatever part of its purpose remains meaningful, or b) under the GNU GPL, with none of the additional permissions of this License applicable to that copy. 3. Object Code Incorporating Material from Library Header Files. The object code form of an Application may incorporate material from a header file that is part of the Library. You may convey such object code under terms of your choice, provided that, if the incorporated material is not limited to numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates (ten or fewer lines in length), you do both of the following: a) Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the object code with a copy of the GNU GPL and this license document. 4. Combined Works. You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications, if you also do each of the following: a) Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the Combined Work with a copy of the GNU GPL and this license document. c) For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document. d) Do one of the following: 0) Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source. 1) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a) uses at run time a copy of the Library already present on the user's computer system, and (b) will operate properly with a modified version of the Library that is interface-compatible with the Linked Version. e) Provide Installation Information, but only if you would otherwise be required to provide such information under section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified version of the Combined Work produced by recombining or relinking the Application with a modified version of the Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.) 5. Combined Libraries. You may place library facilities that are a work based on the Library side by side in a single library together with other library facilities that are not Applications and are not covered by this License, and convey such a combined library under terms of your choice, if you do both of the following: a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities, conveyed under the terms of this License. b) Give prominent notice with the combined library that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. 6. Revised Versions of the GNU Lesser General Public License. The Free Software Foundation may publish revised and/or new versions of the GNU Lesser 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 Library as you received it specifies that a certain numbered version of the GNU Lesser General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that published version or of any later version published by the Free Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software Foundation. If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General Public License shall apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672813297.0 mailmanclient-3.3.5/MANIFEST.in0000644000076500000240000000023514355215361015463 0ustar00maxkingstaffinclude *.py MANIFEST.in *.cfg *.ini COPYING.LESSER global-include *.txt *.rst *.yaml include Makefile prune _build prune dist prune .tox exclude .bzrignore ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672813297.0 mailmanclient-3.3.5/Makefile0000644000076500000240000000603514355215361015371 0ustar00maxkingstaff# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d _build/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help clean html dirhtml pickle json htmlhelp qthelp latex changes linkcheck doctest help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " changes to make an overview of all changed/added/deprecated items" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: -rm -rf _build/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) _build/html @echo @echo "Build finished. The HTML pages are in _build/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) _build/dirhtml @echo @echo "Build finished. The HTML pages are in _build/dirhtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) _build/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) _build/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) _build/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in _build/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) _build/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in _build/qthelp, like this:" @echo "# qcollectiongenerator _build/qthelp/munepy.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile _build/qthelp/munepy.qhc" latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) _build/latex @echo @echo "Build finished; the LaTeX files are in _build/latex." @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ "run these through (pdf)latex." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) _build/changes @echo @echo "The overview file is in _build/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) _build/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in _build/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) _build/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in _build/doctest/output.txt." pypi: html (cd _build/html; \ rm -f index.html; \ ln -s README.html index.html; \ zip -r ../pypi .) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1672820293.400198 mailmanclient-3.3.5/PKG-INFO0000644000076500000240000000677614355233105015036 0ustar00maxkingstaffMetadata-Version: 2.1 Name: mailmanclient Version: 3.3.5 Summary: mailmanclient -- Python bindings for Mailman REST API Home-page: http://www.list.org/ Maintainer: Barry Warsaw Maintainer-email: barry@list.org License: LGPLv3 Platform: UNKNOWN Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) Classifier: Operating System :: POSIX Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Topic :: Internet :: WWW/HTTP Description-Content-Type: text/x-rst Provides-Extra: testing Provides-Extra: docs Provides-Extra: lint License-File: COPYING.LESSER .. This file is part of mailmanclient. mailmanclient is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation, version 3 of the License. mailmanclient is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with mailman.client. If not, see . ============== Mailman Client ============== .. image:: https://gitlab.com/mailman/mailmanclient/badges/master/build.svg :target: https://gitlab.com/mailman/mailmanclient/commits/master .. image:: https://readthedocs.org/projects/mailmanclient/badge :target: https://mailmanclient.readthedocs.io .. image:: http://img.shields.io/pypi/v/mailmanclient.svg :target: https://pypi.python.org/pypi/mailmanclient .. image:: http://img.shields.io/pypi/dm/mailmanclient.svg :target: https://pypi.python.org/pypi/mailmanclient The ``mailmanclient`` library provides official Python bindings for the GNU Mailman 3 REST API. Requirements ============ ``mailmanclient`` requires Python 3.7 or newer. Documentation ============= A `simple guide`_ to using the library is available within this package, in the form of doctests. The manual is also available online at: https://mailmanclient.readthedocs.io/en/latest/ Project details =============== The project home page is: https://gitlab.com/mailman/mailmanclient You should report bugs at: https://gitlab.com/mailman/mailmanclient/issues You can download the latest version of the package either from the `Cheese Shop`_: http://pypi.python.org/pypi/mailmanclient or from the GitLab page above. Of course you can also just install it with ``pip`` from the command line:: $ pip install mailmanclient You can grab the latest development copy of the code using Git, from the Gitlab home page above. If you have Git installed, you can grab your own branch of the code like this:: $ git clone https://gitlab.com/mailman/mailmanclient.git You may contact the developers via mailman-developers@python.org Acknowledgements ================ Many thanks to Florian Fuchs for his contribution of an initial REST client. Also thanks to all the contributors of Mailman Client who have contributed code, raised issues or devoted their time in any capacity! .. _`simple guide`: https://mailmanclient.readthedocs.io/en/latest/src/mailmanclient/docs/using.html .. _`Cheese Shop`: https://pypi.org/project/mailmanclient ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672813297.0 mailmanclient-3.3.5/README.rst0000644000076500000240000000533714355215361015424 0ustar00maxkingstaff.. This file is part of mailmanclient. mailmanclient is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation, version 3 of the License. mailmanclient is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with mailman.client. If not, see . ============== Mailman Client ============== .. image:: https://gitlab.com/mailman/mailmanclient/badges/master/build.svg :target: https://gitlab.com/mailman/mailmanclient/commits/master .. image:: https://readthedocs.org/projects/mailmanclient/badge :target: https://mailmanclient.readthedocs.io .. image:: http://img.shields.io/pypi/v/mailmanclient.svg :target: https://pypi.python.org/pypi/mailmanclient .. image:: http://img.shields.io/pypi/dm/mailmanclient.svg :target: https://pypi.python.org/pypi/mailmanclient The ``mailmanclient`` library provides official Python bindings for the GNU Mailman 3 REST API. Requirements ============ ``mailmanclient`` requires Python 3.7 or newer. Documentation ============= A `simple guide`_ to using the library is available within this package, in the form of doctests. The manual is also available online at: https://mailmanclient.readthedocs.io/en/latest/ Project details =============== The project home page is: https://gitlab.com/mailman/mailmanclient You should report bugs at: https://gitlab.com/mailman/mailmanclient/issues You can download the latest version of the package either from the `Cheese Shop`_: http://pypi.python.org/pypi/mailmanclient or from the GitLab page above. Of course you can also just install it with ``pip`` from the command line:: $ pip install mailmanclient You can grab the latest development copy of the code using Git, from the Gitlab home page above. If you have Git installed, you can grab your own branch of the code like this:: $ git clone https://gitlab.com/mailman/mailmanclient.git You may contact the developers via mailman-developers@python.org Acknowledgements ================ Many thanks to Florian Fuchs for his contribution of an initial REST client. Also thanks to all the contributors of Mailman Client who have contributed code, raised issues or devoted their time in any capacity! .. _`simple guide`: https://mailmanclient.readthedocs.io/en/latest/src/mailmanclient/docs/using.html .. _`Cheese Shop`: https://pypi.org/project/mailmanclient ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672813297.0 mailmanclient-3.3.5/conf.py0000644000076500000240000002077514355215361015237 0ustar00maxkingstaff# -*- coding: utf-8 -*- # # mailman.client documentation build configuration file, created by # sphinx-quickstart on Wed Aug 17 15:43:10 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 sys import os import pathlib # Configure intersphinx magic intersphinx_mapping = { 'mailmanclient': ( 'https://docs.mailman3.org/projects/mailmanclient/en/latest/api', None ), } # import the source code directory into Python Path for use with Auto Module APP_ROOT = os.path.dirname(__file__) sys.path.insert(0, APP_ROOT) # 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' # 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_rtd_theme', 'sphinx_issues', 'sphinx.ext.intersphinx', 'pydoctor.sphinx_ext.build_apidocs', ] # 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'Mailman Client' copyright = u'2012-2019, The Free Software Foundation' # 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. from setup_helpers import get_version # noqa _version = get_version('src/mailmanclient/constants.py') version = '.'.join(_version.split('.')[0:2]) # The full version, including alpha/beta/rc tags. release = _version base_dir = str(pathlib.Path(__file__).parent) pydoctor_args = [ '--project-name=mailmanclient', f'--project-version={version}', '--project-url=https://gitlab.com/mailman/mailmanclient', '--html-viewsource-base=https://gitlab.com/mailman/mailmanclient/tree/master', # noqa '--html-output={outdir}/src/mailmanclient/docs/api/', f'--project-base-dir={base_dir}', '--docformat=restructuredtext', '--process-types', '--privacy=HIDDEN:mailmanclient.tests*', '--privacy=HIDDEN:mailmanclient.docs*', 'src/mailmanclient' ] pydoctor_url_path = '/en/{rtd_version}/api/' # 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', '.tox', 'eggs', '.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 = None # 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 = 'mailmanclientdoc' # -- 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', 'mailmanclient.tex', u'Mailman Client Documentation', u'Mailman Coders', '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 # -- Options for manual page output ------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ('README', 'mailmanclient', u'Mailman Client Documentation', [u'Mailman Coders'], 1) ] issues_uri = "https://gitlab.com/mailman/mailmanclient/issues/{issue}" issues_pr_uri = "https://gitlab.com/mailman/mailmanclient/merge_requests/{pr}" issues_commit_uri = "https://gitlab.com/mailman/mailmanclient/commit/{commit}" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/conftest.py0000644000076500000240000000164614355230520016125 0ustar00maxkingstaff# Copyright (C) 2017-2023 by the Free Software Foundation, Inc. # # This file is part of mailman.client. # # mailman.client is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailman.client is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailman.client. If not, see . """pytest conftest""" __metaclass__ = type import os import pytest @pytest.fixture def vcr_cassette_path(request, vcr_cassette_name): return os.path.join('src/mailmanclient/tests/data', vcr_cassette_name) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672813297.0 mailmanclient-3.3.5/copybump.py0000755000076500000240000000534214355215361016144 0ustar00maxkingstaff#! /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() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672813297.0 mailmanclient-3.3.5/index.rst0000644000076500000240000000022614355215361015566 0ustar00maxkingstaff.. include:: README.rst Contents ======== .. toctree:: :glob: :maxdepth: 2 src/mailmanclient/docs/* src/mailmanclient/docs/api/index ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672813297.0 mailmanclient-3.3.5/mailman_test.cfg0000644000076500000240000000064214355215361017065 0ustar00maxkingstaff# Example Mailman config file suitable for testing [devmode] enabled: yes testing: yes recipient: you@yourdomain.com [mta] smtp_port: 9025 lmtp_port: 9024 incoming: mailman.testing.mta.FakeMTA [webservice] port: 9001 [archiver.mhonarc] enable: yes [archiver.mail_archive] enable: yes [archiver.prototype] enable: yes [logging.master] path: /dev/stdout level: info [logging.http] path: /dev/stdout level: error ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672813297.0 mailmanclient-3.3.5/mypy.ini0000644000076500000240000000027414355215361015427 0ustar00maxkingstaff[mypy] files = src/mailmanclient/asynclient.py,src/mailmanclient/asyncobjects/**.py,src/mailmanclient/restobjects/types.py,src/mailmanclient/restobjects/utils.py no_implicit_optional=False././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672813297.0 mailmanclient-3.3.5/pytest.ini0000644000076500000240000000017014355215361015754 0ustar00maxkingstaff[pytest] addopts = --doctest-glob='*.rst' --tb=short --run-services doctest_optionflags = NORMALIZE_WHITESPACE ELLIPSIS ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1672820293.4005454 mailmanclient-3.3.5/setup.cfg0000644000076500000240000000016314355233105015542 0ustar00maxkingstaff[build_sphinx] source_dir = . [upload_docs] upload_dir = build/sphinx/html [egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/setup.py0000644000076500000240000000460714355230520015440 0ustar00maxkingstaff# Copyright (C) 2010-2023 by the Free Software Foundation, Inc. # # This file is part of mailman.client. # # mailman.client is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, either version 3 of the License, or (at your # option) any later version. # # mailman.client is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License # for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailman.client. If not, see . from setuptools import find_packages, setup from setup_helpers import get_version, require_python require_python(0x30600f0) __version__ = get_version('src/mailmanclient/constants.py') def readme(): with open('README.rst') as fd: return fd.read() setup( name='mailmanclient', version=__version__, packages=find_packages('src'), description='mailmanclient -- Python bindings for Mailman REST API', long_description=readme(), long_description_content_type='text/x-rst', package_dir={'': 'src'}, include_package_data=True, maintainer='Barry Warsaw', maintainer_email='barry@list.org', license='LGPLv3', url='http://www.list.org/', classifiers=[ 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', # noqa 'Operating System :: POSIX', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Topic :: Internet :: WWW/HTTP ', ], install_requires=[ 'requests', 'typing_extensions;python_version<"3.8"', ], extras_require={ 'testing': [ 'pytest', 'pytest-services', 'mailman>=3.3.1', 'falcon>1.4.1', 'httpx', ], 'docs': [ 'sphinx', 'sphinx-rtd-theme', 'sphinx-issues', 'pydoctor', ], 'lint': [ 'flake8>3.0', 'flake8-bugbear', ] }, ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/setup_helpers.py0000644000076500000240000001127014355230520017154 0ustar00maxkingstaff# Copyright (C) 2009-2023 by the Free Software Foundation, Inc. # # This file is part of mailman.client # # mailman.client is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailman.client is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more # details. # # You should have received a copy of the GNU Lesser General Public License # along with mailman.client. If not, see . """setup.py helper functions.""" from __future__ import absolute_import, print_function, unicode_literals import os import re import sys import codecs __metaclass__ = type __all__ = [ 'description', 'find_doctests', 'get_version', 'long_description', 'require_python', ] DEFAULT_VERSION_RE = re.compile(r'(?P\d+\.\d(?:\.\w+)?)') NL = '\n' def require_python(minimum): """Require at least a minimum Python version. The version number is expressed in terms of `sys.hexversion`. E.g. to require a minimum of Python 2.6, use:: >>> require_python(0x206000f0) :param minimum: Minimum Python version supported. :type minimum: integer """ if sys.hexversion < minimum: hversion = hex(minimum)[2:] if len(hversion) % 2 != 0: hversion = '0' + hversion split = list(hversion) parts = [] while split: parts.append(int(''.join((split.pop(0), split.pop(0))), 16)) major, minor, micro, release = parts if release == 0xf0: print('Python {0}.{1}.{2} or better is required'.format( major, minor, micro)) else: print('Python {0}.{1}.{2} ({3}) or better is required'.format( major, minor, micro, hex(release)[2:])) sys.exit(1) def get_version(filename, pattern=None): """Extract the __version__ from a file without importing it. While you could get the __version__ by importing the module, the very act of importing can cause unintended consequences. For example, Distribute's automatic 2to3 support will break. Instead, this searches the file for a line that starts with __version__, and extract the version number by regular expression matching. By default, two or three dot-separated digits are recognized, but by passing a pattern parameter, you can recognize just about anything. Use the `version` group name to specify the match group. :param filename: The name of the file to search. :type filename: string :param pattern: Optional alternative regular expression pattern to use. :type pattern: string :return: The version that was extracted. :rtype: string """ if pattern is None: cre = DEFAULT_VERSION_RE else: cre = re.compile(pattern) with open(filename) as fp: for line in fp: if line.startswith('__version__'): mo = cre.search(line) assert mo, 'No valid __version__ string found' return mo.group('version') raise AssertionError('No __version__ assignment found') def find_doctests(start='.', extension='.txt'): """Find separate-file doctests in the package. This is useful for Distribute's automatic 2to3 conversion support. The `setup()` keyword argument `convert_2to3_doctests` requires file names, which may be difficult to track automatically as you add new doctests. :param start: Directory to start searching in (default is cwd) :type start: string :param extension: Doctest file extension (default is .txt) :type extension: string :return: The doctest files found. :rtype: list """ doctests = [] for dirpath, _, filenames in os.walk(start): doctests.extend(os.path.join(dirpath, filename) for filename in filenames if filename.endswith(extension)) return doctests def long_description(*filenames): """Provide a long description.""" res = [] for value in filenames: base, ext = os.path.splitext(value) if ext in ('.txt', '.rst'): with codecs.open(value, 'r', encoding='utf-8') as fp: value = fp.read() res.append(value) if not value.endswith(NL): res.append('') return NL.join(res) def description(filename): """Provide a short description.""" with codecs.open(filename, 'r', encoding='utf-8') as fp: for line in fp: return line.strip() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1672820293.3754106 mailmanclient-3.3.5/src/0000755000076500000240000000000014355233105014510 5ustar00maxkingstaff././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1672820293.3804162 mailmanclient-3.3.5/src/mailmanclient/0000755000076500000240000000000014355233105017325 5ustar00maxkingstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/__init__.py0000644000076500000240000000415514355230520021441 0ustar00maxkingstaff# Copyright (C) 2010-2023 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . # # flake8: noqa """Package contents.""" from mailmanclient.client import Client from mailmanclient.constants import __version__ from mailmanclient.restbase.connection import MailmanConnectionError from mailmanclient.restobjects.address import Address, Addresses from mailmanclient.restobjects.ban import Bans, BannedAddress from mailmanclient.restobjects.configuration import Configuration from mailmanclient.restobjects.domain import Domain from mailmanclient.restobjects.header_match import HeaderMatch, HeaderMatches from mailmanclient.restobjects.held_message import HeldMessage from mailmanclient.restobjects.archivers import ListArchivers from mailmanclient.restobjects.mailinglist import MailingList from mailmanclient.restobjects.member import Member from mailmanclient.restobjects.preferences import Preferences, PreferencesMixin from mailmanclient.restobjects.queue import Queue from mailmanclient.restobjects.settings import Settings from mailmanclient.restobjects.user import User __metaclass__ = type __all__ = [ 'Address', 'Addresses', 'Bans', 'BannedAddress', 'Client', 'Configuration', 'Domain' 'HeaderMatch', 'HeaderMatches', 'HeldMessage', 'ListArchivers', 'MailingList', 'MailmanConnectionError', 'Member', 'Preferences', 'PreferencesMixin', 'Queue', 'Settings', 'User', '__version__', ] __all__ = [bytes(x, 'utf-8') for x in __all__] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/_client.py0000644000076500000240000000215514355230520021315 0ustar00maxkingstaff# Copyright (C) 2017-2023 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailman.client is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailman.client is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailman.client. If not, see . """Old module for backwards compatibility""" from __future__ import absolute_import, print_function, unicode_literals from mailmanclient import * # noqa __metaclass__ = type # XXX: This module exists for backwards compatibility with older versions of # MailmanClient which had all the API under this module. It will be removed in # future after few cycles of deprecation. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/asynclient.py0000644000076500000240000001417314355230520022054 0ustar00maxkingstaff# Copyright (C) 2021-2023 by the Free Software Foundation, Inc. # # This file is part of mailman.client. # # mailman.client is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailman.client is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailman.client. If not, see . """Async client for Mailman Core 3.1 API. AsyncClient provides a thin Python API over Mailman Core's HTTP API. It is currently is very early stages and supports on read operations. Some of the read operations might be missinng as well. To start using the client, you need an async http library. httpx_ is officially supported one, but making some other client work with it is pretty easy. :: >>> import httpx >>> conn = httpx.AsyncClient() >>> from mailmanclient.asynclient import AsyncClient >>> client = AsyncClient(conn, 'http://localhost:8001/3.1', ... 'restadmin', 'restpass') You will need an event loop to actually run the client. You can reuse your existing one by calling ``await`` on the client methods, or you can create a new one using the standard library ``asyncio`` module. :: >>> import asyncio >>> domains = asyncio.run(client.domains()) .. _httpx: https://www.python-httpx.org/ """ __all__ = [ 'AsyncClient', ] from typing import List, Mapping, Any from mailmanclient.restobjects.utils import list_of_objects from mailmanclient.restobjects.types import HTTPClientProto from mailmanclient.restbase.async_connection import Connection from mailmanclient.asyncobjects.domain import Domain from mailmanclient.asyncobjects.mailinglist import MailingList from mailmanclient.asyncobjects.user import User from mailmanclient.asyncobjects.address import Address from mailmanclient.asyncobjects.member import Member JSON_CONTENT_TYPE = 'application/json' class AsyncClient: """Provide an Idiomatic API for Mailman Core. It requires an HTTP client instance as the first argument. You can use any client which has a ``.request()`` method and accepts named parameters ``url``, ``path``, ``auth``, ``method`` and ``data``. ``data`` is supposed to be a dictionary of parameters to be passed to the HTTP request and the rest are string parameters with their usual meaning. The parameters are based off on httpx python library. :param client: Http client object with an async request method. :param base_url: Base URL to Core's API. :param user: Core admin username. :param password: Core admin password. """ def __init__( self, client: HTTPClientProto, base_url: str, user: str, password: str, ) -> None: self.client = client self.connection = Connection(self.client, base_url, user, password) async def domains(self) -> List[Domain]: """Get all domains. ``//domains`` """ response, content = await self.connection.call('domains') return list_of_objects(Domain, content, self.connection) async def system(self) -> Mapping[str, Any]: """Get the Mailman system information. ``//system`` """ response, content = await self.connection.call('system') return content async def lists(self) -> List[MailingList]: """Get a list of MailingLists ``//lists`` """ response, content = await self.connection.call('lists') return list_of_objects(MailingList, content, self.connection) async def members(self) -> List[Member]: """All the Members ``//members`` """ response, content = await self.connection.call('members') return list_of_objects(Member, content, self.connection) async def users(self) -> List[User]: """All the users in Mailman Core ``//users`` """ response, content = await self.connection.call('users') return list_of_objects(User, content, self.connection) async def addresses(self) -> List[Address]: """All the addresses in Mailman ``//address`` """ response, content = await self.connection.call('addresses') return list_of_objects(Address, content, self.connection) async def find_members( self, list_id: str = None, subscriber: str = None, role: str = None, moderation_action: str = None, delivery_status: str = None, delivery_mode: str = None, ) -> List[Member]: """Find members. ``//members/find`` :param list_id: Mailinglist id. :param subscriber: Email or user_id or partial search string. :param role: Membership role. One of 'owner', 'member', 'nonmember' or 'moderator'. :param moderation_action: One of the moderation action from 'defer', 'accept', 'discard', 'reject', 'hold'. :param delivery_status: Delivery status of the Member. It can be one among 'enabled', 'by_user', 'by_moderator' or 'by_bounces'. :param delivery_mode: Delivery mode of the member. It can be one between 'plaintext_digests', 'mime_digests', 'regular'. """ data = dict(list_id=list_id, subscriber=subscriber, role=role, moderation_action=moderation_action, delivery_status=delivery_status, delivery_mode=delivery_mode) # Skip parameters that have None value. # TODO: Handle parameters that can have None value. data = {key: value for key, value in data.items() if value is not None} response, content = await self.connection.call( 'members/find', data=data, method='GET') return list_of_objects(Member, content, self.connection) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1672820293.3852773 mailmanclient-3.3.5/src/mailmanclient/asyncobjects/0000755000076500000240000000000014355233105022014 5ustar00maxkingstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672813297.0 mailmanclient-3.3.5/src/mailmanclient/asyncobjects/__init__.py0000644000076500000240000000000014355215361024117 0ustar00maxkingstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/asyncobjects/address.py0000644000076500000240000000221214355230520024006 0ustar00maxkingstaff# Copyright (C) 2021-2023 by the Free Software Foundation, Inc. # # This file is part of mailman.client. # # mailman.client is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailman.client is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailman.client. If not, see . """Address async object.""" __all__ = [ 'Address', ] from mailmanclient.restbase.async_base import RESTObject from mailmanclient.asyncobjects.preferences import PreferencesMixin class Address(RESTObject, PreferencesMixin): _properties = ('display_name', 'email', 'original_email', 'registered_on', 'self_link', 'verified_on') def __repr__(self) -> str: return '
'.format(self.email) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/asyncobjects/domain.py0000644000076500000240000000177714355230520023647 0ustar00maxkingstaff# Copyright (C) 2021-2023 by the Free Software Foundation, Inc. # # This file is part of mailman.client. # # mailman.client is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailman.client is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailman.client. If not, see . """Async domain object.""" __all__ = [ 'Domain' ] from mailmanclient.restbase.async_base import RESTObject class Domain(RESTObject): _properties = ('alias_domain', 'description', 'mail_host', 'self_link') def __repr__(self) -> str: return ''.format(self.mail_host) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/asyncobjects/mailinglist.py0000644000076500000240000000745714355230520024715 0ustar00maxkingstaff# Copyright (C) 2021-2023 by the Free Software Foundation, Inc. # # This file is part of mailman.client. # # mailman.client is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailman.client is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailman.client. If not, see . """Async Mailinglist object..""" __all__ = [ 'MailingList', ] from enum import Enum from typing import List from mailmanclient.restbase.async_base import RESTObject from mailmanclient.asyncobjects.member import Member from mailmanclient.restobjects.utils import list_of_objects from mailmanclient.restobjects.types import ConnectionProto, ContentType class MemberRole(Enum): """Member role values.""" member = 'member' owner = 'owner' moderator = 'moderator' nonmember = 'nonmember' class MailingList(RESTObject): _properties = ('advertised', 'display_name', 'fqdn_listname', 'list_id', 'list_name', 'mail_host', 'member_count', 'volume', 'self_link', 'description') def __repr__(self) -> str: return ''.format(self.fqdn_listname) async def config(self) -> 'Config': """Get MailingList settings. //lists//config """ path = 'lists/{}/config'.format(self.list_id) _, content = await self._connection.call(path) return Config(self, self._connection, content) async def get_roster(self, role) -> List[Member]: """Get MailingList roster. //lists//roster/ """ path = 'lists/{}/roster/{}'.format(self.fqdn_listname, role) _, content = await self._connection.call(path) return list_of_objects(Member, content, self._connection) async def members(self) -> List[Member]: """Get Mailinglist members (subscribers.) //lists//roster/member """ return await self.get_roster(MemberRole.member) async def owners(self) -> List[Member]: """Get Mailinglist owners. //lists//roster/owner """ return await self.get_roster(MemberRole.owner) async def moderators(self) -> List[Member]: """Get Mailinglist moderators. //lists//roster/moderator """ return await self.get_roster(MemberRole.moderator) async def nonmember(self) -> List[Member]: """Get Mailinglist nonmembers. //lists//roster/nonmember """ return await self.get_roster(MemberRole.nonmember) class Config(RESTObject): _read_only_properties = ( 'bounces_address', 'created_at', 'digest_last_sent_at', 'fqdn_listname', 'join_address', 'last_post_at', 'leave_address', 'list_id', 'list_name', 'mail_host', 'next_digest_number', 'no_reply_address', 'owner_address', 'post_id', 'posting_address', 'request_address', 'scheme', 'self_link', 'volume', 'web_host', ) def __init__(self, mailing_list: MailingList, connection: ConnectionProto, data: ContentType) -> None: super().__init__(connection, data) self.mailing_list = mailing_list def __repr__(self) -> str: return ''.format(self.mailing_list.fqdn_listname) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/asyncobjects/member.py0000644000076500000240000000252314355230520023635 0ustar00maxkingstaff# Copyright (C) 2021-2023 by the Free Software Foundation, Inc. # # This file is part of mailman.client. # # mailman.client is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailman.client is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailman.client. If not, see . """Member async object.""" __all__ = [ 'Member', ] from mailmanclient.restbase.async_base import RESTObject from mailmanclient.asyncobjects.preferences import PreferencesMixin class Member(RESTObject, PreferencesMixin): _properties = ('address', 'delivery_mode', 'email', 'list_id', 'moderation_action', 'display_name', 'role', 'self_link', 'subscription_mode', 'member_id') _writable_properties = ('address', 'delivery_mode', 'moderation_action') def __repr__(self): return ''.format( self.email, self.list_id, self.role) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/asyncobjects/preferences.py0000644000076500000240000000273714355230520024676 0ustar00maxkingstaff# Copyright (C) 2021-2023 by the Free Software Foundation, Inc. # # This file is part of mailman.client. # # mailman.client is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailman.client is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailman.client. If not, see . """Preferences object and mixins.""" __all__ = [ 'Preferences', 'PreferencesMixin', ] from mailmanclient.restbase.async_base import RESTObject class Preferences(RESTObject): _properties = ( 'acknowledge_posts', 'delivery_mode', 'delivery_status', 'hide_address', 'preferred_language', 'receive_list_copy', 'receive_own_postings', ) class PreferencesMixin: """Mixin for restobjects that have preferences.""" async def preferences(self): if getattr(self, '_preferences', None) is None: path = '{0}/preferences'.format(self.self_link) response, content = await self._connection.call(path) self._preferences = Preferences(self._connection, content) return self._preferences ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/asyncobjects/user.py0000644000076500000240000000310414355230520023340 0ustar00maxkingstaff# Copyright (C) 2021-2023 by the Free Software Foundation, Inc. # # This file is part of mailman.client. # # mailman.client is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailman.client is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailman.client. If not, see . """User async object.""" __all__ = [ 'User', ] from mailmanclient.restbase.async_base import RESTObject from mailmanclient.restobjects.utils import list_of_objects from mailmanclient.asyncobjects.preferences import PreferencesMixin class User(RESTObject, PreferencesMixin): _properties = ('created_on', 'display_name', 'is_server_owner', 'password', 'self_link', 'user_id') _writable_properties = ('cleartext_password', 'display_name', 'is_server_owner') def __repr__(self): return ''.format(self.display_name, self.user_id) async def addresses(self): from mailmanclient.asyncobjects.address import Address url = self.self_link + '/addresses' resp, content = await self._connection.call(url) return list_of_objects(Address, content, self._connection) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/client.py0000644000076500000240000004473614355230520021171 0ustar00maxkingstaff# Copyright (C) 2010-2023 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . """Client code.""" import warnings from operator import itemgetter from urllib.parse import quote from mailmanclient.constants import (MISSING) from mailmanclient.restobjects.address import Address from mailmanclient.restobjects.ban import Bans, BannedAddress from mailmanclient.restobjects.configuration import Configuration from mailmanclient.restobjects.domain import Domain from mailmanclient.restobjects.mailinglist import MailingList from mailmanclient.restobjects.member import Member from mailmanclient.restobjects.preferences import Preferences from mailmanclient.restobjects.queue import Queue from mailmanclient.restobjects.styles import Styles from mailmanclient.restobjects.user import User from mailmanclient.restobjects.templates import Template, TemplateList from mailmanclient.restbase.connection import Connection from mailmanclient.restbase.page import Page __metaclass__ = type __all__ = [ 'Client' ] # # --- The following classes are part of the API # class Client: """Access the Mailman REST API root. :param baseurl: The base url to access the Mailman 3 REST API. :type baseurl: str :param name: The Basic Auth user name. If given, the `password` must also be given. :type name: str :param password: The Basic Auth password. If given the `name` must also be given. :type password: str :param request_hooks: Callable hooks to process request parameters before being sent to Core's API. :type request_hooks: List[callables] """ def __init__(self, baseurl, name=None, password=None, request_hooks=None): """Initialize client access to the REST API.""" self._connection = Connection(baseurl, name, password, request_hooks) def __repr__(self): return ''.format( self._connection) def add_hooks(self, request_hooks): """Add a hook to process connections to Mailman's API. Hooks are callables that are passed in the parameters of HTTP call made to Mailman Core' RESt API and are expected to return the same number of parameters back. This can be useful to manipulate parameters and update them, or simply log each call to API. :: from mailmanclient.client import Client client = Client('http://localhost:8001/3.1', , ) def sample_hook(params): print(f'Request params are {params}') return params client.add_hook([sample_hook]) :param request_hooks: A list of callables that take in a dictionary of parameters. ``params`` is the list of request parameters before the request is made. """ self._connection.add_hooks(request_hooks=request_hooks) @property def system(self): """Get the basic system information. :returns: System information about Mailman Core :rtype: Dict[str, str] """ return self._connection.call('system/versions')[1] @property def preferences(self): """Get all default system Preferences. :returns: System preferences. :rtype: :class:`Preferences` """ return Preferences(self._connection, 'system/preferences') @property def configuration(self): """Get the system configuration. :returns: All the system configuration. :rtype: Dict[str, :class:`Configuration`] """ response, content = self._connection.call('system/configuration') return {section: Configuration( self._connection, section) for section in content['sections']} @property def pipelines(self): """Get a list of all Pipelines. :returns: A list of all the pipelines in Core. :rtype: List """ response, content = self._connection.call('system/pipelines') return content @property def chains(self): """Get a list of all the Chains. :returns: A list of all the chains in Core. :rtype: List """ response, content = self._connection.call('system/chains') return content @property def queues(self): """Get a list of all Queues. :returns: A list of all the queues in Core. :rtype: List """ response, content = self._connection.call('queues') queues = {} for entry in content['entries']: queues[entry['name']] = Queue( self._connection, entry['self_link'], entry) return queues @property def styles(self): """All the default styles in Mailman Core. :returns: All the styles in Core. :rtype: :class:`Styles` """ return Styles(self._connection, 'lists/styles') @property def lists(self): """Get a list of all MailingLists. :returns: All the mailing lists. :rtype: list(:class:`MailingList`) """ return self.get_lists() def get_lists(self, advertised=False): """Get a list of all the MailingLists. :param advertised: If marked True, returns all MailingLists including the ones that aren't advertised. :type advertised: bool :returns: A list of mailing lists. :rtype: List(:class:`MailingList`) """ url = 'lists' if advertised: url += '?advertised=true' response, content = self._connection.call(url) if 'entries' not in content: return [] return [MailingList(self._connection, entry['self_link'], entry) for entry in content['entries']] def get_list_page(self, count=50, page=1, advertised=None, mail_host=None): """Get a list of all MailingList with pagination. :param count: Number of entries per-page (defaults to 50). :param page: The page number to return (defaults to 1). :param advertised: If marked True, returns all MailingLists including the ones that aren't advertised. :param mail_host: Domain to filter results by. """ if mail_host: url = 'domains/{0}/lists'.format(mail_host) else: url = 'lists' if advertised: url += '?advertised=true' return Page(self._connection, url, MailingList, count, page) @property def domains(self): """Get a list of all Domains. :returns: All the domains on the system. :rtype: List[:class:`Domain`] """ response, content = self._connection.call('domains') if 'entries' not in content: return [] return [Domain(self._connection, entry['self_link']) for entry in sorted(content['entries'], key=itemgetter('mail_host'))] @property def members(self): """Get a list of all the Members. :returns: All the list memebrs. :rtype: List[:class:`Member`] """ response, content = self._connection.call('members') if 'entries' not in content: return [] return [Member(self._connection, entry['self_link'], entry) for entry in content['entries']] def get_member(self, fqdn_listname, subscriber_address): """Get the Member object for a given MailingList and Subsciber's Email Address. :param str fqdn_listname: Fully qualified address for the MailingList. :param str subscriber_address: Email Address for the subscriber. :returns: A member of a list. :rtype: :class:`Member` """ return self.get_list(fqdn_listname).get_member(subscriber_address) def get_nonmember(self, fqdn_listname, nonmember_address): """Get the Member object for a given MailingList and Non-member's Email. :param str fqdn_listname: Fully qualified address for the MailingList. :param str subscriber_address: Email Address for the non-member. :returns: A member of a list. :rtype: :class:`Member` """ return self.get_list(fqdn_listname).get_nonmember(nonmember_address) def get_member_page(self, count=50, page=1): """Return a paginated list of Members. :param int count: Number of items to return. :param int page: The page number. :returns: Paginated lists of members. :rtype: :class:`Page` of :class:`Member`. """ return Page(self._connection, 'members', Member, count, page) @property def users(self): """Get all the users. :returns: All the users in Mailman Core. :rtype: List[:class:`User`] """ response, content = self._connection.call('users') if 'entries' not in content: return [] return [User(self._connection, entry['self_link'], entry) for entry in sorted(content['entries'], key=itemgetter('self_link'))] def get_user_page(self, count=50, page=1): """Get all the users with pagination. :param int count: Number of entries per-page (defaults to 50). :param int page: The page number to return (defaults to 1). :returns: Paginated list of users on Mailman. :rtype: :class:`Page` of :class:`User` """ return Page(self._connection, 'users', User, count, page) def create_domain(self, mail_host, base_url=MISSING, description=None, owner=None, alias_domain=None): """Create a new Domain. :param str mail_host: The Mail host for the new domain. If you want foo@bar.com" as the address for your MailingList, use "bar.com" here. :param str description: A brief description for this Domain. :param str owner: Email address for the owner of this list. :param str alias_domain: Alias domain. :returns: The created Domain. :rtype: :class:`Domain` """ if base_url is not MISSING: warnings.warn( 'The `base_url` parameter in the `create_domain()` method is ' 'deprecated. It is not used any more and will be removed in ' 'the future.', DeprecationWarning, stacklevel=2) data = dict(mail_host=mail_host) if description is not None: data['description'] = description if owner is not None: data['owner'] = owner if alias_domain is not None: data['alias_domain'] = alias_domain response, content = self._connection.call('domains', data) return Domain(self._connection, response.headers.get('location')) def delete_domain(self, mail_host): """Delete a Domain. :param str mail_host: The Mail host for the domain you want to delete. """ response, content = self._connection.call( 'domains/{0}'.format(mail_host), None, 'DELETE') def get_domain(self, mail_host, web_host=MISSING): """Get Domain by its mail_host.""" if web_host is not MISSING: warnings.warn( 'The `web_host` parameter in the `get_domain()` method is ' 'deprecated. It is not used any more and will be removed in ' 'the future.', DeprecationWarning, stacklevel=2) response, content = self._connection.call( 'domains/{0}'.format(mail_host)) return Domain(self._connection, content['self_link']) def create_user(self, email, password, display_name=''): """Create a new User. :param str email: Email address for the new user. :param str password: Password for the new user. :param str display_name: An optional name for the new user. :returns: The created user instance. :rtype: :class:`User` """ response, content = self._connection.call( 'users', dict(email=email, password=password, display_name=display_name)) return User(self._connection, response.headers.get('location')) def get_user(self, address): """Given an Email Address, return the User it belongs to. :param str address: Email Address of the User. :returns: The user instance that owns the address. :rtype: :class:`User` """ response, content = self._connection.call( 'users/{0}'.format(address)) return User(self._connection, content['self_link'], content) def get_address(self, address): """Given an Email Address, return the Address object. :param str address: Email address. :returns: The Address object for given email address. :rtype: :class:`Address` """ response, content = self._connection.call( 'addresses/{0}'.format(address)) return Address(self._connection, content['self_link'], content) def get_list(self, fqdn_listname): """Get a MailingList object. :param str fqdn_listname: Fully qualified name of the MailingList. :returns: The mailing list object of the given fqdn_listname. :rtype: :class:`MailingList` """ response, content = self._connection.call( 'lists/{0}'.format(fqdn_listname)) return MailingList(self._connection, content['self_link'], content) def delete_list(self, fqdn_listname): """Delete a MailingList. :param str fqdn_listname: Fully qualified name of the MailingList. """ response, content = self._connection.call( 'lists/{0}'.format(fqdn_listname), None, 'DELETE') @property def bans(self): """Get a list of all the bans. :returns: A list of all the bans. :rtype: :class:`Bans` """ return Bans(self._connection, 'bans', mlist=None) def get_bans_page(self, count=50, page=1): """Get a list of all the bans with pagination. :param int count: Number of entries per-page (defaults to 50). :param int page: The page number to return (defaults to 1). :returns: Paginated list of banned addresses. :rtype: :class:`Page` of :class:`BannedAddress` """ return Page(self._connection, 'bans', BannedAddress, count, page) @property def templates(self): """Get all site-context templates. :returns: List of templates for the site context. :rtype: :class:`TemplateList` """ return TemplateList(self._connection, 'uris') def get_templates_page(self, count=25, page=1): """Get paginated site-context templates. :returns: Paginated list of templates of site context. :rtype: :class:`Page` of :class:`Template` """ return Page(self._connection, 'uris', Template, count, page) def set_template(self, template_name, url, username=None, password=None): """Set template in site-context. :param str template_name: The template to set. :param str url: The URL to fetch the template from. :param str username: Username for access to the template. :param str password: Password for the ``username`` to access templates. """ data = {template_name: url} if username is not None and password is not None: data['username'] = username data['password'] = password return self._connection.call('uris', data, 'PATCH')[1] def find_lists(self, subscriber, role=None, count=50, page=1, mail_host=None): """Given a subscriber and a role, return all the list they are subscribed to with given role. If no role is specified all the related mailing lists are returned without duplicates, even though there can potentially be multiple memberships of a user in a single mailing list. :param str subscriber: The address of the subscriber. :param str role: owner, moderator or subscriber. :param int count: Number of entries per-page (defaults to 50). :param int page: The page number to return (defaults to 1). :param str mail_host: Domain to filter results by. :returns: A filtered list of mailing lists with given filters. :rtype: List[:class:`MailingList`] """ url = 'lists/find' data = dict(subscriber=subscriber, count=count, page=page) if role is not None: data['role'] = role response, content = self._connection.call(url, data) if 'entries' not in content: return [] return [MailingList(self._connection, entry['self_link'], entry) for entry in content['entries'] if not mail_host or entry['mail_host'] == mail_host] def find_users(self, query): """Find users with query string matching display name and emails. :param str query: The string to search for. The search string is case insensitive as core doens't care about the case. :param int count: Number of entries per-page (defaults to 50). :param int page: The page number to return (defaults to 1). """ url = 'users/find?q={}'.format(quote(query)) response, content = self._connection.call(url) if 'entries' not in content: return [] return [User(self._connection, entry['self_link'], entry) for entry in content['entries']] def find_users_page(self, query, count, page): """Same as :py:meth:`find_users` but allows for pagination. :param str query: The string to search for. The search string is case insensitive as core doens't care about the case. :param int count: Number of entries per-page (defaults to 50). :param int page: The page number to return (defaults to 1). """ url = 'users/find?q={}'.format(quote(query)) return Page(self._connection, url, User, count, page) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/conftest.py0000644000076500000240000000306614355230520021527 0ustar00maxkingstaff# Copyright (C) 2017-2023 by the Free Software Foundation, Inc. # # This file is part of mailman.client. # # mailman.client is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailman.client is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailman.client. If not, see . """Testing utilities.""" import pytest import subprocess import socket from contextlib import closing def check_core(): with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock: if sock.connect_ex(('localhost', 9001)) == 0: return True else: return False @pytest.fixture(scope='module', autouse=True) def mailman_core(request, watcher_getter): """Mailman Core instance which is ready to be used by the tests""" def teardown_core(): print('Stopping Mailman Server') subprocess.run(['mailman', 'stop']) subprocess.run(['rm', '-rf', 'var/']) request.addfinalizer(teardown_core) print('Starting Mailman Server') return watcher_getter( name='master', arguments=['-C', 'mailman_test.cfg'], checker=check_core, request=request, ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672820132.0 mailmanclient-3.3.5/src/mailmanclient/constants.py0000644000076500000240000000142514355232644021724 0ustar00maxkingstaff# Copyright (C) 2010-2023 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . __version__ = '3.3.5' DEFAULT_PAGE_ITEM_COUNT = 50 MISSING = object() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1672820293.3864028 mailmanclient-3.3.5/src/mailmanclient/docs/0000755000076500000240000000000014355233105020255 5ustar00maxkingstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672820132.0 mailmanclient-3.3.5/src/mailmanclient/docs/NEWS.rst0000644000076500000240000001337414355232644021602 0ustar00maxkingstaff======================= NEWS for mailmanclient ======================= .. _news-3-3-5: 3.3.5 (2023-01-04) ================== - Add support for Python 3.11. .. _news-3-3-4: 3.3.4 (2022-10-23) ================== - URL quote the query in find_user* methods. (Fixes :issue:`75`) - Add support for Python 3.10 and drops support for 3.6. .. _news-3.3.3: 3.3.3 (2021-09-02) ================== - Add ``pre_confirmed`` and ``pre_approved`` parameters to ``MailingList.unsubscribe``. (Fixes :issue:`62`) - Add support to fetch pending unsubscription requests. (Closes :issue:`63`) - Add ``member_id`` as a property of ``Member`` object. (Closes :issue:`64`) - Return pending token when a Member is unsubscribed. (Closes :issue:`65`) - Allow specifying a reason when handling subscription requests (Closes :issue:`66`) - Add support to specify fields when fetching a roster. (Closes :issue:`67`) - Add a mechanism to hook into the request parameters. (Closes :issue:`68`) - Add basic support for async client for Mailman API. - Allow specifying ``delivery_mode`` and ``delivery_status`` when subscribing a Member. (Closes :issue:`78`) - Add a new ``Client.find_users`` API which allows searching for the users. (Closes :issue:`71`) - Add bounce parameters in Member resource. .. _news-3.3.2: 3.3.2 (2021-01-10) ================== - Add two new ``get_requests()`` and ``get_requests_count()`` to get pending subscription requests``MailingList.get_requests`` is the new API to fetch pending requests and supersedes the previous ``requests`` property. (See :pr:`121`) - Add ``Member.subscription_mode`` to determine if a User is subscribed or an Address. (See :pr:`121`) - Add a new ``get_held_count()`` API to get a count of held messages for a ``MailingList``. (See :pr:`122`) - Add ``display_name`` to the pending subscription requests. (Fixes :issue:`55`) - Allow setting a ``Member``'s ``address`` attribute. (See :pr:`128`) - Add support for inviting an email address to join a list. - Rewrite urls according to the ``baseurl`` used to instantiate ``Client`` instead of relying on ``self_link``. (Fixes :issue:`22`) - Add ``get_request`` API to MailingList to get individual request objects. - Add ``send_welcome_message`` parameter to MailingList.subscribe() to suppress welcome message. (Closes :issue:`61`) 3.3.1 (2020-06-01) ================== - Held message moderation now supports an optional keyword, ``reason`` to specify the reason to reject the message. (Closes :issue:`49`) - Fix a bug where missing ``display_name`` attribute with ``MalingList.subscribe`` would subscribe the user with a display name of "None". (Fixes :issue:`52`) - Add ``advertised`` flag to ``MailingList`` object. (See :pr:`115`) - ``MailingList.nonmembers`` now uses ``roster/nonmembers`` resource instead of the ``find/`` API for consistency. - Add ``Client.get_nonmember`` and ``MailingList.get_nonmember`` to get a non-member by address. (Fixes :issue:`47`) 3.3.0 (2019-09-03) ================== * Add a ``mail_host`` parameter to ``get_list_page`` and ``find_lists`` to support filtering the response by a list domain. * URL encode values in URL which are url unsafe. (Closes :issue:`44`) * Add support to mass unsubscribe memebrs from a Mailing List. (Closes :issue:`43`) * Add support to set a user's preferred address. (See :pr:`99`) * Add a new ``tag`` attribute to HeaderMatches and support to find a set of matches based on tag. 3.2.2 (2019-02-09) ================== 3.2.1 (2019-01-04) ================== * Add support for Python 3.7 * Add ``description`` as a property of ``MailingList``. Initially, this was a part of ``Preferences`` object, which would mean an additional API call to get the description of a Mailing List. (Closes :issue:`35`) * ``MailingList.get_members`` no longer requires ``address`` as a mandatory argument which allows searching for all memberships of of a particular role. Also, ``role`` no longer has a default argument, so that we can search for all memberships of an address. 3.2.0 (2018-07-10) ================== Changes ------- * Add '.pc' (patch directory) to list of ignored patterns when building the documentation with Sphinx. * `Mailinglist.add_owner` and `Mailinglist.add_moderator` now accept an additional `display_name` argument that allows associating display names with these memberships. * Add a new API ``Client.find_lists`` which allows filtering mailing lists related to a subscriber. It optionally allows a role, which filters the lists that the address is subscribed to with that role. Backwards Incompatible Changes ------------------------------- * `MailingList.owners` and `MailingList.moderators` now returns a list of `Member` objects instead of a list of emails. * `Domain.owners` now returns a list of `User` objects instead of just a dictionary of JSON response. (:pr:`63`) * Python 2.7 is no longer supported. 3.1.1 (2017-10-07) ================== * Python3 compatibility is fixed, mailmanclient is now compatible through Python2.7 - Python3.6 * Internal source code is now split into several class-specific modules as compared to previously a single giant _client module. * All the RestObjects, like MailingList, are now exposed from the top level import. * Old `mailmanclient._client` module is added back for compatibility with versions of Postorius that use some internal APIs. 3.1 (2017-05-25) ================ * Bug fixes. * Align with Mailman 3.1 Core REST API. * Python3 compatibility is broken because of a urllib bug. 1.0.1 (2015-11-14) ================== * Bugfix release. 1.0.0 (2015-04-17) ================== * Port to Python 3.4. * Run test suite with `tox`. * Use vcrpy for HTTP testing. * Add list archiver access. * Add subscription moderation 1.0.0a1 (2014-03-15) ==================== * Initial release. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672813297.0 mailmanclient-3.3.5/src/mailmanclient/docs/__init__.py0000644000076500000240000000000014355215361022360 0ustar00maxkingstaff././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1672820293.3879325 mailmanclient-3.3.5/src/mailmanclient/docs/api/0000755000076500000240000000000014355233105021026 5ustar00maxkingstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672813297.0 mailmanclient-3.3.5/src/mailmanclient/docs/api/__init__.py0000644000076500000240000000000014355215361023131 0ustar00maxkingstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672813297.0 mailmanclient-3.3.5/src/mailmanclient/docs/api/index.rst0000644000076500000240000000034614355215361022676 0ustar00maxkingstaffAPI Reference ============= This file will be overwritten by the pydoctor build triggered at the end of the Sphinx build. It's a hack to be able to reference the API index page from inside Sphinx and have it as part of the TOC. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672813297.0 mailmanclient-3.3.5/src/mailmanclient/docs/async.rst0000644000076500000240000000153214355215361022131 0ustar00maxkingstaff=================== Async API Reference =================== .. automodule:: mailmanclient.asynclient :members: :undoc-members: :inherited-members: .. autoclass:: mailmanclient.asyncobjects.domain.Domain :members: :undoc-members: :inherited-members: .. autoclass:: mailmanclient.asyncobjects.mailinglist.MailingList :members: :undoc-members: :inherited-members: .. autoclass:: mailmanclient.asyncobjects.member.Member :members: :undoc-members: :inherited-members: .. autoclass:: mailmanclient.asyncobjects.user.User :members: :undoc-members: :inherited-members: .. autoclass:: mailmanclient.asyncobjects.address.Address :members: :undoc-members: :inherited-members: .. autoclass:: mailmanclient.asyncobjects.preferences.Preferences :members: :undoc-members: :inherited-members: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/docs/conftest.py0000644000076500000240000000165214355230520022456 0ustar00maxkingstaff# Copyright (C) 2017-2023 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . """Wrappers for doctests to run with pytest""" import pytest from mailmanclient.testing.documentation import dump @pytest.fixture(autouse=True) def import_stuff(doctest_namespace): doctest_namespace['dump'] = dump ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672813297.0 mailmanclient-3.3.5/src/mailmanclient/docs/testing.rst0000644000076500000240000000153614355215361022475 0ustar00maxkingstaff======================== Developing MailmanClient ======================== Running Tests ============= The test suite is run with the `tox`_ tool, which allows it to be run against multiple versions of Python. The tests are discovered and run using `pytest`_. To run the test suite, run:: $ tox To run tests for only one version of Python, you can run:: $ tox -e py39 ``pytest`` starts Mailman Core using ``pytest-services`` plugin and automatically manages it's start and stop cycle for every module. .. note:: Previously, we used ``vcrpy`` and ``pytest-vcr`` packages to manage recorded tapes for interaction with Mailman Core. That was replaced with ``pytest-services`` plugin, which instead start Core for every test. .. _`tox`: https://testrun.org/tox/latest/ .. _`pytest`: https://docs.pytest.org/en/latest/ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672813297.0 mailmanclient-3.3.5/src/mailmanclient/docs/using.rst0000644000076500000240000011676314355215361022156 0ustar00maxkingstaff============= Example Usage ============= This is the official Python bindings for the GNU Mailman REST API. In order to talk to Mailman, the engine's REST server must be running. You begin by instantiating a client object to access the root of the REST hierarchy, providing it the base URL, user name and password (for Basic Auth). >>> from mailmanclient import Client >>> client = Client('http://localhost:9001/3.1', 'restadmin', 'restpass') .. note:: Please note that port '9001' is used above, since mailman's test server runs on port *9001*. In production Mailman's REST API usually listens on port *8001*. We can retrieve basic information about the server. >>> dump(client.system) api_version: 3.1 http_etag: "..." mailman_version: GNU Mailman ... (...) python_version: ... self_link: http://localhost:9001/3.1/system/versions .. note:: The content accessible via mailmanclient depends heavily on the version of the mailman core REST API it's connecting to. See `Core REST API docs`_ for more details. To start with, there are no known mailing lists. >>> client.lists [] Domains ======= Before new mailing lists can be added, the domain that the list will live in must be added. By default, there are no known domains. >>> client.domains [] It's easy to create a new domain; when you do, a proxy object for that domain is returned. >>> example_dot_com = client.create_domain('example.com') >>> print(example_dot_com.description) None >>> print(example_dot_com.mail_host) example.com >>> print(example_dot_com.alias_domain) None A domain can have an alias_domain attribute to help with some unusual Postfix configurations. >>> example_dot_edu = client.create_domain('example.edu', ... alias_domain='x.example.edu') >>> print(example_dot_edu.mail_host) example.edu >>> print(example_dot_edu.alias_domain) x.example.edu You can also get an existing domain independently using its mail host. >>> example = client.get_domain('example.com') >>> print(example.mail_host) example.com After creating a few more domains, we can print the list of all domains. >>> example_net = client.create_domain('example.net') >>> example_org = client.create_domain('example.org') >>> print(example_org.mail_host) example.org >>> for domain in client.domains: ... print(domain.mail_host) example.com example.edu example.net example.org Also, domain can be deleted. >>> example_org.delete() >>> for domain in client.domains: ... print(domain.mail_host) example.com example.edu example.net Mailing lists ============= Once you have a domain, you can create mailing lists in that domain. >>> test_one = example.create_list('test-1') >>> print(test_one.fqdn_listname) test-1@example.com >>> print(test_one.mail_host) example.com >>> print(test_one.list_name) test-1 >>> print(test_one.display_name) Test-1 You can create a mailing list with a specific list style. >>> test_two = example.create_list('test-announce', style_name='legacy-announce') >>> print(test_two.fqdn_listname) test-announce@example.com You can retrieve a list of known mailing list styles along with the default one. >>> styles = client.styles >>> from operator import itemgetter >>> for style in sorted(styles['styles'], key=itemgetter('name')): ... print('{0}: {1}'.format(style['name'], style['description'])) legacy-announce: Announce only mailing list style. legacy-default: Ordinary discussion mailing list style. private-default: Discussion mailing list style with private archives. >>> print(styles['default']) legacy-default You can also retrieve the mailing list after the fact. >>> my_list = client.get_list('test-1@example.com') >>> print(my_list.fqdn_listname) test-1@example.com And you can print all the known mailing lists. :: >>> print(example.create_list('test-2').fqdn_listname) test-2@example.com >>> domain = client.get_domain('example.net') >>> print(domain.create_list('test-3').fqdn_listname) test-3@example.net >>> print(example.create_list('test-3').fqdn_listname) test-3@example.com >>> for mlist in client.lists: ... print(mlist.fqdn_listname) test-1@example.com test-2@example.com test-3@example.com test-3@example.net test-announce@example.com You can also select advertised lists only. :: >>> my_list.settings['advertised'] = False >>> my_list.settings.save() >>> for mlist in client.get_lists(advertised=True): ... print(mlist.fqdn_listname) test-2@example.com test-3@example.com test-3@example.net test-announce@example.com List results can be retrieved as pages: >>> page = client.get_list_page(count=2, page=1) >>> page.nr 1 >>> len(page) 2 >>> page.total_size 5 >>> for m_list in page: ... print(m_list.fqdn_listname) test-1@example.com test-2@example.com >>> page = page.next >>> page.nr 2 >>> for m_list in page: ... print(m_list.fqdn_listname) test-3@example.com test-3@example.net Pages can also use the advertised filter: >>> page = client.get_list_page(count=2, page=1, advertised=True) >>> for m_list in page: ... print(m_list.fqdn_listname) test-2@example.com test-3@example.com Pages can also limit the results by domain: >>> page = client.get_list_page(mail_host='example.net') >>> for m_list in page: ... print(m_list.fqdn_listname) test-3@example.net You can also use the domain object if you only want to know all lists for a specific domain without pagination. >>> for mlist in example.lists: ... print(mlist.fqdn_listname) test-1@example.com test-2@example.com test-3@example.com test-announce@example.com It is also possible to display only advertised lists when using the domain. >>> for mlist in example.get_lists(advertised=True): ... print(mlist.fqdn_listname) test-2@example.com test-3@example.com test-announce@example.com >>> for mlist in example.get_list_page(count=2, page=1, advertised=True): ... print(mlist.fqdn_listname) test-2@example.com test-3@example.com You can use a list instance to delete the list. >>> test_three = client.get_list('test-3@example.net') >>> test_three.delete() You can also delete a list using the client instance's delete_list method. >>> client.delete_list('test-3@example.com') >>> for mlist in client.lists: ... print(mlist.fqdn_listname) test-1@example.com test-2@example.com test-announce@example.com Membership ========== Email addresses can subscribe to existing mailing lists, becoming members of that list. The address is a unique id for a specific user in the system, and a member is a user that is subscribed to a mailing list. Email addresses need not be pre-registered, though the auto-registered user will be unique for each email address. The system starts out with no members. >>> client.members [] New members can be easily added; users are automatically registered. :: >>> test_two = client.get_list('test-2@example.com') >>> print(test_two.settings['subscription_policy']) confirm Email addresses need to be verified first, so if we try to subscribe a user, we get a response with a token: >>> data = test_one.subscribe('unverified@example.com', 'Unverified') >>> data['token'] is not None True >>> print(data['token_owner']) subscriber We can also invite an email address to join the list. This will send an invitation email to the address for the user to accept. Here again we get a response with a token: >>> data = test_one.subscribe('invitee@example.com', ... 'Invitee', ... invitation=True) >>> data['token'] is not None True >>> print(data['token_owner']) subscriber If we know the email address to be valid, we can set the ``pre_verified`` flag. However, the list's subscription policy is "confirm", so if we try to subscribe a user, we will also get a token back: >>> data = test_one.subscribe('unconfirmed@example.com', ... 'Unconfirmed', ... pre_verified=True) >>> data['token'] is not None True >>> print(data['token_owner']) subscriber If we know the user originated the subscription (for example if she or he has been authenticated elsewhere), we can set the ``pre_confirmed`` flag. The ``pre_approved`` flag is used for lists that require moderator approval and should only be used if the subscription is initiated by a moderator or admin. >>> print(test_one.subscribe('anna@example.com', 'Anna', ... pre_verified=True, ... pre_confirmed=True)) Member "anna@example.com" on "test-1.example.com" >>> print(test_one.subscribe('bill@example.com', 'Bill', ... pre_verified=True, ... pre_confirmed=True)) Member "bill@example.com" on "test-1.example.com" >>> print(test_two.subscribe('anna@example.com', ... pre_verified=True, ... pre_confirmed=True)) Member "anna@example.com" on "test-2.example.com" >>> print(test_two.subscribe('cris@example.com', 'Cris', ... pre_verified=True, ... pre_confirmed=True)) Member "cris@example.com" on "test-2.example.com" We can retrieve all known memberships. These are sorted first by mailing list name, then by email address. >>> for member in client.members: ... print(member) Member "anna@example.com" on "test-1.example.com" Member "bill@example.com" on "test-1.example.com" Member "anna@example.com" on "test-2.example.com" Member "cris@example.com" on "test-2.example.com" We can also view the memberships for a single mailing list. >>> for member in test_one.members: ... print(member) Member "anna@example.com" on "test-1.example.com" Member "bill@example.com" on "test-1.example.com" Membership may have a name associated, this depends on whether the member ``Address`` or ``User`` has a ``display_name`` attribute. >>> for member in test_one.members: ... print(member.display_name) Anna Bill Membership lists can be paginated, to recieve only a part of the result. >>> page = client.get_member_page(count=2, page=1) >>> page.nr 1 >>> page.total_size 4 >>> for member in page: ... print(member) Member "anna@example.com" on "test-1.example.com" Member "bill@example.com" on "test-1.example.com" >>> page = page.next >>> page.nr 2 >>> for member in page: ... print(member) Member "anna@example.com" on "test-2.example.com" Member "cris@example.com" on "test-2.example.com" >>> page = test_one.get_member_page(count=1, page=1) >>> page.nr 1 >>> page.total_size 2 >>> for member in page: ... print(member) Member "anna@example.com" on "test-1.example.com" >>> page = page.next >>> page.nr 2 >>> page.total_size 2 >>> for member in page: ... print(member) Member "bill@example.com" on "test-1.example.com" We can get a single membership too. >>> cris_test_two = test_two.get_member('cris@example.com') >>> print(cris_test_two) Member "cris@example.com" on "test-2.example.com" >>> print(cris_test_two.role) member >>> print(cris_test_two.display_name) Cris A membership can also be retrieved without instantiating the list object first: >>> print(client.get_member('test-2@example.com', 'cris@example.com')) Member "cris@example.com" on "test-2.example.com" A membership has preferences. >>> prefs = cris_test_two.preferences >>> print(prefs['delivery_mode']) None >>> print(prefs['acknowledge_posts']) None >>> print(prefs['delivery_status']) None >>> print(prefs['hide_address']) None >>> print(prefs['preferred_language']) None >>> print(prefs['receive_list_copy']) None >>> print(prefs['receive_own_postings']) None The membership object's ``user`` attribute will return a User object: >>> cris_u = cris_test_two.user >>> print(cris_u.display_name, cris_u.user_id) Cris ... If you use an address which is not a member of test_two `ValueError` is raised: >>> from mailmanclient.testing.documentation import print_exception >>> with print_exception(): ... test_two.unsubscribe('nomember@example.com') ValueError: nomember@example.com is not a member address of test-2@example.com After a while, Anna decides to unsubscribe from the Test One mailing list, though she keeps her Test Two membership active. >>> import time >>> time.sleep(2) >>> test_one.unsubscribe('anna@example.com') >>> for member in client.members: ... print(member) Member "bill@example.com" on "test-1.example.com" Member "anna@example.com" on "test-2.example.com" Member "cris@example.com" on "test-2.example.com" A little later, Cris decides to unsubscribe from the Test Two mailing list. >>> cris_test_two.unsubscribe() >>> for member in client.members: ... print(member) Member "bill@example.com" on "test-1.example.com" Member "anna@example.com" on "test-2.example.com" If you try to unsubscribe an address which is not a member address `ValueError` is raised: >>> with print_exception(): ... test_one.unsubscribe('nomember@example.com') ValueError: nomember@example.com is not a member address of test-1@example.com If we want to mass unsubscribe users. >>> print(test_one.subscribe('jack@example.com', 'Jack', ... pre_verified=True, ... pre_confirmed=True)) Member "jack@example.com" on "test-1.example.com" >>> print(test_one.subscribe('jill@example.com', 'Jill', ... pre_verified=True, ... pre_confirmed=True)) Member "jill@example.com" on "test-1.example.com" >>> print(test_one.subscribe('hans@example.com', 'Hans', ... pre_verified=True, ... pre_confirmed=True)) Member "hans@example.com" on "test-1.example.com" >>> email_list = ['jack@example.com','hans@example.com','jill@example.com','bully@example.com'] >>> ();test_one.mass_unsubscribe(email_list);() # doctest: +ELLIPSIS (...) >>> for member in test_one.members: ... print(member) Member "bill@example.com" on "test-1.example.com" Non-Members =========== When someone attempts to post to a list but is not a member, then they are listed as a "non-member" of that list so that a moderator can choose how to handle their messages going forward. In some cases, one might wish to accept or reject their future messages automatically. Just like with regular members, they are given a unique id. The list starts out with no nonmembers. >>> test_one.nonmembers [] When someone tries to send a message to the list and they are not a subscriber, they get added to the nonmember list. Users ===== Users are people with one or more list memberships. To get a list of all users, access the clients user property. >>> for user in client.users: ... print(user.display_name) Unverified Invitee Unconfirmed Anna Bill Cris Jack Jill Hans The list of users can also be paginated: >>> page = client.get_user_page(count=4, page=1) >>> page.nr 1 >>> page.total_size 9 >>> for user in page: ... print(user.display_name) Unverified Invitee Unconfirmed Anna You can get the next or previous pages without calling ``get_userpage`` again. >>> page = page.next >>> page.nr 2 >>> for user in page: ... print(user.display_name) Bill Cris Jack Jill >>> page = page.previous >>> page.nr 1 >>> for user in page: ... print(user.display_name) Unverified Invitee Unconfirmed Anna A single user can be retrieved using their email address. >>> cris = client.get_user('cris@example.com') >>> print(cris.display_name) Cris Every user has a list of one or more addresses. >>> for address in cris.addresses: ... print(address) ... print(address.display_name) ... print(address.registered_on) cris@example.com Cris ... Multiple addresses can be assigned to a user record: >>> print(cris.add_address('cris.person@example.org')) cris.person@example.org >>> print(client.get_address('cris.person@example.org')) cris.person@example.org >>> for address in cris.addresses: ... print(address) cris.person@example.org cris@example.com Trying to add an existing address will raise an error: >>> dana = client.create_user(email='dana@example.org', ... password='somepass', ... display_name='Dana') >>> print(dana.display_name) Dana >>> with print_exception(): ... cris.add_address('dana@example.org') # doctest: +IGNORE_EXCEPTION_DETAIL HTTPError: HTTP Error 400: Address belongs to other user This can be overridden by using the ``absorb_existing`` flag: >>> print(cris.add_address('dana@example.org', absorb_existing=True)) dana@example.org The user Chris will then be merged with Dana, acquiring all its subscriptions and preferences. In case of conflict, Chris' original preferences will prevail. >>> for address in cris.addresses: ... print(address) cris.person@example.org cris@example.com dana@example.org Users can have one preferred address, which they can use for subscriptions. By default, a User has no preferred address. >>> print(cris.preferred_address) None A User can have a preferred address, but before that, the address needs to be verified:: >>> address = client.get_address('cris.person@example.org') >>> address.verify() >>> print(address.verified) True >>> cris.preferred_address = 'cris.person@example.org' >>> print(cris.preferred_address) cris.person@example.org A User can change their preferred address. >>> cris.preferred_address = 'cris@example.com' >>> print(cris.preferred_address) cris@example.com A User can also unset their preferred address by setting it to ``None``. >>> cris.preferred_address = None >>> print(cris.preferred_address) None Addresses ========= Addresses can be accessed directly: >>> address = client.get_address('dana@example.org') >>> print(address) dana@example.org >>> print(address.display_name) Dana The address has not been verified: >>> print(address.verified) False But that can be done via the address object: >>> address.verify() >>> print(address.verified) True It can also be unverified: >>> address.unverify() >>> print(address.verified) False Addresses can be deleted by calling their ``delete()`` method or by removing them from their user's ``addresses`` list: >>> cris.addresses.remove('dana@example.org') >>> for address in cris.addresses: ... print(address) cris.person@example.org cris@example.com Users can be added using ``create_user``. The display_name is optional: >>> ler = client.create_user(email='ler@primus.org', ... password='somepass', ... display_name='Ler') >>> print(ler.display_name) Ler >>> ler = client.get_user('ler@primus.org') >>> print(ler.password) $... >>> print(ler.display_name) Ler User attributes can be changed through assignment, but you need to call the object's ``save`` method to store the changes in the mailman core database. >>> ler.display_name = 'Sir Ler' >>> ler.save() >>> ler = client.get_user('ler@primus.org') >>> print(ler.display_name) Sir Ler Passwords can be changed as well: >>> old_pwd = ler.password >>> ler.password = 'easy' >>> old_pwd == ler.password True >>> ler.save() >>> old_pwd == ler.password False User Subscriptions ------------------ A User's subscriptions can be accessed through the User's ``subscriptions`` property. >>> bill = client.get_user('bill@example.com') >>> for subscription in bill.subscriptions: ... print(subscription) Member "bill@example.com" on "test-1.example.com" If all you need are the list ids of all mailing lists a user is subscribed to, you can use the ``subscription_list_ids`` property. >>> for list_id in bill.subscription_list_ids: ... print(list_id) test-1.example.com List Settings ============= We can get all list settings via a lists settings attribute. A proxy object for the settings is returned which behaves much like a dictionary. >>> settings = test_one.settings >>> for attr in sorted(settings): ... print(attr + ': ' + str(settings[attr])) accept_these_nonmembers: [] acceptable_aliases: [] ... volume: 1 >>> print(settings['display_name']) Test-1 We can access all valid list settings as attributes. >>> print(settings['fqdn_listname']) test-1@example.com >>> print(settings['description']) >>> settings['description'] = 'A very meaningful description.' >>> settings['display_name'] = 'Test Numero Uno' >>> settings.save() >>> settings_new = test_one.settings >>> print(settings_new['description']) A very meaningful description. >>> print(settings_new['display_name']) Test Numero Uno The settings object also supports the `get` method of usual Python dictionaries: >>> print(settings_new.get('OhNoIForgotTheKey', ... 'HowGoodIPlacedOneUnderTheDoormat')) HowGoodIPlacedOneUnderTheDoormat Preferences =========== Preferences can be accessed and set for users, members and addresses. By default, preferences are not set and fall back to the global system preferences. They're read-only and can be accessed through the client object. >>> global_prefs = client.preferences >>> print(global_prefs['acknowledge_posts']) False >>> print(global_prefs['delivery_mode']) regular >>> print(global_prefs['delivery_status']) enabled >>> print(global_prefs['hide_address']) True >>> print(global_prefs['preferred_language']) en >>> print(global_prefs['receive_list_copy']) True >>> print(global_prefs['receive_own_postings']) True Preferences can be set, but you have to call ``save`` to make your changes permanent. >>> prefs = test_two.get_member('anna@example.com').preferences >>> prefs['delivery_status'] = 'by_user' >>> prefs.save() >>> prefs = test_two.get_member('anna@example.com').preferences >>> print(prefs['delivery_status']) by_user Pipelines and Chains ==================== The available pipelines and chains can also be retrieved: >>> pipelines = client.pipelines['pipelines'] >>> for pipeline in pipelines: ... print(pipeline) default-owner-pipeline default-posting-pipeline virgin >>> chains = client.chains['chains'] >>> for chain in chains: ... print(chain) accept default-owner-chain default-posting-chain discard dmarc header-match hold moderation reject Owners and Moderators ===================== Owners and moderators are properties of the list object. >>> test_one.owners [] >>> test_one.moderators [] Owners can be added via the ``add_owner`` method and they can have an optional ``display_name`` associated like other ``members``: >>> test_one.add_owner('foo@example.com', display_name='Foo') >>> for owner in test_one.owners: ... print(owner.email) foo@example.com The owner of the list not automatically added as a member: >>> for m in test_one.members: ... print(m) Member "bill@example.com" on "test-1.example.com" Moderators can be added similarly: >>> test_one.add_moderator('bar@example.com', display_name='Bar') >>> for moderator in test_one.moderators: ... print(moderator.email) bar@example.com Moderators are also not automatically added as members: >>> for m in test_one.members: ... print(m) Member "bill@example.com" on "test-1.example.com" Members and owners/moderators are separate entries in the general members list: >>> print(test_one.subscribe('bar@example.com', 'Bar', ... pre_verified=True, ... pre_confirmed=True)) Member "bar@example.com" on "test-1.example.com" >>> test_four_net = example_net.create_list('test-4') >>> test_four_net.add_owner('foo@example.com', display_name='Foo') >>> for member in client.members: ... print('%s: %s' % (member, member.role)) Member "foo@example.com" on "test-1.example.com": owner Member "bar@example.com" on "test-1.example.com": moderator Member "bar@example.com" on "test-1.example.com": member Member "bill@example.com" on "test-1.example.com": member Member "anna@example.com" on "test-2.example.com": member Member "foo@example.com" on "test-4.example.net": owner You can find the lists that a user is a member, moderator, or owner of: >>> lists = client.find_lists('bill@example.com', 'member') >>> for m_list in lists: ... print(m_list.fqdn_listname) test-1@example.com >>> lists = client.find_lists('bar@example.com', 'moderator') >>> for m_list in lists: ... print(m_list.fqdn_listname) test-1@example.com >>> lists = client.find_lists('foo@example.com', 'owner') >>> for m_list in lists: ... print(m_list.fqdn_listname) test-1@example.com test-4@example.net You can also filter those results by domain: >>> lists = client.find_lists('foo@example.com', 'owner', ... mail_host='example.net') >>> for m_list in lists: ... print(m_list.fqdn_listname) test-4@example.net Both owners and moderators can be removed: >>> test_one.remove_owner('foo@example.com') >>> test_one.owners [] test_one.remove_moderator('bar@example.com') test_one.moderators [] Moderation ========== Subscription Moderation ----------------------- Subscription requests can be accessed through the list object's `request` property. So let's create a non-open list first. >>> confirm_first = example_dot_com.create_list('confirm-first') >>> settings = confirm_first.settings >>> settings['subscription_policy'] = 'confirm_then_moderate' >>> settings.save() >>> confirm_first = client.get_list('confirm-first.example.com') >>> print(confirm_first.settings['subscription_policy']) confirm_then_moderate Initially there are no requests, so let's subscribe someone to the list. We'll get a token back. >>> confirm_first.requests [] >>> data = confirm_first.subscribe('groucho@example.com', ... pre_verified=True, ... pre_confirmed=True) >>> print(data['token_owner']) moderator Now the request shows up in the list of requests: >>> import time; time.sleep(5) >>> confirm_first.get_requests_count() 1 >>> request_1 = confirm_first.requests[0] >>> print(request_1['email']) groucho@example.com >>> print (request_1['token'] is not None) True >>> print(request_1['token_owner']) moderator >>> print(request_1['request_date'] is not None) True >>> print(request_1['list_id']) confirm-first.example.com It is possible to filter subscription requests based on who is it pending an action from using ``token_owner`` parameter:: >>> data = confirm_first.subscribe('harpo@example.com', ... pre_verified=True, ... pre_confirmed=True) >>> data = confirm_first.subscribe('zeppo@example.com', ... pre_verified=True, ... pre_confirmed=False) >>> confirm_first.get_requests_count() 3 Possible values for ``token_owner`` include: - ``subscriber`` - ``moderator`` - ``no_one`` All these are pending an approval from moderator:: >>> confirm_first.get_requests_count(token_owner='moderator') 2 Subscriptions which aren't ``pre_confirmed`` first require confirmation from the user if list's subscription policy is ``confirm`` or ``confirm_then_moderate``:: >>> confirm_first.get_requests_count(token_owner='subscriber') 1 >>> data = confirm_first.get_requests(token_owner='subscriber')[0] >>> print(data['email']) zeppo@example.com Subscription requests can be accepted, deferred, rejected or discarded using the request token. Let's accept Groucho: >>> response = confirm_first.moderate_request(request_1['token'], 'accept') >>> confirm_first.get_requests_count() 2 >>> request_2 = confirm_first.requests[0] >>> print(request_2['email']) harpo@example.com >>> request_3 = confirm_first.requests[1] >>> print(request_3['email']) zeppo@example.com Let's reject Harpo: >>> response = confirm_first.moderate_request(request_2['token'], 'reject') >>> confirm_first.get_requests_count() 1 Let's discard Zeppo's request: >>> response = confirm_first.moderate_request(request_3['token'], 'discard') >>> confirm_first.get_requests_count() 0 Message Moderation ------------------ By injecting a message by a non-member into the incoming queue, we can simulate a message being held for moderator approval. >>> msg = """From: nomember@example.com ... To: test-1@example.com ... Subject: Something ... Message-ID: ... ... Some text. ... ... """ >>> inq = client.queues['in'] >>> inq.inject('test-1.example.com', msg) Now wait until the message has been processed. >>> while True: ... if len(inq.files) == 0: ... break ... time.sleep(0.1) It might take a few moments for the message to show up in the moderation queue. >>> while True: ... if test_one.get_held_count() > 0: ... break ... time.sleep(0.1) Messages held for moderation can be listed on a per list basis. >>> all_held = test_one.held >>> print(all_held[0].request_id) 1 A held message can be retrieved by ID, and have attributes: >>> heldmsg = test_one.get_held_message(1) >>> print(heldmsg.subject) Something >>> print(heldmsg.reason) The message is not from a list member >>> print(heldmsg.sender) nomember@example.com >>> 'Message-ID: ' in heldmsg.msg True A moderation action can be taken on them using the list methods or the held message's methods. >>> print(test_one.defer_message(heldmsg.request_id).status_code) 204 >>> len(test_one.held) 1 >>> print(heldmsg.discard().status_code) 204 >>> len(test_one.held) 0 Member moderation ----------------- Each member or non-member can have a specific moderation action. It is set using the 'moderation_action' property: >>> bill_member = test_one.get_member('bill@example.com') >>> print(bill_member.moderation_action) None >>> bill_member.moderation_action = 'hold' >>> bill_member.save() >>> print(test_one.get_member('bill@example.com').moderation_action) hold Banning addresses ----------------- A ban list is a list of email addresses that are not allowed to subscribe to a mailing-list. There are two types of ban lists: each mailing-list has its ban list, and there is a site-wide list. Addresses on the site-wide list are prevented from subscribing to every mailing-list on the server. To view the site-wide ban list, use the `bans` property:: >>> list(client.bans) [] You can use the `add` method on the ban list to ban an email address:: >>> banned_anna = client.bans.add('anna@example.com') >>> print(banned_anna) anna@example.com >>> 'anna@example.com' in client.bans True >>> print(client.bans.add('bill@example.com')) bill@example.com >>> for addr in list(client.bans): ... print(addr) anna@example.com bill@example.com The list of banned addresses can be paginated using the ``get_bans_page()`` method:: >>> for addr in list(client.get_bans_page(count=1, page=1)): ... print(addr) anna@example.com >>> for addr in list(client.get_bans_page(count=1, page=2)): ... print(addr) bill@example.com You can use the ``delete()`` method on a banned address to unban it, or the ``remove()`` method on the ban list:: >>> banned_anna.delete() >>> 'anna@example.com' in client.bans False >>> for addr in list(client.bans): ... print(addr) bill@example.com >>> client.bans.remove('bill@example.com') >>> 'bill@example.com' in client.bans False >>> print(list(client.bans)) [] The mailing-list-specific ban lists work in the same way:: >>> print(list(test_one.bans)) [] >>> banned_anna = test_one.bans.add('anna@example.com') >>> 'anna@example.com' in test_one.bans True >>> print(test_one.bans.add('bill@example.com')) bill@example.com >>> for addr in list(test_one.bans): ... print(addr) anna@example.com bill@example.com >>> for addr in list(test_one.get_bans_page(count=1, page=1)): ... print(addr) anna@example.com >>> for addr in list(test_one.get_bans_page(count=1, page=2)): ... print(addr) bill@example.com >>> banned_anna.delete() >>> 'anna@example.com' in test_one.bans False >>> test_one.bans.remove('bill@example.com') >>> print(list(test_one.bans)) [] Archivers ========= Each list object has an ``archivers`` attribute. >>> archivers = test_one.archivers >>> print(archivers) Archivers on test-1.example.com The activation status of each available archiver can be accessed like a key in a dictionary. >>> archivers = test_one.archivers >>> for archiver in sorted(archivers.keys()): ... print('{0}: {1}'.format(archiver, archivers[archiver])) mail-archive: True mhonarc: True prototype: True >>> archivers['mail-archive'] True >>> archivers['mhonarc'] True They can also be set like items in dictionary. >>> archivers['mail-archive'] = False >>> archivers['mhonarc'] = False So if we get a new ``archivers`` object from the API (by accessing the list's archiver attribute again), we can see that the archiver stati have now been set. >>> archivers = test_one.archivers >>> archivers['mail-archive'] False >>> archivers['mhonarc'] False Header matches ============== Header matches are filtering rules that apply to messages sent to a mailing list. They match a header to a pattern using a regular expression, and matching patterns can trigger specific moderation actions. They are accessible via the mailing list's ``header_matches`` attribute, which behaves like a list. >>> header_matches = test_one.header_matches >>> print(header_matches) Header matches for "test-1.example.com" >>> len(header_matches) 0 Header matches can be added using the ``add()`` method. The arguments are: - the header to consider (``str``). Il will be lower-cased. - the regular expression to use for filtering (``str``) - the action to take when the header matches the pattern. This can be ``'accept'``, ``'discard'``, ``'reject'``, or ``'hold'``. - the tag (``str``) to group a set of header matches. >>> print(header_matches.add('Subject', '^test: ', 'discard', 'sometag')) Header match on "subject" >>> print(header_matches) Header matches for "test-1.example.com" >>> len(header_matches) 1 >>> for hm in list(header_matches): ... print(hm) Header match on "subject" Header matches can be filtered using ``.find()`` method to query a set of HeaderMatches:: >>> header_matches.find(tag='sometag') [] You can delete a header match by deleting it from the ``header_matches`` collection. >>> del header_matches[0] >>> len(header_matches) 0 You can also delete a header match using its ``delete()`` method, but be aware that the collection will not automatically be updated. Get a new collection from the list's ``header_matches`` attribute to see the change. >>> print(header_matches.add('Subject', '^test: ', 'discard')) Header match on "subject" >>> header_matches[0].delete() >>> len(header_matches) # not automatically updated 1 >>> len(test_one.header_matches) 0 Configuration ============= Mailman Core exposes all its configuration through REST API. All these configuration options are read-only. >>> cfg = client.configuration >>> for key in sorted(cfg): ... print(cfg[key].name) ARC antispam archiver.mail_archive archiver.master archiver.mhonarc archiver.prototype bounces database devmode digests dmarc language.ar language.ast language.bg language.ca language.cs language.da language.de language.el language.en language.es language.et language.eu language.fi language.fr language.gl language.he language.hr language.hu language.ia language.it language.ja language.ko language.lt language.nl language.no language.pl language.pt language.pt_BR language.ro language.ru language.sk language.sl language.sr language.sv language.tr language.uk language.vi language.zh_CN language.zh_TW logging.archiver logging.bounce logging.config logging.database logging.debug logging.error logging.fromusenet logging.http logging.locks logging.mischief logging.plugins logging.root logging.runner logging.smtp logging.subscribe logging.task logging.vette mailman mta nntp passwords paths.dev paths.fhs paths.here paths.local plugin.master runner.archive runner.bad runner.bounces runner.command runner.digest runner.in runner.lmtp runner.nntp runner.out runner.pipeline runner.rest runner.retry runner.shunt runner.task runner.virgin shell styles webservice Each configuration object is a dictionary and you can iterate over them :: >>> for key in sorted(cfg['mailman']): ... print('{} : {}'.format(key, cfg['mailman'][key])) anonymous_list_keep_headers : ... cache_life : 7d check_max_size_on_filtered_message : no default_language : en email_commands_max_lines : 10 filter_report : no filtered_messages_are_preservable : no hold_digest : no html_to_plain_text_command : /usr/bin/lynx -dump $filename layout : here listname_chars : [-_.0-9a-z] masthead_threshold : 4 moderator_request_life : 180d noreply_address : noreply pending_request_life : 3d post_hook : pre_hook : run_tasks_every : 1h self_link : http://localhost:9001/3.1/system/configuration/mailman sender_headers : from from_ reply-to sender site_owner : changeme@example.com .. >>> for domain in client.domains: ... domain.delete() >>> for user in client.users: ... user.delete() .. _`Core REST API docs`: https://docs.mailman3.org/projects/mailman/en/latest/src/mailman/rest/docs/rest.html ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1672820293.3887568 mailmanclient-3.3.5/src/mailmanclient/restbase/0000755000076500000240000000000014355233105021135 5ustar00maxkingstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672813297.0 mailmanclient-3.3.5/src/mailmanclient/restbase/__init__.py0000644000076500000240000000000014355215361023240 0ustar00maxkingstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/restbase/async_base.py0000644000076500000240000000724114355230520023620 0ustar00maxkingstaff# Copyright (C) 2021-2023 by the Free Software Foundation, Inc. # # This file is part of mailman.client. # # mailman.client is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailman.client is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailman.client. If not, see . """Base classes for async objects.""" __all__ = [ 'RESTBase', 'RESTObject', ] from typing import Sequence, Any, Tuple from mailmanclient.restobjects.types import ( ConnectionProto, ResponseType, ContentType) class RESTBase: _properties: Sequence[str] = ['self_link'] _writable_properties: Sequence[str] = [] _read_only_properties: Sequence[str] = ['self_link'] def __init__(self, connection: ConnectionProto, data: ContentType, ) -> None: """ :param connection: API connection object to fetch sub-resources. :param data: Data from REST API. """ self._data = data self._connection = connection self._changed_rest_data = {} def __repr__(self) -> str: """Provide a default repr for all object types.""" return '<{} at {}>'.format(self.__class__, self._data.get('self_link')) def _get(self, key: str) -> str: """Get the value of 'key' from object's REST data. :param key: The key to get value for. """ if self._properties is not None: # Some REST key/values may not be returned by Mailman if the value # is None. if key in self._data: return self._data.get(key) raise KeyError(key) else: return self._data.get(key) def _set(self, key: str, value: Any) -> None: if (key in self._read_only_properties or ( self._writable_properties is not None and key not in self._writable_properties)): raise ValueError(f'{key} is read-only') # Don't check that the key is in _properties, the accepted values for # write may be different from the returned values (eg: User.password # and User.cleartext_password). if self._data.get(key, None) == value: return self._changed_rest_data[key] = value def _reset_cache(self) -> None: self._changed_rest_data = {} self._data = None async def save(self) -> Tuple[ResponseType, ContentType]: res = await self._connection.call( self._data.get('self_link'), self._changed_rest_data, method='PATCH' ) self._reset_cache() return res class RESTObject(RESTBase): def __getattr__(self, name) -> Any: try: return self._get(name) except KeyError: raise AttributeError( '"{}" has no attribute "{}"'.format( self.__class__.__name__, name)) def __setattr__(self, name: str, value: Any): if self._properties and (name not in self._properties): return super().__setattr__(name, value) return self._set(name, value) async def delete(self) -> Tuple[ResponseType, ContentType]: res = await self.connection.call( self._data.get('self_link'), method='DELETE') self._reset_cache() return res ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/restbase/async_connection.py0000644000076500000240000000370514355230520025046 0ustar00maxkingstaff# Copyright (C) 2021-2023 by the Free Software Foundation, Inc. # # This file is part of mailman.client. # # mailman.client is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailman.client is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailman.client. If not, see . """Async connection object.""" __all__ = [ 'Connection', ] from urllib.error import HTTPError from mailmanclient.restbase.connection import Connection as BaseConnection class Connection(BaseConnection): """A standard Connection object. This is an abstraction over HTTP connections for Mailmanclient. It can be initialized with any http client library with and async `request` method. The paramters are currently tailored for httpx, but if there are folks interested in others, it is easy to provide a wrapper which accept such parameters. :param client: The http client object with ``request`` method. """ def __init__(self, client, *args, **kw) -> None: self.client = client super().__init__(*args, **kw) async def call(self, path, data=None, method=None): params = self._prepare_request( path, data, method ) response = await self.client.request(auth=self.auth, **params) if response.status_code // 100 != 2: raise HTTPError(params.get('url'), response.status_code, response.content, None, None) if len(response.content) == 0: return response, None return response, response.json() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/restbase/base.py0000644000076500000240000001575014355230520022427 0ustar00maxkingstaff# Copyright (C) 2010-2023 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . from collections.abc import MutableMapping, Sequence __metaclass__ = type __all__ = [ 'RESTBase', 'RESTDict', 'RESTList', 'RESTObject' ] class RESTBase: """ Base class for data coming from the REST API. Subclasses can (and sometimes must) define some attributes to handle a particular entity. :cvar _properties: the list of expected entity properties. This is required for API elements that behave like an object, with REST data accessed through attributes. If this value is None, the REST data is used to list available properties. :cvar _writable_properties: list of properties that can be written to using a `PATCH` request. If this value is `None`, all properties are writable. :cvar _read_only_properties: list of properties that cannot be written to (defaults to `self_link` only). :cvar _autosave: automatically send a `PATCH` request to the API when a value is changed. Otherwise, the `save()` method must be called. """ _properties = None _writable_properties = None _read_only_properties = ['self_link'] _autosave = False def __init__(self, connection, url, data=None): """ :param connection: An API connection object. :type connection: Connection. :param url: The url of the API endpoint. :type url: str. :param data: The initial data to use. :type data: dict. """ self._connection = connection self._url = url self._rest_data = data self._changed_rest_data = {} def __repr__(self): return '<{0} at {1}>'.format(self.__class__.__name__, self._url) @property def rest_data(self): """Get data from API and cache it (only once per instance).""" if self._rest_data is None: response, content = self._connection.call(self._url) if isinstance(content, dict) and 'http_etag' in content: del content['http_etag'] # We don't care about etags. self._rest_data = content return self._rest_data def _get(self, key): if self._properties is not None: # Some REST key/values may not be returned by Mailman if the value # is None. if key in self._properties: return self.rest_data.get(key) raise KeyError(key) else: return self.rest_data[key] def _set(self, key, value): if (key in self._read_only_properties or ( self._writable_properties is not None and key not in self._writable_properties)): raise ValueError('value is read-only') # Don't check that the key is in _properties, the accepted values for # write may be different from the returned values (eg: User.password # and User.cleartext_password). if key in self.rest_data and self.rest_data[key] == value: return # Nothing to do self._changed_rest_data[key] = value if self._autosave: self.save() def _reset_cache(self): self._changed_rest_data = {} self._rest_data = None def save(self): response, content = self._connection.call( self._url, self._changed_rest_data, method='PATCH') self._reset_cache() class RESTObject(RESTBase): """Base class for REST data that behaves like an object with attributes.""" def __getattr__(self, name): try: return self._get(name) except KeyError: # Transform the KeyError into the more appropriate AttributeError raise AttributeError( "'{0}' object has no attribute '{1}'".format( self.__class__.__name__, name)) def __setattr__(self, name, value): # RESTObject must list REST-specific properties or we won't be able to # store the _connection, _url, etc. assert self._properties is not None if name not in self._properties: return super(RESTObject, self).__setattr__(name, value) return self._set(name, value) def delete(self): self._connection.call(self._url, method='DELETE') self._reset_cache() class RESTDict(RESTBase, MutableMapping): """Base class for REST data that behaves like a dictionary.""" def __repr__(self): return repr(self.rest_data) def __getitem__(self, key): return self._get(key) def __setitem__(self, key, value): self._set(key, value) def __delitem__(self, key): raise NotImplementedError("REST dictionnary keys can't be deleted.") def __iter__(self): for key in self.rest_data: if self._properties is not None and key not in self._properties: continue yield key def __len__(self): return len(self.rest_data) def get(self, key, default=None): return self.rest_data.get(key, default) def keys(self): return list(self) def update(self, other): # Optimize the update to call save() only once _old_autosave = self._autosave self._autosave = False super(RESTDict, self).update(other) self._autosave = _old_autosave if self._autosave: self.save() class RESTList(RESTBase, Sequence): """ Base class for REST data that behaves like a list. The `_factory` attribute is a callable that will be applied on each returned member of the list. """ _factory = lambda x: x # noqa: E731 @property def rest_data(self): if self._rest_data is None: response, content = self._connection.call(self._url) if 'entries' not in content: self._rest_data = [] else: self._rest_data = content['entries'] return self._rest_data def __repr__(self): return repr(self.rest_data) def __getitem__(self, key): return self._factory(self.rest_data[key]) def __delitem__(self, key): self[key].delete() self._reset_cache() def __len__(self): return len(self.rest_data) def __iter__(self): for entry in self.rest_data: yield self._factory(entry) def clear(self): self._connection.call(self._url, method='DELETE') self._reset_cache() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/restbase/connection.py0000644000076500000240000001460414355230520023651 0ustar00maxkingstaff# Copyright (C) 2010-2023 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . from urllib.error import HTTPError from urllib.parse import urljoin, urlencode, urlparse, urlunparse from requests import request from mailmanclient.constants import __version__ __metaclass__ = type __all__ = [ 'MailmanConnectionError', 'Connection' ] class MailmanConnectionError(Exception): """Custom Exception to catch connection errors.""" class Connection: """A connection to the REST client.""" def __init__(self, baseurl, name=None, password=None, request_hooks=None): """Initialize a connection to the REST API. :param baseurl: The base url to access the Mailman 3 REST API. :param name: The Basic Auth user name. If given, the `password` must also be given. :param password: The Basic Auth password. If given the `name` must also be given. :param request_hooks: A list of callables that can receive the request parameters and return them with some changes or unchanged. """ if baseurl[-1] != '/': baseurl += '/' self.baseurl = baseurl self.name = name self.password = password if name is not None and password is None: raise TypeError('`password` is required when `name` is given') if name is None and password is not None: raise TypeError('`name` is required when `password` is given') if name is None: self.auth = None else: self.auth = (name, password) self.request_hooks = request_hooks def add_hooks(self, request_hooks): """Add a list of hooks to an existing connection object. :param request_hooks: A list of Request hook which receive the request parameters. :type request_hooks: List[callables] """ if self.request_hooks is None: self.request_hooks = request_hooks else: self.request_hooks.extend(request_hooks) def rewrite_url(self, url): """rewrite url component with self.baseurl prefix "scheme://netloc" :param url: the URL to rewrite :type url: str :return: modified URL :rtype: str """ # rewrite url component with self.baseurl prefix "scheme://netloc" pbaseurl = urlparse(self.baseurl) parsed = urlparse(url) parsed = parsed._replace(scheme=pbaseurl.scheme, netloc=pbaseurl.netloc) return urlunparse(parsed) def _process_request_hooks(self, params): """Given the request parameters, pass them through the list of hooks. Hooks are simple callables that are provided with request parameters and return the same parameters, possibly with some modification or not. :param params: The HTTP request parameters. :returns: The HTTP request parameters. """ for hook in self.request_hooks: try: params = hook(params) except Exception: print('[DEBUG] Failed to run hook {hook}') return params def _prepare_request(self, path, data, method): headers = { 'User-Agent': 'GNU Mailman REST client v{0}'.format(__version__), } data_str = None if data is not None: data_str = urlencode(data, doseq=True, encoding='utf-8') headers['Content-Type'] = 'application/x-www-form-urlencoded' if method is None: if data_str is None: method = 'GET' else: method = 'POST' method = method.upper() url = urljoin(self.baseurl, path) url = self.rewrite_url(url) return dict(url=url, method=method, data=data_str, headers=headers) def call(self, path, data=None, method=None): """Make a call to the Mailman REST API. :param path: The url path to the resource. :type path: str :param data: Data to send, implies POST (default) or PUT. :type data: dict :param method: The HTTP method to call. Defaults to GET when `data` is None or POST if `data` is given. :type method: str :return: The response content, which will be None, a dictionary, or a list depending on the actual JSON type returned. :rtype: None, list, dict :raises HTTPError: when a non-2xx status code is returned. """ params = self._prepare_request(path, data, method) if self.request_hooks: params = self._process_request_hooks(params) try: response = request(**params, auth=self.auth) # content = response.content # If we did not get a 2xx status code, make this look like a # urllib2 exception, for backward compatibility. if response.status_code // 100 != 2: try: err = response.json() # If this fails, a ValueError is raised. It means either # the response is malformed JSON or None. error_msg = err['description'] # This can fail if the error message does not container # description field. except (KeyError, ValueError): error_msg = response.text raise HTTPError(params.get('url'), response.status_code, error_msg, response, None) if len(response.content) == 0: return response, None return response, response.json() except HTTPError: raise except IOError as e: raise MailmanConnectionError( 'Could not connect to Mailman API: ', repr(e)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/restbase/page.py0000644000076500000240000000526314355230520022427 0ustar00maxkingstaff# Copyright (C) 2010-2023 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . from urllib.parse import urlencode, urlsplit, parse_qs, urlunsplit from mailmanclient.constants import DEFAULT_PAGE_ITEM_COUNT __metaclass__ = type __all__ = [ 'Page' ] class Page: def __init__(self, connection, path, model, count=DEFAULT_PAGE_ITEM_COUNT, page=1): self._connection = connection self._path = path self._count = count self._page = page self._model = model self._entries = [] self.total_size = 0 self._create_page() def __getitem__(self, key): return self._entries[key] def __iter__(self): for entry in self._entries: yield entry def __repr__(self): return ' 1 @property def has_next(self): return self._count * self._page < self.total_size ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1672820293.393944 mailmanclient-3.3.5/src/mailmanclient/restobjects/0000755000076500000240000000000014355233105021654 5ustar00maxkingstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672813297.0 mailmanclient-3.3.5/src/mailmanclient/restobjects/__init__.py0000644000076500000240000000000014355215361023757 0ustar00maxkingstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/restobjects/address.py0000644000076500000240000000505614355230520023657 0ustar00maxkingstaff# Copyright (C) 2010-2023 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . from urllib.parse import quote_plus from mailmanclient.restobjects.preferences import PreferencesMixin from mailmanclient.restbase.base import RESTList, RESTObject __metaclass__ = type __all__ = [ 'Address', 'Addresses' ] class Addresses(RESTList): def __init__(self, connection, url, data=None): super(Addresses, self).__init__(connection, url, data) self._factory = lambda data: Address( self._connection, data['self_link'], data) def find_by_email(self, email): for address in self: if address.email == email: return address return None def remove(self, email): address = self.find_by_email(email) if address is not None: address.delete() self._reset_cache() else: raise ValueError('The address {} does not exist'.format(email)) class Address(RESTObject, PreferencesMixin): _properties = ('display_name', 'email', 'original_email', 'registered_on', 'self_link', 'verified_on') def __repr__(self): return '
'.format(self.email) def __str__(self): return self.email @property def user(self): from mailmanclient.restobjects.user import User if 'user' in self.rest_data: return User(self._connection, self.rest_data['user']) else: return None @property def verified(self): return self.verified_on is not None def verify(self): self._connection.call( 'addresses/{0}/verify'.format(quote_plus(self.email)), method='POST', ) self._reset_cache() def unverify(self): self._connection.call( 'addresses/{0}/unverify'.format(quote_plus(self.email)), method='POST' ) self._reset_cache() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/restobjects/archivers.py0000644000076500000240000000307214355230520024214 0ustar00maxkingstaff# Copyright (C) 2010-2023 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . from mailmanclient.restbase.base import RESTDict __metaclass__ = type __all__ = [ 'ListArchivers' ] class ListArchivers(RESTDict): """ Represents the activation status for each site-wide available archiver for a given list. """ _autosave = True def __init__(self, connection, url, mlist): """ :param connection: An API connection object. :type connection: Connection. :param url: The API url of the list's archiver endpoint. :type url: str. :param mlist: The corresponding list object. :type mlist: MailingList. """ super(ListArchivers, self).__init__(connection, url) self._mlist = mlist def __repr__(self): return ''.format(self._mlist.list_id) def __str__(self): return 'Archivers on {}'.format(self._mlist.list_id) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/restobjects/ban.py0000644000076500000240000000650414355230520022771 0ustar00maxkingstaff# Copyright (C) 2010-2023 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . from urllib.error import HTTPError from urllib.parse import quote_plus from mailmanclient.restobjects.mailinglist import MailingList from mailmanclient.restbase.base import RESTList, RESTObject __metaclass__ = type __all__ = [ 'Bans', 'BannedAddress' ] class Bans(RESTList): """ The list of banned addresses from a mailing-list or from the whole site. """ def __init__(self, connection, url, data=None, mlist=None): """ :param mlist: The corresponding list object, or None if it is a global ban list. :type mlist: MailingList or None. """ super(Bans, self).__init__(connection, url, data) self._mlist = mlist self._factory = lambda data: BannedAddress( self._connection, data['self_link'], data) def __repr__(self): if self._mlist is None: return '' else: return ''.format(self._mlist.list_id) def __contains__(self, item): # Accept email addresses and BannedAddress restobjects if isinstance(item, BannedAddress): item = item.email if self._rest_data is not None: return item in [data['email'] for data in self._rest_data] else: # Avoid getting the whole list just to check membership try: response, content = self._connection.call( '{}/{}'.format(self._url, quote_plus(item))) except HTTPError as e: if e.code == 404: return False else: raise else: return True def add(self, email): response, content = self._connection.call(self._url, dict(email=email)) self._reset_cache() return BannedAddress( self._connection, response.headers.get('location')) def find_by_email(self, email): for ban in self: if ban.email == email: return ban return None def remove(self, email): ban = self.find_by_email(email) if ban is not None: ban.delete() self._reset_cache() else: raise ValueError('The address {} is not banned'.format(email)) class BannedAddress(RESTObject): _properties = ('email', 'list_id', 'self_link') _writable_properties = [] def __repr__(self): return ''.format(self.email) def __str__(self): return self.email @property def mailinglist(self): return MailingList( self._connection, 'lists/{0}'.format(self.list_id)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/restobjects/configuration.py0000644000076500000240000000217014355230520025073 0ustar00maxkingstaff# Copyright (C) 2010-2023 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . from mailmanclient.restbase.base import RESTDict __metaclass__ = type __all__ = [ 'Configuration' ] class Configuration(RESTDict): _writable_properties = () def __init__(self, connection, name): super(Configuration, self).__init__( connection, 'system/configuration/{}'.format(name)) self.name = name def __repr__(self): return ''.format(self.name) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/restobjects/domain.py0000644000076500000240000001045314355230520023476 0ustar00maxkingstaff# Copyright (C) 2010-2023 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . import warnings from mailmanclient.restobjects.mailinglist import MailingList from mailmanclient.restobjects.templates import TemplateList from mailmanclient.restobjects.user import User from mailmanclient.restbase.base import RESTObject from mailmanclient.restbase.page import Page __metaclass__ = type __all__ = [ 'Domain' ] class Domain(RESTObject): _properties = ('alias_domain', 'description', 'mail_host', 'self_link') def __repr__(self): return ''.format(self.mail_host) def __str__(self): return self.mail_host @property def web_host(self): warnings.warn( 'The `Domain.web_host` attribute is deprecated. It is not used ' 'any more and will be removed in the future.', DeprecationWarning, stacklevel=2) return 'http://{}'.format(self.mail_host) @property def base_url(self): warnings.warn( 'The `Domain.base_url` attribute is deprecated. It is not used ' 'any more and will be removed in the future.', DeprecationWarning, stacklevel=2) return 'http://{}'.format(self.mail_host) @property def owners(self): url = self._url + '/owners' response, content = self._connection.call(url) if 'entries' not in content: return [] else: return [User(self._connection, entry['self_link'], entry) for entry in content['entries']] @property def lists(self): return self.get_lists() def get_lists(self, advertised=None): url = 'domains/{0}/lists'.format(self.mail_host) if advertised: url += '?advertised=true' response, content = self._connection.call(url) if 'entries' not in content: return [] return [MailingList(self._connection, entry['self_link'], entry) for entry in content['entries']] def get_list_page(self, count=50, page=1, advertised=None): url = 'domains/{0}/lists'.format(self.mail_host) if advertised: url += '?advertised=true' return Page(self._connection, url, MailingList, count, page) def create_list(self, list_name, style_name=None): fqdn_listname = '{0}@{1}'.format(list_name, self.mail_host) data = dict(fqdn_listname=fqdn_listname) if style_name is not None: data['style_name'] = style_name response, content = self._connection.call('lists', data) return MailingList(self._connection, response.headers.get('location')) # TODO: Add this when the API supports removing a single owner. # def remove_owner(self, owner): # url = self._url + '/owners/{}'.format(owner) # response, content = self._connection.call( # url, method='DELETE') # return response def remove_all_owners(self): url = self._url + '/owners' response, content = self._connection.call( url, method='DELETE') return response def add_owner(self, owner): url = self._url + '/owners' response, content = self._connection.call( url, {'owner': owner}) @property def templates(self): url = self._url + '/uris' return TemplateList(self._connection, url) def set_template(self, template_name, uri, username=None, password=None): url = self._url + '/uris' data = {template_name: uri} if username is not None and password is not None: data['username'] = username data['password'] = password return self._connection.call(url, data, 'PATCH')[1] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/restobjects/header_match.py0000644000076500000240000000714014355230520024632 0ustar00maxkingstaff# Copyright (C) 2010-2023 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . from urllib.error import HTTPError from mailmanclient.restbase.base import RESTList, RESTObject __metaclass__ = type __all__ = [ 'HeaderMatch', 'HeaderMatches' ] class HeaderMatches(RESTList): """ The list of header matches for a mailing-list. """ def __init__(self, connection, url, mlist): """ :param mlist: The corresponding list object. :type mlist: MailingList. """ super(HeaderMatches, self).__init__(connection, url) self._mlist = mlist self._factory = lambda data: HeaderMatch( self._connection, data['self_link'], data) def __repr__(self): return ''.format(self._mlist.list_id) def __str__(self): return 'Header matches for "{}"'.format(self._mlist.list_id) def add(self, header, pattern, action=None, tag=None): """Add a new HeaderMatch rule to the MailingList. :param header: The header to consider. :type header: str :param pattern: The regular expression to use for filtering. :type pattern: str :param action: The action to take when the header matches the pattern. This can be 'accept', 'discard', 'reject', or 'hold'. :type action: str """ data = dict(header=header, pattern=pattern) if action is not None: data['action'] = action if tag is not None: data['tag'] = tag response, content = self._connection.call(self._url, data) self._reset_cache() return HeaderMatch(self._connection, response.headers.get('location')) def find(self, header=None, tag=None, action=None): """Find a set of HeaderMatch rules. :param header: The header to consider. :type header: str :param tag: The tag associated with header. :type tag: str :param action: The action to take when the header matches the pattern. This can be 'accept', 'discard', 'reject', or 'hold'. :type action: str """ url = self._url + '/find' data = dict(header=header, tag=tag, action=action) data = {key: value for key, value in data.items() if value} if not data: return [] try: response, content = self._connection.call(url, data) except HTTPError as e: if e.code == 404: return [] raise return [HeaderMatch(self._connection, entry['self_link'], entry) for entry in content['entries']] class HeaderMatch(RESTObject): _properties = ('header', 'pattern', 'position', 'action', 'tag', 'self_link') _writable_properties = ('header', 'pattern', 'position', 'action', 'tag') def __repr__(self): return ''.format(self.header) def __str__(self): return 'Header match on "{}"'.format(self.header) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/restobjects/held_message.py0000644000076500000240000000375014355230520024651 0ustar00maxkingstaff# Copyright (C) 2010-2023 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . from mailmanclient.restbase.base import RESTObject __metaclass__ = type __all__ = [ 'HeldMessage' ] class HeldMessage(RESTObject): _properties = ('hold_date', 'message_id', 'msg', 'reason', 'request_id', 'self_link', 'sender', 'subject', 'type') def __repr__(self): return ''.format( self.request_id, self.sender) def moderate(self, action, comment=None): """Moderate a held message. :param action: Action to perform on held message. :type action: String. """ data = dict(action=action) if comment is not None: data['comment'] = comment response, content = self._connection.call( self._url, data, 'POST') return response def discard(self): """Shortcut for moderate.""" return self.moderate('discard') def reject(self, reason=None): """Shortcut for moderate. :param reason: An optional reason for rejecting the held message. """ return self.moderate('reject', comment=reason) def defer(self): """Shortcut for moderate.""" return self.moderate('defer') def accept(self): """Shortcut for moderate.""" return self.moderate('accept') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/restobjects/mailinglist.py0000644000076500000240000006027114355230520024546 0ustar00maxkingstaff# Copyright (C) 2010-2023 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . import warnings from operator import itemgetter from urllib.error import HTTPError from urllib.parse import urlencode, quote_plus from mailmanclient.restobjects.header_match import HeaderMatches from mailmanclient.restobjects.archivers import ListArchivers from mailmanclient.restobjects.member import Member from mailmanclient.restobjects.settings import Settings from mailmanclient.restobjects.held_message import HeldMessage from mailmanclient.restobjects.templates import TemplateList from mailmanclient.restbase.base import RESTObject from mailmanclient.restbase.page import Page __metaclass__ = type __all__ = [ 'MailingList' ] class MailingList(RESTObject): _properties = ('advertised', 'display_name', 'fqdn_listname', 'list_id', 'list_name', 'mail_host', 'member_count', 'volume', 'self_link', 'description') def __init__(self, connection, url, data=None): super(MailingList, self).__init__(connection, url, data) self._settings = None def __repr__(self): return ''.format(self.fqdn_listname) @property def owners(self): """All MailingList owners.""" return self.get_roster('owner') def get_roster(self, roster, fields=None): """Get roster of the MailingList. If the fields is specified without `self_link` and `address`, they are added since it is required for returning the response. :param str roster: One of the Membership rosters from 'owner', 'moderator', 'member' and 'nonmember'. :param List[str] fields: List of Member's fields to fetch from the API. Skipping certain fields can speed up the API response when they aren't required since they need to be fetched from database individually. """ url = self._url + '/roster/{}'.format(roster) if fields is not None: # We cannot instantiate Member object without address and # self_link objects, so just add them too. They don't add # a lot of overhead. if 'address' not in fields: fields.append('address') if 'self_link' not in fields: fields.append('self_link') url += '?' + '&'.join('fields={}'.format(each) for each in fields) response, content = self._connection.call(url) if 'entries' not in content: return [] else: return [Member(self._connection, entry['self_link'], entry) for entry in sorted(content['entries'], key=itemgetter('address'))] @property def moderators(self): """All MailingList moderators.""" return self.get_roster('moderator') @property def members(self): """All MailingList members.""" return self.get_roster('member') @property def nonmembers(self): """All MailingList non-members.""" return self.get_roster('nonmember') def get_member_page(self, count=50, page=1, fields=None): """Return a paginated list of MailingList's members. :param int count: Count of members in one page. :param int page: The page number. """ url = 'lists/{0}/roster/member'.format(self.fqdn_listname) return Page(self._connection, url, Member, count, page) def find_members( self, address=None, role=None, page=None, count=50): """Find a Mailinglist's members. This provides a filtering API for list's Members including, non-members, owners and moderators by speciying the role. :param str address: Member's address. :param str role: Member's role. :param int page: Page number for paginated results. :param int count: Number of results per-page for paginated results. """ data = {'list_id': self.list_id} if address: data['subscriber'] = address if role: data['role'] = role url = 'members/find?{}'.format(urlencode(data, doseq=True)) if page is None: response, content = self._connection.call(url, data) if 'entries' not in content: return [] return [Member(self._connection, entry['self_link'], entry) for entry in content['entries']] else: return Page(self._connection, url, Member, count, page) @property def settings(self): """All MailingList settings.""" if self._settings is None: self._settings = Settings( self._connection, 'lists/{0}/config'.format(self.fqdn_listname)) return self._settings @property def held(self): """Held messages of a MailingList..""" response, content = self._connection.call( 'lists/{0}/held'.format(self.fqdn_listname), None, 'GET') if 'entries' not in content: return [] return [HeldMessage(self._connection, entry['self_link'], entry) for entry in content['entries']] def get_held_page(self, count=50, page=1): """Paginated list of held messages for the MailingList. :param int page: Page number for paginated results. :param int count: Number of results per-page for paginated results. """ url = 'lists/{0}/held'.format(self.fqdn_listname) return Page(self._connection, url, HeldMessage, count, page) def get_held_count(self): """Get a count of held messages for the MailingList.""" response, json = self._connection.call( 'lists/{}/held/count'.format(self.fqdn_listname), None, 'GET') return json['count'] def get_held_message(self, held_id): """Get a single held message for MailingList. :param int held_id: Held message id to get. """ url = 'lists/{0}/held/{1}'.format(self.fqdn_listname, held_id) return HeldMessage(self._connection, url) @property def requests(self): """See :meth:`get_requests`.""" return self.get_requests() @property def unsubscription_requests(self): """Get a list of subscription requests pending moderator Approval.""" return self.get_requests(request_type='unsubscription') def get_requests(self, token_owner=None, request_type='subscription'): """Return a list of dicts with subscription requests. This is the new API for requests which allows filtering via `token_owner` since it isn't possible to do so via the property requests. :param token_owner: Who owns the pending requests? Should be one in 'no_one', 'moderator' and 'subscriber'. :param request_type: The type of pending request. Value should be in 'subscription' or 'unsubscription'. Defaults to 'subscription'. """ url = 'lists/{0}/requests'.format(self.fqdn_listname) fragments = [] if token_owner: fragments.append('token_owner={}'.format(token_owner)) if request_type: fragments.append('request_type={}'.format(request_type)) if fragments: url += '?{}'.format('&'.join(fragments)) response, content = self._connection.call(url, None, 'GET') if 'entries' not in content: return [] else: entries = [] for entry in content['entries']: request = dict(email=entry['email'], token=entry['token'], display_name=entry['display_name'], token_owner=entry['token_owner'], list_id=entry['list_id'], request_date=entry['when']) entries.append(request) return entries def get_requests_count(self, token_owner=None): """Return a total count of pending subscription requests. This should be a faster query when *all* the requests aren't needed and only a count is needed to display on the badge in List's settings page. :param token_owner: Who owns the pending requests? Should be one in 'no_one', 'moderator' and 'subscriber'. :returns: The count of pending requests. """ url = 'lists/{}/requests/count'.format(self.fqdn_listname) if token_owner: url += '?token_owner={}'.format(token_owner) response, json = self._connection.call(url) return json['count'] def get_request(self, token): """Get an individual pending request for the given token. :param token: The token for the request. :returns: The request dictionary. """ url = 'lists/{}/requests/{}'.format(self.fqdn_listname, token) response, json = self._connection.call(url) return json @property def archivers(self): """Get a list of MailingList archivers.""" url = 'lists/{0}/archivers'.format(self.list_id) return ListArchivers(self._connection, url, self) @archivers.setter def archivers(self, new_value): url = 'lists/{0}/archivers'.format(self.list_id) archivers = ListArchivers(self._connection, url, self) archivers.update(new_value) archivers.save() def add_owner(self, address, display_name=None): """Add a list owner. :param str address: Email address of the owner. :param str display_name: Display name of the Owner. """ self.add_role('owner', address, display_name) def add_moderator(self, address, display_name=None): """Add a list moderator. :param str address: Email address of the moderator. :param str display_name: Display name of the moderator. """ self.add_role('moderator', address, display_name) def add_role(self, role, address, display_name=None): """Add a new Member with a specific role. :param str role: The role for the new member. :param str address: A valid email address for the new Member. :param str display_name: An optional display name for the Member. """ data = dict(list_id=self.list_id, subscriber=address, display_name=display_name, role=role) self._connection.call('members', data) def remove_owner(self, address): """Remove a list owner. :param str address: Email address of the owner to remove. """ self.remove_role('owner', address) def remove_moderator(self, address): """Remove a list moderator. :param str address: Email address of the moderator to remove. """ self.remove_role('moderator', address) def remove_role(self, role, address): """Remove a list Member with a specific Role. :param str role: The role for the new member. :param str address: A valid email address for the new Member. """ url = 'lists/%s/%s/%s' % ( self.fqdn_listname, role, quote_plus(address)) self._connection.call(url, method='DELETE') def moderate_message(self, request_id, action, comment=None): """Moderate a held message. :param request_id: Id of the held message. :type request_id: Int. :param action: Action to perform on held message. :type action: String. :param comment: The reason for action, only supported for rejection. :type comment: str """ data = dict(action=action) if comment is not None: data['comment'] = comment path = 'lists/{0}/held/{1}'.format( self.fqdn_listname, str(request_id)) response, content = self._connection.call( path, data, 'POST') return response def discard_message(self, request_id): """Shortcut for moderate_message. :param str request_id: The request_id of the held message. """ return self.moderate_message(request_id, 'discard') def reject_message(self, request_id, reason=None): """Shortcut for moderate_message. :param str request_id: The request_id of the held message. :param str reason: An optional reason for rejection of the message. """ return self.moderate_message(request_id, 'reject', reason) def defer_message(self, request_id): """Shortcut for moderate_message. :param str request_id: The request_id of the held message. """ return self.moderate_message(request_id, 'defer') def accept_message(self, request_id): """Shortcut for moderate_message. :param str request_id: The request_id of the held message. """ return self.moderate_message(request_id, 'accept') def moderate_request(self, request_id, action, reason=None): """ Moderate a subscription request. :param action: accept|reject|discard|defer :type action: str. :param reason: The reason associated with rejections. :type reason: str """ path = 'lists/{0}/requests/{1}'.format(self.list_id, request_id) data = {'action': action} if reason: data['reason'] = reason response, content = self._connection.call(path, data) return response def manage_request(self, token, action): """Alias for moderate_request, kept for compatibility""" warnings.warn( 'The `manage_request()` method has been replaced by ' '`moderate_request()` and will be removed in the future.', DeprecationWarning, stacklevel=2) return self.moderate_request(token, action) def accept_request(self, request_id): """Shortcut to accept a subscription request.""" return self.moderate_request(request_id, 'accept') def reject_request(self, request_id): """Shortcut to reject a subscription request.""" return self.moderate_request(request_id, 'reject') def discard_request(self, request_id): """Shortcut to discard a subscription request.""" return self.moderate_request(request_id, 'discard') def defer_request(self, request_id): """Shortcut to defer a subscription request.""" return self.moderate_request(request_id, 'defer') def _get_membership(self, email, role): """Get a single membership resource. :param address: The email address of the member for this list. :param role: The membership role. :return: A member proxy object. """ # In order to get the member object we query the REST API for # the member. Incase there is no matching subscription, an # HTTPError is returned instead. try: path = 'lists/{0}/{1}/{2}'.format( self.list_id, role, quote_plus(email)) response, content = self._connection.call(path) return Member(self._connection, content['self_link'], content) except HTTPError: raise ValueError('%s is not a %s address of %s' % (email, role, self.fqdn_listname)) def get_member(self, email): """Get a Member of the list. :param address: The email address of the member for this list. :return: A member proxy object. """ return self._get_membership(email, 'member') def get_nonmember(self, email): """Get a non-member of the list. :param address: The email address of the non-member for this list. :return: A member proxy object. """ return self._get_membership(email, 'nonmember') def subscribe(self, address, display_name=None, pre_verified=False, pre_confirmed=False, pre_approved=False, invitation=False, send_welcome_message=None, delivery_mode=None, delivery_status=None): """Subscribe an email address to a mailing list. :param address: Email address to subscribe to the list. :type address: str :param display_name: The real name of the new member. :type display_name: str :param pre_verified: True if the address has been verified. :type pre_verified: bool :param pre_confirmed: True if membership has been approved by the user. :type pre_confirmed: bool :param pre_approved: True if membership is moderator-approved. :type pre_approved: bool :param invitation: True if this is an invitation to join the list. :type invitation: bool :param send_welcome_message: True if welcome message should be sent. :type send_welcome_message: bool :param delivery_mode: Delivery mode of the Member. :type delivery_mode: str. One between 'regular', 'plaintext_digests', 'mime_digests', 'summary_digests'. :param delivery_status: Delivery status of the Member. :type delivery_status: str. One between 'enabled', 'by_owner', 'by_moderator', 'by_user'. :return: A member proxy object. """ data = dict( list_id=self.list_id, subscriber=address, ) if display_name: data['display_name'] = display_name if pre_verified: data['pre_verified'] = True if pre_confirmed: data['pre_confirmed'] = True if pre_approved: data['pre_approved'] = True if invitation: data['invitation'] = True if delivery_mode: data['delivery_mode'] = delivery_mode if delivery_status: data['delivery_status'] = delivery_status # Even if it is False, we should send this value because it means we # should suppress welcome message, so check for None value to skip the # parameter. if send_welcome_message is not None: data['send_welcome_message'] = send_welcome_message response, content = self._connection.call('members', data) # If a member is not immediately subscribed (i.e. verificatoin, # confirmation or approval need), the response content is returned. if response.status_code == 202: return content # If the subscription is executed immediately, a member object # is returned. return Member(self._connection, response.headers.get('location')) def unsubscribe(self, email, pre_confirmed=None, pre_approved=None): """Unsubscribe an email address from a mailing list. :param address: Email address to unsubscribe. :type address: str :param pre_confirmed: True if unsubscribe is approved by the user. :type pre_confirmed: bool :param pre_approved: True if unsubscribe is moderator-approved. :type pre_approved: bool """ data = dict() if pre_confirmed is not None: data['pre_confirmed'] = pre_confirmed if pre_approved is not None: data['pre_approved'] = pre_approved try: path = 'lists/{0}/member/{1}'.format(self.list_id, email) response, json = self._connection.call(path, data, method='DELETE') if response.status_code == 202: return json except HTTPError: # The member link does not exist, i.e. they are not a member raise ValueError('%s is not a member address of %s' % (email, self.fqdn_listname)) def mass_unsubscribe(self, email_list): """Unsubscribe a list of emails from a mailing list. This function return a json of emails mapped to booleans based on whether they were unsubscribed or not, for whatever reasons :param email_list: list of emails to unsubscribe """ try: path = 'lists/{}/roster/member'.format(self.list_id) response, content = self._connection.call( path, {'emails': email_list}, 'DELETE') return content except HTTPError as e: raise ValueError(str(e)) @property def bans(self): """A list of banned addresses for this MailingList.""" from mailmanclient.restobjects.ban import Bans url = 'lists/{0}/bans'.format(self.list_id) return Bans(self._connection, url, mlist=self) def get_bans_page(self, count=50, page=1): """Get a paginated list of bans for this MailingList. :param int page: Page number for paginated results. :param int count: Number of results per-page for paginated results. """ from mailmanclient.restobjects.ban import BannedAddress url = 'lists/{0}/bans'.format(self.list_id) return Page(self._connection, url, BannedAddress, count, page) @property def header_matches(self): """A list of header-match rules for the MailingList.""" url = 'lists/{0}/header-matches'.format(self.list_id) return HeaderMatches(self._connection, url, self) @property def templates(self): """Get a list of MailingList templates.""" url = self._url + '/uris' return TemplateList(self._connection, url) def set_template(self, template_name, uri, username=None, password=None): """Set a MailingList template URI. :param str template_name: The name of the template. :param str uri: The URI to fetch the template. :param str username: Username for fetching template from uri. :param str password: Password for fetching template from uri. """ url = self._url + '/uris' data = {template_name: uri} if username is not None and password is not None: data['username'] = username data['password'] = password return self._connection.call(url, data, 'PATCH')[1] def _check_membership(self, address, allowed_roles): """ Given an address and role, check if there is a membership record that matches the given address with a given role for this Mailing List. """ url = 'members/find' data = {'subscriber': address, 'list_id': self.list_id} response, content = self._connection.call(url, data=data) if 'entries' not in content: return False for membership in content['entries']: # We check for all the returned roles for this User and MailingList if membership['role'] in allowed_roles: return True return False def is_owner(self, address): """ Given an address, checks if the given address is an owner of this mailing list. """ return self._check_membership(address=address, allowed_roles=('owner',)) def is_moderator(self, address): """ Given an address, checks if the given address is a moderator of this mailing list. """ return self._check_membership(address=address, allowed_roles=('moderator',)) def is_member(self, address): """ Given an address, checks if the given address is subscribed to this mailing list. """ return self._check_membership(address=address, allowed_roles=('member',)) def is_owner_or_mod(self, address): """ Given an address, checks if the given address is either a owner or a moderator of this list. It is possible for them to be both owner and moderator. """ return self._check_membership(address=address, allowed_roles=('owner', 'moderator')) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/restobjects/member.py0000644000076500000240000000410014355230520023466 0ustar00maxkingstaff# Copyright (C) 2010-2023 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . from mailmanclient.restobjects.preferences import PreferencesMixin from mailmanclient.restbase.base import RESTObject __metaclass__ = type __all__ = [ 'Member' ] class Member(RESTObject, PreferencesMixin): _properties = ('address', 'delivery_mode', 'email', 'list_id', 'moderation_action', 'display_name', 'role', 'self_link', 'subscription_mode', 'member_id', 'bounce_score', 'last_bounce_received', 'last_warning_sent', 'total_warnings_sent') _writable_properties = ('address', 'delivery_mode', 'moderation_action') def __repr__(self): return ''.format( self.email, self.list_id, self.role) def __str__(self): return 'Member "{0}" on "{1}"'.format(self.email, self.list_id) @property def address(self): from mailmanclient.restobjects.address import Address return Address(self._connection, self.rest_data['address']) @property def user(self): from mailmanclient.restobjects.user import User return User(self._connection, self.rest_data['user']) def unsubscribe(self): """Unsubscribe the member from a mailing list.""" response, json = self._connection.call(self.self_link, method='DELETE') if response.status_code == 202: return json ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/restobjects/preferences.py0000644000076500000240000000274614355230520024536 0ustar00maxkingstaff# Copyright (C) 2010-2023 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . from mailmanclient.restbase.base import RESTDict __metaclass__ = type __all__ = [ 'Preferences', 'PreferencesMixin' ] class Preferences(RESTDict): _properties = ( 'acknowledge_posts', 'delivery_mode', 'delivery_status', 'hide_address', 'preferred_language', 'receive_list_copy', 'receive_own_postings', ) def delete(self): response, content = self._connection.call(self._url, method='DELETE') class PreferencesMixin: """Mixin for restobjects that have preferences.""" @property def preferences(self): if getattr(self, '_preferences', None) is None: path = '{0}/preferences'.format(self.self_link) self._preferences = Preferences(self._connection, path) return self._preferences ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/restobjects/queue.py0000644000076500000240000000231614355230520023352 0ustar00maxkingstaff# Copyright (C) 2010-2023 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . from mailmanclient.restbase.base import RESTObject __metaclass__ = type __all__ = [ 'Queue' ] class Queue(RESTObject): _properties = ('name', 'directory', 'files') def __repr__(self): return ''.format(self.name) def inject(self, list_id, text): self._connection.call(self._url, dict(list_id=list_id, text=text)) @property def files(self): # No caching. response, content = self._connection.call(self._url) return content['files'] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/restobjects/settings.py0000644000076500000240000000251514355230520024067 0ustar00maxkingstaff# Copyright (C) 2010-2023 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . from mailmanclient.restbase.base import RESTDict __metaclass__ = type __all__ = [ 'Settings' ] class Settings(RESTDict): _read_only_properties = ( 'bounces_address', 'created_at', 'digest_last_sent_at', 'fqdn_listname', 'join_address', 'last_post_at', 'leave_address', 'list_id', 'list_name', 'mail_host', 'next_digest_number', 'no_reply_address', 'owner_address', 'post_id', 'posting_address', 'request_address', 'scheme', 'self_link', 'volume', 'web_host', ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/restobjects/styles.py0000644000076500000240000000156614355230520023557 0ustar00maxkingstaff# Copyright (C) 2018-2023 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . from mailmanclient.restbase.base import RESTDict class Styles(RESTDict): _read_only_properties = ( 'style_names', 'styles', 'default' ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672819024.0 mailmanclient-3.3.5/src/mailmanclient/restobjects/templates.py0000644000076500000240000000262614355230520024230 0ustar00maxkingstaff# Copyright (C) 2017-2023 by the Free Software Foundation, Inc. # # This file is part of mailman.client. # # mailman.client is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailman.client is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailman.client. If not, see . """Template objects.""" from __future__ import absolute_import, print_function, unicode_literals from mailmanclient.restbase.base import RESTList, RESTObject __all__ = [ 'Template', 'TemplateList' ] class TemplateList(RESTList): def __init__(self, connection, url, data=None, context=None): super(RESTList, self).__init__(connection, url, data) self._factory = lambda data: Template( self._connection, data['self_link'], data) class Template(RESTObject): _properties = ('self_link', 'name', 'uri', 'username', 'password') _writable_properties = ['uri', 'username', 'password'] def __repr__(self): return '