- 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)
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.Localin dev (config.exs, line 40) - Config uses
Swoosh.Adapters.Testin test (test.exs, line 24) - Production uses
Swoosh.Adapters.SMTPvia 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 eitherdeliver_confirmation_instructionsordeliver_magic_link_instructions
- Internal
deliver/4helper (line 19) constructs aSwoosh.Email, callsMailer.deliver/1 - Uses
SENDER_NAMEandSMTP_FROM_EMAILenv 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/3anddeliver_login_instructions/2 - Uses
UserToken.build_email_token/2for 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, timestampsusers_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 |
| 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:
citextextension already exists (from the users migration, line 4)- Unique index on email
- Index on
confirmed_atfor 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)
- User enters email on subscription form
- Server creates subscriber record with
confirmed_at: nil, generates a hashed confirmation token - Server sends confirmation email with a link like
GET /newsletter/confirm/:token - User clicks link →
confirmed_atis set → subscription is active - 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):
- Simple GenServer — Polls for new published posts every N minutes, sends to confirmed subscribers. No external dependency, but no retry/backoff.
- 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. - 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_atand 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_tokenstable with a new context like"subscribe_confirm" - Create a simpler token field directly on the
subscriberstable
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/3for lookupschangeset+Repo.insert/1for creationRepo.transact/1for transactional operationsRepo.update/1for 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 existscreate table(:users)withtimestamps(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 usingSystem.unique_integer()valid_user_attributes/1— merges defaults with overridesuser_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.