Support UniSender Go HTTPS email delivery
Some checks are pending
Quality / full-local-gates (push) Waiting to run

This commit is contained in:
SimpleTest 2026-07-21 14:42:14 +03:00
parent 27c615d6cb
commit 6e11bb35aa
16 changed files with 789 additions and 126 deletions

View File

@ -122,8 +122,9 @@ 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
# Mailpit settings for local development. Replace these with the selected SMTP # Select `smtp` for Mailpit or a regular transactional SMTP relay. Select
# provider for public registration and magic links. # `unisender_go` to use UniSender Go's HTTPS transactional Web API instead.
EMAIL_DELIVERY_PROVIDER=smtp
SMTP_RELAY=mailpit SMTP_RELAY=mailpit
SMTP_PORT=1025 SMTP_PORT=1025
SMTP_USERNAME= SMTP_USERNAME=
@ -131,6 +132,11 @@ 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
# 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

@ -209,9 +209,12 @@ existing file:
./scripts/init-production-env.sh whoneedhelp.com .env.production ./scripts/init-production-env.sh whoneedhelp.com .env.production
``` ```
Configure the verified reverse-proxy source IP/CIDR and transactional SMTP Configure the verified reverse-proxy source IP/CIDR and transactional email
provider in that file. Email registration and magic-link login are unusable for provider in that file. `EMAIL_DELIVERY_PROVIDER=smtp` uses the standard Swoosh
real recipients until that relay and its accepted sender are configured. Set 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
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
database queues continue to work when it is empty. See database queues continue to work when it is empty. See
@ -588,8 +591,10 @@ For an external cluster, provide a real PostgreSQL/PostGIS service and a
pre-created Secret through required `existingSecret`; the chart never renders 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 GitHub OAuth client ID and client secret described may additionally contain the OAuth credentials described above. Set
above. The chart intentionally has no invented CPU/RAM limits or HPA `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
thresholds; measure this application in the target environment before setting thresholds; measure this application in the target environment before setting
them. them.

View File

@ -18,13 +18,18 @@ x-app-environment: &app-environment
PHX_SCHEME: ${PHX_SCHEME:?Set PHX_SCHEME in .env} PHX_SCHEME: ${PHX_SCHEME:?Set PHX_SCHEME in .env}
PHX_URL_PORT: ${PHX_URL_PORT:?Set PHX_URL_PORT in .env} PHX_URL_PORT: ${PHX_URL_PORT:?Set PHX_URL_PORT in .env}
PORT: "4000" PORT: "4000"
SMTP_RELAY: ${SMTP_RELAY:?Set SMTP_RELAY in .env} EMAIL_DELIVERY_PROVIDER: ${EMAIL_DELIVERY_PROVIDER:-smtp}
SMTP_PORT: ${SMTP_PORT:?Set SMTP_PORT in .env} SMTP_RELAY: ${SMTP_RELAY:-mailpit}
SMTP_PORT: ${SMTP_PORT:-1025}
SMTP_USERNAME: ${SMTP_USERNAME:-} SMTP_USERNAME: ${SMTP_USERNAME:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-} SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_AUTH: ${SMTP_AUTH:?Set SMTP_AUTH in .env} SMTP_AUTH: ${SMTP_AUTH:-never}
SMTP_TLS: ${SMTP_TLS:?Set SMTP_TLS in .env} SMTP_TLS: ${SMTP_TLS:-never}
SMTP_SSL: ${SMTP_SSL:?Set SMTP_SSL in .env} 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}
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

@ -396,20 +396,39 @@ if config_env() == :prod do
default_url_port = if scheme == "https", do: "443", else: "80" default_url_port = if scheme == "https", do: "443", else: "80"
url_port = String.to_integer(System.get_env("PHX_URL_PORT", default_url_port)) url_port = String.to_integer(System.get_env("PHX_URL_PORT", default_url_port))
email_delivery_provider = System.get_env("EMAIL_DELIVERY_PROVIDER", "smtp")
mailer_config =
case email_delivery_provider do
"smtp" ->
smtp_auth = smtp_auth =
case System.get_env("SMTP_AUTH", "never") do case System.get_env("SMTP_AUTH", "never") do
"always" -> :always "always" ->
"never" -> :never :always
"if_available" -> :if_available
other -> raise "SMTP_AUTH must be always, never, or if_available; got #{inspect(other)}" "never" ->
:never
"if_available" ->
:if_available
other ->
raise "SMTP_AUTH must be always, never, or if_available; got #{inspect(other)}"
end end
smtp_tls = smtp_tls =
case System.get_env("SMTP_TLS", "never") do case System.get_env("SMTP_TLS", "never") do
"always" -> :always "always" ->
"never" -> :never :always
"if_available" -> :if_available
other -> raise "SMTP_TLS must be always, never, or if_available; got #{inspect(other)}" "never" ->
:never
"if_available" ->
:if_available
other ->
raise "SMTP_TLS must be always, never, or if_available; got #{inspect(other)}"
end end
smtp_ssl = smtp_ssl =
@ -436,7 +455,6 @@ if config_env() == :prod do
raise "SMTP_TLS must be never when SMTP_SSL enables an implicit TLS connection." raise "SMTP_TLS must be never when SMTP_SSL enables an implicit TLS connection."
end end
smtp_config =
[ [
adapter: Swoosh.Adapters.SMTP, adapter: Swoosh.Adapters.SMTP,
relay: System.get_env("SMTP_RELAY", "mailpit"), relay: System.get_env("SMTP_RELAY", "mailpit"),
@ -458,6 +476,56 @@ 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
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,
client_options: client_options
]
other ->
raise "EMAIL_DELIVERY_PROVIDER must be smtp or unisender_go; got #{inspect(other)}"
end
config :who_need_help, :handover_secret, handover_secret config :who_need_help, :handover_secret, handover_secret
config :who_need_help, :mailer_from, config :who_need_help, :mailer_from,
@ -477,7 +545,7 @@ 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
config :who_need_help, WhoNeedHelp.Mailer, smtp_config config :who_need_help, WhoNeedHelp.Mailer, mailer_config
config :who_need_help, WhoNeedHelpWeb.Endpoint, config :who_need_help, WhoNeedHelpWeb.Endpoint,
url: [host: host, port: url_port, scheme: scheme], url: [host: host, port: url_port, scheme: scheme],

View File

@ -51,6 +51,10 @@ spec:
value: {{ printf "+Q %d" (int $root.Values.app.erlangPortLimit) | quote }} value: {{ printf "+Q %d" (int $root.Values.app.erlangPortLimit) | quote }}
- name: CLUSTER_INTERFACE - name: CLUSTER_INTERFACE
value: {{ $root.Values.app.clusterInterface | quote }} 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 }}
securityContext: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
readOnlyRootFilesystem: true readOnlyRootFilesystem: true
@ -89,10 +93,14 @@ spec:
value: {{ $root.Values.worker.maintenanceConcurrency | quote }} value: {{ $root.Values.worker.maintenanceConcurrency | quote }}
- name: OBAN_PUSH_CONCURRENCY - name: OBAN_PUSH_CONCURRENCY
value: {{ $root.Values.worker.pushConcurrency | quote }} value: {{ $root.Values.worker.pushConcurrency | quote }}
- name: EMAIL_DELIVERY_PROVIDER
value: {{ $root.Values.app.emailDeliveryProvider | quote }}
- name: SMTP_RELAY - name: SMTP_RELAY
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: 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

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

View File

@ -9,10 +9,23 @@
"type": "integer", "type": "integer",
"minimum": 1024, "minimum": 1024,
"maximum": 134217727 "maximum": 134217727
},
"emailDeliveryProvider": {
"type": "string",
"enum": [
"smtp",
"unisender_go"
]
},
"unisenderGoBaseUrl": {
"type": "string",
"pattern": "^https://"
} }
}, },
"required": [ "required": [
"erlangPortLimit" "erlangPortLimit",
"emailDeliveryProvider",
"unisenderGoBaseUrl"
] ]
} }
} }

View File

@ -33,15 +33,20 @@ app:
# Shared limits are opt-in; set only after product policy thresholds are approved. # Shared limits are opt-in; set only after product policy thresholds are approved.
rateLimitPoliciesJson: "{}" rateLimitPoliciesJson: "{}"
mapTileUrl: https://tile.openstreetmap.org/{z}/{x}/{y}.png mapTileUrl: https://tile.openstreetmap.org/{z}/{x}/{y}.png
emailDeliveryProvider: smtp
smtpRelay: mailpit smtpRelay: mailpit
smtpPort: "1025" smtpPort: "1025"
unisenderGoBaseUrl: https://goapi.unisender.ru/ru/transactional/api/v1
# 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
# GITHUB_OAUTH_CLIENT_ID and GITHUB_OAUTH_CLIENT_SECRET to enable verified # GITHUB_OAUTH_CLIENT_ID and GITHUB_OAUTH_CLIENT_SECRET to enable verified
# 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. Push is # 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.
# 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,
# PUSH_HTTP_RECEIVE_TIMEOUT_MS, PUSH_HTTP_CONNECT_TIMEOUT_MS, and # PUSH_HTTP_RECEIVE_TIMEOUT_MS, PUSH_HTTP_CONNECT_TIMEOUT_MS, and

View File

@ -173,7 +173,7 @@ allows unrestricted Erlang distribution only between pods belonging to the
same chart instance, exposes the configured HTTP listener, and isolates other same chart instance, exposes the configured HTTP listener, and isolates other
inbound pod ports. Kubernetes enforces this only when the cluster's network inbound pod ports. Kubernetes enforces this only when the cluster's network
plugin implements NetworkPolicy. It does not restrict egress because the plugin implements NetworkPolicy. It does not restrict egress because the
production database, SMTP, OAuth, map, and push-provider destinations are not production database, email provider, OAuth, map, and push-provider destinations are not
known yet; those rules must be added from verified deployment-specific known yet; those rules must be added from verified deployment-specific
addresses rather than invented in the chart. The chart does not invent addresses rather than invented in the chart. The chart does not invent
CPU/memory limits or an HPA threshold before measurements exist. CPU/memory limits or an HPA threshold before measurements exist.

View File

@ -99,7 +99,7 @@ PRODUCTION_CODEX_SESSION_ID=YOUR_MAIN_CODEX_SESSION_ID \
whoneedhelp.com .env.production whoneedhelp.com .env.production
``` ```
Configure and validate SMTP/OAuth independently in each ignored file. Start Configure and validate email delivery/OAuth independently in each ignored file. Start
staging first, run database/load/browser/Android verification there, and then staging first, run database/load/browser/Android verification there, and then
start the clean production project. Stopping staging does not stop Caddy or start the clean production project. Stopping staging does not stop Caddy or
production: production:
@ -131,10 +131,15 @@ 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 SMTP relay and sender accepted by that provider. Configure the transactional email provider and a sender accepted by it. SMTP
Provider-specific auth, TLS, ports, and credentials can be supplied to the uses `EMAIL_DELIVERY_PROVIDER=smtp` plus the `SMTP_*` settings. UniSender Go's
initializer through the documented `PRODUCTION_SMTP_*` environment values or HTTPS Web API uses `EMAIL_DELIVERY_PROVIDER=unisender_go`,
edited in the resulting ignored file. Set optional `SUPPORT_INBOX_ADDRESS` to `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.
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
Google registration/sign-in is Google registration/sign-in is
@ -150,10 +155,11 @@ URI. Leave both credentials empty to keep the feature disabled. Then run:
The production override keeps Mailpit behind its inactive `local-mail` profile, The production override keeps Mailpit behind its inactive `local-mail` profile,
so public registration cannot appear to succeed while mail is only retained so public registration cannot appear to succeed while mail is only retained
locally. The validator checks file ownership/mode, origin consistency, locally. The validator checks file ownership/mode, origin consistency,
template markers, independent generated secrets, supported SMTP values, a template markers, independent generated secrets, settings required by the
selected email provider, a
complete-or-empty Google credential pair, the generated database URL, and the complete-or-empty Google credential pair, the generated database URL, and the
final Compose render without printing secrets. final Compose render without printing secrets.
It does not contact DNS, TLS, SMTP, the reverse proxy, or the application. It does not contact DNS, TLS, the email provider, the reverse proxy, or the application.
After deployment, verify `/healthz/ready`, inspect all replica health and logs, After deployment, verify `/healthz/ready`, inspect all replica health and logs,
register a unique address through the public browser, receive its message at register a unique address through the public browser, receive its message at
the real mailbox, follow the HTTPS confirmation link, and remove only that the real mailbox, follow the HTTPS confirmation link, and remove only that
@ -375,6 +381,13 @@ 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
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).
## Isolated restore drill ## Isolated restore drill
Run a real restore into a uniquely named temporary database: Run a real restore into a uniquely named temporary database:

