n8n-nodes-gmail-custom 0.3.0 → 0.5.0

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/README.md CHANGED
@@ -1,32 +1,66 @@
1
1
  # n8n-nodes-gmail-custom
2
2
 
3
- Custom Gmail node for n8n with token caching and 429 retry.
3
+ Self-contained n8n node for sending emails via Gmail API with a Google service account and domain-wide delegation.
4
4
 
5
- A drop-in replacement for the native Gmail v2 Send operation, optimized for service account usage with domain-wide delegation.
5
+ No credential setup in n8n required all parameters are inline, supports expressions.
6
6
 
7
7
  ## Features
8
8
 
9
- - Same UI as native Gmail Send (To, Subject, Email Type, Message, Options with CC/BCC/ReplyTo/Attachments)
10
- - **Token caching** access token is reused across calls (1 auth call per hour instead of per email)
11
- - **429 retry** automatic retry with exponential backoff when rate limited
12
- - **From Email inline** specify the delegated user directly in the node (no need to set in credential)
13
- - No extra API calls (no GET /profile like the native node)
9
+ - **Self-contained**: service account email + private key are node parameters (no n8n credential needed)
10
+ - **Token caching**: access token reused for 58 minutes — only 1 oauth call per hour instead of per email
11
+ - **Domain-wide delegation**: send as any user in your Workspace domain
12
+ - **Sender name**: set a display name without extra API calls
13
+ - **Optional custom Message-ID**: auto-generated `<uuid@domain>` or specify your own included in output
14
+ - **Attachments**: from binary data with graceful skip if missing
15
+ - **HTML and plain text email support**
14
16
 
15
17
  ## Installation
16
18
 
17
- In n8n: **Settings → Community Nodes → Install** → Enter `n8n-nodes-gmail-custom`
19
+ In n8n: **Settings → Community Nodes → Install** → `n8n-nodes-gmail-custom`
18
20
 
19
21
  ## Usage
20
22
 
21
23
  1. Add a **Gmail Custom** node to your workflow
22
- 2. Select your **Google API** credential (service account)
23
- 3. Fill in:
24
- - **From Email**: the delegated user to send as (e.g. `segreteria.a@domain.com`)
25
- - **To**: recipient(s)
26
- - **Subject**: email subject
27
- - **Email Type**: Text or HTML
28
- - **Message**: email body
29
- 4. Configure advanced Options as needed (CC, BCC, ReplyTo, Attachments)
24
+ 2. Fill in the required fields (supports expressions):
25
+
26
+ | Field | Description | Example |
27
+ |---|---|---|
28
+ | Service Account Email | Your SA `client_email` | `sa-name@project.iam.gserviceaccount.com` |
29
+ | Private Key | RSA private key (PEM, hidden) | Expression from a previous node |
30
+ | From Email | The user to impersonate (DWD) | `sender@your-domain.com` |
31
+ | To | Recipient(s), comma-separated | `recipient@example.com` |
32
+ | Subject | Email subject | `Hello World` |
33
+ | Email Type | `HTML` or `Text` | `HTML` |
34
+ | Message | Email body | Your HTML or text |
35
+
36
+ 3. Configure **Options** as needed:
37
+ - **CC / BCC**: carbon copy recipients
38
+ - **Reply To**: reply-to address
39
+ - **Sender Name**: display name (e.g. `John Doe`)
40
+ - **Attachments**: binary data field names from previous nodes
41
+ - **Custom Message-ID**: enable and optionally specify a `Message-ID` header. Auto-generated as `<uuid@domain>` if left empty. Returned in the node output.
42
+
43
+ ## Output
44
+
45
+ ```json
46
+ {
47
+ "id": "19ef486e4ed521e6",
48
+ "threadId": "19ef486e4ed521e6",
49
+ "labelIds": ["SENT"],
50
+ "messageId": "<generated-uuid@your-domain.com>"
51
+ }
52
+ ```
53
+
54
+ `messageId` is only present when the Custom Message-ID option is enabled.
55
+
56
+ ## How it works
57
+
58
+ 1. Signs a JWT with the service account private key
59
+ 2. Obtains an access token from `oauth2.googleapis.com/token` (cached)
60
+ 3. Builds a MIME email message
61
+ 4. Sends via `POST gmail.googleapis.com/gmail/v1/users/me/messages/send`
62
+
63
+ Per email sent: **1 Gmail API call** (token is cached for 1 hour).
30
64
 
