@oxygen-agent/cli 1.922.14 → 1.948.1
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.d.ts +18 -0
- package/dist/admin-primary-providers-render.js +371 -0
- package/dist/command-manifest.js +30 -2
- package/dist/functions-commands.d.ts +6 -0
- package/dist/functions-commands.js +56 -0
- package/dist/help.js +1 -0
- package/dist/http-client.d.ts +4 -0
- package/dist/http-client.js +49 -2
- package/dist/index.js +515 -92
- package/dist/ugc-commands.d.ts +6 -0
- package/dist/ugc-commands.js +1089 -0
- package/dist/visual-commands.d.ts +6 -0
- package/dist/visual-commands.js +57 -0
- package/dist/visual-render-wait.d.ts +3 -0
- package/dist/visual-render-wait.js +56 -0
- package/node_modules/@oxygen/shared/dist/byok-connect.d.ts +48 -0
- package/node_modules/@oxygen/shared/dist/byok-connect.js +92 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +77 -13
- 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/feature-gates.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/feature-gates.js +3 -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-bootstrap.d.ts +50 -21
- package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +47 -21
- package/node_modules/@oxygen/shared/dist/knowledge-constants.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/knowledge-constants.js +3 -2
- package/node_modules/@oxygen/shared/dist/knowledge-seed-content.js +1 -1
- package/node_modules/@oxygen/shared/dist/langfuse.d.ts +8 -3
- package/node_modules/@oxygen/shared/dist/langfuse.js +185 -121
- 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/object-storage.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/object-storage.js +5 -0
- 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 +92 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-events.js +96 -0
- package/node_modules/@oxygen/shared/dist/provider-balance-signal.d.ts +113 -0
- package/node_modules/@oxygen/shared/dist/provider-balance-signal.js +158 -0
- package/node_modules/@oxygen/shared/dist/sequence-failures.d.ts +12 -0
- package/node_modules/@oxygen/shared/dist/sequence-failures.js +24 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +16 -0
- package/node_modules/@oxygen/shared/dist/sequences.js +54 -0
- package/node_modules/@oxygen/shared/dist/ugc.d.ts +133 -0
- package/node_modules/@oxygen/shared/dist/ugc.js +2 -0
- package/node_modules/@oxygen/shared/dist/vercel-sandbox-fetch.d.ts +9 -0
- package/node_modules/@oxygen/shared/dist/vercel-sandbox-fetch.js +33 -0
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +1 -1
- package/node_modules/@oxygen/shared/dist/visual-render.d.ts +30 -0
- package/node_modules/@oxygen/shared/dist/visual-render.js +55 -0
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +43 -0
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +126 -0
- package/node_modules/@oxygen/shared/package.json +10 -0
- package/node_modules/@oxygen/workflows/dist/graph/lint.js +22 -0
- package/package.json +1 -1
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { OxygenError } from "@oxygen/shared";
|
|
5
|
+
import { requestOxygen } from "./http-client.js";
|
|
6
|
+
import { waitForVisualRender } from "./visual-render-wait.js";
|
|
7
|
+
export function registerVisualCommands(program, handle) {
|
|
8
|
+
const visuals = program.command("visuals").description("Author, render, inspect and download GTM infographics using tenant workspace files and governed render jobs.");
|
|
9
|
+
visuals.command("source-create").description("Save an immutable HTML/CSS/SVG source revision; no code executes.")
|
|
10
|
+
.requiredOption("--name <name>", "Workspace filename.")
|
|
11
|
+
.requiredOption("--file <path>", "Local HTML source file (max 1.5 MB).")
|
|
12
|
+
.option("--parent-file-id <id>", "Previous source revision UUID.").option("--json", "Print a JSON envelope.")
|
|
13
|
+
.action((options) => handle("visuals source create", options, async () => {
|
|
14
|
+
if (statSync(options.file).size > 1_500_000)
|
|
15
|
+
throw new OxygenError("visual_source_limit", "HTML source must fit within 1.5 MB.");
|
|
16
|
+
const bytes = readFileSync(options.file);
|
|
17
|
+
if (bytes.length > 1_500_000)
|
|
18
|
+
throw new OxygenError("visual_source_limit", "HTML source must fit within 1.5 MB.");
|
|
19
|
+
return requestOxygen("/api/cli/visuals/sources", { method: "POST", body: { name: options.name, html: bytes.toString("utf8"), ...(options.parentFileId ? { parent_file_id: options.parentFileId } : {}) } });
|
|
20
|
+
}));
|
|
21
|
+
visuals.command("file-get <file-id>").description("Read source, file metadata, provenance and web/download links.").option("--json", "Print a JSON envelope.")
|
|
22
|
+
.action((id, options) => handle("visuals file get", options, () => requestOxygen(`/api/cli/visuals/files/${encodeURIComponent(id)}`)));
|
|
23
|
+
visuals.command("download <file-id>").description("Download a file with SHA-256 verification; refuses to overwrite existing local files.")
|
|
24
|
+
.requiredOption("--out <path>", "New local destination filename.").option("--json", "Print a JSON envelope.")
|
|
25
|
+
.action((id, options) => handle("visuals download", options, async () => {
|
|
26
|
+
const path = `/api/cli/visuals/files/${encodeURIComponent(id)}`;
|
|
27
|
+
const data = await requestOxygen(path);
|
|
28
|
+
const bytes = await requestOxygen(`${path}/download`, { binaryMaxBytes: Math.max(1, data.file.size_bytes) });
|
|
29
|
+
if (bytes.length !== data.file.size_bytes || createHash("sha256").update(bytes).digest("hex") !== data.file.sha256)
|
|
30
|
+
throw new OxygenError("file_integrity", "Downloaded bytes do not match the workspace file hash.");
|
|
31
|
+
writeFileSync(options.out, bytes, { flag: "wx", mode: 0o600 });
|
|
32
|
+
return { path: resolve(options.out), ...data.file };
|
|
33
|
+
}));
|
|
34
|
+
visuals.command("render-preview").description("Preview rendering without starting paid compute.")
|
|
35
|
+
.requiredOption("--source-file-ids <ids>", "Comma-separated HTML file UUIDs in page order.")
|
|
36
|
+
.option("--width <pixels>", "CSS pixel width.", "1080").option("--height <pixels>", "CSS pixel height.", "1350")
|
|
37
|
+
.option("--scale <1|2>", "PNG scale.", "1").option("--formats <png,pdf>", "Output formats.", "png")
|
|
38
|
+
.option("--json", "Print a JSON envelope.")
|
|
39
|
+
.action((options) => handle("visuals render preview", options, () => requestOxygen("/api/cli/visuals/renders", { method: "POST", body: { source_file_ids: options.sourceFileIds.split(",").map((s) => s.trim()), width: Number(options.width), height: Number(options.height), scale: Number(options.scale), formats: options.formats.split(",") } })));
|
|
40
|
+
visuals.command("render-start <render-id>").description("Approve an exact preview and enqueue one idempotent render job.")
|
|
41
|
+
.requiredOption("--scope-hash <hash>", "Exact preview scope hash.").requiredOption("--max-credits <credits>", "Approved ceiling from preview.")
|
|
42
|
+
.requiredOption("--approved", "Authorize this exact render.").option("--json", "Print a JSON envelope.")
|
|
43
|
+
.action((id, options) => handle("visuals render start", options, () => requestOxygen(`/api/cli/visuals/renders/${encodeURIComponent(id)}`, { method: "POST", body: { scope_hash: options.scopeHash, max_credits: Number(options.maxCredits), approved: options.approved } })));
|
|
44
|
+
visuals.command("render-get <render-id>").description("Read render state and output downloads; optionally wait for a terminal state.")
|
|
45
|
+
.option("--wait", "Poll every 2 seconds until completed, failed, cancelled or effect_unknown; starts no render.")
|
|
46
|
+
.option("--timeout-seconds <n>", "Maximum wait with --wait, from 1 to 600 seconds. Defaults to 180.")
|
|
47
|
+
.option("--json", "Print a JSON envelope.")
|
|
48
|
+
.action((id, options) => handle("visuals render get", options, async () => {
|
|
49
|
+
if (options.wait)
|
|
50
|
+
return waitForVisualRender(id, options.timeoutSeconds);
|
|
51
|
+
if (options.timeoutSeconds !== undefined)
|
|
52
|
+
throw new OxygenError("invalid_request", "Pass --wait with --timeout-seconds.");
|
|
53
|
+
return requestOxygen(`/api/cli/visuals/renders/${encodeURIComponent(id)}`);
|
|
54
|
+
}));
|
|
55
|
+
visuals.command("render-cancel <render-id>").description("Request cancellation; read state to verify it stopped.")
|
|
56
|
+
.option("--json", "Print a JSON envelope.").action((id, options) => handle("visuals render cancel", options, () => requestOxygen(`/api/cli/visuals/renders/${encodeURIComponent(id)}`, { method: "DELETE" })));
|
|
57
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { OxygenError } from "@oxygen/shared";
|
|
2
|
+
import { readPositiveInt, readRecordString } from "./cli-values.js";
|
|
3
|
+
import { requestOxygen } from "./http-client.js";
|
|
4
|
+
import { waitForCliRun } from "./run-wait.js";
|
|
5
|
+
const TERMINAL = new Set(["completed", "failed", "cancelled", "effect_unknown"]);
|
|
6
|
+
const DEFAULT_TIMEOUT_SECONDS = 180;
|
|
7
|
+
const MAX_TIMEOUT_SECONDS = 600;
|
|
8
|
+
/** Poll only the existing job. Neither terminal failure nor timeout authorizes
|
|
9
|
+
* a start, retry, cancellation, or a new credit reservation. */
|
|
10
|
+
export async function waitForVisualRender(id, requestedTimeoutSeconds) {
|
|
11
|
+
const timeoutSeconds = readPositiveInt(requestedTimeoutSeconds) ?? DEFAULT_TIMEOUT_SECONDS;
|
|
12
|
+
if (timeoutSeconds > MAX_TIMEOUT_SECONDS)
|
|
13
|
+
throw new OxygenError("invalid_number", "Visual render wait timeout must be between 1 and 600 seconds.");
|
|
14
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
15
|
+
const controller = new AbortController();
|
|
16
|
+
let polls = 0;
|
|
17
|
+
let latestStatus = null;
|
|
18
|
+
const timedOut = () => new OxygenError("visual_render_wait_timeout", "Timed out waiting for the visual render. Read the same render ID again to continue checking.", {
|
|
19
|
+
details: { render_id: id, status: latestStatus, timeout_seconds: timeoutSeconds, polls }, exitCode: 1,
|
|
20
|
+
});
|
|
21
|
+
let timer;
|
|
22
|
+
const timeout = new Promise((_, reject) => {
|
|
23
|
+
timer = setTimeout(() => { controller.abort(); reject(timedOut()); }, timeoutSeconds * 1000);
|
|
24
|
+
});
|
|
25
|
+
try {
|
|
26
|
+
return await Promise.race([waitForCliRun({
|
|
27
|
+
runId: id, requestedTimeoutSeconds: String(timeoutSeconds),
|
|
28
|
+
defaultTimeoutSeconds: DEFAULT_TIMEOUT_SECONDS, defaultIntervalSeconds: 2,
|
|
29
|
+
fetchRun: async () => {
|
|
30
|
+
const remainingMs = deadline - Date.now();
|
|
31
|
+
if (controller.signal.aborted || remainingMs <= 0)
|
|
32
|
+
throw timedOut();
|
|
33
|
+
polls += 1;
|
|
34
|
+
const data = await requestOxygen(`/api/cli/visuals/renders/${encodeURIComponent(id)}`, {
|
|
35
|
+
method: "GET", timeoutMs: remainingMs, signal: controller.signal,
|
|
36
|
+
});
|
|
37
|
+
latestStatus = readRecordString(data.render, "status");
|
|
38
|
+
if (latestStatus === "preview")
|
|
39
|
+
throw new OxygenError("visual_render_not_started", "This render is still a preview. Start the approved render before waiting.", {
|
|
40
|
+
details: { render_id: id, status: latestStatus },
|
|
41
|
+
});
|
|
42
|
+
if (!latestStatus || !(TERMINAL.has(latestStatus) || latestStatus === "queued" || latestStatus === "running")) {
|
|
43
|
+
throw new OxygenError("invalid_response", "The render returned an unrecognized status.", { details: { render_id: id, status: latestStatus } });
|
|
44
|
+
}
|
|
45
|
+
return { ...data, status: latestStatus };
|
|
46
|
+
},
|
|
47
|
+
isTerminal: (status) => status !== null && TERMINAL.has(status),
|
|
48
|
+
shapeTerminal: (data, status, count, elapsedMs) => ({ ...data, status, terminal: true, polls: count, elapsedMs }),
|
|
49
|
+
timeoutCode: "visual_render_wait_timeout", timeoutMessage: "Timed out waiting for the visual render.", timeoutDetailIdKey: "render_id",
|
|
50
|
+
}), timeout]);
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
controller.abort();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which providers `oxygen integrations connect <id> --api-key <key>` ACTUALLY
|
|
3
|
+
* connects for native execution.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS. `native_provider.execution.supports_byok` says a TOOL can run on
|
|
6
|
+
* a customer's own key. It does not say the customer has any way to give us one.
|
|
7
|
+
* Those are two different facts, and 9 of the 42 byok-capable native providers
|
|
8
|
+
* have the first without the second:
|
|
9
|
+
*
|
|
10
|
+
* apify builtwith dropleads lusha openwebninja peopledatalabs
|
|
11
|
+
* rocketreach theirstack wiza
|
|
12
|
+
*
|
|
13
|
+
* For those, `oxygen integrations connect <id> --api-key` does not reach
|
|
14
|
+
* `connectViaApiKey` at all. With no native catalog definition,
|
|
15
|
+
* `shouldConnectIntegrationViaComposio` is true, so the key is stored WITH COMPOSIO
|
|
16
|
+
* and no `integration_secrets` row of kind `api_key` is written. On the run path the
|
|
17
|
+
* tool is native generic-REST and `shouldRouteStaticToolThroughComposio` is false, so
|
|
18
|
+
* `resolveOptionalApiKeyCredentials` finds nothing and the call falls back to the
|
|
19
|
+
* MANAGED key — the very key that was out of credits. The command appears to work,
|
|
20
|
+
* changes nothing, and the customer is back where they started with no error. The
|
|
21
|
+
* connect route now refuses each of those nine with a typed `byok_connect_unavailable`
|
|
22
|
+
* rather than a silent Composio "success"; they stay OFF this list until one gains a
|
|
23
|
+
* real native connect form.
|
|
24
|
+
*
|
|
25
|
+
* That is precisely the failure Plain T-104 hit: `parallel.search` was one of the
|
|
26
|
+
* refused tools, and a refusal message that told Mentcape to connect their own Parallel
|
|
27
|
+
* key would once have sent them down a path that silently does nothing. Parallel now has
|
|
28
|
+
* a native api-key connect (catalog.ts) and IS on this list, so that message is finally
|
|
29
|
+
* true for it — the nine above are the ones it must still never name.
|
|
30
|
+
*
|
|
31
|
+
* So a message may only offer BYOK when BOTH halves hold, and this list is the second
|
|
32
|
+
* half. It lives in @oxygen/shared because the two sides that need it cannot see each
|
|
33
|
+
* other: `packages/integrations` writes the message, the catalog it would have to
|
|
34
|
+
* consult lives in `apps/web/src/lib/integrations/catalog.ts`, and packages never
|
|
35
|
+
* import from apps. `catalog.byok-connect.test.ts` in apps/web binds the two together
|
|
36
|
+
* so this list cannot drift from the connect forms it claims exist.
|
|
37
|
+
*
|
|
38
|
+
* ALLOWLIST, NOT DENYLIST, on purpose. A new provider that arrives without a connect
|
|
39
|
+
* form gets the alternatives-only message until someone adds it here — quieter than
|
|
40
|
+
* correct. A denylist would fail the other way: silently promising a connect form
|
|
41
|
+
* nobody built, which is the bug this fixes.
|
|
42
|
+
*/
|
|
43
|
+
export declare const NATIVE_API_KEY_CONNECT_INTEGRATION_IDS: ReadonlySet<string>;
|
|
44
|
+
/**
|
|
45
|
+
* True when a customer can hand Oxygen their own key for this provider AND the
|
|
46
|
+
* native runner will use it. Never infer this from `supports_byok` alone.
|
|
47
|
+
*/
|
|
48
|
+
export declare function hasNativeApiKeyConnect(integrationId: string | null | undefined): boolean;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which providers `oxygen integrations connect <id> --api-key <key>` ACTUALLY
|
|
3
|
+
* connects for native execution.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS. `native_provider.execution.supports_byok` says a TOOL can run on
|
|
6
|
+
* a customer's own key. It does not say the customer has any way to give us one.
|
|
7
|
+
* Those are two different facts, and 9 of the 42 byok-capable native providers
|
|
8
|
+
* have the first without the second:
|
|
9
|
+
*
|
|
10
|
+
* apify builtwith dropleads lusha openwebninja peopledatalabs
|
|
11
|
+
* rocketreach theirstack wiza
|
|
12
|
+
*
|
|
13
|
+
* For those, `oxygen integrations connect <id> --api-key` does not reach
|
|
14
|
+
* `connectViaApiKey` at all. With no native catalog definition,
|
|
15
|
+
* `shouldConnectIntegrationViaComposio` is true, so the key is stored WITH COMPOSIO
|
|
16
|
+
* and no `integration_secrets` row of kind `api_key` is written. On the run path the
|
|
17
|
+
* tool is native generic-REST and `shouldRouteStaticToolThroughComposio` is false, so
|
|
18
|
+
* `resolveOptionalApiKeyCredentials` finds nothing and the call falls back to the
|
|
19
|
+
* MANAGED key — the very key that was out of credits. The command appears to work,
|
|
20
|
+
* changes nothing, and the customer is back where they started with no error. The
|
|
21
|
+
* connect route now refuses each of those nine with a typed `byok_connect_unavailable`
|
|
22
|
+
* rather than a silent Composio "success"; they stay OFF this list until one gains a
|
|
23
|
+
* real native connect form.
|
|
24
|
+
*
|
|
25
|
+
* That is precisely the failure Plain T-104 hit: `parallel.search` was one of the
|
|
26
|
+
* refused tools, and a refusal message that told Mentcape to connect their own Parallel
|
|
27
|
+
* key would once have sent them down a path that silently does nothing. Parallel now has
|
|
28
|
+
* a native api-key connect (catalog.ts) and IS on this list, so that message is finally
|
|
29
|
+
* true for it — the nine above are the ones it must still never name.
|
|
30
|
+
*
|
|
31
|
+
* So a message may only offer BYOK when BOTH halves hold, and this list is the second
|
|
32
|
+
* half. It lives in @oxygen/shared because the two sides that need it cannot see each
|
|
33
|
+
* other: `packages/integrations` writes the message, the catalog it would have to
|
|
34
|
+
* consult lives in `apps/web/src/lib/integrations/catalog.ts`, and packages never
|
|
35
|
+
* import from apps. `catalog.byok-connect.test.ts` in apps/web binds the two together
|
|
36
|
+
* so this list cannot drift from the connect forms it claims exist.
|
|
37
|
+
*
|
|
38
|
+
* ALLOWLIST, NOT DENYLIST, on purpose. A new provider that arrives without a connect
|
|
39
|
+
* form gets the alternatives-only message until someone adds it here — quieter than
|
|
40
|
+
* correct. A denylist would fail the other way: silently promising a connect form
|
|
41
|
+
* nobody built, which is the bug this fixes.
|
|
42
|
+
*/
|
|
43
|
+
export const NATIVE_API_KEY_CONNECT_INTEGRATION_IDS = new Set([
|
|
44
|
+
"adyntel",
|
|
45
|
+
"ai_ark",
|
|
46
|
+
"apollo",
|
|
47
|
+
"bettercontact",
|
|
48
|
+
"blitzapi",
|
|
49
|
+
"bounceban",
|
|
50
|
+
"breakcold",
|
|
51
|
+
"brightdata",
|
|
52
|
+
"clearoutphone",
|
|
53
|
+
"contactout",
|
|
54
|
+
"crustdata",
|
|
55
|
+
"discolike",
|
|
56
|
+
"exa",
|
|
57
|
+
"findymail",
|
|
58
|
+
"firecrawl",
|
|
59
|
+
"forager",
|
|
60
|
+
"fullenrich",
|
|
61
|
+
"getsales",
|
|
62
|
+
"hunter",
|
|
63
|
+
"icypeas",
|
|
64
|
+
"leadmagic",
|
|
65
|
+
"linkup",
|
|
66
|
+
"millionverifier",
|
|
67
|
+
// Connects through saveMoltsetsConnection rather than the generic save, but that
|
|
68
|
+
// service calls saveApiKeyIntegrationConnection with secretKind "api_key" like the
|
|
69
|
+
// rest — so the native runner does find the key.
|
|
70
|
+
"moltsets",
|
|
71
|
+
"ocean",
|
|
72
|
+
// Native api-key connect added in catalog.ts (2026-09-10): connect writes an
|
|
73
|
+
// integration_secrets api_key row the native parallel.* runner reads. T-104.
|
|
74
|
+
"parallel",
|
|
75
|
+
"predictleads",
|
|
76
|
+
"prospeo",
|
|
77
|
+
"scalelist",
|
|
78
|
+
// scraper: dev v1.920.2 added the HarvestAPI workspace-key connect (native api-key form).
|
|
79
|
+
"scraper",
|
|
80
|
+
"serper",
|
|
81
|
+
"signalbase",
|
|
82
|
+
"zapmail",
|
|
83
|
+
"zerobounce",
|
|
84
|
+
]);
|
|
85
|
+
/**
|
|
86
|
+
* True when a customer can hand Oxygen their own key for this provider AND the
|
|
87
|
+
* native runner will use it. Never infer this from `supports_byok` alone.
|
|
88
|
+
*/
|
|
89
|
+
export function hasNativeApiKeyConnect(integrationId) {
|
|
90
|
+
return typeof integrationId === "string"
|
|
91
|
+
&& NATIVE_API_KEY_CONNECT_INTEGRATION_IDS.has(integrationId);
|
|
92
|
+
}
|
|
@@ -3,6 +3,20 @@ import { RECIPE_PRIMITIVES } from "./recipes.js";
|
|
|
3
3
|
// It is navigation, never execution: live schemas, prices, readiness, and
|
|
4
4
|
// approval enforcement remain on the exact command/tool selected from here.
|
|
5
5
|
export const OXYGEN_CAPABILITY_ROUTES = [
|
|
6
|
+
{
|
|
7
|
+
id: "visual-rendering",
|
|
8
|
+
layer: "Action",
|
|
9
|
+
primitive: null,
|
|
10
|
+
owns: "Agent-authored GTM infographics and carousel pages: editable HTML/CSS/SVG sources, isolated PNG/PDF rendering, actual visual inspection, revisions and downloads.",
|
|
11
|
+
notFor: "A separate product primitive, arbitrary package execution, image generation providers, motion/video, or publishing.",
|
|
12
|
+
execution: "Save source in workspace files; preview exact rendering scope and price; approve, poll the durable job, inspect its PNG pixels, revise and export. Renderer availability is checked by preview.",
|
|
13
|
+
posture: "mixed",
|
|
14
|
+
gatewayTools: ["oxygen_visual_source_create", "oxygen_visual_render_preview", "oxygen_visual_render_get", "oxygen_visual_image_view"],
|
|
15
|
+
gatewayCommands: ["visuals source-create", "visuals render-preview", "visuals render-get", "visuals download"],
|
|
16
|
+
skills: ["oxygen-visual-design", "oxygen-knowledge"],
|
|
17
|
+
endpointSections: ["visuals"],
|
|
18
|
+
intentTerms: ["infographic", "gtm flow image", "render html", "visual design", "carousel pages", "svg", "png", "pdf export", "graphic designer"],
|
|
19
|
+
},
|
|
6
20
|
{
|
|
7
21
|
id: "workspace-access",
|
|
8
22
|
layer: "Control",
|
|
@@ -194,20 +208,20 @@ export const OXYGEN_CAPABILITY_ROUTES = [
|
|
|
194
208
|
id: "tables",
|
|
195
209
|
layer: "Data",
|
|
196
210
|
primitive: "tables",
|
|
197
|
-
owns: "Typed working datasets, rows, formulas, AI/tool/waterfall columns, cell state, projects, and run provenance.",
|
|
211
|
+
owns: "Typed working datasets, rows, formulas, AI/tool/waterfall columns, reusable Functions with isolated drafts and published versions, cell state, projects, and run provenance.",
|
|
198
212
|
notFor: "Canonical CRM truth, message cadence, or an off-platform spreadsheet runtime.",
|
|
199
|
-
execution: "Create and run work in hosted OXYGEN Tables; validate a small sample before bounded paid runs.",
|
|
213
|
+
execution: "Create and run work in hosted OXYGEN Tables; validate a small sample before bounded paid runs. For standard person or company enrichment, `columns add <table> --preset person_enrich|company_enrich` (MCP oxygen_columns_add with preset) adds the maintained bundle in one call before any hand-built tool column.",
|
|
200
214
|
posture: "mixed",
|
|
201
|
-
gatewayTools: ["oxygen_tables_create", "oxygen_columns_add", "oxygen_enrich_column_preview", "oxygen_tables_link_bulk"],
|
|
202
|
-
gatewayCommands: ["tables create", "columns add", "enrich-column preview", "tables link"],
|
|
203
|
-
skills: ["oxygen-gtm", "oxygen-table-tidy", "oxygen-diagnostics", "oxygen-clay-migration"],
|
|
204
|
-
endpointSections: ["action-columns", "callables", "columns", "company-enrichment", "enrich-column", "enrichment", "projects", "table-action-items", "table-action-runs", "table-ingestion-runs", "tables"],
|
|
215
|
+
gatewayTools: ["oxygen_tables_create", "oxygen_columns_add", "oxygen_enrich_column_preview", "oxygen_tables_link_bulk", "oxygen_callables_manage"],
|
|
216
|
+
gatewayCommands: ["tables create", "columns add", "enrich-column preview", "tables link", "functions list", "functions draft"],
|
|
217
|
+
skills: ["oxygen-gtm", "oxygen-table-tidy", "oxygen-diagnostics", "oxygen-clay-migration", "oxygen-linkedin-marketing"],
|
|
218
|
+
endpointSections: ["action-columns", "callables", "functions", "columns", "company-enrichment", "enrich-column", "enrichment", "projects", "table-action-items", "table-action-runs", "table-ingestion-runs", "tables"],
|
|
205
219
|
// "link"/"join"/"connect"/"relate" route here for `tables link`. Added after a
|
|
206
220
|
// blind user eval asked for exactly "link two tables" and was routed to
|
|
207
221
|
// `tables create` / `columns add` / `enrich-column preview` — none of which
|
|
208
222
|
// do it. The agent only found the right command by grepping the raw 25k-line
|
|
209
223
|
// command manifest, which is not a discovery path a customer has.
|
|
210
|
-
intentTerms: ["table", "rows", "column", "columns", "dataset", "csv", "import", "enrich", "enrichment", "waterfall", "score", "formula", "ai column", "lookup", "link", "link tables", "join", "connect", "relate", "relationship"],
|
|
224
|
+
intentTerms: ["table", "rows", "column", "columns", "dataset", "csv", "import", "enrich", "enrichment", "waterfall", "score", "formula", "ai column", "lookup", "link", "link tables", "join", "connect", "relate", "relationship", "function", "functions", "reusable function", "function draft", "function version", "callable"],
|
|
211
225
|
},
|
|
212
226
|
{
|
|
213
227
|
id: "messages",
|
|
@@ -320,11 +334,14 @@ export const OXYGEN_CAPABILITY_ROUTES = [
|
|
|
320
334
|
notFor: "One-to-one messaging, outreach campaigns, or general automation.",
|
|
321
335
|
execution: "Create and schedule on OXYGEN; explicit approval releases the hosted publisher.",
|
|
322
336
|
posture: "external_write",
|
|
323
|
-
gatewayTools: ["oxygen_publishing_posts_create", "oxygen_publishing_posts_list", "oxygen_publishing_posts_approve"],
|
|
324
|
-
gatewayCommands: ["publishing posts create", "publishing posts list", "publishing posts approve"],
|
|
325
|
-
skills: ["oxygen-linkedin-marketing"],
|
|
326
|
-
endpointSections: ["publishing"],
|
|
327
|
-
|
|
337
|
+
gatewayTools: ["oxygen_publishing_posts_create", "oxygen_publishing_posts_list", "oxygen_publishing_posts_approve", "oxygen_ugc_get"],
|
|
338
|
+
gatewayCommands: ["publishing posts create", "publishing posts list", "publishing posts approve", "ugc programs list", "ugc programs duplicate", "ugc programs archive"],
|
|
339
|
+
skills: ["oxygen-linkedin-marketing", "oxygen-ugc"],
|
|
340
|
+
endpointSections: ["publishing", "ugc"],
|
|
341
|
+
// "remove program" / "delete program" landed on route: null in a blind eval
|
|
342
|
+
// (2026-09-10); a program is a UGC noun here, and its lifecycle verbs must
|
|
343
|
+
// resolve to the group that owns `ugc programs archive|delete|duplicate`.
|
|
344
|
+
intentTerms: ["publish", "publishing", "schedule post", "content calendar", "posting calendar", "approve post", "social publishing", "ugc", "creator program", "user generated content", "creator voice", "sponsored creator", "ugc program", "archive program", "delete program", "remove program", "duplicate program", "copy program"],
|
|
328
345
|
},
|
|
329
346
|
{
|
|
330
347
|
id: "workflows",
|
|
@@ -541,7 +558,16 @@ export function serializeCapabilityRoute(route) {
|
|
|
541
558
|
function normalizeIntent(query) {
|
|
542
559
|
return query.toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
|
|
543
560
|
}
|
|
561
|
+
function isLinkedInProfileWatcherIntent(query) {
|
|
562
|
+
return /\blinkedin\b/.test(query)
|
|
563
|
+
&& /\bprofiles?\b/.test(query)
|
|
564
|
+
&& /\b(watcher|watch|watching|monitor|monitoring|daily|every day|recurring)\b/.test(query)
|
|
565
|
+
&& (/\b(engagers?|engaging|engagement|reactions?|comments?|posts?)\b/.test(query) || /\bprofile watcher\b/.test(query))
|
|
566
|
+
&& !isNetNewLinkedInInitiation(query);
|
|
567
|
+
}
|
|
544
568
|
function explicitCapabilityIntent(query) {
|
|
569
|
+
if (/\b(infographic|graphic designer|render html|carousel pages|visual design|gtm flow image)\b/.test(query))
|
|
570
|
+
return ROUTE_BY_ID.get("visual-rendering") ?? null;
|
|
545
571
|
// A unified sender profile is an owned Sequence identity, even when the ask
|
|
546
572
|
// names every attached channel (LinkedIn + WhatsApp + email). Resolve this
|
|
547
573
|
// before public LinkedIn research, whose generic "profile" wording would
|
|
@@ -558,6 +584,8 @@ function explicitCapabilityIntent(query) {
|
|
|
558
584
|
if (isMailboxOnboardingIntent(query)) {
|
|
559
585
|
return ROUTE_BY_ID.get("sending-infrastructure") ?? null;
|
|
560
586
|
}
|
|
587
|
+
if (isLinkedInProfileWatcherIntent(query))
|
|
588
|
+
return ROUTE_BY_PRIMITIVE.get("tables") ?? null;
|
|
561
589
|
if (isHostedWorkflowIntent(query))
|
|
562
590
|
return ROUTE_BY_PRIMITIVE.get("workflows") ?? null;
|
|
563
591
|
if (isOwnedPostCommentIntent(query))
|
|
@@ -596,6 +624,17 @@ function explicitCapabilityIntent(query) {
|
|
|
596
624
|
if (/\b(my|our|own)\b.{0,40}\blinkedin\b.{0,40}\b(post )?engagers?\b/.test(query)) {
|
|
597
625
|
return ROUTE_BY_PRIMITIVE.get("signals") ?? null;
|
|
598
626
|
}
|
|
627
|
+
// Grading addresses a row ALREADY holds — "verify these emails", "which are
|
|
628
|
+
// safe to send", "catch-all or valid" — is Tables work (the Email verification
|
|
629
|
+
// column: `columns add --capability verify_email`, previewed and run through
|
|
630
|
+
// `enrich-column`), with `verify email` as the one-shot twin. Without this
|
|
631
|
+
// rule the bare word "email" scored the Messages card, and a 2026-09-10 blind
|
|
632
|
+
// user asking "verify email deliverability safe to send" was routed to
|
|
633
|
+
// `email send`. Sits after the mailbox rules above so warm-up and sender
|
|
634
|
+
// deliverability keep their owner, and after the scheduling rule so "every
|
|
635
|
+
// Monday verify new signups" still lands on Workflows.
|
|
636
|
+
if (isEmailVerificationIntent(query))
|
|
637
|
+
return ROUTE_BY_PRIMITIVE.get("tables") ?? null;
|
|
599
638
|
if (/\b(recipe|playbook|proven play|what should i do)\b/.test(query)) {
|
|
600
639
|
return ROUTE_BY_PRIMITIVE.get("recipes") ?? null;
|
|
601
640
|
}
|
|
@@ -736,12 +775,27 @@ function recommendationsFor(card, query) {
|
|
|
736
775
|
};
|
|
737
776
|
}
|
|
738
777
|
if (card.primitive === "tables") {
|
|
778
|
+
if (isLinkedInProfileWatcherIntent(query)) {
|
|
779
|
+
return {
|
|
780
|
+
tools: ["oxygen_tables_watcher"],
|
|
781
|
+
commands: ["tables watcher preview", "tables watcher create", "tables watcher get", "tables watcher update", "tables watcher pause", "tables watcher resume"],
|
|
782
|
+
};
|
|
783
|
+
}
|
|
739
784
|
if (/\b(table )?(action )?runs?\b/.test(query)) {
|
|
740
785
|
return {
|
|
741
786
|
tools: ["oxygen_table_runs_get", "oxygen_table_runs_items", "oxygen_table_runs_wait", "oxygen_table_runs_retry_failed"],
|
|
742
787
|
commands: ["table-runs get", "table-runs items", "table-runs wait", "table-runs retry-failed"],
|
|
743
788
|
};
|
|
744
789
|
}
|
|
790
|
+
if (isEmailVerificationIntent(query)) {
|
|
791
|
+
// Define-without-running first (0 credits), then the free preview that
|
|
792
|
+
// prices it, then the approved run, then the one-shot for a handful of
|
|
793
|
+
// addresses that never needed a table.
|
|
794
|
+
return {
|
|
795
|
+
tools: ["oxygen_columns_add", "oxygen_enrich_column_preview", "oxygen_enrich_column_run", "oxygen_verify_email"],
|
|
796
|
+
commands: ["columns add", "enrich-column preview", "enrich-column run", "verify email"],
|
|
797
|
+
};
|
|
798
|
+
}
|
|
745
799
|
if (/\b(waterfall|enrich|enrichment|work email|mobile phone)\b/.test(query)) {
|
|
746
800
|
return {
|
|
747
801
|
tools: ["oxygen_enrich_column_preview", "oxygen_columns_add", "oxygen_enrich_column_run", "oxygen_table_runs_get"],
|
|
@@ -873,7 +927,7 @@ function recommendationsFor(card, query) {
|
|
|
873
927
|
if (card.primitive === "signals" && /\blinkedin\b.{0,40}\bengagers?\b/.test(query)) {
|
|
874
928
|
return {
|
|
875
929
|
tools: ["oxygen_engagement_list_engagers", "oxygen_signals_list", "oxygen_signals_leads_today"],
|
|
876
|
-
commands: ["engagement
|
|
930
|
+
commands: ["engagement engagers", "signals list", "signals leads-today"],
|
|
877
931
|
};
|
|
878
932
|
}
|
|
879
933
|
if (card.id === "connected-whatsapp" && /\b(account|connect|limits?|sync)\b/.test(query)) {
|
|
@@ -884,6 +938,16 @@ function recommendationsFor(card, query) {
|
|
|
884
938
|
}
|
|
885
939
|
return { tools: [...card.gatewayTools], commands: [...card.gatewayCommands] };
|
|
886
940
|
}
|
|
941
|
+
// An email-VERIFICATION ask names a grading verb next to the addresses, or the
|
|
942
|
+
// addresses next to a grade. "check my emails" is deliberately not matched
|
|
943
|
+
// (that is an inbox), and bare "deliverability" is left to the sending rail —
|
|
944
|
+
// only "are these emails deliverable" counts. Bare "bounce" describes an
|
|
945
|
+
// observed delivery failure too; require predictive wording or "bounce risk".
|
|
946
|
+
function isEmailVerificationIntent(query) {
|
|
947
|
+
const gradeThenAddress = /\b(verif(?:y|ied|ication)|validat(?:e|ed|ion)|grade|scrub|clean)\b.{0,40}\b(e ?mails?|email addresses|addresses)\b/;
|
|
948
|
+
const addressThenGrade = /\b(e ?mails?|addresses)\b.{0,40}\b(verif(?:y|ied|ication)|validat(?:e|ed|ion)|valid|invalid|deliverable|(?:will|would|might) bounce|bounce risk|risky|safe to (?:send|email)|catch ?all|accept ?all)\b/;
|
|
949
|
+
return gradeThenAddress.test(query) || addressThenGrade.test(query);
|
|
950
|
+
}
|
|
887
951
|
function isInboxAvatarIntent(query) {
|
|
888
952
|
return /\b(avatar|profile (?:picture|photo)|headshot|hosted (?:picture|image)|mailbox (?:picture|photo))\b/.test(query);
|
|
889
953
|
}
|
|
@@ -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
|
+
}
|