@maestro-js/mail 1.0.0-alpha.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.
package/README.md ADDED
@@ -0,0 +1,360 @@
1
+ # @maestro-js/mail
2
+
3
+ Driver-based email sending with queued delivery, scheduled sending, and reusable mailable templates. Follows the Maestro Provider pattern with pluggable drivers (SendGrid, SES, MailTrap, Stub).
4
+
5
+ ## Quick Setup
6
+
7
+ ```ts
8
+ import { Mail } from '@maestro-js/mail'
9
+ import { Queue } from '@maestro-js/queue'
10
+ import { Log } from '@maestro-js/log'
11
+
12
+ // Create a logger for mail records
13
+ const mailLogger = Log.createLogger<[Mail.LogMessage]>({ transports: [Log.transports.console()] })
14
+
15
+ // Create and register the mail service
16
+ const mailService = Mail.Provider.create({
17
+ driver: Mail.drivers.sendGrid(sendGridClient),
18
+ name: 'default',
19
+ globalFrom: { email: 'noreply@example.com', name: 'MyApp' },
20
+ logger: mailLogger,
21
+ queueService: Queue.provider('default')
22
+ })
23
+ Mail.Provider.register('default', mailService)
24
+ ```
25
+
26
+ ## Drivers
27
+
28
+ ### SendGrid
29
+
30
+ ```ts
31
+ import sgMail from '@sendgrid/mail'
32
+ sgMail.setApiKey(process.env.SENDGRID_API_KEY)
33
+ const driver = Mail.drivers.sendGrid(sgMail)
34
+ ```
35
+
36
+ The SendGrid driver accepts the `@sendgrid/mail` client. It passes `envelope.extras` as `customArgs` and supports webhook event normalization via `driver.toMailEvent(sgEvent)`.
37
+
38
+ ### SES
39
+
40
+ ```ts
41
+ import { SESv2Client, SendRawEmailCommand } from '@aws-sdk/client-sesv2'
42
+ const sesClient = new SESv2Client({ region: 'us-east-1' })
43
+ const driver = Mail.drivers.ses(sesClient, 'my-configuration-set')
44
+ ```
45
+
46
+ The SES driver requires a client adapter with a `send()` method and a configuration set name. It uses `nodemailer/MailComposer` to build raw MIME messages, sends per-recipient with retry (5 attempts, 3s delay), and passes `envelope.extras` as SES message tags. Supports webhook event normalization via `driver.toMailEvent(sesEvent)`.
47
+
48
+ ### MailTrap
49
+
50
+ ```ts
51
+ import { MailtrapClient } from 'mailtrap'
52
+ const mailtrapClient = new MailtrapClient({ token: process.env.MAILTRAP_TOKEN })
53
+ const driver = Mail.drivers.mailTrap(mailtrapClient)
54
+ ```
55
+
56
+ The MailTrap driver retries up to 10 attempts with 10s delay. It passes `envelope.extras` as `custom_variables`. Supports webhook event normalization via `driver.toMailEvent(mtEvent)`.
57
+
58
+ ### Stub
59
+
60
+ ```ts
61
+ const driver = Mail.drivers.stub()
62
+ // Or with logging:
63
+ const driver = Mail.drivers.stub({ logger: myLogger })
64
+ ```
65
+
66
+ The Stub driver returns success with random UUIDs as message IDs. Use for testing and development. Optionally accepts a logger to record sent envelopes.
67
+
68
+ ## API Reference
69
+
70
+ ### Mail.Provider.create(config)
71
+
72
+ Create a mail service instance. Config fields:
73
+
74
+ | Field | Type | Description |
75
+ |---|---|---|
76
+ | `driver` | `Mail.Driver` | The mail driver instance |
77
+ | `name` | `string` | Instance name, used for queue naming (`Maestro.Mail.{name}`) |
78
+ | `globalFrom` | `Mail.Address` | Default sender, overridden by `envelope.from` |
79
+ | `logger` | `Log.Logger<[Mail.LogMessage]>` | Logger for envelope and event records |
80
+ | `queueService` | `Queue.QueueService` | Queue service for async delivery |
81
+ | `onMessageSendFailure?` | `(envelope, error) => unknown` | Optional callback on send failure |
82
+
83
+ ### Mail.Provider.register(name, service)
84
+
85
+ Register a service instance in the named registry.
86
+
87
+ ### Mail.send(envelope)
88
+
89
+ Send an email immediately. Returns `{ success: boolean, messages: { id: string, email: string }[] }`. Addresses are trimmed, lowercased, and deduplicated automatically.
90
+
91
+ ```ts
92
+ await Mail.send({
93
+ to: 'alice@example.com',
94
+ subject: 'Order Confirmed',
95
+ content: { html: '<h1>Thank you!</h1>' }
96
+ })
97
+ ```
98
+
99
+ ### Mail.queue(envelope)
100
+
101
+ Enqueue an email for immediate async processing by the queue worker. Does not block the current request.
102
+
103
+ ```ts
104
+ Mail.queue({
105
+ to: 'bob@example.com',
106
+ subject: 'Password Reset',
107
+ content: { html: '<p>Click the link to reset your password.</p>' }
108
+ })
109
+ ```
110
+
111
+ ### Mail.later(envelope, scheduledDate)
112
+
113
+ Enqueue an email for delivery at a future date. The `scheduledDate` is an `Iso.Instant`.
114
+
115
+ ```ts
116
+ Mail.later(
117
+ { to: 'user@example.com', subject: 'Reminder', content: { text: 'Your trial ends tomorrow.' } },
118
+ '2025-01-15T09:00:00Z' as Iso.Instant
119
+ )
120
+ ```
121
+
122
+ ### Mail.provider(key)
123
+
124
+ Resolve a named mail service instance. Returns `{ send, queue, later }`.
125
+
126
+ ```ts
127
+ const transactional = Mail.provider('transactional')
128
+ await transactional.send({ to: '...', subject: '...', content: { html: '...' } })
129
+ ```
130
+
131
+ ### Mail.mailable(name, renderFn)
132
+
133
+ Register a reusable mailable template. The render function receives args and returns an `Envelope` or `Mail.EMPTY_ENVELOPE` to skip sending.
134
+
135
+ ```ts
136
+ const OrderConfirmation = Mail.mailable('OrderConfirmation', async (args: { orderId: string }) => {
137
+ const order = await db.query('SELECT * FROM orders WHERE id = ?', [args.orderId])
138
+ return {
139
+ to: order.email,
140
+ subject: `Order #${order.id} Confirmed`,
141
+ content: { html: `<h1>Thank you, ${order.name}!</h1>` }
142
+ }
143
+ })
144
+ ```
145
+
146
+ #### Rendered Mailable Methods
147
+
148
+ After calling `.render(args)`, the returned object provides:
149
+
150
+ - **`send()`** -- Send immediately
151
+ - **`queue()`** -- Enqueue for async processing
152
+ - **`later(scheduledDate)`** -- Schedule for future delivery
153
+ - **`content()`** -- Retrieve the generated content without sending
154
+ - **`provider(key)`** -- Target a specific named mail service
155
+
156
+ ```ts
157
+ // Send immediately
158
+ await OrderConfirmation.render({ orderId: '1042' }).send()
159
+
160
+ // Queue for async processing
161
+ await OrderConfirmation.render({ orderId: '1042' }).queue()
162
+
163
+ // Schedule for later
164
+ await OrderConfirmation.render({ orderId: '1042' }).later('2025-06-01T12:00:00Z' as Iso.Instant)
165
+
166
+ // Get content without sending
167
+ const content = await OrderConfirmation.render({ orderId: '1042' }).content()
168
+
169
+ // Use a specific provider
170
+ await OrderConfirmation.render({ orderId: '1042' }).provider('transactional').send()
171
+ ```
172
+
173
+ ### Mail.EMPTY_ENVELOPE
174
+
175
+ Return from a mailable render function to skip sending. Useful for conditional emails.
176
+
177
+ ```ts
178
+ const WeeklyDigest = Mail.mailable('WeeklyDigest', async (args: { userId: string }) => {
179
+ const updates = await getUpdates(args.userId)
180
+ if (updates.length === 0) return Mail.EMPTY_ENVELOPE
181
+ return { to: '...', subject: 'Weekly Digest', content: { html: '...' } }
182
+ })
183
+ ```
184
+
185
+ ### Mail.attachments.ics(options)
186
+
187
+ Generate an ICS calendar attachment from structured options.
188
+
189
+ ```ts
190
+ const attachment = Mail.attachments.ics({
191
+ title: 'Team Standup',
192
+ start: '2025-03-01T10:00:00Z' as Iso.Instant,
193
+ end: 'PT1H' as Iso.Duration, // 1-hour duration
194
+ location: 'Conference Room A',
195
+ organizer: { name: 'Alice', email: 'alice@example.com' },
196
+ attendees: [{ name: 'Bob', email: 'bob@example.com', rsvp: true }],
197
+ status: 'CONFIRMED',
198
+ alarms: [{ action: 'display', description: 'Standup in 15 min', trigger: '-PT15M' as Iso.Duration }]
199
+ })
200
+
201
+ await Mail.send({
202
+ to: 'bob@example.com',
203
+ subject: 'Team Standup Invite',
204
+ content: { html: '<p>You are invited to standup.</p>' },
205
+ attachments: [attachment]
206
+ })
207
+ ```
208
+
209
+ ICS options support `start` and `end` as `Iso.Instant`, `Iso.ZonedDateTime`, or `Iso.DateTime`. The `end` field also accepts `Iso.Duration`. Additional fields: `description`, `geo`, `url`, `busyStatus`, `categories`, `uid`, `method`, `recurrenceRule`, `sequence`, `calName`, `classification`, `created`, `lastModified`, `htmlContent`, `alarms`.
210
+
211
+ ## Envelope Type
212
+
213
+ ```ts
214
+ interface Envelope {
215
+ to: Mail.Address | Mail.Address[]
216
+ from?: Mail.Address // overrides globalFrom
217
+ cc?: Mail.Address | Mail.Address[]
218
+ bcc?: Mail.Address | Mail.Address[]
219
+ attachments?: Mail.Attachment[]
220
+ subject: string | null
221
+ content: { text: string } | { html: string }
222
+ headers?: {
223
+ listUnsubscribe?: string
224
+ listUnsubscribePost?: string
225
+ replyTo?: Mail.Address
226
+ }
227
+ category?: string | null
228
+ extras?: Record<string, string | null>
229
+ }
230
+ ```
231
+
232
+ The `Mail.Address` type is either a plain email string or `{ email: string, name?: string }`.
233
+
234
+ ## Common Patterns
235
+
236
+ ### Sending with Attachments
237
+
238
+ ```ts
239
+ await Mail.send({
240
+ to: 'user@example.com',
241
+ subject: 'Your Invoice',
242
+ content: { html: '<p>Please find your invoice attached.</p>' },
243
+ attachments: [{
244
+ content: Buffer.from(pdfData).toString('base64'),
245
+ filename: 'invoice.pdf',
246
+ type: 'application/pdf',
247
+ disposition: 'attachment'
248
+ }]
249
+ })
250
+ ```
251
+
252
+ ### Unsubscribe Headers
253
+
254
+ ```ts
255
+ await Mail.send({
256
+ to: 'subscriber@example.com',
257
+ subject: 'Newsletter',
258
+ content: { html: '...' },
259
+ headers: {
260
+ listUnsubscribe: '<https://example.com/unsubscribe?id=123>',
261
+ listUnsubscribePost: 'List-Unsubscribe=One-Click'
262
+ },
263
+ category: 'newsletter'
264
+ })
265
+ ```
266
+
267
+ ### Processing Webhook Events
268
+
269
+ SendGrid, SES, and MailTrap drivers expose `toMailEvent()` to normalize provider-specific webhook payloads into a unified `Mail.Event` type.
270
+
271
+ ```ts
272
+ // SendGrid webhook handler
273
+ const sgDriver = Mail.drivers.sendGrid(sgMail) as SendGridMailDriver
274
+ const event = sgDriver.toMailEvent(rawSendGridEvent) // returns MailEvent | null
275
+
276
+ // SES webhook handler
277
+ const sesDriver = Mail.drivers.ses(sesClient, 'config-set') as SesMailDriver
278
+ const event = sesDriver.toMailEvent(rawSesEvent) // returns MailEvent | null
279
+
280
+ // MailTrap webhook handler
281
+ const mtDriver = Mail.drivers.mailTrap(mtClient) as MailTrapMailDriver
282
+ const event = mtDriver.toMailEvent(rawMailTrapEvent) // returns MailEvent
283
+ ```
284
+
285
+ The normalized `Mail.Event` includes: `eventId`, `event` (delivery, bounce, softBounce, open, click, unsubscribe, spamComplaint, reject, suspension), `email`, `messageId`, `timestamp`, `category`, `extras`, `driverName`.
286
+
287
+ ### Conditional Mailable with EMPTY_ENVELOPE
288
+
289
+ ```ts
290
+ const InactivityReminder = Mail.mailable('InactivityReminder', async (args: { userId: string }) => {
291
+ const user = await getUser(args.userId)
292
+ if (user.lastActiveAt > thirtyDaysAgo) return Mail.EMPTY_ENVELOPE
293
+ return {
294
+ to: user.email,
295
+ subject: 'We miss you!',
296
+ content: { html: `<p>Hi ${user.name}, it has been a while...</p>` }
297
+ }
298
+ })
299
+ ```
300
+
301
+ ### Error Handling with onMessageSendFailure
302
+
303
+ ```ts
304
+ Mail.Provider.create({
305
+ driver: Mail.drivers.sendGrid(sgMail),
306
+ name: 'default',
307
+ globalFrom: { email: 'noreply@example.com' },
308
+ logger: mailLogger,
309
+ queueService: Queue.provider('default'),
310
+ onMessageSendFailure(envelope, error) {
311
+ Log.error({ message: 'Mail send failed', to: envelope.to, error })
312
+ }
313
+ })
314
+ ```
315
+
316
+ ## Testing
317
+
318
+ Use the Stub driver in tests to avoid real email delivery.
319
+
320
+ ```ts
321
+ const stubDriver = Mail.drivers.stub()
322
+
323
+ const mailService = Mail.Provider.create({
324
+ driver: stubDriver,
325
+ name: 'default',
326
+ globalFrom: { email: 'test@example.com' },
327
+ logger: testLogger,
328
+ queueService: testQueueService
329
+ })
330
+ Mail.Provider.register('default', mailService)
331
+
332
+ // Emails go through the full pipeline but are not delivered
333
+ await Mail.send({ to: 'user@example.com', subject: 'Test', content: { text: 'Hello' } })
334
+ ```
335
+
336
+ To inspect sent envelopes, pass a logger to the stub driver:
337
+
338
+ ```ts
339
+ const sent: Mail.Envelope[] = []
340
+ const stubDriver = Mail.drivers.stub({
341
+ logger: { info: (envelope) => sent.push(envelope) } as any
342
+ })
343
+ ```
344
+
345
+ ## Cross-Package Integration
346
+
347
+ - **@maestro-js/service-registry** -- Provides the Provider pattern (`create`, `register`, `provider`, facade)
348
+ - **@maestro-js/log** -- Used for structured mail logging (envelope records and webhook events)
349
+ - **@maestro-js/queue** -- Powers `Mail.queue()` and `Mail.later()` for async and scheduled delivery. Queue names follow the pattern `Maestro.Mail.{name}` and `Maestro.Mailable.{name}`
350
+ - **@maestro-js/helpers** -- Used by SES and MailTrap drivers for retry logic (`Helpers.retry()`)
351
+
352
+ ## Key Source Files
353
+
354
+ - `packages/mail/src/index.ts` -- Main module, service creation, mailable system, facade export
355
+ - `packages/mail/src/mail-types.ts` -- Type definitions (Envelope, Address, Content, Attachment, Event, Driver)
356
+ - `packages/mail/src/send-grid-mail-driver.ts` -- SendGrid driver with webhook event mapping
357
+ - `packages/mail/src/ses-mail-driver.ts` -- SES driver with raw MIME composition and retry
358
+ - `packages/mail/src/mail-trap-mail-driver.ts` -- MailTrap driver with retry and webhook events
359
+ - `packages/mail/src/stub-mail-driver.ts` -- Stub driver for testing
360
+ - `packages/mail/src/ics-attachment.ts` -- ICS calendar attachment generator