8000 Email notifications: account security (A4 + A5) by mwoods79 · Pull Request #127 · 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
19 commits
Select commit Hold shift + click to select a range
bd0ae20
feat(notifications): send security email on admin role change (A5)
mwoods79 Apr 30, 2026
2f9f4f6
feat(accounts): add change_email action with current-password gate
mwoods79 Apr 30, 2026
abe2e1a
feat(notifications): send security email when address changes (A4)
mwoods79 Apr 30, 2026
936d569
refactor(notifications): tighten A4/A5 senders and step helpers
mwoods79 Apr 30, 2026
385f68c
refactor(notifications): use change context actor in update_role hook
mwoods79 Apr 30, 2026
7cea62b
refactor(accounts): drop previous_email context round-trip in change_…
mwoods79 Apr 30, 2026
67952ea
refactor(notifications): extract Senders.HtmlEscape and apply to all …
mwoods79 Apr 30, 2026
871c422
fix(test): correct same-email step comment
mwoods79 Apr 30, 2026
210e48a
feat(notifications): raise on unknown audience in EmailChanged.build/2
mwoods79 Apr 30, 2026
8d9c5c5
test(accounts): tighten change_email error assertions
mwoods79 Apr 30, 2026
fea2e21
test(graphql): cover change_email mutation end-to-end
mwoods79 Apr 30, 2026
a3f7edd
fix(notifications): align recovery advice in old-address email
mwoods79 Apr 30, 2026
798d3e6
docs(notifications): codify sender conventions for future authors
mwoods79 Apr 30, 2026
a291b05
test(accounts): tighten errors assertion and drop redundant test
mwoods79 Apr 30, 2026
55ebdf7
chore(notifications): document non-obvious invariants in security flow
mwoods79 Apr 30, 2026
82f9d46
test(group_live): pin description to fix meta-tag flake
mwoods79 Apr 30, 2026
be5d6f6
test(group_live): pin name to fix og:url flake from CJK slugs
mwoods79 Apr 30, 2026
2b3080e
test(huddl_live): pin max_attendees to fix capacity-validation flake
mwoods79 Apr 30, 2026
1cabc33
test(generator): pin slug to nil in group defaults
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
39 changes: 39 additions & 0 deletions docs/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,45 @@ Unsubscribe links in email footers deep-link to a confirmation page, then a POST
- `config/config.exs:22-32` — add notification queue
- `lib/huddlz_web/router.ex` — needs `/unsubscribe/:token`

## Sender conventions

Rules every `Huddlz.Notifications.Senders.*` module follows. New senders should be cargo-cultable from any existing one.

### HTML escaping

Every user-controlled string interpolated into `html_body` MUST go through `Huddlz.Notifications.Senders.HtmlEscape.escape/1`. This includes display names, email addresses, and any value that originated outside the sender module. Plain-text bodies use the raw value.

### Footer

- **Trans 8000 actional** senders: no footer. There is no preference toggle, so an unsubscribe link would be misleading.
- **Activity** senders: include `<hr/>` + a settings link + an unsubscribe link. Generate the unsubscribe token via `Huddlz.Notifications.unsubscribe_token(user, trigger)` so the route flips the right preference key.

(When the second activity sender lands, lift the inline footer HTML into a shared helper — until then, inline keeps the diff small.)

### Recovery advice in security notices

Include a `/reset` link **only when the recipient's address is the current account email**, i.e. when the password-reset channel itself is unchanged.

| Sender | Recipient | Recovery channel intact? | Link `/reset`? |
|---|---|---|---|
| `password_changed` | current email | yes | yes |
| `email_changed` (audience: "old") | previous email | **no** — reset email goes to the new (possibly attacker-controlled) address | no — direct to support |
| `email_changed` (audience: "new") | new email | n/a — confirmation only | no |

When the channel has moved, lead with "contact support" and explicitly note that `/reset` won't help. Anything else gives the user a false sense of recovery.

### Test floor

Each sender test should at minimum assert:

- greeting includes the user's display name
- recipient is correct (`email.to`)
- `email.from` matches the configured `Mailer.from()`
- subject line
- a body keyword that anchors the message
- `<script>` payload in display name is HTML-escaped
- `refute email.text_body =~ "<"` — no markup leaks into plain text

## Testing

- One sender test per trigger in `test/huddlz/notifications/`, using `Swoosh.Adapters.Test`.
Expand Down
2 changes: 2 additions & 0 deletions lib/huddlz/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ defmodule Huddlz.Accounts do
define :update_home_location,
action: :update_home_location,
args: [:home_location, :home_latitude, :home_longitude]

define :change_email, action: :change_email, args: [:email, :current_password]
end

