@teleporthq/teleport-plugin-next-workflows 0.43.65 → 0.43.66

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 (168) hide show
  1. package/__tests__/cart-clear-db-mirror.test.ts +12 -0
  2. package/__tests__/cart-get-total-regional-pricing.test.ts +166 -0
  3. package/__tests__/continue-on-error.test.ts +119 -0
  4. package/__tests__/invoice-delivery-fee.test.ts +104 -0
  5. package/__tests__/invoice-regional-tax.test.ts +126 -0
  6. package/__tests__/payment-redirect-and-order-notification.test.ts +6 -0
  7. package/__tests__/paypal-webhook-order-notification.test.ts +41 -0
  8. package/__tests__/sent-email-log-module.test.ts +381 -0
  9. package/__tests__/sent-email-log-wiring.test.ts +267 -0
  10. package/dist/cjs/account-delete-route-generator.d.ts.map +1 -1
  11. package/dist/cjs/account-delete-route-generator.js +14 -5
  12. package/dist/cjs/account-delete-route-generator.js.map +1 -1
  13. package/dist/cjs/api-route-generator.d.ts.map +1 -1
  14. package/dist/cjs/api-route-generator.js +44 -9
  15. package/dist/cjs/api-route-generator.js.map +1 -1
  16. package/dist/cjs/auth-generator.d.ts.map +1 -1
  17. package/dist/cjs/auth-generator.js +15 -6
  18. package/dist/cjs/auth-generator.js.map +1 -1
  19. package/dist/cjs/executor-generator.d.ts.map +1 -1
  20. package/dist/cjs/executor-generator.js +17 -4
  21. package/dist/cjs/executor-generator.js.map +1 -1
  22. package/dist/cjs/index.d.ts +1 -0
  23. package/dist/cjs/index.d.ts.map +1 -1
  24. package/dist/cjs/index.js +3 -1
  25. package/dist/cjs/index.js.map +1 -1
  26. package/dist/cjs/invoice/api-routes-code.d.ts.map +1 -1
  27. package/dist/cjs/invoice/api-routes-code.js +42 -4
  28. package/dist/cjs/invoice/api-routes-code.js.map +1 -1
  29. package/dist/cjs/invoice/email-sender-code.d.ts.map +1 -1
  30. package/dist/cjs/invoice/email-sender-code.js +58 -29
  31. package/dist/cjs/invoice/email-sender-code.js.map +1 -1
  32. package/dist/cjs/invoice/index.d.ts +1 -4
  33. package/dist/cjs/invoice/index.d.ts.map +1 -1
  34. package/dist/cjs/invoice/index.js +5 -19
  35. package/dist/cjs/invoice/index.js.map +1 -1
  36. package/dist/cjs/invoice/invoice-html-code.d.ts.map +1 -1
  37. package/dist/cjs/invoice/invoice-html-code.js +31 -8
  38. package/dist/cjs/invoice/invoice-html-code.js.map +1 -1
  39. package/dist/cjs/invoice/regional-invoice-tax-code.d.ts +25 -0
  40. package/dist/cjs/invoice/regional-invoice-tax-code.d.ts.map +1 -0
  41. package/dist/cjs/invoice/regional-invoice-tax-code.js +116 -0
  42. package/dist/cjs/invoice/regional-invoice-tax-code.js.map +1 -0
  43. package/dist/cjs/invoice/resolve-data-source.d.ts +6 -0
  44. package/dist/cjs/invoice/resolve-data-source.d.ts.map +1 -0
  45. package/dist/cjs/invoice/resolve-data-source.js +23 -0
  46. package/dist/cjs/invoice/resolve-data-source.js.map +1 -0
  47. package/dist/cjs/nodes/cart/cart-clear.d.ts.map +1 -1
  48. package/dist/cjs/nodes/cart/cart-clear.js +6 -0
  49. package/dist/cjs/nodes/cart/cart-clear.js.map +1 -1
  50. package/dist/cjs/nodes/cart/cart-get-total.d.ts.map +1 -1
  51. package/dist/cjs/nodes/cart/cart-get-total.js +81 -5
  52. package/dist/cjs/nodes/cart/cart-get-total.js.map +1 -1
  53. package/dist/cjs/nodes/data/data-create-item.d.ts.map +1 -1
  54. package/dist/cjs/nodes/data/data-create-item.js +8 -0
  55. package/dist/cjs/nodes/data/data-create-item.js.map +1 -1
  56. package/dist/cjs/sent-email-log/index.d.ts +16 -0
  57. package/dist/cjs/sent-email-log/index.d.ts.map +1 -0
  58. package/dist/cjs/sent-email-log/index.js +53 -0
  59. package/dist/cjs/sent-email-log/index.js.map +1 -0
  60. package/dist/cjs/sent-email-log/sent-email-log-code.d.ts +4 -0
  61. package/dist/cjs/sent-email-log/sent-email-log-code.d.ts.map +1 -0
  62. package/dist/cjs/sent-email-log/sent-email-log-code.js +415 -0
  63. package/dist/cjs/sent-email-log/sent-email-log-code.js.map +1 -0
  64. package/dist/cjs/sent-email-log/sent-email-log-scope.d.ts +21 -0
  65. package/dist/cjs/sent-email-log/sent-email-log-scope.d.ts.map +1 -0
  66. package/dist/cjs/sent-email-log/sent-email-log-scope.js +36 -0
  67. package/dist/cjs/sent-email-log/sent-email-log-scope.js.map +1 -0
  68. package/dist/cjs/transactional-email-code.d.ts +12 -1
  69. package/dist/cjs/transactional-email-code.d.ts.map +1 -1
  70. package/dist/cjs/transactional-email-code.js +43 -5
  71. package/dist/cjs/transactional-email-code.js.map +1 -1
  72. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  73. package/dist/cjs/webhook-generator.d.ts.map +1 -1
  74. package/dist/cjs/webhook-generator.js +9 -2
  75. package/dist/cjs/webhook-generator.js.map +1 -1
  76. package/dist/cjs/workflow-project-plugin.d.ts.map +1 -1
  77. package/dist/cjs/workflow-project-plugin.js +14 -0
  78. package/dist/cjs/workflow-project-plugin.js.map +1 -1
  79. package/dist/esm/account-delete-route-generator.d.ts.map +1 -1
  80. package/dist/esm/account-delete-route-generator.js +15 -6
  81. package/dist/esm/account-delete-route-generator.js.map +1 -1
  82. package/dist/esm/api-route-generator.d.ts.map +1 -1
  83. package/dist/esm/api-route-generator.js +44 -9
  84. package/dist/esm/api-route-generator.js.map +1 -1
  85. package/dist/esm/auth-generator.d.ts.map +1 -1
  86. package/dist/esm/auth-generator.js +15 -6
  87. package/dist/esm/auth-generator.js.map +1 -1
  88. package/dist/esm/executor-generator.d.ts.map +1 -1
  89. package/dist/esm/executor-generator.js +17 -4
  90. package/dist/esm/executor-generator.js.map +1 -1
  91. package/dist/esm/index.d.ts +1 -0
  92. package/dist/esm/index.d.ts.map +1 -1
  93. package/dist/esm/index.js +1 -0
  94. package/dist/esm/index.js.map +1 -1
  95. package/dist/esm/invoice/api-routes-code.d.ts.map +1 -1
  96. package/dist/esm/invoice/api-routes-code.js +42 -4
  97. package/dist/esm/invoice/api-routes-code.js.map +1 -1
  98. package/dist/esm/invoice/email-sender-code.d.ts.map +1 -1
  99. package/dist/esm/invoice/email-sender-code.js +58 -29
  100. package/dist/esm/invoice/email-sender-code.js.map +1 -1
  101. package/dist/esm/invoice/index.d.ts +1 -4
  102. package/dist/esm/invoice/index.d.ts.map +1 -1
  103. package/dist/esm/invoice/index.js +4 -18
  104. package/dist/esm/invoice/index.js.map +1 -1
  105. package/dist/esm/invoice/invoice-html-code.d.ts.map +1 -1
  106. package/dist/esm/invoice/invoice-html-code.js +31 -8
  107. package/dist/esm/invoice/invoice-html-code.js.map +1 -1
  108. package/dist/esm/invoice/regional-invoice-tax-code.d.ts +25 -0
  109. package/dist/esm/invoice/regional-invoice-tax-code.d.ts.map +1 -0
  110. package/dist/esm/invoice/regional-invoice-tax-code.js +113 -0
  111. package/dist/esm/invoice/regional-invoice-tax-code.js.map +1 -0
  112. package/dist/esm/invoice/resolve-data-source.d.ts +6 -0
  113. package/dist/esm/invoice/resolve-data-source.d.ts.map +1 -0
  114. package/dist/esm/invoice/resolve-data-source.js +19 -0
  115. package/dist/esm/invoice/resolve-data-source.js.map +1 -0
  116. package/dist/esm/nodes/cart/cart-clear.d.ts.map +1 -1
  117. package/dist/esm/nodes/cart/cart-clear.js +6 -0
  118. package/dist/esm/nodes/cart/cart-clear.js.map +1 -1
  119. package/dist/esm/nodes/cart/cart-get-total.d.ts.map +1 -1
  120. package/dist/esm/nodes/cart/cart-get-total.js +81 -5
  121. package/dist/esm/nodes/cart/cart-get-total.js.map +1 -1
  122. package/dist/esm/nodes/data/data-create-item.d.ts.map +1 -1
  123. package/dist/esm/nodes/data/data-create-item.js +8 -0
  124. package/dist/esm/nodes/data/data-create-item.js.map +1 -1
  125. package/dist/esm/sent-email-log/index.d.ts +16 -0
  126. package/dist/esm/sent-email-log/index.d.ts.map +1 -0
  127. package/dist/esm/sent-email-log/index.js +45 -0
  128. package/dist/esm/sent-email-log/index.js.map +1 -0
  129. package/dist/esm/sent-email-log/sent-email-log-code.d.ts +4 -0
  130. package/dist/esm/sent-email-log/sent-email-log-code.d.ts.map +1 -0
  131. package/dist/esm/sent-email-log/sent-email-log-code.js +411 -0
  132. package/dist/esm/sent-email-log/sent-email-log-code.js.map +1 -0
  133. package/dist/esm/sent-email-log/sent-email-log-scope.d.ts +21 -0
  134. package/dist/esm/sent-email-log/sent-email-log-scope.d.ts.map +1 -0
  135. package/dist/esm/sent-email-log/sent-email-log-scope.js +31 -0
  136. package/dist/esm/sent-email-log/sent-email-log-scope.js.map +1 -0
  137. package/dist/esm/transactional-email-code.d.ts +12 -1
  138. package/dist/esm/transactional-email-code.d.ts.map +1 -1
  139. package/dist/esm/transactional-email-code.js +42 -4
  140. package/dist/esm/transactional-email-code.js.map +1 -1
  141. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  142. package/dist/esm/webhook-generator.d.ts.map +1 -1
  143. package/dist/esm/webhook-generator.js +9 -2
  144. package/dist/esm/webhook-generator.js.map +1 -1
  145. package/dist/esm/workflow-project-plugin.d.ts.map +1 -1
  146. package/dist/esm/workflow-project-plugin.js +14 -0
  147. package/dist/esm/workflow-project-plugin.js.map +1 -1
  148. package/package.json +4 -4
  149. package/src/account-delete-route-generator.ts +16 -5
  150. package/src/api-route-generator.ts +51 -9
  151. package/src/auth-generator.ts +17 -6
  152. package/src/executor-generator.ts +17 -4
  153. package/src/index.ts +1 -0
  154. package/src/invoice/api-routes-code.ts +48 -4
  155. package/src/invoice/email-sender-code.ts +58 -29
  156. package/src/invoice/index.ts +4 -23
  157. package/src/invoice/invoice-html-code.ts +31 -8
  158. package/src/invoice/regional-invoice-tax-code.ts +112 -0
  159. package/src/invoice/resolve-data-source.ts +25 -0
  160. package/src/nodes/cart/cart-clear.ts +6 -0
  161. package/src/nodes/cart/cart-get-total.ts +94 -5
  162. package/src/nodes/data/data-create-item.ts +8 -0
  163. package/src/sent-email-log/index.ts +57 -0
  164. package/src/sent-email-log/sent-email-log-code.ts +419 -0
  165. package/src/sent-email-log/sent-email-log-scope.ts +36 -0
  166. package/src/transactional-email-code.ts +61 -4
  167. package/src/webhook-generator.ts +11 -2
  168. package/src/workflow-project-plugin.ts +15 -0
