@oxygen-agent/cli 1.1014.1600 → 1.1014.1763

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +1 -1
  2. package/dist/command-families/program.js +4 -0
  3. package/dist/command-manifest.js +18 -3
  4. package/dist/command-support.js +1 -1
  5. package/dist/commands/admin.js +82 -1
  6. package/dist/commands/billing.js +1 -1
  7. package/dist/commands/copilot.js +29 -7
  8. package/dist/commands/custom-integrations.js +13 -0
  9. package/dist/commands/domains.js +7 -0
  10. package/dist/commands/email-assets.d.ts +3 -0
  11. package/dist/commands/email-assets.js +78 -0
  12. package/dist/commands/email-templates.d.ts +3 -0
  13. package/dist/commands/email-templates.js +101 -0
  14. package/dist/commands/inbox.js +1 -1
  15. package/dist/commands/integrations.js +5 -1
  16. package/dist/commands/mailboxes.js +26 -6
  17. package/dist/commands/managed-inboxes.js +2 -2
  18. package/dist/commands/senders.js +4 -4
  19. package/dist/commands/sequences.js +47 -12
  20. package/dist/commands/subscriptions.d.ts +2 -4
  21. package/dist/commands/subscriptions.js +297 -51
  22. package/dist/credential-storage/store.d.ts +10 -0
  23. package/dist/credential-storage/store.js +282 -0
  24. package/dist/credentials.d.ts +1 -3
  25. package/dist/credentials.js +3 -275
  26. package/dist/mailbox-fleet-output.js +11 -2
  27. package/dist/sequence-start-at.d.ts +7 -0
  28. package/dist/sequence-start-at.js +21 -0
  29. package/dist/sequences-brand-kit-command.d.ts +28 -0
  30. package/dist/sequences-brand-kit-command.js +83 -0
  31. package/dist/sequences-content-commands.d.ts +28 -0
  32. package/dist/sequences-content-commands.js +176 -0
  33. package/dist/sequences-newsletter-commands.d.ts +6 -0
  34. package/dist/sequences-newsletter-commands.js +108 -0
  35. package/dist/sequences-test-send-command.d.ts +3 -0
  36. package/dist/sequences-test-send-command.js +47 -0
  37. package/dist/workspace-placement-commands.js +3 -2
  38. package/node_modules/@oxygen/formula/dist/evaluate.js +2 -4
  39. package/node_modules/@oxygen/formula/dist/expression.d.ts +7 -5
  40. package/node_modules/@oxygen/formula/dist/expression.js +26 -4
  41. package/node_modules/@oxygen/formula/dist/formula-functions.d.ts +6 -0
  42. package/node_modules/@oxygen/formula/dist/formula-functions.js +55 -0
  43. package/node_modules/@oxygen/shared/dist/billing.d.ts +2 -2
  44. package/node_modules/@oxygen/shared/dist/billing.js +5 -7
  45. package/node_modules/@oxygen/shared/dist/bulk-email-pacing.d.ts +105 -0
  46. package/node_modules/@oxygen/shared/dist/bulk-email-pacing.js +125 -0
  47. package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +29 -0
  48. package/node_modules/@oxygen/shared/dist/capability-discovery.js +236 -18
  49. package/node_modules/@oxygen/shared/dist/clay-migration-auth-state.d.ts +7 -1
  50. package/node_modules/@oxygen/shared/dist/clay-migration-auth-state.js +6 -5
  51. package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +1 -0
  52. package/node_modules/@oxygen/shared/dist/contracts/clay-migration.d.ts +40 -1
  53. package/node_modules/@oxygen/shared/dist/contracts/clay-migration.js +60 -6
  54. package/node_modules/@oxygen/shared/dist/contracts/copilot-attachment-read.d.ts +24 -0
  55. package/node_modules/@oxygen/shared/dist/contracts/copilot-attachment-read.js +7 -0
  56. package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +2 -1
  57. package/node_modules/@oxygen/shared/dist/contracts/teammates.js +6 -1
  58. package/node_modules/@oxygen/shared/dist/copilot-journeys.generated.js +1 -1
  59. package/node_modules/@oxygen/shared/dist/copilot-playbooks.generated.d.ts +1 -1
  60. package/node_modules/@oxygen/shared/dist/copilot-playbooks.generated.js +2 -2
  61. package/node_modules/@oxygen/shared/dist/email-brand-kit.d.ts +108 -0
  62. package/node_modules/@oxygen/shared/dist/email-brand-kit.js +214 -0
  63. package/node_modules/@oxygen/shared/dist/email-classification-scope.d.ts +35 -0
  64. package/node_modules/@oxygen/shared/dist/email-classification-scope.js +40 -0
  65. package/node_modules/@oxygen/shared/dist/email-delivery.d.ts +10 -0
  66. package/node_modules/@oxygen/shared/dist/email-delivery.js +14 -0
  67. package/node_modules/@oxygen/shared/dist/email-documents.d.ts +61 -0
  68. package/node_modules/@oxygen/shared/dist/email-documents.js +143 -0
  69. package/node_modules/@oxygen/shared/dist/email-dsn.js +52 -10
  70. package/node_modules/@oxygen/shared/dist/email-html-sanitize.d.ts +23 -0
  71. package/node_modules/@oxygen/shared/dist/email-html-sanitize.js +96 -0
  72. package/node_modules/@oxygen/shared/dist/email-subscriptions.d.ts +63 -1
  73. package/node_modules/@oxygen/shared/dist/email-subscriptions.js +149 -1
  74. package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.d.ts +41 -2
  75. package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.js +50 -1
  76. package/node_modules/@oxygen/shared/dist/native-chat.d.ts +9 -0
  77. package/node_modules/@oxygen/shared/dist/native-chat.js +12 -1
  78. package/node_modules/@oxygen/shared/dist/newsletter-analytics.d.ts +90 -0
  79. package/node_modules/@oxygen/shared/dist/newsletter-analytics.js +28 -0
  80. package/node_modules/@oxygen/shared/dist/newsletter-test-send.d.ts +20 -0
  81. package/node_modules/@oxygen/shared/dist/newsletter-test-send.js +46 -0
  82. package/node_modules/@oxygen/shared/dist/object-storage.d.ts +11 -0
  83. package/node_modules/@oxygen/shared/dist/object-storage.js +12 -0
  84. package/node_modules/@oxygen/shared/dist/onboarding-research.d.ts +9 -2
  85. package/node_modules/@oxygen/shared/dist/onboarding-research.js +9 -2
  86. package/node_modules/@oxygen/shared/dist/plan-band.d.ts +9 -0
  87. package/node_modules/@oxygen/shared/dist/plan-band.js +17 -0
  88. package/node_modules/@oxygen/shared/dist/plan-capabilities.d.ts +4 -2
  89. package/node_modules/@oxygen/shared/dist/plan-capabilities.js +12 -1
  90. package/node_modules/@oxygen/shared/dist/plan-limits-view.d.ts +2 -0
  91. package/node_modules/@oxygen/shared/dist/plan-limits-view.js +3 -0
  92. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +5 -0
  93. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +5 -0
  94. package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +29 -0
  95. package/node_modules/@oxygen/shared/dist/product-analytics-events.js +29 -0
  96. package/node_modules/@oxygen/shared/dist/recipes.d.ts +2 -0
  97. package/node_modules/@oxygen/shared/dist/recipes.js +13 -0
  98. package/node_modules/@oxygen/shared/dist/sending-domain-faults.d.ts +21 -0
  99. package/node_modules/@oxygen/shared/dist/sending-domain-faults.js +53 -0
  100. package/node_modules/@oxygen/shared/dist/sending-domain-rollout.d.ts +30 -0
  101. package/node_modules/@oxygen/shared/dist/sending-domain-rollout.js +75 -0
  102. package/node_modules/@oxygen/shared/dist/sequence-failures.d.ts +10 -1
  103. package/node_modules/@oxygen/shared/dist/sequence-failures.js +68 -6
  104. package/node_modules/@oxygen/shared/dist/sequence-lifecycle.d.ts +34 -3
  105. package/node_modules/@oxygen/shared/dist/sequence-lifecycle.js +57 -4
  106. package/node_modules/@oxygen/shared/dist/sequence-template.d.ts +17 -0
  107. package/node_modules/@oxygen/shared/dist/sequence-template.js +0 -0
  108. package/node_modules/@oxygen/shared/dist/sequences.d.ts +41 -0
  109. package/node_modules/@oxygen/shared/dist/sequences.js +86 -4
  110. package/node_modules/@oxygen/shared/dist/ses-identity-policy.d.ts +19 -0
  111. package/node_modules/@oxygen/shared/dist/ses-identity-policy.js +54 -0
  112. package/node_modules/@oxygen/shared/dist/skill-play-routing.d.ts +22 -0
  113. package/node_modules/@oxygen/shared/dist/skill-play-routing.js +75 -0
  114. package/node_modules/@oxygen/shared/dist/skill-plays.generated.d.ts +5 -0
  115. package/node_modules/@oxygen/shared/dist/skill-plays.generated.js +269 -0
  116. package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +7 -0
  117. package/node_modules/@oxygen/shared/dist/spend-safety.js +7 -0
  118. package/node_modules/@oxygen/shared/dist/teammates.js +2 -1
  119. package/node_modules/@oxygen/shared/dist/tool-call-arguments.d.ts +24 -0
  120. package/node_modules/@oxygen/shared/dist/tool-call-arguments.js +68 -0
  121. package/node_modules/@oxygen/shared/dist/user-capability-routing.js +15 -1
  122. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  123. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  124. package/node_modules/@oxygen/shared/dist/workspace-event-catalog.js +2 -2
  125. package/node_modules/@oxygen/shared/dist/workspace-placement.d.ts +3 -0
  126. package/node_modules/@oxygen/shared/dist/workspace-placement.js +11 -0
  127. package/package.json +2 -2
