Skip to content

Commit 5757bea

Browse files
ralyodioclaude
andauthored
fix(outreach): stop paying for a fresh draft every tick when a send cannot happen (#332)
Since 2026-09-25 the sender mailbox SMTP login has failed (535 from forwardemail). A failed send logs the draft as dry_run and leaves the prospect "researched", so every 15-minute tick drafted the same 26 people again: ~2,800 model calls and ~$56/day on gpt-5.6-sol, zero emails sent. - checkRecipient() runs before drafting; permanent refusals (machine mailbox, suppressed, unsubscribed, bad address) mark the prospect skipped - reuse the newest logged draft for the prospect+step (7 days) - halt the campaign's tick on a provider failure or a sender failure (bad SMTP creds, unreachable relay), not on one recipient's bounce - OpenAI: reasoning headroom on max_output_tokens and low effort when the caller disabled thinking; 900 tokens at medium came back empty but billed Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent deec8a0 commit 5757bea

4 files changed

Lines changed: 195 additions & 27 deletions

File tree

‎lib/lx/backendAi.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,8 @@ async function generateWithAnthropic<T>(
161161
return parsed;
162162
}
163163

164+
const OPENAI_REASONING_HEADROOM = 2048;
165+
164166
async function generateWithOpenAI<T>(
165167
args: StructuredOutputArgs<T>,
166168
): Promise<T> {
@@ -186,8 +188,13 @@ async function generateWithOpenAI<T>(
186188
"",
187189
"Return JSON only. The final answer must be valid JSON and must not include Markdown.",
188190
].join("\n"),
189-
max_output_tokens: args.maxTokens,
190-
reasoning: { effort: "medium" },
191+
// max_output_tokens counts reasoning as well as the answer, so without
192+
// headroom a reasoning model spends the whole budget thinking and returns
193+
// nothing, billed. The outreach drafts (900 tokens, medium effort) came
194+
// back empty on most calls this way. A caller that turned thinking off for
195+
// Anthropic wants the cheap path here too.
196+
max_output_tokens: args.maxTokens + OPENAI_REASONING_HEADROOM,
197+
reasoning: { effort: args.anthropicEffort === false ? "low" : (args.anthropicEffort ?? "medium") },
191198
text: { format },
192199
store: false,
193200
});

‎lib/outreach/pipeline.ts‎

Lines changed: 74 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -678,7 +678,9 @@ Hard rules, in order of importance:
678678

679679
export type DraftResult =
680680
| { ok: true; subject: string; body: string; evidenceUsed: string[] }
681-
| { ok: false; problems: string[] };
681+
// generationFailed marks a provider failure rather than a bad draft: the
682+
// next prospect would fail the same way, so a caller in a loop should stop.
683+
| { ok: false; problems: string[]; generationFailed?: boolean };
682684

683685
export async function draftEmail(input: {
684686
prospect: ProspectRow;
@@ -745,7 +747,11 @@ export async function draftEmail(input: {
745747
});
746748
output = res.output;
747749
} catch (err) {
748-
return { ok: false, problems: [`generation failed: ${err instanceof Error ? err.message : "unknown"}`] };
750+
return {
751+
ok: false,
752+
problems: [`generation failed: ${err instanceof Error ? err.message : "unknown"}`],
753+
generationFailed: true,
754+
};
749755
}
750756

751757
// The model is the least trustworthy part of this pipeline, so its output
@@ -830,7 +836,11 @@ async function draftCustomEmail(input: {
830836
});
831837
output = res.output;
832838
} catch (err) {
833-
return { ok: false, problems: [`generation failed: ${err instanceof Error ? err.message : "unknown"}`] };
839+
return {
840+
ok: false,
841+
problems: [`generation failed: ${err instanceof Error ? err.message : "unknown"}`],
842+
generationFailed: true,
843+
};
834844
}
835845

