Newsletter
On Zkrinet: add the block under Building blocks in the panel — one click, your own code is untouched and a snapshot is taken first. The installation steps below apply if you run PocketBase yourself, for example after an export.
Subscription list with double opt-in, one-click unsubscribe in every mailing, and mailings sent in batches from the admin panel. No external services — the emails go through your own SMTP.
Requires the Mejl (email) plugin. Tested against PocketBase 0.40.4.
#Installation
- Install Mejl first (and set up SMTP), then copy
pb_hooks/andpb_migrations/to your PocketBase and restart. - Check Settings → Application URL — the links in the emails are built on it (
https://din-pb.se/api/zk/nyhetsbrev/...). zk_nyhetsbrev_installningaralready has one row:per_timme(sign-ups per IP and hour, default 5),omgang(emails per batch, default 50) andhemlighet(created automatically, hidden). The secret can instead be set with the environment variableZK_NYHETSBREV_HEMLIGHET.- Behind a proxy (Cloudflare, nginx)? Set Settings → Trusted proxy, otherwise all visitors share the same limit.
- The email templates
nyhetsbrev_bekraftaandnyhetsbrev_redanare inzk_mejlmallarand can be edited there.
#Sign-up from your site
await fetch("https://din-pb.se/api/zk/nyhetsbrev/prenumerera", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ epost: "anna@exempel.se" }),
})
// 200 { ok: true, meddelande: "Om adressen är giltig får du strax ett mejl…" } · 400 invalid address · 429 too many attempts
Or without JavaScript:
<form method="post" action="https://din-pb.se/api/zk/nyhetsbrev/prenumerera">
<input name="epost" type="email" required placeholder="din@adress.se">
<button>Subscribe</button>
</form>
How it works:
- Sign-up → the row is created with
status = vantarand a confirmation email is sent. - The link in the email opens a page with a button. Nothing is confirmed until the button is pressed — email scanners (Outlook, Gmail, corporate filters) open links but do not press buttons. The link is valid for 7 days and works once.
- The button →
status = bekraftad. Only now does the address receive mailings.
#Sending a mailing
- Create a row in
zk_nyhetsbrev_utskick:amne,innehall(HTML, the editor in the admin panel), optionallytext(plain text version — otherwise one is made from the HTML),status = utkast. - Start it (superuser, e.g. from an admin tool or curl):
POST /api/zk/nyhetsbrev/skicka/{id}
→ { status: "skickas", skickade_nu: 50, antal: 50, fel: 0 }
The first call sends one batch. The rest are sent automatically, one batch per minute (cron). Call again if you want to run the next batch right away. When all are done, status = skickat; a sent mailing cannot be sent again (409).
Each recipient gets a row in zk_nyhetsbrev_leveranser (skickat / fel). A unique key on (mailing, subscriber) ensures that the same letter never goes twice to the same address, even if cron and a manual call run at the same time. If an email fails (e.g. SMTP down), the batch is aborted and the recipient is retried in the next batch, at most 3 attempts.
Every mailing automatically gets a footer with an unsubscribe link — it cannot be forgotten. In the content you can use {{app}} (the app name) and {{avregistrera}} (the link) if you want it in more places. The content is locked when the mailing starts: if you change it in the meantime, everyone still gets the same letter.
#Unsubscribe
The link in every mailing unsubscribes directly with one click and works any number of times (idempotent). It is signed with the server's secret (HMAC), so nobody can unsubscribe someone else by guessing ids. POST to the same address also works, per RFC 8058 (one-click).
Anyone who signs up again after unsubscribing must confirm again.
#Security and privacy
- No enumeration: subscribe always responds exactly the same way for a valid address — new, pending, confirmed or unsubscribed. Someone who is already subscribed instead gets an email saying nothing has changed (with an unsubscribe link), so that every case sends an email and takes about the same time.
- Email bombing: at most one email per address per ten minutes from sign-up, no matter how many times someone presses. Plus rate limit per IP.
- The confirmation token is stored only as a SHA-256 hash and cleared when used. If the database leaks, it cannot be used to confirm anyone.
- The unsubscribe links are not stored at all — they are computed with HMAC. If you change
hemlighet, all old unsubscribe links stop working (and new emails get new ones). - IP is never stored. The counter stores a hash of secret + IP + hour and is deleted after one to two hours. The IP is still processed temporarily — mention this in your privacy policy.
- All collections are only readable by superusers. Guests and logged-in users cannot see the list; only superusers can send.
- Consent:
bekraftad(time) is your proof of when the address was confirmed. Unsubscribed rows remain withavregistrerad(time) so that they do not get more mailings; delete the row if the person wants to be removed entirely (the delivery log is deleted along with it).
#Limitations
- Every mailing has
List-UnsubscribeandList-Unsubscribe-Post: List-Unsubscribe=One-Click(RFC 8058) — Gmail's and Yahoo's unsubscribe button works. Requires the app's address (Application URL) to be https; otherwise the headers are not set (the link in the footer is always there). Gmail/Yahoo also require SPF/DKIM/DMARC for your sender domain — that is handled in your SMTP/DNS, not here. - One-click unsubscribe means that an email scanner that opens all links can accidentally unsubscribe someone. We chose that over an extra button: unsubscribing should be easy, and a wrongful unsubscribe is the harmless error.
- The response time is roughly the same in all cases, not exactly — the email is sent while the call waits. Someone who measures timings carefully against a slow SMTP might be able to see a difference between the cases. The response itself is identical.
- No segmentation, no lists, no statistics on opens or clicks (and no tracking pixels).
- The rate is
omgangemails per minute. Your SMTP provider has its own limits — setomgangaccordingly. - If the server crashes in the middle of an email, a recipient can be left as
skickarand skipped (better a missed email than a duplicate).
#Testing
./bank.py nyhetsbrev
18 tests against the test bench's SMTP trap (nothing reaches the internet): double opt-in with button, token hash-stored/single-use/expired, no enumeration, pause against email bombing, case-insensitive unique address, mailings only to confirmed, batches without duplicates, unsubscribe (one click, idempotent, RFC 8058 POST, forged link rejected), new sign-up requires new confirmation, permissions, rate limit and SMTP outage in the middle of a mailing. The security tests are mutant-verified.