From 8c281cc756bbbaca2c22b1799b1790218016ea18 Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Wed, 22 Jul 2026 00:38:37 +0300 Subject: [PATCH] Replace UniSender delivery with isolated SMTP --- .env.example | 10 +- README.md | 10 +- compose.yaml | 5 - config/runtime.exs | 61 +---- .../who-need-help/templates/deployments.yaml | 8 - .../who-need-help/templates/migrate-job.yaml | 4 - deploy/helm/who-need-help/values.schema.json | 14 +- deploy/helm/who-need-help/values.yaml | 5 +- docs/operations.md | 43 ++-- docs/verification.md | 7 +- .../email/unisender_go_adapter.ex | 238 ------------------ scripts/init-production-env.sh | 39 +-- scripts/quality.sh | 90 ++----- scripts/validate-deployment-isolation.sh | 8 +- scripts/validate-production-env.sh | 34 +-- scripts/validate-test-env.sh | 11 +- .../email/unisender_go_adapter_test.exs | 221 ---------------- 17 files changed, 55 insertions(+), 753 deletions(-) delete mode 100644 lib/who_need_help/email/unisender_go_adapter.ex delete mode 100644 test/who_need_help/email/unisender_go_adapter_test.exs diff --git a/.env.example b/.env.example index 1233776..f707710 100644 --- a/.env.example +++ b/.env.example @@ -145,8 +145,7 @@ HANDOVER_SECRET=generate-an-independent-random-secret RELEASE_COOKIE=generate-an-independent-beam-cluster-cookie METRICS_TOKEN=generate-an-independent-random-bearer-token -# Select `smtp` for Mailpit or a regular transactional SMTP relay. Select -# `unisender_go` to use UniSender Go's HTTPS transactional Web API instead. +# Use Mailpit locally or a transactional SMTP relay in public environments. EMAIL_DELIVERY_PROVIDER=smtp SMTP_RELAY=mailpit SMTP_PORT=1025 @@ -155,13 +154,6 @@ SMTP_PASSWORD= SMTP_AUTH=never SMTP_TLS=never SMTP_SSL=false -UNISENDER_GO_API_KEY= -UNISENDER_GO_BASE_URL=https://goapi.unisender.ru/ru/transactional/api/v1 -# Keep false until UniSender Go support enables this account-level capability. -UNISENDER_GO_SKIP_UNSUBSCRIBE=false -# Optional. When empty, the Req/Finch library defaults are used. -EMAIL_HTTP_CONNECT_TIMEOUT_MS= -EMAIL_HTTP_RECEIVE_TIMEOUT_MS= EMAIL_FROM_NAME="Who Need Help" EMAIL_FROM_ADDRESS=contact@example.com # Optional monitored inbox. It receives new-case alerts and is used as Reply-To. diff --git a/README.md b/README.md index 0cb4228..8bfa496 100644 --- a/README.md +++ b/README.md @@ -231,9 +231,8 @@ existing file: Configure the verified reverse-proxy source IP/CIDR and transactional email provider in that file. `EMAIL_DELIVERY_PROVIDER=smtp` uses the standard Swoosh -SMTP adapter. `EMAIL_DELIVERY_PROVIDER=unisender_go` uses UniSender Go's HTTPS -Web API and requires `UNISENDER_GO_API_KEY`; this mode does not need outbound -SMTP ports. Email registration and magic-link login are unusable for real +SMTP adapter and requires the relay's `SMTP_*` credentials. Email registration +and magic-link login are unusable for real recipients until the selected provider and its accepted sender are configured. Set the optional `SUPPORT_INBOX_ADDRESS` to a monitored mailbox to receive metadata-only new-case alerts and make replies return to the support team; the @@ -619,9 +618,8 @@ pre-created Secret through required `existingSecret`; the chart never renders credentials from tracked values. The Secret must contain `DATABASE_URL`, `SECRET_KEY_BASE`, `HANDOVER_SECRET`, `RELEASE_COOKIE`, and `METRICS_TOKEN`, and may additionally contain the OAuth credentials described above. Set -`app.emailDeliveryProvider`; UniSender Go API mode also requires -`UNISENDER_GO_API_KEY` in that Secret, while SMTP credentials remain Secret -values in SMTP mode. The chart intentionally has no invented CPU/RAM limits or HPA +`app.emailDeliveryProvider` to `smtp`; keep SMTP credentials in that Secret. +The chart intentionally has no invented CPU/RAM limits or HPA thresholds; measure this application in the target environment before setting them. diff --git a/compose.yaml b/compose.yaml index 9ac50f3..fd5ef13 100644 --- a/compose.yaml +++ b/compose.yaml @@ -24,11 +24,6 @@ x-app-environment: &app-environment SMTP_AUTH: ${SMTP_AUTH:-never} SMTP_TLS: ${SMTP_TLS:-never} SMTP_SSL: ${SMTP_SSL:-false} - UNISENDER_GO_API_KEY: ${UNISENDER_GO_API_KEY:-} - UNISENDER_GO_BASE_URL: ${UNISENDER_GO_BASE_URL:-https://goapi.unisender.ru/ru/transactional/api/v1} - UNISENDER_GO_SKIP_UNSUBSCRIBE: ${UNISENDER_GO_SKIP_UNSUBSCRIBE:-false} - EMAIL_HTTP_CONNECT_TIMEOUT_MS: ${EMAIL_HTTP_CONNECT_TIMEOUT_MS:-} - EMAIL_HTTP_RECEIVE_TIMEOUT_MS: ${EMAIL_HTTP_RECEIVE_TIMEOUT_MS:-} EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:?Set EMAIL_FROM_NAME in .env} EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:?Set EMAIL_FROM_ADDRESS in .env} SUPPORT_INBOX_ADDRESS: ${SUPPORT_INBOX_ADDRESS:-} diff --git a/config/runtime.exs b/config/runtime.exs index 68c10ad..b113d44 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -482,67 +482,8 @@ if config_env() == :prod do end end) - "unisender_go" -> - api_key = - System.get_env("UNISENDER_GO_API_KEY") || - raise "UNISENDER_GO_API_KEY is required when EMAIL_DELIVERY_PROVIDER=unisender_go." - - if api_key == "" do - raise "UNISENDER_GO_API_KEY is required when EMAIL_DELIVERY_PROVIDER=unisender_go." - end - - base_url = - System.get_env( - "UNISENDER_GO_BASE_URL", - "https://goapi.unisender.ru/ru/transactional/api/v1" - ) - |> String.trim_trailing("/") - - uri = URI.parse(base_url) - - unless uri.scheme == "https" and is_binary(uri.host) and uri.host != "" and - is_binary(uri.path) and uri.path != "" and is_nil(uri.userinfo) and - is_nil(uri.query) and is_nil(uri.fragment) do - raise "UNISENDER_GO_BASE_URL must be an HTTPS origin and path without credentials, query, or fragment." - end - - skip_unsubscribe = - case System.get_env("UNISENDER_GO_SKIP_UNSUBSCRIBE", "false") do - "true" -> - true - - "false" -> - false - - other -> - raise "UNISENDER_GO_SKIP_UNSUBSCRIBE must be true or false; got #{inspect(other)}" - end - - client_options = - [] - |> then(fn options -> - case optional_positive_integer.("EMAIL_HTTP_CONNECT_TIMEOUT_MS") do - nil -> options - timeout -> Keyword.put(options, :connect_options, timeout: timeout) - end - end) - |> then(fn options -> - case optional_positive_integer.("EMAIL_HTTP_RECEIVE_TIMEOUT_MS") do - nil -> options - timeout -> Keyword.put(options, :receive_timeout, timeout) - end - end) - - [ - adapter: WhoNeedHelp.Email.UnisenderGoAdapter, - api_key: api_key, - base_url: base_url, - skip_unsubscribe: skip_unsubscribe, - client_options: client_options - ] - other -> - raise "EMAIL_DELIVERY_PROVIDER must be smtp or unisender_go; got #{inspect(other)}" + raise "EMAIL_DELIVERY_PROVIDER must be smtp; got #{inspect(other)}" end config :who_need_help, :handover_secret, handover_secret diff --git a/deploy/helm/who-need-help/templates/deployments.yaml b/deploy/helm/who-need-help/templates/deployments.yaml index 5a6ace3..5b42ac2 100644 --- a/deploy/helm/who-need-help/templates/deployments.yaml +++ b/deploy/helm/who-need-help/templates/deployments.yaml @@ -53,10 +53,6 @@ spec: value: {{ $root.Values.app.clusterInterface | quote }} - name: EMAIL_DELIVERY_PROVIDER value: {{ $root.Values.app.emailDeliveryProvider | quote }} - - name: UNISENDER_GO_BASE_URL - value: {{ $root.Values.app.unisenderGoBaseUrl | quote }} - - name: UNISENDER_GO_SKIP_UNSUBSCRIBE - value: {{ $root.Values.app.unisenderGoSkipUnsubscribe | quote }} securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true @@ -101,10 +97,6 @@ spec: value: {{ $root.Values.app.smtpRelay | quote }} - name: SMTP_PORT value: {{ $root.Values.app.smtpPort | quote }} - - name: UNISENDER_GO_BASE_URL - value: {{ $root.Values.app.unisenderGoBaseUrl | quote }} - - name: UNISENDER_GO_SKIP_UNSUBSCRIBE - value: {{ $root.Values.app.unisenderGoSkipUnsubscribe | quote }} - name: CODEX_SESSION_ID value: {{ $root.Values.app.codexSessionId | quote }} - name: RATE_LIMIT_POLICIES_JSON diff --git a/deploy/helm/who-need-help/templates/migrate-job.yaml b/deploy/helm/who-need-help/templates/migrate-job.yaml index 2a473d5..347a132 100644 --- a/deploy/helm/who-need-help/templates/migrate-job.yaml +++ b/deploy/helm/who-need-help/templates/migrate-job.yaml @@ -45,10 +45,6 @@ spec: value: {{ .Values.worker.pushConcurrency | quote }} - name: EMAIL_DELIVERY_PROVIDER value: {{ .Values.app.emailDeliveryProvider | quote }} - - name: UNISENDER_GO_BASE_URL - value: {{ .Values.app.unisenderGoBaseUrl | quote }} - - name: UNISENDER_GO_SKIP_UNSUBSCRIBE - value: {{ .Values.app.unisenderGoSkipUnsubscribe | quote }} securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true diff --git a/deploy/helm/who-need-help/values.schema.json b/deploy/helm/who-need-help/values.schema.json index c3f1c9c..838ee5f 100644 --- a/deploy/helm/who-need-help/values.schema.json +++ b/deploy/helm/who-need-help/values.schema.json @@ -13,23 +13,13 @@ "emailDeliveryProvider": { "type": "string", "enum": [ - "smtp", - "unisender_go" + "smtp" ] - }, - "unisenderGoBaseUrl": { - "type": "string", - "pattern": "^https://" - }, - "unisenderGoSkipUnsubscribe": { - "type": "boolean" } }, "required": [ "erlangPortLimit", - "emailDeliveryProvider", - "unisenderGoBaseUrl", - "unisenderGoSkipUnsubscribe" + "emailDeliveryProvider" ] } } diff --git a/deploy/helm/who-need-help/values.yaml b/deploy/helm/who-need-help/values.yaml index 720b526..5562e84 100644 --- a/deploy/helm/who-need-help/values.yaml +++ b/deploy/helm/who-need-help/values.yaml @@ -36,8 +36,6 @@ app: emailDeliveryProvider: smtp smtpRelay: mailpit smtpPort: "1025" - unisenderGoBaseUrl: https://goapi.unisender.ru/ru/transactional/api/v1 - unisenderGoSkipUnsubscribe: false # Required. The Secret must contain DATABASE_URL, SECRET_KEY_BASE, # HANDOVER_SECRET, RELEASE_COOKIE, and METRICS_TOKEN. It may also contain both @@ -45,8 +43,7 @@ app: # GitHub linking, and both GOOGLE_OAUTH_CLIENT_ID and # GOOGLE_OAUTH_CLIENT_SECRET to enable Google registration/sign-in. Optional # SUPPORT_INBOX_ADDRESS enables metadata-only operator alerts and Reply-To. -# EMAIL_DELIVERY_PROVIDER=unisender_go additionally requires -# UNISENDER_GO_API_KEY. SMTP mode can keep provider credentials in this Secret. +# SMTP provider credentials can be kept in this Secret. # Push is # opt-in: provide PUSH_HTTP_ENDPOINT, # PUSH_HTTP_BEARER_TOKEN, PUSH_HTTP_MAX_ATTEMPTS, diff --git a/docs/operations.md b/docs/operations.md index 2712dac..20bdf3f 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -91,8 +91,9 @@ the image tags. Existing deployment secrets remain unchanged: ./scripts/deploy-up.sh .env ``` -Test always uses its own `who_need_help_test` PostGIS container/volume and -Mailpit. Its messages cannot be sent by the production UniSender account. +Test always uses its own `who_need_help_test` PostGIS container/volume. Local +development can use Mailpit; a public test deployment must use its own SMTP +password and a visibly test-specific sender identity. Create the production configuration inside the production checkout after loading only the production database/provider credentials into the process: @@ -102,8 +103,11 @@ cd /srv/who_need_help-production PRODUCTION_DATABASE_MODE=external \ PRODUCTION_DATABASE_URL='ecto://PRODUCTION_ROLE:PASSWORD@localhost/who_need_help_production' \ PRODUCTION_DATABASE_SOCKET_DIR=/var/run/postgresql \ -PRODUCTION_EMAIL_DELIVERY_PROVIDER=unisender_go \ -PRODUCTION_UNISENDER_GO_API_KEY=YOUR_PRODUCTION_KEY \ +PRODUCTION_EMAIL_DELIVERY_PROVIDER=smtp \ +PRODUCTION_SMTP_RELAY=smtp.example.net \ +PRODUCTION_SMTP_PORT=587 \ +PRODUCTION_SMTP_USERNAME=YOUR_PRODUCTION_SMTP_LOGIN \ +PRODUCTION_SMTP_PASSWORD=YOUR_PRODUCTION_SMTP_PASSWORD \ PRODUCTION_GOOGLE_OAUTH_CLIENT_ID=YOUR_PRODUCTION_CLIENT_ID \ PRODUCTION_GOOGLE_OAUTH_CLIENT_SECRET=YOUR_PRODUCTION_CLIENT_SECRET \ PRODUCTION_CODEX_SESSION_ID=YOUR_MAIN_CODEX_SESSION_ID \ @@ -155,14 +159,10 @@ edit `HTTP_BIND_ADDRESS` afterward, to match the observed target topology. Replace `TRAEFIK_TRUSTED_IPS` with the exact source IP/CIDR observed at Traefik; do not copy the temporary VPN value into an unrelated server. -Configure the transactional email provider and a sender accepted by it. SMTP -uses `EMAIL_DELIVERY_PROVIDER=smtp` plus the `SMTP_*` settings. UniSender Go's -HTTPS Web API uses `EMAIL_DELIVERY_PROVIDER=unisender_go`, -`UNISENDER_GO_API_KEY`, and the default documented API base URL. The initializer -accepts the corresponding `PRODUCTION_EMAIL_DELIVERY_PROVIDER`, -`PRODUCTION_SMTP_*`, and `PRODUCTION_UNISENDER_GO_*` inputs. Optional -`EMAIL_HTTP_CONNECT_TIMEOUT_MS` and `EMAIL_HTTP_RECEIVE_TIMEOUT_MS` override the -Req/Finch defaults only when deployment measurements justify explicit values. +Configure the transactional SMTP relay and a sender accepted by it using +`EMAIL_DELIVERY_PROVIDER=smtp` plus the `SMTP_*` settings. The initializer +accepts the corresponding `PRODUCTION_EMAIL_DELIVERY_PROVIDER` and +`PRODUCTION_SMTP_*` inputs. Set optional `SUPPORT_INBOX_ADDRESS` to a monitored address for support/removal queue alerts and email `Reply-To`; leaving it empty disables operator email alerts, not the protected queues. If @@ -405,21 +405,10 @@ semantics documented by [GitHub OAuth](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps), and [SMTP RFC 5321](https://datatracker.ietf.org/doc/html/rfc5321). -The optional `WhoNeedHelp.Email.UnisenderGoAdapter` maps the same Swoosh email -objects to UniSender Go's documented JSON contract, supplies a per-delivery -idempotence key, redacts rejected recipient addresses from returned errors, and -uses only HTTPS. The provider documents the endpoint, `X-API-KEY` header, -response shape, and one-minute/64-character idempotence-key behavior in its -[Web API reference](https://godocs.unisender.ru/web-api-ref). -Every request explicitly sets `track_links` and `track_read` to `0`; UniSender -Go otherwise enables both by default and may require account-level approval -before it honors those values. -`UNISENDER_GO_SKIP_UNSUBSCRIBE` defaults to `false` and must remain disabled -until UniSender Go support explicitly enables that transactional capability for -the account. After approval, set it to `true` to add `skip_unsubscribe=1` to the -provider request. -Verify a delivered message's raw HTML after the provider confirms the account -change and before launch. +The application uses Swoosh's SMTP adapter and `gen_smtp`. Verify the selected +relay's authenticated delivery, sender-domain authentication, and delivered +message headers before launch. Test and production may share an account-level +SMTP login only when they use independently revocable SMTP passwords. ## Isolated restore drill diff --git a/docs/verification.md b/docs/verification.md index f2675ed..6446a20 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -1144,7 +1144,12 @@ These checks establish the mode wiring on this workstation. They do not prove the future provider's TLS/CA policy, network reachability, backup service, high availability, or target-server capacity. -## UniSender Go HTTPS delivery boundary on 2026-07-21 +## Retired UniSender Go delivery boundary observed on 2026-07-21 + +This section is retained only as historical evidence. The adapter, runtime +mode, environment variables, Compose wiring, Helm wiring, and deployment +instructions were removed when both public environments migrated to SMTP. +None of the observations below describe the current delivery path. - The production server returned HTTP 200 and a successful authenticated result from UniSender Go's `system/ping` Web API method. The API key was loaded from diff --git a/lib/who_need_help/email/unisender_go_adapter.ex b/lib/who_need_help/email/unisender_go_adapter.ex deleted file mode 100644 index 019fd9b..0000000 --- a/lib/who_need_help/email/unisender_go_adapter.ex +++ /dev/null @@ -1,238 +0,0 @@ -defmodule WhoNeedHelp.Email.UnisenderGoAdapter do - @moduledoc """ - Swoosh adapter for the UniSender Go transactional Web API. - - The adapter deliberately accepts the regular `Swoosh.Email` structure used - by the rest of the application. Delivery therefore remains provider-neutral - and can be switched back to SMTP through runtime configuration. - """ - - @behaviour Swoosh.Adapter - - alias Swoosh.Email - - @default_base_url "https://goapi.unisender.ru/ru/transactional/api/v1" - @send_path "/email/send.json" - @max_recipients 500 - @max_headers 50 - @max_idempotence_key_length 64 - - @impl true - def validate_config(config) do - validate_api_key!(Keyword.get(config, :api_key)) - validate_base_url!(Keyword.get(config, :base_url, @default_base_url)) - validate_skip_unsubscribe!(Keyword.get(config, :skip_unsubscribe, false)) - :ok - end - - @impl true - def validate_dependency do - Swoosh.Adapter.validate_dependency([Req, Jason]) - end - - @impl true - def deliver(%Email{} = email, config) do - with :ok <- validate_supported_email(email), - {:ok, body} <- encode_payload(email, config), - {:ok, status, _headers, response_body} <- - Swoosh.ApiClient.post( - endpoint(config), - request_headers(Keyword.fetch!(config, :api_key)), - body, - with_client_options(email, config) - ) do - decode_response(status, response_body) - end - end - - defp validate_supported_email(%Email{to: []}), do: {:error, :to_not_set} - - defp validate_supported_email(%Email{from: nil}), do: {:error, :from_not_set} - - defp validate_supported_email(%Email{subject: subject}) when subject in [nil, ""], - do: {:error, :subject_not_set} - - defp validate_supported_email(%Email{to: recipients}) - when length(recipients) > @max_recipients, - do: {:error, {:too_many_recipients, @max_recipients}} - - defp validate_supported_email(%Email{cc: [_ | _]}), - do: {:error, {:unsupported_email_feature, :cc}} - - defp validate_supported_email(%Email{bcc: [_ | _]}), - do: {:error, {:unsupported_email_feature, :bcc}} - - defp validate_supported_email(%Email{attachments: [_ | _]}), - do: {:error, {:unsupported_email_feature, :attachments}} - - defp validate_supported_email(%Email{reply_to: reply_to}) when is_list(reply_to), - do: {:error, {:unsupported_email_feature, :multiple_reply_to}} - - defp validate_supported_email(%Email{text_body: nil, html_body: nil}), - do: {:error, :body_not_set} - - defp validate_supported_email(%Email{headers: headers}) when map_size(headers) > @max_headers, - do: {:error, {:too_many_headers, @max_headers}} - - defp validate_supported_email(%Email{provider_options: %{idempotence_key: key}}) - when not is_binary(key) or key == "", - do: {:error, :invalid_idempotence_key} - - defp validate_supported_email(%Email{provider_options: %{idempotence_key: key}}) - when byte_size(key) > @max_idempotence_key_length, - do: {:error, {:idempotence_key_too_long, @max_idempotence_key_length}} - - defp validate_supported_email(%Email{}), do: :ok - - defp encode_payload(%Email{} = email, config) do - payload = - %{ - "message" => - %{ - "recipients" => Enum.map(email.to, &recipient/1), - "subject" => email.subject, - "from_email" => elem(email.from, 1), - "from_name" => elem(email.from, 0), - "body" => message_body(email), - "track_links" => 0, - "track_read" => 0, - "idempotence_key" => idempotence_key(email) - } - |> maybe_put("skip_unsubscribe", 1, Keyword.get(config, :skip_unsubscribe, false)) - |> maybe_put_reply_to(email.reply_to) - |> maybe_put_headers(email.headers) - } - - Jason.encode(payload) - end - - defp recipient({_name, address}), do: %{"email" => address} - - defp message_body(%Email{text_body: text_body, html_body: html_body}) do - %{} - |> maybe_put("plaintext", text_body) - |> maybe_put("html", html_body) - end - - defp maybe_put_reply_to(message, nil), do: message - - defp maybe_put_reply_to(message, {name, address}) do - message - |> Map.put("reply_to", address) - |> maybe_put("reply_to_name", name) - end - - defp maybe_put_headers(message, headers) do - allowed_headers = - Map.new(headers, fn {name, value} -> {to_string(name), to_string(value)} end) - |> Map.filter(fn {name, _value} -> String.starts_with?(String.downcase(name), "x-") end) - - maybe_put(message, "headers", allowed_headers, allowed_headers != %{}) - end - - defp maybe_put(map, _key, nil), do: map - defp maybe_put(map, key, value), do: Map.put(map, key, value) - defp maybe_put(map, key, value, true), do: Map.put(map, key, value) - defp maybe_put(map, _key, _value, false), do: map - - defp idempotence_key(%Email{provider_options: %{idempotence_key: key}}) - when is_binary(key) and key != "", - do: key - - defp idempotence_key(_email) do - :crypto.strong_rand_bytes(24) - |> Base.url_encode64(padding: false) - end - - defp request_headers(api_key) do - [ - {"Accept", "application/json"}, - {"Content-Type", "application/json"}, - {"X-API-KEY", api_key} - ] - end - - defp endpoint(config) do - config - |> Keyword.get(:base_url, @default_base_url) - |> String.trim_trailing("/") - |> Kernel.<>(@send_path) - end - - defp with_client_options(email, config) do - configured_options = Keyword.get(config, :client_options, []) - existing_options = Map.get(email.private, :client_options, []) - - Swoosh.Email.put_private( - email, - :client_options, - Keyword.merge(configured_options, existing_options) - ) - end - - defp decode_response(200, response_body) do - case Jason.decode(response_body) do - {:ok, %{"status" => "success"} = response} -> success_response(response) - {:ok, response} -> {:error, api_error(200, response)} - {:error, _reason} -> {:error, {:invalid_unisender_go_response, 200}} - end - end - - defp decode_response(status, response_body) do - case Jason.decode(response_body) do - {:ok, response} -> {:error, api_error(status, response)} - {:error, _reason} -> {:error, {:invalid_unisender_go_response, status}} - end - end - - defp success_response(%{"failed_emails" => failed_emails}) - when is_map(failed_emails) and map_size(failed_emails) > 0 do - reasons = failed_emails |> Map.values() |> Enum.frequencies() - {:error, {:unisender_go_recipient_rejected, reasons}} - end - - defp success_response(response) do - {:ok, - %{ - provider: :unisender_go, - job_id: Map.get(response, "job_id"), - accepted_count: response |> Map.get("emails", []) |> length() - }} - end - - defp api_error(status, response) when is_map(response) do - {:unisender_go_api_error, status, Map.get(response, "code")} - end - - defp api_error(status, _response), do: {:unisender_go_api_error, status, nil} - - defp validate_api_key!(api_key) when is_binary(api_key) do - if String.trim(api_key) == "" do - raise ArgumentError, "UniSender Go API key is missing" - end - end - - defp validate_api_key!(_api_key) do - raise ArgumentError, "UniSender Go API key is missing" - end - - defp validate_base_url!(base_url) when is_binary(base_url) do - uri = URI.parse(base_url) - - unless uri.scheme == "https" and is_binary(uri.host) and uri.host != "" and - is_binary(uri.path) and uri.path != "" and is_nil(uri.userinfo) and - is_nil(uri.query) and is_nil(uri.fragment) do - raise ArgumentError, "UniSender Go base URL must be an HTTPS URL without credentials" - end - end - - defp validate_base_url!(_base_url) do - raise ArgumentError, "UniSender Go base URL must be an HTTPS URL without credentials" - end - - defp validate_skip_unsubscribe!(value) when is_boolean(value), do: :ok - - defp validate_skip_unsubscribe!(_value) do - raise ArgumentError, "UniSender Go skip_unsubscribe must be a boolean" - end -end diff --git a/scripts/init-production-env.sh b/scripts/init-production-env.sh index a903711..6da31c7 100755 --- a/scripts/init-production-env.sh +++ b/scripts/init-production-env.sh @@ -135,32 +135,13 @@ smtp_password=${PRODUCTION_SMTP_PASSWORD:-} smtp_auth=${PRODUCTION_SMTP_AUTH:-always} smtp_tls=${PRODUCTION_SMTP_TLS:-always} smtp_ssl=${PRODUCTION_SMTP_SSL:-false} -unisender_go_api_key=${PRODUCTION_UNISENDER_GO_API_KEY:-} -unisender_go_base_url=${PRODUCTION_UNISENDER_GO_BASE_URL:-https://goapi.unisender.ru/ru/transactional/api/v1} -unisender_go_skip_unsubscribe=${PRODUCTION_UNISENDER_GO_SKIP_UNSUBSCRIBE:-false} -email_http_connect_timeout_ms=${PRODUCTION_EMAIL_HTTP_CONNECT_TIMEOUT_MS:-} -email_http_receive_timeout_ms=${PRODUCTION_EMAIL_HTTP_RECEIVE_TIMEOUT_MS:-} email_from_address=${PRODUCTION_EMAIL_FROM_ADDRESS:-"contact@$domain"} support_inbox_address=${PRODUCTION_SUPPORT_INBOX_ADDRESS:-} -case "$email_delivery_provider" in - smtp) ;; - unisender_go) - if [ -z "$unisender_go_api_key" ]; then - echo "PRODUCTION_UNISENDER_GO_API_KEY is required for the unisender_go email provider." >&2 - exit 1 - fi - ;; - *) - echo "PRODUCTION_EMAIL_DELIVERY_PROVIDER must be smtp or unisender_go." >&2 - exit 1 - ;; -esac - -case "$unisender_go_skip_unsubscribe" in - true | false) ;; - *) echo "PRODUCTION_UNISENDER_GO_SKIP_UNSUBSCRIBE must be true or false." >&2; exit 1 ;; -esac +[ "$email_delivery_provider" = smtp ] || { + echo "PRODUCTION_EMAIL_DELIVERY_PROVIDER must be smtp." >&2 + exit 1 +} tmp=$(mktemp "$target_dir/.production-env.XXXXXX") trap 'rm -f "$tmp"' EXIT HUP INT TERM @@ -192,11 +173,6 @@ SMTP_PASSWORD_VALUE=$smtp_password \ SMTP_AUTH_VALUE=$smtp_auth \ SMTP_TLS_VALUE=$smtp_tls \ SMTP_SSL_VALUE=$smtp_ssl \ -UNISENDER_GO_API_KEY_VALUE=$unisender_go_api_key \ -UNISENDER_GO_BASE_URL_VALUE=$unisender_go_base_url \ -UNISENDER_GO_SKIP_UNSUBSCRIBE_VALUE=$unisender_go_skip_unsubscribe \ -EMAIL_HTTP_CONNECT_TIMEOUT_MS_VALUE=$email_http_connect_timeout_ms \ -EMAIL_HTTP_RECEIVE_TIMEOUT_MS_VALUE=$email_http_receive_timeout_ms \ EMAIL_FROM_ADDRESS_VALUE=$email_from_address \ SUPPORT_INBOX_ADDRESS_VALUE=$support_inbox_address \ CODEX_SESSION_ID_VALUE=$codex_session_id \ @@ -254,11 +230,6 @@ TEST_UPSTREAM_VALUE=$test_upstream \ replacement["SMTP_AUTH"] = ENVIRON["SMTP_AUTH_VALUE"] replacement["SMTP_TLS"] = ENVIRON["SMTP_TLS_VALUE"] replacement["SMTP_SSL"] = ENVIRON["SMTP_SSL_VALUE"] - replacement["UNISENDER_GO_API_KEY"] = ENVIRON["UNISENDER_GO_API_KEY_VALUE"] - replacement["UNISENDER_GO_BASE_URL"] = ENVIRON["UNISENDER_GO_BASE_URL_VALUE"] - replacement["UNISENDER_GO_SKIP_UNSUBSCRIBE"] = ENVIRON["UNISENDER_GO_SKIP_UNSUBSCRIBE_VALUE"] - replacement["EMAIL_HTTP_CONNECT_TIMEOUT_MS"] = ENVIRON["EMAIL_HTTP_CONNECT_TIMEOUT_MS_VALUE"] - replacement["EMAIL_HTTP_RECEIVE_TIMEOUT_MS"] = ENVIRON["EMAIL_HTTP_RECEIVE_TIMEOUT_MS_VALUE"] replacement["EMAIL_FROM_ADDRESS"] = ENVIRON["EMAIL_FROM_ADDRESS_VALUE"] replacement["SUPPORT_INBOX_ADDRESS"] = ENVIRON["SUPPORT_INBOX_ADDRESS_VALUE"] replacement["GOOGLE_OAUTH_CLIENT_ID"] = ENVIRON["GOOGLE_OAUTH_CLIENT_ID_VALUE"] @@ -282,7 +253,7 @@ chmod 600 "$target" trap - EXIT HUP INT TERM unset postgres_password secret_key_base handover_secret release_cookie metrics_token -unset smtp_password unisender_go_api_key +unset smtp_password unset google_oauth_client_secret echo "Generated independent deployment secrets without printing them." diff --git a/scripts/quality.sh b/scripts/quality.sh index c38a4fc..5a8025e 100755 --- a/scripts/quality.sh +++ b/scripts/quality.sh @@ -178,67 +178,30 @@ cp "$production_env" "$production_checkout/.env" chmod 600 "$test_checkout/.env" "$production_checkout/.env" ./scripts/validate-deployment-isolation.sh \ "$test_checkout" "$production_checkout" >/dev/null -api_production_env="$scan_dir/.env.production.unisender-go" -PRODUCTION_EMAIL_DELIVERY_PROVIDER=unisender_go \ -PRODUCTION_UNISENDER_GO_API_KEY=quality-unisender-go-api-key \ -PRODUCTION_EMAIL_FROM_ADDRESS=contact@help.test \ -PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ - ./scripts/init-production-env.sh help.test "$api_production_env" >/dev/null -./scripts/validate-production-env.sh "$api_production_env" help.test >/dev/null -grep -Fx 'EMAIL_DELIVERY_PROVIDER=unisender_go' "$api_production_env" >/dev/null -grep -Fx 'UNISENDER_GO_SKIP_UNSUBSCRIBE=false' "$api_production_env" >/dev/null -api_test_env="$scan_dir/.env.test.unisender-go" -cp "$test_env" "$api_test_env" -chmod 600 "$api_test_env" + +# Account-level SMTP logins may be shared by a relay, but the independently +# revocable SMTP passwords must remain isolated between deployments. +production_smtp_relay=$(awk -F= '$1 == "SMTP_RELAY" { print substr($0, index($0, "=") + 1); exit }' "$production_checkout/.env") +production_smtp_username=$(awk -F= '$1 == "SMTP_USERNAME" { print substr($0, index($0, "=") + 1); exit }' "$production_checkout/.env") +production_smtp_password=$(awk -F= '$1 == "SMTP_PASSWORD" { print substr($0, index($0, "=") + 1); exit }' "$production_checkout/.env") sed -i \ - -e 's/^EMAIL_DELIVERY_PROVIDER=.*/EMAIL_DELIVERY_PROVIDER=unisender_go/' \ - -e 's/^UNISENDER_GO_API_KEY=.*/UNISENDER_GO_API_KEY=quality-test-unisender-go-api-key/' \ - "$api_test_env" -./scripts/validate-test-env.sh "$api_test_env" test.help.test >/dev/null -if ./scripts/compose.sh "$api_test_env" config --services | grep -Fx mailpit >/dev/null; then - echo "External test email configuration unexpectedly starts Mailpit." >&2 - exit 1 -fi -cp "$api_test_env" "$test_checkout/.env" -cp "$api_production_env" "$production_checkout/.env" -chmod 600 "$test_checkout/.env" "$production_checkout/.env" + -e 's/^EMAIL_DELIVERY_PROVIDER=.*/EMAIL_DELIVERY_PROVIDER=smtp/' \ + -e "s|^SMTP_RELAY=.*|SMTP_RELAY=$production_smtp_relay|" \ + -e "s|^SMTP_USERNAME=.*|SMTP_USERNAME=$production_smtp_username|" \ + -e 's/^SMTP_PASSWORD=.*/SMTP_PASSWORD=quality-test-isolated-smtp-password/' \ + "$test_checkout/.env" ./scripts/validate-deployment-isolation.sh \ "$test_checkout" "$production_checkout" >/dev/null sed -i \ - 's/^UNISENDER_GO_API_KEY=.*/UNISENDER_GO_API_KEY=quality-unisender-go-api-key/' \ + "s|^SMTP_PASSWORD=.*|SMTP_PASSWORD=$production_smtp_password|" \ "$test_checkout/.env" if ./scripts/validate-deployment-isolation.sh \ "$test_checkout" "$production_checkout" >/dev/null 2>&1; then - echo "Deployment isolation accepted a shared UniSender Go API key." >&2 - exit 1 -fi -api_override_env="$scan_dir/.env.production.unisender-go-skip-unsubscribe" -PRODUCTION_EMAIL_DELIVERY_PROVIDER=unisender_go \ -PRODUCTION_UNISENDER_GO_API_KEY=quality-unisender-go-api-key \ -PRODUCTION_UNISENDER_GO_SKIP_UNSUBSCRIBE=true \ -PRODUCTION_EMAIL_FROM_ADDRESS=contact@help.test \ -PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ - ./scripts/init-production-env.sh help.test "$api_override_env" >/dev/null -./scripts/validate-production-env.sh "$api_override_env" help.test >/dev/null -grep -Fx 'UNISENDER_GO_SKIP_UNSUBSCRIBE=true' "$api_override_env" >/dev/null -if PRODUCTION_EMAIL_DELIVERY_PROVIDER=unisender_go \ - PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ - ./scripts/init-production-env.sh \ - help.test "$scan_dir/.env.production.missing-api-key" >/dev/null 2>&1; then - echo "Production initializer accepted UniSender Go without an API key." >&2 - exit 1 -fi -invalid_api_url_env="$scan_dir/.env.production.invalid-api-url" -cp "$api_production_env" "$invalid_api_url_env" -chmod 600 "$invalid_api_url_env" -sed -i \ - 's#^UNISENDER_GO_BASE_URL=.*#UNISENDER_GO_BASE_URL=http://goapi.example.test/v1#' \ - "$invalid_api_url_env" -if ./scripts/validate-production-env.sh \ - "$invalid_api_url_env" help.test >/dev/null 2>&1; then - echo "Production validator accepted a non-HTTPS UniSender Go API URL." >&2 + echo "Deployment isolation accepted a shared SMTP password." >&2 exit 1 fi +cp "$test_env" "$test_checkout/.env" +chmod 600 "$test_checkout/.env" placeholder_codex_env="$scan_dir/.env.production.placeholder-codex" cp "$production_env" "$placeholder_codex_env" chmod 600 "$placeholder_codex_env" @@ -367,12 +330,6 @@ docker compose --project-name who_need_help_edge \ ' >/dev/null ./scripts/compose.sh .env.example config --quiet ./scripts/compose.sh "$production_env" config --quiet -./scripts/compose.sh "$api_production_env" config --quiet -./scripts/compose.sh "$api_override_env" config --format json | - jq --exit-status ' - .services.app.environment.UNISENDER_GO_SKIP_UNSUBSCRIBE == "true" and - .services.migrate.environment.UNISENDER_GO_SKIP_UNSUBSCRIBE == "true" - ' >/dev/null ./scripts/compose.sh "$external_production_env" config --quiet ./scripts/compose.sh "$external_socket_production_env" config --quiet ./scripts/compose.sh "$external_split_production_env" config --quiet @@ -611,29 +568,12 @@ tar --extract --file="$scan_tar" --directory "$scan_dir" --values "$ROOT/deploy/helm/who-need-help/values-kind.yaml" \ "$ROOT/deploy/helm/who-need-help" \ >"$scan_dir/rendered-helm.yaml" -"$ROOT/.tools/bin/helm" template who-need-help \ - --values "$ROOT/deploy/helm/who-need-help/values-kind.yaml" \ - --set app.unisenderGoSkipUnsubscribe=true \ - "$ROOT/deploy/helm/who-need-help" \ - >"$scan_dir/rendered-helm-unisender-override.yaml" test "$( grep -c 'name: ERL_ZFLAGS' "$scan_dir/rendered-helm.yaml" )" -eq 5 test "$( grep -c 'value: "+Q 65536"' "$scan_dir/rendered-helm.yaml" )" -eq 5 -test "$( - grep -c 'name: UNISENDER_GO_SKIP_UNSUBSCRIBE' "$scan_dir/rendered-helm.yaml" -)" -eq 5 -test "$( - grep -c 'name: UNISENDER_GO_SKIP_UNSUBSCRIBE' \ - "$scan_dir/rendered-helm-unisender-override.yaml" -)" -eq 5 -test "$( - grep -A1 'name: UNISENDER_GO_SKIP_UNSUBSCRIBE' \ - "$scan_dir/rendered-helm-unisender-override.yaml" | - grep -c 'value: "true"' -)" -eq 5 test "$( grep -c '^kind: NetworkPolicy$' "$scan_dir/rendered-helm.yaml" )" -eq 1 diff --git a/scripts/validate-deployment-isolation.sh b/scripts/validate-deployment-isolation.sh index ce67b3c..92d12c4 100755 --- a/scripts/validate-deployment-isolation.sh +++ b/scripts/validate-deployment-isolation.sh @@ -97,16 +97,12 @@ fi test_email_provider=$(read_env "$test_env" EMAIL_DELIVERY_PROVIDER) production_email_provider=$(read_env "$production_env" EMAIL_DELIVERY_PROVIDER) -if [[ "$test_email_provider" == unisender_go && - "$production_email_provider" == unisender_go ]]; then - require_different UNISENDER_GO_API_KEY -fi - if [[ "$test_email_provider" == smtp && "$(read_env "$test_env" SMTP_RELAY)" != mailpit && "$production_email_provider" == smtp && "$(read_env "$test_env" SMTP_RELAY)" == "$(read_env "$production_env" SMTP_RELAY)" ]]; then - require_different SMTP_USERNAME + # Some relays (including Brevo) issue one account-level SMTP login and + # isolate applications with independently revocable SMTP passwords. require_different SMTP_PASSWORD fi [[ "$(read_env "$production_env" EMAIL_DELIVERY_PROVIDER)" != smtp || diff --git a/scripts/validate-production-env.sh b/scripts/validate-production-env.sh index 6186d7b..4b836d9 100755 --- a/scripts/validate-production-env.sh +++ b/scripts/validate-production-env.sh @@ -106,11 +106,6 @@ smtp_password=$(optional_value SMTP_PASSWORD) smtp_auth=$(optional_value SMTP_AUTH) smtp_tls=$(optional_value SMTP_TLS) smtp_ssl=$(optional_value SMTP_SSL) -unisender_go_api_key=$(optional_value UNISENDER_GO_API_KEY) -unisender_go_base_url=$(optional_value UNISENDER_GO_BASE_URL) -unisender_go_skip_unsubscribe=$(optional_value UNISENDER_GO_SKIP_UNSUBSCRIBE) -email_http_connect_timeout_ms=$(optional_value EMAIL_HTTP_CONNECT_TIMEOUT_MS) -email_http_receive_timeout_ms=$(optional_value EMAIL_HTTP_RECEIVE_TIMEOUT_MS) email_from_address=$(require_value EMAIL_FROM_ADDRESS) support_inbox_address=$(optional_value SUPPORT_INBOX_ADDRESS) google_oauth_client_id=$(optional_value GOOGLE_OAUTH_CLIENT_ID) @@ -296,38 +291,11 @@ case "$email_delivery_provider" in exit 1 fi ;; - unisender_go) - [[ -n "$unisender_go_api_key" ]] || { - echo "UNISENDER_GO_API_KEY is required when EMAIL_DELIVERY_PROVIDER=unisender_go." >&2 - exit 1 - } - reject_marker UNISENDER_GO_API_KEY "$unisender_go_api_key" - [[ "$unisender_go_base_url" =~ ^https://[^/@?#[:space:]]+(/[^?#[:space:]]*)?$ ]] || { - echo "UNISENDER_GO_BASE_URL must be an HTTPS origin and path without credentials, query, or fragment." >&2 - exit 1 - } - reject_marker UNISENDER_GO_BASE_URL "$unisender_go_base_url" - [[ "$unisender_go_skip_unsubscribe" =~ ^(true|false)$ ]] || { - echo "UNISENDER_GO_SKIP_UNSUBSCRIBE must be true or false." >&2 - exit 1 - } - ;; *) - echo "EMAIL_DELIVERY_PROVIDER must be smtp or unisender_go." >&2 + echo "EMAIL_DELIVERY_PROVIDER must be smtp." >&2 exit 1 ;; esac - -for timeout_pair in \ - "EMAIL_HTTP_CONNECT_TIMEOUT_MS:$email_http_connect_timeout_ms" \ - "EMAIL_HTTP_RECEIVE_TIMEOUT_MS:$email_http_receive_timeout_ms" -do - timeout_value=${timeout_pair#*:} - if [[ -n "$timeout_value" && ! "$timeout_value" =~ ^[1-9][0-9]*$ ]]; then - echo "${timeout_pair%%:*} must be a positive integer when configured." >&2 - exit 1 - fi -done [[ "$email_from_address" == *@* ]] || { echo "EMAIL_FROM_ADDRESS is not an email address." >&2 exit 1 diff --git a/scripts/validate-test-env.sh b/scripts/validate-test-env.sh index 726f45d..3b8312b 100755 --- a/scripts/validate-test-env.sh +++ b/scripts/validate-test-env.sh @@ -99,17 +99,8 @@ case "$email_delivery_provider" in fi ;; - unisender_go) - require_value UNISENDER_GO_API_KEY >/dev/null - unisender_base_url=$(require_value UNISENDER_GO_BASE_URL) - [[ "$unisender_base_url" == https://* ]] || { - echo "UNISENDER_GO_BASE_URL must use HTTPS." >&2 - exit 1 - } - ;; - *) - echo "EMAIL_DELIVERY_PROVIDER must be smtp or unisender_go." >&2 + echo "EMAIL_DELIVERY_PROVIDER must be smtp." >&2 exit 1 ;; esac diff --git a/test/who_need_help/email/unisender_go_adapter_test.exs b/test/who_need_help/email/unisender_go_adapter_test.exs deleted file mode 100644 index 1a65843..0000000 --- a/test/who_need_help/email/unisender_go_adapter_test.exs +++ /dev/null @@ -1,221 +0,0 @@ -defmodule WhoNeedHelp.Email.UnisenderGoAdapterTest do - use ExUnit.Case, async: false - - import Swoosh.Email - - alias WhoNeedHelp.Email.UnisenderGoAdapter - - setup do - previous_client = Application.get_env(:swoosh, :api_client) - Application.put_env(:swoosh, :api_client, Swoosh.ApiClient.Req) - - on_exit(fn -> - if previous_client do - Application.put_env(:swoosh, :api_client, previous_client) - else - Application.delete_env(:swoosh, :api_client) - end - end) - - :ok - end - - test "delivers a Swoosh email through the documented HTTPS JSON contract" do - owner = self() - - plug = fn conn -> - {:ok, request_body, conn} = Plug.Conn.read_body(conn) - send(owner, {:request, conn, Jason.decode!(request_body)}) - - response = - Jason.encode!(%{ - "status" => "success", - "job_id" => "job-123", - "emails" => ["helper@example.com"] - }) - - conn - |> Plug.Conn.put_resp_content_type("application/json") - |> Plug.Conn.send_resp(200, response) - end - - email = - new() - |> from({"Who Need Help", "contact@whoneedhelp.com"}) - |> to("helper@example.com") - |> reply_to({"Support", "support@whoneedhelp.com"}) - |> subject("Confirmation instructions") - |> text_body("Open the confirmation link") - |> html_body("

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", - "track_links" => 0, - "track_read" => 0 - } - } - end - - test "returns a redacted recipient rejection without exposing addresses" do - plug = fn conn -> - response = - Jason.encode!(%{ - "status" => "success", - "job_id" => "job-123", - "failed_emails" => %{"private@example.com" => "unsubscribed"} - }) - - Plug.Conn.send_resp(conn, 200, response) - end - - assert {:error, {:unisender_go_recipient_rejected, %{"unsubscribed" => 1}}} = - UnisenderGoAdapter.deliver(email(plug), api_key: "secret-api-key") - end - - test "adds skip_unsubscribe only after the provider capability is explicitly enabled" do - owner = self() - - plug = fn conn -> - {:ok, request_body, conn} = Plug.Conn.read_body(conn) - send(owner, {:request, Jason.decode!(request_body)}) - - Plug.Conn.send_resp( - conn, - 200, - Jason.encode!(%{ - "status" => "success", - "job_id" => "job-123", - "emails" => ["helper@example.com"] - }) - ) - end - - assert {:ok, _response} = - UnisenderGoAdapter.deliver(email(plug), - api_key: "secret-api-key", - skip_unsubscribe: true - ) - - assert_receive {:request, %{"message" => %{"skip_unsubscribe" => 1}}} - end - - test "returns structured API and invalid-response failures" do - error_plug = fn conn -> - Plug.Conn.send_resp( - conn, - 401, - Jason.encode!(%{"status" => "error", "code" => 101, "message" => "invalid API key"}) - ) - end - - assert {:error, {:unisender_go_api_error, 401, 101}} = - UnisenderGoAdapter.deliver(email(error_plug), api_key: "secret-api-key") - - invalid_plug = fn conn -> Plug.Conn.send_resp(conn, 502, "not-json") end - - assert {:error, {:invalid_unisender_go_response, 502}} = - UnisenderGoAdapter.deliver(email(invalid_plug), api_key: "secret-api-key") - end - - test "rejects unsupported fields before making a request" do - email = - new() - |> from("contact@whoneedhelp.com") - |> to("helper@example.com") - |> cc("operator@example.com") - |> subject("Test") - |> text_body("body") - - assert {:error, {:unsupported_email_feature, :cc}} = - UnisenderGoAdapter.deliver(email, api_key: "secret-api-key") - end - - test "rejects malformed or provider-incompatible messages before making a request" do - assert {:error, :from_not_set} = - new() - |> to("helper@example.com") - |> subject("Test") - |> text_body("Body") - |> UnisenderGoAdapter.deliver(api_key: "secret-api-key") - - assert {:error, :subject_not_set} = - new() - |> from("contact@whoneedhelp.com") - |> to("helper@example.com") - |> text_body("Body") - |> UnisenderGoAdapter.deliver(api_key: "secret-api-key") - - too_many_recipients = List.duplicate("helper@example.com", 501) - - assert {:error, {:too_many_recipients, 500}} = - new() - |> from("contact@whoneedhelp.com") - |> to(too_many_recipients) - |> subject("Test") - |> text_body("Body") - |> UnisenderGoAdapter.deliver(api_key: "secret-api-key") - - assert {:error, {:idempotence_key_too_long, 64}} = - email(fn conn -> Plug.Conn.send_resp(conn, 500, "not reached") end) - |> put_provider_option(:idempotence_key, String.duplicate("x", 65)) - |> UnisenderGoAdapter.deliver(api_key: "secret-api-key") - end - - test "validates the API key without including the adapter config in the exception" do - assert_raise ArgumentError, "UniSender Go API key is missing", fn -> - UnisenderGoAdapter.validate_config(api_key: "") - end - - assert_raise ArgumentError, - "UniSender Go base URL must be an HTTPS URL without credentials", - fn -> - UnisenderGoAdapter.validate_config( - api_key: "secret-api-key", - base_url: "http://secret@example.test/api?key=secret" - ) - end - - assert_raise ArgumentError, "UniSender Go skip_unsubscribe must be a boolean", fn -> - UnisenderGoAdapter.validate_config( - api_key: "secret-api-key", - skip_unsubscribe: "true" - ) - end - end - - defp email(plug) do - new() - |> from({"Who Need Help", "contact@whoneedhelp.com"}) - |> to("helper@example.com") - |> subject("Test") - |> text_body("Body") - |> put_private(:client_options, plug: plug, retry: false) - end -end