311 lines
11 KiB
TypeScript
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" });
|
|
}
|
|
}
|
|
},
|
|
});
|