Reduce transactional email noise

This commit is contained in:
SimpleTest 2026-08-12 16:04:11 +03:00
parent ecd5ef3c4f
commit 128ae0ea85
45 changed files with 520 additions and 308 deletions

View File

@ -36,6 +36,7 @@ MIGRATE_POOL_SIZE=2
COMBINED_POOL_SIZE=4 COMBINED_POOL_SIZE=4
OBAN_MAINTENANCE_CONCURRENCY=2 OBAN_MAINTENANCE_CONCURRENCY=2
OBAN_PUSH_CONCURRENCY=1 OBAN_PUSH_CONCURRENCY=1
OBAN_MAIL_CONCURRENCY=1
WEB_REPLICAS=2 WEB_REPLICAS=2
WORKER_REPLICAS=2 WORKER_REPLICAS=2
ERLANG_PORT_LIMIT=65536 ERLANG_PORT_LIMIT=65536

View File

@ -199,6 +199,7 @@ MIGRATE_POOL_SIZE=2
COMBINED_POOL_SIZE=4 COMBINED_POOL_SIZE=4
OBAN_MAINTENANCE_CONCURRENCY=2 OBAN_MAINTENANCE_CONCURRENCY=2
OBAN_PUSH_CONCURRENCY=1 OBAN_PUSH_CONCURRENCY=1
OBAN_MAIL_CONCURRENCY=1
WEB_REPLICAS=2 WEB_REPLICAS=2
WORKER_REPLICAS=2 WORKER_REPLICAS=2
# Maximum simultaneously existing Erlang ports (files, sockets and drivers). # Maximum simultaneously existing Erlang ports (files, sockets and drivers).
@ -225,7 +226,9 @@ EMAIL_FROM_ADDRESS=contact@example.com
SUPPORT_INBOX_ADDRESS= SUPPORT_INBOX_ADDRESS=
# Operator email alerts are disabled by default because the permission-scoped # Operator email alerts are disabled by default because the permission-scoped
# staff workspace is the canonical queue. Set immediate only when a monitored # staff workspace is the canonical queue. Set immediate only when a monitored
# mailbox should receive one metadata-only alert for each verified case/update. # mailbox should receive one metadata-only alert for each newly verified case.
# Ongoing conversation stays in the staff workspace and in-app inbox. Both
# operator alerts and user-facing support updates use the separate Oban mail queue.
SUPPORT_OPERATOR_EMAIL_MODE=disabled SUPPORT_OPERATOR_EMAIL_MODE=disabled
# Pilot policy: an anonymous support/removal email must be confirmed within one # Pilot policy: an anonymous support/removal email must be confirmed within one
# day. Confirmed case links remain usable for one year. Both values are seconds # day. Confirmed case links remain usable for one year. Both values are seconds

View File

@ -37,6 +37,7 @@ MIGRATE_POOL_SIZE=2
COMBINED_POOL_SIZE=4 COMBINED_POOL_SIZE=4
OBAN_MAINTENANCE_CONCURRENCY=2 OBAN_MAINTENANCE_CONCURRENCY=2
OBAN_PUSH_CONCURRENCY=1 OBAN_PUSH_CONCURRENCY=1
OBAN_MAIL_CONCURRENCY=1
WEB_REPLICAS=2 WEB_REPLICAS=2
WORKER_REPLICAS=2 WORKER_REPLICAS=2
ERLANG_PORT_LIMIT=65536 ERLANG_PORT_LIMIT=65536

View File

@ -60,7 +60,9 @@ local Codex CLI authenticated with their ChatGPT subscription.
before raw totals. before raw totals.
- A private notification inbox, category/radius/urgency/availability-based - A private notification inbox, category/radius/urgency/availability-based
nearby-help subscriptions, quiet hours, per-channel preferences, browser Web nearby-help subscriptions, quiet hours, per-channel preferences, browser Web
Push registrations, email alerts, and Android FCM device registrations. Push registrations, and Android FCM device registrations. Nearby matches use
the inbox and optional real-time push; immediate nearby email is disabled and
a batched email digest is not implemented yet.
Remote payloads contain navigation metadata and generic text, never chat Remote payloads contain navigation metadata and generic text, never chat
bodies or exact coordinates; Oban retries transient delivery failures and bodies or exact coordinates; Oban retries transient delivery failures and
disables rejected device registrations. disables rejected device registrations.
@ -73,7 +75,9 @@ local Codex CLI authenticated with their ChatGPT subscription.
- Separate public support and content-removal intake, including moderation - Separate public support and content-removal intake, including moderation
appeals, account deletion/data requests, a URL-only TAKE IT DOWN form, appeals, account deletion/data requests, a URL-only TAKE IT DOWN form,
verified-contact status links, verification-gated support alerts, and audited verified-contact status links, verification-gated support alerts, and audited
staff queues. staff queues. Support sends one initial response email and later public-status
changes through the dedicated Oban `mail` queue; ordinary conversation
messages stay in the private inbox and optional push.
Authenticated users can download an allow-listed JSON data export that omits Authenticated users can download an allow-listed JSON data export that omits
password/session/push credentials and counterpart message bodies. A password/session/push credentials and counterpart message bodies. A
moderator-only deletion preflight reports active workflows without performing moderator-only deletion preflight reports active workflows without performing

View File

@ -66,6 +66,7 @@ x-app-environment: &app-environment
ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS: ${ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS:-} ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS: ${ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS:-}
OBAN_MAINTENANCE_CONCURRENCY: ${OBAN_MAINTENANCE_CONCURRENCY:-2} OBAN_MAINTENANCE_CONCURRENCY: ${OBAN_MAINTENANCE_CONCURRENCY:-2}
OBAN_PUSH_CONCURRENCY: ${OBAN_PUSH_CONCURRENCY:-1} OBAN_PUSH_CONCURRENCY: ${OBAN_PUSH_CONCURRENCY:-1}
OBAN_MAIL_CONCURRENCY: ${OBAN_MAIL_CONCURRENCY:-1}
services: services:
docker-api-proxy: docker-api-proxy:

View File

