Add confirmation-gated inbound support email
This commit is contained in:
parent
dab96a9ed1
commit
dd10e2d2d0
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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" ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 <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
|
||||
./scripts/validate-production-env.sh .env whoneedhelp.com
|
||||
./scripts/deploy-up.sh .env
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
- <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
|
||||
valid for `PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS` (the initial pilot
|
||||
policy is 24 hours). Confirming it moves the record into the permission-scoped
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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={}|' \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue
Block a user