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