Packages
phoenix_kit
1.7.209
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
Current section
Files
lib/phoenix_kit/mailer/smtp_transport.ex
defmodule PhoenixKit.Mailer.SmtpTransport do
@moduledoc """
Builds the gen_smtp/Swoosh connection options for an SMTP integration.
Extracted so that *sending* and *"Test Connection"* are driven by literally the
same options — a check that connects differently from the sender is a check
that can lie in either direction. It is a pure function of the credentials map
and depends on nothing else in the tree (in particular not on
`PhoenixKit.Integrations`, which would otherwise close a
Integrations → Validators → Mailer → Integrations cycle).
## TLS
gen_smtp supplies **no** TLS options of its own, and OTP's `:ssl` now defaults
to `verify: :verify_peer` with no CA store. Left alone, that means:
* implicit TLS (465, `ssl: true`) dies on connect with
`{:options, :incompatible, [verify: :verify_peer, cacerts: :undefined]}`;
* STARTTLS (`tls: :always`) fails the handshake with `:tls_failed`.
So the options below are load-bearing, not decoration. They ride on `sockopts`
for implicit TLS (gen_smtp hands those straight to `:ssl.connect/4`) and on
`tls_options` for STARTTLS.
Note that passing `tls_options` *replaces* gen_smtp's default
`[{versions, ['tlsv1', 'tlsv1.1', 'tlsv1.2']}]` wholesale (it merges with
`lists:ukeymerge/3`). That is deliberate: we take OTP's defaults, which drop
the long-dead TLS 1.0/1.1 and allow TLS 1.3.
## Certificate verification is not optional when credentials are on the wire
If no CA store can be found we refuse to build a config for a relay that
expects a password (`{:error, :no_ca_store}`) rather than silently falling back
to `verify: :verify_none` — an unauthenticated TLS peer can present any
certificate, terminate the connection and harvest the AUTH exchange. A relay
that takes no credentials has nothing to protect, so it degrades instead.
"""
require Logger
@doc """
Returns `{:ok, options}` for gen_smtp/`Swoosh.Adapters.SMTP`, or `{:error, reason}`.
Reasons: `{:invalid_smtp_port, term}`, `:no_ca_store`.
"""
@spec config(map()) :: {:ok, keyword()} | {:error, term()}
def config(creds) when is_map(creds), do: config(creds, cacerts())
@doc """
Same as `config/1`, with the trusted CA store supplied explicitly.
The options are a pure function of the credentials and the CA store; `config/1`
simply reads the store from the system. Passing it in makes the fail-closed
branch — no CA store, credentials on the wire — reachable from a test.
"""
@spec config(map(), [binary()] | [tuple()]) :: {:ok, keyword()} | {:error, term()}
def config(creds, cacerts) when is_map(creds) and is_list(cacerts) do
with {:ok, port} <- parse_port(creds["port"]),
{:ok, transport} <- transport(port, creds, cacerts) do
base = [
relay: creds["host"],
port: port,
username: creds["username"],
password: creds["password"],
# An explicitly configured smarthost is an address, not a domain to
# resolve: gen_smtp would otherwise MX-look-up the relay and connect to
# whatever the MX records point at, while we pin SNI and the hostname
# check to the configured name — a guaranteed certificate mismatch.
no_mx_lookups: true
]
{:ok, base ++ transport}
end
end
# Setup fields are typed `:number` but travel through LiveView form params and
# JSONB storage as strings — normalize either shape. A port we cannot parse
# would silently become gen_smtp's default (25) and relay to an unintended
# server, so it is an error, not a fallback.
defp parse_port(port) when is_integer(port), do: {:ok, port}
defp parse_port(port) when is_binary(port) do
case Integer.parse(port) do
{int, _} -> {:ok, int}
:error -> {:error, {:invalid_smtp_port, port}}
end
end
defp parse_port(port), do: {:error, {:invalid_smtp_port, port}}
# Port 465 = implicit TLS (SMTPS). gen_smtp decides the protocol solely from
# the `ssl` option (`gen_smtp_client.erl` — `ssl: true` → ssl socket, else
# plaintext tcp); `tls` only drives a STARTTLS upgrade *after* a plaintext
# connect, so `tls: :always` on 465 would open plaintext to an SMTPS port.
defp transport(465, creds, cacerts) do
case tls_options(creds, cacerts) do
{:ok, tls_opts} -> {:ok, [ssl: true, sockopts: tls_opts]}
{:error, _} = error -> error
end
end
defp transport(_port, creds, cacerts) do
case tls_options(creds, cacerts) do
{:ok, tls_opts} ->
if credentials?(creds) do
# Credentials on the wire: mandatory, verified STARTTLS. Fail closed
# rather than let a stripped STARTTLS capability downgrade us to
# plaintext.
{:ok, [tls: :always, tls_options: tls_opts]}
else
# Nothing to protect — still offer verified TLS, but do not refuse a
# relay that has none.
{:ok, [tls: :if_available, tls_options: tls_opts]}
end
{:error, _} = error ->
error
end
end
defp tls_options(creds, cacerts) do
case cacerts do
[] ->
if credentials?(creds) do
{:error, :no_ca_store}
else
Logger.warning(
"No system CA certificates found — SMTP TLS certificate verification is disabled " <>
"for this credential-less relay. Install a CA bundle to enable it."
)
{:ok, [verify: :verify_none]}
end
cacerts ->
{:ok,
[
verify: :verify_peer,
# NOT optional, and not a copy-paste default: gen_smtp's own socket
# layer ships `{depth, 0}` (smtp_socket.erl:43,52) and merges it into
# whatever we pass. Depth 0 means "no intermediate CAs allowed", so
# every real certificate chain (leaf + intermediate) fails
# verification and the handshake dies with `:tls_failed`. Verified
# against a live relay: omit this key and the connection fails 4/4;
# set it to anything >= 1 and it succeeds. 10 is the conventional
# ceiling for a chain.
depth: 10,
cacerts: cacerts,
server_name_indication: sni(creds["host"]),
customize_hostname_check: [
match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
]
]}
end
end
defp sni(host) when is_binary(host) and host != "", do: to_charlist(host)
defp sni(_host), do: :disable
defp cacerts do
:public_key.cacerts_get()
rescue
_ -> []
end
defp credentials?(creds), do: not (blank?(creds["username"]) and blank?(creds["password"]))
defp blank?(nil), do: true
defp blank?(value) when is_binary(value), do: String.trim(value) == ""
defp blank?(_), do: false
end