Skip to content

feat(web): public web pages for broadcasts, team archive, RSS and sitemap - #97

Merged
clbotdev merged 1 commit into
mainfrom
issue-63-web-archive
Oct 19, 2026
Merged

clbotdev merged 1 commit into
mainfrom
issue-63-web-archive

Conversation

@clbotdev

Copy link
Copy Markdown
Contributor

Part of #79. Closes #63.

What

  • Data (migration 0019, additive). These are new columns, not tables:
    • On sequences: web_published, web_slug (unique per team), SEO title and description, OG image, web_noindex, web_canonical_url.
    • On settings: web_enabled, web_handle (unique), title, description, and the archive's signup form.
  • Publishing: GET/PATCH /sequences/:id/web, broadcasts only.
    • The slug is made from the title when none is given, and is kept when the issue is unpublished.
    • A page is live once it's published and the broadcast has started sending.
  • Archive settings: GET/PATCH /settings/web. Turning hosting on picks a unique handle from the team name and creates a "Web archive" signup form.
  • Platform API: GET /web/issues (paginated: excerpt from the text blocks, plus SEO fields) and GET /web/issues/:slug (sanitised HTML). Both work with hosting off.
  • Hosted pages (web/public-routes.ts):
    • /w/:handle: the archive, newest first, with a subscribe form and pagination.
    • /w/:handle/p/:slug: one issue, with title, description, canonical (or the sender's), OG and Twitter tags, Article JSON-LD and noindex.
    • /w/:handle/feed.xml: RSS 2.0 with content:encoded. An issue that fails to render is skipped rather than failing the feed.
    • /w/:handle/sitemap.xml: leaves out noindex issues and issues with an external canonical.
    • CSP default-src 'none', form-action 'self'; scripts and handlers stripped; Cache-Control: public, max-age=300.
  • Rendering: renderCampaignHtml(web: "public") renders with no subscriber data, no tracking, and the unsubscribe link removed.
  • Merge tag: {{ web_version_url }} and the footer's "view in browser" link go to the public page once the issue is published, otherwise to the reader's own web version. The tag is reserved, like the other platform variables.
  • MCP: get/update_broadcast_web_publishing, get/update_web_settings, list_web_issues, get_web_issue.
  • UI: a "Web page" dialog on broadcasts (publish, slug, SEO, social image, canonical, noindex, live URL) and a Settings → Web archive tab.
  • Docs: email-marketing/web-archive, plus updates to the broadcasts, transactional and MCP docs.

Verification

  • check.sh (format, lint, typecheck, 715 API tests incl. 9 new, web tests), check:drift, test:conformance.
  • Browser E2E (10/10):
    1. Enable the archive in Settings.
    2. Publish a broadcast before sending ("Live once sent"); the page 404s until it's sent.
    3. Send it. The email contains "Hi Pat!" and web_version_url pointing to the public page.
    4. The dialog shows Live.
    5. The public page has no name, email, tracking, unsubscribe or view links; it shows the public URL for web_version_url and has title, description, OG, canonical and JSON-LD.
    6. The archive lists the issue, and its form subscribes a reader with the web-archive tag after double opt-in.
    7. The RSS feed and sitemap include the issue.
    8. /web/issues and /web/issues/:slug return it.
    9. Unpublishing gives a 404 and removes it from the feed and sitemap.
    10. Turning hosting off takes the archive down.

Found along the way (not changed here)

A quoted Liquid filter in a marketing text block, e.g. {{ subscriber.name | default: "there" }}, fails the send: the text is HTML-escaped (") before Liquid runs. This already happened before this PR, and the docs here avoid recommending that syntax.

…emap

Broadcasts can be published to the web (dashboard, REST, MCP). Once sent,
a published broadcast gets a public page at /w/<handle>/p/<slug> without
personal data, tracking or an unsubscribe link, with title, description,
canonical, Open Graph/Twitter tags and Article JSON-LD (noindex on
request). The team archive at /w/<handle> lists issues with a signup form,
plus an RSS feed and a sitemap. {{ web_version_url }} and the footer's
"view in browser" link point to the public page once published.

Platforms can turn hosting off and render issues from GET /web/issues and
GET /web/issues/:slug instead.

Closes #63
@clbotdev
clbotdev merged commit 22569ac into main Oct 19, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Web version of broadcasts: public issue pages, team archive and "view in browser" link

2 participants