336 lines
9.1 KiB
TypeScript
336 lines
9.1 KiB
TypeScript
"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.");
|
|
}
|
|
},
|
|
});
|