Patch notes — native newsletter platform + editor, mail headers, CI hardening (2026-06-14, session 3)
A feature session on Ethica.no: a newsletter/campaign platform built natively
into the stdlib admin (no external service), followed by deliverability and
editor-UX fixes and a CI build-resilience change. All items below are merged to
main.
1 — Native newsletter platform ✅
A campaign mailer built directly into the platform — same http.server + Postgres
stack, no new service. The engine is a clean-room Python port of the
MailCarrier (MIT) campaign/template/delivery model (only the model is reused;
no upstream PHP is copied — newsletter.py carries the attribution header).
- Engine (
newsletter.py). Pure, import-safe helpers — layout render ({{content}}/{{unsubscribe}}slots), HTML→plaintext for the multipart alternative, and email-bound HMAC-SHA256 unsubscribe tokens — plus a background sender thread (started frommain(), mirroring the deploy-check loop) that drains queued recipients in batches over the dedicated newsletter SMTP, with a per-message throttle and per-recipient success/failure recording. - Own SMTP settings. The newsletter has its own SMTP configuration area
(separate from the contact-form mailer), stored in the settings table under
nl_smtp_host/port/security/user/pass,nl_from,nl_from_name,nl_reply_to,nl_throttle_ms. The password is held with the existingenc_secret/dec_secretenvelope. A “send test” button validates the config. - Admin (
/admin/newsletter). Five tabs — Subscribers, Campaigns, Templates, Delivery, Settings (?tab=aware). HTML-body compose with a branded layout, a live-preview template editor, and a per-recipient delivery log (sent / failed / error) — deliberately no tracking pixels. - Branded template.
migrate()idempotently seeds an “Ethica — branded” template (light, table-based, absolute-URL logo) alongside the seeded default; the email wordmark PNGs are served fromstatic/img/. - Compliance. Every send carries a working unsubscribe — a
{{unsubscribe}}slot, or an appended footer link if the template omits it — plusList-Unsubscribe/List-Unsubscribe-Postone-click headers. The public/newsletter/unsubscriberoute verifies the HMAC token and clearsconfirmed/newsletterflags. - Schema (idempotent,
scripts/init_db.py). Three new tables —newsletter_templates,newsletter_campaigns,newsletter_sends(+idx_nl_sends_campaign). - Routes.
/admin/newsletter(+?format=csv),/admin/newsletter/campaign(+?preview=1), and thecampaign/{save,test,send,duplicate,cancel,delete}/template/{save,delete}/settingsPOST actions — all CSRF-checked — plus the public/newsletter/unsubscribe.
2 — Mail deliverability: Date + Message-ID headers ✅
A test send scored a SpamAssassin penalty for a missing Date: header
(-1.396 MISSING_DATE). Message construction was factored into
newsletter.build_message(), which now always sets a proper Date and a
domain-correct Message-ID (MISSING_MID is the very next penalty); the same
two headers were added to the contact-form send_mail. Covered by a unit test.
3 — Campaign editor: full-width two-column layout + isolated preview ✅
The campaign edit page rendered cramped on the left — root cause: the form was
wrapped in .card-form (max-width:460px), squeezing subject, layout and body
into a narrow card, with the body shown as a tiny embedded card rather than an
email preview.
- Replaced with a full-width
.campaign-editor-grid: settings + body editor on the left, email preview on the right, stacking to one column under 1000px. - Subject / layout / source now span the panel. Actions are grouped — save + open-preview, then a separated send block (test, send-to-confirmed) and a visually-separated danger “Delete draft”. The layout selector’s no-layout option is relabelled “Raw HTML / no layout” to make that case explicit.
- The preview is now a real
<iframe>sourcing the existing?preview=1route, so the campaign renders through the samerender_campaign()path used at send time (layout + body + visible unsubscribe), in an isolated document the dark admin CSS can’t leak into — light email canvas, email-client width, with a Refresh button. It reflects the last saved draft; preview never mutates saved content, and there is no double-wrapping (body is stored as a fragment, wrapped only at render time). - CSRF, admin auth, and HTML handling unchanged.
4 — CI: build resilience against Docker Hub blips ✅
A deploy failed on a transient TLS handshake timeout pulling the
hugomods/hugo base image from Docker Hub. The Build and push step now retries
docker build --pull up to 3× with backoff, so a momentary registry blip no
longer fails the whole release.
Deployment status
All four items are merged to main and deploy via the standard CI build →
compose pull. Item 4 is the first build to run with the new retry in effect.
Verification
py_compileclean · newsletter unit tests 5/5 · docs_reader access / public-sections / recent-pages 10 / 4 / 13 · Docker smoke 74 / 0 (the newsletter create/send/dryrun/delivery/unsubscribe/templates flow and the new two-column editor + isolated-preview-iframe assertions all green).