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
|
-
|
|
3
|
+
Self-contained n8n node for sending emails via Gmail API with a Google service account and domain-wide delegation.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
No credential setup in n8n required — all parameters are inline, supports expressions.
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
|
-
-
|
|
10
|
-
- **Token caching
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
13
|
-
-
|
|
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** →
|
|
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.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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.
|
|
4
|
-
"description": "Custom Gmail
|
|
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
|
}
|