@shware/workflow 6.0.0 → 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 +78 -30
- package/dist/client.cjs +34 -11
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +16 -2
- package/dist/client.d.cts.map +1 -1
- package/dist/client.d.mts +16 -2
- package/dist/client.d.mts.map +1 -1
- package/dist/client.mjs +34 -11
- package/dist/client.mjs.map +1 -1
- package/dist/cloudflare/index.cjs +2 -2
- package/dist/cloudflare/index.mjs +2 -2
- package/dist/email-sender.cjs +2 -1
- package/dist/email-sender.cjs.map +1 -1
- package/dist/email-sender.d.cts +4 -3
- package/dist/email-sender.d.cts.map +1 -1
- package/dist/email-sender.d.mts +4 -3
- package/dist/email-sender.d.mts.map +1 -1
- package/dist/email-sender.mjs +2 -1
- package/dist/email-sender.mjs.map +1 -1
- package/dist/email.cjs +38 -10
- package/dist/email.cjs.map +1 -1
- package/dist/email.d.cts +43 -12
- package/dist/email.d.cts.map +1 -1
- package/dist/email.d.mts +43 -12
- package/dist/email.d.mts.map +1 -1
- package/dist/email.mjs +36 -11
- package/dist/email.mjs.map +1 -1
- package/dist/engine/ports.d.cts +6 -0
- package/dist/engine/ports.d.cts.map +1 -1
- package/dist/engine/ports.d.mts +6 -0
- package/dist/engine/ports.d.mts.map +1 -1
- package/dist/host/http.cjs +38 -3
- package/dist/host/http.cjs.map +1 -1
- package/dist/host/http.d.cts +1 -1
- package/dist/host/http.d.cts.map +1 -1
- package/dist/host/http.d.mts +1 -1
- package/dist/host/http.d.mts.map +1 -1
- package/dist/host/http.mjs +39 -4
- package/dist/host/http.mjs.map +1 -1
- package/dist/host/ingest.cjs +45 -0
- package/dist/host/ingest.cjs.map +1 -1
- package/dist/host/ingest.d.cts +28 -0
- package/dist/host/ingest.d.cts.map +1 -1
- package/dist/host/ingest.d.mts +28 -0
- package/dist/host/ingest.d.mts.map +1 -1
- package/dist/host/ingest.mjs +43 -1
- package/dist/host/ingest.mjs.map +1 -1
- package/dist/index.cjs +3 -0
- package/dist/index.d.cts +2 -2
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +2 -2
- 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 +3 -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 +3 -1
- package/dist/reporting/observer.mjs.map +1 -1
- package/dist/reporting/tracking.cjs +3 -0
- package/dist/reporting/tracking.cjs.map +1 -1
- package/dist/reporting/tracking.d.cts +2 -0
- package/dist/reporting/tracking.d.cts.map +1 -1
- package/dist/reporting/tracking.d.mts +2 -0
- package/dist/reporting/tracking.d.mts.map +1 -1
- package/dist/reporting/tracking.mjs +3 -1
- package/dist/reporting/tracking.mjs.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"email-sender.cjs","names":["isUnsubscribeLink"],"sources":["../src/email-sender.ts"],"sourcesContent":["import {
|
|
1
|
+
{"version":3,"file":"email-sender.cjs","names":["isUnsubscribeLink"],"sources":["../src/email-sender.ts"],"sourcesContent":["import {\n type EmailAddress,\n type EmailRenderOptions,\n type ResolvedEmail,\n isUnsubscribeLink,\n} from './email';\nimport type { MessageSender, MessageTracking, OutboundMessage } from './engine/ports';\nimport type { ScalarIR } from './ir';\n\n/**\n * The email channel, split where the runtime stops mattering: `EmailSender` renders a copy,\n * applies its tracking and commits the tracked links — the same on every host — and hands the\n * finished email to an `EmailTransport`, the only part that knows a provider (SES, Cloudflare\n * Email, ...). Nothing here imports a platform module, so a Worker and a Lambda share it.\n */\n\n/** A rendered email: the template's resolved metadata (`resolveEmail`), its subject and its HTML. */\nexport interface RenderedEmail extends ResolvedEmail {\n subject: string;\n html: string;\n}\n\n/**\n * Template renderer, injected by the app — this package's core stays free of\n * any react-email dependency (`@shware/workflow/react-email` ships one over a\n * registry). The subject comes back final: it is sent exactly as written, from\n * the template's own sender, and the unsubscribe link is the one the HTML was\n * rendered with. `options` carries the copy's message id when reporting records one, for the\n * preferences link (`resolveEmail`).\n */\nexport type EmailRenderer = (\n template: string,\n props: Record<string, ScalarIR | undefined>,\n options?: EmailRenderOptions\n) => Promise<RenderedEmail>;\n\n/** A finished email, as a transport takes it: rendered, tracked, addressed. */\nexport interface EmailMessage {\n from: EmailAddress;\n to: string;\n replyTo?: EmailAddress;\n subject: string;\n html: string;\n /**\n * Extra headers, already formatted: `List-Unsubscribe` and `List-Unsubscribe-Post` for an\n * email that has an unsubscribe link (see `unsubscribeHeaders`).\n */\n headers?: Readonly<Record<string, string>>;\n /**\n * `${instanceId}:${nodeId}`: the copy's identity, stable across retries and replays. A provider\n * that de-duplicates on a key receives it; one that cannot (SES) still sends at least once —\n * the window between the provider accepting the email and the step's checkpoint stays open.\n */\n idempotencyKey: string;\n}\n\n/**\n * One email provider. Throwing hands the retry to the durable step; resolving means the provider\n * accepted the email (not that it was delivered).\n */\nexport interface EmailTransport {\n send(message: EmailMessage): Promise<{ providerMessageId?: string }>;\n}\n\nexport interface EmailSenderOptions {\n transport: EmailTransport;\n render: EmailRenderer;\n}\n\n/**\n * The email channel over any transport: render → tracking applied to the HTML → the tracked\n * links committed → the transport. The links resolve before the email exists anywhere a\n * scanner could follow them.\n */\nexport class EmailSender implements MessageSender {\n constructor(private readonly options: EmailSenderOptions) {}\n\n async send(message: OutboundMessage): Promise<void> {\n if (message.channel !== 'email') {\n throw new Error(`EmailSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`EmailSender: no recipient for user '${message.userId}'`);\n }\n const messageId = message.tracking?.messageId;\n const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(\n message.template,\n message.props,\n messageId === undefined ? {} : { messageId }\n );\n const tracked =\n message.tracking === undefined ? html : trackEmail(html, message.tracking, unsubscribeUrl);\n await message.tracking?.commit?.();\n const headers = unsubscribeHeaders(unsubscribeUrl, message.tracking?.unsubscribe);\n await this.options.transport.send({\n from,\n to: message.recipient,\n ...(replyTo === undefined ? {} : { replyTo }),\n subject,\n html: tracked,\n ...(headers === undefined ? {} : { headers }),\n idempotencyKey: message.idempotencyKey,\n });\n }\n}\n\n/**\n * The list-unsubscribe headers of an email with an unsubscribe link (RFC 2369, RFC 8058), so a\n * mail client's own unsubscribe button works — Gmail and Yahoo require one-click for bulk mail:\n *\n * - With a one-click endpoint (`MessageTracking.unsubscribe`, which reporting provides): the\n * header names it, and `List-Unsubscribe-Post` says a POST there unsubscribes at once.\n * - Without one: the header names the email's own unsubscribe link, with no one-click claim —\n * whether that page takes a POST is the app's.\n * - An email without an unsubscribe link (a transactional one) gets neither.\n *\n * Only an https URL is named: mail clients and Cloudflare Email refuse any other.\n */\nexport function unsubscribeHeaders(\n unsubscribeUrl: string | undefined,\n oneClick: string | undefined\n): Record<string, string> | undefined {\n if (unsubscribeUrl === undefined) return undefined;\n const https = (url: string | undefined) => url?.startsWith('https://') === true;\n if (https(oneClick)) {\n return {\n 'List-Unsubscribe': `<${oneClick}>`,\n 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',\n };\n }\n if (https(unsubscribeUrl)) return { 'List-Unsubscribe': `<${unsubscribeUrl}>` };\n return undefined;\n}\n\nconst LINK = /(<a\\b[^>]*?\\shref\\s*=\\s*)([\"'])(https?:\\/\\/[^\"']+)\\2/gi;\n\n/**\n * An email's tracking applied to its rendered HTML: every `http(s)` anchor routed through\n * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back) —\n * as the unsubscribe link when it is the one the email was rendered with, so what a click on it\n * means is fixed when the email is sent — and the open pixel appended before `</body>`, or at the\n * end when there is none.\n */\nexport function trackEmail(\n html: string,\n tracking: MessageTracking,\n unsubscribeUrl?: string\n): string {\n const { link, pixel } = tracking;\n let out = html;\n if (link !== undefined) {\n out = out.replace(LINK, (_, start: string, quote: string, attribute: string) => {\n const url = unescapeAttribute(attribute);\n const purpose = isUnsubscribeLink(url, unsubscribeUrl) ? 'unsubscribe' : 'link';\n return `${start}${quote}${escapeAttribute(link(url, purpose))}${quote}`;\n });\n }\n if (pixel !== undefined) {\n const image = `<img src=\"${escapeAttribute(pixel)}\" width=\"1\" height=\"1\" alt=\"\" style=\"display:block;border:0;width:1px;height:1px\" />`;\n out = /<\\/body>/i.test(out) ? out.replace(/<\\/body>/i, `${image}</body>`) : `${out}${image}`;\n }\n return out;\n}\n\nfunction unescapeAttribute(value: string): string {\n return value.replaceAll('&', '&').replaceAll('"', '\"').replaceAll(''', \"'\");\n}\n\nfunction escapeAttribute(value: string): string {\n return value.replaceAll('&', '&').replaceAll('\"', '"');\n}\n"],"mappings":";;;;;;;;AA0EA,IAAa,cAAb,MAAkD;CACnB;CAA7B,YAAY,SAA8C;EAA7B,KAAA,UAAA;CAA8B;CAE3D,MAAM,KAAK,SAAyC;EAClD,IAAI,QAAQ,YAAY,SACtB,MAAM,IAAI,MAAM,qCAAqC,QAAQ,QAAQ,EAAE;EAEzE,IAAI,QAAQ,cAAc,KAAA,GACxB,MAAM,IAAI,MAAM,uCAAuC,QAAQ,OAAO,EAAE;EAE1E,MAAM,YAAY,QAAQ,UAAU;EACpC,MAAM,EAAE,SAAS,MAAM,MAAM,SAAS,mBAAmB,MAAM,KAAK,QAAQ,OAC1E,QAAQ,UACR,QAAQ,OACR,cAAc,KAAA,IAAY,CAAC,IAAI,EAAE,UAAU,CAC7C;EACA,MAAM,UACJ,QAAQ,aAAa,KAAA,IAAY,OAAO,WAAW,MAAM,QAAQ,UAAU,cAAc;EAC3F,MAAM,QAAQ,UAAU,SAAS;EACjC,MAAM,UAAU,mBAAmB,gBAAgB,QAAQ,UAAU,WAAW;EAChF,MAAM,KAAK,QAAQ,UAAU,KAAK;GAChC;GACA,IAAI,QAAQ;GACZ,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ;GAC3C;GACA,MAAM;GACN,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ;GAC3C,gBAAgB,QAAQ;EAC1B,CAAC;CACH;AACF;;;;;;;;;;;;;AAcA,SAAgB,mBACd,gBACA,UACoC;CACpC,IAAI,mBAAmB,KAAA,GAAW,OAAO,KAAA;CACzC,MAAM,SAAS,QAA4B,KAAK,WAAW,UAAU,MAAM;CAC3E,IAAI,MAAM,QAAQ,GAChB,OAAO;EACL,oBAAoB,IAAI,SAAS;EACjC,yBAAyB;CAC3B;CAEF,IAAI,MAAM,cAAc,GAAG,OAAO,EAAE,oBAAoB,IAAI,eAAe,GAAG;AAEhF;AAEA,MAAM,OAAO;;;;;;;;AASb,SAAgB,WACd,MACA,UACA,gBACQ;CACR,MAAM,EAAE,MAAM,UAAU;CACxB,IAAI,MAAM;CACV,IAAI,SAAS,KAAA,GACX,MAAM,IAAI,QAAQ,OAAO,GAAG,OAAe,OAAe,cAAsB;EAC9E,MAAM,MAAM,kBAAkB,SAAS;EACvC,MAAM,UAAUA,cAAAA,kBAAkB,KAAK,cAAc,IAAI,gBAAgB;EACzE,OAAO,GAAG,QAAQ,QAAQ,gBAAgB,KAAK,KAAK,OAAO,CAAC,IAAI;CAClE,CAAC;CAEH,IAAI,UAAU,KAAA,GAAW;EACvB,MAAM,QAAQ,aAAa,gBAAgB,KAAK,EAAE;EAClD,MAAM,YAAY,KAAK,GAAG,IAAI,IAAI,QAAQ,aAAa,GAAG,MAAM,QAAQ,IAAI,GAAG,MAAM;CACvF;CACA,OAAO;AACT;AAEA,SAAS,kBAAkB,OAAuB;CAChD,OAAO,MAAM,WAAW,SAAS,GAAG,CAAC,CAAC,WAAW,UAAU,IAAG,CAAC,CAAC,WAAW,UAAU,GAAG;AAC1F;AAEA,SAAS,gBAAgB,OAAuB;CAC9C,OAAO,MAAM,WAAW,KAAK,OAAO,CAAC,CAAC,WAAW,MAAK,QAAQ;AAChE"}
|
package/dist/email-sender.d.cts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ScalarIR } from "./ir.cjs";
|
|
2
2
|
import { MessageSender, MessageTracking, OutboundMessage } from "./engine/ports.cjs";
|
|
3
|
-
import { EmailAddress, ResolvedEmail } from "./email.cjs";
|
|
3
|
+
import { EmailAddress, EmailRenderOptions, ResolvedEmail } from "./email.cjs";
|
|
4
4
|
/**
|
|
5
5
|
* The email channel, split where the runtime stops mattering: `EmailSender` renders a copy,
|
|
6
6
|
* applies its tracking and commits the tracked links — the same on every host — and hands the
|
|
@@ -17,9 +17,10 @@ export interface RenderedEmail extends ResolvedEmail {
|
|
|
17
17
|
* any react-email dependency (`@shware/workflow/react-email` ships one over a
|
|
18
18
|
* registry). The subject comes back final: it is sent exactly as written, from
|
|
19
19
|
* the template's own sender, and the unsubscribe link is the one the HTML was
|
|
20
|
-
* rendered with.
|
|
20
|
+
* rendered with. `options` carries the copy's message id when reporting records one, for the
|
|
21
|
+
* preferences link (`resolveEmail`).
|
|
21
22
|
*/
|
|
22
|
-
export type EmailRenderer = (template: string, props: Record<string, ScalarIR | undefined
|
|
23
|
+
export type EmailRenderer = (template: string, props: Record<string, ScalarIR | undefined>, options?: EmailRenderOptions) => Promise<RenderedEmail>;
|
|
23
24
|
/** A finished email, as a transport takes it: rendered, tracked, addressed. */
|
|
24
25
|
export interface EmailMessage {
|
|
25
26
|
from: EmailAddress;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"email-sender.d.cts","names":[],"sources":["../src/email-sender.ts"],"mappings":";;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"email-sender.d.cts","names":[],"sources":["../src/email-sender.ts"],"mappings":";;;;;;;;;;iBAiBiB,sBAAsB;EACrC;EACA;;;;;;;;;;YAWU,iBACV,kBACA,OAAO,eAAe,uBACtB,UAAU,uBACP,QAAQ;;iBAGI;EACf,MAAM;EACN;EACA,UAAU;EACV;EACA;;;;;EAKA,UAAU,SAAS;;;;;;EAMnB;;;;;;iBAOe;EACf,KAAK,SAAS,eAAe;IAAU;;;iBAGxB;EACf,WAAW;EACX,QAAQ;;;;;;;qBAQG,uBAAuB;mBACL;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB;;;;;;;;;;;;;;wBAyCxB,mBACd,oCACA,+BACC;;;;;;;;wBAsBa,WACd,cACA,UAAU,iBACV"}
|
package/dist/email-sender.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ScalarIR } from "./ir.mjs";
|
|
2
2
|
import { MessageSender, MessageTracking, OutboundMessage } from "./engine/ports.mjs";
|
|
3
|
-
import { EmailAddress, ResolvedEmail } from "./email.mjs";
|
|
3
|
+
import { EmailAddress, EmailRenderOptions, ResolvedEmail } from "./email.mjs";
|
|
4
4
|
/**
|
|
5
5
|
* The email channel, split where the runtime stops mattering: `EmailSender` renders a copy,
|
|
6
6
|
* applies its tracking and commits the tracked links — the same on every host — and hands the
|
|
@@ -17,9 +17,10 @@ export interface RenderedEmail extends ResolvedEmail {
|
|
|
17
17
|
* any react-email dependency (`@shware/workflow/react-email` ships one over a
|
|
18
18
|
* registry). The subject comes back final: it is sent exactly as written, from
|
|
19
19
|
* the template's own sender, and the unsubscribe link is the one the HTML was
|
|
20
|
-
* rendered with.
|
|
20
|
+
* rendered with. `options` carries the copy's message id when reporting records one, for the
|
|
21
|
+
* preferences link (`resolveEmail`).
|
|
21
22
|
*/
|
|
22
|
-
export type EmailRenderer = (template: string, props: Record<string, ScalarIR | undefined
|
|
23
|
+
export type EmailRenderer = (template: string, props: Record<string, ScalarIR | undefined>, options?: EmailRenderOptions) => Promise<RenderedEmail>;
|
|
23
24
|
/** A finished email, as a transport takes it: rendered, tracked, addressed. */
|
|
24
25
|
export interface EmailMessage {
|
|
25
26
|
from: EmailAddress;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"email-sender.d.mts","names":[],"sources":["../src/email-sender.ts"],"mappings":";;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"email-sender.d.mts","names":[],"sources":["../src/email-sender.ts"],"mappings":";;;;;;;;;;iBAiBiB,sBAAsB;EACrC;EACA;;;;;;;;;;YAWU,iBACV,kBACA,OAAO,eAAe,uBACtB,UAAU,uBACP,QAAQ;;iBAGI;EACf,MAAM;EACN;EACA,UAAU;EACV;EACA;;;;;EAKA,UAAU,SAAS;;;;;;EAMnB;;;;;;iBAOe;EACf,KAAK,SAAS,eAAe;IAAU;;;iBAGxB;EACf,WAAW;EACX,QAAQ;;;;;;;qBAQG,uBAAuB;mBACL;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB;;;;;;;;;;;;;;wBAyCxB,mBACd,oCACA,+BACC;;;;;;;;wBAsBa,WACd,cACA,UAAU,iBACV"}
|
package/dist/email-sender.mjs
CHANGED
|
@@ -13,7 +13,8 @@ var EmailSender = class {
|
|
|
13
13
|
async send(message) {
|
|
14
14
|
if (message.channel !== "email") throw new Error(`EmailSender: unsupported channel '${message.channel}'`);
|
|
15
15
|
if (message.recipient === void 0) throw new Error(`EmailSender: no recipient for user '${message.userId}'`);
|
|
16
|
-
const
|
|
16
|
+
const messageId = message.tracking?.messageId;
|
|
17
|
+
const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(message.template, message.props, messageId === void 0 ? {} : { messageId });
|
|
17
18
|
const tracked = message.tracking === void 0 ? html : trackEmail(html, message.tracking, unsubscribeUrl);
|
|
18
19
|
await message.tracking?.commit?.();
|
|
19
20
|
const headers = unsubscribeHeaders(unsubscribeUrl, message.tracking?.unsubscribe);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"email-sender.mjs","names":[],"sources":["../src/email-sender.ts"],"sourcesContent":["import {
|
|
1
|
+
{"version":3,"file":"email-sender.mjs","names":[],"sources":["../src/email-sender.ts"],"sourcesContent":["import {\n type EmailAddress,\n type EmailRenderOptions,\n type ResolvedEmail,\n isUnsubscribeLink,\n} from './email';\nimport type { MessageSender, MessageTracking, OutboundMessage } from './engine/ports';\nimport type { ScalarIR } from './ir';\n\n/**\n * The email channel, split where the runtime stops mattering: `EmailSender` renders a copy,\n * applies its tracking and commits the tracked links — the same on every host — and hands the\n * finished email to an `EmailTransport`, the only part that knows a provider (SES, Cloudflare\n * Email, ...). Nothing here imports a platform module, so a Worker and a Lambda share it.\n */\n\n/** A rendered email: the template's resolved metadata (`resolveEmail`), its subject and its HTML. */\nexport interface RenderedEmail extends ResolvedEmail {\n subject: string;\n html: string;\n}\n\n/**\n * Template renderer, injected by the app — this package's core stays free of\n * any react-email dependency (`@shware/workflow/react-email` ships one over a\n * registry). The subject comes back final: it is sent exactly as written, from\n * the template's own sender, and the unsubscribe link is the one the HTML was\n * rendered with. `options` carries the copy's message id when reporting records one, for the\n * preferences link (`resolveEmail`).\n */\nexport type EmailRenderer = (\n template: string,\n props: Record<string, ScalarIR | undefined>,\n options?: EmailRenderOptions\n) => Promise<RenderedEmail>;\n\n/** A finished email, as a transport takes it: rendered, tracked, addressed. */\nexport interface EmailMessage {\n from: EmailAddress;\n to: string;\n replyTo?: EmailAddress;\n subject: string;\n html: string;\n /**\n * Extra headers, already formatted: `List-Unsubscribe` and `List-Unsubscribe-Post` for an\n * email that has an unsubscribe link (see `unsubscribeHeaders`).\n */\n headers?: Readonly<Record<string, string>>;\n /**\n * `${instanceId}:${nodeId}`: the copy's identity, stable across retries and replays. A provider\n * that de-duplicates on a key receives it; one that cannot (SES) still sends at least once —\n * the window between the provider accepting the email and the step's checkpoint stays open.\n */\n idempotencyKey: string;\n}\n\n/**\n * One email provider. Throwing hands the retry to the durable step; resolving means the provider\n * accepted the email (not that it was delivered).\n */\nexport interface EmailTransport {\n send(message: EmailMessage): Promise<{ providerMessageId?: string }>;\n}\n\nexport interface EmailSenderOptions {\n transport: EmailTransport;\n render: EmailRenderer;\n}\n\n/**\n * The email channel over any transport: render → tracking applied to the HTML → the tracked\n * links committed → the transport. The links resolve before the email exists anywhere a\n * scanner could follow them.\n */\nexport class EmailSender implements MessageSender {\n constructor(private readonly options: EmailSenderOptions) {}\n\n async send(message: OutboundMessage): Promise<void> {\n if (message.channel !== 'email') {\n throw new Error(`EmailSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`EmailSender: no recipient for user '${message.userId}'`);\n }\n const messageId = message.tracking?.messageId;\n const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(\n message.template,\n message.props,\n messageId === undefined ? {} : { messageId }\n );\n const tracked =\n message.tracking === undefined ? html : trackEmail(html, message.tracking, unsubscribeUrl);\n await message.tracking?.commit?.();\n const headers = unsubscribeHeaders(unsubscribeUrl, message.tracking?.unsubscribe);\n await this.options.transport.send({\n from,\n to: message.recipient,\n ...(replyTo === undefined ? {} : { replyTo }),\n subject,\n html: tracked,\n ...(headers === undefined ? {} : { headers }),\n idempotencyKey: message.idempotencyKey,\n });\n }\n}\n\n/**\n * The list-unsubscribe headers of an email with an unsubscribe link (RFC 2369, RFC 8058), so a\n * mail client's own unsubscribe button works — Gmail and Yahoo require one-click for bulk mail:\n *\n * - With a one-click endpoint (`MessageTracking.unsubscribe`, which reporting provides): the\n * header names it, and `List-Unsubscribe-Post` says a POST there unsubscribes at once.\n * - Without one: the header names the email's own unsubscribe link, with no one-click claim —\n * whether that page takes a POST is the app's.\n * - An email without an unsubscribe link (a transactional one) gets neither.\n *\n * Only an https URL is named: mail clients and Cloudflare Email refuse any other.\n */\nexport function unsubscribeHeaders(\n unsubscribeUrl: string | undefined,\n oneClick: string | undefined\n): Record<string, string> | undefined {\n if (unsubscribeUrl === undefined) return undefined;\n const https = (url: string | undefined) => url?.startsWith('https://') === true;\n if (https(oneClick)) {\n return {\n 'List-Unsubscribe': `<${oneClick}>`,\n 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',\n };\n }\n if (https(unsubscribeUrl)) return { 'List-Unsubscribe': `<${unsubscribeUrl}>` };\n return undefined;\n}\n\nconst LINK = /(<a\\b[^>]*?\\shref\\s*=\\s*)([\"'])(https?:\\/\\/[^\"']+)\\2/gi;\n\n/**\n * An email's tracking applied to its rendered HTML: every `http(s)` anchor routed through\n * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back) —\n * as the unsubscribe link when it is the one the email was rendered with, so what a click on it\n * means is fixed when the email is sent — and the open pixel appended before `</body>`, or at the\n * end when there is none.\n */\nexport function trackEmail(\n html: string,\n tracking: MessageTracking,\n unsubscribeUrl?: string\n): string {\n const { link, pixel } = tracking;\n let out = html;\n if (link !== undefined) {\n out = out.replace(LINK, (_, start: string, quote: string, attribute: string) => {\n const url = unescapeAttribute(attribute);\n const purpose = isUnsubscribeLink(url, unsubscribeUrl) ? 'unsubscribe' : 'link';\n return `${start}${quote}${escapeAttribute(link(url, purpose))}${quote}`;\n });\n }\n if (pixel !== undefined) {\n const image = `<img src=\"${escapeAttribute(pixel)}\" width=\"1\" height=\"1\" alt=\"\" style=\"display:block;border:0;width:1px;height:1px\" />`;\n out = /<\\/body>/i.test(out) ? out.replace(/<\\/body>/i, `${image}</body>`) : `${out}${image}`;\n }\n return out;\n}\n\nfunction unescapeAttribute(value: string): string {\n return value.replaceAll('&', '&').replaceAll('"', '\"').replaceAll(''', \"'\");\n}\n\nfunction escapeAttribute(value: string): string {\n return value.replaceAll('&', '&').replaceAll('\"', '"');\n}\n"],"mappings":";;;;;;;AA0EA,IAAa,cAAb,MAAkD;CACnB;CAA7B,YAAY,SAA8C;EAA7B,KAAA,UAAA;CAA8B;CAE3D,MAAM,KAAK,SAAyC;EAClD,IAAI,QAAQ,YAAY,SACtB,MAAM,IAAI,MAAM,qCAAqC,QAAQ,QAAQ,EAAE;EAEzE,IAAI,QAAQ,cAAc,KAAA,GACxB,MAAM,IAAI,MAAM,uCAAuC,QAAQ,OAAO,EAAE;EAE1E,MAAM,YAAY,QAAQ,UAAU;EACpC,MAAM,EAAE,SAAS,MAAM,MAAM,SAAS,mBAAmB,MAAM,KAAK,QAAQ,OAC1E,QAAQ,UACR,QAAQ,OACR,cAAc,KAAA,IAAY,CAAC,IAAI,EAAE,UAAU,CAC7C;EACA,MAAM,UACJ,QAAQ,aAAa,KAAA,IAAY,OAAO,WAAW,MAAM,QAAQ,UAAU,cAAc;EAC3F,MAAM,QAAQ,UAAU,SAAS;EACjC,MAAM,UAAU,mBAAmB,gBAAgB,QAAQ,UAAU,WAAW;EAChF,MAAM,KAAK,QAAQ,UAAU,KAAK;GAChC;GACA,IAAI,QAAQ;GACZ,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ;GAC3C;GACA,MAAM;GACN,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ;GAC3C,gBAAgB,QAAQ;EAC1B,CAAC;CACH;AACF;;;;;;;;;;;;;AAcA,SAAgB,mBACd,gBACA,UACoC;CACpC,IAAI,mBAAmB,KAAA,GAAW,OAAO,KAAA;CACzC,MAAM,SAAS,QAA4B,KAAK,WAAW,UAAU,MAAM;CAC3E,IAAI,MAAM,QAAQ,GAChB,OAAO;EACL,oBAAoB,IAAI,SAAS;EACjC,yBAAyB;CAC3B;CAEF,IAAI,MAAM,cAAc,GAAG,OAAO,EAAE,oBAAoB,IAAI,eAAe,GAAG;AAEhF;AAEA,MAAM,OAAO;;;;;;;;AASb,SAAgB,WACd,MACA,UACA,gBACQ;CACR,MAAM,EAAE,MAAM,UAAU;CACxB,IAAI,MAAM;CACV,IAAI,SAAS,KAAA,GACX,MAAM,IAAI,QAAQ,OAAO,GAAG,OAAe,OAAe,cAAsB;EAC9E,MAAM,MAAM,kBAAkB,SAAS;EACvC,MAAM,UAAU,kBAAkB,KAAK,cAAc,IAAI,gBAAgB;EACzE,OAAO,GAAG,QAAQ,QAAQ,gBAAgB,KAAK,KAAK,OAAO,CAAC,IAAI;CAClE,CAAC;CAEH,IAAI,UAAU,KAAA,GAAW;EACvB,MAAM,QAAQ,aAAa,gBAAgB,KAAK,EAAE;EAClD,MAAM,YAAY,KAAK,GAAG,IAAI,IAAI,QAAQ,aAAa,GAAG,MAAM,QAAQ,IAAI,GAAG,MAAM;CACvF;CACA,OAAO;AACT;AAEA,SAAS,kBAAkB,OAAuB;CAChD,OAAO,MAAM,WAAW,SAAS,GAAG,CAAC,CAAC,WAAW,UAAU,IAAG,CAAC,CAAC,WAAW,UAAU,GAAG;AAC1F;AAEA,SAAS,gBAAgB,OAAuB;CAC9C,OAAO,MAAM,WAAW,KAAK,OAAO,CAAC,CAAC,WAAW,MAAK,QAAQ;AAChE"}
|
package/dist/email.cjs
CHANGED
|
@@ -26,18 +26,33 @@ function mimeAddress(address) {
|
|
|
26
26
|
return `${name} <${email}>`;
|
|
27
27
|
}
|
|
28
28
|
/**
|
|
29
|
-
* The
|
|
30
|
-
*
|
|
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
31
|
*/
|
|
32
|
-
|
|
33
|
-
|
|
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;
|
|
34
42
|
return {
|
|
35
43
|
from: template.from,
|
|
36
44
|
topic: template.topic,
|
|
37
45
|
...template.replyTo === void 0 ? {} : { replyTo: template.replyTo },
|
|
38
|
-
...unsubscribeUrl === void 0 ? {} : { unsubscribeUrl }
|
|
46
|
+
...unsubscribeUrl === void 0 ? {} : { unsubscribeUrl },
|
|
47
|
+
...preferencesUrl === void 0 ? {} : { preferencesUrl }
|
|
39
48
|
};
|
|
40
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
|
+
}
|
|
41
56
|
const ADDRESS = /^[^\s@<>]+@[^\s@<>]+$/;
|
|
42
57
|
/** Throws naming `where` when `address` is neither `user@host` nor `{ email: 'user@host', name? }`. */
|
|
43
58
|
function assertEmailAddress(address, where) {
|
|
@@ -47,7 +62,7 @@ function assertEmailAddress(address, where) {
|
|
|
47
62
|
if (name !== void 0 && typeof name !== "string") throw new TypeError(`${where}.name must be a string`);
|
|
48
63
|
}
|
|
49
64
|
/** Throws naming `where` unless `url` is an absolute http(s) URL; '' and null are not a way to leave it out. */
|
|
50
|
-
function
|
|
65
|
+
function assertHttpUrl(url, where) {
|
|
51
66
|
let parsed;
|
|
52
67
|
try {
|
|
53
68
|
parsed = typeof url === "string" ? new URL(url) : void 0;
|
|
@@ -56,16 +71,26 @@ function assertUnsubscribeUrl(url, where) {
|
|
|
56
71
|
}
|
|
57
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)`);
|
|
58
73
|
}
|
|
74
|
+
/** `assertHttpUrl`, under its earlier name. */
|
|
75
|
+
const assertUnsubscribeUrl = assertHttpUrl;
|
|
59
76
|
/** The config's `email` and every registered template's metadata; throws on the first invalid value. */
|
|
60
77
|
function assertEmailConfig(config, templates) {
|
|
61
|
-
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 }`");
|
|
62
79
|
const email = config.email;
|
|
63
80
|
if (email !== void 0) {
|
|
64
|
-
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
|
+
}
|
|
65
86
|
const quick = email.tracking?.quickClickMs;
|
|
66
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");
|
|
67
88
|
}
|
|
68
|
-
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
|
+
}
|
|
69
94
|
}
|
|
70
95
|
/**
|
|
71
96
|
* A template's own `from` / `replyTo` / `unsubscribeUrl` and what it is (`topic` or
|
|
@@ -76,7 +101,7 @@ function assertEmailConfig(config, templates) {
|
|
|
76
101
|
function assertEmailMetadata(template, where, declared = false) {
|
|
77
102
|
assertEmailAddress(template.from, `${where}: from`);
|
|
78
103
|
if (template.replyTo !== void 0) assertEmailAddress(template.replyTo, `${where}: replyTo`);
|
|
79
|
-
if (template.unsubscribeUrl !== void 0)
|
|
104
|
+
if (template.unsubscribeUrl !== void 0) assertHttpUrl(template.unsubscribeUrl, `${where}: unsubscribeUrl`);
|
|
80
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'`);
|
|
81
106
|
}
|
|
82
107
|
/**
|
|
@@ -106,14 +131,17 @@ function isUnsubscribeLink(url, unsubscribeUrl) {
|
|
|
106
131
|
return false;
|
|
107
132
|
}
|
|
108
133
|
}
|
|
134
|
+
exports.PREVIEW_MESSAGE_ID = PREVIEW_MESSAGE_ID;
|
|
109
135
|
exports.assertEmailAddress = assertEmailAddress;
|
|
110
136
|
exports.assertEmailConfig = assertEmailConfig;
|
|
111
137
|
exports.assertEmailMetadata = assertEmailMetadata;
|
|
138
|
+
exports.assertHttpUrl = assertHttpUrl;
|
|
112
139
|
exports.assertTemplateTopic = assertTemplateTopic;
|
|
113
140
|
exports.assertUnsubscribeUrl = assertUnsubscribeUrl;
|
|
114
141
|
exports.formatEmailAddress = formatEmailAddress;
|
|
115
142
|
exports.isUnsubscribeLink = isUnsubscribeLink;
|
|
116
143
|
exports.mimeAddress = mimeAddress;
|
|
144
|
+
exports.preferencesLink = preferencesLink;
|
|
117
145
|
exports.resolveEmail = resolveEmail;
|
|
118
146
|
|
|
119
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) and says what it is: a `topic` the recipient may unsubscribe from, or\n * `transactional: true`. A topic email's unsubscribe link is the template's `unsubscribeUrl`,\n * else the config's `email.unsubscribeUrl`, else none; a transactional email never has one,\n * whatever the config says. `resolveEmail` is that rule, and the sender, the renderer, the\n * Worker's preview and the studio all go through it. No React and no I/O here, so a browser can\n * 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 /** 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\n/**\n * The template's metadata, its `unsubscribeUrl` falling back to the config's — for a topic\n * email; a transactional one resolves to no unsubscribe link at all. Leaves both untouched.\n */\nexport function resolveEmail(\n template: Pick<EmailTemplate, 'from' | 'replyTo' | 'unsubscribeUrl' | 'topic'>,\n config?: EmailConfig\n): ResolvedEmail {\n const unsubscribeUrl =\n template.topic === null ? undefined : (template.unsubscribeUrl ?? config?.unsubscribeUrl);\n return {\n from: template.from,\n topic: template.topic,\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}'`, true);\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 assertUnsubscribeUrl(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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"}
|
|
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,13 +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) and says what it is: a `topic` the recipient may
|
|
6
|
-
* `transactional: true`. A topic email's unsubscribe link is the template's
|
|
7
|
-
* else the config's `email.unsubscribeUrl`, else none;
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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.
|
|
11
12
|
*/
|
|
12
13
|
/** An address as the transports take it: bare, or with a display name. */
|
|
13
14
|
export type EmailAddress = string | {
|
|
@@ -30,6 +31,15 @@ export interface EmailConfig {
|
|
|
30
31
|
* confirmation page, and only the confirmation (a POST no link scanner sends) unsubscribes.
|
|
31
32
|
*/
|
|
32
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;
|
|
33
43
|
/** How the Worker tells a person's opens and clicks from a machine's (see the reporting classifier). */
|
|
34
44
|
tracking?: {
|
|
35
45
|
/**
|
|
@@ -52,16 +62,37 @@ export interface ResolvedEmail {
|
|
|
52
62
|
* the email has no unsubscribe link.
|
|
53
63
|
*/
|
|
54
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;
|
|
55
71
|
}
|
|
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";
|
|
56
82
|
/**
|
|
57
|
-
* The template's metadata, its `unsubscribeUrl` falling back to the config's
|
|
58
|
-
*
|
|
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.
|
|
59
86
|
*/
|
|
60
|
-
export declare function resolveEmail(template: Pick<EmailTemplate, 'from' | 'replyTo' | 'unsubscribeUrl' | 'topic'>, config?: EmailConfig): ResolvedEmail;
|
|
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;
|
|
61
90
|
/** Throws naming `where` when `address` is neither `user@host` nor `{ email: 'user@host', name? }`. */
|
|
62
91
|
export declare function assertEmailAddress(address: unknown, where: string): void;
|
|
63
92
|
/** Throws naming `where` unless `url` is an absolute http(s) URL; '' and null are not a way to leave it out. */
|
|
64
|
-
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;
|
|
65
96
|
/** The config's `email` and every registered template's metadata; throws on the first invalid value. */
|
|
66
97
|
export declare function assertEmailConfig(config: {
|
|
67
98
|
email?: EmailConfig;
|
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,13 +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) and says what it is: a `topic` the recipient may
|
|
6
|
-
* `transactional: true`. A topic email's unsubscribe link is the template's
|
|
7
|
-
* else the config's `email.unsubscribeUrl`, else none;
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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.
|
|
11
12
|
*/
|
|
12
13
|
/** An address as the transports take it: bare, or with a display name. */
|
|
13
14
|
export type EmailAddress = string | {
|
|
@@ -30,6 +31,15 @@ export interface EmailConfig {
|
|
|
30
31
|
* confirmation page, and only the confirmation (a POST no link scanner sends) unsubscribes.
|
|
31
32
|
*/
|
|
32
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;
|
|
33
43
|
/** How the Worker tells a person's opens and clicks from a machine's (see the reporting classifier). */
|
|
34
44
|
tracking?: {
|
|
35
45
|
/**
|
|
@@ -52,16 +62,37 @@ export interface ResolvedEmail {
|
|
|
52
62
|
* the email has no unsubscribe link.
|
|
53
63
|
*/
|
|
54
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;
|
|
55
71
|
}
|
|
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";
|
|
56
82
|
/**
|
|
57
|
-
* The template's metadata, its `unsubscribeUrl` falling back to the config's
|
|
58
|
-
*
|
|
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.
|
|
59
86
|
*/
|
|
60
|
-
export declare function resolveEmail(template: Pick<EmailTemplate, 'from' | 'replyTo' | 'unsubscribeUrl' | 'topic'>, config?: EmailConfig): ResolvedEmail;
|
|
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;
|
|
61
90
|
/** Throws naming `where` when `address` is neither `user@host` nor `{ email: 'user@host', name? }`. */
|
|
62
91
|
export declare function assertEmailAddress(address: unknown, where: string): void;
|
|
63
92
|
/** Throws naming `where` unless `url` is an absolute http(s) URL; '' and null are not a way to leave it out. */
|
|
64
|
-
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;
|
|
65
96
|
/** The config's `email` and every registered template's metadata; throws on the first invalid value. */
|
|
66
97
|
export declare function assertEmailConfig(config: {
|
|
67
98
|
email?: EmailConfig;
|
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"}
|