@maestro-js/agent-skills 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.
@@ -0,0 +1,321 @@
1
+ ---
2
+ name: log
3
+ description: "Structured logging with pluggable transports and channel-based routing for the Maestro framework. Use when working with @maestro-js/log, adding or modifying log transports, configuring log channels, filtering log messages by level or channel, integrating logging into other packages, or writing tests that capture log output. Key capabilities: create loggers with pluggable transports (console, file, Sentry, stub), route messages through named channels, filter by level and channel, override Node.js console, custom formatters, and the silence transport for suppressing output."
4
+ ---
5
+
6
+ # @maestro-js/log
7
+
8
+ Structured logging with pluggable transports and channel-based routing. Messages have a severity level (`emergency`, `error`, `warn`, `info`) and an optional channel for routing to specific transports.
9
+
10
+ ## Quick Setup
11
+
12
+ ```ts
13
+ import { Log } from '@maestro-js/log'
14
+
15
+ // Use the default logger directly (falls back to console if no transports added)
16
+ Log.info('Server started on port', 3000)
17
+
18
+ // Or create an independent logger with transports
19
+ const logger = Log.create({
20
+ transports: [
21
+ Log.transports.console(),
22
+ Log.transports.file({ filename: '/var/log/app.log' })
23
+ ]
24
+ })
25
+
26
+ logger.info('Server started on port', 3000)
27
+ logger.error('Connection failed', err)
28
+ ```
29
+
30
+ ## Architecture
31
+
32
+ The package does NOT use the service-registry Provider pattern. Instead it exports a `Log` object that acts as a default logger facade and a `Log.create()` factory for independent logger instances.
33
+
34
+ ### Source Files
35
+
36
+ - `packages/log/src/index.ts` -- Main entry: `Log.create()`, default logger facade, `Log.transports` namespace
37
+ - `packages/log/src/log-types.ts` -- Core types: `LogLevel`, `LogChannel`, `LogMessage`, `LogTransport`, `LogMessageFilter`, `LogFormatter`
38
+ - `packages/log/src/console-transport.ts` -- Console transport with colorized output
39
+ - `packages/log/src/file-transport.ts` -- File transport with streaming writes and `flush()`
40
+ - `packages/log/src/sentry-transport.ts` -- Sentry error/message capture transport
41
+ - `packages/log/src/sentry-breadcrumbs-transport.ts` -- Sentry breadcrumbs transport
42
+ - `packages/log/src/stub-transport.ts` -- In-memory transport for testing
43
+ - `packages/log/src/transport-helpers.ts` -- `parseLogMessageFilter()` utility
44
+
45
+ ### Key Types
46
+
47
+ ```ts
48
+ type LogLevel = 'emergency' | 'error' | 'warn' | 'info'
49
+ type LogChannel = string | null
50
+
51
+ interface LogMessage<LogMessageData extends any[] = any[]> {
52
+ data: LogMessageData
53
+ level: LogLevel
54
+ channel: LogChannel
55
+ time: Iso.Instant
56
+ }
57
+
58
+ interface LogTransport<LogMessageData extends any[] = any[]> {
59
+ write(message: LogMessage<LogMessageData>): void
60
+ }
61
+
62
+ type LogMessageFilter<LogMessageData extends any[] = any[]> =
63
+ | { level?: LogLevel | LogLevel[]; channel?: LogChannel | LogChannel[] }
64
+ | ((message: LogMessage<LogMessageData>) => boolean)
65
+ | undefined
66
+ ```
67
+
68
+ ## Transports
69
+
70
+ All transports accept an optional `filter` for level/channel-based routing.
71
+
72
+ ### Console Transport
73
+
74
+ Colorized output to stdout with configurable formatting.
75
+
76
+ ```ts
77
+ Log.transports.console()
78
+ Log.transports.console({ filter: { level: ['error', 'emergency'] } })
79
+ Log.transports.console({ format: (message) => `${message.level}: ${message.data.join(' ')}\n` })
80
+ // Pass InspectOptions instead of a formatter function
81
+ Log.transports.console({ format: { colors: true, depth: 4 } })
82
+ ```
83
+
84
+ ### File Transport
85
+
86
+ Appends log messages to a file via streaming. Creates the directory if it does not exist. Returns a transport with an additional `flush()` method.
87
+
88
+ ```ts
89
+ const fileTransport = Log.transports.file({
90
+ filename: '/var/log/app.log',
91
+ filter: { level: ['error', 'emergency'] },
92
+ format: (message) => JSON.stringify(message) + '\n'
93
+ })
94
+
95
+ // Ensure all buffered writes are flushed to disk
96
+ await fileTransport.flush()
97
+ ```
98
+
99
+ ### Sentry Transport
100
+
101
+ Captures errors and messages to Sentry. Errors at `error`/`emergency` level use `captureException`; other levels use `captureMessage`.
102
+
103
+ ```ts
104
+ import * as Sentry from '@sentry/node'
105
+
106
+ Log.transports.sentry({
107
+ sentry: Sentry,
108
+ filter: { level: ['error', 'emergency'] },
109
+ getCaptureContext(message) {
110
+ return {
111
+ tags: { channel: message.channel ?? 'default' },
112
+ extra: {},
113
+ level: message.level === 'emergency' ? 'fatal' : message.level
114
+ }
115
+ }
116
+ })
117
+ ```
118
+
119
+ ### Sentry Breadcrumbs Transport
120
+
121
+ Adds log messages as Sentry breadcrumbs for context on future error captures.
122
+
123
+ ```ts
124
+ import * as Sentry from '@sentry/node'
125
+
126
+ Log.transports.sentryBreadcrumbs({
127
+ sentry: Sentry,
128
+ filter: { level: ['info', 'warn'] }
129
+ })
130
+ ```
131
+
132
+ ### Stub Transport
133
+
134
+ In-memory transport for testing. Captures messages and provides `listMessages()` and `reset()`.
135
+
136
+ ```ts
137
+ const stub = Log.transports.stub()
138
+ const logger = Log.create({ transports: [stub] })
139
+
140
+ logger.info('test message')
141
+ stub.listMessages() // => [{ data: ['test message'], level: 'info', channel: null, time: '...' }]
142
+ stub.reset()
143
+ ```
144
+
145
+ ### Silence Transport
146
+
147
+ Suppresses all output. Use to disable logging entirely.
148
+
149
+ ```ts
150
+ Log.addTransport(Log.transports.silence)
151
+ ```
152
+
153
+ ## API Reference
154
+
155
+ ### `Log.create(config?)`
156
+
157
+ Create an independent logger instance.
158
+
159
+ ```ts
160
+ const logger = Log.create({
161
+ transports: [Log.transports.console()]
162
+ })
163
+ ```
164
+
165
+ ### Default Logger Facade
166
+
167
+ Call logging methods directly on `Log` without creating an instance.
168
+
169
+ ```ts
170
+ Log.addTransport(Log.transports.console())
171
+ Log.info('message', data)
172
+ Log.warn('slow query', { durationMs: 500 })
173
+ Log.error('failed', new Error('timeout'))
174
+ Log.emergency('system down')
175
+ Log.removeTransport(someTransport)
176
+ ```
177
+
178
+ ### Logging Methods
179
+
180
+ - `info(...data)` -- Log at info level
181
+ - `warn(...data)` -- Log at warn level
182
+ - `error(...data)` -- Log at error level
183
+ - `emergency(...data)` -- Log at emergency level
184
+ - `write(message)` -- Write a raw log message (supply `level`, `data`, and `time` yourself)
185
+
186
+ ### Channels
187
+
188
+ Create channel-scoped sub-loggers that tag every message with a channel name. Transports can filter by channel.
189
+
190
+ ```ts
191
+ const logger = Log.create({ transports: [Log.transports.console()] })
192
+ const dbLog = logger.channel('database')
193
+ dbLog.info('Query executed', { table: 'users', durationMs: 12 })
194
+ dbLog.warn('Slow query detected')
195
+
196
+ // Filter transport to only receive database channel messages
197
+ Log.transports.console({ filter: { channel: 'database' } })
198
+ ```
199
+
200
+ ### `Log.overrideNodeJsConsole(logger)`
201
+
202
+ Replace global `console.log`, `console.warn`, `console.error`, `console.info`, and `console.debug` with methods from the provided logger. Returns a restore function.
203
+
204
+ ```ts
205
+ const restore = Log.overrideNodeJsConsole(logger)
206
+ console.log('now goes through logger')
207
+ restore() // restores original console methods
208
+ ```
209
+
210
+ ## Filtering
211
+
212
+ Transports accept a `filter` option that controls which messages reach them.
213
+
214
+ ```ts
215
+ // Filter by level
216
+ Log.transports.console({ filter: { level: 'error' } })
217
+ Log.transports.console({ filter: { level: ['error', 'emergency'] } })
218
+
219
+ // Filter by channel
220
+ Log.transports.console({ filter: { channel: 'database' } })
221
+
222
+ // Filter by both level and channel
223
+ Log.transports.console({ filter: { level: 'error', channel: 'database' } })
224
+
225
+ // Custom filter function
226
+ Log.transports.console({
227
+ filter: (message) => message.level === 'error' && message.data.some((d) => d instanceof Error)
228
+ })
229
+ ```
230
+
231
+ ## Fallback Behavior
232
+
233
+ When a logger has no transports registered, it falls back to a default `ConsoleTransport()`. Add `Log.transports.silence` to explicitly suppress all output.
234
+
235
+ ## Custom Transports
236
+
237
+ Implement the `LogTransport` interface.
238
+
239
+ ```ts
240
+ const customTransport: Log.Transport = {
241
+ write(message) {
242
+ // message.data, message.level, message.channel, message.time
243
+ fetch('https://log-service.example.com', {
244
+ method: 'POST',
245
+ body: JSON.stringify(message)
246
+ })
247
+ }
248
+ }
249
+ ```
250
+
251
+ ## Testing
252
+
253
+ Run tests:
254
+
255
+ ```bash
256
+ pnpm --filter @maestro-js/log test
257
+ cd packages/log && npx beartest ./tests/log.test.ts
258
+ ```
259
+
260
+ Use `StubTransport` to capture and assert on log output:
261
+
262
+ ```ts
263
+ import { test } from 'beartest-js'
264
+ import { expect } from 'expect'
265
+ import { Log } from '@maestro-js/log'
266
+
267
+ test('should capture log messages', () => {
268
+ const stub = Log.transports.stub()
269
+ const logger = Log.create({ transports: [stub] })
270
+
271
+ logger.info('test')
272
+
273
+ const messages = stub.listMessages()
274
+ expect(messages).toHaveLength(1)
275
+ expect(messages[0].level).toBe('info')
276
+ expect(messages[0].data).toEqual(['test'])
277
+
278
+ stub.reset()
279
+ })
280
+ ```
281
+
282
+ Use inline transports for targeted assertions:
283
+
284
+ ```ts
285
+ test('should write to transport', () => {
286
+ const messages: Log.Message[] = []
287
+ const log = Log.create()
288
+ log.addTransport({ write(message) { messages.push(message) } })
289
+
290
+ log.info('log')
291
+ expect(messages).toHaveLength(1)
292
+ })
293
+ ```
294
+
295
+ ## Cross-Package Integration
296
+
297
+ Log is used widely across Maestro packages as the standard logging mechanism:
298
+
299
+ - **queue, recurring-jobs, mail** -- Log job processing, email sending, and scheduling events
300
+ - **db** -- Log queries and connection events
301
+ - **cache, events, broadcast** -- Log cache operations and event dispatching
302
+ - **metrics, exceptions** -- Log metric collection and exception handling
303
+
304
+ Typical integration pattern in other packages:
305
+
306
+ ```ts
307
+ import { Log } from '@maestro-js/log'
308
+
309
+ const log = Log.create()
310
+ // or use Log.info(), Log.error() directly via the default facade
311
+ ```
312
+
313
+ ### Context Integration
314
+
315
+ Combine with `@maestro-js/context` (AsyncLocalStorage) for request-scoped logging by passing contextual data through log message data arguments.
316
+
317
+ ## Dependencies
318
+
319
+ - `colorette` -- Terminal color support for console transport
320
+ - `iso-fns2` -- ISO timestamp types (`Iso.Instant`)
321
+ - `@sentry/types` (devDependency) -- Sentry type definitions
@@ -0,0 +1,365 @@
1
+ ---
2
+ name: mail
3
+ description: "Driver-based email sending with queued delivery, scheduled sending, and mailable templates for the @maestro-js/mail package. Use when working with @maestro-js/mail. Covers creating mail services with SendGrid/SES/MailTrap/Stub drivers, sending emails immediately or via queue, scheduling future delivery, building reusable mailable templates, processing webhook events, generating ICS calendar attachments, and testing with the stub driver."
4
+ ---
5
+
6
+ # @maestro-js/mail
7
+
8
+ 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).
9
+
10
+ ## Quick Setup
11
+
12
+ ```ts
13
+ import { Mail } from '@maestro-js/mail'
14
+ import { Queue } from '@maestro-js/queue'
15
+ import { Log } from '@maestro-js/log'
16
+
17
+ // Create a logger for mail records
18
+ const mailLogger = Log.createLogger<[Mail.LogMessage]>({ transports: [Log.transports.console()] })
19
+
20
+ // Create and register the mail service
21
+ const mailService = Mail.Provider.create({
22
+ driver: Mail.drivers.sendGrid(sendGridClient),
23
+ name: 'default',
24
+ globalFrom: { email: 'noreply@example.com', name: 'MyApp' },
25
+ logger: mailLogger,
26
+ queueService: Queue.provider('default')
27
+ })
28
+ Mail.Provider.register('default', mailService)
29
+ ```
30
+
31
+ ## Drivers
32
+
33
+ ### SendGrid
34
+
35
+ ```ts
36
+ import sgMail from '@sendgrid/mail'
37
+ sgMail.setApiKey(process.env.SENDGRID_API_KEY)
38
+ const driver = Mail.drivers.sendGrid(sgMail)
39
+ ```
40
+
41
+ The SendGrid driver accepts the `@sendgrid/mail` client. It passes `envelope.extras` as `customArgs` and supports webhook event normalization via `driver.toMailEvent(sgEvent)`.
42
+
43
+ ### SES
44
+
45
+ ```ts
46
+ import { SESv2Client, SendRawEmailCommand } from '@aws-sdk/client-sesv2'
47
+ const sesClient = new SESv2Client({ region: 'us-east-1' })
48
+ const driver = Mail.drivers.ses(sesClient, 'my-configuration-set')
49
+ ```
50
+
51
+ 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)`.
52
+
53
+ ### MailTrap
54
+
55
+ ```ts
56
+ import { MailtrapClient } from 'mailtrap'
57
+ const mailtrapClient = new MailtrapClient({ token: process.env.MAILTRAP_TOKEN })
58
+ const driver = Mail.drivers.mailTrap(mailtrapClient)
59
+ ```
60
+
61
+ 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)`.
62
+
63
+ ### Stub
64
+
65
+ ```ts
66
+ const driver = Mail.drivers.stub()
67
+ // Or with logging:
68
+ const driver = Mail.drivers.stub({ logger: myLogger })
69
+ ```
70
+
71
+ The Stub driver returns success with random UUIDs as message IDs. Use for testing and development. Optionally accepts a logger to record sent envelopes.
72
+
73
+ ## API Reference
74
+
75
+ ### Mail.Provider.create(config)
76
+
77
+ Create a mail service instance. Config fields:
78
+
79
+ | Field | Type | Description |
80
+ |---|---|---|
81
+ | `driver` | `Mail.Driver` | The mail driver instance |
82
+ | `name` | `string` | Instance name, used for queue naming (`Maestro.Mail.{name}`) |
83
+ | `globalFrom` | `Mail.Address` | Default sender, overridden by `envelope.from` |
84
+ | `logger` | `Log.Logger<[Mail.LogMessage]>` | Logger for envelope and event records |
85
+ | `queueService` | `Queue.QueueService` | Queue service for async delivery |
86
+ | `onMessageSendFailure?` | `(envelope, error) => unknown` | Optional callback on send failure |
87
+
88
+ ### Mail.Provider.register(name, service)
89
+
90
+ Register a service instance in the named registry.
91
+
92
+ ### Mail.send(envelope)
93
+
94
+ Send an email immediately. Returns `{ success: boolean, messages: { id: string, email: string }[] }`. Addresses are trimmed, lowercased, and deduplicated automatically.
95
+
96
+ ```ts
97
+ await Mail.send({
98
+ to: 'alice@example.com',
99
+ subject: 'Order Confirmed',
100
+ content: { html: '<h1>Thank you!</h1>' }
101
+ })
102
+ ```
103
+
104
+ ### Mail.queue(envelope)
105
+
106
+ Enqueue an email for immediate async processing by the queue worker. Does not block the current request.
107
+
108
+ ```ts
109
+ Mail.queue({
110
+ to: 'bob@example.com',
111
+ subject: 'Password Reset',
112
+ content: { html: '<p>Click the link to reset your password.</p>' }
113
+ })
114
+ ```
115
+
116
+ ### Mail.later(envelope, scheduledDate)
117
+
118
+ Enqueue an email for delivery at a future date. The `scheduledDate` is an `Iso.Instant`.
119
+
120
+ ```ts
121
+ Mail.later(
122
+ { to: 'user@example.com', subject: 'Reminder', content: { text: 'Your trial ends tomorrow.' } },
123
+ '2025-01-15T09:00:00Z' as Iso.Instant
124
+ )
125
+ ```
126
+
127
+ ### Mail.provider(key)
128
+
129
+ Resolve a named mail service instance. Returns `{ send, queue, later }`.
130
+
131
+ ```ts
132
+ const transactional = Mail.provider('transactional')
133
+ await transactional.send({ to: '...', subject: '...', content: { html: '...' } })
134
+ ```
135
+
136
+ ### Mail.mailable(name, renderFn)
137
+
138
+ Register a reusable mailable template. The render function receives args and returns an `Envelope` or `Mail.EMPTY_ENVELOPE` to skip sending.
139
+
140
+ ```ts
141
+ const OrderConfirmation = Mail.mailable('OrderConfirmation', async (args: { orderId: string }) => {
142
+ const order = await db.query('SELECT * FROM orders WHERE id = ?', [args.orderId])
143
+ return {
144
+ to: order.email,
145
+ subject: `Order #${order.id} Confirmed`,
146
+ content: { html: `<h1>Thank you, ${order.name}!</h1>` }
147
+ }
148
+ })
149
+ ```
150
+
151
+ #### Rendered Mailable Methods
152
+
153
+ After calling `.render(args)`, the returned object provides:
154
+
155
+ - **`send()`** -- Send immediately
156
+ - **`queue()`** -- Enqueue for async processing
157
+ - **`later(scheduledDate)`** -- Schedule for future delivery
158
+ - **`content()`** -- Retrieve the generated content without sending
159
+ - **`provider(key)`** -- Target a specific named mail service
160
+
161
+ ```ts
162
+ // Send immediately
163
+ await OrderConfirmation.render({ orderId: '1042' }).send()
164
+
165
+ // Queue for async processing
166
+ await OrderConfirmation.render({ orderId: '1042' }).queue()
167
+
168
+ // Schedule for later
169
+ await OrderConfirmation.render({ orderId: '1042' }).later('2025-06-01T12:00:00Z' as Iso.Instant)
170
+
171
+ // Get content without sending
172
+ const content = await OrderConfirmation.render({ orderId: '1042' }).content()
173
+
174
+ // Use a specific provider
175
+ await OrderConfirmation.render({ orderId: '1042' }).provider('transactional').send()
176
+ ```
177
+
178
+ ### Mail.EMPTY_ENVELOPE
179
+
180
+ Return from a mailable render function to skip sending. Useful for conditional emails.
181
+
182
+ ```ts
183
+ const WeeklyDigest = Mail.mailable('WeeklyDigest', async (args: { userId: string }) => {
184
+ const updates = await getUpdates(args.userId)
185
+ if (updates.length === 0) return Mail.EMPTY_ENVELOPE
186
+ return { to: '...', subject: 'Weekly Digest', content: { html: '...' } }
187
+ })
188
+ ```
189
+
190
+ ### Mail.attachments.ics(options)
191
+
192
+ Generate an ICS calendar attachment from structured options.
193
+
194
+ ```ts
195
+ const attachment = Mail.attachments.ics({
196
+ title: 'Team Standup',
197
+ start: '2025-03-01T10:00:00Z' as Iso.Instant,
198
+ end: 'PT1H' as Iso.Duration, // 1-hour duration
199
+ location: 'Conference Room A',
200
+ organizer: { name: 'Alice', email: 'alice@example.com' },
201
+ attendees: [{ name: 'Bob', email: 'bob@example.com', rsvp: true }],
202
+ status: 'CONFIRMED',
203
+ alarms: [{ action: 'display', description: 'Standup in 15 min', trigger: '-PT15M' as Iso.Duration }]
204
+ })
205
+
206
+ await Mail.send({
207
+ to: 'bob@example.com',
208
+ subject: 'Team Standup Invite',
209
+ content: { html: '<p>You are invited to standup.</p>' },
210
+ attachments: [attachment]
211
+ })
212
+ ```
213
+
214
+ 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`.
215
+
216
+ ## Envelope Type
217
+
218
+ ```ts
219
+ interface Envelope {
220
+ to: Mail.Address | Mail.Address[]
221
+ from?: Mail.Address // overrides globalFrom
222
+ cc?: Mail.Address | Mail.Address[]
223
+ bcc?: Mail.Address | Mail.Address[]
224
+ attachments?: Mail.Attachment[]
225
+ subject: string | null
226
+ content: { text: string } | { html: string }
227
+ headers?: {
228
+ listUnsubscribe?: string
229
+ listUnsubscribePost?: string
230
+ replyTo?: Mail.Address
231
+ }
232
+ category?: string | null
233
+ extras?: Record<string, string | null>
234
+ }
235
+ ```
236
+
237
+ The `Mail.Address` type is either a plain email string or `{ email: string, name?: string }`.
238
+
239
+ ## Common Patterns
240
+
241
+ ### Sending with Attachments
242
+
243
+ ```ts
244
+ await Mail.send({
245
+ to: 'user@example.com',
246
+ subject: 'Your Invoice',
247
+ content: { html: '<p>Please find your invoice attached.</p>' },
248
+ attachments: [{
249
+ content: Buffer.from(pdfData).toString('base64'),
250
+ filename: 'invoice.pdf',
251
+ type: 'application/pdf',
252
+ disposition: 'attachment'
253
+ }]
254
+ })
255
+ ```
256
+
257
+ ### Unsubscribe Headers
258
+
259
+ ```ts
260
+ await Mail.send({
261
+ to: 'subscriber@example.com',
262
+ subject: 'Newsletter',
263
+ content: { html: '...' },
264
+ headers: {
265
+ listUnsubscribe: '<https://example.com/unsubscribe?id=123>',
266
+ listUnsubscribePost: 'List-Unsubscribe=One-Click'
267
+ },
268
+ category: 'newsletter'
269
+ })
270
+ ```
271
+
272
+ ### Processing Webhook Events
273
+
274
+ SendGrid, SES, and MailTrap drivers expose `toMailEvent()` to normalize provider-specific webhook payloads into a unified `Mail.Event` type.
275
+
276
+ ```ts
277
+ // SendGrid webhook handler
278
+ const sgDriver = Mail.drivers.sendGrid(sgMail) as SendGridMailDriver
279
+ const event = sgDriver.toMailEvent(rawSendGridEvent) // returns MailEvent | null
280
+
281
+ // SES webhook handler
282
+ const sesDriver = Mail.drivers.ses(sesClient, 'config-set') as SesMailDriver
283
+ const event = sesDriver.toMailEvent(rawSesEvent) // returns MailEvent | null
284
+
285
+ // MailTrap webhook handler
286
+ const mtDriver = Mail.drivers.mailTrap(mtClient) as MailTrapMailDriver
287
+ const event = mtDriver.toMailEvent(rawMailTrapEvent) // returns MailEvent
288
+ ```
289
+
290
+ The normalized `Mail.Event` includes: `eventId`, `event` (delivery, bounce, softBounce, open, click, unsubscribe, spamComplaint, reject, suspension), `email`, `messageId`, `timestamp`, `category`, `extras`, `driverName`.
291
+
292
+ ### Conditional Mailable with EMPTY_ENVELOPE
293
+
294
+ ```ts
295
+ const InactivityReminder = Mail.mailable('InactivityReminder', async (args: { userId: string }) => {
296
+ const user = await getUser(args.userId)
297
+ if (user.lastActiveAt > thirtyDaysAgo) return Mail.EMPTY_ENVELOPE
298
+ return {
299
+ to: user.email,
300
+ subject: 'We miss you!',
301
+ content: { html: `<p>Hi ${user.name}, it has been a while...</p>` }
302
+ }
303
+ })
304
+ ```
305
+
306
+ ### Error Handling with onMessageSendFailure
307
+
308
+ ```ts
309
+ Mail.Provider.create({
310
+ driver: Mail.drivers.sendGrid(sgMail),
311
+ name: 'default',
312
+ globalFrom: { email: 'noreply@example.com' },
313
+ logger: mailLogger,
314
+ queueService: Queue.provider('default'),
315
+ onMessageSendFailure(envelope, error) {
316
+ Log.error({ message: 'Mail send failed', to: envelope.to, error })
317
+ }
318
+ })
319
+ ```
320
+
321
+ ## Testing
322
+
323
+ Use the Stub driver in tests to avoid real email delivery.
324
+
325
+ ```ts
326
+ const stubDriver = Mail.drivers.stub()
327
+
328
+ const mailService = Mail.Provider.create({
329
+ driver: stubDriver,
330
+ name: 'default',
331
+ globalFrom: { email: 'test@example.com' },
332
+ logger: testLogger,
333
+ queueService: testQueueService
334
+ })
335
+ Mail.Provider.register('default', mailService)
336
+
337
+ // Emails go through the full pipeline but are not delivered
338
+ await Mail.send({ to: 'user@example.com', subject: 'Test', content: { text: 'Hello' } })
339
+ ```
340
+
341
+ To inspect sent envelopes, pass a logger to the stub driver:
342
+
343
+ ```ts
344
+ const sent: Mail.Envelope[] = []
345
+ const stubDriver = Mail.drivers.stub({
346
+ logger: { info: (envelope) => sent.push(envelope) } as any
347
+ })
348
+ ```
349
+
350
+ ## Cross-Package Integration
351
+
352
+ - **@maestro-js/service-registry** -- Provides the Provider pattern (`create`, `register`, `provider`, facade)
353
+ - **@maestro-js/log** -- Used for structured mail logging (envelope records and webhook events)
354
+ - **@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}`
355
+ - **@maestro-js/helpers** -- Used by SES and MailTrap drivers for retry logic (`Helpers.retry()`)
356
+
357
+ ## Key Source Files
358
+
359
+ - `packages/mail/src/index.ts` -- Main module, service creation, mailable system, facade export
360
+ - `packages/mail/src/mail-types.ts` -- Type definitions (Envelope, Address, Content, Attachment, Event, Driver)
361
+ - `packages/mail/src/send-grid-mail-driver.ts` -- SendGrid driver with webhook event mapping
362
+ - `packages/mail/src/ses-mail-driver.ts` -- SES driver with raw MIME composition and retry
363
+ - `packages/mail/src/mail-trap-mail-driver.ts` -- MailTrap driver with retry and webhook events
364
+ - `packages/mail/src/stub-mail-driver.ts` -- Stub driver for testing
365
+ - `packages/mail/src/ics-attachment.ts` -- ICS calendar attachment generator