@@ -12,7 +12,16 @@
12
12
  * Pure and browser-safe: the web Delivery control, the CLI/MCP contracts, the
13
13
  * tenant settings validator and the dispatcher all read the same parser.
14
14
  */
15
+ import { BULK_EMAIL_CREDITS_PER_EMAIL } from "./pricing-snapshot.generated.js";
15
16
  export const SEQUENCE_EMAIL_DELIVERY_MODES = ["inboxes", "sending_domain"];
17
+ /**
18
+ * The opt-out line of a cold email sent as plain text, when its Sequence turned the
19
+ * visible unsubscribe on without writing its own `reply_to_stop_text`. Plain text
20
+ * shows a line, never the signed URL (Plain T-317). Any reply stops the lead's
21
+ * enrollment in that Sequence, and opt-out wording ("not interested") puts the
22
+ * address on do-not-contact unless the workspace lowered `email_optout_phrase`.
23
+ */
24
+ export const DEFAULT_PLAIN_TEXT_OPT_OUT = "Not interested? Just reply and I won't follow up.";
16
25
  export class EmailDeliverySettingError extends Error {
17
26
  field;
18
27
  constructor(field, message) {
@@ -205,6 +214,11 @@ export function estimateSesCostUsd(sends) {
205
214
  const count = Math.max(0, Math.floor(sends));
206
215
  return Math.ceil(count * SES_USD_PER_THOUSAND * 100 / 1000) / 100;
207
216
  }
217
+ /** Credits for newsletter emails sent through a sending domain (`send.bulk_email`, 5x AWS). */
218
+ export function bulkEmailCredits(emails) {
219
+ const count = Number.isFinite(emails) ? Math.max(0, Math.floor(emails)) : 0;
220
+ return Math.round(count * BULK_EMAIL_CREDITS_PER_EMAIL * 1000) / 1000;
221
+ }
208
222
  // ---------------------------------------------------------------------------
209
223
  // Release slice (clean-room). The first live start of a sending-domain Sequence
210
224
  // sends a small slice, holds, and releases the rest only when the slice's hard
@@ -0,0 +1,61 @@
1
+ /** Gmail clips a message above ~102 KB; the server refuses a revision above this. */
2
+ export declare const EMAIL_DOCUMENT_MAX_HTML_BYTES = 102400;
3
+ /** The editor's size meter warns from here. */
4
+ export declare const EMAIL_DOCUMENT_WARN_HTML_BYTES = 81920;
5
+ export declare const EMAIL_DOCUMENT_MAX_TEXT_CHARS = 102400;
6
+ export declare const EMAIL_DOCUMENT_MAX_PREVIEW_TEXT_CHARS = 500;
7
+ export declare const EMAIL_DOCUMENT_MAX_NAME_CHARS = 200;
8
+ /** The editor's JSON tree; generous, but bounded so a revision row stays small. */
9
+ export declare const EMAIL_DOCUMENT_MAX_EDITOR_JSON_BYTES = 512000;
10
+ export declare const EMAIL_DOCUMENT_ROLES: readonly ["step", "template"];
11
+ export type EmailDocumentRole = typeof EMAIL_DOCUMENT_ROLES[number];
12
+ export declare const EMAIL_DOCUMENT_KINDS: readonly ["visual", "html"];
13
+ export type EmailDocumentKind = typeof EMAIL_DOCUMENT_KINDS[number];
14
+ /** A step's reference to its body. Without `revision` it follows the current one until the Sequence goes live. */
15
+ export type EmailDocumentRef = {
16
+ id: string;
17
+ revision?: number;
18
+ };
19
+ export declare function isEmailDocumentId(value: unknown): value is string;
20
+ /** UTF-8 byte length, the unit Gmail clips on. */
21
+ export declare function emailHtmlByteLength(html: string): number;
22
+ /** A refused document input: a fixed sentence naming the field (ADR 0023 validation copy). */
23
+ export declare class EmailDocumentInputError extends Error {
24
+ readonly code: string;
25
+ readonly field: string;
26
+ readonly details: Record<string, unknown>;
27
+ constructor(code: string, field: string, message: string, details?: Record<string, unknown>);
28
+ }
29
+ export type EmailDocumentContentInput = {
30
+ html: string;
31
+ text: string;
32
+ previewText: string | null;
33
+ editorJson: unknown | null;
34
+ htmlBytes: number;
35
+ /** True when the save removed scripts, handlers or unsafe links from raw HTML. */
36
+ sanitized: boolean;
37
+ removed: {
38
+ elements: number;
39
+ handlers: number;
40
+ urls: number;
41
+ };
42
+ };
43
+ /**
44
+ * A readable plain-text part from email HTML, for a revision saved without one.
45
+ * Links keep their target (`label (https://...)`); merge tags pass through.
46
+ */
47
+ export declare function emailHtmlToPlainText(html: string): string;
48
+ /**
49
+ * Validate one revision's content: raw HTML is cleaned of active content,
50
+ * text is derived when absent, and the 100 KB HTML ceiling is enforced on the
51
+ * cleaned bytes. Throws EmailDocumentInputError; never truncates.
52
+ */
53
+ export declare function normalizeEmailDocumentContent(input: {
54
+ html: unknown;
55
+ text?: unknown;
56
+ previewText?: unknown;
57
+ editorJson?: unknown;
58
+ }): EmailDocumentContentInput;
59
+ export declare function normalizeEmailDocumentName(value: unknown, fallback: string): string;
60
+ export declare function isEmailDocumentRole(value: unknown): value is EmailDocumentRole;
61
+ export declare function isEmailDocumentKind(value: unknown): value is EmailDocumentKind;
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Email documents: the designed body of a newsletter step (or a reusable
3
+ * template), stored once with append-only revisions (tenant migration 0555)
4
+ * and referenced by a step as `body_document {id, revision}`. This leaf holds
5
+ * the shared vocabulary every surface validates against; storage lives in
6
+ * `@oxygen/tenant-db/email-documents`. Browser-safe: no Node imports.
7
+ */
8
+ import { sanitizeEmailHtml } from "./email-html-sanitize.js";
9
+ /** Gmail clips a message above ~102 KB; the server refuses a revision above this. */
10
+ export const EMAIL_DOCUMENT_MAX_HTML_BYTES = 102_400;
11
+ /** The editor's size meter warns from here. */
12
+ export const EMAIL_DOCUMENT_WARN_HTML_BYTES = 81_920;
13
+ export const EMAIL_DOCUMENT_MAX_TEXT_CHARS = 102_400;
14
+ export const EMAIL_DOCUMENT_MAX_PREVIEW_TEXT_CHARS = 500;
15
+ export const EMAIL_DOCUMENT_MAX_NAME_CHARS = 200;
16
+ /** The editor's JSON tree; generous, but bounded so a revision row stays small. */
17
+ export const EMAIL_DOCUMENT_MAX_EDITOR_JSON_BYTES = 512_000;
18
+ export const EMAIL_DOCUMENT_ROLES = ["step", "template"];
19
+ export const EMAIL_DOCUMENT_KINDS = ["visual", "html"];
20
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
21
+ export function isEmailDocumentId(value) {
22
+ return typeof value === "string" && UUID_RE.test(value);
23
+ }
24
+ /** UTF-8 byte length, the unit Gmail clips on. */
25
+ export function emailHtmlByteLength(html) {
26
+ return new TextEncoder().encode(html).length;
27
+ }
28
+ /** A refused document input: a fixed sentence naming the field (ADR 0023 validation copy). */
29
+ export class EmailDocumentInputError extends Error {
30
+ code;
31
+ field;
32
+ details;
33
+ constructor(code, field, message, details = {}) {
34
+ super(message);
35
+ this.name = "EmailDocumentInputError";
36
+ this.code = code;
37
+ this.field = field;
38
+ this.details = details;
39
+ }
40
+ }
41
+ const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: "\"", apos: "'", nbsp: " ", "#39": "'" };
42
+ /**
43
+ * A readable plain-text part from email HTML, for a revision saved without one.
44
+ * Links keep their target (`label (https://...)`); merge tags pass through.
45
+ */
46
+ export function emailHtmlToPlainText(html) {
47
+ const withoutHidden = html
48
+ .replace(/<(head|style|script|title)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, "")
49
+ .replace(/<!--[\s\S]*?-->/g, "");
50
+ const withLinks = withoutHidden.replace(/<a\b[^>]*?\shref\s*=\s*("([^"]*)"|'([^']*)')[^>]*>([\s\S]*?)<\/a\s*>/gi, (_whole, _raw, dq, sq, label) => {
51
+ const href = (dq ?? sq ?? "").trim();
52
+ const text = label.replace(/<[^>]+>/g, "").trim();
53
+ if (!href || href === "#" || href.startsWith("mailto:") && text.includes(href.slice(7)))
54
+ return text;
55
+ return text && text !== href ? `${text} (${href})` : href;
56
+ });
57
+ const text = withLinks
58
+ .replace(/<br\s*\/?>/gi, "\n")
59
+ .replace(/<\/(p|div|h[1-6]|li|tr|table|blockquote|section|article|header|footer)\s*>/gi, "\n\n")
60
+ .replace(/<li\b[^>]*>/gi, "- ")
61
+ .replace(/<[^>]+>/g, "")
62
+ .replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, (entity, name) => {
63
+ const lower = name.toLowerCase();
64
+ if (lower.startsWith("#x"))
65
+ return String.fromCharCode(Number.parseInt(lower.slice(2), 16));
66
+ if (lower.startsWith("#") && lower !== "#39")
67
+ return String.fromCharCode(Number.parseInt(lower.slice(1), 10));
68
+ return ENTITIES[lower] ?? entity;
69
+ });
70
+ return text
71
+ .split("\n")
72
+ .map((line) => line.replace(/[ \t ]+/g, " ").trim())
73
+ .join("\n")
74
+ .replace(/\n{3,}/g, "\n\n")
75
+ .trim();
76
+ }
77
+ /**
78
+ * Validate one revision's content: raw HTML is cleaned of active content,
79
+ * text is derived when absent, and the 100 KB HTML ceiling is enforced on the
80
+ * cleaned bytes. Throws EmailDocumentInputError; never truncates.
81
+ */
82
+ export function normalizeEmailDocumentContent(input) {
83
+ if (typeof input.html !== "string" || !input.html.trim()) {
84
+ throw new EmailDocumentInputError("invalid_email_document", "html", "html is required and must be a non-empty string.");
85
+ }
86
+ const cleaned = sanitizeEmailHtml(input.html);
87
+ const htmlBytes = emailHtmlByteLength(cleaned.html);
88
+ if (htmlBytes > EMAIL_DOCUMENT_MAX_HTML_BYTES) {
89
+ throw new EmailDocumentInputError("email_document_too_large", "html", `The email's HTML is ${htmlBytes} bytes; the limit is ${EMAIL_DOCUMENT_MAX_HTML_BYTES} bytes (Gmail clips larger emails). Remove content or images embedded as data.`, { html_bytes: htmlBytes, max_html_bytes: EMAIL_DOCUMENT_MAX_HTML_BYTES });
90
+ }
91
+ let text;
92
+ if (input.text === undefined || input.text === null || (typeof input.text === "string" && !input.text.trim())) {
93
+ text = emailHtmlToPlainText(cleaned.html);
94
+ }
95
+ else if (typeof input.text !== "string") {
96
+ throw new EmailDocumentInputError("invalid_email_document", "text", "text must be a string.");
97
+ }
98
+ else {
99
+ text = input.text;
100
+ }
101
+ if (text.length > EMAIL_DOCUMENT_MAX_TEXT_CHARS) {
102
+ throw new EmailDocumentInputError("email_document_too_large", "text", `The plain-text part is longer than ${EMAIL_DOCUMENT_MAX_TEXT_CHARS} characters.`, { max_text_chars: EMAIL_DOCUMENT_MAX_TEXT_CHARS });
103
+ }
104
+ let previewText = null;
105
+ if (input.previewText !== undefined && input.previewText !== null) {
106
+ if (typeof input.previewText !== "string") {
107
+ throw new EmailDocumentInputError("invalid_email_document", "preview_text", "preview_text must be a string.");
108
+ }
109
+ if (input.previewText.length > EMAIL_DOCUMENT_MAX_PREVIEW_TEXT_CHARS) {
110
+ throw new EmailDocumentInputError("invalid_email_document", "preview_text", `preview_text must be at most ${EMAIL_DOCUMENT_MAX_PREVIEW_TEXT_CHARS} characters.`);
111
+ }
112
+ previewText = input.previewText.trim() ? input.previewText : null;
113
+ }
114
+ let editorJson = null;
115
+ if (input.editorJson !== undefined && input.editorJson !== null) {
116
+ if (typeof input.editorJson !== "object") {
117
+ throw new EmailDocumentInputError("invalid_email_document", "editor_json", "editor_json must be a JSON object.");
118
+ }
119
+ if (emailHtmlByteLength(JSON.stringify(input.editorJson)) > EMAIL_DOCUMENT_MAX_EDITOR_JSON_BYTES) {
120
+ throw new EmailDocumentInputError("email_document_too_large", "editor_json", `editor_json is larger than ${EMAIL_DOCUMENT_MAX_EDITOR_JSON_BYTES} bytes.`);
121
+ }
122
+ editorJson = input.editorJson;
123
+ }
124
+ return { html: cleaned.html, text, previewText, editorJson, htmlBytes, sanitized: cleaned.sanitized, removed: cleaned.removed };
125
+ }
126
+ export function normalizeEmailDocumentName(value, fallback) {
127
+ if (value === undefined || value === null)
128
+ return fallback;
129
+ if (typeof value !== "string" || !value.trim()) {
130
+ throw new EmailDocumentInputError("invalid_email_document", "name", "name must be a non-empty string.");
131
+ }
132
+ const name = value.trim();
133
+ if (name.length > EMAIL_DOCUMENT_MAX_NAME_CHARS) {
134
+ throw new EmailDocumentInputError("invalid_email_document", "name", `name must be at most ${EMAIL_DOCUMENT_MAX_NAME_CHARS} characters.`);
135
+ }
136
+ return name;
137
+ }
138
+ export function isEmailDocumentRole(value) {
139
+ return typeof value === "string" && EMAIL_DOCUMENT_ROLES.includes(value);
140
+ }
141
+ export function isEmailDocumentKind(value) {
142
+ return typeof value === "string" && EMAIL_DOCUMENT_KINDS.includes(value);
143
+ }
@@ -145,9 +145,21 @@ const REPUTATION_BLOCK = /\b5\.7\.\d{1,3}\b/;
145
145
  const MAILBOX_FULL_CODE = /\b[45]\.2\.2\b/;
