MotirBuilding in public
MOTIR · moooon
onMotir
You’re viewing a public project. Anyone can view it — no account needed. Sign in to submit, upvote, or comment on requests.View-only — you can’t edit work items
MOTIR-62

1.6.3 First production job: `email.send` — migrate password-reset + workspace-invite sends from synchronous to job-backed

Done
Description

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 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.
  • Event payload shape (typed in 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().
  • Handler body: a single 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 } }).
  • 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.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.
  • The 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.
  • The 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).
  • Vitest specs in 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.
  • 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.md grows 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 needed
  • lib/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 against
  • The 1.6.2 docs section on defineJob + idempotency conventions
  • Inngest idempotency reference