Plugins

See also

See an example Mailman plugin as a starting point for writing a new plugin.

Mailman defines a plugin as a Python package on sys.path that provides components matching the IPlugin interface. IPlugin implementations can define a pre-hook, a post-hook, and a REST resource. Plugins are enabled by adding a section to your mailman.cfg file, such as:

[plugin.example]
class: example.hooks.ExamplePlugin
enabled: yes

Note

Because of a design limitation in the underlying configuration library, you cannot name a plugin “master”. Specifically you cannot define a section in your mailman.cfg file named [plugin.master].

We have such a configuration file handy.

>>> from importlib.resources import path
>>> config_file = str(cleanups.enter_context(
...     path('mailman.plugins.testing', 'hooks.cfg')))

The section must at least define the class implementing the IPlugin interface, using a Python dotted-name import path. For the import to work, you must include the top-level directory on sys.path.

>>> import os
>>> plugin_dir = str(cleanups.enter_context(
...     path('mailman.plugins', '__init__.py')))
>>> plugin_path = os.path.join(os.path.dirname(plugin_dir), 'testing')

Hooks

Plugins can add initialization hooks, which will be run at two stages in the initialization process - one before the database is initialized and one after. These correspond to methods the plugin defines, a pre_hook() method and a post_hook() method. Each of these methods must be present, together with the resource property, for the plugin to be operational.

Here is a plugin that defines these hooks:

import os

from mailman.interfaces.plugin import IPlugin
from public import public
from zope.interface import implementer


@public
@implementer(IPlugin)
class ExamplePlugin:
    def pre_hook(self):
        if os.environ.get('DEBUG_HOOKS'):
            print("I'm in my pre-hook")

    def post_hook(self):
        if os.environ.get('DEBUG_HOOKS'):
            print("I'm in my post-hook")

    @property
    def resource(self):
        return None

To illustrate how the hooks work, we’ll invoke a simple Mailman command to be run in a subprocess. The plugin itself supports debugging hooking invocation when an environment variable is set.

>>> from mailman.testing.documentation import run_mailman as run
>>> proc = run(['-C', config_file, 'info'],
...            DEBUG_HOOKS='1',
...            PYTHONPATH=plugin_path)
>>> print(proc.stdout)
I'm in my pre-hook
I'm in my post-hook
...

Components

