Handoff — Email delivery settings (configurable From / Reply-to / CC / BCC)
Status: ✅ Built (2026-06-21). tsc + Biome clean; merge-logic unit tests added
(tests/lib/email/delivery.test.ts). No DB migration (reuses the existing settings table).
Decision locked: Approach A — API key stays in env, everything else is in-app config. (No Vault.)
Effort: medium.
What shipped
settingskeysemail_delivery(company From / Reply-to / Enabled) +email_process(per-flow CC/BCC).- Pure merge
resolveEmailDelivery(src/lib/email/delivery.ts) +sendCompanyEmailseam wrapper. - Setup → Email delivery section (status badge, From/Reply-to/Enabled, Send-test, invoice CC/BCC).
- Invoice Review & send dialog: CC/BCC (pre-filled from saved default) + "Save as default" toggle.
- All four call sites (welcome / pipeline / recruiting / invoice send + resend) now go through
sendCompanyEmail; the inlineemailConfiguredcheck is replaced bygetEmailStatus. - Open questions resolved: the
enabledkill switch is company-wide (every flow's send goes throughsendCompanyEmail); per-process reply-to override is stored in the data model but the UI exposes company-level reply-to only for v1.
1. Why this exists
Came out of the invoicing work. When an invoice was sent before Resend was configured, the email
seam skipped delivery (reason: 'no provider configured') while the invoice still flipped to
sent — surfaced to the user as a dead-end "email not configured" with nowhere in the app to fix
it. PR #28 made that error actionable and surfaced Resend as a top-level button. This feature
closes the loop: give admins an in-app place to configure email delivery and control
CC/BCC/Reply-to, instead of it being env-only.
Two user requirements drove the final shape:
- CC/BCC should be addable during a send, and saveable as the default for a given process (e.g. invoice sending) via a toggle.
- Reply-to (e.g. a real address, or a purely-informational
noreply@nightingalepm.com) should be changeable in-app.
2. The model — three layers
| Layer | Set where | Controls | Applies to |
|---|---|---|---|
| Company | /admin/setup → new Email delivery section | from, reply-to, enabled (kill switch) | all email |
| Per-process | Setup Menu (per process) or saved from a send dialog | default CC, BCC, optional reply-to override | that process (e.g. invoice sends) |
| Per-send | the send dialog (e.g. Review & send) | ad-hoc CC/BCC for this send + "Save as default …" toggle | this one send |
Merge order at send time: company from/reply-to/enabled → process default cc/bcc
(and reply-to override) → per-send cc/bcc (additive, de-duplicated). If enabled is false,
nothing sends (logged as skipped).
3. Grounding — what already exists (read these first)
- Setup Menu lives at
src/app/admin/setup/page.tsx— currently one<Section title="Company defaults">backed by a settings form. Add an Email delivery<Section>here. - Settings store = the
settingstable, rows keyed by(company_id, key)with ajsonbvalue. Read viagetSettingValue(svc, companyId, key)insrc/server/services/settings.ts. Write viaupdateSettingsinsrc/server/actions/settings.ts, which upserts with{ onConflict: 'company_id,key' }. New keys slot in with zero schema change. - Email seam =
src/server/services/email.ts:sendEmail(msg: EmailMessage)readsprocess.env.RESEND_API_KEY+process.env.EMAIL_FROM, POSTs to Resend, logsemail.sent/email.skipped/email.failed.EmailMessagealready hasreplyTo?and already passesreply_toto Resend. ✅ So reply-to needs no seam change — only a place to source it from.- It does not yet support
fromoverride,cc, orbcc. Resend natively supports all three.
emailConfiguredis computed inline atsrc/app/admin/invoices/page.tsx:116as!!(process.env.RESEND_API_KEY && process.env.EMAIL_FROM)and passed into the cockpit /ReviewAndSendDialog. This needs to also consider settings (from,enabled).- Send dialog =
src/components/admin/invoicing/ReviewAndSendDialog.tsx— takesemailConfigured, shows per-invoice email badges (Emailed/Email not configured/Email failed), and already has a pre-send note naming the env keys. Add CC/BCC here. - Actionable-error pattern to reuse =
src/components/admin/InvoiceActions.tsx(PR #28): skipped → "Add RESEND_API_KEY + EMAIL_FROM …, then Resend"; failed → "the provider rejected it …".sendTestEmailshould surface results the same way. sendEmailcall sites to migrate onto the new company-aware wrapper:src/server/actions/welcomeEmail.tssrc/server/actions/pipeline.tssrc/server/actions/recruiting.tssrc/server/services/invoice/send.ts(invoice send + resend paths)
4. Build plan
4.1 Data (no migration — new settings keys)
// key: 'email_delivery' (company-wide)
{ from: string; replyTo: string | null; enabled: boolean }
// default: { from: env.EMAIL_FROM ?? '', replyTo: null, enabled: true }
// key: 'email_process' (per-process defaults)
{ [process in 'invoice' | 'welcome' | 'recruiting' | 'pipeline']?: {
cc: string[]; bcc: string[]; replyTo?: string | null;
} }
4.2 Service — src/server/services/settings.ts
EmailDeliverySettings,ProcessEmailDefaultstypes +EMAIL_PROCESSESconst.getEmailSettings(svc, companyId): Promise<EmailDeliverySettings>(env fallback forfrom).getProcessEmailDefaults(svc, companyId, process): Promise<ProcessEmailDefaults>.getEmailStatus(svc, companyId)→'connected' | 'key_missing' | 'disabled'for the badge and to replace the inlineemailConfiguredcheck.
4.3 Seam — src/server/services/email.ts
- Extend
EmailMessagewithfrom?: string,cc?: string[],bcc?: string[](replyTo already there). Passcc/bccthrough to the Resend payload;fromoverrides the env default. - New wrapper
sendCompanyEmail(svc, companyId, msg, opts: { process; cc?; bcc? }): resolves company + process settings, honors theenabledkill switch, merges From / Reply-to / CC / BCC per §2, then delegates tosendEmail. Backward-compatible —sendEmailkeeps working. - Migrate the four call sites in §3 to
sendCompanyEmail(..., { process }). They already havesvc+companyIdin scope.
4.4 Actions — src/server/actions/settings.ts
updateEmailSettings(formData)— admin-gated; upsertemail_delivery(Zod: validfrom, optionalreplyTo, booleanenabled).updateProcessEmailDefaults(formData)— admin-gated; upsert one process'scc/bccintoemail_process(Zod: arrays of valid emails, capped length).sendTestEmail(formData)— admin-gated;sendCompanyEmaila fixed test body to a typed address; return{ ok, status }and surface sent/skipped/failed actionably (reuse #28 wording).
4.5 UI
/admin/setup→ new Email delivery<Section>: status badge (Connected/Key not set (RESEND_API_KEY)/Disabled), From, Reply-to, Enabled toggle, Send test email (address input + button), and a note that the API key is set viaRESEND_API_KEY(env) + "verify your domain in Resend". A small per-process panel to view/edit saved CC/BCC defaults (invoice first).ReviewAndSendDialog: CC/BCC inputs pre-filled fromemail_process.invoice, plus a "Save as default for invoice sending" toggle that callsupdateProcessEmailDefaults. Thread the per-sendcc/bccthroughsendInvoices(src/server/actions/invoicing.ts) →performSend(invoice/send.ts) →sendCompanyEmail(..., { process: 'invoice', cc, bcc }).- Replace the inline
emailConfigured(invoices/page.tsx) withgetEmailStatus.
5. Security notes
- The API key never touches the DB or the UI. Settings store only non-secret config (sender, reply-to, cc/bcc, on/off). The status badge shows whether the key exists, never its value.
- All three actions are admin-gated + company-scoped (match the existing
updateSettings). - Validate every address with Zod (the cc/bcc lists are user input → a trust boundary). Cap list length to avoid accidental mass-send.
- Masking/PII rules in
CLAUDE.mdare unaffected (no SSN/bank/tax data here).
6. Decisions & open questions
- ✅ Key storage: env (Approach A). Vault was the alternative — deferred; revisit only if a non-developer admin must set/rotate the key with no env access.
- ✅
enabled= company-wide outbound kill switch (off ⇒ everything logs as skipped). Confirm this is the intended scope (vs. gating only invoice/welcome). - ❓ Per-send CC/BCC UI rollout: invoice sending first (it's the only flow with a real send dialog); welcome/recruiting/pipeline get company + per-process defaults via the Setup Menu and can grow their own per-send UI later.
- ❓ Should a process's reply-to override be exposed in the UI now, or only company-level reply-to for v1? (Data model supports the override either way.)
7. Acceptance criteria
- Setup Menu shows correct status; editing From / Reply-to / Enabled persists and takes effect on the next send.
- Send test email delivers (HTTP 200 + id) when configured, or shows the actionable skip/fail message otherwise.
- In the invoice send dialog, CC/BCC are editable and pre-filled from the saved default; Save as default persists; the delivered email includes the cc/bcc.
- Disabling email (kill switch) stops all outbound and logs each as skipped.
- No secret value is ever written to
settingsor rendered in the UI. tsc+ Biome clean; new unit tests for the merge logic insendCompanyEmail(company → process → per-send, de-dup, kill switch).
8. Prior context (shipped, for reference)
- PR #28 (
main@b9c37d3) — surfaced Resend as a top-level action and made the "email not configured" send/resend feedback actionable. The pattern there is the template forsendTestEmail's result messaging.