/** * apps/worker — SEND_EMAIL executor (TASK-0112 §7/§8). * * Resolve the recipient (`to`: literal email | trigger_user | * automation_creator → the User's email; a missing/invalid address → fatal * EMAIL_ADDRESS_MISSING), reserve the daily cap slot (fatal EMAIL_DAILY_CAP), * interpolate subject + markdown→HTML body, and enqueue the email job on the * email queue with a STABLE job id (`automation-email:{runId}:{i}`) so a * run-retry never double-sends. CC is a literal email list (max 5, schema). */ import { renderMarkdown } from "@factory/shared"; import { enqueue, QUEUES } from "../../../queues.js"; import { FatalError } from "./classify.js"; import { reserveEmailSlot } from "./email-cap.js"; import { interpolateConfig, resolveUserId } from "./helpers.js"; import type { ActionExecutor, ActionResult } from "./types.js"; /** * @param index the action's position in the run (for the stable job id). */ export async function sendEmail( cfg: Record, ctx: import("./types.js").AutomationRunContext, deps: import("./types.js").ExecutorDeps, index: number ): Promise { const warnings: string[] = []; const toRaw = (cfg.to as string) ?? "automation_creator"; let toEmail: string; if (toRaw === "trigger_user" || toRaw === "automation_creator") { const userId = resolveUserId(toRaw, ctx); const user = await deps.prisma.user.findUnique({ where: { id: userId }, select: { email: true } }); if (!user?.email) throw new FatalError("EMAIL_ADDRESS_MISSING", `recipient ${toRaw} has no email address`); toEmail = user.email; } else { toEmail = toRaw; // literal (validated as an email at save time) } const cc = Array.isArray(cfg.cc) ? (cfg.cc as string[]) : []; const subject = interpolateConfig(cfg.subject as string, ctx.vars, 200, "subject", warnings); const bodyHtml = interpolateConfig(cfg.body as string, ctx.vars, 20_000, "body", warnings); // Reserve the daily slot BEFORE enqueueing (the enqueue is the commit). await reserveEmailSlot(deps.redis, ctx.workspaceId); await enqueue( deps.redis, QUEUES.email, { to: toEmail, cc, subject, html: renderMarkdown(bodyHtml), text: bodyHtml, template: "automation" }, { jobId: `automation-email:${ctx.runId}:${index}` } ); return { output: { to: toEmail, cc, subject, slotReserved: true }, emittedEvents: [], audit: { action: "automation.email_sent", resourceType: "user", resourceId: toEmail, metadata: { cc } } }; } const executor: ActionExecutor = { type: "SEND_EMAIL", execute: (cfg, ctx, deps, index) => sendEmail(cfg, ctx, deps, index ?? 0) }; export default executor;