resource Huddlz.Accounts.ProfilePicture do
Expand Down
81 changes: 81 additions & 0 deletions lib/huddlz/accounts/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,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 :change_email, :change_email
update :update_notification_preferences, :update_notification_preferences
end
end
Expand Down Expand Up @@ -147,7 +148,44 @@ defmodule Huddlz.Accounts.User do

update :update_role do
description "Update a user's role"
require_atomic? false
accept [:role]

change after_action(fn changeset, user, %{actor: actor} ->
previous_role = changeset.data.role

cond do
previous_role == user.role ->
:ok

# Skip only on actor-self updates. A nil actor (system call with
# `authorize?: false`) intentionally falls through and notifies.
actor && actor.id == user.id ->
:ok

true ->
payload = %{
"new_role" => Atom.to_string(user.role),
"previous_role" => Atom.to_string(previous_role)
}

case Huddlz.Notifications.deliver_async(
user,
:account_role_changed,
payload
) do
{:ok, _job} ->
:ok

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

{:ok, user}
end)
end

update :update_notification_preferences do
Expand All @@ -170,6 +208,30 @@ defmodule Huddlz.Accounts.User do
end
end

update :change_email do
description "Change the user's email address. Requires the current password and emails old + new addresses as a security notice."

require_atomic? false
accept [:email]

argument :current_password, :string, sensitive?: true, allow_nil?: false

validate {AshAuthentication.Strategy.Password.PasswordValidation,
strategy_name: :password, password_argument: :current_password}

