Skip to content

Powered by Grav CMS + Helios Theme

Subscribers, lists and tags

This page explains how people subscribe, what the store records about their consent, and how to manage subscriber records and unsubscribe requests.

Before you begin

  • At least one list. See Getting started.
  • kahunacart.newsletters.manage for anything that writes.

What the store records

Every signup writes these to the subscriber row: the normalised address and its SHA-256, the name when one was given, the source, the source_ref, the moment they agreed, the address they agreed from, a SHA-256 of the exact consent sentence they were shown, and their language. The membership row records which list, at what status, and from what source.

The consent sentence is hashed rather than stored. Changing consent_text changes what new people see and leaves everybody who already signed up alone.

The five statuses

Status Means
pending They signed up on a list that asks people to confirm, and have not clicked the link yet. Nothing is mailed to them.
subscribed Mailable.
unsubscribed They left, through a link, the one-click header, the preference centre, or the admin.
bounced A provider reported a hard bounce, or three soft bounces in thirty days. The address is suppressed.
complained Somebody pressed the spam button. The address is suppressed.

The six sources

form, api, checkout, account, import and admin. A subscriber's own screen shows their source, and a segment can ask about it.

Put a signup form on a page

There are three ways. All three write the same consent record.

The Twig function

Anywhere in a theme:

TWIG
{{ kahunacart_newsletter_form() }}

That draws the form, posts to {base}/newsletter/subscribe, and puts the store's consent sentence beside a checkbox. With JavaScript it posts as JSON and prints the answer in place. Without it, the same form posts normally, carrying the storefront nonce. It does not need a session.

The first argument is the list's code, and the second is a map of what you want to change:

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'
}) }}

An unknown list code falls back to the store's default list. name: true draws a name field. tags are created on demand. source_ref is what the subscriber's history says about where they came from, so set it when a site has more than one form.

consent_text overrides the store's sentence for this one form. Whatever you pass is what gets hashed, so it has to be the sentence that was actually shown.

To change the markup, copy templates/partials/newsletter-signup.html.twig into your theme at the same path. The route expects email, optionally name, list, tags, source_ref, language and consent, plus nonce_field('kahunacart', 'nonce') and the honeypot field.

The Form plugin action

For a site already using the Form plugin, use the newsletter process action:

YAML
process:
    -
        newsletter:
            list: early-access
            tags: [site]
            consent_field: consent
Parameter Default What it does
list the store's default list The code of the list people join. A code that does not exist falls back to the default.
tags none Tags put on everybody who signs up through this form. A YAML list, or one string with commas in it.
email_field email The form field holding the address.
name_field none The form field holding their name.
consent_field none The form field that is the opt-in box.
consent_text the store's consent_text The wording that was shown, when the theme renders it outside the form definition.
language none A language tag stored against the subscriber, for a multi-language site.

When consent_field names a checkbox, toggle or switch field, the box has to be ticked. Leaving it empty is a refusal with its own message, and nothing is written. A consent_field pointing at a display field is read for its wording alone.

The sentence hashed against the consent record comes from the first of these that exists: the consent_text parameter, the label of the field named by consent_field, then the store's own consent_text setting.

A form with xhr_submit: true works unchanged. The action sets form.status and form.message exactly as an ordinary submission does.

The JSON route

For a signup written in JavaScript, or for anything that is not a browser:

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."}

See Developers for the fields, the statuses and the CSRF rule.

The honeypot

The partial carries a field called website that a person never fills in. It is a real, labelled input moved off screen, since form fillers skip hidden inputs. A submission with it filled in gets the ordinary thank-you message and writes nothing.

What the visitor is told

There are only two replies. "Thanks. You are on the list." for a list with no confirmation step, and "We have sent you a confirmation email. Check your inbox and press the button in it to be added to the list." for one with.

An address the store already has gets the same reply as a new one, and so does a suppressed address. Any other reply would tell a stranger that a particular person shops here.