Plugins can also add components such as rules, chains, list styles, etc. By default, components are searched for in the package matching the plugin’s name. So in the case above, the plugin is named example (because the section is called [plugin.example], and there is a subpackage called rules under the example package. The file system layout looks like this:

example/
    __init__.py
    hooks.py
    rules/
        __init__.py
        rules.py

And the contents of rules.py looks like:

from mailman.interfaces.rules import IRule
from public import public
from zope.interface import implementer


@public
@implementer(IRule)
class ExampleRule:
    name = 'example-rule'
    description = 'An example rule.'
    record = True

    def check(self, mlist, msg, msgdata):
        return 'example' in msgdata

To see that the plugin’s rule get added, we invoke Mailman as an external process, running a script that prints out all the defined rule names, including our plugin’s example-rule.

>>> proc = run(['-C', config_file, 'withlist', '-r', 'showrules'],
...            PYTHONPATH=plugin_path)
>>> print(proc.stdout)
administrivia
...
example-rule
...

Component directories can live under any importable path, not just one named after the plugin. By adding a component_package section to your plugin’s configuration, you can name an alternative location to search for components.

[plugin.example]
class: example.hooks.ExamplePlugin
enabled: yes
component_package: alternate

[logging.plugins]
propagate: yes

We use this configuration file and the following file system layout:

example/
    __init__.py
    hooks.py
alternate/
    rules/
        __init__.py
        rules.py

Here, rules.py likes like:

from mailman.interfaces.rules import IRule
from public import public
from zope.interface import implementer


@public
@implementer(IRule)
class AlternateRule:
    name = 'alternate-rule'
    description = 'An alternate rule.'
    record = True

    def check(self, mlist, msg, msgdata):
        return 'alternate' in msgdata

You can see that this rule has a different name. If we use the alternate.cfg configuration file from above:

>>> config_file = str(cleanups.enter_context(path(
...     'mailman.plugins.testing', 'alternate.cfg')))

we’ll pick up the alternate rule when we print them out.

>>> proc = run(['-C', config_file, 'withlist', '-r', 'showrules'],
...            PYTHONPATH=plugin_path)
>>> print(proc.stdout)
administrivia
alternate-rule
...

REST

Plugins can also supply REST routes. Let’s say we have a plugin defined like so:

from mailman.config import config
from mailman.interfaces.plugin import IPlugin
from mailman.rest.helpers import bad_request, child, etag, no_content, okay
from mailman.rest.validator import Validator
from public import public
from zope.interface import implementer


@public
class Yes:
    def on_get(self, request, response):
        okay(response, etag(dict(yes=True)))


@public
class No:
    def on_get(self, request, response):
        bad_request(response, etag(dict(no=False)))


@public
class NumberEcho:
    def __init__(self):
        self._plugin = config.plugins['example']

    def on_get(self, request, response):
        okay(response, etag(dict(number=self._plugin.number)))

    def on_post(self, request, response):
        try:
            resource = Validator(number=int)(request)
            self._plugin.number = resource['number']
        except ValueError as error:
            bad_request(response, str(error))
        else:
            no_content(response)

    def on_delete(self, request, response):
        self._plugin.number = 0
        no_content(response)


@public
class RESTExample:
    def on_get(self, request, response):
        resource = {
            'my-name': 'example-plugin',
            'my-child-resources': 'yes, no, echo',
            }
        okay(response, etag(resource))

    @child()
    def yes(self, context, segments):
        return Yes(), []

    @child()
    def no(self, context, segments):
        return No(), []

    @child()
    def echo(self, context, segments):
        return NumberEcho(), []


@public
@implementer(IPlugin)
class ExamplePlugin:
    def __init__(self):
        self.number = 0

    def pre_hook(self):
        pass

    def post_hook(self):
        pass

    @property
    def resource(self):
        return RESTExample()

which we can enable with the following configuration file:

[plugin.example]
class: example.rest.ExamplePlugin
enabled: yes

[webservice]
port: 9001
workers: 1

The plugin defines a resource attribute that exposes the root of the plugin’s resource tree. The plugin will show up when we navigate to the plugin resource.

>>> from mailman.testing.documentation import dump_json
>>> dump_json('http://localhost:9001/3.1/plugins')
entry 0:
    class: example.rest.ExamplePlugin
    enabled: True
    http_etag: "..."
    name: example
http_etag: "..."
start: 0
total_size: 1

The plugin may provide a GET on the resource itself.

>>> dump_json('http://localhost:9001/3.1/plugins/example')
http_etag: "..."
my-child-resources: yes, no, echo
my-name: example-plugin

And it may provide child resources.

>>> dump_json('http://localhost:9001/3.1/plugins/example/yes')
http_etag: "..."
yes: True

Plugins and their child resources can support any HTTP method, such as GET

>>> dump_json('http://localhost:9001/3.1/plugins/example/echo')
http_etag: "..."
number: 0

… or POST

>>> dump_json('http://localhost:9001/3.1/plugins/example/echo',
...           dict(number=7))
date: ...
server: ...
status: 204

>>> dump_json('http://localhost:9001/3.1/plugins/example/echo')
http_etag: "..."
number: 7

… or DELETE.

>>> dump_json('http://localhost:9001/3.1/plugins/example/echo',
...           method='DELETE')
date: ...
server: ...
status: 204

>>> dump_json('http://localhost:9001/3.1/plugins/example/echo')
http_etag: "..."
number: 0

It’s up to the plugin of course.