@oxygen-agent/cli 1.1014.1465 → 1.1014.1598
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/command-families/program.js +2 -0
- package/dist/command-manifest.js +12 -2
- package/dist/commands/budget.js +2 -2
- package/dist/commands/capabilities.js +3 -2
- package/dist/commands/columns.js +1 -1
- package/dist/commands/companies.js +3 -0
- package/dist/commands/domains.js +122 -5
- package/dist/commands/feedback.js +1 -1
- package/dist/commands/feeds.js +34 -17
- package/dist/commands/mailboxes.js +19 -9
- package/dist/commands/publishing.js +1 -1
- package/dist/commands/senders.js +2 -2
- package/dist/commands/sequences.js +75 -12
- package/dist/commands/subscriptions.d.ts +10 -0
- package/dist/commands/subscriptions.js +203 -0
- package/dist/commands/tags.js +4 -4
- package/dist/customer-operation-commands.js +17 -0
- package/dist/mailbox-fleet-output.d.ts +3 -0
- package/dist/mailbox-fleet-output.js +80 -0
- package/dist/native-chat-host.d.ts +2 -0
- package/dist/native-chat-host.js +6 -0
- package/dist/workspace-placement-commands.js +67 -2
- package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +9 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +59 -11
- package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +2 -2
- package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +18 -0
- package/node_modules/@oxygen/shared/dist/contracts/guided-activation.js +4 -4
- package/node_modules/@oxygen/shared/dist/contracts/native-chat.d.ts +120 -0
- package/node_modules/@oxygen/shared/dist/contracts/native-chat.js +35 -0
- package/node_modules/@oxygen/shared/dist/contracts/sender-limits.d.ts +9 -0
- package/node_modules/@oxygen/shared/dist/contracts/sender-limits.js +6 -1
- package/node_modules/@oxygen/shared/dist/contracts/sequence-list.d.ts +5 -0
- package/node_modules/@oxygen/shared/dist/contracts/sequence-list.js +2 -1
- package/node_modules/@oxygen/shared/dist/contracts/signal-search-compatibility.d.ts +78 -0
- package/node_modules/@oxygen/shared/dist/contracts/signal-search-compatibility.js +32 -0
- package/node_modules/@oxygen/shared/dist/dnc-rules.js +14 -0
- package/node_modules/@oxygen/shared/dist/email-delivery.d.ts +150 -0
- package/node_modules/@oxygen/shared/dist/email-delivery.js +246 -0
- package/node_modules/@oxygen/shared/dist/email-dsn.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/email-dsn.js +51 -14
- package/node_modules/@oxygen/shared/dist/email-health-summary.d.ts +29 -0
- package/node_modules/@oxygen/shared/dist/email-health-summary.js +28 -0
- package/node_modules/@oxygen/shared/dist/email-subscriptions.d.ts +50 -0
- package/node_modules/@oxygen/shared/dist/email-subscriptions.js +80 -0
- package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.d.ts +28 -0
- package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.js +47 -0
- package/node_modules/@oxygen/shared/dist/error-redaction.d.ts +4 -16
- package/node_modules/@oxygen/shared/dist/error-redaction.js +8 -17
- package/node_modules/@oxygen/shared/dist/guided-activation.d.ts +15 -1
- package/node_modules/@oxygen/shared/dist/guided-activation.js +3 -1
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/mailbox-standby-rollout.d.ts +5 -0
- package/node_modules/@oxygen/shared/dist/mailbox-standby-rollout.js +9 -0
- package/node_modules/@oxygen/shared/dist/mailbox-standby.d.ts +43 -0
- package/node_modules/@oxygen/shared/dist/mailbox-standby.js +52 -0
- package/node_modules/@oxygen/shared/dist/native-chat-discovery.d.ts +5 -0
- package/node_modules/@oxygen/shared/dist/native-chat-discovery.js +27 -0
- package/node_modules/@oxygen/shared/dist/native-chat.d.ts +124 -0
- package/node_modules/@oxygen/shared/dist/native-chat.js +1 -0
- package/node_modules/@oxygen/shared/dist/onboarding-plays.d.ts +14 -3
- package/node_modules/@oxygen/shared/dist/onboarding-plays.js +26 -13
- package/node_modules/@oxygen/shared/dist/onboarding-research.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/onboarding-research.js +8 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-events.js +6 -0
- package/node_modules/@oxygen/shared/dist/select-options.js +1 -0
- package/node_modules/@oxygen/shared/dist/sequence-failures.js +6 -5
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/sequences.js +44 -2
- package/node_modules/@oxygen/shared/dist/tags.d.ts +7 -3
- package/node_modules/@oxygen/shared/dist/tags.js +31 -6
- 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/workspace-placement-bulk.d.ts +80 -0
- package/node_modules/@oxygen/shared/dist/workspace-placement-bulk.js +68 -0
- package/node_modules/@oxygen/shared/dist/workspace-placement-view.d.ts +2 -1
- package/node_modules/@oxygen/shared/dist/workspace-placement.d.ts +11 -1
- package/node_modules/@oxygen/shared/dist/workspace-placement.js +15 -4
- package/node_modules/@oxygen/shared/dist/write-rollout.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/write-rollout.js +59 -0
- package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +176 -2
- package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.js +50 -1
- package/node_modules/@oxygen/workflows/dist/index.js +108 -3
- package/package.json +1 -1
|
@@ -80,7 +80,7 @@ function dsnHtmlText(html) {
|
|
|
80
80
|
}
|
|
81
81
|
return parts.join("");
|
|
82
82
|
}
|
|
83
|
-
function
|
|
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")
|
|
@@ -117,14 +117,37 @@ const MICROSOFT_BODY = /(microsoft exchange|exchange server|office\s?365)/i;
|
|
|
117
117
|
// Enhanced status code (RFC 3463): class.subject.detail, where subject/detail can
|
|
118
118
|
// be multi-digit (Google emits 5.7.708). Kept separate from the bare 3-digit SMTP
|
|
119
119
|
// reply so a caller can tell "550" from "5.7.1".
|
|
120
|
-
const ENHANCED_CODE = /\b
|
|
120
|
+
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
|
+
// Reported status fields outrank prose and SMTP examples in quoted help text.
|
|
123
|
+
// Exchange's 5.4.14 loop report includes a 5.1.1 help article and generic
|
|
124
|
+
// "Recipient Unknown" instructions before its actual status/diagnostic.
|
|
125
|
+
const REPORTED_ENHANCED_CODES = [
|
|
126
|
+
/(?:^|\n)[ \t]*Status[ \t]*:[ \t]*([2-5]\.\d{1,3}\.\d{1,3})\b/im,
|
|
127
|
+
/(?:^|\n)[ \t]*Diagnostic-Code[ \t]*:[ \t]*smtp[ \t]*;[ \t]*(?:[2-5]\d{2}[ -]+)?([2-5]\.\d{1,3}\.\d{1,3})\b/im,
|
|
128
|
+
/(?:^|\n)[ \t]*(?:Status[ \t]+code[ \t]*:?|Error[ \t]*:)[ \t]*(?:[2-5]\d{2}[ -]+)?([2-5]\.\d{1,3}\.\d{1,3})\b/im,
|
|
129
|
+
/\b(?:the response(?: from the remote server)? was|remote server returned)[ \t]*:?[ \t]*['"]?(?:[2-5]\d{2}[ -]+)?([2-5]\.\d{1,3}\.\d{1,3})\b/i,
|
|
130
|
+
/\b[2-5]\d{2}[ -]+([2-5]\.\d{1,3}\.\d{1,3})\b/,
|
|
131
|
+
/(?:^|\n)[ \t]*([2-5]\.\d{1,3}\.\d{1,3})\b/,
|
|
132
|
+
];
|
|
133
|
+
function readEnhancedStatusCode(haystack) {
|
|
134
|
+
for (const pattern of REPORTED_ENHANCED_CODES) {
|
|
135
|
+
const code = pattern.exec(haystack)?.[1];
|
|
136
|
+
if (code)
|
|
137
|
+
return code;
|
|
138
|
+
}
|
|
139
|
+
// Unscoped codes can retain a policy/quota/routing verdict, but cannot prove
|
|
140
|
+
// an invalid destination: that code may only be in a generic help article.
|
|
141
|
+
return haystack.match(/\b[2-5]\.\d{1,3}\.\d{1,3}\b/g)
|
|
142
|
+
?.find(code => !NO_SUCH_USER_CODE.test(code)) ?? null;
|
|
143
|
+
}
|
|
122
144
|
const REPUTATION_BLOCK = /\b5\.7\.\d{1,3}\b/;
|
|
123
145
|
const MAILBOX_FULL_CODE = /\b[45]\.2\.2\b/;
|
|
124
|
-
|
|
146
|
+
// 5.1.0 is a generic address-status failure, not proof the recipient is invalid.
|
|
147
|
+
const NO_SUCH_USER_CODE = /\b5\.1\.(?:[123]|10)\b/;
|
|
125
148
|
const TRANSIENT_CODE = /(\b4\.\d{1,3}\.\d{1,3}\b|\b4\d{2}\b)/;
|
|
126
149
|
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 )?
|
|
150
|
+
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
151
|
const PROVIDER_REJECTED_TEXT = /(message rejected|has been blocked|\bblocked\b|\bbanned\b|\bspam\b|local policy violation|unsolicited|reputation)/i;
|
|
129
152
|
const TRANSIENT_TEXT = /(temporar|will retry|try again later|rate ?limit|throttl)/i;
|
|
130
153
|
// Greedy address capture: the character class already stops at whitespace and
|
|
@@ -155,21 +178,34 @@ function readProvider(from, haystack) {
|
|
|
155
178
|
return "microsoft";
|
|
156
179
|
return "unknown";
|
|
157
180
|
}
|
|
158
|
-
function readKind(subject, haystack, hasBody) {
|
|
181
|
+
function readKind(subject, haystack, hasBody, enhancedCode) {
|
|
159
182
|
// Order is load-bearing. A Google "(Delay)" report can quote block-sounding
|
|
160
183
|
// prose while the message is still queued, so the transient marker wins first;
|
|
161
184
|
// an explicit enhanced code then beats keyword matching, because "blocked" and
|
|
162
185
|
// "spam" appear in boilerplate that a 5.1.1 no-such-user report also carries.
|
|
163
186
|
if (/\(delay\)/i.test(subject))
|
|
164
187
|
return "delayed";
|
|
165
|
-
if (
|
|
166
|
-
|
|
167
|
-
|
|
188
|
+
if (enhancedCode) {
|
|
189
|
+
if (REPUTATION_BLOCK.test(enhancedCode))
|
|
190
|
+
return "provider_rejected";
|
|
191
|
+
if (MAILBOX_FULL_CODE.test(enhancedCode))
|
|
192
|
+
return "mailbox_full";
|
|
193
|
+
if (NO_SUCH_USER_CODE.test(enhancedCode))
|
|
194
|
+
return "no_such_user";
|
|
195
|
+
if (enhancedCode.startsWith("4."))
|
|
196
|
+
return "delayed";
|
|
197
|
+
// An explicit generic/routing failure is not address-nonexistence evidence,
|
|
198
|
+
// even when the surrounding help text suggests checking the address.
|
|
199
|
+
return "other";
|
|
200
|
+
}
|
|
201
|
+
if (MAILBOX_FULL_TEXT.test(haystack))
|
|
168
202
|
return "mailbox_full";
|
|
169
|
-
if (NO_SUCH_USER_CODE.test(haystack) || NO_SUCH_USER_TEXT.test(haystack))
|
|
170
|
-
return "no_such_user";
|
|
171
203
|
if (PROVIDER_REJECTED_TEXT.test(haystack))
|
|
172
204
|
return "provider_rejected";
|
|
205
|
+
if (ENHANCED_CODE.test(haystack))
|
|
206
|
+
return "other";
|
|
207
|
+
if (NO_SUCH_USER_TEXT.test(haystack))
|
|
208
|
+
return "no_such_user";
|
|
173
209
|
if (TRANSIENT_CODE.test(haystack) || TRANSIENT_TEXT.test(haystack))
|
|
174
210
|
return "delayed";
|
|
175
211
|
// Nothing matched AND there was no body to match against: the sync stored the
|
|
@@ -186,13 +222,14 @@ function readKind(subject, haystack, hasBody) {
|
|
|
186
222
|
export function classifyDeliveryStatusNotification(input) {
|
|
187
223
|
const from = typeof input.fromAddress === "string" ? input.fromAddress : "";
|
|
188
224
|
const subject = typeof input.subject === "string" ? input.subject : "";
|
|
189
|
-
const body =
|
|
225
|
+
const body = readDeliveryStatusNotificationBody(input);
|
|
190
226
|
if (!DSN_FROM_PATTERN.test(from) && !DSN_SUBJECT_PATTERN.test(subject))
|
|
191
227
|
return null;
|
|
192
228
|
const haystack = `${subject}\n${body}`;
|
|
193
|
-
const
|
|
229
|
+
const enhancedCode = readEnhancedStatusCode(haystack);
|
|
230
|
+
const smtpCode = enhancedCode ?? (ENHANCED_CODE.test(haystack) ? null : SMTP_REPLY_CODE.exec(haystack)?.[1] ?? null);
|
|
194
231
|
return {
|
|
195
|
-
kind: readKind(subject, haystack, body.trim().length > 0),
|
|
232
|
+
kind: readKind(subject, haystack, body.trim().length > 0, enhancedCode),
|
|
196
233
|
provider: readProvider(from, haystack),
|
|
197
234
|
recipient: readRecipient(haystack),
|
|
198
235
|
smtpCode,
|
|
@@ -206,7 +243,7 @@ export function classifyDeliveryStatusNotification(input) {
|
|
|
206
243
|
export function isGoogleDeliveryStatusRejection(input) {
|
|
207
244
|
if (classifyDeliveryStatusNotification(input)?.kind !== "provider_rejected")
|
|
208
245
|
return false;
|
|
209
|
-
const body =
|
|
246
|
+
const body = readDeliveryStatusNotificationBody(input);
|
|
210
247
|
const remote = /^Remote-MTA:\s*(?:dns|rfc822);\s*([a-z0-9.-]+)/im.exec(body)?.[1];
|
|
211
248
|
if (remote) {
|
|
212
249
|
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 {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const HEALTHY_SCORE_MIN = 80;
|
|
2
|
+
export const DEGRADED_SCORE_MIN = 50;
|
|
3
|
+
export function deriveEmailHealthState(score) {
|
|
4
|
+
if (score == null || !Number.isFinite(score))
|
|
5
|
+
return "unknown";
|
|
6
|
+
const value = Math.max(0, Math.min(100, score));
|
|
7
|
+
return value >= HEALTHY_SCORE_MIN ? "healthy" : value >= DEGRADED_SCORE_MIN ? "degraded" : "critical";
|
|
8
|
+
}
|
|
9
|
+
export function summarizeFleetHealth(readings) {
|
|
10
|
+
const scored = readings.filter(row => row.healthState !== "unknown" && row.healthScore !== null && Number.isFinite(row.healthScore));
|
|
11
|
+
const score = scored.length ? scored.reduce((sum, row) => sum + Math.max(0, Math.min(100, row.healthScore)), 0) / scored.length : null;
|
|
12
|
+
const current = scored.filter(row => row.healthFreshness === "current").length;
|
|
13
|
+
const stale = scored.filter(row => row.healthFreshness === "stale").length;
|
|
14
|
+
const unverified = scored.length - current - stale;
|
|
15
|
+
const scoreState = deriveEmailHealthState(score);
|
|
16
|
+
const critical = readings.filter(row => row.healthState === "critical").length;
|
|
17
|
+
const degraded = readings.filter(row => row.healthState === "degraded").length;
|
|
18
|
+
const coverage = scored.length === 0 ? "none" : scored.length === readings.length ? "complete" : "partial";
|
|
19
|
+
const freshness = scored.length === 0 ? "unmeasured" : current === scored.length ? "current" : stale === scored.length ? "stale" : "mixed";
|
|
20
|
+
// A strong recorded average cannot clear an unmeasured or stale fleet. Individual faults remain independent.
|
|
21
|
+
const fleetState = scoreState === "healthy" && (coverage !== "complete" || freshness !== "current") ? "unknown" : scoreState;
|
|
22
|
+
return {
|
|
23
|
+
score, scoreState, fleetState,
|
|
24
|
+
worstMailboxState: critical ? "critical" : degraded ? "degraded" : scored.length ? "healthy" : "unknown",
|
|
25
|
+
total: readings.length, scored: scored.length, current, stale, unverified,
|
|
26
|
+
unscored: readings.length - scored.length, degraded, critical, coverage, freshness,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription lists and consent for bulk email (sending-domain Sequences).
|
|
3
|
+
*
|
|
4
|
+
* A list is who agreed to hear from the workspace about one thing (the
|
|
5
|
+
* newsletter, product updates, onboarding). A subscription is one address on
|
|
6
|
+
* one list with a status; every change appends a consent event, which is the
|
|
7
|
+
* evidence a regulator or a mailbox provider can ask for. Sending-domain
|
|
8
|
+
* Sequences only send to `subscribed` addresses on their own list, on top of
|
|
9
|
+
* do-not-contact. Pure and browser-safe.
|
|
10
|
+
*/
|
|
11
|
+
export declare const EMAIL_SUBSCRIPTION_LIST_KINDS: readonly ["marketing", "product_updates", "onboarding"];
|
|
12
|
+
export type EmailSubscriptionListKind = typeof EMAIL_SUBSCRIPTION_LIST_KINDS[number];
|
|
13
|
+
export declare const EMAIL_SUBSCRIPTION_STATUSES: readonly ["subscribed", "unsubscribed", "pending", "cleaned"];
|
|
14
|
+
export type EmailSubscriptionStatus = typeof EMAIL_SUBSCRIPTION_STATUSES[number];
|
|
15
|
+
/** Append-only consent event kinds. Status changes plus the provider facts that cause them. */
|
|
16
|
+
export declare const EMAIL_SUBSCRIPTION_EVENT_TYPES: readonly ["subscribed", "unsubscribed", "pending", "cleaned", "complaint", "hard_bounce"];
|
|
17
|
+
export type EmailSubscriptionEventType = typeof EMAIL_SUBSCRIPTION_EVENT_TYPES[number];
|
|
18
|
+
export declare const EMAIL_SUBSCRIPTION_LIST_KEY_RE: RegExp;
|
|
19
|
+
/** Most addresses one import call accepts. */
|
|
20
|
+
export declare const EMAIL_SUBSCRIPTION_IMPORT_LIMIT = 5000;
|
|
21
|
+
export declare class EmailSubscriptionInputError extends Error {
|
|
22
|
+
readonly code: string;
|
|
23
|
+
readonly field: string;
|
|
24
|
+
constructor(code: string, field: string, message: string);
|
|
25
|
+
}
|
|
26
|
+
export declare function isEmailSubscriptionStatus(value: unknown): value is EmailSubscriptionStatus;
|
|
27
|
+
export declare function isEmailSubscriptionListKind(value: unknown): value is EmailSubscriptionListKind;
|
|
28
|
+
/** The stored subscription key: trimmed, lowercased, one plausible address. */
|
|
29
|
+
export declare function normalizeSubscriptionEmail(value: unknown): string;
|
|
30
|
+
/**
|
|
31
|
+
* Consent evidence recorded with a subscription change. A change to
|
|
32
|
+
* `subscribed` must say where consent came from: the notice version and a hash
|
|
33
|
+
* of the exact notice text the person saw, or, for an import, the source and
|
|
34
|
+
* the person who vouches for it. IP addresses are stored hashed only.
|
|
35
|
+
*/
|
|
36
|
+
export type EmailConsentEvidence = {
|
|
37
|
+
notice_version?: string | null;
|
|
38
|
+
/** sha256 hex of the exact consent notice text shown. */
|
|
39
|
+
notice_text_hash?: string | null;
|
|
40
|
+
form_url?: string | null;
|
|
41
|
+
/** sha256 hex of the subscriber's IP address; never the address itself. */
|
|
42
|
+
ip_hash?: string | null;
|
|
43
|
+
user_agent?: string | null;
|
|
44
|
+
/** Free-text provenance for imports, e.g. "Resend segment export 2026-10-20". */
|
|
45
|
+
basis?: string | null;
|
|
46
|
+
};
|
|
47
|
+
/** Normalize evidence; `requireForSubscribe` refuses a subscribe that names no basis. */
|
|
48
|
+
export declare function parseEmailConsentEvidence(value: unknown, options: {
|
|
49
|
+
requireForSubscribe: boolean;
|
|
50
|
+
}): EmailConsentEvidence;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription lists and consent for bulk email (sending-domain Sequences).
|
|
3
|
+
*
|
|
4
|
+
* A list is who agreed to hear from the workspace about one thing (the
|
|
5
|
+
* newsletter, product updates, onboarding). A subscription is one address on
|
|
6
|
+
* one list with a status; every change appends a consent event, which is the
|
|
7
|
+
* evidence a regulator or a mailbox provider can ask for. Sending-domain
|
|
8
|
+
* Sequences only send to `subscribed` addresses on their own list, on top of
|
|
9
|
+
* do-not-contact. Pure and browser-safe.
|
|
10
|
+
*/
|
|
11
|
+
export const EMAIL_SUBSCRIPTION_LIST_KINDS = ["marketing", "product_updates", "onboarding"];
|
|
12
|
+
export const EMAIL_SUBSCRIPTION_STATUSES = ["subscribed", "unsubscribed", "pending", "cleaned"];
|
|
13
|
+
/** Append-only consent event kinds. Status changes plus the provider facts that cause them. */
|
|
14
|
+
export const EMAIL_SUBSCRIPTION_EVENT_TYPES = [
|
|
15
|
+
"subscribed", "unsubscribed", "pending", "cleaned", "complaint", "hard_bounce",
|
|
16
|
+
];
|
|
17
|
+
export const EMAIL_SUBSCRIPTION_LIST_KEY_RE = /^[a-z0-9][a-z0-9_-]{0,62}$/;
|
|
18
|
+
/** Most addresses one import call accepts. */
|
|
19
|
+
export const EMAIL_SUBSCRIPTION_IMPORT_LIMIT = 5000;
|
|
20
|
+
export class EmailSubscriptionInputError extends Error {
|
|
21
|
+
code;
|
|
22
|
+
field;
|
|
23
|
+
constructor(code, field, message) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.code = code;
|
|
26
|
+
this.field = field;
|
|
27
|
+
this.name = "EmailSubscriptionInputError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function isEmailSubscriptionStatus(value) {
|
|
31
|
+
return typeof value === "string" && EMAIL_SUBSCRIPTION_STATUSES.includes(value);
|
|
32
|
+
}
|
|
33
|
+
export function isEmailSubscriptionListKind(value) {
|
|
34
|
+
return typeof value === "string" && EMAIL_SUBSCRIPTION_LIST_KINDS.includes(value);
|
|
35
|
+
}
|
|
36
|
+
/** The stored subscription key: trimmed, lowercased, one plausible address. */
|
|
37
|
+
export function normalizeSubscriptionEmail(value) {
|
|
38
|
+
const email = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
39
|
+
if (!email || email.length > 320 || !/^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/.test(email)) {
|
|
40
|
+
throw new EmailSubscriptionInputError("invalid_subscription_email", "email", "Each subscription needs one valid email address.");
|
|
41
|
+
}
|
|
42
|
+
return email;
|
|
43
|
+
}
|
|
44
|
+
const SHA256_HEX = /^[a-f0-9]{64}$/;
|
|
45
|
+
function optionalText(value, field, max) {
|
|
46
|
+
if (value === undefined || value === null || value === "")
|
|
47
|
+
return null;
|
|
48
|
+
if (typeof value !== "string" || value.length > max) {
|
|
49
|
+
throw new EmailSubscriptionInputError("invalid_consent_evidence", `evidence.${field}`, `evidence.${field} must be text of at most ${max} characters.`);
|
|
50
|
+
}
|
|
51
|
+
return value.trim() || null;
|
|
52
|
+
}
|
|
53
|
+
function optionalHash(value, field) {
|
|
54
|
+
const text = optionalText(value, field, 64);
|
|
55
|
+
if (text === null)
|
|
56
|
+
return null;
|
|
57
|
+
if (!SHA256_HEX.test(text.toLowerCase())) {
|
|
58
|
+
throw new EmailSubscriptionInputError("invalid_consent_evidence", `evidence.${field}`, `evidence.${field} must be a sha256 hex digest.`);
|
|
59
|
+
}
|
|
60
|
+
return text.toLowerCase();
|
|
61
|
+
}
|
|
62
|
+
/** Normalize evidence; `requireForSubscribe` refuses a subscribe that names no basis. */
|
|
63
|
+
export function parseEmailConsentEvidence(value, options) {
|
|
64
|
+
const record = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
65
|
+
const evidence = {
|
|
66
|
+
notice_version: optionalText(record.notice_version, "notice_version", 80),
|
|
67
|
+
notice_text_hash: optionalHash(record.notice_text_hash, "notice_text_hash"),
|
|
68
|
+
form_url: optionalText(record.form_url, "form_url", 2048),
|
|
69
|
+
ip_hash: optionalHash(record.ip_hash, "ip_hash"),
|
|
70
|
+
user_agent: optionalText(record.user_agent, "user_agent", 512),
|
|
71
|
+
basis: optionalText(record.basis, "basis", 500),
|
|
72
|
+
};
|
|
73
|
+
if (options.requireForSubscribe) {
|
|
74
|
+
const notice = Boolean(evidence.notice_version && evidence.notice_text_hash);
|
|
75
|
+
if (!notice && !evidence.basis) {
|
|
76
|
+
throw new EmailSubscriptionInputError("consent_evidence_required", "evidence", "Subscribing someone needs consent evidence: notice_version with notice_text_hash, or a basis naming where consent came from.");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return evidence;
|
|
80
|
+
}
|
|
@@ -43,6 +43,34 @@ export declare function signUnsubscribeToken(payload: UnsubscribeTokenPayload, s
|
|
|
43
43
|
* secret, tampered body, replayed garbage). Pure: the route maps null -> 401.
|
|
44
44
|
*/
|
|
45
45
|
export declare function verifyUnsubscribeToken(token: string, secret: string): UnsubscribeTokenPayload | null;
|
|
46
|
+
/**
|
|
47
|
+
* v2: a subscription-list unsubscribe for bulk mail sent through a sending
|
|
48
|
+
* domain. `lst` names the list the recipient leaves; `scope` says whether the
|
|
49
|
+
* click leaves that one list or every list (and the do-not-contact ledger).
|
|
50
|
+
* v1 tokens are untouched: `verifyUnsubscribeToken` still accepts only v1.
|
|
51
|
+
*/
|
|
52
|
+
export type UnsubscribeTokenPayloadV2 = {
|
|
53
|
+
v: 2;
|
|
54
|
+
org: string;
|
|
55
|
+
email: string;
|
|
56
|
+
/** Subscription list id the recipient leaves. */
|
|
57
|
+
lst: string;
|
|
58
|
+
/** `list` leaves only `lst`; `all` leaves every list and suppresses the address. */
|
|
59
|
+
scope: "list" | "all";
|
|
60
|
+
seq?: string;
|
|
61
|
+
enr?: string;
|
|
62
|
+
/** Sending-domain account id, for provenance. */
|
|
63
|
+
sd?: string;
|
|
64
|
+
iat: number;
|
|
65
|
+
};
|
|
66
|
+
export type AnyUnsubscribeTokenPayload = UnsubscribeTokenPayload | UnsubscribeTokenPayloadV2;
|
|
67
|
+
/** Sign a v2 (list-scoped) token. Same wire format and secret as v1. */
|
|
68
|
+
export declare function signUnsubscribeTokenV2(payload: UnsubscribeTokenPayloadV2, secret: string): string;
|
|
69
|
+
/**
|
|
70
|
+
* Verify a v1 OR v2 token. The one-click route uses this and branches on `v`;
|
|
71
|
+
* v1 callers keep `verifyUnsubscribeToken`, whose contract is unchanged.
|
|
72
|
+
*/
|
|
73
|
+
export declare function verifyAnyUnsubscribeToken(token: string, secret: string): AnyUnsubscribeTokenPayload | null;
|
|
46
74
|
/**
|
|
47
75
|
* The HMAC signing secret for unsubscribe tokens, or null when unset. The send
|
|
48
76
|
* path treats null as "omit the header" (a send with no one-click link still
|
|
@@ -49,6 +49,53 @@ export function verifyUnsubscribeToken(token, secret) {
|
|
|
49
49
|
return null;
|
|
50
50
|
return parseTokenPayload(body);
|
|
51
51
|
}
|
|
52
|
+
/** Sign a v2 (list-scoped) token. Same wire format and secret as v1. */
|
|
53
|
+
export function signUnsubscribeTokenV2(payload, secret) {
|
|
54
|
+
const normalized = { ...payload, email: payload.email.trim().toLowerCase() };
|
|
55
|
+
return signPayload(normalized, secret);
|
|
56
|
+
}
|
|
57
|
+
function parseTokenPayloadV2(body) {
|
|
58
|
+
let parsed;
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (!parsed || typeof parsed !== "object")
|
|
66
|
+
return null;
|
|
67
|
+
const candidate = parsed;
|
|
68
|
+
if (candidate.v !== 2)
|
|
69
|
+
return null;
|
|
70
|
+
const org = typeof candidate.org === "string" ? candidate.org.trim() : "";
|
|
71
|
+
const email = typeof candidate.email === "string" ? candidate.email.trim().toLowerCase() : "";
|
|
72
|
+
const lst = typeof candidate.lst === "string" ? candidate.lst.trim() : "";
|
|
73
|
+
if (!org || !email || !email.includes("@") || !lst)
|
|
74
|
+
return null;
|
|
75
|
+
if (candidate.scope !== "list" && candidate.scope !== "all")
|
|
76
|
+
return null;
|
|
77
|
+
return {
|
|
78
|
+
v: 2,
|
|
79
|
+
org,
|
|
80
|
+
email,
|
|
81
|
+
lst,
|
|
82
|
+
scope: candidate.scope,
|
|
83
|
+
...(typeof candidate.seq === "string" && candidate.seq ? { seq: candidate.seq } : {}),
|
|
84
|
+
...(typeof candidate.enr === "string" && candidate.enr ? { enr: candidate.enr } : {}),
|
|
85
|
+
...(typeof candidate.sd === "string" && candidate.sd ? { sd: candidate.sd } : {}),
|
|
86
|
+
iat: typeof candidate.iat === "number" && Number.isFinite(candidate.iat) ? candidate.iat : 0,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Verify a v1 OR v2 token. The one-click route uses this and branches on `v`;
|
|
91
|
+
* v1 callers keep `verifyUnsubscribeToken`, whose contract is unchanged.
|
|
92
|
+
*/
|
|
93
|
+
export function verifyAnyUnsubscribeToken(token, secret) {
|
|
94
|
+
const body = splitAndVerifyToken(token, secret);
|
|
95
|
+
if (body === null)
|
|
96
|
+
return null;
|
|
97
|
+
return parseTokenPayload(body) ?? parseTokenPayloadV2(body);
|
|
98
|
+
}
|
|
52
99
|
/**
|
|
53
100
|
* The HMAC signing secret for unsubscribe tokens, or null when unset. The send
|
|
54
101
|
* path treats null as "omit the header" (a send with no one-click link still
|
|
@@ -78,22 +78,10 @@ export declare function readWorkflowStepFailure(error: unknown): WorkflowStepFai
|
|
|
78
78
|
*/
|
|
79
79
|
export declare const AUTOMATION_ACTIONS_EXCEEDED_ERROR_CODE = "automation_actions_exceeded";
|
|
80
80
|
export declare function isAutomationUsageQuotaError(error: unknown): boolean;
|
|
81
|
-
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* `monthlyAutomationActions`. Pricing Model 2.0 ([v1.330.0]) set that field to null on
|
|
86
|
-
* every base plan, so on current plans the admission gate in captureAutomationActions()
|
|
87
|
-
* is unreachable and assertAutomationActionsAvailable() refuses with `insufficient_credits`
|
|
88
|
-
* instead — steps bill automationStepCredits() each out of the one credit pool.
|
|
89
|
-
*
|
|
90
|
-
* Both codes mean the same thing to a caller: "this automation work was REFUSED admission,
|
|
91
|
-
* the customer must feel it, and the run must stop cleanly." Anything else is a metering
|
|
92
|
-
* write that failed and must be stepped over. Matching only the quota code is how a
|
|
93
|
-
* credit-exhausted cron ends up rethrowing out of the scheduler's enqueue loop instead of
|
|
94
|
-
* taking the graceful pause path — abandoning the lease and every remaining due trigger
|
|
95
|
-
* for that tenant's sweep.
|
|
96
|
-
*/
|
|
81
|
+
/** Stop dispatch on a known cap refusal or unavailable authoritative Workflow
|
|
82
|
+
* admission. Unavailable accounting can follow a lost commit acknowledgement;
|
|
83
|
+
* this predicate does not prove that the fee capture was not applied. Other
|
|
84
|
+
* legacy metering faults retain their best-effort behavior. */
|
|
97
85
|
export declare const AUTOMATION_INSUFFICIENT_CREDITS_ERROR_CODE = "insufficient_credits";
|
|
98
86
|
export declare function isAutomationAdmissionRefusal(error: unknown): boolean;
|
|
99
87
|
/**
|
|
@@ -216,29 +216,20 @@ export function isAutomationUsageQuotaError(error) {
|
|
|
216
216
|
&& typeof error === "object"
|
|
217
217
|
&& error.code === AUTOMATION_ACTIONS_EXCEEDED_ERROR_CODE;
|
|
218
218
|
}
|
|
219
|
-
/**
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
* `monthlyAutomationActions`. Pricing Model 2.0 ([v1.330.0]) set that field to null on
|
|
224
|
-
* every base plan, so on current plans the admission gate in captureAutomationActions()
|
|
225
|
-
* is unreachable and assertAutomationActionsAvailable() refuses with `insufficient_credits`
|
|
226
|
-
* instead — steps bill automationStepCredits() each out of the one credit pool.
|
|
227
|
-
*
|
|
228
|
-
* Both codes mean the same thing to a caller: "this automation work was REFUSED admission,
|
|
229
|
-
* the customer must feel it, and the run must stop cleanly." Anything else is a metering
|
|
230
|
-
* write that failed and must be stepped over. Matching only the quota code is how a
|
|
231
|
-
* credit-exhausted cron ends up rethrowing out of the scheduler's enqueue loop instead of
|
|
232
|
-
* taking the graceful pause path — abandoning the lease and every remaining due trigger
|
|
233
|
-
* for that tenant's sweep.
|
|
234
|
-
*/
|
|
219
|
+
/** Stop dispatch on a known cap refusal or unavailable authoritative Workflow
|
|
220
|
+
* admission. Unavailable accounting can follow a lost commit acknowledgement;
|
|
221
|
+
* this predicate does not prove that the fee capture was not applied. Other
|
|
222
|
+
* legacy metering faults retain their best-effort behavior. */
|
|
235
223
|
export const AUTOMATION_INSUFFICIENT_CREDITS_ERROR_CODE = "insufficient_credits";
|
|
236
224
|
export function isAutomationAdmissionRefusal(error) {
|
|
237
225
|
if (!error || typeof error !== "object")
|
|
238
226
|
return false;
|
|
239
227
|
const code = error.code;
|
|
240
228
|
return code === AUTOMATION_ACTIONS_EXCEEDED_ERROR_CODE
|
|
241
|
-
|| code === AUTOMATION_INSUFFICIENT_CREDITS_ERROR_CODE
|
|
229
|
+
|| code === AUTOMATION_INSUFFICIENT_CREDITS_ERROR_CODE
|
|
230
|
+
|| code === "budget_policy_exceeded"
|
|
231
|
+
|| code === "workspace_credit_cap_exceeded"
|
|
232
|
+
|| code === "automation_admission_unavailable";
|
|
242
233
|
}
|
|
243
234
|
const MAX_CAUSE_DEPTH = 4;
|
|
244
235
|
const MAX_CAUSE_MESSAGE_LENGTH = 200;
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
export declare const GUIDED_ACTIVATION_PURPOSES: readonly ["lead_scan", "morning_briefing"];
|
|
2
|
+
/**
|
|
3
|
+
* The research that runs beside the onboarding chat: its own session and turn,
|
|
4
|
+
* so the person can talk to the Oxygent while it works. `results_turn_id` is
|
|
5
|
+
* the chat turn that reported it; `results_pending` means the chat was busy
|
|
6
|
+
* when it finished and the report goes out after that turn.
|
|
7
|
+
*/
|
|
8
|
+
export type GuidedOnboardingResearch = {
|
|
9
|
+
session_id: string;
|
|
10
|
+
turn_id?: string;
|
|
11
|
+
results_turn_id?: string;
|
|
12
|
+
results_pending?: boolean;
|
|
13
|
+
};
|
|
2
14
|
export type GuidedActivationPurpose = typeof GUIDED_ACTIVATION_PURPOSES[number];
|
|
3
15
|
export type GuidedActivationRoutine = {
|
|
4
16
|
activation_id: string;
|
|
@@ -54,13 +66,15 @@ export type GuidedActivation = {
|
|
|
54
66
|
turn_id?: string;
|
|
55
67
|
event_seq?: number;
|
|
56
68
|
finished_at?: string;
|
|
69
|
+
/** Agent-led onboarding's background research (Philipp, 2026-10-20): its hidden session and turn, then the chat turn that reported it. */
|
|
70
|
+
research?: GuidedOnboardingResearch;
|
|
57
71
|
} | null;
|
|
58
72
|
last_fault: {
|
|
59
73
|
code: string;
|
|
60
74
|
at: string;
|
|
61
75
|
} | null;
|
|
62
76
|
};
|
|
63
|
-
/**
|
|
77
|
+
/** Production requires the explicit all-workspace rollout; the DEV pilot keeps its own switch. */
|
|
64
78
|
export declare function guidedActivationEnabled(env?: Record<string, string | undefined>): boolean;
|
|
65
79
|
export declare function guidedActivationWorkspaceAllowed(organizationId: string, env?: Record<string, string | undefined>): boolean;
|
|
66
80
|
export declare function guidedActivationAvailableTo(env: Record<string, string | undefined>, viewer: {
|
|
@@ -3,8 +3,10 @@ import { isProductionData } from "./deploy-env.js";
|
|
|
3
3
|
export const GUIDED_ACTIVATION_PURPOSES = ["lead_scan", "morning_briefing"];
|
|
4
4
|
export const GUIDED_ACTIVATION_POINTERS = ["knowledge", "first_leads_table", "sequence", "sender", "oxygent", "direct_chat", "scan_routine", "briefing_routine"];
|
|
5
5
|
export const GUIDED_ACTIVATION_REVISIONS = ["configuration", "sequence", "audience", "recipients", "sender", "copy", "knowledge", "schedule", "authority"];
|
|
6
|
-
/**
|
|
6
|
+
/** Production requires the explicit all-workspace rollout; the DEV pilot keeps its own switch. */
|
|
7
7
|
export function guidedActivationEnabled(env = process.env) {
|
|
8
|
+
if (env.OXYGEN_DEPLOY_ENV === "selfhost-prod")
|
|
9
|
+
return env.OXYGEN_GUIDED_ACTIVATION_ENABLED === "all";
|
|
8
10
|
return env.OXYGEN_DEPLOY_ENV === "selfhost-dev" && !isProductionData(env)
|
|
9
11
|
&& env.OXYGEN_GUIDED_ACTIVATION_ENABLED === "staff";
|
|
10
12
|
}
|
|
@@ -46,6 +46,7 @@ export type LinkedInInviteStep = LinkedInStepBase & {
|
|
|
46
46
|
};
|
|
47
47
|
export type LinkedInWaitForConnectionStep = LinkedInStepBase & {
|
|
48
48
|
kind: "wait_for_connection";
|
|
49
|
+
withdraw_on_timeout?: boolean;
|
|
49
50
|
/** Days to wait for the invite to be accepted before acting on on_timeout. */
|
|
50
51
|
timeout_days: number;
|
|
51
52
|
/** What to do if the invite is never accepted. Defaults to "stop". */
|
|
@@ -53,6 +54,7 @@ export type LinkedInWaitForConnectionStep = LinkedInStepBase & {
|
|
|
53
54
|
};
|
|
54
55
|
export type LinkedInWaitStep = LinkedInStepBase & {
|
|
55
56
|
kind: "wait";
|
|
57
|
+
working_days?: boolean;
|
|
56
58
|
/** Fixed delay before the next step. Provide days and/or hours (>= 1 total). */
|
|
57
59
|
days?: number;
|
|
58
60
|
hours?: number;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { mailboxStandbyWritesReady, assertMailboxStandbyWritesReady } from "./write-rollout.js";
|
|
2
|
+
export declare function assertMailboxStandbyMetadataWriteReady(metadata: Record<string, unknown> | null | undefined): void;
|
|
3
|
+
export declare function assertSequenceStandbyWriteReady(settings: {
|
|
4
|
+
auto_replace_mailboxes?: boolean;
|
|
5
|
+
} | null | undefined): void;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { assertMailboxStandbyMetadataWritesReady, assertMailboxStandbyWritesReady } from "./write-rollout.js";
|
|
2
|
+
export { mailboxStandbyWritesReady, assertMailboxStandbyWritesReady } from "./write-rollout.js";
|
|
3
|
+
export function assertMailboxStandbyMetadataWriteReady(metadata) {
|
|
4
|
+
assertMailboxStandbyMetadataWritesReady(metadata);
|
|
5
|
+
}
|
|
6
|
+
export function assertSequenceStandbyWriteReady(settings) {
|
|
7
|
+
if (settings?.auto_replace_mailboxes === true)
|
|
8
|
+
assertMailboxStandbyWritesReady();
|
|
9
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export type MailboxOutreachRole = "outreach" | "standby";
|
|
2
|
+
export type MailboxStandbyReadinessEvidence = {
|
|
3
|
+
observed_at: string;
|
|
4
|
+
confirmed_days: number;
|
|
5
|
+
warmup_running: boolean;
|
|
6
|
+
dns_ready: boolean;
|
|
7
|
+
auth_healthy: boolean;
|
|
8
|
+
warmup_provider: string;
|
|
9
|
+
warmup_vendor_ref: string;
|
|
10
|
+
ready_since?: string;
|
|
11
|
+
};
|
|
12
|
+
export declare const DEFAULT_MAILBOX_STANDBY_MINIMUM_DAYS = 14;
|
|
13
|
+
export declare const MAILBOX_STANDBY_EVIDENCE_MAX_AGE_MS: number;
|
|
14
|
+
export declare function mailboxOutreachRole(metadata: Record<string, unknown> | null | undefined): MailboxOutreachRole;
|
|
15
|
+
/** Campaign membership is authoritative DB evidence, never the promotion marker alone. */
|
|
16
|
+
export declare function isMailboxOutreachAllowed(input: {
|
|
17
|
+
metadata: Record<string, unknown> | null | undefined;
|
|
18
|
+
sequenceId?: string;
|
|
19
|
+
autoReplace?: boolean;
|
|
20
|
+
replacementSequenceIds?: string[];
|
|
21
|
+
}): boolean;
|
|
22
|
+
export declare function mailboxStandbyReadiness(input: {
|
|
23
|
+
metadata: Record<string, unknown>;
|
|
24
|
+
status: string;
|
|
25
|
+
warmupState: string;
|
|
26
|
+
warmupProvider: string | null;
|
|
27
|
+
warmupVendorRef: string | null;
|
|
28
|
+
senderProfileId: string | null;
|
|
29
|
+
senderProfileActive: boolean;
|
|
30
|
+
minimumDays: number;
|
|
31
|
+
now?: Date;
|
|
32
|
+
}): {
|
|
33
|
+
ready: boolean;
|
|
34
|
+
confirmed_days: number;
|
|
35
|
+
minimum_days: number;
|
|
36
|
+
blockers: string[];
|
|
37
|
+
};
|
|
38
|
+
/** The latest promoted descendant; waiting records never create a sending identity. */
|
|
39
|
+
export declare function resolveMailboxStandbyReplacement(rows: readonly {
|
|
40
|
+
oldMailboxId: string;
|
|
41
|
+
newMailboxId: string | null;
|
|
42
|
+
status: string;
|
|
43
|
+
}[], original: string): string | null;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export const DEFAULT_MAILBOX_STANDBY_MINIMUM_DAYS = 14;
|
|
2
|
+
export const MAILBOX_STANDBY_EVIDENCE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
3
|
+
export function mailboxOutreachRole(metadata) {
|
|
4
|
+
return metadata?.outreach_role === "standby" ? "standby" : "outreach";
|
|
5
|
+
}
|
|
6
|
+
/** Campaign membership is authoritative DB evidence, never the promotion marker alone. */
|
|
7
|
+
export function isMailboxOutreachAllowed(input) {
|
|
8
|
+
if (mailboxOutreachRole(input.metadata) === "standby")
|
|
9
|
+
return false;
|
|
10
|
+
if (!Object.hasOwn(input.metadata ?? {}, "standby_promotion"))
|
|
11
|
+
return true;
|
|
12
|
+
return input.autoReplace === true && !!input.sequenceId && input.replacementSequenceIds?.includes(input.sequenceId) === true;
|
|
13
|
+
}
|
|
14
|
+
export function mailboxStandbyReadiness(input) {
|
|
15
|
+
const raw = input.metadata.standby_readiness;
|
|
16
|
+
const evidence = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
17
|
+
const blockers = [];
|
|
18
|
+
const observed = Date.parse(evidence.observed_at ?? "");
|
|
19
|
+
const age = (input.now ?? new Date()).getTime() - observed;
|
|
20
|
+
if (!Number.isFinite(observed) || age > MAILBOX_STANDBY_EVIDENCE_MAX_AGE_MS || age < -300_000)
|
|
21
|
+
blockers.push("readiness_evidence_stale_or_missing");
|
|
22
|
+
if (!evidence.warmup_provider || !evidence.warmup_vendor_ref || evidence.warmup_provider !== input.warmupProvider || evidence.warmup_vendor_ref !== input.warmupVendorRef)
|
|
23
|
+
blockers.push("warmup_seat_unverified");
|
|
24
|
+
const days = typeof evidence.confirmed_days === "number" && Number.isInteger(evidence.confirmed_days) && evidence.confirmed_days >= 0 ? evidence.confirmed_days : 0;
|
|
25
|
+
if (days < input.minimumDays)
|
|
26
|
+
blockers.push("warmup_days_remaining");
|
|
27
|
+
if (evidence.warmup_running !== true || !["warming", "active"].includes(input.warmupState))
|
|
28
|
+
blockers.push("warmup_not_running");
|
|
29
|
+
if (evidence.dns_ready !== true)
|
|
30
|
+
blockers.push("dns_not_ready");
|
|
31
|
+
if (evidence.auth_healthy !== true || input.metadata.oauth_grant_failure || input.metadata.mailbox_deleted_at)
|
|
32
|
+
blockers.push("authorization_unverified");
|
|
33
|
+
if (input.status !== "active" || input.metadata.auto_pause)
|
|
34
|
+
blockers.push("mailbox_not_active_or_safety_paused");
|
|
35
|
+
if (!input.senderProfileId || !input.senderProfileActive)
|
|
36
|
+
blockers.push("sender_profile_required");
|
|
37
|
+
return { ready: blockers.length === 0, confirmed_days: days, minimum_days: input.minimumDays, blockers };
|
|
38
|
+
}
|
|
39
|
+
/** The latest promoted descendant; waiting records never create a sending identity. */
|
|
40
|
+
export function resolveMailboxStandbyReplacement(rows, original) {
|
|
41
|
+
const visited = new Set([original]);
|
|
42
|
+
let current = original;
|
|
43
|
+
while (true) {
|
|
44
|
+
const next = rows.find(row => row.status === "promoted" && row.newMailboxId && row.oldMailboxId === current)?.newMailboxId;
|
|
45
|
+
if (!next)
|
|
46
|
+
return current === original ? null : current;
|
|
47
|
+
if (visited.has(next))
|
|
48
|
+
return null;
|
|
49
|
+
visited.add(next);
|
|
50
|
+
current = next;
|
|
51
|
+
}
|
|
52
|
+
}
|