@shware/workflow 6.1.1 → 8.0.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 +132 -35
- package/dist/aws/engine-step.cjs +4 -1
- package/dist/aws/engine-step.cjs.map +1 -1
- package/dist/aws/engine-step.d.cts.map +1 -1
- package/dist/aws/engine-step.d.mts.map +1 -1
- package/dist/aws/engine-step.mjs +4 -1
- package/dist/aws/engine-step.mjs.map +1 -1
- package/dist/aws/index.cjs +3 -2
- package/dist/aws/index.cjs.map +1 -1
- package/dist/aws/index.mjs +3 -2
- package/dist/aws/index.mjs.map +1 -1
- package/dist/client.cjs +66 -41
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +72 -22
- package/dist/client.d.cts.map +1 -1
- package/dist/client.d.mts +72 -22
- package/dist/client.d.mts.map +1 -1
- package/dist/client.mjs +66 -42
- package/dist/client.mjs.map +1 -1
- package/dist/cloudflare/index.d.cts +4 -3
- package/dist/cloudflare/index.d.mts +4 -3
- package/dist/cloudflare/runner.cjs +3 -1
- package/dist/cloudflare/runner.cjs.map +1 -1
- package/dist/cloudflare/runner.d.cts.map +1 -1
- package/dist/cloudflare/runner.d.mts.map +1 -1
- package/dist/cloudflare/runner.mjs +3 -1
- package/dist/cloudflare/runner.mjs.map +1 -1
- package/dist/cloudflare/worker.cjs +13 -2
- package/dist/cloudflare/worker.cjs.map +1 -1
- package/dist/cloudflare/worker.d.cts +9 -1
- package/dist/cloudflare/worker.d.cts.map +1 -1
- package/dist/cloudflare/worker.d.mts +9 -1
- package/dist/cloudflare/worker.d.mts.map +1 -1
- package/dist/cloudflare/worker.mjs +13 -2
- package/dist/cloudflare/worker.mjs.map +1 -1
- package/dist/contract.cjs.map +1 -1
- package/dist/contract.d.cts +4 -4
- package/dist/contract.d.mts +4 -4
- package/dist/contract.mjs.map +1 -1
- package/dist/dsl/trigger.cjs +2 -1
- package/dist/dsl/trigger.cjs.map +1 -1
- package/dist/dsl/trigger.d.cts +6 -0
- package/dist/dsl/trigger.d.cts.map +1 -1
- package/dist/dsl/trigger.d.mts +6 -0
- package/dist/dsl/trigger.d.mts.map +1 -1
- package/dist/dsl/trigger.mjs +2 -1
- package/dist/dsl/trigger.mjs.map +1 -1
- package/dist/email-sender.cjs +8 -1
- package/dist/email-sender.cjs.map +1 -1
- package/dist/email-sender.d.cts +2 -2
- package/dist/email-sender.d.cts.map +1 -1
- package/dist/email-sender.d.mts +2 -2
- package/dist/email-sender.d.mts.map +1 -1
- package/dist/email-sender.mjs +9 -2
- package/dist/email-sender.mjs.map +1 -1
- package/dist/email.cjs +5 -0
- package/dist/email.cjs.map +1 -1
- package/dist/email.d.cts +2 -0
- package/dist/email.d.cts.map +1 -1
- package/dist/email.d.mts +2 -0
- package/dist/email.d.mts.map +1 -1
- package/dist/email.mjs +5 -1
- package/dist/email.mjs.map +1 -1
- package/dist/engine/condition.cjs +7 -5
- package/dist/engine/condition.cjs.map +1 -1
- package/dist/engine/condition.d.cts +3 -3
- package/dist/engine/condition.d.cts.map +1 -1
- package/dist/engine/condition.d.mts +3 -3
- package/dist/engine/condition.d.mts.map +1 -1
- package/dist/engine/condition.mjs +7 -5
- package/dist/engine/condition.mjs.map +1 -1
- package/dist/engine/interpreter.cjs +11 -6
- 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 +11 -6
- package/dist/engine/interpreter.mjs.map +1 -1
- package/dist/engine/ports.d.cts +21 -3
- package/dist/engine/ports.d.cts.map +1 -1
- package/dist/engine/ports.d.mts +21 -3
- package/dist/engine/ports.d.mts.map +1 -1
- package/dist/host/batch.cjs +172 -0
- package/dist/host/batch.cjs.map +1 -0
- package/dist/host/batch.d.cts +88 -0
- package/dist/host/batch.d.cts.map +1 -0
- package/dist/host/batch.d.mts +88 -0
- package/dist/host/batch.d.mts.map +1 -0
- package/dist/host/batch.mjs +163 -0
- package/dist/host/batch.mjs.map +1 -0
- package/dist/host/execute.cjs +10 -6
- package/dist/host/execute.cjs.map +1 -1
- package/dist/host/execute.mjs +10 -6
- package/dist/host/execute.mjs.map +1 -1
- package/dist/host/http.cjs +31 -90
- package/dist/host/http.cjs.map +1 -1
- package/dist/host/http.d.cts +4 -2
- package/dist/host/http.d.cts.map +1 -1
- package/dist/host/http.d.mts +4 -2
- package/dist/host/http.d.mts.map +1 -1
- package/dist/host/http.mjs +25 -84
- package/dist/host/http.mjs.map +1 -1
- package/dist/host/ingest.cjs +164 -25
- package/dist/host/ingest.cjs.map +1 -1
- package/dist/host/ingest.d.cts +38 -8
- package/dist/host/ingest.d.cts.map +1 -1
- package/dist/host/ingest.d.mts +38 -8
- package/dist/host/ingest.d.mts.map +1 -1
- package/dist/host/ingest.mjs +161 -26
- package/dist/host/ingest.mjs.map +1 -1
- package/dist/host/journey-host.cjs +8 -1
- package/dist/host/journey-host.cjs.map +1 -1
- package/dist/host/journey-host.d.cts +10 -4
- package/dist/host/journey-host.d.cts.map +1 -1
- package/dist/host/journey-host.d.mts +10 -4
- package/dist/host/journey-host.d.mts.map +1 -1
- package/dist/host/journey-host.mjs +8 -1
- package/dist/host/journey-host.mjs.map +1 -1
- package/dist/host/platform.cjs.map +1 -1
- package/dist/host/platform.d.cts +2 -0
- package/dist/host/platform.d.cts.map +1 -1
- package/dist/host/platform.d.mts +2 -0
- package/dist/host/platform.d.mts.map +1 -1
- package/dist/host/platform.mjs.map +1 -1
- package/dist/index.cjs +1 -0
- package/dist/index.d.cts +2 -2
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +2 -2
- package/dist/ir.cjs +2 -1
- package/dist/ir.cjs.map +1 -1
- package/dist/ir.d.cts +3 -0
- package/dist/ir.d.cts.map +1 -1
- package/dist/ir.d.mts +3 -0
- package/dist/ir.d.mts.map +1 -1
- package/dist/ir.mjs +2 -1
- package/dist/ir.mjs.map +1 -1
- package/dist/reporting/observer.cjs +1 -0
- package/dist/reporting/observer.cjs.map +1 -1
- package/dist/reporting/observer.mjs +1 -0
- package/dist/reporting/observer.mjs.map +1 -1
- package/dist/store/facts.cjs +29 -9
- package/dist/store/facts.cjs.map +1 -1
- package/dist/store/facts.d.cts +7 -2
- package/dist/store/facts.d.cts.map +1 -1
- package/dist/store/facts.d.mts +7 -2
- package/dist/store/facts.d.mts.map +1 -1
- package/dist/store/facts.mjs +29 -9
- package/dist/store/facts.mjs.map +1 -1
- package/dist/store/identity.cjs +34 -0
- package/dist/store/identity.cjs.map +1 -0
- package/dist/store/identity.d.cts +13 -0
- package/dist/store/identity.d.cts.map +1 -0
- package/dist/store/identity.d.mts +13 -0
- package/dist/store/identity.d.mts.map +1 -0
- package/dist/store/identity.mjs +30 -0
- package/dist/store/identity.mjs.map +1 -0
- package/dist/store/index.cjs +5 -0
- package/dist/store/index.cjs.map +1 -1
- package/dist/store/index.d.cts +119 -25
- package/dist/store/index.d.cts.map +1 -1
- package/dist/store/index.d.mts +119 -25
- package/dist/store/index.d.mts.map +1 -1
- package/dist/store/index.mjs +2 -1
- package/dist/store/index.mjs.map +1 -1
- package/dist/store/postgres-reporting.cjs +4 -0
- package/dist/store/postgres-reporting.cjs.map +1 -1
- package/dist/store/postgres-reporting.d.cts +1 -0
- package/dist/store/postgres-reporting.d.cts.map +1 -1
- package/dist/store/postgres-reporting.d.mts +1 -0
- package/dist/store/postgres-reporting.d.mts.map +1 -1
- package/dist/store/postgres-reporting.mjs +4 -0
- package/dist/store/postgres-reporting.mjs.map +1 -1
- package/dist/store/postgres.cjs +271 -55
- package/dist/store/postgres.cjs.map +1 -1
- package/dist/store/postgres.d.cts +29 -7
- package/dist/store/postgres.d.cts.map +1 -1
- package/dist/store/postgres.d.mts +29 -7
- package/dist/store/postgres.d.mts.map +1 -1
- package/dist/store/postgres.mjs +272 -56
- package/dist/store/postgres.mjs.map +1 -1
- package/dist/store/tables.cjs +28 -9
- package/dist/store/tables.cjs.map +1 -1
- package/dist/store/tables.d.cts +1 -0
- package/dist/store/tables.d.cts.map +1 -1
- package/dist/store/tables.d.mts +1 -0
- package/dist/store/tables.d.mts.map +1 -1
- package/dist/store/tables.mjs +28 -9
- package/dist/store/tables.mjs.map +1 -1
- package/dist/webpush.cjs.map +1 -1
- package/dist/webpush.d.cts +1 -1
- package/dist/webpush.d.mts +1 -1
- package/dist/webpush.mjs.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
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
|
|
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 isEmailAddress,\n isUnsubscribeLink,\n} from './email';\nimport type { MessageSender, MessageTracking, OutboundMessage, SendResult } 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 | SendResult> {\n if (message.channel !== 'email') {\n throw new Error(`EmailSender: unsupported channel '${message.channel}'`);\n }\n // A user with no usable address is a fact about that user, not a fault worth retrying: the\n // copy settles undelivered and the journey goes on, the way a push with no device is skipped.\n // Throwing here burned the step's retries and then failed the whole instance.\n if (message.recipient === undefined) return { delivered: false, reason: 'no_recipient' };\n if (!isEmailAddress(message.recipient)) {\n return { delivered: false, reason: 'invalid_recipient' };\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":";;;;;;;AA2EA,IAAa,cAAb,MAAkD;CACnB;CAA7B,YAAY,SAA8C;EAA7B,KAAA,UAAA;CAA8B;CAE3D,MAAM,KAAK,SAAsD;EAC/D,IAAI,QAAQ,YAAY,SACtB,MAAM,IAAI,MAAM,qCAAqC,QAAQ,QAAQ,EAAE;EAKzE,IAAI,QAAQ,cAAc,KAAA,GAAW,OAAO;GAAE,WAAW;GAAO,QAAQ;EAAe;EACvF,IAAI,CAAC,eAAe,QAAQ,SAAS,GACnC,OAAO;GAAE,WAAW;GAAO,QAAQ;EAAoB;EAEzD,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
|
@@ -54,6 +54,10 @@ function preferencesLink(preferencesUrl, messageId) {
|
|
|
54
54
|
return url.toString();
|
|
55
55
|
}
|
|
56
56
|
const ADDRESS = /^[^\s@<>]+@[^\s@<>]+$/;
|
|
57
|
+
/** Whether `value` is a bare `user@host` address — the shape a profile's email must have to be sent to. */
|
|
58
|
+
function isEmailAddress(value) {
|
|
59
|
+
return ADDRESS.test(value);
|
|
60
|
+
}
|
|
57
61
|
/** Throws naming `where` when `address` is neither `user@host` nor `{ email: 'user@host', name? }`. */
|
|
58
62
|
function assertEmailAddress(address, where) {
|
|
59
63
|
const email = typeof address === "object" && address !== null ? address.email : address;
|
|
@@ -139,6 +143,7 @@ exports.assertHttpUrl = assertHttpUrl;
|
|
|
139
143
|
exports.assertTemplateTopic = assertTemplateTopic;
|
|
140
144
|
exports.assertUnsubscribeUrl = assertUnsubscribeUrl;
|
|
141
145
|
exports.formatEmailAddress = formatEmailAddress;
|
|
146
|
+
exports.isEmailAddress = isEmailAddress;
|
|
142
147
|
exports.isUnsubscribeLink = isUnsubscribeLink;
|
|
143
148
|
exports.mimeAddress = mimeAddress;
|
|
144
149
|
exports.preferencesLink = preferencesLink;
|
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 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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"}
|
|
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/** Whether `value` is a bare `user@host` address — the shape a profile's email must have to be sent to. */\nexport function isEmailAddress(value: string): boolean {\n return ADDRESS.test(value);\n}\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
|
@@ -87,6 +87,8 @@ export declare const PREVIEW_MESSAGE_ID = "00000000-0000-0000-0000-000000000000"
|
|
|
87
87
|
export declare function resolveEmail(template: Pick<EmailTemplate, 'from' | 'replyTo' | 'unsubscribeUrl' | 'topic'>, config?: EmailConfig, options?: EmailRenderOptions): ResolvedEmail;
|
|
88
88
|
/** The preference page's URL for one copy: `m=<message id>` set on its query, the rest of it kept. */
|
|
89
89
|
export declare function preferencesLink(preferencesUrl: string, messageId: string): string;
|
|
90
|
+
/** Whether `value` is a bare `user@host` address — the shape a profile's email must have to be sent to. */
|
|
91
|
+
export declare function isEmailAddress(value: string): boolean;
|
|
90
92
|
/** Throws naming `where` when `address` is neither `user@host` nor `{ email: 'user@host', name? }`. */
|
|
91
93
|
export declare function assertEmailAddress(address: unknown, where: string): void;
|
|
92
94
|
/** Throws naming `where` unless `url` is an absolute http(s) URL; '' and null are not a way to leave it out. */
|
package/dist/email.d.cts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
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"}
|
|
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,eAAe;;wBAKf,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
|
@@ -87,6 +87,8 @@ export declare const PREVIEW_MESSAGE_ID = "00000000-0000-0000-0000-000000000000"
|
|
|
87
87
|
export declare function resolveEmail(template: Pick<EmailTemplate, 'from' | 'replyTo' | 'unsubscribeUrl' | 'topic'>, config?: EmailConfig, options?: EmailRenderOptions): ResolvedEmail;
|
|
88
88
|
/** The preference page's URL for one copy: `m=<message id>` set on its query, the rest of it kept. */
|
|
89
89
|
export declare function preferencesLink(preferencesUrl: string, messageId: string): string;
|
|
90
|
+
/** Whether `value` is a bare `user@host` address — the shape a profile's email must have to be sent to. */
|
|
91
|
+
export declare function isEmailAddress(value: string): boolean;
|
|
90
92
|
/** Throws naming `where` when `address` is neither `user@host` nor `{ email: 'user@host', name? }`. */
|
|
91
93
|
export declare function assertEmailAddress(address: unknown, where: string): void;
|
|
92
94
|
/** Throws naming `where` unless `url` is an absolute http(s) URL; '' and null are not a way to leave it out. */
|
package/dist/email.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
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"}
|
|
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,eAAe;;wBAKf,mBAAmB,kBAAkB;;wBAkBrC,cAAc,cAAc;;qBAa/B,6BAA6B;;wBAG1B,kBACd;EAAU,QAAQ;GAClB,WAAW,SAAS;;;;;;;wBAqDN,oBACd;EACE;EACA;EACA;EACA;EACA;GAEF;;AAEA;;;;;;;wBAqBc,oBACd;EAAY;EAAiB;GAC7B,eACA,2BACA;;wBA0Bc,kBAAkB,aAAa"}
|
package/dist/email.mjs
CHANGED
|
@@ -53,6 +53,10 @@ function preferencesLink(preferencesUrl, messageId) {
|
|
|
53
53
|
return url.toString();
|
|
54
54
|
}
|
|
55
55
|
const ADDRESS = /^[^\s@<>]+@[^\s@<>]+$/;
|
|
56
|
+
/** Whether `value` is a bare `user@host` address — the shape a profile's email must have to be sent to. */
|
|
57
|
+
function isEmailAddress(value) {
|
|
58
|
+
return ADDRESS.test(value);
|
|
59
|
+
}
|
|
56
60
|
/** Throws naming `where` when `address` is neither `user@host` nor `{ email: 'user@host', name? }`. */
|
|
57
61
|
function assertEmailAddress(address, where) {
|
|
58
62
|
const email = typeof address === "object" && address !== null ? address.email : address;
|
|
@@ -130,6 +134,6 @@ function isUnsubscribeLink(url, unsubscribeUrl) {
|
|
|
130
134
|
return false;
|
|
131
135
|
}
|
|
132
136
|
}
|
|
133
|
-
export { PREVIEW_MESSAGE_ID, assertEmailAddress, assertEmailConfig, assertEmailMetadata, assertHttpUrl, assertTemplateTopic, assertUnsubscribeUrl, formatEmailAddress, isUnsubscribeLink, mimeAddress, preferencesLink, resolveEmail };
|
|
137
|
+
export { PREVIEW_MESSAGE_ID, assertEmailAddress, assertEmailConfig, assertEmailMetadata, assertHttpUrl, assertTemplateTopic, assertUnsubscribeUrl, formatEmailAddress, isEmailAddress, isUnsubscribeLink, mimeAddress, preferencesLink, resolveEmail };
|
|
134
138
|
|
|
135
139
|
//# sourceMappingURL=email.mjs.map
|
package/dist/email.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"email.mjs","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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"}
|
|
1
|
+
{"version":3,"file":"email.mjs","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/** Whether `value` is a bare `user@host` address — the shape a profile's email must have to be sent to. */\nexport function isEmailAddress(value: string): boolean {\n return ADDRESS.test(value);\n}\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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"}
|
|
@@ -38,11 +38,13 @@ async function evaluate(condition, facts, nowMs, opts, enclosing) {
|
|
|
38
38
|
if (condition.within !== void 0) bounds.push(nowMs - condition.within.ms);
|
|
39
39
|
if (opts?.anchorMs !== void 0) bounds.push(opts.anchorMs);
|
|
40
40
|
const sinceMs = bounds.length > 0 ? Math.max(...bounds) : void 0;
|
|
41
|
+
const atLeast = condition.count ?? 1;
|
|
41
42
|
return await facts.countEvents(condition.event, {
|
|
42
43
|
...sinceMs !== void 0 ? { sinceMs } : {},
|
|
43
44
|
...opts?.untilMs !== void 0 ? { untilMs: opts.untilMs } : {},
|
|
44
|
-
...condition.where !== void 0 ? { where: condition.where } : {}
|
|
45
|
-
|
|
45
|
+
...condition.where !== void 0 ? { where: condition.where } : {},
|
|
46
|
+
atLeast
|
|
47
|
+
}) >= atLeast;
|
|
46
48
|
}
|
|
47
49
|
case "property": return compareProperty(condition, await facts.getProperty(condition.path));
|
|
48
50
|
case "reachable": {
|
|
@@ -109,13 +111,13 @@ function isBetween(actual, values) {
|
|
|
109
111
|
/**
|
|
110
112
|
* Wake-up channel for profile changes: a property predicate has no event name
|
|
111
113
|
* of its own, so subscriptions use this reserved name and the ingest router
|
|
112
|
-
* fires it on every
|
|
114
|
+
* fires it on every profile merge. The '$' prefix marks internal names — real
|
|
113
115
|
* events may never use it (the router rejects them).
|
|
114
116
|
*/
|
|
115
117
|
const PROFILE_UPDATED_EVENT = "$profile_updated";
|
|
116
118
|
/**
|
|
117
119
|
* The wake-up channel for one profile property: `$profile_updated:<path>`. A property predicate
|
|
118
|
-
* subscribes to its own property's channel, and
|
|
120
|
+
* subscribes to its own property's channel, and a merge fires one per property it patched —
|
|
119
121
|
* so an identify that touches `plan` does not wake a wait on `email`.
|
|
120
122
|
*/
|
|
121
123
|
function profileUpdatedEvent(path) {
|
|
@@ -130,7 +132,7 @@ const DEVICES_UPDATED_EVENT = "$devices_updated";
|
|
|
130
132
|
* The set of event names a condition cares about — what wait_until subscribes
|
|
131
133
|
* to for wake-ups. `performed` contributes its event; a property predicate
|
|
132
134
|
* contributes its property's channel (`profileUpdatedEvent(path)`, so an
|
|
133
|
-
*
|
|
135
|
+
* a merge touching that property wakes the wait and one touching another
|
|
134
136
|
* does not); `reachable` contributes the recipient property's channel, or the
|
|
135
137
|
* device channel for push (in_app, slack and discord have no address to wait
|
|
136
138
|
* on). Segment references are expanded, which is why a FactSource is needed to
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"condition.cjs","names":["RECIPIENT_PROPERTY"],"sources":["../../src/engine/condition.ts"],"sourcesContent":["import type { ConditionIR, PropertyOperatorIR, ScalarIR } from '../ir';\nimport type { FactSource } from './ports';\nimport { RECIPIENT_PROPERTY } from './recipient';\n\n/**\n * Evaluation options.\n * - anchorMs: the earliest instant `performed` counts events from — the goal\n * guard anchors at workflow entry (a conversion must happen *after* entry),\n * waitUntil at the moment the wait began (a wake must not be satisfied by\n * pre-existing history). Combined with `within` by taking the later bound.\n * - untilMs: the latest instant `performed` counts events up to — the goal's\n * attribution window ends there, so an event after it is no conversion even\n * when the check runs later (a resumed instance, a delayed wake).\n */\nexport interface EvaluateOptions {\n anchorMs?: number;\n untilMs?: number;\n}\n\n/**\n * ConditionIR evaluation: pure logic, with facts injected through FactSource,\n * so the store-backed implementation and the in-memory test one share a single set of\n * semantics and cannot drift into two evaluators.\n */\nexport async function evaluateCondition(\n condition: ConditionIR,\n facts: FactSource,\n nowMs: number,\n opts?: EvaluateOptions\n): Promise<boolean> {\n return evaluate(condition, facts, nowMs, opts, []);\n}\n\n/**\n * `enclosing` is the chain of segment names currently being expanded. A\n * segment that reaches itself has no truth value to converge on, and left\n * unchecked the recursion runs until memory gives out, one store round trip per\n * level — inside a step, so Workflows retries it and the instance never gets\n * out. Failing immediately keeps it bounded, and the message names the cycle\n * because only a deployed bundle can fix it. Only the *path* is tracked, so a\n * diamond (two branches referencing one segment) still evaluates twice.\n */\nasync function evaluate(\n condition: ConditionIR,\n facts: FactSource,\n nowMs: number,\n opts: EvaluateOptions | undefined,\n enclosing: readonly string[]\n): Promise<boolean> {\n switch (condition.type) {\n case 'and': {\n for (const child of condition.conditions) {\n if (!(await evaluate(child, facts, nowMs, opts, enclosing))) return false;\n }\n return true;\n }\n case 'or': {\n for (const child of condition.conditions) {\n if (await evaluate(child, facts, nowMs, opts, enclosing)) return true;\n }\n return false;\n }\n case 'not':\n return !(await evaluate(condition.condition, facts, nowMs, opts, enclosing));\n case 'segment': {\n if (enclosing.includes(condition.segment)) {\n throw new Error(\n `segment '${condition.segment}' references itself: ${[...enclosing, condition.segment].join(' -> ')}`\n );\n }\n const def = await facts.getSegmentCondition(condition.segment);\n if (def === undefined) {\n throw new Error(`Unknown segment: '${condition.segment}' (bundle not deployed?)`);\n }\n return evaluate(def, facts, nowMs, opts, [...enclosing, condition.segment]);\n }\n case 'performed': {\n const bounds: number[] = [];\n if (condition.within !== undefined) bounds.push(nowMs - condition.within.ms);\n if (opts?.anchorMs !== undefined) bounds.push(opts.anchorMs);\n const sinceMs = bounds.length > 0 ? Math.max(...bounds) : undefined;\n const count = await facts.countEvents(condition.event, {\n ...(sinceMs !== undefined ? { sinceMs } : {}),\n ...(opts?.untilMs !== undefined ? { untilMs: opts.untilMs } : {}),\n ...(condition.where !== undefined ? { where: condition.where } : {}),\n });\n return count >= (condition.count ?? 1);\n }\n case 'property': {\n const actual = await facts.getProperty(condition.path);\n return compareProperty(condition, actual);\n }\n case 'reachable': {\n // The channel-wide opt-out only: a user opted out of one topic (an email topic, a push\n // topic) is still reachable on the channel, so `exitWhen: not(reachable('email'))` does\n // not fire on it\n if (await facts.isSuppressed?.(condition.channel)) return false;\n if (condition.channel === 'push') return (await facts.listDevices()).length > 0;\n const path = RECIPIENT_PROPERTY[condition.channel];\n // in_app, slack and discord need no address: a signed-in user is reachable in the app, and\n // the webhook channels post to a team channel, not to the user\n return path === undefined || (await facts.getProperty(path)) !== undefined;\n }\n case 'payload':\n // Placement is validated at compile; reaching here means hand-built IR\n throw new Error(\n 'payload conditions are only valid inside performed({ where }) or a trigger where clause'\n );\n }\n}\n\n/**\n * Evaluate a where tree against one event's payload. Shared by performed\n * counting (FactSource implementations) and the ingest router's trigger gate.\n * Dotted paths descend nested objects; a missing or non-scalar leaf behaves\n * like a missing property (exists sees objects, comparisons do not).\n */\nexport function matchesWhere(payload: unknown, where: ConditionIR): boolean {\n switch (where.type) {\n case 'and':\n return where.conditions.every((child) => matchesWhere(payload, child));\n case 'or':\n return where.conditions.some((child) => matchesWhere(payload, child));\n case 'not':\n return !matchesWhere(payload, where.condition);\n case 'payload': {\n const raw = valueAtPath(payload, where.path);\n if (where.op === 'exists') return raw !== undefined && raw !== null;\n if (where.op === 'not_exists') return raw === undefined || raw === null;\n const actual =\n typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean'\n ? raw\n : undefined;\n return compareProperty(where, actual);\n }\n default:\n // compileBundle rejects these; defend against hand-built IR\n throw new Error(`where clause may not contain a '${where.type}' condition`);\n }\n}\n\nfunction valueAtPath(payload: unknown, path: string): unknown {\n let current: unknown = payload;\n for (const key of path.split('.')) {\n if (current === null || typeof current !== 'object') return undefined;\n current = (current as Record<string, unknown>)[key];\n }\n return current;\n}\n\nfunction compareProperty(\n condition: {\n op: PropertyOperatorIR;\n value?: ScalarIR | undefined;\n values?: ScalarIR[] | undefined;\n },\n actual: ScalarIR | undefined\n): boolean {\n const { op, value, values } = condition;\n switch (op) {\n case 'exists':\n return actual !== undefined;\n case 'not_exists':\n return actual === undefined;\n case 'eq':\n return actual === value;\n case 'ne':\n return actual !== value;\n case 'gt':\n return actual !== undefined && value !== undefined && actual > value;\n case 'gte':\n return actual !== undefined && value !== undefined && actual >= value;\n case 'lt':\n return actual !== undefined && value !== undefined && actual < value;\n case 'lte':\n return actual !== undefined && value !== undefined && actual <= value;\n case 'between':\n return isBetween(actual, values);\n case 'not_between':\n // A missing property is outside any range, mirroring not_in_array.\n return !isBetween(actual, values);\n case 'in_array':\n return actual !== undefined && (values ?? []).includes(actual);\n case 'not_in_array':\n return actual === undefined || !(values ?? []).includes(actual);\n case 'contains':\n return typeof actual === 'string' && typeof value === 'string' && actual.includes(value);\n case 'not_contains':\n return !(typeof actual === 'string' && typeof value === 'string' && actual.includes(value));\n }\n}\n\nfunction isBetween(actual: ScalarIR | undefined, values: ScalarIR[] | undefined): boolean {\n if (actual === undefined || values === undefined || values.length < 2) return false;\n const [min, max] = values;\n return actual >= min && actual <= max;\n}\n\n/**\n * Wake-up channel for profile changes: a property predicate has no event name\n * of its own, so subscriptions use this reserved name and the ingest router\n * fires it on every /identify. The '$' prefix marks internal names — real\n * events may never use it (the router rejects them).\n */\nexport const PROFILE_UPDATED_EVENT = '$profile_updated';\n\n/**\n * The wake-up channel for one profile property: `$profile_updated:<path>`. A property predicate\n * subscribes to its own property's channel, and `/identify` fires one per property it patched —\n * so an identify that touches `plan` does not wake a wait on `email`.\n */\nexport function profileUpdatedEvent(path: string): string {\n return `${PROFILE_UPDATED_EVENT}:${path}`;\n}\n\n/**\n * Wake-up channel for device changes: a device registered for the user, moved to another account\n * or was unbound or removed (`POST /devices`, `/devices/unbind`, `/devices/remove`). `reachable('push')` subscribes to it.\n */\nexport const DEVICES_UPDATED_EVENT = '$devices_updated';\n\n/**\n * The set of event names a condition cares about — what wait_until subscribes\n * to for wake-ups. `performed` contributes its event; a property predicate\n * contributes its property's channel (`profileUpdatedEvent(path)`, so an\n * /identify touching that property wakes the wait and one touching another\n * does not); `reachable` contributes the recipient property's channel, or the\n * device channel for push (in_app, slack and discord have no address to wait\n * on). Segment references are expanded, which is why a FactSource is needed to\n * resolve their definitions.\n */\nexport async function relevantEvents(condition: ConditionIR, facts: FactSource): Promise<string[]> {\n const found = new Set<string>();\n // Names already expanded. The result is a union, so re-walking a segment can\n // only add what is already there — and a segment that reaches itself would\n // otherwise recurse until the stack blows, taking a store read per level.\n const expanded = new Set<string>();\n async function walk(c: ConditionIR): Promise<void> {\n switch (c.type) {\n case 'and':\n case 'or':\n for (const child of c.conditions) await walk(child);\n break;\n case 'not':\n await walk(c.condition);\n break;\n case 'performed':\n found.add(c.event);\n break;\n case 'segment': {\n if (expanded.has(c.segment)) break;\n expanded.add(c.segment);\n const def = await facts.getSegmentCondition(c.segment);\n if (def) await walk(def);\n break;\n }\n case 'property':\n found.add(profileUpdatedEvent(c.path));\n break;\n case 'reachable': {\n if (c.channel === 'push') {\n found.add(DEVICES_UPDATED_EVENT);\n break;\n }\n const path = RECIPIENT_PROPERTY[c.channel];\n if (path !== undefined) found.add(profileUpdatedEvent(path));\n break;\n }\n case 'payload':\n break; // only appears inside performed.where, which is not walked\n }\n }\n await walk(condition);\n return [...found];\n}\n"],"mappings":";;;;;;;;AAwBA,eAAsB,kBACpB,WACA,OACA,OACA,MACkB;CAClB,OAAO,SAAS,WAAW,OAAO,OAAO,MAAM,CAAC,CAAC;AACnD;;;;;;;;;;AAWA,eAAe,SACb,WACA,OACA,OACA,MACA,WACkB;CAClB,QAAQ,UAAU,MAAlB;EACE,KAAK;GACH,KAAK,MAAM,SAAS,UAAU,YAC5B,IAAI,CAAE,MAAM,SAAS,OAAO,OAAO,OAAO,MAAM,SAAS,GAAI,OAAO;GAEtE,OAAO;EAET,KAAK;GACH,KAAK,MAAM,SAAS,UAAU,YAC5B,IAAI,MAAM,SAAS,OAAO,OAAO,OAAO,MAAM,SAAS,GAAG,OAAO;GAEnE,OAAO;EAET,KAAK,OACH,OAAO,CAAE,MAAM,SAAS,UAAU,WAAW,OAAO,OAAO,MAAM,SAAS;EAC5E,KAAK,WAAW;GACd,IAAI,UAAU,SAAS,UAAU,OAAO,GACtC,MAAM,IAAI,MACR,YAAY,UAAU,QAAQ,uBAAuB,CAAC,GAAG,WAAW,UAAU,OAAO,CAAC,CAAC,KAAK,MAAM,GACpG;GAEF,MAAM,MAAM,MAAM,MAAM,oBAAoB,UAAU,OAAO;GAC7D,IAAI,QAAQ,KAAA,GACV,MAAM,IAAI,MAAM,qBAAqB,UAAU,QAAQ,yBAAyB;GAElF,OAAO,SAAS,KAAK,OAAO,OAAO,MAAM,CAAC,GAAG,WAAW,UAAU,OAAO,CAAC;EAC5E;EACA,KAAK,aAAa;GAChB,MAAM,SAAmB,CAAC;GAC1B,IAAI,UAAU,WAAW,KAAA,GAAW,OAAO,KAAK,QAAQ,UAAU,OAAO,EAAE;GAC3E,IAAI,MAAM,aAAa,KAAA,GAAW,OAAO,KAAK,KAAK,QAAQ;GAC3D,MAAM,UAAU,OAAO,SAAS,IAAI,KAAK,IAAI,GAAG,MAAM,IAAI,KAAA;GAM1D,OAAO,MALa,MAAM,YAAY,UAAU,OAAO;IACrD,GAAI,YAAY,KAAA,IAAY,EAAE,QAAQ,IAAI,CAAC;IAC3C,GAAI,MAAM,YAAY,KAAA,IAAY,EAAE,SAAS,KAAK,QAAQ,IAAI,CAAC;IAC/D,GAAI,UAAU,UAAU,KAAA,IAAY,EAAE,OAAO,UAAU,MAAM,IAAI,CAAC;GACpE,CAAC,MACgB,UAAU,SAAS;EACtC;EACA,KAAK,YAEH,OAAO,gBAAgB,WAAW,MADb,MAAM,YAAY,UAAU,IAAI,CACb;EAE1C,KAAK,aAAa;GAIhB,IAAI,MAAM,MAAM,eAAe,UAAU,OAAO,GAAG,OAAO;GAC1D,IAAI,UAAU,YAAY,QAAQ,QAAQ,MAAM,MAAM,YAAY,EAAA,CAAG,SAAS;GAC9E,MAAM,OAAOA,yBAAAA,mBAAmB,UAAU;GAG1C,OAAO,SAAS,KAAA,KAAc,MAAM,MAAM,YAAY,IAAI,MAAO,KAAA;EACnE;EACA,KAAK,WAEH,MAAM,IAAI,MACR,yFACF;CACJ;AACF;;;;;;;AAQA,SAAgB,aAAa,SAAkB,OAA6B;CAC1E,QAAQ,MAAM,MAAd;EACE,KAAK,OACH,OAAO,MAAM,WAAW,OAAO,UAAU,aAAa,SAAS,KAAK,CAAC;EACvE,KAAK,MACH,OAAO,MAAM,WAAW,MAAM,UAAU,aAAa,SAAS,KAAK,CAAC;EACtE,KAAK,OACH,OAAO,CAAC,aAAa,SAAS,MAAM,SAAS;EAC/C,KAAK,WAAW;GACd,MAAM,MAAM,YAAY,SAAS,MAAM,IAAI;GAC3C,IAAI,MAAM,OAAO,UAAU,OAAO,QAAQ,KAAA,KAAa,QAAQ;GAC/D,IAAI,MAAM,OAAO,cAAc,OAAO,QAAQ,KAAA,KAAa,QAAQ;GAKnE,OAAO,gBAAgB,OAHrB,OAAO,QAAQ,YAAY,OAAO,QAAQ,YAAY,OAAO,QAAQ,YACjE,MACA,KAAA,CAC8B;EACtC;EACA,SAEE,MAAM,IAAI,MAAM,mCAAmC,MAAM,KAAK,YAAY;CAC9E;AACF;AAEA,SAAS,YAAY,SAAkB,MAAuB;CAC5D,IAAI,UAAmB;CACvB,KAAK,MAAM,OAAO,KAAK,MAAM,GAAG,GAAG;EACjC,IAAI,YAAY,QAAQ,OAAO,YAAY,UAAU,OAAO,KAAA;EAC5D,UAAW,QAAoC;CACjD;CACA,OAAO;AACT;AAEA,SAAS,gBACP,WAKA,QACS;CACT,MAAM,EAAE,IAAI,OAAO,WAAW;CAC9B,QAAQ,IAAR;EACE,KAAK,UACH,OAAO,WAAW,KAAA;EACpB,KAAK,cACH,OAAO,WAAW,KAAA;EACpB,KAAK,MACH,OAAO,WAAW;EACpB,KAAK,MACH,OAAO,WAAW;EACpB,KAAK,MACH,OAAO,WAAW,KAAA,KAAa,UAAU,KAAA,KAAa,SAAS;EACjE,KAAK,OACH,OAAO,WAAW,KAAA,KAAa,UAAU,KAAA,KAAa,UAAU;EAClE,KAAK,MACH,OAAO,WAAW,KAAA,KAAa,UAAU,KAAA,KAAa,SAAS;EACjE,KAAK,OACH,OAAO,WAAW,KAAA,KAAa,UAAU,KAAA,KAAa,UAAU;EAClE,KAAK,WACH,OAAO,UAAU,QAAQ,MAAM;EACjC,KAAK,eAEH,OAAO,CAAC,UAAU,QAAQ,MAAM;EAClC,KAAK,YACH,OAAO,WAAW,KAAA,MAAc,UAAU,CAAC,EAAA,CAAG,SAAS,MAAM;EAC/D,KAAK,gBACH,OAAO,WAAW,KAAA,KAAa,EAAE,UAAU,CAAC,EAAA,CAAG,SAAS,MAAM;EAChE,KAAK,YACH,OAAO,OAAO,WAAW,YAAY,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK;EACzF,KAAK,gBACH,OAAO,EAAE,OAAO,WAAW,YAAY,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK;CAC7F;AACF;AAEA,SAAS,UAAU,QAA8B,QAAyC;CACxF,IAAI,WAAW,KAAA,KAAa,WAAW,KAAA,KAAa,OAAO,SAAS,GAAG,OAAO;CAC9E,MAAM,CAAC,KAAK,OAAO;CACnB,OAAO,UAAU,OAAO,UAAU;AACpC;;;;;;;AAQA,MAAa,wBAAwB;;;;;;AAOrC,SAAgB,oBAAoB,MAAsB;CACxD,OAAO,GAAG,sBAAsB,GAAG;AACrC;;;;;AAMA,MAAa,wBAAwB;;;;;;;;;;;AAYrC,eAAsB,eAAe,WAAwB,OAAsC;CACjG,MAAM,wBAAQ,IAAI,IAAY;CAI9B,MAAM,2BAAW,IAAI,IAAY;CACjC,eAAe,KAAK,GAA+B;EACjD,QAAQ,EAAE,MAAV;GACE,KAAK;GACL,KAAK;IACH,KAAK,MAAM,SAAS,EAAE,YAAY,MAAM,KAAK,KAAK;IAClD;GACF,KAAK;IACH,MAAM,KAAK,EAAE,SAAS;IACtB;GACF,KAAK;IACH,MAAM,IAAI,EAAE,KAAK;IACjB;GACF,KAAK,WAAW;IACd,IAAI,SAAS,IAAI,EAAE,OAAO,GAAG;IAC7B,SAAS,IAAI,EAAE,OAAO;IACtB,MAAM,MAAM,MAAM,MAAM,oBAAoB,EAAE,OAAO;IACrD,IAAI,KAAK,MAAM,KAAK,GAAG;IACvB;GACF;GACA,KAAK;IACH,MAAM,IAAI,oBAAoB,EAAE,IAAI,CAAC;IACrC;GACF,KAAK,aAAa;IAChB,IAAI,EAAE,YAAY,QAAQ;KACxB,MAAM,IAAI,qBAAqB;KAC/B;IACF;IACA,MAAM,OAAOA,yBAAAA,mBAAmB,EAAE;IAClC,IAAI,SAAS,KAAA,GAAW,MAAM,IAAI,oBAAoB,IAAI,CAAC;IAC3D;GACF;EAGF;CACF;CACA,MAAM,KAAK,SAAS;CACpB,OAAO,CAAC,GAAG,KAAK;AAClB"}
|
|
1
|
+
{"version":3,"file":"condition.cjs","names":["RECIPIENT_PROPERTY"],"sources":["../../src/engine/condition.ts"],"sourcesContent":["import type { ConditionIR, PropertyOperatorIR, ScalarIR } from '../ir';\nimport type { FactSource } from './ports';\nimport { RECIPIENT_PROPERTY } from './recipient';\n\n/**\n * Evaluation options.\n * - anchorMs: the earliest instant `performed` counts events from — the goal\n * guard anchors at workflow entry (a conversion must happen *after* entry),\n * waitUntil at the moment the wait began (a wake must not be satisfied by\n * pre-existing history). Combined with `within` by taking the later bound.\n * - untilMs: the latest instant `performed` counts events up to — the goal's\n * attribution window ends there, so an event after it is no conversion even\n * when the check runs later (a resumed instance, a delayed wake).\n */\nexport interface EvaluateOptions {\n anchorMs?: number;\n untilMs?: number;\n}\n\n/**\n * ConditionIR evaluation: pure logic, with facts injected through FactSource,\n * so the store-backed implementation and the in-memory test one share a single set of\n * semantics and cannot drift into two evaluators.\n */\nexport async function evaluateCondition(\n condition: ConditionIR,\n facts: FactSource,\n nowMs: number,\n opts?: EvaluateOptions\n): Promise<boolean> {\n return evaluate(condition, facts, nowMs, opts, []);\n}\n\n/**\n * `enclosing` is the chain of segment names currently being expanded. A\n * segment that reaches itself has no truth value to converge on, and left\n * unchecked the recursion runs until memory gives out, one store round trip per\n * level — inside a step, so Workflows retries it and the instance never gets\n * out. Failing immediately keeps it bounded, and the message names the cycle\n * because only a deployed bundle can fix it. Only the *path* is tracked, so a\n * diamond (two branches referencing one segment) still evaluates twice.\n */\nasync function evaluate(\n condition: ConditionIR,\n facts: FactSource,\n nowMs: number,\n opts: EvaluateOptions | undefined,\n enclosing: readonly string[]\n): Promise<boolean> {\n switch (condition.type) {\n case 'and': {\n for (const child of condition.conditions) {\n if (!(await evaluate(child, facts, nowMs, opts, enclosing))) return false;\n }\n return true;\n }\n case 'or': {\n for (const child of condition.conditions) {\n if (await evaluate(child, facts, nowMs, opts, enclosing)) return true;\n }\n return false;\n }\n case 'not':\n return !(await evaluate(condition.condition, facts, nowMs, opts, enclosing));\n case 'segment': {\n if (enclosing.includes(condition.segment)) {\n throw new Error(\n `segment '${condition.segment}' references itself: ${[...enclosing, condition.segment].join(' -> ')}`\n );\n }\n const def = await facts.getSegmentCondition(condition.segment);\n if (def === undefined) {\n throw new Error(`Unknown segment: '${condition.segment}' (bundle not deployed?)`);\n }\n return evaluate(def, facts, nowMs, opts, [...enclosing, condition.segment]);\n }\n case 'performed': {\n const bounds: number[] = [];\n if (condition.within !== undefined) bounds.push(nowMs - condition.within.ms);\n if (opts?.anchorMs !== undefined) bounds.push(opts.anchorMs);\n const sinceMs = bounds.length > 0 ? Math.max(...bounds) : undefined;\n const atLeast = condition.count ?? 1;\n const count = await facts.countEvents(condition.event, {\n ...(sinceMs !== undefined ? { sinceMs } : {}),\n ...(opts?.untilMs !== undefined ? { untilMs: opts.untilMs } : {}),\n ...(condition.where !== undefined ? { where: condition.where } : {}),\n atLeast,\n });\n return count >= atLeast;\n }\n case 'property': {\n const actual = await facts.getProperty(condition.path);\n return compareProperty(condition, actual);\n }\n case 'reachable': {\n // The channel-wide opt-out only: a user opted out of one topic (an email topic, a push\n // topic) is still reachable on the channel, so `exitWhen: not(reachable('email'))` does\n // not fire on it\n if (await facts.isSuppressed?.(condition.channel)) return false;\n if (condition.channel === 'push') return (await facts.listDevices()).length > 0;\n const path = RECIPIENT_PROPERTY[condition.channel];\n // in_app, slack and discord need no address: a signed-in user is reachable in the app, and\n // the webhook channels post to a team channel, not to the user\n return path === undefined || (await facts.getProperty(path)) !== undefined;\n }\n case 'payload':\n // Placement is validated at compile; reaching here means hand-built IR\n throw new Error(\n 'payload conditions are only valid inside performed({ where }) or a trigger where clause'\n );\n }\n}\n\n/**\n * Evaluate a where tree against one event's payload. Shared by performed\n * counting (FactSource implementations) and the ingest router's trigger gate.\n * Dotted paths descend nested objects; a missing or non-scalar leaf behaves\n * like a missing property (exists sees objects, comparisons do not).\n */\nexport function matchesWhere(payload: unknown, where: ConditionIR): boolean {\n switch (where.type) {\n case 'and':\n return where.conditions.every((child) => matchesWhere(payload, child));\n case 'or':\n return where.conditions.some((child) => matchesWhere(payload, child));\n case 'not':\n return !matchesWhere(payload, where.condition);\n case 'payload': {\n const raw = valueAtPath(payload, where.path);\n if (where.op === 'exists') return raw !== undefined && raw !== null;\n if (where.op === 'not_exists') return raw === undefined || raw === null;\n const actual =\n typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean'\n ? raw\n : undefined;\n return compareProperty(where, actual);\n }\n default:\n // compileBundle rejects these; defend against hand-built IR\n throw new Error(`where clause may not contain a '${where.type}' condition`);\n }\n}\n\nfunction valueAtPath(payload: unknown, path: string): unknown {\n let current: unknown = payload;\n for (const key of path.split('.')) {\n if (current === null || typeof current !== 'object') return undefined;\n current = (current as Record<string, unknown>)[key];\n }\n return current;\n}\n\nfunction compareProperty(\n condition: {\n op: PropertyOperatorIR;\n value?: ScalarIR | undefined;\n values?: ScalarIR[] | undefined;\n },\n actual: ScalarIR | undefined\n): boolean {\n const { op, value, values } = condition;\n switch (op) {\n case 'exists':\n return actual !== undefined;\n case 'not_exists':\n return actual === undefined;\n case 'eq':\n return actual === value;\n case 'ne':\n return actual !== value;\n case 'gt':\n return actual !== undefined && value !== undefined && actual > value;\n case 'gte':\n return actual !== undefined && value !== undefined && actual >= value;\n case 'lt':\n return actual !== undefined && value !== undefined && actual < value;\n case 'lte':\n return actual !== undefined && value !== undefined && actual <= value;\n case 'between':\n return isBetween(actual, values);\n case 'not_between':\n // A missing property is outside any range, mirroring not_in_array.\n return !isBetween(actual, values);\n case 'in_array':\n return actual !== undefined && (values ?? []).includes(actual);\n case 'not_in_array':\n return actual === undefined || !(values ?? []).includes(actual);\n case 'contains':\n return typeof actual === 'string' && typeof value === 'string' && actual.includes(value);\n case 'not_contains':\n return !(typeof actual === 'string' && typeof value === 'string' && actual.includes(value));\n }\n}\n\nfunction isBetween(actual: ScalarIR | undefined, values: ScalarIR[] | undefined): boolean {\n if (actual === undefined || values === undefined || values.length < 2) return false;\n const [min, max] = values;\n return actual >= min && actual <= max;\n}\n\n/**\n * Wake-up channel for profile changes: a property predicate has no event name\n * of its own, so subscriptions use this reserved name and the ingest router\n * fires it on every profile merge. The '$' prefix marks internal names — real\n * events may never use it (the router rejects them).\n */\nexport const PROFILE_UPDATED_EVENT = '$profile_updated';\n\n/**\n * The wake-up channel for one profile property: `$profile_updated:<path>`. A property predicate\n * subscribes to its own property's channel, and a merge fires one per property it patched —\n * so an identify that touches `plan` does not wake a wait on `email`.\n */\nexport function profileUpdatedEvent(path: string): string {\n return `${PROFILE_UPDATED_EVENT}:${path}`;\n}\n\n/**\n * Wake-up channel for device changes: a device registered for the user, moved to another account\n * or was unbound or removed (`POST /devices`, `/devices/unbind`, `/devices/remove`). `reachable('push')` subscribes to it.\n */\nexport const DEVICES_UPDATED_EVENT = '$devices_updated';\n\n/**\n * The set of event names a condition cares about — what wait_until subscribes\n * to for wake-ups. `performed` contributes its event; a property predicate\n * contributes its property's channel (`profileUpdatedEvent(path)`, so an\n * a merge touching that property wakes the wait and one touching another\n * does not); `reachable` contributes the recipient property's channel, or the\n * device channel for push (in_app, slack and discord have no address to wait\n * on). Segment references are expanded, which is why a FactSource is needed to\n * resolve their definitions.\n */\nexport async function relevantEvents(condition: ConditionIR, facts: FactSource): Promise<string[]> {\n const found = new Set<string>();\n // Names already expanded. The result is a union, so re-walking a segment can\n // only add what is already there — and a segment that reaches itself would\n // otherwise recurse until the stack blows, taking a store read per level.\n const expanded = new Set<string>();\n async function walk(c: ConditionIR): Promise<void> {\n switch (c.type) {\n case 'and':\n case 'or':\n for (const child of c.conditions) await walk(child);\n break;\n case 'not':\n await walk(c.condition);\n break;\n case 'performed':\n found.add(c.event);\n break;\n case 'segment': {\n if (expanded.has(c.segment)) break;\n expanded.add(c.segment);\n const def = await facts.getSegmentCondition(c.segment);\n if (def) await walk(def);\n break;\n }\n case 'property':\n found.add(profileUpdatedEvent(c.path));\n break;\n case 'reachable': {\n if (c.channel === 'push') {\n found.add(DEVICES_UPDATED_EVENT);\n break;\n }\n const path = RECIPIENT_PROPERTY[c.channel];\n if (path !== undefined) found.add(profileUpdatedEvent(path));\n break;\n }\n case 'payload':\n break; // only appears inside performed.where, which is not walked\n }\n }\n await walk(condition);\n return [...found];\n}\n"],"mappings":"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"}
|
|
@@ -30,13 +30,13 @@ export declare function matchesWhere(payload: unknown, where: ConditionIR): bool
|
|
|
30
30
|
/**
|
|
31
31
|
* Wake-up channel for profile changes: a property predicate has no event name
|
|
32
32
|
* of its own, so subscriptions use this reserved name and the ingest router
|
|
33
|
-
* fires it on every
|
|
33
|
+
* fires it on every profile merge. The '$' prefix marks internal names — real
|
|
34
34
|
* events may never use it (the router rejects them).
|
|
35
35
|
*/
|
|
36
36
|
export declare const PROFILE_UPDATED_EVENT = "$profile_updated";
|
|
37
37
|
/**
|
|
38
38
|
* The wake-up channel for one profile property: `$profile_updated:<path>`. A property predicate
|
|
39
|
-
* subscribes to its own property's channel, and
|
|
39
|
+
* subscribes to its own property's channel, and a merge fires one per property it patched —
|
|
40
40
|
* so an identify that touches `plan` does not wake a wait on `email`.
|
|
41
41
|
*/
|
|
42
42
|
export declare function profileUpdatedEvent(path: string): string;
|
|
@@ -49,7 +49,7 @@ export declare const DEVICES_UPDATED_EVENT = "$devices_updated";
|
|
|
49
49
|
* The set of event names a condition cares about — what wait_until subscribes
|
|
50
50
|
* to for wake-ups. `performed` contributes its event; a property predicate
|
|
51
51
|
* contributes its property's channel (`profileUpdatedEvent(path)`, so an
|
|
52
|
-
*
|
|
52
|
+
* a merge touching that property wakes the wait and one touching another
|
|
53
53
|
* does not); `reachable` contributes the recipient property's channel, or the
|
|
54
54
|
* device channel for push (in_app, slack and discord have no address to wait
|
|
55
55
|
* on). Segment references are expanded, which is why a FactSource is needed to
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"condition.d.cts","names":[],"sources":["../../src/engine/condition.ts"],"mappings":";;;;;;;;;;;;iBAciB;EACf;EACA;;;;;;;wBAQoB,kBACpB,WAAW,aACX,OAAO,YACP,eACA,OAAO,kBACN;;;;;;;
|
|
1
|
+
{"version":3,"file":"condition.d.cts","names":[],"sources":["../../src/engine/condition.ts"],"mappings":";;;;;;;;;;;;iBAciB;EACf;EACA;;;;;;;wBAQoB,kBACpB,WAAW,aACX,OAAO,YACP,eACA,OAAO,kBACN;;;;;;;wBA0Fa,aAAa,kBAAkB,OAAO;;;;;;;qBAuFzC;;;;;;wBAOG,oBAAoB;;;;;qBAQvB;;;;;;;;;;;wBAYS,eAAe,WAAW,aAAa,OAAO,aAAa"}
|
|
@@ -30,13 +30,13 @@ export declare function matchesWhere(payload: unknown, where: ConditionIR): bool
|
|
|
30
30
|
/**
|
|
31
31
|
* Wake-up channel for profile changes: a property predicate has no event name
|
|
32
32
|
* of its own, so subscriptions use this reserved name and the ingest router
|
|
33
|
-
* fires it on every
|
|
33
|
+
* fires it on every profile merge. The '$' prefix marks internal names — real
|
|
34
34
|
* events may never use it (the router rejects them).
|
|
35
35
|
*/
|
|
36
36
|
export declare const PROFILE_UPDATED_EVENT = "$profile_updated";
|
|
37
37
|
/**
|
|
38
38
|
* The wake-up channel for one profile property: `$profile_updated:<path>`. A property predicate
|
|
39
|
-
* subscribes to its own property's channel, and
|
|
39
|
+
* subscribes to its own property's channel, and a merge fires one per property it patched —
|
|
40
40
|
* so an identify that touches `plan` does not wake a wait on `email`.
|
|
41
41
|
*/
|
|
42
42
|
export declare function profileUpdatedEvent(path: string): string;
|
|
@@ -49,7 +49,7 @@ export declare const DEVICES_UPDATED_EVENT = "$devices_updated";
|
|
|
49
49
|
* The set of event names a condition cares about — what wait_until subscribes
|
|
50
50
|
* to for wake-ups. `performed` contributes its event; a property predicate
|
|
51
51
|
* contributes its property's channel (`profileUpdatedEvent(path)`, so an
|
|
52
|
-
*
|
|
52
|
+
* a merge touching that property wakes the wait and one touching another
|
|
53
53
|
* does not); `reachable` contributes the recipient property's channel, or the
|
|
54
54
|
* device channel for push (in_app, slack and discord have no address to wait
|
|
55
55
|
* on). Segment references are expanded, which is why a FactSource is needed to
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"condition.d.mts","names":[],"sources":["../../src/engine/condition.ts"],"mappings":";;;;;;;;;;;;iBAciB;EACf;EACA;;;;;;;wBAQoB,kBACpB,WAAW,aACX,OAAO,YACP,eACA,OAAO,kBACN;;;;;;;
|
|
1
|
+
{"version":3,"file":"condition.d.mts","names":[],"sources":["../../src/engine/condition.ts"],"mappings":";;;;;;;;;;;;iBAciB;EACf;EACA;;;;;;;wBAQoB,kBACpB,WAAW,aACX,OAAO,YACP,eACA,OAAO,kBACN;;;;;;;wBA0Fa,aAAa,kBAAkB,OAAO;;;;;;;qBAuFzC;;;;;;wBAOG,oBAAoB;;;;;qBAQvB;;;;;;;;;;;wBAYS,eAAe,WAAW,aAAa,OAAO,aAAa"}
|
|
@@ -37,11 +37,13 @@ async function evaluate(condition, facts, nowMs, opts, enclosing) {
|
|
|
37
37
|
if (condition.within !== void 0) bounds.push(nowMs - condition.within.ms);
|
|
38
38
|
if (opts?.anchorMs !== void 0) bounds.push(opts.anchorMs);
|
|
39
39
|
const sinceMs = bounds.length > 0 ? Math.max(...bounds) : void 0;
|
|
40
|
+
const atLeast = condition.count ?? 1;
|
|
40
41
|
return await facts.countEvents(condition.event, {
|
|
41
42
|
...sinceMs !== void 0 ? { sinceMs } : {},
|
|
42
43
|
...opts?.untilMs !== void 0 ? { untilMs: opts.untilMs } : {},
|
|
43
|
-
...condition.where !== void 0 ? { where: condition.where } : {}
|
|
44
|
-
|
|
44
|
+
...condition.where !== void 0 ? { where: condition.where } : {},
|
|
45
|
+
atLeast
|
|
46
|
+
}) >= atLeast;
|
|
45
47
|
}
|
|
46
48
|
case "property": return compareProperty(condition, await facts.getProperty(condition.path));
|
|
47
49
|
case "reachable": {
|
|
@@ -108,13 +110,13 @@ function isBetween(actual, values) {
|
|
|
108
110
|
/**
|
|
109
111
|
* Wake-up channel for profile changes: a property predicate has no event name
|
|
110
112
|
* of its own, so subscriptions use this reserved name and the ingest router
|
|
111
|
-
* fires it on every
|
|
113
|
+
* fires it on every profile merge. The '$' prefix marks internal names — real
|
|
112
114
|
* events may never use it (the router rejects them).
|
|
113
115
|
*/
|
|
114
116
|
const PROFILE_UPDATED_EVENT = "$profile_updated";
|
|
115
117
|
/**
|
|
116
118
|
* The wake-up channel for one profile property: `$profile_updated:<path>`. A property predicate
|
|
117
|
-
* subscribes to its own property's channel, and
|
|
119
|
+
* subscribes to its own property's channel, and a merge fires one per property it patched —
|
|
118
120
|
* so an identify that touches `plan` does not wake a wait on `email`.
|
|
119
121
|
*/
|
|
120
122
|
function profileUpdatedEvent(path) {
|
|
@@ -129,7 +131,7 @@ const DEVICES_UPDATED_EVENT = "$devices_updated";
|
|
|
129
131
|
* The set of event names a condition cares about — what wait_until subscribes
|
|
130
132
|
* to for wake-ups. `performed` contributes its event; a property predicate
|
|
131
133
|
* contributes its property's channel (`profileUpdatedEvent(path)`, so an
|
|
132
|
-
*
|
|
134
|
+
* a merge touching that property wakes the wait and one touching another
|
|
133
135
|
* does not); `reachable` contributes the recipient property's channel, or the
|
|
134
136
|
* device channel for push (in_app, slack and discord have no address to wait
|
|
135
137
|
* on). Segment references are expanded, which is why a FactSource is needed to
|