Repository navigation
Email 5/7 (PR 4): admin Email setup page with a test email #423
Description
Part of #418. Read the parent first. Its Email setup page section is the contract here. Step 5 of 7. server/ is short for apps/hocuspocus.server/.
Goal
Give the admin one page that answers "is email set up correctly?" and can send a test email. It needs no learning curve. The page never accepts or shows a secret; .env stays the only source.
Owner and gate
- Builds it: an agent or engineer.
- Gate to merge: Email 3/7 (PR 2): typed failures, delivery and a safe DLQ drain #421 (PR 2) is merged, because the test email uses
deliverEmail. The page shows webhook data when Email 4/7 (PR 3): Resend webhook for bounces and complaints #422 (PR 3) has shipped, and "not set up" when it has not.
Steps
-
Module
server/src/modules/email-setup/, in the shape ofserver/src/modules/link-metadata/:domain/toEmailSetupView.ts:toEmailSetupView(delivery, facts, extras): EmailSetupView. Inputs areconfig.email.deliveryandconfig.email.facts, so no secret string enters the module. It is the only code that builds the page data. It masks the bounce row's address withmaskEmail, dropsusername, and cutsreasonto 80 characters.infra/testSendBudget.ts:createTestSendBudget(redis). It wrapsnew RateLimiterRedis({ storeClient: redis, points: 1, duration: 60, keyPrefix: 'email-test-send' })and callsconsumeWithin(server/src/lib/consumeWithin.ts), asserver/src/modules/mcp/infra/toolBudget.tsdoes.limited,unavailableand no Redis all refuse.http/controller.tsandhttp/router.ts, usingokandfailfromserver/src/http/envelope.ts.module.ts:init(deps: InitDeps): InitResult. Deps:delivery,facts,getEmailProvider,deliverEmail,getEmailDlqDepth,getLatestBounce,redis,logger.
-
Routes, mounted in
server/src/api/routers/admin.router.tswithadmin.route('/email/setup', emailSetup.init(deps).router). The existingadmin.use('*', adminAuthMiddleware)(:31) covers them. The parent explains why this mount is a deliberate exception.GET /api/admin/email/setupreturns the view:- provider and status, with the problems for
invalid; EMAIL_FROM, andsetormissingper secret;SMTP_HOSTandSMTP_PORT;- a live
checkConnection, bounded byEMAIL_CHECK_TIMEOUT_MS; - the newest
get_email_bouncesrow (server/src/api/controllers/admin-audit.controller.ts:195-199), masked as above; - the email DLQ depth.
- provider and status, with the problems for
POST /api/admin/email/setup/test-sendtakes no body. It callsdeliverEmailto the signed-in admin's own address, with the key<namespace>/test/<uuid>. It answers with the message id, or the error kind and code.- Add
getEmailDlqDepth()toserver/src/lib/email/queue.ts, usingEMAIL_DLQ_NAME.
-
Page
apps/admin-dashboard/src/pages/email.tsx(create). Add one entry labeled Email setup toapps/admin-dashboard/src/constants/navigation.ts. Copy the layout and data-loading pattern ofapps/admin-dashboard/src/pages/notifications.tsx, and build from.cursor/docs/design-system.md. The page shows:- a status line:
ready,off, orinvalid, with each problem in plain words; - a table of values:
setormissingfor secrets, and plain values for the rest; - the connection check result;
- the webhook state;
- the email DLQ depth, with a link to the failure summary;
- a copy-ready
.envblock, with a blank line for each missing value and the note "Edit the host env file, then redeploy"; - one Send test email button, with its result shown inline.
It also says that the test email runs in
rest-api, and that the worker's state is in the failure summary. - a status line:
-
Public
/api/email/health: reduce it to{ status, queue_connected }, following Stop REST /health from leaking error text or taking every /api route down #405's rule that public routes return status only. Point the Notifications page's gateway card (apps/admin-dashboard/src/services/api.ts:476,:493-495,apps/admin-dashboard/src/pages/notifications.tsx:459) atGET /api/admin/email/setup. Update the route inserver/src/modules/openapi/domain/paths/email.ts.
Test (one file)
server/src/modules/email-setup/__tests__/unit/toEmailSetupView.test.ts. Feed the projection facts where every secret is set, and assert that each reads set. Feed it facts where every secret is unset, and assert missing. Feed it a bounce row, and assert the masked address, no username, and a reason of at most 80 characters. The input type carries no secret value, so the test checks the projection, not a leak.
Docs (same PR)
server/API.mdandserver/src/modules/openapi/domain/paths/admin.ts: both routes.apps/admin-dashboard/CLAUDE.md: the Email setup page is read-only for config. It never shows a secret value or tail. Credentials are never written from the UI.
Checks before push
bun run checkat the repo root, andbun testinserver/.- Import
domain/toEmailSetupView.tswithREDIS_HOST,REDIS_PORTandDATABASE_URLunset.
Deploy
The deploy subject is exactly (build): back front. The deploy step rolls every service, admin-dashboard included (.github/workflows/prod.docs.plus.yml:687-692).
Acceptance
- On staging, the page and the
GET /api/admin/email/setupresponse show every secret only assetormissing, and no unmasked address. - Public
GET /api/email/healthreturns onlystatusandqueue_connected, and the Notifications gateway card still works. - Send test email delivers to the admin's address and shows the message id.
- A second test email within 60 s is refused.
- Run locally with
SMTP_HOSTset andEMAIL_PROVIDERunset. The page showsinvalid, names the problem, and shows the.envlines to add. -
bun testis green, including the new file. - Every doc listed above is updated in this PR.
Exit evidence
Post as a comment here:
bun run checkandbun testoutput.- A screenshot of the page on staging, checked for unmasked addresses before posting.
- The JSON of
GET /api/admin/email/setup.
Revert
git revert the merge, then redeploy. Nothing else depends on these routes.
Activity
[-]Email 5/7 (PR 4): admin Email connector page (read-only config, test send)[/-][+]Email 5/7 (PR 4): admin Email setup page with a test email[/+]feat(email): add the admin Email setup page with a test send
docs(hocuspocus): list the status removal as Breaking and record the …
docs(hocuspocus): sync server docs with the 9 October fixes
docs(repo): record the 9 Oct security and email batch in the changelo…
refactor(email): tidy bounce, webhook and setup code after review
fix(admin): make the Email setup page announce results and read by ke…
HMarzban commentedon Oct 9, 2026
Fixed and live.
6d69afb7f,9df360006,08d907e94bbdaae66c(production run 37976785981, green).