@oxygen-agent/cli 1.1014.1696 → 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 (96) hide show
  1. package/README.md +1 -1
  2. package/dist/command-families/program.js +4 -0
  3. package/dist/command-manifest.js +14 -2
  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/domains.js +7 -0
  9. package/dist/commands/email-assets.d.ts +3 -0
  10. package/dist/commands/email-assets.js +78 -0
  11. package/dist/commands/email-templates.d.ts +3 -0
  12. package/dist/commands/email-templates.js +101 -0
  13. package/dist/commands/sequences.js +47 -12
  14. package/dist/commands/subscriptions.d.ts +2 -4
  15. package/dist/commands/subscriptions.js +297 -51
  16. package/dist/sequence-start-at.d.ts +7 -0
  17. package/dist/sequence-start-at.js +21 -0
  18. package/dist/sequences-brand-kit-command.d.ts +28 -0
  19. package/dist/sequences-brand-kit-command.js +83 -0
  20. package/dist/sequences-content-commands.d.ts +28 -0
  21. package/dist/sequences-content-commands.js +176 -0
  22. package/dist/sequences-newsletter-commands.d.ts +6 -0
  23. package/dist/sequences-newsletter-commands.js +108 -0
  24. package/dist/sequences-test-send-command.d.ts +3 -0
  25. package/dist/sequences-test-send-command.js +47 -0
  26. package/node_modules/@oxygen/formula/dist/evaluate.js +2 -4
  27. package/node_modules/@oxygen/formula/dist/expression.d.ts +7 -5
  28. package/node_modules/@oxygen/formula/dist/expression.js +26 -4
  29. package/node_modules/@oxygen/formula/dist/formula-functions.d.ts +6 -0
  30. package/node_modules/@oxygen/formula/dist/formula-functions.js +55 -0
  31. package/node_modules/@oxygen/shared/dist/billing.d.ts +2 -2
  32. package/node_modules/@oxygen/shared/dist/billing.js +5 -7
  33. package/node_modules/@oxygen/shared/dist/bulk-email-pacing.d.ts +105 -0
  34. package/node_modules/@oxygen/shared/dist/bulk-email-pacing.js +125 -0
  35. package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +29 -0
  36. package/node_modules/@oxygen/shared/dist/capability-discovery.js +174 -10
  37. package/node_modules/@oxygen/shared/dist/contracts/clay-migration.d.ts +1 -0
  38. package/node_modules/@oxygen/shared/dist/contracts/clay-migration.js +28 -2
  39. package/node_modules/@oxygen/shared/dist/contracts/copilot-attachment-read.d.ts +24 -0
  40. package/node_modules/@oxygen/shared/dist/contracts/copilot-attachment-read.js +7 -0
  41. package/node_modules/@oxygen/shared/dist/contracts/teammates.js +6 -1
  42. package/node_modules/@oxygen/shared/dist/email-brand-kit.d.ts +108 -0
  43. package/node_modules/@oxygen/shared/dist/email-brand-kit.js +214 -0
  44. package/node_modules/@oxygen/shared/dist/email-delivery.d.ts +10 -0
  45. package/node_modules/@oxygen/shared/dist/email-delivery.js +14 -0
  46. package/node_modules/@oxygen/shared/dist/email-documents.d.ts +61 -0
  47. package/node_modules/@oxygen/shared/dist/email-documents.js +143 -0
  48. package/node_modules/@oxygen/shared/dist/email-dsn.js +52 -10
  49. package/node_modules/@oxygen/shared/dist/email-html-sanitize.d.ts +23 -0
  50. package/node_modules/@oxygen/shared/dist/email-html-sanitize.js +96 -0
  51. package/node_modules/@oxygen/shared/dist/email-subscriptions.d.ts +63 -1
  52. package/node_modules/@oxygen/shared/dist/email-subscriptions.js +149 -1
  53. package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.d.ts +41 -2
  54. package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.js +50 -1
  55. package/node_modules/@oxygen/shared/dist/newsletter-analytics.d.ts +90 -0
  56. package/node_modules/@oxygen/shared/dist/newsletter-analytics.js +28 -0
  57. package/node_modules/@oxygen/shared/dist/newsletter-test-send.d.ts +20 -0
  58. package/node_modules/@oxygen/shared/dist/newsletter-test-send.js +46 -0
  59. package/node_modules/@oxygen/shared/dist/object-storage.d.ts +11 -0
  60. package/node_modules/@oxygen/shared/dist/object-storage.js +12 -0
  61. package/node_modules/@oxygen/shared/dist/plan-band.d.ts +9 -0
  62. package/node_modules/@oxygen/shared/dist/plan-band.js +17 -0
  63. package/node_modules/@oxygen/shared/dist/plan-capabilities.d.ts +4 -2
  64. package/node_modules/@oxygen/shared/dist/plan-capabilities.js +12 -1
  65. package/node_modules/@oxygen/shared/dist/plan-limits-view.d.ts +2 -0
  66. package/node_modules/@oxygen/shared/dist/plan-limits-view.js +3 -0
  67. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +5 -0
  68. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +5 -0
  69. package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +29 -0
  70. package/node_modules/@oxygen/shared/dist/product-analytics-events.js +29 -0
  71. package/node_modules/@oxygen/shared/dist/sending-domain-faults.d.ts +21 -0
  72. package/node_modules/@oxygen/shared/dist/sending-domain-faults.js +53 -0
  73. package/node_modules/@oxygen/shared/dist/sending-domain-rollout.d.ts +24 -1
  74. package/node_modules/@oxygen/shared/dist/sending-domain-rollout.js +51 -4
  75. package/node_modules/@oxygen/shared/dist/sequence-failures.d.ts +10 -1
  76. package/node_modules/@oxygen/shared/dist/sequence-failures.js +68 -6
  77. package/node_modules/@oxygen/shared/dist/sequence-lifecycle.d.ts +34 -3
  78. package/node_modules/@oxygen/shared/dist/sequence-lifecycle.js +57 -4
  79. package/node_modules/@oxygen/shared/dist/sequence-template.d.ts +17 -0
  80. package/node_modules/@oxygen/shared/dist/sequence-template.js +0 -0
  81. package/node_modules/@oxygen/shared/dist/sequences.d.ts +41 -0
  82. package/node_modules/@oxygen/shared/dist/sequences.js +86 -4
  83. package/node_modules/@oxygen/shared/dist/ses-identity-policy.d.ts +19 -0
  84. package/node_modules/@oxygen/shared/dist/ses-identity-policy.js +54 -0
  85. package/node_modules/@oxygen/shared/dist/skill-play-routing.d.ts +22 -0
  86. package/node_modules/@oxygen/shared/dist/skill-play-routing.js +75 -0
  87. package/node_modules/@oxygen/shared/dist/skill-plays.generated.d.ts +5 -0
  88. package/node_modules/@oxygen/shared/dist/skill-plays.generated.js +269 -0
  89. package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +7 -0
  90. package/node_modules/@oxygen/shared/dist/spend-safety.js +7 -0
  91. package/node_modules/@oxygen/shared/dist/teammates.js +2 -1
  92. package/node_modules/@oxygen/shared/dist/user-capability-routing.js +15 -1
  93. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  94. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  95. package/node_modules/@oxygen/shared/dist/workspace-event-catalog.js +2 -2
  96. package/package.json +2 -2
