Newsletters for developers
This page covers what the newsletter add-on exposes to code: the addresses it claims, the events it raises, the templates a theme can override, and the two registries another plugin can contribute to. It assumes PHP and Twig.
Before you begin
- The
kahunacart-newslettersplugin installed and enabled. - For anything reached over HTTP, the Grav API plugin and an account with the relevant permission. See REST API.
Storefront routes
Eight addresses, all under {base}/newsletter/… where {base} is wherever the store is mounted (/shop/newsletter/u/… on a default install). They are claimed on onPagesInitialized at priority -10, below KahunaCart's own handler. Anything else under {base}/newsletter/ is left alone, so a store that has a page there keeps it.
| Route | Method | What it does | Writes? |
|---|---|---|---|
subscribe |
POST | Creates or updates a subscriber and sends the confirmation when the list asks for one. Answers 200 for "you are on the list", 202 for "check your email", 422 for an address that is not one. | Yes |
confirm/{token} |
GET, POST | GET draws a page with one button. POST confirms every list this person is waiting on. | POST only |
u/{token} |
GET, POST | GET draws the unsubscribe page with one button and a link to the preference centre. POST unsubscribes. A POST whose body is List-Unsubscribe=One-Click is the RFC 8058 path and answers 200 with an empty body. |
POST only |
p/{token} |
GET, POST | The preference centre: a checkbox per list this person is on and per public list, plus a name field and, on a multi-language site, a language select. | POST only |
o/{token}.gif |
GET | The open pixel. Records the open and answers a 1×1 transparent GIF. | Yes |
c/{token} |
GET | The click redirect. Records the click and sends the visitor to the page the link named. | Yes |
v/{token} |
GET | View in browser: the campaign as this person received it, rendered with tracking off. | No |
webhook/{provider}/{secret} |
POST | Where a provider reports what happened to the store's mail. A GET answers 405 with Allow: POST; a HEAD answers 200 with no body, because several providers probe an address before they will save it. |
Yes |
None of these needs a session, a cookie or a login. A signed token in the URL is the whole credential.
The webhook is the one address whose credential is not a signed token. {provider} has to be one a transport plugin on this site registered, and anything else is a 404 before the body is read. {secret} is compared in constant time, and a wrong one is a 404 as well, so a scanner cannot tell "no such provider" from "wrong secret".
The scanner rule
Corporate mail scanners and spam filters open every link in an inbox before the person does. So a GET never writes anything on the confirmation page, the unsubscribe page or the preference centre. Each draws a page with one button, and pressing the button is a POST.
There are two exceptions, and both are tracking. The open pixel and the click redirect write on a GET because the request is the event. confirm.on_get and unsubscribe.confirm_on_get turn the exception on for the two token pages, and both are off by default.
confirm.auto_submit, on by default, has the confirmation page press its own button from a script as soon as it loads. A scanner fetching the link without running scripts still only sees the page with the button on it. A browser driven by automation announces itself through navigator.webdriver and is left to the button.
Signing up over HTTP
POST {base}/newsletter/subscribe takes two kinds of request, and which one you send is the CSRF check.
JSON. Content-Type: application/json, with email and optionally name, list, tags, consent, language and source_ref. The content type is itself the credential: a cross-site form cannot send it without a preflight the browser will not grant.
A form post carrying the storefront nonce, in the field nonce, from nonce_field('kahunacart', 'nonce').
A form post with neither is a 415. Consent is required whenever the store has a consent_text configured, which it does out of the box.
curl -sS https://example.com/shop/newsletter/subscribe \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","list":"newsletter","consent":true}'
{"status":"pending","message":"We have sent you a confirmation email. Check your inbox and press the button in it to be added to the list."}
The answer is byte-identical for an address the store already has, one it has never seen, and one on the suppression list.
Rate limits
Every one of these is counted through KahunaCart's own rate limiter: one row per key in the store's database, shared by every worker and every node.
| Bucket | Covers | Default | Config key |
|---|---|---|---|
newsletter_subscribe |
subscribe |
10 an hour | rate_limits.subscribe |
newsletter_token |
confirm, u, p, v |
60 an hour | rate_limits.token |
newsletter_tracking |
o, c |
600 an hour | rate_limits.tracking |
newsletter_webhook |
webhook |
6000 an hour | rate_limits.webhook |
Counted per visitor address, honouring X-Forwarded-For only when the connection itself came from a security.trusted_proxies entry. Set a limit to zero to turn its bucket off. The limiter short-circuits before it opens the table.
When a bucket is spent, the token pages and the signup endpoint answer 429 with Retry-After: as JSON for a caller that asked for JSON, as a page for a browser. The pixel still answers its GIF and the click still redirects to the store, because a 429 in an <img> is a torn-page icon in somebody's mail. Nothing is recorded either way.
Headers on every response
Cache-Control: private, no-store, max-age=0. Every one of these URLs is a credential, so a shared cache holding one would hand the next visitor somebody else's mailing-list membership.
Referrer-Policy: no-referrer. Without it the site a campaign links to receives the click redirect's URL, which is a working credential for one of the store's subscribers.
X-Robots-Tag: noindex, nofollow.
What a failed token looks like
Token verification answers the same null for every kind of failure: a malformed string, a forged signature, an expired token, a subscriber's rotated secret, a subscriber who has since been erased.
The three pages answer 410 Gone with a sentence saying the link has expired and how to get a fresh one. The pixel answers its GIF. The click redirect sends the visitor to the store's front page. A one-click POST gets its empty 200, because a mail client has no user to show a page to.
The admin API
Every route is under /kahunacart/newsletters. Reading takes kahunacart.newsletters.view, writing takes kahunacart.newsletters.manage, and starting, resuming or test-sending a campaign takes kahunacart.newsletters.send in addition to .manage.
Every route has an entry in the REST API reference, generated from the plugin's own manifest, one page per area: Campaigns, Subscribers, Lists and tags, Segments, Templates, Automations, Imports, Delivery, and Overview and reports. Every route that has a tool also has an MCP tool, listed on Run the store from an AI assistant.
Permissions
| Permission | What it allows |
|---|---|
kahunacart.newsletters.view |
Reading. Subscribers, campaigns, counters, lists, templates, suppressions, settings. |
kahunacart.newsletters.manage |
Everything that writes. |
kahunacart.newsletters.send |
Starting a campaign, resuming one, and the test send. Checked in addition to .manage, never instead of it. |
They are Grav account permissions, set per user or per group in Accounts. The file that declares them is keyed actions: and registered with Permissions::addActions().
Events
The five events the add-on raises and the ones it listens for are on Events → Newsletters add-on events.
Two are useful for a listener of your own. onKahunaCartNewslettersSubscribed fires whenever somebody joins a list and is mailable straight away; onKahunaCartNewslettersConfirmed fires when somebody clicks the link in their confirmation email. Both carry subscriber_id, list_id and source. Neither fires for a signup still waiting on a double opt-in.
The Twig function
{{ kahunacart_newsletter_form('early-access', {
heading: 'Get the launch email',
intro: 'One email when we open the doors. Nothing else.',
button: 'Keep me posted',
name: true,
tags: ['homepage'],
source_ref: 'homepage-footer',
consent_text: 'Yes, email me about new products and offers.'
}) }}
The first argument is the list's code and the second is a map of overrides. An unknown list code falls back to the store's default list. consent_text is what gets hashed against the consent record, so it has to be the sentence that was shown.
The signature is on Twig functions and filters.
The newsletter form action
For a site already using the Form plugin:
process:
-
newsletter:
list: early-access
tags: [site]
consent_field: consent
Every parameter is optional. The full table is on Subscribers, lists and tags.
An address that is empty or is not an address sets the form's error message and stops the remaining process actions, so the reset and message actions below it do not run. Everything else succeeds: a new address, an address the store already had, and a suppressed address all get the same answer.
A form with xhr_submit: true works unchanged. The action sets form.status and form.message as an ordinary submission does.
CLI commands
The five commands are on CLI → Newsletters add-on commands, with their flags.
Everything else the newsletter needs is already a base plugin command. Migrations are bin/plugin kahunacart migrate, which covers this plugin's tables because it registers them on onKahunaCartRegisterMigrations. The worker that runs imports and sends campaigns is bin/plugin kahunacart work.
Templates a theme can override
Copy any of these into your theme at the same path.
| Template | What it draws |
|---|---|
templates/partials/newsletter-signup.html.twig |
The signup form the Twig function renders. |
templates/partials/newsletter-account-tab.html.twig |
The Email preferences tab in the customer account area. |
templates/newsletter-confirm.html.twig |
The double opt-in confirmation page. |
templates/newsletter-unsubscribe.html.twig |
The unsubscribe page. |
templates/newsletter-preferences.html.twig |
The preference centre. |
templates/newsletter-message.html.twig |
The page a non-JavaScript signup lands on. |
templates/emails/newsletter-campaign.html.twig |
The HTML half of a campaign. |
templates/emails/newsletter-campaign.txt.twig |
The plain-text half. Overriding this away is what the Health tab's plain-text check catches. |
templates/emails/newsletter-plain-layout.html.twig |
The plain campaign layout. |
templates/emails/newsletter-confirmation.html.twig |
The double opt-in email. |
templates/emails/newsletter-confirmation.txt.twig |
Its plain-text half. |
The signup form's contract with the route is small: email, optionally name, list, tags, source_ref, language and consent, plus nonce_field('kahunacart', 'nonce') and the honeypot field named website. The account tab's contract is its action, the nonce field and the lists[] checkbox names.
Add a segment field
Any plugin can contribute a field to the segment vocabulary. The event is onKahunaCartNewslettersRegisterSegmentFields, fired once the first time anything asks for the vocabulary.
public static function getSubscribedEvents(): array
{
return [
'onKahunaCartNewslettersRegisterSegmentFields' => ['onRegisterSegmentFields', 0],
];
}
public function onRegisterSegmentFields(Event $event): void
{
// Never a hard dependency: the newsletter may not be installed at all.
if (!class_exists(\Grav\Plugin\KahunaCartNewsletters\Segments\Registry::class)) {
return;
}
$registry = $event['registry'];
$registry->registering('my-plugin');
$registry->declare(
'loyalty_tier',
Operator::TYPE_SELECT,
'PLUGIN_MY_PLUGIN.SEGMENT_LOYALTY_TIER',
'Loyalty tier',
'Loyalty',
[Operator::IS, Operator::IS_NOT],
static function (Dialect $dialect, string $op, mixed $value): Compiled {
$sql = 'EXISTS (SELECT 1 FROM my_loyalty_members m'
. ' WHERE m.customer_id = s.customer_id AND m.tier = ?)';
$compiled = new Compiled($sql, [(string)$value]);
return $op === Operator::IS_NOT ? $compiled->negated() : $compiled;
},
static fn (): array => [
['value' => 'bronze', 'label' => 'Bronze'],
['value' => 'silver', 'label' => 'Silver'],
],
);
}
Five rules make up the whole contract.
The alias is s and it is the subscribers table. Your fragment is assembled into one WHERE with everybody else's, so it may not assume any join. Reach other tables with an EXISTS or a scalar subquery, correlated on s.id or s.customer_id.
Never interpolate a value. Bind it.
Declare only operators your type allows. A select takes is and is_not; a number takes eq, gte and lte. Declaring fewer is fine. Declaring one from another type is refused, because the editor picks its value control from the type alone.
First wins. The plugin registers its own fields before firing the event, so a key that is already taken is dropped and a line naming your plugin goes in the log. Namespace your keys.
Never a hard dependency. Check the class exists. A store that has your add-on and not the newsletter must not see an error.
If your listener throws, only your fields are lost. Whatever you registered before the throw stays, and the failure is logged with your message in it.
A segment saved with a field that has since gone away refuses when it runs and says which field. A campaign pointed at it refuses to start for the same reason.
Add a deliverability check
An add-on registers its own Health check on onKahunaCartNewslettersRegisterDeliverabilityChecks, fired once per run with a registry.
public function onKahunaCartNewslettersRegisterDeliverabilityChecks(Event $event): void
{
$registry = $event['registry'];
$registry->registering('my-addon');
$registry->add(new MyCheck($event['context']));
}
A check is a class implementing Deliverability\Check. key() is a stable snake_case identity, label() and every sentence a result carries are language keys, group() is domain, transport or list, and run() answers a Deliverability\Result built through Result::ok(), warn(), fail() or skip().
The context on the event carries the resolver, the transport, the from address and the counters, so a check reads what everything else reads.
The nine built-in checks are registered before the event fires, so a key that is already taken is refused and logged with the name of whoever tried. A listener that throws loses its own checks and nothing else. A check that throws when it runs becomes a failing row naming itself.
The customer card
The Newsletters card beside a customer is the widget kahunacart-newsletters.customer in the extension zone customer.details.side. Move it or reorder it from KahunaCart → Extensions. See Extension zones.