Add confirmation-gated inbound support email

This commit is contained in:
SimpleTest 2026-08-13 23:36:29 +03:00
parent dab96a9ed1
commit dd10e2d2d0
22 changed files with 686 additions and 0 deletions

View File

@ -224,6 +224,12 @@ EMAIL_FROM_NAME="Who Need Help"
EMAIL_FROM_ADDRESS=contact@example.com EMAIL_FROM_ADDRESS=contact@example.com
# Optional monitored inbox used as Reply-To for support and legal correspondence. # Optional monitored inbox used as Reply-To for support and legal correspondence.
SUPPORT_INBOX_ADDRESS= 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 # 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 newly verified case. # mailbox should receive one metadata-only alert for each newly verified case.

View File

@ -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 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 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). [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: Then validate the file structure and the production Compose render:
```bash ```bash

View File

@ -29,6 +29,8 @@ x-app-environment: &app-environment
EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:?Set EMAIL_FROM_NAME in .env} EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:?Set EMAIL_FROM_NAME in .env}
EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:?Set EMAIL_FROM_ADDRESS in .env} EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:?Set EMAIL_FROM_ADDRESS in .env}
SUPPORT_INBOX_ADDRESS: ${SUPPORT_INBOX_ADDRESS:-} 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} CODEX_SESSION_ID: ${CODEX_SESSION_ID:-not-configured}
# Empty/unset uses the compiled pilot policy. Set exactly {} only for an # Empty/unset uses the compiled pilot policy. Set exactly {} only for an
# isolated benchmark or test environment that must disable every counter. # isolated benchmark or test environment that must disable every counter.

View File