836846
// Facts, plus the intro and ask — everything the operator authored counts
@@ -873,26 +883,44 @@ async function draftCustomEmail(input: {
873883

874884
export type SendOutcome =
875885
| { ok: true; dryRun: boolean; to: string; sentToday: number }
876-
| { ok: false; reason: string };
886+
// transport marks a failure of the sender itself (bad SMTP password, host
887+
// down), not of this recipient. Every later send in the tick would fail the
888+
// same way.
889+
| { ok: false; reason: string; transport?: boolean };
877890

878891
/**
879-
* The only function in the codebase that puts a cold email on the wire.
880-
* Everything protective lives here rather than in the callers, so a new
881-
* caller cannot forget it.
892+
* A send error that says the sender is broken rather than this recipient:
893+
* rejected SMTP credentials, or a relay that cannot be reached at all. A 550
894+
* for one unknown user says nothing about the next address, so it is not one.
882895
*/
883-
export async function sendProspectEmail(input: {
896+
export function isSenderFailure(error: string): boolean {
897+
return /\b53[045]\b|invalid login|EAUTH|ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOTFOUND|EHOSTUNREACH|not set\b|not configured/i.test(
898+
error,
899+
);
900+
}
901+
902+
export type RecipientCheck =
903+
| { ok: true; to: string; sentToday: number }
904+
// permanent means no later tick will change the answer, so the prospect can
905+
// leave the queue instead of being drafted for again every fifteen minutes.
906+
| { ok: false; reason: string; permanent: boolean };
907+
908+
/**
909+
* Whether this prospect may be emailed at this step, decided without a draft.
910+
*
911+
* Runs before drafting as well as inside sendProspectEmail, because a draft is
912+
* a paid model call: checking only at the wire paid for a message to every
913+
* noreply@ address on every tick, forever.
914+
*/
915+
export async function checkRecipient(input: {
884916
userId: string;
885917
prospect: ProspectRow;
886-
subject: string;
887-
body: string;
888918
step: OutreachStep;
889919
campaign: string;
890920
to?: string | null;
891-
replyTo?: string | null;
892-
dryRun: boolean;
893-
}): Promise<SendOutcome> {
921+
}): Promise<RecipientCheck> {
894922
const to = normalizeEmail(input.to ?? input.prospect.contact_email ?? "");
895-
if (!looksLikeEmail(to)) return { ok: false, reason: "no usable recipient address" };
923+
if (!looksLikeEmail(to)) return { ok: false, reason: "no usable recipient address", permanent: true };
896924

897925
const sb = serviceClient();
898926
const [suppressed, unsubAt, sentToday, prior] = await Promise.all([
@@ -919,7 +947,37 @@ export async function sendProspectEmail(input: {
919947
sentToday,
920948
dailyCap: env.outreachDailyCap,
921949
});
922-
if (reason) return { ok: false, reason: explainSuppression(reason) };
950+
if (reason) {
951+
return {
952+
ok: false,
953+
reason: explainSuppression(reason),
954+
permanent: reason !== "daily-cap" && reason !== "already-contacted",
955+
};
956+
}
957+
return { ok: true, to, sentToday };
958+
}
959+
960+
/**
961+
* The only function in the codebase that puts a cold email on the wire.
962+
* Everything protective lives here rather than in the callers, so a new
963+
* caller cannot forget it.
964+
*/
965+
export async function sendProspectEmail(input: {
966+
userId: string;
967+
prospect: ProspectRow;
968+
subject: string;
969+
body: string;
970+
step: OutreachStep;
971+
campaign: string;
972+
to?: string | null;
973+
replyTo?: string | null;
974+
dryRun: boolean;
975+
}): Promise<SendOutcome> {
976+
const check = await checkRecipient(input);
977+
if (!check.ok) return { ok: false, reason: check.reason };
978+
const { to, sentToday } = check;
979+
980+
const sb = serviceClient();
923981

924982
const facts = factsOf(input.prospect);
925983
const claims = unsupportedClaims(input.body, facts);
@@ -994,7 +1052,7 @@ export async function sendProspectEmail(input: {
9941052
track_token: failed ? null : trackToken,
9951053
});
9961054

997-
if (failed) return { ok: false, reason: failed };
1055+
if (failed) return { ok: false, reason: failed, transport: isSenderFailure(failed) };
9981056

9991057
if (!input.dryRun) {
10001058
await sb

‎lib/outreach/runner.ts‎

Lines changed: 84 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { discoverProspects } from "./discover";
1515
import { isEmailSuppressed, sendsInLast24h } from "./suppress";
1616
import {
1717
PROSPECT_COLUMNS,
18+
checkRecipient,
1819
draftEmail,
1920
isWeakEnough,
2021
latestAuditForHost,
@@ -195,6 +196,10 @@ export async function runEmailCampaignTick(campaign: CampaignRow): Promise<TickR
195196
);
196197

197198
// ---- 1. Follow-ups. Finishing a started conversation beats starting one.
199+
// Set when a draft or a send fails for a reason that is not the prospect's.
200+
// Both loops below stop on it: the rest of the tick would pay for the same
201+
// failure again.
202+
let halted = false;
198203
if (campaign.follow_ups) {
199204
const due = prospects
200205
.filter((p) => p.status === "contacted" && p.last_sent_at && p.last_step >= 1 && p.last_step < 3)
@@ -212,6 +217,10 @@ export async function runEmailCampaignTick(campaign: CampaignRow): Promise<TickR
212217
const outcome = await draftAndSend(campaign, p, step);
213218
applyOutcome(result, outcome);
214219
if (outcome.kind === "sent") sendBudget -= 1;
220+
if ("halt" in outcome && outcome.halt) {
221+
halted = true;
222+
break;
223+
}
215224
}
216225
}
217226

@@ -233,7 +242,7 @@ export async function runEmailCampaignTick(campaign: CampaignRow): Promise<TickR
233242
)
234243
.slice(0, MAX_SEND_PER_TICK);
235244

236-
for (const p of ready) {
245+
for (const p of halted ? [] : ready) {
237246
if (sendBudget <= 0) {
238247
result.skipped.push(`${p.target_key}: daily send budget exhausted`);
239248
break;
@@ -242,6 +251,7 @@ export async function runEmailCampaignTick(campaign: CampaignRow): Promise<TickR
242251
const outcome = await draftAndSend(campaign, p, 1);
243252
applyOutcome(result, outcome);
244253
if (outcome.kind === "sent") sendBudget -= 1;
254+
if ("halt" in outcome && outcome.halt) break;
245255
}
246256

247257
// Prospects that scored too well are not a lie we're willing to tell.
@@ -528,11 +538,48 @@ export async function runEmailCampaignTick(campaign: CampaignRow): Promise<TickR
528538
return result;
529539
}
530540

541+
// halt: the failure was the provider's or the sender's, not this prospect's,
542+
// so every later prospect in the tick would fail identically, and a failed
543+
// draft is still a billed model call.
531544
type Outcome =
532545
| { kind: "sent"; host: string }
533546
| { kind: "dry"; host: string }
534-
| { kind: "skipped"; host: string; reason: string }
535-
| { kind: "error"; host: string; reason: string };
547+
| { kind: "skipped"; host: string; reason: string; halt?: boolean }
548+
| { kind: "error"; host: string; reason: string; halt?: boolean };
549+
550+
// How long a logged draft stays good enough to send instead of paying for a
551+
// new one. Long enough to ride out a broken mailbox over a weekend; short
552+
// enough that an edited campaign pitch reaches the queue within the week.
553+
const DRAFT_REUSE_DAYS = 7;
554+
555+
/**
556+
* The newest draft already written for this prospect at this step.
557+
*
558+
* A send that fails, or a campaign with auto_send off, logs its draft and
559+
* leaves the prospect where it was, so the next tick picks it up again.
560+
* Drafting afresh each time is what turned one broken SMTP password into
561+
* ~2,800 model calls a day for twenty-six people.
562+
*/
563+
async function reusableDraft(
564+
campaign: CampaignRow,
565+
prospect: ProspectRow,
566+
step: OutreachStep,
567+
): Promise<{ ok: true; subject: string; body: string } | null> {
568+
const since = new Date(Date.now() - DRAFT_REUSE_DAYS * 24 * 3600 * 1000).toISOString();
569+
const { data } = await serviceClient()
570+
.from("outreach_sends")
571+
.select("subject, body")
572+
.eq("prospect_id", prospect.id)
573+
.eq("campaign", campaign.name)
574+
.eq("step", step)
575+
.gte("sent_at", since)
576+
.order("sent_at", { ascending: false })
577+
.limit(1)
578+
.maybeSingle();
579+
const row = data as { subject: string | null; body: string | null } | null;
580+
if (!row?.subject?.trim() || !row.body?.trim()) return null;
581+
return { ok: true, subject: row.subject, body: row.body };
582+
}
536583

537584

538585
/**
@@ -557,15 +604,41 @@ async function draftAndSend(
557604
prospect: ProspectRow,
558605
step: OutreachStep,
559606
): Promise<Outcome> {
560-
const draft = await draftEmail({
607+
// Who it is going to is settled before anything is paid for. A machine
608+
// mailbox or an unsubscribed address will never become sendable, so it
609+
// leaves the queue rather than being drafted for on every tick.
610+
const recipient = await checkRecipient({
611+
userId: campaign.owner_id,
561612
prospect,
562613
step,
563-
angle: campaign.angle,
564-
sender: campaign.sender_name,
565-
pitch: campaignPitch(campaign),
614+
campaign: campaign.name,
566615
});
616+
if (!recipient.ok) {
617+
if (recipient.permanent) {
618+
await serviceClient()
619+
.from("outreach_prospects")
620+
.update({ status: "skipped", notes: recipient.reason })
621+
.eq("id", prospect.id);
622+
}
623+
return { kind: "skipped", host: prospect.target_key, reason: recipient.reason };
624+
}
625+
626+
const draft =
627+
(await reusableDraft(campaign, prospect, step)) ??
628+
(await draftEmail({
629+
prospect,
630+
step,
631+
angle: campaign.angle,
632+
sender: campaign.sender_name,
633+
pitch: campaignPitch(campaign),
634+
}));
567635
if (!draft.ok) {
568-
return { kind: "error", host: prospect.target_key, reason: draft.problems.join("; ") };
636+
return {
637+
kind: "error",
638+
host: prospect.target_key,
639+
reason: draft.problems.join("; "),
640+
halt: draft.generationFailed,
641+
};
569642
}
570643

571644
// auto_send off means the campaign builds the whole funnel and stops at the
@@ -582,7 +655,9 @@ async function draftAndSend(
582655
dryRun: !campaign.auto_send,
583656
});
584657

585-
if (!outcome.ok) return { kind: "skipped", host: prospect.target_key, reason: outcome.reason };
658+
if (!outcome.ok) {
659+
return { kind: "skipped", host: prospect.target_key, reason: outcome.reason, halt: outcome.transport };
660+
}
586661
return outcome.dryRun
587662
? { kind: "dry", host: prospect.target_key }
588663
: { kind: "sent", host: prospect.target_key };
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, it, expect } from "vitest";
2+
import { isSenderFailure } from "@/lib/outreach/pipeline";
3+
4+
// A sender failure halts the campaign tick, so misreading one recipient's
5+
// bounce as a dead sender would stop a healthy campaign, and missing a dead
6+
// sender pays for a fresh draft to every prospect in the queue.
7+
describe("isSenderFailure", () => {
8+
it("treats rejected SMTP credentials as the sender's failure", () => {
9+
// Verbatim from forwardemail.net, the failure that ran from 2026-09-25.
10+
expect(
11+
isSenderFailure(
12+
'Invalid login: 535 5.7.8 Invalid username or password, please try again or go to https://forwardemail.net/my-account/domains/profullstack.com/aliases and click "Generate Password"',
13+
),
14+
).toBe(true);
15+
});
16+
17+
it("treats an unreachable relay as the sender's failure", () => {
18+
expect(isSenderFailure("connect ECONNREFUSED 127.0.0.1:465")).toBe(true);
19+
expect(isSenderFailure("getaddrinfo ENOTFOUND smtp.example.com")).toBe(true);
20+
expect(isSenderFailure("RESEND_API_KEY not set")).toBe(true);
21+
});
22+
23+
it("does not halt on one recipient's rejection", () => {
24+
expect(isSenderFailure("550 5.1.1 <nobody@example.com>: Recipient address rejected: User unknown")).toBe(false);
25+
expect(isSenderFailure("552 5.2.2 Mailbox full")).toBe(false);
26+
expect(isSenderFailure("send failed")).toBe(false);
27+
});
28+
});

0 commit comments

Comments
 (0)