Replace UniSender delivery with isolated SMTP
Some checks are pending
Quality / full-local-gates (push) Waiting to run

This commit is contained in:
SimpleTest 2026-07-22 00:38:37 +03:00
parent 5923608f07
commit 8c281cc756
17 changed files with 55 additions and 753 deletions

View File

@ -145,8 +145,7 @@ HANDOVER_SECRET=generate-an-independent-random-secret
RELEASE_COOKIE=generate-an-independent-beam-cluster-cookie
METRICS_TOKEN=generate-an-independent-random-bearer-token
# Select `smtp` for Mailpit or a regular transactional SMTP relay. Select
# `unisender_go` to use UniSender Go's HTTPS transactional Web API instead.
# Use Mailpit locally or a transactional SMTP relay in public environments.
EMAIL_DELIVERY_PROVIDER=smtp
SMTP_RELAY=mailpit
SMTP_PORT=1025
@ -155,13 +154,6 @@ SMTP_PASSWORD=
SMTP_AUTH=never
SMTP_TLS=never
SMTP_SSL=false
UNISENDER_GO_API_KEY=
UNISENDER_GO_BASE_URL=https://goapi.unisender.ru/ru/transactional/api/v1
# Keep false until UniSender Go support enables this account-level capability.
UNISENDER_GO_SKIP_UNSUBSCRIBE=false
# Optional. When empty, the Req/Finch library defaults are used.
EMAIL_HTTP_CONNECT_TIMEOUT_MS=
EMAIL_HTTP_RECEIVE_TIMEOUT_MS=
EMAIL_FROM_NAME="Who Need Help"
EMAIL_FROM_ADDRESS=contact@example.com
# Optional monitored inbox. It receives new-case alerts and is used as Reply-To.

View File

@ -231,9 +231,8 @@ existing file:
Configure the verified reverse-proxy source IP/CIDR and transactional email
provider in that file. `EMAIL_DELIVERY_PROVIDER=smtp` uses the standard Swoosh
SMTP adapter. `EMAIL_DELIVERY_PROVIDER=unisender_go` uses UniSender Go's HTTPS
Web API and requires `UNISENDER_GO_API_KEY`; this mode does not need outbound
SMTP ports. Email registration and magic-link login are unusable for real
SMTP adapter and requires the relay's `SMTP_*` credentials. Email registration
and magic-link login are unusable for real
recipients until the selected provider and its accepted sender are configured. Set
the optional `SUPPORT_INBOX_ADDRESS` to a monitored mailbox to receive
metadata-only new-case alerts and make replies return to the support team; the
@ -619,9 +618,8 @@ pre-created Secret through required `existingSecret`; the chart never renders
credentials from tracked values. The Secret must contain `DATABASE_URL`,
`SECRET_KEY_BASE`, `HANDOVER_SECRET`, `RELEASE_COOKIE`, and `METRICS_TOKEN`, and
may additionally contain the OAuth credentials described above. Set
`app.emailDeliveryProvider`; UniSender Go API mode also requires
`UNISENDER_GO_API_KEY` in that Secret, while SMTP credentials remain Secret
values in SMTP mode. The chart intentionally has no invented CPU/RAM limits or HPA
`app.emailDeliveryProvider` to `smtp`; keep SMTP credentials in that Secret.
The chart intentionally has no invented CPU/RAM limits or HPA
thresholds; measure this application in the target environment before setting
them.

View File

@ -24,11 +24,6 @@ x-app-environment: &app-environment
SMTP_AUTH: ${SMTP_AUTH:-never}
SMTP_TLS: ${SMTP_TLS:-never}
SMTP_SSL: ${SMTP_SSL:-false}
UNISENDER_GO_API_KEY: ${UNISENDER_GO_API_KEY:-}
UNISENDER_GO_BASE_URL: ${UNISENDER_GO_BASE_URL:-https://goapi.unisender.ru/ru/transactional/api/v1}
UNISENDER_GO_SKIP_UNSUBSCRIBE: ${UNISENDER_GO_SKIP_UNSUBSCRIBE:-false}
EMAIL_HTTP_CONNECT_TIMEOUT_MS: ${EMAIL_HTTP_CONNECT_TIMEOUT_MS:-}
EMAIL_HTTP_RECEIVE_TIMEOUT_MS: ${EMAIL_HTTP_RECEIVE_TIMEOUT_MS:-}
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:-}

View File

@ -482,67 +482,8 @@ if config_env() == :prod do
end
end)
"unisender_go" ->
api_key =
System.get_env("UNISENDER_GO_API_KEY") ||
raise "UNISENDER_GO_API_KEY is required when EMAIL_DELIVERY_PROVIDER=unisender_go."
if api_key == "" do
raise "UNISENDER_GO_API_KEY is required when EMAIL_DELIVERY_PROVIDER=unisender_go."
end
base_url =
System.get_env(
"UNISENDER_GO_BASE_URL",
"https://goapi.unisender.ru/ru/transactional/api/v1"
)
|> String.trim_trailing("/")
uri = URI.parse(base_url)
unless uri.scheme == "https" and is_binary(uri.host) and uri.host != "" and
is_binary(uri.path) and uri.path != "" and is_nil(uri.userinfo) and
is_nil(uri.query) and is_nil(uri.fragment) do
raise "UNISENDER_GO_BASE_URL must be an HTTPS origin and path without credentials, query, or fragment."
end
skip_unsubscribe =
case System.get_env("UNISENDER_GO_SKIP_UNSUBSCRIBE", "false") do
"true" ->
true
"false" ->
false
other ->
raise "UNISENDER_GO_SKIP_UNSUBSCRIBE must be true or false; got #{inspect(other)}"
end
client_options =
[]
|> then(fn options ->
case optional_positive_integer.("EMAIL_HTTP_CONNECT_TIMEOUT_MS") do
nil -> options
timeout -> Keyword.put(options, :connect_options, timeout: timeout)
end
end)
|> then(fn options ->
case optional_positive_integer.("EMAIL_HTTP_RECEIVE_TIMEOUT_MS") do
nil -> options
timeout -> Keyword.put(options, :receive_timeout, timeout)
end
end)
[
adapter: WhoNeedHelp.Email.UnisenderGoAdapter,
api_key: api_key,
base_url: base_url,
skip_unsubscribe: skip_unsubscribe,
client_options: client_options
]
other ->
raise "EMAIL_DELIVERY_PROVIDER must be smtp or unisender_go; got #{inspect(other)}"
raise "EMAIL_DELIVERY_PROVIDER must be smtp; got #{inspect(other)}"
end
config :who_need_help, :handover_secret, handover_secret

