Gate UniSender unsubscribe override by configuration
Some checks are pending
Quality / full-local-gates (push) Waiting to run

This commit is contained in:
SimpleTest 2026-07-21 15:54:42 +03:00
parent 8baa9abc13
commit d1d64afe2b
7 changed files with 80 additions and 4 deletions

View File

@ -140,6 +140,8 @@ SMTP_TLS=never
SMTP_SSL=false SMTP_SSL=false
UNISENDER_GO_API_KEY= UNISENDER_GO_API_KEY=
UNISENDER_GO_BASE_URL=https://goapi.unisender.ru/ru/transactional/api/v1 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. # Optional. When empty, the Req/Finch library defaults are used.
EMAIL_HTTP_CONNECT_TIMEOUT_MS= EMAIL_HTTP_CONNECT_TIMEOUT_MS=
EMAIL_HTTP_RECEIVE_TIMEOUT_MS= EMAIL_HTTP_RECEIVE_TIMEOUT_MS=

View File

@ -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." raise "UNISENDER_GO_BASE_URL must be an HTTPS origin and path without credentials, query, or fragment."
end 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 = client_options =
[] []
|> then(fn options -> |> then(fn options ->
@ -519,6 +531,7 @@ if config_env() == :prod do
adapter: WhoNeedHelp.Email.UnisenderGoAdapter, adapter: WhoNeedHelp.Email.UnisenderGoAdapter,
api_key: api_key, api_key: api_key,
base_url: base_url, base_url: base_url,
skip_unsubscribe: skip_unsubscribe,
client_options: client_options client_options: client_options
] ]

View File

