Uh oh!
There was an error while loading. Please reload this page.
fix: SMTP provider could not send (missing TLS options); Test Connection validated nothing - #636
fix: SMTP provider could not send (missing TLS options); Test Connection validated nothing#636timujinne wants to merge 13 commits into
Conversation
…ed nothing
Two defects, one of them a live break in the released code.
## 1. The universal SMTP provider could not send (regression, v1.7.190)
gen_smtp supplies no TLS options of its own, and OTP's `:ssl` now defaults to
`verify: :verify_peer` with no CA store. So, against a real relay:
- port 465 (`ssl: true`) died on connect with
`{:options, :incompatible, [verify: :verify_peer, cacerts: :undefined]}`;
- every other port (`tls: :always`) failed the STARTTLS handshake (`:tls_failed`).
The previous `tls: :if_available` masked this: when the handshake failed gen_smtp
silently fell back to **plaintext** — so mail went out, with the relay password
on the wire. Making the transport fail-closed turned that silent downgrade into
an outright failure, which is how the bug surfaced.
`swoosh_config_for/1` now passes proper TLS options in both modes — system CA
certs, `verify_peer`, SNI, and the hostname check `:ssl` does not do on its own
(on `sockopts` for implicit TLS, since gen_smtp hands those straight to
`:ssl.connect/4`; on `tls_options` for STARTTLS). Verified against a live relay:
both 465 and 587 connect, and a real send now succeeds over verified TLS.
## 2. "Test Connection" verified nothing for aws_ses and smtp
Neither provider declared a validation, so `do_validate/2` fell through to `:ok`
and `record_validation` stamped the connection "connected" without a single byte
leaving the box. An operator who pasted a wrong key or a bad SMTP password got a
green check and a failing send.
New `PhoenixKit.Integrations.Validators`:
- `aws_ses/1` asks SES itself for the account send quota (raw
`ExAws.Operation.Query`, so no `ex_aws_ses` dependency) — proving the
credentials are real *and* authorised for SES *in that region*;
- `smtp/1` opens a real session and authenticates (`auth: :always`, so a relay
that does not demand auth cannot mask a wrong password). It builds its options
from `Mailer.swoosh_config_for/1`, so it exercises exactly the transport a real
send uses — one source of truth, no drift between "tested" and "sent".
Verified live against real credentials: SES and SMTP both validate `:ok`; a wrong
secret, a wrong password and an unreachable host are each rejected with a
specific message.
The SMTP provider test previously asserted `:ok = validate_connection(...)` on
fabricated credentials — it only passed because validation was a no-op. It now
asserts what actually matters (credentials remain retrievable from the
"configured" status) plus a new case proving an unreachable relay is rejected.timujinne
commented
Jul 14, 2026
Live verification (real relay, real credentials)Run against the running dev app, using the actual The last line is the one that matters: sending over the universal SMTP provider works again, and now over verified TLS. Before this branch it failed on both ports — and before v1.7.190 it "worked" only because a failed TLS handshake silently fell back to plaintext, putting the relay password on the wire. Both TLS modes were confirmed directly against the live relay: Gates
|
timujinne
commented
Jul 14, 2026
Backward compatibility: does real validation break anything that used to pass?Short answer: no. Validation status is informational — credential retrieval, and therefore sending, does not depend on it. Proven live, on a connection whose validation genuinely fails: Why it holds:
The practical consequence for the Observation (pre-existing, not introduced here)
|
Review verdict on the first cut was Needs-rework, and it was right: the branch's
own thesis is "a check that always says yes is worse than no check", and the
check it shipped could say yes when it should say no, and no when it should say
yes. Every item below was reproduced live against a real relay and a real SES
account before and after the fix.
## Said yes when it should have said no
- **Blank region → silently probed us-east-1.** `has_credentials?/1` for
`:key_secret` only looked at `access_key`, so an SES connection with no region
reached the validator; ExAws defaults a missing region to us-east-1 and the
check went green — while the send path builds "email..amazonaws.com" from the
same blank region and raises. The `:key_secret` gate now requires every field
the provider declares required, and the validator refuses a blank region.
- **Fail-open to `verify: :verify_none` when no CA store is found.** Latent in
dev (the container has 150 CA certs), fires exactly where you cannot watch it:
minimal production images. Both the sender and the check would then trust any
certificate — and the check would still show green. Now a relay that expects a
password fails closed with `:no_ca_store`; only a credential-less relay
degrades.
## Said no when it should have said yes
- **`auth: :always` rejected relays that offer no AUTH verb** (internal
smarthosts that authenticate by IP). Sending works there; the check went red.
gen_smtp throws `{:missing_requirement, auth}` for that case, which is now
treated as a pass. Relays that *do* advertise AUTH still fail closed on a bad
password.
- **SES `AccessDenied` was reported as "Invalid credentials".** The least-
privilege policy AWS itself recommends (grant only `ses:SendEmail`) cannot read
the send quota, so a correctly configured integration got a permanent red
cross. The XML error code is now decoded: signature/token errors mean invalid
credentials, `AccessDenied` passes with a note, throttling says so, and
anything else is surfaced verbatim.
- **A valid key could be reported invalid.** Observed live: after AWS rejects a
bad signature, a *correct* request from the same key comes back
SignatureDoesNotMatch for a moment — which is exactly what an operator produces
by pasting a wrong key, fixing it and pressing Test again. A verdict of
"invalid credentials" is now confirmed with a second attempt. Adversarial
sequence: was intermittently red, now 6/6 green.
## Could hang the caller for twenty minutes
`timeout:` bounds only gen_smtp's TCP connect; every read after it waits on a
hard-coded `?TIMEOUT` of 1_200_000 ms, in the *calling* process — and both call
sites are LiveView callbacks. A tarpit relay would park a LiveView process for
twenty minutes. ExAws had the same disease more mildly (ten retries with
backoff). Both checks now run under a hard `Task.yield`/`Task.shutdown` deadline
(15s, configurable), and ExAws is capped at two attempts with 5s timeouts. There
is a regression test with a socket that accepts and then says nothing.
## Structure
The transport is extracted to `PhoenixKit.Mailer.SmtpTransport` — a pure function
of the credentials map, depending on nothing. `Mailer` and `Validators` both use
it, so "tested" and "sent" are literally the same options, and the
Integrations → Validators → Mailer → Integrations cycle is gone.
Two more things it fixes, both found by testing rather than reading:
- **`depth` is not decoration.** gen_smtp's socket layer ships `{depth, 0}`
(smtp_socket.erl:43,52) and merges it into whatever we pass. Depth 0 means "no
intermediate CAs", so every real chain fails verification and the handshake
dies with `:tls_failed`. Omit the key and the connection fails 4/4; set it to
anything >= 1 and it succeeds. There is a test guarding it.
- **MX lookups are off.** gen_smtp resolves the relay's MX records and connects
to the targets, while we pin SNI and the hostname check to the configured name
— a guaranteed certificate mismatch for any relay whose name carries MX records.
86 tests / 0 failures; --warnings-as-errors, credo --strict and dialyzer clean.timujinne
commented
Jul 14, 2026
Code review, round 2 — verdict was Needs-rework, now addressedGLM-5.2 (our usual reviewer) was returning 529 for hours, so this round was reviewed by two independent Opus agents — a code reviewer and an architecture/security reviewer. Both read the diff, the call sites and the deps ( The review's core objection was fair, and is the reason for the rework: this branch's own thesis is "a check that always says yes is worse than no check" — and the check it first shipped could say yes when it should say no, and no when it should say yes. Said yes when it should have said no
Said no when it should have said yes
Could park a LiveView process for twenty minutes
Both checks now run under a hard StructureThe transport moved to Two things the reviewers got wrong — and testing caught
Gates
Live, against a real relay and a real SES account: |
…veView
`with_deadline/1` ran the check under `Task.async/1`, which *links*. Every call
site is a LiveView callback, and a LiveView process does not trap exits — so any
crash inside the check killed the operator's page outright. The `{:exit, reason}`
clause meant to turn that into an error message could never run: the caller was
already dead by the time `Task.yield/2` would have returned it.
Verified rather than reasoned about — a non-trapping caller running exactly what
`with_deadline/1` ran:
task raises -> CALLER DIES
task exits abnormally -> CALLER DIES
task killed externally -> CALLER DIES
task exits :normal -> survives, {:exit, :normal}
task succeeds -> survives
deadline fires -> survives (Task.shutdown unlinks first)
The SMTP check happened to be safe by accident — `open_smtp/1` both rescues and
catches `:exit`. The SES check was not: `send_quota_request/2` only rescues, and
hackney is entitled to exit under it.
The harness moves to `PhoenixKit.Integrations.Probe`, built on `spawn_monitor/1`
— which is what LiveView's own `start_async` uses, for this exact reason. A crash
is now an error message, and the `:DOWN` branch is reachable code instead of a
comforting lie. Because it is a module rather than a private helper, the property
can actually be tested: three of its seven tests run the probe from a spawned,
non-trapping caller and fail with `{:caller_died, _}` if anyone swaps `Task.async`
back in.
Two smaller things fixed in passing, both consequences of the same seam:
- **The check lost the operator's language.** Gettext keeps the locale in the
process dictionary, which a spawned process does not inherit. Errors rendered
*inside* the check came back in the default language while errors rendered in
the caller ("Region is required", "Invalid port") came back translated — the
operator saw a mix. The locale is now carried across explicitly.
- **A reply landing in the instant the deadline fires was left in the caller's
mailbox.** The caller is a LiveView; it would log the stray as an unexpected
message. It is flushed.
Blast radius of the tightened `:key_secret` gate, checked live on the dev app
before committing: the existing SES connection still resolves and still sends; a
region-less connection now returns `{:error, :not_configured}` instead of raising
inside Swoosh's AmazonSES adapter (which interpolates the blank region into the
hostname), so it fails cleanly where it previously crashed — it could never send
either way; and `aws_ses` is the only `:key_secret` provider, so nothing else is
touched.
Timings on a real relay and a real SES account, all far inside the 15s deadline:
SES bad key 1.7s (includes the confirm-retry), SES good 1.1s, SMTP bad password
0.6s, SMTP good 0.3s.
93 tests / 0 failures in the affected suites; credo --strict and dialyzer clean;
the full suite gains no failures (every failure in it also fails without this
change).…te function ExDoc resolves `Mod.fun/arity` references and warns when the target is private, which do_validate/2 is. Name the module instead — the sentence is about where validation falls through, not about that particular function.
timujinne
commented
Jul 14, 2026
Round 3 — one more bug, found in the fix for the last oneFirst, a correction to the round-2 note aboveI filed Only Ten of twelve findings from that review stand, and all five MUST-FIX items landed. The new bug: a failed connection check killed the operator's pageThe round-2 rework put both checks under a deadline, because
Measured rather than reasoned about — a non-trapping caller running exactly what
The SMTP check was safe only by accident — Fix: the harness moves to Making it a module rather than a private helper is the point: the property can now be tested. Three of its seven tests run the probe from a spawned, non-trapping caller and fail with Two smaller bugs fell out of the same seam:
Blast radius of the tightened |
| SES wrong key (incl. confirm-retry) | 1.7 s → Invalid credentials |
| SES valid | 1.1 s → ok |
| SES unreachable region | 0.1 s → Could not reach |
| SMTP wrong password | 0.6 s → Invalid credentials |
| SMTP valid | 0.3 s → ok |
| SMTP unreachable host | 0.005 s → Could not reach |
No. A "wrong credentials" verdict comes from an HTTP 403, which is immediate and not retried (ExAws retries transport errors, not 403s), so the pathological 2 × 2 × 5s path needs the request to time out and return a signature error — contradictory. And the degraded case is a less precise red cross, never a false green.
Gates
93 tests / 0 failures in the affected suites (86 → 93, all seven new ones are the probe's). --warnings-as-errors, credo --strict, dialyzer and mix docs all clean. The full suite gains no failures: every failure in it also fails without this branch, and the handful that differ run-to-run pass in isolation (pre-existing sandbox contention — Activity.log/1 hits DBConnection.OwnershipError on main too).
On reviewers
GLM-5.2 was returning 529 for the whole of this work, so rounds 2 and 3 were reviewed by Opus agents instead. The second-round architecture reviewer has now hit a hard billing limit mid-run, so this round's findings are mine, verified the same way the others were: by experiment against the running app and by reading gen_smtp, ExAws and LiveView rather than trusting recollection. That is worth stating plainly rather than implying a review happened that did not.
…zer after the merge
Review follows the repo convention
(dev_docs/pull_requests/<year>/<pr>-<slug>/CLAUDE_REVIEW.md). It records what
three rounds found and fixed, and is explicit about who reviewed what: GLM-5.2
was unreachable throughout, so rounds 1-2 were Opus agents and round 3 is
self-verified against the running dev app. It also corrects the round-2 note,
which filed auth: :always under reviewer error - that hypothesis was mine, and
the reviewer had in fact recommended keeping it.
Merging main brought two dialyzer consequences:
- qr_login.ex trips call_without_opaque at Task.yield/2. It is upstream code and
a false positive of the same class the file already suppresses for gettext:
Task.Supervisor.async_nolink/2 returns a %Task{} dialyzer knows structurally,
Task.yield/2 declares Task.t() opaque. Surfaced now because the dep upgrades in
867bc5b rebuilt the PLT. Suppressed, with the reasoning written down.
- It is also independent confirmation of this PRs own HIGH finding. 303286d
reached the identical conclusion in the same week, in its own words: "async_
nolink (not Task.async): the lookup must never be able to crash the calling
LiveView via a link". Probe stays on spawn_monitor rather than switching to the
house pattern - a connection check touches neither repo nor mock, so it needs
neither $callers nor supervision, and async_nolink would cost a suppression in
new code. That reasoning now lives in the moduledoc.timujinne
commented
Jul 14, 2026
Merged |
timujinne
commented
Jul 14, 2026
Hold — a late review landed, verdict Needs-reworkRetracting the "ready" note above until this is fixed. Two findings, both proven by execution, both mine:
Verifying both against source now and fixing. Do not merge this branch yet. |
…cket
A late review caught two things the green gates did not, because neither had a
test. Both are reproduced below against the running app.
## The retry cap did not cap retries — it crashed them
`retries: [max_attempts: 2]` looks like a cap. ExAws merges config overrides with
`Map.merge` (config.ex:121), which is SHALLOW, so the list REPLACES the default
`[max_attempts: 10, base_backoff_in_ms: 10, max_backoff_in_ms: 10_000]` and takes
both backoff keys with it. `ExAws.Request.backoff/2` then evaluates
`nil * :math.pow(2, attempt)` and raises. The rescue below it swallowed the
ArithmeticError, so the check performed *zero* retries and reported an arithmetic
error while the real cause scrolled past above it:
before: HTTP ERROR: :nxdomain ... ATTEMPT: 1
SES connection check failed: %ArithmeticError{}
after: HTTP ERROR: :nxdomain ... ATTEMPT: 1
HTTP ERROR: :nxdomain ... ATTEMPT: 2
{:error, "Could not reach AWS SES"} (278 ms)
The comment above it claimed "two attempts survives a single blip". It survived
nothing. All three keys are mandatory; the comment now says so.
## The probe relocated the hang instead of removing it
The last commit replaced `Task.async/1` with `spawn_monitor/1` because Task links,
and a crashing check killed the LiveView. That much was right. But `spawn_monitor`
watches in one direction only: the deadline lives in the caller's `receive/after`,
so when the LiveView goes away mid-check — the operator hit refresh — nothing is
left alive to fire it. The check stays parked in gen_smtp's 20-minute `?TIMEOUT`
holding its socket, and, being unlinked, it is now unreachable rather than merely
slow. Linked, it would at least have been reaped. Measured:
caller dies mid-check, spawn_monitor -> check still alive (socket held 20 min)
caller dies mid-check, link+unlink -> check reaped with it
The moduledoc justified the choice by claiming LiveView "monitors rather than
links". That was wrong, and I had asserted it from reading the `:DOWN` handler in
channel.ex without opening async.ex. LiveView does BOTH: `Task.start_link/1`, a
monitor on top for result delivery, and the work wrapped in
`try/after Process.unlink/1` so the child unlinks before it dies. The link reaps
the child when the parent goes; the unlink stops the child taking the parent with
it. Handling one direction and not the other is worse than handling neither.
`Probe` now does the same, and unlinks before killing at the deadline (`:kill` is
untrappable, so the check cannot unlink itself and the link would carry `:killed`
straight back). The check also waits for a go-ahead so it cannot finish, or die,
before the monitor is in place.
## The new logic was the untested logic
That is why both survived. Fixed at the root rather than patched around:
- The CA store is now a parameter of `SmtpTransport.config/2` — it was always a
pure function of (credentials, CA store), the store was just ambient — so the
fail-closed branch, the security-critical one, is finally reachable from a test.
- `request_send_quota/3` takes its requester, so the confirm-retry (the behaviour
that keeps a *valid* key from being called invalid) is tested without AWS.
- `interpret_ses_error/1` is pure and now public, so the mapping from an AWS error
body to an operator-facing verdict is tested against real SES codes.
- A fake relay that greets and advertises no AUTH verb proves the
`{:missing_requirement, _, :auth}` carve-out, which was asserted and never run.
86 -> 107 tests, 0 failures.
## Also fixed
- `send_quota_request/2` rescued but did not catch exits; hackney reaches its pool
through `GenServer.call`, which exits. Harmless while the probe was unlinked
(it lost the diagnosis, not the caller); load-bearing again now that the link is
back.
- The check no longer inherits gen_smtp's default `{retries, 1}`
(gen_smtp_client.erl:38), which probed a temporarily-failing relay twice and
could push a slow failure past our own 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.
- `InvalidAccessKeyId`, `ExpiredToken` and `TokenRefreshRequired` are invalid
credentials, not "AWS SES error: <code>" — and they now go through the
confirm-retry like the rest.
- The confirm-retry waits a full second, not 700ms: SES throttles GetSendQuota at
about one request per second and the retry doubles our rate against it, so a
genuinely invalid key could come back as "AWS SES is busy".
- The `FunctionClauseError` rescue is a wide net that logged nothing; it now leaves
a trace, so a real bug elsewhere cannot vanish as "Incomplete SMTP settings".
- The tarpit test leaked its acceptor: `spawn_link` plus a `:normal` test exit does
not propagate, so the acceptor, its listener and the accepted socket outlived
every run for the life of the VM.
- providers.ex carried a comment describing the credential gate as it was before
this branch tightened it.
credo --strict, dialyzer and mix docs clean. Live on the dev app: both integrations
validate, both reject bad credentials, and a real send still goes out.… bare green tick
SES answers `AccessDenied` to GetSendQuota when the key is scoped to `ses:SendEmail`
alone — AWS's own least-privilege guidance. Reporting that as "invalid credentials"
put a permanent red cross on a correctly configured integration, so the check passed
it instead and wrote the caveat to `Logger.info`.
Which is where the caveat stayed. The operator saw an unqualified "Connection
verified", and `AccessDenied` proves only that the signature is valid for *some* AWS
principal — a key from the wrong account lands there too. That is this branch's own
thesis ("a check that always says yes is worse than no check") reopened at the one
door it had closed.
A check can now pass with something to say: `{:ok, note}` alongside `:ok`. The
connection is still connected and still sends — `get_credentials/1` resolves it
exactly as before — but the note is stored in `validation_status` and rendered next
to the badge, in both the connection list and the form, in warning rather than error
colours. The operator reads "Connection verified — Credentials are valid, but not
authorised for GetSendQuota; sending was not verified", which is the whole truth and
is what they need to decide whether to care.
Threaded through `Validators` -> `Probe` -> `do_validate/2` -> `validate_connection/2`
/ `validate_credentials/2` -> `record_validation/2` -> the templates. Dialyzer caught
the one place the widening had not reached (`record_validation/2`'s contract silently
narrowed `result` at the call site, which made the new clause unreachable) — which is
what it is for.
Live on the dev app: recording a pass-with-note leaves the connection `"connected"`
and still resolvable for sending, and the note comes back out of the database.
107 tests / 0 failures in the affected suites; credo --strict, dialyzer and mix docs
clean; the full suite gains no failures.…view Two of this branch fixes change the SEND path, not just the check, and neither was written down: SMTP now stops on images with no CA bundle (it used to proceed with certificate verification disabled, which is why slim images never noticed they had none), and configured relays are no longer MX-resolved. Both are in CHANGELOG under Unreleased, and :no_ca_store is now in deliver_via_integration/3 own @doc, which enumerated every other error it can return. The published review is updated with the final round - the one that arrived after the branch had been declared ready and found a shipped crash plus a leak introduced by the previous round fix. Its closing line is the part worth keeping: green PRs did not catch either, because neither had a test.
timujinne
commented
Jul 14, 2026
Hold lifted — both findings fixed, and the reason they got through is fixed tooThe late review's two findings were real, and I reproduced both against source and against the running app before touching anything. Details are in the published review; the short version: 1. The retry cap did not cap retries — it crashed them
The comment above it claimed "two attempts survives a single blip". It survived nothing. 2. My own |
| caller dies mid-check | check |
|---|---|
spawn_monitor (as shipped) | still alive — socket held 20 min |
spawn_link + monitor + unlink | reaped with its caller |
And the moduledoc I wrote to justify it — that LiveView "monitors rather than links" — was simply wrong. I asserted it from the :DOWN handler in channel.ex without opening async.ex. LiveView does both: Task.start_link/1, a monitor on top for result delivery, and the work wrapped in try/after Process.unlink/1 so the child unlinks before it dies. The link reaps the child when the parent goes; the unlink stops the child taking the parent with it. Handling one direction and not the other is worse than handling neither. Probe now does the same, and unlinks before killing at the deadline, since :kill is untrappable.
The reason both got through, fixed at the root
The reviewer's closing line is the one worth keeping: green PRs did not catch these, because neither had a test. The new logic was the untested logic. So the seams came with the fixes:
- the CA store is a parameter of
SmtpTransport.config/2— it was always a pure function of (credentials, CA store), the store was just ambient — so the security-critical fail-closed branch is finally reachable from a test; request_send_quota/3takes its requester, so the confirm-retry is tested without AWS;interpret_ses_error/1is pure and public, so the AWS-error mapping is tested against real SES codes;- a fake relay that advertises no
AUTHverb proves the{:missing_requirement, _, :auth}carve-out, which had been asserted and never run; Probe's tests now run in both directions — the asymmetry (five tests for "check dies, caller survives", none for "caller dies, check is reaped") is exactly why the leak survived the fix.
86 → 107 tests, 0 failures.
Also in this round
AccessDenied used to pass with a "note" that went only to Logger.info — so the operator saw a bare "Connection verified", while AccessDenied proves only that the signature is valid for some AWS principal (a key from the wrong account lands there too). That was this branch's own thesis reopened at the one door it had closed. A check can now pass with something to say: the connection is connected and sends exactly as before, but the caveat is stored and rendered next to the badge. Dialyzer caught the one place the widening had not reached.
Plus: catch :exit on the SES path (hackney reaches its pool through GenServer.call, which exits); gen_smtp's default {retries, 1} disabled for the check only; InvalidAccessKeyId / ExpiredToken / TokenRefreshRequired treated as invalid credentials; the confirm-retry waits a full second, since SES throttles GetSendQuota at ~1/s; and the tarpit test no longer leaks its acceptor for the life of the VM.
Upgrade note — now in CHANGELOG
Two fixes change the send path, not just the check, and neither had been written down:
- SMTP sending stops on images with no CA bundle (
{:error, :no_ca_store}) instead of proceeding with certificate verification disabled — which is why slim images (distroless, scratch, some Alpine builds) never noticed they had none. Installca-certificates. A relay with no credentials still degrades rather than failing. - Configured relays are no longer MX-resolved (
no_mx_lookups: true). If you sethostto a bare domain and relied on MX resolution, point it at the relay.
Gates
107 tests / 0 failures; --warnings-as-errors, credo --strict, dialyzer, mix docs clean; the full suite gains no failures. Live on the dev app: both integrations validate, both reject bad credentials, and a real send still goes out through the SMTP integration.
Ready to merge.
…alidators Only CHANGELOG.md conflicted: both sides wanted the top of the file. Both belong - our Unreleased block sits above upstream new 1.7.193 release section.
Only CHANGELOG.md conflicted (top-of-file, as before); providers.ex auto-merged. Our stale-comment fix and all Phase-1 work intact.
# Conflicts: # CHANGELOG.md
ddon
commented
Jul 18, 2026
Please solve the conflict |
ddon
commented
Jul 19, 2026
Still this branch has conflicts |
timujinne
commented
Jul 19, 2026
Closing as superseded: every change on this branch (the SMTP transport TLS fix, the Test Connection validators, the Probe, and all their tests) landed on main via #647 and shipped in 1.7.203. After syncing this branch with main the residual diff is zero files. |
Two defects in the e-mail integrations shipped in v1.7.190 (PR #633). One of them means the universal SMTP provider cannot send at all.
1. 🔴 SMTP could not send — the TLS options were missing
gen_smtp supplies no TLS options of its own, and OTP's
:sslnow defaults toverify: :verify_peerwith no CA store. Against a real relay:ssl: true){:options, :incompatible, [verify: :verify_peer, cacerts: :undefined]}tls: :always):tls_failedThe earlier
tls: :if_availablehid this: when the handshake failed, gen_smtp silently fell back to plaintext — mail went out with the relay password on the wire. Making the transport fail-closed (#633) turned that silent downgrade into an outright failure, which is how the bug surfaced.swoosh_config_for/1now passes proper TLS options in both modes — system CA certs,verify_peer, SNI, and the hostname check:ssldoes not perform on its own. They ride onsockoptsfor implicit TLS (gen_smtp hands those straight to:ssl.connect/4) and ontls_optionsfor STARTTLS.Verified against a live relay: 465 and 587 both connect, and a real send now succeeds over verified TLS.
2. 🟠 "Test Connection" verified nothing for
aws_sesandsmtpNeither provider declared a validation, so
do_validate/2fell through to the:okcatch-all andrecord_validationstamped the connection "connected" without a single byte leaving the box. Paste a wrong key or a bad SMTP password → green check, failing send.New
PhoenixKit.Integrations.Validators:aws_ses/1asks SES itself for the account send quota — a rawExAws.Operation.Query, so noex_aws_sesdependency is needed. It proves the credentials are real and authorised for SES in that region.smtp/1opens a real session and authenticates.auth: :alwaysis deliberate: with gen_smtp's default, a relay that does not demand authentication would accept a wrong password and report success. Its options come fromMailer.swoosh_config_for/1, so the check exercises exactly the transport a real send uses — one source of truth, no drift between "tested" and "sent".Verified live with real credentials:
Tests
The SMTP provider test used to assert
:ok = validate_connection(...)on fabricated credentials — it only passed because validation was a no-op. It now asserts what actually matters (credentials stay retrievable from the"configured"status) and adds a case proving an unreachable relay is rejected.73 tests, 0 failuresacross the mailer + integrations suites;--warnings-as-errors,credo --strictanddialyzerall clean (dialyzer caught a dead{:error, reason}clause —gen_smtp_client.open/1only ever answers with a three-element error tuple).