View File

@ -53,10 +53,6 @@ spec:
value: {{ $root.Values.app.clusterInterface | quote }}
- name: EMAIL_DELIVERY_PROVIDER
value: {{ $root.Values.app.emailDeliveryProvider | quote }}
- name: UNISENDER_GO_BASE_URL
value: {{ $root.Values.app.unisenderGoBaseUrl | quote }}
- name: UNISENDER_GO_SKIP_UNSUBSCRIBE
value: {{ $root.Values.app.unisenderGoSkipUnsubscribe | quote }}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
@ -101,10 +97,6 @@ spec:
value: {{ $root.Values.app.smtpRelay | quote }}
- name: SMTP_PORT
value: {{ $root.Values.app.smtpPort | quote }}
- name: UNISENDER_GO_BASE_URL
value: {{ $root.Values.app.unisenderGoBaseUrl | quote }}
- name: UNISENDER_GO_SKIP_UNSUBSCRIBE
value: {{ $root.Values.app.unisenderGoSkipUnsubscribe | quote }}
- name: CODEX_SESSION_ID
value: {{ $root.Values.app.codexSessionId | quote }}
- name: RATE_LIMIT_POLICIES_JSON

View File

@ -45,10 +45,6 @@ spec:
value: {{ .Values.worker.pushConcurrency | quote }}
- name: EMAIL_DELIVERY_PROVIDER
value: {{ .Values.app.emailDeliveryProvider | quote }}
- name: UNISENDER_GO_BASE_URL
value: {{ .Values.app.unisenderGoBaseUrl | quote }}
- name: UNISENDER_GO_SKIP_UNSUBSCRIBE
value: {{ .Values.app.unisenderGoSkipUnsubscribe | quote }}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true

View File

@ -13,23 +13,13 @@
"emailDeliveryProvider": {
"type": "string",
"enum": [
"smtp",
"unisender_go"
"smtp"
]
},
"unisenderGoBaseUrl": {
"type": "string",
"pattern": "^https://"
},
"unisenderGoSkipUnsubscribe": {
"type": "boolean"
}
},
"required": [
"erlangPortLimit",
"emailDeliveryProvider",
"unisenderGoBaseUrl",
"unisenderGoSkipUnsubscribe"
"emailDeliveryProvider"
]
}
}

View File

@ -36,8 +36,6 @@ app:
emailDeliveryProvider: smtp
smtpRelay: mailpit
smtpPort: "1025"
unisenderGoBaseUrl: https://goapi.unisender.ru/ru/transactional/api/v1
unisenderGoSkipUnsubscribe: false
# Required. The Secret must contain DATABASE_URL, SECRET_KEY_BASE,
# HANDOVER_SECRET, RELEASE_COOKIE, and METRICS_TOKEN. It may also contain both
@ -45,8 +43,7 @@ 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.
# EMAIL_DELIVERY_PROVIDER=unisender_go additionally requires
# UNISENDER_GO_API_KEY. SMTP mode can keep provider credentials in this Secret.
# SMTP provider credentials can be kept in this Secret.
# Push is
# opt-in: provide PUSH_HTTP_ENDPOINT,
# PUSH_HTTP_BEARER_TOKEN, PUSH_HTTP_MAX_ATTEMPTS,

View File

