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