Add SMTP send flow for approved outreach

This commit is contained in:
Matthias
2026-06-05 21:05:59 +02:00
parent 42a3ea64a5
commit b2f7348ef0
10 changed files with 1531 additions and 56 deletions

View File

@@ -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;

View File

@@ -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")),

View File

@@ -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<void> {
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.");
}
},
});

View File

@@ -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(),