@oxygen-agent/cli 1.1010.650 → 1.1010.905
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/auto-update.d.ts +129 -0
- package/dist/auto-update.js +392 -0
- package/dist/command-manifest.js +15 -1
- package/dist/credentials.d.ts +2 -0
- package/dist/credentials.js +6 -3
- package/dist/functions-commands.js +1 -1
- package/dist/http-client.js +28 -4
- package/dist/inbox-needs-reply-notice.d.ts +12 -0
- package/dist/inbox-needs-reply-notice.js +51 -0
- package/dist/index.js +756 -177
- package/dist/run-wait.d.ts +3 -1
- package/dist/run-wait.js +19 -5
- package/dist/skills.js +48 -22
- package/dist/streamed-file-import.d.ts +58 -0
- package/dist/streamed-file-import.js +115 -0
- package/dist/update.d.ts +29 -0
- package/dist/update.js +62 -16
- package/dist/workflow-plan-limit-notices.d.ts +8 -0
- package/dist/workflow-plan-limit-notices.js +28 -0
- package/node_modules/@oxygen/cli-ugc/dist/commands.js +3 -3
- package/node_modules/@oxygen/shared/dist/billing-anchors.d.ts +50 -2
- package/node_modules/@oxygen/shared/dist/billing-anchors.js +94 -2
- package/node_modules/@oxygen/shared/dist/billing.d.ts +247 -37
- package/node_modules/@oxygen/shared/dist/billing.js +418 -45
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +66 -6
- package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +6 -6
- package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +6 -6
- package/node_modules/@oxygen/shared/dist/cost-estimate-view.d.ts +50 -0
- package/node_modules/@oxygen/shared/dist/cost-estimate-view.js +90 -0
- package/node_modules/@oxygen/shared/dist/cost-estimate.d.ts +167 -0
- package/node_modules/@oxygen/shared/dist/cost-estimate.js +361 -0
- package/node_modules/@oxygen/shared/dist/credit-gate.d.ts +26 -0
- package/node_modules/@oxygen/shared/dist/credit-gate.js +65 -0
- package/node_modules/@oxygen/shared/dist/email-deliverability-policy.d.ts +51 -0
- package/node_modules/@oxygen/shared/dist/email-deliverability-policy.js +101 -0
- package/node_modules/@oxygen/shared/dist/email-hard-bounce.d.ts +27 -0
- package/node_modules/@oxygen/shared/dist/email-hard-bounce.js +27 -0
- package/node_modules/@oxygen/shared/dist/error-redaction.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/error-redaction.js +1 -1
- package/node_modules/@oxygen/shared/dist/feature-gates.d.ts +10 -1
- package/node_modules/@oxygen/shared/dist/feature-gates.js +12 -1
- package/node_modules/@oxygen/shared/dist/file-import.d.ts +13 -1
- package/node_modules/@oxygen/shared/dist/file-import.js +33 -6
- package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +73 -3
- package/node_modules/@oxygen/shared/dist/hosted-ai.js +246 -24
- package/node_modules/@oxygen/shared/dist/import-limits.d.ts +25 -1
- package/node_modules/@oxygen/shared/dist/import-limits.js +35 -2
- package/node_modules/@oxygen/shared/dist/index.d.ts +4 -23
- package/node_modules/@oxygen/shared/dist/index.js +4 -43
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +114 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +150 -0
- package/node_modules/@oxygen/shared/dist/object-storage.d.ts +9 -0
- package/node_modules/@oxygen/shared/dist/object-storage.js +17 -0
- package/node_modules/@oxygen/shared/dist/operational-telemetry.d.ts +41 -0
- package/node_modules/@oxygen/shared/dist/operational-telemetry.js +55 -0
- package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +19 -2
- package/node_modules/@oxygen/shared/dist/plan-band.d.ts +234 -0
- package/node_modules/@oxygen/shared/dist/plan-band.js +312 -0
- package/node_modules/@oxygen/shared/dist/plan-capabilities.d.ts +77 -7
- package/node_modules/@oxygen/shared/dist/plan-capabilities.js +87 -7
- package/node_modules/@oxygen/shared/dist/plan-limits-view.d.ts +219 -0
- package/node_modules/@oxygen/shared/dist/plan-limits-view.js +330 -0
- package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +335 -126
- package/node_modules/@oxygen/shared/dist/plan-limits.js +277 -86
- package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +158 -49
- package/node_modules/@oxygen/shared/dist/pricing-sheet.js +139 -41
- package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +42 -23
- package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +56 -37
- package/node_modules/@oxygen/shared/dist/process-resource.d.ts +4 -0
- package/node_modules/@oxygen/shared/dist/process-resource.js +25 -0
- package/node_modules/@oxygen/shared/dist/provider-http-error.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/provider-http-error.js +27 -0
- package/node_modules/@oxygen/shared/dist/repricing.d.ts +257 -0
- package/node_modules/@oxygen/shared/dist/repricing.js +721 -0
- package/node_modules/@oxygen/shared/dist/semver.d.ts +21 -0
- package/node_modules/@oxygen/shared/dist/semver.js +41 -0
- package/node_modules/@oxygen/shared/dist/sending-limits.d.ts +30 -0
- package/node_modules/@oxygen/shared/dist/sending-limits.js +43 -0
- package/node_modules/@oxygen/shared/dist/sending-seats.d.ts +18 -15
- package/node_modules/@oxygen/shared/dist/sending-seats.js +22 -17
- package/node_modules/@oxygen/shared/dist/sequence-failures.js +4 -1
- package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +57 -8
- package/node_modules/@oxygen/shared/dist/spend-safety.js +64 -11
- package/node_modules/@oxygen/shared/dist/stripe-price-catalog.d.ts +33 -1
- package/node_modules/@oxygen/shared/dist/stripe-price-catalog.js +71 -1
- package/node_modules/@oxygen/shared/dist/table-capacity.d.ts +68 -10
- package/node_modules/@oxygen/shared/dist/table-capacity.js +85 -4
- package/node_modules/@oxygen/shared/dist/telemetry-export-observer.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/telemetry-export-observer.js +13 -5
- package/node_modules/@oxygen/shared/dist/telemetry-resource.d.ts +40 -0
- package/node_modules/@oxygen/shared/dist/telemetry-resource.js +35 -0
- package/node_modules/@oxygen/shared/dist/telemetry.d.ts +9 -0
- package/node_modules/@oxygen/shared/dist/telemetry.js +41 -2
- package/node_modules/@oxygen/shared/dist/trace-context.d.ts +29 -0
- package/node_modules/@oxygen/shared/dist/trace-context.js +88 -0
- package/node_modules/@oxygen/shared/dist/ugc.d.ts +15 -0
- package/node_modules/@oxygen/shared/dist/ugc.js +29 -0
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -3
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +14 -27
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +5 -0
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +5 -0
- package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +3 -3
- package/node_modules/@oxygen/workflows/dist/graph/types.d.ts +15 -1
- package/node_modules/@oxygen/workflows/dist/graph/types.js +15 -1
- package/node_modules/@oxygen/workflows/dist/index.d.ts +45 -0
- package/node_modules/@oxygen/workflows/dist/index.js +152 -2
- package/node_modules/@oxygen/workflows/dist/usage-estimate.d.ts +10 -1
- package/node_modules/@oxygen/workflows/dist/usage-estimate.js +33 -29
- package/package.json +1 -1
- package/node_modules/@oxygen/shared/dist/email-warmup-readiness.d.ts +0 -64
- package/node_modules/@oxygen/shared/dist/email-warmup-readiness.js +0 -90
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scenario cost estimator behind `POST /api/cli/billing/estimate`
|
|
3
|
+
* (`oxygen billing estimate`, MCP `oxygen_billing_estimate`, Settings → Plan
|
|
4
|
+
* limits → Estimate a month). OXP-43.33.
|
|
5
|
+
*
|
|
6
|
+
* A founder asks "2,000 leads a month, work email on each, a 3-step email and
|
|
7
|
+
* LinkedIn sequence from 2 mailboxes and 1 LinkedIn account: what does that
|
|
8
|
+
* cost, and does anything run out?". Before this, an agent answered it by
|
|
9
|
+
* stitching six reads together by hand (blind baseline 2026-09-26, finding 9).
|
|
10
|
+
*
|
|
11
|
+
* Pure: the route resolves every price from the live pricing book, the
|
|
12
|
+
* enrichment catalog and the sending limits the send path enforces, and hands
|
|
13
|
+
* them in as a price sheet. Nothing here knows a price, so the estimate can
|
|
14
|
+
* never quote a number the product does not charge.
|
|
15
|
+
*
|
|
16
|
+
* Three things are kept apart on purpose, because they are paid differently:
|
|
17
|
+
* - credits that recur every month (sender reservations, enrichment), which
|
|
18
|
+
* size the plan;
|
|
19
|
+
* - dollars that recur on a card (sending seats while seats are sold), which a
|
|
20
|
+
* plan's credits never pay for;
|
|
21
|
+
* - sending itself, which is free and is bounded by per-account limits, not by
|
|
22
|
+
* money.
|
|
23
|
+
*/
|
|
24
|
+
export const COST_ESTIMATE_BOUNDS = {
|
|
25
|
+
maxLeadsPerMonth: 10_000_000,
|
|
26
|
+
maxStepsPerChannel: 20,
|
|
27
|
+
maxSendersPerKind: 1_000,
|
|
28
|
+
maxEnrichments: 12,
|
|
29
|
+
};
|
|
30
|
+
function round2(value) {
|
|
31
|
+
return Math.round((value + Number.EPSILON) * 100) / 100;
|
|
32
|
+
}
|
|
33
|
+
function whole(value) {
|
|
34
|
+
return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
|
35
|
+
}
|
|
36
|
+
function formatInt(value) {
|
|
37
|
+
return Math.round(value).toLocaleString("en-US");
|
|
38
|
+
}
|
|
39
|
+
function formatUsd(value) {
|
|
40
|
+
return `$${value.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
41
|
+
}
|
|
42
|
+
function senderLine(input) {
|
|
43
|
+
const { price, quantity } = input;
|
|
44
|
+
if (price.billedAs === "usd") {
|
|
45
|
+
const usd = round2((quantity * price.usdCentsPerMonth) / 100);
|
|
46
|
+
return {
|
|
47
|
+
key: input.key,
|
|
48
|
+
group: "senders",
|
|
49
|
+
label: input.label,
|
|
50
|
+
quantity,
|
|
51
|
+
unit: input.unit,
|
|
52
|
+
billed_as: "usd",
|
|
53
|
+
unit_credits: 0,
|
|
54
|
+
worst_case_unit_credits: 0,
|
|
55
|
+
credits_per_month: 0,
|
|
56
|
+
worst_case_credits_per_month: 0,
|
|
57
|
+
usd_per_month: usd,
|
|
58
|
+
note: `${formatUsd(price.usdCentsPerMonth / 100)} per ${input.unit} a month, billed in dollars as a sending seat; a plan's credits do not pay for it. ${input.note}`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const unit = price.creditsPerMonth;
|
|
62
|
+
const credits = unit === null ? null : round2(quantity * unit);
|
|
63
|
+
const scheduled = price.scheduledCreditsPerMonth;
|
|
64
|
+
return {
|
|
65
|
+
key: input.key,
|
|
66
|
+
group: "senders",
|
|
67
|
+
label: input.label,
|
|
68
|
+
quantity,
|
|
69
|
+
unit: input.unit,
|
|
70
|
+
billed_as: "credits",
|
|
71
|
+
unit_credits: unit,
|
|
72
|
+
worst_case_unit_credits: unit,
|
|
73
|
+
credits_per_month: credits,
|
|
74
|
+
worst_case_credits_per_month: credits,
|
|
75
|
+
usd_per_month: credits === null ? null : round2(credits / input.creditsPerUsd),
|
|
76
|
+
...(scheduled !== undefined && scheduled !== unit ? { scheduled_unit_credits: scheduled } : {}),
|
|
77
|
+
note: unit === null
|
|
78
|
+
? `No monthly price is in force for this ${input.unit} yet, so nothing is reserved. ${input.note}`
|
|
79
|
+
: `${formatInt(unit)} credits per ${input.unit} a month, reserved from your balance on the day it connects. ${input.note}`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function planOutcome(rung, credits, usdSeats, sheet) {
|
|
83
|
+
const shortfall = Math.max(0, Math.ceil(credits - rung.monthlyCredits));
|
|
84
|
+
// The amount `billing topup` actually sells: whole 100-credit steps, never
|
|
85
|
+
// below the smallest pack. Above 100,000 it takes more than one checkout.
|
|
86
|
+
const topupCredits = shortfall === 0
|
|
87
|
+
? 0
|
|
88
|
+
: Math.max(sheet.topupMinCredits, Math.ceil(shortfall / sheet.topupStepCredits) * sheet.topupStepCredits);
|
|
89
|
+
const topupUsd = round2((topupCredits / 100) * (sheet.topupUsdCentsPer100 / 100));
|
|
90
|
+
const price = rung.monthlyPriceCents === null ? null : rung.monthlyPriceCents / 100;
|
|
91
|
+
return {
|
|
92
|
+
plan_key: rung.planKey,
|
|
93
|
+
label: rung.label,
|
|
94
|
+
monthly_price_usd: price,
|
|
95
|
+
monthly_credits: rung.monthlyCredits,
|
|
96
|
+
covered: shortfall === 0,
|
|
97
|
+
shortfall_credits: shortfall,
|
|
98
|
+
topup_credits: topupCredits,
|
|
99
|
+
topup_usd: topupUsd,
|
|
100
|
+
monthly_usd: price === null ? null : round2(price + topupUsd + usdSeats),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/** The cheapest monthly bill: plan plus top-ups, the smallest plan that covers winning a tie. */
|
|
104
|
+
function cheapestRung(credits, usdSeats, sheet) {
|
|
105
|
+
let best = null;
|
|
106
|
+
for (const rung of sheet.rungs) {
|
|
107
|
+
if (rung.monthlyPriceCents === null)
|
|
108
|
+
continue;
|
|
109
|
+
const outcome = planOutcome(rung, credits, usdSeats, sheet);
|
|
110
|
+
if (best === null || (outcome.monthly_usd ?? Infinity) < (best.monthly_usd ?? Infinity))
|
|
111
|
+
best = outcome;
|
|
112
|
+
}
|
|
113
|
+
return best;
|
|
114
|
+
}
|
|
115
|
+
function rampFirstMonthCapacity(sheet) {
|
|
116
|
+
const { emailRamp, emailPerMailboxPerDay, sendingDaysPerMonth } = sheet.sending;
|
|
117
|
+
let total = 0;
|
|
118
|
+
for (let day = 1; day <= sendingDaysPerMonth; day += 1) {
|
|
119
|
+
const tier = emailRamp.find((step) => day <= step.throughDay);
|
|
120
|
+
total += tier ? Math.min(tier.perDay, emailPerMailboxPerDay) : emailPerMailboxPerDay;
|
|
121
|
+
}
|
|
122
|
+
return total;
|
|
123
|
+
}
|
|
124
|
+
function limitRow(input) {
|
|
125
|
+
const capacity = input.senders * input.perSender;
|
|
126
|
+
const sendersNeeded = input.needed === 0 || input.perSender <= 0 ? 0 : Math.ceil(input.needed / input.perSender);
|
|
127
|
+
const hit = input.needed > capacity;
|
|
128
|
+
const note = input.needed === 0
|
|
129
|
+
? "Not used by this scenario."
|
|
130
|
+
: hit
|
|
131
|
+
? `${formatInt(input.needed)} ${input.unit} a month needs ${formatInt(sendersNeeded)}; you entered ${formatInt(input.senders)}. Each sends ${formatInt(input.perSender)} a month (${input.bindingLimit}). ${input.addWith}`
|
|
132
|
+
: `${formatInt(input.needed)} of ${formatInt(capacity)} ${input.unit} a month (${input.bindingLimit} each).`;
|
|
133
|
+
return {
|
|
134
|
+
key: input.key,
|
|
135
|
+
label: input.label,
|
|
136
|
+
needed_per_month: input.needed,
|
|
137
|
+
senders: input.senders,
|
|
138
|
+
per_sender_per_month: input.perSender,
|
|
139
|
+
binding_limit: input.bindingLimit,
|
|
140
|
+
capacity_per_month: capacity,
|
|
141
|
+
first_month_capacity: input.firstMonthPerSender === null ? null : input.senders * input.firstMonthPerSender,
|
|
142
|
+
senders_needed: sendersNeeded,
|
|
143
|
+
hit,
|
|
144
|
+
note,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/** Clamp a scenario into the accepted bounds: whole, non-negative numbers. */
|
|
148
|
+
export function normalizeCostEstimateScenario(scenario) {
|
|
149
|
+
const bound = (value, max) => Math.min(max, whole(value));
|
|
150
|
+
return {
|
|
151
|
+
leadsPerMonth: bound(scenario.leadsPerMonth, COST_ESTIMATE_BOUNDS.maxLeadsPerMonth),
|
|
152
|
+
enrichmentIds: [...new Set(scenario.enrichmentIds)].slice(0, COST_ESTIMATE_BOUNDS.maxEnrichments),
|
|
153
|
+
emailSteps: bound(scenario.emailSteps, COST_ESTIMATE_BOUNDS.maxStepsPerChannel),
|
|
154
|
+
linkedinSteps: bound(scenario.linkedinSteps, COST_ESTIMATE_BOUNDS.maxStepsPerChannel),
|
|
155
|
+
mailboxes: bound(scenario.mailboxes, COST_ESTIMATE_BOUNDS.maxSendersPerKind),
|
|
156
|
+
managedMailboxes: bound(scenario.managedMailboxes, COST_ESTIMATE_BOUNDS.maxSendersPerKind),
|
|
157
|
+
linkedinAccounts: bound(scenario.linkedinAccounts, COST_ESTIMATE_BOUNDS.maxSendersPerKind),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
export function estimateScenarioCost(rawScenario, sheet) {
|
|
161
|
+
const scenario = normalizeCostEstimateScenario(rawScenario);
|
|
162
|
+
const leads = scenario.leadsPerMonth;
|
|
163
|
+
const lines = [];
|
|
164
|
+
const warnings = [];
|
|
165
|
+
for (const enrichment of sheet.enrichments) {
|
|
166
|
+
const expected = enrichment.expectedCreditsPerRow;
|
|
167
|
+
const worst = enrichment.worstCaseCreditsPerRow ?? expected;
|
|
168
|
+
const credits = expected === null ? null : round2(leads * expected);
|
|
169
|
+
const worstCredits = worst === null ? null : round2(leads * worst);
|
|
170
|
+
if (enrichment.kind === "unavailable" || expected === null) {
|
|
171
|
+
warnings.push(`${enrichment.label} has no per-row estimate, so it is not in the totals. Price it with a one-row dry run.`);
|
|
172
|
+
}
|
|
173
|
+
lines.push({
|
|
174
|
+
key: `enrichment.${enrichment.id}`,
|
|
175
|
+
group: "enrichment",
|
|
176
|
+
label: enrichment.label,
|
|
177
|
+
quantity: leads,
|
|
178
|
+
unit: "row",
|
|
179
|
+
billed_as: "credits",
|
|
180
|
+
unit_credits: expected,
|
|
181
|
+
worst_case_unit_credits: worst,
|
|
182
|
+
credits_per_month: credits,
|
|
183
|
+
worst_case_credits_per_month: worstCredits,
|
|
184
|
+
usd_per_month: credits === null ? null : round2(credits / sheet.creditsPerUsd),
|
|
185
|
+
note: enrichment.kind === "estimated"
|
|
186
|
+
? `Billed only when a provider finds a result${enrichment.hitRatesAssumed ? "; the expected figure uses assumed hit rates" : ""}. Up to ${worst === null ? "an unknown amount" : `${round2(worst)} credits`} a row if the dearest provider answers every row.`
|
|
187
|
+
: enrichment.kind === "fixed"
|
|
188
|
+
? "The same price on every row."
|
|
189
|
+
: "No estimate available.",
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
const mailboxNote = "Warm-up is included.";
|
|
193
|
+
if (scenario.mailboxes > 0) {
|
|
194
|
+
lines.push(senderLine({
|
|
195
|
+
key: "senders.mailbox",
|
|
196
|
+
label: "Connected mailboxes",
|
|
197
|
+
quantity: scenario.mailboxes,
|
|
198
|
+
unit: "mailbox",
|
|
199
|
+
price: sheet.senders.mailbox,
|
|
200
|
+
creditsPerUsd: sheet.creditsPerUsd,
|
|
201
|
+
note: mailboxNote,
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
if (scenario.managedMailboxes > 0) {
|
|
205
|
+
lines.push(senderLine({
|
|
206
|
+
key: "senders.managed_mailbox",
|
|
207
|
+
label: "Mailboxes bought from OXYGEN",
|
|
208
|
+
quantity: scenario.managedMailboxes,
|
|
209
|
+
unit: "mailbox",
|
|
210
|
+
price: sheet.senders.managedMailbox,
|
|
211
|
+
creditsPerUsd: sheet.creditsPerUsd,
|
|
212
|
+
note: "Mailbox, connection and warm-up in one price; the sending domain is bought separately, once a year.",
|
|
213
|
+
}));
|
|
214
|
+
}
|
|
215
|
+
if (scenario.linkedinAccounts > 0) {
|
|
216
|
+
lines.push(senderLine({
|
|
217
|
+
key: "senders.linkedin_account",
|
|
218
|
+
label: "LinkedIn accounts",
|
|
219
|
+
quantity: scenario.linkedinAccounts,
|
|
220
|
+
unit: "account",
|
|
221
|
+
price: sheet.senders.linkedinAccount,
|
|
222
|
+
creditsPerUsd: sheet.creditsPerUsd,
|
|
223
|
+
note: "Invites, messages and every other LinkedIn action cost 0 credits.",
|
|
224
|
+
}));
|
|
225
|
+
}
|
|
226
|
+
const emails = leads * scenario.emailSteps;
|
|
227
|
+
const linkedinActions = leads * scenario.linkedinSteps;
|
|
228
|
+
for (const [key, label, quantity] of [
|
|
229
|
+
["sending.email", "Emails sent", emails],
|
|
230
|
+
["sending.linkedin", "LinkedIn invites and messages", linkedinActions],
|
|
231
|
+
]) {
|
|
232
|
+
if (quantity === 0)
|
|
233
|
+
continue;
|
|
234
|
+
lines.push({
|
|
235
|
+
key,
|
|
236
|
+
group: "sending",
|
|
237
|
+
label,
|
|
238
|
+
quantity,
|
|
239
|
+
unit: "message",
|
|
240
|
+
billed_as: "free",
|
|
241
|
+
unit_credits: 0,
|
|
242
|
+
worst_case_unit_credits: 0,
|
|
243
|
+
credits_per_month: 0,
|
|
244
|
+
worst_case_credits_per_month: 0,
|
|
245
|
+
usd_per_month: 0,
|
|
246
|
+
note: "Sending costs 0 credits; the limits below bound how fast it goes.",
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
const sum = (pick, filter) => round2(lines.filter(filter).reduce((total, line) => total + (pick(line) ?? 0), 0));
|
|
250
|
+
const fixed = sum((line) => line.credits_per_month, (line) => line.group === "senders" && line.billed_as === "credits");
|
|
251
|
+
const flexible = sum((line) => line.credits_per_month, (line) => line.group === "enrichment");
|
|
252
|
+
const flexibleWorst = sum((line) => line.worst_case_credits_per_month, (line) => line.group === "enrichment");
|
|
253
|
+
const usdSeats = sum((line) => line.usd_per_month, (line) => line.billed_as === "usd");
|
|
254
|
+
const unpriced = lines
|
|
255
|
+
.filter((line) => line.billed_as === "credits" && line.credits_per_month === null)
|
|
256
|
+
.map((line) => line.key);
|
|
257
|
+
for (const line of lines) {
|
|
258
|
+
if (line.group === "senders" && line.billed_as === "credits" && line.credits_per_month === null) {
|
|
259
|
+
warnings.push(`${line.label} have no monthly price in force yet, so the estimate reserves nothing for them.`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const credits = round2(fixed + flexible);
|
|
263
|
+
const worstCredits = round2(fixed + flexibleWorst);
|
|
264
|
+
const planFit = sheet.comparePlan
|
|
265
|
+
? {
|
|
266
|
+
expected: planOutcome(sheet.comparePlan, credits, usdSeats, sheet),
|
|
267
|
+
worst_case: planOutcome(sheet.comparePlan, worstCredits, usdSeats, sheet),
|
|
268
|
+
}
|
|
269
|
+
: null;
|
|
270
|
+
const recommendedExpected = cheapestRung(credits, usdSeats, sheet);
|
|
271
|
+
const recommendedWorst = cheapestRung(worstCredits, usdSeats, sheet);
|
|
272
|
+
const recommended = recommendedExpected && recommendedWorst
|
|
273
|
+
? { expected: recommendedExpected, worst_case: recommendedWorst }
|
|
274
|
+
: null;
|
|
275
|
+
const { sending } = sheet;
|
|
276
|
+
const invitesByWeek = Math.floor((sending.linkedinInvitesPerWeek * 52) / 12);
|
|
277
|
+
const invitesByDay = sending.linkedinInvitesPerDay * sending.sendingDaysPerMonth;
|
|
278
|
+
const inviteCapacity = Math.min(invitesByWeek, invitesByDay);
|
|
279
|
+
const inviteBinding = invitesByWeek <= invitesByDay
|
|
280
|
+
? `${formatInt(sending.linkedinInvitesPerWeek)} invites a week`
|
|
281
|
+
: `${formatInt(sending.linkedinInvitesPerDay)} invites a day`;
|
|
282
|
+
const totalMailboxes = scenario.mailboxes + scenario.managedMailboxes;
|
|
283
|
+
const limits = [
|
|
284
|
+
limitRow({
|
|
285
|
+
key: "email_per_mailbox",
|
|
286
|
+
label: "Emails per mailbox",
|
|
287
|
+
needed: emails,
|
|
288
|
+
senders: totalMailboxes,
|
|
289
|
+
perSender: sending.emailPerMailboxPerDay * sending.sendingDaysPerMonth,
|
|
290
|
+
firstMonthPerSender: rampFirstMonthCapacity(sheet),
|
|
291
|
+
bindingLimit: `${formatInt(sending.emailPerMailboxPerDay)} emails a day`,
|
|
292
|
+
unit: "emails",
|
|
293
|
+
addWith: "Add mailboxes, or spread the leads over more months.",
|
|
294
|
+
}),
|
|
295
|
+
limitRow({
|
|
296
|
+
key: "linkedin_invites_per_account",
|
|
297
|
+
label: "LinkedIn invites per account",
|
|
298
|
+
needed: scenario.linkedinSteps > 0 ? leads : 0,
|
|
299
|
+
senders: scenario.linkedinAccounts,
|
|
300
|
+
perSender: inviteCapacity,
|
|
301
|
+
firstMonthPerSender: null,
|
|
302
|
+
bindingLimit: inviteBinding,
|
|
303
|
+
unit: "invites",
|
|
304
|
+
addWith: "Connect more LinkedIn accounts, or spread the leads over more months.",
|
|
305
|
+
}),
|
|
306
|
+
limitRow({
|
|
307
|
+
key: "linkedin_messages_per_account",
|
|
308
|
+
label: "LinkedIn messages per account",
|
|
309
|
+
needed: leads * Math.max(0, scenario.linkedinSteps - 1),
|
|
310
|
+
senders: scenario.linkedinAccounts,
|
|
311
|
+
perSender: sending.linkedinMessagesPerDay * sending.sendingDaysPerMonth,
|
|
312
|
+
firstMonthPerSender: null,
|
|
313
|
+
bindingLimit: `${formatInt(sending.linkedinMessagesPerDay)} messages a day`,
|
|
314
|
+
unit: "messages",
|
|
315
|
+
addWith: "Connect more LinkedIn accounts, or spread the leads over more months.",
|
|
316
|
+
}),
|
|
317
|
+
].filter((row) => row.needed_per_month > 0);
|
|
318
|
+
const limitsHit = limits.filter((row) => row.hit).map((row) => row.key);
|
|
319
|
+
const assumptions = [
|
|
320
|
+
"Every lead is already in a table and goes through each enrichment and every sequence step. Finding new leads is priced separately.",
|
|
321
|
+
`Sending limits use ${sending.sendingDaysPerMonth} sending days a month and the defaults for a newly connected account (a sender you already run may be set lower): ${sending.emailPerMailboxPerDay} emails a mailbox a day, ${sending.linkedinInvitesPerDay} LinkedIn invites a day and ${sending.linkedinInvitesPerWeek} a week, ${sending.linkedinMessagesPerDay} LinkedIn messages a day.`,
|
|
322
|
+
"The first LinkedIn step is a connection invite and each later step a message to every lead, so the message count is an upper bound: only accepted invites receive messages.",
|
|
323
|
+
"While senders are paid as sending seats (sender_billing: sending_seats) they reserve no credits; once seats are retired each connected sender reserves its monthly price in credits instead, never both.",
|
|
324
|
+
`Credits are shown at face value, ${formatInt(sheet.creditsPerUsd)} credits to $1; top-ups cost ${formatUsd(sheet.topupUsdCentsPer100 / 100)} per 100 credits.`,
|
|
325
|
+
];
|
|
326
|
+
if (totalMailboxes > 0 && emails > 0) {
|
|
327
|
+
const ramp = sending.emailRamp.map((step) => step.perDay).join(", ");
|
|
328
|
+
assumptions.push(`A newly connected mailbox ramps up (${ramp} a day) before it sends at its full daily limit; first_month_capacity shows that month.`);
|
|
329
|
+
}
|
|
330
|
+
const planText = recommended
|
|
331
|
+
? `cheapest all in: the ${recommended.expected.label} plan${recommended.expected.topup_credits > 0 ? " with top-ups" : ""}${usdSeats > 0 ? " and seats" : ""}, ${formatUsd(recommended.expected.monthly_usd ?? 0)} a month`
|
|
332
|
+
: "no plan could be priced";
|
|
333
|
+
const steps = scenario.emailSteps + scenario.linkedinSteps;
|
|
334
|
+
const scenarioText = `Priced: ${formatInt(leads)} leads a month, ${steps} sequence ${steps === 1 ? "step" : "steps"} per lead (${scenario.emailSteps} email, ${scenario.linkedinSteps} LinkedIn), ${formatInt(totalMailboxes)} ${totalMailboxes === 1 ? "mailbox" : "mailboxes"}, ${formatInt(scenario.linkedinAccounts)} LinkedIn ${scenario.linkedinAccounts === 1 ? "account" : "accounts"}.`;
|
|
335
|
+
const summary = [
|
|
336
|
+
scenarioText,
|
|
337
|
+
`About ${formatInt(credits)} credits a month expected (up to ${formatInt(worstCredits)} worst case)${usdSeats > 0 ? ` plus ${formatUsd(usdSeats)} a month in sending seats` : ""}; ${planText}.`,
|
|
338
|
+
limitsHit.length > 0
|
|
339
|
+
? `Limits hit: ${limits.filter((row) => row.hit).map((row) => `${row.label.toLowerCase()} (needs ${formatInt(row.senders_needed)})`).join(", ")}.`
|
|
340
|
+
: limits.length > 0 ? "No sending limit is hit." : "",
|
|
341
|
+
].filter(Boolean).join(" ");
|
|
342
|
+
return {
|
|
343
|
+
lines,
|
|
344
|
+
totals: {
|
|
345
|
+
fixed_credits_per_month: fixed,
|
|
346
|
+
flexible_credits_per_month: flexible,
|
|
347
|
+
flexible_worst_case_credits_per_month: flexibleWorst,
|
|
348
|
+
credits_per_month: credits,
|
|
349
|
+
worst_case_credits_per_month: worstCredits,
|
|
350
|
+
usd_seats_per_month: usdSeats,
|
|
351
|
+
unpriced_lines: unpriced,
|
|
352
|
+
},
|
|
353
|
+
plan_fit: planFit,
|
|
354
|
+
recommended_plan: recommended,
|
|
355
|
+
limits,
|
|
356
|
+
limits_hit: limitsHit,
|
|
357
|
+
assumptions,
|
|
358
|
+
warnings,
|
|
359
|
+
summary,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { PlanGatedCapability } from "./plan-capabilities.js";
|
|
2
|
+
/** PROPOSED name: the specification calls it "the credit-gate flag" without naming it. */
|
|
3
|
+
export declare const CREDIT_GATE_ENABLED_ENV_VAR = "OXYGEN_CREDIT_GATE_ENABLED";
|
|
4
|
+
/**
|
|
5
|
+
* The commitment kinds that must bill before credits may gate a connect: every
|
|
6
|
+
* account a customer can connect for itself. Managed mailboxes and warm-up are
|
|
7
|
+
* bought, not connected, and are out of the gate.
|
|
8
|
+
*/
|
|
9
|
+
export declare const CREDIT_GATE_BILLING_KINDS: readonly ["sending_mailbox", "linkedin_account", "whatsapp_account", "x_account", "phone_number"];
|
|
10
|
+
export type CreditGateKind = (typeof CREDIT_GATE_BILLING_KINDS)[number];
|
|
11
|
+
/**
|
|
12
|
+
* The plan walls credits replace once the gate is in force (S22). The
|
|
13
|
+
* reasoning, and the two capabilities that stay walled, live with the wall
|
|
14
|
+
* itself in `plan-capabilities.ts`; the list sits in this leaf so a client
|
|
15
|
+
* bundle can ask the question without loading the plan catalog.
|
|
16
|
+
*/
|
|
17
|
+
export declare const CREDIT_GATE_RETIRED_CAPABILITIES: readonly ["sender_connect", "sequence_dispatch", "publishing_deliver", "managed_email_infrastructure"];
|
|
18
|
+
export declare function isCapabilityRetiredByCreditGate(capability: PlanGatedCapability): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Whether an operator asked for the credit gate. Off unless set to 1/true/yes/on.
|
|
21
|
+
* This is the REQUEST only; the gate is in force only when commitments bill every
|
|
22
|
+
* kind above as well, which `isCreditGateEnabled` checks.
|
|
23
|
+
*/
|
|
24
|
+
export declare function isCreditGateRequested(env?: {
|
|
25
|
+
[key: string]: string | undefined;
|
|
26
|
+
}): boolean;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// The credit-gate flag of the 2026-09 repricing (decision 2.7, spec slices S21
|
|
2
|
+
// and S22): the switch that makes CREDITS, not dollar sending seats, decide
|
|
3
|
+
// whether a workspace may connect an account.
|
|
4
|
+
//
|
|
5
|
+
// WHAT IT MOVES. With the flag off, nothing changes: a connect is judged by the
|
|
6
|
+
// sending-seat gate (`OXYGEN_SENDING_SEAT_GATE_ENABLED`) exactly as today. With it
|
|
7
|
+
// on, a connect is admitted when the spendable balance covers the account's
|
|
8
|
+
// first monthly credit reservation, refused with `insufficient_credits` when it
|
|
9
|
+
// does not, and `billing seats` reports that seats are retired (S21). The plan
|
|
10
|
+
// wall removal (S22) sits behind the same flag.
|
|
11
|
+
//
|
|
12
|
+
// WHY IT IS ITS OWN FLAG, AND WHY IT IS NOT ENOUGH ALONE. A balance check debits
|
|
13
|
+
// nothing: while commitments are dark, a workspace holding 3,400 credits would
|
|
14
|
+
// pass it, connect a LinkedIn account and send for free for good. So the flag
|
|
15
|
+
// only takes effect when every kind below is also being BILLED as a credit
|
|
16
|
+
// commitment and has a price in force, which for X accounts and phone numbers
|
|
17
|
+
// means once the repricing's effective date has passed (`isCreditGateEnabled` in
|
|
18
|
+
// `@oxygen/integrations`), and a process that sees the flag set without the
|
|
19
|
+
// commitments refuses to start (`assertCreditGateArmed`).
|
|
20
|
+
//
|
|
21
|
+
// This leaf holds only the flag's name, its raw reading and the kinds it
|
|
22
|
+
// requires, so request paths can skip the integrations barrel when it is off.
|
|
23
|
+
// Setting it is a production configuration change that needs explicit human
|
|
24
|
+
// approval (spec S95), and it is turned on only after S26 arms every kind.
|
|
25
|
+
/** PROPOSED name: the specification calls it "the credit-gate flag" without naming it. */
|
|
26
|
+
export const CREDIT_GATE_ENABLED_ENV_VAR = "OXYGEN_CREDIT_GATE_ENABLED";
|
|
27
|
+
/**
|
|
28
|
+
* The commitment kinds that must bill before credits may gate a connect: every
|
|
29
|
+
* account a customer can connect for itself. Managed mailboxes and warm-up are
|
|
30
|
+
* bought, not connected, and are out of the gate.
|
|
31
|
+
*/
|
|
32
|
+
export const CREDIT_GATE_BILLING_KINDS = [
|
|
33
|
+
"sending_mailbox",
|
|
34
|
+
"linkedin_account",
|
|
35
|
+
"whatsapp_account",
|
|
36
|
+
"x_account",
|
|
37
|
+
"phone_number",
|
|
38
|
+
];
|
|
39
|
+
/**
|
|
40
|
+
* The plan walls credits replace once the gate is in force (S22). The
|
|
41
|
+
* reasoning, and the two capabilities that stay walled, live with the wall
|
|
42
|
+
* itself in `plan-capabilities.ts`; the list sits in this leaf so a client
|
|
43
|
+
* bundle can ask the question without loading the plan catalog.
|
|
44
|
+
*/
|
|
45
|
+
export const CREDIT_GATE_RETIRED_CAPABILITIES = [
|
|
46
|
+
"sender_connect",
|
|
47
|
+
"sequence_dispatch",
|
|
48
|
+
"publishing_deliver",
|
|
49
|
+
"managed_email_infrastructure",
|
|
50
|
+
];
|
|
51
|
+
export function isCapabilityRetiredByCreditGate(capability) {
|
|
52
|
+
return CREDIT_GATE_RETIRED_CAPABILITIES.includes(capability);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Whether an operator asked for the credit gate. Off unless set to 1/true/yes/on.
|
|
56
|
+
* This is the REQUEST only; the gate is in force only when commitments bill every
|
|
57
|
+
* kind above as well, which `isCreditGateEnabled` checks.
|
|
58
|
+
*/
|
|
59
|
+
export function isCreditGateRequested(env = process.env) {
|
|
60
|
+
const raw = env[CREDIT_GATE_ENABLED_ENV_VAR];
|
|
61
|
+
if (!raw)
|
|
62
|
+
return false;
|
|
63
|
+
const normalized = raw.trim().toLowerCase();
|
|
64
|
+
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
|
65
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { SequenceSendWindow } from "./sequences.js";
|
|
2
|
+
/** Deliverability defaults are independent of pricing and confer no send/spend authority. */
|
|
3
|
+
export declare const EMAIL_DELIVERABILITY_POLICY_VERSION = "2026-09-27-v1";
|
|
4
|
+
export declare const EMAIL_DELIVERABILITY_DEFAULTS: Readonly<{
|
|
5
|
+
campaignDailyCap: 25;
|
|
6
|
+
campaignGapMinutes: 12;
|
|
7
|
+
trackingOpens: false;
|
|
8
|
+
trackingClicks: false;
|
|
9
|
+
warmupInitialPerDay: 1;
|
|
10
|
+
warmupIncreasePerDay: 1;
|
|
11
|
+
warmupMaxPerDay: 10;
|
|
12
|
+
combinedVolumeWarning: 50;
|
|
13
|
+
bounceWarningRate: 0.02;
|
|
14
|
+
bouncePauseRate: 0.03;
|
|
15
|
+
bounceMinSends: 20;
|
|
16
|
+
bounceWindowDays: 7;
|
|
17
|
+
}>;
|
|
18
|
+
export type EmailBounceProtection = {
|
|
19
|
+
enabled: boolean;
|
|
20
|
+
warning_rate: number;
|
|
21
|
+
pause_rate: number;
|
|
22
|
+
min_sends: number;
|
|
23
|
+
window_days: number;
|
|
24
|
+
};
|
|
25
|
+
export declare function resolveEmailBounceProtection(value?: unknown): EmailBounceProtection;
|
|
26
|
+
export declare function defaultEmailSendWindow(timezone?: string | null): SequenceSendWindow;
|
|
27
|
+
/** Completed UTC days with an accepted campaign send; import age is not evidence. */
|
|
28
|
+
export declare function campaignSendingDays(metadata: unknown, now?: Date): number;
|
|
29
|
+
/** Fill absent settings only. Explicit zero caps/gaps and schedules remain choices. */
|
|
30
|
+
export declare function resolveEmailDeliverabilityPolicy(input?: {
|
|
31
|
+
settings?: {
|
|
32
|
+
max_emails_per_mailbox_per_day?: number;
|
|
33
|
+
email_min_gap_minutes?: number;
|
|
34
|
+
email_send_window?: SequenceSendWindow;
|
|
35
|
+
} | null;
|
|
36
|
+
trackingOpens?: boolean | null;
|
|
37
|
+
trackingClicks?: boolean | null;
|
|
38
|
+
workspaceTimezone?: string | null;
|
|
39
|
+
}): {
|
|
40
|
+
version: string;
|
|
41
|
+
campaignDailyCap: number;
|
|
42
|
+
campaignGapMinutes: number;
|
|
43
|
+
sendWindow: SequenceSendWindow;
|
|
44
|
+
trackingOpens: boolean;
|
|
45
|
+
trackingClicks: boolean;
|
|
46
|
+
externalSendingMeasured: boolean;
|
|
47
|
+
};
|
|
48
|
+
/** Strict authoring validation; the resolver remains defensive for legacy rows. */
|
|
49
|
+
export declare function validateEmailBounceProtection(value: unknown): void;
|
|
50
|
+
/** Inspect executable signal/metric fields, never authored prose. */
|
|
51
|
+
export declare function missingEmailTracking(definition: unknown, opens: boolean, clicks: boolean, autoOptimize?: boolean): string[];
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/** Deliverability defaults are independent of pricing and confer no send/spend authority. */
|
|
2
|
+
export const EMAIL_DELIVERABILITY_POLICY_VERSION = "2026-09-27-v1";
|
|
3
|
+
export const EMAIL_DELIVERABILITY_DEFAULTS = Object.freeze({
|
|
4
|
+
campaignDailyCap: 25,
|
|
5
|
+
campaignGapMinutes: 12,
|
|
6
|
+
trackingOpens: false,
|
|
7
|
+
trackingClicks: false,
|
|
8
|
+
warmupInitialPerDay: 1,
|
|
9
|
+
warmupIncreasePerDay: 1,
|
|
10
|
+
warmupMaxPerDay: 10,
|
|
11
|
+
combinedVolumeWarning: 50,
|
|
12
|
+
bounceWarningRate: 0.02,
|
|
13
|
+
bouncePauseRate: 0.03,
|
|
14
|
+
bounceMinSends: 20,
|
|
15
|
+
bounceWindowDays: 7,
|
|
16
|
+
});
|
|
17
|
+
export function resolveEmailBounceProtection(value) {
|
|
18
|
+
const record = value && typeof value === "object" ? value : {};
|
|
19
|
+
const number = (key, fallback, min, max, integer = false) => {
|
|
20
|
+
const v = record[key];
|
|
21
|
+
return typeof v === "number" && Number.isFinite(v) && v >= min && v <= max
|
|
22
|
+
&& (!integer || Number.isInteger(v)) ? v : fallback;
|
|
23
|
+
};
|
|
24
|
+
return {
|
|
25
|
+
enabled: record.enabled !== false,
|
|
26
|
+
warning_rate: number("warning_rate", EMAIL_DELIVERABILITY_DEFAULTS.bounceWarningRate, 0, 1),
|
|
27
|
+
pause_rate: number("pause_rate", EMAIL_DELIVERABILITY_DEFAULTS.bouncePauseRate, 0, 1),
|
|
28
|
+
min_sends: number("min_sends", EMAIL_DELIVERABILITY_DEFAULTS.bounceMinSends, 1, 100000, true),
|
|
29
|
+
window_days: number("window_days", EMAIL_DELIVERABILITY_DEFAULTS.bounceWindowDays, 1, 90, true),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function defaultEmailSendWindow(timezone) {
|
|
33
|
+
let zone = timezone?.trim() || "UTC";
|
|
34
|
+
try {
|
|
35
|
+
new Intl.DateTimeFormat("en", { timeZone: zone }).format();
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
zone = "UTC";
|
|
39
|
+
}
|
|
40
|
+
return { timezone: zone, days: [1, 2, 3, 4, 5], start: "09:00", end: "17:00" };
|
|
41
|
+
}
|
|
42
|
+
/** Completed UTC days with an accepted campaign send; import age is not evidence. */
|
|
43
|
+
export function campaignSendingDays(metadata, now = new Date()) {
|
|
44
|
+
const record = metadata && typeof metadata === "object" ? metadata : {};
|
|
45
|
+
const days = record.campaign_sending_days;
|
|
46
|
+
if (!Array.isArray(days))
|
|
47
|
+
return 0;
|
|
48
|
+
const today = now.toISOString().slice(0, 10);
|
|
49
|
+
return new Set(days.filter((day) => typeof day === "string"
|
|
50
|
+
&& /^\d{4}-\d{2}-\d{2}$/.test(day) && day < today)).size;
|
|
51
|
+
}
|
|
52
|
+
/** Fill absent settings only. Explicit zero caps/gaps and schedules remain choices. */
|
|
53
|
+
export function resolveEmailDeliverabilityPolicy(input = {}) {
|
|
54
|
+
return {
|
|
55
|
+
version: EMAIL_DELIVERABILITY_POLICY_VERSION,
|
|
56
|
+
campaignDailyCap: input.settings?.max_emails_per_mailbox_per_day ?? EMAIL_DELIVERABILITY_DEFAULTS.campaignDailyCap,
|
|
57
|
+
campaignGapMinutes: input.settings?.email_min_gap_minutes ?? EMAIL_DELIVERABILITY_DEFAULTS.campaignGapMinutes,
|
|
58
|
+
sendWindow: input.settings?.email_send_window ?? defaultEmailSendWindow(input.workspaceTimezone),
|
|
59
|
+
trackingOpens: input.trackingOpens ?? EMAIL_DELIVERABILITY_DEFAULTS.trackingOpens,
|
|
60
|
+
trackingClicks: input.trackingClicks ?? EMAIL_DELIVERABILITY_DEFAULTS.trackingClicks,
|
|
61
|
+
externalSendingMeasured: false,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** Strict authoring validation; the resolver remains defensive for legacy rows. */
|
|
65
|
+
export function validateEmailBounceProtection(value) {
|
|
66
|
+
if (value === undefined)
|
|
67
|
+
return;
|
|
68
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
69
|
+
throw new Error("bounce_protection must be an object.");
|
|
70
|
+
const record = value;
|
|
71
|
+
const resolved = resolveEmailBounceProtection(record);
|
|
72
|
+
for (const [key, configured] of Object.entries(record)) {
|
|
73
|
+
if (!(key in resolved) || resolved[key] !== configured) {
|
|
74
|
+
throw new Error(`Invalid bounce_protection.${key}. Use enabled (boolean), rates from 0 to 1, min_sends from 1 to 100000, and window_days from 1 to 90.`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Inspect executable signal/metric fields, never authored prose. */
|
|
79
|
+
export function missingEmailTracking(definition, opens, clicks, autoOptimize = true) {
|
|
80
|
+
const missing = new Set();
|
|
81
|
+
const visit = (value) => {
|
|
82
|
+
if (!value || typeof value !== "object")
|
|
83
|
+
return;
|
|
84
|
+
if (Array.isArray(value)) {
|
|
85
|
+
value.forEach(visit);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
for (const [key, child] of Object.entries(value)) {
|
|
89
|
+
if (key === "auto_optimize" && !autoOptimize)
|
|
90
|
+
continue;
|
|
91
|
+
if ((key === "signal" && child === "email_opened" || key === "metric" && child === "open") && !opens)
|
|
92
|
+
missing.add("opens");
|
|
93
|
+
if ((key === "signal" && child === "email_clicked" || key === "metric" && child === "click") && !clicks)
|
|
94
|
+
missing.add("clicks");
|
|
95
|
+
if (typeof child === "object")
|
|
96
|
+
visit(child);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
visit(definition);
|
|
100
|
+
return [...missing];
|
|
101
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PURE hard-bounce rule: when a fleet's bounces are a reputation problem rather
|
|
3
|
+
* than list noise.
|
|
4
|
+
*
|
|
5
|
+
* WHY it lives in @oxygen/shared and not next to the other email-health rules in
|
|
6
|
+
* @oxygen/integrations: the tenant rollup applies it per mailbox, and
|
|
7
|
+
* @oxygen/tenant-db must never import @oxygen/integrations (integrations depends
|
|
8
|
+
* on tenant-db). It is re-exported from
|
|
9
|
+
* packages/integrations/src/email-health/health-state.ts, which stays the
|
|
10
|
+
* email-health surface every caller reads.
|
|
11
|
+
*
|
|
12
|
+
* DIRECTIONAL: it produces a warning, never pauses a mailbox or changes a cap.
|
|
13
|
+
*/
|
|
14
|
+
/** Hard-bounce rate above which a fleet is burning its domains, not just its list. */
|
|
15
|
+
export declare const HARD_BOUNCE_RATE_CEILING = 0.02;
|
|
16
|
+
/** Below this send volume a bounce rate is noise, not a signal. */
|
|
17
|
+
export declare const HARD_BOUNCE_RATE_MIN_SENDS = 20;
|
|
18
|
+
/**
|
|
19
|
+
* True when a hard-bounce count is high enough, over enough sends, to be a real
|
|
20
|
+
* reputation problem rather than list noise.
|
|
21
|
+
*/
|
|
22
|
+
export declare function hardBounceRateIsHigh(input: {
|
|
23
|
+
hardBounces: number;
|
|
24
|
+
coldSends: number;
|
|
25
|
+
warningRate?: number;
|
|
26
|
+
minSends?: number;
|
|
27
|
+
}): boolean;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PURE hard-bounce rule: when a fleet's bounces are a reputation problem rather
|
|
3
|
+
* than list noise.
|
|
4
|
+
*
|
|
5
|
+
* WHY it lives in @oxygen/shared and not next to the other email-health rules in
|
|
6
|
+
* @oxygen/integrations: the tenant rollup applies it per mailbox, and
|
|
7
|
+
* @oxygen/tenant-db must never import @oxygen/integrations (integrations depends
|
|
8
|
+
* on tenant-db). It is re-exported from
|
|
9
|
+
* packages/integrations/src/email-health/health-state.ts, which stays the
|
|
10
|
+
* email-health surface every caller reads.
|
|
11
|
+
*
|
|
12
|
+
* DIRECTIONAL: it produces a warning, never pauses a mailbox or changes a cap.
|
|
13
|
+
*/
|
|
14
|
+
/** Hard-bounce rate above which a fleet is burning its domains, not just its list. */
|
|
15
|
+
export const HARD_BOUNCE_RATE_CEILING = 0.02;
|
|
16
|
+
/** Below this send volume a bounce rate is noise, not a signal. */
|
|
17
|
+
export const HARD_BOUNCE_RATE_MIN_SENDS = 20;
|
|
18
|
+
/**
|
|
19
|
+
* True when a hard-bounce count is high enough, over enough sends, to be a real
|
|
20
|
+
* reputation problem rather than list noise.
|
|
21
|
+
*/
|
|
22
|
+
export function hardBounceRateIsHigh(input) {
|
|
23
|
+
if (!Number.isFinite(input.coldSends) || input.coldSends < (input.minSends ?? HARD_BOUNCE_RATE_MIN_SENDS)) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
return input.hardBounces / input.coldSends > (input.warningRate ?? HARD_BOUNCE_RATE_CEILING);
|
|
27
|
+
}
|