@shware/workflow 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. package/README.md +96 -2
  2. package/dist/apns-relay.cjs +4 -17
  3. package/dist/apns-relay.cjs.map +1 -1
  4. package/dist/apns-relay.mjs +4 -17
  5. package/dist/apns-relay.mjs.map +1 -1
  6. package/dist/aws/engine-step.cjs +104 -0
  7. package/dist/aws/engine-step.cjs.map +1 -0
  8. package/dist/aws/engine-step.d.cts +28 -0
  9. package/dist/aws/engine-step.d.cts.map +1 -0
  10. package/dist/aws/engine-step.d.mts +28 -0
  11. package/dist/aws/engine-step.d.mts.map +1 -0
  12. package/dist/aws/engine-step.mjs +102 -0
  13. package/dist/aws/engine-step.mjs.map +1 -0
  14. package/dist/aws/function-url.cjs +62 -0
  15. package/dist/aws/function-url.cjs.map +1 -0
  16. package/dist/aws/function-url.d.cts +38 -0
  17. package/dist/aws/function-url.d.cts.map +1 -0
  18. package/dist/aws/function-url.d.mts +38 -0
  19. package/dist/aws/function-url.d.mts.map +1 -0
  20. package/dist/aws/function-url.mjs +59 -0
  21. package/dist/aws/function-url.mjs.map +1 -0
  22. package/dist/aws/index.cjs +160 -0
  23. package/dist/aws/index.cjs.map +1 -0
  24. package/dist/aws/index.d.cts +62 -0
  25. package/dist/aws/index.d.cts.map +1 -0
  26. package/dist/aws/index.d.mts +62 -0
  27. package/dist/aws/index.d.mts.map +1 -0
  28. package/dist/aws/index.mjs +148 -0
  29. package/dist/aws/index.mjs.map +1 -0
  30. package/dist/aws/instances.cjs +176 -0
  31. package/dist/aws/instances.cjs.map +1 -0
  32. package/dist/aws/instances.d.cts +75 -0
  33. package/dist/aws/instances.d.cts.map +1 -0
  34. package/dist/aws/instances.d.mts +75 -0
  35. package/dist/aws/instances.d.mts.map +1 -0
  36. package/dist/aws/instances.mjs +172 -0
  37. package/dist/aws/instances.mjs.map +1 -0
  38. package/dist/aws/lambda-api.cjs +89 -0
  39. package/dist/aws/lambda-api.cjs.map +1 -0
  40. package/dist/aws/lambda-api.d.cts +43 -0
  41. package/dist/aws/lambda-api.d.cts.map +1 -0
  42. package/dist/aws/lambda-api.d.mts +43 -0
  43. package/dist/aws/lambda-api.d.mts.map +1 -0
  44. package/dist/aws/lambda-api.mjs +88 -0
  45. package/dist/aws/lambda-api.mjs.map +1 -0
  46. package/dist/client.d.cts +1 -1
  47. package/dist/client.d.mts +1 -1
  48. package/dist/cloudflare/bindings.cjs +19 -0
  49. package/dist/cloudflare/bindings.cjs.map +1 -1
  50. package/dist/cloudflare/bindings.d.cts +37 -0
  51. package/dist/cloudflare/bindings.d.cts.map +1 -1
  52. package/dist/cloudflare/bindings.d.mts +37 -0
  53. package/dist/cloudflare/bindings.d.mts.map +1 -1
  54. package/dist/cloudflare/bindings.mjs +18 -1
  55. package/dist/cloudflare/bindings.mjs.map +1 -1
  56. package/dist/cloudflare/index.cjs +32 -28
  57. package/dist/cloudflare/index.d.cts +8 -6
  58. package/dist/cloudflare/index.d.mts +8 -6
  59. package/dist/cloudflare/index.mjs +12 -10
  60. package/dist/cloudflare/runner.cjs +27 -76
  61. package/dist/cloudflare/runner.cjs.map +1 -1
  62. package/dist/cloudflare/runner.d.cts +7 -2
  63. package/dist/cloudflare/runner.d.cts.map +1 -1
  64. package/dist/cloudflare/runner.d.mts +7 -2
  65. package/dist/cloudflare/runner.d.mts.map +1 -1
  66. package/dist/cloudflare/runner.mjs +26 -75
  67. package/dist/cloudflare/runner.mjs.map +1 -1
  68. package/dist/cloudflare/worker.cjs +22 -203
  69. package/dist/cloudflare/worker.cjs.map +1 -1
  70. package/dist/cloudflare/worker.d.cts +10 -56
  71. package/dist/cloudflare/worker.d.cts.map +1 -1
  72. package/dist/cloudflare/worker.d.mts +10 -56
  73. package/dist/cloudflare/worker.d.mts.map +1 -1
  74. package/dist/cloudflare/worker.mjs +20 -199
  75. package/dist/cloudflare/worker.mjs.map +1 -1
  76. package/dist/config.cjs +2 -2
  77. package/dist/config.cjs.map +1 -1
  78. package/dist/config.d.cts +1 -1
  79. package/dist/config.d.mts +1 -1
  80. package/dist/config.mjs +2 -2
  81. package/dist/config.mjs.map +1 -1
  82. package/dist/contract.cjs.map +1 -1
  83. package/dist/contract.d.cts +1 -1
  84. package/dist/contract.d.cts.map +1 -1
  85. package/dist/contract.d.mts +1 -1
  86. package/dist/contract.d.mts.map +1 -1
  87. package/dist/contract.mjs.map +1 -1
  88. package/dist/dsl/action.cjs.map +1 -1
  89. package/dist/dsl/action.d.cts +2 -2
  90. package/dist/dsl/action.d.cts.map +1 -1
  91. package/dist/dsl/action.d.mts +2 -2
  92. package/dist/dsl/action.d.mts.map +1 -1
  93. package/dist/dsl/action.mjs.map +1 -1
  94. package/dist/dsl/bundle.cjs.map +1 -1
  95. package/dist/dsl/bundle.d.cts +1 -1
  96. package/dist/dsl/bundle.d.mts +1 -1
  97. package/dist/dsl/bundle.mjs.map +1 -1
  98. package/dist/dsl/flow.cjs.map +1 -1
  99. package/dist/dsl/flow.d.cts +1 -1
  100. package/dist/dsl/flow.d.cts.map +1 -1
  101. package/dist/dsl/flow.d.mts +1 -1
  102. package/dist/dsl/flow.d.mts.map +1 -1
  103. package/dist/dsl/flow.mjs.map +1 -1
  104. package/dist/dsl/refs.cjs.map +1 -1
  105. package/dist/dsl/refs.d.cts +5 -4
  106. package/dist/dsl/refs.d.cts.map +1 -1
  107. package/dist/dsl/refs.d.mts +5 -4
  108. package/dist/dsl/refs.d.mts.map +1 -1
  109. package/dist/dsl/refs.mjs.map +1 -1
  110. package/dist/dsl/template.cjs.map +1 -1
  111. package/dist/dsl/template.d.cts +2 -2
  112. package/dist/dsl/template.d.cts.map +1 -1
  113. package/dist/dsl/template.d.mts +2 -2
  114. package/dist/dsl/template.d.mts.map +1 -1
  115. package/dist/dsl/template.mjs.map +1 -1
  116. package/dist/email-sender.cjs +62 -0
  117. package/dist/email-sender.cjs.map +1 -0
  118. package/dist/email-sender.d.cts +70 -0
  119. package/dist/email-sender.d.cts.map +1 -0
  120. package/dist/email-sender.d.mts +70 -0
  121. package/dist/email-sender.d.mts.map +1 -0
  122. package/dist/email-sender.mjs +60 -0
  123. package/dist/email-sender.mjs.map +1 -0
  124. package/dist/email.cjs +21 -0
  125. package/dist/email.cjs.map +1 -1
  126. package/dist/email.d.cts +6 -0
  127. package/dist/email.d.cts.map +1 -1
  128. package/dist/email.d.mts +6 -0
  129. package/dist/email.d.mts.map +1 -1
  130. package/dist/email.mjs +21 -1
  131. package/dist/email.mjs.map +1 -1
  132. package/dist/engine/index.cjs +1 -1
  133. package/dist/engine/index.mjs +1 -1
  134. package/dist/engine/interpreter.cjs +1 -1
  135. package/dist/engine/interpreter.cjs.map +1 -1
  136. package/dist/engine/interpreter.d.cts.map +1 -1
  137. package/dist/engine/interpreter.d.mts.map +1 -1
  138. package/dist/engine/interpreter.mjs +1 -1
  139. package/dist/engine/interpreter.mjs.map +1 -1
  140. package/dist/engine/ports.d.cts +6 -1
  141. package/dist/engine/ports.d.cts.map +1 -1
  142. package/dist/engine/ports.d.mts +6 -1
  143. package/dist/engine/ports.d.mts.map +1 -1
  144. package/dist/{cloudflare → host}/channels.cjs +13 -11
  145. package/dist/host/channels.cjs.map +1 -0
  146. package/dist/{cloudflare → host}/channels.d.cts +8 -3
  147. package/dist/host/channels.d.cts.map +1 -0
  148. package/dist/{cloudflare → host}/channels.d.mts +8 -3
  149. package/dist/host/channels.d.mts.map +1 -0
  150. package/dist/{cloudflare → host}/channels.mjs +9 -7
  151. package/dist/host/channels.mjs.map +1 -0
  152. package/dist/host/execute.cjs +82 -0
  153. package/dist/host/execute.cjs.map +1 -0
  154. package/dist/host/execute.d.cts +41 -0
  155. package/dist/host/execute.d.cts.map +1 -0
  156. package/dist/host/execute.d.mts +41 -0
  157. package/dist/host/execute.d.mts.map +1 -0
  158. package/dist/host/execute.mjs +81 -0
  159. package/dist/host/execute.mjs.map +1 -0
  160. package/dist/host/journey-host.cjs +214 -0
  161. package/dist/host/journey-host.cjs.map +1 -0
  162. package/dist/host/journey-host.d.cts +105 -0
  163. package/dist/host/journey-host.d.cts.map +1 -0
  164. package/dist/host/journey-host.d.mts +105 -0
  165. package/dist/host/journey-host.d.mts.map +1 -0
  166. package/dist/host/journey-host.mjs +210 -0
  167. package/dist/host/journey-host.mjs.map +1 -0
  168. package/dist/{cloudflare → host}/router.cjs +28 -9
  169. package/dist/host/router.cjs.map +1 -0
  170. package/dist/{cloudflare → host}/router.d.cts +2 -2
  171. package/dist/host/router.d.cts.map +1 -0
  172. package/dist/{cloudflare → host}/router.d.mts +2 -2
  173. package/dist/host/router.d.mts.map +1 -0
  174. package/dist/{cloudflare → host}/router.mjs +28 -9
  175. package/dist/host/router.mjs.map +1 -0
  176. package/dist/host/senders.cjs +63 -0
  177. package/dist/host/senders.cjs.map +1 -0
  178. package/dist/host/senders.d.cts +31 -0
  179. package/dist/host/senders.d.cts.map +1 -0
  180. package/dist/host/senders.d.mts +31 -0
  181. package/dist/host/senders.d.mts.map +1 -0
  182. package/dist/host/senders.mjs +59 -0
  183. package/dist/host/senders.mjs.map +1 -0
  184. package/dist/http2-client.cjs +88 -0
  185. package/dist/http2-client.cjs.map +1 -0
  186. package/dist/http2-client.d.cts +44 -0
  187. package/dist/http2-client.d.cts.map +1 -0
  188. package/dist/http2-client.d.mts +44 -0
  189. package/dist/http2-client.d.mts.map +1 -0
  190. package/dist/http2-client.mjs +86 -0
  191. package/dist/http2-client.mjs.map +1 -0
  192. package/dist/index.cjs +1 -0
  193. package/dist/index.d.cts +2 -2
  194. package/dist/index.d.mts +2 -2
  195. package/dist/index.mjs +2 -2
  196. package/dist/react-email.cjs.map +1 -1
  197. package/dist/react-email.d.cts +1 -2
  198. package/dist/react-email.d.cts.map +1 -1
  199. package/dist/react-email.d.mts +1 -2
  200. package/dist/react-email.d.mts.map +1 -1
  201. package/dist/react-email.mjs.map +1 -1
  202. package/dist/reporting/tracking.cjs +5 -1
  203. package/dist/reporting/tracking.cjs.map +1 -1
  204. package/dist/reporting/tracking.d.cts.map +1 -1
  205. package/dist/reporting/tracking.d.mts.map +1 -1
  206. package/dist/reporting/tracking.mjs +5 -1
  207. package/dist/reporting/tracking.mjs.map +1 -1
  208. package/dist/store/d1.cjs +1 -0
  209. package/dist/store/d1.cjs.map +1 -1
  210. package/dist/store/d1.d.cts.map +1 -1
  211. package/dist/store/d1.d.mts.map +1 -1
  212. package/dist/store/d1.mjs +1 -0
  213. package/dist/store/d1.mjs.map +1 -1
  214. package/dist/store/index.cjs.map +1 -1
  215. package/dist/store/index.d.cts +63 -0
  216. package/dist/store/index.d.cts.map +1 -1
  217. package/dist/store/index.d.mts +63 -0
  218. package/dist/store/index.d.mts.map +1 -1
  219. package/dist/store/index.mjs.map +1 -1
  220. package/dist/store/postgres-reporting.cjs +5 -5
  221. package/dist/store/postgres-reporting.cjs.map +1 -1
  222. package/dist/store/postgres-reporting.mjs +5 -5
  223. package/dist/store/postgres-reporting.mjs.map +1 -1
  224. package/dist/store/postgres.cjs +143 -2
  225. package/dist/store/postgres.cjs.map +1 -1
  226. package/dist/store/postgres.d.cts +4 -1
  227. package/dist/store/postgres.d.cts.map +1 -1
  228. package/dist/store/postgres.d.mts +4 -1
  229. package/dist/store/postgres.d.mts.map +1 -1
  230. package/dist/store/postgres.mjs +143 -2
  231. package/dist/store/postgres.mjs.map +1 -1
  232. package/dist/store/tables.cjs +26 -0
  233. package/dist/store/tables.cjs.map +1 -1
  234. package/dist/store/tables.d.cts +1 -0
  235. package/dist/store/tables.d.cts.map +1 -1
  236. package/dist/store/tables.d.mts +1 -0
  237. package/dist/store/tables.d.mts.map +1 -1
  238. package/dist/store/tables.mjs +26 -0
  239. package/dist/store/tables.mjs.map +1 -1
  240. package/dist/templates.cjs.map +1 -1
  241. package/dist/templates.mjs.map +1 -1
  242. package/dist/transports/cloudflare-email.cjs +59 -0
  243. package/dist/transports/cloudflare-email.cjs.map +1 -0
  244. package/dist/transports/cloudflare-email.d.cts +38 -0
  245. package/dist/transports/cloudflare-email.d.cts.map +1 -0
  246. package/dist/transports/cloudflare-email.d.mts +38 -0
  247. package/dist/transports/cloudflare-email.d.mts.map +1 -0
  248. package/dist/transports/cloudflare-email.mjs +57 -0
  249. package/dist/transports/cloudflare-email.mjs.map +1 -0
  250. package/dist/transports/ses.cjs +91 -0
  251. package/dist/transports/ses.cjs.map +1 -0
  252. package/dist/transports/ses.d.cts +39 -0
  253. package/dist/transports/ses.d.cts.map +1 -0
  254. package/dist/transports/ses.d.mts +39 -0
  255. package/dist/transports/ses.d.mts.map +1 -0
  256. package/dist/transports/ses.mjs +89 -0
  257. package/dist/transports/ses.mjs.map +1 -0
  258. package/dist/transports/sigv4.cjs +83 -0
  259. package/dist/transports/sigv4.cjs.map +1 -0
  260. package/dist/transports/sigv4.d.cts +27 -0
  261. package/dist/transports/sigv4.d.cts.map +1 -0
  262. package/dist/transports/sigv4.d.mts +27 -0
  263. package/dist/transports/sigv4.d.mts.map +1 -0
  264. package/dist/transports/sigv4.mjs +82 -0
  265. package/dist/transports/sigv4.mjs.map +1 -0
  266. package/package.json +53 -1
  267. package/dist/cloudflare/channels.cjs.map +0 -1
  268. package/dist/cloudflare/channels.d.cts.map +0 -1
  269. package/dist/cloudflare/channels.d.mts.map +0 -1
  270. package/dist/cloudflare/channels.mjs.map +0 -1
  271. package/dist/cloudflare/router.cjs.map +0 -1
  272. package/dist/cloudflare/router.d.cts.map +0 -1
  273. package/dist/cloudflare/router.d.mts.map +0 -1
  274. package/dist/cloudflare/router.mjs.map +0 -1
  275. package/dist/cloudflare/senders.cjs +0 -121
  276. package/dist/cloudflare/senders.cjs.map +0 -1
  277. package/dist/cloudflare/senders.d.cts +0 -72
  278. package/dist/cloudflare/senders.d.cts.map +0 -1
  279. package/dist/cloudflare/senders.d.mts +0 -72
  280. package/dist/cloudflare/senders.d.mts.map +0 -1
  281. package/dist/cloudflare/senders.mjs +0 -115
  282. package/dist/cloudflare/senders.mjs.map +0 -1
