@oxygen-agent/cli 1.1014.1464 → 1.1014.1597

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.
Files changed (85) hide show
  1. package/README.md +1 -1
  2. package/dist/command-families/program.js +2 -0
  3. package/dist/command-manifest.js +12 -2
  4. package/dist/commands/budget.js +2 -2
  5. package/dist/commands/capabilities.js +3 -2
  6. package/dist/commands/columns.js +1 -1
  7. package/dist/commands/companies.js +3 -0
  8. package/dist/commands/domains.js +122 -5
  9. package/dist/commands/feedback.js +1 -1
  10. package/dist/commands/feeds.js +34 -17
  11. package/dist/commands/mailboxes.js +19 -9
  12. package/dist/commands/publishing.js +1 -1
  13. package/dist/commands/senders.js +2 -2
  14. package/dist/commands/sequences.js +75 -12
  15. package/dist/commands/subscriptions.d.ts +10 -0
  16. package/dist/commands/subscriptions.js +203 -0
  17. package/dist/commands/tags.js +4 -4
  18. package/dist/customer-operation-commands.js +17 -0
  19. package/dist/mailbox-fleet-output.d.ts +3 -0
  20. package/dist/mailbox-fleet-output.js +80 -0
  21. package/dist/native-chat-host.d.ts +2 -0
  22. package/dist/native-chat-host.js +6 -0
  23. package/dist/workspace-placement-commands.js +67 -2
  24. package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +9 -0
  25. package/node_modules/@oxygen/shared/dist/capability-discovery.js +59 -11
  26. package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +2 -2
  27. package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +18 -0
  28. package/node_modules/@oxygen/shared/dist/contracts/guided-activation.js +4 -4
  29. package/node_modules/@oxygen/shared/dist/contracts/native-chat.d.ts +120 -0
  30. package/node_modules/@oxygen/shared/dist/contracts/native-chat.js +35 -0
  31. package/node_modules/@oxygen/shared/dist/contracts/sender-limits.d.ts +9 -0
  32. package/node_modules/@oxygen/shared/dist/contracts/sender-limits.js +6 -1
  33. package/node_modules/@oxygen/shared/dist/contracts/sequence-list.d.ts +5 -0
  34. package/node_modules/@oxygen/shared/dist/contracts/sequence-list.js +2 -1
  35. package/node_modules/@oxygen/shared/dist/contracts/signal-search-compatibility.d.ts +78 -0
  36. package/node_modules/@oxygen/shared/dist/contracts/signal-search-compatibility.js +32 -0
  37. package/node_modules/@oxygen/shared/dist/dnc-rules.js +14 -0
  38. package/node_modules/@oxygen/shared/dist/email-delivery.d.ts +150 -0
  39. package/node_modules/@oxygen/shared/dist/email-delivery.js +246 -0
  40. package/node_modules/@oxygen/shared/dist/email-dsn.d.ts +1 -0
  41. package/node_modules/@oxygen/shared/dist/email-dsn.js +9 -6
  42. package/node_modules/@oxygen/shared/dist/email-health-summary.d.ts +29 -0
  43. package/node_modules/@oxygen/shared/dist/email-health-summary.js +28 -0
  44. package/node_modules/@oxygen/shared/dist/email-subscriptions.d.ts +50 -0
  45. package/node_modules/@oxygen/shared/dist/email-subscriptions.js +80 -0
  46. package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.d.ts +28 -0
  47. package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.js +47 -0
  48. package/node_modules/@oxygen/shared/dist/error-redaction.d.ts +4 -16
  49. package/node_modules/@oxygen/shared/dist/error-redaction.js +8 -17
  50. package/node_modules/@oxygen/shared/dist/guided-activation.d.ts +15 -1
  51. package/node_modules/@oxygen/shared/dist/guided-activation.js +3 -1
  52. package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +2 -0
  53. package/node_modules/@oxygen/shared/dist/mailbox-standby-rollout.d.ts +5 -0
  54. package/node_modules/@oxygen/shared/dist/mailbox-standby-rollout.js +9 -0
  55. package/node_modules/@oxygen/shared/dist/mailbox-standby.d.ts +43 -0
  56. package/node_modules/@oxygen/shared/dist/mailbox-standby.js +52 -0
  57. package/node_modules/@oxygen/shared/dist/native-chat-discovery.d.ts +5 -0
  58. package/node_modules/@oxygen/shared/dist/native-chat-discovery.js +27 -0
  59. package/node_modules/@oxygen/shared/dist/native-chat.d.ts +124 -0
  60. package/node_modules/@oxygen/shared/dist/native-chat.js +1 -0
  61. package/node_modules/@oxygen/shared/dist/onboarding-plays.d.ts +14 -3
  62. package/node_modules/@oxygen/shared/dist/onboarding-plays.js +26 -13
  63. package/node_modules/@oxygen/shared/dist/onboarding-research.d.ts +11 -0
  64. package/node_modules/@oxygen/shared/dist/onboarding-research.js +8 -0
  65. package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +6 -0
  66. package/node_modules/@oxygen/shared/dist/product-analytics-events.js +6 -0
  67. package/node_modules/@oxygen/shared/dist/select-options.js +1 -0
  68. package/node_modules/@oxygen/shared/dist/sequence-failures.js +6 -5
  69. package/node_modules/@oxygen/shared/dist/sequences.d.ts +8 -0
  70. package/node_modules/@oxygen/shared/dist/sequences.js +44 -2
  71. package/node_modules/@oxygen/shared/dist/tags.d.ts +7 -3
  72. package/node_modules/@oxygen/shared/dist/tags.js +31 -6
  73. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  74. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  75. package/node_modules/@oxygen/shared/dist/workspace-placement-bulk.d.ts +80 -0
  76. package/node_modules/@oxygen/shared/dist/workspace-placement-bulk.js +68 -0
  77. package/node_modules/@oxygen/shared/dist/workspace-placement-view.d.ts +2 -1
  78. package/node_modules/@oxygen/shared/dist/workspace-placement.d.ts +11 -1
  79. package/node_modules/@oxygen/shared/dist/workspace-placement.js +15 -4
  80. package/node_modules/@oxygen/shared/dist/write-rollout.d.ts +11 -0
  81. package/node_modules/@oxygen/shared/dist/write-rollout.js +59 -0
  82. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +176 -2
  83. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.js +50 -1
  84. package/node_modules/@oxygen/workflows/dist/index.js +108 -3
  85. package/package.json +1 -1
