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 RELEASE_COOKIE=generate-an-independent-beam-cluster-cookie
METRICS_TOKEN=generate-an-independent-random-bearer-token METRICS_TOKEN=generate-an-independent-random-bearer-token
# Select `smtp` for Mailpit or a regular transactional SMTP relay. Select # Use Mailpit locally or a transactional SMTP relay in public environments.
# `unisender_go` to use UniSender Go's HTTPS transactional Web API instead.
EMAIL_DELIVERY_PROVIDER=smtp EMAIL_DELIVERY_PROVIDER=smtp
SMTP_RELAY=mailpit SMTP_RELAY=mailpit
SMTP_PORT=1025 SMTP_PORT=1025
@ -155,13 +154,6 @@ SMTP_PASSWORD=
SMTP_AUTH=never SMTP_AUTH=never
SMTP_TLS=never SMTP_TLS=never
SMTP_SSL=false 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_NAME="Who Need Help"
EMAIL_FROM_ADDRESS=contact@example.com EMAIL_FROM_ADDRESS=contact@example.com
# Optional monitored inbox. It receives new-case alerts and is used as Reply-To. # 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 Configure the verified reverse-proxy source IP/CIDR and transactional email
provider in that file. `EMAIL_DELIVERY_PROVIDER=smtp` uses the standard Swoosh provider in that file. `EMAIL_DELIVERY_PROVIDER=smtp` uses the standard Swoosh
SMTP adapter. `EMAIL_DELIVERY_PROVIDER=unisender_go` uses UniSender Go's HTTPS SMTP adapter and requires the relay's `SMTP_*` credentials. Email registration
Web API and requires `UNISENDER_GO_API_KEY`; this mode does not need outbound and magic-link login are unusable for real
SMTP ports. Email registration and magic-link login are unusable for real
recipients until the selected provider and its accepted sender are configured. Set recipients until the selected provider and its accepted sender are configured. Set
the optional `SUPPORT_INBOX_ADDRESS` to a monitored mailbox to receive 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 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`, credentials from tracked values. The Secret must contain `DATABASE_URL`,
`SECRET_KEY_BASE`, `HANDOVER_SECRET`, `RELEASE_COOKIE`, and `METRICS_TOKEN`, and `SECRET_KEY_BASE`, `HANDOVER_SECRET`, `RELEASE_COOKIE`, and `METRICS_TOKEN`, and
may additionally contain the OAuth credentials described above. Set may additionally contain the OAuth credentials described above. Set
`app.emailDeliveryProvider`; UniSender Go API mode also requires `app.emailDeliveryProvider` to `smtp`; keep SMTP credentials in that Secret.
`UNISENDER_GO_API_KEY` in that Secret, while SMTP credentials remain Secret The chart intentionally has no invented CPU/RAM limits or HPA
values in SMTP mode. The chart intentionally has no invented CPU/RAM limits or HPA
thresholds; measure this application in the target environment before setting thresholds; measure this application in the target environment before setting
them. them.

View File

@ -24,11 +24,6 @@ x-app-environment: &app-environment
SMTP_AUTH: ${SMTP_AUTH:-never} SMTP_AUTH: ${SMTP_AUTH:-never}
SMTP_TLS: ${SMTP_TLS:-never} SMTP_TLS: ${SMTP_TLS:-never}
SMTP_SSL: ${SMTP_SSL:-false} 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_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:-}

View File

@ -482,67 +482,8 @@ if config_env() == :prod do
end end
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 -> other ->
raise "UNISENDER_GO_SKIP_UNSUBSCRIBE must be true or false; got #{inspect(other)}" raise "EMAIL_DELIVERY_PROVIDER must be smtp; 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)}"
end end
config :who_need_help, :handover_secret, handover_secret config :who_need_help, :handover_secret, handover_secret

View File

@ -53,10 +53,6 @@ spec:
value: {{ $root.Values.app.clusterInterface | quote }} value: {{ $root.Values.app.clusterInterface | quote }}
- name: EMAIL_DELIVERY_PROVIDER - name: EMAIL_DELIVERY_PROVIDER
value: {{ $root.Values.app.emailDeliveryProvider | quote }} 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: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
readOnlyRootFilesystem: true readOnlyRootFilesystem: true
@ -101,10 +97,6 @@ spec:
value: {{ $root.Values.app.smtpRelay | quote }} value: {{ $root.Values.app.smtpRelay | quote }}
- name: SMTP_PORT - name: SMTP_PORT
value: {{ $root.Values.app.smtpPort | quote }} 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 - name: CODEX_SESSION_ID
value: {{ $root.Values.app.codexSessionId | quote }} value: {{ $root.Values.app.codexSessionId | quote }}
- name: RATE_LIMIT_POLICIES_JSON - name: RATE_LIMIT_POLICIES_JSON

View File

@ -45,10 +45,6 @@ spec:
value: {{ .Values.worker.pushConcurrency | quote }} value: {{ .Values.worker.pushConcurrency | quote }}
- name: EMAIL_DELIVERY_PROVIDER - name: EMAIL_DELIVERY_PROVIDER
value: {{ .Values.app.emailDeliveryProvider | quote }} 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: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
readOnlyRootFilesystem: true readOnlyRootFilesystem: true

View File

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

View File

@ -36,8 +36,6 @@ app:
emailDeliveryProvider: smtp emailDeliveryProvider: smtp
smtpRelay: mailpit smtpRelay: mailpit
smtpPort: "1025" smtpPort: "1025"
unisenderGoBaseUrl: https://goapi.unisender.ru/ru/transactional/api/v1
unisenderGoSkipUnsubscribe: false
# Required. The Secret must contain DATABASE_URL, SECRET_KEY_BASE, # Required. The Secret must contain DATABASE_URL, SECRET_KEY_BASE,
# HANDOVER_SECRET, RELEASE_COOKIE, and METRICS_TOKEN. It may also contain both # 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 # 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.
# EMAIL_DELIVERY_PROVIDER=unisender_go additionally requires # SMTP provider credentials can be kept in this Secret.
# UNISENDER_GO_API_KEY. SMTP mode can keep provider credentials in this Secret.
# Push is # Push is
# opt-in: provide PUSH_HTTP_ENDPOINT, # opt-in: provide PUSH_HTTP_ENDPOINT,
# PUSH_HTTP_BEARER_TOKEN, PUSH_HTTP_MAX_ATTEMPTS, # 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 ./scripts/deploy-up.sh .env
``` ```
Test always uses its own `who_need_help_test` PostGIS container/volume and Test always uses its own `who_need_help_test` PostGIS container/volume. Local
Mailpit. Its messages cannot be sent by the production UniSender account. 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 Create the production configuration inside the production checkout after
loading only the production database/provider credentials into the process: 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_MODE=external \
PRODUCTION_DATABASE_URL='ecto://PRODUCTION_ROLE:PASSWORD@localhost/who_need_help_production' \ PRODUCTION_DATABASE_URL='ecto://PRODUCTION_ROLE:PASSWORD@localhost/who_need_help_production' \
PRODUCTION_DATABASE_SOCKET_DIR=/var/run/postgresql \ PRODUCTION_DATABASE_SOCKET_DIR=/var/run/postgresql \
PRODUCTION_EMAIL_DELIVERY_PROVIDER=unisender_go \ PRODUCTION_EMAIL_DELIVERY_PROVIDER=smtp \
PRODUCTION_UNISENDER_GO_API_KEY=YOUR_PRODUCTION_KEY \ 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_ID=YOUR_PRODUCTION_CLIENT_ID \
PRODUCTION_GOOGLE_OAUTH_CLIENT_SECRET=YOUR_PRODUCTION_CLIENT_SECRET \ PRODUCTION_GOOGLE_OAUTH_CLIENT_SECRET=YOUR_PRODUCTION_CLIENT_SECRET \
PRODUCTION_CODEX_SESSION_ID=YOUR_MAIN_CODEX_SESSION_ID \ 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; Replace `TRAEFIK_TRUSTED_IPS` with the exact source IP/CIDR observed at Traefik;
do not copy the temporary VPN value into an unrelated server. do not copy the temporary VPN value into an unrelated server.
Configure the transactional email provider and a sender accepted by it. SMTP Configure the transactional SMTP relay and a sender accepted by it using
uses `EMAIL_DELIVERY_PROVIDER=smtp` plus the `SMTP_*` settings. UniSender Go's `EMAIL_DELIVERY_PROVIDER=smtp` plus the `SMTP_*` settings. The initializer
HTTPS Web API uses `EMAIL_DELIVERY_PROVIDER=unisender_go`, accepts the corresponding `PRODUCTION_EMAIL_DELIVERY_PROVIDER` and
`UNISENDER_GO_API_KEY`, and the default documented API base URL. The initializer `PRODUCTION_SMTP_*` inputs.
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.
Set optional `SUPPORT_INBOX_ADDRESS` to Set optional `SUPPORT_INBOX_ADDRESS` to
a monitored address for support/removal queue alerts and email `Reply-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 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), [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). and [SMTP RFC 5321](https://datatracker.ietf.org/doc/html/rfc5321).
The optional `WhoNeedHelp.Email.UnisenderGoAdapter` maps the same Swoosh email The application uses Swoosh's SMTP adapter and `gen_smtp`. Verify the selected
objects to UniSender Go's documented JSON contract, supplies a per-delivery relay's authenticated delivery, sender-domain authentication, and delivered
idempotence key, redacts rejected recipient addresses from returned errors, and message headers before launch. Test and production may share an account-level
uses only HTTPS. The provider documents the endpoint, `X-API-KEY` header, SMTP login only when they use independently revocable SMTP passwords.
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.
## Isolated restore drill ## 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 the future provider's TLS/CA policy, network reachability, backup service, high
availability, or target-server capacity. 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 - 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 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_auth=${PRODUCTION_SMTP_AUTH:-always}
smtp_tls=${PRODUCTION_SMTP_TLS:-always} smtp_tls=${PRODUCTION_SMTP_TLS:-always}
smtp_ssl=${PRODUCTION_SMTP_SSL:-false} 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"} email_from_address=${PRODUCTION_EMAIL_FROM_ADDRESS:-"contact@$domain"}
support_inbox_address=${PRODUCTION_SUPPORT_INBOX_ADDRESS:-} support_inbox_address=${PRODUCTION_SUPPORT_INBOX_ADDRESS:-}
case "$email_delivery_provider" in [ "$email_delivery_provider" = smtp ] || {
smtp) ;; echo "PRODUCTION_EMAIL_DELIVERY_PROVIDER must be smtp." >&2
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 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
tmp=$(mktemp "$target_dir/.production-env.XXXXXX") tmp=$(mktemp "$target_dir/.production-env.XXXXXX")
trap 'rm -f "$tmp"' EXIT HUP INT TERM trap 'rm -f "$tmp"' EXIT HUP INT TERM
@ -192,11 +173,6 @@ SMTP_PASSWORD_VALUE=$smtp_password \
SMTP_AUTH_VALUE=$smtp_auth \ SMTP_AUTH_VALUE=$smtp_auth \
SMTP_TLS_VALUE=$smtp_tls \ SMTP_TLS_VALUE=$smtp_tls \
SMTP_SSL_VALUE=$smtp_ssl \ 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 \ EMAIL_FROM_ADDRESS_VALUE=$email_from_address \
SUPPORT_INBOX_ADDRESS_VALUE=$support_inbox_address \ SUPPORT_INBOX_ADDRESS_VALUE=$support_inbox_address \
CODEX_SESSION_ID_VALUE=$codex_session_id \ 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_AUTH"] = ENVIRON["SMTP_AUTH_VALUE"]
replacement["SMTP_TLS"] = ENVIRON["SMTP_TLS_VALUE"] replacement["SMTP_TLS"] = ENVIRON["SMTP_TLS_VALUE"]
replacement["SMTP_SSL"] = ENVIRON["SMTP_SSL_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["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["GOOGLE_OAUTH_CLIENT_ID"] = ENVIRON["GOOGLE_OAUTH_CLIENT_ID_VALUE"] replacement["GOOGLE_OAUTH_CLIENT_ID"] = ENVIRON["GOOGLE_OAUTH_CLIENT_ID_VALUE"]
@ -282,7 +253,7 @@ chmod 600 "$target"
trap - EXIT HUP INT TERM trap - EXIT HUP INT TERM
unset postgres_password secret_key_base handover_secret release_cookie metrics_token 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 unset google_oauth_client_secret
echo "Generated independent deployment secrets without printing them." 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" chmod 600 "$test_checkout/.env" "$production_checkout/.env"
./scripts/validate-deployment-isolation.sh \ ./scripts/validate-deployment-isolation.sh \
"$test_checkout" "$production_checkout" >/dev/null "$test_checkout" "$production_checkout" >/dev/null
api_production_env="$scan_dir/.env.production.unisender-go"
PRODUCTION_EMAIL_DELIVERY_PROVIDER=unisender_go \ # Account-level SMTP logins may be shared by a relay, but the independently
PRODUCTION_UNISENDER_GO_API_KEY=quality-unisender-go-api-key \ # revocable SMTP passwords must remain isolated between deployments.
PRODUCTION_EMAIL_FROM_ADDRESS=contact@help.test \ production_smtp_relay=$(awk -F= '$1 == "SMTP_RELAY" { print substr($0, index($0, "=") + 1); exit }' "$production_checkout/.env")
PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ production_smtp_username=$(awk -F= '$1 == "SMTP_USERNAME" { print substr($0, index($0, "=") + 1); exit }' "$production_checkout/.env")
./scripts/init-production-env.sh help.test "$api_production_env" >/dev/null production_smtp_password=$(awk -F= '$1 == "SMTP_PASSWORD" { print substr($0, index($0, "=") + 1); exit }' "$production_checkout/.env")
./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"
sed -i \ sed -i \
-e 's/^EMAIL_DELIVERY_PROVIDER=.*/EMAIL_DELIVERY_PROVIDER=unisender_go/' \ -e 's/^EMAIL_DELIVERY_PROVIDER=.*/EMAIL_DELIVERY_PROVIDER=smtp/' \
-e 's/^UNISENDER_GO_API_KEY=.*/UNISENDER_GO_API_KEY=quality-test-unisender-go-api-key/' \ -e "s|^SMTP_RELAY=.*|SMTP_RELAY=$production_smtp_relay|" \
"$api_test_env" -e "s|^SMTP_USERNAME=.*|SMTP_USERNAME=$production_smtp_username|" \
./scripts/validate-test-env.sh "$api_test_env" test.help.test >/dev/null -e 's/^SMTP_PASSWORD=.*/SMTP_PASSWORD=quality-test-isolated-smtp-password/' \
if ./scripts/compose.sh "$api_test_env" config --services | grep -Fx mailpit >/dev/null; then "$test_checkout/.env"
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"
./scripts/validate-deployment-isolation.sh \ ./scripts/validate-deployment-isolation.sh \
"$test_checkout" "$production_checkout" >/dev/null "$test_checkout" "$production_checkout" >/dev/null
sed -i \ 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" "$test_checkout/.env"
if ./scripts/validate-deployment-isolation.sh \ if ./scripts/validate-deployment-isolation.sh \
"$test_checkout" "$production_checkout" >/dev/null 2>&1; then "$test_checkout" "$production_checkout" >/dev/null 2>&1; then
echo "Deployment isolation accepted a shared UniSender Go API key." >&2 echo "Deployment isolation accepted a shared SMTP password." >&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
exit 1 exit 1
fi fi
cp "$test_env" "$test_checkout/.env"
chmod 600 "$test_checkout/.env"
placeholder_codex_env="$scan_dir/.env.production.placeholder-codex" placeholder_codex_env="$scan_dir/.env.production.placeholder-codex"
cp "$production_env" "$placeholder_codex_env" cp "$production_env" "$placeholder_codex_env"
chmod 600 "$placeholder_codex_env" chmod 600 "$placeholder_codex_env"
@ -367,12 +330,6 @@ docker compose --project-name who_need_help_edge \
' >/dev/null ' >/dev/null
./scripts/compose.sh .env.example config --quiet ./scripts/compose.sh .env.example config --quiet
./scripts/compose.sh "$production_env" 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_production_env" config --quiet
./scripts/compose.sh "$external_socket_production_env" config --quiet ./scripts/compose.sh "$external_socket_production_env" config --quiet
./scripts/compose.sh "$external_split_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" \ --values "$ROOT/deploy/helm/who-need-help/values-kind.yaml" \
"$ROOT/deploy/helm/who-need-help" \ "$ROOT/deploy/helm/who-need-help" \
>"$scan_dir/rendered-helm.yaml" >"$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 "$( test "$(
grep -c 'name: ERL_ZFLAGS' "$scan_dir/rendered-helm.yaml" grep -c 'name: ERL_ZFLAGS' "$scan_dir/rendered-helm.yaml"
)" -eq 5 )" -eq 5
test "$( test "$(
grep -c 'value: "+Q 65536"' "$scan_dir/rendered-helm.yaml" grep -c 'value: "+Q 65536"' "$scan_dir/rendered-helm.yaml"
)" -eq 5 )" -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 "$( test "$(
grep -c '^kind: NetworkPolicy$' "$scan_dir/rendered-helm.yaml" grep -c '^kind: NetworkPolicy$' "$scan_dir/rendered-helm.yaml"
)" -eq 1 )" -eq 1

View File

@ -97,16 +97,12 @@ fi
test_email_provider=$(read_env "$test_env" EMAIL_DELIVERY_PROVIDER) test_email_provider=$(read_env "$test_env" EMAIL_DELIVERY_PROVIDER)
production_email_provider=$(read_env "$production_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 && if [[ "$test_email_provider" == smtp &&
"$(read_env "$test_env" SMTP_RELAY)" != mailpit && "$(read_env "$test_env" SMTP_RELAY)" != mailpit &&
"$production_email_provider" == smtp && "$production_email_provider" == smtp &&
"$(read_env "$test_env" SMTP_RELAY)" == "$(read_env "$production_env" SMTP_RELAY)" ]]; then "$(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 require_different SMTP_PASSWORD
fi fi
[[ "$(read_env "$production_env" EMAIL_DELIVERY_PROVIDER)" != smtp || [[ "$(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_auth=$(optional_value SMTP_AUTH)
smtp_tls=$(optional_value SMTP_TLS) smtp_tls=$(optional_value SMTP_TLS)
smtp_ssl=$(optional_value SMTP_SSL) 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) email_from_address=$(require_value EMAIL_FROM_ADDRESS)
support_inbox_address=$(optional_value SUPPORT_INBOX_ADDRESS) support_inbox_address=$(optional_value SUPPORT_INBOX_ADDRESS)
google_oauth_client_id=$(optional_value GOOGLE_OAUTH_CLIENT_ID) google_oauth_client_id=$(optional_value GOOGLE_OAUTH_CLIENT_ID)
@ -296,38 +291,11 @@ case "$email_delivery_provider" in
exit 1 exit 1
fi 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 exit 1
;; ;;
esac 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" == *@* ]] || { [[ "$email_from_address" == *@* ]] || {
echo "EMAIL_FROM_ADDRESS is not an email address." >&2 echo "EMAIL_FROM_ADDRESS is not an email address." >&2
exit 1 exit 1

View File

@ -99,17 +99,8 @@ case "$email_delivery_provider" in
fi 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 exit 1
;; ;;
esac 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