@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.
- package/luca.cli.ts +14 -1
- package/package.json +1 -1
- package/src/bootstrap/generated.ts +1 -1
- package/src/cli/cli.ts +45 -6
- package/src/commands/prompt.ts +14 -1
- package/src/introspection/generated.agi.ts +1207 -872
- package/src/introspection/generated.node.ts +841 -506
- package/src/introspection/generated.web.ts +1 -1
- package/src/node/container.ts +31 -1
- package/src/node/features/google-auth.ts +1 -0
- package/src/node/features/google-calendar.ts +5 -0
- package/src/node/features/google-docs.ts +8 -1
- package/src/node/features/google-drive.ts +6 -0
- package/src/node/features/google-mail.ts +540 -0
- package/src/node/features/google-sheets.ts +6 -0
- package/src/scaffolds/generated.ts +1 -1
|
@@ -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-
|
|
4
|
+
// Generated at: 2026-03-21T15:48:33.058Z
|
|
5
5
|
|
|
6
6
|
setBuildTimeData('features.containerLink', {
|
|
7
7
|
"id": "features.containerLink",
|
package/src/node/container.ts
CHANGED
|
@@ -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
|