/** * convex/outreach.ts * * Outreach-Versand über die BYO-Mailbox des Nutzers — erst nach manueller * Freigabe, mit Suppression-/Blacklist-Prüfung und Pflicht-Footer. * * Versand-Gate (architektonisch erzwungen): gesendet wird ausschließlich, * wenn approvalStatus === "approved" UND der Empfänger nicht gesperrt ist. * SMTP wird an outreachNode.ts delegiert; Gmail/Graph laufen hier per fetch. */ import { v } from "convex/values"; import { query, mutation, internalAction, internalMutation, internalQuery, } from "./_generated/server"; import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; import { decryptSecret } from "./lib/crypto"; import { buildFooter, buildHtmlEmail, buildGmailRaw, getGmailAccessToken, getMsAccessToken, sendViaGmail, sendViaGraph, domainOf, } from "./lib/email"; const DEFAULT_FOOTER_FIELDS = ["name", "address", "imprint_link"]; // ---- Auth-Helfer ------------------------------------------------------------- async function requireOwnedOutreach(ctx: any, outreachId: Id<"outreach">) { const identity = await ctx.auth.getUserIdentity(); if (!identity) throw new Error("Nicht authentifiziert"); const user = await ctx.db .query("users") .withIndex("by_auth", (q: any) => q.eq("authRef", identity.subject)) .first(); const outreach = await ctx.db.get(outreachId); if (!user || !outreach || outreach.orgId !== user.orgId) { throw new Error("Outreach nicht gefunden oder nicht berechtigt"); } return { user, outreach }; } // ---- Entwurf bearbeiten & freigeben ------------------------------------------ export const updateDraft = mutation({ args: { outreachId: v.id("outreach"), emailSubject: v.optional(v.string()), emailBody: v.optional(v.string()), phoneScript: v.optional(v.string()), contactStrategy: v.optional( v.union( v.literal("call_first"), v.literal("email_direct"), v.literal("defer"), v.literal("do_not_contact"), ), ), }, handler: async (ctx, { outreachId, ...patch }) => { const { outreach } = await requireOwnedOutreach(ctx, outreachId); if (outreach.approvalStatus !== "pending") { throw new Error("Nur Entwürfe sind editierbar"); } await ctx.db.patch(outreachId, patch); }, }); export const approveOutreach = mutation({ args: { outreachId: v.id("outreach") }, handler: async (ctx, { outreachId }) => { const { outreach } = await requireOwnedOutreach(ctx, outreachId); if (outreach.auditId) { const audit = await ctx.db.get(outreach.auditId); if (audit && audit.status === "draft") { throw new Error("Zugehöriges Audit ist noch nicht freigegeben"); } } await ctx.db.patch(outreachId, { approvalStatus: "approved" }); }, }); /** Versand anstoßen — nur für freigegebene Outreach mit gewähltem Postfach. */ export const requestSend = mutation({ args: { outreachId: v.id("outreach"), mailboxConnectionId: v.id("mailboxConnections"), }, handler: async (ctx, { outreachId, mailboxConnectionId }) => { const { user, outreach } = await requireOwnedOutreach(ctx, outreachId); if (outreach.approvalStatus !== "approved") { throw new Error("Erst freigeben"); } if (outreach.sentAt) throw new Error("Bereits versendet"); const lead = await ctx.db.get(outreach.leadId); if (!lead?.email) throw new Error("Kein Empfänger (keine E-Mail-Adresse)"); const conn = await ctx.db.get(mailboxConnectionId); if (!conn || conn.orgId !== user.orgId || conn.status !== "active") { throw new Error("Postfach nicht verfügbar"); } const org = await ctx.db.get(user.orgId); if (org?.killSwitch) throw new Error("Kill-Switch aktiv"); await ctx.db.patch(outreachId, { mailboxConnectionId }); await ctx.scheduler.runAfter(0, internal.outreach.sendApprovedOutreach, { orgId: user.orgId, outreachId, }); }, }); // ---- Interne DB-Helfer ------------------------------------------------------- export const getSendContext = internalQuery({ args: { orgId: v.id("organizations"), outreachId: v.id("outreach") }, handler: async (ctx, { orgId, outreachId }) => { const outreach = await ctx.db.get(outreachId); if (!outreach || outreach.orgId !== orgId) return null; const lead = await ctx.db.get(outreach.leadId); const org = await ctx.db.get(orgId); const conn = outreach.mailboxConnectionId ? await ctx.db.get(outreach.mailboxConnectionId) : null; const marketConfig = org?.marketConfigId ? await ctx.db.get(org.marketConfigId) : null; return { outreach, email: lead?.email ?? null, domain: lead?.email ? domainOf(lead.email) : null, conn, requiredFooterFields: marketConfig?.requiredFooterFields ?? DEFAULT_FOOTER_FIELDS, killSwitch: org?.killSwitch ?? false, // senderAddress / imprintUrl sollten aus Org-Einstellungen kommen: senderAddress: undefined as string | undefined, imprintUrl: undefined as string | undefined, }; }, }); /** Suppression (Opt-out) UND Blacklist prüfen — gilt für E-Mail und Domain. */ export const isBlockedRecipient = internalQuery({ args: { orgId: v.id("organizations"), email: v.string(), domain: v.string(), }, handler: async (ctx, { orgId, email, domain }) => { for (const value of [email.toLowerCase(), domain]) { const supp = await ctx.db .query("suppressions") .withIndex("by_org_value", (q) => q.eq("orgId", orgId).eq("value", value)) .first(); if (supp) return { blocked: true, reason: "opt_out" as const }; const bl = await ctx.db .query("blacklist") .withIndex("by_org_value", (q) => q.eq("orgId", orgId).eq("value", value)) .first(); if (bl) return { blocked: true, reason: "blacklist" as const }; } return { blocked: false }; }, }); export const markSent = internalMutation({ args: { outreachId: v.id("outreach"), leadId: v.id("leads") }, handler: async (ctx, { outreachId, leadId }) => { await ctx.db.patch(outreachId, { sentAt: Date.now(), salesStatus: "follow_up_planned", }); await ctx.db.patch(leadId, { contactStatus: "contacted" }); }, }); // ---- Versand-Orchestrator (Action) ------------------------------------------- export const sendApprovedOutreach = internalAction({ args: { orgId: v.id("organizations"), outreachId: v.id("outreach") }, handler: async (ctx, { orgId, outreachId }) => { const c = await ctx.runQuery(internal.outreach.getSendContext, { orgId, outreachId }); if (!c) throw new Error("Send-Kontext nicht gefunden"); // Gate erneut serverseitig prüfen (Defense in Depth) if (c.outreach.approvalStatus !== "approved") throw new Error("Nicht freigegeben"); if (c.outreach.sentAt) return; // schon versendet if (c.killSwitch) throw new Error("Kill-Switch aktiv"); if (!c.email || !c.domain) throw new Error("Kein Empfänger"); if (!c.conn || c.conn.status !== "active") throw new Error("Postfach inaktiv"); // Suppression / Blacklist const blocked = await ctx.runQuery(internal.outreach.isBlockedRecipient, { orgId, email: c.email, domain: c.domain, }); if (blocked.blocked) { console.warn(`Versand blockiert (${blocked.reason}) für ${outreachId}`); return; } // Pflicht-Footer + Inhalt const unsubscribeUrl = `${process.env.PUBLIC_APP_URL ?? ""}/abmelden?o=${outreachId}`; const footer = buildFooter({ requiredFields: c.requiredFooterFields, fromName: c.conn.fromName, fromAddress: c.conn.fromAddress, senderAddress: c.senderAddress, imprintUrl: c.imprintUrl, unsubscribeUrl, }); const subject = c.outreach.emailSubject ?? ""; const bodyText = c.outreach.emailBody ?? ""; const html = buildHtmlEmail(bodyText, footer.html); const text = `${bodyText}\n\n${footer.text}`; // Versand je nach Provider if (c.conn.provider === "gmail") { if (!c.conn.oauthRef) throw new Error("Kein OAuth-Token"); const token = await getGmailAccessToken(await decryptSecret(c.conn.oauthRef)); const raw = buildGmailRaw({ fromName: c.conn.fromName, fromAddress: c.conn.fromAddress, to: c.email, subject, html, }); await sendViaGmail(token, raw); } else if (c.conn.provider === "microsoft") { if (!c.conn.oauthRef) throw new Error("Kein OAuth-Token"); const token = await getMsAccessToken(await decryptSecret(c.conn.oauthRef)); await sendViaGraph({ accessToken: token, to: c.email, subject, html }); } else if (c.conn.provider === "smtp") { const cfg = c.conn.smtpConfig; if (!cfg) throw new Error("Keine SMTP-Konfiguration"); await ctx.runAction(internal.outreachNode.sendViaSmtp, { host: cfg.host, port: cfg.port, secure: cfg.secure, username: cfg.username, password: await decryptSecret(cfg.encryptedPassword), fromName: c.conn.fromName, fromAddress: c.conn.fromAddress, to: c.email, subject, html, text, }); } await ctx.runMutation(internal.outreach.markSent, { outreachId, leadId: c.outreach.leadId, }); }, }); // ---- Opt-out (von http.ts /abmelden aufgerufen) ------------------------------ export const getOptOutTarget = internalQuery({ args: { outreachId: v.id("outreach") }, handler: async (ctx, { outreachId }) => { const outreach = await ctx.db.get(outreachId); if (!outreach) return null; const lead = await ctx.db.get(outreach.leadId); if (!lead?.email) return null; return { orgId: outreach.orgId, email: lead.email, domain: domainOf(lead.email) }; }, }); export const recordOptOut = internalMutation({ args: { orgId: v.id("organizations"), email: v.string(), domain: v.string(), outreachId: v.optional(v.id("outreach")), }, handler: async (ctx, { orgId, email, domain, outreachId }) => { for (const [value, valueType] of [ [email.toLowerCase(), "email"] as const, [domain, "domain"] as const, ]) { const existing = await ctx.db .query("suppressions") .withIndex("by_org_value", (q) => q.eq("orgId", orgId).eq("value", value)) .first(); if (!existing) { await ctx.db.insert("suppressions", { orgId, value, valueType, reason: "opt_out" }); } } if (outreachId) { const o = await ctx.db.get(outreachId); if (o) { await ctx.db.patch(outreachId, { salesStatus: "do_not_pursue" }); await ctx.db.patch(o.leadId, { contactStatus: "do_not_contact" }); } } }, });