firehose/investigation/question-3-email-subscriptions.md
Firehose Bot fe156ef81e Add Dashboard nav link for authenticated users
- Add conditional "Dashboard" link in navbar that shows when a user is logged in
- Tests verify the link is hidden for unauthenticated users and visible when authenticated
- Investigation reports for Q1 (drafts/scheduled), Q2 (RSS), Q3 (email subs)
2026-07-06 09:11:13 +01:00

11 KiB

Question 3: Email Subscriptions — Investigation

1. Does any email subscription functionality already exist?

No. Zero. The codebase contains no newsletter, subscription, or subscriber functionality whatsoever.

A grep for newsletter, subscrib, subscriber, unsubscribe across all .ex and .heex files in the project returned zero matches.

2. What existing email infrastructure is already in place?

Mailer (Swoosh)

  • File: app/lib/firehose/mailer.ex (line 1-3)
  • Uses Swoosh.Mailer, otp_app: :firehose
  • Config uses Swoosh.Adapters.Local in dev (config.exs, line 40)
  • Config uses Swoosh.Adapters.Test in test (test.exs, line 24)
  • Production uses Swoosh.Adapters.SMTP via env vars (runtime.exs, lines 54-65)

UserNotifier (existing pattern for transactional emails)

  • File: app/lib/firehose/accounts/user_notifier.ex
  • Has two notification functions:
    • deliver_update_email_instructions/2 (line 41)
    • deliver_login_instructions/2 (line 53) — which dispatches to either deliver_confirmation_instructions or deliver_magic_link_instructions
  • Internal deliver/4 helper (line 19) constructs a Swoosh.Email, calls Mailer.deliver/1
  • Uses SENDER_NAME and SMTP_FROM_EMAIL env vars (lines 12-13)
  • Only sends plain-text emails (no HTML templates)

Accounts Context

  • File: app/lib/firehose/accounts.ex
  • Has deliver_user_update_email_instructions/3 and deliver_login_instructions/2
  • Uses UserToken.build_email_token/2 for token generation (hashed tokens stored in DB)
  • Token expiry: 15 minutes for magic links, 7 days for email change

Production SMTP config (runtime.exs, lines 46-64)

Uses env vars:

  • SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD, SMTP_SSL, SMTP_TLS

No Oban or background job system

  • mix.exs has no Oban dependency
  • No GenServer for background email sending
  • app/lib/firehose/application.ex — no Oban or job queue in the supervision tree
  • Current mailer calls are synchronous (not ideal for bulk sending)

3. What database tables exist?

Only one migration exists:

File: app/priv/repo/migrations/20260401201722_create_users_auth_tables.exs

Two tables:

  • users — id, email (citext, unique), hashed_password, confirmed_at, timestamps
  • users_tokens — id, user_id (FK), token (binary), context (string), sent_to (string), authenticated_at (utc_datetime), inserted_at (timestamps, no updated_at)

No subscriber table, no subscription table, no newsletter table exists.

4. What would a minimal email subscription feature need?

4.1 Database Model

New table: subscribers

Column Type Purpose
id bigserial PK
email citext, unique, not null Subscriber email
confirmed_at utc_datetime, nullable Set when double opt-in confirmed
unsubscribed_at utc_datetime, nullable Set when unsubscribed
token binary, nullable Confirmation token (hashed)
token_inserted_at utc_datetime, nullable When token was created
inserted_at utc_datetime Timestamps
updated_at utc_datetime Timestamps

The confirmed_at field is the canonical way to know if a subscription is active (following the same pattern as users.confirmed_at).

4.2 Migration

Would create the subscribers table with:

  • citext extension already exists (from the users migration, line 4)
  • Unique index on email
  • Index on confirmed_at for querying active subscribers

4.3 Subscription Form

Where to place it:

  • Homepage (app/lib/firehose_web/controllers/page_html/home.html.heex) — add a "Subscribe to newsletter" section
  • Blog index page (app/lib/firehose_web/controllers/blog_controller.ex) — could also have a subscription form
  • Footer of the layout (app/lib/firehose_web/components/layouts/app.html.heex) — persistent subscription form

Form would POST to a new SubscriptionController (or use a LiveView component).

4.4 Confirmation Email Flow (Double Opt-In)

  1. User enters email on subscription form
  2. Server creates subscriber record with confirmed_at: nil, generates a hashed confirmation token
  3. Server sends confirmation email with a link like GET /newsletter/confirm/:token
  4. User clicks link → confirmed_at is set → subscription is active
  5. User can unsubscribe via GET /newsletter/unsubscribe/:token (or a link in each email)

Pattern to follow: The existing UserToken + UserNotifier pattern in app/lib/firehose/accounts/user_token.ex shows how tokens are built, hashed, stored, and verified. The subscriber confirmation flow would be simpler — no need for the full context system, just a single-purpose token.

4.5 Sending New Posts to Subscribers

Options (in order of complexity):

  1. Simple GenServer — Polls for new published posts every N minutes, sends to confirmed subscribers. No external dependency, but no retry/backoff.
  2. Oban — Background job queue. Add {:oban, "~> 2.18"} to mix.exs. Schedule a recurring job (via Oban.Cron) to check for new posts and enqueue delivery jobs. Provides retries, observability, and proper error handling.
  3. Manual trigger — When a post is published (from the editor dashboard), fan out to subscribers. Requires hooking into the post-publishing flow.

Recommendation: The GenServer approach is simpler, but Oban is more robust. The current codebase has no background job system, so either would be an addition.

