ipf / newsman
Newsman subscription extension with Mailman integration
Requires
- php: >=8.1
- typo3/cms-core: ^13.4 || ^14.3
- typo3/cms-extbase: ^13.4 || ^14.3
- typo3/cms-fluid: ^13.4 || ^14.3
Requires (Dev)
- typo3/testing-framework: ^8.0 || ^9.0
Suggests
- subugoe/typo3-cap: Protects the signup form against bots with a proof-of-work challenge (TYPO3 12.4/13.4, path based), see Documentation/Usage.rst
Provides
None
Conflicts
None
Replaces
None
README
A subscribe button (email field) as a TYPO3 content element, subscribing visitors to a remote GNU Mailman 3 mailing list.
- Extension key:
newsman - Namespace:
Ipf\NewsMan\ - Package:
ipf/newsman - Supports TYPO3 13.4 and 14.x
- Full documentation:
Documentation/(reStructuredText, TYPO3 Sphinx tooling)
The extension is standalone: it ships its own content element, FlexForm, template and CSS, and stores nothing in the TYPO3 database. Mailman remains the single source of truth for the subscribers.
Installation
composer require ipf/newsman
Then activate it:
vendor/bin/typo3 extension:setup
Configuration
The settings are declared in ext_conf_template.txt. The install tool keeps
$GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS']['newsman'] in sync with it, and a
site overrides individual values in config/system/settings.php. Because the
keys come from the template, the install tool does not drop them when it
rewrites the configuration.
The connection details are read in the constructor of MailmanService through
constructor injection; the extension does not use GeneralUtility::makeInstance().
| Key | Default | Purpose |
|---|---|---|
mode |
rest |
rest = Mailman 3 REST API, email = Mailman 2 style command mail |
apiUrl |
'' |
Base URL of the REST API, e.g. https://mailman.example.com/3.0 |
apiUser |
'' |
REST user (needs the Mailman admin role) |
apiPassword |
'' |
Password for apiUser |
authToken |
'' |
Bearer token; takes precedence over user/password when set |
verifySsl |
1 |
TLS certificate validation |
timeout |
10 |
HTTP timeout in seconds |
emailDomain |
'' |
Only for mode = email: the list domain, e.g. example.com |
emailCommand |
subscribe |
Only for mode = email: subscribe or request, see below |
emailSender |
'' |
Only for mode = email with emailCommand = request: the sender of the command mail |
Read the values from the environment so nothing secret ends up in the repository:
// config/system/settings.php 'EXTENSIONS' => [ 'newsman' => [ 'mode' => getenv('MAILMAN_MODE') ?: 'rest', 'apiUrl' => getenv('MAILMAN_API_URL') ?: '', 'apiUser' => getenv('MAILMAN_API_USER') ?: '', 'apiPassword' => getenv('MAILMAN_API_PASSWORD') ?: '', 'authToken' => getenv('MAILMAN_AUTH_TOKEN') ?: '', 'verifySsl' => getenv('MAILMAN_VERIFY_SSL') !== 'false', 'timeout' => (int)(getenv('MAILMAN_TIMEOUT') ?: 10), 'emailDomain' => getenv('MAILMAN_EMAIL_DOMAIN') ?: '', 'emailCommand' => getenv('MAILMAN_EMAIL_COMMAND') ?: 'subscribe', 'emailSender' => getenv('MAILMAN_EMAIL_SENDER') ?: '', ], ],
The settings can also be edited in the Extension Manager under Configure Extension.
With apiUrl empty the plugin shows a "not configured" message instead of
failing silently, so a forgotten configuration is visible rather than a dead
form.
Content element
Create a content element → Newsman Subscribe (CType: newsman_subscribe).
FlexForm settings:
| Setting | Description |
|---|---|
list |
The mailing list, e.g. newsletter@example.com |
successMessage |
Optional text shown instead of the default success message |
emailCommand |
Overrides emailCommand for this element (email mode) |
emailSender |
Overrides emailSender for this element (email mode) |
emailLabel |
Text of the field label |
emailPlaceholder |
Placeholder of the input |
buttonLabel |
Text of the submit button |
The three form texts are plain texts rather than labels, because the site brings
its own wording. Both the posting address (newsletter@example.com) and
Mailman's list id (newsletter.example.com, optionally prefixed with list:)
are accepted.
Mailman 2 (mode email)
Mailman 2 has no REST API, so mode = email subscribes by sending one command
mail; the list server then mails the visitor a confirmation the extension never
sees. emailCommand picks how that mail is addressed:
emailCommand |
Note | |
|---|---|---|
subscribe (default) |
to <list>-subscribe@<domain>, address in the From: header |
Fails SPF/DMARC at most hosted list servers, because the mail claims to come from the visitor while it comes from the web host |
request |
to <list>-request@<domain>, body subscribe <address>, sender emailSender |
The address travels in the body, so the mail passes those checks. Needs a valid emailSender |
request requires emailSender, because that mail is sent on behalf of the
site and the list server answers the site operator, not the visitor. Without it
the form reports "the sender of the command email is not configured" rather than
sending a mail that would only be rejected. Both settings can be overridden per
element, so one site can mix them; an empty FlexForm field means the global
value applies.
Styling
The template loads the stylesheet itself with <f:asset.css>, so no TypoScript
is needed; EXT:newsman/Resources/Public/Css/newsman.css is published with the
page. Classes: newsman-subscribe, newsman-subscribe__form,
newsman-subscribe__field, newsman-subscribe__label,
newsman-subscribe__input, newsman-subscribe__submit,
newsman-subscribe__message--{success,error}.
Mailman setup (server side)
The REST user needs the admin role:
mailman shell >>> from mailman.rest.auth import add_member >>> add_member('example.com', 'restadmin', 'secret') # domain, user, password
The extension subscribes with pre_verified, pre_confirmed and
pre_approved set, so the visitor becomes a member immediately. For a double
opt-in flow, send those as "false" in MailmanService::subscribeViaRest() and
let Mailman send the confirmation mail.
Site integration notes
The plugin is registered as EXTBASEPLUGIN from the extension's own
Configuration/TypoScript/setup.typoscript — deliberately not via
ExtensionUtility::configurePlugin(). That helper emits
tt_content.newsman_subscribe =< lib.contentElement with
templateName = Generic at defaultContentRendering, i.e. after every site
configuration. A site package that redefines lib.contentElement as a
FLUIDTEMPLATE (a common pattern that derives the template name from the
CType) would then be overridden, the numbered 20 = EXTBASEPLUGIN child would
be dropped by FLUIDTEMPLATE, and the element would render empty.
Because site configuration is applied after extension TypoScript, a site
package that dispatches content elements through a CASE has to repeat the
key itself. Such a site package can render the plugin as a numbered child of
its own template, so that the texts and the design of the site can be put
around the form:
tt_content = CASE
# ...
tt_content.newsman_subscribe =< lib.contentElement
tt_content.newsman_subscribe {
templateName = NewsmanSubscribe
20 = EXTBASEPLUGIN
20 {
extensionName = Newsman
pluginName = Subscribe
}
}
<!-- Content/NewsmanSubscribe.fluid.html --> <f:cObject typoscriptObjectPath="tt_content.{data.CType}.20" data="{data}" table="tt_content"/>
FLUIDTEMPLATE does not render numbered children by itself, which is why the
template calls the child the way the core does it in
EXT:fluid_styled_content/Resources/Private/Templates/Generic.fluid.html.
templateName stays empty on purpose so Extbase resolves the template from
controller and action (Resources/Private/Templates/Subscribe/Subscribe.html)
instead of a hard-coded name.
The texts of the field and of the button are plain texts of the FlexForm, not
labels: the site brings its own wording. The stylesheet of the form is
loaded by the template with f:asset.css, because TYPO3 v14 has no
configureExtension() for frontend stylesheets any more and the form is only
ever rendered inside a page.
Behaviour
| Situation | Result |
|---|---|
| New address | Member added, success message |
| Already subscribed | "already subscribed" hint, no duplicate |
| Invalid address | Validation error, nothing sent |
| Mailman unreachable | Connection error, no silent failure |
| Unknown list | Points the editor at the list configuration |
No REST API at apiUrl |
Points the operator at apiUrl, not at the list |
apiUrl empty |
"not configured" hint |
email mode |
Command mail sent, visitor confirms with the list server |
email mode, request without a sender |
"sender not configured" hint, no mail sent |
Mailman's REST API expects the pre_* flags as strings; real JSON booleans
make its validator throw a 500 (lazr.config calls ->lower() on them). The
service sends them quoted for that reason.
Local development with DDEV
Optional: a local GNU Mailman 3 core and its admin interface run as DDEV
services, so the form can be developed and tested without a hosted instance.
The extension ships them as a DDEV add-on in ddev/, which is
installed from the project:
ddev add-on get vendor/ipf/newsman/ddev ddev start
The add-on writes .ddev/.env.web.newsman (the settings the web container
reads, apiUrl = http://mailman:8001/3.0) and .ddev/.env.newsman (the
credentials of the two services), so nothing has to be configured by hand.
ddev add-on remove newsman takes it away again.
ddev describe then shows both services with their URLs and credentials:
| Service | URL from the host | Credentials |
|---|---|---|
mailman |
https://<project>.ddev.site:8028/3.0 |
restadmin / restpass |
postorius |
https://<project>.ddev.site:8030 |
admin / admin |
<project> is the name from .ddev/config.yaml; ddev-router routes by
hostname, so requests to localhost have to carry the Host header. Ports and
credentials are changed in .ddev/.env.newsman, and
ddev/README.md has the steps that create the mail domain,
the list and the Postorius password.
Admin interface (Postorius)
Lists, members, moderation and settings are managed in Postorius, Mailman 3's web interface, which runs as the second container:
- <http://.ddev.site:8029> (or
https://<project>.ddev.site:8030) - user
admin, passwordadmin(POSTORIUS_ADMIN_USER/POSTORIUS_ADMIN_PASSWORD)
HyperKitty, the web archive of sent messages, is not part of this image.
Two things about that container are worth knowing, because the image is built to run behind a reverse proxy:
- It contains neither nginx nor WhiteNoise, so it cannot serve
/staticon its own. The compose file therefore runs Django's development server with theDEBUGfromddev/newsman/settings_local.py. Use a real reverse proxy (that is what themaxking/mailman-webimage adds) for anything but local use. - Its
settings.pycallsgethostbyname("mailman-web")while buildingALLOWED_HOSTS, so the container needs that network alias or Django dies on import before reading any environment variable.
Security
- Form input is only used to call the Mailman API, nothing is persisted.
- TLS is verified by default;
verifySslshould stay enabled in production. - Credentials come from the environment, not from versioned files.
- The form has no captcha, so an open form lets bots subscribe third-party
addresses.
subugoe/typo3-capis an optional dependency that puts a proof-of-work challenge in front of the page the form posts to (details).
Deprecation-free notes
- No
ext_emconf.php. For composer packages it is deprecated (TYPO3 deprecation 108345); metadata and constraints live incomposer.json, which declaresextra.typo3/cms.versionandprovidesPackagesso core treats the package as composer-only. ext_conf_template.txtis the supported way to declare settings, soExtensionConfiguration::get()has values to work with.ExtensionUtility::registerPlugin()is public API. The controller actions are registered through Extbase's documented configuration array rather thanExtensionUtility::registerControllerActions(), which is marked@internal.Configuration/TypoScript/setup.typoscriptreplaces the TypoScript thatExtensionUtility::configurePlugin()would add.ExtensionConfigurationis constructor injected (it is a registered service), and no$GLOBALSis touched.- Templates use the
*.fluid.htmlextension sotypo3 fluid:analyzecovers them.