feat(admin): add a Test connection button to SMTP settings (BUNYIP-433) #427

Merged
longjacksonle merged 1 commit from feat/BUNYIP-433-smtp-test-connection into main 2026-08-01 05:02:42 +02:00

What

Adds a Test connection button to the admin email settings page. One click opens a real SMTP connection to the configured relay, negotiates TLS, and authenticates, without sending any mail, then reports back what succeeded or the specific stage that failed.

Resolves BUNYIP-433.

Why

Until now the only way to verify SMTP config was to trigger a real email and wait to see whether it arrived. PMS-669 is the worked example of what that costs: a rotated password silently broke delivery while everything still looked configured, and it was only caught after verification emails stopped landing. This button is the control that would have caught it in seconds and named the cause.

How it works

  • UI: a Test connection button in its own form, separate from Save. It submits no fields, so it always tests the SAVED settings, never unsaved edits (the helper text says so). The result renders as a banner: success, or a failure naming the stage.
  • API: POST /v1/admin/email/test loads the saved config and runs EmailService::test_connection. Returns 200 { ok, stage, message } for a reached target (a failing relay is a diagnostic result, not an API error); only the rate-limit trip surfaces as 429.
  • Probe: drives the handshake by hand via lettre's AsyncSmtpConnection. A plain TCP reach first, so an unreachable host reports as connect distinctly from a TLS error; then the implicit-TLS wrapper or STARTTLS upgrade (tls); then AUTH (auth, the stage that catches a rotated/rejected password). QUIT after AUTH, so no message is ever sent.
  • Rate limited: RateLimitConfig::SMTP_TEST, 6 per 5 minutes per admin, so the button cannot be used to hammer the relay. Runs inside the existing per-route rate-limit floor.
  • Audited: each run writes AdminEmailConnectionTested with host/port/tls/ok/stage. Never the password.

Acceptance criteria

  • Test connection button present on the email settings page.
  • A correct configuration reports success (authenticate_succeeds_when_relay_accepts).
  • A wrong password reports an authentication failure specifically (authenticate_reports_auth_stage_when_relay_rejects).
  • An unreachable host reports a connection failure specifically (test_connection_reports_connect_stage_for_unreachable_host).
  • No mail is delivered to a real recipient by the test (QUIT after AUTH; verified in code and by the mock relay).
  • The action is rate limited (RateLimitConfig::SMTP_TEST).
  • Tests covering the success and authentication-failure paths.

Testing

just check-container green (fmt + clippy -D warnings + cargo test --workspace --all-targets), including 5 new tests. Verified end to end on the dev stack as an admin: the button renders, a POST returns the specific connect-stage diagnostic banner ("Could not connect to localhost:465: Connection refused"), and the audit row lands without the password.

Follow-up (out of scope, noted in the ticket)

A periodic background health check that alerts on failure. Useful later; this button answers the immediate "did the settings I just entered work?" question first.

🤖 Generated with Claude Code

https://claude.ai/code/session_01US9AFL2ZTGrLH9TEPSmNfm