How to know which posts are new: The Blogex system compiles posts at build-time from markdown files, so there's no "published_at" event. A polling approach would need to track which posts have already been sent (e.g., a sent_posts table tracking post_id + sent_at).

4.6 Unsubscribe Mechanism

  • Each email would include a link like https://example.com/newsletter/unsubscribe?token=<token>
  • The token is a hashed version of the subscriber's ID or a dedicated unsubscribe token
  • Clicking the link sets unsubscribed_at and the subscriber is excluded from all future sends
  • No login required — one-click unsubscribe

4.7 New Files Needed

File Purpose
app/lib/firehose/newsletter/subscriber.ex Ecto schema for subscribers table
app/lib/firehose/newsletter.ex Context module (CRUD, subscribe, confirm, unsubscribe)
app/lib/firehose/newsletter/notifier.ex Email sending (confirmation, new post)
app/priv/repo/migrations/*_create_subscribers.exs Migration
app/lib/firehose_web/controllers/newsletter_controller.ex Handle confirm/unsubscribe links
OR app/lib/firehose_web/live/newsletter_live.ex LiveView component for subscription form
app/lib/firehose/newsletter/sender.ex GenServer or Oban worker for sending to subscribers

5. Roughly how complex is this? Estimate the scope.

Complexity: Medium (roughly 2-3 days of focused work)

Breakdown by layer:

Layer Effort Details
DB schema + migration 1-2 hours Simple table, following existing patterns
Ecto schema + context 2-3 hours CRUD, changeset validations, subscribe/confirm/unsubscribe
Confirmation email flow 2-3 hours Token generation, email sending, link handling
Subscription form UI 2-3 hours Form on homepage, validation, success/error states
Unsubscribe mechanism 1-2 hours Token-based one-click unsubscribe
Email sending to subscribers 4-6 hours The bulk of the work — polling mechanism, tracking sent posts, formatting emails
Tests 3-4 hours Following existing test patterns in accounts_test.exs
Total ~15-22 hours

Risk factors:

  • The blog posts are compiled at build-time, so there's no "post published" event to hook into. A polling mechanism or a manual "send to subscribers" button in the editor dashboard would be needed.
  • No background job system exists — either Oban needs to be added (additional dependency, migration) or a simpler GenServer approach is used.
  • Sending bulk emails from a single-threaded GenServer could block. Oban would handle this properly with concurrent workers.

Simpler alternative (MVP): A "Subscribe to newsletter" form that stores emails, with a manual CSV export workflow. No automated sending. This would be ~half the effort but would not be a real solution.

6. Existing patterns to follow

User Auth Token Pattern (for confirmation tokens)

File: app/lib/firehose/accounts/user_token.ex

The UserToken module shows:

  • Tokens are generated with :crypto.strong_rand_bytes(32) (line 58)
  • Tokens are hashed with :crypto.hash(:sha256, token) before storage (line 79)
  • Raw tokens are Base64-url-encoded for URLs (line 80)
  • Verification decodes, re-hashes, and queries the database (lines 97-112)
  • Context system distinguishes token types (e.g., "session", "login", "change:email")

For subscriber confirmation, we could either:

  • Reuse users_tokens table with a new context like "subscribe_confirm"
  • Create a simpler token field directly on the subscribers table

The simpler approach (token field on subscribers table) is recommended to avoid coupling to the auth system.

User Notifier Pattern (for email sending)

File: app/lib/firehose/accounts/user_notifier.ex

The deliver/4 function (line 19) constructs a Swoosh.Email:

defp deliver(recipient, subject, body) do
  email = new() |> to(recipient) |> from(sender()) |> subject(subject) |> text_body(body)
  with {:ok, _metadata} <- Mailer.deliver(email), do: {:ok, email}
end

A newsletter notifier would follow this pattern exactly, possibly adding HTML body support.

Accounts Context Pattern (for CRUD operations)

File: app/lib/firehose/accounts.ex

The Accounts context module shows the standard pattern:

  • Repo.get_by/3 for lookups
  • changeset + Repo.insert/1 for creation
  • Repo.transact/1 for transactional operations
  • Repo.update/1 for updates
  • Function naming convention: subscribe/1, confirm_subscriber/1, unsubscribe/1

Scope Pattern (for authorization)

File: app/lib/firehose/accounts/scope.ex

For newsletter subscriptions, scopes aren't directly relevant (subscribers are anonymous). But the pattern shows how struct-based contexts are used.

Migration Pattern

File: app/priv/repo/migrations/20260401201722_create_users_auth_tables.exs

The migration shows:

  • execute "CREATE EXTENSION IF NOT EXISTS citext", "" (line 4) — already exists
  • create table(:users) with timestamps(type: :utc_datetime) (line 6)
  • create unique_index(:users, [:email]) (line 11)

Test Fixture Pattern

File: app/test/support/fixtures/accounts_fixtures.ex

Shows the fixture pattern:

  • unique_user_email/0 — generates unique emails using System.unique_integer()
  • valid_user_attributes/1 — merges defaults with overrides
  • user_fixture/1 — creates a real DB record

Summary

Email subscriptions do not exist in any form. The infrastructure is there (Swoosh mailer, SMTP config, email sending patterns), but the feature is a clean-slate addition. The heaviest part would be the automated sending of new posts to subscribers, which requires either adding Oban or building a polling GenServer, and solving the "which posts are new" problem since posts are compiled at build-time rather than published at runtime.