Replace UniSender delivery with isolated SMTP
Some checks are pending
Quality / full-local-gates (push) Waiting to run
Some checks are pending
Quality / full-local-gates (push) Waiting to run
This commit is contained in:
parent
5923608f07
commit
8c281cc756
10
.env.example
10
.env.example
|
|
@ -145,8 +145,7 @@ HANDOVER_SECRET=generate-an-independent-random-secret
|
||||||
RELEASE_COOKIE=generate-an-independent-beam-cluster-cookie
|
RELEASE_COOKIE=generate-an-independent-beam-cluster-cookie
|
||||||
METRICS_TOKEN=generate-an-independent-random-bearer-token
|
METRICS_TOKEN=generate-an-independent-random-bearer-token
|
||||||
|
|
||||||
# Select `smtp` for Mailpit or a regular transactional SMTP relay. Select
|
# Use Mailpit locally or a transactional SMTP relay in public environments.
|
||||||
# `unisender_go` to use UniSender Go's HTTPS transactional Web API instead.
|
|
||||||
EMAIL_DELIVERY_PROVIDER=smtp
|
EMAIL_DELIVERY_PROVIDER=smtp
|
||||||
SMTP_RELAY=mailpit
|
SMTP_RELAY=mailpit
|
||||||
SMTP_PORT=1025
|
SMTP_PORT=1025
|
||||||
|
|
@ -155,13 +154,6 @@ SMTP_PASSWORD=
|
||||||
SMTP_AUTH=never
|
SMTP_AUTH=never
|
||||||
SMTP_TLS=never
|
SMTP_TLS=never
|
||||||
SMTP_SSL=false
|
SMTP_SSL=false
|
||||||
UNISENDER_GO_API_KEY=
|
|
||||||
UNISENDER_GO_BASE_URL=https://goapi.unisender.ru/ru/transactional/api/v1
|
|
||||||
# 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_NAME="Who Need Help"
|
||||||
EMAIL_FROM_ADDRESS=contact@example.com
|
EMAIL_FROM_ADDRESS=contact@example.com
|
||||||
# Optional monitored inbox. It receives new-case alerts and is used as Reply-To.
|
# Optional monitored inbox. It receives new-case alerts and is used as Reply-To.
|
||||||
|
|
|
||||||
10
README.md
10
README.md
|
|
@ -231,9 +231,8 @@ existing file:
|
||||||
|
|
||||||
Configure the verified reverse-proxy source IP/CIDR and transactional email
|
Configure the verified reverse-proxy source IP/CIDR and transactional email
|
||||||
provider in that file. `EMAIL_DELIVERY_PROVIDER=smtp` uses the standard Swoosh
|
provider in that file. `EMAIL_DELIVERY_PROVIDER=smtp` uses the standard Swoosh
|
||||||
SMTP adapter. `EMAIL_DELIVERY_PROVIDER=unisender_go` uses UniSender Go's HTTPS
|
SMTP adapter and requires the relay's `SMTP_*` credentials. Email registration
|
||||||
Web API and requires `UNISENDER_GO_API_KEY`; this mode does not need outbound
|
and magic-link login are unusable for real
|
||||||
SMTP ports. Email registration and magic-link login are unusable for real
|
|
||||||
recipients until the selected provider and its accepted sender are configured. Set
|
recipients until the selected provider and its accepted sender are configured. Set
|
||||||
the optional `SUPPORT_INBOX_ADDRESS` to a monitored mailbox to receive
|
the optional `SUPPORT_INBOX_ADDRESS` to a monitored mailbox to receive
|
||||||
metadata-only new-case alerts and make replies return to the support team; the
|
metadata-only new-case alerts and make replies return to the support team; the
|
||||||
|
|
@ -619,9 +618,8 @@ pre-created Secret through required `existingSecret`; the chart never renders
|
||||||
credentials from tracked values. The Secret must contain `DATABASE_URL`,
|
credentials from tracked values. The Secret must contain `DATABASE_URL`,
|
||||||
`SECRET_KEY_BASE`, `HANDOVER_SECRET`, `RELEASE_COOKIE`, and `METRICS_TOKEN`, and
|
`SECRET_KEY_BASE`, `HANDOVER_SECRET`, `RELEASE_COOKIE`, and `METRICS_TOKEN`, and
|
||||||
may additionally contain the OAuth credentials described above. Set
|
may additionally contain the OAuth credentials described above. Set
|
||||||
`app.emailDeliveryProvider`; UniSender Go API mode also requires
|
`app.emailDeliveryProvider` to `smtp`; keep SMTP credentials in that Secret.
|
||||||
`UNISENDER_GO_API_KEY` in that Secret, while SMTP credentials remain Secret
|
The chart intentionally has no invented CPU/RAM limits or HPA
|
||||||
values in SMTP mode. The chart intentionally has no invented CPU/RAM limits or HPA
|
|
||||||
thresholds; measure this application in the target environment before setting
|
thresholds; measure this application in the target environment before setting
|
||||||
them.
|
them.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,6 @@ x-app-environment: &app-environment
|
||||||
SMTP_AUTH: ${SMTP_AUTH:-never}
|
SMTP_AUTH: ${SMTP_AUTH:-never}
|
||||||
SMTP_TLS: ${SMTP_TLS:-never}
|
SMTP_TLS: ${SMTP_TLS:-never}
|
||||||
SMTP_SSL: ${SMTP_SSL:-false}
|
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_NAME: ${EMAIL_FROM_NAME:?Set EMAIL_FROM_NAME in .env}
|
||||||
EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:?Set EMAIL_FROM_ADDRESS in .env}
|
EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:?Set EMAIL_FROM_ADDRESS in .env}
|
||||||
SUPPORT_INBOX_ADDRESS: ${SUPPORT_INBOX_ADDRESS:-}
|
SUPPORT_INBOX_ADDRESS: ${SUPPORT_INBOX_ADDRESS:-}
|
||||||
|
|
|
||||||
|
|
@ -482,67 +482,8 @@ if config_env() == :prod do
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
"unisender_go" ->
|
|
||||||
api_key =
|
|
||||||
System.get_env("UNISENDER_GO_API_KEY") ||
|
|
||||||
raise "UNISENDER_GO_API_KEY is required when EMAIL_DELIVERY_PROVIDER=unisender_go."
|
|
||||||
|
|
||||||
if api_key == "" do
|
|
||||||
raise "UNISENDER_GO_API_KEY is required when EMAIL_DELIVERY_PROVIDER=unisender_go."
|
|
||||||
end
|
|
||||||
|
|
||||||
base_url =
|
|
||||||
System.get_env(
|
|
||||||
"UNISENDER_GO_BASE_URL",
|
|
||||||
"https://goapi.unisender.ru/ru/transactional/api/v1"
|
|
||||||
)
|
|
||||||
|> String.trim_trailing("/")
|
|
||||||
|
|
||||||
uri = URI.parse(base_url)
|
|
||||||
|
|
||||||
unless uri.scheme == "https" and is_binary(uri.host) and uri.host != "" and
|
|
||||||
is_binary(uri.path) and uri.path != "" and is_nil(uri.userinfo) and
|
|
||||||
is_nil(uri.query) and is_nil(uri.fragment) do
|
|
||||||
raise "UNISENDER_GO_BASE_URL must be an HTTPS origin and path without credentials, query, or fragment."
|
|
||||||
end
|
|
||||||
|
|
||||||
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 ->
|
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
|
end
|
||||||
|
|
||||||
config :who_need_help, :handover_secret, handover_secret
|
config :who_need_help, :handover_secret, handover_secret
|
||||||
|
|
|
||||||
|
|
@ -53,10 +53,6 @@ spec:
|
||||||
value: {{ $root.Values.app.clusterInterface | quote }}
|
value: {{ $root.Values.app.clusterInterface | quote }}
|
||||||
- name: EMAIL_DELIVERY_PROVIDER
|
- name: EMAIL_DELIVERY_PROVIDER
|
||||||
value: {{ $root.Values.app.emailDeliveryProvider | quote }}
|
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:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
readOnlyRootFilesystem: true
|
readOnlyRootFilesystem: true
|
||||||
|
|
@ -101,10 +97,6 @@ spec:
|
||||||
value: {{ $root.Values.app.smtpRelay | quote }}
|
value: {{ $root.Values.app.smtpRelay | quote }}
|
||||||
- name: SMTP_PORT
|
- name: SMTP_PORT
|
||||||
value: {{ $root.Values.app.smtpPort | quote }}
|
value: {{ $root.Values.app.smtpPort | quote }}
|
||||||
- name: UNISENDER_GO_BASE_URL
|
|
||||||
value: {{ $root.Values.app.unisenderGoBaseUrl | quote }}
|
|
||||||
- name: UNISENDER_GO_SKIP_UNSUBSCRIBE
|
|
||||||
value: {{ $root.Values.app.unisenderGoSkipUnsubscribe | quote }}
|
|
||||||
- name: CODEX_SESSION_ID
|
- name: CODEX_SESSION_ID
|
||||||
value: {{ $root.Values.app.codexSessionId | quote }}
|
value: {{ $root.Values.app.codexSessionId | quote }}
|
||||||
- name: RATE_LIMIT_POLICIES_JSON
|
- name: RATE_LIMIT_POLICIES_JSON
|
||||||
|
|
|
||||||
|
|
@ -45,10 +45,6 @@ spec:
|
||||||
value: {{ .Values.worker.pushConcurrency | quote }}
|
value: {{ .Values.worker.pushConcurrency | quote }}
|
||||||
- name: EMAIL_DELIVERY_PROVIDER
|
- name: EMAIL_DELIVERY_PROVIDER
|
||||||
value: {{ .Values.app.emailDeliveryProvider | quote }}
|
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:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
readOnlyRootFilesystem: true
|
readOnlyRootFilesystem: true
|
||||||
|
|
|
||||||
|
|
@ -13,23 +13,13 @@
|
||||||
"emailDeliveryProvider": {
|
"emailDeliveryProvider": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"smtp",
|
"smtp"
|
||||||
"unisender_go"
|
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"unisenderGoBaseUrl": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^https://"
|
|
||||||
},
|
|
||||||
"unisenderGoSkipUnsubscribe": {
|
|
||||||
"type": "boolean"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"erlangPortLimit",
|
"erlangPortLimit",
|
||||||
"emailDeliveryProvider",
|
"emailDeliveryProvider"
|
||||||
"unisenderGoBaseUrl",
|
|
||||||
"unisenderGoSkipUnsubscribe"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,6 @@ app:
|
||||||
emailDeliveryProvider: smtp
|
emailDeliveryProvider: smtp
|
||||||
smtpRelay: mailpit
|
smtpRelay: mailpit
|
||||||
smtpPort: "1025"
|
smtpPort: "1025"
|
||||||
unisenderGoBaseUrl: https://goapi.unisender.ru/ru/transactional/api/v1
|
|
||||||
unisenderGoSkipUnsubscribe: false
|
|
||||||
|
|
||||||
# Required. The Secret must contain DATABASE_URL, SECRET_KEY_BASE,
|
# Required. The Secret must contain DATABASE_URL, SECRET_KEY_BASE,
|
||||||
# HANDOVER_SECRET, RELEASE_COOKIE, and METRICS_TOKEN. It may also contain both
|
# HANDOVER_SECRET, RELEASE_COOKIE, and METRICS_TOKEN. It may also contain both
|
||||||
|
|
@ -45,8 +43,7 @@ app:
|
||||||
# GitHub linking, and both GOOGLE_OAUTH_CLIENT_ID and
|
# GitHub linking, and both GOOGLE_OAUTH_CLIENT_ID and
|
||||||
# GOOGLE_OAUTH_CLIENT_SECRET to enable Google registration/sign-in. Optional
|
# GOOGLE_OAUTH_CLIENT_SECRET to enable Google registration/sign-in. Optional
|
||||||
# SUPPORT_INBOX_ADDRESS enables metadata-only operator alerts and Reply-To.
|
# SUPPORT_INBOX_ADDRESS enables metadata-only operator alerts and Reply-To.
|
||||||
# EMAIL_DELIVERY_PROVIDER=unisender_go additionally requires
|
# SMTP provider credentials can be kept in this Secret.
|
||||||
# UNISENDER_GO_API_KEY. SMTP mode can keep provider credentials in this Secret.
|
|
||||||
# Push is
|
# Push is
|
||||||
# opt-in: provide PUSH_HTTP_ENDPOINT,
|
# opt-in: provide PUSH_HTTP_ENDPOINT,
|
||||||
# PUSH_HTTP_BEARER_TOKEN, PUSH_HTTP_MAX_ATTEMPTS,
|
# PUSH_HTTP_BEARER_TOKEN, PUSH_HTTP_MAX_ATTEMPTS,
|
||||||
|
|
|
||||||
|
|
@ -91,8 +91,9 @@ the image tags. Existing deployment secrets remain unchanged:
|
||||||
./scripts/deploy-up.sh .env
|
./scripts/deploy-up.sh .env
|
||||||
```
|
```
|
||||||
|
|
||||||
Test always uses its own `who_need_help_test` PostGIS container/volume and
|
Test always uses its own `who_need_help_test` PostGIS container/volume. Local
|
||||||
Mailpit. Its messages cannot be sent by the production UniSender account.
|
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
|
Create the production configuration inside the production checkout after
|
||||||
loading only the production database/provider credentials into the process:
|
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_MODE=external \
|
||||||
PRODUCTION_DATABASE_URL='ecto://PRODUCTION_ROLE:PASSWORD@localhost/who_need_help_production' \
|
PRODUCTION_DATABASE_URL='ecto://PRODUCTION_ROLE:PASSWORD@localhost/who_need_help_production' \
|
||||||
PRODUCTION_DATABASE_SOCKET_DIR=/var/run/postgresql \
|
PRODUCTION_DATABASE_SOCKET_DIR=/var/run/postgresql \
|
||||||
PRODUCTION_EMAIL_DELIVERY_PROVIDER=unisender_go \
|
PRODUCTION_EMAIL_DELIVERY_PROVIDER=smtp \
|
||||||
PRODUCTION_UNISENDER_GO_API_KEY=YOUR_PRODUCTION_KEY \
|
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_ID=YOUR_PRODUCTION_CLIENT_ID \
|
||||||
PRODUCTION_GOOGLE_OAUTH_CLIENT_SECRET=YOUR_PRODUCTION_CLIENT_SECRET \
|
PRODUCTION_GOOGLE_OAUTH_CLIENT_SECRET=YOUR_PRODUCTION_CLIENT_SECRET \
|
||||||
PRODUCTION_CODEX_SESSION_ID=YOUR_MAIN_CODEX_SESSION_ID \
|
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;
|
Replace `TRAEFIK_TRUSTED_IPS` with the exact source IP/CIDR observed at Traefik;
|
||||||
do not copy the temporary VPN value into an unrelated server.
|
do not copy the temporary VPN value into an unrelated server.
|
||||||
|
|
||||||
Configure the transactional email provider and a sender accepted by it. SMTP
|
Configure the transactional SMTP relay and a sender accepted by it using
|
||||||
uses `EMAIL_DELIVERY_PROVIDER=smtp` plus the `SMTP_*` settings. UniSender Go's
|
`EMAIL_DELIVERY_PROVIDER=smtp` plus the `SMTP_*` settings. The initializer
|
||||||
HTTPS Web API uses `EMAIL_DELIVERY_PROVIDER=unisender_go`,
|
accepts the corresponding `PRODUCTION_EMAIL_DELIVERY_PROVIDER` and
|
||||||
`UNISENDER_GO_API_KEY`, and the default documented API base URL. The initializer
|
`PRODUCTION_SMTP_*` inputs.
|
||||||
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
|
Set optional `SUPPORT_INBOX_ADDRESS` to
|
||||||
a monitored address for support/removal queue alerts and email `Reply-To`;
|
a monitored address for support/removal queue alerts and email `Reply-To`;
|
||||||
leaving it empty disables operator email alerts, not the protected queues. If
|
leaving it empty disables operator email alerts, not the protected queues. If
|
||||||
|
|
@ -405,21 +405,10 @@ semantics documented by
|
||||||
[GitHub OAuth](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps),
|
[GitHub OAuth](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps),
|
||||||
and [SMTP RFC 5321](https://datatracker.ietf.org/doc/html/rfc5321).
|
and [SMTP RFC 5321](https://datatracker.ietf.org/doc/html/rfc5321).
|
||||||
|
|
||||||
The optional `WhoNeedHelp.Email.UnisenderGoAdapter` maps the same Swoosh email
|
The application uses Swoosh's SMTP adapter and `gen_smtp`. Verify the selected
|
||||||
objects to UniSender Go's documented JSON contract, supplies a per-delivery
|
relay's authenticated delivery, sender-domain authentication, and delivered
|
||||||
idempotence key, redacts rejected recipient addresses from returned errors, and
|
message headers before launch. Test and production may share an account-level
|
||||||
uses only HTTPS. The provider documents the endpoint, `X-API-KEY` header,
|
SMTP login only when they use independently revocable SMTP passwords.
|
||||||
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.
|
|
||||||
|
|
||||||
## Isolated restore drill
|
## Isolated restore drill
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
the future provider's TLS/CA policy, network reachability, backup service, high
|
||||||
availability, or target-server capacity.
|
availability, or target-server capacity.
|
||||||
|
|
||||||
## UniSender Go HTTPS delivery boundary on 2026-07-21
|
## 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
|
- 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
|
from UniSender Go's `system/ping` Web API method. The API key was loaded from
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -135,32 +135,13 @@ smtp_password=${PRODUCTION_SMTP_PASSWORD:-}
|
||||||
smtp_auth=${PRODUCTION_SMTP_AUTH:-always}
|
smtp_auth=${PRODUCTION_SMTP_AUTH:-always}
|
||||||
smtp_tls=${PRODUCTION_SMTP_TLS:-always}
|
smtp_tls=${PRODUCTION_SMTP_TLS:-always}
|
||||||
smtp_ssl=${PRODUCTION_SMTP_SSL:-false}
|
smtp_ssl=${PRODUCTION_SMTP_SSL:-false}
|
||||||
unisender_go_api_key=${PRODUCTION_UNISENDER_GO_API_KEY:-}
|
|
||||||
unisender_go_base_url=${PRODUCTION_UNISENDER_GO_BASE_URL:-https://goapi.unisender.ru/ru/transactional/api/v1}
|
|
||||||
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"}
|
email_from_address=${PRODUCTION_EMAIL_FROM_ADDRESS:-"contact@$domain"}
|
||||||
support_inbox_address=${PRODUCTION_SUPPORT_INBOX_ADDRESS:-}
|
support_inbox_address=${PRODUCTION_SUPPORT_INBOX_ADDRESS:-}
|
||||||
|
|
||||||
case "$email_delivery_provider" in
|
[ "$email_delivery_provider" = smtp ] || {
|
||||||
smtp) ;;
|
echo "PRODUCTION_EMAIL_DELIVERY_PROVIDER must be smtp." >&2
|
||||||
unisender_go)
|
exit 1
|
||||||
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
|
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -192,11 +173,6 @@ SMTP_PASSWORD_VALUE=$smtp_password \
|
||||||
SMTP_AUTH_VALUE=$smtp_auth \
|
SMTP_AUTH_VALUE=$smtp_auth \
|
||||||
SMTP_TLS_VALUE=$smtp_tls \
|
SMTP_TLS_VALUE=$smtp_tls \
|
||||||
SMTP_SSL_VALUE=$smtp_ssl \
|
SMTP_SSL_VALUE=$smtp_ssl \
|
||||||
UNISENDER_GO_API_KEY_VALUE=$unisender_go_api_key \
|
|
||||||
UNISENDER_GO_BASE_URL_VALUE=$unisender_go_base_url \
|
|
||||||
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 \
|
EMAIL_FROM_ADDRESS_VALUE=$email_from_address \
|
||||||
SUPPORT_INBOX_ADDRESS_VALUE=$support_inbox_address \
|
SUPPORT_INBOX_ADDRESS_VALUE=$support_inbox_address \
|
||||||
CODEX_SESSION_ID_VALUE=$codex_session_id \
|
CODEX_SESSION_ID_VALUE=$codex_session_id \
|
||||||
|
|
@ -254,11 +230,6 @@ TEST_UPSTREAM_VALUE=$test_upstream \
|
||||||
replacement["SMTP_AUTH"] = ENVIRON["SMTP_AUTH_VALUE"]
|
replacement["SMTP_AUTH"] = ENVIRON["SMTP_AUTH_VALUE"]
|
||||||
replacement["SMTP_TLS"] = ENVIRON["SMTP_TLS_VALUE"]
|
replacement["SMTP_TLS"] = ENVIRON["SMTP_TLS_VALUE"]
|
||||||
replacement["SMTP_SSL"] = ENVIRON["SMTP_SSL_VALUE"]
|
replacement["SMTP_SSL"] = ENVIRON["SMTP_SSL_VALUE"]
|
||||||
replacement["UNISENDER_GO_API_KEY"] = ENVIRON["UNISENDER_GO_API_KEY_VALUE"]
|
|
||||||
replacement["UNISENDER_GO_BASE_URL"] = ENVIRON["UNISENDER_GO_BASE_URL_VALUE"]
|
|
||||||
replacement["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["EMAIL_FROM_ADDRESS"] = ENVIRON["EMAIL_FROM_ADDRESS_VALUE"]
|
||||||
replacement["SUPPORT_INBOX_ADDRESS"] = ENVIRON["SUPPORT_INBOX_ADDRESS_VALUE"]
|
replacement["SUPPORT_INBOX_ADDRESS"] = ENVIRON["SUPPORT_INBOX_ADDRESS_VALUE"]
|
||||||
replacement["GOOGLE_OAUTH_CLIENT_ID"] = ENVIRON["GOOGLE_OAUTH_CLIENT_ID_VALUE"]
|
replacement["GOOGLE_OAUTH_CLIENT_ID"] = ENVIRON["GOOGLE_OAUTH_CLIENT_ID_VALUE"]
|
||||||
|
|
@ -282,7 +253,7 @@ chmod 600 "$target"
|
||||||
trap - EXIT HUP INT TERM
|
trap - EXIT HUP INT TERM
|
||||||
|
|
||||||
unset postgres_password secret_key_base handover_secret release_cookie metrics_token
|
unset postgres_password secret_key_base handover_secret release_cookie metrics_token
|
||||||
unset smtp_password unisender_go_api_key
|
unset smtp_password
|
||||||
unset google_oauth_client_secret
|
unset google_oauth_client_secret
|
||||||
|
|
||||||
echo "Generated independent deployment secrets without printing them."
|
echo "Generated independent deployment secrets without printing them."
|
||||||
|
|
|
||||||
|
|
@ -178,67 +178,30 @@ cp "$production_env" "$production_checkout/.env"
|
||||||
chmod 600 "$test_checkout/.env" "$production_checkout/.env"
|
chmod 600 "$test_checkout/.env" "$production_checkout/.env"
|
||||||
./scripts/validate-deployment-isolation.sh \
|
./scripts/validate-deployment-isolation.sh \
|
||||||
"$test_checkout" "$production_checkout" >/dev/null
|
"$test_checkout" "$production_checkout" >/dev/null
|
||||||
api_production_env="$scan_dir/.env.production.unisender-go"
|
|
||||||
PRODUCTION_EMAIL_DELIVERY_PROVIDER=unisender_go \
|
# Account-level SMTP logins may be shared by a relay, but the independently
|
||||||
PRODUCTION_UNISENDER_GO_API_KEY=quality-unisender-go-api-key \
|
# revocable SMTP passwords must remain isolated between deployments.
|
||||||
PRODUCTION_EMAIL_FROM_ADDRESS=contact@help.test \
|
production_smtp_relay=$(awk -F= '$1 == "SMTP_RELAY" { print substr($0, index($0, "=") + 1); exit }' "$production_checkout/.env")
|
||||||
PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \
|
production_smtp_username=$(awk -F= '$1 == "SMTP_USERNAME" { print substr($0, index($0, "=") + 1); exit }' "$production_checkout/.env")
|
||||||
./scripts/init-production-env.sh help.test "$api_production_env" >/dev/null
|
production_smtp_password=$(awk -F= '$1 == "SMTP_PASSWORD" { print substr($0, index($0, "=") + 1); exit }' "$production_checkout/.env")
|
||||||
./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"
|
|
||||||
sed -i \
|
sed -i \
|
||||||
-e 's/^EMAIL_DELIVERY_PROVIDER=.*/EMAIL_DELIVERY_PROVIDER=unisender_go/' \
|
-e 's/^EMAIL_DELIVERY_PROVIDER=.*/EMAIL_DELIVERY_PROVIDER=smtp/' \
|
||||||
-e 's/^UNISENDER_GO_API_KEY=.*/UNISENDER_GO_API_KEY=quality-test-unisender-go-api-key/' \
|
-e "s|^SMTP_RELAY=.*|SMTP_RELAY=$production_smtp_relay|" \
|
||||||
"$api_test_env"
|
-e "s|^SMTP_USERNAME=.*|SMTP_USERNAME=$production_smtp_username|" \
|
||||||
./scripts/validate-test-env.sh "$api_test_env" test.help.test >/dev/null
|
-e 's/^SMTP_PASSWORD=.*/SMTP_PASSWORD=quality-test-isolated-smtp-password/' \
|
||||||
if ./scripts/compose.sh "$api_test_env" config --services | grep -Fx mailpit >/dev/null; then
|
"$test_checkout/.env"
|
||||||
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"
|
|
||||||
./scripts/validate-deployment-isolation.sh \
|
./scripts/validate-deployment-isolation.sh \
|
||||||
"$test_checkout" "$production_checkout" >/dev/null
|
"$test_checkout" "$production_checkout" >/dev/null
|
||||||
sed -i \
|
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"
|
"$test_checkout/.env"
|
||||||
if ./scripts/validate-deployment-isolation.sh \
|
if ./scripts/validate-deployment-isolation.sh \
|
||||||
"$test_checkout" "$production_checkout" >/dev/null 2>&1; then
|
"$test_checkout" "$production_checkout" >/dev/null 2>&1; then
|
||||||
echo "Deployment isolation accepted a shared UniSender Go API key." >&2
|
echo "Deployment isolation accepted a shared SMTP password." >&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
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
cp "$test_env" "$test_checkout/.env"
|
||||||
|
chmod 600 "$test_checkout/.env"
|
||||||
placeholder_codex_env="$scan_dir/.env.production.placeholder-codex"
|
placeholder_codex_env="$scan_dir/.env.production.placeholder-codex"
|
||||||
cp "$production_env" "$placeholder_codex_env"
|
cp "$production_env" "$placeholder_codex_env"
|
||||||
chmod 600 "$placeholder_codex_env"
|
chmod 600 "$placeholder_codex_env"
|
||||||
|
|
@ -367,12 +330,6 @@ docker compose --project-name who_need_help_edge \
|
||||||
' >/dev/null
|
' >/dev/null
|
||||||
./scripts/compose.sh .env.example config --quiet
|
./scripts/compose.sh .env.example config --quiet
|
||||||
./scripts/compose.sh "$production_env" config --quiet
|
./scripts/compose.sh "$production_env" config --quiet
|
||||||
./scripts/compose.sh "$api_production_env" config --quiet
|
|
||||||
./scripts/compose.sh "$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_production_env" config --quiet
|
||||||
./scripts/compose.sh "$external_socket_production_env" config --quiet
|
./scripts/compose.sh "$external_socket_production_env" config --quiet
|
||||||
./scripts/compose.sh "$external_split_production_env" config --quiet
|
./scripts/compose.sh "$external_split_production_env" config --quiet
|
||||||
|
|
@ -611,29 +568,12 @@ tar --extract --file="$scan_tar" --directory "$scan_dir"
|
||||||
--values "$ROOT/deploy/helm/who-need-help/values-kind.yaml" \
|
--values "$ROOT/deploy/helm/who-need-help/values-kind.yaml" \
|
||||||
"$ROOT/deploy/helm/who-need-help" \
|
"$ROOT/deploy/helm/who-need-help" \
|
||||||
>"$scan_dir/rendered-helm.yaml"
|
>"$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 "$(
|
test "$(
|
||||||
grep -c 'name: ERL_ZFLAGS' "$scan_dir/rendered-helm.yaml"
|
grep -c 'name: ERL_ZFLAGS' "$scan_dir/rendered-helm.yaml"
|
||||||
)" -eq 5
|
)" -eq 5
|
||||||
test "$(
|
test "$(
|
||||||
grep -c 'value: "+Q 65536"' "$scan_dir/rendered-helm.yaml"
|
grep -c 'value: "+Q 65536"' "$scan_dir/rendered-helm.yaml"
|
||||||
)" -eq 5
|
)" -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 "$(
|
test "$(
|
||||||
grep -c '^kind: NetworkPolicy$' "$scan_dir/rendered-helm.yaml"
|
grep -c '^kind: NetworkPolicy$' "$scan_dir/rendered-helm.yaml"
|
||||||
)" -eq 1
|
)" -eq 1
|
||||||
|
|
|
||||||
|
|
@ -97,16 +97,12 @@ fi
|
||||||
test_email_provider=$(read_env "$test_env" EMAIL_DELIVERY_PROVIDER)
|
test_email_provider=$(read_env "$test_env" EMAIL_DELIVERY_PROVIDER)
|
||||||
production_email_provider=$(read_env "$production_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 &&
|
if [[ "$test_email_provider" == smtp &&
|
||||||
"$(read_env "$test_env" SMTP_RELAY)" != mailpit &&
|
"$(read_env "$test_env" SMTP_RELAY)" != mailpit &&
|
||||||
"$production_email_provider" == smtp &&
|
"$production_email_provider" == smtp &&
|
||||||
"$(read_env "$test_env" SMTP_RELAY)" == "$(read_env "$production_env" SMTP_RELAY)" ]]; then
|
"$(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
|
require_different SMTP_PASSWORD
|
||||||
fi
|
fi
|
||||||
[[ "$(read_env "$production_env" EMAIL_DELIVERY_PROVIDER)" != smtp ||
|
[[ "$(read_env "$production_env" EMAIL_DELIVERY_PROVIDER)" != smtp ||
|
||||||
|
|
|
||||||
|
|
@ -106,11 +106,6 @@ smtp_password=$(optional_value SMTP_PASSWORD)
|
||||||
smtp_auth=$(optional_value SMTP_AUTH)
|
smtp_auth=$(optional_value SMTP_AUTH)
|
||||||
smtp_tls=$(optional_value SMTP_TLS)
|
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_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)
|
email_from_address=$(require_value EMAIL_FROM_ADDRESS)
|
||||||
support_inbox_address=$(optional_value SUPPORT_INBOX_ADDRESS)
|
support_inbox_address=$(optional_value SUPPORT_INBOX_ADDRESS)
|
||||||
google_oauth_client_id=$(optional_value GOOGLE_OAUTH_CLIENT_ID)
|
google_oauth_client_id=$(optional_value GOOGLE_OAUTH_CLIENT_ID)
|
||||||
|
|
@ -296,38 +291,11 @@ case "$email_delivery_provider" in
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
unisender_go)
|
|
||||||
[[ -n "$unisender_go_api_key" ]] || {
|
|
||||||
echo "UNISENDER_GO_API_KEY is required when EMAIL_DELIVERY_PROVIDER=unisender_go." >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
reject_marker UNISENDER_GO_API_KEY "$unisender_go_api_key"
|
|
||||||
[[ "$unisender_go_base_url" =~ ^https://[^/@?#[:space:]]+(/[^?#[:space:]]*)?$ ]] || {
|
|
||||||
echo "UNISENDER_GO_BASE_URL must be an HTTPS origin and path without credentials, query, or fragment." >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
reject_marker UNISENDER_GO_BASE_URL "$unisender_go_base_url"
|
|
||||||
[[ "$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
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
for timeout_pair in \
|
|
||||||
"EMAIL_HTTP_CONNECT_TIMEOUT_MS:$email_http_connect_timeout_ms" \
|
|
||||||
"EMAIL_HTTP_RECEIVE_TIMEOUT_MS:$email_http_receive_timeout_ms"
|
|
||||||
do
|
|
||||||
timeout_value=${timeout_pair#*:}
|
|
||||||
if [[ -n "$timeout_value" && ! "$timeout_value" =~ ^[1-9][0-9]*$ ]]; then
|
|
||||||
echo "${timeout_pair%%:*} must be a positive integer when configured." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
[[ "$email_from_address" == *@* ]] || {
|
[[ "$email_from_address" == *@* ]] || {
|
||||||
echo "EMAIL_FROM_ADDRESS is not an email address." >&2
|
echo "EMAIL_FROM_ADDRESS is not an email address." >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|
|
||||||
|
|
@ -99,17 +99,8 @@ case "$email_delivery_provider" in
|
||||||
fi
|
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
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
|
||||||
|
|
@ -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("<p>Open the confirmation link</p>")
|
|
||||||
|> header("X-WNH-Message-Type", "account-confirmation")
|
|
||||||
|> header("List-Unsubscribe", "must-not-be-forwarded")
|
|
||||||
|> put_provider_option(:idempotence_key, "stable-delivery-key")
|
|
||||||
|> put_private(:client_options, plug: plug, retry: false)
|
|
||||||
|
|
||||||
assert {:ok, %{provider: :unisender_go, job_id: "job-123", accepted_count: 1}} =
|
|
||||||
UnisenderGoAdapter.deliver(email,
|
|
||||||
api_key: "secret-api-key",
|
|
||||||
base_url: "https://goapi.example.test/v1/"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert_receive {:request, conn, payload}
|
|
||||||
assert conn.method == "POST"
|
|
||||||
assert conn.request_path == "/v1/email/send.json"
|
|
||||||
assert Plug.Conn.get_req_header(conn, "x-api-key") == ["secret-api-key"]
|
|
||||||
|
|
||||||
assert payload == %{
|
|
||||||
"message" => %{
|
|
||||||
"body" => %{
|
|
||||||
"html" => "<p>Open the confirmation link</p>",
|
|
||||||
"plaintext" => "Open the confirmation link"
|
|
||||||
},
|
|
||||||
"from_email" => "contact@whoneedhelp.com",
|
|
||||||
"from_name" => "Who Need Help",
|
|
||||||
"headers" => %{"X-WNH-Message-Type" => "account-confirmation"},
|
|
||||||
"idempotence_key" => "stable-delivery-key",
|
|
||||||
"recipients" => [%{"email" => "helper@example.com"}],
|
|
||||||
"reply_to" => "support@whoneedhelp.com",
|
|
||||||
"reply_to_name" => "Support",
|
|
||||||
"subject" => "Confirmation instructions",
|
|
||||||
"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
|
|
||||||
Loading…
Reference in New Issue
Block a user