@oxygen-agent/cli 1.936.1 → 1.982.3
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/admin-primary-providers-render.js +9 -1
- package/dist/cli-values.d.ts +14 -0
- package/dist/cli-values.js +26 -0
- package/dist/command-manifest.js +30 -2
- package/dist/functions-commands.js +13 -5
- package/dist/help.js +2 -0
- package/dist/index.js +1509 -290
- package/dist/knowledge-repository-commands.d.ts +6 -0
- package/dist/knowledge-repository-commands.js +198 -0
- package/dist/skills.js +20 -0
- package/dist/ugc-commands.js +470 -15
- package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/byok-connect.d.ts +11 -6
- package/node_modules/@oxygen/shared/dist/byok-connect.js +14 -6
- package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +152 -20
- package/node_modules/@oxygen/shared/dist/copilot-errors.js +3 -0
- package/node_modules/@oxygen/shared/dist/copilot-journeys.d.ts +19 -1
- package/node_modules/@oxygen/shared/dist/copilot-journeys.generated.d.ts +19 -0
- package/node_modules/@oxygen/shared/dist/copilot-journeys.generated.js +26 -0
- package/node_modules/@oxygen/shared/dist/copilot-journeys.js +8 -41
- package/node_modules/@oxygen/shared/dist/email-dsn.d.ts +60 -0
- package/node_modules/@oxygen/shared/dist/email-dsn.js +120 -0
- package/node_modules/@oxygen/shared/dist/email-warmup-readiness.d.ts +64 -0
- package/node_modules/@oxygen/shared/dist/email-warmup-readiness.js +90 -0
- package/node_modules/@oxygen/shared/dist/inbox-avatar-url.d.ts +28 -0
- package/node_modules/@oxygen/shared/dist/inbox-avatar-url.js +57 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/index.js +10 -0
- package/node_modules/@oxygen/shared/dist/knowledge-bases.d.ts +74 -0
- package/node_modules/@oxygen/shared/dist/knowledge-bases.js +456 -0
- package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +56 -48
- package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +50 -49
- package/node_modules/@oxygen/shared/dist/knowledge-repository.d.ts +22 -0
- package/node_modules/@oxygen/shared/dist/knowledge-repository.js +121 -0
- package/node_modules/@oxygen/shared/dist/knowledge-vault-markdown.d.ts +20 -0
- package/node_modules/@oxygen/shared/dist/knowledge-vault-markdown.js +155 -0
- package/node_modules/@oxygen/shared/dist/langfuse.d.ts +8 -3
- package/node_modules/@oxygen/shared/dist/langfuse.js +177 -130
- package/node_modules/@oxygen/shared/dist/llm-payload.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/llm-payload.js +54 -0
- package/node_modules/@oxygen/shared/dist/llm-usage.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/llm-usage.js +30 -0
- package/node_modules/@oxygen/shared/dist/mailbox-import.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/mailbox-import.js +53 -0
- package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/plan-limits.js +8 -0
- package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/pricing-sheet.js +1 -1
- package/node_modules/@oxygen/shared/dist/product-analytics-core.d.ts +98 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-core.js +159 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-environment.d.ts +18 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-environment.js +46 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +116 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-events.js +120 -0
- package/node_modules/@oxygen/shared/dist/recipes.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/recipes.js +23 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +126 -2
- package/node_modules/@oxygen/shared/dist/sequences.js +280 -4
- package/node_modules/@oxygen/shared/dist/ugc-amplification-identity.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/ugc-amplification-identity.js +24 -0
- package/node_modules/@oxygen/shared/dist/ugc.d.ts +29 -1
- package/node_modules/@oxygen/shared/dist/user-capability-routing.js +8 -1
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +3 -1
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +6 -2
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +15 -4
- package/node_modules/@oxygen/shared/package.json +15 -0
- package/node_modules/@oxygen/workflows/dist/graph/lint.js +22 -0
- package/package.json +2 -1
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PURE classifier for delivery-status notifications (bounce NDRs / DSNs) that
|
|
3
|
+
* land back in a sending mailbox's own inbox.
|
|
4
|
+
*
|
|
5
|
+
* WHY this exists: a mailbox-provider reputation block is the loudest
|
|
6
|
+
* deliverability signal a workspace ever receives, and it arrives as an ordinary
|
|
7
|
+
* inbound email — "Message rejected", "support.google.com/mail/answer/69585" —
|
|
8
|
+
* that no OXYGEN surface reads. A warm-up vendor reporting 96% inbox placement
|
|
9
|
+
* cannot contradict it, because the vendor only measures its own seed network.
|
|
10
|
+
* Classifying the DSN turns the workspace's own inbox into first-party
|
|
11
|
+
* deliverability evidence: OXYGEN's send log said we sent, the DSN says the
|
|
12
|
+
* receiving provider refused it, and only the second one is the truth.
|
|
13
|
+
*
|
|
14
|
+
* Pure by design — no clock, no I/O, no provider call — so the same
|
|
15
|
+
* classification runs in the tenant rollup, a worker sweep, and a unit test.
|
|
16
|
+
* Output is stable machine data (kinds and codes), never prose.
|
|
17
|
+
*/
|
|
18
|
+
/** What the notification actually says happened. */
|
|
19
|
+
export type DeliveryStatusNotificationKind =
|
|
20
|
+
/** The receiving provider refused the message on reputation/policy grounds. */
|
|
21
|
+
"provider_rejected"
|
|
22
|
+
/** The address does not exist (list hygiene, not reputation). */
|
|
23
|
+
| "no_such_user"
|
|
24
|
+
/** The recipient mailbox is over quota. */
|
|
25
|
+
| "mailbox_full"
|
|
26
|
+
/** Transient: retried, not dead. */
|
|
27
|
+
| "delayed"
|
|
28
|
+
/**
|
|
29
|
+
* The envelope proves it is a DSN, but nothing classifiable was stored — the
|
|
30
|
+
* body was never synced, so only the subject survives. Kept distinct from
|
|
31
|
+
* "other" because counting these as "other" reads as "we looked and found
|
|
32
|
+
* nothing wrong", when the truth is that we could not look.
|
|
33
|
+
*/
|
|
34
|
+
| "unclassified"
|
|
35
|
+
/** A DSN with real content we can recognize but not attribute. */
|
|
36
|
+
| "other";
|
|
37
|
+
/** Every kind, in one place, so callers can build a complete counts map. */
|
|
38
|
+
export declare const DELIVERY_STATUS_NOTIFICATION_KINDS: readonly ["provider_rejected", "no_such_user", "mailbox_full", "delayed", "unclassified", "other"];
|
|
39
|
+
/** Which mailbox provider generated the notification, when it is knowable. */
|
|
40
|
+
export type DeliveryStatusNotificationProvider = "google" | "microsoft" | "unknown";
|
|
41
|
+
export type DeliveryStatusNotification = {
|
|
42
|
+
kind: DeliveryStatusNotificationKind;
|
|
43
|
+
provider: DeliveryStatusNotificationProvider;
|
|
44
|
+
/** The address the original message was addressed to, when the DSN names it. */
|
|
45
|
+
recipient: string | null;
|
|
46
|
+
/** Enhanced status code (5.7.1) when present, else the bare SMTP reply (550). */
|
|
47
|
+
smtpCode: string | null;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Classify one stored inbound message as a delivery-status notification.
|
|
51
|
+
*
|
|
52
|
+
* Returns null when the message is not a DSN at all — the caller's SQL prefilter
|
|
53
|
+
* is intentionally wide, and a false positive here would invent a deliverability
|
|
54
|
+
* incident out of a customer reply.
|
|
55
|
+
*/
|
|
56
|
+
export declare function classifyDeliveryStatusNotification(input: {
|
|
57
|
+
fromAddress?: string | null;
|
|
58
|
+
subject?: string | null;
|
|
59
|
+
bodyText?: string | null;
|
|
60
|
+
}): DeliveryStatusNotification | null;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PURE classifier for delivery-status notifications (bounce NDRs / DSNs) that
|
|
3
|
+
* land back in a sending mailbox's own inbox.
|
|
4
|
+
*
|
|
5
|
+
* WHY this exists: a mailbox-provider reputation block is the loudest
|
|
6
|
+
* deliverability signal a workspace ever receives, and it arrives as an ordinary
|
|
7
|
+
* inbound email — "Message rejected", "support.google.com/mail/answer/69585" —
|
|
8
|
+
* that no OXYGEN surface reads. A warm-up vendor reporting 96% inbox placement
|
|
9
|
+
* cannot contradict it, because the vendor only measures its own seed network.
|
|
10
|
+
* Classifying the DSN turns the workspace's own inbox into first-party
|
|
11
|
+
* deliverability evidence: OXYGEN's send log said we sent, the DSN says the
|
|
12
|
+
* receiving provider refused it, and only the second one is the truth.
|
|
13
|
+
*
|
|
14
|
+
* Pure by design — no clock, no I/O, no provider call — so the same
|
|
15
|
+
* classification runs in the tenant rollup, a worker sweep, and a unit test.
|
|
16
|
+
* Output is stable machine data (kinds and codes), never prose.
|
|
17
|
+
*/
|
|
18
|
+
/** Every kind, in one place, so callers can build a complete counts map. */
|
|
19
|
+
export const DELIVERY_STATUS_NOTIFICATION_KINDS = [
|
|
20
|
+
"provider_rejected",
|
|
21
|
+
"no_such_user",
|
|
22
|
+
"mailbox_full",
|
|
23
|
+
"delayed",
|
|
24
|
+
"unclassified",
|
|
25
|
+
"other",
|
|
26
|
+
];
|
|
27
|
+
// A message only enters classification when its envelope looks like a DSN. The
|
|
28
|
+
// SQL prefilter that feeds this is deliberately loose (ILIKE), so this gate is
|
|
29
|
+
// what keeps an ordinary customer reply that happens to contain the word
|
|
30
|
+
// "blocked" out of the deliverability evidence.
|
|
31
|
+
const DSN_FROM_PATTERN = /(mailer-daemon|mail-daemon|postmaster|microsoftexchange)/i;
|
|
32
|
+
const DSN_SUBJECT_PATTERN = /(undeliver|delivery status notification|mail delivery fail|delivery has failed|returned mail)/i;
|
|
33
|
+
const GOOGLE_BLOCK_URL = /support\.google\.com\/mail\/answer\/69585/i;
|
|
34
|
+
const GOOGLE_FROM = /@(?:[a-z0-9-]+\.)*(?:googlemail|google)\.com\b/i;
|
|
35
|
+
const MICROSOFT_FROM = /(?:@(?:[a-z0-9-]+\.)*(?:outlook|office365|hotmail|microsoft)\.com\b|microsoftexchange|\bexchange\b)/i;
|
|
36
|
+
const MICROSOFT_BODY = /(microsoft exchange|exchange server|office\s?365)/i;
|
|
37
|
+
// Enhanced status code (RFC 3463): class.subject.detail, where subject/detail can
|
|
38
|
+
// be multi-digit (Google emits 5.7.708). Kept separate from the bare 3-digit SMTP
|
|
39
|
+
// reply so a caller can tell "550" from "5.7.1".
|
|
40
|
+
const ENHANCED_CODE = /\b([2-5]\.\d{1,3}\.\d{1,3})\b/;
|
|
41
|
+
const SMTP_REPLY_CODE = /\b([2-5]\d{2})\b/;
|
|
42
|
+
const REPUTATION_BLOCK = /\b5\.7\.\d{1,3}\b/;
|
|
43
|
+
const MAILBOX_FULL_CODE = /\b[45]\.2\.2\b/;
|
|
44
|
+
const NO_SUCH_USER_CODE = /\b5\.1\.\d{1,3}\b/;
|
|
45
|
+
const TRANSIENT_CODE = /(\b4\.\d{1,3}\.\d{1,3}\b|\b4\d{2}\b)/;
|
|
46
|
+
const MAILBOX_FULL_TEXT = /(mailbox (?:is )?full|over quota|quota exceeded|insufficient storage)/i;
|
|
47
|
+
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;
|
|
48
|
+
const PROVIDER_REJECTED_TEXT = /(message rejected|has been blocked|\bblocked\b|\bbanned\b|\bspam\b|local policy violation|unsolicited|reputation)/i;
|
|
49
|
+
const TRANSIENT_TEXT = /(temporar|will retry|try again later|rate ?limit|throttl)/i;
|
|
50
|
+
// Greedy address capture: the character class already stops at whitespace and
|
|
51
|
+
// angle brackets, so trailing sentence punctuation is stripped afterwards. A lazy
|
|
52
|
+
// capture would stop at the dot inside "gmail.com".
|
|
53
|
+
const ADDRESS = "([^\\s<>,;()]+@[^\\s<>,;()]+)";
|
|
54
|
+
const RECIPIENT_PATTERNS = [
|
|
55
|
+
new RegExp(`your message to\\s+<?${ADDRESS}>?`, "i"),
|
|
56
|
+
new RegExp(`\\bto\\s+<?${ADDRESS}>?\\s+(?:was|were|could not|couldn't|has been|had been)`, "i"),
|
|
57
|
+
// Machine-readable half of a real RFC 3464 report, when the provider sends one.
|
|
58
|
+
new RegExp(`(?:final|original)-recipient:\\s*rfc822;\\s*<?${ADDRESS}>?`, "i"),
|
|
59
|
+
];
|
|
60
|
+
function readRecipient(haystack) {
|
|
61
|
+
for (const pattern of RECIPIENT_PATTERNS) {
|
|
62
|
+
const match = pattern.exec(haystack);
|
|
63
|
+
const value = match?.[1]?.replace(/[.,;:>]+$/, "").trim().toLowerCase();
|
|
64
|
+
if (value && value.includes("@"))
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
function readProvider(from, haystack) {
|
|
70
|
+
// The 69585 URL is decisive: only Google emits it, and it is the exact article
|
|
71
|
+
// a Gmail reputation block cites.
|
|
72
|
+
if (GOOGLE_BLOCK_URL.test(haystack) || GOOGLE_FROM.test(from))
|
|
73
|
+
return "google";
|
|
74
|
+
if (MICROSOFT_FROM.test(from) || MICROSOFT_BODY.test(haystack))
|
|
75
|
+
return "microsoft";
|
|
76
|
+
return "unknown";
|
|
77
|
+
}
|
|
78
|
+
function readKind(subject, haystack, hasBody) {
|
|
79
|
+
// Order is load-bearing. A Google "(Delay)" report can quote block-sounding
|
|
80
|
+
// prose while the message is still queued, so the transient marker wins first;
|
|
81
|
+
// an explicit enhanced code then beats keyword matching, because "blocked" and
|
|
82
|
+
// "spam" appear in boilerplate that a 5.1.1 no-such-user report also carries.
|
|
83
|
+
if (/\(delay\)/i.test(subject))
|
|
84
|
+
return "delayed";
|
|
85
|
+
if (REPUTATION_BLOCK.test(haystack))
|
|
86
|
+
return "provider_rejected";
|
|
87
|
+
if (MAILBOX_FULL_CODE.test(haystack) || MAILBOX_FULL_TEXT.test(haystack))
|
|
88
|
+
return "mailbox_full";
|
|
89
|
+
if (NO_SUCH_USER_CODE.test(haystack) || NO_SUCH_USER_TEXT.test(haystack))
|
|
90
|
+
return "no_such_user";
|
|
91
|
+
if (PROVIDER_REJECTED_TEXT.test(haystack))
|
|
92
|
+
return "provider_rejected";
|
|
93
|
+
if (TRANSIENT_CODE.test(haystack) || TRANSIENT_TEXT.test(haystack))
|
|
94
|
+
return "delayed";
|
|
95
|
+
// Nothing matched AND there was no body to match against: the sync stored the
|
|
96
|
+
// envelope only. Reporting that as "other" would be a false clean.
|
|
97
|
+
return hasBody ? "other" : "unclassified";
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Classify one stored inbound message as a delivery-status notification.
|
|
101
|
+
*
|
|
102
|
+
* Returns null when the message is not a DSN at all — the caller's SQL prefilter
|
|
103
|
+
* is intentionally wide, and a false positive here would invent a deliverability
|
|
104
|
+
* incident out of a customer reply.
|
|
105
|
+
*/
|
|
106
|
+
export function classifyDeliveryStatusNotification(input) {
|
|
107
|
+
const from = typeof input.fromAddress === "string" ? input.fromAddress : "";
|
|
108
|
+
const subject = typeof input.subject === "string" ? input.subject : "";
|
|
109
|
+
const body = typeof input.bodyText === "string" ? input.bodyText : "";
|
|
110
|
+
if (!DSN_FROM_PATTERN.test(from) && !DSN_SUBJECT_PATTERN.test(subject))
|
|
111
|
+
return null;
|
|
112
|
+
const haystack = `${subject}\n${body}`;
|
|
113
|
+
const smtpCode = ENHANCED_CODE.exec(haystack)?.[1] ?? SMTP_REPLY_CODE.exec(haystack)?.[1] ?? null;
|
|
114
|
+
return {
|
|
115
|
+
kind: readKind(subject, haystack, body.trim().length > 0),
|
|
116
|
+
provider: readProvider(from, haystack),
|
|
117
|
+
recipient: readRecipient(haystack),
|
|
118
|
+
smtpCode,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PURE warm-up readiness rule: what daily cold volume a sending mailbox can
|
|
3
|
+
* actually carry, given its warm-up age rather than a provider's opinion of it.
|
|
4
|
+
*
|
|
5
|
+
* WHY it lives in @oxygen/shared and not next to the other email-health rules in
|
|
6
|
+
* @oxygen/integrations: the tenant rollup computes this per mailbox, and
|
|
7
|
+
* @oxygen/tenant-db must never import @oxygen/integrations (integrations depends
|
|
8
|
+
* on tenant-db). Duplicating the thresholds so each package could own a copy is
|
|
9
|
+
* exactly how two surfaces start recommending different numbers, so the rule is
|
|
10
|
+
* defined once here and re-exported from
|
|
11
|
+
* packages/integrations/src/email-health/health-state.ts, which stays the
|
|
12
|
+
* email-health surface every caller reads.
|
|
13
|
+
*
|
|
14
|
+
* DIRECTIONAL, like everything else in the deliverability cluster: it produces a
|
|
15
|
+
* recommendation and a launch-preview warning. It never clamps a cap, never
|
|
16
|
+
* pauses a mailbox, and never changes what a sequence sends.
|
|
17
|
+
*/
|
|
18
|
+
/** Below this warm-up day a mailbox should carry only the starter cold volume. */
|
|
19
|
+
export declare const WARMUP_EARLY_DAY_LIMIT = 14;
|
|
20
|
+
/** Below this warm-up day a mailbox should stay at the reduced cold volume. */
|
|
21
|
+
export declare const WARMUP_ESTABLISHED_DAY_LIMIT = 28;
|
|
22
|
+
/** Recommended cold sends/day before day 14 (and for an un-warmed young mailbox). */
|
|
23
|
+
export declare const WARMUP_EARLY_DAILY_CAP = 5;
|
|
24
|
+
/** Recommended cold sends/day between day 14 and day 28. */
|
|
25
|
+
export declare const WARMUP_ESTABLISHED_DAILY_CAP = 10;
|
|
26
|
+
/** A warm-up health score under this is treated as "not ready for volume". */
|
|
27
|
+
export declare const WARMUP_HEALTH_SCORE_FLOOR = 60;
|
|
28
|
+
/** Hard-bounce rate above which a fleet is burning its domains, not just its list. */
|
|
29
|
+
export declare const HARD_BOUNCE_RATE_CEILING = 0.03;
|
|
30
|
+
/** Below this send volume a bounce rate is noise, not a signal. */
|
|
31
|
+
export declare const HARD_BOUNCE_RATE_MIN_SENDS = 20;
|
|
32
|
+
/**
|
|
33
|
+
* The DIRECTIONAL per-mailbox daily cold-send ceiling warm-up readiness supports,
|
|
34
|
+
* or null when nothing in the evidence argues for holding volume back.
|
|
35
|
+
*
|
|
36
|
+
* Pure — the caller resolves `mailboxAgeDays` from created_at, so this stays
|
|
37
|
+
* clock-free and identical on every surface.
|
|
38
|
+
*
|
|
39
|
+
* - warm-up day < 14 -> 5/day
|
|
40
|
+
* - mailbox younger than 28 days, with either no active
|
|
41
|
+
* warm-up or no warm-up day reported at all -> 5/day
|
|
42
|
+
* - warm-up day < 28 -> 10/day
|
|
43
|
+
* - warm-up health score < 60 -> at most 5/day
|
|
44
|
+
* - otherwise -> null (no advice)
|
|
45
|
+
*
|
|
46
|
+
* The second rule is the one that matters for a freshly provisioned fleet: a
|
|
47
|
+
* mailbox whose warm-up never started (state "unknown") reports no day at all,
|
|
48
|
+
* and a rule keyed only on the day number would have said nothing about the exact
|
|
49
|
+
* mailboxes most likely to get blocked.
|
|
50
|
+
*/
|
|
51
|
+
export declare function recommendedDailyCapForWarmup(input: {
|
|
52
|
+
warmupState?: string | null;
|
|
53
|
+
warmupDay?: number | null;
|
|
54
|
+
warmupHealthScore?: number | null;
|
|
55
|
+
mailboxAgeDays?: number | null;
|
|
56
|
+
}): number | null;
|
|
57
|
+
/**
|
|
58
|
+
* True when a hard-bounce count is high enough, over enough sends, to be a real
|
|
59
|
+
* reputation problem rather than list noise.
|
|
60
|
+
*/
|
|
61
|
+
export declare function hardBounceRateIsHigh(input: {
|
|
62
|
+
hardBounces: number;
|
|
63
|
+
coldSends: number;
|
|
64
|
+
}): boolean;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PURE warm-up readiness rule: what daily cold volume a sending mailbox can
|
|
3
|
+
* actually carry, given its warm-up age rather than a provider's opinion of it.
|
|
4
|
+
*
|
|
5
|
+
* WHY it lives in @oxygen/shared and not next to the other email-health rules in
|
|
6
|
+
* @oxygen/integrations: the tenant rollup computes this per mailbox, and
|
|
7
|
+
* @oxygen/tenant-db must never import @oxygen/integrations (integrations depends
|
|
8
|
+
* on tenant-db). Duplicating the thresholds so each package could own a copy is
|
|
9
|
+
* exactly how two surfaces start recommending different numbers, so the rule is
|
|
10
|
+
* defined once here and re-exported from
|
|
11
|
+
* packages/integrations/src/email-health/health-state.ts, which stays the
|
|
12
|
+
* email-health surface every caller reads.
|
|
13
|
+
*
|
|
14
|
+
* DIRECTIONAL, like everything else in the deliverability cluster: it produces a
|
|
15
|
+
* recommendation and a launch-preview warning. It never clamps a cap, never
|
|
16
|
+
* pauses a mailbox, and never changes what a sequence sends.
|
|
17
|
+
*/
|
|
18
|
+
/** Below this warm-up day a mailbox should carry only the starter cold volume. */
|
|
19
|
+
export const WARMUP_EARLY_DAY_LIMIT = 14;
|
|
20
|
+
/** Below this warm-up day a mailbox should stay at the reduced cold volume. */
|
|
21
|
+
export const WARMUP_ESTABLISHED_DAY_LIMIT = 28;
|
|
22
|
+
/** Recommended cold sends/day before day 14 (and for an un-warmed young mailbox). */
|
|
23
|
+
export const WARMUP_EARLY_DAILY_CAP = 5;
|
|
24
|
+
/** Recommended cold sends/day between day 14 and day 28. */
|
|
25
|
+
export const WARMUP_ESTABLISHED_DAILY_CAP = 10;
|
|
26
|
+
/** A warm-up health score under this is treated as "not ready for volume". */
|
|
27
|
+
export const WARMUP_HEALTH_SCORE_FLOOR = 60;
|
|
28
|
+
/** Hard-bounce rate above which a fleet is burning its domains, not just its list. */
|
|
29
|
+
export const HARD_BOUNCE_RATE_CEILING = 0.03;
|
|
30
|
+
/** Below this send volume a bounce rate is noise, not a signal. */
|
|
31
|
+
export const HARD_BOUNCE_RATE_MIN_SENDS = 20;
|
|
32
|
+
/** Warm-up states in which a vendor is actively conditioning the mailbox. */
|
|
33
|
+
const ACTIVE_WARMUP_STATES = new Set(["warming", "active"]);
|
|
34
|
+
function finiteOrNull(value) {
|
|
35
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The DIRECTIONAL per-mailbox daily cold-send ceiling warm-up readiness supports,
|
|
39
|
+
* or null when nothing in the evidence argues for holding volume back.
|
|
40
|
+
*
|
|
41
|
+
* Pure — the caller resolves `mailboxAgeDays` from created_at, so this stays
|
|
42
|
+
* clock-free and identical on every surface.
|
|
43
|
+
*
|
|
44
|
+
* - warm-up day < 14 -> 5/day
|
|
45
|
+
* - mailbox younger than 28 days, with either no active
|
|
46
|
+
* warm-up or no warm-up day reported at all -> 5/day
|
|
47
|
+
* - warm-up day < 28 -> 10/day
|
|
48
|
+
* - warm-up health score < 60 -> at most 5/day
|
|
49
|
+
* - otherwise -> null (no advice)
|
|
50
|
+
*
|
|
51
|
+
* The second rule is the one that matters for a freshly provisioned fleet: a
|
|
52
|
+
* mailbox whose warm-up never started (state "unknown") reports no day at all,
|
|
53
|
+
* and a rule keyed only on the day number would have said nothing about the exact
|
|
54
|
+
* mailboxes most likely to get blocked.
|
|
55
|
+
*/
|
|
56
|
+
export function recommendedDailyCapForWarmup(input) {
|
|
57
|
+
const day = finiteOrNull(input.warmupDay);
|
|
58
|
+
const ageDays = finiteOrNull(input.mailboxAgeDays);
|
|
59
|
+
const healthScore = finiteOrNull(input.warmupHealthScore);
|
|
60
|
+
const warmingNow = ACTIVE_WARMUP_STATES.has((input.warmupState ?? "").trim().toLowerCase());
|
|
61
|
+
let cap = null;
|
|
62
|
+
if (day !== null && day < WARMUP_EARLY_DAY_LIMIT) {
|
|
63
|
+
cap = WARMUP_EARLY_DAILY_CAP;
|
|
64
|
+
}
|
|
65
|
+
else if (ageDays !== null &&
|
|
66
|
+
ageDays < WARMUP_ESTABLISHED_DAY_LIMIT &&
|
|
67
|
+
(day === null || !warmingNow)) {
|
|
68
|
+
// A rail that reports "warming" but no day number proves nothing about how
|
|
69
|
+
// far the ramp got, so age still governs. Gating this on !warmingNow alone
|
|
70
|
+
// left exactly that mailbox — enrolled, young, no telemetry — with no advice.
|
|
71
|
+
cap = WARMUP_EARLY_DAILY_CAP;
|
|
72
|
+
}
|
|
73
|
+
else if (day !== null && day < WARMUP_ESTABLISHED_DAY_LIMIT) {
|
|
74
|
+
cap = WARMUP_ESTABLISHED_DAILY_CAP;
|
|
75
|
+
}
|
|
76
|
+
if (healthScore !== null && healthScore < WARMUP_HEALTH_SCORE_FLOOR) {
|
|
77
|
+
cap = cap === null ? WARMUP_EARLY_DAILY_CAP : Math.min(cap, WARMUP_EARLY_DAILY_CAP);
|
|
78
|
+
}
|
|
79
|
+
return cap;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* True when a hard-bounce count is high enough, over enough sends, to be a real
|
|
83
|
+
* reputation problem rather than list noise.
|
|
84
|
+
*/
|
|
85
|
+
export function hardBounceRateIsHigh(input) {
|
|
86
|
+
if (!Number.isFinite(input.coldSends) || input.coldSends < HARD_BOUNCE_RATE_MIN_SENDS) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
return input.hardBounces / input.coldSends > HARD_BOUNCE_RATE_CEILING;
|
|
90
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a hosted inbox-avatar URL may be minted, and how.
|
|
3
|
+
*
|
|
4
|
+
* An avatar URL is handed to an external inbox vendor that fetches it server-side,
|
|
5
|
+
* later, possibly more than once — and Oxygen stores nothing about that hand-off
|
|
6
|
+
* afterwards, so a bad URL cannot be found again or reissued. A URL minted on a
|
|
7
|
+
* preview deployment would be baked into a vendor's mailbox record and die when
|
|
8
|
+
* that deployment does; one minted on localhost never resolves at all. So the
|
|
9
|
+
* origin is an allowlist, not "whatever host served this request", and the SAME
|
|
10
|
+
* allowlist binds every minter: the web request path (which prefers the inbound
|
|
11
|
+
* host) and the worker's LinkedIn-photo backfill (which only has the configured
|
|
12
|
+
* app URL). Two allowlists would drift, and the drift would ship as a mailbox
|
|
13
|
+
* photo that resolves from one deployment and 404s from the other.
|
|
14
|
+
*/
|
|
15
|
+
export declare const INBOX_AVATAR_ORIGIN_HOSTS: ReadonlySet<string>;
|
|
16
|
+
/**
|
|
17
|
+
* The first candidate that is an https URL on an allowed host, normalized to
|
|
18
|
+
* `protocol//host`, or null when none qualifies. Returning null rather than a
|
|
19
|
+
* best-effort URL is the point: on localhost the only honest answer is "not from
|
|
20
|
+
* here", and an `http://localhost` URL would sail through this function only to
|
|
21
|
+
* be rejected by an https-only check one step later and much harder to read.
|
|
22
|
+
*/
|
|
23
|
+
export declare function resolveInboxAvatarOrigin(candidates: readonly (string | null | undefined)[]): string | null;
|
|
24
|
+
/**
|
|
25
|
+
* The public, permanent URL for a stored inbox avatar on an already-resolved
|
|
26
|
+
* origin — or null when there is no origin, or the key is not an inbox avatar.
|
|
27
|
+
*/
|
|
28
|
+
export declare function inboxAvatarPublicUrlForOrigin(origin: string | null, storageKey: string): string | null;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a hosted inbox-avatar URL may be minted, and how.
|
|
3
|
+
*
|
|
4
|
+
* An avatar URL is handed to an external inbox vendor that fetches it server-side,
|
|
5
|
+
* later, possibly more than once — and Oxygen stores nothing about that hand-off
|
|
6
|
+
* afterwards, so a bad URL cannot be found again or reissued. A URL minted on a
|
|
7
|
+
* preview deployment would be baked into a vendor's mailbox record and die when
|
|
8
|
+
* that deployment does; one minted on localhost never resolves at all. So the
|
|
9
|
+
* origin is an allowlist, not "whatever host served this request", and the SAME
|
|
10
|
+
* allowlist binds every minter: the web request path (which prefers the inbound
|
|
11
|
+
* host) and the worker's LinkedIn-photo backfill (which only has the configured
|
|
12
|
+
* app URL). Two allowlists would drift, and the drift would ship as a mailbox
|
|
13
|
+
* photo that resolves from one deployment and 404s from the other.
|
|
14
|
+
*/
|
|
15
|
+
export const INBOX_AVATAR_ORIGIN_HOSTS = new Set([
|
|
16
|
+
"oxygen-agent.com",
|
|
17
|
+
"www.oxygen-agent.com",
|
|
18
|
+
"dev.oxygen-agent.com",
|
|
19
|
+
]);
|
|
20
|
+
const INBOX_AVATAR_KEY_PREFIX = "inbox-avatars/";
|
|
21
|
+
/**
|
|
22
|
+
* The first candidate that is an https URL on an allowed host, normalized to
|
|
23
|
+
* `protocol//host`, or null when none qualifies. Returning null rather than a
|
|
24
|
+
* best-effort URL is the point: on localhost the only honest answer is "not from
|
|
25
|
+
* here", and an `http://localhost` URL would sail through this function only to
|
|
26
|
+
* be rejected by an https-only check one step later and much harder to read.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveInboxAvatarOrigin(candidates) {
|
|
29
|
+
for (const candidate of candidates) {
|
|
30
|
+
if (!candidate)
|
|
31
|
+
continue;
|
|
32
|
+
try {
|
|
33
|
+
const url = new URL(candidate);
|
|
34
|
+
if (url.protocol === "https:" && INBOX_AVATAR_ORIGIN_HOSTS.has(url.host)) {
|
|
35
|
+
return `${url.protocol}//${url.host}`;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Not a URL at all — try the next candidate.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The public, permanent URL for a stored inbox avatar on an already-resolved
|
|
46
|
+
* origin — or null when there is no origin, or the key is not an inbox avatar.
|
|
47
|
+
*/
|
|
48
|
+
export function inboxAvatarPublicUrlForOrigin(origin, storageKey) {
|
|
49
|
+
if (!origin || !storageKey.startsWith(INBOX_AVATAR_KEY_PREFIX))
|
|
50
|
+
return null;
|
|
51
|
+
const path = storageKey
|
|
52
|
+
.slice(INBOX_AVATAR_KEY_PREFIX.length)
|
|
53
|
+
.split("/")
|
|
54
|
+
.map((segment) => encodeURIComponent(segment))
|
|
55
|
+
.join("/");
|
|
56
|
+
return `${origin}/api/inbox-avatars/${path}`;
|
|
57
|
+
}
|
|
@@ -30,20 +30,27 @@ export * from "./copilot-journeys.js";
|
|
|
30
30
|
export * from "./copilot-plan.js";
|
|
31
31
|
export * from "./credit-guidance.js";
|
|
32
32
|
export * from "./directory.js";
|
|
33
|
+
export * from "./email-dsn.js";
|
|
34
|
+
export * from "./email-warmup-readiness.js";
|
|
33
35
|
export * from "./email-tracking-token.js";
|
|
34
36
|
export * from "./email-unsubscribe-token.js";
|
|
35
37
|
export * from "./error-redaction.js";
|
|
36
38
|
export * from "./future-signup-events.js";
|
|
37
39
|
export * from "./future-signup-lifecycle-projection.js";
|
|
38
40
|
export * from "./feature-gates.js";
|
|
41
|
+
export * from "./product-analytics-core.js";
|
|
42
|
+
export * from "./product-analytics-environment.js";
|
|
43
|
+
export * from "./product-analytics-events.js";
|
|
39
44
|
export * from "./hosted-ai.js";
|
|
40
45
|
export * from "./identifiers.js";
|
|
41
46
|
export * from "./knowledge-constants.js";
|
|
42
47
|
export * from "./knowledge-bootstrap.js";
|
|
43
48
|
export * from "./knowledge-links.js";
|
|
44
49
|
export * from "./knowledge-markdown.js";
|
|
50
|
+
export * from "./knowledge-vault-markdown.js";
|
|
45
51
|
export * from "./knowledge-seed-content.js";
|
|
46
52
|
export * from "./langfuse.js";
|
|
53
|
+
export * from "./llm-usage.js";
|
|
47
54
|
export * from "./linkedin-mentions.js";
|
|
48
55
|
export * from "./linkedin-post-url.js";
|
|
49
56
|
export * from "./linkedin-quota-denial.js";
|
|
@@ -113,3 +120,6 @@ export declare function isVersionGreater(a: string, b: string): boolean;
|
|
|
113
120
|
/** True when `a` is a strictly lesser semantic version than `b`. */
|
|
114
121
|
export declare function isVersionLess(a: string, b: string): boolean;
|
|
115
122
|
export * from "./ugc.js";
|
|
123
|
+
export * from "./ugc-amplification-identity.js";
|
|
124
|
+
export * from "./knowledge-repository.js";
|
|
125
|
+
export * from "./knowledge-bases.js";
|
|
@@ -30,20 +30,27 @@ export * from "./copilot-journeys.js";
|
|
|
30
30
|
export * from "./copilot-plan.js";
|
|
31
31
|
export * from "./credit-guidance.js";
|
|
32
32
|
export * from "./directory.js";
|
|
33
|
+
export * from "./email-dsn.js";
|
|
34
|
+
export * from "./email-warmup-readiness.js";
|
|
33
35
|
export * from "./email-tracking-token.js";
|
|
34
36
|
export * from "./email-unsubscribe-token.js";
|
|
35
37
|
export * from "./error-redaction.js";
|
|
36
38
|
export * from "./future-signup-events.js";
|
|
37
39
|
export * from "./future-signup-lifecycle-projection.js";
|
|
38
40
|
export * from "./feature-gates.js";
|
|
41
|
+
export * from "./product-analytics-core.js";
|
|
42
|
+
export * from "./product-analytics-environment.js";
|
|
43
|
+
export * from "./product-analytics-events.js";
|
|
39
44
|
export * from "./hosted-ai.js";
|
|
40
45
|
export * from "./identifiers.js";
|
|
41
46
|
export * from "./knowledge-constants.js";
|
|
42
47
|
export * from "./knowledge-bootstrap.js";
|
|
43
48
|
export * from "./knowledge-links.js";
|
|
44
49
|
export * from "./knowledge-markdown.js";
|
|
50
|
+
export * from "./knowledge-vault-markdown.js";
|
|
45
51
|
export * from "./knowledge-seed-content.js";
|
|
46
52
|
export * from "./langfuse.js";
|
|
53
|
+
export * from "./llm-usage.js";
|
|
47
54
|
export * from "./linkedin-mentions.js";
|
|
48
55
|
export * from "./linkedin-post-url.js";
|
|
49
56
|
export * from "./linkedin-quota-denial.js";
|
|
@@ -149,3 +156,6 @@ export function isVersionLess(a, b) {
|
|
|
149
156
|
return compareSemver(a, b) < 0;
|
|
150
157
|
}
|
|
151
158
|
export * from "./ugc.js";
|
|
159
|
+
export * from "./ugc-amplification-identity.js";
|
|
160
|
+
export * from "./knowledge-repository.js";
|
|
161
|
+
export * from "./knowledge-bases.js";
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export type KnowledgeBaseViewType = "table" | "cards" | "kanban";
|
|
2
|
+
export type KnowledgeBaseFilter = string | {
|
|
3
|
+
and?: KnowledgeBaseFilter[];
|
|
4
|
+
or?: KnowledgeBaseFilter[];
|
|
5
|
+
not?: KnowledgeBaseFilter[];
|
|
6
|
+
};
|
|
7
|
+
export type KnowledgeBaseIssue = {
|
|
8
|
+
code: "invalid_base" | "unsupported_filter" | "unsupported_view" | "invalid_view";
|
|
9
|
+
message: string;
|
|
10
|
+
expression?: string;
|
|
11
|
+
view?: string;
|
|
12
|
+
};
|
|
13
|
+
export type KnowledgeBaseProperty = {
|
|
14
|
+
displayName?: string;
|
|
15
|
+
raw: Record<string, unknown>;
|
|
16
|
+
};
|
|
17
|
+
export type KnowledgeBaseView = {
|
|
18
|
+
type: string;
|
|
19
|
+
name: string;
|
|
20
|
+
groupBy?: {
|
|
21
|
+
property: string;
|
|
22
|
+
direction: "ASC" | "DESC";
|
|
23
|
+
};
|
|
24
|
+
order: string[];
|
|
25
|
+
boardColumns: string[];
|
|
26
|
+
cardTitleProperty?: string;
|
|
27
|
+
sort: Array<{
|
|
28
|
+
property: string;
|
|
29
|
+
direction: "ASC" | "DESC";
|
|
30
|
+
}>;
|
|
31
|
+
filters?: KnowledgeBaseFilter;
|
|
32
|
+
columnColors: Record<string, string>;
|
|
33
|
+
wipLimits: Record<string, number>;
|
|
34
|
+
raw: Record<string, unknown>;
|
|
35
|
+
};
|
|
36
|
+
export type KnowledgeBaseDefinition = {
|
|
37
|
+
path: string;
|
|
38
|
+
name: string;
|
|
39
|
+
filters?: KnowledgeBaseFilter;
|
|
40
|
+
properties: Record<string, KnowledgeBaseProperty>;
|
|
41
|
+
views: KnowledgeBaseView[];
|
|
42
|
+
raw: Record<string, unknown>;
|
|
43
|
+
};
|
|
44
|
+
export type KnowledgeBaseFile = {
|
|
45
|
+
id: string;
|
|
46
|
+
path: string;
|
|
47
|
+
body?: string;
|
|
48
|
+
properties: Record<string, unknown>;
|
|
49
|
+
revision: number;
|
|
50
|
+
};
|
|
51
|
+
export type KnowledgeBaseParseResult = {
|
|
52
|
+
definition: KnowledgeBaseDefinition | null;
|
|
53
|
+
issues: KnowledgeBaseIssue[];
|
|
54
|
+
};
|
|
55
|
+
export type KnowledgeBaseEvaluation = {
|
|
56
|
+
files: KnowledgeBaseFile[];
|
|
57
|
+
issues: KnowledgeBaseIssue[];
|
|
58
|
+
};
|
|
59
|
+
export type KnowledgeBaseGroup = {
|
|
60
|
+
key: string;
|
|
61
|
+
title: string;
|
|
62
|
+
files: KnowledgeBaseFile[];
|
|
63
|
+
};
|
|
64
|
+
export declare function parseKnowledgeBase(path: string, source: string): KnowledgeBaseParseResult;
|
|
65
|
+
export declare function knowledgeBasePropertyKey(property: string): string;
|
|
66
|
+
export declare function knowledgeBasePropertyValue(file: KnowledgeBaseFile, property: string): unknown;
|
|
67
|
+
export declare function knowledgeBasePropertyText(file: KnowledgeBaseFile, property: string): string;
|
|
68
|
+
export declare function evaluateKnowledgeBaseView(definition: KnowledgeBaseDefinition, view: KnowledgeBaseView, files: readonly KnowledgeBaseFile[]): KnowledgeBaseEvaluation;
|
|
69
|
+
export declare function knowledgeBaseGroups(view: KnowledgeBaseView, files: readonly KnowledgeBaseFile[], configuredColumns?: readonly string[]): KnowledgeBaseGroup[];
|
|
70
|
+
export declare function knowledgeBaseDisplayName(definition: KnowledgeBaseDefinition, property: string): string;
|
|
71
|
+
export declare function knowledgeBaseCardTitle(file: KnowledgeBaseFile, view: KnowledgeBaseView): string;
|
|
72
|
+
export declare function parseBaseBoardColumnOrder(value: unknown): Record<string, string[]>;
|
|
73
|
+
/** Resolve Base Board's `<folder>::<view>::<property>` lane order, with view-name shorthand for API callers. */
|
|
74
|
+
export declare function resolveBaseBoardColumnOrder(definition: KnowledgeBaseDefinition, view: KnowledgeBaseView, configured: Readonly<Record<string, readonly string[]>>): string[];
|