Packages

phoenix_kit

1.7.219
1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit integrations validators.ex
Raw

lib/phoenix_kit/integrations/validators.ex

defmodule PhoenixKit.Integrations.Validators do
@moduledoc """
Real connection checks for providers that cannot be validated with a simple
authenticated HTTP GET.
`PhoenixKit.Integrations` falls through to `:ok` for any provider that declares
no validation. For the e-mail providers that meant "Test Connection" verified
*nothing*: the connection was stamped `"connected"` without a single byte
leaving the box, so an operator who pasted a wrong key or a bad SMTP password
saw a green check and then a failing send.
A check that always says yes is worse than no check — but so is one that says
no when the configuration is fine. These validators are therefore careful in
both directions:
* a relay that authenticates by IP and offers no `AUTH` verb passes (the
credentials are simply unused — sending works, so the check is green);
* SES credentials scoped to `ses:SendEmail` only — the least-privilege policy
AWS itself recommends — pass, even though they cannot read the send quota.
Every check runs through `PhoenixKit.Integrations.Probe`, which bounds it with
a hard deadline and isolates it from the caller — the libraries underneath
bound neither themselves nor their crashes, and both call sites are LiveView
callbacks.
"""
use Gettext, backend: PhoenixKitWeb.Gettext
require Logger
alias PhoenixKit.Integrations.Probe
alias PhoenixKit.Mailer.SmtpTransport
alias PhoenixKit.Utils.Number
@http_timeout 15_000
@doc """
Validates AWS SES credentials against the SES API itself.
Asks SES for the account's send quota — the cheapest call that proves the
credentials are real. Built as a raw `ExAws.Operation.Query`, so it needs no
`ex_aws_ses` dependency.
The endpoint host is passed explicitly rather than left to ExAws, whose region
resolution is a hard-coded prefix allowlist (`~r/^(us|eu|af|ap|sa|ca|me)-.../`)
that silently yields *no host at all* for newer regions such as `il-central-1`
— while the send path (`Swoosh.Adapters.AmazonSES`) simply interpolates the
region and works. Validate and send must resolve the same endpoint.
"""
@spec aws_ses(map()) :: :ok | {:ok, String.t()} | {:error, String.t()}
def aws_ses(data) do
region = data["aws_region"]
cond do
blank?(data["access_key"]) or blank?(data["secret_key"]) ->
{:error, gettext("Incomplete credentials")}
# ExAws would otherwise default a blank region to us-east-1 and happily
# report success, while the send path builds "email..amazonaws.com" from
# the same blank region and raises.
blank?(region) ->
{:error, gettext("Region is required")}
true ->
Probe.run(fn -> request_send_quota(region, data) end)
end
end
@doc """
Validates an SMTP relay by opening a real session and authenticating.
The connection options come from `PhoenixKit.Mailer.SmtpTransport.config/1`, so
the check exercises exactly the transport a real send uses — one source of
truth, no drift between "tested" and "sent".
Forcing AUTH for the probe is deliberate: with gen_smtp's `:if_available` the
AUTH exchange is attempted but its failure is tolerated, so a wrong password
would still open a session and the check would pass. The operator's `auth`
setting is honored in one direction only — `never` stays `never` (there are no
credentials to prove), while `if_available` is upgraded to `always` for the
probe so a bad password fails the check. A connection with no username and no
password stays `never` for the same reason as an explicit `never`. A relay that
advertises no `AUTH` verb at all is a different case, and is treated as a pass
— see the module doc.
"""
@spec smtp(map()) :: :ok | {:ok, String.t()} | {:error, String.t()}
def smtp(data) do
case SmtpTransport.config(data) do
{:ok, options} ->
Probe.run(fn -> open_smtp(options) end)
{:error, {:invalid_smtp_port, port}} ->
{:error, gettext("Invalid port: %{port}", port: inspect(port))}
{:error, :no_ca_store} ->
{:error,
gettext("No system CA certificates found, so the relay's certificate cannot be verified")}
{:error, :invalid_ca_cert} ->
{:error,
gettext(
"The CA certificate is not a valid PEM bundle (expected a -----BEGIN CERTIFICATE----- block)"
)}
{:error, {:invalid_timeout, value}} ->
{:error, gettext("Invalid timeout: %{value}", value: inspect(value))}
{:error, {key, value}}
when key in [:invalid_security, :invalid_verify_cert, :invalid_auth] ->
{:error,
gettext("Invalid %{setting}: %{value}",
setting: to_string(key) |> String.replace_prefix("invalid_", ""),
value: inspect(value)
)}
# This case runs OUTSIDE Probe.run and its rescue, so a reason added to
# SmtpTransport later would raise CaseClauseError straight out of a
# LiveView callback. Say something useless-but-safe instead.
{:error, reason} ->
{:error, gettext("Invalid SMTP settings: %{reason}", reason: inspect(reason))}
end
end
# --- SMTP -----------------------------------------------------------------
# `never` is the operator saying there are no credentials to prove, so leave it
# alone; anything else becomes `always` so a wrong password fails the check
# instead of being tolerated (see `smtp/1`'s doc).
#
# A relay configured with no login at all is the same case arrived at from the
# other direction: forcing `always` there makes the probe demand an AUTH
# exchange it has nothing to send, so a perfectly working IP-authenticated
# relay would fail Test Connection while sending fine.
defp probe_auth(options) do
cond do
Keyword.get(options, :auth, :if_available) == :never -> :never
blank?(options[:username]) and blank?(options[:password]) -> :never
true -> :always
end
end
defp open_smtp(options) do
probe_options =
options
|> Keyword.put(:auth, probe_auth(options))
# gen_smtp retries a temporarily-failing relay once by default, which doubles
# the time to a verdict and can push a slow failure past our deadline — the
# operator would be told "did not respond in time" instead of what went wrong.
# A real send still wants the retry; a check does not.
|> Keyword.put(:retries, 0)
case :gen_smtp_client.open(probe_options) do
{:ok, socket} ->
:gen_smtp_client.close(socket)
:ok
# The relay offers no AUTH verb — it authenticates by IP, or not at all.
# The credentials are unused, a real send works, and `username`/`password`
# are required fields the operator had to fill in with something. A red
# cross here would be a lie about a working relay.
{:error, _type, {:missing_requirement, _host, :auth}} ->
Logger.info("SMTP relay advertises no AUTH; credentials are unused by this relay")
:ok
{:error, :bad_option, reason} ->
{:error, describe_bad_option(reason)}
{:error, _type, reason} ->
{:error, describe_smtp_failure(reason)}
end
rescue
# gen_smtp calls to_binary/1 on the username, which has no clause for nil —
# reachable through the pre-save "test what you typed" probe, which does not
# go through the credential gate. It is a wide net, so it leaves a trace: a
# FunctionClauseError from anywhere else must not vanish as "incomplete".
error in FunctionClauseError ->
Logger.warning("SMTP connection check raised FunctionClauseError: #{inspect(error)}")
{:error, gettext("Incomplete SMTP settings")}
error ->
Logger.warning("SMTP connection check failed: #{inspect(error)}")
{:error, gettext("Could not reach the SMTP server")}
catch
:exit, reason ->
Logger.warning("SMTP connection check exited: #{inspect(reason)}")
{:error, describe_smtp_failure(reason)}
end
defp describe_bad_option(reason)
when reason in [:no_relay, :no_credentials, :invalid_port],
do: gettext("Incomplete SMTP settings")
defp describe_bad_option(_reason), do: gettext("Invalid SMTP settings")
defp describe_smtp_failure(reason) do
text = reason |> inspect() |> String.downcase()
cond do
# gen_smtp reports a rejected login as {:permanent_failure, host, :auth_failed}
String.contains?(text, ["auth_failed", "535", "authentication", "not authenticated"]) ->
gettext("Invalid credentials")
String.contains?(text, ["tls_failed", "ssl_not_started"]) ->
gettext("TLS handshake failed")
String.contains?(text, ["nxdomain", "econnrefused", "timeout", "ehostunreach"]) ->
gettext("Could not reach the SMTP server")
true ->
gettext("SMTP server rejected the connection")
end
end
# --- SES ------------------------------------------------------------------
@doc false
# `requester` is injectable so the confirm-retry — the behaviour that keeps a valid
# key from being reported invalid — can be tested without talking to AWS.
def request_send_quota(region, data, requester \\ &send_quota_request/2) do
case requester.(region, data) do
# Never call credentials invalid on a single 403. AWS briefly answers a
# *correct* request with SignatureDoesNotMatch right after it has rejected
# a bad signature from the same key — which is exactly the flow an operator
# produces by pasting a wrong key, fixing it, and pressing Test again.
# Telling them their good keys are invalid sends them off to reissue
# credentials that were never the problem. Confirm before accusing.
{:invalid, _} ->
# A full second, not less: SES throttles GetSendQuota at about one request
# per second, and the confirm-retry doubles our rate against it. Come back
# too soon and the second call answers Throttling, which reports a genuinely
# invalid key as "AWS SES is busy".
Process.sleep(1_000)
case requester.(region, data) do
{:invalid, _} -> {:error, gettext("Invalid credentials")}
confirmed -> confirmed
end
result ->
result
end
end
defp send_quota_request(region, data) do
operation = %ExAws.Operation.Query{
action: :get_send_quota,
path: "/",
params: %{"Action" => "GetSendQuota", "Version" => "2010-12-01"},
service: :ses,
parser: fn response, _action -> response end
}
operation
|> ExAws.request(
access_key_id: data["access_key"],
secret_access_key: data["secret_key"],
region: region,
host: "email.#{region}.amazonaws.com",
# ExAws retries transport errors ten times with backoff by default; an
# unreachable endpoint would block for minutes. Two attempts survives a
# single blip (SES throttles GetSendQuota aggressively) and still stays
# well inside the outer deadline.
#
# All three keys are mandatory. ExAws merges this override with `Map.merge`,
# so the list REPLACES the default wholesale — pass `max_attempts` alone and
# the backoff keys vanish, leaving `ExAws.Request.backoff/2` to evaluate
# `nil * :math.pow(2, attempt)`. It raises, the rescue below swallows it, and
# the check silently performs no retries at all.
retries: [max_attempts: 2, base_backoff_in_ms: 10, max_backoff_in_ms: 1_000],
http_opts: [recv_timeout: 5_000, connect_timeout: 5_000]
)
|> case do
{:ok, %{body: body}} ->
case format_quota_note(body) do
nil -> :ok
note -> {:ok, note}
end
{:error, reason} ->
interpret_ses_error(reason)
end
rescue
error ->
Logger.warning("SES connection check failed: #{inspect(error)}")
{:error, gettext("Could not reach AWS SES")}
catch
# hackney reaches its connection pool through GenServer.call, which exits
# rather than raising — `rescue` alone does not see it.
:exit, reason ->
Logger.warning("SES connection check exited: #{inspect(reason)}")
{:error, gettext("Could not reach AWS SES")}
end
@doc false
# Pure: an AWS error payload in, an operator-facing verdict out. Public so the
# mapping can be tested against real SES bodies without a network round trip.
def interpret_ses_error({:http_error, _status, response}) do
case aws_error_code(response) do
code
when code in ~w(SignatureDoesNotMatch InvalidClientTokenId UnrecognizedClientException
InvalidAccessKeyId ExpiredToken TokenRefreshRequired) ->
{:invalid, code}
# SES throttles GetSendQuota hard (~1 request/second). Reporting a
# throttle as "invalid credentials" would send an operator off to reissue
# perfectly good keys — observed live while testing this very validator.
code
when code in ~w(Throttling ThrottlingException RequestExpired
ServiceUnavailable InternalFailure) ->
{:error, gettext("AWS SES is busy — try again in a moment")}
# The credentials are valid and merely lack `ses:GetSendQuota` — which is
# exactly what AWS's own least-privilege guidance produces (grant only
# ses:SendEmail / ses:SendRawEmail). Reporting "invalid credentials" here
# would put a permanent red cross on a correctly configured integration and
# teach operators to ignore the check.
#
# But this is NOT proof that the key can send: a signature valid for the
# wrong AWS account also lands here. So it passes with the truth attached
# rather than a bare green tick — the note reaches the operator, not just
# the log.
"AccessDenied" <> _ ->
{:ok,
gettext(
"Credentials are valid, but not authorised for GetSendQuota — sending was not verified"
)}
nil ->
{:error, gettext("Could not reach AWS SES")}
code ->
{:error, gettext("AWS SES error: %{code}", code: code)}
end
end
def interpret_ses_error(_reason), do: {:error, gettext("Could not reach AWS SES")}
# The Query API answers with an XML body carrying an <Code> element.
defp aws_error_code(%{body: body}) when is_binary(body), do: aws_error_code(body)
defp aws_error_code(body) when is_binary(body) do
case Regex.run(~r{<Code>([^<]+)</Code>}, body) do
[_, code] -> code
_ -> nil
end
end
defp aws_error_code(_), do: nil
@doc false
# Pure: an XML GetSendQuota body in, a compact operator-facing note out (or
# `nil` if the body doesn't have the shape we expect — the credentials are
# still good, we just have nothing to add). Public so the parsing can be
# tested against real SES bodies without a network round trip.
def format_quota_note(body) when is_binary(body) do
with max when not is_nil(max) <- quota_tag(body, "Max24HourSend"),
sent when not is_nil(sent) <- quota_tag(body, "SentLast24Hours"),
rate when not is_nil(rate) <- quota_tag(body, "MaxSendRate") do
# -1 signifies an unlimited quota — AWS's own convention for accounts
# with no daily cap.
max_text = if max == -1.0, do: gettext("unlimited"), else: Number.format(round(max))
gettext("Quota: %{sent} / %{max} sent last 24h · max %{rate}/s",
sent: Number.format(round(sent)),
max: max_text,
rate: Number.format(round(rate))
)
else
_ -> nil
end
end
def format_quota_note(_body), do: nil
defp quota_tag(body, tag) do
with [_, value] <- Regex.run(~r{<#{tag}>([^<]+)</#{tag}>}, body),
{float, _rest} <- Float.parse(value) do
float
else
_ -> nil
end
end
# --- Brevo ------------------------------------------------------------
@doc """
Validates a Brevo API key against the account endpoint, and reports the
remaining credits.
Brevo authenticates with a bare `api-key` header (no `Bearer` prefix), and
`GET /v3/account` doubles as the cheapest authenticated call and the only
one that tells the operator anything beyond "the key works" — the plan's
remaining send credits, which is exactly what silently runs out mid-campaign.
"""
@spec brevo_api(map()) :: :ok | {:ok, String.t()} | {:error, String.t()}
def brevo_api(data) do
api_key = data["api_key"]
if blank?(api_key) do
{:error, gettext("No credentials configured")}
else
request_account(api_key)
end
end
defp request_account(api_key) do
case Req.get("https://api.brevo.com/v3/account",
headers: [{"api-key", api_key}],
receive_timeout: @http_timeout
) do
{:ok, %{status: 200, body: body}} ->
case format_credits_note(body) do
nil -> :ok
note -> {:ok, note}
end
{:ok, %{status: 401}} ->
{:error, gettext("Invalid credentials")}
{:ok, %{status: 403}} ->
{:error, gettext("Access denied")}
{:ok, %{status: status}} ->
{:error, gettext("Brevo error %{status}", status: status)}
{:error, reason} ->
Logger.warning("Brevo connection check failed: #{inspect(reason)}")
{:error, gettext("Could not reach Brevo")}
end
end
@doc false
# Pure: a decoded /v3/account JSON body in, a compact operator-facing note
# out (or `nil` if there is no usable plan info). Public so the parsing can
# be tested against real Brevo bodies without a network round trip.
def format_credits_note(%{"plan" => plan}) when is_list(plan) and plan != [] do
case plan |> Enum.map(&format_plan_entry/1) |> Enum.reject(&is_nil/1) do
[] -> nil
entries -> gettext("Plan: %{entries}", entries: Enum.join(entries, "; "))
end
end
def format_credits_note(_body), do: nil
defp format_plan_entry(%{"type" => type} = entry) when is_binary(type) do
entry
|> Map.get("credits")
|> credits_text(type)
|> with_reset_date(entry["endDate"])
end
defp format_plan_entry(_entry), do: nil
defp credits_text(credits, type) when is_number(credits) do
gettext("%{type} · %{credits} credits left",
type: type,
credits: Number.format(round(credits))
)
end
defp credits_text(_credits, type), do: type
defp with_reset_date(text, end_date) do
case reset_date(end_date) do
nil -> text
date -> gettext("%{text} · resets %{date}", text: text, date: date)
end
end
# Brevo's real /v3/account sends `endDate` as an ISO-8601 STRING
# ("2017-04-11T00:00:00.000Z" — confirmed against the official reference,
# Postman collection and SDKs). The integer clause is belt-and-braces for
# a unix timestamp, should the API ever change shape.
defp reset_date(end_date) when is_binary(end_date) do
case DateTime.from_iso8601(end_date) do
{:ok, datetime, _offset} -> Date.to_iso8601(DateTime.to_date(datetime))
{:error, _reason} -> nil
end
end
defp reset_date(end_date) when is_integer(end_date) do
case DateTime.from_unix(end_date) do
{:ok, datetime} -> Date.to_iso8601(DateTime.to_date(datetime))
{:error, _reason} -> nil
end
end
defp reset_date(_end_date), do: nil
# --- Telegram ---------------------------------------------------------
@doc """
Validates a Telegram bot token against the Bot API's `getMe`.
Telegram embeds the token in the URL path (`/bot<token>/getMe`) rather than a
header, so it can't use the generic header-based check. `getMe` is the
cheapest authenticated call, and its reply carries the bot's username — a
useful confirmation the operator connected the bot they meant to.
"""
@spec telegram(map()) :: :ok | {:ok, String.t()} | {:error, String.t()}
def telegram(data) do
token = data["bot_token"]
if blank?(token) do
{:error, gettext("No bot token configured")}
else
request_telegram(token)
end
end
defp request_telegram(token) do
case Req.get("https://api.telegram.org/bot#{token}/getMe", receive_timeout: @http_timeout) do
{:ok, %{status: 200, body: body}} ->
case telegram_username(body) do
nil -> :ok
username -> {:ok, gettext("Connected as @%{username}", username: username)}
end
# Telegram answers a bad or malformed token with 401/404 on this path.
{:ok, %{status: status}} when status in [401, 404] ->
{:error, gettext("Invalid bot token")}
{:ok, %{status: status}} ->
{:error, gettext("Telegram error %{status}", status: status)}
{:error, reason} ->
Logger.warning("Telegram connection check failed: #{inspect(reason)}")
{:error, gettext("Could not reach Telegram")}
end
end
defp telegram_username(%{"ok" => true, "result" => %{"username" => username}})
when is_binary(username) and username != "",
do: username
defp telegram_username(_body), do: nil
# --- shared ---------------------------------------------------------------
defp blank?(nil), do: true
defp blank?(value) when is_binary(value), do: String.trim(value) == ""
defp blank?(_), do: false
end