@ -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 response shape, and one-minute/64-character idempotence-key behavior in its
[Web API reference](https://godocs.unisender.ru/web-api-ref). [Web API reference](https://godocs.unisender.ru/web-api-ref).
Every request explicitly sets `track_links` and `track_read` to `0`; UniSender 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 Go otherwise enables both by default and may require account-level approval
honour those values, so verify a delivered message's raw HTML before launch. before it honors those values.
`UNISENDER_GO_SKIP_UNSUBSCRIBE` defaults to `false` and must remain disabled
until UniSender Go support explicitly enables that transactional capability for
the account. After approval, set it to `true` to add `skip_unsubscribe=1` to the
provider request.
Verify a delivered message's raw HTML after the provider confirms the account
change and before launch.
## Isolated restore drill ## Isolated restore drill

View File

@ -21,6 +21,7 @@ defmodule WhoNeedHelp.Email.UnisenderGoAdapter do
def validate_config(config) do def validate_config(config) do
validate_api_key!(Keyword.get(config, :api_key)) validate_api_key!(Keyword.get(config, :api_key))
validate_base_url!(Keyword.get(config, :base_url, @default_base_url)) validate_base_url!(Keyword.get(config, :base_url, @default_base_url))
validate_skip_unsubscribe!(Keyword.get(config, :skip_unsubscribe, false))
:ok :ok
end end
@ -32,7 +33,7 @@ defmodule WhoNeedHelp.Email.UnisenderGoAdapter do
@impl true @impl true
def deliver(%Email{} = email, config) do def deliver(%Email{} = email, config) do
with :ok <- validate_supported_email(email), with :ok <- validate_supported_email(email),
{:ok, body} <- encode_payload(email), {:ok, body} <- encode_payload(email, config),
{:ok, status, _headers, response_body} <- {:ok, status, _headers, response_body} <-
Swoosh.ApiClient.post( Swoosh.ApiClient.post(
endpoint(config), endpoint(config),
@ -83,7 +84,7 @@ defmodule WhoNeedHelp.Email.UnisenderGoAdapter do
defp validate_supported_email(%Email{}), do: :ok defp validate_supported_email(%Email{}), do: :ok
defp encode_payload(%Email{} = email) do defp encode_payload(%Email{} = email, config) do
payload = payload =
%{ %{
"message" => "message" =>
@ -97,6 +98,7 @@ defmodule WhoNeedHelp.Email.UnisenderGoAdapter do
"track_read" => 0, "track_read" => 0,
"idempotence_key" => idempotence_key(email) "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_reply_to(email.reply_to)
|> maybe_put_headers(email.headers) |> maybe_put_headers(email.headers)
} }
@ -227,4 +229,10 @@ defmodule WhoNeedHelp.Email.UnisenderGoAdapter do
defp validate_base_url!(_base_url) do defp validate_base_url!(_base_url) do
raise ArgumentError, "UniSender Go base URL must be an HTTPS URL without credentials" raise ArgumentError, "UniSender Go base URL must be an HTTPS URL without credentials"
end 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 end

View File

@ -125,6 +125,7 @@ smtp_tls=${PRODUCTION_SMTP_TLS:-always}
smtp_ssl=${PRODUCTION_SMTP_SSL:-false} smtp_ssl=${PRODUCTION_SMTP_SSL:-false}
unisender_go_api_key=${PRODUCTION_UNISENDER_GO_API_KEY:-} unisender_go_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_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_connect_timeout_ms=${PRODUCTION_EMAIL_HTTP_CONNECT_TIMEOUT_MS:-}
email_http_receive_timeout_ms=${PRODUCTION_EMAIL_HTTP_RECEIVE_TIMEOUT_MS:-} email_http_receive_timeout_ms=${PRODUCTION_EMAIL_HTTP_RECEIVE_TIMEOUT_MS:-}
email_from_address=${PRODUCTION_EMAIL_FROM_ADDRESS:-"contact@$domain"} email_from_address=${PRODUCTION_EMAIL_FROM_ADDRESS:-"contact@$domain"}
@ -144,6 +145,11 @@ case "$email_delivery_provider" in
;; ;;
esac esac
case "$unisender_go_skip_unsubscribe" in
true | false) ;;
*) echo "PRODUCTION_UNISENDER_GO_SKIP_UNSUBSCRIBE must be true or false." >&2; exit 1 ;;
esac
tmp=$(mktemp "$target_dir/.production-env.XXXXXX") tmp=$(mktemp "$target_dir/.production-env.XXXXXX")
trap 'rm -f "$tmp"' EXIT HUP INT TERM trap 'rm -f "$tmp"' EXIT HUP INT TERM
chmod 600 "$tmp" chmod 600 "$tmp"
@ -176,6 +182,7 @@ SMTP_TLS_VALUE=$smtp_tls \
SMTP_SSL_VALUE=$smtp_ssl \ SMTP_SSL_VALUE=$smtp_ssl \
UNISENDER_GO_API_KEY_VALUE=$unisender_go_api_key \ UNISENDER_GO_API_KEY_VALUE=$unisender_go_api_key \
UNISENDER_GO_BASE_URL_VALUE=$unisender_go_base_url \ 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_CONNECT_TIMEOUT_MS_VALUE=$email_http_connect_timeout_ms \
EMAIL_HTTP_RECEIVE_TIMEOUT_MS_VALUE=$email_http_receive_timeout_ms \ EMAIL_HTTP_RECEIVE_TIMEOUT_MS_VALUE=$email_http_receive_timeout_ms \
EMAIL_FROM_ADDRESS_VALUE=$email_from_address \ EMAIL_FROM_ADDRESS_VALUE=$email_from_address \
@ -219,6 +226,7 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \
replacement["SMTP_SSL"] = ENVIRON["SMTP_SSL_VALUE"] replacement["SMTP_SSL"] = ENVIRON["SMTP_SSL_VALUE"]
replacement["UNISENDER_GO_API_KEY"] = ENVIRON["UNISENDER_GO_API_KEY_VALUE"] replacement["UNISENDER_GO_API_KEY"] = ENVIRON["UNISENDER_GO_API_KEY_VALUE"]
replacement["UNISENDER_GO_BASE_URL"] = ENVIRON["UNISENDER_GO_BASE_URL_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_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_HTTP_RECEIVE_TIMEOUT_MS"] = ENVIRON["EMAIL_HTTP_RECEIVE_TIMEOUT_MS_VALUE"]
replacement["EMAIL_FROM_ADDRESS"] = ENVIRON["EMAIL_FROM_ADDRESS_VALUE"] replacement["EMAIL_FROM_ADDRESS"] = ENVIRON["EMAIL_FROM_ADDRESS_VALUE"]

View File

@ -103,6 +103,7 @@ smtp_tls=$(optional_value SMTP_TLS)
smtp_ssl=$(optional_value SMTP_SSL) smtp_ssl=$(optional_value SMTP_SSL)
unisender_go_api_key=$(optional_value UNISENDER_GO_API_KEY) unisender_go_api_key=$(optional_value UNISENDER_GO_API_KEY)
unisender_go_base_url=$(optional_value UNISENDER_GO_BASE_URL) 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_connect_timeout_ms=$(optional_value EMAIL_HTTP_CONNECT_TIMEOUT_MS)
email_http_receive_timeout_ms=$(optional_value EMAIL_HTTP_RECEIVE_TIMEOUT_MS) email_http_receive_timeout_ms=$(optional_value EMAIL_HTTP_RECEIVE_TIMEOUT_MS)
email_from_address=$(require_value EMAIL_FROM_ADDRESS) email_from_address=$(require_value EMAIL_FROM_ADDRESS)
@ -275,6 +276,10 @@ case "$email_delivery_provider" in
exit 1 exit 1
} }
reject_marker UNISENDER_GO_BASE_URL "$unisender_go_base_url" 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 or unisender_go." >&2

View File

@ -99,6 +99,33 @@ defmodule WhoNeedHelp.Email.UnisenderGoAdapterTest do
UnisenderGoAdapter.deliver(email(plug), api_key: "secret-api-key") UnisenderGoAdapter.deliver(email(plug), api_key: "secret-api-key")
end 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 test "returns structured API and invalid-response failures" do
error_plug = fn conn -> error_plug = fn conn ->
Plug.Conn.send_resp( Plug.Conn.send_resp(
@ -174,6 +201,13 @@ defmodule WhoNeedHelp.Email.UnisenderGoAdapterTest do
base_url: "http://secret@example.test/api?key=secret" base_url: "http://secret@example.test/api?key=secret"
) )
end 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 end
defp email(plug) do defp email(plug) do