digital-workers 2.1.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +136 -180
  3. package/dist/actions.d.ts.map +1 -1
  4. package/dist/actions.js +34 -21
  5. package/dist/actions.js.map +1 -1
  6. package/dist/agent-comms.d.ts +438 -0
  7. package/dist/agent-comms.d.ts.map +1 -0
  8. package/dist/agent-comms.js +677 -0
  9. package/dist/agent-comms.js.map +1 -0
  10. package/dist/approve.d.ts +40 -8
  11. package/dist/approve.d.ts.map +1 -1
  12. package/dist/approve.js +86 -20
  13. package/dist/approve.js.map +1 -1
  14. package/dist/ask.d.ts +38 -7
  15. package/dist/ask.d.ts.map +1 -1
  16. package/dist/ask.js +85 -25
  17. package/dist/ask.js.map +1 -1
  18. package/dist/browse.d.ts +223 -0
  19. package/dist/browse.d.ts.map +1 -0
  20. package/dist/browse.js +392 -0
  21. package/dist/browse.js.map +1 -0
  22. package/dist/capability-tiers.d.ts +230 -0
  23. package/dist/capability-tiers.d.ts.map +1 -0
  24. package/dist/capability-tiers.js +388 -0
  25. package/dist/capability-tiers.js.map +1 -0
  26. package/dist/cascade-context.d.ts +523 -0
  27. package/dist/cascade-context.d.ts.map +1 -0
  28. package/dist/cascade-context.js +494 -0
  29. package/dist/cascade-context.js.map +1 -0
  30. package/dist/client.d.ts +162 -0
  31. package/dist/client.d.ts.map +1 -0
  32. package/dist/client.js +64 -0
  33. package/dist/client.js.map +1 -0
  34. package/dist/decide.d.ts +42 -6
  35. package/dist/decide.d.ts.map +1 -1
  36. package/dist/decide.js +54 -11
  37. package/dist/decide.js.map +1 -1
  38. package/dist/do.d.ts +36 -7
  39. package/dist/do.d.ts.map +1 -1
  40. package/dist/do.js +82 -39
  41. package/dist/do.js.map +1 -1
  42. package/dist/error-escalation.d.ts +416 -0
  43. package/dist/error-escalation.d.ts.map +1 -0
  44. package/dist/error-escalation.js +656 -0
  45. package/dist/error-escalation.js.map +1 -0
  46. package/dist/generate.d.ts +48 -7
  47. package/dist/generate.d.ts.map +1 -1
  48. package/dist/generate.js +49 -8
  49. package/dist/generate.js.map +1 -1
  50. package/dist/goals.d.ts +10 -9
  51. package/dist/goals.d.ts.map +1 -1
  52. package/dist/goals.js +30 -24
  53. package/dist/goals.js.map +1 -1
  54. package/dist/image.d.ts +189 -0
  55. package/dist/image.d.ts.map +1 -0
  56. package/dist/image.js +528 -0
  57. package/dist/image.js.map +1 -0
  58. package/dist/index.d.ts +59 -2
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +92 -2
  61. package/dist/index.js.map +1 -1
  62. package/dist/is.d.ts +45 -10
  63. package/dist/is.d.ts.map +1 -1
  64. package/dist/is.js +56 -21
  65. package/dist/is.js.map +1 -1
  66. package/dist/kpis.d.ts +24 -15
  67. package/dist/kpis.d.ts.map +1 -1
  68. package/dist/kpis.js +16 -14
  69. package/dist/kpis.js.map +1 -1
  70. package/dist/load-balancing.d.ts +395 -0
  71. package/dist/load-balancing.d.ts.map +1 -0
  72. package/dist/load-balancing.js +991 -0
  73. package/dist/load-balancing.js.map +1 -0
  74. package/dist/logger.d.ts +76 -0
  75. package/dist/logger.d.ts.map +1 -0
  76. package/dist/logger.js +39 -0
  77. package/dist/logger.js.map +1 -0
  78. package/dist/notify.d.ts +38 -9
  79. package/dist/notify.d.ts.map +1 -1
  80. package/dist/notify.js +72 -17
  81. package/dist/notify.js.map +1 -1
  82. package/dist/role.d.ts +5 -4
  83. package/dist/role.d.ts.map +1 -1
  84. package/dist/role.js +13 -10
  85. package/dist/role.js.map +1 -1
  86. package/dist/runtime.d.ts +310 -0
  87. package/dist/runtime.d.ts.map +1 -0
  88. package/dist/runtime.js +510 -0
  89. package/dist/runtime.js.map +1 -0
  90. package/dist/team.d.ts +11 -6
  91. package/dist/team.d.ts.map +1 -1
  92. package/dist/team.js +22 -15
  93. package/dist/team.js.map +1 -1
  94. package/dist/transports/email.d.ts +318 -0
  95. package/dist/transports/email.d.ts.map +1 -0
  96. package/dist/transports/email.js +779 -0
  97. package/dist/transports/email.js.map +1 -0
  98. package/dist/transports/slack.d.ts +515 -0
  99. package/dist/transports/slack.d.ts.map +1 -0
  100. package/dist/transports/slack.js +844 -0
  101. package/dist/transports/slack.js.map +1 -0
  102. package/dist/transports.d.ts.map +1 -1
  103. package/dist/transports.js +44 -25
  104. package/dist/transports.js.map +1 -1
  105. package/dist/types.d.ts +149 -19
  106. package/dist/types.d.ts.map +1 -1
  107. package/dist/types.js +6 -0
  108. package/dist/types.js.map +1 -1
  109. package/dist/utils/id.d.ts +19 -0
  110. package/dist/utils/id.d.ts.map +1 -0
  111. package/dist/utils/id.js +21 -0
  112. package/dist/utils/id.js.map +1 -0
  113. package/dist/video.d.ts +203 -0
  114. package/dist/video.d.ts.map +1 -0
  115. package/dist/video.js +528 -0
  116. package/dist/video.js.map +1 -0
  117. package/dist/worker.d.ts +343 -0
  118. package/dist/worker.d.ts.map +1 -0
  119. package/dist/worker.js +698 -0
  120. package/dist/worker.js.map +1 -0
  121. package/package.json +24 -5
  122. package/src/actions.ts +48 -38
  123. package/src/agent-comms.ts +1200 -0
  124. package/src/approve.ts +91 -20
  125. package/src/ask.ts +99 -25
  126. package/src/browse.ts +627 -0
  127. package/src/capability-tiers.ts +545 -0
  128. package/src/cascade-context.ts +648 -0
  129. package/src/client.ts +221 -0
  130. package/src/decide.ts +81 -35
  131. package/src/do.ts +98 -52
  132. package/src/error-escalation.ts +1123 -0
  133. package/src/generate.ts +52 -18
  134. package/src/goals.ts +36 -27
  135. package/src/image.ts +816 -0
  136. package/src/index.ts +410 -2
  137. package/src/is.ts +59 -25
  138. package/src/kpis.ts +41 -36
  139. package/src/load-balancing.ts +1467 -0
  140. package/src/logger.ts +93 -0
  141. package/src/notify.ts +78 -17
  142. package/src/role.ts +30 -20
  143. package/src/runtime.ts +796 -0
  144. package/src/team.ts +24 -19
  145. package/src/transports/email.ts +1160 -0
  146. package/src/transports/slack.ts +1320 -0
  147. package/src/transports.ts +58 -43
  148. package/src/types.ts +182 -46
  149. package/src/utils/id.ts +21 -0
  150. package/src/video.ts +906 -0
  151. package/src/worker.ts +1007 -0
  152. package/test/agent-comms.test.ts +1397 -0
  153. package/test/approve.test.ts +305 -0
  154. package/test/ask.test.ts +274 -0
  155. package/test/browse.test.ts +361 -0
  156. package/test/capability-tiers.test.ts +631 -0
  157. package/test/cascade-context.test.ts +692 -0
  158. package/test/decide.test.ts +252 -0
  159. package/test/do.test.ts +144 -0
  160. package/test/error-escalation.test.ts +1205 -0
  161. package/test/error-logging.test.ts +357 -0
  162. package/test/generate.test.ts +319 -0
  163. package/test/image.test.ts +398 -0
  164. package/test/is.test.ts +287 -0
  165. package/test/load-balancing-safety.test.ts +404 -0
  166. package/test/load-balancing-thread-safety.test.ts +464 -0
  167. package/test/load-balancing.test.ts +1145 -0
  168. package/test/notify.test.ts +434 -0
  169. package/test/primitives.test.ts +320 -0
  170. package/test/runtime-integration.test.ts +892 -0
  171. package/test/transports/crypto.test.ts +230 -0
  172. package/test/transports/email.test.ts +866 -0
  173. package/test/transports/id-generation.test.ts +91 -0
  174. package/test/transports/slack.test.ts +760 -0
  175. package/test/type-safety.test.ts +834 -0
  176. package/test/types.test.ts +95 -2
  177. package/test/video.test.ts +530 -0
  178. package/test/worker.test.ts +1433 -0
  179. package/tsconfig.json +4 -1
  180. package/vitest.config.ts +42 -0
  181. package/wrangler.jsonc +36 -0
  182. package/.turbo/turbo-build.log +0 -5
  183. package/src/actions.js +0 -436
  184. package/src/approve.js +0 -234
  185. package/src/ask.js +0 -226
  186. package/src/decide.js +0 -244
  187. package/src/do.js +0 -227
  188. package/src/generate.js +0 -298
  189. package/src/goals.js +0 -205
  190. package/src/index.js +0 -68
  191. package/src/is.js +0 -317
  192. package/src/kpis.js +0 -270
  193. package/src/notify.js +0 -219
  194. package/src/role.js +0 -110
  195. package/src/team.js +0 -130
  196. package/src/transports.js +0 -357
  197. package/src/types.js +0 -71
