@guayaba/workflow-piece-gmail 0.12.3

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,413 @@
1
+ import {
2
+ createTrigger,
3
+ TriggerStrategy,
4
+ FilesService,
5
+ Property,
6
+ } from '@guayaba/workflows-framework';
7
+ import { GmailProps } from '../common/props';
8
+ import { gmailAuth, createGoogleClient } from '../auth';
9
+ import { google } from 'googleapis';
10
+ import { parseStream, convertAttachment } from '../common/data';
11
+
12
+ async function enrichNewConversation({
13
+ gmail,
14
+ threadId,
15
+ files,
16
+ conversationInfo,
17
+ }: {
18
+ gmail: any;
19
+ threadId: string;
20
+ files: FilesService;
21
+ conversationInfo: {
22
+ createdAt: number;
23
+ historyId: string;
24
+ };
25
+ }) {
26
+ const threadResponse = await gmail.users.threads.get({
27
+ userId: 'me',
28
+ id: threadId,
29
+ format: 'full',
30
+ });
31
+
32
+ const thread = threadResponse.data;
33
+ const messages = thread.messages || [];
34
+
35
+ const firstMessage = messages[0];
36
+ if (!firstMessage?.id) {
37
+ throw new Error('No messages found in thread');
38
+ }
39
+
40
+ const rawMessageResponse = await gmail.users.messages.get({
41
+ userId: 'me',
42
+ id: firstMessage.id,
43
+ format: 'raw',
44
+ });
45
+
46
+ const parsedFirstMessage = await parseStream(
47
+ Buffer.from(rawMessageResponse.data.raw as string, 'base64').toString(
48
+ 'utf-8'
49
+ )
50
+ );
51
+
52
+ const headers = firstMessage.payload?.headers || [];
53
+ const headerMap = headers.reduce(
54
+ (acc: { [key: string]: string }, header: any) => {
55
+ if (header.name && header.value) {
56
+ acc[header.name.toLowerCase()] = header.value;
57
+ }
58
+ return acc;
59
+ },
60
+ {}
61
+ );
62
+
63
+ return {
64
+ conversation: {
65
+ threadId: threadId,
66
+ messageCount: messages.length,
67
+ snippet: thread.snippet || '',
68
+ historyId: thread.historyId,
69
+ participants: extractParticipants(messages),
70
+ subject: headerMap['subject'] || '',
71
+ starter: {
72
+ from: headerMap['from'] || '',
73
+ to: headerMap['to'] || '',
74
+ cc: headerMap['cc'] || '',
75
+ bcc: headerMap['bcc'] || '',
76
+ date: headerMap['date'] || '',
77
+ messageId: firstMessage.id,
78
+ },
79
+ },
80
+ firstMessage: {
81
+ ...parsedFirstMessage,
82
+ messageId: firstMessage.id,
83
+ attachments: await convertAttachment(
84
+ parsedFirstMessage.attachments,
85
+ files
86
+ ),
87
+ },
88
+ conversationInfo: {
89
+ ...conversationInfo,
90
+ triggeredAt: Date.now(),
91
+ },
92
+ };
93
+ }
94
+
95
+ function extractParticipants(messages: any[]): {
96
+ from: Set<string>;
97
+ to: Set<string>;
98
+ cc: Set<string>;
99
+ } {
100
+ const participants = {
101
+ from: new Set<string>(),
102
+ to: new Set<string>(),
103
+ cc: new Set<string>(),
104
+ };
105
+
106
+ messages.forEach((message) => {
107
+ const headers = message.payload?.headers || [];
108
+ headers.forEach((header: any) => {
109
+ if (header.name && header.value) {
110
+ const name = header.name.toLowerCase();
111
+ const value = header.value;
112
+
113
+ if (name === 'from') {
114
+ participants.from.add(value);
115
+ } else if (name === 'to') {
116
+ value
117
+ .split(',')
118
+ .forEach((email: string) => participants.to.add(email.trim()));
119
+ } else if (name === 'cc') {
120
+ value
121
+ .split(',')
122
+ .forEach((email: string) => participants.cc.add(email.trim()));
123
+ }
124
+ }
125
+ });
126
+ });
127
+
128
+ return {
129
+ from: participants.from,
130
+ to: participants.to,
131
+ cc: participants.cc,
132
+ };
133
+ }
134
+
135
+ export const gmailNewConversationTrigger = createTrigger({
136
+ auth: gmailAuth,
137
+ name: 'new_conversation',
138
+ displayName: 'New Conversation',
139
+ description: 'Triggers when a new email conversation (thread) begins',
140
+ props: {
141
+ from: {
142
+ ...GmailProps.from,
143
+ description: 'Filter by sender email (optional)',
144
+ displayName: 'From',
145
+ required: false,
146
+ },
147
+ subject: Property.ShortText({
148
+ displayName: 'Subject Contains',
149
+ description:
150
+ 'Only trigger for conversations containing this text in the subject (optional)',
151
+ required: false,
152
+ }),
153
+ maxAgeHours: Property.Number({
154
+ displayName: 'Maximum Age (Hours)',
155
+ description:
156
+ 'Only trigger for conversations started within this many hours',
157
+ required: false,
158
+ defaultValue: 24,
159
+ }),
160
+ },
161
+ sampleData: {},
162
+ type: TriggerStrategy.POLLING,
163
+ onEnable: async (context) => {
164
+ const authClient = await createGoogleClient(context.auth);
165
+ const gmail = google.gmail({ version: 'v1', auth: authClient });
166
+
167
+ const profile = await gmail.users.getProfile({ userId: 'me' });
168
+ await context.store.put('lastHistoryId', profile.data.historyId);
169
+ await context.store.put('processedThreads', []);
170
+ },
171
+ onDisable: async (context) => {
172
+ await context.store.delete('lastHistoryId');
173
+ await context.store.delete('processedThreads');
174
+ },
175
+ run: async (context) => {
176
+ const authClient = await createGoogleClient(context.auth);
177
+ const gmail = google.gmail({ version: 'v1', auth: authClient });
178
+
179
+ const lastHistoryId = await context.store.get('lastHistoryId');
180
+ const processedThreads =
181
+ (await context.store.get<string[]>('processedThreads')) || [];
182
+ const maxAge = (context.propsValue.maxAgeHours || 24) * 60 * 60 * 1000;
183
+ const cutoffTime = Date.now() - maxAge;
184
+
185
+ try {
186
+ const historyResponse = await gmail.users.history.list({
187
+ userId: 'me',
188
+ startHistoryId: lastHistoryId as string,
189
+ historyTypes: ['messageAdded'],
190
+ maxResults: 100,
191
+ });
192
+
193
+ const newConversations: string[] = [];
194
+
195
+ if (historyResponse.data.history) {
196
+ for (const history of historyResponse.data.history) {
197
+ if (history.messagesAdded) {
198
+ for (const added of history.messagesAdded) {
199
+ const threadId = added.message?.threadId;
200
+ if (threadId && !processedThreads.includes(threadId)) {
201
+ const threadResponse = await gmail.users.threads.get({
202
+ userId: 'me',
203
+ id: threadId,
204
+ format: 'minimal',
205
+ });
206
+
207
+ if (threadResponse.data.messages?.length === 1) {
208
+ newConversations.push(threadId);
209
+ }
210
+ }
211
+ }
212
+ }
213
+ }
214
+ }
215
+
216
+ const results: any[] = [];
217
+ for (const threadId of newConversations) {
218
+ const threadResponse = await gmail.users.threads.get({
219
+ userId: 'me',
220
+ id: threadId,
221
+ format: 'full',
222
+ });
223
+
224
+ const thread = threadResponse.data;
225
+ const firstMessage = thread.messages?.[0];
226
+ if (!firstMessage) continue;
227
+
228
+ const messageDate = parseInt(firstMessage.internalDate || '0');
229
+ if (messageDate < cutoffTime) continue;
230
+
231
+ const headers = firstMessage.payload?.headers || [];
232
+ const headerMap: { [key: string]: string } = {};
233
+ headers.forEach((h: any) => {
234
+ if (h.name && h.value) {
235
+ headerMap[h.name.toLowerCase()] = h.value;
236
+ }
237
+ });
238
+
239
+ if (context.propsValue.from) {
240
+ const from = headerMap['from'] || '';
241
+ if (
242
+ !from.toLowerCase().includes(context.propsValue.from.toLowerCase())
243
+ ) {
244
+ continue;
245
+ }
246
+ }
247
+
248
+ if (context.propsValue.subject) {
249
+ const subject = headerMap['subject'] || '';
250
+ if (
251
+ !subject
252
+ .toLowerCase()
253
+ .includes(context.propsValue.subject.toLowerCase())
254
+ ) {
255
+ continue;
256
+ }
257
+ }
258
+
259
+ const rawResponse = await gmail.users.messages.get({
260
+ userId: 'me',
261
+ id: firstMessage.id!,
262
+ format: 'raw',
263
+ });
264
+
265
+ const parsedMessage = await parseStream(
266
+ Buffer.from(rawResponse.data.raw as string, 'base64').toString(
267
+ 'utf-8'
268
+ )
269
+ );
270
+
271
+ results.push({
272
+ id: `conversation_${threadId}`,
273
+ data: {
274
+ thread: {
275
+ id: threadId,
276
+ snippet: thread.snippet,
277
+ messageCount: 1,
278
+ },
279
+ message: {
280
+ ...parsedMessage,
281
+ id: firstMessage.id,
282
+ threadId: threadId,
283
+ date: new Date(messageDate).toISOString(),
284
+ attachments: await convertAttachment(
285
+ parsedMessage.attachments,
286
+ context.files
287
+ ),
288
+ },
289
+ conversation: {
290
+ starter: {
291
+ from: headerMap['from'],
292
+ to: headerMap['to'],
293
+ subject: headerMap['subject'],
294
+ date: headerMap['date'],
295
+ },
296
+ },
297
+ },
298
+ });
299
+
300
+ processedThreads.push(threadId);
301
+ }
302
+
303
+ if (historyResponse.data.historyId) {
304
+ await context.store.put(
305
+ 'lastHistoryId',
306
+ historyResponse.data.historyId
307
+ );
308
+ }
309
+
310
+ const recentThreads = processedThreads.slice(-1000);
311
+ await context.store.put('processedThreads', recentThreads);
312
+
313
+ return results;
314
+ } catch (error: any) {
315
+ if (error.code === 404) {
316
+ const profile = await gmail.users.getProfile({ userId: 'me' });
317
+ await context.store.put('lastHistoryId', profile.data.historyId);
318
+ return [];
319
+ }
320
+ throw error;
321
+ }
322
+ },
323
+ test: async (context) => {
324
+ const authClient = await createGoogleClient(context.auth);
325
+ const gmail = google.gmail({ version: 'v1', auth: authClient });
326
+
327
+ const maxAge = (context.propsValue.maxAgeHours || 24) * 60 * 60 * 1000;
328
+ const cutoffSeconds = Math.floor((Date.now() - maxAge) / 1000);
329
+
330
+ let query = `after:${cutoffSeconds}`;
331
+ if (context.propsValue.from) {
332
+ query += ` from:(${context.propsValue.from})`;
333
+ }
334
+ if (context.propsValue.subject) {
335
+ query += ` subject:(${context.propsValue.subject})`;
336
+ }
337
+
338
+ const threadsResponse = await gmail.users.threads.list({
339
+ userId: 'me',
340
+ q: query,
341
+ maxResults: 5,
342
+ });
343
+
344
+ const results: any[] = [];
345
+ if (threadsResponse.data.threads) {
346
+ for (const thread of threadsResponse.data.threads) {
347
+ const threadId = thread.id!;
348
+
349
+ const fullThread = await gmail.users.threads.get({
350
+ userId: 'me',
351
+ id: threadId,
352
+ format: 'full',
353
+ });
354
+
355
+ if (fullThread.data.messages?.length === 1) {
356
+ const firstMessage = fullThread.data.messages[0];
357
+ const headers = firstMessage.payload?.headers || [];
358
+ const headerMap: { [key: string]: string } = {};
359
+ headers.forEach((h: any) => {
360
+ if (h.name && h.value) {
361
+ headerMap[h.name.toLowerCase()] = h.value;
362
+ }
363
+ });
364
+
365
+ const rawResponse = await gmail.users.messages.get({
366
+ userId: 'me',
367
+ id: firstMessage.id!,
368
+ format: 'raw',
369
+ });
370
+
371
+ const parsedMessage = await parseStream(
372
+ Buffer.from(rawResponse.data.raw as string, 'base64').toString(
373
+ 'utf-8'
374
+ )
375
+ );
376
+
377
+ results.push({
378
+ id: `test_conversation_${threadId}`,
379
+ data: {
380
+ thread: {
381
+ id: threadId,
382
+ snippet: fullThread.data.snippet,
383
+ messageCount: 1,
384
+ },
385
+ message: {
386
+ ...parsedMessage,
387
+ id: firstMessage.id,
388
+ threadId: threadId,
389
+ date: new Date(
390
+ parseInt(firstMessage.internalDate || '0')
391
+ ).toISOString(),
392
+ attachments: await convertAttachment(
393
+ parsedMessage.attachments,
394
+ context.files
395
+ ),
396
+ },
397
+ conversation: {
398
+ starter: {
399
+ from: headerMap['from'],
400
+ to: headerMap['to'],
401
+ subject: headerMap['subject'],
402
+ date: headerMap['date'],
403
+ },
404
+ },
405
+ },
406
+ });
407
+ }
408
+ }
409
+ }
410
+
411
+ return results;
412
+ },
413
+ });
@@ -0,0 +1,167 @@
1
+ import {
2
+ createTrigger,
3
+ TriggerStrategy,
4
+ FilesService,
5
+ } from '@guayaba/workflows-framework';
6
+ import { GmailLabel } from '../common/models';
7
+ import { GmailProps } from '../common/props';
8
+ import { gmailAuth, createGoogleClient, GmailAuthValue } from '../auth';
9
+ import {
10
+ parseStream,
11
+ convertAttachment,
12
+ getFirstFiveOrAll,
13
+ } from '../common/data';
14
+ import { google } from 'googleapis';
15
+
16
+ export const gmailNewEmailTrigger = createTrigger({
17
+ auth: gmailAuth,
18
+ name: 'gmail_new_email_received',
19
+ displayName: 'New Email',
20
+ description: 'Triggers when new mail is found in your Gmail inbox',
21
+ props: {
22
+ subject: GmailProps.subject,
23
+ from: GmailProps.from,
24
+ to: GmailProps.to,
25
+ label: GmailProps.label,
26
+ category: GmailProps.category,
27
+ },
28
+ sampleData: {},
29
+ type: TriggerStrategy.POLLING,
30
+ async onEnable(context) {
31
+ await context.store.put('lastPoll', Date.now());
32
+ },
33
+ async onDisable(context) {
34
+ return;
35
+ },
36
+ async run(context) {
37
+ const lastFetchEpochMS = (await context.store.get<number>('lastPoll')) ?? 0;
38
+
39
+ const items = await pollRecentMessages({
40
+ auth: context.auth,
41
+ props: context.propsValue,
42
+ files: context.files,
43
+ lastFetchEpochMS: lastFetchEpochMS,
44
+ });
45
+
46
+ const newLastEpochMilliSeconds = items.reduce(
47
+ (acc, item) => Math.max(acc, item.epochMilliSeconds),
48
+ lastFetchEpochMS
49
+ );
50
+ await context.store.put('lastPoll', newLastEpochMilliSeconds);
51
+ return items
52
+ .filter((f) => f.epochMilliSeconds > lastFetchEpochMS)
53
+ .map((item) => item.data);
54
+ },
55
+ async test(context) {
56
+ const lastFetchEpochMS = (await context.store.get<number>('lastPoll')) ?? 0;
57
+
58
+ const items = await pollRecentMessages({
59
+ auth: context.auth,
60
+ props: context.propsValue,
61
+ files: context.files,
62
+ lastFetchEpochMS: lastFetchEpochMS,
63
+ });
64
+
65
+ return getFirstFiveOrAll(items.map((item) => item.data));
66
+ },
67
+ });
68
+
69
+ interface PropsValue {
70
+ from: string | undefined;
71
+ to: string | undefined;
72
+ subject: string | undefined;
73
+ label: GmailLabel | undefined;
74
+ category: string | undefined;
75
+ }
76
+
77
+ async function pollRecentMessages({
78
+ auth,
79
+ props,
80
+ files,
81
+ lastFetchEpochMS,
82
+ }: {
83
+ auth: GmailAuthValue;
84
+ props: PropsValue;
85
+ files: FilesService;
86
+ lastFetchEpochMS: number;
87
+ }): Promise<
88
+ {
89
+ epochMilliSeconds: number;
90
+ data: unknown;
91
+ }[]
92
+ > {
93
+ const authClient = await createGoogleClient(auth);
94
+
95
+ const gmail = google.gmail({ version: 'v1', auth: authClient });
96
+
97
+ // construct query
98
+ const query = [];
99
+ const maxResults = lastFetchEpochMS === 0 ? 5 : 20;
100
+ const afterUnixSeconds = Math.floor(lastFetchEpochMS / 1000);
101
+
102
+ if (props.from) query.push(`from:(${props.from})`);
103
+ if (props.to) query.push(`to:(${props.to})`);
104
+ if (props.subject) query.push(`subject:(${props.subject})`);
105
+ if (props.label) query.push(`label:${props.label.name}`);
106
+ if (props.category) query.push(`category:${props.category}`);
107
+ if (afterUnixSeconds != null && afterUnixSeconds > 0)
108
+ query.push(`after:${afterUnixSeconds}`);
109
+
110
+ // List Messages
111
+ const messagesResponse = await gmail.users.messages.list({
112
+ userId: 'me',
113
+ q: query.join(' '),
114
+ maxResults,
115
+ });
116
+
117
+ // Reverse to process oldest-first so partial progress doesn't skip messages
118
+ const messages = (messagesResponse.data.messages || []).slice().reverse();
119
+
120
+ const pollingResponse = [];
121
+ for (const message of messages) {
122
+ try {
123
+ const rawMailResponse = await gmail.users.messages.get({
124
+ userId: 'me',
125
+ id: message.id!,
126
+ format: 'raw',
127
+ });
128
+ const threadResponse = await gmail.users.threads.get({
129
+ userId: 'me',
130
+ id: message.threadId!,
131
+ });
132
+
133
+ const parsedMailResponse = await parseStream(
134
+ Buffer.from(rawMailResponse.data.raw as string, 'base64').toString(
135
+ 'utf-8'
136
+ )
137
+ );
138
+
139
+ pollingResponse.push({
140
+ epochMilliSeconds: Number(rawMailResponse.data.internalDate),
141
+ data: {
142
+ message: {
143
+ ...parsedMailResponse,
144
+ attachments: await convertAttachment(
145
+ parsedMailResponse.attachments,
146
+ files
147
+ ),
148
+ },
149
+ thread: {
150
+ ...threadResponse,
151
+ },
152
+ },
153
+ });
154
+ } catch (error: any) {
155
+ const isRateLimit =
156
+ error.status === 429 ||
157
+ (error.status === 403 &&
158
+ /quota|rate.?limit/i.test(error.message ?? ''));
159
+ if (isRateLimit) {
160
+ break;
161
+ }
162
+ throw error;
163
+ }
164
+ }
165
+
166
+ return pollingResponse;
167
+ }
@@ -0,0 +1,77 @@
1
+ import { createTrigger, TriggerStrategy } from '@guayaba/workflows-framework';
2
+ import { gmailAuth, createGoogleClient } from '../auth';
3
+ import { google } from 'googleapis';
4
+ import { getFirstFiveOrAll } from '../common/data';
5
+ import { isNil } from '@guayaba/workflows-shared';
6
+
7
+ const TRIGGER_KEY = 'labels';
8
+
9
+ export const gmailNewLabelTrigger = createTrigger({
10
+ auth: gmailAuth,
11
+ name: 'new_label',
12
+ displayName: 'New Label',
13
+ description: 'Triggers when a new label is created.',
14
+ props: {},
15
+ sampleData: {},
16
+ type: TriggerStrategy.POLLING,
17
+ async onEnable(context) {
18
+ const authClient = await createGoogleClient(context.auth);
19
+ const gmail = google.gmail({ version: 'v1', auth: authClient });
20
+
21
+ const response = await gmail.users.labels.list({
22
+ userId: 'me',
23
+ });
24
+
25
+ const labels = response.data.labels || [];
26
+
27
+ const existingLabelIds = labels.map((label) => label.id);
28
+ await context.store.put(TRIGGER_KEY, JSON.stringify(existingLabelIds));
29
+ },
30
+ async onDisable(context) {
31
+ await context.store.delete(TRIGGER_KEY);
32
+ },
33
+ async test(context) {
34
+ const authClient = await createGoogleClient(context.auth);
35
+ const gmail = google.gmail({ version: 'v1', auth: authClient });
36
+
37
+ const response = await gmail.users.labels.list({
38
+ userId: 'me',
39
+ });
40
+
41
+ const labels = response.data.labels || [];
42
+
43
+ return getFirstFiveOrAll(labels);
44
+ },
45
+ async run(context) {
46
+ const existingIds = (await context.store.get<string>(TRIGGER_KEY)) ?? '[]';
47
+ const parsedExistingIds = JSON.parse(existingIds) as string[];
48
+
49
+ const authClient = await createGoogleClient(context.auth);
50
+
51
+ const gmail = google.gmail({ version: 'v1', auth: authClient });
52
+
53
+ const response = await gmail.users.labels.list({
54
+ userId: 'me',
55
+ });
56
+
57
+ if (isNil(response.data.labels) || response.data.labels.length === 0) {
58
+ await context.store.put(TRIGGER_KEY, '[]');
59
+ return [];
60
+ }
61
+
62
+ const allCurrentIds = response.data.labels.map((label) => label.id);
63
+
64
+ const newLables = response.data.labels.filter((label) => {
65
+ const labelId = label.id ?? undefined;
66
+ return labelId !== undefined && !parsedExistingIds.includes(labelId);
67
+ });
68
+
69
+ await context.store.put(TRIGGER_KEY, JSON.stringify(allCurrentIds));
70
+
71
+ if (newLables.length === 0) {
72
+ return [];
73
+ }
74
+
75
+ return newLables;
76
+ },
77
+ });