@soederpop/luca 0.0.21 → 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.
@@ -1,7 +1,7 @@
1
1
  import { setBuildTimeData, setContainerBuildTimeData } from './index.js';
2
2
 
3
3
  // Auto-generated introspection registry data
4
- // Generated at: 2026-03-21T05:24:15.156Z
4
+ // Generated at: 2026-03-21T15:48:33.058Z
5
5
 
6
6
  setBuildTimeData('features.containerLink', {
7
7
  "id": "features.containerLink",
@@ -56,6 +56,7 @@ import "./features/google-drive";
56
56
  import "./features/google-sheets";
57
57
  import "./features/google-calendar";
58
58
  import "./features/google-docs";
59
+ import "./features/google-mail";
59
60
  import "./features/window-manager";
60
61
  import "./features/nlp";
61
62
  import "./features/process-manager"
@@ -100,6 +101,7 @@ import type { GoogleDrive } from './features/google-drive';
100
101
  import type { GoogleSheets } from './features/google-sheets';
101
102
  import type { GoogleCalendar } from './features/google-calendar';
102
103
  import type { GoogleDocs } from './features/google-docs';
104
+ import type { GoogleMail } from './features/google-mail';
103
105
  import type { WindowManager } from './features/window-manager';
104
106
  import type { NLP } from './features/nlp';
105
107
  import type { ProcessManager } from './features/process-manager'
@@ -139,6 +141,7 @@ export {
139
141
  type GoogleSheets,
140
142
  type GoogleCalendar,
141
143
  type GoogleDocs,
144
+ type GoogleMail,
142
145
  type WindowManager,
143
146
  type NLP,
144
147
  type ProcessManager,
@@ -204,6 +207,7 @@ export interface NodeFeatures extends AvailableFeatures {
204
207
  googleSheets: typeof GoogleSheets;
205
208
  googleCalendar: typeof GoogleCalendar;
206
209
  googleDocs: typeof GoogleDocs;
210
+ googleMail: typeof GoogleMail;
207
211
  windowManager: typeof WindowManager;
208
212
  nlp: typeof NLP;
209
213
  processManager: typeof ProcessManager;
@@ -218,9 +222,20 @@ export type ClientsAndServersInterface = ClientsInterface & ServersInterface & C
218
222
 
219
223
  export interface NodeContainer extends ClientsAndServersInterface {}
220
224
 
225
+ /*
226
+ export interface NodeContainerState extends ContainerState {
227
+ // in luca.cli.ts you can set this to true and instead of displaying the help screen
228
+ // it will emit a 'commandMissing' event with the command name and args
229
+ // so that you can handle it however you want. This allows for a CLI dx like
230
+ // luca literally type whatever you want and if your project wants to try and do
231
+ // something with it, it will try and do it.
232
+ captureMissingCommands?: boolean;
233
+ }
234
+ */
235
+
221
236
  export class NodeContainer<
222
237
  Features extends NodeFeatures = NodeFeatures,
223
- K extends ContainerState = ContainerState
238
+ K extends ContainerState = ContainerState
224
239
  > extends Container<Features, K> {
225
240
  fs!: FS;
226
241
  git!: Git;
@@ -252,6 +267,7 @@ export class NodeContainer<
252
267
  googleSheets?: GoogleSheets;
253
268
  googleCalendar?: GoogleCalendar;
254
269
  googleDocs?: GoogleDocs;
270
+ googleMail?: GoogleMail;
255
271
  windowManager?: WindowManager;
256
272
  nlp?: NLP;
257
273
  processManager?: ProcessManager;
@@ -354,4 +370,18 @@ export class NodeContainer<
354
370
  parse
355
371
  };
356
372
  }
373
+
374
+ /**
375
+ * In your project's luca.cli.ts you can call this method and pass it a function
376
+ * and when you call an invalid command, the function will be called with the command name and args
377
+ * this allows you to define your own DX behavior for handling unknown commands in your project
378
+ *
379
+ * This is a special luca cli hook. The function will be called with { words: string[], phrase: string, argv: any }
380
+ */
381
+ private onMissingCommand(handler: any) {
382
+ // @ts-ignore
383
+ this.state.set('missingCommandHandler', handler)
384
+
385
+ return this
386
+ }
357
387
  }
@@ -139,6 +139,7 @@ export class GoogleAuth extends Feature<GoogleAuthState, GoogleAuthOptions> {
139
139
  'https://www.googleapis.com/auth/spreadsheets.readonly',
140
140
  'https://www.googleapis.com/auth/calendar.readonly',
141
141
  'https://www.googleapis.com/auth/documents.readonly',
142
+ 'https://www.googleapis.com/auth/gmail.readonly',
142
143
  ]
143
144
  }
144
145
 
@@ -57,6 +57,7 @@ export const GoogleCalendarStateSchema = FeatureStateSchema.extend({
57
57
  export type GoogleCalendarState = z.infer<typeof GoogleCalendarStateSchema>
58
58
 
59
59
  export const GoogleCalendarOptionsSchema = FeatureOptionsSchema.extend({
60
+ auth: z.any().describe('An authorized instance of the googleAuth feature').optional(),
60
61
  defaultCalendarId: z.string().optional()
61
62
  .describe('Default calendar ID (default: "primary")'),
62
63
  timeZone: z.string().optional()
@@ -114,6 +115,10 @@ export class GoogleCalendar extends Feature<GoogleCalendarState, GoogleCalendarO
114
115
 
115
116
  /** Access the google-auth feature lazily. */
116
117
  get auth(): GoogleAuth {
118
+ if (this.options.auth) {
119
+ return this.options.auth as GoogleAuth
120
+ }
121
+
117
122
  return this.container.feature('googleAuth') as unknown as GoogleAuth
118
123
  }
119
124
 
@@ -15,7 +15,10 @@ export const GoogleDocsStateSchema = FeatureStateSchema.extend({
15
15
  })
16
16
  export type GoogleDocsState = z.infer<typeof GoogleDocsStateSchema>
17
17
 
18
- export const GoogleDocsOptionsSchema = FeatureOptionsSchema.extend({})
18
+ export const GoogleDocsOptionsSchema = FeatureOptionsSchema.extend({
19
+ auth: z.any().describe('An authorized instance of the googleAuth feature').optional(),
20
+ })
21
+
19
22
  export type GoogleDocsOptions = z.infer<typeof GoogleDocsOptionsSchema>
20
23
 
21
24
  export const GoogleDocsEventsSchema = FeatureEventsSchema.extend({
@@ -65,6 +68,10 @@ export class GoogleDocs extends Feature<GoogleDocsState, GoogleDocsOptions> {
65
68
 
66
69
  /** Access the google-auth feature lazily. */
67
70
  get auth(): GoogleAuth {
71
+ if (this.options.auth) {
72
+ return this.options.auth as GoogleAuth
73
+ }
74
+
68
75
  return this.container.feature('googleAuth') as unknown as GoogleAuth
69
76
  }
70
77
 
@@ -59,6 +59,8 @@ export const GoogleDriveStateSchema = FeatureStateSchema.extend({
59
59
  export type GoogleDriveState = z.infer<typeof GoogleDriveStateSchema>
60
60
 
61
61
  export const GoogleDriveOptionsSchema = FeatureOptionsSchema.extend({
62
+ auth: z.any().describe('An authorized instance of the googleAuth feature').optional(),
63
+
62
64
  defaultCorpora: z.enum(['user', 'drive', 'allDrives']).optional()
63
65
  .describe('Default corpus for file queries (default: user)'),
64
66
  pageSize: z.number().optional()
@@ -114,6 +116,10 @@ export class GoogleDrive extends Feature<GoogleDriveState, GoogleDriveOptions> {
114
116
 
115
117
  /** Access the google-auth feature lazily. */
116
118
  get auth(): GoogleAuth {
119
+ if (this.options.auth) {
120
+ return this.options.auth as GoogleAuth
121
+ }
122
+
117
123
  return this.container.feature('googleAuth') as unknown as GoogleAuth
118
124
  }
119
125
 
@@ -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