diff --git a/.env.example b/.env.example index a292f69..dc4d0fb 100644 --- a/.env.example +++ b/.env.example @@ -122,8 +122,9 @@ HANDOVER_SECRET=generate-an-independent-random-secret RELEASE_COOKIE=generate-an-independent-beam-cluster-cookie METRICS_TOKEN=generate-an-independent-random-bearer-token -# Mailpit settings for local development. Replace these with the selected SMTP -# provider for public registration and magic links. +# Select `smtp` for Mailpit or a regular transactional SMTP relay. Select +# `unisender_go` to use UniSender Go's HTTPS transactional Web API instead. +EMAIL_DELIVERY_PROVIDER=smtp SMTP_RELAY=mailpit SMTP_PORT=1025 SMTP_USERNAME= @@ -131,6 +132,11 @@ SMTP_PASSWORD= SMTP_AUTH=never SMTP_TLS=never SMTP_SSL=false +UNISENDER_GO_API_KEY= +UNISENDER_GO_BASE_URL=https://goapi.unisender.ru/ru/transactional/api/v1 +# Optional. When empty, the Req/Finch library defaults are used. +EMAIL_HTTP_CONNECT_TIMEOUT_MS= +EMAIL_HTTP_RECEIVE_TIMEOUT_MS= EMAIL_FROM_NAME="Who Need Help" EMAIL_FROM_ADDRESS=contact@example.com # Optional monitored inbox. It receives new-case alerts and is used as Reply-To. diff --git a/README.md b/README.md index fb552fb..e4bcce3 100644 --- a/README.md +++ b/README.md @@ -209,9 +209,12 @@ existing file: ./scripts/init-production-env.sh whoneedhelp.com .env.production ``` -Configure the verified reverse-proxy source IP/CIDR and transactional SMTP -provider in that file. Email registration and magic-link login are unusable for -real recipients until that relay and its accepted sender are configured. Set +Configure the verified reverse-proxy source IP/CIDR and transactional email +provider in that file. `EMAIL_DELIVERY_PROVIDER=smtp` uses the standard Swoosh +SMTP adapter. `EMAIL_DELIVERY_PROVIDER=unisender_go` uses UniSender Go's HTTPS +Web API and requires `UNISENDER_GO_API_KEY`; this mode does not need outbound +SMTP ports. Email registration and magic-link login are unusable for real +recipients until the selected provider and its accepted sender are configured. Set the optional `SUPPORT_INBOX_ADDRESS` to a monitored mailbox to receive metadata-only new-case alerts and make replies return to the support team; the 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 credentials from tracked values. The Secret must contain `DATABASE_URL`, `SECRET_KEY_BASE`, `HANDOVER_SECRET`, `RELEASE_COOKIE`, and `METRICS_TOKEN`, and -may additionally contain the GitHub OAuth client ID and client secret described -above. The chart intentionally has no invented CPU/RAM limits or HPA +may additionally contain the OAuth credentials described above. Set +`app.emailDeliveryProvider`; UniSender Go API mode also requires +`UNISENDER_GO_API_KEY` in that Secret, while SMTP credentials remain Secret +values in SMTP mode. The chart intentionally has no invented CPU/RAM limits or HPA thresholds; measure this application in the target environment before setting them. diff --git a/compose.yaml b/compose.yaml index 7da79e9..e00cda9 100644 --- a/compose.yaml +++ b/compose.yaml @@ -18,13 +18,18 @@ x-app-environment: &app-environment PHX_SCHEME: ${PHX_SCHEME:?Set PHX_SCHEME in .env} PHX_URL_PORT: ${PHX_URL_PORT:?Set PHX_URL_PORT in .env} PORT: "4000" - SMTP_RELAY: ${SMTP_RELAY:?Set SMTP_RELAY in .env} - SMTP_PORT: ${SMTP_PORT:?Set SMTP_PORT in .env} + EMAIL_DELIVERY_PROVIDER: ${EMAIL_DELIVERY_PROVIDER:-smtp} + SMTP_RELAY: ${SMTP_RELAY:-mailpit} + SMTP_PORT: ${SMTP_PORT:-1025} SMTP_USERNAME: ${SMTP_USERNAME:-} SMTP_PASSWORD: ${SMTP_PASSWORD:-} - SMTP_AUTH: ${SMTP_AUTH:?Set SMTP_AUTH in .env} - SMTP_TLS: ${SMTP_TLS:?Set SMTP_TLS in .env} - SMTP_SSL: ${SMTP_SSL:?Set SMTP_SSL in .env} + SMTP_AUTH: ${SMTP_AUTH:-never} + SMTP_TLS: ${SMTP_TLS:-never} + SMTP_SSL: ${SMTP_SSL:-false} + UNISENDER_GO_API_KEY: ${UNISENDER_GO_API_KEY:-} + UNISENDER_GO_BASE_URL: ${UNISENDER_GO_BASE_URL:-https://goapi.unisender.ru/ru/transactional/api/v1} + EMAIL_HTTP_CONNECT_TIMEOUT_MS: ${EMAIL_HTTP_CONNECT_TIMEOUT_MS:-} + EMAIL_HTTP_RECEIVE_TIMEOUT_MS: ${EMAIL_HTTP_RECEIVE_TIMEOUT_MS:-} EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:?Set EMAIL_FROM_NAME in .env} EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:?Set EMAIL_FROM_ADDRESS in .env} SUPPORT_INBOX_ADDRESS: ${SUPPORT_INBOX_ADDRESS:-} diff --git a/config/runtime.exs b/config/runtime.exs index 9b34cd4..d350cc8 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -396,68 +396,136 @@ if config_env() == :prod do default_url_port = if scheme == "https", do: "443", else: "80" url_port = String.to_integer(System.get_env("PHX_URL_PORT", default_url_port)) - smtp_auth = - case System.get_env("SMTP_AUTH", "never") do - "always" -> :always - "never" -> :never - "if_available" -> :if_available - other -> raise "SMTP_AUTH must be always, never, or if_available; got #{inspect(other)}" + email_delivery_provider = System.get_env("EMAIL_DELIVERY_PROVIDER", "smtp") + + mailer_config = + case email_delivery_provider do + "smtp" -> + smtp_auth = + case System.get_env("SMTP_AUTH", "never") do + "always" -> + :always + + "never" -> + :never + + "if_available" -> + :if_available + + other -> + raise "SMTP_AUTH must be always, never, or if_available; got #{inspect(other)}" + end + + smtp_tls = + case System.get_env("SMTP_TLS", "never") do + "always" -> + :always + + "never" -> + :never + + "if_available" -> + :if_available + + other -> + raise "SMTP_TLS must be always, never, or if_available; got #{inspect(other)}" + end + + smtp_ssl = + case System.get_env("SMTP_SSL", "false") do + value when value in ["true", "1"] -> true + value when value in ["false", "0"] -> false + other -> raise "SMTP_SSL must be true, false, 1, or 0; got #{inspect(other)}" + end + + smtp_username = System.get_env("SMTP_USERNAME") + smtp_password = System.get_env("SMTP_PASSWORD") + smtp_username_set? = is_binary(smtp_username) and smtp_username != "" + smtp_password_set? = is_binary(smtp_password) and smtp_password != "" + + if smtp_username_set? != smtp_password_set? do + raise "SMTP_USERNAME and SMTP_PASSWORD must either both be set or both be empty." + end + + if smtp_auth == :always and not (smtp_username_set? and smtp_password_set?) do + raise "SMTP_USERNAME and SMTP_PASSWORD are required when SMTP_AUTH is always." + end + + if smtp_ssl and smtp_tls != :never do + raise "SMTP_TLS must be never when SMTP_SSL enables an implicit TLS connection." + end + + [ + adapter: Swoosh.Adapters.SMTP, + relay: System.get_env("SMTP_RELAY", "mailpit"), + port: String.to_integer(System.get_env("SMTP_PORT", "1025")), + auth: smtp_auth, + tls: smtp_tls, + ssl: smtp_ssl + ] + |> then(fn config -> + case smtp_username do + value when is_binary(value) and value != "" -> Keyword.put(config, :username, value) + _ -> config + end + end) + |> then(fn config -> + case smtp_password do + value when is_binary(value) and value != "" -> Keyword.put(config, :password, value) + _ -> config + 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 - smtp_tls = - case System.get_env("SMTP_TLS", "never") do - "always" -> :always - "never" -> :never - "if_available" -> :if_available - other -> raise "SMTP_TLS must be always, never, or if_available; got #{inspect(other)}" - end - - smtp_ssl = - case System.get_env("SMTP_SSL", "false") do - value when value in ["true", "1"] -> true - value when value in ["false", "0"] -> false - other -> raise "SMTP_SSL must be true, false, 1, or 0; got #{inspect(other)}" - end - - smtp_username = System.get_env("SMTP_USERNAME") - smtp_password = System.get_env("SMTP_PASSWORD") - smtp_username_set? = is_binary(smtp_username) and smtp_username != "" - smtp_password_set? = is_binary(smtp_password) and smtp_password != "" - - if smtp_username_set? != smtp_password_set? do - raise "SMTP_USERNAME and SMTP_PASSWORD must either both be set or both be empty." - end - - if smtp_auth == :always and not (smtp_username_set? and smtp_password_set?) do - raise "SMTP_USERNAME and SMTP_PASSWORD are required when SMTP_AUTH is always." - end - - if smtp_ssl and smtp_tls != :never do - raise "SMTP_TLS must be never when SMTP_SSL enables an implicit TLS connection." - end - - smtp_config = - [ - adapter: Swoosh.Adapters.SMTP, - relay: System.get_env("SMTP_RELAY", "mailpit"), - port: String.to_integer(System.get_env("SMTP_PORT", "1025")), - auth: smtp_auth, - tls: smtp_tls, - ssl: smtp_ssl - ] - |> then(fn config -> - case smtp_username do - value when is_binary(value) and value != "" -> Keyword.put(config, :username, value) - _ -> config - end - end) - |> then(fn config -> - case smtp_password do - value when is_binary(value) and value != "" -> Keyword.put(config, :password, value) - _ -> config - end - end) - config :who_need_help, :handover_secret, handover_secret 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, WhoNeedHelp.Mailer, smtp_config + config :who_need_help, WhoNeedHelp.Mailer, mailer_config config :who_need_help, WhoNeedHelpWeb.Endpoint, url: [host: host, port: url_port, scheme: scheme], diff --git a/deploy/helm/who-need-help/templates/deployments.yaml b/deploy/helm/who-need-help/templates/deployments.yaml index e2550b4..7011f28 100644 --- a/deploy/helm/who-need-help/templates/deployments.yaml +++ b/deploy/helm/who-need-help/templates/deployments.yaml @@ -51,6 +51,10 @@ spec: value: {{ printf "+Q %d" (int $root.Values.app.erlangPortLimit) | quote }} - name: CLUSTER_INTERFACE 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: allowPrivilegeEscalation: false readOnlyRootFilesystem: true @@ -89,10 +93,14 @@ spec: value: {{ $root.Values.worker.maintenanceConcurrency | quote }} - name: OBAN_PUSH_CONCURRENCY value: {{ $root.Values.worker.pushConcurrency | quote }} + - name: EMAIL_DELIVERY_PROVIDER + value: {{ $root.Values.app.emailDeliveryProvider | quote }} - name: SMTP_RELAY value: {{ $root.Values.app.smtpRelay | quote }} - name: SMTP_PORT value: {{ $root.Values.app.smtpPort | quote }} + - name: UNISENDER_GO_BASE_URL + value: {{ $root.Values.app.unisenderGoBaseUrl | quote }} - name: CODEX_SESSION_ID value: {{ $root.Values.app.codexSessionId | quote }} - name: RATE_LIMIT_POLICIES_JSON diff --git a/deploy/helm/who-need-help/templates/migrate-job.yaml b/deploy/helm/who-need-help/templates/migrate-job.yaml index b8a8a72..d6d6e04 100644 --- a/deploy/helm/who-need-help/templates/migrate-job.yaml +++ b/deploy/helm/who-need-help/templates/migrate-job.yaml @@ -43,6 +43,10 @@ spec: value: {{ .Values.worker.maintenanceConcurrency | quote }} - name: OBAN_PUSH_CONCURRENCY 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: allowPrivilegeEscalation: false readOnlyRootFilesystem: true diff --git a/deploy/helm/who-need-help/values.schema.json b/deploy/helm/who-need-help/values.schema.json index 16637fd..a1e126e 100644 --- a/deploy/helm/who-need-help/values.schema.json +++ b/deploy/helm/who-need-help/values.schema.json @@ -9,10 +9,23 @@ "type": "integer", "minimum": 1024, "maximum": 134217727 + }, + "emailDeliveryProvider": { + "type": "string", + "enum": [ + "smtp", + "unisender_go" + ] + }, + "unisenderGoBaseUrl": { + "type": "string", + "pattern": "^https://" } }, "required": [ - "erlangPortLimit" + "erlangPortLimit", + "emailDeliveryProvider", + "unisenderGoBaseUrl" ] } } diff --git a/deploy/helm/who-need-help/values.yaml b/deploy/helm/who-need-help/values.yaml index 93c642f..36e366e 100644 --- a/deploy/helm/who-need-help/values.yaml +++ b/deploy/helm/who-need-help/values.yaml @@ -33,15 +33,20 @@ app: # Shared limits are opt-in; set only after product policy thresholds are approved. rateLimitPoliciesJson: "{}" mapTileUrl: https://tile.openstreetmap.org/{z}/{x}/{y}.png + emailDeliveryProvider: smtp smtpRelay: mailpit smtpPort: "1025" + unisenderGoBaseUrl: https://goapi.unisender.ru/ru/transactional/api/v1 # Required. The Secret must contain DATABASE_URL, SECRET_KEY_BASE, # 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 linking, and both GOOGLE_OAUTH_CLIENT_ID and # GOOGLE_OAUTH_CLIENT_SECRET to enable Google registration/sign-in. Optional -# SUPPORT_INBOX_ADDRESS enables metadata-only operator alerts and Reply-To. 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, # PUSH_HTTP_BEARER_TOKEN, PUSH_HTTP_MAX_ATTEMPTS, # PUSH_HTTP_RECEIVE_TIMEOUT_MS, PUSH_HTTP_CONNECT_TIMEOUT_MS, and diff --git a/docs/architecture.md b/docs/architecture.md index 103a3cb..3826441 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 inbound pod ports. Kubernetes enforces this only when the cluster's network 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 addresses rather than invented in the chart. The chart does not invent CPU/memory limits or an HPA threshold before measurements exist. diff --git a/docs/operations.md b/docs/operations.md index 9eb460e..f660e3c 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -99,7 +99,7 @@ PRODUCTION_CODEX_SESSION_ID=YOUR_MAIN_CODEX_SESSION_ID \ 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 start the clean production project. Stopping staging does not stop Caddy or 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; do not copy the temporary VPN value into an unrelated server. -Configure the transactional SMTP relay and sender accepted by that provider. -Provider-specific auth, TLS, ports, and credentials can be supplied to the -initializer through the documented `PRODUCTION_SMTP_*` environment values or -edited in the resulting ignored file. Set optional `SUPPORT_INBOX_ADDRESS` to +Configure the transactional email provider and a sender accepted by it. SMTP +uses `EMAIL_DELIVERY_PROVIDER=smtp` plus the `SMTP_*` settings. UniSender Go's +HTTPS Web API uses `EMAIL_DELIVERY_PROVIDER=unisender_go`, +`UNISENDER_GO_API_KEY`, and the default documented API base URL. The initializer +accepts the corresponding `PRODUCTION_EMAIL_DELIVERY_PROVIDER`, +`PRODUCTION_SMTP_*`, and `PRODUCTION_UNISENDER_GO_*` inputs. Optional +`EMAIL_HTTP_CONNECT_TIMEOUT_MS` and `EMAIL_HTTP_RECEIVE_TIMEOUT_MS` override the +Req/Finch defaults only when deployment measurements justify explicit values. +Set optional `SUPPORT_INBOX_ADDRESS` to a monitored address for support/removal queue alerts and email `Reply-To`; leaving it empty disables operator email alerts, not the protected queues. If 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, so public registration cannot appear to succeed while mail is only retained 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 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, register a unique address through the public browser, receive its message at 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), 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 Run a real restore into a uniquely named temporary database: diff --git a/docs/verification.md b/docs/verification.md index 02a3fdd..648e106 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -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 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 - 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. - Operate PostgreSQL/PostGIS with off-site backups, recovery testing, monitoring, and the availability model selected for real usage. -- Configure a real transactional email provider before public registration. - The current Compose mail path terminates at local Mailpit. +- Publish and verify the issued UniSender Go SPF, DKIM, domain-validation, + 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 its exact `/auth/google/callback` redirect, and exercise registration, sign-in, and settings linking against Google if Google authentication is to diff --git a/lib/who_need_help/email/unisender_go_adapter.ex b/lib/who_need_help/email/unisender_go_adapter.ex new file mode 100644 index 0000000..c8bff82 --- /dev/null +++ b/lib/who_need_help/email/unisender_go_adapter.ex @@ -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 diff --git a/scripts/init-production-env.sh b/scripts/init-production-env.sh index fdb2938..8a3e69c 100755 --- a/scripts/init-production-env.sh +++ b/scripts/init-production-env.sh @@ -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_port=${PRODUCTION_HTTP_PORT:-4010} 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_port=${PRODUCTION_SMTP_PORT:-587} smtp_username=${PRODUCTION_SMTP_USERNAME:-} @@ -122,9 +123,27 @@ smtp_password=${PRODUCTION_SMTP_PASSWORD:-} smtp_auth=${PRODUCTION_SMTP_AUTH:-always} smtp_tls=${PRODUCTION_SMTP_TLS:-always} smtp_ssl=${PRODUCTION_SMTP_SSL:-false} +unisender_go_api_key=${PRODUCTION_UNISENDER_GO_API_KEY:-} +unisender_go_base_url=${PRODUCTION_UNISENDER_GO_BASE_URL:-https://goapi.unisender.ru/ru/transactional/api/v1} +email_http_connect_timeout_ms=${PRODUCTION_EMAIL_HTTP_CONNECT_TIMEOUT_MS:-} +email_http_receive_timeout_ms=${PRODUCTION_EMAIL_HTTP_RECEIVE_TIMEOUT_MS:-} email_from_address=${PRODUCTION_EMAIL_FROM_ADDRESS:-"contact@$domain"} support_inbox_address=${PRODUCTION_SUPPORT_INBOX_ADDRESS:-} +case "$email_delivery_provider" in + smtp) ;; + unisender_go) + if [ -z "$unisender_go_api_key" ]; then + echo "PRODUCTION_UNISENDER_GO_API_KEY is required for the unisender_go email provider." >&2 + exit 1 + fi + ;; + *) + echo "PRODUCTION_EMAIL_DELIVERY_PROVIDER must be smtp or unisender_go." >&2 + exit 1 + ;; +esac + tmp=$(mktemp "$target_dir/.production-env.XXXXXX") trap 'rm -f "$tmp"' EXIT HUP INT TERM chmod 600 "$tmp" @@ -147,6 +166,7 @@ SECRET_KEY_BASE_VALUE=$secret_key_base \ HANDOVER_SECRET_VALUE=$handover_secret \ RELEASE_COOKIE_VALUE=$release_cookie \ METRICS_TOKEN_VALUE=$metrics_token \ +EMAIL_DELIVERY_PROVIDER_VALUE=$email_delivery_provider \ SMTP_RELAY_VALUE=$smtp_relay \ SMTP_PORT_VALUE=$smtp_port \ SMTP_USERNAME_VALUE=$smtp_username \ @@ -154,6 +174,10 @@ SMTP_PASSWORD_VALUE=$smtp_password \ SMTP_AUTH_VALUE=$smtp_auth \ SMTP_TLS_VALUE=$smtp_tls \ SMTP_SSL_VALUE=$smtp_ssl \ +UNISENDER_GO_API_KEY_VALUE=$unisender_go_api_key \ +UNISENDER_GO_BASE_URL_VALUE=$unisender_go_base_url \ +EMAIL_HTTP_CONNECT_TIMEOUT_MS_VALUE=$email_http_connect_timeout_ms \ +EMAIL_HTTP_RECEIVE_TIMEOUT_MS_VALUE=$email_http_receive_timeout_ms \ EMAIL_FROM_ADDRESS_VALUE=$email_from_address \ SUPPORT_INBOX_ADDRESS_VALUE=$support_inbox_address \ CODEX_SESSION_ID_VALUE=$codex_session_id \ @@ -185,6 +209,7 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \ replacement["HANDOVER_SECRET"] = ENVIRON["HANDOVER_SECRET_VALUE"] replacement["RELEASE_COOKIE"] = ENVIRON["RELEASE_COOKIE_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_PORT"] = ENVIRON["SMTP_PORT_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_TLS"] = ENVIRON["SMTP_TLS_VALUE"] replacement["SMTP_SSL"] = ENVIRON["SMTP_SSL_VALUE"] + replacement["UNISENDER_GO_API_KEY"] = ENVIRON["UNISENDER_GO_API_KEY_VALUE"] + replacement["UNISENDER_GO_BASE_URL"] = ENVIRON["UNISENDER_GO_BASE_URL_VALUE"] + replacement["EMAIL_HTTP_CONNECT_TIMEOUT_MS"] = ENVIRON["EMAIL_HTTP_CONNECT_TIMEOUT_MS_VALUE"] + replacement["EMAIL_HTTP_RECEIVE_TIMEOUT_MS"] = ENVIRON["EMAIL_HTTP_RECEIVE_TIMEOUT_MS_VALUE"] replacement["EMAIL_FROM_ADDRESS"] = ENVIRON["EMAIL_FROM_ADDRESS_VALUE"] replacement["SUPPORT_INBOX_ADDRESS"] = ENVIRON["SUPPORT_INBOX_ADDRESS_VALUE"] replacement["CODEX_SESSION_ID"] = ENVIRON["CODEX_SESSION_ID_VALUE"] @@ -213,7 +242,8 @@ chmod 600 "$target" trap - EXIT HUP INT TERM unset postgres_password secret_key_base handover_secret release_cookie metrics_token +unset smtp_password unisender_go_api_key echo "Generated independent deployment secrets without printing them." 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." diff --git a/scripts/quality.sh b/scripts/quality.sh index 907b85d..a15f866 100755 --- a/scripts/quality.sh +++ b/scripts/quality.sh @@ -128,6 +128,32 @@ PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ ./scripts/init-production-env.sh help.test "$production_env" >/dev/null test "$(stat -c '%a' "$production_env")" = 600 ./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" cp "$production_env" "$placeholder_codex_env" chmod 600 "$placeholder_codex_env" @@ -253,6 +279,7 @@ docker compose --project-directory "$ROOT" --env-file "$edge_env" \ ' >/dev/null ./scripts/compose.sh .env.example config --quiet ./scripts/compose.sh "$production_env" config --quiet +./scripts/compose.sh "$api_production_env" config --quiet ./scripts/compose.sh "$external_production_env" config --quiet ./scripts/compose.sh "$external_socket_production_env" config --quiet ./scripts/compose.sh "$external_split_production_env" config --quiet diff --git a/scripts/validate-production-env.sh b/scripts/validate-production-env.sh index 2cd6791..0502a79 100755 --- a/scripts/validate-production-env.sh +++ b/scripts/validate-production-env.sh @@ -93,13 +93,18 @@ secret_key_base=$(require_value SECRET_KEY_BASE) handover_secret=$(require_value HANDOVER_SECRET) release_cookie=$(require_value RELEASE_COOKIE) metrics_token=$(require_value METRICS_TOKEN) -smtp_relay=$(require_value SMTP_RELAY) -smtp_port=$(require_value SMTP_PORT) +email_delivery_provider=$(require_value EMAIL_DELIVERY_PROVIDER) +smtp_relay=$(optional_value SMTP_RELAY) +smtp_port=$(optional_value SMTP_PORT) smtp_username=$(optional_value SMTP_USERNAME) smtp_password=$(optional_value SMTP_PASSWORD) -smtp_auth=$(require_value SMTP_AUTH) -smtp_tls=$(require_value SMTP_TLS) -smtp_ssl=$(require_value SMTP_SSL) +smtp_auth=$(optional_value SMTP_AUTH) +smtp_tls=$(optional_value SMTP_TLS) +smtp_ssl=$(optional_value SMTP_SSL) +unisender_go_api_key=$(optional_value UNISENDER_GO_API_KEY) +unisender_go_base_url=$(optional_value UNISENDER_GO_BASE_URL) +email_http_connect_timeout_ms=$(optional_value EMAIL_HTTP_CONNECT_TIMEOUT_MS) +email_http_receive_timeout_ms=$(optional_value EMAIL_HTTP_RECEIVE_TIMEOUT_MS) email_from_address=$(require_value EMAIL_FROM_ADDRESS) support_inbox_address=$(optional_value SUPPORT_INBOX_ADDRESS) google_oauth_client_id=$(optional_value GOOGLE_OAUTH_CLIENT_ID) @@ -165,7 +170,6 @@ for pair in \ "HANDOVER_SECRET:$handover_secret" \ "RELEASE_COOKIE:$release_cookie" \ "METRICS_TOKEN:$metrics_token" \ - "SMTP_RELAY:$smtp_relay" \ "EMAIL_FROM_ADDRESS:$email_from_address" do reject_marker "${pair%%:*}" "${pair#*:}" @@ -218,40 +222,76 @@ if [[ "$database_mode" != external && -n "$database_socket_dir" ]]; then exit 1 fi -[[ "$smtp_relay" != mailpit ]] || { - echo "SMTP_RELAY still targets local Mailpit; public registration needs a transactional relay." >&2 - exit 1 -} -[[ "$smtp_port" =~ ^[0-9]+$ ]] || { - echo "SMTP_PORT must be numeric." >&2 - exit 1 -} -[[ "$smtp_auth" =~ ^(always|never|if_available)$ ]] || { - echo "SMTP_AUTH has an unsupported value." >&2 - exit 1 -} -[[ "$smtp_tls" =~ ^(always|never|if_available)$ ]] || { - echo "SMTP_TLS has an unsupported value." >&2 - exit 1 -} -[[ "$smtp_ssl" =~ ^(true|false|0|1)$ ]] || { - echo "SMTP_SSL has an unsupported value." >&2 - exit 1 -} -if [[ -n "$smtp_username" || -n "$smtp_password" ]]; then - [[ -n "$smtp_username" && -n "$smtp_password" ]] || { - echo "SMTP username and password must either both be set or both be empty." >&2 +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 ]] || { + echo "SMTP_RELAY still targets local Mailpit; public registration needs a transactional relay." >&2 + exit 1 + } + [[ "$smtp_port" =~ ^[0-9]+$ ]] || { + echo "SMTP_PORT must be numeric." >&2 + exit 1 + } + [[ "$smtp_auth" =~ ^(always|never|if_available)$ ]] || { + echo "SMTP_AUTH has an unsupported value." >&2 + exit 1 + } + [[ "$smtp_tls" =~ ^(always|never|if_available)$ ]] || { + echo "SMTP_TLS has an unsupported value." >&2 + exit 1 + } + [[ "$smtp_ssl" =~ ^(true|false|0|1)$ ]] || { + echo "SMTP_SSL has an unsupported value." >&2 + exit 1 + } + if [[ -n "$smtp_username" || -n "$smtp_password" ]]; then + [[ -n "$smtp_username" && -n "$smtp_password" ]] || { + echo "SMTP username and password must either both be set or both be empty." >&2 + exit 1 + } + fi + if [[ "$smtp_auth" == always && (-z "$smtp_username" || -z "$smtp_password") ]]; then + echo "SMTP username and password are required when SMTP_AUTH is always." >&2 + exit 1 + fi + if [[ "$smtp_ssl" =~ ^(true|1)$ && "$smtp_tls" != never ]]; then + echo "SMTP_TLS must be never when SMTP_SSL enables an implicit TLS connection." >&2 + exit 1 + fi + ;; + unisender_go) + [[ -n "$unisender_go_api_key" ]] || { + echo "UNISENDER_GO_API_KEY is required when EMAIL_DELIVERY_PROVIDER=unisender_go." >&2 + exit 1 + } + reject_marker UNISENDER_GO_API_KEY "$unisender_go_api_key" + [[ "$unisender_go_base_url" =~ ^https://[^/@?#[:space:]]+(/[^?#[:space:]]*)?$ ]] || { + echo "UNISENDER_GO_BASE_URL must be an HTTPS origin and path without credentials, query, or fragment." >&2 + exit 1 + } + reject_marker UNISENDER_GO_BASE_URL "$unisender_go_base_url" + ;; + *) + echo "EMAIL_DELIVERY_PROVIDER must be smtp or unisender_go." >&2 exit 1 - } -fi -if [[ "$smtp_auth" == always && (-z "$smtp_username" || -z "$smtp_password") ]]; then - echo "SMTP username and password are required when SMTP_AUTH is always." >&2 - exit 1 -fi -if [[ "$smtp_ssl" =~ ^(true|1)$ && "$smtp_tls" != never ]]; then - echo "SMTP_TLS must be never when SMTP_SSL enables an implicit TLS connection." >&2 - exit 1 -fi + ;; +esac + +for timeout_pair in \ + "EMAIL_HTTP_CONNECT_TIMEOUT_MS:$email_http_connect_timeout_ms" \ + "EMAIL_HTTP_RECEIVE_TIMEOUT_MS:$email_http_receive_timeout_ms" +do + timeout_value=${timeout_pair#*:} + if [[ -n "$timeout_value" && ! "$timeout_value" =~ ^[1-9][0-9]*$ ]]; then + echo "${timeout_pair%%:*} must be a positive integer when configured." >&2 + exit 1 + fi +done [[ "$email_from_address" == *@* ]] || { echo "EMAIL_FROM_ADDRESS is not an email address." >&2 exit 1 @@ -303,4 +343,4 @@ done "$ROOT/scripts/compose.sh" "$env_file" config --quiet 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." diff --git a/test/who_need_help/email/unisender_go_adapter_test.exs b/test/who_need_help/email/unisender_go_adapter_test.exs new file mode 100644 index 0000000..676a64b --- /dev/null +++ b/test/who_need_help/email/unisender_go_adapter_test.exs @@ -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("

Open the confirmation link

") + |> 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" => "

Open the confirmation link

", + "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