diff --git a/packages/nodes-base/nodes/SendGrid/GenericFunctions.ts b/packages/nodes-base/nodes/SendGrid/GenericFunctions.ts index 60612c7de8..0e4c81713e 100644 --- a/packages/nodes-base/nodes/SendGrid/GenericFunctions.ts +++ b/packages/nodes-base/nodes/SendGrid/GenericFunctions.ts @@ -13,7 +13,7 @@ import { IDataObject, } from 'n8n-workflow'; -export async function sendGridApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, endpoint: string, method: string, body: any = {}, qs: IDataObject = {}, uri?: string | undefined): Promise { // tslint:disable-line:no-any +export async function sendGridApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, endpoint: string, method: string, body: any = {}, qs: IDataObject = {}, option: IDataObject = {}): Promise { // tslint:disable-line:no-any const credentials = this.getCredentials('sendGridApi') as IDataObject; const host = 'api.sendgrid.com/v3'; @@ -25,7 +25,7 @@ export async function sendGridApiRequest(this: IHookFunctions | IExecuteFunction method, qs, body, - uri: uri || `https://${host}${endpoint}`, + uri: `https://${host}${endpoint}`, json: true, }; @@ -33,6 +33,10 @@ export async function sendGridApiRequest(this: IHookFunctions | IExecuteFunction delete options.body; } + if (Object.keys(option).length !== 0) { + Object.assign(options, option); + } + try { //@ts-ignore return await this.helpers.request!(options); diff --git a/packages/nodes-base/nodes/SendGrid/MailDescription.ts b/packages/nodes-base/nodes/SendGrid/MailDescription.ts new file mode 100644 index 0000000000..6ba317168f --- /dev/null +++ b/packages/nodes-base/nodes/SendGrid/MailDescription.ts @@ -0,0 +1,381 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const mailOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'mail', + ], + }, + }, + options: [ + { + name: 'Send', + value: 'send', + description: 'Send an email.', + }, + ], + default: 'send', + description: 'Operation to perform.', + }, +] as INodeProperties[]; + +export const mailFields = [ + /* -------------------------------------------------------------------------- */ + /* mail:send */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Sender Email', + name: 'fromEmail', + type: 'string', + default: '', + placeholder: 'sender@domain.com', + description: 'Email address of the sender of the email.', + displayOptions: { + show: { + resource: [ + 'mail', + ], + operation: [ + 'send', + ], + }, + }, + }, + { + displayName: 'Sender Name', + name: 'fromName', + type: 'string', + default: '', + placeholder: 'John Smith', + description: 'Name of the sender of the email.', + displayOptions: { + show: { + resource: [ + 'mail', + ], + operation: [ + 'send', + ], + }, + }, + }, + { + displayName: 'Recipient Email', + name: 'toEmail', + type: 'string', + default: '', + placeholder: 'recipient@domain.com', + description: 'Comma-separated list of recipient email addresses.', + displayOptions: { + show: { + resource: [ + 'mail', + ], + operation: [ + 'send', + ], + }, + }, + }, + { + displayName: 'Subject', + name: 'subject', + type: 'string', + default: '', + description: 'Subject of the email to send.', + displayOptions: { + show: { + resource: [ + 'mail', + ], + operation: [ + 'send', + ], + dynamicTemplate: [ + false, + ], + }, + }, + }, + { + displayName: 'Dynamic Template', + name: 'dynamicTemplate', + type: 'boolean', + required: true, + default: false, + description: 'Whether this email will contain a dynamic template.', + displayOptions: { + show: { + resource: [ + 'mail', + ], + operation: [ + 'send', + ], + }, + }, + }, + { + displayName: 'MIME type', + name: 'contentType', + type: 'options', + default: 'text/plain', + description: 'MIME type of the email to send.', + options: [ + { + name: 'Plain Text', + value: 'text/plain', + }, + { + name: 'HTML', + value: 'text/html', + }, + ], + displayOptions: { + show: { + resource: [ + 'mail', + ], + operation: [ + 'send', + ], + dynamicTemplate: [ + false, + ], + }, + }, + }, + { + displayName: 'Message Body', + name: 'contentValue', + type: 'string', + default: '', + required: true, + description: 'Message body of the email to send.', + typeOptions: { + alwaysOpenEditWindow: true, + }, + displayOptions: { + show: { + resource: [ + 'mail', + ], + operation: [ + 'send', + ], + dynamicTemplate: [ + false, + ], + }, + }, + }, + { + displayName: 'Template ID', + name: 'templateId', + type: 'options', + default: [], + typeOptions: { + loadOptionsMethod: 'getTemplateIds', + }, + displayOptions: { + show: { + resource: [ + 'mail', + ], + operation: [ + 'send', + ], + dynamicTemplate: [ + true, + ], + }, + }, + }, + { + displayName: 'Dynamic Template Fields', + name: 'dynamicTemplateFields', + placeholder: 'Add Dynamic Template Fields', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + displayOptions: { + show: { + resource: [ + 'mail', + ], + operation: [ + 'send', + ], + dynamicTemplate: [ + true, + ], + }, + }, + options: [ + { + displayName: 'Fields', + name: 'fields', + values: [ + { + displayName: 'Key', + name: 'key', + type: 'string', + default: '', + description: 'Key of the dynamic template field.', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + description: 'Value for the field', + }, + ], + }, + ], + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'mail', + ], + operation: [ + 'send', + ], + }, + }, + options: [ + { + displayName: 'Attachments', + name: 'attachments', + type: 'string', + default: '', + description: 'Comma-separated list of binary properties', + }, + { + displayName: 'BCC Email', + name: 'bccEmail', + type: 'string', + default: '', + description: 'Comma-separated list of emails of the recipients
of a blind carbon copy of the email.', + }, + { + displayName: 'Categories', + name: 'categories', + type: 'string', + default: '', + description: 'Comma-separated list of categories. Each category name may not exceed 255 characters.', + }, + { + displayName: 'CC Email', + name: 'ccEmail', + type: 'string', + default: '', + description: 'Comma-separated list of emails of the recipients
of a carbon copy of the email.', + }, + { + displayName: 'Enable Sandbox', + name: 'enableSandbox', + type: 'boolean', + default: false, + description: 'Whether to use to the sandbox for testing out email-sending functionality.', + }, + { + displayName: 'IP Pool Name', + name: 'ipPoolName', + type: 'string', + default: '', + description: 'The IP Pool that you would like to send this email from.', + }, + { + displayName: 'Headers', + name: 'headers', + placeholder: 'Add Header', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + displayName: 'Details', + name: 'details', + values: [ + { + displayName: 'Key', + name: 'key', + type: 'string', + default: '', + description: 'Key to set in the header object.', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + description: 'Value to set in the header object.', + }, + ], + }, + ], + }, + { + displayName: 'Send At', + name: 'sendAt', + type: 'dateTime', + default: '', + description: 'When to deliver the email. Scheduling more than 72 hours in advance is forbidden.', + }, + ], + }, +] as INodeProperties[]; + +export type SendMailBody = { + personalizations: Array<{ + to: EmailName[], + subject?: string, + cc?: EmailName[], + bcc?: EmailName[], + dynamic_template_data?: { [key: string]: string }, + send_at?: number, + }>, + ip_pool_name?: string; + from: EmailName, + template_id?: string, + content?: Array<{ + type: string, + value: string, + }>, + categories?: string[], + headers?: { [key: string]: string }, + attachments?: Array<{ + content: string, + filename: string, + type: string, + }>, + mail_settings: { + sandbox_mode: { + enable: boolean, + }, + }, +}; + +type EmailName = { + email: string, + name?: string, +}; diff --git a/packages/nodes-base/nodes/SendGrid/SendGrid.node.ts b/packages/nodes-base/nodes/SendGrid/SendGrid.node.ts index 6c4f33645d..8d8996255c 100644 --- a/packages/nodes-base/nodes/SendGrid/SendGrid.node.ts +++ b/packages/nodes-base/nodes/SendGrid/SendGrid.node.ts @@ -1,4 +1,5 @@ import { + BINARY_ENCODING, IExecuteFunctions, } from 'n8n-core'; @@ -21,11 +22,19 @@ import { contactOperations } from './ContactDescription'; +import { + mailFields, + mailOperations, + SendMailBody, +} from './MailDescription'; + import { sendGridApiRequest, sendGridApiRequestAllItems, } from './GenericFunctions'; +import * as moment from 'moment-timezone'; + export class SendGrid implements INodeType { description: INodeTypeDescription = { displayName: 'SendGrid', @@ -63,6 +72,10 @@ export class SendGrid implements INodeType { name: 'List', value: 'list', }, + { + name: 'Mail', + value: 'mail', + }, ], default: 'list', required: true, @@ -72,17 +85,19 @@ export class SendGrid implements INodeType { ...listFields, ...contactOperations, ...contactFields, + ...mailOperations, + ...mailFields, ], }; - methods ={ + methods = { loadOptions: { // Get custom fields to display to user so that they can select them easily - async getCustomFields(this: ILoadOptionsFunctions,):Promise{ + async getCustomFields(this: ILoadOptionsFunctions,): Promise { const returnData: INodePropertyOptions[] = []; const { custom_fields } = await sendGridApiRequest.call(this, '/marketing/field_definitions', 'GET', {}, {}); if (custom_fields !== undefined) { - for (const customField of custom_fields){ + for (const customField of custom_fields) { returnData.push({ name: customField.name, value: customField.id, @@ -103,6 +118,10 @@ export class SendGrid implements INodeType { } return returnData; }, + async getTemplateIds(this: ILoadOptionsFunctions): Promise { + const responseData = await sendGridApiRequest.call(this, '/templates', 'GET', {}, { generations: 'dynamic' }); + return responseData.templates.map(({ id, name }: { id: string, name: string }) => ({ name, value: id })); + }, }, }; @@ -111,6 +130,7 @@ export class SendGrid implements INodeType { const length = (items.length as unknown) as number; const qs: IDataObject = {}; let responseData; + const timezone = this.getTimezone(); const returnData: IDataObject[] = []; const resource = this.getNodeParameter('resource', 0) as string; const operation = this.getNodeParameter('operation', 0) as string; @@ -150,7 +170,7 @@ export class SendGrid implements INodeType { const email = this.getNodeParameter('email', i) as string; endpoint = '/marketing/contacts/search'; method = 'POST'; - Object.assign(body, { query: `email LIKE '${email}' `}); + Object.assign(body, { query: `email LIKE '${email}' ` }); } responseData = await sendGridApiRequest.call(this, endpoint, method, body, qs); responseData = responseData.result || responseData; @@ -163,7 +183,7 @@ export class SendGrid implements INodeType { if (operation === 'upsert') { const contacts = []; for (let i = 0; i < length; i++) { - const email = this.getNodeParameter('email',i) as string; + const email = this.getNodeParameter('email', i) as string; const additionalFields = this.getNodeParameter( 'additionalFields', i, @@ -175,7 +195,7 @@ export class SendGrid implements INodeType { const addressValues = (additionalFields.addressUi as IDataObject).addressValues as IDataObject; const addressLine1 = addressValues.address1 as string; const addressLine2 = addressValues.address2 as string; - if (addressLine2){ + if (addressLine2) { Object.assign(contact, { address_line_2: addressLine2 }); } Object.assign(contact, { address_line_1: addressLine1 }); @@ -194,7 +214,7 @@ export class SendGrid implements INodeType { } if (additionalFields.lastName) { const lastName = additionalFields.lastName as string; - Object.assign(contact, { last_name:lastName}); + Object.assign(contact, { last_name: lastName }); } if (additionalFields.postalCode) { const postalCode = additionalFields.postalCode as string; @@ -231,17 +251,17 @@ export class SendGrid implements INodeType { if (operation === 'delete') { for (let i = 0; i < length; i++) { const deleteAll = this.getNodeParameter('deleteAll', i) as boolean; - if(deleteAll === true) { + if (deleteAll === true) { qs.delete_all_contacts = 'true'; } - qs.ids = (this.getNodeParameter('ids',i) as string).replace(/\s/g, ''); + qs.ids = (this.getNodeParameter('ids', i) as string).replace(/\s/g, ''); responseData = await sendGridApiRequest.call(this, `/marketing/contacts`, 'DELETE', {}, qs); returnData.push(responseData); } } } if (resource === 'list') { - if (operation === 'getAll'){ + if (operation === 'getAll') { for (let i = 0; i < length; i++) { const returnAll = this.getNodeParameter('returnAll', i) as boolean; responseData = await sendGridApiRequestAllItems.call(this, `/marketing/lists`, 'GET', 'result', {}, qs); @@ -254,7 +274,7 @@ export class SendGrid implements INodeType { } if (operation === 'get') { for (let i = 0; i < length; i++) { - const listId = this.getNodeParameter('listId',i) as string; + const listId = this.getNodeParameter('listId', i) as string; qs.contact_sample = this.getNodeParameter('contactSample', i) as boolean; responseData = await sendGridApiRequest.call(this, `/marketing/lists/${listId}`, 'GET', {}, qs); returnData.push(responseData); @@ -262,29 +282,156 @@ export class SendGrid implements INodeType { } if (operation === 'create') { for (let i = 0; i < length; i++) { - const name = this.getNodeParameter('name',i) as string; + const name = this.getNodeParameter('name', i) as string; responseData = await sendGridApiRequest.call(this, '/marketing/lists', 'POST', { name }, qs); returnData.push(responseData); } } if (operation === 'delete') { for (let i = 0; i < length; i++) { - const listId = this.getNodeParameter('listId',i) as string; + const listId = this.getNodeParameter('listId', i) as string; qs.delete_contacts = this.getNodeParameter('deleteContacts', i) as boolean; responseData = await sendGridApiRequest.call(this, `/marketing/lists/${listId}`, 'DELETE', {}, qs); responseData = { success: true }; returnData.push(responseData); } } - if (operation=== 'update'){ + if (operation === 'update') { for (let i = 0; i < length; i++) { - const name = this.getNodeParameter('name',i) as string; - const listId = this.getNodeParameter('listId',i) as string; + const name = this.getNodeParameter('name', i) as string; + const listId = this.getNodeParameter('listId', i) as string; responseData = await sendGridApiRequest.call(this, `/marketing/lists/${listId}`, 'PATCH', { name }, qs); returnData.push(responseData); } } } + if (resource === 'mail') { + if (operation === 'send') { + for (let i = 0; i < length; i++) { + + const toEmail = this.getNodeParameter('toEmail', i) as string; + + const parsedToEmail = toEmail.includes(',') + ? toEmail.split(',').map((i) => ({ email: i.trim() })) + : [{ email: toEmail.trim() }]; + + const { + bccEmail, + ccEmail, + enableSandbox, + sendAt, + headers, + attachments, + categories, + ipPoolName, + } = this.getNodeParameter('additionalFields', i) as { + bccEmail: string; + ccEmail: string; + enableSandbox: boolean, + sendAt: string; + headers: { details: Array<{ key: string; value: string }> }; + attachments: string; + categories: string; + ipPoolName: string; + }; + + const body: SendMailBody = { + personalizations: [{ + to: parsedToEmail, + }], + from: { + email: (this.getNodeParameter('fromEmail', i) as string).trim(), + name: this.getNodeParameter('fromName', i) as string, + }, + mail_settings: { + sandbox_mode: { + enable: enableSandbox || false, + }, + }, + }; + + const dynamicTemplateEnabled = this.getNodeParameter('dynamicTemplate', i); + + // dynamic template + if (dynamicTemplateEnabled) { + body.template_id = this.getNodeParameter('templateId', i) as string; + + const { fields } = this.getNodeParameter('dynamicTemplateFields', i) as { + fields: Array<{ [key: string]: string }> + }; + + if (fields) { + fields.forEach(field => { + body.personalizations[0].dynamic_template_data = { + [field.key]: field.value, + }; + }); + } + + // message body + } else { + body.personalizations[0].subject = this.getNodeParameter('subject', i) as string; + body.content = [{ + type: this.getNodeParameter('contentType', i) as string, + value: this.getNodeParameter('contentValue', i) as string, + }]; + } + + if (attachments) { + const attachmentsToSend = []; + const binaryProperties = attachments.split(',').map((p) => p.trim()); + + for (const property of binaryProperties) { + if (!items[i].binary?.hasOwnProperty(property)) { + throw new Error(`The binary property ${property} does not exist`); + } + + const binaryProperty = items[i].binary![property]; + + attachmentsToSend.push({ + content: binaryProperty.data, + filename: binaryProperty.fileName || 'unknown', + type: binaryProperty.mimeType, + }); + } + + if (attachmentsToSend.length) { + body.attachments = attachmentsToSend; + } + } + + if (bccEmail) { + body.personalizations[0].bcc = bccEmail.split(',').map(i => ({ email: i.trim() })); + } + + if (ccEmail) { + body.personalizations[0].cc = ccEmail.split(',').map(i => ({ email: i.trim() })); + } + + if (headers?.details.length) { + const parsedHeaders: { [key: string]: string } = {}; + headers.details.forEach(obj => parsedHeaders[obj['key']] = obj['value']); + body.headers = parsedHeaders; + } + + if (categories) { + body.categories = categories.split(',') as string[]; + } + + if (ipPoolName) { + body.ip_pool_name = ipPoolName as string; + } + + if (sendAt) { + body.personalizations[0].send_at = moment.tz(sendAt, timezone).unix(); + } + + const data = await sendGridApiRequest.call(this, '/mail/send', 'POST', body, qs, { resolveWithFullResponse: true }); + + returnData.push({ messageId: data!.headers['x-message-id'] }); + } + } + } return [this.helpers.returnJsonArray(returnData)]; } }