/** * apps/worker — email queue processor (outbound mail). * * Job data: {to, subject, html?, text?, template?, vars?} * * Dev-safe default: when SMTP_HOST is empty the job is a no-op that logs * "[email] (no SMTP configured, skipping)" and completes — local dev must * never require a mail server. Documented in README. * * Templates: welcome, invite, mention, password-reset (6-digit code), * digest. A template + vars is rendered to {subject, html, text} before * sending; `{{var}}` interpolation over the vars object. * * Transport: the dependency-free SMTP client in lib/smtp.ts (EHLO, * optional STARTTLS, AUTH LOGIN, dot-stuffed DATA). SMTP failures throw → * BullMQ retries (8 × exponential). */ import { Worker, type Job } from "bullmq"; import { withJobOtel } from "../lib/job-otel.js"; import type { Redis } from "ioredis"; import { z } from "zod"; import type { PrismaClient } from "@prisma/client"; import { QUEUES } from "../queues.js"; import { getLogger } from "../logger.js"; import type { WorkerEnv } from "../config.js"; import { smtpSend } from "../lib/smtp.js"; const log = getLogger("email"); export const emailJobSchema = z.object({ to: z.string().email(), cc: z.array(z.string().email()).optional(), subject: z.string().min(1), html: z.string().optional(), text: z.string().optional(), template: z .enum(["welcome", "invite", "mention", "password-reset", "digest", "automation"]) .optional(), vars: z.record(z.string(), z.unknown()).optional(), /** * TASK-0094 R6 — the Notification row(s) this email belongs to. An INSTANT * notification email carries `[notificationId]`; a DIGEST email carries the * collected ids. On a successful send the worker stamps every row here with * `emailSentAt` (the idempotency flag — a job redelivered after a partial * SMTP failure must not double-send; the job-key dedup guards the enqueue * race, this flag guards the redelivery). Backward-compatible: existing * enqueue paths (verify / invite / admin alerts) omit it. */ notificationIds: z.array(z.string().min(1)).optional() }); export type EmailJobData = z.infer; // --------------------------------------------------------------------------- // Templates // --------------------------------------------------------------------------- function interpolate(s: string, vars: Record): string { return s.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_m, path: string) => { const v = path.split(".").reduce( (acc, k) => acc && typeof acc === "object" ? (acc as Record)[k] : undefined, vars ); if (v === undefined || v === null) return ""; if (typeof v === "object") return JSON.stringify(v); return String(v); }); } interface TemplateRender { subject: string; html: string; text: string; } const TEMPLATES: Record< string, (vars: Record) => TemplateRender > = { welcome: (v) => { const subject = `Welcome to ${interpolate("{{workspace}}", v)}`; const html = `

Welcome to ${esc(interpolate("{{workspace}}", v))}!

` + `

Hi ${esc(interpolate("{{name}}", v))},

` + `

Your workspace is ready. Open your workspace.

`; const text = `Welcome to ${interpolate("{{workspace}}", v)}!\nHi ${interpolate("{{name}}", v)},\nYour workspace is ready: ${interpolate("{{url}}", v)}`; return page(subject, html, text, v); }, invite: (v) => { const subject = `${interpolate("{{inviter}}", v)} invited you to ${interpolate("{{workspace}}", v)}`; const html = `

${esc(interpolate("{{inviter}}", v))} invited you to join ` + `${esc(interpolate("{{workspace}}", v))} as ${esc(interpolate("{{role}}", v))}.

` + `

Accept invitation (valid for 7 days).

`; const text = `${interpolate("{{inviter}}", v)} invited you to ${interpolate("{{workspace}}", v)} as ${interpolate("{{role}}", v)}.\nAccept: ${interpolate("{{url}}", v)} (valid for 7 days)`; return page(subject, html, text, v); }, mention: (v) => { const subject = `${interpolate("{{mentioner}}", v)} mentioned you: ${interpolate("{{context}}", v)}`; const html = `

${esc(interpolate("{{mentioner}}", v))} mentioned you in ` + `${esc(interpolate("{{context}}", v))}:

` + `
${esc(interpolate("{{quote}}", v))}
` + `

View in workspace

`; const text = `${interpolate("{{mentioner}}", v)} mentioned you in ${interpolate("{{context}}", v)}:\n"${interpolate("{{quote}}", v)}"\n${interpolate("{{url}}", v)}`; return page(subject, html, text, v); }, "password-reset": (v) => { const subject = "Your Factory password reset code"; const html = `

Your password reset code is:

` + `

${esc(interpolate("{{code}}", v))}

` + `

It expires in 30 minutes. If you did not request this, ignore this email.