The one exception is something that is not an email address at all, which is a 422 and a sentence saying so.

double_opt_in is the default for a new list, and each list can override it. On a list that asks, a confirmation email goes out and nothing else is mailed until the link in it is clicked.

The link draws a page with one button, and pressing the button is a POST, so a mail scanner that opens every link cannot confirm a signup by itself.

confirm.auto_submit is on by default. The page presses its own button from a script as soon as it loads, so a person who clicked the email lands on "you are on the list" without a second click. A scanner that does not run scripts only sees the page with the button.

confirm.on_get makes the link confirm on being opened. Leave it off; it loses the record that a person acted.

Leaving

Every campaign carries an unsubscribe link and the List-Unsubscribe header that puts an unsubscribe button in Gmail's own chrome. Both go to {base}/newsletter/u/{token}, and both act immediately.

The link draws a page with one button, for the same mail-scanner reason as the confirmation link. The header's one-click POST acts on the spot. The page also links to the preference centre for somebody who wants to leave one topic rather than all of them.

unsubscribe.confirm_on_get is the equivalent switch for the unsubscribe link, separate from confirm.on_get. Both are off by default.

The preference centre

Every campaign's footer links to {base}/newsletter/p/{token}, and so does the unsubscribe page. The page shows the person's address, their name, a checkbox for every list they hold a membership on, a checkbox for every list you have marked public, and a language select on a multi-language site. Nothing is written until the save button is pressed.

On the preference centre is a switch per list on the Audience screen. It starts off for every list, including on a store upgrading from an earlier version. A list with it off is joined only through a form, an import or the admin, and never appears in front of anybody who is not already on it.

Joining a list from here does not ask for a second confirmation. Somebody who has not confirmed yet is different: their new list joins as pending, they get no second email, and the confirmation they already have confirms every list they are waiting on at once.

Unticking a list is an unsubscribe on that list, recorded with preferences as the source. Unticking everything leaves them unsubscribed outright.

A list id typed into the form by hand does nothing. Only the lists the page drew count. A dead token is a 410 with a sentence saying so.

The account tab

A signed-in customer gets the same screen at {base}/account/email-preferences, under Email preferences in the account navigation. It is the same form and the same rules; the only difference is how the store knows who is asking.

A customer who has never subscribed gets the public lists with every box clear. Ticking one creates their subscriber on save with account as the source, confirmed straight away. Ticking nothing and pressing save writes nothing. A suppressed address is still never written.

To restyle the tab, copy templates/partials/newsletter-account-tab.html.twig into your theme at the same path. Keep the form's action, the nonce field and the lists[] checkbox names.

Checkout opt-in

One unticked checkbox in the contact section of your checkout, reading "Email me about news and offers". It is declared like every other checkout field, so KahunaCart → Checkout can relabel it, move it, require it in one country, hide it in another, or switch it off. It never makes an order fail.

It acts when the money lands: either when an order completes already paid, or when an order becomes paid afterwards. Both end in the same single write, and a second order from the same address adds nothing.

The consent record is stamped with the moment the order was placed, not the moment the payment arrived.

Which list they join is checkout.list, named by its code. Leave it empty and they go to your first list in the order on the Audience screen, not the one flagged as the default. A store with no lists at all gets one line in logs/grav.log naming the order and no subscriber.

Lists and tags

Lists show the number of people a campaign to each would reach: the subscribed ones, not the pending or the departed.

There is no delete for a list. Memberships are consent records. Rename and empty a list nobody should use again.

Tags show how many people carry each one. Deleting a tag takes it off everybody who has it and changes nothing else.

Work on one subscriber

Search matches the start of an address or a name, so it can use the database index.

One subscriber's screen is the consent record: when they agreed, from what address, whether the store still holds the sentence they were shown, which import they came in with, the customer record they are linked to, whether the address is suppressed, and every campaign they have been part of.

