Reduce transactional email noise
This commit is contained in:
parent
ecd5ef3c4f
commit
128ae0ea85
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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}]}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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. |
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
43
lib/who_need_help/email_address.ex
Normal file
43
lib/who_need_help/email_address.ex
Normal 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
|
||||||
57
lib/who_need_help/email_delivery.ex
Normal file
57
lib/who_need_help/email_delivery.ex
Normal 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
|
||||||
30
lib/who_need_help/mail/support_operator_alert_worker.ex
Normal file
30
lib/who_need_help/mail/support_operator_alert_worker.ex
Normal 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
|
||||||
44
lib/who_need_help/mail/support_update_worker.ex
Normal file
44
lib/who_need_help/mail/support_update_worker.ex
Normal 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
|
||||||
|
|
@ -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?(
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 ->
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 \
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
19
test/who_need_help/email_address_test.exs
Normal file
19
test/who_need_help/email_address_test.exs
Normal 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
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user