@ -73,7 +73,7 @@ config :phoenix,
config :who_need_help, Oban, config :who_need_help, Oban,
repo: WhoNeedHelp.Repo, repo: WhoNeedHelp.Repo,
queues: [maintenance: 2, push: 1], queues: [maintenance: 2, push: 1, mail: 1],
plugins: [ plugins: [
{Oban.Plugins.Pruner, max_age: 86_400}, {Oban.Plugins.Pruner, max_age: 86_400},
{Oban.Plugins.Cron, crontab: [{"* * * * *", WhoNeedHelp.Workers.ExpireRequests}]} {Oban.Plugins.Cron, crontab: [{"* * * * *", WhoNeedHelp.Workers.ExpireRequests}]}

View File

@ -108,7 +108,8 @@ if config_env() == :prod do
config :who_need_help, Oban, config :who_need_help, Oban,
queues: [ queues: [
maintenance: positive_integer_with_default.("OBAN_MAINTENANCE_CONCURRENCY", 2), maintenance: positive_integer_with_default.("OBAN_MAINTENANCE_CONCURRENCY", 2),
push: positive_integer_with_default.("OBAN_PUSH_CONCURRENCY", 1) push: positive_integer_with_default.("OBAN_PUSH_CONCURRENCY", 1),
mail: positive_integer_with_default.("OBAN_MAIL_CONCURRENCY", 1)
] ]
end end

View File

@ -95,6 +95,8 @@ spec:
value: {{ $root.Values.worker.maintenanceConcurrency | quote }} value: {{ $root.Values.worker.maintenanceConcurrency | quote }}
- name: OBAN_PUSH_CONCURRENCY - name: OBAN_PUSH_CONCURRENCY
value: {{ $root.Values.worker.pushConcurrency | quote }} value: {{ $root.Values.worker.pushConcurrency | quote }}
- name: OBAN_MAIL_CONCURRENCY
value: {{ $root.Values.worker.mailConcurrency | quote }}
- name: EMAIL_DELIVERY_PROVIDER - name: EMAIL_DELIVERY_PROVIDER
value: {{ $root.Values.app.emailDeliveryProvider | quote }} value: {{ $root.Values.app.emailDeliveryProvider | quote }}
- name: SMTP_RELAY - name: SMTP_RELAY

View File

@ -43,6 +43,8 @@ spec:
value: {{ .Values.worker.maintenanceConcurrency | quote }} value: {{ .Values.worker.maintenanceConcurrency | quote }}
- name: OBAN_PUSH_CONCURRENCY - name: OBAN_PUSH_CONCURRENCY
value: {{ .Values.worker.pushConcurrency | quote }} value: {{ .Values.worker.pushConcurrency | quote }}
- name: OBAN_MAIL_CONCURRENCY
value: {{ .Values.worker.mailConcurrency | quote }}
- name: EMAIL_DELIVERY_PROVIDER - name: EMAIL_DELIVERY_PROVIDER
value: {{ .Values.app.emailDeliveryProvider | quote }} value: {{ .Values.app.emailDeliveryProvider | quote }}
securityContext: securityContext:

View File

@ -12,6 +12,7 @@ worker:
poolSize: "2" poolSize: "2"
maintenanceConcurrency: "2" maintenanceConcurrency: "2"
pushConcurrency: "1" pushConcurrency: "1"
mailConcurrency: "1"
service: service:
type: ClusterIP type: ClusterIP

View File

@ -75,7 +75,9 @@ and are not represented as complete.
provider-neutral gateway delivery, direct Web Push and FCM device delivery, provider-neutral gateway delivery, direct Web Push and FCM device delivery,
invalid-registration cleanup, and request/message/lifecycle/nearby events. invalid-registration cleanup, and request/message/lifecycle/nearby events.
- `Notifications`: the private inbox, device registry, user preferences, quiet - `Notifications`: the private inbox, device registry, user preferences, quiet
hours, durable email delivery, and PostGIS-backed nearby subscriptions. hours, optional push, and PostGIS-backed nearby subscriptions. Immediate
per-match nearby email is retired; a future digest requires an explicit
batching cadence and delivery cursor.
Subscription centers and push credentials are never part of public discovery Subscription centers and push credentials are never part of public discovery
results. results.
- `ProductAnalytics`: daily aggregate counters from a fixed metric allow-list. - `ProductAnalytics`: daily aggregate counters from a fixed metric allow-list.

View File

@ -442,8 +442,9 @@ Set optional `SUPPORT_INBOX_ADDRESS` to
a monitored address for support/removal queue alerts and email `Reply-To`. a monitored address for support/removal queue alerts and email `Reply-To`.
Public support creates an alert only after email verification; authenticated Public support creates an alert only after email verification; authenticated
support is already verified, while content-removal notification rules remain support is already verified, while content-removal notification rules remain
separate. Leaving the value empty disables operator email alerts, not the separate. The alert is queued in `mail`; conversation messages remain in the
protected queues. If permission-scoped staff workspace rather than producing email per message.
Leaving the value empty disables operator email alerts, not the protected queues. If
Google registration/sign-in is Google registration/sign-in is
enabled, also set both Google Web client credentials and register enabled, also set both Google Web client credentials and register
`https://YOUR_PHX_HOST/auth/google/callback` as the exact authorized redirect `https://YOUR_PHX_HOST/auth/google/callback` as the exact authorized redirect
@ -1021,10 +1022,15 @@ requirements.
## Isolated Oban burst measurement ## Isolated Oban burst measurement
The worker role consumes only `maintenance` and `push`. Configure their The worker role consumes `maintenance`, `push`, and `mail`. Configure their
per-worker limits with `OBAN_MAINTENANCE_CONCURRENCY` and per-worker limits with `OBAN_MAINTENANCE_CONCURRENCY`,
`OBAN_PUSH_CONCURRENCY`; multiplying either value by the number of worker `OBAN_PUSH_CONCURRENCY`, and `OBAN_MAIL_CONCURRENCY`; multiplying a value by
replicas gives the configured cluster-wide concurrency for that queue. the number of worker replicas gives the configured cluster-wide concurrency
for that queue. Authentication and mandatory legal-intake confirmations remain
synchronous. Ordinary support-update email runs in `mail`, so SMTP latency does
not occupy a web request or the `push` queue. Optional new-case operator alerts
also run in `mail`; requester conversation messages do not generate operator
email.
After starting the isolated load project, run an explicitly sized experiment: After starting the isolated load project, run an explicitly sized experiment:

View File

@ -45,9 +45,11 @@ status token in the redirect. They are stored as `pending_verification`, are not
visible in the operator queue, and do not produce an operator alert. The private visible in the operator queue, and do not produce an operator alert. The private
status link is sent to the contact email. Opening it atomically verifies the status link is sent to the contact email. Opening it atomically verifies the
address, changes the request to `open`, makes it visible to authorised staff, address, changes the request to `open`, makes it visible to authorised staff,
and sends one metadata-only operator alert. Opening the same link again does not and enqueues at most one metadata-only operator alert when that optional mode is
send another alert. An authenticated submission uses the confirmed account enabled. Opening the same link again does not send another alert. An authenticated
email, is verified immediately, and enters the queue without this extra step. submission uses the confirmed account email, is verified immediately, and enters
the queue without this extra step. Ongoing requester messages update the staff
workspace through PubSub and do not generate one operator email per message.
Exact repeats of the same unverified public support submission are represented Exact repeats of the same unverified public support submission are represented
by one pending row. Its temporary fingerprint is a SHA-256 digest of normalized by one pending row. Its temporary fingerprint is a SHA-256 digest of normalized

View File

@ -1068,7 +1068,7 @@ this audit.
| Privacy settings | Implemented and browser-verified | The profile exposed hidden, approximate public, exact for active match, and explicit exact-public options. Blocking and current-position cleanup have automated tests. | Exact public location remains a user opt-in; legal privacy and retention text still requires jurisdiction-specific review before launch. | | Privacy settings | Implemented and browser-verified | The profile exposed hidden, approximate public, exact for active match, and explicit exact-public options. Blocking and current-position cleanup have automated tests. | Exact public location remains a user opt-in; legal privacy and retention text still requires jurisdiction-specific review before launch. |
| Reputation and anti-abuse | Implemented at MVP level | Handover codes, two-party completion, double-blind reviews, unique-counterpart ranking, optional movement/proximity evidence, reports, blocks, abuse signals, and moderator audit paths have automated tests. | The system is not bot-proof and does not claim identity verification. No punitive numeric policy is enabled without measured and approved thresholds. | | Reputation and anti-abuse | Implemented at MVP level | Handover codes, two-party completion, double-blind reviews, unique-counterpart ranking, optional movement/proximity evidence, reports, blocks, abuse signals, and moderator audit paths have automated tests. | The system is not bot-proof and does not claim identity verification. No punitive numeric policy is enabled without measured and approved thresholds. |
| Account registration and sign-in | Implemented and browser/physical-device verified | Email registration is a single passwordless flow: it records the display name and acceptance once, sends a confirmation link, and does not duplicate a user on subsequent sign-in. Confirmed users can keep using magic links or add a password in settings. Google OpenID Connect registration, sign-in, link, unlink, replay prevention, verified-email enforcement, and account-ownership rules are covered by the automated suite. Real headed Chrome exercised the development callback and identity-linking paths. The Play-delivered production build then completed production Google sign-in without a secondary ownership email or duplicate account. A production authentication email was also observed in the external Gmail mailbox with `whoneedhelp.com` DKIM signing. | Brevo rewrites the action href through its tracking domain; direct production-domain action URLs remain an open deliverability/privacy check. | | Account registration and sign-in | Implemented and browser/physical-device verified | Email registration is a single passwordless flow: it records the display name and acceptance once, sends a confirmation link, and does not duplicate a user on subsequent sign-in. Confirmed users can keep using magic links or add a password in settings. Google OpenID Connect registration, sign-in, link, unlink, replay prevention, verified-email enforcement, and account-ownership rules are covered by the automated suite. Real headed Chrome exercised the development callback and identity-linking paths. The Play-delivered production build then completed production Google sign-in without a secondary ownership email or duplicate account. A production authentication email was also observed in the external Gmail mailbox with `whoneedhelp.com` DKIM signing. | Brevo rewrites the action href through its tracking domain; direct production-domain action URLs remain an open deliverability/privacy check. |
| Notifications and nearby alerts | Implemented and browser/physical-device verified | Users can configure push/email preferences, quiet hours, category/urgency/day/time filters, a private matching center, and 1/3/5/10/25 km radii. Durable inbox notifications and Oban delivery jobs are tested; public notification payloads omit chat text, exact coordinates, and the private saved-area label. Development Web Push and FCM delivery were exercised. The Play-delivered production build registered its FCM device, received one run-scoped production notification, and routed its tap to the in-app inbox; exact cleanup removed that notification and its jobs. A guarded production smoke then delivered one browser-only job to the newest real active Web Push subscription on its first attempt and removed the exact job and notification. | Provider acceptance and the still-active subscription are verified; visible operating-system presentation and click navigation were not programmatically observed. | | Notifications and nearby alerts | Implemented and browser/physical-device verified | Users can configure push, quiet hours, category/urgency/day/time filters, a private matching center, and 1/3/5/10/25 km radii. Durable inbox notifications and Oban push jobs are tested; public notification payloads omit chat text, exact coordinates, and the private saved-area label. Immediate per-request nearby email is retired. Development Web Push and FCM delivery were exercised. The Play-delivered production build registered its FCM device, received one run-scoped production notification, and routed its tap to the in-app inbox; exact cleanup removed that notification and its jobs. A guarded production smoke then delivered one browser-only job to the newest real active Web Push subscription on its first attempt and removed the exact job and notification. | A batched nearby email digest is not implemented; it requires a defined cadence and delivery cursor. Provider acceptance and the still-active subscription are verified; visible operating-system presentation and click navigation were not programmatically observed. |
| Social profiles | Manual links implemented; optional GitHub verification implemented and automated-tested | Manual links cannot set verification fields. The optional GitHub flow uses state, PKCE, a user-bound one-time session, unique provider ownership, and an audit record. The local protocol drill also performs real HTTP token/user exchanges without returning an access token to the application. | GitHub OAuth credentials are intentionally absent and are not required for registration or the help flow. The real external provider redirect/callback remains disabled and unverified; other providers remain manual/unverified. | | Social profiles | Manual links implemented; optional GitHub verification implemented and automated-tested | Manual links cannot set verification fields. The optional GitHub flow uses state, PKCE, a user-bound one-time session, unique provider ownership, and an audit record. The local protocol drill also performs real HTTP token/user exchanges without returning an access token to the application. | GitHub OAuth credentials are intentionally absent and are not required for registration or the help flow. The real external provider redirect/callback remains disabled and unverified; other providers remain manual/unverified. |
| Support and content removal | Implemented and browser-verified | Public support, account deletion, general removal, and TAKE IT DOWN forms use separate audited workflows; public support remains pending and outside the staff queue until its private email link verifies the contact, while authenticated submissions use the account email immediately. Exact pending repeats are deduplicated, email/IP intake limits are independently configurable, and moderator-only operations can update verified cases. TAKE IT DOWN accepts URLs/text only and records a 48-hour review due time. Authenticated users can download an allowlisted JSON export, and moderators can run a read-only deletion relationship preflight. | The current product hosts no user media and does not claim TAKE IT DOWN coverage. Staffing, measured rate-limit thresholds, jurisdiction-specific legal classification, final retention rules, destructive account erasure/anonymisation, and identical-media-copy handling remain operational/legal work. | | Support and content removal | Implemented and browser-verified | Public support, account deletion, general removal, and TAKE IT DOWN forms use separate audited workflows; public support remains pending and outside the staff queue until its private email link verifies the contact, while authenticated submissions use the account email immediately. Exact pending repeats are deduplicated, email/IP intake limits are independently configurable, and moderator-only operations can update verified cases. TAKE IT DOWN accepts URLs/text only and records a 48-hour review due time. Authenticated users can download an allowlisted JSON export, and moderators can run a read-only deletion relationship preflight. | The current product hosts no user media and does not claim TAKE IT DOWN coverage. Staffing, measured rate-limit thresholds, jurisdiction-specific legal classification, final retention rules, destructive account erasure/anonymisation, and identical-media-copy handling remain operational/legal work. |
| Voluntary thanks | Implemented as an external optional link | A helper can expose an optional link after completion; the UI states that the platform does not process the payment. | The platform does not provide payments, escrow, refunds, tax reporting, or payment guarantees. | | Voluntary thanks | Implemented as an external optional link | A helper can expose an optional link after completion; the UI states that the platform does not process the payment. | The platform does not provide payments, escrow, refunds, tax reporting, or payment guarantees. |

View File

@ -2,6 +2,8 @@ defmodule WhoNeedHelp.Accounts.AuthIdentity do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
alias WhoNeedHelp.EmailAddress
@primary_key {:id, :binary_id, autogenerate: true} @primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id @foreign_key_type :binary_id
@ -21,9 +23,7 @@ defmodule WhoNeedHelp.Accounts.AuthIdentity do
|> validate_required([:provider, :provider_uid, :email, :user_id]) |> validate_required([:provider, :provider_uid, :email, :user_id])
|> validate_inclusion(:provider, [:google]) |> validate_inclusion(:provider, [:google])
|> validate_length(:provider_uid, min: 1, max: 255) |> validate_length(:provider_uid, min: 1, max: 255)
|> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/, |> EmailAddress.validate(:email)
message: "must have the @ sign and no spaces"
)
|> validate_length(:email, max: 160) |> validate_length(:email, max: 160)
|> unique_constraint([:provider, :provider_uid]) |> unique_constraint([:provider, :provider_uid])
|> unique_constraint([:user_id, :provider]) |> unique_constraint([:user_id, :provider])

View File

@ -3,6 +3,7 @@ defmodule WhoNeedHelp.Accounts.User do
import Ecto.Changeset import Ecto.Changeset
alias WhoNeedHelp.Locales alias WhoNeedHelp.Locales
alias WhoNeedHelp.EmailAddress
@primary_key {:id, :binary_id, autogenerate: true} @primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id @foreign_key_type :binary_id
@ -133,9 +134,7 @@ defmodule WhoNeedHelp.Accounts.User do
changeset = changeset =
changeset changeset
|> validate_required([:email]) |> validate_required([:email])
|> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/, |> EmailAddress.validate(:email)
message: "must have the @ sign and no spaces"
)
|> validate_length(:email, max: 160) |> validate_length(:email, max: 160)
if Keyword.get(opts, :validate_unique, true) do if Keyword.get(opts, :validate_unique, true) do