Action What it does
Confirm by hand Moves somebody who is still waiting to confirm. The memberships are recorded as confirmed by the store rather than by the person.
Unsubscribe Takes somebody off every list, recorded as done by the store.
Put them back on Refused for an address that bounced or complained, and for one on the suppression list. Remove the suppression first, on its own screen.
Delete Removes the person, their lists, their tags, every send they were part of and every click they made. It leaves the suppression table alone.

The email address cannot be edited. Every consent record, suppression, send row and signed link is tied to it. Delete and re-add instead.

The per-subscriber signing secret is never sent to the browser.

Note

Test addresses are not on the list. Sending yourself a test copy of a campaign writes a real subscriber row, because the links have to be signed with somebody's secret. That row is left out of the table, the chips above it, the four tiles on the Overview, the growth line and the CSV export. The Test addresses chip on the Subscribers screen is the one place it appears, and it is only drawn on a store that has sent a test.

Suppressions

The addresses this store will never mail again. The audience of every campaign is checked against this table before a single recipient row is written.

A row is keyed by the SHA-256 of the address, so it survives an erasure: the address column is nulled and the hash stays. The screen says "address erased" for those.

Adding one by hand takes reason manual or not a valid address. Bounces and complaints are recorded by the store itself from what the provider reports.

Removing one is behind a confirmation. It does not resubscribe anybody; do that on the subscriber's own screen.

Warning

The suppression table is the one thing bin/plugin kahunacart reset --customers leaves alone. Everything else the newsletter owns is emptied by a reset.

Import a contact list

Export your contacts from whatever you are using now, unzip the download if it came as a ZIP, then:

  1. Go to Newsletters → Subscribers → Import a CSV.
  2. Pick the file. The preview shows the header row, the first twenty rows, and a dropdown above each column.
  3. Correct any dropdown that is wrong. Changing one re-reads the file under the new mapping.
  4. Leave Ask everybody to confirm off for a list from another tool.
  5. Click Start.

Start stays disabled until a column is mapped to the email address and no two columns point at the same field. The run happens on the worker, with the counters written every five hundred rows.

What each column can be

Field What it is
email The address. Required. Trimmed and lower-cased on the way in.
name A whole name in one column.
first_name, last_name Or the two halves. name wins where a file has all three.
tags One cell holding one or more tag names, separated by commas, semicolons or pipes.
status subscribed, unsubscribed or pending, in any capitalisation. Anything else reads as subscribed.
subscribed_at ISO-8601, a plain date, or a bare epoch. A date that cannot be read becomes the moment of the import.
language A language tag like en or en-GB. A column holding the word "English" is ignored.

What each row does

Counter What it means
New An address the store did not have. A subscriber was created.
Already here An address it did. The name, the list membership and the tags were brought up to date; nothing was downgraded.
Skipped Nothing was written. No usable address, a duplicate earlier in the same file, or the file says that person never confirmed.
Suppressed The row ended in a suppression. Either the file says they unsubscribed, or the store already has that address suppressed, or the store has seen it bounce or complain.

The counters add up to the number of rows in the file. Both the screen and the CLI break the skipped and suppressed counts down by reason.

The three things an import will not do

It will not revive a bounced or complaining address. Those are facts about mail this store sent, and a CSV from another tool cannot overturn them.

It will not mail a suppressed address. The suppression check runs before anything is written, by a hash of the address.

It will not downgrade anybody. Somebody the store already has as subscribed stays subscribed, whatever the file says.

What "confirmed" means on import

Ask everybody to confirm (--ask-to-confirm on the command line) decides whether the people in the file already agreed somewhere else.

Off, the default: they arrive subscribed and confirmed, with the consent date taken from the file where it has one, and nobody is asked anything. Use this for a contact export from another mailing tool.

On: they arrive pending and each is sent a confirmation email. Nobody is mailed a campaign until they click it.

Caution

