1.6.3 First production job: `email.send` — migrate password-reset + workspace-invite sends from synchronous to job-backed
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).- Idempotency:
"{{event.data.idempotencyKey}}". Callers must supplyidempotencyKeyin 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. - Event payload shape (typed in
lib/jobs/types.ts):{ workspaceId: string; idempotencyKey: string; template: "password-reset" | "workspace-invite"; to: string; data: TemplateData }.TemplateDatais a discriminated union bytemplate; the handler narrows it via the discriminant before callingsendEmail(). - Handler body: a single
step.run("send", async () => ...)that callssendEmail()and returns its result. Thestep.runwrapper 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 callingsendEmail()directly and instead callssendEvent("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 sentinelworkspaceId: "system"for cross-workspace system events. ThesendEventwrapper accepts the sentinel; the dashboard in 1.6.5 surfacesworkspace_id = "system"runs in a separate "System" tab visible only to platform admins (gated to a hardcodedprocess.env.PLATFORM_ADMIN_EMAILuntil real platform-admin roles ship in Epic 6).lib/workspaces/invites.ts:createInvitation()'s tail-endsendEmail()becomessendEvent("email.send", { workspaceId, idempotencyKey: invite.id, template: "workspace-invite", to: invite.email, data: { inviteUrl, workspaceName, inviterName } }).- Both call-sites stop awaiting the email outcome. The user-facing response returns immediately after the event is enqueued. Failures surface in the dashboard, not in the request.
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).
Acceptance criteria
lib/jobs/email/send.tsships theemail.sendjob per the description; registered inlib/jobs/registry.ts.lib/jobs/types.tsgrows theemail.sendevent entry with the discriminatedTemplateDataunion (password-resetandworkspace-invitearms).lib/auth/password-reset.tsandlib/workspaces/invites.tsno longer import or callsendEmail(); they callsendEvent("email.send", ...)instead.- The
sendEmail()import is now reachable only fromlib/jobs/email/send.ts; an ESLintno-restricted-importsrule enforces this so a future contributor can't accidentally regress. - The
workspaceId: "system"sentinel is supported insendEvent's typed signature and routed correctly through thejob_runtable (the column is nullable for system events; the dashboard tab gating is wired in 1.6.5). - Vitest specs in
tests/jobs/email-send.test.ts+ the migratedtests/auth/password-reset.test.ts+tests/workspaces/invites.test.tsassert: event payload shape; idempotency dedup (same-key event fired twice → handler runs once); handler invokessendEmail()with the right template + recipient. - Existing Playwright password-reset + invite-acceptance specs stay green — they should be agnostic to the synchronous-vs-async send distinction since both paths complete the user-visible flow identically.
docs/jobs.mdgrows a "Canonical job: email.send" section walking through the file as the reference exemplar.- All quality gates green; existing tests + E2E stay green.
Context refs
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-AuthsendResetPasswordhook (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 against- The 1.6.2 docs section on
defineJob+ idempotency conventions - Inngest idempotency reference