@@ -0,0 +1,381 @@
1
+ // The sent-email ledger module is emitted as a runtime string; these tests
2
+ // EXECUTE it against a stubbed `pg` so the row it writes, its allow-list, its
3
+ // disable rules and its drain contract are proven on the code that ships.
4
+ import { generateSentEmailLogCode } from '../src/sent-email-log/sent-email-log-code'
5
+
6
+ interface StubQuery {
7
+ sql: string
8
+ values: unknown[]
9
+ }
10
+
11
+ const loadModule = (options: {
12
+ queryImpl?: (sql: string, values: unknown[]) => Promise<unknown>
13
+ pgAvailable?: boolean
14
+ env?: Record<string, string>
15
+ }) => {
16
+ const queries: StubQuery[] = []
17
+ const connects: number[] = []
18
+ const ends: number[] = []
19
+ const queryImpl = options.queryImpl || (async () => ({ rowCount: 1 }))
20
+
21
+ class Client {
22
+ async connect() {
23
+ connects.push(1)
24
+ }
25
+ async query(sql: string, values: unknown[]) {
26
+ queries.push({ sql, values })
27
+ return queryImpl(sql, values)
28
+ }
29
+ async end() {
30
+ ends.push(1)
31
+ }
32
+ }
33
+
34
+ const requireShim = (id: string) => {
35
+ if (id === 'pg') {
36
+ if (options.pgAvailable === false) {
37
+ throw new Error("Cannot find module 'pg'")
38
+ }
39
+ return { Client }
40
+ }
41
+ if (id === 'crypto') {
42
+ return require('crypto')
43
+ }
44
+ throw new Error('unexpected require: ' + id)
45
+ }
46
+
47
+ const savedEnv = { ...process.env }
48
+ process.env.TELEPORT_DB_CONNECTION_STRING = 'postgresql://u:p@localhost:5432/db'
49
+ Object.assign(process.env, options.env || {})
50
+
51
+ const module = { exports: {} as any }
52
+ const code = generateSentEmailLogCode({ pgSupported: true })
53
+ // eslint-disable-next-line no-new-func
54
+ new Function('module', 'exports', 'require', 'process', 'console', code)(
55
+ module,
56
+ module.exports,
57
+ requireShim,
58
+ process,
59
+ silentConsole
60
+ )
61
+
62
+ const restore = () => {
63
+ for (const key of Object.keys(process.env)) {
64
+ if (!(key in savedEnv)) {
65
+ delete process.env[key]
66
+ }
67
+ }
68
+ Object.assign(process.env, savedEnv)
69
+ }
70
+
71
+ return { api: module.exports, queries, connects, ends, restore }
72
+ }
73
+
74
+ const noop = (): void => undefined
75
+ const silentConsole = { warn: noop, error: noop, log: noop, info: noop }
76
+
77
+ const rowOf = (query: StubQuery): Record<string, unknown> => {
78
+ const columns = /\(([^)]+)\) VALUES/
79
+ .exec(query.sql)![1]
80
+ .split(',')
81
+ .map((c) => c.trim().replace(/"/g, ''))
82
+ const row: Record<string, unknown> = {}
83
+ columns.forEach((column, index) => {
84
+ row[column] = query.values[index]
85
+ })
86
+ return row
87
+ }
88
+
89
+ describe('sent-email-log runtime module', () => {
90
+ it('writes one parameterized row per attempt, with a fresh id and the allow-listed fields only', async () => {
91
+ const { api, queries, connects, ends, restore } = loadModule({})
92
+ try {
93
+ const landed = await api.recordSentEmail({
94
+ emailType: 'order-shipped',
95
+ audience: 'customer',
96
+ to: 'jane@example.com',
97
+ cc: 'ops@example.com, boss@example.com',
98
+ from: 'Acme Store <shop@acme.com>',
99
+ subject: 'Your parcel is on its way',
100
+ html: '<p>Hi Jane</p>',
101
+ payload: [
102
+ { key: 'customerName', value: 'Jane' },
103
+ { key: 'items', value: [{ name: 'Mug', qty: 2 }] },
104
+ ],
105
+ provider: 'resend',
106
+ providerMessageId: 'msg_1',
107
+ status: 'sent',
108
+ source: 'workflow',
109
+ sourceRef: 'email-resend',
110
+ orderId: '5f2b1a1e-8d7f-4c2e-9a1b-2c3d4e5f6a7b',
111
+ userId: 'not-a-uuid',
112
+ apiKey: 'sk_live_secret',
113
+ })
114
+ expect(landed).toBe(true)
115
+ expect(queries).toHaveLength(1)
116
+ expect(connects).toHaveLength(1)
117
+ expect(ends).toHaveLength(1)
118
+ expect(queries[0].sql).toMatch(/^INSERT INTO "teleport_sent_emails" \(/)
119
+ expect(queries[0].sql).not.toContain('sk_live_secret')
120
+ const row = rowOf(queries[0])
121
+ expect(row.id).toMatch(/^[0-9a-f-]{36}$/)
122
+ expect(row.email_type).toBe('order-shipped')
123
+ expect(row.audience).toBe('customer')
124
+ expect(row.recipient_email).toBe('jane@example.com')
125
+ expect(row.recipients).toBe('["jane@example.com"]')
126
+ expect(row.cc).toBe('["ops@example.com","boss@example.com"]')
127
+ expect(row.from_email).toBe('shop@acme.com')
128
+ expect(row.from_name).toBe('Acme Store')
129
+ expect(row.subject).toBe('Your parcel is on its way')
130
+ expect(row.body_html).toBe('<p>Hi Jane</p>')
131
+ expect(JSON.parse(row.payload as string)).toEqual({
132
+ customerName: 'Jane',
133
+ items: [{ name: 'Mug', qty: 2 }],
134
+ })
135
+ expect(row.provider).toBe('resend')
136
+ expect(row.provider_message_id).toBe('msg_1')
137
+ expect(row.status).toBe('sent')
138
+ expect(row.source).toBe('workflow')
139
+ expect(row.order_id).toBe('5f2b1a1e-8d7f-4c2e-9a1b-2c3d4e5f6a7b')
140
+ // A non-UUID never reaches a UUID column (22P02 would fail the insert).
141
+ expect(row.user_id).toBeNull()
142
+ expect(Object.keys(row)).not.toContain('apiKey')
143
+ expect(queries[0].values.join(' ')).not.toContain('sk_live_secret')
144
+ } finally {
145
+ restore()
146
+ }
147
+ })
148
+
149
+ it('redacts token-bearing parameters in the payload AND scrubs their values from the body', async () => {
150
+ const { api, queries, restore } = loadModule({})
151
+ try {
152
+ await api.recordSentEmail({
153
+ emailType: 'forgot-password',
154
+ to: 'jane@example.com',
155
+ subject: 'Reset',
156
+ html: '<a href="https://shop.test/reset?token=abcdef123456">Reset</a>',
157
+ payload: [
158
+ { key: 'resetUrl', value: 'https://shop.test/reset?token=abcdef123456' },
159
+ { key: 'userName', value: 'Jane' },
160
+ ],
161
+ source: 'workflow',
162
+ })
163
+ const row = rowOf(queries[0])
164
+ expect(JSON.parse(row.payload as string)).toEqual({
165
+ resetUrl: '[redacted]',
166
+ userName: 'Jane',
167
+ })
168
+ expect(row.body_html).toBe('<a href="[redacted]">Reset</a>')
169
+ } finally {
170
+ restore()
171
+ }
172
+ })
173
+
174
+ it('records nothing without a recipient, and never rejects', async () => {
175
+ const { api, queries, restore } = loadModule({})
176
+ try {
177
+ await expect(api.recordSentEmail({ emailType: 'x', to: '', html: 'a' })).resolves.toBe(false)
178
+ await expect(api.recordSentEmail(null)).resolves.toBe(false)
179
+ expect(queries).toHaveLength(0)
180
+ } finally {
181
+ restore()
182
+ }
183
+ })
184
+
185
+ it('disables itself for the process once the table is missing (42P01)', async () => {
186
+ const { api, queries, restore } = loadModule({
187
+ queryImpl: async () => {
188
+ const err: any = new Error('relation "teleport_sent_emails" does not exist')
189
+ err.code = '42P01'
190
+ throw err
191
+ },
192
+ })
193
+ try {
194
+ await expect(api.recordSentEmail({ to: 'a@b.c', html: 'x' })).resolves.toBe(false)
195
+ await expect(api.recordSentEmail({ to: 'a@b.c', html: 'x' })).resolves.toBe(false)
196
+ expect(queries).toHaveLength(1)
197
+ } finally {
198
+ restore()
199
+ }
200
+ })
201
+
202
+ it('keeps trying through transient failures and gives up after three in a row', async () => {
203
+ let calls = 0
204
+ const { api, queries, restore } = loadModule({
205
+ queryImpl: async () => {
206
+ calls += 1
207
+ throw new Error('connection reset')
208
+ },
209
+ })
210
+ try {
211
+ for (let i = 0; i < 5; i++) {
212
+ await api.recordSentEmail({ to: 'a@b.c', html: 'x' })
213
+ }
214
+ expect(calls).toBe(3)
215
+ expect(queries).toHaveLength(3)
216
+ } finally {
217
+ restore()
218
+ }
219
+ })
220
+
221
+ it('is a no-op when the pg driver is not installed or no database is configured', async () => {
222
+ const withoutPg = loadModule({ pgAvailable: false })
223
+ try {
224
+ await expect(withoutPg.api.recordSentEmail({ to: 'a@b.c', html: 'x' })).resolves.toBe(false)
225
+ expect(withoutPg.queries).toHaveLength(0)
226
+ } finally {
227
+ withoutPg.restore()
228
+ }
229
+ const withoutDb = loadModule({})
230
+ try {
231
+ delete process.env.TELEPORT_DB_CONNECTION_STRING
232
+ delete process.env.TELEPORT_DB_HOST
233
+ await expect(withoutDb.api.recordSentEmail({ to: 'a@b.c', html: 'x' })).resolves.toBe(false)
234
+ expect(withoutDb.queries).toHaveLength(0)
235
+ } finally {
236
+ withoutDb.restore()
237
+ }
238
+ })
239
+
240
+ it('caps an oversized body and payload with a marker instead of failing', async () => {
241
+ const { api, queries, restore } = loadModule({})
242
+ try {
243
+ await api.recordSentEmail({
244
+ to: 'a@b.c',
245
+ html: 'x'.repeat(1000100),
246
+ payload: { blob: 'y'.repeat(300000) },
247
+ })
248
+ const row = rowOf(queries[0])
249
+ expect((row.body_html as string).length).toBeLessThan(1000200)
250
+ expect(row.body_html).toContain('truncated by sent-email-log')
251
+ expect(row.payload).toContain('truncated by sent-email-log')
252
+ } finally {
253
+ restore()
254
+ }
255
+ })
256
+
257
+ it('settleSentEmailLog waits for every in-flight record', async () => {
258
+ let release: () => void = () => undefined
259
+ const { api, queries, restore } = loadModule({
260
+ queryImpl: () =>
261
+ new Promise((resolve) => {
262
+ release = () => resolve({ rowCount: 1 })
263
+ }),
264
+ })
265
+ try {
266
+ const pending = api.recordSentEmail({ to: 'a@b.c', html: 'x' })
267
+ let settled = false
268
+ const settle = api.settleSentEmailLog().then(() => {
269
+ settled = true
270
+ })
271
+ await new Promise((r) => setTimeout(r, 5))
272
+ expect(settled).toBe(false)
273
+ release()
274
+ await pending
275
+ await settle
276
+ expect(settled).toBe(true)
277
+ expect(queries).toHaveLength(1)
278
+ } finally {
279
+ restore()
280
+ }
281
+ })
282
+
283
+ describe('wrapEmailNodeHandler', () => {
284
+ it('records after the handler resolves and registers the record on the workflow context queue', async () => {
285
+ const { api, queries, restore } = loadModule({})
286
+ try {
287
+ const handler = jest.fn(async () => ({ id: 'rs_1', success: true }))
288
+ const wrapped = api.wrapEmailNodeHandler('email-resend', handler)
289
+ const context: any = { __pendingNodePromises: [] }
290
+ const config = {
291
+ to: ['a@b.c'],
292
+ from: 'shop@acme.com',
293
+ subject: 'Hi',
294
+ body: '<p>x</p>',
295
+ emailPurpose: 'welcome',
296
+ emailAudience: 'customer',
297
+ apiKey: 'secret',
298
+ }
299
+ const result = await wrapped(config, context)
300
+ expect(result).toEqual({ id: 'rs_1', success: true })
301
+ expect(context.__pendingNodePromises).toHaveLength(1)
302
+ await Promise.all(context.__pendingNodePromises)
303
+ const row = rowOf(queries[0])
304
+ expect(row.email_type).toBe('welcome')
305
+ expect(row.audience).toBe('customer')
306
+ expect(row.provider).toBe('resend')
307
+ expect(row.provider_message_id).toBe('rs_1')
308
+ expect(row.source).toBe('workflow')
309
+ expect(row.source_ref).toBe('email-resend')
310
+ expect(row.status).toBe('sent')
311
+ } finally {
312
+ restore()
313
+ }
314
+ })
315
+
316
+ it('records a failed attempt when the handler reports failure or throws, and rethrows', async () => {
317
+ const { api, queries, restore } = loadModule({})
318
+ try {
319
+ const refused = api.wrapEmailNodeHandler('email-postmark', async () => ({
320
+ id: '',
321
+ success: false,
322
+ error: 'domain not verified',
323
+ }))
324
+ const ctx1: any = {}
325
+ await refused({ to: 'a@b.c', body: 'x' }, ctx1)
326
+ await Promise.all(ctx1.__pendingNodePromises)
327
+ expect(rowOf(queries[0]).status).toBe('failed')
328
+ expect(rowOf(queries[0]).error_message).toBe('domain not verified')
329
+
330
+ const thrown = api.wrapEmailNodeHandler('email-sendgrid', async () => {
331
+ throw new Error('boom')
332
+ })
333
+ const ctx2: any = {}
334
+ await expect(thrown({ to: 'a@b.c', body: 'x' }, ctx2)).rejects.toThrow('boom')
335
+ await Promise.all(ctx2.__pendingNodePromises)
336
+ expect(rowOf(queries[1]).status).toBe('failed')
337
+ expect(rowOf(queries[1]).error_message).toBe('boom')
338
+ } finally {
339
+ restore()
340
+ }
341
+ })
342
+
343
+ it('records an integration node only for its send-email action, and forwards extra arguments', async () => {
344
+ const { api, queries, restore } = loadModule({})
345
+ try {
346
+ const calls: unknown[][] = []
347
+ const handler = async (...args: unknown[]) => {
348
+ calls.push(args)
349
+ return { success: true, message: { id: 'gm_1' } }
350
+ }
351
+ const wrapped = api.wrapEmailNodeHandler('integration-gmail', handler)
352
+ const ctx: any = {}
353
+ const onChunk = noop
354
+ await wrapped({ action: 'list-messages' }, ctx, onChunk)
355
+ expect(calls[0]).toHaveLength(3)
356
+ expect(ctx.__pendingNodePromises).toBeUndefined()
357
+
358
+ await wrapped({ action: 'send-email', to: 'a@b.c', subject: 's', body: '<p>b</p>' }, ctx)
359
+ await Promise.all(ctx.__pendingNodePromises)
360
+ expect(queries).toHaveLength(1)
361
+ expect(rowOf(queries[0]).provider).toBe('gmail')
362
+ expect(rowOf(queries[0]).provider_message_id).toBe('gm_1')
363
+ expect(rowOf(queries[0]).email_type).toBe('custom')
364
+ } finally {
365
+ restore()
366
+ }
367
+ })
368
+ })
369
+
370
+ it('emits a same-shaped no-op module for a non-Postgres data source', async () => {
371
+ const code = generateSentEmailLogCode({ pgSupported: false })
372
+ const module = { exports: {} as any }
373
+ // eslint-disable-next-line no-new-func
374
+ new Function('module', 'exports', code)(module, module.exports)
375
+ expect(code).not.toContain("require('pg')")
376
+ await expect(module.exports.recordSentEmail({ to: 'a@b.c' })).resolves.toBe(false)
377
+ await expect(module.exports.settleSentEmailLog()).resolves.toBeUndefined()
378
+ const handler = async () => 1
379
+ expect(module.exports.wrapEmailNodeHandler('email-resend', handler)).toBe(handler)
380
+ })
381
+ })
@@ -0,0 +1,267 @@
1
+ // Every path that sends mail from a generated app must go through the
2
+ // sent-email ledger. These tests pin the wiring of each path, and the last
3
+ // one is the guard: any emitted file that talks to a mail provider must also
4
+ // record — a sixth transport cannot appear unlogged.
5
+ import {
6
+ generateServerSegmentAPIRoute,
7
+ generateStreamingServerSegmentAPIRoute,
8
+ generateCronAPIRoute,
9
+ generateWebhookWorkflowAPIRoute,
10
+ } from '../src/api-route-generator'
11
+ import { generateSharedRuntimeUtilsCode } from '../src/executor-generator'
12
+ import { generateEmailSenderCode } from '../src/invoice/email-sender-code'
13
+ import { generateInvoiceGenerateRouteCode } from '../src/invoice/api-routes-code'
14
+ import { generateSignupRouteFile } from '../src/auth-generator'
15
+ import { generateAccountDeleteRoute } from '../src/account-delete-route-generator'
16
+ import { generateProviderSendFunction } from '../src/transactional-email-code'
17
+ import { ensureSentEmailLogModule } from '../src/sent-email-log'
18
+ import { nodeRegistry } from '../src/nodes'
19
+ import { isEmailSendingNodeType } from '../src/sent-email-log/sent-email-log-scope'
20
+ import { WorkflowSegment } from '../src/types'
21
+
22
+ const emailNode = (id: string, type = 'email-resend'): any => ({
23
+ id,
24
+ type,
25
+ config: { to: 'a@b.c', subject: 's', body: '<p>b</p>', apiKey: 'k' },
26
+ executionEnv: 'server',
27
+ stepNumber: 1,
28
+ label: 'Send',
29
+ })
30
+
31
+ const segmentWith = (nodes: any[]): WorkflowSegment => ({
32
+ id: 'seg-1',
33
+ env: 'server',
34
+ nodeIds: nodes.map((n) => n.id),
35
+ nodes,
36
+ edges: [],
37
+ })
38
+
39
+ const PREAMBLE = "require('../../../utils/email/sent-email-log')"
40
+
41
+ describe('sent-email ledger — workflow routes', () => {
42
+ it('wraps every mail-sending node handler and requires the module only when one is present', () => {
43
+ const withEmail = generateServerSegmentAPIRoute(segmentWith([emailNode('e1')]), 'wf')
44
+ expect(withEmail).toContain(PREAMBLE)
45
+ expect(withEmail).toContain("'email-resend': __wrapEmailHandler('email-resend', (function () {")
46
+
47
+ const withoutEmail = generateServerSegmentAPIRoute(
48
+ segmentWith([{ ...emailNode('d1', 'data-select'), config: {} }]),
49
+ 'wf'
50
+ )
51
+ expect(withoutEmail).not.toContain('sent-email-log')
52
+ expect(withoutEmail).not.toContain('__wrapEmailHandler')
53
+ })
54
+
55
+ it('degrades to the bare handler where the module cannot be loaded', () => {
56
+ const route = generateServerSegmentAPIRoute(segmentWith([emailNode('e1')]), 'wf')
57
+ const module = { exports: null as any }
58
+ const requireShim = (id: string) => {
59
+ if (id.endsWith('server-runtime')) {
60
+ const utils = { exports: {} as any }
61
+ // eslint-disable-next-line no-new-func
62
+ new Function('module', 'exports', generateSharedRuntimeUtilsCode())(utils, utils.exports)
63
+ return utils.exports
64
+ }
65
+ throw new Error('missing ' + id)
66
+ }
67
+ // eslint-disable-next-line no-new-func
68
+ expect(() =>
69
+ new Function('module', 'exports', 'require', route)(module, {}, requireShim)
70
+ ).not.toThrow()
71
+ expect(typeof module.exports).toBe('function')
72
+ })
73
+
74
+ it('is wired into the streaming, cron and webhook routes too', () => {
75
+ const streaming = generateStreamingServerSegmentAPIRoute(segmentWith([emailNode('e1')]), 'wf')
76
+ expect(streaming).toContain(PREAMBLE)
77
+ expect(streaming).toContain('__wrapEmailHandler(')
78
+
79
+ const baseWorkflow = {
80
+ id: 'wf-1',
81
+ name: 'WF',
82
+ nodes: [emailNode('e1', 'email-postmark')],
83
+ edges: [],
84
+ }
85
+ const cron = generateCronAPIRoute({
86
+ ...baseWorkflow,
87
+ trigger: {
88
+ type: 'event-cron-triggered',
89
+ nodeId: 't',
90
+ scope: 'global',
91
+ config: { schedule: '0 * * * *', urlPath: 'cron_x' },
92
+ },
93
+ } as any)
94
+ expect(cron).toContain(PREAMBLE)
95
+ expect(cron).toContain("__wrapEmailHandler('email-postmark'")
96
+ // A failed run still lands the rows the emails it DID send produced.
97
+ expect(cron).toContain(
98
+ "console.error('Cron workflow error:', error);\n if (__wfContext) { await utils.settlePendingNodePromises(__wfContext); }"
99
+ )
100
+
101
+ const webhook = generateWebhookWorkflowAPIRoute({
102
+ ...baseWorkflow,
103
+ trigger: { type: 'event-webhook-triggered', nodeId: 't', scope: 'global', config: {} },
104
+ webhookConfig: { urlPath: 'hooks/x', httpMethod: 'POST', signatureSecret: '' },
105
+ } as any)
106
+ expect(webhook).toContain("/utils/email/sent-email-log')")
107
+ expect(webhook).toContain("__wrapEmailHandler('email-postmark'")
108
+ expect(webhook).toContain(
109
+ "if (typeof context !== 'undefined' && context) { await utils.settlePendingNodePromises(context); }"
110
+ )
111
+ })
112
+
113
+ it('the shared drain settles in place so a still-running parallel branch keeps the same queue', async () => {
114
+ const utils = { exports: {} as any }
115
+ // eslint-disable-next-line no-new-func
116
+ new Function('module', 'exports', generateSharedRuntimeUtilsCode())(utils, utils.exports)
117
+ const context: any = { __pendingNodePromises: [Promise.resolve()] }
118
+ const branchView = context.__pendingNodePromises
119
+ await utils.exports.settlePendingNodePromises(context)
120
+ expect(context.__pendingNodePromises).toBe(branchView)
121
+ let landed = false
122
+ branchView.push(
123
+ Promise.resolve().then(() => {
124
+ landed = true
125
+ })
126
+ )
127
+ await utils.exports.settlePendingNodePromises(context)
128
+ expect(landed).toBe(true)
129
+ })
130
+ })
131
+
132
+ describe('sent-email ledger — invoice, welcome, farewell', () => {
133
+ const delivery: any = {
134
+ enabled: true,
135
+ provider: 'resend',
136
+ fromEmail: 'billing@acme.com',
137
+ fromName: 'Acme',
138
+ subject: 'Invoice {{invoiceNumber}}',
139
+ body: '<p>Attached</p>',
140
+ secretKeys: { apiKey: 'INVOICE_RESEND_APIKEY' },
141
+ }
142
+
143
+ it('the invoice sender builds the message once and records it with the PDF as attachment metadata', () => {
144
+ const code = generateEmailSenderCode(delivery)
145
+ expect(code).toContain("require('../email/sent-email-log')")
146
+ expect(code).toContain(
147
+ 'async function sendInvoiceEmailWithProvider(emailData, invoiceData, pdfBuffer)'
148
+ )
149
+ expect(code).toContain("emailType: 'invoice-email'")
150
+ expect(code).toContain("contentType: 'application/pdf'")
151
+ expect(code).toContain('url: invoiceData.pdfUrl || null')
152
+ expect(code).not.toContain("content: pdfBuffer.toString('base64'),\n url")
153
+ // The provider body no longer rebuilds the message, so the row is what went out.
154
+ expect(code.split('= buildEmailData(invoiceData)')).toHaveLength(2)
155
+ expect(code).toContain('module.exports = { sendInvoiceEmail }')
156
+ })
157
+
158
+ it('the invoice route settles the ledger before every reply', () => {
159
+ const route = generateInvoiceGenerateRouteCode({
160
+ enabled: true,
161
+ emailDelivery: delivery,
162
+ } as any)
163
+ expect(route).toContain("var sentEmailLog = require('../../../utils/email/sent-email-log')")
164
+ expect(route.split('await sentEmailLog.settleSentEmailLog()')).toHaveLength(3)
165
+ const noEmail = generateInvoiceGenerateRouteCode({ enabled: true } as any)
166
+ expect(noEmail).not.toContain('sent-email-log')
167
+ })
168
+
169
+ it('a transactional send records success and failure and rethrows', () => {
170
+ const code = generateProviderSendFunction('sendgrid', {
171
+ emailType: 'welcome',
172
+ source: 'signup',
173
+ sourceRef: 'api/auth/signup',
174
+ relativePrefix: '../../..',
175
+ })
176
+ expect(code).toContain('async function __sendProviderEmailRaw(msg)')
177
+ expect(code).toContain('async function __sendProviderEmail(msg)')
178
+ expect(code).toContain('emailType: "welcome"')
179
+ expect(code).toContain('if (failure) { throw failure; }')
180
+ // No provider ⇒ the no-op, no ledger.
181
+ expect(
182
+ generateProviderSendFunction(null, {
183
+ emailType: 'x',
184
+ source: 'y',
185
+ sourceRef: 'z',
186
+ relativePrefix: '.',
187
+ })
188
+ ).not.toContain('sent-email-log')
189
+ })
190
+
191
+ it('signup and account-delete routes pass the token values + user id and settle before replying', () => {
192
+ const signup = generateSignupRouteFile({ enabled: true, dataSourceType: 'teleport' } as any, {
193
+ emailProvider: 'resend',
194
+ fromEmail: 'hi@acme.com',
195
+ emailSecretEnvName: 'RESEND_KEY',
196
+ emailBodyHtml: '<p>Welcome {{userName}}</p>',
197
+ })
198
+ expect(signup).toContain('emailType: "welcome"')
199
+ expect(signup).toContain('tokenValues: tokenValues, userId: userId')
200
+ expect(signup).toContain('}, newUser && newUser.id);')
201
+ expect(signup).toContain(
202
+ "if (typeof __sentEmailLog !== 'undefined') { await __sentEmailLog.settleSentEmailLog(); }"
203
+ )
204
+
205
+ const del = generateAccountDeleteRoute({
206
+ authUsersTableName: 'users',
207
+ emailProvider: 'postmark',
208
+ fromEmail: 'hi@acme.com',
209
+ emailSecretEnvName: 'PM',
210
+ emailBodyHtml: '<p>Bye</p>',
211
+ })
212
+ expect(del).toContain('emailType: "account-deleted"')
213
+ expect(del).toContain('}, userId);')
214
+ expect(del).toContain('await __sentEmailLog.settleSentEmailLog()')
215
+
216
+ const silent = generateAccountDeleteRoute({ authUsersTableName: 'users' })
217
+ expect(silent).not.toContain('sent-email-log')
218
+ })
219
+ })
220
+
221
+ describe('sent-email ledger — module emission', () => {
222
+ const structureFor = (dataSourceType: string | null): any => ({
223
+ uidl: {
224
+ name: 'p',
225
+ dataSources: dataSourceType ? { ds1: { type: dataSourceType, config: {} } } : undefined,
226
+ globals: { env: {} },
227
+ },
228
+ files: new Map(),
229
+ dependencies: {},
230
+ })
231
+
232
+ it('emits the module once, under utils/email, and adds pg only for a Postgres data source', () => {
233
+ const pg = structureFor('teleport')
234
+ ensureSentEmailLogModule(pg)
235
+ ensureSentEmailLogModule(pg)
236
+ expect(pg.files.get('sent-email-log').path).toEqual(['utils', 'email'])
237
+ expect(pg.files.get('sent-email-log').files[0].content).toContain(
238
+ "var TABLE_NAME = 'teleport_sent_emails'"
239
+ )
240
+ expect(pg.dependencies.pg).toBeDefined()
241
+
242
+ const mysql = structureFor('mysql')
243
+ ensureSentEmailLogModule(mysql)
244
+ expect(mysql.files.get('sent-email-log').files[0].content).toContain('disabled')
245
+ expect(mysql.dependencies.pg).toBeUndefined()
246
+ })
247
+ })
248
+
249
+ describe('sent-email ledger — coverage guard', () => {
250
+ it('every registered node that talks to a mail provider is classified as mail-sending', () => {
251
+ // One pattern per mail transport a handler could embed: the Resend /
252
+ // SendGrid / Postmark / Mailgun / MailerSend SDK calls, nodemailer, and the
253
+ // Gmail / Graph REST send endpoints.
254
+ const providerCall =
255
+ /resend\.emails\.send\(|sgMail\.send\(|ServerClient\(|mg\.messages\.create\(|mailerSend\.email\.send\(|\.sendMail\(|'messages\/send'|'sendMail'/
256
+ const missing: string[] = []
257
+ for (const [nodeType, generator] of Object.entries(nodeRegistry)) {
258
+ const source = generator.generateServerHandler
259
+ ? generator.generateServerHandler()
260
+ : generator.generateHandler()
261
+ if (providerCall.test(source) && !isEmailSendingNodeType(nodeType)) {
262
+ missing.push(nodeType)
263
+ }
264
+ }
265
+ expect(missing).toEqual([])
266
+ })
267
+ })
@@ -1 +1 @@
1
- {"version":3,"file":"account-delete-route-generator.d.ts","sourceRoot":"","sources":["../../src/account-delete-route-generator.ts"],"names":[],"mappings":"AA2BA,MAAM,WAAW,yBAAyB;IAExC,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAI3B,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,kBAAkB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAClC,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,aAAa,CAAC,EAAE,MAAM,CAAA;IAEtB,QAAQ,CAAC,EAAE,MAAM,CAAA;IAEjB,mBAAmB,CAAC,EAAE,MAAM,CAAA;CAC7B;AAID,eAAO,MAAM,8BAA8B,+CAAiC,CAAA;AAE5E,eAAO,MAAM,0BAA0B,aAAa,yBAAyB,KAAQ,MA4NpF,CAAA"}
1
+ {"version":3,"file":"account-delete-route-generator.d.ts","sourceRoot":"","sources":["../../src/account-delete-route-generator.ts"],"names":[],"mappings":"AA2BA,MAAM,WAAW,yBAAyB;IAExC,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAI3B,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,kBAAkB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAClC,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,aAAa,CAAC,EAAE,MAAM,CAAA;IAEtB,QAAQ,CAAC,EAAE,MAAM,CAAA;IAEjB,mBAAmB,CAAC,EAAE,MAAM,CAAA;CAC7B;AAID,eAAO,MAAM,8BAA8B,+CAAiC,CAAA;AAE5E,eAAO,MAAM,0BAA0B,aAAa,yBAAyB,KAAQ,MAuOpF,CAAA"}