Skip to content

feat(1455): SMTP test-email button on the settings page - #1460

Merged
rumblefrog merged 1 commit into
mainfrom
feat-1455-smtp-test-email
May 25, 2026
Merged

feat(1455): SMTP test-email button on the settings page#1460
rumblefrog merged 1 commit into
mainfrom
feat-1455-smtp-test-email

Conversation

@rumblefrog

Copy link
Copy Markdown
Member

Closes #1455.

Summary

Adds a Send test email affordance to the SMTP card on
?p=admin&c=settings&section=settings so operators can verify
their SMTP credentials end-to-end (Symfony Mailer → relay →
recipient inbox) without waiting for the next real outbound mail
(password reset, ban protest, etc.). Pre-fix, broken SMTP routinely
shipped into production for days before anyone noticed.

  • New JSON handler system.test_email (gated
    ADMIN_OWNER | ADMIN_WEB_SETTINGS, mirroring every other
    settings-page-only handler).
  • Button server-rendered disabled when smtp.host / smtp.user /
    config.mail.from_email are empty; live-re-evaluated as the
    operator types so a fresh-install operator who just entered
    valid creds doesn't have to save first.
  • Recipient input defaults to the operator's own email address so
    the common "send the test to me" path is one click.
  • Rate limited at 1 attempt / 10s per panel install, stamped
    BEFORE the SMTP I/O so a hung relay can't be hammered while the
    first call is mid-handshake. File-backed throttle
    (`SB_CACHE/test-email-throttle`) with atomic tempfile + rename
    mirroring `_api_system_release_save_cache`.
  • Validation + smtp_not_configured branches short-circuit BEFORE
    the throttle file is stamped so a typo doesn't consume a slot.
  • Every send attempt (success OR mail_failed) lands an audit-log
    row interpolating the admin's name + recipient — test sends
    can't be used to silently probe SMTP credentials.

Operator-visible flow

  1. Configure SMTP (smtp.host / smtp.user / smtp.pass + a
    from_email), save the form.
  2. Recipient field auto-populates with the logged-in admin's
    email; tweak if desired.
  3. Click Send test email.
  4. Toast confirms the result:
    • Success: `Test email sent — Sent to .`
    • smtp_not_configured: `SMTP host, username, or password
      is empty. Configure SMTP first and save the form, then try
      again.`
    • rate_limited: `Test email throttled — try again in N
      seconds.`
    • mail_failed: `SMTP send failed. Check the audit log
      under Admin → System Log for the cause.`

Test plan

  • `./sbpp.sh phpstan` — 0 errors.
  • `./sbpp.sh ts-check` — passes.
  • `./sbpp.sh composer api-contract` — regenerated; clean
    diff.
  • `./sbpp.sh test --testsuite=api` — 352 tests, 1492
    assertions, all green (includes 9 new `testTestEmail*`
    PHPUnit tests + 4 paired snapshot files).
  • `./sbpp.sh test --testsuite=integration` — 576 tests
    green.
  • `./sbpp.sh e2e --workers=1 specs/flows/smtp-test-email.spec.ts`
    — 6 tests green (3 specs × chromium + mobile-chromium): happy
    path drives the full chain through mailpit + asserts the email
    lands, disabled-state arm, and native-validation arm.
  • PermissionMatrixTest pins
    `system.test_email => ADMIN_OWNER | ADMIN_WEB_SETTINGS`.
  • Manual smoke under `./sbpp.sh up` — happy path lands a
    message in mailpit, disabled state behaves correctly, rate
    limit fires the expected toast.
  • Adversarial review pass — every High / Medium / Low finding
    was addressed:
    • dropped `global $username` for `$userbank->GetProperty('user')`;
    • added refuse-if-prod-DB guard to the E2E throttle-clear shim;
    • corrected `?section=main` → `?section=settings` in the
      spec;
    • rewrote the `retry_after_seconds` docblock claim to match
      reality (the retry hint is in the message, not a separate
      field on the wire);
    • subject + body-subject now share a single string so inbox
      subject and body identifier match;
    • clarified throttle docblock: stamped BEFORE the SMTP wire,
      so mail_failed outcomes also consume slots (intentional);
    • tempfile cleanup now also fires on rename-failure (EXDEV
      across mounts);
    • audit log: paired the success-row body shape check with
      the count check so a future refactor that drops the
      admin/recipient interpolation fails loudly.