31
65
  ## License
32
66
 
@@ -1,28 +1,5 @@
1
1
  const crypto = require('crypto');
2
-
3
- let jwtLib;
4
- try { jwtLib = require('jsonwebtoken'); } catch (e) {}
5
-
6
- const TOKEN_CACHE = {};
7
-
8
- function formatPrivateKey(privateKey) {
9
- if (privateKey.includes('\\n')) {
10
- return privateKey.replace(/\\n/g, '\n');
11
- }
12
- return privateKey;
13
- }
14
-
15
- function base64UrlEncode(str) {
16
- return Buffer.from(str)
17
- .toString('base64')
18
- .replace(/=/g, '')
19
- .replace(/\+/g, '-')
20
- .replace(/\//g, '_');
21
- }
22
-
23
- function base64urlEscape(base64) {
24
- return base64.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
25
- }
2
+ const { getOrRefreshAccessToken } = require('./utils');
26
3
 
27
4
  function generateBoundary() {
28
5
  return '----=_Part_' + crypto.randomBytes(16).toString('hex');
@@ -51,6 +28,10 @@ async function buildMimeMessage(options) {
51
28
  mailOptions.html = options.htmlBody;
52
29
  }
53
30
 
31
+ if (options.messageId) {
32
+ mailOptions.messageId = options.messageId;
33
+ }
34
+
54
35
  if (options.attachments && options.attachments.length > 0) {
55
36
  mailOptions.attachments = options.attachments.map((att) => ({
56
37
  filename: att.fileName || 'attachment',
@@ -89,6 +70,7 @@ async function buildMimeMessage(options) {
89
70
  if (cc) lines.push(`CC: ${cc}`);
90
71
  if (bcc) lines.push(`BCC: ${bcc}`);
91
72
  if (replyTo) lines.push(`Reply-To: ${replyTo}`);
73
+ if (options.messageId) lines.push(`Message-ID: ${options.messageId}`);
92
74
  lines.push(`Subject: ${subject}`);
93
75
  lines.push('MIME-Version: 1.0');
94
76
 
@@ -161,58 +143,6 @@ async function buildMimeMessage(options) {
161
143
  .replace(/=+$/, '');
162
144
  }
163
145
 
164
- async function getOrRefreshAccessToken(ctx, serviceAccountEmail, privateKeyRaw, fromEmail) {
165
- const cacheKey = `${serviceAccountEmail}:${fromEmail}`;
166
- let token = TOKEN_CACHE[cacheKey];
167
- if (token && token.expiresAt > Date.now()) {
168
- return token.accessToken;
169
- }
170
-
171
- const privateKey = formatPrivateKey(privateKeyRaw);
172
- const now = Math.floor(Date.now() / 1000);
173
-
174
- let jwt;
175
- const payload = {
176
- iss: serviceAccountEmail,
177
- scope: 'https://mail.google.com/',
178
- aud: 'https://oauth2.googleapis.com/token',
179
- exp: now + 3600,
180
- iat: now,
181
- sub: fromEmail,
182
- };
183
-
184
- if (jwtLib) {
185
- jwt = jwtLib.sign(payload, privateKey, {
186
- algorithm: 'RS256',
187
- header: { kid: privateKey, typ: 'JWT', alg: 'RS256' },
188
- });
189
- } else {
190
- const header = { alg: 'RS256', typ: 'JWT', kid: privateKey };
191
- const signatureInput = base64UrlEncode(JSON.stringify(header)) + '.' + base64UrlEncode(JSON.stringify(payload));
192
- const signer = crypto.createSign('RSA-SHA256');
193
- signer.update(signatureInput);
194
- const sig = signer.sign(privateKey);
195
- jwt = signatureInput + '.' + base64urlEscape(sig.toString('base64'));
196
- }
197
-
198
- const response = await ctx.helpers.httpRequest({
199
- method: 'POST',
200
- url: 'https://oauth2.googleapis.com/token',
201
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
202
- body: new URLSearchParams({
203
- grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
204
- assertion: jwt,
205
- }).toString(),
206
- });
207
-
208
- TOKEN_CACHE[cacheKey] = {
209
- accessToken: response.access_token,
210
- expiresAt: Date.now() + 3500 * 1000,
211
- };
212
-
213
- return response.access_token;
214
- }
215
-
216
146
  class GmailCustom {
217
147
 
218
148
  constructor() {
@@ -363,6 +293,25 @@ class GmailCustom {
363
293
  placeholder: 'e.g. John Doe',
364
294
  description: 'Name shown as the sender in recipients\' inboxes',
365
295
  },
296
+ {
297
+ displayName: 'Custom Message-ID',
298
+ name: 'enableMessageId',
299
+ type: 'boolean',
300
+ default: false,
301
+ description: 'Whether to set a custom Message-ID header (auto-generated if not specified)',
302
+ },
303
+ {
304
+ displayName: 'Message-ID Value',
305
+ name: 'customMessageId',
306
+ type: 'string',
307
+ default: '',
308
+ displayOptions: {
309
+ show: {
310
+ enableMessageId: [true],
311
+ },
312
+ },
313
+ description: 'Leave empty to auto-generate a unique Message-ID',
314
+ },
366
315
  ],
367
316
  },
368
317
  ],
@@ -404,6 +353,16 @@ class GmailCustom {
404
353
  fromHeader = `${senderName} <${fromEmail}>`;
405
354
  }
406
355
 
356
+ let messageId = null;
357
+ if (options.enableMessageId) {
358
+ if (options.customMessageId) {
359
+ messageId = options.customMessageId;
360
+ } else {
361
+ const domain = fromEmail.includes('@') ? fromEmail.split('@')[1] : 'localhost';
362
+ messageId = `<${crypto.randomUUID()}@${domain}>`;
363
+ }
364
+ }
365
+
407
366
  let bodyText = message;
408
367
  let bodyHtml = '';
409
368
 
@@ -450,22 +409,27 @@ class GmailCustom {
450
409
  textBody: bodyText,
451
410
  htmlBody: bodyHtml,
452
411
  attachments,
412
+ messageId,
453
413
  });
454
414
 
455
415
  const sendResponse = await this.helpers.httpRequest({
456
- method: 'POST',
457
- url: 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send',
458
- headers: {
459
- Authorization: `Bearer ${accessToken}`,
460
- 'Content-Type': 'application/json',
461
- },
462
- body: { raw: base64UrlMessage },
463
- });
416
+ method: 'POST',
417
+ url: 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send',
418
+ headers: {
419
+ Authorization: `Bearer ${accessToken}`,
420
+ 'Content-Type': 'application/json',
421
+ },
422
+ body: { raw: base64UrlMessage },
423
+ });
464
424
 
465
- returnData.push({
466
- json: sendResponse,
467
- pairedItem: { item: i },
468
- });
425
+ const result = { ...sendResponse };
426
+ if (messageId) {
427
+ result.messageId = messageId;
428
+ }
429
+ returnData.push({
430
+ json: result,
431
+ pairedItem: { item: i },
432
+ });
469
433
 
470
434
  } catch (error) {
471
435
  let continueOnFail = false;
@@ -0,0 +1,285 @@
1
+ const { getOrRefreshAccessToken } = require('./utils');
2
+
3
+ class GmailTriggerCustom {
4
+ constructor() {
5
+ this.description = {
6
+ displayName: 'Gmail Trigger Custom',
7
+ name: 'gmailTriggerCustom',
8
+ icon: 'fa:envelope',
9
+ group: ['trigger'],
10
+ version: 1,
11
+ subtitle: 'Gmail Trigger',
12
+ description: 'Fetches emails from Gmail on polling intervals with token caching',
13
+ defaults: { name: 'Gmail Trigger Custom' },
14
+ polling: true,
15
+ inputs: [],
16
+ outputs: ['main'],
17
+ properties: [
18
+ {
19
+ displayName: 'Service Account Email',
20
+ name: 'serviceAccountEmail',
21
+ type: 'string',
22
+ default: '',
23
+ required: true,
24
+ placeholder: 'sa-name@project.iam.gserviceaccount.com',
25
+ description: 'The service account client_email (issuer for JWT)',
26
+ },
27
+ {
28
+ displayName: 'Private Key',
29
+ name: 'privateKey',
30
+ type: 'string',
31
+ typeOptions: { password: true },
32
+ default: '',
33
+ required: true,
34
+ description: 'The service account RSA private key in PEM format',
35
+ },
36
+ {
37
+ displayName: 'Delegated Email',
38
+ name: 'delegatedEmail',
39
+ type: 'string',
40
+ default: '',
41
+ required: true,
42
+ placeholder: 'user@domain.com',
43
+ description: 'The Gmail mailbox to access (user to impersonate via domain-wide delegation)',
44
+ },
45
+ {
46
+ displayName: 'Simplify',
47
+ name: 'simple',
48
+ type: 'boolean',
49
+ default: true,
50
+ description: 'Whether to return a simplified version of the response instead of the raw data',
51
+ },
52
+ {
53
+ displayName: 'Filters',
54
+ name: 'filters',
55
+ type: 'collection',
56
+ placeholder: 'Add Filter',
57
+ default: {},
58
+ options: [
59
+ {
60
+ displayName: 'Include Spam and Trash',
61
+ name: 'includeSpamTrash',
62
+ type: 'boolean',
63
+ default: false,
64
+ description: 'Whether to include messages from SPAM and TRASH in the results',
65
+ },
66
+ {
67
+ displayName: 'Include Drafts',
68
+ name: 'includeDrafts',
69
+ type: 'boolean',
70
+ default: false,
71
+ description: 'Whether to include email drafts in the results',
72
+ },
73
+ {
74
+ displayName: 'Label Names or IDs',
75
+ name: 'labelIds',
76
+ type: 'string',
77
+ default: '',
78
+ description: 'Comma-separated label IDs to filter',
79
+ },
80
+ {
81
+ displayName: 'Search',
82
+ name: 'q',
83
+ type: 'string',
84
+ default: '',
85
+ placeholder: 'has:attachment',
86
+ description: 'Gmail search query. See <a href="https://support.google.com/mail/answer/7190">Gmail search syntax</a>.',
87
+ },
88
+ {
89
+ displayName: 'Read Status',
90
+ name: 'readStatus',
91
+ type: 'options',
92
+ default: 'unread',
93
+ options: [
94
+ { name: 'Unread and read emails', value: 'both' },
95
+ { name: 'Unread emails only', value: 'unread' },
96
+ { name: 'Read emails only', value: 'read' },
97
+ ],
98
+ },
99
+ {
100
+ displayName: 'Sender',
101
+ name: 'sender',
102
+ type: 'string',
103
+ default: '',
104
+ hint: 'Enter an email or part of a sender name',
105
+ },
106
+ ],
107
+ },
108
+ ],
109
+ };
110
+ }
111
+
112
+ async poll() {
113
+ const webhookName = 'default';
114
+ const workflowStaticData = this.getWorkflowStaticData('node') || {};
115
+ if (!workflowStaticData[webhookName]) {
116
+ workflowStaticData[webhookName] = {};
117
+ }
118
+ const nodeStaticData = workflowStaticData[webhookName];
119
+
120
+ const now = Math.floor(Date.now() / 1000);
121
+
122
+ if (this.getMode() !== 'manual') {
123
+ nodeStaticData.lastTimeChecked = nodeStaticData.lastTimeChecked || now;
124
+ }
125
+ const startDate = nodeStaticData.lastTimeChecked || now;
126
+
127
+ const serviceAccountEmail = this.getNodeParameter('serviceAccountEmail', 0) || '';
128
+ const privateKey = this.getNodeParameter('privateKey', 0) || '';
129
+ const delegatedEmail = this.getNodeParameter('delegatedEmail', 0) || '';
130
+ const simple = this.getNodeParameter('simple', 0);
131
+ const filters = this.getNodeParameter('filters', 0, {});
132
+
133
+ const accessToken = await getOrRefreshAccessToken(this, serviceAccountEmail, privateKey, delegatedEmail);
134
+
135
+ const responseData = [];
136
+ const allFetchedMessages = [];
137
+
138
+ const getEmailDateAsSeconds = (email) => {
139
+ let date;
140
+ if (email.internalDate) {
141
+ date = Math.floor(parseInt(email.internalDate, 10) / 1000);
142
+ }
143
+ if (!date || isNaN(date)) {
144
+ return startDate;
145
+ }
146
+ return date;
147
+ };
148
+
149
+ const fetchAndProcessMessage = async (messageId, fetchQs) => {
150
+ const fullMessage = await this.helpers.httpRequest({
151
+ method: 'GET',
152
+ url: `https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}`,
153
+ headers: { Authorization: `Bearer ${accessToken}` },
154
+ qs: fetchQs,
155
+ });
156
+
157
+ allFetchedMessages.push({
158
+ id: fullMessage.id,
159
+ date: getEmailDateAsSeconds(fullMessage),
160
+ });
161
+
162
+ if (!filters.includeDrafts && fullMessage.labelIds && fullMessage.labelIds.includes('DRAFT')) {
163
+ return;
164
+ }
165
+ if (fullMessage.labelIds && fullMessage.labelIds.includes('SENT') && !fullMessage.labelIds.includes('INBOX')) {
166
+ return;
167
+ }
168
+
169
+ if (simple) {
170
+ responseData.push({ json: fullMessage });
171
+ } else {
172
+ responseData.push({ json: fullMessage });
173
+ }
174
+ };
175
+
176
+ try {
177
+ const qs = {};
178
+ const parts = [];
179
+
180
+ if (!simple) {
181
+ qs.format = 'raw';
182
+ } else {
183
+ qs.format = 'metadata';
184
+ qs.metadataHeaders = ['From', 'To', 'Cc', 'Bcc', 'Subject'];
185
+ }
186
+
187
+ if (this.getMode() !== 'manual') {
188
+ parts.push(`after:${startDate}`);
189
+ }
190
+
191
+ if (filters.includeSpamTrash) {
192
+ parts.push('in:anywhere');
193
+ } else {
194
+ parts.push('-in:spam -in:trash');
195
+ }
196
+
197
+ if (filters.readStatus === 'unread') {
198
+ parts.push('is:unread');
199
+ } else if (filters.readStatus === 'read') {
200
+ parts.push('is:read');
201
+ }
202
+
203
+ if (filters.sender) {
204
+ parts.push(`from:${filters.sender}`);
205
+ }
206
+
207
+ if (filters.labelIds) {
208
+ for (const labelId of filters.labelIds.split(',')) {
209
+ parts.push(`label:${labelId.trim()}`);
210
+ }
211
+ }
212
+
213
+ if (filters.q) {
214
+ parts.push(filters.q);
215
+ }
216
+
217
+ parts.push('-in:scheduled');
218
+
219
+ qs.q = parts.join(' ');
220
+
221
+ if (this.getMode() === 'manual') {
222
+ qs.maxResults = 1;
223
+ }
224
+
225
+ const messagesResponse = await this.helpers.httpRequest({
226
+ method: 'GET',
227
+ url: 'https://gmail.googleapis.com/gmail/v1/users/me/messages',
228
+ headers: { Authorization: `Bearer ${accessToken}` },
229
+ qs,
230
+ });
231
+
232
+ const messages = messagesResponse.messages || [];
233
+
234
+ if (!messages.length && !allFetchedMessages.length) {
235
+ return null;
236
+ }
237
+
238
+ const possibleDuplicates = new Set(nodeStaticData.possibleDuplicates || []);
239
+ const filteredMessages = possibleDuplicates.size > 0
240
+ ? messages.filter((m) => !possibleDuplicates.has(m.id))
241
+ : messages;
242
+
243
+ if (!filteredMessages.length && !allFetchedMessages.length) {
244
+ return null;
245
+ }
246
+
247
+ const fetchQs = {};
248
+ if (!simple) {
249
+ fetchQs.format = 'raw';
250
+ } else {
251
+ fetchQs.format = 'metadata';
252
+ fetchQs.metadataHeaders = ['From', 'To', 'Cc', 'Bcc', 'Subject'];
253
+ }
254
+
255
+ for (const message of filteredMessages) {
256
+ await fetchAndProcessMessage(message.id, fetchQs);
257
+ }
258
+ } catch (error) {
259
+ if (this.getMode() === 'manual' || !nodeStaticData.lastTimeChecked) {
260
+ throw error;
261
+ }
262
+ try {
263
+ this.logger.error(`Gmail Trigger Custom poll error: ${error.message}`);
264
+ } catch (e) {}
265
+ }
266
+
267
+ if (!allFetchedMessages.length) {
268
+ return null;
269
+ }
270
+
271
+ const lastEmailDate = allFetchedMessages.reduce(
272
+ (last, m) => (m.date > last ? m.date : last), 0,
273
+ );
274
+
275
+ nodeStaticData.possibleDuplicates = allFetchedMessages.map((m) => m.id);
276
+ nodeStaticData.lastTimeChecked = Math.max(lastEmailDate || startDate, startDate);
277
+
278
+ return responseData.length > 0 ? [responseData] : null;
279
+ }
280
+ }
281
+
282
+ module.exports = {
283
+ nodeTypes: [GmailTriggerCustom],
284
+ GmailTriggerCustom,
285
+ };
@@ -0,0 +1,90 @@
1
+ const crypto = require('crypto');
2
+
3
+ let jwtLib;
4
+ try { jwtLib = require('jsonwebtoken'); } catch (e) {}
5
+
6
+ const TOKEN_CACHE = {};
7
+
8
+ function formatPrivateKey(privateKey) {
9
+ if (privateKey.includes('\\n')) {
10
+ return privateKey.replace(/\\n/g, '\n');
11
+ }
12
+ return privateKey;
13
+ }
14
+
15
+ function base64UrlEncode(str) {
16
+ return Buffer.from(str)
17
+ .toString('base64')
18
+ .replace(/=/g, '')
19
+ .replace(/\+/g, '-')
20
+ .replace(/\//g, '_');
21
+ }
22
+
23
+ function base64urlEscape(base64) {
24
+ return base64.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
25
+ }
26
+
27
+ async function getOrRefreshAccessToken(ctx, serviceAccountEmail, privateKeyRaw, delegatedEmail) {
28
+ const cacheKey = `${serviceAccountEmail}:${delegatedEmail}`;
29
+ let token = TOKEN_CACHE[cacheKey];
30
+ if (token && token.expiresAt > Date.now()) {
31
+ return token.accessToken;
32
+ }
33
+
34
+ const privateKey = formatPrivateKey(privateKeyRaw);
35
+ const now = Math.floor(Date.now() / 1000);
36
+
37
+ let jwt;
38
+ const payload = {
39
+ iss: serviceAccountEmail,
40
+ scope: 'https://mail.google.com/',
41
+ aud: 'https://oauth2.googleapis.com/token',
42
+ exp: now + 3600,
43
+ iat: now,
44
+ sub: delegatedEmail,
45
+ };
46
+
47
+ if (jwtLib) {
48
+ jwt = jwtLib.sign(payload, privateKey, {
49
+ algorithm: 'RS256',
50
+ header: { kid: privateKey, typ: 'JWT', alg: 'RS256' },
51
+ });
52
+ } else {
53
+ const header = { alg: 'RS256', typ: 'JWT', kid: privateKey };
54
+ const signatureInput = base64UrlEncode(JSON.stringify(header)) + '.' + base64UrlEncode(JSON.stringify(payload));
55
+ const signer = crypto.createSign('RSA-SHA256');
56
+ signer.update(signatureInput);
57
+ const sig = signer.sign(privateKey);
58
+ jwt = signatureInput + '.' + base64urlEscape(sig.toString('base64'));
59
+ }
60
+
61
+ const response = await ctx.helpers.httpRequest({
62
+ method: 'POST',
63
+ url: 'https://oauth2.googleapis.com/token',
64
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
65
+ body: new URLSearchParams({
66
+ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
67
+ assertion: jwt,
68
+ }).toString(),
69
+ });
70
+
71
+ TOKEN_CACHE[cacheKey] = {
72
+ accessToken: response.access_token,
73
+ expiresAt: Date.now() + 3500 * 1000,
74
+ };
75
+
76
+ return response.access_token;
77
+ }
78
+
79
+ function clearTokenCache(serviceAccountEmail, delegatedEmail) {
80
+ delete TOKEN_CACHE[`${serviceAccountEmail}:${delegatedEmail}`];
81
+ }
82
+
83
+ module.exports = {
84
+ formatPrivateKey,
85
+ base64UrlEncode,
86
+ base64urlEscape,
87
+ getOrRefreshAccessToken,
88
+ clearTokenCache,
89
+ TOKEN_CACHE,
90
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "n8n-nodes-gmail-custom",
3
- "version": "0.3.0",
4
- "description": "Custom Gmail node for n8n with token caching and 429 retry",
3
+ "version": "0.5.0",
4
+ "description": "Custom Gmail nodes for n8n with token caching — Send and Trigger, self-contained credentials",
5
5
  "keywords": [
6
6
  "n8n-community-node-package",
7
7
  "n8n",
@@ -19,7 +19,8 @@
19
19
  "n8nNodesApiVersion": 1,
20
20
  "credentials": [],
21
21
  "nodes": [
22
- "nodes/GmailCustom/GmailCustom.node.js"
22
+ "nodes/GmailCustom/GmailCustom.node.js",
23
+ "nodes/GmailCustom/GmailTriggerCustom.node.js"
23
24
  ]
24
25
  }
25
26
  }