@ -91,8 +91,9 @@ the image tags. Existing deployment secrets remain unchanged:
./scripts/deploy-up.sh .env
```
Test always uses its own `who_need_help_test` PostGIS container/volume and
Mailpit. Its messages cannot be sent by the production UniSender account.
Test always uses its own `who_need_help_test` PostGIS container/volume. Local
development can use Mailpit; a public test deployment must use its own SMTP
password and a visibly test-specific sender identity.
Create the production configuration inside the production checkout after
loading only the production database/provider credentials into the process:
@ -102,8 +103,11 @@ cd /srv/who_need_help-production
PRODUCTION_DATABASE_MODE=external \
PRODUCTION_DATABASE_URL='ecto://PRODUCTION_ROLE:PASSWORD@localhost/who_need_help_production' \
PRODUCTION_DATABASE_SOCKET_DIR=/var/run/postgresql \
PRODUCTION_EMAIL_DELIVERY_PROVIDER=unisender_go \
PRODUCTION_UNISENDER_GO_API_KEY=YOUR_PRODUCTION_KEY \
PRODUCTION_EMAIL_DELIVERY_PROVIDER=smtp \
PRODUCTION_SMTP_RELAY=smtp.example.net \
PRODUCTION_SMTP_PORT=587 \
PRODUCTION_SMTP_USERNAME=YOUR_PRODUCTION_SMTP_LOGIN \
PRODUCTION_SMTP_PASSWORD=YOUR_PRODUCTION_SMTP_PASSWORD \
PRODUCTION_GOOGLE_OAUTH_CLIENT_ID=YOUR_PRODUCTION_CLIENT_ID \
PRODUCTION_GOOGLE_OAUTH_CLIENT_SECRET=YOUR_PRODUCTION_CLIENT_SECRET \
PRODUCTION_CODEX_SESSION_ID=YOUR_MAIN_CODEX_SESSION_ID \
@ -155,14 +159,10 @@ edit `HTTP_BIND_ADDRESS` afterward, to match the observed target topology.
Replace `TRAEFIK_TRUSTED_IPS` with the exact source IP/CIDR observed at Traefik;
do not copy the temporary VPN value into an unrelated server.
Configure the transactional email provider and a sender accepted by it. SMTP
uses `EMAIL_DELIVERY_PROVIDER=smtp` plus the `SMTP_*` settings. UniSender Go's
HTTPS Web API uses `EMAIL_DELIVERY_PROVIDER=unisender_go`,
`UNISENDER_GO_API_KEY`, and the default documented API base URL. The initializer
accepts the corresponding `PRODUCTION_EMAIL_DELIVERY_PROVIDER`,
`PRODUCTION_SMTP_*`, and `PRODUCTION_UNISENDER_GO_*` inputs. Optional
`EMAIL_HTTP_CONNECT_TIMEOUT_MS` and `EMAIL_HTTP_RECEIVE_TIMEOUT_MS` override the
Req/Finch defaults only when deployment measurements justify explicit values.
Configure the transactional SMTP relay and a sender accepted by it using
`EMAIL_DELIVERY_PROVIDER=smtp` plus the `SMTP_*` settings. The initializer
accepts the corresponding `PRODUCTION_EMAIL_DELIVERY_PROVIDER` and
`PRODUCTION_SMTP_*` inputs.
Set optional `SUPPORT_INBOX_ADDRESS` to
a monitored address for support/removal queue alerts and email `Reply-To`;
leaving it empty disables operator email alerts, not the protected queues. If
@ -405,21 +405,10 @@ semantics documented by
[GitHub OAuth](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps),
and [SMTP RFC 5321](https://datatracker.ietf.org/doc/html/rfc5321).
The optional `WhoNeedHelp.Email.UnisenderGoAdapter` maps the same Swoosh email
objects to UniSender Go's documented JSON contract, supplies a per-delivery
idempotence key, redacts rejected recipient addresses from returned errors, and
uses only HTTPS. The provider documents the endpoint, `X-API-KEY` header,
response shape, and one-minute/64-character idempotence-key behavior in its
[Web API reference](https://godocs.unisender.ru/web-api-ref).
Every request explicitly sets `track_links` and `track_read` to `0`; UniSender
Go otherwise enables both by default and may require account-level approval
before it honors those values.
`UNISENDER_GO_SKIP_UNSUBSCRIBE` defaults to `false` and must remain disabled
until UniSender Go support explicitly enables that transactional capability for
the account. After approval, set it to `true` to add `skip_unsubscribe=1` to the
provider request.
Verify a delivered message's raw HTML after the provider confirms the account
change and before launch.
The application uses Swoosh's SMTP adapter and `gen_smtp`. Verify the selected
relay's authenticated delivery, sender-domain authentication, and delivered
message headers before launch. Test and production may share an account-level
SMTP login only when they use independently revocable SMTP passwords.
## Isolated restore drill

View File

@ -1144,7 +1144,12 @@ These checks establish the mode wiring on this workstation. They do not prove
the future provider's TLS/CA policy, network reachability, backup service, high
availability, or target-server capacity.
## UniSender Go HTTPS delivery boundary on 2026-07-21
## Retired UniSender Go delivery boundary observed on 2026-07-21
This section is retained only as historical evidence. The adapter, runtime
mode, environment variables, Compose wiring, Helm wiring, and deployment
instructions were removed when both public environments migrated to SMTP.
None of the observations below describe the current delivery path.
- The production server returned HTTP 200 and a successful authenticated result
from UniSender Go's `system/ping` Web API method. The API key was loaded from

View File

@ -1,238 +0,0 @@
defmodule WhoNeedHelp.Email.UnisenderGoAdapter do
@moduledoc """
Swoosh adapter for the UniSender Go transactional Web API.
The adapter deliberately accepts the regular `Swoosh.Email` structure used
by the rest of the application. Delivery therefore remains provider-neutral
and can be switched back to SMTP through runtime configuration.
"""
@behaviour Swoosh.Adapter
alias Swoosh.Email
@default_base_url "https://goapi.unisender.ru/ru/transactional/api/v1"
@send_path "/email/send.json"
@max_recipients 500
@max_headers 50
@max_idempotence_key_length 64
@impl true
def validate_config(config) do
validate_api_key!(Keyword.get(config, :api_key))
validate_base_url!(Keyword.get(config, :base_url, @default_base_url))
validate_skip_unsubscribe!(Keyword.get(config, :skip_unsubscribe, false))
:ok
end
@impl true
def validate_dependency do
Swoosh.Adapter.validate_dependency([Req, Jason])
end
@impl true
def deliver(%Email{} = email, config) do
with :ok <- validate_supported_email(email),
{:ok, body} <- encode_payload(email, config),
{:ok, status, _headers, response_body} <-
Swoosh.ApiClient.post(
endpoint(config),
request_headers(Keyword.fetch!(config, :api_key)),
body,
with_client_options(email, config)
) do
decode_response(status, response_body)
end
end
defp validate_supported_email(%Email{to: []}), do: {:error, :to_not_set}
defp validate_supported_email(%Email{from: nil}), do: {:error, :from_not_set}
defp validate_supported_email(%Email{subject: subject}) when subject in [nil, ""],
do: {:error, :subject_not_set}
defp validate_supported_email(%Email{to: recipients})
when length(recipients) > @max_recipients,
do: {:error, {:too_many_recipients, @max_recipients}}
defp validate_supported_email(%Email{cc: [_ | _]}),
do: {:error, {:unsupported_email_feature, :cc}}
defp validate_supported_email(%Email{bcc: [_ | _]}),
do: {:error, {:unsupported_email_feature, :bcc}}
defp validate_supported_email(%Email{attachments: [_ | _]}),
do: {:error, {:unsupported_email_feature, :attachments}}
defp validate_supported_email(%Email{reply_to: reply_to}) when is_list(reply_to),
do: {:error, {:unsupported_email_feature, :multiple_reply_to}}
defp validate_supported_email(%Email{text_body: nil, html_body: nil}),
do: {:error, :body_not_set}
defp validate_supported_email(%Email{headers: headers}) when map_size(headers) > @max_headers,
do: {:error, {:too_many_headers, @max_headers}}
defp validate_supported_email(%Email{provider_options: %{idempotence_key: key}})
when not is_binary(key) or key == "",
do: {:error, :invalid_idempotence_key}
defp validate_supported_email(%Email{provider_options: %{idempotence_key: key}})
when byte_size(key) > @max_idempotence_key_length,
do: {:error, {:idempotence_key_too_long, @max_idempotence_key_length}}
defp validate_supported_email(%Email{}), do: :ok
defp encode_payload(%Email{} = email, config) do
payload =
%{
"message" =>
%{
"recipients" => Enum.map(email.to, &recipient/1),
"subject" => email.subject,
"from_email" => elem(email.from, 1),
"from_name" => elem(email.from, 0),
"body" => message_body(email),
"track_links" => 0,
"track_read" => 0,
"idempotence_key" => idempotence_key(email)
}
|> maybe_put("skip_unsubscribe", 1, Keyword.get(config, :skip_unsubscribe, false))
|> maybe_put_reply_to(email.reply_to)
|> maybe_put_headers(email.headers)
}
Jason.encode(payload)
end
defp recipient({_name, address}), do: %{"email" => address}
defp message_body(%Email{text_body: text_body, html_body: html_body}) do
%{}
|> maybe_put("plaintext", text_body)
|> maybe_put("html", html_body)
end
defp maybe_put_reply_to(message, nil), do: message
defp maybe_put_reply_to(message, {name, address}) do
message
|> Map.put("reply_to", address)
|> maybe_put("reply_to_name", name)
end
defp maybe_put_headers(message, headers) do
allowed_headers =
Map.new(headers, fn {name, value} -> {to_string(name), to_string(value)} end)
|> Map.filter(fn {name, _value} -> String.starts_with?(String.downcase(name), "x-") end)
maybe_put(message, "headers", allowed_headers, allowed_headers != %{})
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
defp maybe_put(map, key, value, true), do: Map.put(map, key, value)
defp maybe_put(map, _key, _value, false), do: map
defp idempotence_key(%Email{provider_options: %{idempotence_key: key}})
when is_binary(key) and key != "",
do: key
defp idempotence_key(_email) do
:crypto.strong_rand_bytes(24)
|> Base.url_encode64(padding: false)
end
defp request_headers(api_key) do
[
{"Accept", "application/json"},
{"Content-Type", "application/json"},
{"X-API-KEY", api_key}
]
end
defp endpoint(config) do
config
|> Keyword.get(:base_url, @default_base_url)
|> String.trim_trailing("/")
|> Kernel.<>(@send_path)
end
defp with_client_options(email, config) do
configured_options = Keyword.get(config, :client_options, [])
existing_options = Map.get(email.private, :client_options, [])
Swoosh.Email.put_private(
email,
:client_options,
Keyword.merge(configured_options, existing_options)
)
end
defp decode_response(200, response_body) do
case Jason.decode(response_body) do
{:ok, %{"status" => "success"} = response} -> success_response(response)
{:ok, response} -> {:error, api_error(200, response)}
{:error, _reason} -> {:error, {:invalid_unisender_go_response, 200}}
end
end
defp decode_response(status, response_body) do
case Jason.decode(response_body) do
{:ok, response} -> {:error, api_error(status, response)}
{:error, _reason} -> {:error, {:invalid_unisender_go_response, status}}
end
end
defp success_response(%{"failed_emails" => failed_emails})
when is_map(failed_emails) and map_size(failed_emails) > 0 do
reasons = failed_emails |> Map.values() |> Enum.frequencies()
{:error, {:unisender_go_recipient_rejected, reasons}}
end
defp success_response(response) do
{:ok,
%{
provider: :unisender_go,
job_id: Map.get(response, "job_id"),
accepted_count: response |> Map.get("emails", []) |> length()
}}
end
defp api_error(status, response) when is_map(response) do
{:unisender_go_api_error, status, Map.get(response, "code")}
end
defp api_error(status, _response), do: {:unisender_go_api_error, status, nil}
defp validate_api_key!(api_key) when is_binary(api_key) do
if String.trim(api_key) == "" do
raise ArgumentError, "UniSender Go API key is missing"
end
end
defp validate_api_key!(_api_key) do
raise ArgumentError, "UniSender Go API key is missing"
end
defp validate_base_url!(base_url) when is_binary(base_url) do
uri = URI.parse(base_url)
unless uri.scheme == "https" and is_binary(uri.host) and uri.host != "" and
is_binary(uri.path) and uri.path != "" and is_nil(uri.userinfo) and
is_nil(uri.query) and is_nil(uri.fragment) do
raise ArgumentError, "UniSender Go base URL must be an HTTPS URL without credentials"
end
end
defp validate_base_url!(_base_url) do
raise ArgumentError, "UniSender Go base URL must be an HTTPS URL without credentials"
end
defp validate_skip_unsubscribe!(value) when is_boolean(value), do: :ok
defp validate_skip_unsubscribe!(_value) do
raise ArgumentError, "UniSender Go skip_unsubscribe must be a boolean"
end
end

View File

@ -135,32 +135,13 @@ smtp_password=${PRODUCTION_SMTP_PASSWORD:-}
smtp_auth=${PRODUCTION_SMTP_AUTH:-always}
smtp_tls=${PRODUCTION_SMTP_TLS:-always}
smtp_ssl=${PRODUCTION_SMTP_SSL:-false}
unisender_go_api_key=${PRODUCTION_UNISENDER_GO_API_KEY:-}
unisender_go_base_url=${PRODUCTION_UNISENDER_GO_BASE_URL:-https://goapi.unisender.ru/ru/transactional/api/v1}
unisender_go_skip_unsubscribe=${PRODUCTION_UNISENDER_GO_SKIP_UNSUBSCRIBE:-false}
email_http_connect_timeout_ms=${PRODUCTION_EMAIL_HTTP_CONNECT_TIMEOUT_MS:-}
email_http_receive_timeout_ms=${PRODUCTION_EMAIL_HTTP_RECEIVE_TIMEOUT_MS:-}
email_from_address=${PRODUCTION_EMAIL_FROM_ADDRESS:-"contact@$domain"}
support_inbox_address=${PRODUCTION_SUPPORT_INBOX_ADDRESS:-}
case "$email_delivery_provider" in
smtp) ;;
unisender_go)
if [ -z "$unisender_go_api_key" ]; then
echo "PRODUCTION_UNISENDER_GO_API_KEY is required for the unisender_go email provider." >&2
exit 1
fi
;;
*)
echo "PRODUCTION_EMAIL_DELIVERY_PROVIDER must be smtp or unisender_go." >&2
exit 1
;;
esac
case "$unisender_go_skip_unsubscribe" in
true | false) ;;
*) echo "PRODUCTION_UNISENDER_GO_SKIP_UNSUBSCRIBE must be true or false." >&2; exit 1 ;;
esac
[ "$email_delivery_provider" = smtp ] || {
echo "PRODUCTION_EMAIL_DELIVERY_PROVIDER must be smtp." >&2
exit 1
}
tmp=$(mktemp "$target_dir/.production-env.XXXXXX")
trap 'rm -f "$tmp"' EXIT HUP INT TERM
@ -192,11 +173,6 @@ SMTP_PASSWORD_VALUE=$smtp_password \
SMTP_AUTH_VALUE=$smtp_auth \
SMTP_TLS_VALUE=$smtp_tls \
SMTP_SSL_VALUE=$smtp_ssl \
UNISENDER_GO_API_KEY_VALUE=$unisender_go_api_key \
UNISENDER_GO_BASE_URL_VALUE=$unisender_go_base_url \
UNISENDER_GO_SKIP_UNSUBSCRIBE_VALUE=$unisender_go_skip_unsubscribe \
EMAIL_HTTP_CONNECT_TIMEOUT_MS_VALUE=$email_http_connect_timeout_ms \
EMAIL_HTTP_RECEIVE_TIMEOUT_MS_VALUE=$email_http_receive_timeout_ms \
EMAIL_FROM_ADDRESS_VALUE=$email_from_address \
SUPPORT_INBOX_ADDRESS_VALUE=$support_inbox_address \
CODEX_SESSION_ID_VALUE=$codex_session_id \
@ -254,11 +230,6 @@ TEST_UPSTREAM_VALUE=$test_upstream \
replacement["SMTP_AUTH"] = ENVIRON["SMTP_AUTH_VALUE"]
replacement["SMTP_TLS"] = ENVIRON["SMTP_TLS_VALUE"]
replacement["SMTP_SSL"] = ENVIRON["SMTP_SSL_VALUE"]
replacement["UNISENDER_GO_API_KEY"] = ENVIRON["UNISENDER_GO_API_KEY_VALUE"]
replacement["UNISENDER_GO_BASE_URL"] = ENVIRON["UNISENDER_GO_BASE_URL_VALUE"]
replacement["UNISENDER_GO_SKIP_UNSUBSCRIBE"] = ENVIRON["UNISENDER_GO_SKIP_UNSUBSCRIBE_VALUE"]
replacement["EMAIL_HTTP_CONNECT_TIMEOUT_MS"] = ENVIRON["EMAIL_HTTP_CONNECT_TIMEOUT_MS_VALUE"]
replacement["EMAIL_HTTP_RECEIVE_TIMEOUT_MS"] = ENVIRON["EMAIL_HTTP_RECEIVE_TIMEOUT_MS_VALUE"]
replacement["EMAIL_FROM_ADDRESS"] = ENVIRON["EMAIL_FROM_ADDRESS_VALUE"]
replacement["SUPPORT_INBOX_ADDRESS"] = ENVIRON["SUPPORT_INBOX_ADDRESS_VALUE"]
replacement["GOOGLE_OAUTH_CLIENT_ID"] = ENVIRON["GOOGLE_OAUTH_CLIENT_ID_VALUE"]
@ -282,7 +253,7 @@ chmod 600 "$target"
trap - EXIT HUP INT TERM
unset postgres_password secret_key_base handover_secret release_cookie metrics_token
unset smtp_password unisender_go_api_key
unset smtp_password
unset google_oauth_client_secret
echo "Generated independent deployment secrets without printing them."

View File

@ -178,67 +178,30 @@ cp "$production_env" "$production_checkout/.env"
chmod 600 "$test_checkout/.env" "$production_checkout/.env"
./scripts/validate-deployment-isolation.sh \
"$test_checkout" "$production_checkout" >/dev/null
api_production_env="$scan_dir/.env.production.unisender-go"
PRODUCTION_EMAIL_DELIVERY_PROVIDER=unisender_go \
PRODUCTION_UNISENDER_GO_API_KEY=quality-unisender-go-api-key \
PRODUCTION_EMAIL_FROM_ADDRESS=contact@help.test \
PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \
./scripts/init-production-env.sh help.test "$api_production_env" >/dev/null
./scripts/validate-production-env.sh "$api_production_env" help.test >/dev/null
grep -Fx 'EMAIL_DELIVERY_PROVIDER=unisender_go' "$api_production_env" >/dev/null
grep -Fx 'UNISENDER_GO_SKIP_UNSUBSCRIBE=false' "$api_production_env" >/dev/null
api_test_env="$scan_dir/.env.test.unisender-go"
cp "$test_env" "$api_test_env"
chmod 600 "$api_test_env"
# Account-level SMTP logins may be shared by a relay, but the independently
# revocable SMTP passwords must remain isolated between deployments.
production_smtp_relay=$(awk -F= '$1 == "SMTP_RELAY" { print substr($0, index($0, "=") + 1); exit }' "$production_checkout/.env")
production_smtp_username=$(awk -F= '$1 == "SMTP_USERNAME" { print substr($0, index($0, "=") + 1); exit }' "$production_checkout/.env")
production_smtp_password=$(awk -F= '$1 == "SMTP_PASSWORD" { print substr($0, index($0, "=") + 1); exit }' "$production_checkout/.env")
sed -i \
-e 's/^EMAIL_DELIVERY_PROVIDER=.*/EMAIL_DELIVERY_PROVIDER=unisender_go/' \
-e 's/^UNISENDER_GO_API_KEY=.*/UNISENDER_GO_API_KEY=quality-test-unisender-go-api-key/' \
"$api_test_env"
./scripts/validate-test-env.sh "$api_test_env" test.help.test >/dev/null
if ./scripts/compose.sh "$api_test_env" config --services | grep -Fx mailpit >/dev/null; then
echo "External test email configuration unexpectedly starts Mailpit." >&2
exit 1
fi
cp "$api_test_env" "$test_checkout/.env"
cp "$api_production_env" "$production_checkout/.env"
chmod 600 "$test_checkout/.env" "$production_checkout/.env"
-e 's/^EMAIL_DELIVERY_PROVIDER=.*/EMAIL_DELIVERY_PROVIDER=smtp/' \
-e "s|^SMTP_RELAY=.*|SMTP_RELAY=$production_smtp_relay|" \
-e "s|^SMTP_USERNAME=.*|SMTP_USERNAME=$production_smtp_username|" \
-e 's/^SMTP_PASSWORD=.*/SMTP_PASSWORD=quality-test-isolated-smtp-password/' \
"$test_checkout/.env"
./scripts/validate-deployment-isolation.sh \
"$test_checkout" "$production_checkout" >/dev/null
sed -i \
's/^UNISENDER_GO_API_KEY=.*/UNISENDER_GO_API_KEY=quality-unisender-go-api-key/' \
"s|^SMTP_PASSWORD=.*|SMTP_PASSWORD=$production_smtp_password|" \
"$test_checkout/.env"
if ./scripts/validate-deployment-isolation.sh \
"$test_checkout" "$production_checkout" >/dev/null 2>&1; then
echo "Deployment isolation accepted a shared UniSender Go API key." >&2
exit 1
fi
api_override_env="$scan_dir/.env.production.unisender-go-skip-unsubscribe"
PRODUCTION_EMAIL_DELIVERY_PROVIDER=unisender_go \
PRODUCTION_UNISENDER_GO_API_KEY=quality-unisender-go-api-key \
PRODUCTION_UNISENDER_GO_SKIP_UNSUBSCRIBE=true \
PRODUCTION_EMAIL_FROM_ADDRESS=contact@help.test \
PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \
./scripts/init-production-env.sh help.test "$api_override_env" >/dev/null
./scripts/validate-production-env.sh "$api_override_env" help.test >/dev/null
grep -Fx 'UNISENDER_GO_SKIP_UNSUBSCRIBE=true' "$api_override_env" >/dev/null
if PRODUCTION_EMAIL_DELIVERY_PROVIDER=unisender_go \
PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \
./scripts/init-production-env.sh \
help.test "$scan_dir/.env.production.missing-api-key" >/dev/null 2>&1; then
echo "Production initializer accepted UniSender Go without an API key." >&2
exit 1
fi
invalid_api_url_env="$scan_dir/.env.production.invalid-api-url"
cp "$api_production_env" "$invalid_api_url_env"
chmod 600 "$invalid_api_url_env"
sed -i \
's#^UNISENDER_GO_BASE_URL=.*#UNISENDER_GO_BASE_URL=http://goapi.example.test/v1#' \
"$invalid_api_url_env"
if ./scripts/validate-production-env.sh \
"$invalid_api_url_env" help.test >/dev/null 2>&1; then
echo "Production validator accepted a non-HTTPS UniSender Go API URL." >&2
echo "Deployment isolation accepted a shared SMTP password." >&2
exit 1
fi
cp "$test_env" "$test_checkout/.env"
chmod 600 "$test_checkout/.env"
placeholder_codex_env="$scan_dir/.env.production.placeholder-codex"
cp "$production_env" "$placeholder_codex_env"
chmod 600 "$placeholder_codex_env"
@ -367,12 +330,6 @@ docker compose --project-name who_need_help_edge \
' >/dev/null
./scripts/compose.sh .env.example config --quiet
./scripts/compose.sh "$production_env" config --quiet
./scripts/compose.sh "$api_production_env" config --quiet
./scripts/compose.sh "$api_override_env" config --format json |
jq --exit-status '
.services.app.environment.UNISENDER_GO_SKIP_UNSUBSCRIBE == "true" and
.services.migrate.environment.UNISENDER_GO_SKIP_UNSUBSCRIBE == "true"
' >/dev/null
./scripts/compose.sh "$external_production_env" config --quiet
./scripts/compose.sh "$external_socket_production_env" config --quiet
./scripts/compose.sh "$external_split_production_env" config --quiet
@ -611,29 +568,12 @@ tar --extract --file="$scan_tar" --directory "$scan_dir"
--values "$ROOT/deploy/helm/who-need-help/values-kind.yaml" \
"$ROOT/deploy/helm/who-need-help" \
>"$scan_dir/rendered-helm.yaml"
"$ROOT/.tools/bin/helm" template who-need-help \
--values "$ROOT/deploy/helm/who-need-help/values-kind.yaml" \
--set app.unisenderGoSkipUnsubscribe=true \
"$ROOT/deploy/helm/who-need-help" \
>"$scan_dir/rendered-helm-unisender-override.yaml"
test "$(
grep -c 'name: ERL_ZFLAGS' "$scan_dir/rendered-helm.yaml"
)" -eq 5
test "$(
grep -c 'value: "+Q 65536"' "$scan_dir/rendered-helm.yaml"
)" -eq 5
test "$(
grep -c 'name: UNISENDER_GO_SKIP_UNSUBSCRIBE' "$scan_dir/rendered-helm.yaml"
)" -eq 5
test "$(
grep -c 'name: UNISENDER_GO_SKIP_UNSUBSCRIBE' \
"$scan_dir/rendered-helm-unisender-override.yaml"
)" -eq 5
test "$(
grep -A1 'name: UNISENDER_GO_SKIP_UNSUBSCRIBE' \
"$scan_dir/rendered-helm-unisender-override.yaml" |
grep -c 'value: "true"'
)" -eq 5
test "$(
grep -c '^kind: NetworkPolicy$' "$scan_dir/rendered-helm.yaml"
)" -eq 1

View File

@ -97,16 +97,12 @@ fi
test_email_provider=$(read_env "$test_env" EMAIL_DELIVERY_PROVIDER)
production_email_provider=$(read_env "$production_env" EMAIL_DELIVERY_PROVIDER)
if [[ "$test_email_provider" == unisender_go &&
"$production_email_provider" == unisender_go ]]; then
require_different UNISENDER_GO_API_KEY
fi
if [[ "$test_email_provider" == smtp &&
"$(read_env "$test_env" SMTP_RELAY)" != mailpit &&
"$production_email_provider" == smtp &&
"$(read_env "$test_env" SMTP_RELAY)" == "$(read_env "$production_env" SMTP_RELAY)" ]]; then
require_different SMTP_USERNAME
# Some relays (including Brevo) issue one account-level SMTP login and
# isolate applications with independently revocable SMTP passwords.
require_different SMTP_PASSWORD
fi
[[ "$(read_env "$production_env" EMAIL_DELIVERY_PROVIDER)" != smtp ||

View File

@ -106,11 +106,6 @@ smtp_password=$(optional_value SMTP_PASSWORD)
smtp_auth=$(optional_value SMTP_AUTH)
smtp_tls=$(optional_value SMTP_TLS)
smtp_ssl=$(optional_value SMTP_SSL)
unisender_go_api_key=$(optional_value UNISENDER_GO_API_KEY)
unisender_go_base_url=$(optional_value UNISENDER_GO_BASE_URL)
unisender_go_skip_unsubscribe=$(optional_value UNISENDER_GO_SKIP_UNSUBSCRIBE)
email_http_connect_timeout_ms=$(optional_value EMAIL_HTTP_CONNECT_TIMEOUT_MS)
email_http_receive_timeout_ms=$(optional_value EMAIL_HTTP_RECEIVE_TIMEOUT_MS)
email_from_address=$(require_value EMAIL_FROM_ADDRESS)
support_inbox_address=$(optional_value SUPPORT_INBOX_ADDRESS)
google_oauth_client_id=$(optional_value GOOGLE_OAUTH_CLIENT_ID)
@ -296,38 +291,11 @@ case "$email_delivery_provider" in
exit 1
fi
;;
unisender_go)
[[ -n "$unisender_go_api_key" ]] || {
echo "UNISENDER_GO_API_KEY is required when EMAIL_DELIVERY_PROVIDER=unisender_go." >&2
exit 1
}
reject_marker UNISENDER_GO_API_KEY "$unisender_go_api_key"
[[ "$unisender_go_base_url" =~ ^https://[^/@?#[:space:]]+(/[^?#[:space:]]*)?$ ]] || {
echo "UNISENDER_GO_BASE_URL must be an HTTPS origin and path without credentials, query, or fragment." >&2
exit 1
}
reject_marker UNISENDER_GO_BASE_URL "$unisender_go_base_url"
[[ "$unisender_go_skip_unsubscribe" =~ ^(true|false)$ ]] || {
echo "UNISENDER_GO_SKIP_UNSUBSCRIBE must be true or false." >&2
exit 1
}
;;
*)
echo "EMAIL_DELIVERY_PROVIDER must be smtp or unisender_go." >&2
echo "EMAIL_DELIVERY_PROVIDER must be smtp." >&2
exit 1
;;
esac
for timeout_pair in \
"EMAIL_HTTP_CONNECT_TIMEOUT_MS:$email_http_connect_timeout_ms" \
"EMAIL_HTTP_RECEIVE_TIMEOUT_MS:$email_http_receive_timeout_ms"
do
timeout_value=${timeout_pair#*:}
if [[ -n "$timeout_value" && ! "$timeout_value" =~ ^[1-9][0-9]*$ ]]; then
echo "${timeout_pair%%:*} must be a positive integer when configured." >&2
exit 1
fi
done
[[ "$email_from_address" == *@* ]] || {
echo "EMAIL_FROM_ADDRESS is not an email address." >&2
exit 1

View File

@ -99,17 +99,8 @@ case "$email_delivery_provider" in
fi
;;
unisender_go)
require_value UNISENDER_GO_API_KEY >/dev/null
unisender_base_url=$(require_value UNISENDER_GO_BASE_URL)
[[ "$unisender_base_url" == https://* ]] || {
echo "UNISENDER_GO_BASE_URL must use HTTPS." >&2
exit 1
}
;;
*)
echo "EMAIL_DELIVERY_PROVIDER must be smtp or unisender_go." >&2
echo "EMAIL_DELIVERY_PROVIDER must be smtp." >&2
exit 1
;;
esac

View File

@ -1,221 +0,0 @@
defmodule WhoNeedHelp.Email.UnisenderGoAdapterTest do
use ExUnit.Case, async: false
import Swoosh.Email
alias WhoNeedHelp.Email.UnisenderGoAdapter
setup do
previous_client = Application.get_env(:swoosh, :api_client)
Application.put_env(:swoosh, :api_client, Swoosh.ApiClient.Req)
on_exit(fn ->
if previous_client do
Application.put_env(:swoosh, :api_client, previous_client)
else
Application.delete_env(:swoosh, :api_client)
end
end)
:ok
end
test "delivers a Swoosh email through the documented HTTPS JSON contract" do
owner = self()
plug = fn conn ->
{:ok, request_body, conn} = Plug.Conn.read_body(conn)
send(owner, {:request, conn, Jason.decode!(request_body)})
response =
Jason.encode!(%{
"status" => "success",
"job_id" => "job-123",
"emails" => ["helper@example.com"]
})
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, response)
end
email =
new()
|> from({"Who Need Help", "contact@whoneedhelp.com"})
|> to("helper@example.com")
|> reply_to({"Support", "support@whoneedhelp.com"})
|> subject("Confirmation instructions")
|> text_body("Open the confirmation link")
|> html_body("<p>Open the confirmation link</p>")
|> header("X-WNH-Message-Type", "account-confirmation")
|> header("List-Unsubscribe", "must-not-be-forwarded")
|> put_provider_option(:idempotence_key, "stable-delivery-key")
|> put_private(:client_options, plug: plug, retry: false)
assert {:ok, %{provider: :unisender_go, job_id: "job-123", accepted_count: 1}} =
UnisenderGoAdapter.deliver(email,
api_key: "secret-api-key",
base_url: "https://goapi.example.test/v1/"
)
assert_receive {:request, conn, payload}
assert conn.method == "POST"
assert conn.request_path == "/v1/email/send.json"
assert Plug.Conn.get_req_header(conn, "x-api-key") == ["secret-api-key"]
assert payload == %{
"message" => %{
"body" => %{
"html" => "<p>Open the confirmation link</p>",
"plaintext" => "Open the confirmation link"
},
"from_email" => "contact@whoneedhelp.com",
"from_name" => "Who Need Help",
"headers" => %{"X-WNH-Message-Type" => "account-confirmation"},
"idempotence_key" => "stable-delivery-key",
"recipients" => [%{"email" => "helper@example.com"}],
"reply_to" => "support@whoneedhelp.com",
"reply_to_name" => "Support",
"subject" => "Confirmation instructions",
"track_links" => 0,
"track_read" => 0
}
}
end
test "returns a redacted recipient rejection without exposing addresses" do
plug = fn conn ->
response =
Jason.encode!(%{
"status" => "success",
"job_id" => "job-123",
"failed_emails" => %{"private@example.com" => "unsubscribed"}
})
Plug.Conn.send_resp(conn, 200, response)
end
assert {:error, {:unisender_go_recipient_rejected, %{"unsubscribed" => 1}}} =
UnisenderGoAdapter.deliver(email(plug), api_key: "secret-api-key")
end
test "adds skip_unsubscribe only after the provider capability is explicitly enabled" do
owner = self()
plug = fn conn ->
{:ok, request_body, conn} = Plug.Conn.read_body(conn)
send(owner, {:request, Jason.decode!(request_body)})
Plug.Conn.send_resp(
conn,
200,
Jason.encode!(%{
"status" => "success",
"job_id" => "job-123",
"emails" => ["helper@example.com"]
})
)
end
assert {:ok, _response} =
UnisenderGoAdapter.deliver(email(plug),
api_key: "secret-api-key",
skip_unsubscribe: true
)
assert_receive {:request, %{"message" => %{"skip_unsubscribe" => 1}}}
end
test "returns structured API and invalid-response failures" do
error_plug = fn conn ->
Plug.Conn.send_resp(
conn,
401,
Jason.encode!(%{"status" => "error", "code" => 101, "message" => "invalid API key"})
)
end
assert {:error, {:unisender_go_api_error, 401, 101}} =
UnisenderGoAdapter.deliver(email(error_plug), api_key: "secret-api-key")
invalid_plug = fn conn -> Plug.Conn.send_resp(conn, 502, "not-json") end
assert {:error, {:invalid_unisender_go_response, 502}} =
UnisenderGoAdapter.deliver(email(invalid_plug), api_key: "secret-api-key")
end
test "rejects unsupported fields before making a request" do
email =
new()
|> from("contact@whoneedhelp.com")
|> to("helper@example.com")
|> cc("operator@example.com")
|> subject("Test")
|> text_body("body")
assert {:error, {:unsupported_email_feature, :cc}} =
UnisenderGoAdapter.deliver(email, api_key: "secret-api-key")
end
test "rejects malformed or provider-incompatible messages before making a request" do
assert {:error, :from_not_set} =
new()
|> to("helper@example.com")
|> subject("Test")
|> text_body("Body")
|> UnisenderGoAdapter.deliver(api_key: "secret-api-key")
assert {:error, :subject_not_set} =
new()
|> from("contact@whoneedhelp.com")
|> to("helper@example.com")
|> text_body("Body")
|> UnisenderGoAdapter.deliver(api_key: "secret-api-key")
too_many_recipients = List.duplicate("helper@example.com", 501)
assert {:error, {:too_many_recipients, 500}} =
new()
|> from("contact@whoneedhelp.com")
|> to(too_many_recipients)
|> subject("Test")
|> text_body("Body")
|> UnisenderGoAdapter.deliver(api_key: "secret-api-key")
assert {:error, {:idempotence_key_too_long, 64}} =
email(fn conn -> Plug.Conn.send_resp(conn, 500, "not reached") end)
|> put_provider_option(:idempotence_key, String.duplicate("x", 65))
|> UnisenderGoAdapter.deliver(api_key: "secret-api-key")
end
test "validates the API key without including the adapter config in the exception" do
assert_raise ArgumentError, "UniSender Go API key is missing", fn ->
UnisenderGoAdapter.validate_config(api_key: "")
end
assert_raise ArgumentError,
"UniSender Go base URL must be an HTTPS URL without credentials",
fn ->
UnisenderGoAdapter.validate_config(
api_key: "secret-api-key",
base_url: "http://secret@example.test/api?key=secret"
)
end
assert_raise ArgumentError, "UniSender Go skip_unsubscribe must be a boolean", fn ->
UnisenderGoAdapter.validate_config(
api_key: "secret-api-key",
skip_unsubscribe: "true"
)
end
end
defp email(plug) do
new()
|> from({"Who Need Help", "contact@whoneedhelp.com"})
|> to("helper@example.com")
|> subject("Test")
|> text_body("Body")
|> put_private(:client_options, plug: plug, retry: false)
end
end