Add SMTP send flow for approved outreach
This commit is contained in:
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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")),
|
||||
|
||||
335
convex/outreachSendAction.ts
Normal file
335
convex/outreachSendAction.ts
Normal 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.");
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user