View File

@ -1103,6 +1103,31 @@ 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
- 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
the server's mode-`0600` credential fragment and was not printed.
- Direct TCP connection attempts from the same server to SMTP ports 25, 465,
and 587 timed out for UniSender Go; control attempts to other public SMTP
providers also timed out. This observation does not establish where the
filtering occurs.
- `WhoNeedHelp.Email.UnisenderGoAdapter` now maps the application's existing
Swoosh messages to the provider's HTTPS `email/send.json` contract. Six
focused tests passed for the exact request shape and API-key header, success,
redacted recipient rejection, structured API errors, invalid responses, and
rejection of unsupported or provider-invalid messages before network I/O.
Runtime configuration and production
environment validation can select either `smtp` or `unisender_go` without
requiring SMTP settings in API mode.
- UniSender Go's account settings were saved with read/open tracking and link
tracking disabled, then reloaded and observed still disabled. The provider's
unsubscribe-link setting remains enabled and locked; disabling it requires
provider approval and has not been claimed.
- A real email has not yet been submitted through the new adapter. The sending
domain's UniSender Go verification/DKIM records were issued but are not yet
present in authoritative DNS, so end-to-end delivery remains unproven.
## Known work before a public production launch ## Known work before a public production launch
- Replace the temporary staging origin with the production-owned domain and - Replace the temporary staging origin with the production-owned domain and
@ -1113,8 +1138,9 @@ availability, or target-server capacity.
verified App Links are wanted, and complete store policy/release work. verified App Links are wanted, and complete store policy/release work.
- Operate PostgreSQL/PostGIS with off-site backups, recovery testing, monitoring, - Operate PostgreSQL/PostGIS with off-site backups, recovery testing, monitoring,
and the availability model selected for real usage. and the availability model selected for real usage.
- Configure a real transactional email provider before public registration. - Publish and verify the issued UniSender Go SPF, DKIM, domain-validation,
The current Compose mail path terminates at local Mailpit. DMARC, and link-domain DNS records, then exercise registration and magic-link
delivery through the selected HTTPS provider to a real mailbox.
- Create a Google OAuth Web client for the final owned HTTPS origin, configure - Create a Google OAuth Web client for the final owned HTTPS origin, configure
its exact `/auth/google/callback` redirect, and exercise registration, its exact `/auth/google/callback` redirect, and exercise registration,
sign-in, and settings linking against Google if Google authentication is to sign-in, and settings linking against Google if Google authentication is to