@@ -0,0 +1,1160 @@
1
+ /**
2
+ * Email Transport Adapter for digital-workers
3
+ *
4
+ * Implements the transport interface for sending notifications, approval requests,
5
+ * and handling email replies. Designed primarily for Resend but with a
6
+ * provider-agnostic interface.
7
+ *
8
+ * @packageDocumentation
9
+ */
10
+
11
+ import type {
12
+ Transport,
13
+ TransportConfig,
14
+ MessagePayload,
15
+ DeliveryResult,
16
+ TransportHandler,
17
+ } from '../transports.js'
18
+ import { registerTransport } from '../transports.js'
19
+ import type { WorkerRef, ApprovalResult, ContactChannel } from '../types.js'
20
+ import { generateRequestId } from '../utils/id.js'
21
+
22
+ // =============================================================================
23
+ // Email Provider Interface (Provider-Agnostic)
24
+ // =============================================================================
25
+
26
+ /**
27
+ * Email message to be sent
28
+ */
29
+ export interface EmailMessage {
30
+ /** Recipient email address(es) */
31
+ to: string | string[]
32
+ /** Sender email address */
33
+ from: string
34
+ /** Reply-to address */
35
+ replyTo?: string
36
+ /** Email subject line */
37
+ subject: string
38
+ /** Plain text body */
39
+ text?: string
40
+ /** HTML body */
41
+ html?: string
42
+ /** Custom headers */
43
+ headers?: Record<string, string>
44
+ /** Attachments */
45
+ attachments?: EmailAttachment[]
46
+ /** Tags for categorization */
47
+ tags?: EmailTag[]
48
+ }
49
+
50
+ /**
51
+ * Email attachment
52
+ */
53
+ export interface EmailAttachment {
54
+ /** Filename */
55
+ filename: string
56
+ /** Content as base64 or Buffer */
57
+ content: string | ArrayBuffer
58
+ /** MIME type */
59
+ contentType?: string
60
+ }
61
+
62
+ /**
63
+ * Email tag for categorization
64
+ */
65
+ export interface EmailTag {
66
+ /** Tag name */
67
+ name: string
68
+ /** Tag value */
69
+ value: string
70
+ }
71
+
72
+ /**
73
+ * Result of sending an email
74
+ */
75
+ export interface EmailSendResult {
76
+ /** Whether the send was successful */
77
+ success: boolean
78
+ /** Provider-specific message ID */
79
+ messageId?: string
80
+ /** Error message if failed */
81
+ error?: string
82
+ /** Raw provider response */
83
+ raw?: unknown
84
+ }
85
+
86
+ /**
87
+ * Email provider interface - implement this for different email services
88
+ */
89
+ export interface EmailProvider {
90
+ /** Provider name (e.g., 'resend', 'sendgrid', 'ses') */
91
+ name: string
92
+ /** Send an email */
93
+ send(message: EmailMessage): Promise<EmailSendResult>
94
+ /** Verify the provider is configured correctly */
95
+ verify?(): Promise<boolean>
96
+ }
97
+
98
+ // =============================================================================
99
+ // Email Transport Configuration
100
+ // =============================================================================
101
+
102
+ /**
103
+ * Email transport configuration
104
+ */
105
+ export interface EmailTransportConfig extends TransportConfig {
106
+ transport: 'email'
107
+ /** Email provider to use */
108
+ provider?: 'resend' | 'sendgrid' | 'ses' | 'smtp' | 'custom'
109
+ /** API key for the provider */
110
+ apiKey?: string
111
+ /** API URL (for custom providers) */
112
+ apiUrl?: string
113
+ /** Default sender address */
114
+ from?: string
115
+ /** Default reply-to address */
116
+ replyTo?: string
117
+ /** Base URL for approval links */
118
+ approvalBaseUrl?: string
119
+ /** Custom email provider instance */
120
+ customProvider?: EmailProvider
121
+ /** Template options */
122
+ templates?: EmailTemplateOptions
123
+ }
124
+
125
+ /**
126
+ * Template customization options
127
+ */
128
+ export interface EmailTemplateOptions {
129
+ /** Custom CSS styles */
130
+ styles?: string
131
+ /** Company/product name */
132
+ brandName?: string
133
+ /** Logo URL */
134
+ logoUrl?: string
135
+ /** Primary brand color */
136
+ primaryColor?: string
137
+ /** Footer text */
138
+ footerText?: string
139
+ }
140
+
141
+ // =============================================================================
142
+ // Approval Request/Response Types
143
+ // =============================================================================
144
+
145
+ /**
146
+ * Approval request data encoded in email
147
+ */
148
+ export interface ApprovalRequestData {
149
+ /** Unique approval request ID */
150
+ requestId: string
151
+ /** What is being requested */
152
+ request: string
153
+ /** Who initiated the request */
154
+ requestedBy?: WorkerRef | string
155
+ /** Additional context */
156
+ context?: Record<string, unknown>
157
+ /** Expiration timestamp */
158
+ expiresAt?: number
159
+ /** Callback URL for webhook notifications */
160
+ callbackUrl?: string
161
+ }
162
+
163
+ /**
164
+ * Parsed email reply for approval
165
+ */
166
+ export interface ParsedEmailReply {
167
+ /** Whether this is an approval response */
168
+ isApprovalResponse: boolean
169
+ /** The decision */
170
+ approved?: boolean
171
+ /** The approval request ID */
172
+ requestId?: string
173
+ /** Any notes/comments from the approver */
174
+ notes?: string
175
+ /** Who replied */
176
+ from?: string
177
+ /** When they replied */
178
+ repliedAt?: Date
179
+ /** Raw email content */
180
+ rawContent?: string
181
+ }
182
+
183
+ /**
184
+ * Inbound email for parsing
185
+ */
186
+ export interface InboundEmail {
187
+ /** Sender address */
188
+ from: string
189
+ /** Recipient address */
190
+ to: string | string[]
191
+ /** Email subject */
192
+ subject: string
193
+ /** Plain text body */
194
+ text?: string
195
+ /** HTML body */
196
+ html?: string
197
+ /** In-Reply-To header */
198
+ inReplyTo?: string
199
+ /** References header */
200
+ references?: string
201
+ /** Custom headers */
202
+ headers?: Record<string, string>
203
+ }
204
+
205
+ // =============================================================================
206
+ // Resend Provider Implementation
207
+ // =============================================================================
208
+
209
+ /**
210
+ * Resend email provider
211
+ *
212
+ * @example
213
+ * ```ts
214
+ * const resend = createResendProvider({ apiKey: 'your-api-key' })
215
+ * const transport = new EmailTransport({ provider: resend })
216
+ * ```
217
+ */
218
+ export function createResendProvider(config: { apiKey: string; apiUrl?: string }): EmailProvider {
219
+ const apiUrl = config.apiUrl || 'https://api.resend.com'
220
+
221
+ return {
222
+ name: 'resend',
223
+
224
+ async send(message: EmailMessage): Promise<EmailSendResult> {
225
+ try {
226
+ const response = await fetch(`${apiUrl}/emails`, {
227
+ method: 'POST',
228
+ headers: {
229
+ Authorization: `Bearer ${config.apiKey}`,
230
+ 'Content-Type': 'application/json',
231
+ },
232
+ body: JSON.stringify({
233
+ from: message.from,
234
+ to: Array.isArray(message.to) ? message.to : [message.to],
235
+ subject: message.subject,
236
+ text: message.text,
237
+ html: message.html,
238
+ reply_to: message.replyTo,
239
+ headers: message.headers,
240
+ tags: message.tags,
241
+ }),
242
+ })
243
+
244
+ if (!response.ok) {
245
+ const error = await response.json().catch(() => ({ message: response.statusText }))
246
+ return {
247
+ success: false,
248
+ error: (error as { message?: string }).message || 'Failed to send email',
249
+ raw: error,
250
+ }
251
+ }
252
+
253
+ const result = (await response.json()) as { id?: string }
254
+ const sendResult: EmailSendResult = {
255
+ success: true,
256
+ raw: result,
257
+ }
258
+ if (result.id) {
259
+ sendResult.messageId = result.id
260
+ }
261
+ return sendResult
262
+ } catch (error) {
263
+ return {
264
+ success: false,
265
+ error: error instanceof Error ? error.message : 'Unknown error',
266
+ }
267
+ }
268
+ },
269
+
270
+ async verify(): Promise<boolean> {
271
+ try {
272
+ const response = await fetch(`${apiUrl}/domains`, {
273
+ headers: {
274
+ Authorization: `Bearer ${config.apiKey}`,
275
+ },
276
+ })
277
+ return response.ok
278
+ } catch {
279
+ return false
280
+ }
281
+ },
282
+ }
283
+ }
284
+
285
+ // =============================================================================
286
+ // Email Templates
287
+ // =============================================================================
288
+
289
+ /**
290
+ * Default CSS styles for email templates
291
+ */
292
+ const DEFAULT_STYLES = `
293
+ body {
294
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
295
+ line-height: 1.6;
296
+ color: #333;
297
+ max-width: 600px;
298
+ margin: 0 auto;
299
+ padding: 20px;
300
+ }
301
+ .container {
302
+ background: #ffffff;
303
+ border: 1px solid #e5e5e5;
304
+ border-radius: 8px;
305
+ padding: 24px;
306
+ }
307
+ .header {
308
+ border-bottom: 1px solid #e5e5e5;
309
+ padding-bottom: 16px;
310
+ margin-bottom: 20px;
311
+ }
312
+ .header h1 {
313
+ margin: 0;
314
+ font-size: 20px;
315
+ color: #111;
316
+ }
317
+ .content {
318
+ margin-bottom: 24px;
319
+ }
320
+ .content p {
321
+ margin: 0 0 16px;
322
+ }
323
+ .context {
324
+ background: #f9f9f9;
325
+ border-radius: 6px;
326
+ padding: 16px;
327
+ margin: 16px 0;
328
+ }
329
+ .context-item {
330
+ display: flex;
331
+ margin-bottom: 8px;
332
+ }
333
+ .context-label {
334
+ font-weight: 600;
335
+ min-width: 120px;
336
+ color: #666;
337
+ }
338
+ .actions {
339
+ display: flex;
340
+ gap: 12px;
341
+ margin-top: 24px;
342
+ }
343
+ .btn {
344
+ display: inline-block;
345
+ padding: 12px 24px;
346
+ border-radius: 6px;
347
+ text-decoration: none;
348
+ font-weight: 600;
349
+ text-align: center;
350
+ }
351
+ .btn-primary {
352
+ background: #0066cc;
353
+ color: #ffffff;
354
+ }
355
+ .btn-danger {
356
+ background: #dc3545;
357
+ color: #ffffff;
358
+ }
359
+ .btn-secondary {
360
+ background: #6c757d;
361
+ color: #ffffff;
362
+ }
363
+ .footer {
364
+ margin-top: 24px;
365
+ padding-top: 16px;
366
+ border-top: 1px solid #e5e5e5;
367
+ font-size: 12px;
368
+ color: #666;
369
+ }
370
+ .reply-instructions {
371
+ background: #fff3cd;
372
+ border: 1px solid #ffc107;
373
+ border-radius: 6px;
374
+ padding: 12px;
375
+ margin-top: 16px;
376
+ font-size: 13px;
377
+ }
378
+ `
379
+
380
+ /**
381
+ * Generate notification email HTML
382
+ */
383
+ export function generateNotificationEmail(
384
+ message: string,
385
+ options: {
386
+ subject?: string
387
+ priority?: 'low' | 'normal' | 'high' | 'urgent'
388
+ metadata?: Record<string, unknown>
389
+ templates?: EmailTemplateOptions
390
+ } = {}
391
+ ): { subject: string; html: string; text: string } {
392
+ const priority = options.priority ?? 'normal'
393
+ const metadata = options.metadata
394
+ const templates = options.templates ?? {}
395
+ const styles = templates.styles || DEFAULT_STYLES
396
+ const brandName = templates.brandName || 'Digital Workers'
397
+ const footerText = templates.footerText || 'Sent via Digital Workers notification system'
398
+
399
+ const priorityBadge =
400
+ priority === 'urgent' || priority === 'high'
401
+ ? `<span style="background: #dc3545; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-left: 8px;">${priority.toUpperCase()}</span>`
402
+ : ''
403
+
404
+ const subject =
405
+ options.subject || `[${brandName}] Notification${priority === 'urgent' ? ' - URGENT' : ''}`
406
+
407
+ const contextHtml = metadata
408
+ ? `
409
+ <div class="context">
410
+ ${Object.entries(metadata)
411
+ .map(
412
+ ([key, value]) =>
413
+ `<div class="context-item"><span class="context-label">${key}:</span><span>${String(
414
+ value
415
+ )}</span></div>`
416
+ )
417
+ .join('')}
418
+ </div>
419
+ `
420
+ : ''
421
+
422
+ const html = `
423
+ <!DOCTYPE html>
424
+ <html>
425
+ <head>
426
+ <meta charset="utf-8">
427
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
428
+ <style>${styles}</style>
429
+ </head>
430
+ <body>
431
+ <div class="container">
432
+ <div class="header">
433
+ <h1>Notification${priorityBadge}</h1>
434
+ </div>
435
+ <div class="content">
436
+ <p>${escapeHtml(message)}</p>
437
+ ${contextHtml}
438
+ </div>
439
+ <div class="footer">
440
+ <p>${footerText}</p>
441
+ </div>
442
+ </div>
443
+ </body>
444
+ </html>
445
+ `
446
+
447
+ const text = `${brandName} Notification\n\n${message}\n\n${
448
+ metadata
449
+ ? Object.entries(metadata)
450
+ .map(([k, v]) => `${k}: ${v}`)
451
+ .join('\n')
452
+ : ''
453
+ }\n\n${footerText}`
454
+
455
+ return { subject, html, text }
456
+ }
457
+
458
+ /**
459
+ * Generate approval request email HTML
460
+ */
461
+ export function generateApprovalEmail(
462
+ request: string,
463
+ requestData: ApprovalRequestData,
464
+ options: {
465
+ approveUrl?: string
466
+ rejectUrl?: string
467
+ templates?: EmailTemplateOptions
468
+ } = {}
469
+ ): { subject: string; html: string; text: string } {
470
+ const { approveUrl, rejectUrl } = options
471
+ const templates = options.templates ?? {}
472
+ const styles = templates.styles || DEFAULT_STYLES
473
+ const brandName = templates.brandName || 'Digital Workers'
474
+ const footerText = templates.footerText || 'Sent via Digital Workers approval system'
475
+
476
+ const subject = `[${brandName}] Approval Required: ${truncate(request, 50)}`
477
+
478
+ const contextHtml = requestData.context
479
+ ? `
480
+ <div class="context">
481
+ <strong>Additional Context:</strong>
482
+ ${Object.entries(requestData.context)
483
+ .map(
484
+ ([key, value]) =>
485
+ `<div class="context-item"><span class="context-label">${key}:</span><span>${String(
486
+ value
487
+ )}</span></div>`
488
+ )
489
+ .join('')}
490
+ </div>
491
+ `
492
+ : ''
493
+
494
+ const actionsHtml =
495
+ approveUrl && rejectUrl
496
+ ? `
497
+ <div class="actions">
498
+ <a href="${approveUrl}" class="btn btn-primary">Approve</a>
499
+ <a href="${rejectUrl}" class="btn btn-danger">Reject</a>
500
+ </div>
501
+ `
502
+ : ''
503
+
504
+ const replyInstructions = `
505
+ <div class="reply-instructions">
506
+ <strong>Reply via Email:</strong> You can also respond by replying to this email with:
507
+ <ul style="margin: 8px 0; padding-left: 20px;">
508
+ <li><strong>APPROVED</strong> - to approve this request</li>
509
+ <li><strong>REJECTED</strong> - to reject this request</li>
510
+ </ul>
511
+ Add any notes after your decision.
512
+ </div>
513
+ `
514
+
515
+ const expiresHtml = requestData.expiresAt
516
+ ? `<p style="color: #dc3545; font-size: 13px;">This request expires at ${new Date(
517
+ requestData.expiresAt
518
+ ).toLocaleString()}</p>`
519
+ : ''
520
+
521
+ const html = `
522
+ <!DOCTYPE html>
523
+ <html>
524
+ <head>
525
+ <meta charset="utf-8">
526
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
527
+ <style>${styles}</style>
528
+ </head>
529
+ <body>
530
+ <div class="container">
531
+ <div class="header">
532
+ <h1>Approval Required</h1>
533
+ </div>
534
+ <div class="content">
535
+ <p><strong>Request:</strong></p>
536
+ <p>${escapeHtml(request)}</p>
537
+ ${contextHtml}
538
+ ${expiresHtml}
539
+ ${actionsHtml}
540
+ ${replyInstructions}
541
+ </div>
542
+ <div class="footer">
543
+ <p>${footerText}</p>
544
+ <p style="font-size: 10px; color: #999;">Request ID: ${requestData.requestId}</p>
545
+ </div>
546
+ </div>
547
+ </body>
548
+ </html>
549
+ `
550
+
551
+ const text = `${brandName} - Approval Required
552
+
553
+ Request: ${request}
554
+
555
+ ${
556
+ requestData.context
557
+ ? Object.entries(requestData.context)
558
+ .map(([k, v]) => `${k}: ${v}`)
559
+ .join('\n')
560
+ : ''
561
+ }
562
+
563
+ To respond, reply to this email with:
564
+ - APPROVED - to approve this request
565
+ - REJECTED - to reject this request
566
+
567
+ Add any notes after your decision.
568
+
569
+ ${
570
+ requestData.expiresAt
571
+ ? `This request expires at ${new Date(requestData.expiresAt).toLocaleString()}`
572
+ : ''
573
+ }
574
+
575
+ ${footerText}
576
+ Request ID: ${requestData.requestId}`
577
+
578
+ return { subject, html, text }
579
+ }
580
+
581
+ // =============================================================================
582
+ // Email Reply Parser
583
+ // =============================================================================
584
+
585
+ /**
586
+ * Parse an email reply for approval response
587
+ */
588
+ export function parseApprovalReply(email: InboundEmail): ParsedEmailReply {
589
+ const content = email.text || stripHtml(email.html || '')
590
+ const contentLower = content.toLowerCase().trim()
591
+
592
+ // Extract request ID from subject or references
593
+ const requestIdMatch =
594
+ email.subject?.match(/Request ID:\s*([a-zA-Z0-9_-]+)/i) ||
595
+ content.match(/Request ID:\s*([a-zA-Z0-9_-]+)/i)
596
+
597
+ // Check for approval/rejection keywords
598
+ const approvedPatterns = [/^approved\b/i, /\bapprove\b/i, /\byes\b/i, /\blgtm\b/i, /\bok\b/i]
599
+ const rejectedPatterns = [/^rejected\b/i, /\breject\b/i, /\bno\b/i, /\bdeny\b/i, /\bdecline\b/i]
600
+
601
+ // Get the first meaningful line (skip quoted content)
602
+ const lines = content.split('\n').filter((line) => !line.startsWith('>') && line.trim())
603
+ const firstLine = lines[0] || ''
604
+ const firstLineLower = firstLine.toLowerCase().trim()
605
+
606
+ let isApprovalResponse = false
607
+ let approved: boolean | undefined
608
+
609
+ // Check first line for explicit approval/rejection
610
+ for (const pattern of approvedPatterns) {
611
+ if (pattern.test(firstLineLower)) {
612
+ isApprovalResponse = true
613
+ approved = true
614
+ break
615
+ }
616
+ }
617
+
618
+ if (!isApprovalResponse) {
619
+ for (const pattern of rejectedPatterns) {
620
+ if (pattern.test(firstLineLower)) {
621
+ isApprovalResponse = true
622
+ approved = false
623
+ break
624
+ }
625
+ }
626
+ }
627
+
628
+ // Extract notes (everything after the decision keyword)
629
+ let notes: string | undefined
630
+ if (isApprovalResponse && lines.length > 1) {
631
+ notes = lines.slice(1).join('\n').trim()
632
+ } else if (isApprovalResponse) {
633
+ // Notes might be on the same line after the keyword
634
+ const keywordMatch = firstLine.match(
635
+ /^(approved|rejected|approve|reject|yes|no|lgtm|ok)\b[:\s]*(.*)/i
636
+ )
637
+ if (keywordMatch && keywordMatch[2]) {
638
+ notes = keywordMatch[2].trim()
639
+ }
640
+ }
641
+
642
+ // Build result with only defined properties
643
+ const result: ParsedEmailReply = {
644
+ isApprovalResponse,
645
+ from: email.from,
646
+ repliedAt: new Date(),
647
+ rawContent: content,
648
+ }
649
+
650
+ if (approved !== undefined) {
651
+ result.approved = approved
652
+ }
653
+ if (requestIdMatch?.[1]) {
654
+ result.requestId = requestIdMatch[1]
655
+ }
656
+ if (notes) {
657
+ result.notes = notes
658
+ }
659
+
660
+ return result
661
+ }
662
+
663
+ // =============================================================================
664
+ // EmailTransport Class
665
+ // =============================================================================
666
+
667
+ /**
668
+ * Email transport for digital-workers notifications and approvals
669
+ *
670
+ * @example
671
+ * ```ts
672
+ * // Create with Resend
673
+ * const transport = new EmailTransport({
674
+ * apiKey: process.env.RESEND_API_KEY,
675
+ * from: 'notifications@example.com',
676
+ * approvalBaseUrl: 'https://app.example.com/approvals',
677
+ * })
678
+ *
679
+ * // Send notification
680
+ * await transport.sendNotification({
681
+ * to: 'user@example.com',
682
+ * message: 'Deployment completed',
683
+ * priority: 'normal',
684
+ * })
685
+ *
686
+ * // Send approval request
687
+ * await transport.sendApprovalRequest({
688
+ * to: 'manager@example.com',
689
+ * request: 'Expense: $500 for cloud services',
690
+ * requestId: 'apr_123',
691
+ * context: { amount: 500, category: 'Infrastructure' },
692
+ * })
693
+ * ```
694
+ */
695
+ export class EmailTransport {
696
+ private provider: EmailProvider
697
+ private config: EmailTransportConfig
698
+
699
+ constructor(config: EmailTransportConfig) {
700
+ this.config = config
701
+
702
+ // Initialize provider
703
+ if (config.customProvider) {
704
+ this.provider = config.customProvider
705
+ } else if (config.apiKey) {
706
+ // Default to Resend
707
+ const providerConfig: { apiKey: string; apiUrl?: string } = {
708
+ apiKey: config.apiKey,
709
+ }
710
+ if (config.apiUrl) {
711
+ providerConfig.apiUrl = config.apiUrl
712
+ }
713
+ this.provider = createResendProvider(providerConfig)
714
+ } else {
715
+ throw new Error('Email transport requires either apiKey or customProvider')
716
+ }
717
+ }
718
+
719
+ /**
720
+ * Get the underlying email provider
721
+ */
722
+ getProvider(): EmailProvider {
723
+ return this.provider
724
+ }
725
+
726
+ /**
727
+ * Get the transport configuration
728
+ */
729
+ getConfig(): EmailTransportConfig {
730
+ return this.config
731
+ }
732
+
733
+ /**
734
+ * Send a notification email
735
+ */
736
+ async sendNotification(options: {
737
+ to: string | string[]
738
+ message: string
739
+ subject?: string
740
+ priority?: 'low' | 'normal' | 'high' | 'urgent'
741
+ metadata?: Record<string, unknown>
742
+ from?: string
743
+ replyTo?: string
744
+ }): Promise<DeliveryResult> {
745
+ const templateOptions: {
746
+ subject?: string
747
+ priority?: 'low' | 'normal' | 'high' | 'urgent'
748
+ metadata?: Record<string, unknown>
749
+ templates?: EmailTemplateOptions
750
+ } = {}
751
+
752
+ if (options.subject) {
753
+ templateOptions.subject = options.subject
754
+ }
755
+ if (options.priority) {
756
+ templateOptions.priority = options.priority
757
+ }
758
+ if (options.metadata) {
759
+ templateOptions.metadata = options.metadata
760
+ }
761
+ if (this.config.templates) {
762
+ templateOptions.templates = this.config.templates
763
+ }
764
+
765
+ const { subject, html, text } = generateNotificationEmail(options.message, templateOptions)
766
+
767
+ const emailMessage: EmailMessage = {
768
+ to: options.to,
769
+ from: options.from || this.config.from || 'notifications@example.com',
770
+ subject,
771
+ html,
772
+ text,
773
+ tags: [
774
+ { name: 'type', value: 'notification' },
775
+ { name: 'priority', value: options.priority || 'normal' },
776
+ ],
777
+ }
778
+
779
+ const replyTo = options.replyTo || this.config.replyTo
780
+ if (replyTo) {
781
+ emailMessage.replyTo = replyTo
782
+ }
783
+
784
+ const result = await this.provider.send(emailMessage)
785
+
786
+ const deliveryResult: DeliveryResult = {
787
+ success: result.success,
788
+ transport: 'email',
789
+ metadata: { provider: this.provider.name, raw: result.raw },
790
+ }
791
+
792
+ if (result.messageId) {
793
+ deliveryResult.messageId = result.messageId
794
+ }
795
+ if (result.error) {
796
+ deliveryResult.error = result.error
797
+ }
798
+
799
+ return deliveryResult
800
+ }
801
+
802
+ /**
803
+ * Send an approval request email
804
+ */
805
+ async sendApprovalRequest(options: {
806
+ to: string | string[]
807
+ request: string
808
+ requestId: string
809
+ requestedBy?: WorkerRef | string
810
+ context?: Record<string, unknown>
811
+ expiresAt?: Date | number
812
+ callbackUrl?: string
813
+ from?: string
814
+ replyTo?: string
815
+ }): Promise<DeliveryResult> {
816
+ const requestData: ApprovalRequestData = {
817
+ requestId: options.requestId,
818
+ request: options.request,
819
+ }
820
+
821
+ if (options.requestedBy) {
822
+ requestData.requestedBy = options.requestedBy
823
+ }
824
+ if (options.context) {
825
+ requestData.context = options.context
826
+ }
827
+ if (options.expiresAt) {
828
+ requestData.expiresAt =
829
+ options.expiresAt instanceof Date ? options.expiresAt.getTime() : options.expiresAt
830
+ }
831
+ if (options.callbackUrl) {
832
+ requestData.callbackUrl = options.callbackUrl
833
+ }
834
+
835
+ // Generate approval/reject URLs if base URL is configured
836
+ let approveUrl: string | undefined
837
+ let rejectUrl: string | undefined
838
+
839
+ if (this.config.approvalBaseUrl) {
840
+ const baseUrl = this.config.approvalBaseUrl.replace(/\/$/, '')
841
+ approveUrl = `${baseUrl}/${options.requestId}/approve`
842
+ rejectUrl = `${baseUrl}/${options.requestId}/reject`
843
+ }
844
+
845
+ const templateOptions: {
846
+ approveUrl?: string
847
+ rejectUrl?: string
848
+ templates?: EmailTemplateOptions
849
+ } = {}
850
+
851
+ if (approveUrl) {
852
+ templateOptions.approveUrl = approveUrl
853
+ }
854
+ if (rejectUrl) {
855
+ templateOptions.rejectUrl = rejectUrl
856
+ }
857
+ if (this.config.templates) {
858
+ templateOptions.templates = this.config.templates
859
+ }
860
+
861
+ const { subject, html, text } = generateApprovalEmail(
862
+ options.request,
863
+ requestData,
864
+ templateOptions
865
+ )
866
+
867
+ const emailMessage: EmailMessage = {
868
+ to: options.to,
869
+ from: options.from || this.config.from || 'approvals@example.com',
870
+ subject,
871
+ html,
872
+ text,
873
+ headers: {
874
+ 'X-Approval-Request-Id': options.requestId,
875
+ },
876
+ tags: [
877
+ { name: 'type', value: 'approval' },
878
+ { name: 'request_id', value: options.requestId },
879
+ ],
880
+ }
881
+
882
+ const replyTo = options.replyTo || this.config.replyTo
883
+ if (replyTo) {
884
+ emailMessage.replyTo = replyTo
885
+ }
886
+
887
+ const result = await this.provider.send(emailMessage)
888
+
889
+ const deliveryMetadata: Record<string, unknown> = {
890
+ provider: this.provider.name,
891
+ requestId: options.requestId,
892
+ raw: result.raw,
893
+ }
894
+ if (approveUrl) {
895
+ deliveryMetadata['approveUrl'] = approveUrl
896
+ }
897
+ if (rejectUrl) {
898
+ deliveryMetadata['rejectUrl'] = rejectUrl
899
+ }
900
+
901
+ const deliveryResult: DeliveryResult = {
902
+ success: result.success,
903
+ transport: 'email',
904
+ metadata: deliveryMetadata,
905
+ }
906
+
907
+ if (result.messageId) {
908
+ deliveryResult.messageId = result.messageId
909
+ }
910
+ if (result.error) {
911
+ deliveryResult.error = result.error
912
+ }
913
+
914
+ return deliveryResult
915
+ }
916
+
917
+ /**
918
+ * Parse an email reply for approval response
919
+ */
920
+ parseReply(email: InboundEmail): ParsedEmailReply {
921
+ return parseApprovalReply(email)
922
+ }
923
+
924
+ /**
925
+ * Convert parsed reply to ApprovalResult
926
+ */
927
+ toApprovalResult(reply: ParsedEmailReply, approver?: WorkerRef): ApprovalResult {
928
+ const result: ApprovalResult = {
929
+ approved: reply.approved ?? false,
930
+ via: 'email' as ContactChannel,
931
+ }
932
+
933
+ if (approver) {
934
+ result.approvedBy = approver
935
+ } else if (reply.from) {
936
+ result.approvedBy = { id: reply.from }
937
+ }
938
+
939
+ if (reply.repliedAt) {
940
+ result.approvedAt = reply.repliedAt
941
+ }
942
+
943
+ if (reply.notes) {
944
+ result.notes = reply.notes
945
+ }
946
+
947
+ return result
948
+ }
949
+
950
+ /**
951
+ * Create transport handler for registration
952
+ */
953
+ createHandler(): TransportHandler {
954
+ return async (payload: MessagePayload, _config: TransportConfig): Promise<DeliveryResult> => {
955
+ const to = Array.isArray(payload.to) ? payload.to : [payload.to]
956
+
957
+ if (payload.type === 'notification') {
958
+ const notifyOptions: {
959
+ to: string[]
960
+ message: string
961
+ subject?: string
962
+ priority?: 'low' | 'normal' | 'high' | 'urgent'
963
+ metadata?: Record<string, unknown>
964
+ from?: string
965
+ replyTo?: string
966
+ } = {
967
+ to,
968
+ message: payload.body,
969
+ }
970
+
971
+ if (payload.subject) {
972
+ notifyOptions.subject = payload.subject
973
+ }
974
+ if (payload.priority) {
975
+ notifyOptions.priority = payload.priority
976
+ }
977
+ if (payload.metadata) {
978
+ notifyOptions.metadata = payload.metadata
979
+ }
980
+ if (payload.from) {
981
+ notifyOptions.from = payload.from
982
+ }
983
+ if (payload.replyTo) {
984
+ notifyOptions.replyTo = payload.replyTo
985
+ }
986
+
987
+ return this.sendNotification(notifyOptions)
988
+ }
989
+
990
+ if (payload.type === 'approval') {
991
+ const requestId = (payload.metadata?.['requestId'] as string) || generateRequestId('apr')
992
+
993
+ const approvalOptions: {
994
+ to: string[]
995
+ request: string
996
+ requestId: string
997
+ context?: Record<string, unknown>
998
+ from?: string
999
+ replyTo?: string
1000
+ } = {
1001
+ to,
1002
+ request: payload.body,
1003
+ requestId,
1004
+ }
1005
+
1006
+ if (payload.metadata) {
1007
+ approvalOptions.context = payload.metadata
1008
+ }
1009
+ if (payload.from) {
1010
+ approvalOptions.from = payload.from
1011
+ }
1012
+ if (payload.replyTo) {
1013
+ approvalOptions.replyTo = payload.replyTo
1014
+ }
1015
+
1016
+ return this.sendApprovalRequest(approvalOptions)
1017
+ }
1018
+
1019
+ // Default: send as notification
1020
+ const defaultOptions: {
1021
+ to: string[]
1022
+ message: string
1023
+ subject?: string
1024
+ metadata?: Record<string, unknown>
1025
+ from?: string
1026
+ replyTo?: string
1027
+ } = {
1028
+ to,
1029
+ message: payload.body,
1030
+ }
1031
+
1032
+ if (payload.subject) {
1033
+ defaultOptions.subject = payload.subject
1034
+ }
1035
+ if (payload.metadata) {
1036
+ defaultOptions.metadata = payload.metadata
1037
+ }
1038
+ if (payload.from) {
1039
+ defaultOptions.from = payload.from
1040
+ }
1041
+ if (payload.replyTo) {
1042
+ defaultOptions.replyTo = payload.replyTo
1043
+ }
1044
+
1045
+ return this.sendNotification(defaultOptions)
1046
+ }
1047
+ }
1048
+
1049
+ /**
1050
+ * Register this transport with the transport registry
1051
+ */
1052
+ register(): void {
1053
+ registerTransport('email', this.createHandler())
1054
+ }
1055
+ }
1056
+
1057
+ // =============================================================================
1058
+ // Factory Functions
1059
+ // =============================================================================
1060
+
1061
+ /**
1062
+ * Create an email transport with Resend
1063
+ */
1064
+ export function createEmailTransport(
1065
+ config: Omit<EmailTransportConfig, 'transport'>
1066
+ ): EmailTransport {
1067
+ return new EmailTransport({ ...config, transport: 'email' })
1068
+ }
1069
+
1070
+ /**
1071
+ * Create an email transport with a custom provider
1072
+ */
1073
+ export function createEmailTransportWithProvider(
1074
+ provider: EmailProvider,
1075
+ config?: Partial<Omit<EmailTransportConfig, 'transport' | 'customProvider'>>
1076
+ ): EmailTransport {
1077
+ return new EmailTransport({
1078
+ transport: 'email',
1079
+ customProvider: provider,
1080
+ ...config,
1081
+ })
1082
+ }
1083
+
1084
+ // =============================================================================
1085
+ // Utility Functions
1086
+ // =============================================================================
1087
+
1088
+ /**
1089
+ * Escape HTML special characters
1090
+ */
1091
+ function escapeHtml(text: string): string {
1092
+ const map: Record<string, string> = {
1093
+ '&': '&amp;',
1094
+ '<': '&lt;',
1095
+ '>': '&gt;',
1096
+ '"': '&quot;',
1097
+ "'": '&#039;',
1098
+ }
1099
+ return text.replace(/[&<>"']/g, (char) => map[char] || char)
1100
+ }
1101
+
1102
+ /**
1103
+ * Strip HTML tags from content
1104
+ */
1105
+ function stripHtml(html: string): string {
1106
+ return (
1107
+ html
1108
+ // Add newlines before block elements
1109
+ .replace(/<(p|div|br|li|h[1-6]|tr)[^>]*>/gi, '\n')
1110
+ // Remove all HTML tags
1111
+ .replace(/<[^>]*>/g, '')
1112
+ // Decode HTML entities
1113
+ .replace(/&nbsp;/g, ' ')
1114
+ .replace(/&amp;/g, '&')
1115
+ .replace(/&lt;/g, '<')
1116
+ .replace(/&gt;/g, '>')
1117
+ .replace(/&quot;/g, '"')
1118
+ .replace(/&#039;/g, "'")
1119
+ // Normalize whitespace
1120
+ .replace(/\n\s*\n/g, '\n')
1121
+ .trim()
1122
+ )
1123
+ }
1124
+
1125
+ /**
1126
+ * Truncate text to a maximum length
1127
+ */
1128
+ function truncate(text: string, maxLength: number): string {
1129
+ if (text.length <= maxLength) return text
1130
+ return text.substring(0, maxLength - 3) + '...'
1131
+ }
1132
+
1133
+ // =============================================================================
1134
+ // Type Guards
1135
+ // =============================================================================
1136
+
1137
+ /**
1138
+ * Check if an object is an EmailTransportConfig
1139
+ */
1140
+ export function isEmailTransportConfig(config: unknown): config is EmailTransportConfig {
1141
+ return (
1142
+ typeof config === 'object' &&
1143
+ config !== null &&
1144
+ (config as TransportConfig).transport === 'email'
1145
+ )
1146
+ }
1147
+
1148
+ /**
1149
+ * Check if a parsed reply indicates approval
1150
+ */
1151
+ export function isApproved(reply: ParsedEmailReply): boolean {
1152
+ return reply.isApprovalResponse && reply.approved === true
1153
+ }
1154
+
1155
+ /**
1156
+ * Check if a parsed reply indicates rejection
1157
+ */
1158
+ export function isRejected(reply: ParsedEmailReply): boolean {
1159
+ return reply.isApprovalResponse && reply.approved === false
1160
+ }