View File

@ -3,11 +3,11 @@ defmodule WhoNeedHelp.Accounts.UserNotifier do
import Swoosh.Email import Swoosh.Email
alias WhoNeedHelp.Mailer
alias WhoNeedHelp.Accounts.User alias WhoNeedHelp.Accounts.User
alias WhoNeedHelp.EmailDelivery
# Delivers the email using the application mailer. # Delivers the email using the application mailer.
defp deliver(recipient, subject, text_body, html_body) do defp deliver(recipient, subject, text_body, html_body, kind) do
from = Application.fetch_env!(:who_need_help, :mailer_from) from = Application.fetch_env!(:who_need_help, :mailer_from)
email = email =
@ -18,7 +18,7 @@ defmodule WhoNeedHelp.Accounts.UserNotifier do
|> text_body(text_body) |> text_body(text_body)
|> html_body(html_body) |> html_body(html_body)
with {:ok, _metadata} <- Mailer.deliver(email) do with {:ok, _metadata} <- EmailDelivery.deliver(email, kind) do
{:ok, email} {:ok, email}
end end
end end
@ -29,6 +29,7 @@ defmodule WhoNeedHelp.Accounts.UserNotifier do
def deliver_update_email_instructions(user, url) do def deliver_update_email_instructions(user, url) do
with_user_locale(user, fn -> with_user_locale(user, fn ->
deliver_action_email(user, deliver_action_email(user,
kind: :auth_email_change,
subject: gettext("Confirm your Who Need Help email change"), subject: gettext("Confirm your Who Need Help email change"),
heading: gettext("Confirm your new email address"), heading: gettext("Confirm your new email address"),
introduction: introduction:
@ -58,6 +59,7 @@ defmodule WhoNeedHelp.Accounts.UserNotifier do
def deliver_google_link_instructions(user, url) do def deliver_google_link_instructions(user, url) do
with_user_locale(user, fn -> with_user_locale(user, fn ->
deliver_action_email(user, deliver_action_email(user,
kind: :auth_google_link,
subject: gettext("Confirm Google sign-in for Who Need Help"), subject: gettext("Confirm Google sign-in for Who Need Help"),
heading: gettext("Confirm Google sign-in"), heading: gettext("Confirm Google sign-in"),
introduction: introduction:
@ -74,6 +76,7 @@ defmodule WhoNeedHelp.Accounts.UserNotifier do
defp deliver_magic_link_instructions(user, url) do defp deliver_magic_link_instructions(user, url) do
with_user_locale(user, fn -> with_user_locale(user, fn ->
deliver_action_email(user, deliver_action_email(user,
kind: :auth_login,
subject: gettext("Your Who Need Help sign-in link"), subject: gettext("Your Who Need Help sign-in link"),
heading: gettext("Sign in to Who Need Help"), heading: gettext("Sign in to Who Need Help"),
introduction: gettext("Use the secure link below to sign in to your account."), introduction: gettext("Use the secure link below to sign in to your account."),
@ -89,6 +92,7 @@ defmodule WhoNeedHelp.Accounts.UserNotifier do
defp deliver_confirmation_instructions(user, url) do defp deliver_confirmation_instructions(user, url) do
with_user_locale(user, fn -> with_user_locale(user, fn ->
deliver_action_email(user, deliver_action_email(user,
kind: :auth_registration,
subject: gettext("Confirm your Who Need Help account"), subject: gettext("Confirm your Who Need Help account"),
heading: gettext("Confirm your account"), heading: gettext("Confirm your account"),
introduction: gettext("Use the secure link below to confirm your Who Need Help account."), introduction: gettext("Use the secure link below to confirm your Who Need Help account."),
@ -102,6 +106,7 @@ defmodule WhoNeedHelp.Accounts.UserNotifier do
end end
defp deliver_action_email(user, content) do defp deliver_action_email(user, content) do
kind = Keyword.fetch!(content, :kind)
subject = Keyword.fetch!(content, :subject) subject = Keyword.fetch!(content, :subject)
heading = Keyword.fetch!(content, :heading) heading = Keyword.fetch!(content, :heading)
introduction = Keyword.fetch!(content, :introduction) introduction = Keyword.fetch!(content, :introduction)
@ -126,7 +131,7 @@ defmodule WhoNeedHelp.Accounts.UserNotifier do
html = action_email_html(heading, introduction, action_label, url, expiry_note, security_note) html = action_email_html(heading, introduction, action_label, url, expiry_note, security_note)
deliver(user.email, subject, text, html) deliver(user.email, subject, text, html, kind)
end end
defp action_email_html(heading, introduction, action_label, url, expiry_note, security_note) do defp action_email_html(heading, introduction, action_label, url, expiry_note, security_note) do

View File

@ -2,6 +2,8 @@ defmodule WhoNeedHelp.ContentRemoval.Notice do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
alias WhoNeedHelp.EmailAddress
@primary_key {:id, :binary_id, autogenerate: true} @primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id @foreign_key_type :binary_id
@ -81,9 +83,7 @@ defmodule WhoNeedHelp.ContentRemoval.Notice do
:regime :regime
]) ])
|> update_change(:contact_email, &normalize_email/1) |> update_change(:contact_email, &normalize_email/1)
|> validate_format(:contact_email, ~r/^[^@,;\s]+@[^@,;\s]+$/, |> EmailAddress.validate(:contact_email)
message: "must have the @ sign and no spaces"
)
|> validate_length(:submitter_name, min: 2, max: 160) |> validate_length(:submitter_name, min: 2, max: 160)
|> validate_length(:contact_email, max: 160) |> validate_length(:contact_email, max: 160)
|> validate_length(:content_locations, min: 5, max: 10_000) |> validate_length(:content_locations, min: 5, max: 10_000)

View File

@ -2,7 +2,7 @@ defmodule WhoNeedHelp.ContentRemoval.Notifier do
import Swoosh.Email import Swoosh.Email
alias WhoNeedHelp.ContentRemoval.Notice alias WhoNeedHelp.ContentRemoval.Notice
alias WhoNeedHelp.Mailer alias WhoNeedHelp.EmailDelivery
def deliver_confirmation(%Notice{contact_email: email} = notice, status_url) def deliver_confirmation(%Notice{contact_email: email} = notice, status_url)
when is_binary(email) and email != "" do when is_binary(email) and email != "" do
@ -16,7 +16,8 @@ defmodule WhoNeedHelp.ContentRemoval.Notifier do
#{status_url} #{status_url}
If you did not make this request, ignore this email. It will not reach the review queue. If you did not make this request, ignore this email. It will not reach the review queue.
""" """,
:content_removal_confirmation
) )
end end
@ -37,7 +38,8 @@ defmodule WhoNeedHelp.ContentRemoval.Notifier do
Do not reply with or upload intimate visual material. If someone is in immediate danger, Do not reply with or upload intimate visual material. If someone is in immediate danger,
contact the emergency service for their location. contact the emergency service for their location.
""" """,
:content_removal_received
) )
end end
@ -57,7 +59,8 @@ defmodule WhoNeedHelp.ContentRemoval.Notifier do
View the current status: View the current status:
#{status_url} #{status_url}
""" """,
:content_removal_update
) )
end end
@ -80,7 +83,8 @@ defmodule WhoNeedHelp.ContentRemoval.Notifier do
Operator queue: #{WhoNeedHelpWeb.Endpoint.url()}/support/operations Operator queue: #{WhoNeedHelpWeb.Endpoint.url()}/support/operations
Do not request the submitter to email or upload intimate visual material. Do not request the submitter to email or upload intimate visual material.
""" """,
:content_removal_operator
) )
{:error, reason} -> {:error, reason} ->
@ -88,7 +92,7 @@ defmodule WhoNeedHelp.ContentRemoval.Notifier do
end end
end end
defp deliver(recipient, subject, body) do defp deliver(recipient, subject, body, kind) do
from = Application.fetch_env!(:who_need_help, :mailer_from) from = Application.fetch_env!(:who_need_help, :mailer_from)
new() new()
@ -97,7 +101,7 @@ defmodule WhoNeedHelp.ContentRemoval.Notifier do
|> maybe_reply_to() |> maybe_reply_to()
|> subject(subject) |> subject(subject)
|> text_body(body) |> text_body(body)
|> Mailer.deliver() |> EmailDelivery.deliver(kind)
end end
defp maybe_reply_to(email) do defp maybe_reply_to(email) do

View File

@ -0,0 +1,43 @@
defmodule WhoNeedHelp.EmailAddress do
@moduledoc false
@spec valid?(term()) :: boolean()
def valid?(address) when is_binary(address) do
with [local, domain] <- String.split(address, "@"),
true <- valid_dot_atom?(local),
true <- valid_domain?(domain),
{:ok, [{_name, parsed_address}]} <- :smtp_util.parse_rfc5322_addresses(address) do
to_string(parsed_address) == address
else
_other -> false
end
end
def valid?(_address), do: false
def validate(changeset, field, message \\ "must have the @ sign and no spaces") do
Ecto.Changeset.validate_change(changeset, field, fn ^field, value ->
if valid?(value), do: [], else: [{field, message}]
end)
end
defp valid_dot_atom?(local) do
local != "" and
Regex.match?(~r/^[A-Za-z0-9.!#$%&'*+\/=\?^_`{|}~-]+$/, local) and
not String.starts_with?(local, ".") and
not String.ends_with?(local, ".") and
not String.contains?(local, "..")
end
defp valid_domain?(domain) do
labels = String.split(domain, ".")
length(labels) >= 2 and
Enum.all?(labels, fn label ->
label != "" and
Regex.match?(~r/^[A-Za-z0-9-]+$/, label) and
not String.starts_with?(label, "-") and
not String.ends_with?(label, "-")
end)
end
end

View File

@ -0,0 +1,57 @@
defmodule WhoNeedHelp.EmailDelivery do
@moduledoc """
Delivers email while emitting low-cardinality, purpose-specific telemetry.
Telemetry metadata contains only fixed type and outcome labels. Recipients,
subjects, bodies, references, and other personal data are never attached.
"""
alias WhoNeedHelp.Mailer
@kinds [
:auth_email_change,
:auth_google_link,
:auth_login,
:auth_registration,
:content_removal_confirmation,
:content_removal_operator,
:content_removal_received,
:content_removal_update,
:nearby_alert,
:support_confirmation,
:support_operator,
:support_update
]
@spec deliver(Swoosh.Email.t(), atom()) :: {:ok, term()} | {:error, term()}
def deliver(%Swoosh.Email{} = email, kind) when kind in @kinds do
started_at = System.monotonic_time()
try do
result = Mailer.deliver(email)
emit(started_at, kind, result_status(result))
result
rescue
exception ->
emit(started_at, kind, :exception)
reraise exception, __STACKTRACE__
catch
class, reason ->
emit(started_at, kind, :exception)
:erlang.raise(class, reason, __STACKTRACE__)
end
end
def kinds, do: @kinds
defp result_status({:ok, _metadata}), do: :ok
defp result_status({:error, _reason}), do: :error
defp emit(started_at, kind, status) do
:telemetry.execute(
[:who_need_help, :email, :delivery],
%{duration: System.monotonic_time() - started_at},
%{kind: to_string(kind), status: to_string(status)}
)
end
end

View File

@ -0,0 +1,30 @@
defmodule WhoNeedHelp.Mail.SupportOperatorAlertWorker do
@moduledoc false
use Oban.Worker,
queue: :mail,
max_attempts: 8,
unique: [
period: :infinity,
fields: [:args, :worker],
keys: [:request_id],
states: :incomplete
]
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Support.{Notifier, SupportRequest}
@impl Oban.Worker
def perform(%Oban.Job{args: %{"request_id" => request_id}}) do
case Repo.get(SupportRequest, request_id) do
nil ->
{:cancel, :support_request_missing}
%SupportRequest{contact_verified_at: nil} ->
{:cancel, :contact_not_verified}
%SupportRequest{} = request ->
Notifier.deliver_operator_alert(request)
end
end
end

View File

@ -0,0 +1,44 @@
defmodule WhoNeedHelp.Mail.SupportUpdateWorker do
@moduledoc false
use Oban.Worker,
queue: :mail,
max_attempts: 8,
unique: [
period: :infinity,
fields: [:args, :worker],
keys: [:request_id],
states: :incomplete
]
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Support
alias WhoNeedHelp.Support.{Notifier, SupportRequest}
@impl Oban.Worker
def perform(%Oban.Job{args: %{"request_id" => request_id}}) do
case Repo.get(SupportRequest, request_id) do
nil ->
{:cancel, :support_request_missing}
%SupportRequest{contact_verified_at: nil} ->
{:cancel, :contact_not_verified}
%SupportRequest{} = request ->
case Notifier.deliver_decision(request, Support.status_url(request)) do
{:ok, _metadata} -> mark_sent(request)
{:error, reason} -> {:error, reason}
end
end
end
defp mark_sent(request) do
request
|> Ecto.Changeset.change(response_sent_at: DateTime.utc_now(:second))
|> Repo.update()
|> case do
{:ok, _request} -> :ok
{:error, reason} -> {:error, reason}
end
end
end

View File

@ -359,8 +359,7 @@ defmodule WhoNeedHelp.Notifications do
"request_id" => request.id, "request_id" => request.id,
"category_id" => request.category_id, "category_id" => request.category_id,
"urgency" => to_string(request.urgency), "urgency" => to_string(request.urgency),
"push_enabled" => subscription.push_enabled, "push_enabled" => subscription.push_enabled
"email_enabled" => subscription.email_enabled
}, },
idempotency_key: idempotency_key:
"nearby-request:#{request.id}:#{subscription.user_id}:#{safe_event_key(event_key)}" "nearby-request:#{request.id}:#{subscription.user_id}:#{safe_event_key(event_key)}"
@ -382,16 +381,6 @@ defmodule WhoNeedHelp.Notifications do
end end
end end
def email_allowed?(
%Preference{} = preference,
%Notification{kind: :nearby_request} = notification
) do
preference.email_enabled and preference.nearby_email_enabled and
Map.get(notification.data, "email_enabled", false)
end
def email_allowed?(%Preference{}, %Notification{}), do: false
def quiet_now?(%Preference{quiet_hours_enabled: false}, _now), do: false def quiet_now?(%Preference{quiet_hours_enabled: false}, _now), do: false
def quiet_now?( def quiet_now?(

View File

@ -1,36 +0,0 @@
defmodule WhoNeedHelp.Notifications.EmailNotifier do
@moduledoc false
use Gettext, backend: WhoNeedHelpWeb.Gettext
import Swoosh.Email
alias WhoNeedHelp.Accounts.User
alias WhoNeedHelp.Mailer
alias WhoNeedHelp.Notifications.Notification
def deliver(%User{} = user, %Notification{kind: :nearby_request} = notification) do
with_user_locale(user, fn ->
from = Application.fetch_env!(:who_need_help, :mailer_from)
url = WhoNeedHelpWeb.Endpoint.url() <> notification.path
email =
new()
|> to(user.email)
|> from({from[:name], from[:address]})
|> subject(gettext("New help request nearby"))
|> text_body(
gettext(
"A request matching one of your nearby-help alerts is available.\n\nOpen Who Need Help to review the public details:\n%{url}\n\nYou can change nearby alerts and email delivery in Notification settings.",
url: url
)
)
Mailer.deliver(email)
end)
end
defp with_user_locale(%User{locale: locale}, fun) do
Gettext.with_locale(WhoNeedHelpWeb.Gettext, WhoNeedHelp.Locales.normalize(locale), fun)
end
end

View File

@ -60,11 +60,6 @@ defmodule WhoNeedHelp.Notifications.NearbySubscription do
|> validate_subset(:available_days, 1..7) |> validate_subset(:available_days, 1..7)
|> validate_length(:category_ids, max: 50) |> validate_length(:category_ids, max: 50)
|> validate_schedule() |> validate_schedule()
|> validate_delivery_channel()
|> check_constraint(:push_enabled,
name: :nearby_subscriptions_delivery_channel_required,
message: "select push, email, or both"
)
end end
def allowed_radii, do: @allowed_radii def allowed_radii, do: @allowed_radii
@ -112,12 +107,4 @@ defmodule WhoNeedHelp.Notifications.NearbySubscription do
add_error(changeset, :available_until, "set both availability times or leave both empty") add_error(changeset, :available_until, "set both availability times or leave both empty")
end end
end end
defp validate_delivery_channel(changeset) do
if get_field(changeset, :push_enabled) or get_field(changeset, :email_enabled) do
changeset
else
add_error(changeset, :push_enabled, "select push, email, or both")
end
end
end end

View File

@ -8,7 +8,7 @@ defmodule WhoNeedHelp.Push.NotificationDispatchWorker do
alias WhoNeedHelp.Notifications alias WhoNeedHelp.Notifications
alias WhoNeedHelp.Notifications.{Notification, Preference, Text} alias WhoNeedHelp.Notifications.{Notification, Preference, Text}
alias WhoNeedHelp.Push alias WhoNeedHelp.Push
alias WhoNeedHelp.Push.{DeliveryWorker, DeviceDeliveryWorker, NotificationEmailWorker} alias WhoNeedHelp.Push.{DeliveryWorker, DeviceDeliveryWorker}
alias WhoNeedHelp.Repo alias WhoNeedHelp.Repo
@impl Oban.Worker @impl Oban.Worker
@ -24,10 +24,9 @@ defmodule WhoNeedHelp.Push.NotificationDispatchWorker do
%Preference{user_id: notification.user_id} %Preference{user_id: notification.user_id}
push_allowed? = Notifications.push_allowed?(preference, notification) push_allowed? = Notifications.push_allowed?(preference, notification)
email_allowed? = Notifications.email_allowed?(preference, notification)
cond do cond do
not push_allowed? and not email_allowed? -> not push_allowed? ->
:ok :ok
Notifications.quiet_now?(preference, now) -> Notifications.quiet_now?(preference, now) ->
@ -40,19 +39,14 @@ defmodule WhoNeedHelp.Push.NotificationDispatchWorker do
{:snooze, seconds} {:snooze, seconds}
true -> true ->
case dispatch_push(localized_notification, push_allowed?) do dispatch_push(localized_notification)
:ok -> dispatch_email(notification, email_allowed?)
{:error, _reason} = error -> error
end
end end
else else
nil -> {:cancel, :notification_missing} nil -> {:cancel, :notification_missing}
end end
end end
defp dispatch_push(_notification, false), do: :ok defp dispatch_push(notification) do
defp dispatch_push(notification, true) do
devices = Notifications.active_devices(notification.user_id) devices = Notifications.active_devices(notification.user_id)
cond do cond do
@ -80,18 +74,6 @@ defmodule WhoNeedHelp.Push.NotificationDispatchWorker do
end end
end end
defp dispatch_email(_notification, false), do: :ok
defp dispatch_email(notification, true) do
notification.id
|> then(&NotificationEmailWorker.new(%{notification_id: &1}))
|> Oban.insert()
|> case do
{:ok, _job} -> :ok
{:error, reason} -> {:error, reason}
end
end
defp gateway_notification(notification) do defp gateway_notification(notification) do
%{ %{
idempotency_key: notification.idempotency_key, idempotency_key: notification.idempotency_key,

View File

@ -1,40 +1,16 @@
defmodule WhoNeedHelp.Push.NotificationEmailWorker do defmodule WhoNeedHelp.Push.NotificationEmailWorker do
@moduledoc false @moduledoc """
Retires legacy immediate nearby-email jobs without delivering them.
New notification dispatches no longer enqueue this worker. Keeping the module
lets Oban resolve and cancel jobs that were persisted before immediate nearby
email was retired.
"""
use Oban.Worker, use Oban.Worker,
queue: :push, queue: :mail,
max_attempts: 8, max_attempts: 1
unique: [period: :infinity, fields: [:args, :worker], keys: [:notification_id]]
alias WhoNeedHelp.Notifications
alias WhoNeedHelp.Notifications.{EmailNotifier, Notification, Preference, Text}
alias WhoNeedHelp.Repo
@impl Oban.Worker @impl Oban.Worker
def perform(%Oban.Job{args: %{"notification_id" => notification_id}}) do def perform(_job), do: {:cancel, :immediate_nearby_email_retired}
notification = Repo.get(Notification, notification_id)
case notification do
nil ->
{:cancel, :notification_missing}
%Notification{} = notification ->
notification = Repo.preload(notification, :user)
preference =
Repo.get_by(Preference, user_id: notification.user_id) ||
%Preference{user_id: notification.user_id}
if Notifications.email_allowed?(preference, notification) do
localized_notification = Text.localize(notification, notification.user.locale)
case EmailNotifier.deliver(notification.user, localized_notification) do
{:ok, _metadata} -> :ok
{:error, reason} -> {:error, reason}
end
else
{:cancel, :email_disabled}
end
end
end
end end

View File

@ -6,6 +6,8 @@ defmodule WhoNeedHelp.Support do
alias WhoNeedHelp.Accounts alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Accounts.DataLifecycle alias WhoNeedHelp.Accounts.DataLifecycle
alias WhoNeedHelp.Accounts.{Scope, User} alias WhoNeedHelp.Accounts.{Scope, User}
alias WhoNeedHelp.Mail.{SupportOperatorAlertWorker, SupportUpdateWorker}
alias WhoNeedHelp.Notifications
alias WhoNeedHelp.Pagination alias WhoNeedHelp.Pagination
alias WhoNeedHelp.Repo alias WhoNeedHelp.Repo
alias WhoNeedHelp.Support.{ConversationMessage, Notifier, StatusEvent, SupportRequest} alias WhoNeedHelp.Support.{ConversationMessage, Notifier, StatusEvent, SupportRequest}
@ -250,9 +252,9 @@ defmodule WhoNeedHelp.Support do
moderator.id, moderator.id,
:staff :staff
), ),
{:ok, _message} <- {:ok, message} <-
maybe_record_message(request, moderator.id, :staff, response_to_record), maybe_record_message(request, moderator.id, :staff, response_to_record),
{:ok, _audit} <- {:ok, audit} <-
Trust.audit( Trust.audit(
moderator.id, moderator.id,
"support_request.moderated", "support_request.moderated",
@ -263,7 +265,13 @@ defmodule WhoNeedHelp.Support do
"assigned_to_id" => request.assigned_to_id "assigned_to_id" => request.assigned_to_id
} }
) do ) do
{:ok, preload_conversation(request)} {:ok,
{preload_conversation(request),
%{
audit_id: audit.id,
message_added: match?(%ConversationMessage{}, message),
status_changed: previous_status != request.status
}}}
end end
else else
{:error, :not_found} {:error, :not_found}
@ -335,7 +343,6 @@ defmodule WhoNeedHelp.Support do
{:error, :not_found} {:error, :not_found}
end end
end) end)
|> notify_operator_update()
|> then(fn |> then(fn
{:ok, {updated, _message}} -> {:ok, preload_conversation(updated)} {:ok, {updated, _message}} -> {:ok, preload_conversation(updated)}
error -> error error -> error
@ -380,7 +387,6 @@ defmodule WhoNeedHelp.Support do
{:error, :invalid_status} {:error, :invalid_status}
end end
end) end)
|> notify_operator_update()
|> then(fn |> then(fn
{:ok, updated} -> {:ok, preload_conversation(updated)} {:ok, updated} -> {:ok, preload_conversation(updated)}
error -> error error -> error
@ -413,47 +419,30 @@ defmodule WhoNeedHelp.Support do
end end
defp notify_created({:ok, {request, :verified}}) do defp notify_created({:ok, {request, :verified}}) do
_ = Notifier.deliver_received(request, status_url(request)) _ = enqueue_operator_alert(request)
_ = Notifier.deliver_operator_alert(request)
{:ok, request} {:ok, request}
end end
defp notify_created({:ok, {request, :duplicate}}), do: {:ok, request} defp notify_created({:ok, {request, :duplicate}}), do: {:ok, request}
defp notify_created(result), do: result defp notify_created(result), do: result
defp notify_decision({:ok, %SupportRequest{contact_verified_at: nil} = request}), defp notify_decision({:ok, {%SupportRequest{contact_verified_at: nil} = request, _update}}),
do: {:ok, request} do: {:ok, request}
defp notify_decision({:ok, request}) do defp notify_decision({:ok, {%SupportRequest{} = request, update}}) do
case Notifier.deliver_decision(request, status_url(request)) do if update.status_changed or update.message_added do
{:ok, _metadata} -> notify_requester_in_app(request, update)
request
|> Ecto.Changeset.change(response_sent_at: DateTime.utc_now(:second))
|> Repo.update()
|> then(fn
{:ok, updated} -> {:ok, preload_conversation(updated)}
error -> error
end)
_error ->
{:ok, request}
end end
if support_email_needed?(request, update) do
_ = enqueue_support_email(request)
end
{:ok, request}
end end
defp notify_decision(result), do: result defp notify_decision(result), do: result
defp notify_operator_update({:ok, {%SupportRequest{} = request, _message}} = result) do
_ = Notifier.deliver_requester_update(request)
result
end
defp notify_operator_update({:ok, %SupportRequest{} = request} = result) do
_ = Notifier.deliver_requester_update(request)
result
end
defp notify_operator_update(result), do: result
defp broadcast_request_update({:ok, %SupportRequest{} = request} = result) do defp broadcast_request_update({:ok, %SupportRequest{} = request} = result) do
event = {:support_request_updated, request.id} event = {:support_request_updated, request.id}
@ -522,8 +511,7 @@ defmodule WhoNeedHelp.Support do
defp verify_contact(%SupportRequest{} = request), do: {:ok, request} defp verify_contact(%SupportRequest{} = request), do: {:ok, request}
defp notify_verified_request({:ok, {request, :newly_verified}}) do defp notify_verified_request({:ok, {request, :newly_verified}}) do
_ = Notifier.deliver_received(request, status_url(request)) _ = enqueue_operator_alert(request)
_ = Notifier.deliver_operator_alert(request)
event = {:support_request_updated, request.id} event = {:support_request_updated, request.id}
Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, request_topic(request.id), event) Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, request_topic(request.id), event)
@ -535,6 +523,44 @@ defmodule WhoNeedHelp.Support do
defp notify_verified_request({:ok, {request, :already_verified}}), do: {:ok, request} defp notify_verified_request({:ok, {request, :already_verified}}), do: {:ok, request}
defp notify_verified_request(result), do: result defp notify_verified_request(result), do: result
defp notify_requester_in_app(%SupportRequest{requester_id: nil}, _update), do: :ok
defp notify_requester_in_app(%SupportRequest{} = request, update) do
Notifications.notify_user(request.requester_id, %{
kind: :support_update,
title: "Support request updated",
body: "Open Who Need Help to review the latest update.",
path: "/support/cases/#{request.id}",
idempotency_key: "support-request-update:#{update.audit_id}:#{request.requester_id}",
data: %{
"kind" => "support_request_updated",
"request_id" => request.id,
"status" => to_string(request.status),
"response_added" => update.message_added
}
})
end
defp support_email_needed?(request, update) do
update.status_changed or (update.message_added and is_nil(request.response_sent_at))
end
defp enqueue_support_email(request) do
request.id
|> then(&SupportUpdateWorker.new(%{request_id: &1}))
|> Oban.insert()
end
defp enqueue_operator_alert(request) do
if Notifier.operator_alerts_enabled?() do
request.id
|> then(&SupportOperatorAlertWorker.new(%{request_id: &1}))
|> Oban.insert()
else
{:ok, :disabled}
end
end
defp verify_legacy_confirmation(%SupportRequest{id: id} = request, token) do defp verify_legacy_confirmation(%SupportRequest{id: id} = request, token) do
case Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @access_salt, token, case Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @access_salt, token,
max_age: contact_verification_max_age_seconds() max_age: contact_verification_max_age_seconds()

View File

@ -1,7 +1,7 @@
defmodule WhoNeedHelp.Support.Notifier do defmodule WhoNeedHelp.Support.Notifier do
import Swoosh.Email import Swoosh.Email
alias WhoNeedHelp.Mailer alias WhoNeedHelp.EmailDelivery
alias WhoNeedHelp.Support.SupportRequest alias WhoNeedHelp.Support.SupportRequest
def deliver_confirmation(%SupportRequest{} = request, status_url) do def deliver_confirmation(%SupportRequest{} = request, status_url) do
@ -15,25 +15,8 @@ defmodule WhoNeedHelp.Support.Notifier do
#{status_url} #{status_url}
If you did not make this request, ignore this email. It will not reach the support queue. If you did not make this request, ignore this email. It will not reach the support queue.
""" """,
) :support_confirmation
end
def deliver_received(%SupportRequest{} = request, status_url) do
deliver(
request.contact_email,
"Who Need Help support request #{request.reference}",
"""
We received your support request #{request.reference}.
Subject: #{request.subject}
Status: #{request.status}
View the current status:
#{status_url}
If there is immediate danger, contact the emergency service for your location.
"""
) )
end end
@ -45,12 +28,11 @@ defmodule WhoNeedHelp.Support.Notifier do
Your support request #{request.reference} was updated. Your support request #{request.reference} was updated.
Status: #{request.status} Status: #{request.status}
Response:
#{request.resolution_note || "No additional response was provided."}
View the current status: Open the private status page to read the latest response and continue the conversation:
#{status_url} #{status_url}
""" """,
:support_update
) )
end end
@ -67,7 +49,8 @@ defmodule WhoNeedHelp.Support.Notifier do
Operator queue: #{WhoNeedHelpWeb.Endpoint.url()}/support/operations Operator queue: #{WhoNeedHelpWeb.Endpoint.url()}/support/operations
Sign in with an authorised moderator or administrator account to review it. Sign in with an authorised moderator or administrator account to review it.
""" """,
:support_operator
) )
{:error, reason} -> {:error, reason} ->
@ -75,29 +58,9 @@ defmodule WhoNeedHelp.Support.Notifier do
end end
end end
def deliver_requester_update(%SupportRequest{} = request) do def operator_alerts_enabled?, do: match?({:ok, _address}, operator_alert_address())
case operator_alert_address() do
{:ok, address} ->
deliver(
address,
"Support request updated by requester #{request.reference}",
"""
The requester added a message or reopened a support request.
Reference: #{request.reference} defp deliver(recipient, subject, body, kind) do
Status: #{request.status}
Operator queue: #{WhoNeedHelpWeb.Endpoint.url()}/support/operations
Sign in with an authorised moderator or administrator account to review it.
"""
)
{:error, reason} ->
{:ok, reason}
end
end
defp deliver(recipient, subject, body) do
from = Application.fetch_env!(:who_need_help, :mailer_from) from = Application.fetch_env!(:who_need_help, :mailer_from)
new() new()
@ -106,7 +69,7 @@ defmodule WhoNeedHelp.Support.Notifier do
|> maybe_reply_to() |> maybe_reply_to()
|> subject(subject) |> subject(subject)
|> text_body(body) |> text_body(body)
|> Mailer.deliver() |> EmailDelivery.deliver(kind)
end end
defp maybe_reply_to(email) do defp maybe_reply_to(email) do

