Add SMTP send flow for approved outreach
This commit is contained in:
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.");
|
||||
}
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user