diff --git a/lib/lx/backendAi.ts b/lib/lx/backendAi.ts index 09d48442..b78f4369 100644 --- a/lib/lx/backendAi.ts +++ b/lib/lx/backendAi.ts @@ -161,6 +161,8 @@ async function generateWithAnthropic( return parsed; } +const OPENAI_REASONING_HEADROOM = 2048; + async function generateWithOpenAI( args: StructuredOutputArgs, ): Promise { @@ -186,8 +188,13 @@ async function generateWithOpenAI( "", "Return JSON only. The final answer must be valid JSON and must not include Markdown.", ].join("\n"), - max_output_tokens: args.maxTokens, - reasoning: { effort: "medium" }, + // max_output_tokens counts reasoning as well as the answer, so without + // headroom a reasoning model spends the whole budget thinking and returns + // nothing, billed. The outreach drafts (900 tokens, medium effort) came + // back empty on most calls this way. A caller that turned thinking off for + // Anthropic wants the cheap path here too. + max_output_tokens: args.maxTokens + OPENAI_REASONING_HEADROOM, + reasoning: { effort: args.anthropicEffort === false ? "low" : (args.anthropicEffort ?? "medium") }, text: { format }, store: false, }); diff --git a/lib/outreach/pipeline.ts b/lib/outreach/pipeline.ts index dd6cd8cb..988f8403 100644 --- a/lib/outreach/pipeline.ts +++ b/lib/outreach/pipeline.ts @@ -678,7 +678,9 @@ Hard rules, in order of importance: export type DraftResult = | { ok: true; subject: string; body: string; evidenceUsed: string[] } - | { ok: false; problems: string[] }; + // generationFailed marks a provider failure rather than a bad draft: the + // next prospect would fail the same way, so a caller in a loop should stop. + | { ok: false; problems: string[]; generationFailed?: boolean }; export async function draftEmail(input: { prospect: ProspectRow; @@ -745,7 +747,11 @@ export async function draftEmail(input: { }); output = res.output; } catch (err) { - return { ok: false, problems: [`generation failed: ${err instanceof Error ? err.message : "unknown"}`] }; + return { + ok: false, + problems: [`generation failed: ${err instanceof Error ? err.message : "unknown"}`], + generationFailed: true, + }; } // The model is the least trustworthy part of this pipeline, so its output @@ -830,7 +836,11 @@ async function draftCustomEmail(input: { }); output = res.output; } catch (err) { - return { ok: false, problems: [`generation failed: ${err instanceof Error ? err.message : "unknown"}`] }; + return { + ok: false, + problems: [`generation failed: ${err instanceof Error ? err.message : "unknown"}`], + generationFailed: true, + }; } // Facts, plus the intro and ask — everything the operator authored counts @@ -873,26 +883,44 @@ async function draftCustomEmail(input: { export type SendOutcome = | { ok: true; dryRun: boolean; to: string; sentToday: number } - | { ok: false; reason: string }; + // transport marks a failure of the sender itself (bad SMTP password, host + // down), not of this recipient. Every later send in the tick would fail the + // same way. + | { ok: false; reason: string; transport?: boolean }; /** - * The only function in the codebase that puts a cold email on the wire. - * Everything protective lives here rather than in the callers, so a new - * caller cannot forget it. + * A send error that says the sender is broken rather than this recipient: + * rejected SMTP credentials, or a relay that cannot be reached at all. A 550 + * for one unknown user says nothing about the next address, so it is not one. */ -export async function sendProspectEmail(input: { +export function isSenderFailure(error: string): boolean { + return /\b53[045]\b|invalid login|EAUTH|ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOTFOUND|EHOSTUNREACH|not set\b|not configured/i.test( + error, + ); +} + +export type RecipientCheck = + | { ok: true; to: string; sentToday: number } + // permanent means no later tick will change the answer, so the prospect can + // leave the queue instead of being drafted for again every fifteen minutes. + | { ok: false; reason: string; permanent: boolean }; + +/** + * Whether this prospect may be emailed at this step, decided without a draft. + * + * Runs before drafting as well as inside sendProspectEmail, because a draft is + * a paid model call: checking only at the wire paid for a message to every + * noreply@ address on every tick, forever. + */ +export async function checkRecipient(input: { userId: string; prospect: ProspectRow; - subject: string; - body: string; step: OutreachStep; campaign: string; to?: string | null; - replyTo?: string | null; - dryRun: boolean; -}): Promise { +}): Promise { const to = normalizeEmail(input.to ?? input.prospect.contact_email ?? ""); - if (!looksLikeEmail(to)) return { ok: false, reason: "no usable recipient address" }; + if (!looksLikeEmail(to)) return { ok: false, reason: "no usable recipient address", permanent: true }; const sb = serviceClient(); const [suppressed, unsubAt, sentToday, prior] = await Promise.all([ @@ -919,7 +947,37 @@ export async function sendProspectEmail(input: { sentToday, dailyCap: env.outreachDailyCap, }); - if (reason) return { ok: false, reason: explainSuppression(reason) }; + if (reason) { + return { + ok: false, + reason: explainSuppression(reason), + permanent: reason !== "daily-cap" && reason !== "already-contacted", + }; + } + return { ok: true, to, sentToday }; +} + +/** + * The only function in the codebase that puts a cold email on the wire. + * Everything protective lives here rather than in the callers, so a new + * caller cannot forget it. + */ +export async function sendProspectEmail(input: { + userId: string; + prospect: ProspectRow; + subject: string; + body: string; + step: OutreachStep; + campaign: string; + to?: string | null; + replyTo?: string | null; + dryRun: boolean; +}): Promise { + const check = await checkRecipient(input); + if (!check.ok) return { ok: false, reason: check.reason }; + const { to, sentToday } = check; + + const sb = serviceClient(); const facts = factsOf(input.prospect); const claims = unsupportedClaims(input.body, facts); @@ -994,7 +1052,7 @@ export async function sendProspectEmail(input: { track_token: failed ? null : trackToken, }); - if (failed) return { ok: false, reason: failed }; + if (failed) return { ok: false, reason: failed, transport: isSenderFailure(failed) }; if (!input.dryRun) { await sb diff --git a/lib/outreach/runner.ts b/lib/outreach/runner.ts index f8637b99..6b68b591 100644 --- a/lib/outreach/runner.ts +++ b/lib/outreach/runner.ts @@ -15,6 +15,7 @@ import { discoverProspects } from "./discover"; import { isEmailSuppressed, sendsInLast24h } from "./suppress"; import { PROSPECT_COLUMNS, + checkRecipient, draftEmail, isWeakEnough, latestAuditForHost, @@ -195,6 +196,10 @@ export async function runEmailCampaignTick(campaign: CampaignRow): Promise 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 { + const since = new Date(Date.now() - DRAFT_REUSE_DAYS * 24 * 3600 * 1000).toISOString(); + const { data } = await serviceClient() + .from("outreach_sends") + .select("subject, body") + .eq("prospect_id", prospect.id) + .eq("campaign", campaign.name) + .eq("step", step) + .gte("sent_at", since) + .order("sent_at", { ascending: false }) + .limit(1) + .maybeSingle(); + const row = data as { subject: string | null; body: string | null } | null; + if (!row?.subject?.trim() || !row.body?.trim()) return null; + return { ok: true, subject: row.subject, body: row.body }; +} /** @@ -557,15 +604,41 @@ async function draftAndSend( prospect: ProspectRow, step: OutreachStep, ): Promise { - const draft = await draftEmail({ + // Who it is going to is settled before anything is paid for. A machine + // mailbox or an unsubscribed address will never become sendable, so it + // leaves the queue rather than being drafted for on every tick. + const recipient = await checkRecipient({ + userId: campaign.owner_id, prospect, step, - angle: campaign.angle, - sender: campaign.sender_name, - pitch: campaignPitch(campaign), + campaign: campaign.name, }); + if (!recipient.ok) { + if (recipient.permanent) { + await serviceClient() + .from("outreach_prospects") + .update({ status: "skipped", notes: recipient.reason }) + .eq("id", prospect.id); + } + return { kind: "skipped", host: prospect.target_key, reason: recipient.reason }; + } + + const draft = + (await reusableDraft(campaign, prospect, step)) ?? + (await draftEmail({ + prospect, + step, + angle: campaign.angle, + sender: campaign.sender_name, + pitch: campaignPitch(campaign), + })); if (!draft.ok) { - return { kind: "error", host: prospect.target_key, reason: draft.problems.join("; ") }; + return { + kind: "error", + host: prospect.target_key, + reason: draft.problems.join("; "), + halt: draft.generationFailed, + }; } // auto_send off means the campaign builds the whole funnel and stops at the @@ -582,7 +655,9 @@ async function draftAndSend( dryRun: !campaign.auto_send, }); - if (!outcome.ok) return { kind: "skipped", host: prospect.target_key, reason: outcome.reason }; + if (!outcome.ok) { + return { kind: "skipped", host: prospect.target_key, reason: outcome.reason, halt: outcome.transport }; + } return outcome.dryRun ? { kind: "dry", host: prospect.target_key } : { kind: "sent", host: prospect.target_key }; diff --git a/tests/outreach-sender-failure.test.ts b/tests/outreach-sender-failure.test.ts new file mode 100644 index 00000000..1a56fc77 --- /dev/null +++ b/tests/outreach-sender-failure.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; +import { isSenderFailure } from "@/lib/outreach/pipeline"; + +// A sender failure halts the campaign tick, so misreading one recipient's +// bounce as a dead sender would stop a healthy campaign, and missing a dead +// sender pays for a fresh draft to every prospect in the queue. +describe("isSenderFailure", () => { + it("treats rejected SMTP credentials as the sender's failure", () => { + // Verbatim from forwardemail.net, the failure that ran from 2026-09-25. + expect( + isSenderFailure( + '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"', + ), + ).toBe(true); + }); + + it("treats an unreachable relay as the sender's failure", () => { + expect(isSenderFailure("connect ECONNREFUSED 127.0.0.1:465")).toBe(true); + expect(isSenderFailure("getaddrinfo ENOTFOUND smtp.example.com")).toBe(true); + expect(isSenderFailure("RESEND_API_KEY not set")).toBe(true); + }); + + it("does not halt on one recipient's rejection", () => { + expect(isSenderFailure("550 5.1.1 : Recipient address rejected: User unknown")).toBe(false); + expect(isSenderFailure("552 5.2.2 Mailbox full")).toBe(false); + expect(isSenderFailure("send failed")).toBe(false); + }); +});