Skip to content

Powered by Grav CMS + Helios Theme

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-newsletters plugin 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.

BASH
curl -sS https://example.com/shop/newsletter/subscribe \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","list":"newsletter","consent":true}'
JSON
{"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

TWIG
{{ 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:

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

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

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