diff --git a/.env.example b/.env.example index 5b8d91a..daa8a84 100644 --- a/.env.example +++ b/.env.example @@ -140,6 +140,8 @@ 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= diff --git a/config/runtime.exs b/config/runtime.exs index d350cc8..46deac0 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -500,6 +500,18 @@ if config_env() == :prod 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 -> @@ -519,6 +531,7 @@ if config_env() == :prod do adapter: WhoNeedHelp.Email.UnisenderGoAdapter, api_key: api_key, base_url: base_url, + skip_unsubscribe: skip_unsubscribe, client_options: client_options ] diff --git a/docs/operations.md b/docs/operations.md index a077a75..94f1028 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -388,8 +388,14 @@ 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. The provider account must be allowed to -honour those values, so verify a delivered message's raw HTML before launch. +Go otherwise enables both by default and may require account-level approval +before it honors those values. +`UNISENDER_GO_SKIP_UNSUBSCRIBE` defaults to `false` and must remain disabled +until UniSender Go support explicitly enables that transactional capability for +the account. After approval, set it to `true` to add `skip_unsubscribe=1` to the +provider request. +Verify a delivered message's raw HTML after the provider confirms the account +change and before launch. ## Isolated restore drill diff --git a/lib/who_need_help/email/unisender_go_adapter.ex b/lib/who_need_help/email/unisender_go_adapter.ex index 66779b8..019fd9b 100644 --- a/lib/who_need_help/email/unisender_go_adapter.ex +++ b/lib/who_need_help/email/unisender_go_adapter.ex @@ -21,6 +21,7 @@ defmodule WhoNeedHelp.Email.UnisenderGoAdapter do 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 @@ -32,7 +33,7 @@ defmodule WhoNeedHelp.Email.UnisenderGoAdapter do @impl true def deliver(%Email{} = email, config) do with :ok <- validate_supported_email(email), - {:ok, body} <- encode_payload(email), + {:ok, body} <- encode_payload(email, config), {:ok, status, _headers, response_body} <- Swoosh.ApiClient.post( endpoint(config), @@ -83,7 +84,7 @@ defmodule WhoNeedHelp.Email.UnisenderGoAdapter do defp validate_supported_email(%Email{}), do: :ok - defp encode_payload(%Email{} = email) do + defp encode_payload(%Email{} = email, config) do payload = %{ "message" => @@ -97,6 +98,7 @@ defmodule WhoNeedHelp.Email.UnisenderGoAdapter do "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) } @@ -227,4 +229,10 @@ defmodule WhoNeedHelp.Email.UnisenderGoAdapter do 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 8a3e69c..27a0297 100755 --- a/scripts/init-production-env.sh +++ b/scripts/init-production-env.sh @@ -125,6 +125,7 @@ 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"} @@ -144,6 +145,11 @@ case "$email_delivery_provider" in ;; esac +case "$unisender_go_skip_unsubscribe" in + true | false) ;; + *) echo "PRODUCTION_UNISENDER_GO_SKIP_UNSUBSCRIBE must be true or false." >&2; exit 1 ;; +esac + tmp=$(mktemp "$target_dir/.production-env.XXXXXX") trap 'rm -f "$tmp"' EXIT HUP INT TERM chmod 600 "$tmp" @@ -176,6 +182,7 @@ 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 \ @@ -219,6 +226,7 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \ 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"] diff --git a/scripts/validate-production-env.sh b/scripts/validate-production-env.sh index 0502a79..275aff4 100755 --- a/scripts/validate-production-env.sh +++ b/scripts/validate-production-env.sh @@ -103,6 +103,7 @@ 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) @@ -275,6 +276,10 @@ case "$email_delivery_provider" in 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 diff --git a/test/who_need_help/email/unisender_go_adapter_test.exs b/test/who_need_help/email/unisender_go_adapter_test.exs index b9e8102..1a65843 100644 --- a/test/who_need_help/email/unisender_go_adapter_test.exs +++ b/test/who_need_help/email/unisender_go_adapter_test.exs @@ -99,6 +99,33 @@ defmodule WhoNeedHelp.Email.UnisenderGoAdapterTest do 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( @@ -174,6 +201,13 @@ defmodule WhoNeedHelp.Email.UnisenderGoAdapterTest do 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