# 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=` - 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.