View File

@ -2,6 +2,8 @@ defmodule WhoNeedHelp.Support.SupportRequest do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
alias WhoNeedHelp.EmailAddress
@primary_key {:id, :binary_id, autogenerate: true} @primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id @foreign_key_type :binary_id
@ -57,9 +59,7 @@ defmodule WhoNeedHelp.Support.SupportRequest do
|> cast(attrs, [:kind, :contact_email, :subject, :details]) |> cast(attrs, [:kind, :contact_email, :subject, :details])
|> validate_required([:kind, :contact_email, :subject, :details, :reference]) |> validate_required([:kind, :contact_email, :subject, :details, :reference])
|> update_change(:contact_email, &normalize_email/1) |> update_change(:contact_email, &normalize_email/1)
|> validate_format(:contact_email, ~r/^[^@,;\s]+@[^@,;\s]+$/, |> EmailAddress.validate(:contact_email)
message: "must have the @ sign and no spaces"
)
|> validate_length(:contact_email, max: 160) |> validate_length(:contact_email, max: 160)
|> validate_length(:subject, min: 3, max: 160) |> validate_length(:subject, min: 3, max: 160)
|> validate_length(:details, min: 10, max: 5_000) |> validate_length(:details, min: 10, max: 5_000)

View File

@ -156,7 +156,7 @@
<p class="mt-4 text-xs text-base-content/65"> <p class="mt-4 text-xs text-base-content/65">
{gettext( {gettext(
"Keep this status link private. A copy is sent to the contact email when email delivery is configured." "Keep this status link private. Important status changes may also be sent to the verified contact email."
)} )}
</p> </p>
</aside> </aside>

