import type {
IPollFunctions,
IDataObject,
ILoadOptionsFunctions,
INodeExecutionData,
INodePropertyOptions,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import { DateTime } from 'luxon';
import {
googleApiRequest,
googleApiRequestAllItems,
parseRawEmail,
prepareQuery,
simplifyOutput,
} from './GenericFunctions';
export class GmailTrigger implements INodeType {
description: INodeTypeDescription = {
displayName: 'Gmail Trigger',
name: 'gmailTrigger',
icon: 'file:gmail.svg',
group: ['trigger'],
version: [1, 1.1],
description:
'Fetches emails from Gmail and starts the workflow on specified polling intervals.',
subtitle: '={{"Gmail Trigger"}}',
defaults: {
name: 'Gmail Trigger',
},
credentials: [
{
name: 'googleApi',
required: true,
displayOptions: {
show: {
authentication: ['serviceAccount'],
},
},
},
{
name: 'gmailOAuth2',
required: true,
displayOptions: {
show: {
authentication: ['oAuth2'],
},
},
},
],
polling: true,
inputs: [],
outputs: ['main'],
properties: [
{
displayName: 'Authentication',
name: 'authentication',
type: 'options',
options: [
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'OAuth2 (recommended)',
value: 'oAuth2',
},
{
name: 'Service Account',
value: 'serviceAccount',
},
],
default: 'oAuth2',
},
{
displayName: 'Event',
name: 'event',
type: 'options',
default: 'messageReceived',
options: [
{
name: 'Message Received',
value: 'messageReceived',
},
],
},
{
displayName: 'Simplify',
name: 'simple',
type: 'boolean',
default: true,
description:
'Whether to return a simplified version of the response instead of the raw data',
},
{
displayName: 'Filters',
name: 'filters',
type: 'collection',
placeholder: 'Add Filter',
default: {},
options: [
{
displayName: 'Include Spam and Trash',
name: 'includeSpamTrash',
type: 'boolean',
default: false,
description: 'Whether to include messages from SPAM and TRASH in the results',
},
{
displayName: 'Label Names or IDs',
name: 'labelIds',
type: 'multiOptions',
typeOptions: {
loadOptionsMethod: 'getLabels',
},
default: [],
description:
'Only return messages with labels that match all of the specified label IDs. Choose from the list, or specify IDs using an expression.',
},
{
displayName: 'Search',
name: 'q',
type: 'string',
default: '',
placeholder: 'has:attachment',
hint: 'Use the same format as in the Gmail search box. More info.',
description: 'Only return messages matching the specified query',
},
{
displayName: 'Read Status',
name: 'readStatus',
type: 'options',
default: 'unread',
hint: 'Filter emails by whether they have been read or not',
options: [
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'Unread and read emails',
value: 'both',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'Unread emails only',
value: 'unread',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'Read emails only',
value: 'read',
},
],
},
{
displayName: 'Sender',
name: 'sender',
type: 'string',
default: '',
description: 'Sender name or email to filter by',
hint: 'Enter an email or part of a sender name',
},
],
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
displayOptions: {
hide: {
simple: [true],
},
},
options: [
{
displayName: 'Attachment Prefix',
name: 'dataPropertyAttachmentsPrefixName',
type: 'string',
default: 'attachment_',
description:
"Prefix for name of the binary property to which to write the attachment. An index starting with 0 will be added. So if name is 'attachment_' the first attachment is saved to 'attachment_0'.",
},
{
displayName: 'Download Attachments',
name: 'downloadAttachments',
type: 'boolean',
default: false,
description: "Whether the email's attachments will be downloaded",
},
],
},
],
};
methods = {
loadOptions: {
// Get all the labels to display them to user so that they can
// select them easily
async getLabels(this: ILoadOptionsFunctions): Promise {
const returnData: INodePropertyOptions[] = [];
const labels = await googleApiRequestAllItems.call(
this,
'labels',
'GET',
'/gmail/v1/users/me/labels',
);
for (const label of labels) {
returnData.push({
name: label.name,
value: label.id,
});
}
return returnData.sort((a, b) => {
if (a.name < b.name) {
return -1;
}
if (a.name > b.name) {
return 1;
}
return 0;
});
},
},
};
async poll(this: IPollFunctions): Promise {
const workflowStaticData = this.getWorkflowStaticData('node');
const node = this.getNode();
let nodeStaticData = workflowStaticData;
if (node.typeVersion > 1) {
const nodeName = node.name;
if (workflowStaticData[nodeName] === undefined) {
workflowStaticData[nodeName] = {} as IDataObject;
nodeStaticData = workflowStaticData[nodeName] as IDataObject;
} else {
nodeStaticData = workflowStaticData[nodeName] as IDataObject;
}
}
let responseData;
const now = Math.floor(DateTime.now().toSeconds()).toString();
const startDate = (nodeStaticData.lastTimeChecked as string) || +now;
const endDate = +now;
const options = this.getNodeParameter('options', {}) as IDataObject;
const filters = this.getNodeParameter('filters', {}) as IDataObject;
try {
const qs: IDataObject = {};
filters.receivedAfter = startDate;
if (this.getMode() === 'manual') {
qs.maxResults = 1;
delete filters.receivedAfter;
}
Object.assign(qs, prepareQuery.call(this, filters, 0), options);
responseData = await googleApiRequest.call(
this,
'GET',
'/gmail/v1/users/me/messages',
{},
qs,
);
responseData = responseData.messages;
if (!responseData?.length) {
nodeStaticData.lastTimeChecked = endDate;
return null;
}
const simple = this.getNodeParameter('simple') as boolean;
if (simple) {
qs.format = 'metadata';
qs.metadataHeaders = ['From', 'To', 'Cc', 'Bcc', 'Subject'];
} else {
qs.format = 'raw';
}
for (let i = 0; i < responseData.length; i++) {
responseData[i] = await googleApiRequest.call(
this,
'GET',
`/gmail/v1/users/me/messages/${responseData[i].id}`,
{},
qs,
);
if (!simple) {
const dataPropertyNameDownload =
(options.dataPropertyAttachmentsPrefixName as string) || 'attachment_';
responseData[i] = await parseRawEmail.call(
this,
responseData[i],
dataPropertyNameDownload,
);
}
}
if (simple) {
responseData = this.helpers.returnJsonArray(
await simplifyOutput.call(this, responseData as IDataObject[]),
);
}
} catch (error) {
if (this.getMode() === 'manual' || !nodeStaticData.lastTimeChecked) {
throw error;
}
const workflow = this.getWorkflow();
this.logger.error(
`There was a problem in '${node.name}' node in workflow '${workflow.id}': '${error.description}'`,
{
node: node.name,
workflowId: workflow.id,
error,
},
);
}
if (!responseData?.length) {
nodeStaticData.lastTimeChecked = endDate;
return null;
}
const emailsWithInvalidDate = new Set();
const getEmailDateAsSeconds = (email: IDataObject): number => {
let date;
if (email.internalDate) {
date = +(email.internalDate as string) / 1000;
} else if (email.date) {
date = +DateTime.fromJSDate(new Date(email.date as string)).toSeconds();
} else {
date = +DateTime.fromJSDate(
new Date((email?.headers as IDataObject)?.date as string),
).toSeconds();
}
if (!date || isNaN(date)) {
emailsWithInvalidDate.add(email.id as string);
return +startDate;
}
return date;
};
const lastEmailDate = (responseData as IDataObject[]).reduce((lastDate, { json }) => {
const emailDate = getEmailDateAsSeconds(json as IDataObject);
return emailDate > lastDate ? emailDate : lastDate;
}, 0);
const nextPollPossibleDuplicates = (responseData as IDataObject[]).reduce(
(duplicates, { json }) => {
const emailDate = getEmailDateAsSeconds(json as IDataObject);
return emailDate === lastEmailDate
? duplicates.concat((json as IDataObject).id as string)
: duplicates;
},
Array.from(emailsWithInvalidDate),
);
const possibleDuplicates = (nodeStaticData.possibleDuplicates as string[]) || [];
if (possibleDuplicates.length) {
responseData = (responseData as IDataObject[]).filter(({ json }) => {
const { id } = json as IDataObject;
return !possibleDuplicates.includes(id as string);
});
}
nodeStaticData.possibleDuplicates = nextPollPossibleDuplicates;
nodeStaticData.lastTimeChecked = lastEmailDate || endDate;
if (Array.isArray(responseData) && responseData.length) {
return [responseData as INodeExecutionData[]];
}
return null;
}
}