change after_action(fn changeset, user, _ctx ->
previous_email = to_string(changeset.data.email)
new_email = to_string(user.email)

if previous_email != new_email do
enqueue_email_changed(user, previous_email, "old")
enqueue_email_changed(user, previous_email, "new")
end

{:ok, user}
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 Down Expand Up @@ -434,6 +496,11 @@ defmodule Huddlz.Accounts.User do
authorize_if expr(id == ^actor(:id))
end

policy action(:change_email) do
description "Users can change their own email"
authorize_if expr(id == ^actor(:id))
end

policy action(:set_password) do
description "Users can set their own password"
authorize_if expr(id == ^actor(:id))
Expand Down Expand Up @@ -526,4 +593,18 @@ defmodule Huddlz.Accounts.User do
identities do
identity :unique_email, [:email]
end

defp enqueue_email_changed(user, previous_email, audience) do
payload = %{"audience" => audience, "old_email" => previous_email}

case Huddlz.Notifications.deliver_async(user, :email_changed, payload) do
{:ok, _job} ->
:ok

{:error, reason} ->
Logger.error(
"Failed to enqueue email-changed (#{audience}) notification: #{inspect(reason)}"
)
end
end
end
3 changes: 3 additions & 0 deletions lib/huddlz/notifications/sender.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ defmodule Huddlz.Notifications.Sender do

See `Huddlz.Notifications` for the orchestrator and `Huddlz.Notifications.Triggers`
for the registry that maps trigger atoms to their sender modules.

See `docs/notifications.md` § Sender conventions for the rules every sender
follows (HTML escaping, footer, recovery-advice, test floor).
"""

alias Huddlz.Accounts.User
Expand Down
81 changes: 81 additions & 0 deletions lib/huddlz/notifications/senders/account_role_changed.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
defmodule Huddlz.Notifications.Senders.AccountRoleChanged do
@moduledoc """
Sender for A5: an admin changed this user's account role.

Activity-category email — respects the user's notification preferences and
includes an unsubscribe link in the footer.
"""

@behaviour Huddlz.Notifications.Sender

use HuddlzWeb, :verified_routes
import Swoosh.Email

alias Huddlz.Mailer
alias Huddlz.Notifications
alias Huddlz.Notifications.Senders.HtmlEscape

@impl true
def build(user, payload) do
new_role = payload["new_role"] || to_string(user.role)
previous_role = payload["previous_role"]

ctx = %{
name: user.display_name,
new_role: new_role,
previous_role: previous_role,
settings_url: url(~p"/profile/notifications"),
unsubscribe_url:
url(~p"/unsubscribe/#{Notifications.unsubscribe_token(user, :account_role_changed)}")
}

new()
|> from(Mailer.from())
|> to(to_string(user.email))
|> subject("Your huddlz account role was updated")
|> html_body(html_body(ctx))
|> text_body(text_body(ctx))
end

defp html_body(ctx) do
"""
<p>Hi #{HtmlEscape.escape(ctx.name)},</p>

<p>An administrator just updated your huddlz account role #{html_change_phrase(ctx)}.</p>

<p>If this looks wrong, reply to this email and we'll sort it out.</p>

<hr/>
<p style="font-size: 0.85em; color: #666;">
You're receiving this because role-change notifications are on.
<a href="#{ctx.settings_url}">Manage notification settings</a> ·
<a href="#{ctx.unsubscribe_url}">Unsubscribe from these</a>
</p>
"""
end

defp text_body(ctx) do
"""
Hi #{ctx.name},

An administrator just updated your huddlz account role #{text_change_phrase(ctx)}.

If this looks wrong, reply to this email and we'll sort it out.

--
You're receiving this because role-change notifications are on.
Manage notification settings: #{ctx.settings_url}
Unsubscribe from these: #{ctx.unsubscribe_url}
"""
end

defp html_change_phrase(%{previous_role: nil, new_role: new}),
do: "to <strong>#{HtmlEscape.escape(new)}</strong>"

defp html_change_phrase(%{previous_role: prev, new_role: new}),
do:
"from <strong>#{HtmlEscape.escape(prev)}</strong> to <strong>#{HtmlEscape.escape(new)}</strong>"

defp text_change_phrase(%{previous_role: nil, new_role: new}), do: "to #{new}"
defp text_change_phrase(%{previous_role: prev, new_role: new}), do: "from #{prev} to #{new}"
end
121 changes: 121 additions & 0 deletions lib/huddlz/notifications/senders/email_changed.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
defmodule Huddlz.Notifications.Senders.EmailChanged do
@moduledoc """
Sender for A4: a user's email address was changed.

Two audiences, both transactional:

* `audience: "old"` — the previous address. Security notice that
directs the user to contact support if the change was unauthorized.
No `/reset` link: by this point the recovery channel (the reset
email) goes to the *new* address, which a hijacker would control.
* `audience: "new"` — the new address. Confirmation that this address
is now associated with the account.

The trigger is `:email_changed`. After_action enqueues the worker twice,
once per audience. Both jobs share the trigger and pass `payload[
"old_email"]` so each side has the full picture.
"""

@behaviour Huddlz.Notifications.Sender

use HuddlzWeb, :verified_routes
import Swoosh.Email

alias Huddlz.Mailer
alias Huddlz.Notifications.Senders.HtmlEscape

@subject "Your huddlz email address was changed"

@impl true
def build(user, %{"audience" => "old"} = payload) do
old_email = payload["old_email"]
new_email = to_string(user.email)
safe_name = HtmlEscape.escape(user.display_name)
safe_new_email = HtmlEscape.escape(new_email)

new()
|> from(Mailer.from())
# Recipient header injection is prevented by the `User.email` regex constraint
# rejecting whitespace (CR/LF). Swoosh does not sanitize recipient strings.
|> to(old_email)
|> subject(@subject)
|> html_body(html_old(safe_name, safe_new_email))
|> text_body(text_old(user.display_name, new_email))
end

def build(user, %{"audience" => "new"} = payload) do
old_email = payload["old_email"]
new_email = to_string(user.email)
safe_name = HtmlEscape.escape(user.display_name)
safe_old_email = HtmlEscape.escape(old_email)

new()
|> from(Mailer.from())
|> to(new_email)
|> subject(@subject)
|> html_body(html_new(safe_name, safe_old_email))
|> text_body(text_new(user.display_name, old_email))
end

def build(_user, payload) do
raise ArgumentError,
"EmailChanged sender received an unknown audience in payload: #{inspect(payload)}. " <>
"Expected `audience: \"old\"` or `audience: \"new\"`."
end

defp html_old(safe_name, safe_new_email) do
"""
<p>Hi #{safe_name},</p>

<p>This is a security notice — the email address on your huddlz account was
just changed to <strong>#{safe_new_email}</strong>.</p>

<p>If this was you, no action is needed. Future emails from huddlz will
go to your new address.</p>

<p>If this <strong>wasn't</strong> you, contact support right away so we
can restore access. Resetting your password won't help here — the reset
email would go to the new address, not this one.</p>
"""
end

defp text_old(name, new_email) do
"""
Hi #{name},

This is a security notice — the email address on your huddlz account was
just changed to #{new_email}.

If this was you, no action is needed. Future emails from huddlz will go to
your new address.

If this wasn't you, contact support right away so we can restore access.
Resetting your password won't help here — the reset email would go to the
new address, not this one.
"""
end

defp html_new(safe_name, safe_old_email) do
"""
<p>Hi #{safe_name},</p>

<p>This email address is now associated with your huddlz account.
The previous address on file was <strong>#{safe_old_email}</strong>.</p>

<p>If you didn't make this change, contact support right away — someone
else may have access to your account.</p>
"""
end

defp text_new(name, old_email) do
"""
Hi #{name},

This email address is now associated with your huddlz account. The
previous address on file was #{old_email}.

If you didn't make this change, contact support right away — someone
else may have access to your account.
"""
end
end
Loading
Loading
0