View File

@ -0,0 +1,228 @@
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))
: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),
{: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) 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),
"idempotence_key" => idempotence_key(email)
}
|> 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
end

View File

@ -115,6 +115,7 @@ docker_socket_gid=$(stat -c '%g' /var/run/docker.sock)
http_bind_address=${PRODUCTION_HTTP_BIND_ADDRESS:-127.0.0.1} http_bind_address=${PRODUCTION_HTTP_BIND_ADDRESS:-127.0.0.1}
http_port=${PRODUCTION_HTTP_PORT:-4010} http_port=${PRODUCTION_HTTP_PORT:-4010}
trusted_proxy_ips=${PRODUCTION_TRAEFIK_TRUSTED_IPS:-REPLACE_WITH_VERIFIED_PROXY_IP_OR_CIDR} trusted_proxy_ips=${PRODUCTION_TRAEFIK_TRUSTED_IPS:-REPLACE_WITH_VERIFIED_PROXY_IP_OR_CIDR}
email_delivery_provider=${PRODUCTION_EMAIL_DELIVERY_PROVIDER:-smtp}
smtp_relay=${PRODUCTION_SMTP_RELAY:-REPLACE_WITH_TRANSACTIONAL_SMTP_RELAY} smtp_relay=${PRODUCTION_SMTP_RELAY:-REPLACE_WITH_TRANSACTIONAL_SMTP_RELAY}
smtp_port=${PRODUCTION_SMTP_PORT:-587} smtp_port=${PRODUCTION_SMTP_PORT:-587}
smtp_username=${PRODUCTION_SMTP_USERNAME:-} smtp_username=${PRODUCTION_SMTP_USERNAME:-}
@ -122,9 +123,27 @@ 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}
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
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
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
chmod 600 "$tmp" chmod 600 "$tmp"
@ -147,6 +166,7 @@ SECRET_KEY_BASE_VALUE=$secret_key_base \
HANDOVER_SECRET_VALUE=$handover_secret \ HANDOVER_SECRET_VALUE=$handover_secret \
RELEASE_COOKIE_VALUE=$release_cookie \ RELEASE_COOKIE_VALUE=$release_cookie \
METRICS_TOKEN_VALUE=$metrics_token \ METRICS_TOKEN_VALUE=$metrics_token \
EMAIL_DELIVERY_PROVIDER_VALUE=$email_delivery_provider \
SMTP_RELAY_VALUE=$smtp_relay \ SMTP_RELAY_VALUE=$smtp_relay \
SMTP_PORT_VALUE=$smtp_port \ SMTP_PORT_VALUE=$smtp_port \
SMTP_USERNAME_VALUE=$smtp_username \ SMTP_USERNAME_VALUE=$smtp_username \
@ -154,6 +174,10 @@ 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 \
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 \
@ -185,6 +209,7 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \
replacement["HANDOVER_SECRET"] = ENVIRON["HANDOVER_SECRET_VALUE"] replacement["HANDOVER_SECRET"] = ENVIRON["HANDOVER_SECRET_VALUE"]
replacement["RELEASE_COOKIE"] = ENVIRON["RELEASE_COOKIE_VALUE"] replacement["RELEASE_COOKIE"] = ENVIRON["RELEASE_COOKIE_VALUE"]
replacement["METRICS_TOKEN"] = ENVIRON["METRICS_TOKEN_VALUE"] replacement["METRICS_TOKEN"] = ENVIRON["METRICS_TOKEN_VALUE"]
replacement["EMAIL_DELIVERY_PROVIDER"] = ENVIRON["EMAIL_DELIVERY_PROVIDER_VALUE"]
replacement["SMTP_RELAY"] = ENVIRON["SMTP_RELAY_VALUE"] replacement["SMTP_RELAY"] = ENVIRON["SMTP_RELAY_VALUE"]
replacement["SMTP_PORT"] = ENVIRON["SMTP_PORT_VALUE"] replacement["SMTP_PORT"] = ENVIRON["SMTP_PORT_VALUE"]
replacement["SMTP_USERNAME"] = ENVIRON["SMTP_USERNAME_VALUE"] replacement["SMTP_USERNAME"] = ENVIRON["SMTP_USERNAME_VALUE"]
@ -192,6 +217,10 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \
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["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["CODEX_SESSION_ID"] = ENVIRON["CODEX_SESSION_ID_VALUE"] replacement["CODEX_SESSION_ID"] = ENVIRON["CODEX_SESSION_ID_VALUE"]
@ -213,7 +242,8 @@ 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
echo "Generated independent deployment secrets without printing them." echo "Generated independent deployment secrets without printing them."
echo "Created mode-0600 environment: $target" echo "Created mode-0600 environment: $target"
echo "Run scripts/validate-production-env.sh '$target' '$domain' after configuring the verified proxy and SMTP values." echo "Run scripts/validate-production-env.sh '$target' '$domain' after configuring the verified proxy and email-provider values."

View File

@ -128,6 +128,32 @@ PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \
./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
./scripts/validate-production-env.sh "$production_env" help.test >/dev/null ./scripts/validate-production-env.sh "$production_env" help.test >/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
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
fi
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"
@ -253,6 +279,7 @@ docker compose --project-directory "$ROOT" --env-file "$edge_env" \
' >/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 "$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

View File

@ -93,13 +93,18 @@ secret_key_base=$(require_value SECRET_KEY_BASE)
handover_secret=$(require_value HANDOVER_SECRET) handover_secret=$(require_value HANDOVER_SECRET)
release_cookie=$(require_value RELEASE_COOKIE) release_cookie=$(require_value RELEASE_COOKIE)
metrics_token=$(require_value METRICS_TOKEN) metrics_token=$(require_value METRICS_TOKEN)
smtp_relay=$(require_value SMTP_RELAY) email_delivery_provider=$(require_value EMAIL_DELIVERY_PROVIDER)
smtp_port=$(require_value SMTP_PORT) smtp_relay=$(optional_value SMTP_RELAY)
smtp_port=$(optional_value SMTP_PORT)
smtp_username=$(optional_value SMTP_USERNAME) smtp_username=$(optional_value SMTP_USERNAME)
smtp_password=$(optional_value SMTP_PASSWORD) smtp_password=$(optional_value SMTP_PASSWORD)
smtp_auth=$(require_value SMTP_AUTH) smtp_auth=$(optional_value SMTP_AUTH)
smtp_tls=$(require_value SMTP_TLS) smtp_tls=$(optional_value SMTP_TLS)
smtp_ssl=$(require_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)
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)
@ -165,7 +170,6 @@ for pair in \
"HANDOVER_SECRET:$handover_secret" \ "HANDOVER_SECRET:$handover_secret" \
"RELEASE_COOKIE:$release_cookie" \ "RELEASE_COOKIE:$release_cookie" \
"METRICS_TOKEN:$metrics_token" \ "METRICS_TOKEN:$metrics_token" \
"SMTP_RELAY:$smtp_relay" \
"EMAIL_FROM_ADDRESS:$email_from_address" "EMAIL_FROM_ADDRESS:$email_from_address"
do do
reject_marker "${pair%%:*}" "${pair#*:}" reject_marker "${pair%%:*}" "${pair#*:}"
@ -218,6 +222,13 @@ if [[ "$database_mode" != external && -n "$database_socket_dir" ]]; then
exit 1 exit 1
fi fi
case "$email_delivery_provider" in
smtp)
[[ -n "$smtp_relay" ]] || {
echo "SMTP_RELAY is required when EMAIL_DELIVERY_PROVIDER=smtp." >&2
exit 1
}
reject_marker SMTP_RELAY "$smtp_relay"
[[ "$smtp_relay" != mailpit ]] || { [[ "$smtp_relay" != mailpit ]] || {
echo "SMTP_RELAY still targets local Mailpit; public registration needs a transactional relay." >&2 echo "SMTP_RELAY still targets local Mailpit; public registration needs a transactional relay." >&2
exit 1 exit 1
@ -252,6 +263,35 @@ if [[ "$smtp_ssl" =~ ^(true|1)$ && "$smtp_tls" != never ]]; then
echo "SMTP_TLS must be never when SMTP_SSL enables an implicit TLS connection." >&2 echo "SMTP_TLS must be never when SMTP_SSL enables an implicit TLS connection." >&2
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"
;;
*)
echo "EMAIL_DELIVERY_PROVIDER must be smtp or unisender_go." >&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" == *@* ]] || { [[ "$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
@ -303,4 +343,4 @@ done
"$ROOT/scripts/compose.sh" "$env_file" config --quiet "$ROOT/scripts/compose.sh" "$env_file" config --quiet
echo "Production environment structure passed validation without printing secrets." echo "Production environment structure passed validation without printing secrets."
echo "This does not test DNS, TLS, SMTP reachability/delivery, proxy source IPs, or server capacity." echo "This does not test DNS, TLS, email-provider availability/delivery, proxy source IPs, or server capacity."

View File

@ -0,0 +1,185 @@
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"
}
}
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 "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
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