@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,256 @@
1
+ import { Property } from '@guayaba/workflows-framework';
2
+ import { GmailRequests } from './data';
3
+ import { GmailLabel } from './models';
4
+ import { gmailAuth, createGoogleClient, GmailAuthValue } from '../auth';
5
+ import { google } from 'googleapis';
6
+
7
+ export const GmailProps = {
8
+ from: Property.ShortText({
9
+ displayName: 'Email sender',
10
+ description:
11
+ 'Optional filteration, leave empty to filter based on the email sender',
12
+ required: false,
13
+ defaultValue: '',
14
+ }),
15
+ to: Property.ShortText({
16
+ displayName: 'Email recipient',
17
+ description:
18
+ 'Optional filteration, leave empty to filter based on the email recipient',
19
+ required: false,
20
+ defaultValue: '',
21
+ }),
22
+ subject: Property.ShortText({
23
+ displayName: 'Email subject',
24
+ description: 'The email subject',
25
+ required: false,
26
+ defaultValue: '',
27
+ }),
28
+ category: Property.StaticDropdown({
29
+ displayName: 'Category',
30
+ description:
31
+ 'Optional filteration, leave unselected to filter based on the email category',
32
+ required: false,
33
+ options: {
34
+ disabled: false,
35
+ options: [
36
+ { label: 'Primary', value: 'primary' },
37
+ { label: 'Social', value: 'social' },
38
+ { label: 'Promotions', value: 'promotions' },
39
+ { label: 'Updates', value: 'updates' },
40
+ { label: 'Forums', value: 'forums' },
41
+ { label: 'Reservations', value: 'reservations' },
42
+ { label: 'Purchases', value: 'purchases' },
43
+ ],
44
+ },
45
+ }),
46
+ label: Property.Dropdown<GmailLabel, false, typeof gmailAuth>({
47
+ auth: gmailAuth,
48
+ displayName: 'Label',
49
+ description:
50
+ 'Optional filteration, leave unselected to filter based on the email label',
51
+ required: false,
52
+ defaultValue: '',
53
+ refreshers: [],
54
+ options: async ({ auth }) => {
55
+ if (!auth) {
56
+ return {
57
+ disabled: true,
58
+ options: [],
59
+ placeholder: 'please authenticate first',
60
+ };
61
+ }
62
+
63
+ const response = await GmailRequests.getLabels(auth);
64
+
65
+ return {
66
+ disabled: false,
67
+ options: response.body.labels.map((label) => ({
68
+ label: label.name,
69
+ value: label,
70
+ })),
71
+ };
72
+ },
73
+ }),
74
+ unread: (required = false) =>
75
+ Property.Checkbox({
76
+ displayName: 'Is unread?',
77
+ description: 'Check if the email is unread or not',
78
+ required,
79
+ defaultValue: false,
80
+ }),
81
+ message: Property.Dropdown({
82
+ displayName: 'Message',
83
+ description:
84
+ 'Select a message from the list or enter a message ID manually.',
85
+ required: true,
86
+ auth: gmailAuth,
87
+ refreshers: [],
88
+ options: async ({ auth }) => {
89
+ if (!auth) {
90
+ return {
91
+ disabled: true,
92
+ options: [],
93
+ placeholder: 'Please authenticate first',
94
+ };
95
+ }
96
+
97
+ try {
98
+ const authValue = auth as GmailAuthValue;
99
+ const authClient = await createGoogleClient(authValue);
100
+
101
+ const gmail = google.gmail({ version: 'v1', auth: authClient });
102
+
103
+ const response = await GmailRequests.getRecentMessages(
104
+ authValue,
105
+ 20 // Get last 20 messages
106
+ );
107
+
108
+ if (!response.body.messages || response.body.messages.length === 0) {
109
+ return {
110
+ disabled: false,
111
+ options: [],
112
+ placeholder:
113
+ 'No recent messages found. You can enter a message ID manually.',
114
+ };
115
+ }
116
+
117
+ // Get message details for better display
118
+ const messageDetails = await Promise.all(
119
+ response.body.messages
120
+ .slice(0, 10)
121
+ .map(async (msg: { id: string; threadId: string }) => {
122
+ try {
123
+ const details = await gmail.users.messages.get({
124
+ metadataHeaders: ['Subject'],
125
+ format: 'metadata',
126
+ id: msg.id,
127
+ userId: 'me',
128
+ });
129
+
130
+ const headers = details.data.payload?.headers || [];
131
+ const subject =
132
+ headers.find((h: any) => h.name === 'Subject')?.value ||
133
+ 'No Subject';
134
+
135
+ return {
136
+ id: msg.id,
137
+ subject:
138
+ subject.length > 50
139
+ ? subject.substring(0, 50) + '...'
140
+ : subject,
141
+ };
142
+ } catch (error) {
143
+ console.log(error);
144
+ return {
145
+ id: msg.id,
146
+ subject: 'Unable to load details',
147
+ };
148
+ }
149
+ })
150
+ );
151
+
152
+ return {
153
+ disabled: false,
154
+ options: messageDetails.map((msg) => ({
155
+ label: msg.subject,
156
+ value: msg.id,
157
+ })),
158
+ };
159
+ } catch (error) {
160
+ return {
161
+ disabled: false,
162
+ options: [],
163
+ placeholder:
164
+ 'Error loading recent messages. You can enter a message ID manually.',
165
+ };
166
+ }
167
+ },
168
+ }),
169
+ thread: Property.Dropdown({
170
+ displayName: 'Thread',
171
+ description: 'Select a thread from the list or enter a thread ID manually',
172
+ required: true,
173
+ refreshers: [],
174
+ auth: gmailAuth,
175
+ options: async ({ auth }) => {
176
+ if (!auth) {
177
+ return {
178
+ disabled: true,
179
+ options: [],
180
+ placeholder: 'Please authenticate first',
181
+ };
182
+ }
183
+
184
+ try {
185
+ const authValue = auth as GmailAuthValue;
186
+ const authClient = await createGoogleClient(authValue);
187
+
188
+ const gmail = google.gmail({ version: 'v1', auth: authClient });
189
+
190
+ const response = await GmailRequests.getRecentThreads(
191
+ authValue,
192
+ 15 // Get last 15 threads
193
+ );
194
+
195
+ if (!response.body.threads || response.body.threads.length === 0) {
196
+ return {
197
+ disabled: false,
198
+ options: [],
199
+ placeholder:
200
+ 'No recent threads found. You can enter a thread ID manually.',
201
+ };
202
+ }
203
+
204
+ // Get thread details for better display
205
+ const threadDetails = await Promise.all(
206
+ response.body.threads
207
+ .slice(0, 10)
208
+ .map(async (thread: { id: string; snippet?: string }) => {
209
+ try {
210
+ const details = await await gmail.users.threads.get({
211
+ metadataHeaders: ['Subject'],
212
+ format: 'metadata',
213
+ id: thread.id,
214
+ userId: 'me',
215
+ });
216
+ // Get the first message to extract subject and participants
217
+ const firstMessage = details.data.messages?.[0];
218
+ const headers = firstMessage?.payload?.headers || [];
219
+ const subject =
220
+ headers.find((h: any) => h.name === 'Subject')?.value ||
221
+ 'No Subject';
222
+
223
+ return {
224
+ id: thread.id,
225
+ subject:
226
+ subject.length > 50
227
+ ? subject.substring(0, 50) + '...'
228
+ : subject,
229
+ };
230
+ } catch (error) {
231
+ return {
232
+ id: thread.id,
233
+ subject: 'Unable to load details',
234
+ };
235
+ }
236
+ })
237
+ );
238
+
239
+ return {
240
+ disabled: false,
241
+ options: threadDetails.map((thread) => ({
242
+ label: thread.subject,
243
+ value: thread.id,
244
+ })),
245
+ };
246
+ } catch (error) {
247
+ return {
248
+ disabled: false,
249
+ options: [],
250
+ placeholder:
251
+ 'Error loading recent threads. You can enter a thread ID manually.',
252
+ };
253
+ }
254
+ },
255
+ }),
256
+ };
@@ -0,0 +1,198 @@
1
+ import {
2
+ createTrigger,
3
+ TriggerStrategy,
4
+ Property,
5
+ FilesService,
6
+ } from '@guayaba/workflows-framework';
7
+ import { GmailProps } from '../common/props';
8
+ import { gmailAuth, createGoogleClient, GmailAuthValue } from '../auth';
9
+ import { google } from 'googleapis';
10
+ import {
11
+ parseStream,
12
+ convertAttachment,
13
+ getFirstFiveOrAll,
14
+ } from '../common/data';
15
+ import { GmailLabel } from '../common/models';
16
+
17
+ type Props = {
18
+ from?: string;
19
+ to?: string;
20
+ subject?: string;
21
+ label?: GmailLabel;
22
+ category?: string;
23
+ filenameExtension?: string;
24
+ };
25
+
26
+ export const gmailNewAttachmentTrigger = createTrigger({
27
+ auth: gmailAuth,
28
+ name: 'new_attachment',
29
+ displayName: 'New Attachment',
30
+ description: 'Triggers when an email with an attachment arrives.',
31
+ props: {
32
+ from: {
33
+ ...GmailProps.from,
34
+ description: 'Filter by sender email.',
35
+ displayName: 'From',
36
+ required: false,
37
+ },
38
+ to: {
39
+ ...GmailProps.to,
40
+ description: 'Filter by recipient email.',
41
+ displayName: 'To',
42
+ required: false,
43
+ },
44
+ subject: Property.ShortText({
45
+ displayName: 'Subject Contains',
46
+ description:
47
+ 'Only trigger for emails containing this text in the subject.',
48
+ required: false,
49
+ }),
50
+ label: {
51
+ ...GmailProps.label,
52
+ description: 'Filter by Gmail label.',
53
+ displayName: 'Label',
54
+ required: false,
55
+ },
56
+ category: {
57
+ ...GmailProps.category,
58
+ description: 'Filter by Gmail category.',
59
+ displayName: 'Category',
60
+ required: false,
61
+ },
62
+ filenameExtension: Property.ShortText({
63
+ displayName: 'File Extension',
64
+ description:
65
+ 'Only trigger for attachments with this file extension (e.g., pdf, jpg, docx).',
66
+ required: false,
67
+ }),
68
+ },
69
+ sampleData: {},
70
+ type: TriggerStrategy.POLLING,
71
+ async onEnable(context) {
72
+ await context.store.put('lastPoll', Date.now());
73
+ },
74
+ async onDisable(context) {
75
+ return;
76
+ },
77
+ async run(context) {
78
+ const lastFetchEpochMS = (await context.store.get<number>('lastPoll')) ?? 0;
79
+
80
+ const items = await pollRecentMessages({
81
+ auth: context.auth,
82
+ props: context.propsValue,
83
+ files: context.files,
84
+ lastFetchEpochMS: lastFetchEpochMS,
85
+ });
86
+
87
+ const newLastEpochMilliSeconds = items.reduce(
88
+ (acc, item) => Math.max(acc, item.epochMilliSeconds),
89
+ lastFetchEpochMS
90
+ );
91
+ await context.store.put('lastPoll', newLastEpochMilliSeconds);
92
+ return items
93
+ .filter((f) => f.epochMilliSeconds > lastFetchEpochMS)
94
+ .map((item) => item.data);
95
+ },
96
+ async test(context) {
97
+ const lastFetchEpochMS = (await context.store.get<number>('lastPoll')) ?? 0;
98
+
99
+ const items = await pollRecentMessages({
100
+ auth: context.auth,
101
+ props: context.propsValue,
102
+ files: context.files,
103
+ lastFetchEpochMS: lastFetchEpochMS,
104
+ });
105
+
106
+ return getFirstFiveOrAll(items.map((item) => item.data));
107
+ },
108
+ });
109
+
110
+ async function pollRecentMessages({
111
+ auth,
112
+ props,
113
+ files,
114
+ lastFetchEpochMS,
115
+ }: {
116
+ auth: GmailAuthValue;
117
+ props: Props;
118
+ files: FilesService;
119
+ lastFetchEpochMS: number;
120
+ }): Promise<
121
+ {
122
+ epochMilliSeconds: number;
123
+ data: unknown;
124
+ }[]
125
+ > {
126
+ const authClient = await createGoogleClient(auth);
127
+
128
+ const gmail = google.gmail({ version: 'v1', auth: authClient });
129
+
130
+ // construct query
131
+ const query = ['has:attachment'];
132
+ const maxResults = lastFetchEpochMS === 0 ? 5 : 20;
133
+ const afterUnixSeconds = Math.floor(lastFetchEpochMS / 1000);
134
+
135
+ if (props.from) query.push(`from:(${props.from})`);
136
+ if (props.to) query.push(`to:(${props.to})`);
137
+ if (props.subject) query.push(`subject:(${props.subject})`);
138
+ if (props.label) query.push(`label:${props.label.name}`);
139
+ if (props.category) query.push(`category:${props.category}`);
140
+ if (props.filenameExtension)
141
+ query.push(`filename:${props.filenameExtension}`);
142
+ if (afterUnixSeconds != null && afterUnixSeconds > 0)
143
+ query.push(`after:${afterUnixSeconds}`);
144
+
145
+ // List Messages
146
+ const messagesResponse = await gmail.users.messages.list({
147
+ userId: 'me',
148
+ q: query.join(' '),
149
+ maxResults,
150
+ });
151
+
152
+ // Reverse to process oldest-first so partial progress doesn't skip messages
153
+ const messages = (messagesResponse.data.messages || []).slice().reverse();
154
+
155
+ const pollingResponse = [];
156
+ for (const message of messages) {
157
+ try {
158
+ const rawMailResponse = await gmail.users.messages.get({
159
+ userId: 'me',
160
+ id: message.id!,
161
+ format: 'raw',
162
+ });
163
+
164
+ const parsedMailResponse = await parseStream(
165
+ Buffer.from(rawMailResponse.data.raw as string, 'base64').toString(
166
+ 'utf-8'
167
+ )
168
+ );
169
+
170
+ const { attachments, ...restOfParsedMailResponse } = parsedMailResponse;
171
+ const parsedAttachments = await convertAttachment(attachments, files);
172
+
173
+ for (const attachment of parsedAttachments) {
174
+ pollingResponse.push({
175
+ epochMilliSeconds: Number(rawMailResponse.data.internalDate),
176
+ data: {
177
+ attachment,
178
+ message: {
179
+ id: message.id,
180
+ ...restOfParsedMailResponse,
181
+ },
182
+ },
183
+ });
184
+ }
185
+ } catch (error: any) {
186
+ const isRateLimit =
187
+ error.status === 429 ||
188
+ (error.status === 403 &&
189
+ /quota|rate.?limit/i.test(error.message ?? ''));
190
+ if (isRateLimit) {
191
+ break;
192
+ }
193
+ throw error;
194
+ }
195
+ }
196
+
197
+ return pollingResponse;
198
+ }