n8n-nodes-gmail-custom 0.1.1

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,474 @@
1
+ const crypto = require('crypto');
2
+
3
+ const TOKEN_CACHE = {};
4
+
5
+ function formatPrivateKey(privateKey) {
6
+ if (privateKey.includes('\\n')) {
7
+ return privateKey.replace(/\\n/g, '\n');
8
+ }
9
+ return privateKey;
10
+ }
11
+
12
+ function base64UrlEncode(str) {
13
+ return Buffer.from(str)
14
+ .toString('base64')
15
+ .replace(/=/g, '')
16
+ .replace(/\+/g, '-')
17
+ .replace(/\//g, '_');
18
+ }
19
+
20
+ function base64urlEscape(base64) {
21
+ return base64.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
22
+ }
23
+
24
+ function generateBoundary() {
25
+ return '----=_Part_' + crypto.randomBytes(16).toString('hex');
26
+ }
27
+
28
+ async function buildMimeMessage(options) {
29
+ let MailComposer;
30
+ try {
31
+ MailComposer = require('nodemailer/lib/mail-composer');
32
+ } catch (e) {
33
+ }
34
+
35
+ if (MailComposer) {
36
+ const mailOptions = {
37
+ from: options.from || undefined,
38
+ to: options.to || undefined,
39
+ cc: options.cc || undefined,
40
+ bcc: options.bcc || undefined,
41
+ replyTo: options.replyTo || undefined,
42
+ subject: options.subject || '',
43
+ text: options.textBody || '',
44
+ keepBcc: true,
45
+ };
46
+
47
+ if (options.htmlBody) {
48
+ mailOptions.html = options.htmlBody;
49
+ }
50
+
51
+ if (options.attachments && options.attachments.length > 0) {
52
+ mailOptions.attachments = options.attachments.map((att) => ({
53
+ filename: att.fileName || 'attachment',
54
+ content: att.content,
55
+ contentType: att.mimeType || 'application/octet-stream',
56
+ encoding: 'base64',
57
+ }));
58
+ }
59
+
60
+ const mail = new MailComposer(mailOptions).compile();
61
+ mail.keepBcc = true;
62
+ const mailBody = await mail.build();
63
+ return mailBody.toString('base64')
64
+ .replace(/\+/g, '-')
65
+ .replace(/\//g, '_')
66
+ .replace(/=+$/, '');
67
+ }
68
+
69
+ const hasAttachments = options.attachments && options.attachments.length > 0;
70
+ const hasHtml = !!options.htmlBody;
71
+ const from = options.from || '';
72
+ const to = options.to || '';
73
+ const cc = options.cc || '';
74
+ const bcc = options.bcc || '';
75
+ const replyTo = options.replyTo || '';
76
+ const subject = options.subject || '';
77
+ const textBody = options.textBody || '';
78
+ const htmlBody = options.htmlBody || '';
79
+
80
+ const boundaryMixed = generateBoundary();
81
+ const boundaryAlt = generateBoundary();
82
+ const lines = [];
83
+
84
+ if (from) lines.push(`From: ${from}`);
85
+ if (to) lines.push(`To: ${to}`);
86
+ if (cc) lines.push(`CC: ${cc}`);
87
+ if (bcc) lines.push(`BCC: ${bcc}`);
88
+ if (replyTo) lines.push(`Reply-To: ${replyTo}`);
89
+ lines.push(`Subject: ${subject}`);
90
+ lines.push('MIME-Version: 1.0');
91
+
92
+ if (hasAttachments) {
93
+ lines.push(`Content-Type: multipart/mixed; boundary="${boundaryMixed}"`);
94
+ lines.push('');
95
+ lines.push(`--${boundaryMixed}`);
96
+ if (hasHtml) {
97
+ lines.push(`Content-Type: multipart/alternative; boundary="${boundaryAlt}"`);
98
+ lines.push('');
99
+ lines.push(`--${boundaryAlt}`);
100
+ lines.push('Content-Type: text/plain; charset="UTF-8"');
101
+ lines.push('Content-Transfer-Encoding: 7bit');
102
+ lines.push('');
103
+ lines.push(textBody);
104
+ lines.push('');
105
+ lines.push(`--${boundaryAlt}`);
106
+ lines.push('Content-Type: text/html; charset="UTF-8"');
107
+ lines.push('Content-Transfer-Encoding: 7bit');
108
+ lines.push('');
109
+ lines.push(htmlBody);
110
+ lines.push('');
111
+ lines.push(`--${boundaryAlt}--`);
112
+ } else {
113
+ lines.push('Content-Type: text/plain; charset="UTF-8"');
114
+ lines.push('Content-Transfer-Encoding: 7bit');
115
+ lines.push('');
116
+ lines.push(textBody);
117
+ }
118
+ lines.push('');
119
+ for (const att of options.attachments) {
120
+ lines.push(`--${boundaryMixed}`);
121
+ lines.push(`Content-Type: ${att.mimeType || 'application/octet-stream'}; name="${att.fileName}"`);
122
+ lines.push('Content-Transfer-Encoding: base64');
123
+ lines.push(`Content-Disposition: attachment; filename="${att.fileName}"`);
124
+ lines.push('');
125
+ const base64Data = typeof att.content === 'string' ? att.content : att.content.toString('base64');
126
+ const lines64 = base64Data.match(/.{1,76}/g) || [''];
127
+ lines.push(lines64.join('\r\n'));
128
+ }
129
+ lines.push(`--${boundaryMixed}--`);
130
+ } else if (hasHtml) {
131
+ lines.push(`Content-Type: multipart/alternative; boundary="${boundaryAlt}"`);
132
+ lines.push('');
133
+ lines.push(`--${boundaryAlt}`);
134
+ lines.push('Content-Type: text/plain; charset="UTF-8"');
135
+ lines.push('Content-Transfer-Encoding: 7bit');
136
+ lines.push('');
137
+ lines.push(textBody);
138
+ lines.push('');
139
+ lines.push(`--${boundaryAlt}`);
140
+ lines.push('Content-Type: text/html; charset="UTF-8"');
141
+ lines.push('Content-Transfer-Encoding: 7bit');
142
+ lines.push('');
143
+ lines.push(htmlBody);
144
+ lines.push('');
145
+ lines.push(`--${boundaryAlt}--`);
146
+ } else {
147
+ lines.push('Content-Type: text/plain; charset="UTF-8"');
148
+ lines.push('Content-Transfer-Encoding: 7bit');
149
+ lines.push('');
150
+ lines.push(textBody);
151
+ }
152
+
153
+ const rawMessage = lines.join('\r\n');
154
+ return Buffer.from(rawMessage, 'utf-8')
155
+ .toString('base64')
156
+ .replace(/\+/g, '-')
157
+ .replace(/\//g, '_')
158
+ .replace(/=+$/, '');
159
+ }
160
+
161
+ class GmailCustom {
162
+
163
+ constructor() {
164
+ this.description = {
165
+ displayName: 'Gmail Custom',
166
+ name: 'gmailCustom',
167
+ icon: 'fa:envelope',
168
+ group: ['output'],
169
+ version: 1,
170
+ subtitle: '={{$parameter["subject"] || "Send Email"}}',
171
+ description: 'Send email via Gmail API with token caching and 429 retry',
172
+ defaults: {
173
+ name: 'Gmail Custom',
174
+ color: '#1a73e8',
175
+ },
176
+ inputs: ['main'],
177
+ outputs: ['main'],
178
+ credentials: [
179
+ {
180
+ name: 'googleApi',
181
+ required: true,
182
+ },
183
+ ],
184
+ properties: [
185
+ {
186
+ displayName: 'From Email',
187
+ name: 'fromEmail',
188
+ type: 'string',
189
+ default: '',
190
+ required: true,
191
+ placeholder: 'name@domain.com',
192
+ description: 'Email address of the sender (must have Google Workspace account with domain-wide delegation)',
193
+ },
194
+ {
195
+ displayName: 'To',
196
+ name: 'sendTo',
197
+ type: 'string',
198
+ default: '',
199
+ required: true,
200
+ placeholder: 'info@example.com',
201
+ description: 'The email addresses of the recipients. Multiple addresses can be separated by a comma.',
202
+ },
203
+ {
204
+ displayName: 'Subject',
205
+ name: 'subject',
206
+ type: 'string',
207
+ default: '',
208
+ required: true,
209
+ placeholder: 'Hello World!',
210
+ },
211
+ {
212
+ displayName: 'Email Type',
213
+ name: 'emailType',
214
+ type: 'options',
215
+ default: 'html',
216
+ required: true,
217
+ noDataExpression: true,
218
+ options: [
219
+ { name: 'Text', value: 'text' },
220
+ { name: 'HTML', value: 'html' },
221
+ ],
222
+ },
223
+ {
224
+ displayName: 'Message',
225
+ name: 'message',
226
+ type: 'string',
227
+ default: '',
228
+ required: true,
229
+ typeOptions: {
230
+ rows: 8,
231
+ },
232
+ },
233
+ {
234
+ displayName: 'Options',
235
+ name: 'options',
236
+ type: 'collection',
237
+ placeholder: 'Add option',
238
+ default: {},
239
+ options: [
240
+ {
241
+ displayName: 'Attachments',
242
+ name: 'attachmentsUi',
243
+ placeholder: 'Add Attachment',
244
+ type: 'fixedCollection',
245
+ typeOptions: {
246
+ multipleValues: true,
247
+ },
248
+ options: [
249
+ {
250
+ name: 'attachmentsBinary',
251
+ displayName: 'Attachment Binary',
252
+ values: [
253
+ {
254
+ displayName: 'Attachment Field Name',
255
+ name: 'property',
256
+ type: 'string',
257
+ default: 'data',
258
+ description: 'Add the field name from the input node. Multiple properties can be set separated by comma.',
259
+ hint: 'The name of the field with the attachment in the node input',
260
+ },
261
+ ],
262
+ },
263
+ ],
264
+ default: {},
265
+ description: 'Array of supported attachments to add to the message',
266
+ },
267
+ {
268
+ displayName: 'BCC',
269
+ name: 'bccList',
270
+ type: 'string',
271
+ description: 'The email addresses of the blind copy recipients. Multiple addresses can be separated by a comma.',
272
+ placeholder: 'info@example.com',
273
+ default: '',
274
+ },
275
+ {
276
+ displayName: 'CC',
277
+ name: 'ccList',
278
+ type: 'string',
279
+ description: 'The email addresses of the copy recipients. Multiple addresses can be separated by a comma.',
280
+ placeholder: 'info@example.com',
281
+ default: '',
282
+ },
283
+ {
284
+ displayName: 'Send Replies To',
285
+ name: 'replyTo',
286
+ type: 'string',
287
+ placeholder: 'reply@example.com',
288
+ default: '',
289
+ description: 'The email address that the reply message is sent to',
290
+ },
291
+ {
292
+ displayName: 'Append n8n Attribution',
293
+ name: 'appendAttribution',
294
+ type: 'boolean',
295
+ default: true,
296
+ description: 'Whether to include the phrase "This email was sent automatically with n8n" to the end of the email',
297
+ },
298
+ ],
299
+ },
300
+ ],
301
+ };
302
+ }
303
+
304
+ async execute() {
305
+ const items = this.getInputData();
306
+ const returnData = [];
307
+
308
+ for (let i = 0; i < items.length; i++) {
309
+ try {
310
+ const fromEmail = this.getNodeParameter('fromEmail', i, '');
311
+ const sendTo = this.getNodeParameter('sendTo', i, '');
312
+ const subject = this.getNodeParameter('subject', i, '');
313
+ const emailType = this.getNodeParameter('emailType', i, 'html');
314
+ const message = this.getNodeParameter('message', i, '');
315
+ const options = this.getNodeParameter('options', i, {});
316
+
317
+ if (!fromEmail) throw new Error('From Email is required');
318
+ if (!sendTo) throw new Error('To is required');
319
+ if (!subject) throw new Error('Subject is required');
320
+ if (!message) throw new Error('Message is required');
321
+
322
+ let cc = options.ccList || '';
323
+ let bcc = options.bccList || '';
324
+ let replyTo = options.replyTo || '';
325
+ const appendAttribution = options.appendAttribution === undefined ? true : options.appendAttribution;
326
+
327
+ let bodyText = message;
328
+ let bodyHtml = '';
329
+
330
+ if (emailType === 'html') {
331
+ bodyHtml = message;
332
+ bodyText = message.replace(/<[^>]*>/g, '').replace(/\n\s*\n/g, '\n').trim();
333
+ if (!bodyText) bodyText = '';
334
+ } else {
335
+ bodyText = message;
336
+ }
337
+
338
+ if (appendAttribution) {
339
+ const attributionText = '\n\n---\nThis email was sent automatically with n8n';
340
+ if (emailType === 'html') {
341
+ bodyHtml += '<br><br>---<br><em>This email was sent automatically with n8n</em>';
342
+ } else {
343
+ bodyText += attributionText;
344
+ }
345
+ }
346
+
347
+ const credentials = await this.getCredentials('googleApi');
348
+ const cacheKey = `${credentials.email}:${fromEmail}`;
349
+ let accessToken;
350
+ let expiresAt = 0;
351
+
352
+ if (TOKEN_CACHE[cacheKey]) {
353
+ expiresAt = TOKEN_CACHE[cacheKey].expiresAt;
354
+ accessToken = TOKEN_CACHE[cacheKey].accessToken;
355
+ }
356
+
357
+ if (!accessToken || Date.now() >= expiresAt) {
358
+ const privateKey = formatPrivateKey(credentials.privateKey);
359
+ const now = Math.floor(Date.now() / 1000);
360
+ const header = { alg: 'RS256', typ: 'JWT' };
361
+ const payload = {
362
+ iss: credentials.email,
363
+ scope: 'https://mail.google.com/',
364
+ aud: 'https://oauth2.googleapis.com/token',
365
+ exp: now + 3600,
366
+ iat: now,
367
+ sub: fromEmail,
368
+ };
369
+
370
+ const signatureInput = base64UrlEncode(JSON.stringify(header)) + '.' + base64UrlEncode(JSON.stringify(payload));
371
+ const signer = crypto.createSign('RSA-SHA256');
372
+ signer.update(signatureInput);
373
+ const sig = signer.sign(privateKey);
374
+ const jwt = signatureInput + '.' + base64urlEscape(sig.toString('base64'));
375
+
376
+ const response = await this.helpers.httpRequest({
377
+ method: 'POST',
378
+ url: 'https://oauth2.googleapis.com/token',
379
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
380
+ body: new URLSearchParams({
381
+ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
382
+ assertion: jwt,
383
+ }).toString(),
384
+ });
385
+
386
+ accessToken = response.access_token;
387
+ TOKEN_CACHE[cacheKey] = {
388
+ accessToken,
389
+ expiresAt: Date.now() + 3500 * 1000,
390
+ };
391
+ }
392
+
393
+ let attachments = [];
394
+ if (options.attachmentsUi && options.attachmentsUi.attachmentsBinary) {
395
+ for (const { property } of options.attachmentsUi.attachmentsBinary) {
396
+ for (const name of property.split(',')) {
397
+ const binaryData = this.helpers.assertBinaryData(i, name.trim());
398
+ const binaryDataBuffer = await this.helpers.getBinaryDataBuffer(i, name.trim());
399
+ attachments.push({
400
+ fileName: binaryData.fileName || 'attachment',
401
+ mimeType: binaryData.mimeType || 'application/octet-stream',
402
+ content: binaryDataBuffer,
403
+ });
404
+ }
405
+ }
406
+ }
407
+
408
+ const base64UrlMessage = await buildMimeMessage({
409
+ from: fromEmail,
410
+ to: sendTo,
411
+ cc,
412
+ bcc,
413
+ replyTo,
414
+ subject,
415
+ textBody: bodyText,
416
+ htmlBody: bodyHtml,
417
+ attachments,
418
+ });
419
+
420
+ const maxRetries = 4;
421
+ let lastError;
422
+
423
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
424
+ try {
425
+ const sendResponse = await this.helpers.httpRequest({
426
+ method: 'POST',
427
+ url: 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send',
428
+ headers: {
429
+ Authorization: `Bearer ${accessToken}`,
430
+ 'Content-Type': 'application/json',
431
+ },
432
+ body: { raw: base64UrlMessage },
433
+ });
434
+
435
+ returnData.push({
436
+ json: sendResponse,
437
+ pairedItem: { item: i },
438
+ });
439
+ lastError = null;
440
+ break;
441
+ } catch (error) {
442
+ lastError = error;
443
+ const statusCode = error.statusCode || error.response?.statusCode;
444
+ if (statusCode === 429 && attempt < maxRetries) {
445
+ const delay = Math.min(Math.pow(2, attempt) * 1000 + Math.random() * 1000, 30000);
446
+ await new Promise(resolve => setTimeout(resolve, delay));
447
+ continue;
448
+ }
449
+ throw error;
450
+ }
451
+ }
452
+
453
+ if (lastError) throw lastError;
454
+
455
+ } catch (error) {
456
+ const continueOnFail = typeof this.continueOnFail === 'function' ? this.continueOnFail() : false;
457
+ if (continueOnFail) {
458
+ returnData.push({
459
+ json: { error: error.message },
460
+ pairedItem: { item: i },
461
+ });
462
+ continue;
463
+ }
464
+ throw error;
465
+ }
466
+ }
467
+
468
+ return [returnData];
469
+ }
470
+ }
471
+
472
+ module.exports = {
473
+ nodeTypes: [GmailCustom],
474
+ };
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "n8n-nodes-gmail-custom",
3
+ "version": "0.1.1",
4
+ "description": "Custom Gmail node for n8n with token caching and 429 retry",
5
+ "keywords": [
6
+ "n8n-community-node-package",
7
+ "n8n",
8
+ "gmail",
9
+ "google"
10
+ ],
11
+ "license": "MIT",
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "n8n": {
16
+ "n8nNodesApiVersion": 1,
17
+ "nodes": [
18
+ "nodes/GmailCustom/GmailCustom.node.js"
19
+ ]
20
+ }
21
+ }