@@ -1,3 +1,8 @@
1
1
  export declare const SEQUENCE_LIST_PROJECTION_CONTRACT: import("./schema.js").ContractObject<{
2
+ readonly view: {
3
+ type: "string";
4
+ enum: readonly ("full" | "summary")[];
5
+ description?: string;
6
+ };
2
7
  readonly omit: import("./schema.js").StringProperty;
3
8
  }, never>;
@@ -2,7 +2,8 @@
2
2
  * Other list filters retain their existing contracts. Unknown CSV values are
3
3
  * ignored by the API; absence retains the normal warning reads and results.
4
4
  */
5
- import { obj, str } from "./schema.js";
5
+ import { obj, str, enumOf } from "./schema.js";
6
6
  export const SEQUENCE_LIST_PROJECTION_CONTRACT = obj({
7
+ view: enumOf(["summary", "full"], { description: "CLI/MCP default summary; full details." }),
7
8
  omit: str({ description: "warnings: skip warnings and their extra reads." }),
8
9
  });
@@ -0,0 +1,78 @@
1
+ /** Fresh nested objects keep separately published tool schemas independent. */
2
+ export declare function signalSearchCompatibilityProperties(): {
3
+ geo: import("./schema.js").ContractObject<{
4
+ readonly countries: {
5
+ oneOf: (import("./schema.js").ArrayProperty<import("./schema.js").StringProperty> | import("./schema.js").StringProperty | {
6
+ type: "null";
7
+ })[];
8
+ };
9
+ }, never>;
10
+ job: import("./schema.js").ContractObject<{
11
+ readonly title: import("./schema.js").ContractObject<{
12
+ readonly include: {
13
+ oneOf: (import("./schema.js").ArrayProperty<import("./schema.js").StringProperty> | import("./schema.js").StringProperty | {
14
+ type: "null";
15
+ })[];
16
+ };
17
+ }, "include">;
18
+ readonly location: import("./schema.js").ContractObject<{
19
+ readonly country_code: import("./schema.js").ContractObject<{
20
+ readonly include: {
21
+ oneOf: (import("./schema.js").ArrayProperty<import("./schema.js").StringProperty> | import("./schema.js").StringProperty | {
22
+ type: "null";
23
+ })[];
24
+ };
25
+ }, "include">;
26
+ }, "country_code">;
27
+ readonly date_posted: import("./schema.js").ContractObject<{
28
+ readonly last_days: {
29
+ readonly oneOf: [{
30
+ type: "integer";
31
+ description?: string;
32
+ minimum?: number;
33
+ maximum?: number;
34
+ "x-clamp"?: true;
35
+ }, import("./schema.js").StringProperty, {
36
+ readonly type: "null";
37
+ }];
38
+ };
39
+ }, "last_days">;
40
+ }, never>;
41
+ company: import("./schema.js").ContractObject<{
42
+ readonly is_agency: {
43
+ readonly oneOf: [{
44
+ readonly type: "boolean";
45
+ readonly description?: string;
46
+ readonly enum: readonly [false];
47
+ }, {
48
+ type: "string";
49
+ enum: readonly ("" | "false")[];
50
+ description?: string;
51
+ }, {
52
+ readonly type: "null";
53
+ }];
54
+ };
55
+ }, never>;
56
+ };
57
+ /** Native JSON Schema constraints supplement the shared property's type dialect.
58
+ * The serving reader independently enforces these same existing restrictions. */
59
+ export declare function signalSearchCompatibilityRules(): {
60
+ if: {
61
+ required: string[];
62
+ properties: {
63
+ filters: {
64
+ anyOf: {
65
+ required: string[];
66
+ }[];
67
+ };
68
+ };
69
+ };
70
+ then: {
71
+ required: string[];
72
+ properties: {
73
+ family: {
74
+ const: string;
75
+ };
76
+ };
77
+ };
78
+ };
@@ -0,0 +1,32 @@
1
+ /** Existing native Signal search compatibility inputs; normalization remains in
2
+ * the serving reader. No provider, approval or family authority is granted here. */
3
+ import { arr, bool, enumOf, int, obj, str } from "./schema.js";
4
+ /** Fresh nested objects keep separately published tool schemas independent. */
5
+ export function signalSearchCompatibilityProperties() {
6
+ const list = () => ({ oneOf: [arr(str()), str(), { type: "null" }] });
7
+ return {
8
+ geo: obj({ countries: list() }, { description: "Countries alias if countries is absent/null; unioned with job country includes." }),
9
+ job: obj({
10
+ title: obj({ include: list() }),
11
+ location: obj({ country_code: obj({ include: list() }) }),
12
+ date_posted: obj({ last_days: { oneOf: [int({ minimum: 1, maximum: 3650 }), str(), { type: "null" }] } }),
13
+ }, { description: "Hiring only. Lists accept arrays or comma strings; values are trimmed. Titles merge with keywords; country includes merge with geo/countries, uppercase and deduplicate. last_days accepts numeric strings for integers 1–3650; differing nonblank top-level last_days is refused." }),
14
+ company: obj({
15
+ is_agency: { oneOf: [{ ...bool(), enum: [false] }, enumOf(["false", ""]), { type: "null" }] },
16
+ }, { description: "Hiring only. Agencies are always excluded: is_agency accepts false, \"false\" or blank." }),
17
+ };
18
+ }
19
+ /** Native JSON Schema constraints supplement the shared property's type dialect.
20
+ * The serving reader independently enforces these same existing restrictions. */
21
+ export function signalSearchCompatibilityRules() {
22
+ // Keep the cross-field condition at the object root without a root-level
23
+ // combinator: marketplace clients require tools to expose an object schema.
24
+ // The agency restriction belongs to the company property above.
25
+ return {
26
+ if: {
27
+ required: ["filters"],
28
+ properties: { filters: { anyOf: [{ required: ["job"] }, { required: ["company"] }] } },
29
+ },
30
+ then: { required: ["family"], properties: { family: { const: "hiring" } } },
31
+ };
32
+ }
@@ -92,6 +92,20 @@ export const DNC_BUILTIN_RULES = [
92
92
  compliance: true,
93
93
  group: "automatic",
94
94
  },