@ -12,6 +12,8 @@ config :who_need_help, :mailer_from,
address: "contact@example.com" address: "contact@example.com"
config :who_need_help, :support_inbox_address, nil 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, config :who_need_help, :scopes,
user: [ user: [

View File

@ -750,6 +750,30 @@ if config_env() == :prod do
config :who_need_help, :support_inbox_address, support_inbox_address 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 = support_operator_email_mode =
case System.get_env("SUPPORT_OPERATOR_EMAIL_MODE", "disabled") do case System.get_env("SUPPORT_OPERATOR_EMAIL_MODE", "disabled") do
"disabled" -> "disabled" ->

View File

@ -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_auth_adapter, WhoNeedHelp.GoogleAuthFake
config :who_need_help, :google_oauth_base_url, "https://accounts.google.example/" 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, :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 # Disable swoosh api client as it is only required for production adapters
config :swoosh, :api_client, false config :swoosh, :api_client, false

View File

@ -50,6 +50,8 @@ app:
# GitHub linking, and both GOOGLE_OAUTH_CLIENT_ID and # GitHub linking, and both GOOGLE_OAUTH_CLIENT_ID and
# GOOGLE_OAUTH_CLIENT_SECRET to enable Google registration/sign-in. Optional # GOOGLE_OAUTH_CLIENT_SECRET to enable Google registration/sign-in. Optional
# SUPPORT_INBOX_ADDRESS enables metadata-only operator alerts and Reply-To. # 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. # SMTP provider credentials can be kept in this Secret.
# Push is # Push is
# opt-in: provide PUSH_HTTP_ENDPOINT, # opt-in: provide PUSH_HTTP_ENDPOINT,

View File

@ -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 `https://YOUR_PHX_HOST/auth/google/callback` as the exact authorized redirect
URI. Leave both credentials empty to keep the feature disabled. Then run: 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 <that token>` 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 ```bash
./scripts/validate-production-env.sh .env whoneedhelp.com ./scripts/validate-production-env.sh .env whoneedhelp.com
./scripts/deploy-up.sh .env ./scripts/deploy-up.sh .env

View File

@ -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 mail delivery: verify its MX routing and complete a real receive-and-reply test
before using it in Google Play or public support pages. 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 ## 3. Record human and legal decisions
The following decisions are intentionally not generated by code: The following decisions are intentionally not generated by code:

View File

@ -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 sent. The configured inbox must be monitored operationally; the application
cannot prove staffing or response availability. 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.
- <https://developers.brevo.com/docs/inbound-parse-webhooks>
- <https://developers.brevo.com/docs/secured-webhooks>
Anonymous submissions use two distinct private links. The confirmation link is Anonymous submissions use two distinct private links. The confirmation link is
valid for `PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS` (the initial pilot valid for `PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS` (the initial pilot
policy is 24 hours). Confirming it moves the record into the permission-scoped policy is 24 hours). Confirming it moves the record into the permission-scoped

View File

@ -88,6 +88,31 @@ defmodule WhoNeedHelp.Support do
end end
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 def create_account_deletion_request(%Scope{user: %User{}} = scope, details \\ nil) do
create_request(scope, %{ create_request(scope, %{
"kind" => "account_deletion", "kind" => "account_deletion",
@ -778,6 +803,103 @@ defmodule WhoNeedHelp.Support do
defp resolve_duplicate_submission(result, _fingerprint), do: result 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 defp duplicate_fingerprint_error?(changeset) do
Enum.any?(changeset.errors, fn Enum.any?(changeset.errors, fn
{:public_submission_fingerprint, {_message, metadata}} -> {:public_submission_fingerprint, {_message, metadata}} ->
@ -788,5 +910,16 @@ defmodule WhoNeedHelp.Support do
end) end)
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) defp normalize_keys(attrs), do: Map.new(attrs, fn {key, value} -> {to_string(key), value} end)
end end

View File

@ -38,6 +38,8 @@ defmodule WhoNeedHelp.Support.SupportRequest do
field :details, :string field :details, :string
field :contact_verified_at, :utc_datetime field :contact_verified_at, :utc_datetime
field :public_submission_fingerprint, :string field :public_submission_fingerprint, :string
field :external_source, :string
field :external_id, :string
field :resolution_note, :string field :resolution_note, :string
field :reviewed_at, :utc_datetime field :reviewed_at, :utc_datetime
field :response_sent_at, :utc_datetime field :response_sent_at, :utc_datetime
@ -67,6 +69,32 @@ defmodule WhoNeedHelp.Support.SupportRequest do
|> unique_constraint(:public_submission_fingerprint) |> unique_constraint(:public_submission_fingerprint)
end 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 def moderation_changeset(request, attrs) do
request request
|> cast(attrs, [ |> cast(attrs, [

View File

@ -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

View File

@ -53,6 +53,12 @@ defmodule WhoNeedHelpWeb.Router do
get "/assetlinks.json", AndroidAppLinksController, :show get "/assetlinks.json", AndroidAppLinksController, :show
end end
scope "/webhooks", WhoNeedHelpWeb do
pipe_through :api
post "/brevo/inbound-support", BrevoInboundSupportController, :create
end
scope "/", WhoNeedHelpWeb do scope "/", WhoNeedHelpWeb do
get "/metrics", MetricsController, :show get "/metrics", MetricsController, :show
end end

View File

@ -10,3 +10,4 @@
20260801141743 application_safe additive concurrent operations queue search indexes 20260801141743 application_safe additive concurrent operations queue search indexes
20260801200302 application_safe additive generated public discovery coordinate columns 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 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

1 # migration_version application_rollback_policy reason
10 20260801141743 application_safe additive concurrent operations queue search indexes
11 20260801200302 application_safe additive generated public discovery coordinate columns
12 20260812120611 application_safe dropping the delivery-channel check allows inbox-only subscriptions that old code can still read safely
13 20260813194249 application_safe additive nullable inbound-provider identity columns and a partial unique index are ignored by the previous application

View File

@ -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

View File

@ -232,6 +232,17 @@ else
missing "support reply address" "SUPPORT_INBOX_ADDRESS" missing "support reply address" "SUPPORT_INBOX_ADDRESS"
fi 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) rate_limit_policies_json=$(value RATE_LIMIT_POLICIES_JSON)
if [[ "$deployment_env" == test && "$rate_limit_policies_json" == "{}" ]]; then if [[ "$deployment_env" == test && "$rate_limit_policies_json" == "{}" ]]; then
local_only "public rate limits" "all shared counters are disabled for this isolated test deployment" local_only "public rate limits" "all shared counters are disabled for this isolated test deployment"

View File

@ -383,6 +383,12 @@ smtp_tls=${PRODUCTION_SMTP_TLS:-always}
smtp_ssl=${PRODUCTION_SMTP_SSL:-false} smtp_ssl=${PRODUCTION_SMTP_SSL:-false}
email_from_address=${PRODUCTION_EMAIL_FROM_ADDRESS:-"contact@$domain"} email_from_address=${PRODUCTION_EMAIL_FROM_ADDRESS:-"contact@$domain"}
support_inbox_address=${PRODUCTION_SUPPORT_INBOX_ADDRESS:-} 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_URL "$database_url"
require_single_line_env_value PRODUCTION_DATABASE_SOCKET_DIR "$database_socket_dir" 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_SMTP_SSL "$smtp_ssl"
require_single_line_env_value PRODUCTION_EMAIL_FROM_ADDRESS "$email_from_address" 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_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 ] || { [ "$email_delivery_provider" = smtp ] || {
echo "PRODUCTION_EMAIL_DELIVERY_PROVIDER must be smtp." >&2 echo "PRODUCTION_EMAIL_DELIVERY_PROVIDER must be smtp." >&2
@ -441,6 +456,8 @@ SMTP_TLS_VALUE=$smtp_tls \
SMTP_SSL_VALUE=$smtp_ssl \ SMTP_SSL_VALUE=$smtp_ssl \
EMAIL_FROM_ADDRESS_VALUE=$email_from_address \ EMAIL_FROM_ADDRESS_VALUE=$email_from_address \
SUPPORT_INBOX_ADDRESS_VALUE=$support_inbox_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 \ CODEX_SESSION_ID_VALUE=$codex_session_id \
GIT_SHA_VALUE=$git_sha \ GIT_SHA_VALUE=$git_sha \
GOOGLE_OAUTH_CLIENT_ID_VALUE=$google_oauth_client_id \ 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["SMTP_SSL"] = ENVIRON["SMTP_SSL_VALUE"]
replacement["EMAIL_FROM_ADDRESS"] = ENVIRON["EMAIL_FROM_ADDRESS_VALUE"] replacement["EMAIL_FROM_ADDRESS"] = ENVIRON["EMAIL_FROM_ADDRESS_VALUE"]
replacement["SUPPORT_INBOX_ADDRESS"] = ENVIRON["SUPPORT_INBOX_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_ID"] = ENVIRON["GOOGLE_OAUTH_CLIENT_ID_VALUE"]
replacement["GOOGLE_OAUTH_CLIENT_SECRET"] = ENVIRON["GOOGLE_OAUTH_CLIENT_SECRET_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"] replacement["GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS"] = ENVIRON["GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS_VALUE"]

View File

@ -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_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_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_INBOX_ADDRESS=support@help.test \
PRODUCTION_SUPPORT_INBOUND_RECIPIENT=support@reply.help.test \
./scripts/init-production-env.sh help.test "$production_env" >/dev/null ./scripts/init-production-env.sh help.test "$production_env" >/dev/null
test "$(stat -c '%a' "$production_env")" = 600 test "$(stat -c '%a' "$production_env")" = 600
grep -Fx "SMTP_PASSWORD=quality\$\$password" "$production_env" >/dev/null 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' \ grep -Fx 'GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS=quality-production-android-client' \
"$production_env" >/dev/null "$production_env" >/dev/null
./scripts/validate-production-env.sh "$production_env" help.test >/dev/null ./scripts/validate-production-env.sh "$production_env" help.test >/dev/null
./scripts/check-environment-readiness.sh "$production_env" --require-release >/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" disabled_rate_limits_env="$scan_dir/production.disabled-rate-limits.env"
cp "$production_env" "$disabled_rate_limits_env" cp "$production_env" "$disabled_rate_limits_env"
sed -i 's|^RATE_LIMIT_POLICIES_JSON=.*|RATE_LIMIT_POLICIES_JSON={}|' \ sed -i 's|^RATE_LIMIT_POLICIES_JSON=.*|RATE_LIMIT_POLICIES_JSON={}|' \

View File

@ -49,6 +49,8 @@ managed_keys=(
GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_ID
GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_CLIENT_SECRET
SUPPORT_INBOX_ADDRESS SUPPORT_INBOX_ADDRESS
SUPPORT_INBOUND_RECIPIENT
SUPPORT_INBOUND_WEBHOOK_TOKEN
SUPPORT_OPERATOR_EMAIL_MODE SUPPORT_OPERATOR_EMAIL_MODE
WEB_PUSH_VAPID_PRIVATE_KEY WEB_PUSH_VAPID_PRIVATE_KEY
WEB_PUSH_VAPID_PUBLIC_KEY WEB_PUSH_VAPID_PUBLIC_KEY

View File

@ -198,6 +198,8 @@ smtp_tls=$(optional_value SMTP_TLS)
smtp_ssl=$(optional_value SMTP_SSL) smtp_ssl=$(optional_value SMTP_SSL)
email_from_address=$(require_value EMAIL_FROM_ADDRESS) email_from_address=$(require_value EMAIL_FROM_ADDRESS)
support_inbox_address=$(optional_value SUPPORT_INBOX_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) rate_limit_policies_json=$(require_value RATE_LIMIT_POLICIES_JSON)
google_oauth_client_id=$(optional_value GOOGLE_OAUTH_CLIENT_ID) google_oauth_client_id=$(optional_value GOOGLE_OAUTH_CLIENT_ID)
google_oauth_client_secret=$(optional_value GOOGLE_OAUTH_CLIENT_SECRET) 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 echo "SUPPORT_INBOX_ADDRESS must be a single SMTP-safe mailbox address." >&2
exit 1 exit 1
fi 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" || { 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 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

View File

@ -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