`; const text = `Your password reset code is ${interpolate("{{code}}", v)}. It expires in 30 minutes.`; return page(subject, html, text, v); }, // TASK-0112 SEND_EMAIL: the executor sends fully-rendered html/text (markdown // already converted + interpolated); this template is a pass-through so the // `automation` label is a valid template without imposing its own layout. automation: (v) => { const subject = typeof v.subject === "string" && v.subject ? v.subject : "Message from Factory"; const html = typeof v.html === "string" ? v.html : `
${esc(String(v.text ?? ""))}
`; const text = typeof v.text === "string" ? v.text : ""; return page(subject, html, text, v); }, digest: (v) => { // TASK-0094 R6: the hourly digest. Subject is FIXED ("Your Factory digest" — // the spec + integration test assert it); the body lists the collected // items (PLAIN-TEXT titles passed via vars.items, escaped here) and links // to the inbox (the "n more" path). const items = Array.isArray(v.items) ? (v.items as string[]) : []; const html = `

Your Factory digest

` + `
    ${items.map((i) => `
  • ${esc(i)}
  • `).join("")}
` + `

Open your inbox

`; const text = `Your Factory digest:\n${items.map((i) => `- ${i}`).join("\n")}\n${interpolate("{{url}}", v)}`; return page("Your Factory digest", html, text, v); } }; /** Build a template render from a subject + inner html + plain text. */ function page(subject: string, html: string, text: string, vars: Record): TemplateRender { // Templates interpolate most tokens inline (with escaping); pass the whole // block through once more so link hrefs like {{url}} resolve. const fullHtml = interpolate(html, vars); return { subject, html: `` + fullHtml + `
` + `

AI Software Factory — you are receiving this because of an activity in your workspace.

` + ``, text }; } function esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">"); } /** Resolve a job into final {subject, html, text} (template or raw). */ export function renderEmail( data: EmailJobData ): { subject: string; html: string; text: string } { if (data.template) { const tpl = TEMPLATES[data.template]; if (!tpl) throw new Error(`unknown email template: ${data.template}`); const vars = data.vars ?? {}; const r = tpl(vars); return { subject: data.subject || r.subject, html: r.html, text: r.text }; } return { subject: data.subject, html: data.html ?? `
${esc(data.text ?? data.subject)}
`, text: data.text ?? "" }; } // --------------------------------------------------------------------------- // Worker // --------------------------------------------------------------------------- /** * TASK-0094 R6 — mark Notification row(s) as emailed. Best-effort: the mail is * already delivered when this runs, so a stamp failure is logged, not thrown * (a throw here would retry an already-sent job — the opposite of what the * idempotency flag exists to prevent). */ export async function stampEmailSent( prisma: PrismaClient, notificationIds: string[] ): Promise { try { await prisma.notification.updateMany({ where: { id: { in: notificationIds }, emailSentAt: null }, data: { emailSentAt: new Date() } }); } catch (err) { log.warn( { err: (err as Error).message, count: notificationIds.length }, "emailSentAt stamp failed (email already sent — non-fatal)" ); } } export function createEmailWorker(opts: { connection: Redis; prisma: PrismaClient; env: WorkerEnv; concurrency?: number; smtp?: (o: Parameters[0]) => Promise; }): Worker { const { connection, env } = opts; const send = opts.smtp ?? smtpSend; return new Worker( QUEUES.email, withJobOtel(QUEUES.email, async (job: Job) => { const started = Date.now(); const data = emailJobSchema.parse(job.data); if (!env.SMTP_HOST) { log.info( { jobId: job.id, to: data.to, subject: data.subject }, "[email] (no SMTP configured, skipping)" ); return { skipped: true, reason: "no smtp host" }; } const { subject, html, text } = renderEmail(data); await send({ host: env.SMTP_HOST, port: env.SMTP_PORT, user: env.SMTP_USER, pass: env.SMTP_PASS, from: env.SMTP_FROM, to: [data.to, ...(data.cc ?? [])], subject, html, text, startTls: env.SMTP_STARTTLS }); // TASK-0094 R6: stamp the source Notification row(s) `emailSentAt` on a // REAL send success only (the no-SMTP path above never reaches here). This // is the idempotency flag — a job redelivered after a partial SMTP // failure re-runs the send, but the decision consumer + this stamp stop // the double-send. Stamping must not fail the job (the mail already went // out), so it is best-effort. if (data.notificationIds?.length) { await stampEmailSent(opts.prisma, data.notificationIds); } log.info( { jobId: job.id, to: data.to, subject, ms: Date.now() - started }, "email sent" ); }), { connection, concurrency: opts.concurrency ?? env.WORKER_CONCURRENCY } ); }