8000 Email notifications: foundation (#114) by mwoods79 · Pull Request #121 · huddlz-hq/huddlz · GitHub
[go: up one dir, main page]

Skip to content

Email notifications: foundation (#114) - #121

Merged
mwoods79 merged 20 commits into
mainfrom
feat/notifications-foundation
Apr 30, 2026
Merged

mwoods79 merged 20 commits into
mainfrom
feat/notifications-foundation

Conversation

@mwoods79
Copy link
Copy Markdown
Contributor

Implements the foundation phase of the email notifications system. Closes #114.

What ships

  • Spec at docs/notifications.md — full inventory of triggers, recipients, decisions
  • User.notification_preferences — JSONB attribute, defaults applied at read time so adding triggers later doesn't need a backfill
  • Huddlz.Notifications orchestrator: deliver/3, should_deliver?/3, plus unsubscribe_token/2 + verify_unsubscribe_token/1
  • Huddlz.Notifications.Triggers registry seeded with every trigger code from the spec
  • Huddlz.Notifications.Sender behaviour
  • Huddlz.Notifications.ICS — wraps the ical Hex package, returns {filename, content} for Swoosh attachments (used later by E3, D1, D2)
  • A3 (password-changed) sender — first real consumer; wired into the change_password Ash action
  • /profile/notifications LiveView with one toggle per trigger, grouped by category
  • /unsubscribe/:token controller — Phoenix.Token-signed, no auth required (token is the auth)

Commits

SHA Commit
102f754 docs: add email notifications spec
819705b feat(notifications): add notification_preferences to user
4f91b22 feat(notifications): add orchestrator, triggers, sender behaviour
5520882 feat(notifications): add ical dependency and ICS generator
e519848 feat(notifications): add password-changed sender (A3)
b3de1a0 feat(notifications): add notification settings page
be05575 feat(notifications): add unsubscribe controller and route

Each commit is mix precommit clean and ships its own tests.

Test plan

  • Cucumber password_change_notification.feature — full password-change journey with email assertion
  • Cucumber notification_settings.feature — opt-out toggle round-trip, profile→notifications nav link
  • Cucumber unsubscribe.feature — valid token flips preference, invalid token shows error
  • ExUnit orchestrator decision matrix (transactional/activity/digest × pref state × user state)
  • ExUnit ICS generator (envelope + identity + round-trip via ICal.from_ics/1)
  • ExUnit unsubscribe-token sign/verify, tampering, expiry, garbage
  • ExUnit A3 sender shape; live-sender path through orchestrator + skip behavior for disabled activity triggers
  • ExUnit update_notification_preferences action: self-update, merge semantics, unauthorized rejected

776 tests passing (one pre-existing flaky profile_picture.feature step intermittently fails — unrelated, see commit e4ac9ee).

Known warts to address in a later commit

What's next

Per-trigger phase issues build on top of this:

mwoods79 added 16 commits April 29, 2026 19:45
- Add :map attribute on User defaulting to %{} (private, not API-exposed)
- Add :update_notification_preferences action that merges a partial map
  onto the existing preferences (so adding new triggers later doesn't
  require a backfill)
- Self-only policy plus the existing admin bypass
- Migration generated by ash_postgres.generate_migrations

Foundation for the email notifications system documented in
docs/notifications.md (issue #114).
- Huddlz.Notifications.deliver/3: single entry point that resolves
  preferences, decides whether to send, and dispatches to a sender
- Huddlz.Notifications.should_deliver?/3: pure decision function for
  the preference matrix; useful in tests without going through the
  mailer
- Huddlz.Notifications.Sender behaviour: senders are pure builders
  returning Swoosh.Email; orchestrator owns the decision and delivery
- Huddlz.Notifications.Triggers registry: every trigger from the spec
  with category, default, sender module, and a label for the settings
  page. Sender modules are referenced by atom; most don't exist yet
  and ship in the per-trigger phase issues.
- Tests cover the full preference decision matrix (transactional /
  activity / digest × pref state × user state) and the registry shape.
- Add :ical ~> 2.0 (lpil's :icalendar 1.1 conflicts with our gettext
  via timex; expothecary's :ical has only optional deps)
- Huddlz.Notifications.ICS.event_for/1 returns {filename, content} for
  attaching via Swoosh.Email.attachment/2
- Description merges huddl description and virtual_link when present;
  location prefers physical_location, falls back to virtual_link
- Tests assert RFC5545 envelope, identity, timing, location/url, and
  round-trip parse via ICal.from_ics/1

Used later by E3 (RSVP confirmation), D1 (24-hour reminder), and D2
(1-hour reminder).
- Huddlz.Notifications.Senders.PasswordChanged: builds a security-notice
  email after a password change, with a link to /reset for users to
  recover if it was not them
- Wire after_action into the change_password Ash action so the
  notification fires automatically on every successful change
- Adjust the orchestrator: transactional emails go through even before
  email confirmation, so a security-critical mutation cannot happen
  without a notice landing
- ExUnit test for the sender's build/2 output
- Cucumber feature test exercising the full password-change journey
  end-to-end and asserting the email contents
- Defensive handle_info({:email, _}) on ProfileLive: Swoosh's test
  adapter forwards delivered emails as messages to every pid in the
  $callers chain, so the LiveView running the change action receives
  the message and would otherwise crash
- HuddlzWeb.ProfileLive.Notifications at /profile/notifications, in
  :authenticated_routes
- Renders one checkbox per registry entry, grouped by category.
  Transactional shown disabled-but-on for transparency; activity and
  digest are editable.
- Hidden+checkbox pair on each editable trigger so unchecked submits
  send "false" (browsers omit unchecked checkboxes otherwise).
- Form submit calls :update_notification_preferences on the User Ash
  resource via Ash.Changeset.
- Adds a "Notification preferences" link from /profile so users can
  reach the page.
- Cucumber feature: opt-out journey end-to-end + nav from profile.
- ExUnit hybrid case: deliver/3 with the live PasswordChanged sender,
  and skip behavior when an activity preference is off (proves the
  registry's not-yet-implemented sender is never reached when skipped).
- Defensive handle_info({:email, _}) to ignore Swoosh test-adapter
  forwards, matching ProfileLive.
- Phoenix.Token-signed unsubscribe links carry {user_id, trigger}.
  30-day max_age covers vacation-length inbox sits.
- Huddlz.Notifications.unsubscribe_token/2 and
  verify_unsubscribe_token/1 helpers — used by future email footers.
- HuddlzWeb.UnsubscribeController.show: verify token, look up user,
  flip the named trigger to false on notification_preferences,
  redirect to /profile/notifications with a confirmation flash.
  Invalid/expired tokens redirect home with an error flash.
- GET /unsubscribe/:token route in the :browser pipeline (no auth —
  the token is the authorization).
- ExUnit covers token round-trip, tampering, expiry, and garbage.
- Cucumber feature exercises the full URL journey (signed-in user
  visits valid token, sees flash, preference flips) and the invalid
  token path.

Closes the foundation work for issue #114; per-trigger phases (#115
huddl lifecycle, #116 RSVPs, #117 membership, #118 security extras)
can now build on top of this orchestrator + settings page +
unsubscribe URL stack.
Notification preferences are now reachable through the API the same way
they are through the LiveView, in line with the API-first convention.

- Add updateNotificationPreferences mutation to the User GraphQL block
- Add a controller test covering self-update, unauthenticated rejection,
  and cross-user authorization
…mented

The triggers registry references sender modules that ship in later phase
issues. Until then, calling deliver/3 for those triggers would surface as
a cryptic UndefinedFunctionError if a user happened to be opted in.

Guard the dispatch with an explicit check so the failure mode is obvious
the moment a new trigger is wired up against a missing sender.
… email

The password-changed email interpolated display_name into raw HTML, which
would render as markup if a user set a display name containing tags.
Escape it before interpolation.

Add a plain-text body so clients without HTML rendering still get the
security notice and the reset link.
Replace the hand-rolled checkbox/hidden pair with the project's <.input>
component, per the Phoenix-1.8 convention in CLAUDE.md.

Split the security tier into a read-only list rather than disabled
checkboxes — the disabled hidden inputs were tripping up PhoenixTest's
form-submission helper, and a static "On" indicator reads cleaner anyway
since users can't change those preferences.

Also tidy the Ash.Changeset.for_update pipeline so the keyword args sit
on their own line.
The from-name/from-address fallback chain was duplicated across all three
sender modules (the new password-changed sender plus the two
AshAuthentication senders). Centralize it on the Mailer module so adding
new senders doesn't require copy-pasting the config lookup.
The settings LiveView and the orchestrator each carried their own copy
of the nil/boolean/default lookup against notification_preferences.
Collapse both into a single public Notifications.preference_for/2 used
by the orchestrator, the LiveView checkbox state, and any future
caller that needs to know 'is this trigger on for this user'.
Email clients and safe-link prefetchers (Gmail, Outlook) hit unsubscribe
URLs eagerly, often more than once. Two changes to handle that gracefully:

- The GET render path no longer fetches the user — token verification and
  trigger lookup are enough to render the confirmation page. The user
  fetch happens only on POST when we actually need to mutate.
- opt_out short-circuits when the preference is already false, so a
  re-clicked link doesn't generate write amplification on the JSONB column.

Also defensively html-escapes the trigger label in the inline confirmation
HTML — the labels are static literals today but the interpolation was a
footgun for any future dynamic value.
Huddl.starts_at and ends_at are :utc_datetime, so the helper's
NaiveDateTime clause could never fire. Inline the DateTime.truncate
call and remove the helper.
Replace the inline html(conn, "...") string in UnsubscribeController.show
with a HuddlzWeb.UnsubscribeHTML view + confirmation.html.heex template.

The template wraps content in <Layouts.app> so the page picks up the
standard navbar, flash region, footer, and design-system styling. HEEX
also gives us safe-by-default escaping, so the manual
Phoenix.HTML.html_escape |> safe_to_string dance is gone.

Add a controller test asserting the layout shell renders.
…User resource

Flip notification_preferences to public? true so AshGraphql/AshJsonApi
include it on the user type. The :read action policies already restrict
reads to the actor themselves (or to deliberately-public projections like
:public_profile, which only selects id + display_name + avatar), so the
field's value is never visible to anyone but the owner.

Without this, an API client could mutate preferences via
updateNotificationPreferences but couldn't read them back to render its
own settings UI. The GraphQL :map exposure is the JsonString scalar, so
the value round-trips as a JSON-encoded string.
Add a :notifications queue, a DeliverWorker, and deliver_async/3 on the
orchestrator so notification emails can be sent off the request path.

The worker is a thin async wrapper — it looks up the user and calls
Notifications.deliver/3 (the existing synchronous code path), so
preferences, eligibility, and sender dispatch all stay in one place.

Worker behaviour:
- :sent / :skipped → :ok (no retry, the job did its job)
- mailer error → {:error, reason} (retries with exponential backoff)
- missing user / unknown trigger → {:cancel, reason} (no retry)

Trigger validity is checked at enqueue time so typos in caller sites
fail loudly there instead of as a cancelled job later.
Switch the change_password after_action from Notifications.deliver/3 to
Notifications.deliver_async/3, which enqueues an Oban job instead of
running SMTP inside the request.

Two consequences land in the same commit because they're entangled:

- Drop the defensive handle_info({:email, _}, _) clauses from ProfileLive
  and ProfileLive.Notifications. They existed only because Swoosh's test
  adapter forwards delivered emails to every pid in the $callers chain;
  with delivery happening inside an Oban worker process, the LV is no
  longer in that chain.

- The password_change_notification cucumber step now drains the
  :notifications queue before walking the test mailbox, since the email
  is no longer sent synchronously.
@mwoods79
mwoods79 merged commit 5dfb5f1 into main Apr 30, 2026
1 check passed
@mwoods79
mwoods79 deleted the feat/notifications-foundation branch April 30, 2026 11:34
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.

Email notifications: foundation (preferences, unsubscribe, settings page, .ics)

1 participant

0