Estimate: 26m · Depends on: 1.6.2
Replace the two synchronous email send-sites with the canonical job-backed pattern. Today's sends in lib/auth/password-reset.ts (Better-Auth's sendResetPassword hook from Story 1.1.6) and lib/workspaces/invites.ts (Story 1.2's invite create flow) call sendEmail() from lib/email.ts synchronously inside the HTTP request lifecycle. If the provider is slow or down, the user-facing request either stalls or returns a misleading success while the email never goes out. Both sites become sendEvent("email.send", { ... }) calls; the email.send job handler does the actual sendEmail() with retries.
Job definition (lib/jobs/email/send.ts):
id: "email.send", event: "email.send", retries: 3 (Inngest's exponential backoff defaults: ~30s, ~2m, ~5m)."{{event.data.idempotencyKey}}". Callers must supply idempotencyKey in the event payload; for password-reset it's the verification token ID; for invites it's the invitation row ID. Inngest dedups same-key events within a 24-hour window, so a retried Server Action that re-fires the same send becomes a no-op.lib/jobs/types.ts): { workspaceId: string; idempotencyKey: string; template: "password-reset" | "workspace-invite"; to: string; data: TemplateData }. TemplateData is a discriminated union by template; the handler narrows it via the discriminant before calling sendEmail().step.run("send", async () => ...) that calls sendEmail() and returns its result. The step.run wrapper makes the send durable across function retries (Inngest persists the result; a retry that survives the send won't double-send).Call-site migrations:
lib/auth/password-reset.ts: sendResetPassword({ user, url, token }) stops calling sendEmail() directly and instead calls sendEvent("email.send", { workspaceId, idempotencyKey: token, template: "password-reset", to: user.email, data: { resetUrl: url, name: user.name } }). Question to resolve in this Subtask: password-reset is pre-workspace-scope (the user might belong to multiple workspaces, or zero); resolution — use a sentinel workspaceId: "system" for cross-workspace system events. The sendEvent wrapper accepts the sentinel; the dashboard in 1.6.5 surfaces workspace_id = "system" runs in a separate "System" tab visible only to platform admins (gated to a hardcoded process.env.PLATFORM_ADMIN_EMAIL until real platform-admin roles ship in Epic 6).lib/workspaces/invites.ts: createInvitation()'s tail-end sendEmail() becomes sendEvent("email.send", { workspaceId, idempotencyKey: invite.id, template: "workspace-invite", to: invite.email, data: { inviteUrl, workspaceName, inviterName } }).Test migrations: existing Vitest specs for password-reset and workspace-invite assert that sendEmail() was called with the right template + recipient. They now assert that sendEvent("email.send", ...) was called with the right payload, AND that running the in-process harness against the queued event invokes sendEmail() with the expected args. The existing dev-console email provider stays the default; production providers (Resend / Postmark) remain per-project planner decisions (per Story 1.1's "email provider is per-project" rule).
Why these two sites first: they're the highest-leverage unreliable surfaces in production today (auth recovery + team onboarding); they establish the canonical pattern Epic 5's notification jobs + Epic 7's LLM jobs will mirror; and they tighten an existing finding from the Story 1.1 work (silent provider-failure swallowing).
lib/jobs/email/send.ts ships the email.send job per the description; registered in lib/jobs/registry.ts.lib/jobs/types.ts grows the email.send event entry with the discriminated TemplateData union (password-reset and workspace-invite arms).lib/auth/password-reset.ts and lib/workspaces/invites.ts no longer import or call sendEmail(); they call sendEvent("email.send", ...) instead.sendEmail() import is now reachable only from lib/jobs/email/send.ts; an ESLint no-restricted-imports rule enforces this so a future contributor can't accidentally regress.workspaceId: "system" sentinel is supported in sendEvent's typed signature and routed correctly through the job_run table (the column is nullable for system events; the dashboard tab gating is wired in 1.6.5).tests/jobs/email-send.test.ts + the migrated tests/auth/password-reset.test.ts + tests/workspaces/invites.test.ts assert: event payload shape; idempotency dedup (same-key event fired twice → handler runs once); handler invokes sendEmail() with the right template + recipient.docs/jobs.md grows a "Canonical job: email.send" section walking through the file as the reference exemplar.motir-core/CLAUDE.md — 4-layer rule (auto-loaded)lib/email.ts + lib/emailTemplates/ — the abstraction the job wraps; no shape change neededlib/auth/password-reset.ts — the Better-Auth sendResetPassword hook (Story 1.1.6)lib/workspaces/invites.ts — the invite create flow (Story 1.2 invite Subtask)lib/jobs/* from 1.6.2 — the wrapper APIs to compose againstdefineJob + idempotency conventions