@@ -47,10 +47,11 @@ export type EventRefs<E> = { readonly [K in keyof E]-?: EventRef<E[K]>; };
47
47
  */
48
48
  export declare function user<U extends UserPropertyBase>(): UserRefs<U>;
49
49
  export declare function event<E extends EventMap>(): EventRefs<E>;
50
- export type PropInput<T> = T | UserPropertyRef<T>;
50
+ /** An impossible field cannot be supplied indirectly through a profile reference either. */
51
+ export type PropInput<T> = [T] extends [never] ? never : T | UserPropertyRef<T>;
51
52
  export type PropsInput<P> = { [K in keyof P]: PropInput<P[K]>; };
52
53
  /** Props may be omitted when a template declares none, and are required when it does. */
53
- export type MessageArgs<P> = Record<never, never> extends P ? [props?: PropsInput<P>] : [props: PropsInput<P>];
54
+ export type MessageArgs<P> = Record<string, never> extends P ? [props?: PropsInput<P>] : [props: PropsInput<P>];
54
55
  /**
55
56
  * Where a payload ref keeps its own state. A payload ref doubles as the table
56
57
  * of its sub-fields, so every *string* key it answered for itself shadowed an
@@ -74,9 +75,9 @@ export interface PayloadRef<T> {
74
75
  readonly __t?: T;
75
76
  }
76
77
  /** A payload field ref that is itself an object also acts as a table of its sub-field refs. */
77
- export type PayloadRefNode<T> = [NonNullable<T>] extends [object] ? PayloadRef<T> & { readonly [K in keyof NonNullable<T> & string]-?: PayloadRefNode<NonNullable<T>[K]>; } : PayloadRef<T>;
78
+ export type PayloadRefNode<T> = [NonNullable<T>] extends [never] ? PayloadRef<T> : [NonNullable<T>] extends [object] ? PayloadRef<T> & { readonly [K in keyof NonNullable<T> & string as [NonNullable<T>[K]] extends [never] ? never : K]-?: PayloadRefNode<NonNullable<T>[K]>; } : PayloadRef<T>;
78
79
  /** Payload reference table for an event's payload P — the `p` in `where: (p) => eq(p.platform, 'web')`. */
