@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.
- package/README.md +1 -1
- package/dist/command-families/program.js +4 -0
- package/dist/command-manifest.js +18 -3
- 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/custom-integrations.js +13 -0
- 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/inbox.js +1 -1
- package/dist/commands/integrations.js +5 -1
- package/dist/commands/mailboxes.js +26 -6
- package/dist/commands/managed-inboxes.js +2 -2
- package/dist/commands/senders.js +4 -4
- package/dist/commands/sequences.js +47 -12
- package/dist/commands/subscriptions.d.ts +2 -4
- package/dist/commands/subscriptions.js +297 -51
- package/dist/credential-storage/store.d.ts +10 -0
- package/dist/credential-storage/store.js +282 -0
- package/dist/credentials.d.ts +1 -3
- package/dist/credentials.js +3 -275
- package/dist/mailbox-fleet-output.js +11 -2
- 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/dist/workspace-placement-commands.js +3 -2
- 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 +236 -18
- package/node_modules/@oxygen/shared/dist/clay-migration-auth-state.d.ts +7 -1
- package/node_modules/@oxygen/shared/dist/clay-migration-auth-state.js +6 -5
- package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +1 -0
- package/node_modules/@oxygen/shared/dist/contracts/clay-migration.d.ts +40 -1
- package/node_modules/@oxygen/shared/dist/contracts/clay-migration.js +60 -6
- 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/customer-operations.js +2 -1
- package/node_modules/@oxygen/shared/dist/contracts/teammates.js +6 -1
- package/node_modules/@oxygen/shared/dist/copilot-journeys.generated.js +1 -1
- package/node_modules/@oxygen/shared/dist/copilot-playbooks.generated.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/copilot-playbooks.generated.js +2 -2
- 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-classification-scope.d.ts +35 -0
- package/node_modules/@oxygen/shared/dist/email-classification-scope.js +40 -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/native-chat.d.ts +9 -0
- package/node_modules/@oxygen/shared/dist/native-chat.js +12 -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/onboarding-research.d.ts +9 -2
- package/node_modules/@oxygen/shared/dist/onboarding-research.js +9 -2
- 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/recipes.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/recipes.js +13 -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 +30 -0
- package/node_modules/@oxygen/shared/dist/sending-domain-rollout.js +75 -0
- 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/tool-call-arguments.d.ts +24 -0
- package/node_modules/@oxygen/shared/dist/tool-call-arguments.js +68 -0
- 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/node_modules/@oxygen/shared/dist/workspace-placement.d.ts +3 -0
- package/node_modules/@oxygen/shared/dist/workspace-placement.js +11 -0
- 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
|
-
|
|
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;
|
|
@@ -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 (`javascript:`, `java	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(/:/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[][];
|