← Back

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 from main(), 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 existing enc_secret/dec_secret envelope. 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 from static/img/.
  • Compliance. Every send carries a working unsubscribe — a {{unsubscribe}} slot, or an appended footer link if the template omits it — plus List-Unsubscribe / List-Unsubscribe-Post one-click headers. The public /newsletter/unsubscribe route verifies the HMAC token and clears confirmed / newsletter flags.
  • 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 the campaign/{save,test,send,duplicate,cancel,delete} / template/{save,delete} / settings POST 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=1 route, so the campaign renders through the same render_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_compile clean · 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).