← Back

Patch notes — newsletter follow-ups + native CMS (Hugo → live), Phase 1 (2026-06-15)

Two threads on Ethica.no: finishing the newsletter (deliverability + lifecycle fixes after the platform shipped) and the first phase of moving the marketing content off the Hugo static build into a live, DB-driven CMS rendered by server.py — so content edits are instant, no rebuild. All items are on main.

Companion bug-fix write-up: Bugfix/bugfix-report-2026-06-15.md.


1 — Newsletter: layout unsubscribe slot, opt-in over the right SMTP, distinct unsubscribed state ✅

  • {{unsubscribe_url}} layout slot. Templates can now style their own unsubscribe link with the raw URL ({{unsubscribe_url}}) in addition to the ready-made {{unsubscribe}} link — needed for a dark, branded footer where the default link colour is unreadable. A layout that provides either slot no longer gets the default footer link appended (which was producing a second, bare link); the fallback still appends when a layout uses neither, so every email stays unsubscribable.
  • Opt-in confirmation sends over the newsletter SMTP. The homepage double-opt-in confirmation was going through the contact-form mailer; it now sends over the dedicated newsletter SMTP (same From/identity as campaigns, with Date/Message-ID), falling back to the general mailer only if the newsletter SMTP isn’t configured.
  • Unsubscribe is a distinct, kept state. Unsubscribing set confirmed=0, which the admin list rendered identically to a never-confirmed “pending” signup. A new unsubscribed_at column (idempotent migration) marks it: the list shows a separate “unsubscribed” badge + stat, the row is excluded from sends, and re-confirming clears the flag. Manual Remove still hard-deletes.
  • Verified 10/10 on mail-tester end-to-end (signup → confirmation → confirm → campaign → one-click unsubscribe) over the self-hosted mail server.

2 — Native CMS: Hugo → live migration, Phase 1 ✅

The marketing blog posts and standalone pages can now be authored in the admin and served live from Postgres — no image rebuild to publish. Built so it’s non-breaking and incremental (full plan: ethica-no/migration/hugomigration.md).

  • Content model. A content table (kind = post | page, slug, body HTML, tags, status, published_at) + query helpers. Slugs reuse the docs reader’s Hugo-matching slugify, so migrated posts keep their /posts/<slug>/ URLs.
  • Editor. A Tiptap (ProseMirror, MIT) rich-text editor, vendored as a single self-contained bundle (built once with esbuild — the repo stays buildless), mounted over a body_html field with a </> Source fallback. New admin Content screen (Website group): list, create/edit, publish/unpublish/delete.
  • Live public render. server.py renders the blog index, posts, and pages class-for-class with the Hugo templates (same fonts + theme.css/components.css/ editorial.css + masthead + footer), so the look is unchanged. editorial.css and sidebar-hydrate.js moved to static/ (stable paths; Hugo still builds identically).
  • Non-breaking routing. /posts(/), /posts/<slug>/, and a top-level /<slug>/ (pages) render live only when a published DB row exists, otherwise they fall through to the static Hugo page. So with an empty content table the public site is byte-identical to before — content can be migrated one item at a time.

3 — CMS editor extras ✅

Four editor/render improvements:

  • Resizable body editor — drag the editor (and Source view) taller/shorter.
  • Image upload — pick a file → client-side resize → upload → the editor inserts a clean /uploads/<id> URL. Images are stored base64 in a new content_images table (airgapped, survive container recreation) and served decoded + long-cacheable, so the post HTML isn’t bloated with base64.
  • Public URL in the editor — shows the item’s live URL (/posts/<slug>/ or /<slug>/) with a Copy button, so a page can be linked elsewhere.
  • Pages default to the watermark layout — a page renders with the faint Σ-E backdrop + prose (the Privacy/Impressum look) unless the body HTML overrides it.

4 — Documentation ✅

  • Newsletter docs (ethica-no/newsletter/): a self-hosted setup & operations runbook and a 10/10 deliverability engineering post.
  • Migration plans (ethica-no/migration/): the Hugo → live CMS plan and a server.py break-out (monolith → ethica/ package) plan.
  • Spec-sheet updated: the newsletter is a shipped native platform (not the EthicaMail fork hosted); EthicaMail clarified as the renamed fork of upstream MailCarrier (MIT), ported into newsletter.py.

Deployment status

All Ethica.no changes are on main and deploy via the standard CI build → compose pull. The CMS is dormant on the public site until content is published (live routes shadow Hugo only where a DB row exists), so the merge carried no visible change.

Verification

py_compile clean · newsletter unit tests 7/7 · docs_reader tests 10 / 4 / 13 · Docker smoke 91 / 0 (incl. the live blog index/post render, editorial chrome + static assets, the static fallback for unknown slugs, image-upload round-trip, the watermark page, and the public-URL hint).