feat(web): SMTP relay in the setup wizard with provider presets - #233
Conversation
Add validateSmtpRelay to the shared setup validators: it opens a real SMTP submission connection (implicit TLS or STARTTLS), runs EHLO and an AUTH exchange, and never sends a message — so a wrong host/port/TLS/ credential is caught at setup, not at first send. SSRF-guarded and bounded by a per-step timeout, mirroring the existing PostHog-host check. Wire it into /api/setup/validate-provider under provider='smtp' alongside the existing API-key path.
Add a fourth transport to the wizard: an SMTP relay with Mailgun/Postmark/ SendGrid/Brevo/custom presets that prefill host/port/TLS, plus a username/ password form. Carry the smtp fields through ProviderChoice / EmailStepDraft / validateEmailStep / buildProviderEnv (EMAIL_PROVIDER=smtp + SMTP_RELAY_*), and validate the relay with a live handshake before advancing. Reframe the step copy as an honest choice: run your own MTA, Amazon SES, or an SMTP relay.
…izard Add the smtp variant to SendingConfig + parseSending + buildEnvPatchFromConfig (host/username/password required; port/TLS default to 587/STARTTLS), re-export validateSmtpRelay, and add an SMTP-relay branch to the terminal wizard with the same presets and a live handshake check. No compose profile change — a relay is external, so getActiveProfiles adds no mta service for it.
validateEmailStep/buildProviderEnv for the smtp choice (presets fill, missing creds block, default port omitted), buildEnvPatchFromConfig + parseSending rejections in setup-cli, and validateSmtpRelay input guards (SSRF, port range, missing credentials) that return before any socket opens.
Add smtp to the supported send-provider kinds and the factory layout, and describe the honest own-MTA / SES / SMTP-relay wizard choice (with live credential validation) in the setup-cli guide.
Review — round 1Verdict: REQUEST_CHANGES Blocking
Improvements
Notes
|
The STARTTLS upgrade had no timeout, so a relay that answers 220 and then never completes the TLS handshake would leave the promise pending forever and hang the setup endpoint / CLI step. Wrap it in the same SMTP_PROBE_TIMEOUT_MS timer as open()/read() (destroy the socket and reject on expiry). Raw remote replies were also embedded verbatim (and unbounded) in operator- facing error strings, so a non-SMTP or hostile server could echo its banner or terminal control codes into the setup response. Truncate to 120 chars and strip control characters before echoing.
The web wizard and setup CLI each carried their own copy of the Mailgun / Postmark / SendGrid / Brevo host/port table, kept in sync only by a comment. Move the single source of truth to @owlat/shared/setupSendingPresets and consume it from both so the two can never drift.
… module setup.ts had grown past the file-size ratchet, largely from the inline SMTP relay branch. Move pickSendingProvider into setupSendingProvider.ts and lift the shared validateWithSpinner helper into lib/progress, dropping setup.ts back under the cap and reverting the drive-by reformatting of untouched functions. While extracting, close two gaps the reviewer flagged: the SMTP port is now validated at the prompt (digits, 1-65535) and the same numeric value that fed the handshake is what gets persisted to SMTP_RELAY_PORT (so '587x' can no longer validate 587 but write 587x), and host/username/password prompts reject empty input up front instead of deferring to the network probe.
… 587 A present-but-non-numeric smtp.port was silently substituted with 587, so the validate-provider endpoint could report success for a different port than the caller asked about. Reject it with a 400; an absent port still defaults to 587.
Author response — round 2Thanks for the thorough pass. All three blocking findings and every improvement are addressed. Blocking
Improvements
To keep the newly-touched |
Review — round 2Verdict: REQUEST_CHANGES All three round-1 blockers and all four improvements are verified fixed: Blocking
Improvements
Notes
|
The env patch omitted SMTP_RELAY_PORT when the port was the default 587. Both apply.post.ts (web) and mergeEnv (CLI) apply the patch over the on-disk .env, so re-running setup over an install that previously used 465/2525 kept the stale port while the handshake validated 587 — worst case SMTP_RELAY_SECURE flips to false against an implicit-TLS port and the first send fails. Always write the port (default '587' when blank) so it can't diverge from the validated value.
group() without an onCancel keeps prompting after Ctrl-C and stores the cancel symbol in the results, so a later result.port.trim() on a symbol threw a TypeError instead of the clean return-null every single-prompt path has. Detect a cancel in any field of the SES and SMTP groups and bail out cleanly.
The re-export comment named @owlat/shared/setupValidators; the deduped preset table lives in @owlat/shared/setupSendingPresets. Also move the 'blank host ⇒ operator fills it in' note off the port field's doc onto host, where it belongs.
Author response — round 3Both round-2 blockers and both improvements are addressed. Blocking
Improvements
Intentionally unchanged
|
Review — round 3Verdict: APPROVE Blockingnone Improvementsnone Both round-2 blockers and both improvements are verified fixed at head:
On the intentionally-unchanged Regression sweep of the three fix commits found nothing new; the earlier security properties (bounded |
Part of the 2026-07-10 experience plan (Sending Transports epic). Implements locked decisions #1 & #2: transport is instance-level (own MTA / SES / SMTP relay), and the long tail is a single generic SMTP relay with provider presets — no per-provider API adapters.
The backend
smtpsend adapter, theSMTP_RELAY_*env keys, and their inclusion inCONVEX_RUNTIME_ENV_KEYSalready shipped; this PR makes the setup wizard actually offer and validate an SMTP relay, end to end.What changed
useSetupWizard.ts) —ProviderChoicegainssmtp;EmailStepDraftcarries anSmtpRelayDraft;validateEmailStep/buildProviderEnv/PROVIDER_ENV_KEYS/PROVIDER_LABELS/buildSetupSummaryall carry the relay fields into anEMAIL_PROVIDER=smtp+SMTP_RELAY_*env patch. New exportedSMTP_RELAY_PRESETS(Mailgun / Postmark / SendGrid / Brevo / Custom) prefill host/port/TLS.setup/email.vue) — fourth "SMTP relay" option with a preset select + host/port/TLS/username/password form; a named preset locks the host to its documented submission endpoint, Custom leaves it editable. Step copy reframed as an honest choice (own MTA / Amazon SES / SMTP relay). Uses only FF design tokens +UiSelect/UiInput; error state wired./api/setup/validate-providergains ansmtpbranch backed by a new sharedvalidateSmtpRelay, which opens a real SMTP submission connection (implicit TLS or STARTTLS), runs EHLO + an AUTH exchange, and sends no message. SSRF-guarded (reusesisBlockedSsrfHost) and bounded by a per-step timeout, mirroring the existing PostHog-host check. Clear operator-facing error strings.SendingConfig+parseSending+buildEnvPatchFromConfigacceptprovider: 'smtp';validators.tsre-exportsvalidateSmtpRelay; the terminal wizard gains an SMTP-relay branch with the same presets and live handshake. No compose profile change — a relay is external, sogetActiveProfilescorrectly adds nomtaservice.Acceptance criteria
ProviderChoice/EmailStepDraft/validateEmailStep/buildProviderEnv/PROVIDER_ENV_KEYS→EMAIL_PROVIDER=smtp+SMTP_RELAY_*validators.ts+ config acceptEMAIL_PROVIDER=smtp(no compose profile change)Preserved behavior
apply.post.ts's delivery-provider floor already acceptssmtpviaisDeliveryProviderKind— no change needed.lib/env.ts;SMTP_RELAY_*were already runtime-pushed.Test notes
Vitest only (CI gate — not run locally per pipeline rules). Added: composable smtp validation + env-patch cases, setup-cli
buildEnvPatchFromConfig/parseSendingsmtp cases, andvalidateSmtpRelayinput-guard tests that return before any socket opens (no network in CI).Auto-merge pipeline: squash-merges on reviewer approval + green CI.