146
146
  // 5.1.0 is a generic address-status failure, not proof the recipient is invalid.
147
147
  const NO_SUCH_USER_CODE = /\b5\.1\.(?:[123]|10)\b/;
148
+ // Exchange Online: "550 5.4.310 DNS domain <d> does not exist [Message=InfoDomainNonexistent]".
149
+ const NO_SUCH_DOMAIN_CODE = /^5\.4\.310$/;
150
+ // Generic or routing statuses whose own prose may still prove an invalid destination.
151
+ const GENERIC_ADDRESS_CODE = /^5\.(?:0\.\d{1,3}|1\.0)$/;
152
+ const ROUTING_CODE = /^5\.4\.\d{1,3}$/;
148
153
  const TRANSIENT_CODE = /(\b4\.\d{1,3}\.\d{1,3}\b|\b4\d{2}\b)/;
149
- const MAILBOX_FULL_TEXT = /(mailbox (?:is )?full|over quota|quota exceeded|insufficient storage)/i;
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;
154
+ const MAILBOX_FULL_TEXT = /(mailbox (?:is )?full|mailbox for user is full|over quota|quota exceeded|insufficient storage)/i;
155
+ // A domain contains dots, so the gap may cross "." but not a sentence end.
156
+ const NAMED_GAP = String.raw `(?:[^\n.]|\.(?=\S)){0,100}`;
157
+ const INVALID_RECIPIENT_TEXT = new RegExp(String.raw `((?:address|account|recipient|user|mailbox)${NAMED_GAP}(?: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|RecipientNotFound|RecipNotFound|mailbox not found|no mailbox (?:here )?by that name|invalid (?:recipient|mailbox|email address)|unrouteable address)`, "i");
158
+ const INVALID_DOMAIN_TEXT = new RegExp(String.raw `(domain${NAMED_GAP}(?:does ?n[o']?t exist|could(?: not|n['’]t) be found)|domain name not found|host or domain name not found|InfoDomainNonexistent|no (?:valid )?MX(?: (?:record|records))?\b|\bNXDOMAIN\b)`, "i");
159
+ const NO_SUCH_USER_TEXT = new RegExp(`${INVALID_RECIPIENT_TEXT.source}|${INVALID_DOMAIN_TEXT.source}`, "i");
160
+ // Exchange/Outlook headline: "<local> wasn't found at <domain>." It is the
161
+ // receiving directory's verdict only when it names the report's own recipient.
162
+ const EXCHANGE_RECIPIENT_NOT_FOUND = /(?:^|[\s>])([^\s@<>"'()]+)\s+wasn['’]t found at\s+([a-z0-9-]+(?:\.[a-z0-9-]+)+)/gi;
151
163
  const PROVIDER_REJECTED_TEXT = /(message rejected|has been blocked|\bblocked\b|\bbanned\b|\bspam\b|local policy violation|unsolicited|reputation)/i;
152
164
  const TRANSIENT_TEXT = /(temporar|will retry|try again later|rate ?limit|throttl)/i;
153
165
  // Greedy address capture: the character class already stops at whitespace and
@@ -178,24 +190,53 @@ function readProvider(from, haystack) {
178
190
  return "microsoft";
179
191
  return "unknown";
180
192
  }
181
- function readKind(subject, haystack, hasBody, enhancedCode) {
193
+ function namesExchangeRecipientNotFound(haystack, recipient) {
194
+ if (!recipient)
195
+ return false;
196
+ for (const match of haystack.matchAll(EXCHANGE_RECIPIENT_NOT_FOUND)) {
197
+ if (`${match[1]}@${match[2]}`.toLowerCase() === recipient)
198
+ return true;
199
+ }
200
+ return false;
201
+ }
202
+ function readKind(subject, haystack, hasBody, enhancedCode, recipient) {
182
203
  // Order is load-bearing. A Google "(Delay)" report can quote block-sounding
183
204
  // prose while the message is still queued, so the transient marker wins first;
184
205
  // an explicit enhanced code then beats keyword matching, because "blocked" and
185
206
  // "spam" appear in boilerplate that a 5.1.1 no-such-user report also carries.
186
207
  if (/\(delay\)/i.test(subject))
187
208
  return "delayed";
209
+ const exchangeNotFound = namesExchangeRecipientNotFound(haystack, recipient);
188
210
  if (enhancedCode) {
211
+ // Spam, policy and quota statuses stay stop-only whatever the prose says.
189
212
  if (REPUTATION_BLOCK.test(enhancedCode))
190
213
  return "provider_rejected";
191
214
  if (MAILBOX_FULL_CODE.test(enhancedCode))
192
215
  return "mailbox_full";
193
- if (NO_SUCH_USER_CODE.test(enhancedCode))
194
- return "no_such_user";
216
+ if (NO_SUCH_USER_CODE.test(enhancedCode)) {
217
+ // Postfix address verification wraps a full mailbox as "550 5.1.1
218
+ // undeliverable address: ... said: 552 5.2.2 Quota exceeded".
219
+ return MAILBOX_FULL_CODE.test(haystack) || MAILBOX_FULL_TEXT.test(haystack) ? "mailbox_full" : "no_such_user";
220
+ }
195
221
  if (enhancedCode.startsWith("4."))
196
222
  return "delayed";
197
- // An explicit generic/routing failure is not address-nonexistence evidence,
198
- // even when the surrounding help text suggests checking the address.
223
+ if (NO_SUCH_DOMAIN_CODE.test(enhancedCode))
224
+ return "no_such_user";
225
+ // SignalFisher, 2026-10-22: Exchange's "554 5.4.14 Hop count exceeded" for
226
+ // malpertuis@intracen.org carried "malpertuis wasn't found at intracen.org";
227
+ // the receiving directory does not know the address. A routing status needs
228
+ // that recipient-bound headline or nonexistent-domain prose, never help text.
229
+ if (ROUTING_CODE.test(enhancedCode) && (exchangeNotFound || INVALID_DOMAIN_TEXT.test(haystack)))
230
+ return "no_such_user";
231
+ if (GENERIC_ADDRESS_CODE.test(enhancedCode)) {
232
+ // Only the diagnostic line itself counts ("550 5.0.0 <x>: User unknown");
233
+ // Outlook's "Recipient Unknown" heading is a template, not a verdict.
234
+ const diagnostic = haystack.split("\n").filter(line => line.includes(enhancedCode)).join("\n");
235
+ if (!PROVIDER_REJECTED_TEXT.test(diagnostic) && (exchangeNotFound || NO_SUCH_USER_TEXT.test(diagnostic)))
236
+ return "no_such_user";
237
+ }
238
+ // Any other explicit failure is not address-nonexistence evidence, even
239
+ // when the surrounding help text suggests checking the address.
199
240
  return "other";
200
241
  }
201
242
  if (MAILBOX_FULL_TEXT.test(haystack))
@@ -204,7 +245,7 @@ function readKind(subject, haystack, hasBody, enhancedCode) {
204
245
  return "provider_rejected";
205
246
  if (ENHANCED_CODE.test(haystack))
206
247
  return "other";
207
- if (NO_SUCH_USER_TEXT.test(haystack))
248
+ if (exchangeNotFound || NO_SUCH_USER_TEXT.test(haystack))
208
249
  return "no_such_user";
209
250
  if (TRANSIENT_CODE.test(haystack) || TRANSIENT_TEXT.test(haystack))
210
251
  return "delayed";
@@ -228,10 +269,11 @@ export function classifyDeliveryStatusNotification(input) {
228
269
  const haystack = `${subject}\n${body}`;
229
270
  const enhancedCode = readEnhancedStatusCode(haystack);
230
271
  const smtpCode = enhancedCode ?? (ENHANCED_CODE.test(haystack) ? null : SMTP_REPLY_CODE.exec(haystack)?.[1] ?? null);
272
+ const recipient = readRecipient(haystack);
231
273
  return {
232
- kind: readKind(subject, haystack, body.trim().length > 0, enhancedCode),
274
+ kind: readKind(subject, haystack, body.trim().length > 0, enhancedCode, recipient),
233
275
  provider: readProvider(from, haystack),
234
- recipient: readRecipient(haystack),
276
+ recipient,
235
277
  smtpCode,
236
278
  };
237
279
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Clean raw HTML a customer saves as an email document. Email clients never run
3
+ * scripts, but the same HTML is previewed in the web app and forwarded by
4
+ * recipients, so active content is removed on save rather than trusted to every
5
+ * reader: `<script>` (and other embedding elements), inline `on*` event
6
+ * handlers, and `javascript:` / `vbscript:` / `data:text/html` URLs in link and
7
+ * source attributes. Styles, tables, images and merge tags are kept as written.
8
+ *
9
+ * Browser-safe and dependency-free. This is a save-time cleanup, not the
10
+ * preview boundary: the web preview still renders inside a sandboxed iframe.
11
+ */
12
+ export type EmailHtmlSanitizeResult = {
13
+ html: string;
14
+ /** What was removed, for the save response (`sanitized` is true when anything was). */
15
+ removed: {
16
+ elements: number;
17
+ handlers: number;
18
+ urls: number;
19
+ };
20
+ sanitized: boolean;
21
+ };
22
+ export declare function isUnsafeEmailUrl(value: string): boolean;
23
+ export declare function sanitizeEmailHtml(input: string): EmailHtmlSanitizeResult;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Clean raw HTML a customer saves as an email document. Email clients never run
3
+ * scripts, but the same HTML is previewed in the web app and forwarded by
4
+ * recipients, so active content is removed on save rather than trusted to every
5
+ * reader: `<script>` (and other embedding elements), inline `on*` event
6
+ * handlers, and `javascript:` / `vbscript:` / `data:text/html` URLs in link and
7
+ * source attributes. Styles, tables, images and merge tags are kept as written.
8
+ *
9
+ * Browser-safe and dependency-free. This is a save-time cleanup, not the
10
+ * preview boundary: the web preview still renders inside a sandboxed iframe.
11
+ */
12
+ const ACTIVE_ELEMENTS = ["script", "iframe", "object", "embed", "applet", "frame", "frameset", "base"];
13
+ const URL_ATTRIBUTES = ["href", "src", "action", "formaction", "background", "poster", "xlink:href", "srcset", "lowsrc", "dynsrc"];
14
+ /** Decode the entity tricks used to hide a scheme (`&#106;avascript:`, `java&#x09;script:`). */
15
+ function decodeForSchemeCheck(value) {
16
+ return value
17
+ .replace(/&#x([0-9a-f]+);?/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
18
+ .replace(/&#(\d+);?/g, (_, dec) => String.fromCharCode(Number.parseInt(dec, 10)))
19
+ .replace(/&(tab|newline);/gi, "")
20
+ .replace(/&colon;/gi, ":")
21
+ // Browsers ignore control characters and whitespace inside a scheme.
22
+ // eslint-disable-next-line no-control-regex
23
+ .replace(/[\u0000- \u007f-\u009f]+/g, "")
24
+ .toLowerCase();
25
+ }
26
+ export function isUnsafeEmailUrl(value) {
27
+ const decoded = decodeForSchemeCheck(value);
28
+ return decoded.startsWith("javascript:") || decoded.startsWith("vbscript:") || decoded.startsWith("data:text/html")
29
+ || decoded.startsWith("data:application/xhtml") || decoded.startsWith("data:image/svg+xml");
30
+ }
31
+ // One attribute: a name, then an optional value. `/` separates attributes the way
32
+ // browsers read `<img/onerror=...>`.
33
+ const ATTRIBUTE = /([^\s"'>\/=]+)(?:\s*=\s*("[^"]*"|'[^']*'|[^\s"'>]+))?/g;
34
+ function unquote(value) {
35
+ return (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ? value.slice(1, -1) : value;
36
+ }
37
+ /** The cleaned attribute string, or null when nothing in it needed cleaning. */
38
+ function rewriteAttributes(attrs, removed) {
39
+ let changed = false;
40
+ const kept = [];
41
+ for (const match of attrs.matchAll(ATTRIBUTE)) {
42
+ const name = match[1] ?? "";
43
+ const lower = name.toLowerCase();
44
+ let value = match[2];
45
+ if (lower.startsWith("on")) {
46
+ removed.handlers += 1;
47
+ changed = true;
48
+ continue;
49
+ }
50
+ if (value !== undefined && URL_ATTRIBUTES.includes(lower) && isUnsafeEmailUrl(unquote(value))) {
51
+ removed.urls += 1;
52
+ changed = true;
53
+ value = '"#"';
54
+ }
55
+ else if (value !== undefined && lower === "style") {
56
+ // A style URL can carry the same schemes (`background:url(javascript:...)`).
57
+ const cleaned = value.replace(/url\(\s*(["']?)([^"')]*)\1\s*\)/gi, (whole, _q, url) => {
58
+ if (!isUnsafeEmailUrl(url))
59
+ return whole;
60
+ removed.urls += 1;
61
+ changed = true;
62
+ return "none";
63
+ });
64
+ value = cleaned;
65
+ }
66
+ kept.push(value === undefined ? name : `${name}=${value}`);
67
+ }
68
+ if (!changed)
69
+ return null;
70
+ const selfClosing = /\/\s*$/.test(attrs);
71
+ return `${kept.length > 0 ? ` ${kept.join(" ")}` : ""}${selfClosing ? " /" : ""}`;
72
+ }
73
+ export function sanitizeEmailHtml(input) {
74
+ const removed = { elements: 0, handlers: 0, urls: 0 };
75
+ let html = input;
76
+ for (const element of ACTIVE_ELEMENTS) {
77
+ const paired = new RegExp(`<${element}\\b[^>]*>[\\s\\S]*?<\\/${element}\\s*>`, "gi");
78
+ const single = new RegExp(`<\\/?${element}\\b[^>]*>`, "gi");
79
+ html = html.replace(paired, () => {
80
+ removed.elements += 1;
81
+ return "";
82
+ });
83
+ html = html.replace(single, () => {
84
+ removed.elements += 1;
85
+ return "";
86
+ });
87
+ }
88
+ // Attributes are rewritten only inside tags (quoted values may hold `>`), so prose
89
+ // that mentions "onclick=" survives and an untouched tag keeps its exact bytes.
90
+ html = html.replace(/<([a-z][a-z0-9:-]*)((?:"[^"]*"|'[^']*'|[^'">])*)>/gi, (match, tag, attrs) => {
91
+ const rewritten = rewriteAttributes(attrs, removed);
92
+ return rewritten === null ? match : `<${tag}${rewritten}>`;
93
+ });
94
+ const sanitized = removed.elements + removed.handlers + removed.urls > 0;
95
+ return { html, removed, sanitized };
96
+ }
@@ -8,8 +8,26 @@
8
8
  * Sequences only send to `subscribed` addresses on their own list, on top of
9
9
  * do-not-contact. Pure and browser-safe.
10
10
  */
11
- export declare const EMAIL_SUBSCRIPTION_LIST_KINDS: readonly ["marketing", "product_updates", "onboarding"];
11
+ export declare const EMAIL_SUBSCRIPTION_LIST_KINDS: readonly ["marketing", "product_updates", "onboarding", "topic"];
12
12
  export type EmailSubscriptionListKind = typeof EMAIL_SUBSCRIPTION_LIST_KINDS[number];
13
+ /**
14
+ * Topics: a `topic` list is a narrower subject inside an audience list
15
+ * (`parent_list_id`). A newsletter on a topic mails only the topic's
16
+ * subscribers. `opt_out` topics are joined together with their audience list
17
+ * (the same consent evidence, source `topic_default`); `opt_in` topics only by
18
+ * an explicit choice. Public lists show on the recipient's preference page;
19
+ * private ones only to someone already subscribed to them.
20
+ */
21
+ export declare const EMAIL_SUBSCRIPTION_LIST_VISIBILITIES: readonly ["public", "private"];
22
+ export type EmailSubscriptionListVisibility = typeof EMAIL_SUBSCRIPTION_LIST_VISIBILITIES[number];
23
+ export declare const EMAIL_SUBSCRIPTION_DEFAULTS: readonly ["opt_in", "opt_out"];
24
+ export type EmailSubscriptionDefault = typeof EMAIL_SUBSCRIPTION_DEFAULTS[number];
25
+ /** The consent source of a topic subscription made by joining its audience list. */
26
+ export declare const TOPIC_DEFAULT_SOURCE = "topic_default";
27
+ /** The consent source of a change the recipient made on the hosted preference page. */
28
+ export declare const PREFERENCE_CENTER_SOURCE = "preference_center";
29
+ export declare function isEmailSubscriptionListVisibility(value: unknown): value is EmailSubscriptionListVisibility;
30
+ export declare function isEmailSubscriptionDefault(value: unknown): value is EmailSubscriptionDefault;
13
31
  export declare const EMAIL_SUBSCRIPTION_STATUSES: readonly ["subscribed", "unsubscribed", "pending", "cleaned"];
14
32
  export type EmailSubscriptionStatus = typeof EMAIL_SUBSCRIPTION_STATUSES[number];
15
33
  /** Append-only consent event kinds. Status changes plus the provider facts that cause them. */
@@ -48,3 +66,47 @@ export type EmailConsentEvidence = {
48
66
  export declare function parseEmailConsentEvidence(value: unknown, options: {
49
67
  requireForSubscribe: boolean;
50
68
  }): EmailConsentEvidence;
69
+ /**
70
+ * Contacts of a list: one subscription read together with the Records person it
71
+ * names (`email_subscriptions.person_id`, a People record). Records owns the
72
+ * person (name and the workspace's own People fields, the contact
73
+ * "properties"); the list owns status and consent.
74
+ */
75
+ export declare const EMAIL_CONTACT_PROPERTY_TYPES: readonly ["string", "number"];
76
+ export type EmailContactPropertyType = typeof EMAIL_CONTACT_PROPERTY_TYPES[number];
77
+ /** Most contacts one list page returns. */
78
+ export declare const EMAIL_CONTACTS_PAGE_LIMIT = 200;
79
+ /**
80
+ * Most import entries per call that carry a name or properties: each one is a
81
+ * Records write (create or match the person by email), so these batches are
82
+ * smaller than an address-only import. The CLI and web split larger files.
83
+ */
84
+ export declare const EMAIL_CONTACT_IMPORT_WITH_FIELDS_LIMIT = 1000;
85
+ export declare function isEmailContactPropertyType(value: unknown): value is EmailContactPropertyType;
86
+ /** One contact an import sends: the address, and what goes to its People record. */
87
+ export type EmailContactImportRow = {
88
+ email: string;
89
+ person_id?: string;
90
+ first_name?: string;
91
+ last_name?: string;
92
+ properties?: Record<string, string>;
93
+ };
94
+ /** Parse CSV text into rows of cells, honouring quoted fields ("" escapes, commas and newlines inside quotes). */
95
+ export declare function parseCsvCells(text: string): string[][];
96
+ /**
97
+ * Contacts from a CSV with an email column (the shape `subscriptions export`
98
+ * writes): first_name and last_name go to the person, every other non-reserved
99
+ * column is a candidate property, named in `propertyColumns` so a caller can
100
+ * keep only the workspace's real properties.
101
+ */
102
+ export declare function parseContactsCsv(text: string): {
103
+ entries: EmailContactImportRow[];
104
+ propertyColumns: string[];
105
+ };
106
+ /** Keep only properties the workspace has; report the columns dropped. */
107
+ export declare function keepContactProperties(entries: EmailContactImportRow[], known: ReadonlySet<string>): {
108
+ entries: EmailContactImportRow[];
109
+ ignored: string[];
110
+ };
111
+ /** Split an import into calls the import route accepts (smaller batches once any entry carries a name or properties). */
112
+ export declare function chunkContactImport<T extends EmailContactImportRow>(entries: T[]): T[][];