Uh oh!
There was an error while loading. Please reload this page.
Add SMTP encryption, auth, certificate and timeout settings; add an optional queue hook - #668
Conversation
The smtp provider exposed only host/port/username/password, and the TLS mode was inferred from the port alone: 465 meant implicit TLS, anything else STARTTLS. An operator could not run implicit TLS on a non-465 port, could not talk to an internal relay in the clear or with a private CA, and could not choose the AUTH mode or a timeout. Adds five optional setup fields — security, auth, verify_cert, ca_cert (PEM) and timeout — all defaulting to the previous behaviour, so existing connections build byte-identical options. SmtpTransport applies them; the Test Connection probe now reads the operator's auth choice instead of hardcoding it, keeping 'never' but still upgrading 'if_available' to 'always' so a wrong password cannot pass the check. The setup-field component rendered every field as type=text, silently dropping the select/textarea/number types providers were already allowed to declare; it now renders by type. The website-wide integration form hand-rolled its own copy of that markup and is switched to the shared component, which is what makes the new fields reachable from /admin/settings/integrations/website. Also warns on the Email Sending settings page when the configured sender is not a full address: the email tracking module refuses to log such messages and swallows the rejection, so mail goes out while the log stays empty with nothing in the UI to explain it.
Every failure mode was inline: the mailer sent on the calling process, so a
package that wants to queue could only queue what its own API sent — not the
host application's password resets and confirmations, which go through the
statically configured mailer.
Adds an optional maybe_enqueue/2 callback to PhoenixKit.Email.Provider and calls
it from both delivery paths, right after interception, via one shared helper.
{:queued, ref} short-circuits the send and is handed back as
{:ok, %{id: ref, queued: true}}; :continue sends as before. The callback is
optional, so a package built against an older core still satisfies the
behaviour, and skip_queue: true lets a queue worker ask for the real send
without being offered its own job back.The case in Validators.smtp/1 sits outside Probe.run and its rescue, so a reason added to SmtpTransport later would raise CaseClauseError straight out of a LiveView callback. Found by external review (GLM-5.2).
…A parsing Second-round review findings (Kimi K3, external): - setup_field/1 hard-accessed :type/:options/:placeholder, so a provider contributed by an external module through integration_providers/0 without a :type would raise KeyError and take the form down — the opposite of the documented text-input fallback. - A stale PEM in ca_cert failed the whole SMTP config even with security=none or verify_cert=verify_none, i.e. an error about a certificate that would never be used. It is still rejected whenever TLS options are actually built. - Documented deliver_email/2's queued return shape.
External code review — GLM-5.2 (effort: max, read-only)Run against this branch on 2026-07-29; the reviewer had the full PR diff and the working tree, no shell. Verdict and findings below are unedited. Every finding is fixed in the follow-up commit What changed in response:
I've completed my verification. Here is my review. VERDICT: APPROVEThe SMTP rewrite is faithful (verified option-by-option against the pre-PR logic), the fail-closed Major (latent) — queued messages are intercepted twice; |
PR #668 (SMTP transport settings + optional queue hook): - Fix the dialyzer failure that broke `mix precommit` — the sender-address check had an unreachable catch-all clause, so `mix dialyzer` halted with exit 2 and the new `test.js` alias step never ran. - Make SMTP `username`/`password` optional. The PR added `auth: never` for "relays that authenticate by IP" and `security: none` for a plaintext internal relay, but marking the login required made both unreachable: the form refused to submit and `connected?/1` refused to call the connection configured, so mail silently fell back to the built-in mailer. The probe stops forcing `auth: always` when there is no login to prove. - Persist blanks on the personal integrations form the way the website form does (drop for `:password` only), so a cleared CA bundle or timeout no longer keeps its old value behind an empty field. - Read `:type` softly on both save paths, matching the hardening applied to `setup_field/1` — a provider field map without it rendered fine and then raised KeyError on submit. - Reject an SMTP timeout with a unit ("30s", "30 minutes") instead of silently using the number `Integer.parse/1` found. - Document the five new SMTP error reasons on `deliver_via_integration/3`, and warn on `maybe_enqueue/2` that `skip_queue: true` still re-runs `intercept_before_send/2`. PR #669 (Chart, StatusDot, ConnectAccountButton): - Skip `mix test.js` when the glob matches nothing — `node --test` with no file arguments walks the whole CWD, deps/ and _build/ included. Reviews: dev_docs/pull_requests/2026/{668-smtp-transport-settings-queue-hook, 669-chart-statusdot-connectbutton-components}/CLAUDE_REVIEW.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Independent re-check of release 1.7.217 (d7008e0) against each review's findings: every claimed fix confirmed present in the released code with file:line evidence, gates re-run (mix quality.ci exit 0, format clean, touched unit suites + test.js green). Records the CHANGELOG gap: the #667 auth fixes ship in 1.7.217 with no changelog entry.
The GLM reviewer pass on PR BeamLabEU#675 (COALESCE guard for the atomic custom_fields merge/delete) was left untracked in a second, branch-name mismatched directory. Filed under the PR's existing directory, whose slug matches the head branch fix-custom-fields-atomic-merge, per the one-directory-per-PR convention in CLAUDE.md. That puts it next to the PR's CLAUDE_REVIEW.md; the two earlier GLM reviews (BeamLabEU#668, BeamLabEU#680) instead sit in their own slug directories, which is drift worth not repeating. .pi-subagents/ holds mission JSON and run transcripts written by the external subagent harness; its reports land outside the repo, so the directory is scratch and is now ignored rather than committed.
Why
Two gaps in the email surface, found while wiring
phoenix_kit_emailsinto a host app for the first time.SMTP integrations could not be configured. The
smtpprovider exposed only host / port / username / password, and the TLS mode was inferred from the port: 465 meant implicit TLS, anything else meant STARTTLS — mandatory when credentials were present, opportunistic when not. So an operator could not run implicit TLS on a non-465 port, could not talk to an internal relay in the clear or with a private CA, and could not choose the AUTH mode or a timeout.Two shared pieces of the integrations UI were quietly broken.
setup_field/1rendered every field astype="text", so the:select/:textarea/:numbertypes the provider contract already allows were impossible — and the website-wide integration form carried a hand-rolled copy of that markup, so anything added to the shared component would not have appeared there anyway.Nothing could queue outgoing mail. A provider could observe a message (
intercept_before_send/2) but not take delivery over, so an optional package could only queue what its own API sent — never the host application's password resets and confirmations, which go through the statically configured mailer.What changed
SMTP settings — five optional setup fields on the
smtpprovider:securityauto/ssl/starttls/starttls_optional/noneauto= the old port-based ruleauthif_available/always/neverif_available(gen_smtp's own default)verify_certverify_peer/verify_noneverify_peerca_certtimeoutBlank fields reproduce the previous behaviour, so existing connections send exactly as before. Unknown values are rejected rather than coerced to
auto.Validators.smtp/1keeps building the probe from the very sameSmtpTransport.config/1, and now reads the operator'sauth— with one deliberate asymmetry documented at the call site:neverstaysnever, butif_availableis upgraded toalwaysfor the probe, because gen_smtp tolerates a failed AUTH and a wrong password would otherwise pass the check.The fail-closed
:no_ca_storerule is unchanged and still unreachable fromauto: it is bypassed only when the operator explicitly picksverify_noneorsecurity: none. Aca_certis no longer parsed when the chosen transport never builds TLS options.Setup fields —
setup_field/1renders by:type(with soft map access, so a provider contributed by an external module without a:typefalls back to a text input instead of raisingKeyError), andLive.Settings.IntegrationFormnow uses the shared component instead of its own copy. Credential-shaped fields staytype="text"on purpose — the existing rationale about browsers' password-save heuristics still holds.Queue hook — a new optional
maybe_enqueue/2callback onPhoenixKit.Email.Provider, called from both delivery paths right after interception through one shared helper.{:queued, ref}short-circuits the send and is returned to the caller as{:ok, %{id: ref, queued: true}};:continuesends as before. Because the callback is optional and guarded byfunction_exported?/3, a package built against an older core keeps working;skip_queue: truelets a queue worker ask for the real send without being handed its own job back.Sender warning — the Email Sending settings page now warns when the configured sender is not a full address. The optional emails package refuses to log such a message (its
Logchangeset requiresname@domain.tld) and swallows the rejection, so with the built-in defaultnoreply@localhostmail is delivered while the log stays empty and nothing in the UI explains it.Verification
mix test test/phoenix_kit/mailer/smtp_transport_test.exs— 28 tests, 0 failures (11 before). New cases cover explicitsslon a non-465 port,starttlsforced on 465,none→tls: :never,verify_nonewithout a CA store, a PEM bundle (and garbage rejected), the auth modes, the timeout in milliseconds, and rejection of unknown values.mix format,mix credo --strictandmix compile --force --warnings-as-errorsare clean on the changed files.noreply@localhostand disappears for a real address.