@soederpop/luca 0.0.20 → 0.0.22

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,540 @@
1
+ import { z } from 'zod'
2
+ import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
3
+ import { Feature } from '../feature.js'
4
+ import { google, type gmail_v1 } from 'googleapis'
5
+ import type { GoogleAuth } from './google-auth.js'
6
+
7
+ export type MailMessage = {
8
+ id: string
9
+ threadId: string
10
+ labelIds: string[]
11
+ snippet: string
12
+ subject: string
13
+ from: string
14
+ to: string
15
+ cc?: string
16
+ date: string
17
+ body: string
18
+ bodyHtml?: string
19
+ isUnread: boolean
20
+ hasAttachments: boolean
21
+ attachments: MailAttachment[]
22
+ }
23
+
24
+ export type MailAttachment = {
25
+ filename: string
26
+ mimeType: string
27
+ size: number
28
+ attachmentId: string
29
+ }
30
+
31
+ export type MailThread = {
32
+ id: string
33
+ snippet: string
34
+ historyId: string
35
+ messages: MailMessage[]
36
+ }
37
+
38
+ export type MailLabel = {
39
+ id: string
40
+ name: string
41
+ type: string
42
+ messagesTotal?: number
43
+ messagesUnread?: number
44
+ }
45
+
46
+ export type SearchMailOptions = {
47
+ query?: string
48
+ from?: string
49
+ to?: string
50
+ subject?: string
51
+ after?: string
52
+ before?: string
53
+ hasAttachment?: boolean
54
+ label?: string
55
+ isUnread?: boolean
56
+ maxResults?: number
57
+ pageToken?: string
58
+ }
59
+
60
+ export type MailMessageList = {
61
+ messages: MailMessage[]
62
+ nextPageToken?: string
63
+ resultSizeEstimate?: number
64
+ }
65
+
66
+ export const GoogleMailStateSchema = FeatureStateSchema.extend({
67
+ lastQuery: z.string().optional()
68
+ .describe('Last search query used'),
69
+ lastResultCount: z.number().optional()
70
+ .describe('Number of messages returned in last search'),
71
+ lastError: z.string().optional()
72
+ .describe('Last Gmail API error message'),
73
+ watchExpiration: z.string().optional()
74
+ .describe('ISO timestamp when the current watch expires'),
75
+ })
76
+ export type GoogleMailState = z.infer<typeof GoogleMailStateSchema>
77
+
78
+ export const GoogleMailOptionsSchema = FeatureOptionsSchema.extend({
79
+ auth: z.any().describe('An authorized instance of the googleAuth feature').optional(),
80
+ userId: z.string().optional()
81
+ .describe('Gmail user ID (default: "me")'),
82
+ pollInterval: z.number().optional()
83
+ .describe('Polling interval in ms for watching new mail (default: 30000)'),
84
+ format: z.enum(['full', 'metadata', 'minimal', 'raw']).optional()
85
+ .describe('Default message format when fetching (default: "full")'),
86
+ })
87
+ export type GoogleMailOptions = z.infer<typeof GoogleMailOptionsSchema>
88
+
89
+ export const GoogleMailEventsSchema = FeatureEventsSchema.extend({
90
+ messagesFetched: z.tuple([z.number().describe('Number of messages returned')])
91
+ .describe('Messages were fetched from Gmail'),
92
+ newMail: z.tuple([z.array(z.any()).describe('Array of new MailMessage objects')])
93
+ .describe('New mail arrived (emitted by watch)'),
94
+ watchStarted: z.tuple([]).describe('Mail watching has started'),
95
+ watchStopped: z.tuple([]).describe('Mail watching has stopped'),
96
+ error: z.tuple([z.any().describe('The error')]).describe('Gmail API error occurred'),
97
+ })
98
+
99
+ /**
100
+ * Google Mail feature for searching, reading, and watching Gmail messages.
101
+ *
102
+ * Depends on the googleAuth feature for authentication. Creates a Gmail v1 API
103
+ * client lazily. Supports Gmail search query syntax, individual message reading,
104
+ * and polling-based new mail detection with event emission.
105
+ *
106
+ * @example
107
+ * ```typescript
108
+ * const mail = container.feature('googleMail')
109
+ *
110
+ * // Search by sender
111
+ * const fromBoss = await mail.search({ from: 'boss@company.com' })
112
+ *
113
+ * // Use Gmail query string
114
+ * const unread = await mail.search({ query: 'is:unread category:primary' })
115
+ *
116
+ * // Read a specific message
117
+ * const msg = await mail.getMessage('message-id-here')
118
+ *
119
+ * // Get a full thread
120
+ * const thread = await mail.getThread('thread-id-here')
121
+ *
122
+ * // List labels
123
+ * const labels = await mail.listLabels()
124
+ *
125
+ * // Watch for new mail (polls and emits 'newMail' events)
126
+ * mail.on('newMail', (messages) => {
127
+ * console.log(`Got ${messages.length} new messages!`)
128
+ * })
129
+ * await mail.startWatching()
130
+ *
131
+ * // Stop watching
132
+ * mail.stopWatching()
133
+ * ```
134
+ */
135
+ export class GoogleMail extends Feature<GoogleMailState, GoogleMailOptions> {
136
+ static override shortcut = 'features.googleMail' as const
137
+ static override stateSchema = GoogleMailStateSchema
138
+ static override optionsSchema = GoogleMailOptionsSchema
139
+ static override eventsSchema = GoogleMailEventsSchema
140
+ static { Feature.register(this, 'googleMail') }
141
+
142
+ private _gmail?: gmail_v1.Gmail
143
+ private _watchTimer?: ReturnType<typeof setInterval>
144
+ private _lastHistoryId?: string
145
+
146
+ override get initialState(): GoogleMailState {
147
+ return { ...super.initialState }
148
+ }
149
+
150
+ /** Access the google-auth feature lazily. */
151
+ get auth(): GoogleAuth {
152
+ if (this.options.auth) {
153
+ return this.options.auth as GoogleAuth
154
+ }
155
+ return this.container.feature('googleAuth') as unknown as GoogleAuth
156
+ }
157
+
158
+ /** Default user ID from options or 'me'. */
159
+ get userId(): string {
160
+ return this.options.userId || 'me'
161
+ }
162
+
163
+ /** Default message format from options or 'full'. */
164
+ get defaultFormat(): 'full' | 'metadata' | 'minimal' | 'raw' {
165
+ return this.options.format || 'full'
166
+ }
167
+
168
+ /** Polling interval from options or 30 seconds. */
169
+ get pollInterval(): number {
170
+ return this.options.pollInterval || 30_000
171
+ }
172
+
173
+ /** Get or create the Gmail v1 API client. */
174
+ private async getGmail(): Promise<gmail_v1.Gmail> {
175
+ if (this._gmail) return this._gmail
176
+ const auth = await this.auth.getAuthClient()
177
+ this._gmail = google.gmail({ version: 'v1', auth: auth as any })
178
+ return this._gmail
179
+ }
180
+
181
+ /**
182
+ * Search for messages using Gmail query syntax and/or structured filters.
183
+ *
184
+ * @param options - Search filters including query, from, to, subject, date ranges
185
+ * @returns Messages array with optional nextPageToken
186
+ */
187
+ async search(options: SearchMailOptions = {}): Promise<MailMessageList> {
188
+ const query = buildQuery(options)
189
+ try {
190
+ const gmail = await this.getGmail()
191
+ const res = await gmail.users.messages.list({
192
+ userId: this.userId,
193
+ q: query || undefined,
194
+ maxResults: options.maxResults || 20,
195
+ pageToken: options.pageToken || undefined,
196
+ })
197
+
198
+ const messageRefs = res.data.messages || []
199
+ const messages: MailMessage[] = []
200
+
201
+ for (const ref of messageRefs) {
202
+ if (ref.id) {
203
+ const msg = await this.getMessage(ref.id)
204
+ messages.push(msg)
205
+ }
206
+ }
207
+
208
+ this.setState({ lastQuery: query, lastResultCount: messages.length })
209
+ this.emit('messagesFetched', messages.length)
210
+
211
+ return {
212
+ messages,
213
+ nextPageToken: res.data.nextPageToken || undefined,
214
+ resultSizeEstimate: res.data.resultSizeEstimate || undefined,
215
+ }
216
+ } catch (err: any) {
217
+ this.setState({ lastError: err.message })
218
+ this.emit('error', err)
219
+ throw err
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Get a single message by ID.
225
+ *
226
+ * @param messageId - The message ID
227
+ * @param format - Message format (defaults to options.format or 'full')
228
+ * @returns The full mail message
229
+ */
230
+ async getMessage(messageId: string, format?: 'full' | 'metadata' | 'minimal' | 'raw'): Promise<MailMessage> {
231
+ try {
232
+ const gmail = await this.getGmail()
233
+ const res = await gmail.users.messages.get({
234
+ userId: this.userId,
235
+ id: messageId,
236
+ format: format || this.defaultFormat,
237
+ })
238
+ return normalizeMessage(res.data)
239
+ } catch (err: any) {
240
+ this.setState({ lastError: err.message })
241
+ this.emit('error', err)
242
+ throw err
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Get a full thread with all its messages.
248
+ *
249
+ * @param threadId - The thread ID
250
+ * @returns The thread with all messages
251
+ */
252
+ async getThread(threadId: string): Promise<MailThread> {
253
+ try {
254
+ const gmail = await this.getGmail()
255
+ const res = await gmail.users.threads.get({
256
+ userId: this.userId,
257
+ id: threadId,
258
+ format: this.defaultFormat,
259
+ })
260
+ return {
261
+ id: res.data.id || '',
262
+ snippet: res.data.snippet || '',
263
+ historyId: res.data.historyId || '',
264
+ messages: (res.data.messages || []).map(normalizeMessage),
265
+ }
266
+ } catch (err: any) {
267
+ this.setState({ lastError: err.message })
268
+ this.emit('error', err)
269
+ throw err
270
+ }
271
+ }
272
+
273
+ /**
274
+ * List all labels for the authenticated user.
275
+ *
276
+ * @returns Array of label objects
277
+ */
278
+ async listLabels(): Promise<MailLabel[]> {
279
+ try {
280
+ const gmail = await this.getGmail()
281
+ const res = await gmail.users.labels.list({ userId: this.userId })
282
+ const labels = res.data.labels || []
283
+
284
+ // Fetch full label info for counts
285
+ const detailed: MailLabel[] = []
286
+ for (const label of labels) {
287
+ if (label.id) {
288
+ try {
289
+ const full = await gmail.users.labels.get({ userId: this.userId, id: label.id })
290
+ detailed.push({
291
+ id: full.data.id || '',
292
+ name: full.data.name || '',
293
+ type: full.data.type || '',
294
+ messagesTotal: full.data.messagesTotal || undefined,
295
+ messagesUnread: full.data.messagesUnread || undefined,
296
+ })
297
+ } catch {
298
+ detailed.push({
299
+ id: label.id || '',
300
+ name: label.name || '',
301
+ type: label.type || '',
302
+ })
303
+ }
304
+ }
305
+ }
306
+ return detailed
307
+ } catch (err: any) {
308
+ this.setState({ lastError: err.message })
309
+ this.emit('error', err)
310
+ throw err
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Start watching for new mail by polling at a regular interval.
316
+ * Emits 'newMail' events with an array of new messages when they arrive.
317
+ *
318
+ * Uses Gmail history API to efficiently detect only new messages since the last check.
319
+ */
320
+ async startWatching(): Promise<void> {
321
+ if (this._watchTimer) return
322
+
323
+ // Get initial history ID
324
+ const gmail = await this.getGmail()
325
+ const profile = await gmail.users.getProfile({ userId: this.userId })
326
+ this._lastHistoryId = profile.data.historyId || undefined
327
+
328
+ this._watchTimer = setInterval(async () => {
329
+ try {
330
+ await this._checkForNewMail()
331
+ } catch (err: any) {
332
+ this.setState({ lastError: err.message })
333
+ this.emit('error', err)
334
+ }
335
+ }, this.pollInterval)
336
+
337
+ this.emit('watchStarted')
338
+ }
339
+
340
+ /**
341
+ * Stop watching for new mail.
342
+ */
343
+ stopWatching(): void {
344
+ if (this._watchTimer) {
345
+ clearInterval(this._watchTimer)
346
+ this._watchTimer = undefined
347
+ this._lastHistoryId = undefined
348
+ this.setState({ watchExpiration: undefined })
349
+ this.emit('watchStopped')
350
+ }
351
+ }
352
+
353
+ /** Check for new messages since last history ID using Gmail history API. */
354
+ private async _checkForNewMail(): Promise<void> {
355
+ if (!this._lastHistoryId) return
356
+
357
+ const gmail = await this.getGmail()
358
+ try {
359
+ const res = await gmail.users.history.list({
360
+ userId: this.userId,
361
+ startHistoryId: this._lastHistoryId,
362
+ historyTypes: ['messageAdded'],
363
+ })
364
+
365
+ const history = res.data.history || []
366
+ const newMessageIds = new Set<string>()
367
+
368
+ for (const record of history) {
369
+ for (const added of record.messagesAdded || []) {
370
+ if (added.message?.id) {
371
+ newMessageIds.add(added.message.id)
372
+ }
373
+ }
374
+ }
375
+
376
+ if (res.data.historyId) {
377
+ this._lastHistoryId = res.data.historyId
378
+ }
379
+
380
+ if (newMessageIds.size > 0) {
381
+ const messages: MailMessage[] = []
382
+ for (const id of newMessageIds) {
383
+ try {
384
+ const msg = await this.getMessage(id)
385
+ messages.push(msg)
386
+ } catch {
387
+ // Message may have been deleted between detection and fetch
388
+ }
389
+ }
390
+ if (messages.length > 0) {
391
+ this.emit('newMail', messages)
392
+ }
393
+ }
394
+ } catch (err: any) {
395
+ // History ID may be expired — reset by fetching fresh profile
396
+ if (err.code === 404 || err.message?.includes('historyId')) {
397
+ const profile = await gmail.users.getProfile({ userId: this.userId })
398
+ this._lastHistoryId = profile.data.historyId || undefined
399
+ } else {
400
+ throw err
401
+ }
402
+ }
403
+ }
404
+ }
405
+
406
+ /** Build a Gmail query string from structured search options. */
407
+ function buildQuery(options: SearchMailOptions): string {
408
+ const parts: string[] = []
409
+
410
+ if (options.query) parts.push(options.query)
411
+ if (options.from) parts.push(`from:${options.from}`)
412
+ if (options.to) parts.push(`to:${options.to}`)
413
+ if (options.subject) parts.push(`subject:${options.subject}`)
414
+ if (options.after) parts.push(`after:${options.after}`)
415
+ if (options.before) parts.push(`before:${options.before}`)
416
+ if (options.hasAttachment) parts.push('has:attachment')
417
+ if (options.label) parts.push(`label:${options.label}`)
418
+ if (options.isUnread) parts.push('is:unread')
419
+
420
+ return parts.join(' ')
421
+ }
422
+
423
+ /** Extract a header value from a Gmail message. */
424
+ function getHeader(headers: gmail_v1.Schema$MessagePartHeader[] | undefined, name: string): string {
425
+ if (!headers) return ''
426
+ const h = headers.find(h => h.name?.toLowerCase() === name.toLowerCase())
427
+ return h?.value || ''
428
+ }
429
+
430
+ /** Decode a base64url-encoded string. */
431
+ function decodeBody(encoded: string): string {
432
+ try {
433
+ return Buffer.from(encoded, 'base64url').toString('utf-8')
434
+ } catch {
435
+ return ''
436
+ }
437
+ }
438
+
439
+ /** Extract the plain text body from a message payload. */
440
+ function extractBody(payload: gmail_v1.Schema$MessagePart | undefined): string {
441
+ if (!payload) return ''
442
+
443
+ // Simple body
444
+ if (payload.body?.data) {
445
+ return decodeBody(payload.body.data)
446
+ }
447
+
448
+ // Multipart — look for text/plain first, then text/html
449
+ if (payload.parts) {
450
+ const textPart = payload.parts.find(p => p.mimeType === 'text/plain')
451
+ if (textPart?.body?.data) return decodeBody(textPart.body.data)
452
+
453
+ const htmlPart = payload.parts.find(p => p.mimeType === 'text/html')
454
+ if (htmlPart?.body?.data) return decodeBody(htmlPart.body.data)
455
+
456
+ // Nested multipart
457
+ for (const part of payload.parts) {
458
+ const nested = extractBody(part)
459
+ if (nested) return nested
460
+ }
461
+ }
462
+
463
+ return ''
464
+ }
465
+
466
+ /** Extract HTML body from a message payload. */
467
+ function extractHtmlBody(payload: gmail_v1.Schema$MessagePart | undefined): string | undefined {
468
+ if (!payload) return undefined
469
+
470
+ if (payload.mimeType === 'text/html' && payload.body?.data) {
471
+ return decodeBody(payload.body.data)
472
+ }
473
+
474
+ if (payload.parts) {
475
+ const htmlPart = payload.parts.find(p => p.mimeType === 'text/html')
476
+ if (htmlPart?.body?.data) return decodeBody(htmlPart.body.data)
477
+
478
+ for (const part of payload.parts) {
479
+ const nested = extractHtmlBody(part)
480
+ if (nested) return nested
481
+ }
482
+ }
483
+
484
+ return undefined
485
+ }
486
+
487
+ /** Extract attachment metadata from a message payload. */
488
+ function extractAttachments(payload: gmail_v1.Schema$MessagePart | undefined): MailAttachment[] {
489
+ if (!payload) return []
490
+
491
+ const attachments: MailAttachment[] = []
492
+
493
+ if (payload.filename && payload.body?.attachmentId) {
494
+ attachments.push({
495
+ filename: payload.filename,
496
+ mimeType: payload.mimeType || '',
497
+ size: payload.body.size || 0,
498
+ attachmentId: payload.body.attachmentId,
499
+ })
500
+ }
501
+
502
+ if (payload.parts) {
503
+ for (const part of payload.parts) {
504
+ attachments.push(...extractAttachments(part))
505
+ }
506
+ }
507
+
508
+ return attachments
509
+ }
510
+
511
+ /** Normalize a raw Gmail message into our clean MailMessage type. */
512
+ function normalizeMessage(msg: gmail_v1.Schema$Message): MailMessage {
513
+ const headers = msg.payload?.headers
514
+ const attachments = extractAttachments(msg.payload)
515
+
516
+ return {
517
+ id: msg.id || '',
518
+ threadId: msg.threadId || '',
519
+ labelIds: msg.labelIds || [],
520
+ snippet: msg.snippet || '',
521
+ subject: getHeader(headers, 'Subject'),
522
+ from: getHeader(headers, 'From'),
523
+ to: getHeader(headers, 'To'),
524
+ cc: getHeader(headers, 'Cc') || undefined,
525
+ date: getHeader(headers, 'Date'),
526
+ body: extractBody(msg.payload),
527
+ bodyHtml: extractHtmlBody(msg.payload),
528
+ isUnread: (msg.labelIds || []).includes('UNREAD'),
529
+ hasAttachments: attachments.length > 0,
530
+ attachments,
531
+ }
532
+ }
533
+
534
+ declare module '../../feature' {
535
+ interface AvailableFeatures {
536
+ googleMail: typeof GoogleMail
537
+ }
538
+ }
539
+
540
+ export default GoogleMail
@@ -32,6 +32,7 @@ export const GoogleSheetsStateSchema = FeatureStateSchema.extend({
32
32
  export type GoogleSheetsState = z.infer<typeof GoogleSheetsStateSchema>
33
33
 
34
34
  export const GoogleSheetsOptionsSchema = FeatureOptionsSchema.extend({
35
+ auth: z.any().describe('An authorized instance of the googleAuth feature').optional(),
35
36
  defaultSpreadsheetId: z.string().optional()
36
37
  .describe('Default spreadsheet ID for operations'),
37
38
  })
@@ -84,9 +85,14 @@ export class GoogleSheets extends Feature<GoogleSheetsState, GoogleSheetsOptions
84
85
 
85
86
  /** Access the google-auth feature lazily. */
86
87
  get auth(): GoogleAuth {
88
+ if (this.options.auth) {
89
+ return this.options.auth as GoogleAuth
90
+ }
91
+
87
92
  return this.container.feature('googleAuth') as unknown as GoogleAuth
88
93
  }
89
94
 
95
+
90
96
  /** Get or create the Sheets v4 API client. */
91
97
  private async getSheets(): Promise<sheets_v4.Sheets> {
92
98
  if (this._sheets) return this._sheets
@@ -1,5 +1,5 @@
1
1
  // Auto-generated scaffold and MCP readme content
2
- // Generated at: 2026-03-21T01:13:43.075Z
2
+ // Generated at: 2026-03-21T05:25:03.287Z
3
3
  // Source: docs/scaffolds/*.md, docs/examples/assistant/, and docs/mcp/readme.md
4
4
  //
5
5
  // Do not edit manually. Run: luca build-scaffolds