Add SMTP send flow for approved outreach

This commit is contained in:
Matthias
2026-06-05 21:05:59 +02:00
parent 42a3ea64a5
commit b2f7348ef0
10 changed files with 1531 additions and 56 deletions

View File

@@ -181,6 +181,62 @@ const loadReviewRow = async (
};
};
type OutreachRecordInsertArgs = {
leadId: Id<"leads">;
auditId?: Id<"audits">;
strategy: "call_first" | "email_first" | "defer" | "do_not_contact";
phoneScript?: string;
emailSubject?: string;
emailBody?: string;
followUpDraft?: string;
now: number;
};
const buildOutreachRecordsInsertPayload = (args: OutreachRecordInsertArgs) => {
const payload: {
leadId: Id<"leads">;
auditId?: Id<"audits">;
strategy: "call_first" | "email_first" | "defer" | "do_not_contact";
phoneScript?: string;
emailSubject?: string;
emailBody?: string;
followUpDraft?: string;
approvalStatus: "draft";
sendStatus: "not_sent";
responseStatus: "none";
salesStatus: "follow_up_planned";
createdAt: number;
updatedAt: number;
} = {
leadId: args.leadId,
strategy: args.strategy,
approvalStatus: "draft",
sendStatus: "not_sent",
responseStatus: "none",
salesStatus: "follow_up_planned",
createdAt: args.now,
updatedAt: args.now,
};
if (args.auditId !== undefined) {
payload.auditId = args.auditId;
}
if (args.phoneScript !== undefined) {
payload.phoneScript = args.phoneScript;
}
if (args.emailSubject !== undefined) {
payload.emailSubject = args.emailSubject;
}
if (args.emailBody !== undefined) {
payload.emailBody = args.emailBody;
}
if (args.followUpDraft !== undefined) {
payload.followUpDraft = args.followUpDraft;
}
return payload;
};
export const create = mutation({
args: {
leadId: v.id("leads"),
@@ -210,16 +266,19 @@ export const create = mutation({
}
const now = Date.now();
return await ctx.db.insert("outreachRecords", {
...args,
approvalStatus: "draft",
sendStatus: "not_sent",
responseStatus: "none",
salesStatus: "follow_up_planned",
createdAt: now,
updatedAt: now,
});
return await ctx.db.insert(
"outreachRecords",
buildOutreachRecordsInsertPayload({
leadId: args.leadId,
auditId: args.auditId,
strategy: args.strategy,
phoneScript: args.phoneScript,
emailSubject: args.emailSubject,
emailBody: args.emailBody,
followUpDraft: args.followUpDraft,
now,
}),
);
},
});
@@ -260,15 +319,19 @@ export const upsertFromAuditGeneration = internalMutation({
if (existing.length > 0) {
const current = existing[0]!;
if (current.sendStatus === "sent") {
return await ctx.db.insert("outreachRecords", {
...args,
approvalStatus: "draft",
sendStatus: "not_sent",
responseStatus: "none",
salesStatus: "follow_up_planned",
createdAt: now,
updatedAt: now,
});
return await ctx.db.insert(
"outreachRecords",
buildOutreachRecordsInsertPayload({
leadId: args.leadId,
auditId: args.auditId,
strategy: args.strategy,
phoneScript: args.phoneScript,
emailSubject: args.emailSubject,
emailBody: args.emailBody,
followUpDraft: args.followUpDraft,
now,
}),
);
}
await ctx.db.patch(current._id, {
@@ -289,15 +352,19 @@ export const upsertFromAuditGeneration = internalMutation({
return current._id;
}
return await ctx.db.insert("outreachRecords", {
...args,
approvalStatus: "draft",
sendStatus: "not_sent",
responseStatus: "none",
salesStatus: "follow_up_planned",
createdAt: now,
updatedAt: now,
});
return await ctx.db.insert(
"outreachRecords",
buildOutreachRecordsInsertPayload({
leadId: args.leadId,
auditId: args.auditId,
strategy: args.strategy,
phoneScript: args.phoneScript,
emailSubject: args.emailSubject,
emailBody: args.emailBody,
followUpDraft: args.followUpDraft,
now,
}),
);
},
});
@@ -425,7 +492,7 @@ export const saveReviewDraft = mutation({
if (!outreach) {
throw new Error("Outreach-Datensatz wurde nicht gefunden.");
}
if (outreach.sendStatus === "sent") {
if (outreach.sendStatus === "sent" || outreach.sendStatus === "queued") {
throw new Error("Gesendete Outreach-Datensaetze koennen nicht bearbeitet werden.");
}
@@ -462,6 +529,9 @@ export const approveEmailDraft = mutation({
if (outreach.sendStatus === "sent") {
throw new Error("Gesendete Outreach-Datensaetze koennen nicht freigegeben werden.");
}
if (outreach.sendStatus === "queued") {
throw new Error("Ausstehend freigegebene Outreach-Datensaetze koennen nicht erneut freigegeben werden.");
}
const lead = await ctx.db.get(outreach.leadId);
if (!lead) {
@@ -487,11 +557,16 @@ export const approveEmailDraft = mutation({
approvalStatus: "approved",
updatedAt: now,
});
const sender = process.env.SMTP_FROM?.trim();
if (!sender) {
throw new Error("SMTP-Absender-Adresse fehlt.");
}
return {
id: args.id,
recipient: recipient,
subject: subject,
sender: sender,
auditSlug: audit?.slug ?? null,
approvalStatus: "approved",
updatedAt: now,
@@ -499,6 +574,243 @@ export const approveEmailDraft = mutation({
},
});
export const claimApprovedEmailForSend = internalMutation({
args: {
id: v.id("outreachRecords"),
},
handler: async (ctx, args) => {
await requireOperator(ctx);
const outreach = await ctx.db.get(args.id);
if (!outreach) {
throw new Error("Outreach-Datensatz wurde nicht gefunden.");
}
if (outreach.approvalStatus !== "approved") {
throw new Error("Nur freigegebene Outreachs können versendet werden.");
}
if (outreach.sendStatus === "sent" || outreach.sendStatus === "queued") {
throw new Error("Outreach ist bereits in Versand-Warteschlange oder gesendet.");
}
const lead = await ctx.db.get(outreach.leadId);
if (!lead) {
throw new Error("Lead wurde nicht gefunden.");
}
const recipient = lead.email?.trim();
const subject = outreach.emailSubject?.trim();
const body = outreach.emailBody?.trim();
const sender = process.env.SMTP_FROM?.trim();
if (!recipient) {
throw new Error("Empfaenger-E-Mail fehlt.");
}
if (!subject) {
throw new Error("E-Mail-Betreff fehlt.");
}
if (!body) {
throw new Error("E-Mail-Text fehlt.");
}
if (!sender) {
throw new Error("SMTP-Absender-Adresse fehlt.");
}
const audit = outreach.auditId ? await ctx.db.get(outreach.auditId) : null;
const now = Date.now();
await ctx.db.patch(args.id, {
sendStatus: "queued",
updatedAt: now,
});
return {
outreachId: outreach._id,
id: outreach._id,
leadId: outreach.leadId,
auditId: outreach.auditId,
recipient,
subject,
body,
sender,
auditLink: audit?.slug ? `/audit/${audit.slug}` : null,
};
},
});
const outreachSendAttemptSuccessStatus = "success" as const;
const outreachSendAttemptFailedStatus = "failed" as const;
export const recordEmailSendSuccess = internalMutation({
args: {
id: v.id("outreachRecords"),
recipient: v.string(),
subject: v.string(),
body: v.string(),
sender: v.string(),
auditId: v.optional(v.id("audits")),
auditLink: v.optional(v.union(v.string(), v.null())),
sentAt: v.number(),
smtpMessageId: v.optional(v.string()),
smtpResponse: v.optional(v.string()),
smtpAccepted: v.optional(v.array(v.string())),
smtpRejected: v.optional(v.array(v.string())),
},
handler: async (ctx, args) => {
await requireOperator(ctx);
const outreach = await ctx.db.get(args.id);
if (!outreach) {
throw new Error("Outreach-Datensatz wurde nicht gefunden.");
}
const lead = await ctx.db.get(outreach.leadId);
if (!lead) {
throw new Error("Lead wurde nicht gefunden.");
}
const now = Date.now();
await ctx.db.patch(args.id, {
sendStatus: "sent",
sentAt: args.sentAt,
updatedAt: now,
});
await ctx.db.patch(lead._id, {
contactStatus: "contacted",
updatedAt: now,
});
const attempt: {
outreachId: Id<"outreachRecords">;
leadId: Id<"leads">;
recipient: string;
subject: string;
body: string;
sender: string;
status: typeof outreachSendAttemptSuccessStatus;
sentAt: number;
createdAt: number;
updatedAt: number;
auditId?: Id<"audits">;
auditLink?: string | null;
smtpMessageId?: string;
smtpResponse?: string;
smtpAccepted?: string[];
smtpRejected?: string[];
} = {
outreachId: args.id,
leadId: outreach.leadId,
recipient: args.recipient,
subject: args.subject,
body: args.body,
sender: args.sender,
status: outreachSendAttemptSuccessStatus,
sentAt: args.sentAt,
createdAt: now,
updatedAt: now,
};
if (args.auditId !== undefined) {
attempt.auditId = args.auditId;
}
if (args.auditLink !== undefined) {
attempt.auditLink = args.auditLink;
}
if (args.smtpMessageId !== undefined) {
attempt.smtpMessageId = args.smtpMessageId;
}
if (args.smtpResponse !== undefined) {
attempt.smtpResponse = args.smtpResponse;
}
if (args.smtpAccepted !== undefined) {
attempt.smtpAccepted = args.smtpAccepted;
}
if (args.smtpRejected !== undefined) {
attempt.smtpRejected = args.smtpRejected;
}
await ctx.db.insert("outreachSendAttempts", attempt);
},
});
export const recordEmailSendFailure = internalMutation({
args: {
id: v.id("outreachRecords"),
recipient: v.string(),
subject: v.string(),
body: v.string(),
sender: v.string(),
auditId: v.optional(v.id("audits")),
auditLink: v.optional(v.union(v.string(), v.null())),
errorMessage: v.optional(v.string()),
errorCode: v.optional(v.string()),
errorResponseCode: v.optional(v.number()),
errorResponse: v.optional(v.string()),
},
handler: async (ctx, args) => {
await requireOperator(ctx);
const outreach = await ctx.db.get(args.id);
if (!outreach) {
throw new Error("Outreach-Datensatz wurde nicht gefunden.");
}
const now = Date.now();
await ctx.db.patch(args.id, {
sendStatus: "failed",
updatedAt: now,
});
const attempt: {
outreachId: Id<"outreachRecords">;
leadId: Id<"leads">;
recipient: string;
subject: string;
body: string;
sender: string;
status: typeof outreachSendAttemptFailedStatus;
createdAt: number;
updatedAt: number;
auditId?: Id<"audits">;
auditLink?: string | null;
errorMessage?: string;
errorCode?: string;
errorResponseCode?: number;
errorResponse?: string;
} = {
outreachId: args.id,
leadId: outreach.leadId,
recipient: args.recipient,
subject: args.subject,
body: args.body,
sender: args.sender,
status: outreachSendAttemptFailedStatus,
createdAt: now,
updatedAt: now,
};
if (args.auditId !== undefined) {
attempt.auditId = args.auditId;
}
if (args.auditLink !== undefined) {
attempt.auditLink = args.auditLink;
}
if (args.errorMessage !== undefined) {
attempt.errorMessage = args.errorMessage;
}
if (args.errorCode !== undefined) {
attempt.errorCode = args.errorCode;
}
if (args.errorResponseCode !== undefined) {
attempt.errorResponseCode = args.errorResponseCode;
}
if (args.errorResponse !== undefined) {
attempt.errorResponse = args.errorResponse;
}
await ctx.db.insert("outreachSendAttempts", attempt);
},
});
export const list = query({
args: {
leadId: v.optional(v.id("leads")),