Skip to main content

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

  • settings keys email_delivery (company From / Reply-to / Enabled) + email_process (per-flow CC/BCC).
  • Pure merge resolveEmailDelivery (src/lib/email/delivery.ts) + sendCompanyEmail seam 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 inline emailConfigured check is replaced by getEmailStatus.
  • Open questions resolved: the enabled kill switch is company-wide (every flow's send goes through sendCompanyEmail); 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:

  1. CC/BCC should be addable during a send, and saveable as the default for a given process (e.g. invoice sending) via a toggle.
  2. Reply-to (e.g. a real address, or a purely-informational noreply@nightingalepm.com) should be changeable in-app.

2. The model — three layers

LayerSet whereControlsApplies to
Company/admin/setup → new Email delivery sectionfrom, reply-to, enabled (kill switch)all email
Per-processSetup Menu (per process) or saved from a send dialogdefault CC, BCC, optional reply-to overridethat process (e.g. invoice sends)
Per-sendthe send dialog (e.g. Review & send)ad-hoc CC/BCC for this send + "Save as default …" togglethis 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 settings table, rows keyed by (company_id, key) with a jsonb value. Read via getSettingValue(svc, companyId, key) in src/server/services/settings.ts. Write via updateSettings in src/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) reads process.env.RESEND_API_KEY + process.env.EMAIL_FROM, POSTs to Resend, logs email.sent / email.skipped / email.failed.
    • EmailMessage already has replyTo? and already passes reply_to to Resend. ✅ So reply-to needs no seam change — only a place to source it from.
    • It does not yet support from override, cc, or bcc. Resend natively supports all three.
  • emailConfigured is computed inline at src/app/admin/invoices/page.tsx:116 as !!(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 — takes emailConfigured, 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 …". sendTestEmail should surface results the same way.
  • sendEmail call sites to migrate onto the new company-aware wrapper:
    • src/server/actions/welcomeEmail.ts
    • src/server/actions/pipeline.ts
    • src/server/actions/recruiting.ts
    • src/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, ProcessEmailDefaults types + EMAIL_PROCESSES const.
  • getEmailSettings(svc, companyId): Promise<EmailDeliverySettings> (env fallback for from).
  • getProcessEmailDefaults(svc, companyId, process): Promise<ProcessEmailDefaults>.
  • getEmailStatus(svc, companyId)'connected' | 'key_missing' | 'disabled' for the badge and to replace the inline emailConfigured check.

4.3 Seam — src/server/services/email.ts

  • Extend EmailMessage with from?: string, cc?: string[], bcc?: string[] (replyTo already there). Pass cc/bcc through to the Resend payload; from overrides the env default.
  • New wrapper sendCompanyEmail(svc, companyId, msg, opts: { process; cc?; bcc? }): resolves company + process settings, honors the enabled kill switch, merges From / Reply-to / CC / BCC per §2, then delegates to sendEmail. Backward-compatiblesendEmail keeps working.
  • Migrate the four call sites in §3 to sendCompanyEmail(..., { process }). They already have svc + companyId in scope.

4.4 Actions — src/server/actions/settings.ts

  • updateEmailSettings(formData) — admin-gated; upsert email_delivery (Zod: valid from, optional replyTo, boolean enabled).
  • updateProcessEmailDefaults(formData) — admin-gated; upsert one process's cc/bcc into email_process (Zod: arrays of valid emails, capped length).
  • sendTestEmail(formData) — admin-gated; sendCompanyEmail a 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 via RESEND_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 from email_process.invoice, plus a "Save as default for invoice sending" toggle that calls updateProcessEmailDefaults. Thread the per-send cc/bcc through sendInvoices (src/server/actions/invoicing.ts) → performSend (invoice/send.ts) → sendCompanyEmail(..., { process: 'invoice', cc, bcc }).
  • Replace the inline emailConfigured (invoices/page.tsx) with getEmailStatus.

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.md are 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

  1. Setup Menu shows correct status; editing From / Reply-to / Enabled persists and takes effect on the next send.
  2. Send test email delivers (HTTP 200 + id) when configured, or shows the actionable skip/fail message otherwise.
  3. 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.
  4. Disabling email (kill switch) stops all outbound and logs each as skipped.
  5. No secret value is ever written to settings or rendered in the UI.
  6. tsc + Biome clean; new unit tests for the merge logic in sendCompanyEmail (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 for sendTestEmail's result messaging.