@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.
- package/README.md +1 -1
- package/dist/command-families/program.js +4 -0
- package/dist/command-manifest.js +14 -2
- package/dist/command-support.js +1 -1
- package/dist/commands/admin.js +82 -1
- package/dist/commands/billing.js +1 -1
- package/dist/commands/copilot.js +29 -7
- package/dist/commands/domains.js +7 -0
- package/dist/commands/email-assets.d.ts +3 -0
- package/dist/commands/email-assets.js +78 -0
- package/dist/commands/email-templates.d.ts +3 -0
- package/dist/commands/email-templates.js +101 -0
- package/dist/commands/sequences.js +47 -12
- package/dist/commands/subscriptions.d.ts +2 -4
- package/dist/commands/subscriptions.js +297 -51
- package/dist/sequence-start-at.d.ts +7 -0
- package/dist/sequence-start-at.js +21 -0
- package/dist/sequences-brand-kit-command.d.ts +28 -0
- package/dist/sequences-brand-kit-command.js +83 -0
- package/dist/sequences-content-commands.d.ts +28 -0
- package/dist/sequences-content-commands.js +176 -0
- package/dist/sequences-newsletter-commands.d.ts +6 -0
- package/dist/sequences-newsletter-commands.js +108 -0
- package/dist/sequences-test-send-command.d.ts +3 -0
- package/dist/sequences-test-send-command.js +47 -0
- package/node_modules/@oxygen/formula/dist/evaluate.js +2 -4
- package/node_modules/@oxygen/formula/dist/expression.d.ts +7 -5
- package/node_modules/@oxygen/formula/dist/expression.js +26 -4
- package/node_modules/@oxygen/formula/dist/formula-functions.d.ts +6 -0
- package/node_modules/@oxygen/formula/dist/formula-functions.js +55 -0
- package/node_modules/@oxygen/shared/dist/billing.d.ts +2 -2
- package/node_modules/@oxygen/shared/dist/billing.js +5 -7
- package/node_modules/@oxygen/shared/dist/bulk-email-pacing.d.ts +105 -0
- package/node_modules/@oxygen/shared/dist/bulk-email-pacing.js +125 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +29 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +174 -10
- package/node_modules/@oxygen/shared/dist/contracts/clay-migration.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/contracts/clay-migration.js +28 -2
- package/node_modules/@oxygen/shared/dist/contracts/copilot-attachment-read.d.ts +24 -0
- package/node_modules/@oxygen/shared/dist/contracts/copilot-attachment-read.js +7 -0
- package/node_modules/@oxygen/shared/dist/contracts/teammates.js +6 -1
- package/node_modules/@oxygen/shared/dist/email-brand-kit.d.ts +108 -0
- package/node_modules/@oxygen/shared/dist/email-brand-kit.js +214 -0
- package/node_modules/@oxygen/shared/dist/email-delivery.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/email-delivery.js +14 -0
- package/node_modules/@oxygen/shared/dist/email-documents.d.ts +61 -0
- package/node_modules/@oxygen/shared/dist/email-documents.js +143 -0
- package/node_modules/@oxygen/shared/dist/email-dsn.js +52 -10
- package/node_modules/@oxygen/shared/dist/email-html-sanitize.d.ts +23 -0
- package/node_modules/@oxygen/shared/dist/email-html-sanitize.js +96 -0
- package/node_modules/@oxygen/shared/dist/email-subscriptions.d.ts +63 -1
- package/node_modules/@oxygen/shared/dist/email-subscriptions.js +149 -1
- package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.d.ts +41 -2
- package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.js +50 -1
- package/node_modules/@oxygen/shared/dist/newsletter-analytics.d.ts +90 -0
- package/node_modules/@oxygen/shared/dist/newsletter-analytics.js +28 -0
- package/node_modules/@oxygen/shared/dist/newsletter-test-send.d.ts +20 -0
- package/node_modules/@oxygen/shared/dist/newsletter-test-send.js +46 -0
- package/node_modules/@oxygen/shared/dist/object-storage.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/object-storage.js +12 -0
- package/node_modules/@oxygen/shared/dist/plan-band.d.ts +9 -0
- package/node_modules/@oxygen/shared/dist/plan-band.js +17 -0
- package/node_modules/@oxygen/shared/dist/plan-capabilities.d.ts +4 -2
- package/node_modules/@oxygen/shared/dist/plan-capabilities.js +12 -1
- package/node_modules/@oxygen/shared/dist/plan-limits-view.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/plan-limits-view.js +3 -0
- package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +5 -0
- package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +5 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +29 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-events.js +29 -0
- package/node_modules/@oxygen/shared/dist/sending-domain-faults.d.ts +21 -0
- package/node_modules/@oxygen/shared/dist/sending-domain-faults.js +53 -0
- package/node_modules/@oxygen/shared/dist/sending-domain-rollout.d.ts +24 -1
- package/node_modules/@oxygen/shared/dist/sending-domain-rollout.js +51 -4
- package/node_modules/@oxygen/shared/dist/sequence-failures.d.ts +10 -1
- package/node_modules/@oxygen/shared/dist/sequence-failures.js +68 -6
- package/node_modules/@oxygen/shared/dist/sequence-lifecycle.d.ts +34 -3
- package/node_modules/@oxygen/shared/dist/sequence-lifecycle.js +57 -4
- package/node_modules/@oxygen/shared/dist/sequence-template.d.ts +17 -0
- package/node_modules/@oxygen/shared/dist/sequence-template.js +0 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +41 -0
- package/node_modules/@oxygen/shared/dist/sequences.js +86 -4
- package/node_modules/@oxygen/shared/dist/ses-identity-policy.d.ts +19 -0
- package/node_modules/@oxygen/shared/dist/ses-identity-policy.js +54 -0
- package/node_modules/@oxygen/shared/dist/skill-play-routing.d.ts +22 -0
- package/node_modules/@oxygen/shared/dist/skill-play-routing.js +75 -0
- package/node_modules/@oxygen/shared/dist/skill-plays.generated.d.ts +5 -0
- package/node_modules/@oxygen/shared/dist/skill-plays.generated.js +269 -0
- package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +7 -0
- package/node_modules/@oxygen/shared/dist/spend-safety.js +7 -0
- package/node_modules/@oxygen/shared/dist/teammates.js +2 -1
- package/node_modules/@oxygen/shared/dist/user-capability-routing.js +15 -1
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
- package/node_modules/@oxygen/shared/dist/workspace-event-catalog.js +2 -2
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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
|
|
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;
|