@shware/workflow 3.2.0 → 3.3.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 (40) hide show
  1. package/README.md +9 -0
  2. package/dist/cloudflare/bindings.cjs.map +1 -1
  3. package/dist/cloudflare/bindings.d.cts +2 -0
  4. package/dist/cloudflare/bindings.d.cts.map +1 -1
  5. package/dist/cloudflare/bindings.d.mts +2 -0
  6. package/dist/cloudflare/bindings.d.mts.map +1 -1
  7. package/dist/cloudflare/bindings.mjs.map +1 -1
  8. package/dist/email-sender.cjs +24 -0
  9. package/dist/email-sender.cjs.map +1 -1
  10. package/dist/email-sender.d.cts +18 -0
  11. package/dist/email-sender.d.cts.map +1 -1
  12. package/dist/email-sender.d.mts +18 -0
  13. package/dist/email-sender.d.mts.map +1 -1
  14. package/dist/email-sender.mjs +24 -1
  15. package/dist/email-sender.mjs.map +1 -1
  16. package/dist/engine/ports.d.cts +6 -0
  17. package/dist/engine/ports.d.cts.map +1 -1
  18. package/dist/engine/ports.d.mts +6 -0
  19. package/dist/engine/ports.d.mts.map +1 -1
  20. package/dist/reporting/observer.cjs +1 -0
  21. package/dist/reporting/observer.cjs.map +1 -1
  22. package/dist/reporting/observer.mjs +1 -0
  23. package/dist/reporting/observer.mjs.map +1 -1
  24. package/dist/reporting/tracking.cjs +7 -2
  25. package/dist/reporting/tracking.cjs.map +1 -1
  26. package/dist/reporting/tracking.d.cts +3 -1
  27. package/dist/reporting/tracking.d.cts.map +1 -1
  28. package/dist/reporting/tracking.d.mts +3 -1
  29. package/dist/reporting/tracking.d.mts.map +1 -1
  30. package/dist/reporting/tracking.mjs +7 -2
  31. package/dist/reporting/tracking.mjs.map +1 -1
  32. package/dist/transports/cloudflare-email.cjs +2 -1
  33. package/dist/transports/cloudflare-email.cjs.map +1 -1
  34. package/dist/transports/cloudflare-email.mjs +2 -1
  35. package/dist/transports/cloudflare-email.mjs.map +1 -1
  36. package/dist/transports/ses.cjs +5 -1
  37. package/dist/transports/ses.cjs.map +1 -1
  38. package/dist/transports/ses.mjs +5 -1
  39. package/dist/transports/ses.mjs.map +1 -1
  40. package/package.json +1 -1
package/README.md CHANGED
@@ -524,6 +524,15 @@ provider accepted just before the step's checkpoint failed can go out twice.
524
524
  A host whose workflows send email but that has no `email` option (nor a
525
525
  `messages` override) fails when it is created, naming those workflows.
526
526
 
527
+ An email with an unsubscribe link also carries `List-Unsubscribe` (RFC 2369), so
528
+ a mail client's own unsubscribe button works. With reporting on (a Postgres store and
529
+ `WORKFLOW_ORIGIN`), it names the copy's one-click endpoint `/t/u/<message>`, together with
530
+ `List-Unsubscribe-Post: List-Unsubscribe=One-Click` (RFC 8058). Gmail and Yahoo require
531
+ one-click unsubscribe for bulk senders. The provider's POST there unsubscribes at once, and the
532
+ same URL opened in a browser asks first. Without reporting, the header names the template's own
533
+ unsubscribe link, with no one-click claim. Only https URLs are named, so a local `http://`
534
+ origin sends no header.
535
+
527
536
  An email step refuses to send when the profile has no `email`.
528
537
  `GET /preview/<id>?props=<JSON>` renders a template, with the subject in the
529
538
  `x-subject` header.
@@ -1 +1 @@
1
- {"version":3,"file":"bindings.cjs","names":[],"sources":["../../src/cloudflare/bindings.ts"],"sourcesContent":["import type { EmailAddress } from '../email';\nimport type { ExecutionStart } from '../store/index';\n\n/**\n * A minimal structural interface over the Cloudflare bindings.\n *\n * @cloudflare/workers-types is deliberately not used: the global types it\n * injects clash with the DOM lib this package's react side needs. A structural\n * subset plus the ambient cloudflare:workers declaration is enough to compile,\n * and the real bindings satisfy it structurally at runtime.\n */\n\nexport interface KVNamespaceLike {\n get(key: string): Promise<string | null>;\n put(key: string, value: string): Promise<void>;\n}\n\n/** Structural subset of D1Result: `meta.changes` is how INSERT OR IGNORE reports whether it inserted. */\nexport interface D1RunResultLike {\n meta?: { changes?: number };\n}\n\nexport interface D1PreparedStatementLike {\n bind(...values: unknown[]): D1PreparedStatementLike;\n first<T = Record<string, unknown>>(): Promise<T | null>;\n all<T = Record<string, unknown>>(): Promise<{ results: T[] }>;\n run(): Promise<D1RunResultLike>;\n}\n\nexport interface D1DatabaseLike {\n prepare(sql: string): D1PreparedStatementLike;\n /** Statements run in order inside one transaction (real D1 semantics) — deploy relies on the atomicity. */\n batch(statements: D1PreparedStatementLike[]): Promise<unknown>;\n}\n\nexport interface WorkflowInstanceLike {\n id: string;\n sendEvent(event: { type: string; payload?: unknown }): Promise<void>;\n /** Stop the instance for good (Cloudflare Workflows' `terminate`). */\n terminate?(): Promise<void>;\n /** Where it stands (`complete`, `errored`, `terminated`, `running`, …). */\n status?(): Promise<{ status: string }>;\n}\n\n/**\n * How the router starts, wakes and stops instances: Cloudflare's workflow binding, or the AWS\n * adapter's equivalent over durable executions.\n */\nexport interface WorkflowBindingLike {\n create(options: { id: string; params: unknown }): Promise<WorkflowInstanceLike>;\n get(id: string): Promise<WorkflowInstanceLike>;\n /**\n * For a runtime whose start is a submit that can be lost in flight (AWS): the start to record\n * with the entry, in one transaction. A failed `create` then keeps the entry — its recorded\n * start is retried by the next ingest for the pair (`resume`) or by maintenance, never rolled\n * back and re-entered as a second execution.\n */\n plan?(options: {\n id: string;\n params: JourneyParams;\n ts: number;\n /** The code version whose routes named `params.contentHash` (absent after a forced deploy). */\n version?: string | undefined;\n }): Promise<ExecutionStart>;\n /** Retry the recorded, unconfirmed start of the (workflow, user) entry still running, if any. */\n resume?(entry: { workflow: string; userId: string }): Promise<void>;\n /**\n * Maintenance the sweep runs first: old unconfirmed starts retried (or given up on), and\n * executions the platform ended on its own (timeout, stop, crash) reconciled into the ledger.\n */\n maintain?(): Promise<void>;\n}\n\n/**\n * Cloudflare Email Service's send_email binding (structural subset).\n *\n * `idempotencyKey` is part of the call because the send happens inside a\n * `step.do`: a step body re-runs after its fn resolved but before the\n * checkpoint committed, so a binding that drops the key mails the user twice.\n * The binding is app-supplied glue (nothing here can de-duplicate — there is\n * no state between two runs of the same step), which is why the port makes the\n * key impossible to miss rather than merely available.\n */\nexport interface EmailBindingLike {\n send(message: {\n from: EmailAddress;\n to: EmailAddress;\n replyTo?: EmailAddress;\n subject: string;\n html: string;\n /** `${instanceId}:${nodeId}`: stable across replays and retries — drop a send whose key was already delivered. */\n idempotencyKey: string;\n }): Promise<unknown>;\n}\n\n/** Every binding the journey engine needs (names match the wrangler config). */\nexport interface JourneyEnv {\n /**\n * BundleIR storage for the default D1 store (`wf:${contentHash}` → WorkflowIR JSON). Optional:\n * a Worker on another JourneyStore (Postgres, say) does not declare it.\n */\n WORKFLOW_KV?: KVNamespaceLike;\n /** Tables of the default D1 store (events / profiles / segments / triggers / entries / subscriptions). Optional, as above. */\n DB?: D1DatabaseLike;\n /** JourneyRunner's workflow binding (creating instances and waking them). */\n JOURNEY: WorkflowBindingLike;\n /** Optional message-delivery webhook (defaults to the console logging sender). */\n MESSAGE_WEBHOOK_URL?: string;\n /**\n * Bearer token required on every mutating HTTP endpoint (`Authorization:\n * Bearer <token>`). Unset = open — acceptable only for local dev; /deploy in\n * particular rewrites the whole routing table, so production must set this.\n */\n API_TOKEN?: string;\n /**\n * This Worker's public origin. With a store that keeps reporting (Postgres), journey emails'\n * links and open pixel point at its `/t/...` routes; without it emails go out untracked.\n */\n WORKFLOW_ORIGIN?: string;\n /** Which `workflow.config.ts` entry applies (`development`, `production`, …); production when unset. */\n ENVIRONMENT?: string;\n /** Cloudflare Email Sending, when the config has `emails`. */\n EMAIL?: EmailBindingLike;\n /** The APNs auth key (`.p8`, base64) named by the config's `apns.keyId`. */\n APNS_PRIVATE_KEY_BASE64?: string;\n /** The Firebase service account (base64 JSON) the config's `fcm` sends with. */\n GOOGLE_APPLICATION_CREDENTIALS_BASE64?: string;\n /** The VAPID key pair web pushes are signed with (`generateVapidKeys`); the config's `webPush` turns the channel on. */\n VAPID_PUBLIC_KEY?: string;\n VAPID_PRIVATE_KEY?: string;\n /**\n * Cloudflare's version metadata binding (wrangler `\"version_metadata\": { \"binding\":\n * \"CF_VERSION_METADATA\" }`). With it, and a store that records the live version (Postgres),\n * journeyWorker makes the bundle compiled into each new version live by itself: `wrangler\n * deploy` is the whole deploy.\n */\n CF_VERSION_METADATA?: { id: string; tag?: string; timestamp: string };\n /** Development only: the HTTP/2 relay `wrangler dev` sends APNs requests through (see `@shware/workflow/apns-relay`). */\n APNS_RELAY_ORIGIN?: string;\n}\n\n/** The runtime does not know the instance: the only failure that lets the router drop its wakes. */\nexport class InstanceNotFoundError extends Error {\n override readonly name = 'InstanceNotFoundError';\n constructor(readonly instanceId: string) {\n super(`no such instance: ${instanceId}`);\n }\n}\n\n/**\n * Does this lookup failure say the instance is gone, rather than that the lookup failed?\n * Cloudflare reports an unknown id as `instance.not_found`.\n */\nexport function isInstanceNotFound(error: unknown): boolean {\n if (error instanceof InstanceNotFoundError) return true;\n return /not[_ ]found|no such instance/i.test(String(error));\n}\n\n/** The event type the router uses to wake waiting instances (sendEvent's `type`). */\nexport const WAKE_EVENT_TYPE = 'wake';\n\n/** Journey instance parameters, passed by the ingest router when it creates one. */\nexport interface JourneyParams {\n workflowId: string;\n /** The IR version pinned at entry. */\n contentHash: string;\n userId: string;\n trigger: { event: string; payload: Record<string, unknown> };\n [key: string]: unknown;\n}\n\n/**\n * Slack on the deadline when deciding whether a thrown wait was a timeout: the\n * platform may surface the expiry a moment early, and a wait shorter than this\n * has no room to tell the two apart anyway.\n */\nexport const WAKE_TIMEOUT_TOLERANCE_MS = 5_000;\n\n/**\n * Did a wait that threw actually reach its deadline?\n *\n * CF signals a wait timeout by throwing, and so does every other failure in\n * there — a broken binding, an evicted instance, a malformed timeout. The\n * adapter used to read them all as timeouts, which made an infrastructure\n * fault indistinguishable from \"the user never did it\": the journey took its\n * onTimeout branch, early and silently. Only the clock can tell them apart.\n */\nexport function wakeExpired(startedAt: number, timeoutMs: number, now: number): boolean {\n return now - startedAt >= Math.max(0, timeoutMs - WAKE_TIMEOUT_TOLERANCE_MS);\n}\n"],"mappings":";;;AA8IA,IAAa,wBAAb,cAA2C,MAAM;CAE1B;CADrB,OAAyB;CACzB,YAAY,YAA6B;EACvC,MAAM,qBAAqB,YAAY;EADpB,KAAA,aAAA;CAErB;AACF;;;;;AAMA,SAAgB,mBAAmB,OAAyB;CAC1D,IAAI,iBAAiB,uBAAuB,OAAO;CACnD,OAAO,iCAAiC,KAAK,OAAO,KAAK,CAAC;AAC5D;;AAGA,MAAa,kBAAkB;;;;;;AAiB/B,MAAa,4BAA4B;;;;;;;;;;AAWzC,SAAgB,YAAY,WAAmB,WAAmB,KAAsB;CACtF,OAAO,MAAM,aAAa,KAAK,IAAI,GAAG,YAAY,yBAAyB;AAC7E"}
1
+ {"version":3,"file":"bindings.cjs","names":[],"sources":["../../src/cloudflare/bindings.ts"],"sourcesContent":["import type { EmailAddress } from '../email';\nimport type { ExecutionStart } from '../store/index';\n\n/**\n * A minimal structural interface over the Cloudflare bindings.\n *\n * @cloudflare/workers-types is deliberately not used: the global types it\n * injects clash with the DOM lib this package's react side needs. A structural\n * subset plus the ambient cloudflare:workers declaration is enough to compile,\n * and the real bindings satisfy it structurally at runtime.\n */\n\nexport interface KVNamespaceLike {\n get(key: string): Promise<string | null>;\n put(key: string, value: string): Promise<void>;\n}\n\n/** Structural subset of D1Result: `meta.changes` is how INSERT OR IGNORE reports whether it inserted. */\nexport interface D1RunResultLike {\n meta?: { changes?: number };\n}\n\nexport interface D1PreparedStatementLike {\n bind(...values: unknown[]): D1PreparedStatementLike;\n first<T = Record<string, unknown>>(): Promise<T | null>;\n all<T = Record<string, unknown>>(): Promise<{ results: T[] }>;\n run(): Promise<D1RunResultLike>;\n}\n\nexport interface D1DatabaseLike {\n prepare(sql: string): D1PreparedStatementLike;\n /** Statements run in order inside one transaction (real D1 semantics) — deploy relies on the atomicity. */\n batch(statements: D1PreparedStatementLike[]): Promise<unknown>;\n}\n\nexport interface WorkflowInstanceLike {\n id: string;\n sendEvent(event: { type: string; payload?: unknown }): Promise<void>;\n /** Stop the instance for good (Cloudflare Workflows' `terminate`). */\n terminate?(): Promise<void>;\n /** Where it stands (`complete`, `errored`, `terminated`, `running`, …). */\n status?(): Promise<{ status: string }>;\n}\n\n/**\n * How the router starts, wakes and stops instances: Cloudflare's workflow binding, or the AWS\n * adapter's equivalent over durable executions.\n */\nexport interface WorkflowBindingLike {\n create(options: { id: string; params: unknown }): Promise<WorkflowInstanceLike>;\n get(id: string): Promise<WorkflowInstanceLike>;\n /**\n * For a runtime whose start is a submit that can be lost in flight (AWS): the start to record\n * with the entry, in one transaction. A failed `create` then keeps the entry — its recorded\n * start is retried by the next ingest for the pair (`resume`) or by maintenance, never rolled\n * back and re-entered as a second execution.\n */\n plan?(options: {\n id: string;\n params: JourneyParams;\n ts: number;\n /** The code version whose routes named `params.contentHash` (absent after a forced deploy). */\n version?: string | undefined;\n }): Promise<ExecutionStart>;\n /** Retry the recorded, unconfirmed start of the (workflow, user) entry still running, if any. */\n resume?(entry: { workflow: string; userId: string }): Promise<void>;\n /**\n * Maintenance the sweep runs first: old unconfirmed starts retried (or given up on), and\n * executions the platform ended on its own (timeout, stop, crash) reconciled into the ledger.\n */\n maintain?(): Promise<void>;\n}\n\n/**\n * Cloudflare Email Service's send_email binding (structural subset).\n *\n * `idempotencyKey` is part of the call because the send happens inside a\n * `step.do`: a step body re-runs after its fn resolved but before the\n * checkpoint committed, so a binding that drops the key mails the user twice.\n * The binding is app-supplied glue (nothing here can de-duplicate — there is\n * no state between two runs of the same step), which is why the port makes the\n * key impossible to miss rather than merely available.\n */\nexport interface EmailBindingLike {\n send(message: {\n from: EmailAddress;\n to: EmailAddress;\n replyTo?: EmailAddress;\n subject: string;\n html: string;\n /** List-Unsubscribe and List-Unsubscribe-Post, when the email has an unsubscribe link. */\n headers?: Readonly<Record<string, string>>;\n /** `${instanceId}:${nodeId}`: stable across replays and retries — drop a send whose key was already delivered. */\n idempotencyKey: string;\n }): Promise<unknown>;\n}\n\n/** Every binding the journey engine needs (names match the wrangler config). */\nexport interface JourneyEnv {\n /**\n * BundleIR storage for the default D1 store (`wf:${contentHash}` → WorkflowIR JSON). Optional:\n * a Worker on another JourneyStore (Postgres, say) does not declare it.\n */\n WORKFLOW_KV?: KVNamespaceLike;\n /** Tables of the default D1 store (events / profiles / segments / triggers / entries / subscriptions). Optional, as above. */\n DB?: D1DatabaseLike;\n /** JourneyRunner's workflow binding (creating instances and waking them). */\n JOURNEY: WorkflowBindingLike;\n /** Optional message-delivery webhook (defaults to the console logging sender). */\n MESSAGE_WEBHOOK_URL?: string;\n /**\n * Bearer token required on every mutating HTTP endpoint (`Authorization:\n * Bearer <token>`). Unset = open — acceptable only for local dev; /deploy in\n * particular rewrites the whole routing table, so production must set this.\n */\n API_TOKEN?: string;\n /**\n * This Worker's public origin. With a store that keeps reporting (Postgres), journey emails'\n * links and open pixel point at its `/t/...` routes; without it emails go out untracked.\n */\n WORKFLOW_ORIGIN?: string;\n /** Which `workflow.config.ts` entry applies (`development`, `production`, …); production when unset. */\n ENVIRONMENT?: string;\n /** Cloudflare Email Sending, when the config has `emails`. */\n EMAIL?: EmailBindingLike;\n /** The APNs auth key (`.p8`, base64) named by the config's `apns.keyId`. */\n APNS_PRIVATE_KEY_BASE64?: string;\n /** The Firebase service account (base64 JSON) the config's `fcm` sends with. */\n GOOGLE_APPLICATION_CREDENTIALS_BASE64?: string;\n /** The VAPID key pair web pushes are signed with (`generateVapidKeys`); the config's `webPush` turns the channel on. */\n VAPID_PUBLIC_KEY?: string;\n VAPID_PRIVATE_KEY?: string;\n /**\n * Cloudflare's version metadata binding (wrangler `\"version_metadata\": { \"binding\":\n * \"CF_VERSION_METADATA\" }`). With it, and a store that records the live version (Postgres),\n * journeyWorker makes the bundle compiled into each new version live by itself: `wrangler\n * deploy` is the whole deploy.\n */\n CF_VERSION_METADATA?: { id: string; tag?: string; timestamp: string };\n /** Development only: the HTTP/2 relay `wrangler dev` sends APNs requests through (see `@shware/workflow/apns-relay`). */\n APNS_RELAY_ORIGIN?: string;\n}\n\n/** The runtime does not know the instance: the only failure that lets the router drop its wakes. */\nexport class InstanceNotFoundError extends Error {\n override readonly name = 'InstanceNotFoundError';\n constructor(readonly instanceId: string) {\n super(`no such instance: ${instanceId}`);\n }\n}\n\n/**\n * Does this lookup failure say the instance is gone, rather than that the lookup failed?\n * Cloudflare reports an unknown id as `instance.not_found`.\n */\nexport function isInstanceNotFound(error: unknown): boolean {\n if (error instanceof InstanceNotFoundError) return true;\n return /not[_ ]found|no such instance/i.test(String(error));\n}\n\n/** The event type the router uses to wake waiting instances (sendEvent's `type`). */\nexport const WAKE_EVENT_TYPE = 'wake';\n\n/** Journey instance parameters, passed by the ingest router when it creates one. */\nexport interface JourneyParams {\n workflowId: string;\n /** The IR version pinned at entry. */\n contentHash: string;\n userId: string;\n trigger: { event: string; payload: Record<string, unknown> };\n [key: string]: unknown;\n}\n\n/**\n * Slack on the deadline when deciding whether a thrown wait was a timeout: the\n * platform may surface the expiry a moment early, and a wait shorter than this\n * has no room to tell the two apart anyway.\n */\nexport const WAKE_TIMEOUT_TOLERANCE_MS = 5_000;\n\n/**\n * Did a wait that threw actually reach its deadline?\n *\n * CF signals a wait timeout by throwing, and so does every other failure in\n * there — a broken binding, an evicted instance, a malformed timeout. The\n * adapter used to read them all as timeouts, which made an infrastructure\n * fault indistinguishable from \"the user never did it\": the journey took its\n * onTimeout branch, early and silently. Only the clock can tell them apart.\n */\nexport function wakeExpired(startedAt: number, timeoutMs: number, now: number): boolean {\n return now - startedAt >= Math.max(0, timeoutMs - WAKE_TIMEOUT_TOLERANCE_MS);\n}\n"],"mappings":";;;AAgJA,IAAa,wBAAb,cAA2C,MAAM;CAE1B;CADrB,OAAyB;CACzB,YAAY,YAA6B;EACvC,MAAM,qBAAqB,YAAY;EADpB,KAAA,aAAA;CAErB;AACF;;;;;AAMA,SAAgB,mBAAmB,OAAyB;CAC1D,IAAI,iBAAiB,uBAAuB,OAAO;CACnD,OAAO,iCAAiC,KAAK,OAAO,KAAK,CAAC;AAC5D;;AAGA,MAAa,kBAAkB;;;;;;AAiB/B,MAAa,4BAA4B;;;;;;;;;;AAWzC,SAAgB,YAAY,WAAmB,WAAmB,KAAsB;CACtF,OAAO,MAAM,aAAa,KAAK,IAAI,GAAG,YAAY,yBAAyB;AAC7E"}
@@ -96,6 +96,8 @@ export interface EmailBindingLike {
96
96
  replyTo?: EmailAddress;
97
97
  subject: string;
98
98
  html: string;
99
+ /** List-Unsubscribe and List-Unsubscribe-Post, when the email has an unsubscribe link. */
100
+ headers?: Readonly<Record<string, string>>;
99
101
  /** `${instanceId}:${nodeId}`: stable across replays and retries — drop a send whose key was already delivered. */
100
102
  idempotencyKey: string;
101
103
  }): Promise<unknown>;
@@ -1 +1 @@
1
- {"version":3,"file":"bindings.d.cts","names":[],"sources":["../../src/cloudflare/bindings.ts"],"mappings":";;;;;;;;;;;iBAYiB;EACf,IAAI,cAAc;EAClB,IAAI,aAAa,gBAAgB;;;iBAIlB;EACf;IAAS;;;iBAGM;EACf,QAAQ,oBAAoB;EAC5B,MAAM,IAAI,4BAA4B,QAAQ;EAC9C,IAAI,IAAI,4BAA4B;IAAU,SAAS;;EACvD,OAAO,QAAQ;;iBAGA;EACf,QAAQ,cAAc;;EAEtB,MAAM,YAAY,4BAA4B;;iBAG/B;EACf;EACA,UAAU;IAAS;IAAc;MAAsB;;EAEvD,cAAc;;EAEd,WAAW;IAAU;;;;;;;iBAON;EACf,OAAO;IAAW;IAAY;MAAoB,QAAQ;EAC1D,IAAI,aAAa,QAAQ;;;;;;;EAOzB,MAAM;IACJ;IACA,QAAQ;IACR;;IAEA;MACE,QAAQ;;EAEZ,QAAQ;IAAS;IAAkB;MAAmB;;;;;EAKtD,aAAa;;;;;;;;;;;;iBAaE;EACf,KAAK;IACH,MAAM;IACN,IAAI;IACJ,UAAU;IACV;IACA;;IAEA;MACE;;;iBAIW;;;;;EAKf,cAAc;;EAEd,KAAK;;EAEL,SAAS;;EAET;;;;;;EAMA;;;;;EAKA;;EAEA;;EAEA,QAAQ;;EAER;;EAEA;;EAEA;EACA;;;;;;;EAOA;IAAwB;IAAY;IAAc;;;EAElD;;;qBAIW,8BAA8B;WAEpB;WADH;EAClB,YAAqB;;;;;;wBASP,mBAAmB;;qBAMtB;;iBAGI;EACf;;EAEA;EACA;EACA;IAAW;IAAe,SAAS;;GAClC;;;;;;;qBAQU;;;;;;;;;;wBAWG,YAAY,mBAAmB,mBAAmB"}
1
+ {"version":3,"file":"bindings.d.cts","names":[],"sources":["../../src/cloudflare/bindings.ts"],"mappings":";;;;;;;;;;;iBAYiB;EACf,IAAI,cAAc;EAClB,IAAI,aAAa,gBAAgB;;;iBAIlB;EACf;IAAS;;;iBAGM;EACf,QAAQ,oBAAoB;EAC5B,MAAM,IAAI,4BAA4B,QAAQ;EAC9C,IAAI,IAAI,4BAA4B;IAAU,SAAS;;EACvD,OAAO,QAAQ;;iBAGA;EACf,QAAQ,cAAc;;EAEtB,MAAM,YAAY,4BAA4B;;iBAG/B;EACf;EACA,UAAU;IAAS;IAAc;MAAsB;;EAEvD,cAAc;;EAEd,WAAW;IAAU;;;;;;;iBAON;EACf,OAAO;IAAW;IAAY;MAAoB,QAAQ;EAC1D,IAAI,aAAa,QAAQ;;;;;;;EAOzB,MAAM;IACJ;IACA,QAAQ;IACR;;IAEA;MACE,QAAQ;;EAEZ,QAAQ;IAAS;IAAkB;MAAmB;;;;;EAKtD,aAAa;;;;;;;;;;;;iBAaE;EACf,KAAK;IACH,MAAM;IACN,IAAI;IACJ,UAAU;IACV;IACA;;IAEA,UAAU,SAAS;;IAEnB;MACE;;;iBAIW;;;;;EAKf,cAAc;;EAEd,KAAK;;EAEL,SAAS;;EAET;;;;;;EAMA;;;;;EAKA;;EAEA;;EAEA,QAAQ;;EAER;;EAEA;;EAEA;EACA;;;;;;;EAOA;IAAwB;IAAY;IAAc;;;EAElD;;;qBAIW,8BAA8B;WAEpB;WADH;EAClB,YAAqB;;;;;;wBASP,mBAAmB;;qBAMtB;;iBAGI;EACf;;EAEA;EACA;EACA;IAAW;IAAe,SAAS;;GAClC;;;;;;;qBAQU;;;;;;;;;;wBAWG,YAAY,mBAAmB,mBAAmB"}
@@ -96,6 +96,8 @@ export interface EmailBindingLike {
96
96
  replyTo?: EmailAddress;
97
97
  subject: string;
98
98
  html: string;
99
+ /** List-Unsubscribe and List-Unsubscribe-Post, when the email has an unsubscribe link. */
100
+ headers?: Readonly<Record<string, string>>;
99
101
  /** `${instanceId}:${nodeId}`: stable across replays and retries — drop a send whose key was already delivered. */
100
102
  idempotencyKey: string;
101
103
  }): Promise<unknown>;