I have read the CLA Document and I hereby sign the CLA

GitHub issue #1455 — operators had no way to verify SMTP credentials
short of waiting for a real outbound mail (password reset, ban
protest, etc.), so broken SMTP routinely shipped into production for
days before anyone noticed. This change adds a "Send test email"
affordance inside the SMTP card on Admin → Settings → Main that
fires `system.test_email` (a new JSON handler) and surfaces an
operator-actionable toast — success or one of four structured
error envelopes (validation / smtp_not_configured / rate_limited /
mail_failed).

Key shape decisions:

- Permission gate: `ADMIN_OWNER | ADMIN_WEB_SETTINGS` — matches
  every other settings-page-only handler (sel_theme / apply_theme /
  clear_cache / preview_intro_text).
- Rate limit: 1 attempt / 10s per panel install, stamped BEFORE the
  SMTP I/O so a hung relay can't be hammered while the first call
  is mid-handshake. File-backed (`SB_CACHE/test-email-throttle`)
  with atomic tempfile + rename, mirroring
  `_api_system_release_save_cache`. Validation /
  smtp_not_configured short-circuit BEFORE the throttle file is
  stamped so a typo doesn't consume a slot.
- Button disabled at first paint when smtp.host / smtp.user /
  config.mail.from_email are empty (server-rendered), AND live-
  re-evaluated as the operator edits the form inputs so a fresh-
  install operator who just typed valid creds doesn't have to save
  first to see the button enable. Server-side guard is the
  `smtp_not_configured` envelope.
- Recipient defaults to the logged-in admin's email so the
  "send the test to me" path is one click; the operator can
  override.
- Audit log: every send attempt lands a row in `:prefix_log`
  (success OR mail_failed) so test sends can't be used to silently
  probe SMTP credentials or enumerate valid relay endpoints. Row
  body interpolates the admin's name + recipient.

Coverage:

- 9 PHPUnit tests under `SystemTest::testTestEmail*` — anonymous
  reject / malformed recipient / oversized recipient (RFC 5321
  cap via FILTER_VALIDATE_EMAIL) / smtp_not_configured /
  mail_failed / rate_limited / default recipient / "validation
  doesn't burn a slot" / "smtp_not_configured doesn't burn a
  slot". 4 paired snapshot files lock the wire format of every
  error envelope; the success-shape snapshot lives in the E2E
  suite (PHPUnit has no SMTP test seam).
- 1 PermissionMatrixTest row pins the
  `ADMIN_OWNER | ADMIN_WEB_SETTINGS` gate.
- 6 Playwright tests (3 specs × chromium + mobile-chromium) under
  `web/tests/e2e/specs/flows/smtp-test-email.spec.ts` — happy
  path drives the full chain through mailpit + asserts the email
  lands at the operator's address with the right subject, plus
  disabled-state + native-validation arms. Throttle-cache shim
  (`web/tests/e2e/scripts/clear-test-email-throttle-e2e.php`)
  clears the 10s lock between specs so parallel project profiles
  don't collide; the shim mirrors `reset-e2e-db.php`'s
  refuse-if-prod-DB guard.
- FAQ doc entry under `docs/src/content/docs/faq/index.md`
  walks operators through the prerequisites + rate limit +
  audit-log surfacing.
@rumblefrog
rumblefrog added this pull request to the merge queue May 25, 2026
Merged via the queue into main with commit 95a5eb6 May 25, 2026
7 checks passed
@rumblefrog
rumblefrog deleted the feat-1455-smtp-test-email branch May 25, 2026 22:26
@github-actions github-actions Bot locked and limited conversation to collaborators May 25, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SMTP - Add test email function

1 participant