From dd10e2d2d03cc435adf198824401160cdc52f280 Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Thu, 13 Aug 2026 23:36:29 +0300 Subject: [PATCH] Add confirmation-gated inbound support email --- .env.example | 6 + README.md | 4 + compose.yaml | 2 + config/config.exs | 2 + config/runtime.exs | 24 +++ config/test.exs | 2 + deploy/helm/who-need-help/values.yaml | 2 + docs/operations.md | 12 ++ docs/public-launch-checklist.md | 6 + docs/support-and-content-removal.md | 28 +++ lib/who_need_help/support.ex | 133 ++++++++++++ lib/who_need_help/support/support_request.ex | 28 +++ .../brevo_inbound_support_controller.ex | 140 ++++++++++++ lib/who_need_help_web/router.ex | 6 + .../migration_application_compatibility.tsv | 1 + ...813194249_add_support_inbound_identity.exs | 15 ++ scripts/check-environment-readiness.sh | 11 + scripts/init-production-env.sh | 19 ++ scripts/quality.sh | 25 +++ scripts/sync-kind-runtime-secret.sh | 2 + scripts/validate-production-env.sh | 14 ++ .../brevo_inbound_support_controller_test.exs | 204 ++++++++++++++++++ 22 files changed, 686 insertions(+) create mode 100644 lib/who_need_help_web/controllers/brevo_inbound_support_controller.ex create mode 100644 priv/repo/migrations/20260813194249_add_support_inbound_identity.exs create mode 100644 test/who_need_help_web/controllers/brevo_inbound_support_controller_test.exs diff --git a/.env.example b/.env.example index 059dd23..59d3c56 100644 --- a/.env.example +++ b/.env.example @@ -224,6 +224,12 @@ EMAIL_FROM_NAME="Who Need Help" EMAIL_FROM_ADDRESS=contact@example.com # Optional monitored inbox used as Reply-To for support and legal correspondence. SUPPORT_INBOX_ADDRESS= +# Optional inbound email intake. Both values are required together. Configure +# the same receiving address/domain and Bearer token in Brevo's inbound parser. +# Inbound email still requires confirmation of its From address before the case +# becomes visible to staff. Attachments are not downloaded by this integration. +SUPPORT_INBOUND_RECIPIENT= +SUPPORT_INBOUND_WEBHOOK_TOKEN= # Operator email alerts are disabled by default because the permission-scoped # staff workspace is the canonical queue. Set immediate only when a monitored # mailbox should receive one metadata-only alert for each newly verified case. diff --git a/README.md b/README.md index 22eac93..0510bf1 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,10 @@ metadata-only alerts for authenticated or email-verified support cases and make replies return to the support team; unverified public support stays outside the operator queue. The database queues continue to work when it is empty. See [the support and content-removal runbook](docs/support-and-content-removal.md). +That address is not proof of inbound delivery. Optional Brevo inbound parsing +uses the paired `SUPPORT_INBOUND_RECIPIENT` and +`SUPPORT_INBOUND_WEBHOOK_TOKEN` settings; it deduplicates provider messages and +still requires the sender to confirm the mailbox before staff can see the case. Then validate the file structure and the production Compose render: ```bash diff --git a/compose.yaml b/compose.yaml index 08fee6f..0e0b91d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -29,6 +29,8 @@ x-app-environment: &app-environment EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:?Set EMAIL_FROM_NAME in .env} EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:?Set EMAIL_FROM_ADDRESS in .env} SUPPORT_INBOX_ADDRESS: ${SUPPORT_INBOX_ADDRESS:-} + SUPPORT_INBOUND_RECIPIENT: ${SUPPORT_INBOUND_RECIPIENT:-} + SUPPORT_INBOUND_WEBHOOK_TOKEN: ${SUPPORT_INBOUND_WEBHOOK_TOKEN:-} CODEX_SESSION_ID: ${CODEX_SESSION_ID:-not-configured} # Empty/unset uses the compiled pilot policy. Set exactly {} only for an # isolated benchmark or test environment that must disable every counter. diff --git a/config/config.exs b/config/config.exs index a5cbe75..a0a677e 100644 --- a/config/config.exs +++ b/config/config.exs @@ -12,6 +12,8 @@ config :who_need_help, :mailer_from, address: "contact@example.com" config :who_need_help, :support_inbox_address, nil +config :who_need_help, :support_inbound_recipient, nil +config :who_need_help, :support_inbound_webhook_token, nil config :who_need_help, :scopes, user: [ diff --git a/config/runtime.exs b/config/runtime.exs index 19afac1..2c35e8d 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -750,6 +750,30 @@ if config_env() == :prod do config :who_need_help, :support_inbox_address, support_inbox_address + support_inbound_recipient = + case System.get_env("SUPPORT_INBOUND_RECIPIENT") do + value when value in [nil, ""] -> nil + value -> value + end + + support_inbound_webhook_token = + case System.get_env("SUPPORT_INBOUND_WEBHOOK_TOKEN") do + value when value in [nil, ""] -> nil + value -> value + end + + if support_inbound_recipient && + not WhoNeedHelp.EmailAddress.valid?(support_inbound_recipient) do + raise "SUPPORT_INBOUND_RECIPIENT must be a single SMTP-safe mailbox address." + end + + if support_inbound_recipient == nil != (support_inbound_webhook_token == nil) do + raise "SUPPORT_INBOUND_RECIPIENT and SUPPORT_INBOUND_WEBHOOK_TOKEN must be set together." + end + + config :who_need_help, :support_inbound_recipient, support_inbound_recipient + config :who_need_help, :support_inbound_webhook_token, support_inbound_webhook_token + support_operator_email_mode = case System.get_env("SUPPORT_OPERATOR_EMAIL_MODE", "disabled") do "disabled" -> diff --git a/config/test.exs b/config/test.exs index f068a23..604d7c2 100644 --- a/config/test.exs +++ b/config/test.exs @@ -38,6 +38,8 @@ config :who_need_help, :social_oauth_adapter, WhoNeedHelp.SocialOAuthFake config :who_need_help, :google_auth_adapter, WhoNeedHelp.GoogleAuthFake config :who_need_help, :google_oauth_base_url, "https://accounts.google.example/" config :who_need_help, :metrics_token, "test-metrics-token" +config :who_need_help, :support_inbound_recipient, "support@reply.whoneedhelp.test" +config :who_need_help, :support_inbound_webhook_token, "test-support-inbound-token" # Disable swoosh api client as it is only required for production adapters config :swoosh, :api_client, false diff --git a/deploy/helm/who-need-help/values.yaml b/deploy/helm/who-need-help/values.yaml index 17593c6..03e0ba0 100644 --- a/deploy/helm/who-need-help/values.yaml +++ b/deploy/helm/who-need-help/values.yaml @@ -50,6 +50,8 @@ app: # GitHub linking, and both GOOGLE_OAUTH_CLIENT_ID and # GOOGLE_OAUTH_CLIENT_SECRET to enable Google registration/sign-in. Optional # SUPPORT_INBOX_ADDRESS enables metadata-only operator alerts and Reply-To. +# SUPPORT_INBOUND_RECIPIENT and SUPPORT_INBOUND_WEBHOOK_TOKEN optionally enable +# authenticated inbound support-email ingestion; both keys must be present. # SMTP provider credentials can be kept in this Secret. # Push is # opt-in: provide PUSH_HTTP_ENDPOINT, diff --git a/docs/operations.md b/docs/operations.md index fb75a37..cab4280 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -457,6 +457,18 @@ enabled, also set both Google Web client credentials and register `https://YOUR_PHX_HOST/auth/google/callback` as the exact authorized redirect URI. Leave both credentials empty to keep the feature disabled. Then run: +`SUPPORT_INBOX_ADDRESS` does not configure inbound delivery. To opt into the +Brevo inbound-parse boundary, set `SUPPORT_INBOUND_RECIPIENT` to the exact +mailbox on a dedicated receiving subdomain. Set +`SUPPORT_INBOUND_WEBHOOK_TOKEN` to an independent secret, or let the production +initializer generate it when a recipient is supplied. Configure Brevo to send +a secured webhook with `Authorization: Bearer ` to +`https://YOUR_PHX_HOST/webhooks/brevo/inbound-support`. Follow Brevo's current +MX instructions for the receiving subdomain, then verify a real inbound message +and the subsequent mailbox-confirmation link. Inbound messages stay outside the +staff queue until confirmation; provider attachment tokens are deliberately not +downloaded. See `docs/support-and-content-removal.md` for the trust boundary. + ```bash ./scripts/validate-production-env.sh .env whoneedhelp.com ./scripts/deploy-up.sh .env diff --git a/docs/public-launch-checklist.md b/docs/public-launch-checklist.md index a54d353..a89f69f 100644 --- a/docs/public-launch-checklist.md +++ b/docs/public-launch-checklist.md @@ -71,6 +71,12 @@ In particular, a configured `SUPPORT_INBOX_ADDRESS` is not evidence of inbound mail delivery: verify its MX routing and complete a real receive-and-reply test before using it in Google Play or public support pages. +If the optional Brevo inbound path is selected, also verify the dedicated +receiving subdomain, exact `SUPPORT_INBOUND_RECIPIENT`, authenticated webhook, +provider `MessageId` deduplication, confirmation-gated staff visibility, and a +real reply. Do not advertise that address while either inbound setting is +missing or before this external test passes. + ## 3. Record human and legal decisions The following decisions are intentionally not generated by code: diff --git a/docs/support-and-content-removal.md b/docs/support-and-content-removal.md index ec90d61..35e6a22 100644 --- a/docs/support-and-content-removal.md +++ b/docs/support-and-content-removal.md @@ -136,6 +136,34 @@ intake and reporter acknowledgement still work, but no operator inbox alert is sent. The configured inbox must be monitored operationally; the application cannot prove staffing or response availability. +### Optional inbound-email intake + +`SUPPORT_INBOX_ADDRESS` is a reply address and operator-notification target; +by itself it does not make a mailbox capable of receiving mail. An optional +Brevo inbound-parse boundary is available at +`POST /webhooks/brevo/inbound-support`. It remains disabled unless both +`SUPPORT_INBOUND_RECIPIENT` and `SUPPORT_INBOUND_WEBHOOK_TOKEN` are set. + +The endpoint accepts only requests carrying the configured Bearer token and +only messages addressed to the configured recipient. It stores the provider +`MessageId` for idempotency, ignores attachment download tokens, and creates +the same `pending_verification` support record as the public form. The sender +must follow the existing confirmation link before the case becomes visible to +staff. The provider's `From` field is therefore never treated as proof that the +sender controls that mailbox. + +Brevo requires the receiving domain to differ from the sending domain. Its +documented example uses a dedicated subdomain such as `reply.example.com`, MX +priority 10 to `inbound1.sendinblue.com.` and priority 20 to +`inbound2.sendinblue.com.`, followed by a secured inbound webhook. For this +project, do not publish a support address until the exact subdomain, Bearer +header, MX records, provider delivery, confirmation email, and reply workflow +have all passed an external test. Provider setup is an external operation and +is not performed merely by enabling these application settings. + +- +- + Anonymous submissions use two distinct private links. The confirmation link is valid for `PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS` (the initial pilot policy is 24 hours). Confirming it moves the record into the permission-scoped diff --git a/lib/who_need_help/support.ex b/lib/who_need_help/support.ex index 97add85..befd757 100644 --- a/lib/who_need_help/support.ex +++ b/lib/who_need_help/support.ex @@ -88,6 +88,31 @@ defmodule WhoNeedHelp.Support do end end + def create_inbound_request(attrs, external_source, external_id) + when is_map(attrs) and is_binary(external_source) and is_binary(external_id) do + attrs = normalize_keys(attrs) + contact_email = attrs["contact_email"] + fingerprint = public_submission_fingerprint(attrs) + + case Repo.get_by(SupportRequest, + external_source: external_source, + external_id: external_id + ) do + %SupportRequest{} = request -> + {:ok, request, :duplicate} + + nil -> + with {:ok, _limit} <- + RateLimiter.check(:support_request, rate_scope(nil, contact_email)) do + result = insert_inbound_request(attrs, external_source, external_id) + + result + |> resolve_duplicate_inbound_request(external_source, external_id, fingerprint) + |> broadcast_inbound_request_update() + end + end + end + def create_account_deletion_request(%Scope{user: %User{}} = scope, details \\ nil) do create_request(scope, %{ "kind" => "account_deletion", @@ -778,6 +803,103 @@ defmodule WhoNeedHelp.Support do defp resolve_duplicate_submission(result, _fingerprint), do: result + defp insert_inbound_request(attrs, external_source, external_id) do + Repo.transact(fn -> + with {:ok, request} <- + %SupportRequest{ + reference: unique_reference("SUP"), + external_source: external_source, + external_id: external_id, + public_submission_fingerprint: public_submission_fingerprint(attrs), + status: :pending_verification + } + |> SupportRequest.inbound_changeset(attrs, external_source, external_id) + |> Repo.insert(), + {:ok, _audit} <- + Trust.audit( + nil, + "support_request.created", + "support_request", + request.id, + %{ + "kind" => to_string(request.kind), + "source" => external_source + } + ), + {:ok, _event} <- + record_status_event(request, nil, request.status, nil, :requester), + {:ok, _job} <- enqueue_created_email(request) do + {:ok, request} + end + end) + end + + defp resolve_duplicate_inbound_request({:ok, request}, _source, _id, _fingerprint), + do: {:ok, request, :created} + + defp resolve_duplicate_inbound_request( + {:error, %Ecto.Changeset{} = changeset} = error, + source, + id, + fingerprint + ) do + cond do + duplicate_external_identity_error?(changeset) -> + resolve_existing_external_identity(source, id, error) + + duplicate_fingerprint_error?(changeset) -> + attach_external_identity(fingerprint, source, id, error) + + true -> + error + end + end + + defp resolve_duplicate_inbound_request(result, _source, _id, _fingerprint), do: result + + defp resolve_existing_external_identity(source, id, error) do + case Repo.get_by(SupportRequest, external_source: source, external_id: id) do + %SupportRequest{} = request -> {:ok, request, :duplicate} + nil -> error + end + end + + defp attach_external_identity(fingerprint, source, id, error) do + case Repo.get_by(SupportRequest, public_submission_fingerprint: fingerprint) do + %SupportRequest{external_source: nil, external_id: nil} = request -> + request + |> SupportRequest.external_identity_changeset(source, id) + |> Repo.update() + |> case do + {:ok, updated} -> + {:ok, updated, :duplicate} + + {:error, changeset} -> + if duplicate_external_identity_error?(changeset), + do: resolve_existing_external_identity(source, id, error), + else: error + end + + %SupportRequest{external_source: ^source, external_id: ^id} = request -> + {:ok, request, :duplicate} + + %SupportRequest{} = request -> + {:ok, request, :duplicate} + + nil -> + error + end + end + + defp broadcast_inbound_request_update({:ok, request, :created} = result) do + _ = broadcast_request_update({:ok, request}) + result + end + + defp broadcast_inbound_request_update({:ok, _request, :duplicate} = result), do: result + + defp broadcast_inbound_request_update(result), do: result + defp duplicate_fingerprint_error?(changeset) do Enum.any?(changeset.errors, fn {:public_submission_fingerprint, {_message, metadata}} -> @@ -788,5 +910,16 @@ defmodule WhoNeedHelp.Support do end) end + defp duplicate_external_identity_error?(changeset) do + Enum.any?(changeset.errors, fn + {field, {_message, metadata}} when field in [:external_source, :external_id] -> + metadata[:constraint] == :unique and + metadata[:constraint_name] == "support_requests_external_identity_index" + + _other -> + false + end) + end + defp normalize_keys(attrs), do: Map.new(attrs, fn {key, value} -> {to_string(key), value} end) end diff --git a/lib/who_need_help/support/support_request.ex b/lib/who_need_help/support/support_request.ex index f98fb8b..8efc81f 100644 --- a/lib/who_need_help/support/support_request.ex +++ b/lib/who_need_help/support/support_request.ex @@ -38,6 +38,8 @@ defmodule WhoNeedHelp.Support.SupportRequest do field :details, :string field :contact_verified_at, :utc_datetime field :public_submission_fingerprint, :string + field :external_source, :string + field :external_id, :string field :resolution_note, :string field :reviewed_at, :utc_datetime field :response_sent_at, :utc_datetime @@ -67,6 +69,32 @@ defmodule WhoNeedHelp.Support.SupportRequest do |> unique_constraint(:public_submission_fingerprint) end + def inbound_changeset(request, attrs, external_source, external_id) do + request + |> submission_changeset(attrs) + |> put_change(:external_source, external_source) + |> put_change(:external_id, external_id) + |> validate_required([:external_source, :external_id]) + |> validate_length(:external_source, max: 40) + |> validate_length(:external_id, max: 998) + |> unique_constraint([:external_source, :external_id], + name: :support_requests_external_identity_index + ) + end + + def external_identity_changeset(request, external_source, external_id) do + request + |> change() + |> put_change(:external_source, external_source) + |> put_change(:external_id, external_id) + |> validate_required([:external_source, :external_id]) + |> validate_length(:external_source, max: 40) + |> validate_length(:external_id, max: 998) + |> unique_constraint([:external_source, :external_id], + name: :support_requests_external_identity_index + ) + end + def moderation_changeset(request, attrs) do request |> cast(attrs, [ diff --git a/lib/who_need_help_web/controllers/brevo_inbound_support_controller.ex b/lib/who_need_help_web/controllers/brevo_inbound_support_controller.ex new file mode 100644 index 0000000..d6a55e9 --- /dev/null +++ b/lib/who_need_help_web/controllers/brevo_inbound_support_controller.ex @@ -0,0 +1,140 @@ +defmodule WhoNeedHelpWeb.BrevoInboundSupportController do + use WhoNeedHelpWeb, :controller + + alias WhoNeedHelp.EmailAddress + alias WhoNeedHelp.Support + alias WhoNeedHelpWeb.MetricsAccess + + @source "brevo_inbound" + + def create(conn, %{"items" => items}) when is_list(items) do + if authorized?(conn) do + outcomes = Enum.map(items, &ingest/1) + + conn + |> put_resp_header("cache-control", "no-store") + |> put_status(:ok) + |> json(summary(outcomes)) + else + unauthorized(conn) + end + end + + def create(conn, _params) do + if authorized?(conn) do + conn + |> put_resp_header("cache-control", "no-store") + |> put_status(:unprocessable_entity) + |> json(%{error: "invalid_inbound_payload"}) + else + unauthorized(conn) + end + end + + defp ingest(item) when is_map(item) do + with {:ok, message_id} <- required_text(item["MessageId"]), + {:ok, contact_email} <- sender_address(item), + true <- EmailAddress.valid?(contact_email), + {:ok, recipient} <- configured_recipient(), + true <- addressed_to?(item, recipient), + {:ok, subject} <- required_text(item["Subject"]), + {:ok, details} <- message_body(item), + {:ok, _request, disposition} <- + Support.create_inbound_request( + %{ + "kind" => "other", + "contact_email" => contact_email, + "subject" => subject, + "details" => details + }, + @source, + message_id + ) do + disposition + else + {:error, :rate_limited} -> :rate_limited + {:error, %Ecto.Changeset{}} -> :invalid + _other -> :invalid + end + end + + defp ingest(_item), do: :invalid + + defp sender_address(%{"From" => %{"Address" => value}}), do: required_text(value) + defp sender_address(_item), do: {:error, :missing_sender} + + defp message_body(item) do + item["ExtractedMarkdownMessage"] + |> present_or(item["RawTextBody"]) + |> required_text() + end + + defp present_or(value, fallback) when is_binary(value) do + if String.trim(value) == "", do: fallback, else: value + end + + defp present_or(_value, fallback), do: fallback + + defp addressed_to?(item, expected) do + recipients = mailbox_addresses(item["To"]) ++ recipient_addresses(item["Recipients"]) + expected in recipients + end + + defp mailbox_addresses(values) when is_list(values) do + Enum.flat_map(values, fn + %{"Address" => value} when is_binary(value) -> [normalize_email(value)] + _other -> [] + end) + end + + defp mailbox_addresses(_values), do: [] + + defp recipient_addresses(values) when is_list(values) do + Enum.flat_map(values, fn + value when is_binary(value) -> [normalize_email(value)] + %{"Address" => value} when is_binary(value) -> [normalize_email(value)] + _other -> [] + end) + end + + defp recipient_addresses(_values), do: [] + + defp configured_recipient do + case Application.get_env(:who_need_help, :support_inbound_recipient) do + value when is_binary(value) and value != "" -> {:ok, normalize_email(value)} + _other -> {:error, :inbound_disabled} + end + end + + defp authorized?(conn) do + token = Application.get_env(:who_need_help, :support_inbound_webhook_token) + MetricsAccess.authorized?(get_req_header(conn, "authorization"), token) + end + + defp unauthorized(conn) do + conn + |> put_resp_header("cache-control", "no-store") + |> put_resp_header("www-authenticate", "Bearer") + |> send_resp(:unauthorized, "") + end + + defp summary(outcomes) do + Enum.reduce(outcomes, %{created: 0, duplicate: 0, invalid: 0, rate_limited: 0}, fn + :created, acc -> Map.update!(acc, :created, &(&1 + 1)) + :duplicate, acc -> Map.update!(acc, :duplicate, &(&1 + 1)) + :rate_limited, acc -> Map.update!(acc, :rate_limited, &(&1 + 1)) + _other, acc -> Map.update!(acc, :invalid, &(&1 + 1)) + end) + end + + defp required_text(value) when is_binary(value) do + case String.trim(value) do + "" -> {:error, :blank} + text -> {:ok, text} + end + end + + defp required_text(_value), do: {:error, :missing} + + defp normalize_email(value), do: value |> String.trim() |> String.downcase() +end diff --git a/lib/who_need_help_web/router.ex b/lib/who_need_help_web/router.ex index 8346175..32deadd 100644 --- a/lib/who_need_help_web/router.ex +++ b/lib/who_need_help_web/router.ex @@ -53,6 +53,12 @@ defmodule WhoNeedHelpWeb.Router do get "/assetlinks.json", AndroidAppLinksController, :show end + scope "/webhooks", WhoNeedHelpWeb do + pipe_through :api + + post "/brevo/inbound-support", BrevoInboundSupportController, :create + end + scope "/", WhoNeedHelpWeb do get "/metrics", MetricsController, :show end diff --git a/priv/repo/migration_application_compatibility.tsv b/priv/repo/migration_application_compatibility.tsv index d876848..aaa87e3 100644 --- a/priv/repo/migration_application_compatibility.tsv +++ b/priv/repo/migration_application_compatibility.tsv @@ -10,3 +10,4 @@ 20260801141743 application_safe additive concurrent operations queue search indexes 20260801200302 application_safe additive generated public discovery coordinate columns 20260812120611 application_safe dropping the delivery-channel check allows inbox-only subscriptions that old code can still read safely +20260813194249 application_safe additive nullable inbound-provider identity columns and a partial unique index are ignored by the previous application diff --git a/priv/repo/migrations/20260813194249_add_support_inbound_identity.exs b/priv/repo/migrations/20260813194249_add_support_inbound_identity.exs new file mode 100644 index 0000000..208dc65 --- /dev/null +++ b/priv/repo/migrations/20260813194249_add_support_inbound_identity.exs @@ -0,0 +1,15 @@ +defmodule WhoNeedHelp.Repo.Migrations.AddSupportInboundIdentity do + use Ecto.Migration + + def change do + alter table(:support_requests) do + add :external_source, :string + add :external_id, :text + end + + create unique_index(:support_requests, [:external_source, :external_id], + name: :support_requests_external_identity_index, + where: "external_source IS NOT NULL AND external_id IS NOT NULL" + ) + end +end diff --git a/scripts/check-environment-readiness.sh b/scripts/check-environment-readiness.sh index 8bfaa7c..9eef3c3 100755 --- a/scripts/check-environment-readiness.sh +++ b/scripts/check-environment-readiness.sh @@ -232,6 +232,17 @@ else missing "support reply address" "SUPPORT_INBOX_ADDRESS" fi +if is_set SUPPORT_INBOUND_RECIPIENT && is_set SUPPORT_INBOUND_WEBHOOK_TOKEN; then + ready "inbound support webhook" \ + "authenticated application endpoint is configured; provider webhook and MX delivery still require an external receive test" +elif is_set SUPPORT_INBOUND_RECIPIENT || is_set SUPPORT_INBOUND_WEBHOOK_TOKEN; then + missing "inbound support webhook" \ + "SUPPORT_INBOUND_RECIPIENT and SUPPORT_INBOUND_WEBHOOK_TOKEN must be set together" +else + ready "inbound support webhook" \ + "optional inbound email intake is disabled" +fi + rate_limit_policies_json=$(value RATE_LIMIT_POLICIES_JSON) if [[ "$deployment_env" == test && "$rate_limit_policies_json" == "{}" ]]; then local_only "public rate limits" "all shared counters are disabled for this isolated test deployment" diff --git a/scripts/init-production-env.sh b/scripts/init-production-env.sh index a55534f..ea95de9 100755 --- a/scripts/init-production-env.sh +++ b/scripts/init-production-env.sh @@ -383,6 +383,12 @@ smtp_tls=${PRODUCTION_SMTP_TLS:-always} smtp_ssl=${PRODUCTION_SMTP_SSL:-false} email_from_address=${PRODUCTION_EMAIL_FROM_ADDRESS:-"contact@$domain"} support_inbox_address=${PRODUCTION_SUPPORT_INBOX_ADDRESS:-} +support_inbound_recipient=${PRODUCTION_SUPPORT_INBOUND_RECIPIENT:-} +support_inbound_webhook_token=${PRODUCTION_SUPPORT_INBOUND_WEBHOOK_TOKEN:-} + +if [ -n "$support_inbound_recipient" ] && [ -z "$support_inbound_webhook_token" ]; then + support_inbound_webhook_token=$(openssl rand -hex 32) +fi require_single_line_env_value PRODUCTION_DATABASE_URL "$database_url" require_single_line_env_value PRODUCTION_DATABASE_SOCKET_DIR "$database_socket_dir" @@ -399,6 +405,15 @@ require_single_line_env_value PRODUCTION_SMTP_TLS "$smtp_tls" require_single_line_env_value PRODUCTION_SMTP_SSL "$smtp_ssl" require_single_line_env_value PRODUCTION_EMAIL_FROM_ADDRESS "$email_from_address" require_single_line_env_value PRODUCTION_SUPPORT_INBOX_ADDRESS "$support_inbox_address" +require_single_line_env_value PRODUCTION_SUPPORT_INBOUND_RECIPIENT "$support_inbound_recipient" +require_single_line_env_value PRODUCTION_SUPPORT_INBOUND_WEBHOOK_TOKEN "$support_inbound_webhook_token" + +if [ -n "$support_inbound_recipient" ] || [ -n "$support_inbound_webhook_token" ]; then + if [ -z "$support_inbound_recipient" ] || [ -z "$support_inbound_webhook_token" ]; then + echo "PRODUCTION_SUPPORT_INBOUND_RECIPIENT and PRODUCTION_SUPPORT_INBOUND_WEBHOOK_TOKEN must be set together." >&2 + exit 1 + fi +fi [ "$email_delivery_provider" = smtp ] || { echo "PRODUCTION_EMAIL_DELIVERY_PROVIDER must be smtp." >&2 @@ -441,6 +456,8 @@ SMTP_TLS_VALUE=$smtp_tls \ SMTP_SSL_VALUE=$smtp_ssl \ EMAIL_FROM_ADDRESS_VALUE=$email_from_address \ SUPPORT_INBOX_ADDRESS_VALUE=$support_inbox_address \ +SUPPORT_INBOUND_RECIPIENT_VALUE=$support_inbound_recipient \ +SUPPORT_INBOUND_WEBHOOK_TOKEN_VALUE=$support_inbound_webhook_token \ CODEX_SESSION_ID_VALUE=$codex_session_id \ GIT_SHA_VALUE=$git_sha \ GOOGLE_OAUTH_CLIENT_ID_VALUE=$google_oauth_client_id \ @@ -515,6 +532,8 @@ TEST_UPSTREAM_VALUE=$test_upstream \ replacement["SMTP_SSL"] = ENVIRON["SMTP_SSL_VALUE"] replacement["EMAIL_FROM_ADDRESS"] = ENVIRON["EMAIL_FROM_ADDRESS_VALUE"] replacement["SUPPORT_INBOX_ADDRESS"] = ENVIRON["SUPPORT_INBOX_ADDRESS_VALUE"] + replacement["SUPPORT_INBOUND_RECIPIENT"] = ENVIRON["SUPPORT_INBOUND_RECIPIENT_VALUE"] + replacement["SUPPORT_INBOUND_WEBHOOK_TOKEN"] = ENVIRON["SUPPORT_INBOUND_WEBHOOK_TOKEN_VALUE"] replacement["GOOGLE_OAUTH_CLIENT_ID"] = ENVIRON["GOOGLE_OAUTH_CLIENT_ID_VALUE"] replacement["GOOGLE_OAUTH_CLIENT_SECRET"] = ENVIRON["GOOGLE_OAUTH_CLIENT_SECRET_VALUE"] replacement["GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS"] = ENVIRON["GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS_VALUE"] diff --git a/scripts/quality.sh b/scripts/quality.sh index 12c3311..6e18a04 100755 --- a/scripts/quality.sh +++ b/scripts/quality.sh @@ -941,13 +941,38 @@ PRODUCTION_ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile \ PRODUCTION_ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=A5:74:2B:AE:70:C6:D0:34:E3:75:44:B6:2E:37:A3:75:C0:E0:05:64:74:50:F4:0F:29:B2:A9:84:F9:FD:B8:FB \ PRODUCTION_ANDROID_PLAY_APP_SIGNING_SHA256_CERT_FINGERPRINTS=A5:74:2B:AE:70:C6:D0:34:E3:75:44:B6:2E:37:A3:75:C0:E0:05:64:74:50:F4:0F:29:B2:A9:84:F9:FD:B8:FB \ PRODUCTION_SUPPORT_INBOX_ADDRESS=support@help.test \ +PRODUCTION_SUPPORT_INBOUND_RECIPIENT=support@reply.help.test \ ./scripts/init-production-env.sh help.test "$production_env" >/dev/null test "$(stat -c '%a' "$production_env")" = 600 grep -Fx "SMTP_PASSWORD=quality\$\$password" "$production_env" >/dev/null +grep -Fx 'SUPPORT_INBOUND_RECIPIENT=support@reply.help.test' "$production_env" >/dev/null +support_inbound_token=$( + awk -F= '$1 == "SUPPORT_INBOUND_WEBHOOK_TOKEN" { print substr($0, index($0, "=") + 1); exit }' \ + "$production_env" +) +case "$support_inbound_token" in + "" | *[!0-9a-f]*) + echo "Production initializer generated an invalid inbound-support token." >&2 + exit 1 + ;; +esac +if [ "${#support_inbound_token}" -ne 64 ]; then + echo "Production initializer generated an invalid inbound-support token length." >&2 + exit 1 +fi grep -Fx 'GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS=quality-production-android-client' \ "$production_env" >/dev/null ./scripts/validate-production-env.sh "$production_env" help.test >/dev/null ./scripts/check-environment-readiness.sh "$production_env" --require-release >/dev/null +partial_inbound_env="$scan_dir/production.partial-inbound.env" +cp "$production_env" "$partial_inbound_env" +sed -i 's|^SUPPORT_INBOUND_WEBHOOK_TOKEN=.*|SUPPORT_INBOUND_WEBHOOK_TOKEN=|' \ + "$partial_inbound_env" +if ./scripts/validate-production-env.sh \ + "$partial_inbound_env" help.test >/dev/null 2>&1; then + echo "Production validation accepted a partial inbound-support configuration." >&2 + exit 1 +fi disabled_rate_limits_env="$scan_dir/production.disabled-rate-limits.env" cp "$production_env" "$disabled_rate_limits_env" sed -i 's|^RATE_LIMIT_POLICIES_JSON=.*|RATE_LIMIT_POLICIES_JSON={}|' \ diff --git a/scripts/sync-kind-runtime-secret.sh b/scripts/sync-kind-runtime-secret.sh index e211488..8f5c787 100755 --- a/scripts/sync-kind-runtime-secret.sh +++ b/scripts/sync-kind-runtime-secret.sh @@ -49,6 +49,8 @@ managed_keys=( GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET SUPPORT_INBOX_ADDRESS + SUPPORT_INBOUND_RECIPIENT + SUPPORT_INBOUND_WEBHOOK_TOKEN SUPPORT_OPERATOR_EMAIL_MODE WEB_PUSH_VAPID_PRIVATE_KEY WEB_PUSH_VAPID_PUBLIC_KEY diff --git a/scripts/validate-production-env.sh b/scripts/validate-production-env.sh index c6ca207..c1585ee 100755 --- a/scripts/validate-production-env.sh +++ b/scripts/validate-production-env.sh @@ -198,6 +198,8 @@ smtp_tls=$(optional_value SMTP_TLS) smtp_ssl=$(optional_value SMTP_SSL) email_from_address=$(require_value EMAIL_FROM_ADDRESS) support_inbox_address=$(optional_value SUPPORT_INBOX_ADDRESS) +support_inbound_recipient=$(optional_value SUPPORT_INBOUND_RECIPIENT) +support_inbound_webhook_token=$(optional_value SUPPORT_INBOUND_WEBHOOK_TOKEN) rate_limit_policies_json=$(require_value RATE_LIMIT_POLICIES_JSON) google_oauth_client_id=$(optional_value GOOGLE_OAUTH_CLIENT_ID) google_oauth_client_secret=$(optional_value GOOGLE_OAUTH_CLIENT_SECRET) @@ -427,6 +429,18 @@ if [[ -n "$support_inbox_address" ]] && echo "SUPPORT_INBOX_ADDRESS must be a single SMTP-safe mailbox address." >&2 exit 1 fi +if [[ -n "$support_inbound_recipient" ]] && + ! valid_mailbox_address "$support_inbound_recipient"; then + echo "SUPPORT_INBOUND_RECIPIENT must be a single SMTP-safe mailbox address." >&2 + exit 1 +fi +if [[ -n "$support_inbound_recipient" || -n "$support_inbound_webhook_token" ]]; then + [[ -n "$support_inbound_recipient" && -n "$support_inbound_webhook_token" ]] || { + echo "SUPPORT_INBOUND_RECIPIENT and SUPPORT_INBOUND_WEBHOOK_TOKEN must be set together." >&2 + exit 1 + } + reject_marker SUPPORT_INBOUND_WEBHOOK_TOKEN "$support_inbound_webhook_token" +fi valid_public_rate_limit_policy "$rate_limit_policies_json" || { echo "RATE_LIMIT_POLICIES_JSON must enable every documented public authentication and intake policy with positive integer limit and window_seconds values; {} is reserved for isolated tests." >&2 diff --git a/test/who_need_help_web/controllers/brevo_inbound_support_controller_test.exs b/test/who_need_help_web/controllers/brevo_inbound_support_controller_test.exs new file mode 100644 index 0000000..67a66c6 --- /dev/null +++ b/test/who_need_help_web/controllers/brevo_inbound_support_controller_test.exs @@ -0,0 +1,204 @@ +defmodule WhoNeedHelpWeb.BrevoInboundSupportControllerTest do + use WhoNeedHelpWeb.ConnCase, async: false + use Oban.Testing, repo: WhoNeedHelp.Repo + + alias WhoNeedHelp.Mail.SupportConfirmationWorker + alias WhoNeedHelp.Repo + alias WhoNeedHelp.Support + alias WhoNeedHelp.Support.SupportRequest + + @path "/webhooks/brevo/inbound-support" + @authorization "Bearer test-support-inbound-token" + + test "rejects requests without the configured bearer token", %{conn: conn} do + assert conn |> post(@path, valid_payload()) |> response(401) == "" + + assert conn + |> put_req_header("authorization", "Bearer incorrect-token") + |> post(@path, valid_payload()) + |> response(401) == "" + end + + test "creates an unverified support case and queues confirmation", %{conn: conn} do + response = + conn + |> authorized() + |> post(@path, valid_payload()) + |> json_response(200) + + assert response == %{ + "created" => 1, + "duplicate" => 0, + "invalid" => 0, + "rate_limited" => 0 + } + + request = Repo.get_by!(SupportRequest, external_id: "brevo-message-1@example.test") + + assert request.external_source == "brevo_inbound" + assert request.contact_email == "sender@example.com" + assert request.subject == "Cannot access my account" + assert request.details == "Please help me recover access to my account." + assert request.status == :pending_verification + assert request.contact_verified_at == nil + + assert_enqueued( + worker: SupportConfirmationWorker, + queue: :mail, + args: %{"request_id" => request.id} + ) + end + + test "deduplicates a retried Brevo message id", %{conn: conn} do + previous = Application.get_env(:who_need_help, :rate_limit_policies) + + Application.put_env(:who_need_help, :rate_limit_policies, %{ + "support_request" => %{limit: 1, window_seconds: 3_600} + }) + + on_exit(fn -> Application.put_env(:who_need_help, :rate_limit_policies, previous) end) + + first = conn |> authorized() |> post(@path, valid_payload()) |> json_response(200) + second = conn |> authorized() |> post(@path, valid_payload()) |> json_response(200) + + assert first["created"] == 1 + assert second["duplicate"] == 1 + assert second["rate_limited"] == 0 + assert Repo.aggregate(SupportRequest, :count) == 1 + + assert length(all_enqueued(worker: SupportConfirmationWorker)) == 1 + end + + test "attaches the provider identity to an identical pending web submission", %{conn: conn} do + attrs = %{ + "kind" => "other", + "contact_email" => "sender@example.com", + "subject" => "Cannot access my account", + "details" => "Please help me recover access to my account." + } + + assert {:ok, web_request} = Support.create_request(nil, attrs) + + response = + conn + |> authorized() + |> post(@path, valid_payload()) + |> json_response(200) + + assert response == %{ + "created" => 0, + "duplicate" => 1, + "invalid" => 0, + "rate_limited" => 0 + } + + assert Repo.aggregate(SupportRequest, :count) == 1 + + updated = Repo.get!(SupportRequest, web_request.id) + assert updated.external_source == "brevo_inbound" + assert updated.external_id == "brevo-message-1@example.test" + assert length(all_enqueued(worker: SupportConfirmationWorker)) == 1 + end + + test "rejects messages sent to a different inbound address", %{conn: conn} do + payload = + valid_payload() + |> put_in(["items", Access.at(0), "To"], [ + %{"Address" => "someone-else@reply.whoneedhelp.test"} + ]) + |> put_in(["items", Access.at(0), "Recipients"], [ + "someone-else@reply.whoneedhelp.test" + ]) + + response = conn |> authorized() |> post(@path, payload) |> json_response(200) + + assert response["invalid"] == 1 + assert Repo.aggregate(SupportRequest, :count) == 0 + assert all_enqueued(worker: SupportConfirmationWorker) == [] + end + + test "uses raw text as a fallback and does not process attachment tokens", %{conn: conn} do + item = + valid_item() + |> Map.delete("ExtractedMarkdownMessage") + |> Map.put("RawTextBody", "Fallback message body for the support case.") + |> Map.put("Attachments", [%{"DownloadToken" => "not-fetched"}]) + + response = + conn + |> authorized() + |> post(@path, %{"items" => [item]}) + |> json_response(200) + + assert response["created"] == 1 + + request = Repo.get_by!(SupportRequest, external_id: "brevo-message-1@example.test") + assert request.details == "Fallback message body for the support case." + end + + test "rate limits new inbound messages by normalized sender address", %{conn: conn} do + previous = Application.get_env(:who_need_help, :rate_limit_policies) + + Application.put_env(:who_need_help, :rate_limit_policies, %{ + "support_request" => %{limit: 1, window_seconds: 3_600} + }) + + on_exit(fn -> Application.put_env(:who_need_help, :rate_limit_policies, previous) end) + + first = conn |> authorized() |> post(@path, valid_payload()) |> json_response(200) + + second_item = + valid_item() + |> Map.put("MessageId", "brevo-message-2@example.test") + |> Map.put("Subject", "A different support question") + |> Map.put("ExtractedMarkdownMessage", "This is a different inbound support message.") + + second = + conn + |> recycle() + |> authorized() + |> post(@path, %{"items" => [second_item]}) + |> json_response(200) + + assert first["created"] == 1 + assert second["rate_limited"] == 1 + assert Repo.aggregate(SupportRequest, :count) == 1 + end + + test "returns an invalid outcome without storing malformed items", %{conn: conn} do + response = + conn + |> authorized() + |> post(@path, %{"items" => [%{"MessageId" => "missing-fields"}]}) + |> json_response(200) + + assert response["invalid"] == 1 + assert Repo.aggregate(SupportRequest, :count) == 0 + end + + test "rejects a malformed webhook envelope", %{conn: conn} do + response = + conn + |> authorized() + |> post(@path, %{"unexpected" => []}) + |> json_response(422) + + assert response == %{"error" => "invalid_inbound_payload"} + end + + defp authorized(conn), do: put_req_header(conn, "authorization", @authorization) + + defp valid_payload, do: %{"items" => [valid_item()]} + + defp valid_item do + %{ + "MessageId" => "brevo-message-1@example.test", + "From" => %{"Address" => "sender@example.com", "Name" => "Sender"}, + "To" => [%{"Address" => "support@reply.whoneedhelp.test"}], + "Recipients" => ["support@reply.whoneedhelp.test"], + "Subject" => "Cannot access my account", + "ExtractedMarkdownMessage" => "Please help me recover access to my account.", + "RawTextBody" => "Quoted history that should not replace the extracted message." + } + end +end