@@ -1 +1 @@
1
- {"version":3,"file":"bindings.d.mts","names":[],"sources":["../../src/cloudflare/bindings.ts"],"mappings":";;;;;;;;;;;iBAYiB;EACf,IAAI,cAAc;EAClB,IAAI,aAAa,gBAAgB;;;iBAIlB;EACf;IAAS;;;iBAGM;EACf,QAAQ,oBAAoB;EAC5B,MAAM,IAAI,4BAA4B,QAAQ;EAC9C,IAAI,IAAI,4BAA4B;IAAU,SAAS;;EACvD,OAAO,QAAQ;;iBAGA;EACf,QAAQ,cAAc;;EAEtB,MAAM,YAAY,4BAA4B;;iBAG/B;EACf;EACA,UAAU;IAAS;IAAc;MAAsB;;EAEvD,cAAc;;EAEd,WAAW;IAAU;;;;;;;iBAON;EACf,OAAO;IAAW;IAAY;MAAoB,QAAQ;EAC1D,IAAI,aAAa,QAAQ;;;;;;;EAOzB,MAAM;IACJ;IACA,QAAQ;IACR;;IAEA;MACE,QAAQ;;EAEZ,QAAQ;IAAS;IAAkB;MAAmB;;;;;EAKtD,aAAa;;;;;;;;;;;;iBAaE;EACf,KAAK;IACH,MAAM;IACN,IAAI;IACJ,UAAU;IACV;IACA;;IAEA;MACE;;;iBAIW;;;;;EAKf,cAAc;;EAEd,KAAK;;EAEL,SAAS;;EAET;;;;;;EAMA;;;;;EAKA;;EAEA;;EAEA,QAAQ;;EAER;;EAEA;;EAEA;EACA;;;;;;;EAOA;IAAwB;IAAY;IAAc;;;EAElD;;;qBAIW,8BAA8B;WAEpB;WADH;EAClB,YAAqB;;;;;;wBASP,mBAAmB;;qBAMtB;;iBAGI;EACf;;EAEA;EACA;EACA;IAAW;IAAe,SAAS;;GAClC;;;;;;;qBAQU;;;;;;;;;;wBAWG,YAAY,mBAAmB,mBAAmB"}
1
+ {"version":3,"file":"bindings.d.mts","names":[],"sources":["../../src/cloudflare/bindings.ts"],"mappings":";;;;;;;;;;;iBAYiB;EACf,IAAI,cAAc;EAClB,IAAI,aAAa,gBAAgB;;;iBAIlB;EACf;IAAS;;;iBAGM;EACf,QAAQ,oBAAoB;EAC5B,MAAM,IAAI,4BAA4B,QAAQ;EAC9C,IAAI,IAAI,4BAA4B;IAAU,SAAS;;EACvD,OAAO,QAAQ;;iBAGA;EACf,QAAQ,cAAc;;EAEtB,MAAM,YAAY,4BAA4B;;iBAG/B;EACf;EACA,UAAU;IAAS;IAAc;MAAsB;;EAEvD,cAAc;;EAEd,WAAW;IAAU;;;;;;;iBAON;EACf,OAAO;IAAW;IAAY;MAAoB,QAAQ;EAC1D,IAAI,aAAa,QAAQ;;;;;;;EAOzB,MAAM;IACJ;IACA,QAAQ;IACR;;IAEA;MACE,QAAQ;;EAEZ,QAAQ;IAAS;IAAkB;MAAmB;;;;;EAKtD,aAAa;;;;;;;;;;;;iBAaE;EACf,KAAK;IACH,MAAM;IACN,IAAI;IACJ,UAAU;IACV;IACA;;IAEA,UAAU,SAAS;;IAEnB;MACE;;;iBAIW;;;;;EAKf,cAAc;;EAEd,KAAK;;EAEL,SAAS;;EAET;;;;;;EAMA;;;;;EAKA;;EAEA;;EAEA,QAAQ;;EAER;;EAEA;;EAEA;EACA;;;;;;;EAOA;IAAwB;IAAY;IAAc;;;EAElD;;;qBAIW,8BAA8B;WAEpB;WADH;EAClB,YAAqB;;;;;;wBASP,mBAAmB;;qBAMtB;;iBAGI;EACf;;EAEA;EACA;EACA;IAAW;IAAe,SAAS;;GAClC;;;;;;;qBAQU;;;;;;;;;;wBAWG,YAAY,mBAAmB,mBAAmB"}
@@ -1 +1 @@
1
- {"version":3,"file":"bindings.mjs","names":[],"sources":["../../src/cloudflare/bindings.ts"],"sourcesContent":["import type { EmailAddress } from '../email';\nimport type { ExecutionStart } from '../store/index';\n\n/**\n * A minimal structural interface over the Cloudflare bindings.\n *\n * @cloudflare/workers-types is deliberately not used: the global types it\n * injects clash with the DOM lib this package's react side needs. A structural\n * subset plus the ambient cloudflare:workers declaration is enough to compile,\n * and the real bindings satisfy it structurally at runtime.\n */\n\nexport interface KVNamespaceLike {\n get(key: string): Promise<string | null>;\n put(key: string, value: string): Promise<void>;\n}\n\n/** Structural subset of D1Result: `meta.changes` is how INSERT OR IGNORE reports whether it inserted. */\nexport interface D1RunResultLike {\n meta?: { changes?: number };\n}\n\nexport interface D1PreparedStatementLike {\n bind(...values: unknown[]): D1PreparedStatementLike;\n first<T = Record<string, unknown>>(): Promise<T | null>;\n all<T = Record<string, unknown>>(): Promise<{ results: T[] }>;\n run(): Promise<D1RunResultLike>;\n}\n\nexport interface D1DatabaseLike {\n prepare(sql: string): D1PreparedStatementLike;\n /** Statements run in order inside one transaction (real D1 semantics) — deploy relies on the atomicity. */\n batch(statements: D1PreparedStatementLike[]): Promise<unknown>;\n}\n\nexport interface WorkflowInstanceLike {\n id: string;\n sendEvent(event: { type: string; payload?: unknown }): Promise<void>;\n /** Stop the instance for good (Cloudflare Workflows' `terminate`). */\n terminate?(): Promise<void>;\n /** Where it stands (`complete`, `errored`, `terminated`, `running`, …). */\n status?(): Promise<{ status: string }>;\n}\n\n/**\n * How the router starts, wakes and stops instances: Cloudflare's workflow binding, or the AWS\n * adapter's equivalent over durable executions.\n */\nexport interface WorkflowBindingLike {\n create(options: { id: string; params: unknown }): Promise<WorkflowInstanceLike>;\n get(id: string): Promise<WorkflowInstanceLike>;\n /**\n * For a runtime whose start is a submit that can be lost in flight (AWS): the start to record\n * with the entry, in one transaction. A failed `create` then keeps the entry — its recorded\n * start is retried by the next ingest for the pair (`resume`) or by maintenance, never rolled\n * back and re-entered as a second execution.\n */\n plan?(options: {\n id: string;\n params: JourneyParams;\n ts: number;\n /** The code version whose routes named `params.contentHash` (absent after a forced deploy). */\n version?: string | undefined;\n }): Promise<ExecutionStart>;\n /** Retry the recorded, unconfirmed start of the (workflow, user) entry still running, if any. */\n resume?(entry: { workflow: string; userId: string }): Promise<void>;\n /**\n * Maintenance the sweep runs first: old unconfirmed starts retried (or given up on), and\n * executions the platform ended on its own (timeout, stop, crash) reconciled into the ledger.\n */\n maintain?(): Promise<void>;\n}\n\n/**\n * Cloudflare Email Service's send_email binding (structural subset).\n *\n * `idempotencyKey` is part of the call because the send happens inside a\n * `step.do`: a step body re-runs after its fn resolved but before the\n * checkpoint committed, so a binding that drops the key mails the user twice.\n * The binding is app-supplied glue (nothing here can de-duplicate — there is\n * no state between two runs of the same step), which is why the port makes the\n * key impossible to miss rather than merely available.\n */\nexport interface EmailBindingLike {\n send(message: {\n from: EmailAddress;\n to: EmailAddress;\n replyTo?: EmailAddress;\n subject: string;\n html: string;\n /** `${instanceId}:${nodeId}`: stable across replays and retries — drop a send whose key was already delivered. */\n idempotencyKey: string;\n }): Promise<unknown>;\n}\n\n/** Every binding the journey engine needs (names match the wrangler config). */\nexport interface JourneyEnv {\n /**\n * BundleIR storage for the default D1 store (`wf:${contentHash}` → WorkflowIR JSON). Optional:\n * a Worker on another JourneyStore (Postgres, say) does not declare it.\n */\n WORKFLOW_KV?: KVNamespaceLike;\n /** Tables of the default D1 store (events / profiles / segments / triggers / entries / subscriptions). Optional, as above. */\n DB?: D1DatabaseLike;\n /** JourneyRunner's workflow binding (creating instances and waking them). */\n JOURNEY: WorkflowBindingLike;\n /** Optional message-delivery webhook (defaults to the console logging sender). */\n MESSAGE_WEBHOOK_URL?: string;\n /**\n * Bearer token required on every mutating HTTP endpoint (`Authorization:\n * Bearer <token>`). Unset = open — acceptable only for local dev; /deploy in\n * particular rewrites the whole routing table, so production must set this.\n */\n API_TOKEN?: string;\n /**\n * This Worker's public origin. With a store that keeps reporting (Postgres), journey emails'\n * links and open pixel point at its `/t/...` routes; without it emails go out untracked.\n */\n WORKFLOW_ORIGIN?: string;\n /** Which `workflow.config.ts` entry applies (`development`, `production`, …); production when unset. */\n ENVIRONMENT?: string;\n /** Cloudflare Email Sending, when the config has `emails`. */\n EMAIL?: EmailBindingLike;\n /** The APNs auth key (`.p8`, base64) named by the config's `apns.keyId`. */\n APNS_PRIVATE_KEY_BASE64?: string;\n /** The Firebase service account (base64 JSON) the config's `fcm` sends with. */\n GOOGLE_APPLICATION_CREDENTIALS_BASE64?: string;\n /** The VAPID key pair web pushes are signed with (`generateVapidKeys`); the config's `webPush` turns the channel on. */\n VAPID_PUBLIC_KEY?: string;\n VAPID_PRIVATE_KEY?: string;\n /**\n * Cloudflare's version metadata binding (wrangler `\"version_metadata\": { \"binding\":\n * \"CF_VERSION_METADATA\" }`). With it, and a store that records the live version (Postgres),\n * journeyWorker makes the bundle compiled into each new version live by itself: `wrangler\n * deploy` is the whole deploy.\n */\n CF_VERSION_METADATA?: { id: string; tag?: string; timestamp: string };\n /** Development only: the HTTP/2 relay `wrangler dev` sends APNs requests through (see `@shware/workflow/apns-relay`). */\n APNS_RELAY_ORIGIN?: string;\n}\n\n/** The runtime does not know the instance: the only failure that lets the router drop its wakes. */\nexport class InstanceNotFoundError extends Error {\n override readonly name = 'InstanceNotFoundError';\n constructor(readonly instanceId: string) {\n super(`no such instance: ${instanceId}`);\n }\n}\n\n/**\n * Does this lookup failure say the instance is gone, rather than that the lookup failed?\n * Cloudflare reports an unknown id as `instance.not_found`.\n */\nexport function isInstanceNotFound(error: unknown): boolean {\n if (error instanceof InstanceNotFoundError) return true;\n return /not[_ ]found|no such instance/i.test(String(error));\n}\n\n/** The event type the router uses to wake waiting instances (sendEvent's `type`). */\nexport const WAKE_EVENT_TYPE = 'wake';\n\n/** Journey instance parameters, passed by the ingest router when it creates one. */\nexport interface JourneyParams {\n workflowId: string;\n /** The IR version pinned at entry. */\n contentHash: string;\n userId: string;\n trigger: { event: string; payload: Record<string, unknown> };\n [key: string]: unknown;\n}\n\n/**\n * Slack on the deadline when deciding whether a thrown wait was a timeout: the\n * platform may surface the expiry a moment early, and a wait shorter than this\n * has no room to tell the two apart anyway.\n */\nexport const WAKE_TIMEOUT_TOLERANCE_MS = 5_000;\n\n/**\n * Did a wait that threw actually reach its deadline?\n *\n * CF signals a wait timeout by throwing, and so does every other failure in\n * there — a broken binding, an evicted instance, a malformed timeout. The\n * adapter used to read them all as timeouts, which made an infrastructure\n * fault indistinguishable from \"the user never did it\": the journey took its\n * onTimeout branch, early and silently. Only the clock can tell them apart.\n */\nexport function wakeExpired(startedAt: number, timeoutMs: number, now: number): boolean {\n return now - startedAt >= Math.max(0, timeoutMs - WAKE_TIMEOUT_TOLERANCE_MS);\n}\n"],"mappings":";;AA8IA,IAAa,wBAAb,cAA2C,MAAM;CAE1B;CADrB,OAAyB;CACzB,YAAY,YAA6B;EACvC,MAAM,qBAAqB,YAAY;EADpB,KAAA,aAAA;CAErB;AACF;;;;;AAMA,SAAgB,mBAAmB,OAAyB;CAC1D,IAAI,iBAAiB,uBAAuB,OAAO;CACnD,OAAO,iCAAiC,KAAK,OAAO,KAAK,CAAC;AAC5D;;AAGA,MAAa,kBAAkB;;;;;;AAiB/B,MAAa,4BAA4B;;;;;;;;;;AAWzC,SAAgB,YAAY,WAAmB,WAAmB,KAAsB;CACtF,OAAO,MAAM,aAAa,KAAK,IAAI,GAAG,YAAY,yBAAyB;AAC7E"}
1
+ {"version":3,"file":"bindings.mjs","names":[],"sources":["../../src/cloudflare/bindings.ts"],"sourcesContent":["import type { EmailAddress } from '../email';\nimport type { ExecutionStart } from '../store/index';\n\n/**\n * A minimal structural interface over the Cloudflare bindings.\n *\n * @cloudflare/workers-types is deliberately not used: the global types it\n * injects clash with the DOM lib this package's react side needs. A structural\n * subset plus the ambient cloudflare:workers declaration is enough to compile,\n * and the real bindings satisfy it structurally at runtime.\n */\n\nexport interface KVNamespaceLike {\n get(key: string): Promise<string | null>;\n put(key: string, value: string): Promise<void>;\n}\n\n/** Structural subset of D1Result: `meta.changes` is how INSERT OR IGNORE reports whether it inserted. */\nexport interface D1RunResultLike {\n meta?: { changes?: number };\n}\n\nexport interface D1PreparedStatementLike {\n bind(...values: unknown[]): D1PreparedStatementLike;\n first<T = Record<string, unknown>>(): Promise<T | null>;\n all<T = Record<string, unknown>>(): Promise<{ results: T[] }>;\n run(): Promise<D1RunResultLike>;\n}\n\nexport interface D1DatabaseLike {\n prepare(sql: string): D1PreparedStatementLike;\n /** Statements run in order inside one transaction (real D1 semantics) — deploy relies on the atomicity. */\n batch(statements: D1PreparedStatementLike[]): Promise<unknown>;\n}\n\nexport interface WorkflowInstanceLike {\n id: string;\n sendEvent(event: { type: string; payload?: unknown }): Promise<void>;\n /** Stop the instance for good (Cloudflare Workflows' `terminate`). */\n terminate?(): Promise<void>;\n /** Where it stands (`complete`, `errored`, `terminated`, `running`, …). */\n status?(): Promise<{ status: string }>;\n}\n\n/**\n * How the router starts, wakes and stops instances: Cloudflare's workflow binding, or the AWS\n * adapter's equivalent over durable executions.\n */\nexport interface WorkflowBindingLike {\n create(options: { id: string; params: unknown }): Promise<WorkflowInstanceLike>;\n get(id: string): Promise<WorkflowInstanceLike>;\n /**\n * For a runtime whose start is a submit that can be lost in flight (AWS): the start to record\n * with the entry, in one transaction. A failed `create` then keeps the entry — its recorded\n * start is retried by the next ingest for the pair (`resume`) or by maintenance, never rolled\n * back and re-entered as a second execution.\n */\n plan?(options: {\n id: string;\n params: JourneyParams;\n ts: number;\n /** The code version whose routes named `params.contentHash` (absent after a forced deploy). */\n version?: string | undefined;\n }): Promise<ExecutionStart>;\n /** Retry the recorded, unconfirmed start of the (workflow, user) entry still running, if any. */\n resume?(entry: { workflow: string; userId: string }): Promise<void>;\n /**\n * Maintenance the sweep runs first: old unconfirmed starts retried (or given up on), and\n * executions the platform ended on its own (timeout, stop, crash) reconciled into the ledger.\n */\n maintain?(): Promise<void>;\n}\n\n/**\n * Cloudflare Email Service's send_email binding (structural subset).\n *\n * `idempotencyKey` is part of the call because the send happens inside a\n * `step.do`: a step body re-runs after its fn resolved but before the\n * checkpoint committed, so a binding that drops the key mails the user twice.\n * The binding is app-supplied glue (nothing here can de-duplicate — there is\n * no state between two runs of the same step), which is why the port makes the\n * key impossible to miss rather than merely available.\n */\nexport interface EmailBindingLike {\n send(message: {\n from: EmailAddress;\n to: EmailAddress;\n replyTo?: EmailAddress;\n subject: string;\n html: string;\n /** List-Unsubscribe and List-Unsubscribe-Post, when the email has an unsubscribe link. */\n headers?: Readonly<Record<string, string>>;\n /** `${instanceId}:${nodeId}`: stable across replays and retries — drop a send whose key was already delivered. */\n idempotencyKey: string;\n }): Promise<unknown>;\n}\n\n/** Every binding the journey engine needs (names match the wrangler config). */\nexport interface JourneyEnv {\n /**\n * BundleIR storage for the default D1 store (`wf:${contentHash}` → WorkflowIR JSON). Optional:\n * a Worker on another JourneyStore (Postgres, say) does not declare it.\n */\n WORKFLOW_KV?: KVNamespaceLike;\n /** Tables of the default D1 store (events / profiles / segments / triggers / entries / subscriptions). Optional, as above. */\n DB?: D1DatabaseLike;\n /** JourneyRunner's workflow binding (creating instances and waking them). */\n JOURNEY: WorkflowBindingLike;\n /** Optional message-delivery webhook (defaults to the console logging sender). */\n MESSAGE_WEBHOOK_URL?: string;\n /**\n * Bearer token required on every mutating HTTP endpoint (`Authorization:\n * Bearer <token>`). Unset = open — acceptable only for local dev; /deploy in\n * particular rewrites the whole routing table, so production must set this.\n */\n API_TOKEN?: string;\n /**\n * This Worker's public origin. With a store that keeps reporting (Postgres), journey emails'\n * links and open pixel point at its `/t/...` routes; without it emails go out untracked.\n */\n WORKFLOW_ORIGIN?: string;\n /** Which `workflow.config.ts` entry applies (`development`, `production`, …); production when unset. */\n ENVIRONMENT?: string;\n /** Cloudflare Email Sending, when the config has `emails`. */\n EMAIL?: EmailBindingLike;\n /** The APNs auth key (`.p8`, base64) named by the config's `apns.keyId`. */\n APNS_PRIVATE_KEY_BASE64?: string;\n /** The Firebase service account (base64 JSON) the config's `fcm` sends with. */\n GOOGLE_APPLICATION_CREDENTIALS_BASE64?: string;\n /** The VAPID key pair web pushes are signed with (`generateVapidKeys`); the config's `webPush` turns the channel on. */\n VAPID_PUBLIC_KEY?: string;\n VAPID_PRIVATE_KEY?: string;\n /**\n * Cloudflare's version metadata binding (wrangler `\"version_metadata\": { \"binding\":\n * \"CF_VERSION_METADATA\" }`). With it, and a store that records the live version (Postgres),\n * journeyWorker makes the bundle compiled into each new version live by itself: `wrangler\n * deploy` is the whole deploy.\n */\n CF_VERSION_METADATA?: { id: string; tag?: string; timestamp: string };\n /** Development only: the HTTP/2 relay `wrangler dev` sends APNs requests through (see `@shware/workflow/apns-relay`). */\n APNS_RELAY_ORIGIN?: string;\n}\n\n/** The runtime does not know the instance: the only failure that lets the router drop its wakes. */\nexport class InstanceNotFoundError extends Error {\n override readonly name = 'InstanceNotFoundError';\n constructor(readonly instanceId: string) {\n super(`no such instance: ${instanceId}`);\n }\n}\n\n/**\n * Does this lookup failure say the instance is gone, rather than that the lookup failed?\n * Cloudflare reports an unknown id as `instance.not_found`.\n */\nexport function isInstanceNotFound(error: unknown): boolean {\n if (error instanceof InstanceNotFoundError) return true;\n return /not[_ ]found|no such instance/i.test(String(error));\n}\n\n/** The event type the router uses to wake waiting instances (sendEvent's `type`). */\nexport const WAKE_EVENT_TYPE = 'wake';\n\n/** Journey instance parameters, passed by the ingest router when it creates one. */\nexport interface JourneyParams {\n workflowId: string;\n /** The IR version pinned at entry. */\n contentHash: string;\n userId: string;\n trigger: { event: string; payload: Record<string, unknown> };\n [key: string]: unknown;\n}\n\n/**\n * Slack on the deadline when deciding whether a thrown wait was a timeout: the\n * platform may surface the expiry a moment early, and a wait shorter than this\n * has no room to tell the two apart anyway.\n */\nexport const WAKE_TIMEOUT_TOLERANCE_MS = 5_000;\n\n/**\n * Did a wait that threw actually reach its deadline?\n *\n * CF signals a wait timeout by throwing, and so does every other failure in\n * there — a broken binding, an evicted instance, a malformed timeout. The\n * adapter used to read them all as timeouts, which made an infrastructure\n * fault indistinguishable from \"the user never did it\": the journey took its\n * onTimeout branch, early and silently. Only the clock can tell them apart.\n */\nexport function wakeExpired(startedAt: number, timeoutMs: number, now: number): boolean {\n return now - startedAt >= Math.max(0, timeoutMs - WAKE_TIMEOUT_TOLERANCE_MS);\n}\n"],"mappings":";;AAgJA,IAAa,wBAAb,cAA2C,MAAM;CAE1B;CADrB,OAAyB;CACzB,YAAY,YAA6B;EACvC,MAAM,qBAAqB,YAAY;EADpB,KAAA,aAAA;CAErB;AACF;;;;;AAMA,SAAgB,mBAAmB,OAAyB;CAC1D,IAAI,iBAAiB,uBAAuB,OAAO;CACnD,OAAO,iCAAiC,KAAK,OAAO,KAAK,CAAC;AAC5D;;AAGA,MAAa,kBAAkB;;;;;;AAiB/B,MAAa,4BAA4B;;;;;;;;;;AAWzC,SAAgB,YAAY,WAAmB,WAAmB,KAAsB;CACtF,OAAO,MAAM,aAAa,KAAK,IAAI,GAAG,YAAY,yBAAyB;AAC7E"}
@@ -17,16 +17,39 @@ var EmailSender = class {
17
17
  const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(message.template, message.props);
18
18
  const tracked = message.tracking === void 0 ? html : trackEmail(html, message.tracking, unsubscribeUrl);
19
19
  await message.tracking?.commit?.();
20
+ const headers = unsubscribeHeaders(unsubscribeUrl, message.tracking?.unsubscribe);
20
21
  await this.options.transport.send({
21
22
  from,
22
23
  to: message.recipient,
23
24
  ...replyTo === void 0 ? {} : { replyTo },
24
25
  subject,
25
26
  html: tracked,
27
+ ...headers === void 0 ? {} : { headers },
26
28
  idempotencyKey: message.idempotencyKey
27
29
  });
28
30
  }
29
31
  };