View File

@ -455,17 +455,11 @@ defmodule WhoNeedHelpWeb.NotificationLive do
type="checkbox" type="checkbox"
label={gettext("Acceptance, arrival, completion, and cancellation")} label={gettext("Acceptance, arrival, completion, and cancellation")}
/> />
<div class="divider text-xs uppercase tracking-wider">{gettext("Email")}</div> <div class="rounded-2xl bg-base-200 p-4 text-sm text-base-content/70">
<.input {gettext(
field={@preference_form[:email_enabled]} "Nearby updates use the in-app inbox and optional push. Email digests are not enabled yet."
type="checkbox" )}
label={gettext("Allow email notifications")} </div>
/>
<.input
field={@preference_form[:nearby_email_enabled]}
type="checkbox"
label={gettext("Nearby requests by email")}
/>
<.input <.input
field={@preference_form[:quiet_hours_enabled]} field={@preference_form[:quiet_hours_enabled]}
type="checkbox" type="checkbox"
@ -508,9 +502,6 @@ defmodule WhoNeedHelpWeb.NotificationLive do
<span :if={subscription.push_enabled} class="badge badge-sm badge-outline"> <span :if={subscription.push_enabled} class="badge badge-sm badge-outline">
{gettext("Push")} {gettext("Push")}
</span> </span>
<span :if={subscription.email_enabled} class="badge badge-sm badge-outline">
{gettext("Email")}
</span>
</div> </div>
</div> </div>
<span class={[ <span class={[
@ -798,19 +789,15 @@ defmodule WhoNeedHelpWeb.NotificationLive do
<fieldset class="rounded-2xl border border-base-300 p-4"> <fieldset class="rounded-2xl border border-base-300 p-4">
<legend class="px-1 font-bold">{gettext("Delivery channels")}</legend> <legend class="px-1 font-bold">{gettext("Delivery channels")}</legend>
<p class="mb-3 text-sm text-base-content/60"> <p class="mb-3 text-sm text-base-content/60">
{gettext("Choose at least one way to receive this nearby alert.")} {gettext("Nearby alerts always appear in your inbox. Push is optional.")}
</p> </p>
<div class="grid gap-3 sm:grid-cols-2"> <div>
<.input <.input
field={@subscription_form[:push_enabled]} field={@subscription_form[:push_enabled]}
type="checkbox" type="checkbox"
label={gettext("Push notification")} label={gettext("Push notification")}
/> />
<.input <input type="hidden" name="nearby_subscription[email_enabled]" value="false" />
field={@subscription_form[:email_enabled]}
type="checkbox"
label={gettext("Email notification")}
/>
</div> </div>
</fieldset> </fieldset>
<.button class="btn btn-primary btn-lg w-full">{gettext("Create nearby alert")}</.button> <.button class="btn btn-primary btn-lg w-full">{gettext("Create nearby alert")}</.button>

View File

@ -171,6 +171,13 @@ defmodule WhoNeedHelpWeb.Telemetry do
measurement: :duration, measurement: :duration,
description: "Single-email delivery attempts that raised an exception" description: "Single-email delivery attempts that raised an exception"
), ),
counter("who_need_help.email.by_kind.deliveries.total",
event_name: [:who_need_help, :email, :delivery],
measurement: :duration,
tags: [:kind, :status],
description:
"Email delivery attempts by fixed purpose and outcome without personal labels"
),
sum("who_need_help.oban.job.duration.microseconds.total", sum("who_need_help.oban.job.duration.microseconds.total",
event_name: [:oban, :job, :stop], event_name: [:oban, :job, :stop],
measurement: fn measurements -> measurement: fn measurements ->

View File

@ -0,0 +1,18 @@
defmodule WhoNeedHelp.Repo.Migrations.AllowInboxOnlyNearbySubscriptions do
use Ecto.Migration
def up do
drop constraint(
:nearby_subscriptions,
:nearby_subscriptions_delivery_channel_required
)
end
def down do
create constraint(
:nearby_subscriptions,
:nearby_subscriptions_delivery_channel_required,
check: "push_enabled OR email_enabled"
)
end
end

View File

@ -206,6 +206,7 @@ MIGRATE_POOL_SIZE=2
COMBINED_POOL_SIZE=4 COMBINED_POOL_SIZE=4
OBAN_MAINTENANCE_CONCURRENCY=2 OBAN_MAINTENANCE_CONCURRENCY=2
OBAN_PUSH_CONCURRENCY=1 OBAN_PUSH_CONCURRENCY=1
OBAN_MAIL_CONCURRENCY=1
WEB_REPLICAS=2 WEB_REPLICAS=2
WORKER_REPLICAS=2 WORKER_REPLICAS=2
ERLANG_PORT_LIMIT=65536 ERLANG_PORT_LIMIT=65536

View File

@ -98,6 +98,7 @@ MIGRATE_POOL_SIZE=2
COMBINED_POOL_SIZE=4 COMBINED_POOL_SIZE=4
OBAN_MAINTENANCE_CONCURRENCY=2 OBAN_MAINTENANCE_CONCURRENCY=2
OBAN_PUSH_CONCURRENCY=1 OBAN_PUSH_CONCURRENCY=1
OBAN_MAIL_CONCURRENCY=1
WEB_REPLICAS=2 WEB_REPLICAS=2
WORKER_REPLICAS=2 WORKER_REPLICAS=2
ERLANG_PORT_LIMIT=65536 ERLANG_PORT_LIMIT=65536

View File

@ -59,6 +59,7 @@ GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS
needs_fixture_password=true needs_fixture_password=true
needs_oban_maintenance_concurrency=true needs_oban_maintenance_concurrency=true
needs_oban_push_concurrency=true needs_oban_push_concurrency=true
needs_oban_mail_concurrency=true
needs_resilience_timeout=true needs_resilience_timeout=true
needs_resilience_interval=true needs_resilience_interval=true
needs_resilience_request_timeout=true needs_resilience_request_timeout=true
@ -88,6 +89,8 @@ GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS
needs_oban_maintenance_concurrency=false needs_oban_maintenance_concurrency=false
grep -q '^OBAN_PUSH_CONCURRENCY=' "$ENV_FILE" && grep -q '^OBAN_PUSH_CONCURRENCY=' "$ENV_FILE" &&
needs_oban_push_concurrency=false needs_oban_push_concurrency=false
grep -q '^OBAN_MAIL_CONCURRENCY=' "$ENV_FILE" &&
needs_oban_mail_concurrency=false
grep -q '^LOAD_RESILIENCE_RECOVERY_TIMEOUT_SECONDS=' "$ENV_FILE" && grep -q '^LOAD_RESILIENCE_RECOVERY_TIMEOUT_SECONDS=' "$ENV_FILE" &&
needs_resilience_timeout=false needs_resilience_timeout=false
grep -q '^LOAD_RESILIENCE_PROBE_INTERVAL_SECONDS=' "$ENV_FILE" && grep -q '^LOAD_RESILIENCE_PROBE_INTERVAL_SECONDS=' "$ENV_FILE" &&
@ -137,6 +140,7 @@ GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS
[ "$needs_fixture_password" = false ] && [ "$needs_fixture_password" = false ] &&
[ "$needs_oban_maintenance_concurrency" = false ] && [ "$needs_oban_maintenance_concurrency" = false ] &&
[ "$needs_oban_push_concurrency" = false ] && [ "$needs_oban_push_concurrency" = false ] &&
[ "$needs_oban_mail_concurrency" = false ] &&
[ "$needs_resilience_timeout" = false ] && [ "$needs_resilience_timeout" = false ] &&
[ "$needs_resilience_interval" = false ] && [ "$needs_resilience_interval" = false ] &&
[ "$needs_resilience_request_timeout" = false ] && [ "$needs_resilience_request_timeout" = false ] &&
@ -209,7 +213,8 @@ GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS
fi fi
if [ "$needs_oban_maintenance_concurrency" = true ] || if [ "$needs_oban_maintenance_concurrency" = true ] ||
[ "$needs_oban_push_concurrency" = true ]; then [ "$needs_oban_push_concurrency" = true ] ||
[ "$needs_oban_mail_concurrency" = true ]; then
printf '\n# Added by the worker-queue configuration upgrade.\n' printf '\n# Added by the worker-queue configuration upgrade.\n'
fi fi
@ -221,6 +226,10 @@ GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS
printf 'OBAN_PUSH_CONCURRENCY=1\n' printf 'OBAN_PUSH_CONCURRENCY=1\n'
fi fi
if [ "$needs_oban_mail_concurrency" = true ]; then
printf 'OBAN_MAIL_CONCURRENCY=1\n'
fi
if [ "$needs_resilience_timeout" = true ] || if [ "$needs_resilience_timeout" = true ] ||
[ "$needs_resilience_interval" = true ]; then [ "$needs_resilience_interval" = true ]; then
printf '\n# Added by the local resilience-profile upgrade.\n' printf '\n# Added by the local resilience-profile upgrade.\n'
@ -347,7 +356,8 @@ GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS
unset load_fixture_password observability_grafana_admin_password \ unset load_fixture_password observability_grafana_admin_password \
backup_minio_root_user backup_minio_root_password backup_restic_password \ backup_minio_root_user backup_minio_root_password backup_restic_password \
needs_fixture_password needs_oban_maintenance_concurrency \ needs_fixture_password needs_oban_maintenance_concurrency \
needs_oban_push_concurrency needs_resilience_timeout \ needs_oban_push_concurrency needs_oban_mail_concurrency \
needs_resilience_timeout \
needs_resilience_interval needs_resilience_request_timeout \ needs_resilience_interval needs_resilience_request_timeout \
needs_traefik_retry_attempts needs_traefik_api_insecure \ needs_traefik_retry_attempts needs_traefik_api_insecure \
needs_observability_prometheus_port \ needs_observability_prometheus_port \

View File

@ -186,6 +186,7 @@ snapshot_domain_counts >"$output_dir/domain-counts-before.json"
printf 'maintenance_concurrency_per_worker=%s\n' \ printf 'maintenance_concurrency_per_worker=%s\n' \
"${OBAN_MAINTENANCE_CONCURRENCY:-2}" "${OBAN_MAINTENANCE_CONCURRENCY:-2}"
printf 'push_concurrency_per_worker=%s\n' "${OBAN_PUSH_CONCURRENCY:-1}" printf 'push_concurrency_per_worker=%s\n' "${OBAN_PUSH_CONCURRENCY:-1}"
printf 'mail_concurrency_per_worker=%s\n' "${OBAN_MAIL_CONCURRENCY:-1}"
} >"$output_dir/inputs.txt" } >"$output_dir/inputs.txt"
for container in "${worker_containers[@]}"; do for container in "${worker_containers[@]}"; do

View File

@ -1356,6 +1356,7 @@ docker compose --project-name who_need_help_edge \
and $root.services.migrate.environment.POOL_SIZE == "2" and $root.services.migrate.environment.POOL_SIZE == "2"
and $root.services.worker.environment.OBAN_MAINTENANCE_CONCURRENCY == "2" and $root.services.worker.environment.OBAN_MAINTENANCE_CONCURRENCY == "2"
and $root.services.worker.environment.OBAN_PUSH_CONCURRENCY == "1" and $root.services.worker.environment.OBAN_PUSH_CONCURRENCY == "1"
and $root.services.worker.environment.OBAN_MAIL_CONCURRENCY == "1"
and $root.services.web.deploy.replicas == 2 and $root.services.web.deploy.replicas == 2
and $root.services.worker.deploy.replicas == 2 and $root.services.worker.deploy.replicas == 2
and ($root.services.proxy.networks | keys | sort) == ["docker-api", "edge", "ingress"] and ($root.services.proxy.networks | keys | sort) == ["docker-api", "edge", "ingress"]

View File

@ -0,0 +1,19 @@
defmodule WhoNeedHelp.EmailAddressTest do
use ExUnit.Case, async: true
alias WhoNeedHelp.EmailAddress
test "accepts ordinary mailbox addresses used by transactional email" do
assert EmailAddress.valid?("person@example.com")
assert EmailAddress.valid?("person+alerts@example.co.uk")
end
test "rejects addresses that the SMTP adapter cannot safely parse" do
refute EmailAddress.valid?("two..dots@example.com")
refute EmailAddress.valid?("person@example")
refute EmailAddress.valid?("person@-example.com")
refute EmailAddress.valid?("person@example..com")
refute EmailAddress.valid?("person@exam ple.com")
refute EmailAddress.valid?("first@example.com,second@example.com")
end
end

View File

@ -162,10 +162,10 @@ defmodule WhoNeedHelp.NotificationsTest do
"email_enabled" => "false" "email_enabled" => "false"
}) })
assert "select push, email, or both" in errors_on(no_channel_changeset).push_enabled refute Map.has_key?(errors_on(no_channel_changeset), :push_enabled)
end end
test "nearby email is durable, generic, and enabled when any matching alert opts in", context do test "nearby alerts use the inbox and push without immediate email", context do
assert_email_sent() assert_email_sent()
assert_email_sent() assert_email_sent()
@ -225,7 +225,6 @@ defmodule WhoNeedHelp.NotificationsTest do
kind: :nearby_request kind: :nearby_request
) )
assert notification.data["email_enabled"]
assert notification.data["push_enabled"] assert notification.data["push_enabled"]
refute inspect(notification) =~ "Private home label" refute inspect(notification) =~ "Private home label"
refute inspect(notification) =~ "Do not send this label" refute inspect(notification) =~ "Do not send this label"
@ -233,23 +232,11 @@ defmodule WhoNeedHelp.NotificationsTest do
assert :ok = assert :ok =
perform_job(NotificationDispatchWorker, %{"notification_id" => notification.id}) perform_job(NotificationDispatchWorker, %{"notification_id" => notification.id})
assert_enqueued( assert all_enqueued(worker: NotificationEmailWorker) == []
worker: NotificationEmailWorker, refute_receive {:email, _nearby_email}, 50
queue: :push,
args: %{"notification_id" => notification.id}
)
assert :ok = assert {:cancel, :immediate_nearby_email_retired} =
perform_job(NotificationEmailWorker, %{"notification_id" => notification.id}) perform_job(NotificationEmailWorker, %{"notification_id" => notification.id})
assert_email_sent(fn email ->
assert email.to == [{"", context.helper.email}]
assert email.subject == "New help request nearby"
assert email.text_body =~ "/requests/#{request.id}"
refute email.text_body =~ "Private home label"
refute email.text_body =~ "Do not send this label"
true
end)
end end
test "device registration is idempotent and follows an authenticated shared installation", test "device registration is idempotent and follows an authenticated shared installation",

