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

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
102f754
docs: add email notifications spec
mwoods79 Apr 29, 2026
819705b
feat(notifications): add notification_preferences to user
mwoods79 Apr 30, 2026
4f91b22
feat(notifications): add orchestrator, triggers, sender behaviour
mwoods79 Apr 30, 2026
5520882
feat(notifications): add ical dependency and ICS generator
mwoods79 Apr 30, 2026
e519848
feat(notifications): add password-changed sender (A3)
mwoods79 Apr 30, 2026
b3de1a0
feat(notifications): add notification settings page
mwoods79 Apr 30, 2026
be05575
feat(notifications): add unsubscribe controller and route
mwoods79 Apr 30, 2026
aee3f4f
fix(notifications): preserve mail errors and confirm unsubscribe
mwoods79 Apr 30, 2026
7d3029a
feat(notifications): expose update_notification_preferences via GraphQL
mwoods79 Apr 30, 2026
9d63358
fix(notifications): raise clearly when a registered sender is unimple…
mwoods79 Apr 30, 2026
9c49d34
fix(notifications): escape display name and add text body to password…
mwoods79 Apr 30, 2026
15d7cea
refactor(notifications): use core_components <.input> on settings page
mwoods79 Apr 30, 2026
a7d22d7
refactor(mailer): extract Huddlz.Mailer.from/0 helper
mwoods79 Apr 30, 2026
75e5e4f
refactor(notifications): expose preference_for/2 and dedupe resolution
mwoods79 Apr 30, 2026
f1837a7
perf(notifications): avoid extra DB work on unsubscribe link clicks
mwoods79 Apr 30, 2026
233dbf5
refactor(notifications): drop dead NaiveDateTime branch in ICS
mwoods79 Apr 30, 2026
c439fff
fix(notifications): render unsubscribe confirmation via HEEX template
mwoods79 Apr 30, 2026
b6059a8
feat(notifications): expose notification_preferences for read on the …
mwoods79 Apr 30, 2026
c0c54a1
feat(notifications): add Oban-based async delivery
mwoods79 Apr 30, 2026
5b2838a
refactor(notifications): deliver password-changed asynchronously
mwoods79 Apr 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ config :huddlz, Oban,
notifier: Oban.Notifiers.Postgres,
queues: [
default: 10,
notifications: 10,
profile_picture_cleanup: 5,
group_image_cleanup: 5,
huddl_image_cleanup: 5
Expand Down
178 changes: 178 additions & 0 deletions docs/notifications.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# Email notifications

Spec for huddlz email notifications. The full inventory of triggers, who gets each one, and the order to build them in.

Status: spec only. Implemented today: account confirmation (A1) and password reset (A2) via AshAuthentication. Everything else is unbuilt.

Out of scope: mobile push, in-app notifications, SMS.

## Categories

Every email lands in one of three buckets:

| Category | Always send? | Examples |
|---|---|---|
| Transactional | Yes. No opt-out. | Account confirmation, password reset, security alerts, huddl cancelled, you were removed from a group |
| Activity | Default ON, per-category opt-out | Huddl reminders, new member joined, RSVP changes, role changes, new huddl in your group |
| Digest | Default OFF, opt-in | Weekly upcoming-huddlz digest, "what you missed" recaps |

Activity and Digest emails need an unsubscribe link in the footer (per-category and full opt-out).

## Triggers

Each trigger has an ID (e.g. C3) used throughout the doc and the GitHub issues.

### A. Authentication & account

| ID | Trigger | Recipient | Category | Status |
|---|---|---|---|---|
| A1 | User registers | New user | Transactional | EXISTS (`SendNewUserConfirmationEmail`) |
| A2 | Password reset requested | User | Transactional | EXISTS (`SendPasswordResetEmail`) |
| A3 | Password successfully changed | User | Transactional | "If this wasn't you…" security notice |
| A4 | Email address changed | Old + new email | Transactional | Security notice on both addresses |
| A5 | Account role changed by admin | User | Activity | Triggered from `lib/huddlz_web/live/admin_live.ex:45` |

### B. Group membership

| ID | Trigger | Recipient | Category | Notes |
|---|---|---|---|---|
| B1 | User joins a public group | Group owner + organizers | Activity | One email per join. Cap at N/day if a group goes viral. |
| B2 | User added to a private group | The added user | Activity | "You're now a member of X" |
| B3 | User removed from a group | The removed user | Transactional | They lose access |
| B4 | User's role changed (member ↔ organizer, etc.) | The user | Activity | |
| B5 | User leaves a group on their own | — | None | They did it themselves |
| B6 | Group deleted/archived | All members | Transactional | They lose access to data they care about |
| B7 | Group ownership transferred | Old + new owner | Transactional | |

### C. Huddl lifecycle

| ID | Trigger | Recipient | Category | Notes |
|---|---|---|---|---|
| C1 | New huddl created in a group | All group members | Activity | Default ON. Body links to settings page. |
| C2 | Huddl details meaningfully changed | All current RSVPs | Activity | "Meaningful" = `starts_at`, `ends_at`, `location`, `virtual_link`, `title`. Skip cosmetic edits. |
| C3 | Huddl cancelled / deleted | All current RSVPs | Transactional | Highest priority. People may have travel plans. |
| C4 | Recurring series modified | RSVPs of the next upcoming instance only | Activity | Subsequent instances are covered by their own reminders (D1/D2). |

### D. Huddl reminders (time-based, Oban cron)

| ID | Trigger | Recipient | Category | Notes |
|---|---|---|---|---|
| D1 | Huddl starts in 24 hours | All RSVPs | Activity | Most important reminder. Includes `.ics`. |
| D2 | Huddl starts in 1 hour | All RSVPs | Activity | Make virtual link prominent. Includes `.ics`. |
| D3 | Huddl ended (post-event) | RSVPs who attended | Digest | Recap, photos, "rate this huddl". Not in v1. |

Reminder jobs must be (a) scheduled when a huddl is created or rescheduled, (b) cancelled when a huddl is cancelled. Resolve recipients (RSVPs) at job run time, not at schedule time, so RSVPs added after creation still get the reminder.

### E. RSVPs

| ID | Trigger | Recipient | Category | Notes |
|---|---|---|---|---|
| E1 | Someone RSVPs to your huddl | Group owner + organizers | Activity | Per-RSVP, no cap in v1. Organizers tune via settings. |
| E2 | Someone cancels their RSVP | Group owner + organizers | Activity | Same volume rule as E1. |
| E3 | RSVP confirmation to the user | User | Activity | "You're going to X." Includes `.ics`. |
| E4 | RSVP cancellation confirmation | User | None | Skip — overkill |

### F. Digests (deferred, v2)

| ID | Trigger | Recipient | Category | Notes |
|---|---|---|---|---|
| F1 | Weekly upcoming-huddlz digest | All members, opt-in | Digest | Re-engagement |
| F2 | "30 days since your last visit" | Inactive users, opt-in | Digest | Reactivation. Easy to feel spammy. |

## Not notifications

These do **not** trigger emails:

- Group description / image / location updates
- Huddl description edits (only the C2 fields trigger)
- Profile updates (display name, avatar, home location)
- Comments / messages (no comment system exists yet)
- Public huddl/group page views
- Admin viewing user list

## Recipient rules

Apply to every trigger:

1. **Actor exclusion** — never email the person who triggered the action. If you RSVP, the organizer gets the email, not you.
2. **Owner + organizers** — for group-leadership emails (B1, E1, E2), notify both, deduped.
3. **Dynamic RSVP set** — for huddl emails (C2, C3, D1, D2), recipients are the RSVPs *at send time*, not at huddl creation time.
4. **Skip unconfirmed users** — don't email until `confirmed_at` is set (except A1).
5. **Skip soft-deleted users**.

## Decisions

Locked for v1, no re-litigation:

1. C1 audience = every group member. Mitigated by the settings page.
2. E1/E2 volume = per-RSVP, no cap. Daily digest is a v2 idea.
3. C4 = one email about the next upcoming instance. Later instances rely on D1/D2.
4. `.ics` attachments ship in v1 on E3, D1, D2.
5. Defaults: Activity ON, Digest OFF, Transactional always on.

## Settings page

Lives under profile (`lib/huddlz_web/live/profile_live.ex`) or a new `/profile/notifications` route. One toggle per item below, stored in `User.notification_preferences` (JSONB map keyed by trigger code, e.g. `"huddl_new"`, `"huddl_reminder_24h"`, `"rsvp_received"`).

Toggles (and the trigger they map to):

- Account security alerts (A3, A4 — always on, shown disabled for transparency)
- New huddl scheduled in a group I'm in (C1)
- Huddl I'm RSVPd to was updated (C2)
- Huddl I'm RSVPd to was cancelled (C3 — always on, shown disabled)
- 24-hour reminder (D1)
- 1-hour reminder (D2)
- Someone RSVPd to a huddl I organize (E1)
- Someone cancelled an RSVP to a huddl I organize (E2)
- RSVP confirmations to me (E3)
- Group membership changes affecting me (B2, B3, B4, B6, B7)
- Someone joined a group I organize (B1)

Unsubscribe links in email footers deep-link to a confirmation page, then a POST flips a single key via signed token and redirects.

## Implementation

- **`User.notification_preferences`** — JSONB map keyed by trigger code. Defaults applied at read time, so adding a new key doesn't need a backfill.
- **Sender modules** — one per email under `lib/huddlz/notifications/senders/` (or colocated under `lib/huddlz/accounts/user/senders/` to match AshAuthentication). Each sender: build email, check preferences, deliver.
- **Ash notifiers** — most action-driven emails (B1, B3, C1, C3, E1, E2, E3) attach as `Ash.Notifier` modules on the resource. Avoids scattering side-effects across LiveViews.
- **Reminder scheduling (D1, D2)** — when a huddl is created, schedule two Oban jobs (`scheduled_at: starts_at - 24h` and `starts_at - 1h`). On huddl update, cancel + reschedule. On RSVP create/destroy, do *not* touch the huddl-level job — the job resolves recipients at run time.
- **Unsubscribe** — `Phoenix.Token` signed payload `{user_id, category}`, route at `/unsubscribe/:token`. GET shows confirmation; POST flips the preference and redirects to the settings page.
- **`.ics` generation** — one shared helper module, used by E3, D1, D2.

### Existing infrastructure to build on

- Mailer: `Huddlz.Mailer` (`lib/huddlz/mailer.ex`), Swoosh, `support@huddlz.com` from-address. Local adapter in dev (`/dev/mailbox`), Mailgun in prod, Test adapter in tests.
- Sender pattern: `lib/huddlz/accounts/user/senders/send_password_reset_email.ex` and `send_new_user_confirmation_email.ex`.
- Background jobs: Oban + AshOban configured in `config/config.exs:22-32`, cron plugin enabled. Image-cleanup AshOban triggers show the pattern.

### Critical files

- `lib/huddlz/communities/huddl.ex:69-270` — Huddl actions (create, update, destroy, rsvp, cancel_rsvp)
- `lib/huddlz/communities/huddl_attendee.ex:49-92` — RSVP create/destroy
- `lib/huddlz/communities/group.ex:64-128` — Group actions
- `lib/huddlz/communities/group_member.ex:65-130` — Membership join/leave/add/remove
- `lib/huddlz/accounts/user.ex` — needs `notification_preferences`
- `config/config.exs:22-32` — add notification queue
- `lib/huddlz_web/router.ex` — needs `/unsubscribe/:token`

## Testing

- One sender test per trigger in `test/huddlz/notifications/`, using `Swoosh.Adapters.Test`.
- One feature test per trigger in `test/features/`, performing the action and asserting the email.
- D1/D2 worker: `Oban.Testing` unit test with a mocked clock.
- Manual: dev run, perform every flow, check `/dev/mailbox`.
- `mix precommit` clean throughout.

## Build order

Each step is shippable on its own.

1. Foundation — `notification_preferences`, unsubscribe route, settings page, `.ics` helper, sender-module pattern
2. C3 — exercises the notifier pattern end-to-end on a transactional email
3. D1 + D2 — exercises Oban scheduling/cancellation and `.ics`
4. C1 + C2
5. E1 + E2 + E3
6. C4 (small once C2 is in place)
7. B1–B7
8. A3–A5
9. F1/F2 — separate epic, post-v1 (see #119 for current feedback)
49 changes: 49 additions & 0 deletions lib/huddlz/accounts/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ defmodule Huddlz.Accounts.User do
User resource with authentication capabilities and role-based permissions.
"""

require Logger

use Ash.Resource,
otp_app: :huddlz,
domain: Huddlz.Accounts,
Expand All @@ -21,6 +23,7 @@ defmodule Huddlz.Accounts.User do
update :update_display_name, :update_display_name
update :update_home_location, :update_home_location
update :change_password, :change_password
update :update_notification_preferences, :update_notification_preferences
end
end

Expand Down Expand Up @@ -147,6 +150,26 @@ defmodule Huddlz.Accounts.User do
accept [:role]
end

update :update_notification_preferences do
description "Merge a partial map of trigger overrides onto the user's notification_preferences."
require_atomic? false

argument :preferences, :map, allow_nil?: false

validate present(:preferences)

change fn changeset, _ctx ->
existing = changeset.data.notification_preferences || %{}
incoming = Ash.Changeset.get_argument(changeset, :preferences)

Ash.Changeset.change_attribute(
changeset,
:notification_preferences,
Map.merge(existing, incoming)
)
end
end

update :change_password do
# Use this action to allow users to change their password by providing
# their current password and a new password.
Expand All @@ -168,6 +191,20 @@ defmodule Huddlz.Accounts.User do
strategy_name: :password, password_argument: :current_password}

change {AshAuthentication.Strategy.Password.HashPasswordChange, strategy_name: :password}

change after_action(fn _changeset, user, _ctx ->
case Huddlz.Notifications.deliver_async(user, :password_changed) do
{:ok, _job} ->
:ok

{:error, reason} ->
Logger.error(
"Failed to enqueue password-changed notification: #{inspect(reason)}"
)
end

{:ok, user}
end)
end

update :set_password do
Expand Down Expand Up @@ -401,6 +438,11 @@ defmodule Huddlz.Accounts.User do
description "Users can set their own password"
authorize_if expr(id == ^actor(:id))
end

policy action(:update_notification_preferences) do
description "Users can update their own notification preferences"
authorize_if expr(id == ^actor(:id))
end
end

attributes do
Expand Down Expand Up @@ -450,6 +492,13 @@ defmodule Huddlz.Accounts.User do
allow_nil? true
constraints min: -180, max: 180
end

attribute :notification_preferences, :map do
description "Per-trigger email opt-in/out. Missing keys fall back to the trigger's default."
allow_nil? false
default %{}
public? true
end
end

relationships do
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,8 @@ defmodule Huddlz.Accounts.User.Senders.SendNewUserConfirmationEmail do

@impl true
def send(user, token, _) do
config = Application.get_env(:huddlz, :email)
from_name = config[:from_name] || "huddlz support"
from_address = config[:from_address] || "support@huddlz.com"

new()
|> from({from_name, from_address})
|> from(Mailer.from())
|> to(to_string(user.email))
|> subject("Confirm your email address")
|> html_body(body(token: token))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,8 @@ defmodule Huddlz.Accounts.User.Senders.SendPasswordResetEmail do

@impl true
def send(user, token, _) do
config = Application.get_env(:huddlz, :email)
from_name = config[:from_name] || "huddlz support"
from_address = config[:from_address] || "support@huddlz.com"

new()
|> from({from_name, from_address})
|> from(Mailer.from())
|> to(to_string(user.email))
|> subject("Reset your password")
|> html_body(body(token: token))
Expand Down
14 changes: 14 additions & 0 deletions lib/huddlz/mailer.ex
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
defmodule Huddlz.Mailer do
use Swoosh.Mailer, otp_app: :huddlz

@doc """
Default `{name, address}` tuple for outbound email senders.

Reads `:huddlz, :email` config and falls back to safe defaults so dev/test
environments work without configuration.
"""
@spec from() :: {String.t(), String.t()}
def from do
config = Application.get_env(:huddlz, :email, [])
name = config[:from_name] || "huddlz support"
address = config[:from_address] || "support@huddlz.com"
{name, address}
end
end
Loading
Loading
0