79
- export type PayloadRefs<P> = [P] extends [object] ? { readonly [K in keyof P & string]-?: PayloadRefNode<P[K]>; } : Record<string, PayloadRef<unknown>>;
80
+ export type PayloadRefs<P> = [P] extends [object] ? { readonly [K in keyof P & string as [P[K]] extends [never] ? never : K]-?: PayloadRefNode<P[K]>; } : Record<string, PayloadRef<unknown>>;
80
81
  /**
81
82
  * Nested Proxy: every string key extends the dotted path, so an author's field
82
83
  * may be named anything — including `path` or `type`. The ref's own path reads
@@ -1 +1 @@
1
- {"version":3,"file":"refs.d.mts","names":[],"sources":["../../src/dsl/refs.ts"],"mappings":";;;;;;;;;;;;;;;;;;iBAmBiB,gBAAgB;WACtB;WACA;WACA,MAAM;;;iBAIA,SAAS;WACf;WACA;WACA,MAAM;;;YAIL,SAAS,iBAAiB,WAAW,MAAM,gBAAgB,EAAE;;YAG7D,UAAU,iBAAiB,WAAW,MAAM,SAAS,EAAE;;;;;;;;;;;;;;;wBAgBnD,KAAK,UAAU,qBAAqB,SAAS;wBAM7C,MAAM,UAAU,aAAa,UAAU;YAM3C,UAAU,KAAK,IAAI,gBAAgB;YACnC,WAAW,QAAQ,WAAW,IAAI,UAAU,EAAE;;YAG9C,YAAY,KACtB,6BAA6B,KAAK,QAAQ,WAAW,OAAO,OAAO,WAAW;;;;;;;;;;;qBAcnE;;;;;;;;iBASI,WAAW;YAChB;WACD,MAAM;;;YAIL,eAAe,MAAM,YAAY,uBACzC,WAAW,iBACC,WAAW,YAAY,gBAAgB,eAAe,YAAY,GAAG,SAEjF,WAAW;;YAGH,YAAY,MAAM,kCACd,WAAW,eAAe,eAAe,EAAE,SACvD,eAAe;;;;;;;wBAQH,YAAY,GAAG,kBAAc,YAAY"}
1
+ {"version":3,"file":"refs.d.mts","names":[],"sources":["../../src/dsl/refs.ts"],"mappings":";;;;;;;;;;;;;;;;;;iBAmBiB,gBAAgB;WACtB;WACA;WACA,MAAM;;;iBAIA,SAAS;WACf;WACA;WACA,MAAM;;;YAIL,SAAS,iBAAiB,WAAW,MAAM,gBAAgB,EAAE;;YAG7D,UAAU,iBAAiB,WAAW,MAAM,SAAS,EAAE;;;;;;;;;;;;;;;wBAgBnD,KAAK,UAAU,qBAAqB,SAAS;wBAM7C,MAAM,UAAU,aAAa,UAAU;;YAO3C,UAAU,MAAM,6BAA6B,IAAI,gBAAgB;YACjE,WAAW,QAAQ,WAAW,IAAI,UAAU,EAAE;;YAG9C,YAAY,KACtB,8BAA8B,KAAK,QAAQ,WAAW,OAAO,OAAO,WAAW;;;;;;;;;;;qBAcpE;;;;;;;;iBASI,WAAW;YAChB;WACD,MAAM;;;YAIL,eAAe,MAAM,YAAY,sBACzC,WAAW,MACV,YAAY,uBACX,WAAW,iBAEP,WAAW,YAAY,gBAAgB,YAAY,GAAG,8BAA8B,MACjF,eAAe,YAAY,GAAG,SAErC,WAAW;;YAGL,YAAY,MAAM,kCAEd,WAAW,eAAe,EAAE,8BAA8B,MAAM,eACxE,EAAE,SAGN,eAAe;;;;;;;wBAQH,YAAY,GAAG,kBAAc,YAAY"}
@@ -1 +1 @@
1
- {"version":3,"file":"refs.mjs","names":[],"sources":["../../src/dsl/refs.ts"],"sourcesContent":["import type { EventMap, UserPropertyBase } from './base';\n\n/**\n * Reference tables — the DSL's only two type-injection points (drizzle's\n * column-object pattern): `u = user<UserProperty>()` and `e = event<Event>()`.\n * Every other function in the DSL is generic-free and reads types off these\n * references.\n *\n * Type technique: phantom types. `UserPropertyRef<T>` carries the property's\n * type in an optional, never-assigned `__t` — purely so free functions like\n * `eq` can infer T from the reference. At runtime a ref is just\n * `{ type, path }`, produced by a Proxy on property access.\n */\n\n/**\n * User-property reference (drizzle's column equivalent), shared by predicates\n * (eq/gt/…) and message-prop personalization. The phantom type __t is how a\n * free function reads the property's type off the reference.\n */\nexport interface UserPropertyRef<T> {\n readonly type: 'user_property';\n readonly path: string;\n readonly __t?: T;\n}\n\n/** Event reference: the argument to `performed`; future payload where-clauses and value refs hang off it. */\nexport interface EventRef<P = unknown> {\n readonly type: 'event_ref';\n readonly name: string;\n readonly __p?: P;\n}\n\n/** Property reference table: `u.subscription_status` is a UserPropertyRef (drizzle's `users.email`). */\nexport type UserRefs<U> = { readonly [K in keyof U]-?: UserPropertyRef<U[K]> };\n\n/** Event reference table: `e.purchase` is an EventRef. */\nexport type EventRefs<E> = { readonly [K in keyof E]-?: EventRef<E[K]> };\n\n/**\n * Build the reference tables: the app calls each once in its schema module and\n * exports the result (implemented with a Proxy — property access *is* the reference).\n *\n * export const u = user<UserProperty>();\n * export const e = event<Event>();\n *\n * From then on predicates and personalization are all property access:\n * eq(u.subscription_status, 'active'), performed(e.purchase, { within: '30 days' }),\n * { plan: u.subscription_plan }.\n *\n * Properties are deliberately single-level: the data source is a database\n * table and is naturally flat, so nested paths are out of scope.\n */\nexport function user<U extends UserPropertyBase>(): UserRefs<U> {\n return new Proxy({} as UserRefs<U>, {\n get: (_target, prop) => ({ type: 'user_property', path: String(prop) }),\n });\n}\n\nexport function event<E extends EventMap>(): EventRefs<E> {\n return new Proxy({} as EventRefs<E>, {\n get: (_target, prop) => ({ type: 'event_ref', name: String(prop) }),\n });\n}\n\nexport type PropInput<T> = T | UserPropertyRef<T>;\nexport type PropsInput<P> = { [K in keyof P]: PropInput<P[K]> };\n\n/** Props may be omitted when a template declares none, and are required when it does. */\nexport type MessageArgs<P> =\n Record<never, never> extends P ? [props?: PropsInput<P>] : [props: PropsInput<P>];\n\n/* ------------------------------ payload references ----------------------------- */\n\n/**\n * Where a payload ref keeps its own state. A payload ref doubles as the table\n * of its sub-fields, so every *string* key it answered for itself shadowed an\n * author's field of that name: with a payload field called `path`,\n * `performed(e.page_view, (p) => eq(p.path, '/pricing'))` type-checked, handed\n * `prop()` the ref's own prefix instead of a sub-ref, and compiled to a\n * *profile* predicate on a property named 'path' — a mistake that only\n * surfaced later, as a zod error nowhere near the line that caused it. A symbol\n * key cannot be reached by `p.<field>`, so no field name can collide with it.\n */\nexport const PAYLOAD_PATH: unique symbol = Symbol('workflow.payloadPath');\n\n/**\n * Event-payload reference: what a `where` callback's parameter hands out. The\n * path it carries is dotted for nested fields ('tags.utm_source'). Predicates\n * accept it alongside UserPropertyRef and compile to a `payload` condition —\n * which is only valid inside `performed({ where })` / the event trigger's\n * `where` (compileBundle enforces the placement).\n */\nexport interface PayloadRef<T> {\n readonly [PAYLOAD_PATH]: string;\n readonly __t?: T;\n}\n\n/** A payload field ref that is itself an object also acts as a table of its sub-field refs. */\nexport type PayloadRefNode<T> = [NonNullable<T>] extends [object]\n ? PayloadRef<T> & {\n readonly [K in keyof NonNullable<T> & string]-?: PayloadRefNode<NonNullable<T>[K]>;\n }\n : PayloadRef<T>;\n\n/** Payload reference table for an event's payload P — the `p` in `where: (p) => eq(p.platform, 'web')`. */\nexport type PayloadRefs<P> = [P] extends [object]\n ? { readonly [K in keyof P & string]-?: PayloadRefNode<P[K]> }\n : Record<string, PayloadRef<unknown>>;\n\n/**\n * Nested Proxy: every string key extends the dotted path, so an author's field\n * may be named anything — including `path` or `type`. The ref's own path reads\n * off PAYLOAD_PATH, the one key a field name cannot spell.\n * @internal used by performed() and trigger.event() to hand `p` to a where callback\n */\nexport function payloadRefs<P>(prefix = ''): PayloadRefs<P> {\n return new Proxy({} as PayloadRefs<P>, {\n get: (_target, prop) => {\n if (prop === PAYLOAD_PATH) return prefix;\n // Other symbols are the runtime's own probes (inspection, iteration, thenable checks), never fields\n if (typeof prop === 'symbol') return undefined;\n return payloadRefs(prefix === '' ? prop : `${prefix}.${prop}`);\n },\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;AAoDA,SAAgB,OAAgD;CAC9D,OAAO,IAAI,MAAM,CAAC,GAAkB,EAClC,MAAM,SAAS,UAAU;EAAE,MAAM;EAAiB,MAAM,OAAO,IAAI;CAAE,GACvE,CAAC;AACH;AAEA,SAAgB,QAA0C;CACxD,OAAO,IAAI,MAAM,CAAC,GAAmB,EACnC,MAAM,SAAS,UAAU;EAAE,MAAM;EAAa,MAAM,OAAO,IAAI;CAAE,GACnE,CAAC;AACH;;;;;;;;;;;AAqBA,MAAa,eAA8B,OAAO,sBAAsB;;;;;;;AAgCxE,SAAgB,YAAe,SAAS,IAAoB;CAC1D,OAAO,IAAI,MAAM,CAAC,GAAqB,EACrC,MAAM,SAAS,SAAS;EACtB,IAAI,SAAS,cAAc,OAAO;EAElC,IAAI,OAAO,SAAS,UAAU,OAAO,KAAA;EACrC,OAAO,YAAY,WAAW,KAAK,OAAO,GAAG,OAAO,GAAG,MAAM;CAC/D,EACF,CAAC;AACH"}
1
+ {"version":3,"file":"refs.mjs","names":[],"sources":["../../src/dsl/refs.ts"],"sourcesContent":["import type { EventMap, UserPropertyBase } from './base';\n\n/**\n * Reference tables — the DSL's only two type-injection points (drizzle's\n * column-object pattern): `u = user<UserProperty>()` and `e = event<Event>()`.\n * Every other function in the DSL is generic-free and reads types off these\n * references.\n *\n * Type technique: phantom types. `UserPropertyRef<T>` carries the property's\n * type in an optional, never-assigned `__t` — purely so free functions like\n * `eq` can infer T from the reference. At runtime a ref is just\n * `{ type, path }`, produced by a Proxy on property access.\n */\n\n/**\n * User-property reference (drizzle's column equivalent), shared by predicates\n * (eq/gt/…) and message-prop personalization. The phantom type __t is how a\n * free function reads the property's type off the reference.\n */\nexport interface UserPropertyRef<T> {\n readonly type: 'user_property';\n readonly path: string;\n readonly __t?: T;\n}\n\n/** Event reference: the argument to `performed`; future payload where-clauses and value refs hang off it. */\nexport interface EventRef<P = unknown> {\n readonly type: 'event_ref';\n readonly name: string;\n readonly __p?: P;\n}\n\n/** Property reference table: `u.subscription_status` is a UserPropertyRef (drizzle's `users.email`). */\nexport type UserRefs<U> = { readonly [K in keyof U]-?: UserPropertyRef<U[K]> };\n\n/** Event reference table: `e.purchase` is an EventRef. */\nexport type EventRefs<E> = { readonly [K in keyof E]-?: EventRef<E[K]> };\n\n/**\n * Build the reference tables: the app calls each once in its schema module and\n * exports the result (implemented with a Proxy — property access *is* the reference).\n *\n * export const u = user<UserProperty>();\n * export const e = event<Event>();\n *\n * From then on predicates and personalization are all property access:\n * eq(u.subscription_status, 'active'), performed(e.purchase, { within: '30 days' }),\n * { plan: u.subscription_plan }.\n *\n * Properties are deliberately single-level: the data source is a database\n * table and is naturally flat, so nested paths are out of scope.\n */\nexport function user<U extends UserPropertyBase>(): UserRefs<U> {\n return new Proxy({} as UserRefs<U>, {\n get: (_target, prop) => ({ type: 'user_property', path: String(prop) }),\n });\n}\n\nexport function event<E extends EventMap>(): EventRefs<E> {\n return new Proxy({} as EventRefs<E>, {\n get: (_target, prop) => ({ type: 'event_ref', name: String(prop) }),\n });\n}\n\n/** An impossible field cannot be supplied indirectly through a profile reference either. */\nexport type PropInput<T> = [T] extends [never] ? never : T | UserPropertyRef<T>;\nexport type PropsInput<P> = { [K in keyof P]: PropInput<P[K]> };\n\n/** Props may be omitted when a template declares none, and are required when it does. */\nexport type MessageArgs<P> =\n Record<string, never> extends P ? [props?: PropsInput<P>] : [props: PropsInput<P>];\n\n/* ------------------------------ payload references ----------------------------- */\n\n/**\n * Where a payload ref keeps its own state. A payload ref doubles as the table\n * of its sub-fields, so every *string* key it answered for itself shadowed an\n * author's field of that name: with a payload field called `path`,\n * `performed(e.page_view, (p) => eq(p.path, '/pricing'))` type-checked, handed\n * `prop()` the ref's own prefix instead of a sub-ref, and compiled to a\n * *profile* predicate on a property named 'path' — a mistake that only\n * surfaced later, as a zod error nowhere near the line that caused it. A symbol\n * key cannot be reached by `p.<field>`, so no field name can collide with it.\n */\nexport const PAYLOAD_PATH: unique symbol = Symbol('workflow.payloadPath');\n\n/**\n * Event-payload reference: what a `where` callback's parameter hands out. The\n * path it carries is dotted for nested fields ('tags.utm_source'). Predicates\n * accept it alongside UserPropertyRef and compile to a `payload` condition —\n * which is only valid inside `performed({ where })` / the event trigger's\n * `where` (compileBundle enforces the placement).\n */\nexport interface PayloadRef<T> {\n readonly [PAYLOAD_PATH]: string;\n readonly __t?: T;\n}\n\n/** A payload field ref that is itself an object also acts as a table of its sub-field refs. */\nexport type PayloadRefNode<T> = [NonNullable<T>] extends [never]\n ? PayloadRef<T>\n : [NonNullable<T>] extends [object]\n ? PayloadRef<T> & {\n readonly [\n K in keyof NonNullable<T> & string as [NonNullable<T>[K]] extends [never] ? never : K\n ]-?: PayloadRefNode<NonNullable<T>[K]>;\n }\n : PayloadRef<T>;\n\n/** Payload reference table for an event's payload P — the `p` in `where: (p) => eq(p.platform, 'web')`. */\nexport type PayloadRefs<P> = [P] extends [object]\n ? {\n readonly [K in keyof P & string as [P[K]] extends [never] ? never : K]-?: PayloadRefNode<\n P[K]\n >;\n }\n : Record<string, PayloadRef<unknown>>;\n\n/**\n * Nested Proxy: every string key extends the dotted path, so an author's field\n * may be named anything — including `path` or `type`. The ref's own path reads\n * off PAYLOAD_PATH, the one key a field name cannot spell.\n * @internal used by performed() and trigger.event() to hand `p` to a where callback\n */\nexport function payloadRefs<P>(prefix = ''): PayloadRefs<P> {\n return new Proxy({} as PayloadRefs<P>, {\n get: (_target, prop) => {\n if (prop === PAYLOAD_PATH) return prefix;\n // Other symbols are the runtime's own probes (inspection, iteration, thenable checks), never fields\n if (typeof prop === 'symbol') return undefined;\n return payloadRefs(prefix === '' ? prop : `${prefix}.${prop}`);\n },\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;AAoDA,SAAgB,OAAgD;CAC9D,OAAO,IAAI,MAAM,CAAC,GAAkB,EAClC,MAAM,SAAS,UAAU;EAAE,MAAM;EAAiB,MAAM,OAAO,IAAI;CAAE,GACvE,CAAC;AACH;AAEA,SAAgB,QAA0C;CACxD,OAAO,IAAI,MAAM,CAAC,GAAmB,EACnC,MAAM,SAAS,UAAU;EAAE,MAAM;EAAa,MAAM,OAAO,IAAI;CAAE,GACnE,CAAC;AACH;;;;;;;;;;;AAsBA,MAAa,eAA8B,OAAO,sBAAsB;;;;;;;AAwCxE,SAAgB,YAAe,SAAS,IAAoB;CAC1D,OAAO,IAAI,MAAM,CAAC,GAAqB,EACrC,MAAM,SAAS,SAAS;EACtB,IAAI,SAAS,cAAc,OAAO;EAElC,IAAI,OAAO,SAAS,UAAU,OAAO,KAAA;EACrC,OAAO,YAAY,WAAW,KAAK,OAAO,GAAG,OAAO,GAAG,MAAM;CAC/D,EACF,CAAC;AACH"}
@@ -1 +1 @@
1
- {"version":3,"file":"template.cjs","names":[],"sources":["../../src/dsl/template.ts"],"sourcesContent":["/**\n * Templates in the DSL. The four channels with template definitions (email, push, discord,\n * slack) take the definition itself — `.email(welcome, props)` — so its Zod `props` schema types\n * the call (`PropsOf`) and its `id` lands in the IR. The channels rendered by app-supplied\n * senders (sms, in-app, survey) have no definition; they take a hand-typed reference by key.\n */\n\nimport type * as core from 'zod/v4/core';\nimport type { DiscordTemplate, EmailTemplate, PushTemplate, SlackTemplate } from '../templates';\n\nexport type Channel = 'email' | 'sms' | 'push' | 'in_app' | 'slack' | 'discord' | 'survey';\n\nexport type EmptyProps = Record<never, never>;\n\n/** A template by key, its props typed by hand: for the channels without template definitions. */\nexport interface TemplateRef<C extends Channel = Channel, P extends object = EmptyProps> {\n readonly channel: C;\n readonly key: string;\n readonly __props?: P;\n}\n\nexport interface TemplateFactory {\n sms<P extends object = EmptyProps>(key: string): TemplateRef<'sms', P>;\n inApp<P extends object = EmptyProps>(key: string): TemplateRef<'in_app', P>;\n survey<P extends object = EmptyProps>(key: string): TemplateRef<'survey', P>;\n}\n\nconst makeTemplate =\n <C extends Channel>(channel: C) =>\n <P extends object = EmptyProps>(key: string): TemplateRef<C, P> => ({ channel, key });\n\n/**\n * Template references for the channels an app-supplied sender renders:\n *\n * const code = template.sms<{ code: string }>('login_code');\n */\nexport const template: TemplateFactory = {\n sms: makeTemplate('sms'),\n inApp: makeTemplate('in_app'),\n survey: makeTemplate('survey'),\n};\n\n/** Template props: the input type of the template's Zod `props` schema — what the DSL passes. */\nexport type PropsOf<M> = M extends\n | EmailTemplate<infer S>\n | PushTemplate<infer S>\n | DiscordTemplate<infer S, never>\n | SlackTemplate<infer S, never>\n ? core.input<S> extends object\n ? core.input<S>\n : EmptyProps\n : never;\n"],"mappings":";;AA2BA,MAAM,gBACgB,aACY,SAAoC;CAAE;CAAS;AAAI;;;;;;AAOrF,MAAa,WAA4B;CACvC,KAAK,aAAa,KAAK;CACvB,OAAO,aAAa,QAAQ;CAC5B,QAAQ,aAAa,QAAQ;AAC/B"}
1
+ {"version":3,"file":"template.cjs","names":[],"sources":["../../src/dsl/template.ts"],"sourcesContent":["/**\n * Templates in the DSL. The four channels with template definitions (email, push, discord,\n * slack) take the definition itself — `.email(welcome, props)` — so its Zod `props` schema types\n * the call (`PropsOf`) and its `id` lands in the IR. The channels rendered by app-supplied\n * senders (sms, in-app, survey) have no definition; they take a hand-typed reference by key.\n */\n\nimport type * as core from 'zod/v4/core';\nimport type { DiscordTemplate, EmailTemplate, PushTemplate, SlackTemplate } from '../templates';\n\nexport type Channel = 'email' | 'sms' | 'push' | 'in_app' | 'slack' | 'discord' | 'survey';\n\nexport type EmptyProps = Record<string, never>;\n\n/** A template by key, its props typed by hand: for the channels without template definitions. */\nexport interface TemplateRef<C extends Channel = Channel, P extends object = object> {\n readonly channel: C;\n readonly key: string;\n readonly __props?: P;\n}\n\nexport interface TemplateFactory {\n sms<P extends object = EmptyProps>(key: string): TemplateRef<'sms', P>;\n inApp<P extends object = EmptyProps>(key: string): TemplateRef<'in_app', P>;\n survey<P extends object = EmptyProps>(key: string): TemplateRef<'survey', P>;\n}\n\nconst makeTemplate =\n <C extends Channel>(channel: C) =>\n <P extends object = EmptyProps>(key: string): TemplateRef<C, P> => ({ channel, key });\n\n/**\n * Template references for the channels an app-supplied sender renders:\n *\n * const code = template.sms<{ code: string }>('login_code');\n */\nexport const template: TemplateFactory = {\n sms: makeTemplate('sms'),\n inApp: makeTemplate('in_app'),\n survey: makeTemplate('survey'),\n};\n\n/** Template props: the input type of the template's Zod `props` schema — what the DSL passes. */\nexport type PropsOf<M> = M extends\n | EmailTemplate<infer S>\n | PushTemplate<infer S>\n | DiscordTemplate<infer S, never>\n | SlackTemplate<infer S, never>\n ? core.input<S> extends object\n ? core.input<S>\n : EmptyProps\n : never;\n"],"mappings":";;AA2BA,MAAM,gBACgB,aACY,SAAoC;CAAE;CAAS;AAAI;;;;;;AAOrF,MAAa,WAA4B;CACvC,KAAK,aAAa,KAAK;CACvB,OAAO,aAAa,QAAQ;CAC5B,QAAQ,aAAa,QAAQ;AAC/B"}
@@ -2,9 +2,9 @@ import { DiscordTemplate, EmailTemplate, PushTemplate, SlackTemplate } from "../
2
2
  import * as core from "zod/v4/core";
3
3
  //#region src/dsl/template.d.ts
4
4
  export type Channel = 'email' | 'sms' | 'push' | 'in_app' | 'slack' | 'discord' | 'survey';
5
- export type EmptyProps = Record<never, never>;
5
+ export type EmptyProps = Record<string, never>;
6
6
  /** A template by key, its props typed by hand: for the channels without template definitions. */
7
- export interface TemplateRef<C extends Channel = Channel, P extends object = EmptyProps> {
7
+ export interface TemplateRef<C extends Channel = Channel, P extends object = object> {
8
8
  readonly channel: C;
9
9
  readonly key: string;
10
10
  readonly __props?: P;
@@ -1 +1 @@
1
- {"version":3,"file":"template.d.cts","names":[],"sources":["../../src/dsl/template.ts"],"mappings":";;;YAUY;YAEA,aAAa;;iBAGR,YAAY,UAAU,UAAU,SAAS,mBAAmB;WAClE,SAAS;WACT;WACA,UAAU;;iBAGJ;EACf,IAAI,mBAAmB,YAAY,cAAc,mBAAmB;EACpE,MAAM,mBAAmB,YAAY,cAAc,sBAAsB;EACzE,OAAO,mBAAmB,YAAY,cAAc,sBAAsB;;;;;;;qBAY/D,UAAU;;YAOX,QAAQ,KAAK,UACrB,oBAAoB,KACpB,mBAAmB,KACnB,sBAAsB,YACtB,oBAAoB,YACpB,KAAK,MAAM,oBACT,KAAK,MAAM,KACX"}
1
+ {"version":3,"file":"template.d.cts","names":[],"sources":["../../src/dsl/template.ts"],"mappings":";;;YAUY;YAEA,aAAa;;iBAGR,YAAY,UAAU,UAAU,SAAS;WAC/C,SAAS;WACT;WACA,UAAU;;iBAGJ;EACf,IAAI,mBAAmB,YAAY,cAAc,mBAAmB;EACpE,MAAM,mBAAmB,YAAY,cAAc,sBAAsB;EACzE,OAAO,mBAAmB,YAAY,cAAc,sBAAsB;;;;;;;qBAY/D,UAAU;;YAOX,QAAQ,KAAK,UACrB,oBAAoB,KACpB,mBAAmB,KACnB,sBAAsB,YACtB,oBAAoB,YACpB,KAAK,MAAM,oBACT,KAAK,MAAM,KACX"}
@@ -2,9 +2,9 @@ import { DiscordTemplate, EmailTemplate, PushTemplate, SlackTemplate } from "../
2
2
  import * as core from "zod/v4/core";
3
3
  //#region src/dsl/template.d.ts
4
4
  export type Channel = 'email' | 'sms' | 'push' | 'in_app' | 'slack' | 'discord' | 'survey';
5
- export type EmptyProps = Record<never, never>;
5
+ export type EmptyProps = Record<string, never>;
6
6
  /** A template by key, its props typed by hand: for the channels without template definitions. */
7
- export interface TemplateRef<C extends Channel = Channel, P extends object = EmptyProps> {
7
+ export interface TemplateRef<C extends Channel = Channel, P extends object = object> {
8
8
  readonly channel: C;
9
9
  readonly key: string;
10
10
  readonly __props?: P;
@@ -1 +1 @@
1
- {"version":3,"file":"template.d.mts","names":[],"sources":["../../src/dsl/template.ts"],"mappings":";;;YAUY;YAEA,aAAa;;iBAGR,YAAY,UAAU,UAAU,SAAS,mBAAmB;WAClE,SAAS;WACT;WACA,UAAU;;iBAGJ;EACf,IAAI,mBAAmB,YAAY,cAAc,mBAAmB;EACpE,MAAM,mBAAmB,YAAY,cAAc,sBAAsB;EACzE,OAAO,mBAAmB,YAAY,cAAc,sBAAsB;;;;;;;qBAY/D,UAAU;;YAOX,QAAQ,KAAK,UACrB,oBAAoB,KACpB,mBAAmB,KACnB,sBAAsB,YACtB,oBAAoB,YACpB,KAAK,MAAM,oBACT,KAAK,MAAM,KACX"}
1
+ {"version":3,"file":"template.d.mts","names":[],"sources":["../../src/dsl/template.ts"],"mappings":";;;YAUY;YAEA,aAAa;;iBAGR,YAAY,UAAU,UAAU,SAAS;WAC/C,SAAS;WACT;WACA,UAAU;;iBAGJ;EACf,IAAI,mBAAmB,YAAY,cAAc,mBAAmB;EACpE,MAAM,mBAAmB,YAAY,cAAc,sBAAsB;EACzE,OAAO,mBAAmB,YAAY,cAAc,sBAAsB;;;;;;;qBAY/D,UAAU;;YAOX,QAAQ,KAAK,UACrB,oBAAoB,KACpB,mBAAmB,KACnB,sBAAsB,YACtB,oBAAoB,YACpB,KAAK,MAAM,oBACT,KAAK,MAAM,KACX"}
@@ -1 +1 @@
1
- {"version":3,"file":"template.mjs","names":[],"sources":["../../src/dsl/template.ts"],"sourcesContent":["/**\n * Templates in the DSL. The four channels with template definitions (email, push, discord,\n * slack) take the definition itself — `.email(welcome, props)` — so its Zod `props` schema types\n * the call (`PropsOf`) and its `id` lands in the IR. The channels rendered by app-supplied\n * senders (sms, in-app, survey) have no definition; they take a hand-typed reference by key.\n */\n\nimport type * as core from 'zod/v4/core';\nimport type { DiscordTemplate, EmailTemplate, PushTemplate, SlackTemplate } from '../templates';\n\nexport type Channel = 'email' | 'sms' | 'push' | 'in_app' | 'slack' | 'discord' | 'survey';\n\nexport type EmptyProps = Record<never, never>;\n\n/** A template by key, its props typed by hand: for the channels without template definitions. */\nexport interface TemplateRef<C extends Channel = Channel, P extends object = EmptyProps> {\n readonly channel: C;\n readonly key: string;\n readonly __props?: P;\n}\n\nexport interface TemplateFactory {\n sms<P extends object = EmptyProps>(key: string): TemplateRef<'sms', P>;\n inApp<P extends object = EmptyProps>(key: string): TemplateRef<'in_app', P>;\n survey<P extends object = EmptyProps>(key: string): TemplateRef<'survey', P>;\n}\n\nconst makeTemplate =\n <C extends Channel>(channel: C) =>\n <P extends object = EmptyProps>(key: string): TemplateRef<C, P> => ({ channel, key });\n\n/**\n * Template references for the channels an app-supplied sender renders:\n *\n * const code = template.sms<{ code: string }>('login_code');\n */\nexport const template: TemplateFactory = {\n sms: makeTemplate('sms'),\n inApp: makeTemplate('in_app'),\n survey: makeTemplate('survey'),\n};\n\n/** Template props: the input type of the template's Zod `props` schema — what the DSL passes. */\nexport type PropsOf<M> = M extends\n | EmailTemplate<infer S>\n | PushTemplate<infer S>\n | DiscordTemplate<infer S, never>\n | SlackTemplate<infer S, never>\n ? core.input<S> extends object\n ? core.input<S>\n : EmptyProps\n : never;\n"],"mappings":";AA2BA,MAAM,gBACgB,aACY,SAAoC;CAAE;CAAS;AAAI;;;;;;AAOrF,MAAa,WAA4B;CACvC,KAAK,aAAa,KAAK;CACvB,OAAO,aAAa,QAAQ;CAC5B,QAAQ,aAAa,QAAQ;AAC/B"}
1
+ {"version":3,"file":"template.mjs","names":[],"sources":["../../src/dsl/template.ts"],"sourcesContent":["/**\n * Templates in the DSL. The four channels with template definitions (email, push, discord,\n * slack) take the definition itself — `.email(welcome, props)` — so its Zod `props` schema types\n * the call (`PropsOf`) and its `id` lands in the IR. The channels rendered by app-supplied\n * senders (sms, in-app, survey) have no definition; they take a hand-typed reference by key.\n */\n\nimport type * as core from 'zod/v4/core';\nimport type { DiscordTemplate, EmailTemplate, PushTemplate, SlackTemplate } from '../templates';\n\nexport type Channel = 'email' | 'sms' | 'push' | 'in_app' | 'slack' | 'discord' | 'survey';\n\nexport type EmptyProps = Record<string, never>;\n\n/** A template by key, its props typed by hand: for the channels without template definitions. */\nexport interface TemplateRef<C extends Channel = Channel, P extends object = object> {\n readonly channel: C;\n readonly key: string;\n readonly __props?: P;\n}\n\nexport interface TemplateFactory {\n sms<P extends object = EmptyProps>(key: string): TemplateRef<'sms', P>;\n inApp<P extends object = EmptyProps>(key: string): TemplateRef<'in_app', P>;\n survey<P extends object = EmptyProps>(key: string): TemplateRef<'survey', P>;\n}\n\nconst makeTemplate =\n <C extends Channel>(channel: C) =>\n <P extends object = EmptyProps>(key: string): TemplateRef<C, P> => ({ channel, key });\n\n/**\n * Template references for the channels an app-supplied sender renders:\n *\n * const code = template.sms<{ code: string }>('login_code');\n */\nexport const template: TemplateFactory = {\n sms: makeTemplate('sms'),\n inApp: makeTemplate('in_app'),\n survey: makeTemplate('survey'),\n};\n\n/** Template props: the input type of the template's Zod `props` schema — what the DSL passes. */\nexport type PropsOf<M> = M extends\n | EmailTemplate<infer S>\n | PushTemplate<infer S>\n | DiscordTemplate<infer S, never>\n | SlackTemplate<infer S, never>\n ? core.input<S> extends object\n ? core.input<S>\n : EmptyProps\n : never;\n"],"mappings":";AA2BA,MAAM,gBACgB,aACY,SAAoC;CAAE;CAAS;AAAI;;;;;;AAOrF,MAAa,WAA4B;CACvC,KAAK,aAAa,KAAK;CACvB,OAAO,aAAa,QAAQ;CAC5B,QAAQ,aAAa,QAAQ;AAC/B"}
@@ -0,0 +1,62 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_email = require("./email.cjs");
3
+ //#region src/email-sender.ts
4
+ /**
5
+ * The email channel over any transport: render → tracking applied to the HTML → the tracked
6
+ * links committed → the transport. The links resolve before the email exists anywhere a
7
+ * scanner could follow them.
8
+ */
9
+ var EmailSender = class {
10
+ options;
11
+ constructor(options) {
12
+ this.options = options;
13
+ }
14
+ async send(message) {
15
+ if (message.channel !== "email") throw new Error(`EmailSender: unsupported channel '${message.channel}'`);
16
+ if (message.recipient === void 0) throw new Error(`EmailSender: no recipient for user '${message.userId}'`);
17
+ const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(message.template, message.props);
18
+ const tracked = message.tracking === void 0 ? html : trackEmail(html, message.tracking, unsubscribeUrl);
19
+ await message.tracking?.commit?.();
20
+ await this.options.transport.send({
21
+ from,
22
+ to: message.recipient,
23
+ ...replyTo === void 0 ? {} : { replyTo },
24
+ subject,
25
+ html: tracked,
26
+ idempotencyKey: message.idempotencyKey
27
+ });
28
+ }
29
+ };
30
+ const LINK = /(<a\b[^>]*?\shref\s*=\s*)(["'])(https?:\/\/[^"']+)\2/gi;
31
+ /**
32
+ * An email's tracking applied to its rendered HTML: every `http(s)` anchor routed through
33
+ * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back) —
34
+ * as the unsubscribe link when it is the one the email was rendered with, so what a click on it
35
+ * means is fixed when the email is sent — and the open pixel appended before `</body>`, or at the
36
+ * end when there is none.
37
+ */
38
+ function trackEmail(html, tracking, unsubscribeUrl) {
39
+ const { link, pixel } = tracking;
40
+ let out = html;
41
+ if (link !== void 0) out = out.replace(LINK, (_, start, quote, attribute) => {
42
+ const url = unescapeAttribute(attribute);
43
+ const purpose = require_email.isUnsubscribeLink(url, unsubscribeUrl) ? "unsubscribe" : "link";
44
+ return `${start}${quote}${escapeAttribute(link(url, purpose))}${quote}`;
45
+ });
46
+ if (pixel !== void 0) {
47
+ const image = `<img src="${escapeAttribute(pixel)}" width="1" height="1" alt="" style="display:block;border:0;width:1px;height:1px" />`;
48
+ out = /<\/body>/i.test(out) ? out.replace(/<\/body>/i, `${image}</body>`) : `${out}${image}`;
49
+ }
50
+ return out;
51
+ }
52
+ function unescapeAttribute(value) {
53
+ return value.replaceAll("&amp;", "&").replaceAll("&quot;", "\"").replaceAll("&#x27;", "'");
54
+ }
55
+ function escapeAttribute(value) {
56
+ return value.replaceAll("&", "&amp;").replaceAll("\"", "&quot;");
57
+ }
58
+ //#endregion
59
+ exports.EmailSender = EmailSender;
60
+ exports.trackEmail = trackEmail;
61
+
62
+ //# sourceMappingURL=email-sender.cjs.map
@@ -0,0 +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"}
@@ -0,0 +1,70 @@
1
+ import { ScalarIR } from "./ir.cjs";
2
+ import { MessageSender, MessageTracking, OutboundMessage } from "./engine/ports.cjs";
3
+ import { EmailAddress, ResolvedEmail } from "./email.cjs";
4
+ //#region src/email-sender.d.ts
5
+ /**
6
+ * The email channel, split where the runtime stops mattering: `EmailSender` renders a copy,
7
+ * applies its tracking and commits the tracked links — the same on every host — and hands the
8
+ * finished email to an `EmailTransport`, the only part that knows a provider (SES, Cloudflare
9
+ * Email, ...). Nothing here imports a platform module, so a Worker and a Lambda share it.
10
+ */
11
+ /** A rendered email: the template's resolved metadata (`resolveEmail`), its subject and its HTML. */
12
+ export interface RenderedEmail extends ResolvedEmail {
13
+ subject: string;
14
+ html: string;
15
+ }
16
+ /**
17
+ * Template renderer, injected by the app — this package's core stays free of
18
+ * any react-email dependency (`@shware/workflow/react-email` ships one over a
19
+ * registry). The subject comes back final: it is sent exactly as written, from
20
+ * the template's own sender, and the unsubscribe link is the one the HTML was
21
+ * rendered with.
22
+ */
23
+ export type EmailRenderer = (template: string, props: Record<string, ScalarIR | undefined>) => Promise<RenderedEmail>;
24
+ /** A finished email, as a transport takes it: rendered, tracked, addressed. */
25
+ export interface EmailMessage {
26
+ from: EmailAddress;
27
+ to: string;
28
+ replyTo?: EmailAddress;
29
+ subject: string;
30
+ html: string;
31
+ /**
32
+ * `${instanceId}:${nodeId}`: the copy's identity, stable across retries and replays. A provider
33
+ * that de-duplicates on a key receives it; one that cannot (SES) still sends at least once —
34
+ * the window between the provider accepting the email and the step's checkpoint stays open.
35
+ */
36
+ idempotencyKey: string;
37
+ }
38
+ /**
39
+ * One email provider. Throwing hands the retry to the durable step; resolving means the provider
40
+ * accepted the email (not that it was delivered).
41
+ */
42
+ export interface EmailTransport {
43
+ send(message: EmailMessage): Promise<{
44
+ providerMessageId?: string;
45
+ }>;
46
+ }
47
+ export interface EmailSenderOptions {
48
+ transport: EmailTransport;
49
+ render: EmailRenderer;
50
+ }
51
+ /**
52
+ * The email channel over any transport: render → tracking applied to the HTML → the tracked
53
+ * links committed → the transport. The links resolve before the email exists anywhere a
54
+ * scanner could follow them.
55
+ */
56
+ export declare class EmailSender implements MessageSender {
57
+ private readonly options;
58
+ constructor(options: EmailSenderOptions);
59
+ send(message: OutboundMessage): Promise<void>;
60
+ }
61
+ /**
62
+ * An email's tracking applied to its rendered HTML: every `http(s)` anchor routed through
63
+ * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back) —
64
+ * as the unsubscribe link when it is the one the email was rendered with, so what a click on it
65
+ * means is fixed when the email is sent — and the open pixel appended before `</body>`, or at the
66
+ * end when there is none.
67
+ */
68
+ export declare function trackEmail(html: string, tracking: MessageTracking, unsubscribeUrl?: string): string;
69
+ //#endregion
70
+ //# sourceMappingURL=email-sender.d.cts.map
@@ -0,0 +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"}
@@ -0,0 +1,70 @@
1
+ import { ScalarIR } from "./ir.mjs";
2
+ import { MessageSender, MessageTracking, OutboundMessage } from "./engine/ports.mjs";
3
+ import { EmailAddress, ResolvedEmail } from "./email.mjs";
4
+ //#region src/email-sender.d.ts
5
+ /**
6
+ * The email channel, split where the runtime stops mattering: `EmailSender` renders a copy,
7
+ * applies its tracking and commits the tracked links — the same on every host — and hands the
8
+ * finished email to an `EmailTransport`, the only part that knows a provider (SES, Cloudflare
9
+ * Email, ...). Nothing here imports a platform module, so a Worker and a Lambda share it.
10
+ */
11
+ /** A rendered email: the template's resolved metadata (`resolveEmail`), its subject and its HTML. */
12
+ export interface RenderedEmail extends ResolvedEmail {
13
+ subject: string;
14
+ html: string;
15
+ }
16
+ /**
17
+ * Template renderer, injected by the app — this package's core stays free of
18
+ * any react-email dependency (`@shware/workflow/react-email` ships one over a
19
+ * registry). The subject comes back final: it is sent exactly as written, from
20
+ * the template's own sender, and the unsubscribe link is the one the HTML was
21
+ * rendered with.
22
+ */
23
+ export type EmailRenderer = (template: string, props: Record<string, ScalarIR | undefined>) => Promise<RenderedEmail>;
24
+ /** A finished email, as a transport takes it: rendered, tracked, addressed. */
25
+ export interface EmailMessage {
26
+ from: EmailAddress;
27
+ to: string;
28
+ replyTo?: EmailAddress;
29
+ subject: string;
30
+ html: string;
31
+ /**
32
+ * `${instanceId}:${nodeId}`: the copy's identity, stable across retries and replays. A provider
33
+ * that de-duplicates on a key receives it; one that cannot (SES) still sends at least once —
34
+ * the window between the provider accepting the email and the step's checkpoint stays open.
35
+ */
36
+ idempotencyKey: string;
37
+ }
38
+ /**
39
+ * One email provider. Throwing hands the retry to the durable step; resolving means the provider
40
+ * accepted the email (not that it was delivered).
41
+ */
42
+ export interface EmailTransport {
43
+ send(message: EmailMessage): Promise<{
44
+ providerMessageId?: string;
45
+ }>;
46
+ }
47
+ export interface EmailSenderOptions {
48
+ transport: EmailTransport;
49
+ render: EmailRenderer;
50
+ }
51
+ /**
52
+ * The email channel over any transport: render → tracking applied to the HTML → the tracked
53
+ * links committed → the transport. The links resolve before the email exists anywhere a
54
+ * scanner could follow them.
55
+ */
56
+ export declare class EmailSender implements MessageSender {
57
+ private readonly options;
58
+ constructor(options: EmailSenderOptions);
59
+ send(message: OutboundMessage): Promise<void>;
60
+ }
61
+ /**
62
+ * An email's tracking applied to its rendered HTML: every `http(s)` anchor routed through
63
+ * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back) —
64
+ * as the unsubscribe link when it is the one the email was rendered with, so what a click on it
65
+ * means is fixed when the email is sent — and the open pixel appended before `</body>`, or at the
66
+ * end when there is none.
67
+ */
68
+ export declare function trackEmail(html: string, tracking: MessageTracking, unsubscribeUrl?: string): string;
69
+ //#endregion
70
+ //# sourceMappingURL=email-sender.d.mts.map
@@ -0,0 +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"}
@@ -0,0 +1,60 @@
1
+ import { isUnsubscribeLink } from "./email.mjs";
2
+ //#region src/email-sender.ts
3
+ /**
4
+ * The email channel over any transport: render → tracking applied to the HTML → the tracked
5
+ * links committed → the transport. The links resolve before the email exists anywhere a
6
+ * scanner could follow them.
7
+ */
8
+ var EmailSender = class {
9
+ options;
10
+ constructor(options) {
11
+ this.options = options;
12
+ }
13
+ async send(message) {
14
+ if (message.channel !== "email") throw new Error(`EmailSender: unsupported channel '${message.channel}'`);
15
+ if (message.recipient === void 0) throw new Error(`EmailSender: no recipient for user '${message.userId}'`);
16
+ const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(message.template, message.props);
17
+ const tracked = message.tracking === void 0 ? html : trackEmail(html, message.tracking, unsubscribeUrl);
18
+ await message.tracking?.commit?.();
19
+ await this.options.transport.send({
20
+ from,
21
+ to: message.recipient,
22
+ ...replyTo === void 0 ? {} : { replyTo },
23
+ subject,
24
+ html: tracked,
25
+ idempotencyKey: message.idempotencyKey
26
+ });
27
+ }
28
+ };
29
+ const LINK = /(<a\b[^>]*?\shref\s*=\s*)(["'])(https?:\/\/[^"']+)\2/gi;
30
+ /**
31
+ * An email's tracking applied to its rendered HTML: every `http(s)` anchor routed through
32
+ * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back) —
33
+ * as the unsubscribe link when it is the one the email was rendered with, so what a click on it
34
+ * means is fixed when the email is sent — and the open pixel appended before `</body>`, or at the
35
+ * end when there is none.
36
+ */
37
+ function trackEmail(html, tracking, unsubscribeUrl) {
38
+ const { link, pixel } = tracking;
39
+ let out = html;
40
+ if (link !== void 0) out = out.replace(LINK, (_, start, quote, attribute) => {
41
+ const url = unescapeAttribute(attribute);
42
+ const purpose = isUnsubscribeLink(url, unsubscribeUrl) ? "unsubscribe" : "link";
43
+ return `${start}${quote}${escapeAttribute(link(url, purpose))}${quote}`;
44
+ });
45
+ if (pixel !== void 0) {
46
+ const image = `<img src="${escapeAttribute(pixel)}" width="1" height="1" alt="" style="display:block;border:0;width:1px;height:1px" />`;
47
+ out = /<\/body>/i.test(out) ? out.replace(/<\/body>/i, `${image}</body>`) : `${out}${image}`;
48
+ }
49
+ return out;
50
+ }
51
+ function unescapeAttribute(value) {
52
+ return value.replaceAll("&amp;", "&").replaceAll("&quot;", "\"").replaceAll("&#x27;", "'");
53
+ }
54
+ function escapeAttribute(value) {
55
+ return value.replaceAll("&", "&amp;").replaceAll("\"", "&quot;");
56
+ }
57
+ //#endregion
58
+ export { EmailSender, trackEmail };
59
+
60
+ //# sourceMappingURL=email-sender.mjs.map
@@ -0,0 +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"}
package/dist/email.cjs CHANGED
@@ -5,6 +5,26 @@ function formatEmailAddress(address) {
5
5
  if (typeof address === "string") return address;
6
6
  return address.name === void 0 ? address.email : `${address.name} <${address.email}>`;
7
7
  }
8
+ const ASCII = /^[\x20-\x7e]*$/;
9
+ const SPECIALS = /[()<>[\]:;@\\,."]/;
10
+ /**
11
+ * An address as a mail API reads it (RFC 5322): a display name with specials is quoted, one
12
+ * outside ASCII is a MIME encoded-word — raw non-ASCII names are refused (SES), and an unquoted
13
+ * comma splits the address in two.
14
+ */
15
+ function mimeAddress(address) {
16
+ if (typeof address === "string") return address;
17
+ const { email, name } = address;
18
+ if (name === void 0 || name === "") return email;
19
+ if (!ASCII.test(name)) {
20
+ const bytes = new TextEncoder().encode(name);
21
+ let binary = "";
22
+ for (const byte of bytes) binary += String.fromCharCode(byte);
23
+ return `=?UTF-8?B?${btoa(binary)}?= <${email}>`;
24
+ }
25
+ if (SPECIALS.test(name)) return `"${name.replace(/(["\\])/g, "\\$1")}" <${email}>`;
26
+ return `${name} <${email}>`;
27
+ }
8
28
  /** The template's metadata, its `unsubscribeUrl` falling back to the config's. Leaves both untouched. */
9
29
  function resolveEmail(template, config) {
10
30
  const unsubscribeUrl = template.unsubscribeUrl ?? config?.unsubscribeUrl;
@@ -67,6 +87,7 @@ exports.assertEmailMetadata = assertEmailMetadata;
67
87
  exports.assertUnsubscribeUrl = assertUnsubscribeUrl;
68
88
  exports.formatEmailAddress = formatEmailAddress;
69
89
  exports.isUnsubscribeLink = isUnsubscribeLink;
90
+ exports.mimeAddress = mimeAddress;
70
91
  exports.resolveEmail = resolveEmail;
71
92
 
72
93
  //# sourceMappingURL=email.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"email.cjs","names":[],"sources":["../src/email.ts"],"sourcesContent":["import type { EmailTemplate } from './templates';\n\n/**\n * Email metadata: who a template's email comes from, where replies go, and the unsubscribe link\n * its layout carries. Each `emailTemplate` declares its own `from` (and `replyTo`, if replies should\n * go elsewhere); the unsubscribe link is the template's `unsubscribeUrl`, else the config's\n * `email.unsubscribeUrl`, else none. `resolveEmail` is that rule, and the sender, the renderer,\n * the Worker's preview and the studio all go through it. No React and no I/O here, so a browser\n * can import it.\n */\n\n/** An address as the transports take it: bare, or with a display name. */\nexport type EmailAddress = string | { email: string; name?: string };\n\n/** `Name <email>`, or the bare address. */\nexport function formatEmailAddress(address: EmailAddress): string {\n if (typeof address === 'string') return address;\n return address.name === undefined ? address.email : `${address.name} <${address.email}>`;\n}\n\n/** The config's `email`: what every email template shares. */\nexport interface EmailConfig {\n /**\n * The unsubscribe link of every email template that names none of its own. The layout reads it\n * with `useEmailContext()`; sent, it is tracked like every link, but following it shows a\n * confirmation page, and only the confirmation (a POST no link scanner sends) unsubscribes.\n */\n unsubscribeUrl?: string;\n /** How the Worker tells a person's opens and clicks from a machine's (see the reporting classifier). */\n tracking?: {\n /**\n * An open or click this many milliseconds after delivery is a weak machine signal — machine\n * only together with another (a second link within seconds); 1000 by default. A person\n * tapping a mail notification can click within a few seconds, so keep it short.\n */\n quickClickMs?: number;\n };\n}\n\n/** One template's email metadata, resolved against the config. */\nexport interface ResolvedEmail {\n from: EmailAddress;\n /** Absent when the template names none: the email goes out without a Reply-To. */\n replyTo?: EmailAddress;\n /** Absent when neither the template nor the config names one: the email has no unsubscribe link. */\n unsubscribeUrl?: string;\n}\n\n/** The template's metadata, its `unsubscribeUrl` falling back to the config's. Leaves both untouched. */\nexport function resolveEmail(\n template: Pick<EmailTemplate, 'from' | 'replyTo' | 'unsubscribeUrl'>,\n config?: EmailConfig\n): ResolvedEmail {\n const unsubscribeUrl = template.unsubscribeUrl ?? config?.unsubscribeUrl;\n return {\n from: template.from,\n ...(template.replyTo === undefined ? {} : { replyTo: template.replyTo }),\n ...(unsubscribeUrl === undefined ? {} : { unsubscribeUrl }),\n };\n}\n\nconst ADDRESS = /^[^\\s@<>]+@[^\\s@<>]+$/;\n\n/** Throws naming `where` when `address` is neither `user@host` nor `{ email: 'user@host', name? }`. */\nexport function assertEmailAddress(address: unknown, where: string): void {\n const email =\n typeof address === 'object' && address !== null\n ? (address as { email?: unknown }).email\n : address;\n const name =\n typeof address === 'object' && address !== null\n ? (address as { name?: unknown }).name\n : undefined;\n if (typeof email !== 'string' || !ADDRESS.test(email)) {\n throw new TypeError(`${where} must be an email address or { email, name? }`);\n }\n if (name !== undefined && typeof name !== 'string') {\n throw new TypeError(`${where}.name must be a string`);\n }\n}\n\n/** Throws naming `where` unless `url` is an absolute http(s) URL; '' and null are not a way to leave it out. */\nexport function assertUnsubscribeUrl(url: unknown, where: string): void {\n let parsed: URL | undefined;\n try {\n parsed = typeof url === 'string' ? new URL(url) : undefined;\n } catch {\n parsed = undefined;\n }\n if (parsed === undefined || (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')) {\n throw new TypeError(`${where} must be an http(s) URL (leave it out for none)`);\n }\n}\n\n/** The config's `email` and every registered template's metadata; throws on the first invalid value. */\nexport function assertEmailConfig(\n config: { email?: EmailConfig },\n templates: Iterable<EmailTemplate>\n): void {\n if (Object.hasOwn(config, 'emails')) {\n throw new TypeError(\n \"the config's `emails` is gone: `from` and `replyTo` are declared on each emailTemplate, \" +\n 'and the rest moved to `email: { unsubscribeUrl, tracking }`'\n );\n }\n const email = config.email;\n if (email !== undefined) {\n if (email.unsubscribeUrl !== undefined) {\n assertUnsubscribeUrl(email.unsubscribeUrl, 'email.unsubscribeUrl');\n }\n const quick = email.tracking?.quickClickMs;\n if (\n quick !== undefined &&\n !(typeof quick === 'number' && Number.isFinite(quick) && quick >= 0)\n ) {\n throw new TypeError('email.tracking.quickClickMs must be a non-negative number');\n }\n }\n for (const template of templates)\n assertEmailMetadata(template, `email template '${template.id}'`);\n}\n\n/** A template's own `from` / `replyTo` / `unsubscribeUrl`, as `emailTemplate` checks them. */\nexport function assertEmailMetadata(\n template: { from?: unknown; replyTo?: unknown; unsubscribeUrl?: unknown },\n where: string\n): void {\n assertEmailAddress(template.from, `${where}: from`);\n if (template.replyTo !== undefined) assertEmailAddress(template.replyTo, `${where}: replyTo`);\n if (template.unsubscribeUrl !== undefined) {\n assertUnsubscribeUrl(template.unsubscribeUrl, `${where}: unsubscribeUrl`);\n }\n}\n\n/** The unsubscribe link may carry its own query (a token, a list): the path names it. */\nexport function isUnsubscribeLink(url: string, unsubscribeUrl: string | undefined): boolean {\n if (unsubscribeUrl === undefined) return false;\n try {\n const a = new URL(url);\n const b = new URL(unsubscribeUrl);\n return a.origin === b.origin && a.pathname === b.pathname;\n } catch {\n return false;\n }\n}\n"],"mappings":";;;AAeA,SAAgB,mBAAmB,SAA+B;CAChE,IAAI,OAAO,YAAY,UAAU,OAAO;CACxC,OAAO,QAAQ,SAAS,KAAA,IAAY,QAAQ,QAAQ,GAAG,QAAQ,KAAK,IAAI,QAAQ,MAAM;AACxF;;AA+BA,SAAgB,aACd,UACA,QACe;CACf,MAAM,iBAAiB,SAAS,kBAAkB,QAAQ;CAC1D,OAAO;EACL,MAAM,SAAS;EACf,GAAI,SAAS,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,SAAS,SAAS,QAAQ;EACtE,GAAI,mBAAmB,KAAA,IAAY,CAAC,IAAI,EAAE,eAAe;CAC3D;AACF;AAEA,MAAM,UAAU;;AAGhB,SAAgB,mBAAmB,SAAkB,OAAqB;CACxE,MAAM,QACJ,OAAO,YAAY,YAAY,YAAY,OACtC,QAAgC,QACjC;CACN,MAAM,OACJ,OAAO,YAAY,YAAY,YAAY,OACtC,QAA+B,OAChC,KAAA;CACN,IAAI,OAAO,UAAU,YAAY,CAAC,QAAQ,KAAK,KAAK,GAClD,MAAM,IAAI,UAAU,GAAG,MAAM,8CAA8C;CAE7E,IAAI,SAAS,KAAA,KAAa,OAAO,SAAS,UACxC,MAAM,IAAI,UAAU,GAAG,MAAM,uBAAuB;AAExD;;AAGA,SAAgB,qBAAqB,KAAc,OAAqB;CACtE,IAAI;CACJ,IAAI;EACF,SAAS,OAAO,QAAQ,WAAW,IAAI,IAAI,GAAG,IAAI,KAAA;CACpD,QAAQ;EACN,SAAS,KAAA;CACX;CACA,IAAI,WAAW,KAAA,KAAc,OAAO,aAAa,YAAY,OAAO,aAAa,SAC/E,MAAM,IAAI,UAAU,GAAG,MAAM,gDAAgD;AAEjF;;AAGA,SAAgB,kBACd,QACA,WACM;CACN,IAAI,OAAO,OAAO,QAAQ,QAAQ,GAChC,MAAM,IAAI,UACR,qJAEF;CAEF,MAAM,QAAQ,OAAO;CACrB,IAAI,UAAU,KAAA,GAAW;EACvB,IAAI,MAAM,mBAAmB,KAAA,GAC3B,qBAAqB,MAAM,gBAAgB,sBAAsB;EAEnE,MAAM,QAAQ,MAAM,UAAU;EAC9B,IACE,UAAU,KAAA,KACV,EAAE,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,KAAK,SAAS,IAElE,MAAM,IAAI,UAAU,2DAA2D;CAEnF;CACA,KAAK,MAAM,YAAY,WACrB,oBAAoB,UAAU,mBAAmB,SAAS,GAAG,EAAE;AACnE;;AAGA,SAAgB,oBACd,UACA,OACM;CACN,mBAAmB,SAAS,MAAM,GAAG,MAAM,OAAO;CAClD,IAAI,SAAS,YAAY,KAAA,GAAW,mBAAmB,SAAS,SAAS,GAAG,MAAM,UAAU;CAC5F,IAAI,SAAS,mBAAmB,KAAA,GAC9B,qBAAqB,SAAS,gBAAgB,GAAG,MAAM,iBAAiB;AAE5E;;AAGA,SAAgB,kBAAkB,KAAa,gBAA6C;CAC1F,IAAI,mBAAmB,KAAA,GAAW,OAAO;CACzC,IAAI;EACF,MAAM,IAAI,IAAI,IAAI,GAAG;EACrB,MAAM,IAAI,IAAI,IAAI,cAAc;EAChC,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,aAAa,EAAE;CACnD,QAAQ;EACN,OAAO;CACT;AACF"}
1
+ {"version":3,"file":"email.cjs","names":[],"sources":["../src/email.ts"],"sourcesContent":["import type { EmailTemplate } from './templates';\n\n/**\n * Email metadata: who a template's email comes from, where replies go, and the unsubscribe link\n * its layout carries. Each `emailTemplate` declares its own `from` (and `replyTo`, if replies should\n * go elsewhere); the unsubscribe link is the template's `unsubscribeUrl`, else the config's\n * `email.unsubscribeUrl`, else none. `resolveEmail` is that rule, and the sender, the renderer,\n * the Worker's preview and the studio all go through it. No React and no I/O here, so a browser\n * can import it.\n */\n\n/** An address as the transports take it: bare, or with a display name. */\nexport type EmailAddress = string | { email: string; name?: string };\n\n/** `Name <email>`, or the bare address. */\nexport function formatEmailAddress(address: EmailAddress): string {\n if (typeof address === 'string') return address;\n return address.name === undefined ? address.email : `${address.name} <${address.email}>`;\n}\n\nconst ASCII = /^[\\x20-\\x7e]*$/;\nconst SPECIALS = /[()<>[\\]:;@\\\\,.\"]/;\n\n/**\n * An address as a mail API reads it (RFC 5322): a display name with specials is quoted, one\n * outside ASCII is a MIME encoded-word — raw non-ASCII names are refused (SES), and an unquoted\n * comma splits the address in two.\n */\nexport function mimeAddress(address: EmailAddress): string {\n if (typeof address === 'string') return address;\n const { email, name } = address;\n if (name === undefined || name === '') return email;\n if (!ASCII.test(name)) {\n const bytes = new TextEncoder().encode(name);\n let binary = '';\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return `=?UTF-8?B?${btoa(binary)}?= <${email}>`;\n }\n if (SPECIALS.test(name)) return `\"${name.replace(/([\"\\\\])/g, '\\\\$1')}\" <${email}>`;\n return `${name} <${email}>`;\n}\n\n/** The config's `email`: what every email template shares. */\nexport interface EmailConfig {\n /**\n * The unsubscribe link of every email template that names none of its own. The layout reads it\n * with `useEmailContext()`; sent, it is tracked like every link, but following it shows a\n * confirmation page, and only the confirmation (a POST no link scanner sends) unsubscribes.\n */\n unsubscribeUrl?: string;\n /** How the Worker tells a person's opens and clicks from a machine's (see the reporting classifier). */\n tracking?: {\n /**\n * An open or click this many milliseconds after delivery is a weak machine signal — machine\n * only together with another (a second link within seconds); 1000 by default. A person\n * tapping a mail notification can click within a few seconds, so keep it short.\n */\n quickClickMs?: number;\n };\n}\n\n/** One template's email metadata, resolved against the config. */\nexport interface ResolvedEmail {\n from: EmailAddress;\n /** Absent when the template names none: the email goes out without a Reply-To. */\n replyTo?: EmailAddress;\n /** Absent when neither the template nor the config names one: the email has no unsubscribe link. */\n unsubscribeUrl?: string;\n}\n\n/** The template's metadata, its `unsubscribeUrl` falling back to the config's. Leaves both untouched. */\nexport function resolveEmail(\n template: Pick<EmailTemplate, 'from' | 'replyTo' | 'unsubscribeUrl'>,\n config?: EmailConfig\n): ResolvedEmail {\n const unsubscribeUrl = template.unsubscribeUrl ?? config?.unsubscribeUrl;\n return {\n from: template.from,\n ...(template.replyTo === undefined ? {} : { replyTo: template.replyTo }),\n ...(unsubscribeUrl === undefined ? {} : { unsubscribeUrl }),\n };\n}\n\nconst ADDRESS = /^[^\\s@<>]+@[^\\s@<>]+$/;\n\n/** Throws naming `where` when `address` is neither `user@host` nor `{ email: 'user@host', name? }`. */\nexport function assertEmailAddress(address: unknown, where: string): void {\n const email =\n typeof address === 'object' && address !== null\n ? (address as { email?: unknown }).email\n : address;\n const name =\n typeof address === 'object' && address !== null\n ? (address as { name?: unknown }).name\n : undefined;\n if (typeof email !== 'string' || !ADDRESS.test(email)) {\n throw new TypeError(`${where} must be an email address or { email, name? }`);\n }\n if (name !== undefined && typeof name !== 'string') {\n throw new TypeError(`${where}.name must be a string`);\n }\n}\n\n/** Throws naming `where` unless `url` is an absolute http(s) URL; '' and null are not a way to leave it out. */\nexport function assertUnsubscribeUrl(url: unknown, where: string): void {\n let parsed: URL | undefined;\n try {\n parsed = typeof url === 'string' ? new URL(url) : undefined;\n } catch {\n parsed = undefined;\n }\n if (parsed === undefined || (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')) {\n throw new TypeError(`${where} must be an http(s) URL (leave it out for none)`);\n }\n}\n\n/** The config's `email` and every registered template's metadata; throws on the first invalid value. */\nexport function assertEmailConfig(\n config: { email?: EmailConfig },\n templates: Iterable<EmailTemplate>\n): void {\n if (Object.hasOwn(config, 'emails')) {\n throw new TypeError(\n \"the config's `emails` is gone: `from` and `replyTo` are declared on each emailTemplate, \" +\n 'and the rest moved to `email: { unsubscribeUrl, tracking }`'\n );\n }\n const email = config.email;\n if (email !== undefined) {\n if (email.unsubscribeUrl !== undefined) {\n assertUnsubscribeUrl(email.unsubscribeUrl, 'email.unsubscribeUrl');\n }\n const quick = email.tracking?.quickClickMs;\n if (\n quick !== undefined &&\n !(typeof quick === 'number' && Number.isFinite(quick) && quick >= 0)\n ) {\n throw new TypeError('email.tracking.quickClickMs must be a non-negative number');\n }\n }\n for (const template of templates)\n assertEmailMetadata(template, `email template '${template.id}'`);\n}\n\n/** A template's own `from` / `replyTo` / `unsubscribeUrl`, as `emailTemplate` checks them. */\nexport function assertEmailMetadata(\n template: { from?: unknown; replyTo?: unknown; unsubscribeUrl?: unknown },\n where: string\n): void {\n assertEmailAddress(template.from, `${where}: from`);\n if (template.replyTo !== undefined) assertEmailAddress(template.replyTo, `${where}: replyTo`);\n if (template.unsubscribeUrl !== undefined) {\n assertUnsubscribeUrl(template.unsubscribeUrl, `${where}: unsubscribeUrl`);\n }\n}\n\n/** The unsubscribe link may carry its own query (a token, a list): the path names it. */\nexport function isUnsubscribeLink(url: string, unsubscribeUrl: string | undefined): boolean {\n if (unsubscribeUrl === undefined) return false;\n try {\n const a = new URL(url);\n const b = new URL(unsubscribeUrl);\n return a.origin === b.origin && a.pathname === b.pathname;\n } catch {\n return false;\n }\n}\n"],"mappings":";;;AAeA,SAAgB,mBAAmB,SAA+B;CAChE,IAAI,OAAO,YAAY,UAAU,OAAO;CACxC,OAAO,QAAQ,SAAS,KAAA,IAAY,QAAQ,QAAQ,GAAG,QAAQ,KAAK,IAAI,QAAQ,MAAM;AACxF;AAEA,MAAM,QAAQ;AACd,MAAM,WAAW;;;;;;AAOjB,SAAgB,YAAY,SAA+B;CACzD,IAAI,OAAO,YAAY,UAAU,OAAO;CACxC,MAAM,EAAE,OAAO,SAAS;CACxB,IAAI,SAAS,KAAA,KAAa,SAAS,IAAI,OAAO;CAC9C,IAAI,CAAC,MAAM,KAAK,IAAI,GAAG;EACrB,MAAM,QAAQ,IAAI,YAAY,CAAC,CAAC,OAAO,IAAI;EAC3C,IAAI,SAAS;EACb,KAAK,MAAM,QAAQ,OAAO,UAAU,OAAO,aAAa,IAAI;EAC5D,OAAO,aAAa,KAAK,MAAM,EAAE,MAAM,MAAM;CAC/C;CACA,IAAI,SAAS,KAAK,IAAI,GAAG,OAAO,IAAI,KAAK,QAAQ,YAAY,MAAM,EAAE,KAAK,MAAM;CAChF,OAAO,GAAG,KAAK,IAAI,MAAM;AAC3B;;AA+BA,SAAgB,aACd,UACA,QACe;CACf,MAAM,iBAAiB,SAAS,kBAAkB,QAAQ;CAC1D,OAAO;EACL,MAAM,SAAS;EACf,GAAI,SAAS,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,SAAS,SAAS,QAAQ;EACtE,GAAI,mBAAmB,KAAA,IAAY,CAAC,IAAI,EAAE,eAAe;CAC3D;AACF;AAEA,MAAM,UAAU;;AAGhB,SAAgB,mBAAmB,SAAkB,OAAqB;CACxE,MAAM,QACJ,OAAO,YAAY,YAAY,YAAY,OACtC,QAAgC,QACjC;CACN,MAAM,OACJ,OAAO,YAAY,YAAY,YAAY,OACtC,QAA+B,OAChC,KAAA;CACN,IAAI,OAAO,UAAU,YAAY,CAAC,QAAQ,KAAK,KAAK,GAClD,MAAM,IAAI,UAAU,GAAG,MAAM,8CAA8C;CAE7E,IAAI,SAAS,KAAA,KAAa,OAAO,SAAS,UACxC,MAAM,IAAI,UAAU,GAAG,MAAM,uBAAuB;AAExD;;AAGA,SAAgB,qBAAqB,KAAc,OAAqB;CACtE,IAAI;CACJ,IAAI;EACF,SAAS,OAAO,QAAQ,WAAW,IAAI,IAAI,GAAG,IAAI,KAAA;CACpD,QAAQ;EACN,SAAS,KAAA;CACX;CACA,IAAI,WAAW,KAAA,KAAc,OAAO,aAAa,YAAY,OAAO,aAAa,SAC/E,MAAM,IAAI,UAAU,GAAG,MAAM,gDAAgD;AAEjF;;AAGA,SAAgB,kBACd,QACA,WACM;CACN,IAAI,OAAO,OAAO,QAAQ,QAAQ,GAChC,MAAM,IAAI,UACR,qJAEF;CAEF,MAAM,QAAQ,OAAO;CACrB,IAAI,UAAU,KAAA,GAAW;EACvB,IAAI,MAAM,mBAAmB,KAAA,GAC3B,qBAAqB,MAAM,gBAAgB,sBAAsB;EAEnE,MAAM,QAAQ,MAAM,UAAU;EAC9B,IACE,UAAU,KAAA,KACV,EAAE,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,KAAK,SAAS,IAElE,MAAM,IAAI,UAAU,2DAA2D;CAEnF;CACA,KAAK,MAAM,YAAY,WACrB,oBAAoB,UAAU,mBAAmB,SAAS,GAAG,EAAE;AACnE;;AAGA,SAAgB,oBACd,UACA,OACM;CACN,mBAAmB,SAAS,MAAM,GAAG,MAAM,OAAO;CAClD,IAAI,SAAS,YAAY,KAAA,GAAW,mBAAmB,SAAS,SAAS,GAAG,MAAM,UAAU;CAC5F,IAAI,SAAS,mBAAmB,KAAA,GAC9B,qBAAqB,SAAS,gBAAgB,GAAG,MAAM,iBAAiB;AAE5E;;AAGA,SAAgB,kBAAkB,KAAa,gBAA6C;CAC1F,IAAI,mBAAmB,KAAA,GAAW,OAAO;CACzC,IAAI;EACF,MAAM,IAAI,IAAI,IAAI,GAAG;EACrB,MAAM,IAAI,IAAI,IAAI,cAAc;EAChC,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,aAAa,EAAE;CACnD,QAAQ;EACN,OAAO;CACT;AACF"}
package/dist/email.d.cts CHANGED
@@ -15,6 +15,12 @@ export type EmailAddress = string | {
15
15
  };
16
16
  /** `Name <email>`, or the bare address. */
17
17
  export declare function formatEmailAddress(address: EmailAddress): string;
18
+ /**
19
+ * An address as a mail API reads it (RFC 5322): a display name with specials is quoted, one
20
+ * outside ASCII is a MIME encoded-word — raw non-ASCII names are refused (SES), and an unquoted
21
+ * comma splits the address in two.
22
+ */
23
+ export declare function mimeAddress(address: EmailAddress): string;
18
24
  /** The config's `email`: what every email template shares. */
19
25
  export interface EmailConfig {
20
26
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"email.d.cts","names":[],"sources":["../src/email.ts"],"mappings":";;;;;;;;;;;YAYY;EAA0B;EAAe;;;wBAGrC,mBAAmB,SAAS;;iBAM3B;;;;;;EAMf;;EAEA;;;;;;IAME;;;;iBAKa;EACf,MAAM;;EAEN,UAAU;;EAEV;;;wBAIc,aACd,UAAU,KAAK,uDACf,SAAS,cACR;;wBAYa,mBAAmB,kBAAkB;;wBAkBrC,qBAAqB,cAAc;;wBAanC,kBACd;EAAU,QAAQ;GAClB,WAAW,SAAS;;wBA0BN,oBACd;EAAY;EAAgB;EAAmB;GAC/C;;wBAUc,kBAAkB,aAAa"}
1
+ {"version":3,"file":"email.d.cts","names":[],"sources":["../src/email.ts"],"mappings":";;;;;;;;;;;YAYY;EAA0B;EAAe;;;wBAGrC,mBAAmB,SAAS;;;;;;wBAa5B,YAAY,SAAS;;iBAepB;;;;;;EAMf;;EAEA;;;;;;IAME;;;;iBAKa;EACf,MAAM;;EAEN,UAAU;;EAEV;;;wBAIc,aACd,UAAU,KAAK,uDACf,SAAS,cACR;;wBAYa,mBAAmB,kBAAkB;;wBAkBrC,qBAAqB,cAAc;;wBAanC,kBACd;EAAU,QAAQ;GAClB,WAAW,SAAS;;wBA0BN,oBACd;EAAY;EAAgB;EAAmB;GAC/C;;wBAUc,kBAAkB,aAAa"}
package/dist/email.d.mts CHANGED
@@ -15,6 +15,12 @@ export type EmailAddress = string | {
15
15
  };
16
16
  /** `Name <email>`, or the bare address. */
17
17
  export declare function formatEmailAddress(address: EmailAddress): string;
18
+ /**
19
+ * An address as a mail API reads it (RFC 5322): a display name with specials is quoted, one
20
+ * outside ASCII is a MIME encoded-word — raw non-ASCII names are refused (SES), and an unquoted
21
+ * comma splits the address in two.
22
+ */
23
+ export declare function mimeAddress(address: EmailAddress): string;
18
24
  /** The config's `email`: what every email template shares. */
19
25
  export interface EmailConfig {
20
26
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"email.d.mts","names":[],"sources":["../src/email.ts"],"mappings":";;;;;;;;;;;YAYY;EAA0B;EAAe;;;wBAGrC,mBAAmB,SAAS;;iBAM3B;;;;;;EAMf;;EAEA;;;;;;IAME;;;;iBAKa;EACf,MAAM;;EAEN,UAAU;;EAEV;;;wBAIc,aACd,UAAU,KAAK,uDACf,SAAS,cACR;;wBAYa,mBAAmB,kBAAkB;;wBAkBrC,qBAAqB,cAAc;;wBAanC,kBACd;EAAU,QAAQ;GAClB,WAAW,SAAS;;wBA0BN,oBACd;EAAY;EAAgB;EAAmB;GAC/C;;wBAUc,kBAAkB,aAAa"}
1
+ {"version":3,"file":"email.d.mts","names":[],"sources":["../src/email.ts"],"mappings":";;;;;;;;;;;YAYY;EAA0B;EAAe;;;wBAGrC,mBAAmB,SAAS;;;;;;wBAa5B,YAAY,SAAS;;iBAepB;;;;;;EAMf;;EAEA;;;;;;IAME;;;;iBAKa;EACf,MAAM;;EAEN,UAAU;;EAEV;;;wBAIc,aACd,UAAU,KAAK,uDACf,SAAS,cACR;;wBAYa,mBAAmB,kBAAkB;;wBAkBrC,qBAAqB,cAAc;;wBAanC,kBACd;EAAU,QAAQ;GAClB,WAAW,SAAS;;wBA0BN,oBACd;EAAY;EAAgB;EAAmB;GAC/C;;wBAUc,kBAAkB,aAAa"}
package/dist/email.mjs CHANGED
@@ -4,6 +4,26 @@ function formatEmailAddress(address) {
4
4
  if (typeof address === "string") return address;
5
5
  return address.name === void 0 ? address.email : `${address.name} <${address.email}>`;
6
6
  }
7
+ const ASCII = /^[\x20-\x7e]*$/;
8
+ const SPECIALS = /[()<>[\]:;@\\,."]/;
9
+ /**
10
+ * An address as a mail API reads it (RFC 5322): a display name with specials is quoted, one
11
+ * outside ASCII is a MIME encoded-word — raw non-ASCII names are refused (SES), and an unquoted
12
+ * comma splits the address in two.
13
+ */
14
+ function mimeAddress(address) {
15
+ if (typeof address === "string") return address;
16
+ const { email, name } = address;
17
+ if (name === void 0 || name === "") return email;
18
+ if (!ASCII.test(name)) {
19
+ const bytes = new TextEncoder().encode(name);
20
+ let binary = "";
21
+ for (const byte of bytes) binary += String.fromCharCode(byte);
22
+ return `=?UTF-8?B?${btoa(binary)}?= <${email}>`;
23
+ }
24
+ if (SPECIALS.test(name)) return `"${name.replace(/(["\\])/g, "\\$1")}" <${email}>`;
25
+ return `${name} <${email}>`;
26
+ }
7
27
  /** The template's metadata, its `unsubscribeUrl` falling back to the config's. Leaves both untouched. */
8
28
  function resolveEmail(template, config) {
9
29
  const unsubscribeUrl = template.unsubscribeUrl ?? config?.unsubscribeUrl;
@@ -60,6 +80,6 @@ function isUnsubscribeLink(url, unsubscribeUrl) {
60
80
  }
61
81
  }
62
82
  //#endregion
63
- export { assertEmailAddress, assertEmailConfig, assertEmailMetadata, assertUnsubscribeUrl, formatEmailAddress, isUnsubscribeLink, resolveEmail };
83
+ export { assertEmailAddress, assertEmailConfig, assertEmailMetadata, assertUnsubscribeUrl, formatEmailAddress, isUnsubscribeLink, mimeAddress, resolveEmail };
64
84
 
65
85
  //# sourceMappingURL=email.mjs.map