View File

@ -1,11 +1,14 @@
defmodule WhoNeedHelp.SupportAndContentRemovalTest do defmodule WhoNeedHelp.SupportAndContentRemovalTest do
use WhoNeedHelp.DataCase, async: false use WhoNeedHelp.DataCase, async: false
use Oban.Testing, repo: WhoNeedHelp.Repo
import WhoNeedHelp.AccountsFixtures import WhoNeedHelp.AccountsFixtures
import Swoosh.TestAssertions import Swoosh.TestAssertions
alias WhoNeedHelp.{Catalog, ContentRemoval, Help, Repo, Support} alias WhoNeedHelp.{Catalog, ContentRemoval, Help, Repo, Support}
alias WhoNeedHelp.ContentRemoval.Notice alias WhoNeedHelp.ContentRemoval.Notice
alias WhoNeedHelp.Mail.{SupportOperatorAlertWorker, SupportUpdateWorker}
alias WhoNeedHelp.Notifications.Notification
alias WhoNeedHelp.Support.{ConversationMessage, StatusEvent, SupportRequest} alias WhoNeedHelp.Support.{ConversationMessage, StatusEvent, SupportRequest}
alias WhoNeedHelp.Trust.AuditEvent alias WhoNeedHelp.Trust.AuditEvent
@ -77,11 +80,7 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
event.target_id == ^request.id event.target_id == ^request.id
) )
assert_email_sent(fn received -> refute_receive {:email, _duplicate_received_email}, 50
received.to == [{"", request.contact_email}] and
received.subject == "Who Need Help support request #{request.reference}" and
received.text_body =~ "/support/cases/#{request.id}?token="
end)
end end
test "operator alert is sent only after public contact verification" do test "operator alert is sent only after public contact verification" do
@ -117,11 +116,14 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
assert verified.status == :open assert verified.status == :open
assert_email_sent(fn email -> assert_enqueued(
email.to == [{"", "appeal@example.com"}] and worker: SupportOperatorAlertWorker,
email.subject == "Who Need Help support request #{request.reference}" and queue: :mail,
email.text_body =~ "/support/cases/#{request.id}?token=" args: %{"request_id" => request.id}
end) )
assert {:ok, _metadata} =
perform_job(SupportOperatorAlertWorker, %{"request_id" => request.id})
assert_email_sent(fn email -> assert_email_sent(fn email ->
email.to == [{"", "support@example.com"}] and email.subject =~ request.reference and email.to == [{"", "support@example.com"}] and email.subject =~ request.reference and
@ -159,9 +161,7 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
assert request.contact_verified_at assert request.contact_verified_at
assert_email_sent(fn email -> refute_enqueued(worker: SupportOperatorAlertWorker)
email.to == [{"", user.email}] and email.subject =~ request.reference
end)
refute_receive {:email, _operator_alert}, 50 refute_receive {:email, _operator_alert}, 50
@ -237,7 +237,6 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
assert request.contact_email == user.email assert request.contact_email == user.email
assert request.contact_verified_at assert request.contact_verified_at
assert_email_sent()
moderator = staff_user_fixture([:support, :legal]) moderator = staff_user_fixture([:support, :legal])
@ -256,7 +255,7 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
}) })
assert resolved.status == :resolved assert resolved.status == :resolved
assert resolved.response_sent_at refute resolved.response_sent_at
assert [%ConversationMessage{sender_role: :staff, body: response}] = assert [%ConversationMessage{sender_role: :staff, body: response}] =
resolved.conversation_messages resolved.conversation_messages
@ -264,9 +263,76 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
assert response == "The export is ready for the verified account owner." assert response == "The export is ready for the verified account owner."
assert Enum.map(resolved.status_events, & &1.to_status) == [:open, :resolved] assert Enum.map(resolved.status_events, & &1.to_status) == [:open, :resolved]
assert_enqueued(
worker: SupportUpdateWorker,
queue: :mail,
args: %{"request_id" => request.id}
)
assert :ok = perform_job(SupportUpdateWorker, %{"request_id" => request.id})
assert Repo.reload!(resolved).response_sent_at
assert_email_sent(fn email -> assert_email_sent(fn email ->
email.subject =~ "support update" and email.text_body =~ "export is ready" email.subject =~ "support update" and
email.text_body =~ "/support/cases/#{request.id}" and
not String.contains?(email.text_body, "export is ready")
end) end)
assert %Notification{kind: :support_update, user_id: user_id} =
Repo.get_by!(Notification, user_id: user.id, kind: :support_update)
assert user_id == user.id
end
test "support chat emails the first reply and later public status changes, not every message" do
requester = user_fixture()
assert_email_sent()
moderator = staff_user_fixture([:support])
assert_email_sent()
assert {:ok, request} =
Support.create_request(user_scope_fixture(requester), %{
"kind" => "technical_issue",
"subject" => "Keep support email volume bounded",
"details" => "Use the private inbox for an ongoing support conversation."
})
assert {:ok, reviewing} =
Support.moderate(user_scope_fixture(moderator), request.id, %{
"status" => "reviewing",
"response" => "We started reviewing this request."
})
assert reviewing.status == :reviewing
assert %{success: 1} = Oban.drain_queue(queue: :mail)
assert_email_sent()
assert {:ok, still_reviewing} =
Support.moderate(user_scope_fixture(moderator), request.id, %{
"status" => "reviewing",
"response" => "This is another message in the same active conversation."
})
assert still_reviewing.status == :reviewing
assert all_enqueued(worker: SupportUpdateWorker) == []
refute_receive {:email, _chat_message_email}, 50
assert {:ok, waiting} =
Support.moderate(user_scope_fixture(moderator), request.id, %{
"status" => "waiting_for_requester",
"response" => "Please add the missing diagnostic information."
})
assert waiting.status == :waiting_for_requester
assert_enqueued(
worker: SupportUpdateWorker,
queue: :mail,
args: %{"request_id" => request.id}
)
assert %{success: 1} = Oban.drain_queue(queue: :mail)
assert_email_sent()
end end
test "support and legal cases can be assigned only to active staff for the matching queue" do test "support and legal cases can be assigned only to active staff for the matching queue" do
@ -352,7 +418,15 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
"I need help understanding which nearby notifications are currently enabled." "I need help understanding which nearby notifications are currently enabled."
}) })
assert_email_sent() assert_enqueued(
worker: SupportOperatorAlertWorker,
queue: :mail,
args: %{"request_id" => request.id}
)
assert {:ok, _metadata} =
perform_job(SupportOperatorAlertWorker, %{"request_id" => request.id})
assert_email_sent() assert_email_sent()
assert {:ok, resolved} = assert {:ok, resolved} =
@ -362,8 +436,11 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
}) })
assert resolved.status == :resolved assert resolved.status == :resolved
assert :ok = perform_job(SupportUpdateWorker, %{"request_id" => request.id})
assert_email_sent() assert_email_sent()
operator_job_count = length(all_enqueued(worker: SupportOperatorAlertWorker))
assert {:ok, reopened} = assert {:ok, reopened} =
Support.add_requester_message(scope, request.id, nil, %{ Support.add_requester_message(scope, request.id, nil, %{
"body" => "I tried that, but the switch still appears disabled." "body" => "I tried that, but the switch still appears disabled."
@ -378,11 +455,8 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
assert Enum.map(reopened.status_events, & &1.to_status) == [:open, :resolved, :open] assert Enum.map(reopened.status_events, & &1.to_status) == [:open, :resolved, :open]
assert_email_sent(fn email -> assert length(all_enqueued(worker: SupportOperatorAlertWorker)) == operator_job_count
email.to == [{"", "support@example.com"}] and refute_receive {:email, _requester_message_email}, 50
email.subject =~ "updated by requester" and
not String.contains?(email.text_body, "switch still appears disabled")
end)
end end
test "only the requester or a valid private token can reply or reopen a support case" do test "only the requester or a valid private token can reply or reopen a support case" do

View File

@ -51,7 +51,8 @@ defmodule WhoNeedHelpWeb.MetricsControllerTest do
|> Enum.filter(fn metric -> |> Enum.filter(fn metric ->
metric.name in [ metric.name in [
[:who_need_help, :email, :deliveries, :total], [:who_need_help, :email, :deliveries, :total],
[:who_need_help, :email, :delivery, :exceptions, :total] [:who_need_help, :email, :delivery, :exceptions, :total],
[:who_need_help, :email, :by_kind, :deliveries, :total]
] ]
end) end)
@ -77,14 +78,26 @@ defmodule WhoNeedHelpWeb.MetricsControllerTest do
%{mailer: WhoNeedHelp.Mailer, kind: :error, reason: :timeout} %{mailer: WhoNeedHelp.Mailer, kind: :error, reason: :timeout}
) )
:telemetry.execute(
[:who_need_help, :email, :delivery],
%{duration: 100},
%{kind: "support_update", status: "ok"}
)
body = TelemetryMetricsPrometheus.Core.scrape(reporter_name) body = TelemetryMetricsPrometheus.Core.scrape(reporter_name)
assert body =~ ~s(who_need_help_email_deliveries_total{status="ok"} 1) assert body =~ ~s(who_need_help_email_deliveries_total{status="ok"} 1)
assert body =~ ~s(who_need_help_email_deliveries_total{status="error"} 1) assert body =~ ~s(who_need_help_email_deliveries_total{status="error"} 1)
assert body =~ "who_need_help_email_delivery_exceptions_total 1" assert body =~ "who_need_help_email_delivery_exceptions_total 1"
assert body =~
~s(who_need_help_email_by_kind_deliveries_total{kind="support_update",status="ok"} 1)
refute body =~ "accepted" refute body =~ "accepted"
refute body =~ "rejected" refute body =~ "rejected"
refute body =~ "recipient" refute body =~ "recipient"
refute body =~ "subject"
refute body =~ "email_address"
end end
test "database execution metric tolerates events without query_time" do test "database execution metric tolerates events without query_time" do

View File

@ -122,8 +122,6 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
"nearby_push_enabled" => "true", "nearby_push_enabled" => "true",
"message_push_enabled" => "true", "message_push_enabled" => "true",
"lifecycle_push_enabled" => "true", "lifecycle_push_enabled" => "true",
"email_enabled" => "true",
"nearby_email_enabled" => "true",
"quiet_hours_enabled" => "true", "quiet_hours_enabled" => "true",
"quiet_start" => "22:00", "quiet_start" => "22:00",
"quiet_end" => "07:00", "quiet_end" => "07:00",
@ -155,7 +153,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
"available_from" => "", "available_from" => "",
"available_until" => "", "available_until" => "",
"push_enabled" => "true", "push_enabled" => "true",
"email_enabled" => "true" "email_enabled" => "false"
} }
}) })
|> render_submit() |> render_submit()
@ -165,7 +163,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert html =~ "Medicine near home" assert html =~ "Medicine near home"
assert html =~ "Private home area · 3 km" assert html =~ "Private home area · 3 km"
assert html =~ "Push" assert html =~ "Push"
assert html =~ "Email" refute html =~ "Email"
assert has_element?(view, "button[phx-click='toggle-subscription']", "Pause") assert has_element?(view, "button[phx-click='toggle-subscription']", "Pause")
end end