Files
webdev-pipeline/v2_elemente/outreach.ts

311 lines
11 KiB
TypeScript

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