## What Adds a **Test connection** button to the admin email settings page. One click opens a real SMTP connection to the configured relay, negotiates TLS, and authenticates, without sending any mail, then reports back what succeeded or the specific stage that failed. Resolves BUNYIP-433. ## Why Until now the only way to verify SMTP config was to trigger a real email and wait to see whether it arrived. PMS-669 is the worked example of what that costs: a rotated password silently broke delivery while everything still looked configured, and it was only caught after verification emails stopped landing. This button is the control that would have caught it in seconds and named the cause. ## How it works - **UI**: a Test connection button in its own form, separate from Save. It submits no fields, so it always tests the SAVED settings, never unsaved edits (the helper text says so). The result renders as a banner: success, or a failure naming the stage. - **API**: `POST /v1/admin/email/test` loads the saved config and runs `EmailService::test_connection`. Returns `200 { ok, stage, message }` for a reached target (a failing relay is a diagnostic result, not an API error); only the rate-limit trip surfaces as 429. - **Probe**: drives the handshake by hand via lettre's `AsyncSmtpConnection`. A plain TCP reach first, so an unreachable host reports as `connect` distinctly from a TLS error; then the implicit-TLS wrapper or STARTTLS upgrade (`tls`); then AUTH (`auth`, the stage that catches a rotated/rejected password). QUIT after AUTH, so no message is ever sent. - **Rate limited**: `RateLimitConfig::SMTP_TEST`, 6 per 5 minutes per admin, so the button cannot be used to hammer the relay. Runs inside the existing per-route rate-limit floor. - **Audited**: each run writes `AdminEmailConnectionTested` with host/port/tls/ok/stage. Never the password. ## Acceptance criteria - [x] Test connection button present on the email settings page. - [x] A correct configuration reports success (`authenticate_succeeds_when_relay_accepts`). - [x] A wrong password reports an authentication failure specifically (`authenticate_reports_auth_stage_when_relay_rejects`). - [x] An unreachable host reports a connection failure specifically (`test_connection_reports_connect_stage_for_unreachable_host`). - [x] No mail is delivered to a real recipient by the test (QUIT after AUTH; verified in code and by the mock relay). - [x] The action is rate limited (`RateLimitConfig::SMTP_TEST`). - [x] Tests covering the success and authentication-failure paths. ## Testing `just check-container` green (fmt + clippy `-D warnings` + `cargo test --workspace --all-targets`), including 5 new tests. Verified end to end on the dev stack as an admin: the button renders, a POST returns the specific connect-stage diagnostic banner ("Could not connect to localhost:465: Connection refused"), and the audit row lands without the password. ## Follow-up (out of scope, noted in the ticket) A periodic background health check that alerts on failure. Useful later; this button answers the immediate "did the settings I just entered work?" question first. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01US9AFL2ZTGrLH9TEPSmNfm
feat(admin): add a Test connection button to SMTP settings (BUNYIP-433)
All checks were successful
E2E PR gate / Install + reachability (no deployment secrets) (pull_request) Successful in 31s
Check / fmt + clippy + build + tests (pull_request) Successful in 30m43s
Create release / Create release from merged PR (pull_request) Has been skipped
a90f1663b0
There was no way to verify SMTP configuration except triggering a real email and waiting to see whether it arrived, so the team trusted settings that might silently be broken. PMS-669 was exactly this: a rotated password quietly stopped delivery while everything still looked configured. This adds a control that answers "are these settings actually working?" in seconds, and names the specific part that is wrong.

The email settings page gains a Test connection button (its own form, separate from Save, so it always tests the SAVED settings rather than unsaved edits). It POSTs to a new POST /v1/admin/email/test, which opens a real SMTP connection to the configured relay, negotiates TLS, and authenticates, without sending any mail (it QUITs after AUTH). The result comes back as a banner: success, or a failure that names the stage - connect (host unreachable / refused), tls (negotiation failed), or auth (credentials rejected). The auth stage is the one that catches the PMS-669 case.

The probe (EmailService::test_connection) drives the handshake by hand via lettre's AsyncSmtpConnection: a plain TCP reach first (so an unreachable host is reported as a connection failure distinctly from a TLS error), then the implicit-TLS wrapper or STARTTLS upgrade, then AUTH. The action is rate limited per admin (RateLimitConfig::SMTP_TEST, 6 per 5 minutes) so it cannot be used to hammer the relay, and each run is written to the audit log (host/port/tls/ok/stage, never the password).

Tests: connect-stage classification for an empty and an unreachable host, and the success + authentication-failure paths against an in-process mock SMTP relay. just check-container green (fmt + clippy -D warnings + tests). Verified end to end on the dev stack: the button renders, a POST returns the specific connect-stage diagnostic, and the audit row lands without the password.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01US9AFL2ZTGrLH9TEPSmNfm
longjacksonle scheduled this pull request to auto merge when all checks succeed 2026-08-01 04:32:33 +02:00
longjacksonle deleted branch feat/BUNYIP-433-smtp-test-connection 2026-08-01 05:02:42 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/bunyip!427
No description provided.