@@ -0,0 +1,214 @@
1
+ /**
2
+ * The workspace's newsletter brand kit (tenant table ox_sequencer.email_brand_kit,
3
+ * migration 0559): the logo, colours, width and font newsletters are designed
4
+ * with, the company name and postal address every bulk email carries in its
5
+ * footer, social links and the preference page's title.
6
+ *
7
+ * One parser serves the `/api/cli/sequencer/brand-kit` route (web, CLI, MCP):
8
+ * a patch names only the fields it changes, `null` clears one (a colour, width
9
+ * or font falls back to its default). Pure and browser-safe.
10
+ */
11
+ export const EMAIL_BRAND_FONT_FAMILIES = ["system", "georgia", "arial", "helvetica", "verdana"];
12
+ export const EMAIL_BRAND_FONT_LABELS = {
13
+ system: "System sans",
14
+ georgia: "Georgia",
15
+ arial: "Arial",
16
+ helvetica: "Helvetica",
17
+ verdana: "Verdana",
18
+ };
19
+ /** The CSS font stack each allowlisted family renders with in an email. */
20
+ export const EMAIL_BRAND_FONT_STACKS = {
21
+ system: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
22
+ georgia: "Georgia, 'Times New Roman', Times, serif",
23
+ arial: "Arial, Helvetica, sans-serif",
24
+ helvetica: "Helvetica, Arial, sans-serif",
25
+ verdana: "Verdana, Geneva, sans-serif",
26
+ };
27
+ export const EMAIL_BRAND_SOCIAL_NETWORKS = [
28
+ "website", "linkedin", "x", "facebook", "instagram", "youtube", "tiktok", "github",
29
+ ];
30
+ export const EMAIL_BRAND_COLOR_FIELDS = [
31
+ "background_color", "content_background_color", "text_color", "link_color", "button_color", "button_text_color",
32
+ ];
33
+ export const EMAIL_BRAND_LIMITS = {
34
+ containerWidthMin: 480,
35
+ containerWidthMax: 800,
36
+ logoWidthMin: 40,
37
+ logoWidthMax: 600,
38
+ urlMax: 2048,
39
+ companyNameMax: 120,
40
+ postalAddressMax: 500,
41
+ socialLinksMax: 10,
42
+ preferencePageTitleMax: 120,
43
+ preferencePageDescriptionMax: 500,
44
+ };
45
+ export const DEFAULT_EMAIL_BRAND_COLORS = {
46
+ background_color: "#f4f4f5",
47
+ content_background_color: "#ffffff",
48
+ text_color: "#18181b",
49
+ link_color: "#2563eb",
50
+ button_color: "#18181b",
51
+ button_text_color: "#ffffff",
52
+ };
53
+ export const DEFAULT_EMAIL_BRAND_KIT = {
54
+ logo_url: null,
55
+ logo_width: null,
56
+ ...DEFAULT_EMAIL_BRAND_COLORS,
57
+ container_width: 600,
58
+ font_family: "system",
59
+ company_name: null,
60
+ postal_address: null,
61
+ social_links: [],
62
+ preference_page_title: null,
63
+ preference_page_description: null,
64
+ updated_by: null,
65
+ updated_at: null,
66
+ };
67
+ /** Every editable field; a patch names a subset. */
68
+ export const EMAIL_BRAND_KIT_FIELDS = [
69
+ "logo_url", "logo_width", ...EMAIL_BRAND_COLOR_FIELDS, "container_width", "font_family", "company_name",
70
+ "postal_address", "social_links", "preference_page_title", "preference_page_description",
71
+ ];
72
+ export class EmailBrandKitInputError extends Error {
73
+ field;
74
+ code = "invalid_brand_kit";
75
+ constructor(field, message) {
76
+ super(message);
77
+ this.field = field;
78
+ this.name = "EmailBrandKitInputError";
79
+ }
80
+ }
81
+ const HEX_RE = /^#?([0-9a-f]{6}|[0-9a-f]{3})$/i;
82
+ /** `#rrggbb` lowercase from `#rrggbb`, `rrggbb` or `#rgb`; throws on anything else. */
83
+ export function normalizeBrandColor(value, field) {
84
+ const raw = typeof value === "string" ? value.trim() : "";
85
+ const match = HEX_RE.exec(raw);
86
+ if (!match)
87
+ throw new EmailBrandKitInputError(field, `${field} must be a hex colour such as #1a2b3c.`);
88
+ const hex = match[1].toLowerCase();
89
+ return `#${hex.length === 3 ? hex.split("").map((char) => char + char).join("") : hex}`;
90
+ }
91
+ /** An absolute https URL; email clients block or warn on plain http images and links. */
92
+ export function normalizeBrandUrl(value, field) {
93
+ const raw = typeof value === "string" ? value.trim() : "";
94
+ let parsed = null;
95
+ try {
96
+ parsed = raw ? new URL(raw) : null;
97
+ }
98
+ catch {
99
+ parsed = null;
100
+ }
101
+ if (!parsed || parsed.protocol !== "https:" || !parsed.hostname || raw.length > EMAIL_BRAND_LIMITS.urlMax) {
102
+ throw new EmailBrandKitInputError(field, `${field} must be an https:// URL.`);
103
+ }
104
+ return parsed.toString();
105
+ }
106
+ function boundedInteger(value, field, min, max) {
107
+ if (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max) {
108
+ throw new EmailBrandKitInputError(field, `${field} must be a whole number from ${min} to ${max}.`);
109
+ }
110
+ return value;
111
+ }
112
+ function boundedText(value, field, max, options = {}) {
113
+ if (typeof value !== "string")
114
+ throw new EmailBrandKitInputError(field, `${field} must be text.`);
115
+ const lines = value.replace(/\r\n?/g, "\n").split("\n").map((line) => line.replace(/\s+/g, " ").trim());
116
+ const text = options.multiline ? lines.filter(Boolean).join("\n") : lines.filter(Boolean).join(" ");
117
+ if (!text)
118
+ return null;
119
+ if (text.length > max)
120
+ throw new EmailBrandKitInputError(field, `${field} must be at most ${max} characters.`);
121
+ return text;
122
+ }
123
+ export function isEmailBrandFontFamily(value) {
124
+ return typeof value === "string" && EMAIL_BRAND_FONT_FAMILIES.includes(value);
125
+ }
126
+ function parseSocialLinks(value) {
127
+ if (!Array.isArray(value))
128
+ throw new EmailBrandKitInputError("social_links", "social_links must be a list of { network, url }.");
129
+ if (value.length > EMAIL_BRAND_LIMITS.socialLinksMax) {
130
+ throw new EmailBrandKitInputError("social_links", `At most ${EMAIL_BRAND_LIMITS.socialLinksMax} social links.`);
131
+ }
132
+ return value.map((entry, index) => {
133
+ const record = entry && typeof entry === "object" && !Array.isArray(entry) ? entry : {};
134
+ const network = typeof record.network === "string" ? record.network.trim().toLowerCase() : "";
135
+ if (!EMAIL_BRAND_SOCIAL_NETWORKS.includes(network)) {
136
+ throw new EmailBrandKitInputError(`social_links[${index}].network`, `network must be one of ${EMAIL_BRAND_SOCIAL_NETWORKS.join(", ")}.`);
137
+ }
138
+ return { network: network, url: normalizeBrandUrl(record.url, `social_links[${index}].url`) };
139
+ });
140
+ }
141
+ /**
142
+ * Validate a brand kit change. Only the named fields are returned; `null` (or
143
+ * an empty string for text) clears one. Throws `EmailBrandKitInputError` naming
144
+ * the field, and refuses a body that names no field.
145
+ */
146
+ export function parseEmailBrandKitPatch(body) {
147
+ const patch = {};
148
+ const has = (field) => Object.hasOwn(body, field) && body[field] !== undefined;
149
+ const cleared = (field) => body[field] === null || body[field] === "";
150
+ if (has("logo_url"))
151
+ patch.logo_url = cleared("logo_url") ? null : normalizeBrandUrl(body.logo_url, "logo_url");
152
+ if (has("logo_width")) {
153
+ patch.logo_width = cleared("logo_width") ? null
154
+ : boundedInteger(body.logo_width, "logo_width", EMAIL_BRAND_LIMITS.logoWidthMin, EMAIL_BRAND_LIMITS.logoWidthMax);
155
+ }
156
+ for (const field of EMAIL_BRAND_COLOR_FIELDS) {
157
+ if (has(field))
158
+ patch[field] = cleared(field) ? null : normalizeBrandColor(body[field], field);
159
+ }
160
+ if (has("container_width")) {
161
+ patch.container_width = cleared("container_width") ? null
162
+ : boundedInteger(body.container_width, "container_width", EMAIL_BRAND_LIMITS.containerWidthMin, EMAIL_BRAND_LIMITS.containerWidthMax);
163
+ }
164
+ if (has("font_family")) {
165
+ const font = typeof body.font_family === "string" ? body.font_family.trim().toLowerCase() : body.font_family;
166
+ if (cleared("font_family"))
167
+ patch.font_family = null;
168
+ else if (isEmailBrandFontFamily(font))
169
+ patch.font_family = font;
170
+ else
171
+ throw new EmailBrandKitInputError("font_family", `font_family must be one of ${EMAIL_BRAND_FONT_FAMILIES.join(", ")}.`);
172
+ }
173
+ if (has("company_name")) {
174
+ patch.company_name = body.company_name === null ? null : boundedText(body.company_name, "company_name", EMAIL_BRAND_LIMITS.companyNameMax);
175
+ }
176
+ if (has("postal_address")) {
177
+ patch.postal_address = body.postal_address === null ? null
178
+ : boundedText(body.postal_address, "postal_address", EMAIL_BRAND_LIMITS.postalAddressMax, { multiline: true });
179
+ }
180
+ if (has("social_links"))
181
+ patch.social_links = body.social_links === null ? null : parseSocialLinks(body.social_links);
182
+ if (has("preference_page_title")) {
183
+ patch.preference_page_title = body.preference_page_title === null ? null
184
+ : boundedText(body.preference_page_title, "preference_page_title", EMAIL_BRAND_LIMITS.preferencePageTitleMax);
185
+ }
186
+ if (has("preference_page_description")) {
187
+ patch.preference_page_description = body.preference_page_description === null ? null
188
+ : boundedText(body.preference_page_description, "preference_page_description", EMAIL_BRAND_LIMITS.preferencePageDescriptionMax, { multiline: true });
189
+ }
190
+ if (Object.keys(patch).length === 0) {
191
+ throw new EmailBrandKitInputError("brand_kit", `Pass at least one of ${EMAIL_BRAND_KIT_FIELDS.join(", ")}.`);
192
+ }
193
+ return patch;
194
+ }
195
+ /** A newsletter may go live only once the kit carries a postal address. */
196
+ export function brandKitHasPostalAddress(kit) {
197
+ return typeof kit?.postal_address === "string" && kit.postal_address.trim().length > 0;
198
+ }
199
+ export function brandKitGlobalStyles(kit) {
200
+ const merged = { ...DEFAULT_EMAIL_BRAND_KIT, ...stripNullish(kit ?? {}) };
201
+ return {
202
+ background: merged.background_color,
203
+ contentBackground: merged.content_background_color,
204
+ text: merged.text_color,
205
+ link: merged.link_color,
206
+ button: merged.button_color,
207
+ buttonText: merged.button_text_color,
208
+ containerWidth: merged.container_width,
209
+ fontFamily: EMAIL_BRAND_FONT_STACKS[merged.font_family] ?? EMAIL_BRAND_FONT_STACKS.system,
210
+ };
211
+ }
212
+ function stripNullish(value) {
213
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== null && entry !== undefined));
214
+ }
@@ -25,6 +25,14 @@ export type SendingDomainEmailDelivery = {
25
25
  export type SequenceEmailDelivery = {
26
26
  mode: "inboxes";
27
27
  } | SendingDomainEmailDelivery;
28
+ /**
29
+ * The opt-out line of a cold email sent as plain text, when its Sequence turned the
30
+ * visible unsubscribe on without writing its own `reply_to_stop_text`. Plain text
31
+ * shows a line, never the signed URL (Plain T-317). Any reply stops the lead's
32
+ * enrollment in that Sequence, and opt-out wording ("not interested") puts the
33
+ * address on do-not-contact unless the workspace lowered `email_optout_phrase`.
34
+ */
35
+ export declare const DEFAULT_PLAIN_TEXT_OPT_OUT = "Not interested? Just reply and I won't follow up.";
28
36
  export declare class EmailDeliverySettingError extends Error {
29
37
  readonly field: string;
30
38
  constructor(field: string, message: string);
@@ -120,6 +128,8 @@ export declare function sendingDomainRemainingCapacity(input: {
120
128
  export declare function bulkSendIntervalMs(maxSendRate: number): number;
121
129
  /** Estimated AWS cost of a send count, USD rounded up to the cent. */
122
130
  export declare function estimateSesCostUsd(sends: number): number;
131
+ /** Credits for newsletter emails sent through a sending domain (`send.bulk_email`, 5x AWS). */
132
+ export declare function bulkEmailCredits(emails: number): number;
123
133
  export declare const RELEASE_SLICE_FRACTION = 0.05;
124
134
  export declare const RELEASE_SLICE_MINIMUM = 50;
125
135
  export declare const RELEASE_SLICE_HOLD_MS: number;
@@ -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;