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
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
|
|
@ -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" ->
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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, [
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
|
@ -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"
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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={}|' \
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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