@shware/workflow 0.4.0 → 2.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.
Files changed (162) hide show
  1. package/README.md +90 -49
  2. package/dist/client.d.cts +1 -1
  3. package/dist/client.d.mts +1 -1
  4. package/dist/cloudflare/bindings.cjs.map +1 -1
  5. package/dist/cloudflare/bindings.d.cts +1 -1
  6. package/dist/cloudflare/bindings.d.mts +1 -1
  7. package/dist/cloudflare/bindings.mjs.map +1 -1
  8. package/dist/cloudflare/channels.cjs +13 -18
  9. package/dist/cloudflare/channels.cjs.map +1 -1
  10. package/dist/cloudflare/channels.d.cts +3 -3
  11. package/dist/cloudflare/channels.d.cts.map +1 -1
  12. package/dist/cloudflare/channels.d.mts +3 -3
  13. package/dist/cloudflare/channels.d.mts.map +1 -1
  14. package/dist/cloudflare/channels.mjs +14 -19
  15. package/dist/cloudflare/channels.mjs.map +1 -1
  16. package/dist/cloudflare/index.d.cts +4 -4
  17. package/dist/cloudflare/index.d.mts +4 -4
  18. package/dist/cloudflare/router.d.cts +1 -1
  19. package/dist/cloudflare/router.d.mts +1 -1
  20. package/dist/cloudflare/runner.d.cts +2 -2
  21. package/dist/cloudflare/runner.d.mts +2 -2
  22. package/dist/cloudflare/senders.cjs +15 -8
  23. package/dist/cloudflare/senders.cjs.map +1 -1
  24. package/dist/cloudflare/senders.d.cts +15 -11
  25. package/dist/cloudflare/senders.d.cts.map +1 -1
  26. package/dist/cloudflare/senders.d.mts +15 -11
  27. package/dist/cloudflare/senders.d.mts.map +1 -1
  28. package/dist/cloudflare/senders.mjs +15 -8
  29. package/dist/cloudflare/senders.mjs.map +1 -1
  30. package/dist/cloudflare/worker.cjs +7 -4
  31. package/dist/cloudflare/worker.cjs.map +1 -1
  32. package/dist/cloudflare/worker.d.cts +2 -2
  33. package/dist/cloudflare/worker.d.cts.map +1 -1
  34. package/dist/cloudflare/worker.d.mts +2 -2
  35. package/dist/cloudflare/worker.d.mts.map +1 -1
  36. package/dist/cloudflare/worker.mjs +8 -5
  37. package/dist/cloudflare/worker.mjs.map +1 -1
  38. package/dist/config.cjs +66 -42
  39. package/dist/config.cjs.map +1 -1
  40. package/dist/config.d.cts +26 -45
  41. package/dist/config.d.cts.map +1 -1
  42. package/dist/config.d.mts +26 -45
  43. package/dist/config.d.mts.map +1 -1
  44. package/dist/config.mjs +67 -42
  45. package/dist/config.mjs.map +1 -1
  46. package/dist/dsl/action.cjs +1 -1
  47. package/dist/dsl/action.cjs.map +1 -1
  48. package/dist/dsl/action.d.cts +1 -1
  49. package/dist/dsl/action.d.mts +1 -1
  50. package/dist/dsl/action.mjs +1 -1
  51. package/dist/dsl/action.mjs.map +1 -1
  52. package/dist/dsl/bundle.cjs +3 -2
  53. package/dist/dsl/bundle.cjs.map +1 -1
  54. package/dist/dsl/bundle.d.cts +3 -2
  55. package/dist/dsl/bundle.d.cts.map +1 -1
  56. package/dist/dsl/bundle.d.mts +3 -2
  57. package/dist/dsl/bundle.d.mts.map +1 -1
  58. package/dist/dsl/bundle.mjs +3 -2
  59. package/dist/dsl/bundle.mjs.map +1 -1
  60. package/dist/dsl/condition.cjs +1 -1
  61. package/dist/dsl/condition.cjs.map +1 -1
  62. package/dist/dsl/condition.d.cts +1 -1
  63. package/dist/dsl/condition.d.mts +1 -1
  64. package/dist/dsl/condition.mjs +1 -1
  65. package/dist/dsl/condition.mjs.map +1 -1
  66. package/dist/dsl/flow.cjs +45 -17
  67. package/dist/dsl/flow.cjs.map +1 -1
  68. package/dist/dsl/flow.d.cts +30 -17
  69. package/dist/dsl/flow.d.cts.map +1 -1
  70. package/dist/dsl/flow.d.mts +30 -17
  71. package/dist/dsl/flow.d.mts.map +1 -1
  72. package/dist/dsl/flow.mjs +45 -18
  73. package/dist/dsl/flow.mjs.map +1 -1
  74. package/dist/dsl/index.cjs +0 -1
  75. package/dist/dsl/index.d.cts +2 -2
  76. package/dist/dsl/index.d.mts +2 -2
  77. package/dist/dsl/index.mjs +2 -2
  78. package/dist/dsl/template.cjs +2 -47
  79. package/dist/dsl/template.cjs.map +1 -1
  80. package/dist/dsl/template.d.cts +4 -54
  81. package/dist/dsl/template.d.cts.map +1 -1
  82. package/dist/dsl/template.d.mts +4 -54
  83. package/dist/dsl/template.d.mts.map +1 -1
  84. package/dist/dsl/template.mjs +3 -47
  85. package/dist/dsl/template.mjs.map +1 -1
  86. package/dist/dsl/workflow.cjs +1 -1
  87. package/dist/dsl/workflow.cjs.map +1 -1
  88. package/dist/dsl/workflow.d.cts +1 -1
  89. package/dist/dsl/workflow.d.mts +1 -1
  90. package/dist/dsl/workflow.mjs +1 -1
  91. package/dist/dsl/workflow.mjs.map +1 -1
  92. package/dist/email-context.cjs +29 -0
  93. package/dist/email-context.cjs.map +1 -0
  94. package/dist/email-context.d.cts +11 -0
  95. package/dist/email-context.d.cts.map +1 -0
  96. package/dist/email-context.d.mts +11 -0
  97. package/dist/email-context.d.mts.map +1 -0
  98. package/dist/email-context.mjs +27 -0
  99. package/dist/email-context.mjs.map +1 -0
  100. package/dist/email.cjs +72 -0
  101. package/dist/email.cjs.map +1 -0
  102. package/dist/email.d.cts +63 -0
  103. package/dist/email.d.cts.map +1 -0
  104. package/dist/email.d.mts +63 -0
  105. package/dist/email.d.mts.map +1 -0
  106. package/dist/email.mjs +65 -0
  107. package/dist/email.mjs.map +1 -0
  108. package/dist/engine/index.d.cts +2 -2
  109. package/dist/engine/index.d.mts +2 -2
  110. package/dist/engine/ports.d.cts +7 -2
  111. package/dist/engine/ports.d.cts.map +1 -1
  112. package/dist/engine/ports.d.mts +7 -2
  113. package/dist/engine/ports.d.mts.map +1 -1
  114. package/dist/index.cjs +13 -3
  115. package/dist/index.d.cts +5 -4
  116. package/dist/index.d.mts +5 -4
  117. package/dist/index.mjs +5 -4
  118. package/dist/push.cjs +3 -5
  119. package/dist/push.cjs.map +1 -1
  120. package/dist/push.d.cts +2 -2
  121. package/dist/push.d.cts.map +1 -1
  122. package/dist/push.d.mts +2 -2
  123. package/dist/push.d.mts.map +1 -1
  124. package/dist/push.mjs +4 -6
  125. package/dist/push.mjs.map +1 -1
  126. package/dist/react-email.cjs +16 -8
  127. package/dist/react-email.cjs.map +1 -1
  128. package/dist/react-email.d.cts +2 -1
  129. package/dist/react-email.d.cts.map +1 -1
  130. package/dist/react-email.d.mts +2 -1
  131. package/dist/react-email.d.mts.map +1 -1
  132. package/dist/react-email.mjs +17 -9
  133. package/dist/react-email.mjs.map +1 -1
  134. package/dist/reporting/classify.cjs.map +1 -1
  135. package/dist/reporting/classify.d.cts +1 -1
  136. package/dist/reporting/classify.d.mts +1 -1
  137. package/dist/reporting/classify.mjs.map +1 -1
  138. package/dist/reporting/observer.cjs +5 -4
  139. package/dist/reporting/observer.cjs.map +1 -1
  140. package/dist/reporting/observer.d.cts +3 -2
  141. package/dist/reporting/observer.d.cts.map +1 -1
  142. package/dist/reporting/observer.d.mts +3 -2
  143. package/dist/reporting/observer.d.mts.map +1 -1
  144. package/dist/reporting/observer.mjs +5 -4
  145. package/dist/reporting/observer.mjs.map +1 -1
  146. package/dist/reporting/tracking.cjs +3 -12
  147. package/dist/reporting/tracking.cjs.map +1 -1
  148. package/dist/reporting/tracking.d.cts +18 -7
  149. package/dist/reporting/tracking.d.cts.map +1 -1
  150. package/dist/reporting/tracking.d.mts +18 -7
  151. package/dist/reporting/tracking.d.mts.map +1 -1
  152. package/dist/reporting/tracking.mjs +3 -12
  153. package/dist/reporting/tracking.mjs.map +1 -1
  154. package/dist/templates.cjs +90 -30
  155. package/dist/templates.cjs.map +1 -1
  156. package/dist/templates.d.cts +70 -13
  157. package/dist/templates.d.cts.map +1 -1
  158. package/dist/templates.d.mts +70 -13
  159. package/dist/templates.d.mts.map +1 -1
  160. package/dist/templates.mjs +87 -31
  161. package/dist/templates.mjs.map +1 -1
  162. package/package.json +11 -1
@@ -1,5 +1,5 @@
1
1
  import { ApnsPushSender, apnsKeyFromBase64 } from "../apns.mjs";
2
- import { renderDiscordContent, renderSlackContent } from "../templates.mjs";
2
+ import { indexTemplates, lookupTemplate, renderDiscordContent, renderSlackContent } from "../templates.mjs";
3
3
  import { apnsEnvironment } from "../config.mjs";
4
4
  import { FcmPushSender, serviceAccountFromBase64 } from "../fcm.mjs";
5
5
  import { resolveRuntime } from "../runtime.mjs";
@@ -57,12 +57,9 @@ function channelsFromConfig(config, env, options = {}) {
57
57
  const registry = config.templates?.email;
58
58
  if (registry === void 0) return unavailable("no email templates are registered");
59
59
  if (env.EMAIL === void 0) return unavailable("no EMAIL (send_email) binding");
60
- if (config.emails === void 0) return unavailable("emails.from is not configured");
61
60
  return new CfEmailSender({
62
61
  binding: env.EMAIL,
63
- from: config.emails.from,
64
- ...config.emails.replyTo === void 0 ? {} : { replyTo: config.emails.replyTo },
65
- render: registryRenderer(registry)
62
+ render: registryRenderer(registry, config.email)
66
63
  });
67
64
  };
68
65
  const push = () => {
@@ -111,24 +108,21 @@ function channelsFromConfig(config, env, options = {}) {
111
108
  * A webhook template's URL, read from the environment the Worker validated at startup (its
112
109
  * `env` schema): a template whose variable is unset fails the step, naming the template.
113
110
  */
114
- const webhookUrl = (channel, key, template) => {
111
+ const webhookUrl = (channel, template) => {
115
112
  const url = template.webhook(env);
116
- if (typeof url !== "string" || url === "") throw new Error(`${channel} template '${key}': its webhook URL is not set in the environment`);
113
+ if (typeof url !== "string" || url === "") throw new Error(`${channel} template '${template.id}': its webhook URL is not set in the environment`);
117
114
  return url;
118
115
  };
119
- const lookupTemplate = (registry, channel, key) => {
120
- if (!Object.hasOwn(registry, key)) throw new Error(`unknown ${channel} template: ${key}`);
121
- return registry[key];
122
- };
123
116
  const discord = () => {
124
117
  const registry = config.templates?.discord;
125
118
  if (registry === void 0) return unavailable("no discord templates are registered");
119
+ const index = indexTemplates("discord", registry);
126
120
  return new DiscordWebhookSender({
127
- render: (key, props) => {
128
- const template = lookupTemplate(registry, "discord", key);
121
+ render: (id, props) => {
122
+ const template = lookupTemplate(index, "discord", id);
129
123
  return {
130
- url: webhookUrl("discord", key, template),
131
- content: renderDiscordContent(template, key, props)
124
+ url: webhookUrl("discord", template),
125
+ content: renderDiscordContent(template, props)
132
126
  };
133
127
  },
134
128
  ...options.fetch === void 0 ? {} : { fetch: options.fetch }
@@ -137,12 +131,13 @@ function channelsFromConfig(config, env, options = {}) {
137
131
  const slack = () => {
138
132
  const registry = config.templates?.slack;
139
133
  if (registry === void 0) return unavailable("no slack templates are registered");
134
+ const index = indexTemplates("slack", registry);
140
135
  return new SlackWebhookSender({
141
- render: (key, props) => {
142
- const template = lookupTemplate(registry, "slack", key);
136
+ render: (id, props) => {
137
+ const template = lookupTemplate(index, "slack", id);
143
138
  return {
144
- url: webhookUrl("slack", key, template),
145
- content: renderSlackContent(template, key, props)
139
+ url: webhookUrl("slack", template),
140
+ content: renderSlackContent(template, props)
146
141
  };
147
142
  },
148
143
  ...options.fetch === void 0 ? {} : { fetch: options.fetch }
@@ -1 +1 @@
1
- {"version":3,"file":"channels.mjs","names":[],"sources":["../../src/cloudflare/channels.ts"],"sourcesContent":["import { ApnsPushSender, apnsKeyFromBase64 } from '../apns';\nimport { type JourneyConfig, apnsEnvironment } from '../config';\nimport type { MessageSender, OutboundMessage } from '../engine/ports';\nimport { FcmPushSender, serviceAccountFromBase64 } from '../fcm';\nimport { registryPushRenderer } from '../push';\nimport { registryRenderer } from '../react-email';\nimport { type JourneyMode, type JourneyRuntimeOptions, resolveRuntime } from '../runtime';\nimport type { JourneyStore } from '../store/index';\nimport {\n type DiscordTemplate,\n type SlackTemplate,\n renderDiscordContent,\n renderSlackContent,\n} from '../templates';\nimport { DiscordWebhookSender, SlackWebhookSender } from '../webhooks';\nimport { WebPushSender } from '../webpush';\nimport type { JourneyEnv } from './bindings';\nimport { removeDevice } from './router';\nimport { CfEmailSender, routeByChannel, routeByPlatform } from './senders';\n\n/**\n * The outlets a `JourneyConfig` describes, assembled against the environment:\n * email through the `EMAIL` binding with the configured addresses, iOS pushes\n * straight to APNs with the configured key (the `.p8` from\n * `APNS_PRIVATE_KEY_BASE64`), Android pushes through FCM with the service\n * account from `GOOGLE_APPLICATION_CREDENTIALS_BASE64`. A channel the config\n * leaves out is simply absent, and a journey that sends on it fails that step\n * with a message saying so — rather than the host having to spell out every\n * sender by hand.\n */\n\n/** Which config entry applies: wrangler's `ENVIRONMENT` var, production when unset. */\nexport function journeyMode(env: JourneyEnv): JourneyMode {\n return env.ENVIRONMENT ?? 'production';\n}\n\n/** The runtime options for this environment's mode. */\nexport function runtimeFromConfig(\n config: Pick<JourneyConfig, 'runtime'> | undefined,\n env: JourneyEnv\n): Required<JourneyRuntimeOptions> {\n return resolveRuntime(config?.runtime, journeyMode(env));\n}\n\n/** A sender that fails every message with the reason the channel cannot deliver. */\nfunction unavailable(reason: string): MessageSender {\n return {\n async send(message) {\n throw new Error(`cannot send '${message.template}' on ${message.channel}: ${reason}`);\n },\n };\n}\n\nfunction requireSecret(\n env: JourneyEnv,\n name:\n | 'APNS_PRIVATE_KEY_BASE64'\n | 'GOOGLE_APPLICATION_CREDENTIALS_BASE64'\n | 'VAPID_PUBLIC_KEY'\n | 'VAPID_PRIVATE_KEY'\n): string {\n const value = env[name];\n if (typeof value !== 'string' || value.length === 0) {\n throw new Error(`${name} is not set: the environment must hold it for the configured channel`);\n }\n return value;\n}\n\n/**\n * A sender built on its first message. A platform's secret is only required\n * when something is actually sent there: a journey that only emails must not\n * fail because the APNs key is missing from a local `.dev.vars`.\n */\nfunction lazy(build: () => MessageSender): MessageSender {\n let sender: MessageSender | undefined;\n return {\n async send(message) {\n sender ??= build();\n return sender.send(message);\n },\n };\n}\n\n/** The channels slice of the config — what `channelsFromConfig` reads. */\nexport type ChannelsConfig = Pick<\n JourneyConfig,\n 'templates' | 'emails' | 'apns' | 'fcm' | 'webPush' | 'runtime'\n>;\n\nexport function channelsFromConfig(\n config: ChannelsConfig,\n env: JourneyEnv,\n options: {\n /** The store a token APNs / FCM / a push service reports as gone is removed from. */\n store?: JourneyStore | undefined;\n fetch?: typeof fetch;\n } = {}\n): MessageSender {\n const mode = journeyMode(env);\n\n const email = (): MessageSender => {\n const registry = config.templates?.email;\n if (registry === undefined) return unavailable('no email templates are registered');\n if (env.EMAIL === undefined) return unavailable('no EMAIL (send_email) binding');\n if (config.emails === undefined) return unavailable('emails.from is not configured');\n return new CfEmailSender({\n binding: env.EMAIL,\n from: config.emails.from,\n ...(config.emails.replyTo === undefined ? {} : { replyTo: config.emails.replyTo }),\n render: registryRenderer(registry),\n });\n };\n\n const push = (): MessageSender => {\n const registry = config.templates?.push;\n if (registry === undefined) return unavailable('no push templates are registered');\n const render = registryPushRenderer(registry);\n // A token the provider reports as gone is forgotten (nothing left to deliver to), waking the\n // waits that read devices, as a removal through the API does\n const onUnregistered = async (message: OutboundMessage) => {\n if (message.device !== undefined && options.store !== undefined) {\n await removeDevice(env, message.device.token, options.store);\n }\n };\n const platforms: Record<string, MessageSender> = {};\n const apns = config.apns;\n if (apns !== undefined) {\n platforms.ios = lazy(\n () =>\n new ApnsPushSender({\n keyId: apns.keyId,\n teamId: apns.teamId,\n privateKey: apnsKeyFromBase64(requireSecret(env, 'APNS_PRIVATE_KEY_BASE64')),\n topic: apns.topic,\n environment: apnsEnvironment(apns, mode),\n render,\n onUnregistered,\n ...(env.APNS_RELAY_ORIGIN === undefined ? {} : { origin: env.APNS_RELAY_ORIGIN }),\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n })\n );\n }\n const fcm = config.fcm;\n if (fcm !== undefined) {\n platforms.android = lazy(\n () =>\n new FcmPushSender({\n serviceAccount: serviceAccountFromBase64(\n requireSecret(env, 'GOOGLE_APPLICATION_CREDENTIALS_BASE64')\n ),\n render,\n onUnregistered,\n ...(fcm.androidChannelId === undefined\n ? {}\n : { android: { channel_id: fcm.androidChannelId } }),\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n })\n );\n }\n const webPush = config.webPush;\n if (webPush !== undefined) {\n platforms.web = lazy(\n () =>\n new WebPushSender({\n vapid: {\n subject: webPush.subject,\n publicKey: requireSecret(env, 'VAPID_PUBLIC_KEY'),\n privateKey: requireSecret(env, 'VAPID_PRIVATE_KEY'),\n },\n render,\n onUnregistered,\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n })\n );\n }\n if (Object.keys(platforms).length === 0)\n return unavailable('none of apns, fcm or webPush is configured');\n return routeByPlatform(platforms);\n };\n\n /**\n * A webhook template's URL, read from the environment the Worker validated at startup (its\n * `env` schema): a template whose variable is unset fails the step, naming the template.\n */\n const webhookUrl = (\n channel: 'discord' | 'slack',\n key: string,\n template: DiscordTemplate | SlackTemplate\n ) => {\n const url = template.webhook(env as unknown as Record<string, unknown>);\n if (typeof url !== 'string' || url === '') {\n throw new Error(\n `${channel} template '${key}': its webhook URL is not set in the environment`\n );\n }\n return url;\n };\n const lookupTemplate = <T>(\n registry: Readonly<Record<string, T>>,\n channel: string,\n key: string\n ) => {\n if (!Object.hasOwn(registry, key)) throw new Error(`unknown ${channel} template: ${key}`);\n return registry[key];\n };\n\n const discord = (): MessageSender => {\n const registry = config.templates?.discord;\n if (registry === undefined) return unavailable('no discord templates are registered');\n return new DiscordWebhookSender({\n render: (key, props) => {\n const template = lookupTemplate(registry, 'discord', key) as DiscordTemplate;\n return {\n url: webhookUrl('discord', key, template),\n content: renderDiscordContent(template, key, props),\n };\n },\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n };\n\n const slack = (): MessageSender => {\n const registry = config.templates?.slack;\n if (registry === undefined) return unavailable('no slack templates are registered');\n return new SlackWebhookSender({\n render: (key, props) => {\n const template = lookupTemplate(registry, 'slack', key) as SlackTemplate;\n return {\n url: webhookUrl('slack', key, template),\n content: renderSlackContent(template, key, props),\n };\n },\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n };\n\n return routeByChannel({ email: email(), push: push(), discord: discord(), slack: slack() });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"channels.mjs","names":[],"sources":["../../src/cloudflare/channels.ts"],"sourcesContent":["import { ApnsPushSender, apnsKeyFromBase64 } from '../apns';\nimport { type JourneyConfig, apnsEnvironment } from '../config';\nimport type { MessageSender, OutboundMessage } from '../engine/ports';\nimport { FcmPushSender, serviceAccountFromBase64 } from '../fcm';\nimport { registryPushRenderer } from '../push';\nimport { registryRenderer } from '../react-email';\nimport { type JourneyMode, type JourneyRuntimeOptions, resolveRuntime } from '../runtime';\nimport type { JourneyStore } from '../store/index';\nimport {\n type DiscordTemplate,\n type SlackTemplate,\n indexTemplates,\n lookupTemplate,\n renderDiscordContent,\n renderSlackContent,\n} from '../templates';\nimport { DiscordWebhookSender, SlackWebhookSender } from '../webhooks';\nimport { WebPushSender } from '../webpush';\nimport type { JourneyEnv } from './bindings';\nimport { removeDevice } from './router';\nimport { CfEmailSender, routeByChannel, routeByPlatform } from './senders';\n\n/**\n * The outlets a `JourneyConfig` describes, assembled against the environment:\n * email through the `EMAIL` binding with the configured addresses, iOS pushes\n * straight to APNs with the configured key (the `.p8` from\n * `APNS_PRIVATE_KEY_BASE64`), Android pushes through FCM with the service\n * account from `GOOGLE_APPLICATION_CREDENTIALS_BASE64`. A channel the config\n * leaves out is simply absent, and a journey that sends on it fails that step\n * with a message saying so — rather than the host having to spell out every\n * sender by hand.\n */\n\n/** Which config entry applies: wrangler's `ENVIRONMENT` var, production when unset. */\nexport function journeyMode(env: JourneyEnv): JourneyMode {\n return env.ENVIRONMENT ?? 'production';\n}\n\n/** The runtime options for this environment's mode. */\nexport function runtimeFromConfig(\n config: Pick<JourneyConfig, 'runtime'> | undefined,\n env: JourneyEnv\n): Required<JourneyRuntimeOptions> {\n return resolveRuntime(config?.runtime, journeyMode(env));\n}\n\n/** A sender that fails every message with the reason the channel cannot deliver. */\nfunction unavailable(reason: string): MessageSender {\n return {\n async send(message) {\n throw new Error(`cannot send '${message.template}' on ${message.channel}: ${reason}`);\n },\n };\n}\n\nfunction requireSecret(\n env: JourneyEnv,\n name:\n | 'APNS_PRIVATE_KEY_BASE64'\n | 'GOOGLE_APPLICATION_CREDENTIALS_BASE64'\n | 'VAPID_PUBLIC_KEY'\n | 'VAPID_PRIVATE_KEY'\n): string {\n const value = env[name];\n if (typeof value !== 'string' || value.length === 0) {\n throw new Error(`${name} is not set: the environment must hold it for the configured channel`);\n }\n return value;\n}\n\n/**\n * A sender built on its first message. A platform's secret is only required\n * when something is actually sent there: a journey that only emails must not\n * fail because the APNs key is missing from a local `.dev.vars`.\n */\nfunction lazy(build: () => MessageSender): MessageSender {\n let sender: MessageSender | undefined;\n return {\n async send(message) {\n sender ??= build();\n return sender.send(message);\n },\n };\n}\n\n/** The channels slice of the config — what `channelsFromConfig` reads. */\nexport type ChannelsConfig = Pick<\n JourneyConfig,\n 'templates' | 'email' | 'apns' | 'fcm' | 'webPush' | 'runtime'\n>;\n\nexport function channelsFromConfig(\n config: ChannelsConfig,\n env: JourneyEnv,\n options: {\n /** The store a token APNs / FCM / a push service reports as gone is removed from. */\n store?: JourneyStore | undefined;\n fetch?: typeof fetch;\n } = {}\n): MessageSender {\n const mode = journeyMode(env);\n\n const email = (): MessageSender => {\n const registry = config.templates?.email;\n if (registry === undefined) return unavailable('no email templates are registered');\n if (env.EMAIL === undefined) return unavailable('no EMAIL (send_email) binding');\n // Each template names its own sender: there is no global one to require\n return new CfEmailSender({\n binding: env.EMAIL,\n render: registryRenderer(registry, config.email),\n });\n };\n\n const push = (): MessageSender => {\n const registry = config.templates?.push;\n if (registry === undefined) return unavailable('no push templates are registered');\n const render = registryPushRenderer(registry);\n // A token the provider reports as gone is forgotten (nothing left to deliver to), waking the\n // waits that read devices, as a removal through the API does\n const onUnregistered = async (message: OutboundMessage) => {\n if (message.device !== undefined && options.store !== undefined) {\n await removeDevice(env, message.device.token, options.store);\n }\n };\n const platforms: Record<string, MessageSender> = {};\n const apns = config.apns;\n if (apns !== undefined) {\n platforms.ios = lazy(\n () =>\n new ApnsPushSender({\n keyId: apns.keyId,\n teamId: apns.teamId,\n privateKey: apnsKeyFromBase64(requireSecret(env, 'APNS_PRIVATE_KEY_BASE64')),\n topic: apns.topic,\n environment: apnsEnvironment(apns, mode),\n render,\n onUnregistered,\n ...(env.APNS_RELAY_ORIGIN === undefined ? {} : { origin: env.APNS_RELAY_ORIGIN }),\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n })\n );\n }\n const fcm = config.fcm;\n if (fcm !== undefined) {\n platforms.android = lazy(\n () =>\n new FcmPushSender({\n serviceAccount: serviceAccountFromBase64(\n requireSecret(env, 'GOOGLE_APPLICATION_CREDENTIALS_BASE64')\n ),\n render,\n onUnregistered,\n ...(fcm.androidChannelId === undefined\n ? {}\n : { android: { channel_id: fcm.androidChannelId } }),\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n })\n );\n }\n const webPush = config.webPush;\n if (webPush !== undefined) {\n platforms.web = lazy(\n () =>\n new WebPushSender({\n vapid: {\n subject: webPush.subject,\n publicKey: requireSecret(env, 'VAPID_PUBLIC_KEY'),\n privateKey: requireSecret(env, 'VAPID_PRIVATE_KEY'),\n },\n render,\n onUnregistered,\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n })\n );\n }\n if (Object.keys(platforms).length === 0)\n return unavailable('none of apns, fcm or webPush is configured');\n return routeByPlatform(platforms);\n };\n\n /**\n * A webhook template's URL, read from the environment the Worker validated at startup (its\n * `env` schema): a template whose variable is unset fails the step, naming the template.\n */\n const webhookUrl = (channel: 'discord' | 'slack', template: DiscordTemplate | SlackTemplate) => {\n const url = template.webhook(env as unknown as Record<string, unknown>);\n if (typeof url !== 'string' || url === '') {\n throw new Error(\n `${channel} template '${template.id}': its webhook URL is not set in the environment`\n );\n }\n return url;\n };\n\n const discord = (): MessageSender => {\n const registry = config.templates?.discord;\n if (registry === undefined) return unavailable('no discord templates are registered');\n const index = indexTemplates('discord', registry);\n return new DiscordWebhookSender({\n render: (id, props) => {\n const template = lookupTemplate(index, 'discord', id);\n return {\n url: webhookUrl('discord', template),\n content: renderDiscordContent(template, props),\n };\n },\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n };\n\n const slack = (): MessageSender => {\n const registry = config.templates?.slack;\n if (registry === undefined) return unavailable('no slack templates are registered');\n const index = indexTemplates('slack', registry);\n return new SlackWebhookSender({\n render: (id, props) => {\n const template = lookupTemplate(index, 'slack', id);\n return { url: webhookUrl('slack', template), content: renderSlackContent(template, props) };\n },\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n };\n\n return routeByChannel({ email: email(), push: push(), discord: discord(), slack: slack() });\n}\n"],"mappings":"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"}
@@ -1,5 +1,5 @@
1
- import { JourneyObserver, MessageTracking, OutboundMessage, PushDevice, SendResult } from "../engine/ports.cjs";
2
- import { EmailAddress } from "../config.cjs";
1
+ import { JourneyObserver, LinkPurpose, MessageTracking, OutboundMessage, PushDevice, SendResult } from "../engine/ports.cjs";
2
+ import { EmailAddress } from "../email.cjs";
3
3
  import { D1DatabaseLike, EmailBindingLike, JourneyEnv, JourneyParams, KVNamespaceLike, WAKE_EVENT_TYPE, WAKE_TIMEOUT_TOLERANCE_MS, WorkflowBindingLike, WorkflowInstanceLike, wakeExpired } from "./bindings.cjs";
4
4
  import { D1JourneyStore } from "../store/d1.cjs";
5
5
  import { JourneyFactSource } from "../store/facts.cjs";
@@ -10,10 +10,10 @@ import { BundleVersion, Engagement, EntryInput, EntryOutcome, EventInput, Journe
10
10
  import { d1Store } from "./d1-env.cjs";
11
11
  import { BundleIntegrityError, DeployResult, DeviceRegistration, IdentifyResult, IngestInput, IngestResult, SweepResult, UnversionedStoreError, deployBundle, forgetUser, handleRequest, identifyUser, ingestEvent, registerDevice, removeDevice, sweepSegments, unbindDevice } from "./router.cjs";
12
12
  import { JourneyRunner } from "./runner.cjs";
13
- import { CfEmailOptions, CfEmailSender, EmailRenderer, LogMessageSender, WebhookMessageSender, routeByChannel, routeByPlatform, trackEmail } from "./senders.cjs";
13
+ import { CfEmailOptions, CfEmailSender, EmailRenderer, LogMessageSender, RenderedEmail, WebhookMessageSender, routeByChannel, routeByPlatform, trackEmail } from "./senders.cjs";
14
14
  import { ChannelsConfig, channelsFromConfig, journeyMode, runtimeFromConfig } from "./channels.cjs";
15
15
  import { reportingObserver } from "../reporting/observer.cjs";
16
16
  import { TrackingOptions, handleTracking, recordPushOpen } from "../reporting/tracking.cjs";
17
17
  import { BURST_WINDOW_MS, ClassifyOptions, ClickFacts, DEFAULT_QUICK_MS, RequestFacts, Verdict, classifyClick, classifyOpen } from "../reporting/classify.cjs";
18
18
  import { EnvValidationError, ExecutionContextLike, JourneyWorker, JourneyWorkerOptions, journeyWorker } from "./worker.cjs";
19
- export { BURST_WINDOW_MS, BundleIntegrityError, type BundleVersion, type CfEmailOptions, CfEmailSender, type ChannelsConfig, type ClassifyOptions, type ClickFacts, type D1DatabaseLike, D1JourneyStore, DEFAULT_QUICK_MS, type DeployResult, type DeviceRegistration, type EmailAddress, type EmailBindingLike, type EmailRenderer, type Engagement, type EntryInput, type EntryOutcome, EnvValidationError, type EventInput, type ExecutionContextLike, type IdentifyResult, type IngestInput, type IngestResult, type JourneyEnv, JourneyFactSource, type JourneyObserver, type JourneyParams, type JourneyReporting, JourneyRunner, type JourneyStore, type JourneyWorker, type JourneyWorkerOptions, type KVNamespaceLike, LogMessageSender, type MessageRecord, type MessageTracking, type OutboundMessage, PostgresJourneyStore, type PostgresJourneyStoreOptions, PostgresReporting, type ProfileProps, type PushDevice, type RequestFacts, SchemaMismatchError, type SegmentTriggerRoute, type SendResult, type SqlClientLike, type StoreLease, type SweepResult, type TrackingOptions, type TriggerRoute, UnversionedStoreError, type Verdict, WAKE_EVENT_TYPE, WAKE_TIMEOUT_TOLERANCE_MS, WebhookMessageSender, type WorkflowBindingLike, type WorkflowInstanceLike, channelsFromConfig, classifyClick, classifyOpen, d1Store, deployBundle, forgetUser, handleRequest, handleTracking, identifyUser, ingestEvent, journeyMode, journeyWorker, recordPushOpen, registerDevice, removeDevice, reportingObserver, routeByChannel, routeByPlatform, runtimeFromConfig, sweepSegments, trackEmail, unbindDevice, wakeExpired };
19
+ export { BURST_WINDOW_MS, BundleIntegrityError, type BundleVersion, type CfEmailOptions, CfEmailSender, type ChannelsConfig, type ClassifyOptions, type ClickFacts, type D1DatabaseLike, D1JourneyStore, DEFAULT_QUICK_MS, type DeployResult, type DeviceRegistration, type EmailAddress, type EmailBindingLike, type EmailRenderer, type Engagement, type EntryInput, type EntryOutcome, EnvValidationError, type EventInput, type ExecutionContextLike, type IdentifyResult, type IngestInput, type IngestResult, type JourneyEnv, JourneyFactSource, type JourneyObserver, type JourneyParams, type JourneyReporting, JourneyRunner, type JourneyStore, type JourneyWorker, type JourneyWorkerOptions, type KVNamespaceLike, type LinkPurpose, LogMessageSender, type MessageRecord, type MessageTracking, type OutboundMessage, PostgresJourneyStore, type PostgresJourneyStoreOptions, PostgresReporting, type ProfileProps, type PushDevice, type RenderedEmail, type RequestFacts, SchemaMismatchError, type SegmentTriggerRoute, type SendResult, type SqlClientLike, type StoreLease, type SweepResult, type TrackingOptions, type TriggerRoute, UnversionedStoreError, type Verdict, WAKE_EVENT_TYPE, WAKE_TIMEOUT_TOLERANCE_MS, WebhookMessageSender, type WorkflowBindingLike, type WorkflowInstanceLike, channelsFromConfig, classifyClick, classifyOpen, d1Store, deployBundle, forgetUser, handleRequest, handleTracking, identifyUser, ingestEvent, journeyMode, journeyWorker, recordPushOpen, registerDevice, removeDevice, reportingObserver, routeByChannel, routeByPlatform, runtimeFromConfig, sweepSegments, trackEmail, unbindDevice, wakeExpired };
@@ -1,5 +1,5 @@
1
- import { JourneyObserver, MessageTracking, OutboundMessage, PushDevice, SendResult } from "../engine/ports.mjs";
2
- import { EmailAddress } from "../config.mjs";
1
+ import { JourneyObserver, LinkPurpose, MessageTracking, OutboundMessage, PushDevice, SendResult } from "../engine/ports.mjs";
2
+ import { EmailAddress } from "../email.mjs";
3
3
  import { D1DatabaseLike, EmailBindingLike, JourneyEnv, JourneyParams, KVNamespaceLike, WAKE_EVENT_TYPE, WAKE_TIMEOUT_TOLERANCE_MS, WorkflowBindingLike, WorkflowInstanceLike, wakeExpired } from "./bindings.mjs";
4
4
  import { D1JourneyStore } from "../store/d1.mjs";
5
5
  import { JourneyFactSource } from "../store/facts.mjs";
@@ -10,10 +10,10 @@ import { BundleVersion, Engagement, EntryInput, EntryOutcome, EventInput, Journe
10
10
  import { d1Store } from "./d1-env.mjs";
11
11
  import { BundleIntegrityError, DeployResult, DeviceRegistration, IdentifyResult, IngestInput, IngestResult, SweepResult, UnversionedStoreError, deployBundle, forgetUser, handleRequest, identifyUser, ingestEvent, registerDevice, removeDevice, sweepSegments, unbindDevice } from "./router.mjs";
12
12
  import { JourneyRunner } from "./runner.mjs";
13
- import { CfEmailOptions, CfEmailSender, EmailRenderer, LogMessageSender, WebhookMessageSender, routeByChannel, routeByPlatform, trackEmail } from "./senders.mjs";
13
+ import { CfEmailOptions, CfEmailSender, EmailRenderer, LogMessageSender, RenderedEmail, WebhookMessageSender, routeByChannel, routeByPlatform, trackEmail } from "./senders.mjs";
14
14
  import { ChannelsConfig, channelsFromConfig, journeyMode, runtimeFromConfig } from "./channels.mjs";
15
15
  import { reportingObserver } from "../reporting/observer.mjs";
16
16
  import { TrackingOptions, handleTracking, recordPushOpen } from "../reporting/tracking.mjs";
17
17
  import { BURST_WINDOW_MS, ClassifyOptions, ClickFacts, DEFAULT_QUICK_MS, RequestFacts, Verdict, classifyClick, classifyOpen } from "../reporting/classify.mjs";
18
18
  import { EnvValidationError, ExecutionContextLike, JourneyWorker, JourneyWorkerOptions, journeyWorker } from "./worker.mjs";
19
- export { BURST_WINDOW_MS, BundleIntegrityError, type BundleVersion, type CfEmailOptions, CfEmailSender, type ChannelsConfig, type ClassifyOptions, type ClickFacts, type D1DatabaseLike, D1JourneyStore, DEFAULT_QUICK_MS, type DeployResult, type DeviceRegistration, type EmailAddress, type EmailBindingLike, type EmailRenderer, type Engagement, type EntryInput, type EntryOutcome, EnvValidationError, type EventInput, type ExecutionContextLike, type IdentifyResult, type IngestInput, type IngestResult, type JourneyEnv, JourneyFactSource, type JourneyObserver, type JourneyParams, type JourneyReporting, JourneyRunner, type JourneyStore, type JourneyWorker, type JourneyWorkerOptions, type KVNamespaceLike, LogMessageSender, type MessageRecord, type MessageTracking, type OutboundMessage, PostgresJourneyStore, type PostgresJourneyStoreOptions, PostgresReporting, type ProfileProps, type PushDevice, type RequestFacts, SchemaMismatchError, type SegmentTriggerRoute, type SendResult, type SqlClientLike, type StoreLease, type SweepResult, type TrackingOptions, type TriggerRoute, UnversionedStoreError, type Verdict, WAKE_EVENT_TYPE, WAKE_TIMEOUT_TOLERANCE_MS, WebhookMessageSender, type WorkflowBindingLike, type WorkflowInstanceLike, channelsFromConfig, classifyClick, classifyOpen, d1Store, deployBundle, forgetUser, handleRequest, handleTracking, identifyUser, ingestEvent, journeyMode, journeyWorker, recordPushOpen, registerDevice, removeDevice, reportingObserver, routeByChannel, routeByPlatform, runtimeFromConfig, sweepSegments, trackEmail, unbindDevice, wakeExpired };
19
+ export { BURST_WINDOW_MS, BundleIntegrityError, type BundleVersion, type CfEmailOptions, CfEmailSender, type ChannelsConfig, type ClassifyOptions, type ClickFacts, type D1DatabaseLike, D1JourneyStore, DEFAULT_QUICK_MS, type DeployResult, type DeviceRegistration, type EmailAddress, type EmailBindingLike, type EmailRenderer, type Engagement, type EntryInput, type EntryOutcome, EnvValidationError, type EventInput, type ExecutionContextLike, type IdentifyResult, type IngestInput, type IngestResult, type JourneyEnv, JourneyFactSource, type JourneyObserver, type JourneyParams, type JourneyReporting, JourneyRunner, type JourneyStore, type JourneyWorker, type JourneyWorkerOptions, type KVNamespaceLike, type LinkPurpose, LogMessageSender, type MessageRecord, type MessageTracking, type OutboundMessage, PostgresJourneyStore, type PostgresJourneyStoreOptions, PostgresReporting, type ProfileProps, type PushDevice, type RenderedEmail, type RequestFacts, SchemaMismatchError, type SegmentTriggerRoute, type SendResult, type SqlClientLike, type StoreLease, type SweepResult, type TrackingOptions, type TriggerRoute, UnversionedStoreError, type Verdict, WAKE_EVENT_TYPE, WAKE_TIMEOUT_TOLERANCE_MS, WebhookMessageSender, type WorkflowBindingLike, type WorkflowInstanceLike, channelsFromConfig, classifyClick, classifyOpen, d1Store, deployBundle, forgetUser, handleRequest, handleTracking, identifyUser, ingestEvent, journeyMode, journeyWorker, recordPushOpen, registerDevice, removeDevice, reportingObserver, routeByChannel, routeByPlatform, runtimeFromConfig, sweepSegments, trackEmail, unbindDevice, wakeExpired };
@@ -1,7 +1,7 @@
1
1
  import { TriggerIR } from "../ir.cjs";
2
- import { Contract } from "../contract.cjs";
3
2
  import { JourneyEnv } from "./bindings.cjs";
4
3
  import { BundleVersion, JourneyStore, ProfileProps } from "../store/index.cjs";
4
+ import { Contract } from "../contract.cjs";
5
5
  //#region src/cloudflare/router.d.ts
6
6
  /**
7
7
  * Ingest router. An incoming event does four things: it is stored, it wakes
@@ -1,7 +1,7 @@
1
1
  import { TriggerIR } from "../ir.mjs";
2
- import { Contract } from "../contract.mjs";
3
2
  import { JourneyEnv } from "./bindings.mjs";
4
3
  import { BundleVersion, JourneyStore, ProfileProps } from "../store/index.mjs";
4
+ import { Contract } from "../contract.mjs";
5
5
  //#region src/cloudflare/router.d.ts
6
6
  /**
7
7
  * Ingest router. An incoming event does four things: it is stored, it wakes
@@ -1,9 +1,9 @@
1
1
  import { ActionInvoker, JourneyObserver, JourneyOutcome, MessageSender, MessageValidator } from "../engine/ports.cjs";
2
2
  import { RegisteredAction } from "../engine/actions.cjs";
3
- import { Contract } from "../contract.cjs";
4
- import { JourneyRuntimeOptions } from "../runtime.cjs";
5
3
  import { JourneyEnv, JourneyParams } from "./bindings.cjs";
6
4
  import { JourneyStore } from "../store/index.cjs";
5
+ import { Contract } from "../contract.cjs";
6
+ import { JourneyRuntimeOptions } from "../runtime.cjs";
7
7
  import { WorkflowEntrypoint, WorkflowEvent, WorkflowStep } from "cloudflare:workers";
8
8
  //#region src/cloudflare/runner.d.ts
9
9
  /**
@@ -1,9 +1,9 @@
1
1
  import { ActionInvoker, JourneyObserver, JourneyOutcome, MessageSender, MessageValidator } from "../engine/ports.mjs";
2
2
  import { RegisteredAction } from "../engine/actions.mjs";
3
- import { Contract } from "../contract.mjs";
4
- import { JourneyRuntimeOptions } from "../runtime.mjs";
5
3
  import { JourneyEnv, JourneyParams } from "./bindings.mjs";
6
4
  import { JourneyStore } from "../store/index.mjs";
5
+ import { Contract } from "../contract.mjs";
6
+ import { JourneyRuntimeOptions } from "../runtime.mjs";
7
7
  import { WorkflowEntrypoint, WorkflowEvent, WorkflowStep } from "cloudflare:workers";
8
8
  //#region src/cloudflare/runner.d.ts
9
9
  /**
@@ -1,4 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_email = require("../email.cjs");
2
3
  //#region src/cloudflare/senders.ts
3
4
  /**
4
5
  * Send straight through Cloudflare Email Service — a binding call, no outbound
@@ -14,13 +15,13 @@ var CfEmailSender = class {
14
15
  async send(message) {
15
16
  if (message.channel !== "email") throw new Error(`CfEmailSender: unsupported channel '${message.channel}'`);
16
17
  if (message.recipient === void 0) throw new Error(`CfEmailSender: no recipient for user '${message.userId}'`);
17
- const { subject, html } = await this.options.render(message.template, message.props);
18
- const tracked = message.tracking === void 0 ? html : trackEmail(html, message.tracking);
18
+ const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(message.template, message.props);
19
+ const tracked = message.tracking === void 0 ? html : trackEmail(html, message.tracking, unsubscribeUrl);
19
20
  await message.tracking?.commit?.();
20
21
  await this.options.binding.send({
21
- from: this.options.from,
22
+ from,
22
23
  to: message.recipient,
23
- ...this.options.replyTo === void 0 ? {} : { replyTo: this.options.replyTo },
24
+ ...replyTo === void 0 ? {} : { replyTo },
24
25
  subject,
25
26
  html: tracked,
26
27
  idempotencyKey: message.idempotencyKey
@@ -30,13 +31,19 @@ var CfEmailSender = class {
30
31
  const LINK = /(<a\b[^>]*?\shref\s*=\s*)(["'])(https?:\/\/[^"']+)\2/gi;
31
32
  /**
32
33
  * An email's tracking applied to its rendered HTML: every `http(s)` anchor routed through
33
- * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back),
34
- * and the open pixel appended before `</body>`, or at the end when there is none.
34
+ * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back) —
35
+ * as the unsubscribe link when it is the one the email was rendered with, so what a click on it
36
+ * means is fixed when the email is sent — and the open pixel appended before `</body>`, or at the
37
+ * end when there is none.
35
38
  */
36
- function trackEmail(html, tracking) {
39
+ function trackEmail(html, tracking, unsubscribeUrl) {
37
40
  const { link, pixel } = tracking;
38
41
  let out = html;
39
- if (link !== void 0) out = out.replace(LINK, (_, start, quote, url) => `${start}${quote}${escapeAttribute(link(unescapeAttribute(url)))}${quote}`);
42
+ if (link !== void 0) out = out.replace(LINK, (_, start, quote, attribute) => {
43
+ const url = unescapeAttribute(attribute);
44
+ const purpose = require_email.isUnsubscribeLink(url, unsubscribeUrl) ? "unsubscribe" : "link";
45
+ return `${start}${quote}${escapeAttribute(link(url, purpose))}${quote}`;
46
+ });
40
47
  if (pixel !== void 0) {
41
48
  const image = `<img src="${escapeAttribute(pixel)}" width="1" height="1" alt="" style="display:block;border:0;width:1px;height:1px" />`;
42
49
  out = /<\/body>/i.test(out) ? out.replace(/<\/body>/i, `${image}</body>`) : `${out}${image}`;
@@ -1 +1 @@
1
- {"version":3,"file":"senders.cjs","names":[],"sources":["../../src/cloudflare/senders.ts"],"sourcesContent":["import type { EmailAddress } from '../config';\nimport type { MessageSender, MessageTracking, OutboundMessage } from '../engine';\nimport type { ScalarIR } from '../ir';\nimport type { EmailBindingLike } from './bindings';\n\nexport type { EmailAddress } from '../config';\nexport type { EmailBindingLike } from './bindings';\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.\n */\nexport type EmailRenderer = (\n template: string,\n props: Record<string, ScalarIR | undefined>\n) => Promise<{ subject: string; html: string }>;\n\nexport interface CfEmailOptions {\n /** The Worker's `send_email` binding. */\n binding: EmailBindingLike;\n from: EmailAddress;\n replyTo?: EmailAddress;\n render: EmailRenderer;\n}\n\n/**\n * Send straight through Cloudflare Email Service — a binding call, no outbound\n * HTTP. Throwing hands retries to the CF step, and the message's\n * idempotencyKey travels with the call so the binding can drop the duplicate a\n * retry or replay produces.\n */\nexport class CfEmailSender implements MessageSender {\n constructor(private readonly options: CfEmailOptions) {}\n\n async send(message: OutboundMessage): Promise<void> {\n if (message.channel !== 'email') {\n throw new Error(`CfEmailSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`CfEmailSender: no recipient for user '${message.userId}'`);\n }\n const { subject, html } = await this.options.render(message.template, message.props);\n const tracked = message.tracking === undefined ? html : trackEmail(html, message.tracking);\n // The tracked links resolve before the email exists anywhere a scanner could follow them\n await message.tracking?.commit?.();\n await this.options.binding.send({\n from: this.options.from,\n to: message.recipient,\n ...(this.options.replyTo === undefined ? {} : { replyTo: this.options.replyTo }),\n subject,\n html: tracked,\n idempotencyKey: message.idempotencyKey,\n });\n }\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 * and the open pixel appended before `</body>`, or at the end when there is none.\n */\nexport function trackEmail(html: string, tracking: MessageTracking): string {\n const { link, pixel } = tracking;\n let out = html;\n if (link !== undefined) {\n out = out.replace(\n LINK,\n (_, start: string, quote: string, url: string) =>\n `${start}${quote}${escapeAttribute(link(unescapeAttribute(url)))}${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('&amp;', '&').replaceAll('&quot;', '\"').replaceAll('&#x27;', \"'\");\n}\n\nfunction escapeAttribute(value: string): string {\n return value.replaceAll('&', '&amp;').replaceAll('\"', '&quot;');\n}\n\n/** Development sender: structured logs, for eyeballing the pipeline. */\nexport class LogMessageSender implements MessageSender {\n async send(message: OutboundMessage): Promise<void> {\n const { tracking: _tracking, ...logged } = message;\n console.log(`[send] ${JSON.stringify(logged)}`);\n }\n}\n\n/**\n * Webhook sender: POST to an external delivery service (a Resend/SES gateway,\n * say). The idempotency key travels with it, so the receiver de-duplicates\n * retries and replays on idempotencyKey.\n */\nexport class WebhookMessageSender implements MessageSender {\n constructor(private readonly url: string) {}\n\n async send(message: OutboundMessage): Promise<void> {\n const response = await fetch(this.url, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n 'idempotency-key': message.idempotencyKey,\n },\n body: JSON.stringify(message),\n });\n if (!response.ok) {\n throw new Error(`message webhook failed: ${response.status}`);\n }\n }\n}\n\n/**\n * One sender per channel: the runner has a single outlet, this fans it out.\n * A channel without a sender fails the send (a misconfiguration, not a retry).\n */\nexport function routeByChannel(\n senders: Partial<Record<OutboundMessage['channel'], MessageSender>>\n): MessageSender {\n return {\n async send(message) {\n const sender = senders[message.channel];\n if (sender === undefined) {\n throw new Error(`no sender configured for channel '${message.channel}'`);\n }\n return sender.send(message);\n },\n };\n}\n\n/**\n * One push outlet per platform, chosen by the device each copy goes to (the\n * engine sends one message per device, and the host's directory says which\n * platform a token belongs to): `ios` straight to APNs, `android` through FCM,\n * say. A platform with no sender fails the step rather than guessing.\n */\nexport function routeByPlatform(senders: Record<string, MessageSender>): MessageSender {\n return {\n async send(message) {\n const platform = message.device?.platform;\n if (platform === undefined) {\n throw new Error(`push for user '${message.userId}' carries no device`);\n }\n if (!Object.hasOwn(senders, platform)) {\n throw new Error(`no push sender configured for platform '${platform}'`);\n }\n return senders[platform].send(message);\n },\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"senders.cjs","names":["isUnsubscribeLink"],"sources":["../../src/cloudflare/senders.ts"],"sourcesContent":["import { type ResolvedEmail, isUnsubscribeLink } from '../email';\nimport type { MessageSender, MessageTracking, OutboundMessage } from '../engine';\nimport type { ScalarIR } from '../ir';\nimport type { EmailBindingLike } from './bindings';\n\nexport type { EmailAddress } from '../email';\nexport type { EmailBindingLike } from './bindings';\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.\n */\nexport type EmailRenderer = (\n template: string,\n props: Record<string, ScalarIR | undefined>\n) => Promise<RenderedEmail>;\n\nexport interface CfEmailOptions {\n /** The Worker's `send_email` binding. */\n binding: EmailBindingLike;\n render: EmailRenderer;\n}\n\n/**\n * Send straight through Cloudflare Email Service — a binding call, no outbound\n * HTTP. Throwing hands retries to the CF step, and the message's\n * idempotencyKey travels with the call so the binding can drop the duplicate a\n * retry or replay produces.\n */\nexport class CfEmailSender implements MessageSender {\n constructor(private readonly options: CfEmailOptions) {}\n\n async send(message: OutboundMessage): Promise<void> {\n if (message.channel !== 'email') {\n throw new Error(`CfEmailSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`CfEmailSender: no recipient for user '${message.userId}'`);\n }\n const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(\n message.template,\n message.props\n );\n const tracked =\n message.tracking === undefined ? html : trackEmail(html, message.tracking, unsubscribeUrl);\n // The tracked links resolve before the email exists anywhere a scanner could follow them\n await message.tracking?.commit?.();\n await this.options.binding.send({\n from,\n to: message.recipient,\n ...(replyTo === undefined ? {} : { replyTo }),\n subject,\n html: tracked,\n idempotencyKey: message.idempotencyKey,\n });\n }\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('&amp;', '&').replaceAll('&quot;', '\"').replaceAll('&#x27;', \"'\");\n}\n\nfunction escapeAttribute(value: string): string {\n return value.replaceAll('&', '&amp;').replaceAll('\"', '&quot;');\n}\n\n/** Development sender: structured logs, for eyeballing the pipeline. */\nexport class LogMessageSender implements MessageSender {\n async send(message: OutboundMessage): Promise<void> {\n const { tracking: _tracking, ...logged } = message;\n console.log(`[send] ${JSON.stringify(logged)}`);\n }\n}\n\n/**\n * Webhook sender: POST to an external delivery service (a Resend/SES gateway,\n * say). The idempotency key travels with it, so the receiver de-duplicates\n * retries and replays on idempotencyKey.\n */\nexport class WebhookMessageSender implements MessageSender {\n constructor(private readonly url: string) {}\n\n async send(message: OutboundMessage): Promise<void> {\n const response = await fetch(this.url, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n 'idempotency-key': message.idempotencyKey,\n },\n body: JSON.stringify(message),\n });\n if (!response.ok) {\n throw new Error(`message webhook failed: ${response.status}`);\n }\n }\n}\n\n/**\n * One sender per channel: the runner has a single outlet, this fans it out.\n * A channel without a sender fails the send (a misconfiguration, not a retry).\n */\nexport function routeByChannel(\n senders: Partial<Record<OutboundMessage['channel'], MessageSender>>\n): MessageSender {\n return {\n async send(message) {\n const sender = senders[message.channel];\n if (sender === undefined) {\n throw new Error(`no sender configured for channel '${message.channel}'`);\n }\n return sender.send(message);\n },\n };\n}\n\n/**\n * One push outlet per platform, chosen by the device each copy goes to (the\n * engine sends one message per device, and the host's directory says which\n * platform a token belongs to): `ios` straight to APNs, `android` through FCM,\n * say. A platform with no sender fails the step rather than guessing.\n */\nexport function routeByPlatform(senders: Record<string, MessageSender>): MessageSender {\n return {\n async send(message) {\n const platform = message.device?.platform;\n if (platform === undefined) {\n throw new Error(`push for user '${message.userId}' carries no device`);\n }\n if (!Object.hasOwn(senders, platform)) {\n throw new Error(`no push sender configured for platform '${platform}'`);\n }\n return senders[platform].send(message);\n },\n };\n}\n"],"mappings":"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"}
@@ -1,23 +1,25 @@
1
1
  import { ScalarIR } from "../ir.cjs";
2
2
  import { MessageSender, MessageTracking, OutboundMessage } from "../engine/ports.cjs";
3
3
  import "../engine/index.cjs";
4
- import { EmailAddress } from "../config.cjs";
4
+ import { EmailAddress, ResolvedEmail } from "../email.cjs";
5
5
  import { EmailBindingLike } from "./bindings.cjs";
6
6
  //#region src/cloudflare/senders.d.ts
7
+ /** A rendered email: the template's resolved metadata (`resolveEmail`), its subject and its HTML. */
8
+ export interface RenderedEmail extends ResolvedEmail {
9
+ subject: string;
10
+ html: string;
11
+ }
7
12
  /**
8
13
  * Template renderer, injected by the app — this package's core stays free of
9
14
  * any react-email dependency (`@shware/workflow/react-email` ships one over a
10
- * registry). The subject comes back final: it is sent exactly as written.
15
+ * registry). The subject comes back final: it is sent exactly as written, from
16
+ * the template's own sender, and the unsubscribe link is the one the HTML was
17
+ * rendered with.
11
18
  */
12
- export type EmailRenderer = (template: string, props: Record<string, ScalarIR | undefined>) => Promise<{
13
- subject: string;
14
- html: string;
15
- }>;
19
+ export type EmailRenderer = (template: string, props: Record<string, ScalarIR | undefined>) => Promise<RenderedEmail>;
16
20
  export interface CfEmailOptions {
17
21
  /** The Worker's `send_email` binding. */
18
22
  binding: EmailBindingLike;
19
- from: EmailAddress;
20
- replyTo?: EmailAddress;
21
23
  render: EmailRenderer;
22
24
  }
23
25
  /**
@@ -33,10 +35,12 @@ export declare class CfEmailSender implements MessageSender {
33
35
  }
34
36
  /**
35
37
  * An email's tracking applied to its rendered HTML: every `http(s)` anchor routed through
36
- * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back),
37
- * and the open pixel appended before `</body>`, or at the end when there is none.
38
+ * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back) —
39
+ * as the unsubscribe link when it is the one the email was rendered with, so what a click on it
40
+ * means is fixed when the email is sent — and the open pixel appended before `</body>`, or at the
41
+ * end when there is none.
38
42
  */
39
- export declare function trackEmail(html: string, tracking: MessageTracking): string;
43
+ export declare function trackEmail(html: string, tracking: MessageTracking, unsubscribeUrl?: string): string;
40
44
  /** Development sender: structured logs, for eyeballing the pipeline. */
41
45
  export declare class LogMessageSender implements MessageSender {
42
46
  send(message: OutboundMessage): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"senders.d.cts","names":[],"sources":["../../src/cloudflare/senders.ts"],"mappings":";;;;;;;;;;;YAaY,iBACV,kBACA,OAAO,eAAe,0BACnB;EAAU;EAAiB;;iBAEf;;EAEf,SAAS;EACT,MAAM;EACN,UAAU;EACV,QAAQ;;;;;;;;qBASG,yBAAyB;mBACP;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB;;;;;;;wBA6BxB,WAAW,cAAc,UAAU;;qBA0BtC,4BAA4B;EACjC,KAAK,SAAS,kBAAkB;;;;;;;qBAW3B,gCAAgC;mBACd;EAA7B,YAA6B;EAEvB,KAAK,SAAS,kBAAkB;;;;;;wBAmBxB,eACd,SAAS,QAAQ,OAAO,4BAA4B,kBACnD;;;;;;;wBAkBa,gBAAgB,SAAS,eAAe,iBAAiB"}
1
+ {"version":3,"file":"senders.d.cts","names":[],"sources":["../../src/cloudflare/senders.ts"],"mappings":";;;;;;;iBASiB,sBAAsB;EACrC;EACA;;;;;;;;;YAUU,iBACV,kBACA,OAAO,eAAe,0BACnB,QAAQ;iBAEI;;EAEf,SAAS;EACT,QAAQ;;;;;;;;qBASG,yBAAyB;mBACP;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB;;;;;;;;;wBAmCxB,WACd,cACA,UAAU,iBACV;;qBA2BW,4BAA4B;EACjC,KAAK,SAAS,kBAAkB;;;;;;;qBAW3B,gCAAgC;mBACd;EAA7B,YAA6B;EAEvB,KAAK,SAAS,kBAAkB;;;;;;wBAmBxB,eACd,SAAS,QAAQ,OAAO,4BAA4B,kBACnD;;;;;;;wBAkBa,gBAAgB,SAAS,eAAe,iBAAiB"}
@@ -1,23 +1,25 @@
1
1
  import { ScalarIR } from "../ir.mjs";
2
2
  import { MessageSender, MessageTracking, OutboundMessage } from "../engine/ports.mjs";
3
3
  import "../engine/index.mjs";
4
- import { EmailAddress } from "../config.mjs";
4
+ import { EmailAddress, ResolvedEmail } from "../email.mjs";
5
5
  import { EmailBindingLike } from "./bindings.mjs";
6
6
  //#region src/cloudflare/senders.d.ts
7
+ /** A rendered email: the template's resolved metadata (`resolveEmail`), its subject and its HTML. */
8
+ export interface RenderedEmail extends ResolvedEmail {
9
+ subject: string;
10
+ html: string;
11
+ }
7
12
  /**
8
13
  * Template renderer, injected by the app — this package's core stays free of
9
14
  * any react-email dependency (`@shware/workflow/react-email` ships one over a
10
- * registry). The subject comes back final: it is sent exactly as written.
15
+ * registry). The subject comes back final: it is sent exactly as written, from
16
+ * the template's own sender, and the unsubscribe link is the one the HTML was
17
+ * rendered with.
11
18
  */
12
- export type EmailRenderer = (template: string, props: Record<string, ScalarIR | undefined>) => Promise<{
13
- subject: string;
14
- html: string;
15
- }>;
19
+ export type EmailRenderer = (template: string, props: Record<string, ScalarIR | undefined>) => Promise<RenderedEmail>;
16
20
  export interface CfEmailOptions {
17
21
  /** The Worker's `send_email` binding. */
18
22
  binding: EmailBindingLike;
19
- from: EmailAddress;
20
- replyTo?: EmailAddress;
21
23
  render: EmailRenderer;
22
24
  }
23
25
  /**
@@ -33,10 +35,12 @@ export declare class CfEmailSender implements MessageSender {
33
35
  }
34
36
  /**
35
37
  * An email's tracking applied to its rendered HTML: every `http(s)` anchor routed through
36
- * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back),
37
- * and the open pixel appended before `</body>`, or at the end when there is none.
38
+ * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back) —
39
+ * as the unsubscribe link when it is the one the email was rendered with, so what a click on it
40
+ * means is fixed when the email is sent — and the open pixel appended before `</body>`, or at the
41
+ * end when there is none.
38
42
  */
39
- export declare function trackEmail(html: string, tracking: MessageTracking): string;
43
+ export declare function trackEmail(html: string, tracking: MessageTracking, unsubscribeUrl?: string): string;
40
44
  /** Development sender: structured logs, for eyeballing the pipeline. */
41
45
  export declare class LogMessageSender implements MessageSender {
42
46
  send(message: OutboundMessage): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"senders.d.mts","names":[],"sources":["../../src/cloudflare/senders.ts"],"mappings":";;;;;;;;;;;YAaY,iBACV,kBACA,OAAO,eAAe,0BACnB;EAAU;EAAiB;;iBAEf;;EAEf,SAAS;EACT,MAAM;EACN,UAAU;EACV,QAAQ;;;;;;;;qBASG,yBAAyB;mBACP;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB;;;;;;;wBA6BxB,WAAW,cAAc,UAAU;;qBA0BtC,4BAA4B;EACjC,KAAK,SAAS,kBAAkB;;;;;;;qBAW3B,gCAAgC;mBACd;EAA7B,YAA6B;EAEvB,KAAK,SAAS,kBAAkB;;;;;;wBAmBxB,eACd,SAAS,QAAQ,OAAO,4BAA4B,kBACnD;;;;;;;wBAkBa,gBAAgB,SAAS,eAAe,iBAAiB"}
1
+ {"version":3,"file":"senders.d.mts","names":[],"sources":["../../src/cloudflare/senders.ts"],"mappings":";;;;;;;iBASiB,sBAAsB;EACrC;EACA;;;;;;;;;YAUU,iBACV,kBACA,OAAO,eAAe,0BACnB,QAAQ;iBAEI;;EAEf,SAAS;EACT,QAAQ;;;;;;;;qBASG,yBAAyB;mBACP;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB;;;;;;;;;wBAmCxB,WACd,cACA,UAAU,iBACV;;qBA2BW,4BAA4B;EACjC,KAAK,SAAS,kBAAkB;;;;;;;qBAW3B,gCAAgC;mBACd;EAA7B,YAA6B;EAEvB,KAAK,SAAS,kBAAkB;;;;;;wBAmBxB,eACd,SAAS,QAAQ,OAAO,4BAA4B,kBACnD;;;;;;;wBAkBa,gBAAgB,SAAS,eAAe,iBAAiB"}
@@ -1,3 +1,4 @@
1
+ import { isUnsubscribeLink } from "../email.mjs";
1
2
  //#region src/cloudflare/senders.ts
2
3
  /**
3
4
  * Send straight through Cloudflare Email Service — a binding call, no outbound
@@ -13,13 +14,13 @@ var CfEmailSender = class {
13
14
  async send(message) {
14
15
  if (message.channel !== "email") throw new Error(`CfEmailSender: unsupported channel '${message.channel}'`);
15
16
  if (message.recipient === void 0) throw new Error(`CfEmailSender: no recipient for user '${message.userId}'`);
16
- const { subject, html } = await this.options.render(message.template, message.props);
17
- const tracked = message.tracking === void 0 ? html : trackEmail(html, message.tracking);
17
+ const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(message.template, message.props);
18
+ const tracked = message.tracking === void 0 ? html : trackEmail(html, message.tracking, unsubscribeUrl);
18
19
  await message.tracking?.commit?.();
19
20
  await this.options.binding.send({
20
- from: this.options.from,
21
+ from,
21
22
  to: message.recipient,
22
- ...this.options.replyTo === void 0 ? {} : { replyTo: this.options.replyTo },
23
+ ...replyTo === void 0 ? {} : { replyTo },
23
24
  subject,
24
25
  html: tracked,
25
26
  idempotencyKey: message.idempotencyKey
@@ -29,13 +30,19 @@ var CfEmailSender = class {
29
30
  const LINK = /(<a\b[^>]*?\shref\s*=\s*)(["'])(https?:\/\/[^"']+)\2/gi;
30
31
  /**
31
32
  * An email's tracking applied to its rendered HTML: every `http(s)` anchor routed through
32
- * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back),
33
- * and the open pixel appended before `</body>`, or at the end when there is none.
33
+ * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back) —
34
+ * as the unsubscribe link when it is the one the email was rendered with, so what a click on it
35
+ * means is fixed when the email is sent — and the open pixel appended before `</body>`, or at the
36
+ * end when there is none.
34
37
  */
35
- function trackEmail(html, tracking) {
38
+ function trackEmail(html, tracking, unsubscribeUrl) {
36
39
  const { link, pixel } = tracking;
37
40
  let out = html;
38
- if (link !== void 0) out = out.replace(LINK, (_, start, quote, url) => `${start}${quote}${escapeAttribute(link(unescapeAttribute(url)))}${quote}`);
41
+ if (link !== void 0) out = out.replace(LINK, (_, start, quote, attribute) => {
42
+ const url = unescapeAttribute(attribute);
43
+ const purpose = isUnsubscribeLink(url, unsubscribeUrl) ? "unsubscribe" : "link";
44
+ return `${start}${quote}${escapeAttribute(link(url, purpose))}${quote}`;
45
+ });
39
46
  if (pixel !== void 0) {
40
47
  const image = `<img src="${escapeAttribute(pixel)}" width="1" height="1" alt="" style="display:block;border:0;width:1px;height:1px" />`;
41
48
  out = /<\/body>/i.test(out) ? out.replace(/<\/body>/i, `${image}</body>`) : `${out}${image}`;
@@ -1 +1 @@
1
- {"version":3,"file":"senders.mjs","names":[],"sources":["../../src/cloudflare/senders.ts"],"sourcesContent":["import type { EmailAddress } from '../config';\nimport type { MessageSender, MessageTracking, OutboundMessage } from '../engine';\nimport type { ScalarIR } from '../ir';\nimport type { EmailBindingLike } from './bindings';\n\nexport type { EmailAddress } from '../config';\nexport type { EmailBindingLike } from './bindings';\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.\n */\nexport type EmailRenderer = (\n template: string,\n props: Record<string, ScalarIR | undefined>\n) => Promise<{ subject: string; html: string }>;\n\nexport interface CfEmailOptions {\n /** The Worker's `send_email` binding. */\n binding: EmailBindingLike;\n from: EmailAddress;\n replyTo?: EmailAddress;\n render: EmailRenderer;\n}\n\n/**\n * Send straight through Cloudflare Email Service — a binding call, no outbound\n * HTTP. Throwing hands retries to the CF step, and the message's\n * idempotencyKey travels with the call so the binding can drop the duplicate a\n * retry or replay produces.\n */\nexport class CfEmailSender implements MessageSender {\n constructor(private readonly options: CfEmailOptions) {}\n\n async send(message: OutboundMessage): Promise<void> {\n if (message.channel !== 'email') {\n throw new Error(`CfEmailSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`CfEmailSender: no recipient for user '${message.userId}'`);\n }\n const { subject, html } = await this.options.render(message.template, message.props);\n const tracked = message.tracking === undefined ? html : trackEmail(html, message.tracking);\n // The tracked links resolve before the email exists anywhere a scanner could follow them\n await message.tracking?.commit?.();\n await this.options.binding.send({\n from: this.options.from,\n to: message.recipient,\n ...(this.options.replyTo === undefined ? {} : { replyTo: this.options.replyTo }),\n subject,\n html: tracked,\n idempotencyKey: message.idempotencyKey,\n });\n }\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 * and the open pixel appended before `</body>`, or at the end when there is none.\n */\nexport function trackEmail(html: string, tracking: MessageTracking): string {\n const { link, pixel } = tracking;\n let out = html;\n if (link !== undefined) {\n out = out.replace(\n LINK,\n (_, start: string, quote: string, url: string) =>\n `${start}${quote}${escapeAttribute(link(unescapeAttribute(url)))}${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('&amp;', '&').replaceAll('&quot;', '\"').replaceAll('&#x27;', \"'\");\n}\n\nfunction escapeAttribute(value: string): string {\n return value.replaceAll('&', '&amp;').replaceAll('\"', '&quot;');\n}\n\n/** Development sender: structured logs, for eyeballing the pipeline. */\nexport class LogMessageSender implements MessageSender {\n async send(message: OutboundMessage): Promise<void> {\n const { tracking: _tracking, ...logged } = message;\n console.log(`[send] ${JSON.stringify(logged)}`);\n }\n}\n\n/**\n * Webhook sender: POST to an external delivery service (a Resend/SES gateway,\n * say). The idempotency key travels with it, so the receiver de-duplicates\n * retries and replays on idempotencyKey.\n */\nexport class WebhookMessageSender implements MessageSender {\n constructor(private readonly url: string) {}\n\n async send(message: OutboundMessage): Promise<void> {\n const response = await fetch(this.url, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n 'idempotency-key': message.idempotencyKey,\n },\n body: JSON.stringify(message),\n });\n if (!response.ok) {\n throw new Error(`message webhook failed: ${response.status}`);\n }\n }\n}\n\n/**\n * One sender per channel: the runner has a single outlet, this fans it out.\n * A channel without a sender fails the send (a misconfiguration, not a retry).\n */\nexport function routeByChannel(\n senders: Partial<Record<OutboundMessage['channel'], MessageSender>>\n): MessageSender {\n return {\n async send(message) {\n const sender = senders[message.channel];\n if (sender === undefined) {\n throw new Error(`no sender configured for channel '${message.channel}'`);\n }\n return sender.send(message);\n },\n };\n}\n\n/**\n * One push outlet per platform, chosen by the device each copy goes to (the\n * engine sends one message per device, and the host's directory says which\n * platform a token belongs to): `ios` straight to APNs, `android` through FCM,\n * say. A platform with no sender fails the step rather than guessing.\n */\nexport function routeByPlatform(senders: Record<string, MessageSender>): MessageSender {\n return {\n async send(message) {\n const platform = message.device?.platform;\n if (platform === undefined) {\n throw new Error(`push for user '${message.userId}' carries no device`);\n }\n if (!Object.hasOwn(senders, platform)) {\n throw new Error(`no push sender configured for platform '${platform}'`);\n }\n return senders[platform].send(message);\n },\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"senders.mjs","names":[],"sources":["../../src/cloudflare/senders.ts"],"sourcesContent":["import { type ResolvedEmail, isUnsubscribeLink } from '../email';\nimport type { MessageSender, MessageTracking, OutboundMessage } from '../engine';\nimport type { ScalarIR } from '../ir';\nimport type { EmailBindingLike } from './bindings';\n\nexport type { EmailAddress } from '../email';\nexport type { EmailBindingLike } from './bindings';\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.\n */\nexport type EmailRenderer = (\n template: string,\n props: Record<string, ScalarIR | undefined>\n) => Promise<RenderedEmail>;\n\nexport interface CfEmailOptions {\n /** The Worker's `send_email` binding. */\n binding: EmailBindingLike;\n render: EmailRenderer;\n}\n\n/**\n * Send straight through Cloudflare Email Service — a binding call, no outbound\n * HTTP. Throwing hands retries to the CF step, and the message's\n * idempotencyKey travels with the call so the binding can drop the duplicate a\n * retry or replay produces.\n */\nexport class CfEmailSender implements MessageSender {\n constructor(private readonly options: CfEmailOptions) {}\n\n async send(message: OutboundMessage): Promise<void> {\n if (message.channel !== 'email') {\n throw new Error(`CfEmailSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`CfEmailSender: no recipient for user '${message.userId}'`);\n }\n const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(\n message.template,\n message.props\n );\n const tracked =\n message.tracking === undefined ? html : trackEmail(html, message.tracking, unsubscribeUrl);\n // The tracked links resolve before the email exists anywhere a scanner could follow them\n await message.tracking?.commit?.();\n await this.options.binding.send({\n from,\n to: message.recipient,\n ...(replyTo === undefined ? {} : { replyTo }),\n subject,\n html: tracked,\n idempotencyKey: message.idempotencyKey,\n });\n }\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('&amp;', '&').replaceAll('&quot;', '\"').replaceAll('&#x27;', \"'\");\n}\n\nfunction escapeAttribute(value: string): string {\n return value.replaceAll('&', '&amp;').replaceAll('\"', '&quot;');\n}\n\n/** Development sender: structured logs, for eyeballing the pipeline. */\nexport class LogMessageSender implements MessageSender {\n async send(message: OutboundMessage): Promise<void> {\n const { tracking: _tracking, ...logged } = message;\n console.log(`[send] ${JSON.stringify(logged)}`);\n }\n}\n\n/**\n * Webhook sender: POST to an external delivery service (a Resend/SES gateway,\n * say). The idempotency key travels with it, so the receiver de-duplicates\n * retries and replays on idempotencyKey.\n */\nexport class WebhookMessageSender implements MessageSender {\n constructor(private readonly url: string) {}\n\n async send(message: OutboundMessage): Promise<void> {\n const response = await fetch(this.url, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n 'idempotency-key': message.idempotencyKey,\n },\n body: JSON.stringify(message),\n });\n if (!response.ok) {\n throw new Error(`message webhook failed: ${response.status}`);\n }\n }\n}\n\n/**\n * One sender per channel: the runner has a single outlet, this fans it out.\n * A channel without a sender fails the send (a misconfiguration, not a retry).\n */\nexport function routeByChannel(\n senders: Partial<Record<OutboundMessage['channel'], MessageSender>>\n): MessageSender {\n return {\n async send(message) {\n const sender = senders[message.channel];\n if (sender === undefined) {\n throw new Error(`no sender configured for channel '${message.channel}'`);\n }\n return sender.send(message);\n },\n };\n}\n\n/**\n * One push outlet per platform, chosen by the device each copy goes to (the\n * engine sends one message per device, and the host's directory says which\n * platform a token belongs to): `ios` straight to APNs, `android` through FCM,\n * say. A platform with no sender fails the step rather than guessing.\n */\nexport function routeByPlatform(senders: Record<string, MessageSender>): MessageSender {\n return {\n async send(message) {\n const platform = message.device?.platform;\n if (platform === undefined) {\n throw new Error(`push for user '${message.userId}' carries no device`);\n }\n if (!Object.hasOwn(senders, platform)) {\n throw new Error(`no push sender configured for platform '${platform}'`);\n }\n return senders[platform].send(message);\n },\n };\n}\n"],"mappings":"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"}
@@ -1,5 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_runtime = require("../_virtual/_rolldown/runtime.cjs");
3
+ const require_email = require("../email.cjs");
3
4
  const require_templates = require("../templates.cjs");
4
5
  const require_config = require("../config.cjs");
5
6
  const require_contract = require("../contract.cjs");
@@ -39,6 +40,8 @@ var EnvValidationError = class extends Error {
39
40
  };
40
41
  function journeyWorker(options) {
41
42
  const { config } = options;
43
+ const emailIds = require_templates.indexTemplates("email", config.templates?.email);
44
+ require_email.assertEmailConfig(config, emailIds.values());
42
45
  const checked = /* @__PURE__ */ new WeakMap();
43
46
  const checkEnv = (env) => {
44
47
  if (options.env === void 0) return env;
@@ -109,7 +112,7 @@ function journeyWorker(options) {
109
112
  headers: { "content-type": "application/json" }
110
113
  });
111
114
  const authorized = (request, env) => env.API_TOKEN === void 0 || request.headers.get("authorization") === `Bearer ${env.API_TOKEN}`;
112
- const preview = emails === void 0 ? void 0 : require_react_email.registryRenderer(emails);
115
+ const preview = emails === void 0 ? void 0 : require_react_email.registryRenderer(emails, config.email);
113
116
  let applying;
114
117
  let retryAt = 0;
115
118
  const applyOwnBundle = (env) => {
@@ -158,9 +161,9 @@ function journeyWorker(options) {
158
161
  if (url.pathname.startsWith("/t/") && store.reporting !== void 0) {
159
162
  const openedEvent = config.webPush?.openedEvent;
160
163
  const tracked = await require_reporting_tracking.handleTracking(request, store.reporting, {
161
- unsubscribe: config.emails?.unsubscribe,
164
+ legacyUnsubscribeUrl: config.email?.unsubscribeUrl,
162
165
  product: config.title,
163
- quickMs: config.emails?.tracking?.quickClickMs,
166
+ quickMs: config.email?.tracking?.quickClickMs,
164
167
  pushOpened: openedEvent === void 0 ? void 0 : async ({ messageId, userId, at }) => {
165
168
  const payload = { message_id: messageId };
166
169
  const contract = config.contract;
@@ -204,7 +207,7 @@ function journeyWorker(options) {
204
207
  if (!authorized(request, env)) return json({ error: "unauthorized" }, 401);
205
208
  if (request.method !== "GET") return json({ error: "method not allowed" }, 405);
206
209
  const key = url.pathname.slice(9);
207
- if (!Object.hasOwn(emails, key)) return json({ error: `unknown email template: ${key}` }, 404);
210
+ if (!emailIds.has(key)) return json({ error: `unknown email template: ${key}` }, 404);
208
211
  let props = {};
209
212
  try {
210
213
  props = JSON.parse(url.searchParams.get("props") ?? "{}");