@shware/workflow 5.0.1 → 6.1.0
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 +224 -53
- package/dist/aws/index.cjs +1 -1
- package/dist/aws/index.mjs +1 -1
- package/dist/client.cjs +68 -4
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +39 -3
- package/dist/client.d.cts.map +1 -1
- package/dist/client.d.mts +39 -3
- package/dist/client.d.mts.map +1 -1
- package/dist/client.mjs +68 -4
- package/dist/client.mjs.map +1 -1
- package/dist/cloudflare/index.cjs +4 -4
- package/dist/cloudflare/index.mjs +4 -4
- package/dist/cloudflare/runner.cjs +1 -1
- package/dist/cloudflare/runner.d.cts +1 -1
- package/dist/cloudflare/runner.d.mts +1 -1
- package/dist/cloudflare/runner.mjs +1 -1
- package/dist/config.cjs +24 -3
- package/dist/config.cjs.map +1 -1
- package/dist/config.d.cts +15 -9
- package/dist/config.d.cts.map +1 -1
- package/dist/config.d.mts +15 -9
- package/dist/config.d.mts.map +1 -1
- package/dist/config.mjs +25 -4
- package/dist/config.mjs.map +1 -1
- package/dist/contract.cjs +11 -0
- package/dist/contract.cjs.map +1 -1
- package/dist/contract.d.cts +28 -7
- package/dist/contract.d.cts.map +1 -1
- package/dist/contract.d.mts +28 -7
- package/dist/contract.d.mts.map +1 -1
- package/dist/contract.mjs +11 -0
- package/dist/contract.mjs.map +1 -1
- package/dist/dsl/bundle.cjs +4 -3
- package/dist/dsl/bundle.cjs.map +1 -1
- package/dist/dsl/bundle.d.cts +5 -1
- package/dist/dsl/bundle.d.cts.map +1 -1
- package/dist/dsl/bundle.d.mts +5 -1
- package/dist/dsl/bundle.d.mts.map +1 -1
- package/dist/dsl/bundle.mjs +4 -3
- package/dist/dsl/bundle.mjs.map +1 -1
- package/dist/dsl/flow.cjs +2 -1
- package/dist/dsl/flow.cjs.map +1 -1
- package/dist/dsl/flow.d.cts +1 -1
- package/dist/dsl/flow.d.cts.map +1 -1
- package/dist/dsl/flow.d.mts +1 -1
- package/dist/dsl/flow.d.mts.map +1 -1
- package/dist/dsl/flow.mjs +2 -1
- package/dist/dsl/flow.mjs.map +1 -1
- package/dist/dsl/workflow.cjs +2 -2
- package/dist/dsl/workflow.mjs +2 -2
- package/dist/email-sender.cjs +3 -2
- package/dist/email-sender.cjs.map +1 -1
- package/dist/email-sender.d.cts +5 -4
- package/dist/email-sender.d.cts.map +1 -1
- package/dist/email-sender.d.mts +5 -4
- package/dist/email-sender.d.mts.map +1 -1
- package/dist/email-sender.mjs +3 -2
- package/dist/email-sender.mjs.map +1 -1
- package/dist/email.cjs +66 -11
- package/dist/email.cjs.map +1 -1
- package/dist/email.d.cts +72 -12
- package/dist/email.d.cts.map +1 -1
- package/dist/email.d.mts +72 -12
- package/dist/email.d.mts.map +1 -1
- package/dist/email.mjs +63 -12
- package/dist/email.mjs.map +1 -1
- package/dist/engine/condition.cjs.map +1 -1
- package/dist/engine/condition.d.cts.map +1 -1
- package/dist/engine/condition.d.mts.map +1 -1
- package/dist/engine/condition.mjs.map +1 -1
- package/dist/engine/index.cjs +1 -1
- package/dist/engine/index.mjs +1 -1
- package/dist/engine/interpreter.cjs +21 -4
- package/dist/engine/interpreter.cjs.map +1 -1
- package/dist/engine/interpreter.mjs +21 -4
- package/dist/engine/interpreter.mjs.map +1 -1
- package/dist/engine/ports.d.cts +14 -2
- package/dist/engine/ports.d.cts.map +1 -1
- package/dist/engine/ports.d.mts +14 -2
- package/dist/engine/ports.d.mts.map +1 -1
- package/dist/host/channels.cjs +1 -1
- package/dist/host/channels.mjs +1 -1
- package/dist/host/execute.cjs +2 -2
- package/dist/host/execute.d.cts +1 -1
- package/dist/host/execute.d.mts +1 -1
- package/dist/host/execute.mjs +2 -2
- package/dist/host/http.cjs +75 -5
- package/dist/host/http.cjs.map +1 -1
- package/dist/host/http.d.cts +2 -2
- package/dist/host/http.d.cts.map +1 -1
- package/dist/host/http.d.mts +2 -2
- package/dist/host/http.d.mts.map +1 -1
- package/dist/host/http.mjs +76 -6
- package/dist/host/http.mjs.map +1 -1
- package/dist/host/ingest.cjs +173 -4
- package/dist/host/ingest.cjs.map +1 -1
- package/dist/host/ingest.d.cts +99 -0
- package/dist/host/ingest.d.cts.map +1 -1
- package/dist/host/ingest.d.mts +99 -0
- package/dist/host/ingest.d.mts.map +1 -1
- package/dist/host/ingest.mjs +167 -5
- package/dist/host/ingest.mjs.map +1 -1
- package/dist/host/journey-host.cjs +10 -8
- package/dist/host/journey-host.cjs.map +1 -1
- package/dist/host/journey-host.mjs +10 -8
- package/dist/host/journey-host.mjs.map +1 -1
- package/dist/index.cjs +8 -2
- package/dist/index.d.cts +5 -5
- package/dist/index.d.mts +5 -5
- package/dist/index.mjs +5 -5
- package/dist/ir.cjs +11 -2
- package/dist/ir.cjs.map +1 -1
- package/dist/ir.d.cts +17 -0
- package/dist/ir.d.cts.map +1 -1
- package/dist/ir.d.mts +17 -0
- package/dist/ir.d.mts.map +1 -1
- package/dist/ir.mjs +11 -3
- package/dist/ir.mjs.map +1 -1
- package/dist/react-email.cjs +6 -5
- package/dist/react-email.cjs.map +1 -1
- package/dist/react-email.d.cts.map +1 -1
- package/dist/react-email.d.mts.map +1 -1
- package/dist/react-email.mjs +6 -5
- package/dist/react-email.mjs.map +1 -1
- package/dist/reporting/observer.cjs +4 -1
- package/dist/reporting/observer.cjs.map +1 -1
- package/dist/reporting/observer.d.cts +2 -1
- package/dist/reporting/observer.d.cts.map +1 -1
- package/dist/reporting/observer.d.mts +2 -1
- package/dist/reporting/observer.d.mts.map +1 -1
- package/dist/reporting/observer.mjs +4 -1
- package/dist/reporting/observer.mjs.map +1 -1
- package/dist/reporting/tracking.cjs +31 -10
- package/dist/reporting/tracking.cjs.map +1 -1
- package/dist/reporting/tracking.d.cts +15 -6
- package/dist/reporting/tracking.d.cts.map +1 -1
- package/dist/reporting/tracking.d.mts +15 -6
- package/dist/reporting/tracking.d.mts.map +1 -1
- package/dist/reporting/tracking.mjs +31 -11
- package/dist/reporting/tracking.mjs.map +1 -1
- package/dist/store/facts.cjs +3 -3
- package/dist/store/facts.cjs.map +1 -1
- package/dist/store/facts.d.cts +2 -2
- package/dist/store/facts.d.cts.map +1 -1
- package/dist/store/facts.d.mts +2 -2
- package/dist/store/facts.d.mts.map +1 -1
- package/dist/store/facts.mjs +3 -3
- package/dist/store/facts.mjs.map +1 -1
- package/dist/store/index.cjs +1 -1
- package/dist/store/index.cjs.map +1 -1
- package/dist/store/index.d.cts +44 -13
- package/dist/store/index.d.cts.map +1 -1
- package/dist/store/index.d.mts +44 -13
- package/dist/store/index.d.mts.map +1 -1
- package/dist/store/index.mjs +1 -1
- package/dist/store/index.mjs.map +1 -1
- package/dist/store/postgres-reporting.cjs +30 -10
- package/dist/store/postgres-reporting.cjs.map +1 -1
- package/dist/store/postgres-reporting.d.cts +4 -2
- package/dist/store/postgres-reporting.d.cts.map +1 -1
- package/dist/store/postgres-reporting.d.mts +4 -2
- package/dist/store/postgres-reporting.d.mts.map +1 -1
- package/dist/store/postgres-reporting.mjs +30 -10
- package/dist/store/postgres-reporting.mjs.map +1 -1
- package/dist/store/postgres.cjs +39 -3
- package/dist/store/postgres.cjs.map +1 -1
- package/dist/store/postgres.d.cts +4 -2
- package/dist/store/postgres.d.cts.map +1 -1
- package/dist/store/postgres.d.mts +4 -2
- package/dist/store/postgres.d.mts.map +1 -1
- package/dist/store/postgres.mjs +40 -4
- package/dist/store/postgres.mjs.map +1 -1
- package/dist/store/tables.cjs +75 -11
- package/dist/store/tables.cjs.map +1 -1
- package/dist/store/tables.d.cts +17 -1
- package/dist/store/tables.d.cts.map +1 -1
- package/dist/store/tables.d.mts +17 -1
- package/dist/store/tables.d.mts.map +1 -1
- package/dist/store/tables.mjs +74 -12
- package/dist/store/tables.mjs.map +1 -1
- package/dist/templates.cjs +60 -7
- package/dist/templates.cjs.map +1 -1
- package/dist/templates.d.cts +101 -13
- package/dist/templates.d.cts.map +1 -1
- package/dist/templates.d.mts +101 -13
- package/dist/templates.d.mts.map +1 -1
- package/dist/templates.mjs +61 -9
- package/dist/templates.mjs.map +1 -1
- package/package.json +1 -1
package/dist/email.cjs
CHANGED
|
@@ -25,15 +25,34 @@ function mimeAddress(address) {
|
|
|
25
25
|
if (SPECIALS.test(name)) return `"${name.replace(/(["\\])/g, "\\$1")}" <${email}>`;
|
|
26
26
|
return `${name} <${email}>`;
|
|
27
27
|
}
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
/**
|
|
29
|
+
* The message id a preview renders with: the preferences link shows in the studio and
|
|
30
|
+
* `GET /preview`, pointing at a copy that does not exist.
|
|
31
|
+
*/
|
|
32
|
+
const PREVIEW_MESSAGE_ID = "00000000-0000-0000-0000-000000000000";
|
|
33
|
+
/**
|
|
34
|
+
* The template's metadata, its `unsubscribeUrl` falling back to the config's, and the
|
|
35
|
+
* preferences link built from the config's page and the copy's message id — for a topic email; a
|
|
36
|
+
* transactional one resolves to neither link. Leaves the inputs untouched.
|
|
37
|
+
*/
|
|
38
|
+
function resolveEmail(template, config, options = {}) {
|
|
39
|
+
const topical = template.topic !== null;
|
|
40
|
+
const unsubscribeUrl = topical ? template.unsubscribeUrl ?? config?.unsubscribeUrl : void 0;
|
|
41
|
+
const preferencesUrl = topical && config?.preferencesUrl !== void 0 && options.messageId !== void 0 ? preferencesLink(config.preferencesUrl, options.messageId) : void 0;
|
|
31
42
|
return {
|
|
32
43
|
from: template.from,
|
|
44
|
+
topic: template.topic,
|
|
33
45
|
...template.replyTo === void 0 ? {} : { replyTo: template.replyTo },
|
|
34
|
-
...unsubscribeUrl === void 0 ? {} : { unsubscribeUrl }
|
|
46
|
+
...unsubscribeUrl === void 0 ? {} : { unsubscribeUrl },
|
|
47
|
+
...preferencesUrl === void 0 ? {} : { preferencesUrl }
|
|
35
48
|
};
|
|
36
49
|
}
|
|
50
|
+
/** The preference page's URL for one copy: `m=<message id>` set on its query, the rest of it kept. */
|
|
51
|
+
function preferencesLink(preferencesUrl, messageId) {
|
|
52
|
+
const url = new URL(preferencesUrl);
|
|
53
|
+
url.searchParams.set("m", messageId);
|
|
54
|
+
return url.toString();
|
|
55
|
+
}
|
|
37
56
|
const ADDRESS = /^[^\s@<>]+@[^\s@<>]+$/;
|
|
38
57
|
/** Throws naming `where` when `address` is neither `user@host` nor `{ email: 'user@host', name? }`. */
|
|
39
58
|
function assertEmailAddress(address, where) {
|
|
@@ -43,7 +62,7 @@ function assertEmailAddress(address, where) {
|
|
|
43
62
|
if (name !== void 0 && typeof name !== "string") throw new TypeError(`${where}.name must be a string`);
|
|
44
63
|
}
|
|
45
64
|
/** Throws naming `where` unless `url` is an absolute http(s) URL; '' and null are not a way to leave it out. */
|
|
46
|
-
function
|
|
65
|
+
function assertHttpUrl(url, where) {
|
|
47
66
|
let parsed;
|
|
48
67
|
try {
|
|
49
68
|
parsed = typeof url === "string" ? new URL(url) : void 0;
|
|
@@ -52,22 +71,54 @@ function assertUnsubscribeUrl(url, where) {
|
|
|
52
71
|
}
|
|
53
72
|
if (parsed === void 0 || parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new TypeError(`${where} must be an http(s) URL (leave it out for none)`);
|
|
54
73
|
}
|
|
74
|
+
/** `assertHttpUrl`, under its earlier name. */
|
|
75
|
+
const assertUnsubscribeUrl = assertHttpUrl;
|
|
55
76
|
/** The config's `email` and every registered template's metadata; throws on the first invalid value. */
|
|
56
77
|
function assertEmailConfig(config, templates) {
|
|
57
|
-
if (Object.hasOwn(config, "emails")) throw new TypeError("the config's `emails` is gone: `from` and `replyTo` are declared on each emailTemplate, and the rest moved to `email: { unsubscribeUrl, tracking }`");
|
|
78
|
+
if (Object.hasOwn(config, "emails")) throw new TypeError("the config's `emails` is gone: `from` and `replyTo` are declared on each emailTemplate, and the rest moved to `email: { unsubscribeUrl, preferencesUrl, tracking }`");
|
|
58
79
|
const email = config.email;
|
|
59
80
|
if (email !== void 0) {
|
|
60
|
-
if (email.unsubscribeUrl !== void 0)
|
|
81
|
+
if (email.unsubscribeUrl !== void 0) assertHttpUrl(email.unsubscribeUrl, "email.unsubscribeUrl");
|
|
82
|
+
if (email.preferencesUrl !== void 0) {
|
|
83
|
+
assertHttpUrl(email.preferencesUrl, "email.preferencesUrl");
|
|
84
|
+
if (isUnsubscribeLink(email.preferencesUrl, email.unsubscribeUrl)) throw new TypeError("email.preferencesUrl must not share its origin and path with email.unsubscribeUrl: the sender would track the preferences link as the unsubscribe link");
|
|
85
|
+
}
|
|
61
86
|
const quick = email.tracking?.quickClickMs;
|
|
62
87
|
if (quick !== void 0 && !(typeof quick === "number" && Number.isFinite(quick) && quick >= 0)) throw new TypeError("email.tracking.quickClickMs must be a non-negative number");
|
|
63
88
|
}
|
|
64
|
-
for (const template of templates)
|
|
89
|
+
for (const template of templates) {
|
|
90
|
+
const where = `email template '${template.id}'`;
|
|
91
|
+
assertEmailMetadata(template, where, true);
|
|
92
|
+
if (email?.preferencesUrl !== void 0 && isUnsubscribeLink(email.preferencesUrl, template.unsubscribeUrl)) throw new TypeError(`${where}: its unsubscribeUrl shares its origin and path with email.preferencesUrl, so the sender would track the preferences link as the unsubscribe link`);
|
|
93
|
+
}
|
|
65
94
|
}
|
|
66
|
-
/**
|
|
67
|
-
|
|
95
|
+
/**
|
|
96
|
+
* A template's own `from` / `replyTo` / `unsubscribeUrl` and what it is (`topic` or
|
|
97
|
+
* `transactional: true`), as `emailTemplate` checks them — and `compileConfig` again, for a
|
|
98
|
+
* definition that did not come through `emailTemplate`. A declared template carries `topic`
|
|
99
|
+
* (a string, or null for transactional); a definition carries one of the two options.
|
|
100
|
+
*/
|
|
101
|
+
function assertEmailMetadata(template, where, declared = false) {
|
|
68
102
|
assertEmailAddress(template.from, `${where}: from`);
|
|
69
103
|
if (template.replyTo !== void 0) assertEmailAddress(template.replyTo, `${where}: replyTo`);
|
|
70
|
-
if (template.unsubscribeUrl !== void 0)
|
|
104
|
+
if (template.unsubscribeUrl !== void 0) assertHttpUrl(template.unsubscribeUrl, `${where}: unsubscribeUrl`);
|
|
105
|
+
if (assertTemplateTopic(template, where, "email", declared) && template.unsubscribeUrl !== void 0) throw new TypeError(`${where}: a transactional email has no unsubscribe link, so it takes no 'unsubscribeUrl'`);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* What a template is — `topic: <id>` or `transactional: true`, one of the two — as `emailTemplate`
|
|
109
|
+
* and `pushTemplate` check their argument and `compileConfig` checks a declared template (where
|
|
110
|
+
* `topic: null` is transactional). Answers whether the template is transactional. Whether the
|
|
111
|
+
* topic is one the contract declares is `compileConfig`'s check, where the contract is at hand.
|
|
112
|
+
*/
|
|
113
|
+
function assertTemplateTopic(template, where, channel, declared = false) {
|
|
114
|
+
const { topic } = template;
|
|
115
|
+
const transactional = template.transactional === true || declared && topic === null;
|
|
116
|
+
if (template.transactional !== void 0 && template.transactional !== true) throw new TypeError(`${where}: 'transactional' must be the literal true (leave it out for a topic ${channel})`);
|
|
117
|
+
const hasTopic = topic !== void 0 && topic !== null;
|
|
118
|
+
if (hasTopic && (typeof topic !== "string" || topic.trim() === "")) throw new TypeError(`${where}: 'topic' must be a topic id the contract declares`);
|
|
119
|
+
if (hasTopic && transactional) throw new TypeError(`${where}: give either 'topic' or 'transactional: true', not both`);
|
|
120
|
+
if (!hasTopic && !transactional) throw new TypeError(`${where}: every ${channel} template says what it is — 'topic: <id>' (one the contract's subscriptions.topics declares) or 'transactional: true'`);
|
|
121
|
+
return transactional;
|
|
71
122
|
}
|
|
72
123
|
/** The unsubscribe link may carry its own query (a token, a list): the path names it. */
|
|
73
124
|
function isUnsubscribeLink(url, unsubscribeUrl) {
|
|
@@ -80,13 +131,17 @@ function isUnsubscribeLink(url, unsubscribeUrl) {
|
|
|
80
131
|
return false;
|
|
81
132
|
}
|
|
82
133
|
}
|
|
134
|
+
exports.PREVIEW_MESSAGE_ID = PREVIEW_MESSAGE_ID;
|
|
83
135
|
exports.assertEmailAddress = assertEmailAddress;
|
|
84
136
|
exports.assertEmailConfig = assertEmailConfig;
|
|
85
137
|
exports.assertEmailMetadata = assertEmailMetadata;
|
|
138
|
+
exports.assertHttpUrl = assertHttpUrl;
|
|
139
|
+
exports.assertTemplateTopic = assertTemplateTopic;
|
|
86
140
|
exports.assertUnsubscribeUrl = assertUnsubscribeUrl;
|
|
87
141
|
exports.formatEmailAddress = formatEmailAddress;
|
|
88
142
|
exports.isUnsubscribeLink = isUnsubscribeLink;
|
|
89
143
|
exports.mimeAddress = mimeAddress;
|
|
144
|
+
exports.preferencesLink = preferencesLink;
|
|
90
145
|
exports.resolveEmail = resolveEmail;
|
|
91
146
|
|
|
92
147
|
//# sourceMappingURL=email.cjs.map
|
package/dist/email.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"email.cjs","names":[],"sources":["../src/email.ts"],"sourcesContent":["import type { EmailTemplate } from './templates';\n\n/**\n * Email metadata: who a template's email comes from, where replies go, and the unsubscribe link\n * its layout carries. Each `emailTemplate` declares its own `from` (and `replyTo`, if replies should\n * go elsewhere); the unsubscribe link is the template's `unsubscribeUrl`, else the config's\n * `email.unsubscribeUrl`, else none. `resolveEmail` is that rule, and the sender, the renderer,\n * the Worker's preview and the studio all go through it. No React and no I/O here, so a browser\n * can import it.\n */\n\n/** An address as the transports take it: bare, or with a display name. */\nexport type EmailAddress = string | { email: string; name?: string };\n\n/** `Name <email>`, or the bare address. */\nexport function formatEmailAddress(address: EmailAddress): string {\n if (typeof address === 'string') return address;\n return address.name === undefined ? address.email : `${address.name} <${address.email}>`;\n}\n\nconst ASCII = /^[\\x20-\\x7e]*$/;\nconst SPECIALS = /[()<>[\\]:;@\\\\,.\"]/;\n\n/**\n * An address as a mail API reads it (RFC 5322): a display name with specials is quoted, one\n * outside ASCII is a MIME encoded-word — raw non-ASCII names are refused (SES), and an unquoted\n * comma splits the address in two.\n */\nexport function mimeAddress(address: EmailAddress): string {\n if (typeof address === 'string') return address;\n const { email, name } = address;\n if (name === undefined || name === '') return email;\n if (!ASCII.test(name)) {\n const bytes = new TextEncoder().encode(name);\n let binary = '';\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return `=?UTF-8?B?${btoa(binary)}?= <${email}>`;\n }\n if (SPECIALS.test(name)) return `\"${name.replace(/([\"\\\\])/g, '\\\\$1')}\" <${email}>`;\n return `${name} <${email}>`;\n}\n\n/** The config's `email`: what every email template shares. */\nexport interface EmailConfig {\n /**\n * The unsubscribe link of every email template that names none of its own. The layout reads it\n * with `useEmailContext()`; sent, it is tracked like every link, but following it shows a\n * confirmation page, and only the confirmation (a POST no link scanner sends) unsubscribes.\n */\n unsubscribeUrl?: string;\n /** How the Worker tells a person's opens and clicks from a machine's (see the reporting classifier). */\n tracking?: {\n /**\n * An open or click this many milliseconds after delivery is a weak machine signal — machine\n * only together with another (a second link within seconds); 1000 by default. A person\n * tapping a mail notification can click within a few seconds, so keep it short.\n */\n quickClickMs?: number;\n };\n}\n\n/** One template's email metadata, resolved against the config. */\nexport interface ResolvedEmail {\n from: EmailAddress;\n /** Absent when the template names none: the email goes out without a Reply-To. */\n replyTo?: EmailAddress;\n /** Absent when neither the template nor the config names one: the email has no unsubscribe link. */\n unsubscribeUrl?: string;\n}\n\n/** The template's metadata, its `unsubscribeUrl` falling back to the config's. Leaves both untouched. */\nexport function resolveEmail(\n template: Pick<EmailTemplate, 'from' | 'replyTo' | 'unsubscribeUrl'>,\n config?: EmailConfig\n): ResolvedEmail {\n const unsubscribeUrl = template.unsubscribeUrl ?? config?.unsubscribeUrl;\n return {\n from: template.from,\n ...(template.replyTo === undefined ? {} : { replyTo: template.replyTo }),\n ...(unsubscribeUrl === undefined ? {} : { unsubscribeUrl }),\n };\n}\n\nconst ADDRESS = /^[^\\s@<>]+@[^\\s@<>]+$/;\n\n/** Throws naming `where` when `address` is neither `user@host` nor `{ email: 'user@host', name? }`. */\nexport function assertEmailAddress(address: unknown, where: string): void {\n const email =\n typeof address === 'object' && address !== null\n ? (address as { email?: unknown }).email\n : address;\n const name =\n typeof address === 'object' && address !== null\n ? (address as { name?: unknown }).name\n : undefined;\n if (typeof email !== 'string' || !ADDRESS.test(email)) {\n throw new TypeError(`${where} must be an email address or { email, name? }`);\n }\n if (name !== undefined && typeof name !== 'string') {\n throw new TypeError(`${where}.name must be a string`);\n }\n}\n\n/** Throws naming `where` unless `url` is an absolute http(s) URL; '' and null are not a way to leave it out. */\nexport function assertUnsubscribeUrl(url: unknown, where: string): void {\n let parsed: URL | undefined;\n try {\n parsed = typeof url === 'string' ? new URL(url) : undefined;\n } catch {\n parsed = undefined;\n }\n if (parsed === undefined || (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')) {\n throw new TypeError(`${where} must be an http(s) URL (leave it out for none)`);\n }\n}\n\n/** The config's `email` and every registered template's metadata; throws on the first invalid value. */\nexport function assertEmailConfig(\n config: { email?: EmailConfig },\n templates: Iterable<EmailTemplate>\n): void {\n if (Object.hasOwn(config, 'emails')) {\n throw new TypeError(\n \"the config's `emails` is gone: `from` and `replyTo` are declared on each emailTemplate, \" +\n 'and the rest moved to `email: { unsubscribeUrl, tracking }`'\n );\n }\n const email = config.email;\n if (email !== undefined) {\n if (email.unsubscribeUrl !== undefined) {\n assertUnsubscribeUrl(email.unsubscribeUrl, 'email.unsubscribeUrl');\n }\n const quick = email.tracking?.quickClickMs;\n if (\n quick !== undefined &&\n !(typeof quick === 'number' && Number.isFinite(quick) && quick >= 0)\n ) {\n throw new TypeError('email.tracking.quickClickMs must be a non-negative number');\n }\n }\n for (const template of templates)\n assertEmailMetadata(template, `email template '${template.id}'`);\n}\n\n/** A template's own `from` / `replyTo` / `unsubscribeUrl`, as `emailTemplate` checks them. */\nexport function assertEmailMetadata(\n template: { from?: unknown; replyTo?: unknown; unsubscribeUrl?: unknown },\n where: string\n): void {\n assertEmailAddress(template.from, `${where}: from`);\n if (template.replyTo !== undefined) assertEmailAddress(template.replyTo, `${where}: replyTo`);\n if (template.unsubscribeUrl !== undefined) {\n assertUnsubscribeUrl(template.unsubscribeUrl, `${where}: unsubscribeUrl`);\n }\n}\n\n/** The unsubscribe link may carry its own query (a token, a list): the path names it. */\nexport function isUnsubscribeLink(url: string, unsubscribeUrl: string | undefined): boolean {\n if (unsubscribeUrl === undefined) return false;\n try {\n const a = new URL(url);\n const b = new URL(unsubscribeUrl);\n return a.origin === b.origin && a.pathname === b.pathname;\n } catch {\n return false;\n }\n}\n"],"mappings":";;;AAeA,SAAgB,mBAAmB,SAA+B;CAChE,IAAI,OAAO,YAAY,UAAU,OAAO;CACxC,OAAO,QAAQ,SAAS,KAAA,IAAY,QAAQ,QAAQ,GAAG,QAAQ,KAAK,IAAI,QAAQ,MAAM;AACxF;AAEA,MAAM,QAAQ;AACd,MAAM,WAAW;;;;;;AAOjB,SAAgB,YAAY,SAA+B;CACzD,IAAI,OAAO,YAAY,UAAU,OAAO;CACxC,MAAM,EAAE,OAAO,SAAS;CACxB,IAAI,SAAS,KAAA,KAAa,SAAS,IAAI,OAAO;CAC9C,IAAI,CAAC,MAAM,KAAK,IAAI,GAAG;EACrB,MAAM,QAAQ,IAAI,YAAY,CAAC,CAAC,OAAO,IAAI;EAC3C,IAAI,SAAS;EACb,KAAK,MAAM,QAAQ,OAAO,UAAU,OAAO,aAAa,IAAI;EAC5D,OAAO,aAAa,KAAK,MAAM,EAAE,MAAM,MAAM;CAC/C;CACA,IAAI,SAAS,KAAK,IAAI,GAAG,OAAO,IAAI,KAAK,QAAQ,YAAY,MAAM,EAAE,KAAK,MAAM;CAChF,OAAO,GAAG,KAAK,IAAI,MAAM;AAC3B;;AA+BA,SAAgB,aACd,UACA,QACe;CACf,MAAM,iBAAiB,SAAS,kBAAkB,QAAQ;CAC1D,OAAO;EACL,MAAM,SAAS;EACf,GAAI,SAAS,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,SAAS,SAAS,QAAQ;EACtE,GAAI,mBAAmB,KAAA,IAAY,CAAC,IAAI,EAAE,eAAe;CAC3D;AACF;AAEA,MAAM,UAAU;;AAGhB,SAAgB,mBAAmB,SAAkB,OAAqB;CACxE,MAAM,QACJ,OAAO,YAAY,YAAY,YAAY,OACtC,QAAgC,QACjC;CACN,MAAM,OACJ,OAAO,YAAY,YAAY,YAAY,OACtC,QAA+B,OAChC,KAAA;CACN,IAAI,OAAO,UAAU,YAAY,CAAC,QAAQ,KAAK,KAAK,GAClD,MAAM,IAAI,UAAU,GAAG,MAAM,8CAA8C;CAE7E,IAAI,SAAS,KAAA,KAAa,OAAO,SAAS,UACxC,MAAM,IAAI,UAAU,GAAG,MAAM,uBAAuB;AAExD;;AAGA,SAAgB,qBAAqB,KAAc,OAAqB;CACtE,IAAI;CACJ,IAAI;EACF,SAAS,OAAO,QAAQ,WAAW,IAAI,IAAI,GAAG,IAAI,KAAA;CACpD,QAAQ;EACN,SAAS,KAAA;CACX;CACA,IAAI,WAAW,KAAA,KAAc,OAAO,aAAa,YAAY,OAAO,aAAa,SAC/E,MAAM,IAAI,UAAU,GAAG,MAAM,gDAAgD;AAEjF;;AAGA,SAAgB,kBACd,QACA,WACM;CACN,IAAI,OAAO,OAAO,QAAQ,QAAQ,GAChC,MAAM,IAAI,UACR,qJAEF;CAEF,MAAM,QAAQ,OAAO;CACrB,IAAI,UAAU,KAAA,GAAW;EACvB,IAAI,MAAM,mBAAmB,KAAA,GAC3B,qBAAqB,MAAM,gBAAgB,sBAAsB;EAEnE,MAAM,QAAQ,MAAM,UAAU;EAC9B,IACE,UAAU,KAAA,KACV,EAAE,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,KAAK,SAAS,IAElE,MAAM,IAAI,UAAU,2DAA2D;CAEnF;CACA,KAAK,MAAM,YAAY,WACrB,oBAAoB,UAAU,mBAAmB,SAAS,GAAG,EAAE;AACnE;;AAGA,SAAgB,oBACd,UACA,OACM;CACN,mBAAmB,SAAS,MAAM,GAAG,MAAM,OAAO;CAClD,IAAI,SAAS,YAAY,KAAA,GAAW,mBAAmB,SAAS,SAAS,GAAG,MAAM,UAAU;CAC5F,IAAI,SAAS,mBAAmB,KAAA,GAC9B,qBAAqB,SAAS,gBAAgB,GAAG,MAAM,iBAAiB;AAE5E;;AAGA,SAAgB,kBAAkB,KAAa,gBAA6C;CAC1F,IAAI,mBAAmB,KAAA,GAAW,OAAO;CACzC,IAAI;EACF,MAAM,IAAI,IAAI,IAAI,GAAG;EACrB,MAAM,IAAI,IAAI,IAAI,cAAc;EAChC,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,aAAa,EAAE;CACnD,QAAQ;EACN,OAAO;CACT;AACF"}
|
|
1
|
+
{"version":3,"file":"email.cjs","names":[],"sources":["../src/email.ts"],"sourcesContent":["import type { EmailTemplate } from './templates';\n\n/**\n * Email metadata: who a template's email comes from, where replies go, and the unsubscribe and\n * preference links its layout carries. Each `emailTemplate` declares its own `from` (and\n * `replyTo`, if replies should go elsewhere) and says what it is: a `topic` the recipient may\n * unsubscribe from, or `transactional: true`. A topic email's unsubscribe link is the template's\n * `unsubscribeUrl`, else the config's `email.unsubscribeUrl`, else none; its preferences link is\n * the config's `email.preferencesUrl` with the copy's message id, when the copy has one (reporting\n * on); a transactional email never has either, whatever the config says. `resolveEmail` is that\n * rule, and the sender, the renderer, the Worker's preview and the studio all go through it. No\n * React and no I/O here, so a browser can import it.\n */\n\n/** An address as the transports take it: bare, or with a display name. */\nexport type EmailAddress = string | { email: string; name?: string };\n\n/** `Name <email>`, or the bare address. */\nexport function formatEmailAddress(address: EmailAddress): string {\n if (typeof address === 'string') return address;\n return address.name === undefined ? address.email : `${address.name} <${address.email}>`;\n}\n\nconst ASCII = /^[\\x20-\\x7e]*$/;\nconst SPECIALS = /[()<>[\\]:;@\\\\,.\"]/;\n\n/**\n * An address as a mail API reads it (RFC 5322): a display name with specials is quoted, one\n * outside ASCII is a MIME encoded-word — raw non-ASCII names are refused (SES), and an unquoted\n * comma splits the address in two.\n */\nexport function mimeAddress(address: EmailAddress): string {\n if (typeof address === 'string') return address;\n const { email, name } = address;\n if (name === undefined || name === '') return email;\n if (!ASCII.test(name)) {\n const bytes = new TextEncoder().encode(name);\n let binary = '';\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return `=?UTF-8?B?${btoa(binary)}?= <${email}>`;\n }\n if (SPECIALS.test(name)) return `\"${name.replace(/([\"\\\\])/g, '\\\\$1')}\" <${email}>`;\n return `${name} <${email}>`;\n}\n\n/** The config's `email`: what every email template shares. */\nexport interface EmailConfig {\n /**\n * The unsubscribe link of every email template that names none of its own. The layout reads it\n * with `useEmailContext()`; sent, it is tracked like every link, but following it shows a\n * confirmation page, and only the confirmation (a POST no link scanner sends) unsubscribes.\n */\n unsubscribeUrl?: string;\n /**\n * The app's email preference page. A topic email sent with reporting on links to it with the\n * copy's message id as the `m` query parameter (`useEmailContext().preferencesUrl`), and the\n * page reads and sets the recipient's subscriptions by that id — no sign-in — through\n * `GET` / `PUT /subscriptions/by-message/:messageId` (`JourneyClient.getSubscriptionsByMessage`\n * / `setSubscriptionsByMessage`). Tracked as an ordinary link; it must not share its origin and\n * path with `unsubscribeUrl`, which would make it the unsubscribe link instead.\n */\n preferencesUrl?: string;\n /** How the Worker tells a person's opens and clicks from a machine's (see the reporting classifier). */\n tracking?: {\n /**\n * An open or click this many milliseconds after delivery is a weak machine signal — machine\n * only together with another (a second link within seconds); 1000 by default. A person\n * tapping a mail notification can click within a few seconds, so keep it short.\n */\n quickClickMs?: number;\n };\n}\n\n/** One template's email metadata, resolved against the config. */\nexport interface ResolvedEmail {\n from: EmailAddress;\n /** The template's subscription topic; null for a transactional email. */\n topic: string | null;\n /** Absent when the template names none: the email goes out without a Reply-To. */\n replyTo?: EmailAddress;\n /**\n * Absent when the email is transactional, or neither the template nor the config names one:\n * the email has no unsubscribe link.\n */\n unsubscribeUrl?: string;\n /**\n * The config's `preferencesUrl` with this copy's message id as `m`. Absent when the email is\n * transactional, the config names no page, or the copy has no message id (reporting off): the\n * email has no preferences link, and a layout renders none.\n */\n preferencesUrl?: string;\n}\n\n/** What a render knows of the copy beyond the template: its message id, when reporting records one. */\nexport interface EmailRenderOptions {\n /** The copy's id in the reporting tables — what `/t/...` and `/subscriptions/by-message` key on. */\n messageId?: string | undefined;\n}\n\n/**\n * The message id a preview renders with: the preferences link shows in the studio and\n * `GET /preview`, pointing at a copy that does not exist.\n */\nexport const PREVIEW_MESSAGE_ID = '00000000-0000-0000-0000-000000000000';\n\n/**\n * The template's metadata, its `unsubscribeUrl` falling back to the config's, and the\n * preferences link built from the config's page and the copy's message id — for a topic email; a\n * transactional one resolves to neither link. Leaves the inputs untouched.\n */\nexport function resolveEmail(\n template: Pick<EmailTemplate, 'from' | 'replyTo' | 'unsubscribeUrl' | 'topic'>,\n config?: EmailConfig,\n options: EmailRenderOptions = {}\n): ResolvedEmail {\n const topical = template.topic !== null;\n const unsubscribeUrl = topical ? (template.unsubscribeUrl ?? config?.unsubscribeUrl) : undefined;\n const preferencesUrl =\n topical && config?.preferencesUrl !== undefined && options.messageId !== undefined\n ? preferencesLink(config.preferencesUrl, options.messageId)\n : undefined;\n return {\n from: template.from,\n topic: template.topic,\n ...(template.replyTo === undefined ? {} : { replyTo: template.replyTo }),\n ...(unsubscribeUrl === undefined ? {} : { unsubscribeUrl }),\n ...(preferencesUrl === undefined ? {} : { preferencesUrl }),\n };\n}\n\n/** The preference page's URL for one copy: `m=<message id>` set on its query, the rest of it kept. */\nexport function preferencesLink(preferencesUrl: string, messageId: string): string {\n const url = new URL(preferencesUrl);\n url.searchParams.set('m', messageId);\n return url.toString();\n}\n\nconst ADDRESS = /^[^\\s@<>]+@[^\\s@<>]+$/;\n\n/** Throws naming `where` when `address` is neither `user@host` nor `{ email: 'user@host', name? }`. */\nexport function assertEmailAddress(address: unknown, where: string): void {\n const email =\n typeof address === 'object' && address !== null\n ? (address as { email?: unknown }).email\n : address;\n const name =\n typeof address === 'object' && address !== null\n ? (address as { name?: unknown }).name\n : undefined;\n if (typeof email !== 'string' || !ADDRESS.test(email)) {\n throw new TypeError(`${where} must be an email address or { email, name? }`);\n }\n if (name !== undefined && typeof name !== 'string') {\n throw new TypeError(`${where}.name must be a string`);\n }\n}\n\n/** Throws naming `where` unless `url` is an absolute http(s) URL; '' and null are not a way to leave it out. */\nexport function assertHttpUrl(url: unknown, where: string): void {\n let parsed: URL | undefined;\n try {\n parsed = typeof url === 'string' ? new URL(url) : undefined;\n } catch {\n parsed = undefined;\n }\n if (parsed === undefined || (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')) {\n throw new TypeError(`${where} must be an http(s) URL (leave it out for none)`);\n }\n}\n\n/** `assertHttpUrl`, under its earlier name. */\nexport const assertUnsubscribeUrl: typeof assertHttpUrl = assertHttpUrl;\n\n/** The config's `email` and every registered template's metadata; throws on the first invalid value. */\nexport function assertEmailConfig(\n config: { email?: EmailConfig },\n templates: Iterable<EmailTemplate>\n): void {\n if (Object.hasOwn(config, 'emails')) {\n throw new TypeError(\n \"the config's `emails` is gone: `from` and `replyTo` are declared on each emailTemplate, \" +\n 'and the rest moved to `email: { unsubscribeUrl, preferencesUrl, tracking }`'\n );\n }\n const email = config.email;\n if (email !== undefined) {\n if (email.unsubscribeUrl !== undefined) {\n assertHttpUrl(email.unsubscribeUrl, 'email.unsubscribeUrl');\n }\n if (email.preferencesUrl !== undefined) {\n assertHttpUrl(email.preferencesUrl, 'email.preferencesUrl');\n // The sender tells the unsubscribe link from the others by origin and path: a preference\n // page at the same address would be tracked as the unsubscribe link and land on `/t/u`\n if (isUnsubscribeLink(email.preferencesUrl, email.unsubscribeUrl)) {\n throw new TypeError(\n 'email.preferencesUrl must not share its origin and path with email.unsubscribeUrl: ' +\n 'the sender would track the preferences link as the unsubscribe link'\n );\n }\n }\n const quick = email.tracking?.quickClickMs;\n if (\n quick !== undefined &&\n !(typeof quick === 'number' && Number.isFinite(quick) && quick >= 0)\n ) {\n throw new TypeError('email.tracking.quickClickMs must be a non-negative number');\n }\n }\n for (const template of templates) {\n const where = `email template '${template.id}'`;\n assertEmailMetadata(template, where, true);\n if (\n email?.preferencesUrl !== undefined &&\n isUnsubscribeLink(email.preferencesUrl, template.unsubscribeUrl)\n ) {\n throw new TypeError(\n `${where}: its unsubscribeUrl shares its origin and path with email.preferencesUrl, so ` +\n 'the sender would track the preferences link as the unsubscribe link'\n );\n }\n }\n}\n\n/**\n * A template's own `from` / `replyTo` / `unsubscribeUrl` and what it is (`topic` or\n * `transactional: true`), as `emailTemplate` checks them — and `compileConfig` again, for a\n * definition that did not come through `emailTemplate`. A declared template carries `topic`\n * (a string, or null for transactional); a definition carries one of the two options.\n */\nexport function assertEmailMetadata(\n template: {\n from?: unknown;\n replyTo?: unknown;\n unsubscribeUrl?: unknown;\n topic?: unknown;\n transactional?: unknown;\n },\n where: string,\n /** True for a declared template (`topic: string | null`), false for `emailTemplate`'s argument. */\n declared = false\n): void {\n assertEmailAddress(template.from, `${where}: from`);\n if (template.replyTo !== undefined) assertEmailAddress(template.replyTo, `${where}: replyTo`);\n if (template.unsubscribeUrl !== undefined) {\n assertHttpUrl(template.unsubscribeUrl, `${where}: unsubscribeUrl`);\n }\n const transactional = assertTemplateTopic(template, where, 'email', declared);\n if (transactional && template.unsubscribeUrl !== undefined) {\n throw new TypeError(\n `${where}: a transactional email has no unsubscribe link, so it takes no 'unsubscribeUrl'`\n );\n }\n}\n\n/**\n * What a template is — `topic: <id>` or `transactional: true`, one of the two — as `emailTemplate`\n * and `pushTemplate` check their argument and `compileConfig` checks a declared template (where\n * `topic: null` is transactional). Answers whether the template is transactional. Whether the\n * topic is one the contract declares is `compileConfig`'s check, where the contract is at hand.\n */\nexport function assertTemplateTopic(\n template: { topic?: unknown; transactional?: unknown },\n where: string,\n channel: 'email' | 'push',\n declared = false\n): boolean {\n const { topic } = template;\n // A declared template says `topic: null` for transactional; a definition says `transactional: true`\n const transactional = template.transactional === true || (declared && topic === null);\n if (template.transactional !== undefined && template.transactional !== true) {\n throw new TypeError(\n `${where}: 'transactional' must be the literal true (leave it out for a topic ${channel})`\n );\n }\n const hasTopic = topic !== undefined && topic !== null;\n if (hasTopic && (typeof topic !== 'string' || topic.trim() === '')) {\n throw new TypeError(`${where}: 'topic' must be a topic id the contract declares`);\n }\n if (hasTopic && transactional) {\n throw new TypeError(`${where}: give either 'topic' or 'transactional: true', not both`);\n }\n if (!hasTopic && !transactional) {\n throw new TypeError(\n `${where}: every ${channel} template says what it is — 'topic: <id>' (one the contract's subscriptions.topics declares) or 'transactional: true'`\n );\n }\n return transactional;\n}\n\n/** The unsubscribe link may carry its own query (a token, a list): the path names it. */\nexport function isUnsubscribeLink(url: string, unsubscribeUrl: string | undefined): boolean {\n if (unsubscribeUrl === undefined) return false;\n try {\n const a = new URL(url);\n const b = new URL(unsubscribeUrl);\n return a.origin === b.origin && a.pathname === b.pathname;\n } catch {\n return false;\n }\n}\n"],"mappings":"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"}
|
package/dist/email.d.cts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { EmailTemplate } from "./templates.cjs";
|
|
2
2
|
/**
|
|
3
|
-
* Email metadata: who a template's email comes from, where replies go, and the unsubscribe
|
|
4
|
-
* its layout carries. Each `emailTemplate` declares its own `from` (and
|
|
5
|
-
* go elsewhere)
|
|
6
|
-
*
|
|
7
|
-
* the
|
|
8
|
-
*
|
|
3
|
+
* Email metadata: who a template's email comes from, where replies go, and the unsubscribe and
|
|
4
|
+
* preference links its layout carries. Each `emailTemplate` declares its own `from` (and
|
|
5
|
+
* `replyTo`, if replies should go elsewhere) and says what it is: a `topic` the recipient may
|
|
6
|
+
* unsubscribe from, or `transactional: true`. A topic email's unsubscribe link is the template's
|
|
7
|
+
* `unsubscribeUrl`, else the config's `email.unsubscribeUrl`, else none; its preferences link is
|
|
8
|
+
* the config's `email.preferencesUrl` with the copy's message id, when the copy has one (reporting
|
|
9
|
+
* on); a transactional email never has either, whatever the config says. `resolveEmail` is that
|
|
10
|
+
* rule, and the sender, the renderer, the Worker's preview and the studio all go through it. No
|
|
11
|
+
* React and no I/O here, so a browser can import it.
|
|
9
12
|
*/
|
|
10
13
|
/** An address as the transports take it: bare, or with a display name. */
|
|
11
14
|
export type EmailAddress = string | {
|
|
@@ -28,6 +31,15 @@ export interface EmailConfig {
|
|
|
28
31
|
* confirmation page, and only the confirmation (a POST no link scanner sends) unsubscribes.
|
|
29
32
|
*/
|
|
30
33
|
unsubscribeUrl?: string;
|
|
34
|
+
/**
|
|
35
|
+
* The app's email preference page. A topic email sent with reporting on links to it with the
|
|
36
|
+
* copy's message id as the `m` query parameter (`useEmailContext().preferencesUrl`), and the
|
|
37
|
+
* page reads and sets the recipient's subscriptions by that id — no sign-in — through
|
|
38
|
+
* `GET` / `PUT /subscriptions/by-message/:messageId` (`JourneyClient.getSubscriptionsByMessage`
|
|
39
|
+
* / `setSubscriptionsByMessage`). Tracked as an ordinary link; it must not share its origin and
|
|
40
|
+
* path with `unsubscribeUrl`, which would make it the unsubscribe link instead.
|
|
41
|
+
*/
|
|
42
|
+
preferencesUrl?: string;
|
|
31
43
|
/** How the Worker tells a person's opens and clicks from a machine's (see the reporting classifier). */
|
|
32
44
|
tracking?: {
|
|
33
45
|
/**
|
|
@@ -41,27 +53,75 @@ export interface EmailConfig {
|
|
|
41
53
|
/** One template's email metadata, resolved against the config. */
|
|
42
54
|
export interface ResolvedEmail {
|
|
43
55
|
from: EmailAddress;
|
|
56
|
+
/** The template's subscription topic; null for a transactional email. */
|
|
57
|
+
topic: string | null;
|
|
44
58
|
/** Absent when the template names none: the email goes out without a Reply-To. */
|
|
45
59
|
replyTo?: EmailAddress;
|
|
46
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* Absent when the email is transactional, or neither the template nor the config names one:
|
|
62
|
+
* the email has no unsubscribe link.
|
|
63
|
+
*/
|
|
47
64
|
unsubscribeUrl?: string;
|
|
65
|
+
/**
|
|
66
|
+
* The config's `preferencesUrl` with this copy's message id as `m`. Absent when the email is
|
|
67
|
+
* transactional, the config names no page, or the copy has no message id (reporting off): the
|
|
68
|
+
* email has no preferences link, and a layout renders none.
|
|
69
|
+
*/
|
|
70
|
+
preferencesUrl?: string;
|
|
48
71
|
}
|
|
49
|
-
/**
|
|
50
|
-
export
|
|
72
|
+
/** What a render knows of the copy beyond the template: its message id, when reporting records one. */
|
|
73
|
+
export interface EmailRenderOptions {
|
|
74
|
+
/** The copy's id in the reporting tables — what `/t/...` and `/subscriptions/by-message` key on. */
|
|
75
|
+
messageId?: string | undefined;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The message id a preview renders with: the preferences link shows in the studio and
|
|
79
|
+
* `GET /preview`, pointing at a copy that does not exist.
|
|
80
|
+
*/
|
|
81
|
+
export declare const PREVIEW_MESSAGE_ID = "00000000-0000-0000-0000-000000000000";
|
|
82
|
+
/**
|
|
83
|
+
* The template's metadata, its `unsubscribeUrl` falling back to the config's, and the
|
|
84
|
+
* preferences link built from the config's page and the copy's message id — for a topic email; a
|
|
85
|
+
* transactional one resolves to neither link. Leaves the inputs untouched.
|
|
86
|
+
*/
|
|
87
|
+
export declare function resolveEmail(template: Pick<EmailTemplate, 'from' | 'replyTo' | 'unsubscribeUrl' | 'topic'>, config?: EmailConfig, options?: EmailRenderOptions): ResolvedEmail;
|
|
88
|
+
/** The preference page's URL for one copy: `m=<message id>` set on its query, the rest of it kept. */
|
|
89
|
+
export declare function preferencesLink(preferencesUrl: string, messageId: string): string;
|
|
51
90
|
/** Throws naming `where` when `address` is neither `user@host` nor `{ email: 'user@host', name? }`. */
|
|
52
91
|
export declare function assertEmailAddress(address: unknown, where: string): void;
|
|
53
92
|
/** Throws naming `where` unless `url` is an absolute http(s) URL; '' and null are not a way to leave it out. */
|
|
54
|
-
export declare function
|
|
93
|
+
export declare function assertHttpUrl(url: unknown, where: string): void;
|
|
94
|
+
/** `assertHttpUrl`, under its earlier name. */
|
|
95
|
+
export declare const assertUnsubscribeUrl: typeof assertHttpUrl;
|
|
55
96
|
/** The config's `email` and every registered template's metadata; throws on the first invalid value. */
|
|
56
97
|
export declare function assertEmailConfig(config: {
|
|
57
98
|
email?: EmailConfig;
|
|
58
99
|
}, templates: Iterable<EmailTemplate>): void;
|
|
59
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* A template's own `from` / `replyTo` / `unsubscribeUrl` and what it is (`topic` or
|
|
102
|
+
* `transactional: true`), as `emailTemplate` checks them — and `compileConfig` again, for a
|
|
103
|
+
* definition that did not come through `emailTemplate`. A declared template carries `topic`
|
|
104
|
+
* (a string, or null for transactional); a definition carries one of the two options.
|
|
105
|
+
*/
|
|
60
106
|
export declare function assertEmailMetadata(template: {
|
|
61
107
|
from?: unknown;
|
|
62
108
|
replyTo?: unknown;
|
|
63
109
|
unsubscribeUrl?: unknown;
|
|
64
|
-
|
|
110
|
+
topic?: unknown;
|
|
111
|
+
transactional?: unknown;
|
|
112
|
+
}, where: string,
|
|
113
|
+
/** True for a declared template (`topic: string | null`), false for `emailTemplate`'s argument. */
|
|
114
|
+
declared?: boolean): void;
|
|
115
|
+
/**
|
|
116
|
+
* What a template is — `topic: <id>` or `transactional: true`, one of the two — as `emailTemplate`
|
|
117
|
+
* and `pushTemplate` check their argument and `compileConfig` checks a declared template (where
|
|
118
|
+
* `topic: null` is transactional). Answers whether the template is transactional. Whether the
|
|
119
|
+
* topic is one the contract declares is `compileConfig`'s check, where the contract is at hand.
|
|
120
|
+
*/
|
|
121
|
+
export declare function assertTemplateTopic(template: {
|
|
122
|
+
topic?: unknown;
|
|
123
|
+
transactional?: unknown;
|
|
124
|
+
}, where: string, channel: 'email' | 'push', declared?: boolean): boolean;
|
|
65
125
|
/** The unsubscribe link may carry its own query (a token, a list): the path names it. */
|
|
66
126
|
export declare function isUnsubscribeLink(url: string, unsubscribeUrl: string | undefined): boolean;
|
|
67
127
|
//# sourceMappingURL=email.d.cts.map
|
package/dist/email.d.cts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"email.d.cts","names":[],"sources":["../src/email.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"email.d.cts","names":[],"sources":["../src/email.ts"],"mappings":";;;;;;;;;;;;;YAeY;EAA0B;EAAe;;;wBAGrC,mBAAmB,SAAS;;;;;;wBAa5B,YAAY,SAAS;;iBAepB;;;;;;EAMf;;;;;;;;;EASA;;EAEA;;;;;;IAME;;;;iBAKa;EACf,MAAM;;EAEN;;EAEA,UAAU;;;;;EAKV;;;;;;EAMA;;;iBAIe;;EAEf;;;;;;qBAOW;;;;;;wBAOG,aACd,UAAU,KAAK,iEACf,SAAS,aACT,UAAS,qBACR;;wBAiBa,gBAAgB,wBAAwB;;wBASxC,mBAAmB,kBAAkB;;wBAkBrC,cAAc,cAAc;;qBAa/B,6BAA6B;;wBAG1B,kBACd;EAAU,QAAQ;GAClB,WAAW,SAAS;;;;;;;wBAqDN,oBACd;EACE;EACA;EACA;EACA;EACA;GAEF;;AAEA;;;;;;;wBAqBc,oBACd;EAAY;EAAiB;GAC7B,eACA,2BACA;;wBA0Bc,kBAAkB,aAAa"}
|
package/dist/email.d.mts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { EmailTemplate } from "./templates.mjs";
|
|
2
2
|
/**
|
|
3
|
-
* Email metadata: who a template's email comes from, where replies go, and the unsubscribe
|
|
4
|
-
* its layout carries. Each `emailTemplate` declares its own `from` (and
|
|
5
|
-
* go elsewhere)
|
|
6
|
-
*
|
|
7
|
-
* the
|
|
8
|
-
*
|
|
3
|
+
* Email metadata: who a template's email comes from, where replies go, and the unsubscribe and
|
|
4
|
+
* preference links its layout carries. Each `emailTemplate` declares its own `from` (and
|
|
5
|
+
* `replyTo`, if replies should go elsewhere) and says what it is: a `topic` the recipient may
|
|
6
|
+
* unsubscribe from, or `transactional: true`. A topic email's unsubscribe link is the template's
|
|
7
|
+
* `unsubscribeUrl`, else the config's `email.unsubscribeUrl`, else none; its preferences link is
|
|
8
|
+
* the config's `email.preferencesUrl` with the copy's message id, when the copy has one (reporting
|
|
9
|
+
* on); a transactional email never has either, whatever the config says. `resolveEmail` is that
|
|
10
|
+
* rule, and the sender, the renderer, the Worker's preview and the studio all go through it. No
|
|
11
|
+
* React and no I/O here, so a browser can import it.
|
|
9
12
|
*/
|
|
10
13
|
/** An address as the transports take it: bare, or with a display name. */
|
|
11
14
|
export type EmailAddress = string | {
|
|
@@ -28,6 +31,15 @@ export interface EmailConfig {
|
|
|
28
31
|
* confirmation page, and only the confirmation (a POST no link scanner sends) unsubscribes.
|
|
29
32
|
*/
|
|
30
33
|
unsubscribeUrl?: string;
|
|
34
|
+
/**
|
|
35
|
+
* The app's email preference page. A topic email sent with reporting on links to it with the
|
|
36
|
+
* copy's message id as the `m` query parameter (`useEmailContext().preferencesUrl`), and the
|
|
37
|
+
* page reads and sets the recipient's subscriptions by that id — no sign-in — through
|
|
38
|
+
* `GET` / `PUT /subscriptions/by-message/:messageId` (`JourneyClient.getSubscriptionsByMessage`
|
|
39
|
+
* / `setSubscriptionsByMessage`). Tracked as an ordinary link; it must not share its origin and
|
|
40
|
+
* path with `unsubscribeUrl`, which would make it the unsubscribe link instead.
|
|
41
|
+
*/
|
|
42
|
+
preferencesUrl?: string;
|
|
31
43
|
/** How the Worker tells a person's opens and clicks from a machine's (see the reporting classifier). */
|
|
32
44
|
tracking?: {
|
|
33
45
|
/**
|
|
@@ -41,27 +53,75 @@ export interface EmailConfig {
|
|
|
41
53
|
/** One template's email metadata, resolved against the config. */
|
|
42
54
|
export interface ResolvedEmail {
|
|
43
55
|
from: EmailAddress;
|
|
56
|
+
/** The template's subscription topic; null for a transactional email. */
|
|
57
|
+
topic: string | null;
|
|
44
58
|
/** Absent when the template names none: the email goes out without a Reply-To. */
|
|
45
59
|
replyTo?: EmailAddress;
|
|
46
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* Absent when the email is transactional, or neither the template nor the config names one:
|
|
62
|
+
* the email has no unsubscribe link.
|
|
63
|
+
*/
|
|
47
64
|
unsubscribeUrl?: string;
|
|
65
|
+
/**
|
|
66
|
+
* The config's `preferencesUrl` with this copy's message id as `m`. Absent when the email is
|
|
67
|
+
* transactional, the config names no page, or the copy has no message id (reporting off): the
|
|
68
|
+
* email has no preferences link, and a layout renders none.
|
|
69
|
+
*/
|
|
70
|
+
preferencesUrl?: string;
|
|
48
71
|
}
|
|
49
|
-
/**
|
|
50
|
-
export
|
|
72
|
+
/** What a render knows of the copy beyond the template: its message id, when reporting records one. */
|
|
73
|
+
export interface EmailRenderOptions {
|
|
74
|
+
/** The copy's id in the reporting tables — what `/t/...` and `/subscriptions/by-message` key on. */
|
|
75
|
+
messageId?: string | undefined;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The message id a preview renders with: the preferences link shows in the studio and
|
|
79
|
+
* `GET /preview`, pointing at a copy that does not exist.
|
|
80
|
+
*/
|
|
81
|
+
export declare const PREVIEW_MESSAGE_ID = "00000000-0000-0000-0000-000000000000";
|
|
82
|
+
/**
|
|
83
|
+
* The template's metadata, its `unsubscribeUrl` falling back to the config's, and the
|
|
84
|
+
* preferences link built from the config's page and the copy's message id — for a topic email; a
|
|
85
|
+
* transactional one resolves to neither link. Leaves the inputs untouched.
|
|
86
|
+
*/
|
|
87
|
+
export declare function resolveEmail(template: Pick<EmailTemplate, 'from' | 'replyTo' | 'unsubscribeUrl' | 'topic'>, config?: EmailConfig, options?: EmailRenderOptions): ResolvedEmail;
|
|
88
|
+
/** The preference page's URL for one copy: `m=<message id>` set on its query, the rest of it kept. */
|
|
89
|
+
export declare function preferencesLink(preferencesUrl: string, messageId: string): string;
|
|
51
90
|
/** Throws naming `where` when `address` is neither `user@host` nor `{ email: 'user@host', name? }`. */
|
|
52
91
|
export declare function assertEmailAddress(address: unknown, where: string): void;
|
|
53
92
|
/** Throws naming `where` unless `url` is an absolute http(s) URL; '' and null are not a way to leave it out. */
|
|
54
|
-
export declare function
|
|
93
|
+
export declare function assertHttpUrl(url: unknown, where: string): void;
|
|
94
|
+
/** `assertHttpUrl`, under its earlier name. */
|
|
95
|
+
export declare const assertUnsubscribeUrl: typeof assertHttpUrl;
|
|
55
96
|
/** The config's `email` and every registered template's metadata; throws on the first invalid value. */
|
|
56
97
|
export declare function assertEmailConfig(config: {
|
|
57
98
|
email?: EmailConfig;
|
|
58
99
|
}, templates: Iterable<EmailTemplate>): void;
|
|
59
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* A template's own `from` / `replyTo` / `unsubscribeUrl` and what it is (`topic` or
|
|
102
|
+
* `transactional: true`), as `emailTemplate` checks them — and `compileConfig` again, for a
|
|
103
|
+
* definition that did not come through `emailTemplate`. A declared template carries `topic`
|
|
104
|
+
* (a string, or null for transactional); a definition carries one of the two options.
|
|
105
|
+
*/
|
|
60
106
|
export declare function assertEmailMetadata(template: {
|
|
61
107
|
from?: unknown;
|
|
62
108
|
replyTo?: unknown;
|
|
63
109
|
unsubscribeUrl?: unknown;
|
|
64
|
-
|
|
110
|
+
topic?: unknown;
|
|
111
|
+
transactional?: unknown;
|
|
112
|
+
}, where: string,
|
|
113
|
+
/** True for a declared template (`topic: string | null`), false for `emailTemplate`'s argument. */
|
|
114
|
+
declared?: boolean): void;
|
|
115
|
+
/**
|
|
116
|
+
* What a template is — `topic: <id>` or `transactional: true`, one of the two — as `emailTemplate`
|
|
117
|
+
* and `pushTemplate` check their argument and `compileConfig` checks a declared template (where
|
|
118
|
+
* `topic: null` is transactional). Answers whether the template is transactional. Whether the
|
|
119
|
+
* topic is one the contract declares is `compileConfig`'s check, where the contract is at hand.
|
|
120
|
+
*/
|
|
121
|
+
export declare function assertTemplateTopic(template: {
|
|
122
|
+
topic?: unknown;
|
|
123
|
+
transactional?: unknown;
|
|
124
|
+
}, where: string, channel: 'email' | 'push', declared?: boolean): boolean;
|
|
65
125
|
/** The unsubscribe link may carry its own query (a token, a list): the path names it. */
|
|
66
126
|
export declare function isUnsubscribeLink(url: string, unsubscribeUrl: string | undefined): boolean;
|
|
67
127
|
//# sourceMappingURL=email.d.mts.map
|
package/dist/email.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"email.d.mts","names":[],"sources":["../src/email.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"email.d.mts","names":[],"sources":["../src/email.ts"],"mappings":";;;;;;;;;;;;;YAeY;EAA0B;EAAe;;;wBAGrC,mBAAmB,SAAS;;;;;;wBAa5B,YAAY,SAAS;;iBAepB;;;;;;EAMf;;;;;;;;;EASA;;EAEA;;;;;;IAME;;;;iBAKa;EACf,MAAM;;EAEN;;EAEA,UAAU;;;;;EAKV;;;;;;EAMA;;;iBAIe;;EAEf;;;;;;qBAOW;;;;;;wBAOG,aACd,UAAU,KAAK,iEACf,SAAS,aACT,UAAS,qBACR;;wBAiBa,gBAAgB,wBAAwB;;wBASxC,mBAAmB,kBAAkB;;wBAkBrC,cAAc,cAAc;;qBAa/B,6BAA6B;;wBAG1B,kBACd;EAAU,QAAQ;GAClB,WAAW,SAAS;;;;;;;wBAqDN,oBACd;EACE;EACA;EACA;EACA;EACA;GAEF;;AAEA;;;;;;;wBAqBc,oBACd;EAAY;EAAiB;GAC7B,eACA,2BACA;;wBA0Bc,kBAAkB,aAAa"}
|
package/dist/email.mjs
CHANGED
|
@@ -24,15 +24,34 @@ function mimeAddress(address) {
|
|
|
24
24
|
if (SPECIALS.test(name)) return `"${name.replace(/(["\\])/g, "\\$1")}" <${email}>`;
|
|
25
25
|
return `${name} <${email}>`;
|
|
26
26
|
}
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
/**
|
|
28
|
+
* The message id a preview renders with: the preferences link shows in the studio and
|
|
29
|
+
* `GET /preview`, pointing at a copy that does not exist.
|
|
30
|
+
*/
|
|
31
|
+
const PREVIEW_MESSAGE_ID = "00000000-0000-0000-0000-000000000000";
|
|
32
|
+
/**
|
|
33
|
+
* The template's metadata, its `unsubscribeUrl` falling back to the config's, and the
|
|
34
|
+
* preferences link built from the config's page and the copy's message id — for a topic email; a
|
|
35
|
+
* transactional one resolves to neither link. Leaves the inputs untouched.
|
|
36
|
+
*/
|
|
37
|
+
function resolveEmail(template, config, options = {}) {
|
|
38
|
+
const topical = template.topic !== null;
|
|
39
|
+
const unsubscribeUrl = topical ? template.unsubscribeUrl ?? config?.unsubscribeUrl : void 0;
|
|
40
|
+
const preferencesUrl = topical && config?.preferencesUrl !== void 0 && options.messageId !== void 0 ? preferencesLink(config.preferencesUrl, options.messageId) : void 0;
|
|
30
41
|
return {
|
|
31
42
|
from: template.from,
|
|
43
|
+
topic: template.topic,
|
|
32
44
|
...template.replyTo === void 0 ? {} : { replyTo: template.replyTo },
|
|
33
|
-
...unsubscribeUrl === void 0 ? {} : { unsubscribeUrl }
|
|
45
|
+
...unsubscribeUrl === void 0 ? {} : { unsubscribeUrl },
|
|
46
|
+
...preferencesUrl === void 0 ? {} : { preferencesUrl }
|
|
34
47
|
};
|
|
35
48
|
}
|
|
49
|
+
/** The preference page's URL for one copy: `m=<message id>` set on its query, the rest of it kept. */
|
|
50
|
+
function preferencesLink(preferencesUrl, messageId) {
|
|
51
|
+
const url = new URL(preferencesUrl);
|
|
52
|
+
url.searchParams.set("m", messageId);
|
|
53
|
+
return url.toString();
|
|
54
|
+
}
|
|
36
55
|
const ADDRESS = /^[^\s@<>]+@[^\s@<>]+$/;
|
|
37
56
|
/** Throws naming `where` when `address` is neither `user@host` nor `{ email: 'user@host', name? }`. */
|
|
38
57
|
function assertEmailAddress(address, where) {
|
|
@@ -42,7 +61,7 @@ function assertEmailAddress(address, where) {
|
|
|
42
61
|
if (name !== void 0 && typeof name !== "string") throw new TypeError(`${where}.name must be a string`);
|
|
43
62
|
}
|
|
44
63
|
/** Throws naming `where` unless `url` is an absolute http(s) URL; '' and null are not a way to leave it out. */
|
|
45
|
-
function
|
|
64
|
+
function assertHttpUrl(url, where) {
|
|
46
65
|
let parsed;
|
|
47
66
|
try {
|
|
48
67
|
parsed = typeof url === "string" ? new URL(url) : void 0;
|
|
@@ -51,22 +70,54 @@ function assertUnsubscribeUrl(url, where) {
|
|
|
51
70
|
}
|
|
52
71
|
if (parsed === void 0 || parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new TypeError(`${where} must be an http(s) URL (leave it out for none)`);
|
|
53
72
|
}
|
|
73
|
+
/** `assertHttpUrl`, under its earlier name. */
|
|
74
|
+
const assertUnsubscribeUrl = assertHttpUrl;
|
|
54
75
|
/** The config's `email` and every registered template's metadata; throws on the first invalid value. */
|
|
55
76
|
function assertEmailConfig(config, templates) {
|
|
56
|
-
if (Object.hasOwn(config, "emails")) throw new TypeError("the config's `emails` is gone: `from` and `replyTo` are declared on each emailTemplate, and the rest moved to `email: { unsubscribeUrl, tracking }`");
|
|
77
|
+
if (Object.hasOwn(config, "emails")) throw new TypeError("the config's `emails` is gone: `from` and `replyTo` are declared on each emailTemplate, and the rest moved to `email: { unsubscribeUrl, preferencesUrl, tracking }`");
|
|
57
78
|
const email = config.email;
|
|
58
79
|
if (email !== void 0) {
|
|
59
|
-
if (email.unsubscribeUrl !== void 0)
|
|
80
|
+
if (email.unsubscribeUrl !== void 0) assertHttpUrl(email.unsubscribeUrl, "email.unsubscribeUrl");
|
|
81
|
+
if (email.preferencesUrl !== void 0) {
|
|
82
|
+
assertHttpUrl(email.preferencesUrl, "email.preferencesUrl");
|
|
83
|
+
if (isUnsubscribeLink(email.preferencesUrl, email.unsubscribeUrl)) throw new TypeError("email.preferencesUrl must not share its origin and path with email.unsubscribeUrl: the sender would track the preferences link as the unsubscribe link");
|
|
84
|
+
}
|
|
60
85
|
const quick = email.tracking?.quickClickMs;
|
|
61
86
|
if (quick !== void 0 && !(typeof quick === "number" && Number.isFinite(quick) && quick >= 0)) throw new TypeError("email.tracking.quickClickMs must be a non-negative number");
|
|
62
87
|
}
|
|
63
|
-
for (const template of templates)
|
|
88
|
+
for (const template of templates) {
|
|
89
|
+
const where = `email template '${template.id}'`;
|
|
90
|
+
assertEmailMetadata(template, where, true);
|
|
91
|
+
if (email?.preferencesUrl !== void 0 && isUnsubscribeLink(email.preferencesUrl, template.unsubscribeUrl)) throw new TypeError(`${where}: its unsubscribeUrl shares its origin and path with email.preferencesUrl, so the sender would track the preferences link as the unsubscribe link`);
|
|
92
|
+
}
|
|
64
93
|
}
|
|
65
|
-
/**
|
|
66
|
-
|
|
94
|
+
/**
|
|
95
|
+
* A template's own `from` / `replyTo` / `unsubscribeUrl` and what it is (`topic` or
|
|
96
|
+
* `transactional: true`), as `emailTemplate` checks them — and `compileConfig` again, for a
|
|
97
|
+
* definition that did not come through `emailTemplate`. A declared template carries `topic`
|
|
98
|
+
* (a string, or null for transactional); a definition carries one of the two options.
|
|
99
|
+
*/
|
|
100
|
+
function assertEmailMetadata(template, where, declared = false) {
|
|
67
101
|
assertEmailAddress(template.from, `${where}: from`);
|
|
68
102
|
if (template.replyTo !== void 0) assertEmailAddress(template.replyTo, `${where}: replyTo`);
|
|
69
|
-
if (template.unsubscribeUrl !== void 0)
|
|
103
|
+
if (template.unsubscribeUrl !== void 0) assertHttpUrl(template.unsubscribeUrl, `${where}: unsubscribeUrl`);
|
|
104
|
+
if (assertTemplateTopic(template, where, "email", declared) && template.unsubscribeUrl !== void 0) throw new TypeError(`${where}: a transactional email has no unsubscribe link, so it takes no 'unsubscribeUrl'`);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* What a template is — `topic: <id>` or `transactional: true`, one of the two — as `emailTemplate`
|
|
108
|
+
* and `pushTemplate` check their argument and `compileConfig` checks a declared template (where
|
|
109
|
+
* `topic: null` is transactional). Answers whether the template is transactional. Whether the
|
|
110
|
+
* topic is one the contract declares is `compileConfig`'s check, where the contract is at hand.
|
|
111
|
+
*/
|
|
112
|
+
function assertTemplateTopic(template, where, channel, declared = false) {
|
|
113
|
+
const { topic } = template;
|
|
114
|
+
const transactional = template.transactional === true || declared && topic === null;
|
|
115
|
+
if (template.transactional !== void 0 && template.transactional !== true) throw new TypeError(`${where}: 'transactional' must be the literal true (leave it out for a topic ${channel})`);
|
|
116
|
+
const hasTopic = topic !== void 0 && topic !== null;
|
|
117
|
+
if (hasTopic && (typeof topic !== "string" || topic.trim() === "")) throw new TypeError(`${where}: 'topic' must be a topic id the contract declares`);
|
|
118
|
+
if (hasTopic && transactional) throw new TypeError(`${where}: give either 'topic' or 'transactional: true', not both`);
|
|
119
|
+
if (!hasTopic && !transactional) throw new TypeError(`${where}: every ${channel} template says what it is — 'topic: <id>' (one the contract's subscriptions.topics declares) or 'transactional: true'`);
|
|
120
|
+
return transactional;
|
|
70
121
|
}
|
|
71
122
|
/** The unsubscribe link may carry its own query (a token, a list): the path names it. */
|
|
72
123
|
function isUnsubscribeLink(url, unsubscribeUrl) {
|
|
@@ -79,6 +130,6 @@ function isUnsubscribeLink(url, unsubscribeUrl) {
|
|
|
79
130
|
return false;
|
|
80
131
|
}
|
|
81
132
|
}
|
|
82
|
-
export { assertEmailAddress, assertEmailConfig, assertEmailMetadata, assertUnsubscribeUrl, formatEmailAddress, isUnsubscribeLink, mimeAddress, resolveEmail };
|
|
133
|
+
export { PREVIEW_MESSAGE_ID, assertEmailAddress, assertEmailConfig, assertEmailMetadata, assertHttpUrl, assertTemplateTopic, assertUnsubscribeUrl, formatEmailAddress, isUnsubscribeLink, mimeAddress, preferencesLink, resolveEmail };
|
|
83
134
|
|
|
84
135
|
//# sourceMappingURL=email.mjs.map
|