Re-confirming an imported list is how a store turns twelve thousand subscribers into four hundred. Turn it on only for a list you bought, inherited, or assembled out of three spreadsheets.

Rows the file marks as unsubscribed become suppressions with reason manual and the note import: {your file name}. Import them too; importing only the subscribed rows means your first campaign lands with people who opted out of the old tool.

Rows the file marks as pending are counted as skipped and nothing is written.

The EmailOctopus preset

An export from EmailOctopus is recognised by its header row and the mapping fills itself in. The download is a ZIP, so unzip it and hand the importer the .csv inside. Their export dialog offers "visible fields" or "all fields", so the header row varies.

Field Headings recognised
email Email address, Email
first_name First name
last_name Last name
status Status
subscribed_at Subscribed at, Created at, Date added
tags Tags

A file is taken to be theirs when it has a recognisable address column and at least one other heading from that table.

There is no preset for Mailchimp, Buttondown, ConvertKit or anybody else. Map their columns by hand; it takes about twenty seconds.

What a file may contain

  • A byte-order mark. A file saved from Excel starts with three invisible bytes. They are stripped.
  • Semicolons instead of commas. The header line is sniffed and the delimiter that produces the most columns wins. Tabs work too.
  • Quoted fields with commas and line breaks in them. Row numbers count records the way a spreadsheet does, not physical lines.

A file may be up to 32 MB through the browser. A bigger one goes in from the command line, which has no limit.

An upload is stored under user/data/kahunacart-newsletters/imports with a generated name, and removed as soon as the import finishes, whether it worked or not. The directory is deny-all to the web on Apache; on nginx or Caddy the rules that already cover user/data cover it too. import.keep_days sweeps anything left behind by an upload whose job never ran.

A run that is interrupted does not pick up where it stopped. Start it again from the top. Running the same file twice reports the same rows under Already here and changes nothing else.

Export your subscribers

From the admin it is the Export button, with the same filters the Subscribers table is showing. From the command line:

BASH
bin/plugin kahunacart-newsletters export:csv --file=subscribers.csv
bin/plugin kahunacart-newsletters export:csv --status=subscribed --list=early-access

Ten columns: email, name, status, lists, tags, source, consent_at, confirmed_at, unsubscribed_at, language. Dates are ISO-8601 in UTC.

The lists column carries the codes of the lists somebody is on, space-separated. A list they left appears as code:unsubscribed, so that fact survives a round trip through another tool.

Three things are never in the file: the per-subscriber signing secret, the IP address the consent came from, and the hash of the consent sentence.

How subscribers and customers are linked

A subscriber and a customer are two records for one person. The join is by lowercased email address and lives in one column, kahunacart_newsletters_subscribers.customer_id.

Most orders are linked on the spot: when an order completes, when it is paid, and when a cart comes back from a recovery link. Everything else is caught by a background sweep, newsletter.link_customers, queued from the worker at most every fifteen minutes. It walks both directions from high-water marks it keeps in kahunacart_newsletters_state.

A subscriber that already names a customer is never repointed. Two customer records sharing an address is a merge somebody has to do by hand.

A customer record never creates a subscriber. Neither an order nor the background sweep creates one.

To make the sweep start again from the beginning, delete the two rows in kahunacart_newsletters_state whose names start link_customers.after_.

Open a customer in the admin and there is a Newsletters card beside their record: which lists they are on, the last campaign they were sent with its date, whether they opened it, whether it bounced, and a link to their subscriber screen. A customer with no subscriber gets no card. Move or reorder it from KahunaCart → Extensions; it is the widget kahunacart-newsletters.customer in the zone customer.details.side.

Erasure

When a customer is erased, everything the newsletter holds about them is deleted, not anonymised: the subscriber row, their list memberships, their tags, every send row they were part of and every click they made.

The one thing that survives is a suppression row, keeping only a SHA-256 of the address. Erasure is never blocked by anything in the newsletter.