95
+ {
96
+ // Added with sending-domain bulk mail (2026-10-20): the mailbox provider's
97
+ // feedback loop reports a spam complaint. A new rule that defaults to
98
+ // `suppress` on purpose: a complaint is the strongest opt-out signal there is.
99
+ key: "email_complaint",
100
+ label: "Spam complaint",
101
+ description: "The recipient marked one of your emails as spam.",
102
+ ledger: "email",
103
+ defaultEffect: "suppress",
104
+ defaultReason: "complaint",
105
+ allowedEffects: ALL_EFFECTS,
106
+ compliance: true,
107
+ group: "automatic",
108
+ },
95
109
  {
96
110
  key: "email_optout_phrase",
97
111
  label: "Opt-out wording in an email reply",
@@ -0,0 +1,150 @@
1
+ /**
2
+ * How a Sequence's email steps leave OXYGEN (`settings.email_delivery`).
3
+ *
4
+ * `inboxes` is the cold-email model every Sequence had before 2026-10-20: each
5
+ * lead is assigned a person's warmed mailbox, capped and spaced per inbox.
6
+ * `sending_domain` is bulk mail to people who subscribed (newsletters, the
7
+ * onboarding drip): one brand From address on a verified sending domain, sent
8
+ * through Amazon SES, with a Reply-To mailbox for answers. A sending domain is
9
+ * an account in Sequencer → Accounts, not a third mailbox provider, so nothing
10
+ * here touches mailbox rotation; the dispatcher routes this mode to its own lane.
11
+ *
12
+ * Pure and browser-safe: the web Delivery control, the CLI/MCP contracts, the
13
+ * tenant settings validator and the dispatcher all read the same parser.
14
+ */
15
+ export declare const SEQUENCE_EMAIL_DELIVERY_MODES: readonly ["inboxes", "sending_domain"];
16
+ export type SequenceEmailDeliveryMode = typeof SEQUENCE_EMAIL_DELIVERY_MODES[number];
17
+ export type SendingDomainEmailDelivery = {
18
+ mode: "sending_domain";
19
+ sending_domain_id: string;
20
+ from_address: string;
21
+ from_name: string | null;
22
+ reply_to_mailbox_id: string | null;
23
+ subscription_list_id: string;
24
+ };
25
+ export type SequenceEmailDelivery = {
26
+ mode: "inboxes";
27
+ } | SendingDomainEmailDelivery;
28
+ export declare class EmailDeliverySettingError extends Error {
29
+ readonly field: string;
30
+ constructor(field: string, message: string);
31
+ }
32
+ /**
33
+ * Parse a stored or submitted `email_delivery` value. Absent/null means
34
+ * `inboxes` (every existing Sequence). Throws EmailDeliverySettingError naming
35
+ * the bad field; callers map it to their own validation error.
36
+ */
37
+ export declare function parseSequenceEmailDelivery(value: unknown): SequenceEmailDelivery;
38
+ /** The delivery a stored settings object resolves to; an unreadable value fails closed to inboxes' validator. */
39
+ export declare function readSequenceEmailDelivery(settings: unknown): SequenceEmailDelivery;
40
+ /** True when the settings route email steps through a sending domain. Never throws. */
41
+ export declare function sendingDomainDeliveryOf(settings: unknown): SendingDomainEmailDelivery | null;
42
+ /** The domain part of an address, lowercased. */
43
+ export declare function emailAddressDomain(address: string): string;
44
+ /** A From address is allowed on a sending domain when it is on the identity (or a subdomain) and listed, if a list exists. */
45
+ export declare function fromAddressAllowedOnSendingDomain(input: {
46
+ fromAddress: string;
47
+ identity: string;
48
+ allowedFromAddresses: readonly string[];
49
+ }): boolean;
50
+ /** Step kinds a sending-domain Sequence may hold: email, and control steps that send nothing. */
51
+ export declare const SENDING_DOMAIN_STEP_KINDS: readonly ["email_send", "wait", "wait_for_signal", "branch", "stop"];
52
+ export type SendingDomainSequenceIssue = {
53
+ code: "sending_domain_step_kind" | "sending_domain_subject_required" | "sending_domain_unsubscribe_required";
54
+ message: string;
55
+ step_index?: number;
56
+ };
57
+ /**
58
+ * The launch rules of a sending-domain Sequence. Bulk mail to subscribers is a
59
+ * different product from a cold thread: every email carries its own subject (no
60
+ * threaded follow-ups), and every email carries the visible unsubscribe footer
61
+ * and the RFC 8058 one-click headers. Returns every problem, empty when valid.
62
+ */
63
+ export declare function sendingDomainSequenceIssues(input: {
64
+ steps: readonly unknown[];
65
+ settings: Record<string, unknown>;
66
+ }): SendingDomainSequenceIssue[];
67
+ /**
68
+ * Sending-domain settings are always saved with the unsubscribe link and headers
69
+ * on and with table auto-enrollment off: a newsletter's audience is its
70
+ * subscription list, never every new row of a table.
71
+ */
72
+ export declare function withSendingDomainSettingDefaults(settings: Record<string, unknown>): Record<string, unknown>;
73
+ /** One DNS record a sending domain needs: Easy DKIM CNAMEs and the custom MAIL FROM MX + SPF. */
74
+ export type SendingDomainDnsRecord = {
75
+ purpose: "dkim" | "mail_from_mx" | "mail_from_spf";
76
+ type: "CNAME" | "MX" | "TXT";
77
+ name: string;
78
+ content: string;
79
+ priority?: number;
80
+ };
81
+ export declare const DEFAULT_SES_REGION = "eu-central-1";
82
+ /**
83
+ * The records Amazon SES needs for a sending domain: three Easy DKIM CNAMEs
84
+ * (`<token>._domainkey.<identity>` → `<token>.dkim.amazonses.com`) and, for the
85
+ * custom MAIL FROM subdomain, an MX to the regional feedback endpoint plus an
86
+ * SPF TXT. DMARC stays the zone owner's record and is never planned here.
87
+ */
88
+ export declare function sendingDomainDnsRecords(input: {
89
+ identity: string;
90
+ dkimTokens: readonly string[];
91
+ mailFromDomain: string | null;
92
+ region?: string | null;
93
+ }): SendingDomainDnsRecord[];
94
+ export declare const SES_TRANSACTIONAL_RESERVE_FRACTION = 0.2;
95
+ export declare const SES_BULK_RATE_FRACTION = 0.8;
96
+ /** AWS list price for SES outbound email, USD per 1,000 messages (à la carte). */
97
+ export declare const SES_USD_PER_THOUSAND = 0.1;
98
+ /** The account facts capacity needs; shape-compatible with the SES transport's account snapshot. */
99
+ export type SendingAccountQuota = {
100
+ enforcementStatus: string;
101
+ sendingEnabled: boolean;
102
+ productionAccessEnabled: boolean;
103
+ max24HourSend: number;
104
+ maxSendRate: number;
105
+ sentLast24Hours: number;
106
+ };
107
+ /** True when the SES account may send bulk mail at all. */
108
+ export declare function sendingAccountHealthy(account: SendingAccountQuota | null): boolean;
109
+ /**
110
+ * Sends one sending domain may still reserve today: the smaller of its own daily
111
+ * ceiling and the account's remaining 24-hour quota after the transactional
112
+ * reserve. No account snapshot, or an account that is not healthy, is zero.
113
+ */
114
+ export declare function sendingDomainRemainingCapacity(input: {
115
+ dailyCeiling: number;
116
+ sentTodayOnDomain: number;
117
+ account: SendingAccountQuota | null;
118
+ }): number;
119
+ /** Milliseconds between bulk sends so one consumer stays at or under 80% of MaxSendRate. */
120
+ export declare function bulkSendIntervalMs(maxSendRate: number): number;
121
+ /** Estimated AWS cost of a send count, USD rounded up to the cent. */
122
+ export declare function estimateSesCostUsd(sends: number): number;
123
+ export declare const RELEASE_SLICE_FRACTION = 0.05;
124
+ export declare const RELEASE_SLICE_MINIMUM = 50;
125
+ export declare const RELEASE_SLICE_HOLD_MS: number;
126
+ export declare const RELEASE_MAX_HARD_BOUNCE_RATE = 0.02;
127
+ export declare const RELEASE_MAX_COMPLAINT_RATE = 0.0008;
128
+ export declare const RELEASE_BLOCK_HARD_BOUNCE_RATE = 0.05;
129
+ export declare const RELEASE_BLOCK_COMPLAINT_RATE = 0.003;
130
+ /** Recipients the first slice sends to: 5% of the audience, at least 50, never more than the audience. */
131
+ export declare function releaseSliceSize(audience: number): number;
132
+ export type ReleaseSliceDecision = "hold" | "release" | "review" | "block";
133
+ /**
134
+ * Decide what happens after the slice. `hold` until the slice has sent and the
135
+ * hold time passed; then `release` when both rates are within the release
136
+ * thresholds, `block` when either crosses a block threshold (checked at any
137
+ * time, including during the hold), else `review`: the rest waits for a person.
138
+ */
139
+ export declare function evaluateReleaseSlice(input: {
140
+ sliceSent: number;
141
+ sliceSize: number;
142
+ hardBounces: number;
143
+ complaints: number;
144
+ holdUntil: Date | null;
145
+ now: Date;
146
+ }): {
147
+ decision: ReleaseSliceDecision;
148
+ hardBounceRate: number;
149
+ complaintRate: number;
150
+ };
@@ -0,0 +1,246 @@
1
+ /**
2
+ * How a Sequence's email steps leave OXYGEN (`settings.email_delivery`).
3
+ *
4
+ * `inboxes` is the cold-email model every Sequence had before 2026-10-20: each
5
+ * lead is assigned a person's warmed mailbox, capped and spaced per inbox.
6
+ * `sending_domain` is bulk mail to people who subscribed (newsletters, the
7
+ * onboarding drip): one brand From address on a verified sending domain, sent
8
+ * through Amazon SES, with a Reply-To mailbox for answers. A sending domain is
9
+ * an account in Sequencer → Accounts, not a third mailbox provider, so nothing
10
+ * here touches mailbox rotation; the dispatcher routes this mode to its own lane.
11
+ *
12
+ * Pure and browser-safe: the web Delivery control, the CLI/MCP contracts, the
13
+ * tenant settings validator and the dispatcher all read the same parser.
14
+ */
15
+ export const SEQUENCE_EMAIL_DELIVERY_MODES = ["inboxes", "sending_domain"];
16
+ export class EmailDeliverySettingError extends Error {
17
+ field;
18
+ constructor(field, message) {
19
+ super(message);
20
+ this.field = field;
21
+ this.name = "EmailDeliverySettingError";
22
+ }
23
+ }
24
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
25
+ const EMAIL_RE = /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/;
26
+ function requiredUuid(record, key) {
27
+ const value = typeof record[key] === "string" ? record[key].trim() : "";
28
+ if (!UUID_RE.test(value))
29
+ throw new EmailDeliverySettingError(`email_delivery.${key}`, `email_delivery.${key} must be an id.`);
30
+ return value.toLowerCase();
31
+ }
32
+ function optionalUuid(record, key) {
33
+ const value = record[key];
34
+ if (value === undefined || value === null || value === "")
35
+ return null;
36
+ return requiredUuid(record, key);
37
+ }
38
+ /**
39
+ * Parse a stored or submitted `email_delivery` value. Absent/null means
40
+ * `inboxes` (every existing Sequence). Throws EmailDeliverySettingError naming
41
+ * the bad field; callers map it to their own validation error.
42
+ */
43
+ export function parseSequenceEmailDelivery(value) {
44
+ if (value === undefined || value === null)
45
+ return { mode: "inboxes" };
46
+ if (typeof value !== "object" || Array.isArray(value)) {
47
+ throw new EmailDeliverySettingError("email_delivery", "email_delivery must be an object.");
48
+ }
49
+ const record = value;
50
+ const mode = record.mode ?? "inboxes";
51
+ if (mode === "inboxes")
52
+ return { mode: "inboxes" };
53
+ if (mode !== "sending_domain") {
54
+ throw new EmailDeliverySettingError("email_delivery.mode", "email_delivery.mode must be inboxes or sending_domain.");
55
+ }
56
+ const fromAddress = typeof record.from_address === "string" ? record.from_address.trim().toLowerCase() : "";
57
+ if (!EMAIL_RE.test(fromAddress) || /[\r\n]/.test(fromAddress)) {
58
+ throw new EmailDeliverySettingError("email_delivery.from_address", "email_delivery.from_address must be an email address.");
59
+ }
60
+ const rawName = typeof record.from_name === "string" ? record.from_name.trim() : "";
61
+ if (rawName.length > 120 || /[\r\n]/.test(rawName)) {
62
+ throw new EmailDeliverySettingError("email_delivery.from_name", "email_delivery.from_name must be one line of at most 120 characters.");
63
+ }
64
+ return {
65
+ mode: "sending_domain",
66
+ sending_domain_id: requiredUuid(record, "sending_domain_id"),
67
+ from_address: fromAddress,
68
+ from_name: rawName || null,
69
+ reply_to_mailbox_id: optionalUuid(record, "reply_to_mailbox_id"),
70
+ subscription_list_id: requiredUuid(record, "subscription_list_id"),
71
+ };
72
+ }
73
+ /** The delivery a stored settings object resolves to; an unreadable value fails closed to inboxes' validator. */
74
+ export function readSequenceEmailDelivery(settings) {
75
+ const record = settings && typeof settings === "object" && !Array.isArray(settings)
76
+ ? settings : {};
77
+ return parseSequenceEmailDelivery(record.email_delivery);
78
+ }
79
+ /** True when the settings route email steps through a sending domain. Never throws. */
80
+ export function sendingDomainDeliveryOf(settings) {
81
+ try {
82
+ const delivery = readSequenceEmailDelivery(settings);
83
+ return delivery.mode === "sending_domain" ? delivery : null;
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ }
89
+ /** The domain part of an address, lowercased. */
90
+ export function emailAddressDomain(address) {
91
+ const at = address.lastIndexOf("@");
92
+ return at >= 0 ? address.slice(at + 1).trim().toLowerCase() : "";
93
+ }
94
+ /** A From address is allowed on a sending domain when it is on the identity (or a subdomain) and listed, if a list exists. */
95
+ export function fromAddressAllowedOnSendingDomain(input) {
96
+ const from = input.fromAddress.trim().toLowerCase();
97
+ const domain = emailAddressDomain(from);
98
+ const identity = input.identity.trim().toLowerCase();
99
+ if (!domain || !(domain === identity || domain.endsWith(`.${identity}`)))
100
+ return false;
101
+ return input.allowedFromAddresses.length === 0
102
+ || input.allowedFromAddresses.some((allowed) => allowed.trim().toLowerCase() === from);
103
+ }
104
+ /** Step kinds a sending-domain Sequence may hold: email, and control steps that send nothing. */
105
+ export const SENDING_DOMAIN_STEP_KINDS = ["email_send", "wait", "wait_for_signal", "branch", "stop"];
106
+ function subjectOf(step) {
107
+ return typeof step.subject_template === "string" ? step.subject_template.trim() : "";
108
+ }
109
+ /**
110
+ * The launch rules of a sending-domain Sequence. Bulk mail to subscribers is a
111
+ * different product from a cold thread: every email carries its own subject (no
112
+ * threaded follow-ups), and every email carries the visible unsubscribe footer
113
+ * and the RFC 8058 one-click headers. Returns every problem, empty when valid.
114
+ */
115
+ export function sendingDomainSequenceIssues(input) {
116
+ const issues = [];
117
+ input.steps.forEach((raw, index) => {
118
+ const step = (raw && typeof raw === "object" ? raw : {});
119
+ const kind = typeof step.kind === "string" ? step.kind : "";
120
+ if (!SENDING_DOMAIN_STEP_KINDS.includes(kind)) {
121
+ issues.push({ code: "sending_domain_step_kind", step_index: index,
122
+ message: `Step ${index + 1} (${kind || "unknown"}) cannot send through a sending domain. Use email, wait or branch steps.` });
123
+ return;
124
+ }
125
+ if (kind !== "email_send")
126
+ return;
127
+ const variants = Array.isArray(step.variants) ? step.variants : [];
128
+ const variantMissing = variants.some((variant) => variant && typeof variant === "object"
129
+ && "subject_template" in variant && !subjectOf(variant));
130
+ if (!subjectOf(step) || variantMissing) {
131
+ issues.push({ code: "sending_domain_subject_required", step_index: index,
132
+ message: `Email step ${index + 1} needs a subject. Sending-domain emails never continue a thread.` });
133
+ }
134
+ });
135
+ if (input.settings.include_unsubscribe_link !== true || input.settings.include_unsubscribe_headers !== true) {
136
+ issues.push({ code: "sending_domain_unsubscribe_required",
137
+ message: "Sending-domain email must carry the unsubscribe link and one-click headers." });
138
+ }
139
+ return issues;
140
+ }
141
+ /**
142
+ * Sending-domain settings are always saved with the unsubscribe link and headers
143
+ * on and with table auto-enrollment off: a newsletter's audience is its
144
+ * subscription list, never every new row of a table.
145
+ */
146
+ export function withSendingDomainSettingDefaults(settings) {
147
+ if (!sendingDomainDeliveryOf(settings))
148
+ return settings;
149
+ return { ...settings, include_unsubscribe_link: true, include_unsubscribe_headers: true, auto_enroll_table_rows: false };
150
+ }
151
+ export const DEFAULT_SES_REGION = "eu-central-1";
152
+ /**
153
+ * The records Amazon SES needs for a sending domain: three Easy DKIM CNAMEs
154
+ * (`<token>._domainkey.<identity>` → `<token>.dkim.amazonses.com`) and, for the
155
+ * custom MAIL FROM subdomain, an MX to the regional feedback endpoint plus an
156
+ * SPF TXT. DMARC stays the zone owner's record and is never planned here.
157
+ */
158
+ export function sendingDomainDnsRecords(input) {
159
+ const identity = input.identity.trim().toLowerCase();
160
+ const region = input.region?.trim() || DEFAULT_SES_REGION;
161
+ const records = input.dkimTokens
162
+ .map((token) => token.trim())
163
+ .filter((token) => /^[A-Za-z0-9_-]{1,128}$/.test(token))
164
+ .map((token) => ({ purpose: "dkim", type: "CNAME", name: `${token}._domainkey.${identity}`, content: `${token}.dkim.amazonses.com` }));
165
+ if (input.mailFromDomain) {
166
+ const mailFrom = input.mailFromDomain.trim().toLowerCase();
167
+ records.push({ purpose: "mail_from_mx", type: "MX", name: mailFrom, content: `feedback-smtp.${region}.amazonses.com`, priority: 10 }, { purpose: "mail_from_spf", type: "TXT", name: mailFrom, content: "v=spf1 include:amazonses.com ~all" });
168
+ }
169
+ return records;
170
+ }
171
+ // ---------------------------------------------------------------------------
172
+ // Capacity and pacing. The SES account quota is shared with transactional mail,
173
+ // so bulk mail never plans past 80% of it: 20% stays for receipts and notices.
174
+ // ---------------------------------------------------------------------------
175
+ export const SES_TRANSACTIONAL_RESERVE_FRACTION = 0.2;
176
+ export const SES_BULK_RATE_FRACTION = 0.8;
177
+ /** AWS list price for SES outbound email, USD per 1,000 messages (à la carte). */
178
+ export const SES_USD_PER_THOUSAND = 0.1;
179
+ /** True when the SES account may send bulk mail at all. */
180
+ export function sendingAccountHealthy(account) {
181
+ return Boolean(account && account.sendingEnabled && account.enforcementStatus.toUpperCase() === "HEALTHY");
182
+ }
183
+ /**
184
+ * Sends one sending domain may still reserve today: the smaller of its own daily
185
+ * ceiling and the account's remaining 24-hour quota after the transactional
186
+ * reserve. No account snapshot, or an account that is not healthy, is zero.
187
+ */
188
+ export function sendingDomainRemainingCapacity(input) {
189
+ if (!sendingAccountHealthy(input.account))
190
+ return 0;
191
+ const account = input.account;
192
+ const bulkQuota = Math.floor(Math.max(0, account.max24HourSend) * (1 - SES_TRANSACTIONAL_RESERVE_FRACTION));
193
+ const accountRemaining = bulkQuota - Math.max(0, account.sentLast24Hours);
194
+ const domainRemaining = Math.max(0, Math.floor(input.dailyCeiling)) - Math.max(0, input.sentTodayOnDomain);
195
+ return Math.max(0, Math.min(accountRemaining, domainRemaining));
196
+ }
197
+ /** Milliseconds between bulk sends so one consumer stays at or under 80% of MaxSendRate. */
198
+ export function bulkSendIntervalMs(maxSendRate) {
199
+ if (!Number.isFinite(maxSendRate) || maxSendRate <= 0)
200
+ return 60_000;
201
+ return Math.ceil(1000 / (maxSendRate * SES_BULK_RATE_FRACTION));
202
+ }
203
+ /** Estimated AWS cost of a send count, USD rounded up to the cent. */
204
+ export function estimateSesCostUsd(sends) {
205
+ const count = Math.max(0, Math.floor(sends));
206
+ return Math.ceil(count * SES_USD_PER_THOUSAND * 100 / 1000) / 100;
207
+ }
208
+ // ---------------------------------------------------------------------------
209
+ // Release slice (clean-room). The first live start of a sending-domain Sequence
210
+ // sends a small slice, holds, and releases the rest only when the slice's hard
211
+ // bounces and complaints stay low. Thresholds are ours; see OXYGEN_PRODUCT.md.
212
+ // ---------------------------------------------------------------------------
213
+ export const RELEASE_SLICE_FRACTION = 0.05;
214
+ export const RELEASE_SLICE_MINIMUM = 50;
215
+ export const RELEASE_SLICE_HOLD_MS = 3 * 60 * 60 * 1000;
216
+ export const RELEASE_MAX_HARD_BOUNCE_RATE = 0.02;
217
+ export const RELEASE_MAX_COMPLAINT_RATE = 0.0008;
218
+ export const RELEASE_BLOCK_HARD_BOUNCE_RATE = 0.05;
219
+ export const RELEASE_BLOCK_COMPLAINT_RATE = 0.003;
220
+ /** Recipients the first slice sends to: 5% of the audience, at least 50, never more than the audience. */
221
+ export function releaseSliceSize(audience) {
222
+ const total = Math.max(0, Math.floor(audience));
223
+ return Math.min(total, Math.max(RELEASE_SLICE_MINIMUM, Math.ceil(total * RELEASE_SLICE_FRACTION)));
224
+ }
225
+ /**
226
+ * Decide what happens after the slice. `hold` until the slice has sent and the
227
+ * hold time passed; then `release` when both rates are within the release
228
+ * thresholds, `block` when either crosses a block threshold (checked at any
229
+ * time, including during the hold), else `review`: the rest waits for a person.
230
+ */
231
+ export function evaluateReleaseSlice(input) {
232
+ const sent = Math.max(0, input.sliceSent);
233
+ const hardBounceRate = sent > 0 ? Math.max(0, input.hardBounces) / sent : 0;
234
+ const complaintRate = sent > 0 ? Math.max(0, input.complaints) / sent : 0;
235
+ const rates = { hardBounceRate, complaintRate };
236
+ if (sent > 0 && (hardBounceRate > RELEASE_BLOCK_HARD_BOUNCE_RATE || complaintRate > RELEASE_BLOCK_COMPLAINT_RATE)) {
237
+ return { decision: "block", ...rates };
238
+ }
239
+ if (sent < input.sliceSize || !input.holdUntil || input.now.getTime() < input.holdUntil.getTime()) {
240
+ return { decision: "hold", ...rates };
241
+ }
242
+ if (hardBounceRate <= RELEASE_MAX_HARD_BOUNCE_RATE && complaintRate <= RELEASE_MAX_COMPLAINT_RATE) {
243
+ return { decision: "release", ...rates };
244
+ }
245
+ return { decision: "review", ...rates };
246
+ }
@@ -54,6 +54,7 @@ type DsnInput = {
54
54
  bodyText?: string | null;
55
55
  bodyHtml?: string | null;
56
56
  };
57
+ export declare function readDeliveryStatusNotificationBody(input: Pick<DsnInput, "bodyText" | "bodyHtml">): string;
57
58
  /**
58
59
  * Classify one stored inbound message as a delivery-status notification.
59
60
  *
@@ -80,7 +80,7 @@ function dsnHtmlText(html) {
80
80
  }
81
81
  return parts.join("");
82
82
  }
83
- function readDsnBody(input) {
83
+ export function readDeliveryStatusNotificationBody(input) {
84
84
  if (typeof input.bodyText === "string" && input.bodyText.trim())
85
85
  return input.bodyText;
86
86
  if (typeof input.bodyHtml !== "string")
@@ -121,10 +121,11 @@ const ENHANCED_CODE = /\b([2-5]\.\d{1,3}\.\d{1,3})\b/;
121
121
  const SMTP_REPLY_CODE = /\b([2-5]\d{2})\b/;
122
122
  const REPUTATION_BLOCK = /\b5\.7\.\d{1,3}\b/;
123
123
  const MAILBOX_FULL_CODE = /\b[45]\.2\.2\b/;
124
- const NO_SUCH_USER_CODE = /\b5\.1\.\d{1,3}\b/;
124
+ // 5.1.0 is a generic address-status failure, not proof the recipient is invalid.
125
+ const NO_SUCH_USER_CODE = /\b5\.1\.(?:[123]|10)\b/;
125
126
  const TRANSIENT_CODE = /(\b4\.\d{1,3}\.\d{1,3}\b|\b4\d{2}\b)/;
126
127
  const MAILBOX_FULL_TEXT = /(mailbox (?:is )?full|over quota|quota exceeded|insufficient storage)/i;
127
- const NO_SUCH_USER_TEXT = /(does ?n[o']?t exist|nosuchuser|no such user|user unknown|unknown user|address not found|recipient (?:address )?(?:not found|rejected)|invalid recipient)/i;
128
+ const NO_SUCH_USER_TEXT = /((?:address|account|recipient|user|mailbox|domain)[^\n.]{0,100}(?:does ?n[o']?t exist|could(?: not|n['’]t) be found)|nosuchuser|no such (?:user|recipient|mailbox)|(?:user|recipient) unknown|unknown (?:user|recipient)|(?:recipient |email )?address not found|recipient (?:address )?not found|invalid (?:recipient|email address)|no (?:valid )?MX(?: (?:record|records))?\b|\bNXDOMAIN\b)/i;
128
129
  const PROVIDER_REJECTED_TEXT = /(message rejected|has been blocked|\bblocked\b|\bbanned\b|\bspam\b|local policy violation|unsolicited|reputation)/i;
129
130
  const TRANSIENT_TEXT = /(temporar|will retry|try again later|rate ?limit|throttl)/i;
130
131
  // Greedy address capture: the character class already stops at whitespace and
@@ -166,10 +167,12 @@ function readKind(subject, haystack, hasBody) {
166
167
  return "provider_rejected";
167
168
  if (MAILBOX_FULL_CODE.test(haystack) || MAILBOX_FULL_TEXT.test(haystack))
168
169
  return "mailbox_full";
169
- if (NO_SUCH_USER_CODE.test(haystack) || NO_SUCH_USER_TEXT.test(haystack))
170
+ if (NO_SUCH_USER_CODE.test(haystack))
170
171
  return "no_such_user";
171
172
  if (PROVIDER_REJECTED_TEXT.test(haystack))
172
173
  return "provider_rejected";
174
+ if (NO_SUCH_USER_TEXT.test(haystack))
175
+ return "no_such_user";
173
176
  if (TRANSIENT_CODE.test(haystack) || TRANSIENT_TEXT.test(haystack))
174
177
  return "delayed";
175
178
  // Nothing matched AND there was no body to match against: the sync stored the
@@ -186,7 +189,7 @@ function readKind(subject, haystack, hasBody) {
186
189
  export function classifyDeliveryStatusNotification(input) {
187
190
  const from = typeof input.fromAddress === "string" ? input.fromAddress : "";
188
191
  const subject = typeof input.subject === "string" ? input.subject : "";
189
- const body = readDsnBody(input);
192
+ const body = readDeliveryStatusNotificationBody(input);
190
193
  if (!DSN_FROM_PATTERN.test(from) && !DSN_SUBJECT_PATTERN.test(subject))
191
194
  return null;
192
195
  const haystack = `${subject}\n${body}`;
@@ -206,7 +209,7 @@ export function classifyDeliveryStatusNotification(input) {
206
209
  export function isGoogleDeliveryStatusRejection(input) {
207
210
  if (classifyDeliveryStatusNotification(input)?.kind !== "provider_rejected")
208
211
  return false;
209
- const body = readDsnBody(input);
212
+ const body = readDeliveryStatusNotificationBody(input);
210
213
  const remote = /^Remote-MTA:\s*(?:dns|rfc822);\s*([a-z0-9.-]+)/im.exec(body)?.[1];
211
214
  if (remote) {
212
215
  return /(?:^|\.)(?:google|googlemail)\.com$/i.test(remote.replace(/\.$/, ""));
@@ -0,0 +1,29 @@
1
+ /** Last-known mailbox scores are advisory; coverage and evidence age qualify the fleet. */
2
+ export type EmailHealthState = "unknown" | "healthy" | "degraded" | "critical";
3
+ export declare const HEALTHY_SCORE_MIN = 80;
4
+ export declare const DEGRADED_SCORE_MIN = 50;
5
+ export declare function deriveEmailHealthState(score: number | null | undefined): EmailHealthState;
6
+ type HealthReading = {
7
+ healthScore: number | null;
8
+ healthState: EmailHealthState;
9
+ healthFreshness?: "current" | "stale" | "unmeasured";
10
+ };
11
+ export type FleetHealthSummary = {
12
+ /** Equal weight for each mailbox with a finite last-known score. Unknowns never become zeros. */
13
+ score: number | null;
14
+ scoreState: EmailHealthState;
15
+ fleetState: EmailHealthState;
16
+ worstMailboxState: EmailHealthState;
17
+ total: number;
18
+ scored: number;
19
+ current: number;
20
+ stale: number;
21
+ unverified: number;
22
+ unscored: number;
23
+ degraded: number;
24
+ critical: number;
25
+ coverage: "none" | "partial" | "complete";
26
+ freshness: "unmeasured" | "current" | "stale" | "mixed";
27
+ };
28
+ export declare function summarizeFleetHealth(readings: readonly HealthReading[]): FleetHealthSummary;
29
+ export {};