@shware/workflow 1.0.0 → 2.0.1
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 +42 -11
- package/dist/client.d.cts +1 -1
- package/dist/client.d.mts +1 -1
- package/dist/cloudflare/bindings.cjs.map +1 -1
- package/dist/cloudflare/bindings.d.cts +1 -1
- package/dist/cloudflare/bindings.d.mts +1 -1
- package/dist/cloudflare/bindings.mjs.map +1 -1
- package/dist/cloudflare/channels.cjs +1 -4
- package/dist/cloudflare/channels.cjs.map +1 -1
- package/dist/cloudflare/channels.d.cts +3 -3
- package/dist/cloudflare/channels.d.mts +3 -3
- package/dist/cloudflare/channels.mjs +1 -4
- package/dist/cloudflare/channels.mjs.map +1 -1
- package/dist/cloudflare/index.d.cts +4 -4
- package/dist/cloudflare/index.d.mts +4 -4
- package/dist/cloudflare/router.d.cts +1 -1
- package/dist/cloudflare/router.d.mts +1 -1
- package/dist/cloudflare/runner.d.cts +2 -2
- package/dist/cloudflare/runner.d.mts +2 -2
- package/dist/cloudflare/senders.cjs +15 -8
- package/dist/cloudflare/senders.cjs.map +1 -1
- package/dist/cloudflare/senders.d.cts +15 -11
- package/dist/cloudflare/senders.d.cts.map +1 -1
- package/dist/cloudflare/senders.d.mts +15 -11
- package/dist/cloudflare/senders.d.mts.map +1 -1
- package/dist/cloudflare/senders.mjs +15 -8
- package/dist/cloudflare/senders.mjs.map +1 -1
- package/dist/cloudflare/worker.cjs +7 -4
- package/dist/cloudflare/worker.cjs.map +1 -1
- package/dist/cloudflare/worker.d.cts +2 -2
- package/dist/cloudflare/worker.d.cts.map +1 -1
- package/dist/cloudflare/worker.d.mts +2 -2
- package/dist/cloudflare/worker.d.mts.map +1 -1
- package/dist/cloudflare/worker.mjs +8 -5
- package/dist/cloudflare/worker.mjs.map +1 -1
- package/dist/config.cjs +10 -11
- package/dist/config.cjs.map +1 -1
- package/dist/config.d.cts +10 -33
- package/dist/config.d.cts.map +1 -1
- package/dist/config.d.mts +10 -33
- package/dist/config.d.mts.map +1 -1
- package/dist/config.mjs +11 -11
- package/dist/config.mjs.map +1 -1
- package/dist/contract.cjs.map +1 -1
- package/dist/contract.d.cts +1 -1
- package/dist/contract.d.cts.map +1 -1
- package/dist/contract.d.mts +1 -1
- package/dist/contract.d.mts.map +1 -1
- package/dist/contract.mjs.map +1 -1
- package/dist/dsl/action.cjs.map +1 -1
- package/dist/dsl/action.d.cts +2 -2
- package/dist/dsl/action.d.cts.map +1 -1
- package/dist/dsl/action.d.mts +2 -2
- package/dist/dsl/action.d.mts.map +1 -1
- package/dist/dsl/action.mjs.map +1 -1
- package/dist/dsl/bundle.cjs.map +1 -1
- package/dist/dsl/bundle.d.cts +1 -1
- package/dist/dsl/bundle.d.mts +1 -1
- package/dist/dsl/bundle.mjs.map +1 -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.map +1 -1
- package/dist/dsl/refs.cjs.map +1 -1
- package/dist/dsl/refs.d.cts +5 -4
- package/dist/dsl/refs.d.cts.map +1 -1
- package/dist/dsl/refs.d.mts +5 -4
- package/dist/dsl/refs.d.mts.map +1 -1
- package/dist/dsl/refs.mjs.map +1 -1
- package/dist/dsl/template.cjs.map +1 -1
- package/dist/dsl/template.d.cts +2 -2
- package/dist/dsl/template.d.cts.map +1 -1
- package/dist/dsl/template.d.mts +2 -2
- package/dist/dsl/template.d.mts.map +1 -1
- package/dist/dsl/template.mjs.map +1 -1
- package/dist/email-context.cjs +29 -0
- package/dist/email-context.cjs.map +1 -0
- package/dist/email-context.d.cts +11 -0
- package/dist/email-context.d.cts.map +1 -0
- package/dist/email-context.d.mts +11 -0
- package/dist/email-context.d.mts.map +1 -0
- package/dist/email-context.mjs +27 -0
- package/dist/email-context.mjs.map +1 -0
- package/dist/email.cjs +72 -0
- package/dist/email.cjs.map +1 -0
- package/dist/email.d.cts +63 -0
- package/dist/email.d.cts.map +1 -0
- package/dist/email.d.mts +63 -0
- package/dist/email.d.mts.map +1 -0
- package/dist/email.mjs +65 -0
- package/dist/email.mjs.map +1 -0
- package/dist/engine/index.d.cts +2 -2
- package/dist/engine/index.d.mts +2 -2
- package/dist/engine/interpreter.cjs.map +1 -1
- package/dist/engine/interpreter.d.cts.map +1 -1
- package/dist/engine/interpreter.d.mts.map +1 -1
- package/dist/engine/interpreter.mjs.map +1 -1
- package/dist/engine/ports.d.cts +7 -2
- package/dist/engine/ports.d.cts.map +1 -1
- package/dist/engine/ports.d.mts +7 -2
- package/dist/engine/ports.d.mts.map +1 -1
- package/dist/index.cjs +8 -1
- package/dist/index.d.cts +4 -3
- package/dist/index.d.mts +4 -3
- package/dist/index.mjs +3 -2
- package/dist/react-email.cjs +11 -3
- package/dist/react-email.cjs.map +1 -1
- package/dist/react-email.d.cts +2 -1
- package/dist/react-email.d.cts.map +1 -1
- package/dist/react-email.d.mts +2 -1
- package/dist/react-email.d.mts.map +1 -1
- package/dist/react-email.mjs +11 -3
- package/dist/react-email.mjs.map +1 -1
- package/dist/reporting/classify.cjs.map +1 -1
- package/dist/reporting/classify.d.cts +1 -1
- package/dist/reporting/classify.d.mts +1 -1
- package/dist/reporting/classify.mjs.map +1 -1
- package/dist/reporting/observer.cjs +5 -4
- package/dist/reporting/observer.cjs.map +1 -1
- package/dist/reporting/observer.d.cts +3 -2
- package/dist/reporting/observer.d.cts.map +1 -1
- package/dist/reporting/observer.d.mts +3 -2
- package/dist/reporting/observer.d.mts.map +1 -1
- package/dist/reporting/observer.mjs +5 -4
- package/dist/reporting/observer.mjs.map +1 -1
- package/dist/reporting/tracking.cjs +8 -13
- package/dist/reporting/tracking.cjs.map +1 -1
- package/dist/reporting/tracking.d.cts +18 -7
- package/dist/reporting/tracking.d.cts.map +1 -1
- package/dist/reporting/tracking.d.mts +18 -7
- package/dist/reporting/tracking.d.mts.map +1 -1
- package/dist/reporting/tracking.mjs +8 -13
- package/dist/reporting/tracking.mjs.map +1 -1
- package/dist/store/postgres-reporting.cjs +5 -5
- package/dist/store/postgres-reporting.cjs.map +1 -1
- package/dist/store/postgres-reporting.mjs +5 -5
- package/dist/store/postgres-reporting.mjs.map +1 -1
- package/dist/templates.cjs +6 -1
- package/dist/templates.cjs.map +1 -1
- package/dist/templates.d.cts +21 -2
- package/dist/templates.d.cts.map +1 -1
- package/dist/templates.d.mts +21 -2
- package/dist/templates.d.mts.map +1 -1
- package/dist/templates.mjs +6 -1
- package/dist/templates.mjs.map +1 -1
- package/package.json +11 -1
package/dist/react-email.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"react-email.cjs","names":["indexTemplates","lookupTemplate","parseTemplateProps","createElement","renderToReadableStream"],"sources":["../src/react-email.ts"],"sourcesContent":["import { type ComponentType, type ReactElement, createElement } from 'react';\nimport { renderToReadableStream } from 'react-dom/server';\nimport type { EmailRenderer } from './cloudflare';\nimport {\n type EmailTemplate,\n indexTemplates,\n lookupTemplate,\n parseTemplateProps,\n} from './templates';\n\n/**\n * `@shware/workflow/react-email` — an EmailRenderer over the project's email\n * registry (`react-dom` is an optional peer dependency; import this subpath\n * only when you use it).\n *\n * The registry holds `emailTemplate({...})` definitions, looked up by their `id`. Props are\n * parsed against the template's schema before the component renders — a\n * failure throws TemplateValidationError and nothing renders — and the\n * component renders as an element
|
|
1
|
+
{"version":3,"file":"react-email.cjs","names":["indexTemplates","lookupTemplate","parseTemplateProps","resolveEmail","createElement","EmailProvider","renderToReadableStream"],"sources":["../src/react-email.ts"],"sourcesContent":["import { type ComponentType, type ReactElement, createElement } from 'react';\nimport { renderToReadableStream } from 'react-dom/server';\nimport type { EmailRenderer } from './cloudflare';\nimport { type EmailConfig, resolveEmail } from './email';\nimport { EmailProvider } from './email-context';\nimport {\n type EmailTemplate,\n indexTemplates,\n lookupTemplate,\n parseTemplateProps,\n} from './templates';\n\n/**\n * `@shware/workflow/react-email` — an EmailRenderer over the project's email\n * registry (`react-dom` is an optional peer dependency; import this subpath\n * only when you use it).\n *\n * The registry holds `emailTemplate({...})` definitions, looked up by their `id`. Props are\n * parsed against the template's schema before the component renders — a\n * failure throws TemplateValidationError and nothing renders — and the\n * component renders as an element inside an `EmailProvider` carrying the\n * template's resolved metadata (`resolveEmail` against the config's `email`),\n * so hooks and context work and a layout's `useEmailContext()` reads the\n * unsubscribe link the email is sent with; the render returns that metadata\n * with the subject and HTML, for the sender. Rendering\n * streams and waits for every Suspense boundary (`allReady`) — react-email's\n * `<Tailwind>` suspends while it compiles the classes, which synchronous\n * static markup cannot wait for — then prepends the XHTML doctype react-email\n * uses. @react-email/render itself is avoided because its Workers build\n * statically imports prettier and html-to-text for options never used here.\n */\n\nconst DOCTYPE =\n '<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">';\n\nexport function registryRenderer(\n emails: Readonly<Record<string, EmailTemplate>>,\n config?: EmailConfig\n): EmailRenderer {\n const index = indexTemplates('email', emails);\n return async (id, props) => {\n const template = lookupTemplate(index, 'email', id);\n const parsed = parseTemplateProps(template, props);\n const email = resolveEmail(template, config);\n const element = createElement(\n EmailProvider,\n { email },\n createElement(template.component as ComponentType<object>, parsed)\n );\n return { ...email, subject: template.subject, html: DOCTYPE + (await renderToHtml(element)) };\n };\n}\n\n/** Render to a string once every Suspense boundary has resolved; a rendering error rejects. */\nasync function renderToHtml(element: ReactElement): Promise<string> {\n let failure: unknown;\n const stream = await renderToReadableStream(element, {\n onError: (error) => {\n failure = error;\n },\n });\n await stream.allReady;\n const html = await new Response(stream).text();\n if (failure !== undefined) {\n throw failure instanceof Error ? failure : new Error('email render failed', { cause: failure });\n }\n // Streaming a document adds what static markup did not: an HTML5 doctype (the caller prepends\n // the XHTML one email clients expect) and React 19's image preload hints, which mean nothing in\n // an email. Both are dropped, as @react-email/render does.\n return html\n .replace(/^<!DOCTYPE html>/i, '')\n .replace(/<link rel=\"preload\" as=\"image\"[^>]*\\/>/g, '');\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AAgCA,MAAM,UACJ;AAEF,SAAgB,iBACd,QACA,QACe;CACf,MAAM,QAAQA,kBAAAA,eAAe,SAAS,MAAM;CAC5C,OAAO,OAAO,IAAI,UAAU;EAC1B,MAAM,WAAWC,kBAAAA,eAAe,OAAO,SAAS,EAAE;EAClD,MAAM,SAASC,kBAAAA,mBAAmB,UAAU,KAAK;EACjD,MAAM,QAAQC,cAAAA,aAAa,UAAU,MAAM;EAC3C,MAAM,WAAA,GAAUC,MAAAA,cAAAA,CACdC,sBAAAA,eACA,EAAE,MAAM,IAAA,GACRD,MAAAA,cAAAA,CAAc,SAAS,WAAoC,MAAM,CACnE;EACA,OAAO;GAAE,GAAG;GAAO,SAAS,SAAS;GAAS,MAAM,UAAW,MAAM,aAAa,OAAO;EAAG;CAC9F;AACF;;AAGA,eAAe,aAAa,SAAwC;CAClE,IAAI;CACJ,MAAM,SAAS,OAAA,GAAME,iBAAAA,uBAAAA,CAAuB,SAAS,EACnD,UAAU,UAAU;EAClB,UAAU;CACZ,EACF,CAAC;CACD,MAAM,OAAO;CACb,MAAM,OAAO,MAAM,IAAI,SAAS,MAAM,CAAC,CAAC,KAAK;CAC7C,IAAI,YAAY,KAAA,GACd,MAAM,mBAAmB,QAAQ,UAAU,IAAI,MAAM,uBAAuB,EAAE,OAAO,QAAQ,CAAC;CAKhG,OAAO,KACJ,QAAQ,qBAAqB,EAAE,CAAC,CAChC,QAAQ,2CAA2C,EAAE;AAC1D"}
|
package/dist/react-email.d.cts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { EmailConfig } from "./email.cjs";
|
|
1
2
|
import { EmailTemplate } from "./templates.cjs";
|
|
2
3
|
import { EmailRenderer } from "./cloudflare/senders.cjs";
|
|
3
4
|
import "./cloudflare/index.cjs";
|
|
4
5
|
//#region src/react-email.d.ts
|
|
5
|
-
export declare function registryRenderer(emails: Readonly<Record<string, EmailTemplate
|
|
6
|
+
export declare function registryRenderer(emails: Readonly<Record<string, EmailTemplate>>, config?: EmailConfig): EmailRenderer;
|
|
6
7
|
//#endregion
|
|
7
8
|
//# sourceMappingURL=react-email.d.cts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"react-email.d.cts","names":[],"sources":["../src/react-email.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"react-email.d.cts","names":[],"sources":["../src/react-email.ts"],"mappings":";;;;;wBAmCgB,iBACd,QAAQ,SAAS,eAAe,iBAChC,SAAS,cACR"}
|
package/dist/react-email.d.mts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { EmailConfig } from "./email.mjs";
|
|
1
2
|
import { EmailTemplate } from "./templates.mjs";
|
|
2
3
|
import { EmailRenderer } from "./cloudflare/senders.mjs";
|
|
3
4
|
import "./cloudflare/index.mjs";
|
|
4
5
|
//#region src/react-email.d.ts
|
|
5
|
-
export declare function registryRenderer(emails: Readonly<Record<string, EmailTemplate
|
|
6
|
+
export declare function registryRenderer(emails: Readonly<Record<string, EmailTemplate>>, config?: EmailConfig): EmailRenderer;
|
|
6
7
|
//#endregion
|
|
7
8
|
//# sourceMappingURL=react-email.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"react-email.d.mts","names":[],"sources":["../src/react-email.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"react-email.d.mts","names":[],"sources":["../src/react-email.ts"],"mappings":";;;;;wBAmCgB,iBACd,QAAQ,SAAS,eAAe,iBAChC,SAAS,cACR"}
|
package/dist/react-email.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { resolveEmail } from "./email.mjs";
|
|
1
2
|
import { indexTemplates, lookupTemplate, parseTemplateProps } from "./templates.mjs";
|
|
3
|
+
import { EmailProvider } from "./email-context.mjs";
|
|
2
4
|
import { createElement } from "react";
|
|
3
5
|
import { renderToReadableStream } from "react-dom/server";
|
|
4
6
|
//#region src/react-email.ts
|
|
@@ -10,7 +12,11 @@ import { renderToReadableStream } from "react-dom/server";
|
|
|
10
12
|
* The registry holds `emailTemplate({...})` definitions, looked up by their `id`. Props are
|
|
11
13
|
* parsed against the template's schema before the component renders — a
|
|
12
14
|
* failure throws TemplateValidationError and nothing renders — and the
|
|
13
|
-
* component renders as an element
|
|
15
|
+
* component renders as an element inside an `EmailProvider` carrying the
|
|
16
|
+
* template's resolved metadata (`resolveEmail` against the config's `email`),
|
|
17
|
+
* so hooks and context work and a layout's `useEmailContext()` reads the
|
|
18
|
+
* unsubscribe link the email is sent with; the render returns that metadata
|
|
19
|
+
* with the subject and HTML, for the sender. Rendering
|
|
14
20
|
* streams and waits for every Suspense boundary (`allReady`) — react-email's
|
|
15
21
|
* `<Tailwind>` suspends while it compiles the classes, which synchronous
|
|
16
22
|
* static markup cannot wait for — then prepends the XHTML doctype react-email
|
|
@@ -18,13 +24,15 @@ import { renderToReadableStream } from "react-dom/server";
|
|
|
18
24
|
* statically imports prettier and html-to-text for options never used here.
|
|
19
25
|
*/
|
|
20
26
|
const DOCTYPE = "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">";
|
|
21
|
-
function registryRenderer(emails) {
|
|
27
|
+
function registryRenderer(emails, config) {
|
|
22
28
|
const index = indexTemplates("email", emails);
|
|
23
29
|
return async (id, props) => {
|
|
24
30
|
const template = lookupTemplate(index, "email", id);
|
|
25
31
|
const parsed = parseTemplateProps(template, props);
|
|
26
|
-
const
|
|
32
|
+
const email = resolveEmail(template, config);
|
|
33
|
+
const element = createElement(EmailProvider, { email }, createElement(template.component, parsed));
|
|
27
34
|
return {
|
|
35
|
+
...email,
|
|
28
36
|
subject: template.subject,
|
|
29
37
|
html: DOCTYPE + await renderToHtml(element)
|
|
30
38
|
};
|
package/dist/react-email.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"react-email.mjs","names":[],"sources":["../src/react-email.ts"],"sourcesContent":["import { type ComponentType, type ReactElement, createElement } from 'react';\nimport { renderToReadableStream } from 'react-dom/server';\nimport type { EmailRenderer } from './cloudflare';\nimport {\n type EmailTemplate,\n indexTemplates,\n lookupTemplate,\n parseTemplateProps,\n} from './templates';\n\n/**\n * `@shware/workflow/react-email` — an EmailRenderer over the project's email\n * registry (`react-dom` is an optional peer dependency; import this subpath\n * only when you use it).\n *\n * The registry holds `emailTemplate({...})` definitions, looked up by their `id`. Props are\n * parsed against the template's schema before the component renders — a\n * failure throws TemplateValidationError and nothing renders — and the\n * component renders as an element
|
|
1
|
+
{"version":3,"file":"react-email.mjs","names":[],"sources":["../src/react-email.ts"],"sourcesContent":["import { type ComponentType, type ReactElement, createElement } from 'react';\nimport { renderToReadableStream } from 'react-dom/server';\nimport type { EmailRenderer } from './cloudflare';\nimport { type EmailConfig, resolveEmail } from './email';\nimport { EmailProvider } from './email-context';\nimport {\n type EmailTemplate,\n indexTemplates,\n lookupTemplate,\n parseTemplateProps,\n} from './templates';\n\n/**\n * `@shware/workflow/react-email` — an EmailRenderer over the project's email\n * registry (`react-dom` is an optional peer dependency; import this subpath\n * only when you use it).\n *\n * The registry holds `emailTemplate({...})` definitions, looked up by their `id`. Props are\n * parsed against the template's schema before the component renders — a\n * failure throws TemplateValidationError and nothing renders — and the\n * component renders as an element inside an `EmailProvider` carrying the\n * template's resolved metadata (`resolveEmail` against the config's `email`),\n * so hooks and context work and a layout's `useEmailContext()` reads the\n * unsubscribe link the email is sent with; the render returns that metadata\n * with the subject and HTML, for the sender. Rendering\n * streams and waits for every Suspense boundary (`allReady`) — react-email's\n * `<Tailwind>` suspends while it compiles the classes, which synchronous\n * static markup cannot wait for — then prepends the XHTML doctype react-email\n * uses. @react-email/render itself is avoided because its Workers build\n * statically imports prettier and html-to-text for options never used here.\n */\n\nconst DOCTYPE =\n '<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">';\n\nexport function registryRenderer(\n emails: Readonly<Record<string, EmailTemplate>>,\n config?: EmailConfig\n): EmailRenderer {\n const index = indexTemplates('email', emails);\n return async (id, props) => {\n const template = lookupTemplate(index, 'email', id);\n const parsed = parseTemplateProps(template, props);\n const email = resolveEmail(template, config);\n const element = createElement(\n EmailProvider,\n { email },\n createElement(template.component as ComponentType<object>, parsed)\n );\n return { ...email, subject: template.subject, html: DOCTYPE + (await renderToHtml(element)) };\n };\n}\n\n/** Render to a string once every Suspense boundary has resolved; a rendering error rejects. */\nasync function renderToHtml(element: ReactElement): Promise<string> {\n let failure: unknown;\n const stream = await renderToReadableStream(element, {\n onError: (error) => {\n failure = error;\n },\n });\n await stream.allReady;\n const html = await new Response(stream).text();\n if (failure !== undefined) {\n throw failure instanceof Error ? failure : new Error('email render failed', { cause: failure });\n }\n // Streaming a document adds what static markup did not: an HTML5 doctype (the caller prepends\n // the XHTML one email clients expect) and React 19's image preload hints, which mean nothing in\n // an email. Both are dropped, as @react-email/render does.\n return html\n .replace(/^<!DOCTYPE html>/i, '')\n .replace(/<link rel=\"preload\" as=\"image\"[^>]*\\/>/g, '');\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;AAgCA,MAAM,UACJ;AAEF,SAAgB,iBACd,QACA,QACe;CACf,MAAM,QAAQ,eAAe,SAAS,MAAM;CAC5C,OAAO,OAAO,IAAI,UAAU;EAC1B,MAAM,WAAW,eAAe,OAAO,SAAS,EAAE;EAClD,MAAM,SAAS,mBAAmB,UAAU,KAAK;EACjD,MAAM,QAAQ,aAAa,UAAU,MAAM;EAC3C,MAAM,UAAU,cACd,eACA,EAAE,MAAM,GACR,cAAc,SAAS,WAAoC,MAAM,CACnE;EACA,OAAO;GAAE,GAAG;GAAO,SAAS,SAAS;GAAS,MAAM,UAAW,MAAM,aAAa,OAAO;EAAG;CAC9F;AACF;;AAGA,eAAe,aAAa,SAAwC;CAClE,IAAI;CACJ,MAAM,SAAS,MAAM,uBAAuB,SAAS,EACnD,UAAU,UAAU;EAClB,UAAU;CACZ,EACF,CAAC;CACD,MAAM,OAAO;CACb,MAAM,OAAO,MAAM,IAAI,SAAS,MAAM,CAAC,CAAC,KAAK;CAC7C,IAAI,YAAY,KAAA,GACd,MAAM,mBAAmB,QAAQ,UAAU,IAAI,MAAM,uBAAuB,EAAE,OAAO,QAAQ,CAAC;CAKhG,OAAO,KACJ,QAAQ,qBAAqB,EAAE,CAAC,CAChC,QAAQ,2CAA2C,EAAE;AAC1D"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"classify.cjs","names":[],"sources":["../../src/reporting/classify.ts"],"sourcesContent":["/**\n * Who opened or clicked: a person, or a machine acting on the message without one — the split\n * between human and machine engagement that open and click rates are read through. No single\n * signal decides on its own unless it is strong; weak ones only count together, the way the\n * ESPs that publish their approach describe it (a quick click alone is a person on a phone as\n * often as a scanner). Every verdict names the rule that made it, and the request's user agent and\n * network are stored beside it, so the rules can be revisited against history.\n *\n * Strong, alone enough:\n * - `scanner`: a security gateway or link checker announcing itself (Barracuda, Mimecast,\n * Proofpoint, Safe Links, curl, a headless browser…);\n * - `datacenter` (clicks): the request came from a cloud or security vendor's network (AWS,\n * Azure, Google Cloud, Proofpoint…), where people do not browse — Microsoft Defender detonates\n * links from Azure. Not Cloudflare, Akamai or Fastly: iCloud Private Relay egresses there. Not\n * for opens: Gmail's, Outlook's and Yahoo's image proxies fetch from their own networks when\n * the person opens the message;\n * - `apple_privacy_proxy` (opens): Apple Mail Privacy Protection, which fetches every image on\n * delivery through Apple's proxies with the bare `Mozilla/5.0` user agent, read or not;\n * - `head_request` (clicks): a person's click is a GET;\n * - `link_burst` (clicks): three or more different links of one message within a few seconds, or\n * every link of it — the way a scanner walks a message. Earlier clicks of the burst are\n * reclassified too (see `recordClick`).\n *\n * Weak, machine only together:\n * - `quick`: within `quickMs` of delivery (1 s by default, `
|
|
1
|
+
{"version":3,"file":"classify.cjs","names":[],"sources":["../../src/reporting/classify.ts"],"sourcesContent":["/**\n * Who opened or clicked: a person, or a machine acting on the message without one — the split\n * between human and machine engagement that open and click rates are read through. No single\n * signal decides on its own unless it is strong; weak ones only count together, the way the\n * ESPs that publish their approach describe it (a quick click alone is a person on a phone as\n * often as a scanner). Every verdict names the rule that made it, and the request's user agent and\n * network are stored beside it, so the rules can be revisited against history.\n *\n * Strong, alone enough:\n * - `scanner`: a security gateway or link checker announcing itself (Barracuda, Mimecast,\n * Proofpoint, Safe Links, curl, a headless browser…);\n * - `datacenter` (clicks): the request came from a cloud or security vendor's network (AWS,\n * Azure, Google Cloud, Proofpoint…), where people do not browse — Microsoft Defender detonates\n * links from Azure. Not Cloudflare, Akamai or Fastly: iCloud Private Relay egresses there. Not\n * for opens: Gmail's, Outlook's and Yahoo's image proxies fetch from their own networks when\n * the person opens the message;\n * - `apple_privacy_proxy` (opens): Apple Mail Privacy Protection, which fetches every image on\n * delivery through Apple's proxies with the bare `Mozilla/5.0` user agent, read or not;\n * - `head_request` (clicks): a person's click is a GET;\n * - `link_burst` (clicks): three or more different links of one message within a few seconds, or\n * every link of it — the way a scanner walks a message. Earlier clicks of the burst are\n * reclassified too (see `recordClick`).\n *\n * Weak, machine only together:\n * - `quick`: within `quickMs` of delivery (1 s by default, `email.tracking.quickClickMs`);\n * - `link_pair`: another link of the same message clicked within a few seconds.\n *\n * Gmail's image proxy fetches when the person opens the message, so its opens count as human —\n * unless it fetched within `quickMs` of delivery (`gmail_prefetch`).\n */\n\nexport interface Verdict {\n machine: boolean;\n reason: string | null;\n}\n\nexport interface RequestFacts {\n method: string;\n userAgent: string | null;\n /** Cloudflare's `request.cf.asOrganization`: who owns the address the request came from. */\n organization: string | null;\n /** Milliseconds between delivering the message and this request. */\n sinceDeliveredMs: number;\n}\n\nexport interface ClassifyOptions {\n /** A request this soon after delivery is a weak machine signal; 1000 ms by default. */\n quickMs?: number;\n}\n\nexport interface ClickFacts extends RequestFacts {\n /** The different links of the message clicked within `BURST_WINDOW_MS`, this one included. */\n linksInBurst: number;\n /** How many tracked links the message has. */\n linkCount: number;\n}\n\n/** How close together clicks on different links of one message must be to count as one walk. */\nexport const BURST_WINDOW_MS = 5_000;\nexport const DEFAULT_QUICK_MS = 1_000;\n\n// Whole words only: \"San Francisco\" is not Cisco, a CUBOT phone is not a bot\nconst SCANNER =\n /(?<![a-z])(?:(?!cubot)[a-z]*bot|crawler|spider|headlesschrome|phantomjs|barracuda|mimecast|proofpoint|safelinks|symantec|trend ?micro|forcepoint|zscaler|cisco|fortinet|sophos|messagelabs)(?![a-z])|curl\\/|wget\\/|python|go-http-client|java\\/|okhttp/i;\nconst DATACENTER =\n /\\b(?:amazon|aws|microsoft|azure|google cloud|digitalocean|ovh|hetzner|linode|akamai connected cloud|vultr|oracle|alibaba|tencent|proofpoint|mimecast|barracuda|forcepoint|zscaler|cisco|fortinet|sophos|trend ?micro|symantec|broadcom)\\b/i;\nconst GOOGLE_PROXY = /GoogleImageProxy|ggpht\\.com/i;\n\nconst human: Verdict = { machine: false, reason: null };\nconst machine = (reason: string): Verdict => ({ machine: true, reason });\n\nconst scanner = (facts: RequestFacts) => SCANNER.test(facts.userAgent?.trim() ?? '');\n\nexport function classifyOpen(facts: RequestFacts, options: ClassifyOptions = {}): Verdict {\n const quickMs = options.quickMs ?? DEFAULT_QUICK_MS;\n const agent = facts.userAgent?.trim() ?? '';\n if (agent === 'Mozilla/5.0' || /apple/i.test(facts.organization ?? '')) {\n return machine('apple_privacy_proxy');\n }\n if (GOOGLE_PROXY.test(agent)) {\n return facts.sinceDeliveredMs < quickMs ? machine('gmail_prefetch') : human;\n }\n return scanner(facts) ? machine('scanner') : human;\n}\n\nexport function classifyClick(facts: ClickFacts, options: ClassifyOptions = {}): Verdict {\n const quickMs = options.quickMs ?? DEFAULT_QUICK_MS;\n if (facts.method === 'HEAD') return machine('head_request');\n if (scanner(facts)) return machine('scanner');\n if (DATACENTER.test(facts.organization ?? '')) return machine('datacenter');\n if (facts.linksInBurst >= 3 || (facts.linkCount >= 2 && facts.linksInBurst >= facts.linkCount)) {\n return machine('link_burst');\n }\n const quick = facts.sinceDeliveredMs < quickMs;\n const pair = facts.linksInBurst >= 2;\n if (quick && pair) return machine('quick+link_pair');\n return human;\n}\n"],"mappings":";;;AA0DA,MAAa,kBAAkB;AAC/B,MAAa,mBAAmB;AAGhC,MAAM,UACJ;AACF,MAAM,aACJ;AACF,MAAM,eAAe;AAErB,MAAM,QAAiB;CAAE,SAAS;CAAO,QAAQ;AAAK;AACtD,MAAM,WAAW,YAA6B;CAAE,SAAS;CAAM;AAAO;AAEtE,MAAM,WAAW,UAAwB,QAAQ,KAAK,MAAM,WAAW,KAAK,KAAK,EAAE;AAEnF,SAAgB,aAAa,OAAqB,UAA2B,CAAC,GAAY;CACxF,MAAM,UAAU,QAAQ,WAAA;CACxB,MAAM,QAAQ,MAAM,WAAW,KAAK,KAAK;CACzC,IAAI,UAAU,iBAAiB,SAAS,KAAK,MAAM,gBAAgB,EAAE,GACnE,OAAO,QAAQ,qBAAqB;CAEtC,IAAI,aAAa,KAAK,KAAK,GACzB,OAAO,MAAM,mBAAmB,UAAU,QAAQ,gBAAgB,IAAI;CAExE,OAAO,QAAQ,KAAK,IAAI,QAAQ,SAAS,IAAI;AAC/C;AAEA,SAAgB,cAAc,OAAmB,UAA2B,CAAC,GAAY;CACvF,MAAM,UAAU,QAAQ,WAAA;CACxB,IAAI,MAAM,WAAW,QAAQ,OAAO,QAAQ,cAAc;CAC1D,IAAI,QAAQ,KAAK,GAAG,OAAO,QAAQ,SAAS;CAC5C,IAAI,WAAW,KAAK,MAAM,gBAAgB,EAAE,GAAG,OAAO,QAAQ,YAAY;CAC1E,IAAI,MAAM,gBAAgB,KAAM,MAAM,aAAa,KAAK,MAAM,gBAAgB,MAAM,WAClF,OAAO,QAAQ,YAAY;CAE7B,MAAM,QAAQ,MAAM,mBAAmB;CACvC,MAAM,OAAO,MAAM,gBAAgB;CACnC,IAAI,SAAS,MAAM,OAAO,QAAQ,iBAAiB;CACnD,OAAO;AACT"}
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* reclassified too (see `recordClick`).
|
|
24
24
|
*
|
|
25
25
|
* Weak, machine only together:
|
|
26
|
-
* - `quick`: within `quickMs` of delivery (1 s by default, `
|
|
26
|
+
* - `quick`: within `quickMs` of delivery (1 s by default, `email.tracking.quickClickMs`);
|
|
27
27
|
* - `link_pair`: another link of the same message clicked within a few seconds.
|
|
28
28
|
*
|
|
29
29
|
* Gmail's image proxy fetches when the person opens the message, so its opens count as human —
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* reclassified too (see `recordClick`).
|
|
24
24
|
*
|
|
25
25
|
* Weak, machine only together:
|
|
26
|
-
* - `quick`: within `quickMs` of delivery (1 s by default, `
|
|
26
|
+
* - `quick`: within `quickMs` of delivery (1 s by default, `email.tracking.quickClickMs`);
|
|
27
27
|
* - `link_pair`: another link of the same message clicked within a few seconds.
|
|
28
28
|
*
|
|
29
29
|
* Gmail's image proxy fetches when the person opens the message, so its opens count as human —
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"classify.mjs","names":[],"sources":["../../src/reporting/classify.ts"],"sourcesContent":["/**\n * Who opened or clicked: a person, or a machine acting on the message without one — the split\n * between human and machine engagement that open and click rates are read through. No single\n * signal decides on its own unless it is strong; weak ones only count together, the way the\n * ESPs that publish their approach describe it (a quick click alone is a person on a phone as\n * often as a scanner). Every verdict names the rule that made it, and the request's user agent and\n * network are stored beside it, so the rules can be revisited against history.\n *\n * Strong, alone enough:\n * - `scanner`: a security gateway or link checker announcing itself (Barracuda, Mimecast,\n * Proofpoint, Safe Links, curl, a headless browser…);\n * - `datacenter` (clicks): the request came from a cloud or security vendor's network (AWS,\n * Azure, Google Cloud, Proofpoint…), where people do not browse — Microsoft Defender detonates\n * links from Azure. Not Cloudflare, Akamai or Fastly: iCloud Private Relay egresses there. Not\n * for opens: Gmail's, Outlook's and Yahoo's image proxies fetch from their own networks when\n * the person opens the message;\n * - `apple_privacy_proxy` (opens): Apple Mail Privacy Protection, which fetches every image on\n * delivery through Apple's proxies with the bare `Mozilla/5.0` user agent, read or not;\n * - `head_request` (clicks): a person's click is a GET;\n * - `link_burst` (clicks): three or more different links of one message within a few seconds, or\n * every link of it — the way a scanner walks a message. Earlier clicks of the burst are\n * reclassified too (see `recordClick`).\n *\n * Weak, machine only together:\n * - `quick`: within `quickMs` of delivery (1 s by default, `
|
|
1
|
+
{"version":3,"file":"classify.mjs","names":[],"sources":["../../src/reporting/classify.ts"],"sourcesContent":["/**\n * Who opened or clicked: a person, or a machine acting on the message without one — the split\n * between human and machine engagement that open and click rates are read through. No single\n * signal decides on its own unless it is strong; weak ones only count together, the way the\n * ESPs that publish their approach describe it (a quick click alone is a person on a phone as\n * often as a scanner). Every verdict names the rule that made it, and the request's user agent and\n * network are stored beside it, so the rules can be revisited against history.\n *\n * Strong, alone enough:\n * - `scanner`: a security gateway or link checker announcing itself (Barracuda, Mimecast,\n * Proofpoint, Safe Links, curl, a headless browser…);\n * - `datacenter` (clicks): the request came from a cloud or security vendor's network (AWS,\n * Azure, Google Cloud, Proofpoint…), where people do not browse — Microsoft Defender detonates\n * links from Azure. Not Cloudflare, Akamai or Fastly: iCloud Private Relay egresses there. Not\n * for opens: Gmail's, Outlook's and Yahoo's image proxies fetch from their own networks when\n * the person opens the message;\n * - `apple_privacy_proxy` (opens): Apple Mail Privacy Protection, which fetches every image on\n * delivery through Apple's proxies with the bare `Mozilla/5.0` user agent, read or not;\n * - `head_request` (clicks): a person's click is a GET;\n * - `link_burst` (clicks): three or more different links of one message within a few seconds, or\n * every link of it — the way a scanner walks a message. Earlier clicks of the burst are\n * reclassified too (see `recordClick`).\n *\n * Weak, machine only together:\n * - `quick`: within `quickMs` of delivery (1 s by default, `email.tracking.quickClickMs`);\n * - `link_pair`: another link of the same message clicked within a few seconds.\n *\n * Gmail's image proxy fetches when the person opens the message, so its opens count as human —\n * unless it fetched within `quickMs` of delivery (`gmail_prefetch`).\n */\n\nexport interface Verdict {\n machine: boolean;\n reason: string | null;\n}\n\nexport interface RequestFacts {\n method: string;\n userAgent: string | null;\n /** Cloudflare's `request.cf.asOrganization`: who owns the address the request came from. */\n organization: string | null;\n /** Milliseconds between delivering the message and this request. */\n sinceDeliveredMs: number;\n}\n\nexport interface ClassifyOptions {\n /** A request this soon after delivery is a weak machine signal; 1000 ms by default. */\n quickMs?: number;\n}\n\nexport interface ClickFacts extends RequestFacts {\n /** The different links of the message clicked within `BURST_WINDOW_MS`, this one included. */\n linksInBurst: number;\n /** How many tracked links the message has. */\n linkCount: number;\n}\n\n/** How close together clicks on different links of one message must be to count as one walk. */\nexport const BURST_WINDOW_MS = 5_000;\nexport const DEFAULT_QUICK_MS = 1_000;\n\n// Whole words only: \"San Francisco\" is not Cisco, a CUBOT phone is not a bot\nconst SCANNER =\n /(?<![a-z])(?:(?!cubot)[a-z]*bot|crawler|spider|headlesschrome|phantomjs|barracuda|mimecast|proofpoint|safelinks|symantec|trend ?micro|forcepoint|zscaler|cisco|fortinet|sophos|messagelabs)(?![a-z])|curl\\/|wget\\/|python|go-http-client|java\\/|okhttp/i;\nconst DATACENTER =\n /\\b(?:amazon|aws|microsoft|azure|google cloud|digitalocean|ovh|hetzner|linode|akamai connected cloud|vultr|oracle|alibaba|tencent|proofpoint|mimecast|barracuda|forcepoint|zscaler|cisco|fortinet|sophos|trend ?micro|symantec|broadcom)\\b/i;\nconst GOOGLE_PROXY = /GoogleImageProxy|ggpht\\.com/i;\n\nconst human: Verdict = { machine: false, reason: null };\nconst machine = (reason: string): Verdict => ({ machine: true, reason });\n\nconst scanner = (facts: RequestFacts) => SCANNER.test(facts.userAgent?.trim() ?? '');\n\nexport function classifyOpen(facts: RequestFacts, options: ClassifyOptions = {}): Verdict {\n const quickMs = options.quickMs ?? DEFAULT_QUICK_MS;\n const agent = facts.userAgent?.trim() ?? '';\n if (agent === 'Mozilla/5.0' || /apple/i.test(facts.organization ?? '')) {\n return machine('apple_privacy_proxy');\n }\n if (GOOGLE_PROXY.test(agent)) {\n return facts.sinceDeliveredMs < quickMs ? machine('gmail_prefetch') : human;\n }\n return scanner(facts) ? machine('scanner') : human;\n}\n\nexport function classifyClick(facts: ClickFacts, options: ClassifyOptions = {}): Verdict {\n const quickMs = options.quickMs ?? DEFAULT_QUICK_MS;\n if (facts.method === 'HEAD') return machine('head_request');\n if (scanner(facts)) return machine('scanner');\n if (DATACENTER.test(facts.organization ?? '')) return machine('datacenter');\n if (facts.linksInBurst >= 3 || (facts.linkCount >= 2 && facts.linksInBurst >= facts.linkCount)) {\n return machine('link_burst');\n }\n const quick = facts.sinceDeliveredMs < quickMs;\n const pair = facts.linksInBurst >= 2;\n if (quick && pair) return machine('quick+link_pair');\n return human;\n}\n"],"mappings":";;AA0DA,MAAa,kBAAkB;AAC/B,MAAa,mBAAmB;AAGhC,MAAM,UACJ;AACF,MAAM,aACJ;AACF,MAAM,eAAe;AAErB,MAAM,QAAiB;CAAE,SAAS;CAAO,QAAQ;AAAK;AACtD,MAAM,WAAW,YAA6B;CAAE,SAAS;CAAM;AAAO;AAEtE,MAAM,WAAW,UAAwB,QAAQ,KAAK,MAAM,WAAW,KAAK,KAAK,EAAE;AAEnF,SAAgB,aAAa,OAAqB,UAA2B,CAAC,GAAY;CACxF,MAAM,UAAU,QAAQ,WAAA;CACxB,MAAM,QAAQ,MAAM,WAAW,KAAK,KAAK;CACzC,IAAI,UAAU,iBAAiB,SAAS,KAAK,MAAM,gBAAgB,EAAE,GACnE,OAAO,QAAQ,qBAAqB;CAEtC,IAAI,aAAa,KAAK,KAAK,GACzB,OAAO,MAAM,mBAAmB,UAAU,QAAQ,gBAAgB,IAAI;CAExE,OAAO,QAAQ,KAAK,IAAI,QAAQ,SAAS,IAAI;AAC/C;AAEA,SAAgB,cAAc,OAAmB,UAA2B,CAAC,GAAY;CACvF,MAAM,UAAU,QAAQ,WAAA;CACxB,IAAI,MAAM,WAAW,QAAQ,OAAO,QAAQ,cAAc;CAC1D,IAAI,QAAQ,KAAK,GAAG,OAAO,QAAQ,SAAS;CAC5C,IAAI,WAAW,KAAK,MAAM,gBAAgB,EAAE,GAAG,OAAO,QAAQ,YAAY;CAC1E,IAAI,MAAM,gBAAgB,KAAM,MAAM,aAAa,KAAK,MAAM,gBAAgB,MAAM,WAClF,OAAO,QAAQ,YAAY;CAE7B,MAAM,QAAQ,MAAM,mBAAmB;CACvC,MAAM,OAAO,MAAM,gBAAgB;CACnC,IAAI,SAAS,MAAM,OAAO,QAAQ,iBAAiB;CACnD,OAAO;AACT"}
|
|
@@ -5,8 +5,9 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
|
5
5
|
* and outcome recorded through `store.reporting`.
|
|
6
6
|
*
|
|
7
7
|
* A copy is recorded before it goes out, so its id can travel with it: with a tracking origin,
|
|
8
|
-
* an email's links become `<origin>/t/
|
|
9
|
-
*
|
|
8
|
+
* an email's links become `<origin>/t/l/<message>/<position>` — `/t/u/...` for its unsubscribe
|
|
9
|
+
* link, so what a click means is fixed when it is sent — and an open pixel `<origin>/t/o/<message>`
|
|
10
|
+
* (served by the Worker, ./tracking.ts), and a push carries
|
|
10
11
|
* `message_id` in its payload for the app to report the tap (`JourneyClient.messageOpened`),
|
|
11
12
|
* plus `delivered_url` / `opened_url` for a client that reports straight to the Worker.
|
|
12
13
|
* The links are recorded when the sender commits the rewritten email, before it goes out.
|
|
@@ -30,9 +31,9 @@ function reportingObserver(reporting, origin) {
|
|
|
30
31
|
if (message.channel === "email" && base !== void 0) {
|
|
31
32
|
const urls = [];
|
|
32
33
|
return {
|
|
33
|
-
link: (url) => {
|
|
34
|
+
link: (url, purpose) => {
|
|
34
35
|
urls.push(url);
|
|
35
|
-
return `${base}/t
|
|
36
|
+
return `${base}/t/${purpose === "unsubscribe" ? "u" : "l"}/${id}/${urls.length - 1}`;
|
|
36
37
|
},
|
|
37
38
|
pixel: `${base}/t/o/${id}`,
|
|
38
39
|
commit: () => reporting.recordLinks(id, urls)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"observer.cjs","names":[],"sources":["../../src/reporting/observer.ts"],"sourcesContent":["import type { JourneyObserver, OutboundMessage } from '../engine';\nimport type { JourneyReporting } from '../store';\n\n/**\n * The observer journeyWorker installs when the store keeps reporting: every node arrival, copy\n * and outcome recorded through `store.reporting`.\n *\n * A copy is recorded before it goes out, so its id can travel with it: with a tracking origin,\n * an email's links become `<origin>/t/
|
|
1
|
+
{"version":3,"file":"observer.cjs","names":[],"sources":["../../src/reporting/observer.ts"],"sourcesContent":["import type { JourneyObserver, OutboundMessage } from '../engine';\nimport type { JourneyReporting } from '../store';\n\n/**\n * The observer journeyWorker installs when the store keeps reporting: every node arrival, copy\n * and outcome recorded through `store.reporting`.\n *\n * A copy is recorded before it goes out, so its id can travel with it: with a tracking origin,\n * an email's links become `<origin>/t/l/<message>/<position>` — `/t/u/...` for its unsubscribe\n * link, so what a click means is fixed when it is sent — and an open pixel `<origin>/t/o/<message>`\n * (served by the Worker, ./tracking.ts), and a push carries\n * `message_id` in its payload for the app to report the tap (`JourneyClient.messageOpened`),\n * plus `delivered_url` / `opened_url` for a client that reports straight to the Worker.\n * The links are recorded when the sender commits the rewritten email, before it goes out.\n */\nexport function reportingObserver(\n reporting: JourneyReporting,\n origin: string | undefined\n): JourneyObserver {\n const base = origin?.replace(/\\/$/, '');\n\n return {\n started: (event) => reporting.instanceStarted(event),\n\n nodeReached: (event) => reporting.nodeReached(event),\n\n async messagePrepared(message) {\n const id = await reporting.recordMessage({\n idempotencyKey: message.idempotencyKey,\n workflow: message.workflow,\n instanceId: message.instanceId,\n nodeId: message.nodeId,\n template: message.template,\n channel: channelOf(message),\n userId: message.userId,\n sentAt: Date.now(),\n });\n if (message.channel === 'email' && base !== undefined) {\n const urls: string[] = [];\n return {\n link: (url, purpose) => {\n urls.push(url);\n return `${base}/t/${purpose === 'unsubscribe' ? 'u' : 'l'}/${id}/${urls.length - 1}`;\n },\n pixel: `${base}/t/o/${id}`,\n // Before the transport takes it, so no click can reach a link not yet recorded\n commit: () => reporting.recordLinks(id, urls),\n };\n }\n if (message.channel === 'push') {\n // The app reports the tap with `message_id`; a client that can reach the Worker on its\n // own (iOS's notification service extension, a browser's service worker) posts to these\n return {\n data: {\n message_id: id,\n ...(base === undefined\n ? {}\n : { delivered_url: `${base}/t/d/${id}`, opened_url: `${base}/t/p/${id}` }),\n },\n };\n }\n return undefined;\n },\n\n async messageSent(message, result) {\n await reporting.settleMessage(message.idempotencyKey, result, Date.now());\n },\n\n finished: (event) => reporting.instanceFinished(event),\n };\n}\n\n/** Where the copy lands — the Studio's metric channel: a push is reported per platform. */\nfunction channelOf(message: OutboundMessage): string {\n if (message.channel !== 'push') return message.channel;\n return `push_${message.device?.platform ?? 'unknown'}`;\n}\n"],"mappings":";;;;;;;;;;;;;;AAeA,SAAgB,kBACd,WACA,QACiB;CACjB,MAAM,OAAO,QAAQ,QAAQ,OAAO,EAAE;CAEtC,OAAO;EACL,UAAU,UAAU,UAAU,gBAAgB,KAAK;EAEnD,cAAc,UAAU,UAAU,YAAY,KAAK;EAEnD,MAAM,gBAAgB,SAAS;GAC7B,MAAM,KAAK,MAAM,UAAU,cAAc;IACvC,gBAAgB,QAAQ;IACxB,UAAU,QAAQ;IAClB,YAAY,QAAQ;IACpB,QAAQ,QAAQ;IAChB,UAAU,QAAQ;IAClB,SAAS,UAAU,OAAO;IAC1B,QAAQ,QAAQ;IAChB,QAAQ,KAAK,IAAI;GACnB,CAAC;GACD,IAAI,QAAQ,YAAY,WAAW,SAAS,KAAA,GAAW;IACrD,MAAM,OAAiB,CAAC;IACxB,OAAO;KACL,OAAO,KAAK,YAAY;MACtB,KAAK,KAAK,GAAG;MACb,OAAO,GAAG,KAAK,KAAK,YAAY,gBAAgB,MAAM,IAAI,GAAG,GAAG,GAAG,KAAK,SAAS;KACnF;KACA,OAAO,GAAG,KAAK,OAAO;KAEtB,cAAc,UAAU,YAAY,IAAI,IAAI;IAC9C;GACF;GACA,IAAI,QAAQ,YAAY,QAGtB,OAAO,EACL,MAAM;IACJ,YAAY;IACZ,GAAI,SAAS,KAAA,IACT,CAAC,IACD;KAAE,eAAe,GAAG,KAAK,OAAO;KAAM,YAAY,GAAG,KAAK,OAAO;IAAK;GAC5E,EACF;EAGJ;EAEA,MAAM,YAAY,SAAS,QAAQ;GACjC,MAAM,UAAU,cAAc,QAAQ,gBAAgB,QAAQ,KAAK,IAAI,CAAC;EAC1E;EAEA,WAAW,UAAU,UAAU,iBAAiB,KAAK;CACvD;AACF;;AAGA,SAAS,UAAU,SAAkC;CACnD,IAAI,QAAQ,YAAY,QAAQ,OAAO,QAAQ;CAC/C,OAAO,QAAQ,QAAQ,QAAQ,YAAY;AAC7C"}
|
|
@@ -7,8 +7,9 @@ import { JourneyReporting } from "../store/index.cjs";
|
|
|
7
7
|
* and outcome recorded through `store.reporting`.
|
|
8
8
|
*
|
|
9
9
|
* A copy is recorded before it goes out, so its id can travel with it: with a tracking origin,
|
|
10
|
-
* an email's links become `<origin>/t/
|
|
11
|
-
*
|
|
10
|
+
* an email's links become `<origin>/t/l/<message>/<position>` — `/t/u/...` for its unsubscribe
|
|
11
|
+
* link, so what a click means is fixed when it is sent — and an open pixel `<origin>/t/o/<message>`
|
|
12
|
+
* (served by the Worker, ./tracking.ts), and a push carries
|
|
12
13
|
* `message_id` in its payload for the app to report the tap (`JourneyClient.messageOpened`),
|
|
13
14
|
* plus `delivered_url` / `opened_url` for a client that reports straight to the Worker.
|
|
14
15
|
* The links are recorded when the sender commits the rewritten email, before it goes out.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"observer.d.cts","names":[],"sources":["../../src/reporting/observer.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"observer.d.cts","names":[],"sources":["../../src/reporting/observer.ts"],"mappings":";;;;;;;;;;;;;;;;wBAegB,kBACd,WAAW,kBACX,6BACC"}
|
|
@@ -7,8 +7,9 @@ import { JourneyReporting } from "../store/index.mjs";
|
|
|
7
7
|
* and outcome recorded through `store.reporting`.
|
|
8
8
|
*
|
|
9
9
|
* A copy is recorded before it goes out, so its id can travel with it: with a tracking origin,
|
|
10
|
-
* an email's links become `<origin>/t/
|
|
11
|
-
*
|
|
10
|
+
* an email's links become `<origin>/t/l/<message>/<position>` — `/t/u/...` for its unsubscribe
|
|
11
|
+
* link, so what a click means is fixed when it is sent — and an open pixel `<origin>/t/o/<message>`
|
|
12
|
+
* (served by the Worker, ./tracking.ts), and a push carries
|
|
12
13
|
* `message_id` in its payload for the app to report the tap (`JourneyClient.messageOpened`),
|
|
13
14
|
* plus `delivered_url` / `opened_url` for a client that reports straight to the Worker.
|
|
14
15
|
* The links are recorded when the sender commits the rewritten email, before it goes out.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"observer.d.mts","names":[],"sources":["../../src/reporting/observer.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"observer.d.mts","names":[],"sources":["../../src/reporting/observer.ts"],"mappings":";;;;;;;;;;;;;;;;wBAegB,kBACd,WAAW,kBACX,6BACC"}
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* and outcome recorded through `store.reporting`.
|
|
5
5
|
*
|
|
6
6
|
* A copy is recorded before it goes out, so its id can travel with it: with a tracking origin,
|
|
7
|
-
* an email's links become `<origin>/t/
|
|
8
|
-
*
|
|
7
|
+
* an email's links become `<origin>/t/l/<message>/<position>` — `/t/u/...` for its unsubscribe
|
|
8
|
+
* link, so what a click means is fixed when it is sent — and an open pixel `<origin>/t/o/<message>`
|
|
9
|
+
* (served by the Worker, ./tracking.ts), and a push carries
|
|
9
10
|
* `message_id` in its payload for the app to report the tap (`JourneyClient.messageOpened`),
|
|
10
11
|
* plus `delivered_url` / `opened_url` for a client that reports straight to the Worker.
|
|
11
12
|
* The links are recorded when the sender commits the rewritten email, before it goes out.
|
|
@@ -29,9 +30,9 @@ function reportingObserver(reporting, origin) {
|
|
|
29
30
|
if (message.channel === "email" && base !== void 0) {
|
|
30
31
|
const urls = [];
|
|
31
32
|
return {
|
|
32
|
-
link: (url) => {
|
|
33
|
+
link: (url, purpose) => {
|
|
33
34
|
urls.push(url);
|
|
34
|
-
return `${base}/t
|
|
35
|
+
return `${base}/t/${purpose === "unsubscribe" ? "u" : "l"}/${id}/${urls.length - 1}`;
|
|
35
36
|
},
|
|
36
37
|
pixel: `${base}/t/o/${id}`,
|
|
37
38
|
commit: () => reporting.recordLinks(id, urls)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"observer.mjs","names":[],"sources":["../../src/reporting/observer.ts"],"sourcesContent":["import type { JourneyObserver, OutboundMessage } from '../engine';\nimport type { JourneyReporting } from '../store';\n\n/**\n * The observer journeyWorker installs when the store keeps reporting: every node arrival, copy\n * and outcome recorded through `store.reporting`.\n *\n * A copy is recorded before it goes out, so its id can travel with it: with a tracking origin,\n * an email's links become `<origin>/t/
|
|
1
|
+
{"version":3,"file":"observer.mjs","names":[],"sources":["../../src/reporting/observer.ts"],"sourcesContent":["import type { JourneyObserver, OutboundMessage } from '../engine';\nimport type { JourneyReporting } from '../store';\n\n/**\n * The observer journeyWorker installs when the store keeps reporting: every node arrival, copy\n * and outcome recorded through `store.reporting`.\n *\n * A copy is recorded before it goes out, so its id can travel with it: with a tracking origin,\n * an email's links become `<origin>/t/l/<message>/<position>` — `/t/u/...` for its unsubscribe\n * link, so what a click means is fixed when it is sent — and an open pixel `<origin>/t/o/<message>`\n * (served by the Worker, ./tracking.ts), and a push carries\n * `message_id` in its payload for the app to report the tap (`JourneyClient.messageOpened`),\n * plus `delivered_url` / `opened_url` for a client that reports straight to the Worker.\n * The links are recorded when the sender commits the rewritten email, before it goes out.\n */\nexport function reportingObserver(\n reporting: JourneyReporting,\n origin: string | undefined\n): JourneyObserver {\n const base = origin?.replace(/\\/$/, '');\n\n return {\n started: (event) => reporting.instanceStarted(event),\n\n nodeReached: (event) => reporting.nodeReached(event),\n\n async messagePrepared(message) {\n const id = await reporting.recordMessage({\n idempotencyKey: message.idempotencyKey,\n workflow: message.workflow,\n instanceId: message.instanceId,\n nodeId: message.nodeId,\n template: message.template,\n channel: channelOf(message),\n userId: message.userId,\n sentAt: Date.now(),\n });\n if (message.channel === 'email' && base !== undefined) {\n const urls: string[] = [];\n return {\n link: (url, purpose) => {\n urls.push(url);\n return `${base}/t/${purpose === 'unsubscribe' ? 'u' : 'l'}/${id}/${urls.length - 1}`;\n },\n pixel: `${base}/t/o/${id}`,\n // Before the transport takes it, so no click can reach a link not yet recorded\n commit: () => reporting.recordLinks(id, urls),\n };\n }\n if (message.channel === 'push') {\n // The app reports the tap with `message_id`; a client that can reach the Worker on its\n // own (iOS's notification service extension, a browser's service worker) posts to these\n return {\n data: {\n message_id: id,\n ...(base === undefined\n ? {}\n : { delivered_url: `${base}/t/d/${id}`, opened_url: `${base}/t/p/${id}` }),\n },\n };\n }\n return undefined;\n },\n\n async messageSent(message, result) {\n await reporting.settleMessage(message.idempotencyKey, result, Date.now());\n },\n\n finished: (event) => reporting.instanceFinished(event),\n };\n}\n\n/** Where the copy lands — the Studio's metric channel: a push is reported per platform. */\nfunction channelOf(message: OutboundMessage): string {\n if (message.channel !== 'push') return message.channel;\n return `push_${message.device?.platform ?? 'unknown'}`;\n}\n"],"mappings":";;;;;;;;;;;;;AAeA,SAAgB,kBACd,WACA,QACiB;CACjB,MAAM,OAAO,QAAQ,QAAQ,OAAO,EAAE;CAEtC,OAAO;EACL,UAAU,UAAU,UAAU,gBAAgB,KAAK;EAEnD,cAAc,UAAU,UAAU,YAAY,KAAK;EAEnD,MAAM,gBAAgB,SAAS;GAC7B,MAAM,KAAK,MAAM,UAAU,cAAc;IACvC,gBAAgB,QAAQ;IACxB,UAAU,QAAQ;IAClB,YAAY,QAAQ;IACpB,QAAQ,QAAQ;IAChB,UAAU,QAAQ;IAClB,SAAS,UAAU,OAAO;IAC1B,QAAQ,QAAQ;IAChB,QAAQ,KAAK,IAAI;GACnB,CAAC;GACD,IAAI,QAAQ,YAAY,WAAW,SAAS,KAAA,GAAW;IACrD,MAAM,OAAiB,CAAC;IACxB,OAAO;KACL,OAAO,KAAK,YAAY;MACtB,KAAK,KAAK,GAAG;MACb,OAAO,GAAG,KAAK,KAAK,YAAY,gBAAgB,MAAM,IAAI,GAAG,GAAG,GAAG,KAAK,SAAS;KACnF;KACA,OAAO,GAAG,KAAK,OAAO;KAEtB,cAAc,UAAU,YAAY,IAAI,IAAI;IAC9C;GACF;GACA,IAAI,QAAQ,YAAY,QAGtB,OAAO,EACL,MAAM;IACJ,YAAY;IACZ,GAAI,SAAS,KAAA,IACT,CAAC,IACD;KAAE,eAAe,GAAG,KAAK,OAAO;KAAM,YAAY,GAAG,KAAK,OAAO;IAAK;GAC5E,EACF;EAGJ;EAEA,MAAM,YAAY,SAAS,QAAQ;GACjC,MAAM,UAAU,cAAc,QAAQ,gBAAgB,QAAQ,KAAK,IAAI,CAAC;EAC1E;EAEA,WAAW,UAAU,UAAU,iBAAiB,KAAK;CACvD;AACF;;AAGA,SAAS,UAAU,SAAkC;CACnD,IAAI,QAAQ,YAAY,QAAQ,OAAO,QAAQ;CAC/C,OAAO,QAAQ,QAAQ,QAAQ,YAAY;AAC7C"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_email = require("../email.cjs");
|
|
2
3
|
const require_reporting_classify = require("./classify.cjs");
|
|
3
4
|
//#region src/reporting/tracking.ts
|
|
4
5
|
const GIF = Uint8Array.from(atob("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"), (char) => char.charCodeAt(0));
|
|
@@ -15,7 +16,11 @@ function requester(request) {
|
|
|
15
16
|
}
|
|
16
17
|
/** A `/t/...` request's response, or undefined when the path is not a tracking route. */
|
|
17
18
|
async function handleTracking(request, reporting, options = {}) {
|
|
18
|
-
const
|
|
19
|
+
const path = new URL(request.url).pathname.split("/");
|
|
20
|
+
const root = path.at(1);
|
|
21
|
+
const kind = path.at(2);
|
|
22
|
+
const id = path.at(3);
|
|
23
|
+
const position = path.at(4);
|
|
19
24
|
if (root !== "t" || id === void 0 || !UUID.test(id)) return void 0;
|
|
20
25
|
const who = requester(request);
|
|
21
26
|
const read = who.method === "GET" || who.method === "HEAD";
|
|
@@ -26,10 +31,10 @@ async function handleTracking(request, reporting, options = {}) {
|
|
|
26
31
|
"cache-control": "no-store, max-age=0"
|
|
27
32
|
} });
|
|
28
33
|
}
|
|
29
|
-
if (kind === "c" && position !== void 0 && read) {
|
|
34
|
+
if ((kind === "l" || kind === "u" || kind === "c") && position !== void 0 && read) {
|
|
30
35
|
const target = await recordClick(reporting, id, Number(position), who, options);
|
|
31
36
|
if (target === void 0) return page(options, "This link has expired.", 404);
|
|
32
|
-
if (
|
|
37
|
+
if (kind === "u" || kind === "c" && require_email.isUnsubscribeLink(target, options.legacyUnsubscribeUrl)) return page(options, `Unsubscribe from ${escape(options.product ?? "these")} emails?`, 200, `<form method="post" action="/t/u/${id}"><button type="submit">Unsubscribe</button></form>`);
|
|
33
38
|
return Response.redirect(target, 302);
|
|
34
39
|
}
|
|
35
40
|
if (kind === "d" && who.method === "POST") {
|
|
@@ -126,16 +131,6 @@ async function recordClick(reporting, id, position, who, options) {
|
|
|
126
131
|
};
|
|
127
132
|
}))?.url;
|
|
128
133
|
}
|
|
129
|
-
/** The unsubscribe link may carry its own query (a token, a list): the path names it. */
|
|
130
|
-
function sameLink(target, unsubscribe) {
|
|
131
|
-
try {
|
|
132
|
-
const a = new URL(target);
|
|
133
|
-
const b = new URL(unsubscribe);
|
|
134
|
-
return a.origin === b.origin && a.pathname === b.pathname;
|
|
135
|
-
} catch {
|
|
136
|
-
return false;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
134
|
function escape(text) {
|
|
140
135
|
return text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
141
136
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tracking.cjs","names":["classifyOpen","BURST_WINDOW_MS","classifyClick"],"sources":["../../src/reporting/tracking.ts"],"sourcesContent":["import type { JourneyReporting } from '../store';\nimport { BURST_WINDOW_MS, classifyClick, classifyOpen } from './classify';\n\n/**\n * The endpoints a journey email's tracking points at (see ./observer.ts), served by journeyWorker\n * without a bearer — recipients open them:\n *\n * GET /t/o/<message> the open pixel: records the open, answers a 1×1 GIF\n * GET /t/c/<message>/<position> a link: records the click, redirects to the recorded URL\n * POST /t/u/<message> the unsubscribe confirmation\n * POST /t/d/<message> a push the device received (iOS's notification service\n * extension, a browser's service worker)\n * POST /t/p/<message> a push opened, reported by a browser's service worker\n *\n * Every open and click is recorded with its verdict (./classify.ts), and the message keeps its\n * first open and click of each kind. The redirect goes only to the URL recorded for that position,\n * never to one the request carries, so the endpoint cannot bounce visitors anywhere else. The\n * config's unsubscribe link (`emails.unsubscribe`) is a tracked link too: following it shows a\n * confirmation page, and only the confirmation — a POST, which no link scanner sends — counts.\n */\n\nexport interface TrackingOptions {\n /** `emails.unsubscribe` from the config: the footer link that asks for confirmation instead of redirecting. */\n unsubscribe?: string | undefined;\n /** The name the confirmation pages show (the config's `title`). */\n product?: string | undefined;\n /** `emails.tracking.quickClickMs`: how soon after delivery an open or click is a weak machine signal. */\n quickMs?: number | undefined;\n /** A browser reported a push tapped (`/t/p`): the host records it as an event too (`webPush.openedEvent`). */\n pushOpened?:\n | ((open: { messageId: string; userId: string; at: number }) => Promise<void>)\n | undefined;\n}\n\nconst GIF = Uint8Array.from(\n atob('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'),\n (char) => char.charCodeAt(0)\n);\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\ninterface Requester {\n method: string;\n userAgent: string | null;\n asn: number | null;\n organization: string | null;\n country: string | null;\n}\n\nfunction requester(request: Request): Requester {\n const cf = (\n request as Request & { cf?: { asn?: number; asOrganization?: string; country?: string } }\n ).cf;\n return {\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n asn: cf?.asn ?? null,\n organization: cf?.asOrganization ?? null,\n country: cf?.country ?? null,\n };\n}\n\n/** A `/t/...` request's response, or undefined when the path is not a tracking route. */\nexport async function handleTracking(\n request: Request,\n reporting: JourneyReporting,\n options: TrackingOptions = {}\n): Promise<Response | undefined> {\n const url = new URL(request.url);\n const [, root, kind, id, position] = url.pathname.split('/');\n if (root !== 't' || id === undefined || !UUID.test(id)) return undefined;\n const who = requester(request);\n const read = who.method === 'GET' || who.method === 'HEAD';\n if (kind === 'o' && read) {\n await recordOpen(reporting, id, who, options);\n return new Response(who.method === 'HEAD' ? null : GIF, {\n headers: { 'content-type': 'image/gif', 'cache-control': 'no-store, max-age=0' },\n });\n }\n if (kind === 'c' && position !== undefined && read) {\n const target = await recordClick(reporting, id, Number(position), who, options);\n if (target === undefined) return page(options, 'This link has expired.', 404);\n if (options.unsubscribe !== undefined && sameLink(target, options.unsubscribe)) {\n return page(\n options,\n `Unsubscribe from ${escape(options.product ?? 'these')} emails?`,\n 200,\n `<form method=\"post\" action=\"/t/u/${id}\"><button type=\"submit\">Unsubscribe</button></form>`\n );\n }\n return Response.redirect(target, 302);\n }\n if (kind === 'd' && who.method === 'POST') {\n const known = await reporting.recordDeviceDelivered(id, Date.now());\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'p' && who.method === 'POST') {\n // A notification tap: a person, as the message id is what proves it is theirs\n const at = Date.now();\n let userId: string | undefined;\n const known = await reporting.recordOpen(id, (message) => {\n userId = message.userId;\n return { at, machine: false, reason: null, ...network(who) };\n });\n if (known && userId !== undefined && options.pushOpened !== undefined) {\n try {\n await options.pushOpened({ messageId: id, userId, at });\n } catch (error) {\n // The open is recorded; the event is the host's extra, never a reason to fail the beacon\n console.error(`[workflow] recording push open ${id} as an event failed`, error);\n }\n }\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'u' && who.method === 'POST') {\n const known = await reporting.recordUnsubscribe(id, Date.now());\n if (!known) return page(options, 'This link has expired.', 404);\n return page(\n options,\n options.product === undefined\n ? \"You won't get these emails anymore.\"\n : `You won't get these emails from ${escape(options.product)} anymore.`\n );\n }\n return undefined;\n}\n\n/**\n * A journey push tapped open, as the app reports it (`JourneyClient.messageOpened`): an open on\n * the copy — only the recipient's own, and never a machine, since no proxy or scanner taps a\n * notification. False when the message is unknown or not the user's.\n */\nexport async function recordPushOpen(\n reporting: JourneyReporting,\n userId: string,\n messageId: string,\n at = Date.now()\n): Promise<boolean> {\n if (!UUID.test(messageId)) return false;\n let recorded = false;\n await reporting.recordOpen(messageId, (message) => {\n if (message.userId !== userId) return undefined;\n recorded = true;\n return {\n at,\n machine: false,\n reason: null,\n userAgent: null,\n asn: null,\n organization: null,\n country: null,\n };\n });\n return recorded;\n}\n\nasync function recordOpen(\n reporting: JourneyReporting,\n id: string,\n who: Requester,\n options: TrackingOptions\n) {\n const at = Date.now();\n await reporting.recordOpen(id, (message) => ({\n at,\n ...classifyOpen(\n { ...who, sinceDeliveredMs: at - message.deliveredAt },\n { quickMs: options.quickMs }\n ),\n ...network(who),\n }));\n}\n\nconst network = (who: Requester) => ({\n userAgent: who.userAgent,\n asn: who.asn,\n organization: who.organization,\n country: who.country,\n});\n\n/** Records the click and answers the URL to send the visitor to, or undefined for an unknown link. */\nasync function recordClick(\n reporting: JourneyReporting,\n id: string,\n position: number,\n who: Requester,\n options: TrackingOptions\n): Promise<string | undefined> {\n if (!Number.isInteger(position) || position < 0) return undefined;\n const at = Date.now();\n const recorded = await reporting.recordClick(id, position, at, BURST_WINDOW_MS, (link) => {\n const linksInBurst = new Set([position, ...link.recent.map((click) => click.position)]).size;\n const verdict = classifyClick(\n { ...who, sinceDeliveredMs: at - link.deliveredAt, linksInBurst, linkCount: link.linkCount },\n { quickMs: options.quickMs }\n );\n return {\n engagement: { at, ...verdict, ...network(who) },\n backdate: verdict.reason === 'link_burst' || verdict.reason === 'quick+link_pair',\n };\n });\n return recorded?.url;\n}\n\n/** The unsubscribe link may carry its own query (a token, a list): the path names it. */\nfunction sameLink(target: string, unsubscribe: string): boolean {\n try {\n const a = new URL(target);\n const b = new URL(unsubscribe);\n return a.origin === b.origin && a.pathname === b.pathname;\n } catch {\n return false;\n }\n}\n\nfunction escape(text: string): string {\n return text.replace(/[&<>\"']/g, (c) => `&#${c.charCodeAt(0)};`);\n}\n\nfunction page(options: TrackingOptions, message: string, status = 200, extra = ''): Response {\n const title = escape(options.product ?? 'Email');\n const html = `<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>${title}</title><style>body{font:16px system-ui;margin:0;display:grid;place-items:center;min-height:100vh;background:#fafafa;color:#18181b}main{text-align:center;padding:24px}button{font:inherit;padding:10px 20px;border-radius:9999px;border:0;background:#18181b;color:#fff;cursor:pointer}</style></head><body><main><p>${message}</p>${extra}</main></body></html>`;\n return new Response(html, { status, headers: { 'content-type': 'text/html; charset=utf-8' } });\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"tracking.cjs","names":["isUnsubscribeLink","classifyOpen","BURST_WINDOW_MS","classifyClick"],"sources":["../../src/reporting/tracking.ts"],"sourcesContent":["import { isUnsubscribeLink } from '../email';\nimport type { JourneyReporting } from '../store';\nimport { BURST_WINDOW_MS, classifyClick, classifyOpen } from './classify';\n\n/**\n * The endpoints a journey email's tracking points at (see ./observer.ts), served by journeyWorker\n * without a bearer — recipients open them:\n *\n * GET /t/o/<message> the open pixel: records the open, answers a 1×1 GIF\n * GET /t/l/<message>/<position> a link: records the click, redirects to the recorded URL\n * GET /t/u/<message>/<position> the unsubscribe link: records the click, asks to confirm\n * POST /t/u/<message> the unsubscribe confirmation\n * GET /t/c/<message>/<position> a link of an email sent before links carried their purpose\n * POST /t/d/<message> a push the device received (iOS's notification service\n * extension, a browser's service worker)\n * POST /t/p/<message> a push opened, reported by a browser's service worker\n *\n * Every open and click is recorded with its verdict (./classify.ts), and the message keeps its\n * first open and click of each kind. The redirect goes only to the URL recorded for that position,\n * never to one the request carries, so the endpoint cannot bounce visitors anywhere else. An\n * email's unsubscribe link is a tracked link too, and which link it is was fixed when the email was\n * sent (its route): following it shows a confirmation page, and only the confirmation — a POST,\n * which no link scanner sends — counts. Changing a template's or the config's unsubscribe URL, or\n * removing the template, changes nothing for emails already sent.\n *\n * Emails sent before that carry `/t/c/` links, which say nothing of their purpose: one is the\n * unsubscribe link when its recorded URL is `legacyUnsubscribeUrl` (the global unsubscribe URL\n * those emails were all sent with), as it was then.\n */\n\nexport interface TrackingOptions {\n /**\n * The unsubscribe URL emails sent with `/t/c/` links carried (the config's `email.unsubscribeUrl`\n * by default): following such a link to it asks for confirmation instead of redirecting.\n */\n legacyUnsubscribeUrl?: string | undefined;\n /** The name the confirmation pages show (the config's `title`). */\n product?: string | undefined;\n /** `email.tracking.quickClickMs`: how soon after delivery an open or click is a weak machine signal. */\n quickMs?: number | undefined;\n /** A browser reported a push tapped (`/t/p`): the host records it as an event too (`webPush.openedEvent`). */\n pushOpened?:\n | ((open: { messageId: string; userId: string; at: number }) => Promise<void>)\n | undefined;\n}\n\nconst GIF = Uint8Array.from(\n atob('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'),\n (char) => char.charCodeAt(0)\n);\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\ninterface Requester {\n method: string;\n userAgent: string | null;\n asn: number | null;\n organization: string | null;\n country: string | null;\n}\n\nfunction requester(request: Request): Requester {\n const cf = (\n request as Request & { cf?: { asn?: number; asOrganization?: string; country?: string } }\n ).cf;\n return {\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n asn: cf?.asn ?? null,\n organization: cf?.asOrganization ?? null,\n country: cf?.country ?? null,\n };\n}\n\n/** A `/t/...` request's response, or undefined when the path is not a tracking route. */\nexport async function handleTracking(\n request: Request,\n reporting: JourneyReporting,\n options: TrackingOptions = {}\n): Promise<Response | undefined> {\n const url = new URL(request.url);\n const path = url.pathname.split('/');\n const root = path.at(1);\n const kind = path.at(2);\n const id = path.at(3);\n const position = path.at(4);\n if (root !== 't' || id === undefined || !UUID.test(id)) return undefined;\n const who = requester(request);\n const read = who.method === 'GET' || who.method === 'HEAD';\n if (kind === 'o' && read) {\n await recordOpen(reporting, id, who, options);\n return new Response(who.method === 'HEAD' ? null : GIF, {\n headers: { 'content-type': 'image/gif', 'cache-control': 'no-store, max-age=0' },\n });\n }\n if ((kind === 'l' || kind === 'u' || kind === 'c') && position !== undefined && read) {\n const target = await recordClick(reporting, id, Number(position), who, options);\n if (target === undefined) return page(options, 'This link has expired.', 404);\n const unsubscribe =\n kind === 'u' || (kind === 'c' && isUnsubscribeLink(target, options.legacyUnsubscribeUrl));\n if (unsubscribe) {\n return page(\n options,\n `Unsubscribe from ${escape(options.product ?? 'these')} emails?`,\n 200,\n `<form method=\"post\" action=\"/t/u/${id}\"><button type=\"submit\">Unsubscribe</button></form>`\n );\n }\n return Response.redirect(target, 302);\n }\n if (kind === 'd' && who.method === 'POST') {\n const known = await reporting.recordDeviceDelivered(id, Date.now());\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'p' && who.method === 'POST') {\n // A notification tap: a person, as the message id is what proves it is theirs\n const at = Date.now();\n let userId: string | undefined;\n const known = await reporting.recordOpen(id, (message) => {\n userId = message.userId;\n return { at, machine: false, reason: null, ...network(who) };\n });\n if (known && userId !== undefined && options.pushOpened !== undefined) {\n try {\n await options.pushOpened({ messageId: id, userId, at });\n } catch (error) {\n // The open is recorded; the event is the host's extra, never a reason to fail the beacon\n console.error(`[workflow] recording push open ${id} as an event failed`, error);\n }\n }\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'u' && who.method === 'POST') {\n const known = await reporting.recordUnsubscribe(id, Date.now());\n if (!known) return page(options, 'This link has expired.', 404);\n return page(\n options,\n options.product === undefined\n ? \"You won't get these emails anymore.\"\n : `You won't get these emails from ${escape(options.product)} anymore.`\n );\n }\n return undefined;\n}\n\n/**\n * A journey push tapped open, as the app reports it (`JourneyClient.messageOpened`): an open on\n * the copy — only the recipient's own, and never a machine, since no proxy or scanner taps a\n * notification. False when the message is unknown or not the user's.\n */\nexport async function recordPushOpen(\n reporting: JourneyReporting,\n userId: string,\n messageId: string,\n at = Date.now()\n): Promise<boolean> {\n if (!UUID.test(messageId)) return false;\n let recorded = false;\n await reporting.recordOpen(messageId, (message) => {\n if (message.userId !== userId) return undefined;\n recorded = true;\n return {\n at,\n machine: false,\n reason: null,\n userAgent: null,\n asn: null,\n organization: null,\n country: null,\n };\n });\n return recorded;\n}\n\nasync function recordOpen(\n reporting: JourneyReporting,\n id: string,\n who: Requester,\n options: TrackingOptions\n) {\n const at = Date.now();\n await reporting.recordOpen(id, (message) => ({\n at,\n ...classifyOpen(\n { ...who, sinceDeliveredMs: at - message.deliveredAt },\n { quickMs: options.quickMs }\n ),\n ...network(who),\n }));\n}\n\nconst network = (who: Requester) => ({\n userAgent: who.userAgent,\n asn: who.asn,\n organization: who.organization,\n country: who.country,\n});\n\n/** Records the click and answers the URL to send the visitor to, or undefined for an unknown link. */\nasync function recordClick(\n reporting: JourneyReporting,\n id: string,\n position: number,\n who: Requester,\n options: TrackingOptions\n): Promise<string | undefined> {\n if (!Number.isInteger(position) || position < 0) return undefined;\n const at = Date.now();\n const recorded = await reporting.recordClick(id, position, at, BURST_WINDOW_MS, (link) => {\n const linksInBurst = new Set([position, ...link.recent.map((click) => click.position)]).size;\n const verdict = classifyClick(\n { ...who, sinceDeliveredMs: at - link.deliveredAt, linksInBurst, linkCount: link.linkCount },\n { quickMs: options.quickMs }\n );\n return {\n engagement: { at, ...verdict, ...network(who) },\n backdate: verdict.reason === 'link_burst' || verdict.reason === 'quick+link_pair',\n };\n });\n return recorded?.url;\n}\n\nfunction escape(text: string): string {\n return text.replace(/[&<>\"']/g, (c) => `&#${c.charCodeAt(0)};`);\n}\n\nfunction page(options: TrackingOptions, message: string, status = 200, extra = ''): Response {\n const title = escape(options.product ?? 'Email');\n const html = `<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>${title}</title><style>body{font:16px system-ui;margin:0;display:grid;place-items:center;min-height:100vh;background:#fafafa;color:#18181b}main{text-align:center;padding:24px}button{font:inherit;padding:10px 20px;border-radius:9999px;border:0;background:#18181b;color:#fff;cursor:pointer}</style></head><body><main><p>${message}</p>${extra}</main></body></html>`;\n return new Response(html, { status, headers: { 'content-type': 'text/html; charset=utf-8' } });\n}\n"],"mappings":"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"}
|
|
@@ -5,24 +5,35 @@ import { JourneyReporting } from "../store/index.cjs";
|
|
|
5
5
|
* without a bearer — recipients open them:
|
|
6
6
|
*
|
|
7
7
|
* GET /t/o/<message> the open pixel: records the open, answers a 1×1 GIF
|
|
8
|
-
* GET /t/
|
|
8
|
+
* GET /t/l/<message>/<position> a link: records the click, redirects to the recorded URL
|
|
9
|
+
* GET /t/u/<message>/<position> the unsubscribe link: records the click, asks to confirm
|
|
9
10
|
* POST /t/u/<message> the unsubscribe confirmation
|
|
11
|
+
* GET /t/c/<message>/<position> a link of an email sent before links carried their purpose
|
|
10
12
|
* POST /t/d/<message> a push the device received (iOS's notification service
|
|
11
13
|
* extension, a browser's service worker)
|
|
12
14
|
* POST /t/p/<message> a push opened, reported by a browser's service worker
|
|
13
15
|
*
|
|
14
16
|
* Every open and click is recorded with its verdict (./classify.ts), and the message keeps its
|
|
15
17
|
* first open and click of each kind. The redirect goes only to the URL recorded for that position,
|
|
16
|
-
* never to one the request carries, so the endpoint cannot bounce visitors anywhere else.
|
|
17
|
-
*
|
|
18
|
-
* confirmation page, and only the confirmation — a POST,
|
|
18
|
+
* never to one the request carries, so the endpoint cannot bounce visitors anywhere else. An
|
|
19
|
+
* email's unsubscribe link is a tracked link too, and which link it is was fixed when the email was
|
|
20
|
+
* sent (its route): following it shows a confirmation page, and only the confirmation — a POST,
|
|
21
|
+
* which no link scanner sends — counts. Changing a template's or the config's unsubscribe URL, or
|
|
22
|
+
* removing the template, changes nothing for emails already sent.
|
|
23
|
+
*
|
|
24
|
+
* Emails sent before that carry `/t/c/` links, which say nothing of their purpose: one is the
|
|
25
|
+
* unsubscribe link when its recorded URL is `legacyUnsubscribeUrl` (the global unsubscribe URL
|
|
26
|
+
* those emails were all sent with), as it was then.
|
|
19
27
|
*/
|
|
20
28
|
export interface TrackingOptions {
|
|
21
|
-
/**
|
|
22
|
-
|
|
29
|
+
/**
|
|
30
|
+
* The unsubscribe URL emails sent with `/t/c/` links carried (the config's `email.unsubscribeUrl`
|
|
31
|
+
* by default): following such a link to it asks for confirmation instead of redirecting.
|
|
32
|
+
*/
|
|
33
|
+
legacyUnsubscribeUrl?: string | undefined;
|
|
23
34
|
/** The name the confirmation pages show (the config's `title`). */
|
|
24
35
|
product?: string | undefined;
|
|
25
|
-
/** `
|
|
36
|
+
/** `email.tracking.quickClickMs`: how soon after delivery an open or click is a weak machine signal. */
|
|
26
37
|
quickMs?: number | undefined;
|
|
27
38
|
/** A browser reported a push tapped (`/t/p`): the host records it as an event too (`webPush.openedEvent`). */
|
|
28
39
|
pushOpened?: ((open: {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tracking.d.cts","names":[],"sources":["../../src/reporting/tracking.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"tracking.d.cts","names":[],"sources":["../../src/reporting/tracking.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;iBA8BiB;;;;;EAKf;;EAEA;;EAEA;;EAEA,eACM;IAAQ;IAAmB;IAAgB;QAAiB;;;wBAgC9C,eACpB,SAAS,SACT,WAAW,kBACX,UAAS,kBACR,QAAQ;;;;;;wBAuEW,eACpB,WAAW,kBACX,gBACA,mBACA,cACC"}
|
|
@@ -5,24 +5,35 @@ import { JourneyReporting } from "../store/index.mjs";
|
|
|
5
5
|
* without a bearer — recipients open them:
|
|
6
6
|
*
|
|
7
7
|
* GET /t/o/<message> the open pixel: records the open, answers a 1×1 GIF
|
|
8
|
-
* GET /t/
|
|
8
|
+
* GET /t/l/<message>/<position> a link: records the click, redirects to the recorded URL
|
|
9
|
+
* GET /t/u/<message>/<position> the unsubscribe link: records the click, asks to confirm
|
|
9
10
|
* POST /t/u/<message> the unsubscribe confirmation
|
|
11
|
+
* GET /t/c/<message>/<position> a link of an email sent before links carried their purpose
|
|
10
12
|
* POST /t/d/<message> a push the device received (iOS's notification service
|
|
11
13
|
* extension, a browser's service worker)
|
|
12
14
|
* POST /t/p/<message> a push opened, reported by a browser's service worker
|
|
13
15
|
*
|
|
14
16
|
* Every open and click is recorded with its verdict (./classify.ts), and the message keeps its
|
|
15
17
|
* first open and click of each kind. The redirect goes only to the URL recorded for that position,
|
|
16
|
-
* never to one the request carries, so the endpoint cannot bounce visitors anywhere else.
|
|
17
|
-
*
|
|
18
|
-
* confirmation page, and only the confirmation — a POST,
|
|
18
|
+
* never to one the request carries, so the endpoint cannot bounce visitors anywhere else. An
|
|
19
|
+
* email's unsubscribe link is a tracked link too, and which link it is was fixed when the email was
|
|
20
|
+
* sent (its route): following it shows a confirmation page, and only the confirmation — a POST,
|
|
21
|
+
* which no link scanner sends — counts. Changing a template's or the config's unsubscribe URL, or
|
|
22
|
+
* removing the template, changes nothing for emails already sent.
|
|
23
|
+
*
|
|
24
|
+
* Emails sent before that carry `/t/c/` links, which say nothing of their purpose: one is the
|
|
25
|
+
* unsubscribe link when its recorded URL is `legacyUnsubscribeUrl` (the global unsubscribe URL
|
|
26
|
+
* those emails were all sent with), as it was then.
|
|
19
27
|
*/
|
|
20
28
|
export interface TrackingOptions {
|
|
21
|
-
/**
|
|
22
|
-
|
|
29
|
+
/**
|
|
30
|
+
* The unsubscribe URL emails sent with `/t/c/` links carried (the config's `email.unsubscribeUrl`
|
|
31
|
+
* by default): following such a link to it asks for confirmation instead of redirecting.
|
|
32
|
+
*/
|
|
33
|
+
legacyUnsubscribeUrl?: string | undefined;
|
|
23
34
|
/** The name the confirmation pages show (the config's `title`). */
|
|
24
35
|
product?: string | undefined;
|
|
25
|
-
/** `
|
|
36
|
+
/** `email.tracking.quickClickMs`: how soon after delivery an open or click is a weak machine signal. */
|
|
26
37
|
quickMs?: number | undefined;
|
|
27
38
|
/** A browser reported a push tapped (`/t/p`): the host records it as an event too (`webPush.openedEvent`). */
|
|
28
39
|
pushOpened?: ((open: {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tracking.d.mts","names":[],"sources":["../../src/reporting/tracking.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"tracking.d.mts","names":[],"sources":["../../src/reporting/tracking.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;iBA8BiB;;;;;EAKf;;EAEA;;EAEA;;EAEA,eACM;IAAQ;IAAmB;IAAgB;QAAiB;;;wBAgC9C,eACpB,SAAS,SACT,WAAW,kBACX,UAAS,kBACR,QAAQ;;;;;;wBAuEW,eACpB,WAAW,kBACX,gBACA,mBACA,cACC"}
|