32
+ /**
33
+ * The list-unsubscribe headers of an email with an unsubscribe link (RFC 2369, RFC 8058), so a
34
+ * mail client's own unsubscribe button works — Gmail and Yahoo require one-click for bulk mail:
35
+ *
36
+ * - With a one-click endpoint (`MessageTracking.unsubscribe`, which reporting provides): the
37
+ * header names it, and `List-Unsubscribe-Post` says a POST there unsubscribes at once.
38
+ * - Without one: the header names the email's own unsubscribe link, with no one-click claim —
39
+ * whether that page takes a POST is the app's.
40
+ * - An email without an unsubscribe link (a receipt) gets neither.
41
+ *
42
+ * Only an https URL is named: mail clients and Cloudflare Email refuse any other.
43
+ */
44
+ function unsubscribeHeaders(unsubscribeUrl, oneClick) {
45
+ if (unsubscribeUrl === void 0) return void 0;
46
+ const https = (url) => url?.startsWith("https://") === true;
47
+ if (https(oneClick)) return {
48
+ "List-Unsubscribe": `<${oneClick}>`,
49
+ "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
50
+ };
51
+ if (https(unsubscribeUrl)) return { "List-Unsubscribe": `<${unsubscribeUrl}>` };
52
+ }
30
53
  const LINK = /(<a\b[^>]*?\shref\s*=\s*)(["'])(https?:\/\/[^"']+)\2/gi;
31
54
  /**
32
55
  * An email's tracking applied to its rendered HTML: every `http(s)` anchor routed through
@@ -58,5 +81,6 @@ function escapeAttribute(value) {
58
81
  //#endregion
59
82
  exports.EmailSender = EmailSender;
60
83
  exports.trackEmail = trackEmail;
84
+ exports.unsubscribeHeaders = unsubscribeHeaders;
61
85
 
62
86
  //# sourceMappingURL=email-sender.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"email-sender.cjs","names":["isUnsubscribeLink"],"sources":["../src/email-sender.ts"],"sourcesContent":["import { type EmailAddress, type ResolvedEmail, isUnsubscribeLink } 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.\n */\nexport type EmailRenderer = (\n template: string,\n props: Record<string, ScalarIR | undefined>\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 * `${instanceId}:${nodeId}`: the copy's identity, stable across retries and replays. A provider\n * that de-duplicates on a key receives it; one that cannot (SES) still sends at least once —\n * the window between the provider accepting the email and the step's checkpoint stays open.\n */\n idempotencyKey: string;\n}\n\n/**\n * One email provider. Throwing hands the retry to the durable step; resolving means the provider\n * accepted the email (not that it was delivered).\n */\nexport interface EmailTransport {\n send(message: EmailMessage): Promise<{ providerMessageId?: string }>;\n}\n\nexport interface EmailSenderOptions {\n transport: EmailTransport;\n render: EmailRenderer;\n}\n\n/**\n * The email channel over any transport: render → tracking applied to the HTML → the tracked\n * links committed → the transport. The links resolve before the email exists anywhere a\n * scanner could follow them.\n */\nexport class EmailSender implements MessageSender {\n constructor(private readonly options: EmailSenderOptions) {}\n\n async send(message: OutboundMessage): Promise<void> {\n if (message.channel !== 'email') {\n throw new Error(`EmailSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`EmailSender: no recipient for user '${message.userId}'`);\n }\n const { 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 await message.tracking?.commit?.();\n await this.options.transport.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"],"mappings":";;;;;;;;AA8DA,IAAa,cAAb,MAAkD;CACnB;CAA7B,YAAY,SAA8C;EAA7B,KAAA,UAAA;CAA8B;CAE3D,MAAM,KAAK,SAAyC;EAClD,IAAI,QAAQ,YAAY,SACtB,MAAM,IAAI,MAAM,qCAAqC,QAAQ,QAAQ,EAAE;EAEzE,IAAI,QAAQ,cAAc,KAAA,GACxB,MAAM,IAAI,MAAM,uCAAuC,QAAQ,OAAO,EAAE;EAE1E,MAAM,EAAE,SAAS,MAAM,MAAM,SAAS,mBAAmB,MAAM,KAAK,QAAQ,OAC1E,QAAQ,UACR,QAAQ,KACV;EACA,MAAM,UACJ,QAAQ,aAAa,KAAA,IAAY,OAAO,WAAW,MAAM,QAAQ,UAAU,cAAc;EAC3F,MAAM,QAAQ,UAAU,SAAS;EACjC,MAAM,KAAK,QAAQ,UAAU,KAAK;GAChC;GACA,IAAI,QAAQ;GACZ,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ;GAC3C;GACA,MAAM;GACN,gBAAgB,QAAQ;EAC1B,CAAC;CACH;AACF;AAEA,MAAM,OAAO;;;;;;;;AASb,SAAgB,WACd,MACA,UACA,gBACQ;CACR,MAAM,EAAE,MAAM,UAAU;CACxB,IAAI,MAAM;CACV,IAAI,SAAS,KAAA,GACX,MAAM,IAAI,QAAQ,OAAO,GAAG,OAAe,OAAe,cAAsB;EAC9E,MAAM,MAAM,kBAAkB,SAAS;EACvC,MAAM,UAAUA,cAAAA,kBAAkB,KAAK,cAAc,IAAI,gBAAgB;EACzE,OAAO,GAAG,QAAQ,QAAQ,gBAAgB,KAAK,KAAK,OAAO,CAAC,IAAI;CAClE,CAAC;CAEH,IAAI,UAAU,KAAA,GAAW;EACvB,MAAM,QAAQ,aAAa,gBAAgB,KAAK,EAAE;EAClD,MAAM,YAAY,KAAK,GAAG,IAAI,IAAI,QAAQ,aAAa,GAAG,MAAM,QAAQ,IAAI,GAAG,MAAM;CACvF;CACA,OAAO;AACT;AAEA,SAAS,kBAAkB,OAAuB;CAChD,OAAO,MAAM,WAAW,SAAS,GAAG,CAAC,CAAC,WAAW,UAAU,IAAG,CAAC,CAAC,WAAW,UAAU,GAAG;AAC1F;AAEA,SAAS,gBAAgB,OAAuB;CAC9C,OAAO,MAAM,WAAW,KAAK,OAAO,CAAC,CAAC,WAAW,MAAK,QAAQ;AAChE"}
1
+ {"version":3,"file":"email-sender.cjs","names":["isUnsubscribeLink"],"sources":["../src/email-sender.ts"],"sourcesContent":["import { type EmailAddress, type ResolvedEmail, isUnsubscribeLink } 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.\n */\nexport type EmailRenderer = (\n template: string,\n props: Record<string, ScalarIR | undefined>\n) => Promise<RenderedEmail>;\n\n/** A finished email, as a transport takes it: rendered, tracked, addressed. */\nexport interface EmailMessage {\n from: EmailAddress;\n to: string;\n replyTo?: EmailAddress;\n subject: string;\n html: string;\n /**\n * Extra headers, already formatted: `List-Unsubscribe` and `List-Unsubscribe-Post` for an\n * email that has an unsubscribe link (see `unsubscribeHeaders`).\n */\n headers?: Readonly<Record<string, string>>;\n /**\n * `${instanceId}:${nodeId}`: the copy's identity, stable across retries and replays. A provider\n * that de-duplicates on a key receives it; one that cannot (SES) still sends at least once —\n * the window between the provider accepting the email and the step's checkpoint stays open.\n */\n idempotencyKey: string;\n}\n\n/**\n * One email provider. Throwing hands the retry to the durable step; resolving means the provider\n * accepted the email (not that it was delivered).\n */\nexport interface EmailTransport {\n send(message: EmailMessage): Promise<{ providerMessageId?: string }>;\n}\n\nexport interface EmailSenderOptions {\n transport: EmailTransport;\n render: EmailRenderer;\n}\n\n/**\n * The email channel over any transport: render → tracking applied to the HTML → the tracked\n * links committed → the transport. The links resolve before the email exists anywhere a\n * scanner could follow them.\n */\nexport class EmailSender implements MessageSender {\n constructor(private readonly options: EmailSenderOptions) {}\n\n async send(message: OutboundMessage): Promise<void> {\n if (message.channel !== 'email') {\n throw new Error(`EmailSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`EmailSender: no recipient for user '${message.userId}'`);\n }\n const { 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 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 receipt) 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('&amp;', '&').replaceAll('&quot;', '\"').replaceAll('&#x27;', \"'\");\n}\n\nfunction escapeAttribute(value: string): string {\n return value.replaceAll('&', '&amp;').replaceAll('\"', '&quot;');\n}\n"],"mappings":";;;;;;;;AAmEA,IAAa,cAAb,MAAkD;CACnB;CAA7B,YAAY,SAA8C;EAA7B,KAAA,UAAA;CAA8B;CAE3D,MAAM,KAAK,SAAyC;EAClD,IAAI,QAAQ,YAAY,SACtB,MAAM,IAAI,MAAM,qCAAqC,QAAQ,QAAQ,EAAE;EAEzE,IAAI,QAAQ,cAAc,KAAA,GACxB,MAAM,IAAI,MAAM,uCAAuC,QAAQ,OAAO,EAAE;EAE1E,MAAM,EAAE,SAAS,MAAM,MAAM,SAAS,mBAAmB,MAAM,KAAK,QAAQ,OAC1E,QAAQ,UACR,QAAQ,KACV;EACA,MAAM,UACJ,QAAQ,aAAa,KAAA,IAAY,OAAO,WAAW,MAAM,QAAQ,UAAU,cAAc;EAC3F,MAAM,QAAQ,UAAU,SAAS;EACjC,MAAM,UAAU,mBAAmB,gBAAgB,QAAQ,UAAU,WAAW;EAChF,MAAM,KAAK,QAAQ,UAAU,KAAK;GAChC;GACA,IAAI,QAAQ;GACZ,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ;GAC3C;GACA,MAAM;GACN,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ;GAC3C,gBAAgB,QAAQ;EAC1B,CAAC;CACH;AACF;;;;;;;;;;;;;AAcA,SAAgB,mBACd,gBACA,UACoC;CACpC,IAAI,mBAAmB,KAAA,GAAW,OAAO,KAAA;CACzC,MAAM,SAAS,QAA4B,KAAK,WAAW,UAAU,MAAM;CAC3E,IAAI,MAAM,QAAQ,GAChB,OAAO;EACL,oBAAoB,IAAI,SAAS;EACjC,yBAAyB;CAC3B;CAEF,IAAI,MAAM,cAAc,GAAG,OAAO,EAAE,oBAAoB,IAAI,eAAe,GAAG;AAEhF;AAEA,MAAM,OAAO;;;;;;;;AASb,SAAgB,WACd,MACA,UACA,gBACQ;CACR,MAAM,EAAE,MAAM,UAAU;CACxB,IAAI,MAAM;CACV,IAAI,SAAS,KAAA,GACX,MAAM,IAAI,QAAQ,OAAO,GAAG,OAAe,OAAe,cAAsB;EAC9E,MAAM,MAAM,kBAAkB,SAAS;EACvC,MAAM,UAAUA,cAAAA,kBAAkB,KAAK,cAAc,IAAI,gBAAgB;EACzE,OAAO,GAAG,QAAQ,QAAQ,gBAAgB,KAAK,KAAK,OAAO,CAAC,IAAI;CAClE,CAAC;CAEH,IAAI,UAAU,KAAA,GAAW;EACvB,MAAM,QAAQ,aAAa,gBAAgB,KAAK,EAAE;EAClD,MAAM,YAAY,KAAK,GAAG,IAAI,IAAI,QAAQ,aAAa,GAAG,MAAM,QAAQ,IAAI,GAAG,MAAM;CACvF;CACA,OAAO;AACT;AAEA,SAAS,kBAAkB,OAAuB;CAChD,OAAO,MAAM,WAAW,SAAS,GAAG,CAAC,CAAC,WAAW,UAAU,IAAG,CAAC,CAAC,WAAW,UAAU,GAAG;AAC1F;AAEA,SAAS,gBAAgB,OAAuB;CAC9C,OAAO,MAAM,WAAW,KAAK,OAAO,CAAC,CAAC,WAAW,MAAK,QAAQ;AAChE"}
@@ -28,6 +28,11 @@ export interface EmailMessage {
28
28
  replyTo?: EmailAddress;
29
29
  subject: string;
30
30
  html: string;
31
+ /**
32
+ * Extra headers, already formatted: `List-Unsubscribe` and `List-Unsubscribe-Post` for an
33
+ * email that has an unsubscribe link (see `unsubscribeHeaders`).
34
+ */
35
+ headers?: Readonly<Record<string, string>>;
31
36
  /**
32
37
  * `${instanceId}:${nodeId}`: the copy's identity, stable across retries and replays. A provider
33
38
  * that de-duplicates on a key receives it; one that cannot (SES) still sends at least once —
@@ -58,6 +63,19 @@ export declare class EmailSender implements MessageSender {
58
63
  constructor(options: EmailSenderOptions);
59
64
  send(message: OutboundMessage): Promise<void>;
60
65
  }
66
+ /**
67
+ * The list-unsubscribe headers of an email with an unsubscribe link (RFC 2369, RFC 8058), so a
68
+ * mail client's own unsubscribe button works — Gmail and Yahoo require one-click for bulk mail:
69
+ *
70
+ * - With a one-click endpoint (`MessageTracking.unsubscribe`, which reporting provides): the
71
+ * header names it, and `List-Unsubscribe-Post` says a POST there unsubscribes at once.
72
+ * - Without one: the header names the email's own unsubscribe link, with no one-click claim —
73
+ * whether that page takes a POST is the app's.
74
+ * - An email without an unsubscribe link (a receipt) gets neither.
75
+ *
76
+ * Only an https URL is named: mail clients and Cloudflare Email refuse any other.
77
+ */
78
+ export declare function unsubscribeHeaders(unsubscribeUrl: string | undefined, oneClick: string | undefined): Record<string, string> | undefined;
61
79
  /**
62
80
  * An email's tracking applied to its rendered HTML: every `http(s)` anchor routed through
63
81
  * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back) —
@@ -1 +1 @@
1
- {"version":3,"file":"email-sender.d.cts","names":[],"sources":["../src/email-sender.ts"],"mappings":";;;;;;;;;;;iBAYiB,sBAAsB;EACrC;EACA;;;;;;;;;YAUU,iBACV,kBACA,OAAO,eAAe,0BACnB,QAAQ;;iBAGI;EACf,MAAM;EACN;EACA,UAAU;EACV;EACA;;;;;;EAMA;;;;;;iBAOe;EACf,KAAK,SAAS,eAAe;IAAU;;;iBAGxB;EACf,WAAW;EACX,QAAQ;;;;;;;qBAQG,uBAAuB;mBACL;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB;;;;;;;;;wBAkCxB,WACd,cACA,UAAU,iBACV"}
1
+ {"version":3,"file":"email-sender.d.cts","names":[],"sources":["../src/email-sender.ts"],"mappings":";;;;;;;;;;;iBAYiB,sBAAsB;EACrC;EACA;;;;;;;;;YAUU,iBACV,kBACA,OAAO,eAAe,0BACnB,QAAQ;;iBAGI;EACf,MAAM;EACN;EACA,UAAU;EACV;EACA;;;;;EAKA,UAAU,SAAS;;;;;;EAMnB;;;;;;iBAOe;EACf,KAAK,SAAS,eAAe;IAAU;;;iBAGxB;EACf,WAAW;EACX,QAAQ;;;;;;;qBAQG,uBAAuB;mBACL;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB;;;;;;;;;;;;;;wBAuCxB,mBACd,oCACA,+BACC;;;;;;;;wBAsBa,WACd,cACA,UAAU,iBACV"}
@@ -28,6 +28,11 @@ export interface EmailMessage {
28
28
  replyTo?: EmailAddress;
29
29
  subject: string;
30
30
  html: string;
31
+ /**
32
+ * Extra headers, already formatted: `List-Unsubscribe` and `List-Unsubscribe-Post` for an
33
+ * email that has an unsubscribe link (see `unsubscribeHeaders`).
34
+ */
35
+ headers?: Readonly<Record<string, string>>;
31
36
  /**
32
37
  * `${instanceId}:${nodeId}`: the copy's identity, stable across retries and replays. A provider
33
38
  * that de-duplicates on a key receives it; one that cannot (SES) still sends at least once —
@@ -58,6 +63,19 @@ export declare class EmailSender implements MessageSender {
58
63
  constructor(options: EmailSenderOptions);
59
64
  send(message: OutboundMessage): Promise<void>;
60
65
  }
66
+ /**
67
+ * The list-unsubscribe headers of an email with an unsubscribe link (RFC 2369, RFC 8058), so a
68
+ * mail client's own unsubscribe button works — Gmail and Yahoo require one-click for bulk mail:
69
+ *
70
+ * - With a one-click endpoint (`MessageTracking.unsubscribe`, which reporting provides): the
71
+ * header names it, and `List-Unsubscribe-Post` says a POST there unsubscribes at once.
72
+ * - Without one: the header names the email's own unsubscribe link, with no one-click claim —
73
+ * whether that page takes a POST is the app's.
74
+ * - An email without an unsubscribe link (a receipt) gets neither.
75
+ *
76
+ * Only an https URL is named: mail clients and Cloudflare Email refuse any other.
77
+ */
78
+ export declare function unsubscribeHeaders(unsubscribeUrl: string | undefined, oneClick: string | undefined): Record<string, string> | undefined;
61
79
  /**
62
80
  * An email's tracking applied to its rendered HTML: every `http(s)` anchor routed through
63
81
  * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back) —
@@ -1 +1 @@
1
- {"version":3,"file":"email-sender.d.mts","names":[],"sources":["../src/email-sender.ts"],"mappings":";;;;;;;;;;;iBAYiB,sBAAsB;EACrC;EACA;;;;;;;;;YAUU,iBACV,kBACA,OAAO,eAAe,0BACnB,QAAQ;;iBAGI;EACf,MAAM;EACN;EACA,UAAU;EACV;EACA;;;;;;EAMA;;;;;;iBAOe;EACf,KAAK,SAAS,eAAe;IAAU;;;iBAGxB;EACf,WAAW;EACX,QAAQ;;;;;;;qBAQG,uBAAuB;mBACL;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB;;;;;;;;;wBAkCxB,WACd,cACA,UAAU,iBACV"}
1
+ {"version":3,"file":"email-sender.d.mts","names":[],"sources":["../src/email-sender.ts"],"mappings":";;;;;;;;;;;iBAYiB,sBAAsB;EACrC;EACA;;;;;;;;;YAUU,iBACV,kBACA,OAAO,eAAe,0BACnB,QAAQ;;iBAGI;EACf,MAAM;EACN;EACA,UAAU;EACV;EACA;;;;;EAKA,UAAU,SAAS;;;;;;EAMnB;;;;;;iBAOe;EACf,KAAK,SAAS,eAAe;IAAU;;;iBAGxB;EACf,WAAW;EACX,QAAQ;;;;;;;qBAQG,uBAAuB;mBACL;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB;;;;;;;;;;;;;;wBAuCxB,mBACd,oCACA,+BACC;;;;;;;;wBAsBa,WACd,cACA,UAAU,iBACV"}
@@ -16,16 +16,39 @@ var EmailSender = class {
16
16
  const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(message.template, message.props);
17
17
  const tracked = message.tracking === void 0 ? html : trackEmail(html, message.tracking, unsubscribeUrl);
18
18
  await message.tracking?.commit?.();
19
+ const headers = unsubscribeHeaders(unsubscribeUrl, message.tracking?.unsubscribe);
19
20
  await this.options.transport.send({
20
21
  from,
21
22
  to: message.recipient,
22
23
  ...replyTo === void 0 ? {} : { replyTo },
23
24
  subject,
24
25
  html: tracked,
26
+ ...headers === void 0 ? {} : { headers },
25
27
  idempotencyKey: message.idempotencyKey
26
28
  });
27
29
  }
28
30
  };
31
+ /**
32
+ * The list-unsubscribe headers of an email with an unsubscribe link (RFC 2369, RFC 8058), so a
33
+ * mail client's own unsubscribe button works — Gmail and Yahoo require one-click for bulk mail:
34
+ *
35
+ * - With a one-click endpoint (`MessageTracking.unsubscribe`, which reporting provides): the
36
+ * header names it, and `List-Unsubscribe-Post` says a POST there unsubscribes at once.
37
+ * - Without one: the header names the email's own unsubscribe link, with no one-click claim —
38
+ * whether that page takes a POST is the app's.
39
+ * - An email without an unsubscribe link (a receipt) gets neither.
40
+ *
41
+ * Only an https URL is named: mail clients and Cloudflare Email refuse any other.
42
+ */
43
+ function unsubscribeHeaders(unsubscribeUrl, oneClick) {
44
+ if (unsubscribeUrl === void 0) return void 0;
45
+ const https = (url) => url?.startsWith("https://") === true;
46
+ if (https(oneClick)) return {
47
+ "List-Unsubscribe": `<${oneClick}>`,
48
+ "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
49
+ };
50
+ if (https(unsubscribeUrl)) return { "List-Unsubscribe": `<${unsubscribeUrl}>` };
51
+ }
29
52
  const LINK = /(<a\b[^>]*?\shref\s*=\s*)(["'])(https?:\/\/[^"']+)\2/gi;
30
53
  /**
31
54
  * An email's tracking applied to its rendered HTML: every `http(s)` anchor routed through
@@ -55,6 +78,6 @@ function escapeAttribute(value) {
55
78
  return value.replaceAll("&", "&amp;").replaceAll("\"", "&quot;");
56
79
  }
57
80
  //#endregion
58
- export { EmailSender, trackEmail };
81
+ export { EmailSender, trackEmail, unsubscribeHeaders };
59
82
 
60
83
  //# sourceMappingURL=email-sender.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"email-sender.mjs","names":[],"sources":["../src/email-sender.ts"],"sourcesContent":["import { type EmailAddress, type ResolvedEmail, isUnsubscribeLink } 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.\n */\nexport type EmailRenderer = (\n template: string,\n props: Record<string, ScalarIR | undefined>\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 * `${instanceId}:${nodeId}`: the copy's identity, stable across retries and replays. A provider\n * that de-duplicates on a key receives it; one that cannot (SES) still sends at least once —\n * the window between the provider accepting the email and the step's checkpoint stays open.\n */\n idempotencyKey: string;\n}\n\n/**\n * One email provider. Throwing hands the retry to the durable step; resolving means the provider\n * accepted the email (not that it was delivered).\n */\nexport interface EmailTransport {\n send(message: EmailMessage): Promise<{ providerMessageId?: string }>;\n}\n\nexport interface EmailSenderOptions {\n transport: EmailTransport;\n render: EmailRenderer;\n}\n\n/**\n * The email channel over any transport: render → tracking applied to the HTML → the tracked\n * links committed → the transport. The links resolve before the email exists anywhere a\n * scanner could follow them.\n */\nexport class EmailSender implements MessageSender {\n constructor(private readonly options: EmailSenderOptions) {}\n\n async send(message: OutboundMessage): Promise<void> {\n if (message.channel !== 'email') {\n throw new Error(`EmailSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`EmailSender: no recipient for user '${message.userId}'`);\n }\n const { 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 await message.tracking?.commit?.();\n await this.options.transport.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"],"mappings":";;;;;;;AA8DA,IAAa,cAAb,MAAkD;CACnB;CAA7B,YAAY,SAA8C;EAA7B,KAAA,UAAA;CAA8B;CAE3D,MAAM,KAAK,SAAyC;EAClD,IAAI,QAAQ,YAAY,SACtB,MAAM,IAAI,MAAM,qCAAqC,QAAQ,QAAQ,EAAE;EAEzE,IAAI,QAAQ,cAAc,KAAA,GACxB,MAAM,IAAI,MAAM,uCAAuC,QAAQ,OAAO,EAAE;EAE1E,MAAM,EAAE,SAAS,MAAM,MAAM,SAAS,mBAAmB,MAAM,KAAK,QAAQ,OAC1E,QAAQ,UACR,QAAQ,KACV;EACA,MAAM,UACJ,QAAQ,aAAa,KAAA,IAAY,OAAO,WAAW,MAAM,QAAQ,UAAU,cAAc;EAC3F,MAAM,QAAQ,UAAU,SAAS;EACjC,MAAM,KAAK,QAAQ,UAAU,KAAK;GAChC;GACA,IAAI,QAAQ;GACZ,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ;GAC3C;GACA,MAAM;GACN,gBAAgB,QAAQ;EAC1B,CAAC;CACH;AACF;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"}
1
+ {"version":3,"file":"email-sender.mjs","names":[],"sources":["../src/email-sender.ts"],"sourcesContent":["import { type EmailAddress, type ResolvedEmail, isUnsubscribeLink } 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.\n */\nexport type EmailRenderer = (\n template: string,\n props: Record<string, ScalarIR | undefined>\n) => Promise<RenderedEmail>;\n\n/** A finished email, as a transport takes it: rendered, tracked, addressed. */\nexport interface EmailMessage {\n from: EmailAddress;\n to: string;\n replyTo?: EmailAddress;\n subject: string;\n html: string;\n /**\n * Extra headers, already formatted: `List-Unsubscribe` and `List-Unsubscribe-Post` for an\n * email that has an unsubscribe link (see `unsubscribeHeaders`).\n */\n headers?: Readonly<Record<string, string>>;\n /**\n * `${instanceId}:${nodeId}`: the copy's identity, stable across retries and replays. A provider\n * that de-duplicates on a key receives it; one that cannot (SES) still sends at least once —\n * the window between the provider accepting the email and the step's checkpoint stays open.\n */\n idempotencyKey: string;\n}\n\n/**\n * One email provider. Throwing hands the retry to the durable step; resolving means the provider\n * accepted the email (not that it was delivered).\n */\nexport interface EmailTransport {\n send(message: EmailMessage): Promise<{ providerMessageId?: string }>;\n}\n\nexport interface EmailSenderOptions {\n transport: EmailTransport;\n render: EmailRenderer;\n}\n\n/**\n * The email channel over any transport: render → tracking applied to the HTML → the tracked\n * links committed → the transport. The links resolve before the email exists anywhere a\n * scanner could follow them.\n */\nexport class EmailSender implements MessageSender {\n constructor(private readonly options: EmailSenderOptions) {}\n\n async send(message: OutboundMessage): Promise<void> {\n if (message.channel !== 'email') {\n throw new Error(`EmailSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`EmailSender: no recipient for user '${message.userId}'`);\n }\n const { 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 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 receipt) 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('&amp;', '&').replaceAll('&quot;', '\"').replaceAll('&#x27;', \"'\");\n}\n\nfunction escapeAttribute(value: string): string {\n return value.replaceAll('&', '&amp;').replaceAll('\"', '&quot;');\n}\n"],"mappings":";;;;;;;AAmEA,IAAa,cAAb,MAAkD;CACnB;CAA7B,YAAY,SAA8C;EAA7B,KAAA,UAAA;CAA8B;CAE3D,MAAM,KAAK,SAAyC;EAClD,IAAI,QAAQ,YAAY,SACtB,MAAM,IAAI,MAAM,qCAAqC,QAAQ,QAAQ,EAAE;EAEzE,IAAI,QAAQ,cAAc,KAAA,GACxB,MAAM,IAAI,MAAM,uCAAuC,QAAQ,OAAO,EAAE;EAE1E,MAAM,EAAE,SAAS,MAAM,MAAM,SAAS,mBAAmB,MAAM,KAAK,QAAQ,OAC1E,QAAQ,UACR,QAAQ,KACV;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"}
@@ -113,6 +113,12 @@ export interface MessageTracking {
113
113
  link?: (url: string, purpose: LinkPurpose) => string;
114
114
  /** Loaded when the email is opened. */
115
115
  pixel?: string;
116
+ /**
117
+ * This copy's one-click unsubscribe endpoint (RFC 8058): a POST there, with no confirmation
118
+ * and no session, unsubscribes the recipient. The email sender puts it in the
119
+ * `List-Unsubscribe` header, so a mail client's own unsubscribe button works.
120
+ */
121
+ unsubscribe?: string;
116
122
  /** Extra string keys in the push payload. */
117
123
  data?: Record<string, string>;
118
124
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"ports.d.cts","names":[],"sources":["../../src/engine/ports.ts"],"mappings":";;;;;;;;;;;;;;;;iBAiBiB;;EAEf,GAAG,GAAG,cAAc,UAAU,QAAQ,KAAK,QAAQ;;EAGnD,MAAM,cAAc,aAAa;;EAGjC,WAAW,cAAc,sBAAsB;;;;;;;;;;;;;;;EAgB/C,UACE,cACA,2BACA;IAAY;MACX;;EAGH,YAAY,eAAe;;;;;EAM3B,YAAY,cAAc,oBAAoB;;;iBAI/B;EACf;EACA;;;iBAIe;;;;;;EAMf,YACE,eACA;IAAS;IAAkB;IAAkB,QAAQ;MACpD;EACH,YAAY,eAAe,QAAQ;;EAEnC,oBAAoB,eAAe,QAAQ;;EAE3C,eAAe,QAAQ;;EAEvB,cAAc,SAAS,YAAY;;iBAGpB;;EAEf;EACA;EACA;EACA,SAAS;EACT;;EAEA,OAAO,eAAe;EACtB;;;;;;EAMA;;EAEA,SAAS;;;;;;EAMT;;EAEA,WAAW;;;YAID;;;;;;iBAOK;;;;;EAKf,QAAQ,aAAa,SAAS;;EAE9B;;EAEA,OAAO;;;;;;EAMP,eAAe;;;;;;;iBAQA;EACf;EACA;;iBAGe;EACf,KAAK,SAAS,kBAAkB,eAAe;;;;;;;;;iBAUhC;;EAEf,SAAS;IACP;IACA;IACA;IACA;MACE;;EAEJ,aAAa;IACX;IACA;IACA;IACA;IACA;MACE;;EAEJ,iBAAiB,SAAS,kBAAkB,QAAQ;;EAEpD,aAAa,SAAS,iBAAiB;IAAU;MAAoB,aAAa;;EAElF,UAAU;IACR;IACA;IACA;IACA,QAAQ;IACR;;IAEA;MACE;;;;;;;;;iBAUW;EACf,SAAS;IACP,SAAS;IACT;IACA,OAAO,eAAe;MACpB,eAAe;;;iBAIJ;;;;;;;;EAQf,KAAK,eAAe,SAAS,eAAe,uBAAuB,aAAa;;;iBAIjE;;EAEf;;EAEA;;EAEA,MAAM,eAAe;EACrB;;EAEA;;;;;;;iBAQe;EACf,OAAO,YAAY,mBAAmB;;iBAGvB;EACf;;EAEA;;;;;;;EAOA;EACA,MAAM;EACN,OAAO;EACP,UAAU;EACV,QAAQ;;EAER,UAAU;;EAEV,YAAY;;EAEZ,WAAW;;;;;;;YAQD;EACN;;EACA;EAAkB;;EAClB;;EACA;;EACA"}
1
+ {"version":3,"file":"ports.d.cts","names":[],"sources":["../../src/engine/ports.ts"],"mappings":";;;;;;;;;;;;;;;;iBAiBiB;;EAEf,GAAG,GAAG,cAAc,UAAU,QAAQ,KAAK,QAAQ;;EAGnD,MAAM,cAAc,aAAa;;EAGjC,WAAW,cAAc,sBAAsB;;;;;;;;;;;;;;;EAgB/C,UACE,cACA,2BACA;IAAY;MACX;;EAGH,YAAY,eAAe;;;;;EAM3B,YAAY,cAAc,oBAAoB;;;iBAI/B;EACf;EACA;;;iBAIe;;;;;;EAMf,YACE,eACA;IAAS;IAAkB;IAAkB,QAAQ;MACpD;EACH,YAAY,eAAe,QAAQ;;EAEnC,oBAAoB,eAAe,QAAQ;;EAE3C,eAAe,QAAQ;;EAEvB,cAAc,SAAS,YAAY;;iBAGpB;;EAEf;EACA;EACA;EACA,SAAS;EACT;;EAEA,OAAO,eAAe;EACtB;;;;;;EAMA;;EAEA,SAAS;;;;;;EAMT;;EAEA,WAAW;;;YAID;;;;;;iBAOK;;;;;EAKf,QAAQ,aAAa,SAAS;;EAE9B;;;;;;EAMA;;EAEA,OAAO;;;;;;EAMP,eAAe;;;;;;;iBAQA;EACf;EACA;;iBAGe;EACf,KAAK,SAAS,kBAAkB,eAAe;;;;;;;;;iBAUhC;;EAEf,SAAS;IACP;IACA;IACA;IACA;MACE;;EAEJ,aAAa;IACX;IACA;IACA;IACA;IACA;MACE;;EAEJ,iBAAiB,SAAS,kBAAkB,QAAQ;;EAEpD,aAAa,SAAS,iBAAiB;IAAU;MAAoB,aAAa;;EAElF,UAAU;IACR;IACA;IACA;IACA,QAAQ;IACR;;IAEA;MACE;;;;;;;;;iBAUW;EACf,SAAS;IACP,SAAS;IACT;IACA,OAAO,eAAe;MACpB,eAAe;;;iBAIJ;;;;;;;;EAQf,KAAK,eAAe,SAAS,eAAe,uBAAuB,aAAa;;;iBAIjE;;EAEf;;EAEA;;EAEA,MAAM,eAAe;EACrB;;EAEA;;;;;;;iBAQe;EACf,OAAO,YAAY,mBAAmB;;iBAGvB;EACf;;EAEA;;;;;;;EAOA;EACA,MAAM;EACN,OAAO;EACP,UAAU;EACV,QAAQ;;EAER,UAAU;;EAEV,YAAY;;EAEZ,WAAW;;;;;;;YAQD;EACN;;EACA;EAAkB;;EAClB;;EACA;;EACA"}
@@ -113,6 +113,12 @@ export interface MessageTracking {
113
113
  link?: (url: string, purpose: LinkPurpose) => string;
114
114
  /** Loaded when the email is opened. */
115
115
  pixel?: string;
116
+ /**
117
+ * This copy's one-click unsubscribe endpoint (RFC 8058): a POST there, with no confirmation
118
+ * and no session, unsubscribes the recipient. The email sender puts it in the
119
+ * `List-Unsubscribe` header, so a mail client's own unsubscribe button works.
120
+ */
121
+ unsubscribe?: string;
116
122
  /** Extra string keys in the push payload. */
117
123
  data?: Record<string, string>;
118
124
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"ports.d.mts","names":[],"sources":["../../src/engine/ports.ts"],"mappings":";;;;;;;;;;;;;;;;iBAiBiB;;EAEf,GAAG,GAAG,cAAc,UAAU,QAAQ,KAAK,QAAQ;;EAGnD,MAAM,cAAc,aAAa;;EAGjC,WAAW,cAAc,sBAAsB;;;;;;;;;;;;;;;EAgB/C,UACE,cACA,2BACA;IAAY;MACX;;EAGH,YAAY,eAAe;;;;;EAM3B,YAAY,cAAc,oBAAoB;;;iBAI/B;EACf;EACA;;;iBAIe;;;;;;EAMf,YACE,eACA;IAAS;IAAkB;IAAkB,QAAQ;MACpD;EACH,YAAY,eAAe,QAAQ;;EAEnC,oBAAoB,eAAe,QAAQ;;EAE3C,eAAe,QAAQ;;EAEvB,cAAc,SAAS,YAAY;;iBAGpB;;EAEf;EACA;EACA;EACA,SAAS;EACT;;EAEA,OAAO,eAAe;EACtB;;;;;;EAMA;;EAEA,SAAS;;;;;;EAMT;;EAEA,WAAW;;;YAID;;;;;;iBAOK;;;;;EAKf,QAAQ,aAAa,SAAS;;EAE9B;;EAEA,OAAO;;;;;;EAMP,eAAe;;;;;;;iBAQA;EACf;EACA;;iBAGe;EACf,KAAK,SAAS,kBAAkB,eAAe;;;;;;;;;iBAUhC;;EAEf,SAAS;IACP;IACA;IACA;IACA;MACE;;EAEJ,aAAa;IACX;IACA;IACA;IACA;IACA;MACE;;EAEJ,iBAAiB,SAAS,kBAAkB,QAAQ;;EAEpD,aAAa,SAAS,iBAAiB;IAAU;MAAoB,aAAa;;EAElF,UAAU;IACR;IACA;IACA;IACA,QAAQ;IACR;;IAEA;MACE;;;;;;;;;iBAUW;EACf,SAAS;IACP,SAAS;IACT;IACA,OAAO,eAAe;MACpB,eAAe;;;iBAIJ;;;;;;;;EAQf,KAAK,eAAe,SAAS,eAAe,uBAAuB,aAAa;;;iBAIjE;;EAEf;;EAEA;;EAEA,MAAM,eAAe;EACrB;;EAEA;;;;;;;iBAQe;EACf,OAAO,YAAY,mBAAmB;;iBAGvB;EACf;;EAEA;;;;;;;EAOA;EACA,MAAM;EACN,OAAO;EACP,UAAU;EACV,QAAQ;;EAER,UAAU;;EAEV,YAAY;;EAEZ,WAAW;;;;;;;YAQD;EACN;;EACA;EAAkB;;EAClB;;EACA;;EACA"}
1
+ {"version":3,"file":"ports.d.mts","names":[],"sources":["../../src/engine/ports.ts"],"mappings":";;;;;;;;;;;;;;;;iBAiBiB;;EAEf,GAAG,GAAG,cAAc,UAAU,QAAQ,KAAK,QAAQ;;EAGnD,MAAM,cAAc,aAAa;;EAGjC,WAAW,cAAc,sBAAsB;;;;;;;;;;;;;;;EAgB/C,UACE,cACA,2BACA;IAAY;MACX;;EAGH,YAAY,eAAe;;;;;EAM3B,YAAY,cAAc,oBAAoB;;;iBAI/B;EACf;EACA;;;iBAIe;;;;;;EAMf,YACE,eACA;IAAS;IAAkB;IAAkB,QAAQ;MACpD;EACH,YAAY,eAAe,QAAQ;;EAEnC,oBAAoB,eAAe,QAAQ;;EAE3C,eAAe,QAAQ;;EAEvB,cAAc,SAAS,YAAY;;iBAGpB;;EAEf;EACA;EACA;EACA,SAAS;EACT;;EAEA,OAAO,eAAe;EACtB;;;;;;EAMA;;EAEA,SAAS;;;;;;EAMT;;EAEA,WAAW;;;YAID;;;;;;iBAOK;;;;;EAKf,QAAQ,aAAa,SAAS;;EAE9B;;;;;;EAMA;;EAEA,OAAO;;;;;;EAMP,eAAe;;;;;;;iBAQA;EACf;EACA;;iBAGe;EACf,KAAK,SAAS,kBAAkB,eAAe;;;;;;;;;iBAUhC;;EAEf,SAAS;IACP;IACA;IACA;IACA;MACE;;EAEJ,aAAa;IACX;IACA;IACA;IACA;IACA;MACE;;EAEJ,iBAAiB,SAAS,kBAAkB,QAAQ;;EAEpD,aAAa,SAAS,iBAAiB;IAAU;MAAoB,aAAa;;EAElF,UAAU;IACR;IACA;IACA;IACA,QAAQ;IACR;;IAEA;MACE;;;;;;;;;iBAUW;EACf,SAAS;IACP,SAAS;IACT;IACA,OAAO,eAAe;MACpB,eAAe;;;iBAIJ;;;;;;;;EAQf,KAAK,eAAe,SAAS,eAAe,uBAAuB,aAAa;;;iBAIjE;;EAEf;;EAEA;;EAEA,MAAM,eAAe;EACrB;;EAEA;;;;;;;iBAQe;EACf,OAAO,YAAY,mBAAmB;;iBAGvB;EACf;;EAEA;;;;;;;EAOA;EACA,MAAM;EACN,OAAO;EACP,UAAU;EACV,QAAQ;;EAER,UAAU;;EAEV,YAAY;;EAEZ,WAAW;;;;;;;YAQD;EACN;;EACA;EAAkB;;EAClB;;EACA;;EACA"}
@@ -36,6 +36,7 @@ function reportingObserver(reporting, origin) {
36
36
  return `${base}/t/${purpose === "unsubscribe" ? "u" : "l"}/${id}/${urls.length - 1}`;
37
37
  },
38
38
  pixel: `${base}/t/o/${id}`,
39
+ unsubscribe: `${base}/t/u/${id}`,
39
40
  commit: () => reporting.recordLinks(id, urls)
40
41
  };
41
42
  }
@@ -1 +1 @@
1
- {"version":3,"file":"observer.cjs","names":[],"sources":["../../src/reporting/observer.ts"],"sourcesContent":["import type { JourneyObserver, OutboundMessage } from '../engine';\nimport type { JourneyReporting } from '../store';\n\n/**\n * The observer journeyWorker installs when the store keeps reporting: every node arrival, copy\n * and outcome recorded through `store.reporting`.\n *\n * A copy is recorded before it goes out, so its id can travel with it: with a tracking origin,\n * an email's links become `<origin>/t/l/<message>/<position>` — `/t/u/...` for its unsubscribe\n * link, so what a click means is fixed when it is sent — and an open pixel `<origin>/t/o/<message>`\n * (served by the Worker, ./tracking.ts), and a push carries\n * `message_id` in its payload for the app to report the tap (`JourneyClient.messageOpened`),\n * plus `delivered_url` / `opened_url` for a client that reports straight to the Worker.\n * The links are recorded when the sender commits the rewritten email, before it goes out.\n */\nexport function reportingObserver(\n reporting: JourneyReporting,\n origin: string | undefined\n): JourneyObserver {\n const base = origin?.replace(/\\/$/, '');\n\n return {\n started: (event) => reporting.instanceStarted(event),\n\n nodeReached: (event) => reporting.nodeReached(event),\n\n async messagePrepared(message) {\n const id = await reporting.recordMessage({\n idempotencyKey: message.idempotencyKey,\n workflow: message.workflow,\n instanceId: message.instanceId,\n nodeId: message.nodeId,\n template: message.template,\n channel: channelOf(message),\n userId: message.userId,\n sentAt: Date.now(),\n });\n if (message.channel === 'email' && base !== undefined) {\n const urls: string[] = [];\n return {\n link: (url, purpose) => {\n urls.push(url);\n return `${base}/t/${purpose === 'unsubscribe' ? 'u' : 'l'}/${id}/${urls.length - 1}`;\n },\n pixel: `${base}/t/o/${id}`,\n // Before the transport takes it, so no click can reach a link not yet recorded\n commit: () => reporting.recordLinks(id, urls),\n };\n }\n if (message.channel === 'push') {\n // The app reports the tap with `message_id`; a client that can reach the Worker on its\n // own (iOS's notification service extension, a browser's service worker) posts to these\n return {\n data: {\n message_id: id,\n ...(base === undefined\n ? {}\n : { delivered_url: `${base}/t/d/${id}`, opened_url: `${base}/t/p/${id}` }),\n },\n };\n }\n return undefined;\n },\n\n async messageSent(message, result) {\n await reporting.settleMessage(message.idempotencyKey, result, Date.now());\n },\n\n finished: (event) => reporting.instanceFinished(event),\n };\n}\n\n/** Where the copy lands — the Studio's metric channel: a push is reported per platform. */\nfunction channelOf(message: OutboundMessage): string {\n if (message.channel !== 'push') return message.channel;\n return `push_${message.device?.platform ?? 'unknown'}`;\n}\n"],"mappings":";;;;;;;;;;;;;;AAeA,SAAgB,kBACd,WACA,QACiB;CACjB,MAAM,OAAO,QAAQ,QAAQ,OAAO,EAAE;CAEtC,OAAO;EACL,UAAU,UAAU,UAAU,gBAAgB,KAAK;EAEnD,cAAc,UAAU,UAAU,YAAY,KAAK;EAEnD,MAAM,gBAAgB,SAAS;GAC7B,MAAM,KAAK,MAAM,UAAU,cAAc;IACvC,gBAAgB,QAAQ;IACxB,UAAU,QAAQ;IAClB,YAAY,QAAQ;IACpB,QAAQ,QAAQ;IAChB,UAAU,QAAQ;IAClB,SAAS,UAAU,OAAO;IAC1B,QAAQ,QAAQ;IAChB,QAAQ,KAAK,IAAI;GACnB,CAAC;GACD,IAAI,QAAQ,YAAY,WAAW,SAAS,KAAA,GAAW;IACrD,MAAM,OAAiB,CAAC;IACxB,OAAO;KACL,OAAO,KAAK,YAAY;MACtB,KAAK,KAAK,GAAG;MACb,OAAO,GAAG,KAAK,KAAK,YAAY,gBAAgB,MAAM,IAAI,GAAG,GAAG,GAAG,KAAK,SAAS;KACnF;KACA,OAAO,GAAG,KAAK,OAAO;KAEtB,cAAc,UAAU,YAAY,IAAI,IAAI;IAC9C;GACF;GACA,IAAI,QAAQ,YAAY,QAGtB,OAAO,EACL,MAAM;IACJ,YAAY;IACZ,GAAI,SAAS,KAAA,IACT,CAAC,IACD;KAAE,eAAe,GAAG,KAAK,OAAO;KAAM,YAAY,GAAG,KAAK,OAAO;IAAK;GAC5E,EACF;EAGJ;EAEA,MAAM,YAAY,SAAS,QAAQ;GACjC,MAAM,UAAU,cAAc,QAAQ,gBAAgB,QAAQ,KAAK,IAAI,CAAC;EAC1E;EAEA,WAAW,UAAU,UAAU,iBAAiB,KAAK;CACvD;AACF;;AAGA,SAAS,UAAU,SAAkC;CACnD,IAAI,QAAQ,YAAY,QAAQ,OAAO,QAAQ;CAC/C,OAAO,QAAQ,QAAQ,QAAQ,YAAY;AAC7C"}
1
+ {"version":3,"file":"observer.cjs","names":[],"sources":["../../src/reporting/observer.ts"],"sourcesContent":["import type { JourneyObserver, OutboundMessage } from '../engine';\nimport type { JourneyReporting } from '../store';\n\n/**\n * The observer journeyWorker installs when the store keeps reporting: every node arrival, copy\n * and outcome recorded through `store.reporting`.\n *\n * A copy is recorded before it goes out, so its id can travel with it: with a tracking origin,\n * an email's links become `<origin>/t/l/<message>/<position>` — `/t/u/...` for its unsubscribe\n * link, so what a click means is fixed when it is sent — and an open pixel `<origin>/t/o/<message>`\n * (served by the Worker, ./tracking.ts), and a push carries\n * `message_id` in its payload for the app to report the tap (`JourneyClient.messageOpened`),\n * plus `delivered_url` / `opened_url` for a client that reports straight to the Worker.\n * The links are recorded when the sender commits the rewritten email, before it goes out.\n */\nexport function reportingObserver(\n reporting: JourneyReporting,\n origin: string | undefined\n): JourneyObserver {\n const base = origin?.replace(/\\/$/, '');\n\n return {\n started: (event) => reporting.instanceStarted(event),\n\n nodeReached: (event) => reporting.nodeReached(event),\n\n async messagePrepared(message) {\n const id = await reporting.recordMessage({\n idempotencyKey: message.idempotencyKey,\n workflow: message.workflow,\n instanceId: message.instanceId,\n nodeId: message.nodeId,\n template: message.template,\n channel: channelOf(message),\n userId: message.userId,\n sentAt: Date.now(),\n });\n if (message.channel === 'email' && base !== undefined) {\n const urls: string[] = [];\n return {\n link: (url, purpose) => {\n urls.push(url);\n return `${base}/t/${purpose === 'unsubscribe' ? 'u' : 'l'}/${id}/${urls.length - 1}`;\n },\n pixel: `${base}/t/o/${id}`,\n unsubscribe: `${base}/t/u/${id}`,\n // Before the transport takes it, so no click can reach a link not yet recorded\n commit: () => reporting.recordLinks(id, urls),\n };\n }\n if (message.channel === 'push') {\n // The app reports the tap with `message_id`; a client that can reach the Worker on its\n // own (iOS's notification service extension, a browser's service worker) posts to these\n return {\n data: {\n message_id: id,\n ...(base === undefined\n ? {}\n : { delivered_url: `${base}/t/d/${id}`, opened_url: `${base}/t/p/${id}` }),\n },\n };\n }\n return undefined;\n },\n\n async messageSent(message, result) {\n await reporting.settleMessage(message.idempotencyKey, result, Date.now());\n },\n\n finished: (event) => reporting.instanceFinished(event),\n };\n}\n\n/** Where the copy lands — the Studio's metric channel: a push is reported per platform. */\nfunction channelOf(message: OutboundMessage): string {\n if (message.channel !== 'push') return message.channel;\n return `push_${message.device?.platform ?? 'unknown'}`;\n}\n"],"mappings":";;;;;;;;;;;;;;AAeA,SAAgB,kBACd,WACA,QACiB;CACjB,MAAM,OAAO,QAAQ,QAAQ,OAAO,EAAE;CAEtC,OAAO;EACL,UAAU,UAAU,UAAU,gBAAgB,KAAK;EAEnD,cAAc,UAAU,UAAU,YAAY,KAAK;EAEnD,MAAM,gBAAgB,SAAS;GAC7B,MAAM,KAAK,MAAM,UAAU,cAAc;IACvC,gBAAgB,QAAQ;IACxB,UAAU,QAAQ;IAClB,YAAY,QAAQ;IACpB,QAAQ,QAAQ;IAChB,UAAU,QAAQ;IAClB,SAAS,UAAU,OAAO;IAC1B,QAAQ,QAAQ;IAChB,QAAQ,KAAK,IAAI;GACnB,CAAC;GACD,IAAI,QAAQ,YAAY,WAAW,SAAS,KAAA,GAAW;IACrD,MAAM,OAAiB,CAAC;IACxB,OAAO;KACL,OAAO,KAAK,YAAY;MACtB,KAAK,KAAK,GAAG;MACb,OAAO,GAAG,KAAK,KAAK,YAAY,gBAAgB,MAAM,IAAI,GAAG,GAAG,GAAG,KAAK,SAAS;KACnF;KACA,OAAO,GAAG,KAAK,OAAO;KACtB,aAAa,GAAG,KAAK,OAAO;KAE5B,cAAc,UAAU,YAAY,IAAI,IAAI;IAC9C;GACF;GACA,IAAI,QAAQ,YAAY,QAGtB,OAAO,EACL,MAAM;IACJ,YAAY;IACZ,GAAI,SAAS,KAAA,IACT,CAAC,IACD;KAAE,eAAe,GAAG,KAAK,OAAO;KAAM,YAAY,GAAG,KAAK,OAAO;IAAK;GAC5E,EACF;EAGJ;EAEA,MAAM,YAAY,SAAS,QAAQ;GACjC,MAAM,UAAU,cAAc,QAAQ,gBAAgB,QAAQ,KAAK,IAAI,CAAC;EAC1E;EAEA,WAAW,UAAU,UAAU,iBAAiB,KAAK;CACvD;AACF;;AAGA,SAAS,UAAU,SAAkC;CACnD,IAAI,QAAQ,YAAY,QAAQ,OAAO,QAAQ;CAC/C,OAAO,QAAQ,QAAQ,QAAQ,YAAY;AAC7C"}
@@ -35,6 +35,7 @@ function reportingObserver(reporting, origin) {
35
35
  return `${base}/t/${purpose === "unsubscribe" ? "u" : "l"}/${id}/${urls.length - 1}`;
36
36
  },
37
37
  pixel: `${base}/t/o/${id}`,
38
+ unsubscribe: `${base}/t/u/${id}`,
38
39
  commit: () => reporting.recordLinks(id, urls)
39
40
  };
40
41
  }
@@ -1 +1 @@
1
- {"version":3,"file":"observer.mjs","names":[],"sources":["../../src/reporting/observer.ts"],"sourcesContent":["import type { JourneyObserver, OutboundMessage } from '../engine';\nimport type { JourneyReporting } from '../store';\n\n/**\n * The observer journeyWorker installs when the store keeps reporting: every node arrival, copy\n * and outcome recorded through `store.reporting`.\n *\n * A copy is recorded before it goes out, so its id can travel with it: with a tracking origin,\n * an email's links become `<origin>/t/l/<message>/<position>` — `/t/u/...` for its unsubscribe\n * link, so what a click means is fixed when it is sent — and an open pixel `<origin>/t/o/<message>`\n * (served by the Worker, ./tracking.ts), and a push carries\n * `message_id` in its payload for the app to report the tap (`JourneyClient.messageOpened`),\n * plus `delivered_url` / `opened_url` for a client that reports straight to the Worker.\n * The links are recorded when the sender commits the rewritten email, before it goes out.\n */\nexport function reportingObserver(\n reporting: JourneyReporting,\n origin: string | undefined\n): JourneyObserver {\n const base = origin?.replace(/\\/$/, '');\n\n return {\n started: (event) => reporting.instanceStarted(event),\n\n nodeReached: (event) => reporting.nodeReached(event),\n\n async messagePrepared(message) {\n const id = await reporting.recordMessage({\n idempotencyKey: message.idempotencyKey,\n workflow: message.workflow,\n instanceId: message.instanceId,\n nodeId: message.nodeId,\n template: message.template,\n channel: channelOf(message),\n userId: message.userId,\n sentAt: Date.now(),\n });\n if (message.channel === 'email' && base !== undefined) {\n const urls: string[] = [];\n return {\n link: (url, purpose) => {\n urls.push(url);\n return `${base}/t/${purpose === 'unsubscribe' ? 'u' : 'l'}/${id}/${urls.length - 1}`;\n },\n pixel: `${base}/t/o/${id}`,\n // Before the transport takes it, so no click can reach a link not yet recorded\n commit: () => reporting.recordLinks(id, urls),\n };\n }\n if (message.channel === 'push') {\n // The app reports the tap with `message_id`; a client that can reach the Worker on its\n // own (iOS's notification service extension, a browser's service worker) posts to these\n return {\n data: {\n message_id: id,\n ...(base === undefined\n ? {}\n : { delivered_url: `${base}/t/d/${id}`, opened_url: `${base}/t/p/${id}` }),\n },\n };\n }\n return undefined;\n },\n\n async messageSent(message, result) {\n await reporting.settleMessage(message.idempotencyKey, result, Date.now());\n },\n\n finished: (event) => reporting.instanceFinished(event),\n };\n}\n\n/** Where the copy lands — the Studio's metric channel: a push is reported per platform. */\nfunction channelOf(message: OutboundMessage): string {\n if (message.channel !== 'push') return message.channel;\n return `push_${message.device?.platform ?? 'unknown'}`;\n}\n"],"mappings":";;;;;;;;;;;;;AAeA,SAAgB,kBACd,WACA,QACiB;CACjB,MAAM,OAAO,QAAQ,QAAQ,OAAO,EAAE;CAEtC,OAAO;EACL,UAAU,UAAU,UAAU,gBAAgB,KAAK;EAEnD,cAAc,UAAU,UAAU,YAAY,KAAK;EAEnD,MAAM,gBAAgB,SAAS;GAC7B,MAAM,KAAK,MAAM,UAAU,cAAc;IACvC,gBAAgB,QAAQ;IACxB,UAAU,QAAQ;IAClB,YAAY,QAAQ;IACpB,QAAQ,QAAQ;IAChB,UAAU,QAAQ;IAClB,SAAS,UAAU,OAAO;IAC1B,QAAQ,QAAQ;IAChB,QAAQ,KAAK,IAAI;GACnB,CAAC;GACD,IAAI,QAAQ,YAAY,WAAW,SAAS,KAAA,GAAW;IACrD,MAAM,OAAiB,CAAC;IACxB,OAAO;KACL,OAAO,KAAK,YAAY;MACtB,KAAK,KAAK,GAAG;MACb,OAAO,GAAG,KAAK,KAAK,YAAY,gBAAgB,MAAM,IAAI,GAAG,GAAG,GAAG,KAAK,SAAS;KACnF;KACA,OAAO,GAAG,KAAK,OAAO;KAEtB,cAAc,UAAU,YAAY,IAAI,IAAI;IAC9C;GACF;GACA,IAAI,QAAQ,YAAY,QAGtB,OAAO,EACL,MAAM;IACJ,YAAY;IACZ,GAAI,SAAS,KAAA,IACT,CAAC,IACD;KAAE,eAAe,GAAG,KAAK,OAAO;KAAM,YAAY,GAAG,KAAK,OAAO;IAAK;GAC5E,EACF;EAGJ;EAEA,MAAM,YAAY,SAAS,QAAQ;GACjC,MAAM,UAAU,cAAc,QAAQ,gBAAgB,QAAQ,KAAK,IAAI,CAAC;EAC1E;EAEA,WAAW,UAAU,UAAU,iBAAiB,KAAK;CACvD;AACF;;AAGA,SAAS,UAAU,SAAkC;CACnD,IAAI,QAAQ,YAAY,QAAQ,OAAO,QAAQ;CAC/C,OAAO,QAAQ,QAAQ,QAAQ,YAAY;AAC7C"}
1
+ {"version":3,"file":"observer.mjs","names":[],"sources":["../../src/reporting/observer.ts"],"sourcesContent":["import type { JourneyObserver, OutboundMessage } from '../engine';\nimport type { JourneyReporting } from '../store';\n\n/**\n * The observer journeyWorker installs when the store keeps reporting: every node arrival, copy\n * and outcome recorded through `store.reporting`.\n *\n * A copy is recorded before it goes out, so its id can travel with it: with a tracking origin,\n * an email's links become `<origin>/t/l/<message>/<position>` — `/t/u/...` for its unsubscribe\n * link, so what a click means is fixed when it is sent — and an open pixel `<origin>/t/o/<message>`\n * (served by the Worker, ./tracking.ts), and a push carries\n * `message_id` in its payload for the app to report the tap (`JourneyClient.messageOpened`),\n * plus `delivered_url` / `opened_url` for a client that reports straight to the Worker.\n * The links are recorded when the sender commits the rewritten email, before it goes out.\n */\nexport function reportingObserver(\n reporting: JourneyReporting,\n origin: string | undefined\n): JourneyObserver {\n const base = origin?.replace(/\\/$/, '');\n\n return {\n started: (event) => reporting.instanceStarted(event),\n\n nodeReached: (event) => reporting.nodeReached(event),\n\n async messagePrepared(message) {\n const id = await reporting.recordMessage({\n idempotencyKey: message.idempotencyKey,\n workflow: message.workflow,\n instanceId: message.instanceId,\n nodeId: message.nodeId,\n template: message.template,\n channel: channelOf(message),\n userId: message.userId,\n sentAt: Date.now(),\n });\n if (message.channel === 'email' && base !== undefined) {\n const urls: string[] = [];\n return {\n link: (url, purpose) => {\n urls.push(url);\n return `${base}/t/${purpose === 'unsubscribe' ? 'u' : 'l'}/${id}/${urls.length - 1}`;\n },\n pixel: `${base}/t/o/${id}`,\n unsubscribe: `${base}/t/u/${id}`,\n // Before the transport takes it, so no click can reach a link not yet recorded\n commit: () => reporting.recordLinks(id, urls),\n };\n }\n if (message.channel === 'push') {\n // The app reports the tap with `message_id`; a client that can reach the Worker on its\n // own (iOS's notification service extension, a browser's service worker) posts to these\n return {\n data: {\n message_id: id,\n ...(base === undefined\n ? {}\n : { delivered_url: `${base}/t/d/${id}`, opened_url: `${base}/t/p/${id}` }),\n },\n };\n }\n return undefined;\n },\n\n async messageSent(message, result) {\n await reporting.settleMessage(message.idempotencyKey, result, Date.now());\n },\n\n finished: (event) => reporting.instanceFinished(event),\n };\n}\n\n/** Where the copy lands — the Studio's metric channel: a push is reported per platform. */\nfunction channelOf(message: OutboundMessage): string {\n if (message.channel !== 'push') return message.channel;\n return `push_${message.device?.platform ?? 'unknown'}`;\n}\n"],"mappings":";;;;;;;;;;;;;AAeA,SAAgB,kBACd,WACA,QACiB;CACjB,MAAM,OAAO,QAAQ,QAAQ,OAAO,EAAE;CAEtC,OAAO;EACL,UAAU,UAAU,UAAU,gBAAgB,KAAK;EAEnD,cAAc,UAAU,UAAU,YAAY,KAAK;EAEnD,MAAM,gBAAgB,SAAS;GAC7B,MAAM,KAAK,MAAM,UAAU,cAAc;IACvC,gBAAgB,QAAQ;IACxB,UAAU,QAAQ;IAClB,YAAY,QAAQ;IACpB,QAAQ,QAAQ;IAChB,UAAU,QAAQ;IAClB,SAAS,UAAU,OAAO;IAC1B,QAAQ,QAAQ;IAChB,QAAQ,KAAK,IAAI;GACnB,CAAC;GACD,IAAI,QAAQ,YAAY,WAAW,SAAS,KAAA,GAAW;IACrD,MAAM,OAAiB,CAAC;IACxB,OAAO;KACL,OAAO,KAAK,YAAY;MACtB,KAAK,KAAK,GAAG;MACb,OAAO,GAAG,KAAK,KAAK,YAAY,gBAAgB,MAAM,IAAI,GAAG,GAAG,GAAG,KAAK,SAAS;KACnF;KACA,OAAO,GAAG,KAAK,OAAO;KACtB,aAAa,GAAG,KAAK,OAAO;KAE5B,cAAc,UAAU,YAAY,IAAI,IAAI;IAC9C;GACF;GACA,IAAI,QAAQ,YAAY,QAGtB,OAAO,EACL,MAAM;IACJ,YAAY;IACZ,GAAI,SAAS,KAAA,IACT,CAAC,IACD;KAAE,eAAe,GAAG,KAAK,OAAO;KAAM,YAAY,GAAG,KAAK,OAAO;IAAK;GAC5E,EACF;EAGJ;EAEA,MAAM,YAAY,SAAS,QAAQ;GACjC,MAAM,UAAU,cAAc,QAAQ,gBAAgB,QAAQ,KAAK,IAAI,CAAC;EAC1E;EAEA,WAAW,UAAU,UAAU,iBAAiB,KAAK;CACvD;AACF;;AAGA,SAAS,UAAU,SAAkC;CACnD,IAAI,QAAQ,YAAY,QAAQ,OAAO,QAAQ;CAC/C,OAAO,QAAQ,QAAQ,QAAQ,YAAY;AAC7C"}
@@ -34,9 +34,10 @@ async function handleTracking(request, reporting, options = {}) {
34
34
  if ((kind === "l" || kind === "u" || kind === "c") && position !== void 0 && read) {
35
35
  const target = await recordClick(reporting, id, Number(position), who, options);
36
36
  if (target === void 0) return page(options, "This link has expired.", 404);
37
- if (kind === "u" || kind === "c" && require_email.isUnsubscribeLink(target, options.legacyUnsubscribeUrl)) return page(options, `Unsubscribe from ${escape(options.product ?? "these")} emails?`, 200, `<form method="post" action="/t/u/${id}"><button type="submit">Unsubscribe</button></form>`);
37
+ if (kind === "u" || kind === "c" && require_email.isUnsubscribeLink(target, options.legacyUnsubscribeUrl)) return confirmUnsubscribe(options, id);
38
38
  return Response.redirect(target, 302);
39
39
  }
40
+ if (kind === "u" && position === void 0 && read) return confirmUnsubscribe(options, id);
40
41
  if (kind === "d" && who.method === "POST") {
41
42
  const known = await reporting.recordDeviceDelivered(id, Date.now());
42
43
  return new Response(null, { status: known ? 204 : 404 });
@@ -65,10 +66,14 @@ async function handleTracking(request, reporting, options = {}) {
65
66
  return new Response(null, { status: known ? 204 : 404 });
66
67
  }
67
68
  if (kind === "u" && who.method === "POST") {
68
- if (!await reporting.recordUnsubscribe(id, Date.now())) return page(options, "This link has expired.", 404);
69
+ if (!await reporting.recordUnsubscribe(id, Date.now())) return page(options, "You won't get these emails anymore.");
69
70
  return page(options, options.product === void 0 ? "You won't get these emails anymore." : `You won't get these emails from ${escape(options.product)} anymore.`);
70
71
  }
71
72
  }
73
+ /** The question an unsubscribe link, or the List-Unsubscribe URL opened in a browser, asks. */
74
+ function confirmUnsubscribe(options, id) {
75
+ return page(options, `Unsubscribe from ${escape(options.product ?? "these")} emails?`, 200, `<form method="post" action="/t/u/${id}"><button type="submit">Unsubscribe</button></form>`);
76
+ }
72
77
  /**
73
78
  * A journey push tapped open, as the app reports it (`JourneyClient.messageOpened`): an open on
74
79
  * the copy — only the recipient's own, and never a machine, since no proxy or scanner taps a
@@ -1 +1 @@
1
- {"version":3,"file":"tracking.cjs","names":["isUnsubscribeLink","classifyOpen","BURST_WINDOW_MS","classifyClick"],"sources":["../../src/reporting/tracking.ts"],"sourcesContent":["import { isUnsubscribeLink } from '../email';\nimport type { JourneyReporting } from '../store';\nimport { BURST_WINDOW_MS, classifyClick, classifyOpen } from './classify';\n\n/**\n * The endpoints a journey email's tracking points at (see ./observer.ts), served by journeyWorker\n * without a bearer — recipients open them:\n *\n * GET /t/o/<message> the open pixel: records the open, answers a 1×1 GIF\n * GET /t/l/<message>/<position> a link: records the click, redirects to the recorded URL\n * GET /t/u/<message>/<position> the unsubscribe link: records the click, asks to confirm\n * POST /t/u/<message> the unsubscribe confirmation\n * GET /t/c/<message>/<position> a link of an email sent before links carried their purpose\n * POST /t/d/<message> a push the device received (iOS's notification service\n * extension, a browser's service worker)\n * POST /t/p/<message> a push opened, reported by a browser's service worker\n *\n * Every open and click is recorded with its verdict (./classify.ts), and the message keeps its\n * first open and click of each kind. The redirect goes only to the URL recorded for that position,\n * never to one the request carries, so the endpoint cannot bounce visitors anywhere else. An\n * email's unsubscribe link is a tracked link too, and which link it is was fixed when the email was\n * sent (its route): following it shows a confirmation page, and only the confirmation — a POST,\n * which no link scanner sends — counts. Changing a template's or the config's unsubscribe URL, or\n * removing the template, changes nothing for emails already sent.\n *\n * Emails sent before that carry `/t/c/` links, which say nothing of their purpose: one is the\n * unsubscribe link when its recorded URL is `legacyUnsubscribeUrl` (the global unsubscribe URL\n * those emails were all sent with), as it was then.\n */\n\nexport interface TrackingOptions {\n /**\n * The unsubscribe URL emails sent with `/t/c/` links carried (the config's `email.unsubscribeUrl`\n * by default): following such a link to it asks for confirmation instead of redirecting.\n */\n legacyUnsubscribeUrl?: string | undefined;\n /** The name the confirmation pages show (the config's `title`). */\n product?: string | undefined;\n /** `email.tracking.quickClickMs`: how soon after delivery an open or click is a weak machine signal. */\n quickMs?: number | undefined;\n /** A browser reported a push tapped (`/t/p`): the host records it as an event too (`webPush.openedEvent`). */\n pushOpened?:\n | ((open: { messageId: string; userId: string; at: number }) => Promise<void>)\n | undefined;\n}\n\nconst GIF = Uint8Array.from(\n atob('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'),\n (char) => char.charCodeAt(0)\n);\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\ninterface Requester {\n method: string;\n userAgent: string | null;\n asn: number | null;\n organization: string | null;\n country: string | null;\n}\n\nfunction requester(request: Request): Requester {\n const cf = (\n request as Request & { cf?: { asn?: number; asOrganization?: string; country?: string } }\n ).cf;\n return {\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n asn: cf?.asn ?? null,\n organization: cf?.asOrganization ?? null,\n country: cf?.country ?? null,\n };\n}\n\n/** A `/t/...` request's response, or undefined when the path is not a tracking route. */\nexport async function handleTracking(\n request: Request,\n reporting: JourneyReporting,\n options: TrackingOptions = {}\n): Promise<Response | undefined> {\n const url = new URL(request.url);\n const path = url.pathname.split('/');\n const root = path.at(1);\n const kind = path.at(2);\n const id = path.at(3);\n const position = path.at(4);\n if (root !== 't' || id === undefined || !UUID.test(id)) return undefined;\n const who = requester(request);\n const read = who.method === 'GET' || who.method === 'HEAD';\n if (kind === 'o' && read) {\n await recordOpen(reporting, id, who, options);\n return new Response(who.method === 'HEAD' ? null : GIF, {\n headers: { 'content-type': 'image/gif', 'cache-control': 'no-store, max-age=0' },\n });\n }\n if ((kind === 'l' || kind === 'u' || kind === 'c') && position !== undefined && read) {\n const target = await recordClick(reporting, id, Number(position), who, options);\n if (target === undefined) return page(options, 'This link has expired.', 404);\n const unsubscribe =\n kind === 'u' || (kind === 'c' && isUnsubscribeLink(target, options.legacyUnsubscribeUrl));\n if (unsubscribe) {\n return page(\n options,\n `Unsubscribe from ${escape(options.product ?? 'these')} emails?`,\n 200,\n `<form method=\"post\" action=\"/t/u/${id}\"><button type=\"submit\">Unsubscribe</button></form>`\n );\n }\n return Response.redirect(target, 302);\n }\n if (kind === 'd' && who.method === 'POST') {\n const known = await reporting.recordDeviceDelivered(id, Date.now());\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'p' && who.method === 'POST') {\n // A notification tap: a person, as the message id is what proves it is theirs\n const at = Date.now();\n let userId: string | undefined;\n const known = await reporting.recordOpen(id, (message) => {\n userId = message.userId;\n return { at, machine: false, reason: null, ...network(who) };\n });\n if (known && userId !== undefined && options.pushOpened !== undefined) {\n try {\n await options.pushOpened({ messageId: id, userId, at });\n } catch (error) {\n // The open is recorded; the event is the host's extra, never a reason to fail the beacon\n console.error(`[workflow] recording push open ${id} as an event failed`, error);\n }\n }\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'u' && who.method === 'POST') {\n const known = await reporting.recordUnsubscribe(id, Date.now());\n if (!known) return page(options, 'This link has expired.', 404);\n return page(\n options,\n options.product === undefined\n ? \"You won't get these emails anymore.\"\n : `You won't get these emails from ${escape(options.product)} anymore.`\n );\n }\n return undefined;\n}\n\n/**\n * A journey push tapped open, as the app reports it (`JourneyClient.messageOpened`): an open on\n * the copy — only the recipient's own, and never a machine, since no proxy or scanner taps a\n * notification. False when the message is unknown or not the user's.\n */\nexport async function recordPushOpen(\n reporting: JourneyReporting,\n userId: string,\n messageId: string,\n at = Date.now()\n): Promise<boolean> {\n if (!UUID.test(messageId)) return false;\n let recorded = false;\n await reporting.recordOpen(messageId, (message) => {\n if (message.userId !== userId) return undefined;\n recorded = true;\n return {\n at,\n machine: false,\n reason: null,\n userAgent: null,\n asn: null,\n organization: null,\n country: null,\n };\n });\n return recorded;\n}\n\nasync function recordOpen(\n reporting: JourneyReporting,\n id: string,\n who: Requester,\n options: TrackingOptions\n) {\n const at = Date.now();\n await reporting.recordOpen(id, (message) => ({\n at,\n ...classifyOpen(\n { ...who, sinceDeliveredMs: at - message.deliveredAt },\n { quickMs: options.quickMs }\n ),\n ...network(who),\n }));\n}\n\nconst network = (who: Requester) => ({\n userAgent: who.userAgent,\n asn: who.asn,\n organization: who.organization,\n country: who.country,\n});\n\n/** Records the click and answers the URL to send the visitor to, or undefined for an unknown link. */\nasync function recordClick(\n reporting: JourneyReporting,\n id: string,\n position: number,\n who: Requester,\n options: TrackingOptions\n): Promise<string | undefined> {\n if (!Number.isInteger(position) || position < 0) return undefined;\n const at = Date.now();\n const recorded = await reporting.recordClick(id, position, at, BURST_WINDOW_MS, (link) => {\n const linksInBurst = new Set([position, ...link.recent.map((click) => click.position)]).size;\n const verdict = classifyClick(\n { ...who, sinceDeliveredMs: at - link.deliveredAt, linksInBurst, linkCount: link.linkCount },\n { quickMs: options.quickMs }\n );\n return {\n engagement: { at, ...verdict, ...network(who) },\n backdate: verdict.reason === 'link_burst' || verdict.reason === 'quick+link_pair',\n };\n });\n return recorded?.url;\n}\n\nfunction escape(text: string): string {\n return text.replace(/[&<>\"']/g, (c) => `&#${c.charCodeAt(0)};`);\n}\n\nfunction page(options: TrackingOptions, message: string, status = 200, extra = ''): Response {\n const title = escape(options.product ?? 'Email');\n const html = `<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>${title}</title><style>body{font:16px system-ui;margin:0;display:grid;place-items:center;min-height:100vh;background:#fafafa;color:#18181b}main{text-align:center;padding:24px}button{font:inherit;padding:10px 20px;border-radius:9999px;border:0;background:#18181b;color:#fff;cursor:pointer}</style></head><body><main><p>${message}</p>${extra}</main></body></html>`;\n return new Response(html, { status, headers: { 'content-type': 'text/html; charset=utf-8' } });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"tracking.cjs","names":["isUnsubscribeLink","classifyOpen","BURST_WINDOW_MS","classifyClick"],"sources":["../../src/reporting/tracking.ts"],"sourcesContent":["import { isUnsubscribeLink } from '../email';\nimport type { JourneyReporting } from '../store';\nimport { BURST_WINDOW_MS, classifyClick, classifyOpen } from './classify';\n\n/**\n * The endpoints a journey email's tracking points at (see ./observer.ts), served by journeyWorker\n * without a bearer — recipients open them:\n *\n * GET /t/o/<message> the open pixel: records the open, answers a 1×1 GIF\n * GET /t/l/<message>/<position> a link: records the click, redirects to the recorded URL\n * GET /t/u/<message>/<position> the unsubscribe link: records the click, asks to confirm\n * GET /t/u/<message> the List-Unsubscribe header's URL, opened: asks to confirm\n * POST /t/u/<message> the unsubscribe confirmation, and the header's one-click\n * unsubscribe (RFC 8058: a mail client's button posts here)\n * GET /t/c/<message>/<position> a link of an email sent before links carried their purpose\n * POST /t/d/<message> a push the device received (iOS's notification service\n * extension, a browser's service worker)\n * POST /t/p/<message> a push opened, reported by a browser's service worker\n *\n * Every open and click is recorded with its verdict (./classify.ts), and the message keeps its\n * first open and click of each kind. The redirect goes only to the URL recorded for that position,\n * never to one the request carries, so the endpoint cannot bounce visitors anywhere else. An\n * email's unsubscribe link is a tracked link too, and which link it is was fixed when the email was\n * sent (its route): following it shows a confirmation page, and only the confirmation — a POST,\n * which no link scanner sends — counts. Changing a template's or the config's unsubscribe URL, or\n * removing the template, changes nothing for emails already sent.\n *\n * Emails sent before that carry `/t/c/` links, which say nothing of their purpose: one is the\n * unsubscribe link when its recorded URL is `legacyUnsubscribeUrl` (the global unsubscribe URL\n * those emails were all sent with), as it was then.\n */\n\nexport interface TrackingOptions {\n /**\n * The unsubscribe URL emails sent with `/t/c/` links carried (the config's `email.unsubscribeUrl`\n * by default): following such a link to it asks for confirmation instead of redirecting.\n */\n legacyUnsubscribeUrl?: string | undefined;\n /** The name the confirmation pages show (the config's `title`). */\n product?: string | undefined;\n /** `email.tracking.quickClickMs`: how soon after delivery an open or click is a weak machine signal. */\n quickMs?: number | undefined;\n /** A browser reported a push tapped (`/t/p`): the host records it as an event too (`webPush.openedEvent`). */\n pushOpened?:\n | ((open: { messageId: string; userId: string; at: number }) => Promise<void>)\n | undefined;\n}\n\nconst GIF = Uint8Array.from(\n atob('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'),\n (char) => char.charCodeAt(0)\n);\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\ninterface Requester {\n method: string;\n userAgent: string | null;\n asn: number | null;\n organization: string | null;\n country: string | null;\n}\n\nfunction requester(request: Request): Requester {\n const cf = (\n request as Request & { cf?: { asn?: number; asOrganization?: string; country?: string } }\n ).cf;\n return {\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n asn: cf?.asn ?? null,\n organization: cf?.asOrganization ?? null,\n country: cf?.country ?? null,\n };\n}\n\n/** A `/t/...` request's response, or undefined when the path is not a tracking route. */\nexport async function handleTracking(\n request: Request,\n reporting: JourneyReporting,\n options: TrackingOptions = {}\n): Promise<Response | undefined> {\n const url = new URL(request.url);\n const path = url.pathname.split('/');\n const root = path.at(1);\n const kind = path.at(2);\n const id = path.at(3);\n const position = path.at(4);\n if (root !== 't' || id === undefined || !UUID.test(id)) return undefined;\n const who = requester(request);\n const read = who.method === 'GET' || who.method === 'HEAD';\n if (kind === 'o' && read) {\n await recordOpen(reporting, id, who, options);\n return new Response(who.method === 'HEAD' ? null : GIF, {\n headers: { 'content-type': 'image/gif', 'cache-control': 'no-store, max-age=0' },\n });\n }\n if ((kind === 'l' || kind === 'u' || kind === 'c') && position !== undefined && read) {\n const target = await recordClick(reporting, id, Number(position), who, options);\n if (target === undefined) return page(options, 'This link has expired.', 404);\n const unsubscribe =\n kind === 'u' || (kind === 'c' && isUnsubscribeLink(target, options.legacyUnsubscribeUrl));\n if (unsubscribe) return confirmUnsubscribe(options, id);\n return Response.redirect(target, 302);\n }\n // The List-Unsubscribe URL opened in a browser (a client without one-click): the same question\n if (kind === 'u' && position === undefined && read) return confirmUnsubscribe(options, id);\n if (kind === 'd' && who.method === 'POST') {\n const known = await reporting.recordDeviceDelivered(id, Date.now());\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'p' && who.method === 'POST') {\n // A notification tap: a person, as the message id is what proves it is theirs\n const at = Date.now();\n let userId: string | undefined;\n const known = await reporting.recordOpen(id, (message) => {\n userId = message.userId;\n return { at, machine: false, reason: null, ...network(who) };\n });\n if (known && userId !== undefined && options.pushOpened !== undefined) {\n try {\n await options.pushOpened({ messageId: id, userId, at });\n } catch (error) {\n // The open is recorded; the event is the host's extra, never a reason to fail the beacon\n console.error(`[workflow] recording push open ${id} as an event failed`, error);\n }\n }\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'u' && who.method === 'POST') {\n const known = await reporting.recordUnsubscribe(id, Date.now());\n // A mail client's one-click POST must succeed: for a copy no longer on record (its user\n // erased) there is nothing left to send to, so the answer is the same\n if (!known) return page(options, \"You won't get these emails anymore.\");\n return page(\n options,\n options.product === undefined\n ? \"You won't get these emails anymore.\"\n : `You won't get these emails from ${escape(options.product)} anymore.`\n );\n }\n return undefined;\n}\n\n/** The question an unsubscribe link, or the List-Unsubscribe URL opened in a browser, asks. */\nfunction confirmUnsubscribe(options: TrackingOptions, id: string): Response {\n return page(\n options,\n `Unsubscribe from ${escape(options.product ?? 'these')} emails?`,\n 200,\n `<form method=\"post\" action=\"/t/u/${id}\"><button type=\"submit\">Unsubscribe</button></form>`\n );\n}\n\n/**\n * A journey push tapped open, as the app reports it (`JourneyClient.messageOpened`): an open on\n * the copy — only the recipient's own, and never a machine, since no proxy or scanner taps a\n * notification. False when the message is unknown or not the user's.\n */\nexport async function recordPushOpen(\n reporting: JourneyReporting,\n userId: string,\n messageId: string,\n at = Date.now()\n): Promise<boolean> {\n if (!UUID.test(messageId)) return false;\n let recorded = false;\n await reporting.recordOpen(messageId, (message) => {\n if (message.userId !== userId) return undefined;\n recorded = true;\n return {\n at,\n machine: false,\n reason: null,\n userAgent: null,\n asn: null,\n organization: null,\n country: null,\n };\n });\n return recorded;\n}\n\nasync function recordOpen(\n reporting: JourneyReporting,\n id: string,\n who: Requester,\n options: TrackingOptions\n) {\n const at = Date.now();\n await reporting.recordOpen(id, (message) => ({\n at,\n ...classifyOpen(\n { ...who, sinceDeliveredMs: at - message.deliveredAt },\n { quickMs: options.quickMs }\n ),\n ...network(who),\n }));\n}\n\nconst network = (who: Requester) => ({\n userAgent: who.userAgent,\n asn: who.asn,\n organization: who.organization,\n country: who.country,\n});\n\n/** Records the click and answers the URL to send the visitor to, or undefined for an unknown link. */\nasync function recordClick(\n reporting: JourneyReporting,\n id: string,\n position: number,\n who: Requester,\n options: TrackingOptions\n): Promise<string | undefined> {\n if (!Number.isInteger(position) || position < 0) return undefined;\n const at = Date.now();\n const recorded = await reporting.recordClick(id, position, at, BURST_WINDOW_MS, (link) => {\n const linksInBurst = new Set([position, ...link.recent.map((click) => click.position)]).size;\n const verdict = classifyClick(\n { ...who, sinceDeliveredMs: at - link.deliveredAt, linksInBurst, linkCount: link.linkCount },\n { quickMs: options.quickMs }\n );\n return {\n engagement: { at, ...verdict, ...network(who) },\n backdate: verdict.reason === 'link_burst' || verdict.reason === 'quick+link_pair',\n };\n });\n return recorded?.url;\n}\n\nfunction escape(text: string): string {\n return text.replace(/[&<>\"']/g, (c) => `&#${c.charCodeAt(0)};`);\n}\n\nfunction page(options: TrackingOptions, message: string, status = 200, extra = ''): Response {\n const title = escape(options.product ?? 'Email');\n const html = `<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>${title}</title><style>body{font:16px system-ui;margin:0;display:grid;place-items:center;min-height:100vh;background:#fafafa;color:#18181b}main{text-align:center;padding:24px}button{font:inherit;padding:10px 20px;border-radius:9999px;border:0;background:#18181b;color:#fff;cursor:pointer}</style></head><body><main><p>${message}</p>${extra}</main></body></html>`;\n return new Response(html, { status, headers: { 'content-type': 'text/html; charset=utf-8' } });\n}\n"],"mappings":"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"}
@@ -7,7 +7,9 @@ import { JourneyReporting } from "../store/index.cjs";
7
7
  * GET /t/o/<message> the open pixel: records the open, answers a 1×1 GIF
8
8
  * GET /t/l/<message>/<position> a link: records the click, redirects to the recorded URL
9
9
  * GET /t/u/<message>/<position> the unsubscribe link: records the click, asks to confirm
10
- * POST /t/u/<message> the unsubscribe confirmation
10
+ * GET /t/u/<message> the List-Unsubscribe header's URL, opened: asks to confirm
11
+ * POST /t/u/<message> the unsubscribe confirmation, and the header's one-click
12
+ * unsubscribe (RFC 8058: a mail client's button posts here)
11
13
  * GET /t/c/<message>/<position> a link of an email sent before links carried their purpose
12
14
  * POST /t/d/<message> a push the device received (iOS's notification service
13
15
  * extension, a browser's service worker)
@@ -1 +1 @@
1
- {"version":3,"file":"tracking.d.cts","names":[],"sources":["../../src/reporting/tracking.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;iBA8BiB;;;;;EAKf;;EAEA;;EAEA;;EAEA,eACM;IAAQ;IAAmB;IAAgB;QAAiB;;;wBAgC9C,eACpB,SAAS,SACT,WAAW,kBACX,UAAS,kBACR,QAAQ;;;;;;wBAuEW,eACpB,WAAW,kBACX,gBACA,mBACA,cACC"}
1
+ {"version":3,"file":"tracking.d.cts","names":[],"sources":["../../src/reporting/tracking.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAgCiB;;;;;EAKf;;EAEA;;EAEA;;EAEA,eACM;IAAQ;IAAmB;IAAgB;QAAiB;;;wBAgC9C,eACpB,SAAS,SACT,WAAW,kBACX,UAAS,kBACR,QAAQ;;;;;;wBA8EW,eACpB,WAAW,kBACX,gBACA,mBACA,cACC"}
@@ -7,7 +7,9 @@ import { JourneyReporting } from "../store/index.mjs";
7
7
  * GET /t/o/<message> the open pixel: records the open, answers a 1×1 GIF
8
8
  * GET /t/l/<message>/<position> a link: records the click, redirects to the recorded URL
9
9
  * GET /t/u/<message>/<position> the unsubscribe link: records the click, asks to confirm
10
- * POST /t/u/<message> the unsubscribe confirmation
10
+ * GET /t/u/<message> the List-Unsubscribe header's URL, opened: asks to confirm
11
+ * POST /t/u/<message> the unsubscribe confirmation, and the header's one-click
12
+ * unsubscribe (RFC 8058: a mail client's button posts here)
11
13
  * GET /t/c/<message>/<position> a link of an email sent before links carried their purpose
12
14
  * POST /t/d/<message> a push the device received (iOS's notification service
13
15
  * extension, a browser's service worker)
@@ -1 +1 @@
1
- {"version":3,"file":"tracking.d.mts","names":[],"sources":["../../src/reporting/tracking.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;iBA8BiB;;;;;EAKf;;EAEA;;EAEA;;EAEA,eACM;IAAQ;IAAmB;IAAgB;QAAiB;;;wBAgC9C,eACpB,SAAS,SACT,WAAW,kBACX,UAAS,kBACR,QAAQ;;;;;;wBAuEW,eACpB,WAAW,kBACX,gBACA,mBACA,cACC"}
1
+ {"version":3,"file":"tracking.d.mts","names":[],"sources":["../../src/reporting/tracking.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAgCiB;;;;;EAKf;;EAEA;;EAEA;;EAEA,eACM;IAAQ;IAAmB;IAAgB;QAAiB;;;wBAgC9C,eACpB,SAAS,SACT,WAAW,kBACX,UAAS,kBACR,QAAQ;;;;;;wBA8EW,eACpB,WAAW,kBACX,gBACA,mBACA,cACC"}
@@ -33,9 +33,10 @@ async function handleTracking(request, reporting, options = {}) {
33
33
  if ((kind === "l" || kind === "u" || kind === "c") && position !== void 0 && read) {
34
34
  const target = await recordClick(reporting, id, Number(position), who, options);
35
35
  if (target === void 0) return page(options, "This link has expired.", 404);
36
- if (kind === "u" || kind === "c" && isUnsubscribeLink(target, options.legacyUnsubscribeUrl)) return page(options, `Unsubscribe from ${escape(options.product ?? "these")} emails?`, 200, `<form method="post" action="/t/u/${id}"><button type="submit">Unsubscribe</button></form>`);
36
+ if (kind === "u" || kind === "c" && isUnsubscribeLink(target, options.legacyUnsubscribeUrl)) return confirmUnsubscribe(options, id);
37
37
  return Response.redirect(target, 302);
38
38
  }
39
+ if (kind === "u" && position === void 0 && read) return confirmUnsubscribe(options, id);
39
40
  if (kind === "d" && who.method === "POST") {
40
41
  const known = await reporting.recordDeviceDelivered(id, Date.now());
41
42
  return new Response(null, { status: known ? 204 : 404 });
@@ -64,10 +65,14 @@ async function handleTracking(request, reporting, options = {}) {
64
65
  return new Response(null, { status: known ? 204 : 404 });
65
66
  }
66
67
  if (kind === "u" && who.method === "POST") {
67
- if (!await reporting.recordUnsubscribe(id, Date.now())) return page(options, "This link has expired.", 404);
68
+ if (!await reporting.recordUnsubscribe(id, Date.now())) return page(options, "You won't get these emails anymore.");
68
69
  return page(options, options.product === void 0 ? "You won't get these emails anymore." : `You won't get these emails from ${escape(options.product)} anymore.`);
69
70
  }
70
71
  }
72
+ /** The question an unsubscribe link, or the List-Unsubscribe URL opened in a browser, asks. */
73
+ function confirmUnsubscribe(options, id) {
74
+ return page(options, `Unsubscribe from ${escape(options.product ?? "these")} emails?`, 200, `<form method="post" action="/t/u/${id}"><button type="submit">Unsubscribe</button></form>`);
75
+ }
71
76
  /**
72
77
  * A journey push tapped open, as the app reports it (`JourneyClient.messageOpened`): an open on
73
78
  * the copy — only the recipient's own, and never a machine, since no proxy or scanner taps a
@@ -1 +1 @@
1
- {"version":3,"file":"tracking.mjs","names":[],"sources":["../../src/reporting/tracking.ts"],"sourcesContent":["import { isUnsubscribeLink } from '../email';\nimport type { JourneyReporting } from '../store';\nimport { BURST_WINDOW_MS, classifyClick, classifyOpen } from './classify';\n\n/**\n * The endpoints a journey email's tracking points at (see ./observer.ts), served by journeyWorker\n * without a bearer — recipients open them:\n *\n * GET /t/o/<message> the open pixel: records the open, answers a 1×1 GIF\n * GET /t/l/<message>/<position> a link: records the click, redirects to the recorded URL\n * GET /t/u/<message>/<position> the unsubscribe link: records the click, asks to confirm\n * POST /t/u/<message> the unsubscribe confirmation\n * GET /t/c/<message>/<position> a link of an email sent before links carried their purpose\n * POST /t/d/<message> a push the device received (iOS's notification service\n * extension, a browser's service worker)\n * POST /t/p/<message> a push opened, reported by a browser's service worker\n *\n * Every open and click is recorded with its verdict (./classify.ts), and the message keeps its\n * first open and click of each kind. The redirect goes only to the URL recorded for that position,\n * never to one the request carries, so the endpoint cannot bounce visitors anywhere else. An\n * email's unsubscribe link is a tracked link too, and which link it is was fixed when the email was\n * sent (its route): following it shows a confirmation page, and only the confirmation — a POST,\n * which no link scanner sends — counts. Changing a template's or the config's unsubscribe URL, or\n * removing the template, changes nothing for emails already sent.\n *\n * Emails sent before that carry `/t/c/` links, which say nothing of their purpose: one is the\n * unsubscribe link when its recorded URL is `legacyUnsubscribeUrl` (the global unsubscribe URL\n * those emails were all sent with), as it was then.\n */\n\nexport interface TrackingOptions {\n /**\n * The unsubscribe URL emails sent with `/t/c/` links carried (the config's `email.unsubscribeUrl`\n * by default): following such a link to it asks for confirmation instead of redirecting.\n */\n legacyUnsubscribeUrl?: string | undefined;\n /** The name the confirmation pages show (the config's `title`). */\n product?: string | undefined;\n /** `email.tracking.quickClickMs`: how soon after delivery an open or click is a weak machine signal. */\n quickMs?: number | undefined;\n /** A browser reported a push tapped (`/t/p`): the host records it as an event too (`webPush.openedEvent`). */\n pushOpened?:\n | ((open: { messageId: string; userId: string; at: number }) => Promise<void>)\n | undefined;\n}\n\nconst GIF = Uint8Array.from(\n atob('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'),\n (char) => char.charCodeAt(0)\n);\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\ninterface Requester {\n method: string;\n userAgent: string | null;\n asn: number | null;\n organization: string | null;\n country: string | null;\n}\n\nfunction requester(request: Request): Requester {\n const cf = (\n request as Request & { cf?: { asn?: number; asOrganization?: string; country?: string } }\n ).cf;\n return {\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n asn: cf?.asn ?? null,\n organization: cf?.asOrganization ?? null,\n country: cf?.country ?? null,\n };\n}\n\n/** A `/t/...` request's response, or undefined when the path is not a tracking route. */\nexport async function handleTracking(\n request: Request,\n reporting: JourneyReporting,\n options: TrackingOptions = {}\n): Promise<Response | undefined> {\n const url = new URL(request.url);\n const path = url.pathname.split('/');\n const root = path.at(1);\n const kind = path.at(2);\n const id = path.at(3);\n const position = path.at(4);\n if (root !== 't' || id === undefined || !UUID.test(id)) return undefined;\n const who = requester(request);\n const read = who.method === 'GET' || who.method === 'HEAD';\n if (kind === 'o' && read) {\n await recordOpen(reporting, id, who, options);\n return new Response(who.method === 'HEAD' ? null : GIF, {\n headers: { 'content-type': 'image/gif', 'cache-control': 'no-store, max-age=0' },\n });\n }\n if ((kind === 'l' || kind === 'u' || kind === 'c') && position !== undefined && read) {\n const target = await recordClick(reporting, id, Number(position), who, options);\n if (target === undefined) return page(options, 'This link has expired.', 404);\n const unsubscribe =\n kind === 'u' || (kind === 'c' && isUnsubscribeLink(target, options.legacyUnsubscribeUrl));\n if (unsubscribe) {\n return page(\n options,\n `Unsubscribe from ${escape(options.product ?? 'these')} emails?`,\n 200,\n `<form method=\"post\" action=\"/t/u/${id}\"><button type=\"submit\">Unsubscribe</button></form>`\n );\n }\n return Response.redirect(target, 302);\n }\n if (kind === 'd' && who.method === 'POST') {\n const known = await reporting.recordDeviceDelivered(id, Date.now());\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'p' && who.method === 'POST') {\n // A notification tap: a person, as the message id is what proves it is theirs\n const at = Date.now();\n let userId: string | undefined;\n const known = await reporting.recordOpen(id, (message) => {\n userId = message.userId;\n return { at, machine: false, reason: null, ...network(who) };\n });\n if (known && userId !== undefined && options.pushOpened !== undefined) {\n try {\n await options.pushOpened({ messageId: id, userId, at });\n } catch (error) {\n // The open is recorded; the event is the host's extra, never a reason to fail the beacon\n console.error(`[workflow] recording push open ${id} as an event failed`, error);\n }\n }\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'u' && who.method === 'POST') {\n const known = await reporting.recordUnsubscribe(id, Date.now());\n if (!known) return page(options, 'This link has expired.', 404);\n return page(\n options,\n options.product === undefined\n ? \"You won't get these emails anymore.\"\n : `You won't get these emails from ${escape(options.product)} anymore.`\n );\n }\n return undefined;\n}\n\n/**\n * A journey push tapped open, as the app reports it (`JourneyClient.messageOpened`): an open on\n * the copy — only the recipient's own, and never a machine, since no proxy or scanner taps a\n * notification. False when the message is unknown or not the user's.\n */\nexport async function recordPushOpen(\n reporting: JourneyReporting,\n userId: string,\n messageId: string,\n at = Date.now()\n): Promise<boolean> {\n if (!UUID.test(messageId)) return false;\n let recorded = false;\n await reporting.recordOpen(messageId, (message) => {\n if (message.userId !== userId) return undefined;\n recorded = true;\n return {\n at,\n machine: false,\n reason: null,\n userAgent: null,\n asn: null,\n organization: null,\n country: null,\n };\n });\n return recorded;\n}\n\nasync function recordOpen(\n reporting: JourneyReporting,\n id: string,\n who: Requester,\n options: TrackingOptions\n) {\n const at = Date.now();\n await reporting.recordOpen(id, (message) => ({\n at,\n ...classifyOpen(\n { ...who, sinceDeliveredMs: at - message.deliveredAt },\n { quickMs: options.quickMs }\n ),\n ...network(who),\n }));\n}\n\nconst network = (who: Requester) => ({\n userAgent: who.userAgent,\n asn: who.asn,\n organization: who.organization,\n country: who.country,\n});\n\n/** Records the click and answers the URL to send the visitor to, or undefined for an unknown link. */\nasync function recordClick(\n reporting: JourneyReporting,\n id: string,\n position: number,\n who: Requester,\n options: TrackingOptions\n): Promise<string | undefined> {\n if (!Number.isInteger(position) || position < 0) return undefined;\n const at = Date.now();\n const recorded = await reporting.recordClick(id, position, at, BURST_WINDOW_MS, (link) => {\n const linksInBurst = new Set([position, ...link.recent.map((click) => click.position)]).size;\n const verdict = classifyClick(\n { ...who, sinceDeliveredMs: at - link.deliveredAt, linksInBurst, linkCount: link.linkCount },\n { quickMs: options.quickMs }\n );\n return {\n engagement: { at, ...verdict, ...network(who) },\n backdate: verdict.reason === 'link_burst' || verdict.reason === 'quick+link_pair',\n };\n });\n return recorded?.url;\n}\n\nfunction escape(text: string): string {\n return text.replace(/[&<>\"']/g, (c) => `&#${c.charCodeAt(0)};`);\n}\n\nfunction page(options: TrackingOptions, message: string, status = 200, extra = ''): Response {\n const title = escape(options.product ?? 'Email');\n const html = `<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>${title}</title><style>body{font:16px system-ui;margin:0;display:grid;place-items:center;min-height:100vh;background:#fafafa;color:#18181b}main{text-align:center;padding:24px}button{font:inherit;padding:10px 20px;border-radius:9999px;border:0;background:#18181b;color:#fff;cursor:pointer}</style></head><body><main><p>${message}</p>${extra}</main></body></html>`;\n return new Response(html, { status, headers: { 'content-type': 'text/html; charset=utf-8' } });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"tracking.mjs","names":[],"sources":["../../src/reporting/tracking.ts"],"sourcesContent":["import { isUnsubscribeLink } from '../email';\nimport type { JourneyReporting } from '../store';\nimport { BURST_WINDOW_MS, classifyClick, classifyOpen } from './classify';\n\n/**\n * The endpoints a journey email's tracking points at (see ./observer.ts), served by journeyWorker\n * without a bearer — recipients open them:\n *\n * GET /t/o/<message> the open pixel: records the open, answers a 1×1 GIF\n * GET /t/l/<message>/<position> a link: records the click, redirects to the recorded URL\n * GET /t/u/<message>/<position> the unsubscribe link: records the click, asks to confirm\n * GET /t/u/<message> the List-Unsubscribe header's URL, opened: asks to confirm\n * POST /t/u/<message> the unsubscribe confirmation, and the header's one-click\n * unsubscribe (RFC 8058: a mail client's button posts here)\n * GET /t/c/<message>/<position> a link of an email sent before links carried their purpose\n * POST /t/d/<message> a push the device received (iOS's notification service\n * extension, a browser's service worker)\n * POST /t/p/<message> a push opened, reported by a browser's service worker\n *\n * Every open and click is recorded with its verdict (./classify.ts), and the message keeps its\n * first open and click of each kind. The redirect goes only to the URL recorded for that position,\n * never to one the request carries, so the endpoint cannot bounce visitors anywhere else. An\n * email's unsubscribe link is a tracked link too, and which link it is was fixed when the email was\n * sent (its route): following it shows a confirmation page, and only the confirmation — a POST,\n * which no link scanner sends — counts. Changing a template's or the config's unsubscribe URL, or\n * removing the template, changes nothing for emails already sent.\n *\n * Emails sent before that carry `/t/c/` links, which say nothing of their purpose: one is the\n * unsubscribe link when its recorded URL is `legacyUnsubscribeUrl` (the global unsubscribe URL\n * those emails were all sent with), as it was then.\n */\n\nexport interface TrackingOptions {\n /**\n * The unsubscribe URL emails sent with `/t/c/` links carried (the config's `email.unsubscribeUrl`\n * by default): following such a link to it asks for confirmation instead of redirecting.\n */\n legacyUnsubscribeUrl?: string | undefined;\n /** The name the confirmation pages show (the config's `title`). */\n product?: string | undefined;\n /** `email.tracking.quickClickMs`: how soon after delivery an open or click is a weak machine signal. */\n quickMs?: number | undefined;\n /** A browser reported a push tapped (`/t/p`): the host records it as an event too (`webPush.openedEvent`). */\n pushOpened?:\n | ((open: { messageId: string; userId: string; at: number }) => Promise<void>)\n | undefined;\n}\n\nconst GIF = Uint8Array.from(\n atob('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'),\n (char) => char.charCodeAt(0)\n);\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\ninterface Requester {\n method: string;\n userAgent: string | null;\n asn: number | null;\n organization: string | null;\n country: string | null;\n}\n\nfunction requester(request: Request): Requester {\n const cf = (\n request as Request & { cf?: { asn?: number; asOrganization?: string; country?: string } }\n ).cf;\n return {\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n asn: cf?.asn ?? null,\n organization: cf?.asOrganization ?? null,\n country: cf?.country ?? null,\n };\n}\n\n/** A `/t/...` request's response, or undefined when the path is not a tracking route. */\nexport async function handleTracking(\n request: Request,\n reporting: JourneyReporting,\n options: TrackingOptions = {}\n): Promise<Response | undefined> {\n const url = new URL(request.url);\n const path = url.pathname.split('/');\n const root = path.at(1);\n const kind = path.at(2);\n const id = path.at(3);\n const position = path.at(4);\n if (root !== 't' || id === undefined || !UUID.test(id)) return undefined;\n const who = requester(request);\n const read = who.method === 'GET' || who.method === 'HEAD';\n if (kind === 'o' && read) {\n await recordOpen(reporting, id, who, options);\n return new Response(who.method === 'HEAD' ? null : GIF, {\n headers: { 'content-type': 'image/gif', 'cache-control': 'no-store, max-age=0' },\n });\n }\n if ((kind === 'l' || kind === 'u' || kind === 'c') && position !== undefined && read) {\n const target = await recordClick(reporting, id, Number(position), who, options);\n if (target === undefined) return page(options, 'This link has expired.', 404);\n const unsubscribe =\n kind === 'u' || (kind === 'c' && isUnsubscribeLink(target, options.legacyUnsubscribeUrl));\n if (unsubscribe) return confirmUnsubscribe(options, id);\n return Response.redirect(target, 302);\n }\n // The List-Unsubscribe URL opened in a browser (a client without one-click): the same question\n if (kind === 'u' && position === undefined && read) return confirmUnsubscribe(options, id);\n if (kind === 'd' && who.method === 'POST') {\n const known = await reporting.recordDeviceDelivered(id, Date.now());\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'p' && who.method === 'POST') {\n // A notification tap: a person, as the message id is what proves it is theirs\n const at = Date.now();\n let userId: string | undefined;\n const known = await reporting.recordOpen(id, (message) => {\n userId = message.userId;\n return { at, machine: false, reason: null, ...network(who) };\n });\n if (known && userId !== undefined && options.pushOpened !== undefined) {\n try {\n await options.pushOpened({ messageId: id, userId, at });\n } catch (error) {\n // The open is recorded; the event is the host's extra, never a reason to fail the beacon\n console.error(`[workflow] recording push open ${id} as an event failed`, error);\n }\n }\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'u' && who.method === 'POST') {\n const known = await reporting.recordUnsubscribe(id, Date.now());\n // A mail client's one-click POST must succeed: for a copy no longer on record (its user\n // erased) there is nothing left to send to, so the answer is the same\n if (!known) return page(options, \"You won't get these emails anymore.\");\n return page(\n options,\n options.product === undefined\n ? \"You won't get these emails anymore.\"\n : `You won't get these emails from ${escape(options.product)} anymore.`\n );\n }\n return undefined;\n}\n\n/** The question an unsubscribe link, or the List-Unsubscribe URL opened in a browser, asks. */\nfunction confirmUnsubscribe(options: TrackingOptions, id: string): Response {\n return page(\n options,\n `Unsubscribe from ${escape(options.product ?? 'these')} emails?`,\n 200,\n `<form method=\"post\" action=\"/t/u/${id}\"><button type=\"submit\">Unsubscribe</button></form>`\n );\n}\n\n/**\n * A journey push tapped open, as the app reports it (`JourneyClient.messageOpened`): an open on\n * the copy — only the recipient's own, and never a machine, since no proxy or scanner taps a\n * notification. False when the message is unknown or not the user's.\n */\nexport async function recordPushOpen(\n reporting: JourneyReporting,\n userId: string,\n messageId: string,\n at = Date.now()\n): Promise<boolean> {\n if (!UUID.test(messageId)) return false;\n let recorded = false;\n await reporting.recordOpen(messageId, (message) => {\n if (message.userId !== userId) return undefined;\n recorded = true;\n return {\n at,\n machine: false,\n reason: null,\n userAgent: null,\n asn: null,\n organization: null,\n country: null,\n };\n });\n return recorded;\n}\n\nasync function recordOpen(\n reporting: JourneyReporting,\n id: string,\n who: Requester,\n options: TrackingOptions\n) {\n const at = Date.now();\n await reporting.recordOpen(id, (message) => ({\n at,\n ...classifyOpen(\n { ...who, sinceDeliveredMs: at - message.deliveredAt },\n { quickMs: options.quickMs }\n ),\n ...network(who),\n }));\n}\n\nconst network = (who: Requester) => ({\n userAgent: who.userAgent,\n asn: who.asn,\n organization: who.organization,\n country: who.country,\n});\n\n/** Records the click and answers the URL to send the visitor to, or undefined for an unknown link. */\nasync function recordClick(\n reporting: JourneyReporting,\n id: string,\n position: number,\n who: Requester,\n options: TrackingOptions\n): Promise<string | undefined> {\n if (!Number.isInteger(position) || position < 0) return undefined;\n const at = Date.now();\n const recorded = await reporting.recordClick(id, position, at, BURST_WINDOW_MS, (link) => {\n const linksInBurst = new Set([position, ...link.recent.map((click) => click.position)]).size;\n const verdict = classifyClick(\n { ...who, sinceDeliveredMs: at - link.deliveredAt, linksInBurst, linkCount: link.linkCount },\n { quickMs: options.quickMs }\n );\n return {\n engagement: { at, ...verdict, ...network(who) },\n backdate: verdict.reason === 'link_burst' || verdict.reason === 'quick+link_pair',\n };\n });\n return recorded?.url;\n}\n\nfunction escape(text: string): string {\n return text.replace(/[&<>\"']/g, (c) => `&#${c.charCodeAt(0)};`);\n}\n\nfunction page(options: TrackingOptions, message: string, status = 200, extra = ''): Response {\n const title = escape(options.product ?? 'Email');\n const html = `<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>${title}</title><style>body{font:16px system-ui;margin:0;display:grid;place-items:center;min-height:100vh;background:#fafafa;color:#18181b}main{text-align:center;padding:24px}button{font:inherit;padding:10px 20px;border-radius:9999px;border:0;background:#18181b;color:#fff;cursor:pointer}</style></head><body><main><p>${message}</p>${extra}</main></body></html>`;\n return new Response(html, { status, headers: { 'content-type': 'text/html; charset=utf-8' } });\n}\n"],"mappings":"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"}
@@ -42,7 +42,8 @@ function cloudflareEmailTransport(options) {
42
42
  to: message.to,
43
43
  ...message.replyTo === void 0 ? {} : { reply_to: require_email.mimeAddress(message.replyTo) },
44
44
  subject: message.subject,
45
- html: message.html
45
+ html: message.html,
46
+ ...message.headers === void 0 ? {} : { headers: message.headers }
46
47
  })
47
48
  });
48
49
  let body = {};
@@ -1 +1 @@
1
- {"version":3,"file":"cloudflare-email.cjs","names":["isRetryableStatus","mimeAddress"],"sources":["../../src/transports/cloudflare-email.ts"],"sourcesContent":["import type { EmailBindingLike } from '../cloudflare/bindings';\nimport { mimeAddress } from '../email';\nimport type { EmailMessage, EmailTransport } from '../email-sender';\nimport { isRetryableStatus } from '../engine/retry';\n\n/**\n * `@shware/workflow/transports/cloudflare-email` — Cloudflare Email Service, through the\n * Worker's `send_email` binding or, from anywhere else (a Lambda), its REST API. Both take the\n * same finished email; only the call and the way each reports a failure differ.\n */\n\nexport type CloudflareEmailTransportOptions =\n | {\n /** The Worker's `send_email` binding. */\n binding: EmailBindingLike;\n accountId?: never;\n apiToken?: never;\n }\n | {\n binding?: never;\n accountId: string;\n /** An API token allowed to send email for the account. */\n apiToken: string;\n /** Defaults to `https://api.cloudflare.com/client/v4`. */\n endpoint?: string;\n fetch?: typeof fetch;\n };\n\n/** The REST API refused the email: `errors` are Cloudflare's `{ code, message }` pairs. */\nexport class CloudflareEmailError extends Error {\n override readonly name = 'CloudflareEmailError';\n /** A refused request (bad token, invalid schema) fails the same way every time. */\n readonly retryable: boolean;\n constructor(\n readonly status: number,\n readonly errors: readonly { code?: number; message?: string }[]\n ) {\n const said = errors.map((e) => `${e.code ?? '?'} ${e.message ?? ''}`.trim()).join('; ');\n super(`Cloudflare Email send failed (${status})${said === '' ? '' : `: ${said}`}`);\n // A 200 with `success: false` says nothing about retrying: treat it as transient\n this.retryable = status < 400 || isRetryableStatus(status);\n }\n}\n\nexport function cloudflareEmailTransport(options: CloudflareEmailTransportOptions): EmailTransport {\n if (options.binding !== undefined) {\n const { binding } = options;\n return {\n async send(message) {\n // The binding takes the key and drops the duplicate a retry or replay produces\n const result: unknown = await binding.send(message);\n const id = (result as { messageId?: unknown } | null | undefined)?.messageId;\n return typeof id === 'string' ? { providerMessageId: id } : {};\n },\n };\n }\n // Checked at runtime too: a JS caller can pass neither\n const given = options as { accountId?: unknown; apiToken?: unknown };\n if (typeof given.accountId !== 'string' || typeof given.apiToken !== 'string') {\n throw new TypeError('cloudflareEmailTransport needs { binding } or { accountId, apiToken }');\n }\n const { accountId, apiToken } = options;\n const endpoint = (options.endpoint ?? 'https://api.cloudflare.com/client/v4').replace(/\\/$/, '');\n const fetchImpl = options.fetch ?? fetch;\n return {\n async send(message: EmailMessage) {\n const response = await fetchImpl(\n `${endpoint}/accounts/${encodeURIComponent(accountId)}/email/sending/send`,\n {\n method: 'POST',\n headers: {\n authorization: `Bearer ${apiToken}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify({\n from: mimeAddress(message.from),\n to: message.to,\n ...(message.replyTo === undefined ? {} : { reply_to: mimeAddress(message.replyTo) }),\n subject: message.subject,\n html: message.html,\n }),\n }\n );\n let body: { success?: unknown; errors?: unknown; result?: { message_id?: unknown } } = {};\n try {\n body = (await response.json()) as typeof body;\n } catch {\n body = {};\n }\n if (!response.ok || body.success !== true) {\n throw new CloudflareEmailError(\n response.status,\n Array.isArray(body.errors) ? (body.errors as { code?: number; message?: string }[]) : []\n );\n }\n // No server-side de-duplication is documented for the REST API: a retry after an accepted\n // send can deliver twice (at least once, like SES)\n const id = body.result?.message_id;\n return typeof id === 'string' ? { providerMessageId: id } : {};\n },\n };\n}\n"],"mappings":";;;;;AA6BA,IAAa,uBAAb,cAA0C,MAAM;CAKnC;CACA;CALX,OAAyB;;CAEzB;CACA,YACE,QACA,QACA;EACA,MAAM,OAAO,OAAO,KAAK,MAAM,GAAG,EAAE,QAAQ,IAAI,GAAG,EAAE,WAAW,KAAK,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI;EACtF,MAAM,iCAAiC,OAAO,GAAG,SAAS,KAAK,KAAK,KAAK,QAAQ;EAJxE,KAAA,SAAA;EACA,KAAA,SAAA;EAKT,KAAK,YAAY,SAAS,OAAOA,qBAAAA,kBAAkB,MAAM;CAC3D;AACF;AAEA,SAAgB,yBAAyB,SAA0D;CACjG,IAAI,QAAQ,YAAY,KAAA,GAAW;EACjC,MAAM,EAAE,YAAY;EACpB,OAAO,EACL,MAAM,KAAK,SAAS;GAGlB,MAAM,MAAM,MADkB,QAAQ,KAAK,OAAO,EAAA,EACiB;GACnE,OAAO,OAAO,OAAO,WAAW,EAAE,mBAAmB,GAAG,IAAI,CAAC;EAC/D,EACF;CACF;CAEA,MAAM,QAAQ;CACd,IAAI,OAAO,MAAM,cAAc,YAAY,OAAO,MAAM,aAAa,UACnE,MAAM,IAAI,UAAU,uEAAuE;CAE7F,MAAM,EAAE,WAAW,aAAa;CAChC,MAAM,YAAY,QAAQ,YAAY,uCAAA,CAAwC,QAAQ,OAAO,EAAE;CAC/F,MAAM,YAAY,QAAQ,SAAS;CACnC,OAAO,EACL,MAAM,KAAK,SAAuB;EAChC,MAAM,WAAW,MAAM,UACrB,GAAG,SAAS,YAAY,mBAAmB,SAAS,EAAE,sBACtD;GACE,QAAQ;GACR,SAAS;IACP,eAAe,UAAU;IACzB,gBAAgB;GAClB;GACA,MAAM,KAAK,UAAU;IACnB,MAAMC,cAAAA,YAAY,QAAQ,IAAI;IAC9B,IAAI,QAAQ;IACZ,GAAI,QAAQ,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,UAAUA,cAAAA,YAAY,QAAQ,OAAO,EAAE;IAClF,SAAS,QAAQ;IACjB,MAAM,QAAQ;GAChB,CAAC;EACH,CACF;EACA,IAAI,OAAmF,CAAC;EACxF,IAAI;GACF,OAAQ,MAAM,SAAS,KAAK;EAC9B,QAAQ;GACN,OAAO,CAAC;EACV;EACA,IAAI,CAAC,SAAS,MAAM,KAAK,YAAY,MACnC,MAAM,IAAI,qBACR,SAAS,QACT,MAAM,QAAQ,KAAK,MAAM,IAAK,KAAK,SAAmD,CAAC,CACzF;EAIF,MAAM,KAAK,KAAK,QAAQ;EACxB,OAAO,OAAO,OAAO,WAAW,EAAE,mBAAmB,GAAG,IAAI,CAAC;CAC/D,EACF;AACF"}
1
+ {"version":3,"file":"cloudflare-email.cjs","names":["isRetryableStatus","mimeAddress"],"sources":["../../src/transports/cloudflare-email.ts"],"sourcesContent":["import type { EmailBindingLike } from '../cloudflare/bindings';\nimport { mimeAddress } from '../email';\nimport type { EmailMessage, EmailTransport } from '../email-sender';\nimport { isRetryableStatus } from '../engine/retry';\n\n/**\n * `@shware/workflow/transports/cloudflare-email` — Cloudflare Email Service, through the\n * Worker's `send_email` binding or, from anywhere else (a Lambda), its REST API. Both take the\n * same finished email; only the call and the way each reports a failure differ.\n */\n\nexport type CloudflareEmailTransportOptions =\n | {\n /** The Worker's `send_email` binding. */\n binding: EmailBindingLike;\n accountId?: never;\n apiToken?: never;\n }\n | {\n binding?: never;\n accountId: string;\n /** An API token allowed to send email for the account. */\n apiToken: string;\n /** Defaults to `https://api.cloudflare.com/client/v4`. */\n endpoint?: string;\n fetch?: typeof fetch;\n };\n\n/** The REST API refused the email: `errors` are Cloudflare's `{ code, message }` pairs. */\nexport class CloudflareEmailError extends Error {\n override readonly name = 'CloudflareEmailError';\n /** A refused request (bad token, invalid schema) fails the same way every time. */\n readonly retryable: boolean;\n constructor(\n readonly status: number,\n readonly errors: readonly { code?: number; message?: string }[]\n ) {\n const said = errors.map((e) => `${e.code ?? '?'} ${e.message ?? ''}`.trim()).join('; ');\n super(`Cloudflare Email send failed (${status})${said === '' ? '' : `: ${said}`}`);\n // A 200 with `success: false` says nothing about retrying: treat it as transient\n this.retryable = status < 400 || isRetryableStatus(status);\n }\n}\n\nexport function cloudflareEmailTransport(options: CloudflareEmailTransportOptions): EmailTransport {\n if (options.binding !== undefined) {\n const { binding } = options;\n return {\n async send(message) {\n // The binding takes the key and drops the duplicate a retry or replay produces\n const result: unknown = await binding.send(message);\n const id = (result as { messageId?: unknown } | null | undefined)?.messageId;\n return typeof id === 'string' ? { providerMessageId: id } : {};\n },\n };\n }\n // Checked at runtime too: a JS caller can pass neither\n const given = options as { accountId?: unknown; apiToken?: unknown };\n if (typeof given.accountId !== 'string' || typeof given.apiToken !== 'string') {\n throw new TypeError('cloudflareEmailTransport needs { binding } or { accountId, apiToken }');\n }\n const { accountId, apiToken } = options;\n const endpoint = (options.endpoint ?? 'https://api.cloudflare.com/client/v4').replace(/\\/$/, '');\n const fetchImpl = options.fetch ?? fetch;\n return {\n async send(message: EmailMessage) {\n const response = await fetchImpl(\n `${endpoint}/accounts/${encodeURIComponent(accountId)}/email/sending/send`,\n {\n method: 'POST',\n headers: {\n authorization: `Bearer ${apiToken}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify({\n from: mimeAddress(message.from),\n to: message.to,\n ...(message.replyTo === undefined ? {} : { reply_to: mimeAddress(message.replyTo) }),\n subject: message.subject,\n html: message.html,\n ...(message.headers === undefined ? {} : { headers: message.headers }),\n }),\n }\n );\n let body: { success?: unknown; errors?: unknown; result?: { message_id?: unknown } } = {};\n try {\n body = (await response.json()) as typeof body;\n } catch {\n body = {};\n }\n if (!response.ok || body.success !== true) {\n throw new CloudflareEmailError(\n response.status,\n Array.isArray(body.errors) ? (body.errors as { code?: number; message?: string }[]) : []\n );\n }\n // No server-side de-duplication is documented for the REST API: a retry after an accepted\n // send can deliver twice (at least once, like SES)\n const id = body.result?.message_id;\n return typeof id === 'string' ? { providerMessageId: id } : {};\n },\n };\n}\n"],"mappings":";;;;;AA6BA,IAAa,uBAAb,cAA0C,MAAM;CAKnC;CACA;CALX,OAAyB;;CAEzB;CACA,YACE,QACA,QACA;EACA,MAAM,OAAO,OAAO,KAAK,MAAM,GAAG,EAAE,QAAQ,IAAI,GAAG,EAAE,WAAW,KAAK,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI;EACtF,MAAM,iCAAiC,OAAO,GAAG,SAAS,KAAK,KAAK,KAAK,QAAQ;EAJxE,KAAA,SAAA;EACA,KAAA,SAAA;EAKT,KAAK,YAAY,SAAS,OAAOA,qBAAAA,kBAAkB,MAAM;CAC3D;AACF;AAEA,SAAgB,yBAAyB,SAA0D;CACjG,IAAI,QAAQ,YAAY,KAAA,GAAW;EACjC,MAAM,EAAE,YAAY;EACpB,OAAO,EACL,MAAM,KAAK,SAAS;GAGlB,MAAM,MAAM,MADkB,QAAQ,KAAK,OAAO,EAAA,EACiB;GACnE,OAAO,OAAO,OAAO,WAAW,EAAE,mBAAmB,GAAG,IAAI,CAAC;EAC/D,EACF;CACF;CAEA,MAAM,QAAQ;CACd,IAAI,OAAO,MAAM,cAAc,YAAY,OAAO,MAAM,aAAa,UACnE,MAAM,IAAI,UAAU,uEAAuE;CAE7F,MAAM,EAAE,WAAW,aAAa;CAChC,MAAM,YAAY,QAAQ,YAAY,uCAAA,CAAwC,QAAQ,OAAO,EAAE;CAC/F,MAAM,YAAY,QAAQ,SAAS;CACnC,OAAO,EACL,MAAM,KAAK,SAAuB;EAChC,MAAM,WAAW,MAAM,UACrB,GAAG,SAAS,YAAY,mBAAmB,SAAS,EAAE,sBACtD;GACE,QAAQ;GACR,SAAS;IACP,eAAe,UAAU;IACzB,gBAAgB;GAClB;GACA,MAAM,KAAK,UAAU;IACnB,MAAMC,cAAAA,YAAY,QAAQ,IAAI;IAC9B,IAAI,QAAQ;IACZ,GAAI,QAAQ,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,UAAUA,cAAAA,YAAY,QAAQ,OAAO,EAAE;IAClF,SAAS,QAAQ;IACjB,MAAM,QAAQ;IACd,GAAI,QAAQ,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,SAAS,QAAQ,QAAQ;GACtE,CAAC;EACH,CACF;EACA,IAAI,OAAmF,CAAC;EACxF,IAAI;GACF,OAAQ,MAAM,SAAS,KAAK;EAC9B,QAAQ;GACN,OAAO,CAAC;EACV;EACA,IAAI,CAAC,SAAS,MAAM,KAAK,YAAY,MACnC,MAAM,IAAI,qBACR,SAAS,QACT,MAAM,QAAQ,KAAK,MAAM,IAAK,KAAK,SAAmD,CAAC,CACzF;EAIF,MAAM,KAAK,KAAK,QAAQ;EACxB,OAAO,OAAO,OAAO,WAAW,EAAE,mBAAmB,GAAG,IAAI,CAAC;CAC/D,EACF;AACF"}
@@ -41,7 +41,8 @@ function cloudflareEmailTransport(options) {
41
41
  to: message.to,
42
42
  ...message.replyTo === void 0 ? {} : { reply_to: mimeAddress(message.replyTo) },
43
43
  subject: message.subject,
44
- html: message.html
44
+ html: message.html,
45
+ ...message.headers === void 0 ? {} : { headers: message.headers }
45
46
  })
46
47
  });
47
48
  let body = {};
@@ -1 +1 @@
1
- {"version":3,"file":"cloudflare-email.mjs","names":[],"sources":["../../src/transports/cloudflare-email.ts"],"sourcesContent":["import type { EmailBindingLike } from '../cloudflare/bindings';\nimport { mimeAddress } from '../email';\nimport type { EmailMessage, EmailTransport } from '../email-sender';\nimport { isRetryableStatus } from '../engine/retry';\n\n/**\n * `@shware/workflow/transports/cloudflare-email` — Cloudflare Email Service, through the\n * Worker's `send_email` binding or, from anywhere else (a Lambda), its REST API. Both take the\n * same finished email; only the call and the way each reports a failure differ.\n */\n\nexport type CloudflareEmailTransportOptions =\n | {\n /** The Worker's `send_email` binding. */\n binding: EmailBindingLike;\n accountId?: never;\n apiToken?: never;\n }\n | {\n binding?: never;\n accountId: string;\n /** An API token allowed to send email for the account. */\n apiToken: string;\n /** Defaults to `https://api.cloudflare.com/client/v4`. */\n endpoint?: string;\n fetch?: typeof fetch;\n };\n\n/** The REST API refused the email: `errors` are Cloudflare's `{ code, message }` pairs. */\nexport class CloudflareEmailError extends Error {\n override readonly name = 'CloudflareEmailError';\n /** A refused request (bad token, invalid schema) fails the same way every time. */\n readonly retryable: boolean;\n constructor(\n readonly status: number,\n readonly errors: readonly { code?: number; message?: string }[]\n ) {\n const said = errors.map((e) => `${e.code ?? '?'} ${e.message ?? ''}`.trim()).join('; ');\n super(`Cloudflare Email send failed (${status})${said === '' ? '' : `: ${said}`}`);\n // A 200 with `success: false` says nothing about retrying: treat it as transient\n this.retryable = status < 400 || isRetryableStatus(status);\n }\n}\n\nexport function cloudflareEmailTransport(options: CloudflareEmailTransportOptions): EmailTransport {\n if (options.binding !== undefined) {\n const { binding } = options;\n return {\n async send(message) {\n // The binding takes the key and drops the duplicate a retry or replay produces\n const result: unknown = await binding.send(message);\n const id = (result as { messageId?: unknown } | null | undefined)?.messageId;\n return typeof id === 'string' ? { providerMessageId: id } : {};\n },\n };\n }\n // Checked at runtime too: a JS caller can pass neither\n const given = options as { accountId?: unknown; apiToken?: unknown };\n if (typeof given.accountId !== 'string' || typeof given.apiToken !== 'string') {\n throw new TypeError('cloudflareEmailTransport needs { binding } or { accountId, apiToken }');\n }\n const { accountId, apiToken } = options;\n const endpoint = (options.endpoint ?? 'https://api.cloudflare.com/client/v4').replace(/\\/$/, '');\n const fetchImpl = options.fetch ?? fetch;\n return {\n async send(message: EmailMessage) {\n const response = await fetchImpl(\n `${endpoint}/accounts/${encodeURIComponent(accountId)}/email/sending/send`,\n {\n method: 'POST',\n headers: {\n authorization: `Bearer ${apiToken}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify({\n from: mimeAddress(message.from),\n to: message.to,\n ...(message.replyTo === undefined ? {} : { reply_to: mimeAddress(message.replyTo) }),\n subject: message.subject,\n html: message.html,\n }),\n }\n );\n let body: { success?: unknown; errors?: unknown; result?: { message_id?: unknown } } = {};\n try {\n body = (await response.json()) as typeof body;\n } catch {\n body = {};\n }\n if (!response.ok || body.success !== true) {\n throw new CloudflareEmailError(\n response.status,\n Array.isArray(body.errors) ? (body.errors as { code?: number; message?: string }[]) : []\n );\n }\n // No server-side de-duplication is documented for the REST API: a retry after an accepted\n // send can deliver twice (at least once, like SES)\n const id = body.result?.message_id;\n return typeof id === 'string' ? { providerMessageId: id } : {};\n },\n };\n}\n"],"mappings":";;;;AA6BA,IAAa,uBAAb,cAA0C,MAAM;CAKnC;CACA;CALX,OAAyB;;CAEzB;CACA,YACE,QACA,QACA;EACA,MAAM,OAAO,OAAO,KAAK,MAAM,GAAG,EAAE,QAAQ,IAAI,GAAG,EAAE,WAAW,KAAK,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI;EACtF,MAAM,iCAAiC,OAAO,GAAG,SAAS,KAAK,KAAK,KAAK,QAAQ;EAJxE,KAAA,SAAA;EACA,KAAA,SAAA;EAKT,KAAK,YAAY,SAAS,OAAO,kBAAkB,MAAM;CAC3D;AACF;AAEA,SAAgB,yBAAyB,SAA0D;CACjG,IAAI,QAAQ,YAAY,KAAA,GAAW;EACjC,MAAM,EAAE,YAAY;EACpB,OAAO,EACL,MAAM,KAAK,SAAS;GAGlB,MAAM,MAAM,MADkB,QAAQ,KAAK,OAAO,EAAA,EACiB;GACnE,OAAO,OAAO,OAAO,WAAW,EAAE,mBAAmB,GAAG,IAAI,CAAC;EAC/D,EACF;CACF;CAEA,MAAM,QAAQ;CACd,IAAI,OAAO,MAAM,cAAc,YAAY,OAAO,MAAM,aAAa,UACnE,MAAM,IAAI,UAAU,uEAAuE;CAE7F,MAAM,EAAE,WAAW,aAAa;CAChC,MAAM,YAAY,QAAQ,YAAY,uCAAA,CAAwC,QAAQ,OAAO,EAAE;CAC/F,MAAM,YAAY,QAAQ,SAAS;CACnC,OAAO,EACL,MAAM,KAAK,SAAuB;EAChC,MAAM,WAAW,MAAM,UACrB,GAAG,SAAS,YAAY,mBAAmB,SAAS,EAAE,sBACtD;GACE,QAAQ;GACR,SAAS;IACP,eAAe,UAAU;IACzB,gBAAgB;GAClB;GACA,MAAM,KAAK,UAAU;IACnB,MAAM,YAAY,QAAQ,IAAI;IAC9B,IAAI,QAAQ;IACZ,GAAI,QAAQ,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,UAAU,YAAY,QAAQ,OAAO,EAAE;IAClF,SAAS,QAAQ;IACjB,MAAM,QAAQ;GAChB,CAAC;EACH,CACF;EACA,IAAI,OAAmF,CAAC;EACxF,IAAI;GACF,OAAQ,MAAM,SAAS,KAAK;EAC9B,QAAQ;GACN,OAAO,CAAC;EACV;EACA,IAAI,CAAC,SAAS,MAAM,KAAK,YAAY,MACnC,MAAM,IAAI,qBACR,SAAS,QACT,MAAM,QAAQ,KAAK,MAAM,IAAK,KAAK,SAAmD,CAAC,CACzF;EAIF,MAAM,KAAK,KAAK,QAAQ;EACxB,OAAO,OAAO,OAAO,WAAW,EAAE,mBAAmB,GAAG,IAAI,CAAC;CAC/D,EACF;AACF"}
1
+ {"version":3,"file":"cloudflare-email.mjs","names":[],"sources":["../../src/transports/cloudflare-email.ts"],"sourcesContent":["import type { EmailBindingLike } from '../cloudflare/bindings';\nimport { mimeAddress } from '../email';\nimport type { EmailMessage, EmailTransport } from '../email-sender';\nimport { isRetryableStatus } from '../engine/retry';\n\n/**\n * `@shware/workflow/transports/cloudflare-email` — Cloudflare Email Service, through the\n * Worker's `send_email` binding or, from anywhere else (a Lambda), its REST API. Both take the\n * same finished email; only the call and the way each reports a failure differ.\n */\n\nexport type CloudflareEmailTransportOptions =\n | {\n /** The Worker's `send_email` binding. */\n binding: EmailBindingLike;\n accountId?: never;\n apiToken?: never;\n }\n | {\n binding?: never;\n accountId: string;\n /** An API token allowed to send email for the account. */\n apiToken: string;\n /** Defaults to `https://api.cloudflare.com/client/v4`. */\n endpoint?: string;\n fetch?: typeof fetch;\n };\n\n/** The REST API refused the email: `errors` are Cloudflare's `{ code, message }` pairs. */\nexport class CloudflareEmailError extends Error {\n override readonly name = 'CloudflareEmailError';\n /** A refused request (bad token, invalid schema) fails the same way every time. */\n readonly retryable: boolean;\n constructor(\n readonly status: number,\n readonly errors: readonly { code?: number; message?: string }[]\n ) {\n const said = errors.map((e) => `${e.code ?? '?'} ${e.message ?? ''}`.trim()).join('; ');\n super(`Cloudflare Email send failed (${status})${said === '' ? '' : `: ${said}`}`);\n // A 200 with `success: false` says nothing about retrying: treat it as transient\n this.retryable = status < 400 || isRetryableStatus(status);\n }\n}\n\nexport function cloudflareEmailTransport(options: CloudflareEmailTransportOptions): EmailTransport {\n if (options.binding !== undefined) {\n const { binding } = options;\n return {\n async send(message) {\n // The binding takes the key and drops the duplicate a retry or replay produces\n const result: unknown = await binding.send(message);\n const id = (result as { messageId?: unknown } | null | undefined)?.messageId;\n return typeof id === 'string' ? { providerMessageId: id } : {};\n },\n };\n }\n // Checked at runtime too: a JS caller can pass neither\n const given = options as { accountId?: unknown; apiToken?: unknown };\n if (typeof given.accountId !== 'string' || typeof given.apiToken !== 'string') {\n throw new TypeError('cloudflareEmailTransport needs { binding } or { accountId, apiToken }');\n }\n const { accountId, apiToken } = options;\n const endpoint = (options.endpoint ?? 'https://api.cloudflare.com/client/v4').replace(/\\/$/, '');\n const fetchImpl = options.fetch ?? fetch;\n return {\n async send(message: EmailMessage) {\n const response = await fetchImpl(\n `${endpoint}/accounts/${encodeURIComponent(accountId)}/email/sending/send`,\n {\n method: 'POST',\n headers: {\n authorization: `Bearer ${apiToken}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify({\n from: mimeAddress(message.from),\n to: message.to,\n ...(message.replyTo === undefined ? {} : { reply_to: mimeAddress(message.replyTo) }),\n subject: message.subject,\n html: message.html,\n ...(message.headers === undefined ? {} : { headers: message.headers }),\n }),\n }\n );\n let body: { success?: unknown; errors?: unknown; result?: { message_id?: unknown } } = {};\n try {\n body = (await response.json()) as typeof body;\n } catch {\n body = {};\n }\n if (!response.ok || body.success !== true) {\n throw new CloudflareEmailError(\n response.status,\n Array.isArray(body.errors) ? (body.errors as { code?: number; message?: string }[]) : []\n );\n }\n // No server-side de-duplication is documented for the REST API: a retry after an accepted\n // send can deliver twice (at least once, like SES)\n const id = body.result?.message_id;\n return typeof id === 'string' ? { providerMessageId: id } : {};\n },\n };\n}\n"],"mappings":";;;;AA6BA,IAAa,uBAAb,cAA0C,MAAM;CAKnC;CACA;CALX,OAAyB;;CAEzB;CACA,YACE,QACA,QACA;EACA,MAAM,OAAO,OAAO,KAAK,MAAM,GAAG,EAAE,QAAQ,IAAI,GAAG,EAAE,WAAW,KAAK,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI;EACtF,MAAM,iCAAiC,OAAO,GAAG,SAAS,KAAK,KAAK,KAAK,QAAQ;EAJxE,KAAA,SAAA;EACA,KAAA,SAAA;EAKT,KAAK,YAAY,SAAS,OAAO,kBAAkB,MAAM;CAC3D;AACF;AAEA,SAAgB,yBAAyB,SAA0D;CACjG,IAAI,QAAQ,YAAY,KAAA,GAAW;EACjC,MAAM,EAAE,YAAY;EACpB,OAAO,EACL,MAAM,KAAK,SAAS;GAGlB,MAAM,MAAM,MADkB,QAAQ,KAAK,OAAO,EAAA,EACiB;GACnE,OAAO,OAAO,OAAO,WAAW,EAAE,mBAAmB,GAAG,IAAI,CAAC;EAC/D,EACF;CACF;CAEA,MAAM,QAAQ;CACd,IAAI,OAAO,MAAM,cAAc,YAAY,OAAO,MAAM,aAAa,UACnE,MAAM,IAAI,UAAU,uEAAuE;CAE7F,MAAM,EAAE,WAAW,aAAa;CAChC,MAAM,YAAY,QAAQ,YAAY,uCAAA,CAAwC,QAAQ,OAAO,EAAE;CAC/F,MAAM,YAAY,QAAQ,SAAS;CACnC,OAAO,EACL,MAAM,KAAK,SAAuB;EAChC,MAAM,WAAW,MAAM,UACrB,GAAG,SAAS,YAAY,mBAAmB,SAAS,EAAE,sBACtD;GACE,QAAQ;GACR,SAAS;IACP,eAAe,UAAU;IACzB,gBAAgB;GAClB;GACA,MAAM,KAAK,UAAU;IACnB,MAAM,YAAY,QAAQ,IAAI;IAC9B,IAAI,QAAQ;IACZ,GAAI,QAAQ,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,UAAU,YAAY,QAAQ,OAAO,EAAE;IAClF,SAAS,QAAQ;IACjB,MAAM,QAAQ;IACd,GAAI,QAAQ,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,SAAS,QAAQ,QAAQ;GACtE,CAAC;EACH,CACF;EACA,IAAI,OAAmF,CAAC;EACxF,IAAI;GACF,OAAQ,MAAM,SAAS,KAAK;EAC9B,QAAQ;GACN,OAAO,CAAC;EACV;EACA,IAAI,CAAC,SAAS,MAAM,KAAK,YAAY,MACnC,MAAM,IAAI,qBACR,SAAS,QACT,MAAM,QAAQ,KAAK,MAAM,IAAK,KAAK,SAAmD,CAAC,CACzF;EAIF,MAAM,KAAK,KAAK,QAAQ;EACxB,OAAO,OAAO,OAAO,WAAW,EAAE,mBAAmB,GAAG,IAAI,CAAC;CAC/D,EACF;AACF"}
@@ -49,7 +49,11 @@ function sesEmailTransport(options) {
49
49
  Body: { Html: {
50
50
  Data: message.html,
51
51
  Charset: "UTF-8"
52
- } }
52
+ } },
53
+ ...message.headers === void 0 ? {} : { Headers: Object.entries(message.headers).map(([Name, Value]) => ({
54
+ Name,
55
+ Value
56
+ })) }
53
57
  } },
54
58
  ...options.configurationSetName === void 0 ? {} : { ConfigurationSetName: options.configurationSetName }
55
59
  });
@@ -1 +1 @@
1
- {"version":3,"file":"ses.cjs","names":["isRetryableStatus","mimeAddress","signRequest"],"sources":["../../src/transports/ses.ts"],"sourcesContent":["import { mimeAddress } from '../email';\nimport type { EmailMessage, EmailTransport } from '../email-sender';\nimport { isRetryableStatus } from '../engine/retry';\nimport { signRequest } from './sigv4';\n\n/**\n * `@shware/workflow/transports/ses` — email through Amazon SES (the v2 `SendEmail` API) over\n * plain `fetch` with a SigV4 signature, so the same transport runs in a Lambda and in a Worker.\n * Nothing here loads the AWS SDK or a Node-only credential chain.\n */\n\nexport interface AwsCredentials {\n accessKeyId: string;\n secretAccessKey: string;\n /** Present for temporary credentials (a role's): signed with the request. */\n sessionToken?: string | undefined;\n}\n\n/** Called on every send, so rotated temporary credentials are picked up rather than frozen. */\nexport type AwsCredentialProvider = () => AwsCredentials | Promise<AwsCredentials>;\n\nexport interface SesEmailTransportOptions {\n region: string;\n /**\n * Fixed credentials, or a provider read on every send. Defaults to the process environment's\n * `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`, read at send time —\n * a Lambda's execution role. A Worker passes them explicitly from its validated env.\n */\n credentials?: AwsCredentials | AwsCredentialProvider;\n /** An SES configuration set (event publishing, a dedicated IP pool). */\n configurationSetName?: string;\n /** Defaults to `https://email.<region>.amazonaws.com`. */\n endpoint?: string;\n fetch?: typeof fetch;\n}\n\n/** An SES error response: `type` is SES's error code (`MessageRejected`, `TooManyRequestsException`, ...). */\nexport class SesSendError extends Error {\n override readonly name = 'SesSendError';\n /** A rejected request (an unverified sender, a bad address) fails the same way every time. */\n readonly retryable: boolean;\n constructor(\n readonly status: number,\n readonly type: string | undefined,\n message: string\n ) {\n super(`SES SendEmail failed (${status}${type === undefined ? '' : ` ${type}`}): ${message}`);\n this.retryable = isRetryableStatus(status);\n }\n}\n\nfunction environmentCredentials(): AwsCredentials {\n // Read through globalThis: a Worker has no `process`, and must pass credentials instead\n const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process\n ?.env;\n const accessKeyId = env?.AWS_ACCESS_KEY_ID;\n const secretAccessKey = env?.AWS_SECRET_ACCESS_KEY;\n if (accessKeyId === undefined || secretAccessKey === undefined) {\n throw new Error(\n 'sesEmailTransport: no credentials — pass `credentials`, or run where AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are set (a Lambda execution role)'\n );\n }\n return { accessKeyId, secretAccessKey, sessionToken: env?.AWS_SESSION_TOKEN };\n}\n\nexport function sesEmailTransport(options: SesEmailTransportOptions): EmailTransport {\n const endpoint = (options.endpoint ?? `https://email.${options.region}.amazonaws.com`).replace(\n /\\/$/,\n ''\n );\n const credentials = async (): Promise<AwsCredentials> => {\n const configured = options.credentials;\n if (configured === undefined) return environmentCredentials();\n return typeof configured === 'function' ? configured() : configured;\n };\n const fetchImpl = options.fetch ?? fetch;\n\n return {\n async send(message: EmailMessage) {\n const body = JSON.stringify({\n FromEmailAddress: mimeAddress(message.from),\n Destination: { ToAddresses: [message.to] },\n ...(message.replyTo === undefined\n ? {}\n : { ReplyToAddresses: [mimeAddress(message.replyTo)] }),\n Content: {\n Simple: {\n Subject: { Data: message.subject, Charset: 'UTF-8' },\n Body: { Html: { Data: message.html, Charset: 'UTF-8' } },\n },\n },\n ...(options.configurationSetName === undefined\n ? {}\n : { ConfigurationSetName: options.configurationSetName }),\n });\n const url = `${endpoint}/v2/email/outbound-emails`;\n const headers = await signRequest({\n method: 'POST',\n url,\n headers: { 'content-type': 'application/json' },\n body,\n credentials: await credentials(),\n region: options.region,\n service: 'ses',\n });\n // No retries here: the durable step owns them, and counts them — SES cannot de-duplicate,\n // so every extra attempt of an accepted send is another chance to deliver twice\n const response = await fetchImpl(url, { method: 'POST', headers, body });\n const text = await response.text();\n if (!response.ok) {\n let detail = text;\n try {\n const parsed = JSON.parse(text) as { message?: unknown; Message?: unknown };\n const said = parsed.message ?? parsed.Message;\n if (typeof said === 'string') detail = said;\n } catch {\n // Not JSON: the raw body says what went wrong\n }\n const type = response.headers.get('x-amzn-errortype')?.split(':')[0];\n throw new SesSendError(response.status, type, detail);\n }\n let messageId: unknown;\n try {\n messageId = (JSON.parse(text) as { MessageId?: unknown }).MessageId;\n } catch {\n messageId = undefined;\n }\n return typeof messageId === 'string' ? { providerMessageId: messageId } : {};\n },\n };\n}\n"],"mappings":";;;;;;AAqCA,IAAa,eAAb,cAAkC,MAAM;CAK3B;CACA;CALX,OAAyB;;CAEzB;CACA,YACE,QACA,MACA,SACA;EACA,MAAM,yBAAyB,SAAS,SAAS,KAAA,IAAY,KAAK,IAAI,OAAO,KAAK,SAAS;EAJlF,KAAA,SAAA;EACA,KAAA,OAAA;EAIT,KAAK,YAAYA,qBAAAA,kBAAkB,MAAM;CAC3C;AACF;AAEA,SAAS,yBAAyC;CAEhD,MAAM,MAAO,WAA0E,SACnF;CACJ,MAAM,cAAc,KAAK;CACzB,MAAM,kBAAkB,KAAK;CAC7B,IAAI,gBAAgB,KAAA,KAAa,oBAAoB,KAAA,GACnD,MAAM,IAAI,MACR,oJACF;CAEF,OAAO;EAAE;EAAa;EAAiB,cAAc,KAAK;CAAkB;AAC9E;AAEA,SAAgB,kBAAkB,SAAmD;CACnF,MAAM,YAAY,QAAQ,YAAY,iBAAiB,QAAQ,OAAO,gBAAA,CAAiB,QACrF,OACA,EACF;CACA,MAAM,cAAc,YAAqC;EACvD,MAAM,aAAa,QAAQ;EAC3B,IAAI,eAAe,KAAA,GAAW,OAAO,uBAAuB;EAC5D,OAAO,OAAO,eAAe,aAAa,WAAW,IAAI;CAC3D;CACA,MAAM,YAAY,QAAQ,SAAS;CAEnC,OAAO,EACL,MAAM,KAAK,SAAuB;EAChC,MAAM,OAAO,KAAK,UAAU;GAC1B,kBAAkBC,cAAAA,YAAY,QAAQ,IAAI;GAC1C,aAAa,EAAE,aAAa,CAAC,QAAQ,EAAE,EAAE;GACzC,GAAI,QAAQ,YAAY,KAAA,IACpB,CAAC,IACD,EAAE,kBAAkB,CAACA,cAAAA,YAAY,QAAQ,OAAO,CAAC,EAAE;GACvD,SAAS,EACP,QAAQ;IACN,SAAS;KAAE,MAAM,QAAQ;KAAS,SAAS;IAAQ;IACnD,MAAM,EAAE,MAAM;KAAE,MAAM,QAAQ;KAAM,SAAS;IAAQ,EAAE;GACzD,EACF;GACA,GAAI,QAAQ,yBAAyB,KAAA,IACjC,CAAC,IACD,EAAE,sBAAsB,QAAQ,qBAAqB;EAC3D,CAAC;EACD,MAAM,MAAM,GAAG,SAAS;EACxB,MAAM,UAAU,MAAMC,yBAAAA,YAAY;GAChC,QAAQ;GACR;GACA,SAAS,EAAE,gBAAgB,mBAAmB;GAC9C;GACA,aAAa,MAAM,YAAY;GAC/B,QAAQ,QAAQ;GAChB,SAAS;EACX,CAAC;EAGD,MAAM,WAAW,MAAM,UAAU,KAAK;GAAE,QAAQ;GAAQ;GAAS;EAAK,CAAC;EACvE,MAAM,OAAO,MAAM,SAAS,KAAK;EACjC,IAAI,CAAC,SAAS,IAAI;GAChB,IAAI,SAAS;GACb,IAAI;IACF,MAAM,SAAS,KAAK,MAAM,IAAI;IAC9B,MAAM,OAAO,OAAO,WAAW,OAAO;IACtC,IAAI,OAAO,SAAS,UAAU,SAAS;GACzC,QAAQ,CAER;GACA,MAAM,OAAO,SAAS,QAAQ,IAAI,kBAAkB,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC;GAClE,MAAM,IAAI,aAAa,SAAS,QAAQ,MAAM,MAAM;EACtD;EACA,IAAI;EACJ,IAAI;GACF,YAAa,KAAK,MAAM,IAAI,CAAC,CAA6B;EAC5D,QAAQ;GACN,YAAY,KAAA;EACd;EACA,OAAO,OAAO,cAAc,WAAW,EAAE,mBAAmB,UAAU,IAAI,CAAC;CAC7E,EACF;AACF"}
1
+ {"version":3,"file":"ses.cjs","names":["isRetryableStatus","mimeAddress","signRequest"],"sources":["../../src/transports/ses.ts"],"sourcesContent":["import { mimeAddress } from '../email';\nimport type { EmailMessage, EmailTransport } from '../email-sender';\nimport { isRetryableStatus } from '../engine/retry';\nimport { signRequest } from './sigv4';\n\n/**\n * `@shware/workflow/transports/ses` — email through Amazon SES (the v2 `SendEmail` API) over\n * plain `fetch` with a SigV4 signature, so the same transport runs in a Lambda and in a Worker.\n * Nothing here loads the AWS SDK or a Node-only credential chain.\n */\n\nexport interface AwsCredentials {\n accessKeyId: string;\n secretAccessKey: string;\n /** Present for temporary credentials (a role's): signed with the request. */\n sessionToken?: string | undefined;\n}\n\n/** Called on every send, so rotated temporary credentials are picked up rather than frozen. */\nexport type AwsCredentialProvider = () => AwsCredentials | Promise<AwsCredentials>;\n\nexport interface SesEmailTransportOptions {\n region: string;\n /**\n * Fixed credentials, or a provider read on every send. Defaults to the process environment's\n * `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`, read at send time —\n * a Lambda's execution role. A Worker passes them explicitly from its validated env.\n */\n credentials?: AwsCredentials | AwsCredentialProvider;\n /** An SES configuration set (event publishing, a dedicated IP pool). */\n configurationSetName?: string;\n /** Defaults to `https://email.<region>.amazonaws.com`. */\n endpoint?: string;\n fetch?: typeof fetch;\n}\n\n/** An SES error response: `type` is SES's error code (`MessageRejected`, `TooManyRequestsException`, ...). */\nexport class SesSendError extends Error {\n override readonly name = 'SesSendError';\n /** A rejected request (an unverified sender, a bad address) fails the same way every time. */\n readonly retryable: boolean;\n constructor(\n readonly status: number,\n readonly type: string | undefined,\n message: string\n ) {\n super(`SES SendEmail failed (${status}${type === undefined ? '' : ` ${type}`}): ${message}`);\n this.retryable = isRetryableStatus(status);\n }\n}\n\nfunction environmentCredentials(): AwsCredentials {\n // Read through globalThis: a Worker has no `process`, and must pass credentials instead\n const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process\n ?.env;\n const accessKeyId = env?.AWS_ACCESS_KEY_ID;\n const secretAccessKey = env?.AWS_SECRET_ACCESS_KEY;\n if (accessKeyId === undefined || secretAccessKey === undefined) {\n throw new Error(\n 'sesEmailTransport: no credentials — pass `credentials`, or run where AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are set (a Lambda execution role)'\n );\n }\n return { accessKeyId, secretAccessKey, sessionToken: env?.AWS_SESSION_TOKEN };\n}\n\nexport function sesEmailTransport(options: SesEmailTransportOptions): EmailTransport {\n const endpoint = (options.endpoint ?? `https://email.${options.region}.amazonaws.com`).replace(\n /\\/$/,\n ''\n );\n const credentials = async (): Promise<AwsCredentials> => {\n const configured = options.credentials;\n if (configured === undefined) return environmentCredentials();\n return typeof configured === 'function' ? configured() : configured;\n };\n const fetchImpl = options.fetch ?? fetch;\n\n return {\n async send(message: EmailMessage) {\n const body = JSON.stringify({\n FromEmailAddress: mimeAddress(message.from),\n Destination: { ToAddresses: [message.to] },\n ...(message.replyTo === undefined\n ? {}\n : { ReplyToAddresses: [mimeAddress(message.replyTo)] }),\n Content: {\n Simple: {\n Subject: { Data: message.subject, Charset: 'UTF-8' },\n Body: { Html: { Data: message.html, Charset: 'UTF-8' } },\n ...(message.headers === undefined\n ? {}\n : {\n Headers: Object.entries(message.headers).map(([Name, Value]) => ({\n Name,\n Value,\n })),\n }),\n },\n },\n ...(options.configurationSetName === undefined\n ? {}\n : { ConfigurationSetName: options.configurationSetName }),\n });\n const url = `${endpoint}/v2/email/outbound-emails`;\n const headers = await signRequest({\n method: 'POST',\n url,\n headers: { 'content-type': 'application/json' },\n body,\n credentials: await credentials(),\n region: options.region,\n service: 'ses',\n });\n // No retries here: the durable step owns them, and counts them — SES cannot de-duplicate,\n // so every extra attempt of an accepted send is another chance to deliver twice\n const response = await fetchImpl(url, { method: 'POST', headers, body });\n const text = await response.text();\n if (!response.ok) {\n let detail = text;\n try {\n const parsed = JSON.parse(text) as { message?: unknown; Message?: unknown };\n const said = parsed.message ?? parsed.Message;\n if (typeof said === 'string') detail = said;\n } catch {\n // Not JSON: the raw body says what went wrong\n }\n const type = response.headers.get('x-amzn-errortype')?.split(':')[0];\n throw new SesSendError(response.status, type, detail);\n }\n let messageId: unknown;\n try {\n messageId = (JSON.parse(text) as { MessageId?: unknown }).MessageId;\n } catch {\n messageId = undefined;\n }\n return typeof messageId === 'string' ? { providerMessageId: messageId } : {};\n },\n };\n}\n"],"mappings":";;;;;;AAqCA,IAAa,eAAb,cAAkC,MAAM;CAK3B;CACA;CALX,OAAyB;;CAEzB;CACA,YACE,QACA,MACA,SACA;EACA,MAAM,yBAAyB,SAAS,SAAS,KAAA,IAAY,KAAK,IAAI,OAAO,KAAK,SAAS;EAJlF,KAAA,SAAA;EACA,KAAA,OAAA;EAIT,KAAK,YAAYA,qBAAAA,kBAAkB,MAAM;CAC3C;AACF;AAEA,SAAS,yBAAyC;CAEhD,MAAM,MAAO,WAA0E,SACnF;CACJ,MAAM,cAAc,KAAK;CACzB,MAAM,kBAAkB,KAAK;CAC7B,IAAI,gBAAgB,KAAA,KAAa,oBAAoB,KAAA,GACnD,MAAM,IAAI,MACR,oJACF;CAEF,OAAO;EAAE;EAAa;EAAiB,cAAc,KAAK;CAAkB;AAC9E;AAEA,SAAgB,kBAAkB,SAAmD;CACnF,MAAM,YAAY,QAAQ,YAAY,iBAAiB,QAAQ,OAAO,gBAAA,CAAiB,QACrF,OACA,EACF;CACA,MAAM,cAAc,YAAqC;EACvD,MAAM,aAAa,QAAQ;EAC3B,IAAI,eAAe,KAAA,GAAW,OAAO,uBAAuB;EAC5D,OAAO,OAAO,eAAe,aAAa,WAAW,IAAI;CAC3D;CACA,MAAM,YAAY,QAAQ,SAAS;CAEnC,OAAO,EACL,MAAM,KAAK,SAAuB;EAChC,MAAM,OAAO,KAAK,UAAU;GAC1B,kBAAkBC,cAAAA,YAAY,QAAQ,IAAI;GAC1C,aAAa,EAAE,aAAa,CAAC,QAAQ,EAAE,EAAE;GACzC,GAAI,QAAQ,YAAY,KAAA,IACpB,CAAC,IACD,EAAE,kBAAkB,CAACA,cAAAA,YAAY,QAAQ,OAAO,CAAC,EAAE;GACvD,SAAS,EACP,QAAQ;IACN,SAAS;KAAE,MAAM,QAAQ;KAAS,SAAS;IAAQ;IACnD,MAAM,EAAE,MAAM;KAAE,MAAM,QAAQ;KAAM,SAAS;IAAQ,EAAE;IACvD,GAAI,QAAQ,YAAY,KAAA,IACpB,CAAC,IACD,EACE,SAAS,OAAO,QAAQ,QAAQ,OAAO,CAAC,CAAC,KAAK,CAAC,MAAM,YAAY;KAC/D;KACA;IACF,EAAE,EACJ;GACN,EACF;GACA,GAAI,QAAQ,yBAAyB,KAAA,IACjC,CAAC,IACD,EAAE,sBAAsB,QAAQ,qBAAqB;EAC3D,CAAC;EACD,MAAM,MAAM,GAAG,SAAS;EACxB,MAAM,UAAU,MAAMC,yBAAAA,YAAY;GAChC,QAAQ;GACR;GACA,SAAS,EAAE,gBAAgB,mBAAmB;GAC9C;GACA,aAAa,MAAM,YAAY;GAC/B,QAAQ,QAAQ;GAChB,SAAS;EACX,CAAC;EAGD,MAAM,WAAW,MAAM,UAAU,KAAK;GAAE,QAAQ;GAAQ;GAAS;EAAK,CAAC;EACvE,MAAM,OAAO,MAAM,SAAS,KAAK;EACjC,IAAI,CAAC,SAAS,IAAI;GAChB,IAAI,SAAS;GACb,IAAI;IACF,MAAM,SAAS,KAAK,MAAM,IAAI;IAC9B,MAAM,OAAO,OAAO,WAAW,OAAO;IACtC,IAAI,OAAO,SAAS,UAAU,SAAS;GACzC,QAAQ,CAER;GACA,MAAM,OAAO,SAAS,QAAQ,IAAI,kBAAkB,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC;GAClE,MAAM,IAAI,aAAa,SAAS,QAAQ,MAAM,MAAM;EACtD;EACA,IAAI;EACJ,IAAI;GACF,YAAa,KAAK,MAAM,IAAI,CAAC,CAA6B;EAC5D,QAAQ;GACN,YAAY,KAAA;EACd;EACA,OAAO,OAAO,cAAc,WAAW,EAAE,mBAAmB,UAAU,IAAI,CAAC;CAC7E,EACF;AACF"}
@@ -48,7 +48,11 @@ function sesEmailTransport(options) {
48
48
  Body: { Html: {
49
49
  Data: message.html,
50
50
  Charset: "UTF-8"
51
- } }
51
+ } },
52
+ ...message.headers === void 0 ? {} : { Headers: Object.entries(message.headers).map(([Name, Value]) => ({
53
+ Name,
54
+ Value
55
+ })) }
52
56
  } },
53
57
  ...options.configurationSetName === void 0 ? {} : { ConfigurationSetName: options.configurationSetName }
54
58
  });
@@ -1 +1 @@
1
- {"version":3,"file":"ses.mjs","names":[],"sources":["../../src/transports/ses.ts"],"sourcesContent":["import { mimeAddress } from '../email';\nimport type { EmailMessage, EmailTransport } from '../email-sender';\nimport { isRetryableStatus } from '../engine/retry';\nimport { signRequest } from './sigv4';\n\n/**\n * `@shware/workflow/transports/ses` — email through Amazon SES (the v2 `SendEmail` API) over\n * plain `fetch` with a SigV4 signature, so the same transport runs in a Lambda and in a Worker.\n * Nothing here loads the AWS SDK or a Node-only credential chain.\n */\n\nexport interface AwsCredentials {\n accessKeyId: string;\n secretAccessKey: string;\n /** Present for temporary credentials (a role's): signed with the request. */\n sessionToken?: string | undefined;\n}\n\n/** Called on every send, so rotated temporary credentials are picked up rather than frozen. */\nexport type AwsCredentialProvider = () => AwsCredentials | Promise<AwsCredentials>;\n\nexport interface SesEmailTransportOptions {\n region: string;\n /**\n * Fixed credentials, or a provider read on every send. Defaults to the process environment's\n * `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`, read at send time —\n * a Lambda's execution role. A Worker passes them explicitly from its validated env.\n */\n credentials?: AwsCredentials | AwsCredentialProvider;\n /** An SES configuration set (event publishing, a dedicated IP pool). */\n configurationSetName?: string;\n /** Defaults to `https://email.<region>.amazonaws.com`. */\n endpoint?: string;\n fetch?: typeof fetch;\n}\n\n/** An SES error response: `type` is SES's error code (`MessageRejected`, `TooManyRequestsException`, ...). */\nexport class SesSendError extends Error {\n override readonly name = 'SesSendError';\n /** A rejected request (an unverified sender, a bad address) fails the same way every time. */\n readonly retryable: boolean;\n constructor(\n readonly status: number,\n readonly type: string | undefined,\n message: string\n ) {\n super(`SES SendEmail failed (${status}${type === undefined ? '' : ` ${type}`}): ${message}`);\n this.retryable = isRetryableStatus(status);\n }\n}\n\nfunction environmentCredentials(): AwsCredentials {\n // Read through globalThis: a Worker has no `process`, and must pass credentials instead\n const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process\n ?.env;\n const accessKeyId = env?.AWS_ACCESS_KEY_ID;\n const secretAccessKey = env?.AWS_SECRET_ACCESS_KEY;\n if (accessKeyId === undefined || secretAccessKey === undefined) {\n throw new Error(\n 'sesEmailTransport: no credentials — pass `credentials`, or run where AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are set (a Lambda execution role)'\n );\n }\n return { accessKeyId, secretAccessKey, sessionToken: env?.AWS_SESSION_TOKEN };\n}\n\nexport function sesEmailTransport(options: SesEmailTransportOptions): EmailTransport {\n const endpoint = (options.endpoint ?? `https://email.${options.region}.amazonaws.com`).replace(\n /\\/$/,\n ''\n );\n const credentials = async (): Promise<AwsCredentials> => {\n const configured = options.credentials;\n if (configured === undefined) return environmentCredentials();\n return typeof configured === 'function' ? configured() : configured;\n };\n const fetchImpl = options.fetch ?? fetch;\n\n return {\n async send(message: EmailMessage) {\n const body = JSON.stringify({\n FromEmailAddress: mimeAddress(message.from),\n Destination: { ToAddresses: [message.to] },\n ...(message.replyTo === undefined\n ? {}\n : { ReplyToAddresses: [mimeAddress(message.replyTo)] }),\n Content: {\n Simple: {\n Subject: { Data: message.subject, Charset: 'UTF-8' },\n Body: { Html: { Data: message.html, Charset: 'UTF-8' } },\n },\n },\n ...(options.configurationSetName === undefined\n ? {}\n : { ConfigurationSetName: options.configurationSetName }),\n });\n const url = `${endpoint}/v2/email/outbound-emails`;\n const headers = await signRequest({\n method: 'POST',\n url,\n headers: { 'content-type': 'application/json' },\n body,\n credentials: await credentials(),\n region: options.region,\n service: 'ses',\n });\n // No retries here: the durable step owns them, and counts them — SES cannot de-duplicate,\n // so every extra attempt of an accepted send is another chance to deliver twice\n const response = await fetchImpl(url, { method: 'POST', headers, body });\n const text = await response.text();\n if (!response.ok) {\n let detail = text;\n try {\n const parsed = JSON.parse(text) as { message?: unknown; Message?: unknown };\n const said = parsed.message ?? parsed.Message;\n if (typeof said === 'string') detail = said;\n } catch {\n // Not JSON: the raw body says what went wrong\n }\n const type = response.headers.get('x-amzn-errortype')?.split(':')[0];\n throw new SesSendError(response.status, type, detail);\n }\n let messageId: unknown;\n try {\n messageId = (JSON.parse(text) as { MessageId?: unknown }).MessageId;\n } catch {\n messageId = undefined;\n }\n return typeof messageId === 'string' ? { providerMessageId: messageId } : {};\n },\n };\n}\n"],"mappings":";;;;;AAqCA,IAAa,eAAb,cAAkC,MAAM;CAK3B;CACA;CALX,OAAyB;;CAEzB;CACA,YACE,QACA,MACA,SACA;EACA,MAAM,yBAAyB,SAAS,SAAS,KAAA,IAAY,KAAK,IAAI,OAAO,KAAK,SAAS;EAJlF,KAAA,SAAA;EACA,KAAA,OAAA;EAIT,KAAK,YAAY,kBAAkB,MAAM;CAC3C;AACF;AAEA,SAAS,yBAAyC;CAEhD,MAAM,MAAO,WAA0E,SACnF;CACJ,MAAM,cAAc,KAAK;CACzB,MAAM,kBAAkB,KAAK;CAC7B,IAAI,gBAAgB,KAAA,KAAa,oBAAoB,KAAA,GACnD,MAAM,IAAI,MACR,oJACF;CAEF,OAAO;EAAE;EAAa;EAAiB,cAAc,KAAK;CAAkB;AAC9E;AAEA,SAAgB,kBAAkB,SAAmD;CACnF,MAAM,YAAY,QAAQ,YAAY,iBAAiB,QAAQ,OAAO,gBAAA,CAAiB,QACrF,OACA,EACF;CACA,MAAM,cAAc,YAAqC;EACvD,MAAM,aAAa,QAAQ;EAC3B,IAAI,eAAe,KAAA,GAAW,OAAO,uBAAuB;EAC5D,OAAO,OAAO,eAAe,aAAa,WAAW,IAAI;CAC3D;CACA,MAAM,YAAY,QAAQ,SAAS;CAEnC,OAAO,EACL,MAAM,KAAK,SAAuB;EAChC,MAAM,OAAO,KAAK,UAAU;GAC1B,kBAAkB,YAAY,QAAQ,IAAI;GAC1C,aAAa,EAAE,aAAa,CAAC,QAAQ,EAAE,EAAE;GACzC,GAAI,QAAQ,YAAY,KAAA,IACpB,CAAC,IACD,EAAE,kBAAkB,CAAC,YAAY,QAAQ,OAAO,CAAC,EAAE;GACvD,SAAS,EACP,QAAQ;IACN,SAAS;KAAE,MAAM,QAAQ;KAAS,SAAS;IAAQ;IACnD,MAAM,EAAE,MAAM;KAAE,MAAM,QAAQ;KAAM,SAAS;IAAQ,EAAE;GACzD,EACF;GACA,GAAI,QAAQ,yBAAyB,KAAA,IACjC,CAAC,IACD,EAAE,sBAAsB,QAAQ,qBAAqB;EAC3D,CAAC;EACD,MAAM,MAAM,GAAG,SAAS;EACxB,MAAM,UAAU,MAAM,YAAY;GAChC,QAAQ;GACR;GACA,SAAS,EAAE,gBAAgB,mBAAmB;GAC9C;GACA,aAAa,MAAM,YAAY;GAC/B,QAAQ,QAAQ;GAChB,SAAS;EACX,CAAC;EAGD,MAAM,WAAW,MAAM,UAAU,KAAK;GAAE,QAAQ;GAAQ;GAAS;EAAK,CAAC;EACvE,MAAM,OAAO,MAAM,SAAS,KAAK;EACjC,IAAI,CAAC,SAAS,IAAI;GAChB,IAAI,SAAS;GACb,IAAI;IACF,MAAM,SAAS,KAAK,MAAM,IAAI;IAC9B,MAAM,OAAO,OAAO,WAAW,OAAO;IACtC,IAAI,OAAO,SAAS,UAAU,SAAS;GACzC,QAAQ,CAER;GACA,MAAM,OAAO,SAAS,QAAQ,IAAI,kBAAkB,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC;GAClE,MAAM,IAAI,aAAa,SAAS,QAAQ,MAAM,MAAM;EACtD;EACA,IAAI;EACJ,IAAI;GACF,YAAa,KAAK,MAAM,IAAI,CAAC,CAA6B;EAC5D,QAAQ;GACN,YAAY,KAAA;EACd;EACA,OAAO,OAAO,cAAc,WAAW,EAAE,mBAAmB,UAAU,IAAI,CAAC;CAC7E,EACF;AACF"}
1
+ {"version":3,"file":"ses.mjs","names":[],"sources":["../../src/transports/ses.ts"],"sourcesContent":["import { mimeAddress } from '../email';\nimport type { EmailMessage, EmailTransport } from '../email-sender';\nimport { isRetryableStatus } from '../engine/retry';\nimport { signRequest } from './sigv4';\n\n/**\n * `@shware/workflow/transports/ses` — email through Amazon SES (the v2 `SendEmail` API) over\n * plain `fetch` with a SigV4 signature, so the same transport runs in a Lambda and in a Worker.\n * Nothing here loads the AWS SDK or a Node-only credential chain.\n */\n\nexport interface AwsCredentials {\n accessKeyId: string;\n secretAccessKey: string;\n /** Present for temporary credentials (a role's): signed with the request. */\n sessionToken?: string | undefined;\n}\n\n/** Called on every send, so rotated temporary credentials are picked up rather than frozen. */\nexport type AwsCredentialProvider = () => AwsCredentials | Promise<AwsCredentials>;\n\nexport interface SesEmailTransportOptions {\n region: string;\n /**\n * Fixed credentials, or a provider read on every send. Defaults to the process environment's\n * `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`, read at send time —\n * a Lambda's execution role. A Worker passes them explicitly from its validated env.\n */\n credentials?: AwsCredentials | AwsCredentialProvider;\n /** An SES configuration set (event publishing, a dedicated IP pool). */\n configurationSetName?: string;\n /** Defaults to `https://email.<region>.amazonaws.com`. */\n endpoint?: string;\n fetch?: typeof fetch;\n}\n\n/** An SES error response: `type` is SES's error code (`MessageRejected`, `TooManyRequestsException`, ...). */\nexport class SesSendError extends Error {\n override readonly name = 'SesSendError';\n /** A rejected request (an unverified sender, a bad address) fails the same way every time. */\n readonly retryable: boolean;\n constructor(\n readonly status: number,\n readonly type: string | undefined,\n message: string\n ) {\n super(`SES SendEmail failed (${status}${type === undefined ? '' : ` ${type}`}): ${message}`);\n this.retryable = isRetryableStatus(status);\n }\n}\n\nfunction environmentCredentials(): AwsCredentials {\n // Read through globalThis: a Worker has no `process`, and must pass credentials instead\n const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process\n ?.env;\n const accessKeyId = env?.AWS_ACCESS_KEY_ID;\n const secretAccessKey = env?.AWS_SECRET_ACCESS_KEY;\n if (accessKeyId === undefined || secretAccessKey === undefined) {\n throw new Error(\n 'sesEmailTransport: no credentials — pass `credentials`, or run where AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are set (a Lambda execution role)'\n );\n }\n return { accessKeyId, secretAccessKey, sessionToken: env?.AWS_SESSION_TOKEN };\n}\n\nexport function sesEmailTransport(options: SesEmailTransportOptions): EmailTransport {\n const endpoint = (options.endpoint ?? `https://email.${options.region}.amazonaws.com`).replace(\n /\\/$/,\n ''\n );\n const credentials = async (): Promise<AwsCredentials> => {\n const configured = options.credentials;\n if (configured === undefined) return environmentCredentials();\n return typeof configured === 'function' ? configured() : configured;\n };\n const fetchImpl = options.fetch ?? fetch;\n\n return {\n async send(message: EmailMessage) {\n const body = JSON.stringify({\n FromEmailAddress: mimeAddress(message.from),\n Destination: { ToAddresses: [message.to] },\n ...(message.replyTo === undefined\n ? {}\n : { ReplyToAddresses: [mimeAddress(message.replyTo)] }),\n Content: {\n Simple: {\n Subject: { Data: message.subject, Charset: 'UTF-8' },\n Body: { Html: { Data: message.html, Charset: 'UTF-8' } },\n ...(message.headers === undefined\n ? {}\n : {\n Headers: Object.entries(message.headers).map(([Name, Value]) => ({\n Name,\n Value,\n })),\n }),\n },\n },\n ...(options.configurationSetName === undefined\n ? {}\n : { ConfigurationSetName: options.configurationSetName }),\n });\n const url = `${endpoint}/v2/email/outbound-emails`;\n const headers = await signRequest({\n method: 'POST',\n url,\n headers: { 'content-type': 'application/json' },\n body,\n credentials: await credentials(),\n region: options.region,\n service: 'ses',\n });\n // No retries here: the durable step owns them, and counts them — SES cannot de-duplicate,\n // so every extra attempt of an accepted send is another chance to deliver twice\n const response = await fetchImpl(url, { method: 'POST', headers, body });\n const text = await response.text();\n if (!response.ok) {\n let detail = text;\n try {\n const parsed = JSON.parse(text) as { message?: unknown; Message?: unknown };\n const said = parsed.message ?? parsed.Message;\n if (typeof said === 'string') detail = said;\n } catch {\n // Not JSON: the raw body says what went wrong\n }\n const type = response.headers.get('x-amzn-errortype')?.split(':')[0];\n throw new SesSendError(response.status, type, detail);\n }\n let messageId: unknown;\n try {\n messageId = (JSON.parse(text) as { MessageId?: unknown }).MessageId;\n } catch {\n messageId = undefined;\n }\n return typeof messageId === 'string' ? { providerMessageId: messageId } : {};\n },\n };\n}\n"],"mappings":";;;;;AAqCA,IAAa,eAAb,cAAkC,MAAM;CAK3B;CACA;CALX,OAAyB;;CAEzB;CACA,YACE,QACA,MACA,SACA;EACA,MAAM,yBAAyB,SAAS,SAAS,KAAA,IAAY,KAAK,IAAI,OAAO,KAAK,SAAS;EAJlF,KAAA,SAAA;EACA,KAAA,OAAA;EAIT,KAAK,YAAY,kBAAkB,MAAM;CAC3C;AACF;AAEA,SAAS,yBAAyC;CAEhD,MAAM,MAAO,WAA0E,SACnF;CACJ,MAAM,cAAc,KAAK;CACzB,MAAM,kBAAkB,KAAK;CAC7B,IAAI,gBAAgB,KAAA,KAAa,oBAAoB,KAAA,GACnD,MAAM,IAAI,MACR,oJACF;CAEF,OAAO;EAAE;EAAa;EAAiB,cAAc,KAAK;CAAkB;AAC9E;AAEA,SAAgB,kBAAkB,SAAmD;CACnF,MAAM,YAAY,QAAQ,YAAY,iBAAiB,QAAQ,OAAO,gBAAA,CAAiB,QACrF,OACA,EACF;CACA,MAAM,cAAc,YAAqC;EACvD,MAAM,aAAa,QAAQ;EAC3B,IAAI,eAAe,KAAA,GAAW,OAAO,uBAAuB;EAC5D,OAAO,OAAO,eAAe,aAAa,WAAW,IAAI;CAC3D;CACA,MAAM,YAAY,QAAQ,SAAS;CAEnC,OAAO,EACL,MAAM,KAAK,SAAuB;EAChC,MAAM,OAAO,KAAK,UAAU;GAC1B,kBAAkB,YAAY,QAAQ,IAAI;GAC1C,aAAa,EAAE,aAAa,CAAC,QAAQ,EAAE,EAAE;GACzC,GAAI,QAAQ,YAAY,KAAA,IACpB,CAAC,IACD,EAAE,kBAAkB,CAAC,YAAY,QAAQ,OAAO,CAAC,EAAE;GACvD,SAAS,EACP,QAAQ;IACN,SAAS;KAAE,MAAM,QAAQ;KAAS,SAAS;IAAQ;IACnD,MAAM,EAAE,MAAM;KAAE,MAAM,QAAQ;KAAM,SAAS;IAAQ,EAAE;IACvD,GAAI,QAAQ,YAAY,KAAA,IACpB,CAAC,IACD,EACE,SAAS,OAAO,QAAQ,QAAQ,OAAO,CAAC,CAAC,KAAK,CAAC,MAAM,YAAY;KAC/D;KACA;IACF,EAAE,EACJ;GACN,EACF;GACA,GAAI,QAAQ,yBAAyB,KAAA,IACjC,CAAC,IACD,EAAE,sBAAsB,QAAQ,qBAAqB;EAC3D,CAAC;EACD,MAAM,MAAM,GAAG,SAAS;EACxB,MAAM,UAAU,MAAM,YAAY;GAChC,QAAQ;GACR;GACA,SAAS,EAAE,gBAAgB,mBAAmB;GAC9C;GACA,aAAa,MAAM,YAAY;GAC/B,QAAQ,QAAQ;GAChB,SAAS;EACX,CAAC;EAGD,MAAM,WAAW,MAAM,UAAU,KAAK;GAAE,QAAQ;GAAQ;GAAS;EAAK,CAAC;EACvE,MAAM,OAAO,MAAM,SAAS,KAAK;EACjC,IAAI,CAAC,SAAS,IAAI;GAChB,IAAI,SAAS;GACb,IAAI;IACF,MAAM,SAAS,KAAK,MAAM,IAAI;IAC9B,MAAM,OAAO,OAAO,WAAW,OAAO;IACtC,IAAI,OAAO,SAAS,UAAU,SAAS;GACzC,QAAQ,CAER;GACA,MAAM,OAAO,SAAS,QAAQ,IAAI,kBAAkB,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC;GAClE,MAAM,IAAI,aAAa,SAAS,QAAQ,MAAM,MAAM;EACtD;EACA,IAAI;EACJ,IAAI;GACF,YAAa,KAAK,MAAM,IAAI,CAAC,CAA6B;EAC5D,QAAQ;GACN,YAAY,KAAA;EACd;EACA,OAAO,OAAO,cAAc,WAAW,EAAE,mBAAmB,UAAU,IAAI,CAAC;CAC7E,EACF;AACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shware/workflow",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/ShwareHQ/shware-sdk.git"