Skip to content
pcreative Commerce

The newsletter, with double opt-in

The backend keeps the list of people who want your newsletter.

The backend keeps the list of people who want your newsletter. Nobody joins by typing an email: they join when the owner of that address clicks the link in the confirmation email.

Campaigns are not sent from here: the list is synced with an email service (Brevo, Mailchimp or Resend) and the campaigns are written there. Read "Connecting an email service".

Why two steps

No law requires double opt-in, but the GDPR (Article 7.1) requires you to be able to prove that the owner of the address consented. A form that just stores the email proves nothing: anyone can type someone else's.

So each subscription stores:

  • the exact text that was shown next to the box, and its version;
  • the date, the IP of whoever asked, the IP of the storefront that passed the request on, and the browser;
  • the date on which the owner of the email clicked the confirmation link.

Without the consent text and its version, the subscription is refused.

How someone subscribes

The theme calls newsletter.subscribe(email, consent) from the commerce contract, with the text shown and its version, the language and, if it has them, where the form was, the IP and the browser.

  1. The subscription is stored as pending and an email goes out with a link.
  2. The link is /boletin/confirmar?token=… on the address in STOREFRONT_URL (or PCC_TIENDA_URL). It is a language-free entry route: the storefront sends it on to the visitor's language. Without that variable the link has no domain, so set it.
  3. The link works once and for seven days. When clicked, the theme calls newsletter.confirm(token) and the subscription becomes confirmed. If it has expired or was already used, confirm returns false.

The email goes out in the language the subscription came with: English or Spanish. Any other language, or none, gets English.

What the form never gives away

The answer is always the same — "pending" — whether the address was new, already pending or already confirmed. If it said "you are already subscribed", the form would tell anyone who is on your list.

And asking again does not become a way to flood someone's inbox: a pending address does not get another email until ten minutes after the last one. An address that is already confirmed gets nothing.

Unsubscribing

Every subscription has its own unsubscribe token. Unsubscribing asks for nothing but that link — it has to be as easy as subscribing (GDPR, Article 7.3). The theme calls newsletter.unsubscribe(token), which always answers yes, even if the person had already left.

Someone who unsubscribes and signs up again starts from zero: new pending state, new confirmation email. The old consent does not count for the new subscription.

Getting the list

The panel has a Newsletter screen (Customers → Newsletter) with the three counts, the list, the proof of consent for each person, an unsubscribe button and a CSV export.

From the management API, with any team account:

GET /gestion/boletin              # only the confirmed ones, with their unsubscribe token
GET /gestion/boletin/suscritos    # the whole list with the proof of consent
GET /gestion/boletin/csv          # the same, as CSV

/gestion/boletin returns only the confirmed ones, in the order they confirmed. Pending and unsubscribed addresses never come out.

Connecting an email service

Campaigns are not sent from here. What the store does is keep its list and the email service's list saying the same thing, in both directions. That sync is where this always breaks, and it is the part with legal consequences: someone who unsubscribes on the service and keeps receiving, or the other way round.

The screen is Newsletter → Connect a service (/boletin/conector), and only an Administrator can open it: these are a third party's keys. They are stored encrypted with the server's secret (PCC_CLAVE_CIFRADO, or JWT_SECRET if it is missing); without either, saving them is refused with a 503 rather than encrypted with something guessable.

Which services, and why

ServiceData lives inUnsubscribe noticesGood for
BrevoEuropean Unionunsubscribed, hardBounce, spamThe default choice in Europe: French company, no transfer framework needed, and the only one that tells the three things apart
MailchimpUnited Statesunsubscribe, cleaned (hard bounce)Plugging into an audience the owner already has
ResendUnited Statescontact.updated, email.complained, email.bounced, suppression.addedStores already sending their email with it

There is no common standard for this, and there is no point waiting for one: Mailchimp models a rich status enum, Brevo a blocklist boolean and Resend a global boolean. The only real standard nearby is RFC 8058 — the one-click List-Unsubscribe header — and it is about sending email, not about syncing lists. What the three do share is exactly the size of the connector's contract: the email as the identifier, a state that boils down to "receives / does not receive", and some untyped attributes.

Only one service can be connected at a time. Two services on the same list means two campaigns to the same inbox and two places to half-unsubscribe. Switching service re-sends everything, because the new service's list knows nothing about what was synced with the old one.

What goes each way

  • Confirmed subscription here → subscribed there. Never before confirming: what makes the list worth anything, and legal, is confirmed consent.
  • Unsubscribe here → unsubscribed there. By link, from the panel, it does not matter.
  • Unsubscribe there → unsubscribed here. By notice, or by the sweep that runs every 30 minutes.
  • A hard bounce or a spam complaint is an unsubscribe too, and it is stored as such (motivo_baja: rebote, queja), because carrying on writing to a mailbox that does not exist — or to someone who marked you as spam — sinks the domain's reputation and with it the order emails.

A subscription made on the service is not brought in. Consent lives here, and an address without its proof does not belong in this list.

And a subscription never resurrects someone who unsubscribed on the service. Mailchimp flatly forbids it (400, "Member In Compliance State"); in Brevo and Resend it would be possible, but it would mean writing again to someone who said no from the other side.

What happens when the service is down

An unsubscribe cannot be lost, so nothing is sent with a bare fetch inside the storefront's request. There are two paths, and both are needed:

  1. The queue. Confirming or unsubscribing writes a boletin.alta or boletin.baja event into the same database, right after the change. The dispatcher takes it to the service and retries with growing waits. If it gives up after six attempts, the event is not deleted: it is set aside, and the panel can retry it.
  2. The sweep (repasar-boletin, every 30 minutes). Each address stores what the service last confirmed it knows (sincronizado_estado) next to what it should know (estado). The difference is the pending work, and the sweep fixes it — whether it comes from a queue that gave up, a service that was down all afternoon, or a change of service.

The sweep asks first and pushes afterwards, and the order matters: pushing first could put someone back into the campaigns right after they unsubscribed on the service, before we had brought that unsubscribe in.

The notice address

The panel shows the address to paste into the service, and it carries its own key. That key is the only protection a service that does not sign its notices has (Brevo does not sign them), and the first door for the ones that do. Treat it like a password.

Where the service signs, the signature is verified as well: Mailchimp with X-Mailchimp-Signature (HMAC-SHA256 over timestamp.body, rejected if it is more than five minutes old) and Resend through Svix (svix-id, svix-timestamp, svix-signature, with the whsec_ secret). A notice that cannot be verified unsubscribes nobody.

The address is built from PCC_BACKEND_URL; set PCC_BOLETIN_WEBHOOK_URL only if the notices have to arrive somewhere else.

What is still missing

  • Sending. There are no campaigns, templates or a send button here: you write the newsletter in the email service and send it from there. Without a connected service, export the list and use your own tool.
  • The unsubscribe link in each email. If you send from a connected service, use its own unsubscribe link and the notice will come back here. If you send by hand, build the link with each person's token, for example /boletin/baja?token=… on your storefront.
  • The factory themes do not connect it. The newsletter section of base posts to an external https:// address you set in its settings (without one, the section is not drawn) and, in the demo, only shows a message and stores nothing; mascotas and cinematografico draw the form without sending it anywhere; dulce-obrador says "You're subscribed!" without storing anything. None of them has the confirm or unsubscribe page. A theme that wants a real newsletter has to call subscribe, confirm and unsubscribe.

See also