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

230 lines
11 KiB
Markdown

# 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`:
```elixir
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.