Repository navigation
Email notifications: foundation (#114) - #121
Merged
Merged
Conversation
- 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.
This was referenced Apr 30, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the foundation phase of the email notifications system. Closes #114.
What ships
docs/notifications.md— full inventory of triggers, recipients, decisionsUser.notification_preferences— JSONB attribute, defaults applied at read time so adding triggers later doesn't need a backfillHuddlz.Notificationsorchestrator:deliver/3,should_deliver?/3, plusunsubscribe_token/2+verify_unsubscribe_token/1Huddlz.Notifications.Triggersregistry seeded with every trigger code from the specHuddlz.Notifications.SenderbehaviourHuddlz.Notifications.ICS— wraps theicalHex package, returns{filename, content}for Swoosh attachments (used later by E3, D1, D2)change_passwordAsh action/profile/notificationsLiveView with one toggle per trigger, grouped by category/unsubscribe/:tokencontroller — Phoenix.Token-signed, no auth required (token is the auth)Commits
102f754819705b4f91b225520882e519848b3de1a0be05575Each commit is
mix precommitclean and ships its own tests.Test plan
password_change_notification.feature— full password-change journey with email assertionnotification_settings.feature— opt-out toggle round-trip, profile→notifications nav linkunsubscribe.feature— valid token flips preference, invalid token shows errorICal.from_ics/1)update_notification_preferencesaction: self-update, merge semantics, unauthorized rejected776 tests passing (one pre-existing flaky
profile_picture.featurestep intermittently fails — unrelated, see commite4ac9ee).Known warts to address in a later commit
ProfileLiveandProfileLive.Notificationsboth have a defensivehandle_info({:email, _}, _)clause to ignore Swoosh test-adapter forwards (the adapter sends emails as messages to every pid in the\$callerschain, which includes the LV when delivery happens during ahandle_event). Long-term cleanup: switch to Oban-based async delivery, or migrate toSwoosh.Adapters.Sandboxin tests.HuddlCancelled,RsvpReceived, etc.) that don't exist yet — they'll land as the per-trigger phase issues ship (Email notifications: huddl lifecycle and remin 8000 ders (C1-C4, D1-D2) #115/Email notifications: RSVPs (E1-E3) #116/Email notifications: group membership (B1-B7) #117/Email notifications: account security (A3-A5) #118). The orchestrator never invokes them today (preference check short-circuits, or A3 is the only wired trigger) so nothing breaks.What's next
Per-trigger phase issues build on top of this: