Support UniSender Go HTTPS email delivery
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
27c615d6cb
commit
6e11bb35aa
10
.env.example
10
.env.example
|
|
@ -122,8 +122,9 @@ 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
|
||||||
|
|
||||||
# Mailpit settings for local development. Replace these with the selected SMTP
|
# Select `smtp` for Mailpit or a regular transactional SMTP relay. Select
|
||||||
# provider for public registration and magic links.
|
# `unisender_go` to use UniSender Go's HTTPS transactional Web API instead.
|
||||||
|
EMAIL_DELIVERY_PROVIDER=smtp
|
||||||
SMTP_RELAY=mailpit
|
SMTP_RELAY=mailpit
|
||||||
SMTP_PORT=1025
|
SMTP_PORT=1025
|
||||||
SMTP_USERNAME=
|
SMTP_USERNAME=
|
||||||
|
|
@ -131,6 +132,11 @@ 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
|
||||||
|
# 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.
|
||||||
|
|
|
||||||
15
README.md
15
README.md
|
|
@ -209,9 +209,12 @@ existing file:
|
||||||
./scripts/init-production-env.sh whoneedhelp.com .env.production
|
./scripts/init-production-env.sh whoneedhelp.com .env.production
|
||||||
```
|
```
|
||||||
|
|
||||||
Configure the verified reverse-proxy source IP/CIDR and transactional SMTP
|
Configure the verified reverse-proxy source IP/CIDR and transactional email
|
||||||
provider in that file. Email registration and magic-link login are unusable for
|
provider in that file. `EMAIL_DELIVERY_PROVIDER=smtp` uses the standard Swoosh
|
||||||
real recipients until that relay and its accepted sender are configured. Set
|
SMTP adapter. `EMAIL_DELIVERY_PROVIDER=unisender_go` uses UniSender Go's HTTPS
|
||||||
|
Web API and requires `UNISENDER_GO_API_KEY`; this mode does not need outbound
|
||||||
|
SMTP ports. Email registration and magic-link login are unusable for real
|
||||||
|
recipients until the selected provider and its accepted sender are configured. Set
|
||||||
the optional `SUPPORT_INBOX_ADDRESS` to a monitored mailbox to receive
|
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
|
||||||
database queues continue to work when it is empty. See
|
database queues continue to work when it is empty. See
|
||||||
|
|
@ -588,8 +591,10 @@ For an external cluster, provide a real PostgreSQL/PostGIS service and a
|
||||||
pre-created Secret through required `existingSecret`; the chart never renders
|
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 GitHub OAuth client ID and client secret described
|
may additionally contain the OAuth credentials described above. Set
|
||||||
above. The chart intentionally has no invented CPU/RAM limits or HPA
|
`app.emailDeliveryProvider`; UniSender Go API mode also requires
|
||||||
|
`UNISENDER_GO_API_KEY` in that Secret, while SMTP credentials remain Secret
|
||||||
|
values in SMTP mode. The chart intentionally has no invented CPU/RAM limits or HPA
|
||||||
thresholds; measure this application in the target environment before setting
|
thresholds; measure this application in the target environment before setting
|
||||||
them.
|
them.
|
||||||
|
|
||||||
|
|
|
||||||
15
compose.yaml
15
compose.yaml
|
|
@ -18,13 +18,18 @@ x-app-environment: &app-environment
|
||||||
PHX_SCHEME: ${PHX_SCHEME:?Set PHX_SCHEME in .env}
|
PHX_SCHEME: ${PHX_SCHEME:?Set PHX_SCHEME in .env}
|
||||||
PHX_URL_PORT: ${PHX_URL_PORT:?Set PHX_URL_PORT in .env}
|
PHX_URL_PORT: ${PHX_URL_PORT:?Set PHX_URL_PORT in .env}
|
||||||
PORT: "4000"
|
PORT: "4000"
|
||||||
SMTP_RELAY: ${SMTP_RELAY:?Set SMTP_RELAY in .env}
|
EMAIL_DELIVERY_PROVIDER: ${EMAIL_DELIVERY_PROVIDER:-smtp}
|
||||||
SMTP_PORT: ${SMTP_PORT:?Set SMTP_PORT in .env}
|
SMTP_RELAY: ${SMTP_RELAY:-mailpit}
|
||||||
|
SMTP_PORT: ${SMTP_PORT:-1025}
|
||||||
SMTP_USERNAME: ${SMTP_USERNAME:-}
|
SMTP_USERNAME: ${SMTP_USERNAME:-}
|
||||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||||
SMTP_AUTH: ${SMTP_AUTH:?Set SMTP_AUTH in .env}
|
SMTP_AUTH: ${SMTP_AUTH:-never}
|
||||||
SMTP_TLS: ${SMTP_TLS:?Set SMTP_TLS in .env}
|
SMTP_TLS: ${SMTP_TLS:-never}
|
||||||
SMTP_SSL: ${SMTP_SSL:?Set SMTP_SSL in .env}
|
SMTP_SSL: ${SMTP_SSL:-false}
|
||||||
|
UNISENDER_GO_API_KEY: ${UNISENDER_GO_API_KEY:-}
|
||||||
|
UNISENDER_GO_BASE_URL: ${UNISENDER_GO_BASE_URL:-https://goapi.unisender.ru/ru/transactional/api/v1}
|
||||||
|
EMAIL_HTTP_CONNECT_TIMEOUT_MS: ${EMAIL_HTTP_CONNECT_TIMEOUT_MS:-}
|
||||||
|
EMAIL_HTTP_RECEIVE_TIMEOUT_MS: ${EMAIL_HTTP_RECEIVE_TIMEOUT_MS:-}
|
||||||
EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:?Set EMAIL_FROM_NAME in .env}
|
EMAIL_FROM_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:-}
|
||||||
|
|
|
||||||
|
|
@ -396,20 +396,39 @@ if config_env() == :prod do
|
||||||
default_url_port = if scheme == "https", do: "443", else: "80"
|
default_url_port = if scheme == "https", do: "443", else: "80"
|
||||||
url_port = String.to_integer(System.get_env("PHX_URL_PORT", default_url_port))
|
url_port = String.to_integer(System.get_env("PHX_URL_PORT", default_url_port))
|
||||||
|
|
||||||
|
email_delivery_provider = System.get_env("EMAIL_DELIVERY_PROVIDER", "smtp")
|
||||||
|
|
||||||
|
mailer_config =
|
||||||
|
case email_delivery_provider do
|
||||||
|
"smtp" ->
|
||||||
smtp_auth =
|
smtp_auth =
|
||||||
case System.get_env("SMTP_AUTH", "never") do
|
case System.get_env("SMTP_AUTH", "never") do
|
||||||
"always" -> :always
|
"always" ->
|
||||||
"never" -> :never
|
:always
|
||||||
"if_available" -> :if_available
|
|
||||||
other -> raise "SMTP_AUTH must be always, never, or if_available; got #{inspect(other)}"
|
"never" ->
|
||||||
|
:never
|
||||||
|
|
||||||
|
"if_available" ->
|
||||||
|
:if_available
|
||||||
|
|
||||||
|
other ->
|
||||||
|
raise "SMTP_AUTH must be always, never, or if_available; got #{inspect(other)}"
|
||||||
end
|
end
|
||||||
|
|
||||||
smtp_tls =
|
smtp_tls =
|
||||||
case System.get_env("SMTP_TLS", "never") do
|
case System.get_env("SMTP_TLS", "never") do
|
||||||
"always" -> :always
|
"always" ->
|
||||||
"never" -> :never
|
:always
|
||||||
"if_available" -> :if_available
|
|
||||||
other -> raise "SMTP_TLS must be always, never, or if_available; got #{inspect(other)}"
|
"never" ->
|
||||||
|
:never
|
||||||
|
|
||||||
|
"if_available" ->
|
||||||
|
:if_available
|
||||||
|
|
||||||
|
other ->
|
||||||
|
raise "SMTP_TLS must be always, never, or if_available; got #{inspect(other)}"
|
||||||
end
|
end
|
||||||
|
|
||||||
smtp_ssl =
|
smtp_ssl =
|
||||||
|
|
@ -436,7 +455,6 @@ if config_env() == :prod do
|
||||||
raise "SMTP_TLS must be never when SMTP_SSL enables an implicit TLS connection."
|
raise "SMTP_TLS must be never when SMTP_SSL enables an implicit TLS connection."
|
||||||
end
|
end
|
||||||
|
|
||||||
smtp_config =
|
|
||||||
[
|
[
|
||||||
adapter: Swoosh.Adapters.SMTP,
|
adapter: Swoosh.Adapters.SMTP,
|
||||||
relay: System.get_env("SMTP_RELAY", "mailpit"),
|
relay: System.get_env("SMTP_RELAY", "mailpit"),
|
||||||
|
|
@ -458,6 +476,56 @@ 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
|
||||||
|
|
||||||
|
client_options =
|
||||||
|
[]
|
||||||
|
|> then(fn options ->
|
||||||
|
case optional_positive_integer.("EMAIL_HTTP_CONNECT_TIMEOUT_MS") do
|
||||||
|
nil -> options
|
||||||
|
timeout -> Keyword.put(options, :connect_options, timeout: timeout)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|> then(fn options ->
|
||||||
|
case optional_positive_integer.("EMAIL_HTTP_RECEIVE_TIMEOUT_MS") do
|
||||||
|
nil -> options
|
||||||
|
timeout -> Keyword.put(options, :receive_timeout, timeout)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
[
|
||||||
|
adapter: WhoNeedHelp.Email.UnisenderGoAdapter,
|
||||||
|
api_key: api_key,
|
||||||
|
base_url: base_url,
|
||||||
|
client_options: client_options
|
||||||
|
]
|
||||||
|
|
||||||
|
other ->
|
||||||
|
raise "EMAIL_DELIVERY_PROVIDER must be smtp or unisender_go; got #{inspect(other)}"
|
||||||
|
end
|
||||||
|
|
||||||
config :who_need_help, :handover_secret, handover_secret
|
config :who_need_help, :handover_secret, handover_secret
|
||||||
|
|
||||||
config :who_need_help, :mailer_from,
|
config :who_need_help, :mailer_from,
|
||||||
|
|
@ -477,7 +545,7 @@ if config_env() == :prod do
|
||||||
|
|
||||||
config :who_need_help, :support_inbox_address, support_inbox_address
|
config :who_need_help, :support_inbox_address, support_inbox_address
|
||||||
|
|
||||||
config :who_need_help, WhoNeedHelp.Mailer, smtp_config
|
config :who_need_help, WhoNeedHelp.Mailer, mailer_config
|
||||||
|
|
||||||
config :who_need_help, WhoNeedHelpWeb.Endpoint,
|
config :who_need_help, WhoNeedHelpWeb.Endpoint,
|
||||||
url: [host: host, port: url_port, scheme: scheme],
|
url: [host: host, port: url_port, scheme: scheme],
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,10 @@ spec:
|
||||||
value: {{ printf "+Q %d" (int $root.Values.app.erlangPortLimit) | quote }}
|
value: {{ printf "+Q %d" (int $root.Values.app.erlangPortLimit) | quote }}
|
||||||
- name: CLUSTER_INTERFACE
|
- name: CLUSTER_INTERFACE
|
||||||
value: {{ $root.Values.app.clusterInterface | quote }}
|
value: {{ $root.Values.app.clusterInterface | quote }}
|
||||||
|
- name: EMAIL_DELIVERY_PROVIDER
|
||||||
|
value: {{ $root.Values.app.emailDeliveryProvider | quote }}
|
||||||
|
- name: UNISENDER_GO_BASE_URL
|
||||||
|
value: {{ $root.Values.app.unisenderGoBaseUrl | quote }}
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
readOnlyRootFilesystem: true
|
readOnlyRootFilesystem: true
|
||||||
|
|
@ -89,10 +93,14 @@ spec:
|
||||||
value: {{ $root.Values.worker.maintenanceConcurrency | quote }}
|
value: {{ $root.Values.worker.maintenanceConcurrency | quote }}
|
||||||
- name: OBAN_PUSH_CONCURRENCY
|
- name: OBAN_PUSH_CONCURRENCY
|
||||||
value: {{ $root.Values.worker.pushConcurrency | quote }}
|
value: {{ $root.Values.worker.pushConcurrency | quote }}
|
||||||
|
- name: EMAIL_DELIVERY_PROVIDER
|
||||||
|
value: {{ $root.Values.app.emailDeliveryProvider | quote }}
|
||||||
- name: SMTP_RELAY
|
- name: SMTP_RELAY
|
||||||
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: 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
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,10 @@ spec:
|
||||||
value: {{ .Values.worker.maintenanceConcurrency | quote }}
|
value: {{ .Values.worker.maintenanceConcurrency | quote }}
|
||||||
- name: OBAN_PUSH_CONCURRENCY
|
- name: OBAN_PUSH_CONCURRENCY
|
||||||
value: {{ .Values.worker.pushConcurrency | quote }}
|
value: {{ .Values.worker.pushConcurrency | quote }}
|
||||||
|
- name: EMAIL_DELIVERY_PROVIDER
|
||||||
|
value: {{ .Values.app.emailDeliveryProvider | quote }}
|
||||||
|
- name: UNISENDER_GO_BASE_URL
|
||||||
|
value: {{ .Values.app.unisenderGoBaseUrl | quote }}
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
readOnlyRootFilesystem: true
|
readOnlyRootFilesystem: true
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,23 @@
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": 1024,
|
"minimum": 1024,
|
||||||
"maximum": 134217727
|
"maximum": 134217727
|
||||||
|
},
|
||||||
|
"emailDeliveryProvider": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"smtp",
|
||||||
|
"unisender_go"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"unisenderGoBaseUrl": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^https://"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"erlangPortLimit"
|
"erlangPortLimit",
|
||||||
|
"emailDeliveryProvider",
|
||||||
|
"unisenderGoBaseUrl"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,15 +33,20 @@ app:
|
||||||
# Shared limits are opt-in; set only after product policy thresholds are approved.
|
# Shared limits are opt-in; set only after product policy thresholds are approved.
|
||||||
rateLimitPoliciesJson: "{}"
|
rateLimitPoliciesJson: "{}"
|
||||||
mapTileUrl: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
mapTileUrl: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||||
|
emailDeliveryProvider: smtp
|
||||||
smtpRelay: mailpit
|
smtpRelay: mailpit
|
||||||
smtpPort: "1025"
|
smtpPort: "1025"
|
||||||
|
unisenderGoBaseUrl: https://goapi.unisender.ru/ru/transactional/api/v1
|
||||||
|
|
||||||
# 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
|
||||||
# GITHUB_OAUTH_CLIENT_ID and GITHUB_OAUTH_CLIENT_SECRET to enable verified
|
# GITHUB_OAUTH_CLIENT_ID and GITHUB_OAUTH_CLIENT_SECRET to enable verified
|
||||||
# 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. Push is
|
# SUPPORT_INBOX_ADDRESS enables metadata-only operator alerts and Reply-To.
|
||||||
|
# EMAIL_DELIVERY_PROVIDER=unisender_go additionally requires
|
||||||
|
# UNISENDER_GO_API_KEY. SMTP mode can keep provider credentials in this Secret.
|
||||||
|
# Push is
|
||||||
# opt-in: provide PUSH_HTTP_ENDPOINT,
|
# opt-in: provide PUSH_HTTP_ENDPOINT,
|
||||||
# PUSH_HTTP_BEARER_TOKEN, PUSH_HTTP_MAX_ATTEMPTS,
|
# PUSH_HTTP_BEARER_TOKEN, PUSH_HTTP_MAX_ATTEMPTS,
|
||||||
# PUSH_HTTP_RECEIVE_TIMEOUT_MS, PUSH_HTTP_CONNECT_TIMEOUT_MS, and
|
# PUSH_HTTP_RECEIVE_TIMEOUT_MS, PUSH_HTTP_CONNECT_TIMEOUT_MS, and
|
||||||
|
|
|
||||||
|
|
@ -173,7 +173,7 @@ allows unrestricted Erlang distribution only between pods belonging to the
|
||||||
same chart instance, exposes the configured HTTP listener, and isolates other
|
same chart instance, exposes the configured HTTP listener, and isolates other
|
||||||
inbound pod ports. Kubernetes enforces this only when the cluster's network
|
inbound pod ports. Kubernetes enforces this only when the cluster's network
|
||||||
plugin implements NetworkPolicy. It does not restrict egress because the
|
plugin implements NetworkPolicy. It does not restrict egress because the
|
||||||
production database, SMTP, OAuth, map, and push-provider destinations are not
|
production database, email provider, OAuth, map, and push-provider destinations are not
|
||||||
known yet; those rules must be added from verified deployment-specific
|
known yet; those rules must be added from verified deployment-specific
|
||||||
addresses rather than invented in the chart. The chart does not invent
|
addresses rather than invented in the chart. The chart does not invent
|
||||||
CPU/memory limits or an HPA threshold before measurements exist.
|
CPU/memory limits or an HPA threshold before measurements exist.
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ PRODUCTION_CODEX_SESSION_ID=YOUR_MAIN_CODEX_SESSION_ID \
|
||||||
whoneedhelp.com .env.production
|
whoneedhelp.com .env.production
|
||||||
```
|
```
|
||||||
|
|
||||||
Configure and validate SMTP/OAuth independently in each ignored file. Start
|
Configure and validate email delivery/OAuth independently in each ignored file. Start
|
||||||
staging first, run database/load/browser/Android verification there, and then
|
staging first, run database/load/browser/Android verification there, and then
|
||||||
start the clean production project. Stopping staging does not stop Caddy or
|
start the clean production project. Stopping staging does not stop Caddy or
|
||||||
production:
|
production:
|
||||||
|
|
@ -131,10 +131,15 @@ edit `HTTP_BIND_ADDRESS` afterward, to match the observed target topology.
|
||||||
Replace `TRAEFIK_TRUSTED_IPS` with the exact source IP/CIDR observed at Traefik;
|
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 SMTP relay and sender accepted by that provider.
|
Configure the transactional email provider and a sender accepted by it. SMTP
|
||||||
Provider-specific auth, TLS, ports, and credentials can be supplied to the
|
uses `EMAIL_DELIVERY_PROVIDER=smtp` plus the `SMTP_*` settings. UniSender Go's
|
||||||
initializer through the documented `PRODUCTION_SMTP_*` environment values or
|
HTTPS Web API uses `EMAIL_DELIVERY_PROVIDER=unisender_go`,
|
||||||
edited in the resulting ignored file. Set optional `SUPPORT_INBOX_ADDRESS` to
|
`UNISENDER_GO_API_KEY`, and the default documented API base URL. The initializer
|
||||||
|
accepts the corresponding `PRODUCTION_EMAIL_DELIVERY_PROVIDER`,
|
||||||
|
`PRODUCTION_SMTP_*`, and `PRODUCTION_UNISENDER_GO_*` inputs. Optional
|
||||||
|
`EMAIL_HTTP_CONNECT_TIMEOUT_MS` and `EMAIL_HTTP_RECEIVE_TIMEOUT_MS` override the
|
||||||
|
Req/Finch defaults only when deployment measurements justify explicit values.
|
||||||
|
Set optional `SUPPORT_INBOX_ADDRESS` to
|
||||||
a monitored address for support/removal queue alerts and email `Reply-To`;
|
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
|
||||||
Google registration/sign-in is
|
Google registration/sign-in is
|
||||||
|
|
@ -150,10 +155,11 @@ URI. Leave both credentials empty to keep the feature disabled. Then run:
|
||||||
The production override keeps Mailpit behind its inactive `local-mail` profile,
|
The production override keeps Mailpit behind its inactive `local-mail` profile,
|
||||||
so public registration cannot appear to succeed while mail is only retained
|
so public registration cannot appear to succeed while mail is only retained
|
||||||
locally. The validator checks file ownership/mode, origin consistency,
|
locally. The validator checks file ownership/mode, origin consistency,
|
||||||
template markers, independent generated secrets, supported SMTP values, a
|
template markers, independent generated secrets, settings required by the
|
||||||
|
selected email provider, a
|
||||||
complete-or-empty Google credential pair, the generated database URL, and the
|
complete-or-empty Google credential pair, the generated database URL, and the
|
||||||
final Compose render without printing secrets.
|
final Compose render without printing secrets.
|
||||||
It does not contact DNS, TLS, SMTP, the reverse proxy, or the application.
|
It does not contact DNS, TLS, the email provider, the reverse proxy, or the application.
|
||||||
After deployment, verify `/healthz/ready`, inspect all replica health and logs,
|
After deployment, verify `/healthz/ready`, inspect all replica health and logs,
|
||||||
register a unique address through the public browser, receive its message at
|
register a unique address through the public browser, receive its message at
|
||||||
the real mailbox, follow the HTTPS confirmation link, and remove only that
|
the real mailbox, follow the HTTPS confirmation link, and remove only that
|
||||||
|
|
@ -375,6 +381,13 @@ semantics documented by
|
||||||
[GitHub OAuth](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps),
|
[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
|
||||||
|
objects to UniSender Go's documented JSON contract, supplies a per-delivery
|
||||||
|
idempotence key, redacts rejected recipient addresses from returned errors, and
|
||||||
|
uses only HTTPS. The provider documents the endpoint, `X-API-KEY` header,
|
||||||
|
response shape, and one-minute/64-character idempotence-key behavior in its
|
||||||
|
[Web API reference](https://godocs.unisender.ru/web-api-ref).
|
||||||
|
|
||||||
## Isolated restore drill
|
## Isolated restore drill
|
||||||
|
|
||||||
Run a real restore into a uniquely named temporary database:
|
Run a real restore into a uniquely named temporary database:
|
||||||
|
|
|
||||||
|
|
@ -1103,6 +1103,31 @@ These checks establish the mode wiring on this workstation. They do not prove
|
||||||
the future provider's TLS/CA policy, network reachability, backup service, high
|
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
|
||||||
|
|
||||||
|
- The production server returned HTTP 200 and a successful authenticated result
|
||||||
|
from UniSender Go's `system/ping` Web API method. The API key was loaded from
|
||||||
|
the server's mode-`0600` credential fragment and was not printed.
|
||||||
|
- Direct TCP connection attempts from the same server to SMTP ports 25, 465,
|
||||||
|
and 587 timed out for UniSender Go; control attempts to other public SMTP
|
||||||
|
providers also timed out. This observation does not establish where the
|
||||||
|
filtering occurs.
|
||||||
|
- `WhoNeedHelp.Email.UnisenderGoAdapter` now maps the application's existing
|
||||||
|
Swoosh messages to the provider's HTTPS `email/send.json` contract. Six
|
||||||
|
focused tests passed for the exact request shape and API-key header, success,
|
||||||
|
redacted recipient rejection, structured API errors, invalid responses, and
|
||||||
|
rejection of unsupported or provider-invalid messages before network I/O.
|
||||||
|
Runtime configuration and production
|
||||||
|
environment validation can select either `smtp` or `unisender_go` without
|
||||||
|
requiring SMTP settings in API mode.
|
||||||
|
- UniSender Go's account settings were saved with read/open tracking and link
|
||||||
|
tracking disabled, then reloaded and observed still disabled. The provider's
|
||||||
|
unsubscribe-link setting remains enabled and locked; disabling it requires
|
||||||
|
provider approval and has not been claimed.
|
||||||
|
- A real email has not yet been submitted through the new adapter. The sending
|
||||||
|
domain's UniSender Go verification/DKIM records were issued but are not yet
|
||||||
|
present in authoritative DNS, so end-to-end delivery remains unproven.
|
||||||
|
|
||||||
## Known work before a public production launch
|
## Known work before a public production launch
|
||||||
|
|
||||||
- Replace the temporary staging origin with the production-owned domain and
|
- Replace the temporary staging origin with the production-owned domain and
|
||||||
|
|
@ -1113,8 +1138,9 @@ availability, or target-server capacity.
|
||||||
verified App Links are wanted, and complete store policy/release work.
|
verified App Links are wanted, and complete store policy/release work.
|
||||||
- Operate PostgreSQL/PostGIS with off-site backups, recovery testing, monitoring,
|
- Operate PostgreSQL/PostGIS with off-site backups, recovery testing, monitoring,
|
||||||
and the availability model selected for real usage.
|
and the availability model selected for real usage.
|
||||||
- Configure a real transactional email provider before public registration.
|
- Publish and verify the issued UniSender Go SPF, DKIM, domain-validation,
|
||||||
The current Compose mail path terminates at local Mailpit.
|
DMARC, and link-domain DNS records, then exercise registration and magic-link
|
||||||
|
delivery through the selected HTTPS provider to a real mailbox.
|
||||||
- Create a Google OAuth Web client for the final owned HTTPS origin, configure
|
- Create a Google OAuth Web client for the final owned HTTPS origin, configure
|
||||||
its exact `/auth/google/callback` redirect, and exercise registration,
|
its exact `/auth/google/callback` redirect, and exercise registration,
|
||||||
sign-in, and settings linking against Google if Google authentication is to
|
sign-in, and settings linking against Google if Google authentication is to
|
||||||
|
|
|
||||||
228
lib/who_need_help/email/unisender_go_adapter.ex
Normal file
228
lib/who_need_help/email/unisender_go_adapter.ex
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
defmodule WhoNeedHelp.Email.UnisenderGoAdapter do
|
||||||
|
@moduledoc """
|
||||||
|
Swoosh adapter for the UniSender Go transactional Web API.
|
||||||
|
|
||||||
|
The adapter deliberately accepts the regular `Swoosh.Email` structure used
|
||||||
|
by the rest of the application. Delivery therefore remains provider-neutral
|
||||||
|
and can be switched back to SMTP through runtime configuration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@behaviour Swoosh.Adapter
|
||||||
|
|
||||||
|
alias Swoosh.Email
|
||||||
|
|
||||||
|
@default_base_url "https://goapi.unisender.ru/ru/transactional/api/v1"
|
||||||
|
@send_path "/email/send.json"
|
||||||
|
@max_recipients 500
|
||||||
|
@max_headers 50
|
||||||
|
@max_idempotence_key_length 64
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def validate_config(config) do
|
||||||
|
validate_api_key!(Keyword.get(config, :api_key))
|
||||||
|
validate_base_url!(Keyword.get(config, :base_url, @default_base_url))
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def validate_dependency do
|
||||||
|
Swoosh.Adapter.validate_dependency([Req, Jason])
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def deliver(%Email{} = email, config) do
|
||||||
|
with :ok <- validate_supported_email(email),
|
||||||
|
{:ok, body} <- encode_payload(email),
|
||||||
|
{:ok, status, _headers, response_body} <-
|
||||||
|
Swoosh.ApiClient.post(
|
||||||
|
endpoint(config),
|
||||||
|
request_headers(Keyword.fetch!(config, :api_key)),
|
||||||
|
body,
|
||||||
|
with_client_options(email, config)
|
||||||
|
) do
|
||||||
|
decode_response(status, response_body)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_supported_email(%Email{to: []}), do: {:error, :to_not_set}
|
||||||
|
|
||||||
|
defp validate_supported_email(%Email{from: nil}), do: {:error, :from_not_set}
|
||||||
|
|
||||||
|
defp validate_supported_email(%Email{subject: subject}) when subject in [nil, ""],
|
||||||
|
do: {:error, :subject_not_set}
|
||||||
|
|
||||||
|
defp validate_supported_email(%Email{to: recipients})
|
||||||
|
when length(recipients) > @max_recipients,
|
||||||
|
do: {:error, {:too_many_recipients, @max_recipients}}
|
||||||
|
|
||||||
|
defp validate_supported_email(%Email{cc: [_ | _]}),
|
||||||
|
do: {:error, {:unsupported_email_feature, :cc}}
|
||||||
|
|
||||||
|
defp validate_supported_email(%Email{bcc: [_ | _]}),
|
||||||
|
do: {:error, {:unsupported_email_feature, :bcc}}
|
||||||
|
|
||||||
|
defp validate_supported_email(%Email{attachments: [_ | _]}),
|
||||||
|
do: {:error, {:unsupported_email_feature, :attachments}}
|
||||||
|
|
||||||
|
defp validate_supported_email(%Email{reply_to: reply_to}) when is_list(reply_to),
|
||||||
|
do: {:error, {:unsupported_email_feature, :multiple_reply_to}}
|
||||||
|
|
||||||
|
defp validate_supported_email(%Email{text_body: nil, html_body: nil}),
|
||||||
|
do: {:error, :body_not_set}
|
||||||
|
|
||||||
|
defp validate_supported_email(%Email{headers: headers}) when map_size(headers) > @max_headers,
|
||||||
|
do: {:error, {:too_many_headers, @max_headers}}
|
||||||
|
|
||||||
|
defp validate_supported_email(%Email{provider_options: %{idempotence_key: key}})
|
||||||
|
when not is_binary(key) or key == "",
|
||||||
|
do: {:error, :invalid_idempotence_key}
|
||||||
|
|
||||||
|
defp validate_supported_email(%Email{provider_options: %{idempotence_key: key}})
|
||||||
|
when byte_size(key) > @max_idempotence_key_length,
|
||||||
|
do: {:error, {:idempotence_key_too_long, @max_idempotence_key_length}}
|
||||||
|
|
||||||
|
defp validate_supported_email(%Email{}), do: :ok
|
||||||
|
|
||||||
|
defp encode_payload(%Email{} = email) do
|
||||||
|
payload =
|
||||||
|
%{
|
||||||
|
"message" =>
|
||||||
|
%{
|
||||||
|
"recipients" => Enum.map(email.to, &recipient/1),
|
||||||
|
"subject" => email.subject,
|
||||||
|
"from_email" => elem(email.from, 1),
|
||||||
|
"from_name" => elem(email.from, 0),
|
||||||
|
"body" => message_body(email),
|
||||||
|
"idempotence_key" => idempotence_key(email)
|
||||||
|
}
|
||||||
|
|> maybe_put_reply_to(email.reply_to)
|
||||||
|
|> maybe_put_headers(email.headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
Jason.encode(payload)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp recipient({_name, address}), do: %{"email" => address}
|
||||||
|
|
||||||
|
defp message_body(%Email{text_body: text_body, html_body: html_body}) do
|
||||||
|
%{}
|
||||||
|
|> maybe_put("plaintext", text_body)
|
||||||
|
|> maybe_put("html", html_body)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_put_reply_to(message, nil), do: message
|
||||||
|
|
||||||
|
defp maybe_put_reply_to(message, {name, address}) do
|
||||||
|
message
|
||||||
|
|> Map.put("reply_to", address)
|
||||||
|
|> maybe_put("reply_to_name", name)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_put_headers(message, headers) do
|
||||||
|
allowed_headers =
|
||||||
|
Map.new(headers, fn {name, value} -> {to_string(name), to_string(value)} end)
|
||||||
|
|> Map.filter(fn {name, _value} -> String.starts_with?(String.downcase(name), "x-") end)
|
||||||
|
|
||||||
|
maybe_put(message, "headers", allowed_headers, allowed_headers != %{})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_put(map, _key, nil), do: map
|
||||||
|
defp maybe_put(map, key, value), do: Map.put(map, key, value)
|
||||||
|
defp maybe_put(map, key, value, true), do: Map.put(map, key, value)
|
||||||
|
defp maybe_put(map, _key, _value, false), do: map
|
||||||
|
|
||||||
|
defp idempotence_key(%Email{provider_options: %{idempotence_key: key}})
|
||||||
|
when is_binary(key) and key != "",
|
||||||
|
do: key
|
||||||
|
|
||||||
|
defp idempotence_key(_email) do
|
||||||
|
:crypto.strong_rand_bytes(24)
|
||||||
|
|> Base.url_encode64(padding: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp request_headers(api_key) do
|
||||||
|
[
|
||||||
|
{"Accept", "application/json"},
|
||||||
|
{"Content-Type", "application/json"},
|
||||||
|
{"X-API-KEY", api_key}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp endpoint(config) do
|
||||||
|
config
|
||||||
|
|> Keyword.get(:base_url, @default_base_url)
|
||||||
|
|> String.trim_trailing("/")
|
||||||
|
|> Kernel.<>(@send_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp with_client_options(email, config) do
|
||||||
|
configured_options = Keyword.get(config, :client_options, [])
|
||||||
|
existing_options = Map.get(email.private, :client_options, [])
|
||||||
|
|
||||||
|
Swoosh.Email.put_private(
|
||||||
|
email,
|
||||||
|
:client_options,
|
||||||
|
Keyword.merge(configured_options, existing_options)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp decode_response(200, response_body) do
|
||||||
|
case Jason.decode(response_body) do
|
||||||
|
{:ok, %{"status" => "success"} = response} -> success_response(response)
|
||||||
|
{:ok, response} -> {:error, api_error(200, response)}
|
||||||
|
{:error, _reason} -> {:error, {:invalid_unisender_go_response, 200}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp decode_response(status, response_body) do
|
||||||
|
case Jason.decode(response_body) do
|
||||||
|
{:ok, response} -> {:error, api_error(status, response)}
|
||||||
|
{:error, _reason} -> {:error, {:invalid_unisender_go_response, status}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp success_response(%{"failed_emails" => failed_emails})
|
||||||
|
when is_map(failed_emails) and map_size(failed_emails) > 0 do
|
||||||
|
reasons = failed_emails |> Map.values() |> Enum.frequencies()
|
||||||
|
{:error, {:unisender_go_recipient_rejected, reasons}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp success_response(response) do
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
provider: :unisender_go,
|
||||||
|
job_id: Map.get(response, "job_id"),
|
||||||
|
accepted_count: response |> Map.get("emails", []) |> length()
|
||||||
|
}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp api_error(status, response) when is_map(response) do
|
||||||
|
{:unisender_go_api_error, status, Map.get(response, "code")}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp api_error(status, _response), do: {:unisender_go_api_error, status, nil}
|
||||||
|
|
||||||
|
defp validate_api_key!(api_key) when is_binary(api_key) do
|
||||||
|
if String.trim(api_key) == "" do
|
||||||
|
raise ArgumentError, "UniSender Go API key is missing"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_api_key!(_api_key) do
|
||||||
|
raise ArgumentError, "UniSender Go API key is missing"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_base_url!(base_url) when is_binary(base_url) do
|
||||||
|
uri = URI.parse(base_url)
|
||||||
|
|
||||||
|
unless uri.scheme == "https" and is_binary(uri.host) and uri.host != "" and
|
||||||
|
is_binary(uri.path) and uri.path != "" and is_nil(uri.userinfo) and
|
||||||
|
is_nil(uri.query) and is_nil(uri.fragment) do
|
||||||
|
raise ArgumentError, "UniSender Go base URL must be an HTTPS URL without credentials"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_base_url!(_base_url) do
|
||||||
|
raise ArgumentError, "UniSender Go base URL must be an HTTPS URL without credentials"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -115,6 +115,7 @@ docker_socket_gid=$(stat -c '%g' /var/run/docker.sock)
|
||||||
http_bind_address=${PRODUCTION_HTTP_BIND_ADDRESS:-127.0.0.1}
|
http_bind_address=${PRODUCTION_HTTP_BIND_ADDRESS:-127.0.0.1}
|
||||||
http_port=${PRODUCTION_HTTP_PORT:-4010}
|
http_port=${PRODUCTION_HTTP_PORT:-4010}
|
||||||
trusted_proxy_ips=${PRODUCTION_TRAEFIK_TRUSTED_IPS:-REPLACE_WITH_VERIFIED_PROXY_IP_OR_CIDR}
|
trusted_proxy_ips=${PRODUCTION_TRAEFIK_TRUSTED_IPS:-REPLACE_WITH_VERIFIED_PROXY_IP_OR_CIDR}
|
||||||
|
email_delivery_provider=${PRODUCTION_EMAIL_DELIVERY_PROVIDER:-smtp}
|
||||||
smtp_relay=${PRODUCTION_SMTP_RELAY:-REPLACE_WITH_TRANSACTIONAL_SMTP_RELAY}
|
smtp_relay=${PRODUCTION_SMTP_RELAY:-REPLACE_WITH_TRANSACTIONAL_SMTP_RELAY}
|
||||||
smtp_port=${PRODUCTION_SMTP_PORT:-587}
|
smtp_port=${PRODUCTION_SMTP_PORT:-587}
|
||||||
smtp_username=${PRODUCTION_SMTP_USERNAME:-}
|
smtp_username=${PRODUCTION_SMTP_USERNAME:-}
|
||||||
|
|
@ -122,9 +123,27 @@ 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}
|
||||||
|
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
|
||||||
|
smtp) ;;
|
||||||
|
unisender_go)
|
||||||
|
if [ -z "$unisender_go_api_key" ]; then
|
||||||
|
echo "PRODUCTION_UNISENDER_GO_API_KEY is required for the unisender_go email provider." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "PRODUCTION_EMAIL_DELIVERY_PROVIDER must be smtp or unisender_go." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
tmp=$(mktemp "$target_dir/.production-env.XXXXXX")
|
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"
|
||||||
|
|
@ -147,6 +166,7 @@ SECRET_KEY_BASE_VALUE=$secret_key_base \
|
||||||
HANDOVER_SECRET_VALUE=$handover_secret \
|
HANDOVER_SECRET_VALUE=$handover_secret \
|
||||||
RELEASE_COOKIE_VALUE=$release_cookie \
|
RELEASE_COOKIE_VALUE=$release_cookie \
|
||||||
METRICS_TOKEN_VALUE=$metrics_token \
|
METRICS_TOKEN_VALUE=$metrics_token \
|
||||||
|
EMAIL_DELIVERY_PROVIDER_VALUE=$email_delivery_provider \
|
||||||
SMTP_RELAY_VALUE=$smtp_relay \
|
SMTP_RELAY_VALUE=$smtp_relay \
|
||||||
SMTP_PORT_VALUE=$smtp_port \
|
SMTP_PORT_VALUE=$smtp_port \
|
||||||
SMTP_USERNAME_VALUE=$smtp_username \
|
SMTP_USERNAME_VALUE=$smtp_username \
|
||||||
|
|
@ -154,6 +174,10 @@ 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 \
|
||||||
|
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 \
|
||||||
|
|
@ -185,6 +209,7 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \
|
||||||
replacement["HANDOVER_SECRET"] = ENVIRON["HANDOVER_SECRET_VALUE"]
|
replacement["HANDOVER_SECRET"] = ENVIRON["HANDOVER_SECRET_VALUE"]
|
||||||
replacement["RELEASE_COOKIE"] = ENVIRON["RELEASE_COOKIE_VALUE"]
|
replacement["RELEASE_COOKIE"] = ENVIRON["RELEASE_COOKIE_VALUE"]
|
||||||
replacement["METRICS_TOKEN"] = ENVIRON["METRICS_TOKEN_VALUE"]
|
replacement["METRICS_TOKEN"] = ENVIRON["METRICS_TOKEN_VALUE"]
|
||||||
|
replacement["EMAIL_DELIVERY_PROVIDER"] = ENVIRON["EMAIL_DELIVERY_PROVIDER_VALUE"]
|
||||||
replacement["SMTP_RELAY"] = ENVIRON["SMTP_RELAY_VALUE"]
|
replacement["SMTP_RELAY"] = ENVIRON["SMTP_RELAY_VALUE"]
|
||||||
replacement["SMTP_PORT"] = ENVIRON["SMTP_PORT_VALUE"]
|
replacement["SMTP_PORT"] = ENVIRON["SMTP_PORT_VALUE"]
|
||||||
replacement["SMTP_USERNAME"] = ENVIRON["SMTP_USERNAME_VALUE"]
|
replacement["SMTP_USERNAME"] = ENVIRON["SMTP_USERNAME_VALUE"]
|
||||||
|
|
@ -192,6 +217,10 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \
|
||||||
replacement["SMTP_AUTH"] = ENVIRON["SMTP_AUTH_VALUE"]
|
replacement["SMTP_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["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["CODEX_SESSION_ID"] = ENVIRON["CODEX_SESSION_ID_VALUE"]
|
replacement["CODEX_SESSION_ID"] = ENVIRON["CODEX_SESSION_ID_VALUE"]
|
||||||
|
|
@ -213,7 +242,8 @@ 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
|
||||||
|
|
||||||
echo "Generated independent deployment secrets without printing them."
|
echo "Generated independent deployment secrets without printing them."
|
||||||
echo "Created mode-0600 environment: $target"
|
echo "Created mode-0600 environment: $target"
|
||||||
echo "Run scripts/validate-production-env.sh '$target' '$domain' after configuring the verified proxy and SMTP values."
|
echo "Run scripts/validate-production-env.sh '$target' '$domain' after configuring the verified proxy and email-provider values."
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,32 @@ PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \
|
||||||
./scripts/init-production-env.sh help.test "$production_env" >/dev/null
|
./scripts/init-production-env.sh help.test "$production_env" >/dev/null
|
||||||
test "$(stat -c '%a' "$production_env")" = 600
|
test "$(stat -c '%a' "$production_env")" = 600
|
||||||
./scripts/validate-production-env.sh "$production_env" help.test >/dev/null
|
./scripts/validate-production-env.sh "$production_env" help.test >/dev/null
|
||||||
|
api_production_env="$scan_dir/.env.production.unisender-go"
|
||||||
|
PRODUCTION_EMAIL_DELIVERY_PROVIDER=unisender_go \
|
||||||
|
PRODUCTION_UNISENDER_GO_API_KEY=quality-unisender-go-api-key \
|
||||||
|
PRODUCTION_EMAIL_FROM_ADDRESS=contact@help.test \
|
||||||
|
PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \
|
||||||
|
./scripts/init-production-env.sh help.test "$api_production_env" >/dev/null
|
||||||
|
./scripts/validate-production-env.sh "$api_production_env" help.test >/dev/null
|
||||||
|
grep -Fx 'EMAIL_DELIVERY_PROVIDER=unisender_go' "$api_production_env" >/dev/null
|
||||||
|
if PRODUCTION_EMAIL_DELIVERY_PROVIDER=unisender_go \
|
||||||
|
PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \
|
||||||
|
./scripts/init-production-env.sh \
|
||||||
|
help.test "$scan_dir/.env.production.missing-api-key" >/dev/null 2>&1; then
|
||||||
|
echo "Production initializer accepted UniSender Go without an API key." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
invalid_api_url_env="$scan_dir/.env.production.invalid-api-url"
|
||||||
|
cp "$api_production_env" "$invalid_api_url_env"
|
||||||
|
chmod 600 "$invalid_api_url_env"
|
||||||
|
sed -i \
|
||||||
|
's#^UNISENDER_GO_BASE_URL=.*#UNISENDER_GO_BASE_URL=http://goapi.example.test/v1#' \
|
||||||
|
"$invalid_api_url_env"
|
||||||
|
if ./scripts/validate-production-env.sh \
|
||||||
|
"$invalid_api_url_env" help.test >/dev/null 2>&1; then
|
||||||
|
echo "Production validator accepted a non-HTTPS UniSender Go API URL." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
placeholder_codex_env="$scan_dir/.env.production.placeholder-codex"
|
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"
|
||||||
|
|
@ -253,6 +279,7 @@ docker compose --project-directory "$ROOT" --env-file "$edge_env" \
|
||||||
' >/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 "$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
|
||||||
|
|
|
||||||
|
|
@ -93,13 +93,18 @@ secret_key_base=$(require_value SECRET_KEY_BASE)
|
||||||
handover_secret=$(require_value HANDOVER_SECRET)
|
handover_secret=$(require_value HANDOVER_SECRET)
|
||||||
release_cookie=$(require_value RELEASE_COOKIE)
|
release_cookie=$(require_value RELEASE_COOKIE)
|
||||||
metrics_token=$(require_value METRICS_TOKEN)
|
metrics_token=$(require_value METRICS_TOKEN)
|
||||||
smtp_relay=$(require_value SMTP_RELAY)
|
email_delivery_provider=$(require_value EMAIL_DELIVERY_PROVIDER)
|
||||||
smtp_port=$(require_value SMTP_PORT)
|
smtp_relay=$(optional_value SMTP_RELAY)
|
||||||
|
smtp_port=$(optional_value SMTP_PORT)
|
||||||
smtp_username=$(optional_value SMTP_USERNAME)
|
smtp_username=$(optional_value SMTP_USERNAME)
|
||||||
smtp_password=$(optional_value SMTP_PASSWORD)
|
smtp_password=$(optional_value SMTP_PASSWORD)
|
||||||
smtp_auth=$(require_value SMTP_AUTH)
|
smtp_auth=$(optional_value SMTP_AUTH)
|
||||||
smtp_tls=$(require_value SMTP_TLS)
|
smtp_tls=$(optional_value SMTP_TLS)
|
||||||
smtp_ssl=$(require_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)
|
||||||
|
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)
|
||||||
|
|
@ -165,7 +170,6 @@ for pair in \
|
||||||
"HANDOVER_SECRET:$handover_secret" \
|
"HANDOVER_SECRET:$handover_secret" \
|
||||||
"RELEASE_COOKIE:$release_cookie" \
|
"RELEASE_COOKIE:$release_cookie" \
|
||||||
"METRICS_TOKEN:$metrics_token" \
|
"METRICS_TOKEN:$metrics_token" \
|
||||||
"SMTP_RELAY:$smtp_relay" \
|
|
||||||
"EMAIL_FROM_ADDRESS:$email_from_address"
|
"EMAIL_FROM_ADDRESS:$email_from_address"
|
||||||
do
|
do
|
||||||
reject_marker "${pair%%:*}" "${pair#*:}"
|
reject_marker "${pair%%:*}" "${pair#*:}"
|
||||||
|
|
@ -218,6 +222,13 @@ if [[ "$database_mode" != external && -n "$database_socket_dir" ]]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
case "$email_delivery_provider" in
|
||||||
|
smtp)
|
||||||
|
[[ -n "$smtp_relay" ]] || {
|
||||||
|
echo "SMTP_RELAY is required when EMAIL_DELIVERY_PROVIDER=smtp." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
reject_marker SMTP_RELAY "$smtp_relay"
|
||||||
[[ "$smtp_relay" != mailpit ]] || {
|
[[ "$smtp_relay" != mailpit ]] || {
|
||||||
echo "SMTP_RELAY still targets local Mailpit; public registration needs a transactional relay." >&2
|
echo "SMTP_RELAY still targets local Mailpit; public registration needs a transactional relay." >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|
@ -252,6 +263,35 @@ if [[ "$smtp_ssl" =~ ^(true|1)$ && "$smtp_tls" != never ]]; then
|
||||||
echo "SMTP_TLS must be never when SMTP_SSL enables an implicit TLS connection." >&2
|
echo "SMTP_TLS must be never when SMTP_SSL enables an implicit TLS connection." >&2
|
||||||
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"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "EMAIL_DELIVERY_PROVIDER must be smtp or unisender_go." >&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" == *@* ]] || {
|
[[ "$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
|
||||||
|
|
@ -303,4 +343,4 @@ done
|
||||||
"$ROOT/scripts/compose.sh" "$env_file" config --quiet
|
"$ROOT/scripts/compose.sh" "$env_file" config --quiet
|
||||||
|
|
||||||
echo "Production environment structure passed validation without printing secrets."
|
echo "Production environment structure passed validation without printing secrets."
|
||||||
echo "This does not test DNS, TLS, SMTP reachability/delivery, proxy source IPs, or server capacity."
|
echo "This does not test DNS, TLS, email-provider availability/delivery, proxy source IPs, or server capacity."
|
||||||
|
|
|
||||||
185
test/who_need_help/email/unisender_go_adapter_test.exs
Normal file
185
test/who_need_help/email/unisender_go_adapter_test.exs
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
defmodule WhoNeedHelp.Email.UnisenderGoAdapterTest do
|
||||||
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
|
import Swoosh.Email
|
||||||
|
|
||||||
|
alias WhoNeedHelp.Email.UnisenderGoAdapter
|
||||||
|
|
||||||
|
setup do
|
||||||
|
previous_client = Application.get_env(:swoosh, :api_client)
|
||||||
|
Application.put_env(:swoosh, :api_client, Swoosh.ApiClient.Req)
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
if previous_client do
|
||||||
|
Application.put_env(:swoosh, :api_client, previous_client)
|
||||||
|
else
|
||||||
|
Application.delete_env(:swoosh, :api_client)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "delivers a Swoosh email through the documented HTTPS JSON contract" do
|
||||||
|
owner = self()
|
||||||
|
|
||||||
|
plug = fn conn ->
|
||||||
|
{:ok, request_body, conn} = Plug.Conn.read_body(conn)
|
||||||
|
send(owner, {:request, conn, Jason.decode!(request_body)})
|
||||||
|
|
||||||
|
response =
|
||||||
|
Jason.encode!(%{
|
||||||
|
"status" => "success",
|
||||||
|
"job_id" => "job-123",
|
||||||
|
"emails" => ["helper@example.com"]
|
||||||
|
})
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> Plug.Conn.put_resp_content_type("application/json")
|
||||||
|
|> Plug.Conn.send_resp(200, response)
|
||||||
|
end
|
||||||
|
|
||||||
|
email =
|
||||||
|
new()
|
||||||
|
|> from({"Who Need Help", "contact@whoneedhelp.com"})
|
||||||
|
|> to("helper@example.com")
|
||||||
|
|> reply_to({"Support", "support@whoneedhelp.com"})
|
||||||
|
|> subject("Confirmation instructions")
|
||||||
|
|> text_body("Open the confirmation link")
|
||||||
|
|> html_body("<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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns a redacted recipient rejection without exposing addresses" do
|
||||||
|
plug = fn conn ->
|
||||||
|
response =
|
||||||
|
Jason.encode!(%{
|
||||||
|
"status" => "success",
|
||||||
|
"job_id" => "job-123",
|
||||||
|
"failed_emails" => %{"private@example.com" => "unsubscribed"}
|
||||||
|
})
|
||||||
|
|
||||||
|
Plug.Conn.send_resp(conn, 200, response)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert {:error, {:unisender_go_recipient_rejected, %{"unsubscribed" => 1}}} =
|
||||||
|
UnisenderGoAdapter.deliver(email(plug), api_key: "secret-api-key")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns structured API and invalid-response failures" do
|
||||||
|
error_plug = fn conn ->
|
||||||
|
Plug.Conn.send_resp(
|
||||||
|
conn,
|
||||||
|
401,
|
||||||
|
Jason.encode!(%{"status" => "error", "code" => 101, "message" => "invalid API key"})
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert {:error, {:unisender_go_api_error, 401, 101}} =
|
||||||
|
UnisenderGoAdapter.deliver(email(error_plug), api_key: "secret-api-key")
|
||||||
|
|
||||||
|
invalid_plug = fn conn -> Plug.Conn.send_resp(conn, 502, "not-json") end
|
||||||
|
|
||||||
|
assert {:error, {:invalid_unisender_go_response, 502}} =
|
||||||
|
UnisenderGoAdapter.deliver(email(invalid_plug), api_key: "secret-api-key")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects unsupported fields before making a request" do
|
||||||
|
email =
|
||||||
|
new()
|
||||||
|
|> from("contact@whoneedhelp.com")
|
||||||
|
|> to("helper@example.com")
|
||||||
|
|> cc("operator@example.com")
|
||||||
|
|> subject("Test")
|
||||||
|
|> text_body("body")
|
||||||
|
|
||||||
|
assert {:error, {:unsupported_email_feature, :cc}} =
|
||||||
|
UnisenderGoAdapter.deliver(email, api_key: "secret-api-key")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects malformed or provider-incompatible messages before making a request" do
|
||||||
|
assert {:error, :from_not_set} =
|
||||||
|
new()
|
||||||
|
|> to("helper@example.com")
|
||||||
|
|> subject("Test")
|
||||||
|
|> text_body("Body")
|
||||||
|
|> UnisenderGoAdapter.deliver(api_key: "secret-api-key")
|
||||||
|
|
||||||
|
assert {:error, :subject_not_set} =
|
||||||
|
new()
|
||||||
|
|> from("contact@whoneedhelp.com")
|
||||||
|
|> to("helper@example.com")
|
||||||
|
|> text_body("Body")
|
||||||
|
|> UnisenderGoAdapter.deliver(api_key: "secret-api-key")
|
||||||
|
|
||||||
|
too_many_recipients = List.duplicate("helper@example.com", 501)
|
||||||
|
|
||||||
|
assert {:error, {:too_many_recipients, 500}} =
|
||||||
|
new()
|
||||||
|
|> from("contact@whoneedhelp.com")
|
||||||
|
|> to(too_many_recipients)
|
||||||
|
|> subject("Test")
|
||||||
|
|> text_body("Body")
|
||||||
|
|> UnisenderGoAdapter.deliver(api_key: "secret-api-key")
|
||||||
|
|
||||||
|
assert {:error, {:idempotence_key_too_long, 64}} =
|
||||||
|
email(fn conn -> Plug.Conn.send_resp(conn, 500, "not reached") end)
|
||||||
|
|> put_provider_option(:idempotence_key, String.duplicate("x", 65))
|
||||||
|
|> UnisenderGoAdapter.deliver(api_key: "secret-api-key")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validates the API key without including the adapter config in the exception" do
|
||||||
|
assert_raise ArgumentError, "UniSender Go API key is missing", fn ->
|
||||||
|
UnisenderGoAdapter.validate_config(api_key: "")
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_raise ArgumentError,
|
||||||
|
"UniSender Go base URL must be an HTTPS URL without credentials",
|
||||||
|
fn ->
|
||||||
|
UnisenderGoAdapter.validate_config(
|
||||||
|
api_key: "secret-api-key",
|
||||||
|
base_url: "http://secret@example.test/api?key=secret"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp email(plug) do
|
||||||
|
new()
|
||||||
|
|> from({"Who Need Help", "contact@whoneedhelp.com"})
|
||||||
|
|> to("helper@example.com")
|
||||||
|
|> subject("Test")
|
||||||
|
|> text_body("Body")
|
||||||
|
|> put_private(:client_options, plug: plug, retry: false)
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Reference in New Issue
Block a user