@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,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[][];
|
|
@@ -8,7 +8,27 @@
|
|
|
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 const EMAIL_SUBSCRIPTION_LIST_KINDS = ["marketing", "product_updates", "onboarding"];
|
|
11
|
+
export const EMAIL_SUBSCRIPTION_LIST_KINDS = ["marketing", "product_updates", "onboarding", "topic"];
|
|
12
|
+
/**
|
|
13
|
+
* Topics: a `topic` list is a narrower subject inside an audience list
|
|
14
|
+
* (`parent_list_id`). A newsletter on a topic mails only the topic's
|
|
15
|
+
* subscribers. `opt_out` topics are joined together with their audience list
|
|
16
|
+
* (the same consent evidence, source `topic_default`); `opt_in` topics only by
|
|
17
|
+
* an explicit choice. Public lists show on the recipient's preference page;
|
|
18
|
+
* private ones only to someone already subscribed to them.
|
|
19
|
+
*/
|
|
20
|
+
export const EMAIL_SUBSCRIPTION_LIST_VISIBILITIES = ["public", "private"];
|
|
21
|
+
export const EMAIL_SUBSCRIPTION_DEFAULTS = ["opt_in", "opt_out"];
|
|
22
|
+
/** The consent source of a topic subscription made by joining its audience list. */
|
|
23
|
+
export const TOPIC_DEFAULT_SOURCE = "topic_default";
|
|
24
|
+
/** The consent source of a change the recipient made on the hosted preference page. */
|
|
25
|
+
export const PREFERENCE_CENTER_SOURCE = "preference_center";
|
|
26
|
+
export function isEmailSubscriptionListVisibility(value) {
|
|
27
|
+
return typeof value === "string" && EMAIL_SUBSCRIPTION_LIST_VISIBILITIES.includes(value);
|
|
28
|
+
}
|
|
29
|
+
export function isEmailSubscriptionDefault(value) {
|
|
30
|
+
return typeof value === "string" && EMAIL_SUBSCRIPTION_DEFAULTS.includes(value);
|
|
31
|
+
}
|
|
12
32
|
export const EMAIL_SUBSCRIPTION_STATUSES = ["subscribed", "unsubscribed", "pending", "cleaned"];
|
|
13
33
|
/** Append-only consent event kinds. Status changes plus the provider facts that cause them. */
|
|
14
34
|
export const EMAIL_SUBSCRIPTION_EVENT_TYPES = [
|
|
@@ -78,3 +98,131 @@ export function parseEmailConsentEvidence(value, options) {
|
|
|
78
98
|
}
|
|
79
99
|
return evidence;
|
|
80
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Contacts of a list: one subscription read together with the Records person it
|
|
103
|
+
* names (`email_subscriptions.person_id`, a People record). Records owns the
|
|
104
|
+
* person (name and the workspace's own People fields, the contact
|
|
105
|
+
* "properties"); the list owns status and consent.
|
|
106
|
+
*/
|
|
107
|
+
export const EMAIL_CONTACT_PROPERTY_TYPES = ["string", "number"];
|
|
108
|
+
/** Most contacts one list page returns. */
|
|
109
|
+
export const EMAIL_CONTACTS_PAGE_LIMIT = 200;
|
|
110
|
+
/**
|
|
111
|
+
* Most import entries per call that carry a name or properties: each one is a
|
|
112
|
+
* Records write (create or match the person by email), so these batches are
|
|
113
|
+
* smaller than an address-only import. The CLI and web split larger files.
|
|
114
|
+
*/
|
|
115
|
+
export const EMAIL_CONTACT_IMPORT_WITH_FIELDS_LIMIT = 1000;
|
|
116
|
+
export function isEmailContactPropertyType(value) {
|
|
117
|
+
return typeof value === "string" && EMAIL_CONTACT_PROPERTY_TYPES.includes(value);
|
|
118
|
+
}
|
|
119
|
+
/** Header names an import reads as the contact itself (or as export bookkeeping), never as a property. */
|
|
120
|
+
const CONTACT_CSV_RESERVED = new Set([
|
|
121
|
+
"email", "email_address", "e-mail", "person_id", "first_name", "last_name", "full_name",
|
|
122
|
+
"status", "source", "subscribed_at", "unsubscribed_at", "consent_at", "consent_source", "consent_basis",
|
|
123
|
+
"consent_notice_version", "consent_form_url",
|
|
124
|
+
]);
|
|
125
|
+
/** Parse CSV text into rows of cells, honouring quoted fields ("" escapes, commas and newlines inside quotes). */
|
|
126
|
+
export function parseCsvCells(text) {
|
|
127
|
+
const rows = [];
|
|
128
|
+
let row = [];
|
|
129
|
+
let cell = "";
|
|
130
|
+
let quoted = false;
|
|
131
|
+
const input = text.replace(/^/, "");
|
|
132
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
133
|
+
const char = input[index];
|
|
134
|
+
if (quoted) {
|
|
135
|
+
if (char === "\"" && input[index + 1] === "\"") {
|
|
136
|
+
cell += "\"";
|
|
137
|
+
index += 1;
|
|
138
|
+
}
|
|
139
|
+
else if (char === "\"")
|
|
140
|
+
quoted = false;
|
|
141
|
+
else
|
|
142
|
+
cell += char;
|
|
143
|
+
}
|
|
144
|
+
else if (char === "\"")
|
|
145
|
+
quoted = true;
|
|
146
|
+
else if (char === ",") {
|
|
147
|
+
row.push(cell.trim());
|
|
148
|
+
cell = "";
|
|
149
|
+
}
|
|
150
|
+
else if (char === "\n" || char === "\r") {
|
|
151
|
+
if (char === "\r" && input[index + 1] === "\n")
|
|
152
|
+
index += 1;
|
|
153
|
+
row.push(cell.trim());
|
|
154
|
+
cell = "";
|
|
155
|
+
if (row.some((value) => value !== ""))
|
|
156
|
+
rows.push(row);
|
|
157
|
+
row = [];
|
|
158
|
+
}
|
|
159
|
+
else
|
|
160
|
+
cell += char;
|
|
161
|
+
}
|
|
162
|
+
row.push(cell.trim());
|
|
163
|
+
if (row.some((value) => value !== ""))
|
|
164
|
+
rows.push(row);
|
|
165
|
+
return rows;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Contacts from a CSV with an email column (the shape `subscriptions export`
|
|
169
|
+
* writes): first_name and last_name go to the person, every other non-reserved
|
|
170
|
+
* column is a candidate property, named in `propertyColumns` so a caller can
|
|
171
|
+
* keep only the workspace's real properties.
|
|
172
|
+
*/
|
|
173
|
+
export function parseContactsCsv(text) {
|
|
174
|
+
const [headerRow = [], ...body] = parseCsvCells(text);
|
|
175
|
+
const header = headerRow.map((cell) => cell.toLowerCase());
|
|
176
|
+
const emailIndex = header.findIndex((cell) => cell === "email" || cell === "email_address" || cell === "e-mail");
|
|
177
|
+
if (emailIndex < 0) {
|
|
178
|
+
throw new EmailSubscriptionInputError("invalid_request", "file", "The CSV needs an email column in its header row.");
|
|
179
|
+
}
|
|
180
|
+
const at = (name) => header.indexOf(name);
|
|
181
|
+
const personIndex = at("person_id");
|
|
182
|
+
const firstIndex = at("first_name");
|
|
183
|
+
const lastIndex = at("last_name");
|
|
184
|
+
const propertyColumns = header.flatMap((cell, index) => (cell && !CONTACT_CSV_RESERVED.has(cell) ? [{ key: cell, index }] : []));
|
|
185
|
+
const entries = body.map((cells) => {
|
|
186
|
+
const properties = Object.fromEntries(propertyColumns.flatMap(({ key, index }) => (cells[index] ? [[key, cells[index]]] : [])));
|
|
187
|
+
const read = (index) => (index >= 0 && cells[index] ? cells[index] : undefined);
|
|
188
|
+
const personId = read(personIndex);
|
|
189
|
+
const firstName = read(firstIndex);
|
|
190
|
+
const lastName = read(lastIndex);
|
|
191
|
+
return {
|
|
192
|
+
email: cells[emailIndex] ?? "",
|
|
193
|
+
...(personId ? { person_id: personId } : {}),
|
|
194
|
+
...(firstName ? { first_name: firstName } : {}),
|
|
195
|
+
...(lastName ? { last_name: lastName } : {}),
|
|
196
|
+
...(Object.keys(properties).length > 0 ? { properties } : {}),
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
return { entries, propertyColumns: propertyColumns.map((column) => column.key) };
|
|
200
|
+
}
|
|
201
|
+
/** Keep only properties the workspace has; report the columns dropped. */
|
|
202
|
+
export function keepContactProperties(entries, known) {
|
|
203
|
+
const columns = new Set(entries.flatMap((entry) => Object.keys(entry.properties ?? {})));
|
|
204
|
+
const ignored = [...columns].filter((column) => !known.has(column)).sort();
|
|
205
|
+
if (ignored.length === 0)
|
|
206
|
+
return { entries, ignored };
|
|
207
|
+
return {
|
|
208
|
+
ignored,
|
|
209
|
+
entries: entries.map((entry) => {
|
|
210
|
+
if (!entry.properties)
|
|
211
|
+
return entry;
|
|
212
|
+
const { properties, ...rest } = entry;
|
|
213
|
+
const kept = Object.fromEntries(Object.entries(properties).filter(([key]) => known.has(key)));
|
|
214
|
+
return Object.keys(kept).length > 0 ? { ...rest, properties: kept } : rest;
|
|
215
|
+
}),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
function importRowHasFields(entry) {
|
|
219
|
+
return Boolean(entry.first_name || entry.last_name || (entry.properties && Object.keys(entry.properties).length > 0));
|
|
220
|
+
}
|
|
221
|
+
/** Split an import into calls the import route accepts (smaller batches once any entry carries a name or properties). */
|
|
222
|
+
export function chunkContactImport(entries) {
|
|
223
|
+
const size = entries.some(importRowHasFields) ? EMAIL_CONTACT_IMPORT_WITH_FIELDS_LIMIT : EMAIL_SUBSCRIPTION_IMPORT_LIMIT;
|
|
224
|
+
const chunks = [];
|
|
225
|
+
for (let index = 0; index < entries.length; index += size)
|
|
226
|
+
chunks.push(entries.slice(index, index + size));
|
|
227
|
+
return chunks.length > 0 ? chunks : [[]];
|
|
228
|
+
}
|
|
@@ -55,8 +55,11 @@ export type UnsubscribeTokenPayloadV2 = {
|
|
|
55
55
|
email: string;
|
|
56
56
|
/** Subscription list id the recipient leaves. */
|
|
57
57
|
lst: string;
|
|
58
|
-
/**
|
|
59
|
-
|
|
58
|
+
/**
|
|
59
|
+
* `list` leaves only `lst`; `all` leaves every list and suppresses the
|
|
60
|
+
* address; `test` (a newsletter test send) changes nothing and says so.
|
|
61
|
+
*/
|
|
62
|
+
scope: "list" | "all" | "test";
|
|
60
63
|
seq?: string;
|
|
61
64
|
enr?: string;
|
|
62
65
|
/** Sending-domain account id, for provenance. */
|
|
@@ -71,6 +74,42 @@ export declare function signUnsubscribeTokenV2(payload: UnsubscribeTokenPayloadV
|
|
|
71
74
|
* v1 callers keep `verifyUnsubscribeToken`, whose contract is unchanged.
|
|
72
75
|
*/
|
|
73
76
|
export declare function verifyAnyUnsubscribeToken(token: string, secret: string): AnyUnsubscribeTokenPayload | null;
|
|
77
|
+
/**
|
|
78
|
+
* v2 `preferences`: the link to the hosted preference page of bulk mail
|
|
79
|
+
* (`{{preferences_url}}`). Same wire format and secret as the unsubscribe
|
|
80
|
+
* tokens, but a separate scope that `verifyAnyUnsubscribeToken` refuses, so a
|
|
81
|
+
* preferences link can never perform a one-click unsubscribe. `test` marks the
|
|
82
|
+
* link of a newsletter test send: the page says so and changes nothing.
|
|
83
|
+
*/
|
|
84
|
+
export type PreferencesTokenPayload = {
|
|
85
|
+
v: 2;
|
|
86
|
+
org: string;
|
|
87
|
+
email: string;
|
|
88
|
+
/** The list the email was sent from; the page shows every list the address may manage. */
|
|
89
|
+
lst: string;
|
|
90
|
+
scope: "preferences";
|
|
91
|
+
test?: true;
|
|
92
|
+
seq?: string;
|
|
93
|
+
sd?: string;
|
|
94
|
+
iat: number;
|
|
95
|
+
};
|
|
96
|
+
/** A preferences link stops opening the page a year after the email that carried it. */
|
|
97
|
+
export declare const PREFERENCES_TOKEN_MAX_AGE_MS: number;
|
|
98
|
+
export declare function signPreferencesToken(payload: PreferencesTokenPayload, secret: string): string;
|
|
99
|
+
/**
|
|
100
|
+
* Verify a preferences token: signature, shape and age. Returns null for any
|
|
101
|
+
* problem, including an unsubscribe token (wrong scope) and a link older than
|
|
102
|
+
* PREFERENCES_TOKEN_MAX_AGE_MS; `expired` tells the page which copy to show.
|
|
103
|
+
*/
|
|
104
|
+
export declare function verifyPreferencesToken(token: string, secret: string, nowMs?: number): {
|
|
105
|
+
payload: PreferencesTokenPayload;
|
|
106
|
+
expired: false;
|
|
107
|
+
} | {
|
|
108
|
+
payload: null;
|
|
109
|
+
expired: boolean;
|
|
110
|
+
};
|
|
111
|
+
/** The hosted preference page: org id in the path (re-checked against the token), token in `token`. */
|
|
112
|
+
export declare function buildPreferencesUrl(orgId: string, token: string, baseUrl: string): string;
|
|
74
113
|
/**
|
|
75
114
|
* The HMAC signing secret for unsubscribe tokens, or null when unset. The send
|
|
76
115
|
* path treats null as "omit the header" (a send with no one-click link still
|
|
@@ -72,7 +72,7 @@ function parseTokenPayloadV2(body) {
|
|
|
72
72
|
const lst = typeof candidate.lst === "string" ? candidate.lst.trim() : "";
|
|
73
73
|
if (!org || !email || !email.includes("@") || !lst)
|
|
74
74
|
return null;
|
|
75
|
-
if (candidate.scope !== "list" && candidate.scope !== "all")
|
|
75
|
+
if (candidate.scope !== "list" && candidate.scope !== "all" && candidate.scope !== "test")
|
|
76
76
|
return null;
|
|
77
77
|
return {
|
|
78
78
|
v: 2,
|
|
@@ -96,6 +96,55 @@ export function verifyAnyUnsubscribeToken(token, secret) {
|
|
|
96
96
|
return null;
|
|
97
97
|
return parseTokenPayload(body) ?? parseTokenPayloadV2(body);
|
|
98
98
|
}
|
|
99
|
+
/** A preferences link stops opening the page a year after the email that carried it. */
|
|
100
|
+
export const PREFERENCES_TOKEN_MAX_AGE_MS = 365 * 24 * 60 * 60 * 1000;
|
|
101
|
+
export function signPreferencesToken(payload, secret) {
|
|
102
|
+
const normalized = { ...payload, email: payload.email.trim().toLowerCase() };
|
|
103
|
+
return signPayload(normalized, secret);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Verify a preferences token: signature, shape and age. Returns null for any
|
|
107
|
+
* problem, including an unsubscribe token (wrong scope) and a link older than
|
|
108
|
+
* PREFERENCES_TOKEN_MAX_AGE_MS; `expired` tells the page which copy to show.
|
|
109
|
+
*/
|
|
110
|
+
export function verifyPreferencesToken(token, secret, nowMs = Date.now()) {
|
|
111
|
+
const body = splitAndVerifyToken(token, secret);
|
|
112
|
+
if (body === null)
|
|
113
|
+
return { payload: null, expired: false };
|
|
114
|
+
let parsed;
|
|
115
|
+
try {
|
|
116
|
+
parsed = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return { payload: null, expired: false };
|
|
120
|
+
}
|
|
121
|
+
if (!parsed || typeof parsed !== "object")
|
|
122
|
+
return { payload: null, expired: false };
|
|
123
|
+
const candidate = parsed;
|
|
124
|
+
const org = typeof candidate.org === "string" ? candidate.org.trim() : "";
|
|
125
|
+
const email = typeof candidate.email === "string" ? candidate.email.trim().toLowerCase() : "";
|
|
126
|
+
const lst = typeof candidate.lst === "string" ? candidate.lst.trim() : "";
|
|
127
|
+
if (candidate.v !== 2 || candidate.scope !== "preferences" || !org || !email.includes("@") || !lst) {
|
|
128
|
+
return { payload: null, expired: false };
|
|
129
|
+
}
|
|
130
|
+
const iat = typeof candidate.iat === "number" && Number.isFinite(candidate.iat) ? candidate.iat : 0;
|
|
131
|
+
if (nowMs - iat > PREFERENCES_TOKEN_MAX_AGE_MS)
|
|
132
|
+
return { payload: null, expired: true };
|
|
133
|
+
return {
|
|
134
|
+
expired: false,
|
|
135
|
+
payload: {
|
|
136
|
+
v: 2, org, email, lst, scope: "preferences",
|
|
137
|
+
...(candidate.test === true ? { test: true } : {}),
|
|
138
|
+
...(typeof candidate.seq === "string" && candidate.seq ? { seq: candidate.seq } : {}),
|
|
139
|
+
...(typeof candidate.sd === "string" && candidate.sd ? { sd: candidate.sd } : {}),
|
|
140
|
+
iat,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/** The hosted preference page: org id in the path (re-checked against the token), token in `token`. */
|
|
145
|
+
export function buildPreferencesUrl(orgId, token, baseUrl) {
|
|
146
|
+
return `${baseUrl}/email/preferences/${encodeURIComponent(orgId)}?token=${token}`;
|
|
147
|
+
}
|
|
99
148
|
/**
|
|
100
149
|
* The HMAC signing secret for unsubscribe tokens, or null when unset. The send
|
|
101
150
|
* path treats null as "omit the header" (a send with no one-click link still
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Newsletter analytics: what one sending-domain Sequence's bulk mail did, read
|
|
3
|
+
* from its SES ledger (`ox_runs.bulk_email_send_jobs` + `ox_sequencer.bulk_email_events`).
|
|
4
|
+
*
|
|
5
|
+
* Opens are estimates: Apple Mail Privacy Protection and corporate link scanners
|
|
6
|
+
* fetch the tracking pixel and links for the recipient, so every surface labels
|
|
7
|
+
* opens "estimated", and events SES flags as bots are never counted. Rates are
|
|
8
|
+
* fractions (0..1) over delivered mail, except the bounce rate, which is over
|
|
9
|
+
* sent mail (a bounced message was never delivered); null when the denominator is 0.
|
|
10
|
+
*
|
|
11
|
+
* Pure and browser-safe: the route, the web panel, the CLI and MCP share it.
|
|
12
|
+
*/
|
|
13
|
+
/** A recipient's furthest outcome, most significant first when several apply. */
|
|
14
|
+
export declare const NEWSLETTER_RECIPIENT_STATUSES: readonly ["queued", "sent", "delivered", "opened", "clicked", "bounced", "complained", "unsubscribed", "suppressed", "failed"];
|
|
15
|
+
export type NewsletterRecipientStatus = typeof NEWSLETTER_RECIPIENT_STATUSES[number];
|
|
16
|
+
export declare const NEWSLETTER_TIMELINE_INTERVALS: readonly ["hour", "day"];
|
|
17
|
+
export type NewsletterTimelineInterval = typeof NEWSLETTER_TIMELINE_INTERVALS[number];
|
|
18
|
+
/** The timeline covers the first seven days after the first send. */
|
|
19
|
+
export declare const NEWSLETTER_TIMELINE_DAYS = 7;
|
|
20
|
+
export declare const NEWSLETTER_RECIPIENTS_MAX_LIMIT = 200;
|
|
21
|
+
export declare const NEWSLETTER_RECIPIENTS_DEFAULT_LIMIT = 50;
|
|
22
|
+
export declare const NEWSLETTER_LINKS_LIMIT = 50;
|
|
23
|
+
export type NewsletterLinkStats = {
|
|
24
|
+
link: string;
|
|
25
|
+
unique_clicks: number;
|
|
26
|
+
total_clicks: number;
|
|
27
|
+
};
|
|
28
|
+
export type NewsletterTimelineBucket = {
|
|
29
|
+
at: string;
|
|
30
|
+
delivered: number;
|
|
31
|
+
/** Recipients whose first (non-bot) open fell in this bucket. */
|
|
32
|
+
opened: number;
|
|
33
|
+
/** Recipients whose first (non-bot) click fell in this bucket. */
|
|
34
|
+
clicked: number;
|
|
35
|
+
};
|
|
36
|
+
export type NewsletterAnalytics = {
|
|
37
|
+
recipients_targeted: number;
|
|
38
|
+
sent: number;
|
|
39
|
+
delivered: number;
|
|
40
|
+
opens: {
|
|
41
|
+
unique: number;
|
|
42
|
+
total: number;
|
|
43
|
+
estimated: true;
|
|
44
|
+
};
|
|
45
|
+
clicks: {
|
|
46
|
+
unique: number;
|
|
47
|
+
total: number;
|
|
48
|
+
};
|
|
49
|
+
bounces: {
|
|
50
|
+
hard: number;
|
|
51
|
+
soft: number;
|
|
52
|
+
};
|
|
53
|
+
complaints: number;
|
|
54
|
+
unsubscribes: number;
|
|
55
|
+
suppressed: number;
|
|
56
|
+
rates: {
|
|
57
|
+
open_rate: number | null;
|
|
58
|
+
click_rate: number | null;
|
|
59
|
+
bounce_rate: number | null;
|
|
60
|
+
complaint_rate: number | null;
|
|
61
|
+
unsubscribe_rate: number | null;
|
|
62
|
+
};
|
|
63
|
+
links: NewsletterLinkStats[];
|
|
64
|
+
timeline: {
|
|
65
|
+
interval: NewsletterTimelineInterval;
|
|
66
|
+
start: string | null;
|
|
67
|
+
buckets: NewsletterTimelineBucket[];
|
|
68
|
+
};
|
|
69
|
+
first_sent_at: string | null;
|
|
70
|
+
last_event_at: string | null;
|
|
71
|
+
};
|
|
72
|
+
export type NewsletterRecipient = {
|
|
73
|
+
email: string;
|
|
74
|
+
status: NewsletterRecipientStatus;
|
|
75
|
+
enrollment_id: string;
|
|
76
|
+
sent_at: string | null;
|
|
77
|
+
opens: number;
|
|
78
|
+
clicks: number;
|
|
79
|
+
last_event_at: string | null;
|
|
80
|
+
};
|
|
81
|
+
export type NewsletterRecipientsPage = {
|
|
82
|
+
recipients: NewsletterRecipient[];
|
|
83
|
+
status: NewsletterRecipientStatus | null;
|
|
84
|
+
total: number;
|
|
85
|
+
limit: number;
|
|
86
|
+
offset: number;
|
|
87
|
+
next_offset: number | null;
|
|
88
|
+
};
|
|
89
|
+
export declare function isNewsletterRecipientStatus(value: unknown): value is NewsletterRecipientStatus;
|
|
90
|
+
export declare function isNewsletterTimelineInterval(value: unknown): value is NewsletterTimelineInterval;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Newsletter analytics: what one sending-domain Sequence's bulk mail did, read
|
|
3
|
+
* from its SES ledger (`ox_runs.bulk_email_send_jobs` + `ox_sequencer.bulk_email_events`).
|
|
4
|
+
*
|
|
5
|
+
* Opens are estimates: Apple Mail Privacy Protection and corporate link scanners
|
|
6
|
+
* fetch the tracking pixel and links for the recipient, so every surface labels
|
|
7
|
+
* opens "estimated", and events SES flags as bots are never counted. Rates are
|
|
8
|
+
* fractions (0..1) over delivered mail, except the bounce rate, which is over
|
|
9
|
+
* sent mail (a bounced message was never delivered); null when the denominator is 0.
|
|
10
|
+
*
|
|
11
|
+
* Pure and browser-safe: the route, the web panel, the CLI and MCP share it.
|
|
12
|
+
*/
|
|
13
|
+
/** A recipient's furthest outcome, most significant first when several apply. */
|
|
14
|
+
export const NEWSLETTER_RECIPIENT_STATUSES = [
|
|
15
|
+
"queued", "sent", "delivered", "opened", "clicked", "bounced", "complained", "unsubscribed", "suppressed", "failed",
|
|
16
|
+
];
|
|
17
|
+
export const NEWSLETTER_TIMELINE_INTERVALS = ["hour", "day"];
|
|
18
|
+
/** The timeline covers the first seven days after the first send. */
|
|
19
|
+
export const NEWSLETTER_TIMELINE_DAYS = 7;
|
|
20
|
+
export const NEWSLETTER_RECIPIENTS_MAX_LIMIT = 200;
|
|
21
|
+
export const NEWSLETTER_RECIPIENTS_DEFAULT_LIMIT = 50;
|
|
22
|
+
export const NEWSLETTER_LINKS_LIMIT = 50;
|
|
23
|
+
export function isNewsletterRecipientStatus(value) {
|
|
24
|
+
return typeof value === "string" && NEWSLETTER_RECIPIENT_STATUSES.includes(value);
|
|
25
|
+
}
|
|
26
|
+
export function isNewsletterTimelineInterval(value) {
|
|
27
|
+
return typeof value === "string" && NEWSLETTER_TIMELINE_INTERVALS.includes(value);
|
|
28
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { CliHttpError } from "./cli-http-error.js";
|
|
2
|
+
export declare const NEWSLETTER_TEST_MAX_RECIPIENTS = 5;
|
|
3
|
+
/** Test sends (requests, not emails) a workspace may make per day. */
|
|
4
|
+
export declare const NEWSLETTER_TEST_DAILY_LIMIT = 20;
|
|
5
|
+
export declare const NEWSLETTER_TEST_SUBJECT_PREFIX = "[Test] ";
|
|
6
|
+
/** SES message tag every test email carries; event apply and workspace health skip it. */
|
|
7
|
+
export declare const NEWSLETTER_TEST_SES_TAG = "oxygen_test";
|
|
8
|
+
export declare const NEWSLETTER_TEST_FAULT_CODES: {
|
|
9
|
+
readonly recipientNotMember: "test_send_recipient_not_member";
|
|
10
|
+
readonly notNewsletter: "test_send_not_newsletter";
|
|
11
|
+
readonly unavailable: "test_send_unavailable";
|
|
12
|
+
readonly failed: "test_send_failed";
|
|
13
|
+
};
|
|
14
|
+
export declare function testSendRecipientNotMemberError(recipients: readonly string[]): CliHttpError;
|
|
15
|
+
export declare function testSendNotNewsletterError(): CliHttpError;
|
|
16
|
+
export declare function testSendUnavailableError(): CliHttpError;
|
|
17
|
+
export declare function testSendFailedError(recipients: ReadonlyArray<{
|
|
18
|
+
email: string;
|
|
19
|
+
status: string;
|
|
20
|
+
}>): CliHttpError;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { CliHttpError } from "./cli-http-error.js";
|
|
2
|
+
// A newsletter test send: one email step rendered as delivery renders it, sent
|
|
3
|
+
// to at most five verified addresses of the workspace's own members. Web, CLI
|
|
4
|
+
// and MCP share these limits and refusals (ADR 0023: fixed copy, a machine code
|
|
5
|
+
// and a next step; none names SES or another workspace).
|
|
6
|
+
export const NEWSLETTER_TEST_MAX_RECIPIENTS = 5;
|
|
7
|
+
/** Test sends (requests, not emails) a workspace may make per day. */
|
|
8
|
+
export const NEWSLETTER_TEST_DAILY_LIMIT = 20;
|
|
9
|
+
export const NEWSLETTER_TEST_SUBJECT_PREFIX = "[Test] ";
|
|
10
|
+
/** SES message tag every test email carries; event apply and workspace health skip it. */
|
|
11
|
+
export const NEWSLETTER_TEST_SES_TAG = "oxygen_test";
|
|
12
|
+
export const NEWSLETTER_TEST_FAULT_CODES = {
|
|
13
|
+
recipientNotMember: "test_send_recipient_not_member",
|
|
14
|
+
notNewsletter: "test_send_not_newsletter",
|
|
15
|
+
unavailable: "test_send_unavailable",
|
|
16
|
+
failed: "test_send_failed",
|
|
17
|
+
};
|
|
18
|
+
export function testSendRecipientNotMemberError(recipients) {
|
|
19
|
+
return new CliHttpError(400, NEWSLETTER_TEST_FAULT_CODES.recipientNotMember, "Test emails go only to verified email addresses of this workspace's members.", {
|
|
20
|
+
details: {
|
|
21
|
+
field: "to",
|
|
22
|
+
recipients: [...recipients],
|
|
23
|
+
retryable: false,
|
|
24
|
+
next_step: "Send to your own address or a teammate's verified address; list them with `oxygen sequences test-send <sequence> --list-recipients`.",
|
|
25
|
+
},
|
|
26
|
+
exitCode: 1,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export function testSendNotNewsletterError() {
|
|
30
|
+
return new CliHttpError(409, NEWSLETTER_TEST_FAULT_CODES.notNewsletter, "Test sends are for Sequences that send through a sending domain.", {
|
|
31
|
+
details: { retryable: false, next_step: "Choose a sending domain in the Sequence's Sending settings, then send a test." },
|
|
32
|
+
exitCode: 1,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
export function testSendUnavailableError() {
|
|
36
|
+
return new CliHttpError(503, NEWSLETTER_TEST_FAULT_CODES.unavailable, "Test emails can't be sent right now.", {
|
|
37
|
+
details: { retryable: true, next_step: "Try again in a few minutes." },
|
|
38
|
+
exitCode: 1,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export function testSendFailedError(recipients) {
|
|
42
|
+
return new CliHttpError(502, NEWSLETTER_TEST_FAULT_CODES.failed, "The test email could not be sent.", {
|
|
43
|
+
details: { recipients: [...recipients], retryable: true, next_step: "Check the sending domain is active, then try again." },
|
|
44
|
+
exitCode: 1,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -103,11 +103,22 @@ export declare function readCopilotAttachmentObject(input: {
|
|
|
103
103
|
contentType: string | null;
|
|
104
104
|
truncated: boolean;
|
|
105
105
|
}>;
|
|
106
|
+
/** Authenticated callers retain authorization; never mint a transferable GET URL. */
|
|
107
|
+
export declare function openCopilotAttachmentObject(input: {
|
|
108
|
+
storageKey: string;
|
|
109
|
+
etag: string;
|
|
110
|
+
window?: {
|
|
111
|
+
offset: number;
|
|
112
|
+
limit: number;
|
|
113
|
+
};
|
|
114
|
+
signal?: AbortSignal;
|
|
115
|
+
}): Promise<ReadableStream<Uint8Array>>;
|
|
106
116
|
export declare function getCopilotAttachmentObjectMetadata(input: {
|
|
107
117
|
storageKey: string;
|
|
108
118
|
}): Promise<{
|
|
109
119
|
contentLength: number | null;
|
|
110
120
|
contentType: string | null;
|
|
121
|
+
etag: string | null;
|
|
111
122
|
}>;
|
|
112
123
|
/**
|
|
113
124
|
* Read the LEADING BYTES of a staged import back out of the bucket.
|