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

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