From b2f7348ef02282fe09ed6933220415d4afcb520f Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 5 Jun 2026 21:05:59 +0200 Subject: [PATCH] Add SMTP send flow for approved outreach --- ...approved-outreach-through-Stalwart-SMTP.md | 28 +- .../outreach/outreach-review-workspace.tsx | 223 +++++++++- convex/_generated/api.d.ts | 2 + convex/outreach.ts | 370 +++++++++++++-- convex/outreachSendAction.ts | 335 ++++++++++++++ convex/schema.ts | 31 ++ package.json | 2 + pnpm-lock.yaml | 19 + tests/outreach-review-contract.test.ts | 420 +++++++++++++++++- tests/outreach-review-workspace-ui.test.ts | 157 ++++++- 10 files changed, 1531 insertions(+), 56 deletions(-) create mode 100644 convex/outreachSendAction.ts diff --git a/backlog/tasks/task-14 - Send-approved-outreach-through-Stalwart-SMTP.md b/backlog/tasks/task-14 - Send-approved-outreach-through-Stalwart-SMTP.md index 293c4f6..603492f 100644 --- a/backlog/tasks/task-14 - Send-approved-outreach-through-Stalwart-SMTP.md +++ b/backlog/tasks/task-14 - Send-approved-outreach-through-Stalwart-SMTP.md @@ -1,9 +1,10 @@ --- id: TASK-14 title: Send approved outreach through Stalwart SMTP -status: To Do +status: In Progress assignee: [] created_date: '2026-06-03 19:14' +updated_date: '2026-06-05 15:57' labels: - mvp - email @@ -24,19 +25,24 @@ Implement approved email sending through the self-hosted Stalwart mail server us ## Acceptance Criteria -- [ ] #1 Nodemailer is configured for Stalwart SMTP/SMTPS using environment or Convex secrets -- [ ] #2 E-Mail freigeben und senden sends only the currently approved/editable email draft to the visible recipient -- [ ] #3 A final send action shows recipient, subject, sender, and audit link before sending -- [ ] #4 Convex records sent timestamp, recipient, subject, audit link, SMTP result, and any error details -- [ ] #5 SMTP failures keep the lead in a retryable review state and do not mark the lead as contacted +- [x] #1 Nodemailer is configured for Stalwart SMTP/SMTPS using environment or Convex secrets +- [x] #2 E-Mail freigeben und senden sends only the currently approved/editable email draft to the visible recipient +- [x] #3 A final send action shows recipient, subject, sender, and audit link before sending +- [x] #4 Convex records sent timestamp, recipient, subject, audit link, SMTP result, and any error details +- [x] #5 SMTP failures keep the lead in a retryable review state and do not mark the lead as contacted ## Implementation Plan -1. Add SMTP transport configuration from secrets. -2. Add server-side send function that accepts only approved outreach IDs. -3. Add final confirmation UI with recipient, subject, sender, and audit link. -4. Store SMTP success/error outcomes and update lead/outreach status. -5. Test success and failure paths with safe non-production recipients before real use. +1. Analyse und TDD-Testergänzung +2. Implementierung backend Claims/Record + Guard-Fixes +3. Typing/Actions straffen + package lock +4. Typechecks lokal ausführen + +## Implementation Notes + + +Implemented TASK-14 via subagent-driven TDD. Verification passed: targeted outreach tests (27/27), pnpm test (278/278), pnpm exec tsc -p tsconfig.json --noEmit, pnpm lint (0 errors, 2 generated BetterAuth warnings), pnpm build (passed with network-enabled run for Google Fonts). Task remains In Progress until explicit user confirmation after manual SMTP testing. + diff --git a/components/outreach/outreach-review-workspace.tsx b/components/outreach/outreach-review-workspace.tsx index f8a44a6..7594632 100644 --- a/components/outreach/outreach-review-workspace.tsx +++ b/components/outreach/outreach-review-workspace.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react"; -import { useMutation, useQuery } from "convex/react"; +import { useAction, useMutation, useQuery } from "convex/react"; import type { FunctionReturnType } from "convex/server"; import { ChevronDown, ChevronRight, ExternalLink, MailCheck, Save } from "lucide-react"; import Link from "next/link"; @@ -11,6 +11,14 @@ import { api } from "@/convex/_generated/api"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogCloseButton, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; @@ -30,6 +38,14 @@ type DraftState = { phoneScript: string; }; +type PendingEmailConfirmation = { + id: NonNullable["_id"]; + recipient: string; + subject: string; + sender: string; + auditSlug: string | null; +}; + const emptyDraft: DraftState = { auditBody: "", auditSummary: "", @@ -60,6 +76,30 @@ function compactText(value?: string | null, fallback = "Offen") { return trimmed ? trimmed : fallback; } +function toStringIfText(value: unknown) { + return typeof value === "string" ? value.trim() : ""; +} + +function toNullableString(value: unknown) { + const text = toStringIfText(value); + return text.length > 0 ? text : null; +} + +function asRecord(value: unknown) { + return (typeof value === "object" && value !== null ? (value as Record) : {}); +} + +function extractRecordValue(record: Record, candidates: string[]) { + for (const candidate of candidates) { + const text = toStringIfText(record[candidate]); + if (text) { + return text; + } + } + + return ""; +} + function formatStrategy(strategy?: string | null) { const labels: Record = { call_first: "Erst anrufen", @@ -140,14 +180,17 @@ export function OutreachReviewWorkspace() { const approveEmailDraft = useMutation(api.outreach.approveEmailDraft); const savePublicAuditContent = useMutation(api.audits.savePublicAuditContent); const publishPublicAudit = useMutation(api.audits.publishPublicAudit); + const sendApprovedEmail = useAction(api.outreachSendAction.sendApprovedEmail); const [drafts, setDrafts] = useState>({}); const [openSources, setOpenSources] = useState>({}); const [openRaw, setOpenRaw] = useState>({}); const [busyAction, setBusyAction] = useState(null); const [notice, setNotice] = useState(null); + const [pendingEmailConfirmation, setPendingEmailConfirmation] = + useState(null); - const rows = useMemo(() => records ?? [], [records]); + const rows = useMemo(() => records ?? [], [records]); if (records === undefined) { return ; @@ -243,6 +286,12 @@ export function OutreachReviewWorkspace() { setNotice("Outreach-Entwurf kann ohne Outreach-ID nicht gespeichert werden."); return; } + if (outreach.sendStatus === "queued") { + setNotice( + "Aufgrund des laufenden Sendevorgangs kann der Outreach-Entwurf nicht gespeichert werden.", + ); + return; + } const draft = drafts[record.id] ?? getDraft(record); const strategy = outreach.strategy; @@ -270,12 +319,57 @@ export function OutreachReviewWorkspace() { } }; + const closeEmailConfirmation = () => { + setPendingEmailConfirmation(null); + }; + + const sendApprovedEmailFromConfirmation = async () => { + const confirmation = pendingEmailConfirmation; + if (!confirmation) { + return; + } + + const isQueuedSend = rows.some( + (row: ReviewWorkspaceItem) => + row.latestOutreach?._id === confirmation.id && + row.latestOutreach?.sendStatus === "queued", + ); + if (isQueuedSend) { + setNotice( + "Aufgrund des laufenden Sendevorgangs kann der Versand nicht erneut gestartet werden.", + ); + return; + } + + setBusyAction(`${confirmation.id}:email-send`); + setNotice(null); + try { + await sendApprovedEmail({ id: confirmation.id }); + setNotice("E-Mail gesendet."); + setPendingEmailConfirmation(null); + } catch { + setNotice( + "E-Mail konnte nicht gesendet werden. Bitte erneut versuchen.", + ); + } finally { + setBusyAction(null); + } + }; + const approveEmail = async (record: ReviewWorkspaceItem) => { const outreach = record.latestOutreach; + const lead = record.lead; + const audit = record.audit; if (!outreach) { setNotice("E-Mail kann ohne Outreach-ID nicht freigegeben werden."); return; } + if (outreach.sendStatus === "queued") { + setNotice( + "Aufgrund des laufenden Sendevorgangs kann die E-Mail nicht freigegeben werden.", + ); + return; + } const draft = drafts[record.id] ?? getDraft(record); const strategy = outreach.strategy; @@ -295,7 +389,34 @@ export function OutreachReviewWorkspace() { followUpDraft: draft.followUpDraft, ...(hasCallablePhone ? { phoneScript: draft.phoneScript } : {}), }); - await approveEmailDraft({ id: outreach._id }); + const approvalResult = await approveEmailDraft({ id: outreach._id }); + const approvalData = asRecord(approvalResult); + const recipient = + extractRecordValue(approvalData, ["recipient", "email", "emailAddress"]) || + lead?.email || + "Offen"; + const subject = + extractRecordValue(approvalData, [ + "emailSubject", + "subject", + "title", + ]) || draft.emailSubject; + const sender = toNullableString(approvalData.sender); + if (!sender) { + throw new Error("SMTP-Absender in der Freigabeantwort fehlt."); + } + const auditSlug = + extractRecordValue(approvalData, ["auditSlug", "slug", "audit"]) || + audit?.slug || + null; + + setPendingEmailConfirmation({ + id: outreach._id, + recipient, + subject, + sender, + auditSlug, + }); setNotice("E-Mail freigegeben."); } catch { setNotice("E-Mail konnte nicht freigegeben werden."); @@ -304,6 +425,16 @@ export function OutreachReviewWorkspace() { } }; + const confirmationAuditLink = pendingEmailConfirmation?.auditSlug + ? `/audit/${pendingEmailConfirmation.auditSlug}` + : null; + const pendingQueuedOutreachId = pendingEmailConfirmation?.id; + const isQueuedSendForConfirmation = rows.some( + (row: ReviewWorkspaceItem) => + row.latestOutreach?._id === pendingQueuedOutreachId && + row.latestOutreach?.sendStatus === "queued", + ); + return (
@@ -319,12 +450,88 @@ export function OutreachReviewWorkspace() {

{notice}

) : null} + { + if (!open) { + closeEmailConfirmation(); + } + }} + > + {pendingEmailConfirmation ? ( + + + E-Mail-Versand bestätigen + + Bitte prüfen Sie vor dem Senden die Finaldaten. + + + +
+
+ +

{pendingEmailConfirmation.recipient}

+
+
+ +

{pendingEmailConfirmation.subject}

+
+
+ +

+ {pendingEmailConfirmation.sender} +

+
+
+ + {confirmationAuditLink ? ( + + + {confirmationAuditLink} + + ) : ( +

Nicht verfügbar.

+ )} +
+
+
+ + +
+
+ ) : null} +
+
{rows.map((record) => { const draft = drafts[record.id] ?? getDraft(record); const lead = record.lead; const audit = record.audit; const outreach = record.latestOutreach; + const isQueuedSend = outreach?.sendStatus === "queued"; const strategy = outreach?.strategy; const contactSources = [ lead.email ? `E-Mail: ${lead.email}` : null, @@ -506,7 +713,9 @@ export function OutreachReviewWorkspace() {
diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 841469e..0de6284 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -19,6 +19,7 @@ import type * as http from "../http.js"; import type * as leadDiscovery from "../leadDiscovery.js"; import type * as leads from "../leads.js"; import type * as outreach from "../outreach.js"; +import type * as outreachSendAction from "../outreachSendAction.js"; import type * as pageSpeed from "../pageSpeed.js"; import type * as pageSpeedAction from "../pageSpeedAction.js"; import type * as runs from "../runs.js"; @@ -45,6 +46,7 @@ declare const fullApi: ApiFromModules<{ leadDiscovery: typeof leadDiscovery; leads: typeof leads; outreach: typeof outreach; + outreachSendAction: typeof outreachSendAction; pageSpeed: typeof pageSpeed; pageSpeedAction: typeof pageSpeedAction; runs: typeof runs; diff --git a/convex/outreach.ts b/convex/outreach.ts index 9df38d6..7f4fdd5 100644 --- a/convex/outreach.ts +++ b/convex/outreach.ts @@ -181,6 +181,62 @@ const loadReviewRow = async ( }; }; +type OutreachRecordInsertArgs = { + leadId: Id<"leads">; + auditId?: Id<"audits">; + strategy: "call_first" | "email_first" | "defer" | "do_not_contact"; + phoneScript?: string; + emailSubject?: string; + emailBody?: string; + followUpDraft?: string; + now: number; +}; + +const buildOutreachRecordsInsertPayload = (args: OutreachRecordInsertArgs) => { + const payload: { + leadId: Id<"leads">; + auditId?: Id<"audits">; + strategy: "call_first" | "email_first" | "defer" | "do_not_contact"; + phoneScript?: string; + emailSubject?: string; + emailBody?: string; + followUpDraft?: string; + approvalStatus: "draft"; + sendStatus: "not_sent"; + responseStatus: "none"; + salesStatus: "follow_up_planned"; + createdAt: number; + updatedAt: number; + } = { + leadId: args.leadId, + strategy: args.strategy, + approvalStatus: "draft", + sendStatus: "not_sent", + responseStatus: "none", + salesStatus: "follow_up_planned", + createdAt: args.now, + updatedAt: args.now, + }; + + if (args.auditId !== undefined) { + payload.auditId = args.auditId; + } + if (args.phoneScript !== undefined) { + payload.phoneScript = args.phoneScript; + } + if (args.emailSubject !== undefined) { + payload.emailSubject = args.emailSubject; + } + if (args.emailBody !== undefined) { + payload.emailBody = args.emailBody; + } + if (args.followUpDraft !== undefined) { + payload.followUpDraft = args.followUpDraft; + } + + return payload; +}; + export const create = mutation({ args: { leadId: v.id("leads"), @@ -210,16 +266,19 @@ export const create = mutation({ } const now = Date.now(); - - return await ctx.db.insert("outreachRecords", { - ...args, - approvalStatus: "draft", - sendStatus: "not_sent", - responseStatus: "none", - salesStatus: "follow_up_planned", - createdAt: now, - updatedAt: now, - }); + return await ctx.db.insert( + "outreachRecords", + buildOutreachRecordsInsertPayload({ + leadId: args.leadId, + auditId: args.auditId, + strategy: args.strategy, + phoneScript: args.phoneScript, + emailSubject: args.emailSubject, + emailBody: args.emailBody, + followUpDraft: args.followUpDraft, + now, + }), + ); }, }); @@ -260,15 +319,19 @@ export const upsertFromAuditGeneration = internalMutation({ if (existing.length > 0) { const current = existing[0]!; if (current.sendStatus === "sent") { - return await ctx.db.insert("outreachRecords", { - ...args, - approvalStatus: "draft", - sendStatus: "not_sent", - responseStatus: "none", - salesStatus: "follow_up_planned", - createdAt: now, - updatedAt: now, - }); + return await ctx.db.insert( + "outreachRecords", + buildOutreachRecordsInsertPayload({ + leadId: args.leadId, + auditId: args.auditId, + strategy: args.strategy, + phoneScript: args.phoneScript, + emailSubject: args.emailSubject, + emailBody: args.emailBody, + followUpDraft: args.followUpDraft, + now, + }), + ); } await ctx.db.patch(current._id, { @@ -289,15 +352,19 @@ export const upsertFromAuditGeneration = internalMutation({ return current._id; } - return await ctx.db.insert("outreachRecords", { - ...args, - approvalStatus: "draft", - sendStatus: "not_sent", - responseStatus: "none", - salesStatus: "follow_up_planned", - createdAt: now, - updatedAt: now, - }); + return await ctx.db.insert( + "outreachRecords", + buildOutreachRecordsInsertPayload({ + leadId: args.leadId, + auditId: args.auditId, + strategy: args.strategy, + phoneScript: args.phoneScript, + emailSubject: args.emailSubject, + emailBody: args.emailBody, + followUpDraft: args.followUpDraft, + now, + }), + ); }, }); @@ -425,7 +492,7 @@ export const saveReviewDraft = mutation({ if (!outreach) { throw new Error("Outreach-Datensatz wurde nicht gefunden."); } - if (outreach.sendStatus === "sent") { + if (outreach.sendStatus === "sent" || outreach.sendStatus === "queued") { throw new Error("Gesendete Outreach-Datensaetze koennen nicht bearbeitet werden."); } @@ -462,6 +529,9 @@ export const approveEmailDraft = mutation({ if (outreach.sendStatus === "sent") { throw new Error("Gesendete Outreach-Datensaetze koennen nicht freigegeben werden."); } + if (outreach.sendStatus === "queued") { + throw new Error("Ausstehend freigegebene Outreach-Datensaetze koennen nicht erneut freigegeben werden."); + } const lead = await ctx.db.get(outreach.leadId); if (!lead) { @@ -487,11 +557,16 @@ export const approveEmailDraft = mutation({ approvalStatus: "approved", updatedAt: now, }); + const sender = process.env.SMTP_FROM?.trim(); + if (!sender) { + throw new Error("SMTP-Absender-Adresse fehlt."); + } return { id: args.id, recipient: recipient, subject: subject, + sender: sender, auditSlug: audit?.slug ?? null, approvalStatus: "approved", updatedAt: now, @@ -499,6 +574,243 @@ export const approveEmailDraft = mutation({ }, }); +export const claimApprovedEmailForSend = internalMutation({ + args: { + id: v.id("outreachRecords"), + }, + handler: async (ctx, args) => { + await requireOperator(ctx); + + const outreach = await ctx.db.get(args.id); + if (!outreach) { + throw new Error("Outreach-Datensatz wurde nicht gefunden."); + } + if (outreach.approvalStatus !== "approved") { + throw new Error("Nur freigegebene Outreachs können versendet werden."); + } + if (outreach.sendStatus === "sent" || outreach.sendStatus === "queued") { + throw new Error("Outreach ist bereits in Versand-Warteschlange oder gesendet."); + } + + const lead = await ctx.db.get(outreach.leadId); + if (!lead) { + throw new Error("Lead wurde nicht gefunden."); + } + + const recipient = lead.email?.trim(); + const subject = outreach.emailSubject?.trim(); + const body = outreach.emailBody?.trim(); + const sender = process.env.SMTP_FROM?.trim(); + + if (!recipient) { + throw new Error("Empfaenger-E-Mail fehlt."); + } + if (!subject) { + throw new Error("E-Mail-Betreff fehlt."); + } + if (!body) { + throw new Error("E-Mail-Text fehlt."); + } + if (!sender) { + throw new Error("SMTP-Absender-Adresse fehlt."); + } + + const audit = outreach.auditId ? await ctx.db.get(outreach.auditId) : null; + const now = Date.now(); + await ctx.db.patch(args.id, { + sendStatus: "queued", + updatedAt: now, + }); + + return { + outreachId: outreach._id, + id: outreach._id, + leadId: outreach.leadId, + auditId: outreach.auditId, + recipient, + subject, + body, + sender, + auditLink: audit?.slug ? `/audit/${audit.slug}` : null, + }; + }, +}); + +const outreachSendAttemptSuccessStatus = "success" as const; +const outreachSendAttemptFailedStatus = "failed" as const; + +export const recordEmailSendSuccess = internalMutation({ + args: { + id: v.id("outreachRecords"), + recipient: v.string(), + subject: v.string(), + body: v.string(), + sender: v.string(), + auditId: v.optional(v.id("audits")), + auditLink: v.optional(v.union(v.string(), v.null())), + sentAt: v.number(), + smtpMessageId: v.optional(v.string()), + smtpResponse: v.optional(v.string()), + smtpAccepted: v.optional(v.array(v.string())), + smtpRejected: v.optional(v.array(v.string())), + }, + handler: async (ctx, args) => { + await requireOperator(ctx); + + const outreach = await ctx.db.get(args.id); + if (!outreach) { + throw new Error("Outreach-Datensatz wurde nicht gefunden."); + } + + const lead = await ctx.db.get(outreach.leadId); + if (!lead) { + throw new Error("Lead wurde nicht gefunden."); + } + + const now = Date.now(); + await ctx.db.patch(args.id, { + sendStatus: "sent", + sentAt: args.sentAt, + updatedAt: now, + }); + + await ctx.db.patch(lead._id, { + contactStatus: "contacted", + updatedAt: now, + }); + + const attempt: { + outreachId: Id<"outreachRecords">; + leadId: Id<"leads">; + recipient: string; + subject: string; + body: string; + sender: string; + status: typeof outreachSendAttemptSuccessStatus; + sentAt: number; + createdAt: number; + updatedAt: number; + auditId?: Id<"audits">; + auditLink?: string | null; + smtpMessageId?: string; + smtpResponse?: string; + smtpAccepted?: string[]; + smtpRejected?: string[]; + } = { + outreachId: args.id, + leadId: outreach.leadId, + recipient: args.recipient, + subject: args.subject, + body: args.body, + sender: args.sender, + status: outreachSendAttemptSuccessStatus, + sentAt: args.sentAt, + createdAt: now, + updatedAt: now, + }; + + if (args.auditId !== undefined) { + attempt.auditId = args.auditId; + } + if (args.auditLink !== undefined) { + attempt.auditLink = args.auditLink; + } + if (args.smtpMessageId !== undefined) { + attempt.smtpMessageId = args.smtpMessageId; + } + if (args.smtpResponse !== undefined) { + attempt.smtpResponse = args.smtpResponse; + } + if (args.smtpAccepted !== undefined) { + attempt.smtpAccepted = args.smtpAccepted; + } + if (args.smtpRejected !== undefined) { + attempt.smtpRejected = args.smtpRejected; + } + + await ctx.db.insert("outreachSendAttempts", attempt); + }, +}); + +export const recordEmailSendFailure = internalMutation({ + args: { + id: v.id("outreachRecords"), + recipient: v.string(), + subject: v.string(), + body: v.string(), + sender: v.string(), + auditId: v.optional(v.id("audits")), + auditLink: v.optional(v.union(v.string(), v.null())), + errorMessage: v.optional(v.string()), + errorCode: v.optional(v.string()), + errorResponseCode: v.optional(v.number()), + errorResponse: v.optional(v.string()), + }, + handler: async (ctx, args) => { + await requireOperator(ctx); + + const outreach = await ctx.db.get(args.id); + if (!outreach) { + throw new Error("Outreach-Datensatz wurde nicht gefunden."); + } + + const now = Date.now(); + await ctx.db.patch(args.id, { + sendStatus: "failed", + updatedAt: now, + }); + + const attempt: { + outreachId: Id<"outreachRecords">; + leadId: Id<"leads">; + recipient: string; + subject: string; + body: string; + sender: string; + status: typeof outreachSendAttemptFailedStatus; + createdAt: number; + updatedAt: number; + auditId?: Id<"audits">; + auditLink?: string | null; + errorMessage?: string; + errorCode?: string; + errorResponseCode?: number; + errorResponse?: string; + } = { + outreachId: args.id, + leadId: outreach.leadId, + recipient: args.recipient, + subject: args.subject, + body: args.body, + sender: args.sender, + status: outreachSendAttemptFailedStatus, + createdAt: now, + updatedAt: now, + }; + + if (args.auditId !== undefined) { + attempt.auditId = args.auditId; + } + if (args.auditLink !== undefined) { + attempt.auditLink = args.auditLink; + } + if (args.errorMessage !== undefined) { + attempt.errorMessage = args.errorMessage; + } + if (args.errorCode !== undefined) { + attempt.errorCode = args.errorCode; + } + if (args.errorResponseCode !== undefined) { + attempt.errorResponseCode = args.errorResponseCode; + } + if (args.errorResponse !== undefined) { + attempt.errorResponse = args.errorResponse; + } + + await ctx.db.insert("outreachSendAttempts", attempt); + }, +}); + export const list = query({ args: { leadId: v.optional(v.id("leads")), diff --git a/convex/outreachSendAction.ts b/convex/outreachSendAction.ts new file mode 100644 index 0000000..f3af45e --- /dev/null +++ b/convex/outreachSendAction.ts @@ -0,0 +1,335 @@ +"use node"; + +import { internal } from "./_generated/api"; +import { action, type ActionCtx } from "./_generated/server"; +import { v } from "convex/values"; +import nodemailer from "nodemailer"; +import type { SentMessageInfo } from "nodemailer"; +import type { Id } from "./_generated/dataModel"; + +type SendRecipientList = string[]; +type SmtpErrorDetails = { + message: string; + code?: string; + responseCode?: number; + response?: string; + accepted?: SendRecipientList; + rejected?: SendRecipientList; +}; + +const DEFAULT_SMTP_PORT = 465; +const SMTP_REQUIRED_FIELDS = [ + "SMTP_HOST", + "SMTP_USER", + "SMTP_PASSWORD", + "SMTP_FROM", +] as const; + +async function requireOperator(ctx: ActionCtx): Promise { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + throw new Error("Nicht autorisiert."); + } +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function sanitizeValue(value: string | undefined | null): string | undefined { + if (!value) { + return value === "" ? "" : undefined; + } + + let safe = value; + + for (const secretName of SMTP_REQUIRED_FIELDS) { + const secret = process.env[secretName]; + if (secret) { + safe = safe.replace(new RegExp(escapeRegExp(secret), "g"), "[REDACTED]"); + } + } + + return safe + .replace( + /\b(?:host|user|userId|userID|password|pass|secret)\s*[:=]\s*[^\s\"']+/gi, + "[REDACTED]", + ) + .trim(); +} + +function parsePort(raw: string | undefined): number { + const fallback = DEFAULT_SMTP_PORT; + const normalized = raw?.trim(); + if (!normalized) { + return fallback; + } + + const parsed = Number.parseInt(normalized, 10); + if (!Number.isFinite(parsed)) { + throw new Error("SMTP-Port ist ungültig."); + } + + if (parsed < 1 || parsed > 65_535) { + throw new Error("SMTP-Port liegt außerhalb gültiger Grenzen."); + } + + return parsed; +} + +function parseResponseCode(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + + if (typeof value === "string") { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : undefined; + } + + return undefined; +} + +function normalizeRecipientList(value: unknown): SendRecipientList { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((entry) => { + return typeof entry === "string" ? entry : String(entry); + }) + .filter(Boolean); +} + +function extractSmtpError(error: unknown): SmtpErrorDetails { + if (error instanceof Error) { + const errorCode = (error as { code?: unknown }).code; + const smtpCode = + typeof errorCode === "string" ? errorCode : undefined; + + return { + message: error.message || "SMTP-Fehler ohne Nachricht.", + code: smtpCode, + responseCode: parseResponseCode( + (error as { responseCode?: unknown }).responseCode, + ), + response: (error as { response?: unknown }).response as string | undefined, + }; + } + + if (typeof error === "object" && error !== null) { + const errorAsRecord = error as { + message?: unknown; + code?: unknown; + responseCode?: unknown; + response?: unknown; + accepted?: unknown; + rejected?: unknown; + }; + + return { + message: + typeof errorAsRecord.message === "string" + ? errorAsRecord.message + : "SMTP-Fehler ohne Nachricht.", + code: + typeof errorAsRecord.code === "string" + ? errorAsRecord.code + : undefined, + responseCode: parseResponseCode(errorAsRecord.responseCode), + response: + typeof errorAsRecord.response === "string" + ? errorAsRecord.response + : undefined, + accepted: normalizeRecipientList(errorAsRecord.accepted), + rejected: normalizeRecipientList(errorAsRecord.rejected), + }; + } + + const message = typeof error === "string" ? error : "SMTP-Fehler ohne Nachricht."; + return { message }; +} + +function toSanitizedErrorForLog(error: unknown) { + const parsed = extractSmtpError(error); + return { + message: sanitizeValue(parsed.message) ?? "SMTP-Fehler ohne Nachricht.", + code: sanitizeValue(parsed.code), + responseCode: parsed.responseCode, + response: sanitizeValue(parsed.response), + }; +} + +function sanitizeSmtpError(error: unknown) { + return toSanitizedErrorForLog(error); +} + +type OutreachSendSnapshot = { + outreachId: Id<"outreachRecords">; + id?: Id<"outreachRecords">; + leadId: Id<"leads">; + auditId?: Id<"audits">; + recipient: string; + subject: string; + body: string; + sender: string; + auditLink?: string | null; +}; + +export const sendApprovedEmail = action({ + args: { + id: v.id("outreachRecords"), + }, + handler: async ( + ctx: ActionCtx, + args: { + id: Id<"outreachRecords">; + }, + ): Promise<{ + ok: boolean; + outreachId: Id<"outreachRecords">; + }> => { + await requireOperator(ctx); + + const snapshot: OutreachSendSnapshot = await ctx.runMutation( + internal.outreach.claimApprovedEmailForSend, + { + id: args.id, + }, + ); + + try { + const smtpPort = parsePort(process.env.SMTP_PORT); + const smtpHost = process.env.SMTP_HOST?.trim(); + const smtpUser = process.env.SMTP_USER?.trim(); + const smtpPassword = process.env.SMTP_PASSWORD?.trim(); + + if (!smtpHost || !smtpUser || !smtpPassword || !snapshot.sender) { + throw new Error("SMTP-Konfiguration ist unvollständig."); + } + + const isSecureSmtp = smtpPort === 465; + const transporter = nodemailer.createTransport({ + host: smtpHost, + port: smtpPort, + secure: isSecureSmtp, + auth: { + user: smtpUser, + pass: smtpPassword, + }, + }); + + const result = (await transporter.sendMail({ + from: snapshot.sender, + to: snapshot.recipient, + subject: snapshot.subject, + text: snapshot.body, + })) as SentMessageInfo; + + const successPayload: { + id: Id<"outreachRecords">; + recipient: string; + subject: string; + body: string; + sender: string; + sentAt: number; + auditId?: Id<"audits">; + auditLink?: string | null; + smtpMessageId?: string; + smtpResponse?: string; + smtpAccepted?: string[]; + smtpRejected?: string[]; + } = { + id: args.id, + recipient: snapshot.recipient, + subject: snapshot.subject, + body: snapshot.body, + sender: snapshot.sender, + sentAt: Date.now(), + }; + if (snapshot.auditId !== undefined) { + successPayload.auditId = snapshot.auditId; + } + if (snapshot.auditLink !== undefined) { + successPayload.auditLink = snapshot.auditLink; + } + if (result.messageId !== undefined) { + successPayload.smtpMessageId = sanitizeValue(result.messageId); + } + if (result.response !== undefined) { + successPayload.smtpResponse = sanitizeValue(result.response); + } + if (Array.isArray(result.accepted) && result.accepted.length > 0) { + successPayload.smtpAccepted = normalizeRecipientList(result.accepted); + } + if (Array.isArray(result.rejected) && result.rejected.length > 0) { + successPayload.smtpRejected = normalizeRecipientList(result.rejected); + } + + await ctx.runMutation(internal.outreach.recordEmailSendSuccess, successPayload); + + return { + ok: true, + outreachId: snapshot.outreachId, + }; + } catch (error) { + const sanitized = sanitizeSmtpError(error); + const failure = extractSmtpError(error); + + const failurePayload: { + id: Id<"outreachRecords">; + recipient: string; + subject: string; + body: string; + sender: string; + auditId?: Id<"audits">; + auditLink?: string | null; + errorMessage?: string; + errorCode?: string; + errorResponseCode?: number; + errorResponse?: string; + } = { + id: args.id, + recipient: snapshot.recipient, + subject: snapshot.subject, + body: snapshot.body, + sender: snapshot.sender, + }; + if (snapshot.auditId !== undefined) { + failurePayload.auditId = snapshot.auditId; + } + if (snapshot.auditLink !== undefined) { + failurePayload.auditLink = snapshot.auditLink; + } + if (failure.message) { + failurePayload.errorMessage = sanitizeValue(failure.message); + } + if (failure.code !== undefined) { + failurePayload.errorCode = sanitizeValue(failure.code); + } + if (failure.responseCode !== undefined) { + failurePayload.errorResponseCode = failure.responseCode; + } + if (failure.response !== undefined) { + failurePayload.errorResponse = sanitizeValue(failure.response); + } + + console.error("SMTP-Versand fehlgeschlagen.", { + outreachId: snapshot.outreachId, + leadId: snapshot.leadId, + message: sanitized.message, + code: sanitized.code, + responseCode: sanitized.responseCode, + response: sanitized.response, + }); + + await ctx.runMutation( + internal.outreach.recordEmailSendFailure, + failurePayload, + ); + + throw new Error("SMTP-Versand ist fehlgeschlagen."); + } + }, +}); diff --git a/convex/schema.ts b/convex/schema.ts index a5c7f28..18c5d92 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -56,6 +56,10 @@ const outreachSendStatus = v.union( v.literal("sent"), v.literal("failed"), ); +const outreachSendAttemptStatus = v.union( + v.literal("success"), + v.literal("failed"), +); const outreachResponseStatus = v.union( v.literal("none"), v.literal("manual_reply_recorded"), @@ -500,6 +504,33 @@ export default defineSchema({ .index("by_sendStatus", ["sendStatus"]) .index("by_sendStatus_and_updatedAt", ["sendStatus", "updatedAt"]), + outreachSendAttempts: defineTable({ + outreachId: v.id("outreachRecords"), + leadId: v.id("leads"), + auditId: v.optional(v.id("audits")), + recipient: v.string(), + subject: v.string(), + body: v.string(), + sender: v.string(), + auditLink: v.optional(v.union(v.string(), v.null())), + status: outreachSendAttemptStatus, + sentAt: v.optional(v.number()), + smtpMessageId: v.optional(v.string()), + smtpResponse: v.optional(v.string()), + smtpAccepted: v.optional(v.array(v.string())), + smtpRejected: v.optional(v.array(v.string())), + errorMessage: v.optional(v.string()), + errorCode: v.optional(v.string()), + errorResponseCode: v.optional(v.number()), + errorResponse: v.optional(v.string()), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_outreachId", ["outreachId"]) + .index("by_leadId", ["leadId"]) + .index("by_status", ["status"]) + .index("by_createdAt", ["createdAt"]), + blacklistEntries: defineTable({ type: blacklistType, value: v.string(), diff --git a/package.json b/package.json index b8dd059..e252d61 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "convex": "^1.40.0", "lucide-react": "^1.17.0", "next": "16.2.7", + "nodemailer": "^8.0.10", "playwright-core": "^1.60.0", "radix-ui": "^1.4.3", "react": "19.2.4", @@ -35,6 +36,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/nodemailer": "^8.0.0", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f77b249..73e0351 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: next: specifier: 16.2.7 version: 16.2.7(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + nodemailer: + specifier: ^8.0.10 + version: 8.0.10 playwright-core: specifier: ^1.60.0 version: 1.60.0 @@ -75,6 +78,9 @@ importers: '@types/node': specifier: ^20 version: 20.19.41 + '@types/nodemailer': + specifier: ^8.0.0 + version: 8.0.0 '@types/react': specifier: ^19 version: 19.2.16 @@ -1758,6 +1764,9 @@ packages: '@types/node@20.19.41': resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==} + '@types/nodemailer@8.0.0': + resolution: {integrity: sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -3523,6 +3532,10 @@ packages: resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} engines: {node: '>=18'} + nodemailer@8.0.10: + resolution: {integrity: sha512-BLFuSth7QtHOkBzyqTehWWyub0NTRDuK2Q2SQfnGLsrJnzyU+Yeh4WpV1eZGuARFj1xQJHIdnTuJZLP+b9R1GQ==} + engines: {node: '>=6.0.0'} + npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -5941,6 +5954,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/nodemailer@8.0.0': + dependencies: + '@types/node': 20.19.41 + '@types/react-dom@19.2.3(@types/react@19.2.16)': dependencies: '@types/react': 19.2.16 @@ -7733,6 +7750,8 @@ snapshots: node-releases@2.0.47: {} + nodemailer@8.0.10: {} + npm-run-path@4.0.1: dependencies: path-key: 3.1.1 diff --git a/tests/outreach-review-contract.test.ts b/tests/outreach-review-contract.test.ts index 66d76ee..9f8311b 100644 --- a/tests/outreach-review-contract.test.ts +++ b/tests/outreach-review-contract.test.ts @@ -6,12 +6,16 @@ import ts from "typescript"; const outreachPath = join(process.cwd(), "convex", "outreach.ts"); const schemaPath = join(process.cwd(), "convex", "schema.ts"); +const actionPath = join(process.cwd(), "convex", "outreachSendAction.ts"); const outreachSource = existsSync(outreachPath) ? readFileSync(outreachPath, "utf8") : ""; const schemaSource = existsSync(schemaPath) ? readFileSync(schemaPath, "utf8") : ""; +const outreachSendActionSource = existsSync(actionPath) + ? readFileSync(actionPath, "utf8") + : ""; const sourceFile = ts.createSourceFile( "outreach.ts", @@ -20,6 +24,13 @@ const sourceFile = ts.createSourceFile( true, ts.ScriptKind.TS, ); +const actionSourceFile = ts.createSourceFile( + "outreachSendAction.ts", + outreachSendActionSource, + ts.ScriptTarget.ES2022, + true, + ts.ScriptKind.TS, +); function getExportedConstNames(file: ts.SourceFile) { const names = new Set(); @@ -73,6 +84,37 @@ function extractExportSource(name: string) { return outreachSource.slice(openBraceIndex, end + 1); } +function extractTableSource(tableName: string) { + const marker = `${tableName}: defineTable({`; + const start = schemaSource.indexOf(marker); + if (start === -1) { + return ""; + } + + const openBraceIndex = schemaSource.indexOf("{", start + marker.length - 1); + let depth = 0; + let end = -1; + + for (let index = openBraceIndex; index < schemaSource.length; index += 1) { + const char = schemaSource[index]; + if (char === "{") { + depth += 1; + } else if (char === "}") { + depth -= 1; + if (depth === 0) { + end = index; + break; + } + } + } + + if (end === -1) { + return ""; + } + + return schemaSource.slice(openBraceIndex, end + 1); +} + function hasPattern(source: string, pattern: RegExp, message: string) { assert.equal(pattern.test(source), true, message); } @@ -110,6 +152,67 @@ test("outreach review module exports authenticated review contracts", () => { ); }); +test("outreach record inserts never spread args directly", () => { + const createSource = extractExportSource("create"); + const upsertSource = extractExportSource("upsertFromAuditGeneration"); + + hasPattern( + createSource, + /buildOutreachRecordsInsertPayload/, + "create should delegate outreachRecords insert payload construction to a local helper.", + ); + hasPattern( + upsertSource, + /buildOutreachRecordsInsertPayload/, + "upsertFromAuditGeneration should delegate outreachRecords insert payload construction to a local helper.", + ); + lacksPattern(createSource, /\.\.\.args/, "create should not spread raw args into db insert payloads."); + lacksPattern( + createSource, + /ctx\.db\.insert\(\s*"outreachRecords"[\s\S]*\.\.\./, + "create should build explicit insert object fields.", + ); + lacksPattern(upsertSource, /\.\.\.args/, "upsertFromAuditGeneration should not spread raw args into db insert payloads."); + lacksPattern( + upsertSource, + /ctx\.db\.insert\(\s*"outreachRecords"[\s\S]*\.\.\.args/, + "upsertFromAuditGeneration should build explicit insert object fields.", + ); +}); + +test("outreach record payload builder keeps optional fields explicit", () => { + hasPattern( + outreachSource, + /const buildOutreachRecordsInsertPayload = /, + "create/upsert should use a dedicated insert payload helper.", + ); + hasPattern( + outreachSource, + /if \(args\.auditId !== undefined\) \{[\s\S]*auditId:/, + "Insert payload should include optional auditId only when it is set.", + ); + hasPattern( + outreachSource, + /if \(args\.phoneScript !== undefined\) \{[\s\S]*phoneScript:/, + "Insert payload should include optional phoneScript only when it is set.", + ); + hasPattern( + outreachSource, + /if \(args\.emailSubject !== undefined\) \{[\s\S]*emailSubject:/, + "Insert payload should include optional emailSubject only when it is set.", + ); + hasPattern( + outreachSource, + /if \(args\.emailBody !== undefined\) \{[\s\S]*emailBody:/, + "Insert payload should include optional emailBody only when it is set.", + ); + hasPattern( + outreachSource, + /if \(args\.followUpDraft !== undefined\) \{[\s\S]*followUpDraft:/, + "Insert payload should include optional followUpDraft only when it is set.", + ); +}); + test("listReviewWorkspace is bounded, authenticated, and joins review context", () => { const listSource = extractExportSource("listReviewWorkspace"); const reviewSource = `${listSource}\n${outreachSource}`; @@ -246,7 +349,7 @@ test("upsertFromAuditGeneration preserves review boundaries for generated copy", ); hasPattern( upsertSource, - /current\.sendStatus\s*===\s*"sent"[\s\S]*?ctx\.db\.insert\(\s*"outreachRecords"/, + /current\.sendStatus\s*===\s*"sent"[\s\S]*?buildOutreachRecordsInsertPayload/, "upsert should create a new draft record instead of patching a sent outreach record.", ); hasPattern( @@ -255,8 +358,8 @@ test("upsertFromAuditGeneration preserves review boundaries for generated copy", "Generated copy changes should reset existing unsent outreach records to draft.", ); hasPattern( - upsertSource, - /approvalStatus:\s*"draft"[\s\S]*sendStatus:\s*"not_sent"/, + outreachSource, + /buildOutreachRecordsInsertPayload[\s\S]*approvalStatus:\s*"draft"[\s\S]*sendStatus:\s*"not_sent"/, "New generated outreach records should start as unsent drafts.", ); }); @@ -302,6 +405,7 @@ test("saveReviewDraft validates editable fields and never edits sent records", ( hasPattern(saveSource, /ctx\.db\.get\(args\.id\)/, "saveReviewDraft should load the outreach record."); hasPattern(saveSource, /!outreach/, "saveReviewDraft should reject missing outreach."); hasPattern(saveSource, /outreach\.sendStatus\s*===\s*"sent"/, "saveReviewDraft should reject sent outreach records."); + hasPattern(saveSource, /outreach\.sendStatus\s*===\s*"queued"/, "saveReviewDraft should reject queued outreach records."); hasPattern(saveSource, /approvalStatus:\s*"draft"/, "Saving edits should reset approval to draft."); hasPattern(saveSource, /updatedAt:\s*now/, "Saving edits should stamp updatedAt."); hasPattern(saveSource, /ctx\.db\.patch\(args\.id/, "saveReviewDraft should patch the existing outreach record."); @@ -316,16 +420,324 @@ test("approveEmailDraft validates approval prerequisites and preserves send sepa hasPattern(approveSource, /ctx\.db\.get\(args\.id\)/, "approveEmailDraft should load the outreach record."); hasPattern(approveSource, /!outreach/, "approveEmailDraft should reject missing outreach."); hasPattern(approveSource, /outreach\.sendStatus\s*===\s*"sent"/, "approveEmailDraft should reject sent outreach records."); + hasPattern( + approveSource, + /outreach\.sendStatus\s*===\s*"queued"/, + "approveEmailDraft should reject queued outreach records.", + ); hasPattern(approveSource, /ctx\.db\.get\(outreach\.leadId\)/, "approveEmailDraft should load the linked lead."); hasPattern(approveSource, /lead\.email\?\.trim\(\)/, "approveEmailDraft should require a trimmed recipient email."); hasPattern(approveSource, /outreach\.emailSubject\?\.trim\(\)/, "approveEmailDraft should require a trimmed subject."); hasPattern(approveSource, /outreach\.emailBody\?\.trim\(\)/, "approveEmailDraft should require a trimmed body."); + hasPattern( + approveSource, + /process\.env\.SMTP_FROM\?\.trim\(\)/, + "approveEmailDraft should resolve the configured SMTP_FROM sender.", + ); + hasPattern( + approveSource, + /SMTP-Absender-Adresse fehlt\./, + "approveEmailDraft should fail fast if SMTP_FROM is not configured.", + ); hasPattern(approveSource, /approvalStatus:\s*"approved"/, "Approval should mark only the approval status."); hasPattern(approveSource, /updatedAt:\s*now/, "Approval should stamp updatedAt."); hasPattern(approveSource, /recipient:/, "Approval should return recipient context."); hasPattern(approveSource, /subject:/, "Approval should return subject context."); + hasPattern(approveSource, /sender:/, "Approval should return sender context."); hasPattern(approveSource, /auditSlug:/, "Approval should return audit slug context when available."); lacksPattern(approveSource, /sendStatus\s*:/, "Approval must not alter sendStatus."); - lacksPattern(approveSource, /ctx\.scheduler|runAfter|runMutation|nodemailer|smtp|smpp|sendEmail/i, "Approval must not queue or send email/SMPP/Nodemailer work."); + lacksPattern( + approveSource, + /SMTP_USER|SMTP_PASSWORD|SMTP_HOST/, + "Approval should not expose SMTP credentials in returned context.", + ); + lacksPattern( + approveSource, + /ctx\.scheduler|runAfter|runMutation|nodemailer|smpp|sendEmail/i, + "Approval must not queue or send email/SMPP/Nodemailer work.", + ); +}); + +test("schema defines outbound send attempt logging table", () => { + assert.equal(existsSync(schemaPath), true, "schema.ts should be present."); + const tableSource = extractTableSource("outreachSendAttempts"); + assert.equal( + tableSource.length > 0, + true, + "schema.ts should define outreachSendAttempts table.", + ); + hasPattern( + tableSource, + /outreachId:\s*v\.id\(\s*"outreachRecords"\s*\)/, + "outreachSendAttempts must track outreachId.", + ); + hasPattern( + tableSource, + /leadId:\s*v\.id\(\s*"leads"\s*\)/, + "outreachSendAttempts must track leadId.", + ); + hasPattern( + tableSource, + /auditId:\s*v\.optional\(v\.id\(\s*"audits"\s*\)\)/, + "outreachSendAttempts should optionally track auditId.", + ); + hasPattern( + tableSource, + /recipient:\s*v\.string\(\)/, + "outreachSendAttempts should persist recipient.", + ); + hasPattern( + tableSource, + /subject:\s*v\.string\(\)/, + "outreachSendAttempts should persist subject.", + ); + hasPattern( + tableSource, + /body:\s*v\.string\(\)/, + "outreachSendAttempts should persist body.", + ); + hasPattern( + tableSource, + /sender:\s*v\.string\(\)/, + "outreachSendAttempts should persist sender.", + ); + hasPattern( + tableSource, + /status:\s*(?:v\.union\(\s*v\.literal\(\s*"success"\s*\),\s*v\.literal\(\s*"failed"\s*\)\s*\)|outreachSendAttemptStatus)/, + "outreachSendAttempts status must be success or failed.", + ); + hasPattern( + tableSource, + /sentAt:\s*v\.optional\(v\.number\(\)\)/, + "outreachSendAttempts should persist optional sentAt.", + ); + hasPattern( + tableSource, + /smtpMessageId:\s*v\.optional\(v\.string\(\)\)/, + "outreachSendAttempts should persist optional smtpMessageId.", + ); + hasPattern( + tableSource, + /smtpResponse:\s*v\.optional\(v\.string\(\)\)/, + "outreachSendAttempts should persist optional smtpResponse.", + ); + hasPattern( + tableSource, + /smtpAccepted:\s*v\.optional\(v\.array\(v\.string\(\)\)\)/, + "outreachSendAttempts should persist optional smtpAccepted.", + ); + hasPattern( + tableSource, + /smtpRejected:\s*v\.optional\(v\.array\(v\.string\(\)\)\)/, + "outreachSendAttempts should persist optional smtpRejected.", + ); + hasPattern( + tableSource, + /errorMessage:\s*v\.optional\(v\.string\(\)\)/, + "outreachSendAttempts should persist optional errorMessage.", + ); + hasPattern( + tableSource, + /errorCode:\s*v\.optional\(v\.string\(\)\)/, + "outreachSendAttempts should persist optional errorCode.", + ); + hasPattern( + tableSource, + /errorResponseCode:\s*v\.optional\(v\.number\(\)\)/, + "outreachSendAttempts should persist optional errorResponseCode.", + ); + hasPattern( + tableSource, + /errorResponse:\s*v\.optional\(v\.string\(\)\)/, + "outreachSendAttempts should persist optional errorResponse.", + ); + hasPattern( + tableSource, + /auditLink:\s*v\.optional\(v\.union\(v\.string\(\),\s*v\.null\(\)\)\)/, + "outreachSendAttempts should persist optional auditLink.", + ); + hasPattern( + tableSource, + /createdAt:\s*v\.number\(\)/, + "outreachSendAttempts should persist createdAt.", + ); + hasPattern( + tableSource, + /updatedAt:\s*v\.number\(\)/, + "outreachSendAttempts should persist updatedAt.", + ); + + hasPattern( + schemaSource, + /defineTable\(\{\s*[\s\S]*\}\)\s*\.index\("by_outreachId",\s*\["outreachId"\]\)/, + "outreachSendAttempts should be queryable by outreach id.", + ); + hasPattern( + schemaSource, + /"by_status",\s*\["status"\]/, + "outreachSendAttempts should include status indexing.", + ); +}); + +test("outreach module exports internal send claim and logging mutations", () => { + const exports = getExportedConstNames(sourceFile); + for (const exportName of [ + "claimApprovedEmailForSend", + "recordEmailSendSuccess", + "recordEmailSendFailure", + ]) { + assert.equal( + exports.has(exportName), + true, + `Expected export: ${exportName}`, + ); + } + + const claimSource = extractExportSource("claimApprovedEmailForSend"); + const successSource = extractExportSource("recordEmailSendSuccess"); + const failureSource = extractExportSource("recordEmailSendFailure"); + + hasPattern( + claimSource, + /outreach\.approvalStatus\s*!==\s*"approved"/, + "claimApprovedEmailForSend must reject non-approved or already sent outreach records.", + ); + hasPattern( + claimSource, + /outreach\.sendStatus\s*===\s*"sent"\s*\|\|\s*outreach\.sendStatus\s*===\s*"queued"/, + "claimApprovedEmailForSend should only claim not_sent or failed records.", + ); + hasPattern( + claimSource, + /ctx\.db\.patch\(args\.id,\s*{[\s\S]*sendStatus:\s*"queued"[\s\S]*updatedAt:\s*now[\s\S]*}\)/, + "claimApprovedEmailForSend must set sendStatus queued and update updatedAt.", + ); + hasPattern( + claimSource, + /sender/, + "claimApprovedEmailForSend should include sender in its snapshot return.", + ); + hasPattern( + claimSource, + /recipient/, + "claimApprovedEmailForSend should include recipient in its snapshot return.", + ); + hasPattern( + claimSource, + /body/, + "claimApprovedEmailForSend should include body in its snapshot return.", + ); + hasPattern( + claimSource, + /auditLink/, + "claimApprovedEmailForSend should include optional auditLink in its snapshot return.", + ); + hasPattern( + successSource, + /ctx\.db\.patch\(args\.id,\s*{[\s\S]*sendStatus:\s*"sent"[\s\S]*sentAt:\s*args\.sentAt[\s\S]*updatedAt:\s*now[\s\S]*}\)/, + "recordEmailSendSuccess should mark status sent, sentAt, and updatedAt.", + ); + hasPattern( + successSource, + /ctx\.db\.patch\(lead\._id,[\s\S]*contactStatus:\s*"contacted"[\s\S]*updatedAt:\s*now[\s\S]*}\)/, + "recordEmailSendSuccess should mark outreach lead as contacted.", + ); + hasPattern( + successSource, + /ctx\.db\.insert\(\s*"outreachSendAttempts"/, + "recordEmailSendSuccess should insert an outreachSendAttempts row.", + ); + hasPattern( + failureSource, + /ctx\.db\.patch\(args\.id,\s*{[\s\S]*sendStatus:\s*"failed"[\s\S]*updatedAt:\s*now[\s\S]*}\)/, + "recordEmailSendFailure should mark status failed and update updatedAt.", + ); + hasPattern( + failureSource, + /ctx\.db\.insert\(\s*"outreachSendAttempts"/, + "recordEmailSendFailure should insert an outreachSendAttempts row.", + ); + hasPattern( + successSource, + /status:\s*outreachSendAttemptSuccessStatus/, + "recordEmailSendSuccess should send a typed success status.", + ); + hasPattern( + failureSource, + /status:\s*outreachSendAttemptFailedStatus/, + "recordEmailSendFailure should send a typed failed status.", + ); + lacksPattern( + failureSource, + /contactStatus:\s*"contacted"/, + "recordEmailSendFailure should not update lead to contacted.", + ); +}); + +test("outreachSendAction exists as Node action and orchestrates claim/send/log flow", () => { + assert.equal(existsSync(actionPath), true, "outreachSendAction.ts should be present."); + const actionExports = getExportedConstNames(actionSourceFile); + + hasPattern( + outreachSendActionSource, + /^"use node";/m, + "outreachSendAction.ts should declare Node runtime.", + ); + assert.equal( + actionExports.has("sendApprovedEmail"), + true, + "outreachSendAction.ts should export sendApprovedEmail.", + ); + + hasPattern( + outreachSendActionSource, + /sendApprovedEmail\s*=\s*action\(\s*{\s*args:\s*{[\s\S]*id:\s*v\.id\(\s*"outreachRecords"\s*\)[\s\S]*}\s*,/, + "sendApprovedEmail should accept outreachRecords id.", + ); + hasPattern( + outreachSendActionSource, + /internal\.outreach\.claimApprovedEmailForSend/, + "sendApprovedEmail must call claimApprovedEmailForSend before sending.", + ); + hasPattern( + outreachSendActionSource, + /nodemailer\.createTransport\(\s*{[\s\S]*host:\s*smtpHost[\s\S]*port:\s*smtpPort[\s\S]*secure:\s*isSecureSmtp/, + "sendApprovedEmail should configure Nodemailer from SMTP env vars.", + ); + hasPattern( + outreachSendActionSource, + /sendMail\(\s*{[\s\S]*from:\s*snapshot\.sender[\s\S]*to:\s*snapshot\.recipient[\s\S]*subject:\s*snapshot\.subject[\s\S]*text:\s*snapshot\.body[\s\S]*}\s*\)/, + "sendApprovedEmail should send with snapshot sender, recipient, subject, body.", + ); + hasPattern( + outreachSendActionSource, + /internal\.outreach\.recordEmailSendSuccess/, + "sendApprovedEmail should delegate successful sends to recordEmailSendSuccess.", + ); + hasPattern( + outreachSendActionSource, + /internal\.outreach\.recordEmailSendFailure/, + "sendApprovedEmail should delegate failed sends to recordEmailSendFailure.", + ); + hasPattern( + outreachSendActionSource, + /console\.error\(|console\.warn\(/, + "sendApprovedEmail should log failed sends with a concrete error path.", + ); + hasPattern( + outreachSendActionSource, + /sanitizeSmtpError/, + "sendApprovedEmail should sanitize SMTP error details before logging.", + ); + hasPattern( + outreachSendActionSource, + /const successPayload: \{[\s\S]*auditId\?:[\s\S]*auditLink\?:[\s\S]*smtpMessageId\?:[\s\S]*smtpAccepted\?:[\s\S]*smtpRejected\?:/, + "Success logging payload should be assembled with optional fields, not spread directly.", + ); + hasPattern( + outreachSendActionSource, + /const failurePayload: \{[\s\S]*auditId\?:[\s\S]*auditLink\?:[\s\S]*errorMessage\?:[\s\S]*errorCode\?:[\s\S]*errorResponseCode\?:[\s\S]*errorResponse\?:/, + "Failure logging payload should be assembled with optional fields, not spread directly.", + ); }); diff --git a/tests/outreach-review-workspace-ui.test.ts b/tests/outreach-review-workspace-ui.test.ts index fc626d9..abdcec9 100644 --- a/tests/outreach-review-workspace-ui.test.ts +++ b/tests/outreach-review-workspace-ui.test.ts @@ -19,8 +19,10 @@ const outreachWorkspacePath = join( ); function extractConstFunction(source: string, name: string) { - const declaration = `const ${name} = async`; - const start = source.indexOf(declaration); + const declaration = new RegExp(`const ${name}\\s*=\\s*(?:async\\s+)?\\(`); + const match = source.match(declaration); + assert.ok(match, `${name} handler should exist.`); + const start = match.index ?? -1; assert.ok(start >= 0, `${name} handler should exist.`); const firstBrace = source.indexOf("{", start); @@ -95,13 +97,13 @@ test("OutreachReviewWorkspace separates audit publication from email approval", assert.match(source, /Audit veröffentlichen/); assert.match(source, /Änderungen speichern/); - assert.match(source, /E-Mail freigeben/); - assert.doesNotMatch(source, /E-Mail freigeben und senden/); - assert.doesNotMatch(source, /api\.outreach\.(send|sendEmail|sendDraft)/); + assert.match(source, /E-Mail freigeben und senden/); + assert.match(source, /useAction/); + assert.match(source, /outreachSendAction[\s\S]*sendApprovedEmail/); const auditPublishIndex = source.indexOf("Audit veröffentlichen"); const auditSaveIndex = source.indexOf("Änderungen speichern"); - const emailApprovalIndex = source.indexOf("E-Mail freigeben"); + const emailApprovalIndex = source.indexOf("E-Mail freigeben und senden"); assert.ok(auditPublishIndex >= 0); assert.ok(auditSaveIndex >= 0); @@ -113,6 +115,117 @@ test("OutreachReviewWorkspace separates audit publication from email approval", ); }); +test("OutreachReviewWorkspace disables draft/approval/final controls for queued send", async () => { + const source = await readFile(outreachWorkspacePath, "utf8"); + + assert.match(source, /outreach\.sendStatus\s*===\s*"queued"/); + assert.match(source, /const isQueuedSend = outreach\?\.sendStatus === "queued"/); + assert.match( + source, + /disabled=\{[\s\S]*busyAction === `\$?\{record\.id\}:outreach-save`[\s\S]*\|\|\s*isQueuedSend[\s\S]*\}/, + "Outreach save control should be disabled while outreach is queued.", + ); + assert.match( + source, + /disabled=\{[\s\S]*busyAction === `\$?\{record\.id\}:email-approval`[\s\S]*\|\|\s*isQueuedSend[\s\S]*\}/, + "Email approval control should be disabled while outreach is queued.", + ); + assert.match( + source, + /disabled=\{[\s\S]*busyAction === `\$?\{pendingEmailConfirmation\.id\}:email-send`[\s\S]*\|\|\s*isQueuedSendForConfirmation[\s\S]*\}/, + "Final send control should be disabled while confirmed outreach is queued.", + ); +}); + +test("OutreachReviewWorkspace prevents draft mutation handlers for queued outreach", async () => { + const source = await readFile(outreachWorkspacePath, "utf8"); + const saveOutreachHandler = extractConstFunction(source, "saveOutreach"); + const approveEmailHandler = extractConstFunction(source, "approveEmail"); + + assert.match(saveOutreachHandler, /outreach\.sendStatus\s*===\s*"queued"/); + assert.match(saveOutreachHandler, /[aA]ufgrund des laufenden Sendevorgangs/); + + assert.match(approveEmailHandler, /outreach\.sendStatus\s*===\s*"queued"/); + assert.match(approveEmailHandler, /[aA]ufgrund des laufenden Sendevorgangs/); +}); + +test("OutreachReviewWorkspace prevents final send when confirmed outreach is queued", async () => { + const source = await readFile(outreachWorkspacePath, "utf8"); + const sendHandler = extractConstFunction(source, "sendApprovedEmailFromConfirmation"); + + assert.match( + sendHandler, + /const isQueuedSend = rows\.some\(/, + "Final send handler should check current list records for queued status before sending.", + ); + assert.match( + sendHandler, + /\.sendStatus\s*===\s*"queued"/, + "Final send handler should guard queued status.", + ); + assert.match( + sendHandler, + /setNotice\(\s*"(?:.*(?:[aA]ufgrund[^"\r\n]*Sendevorgang|.*bereits[^"\r\n]*Vorgang|.*bereits[^"\r\n]*im Gange)[^"]*)"/, + ); +}); + +test("OutreachReviewWorkspace useAction receives a typed send action ref", async () => { + const source = await readFile(outreachWorkspacePath, "utf8"); + + assert.match(source, /useAction\(api\.outreachSendAction\.sendApprovedEmail\)/); + assert.doesNotMatch( + source, + /as \{\s*outreachSendAction:\s*\{\s*sendApprovedEmail:\s*unknown/, + ); +}); + +test("OutreachReviewWorkspace includes final confirmation UI fields", async () => { + const source = await readFile(outreachWorkspacePath, "utf8"); + + assert.match(source, /Dialog/); + assert.match(source, /Empfänger/); + assert.match(source, /Betreff/); + assert.match(source, /Absender/); + assert.match(source, /Audit-Link/); + assert.match(source, /sender/); + assert.doesNotMatch(source, /Konfigurierter SMTP-Absender/); +}); + +test("approveEmail opens confirmation after save and approval", async () => { + const source = await readFile(outreachWorkspacePath, "utf8"); + const handler = extractConstFunction(source, "approveEmail"); + + const draftIndex = handler.indexOf("const draft = drafts[record.id] ?? getDraft(record)"); + const saveIndex = handler.indexOf("await saveReviewDraft"); + const approveIndex = handler.indexOf("await approveEmailDraft"); + const confirmationIndex = handler.indexOf("setPendingEmailConfirmation"); + + assert.ok(draftIndex >= 0, "Approval should read the current local draft."); + assert.ok(saveIndex >= 0, "Approval should persist the current draft first."); + assert.ok(approveIndex >= 0, "Approval should still call approveEmailDraft."); + assert.ok(confirmationIndex >= 0, "Approval should open a confirmation dialog."); + assert.ok(draftIndex < saveIndex, "Approval should read draft before save."); + assert.ok(saveIndex < approveIndex, "Approval should save before approve."); + assert.ok(approveIndex < confirmationIndex, "Confirmation should open after approval."); + assert.equal( + /sendApprovedEmail/.test(handler), + false, + "Approval should not call sendApprovedEmail.", + ); + + assert.match(handler, /emailSubject:\s*draft\.emailSubject/); + assert.match(handler, /emailBody:\s*draft\.emailBody/); + assert.match(handler, /followUpDraft:\s*draft\.followUpDraft/); + assert.match( + handler, + /approvalData\.sender/, + ); + assert.ok( + !/approvalData\.sender\s*\?\?\s*SMTP_SENDER_PLACEHOLDER/.test(handler), + "Sender should come from approvalResult without placeholder fallback.", + ); +}); + test("OutreachReviewWorkspace gates phone scripts to call-first or missing-contact leads with phone numbers", async () => { const source = await readFile(outreachWorkspacePath, "utf8"); @@ -143,6 +256,38 @@ test("approveEmail saves the visible outreach draft before approving it", async assert.match(handler, /followUpDraft:\s*draft\.followUpDraft/); }); +test("final email send handler calls sendApprovedEmail with outreach id", async () => { + const source = await readFile(outreachWorkspacePath, "utf8"); + const handler = extractConstFunction(source, "sendApprovedEmailFromConfirmation"); + + assert.match(handler, /await sendApprovedEmail\(\{\s*id:\s*confirmation\.id/); + assert.ok( + /await sendApprovedEmail\([\s\S]*id:\s*confirmation\.id[\s\S]*\}/.test(handler), + "Final handler should pass the currently confirmed outreach id.", + ); + assert.ok( + handler.indexOf("E-Mail gesendet.") >= 0, + "Final send should show a success notice.", + ); + assert.ok( + /Retry|erneut|nochmal|nicht versendet|nicht gesendet/.test(handler), + "Final send should surface a retry-oriented failure notice.", + ); + assert.equal( + handler.indexOf("setPendingEmailConfirmation(null)") >= 0, + true, + "Final handler should clear confirmation on success.", + ); +}); + +test("canceling confirmation does not send", async () => { + const source = await readFile(outreachWorkspacePath, "utf8"); + const handler = extractConstFunction(source, "closeEmailConfirmation"); + + assert.ok(handler.includes("setPendingEmailConfirmation(null)")); + assert.equal(/sendApprovedEmail/.test(handler), false); +}); + test("publishAudit saves the visible audit draft before publishing it", async () => { const source = await readFile(outreachWorkspacePath, "utf8"); const handler = extractConstFunction(source, "publishAudit");