From 74cedd94a82f0c053a24b6e925d9e3bcadcebfbc Mon Sep 17 00:00:00 2001 From: brianinoa <54530642+brianinoa@users.noreply.github.com> Date: Wed, 3 Aug 2022 18:08:51 +0200 Subject: [PATCH] feat(SendInBlue Node): Add SendInBlue Regular + Trigger Node (#3746) * add sendinblue svg icon * Add code and required files for new sendinblue node * Add node to package.json * Update credentials to display API Key instead of Access Token * Use new svg found in brandfetch * :zap: Improvements * :recycle: Moved descriptions for email to it's own file * :zap: Added support for contact get * :zap: moved email descriptions to it's own file * :zap: Add logic to conditionally remove/format sms,email * :zap: Improvements * :zap: Refactor Sender descriptions to it's own file * :zap: Fix urls * :zap: Improvements attempt * :zap: Refactor remove inline descriptions * :zap: Minor improvement * :art: Learn a nice way to send options as key-value * :zap: Improvements * :recycle: Fix Create Operation structure * :recycle: Refactor create functionality for attribute :recycle: Introduce override for createAttribute selectedCategory :recycle: Add delete functionality * :fire: Remove preSend from delete * :zap: Implement override for body types * :zap: Cleanup node file * :zap: Update response for contact update :zap: Update request url for contact delete * :zap: Add presend check for optional properties that are empty :zap: Add Model file and TransactionalEmail interface * :zap: formatting * :recycle: Remove requestOperations from Node Description level * :recycle: Cleanup routing for Get All :recycle: Make Identifier required * :zap: Formatting * :recycle: Add Options Collection * :recycle: Add Filters area * :recycle: Formatting * :recycle: Handle empty return * :recycle: Remove unused code * :recycle: Fix pagination :recycle: Fix empty return for delete * :zap: Add pagination * :zap: Fix Modified Since * :recycle: Reorder send operation ui * :zap: Remove no longer needed presend :zap: Add send html template operation * :recycle: Make Contact Attribute name and type required * :recycle: Rename Attribute to Contact Attribute * :recycle: Rename Identifier to Contact Identifier * :recycle: Remove SMS from root level because it can exist in Contact Attributes * :recycle: Fix Array type using 'Array' :recycle: Fix double quotes should be single quotes * :tshirt: Lint Fix * :zap: Add email attachment functionality :zap: Add attachment data validation * :zap: Add dynamic loading of Email Template IDs * :recycle: Cleanup validation method * :zap: Introduce workaround and use binary data for attachments * feat: Migrated to npm release of riot-tmpl fork. * :tshirt: Lint fix rules * :shirt: Lint fix rules * fix: Updated imports to use @n8n_io/riot-tmpl * fix: Fixed Logger.ts types. * :zap: Fix mixmatch of filename and package.json credentials list * :zap: fix mixmatch in nodes list * feat(core): Give access to getBinaryDataBuffer in preSend method * :zap: clean up mixmatches in node naming * :recycle: Refactor code to use newly exposed getBinaryDataBuffer method * :zap: Improvements * :fire: Remove unnecessary lines * :shirt: Fix linting issues * :zap: Fix issues with up to date APIs and improve readability * :zap: update naming of files * :recycle: Move sendHtml boolean above subject :recycle: Update naming from Parameters to Fields * :recycle: Move sendHtml boolean above subject :recycle: Update naming from Parameters to Fields * :recycle: Add attribute name url encoding :recycle: Change limit's default to 50 * :zap: Fix default for templateId * :zap: Fix display name for attribute list * :recycle: Add clarity to attribute value display name * :recycle: Add tags and attachments for emails * :recycle: Add use of item's binary data fileName * :shirt: Fix action lint rule * :shirt: Remove deprecated lint rule * :arrow_up: Update eslint-plugin-n8n-nodes-base * :shirt: Fix lint rule for file name * :zap: Fix update attribute * :recycle: Add upsert capabilites * :fire: Remove create or update operation * :recycle: Add sendInBlueWebhookApi namespace * :recycle: Add Webhook API functionality * :zap: Add SendInBlue Trigger * :zap: Return correct webhookId data * :zap: Add placeholder for receiving data * :shirt: Fixing existing linting issues * :rotating_light: Enable namespacing in tslint file * :shirt: Fix linting issues * :zap: Rename exported WebhookApi * :fire: Remove unused Model.ts file * :recycle: Update node to use SendInBlue namespace * :zap: Revert back to allowing upsert functionality * :recycle: Fix options to better describe events * Remove update flag for create operation * :recycle: Fix discrepancies for contact resource * remove no-namespace lint rule * :shirt: Fix linting issues * :recycle: Add sendInBlueWebhookApi namespace * :recycle: Add Webhook API functionality * :zap: Add SendInBlue Trigger * :zap: Return correct webhookId data * :zap: Add placeholder for receiving data * :shirt: Fix linting issues * :zap: Rename exported WebhookApi * :recycle: Fix options to better describe events * Add optionswithuri import that was lost * :zap: Fix details from janober's review * :zap: Fix order of displayName and name properties * :zap: Fix default value and improve loadOptions * :zap: Introduce support for comma separated attribute values * :zap: Introduce support for comma separated attribute values * :shirt: Fix linting issues * Update defaults and required props * :zap: Fix copy paste issue Upsert was not using correct endpoint * :zap: Fix upsert email field display name * :zap: Last update, upsert email description * :zap: Add PostReceived type limit Co-authored-by: ricardo Co-authored-by: Alex Grozav Co-authored-by: Jan Oberhauser --- .../credentials/SendInBlueApi.credentials.ts | 34 + .../nodes/SendInBlue/AttributeDescription.ts | 532 +++++++++++++++ .../nodes/SendInBlue/ContactDescription.ts | 624 ++++++++++++++++++ .../nodes/SendInBlue/EmailDescription.ts | 451 +++++++++++++ .../nodes/SendInBlue/GenericFunctions.ts | 388 +++++++++++ .../nodes/SendInBlue/SendInBlue.node.json | 21 + .../nodes/SendInBlue/SendInBlue.node.ts | 69 ++ .../SendInBlue/SendInBlueTrigger.node.json | 22 + .../SendInBlue/SendInBlueTrigger.node.ts | 293 ++++++++ .../nodes/SendInBlue/SenderDescrition.ts | 195 ++++++ .../nodes/SendInBlue/sendinblue.svg | 20 + packages/nodes-base/package.json | 3 + packages/nodes-base/tslint.json | 33 +- packages/workflow/src/Interfaces.ts | 8 + packages/workflow/src/RoutingNode.ts | 11 + 15 files changed, 2691 insertions(+), 13 deletions(-) create mode 100644 packages/nodes-base/credentials/SendInBlueApi.credentials.ts create mode 100644 packages/nodes-base/nodes/SendInBlue/AttributeDescription.ts create mode 100644 packages/nodes-base/nodes/SendInBlue/ContactDescription.ts create mode 100644 packages/nodes-base/nodes/SendInBlue/EmailDescription.ts create mode 100644 packages/nodes-base/nodes/SendInBlue/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/SendInBlue/SendInBlue.node.json create mode 100644 packages/nodes-base/nodes/SendInBlue/SendInBlue.node.ts create mode 100644 packages/nodes-base/nodes/SendInBlue/SendInBlueTrigger.node.json create mode 100644 packages/nodes-base/nodes/SendInBlue/SendInBlueTrigger.node.ts create mode 100644 packages/nodes-base/nodes/SendInBlue/SenderDescrition.ts create mode 100644 packages/nodes-base/nodes/SendInBlue/sendinblue.svg diff --git a/packages/nodes-base/credentials/SendInBlueApi.credentials.ts b/packages/nodes-base/credentials/SendInBlueApi.credentials.ts new file mode 100644 index 0000000000..157a0df96f --- /dev/null +++ b/packages/nodes-base/credentials/SendInBlueApi.credentials.ts @@ -0,0 +1,34 @@ +import { + IAuthenticateGeneric, + ICredentialTestRequest, + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + +export class SendInBlueApi implements ICredentialType { + name = 'sendInBlueApi'; + displayName = 'SendInBlue'; + documentationUrl = 'sendInBlueApi'; + properties: INodeProperties[] = [ + { + displayName: 'API Key', + name: 'apiKey', + type: 'string', + default: '', + }, + ]; + authenticate: IAuthenticateGeneric = { + type: 'generic', + properties: { + headers: { + 'api-key': '={{$credentials.apiKey}}', + }, + }, + }; + test: ICredentialTestRequest = { + request: { + baseURL: 'https://api.sendinblue.com/v3', + url: '/account', + }, + }; +} diff --git a/packages/nodes-base/nodes/SendInBlue/AttributeDescription.ts b/packages/nodes-base/nodes/SendInBlue/AttributeDescription.ts new file mode 100644 index 0000000000..4e04131f20 --- /dev/null +++ b/packages/nodes-base/nodes/SendInBlue/AttributeDescription.ts @@ -0,0 +1,532 @@ +import { + IExecuteSingleFunctions, + IHttpRequestOptions, + INodeExecutionData, + INodeProperties, + JsonObject, +} from 'n8n-workflow'; +import { SendInBlueNode } from './GenericFunctions'; + +export const attributeOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['attribute'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + routing: { + request: { + method: 'POST', + url: '=/v3/contacts/attributes/{{$parameter.attributeCategory}}/{{encodeURI($parameter.attributeName)}}', + }, + output: { + postReceive: [ + { + type: 'set', + properties: { + value: '={{ { "success": true } }}', // Also possible to use the original response data + }, + }, + ], + }, + send: { + preSend: [ + async function ( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, + ): Promise { + const selectedCategory = this.getNodeParameter('attributeCategory') as string; + const override = SendInBlueNode.INTERCEPTORS.get(selectedCategory); + if (override) { + override.call(this, requestOptions.body! as JsonObject); + } + + return requestOptions; + }, + ], + }, + }, + action: 'Create an attribute', + }, + { + name: 'Update', + value: 'update', + routing: { + request: { + method: 'PUT', + url: '=/v3/contacts/attributes/{{$parameter.updateAttributeCategory}}/{{encodeURI($parameter.updateAttributeName)}}', + }, + }, + action: 'Update an attribute', + }, + { + name: 'Delete', + value: 'delete', + routing: { + request: { + method: 'DELETE', + url: '=/v3/contacts/attributes/{{$parameter.deleteAttributeCategory}}/{{encodeURI($parameter.deleteAttributeName)}}', + }, + output: { + postReceive: [ + { + type: 'set', + properties: { + value: '={{ { "success": true } }}', // Also possible to use the original response data + }, + }, + ], + }, + }, + action: 'Delete an attribute', + }, + { + name: 'Get All', + value: 'getAll', + routing: { + request: { + method: 'GET', + url: 'v3/contacts/attributes', + }, + send: { + paginate: false, + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'attributes', + }, + }, + ], + }, + }, + action: 'Get all attributes', + }, + ], + default: 'create', + }, +]; + +const createAttributeOperations: INodeProperties[] = [ + { + displayName: 'Category', + name: 'attributeCategory', + default: 'normal', + description: 'Category of the attribute', + displayOptions: { + show: { + resource: ['attribute'], + operation: ['create'], + }, + }, + options: [ + { + name: 'Calculated', + value: 'calculated', + }, + { + name: 'Category', + value: 'category', + }, + { + name: 'Global', + value: 'global', + }, + { + name: 'Normal', + value: 'normal', + }, + { + name: 'Transactional', + value: 'transactional', + }, + ], + type: 'options', + required: true, + }, + { + displayName: 'Name', + name: 'attributeName', + default: '', + description: 'Name of the attribute', + displayOptions: { + show: { + resource: ['attribute'], + operation: ['create'], + }, + }, + required: true, + type: 'string', + }, + { + displayName: 'Type', + name: 'attributeType', + default: '', + description: 'Attribute Type', + displayOptions: { + show: { + resource: ['attribute'], + operation: ['create'], + attributeCategory: ['normal'], + }, + }, + options: [ + { + name: 'Boolean', + value: 'boolean', + }, + { + name: 'Date', + value: 'date', + }, + { + name: 'Float', + value: 'float', + }, + { + name: 'Text', + value: 'text', + }, + ], + required: true, + type: 'options', + routing: { + send: { + type: 'body', + property: 'type', + value: '={{$value}}', + }, + }, + }, + { + displayName: 'Value', + name: 'attributeValue', + default: '', + description: 'Value of the attribute', + displayOptions: { + show: { + resource: ['attribute'], + operation: ['create'], + attributeCategory: ['global', 'calculated'], + }, + }, + type: 'string', + placeholder: '', + required: true, + routing: { + send: { + type: 'body', + property: 'value', + value: '={{$value}}', + }, + }, + }, + { + displayName: 'Contact Attribute List', + name: 'attributeCategoryList', + type: 'collection', + placeholder: 'Add Attributes', + default: {}, + displayOptions: { + show: { + resource: ['attribute'], + operation: ['create'], + attributeCategory: ['category'], + }, + }, + options: [ + { + displayName: 'Contact Attributes', + name: 'categoryEnumeration', + placeholder: 'Add Attribute', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + options: [ + { + name: 'attributesValues', + displayName: 'Attribute', + values: [ + { + displayName: 'Value ID', + name: 'attributeCategoryValue', + type: 'number', + default: 1, + description: 'ID of the value, must be numeric', + routing: { + send: { + value: '={{$value}}', + property: '=enumeration[{{$index}}].value', + type: 'body', + }, + }, + }, + { + displayName: 'Label', + name: 'attributeCategoryLabel', + type: 'string', + default: '', + routing: { + send: { + value: '={{$value}}', + property: '=enumeration[{{$index}}].label', + type: 'body', + }, + }, + description: 'Label of the value', + }, + ], + }, + ], + default: {}, + description: 'List of values and labels that the attribute can take', + }, + ], + }, +]; + +const updateAttributeOperations: INodeProperties[] = [ + { + displayName: 'Category', + name: 'updateAttributeCategory', + default: 'calculated', + description: 'Category of the attribute', + displayOptions: { + show: { + resource: ['attribute'], + operation: ['update'], + }, + }, + options: [ + { + name: 'Calculated', + value: 'calculated', + }, + { + name: 'Category', + value: 'category', + }, + { + name: 'Global', + value: 'global', + }, + ], + type: 'options', + }, + { + displayName: 'Name', + name: 'updateAttributeName', + default: '', + description: 'Name of the existing attribute', + displayOptions: { + show: { + resource: ['attribute'], + operation: ['update'], + }, + }, + type: 'string', + }, + { + displayName: 'Value', + name: 'updateAttributeValue', + default: '', + description: 'Value of the attribute to update', + displayOptions: { + show: { + resource: ['attribute'], + operation: ['update'], + }, + hide: { + updateAttributeCategory: ['category'], + }, + }, + type: 'string', + placeholder: '', + routing: { + send: { + type: 'body', + property: 'value', + value: '={{$value}}', + }, + }, + }, + { + displayName: 'Update Fields', + name: 'updateAttributeCategoryList', + default: {}, + description: 'List of the values and labels that the attribute can take', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + resource: ['attribute'], + operation: ['update'], + updateAttributeCategory: ['category'], + }, + }, + options: [ + { + displayName: 'Contact Attributes', + name: 'updateCategoryEnumeration', + placeholder: 'Add Attribute', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + options: [ + { + name: 'updateAttributesValues', + displayName: 'Attribute', + values: [ + { + displayName: 'Value', + name: 'attributeCategoryValue', + type: 'number', + default: 1, + description: 'ID of the value, must be numeric', + routing: { + send: { + value: '={{$value}}', + property: '=enumeration[{{$index}}].value', + type: 'body', + }, + }, + }, + { + displayName: 'Label', + name: 'attributeCategoryLabel', + type: 'string', + default: '', + routing: { + send: { + value: '={{$value}}', + property: '=enumeration[{{$index}}].label', + type: 'body', + }, + }, + description: 'Label of the value', + }, + ], + }, + ], + default: {}, + description: 'List of values and labels that the attribute can take', + }, + ], + }, +]; + +const deleteAttribueOperations: INodeProperties[] = [ + { + displayName: 'Category', + name: 'deleteAttributeCategory', + default: 'normal', + description: 'Category of the attribute', + displayOptions: { + show: { + resource: ['attribute'], + operation: ['delete'], + }, + }, + options: [ + { + name: 'Calculated', + value: 'calculated', + }, + { + name: 'Category', + value: 'category', + }, + { + name: 'Global', + value: 'global', + }, + { + name: 'Normal', + value: 'normal', + }, + { + name: 'Transactional', + value: 'transactional', + }, + ], + type: 'options', + }, + { + displayName: 'Name', + name: 'deleteAttributeName', + default: '', + description: 'Name of the attribute', + displayOptions: { + show: { + resource: ['attribute'], + operation: ['delete'], + }, + }, + type: 'string', + }, +]; + +const getAllAttributeOperations: INodeProperties[] = [ + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: ['attribute'], + operation: ['getAll'], + }, + }, + default: false, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: ['attribute'], + operation: ['getAll'], + returnAll: [false], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 1000, + }, + routing: { + output: { + postReceive: [ + { + type: 'limit', + properties: { + maxResults: '={{$value}}', + }, + }, + ], + }, + }, + default: 50, + description: 'Max number of results to return', + }, +]; + +export const attributeFields: INodeProperties[] = [ + ...createAttributeOperations, + ...updateAttributeOperations, + ...deleteAttribueOperations, + ...getAllAttributeOperations, +]; diff --git a/packages/nodes-base/nodes/SendInBlue/ContactDescription.ts b/packages/nodes-base/nodes/SendInBlue/ContactDescription.ts new file mode 100644 index 0000000000..f1c55010b0 --- /dev/null +++ b/packages/nodes-base/nodes/SendInBlue/ContactDescription.ts @@ -0,0 +1,624 @@ +import { + GenericValue, + IExecuteSingleFunctions, + IHttpRequestOptions, + INodeProperties, + JsonObject, +} from 'n8n-workflow'; + +export const contactOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['contact'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + action: 'Create a contact', + routing: { + request: { + method: 'POST', + url: '/v3/contacts', + }, + }, + }, + { + name: 'Create or Update', + value: 'upsert', + action: 'Upsert a contact', + routing: { + request: { + method: 'POST', + url: '=/v3/contacts', + }, + }, + }, + { + name: 'Delete', + value: 'delete', + action: 'Delete a contact', + }, + { + name: 'Get', + value: 'get', + action: 'Get a contact', + }, + { + name: 'Get All', + value: 'getAll', + routing: { + request: { + method: 'GET', + url: '/v3/contacts', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'contacts', + }, + }, + ], + }, + operations: { + pagination: { + type: 'offset', + properties: { + limitParameter: 'limit', + offsetParameter: 'offset', + pageSize: 1000, + type: 'query', + }, + }, + }, + }, + action: 'Get all contacts', + }, + { + name: 'Update', + value: 'update', + routing: { + output: { + postReceive: [ + { + type: 'set', + properties: { + value: '={{ { "success": true } }}', // Also possible to use the original response data + }, + }, + ], + }, + }, + action: 'Update a contact', + }, + ], + default: 'create', + }, +]; + +const createOperations: INodeProperties[] = [ + { + displayName: 'Email', + name: 'email', + type: 'string', + placeholder: 'name@email.com', + displayOptions: { + show: { + resource: ['contact'], + operation: ['create'], + }, + }, + default: '', + routing: { + send: { + type: 'body', + property: 'email', + }, + }, + }, + { + displayName: 'Contact Attributes', + name: 'createContactAttributes', + default: {}, + description: 'Array of attributes to be added', + displayOptions: { + show: { + resource: ['contact'], + operation: ['create'], + }, + }, + options: [ + { + name: 'attributesValues', + displayName: 'Attribute', + values: [ + { + displayName: 'Field Name', + name: 'fieldName', + type: 'options', + typeOptions: { + loadOptions: { + routing: { + request: { + method: 'GET', + url: '/v3/contacts/attributes', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'attributes', + }, + }, + { + type: 'setKeyValue', + properties: { + name: '={{$responseItem.name}} - ({{$responseItem.category}})', + value: '={{$responseItem.name}}', + }, + }, + { + type: 'sort', + properties: { + key: 'name', + }, + }, + ], + }, + }, + }, + }, + default: '', + }, + { + displayName: 'Field Value', + name: 'fieldValue', + type: 'string', + default: '', + routing: { + send: { + value: '={{$value}}', + property: '=attributes.{{$parent.fieldName}}', + type: 'body', + }, + }, + }, + ], + }, + ], + placeholder: 'Add Attribute', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + }, +]; + +const getAllOperations: INodeProperties[] = [ + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + routing: { + send: { + paginate: '={{$value}}', + }, + }, + displayOptions: { + show: { + resource: ['contact'], + operation: ['getAll'], + }, + }, + default: false, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: ['contact'], + operation: ['getAll'], + returnAll: [false], + }, + }, + routing: { + send: { + type: 'query', + property: 'limit', + }, + }, + typeOptions: { + minValue: 1, + maxValue: 1000, + }, + default: 50, + description: 'Max number of results to return', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + displayOptions: { + show: { + resource: ['contact'], + operation: ['getAll'], + }, + }, + default: {}, + options: [ + { + displayName: 'Sort', + name: 'sort', + type: 'options', + options: [ + { name: 'DESC', value: 'desc' }, + { name: 'ASC', value: 'asc' }, + ], + routing: { + send: { + type: 'query', + property: 'sort', + value: '={{$value}}', + }, + }, + default: 'desc', + description: 'Sort the results in the ascending/descending order of record creation', + }, + ], + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Filter', + displayOptions: { + show: { + resource: ['contact'], + operation: ['getAll'], + }, + }, + default: {}, + options: [ + { + displayName: 'Modified Since', + name: 'modifiedSince', + type: 'dateTime', + routing: { + send: { + type: 'query', + property: 'modifiedSince', + }, + }, + default: '', + description: + 'Filter (urlencoded) the contacts modified after a given UTC date-time (YYYY-MM-DDTHH:mm:ss.SSSZ)', + }, + ], + }, +]; + +const getOperations: INodeProperties[] = [ + { + displayName: 'Contact Identifier', + name: 'identifier', + type: 'string', + displayOptions: { + show: { + resource: ['contact'], + operation: ['get'], + }, + }, + routing: { + request: { + method: 'GET', + url: '=/v3/contacts/{{encodeURIComponent($value)}}', + }, + }, + required: true, + default: '', + description: 'Email (urlencoded) OR ID of the contact OR its SMS attribute value', + }, +]; + +const deleteOperations: INodeProperties[] = [ + { + displayName: 'Contact Identifier', + name: 'identifier', + type: 'string', + displayOptions: { + show: { + resource: ['contact'], + operation: ['delete'], + }, + }, + routing: { + request: { + method: 'DELETE', + url: '=/v3/contacts/{{encodeURIComponent($parameter.identifier)}}', + }, + output: { + postReceive: [ + { + type: 'set', + properties: { + value: '={{ { "success": true } }}', // Also possible to use the original response data + }, + }, + ], + }, + }, + default: '', + description: 'Email (urlencoded) OR ID of the contact OR its SMS attribute value', + }, +]; + +const updateOperations: INodeProperties[] = [ + { + displayName: 'Contact Identifier', + name: 'identifier', + default: '', + description: 'Email (urlencoded) OR ID of the contact OR its SMS attribute value', + displayOptions: { + show: { + resource: ['contact'], + operation: ['update'], + }, + }, + type: 'string', + required: true, + }, + { + displayName: 'Attributes', + name: 'updateAttributes', + default: {}, + description: 'Array of attributes to be updated', + displayOptions: { + show: { + resource: ['contact'], + operation: ['update'], + }, + }, + options: [ + { + displayName: 'Attribute', + name: 'updateAttributesValues', + values: [ + { + displayName: 'Field Name', + name: 'fieldName', + type: 'options', + typeOptions: { + loadOptions: { + routing: { + request: { + method: 'GET', + url: '/v3/contacts/attributes', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'attributes', + }, + }, + { + type: 'setKeyValue', + properties: { + name: '={{$responseItem.name}} - ({{$responseItem.category}})', + value: '={{$responseItem.name}}', + }, + }, + { + type: 'sort', + properties: { + key: 'name', + }, + }, + ], + }, + }, + }, + }, + default: '', + }, + { + displayName: 'Field Value', + name: 'fieldValue', + type: 'string', + default: '', + routing: { + send: { + value: '={{$value}}', + property: '=attributes.{{$parent.fieldName}}', + type: 'body', + }, + }, + }, + ], + }, + ], + placeholder: 'Add Attribute', + routing: { + request: { + method: 'PUT', + url: '=/v3/contacts/{{encodeURIComponent($parameter.identifier)}}', + }, + }, + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + }, +]; + +const upsertOperations: INodeProperties[] = [ + { + displayName: 'Email', + name: 'email', + default: '', + description: 'Email of the contact', + displayOptions: { + show: { + resource: ['contact'], + operation: ['upsert'], + }, + }, + type: 'string', + placeholder: 'name@email.com', + required: true, + routing: { + send: { + value: '={{$value}}', + property: 'email', + type: 'body', + }, + }, + }, + { + displayName: 'Contact Attributes', + name: 'upsertAttributes', + default: {}, + description: 'Array of attributes to be updated', + displayOptions: { + show: { + resource: ['contact'], + operation: ['upsert'], + }, + }, + options: [ + { + name: 'upsertAttributesValues', + displayName: 'Attribute', + values: [ + { + displayName: 'Field Name', + name: 'fieldName', + type: 'options', + typeOptions: { + loadOptions: { + routing: { + request: { + method: 'GET', + url: '/v3/contacts/attributes', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'attributes', + }, + }, + { + type: 'setKeyValue', + properties: { + name: '={{$responseItem.name}} - ({{$responseItem.category}})', + value: '={{$responseItem.name}}', + }, + }, + { + type: 'sort', + properties: { + key: 'name', + }, + }, + ], + }, + }, + }, + }, + default: '', + }, + { + displayName: 'Field Value', + name: 'fieldValue', + type: 'string', + default: '', + routing: { + send: { + value: '={{$value}}', + property: '=attributes.{{$parent.fieldName}}', + type: 'body', + }, + }, + }, + ], + }, + ], + placeholder: 'Add Attribute', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + routing: { + send: { + preSend: [ + async function ( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, + ): Promise { + const { body } = requestOptions as GenericValue as JsonObject; + Object.assign(body!, { updateEnabled: true }); + return requestOptions; + }, + ], + }, + output: { + postReceive: [ + { + type: 'set', + properties: { + value: '={{ { "success": true } }}', // Also possible to use the original response data + }, + }, + ], + }, + }, + }, +]; + +export const contactFields: INodeProperties[] = [ + /* -------------------------------------------------------------------------- */ + /* contact:create */ + /* -------------------------------------------------------------------------- */ + ...createOperations, + + /* -------------------------------------------------------------------------- */ + /* contact:getAll */ + /* -------------------------------------------------------------------------- */ + ...getAllOperations, + + /* -------------------------------------------------------------------------- */ + /* contact:get */ + /* -------------------------------------------------------------------------- */ + ...getOperations, + + /* -------------------------------------------------------------------------- */ + /* contact:delete */ + /* -------------------------------------------------------------------------- */ + ...deleteOperations, + + /* -------------------------------------------------------------------------- */ + /* contact:update */ + /* -------------------------------------------------------------------------- */ + ...updateOperations, + + /* -------------------------------------------------------------------------- */ + /* contact:update */ + /* -------------------------------------------------------------------------- */ + ...upsertOperations, +]; diff --git a/packages/nodes-base/nodes/SendInBlue/EmailDescription.ts b/packages/nodes-base/nodes/SendInBlue/EmailDescription.ts new file mode 100644 index 0000000000..453d8cbd5f --- /dev/null +++ b/packages/nodes-base/nodes/SendInBlue/EmailDescription.ts @@ -0,0 +1,451 @@ +import { IExecuteSingleFunctions, IHttpRequestOptions, INodeProperties } from 'n8n-workflow'; +import { SendInBlueNode } from './GenericFunctions'; + +export const emailOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['email'], + }, + }, + options: [ + { + name: 'Send', + value: 'send', + action: 'Send a transactional email', + }, + { + name: 'Send Template', + value: 'sendTemplate', + action: 'Send an email with an existing Template', + }, + ], + routing: { + request: { + method: 'POST', + url: '/v3/smtp/email', + }, + }, + default: 'send', + }, +]; + +const sendHtmlEmailFields: INodeProperties[] = [ + { + displayName: 'Send HTML', + name: 'sendHTML', + type: 'boolean', + displayOptions: { + show: { + resource: ['email'], + operation: ['send'], + }, + }, + default: false, + }, + { + displayName: 'Subject', + name: 'subject', + type: 'string', + displayOptions: { + show: { + resource: ['email'], + operation: ['send'], + }, + }, + routing: { + send: { + property: 'subject', + type: 'body', + }, + }, + default: '', + description: 'Subject of the email', + }, + { + displayName: 'Text Content', + name: 'textContent', + type: 'string', + displayOptions: { + show: { + resource: ['email'], + operation: ['send'], + sendHTML: [false], + }, + }, + routing: { + send: { + property: 'textContent', + type: 'body', + }, + }, + default: '', + description: 'Text content of the message', + }, + { + displayName: 'HTML Content', + name: 'htmlContent', + type: 'string', + displayOptions: { + show: { + resource: ['email'], + operation: ['send'], + sendHTML: [true], + }, + }, + routing: { + send: { + property: 'htmlContent', + type: 'body', + }, + }, + default: '', + description: 'HTML content of the message', + }, + { + displayName: 'Sender', + name: 'sender', + type: 'string', + displayOptions: { + show: { + resource: ['email'], + operation: ['send'], + }, + }, + default: '', + required: true, + routing: { + send: { + preSend: [SendInBlueNode.Validators.validateAndCompileSenderEmail], + }, + }, + }, + { + displayName: 'Receipients', + name: 'receipients', + type: 'string', + displayOptions: { + show: { + resource: ['email'], + operation: ['send'], + }, + }, + default: '', + required: true, + routing: { + send: { + preSend: [SendInBlueNode.Validators.validateAndCompileReceipientEmails], + }, + }, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + placeholder: 'Add Field', + description: 'Additional fields to add', + type: 'collection', + default: {}, + displayOptions: { + show: { + resource: ['email'], + operation: ['send'], + }, + }, + options: [ + { + displayName: 'Attachments', + name: 'emailAttachments', + placeholder: 'Add Attachment', + type: 'fixedCollection', + default: {}, + options: [ + { + name: 'attachment', + displayName: 'Attachment Data', + values: [ + { + default: '', + displayName: 'Input Data Field Name', + name: 'binaryPropertyName', + type: 'string', + description: + 'The name of the incoming field containing the binary file data to be processed', + }, + ], + }, + ], + routing: { + send: { + preSend: [SendInBlueNode.Validators.validateAndCompileAttachmentsData], + }, + }, + }, + { + displayName: 'Receipients BCC', + name: 'receipientsBCC', + placeholder: 'Add BCC', + type: 'fixedCollection', + default: {}, + options: [ + { + name: 'receipientBcc', + displayName: 'Receipient', + values: [ + { + name: 'bcc', + displayName: 'Receipient', + type: 'string', + default: '', + }, + ], + }, + ], + routing: { + send: { + preSend: [SendInBlueNode.Validators.validateAndCompileBCCEmails], + }, + }, + }, + { + displayName: 'Receipients CC', + name: 'receipientsCC', + placeholder: 'Add CC', + type: 'fixedCollection', + default: {}, + options: [ + { + name: 'receipientCc', + displayName: 'Receipient', + values: [ + { + name: 'cc', + displayName: 'Receipient', + type: 'string', + default: '', + }, + ], + }, + ], + routing: { + send: { + preSend: [SendInBlueNode.Validators.validateAndCompileCCEmails], + }, + }, + }, + { + displayName: 'Email Tags', + name: 'emailTags', + default: {}, + description: 'Add tags to your emails to find them more easily', + placeholder: 'Add Email Tags', + type: 'fixedCollection', + options: [ + { + displayName: 'Tags', + name: 'tags', + values: [ + { + default: '', + displayName: 'Tag', + name: 'tag', + type: 'string', + }, + ], + }, + ], + routing: { + send: { + preSend: [SendInBlueNode.Validators.validateAndCompileTags], + }, + }, + }, + ], + }, +]; + +const sendHtmlTemplateEmailFields: INodeProperties[] = [ + { + displayName: 'Template ID', + name: 'templateId', + type: 'options', + default: '', + typeOptions: { + loadOptions: { + routing: { + request: { + method: 'GET', + url: '/v3/smtp/templates', + qs: { + templateStatus: true, + limit: 1000, + offset: 0, + sort: 'desc', + }, + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'templates', + }, + }, + { + type: 'setKeyValue', + properties: { + name: '={{$responseItem.name}}', + value: '={{$responseItem.id}}', + }, + }, + { + type: 'sort', + properties: { + key: 'name', + }, + }, + ], + }, + }, + }, + }, + displayOptions: { + show: { + resource: ['email'], + operation: ['sendTemplate'], + }, + }, + routing: { + send: { + type: 'body', + property: 'templateId', + }, + }, + }, + { + displayName: 'Receipients', + name: 'receipients', + type: 'string', + displayOptions: { + show: { + resource: ['email'], + operation: ['sendTemplate'], + }, + }, + default: '', + required: true, + routing: { + send: { + preSend: [SendInBlueNode.Validators.validateAndCompileReceipientEmails], + }, + }, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + description: 'Additional fields to add', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['email'], + operation: ['sendTemplate'], + }, + }, + options: [ + { + displayName: 'Attachments', + name: 'emailAttachments', + placeholder: 'Add Attachment', + type: 'fixedCollection', + default: {}, + options: [ + { + displayName: 'Attachment Data', + name: 'attachment', + values: [ + { + displayName: 'Input Data Field Name', + name: 'binaryPropertyName', + default: '', + type: 'string', + description: + 'The name of the incoming field containing the binary file data to be processed', + }, + ], + }, + ], + routing: { + send: { + preSend: [SendInBlueNode.Validators.validateAndCompileAttachmentsData], + }, + }, + }, + { + displayName: 'Email Tags', + name: 'emailTags', + default: {}, + description: 'Add tags to your emails to find them more easily', + placeholder: 'Add Email Tags', + type: 'fixedCollection', + options: [ + { + displayName: 'Tags', + name: 'tags', + values: [ + { + default: '', + displayName: 'Tag', + name: 'tag', + type: 'string', + }, + ], + }, + ], + routing: { + send: { + preSend: [SendInBlueNode.Validators.validateAndCompileTags], + }, + }, + }, + { + displayName: 'Template Parameters', + name: 'templateParameters', + default: {}, + description: 'Pass a set of attributes to customize the template', + placeholder: 'Add Parameter', + type: 'fixedCollection', + options: [ + { + name: 'parameterValues', + displayName: 'Parameters', + values: [ + { + displayName: 'Parameter', + name: 'parmeters', + type: 'string', + default: '', + placeholder: 'key=value', + description: 'Comma-separated key=value pairs', + }, + ], + }, + ], + routing: { + send: { + preSend: [SendInBlueNode.Validators.validateAndCompileTemplateParameters], + }, + }, + }, + ], + }, +]; + +export const emailFields: INodeProperties[] = [ + ...sendHtmlEmailFields, + ...sendHtmlTemplateEmailFields, +]; diff --git a/packages/nodes-base/nodes/SendInBlue/GenericFunctions.ts b/packages/nodes-base/nodes/SendInBlue/GenericFunctions.ts new file mode 100644 index 0000000000..9d40c5bf99 --- /dev/null +++ b/packages/nodes-base/nodes/SendInBlue/GenericFunctions.ts @@ -0,0 +1,388 @@ +import { + IExecuteSingleFunctions, + IHookFunctions, + IHttpRequestOptions, + IWebhookFunctions, + JsonObject, + NodeOperationError, +} from 'n8n-workflow'; +import { OptionsWithUri } from 'request'; +import MailComposer from 'nodemailer/lib/mail-composer'; +export namespace SendInBlueNode { + type ValidEmailFields = { to: string } | { sender: string } | { cc: string } | { bcc: string }; + type Address = { address: string; name?: string }; + type Email = { email: string; name?: string }; + type ToEmail = { to: Email[] }; + type SenderEmail = { sender: Email }; + type CCEmail = { cc: Email[] }; + type BBCEmail = { bbc: Email[] }; + type ValidatedEmail = ToEmail | SenderEmail | CCEmail | BBCEmail; + + enum OVERRIDE_MAP_VALUES { + 'CATEGORY' = 'category', + 'NORMAL' = 'boolean', + 'TRANSACTIONAL' = 'id', + } + + enum OVERRIDE_MAP_TYPE { + 'CATEGORY' = 'category', + 'NORMAL' = 'normal', + 'TRANSACTIONAL' = 'transactional', + } + + export const INTERCEPTORS = new Map void>([ + [ + OVERRIDE_MAP_TYPE.CATEGORY, + (body: JsonObject) => { + body!.type = OVERRIDE_MAP_VALUES.CATEGORY; + }, + ], + [ + OVERRIDE_MAP_TYPE.NORMAL, + (body: JsonObject) => { + body!.type = OVERRIDE_MAP_VALUES.NORMAL; + }, + ], + [ + OVERRIDE_MAP_TYPE.TRANSACTIONAL, + (body: JsonObject) => { + body!.type = OVERRIDE_MAP_VALUES.TRANSACTIONAL; + }, + ], + ]); + export namespace Validators { + export async function validateAndCompileAttachmentsData( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, + ): Promise { + const dataPropertyList = this.getNodeParameter( + 'additionalFields.emailAttachments.attachment', + ) as JsonObject; + const { body } = requestOptions; + + const { attachment = [] } = body as { attachment: Array<{ content: string; name: string }> }; + + try { + const { binaryPropertyName } = dataPropertyList; + const dataMappingList = (binaryPropertyName as string).split(','); + for (const attachmentDataName of dataMappingList) { + const binaryPropertyName = attachmentDataName; + + const item = this.getInputData(); + + if (item.binary![binaryPropertyName as string] === undefined) { + throw new NodeOperationError( + this.getNode(), + `No binary data property “${binaryPropertyName}” exists on item!`, + ); + } + + const bufferFromIncomingData = (await this.helpers.getBinaryDataBuffer( + binaryPropertyName, + )) as Buffer; + + const { + data: content, + mimeType, + fileName, + fileExtension, + } = await this.helpers.prepareBinaryData(bufferFromIncomingData); + + const itemIndex = this.getItemIndex(); + const name = getFileName( + itemIndex, + mimeType, + fileExtension, + fileName || item.binary!.data.fileName, + ); + + attachment.push({ content, name }); + } + + Object.assign(body!, { attachment }); + + return requestOptions; + } catch (err) { + throw new NodeOperationError(this.getNode(), `${err}`); + } + } + + export async function validateAndCompileTags( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, + ): Promise { + const { tag } = this.getNodeParameter('additionalFields.emailTags.tags') as JsonObject; + const tags = (tag as string) + .split(',') + .map((tag) => tag.trim()) + .filter((tag) => { + return tag !== ''; + }); + const { body } = requestOptions; + Object.assign(body!, { tags }); + return requestOptions; + } + + export async function validateAndCompileCCEmails( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, + ): Promise { + const ccData = this.getNodeParameter( + 'additionalFields.receipientsCC.receipientCc', + ) as JsonObject; + const { cc } = ccData; + const { body } = requestOptions; + const data = validateEmailStrings({ cc: cc as string }); + Object.assign(body!, data); + + return requestOptions; + } + + export async function validateAndCompileBCCEmails( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, + ): Promise { + const bccData = this.getNodeParameter( + 'additionalFields.receipientsBCC.receipientBcc', + ) as JsonObject; + const { bcc } = bccData; + const { body } = requestOptions; + const data = validateEmailStrings({ bcc: bcc as string }); + Object.assign(body!, data); + + return requestOptions; + } + + export async function validateAndCompileReceipientEmails( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, + ): Promise { + const to = this.getNodeParameter('receipients') as string; + const { body } = requestOptions; + const data = validateEmailStrings({ to }); + Object.assign(body!, data); + + return requestOptions; + } + + export async function validateAndCompileSenderEmail( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, + ): Promise { + const sender = this.getNodeParameter('sender') as string; + const { body } = requestOptions; + const data = validateEmailStrings({ sender }); + Object.assign(body!, data); + + return requestOptions; + } + + export async function validateAndCompileTemplateParameters( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, + ): Promise { + const parameterData = this.getNodeParameter( + 'additionalFields.templateParameters.parameterValues', + ); + const { body } = requestOptions; + const { parmeters } = parameterData as JsonObject; + const params = (parmeters as string) + .split(',') + .filter((parameter) => { + return parameter.split('=').length === 2; + }) + .map((parameter) => { + const [key, value] = parameter.split('='); + return { + [key]: value, + }; + }) + .reduce((obj, cObj) => { + Object.assign(obj, cObj); + return obj; + }, {}); + + Object.assign(body!, { params }); + return requestOptions; + } + + function validateEmailStrings(input: ValidEmailFields): ValidatedEmail { + const composer = new MailComposer({ ...input }); + const addressFields = composer.compile().getAddresses(); + + const fieldFetcher = new Map Email[] | Email>([ + [ + 'bcc', + () => { + return (addressFields.bcc as unknown as Address[])?.map(formatToEmailName); + }, + ], + [ + 'cc', + () => { + return (addressFields.cc as unknown as Address[])?.map(formatToEmailName); + }, + ], + [ + 'from', + () => { + return (addressFields.from as unknown as Address[])?.map(formatToEmailName); + }, + ], + [ + 'reply-to', + () => { + return (addressFields['reply-to'] as unknown as Address[])?.map(formatToEmailName); + }, + ], + [ + 'sender', + () => { + return (addressFields.sender as unknown as Address[])?.map(formatToEmailName)[0]; + }, + ], + [ + 'to', + () => { + return (addressFields.to as unknown as Address[])?.map(formatToEmailName); + }, + ], + ]); + + const result: { [key in keyof ValidatedEmail]: Email[] | Email } = {} as ValidatedEmail; + Object.keys(input).reduce((obj: { [key: string]: Email[] | Email }, key: string) => { + const getter = fieldFetcher.get(key); + const value = getter!(); + obj[key] = value; + return obj; + }, result); + + return result as ValidatedEmail; + } + } + + function getFileName( + itemIndex: number, + mimeType: string, + fileExt: string, + fileName: string, + ): string { + let ext = fileExt; + if (fileExt === undefined) { + ext = mimeType.split('/')[1]; + } + + let name = `${fileName}.${ext}`; + if (fileName === undefined) { + name = `file-${itemIndex}.${ext}`; + } + return name; + } + + function formatToEmailName(data: Address): Email { + const { address: email, name } = data; + const result = { email }; + if (name !== undefined && name !== '') { + Object.assign(result, { name }); + } + return { ...result }; + } +} + +export namespace SendInBlueWebhookApi { + interface WebhookDetails { + url: string; + id: number; + description: string; + events: string[]; + type: string; + createdAt: string; + modifiedAt: string; + } + + interface WebhookId { + id: string; + } + + interface Webhooks { + webhooks: WebhookDetails[]; + } + + const credentialsName = 'sendinblueApi'; + const baseURL = 'https://api.sendinblue.com/v3'; + export const supportedAuthMap = new Map Promise>([ + [ + 'apiKey', + async (ref: IWebhookFunctions): Promise => { + const credentials = await ref.getCredentials(credentialsName); + return credentials.sharedSecret as string; + }, + ], + ]); + + export const fetchWebhooks = async (ref: IHookFunctions, type: string): Promise => { + const endpoint = `${baseURL}/webhooks?type=${type}`; + + const options: OptionsWithUri = { + method: 'GET', + headers: { + Accept: 'application/json', + }, + uri: endpoint, + }; + + const webhooks = (await ref.helpers.requestWithAuthentication.call( + ref, + credentialsName, + options, + )) as string; + + return JSON.parse(webhooks) as Webhooks; + }; + + export const createWebHook = async ( + ref: IHookFunctions, + type: string, + events: string[], + url: string, + ): Promise => { + const endpoint = `${baseURL}/webhooks`; + + const options: OptionsWithUri = { + method: 'POST', + headers: { + Accept: 'application/json', + }, + uri: endpoint, + body: { + events, + type, + url, + }, + }; + + const webhookId = await ref.helpers.requestWithAuthentication.call( + ref, + credentialsName, + options, + ); + + return JSON.parse(webhookId) as WebhookId; + }; + + export const deleteWebhook = async (ref: IHookFunctions, webhookId: string) => { + const endpoint = `${baseURL}/webhooks/${webhookId}`; + const body = {}; + + const options: OptionsWithUri = { + method: 'DELETE', + headers: { + Accept: 'application/json', + }, + uri: endpoint, + body, + }; + + return await ref.helpers.requestWithAuthentication.call(ref, credentialsName, options); + }; +} diff --git a/packages/nodes-base/nodes/SendInBlue/SendInBlue.node.json b/packages/nodes-base/nodes/SendInBlue/SendInBlue.node.json new file mode 100644 index 0000000000..b63fbbbb85 --- /dev/null +++ b/packages/nodes-base/nodes/SendInBlue/SendInBlue.node.json @@ -0,0 +1,21 @@ +{ + "node": "n8n-nodes-base.sendInBlue", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": [ + "Marketing & Content", + "Communication" + ], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/sendInBlue" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.sendInBlue/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/SendInBlue/SendInBlue.node.ts b/packages/nodes-base/nodes/SendInBlue/SendInBlue.node.ts new file mode 100644 index 0000000000..30079c7e49 --- /dev/null +++ b/packages/nodes-base/nodes/SendInBlue/SendInBlue.node.ts @@ -0,0 +1,69 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +import { INodeType, INodeTypeDescription } from 'n8n-workflow'; +import { attributeFields, attributeOperations } from './AttributeDescription'; +import { contactFields, contactOperations } from './ContactDescription'; +import { emailFields, emailOperations } from './EmailDescription'; +import { senderFields, senderOperations } from './SenderDescrition'; + +export class SendInBlue implements INodeType { + description: INodeTypeDescription = { + displayName: 'SendInBlue', + name: 'sendInBlue', + icon: 'file:sendinblue.svg', + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume Sendinblue API', + defaults: { + name: 'SendInBlue', + color: '#044a75', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'sendInBlueApi', + required: true, + }, + ], + requestDefaults: { + baseURL: 'https://api.sendinblue.com', + }, + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Contact', + value: 'contact', + }, + { + name: 'Contact Attribute', + value: 'attribute', + }, + { + name: 'Email', + value: 'email', + }, + { + name: 'Sender', + value: 'sender', + }, + ], + default: 'email', + }, + + ...attributeOperations, + ...attributeFields, + ...senderOperations, + ...senderFields, + ...contactOperations, + ...contactFields, + ...emailOperations, + ...emailFields, + ], + }; +} diff --git a/packages/nodes-base/nodes/SendInBlue/SendInBlueTrigger.node.json b/packages/nodes-base/nodes/SendInBlue/SendInBlueTrigger.node.json new file mode 100644 index 0000000000..c3636e7870 --- /dev/null +++ b/packages/nodes-base/nodes/SendInBlue/SendInBlueTrigger.node.json @@ -0,0 +1,22 @@ +{ + "node": "n8n-nodes-base.sendInBlueTrigger", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": [ + "Marketing & Content", + "Communication" + ], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/sendInBlue" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.sendInBlueTrigger/" + } + ], + "generic": [] + } +} diff --git a/packages/nodes-base/nodes/SendInBlue/SendInBlueTrigger.node.ts b/packages/nodes-base/nodes/SendInBlue/SendInBlueTrigger.node.ts new file mode 100644 index 0000000000..83cb84d32f --- /dev/null +++ b/packages/nodes-base/nodes/SendInBlue/SendInBlueTrigger.node.ts @@ -0,0 +1,293 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +import { + IDataObject, + IHookFunctions, + INodeType, + INodeTypeDescription, + IWebhookFunctions, + IWebhookResponseData, + NodeOperationError, +} from 'n8n-workflow'; +import { SendInBlueWebhookApi } from './GenericFunctions'; + +export class SendInBlueTrigger implements INodeType { + description: INodeTypeDescription = { + credentials: [ + { + name: 'sendInBlueApi', + required: true, + }, + ], + displayName: 'SendInBlue Trigger', + defaults: { + name: 'SendInBlue-Trigger', + color: '#044a75', + }, + description: 'Starts the workflow when SendInBlue events occur', + group: ['trigger'], + icon: 'file:sendinblue.svg', + inputs: [], + name: 'sendInBlueTrigger', + outputs: ['main'], + version: 1, + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhooks', + }, + ], + properties: [ + { + default: 'transactional', + displayName: 'Resource', + name: 'type', + options: [ + { name: 'Inbound', value: 'inbound' }, + { name: 'Marketing', value: 'marketing' }, + { name: 'Transactional', value: 'transactional' }, + ], + required: true, + type: 'options', + }, + { + displayName: 'Trigger On', + displayOptions: { + show: { + type: ['transactional'], + }, + }, + name: 'events', + placeholder: 'Add Event', + options: [ + { + name: 'Email Blocked', + value: 'blocked', + description: 'Triggers when transactional email is blocked', + }, + { + name: 'Email Clicked', + value: 'click', + description: 'Triggers when transactional email is clicked', + }, + { + name: 'Email Deferred', + value: 'deferred', + description: 'Triggers when transactional email is deferred', + }, + { + name: 'Email Delivered', + value: 'delivered', + description: 'Triggers when transactional email is delivered', + }, + { + name: 'Email Hard Bounce', + value: 'hardBounce', + description: 'Triggers when transactional email is hard bounced', + }, + { + name: 'Email Invalid', + value: 'invalid', + description: 'Triggers when transactional email is invalid', + }, + { + name: 'Email Marked Spam', + value: 'spam', + description: 'Triggers when transactional email is set to spam', + }, + { + name: 'Email Opened', + value: 'opened', + description: 'Triggers when transactional email is opened', + }, + { + name: 'Email Sent', + value: 'request', + description: 'Triggers when transactional email is sent', + }, + { + name: 'Email Soft-Bounce', + value: 'softBounce', + description: 'Triggers when transactional email is soft bounced', + }, + { + name: 'Email Unique Open', + value: 'uniqueOpened', + description: 'Triggers when transactional email is unique opened', + }, + { + name: 'Email Unsubscribed', + value: 'unsubscribed', + description: 'Triggers when transactional email is unsubscribed', + }, + ], + default: [], + required: true, + type: 'multiOptions', + }, + { + displayName: 'Trigger On', + displayOptions: { + show: { + type: ['marketing'], + }, + }, + name: 'events', + placeholder: 'Add Event', + options: [ + { + name: 'Marketing Email Clicked', + value: 'click', + description: 'Triggers when marketing email is clicked', + }, + { + name: 'Marketing Email Delivered', + value: 'delivered', + description: 'Triggers when marketing email is delivered', + }, + { + name: 'Marketing Email Hard Bounce', + value: 'hardBounce', + description: 'Triggers when marketing email is hard bounced', + }, + { + name: 'Marketing Email List Addition', + value: 'listAddition', + description: 'Triggers when marketing email is clicked', + }, + { + name: 'Marketing Email Opened', + value: 'opened', + description: 'Triggers when marketing email is opened', + }, + { + name: 'Marketing Email Soft Bounce', + value: 'softBounce', + description: 'Triggers when marketing email is soft bounced', + }, + { + name: 'Marketing Email Spam', + value: 'spam', + description: 'Triggers when marketing email is spam', + }, + { + name: 'Marketing Email Unsubscribed', + value: 'unsubscribed', + description: 'Triggers when marketing email is unsubscribed', + }, + ], + default: [], + required: true, + type: 'multiOptions', + }, + { + displayName: 'Trigger On', + displayOptions: { + show: { + type: ['inbound'], + }, + }, + name: 'events', + placeholder: 'Add Event', + options: [ + { + name: 'Inbound Email Processed', + value: 'inboundEmailProcessed', + description: 'Triggers when inbound email is processed', + }, + ], + default: [], + required: true, + type: 'multiOptions', + }, + ], + }; + + // @ts-ignore (because of request) + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + + const webhookUrl = this.getNodeWebhookUrl('default') as string; + + const type = this.getNodeParameter('type') as string; + + const events = this.getNodeParameter('events') as string[]; + + try { + const { webhooks } = await SendInBlueWebhookApi.fetchWebhooks(this, type); + + for (const webhook of webhooks) { + if ( + webhook.type === type && + webhook.events.every((event) => events.includes(event)) && + webhookUrl === webhook.url + ) { + webhookData.webhookId = webhook.id; + return true; + } + } + // If it did not error then the webhook exists + return false; + } catch (err) { + return false; + } + }, + async create(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + + const webhookUrl = this.getNodeWebhookUrl('default') as string; + + const type = this.getNodeParameter('type') as string; + + const events = this.getNodeParameter('events') as string[]; + + const responseData = await SendInBlueWebhookApi.createWebHook( + this, + type, + events, + webhookUrl, + ); + + if (responseData === undefined || responseData.id === undefined) { + // Required data is missing so was not successful + return false; + } + + webhookData.webhookId = responseData.id; + + return true; + }, + async delete(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + + if (webhookData.webhookId !== undefined) { + try { + await SendInBlueWebhookApi.deleteWebhook(this, webhookData.webhookId as string); + } catch (error) { + return false; + } + + // Remove from the static workflow data so that it is clear + // that no webhooks are registred anymore + delete webhookData.webhookId; + delete webhookData.webhookEvents; + delete webhookData.hookSecret; + } + + return true; + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + // The data to return and so start the workflow with + const bodyData = this.getBodyData() as IDataObject; + + return { + workflowData: [this.helpers.returnJsonArray(bodyData)], + }; + } +} diff --git a/packages/nodes-base/nodes/SendInBlue/SenderDescrition.ts b/packages/nodes-base/nodes/SendInBlue/SenderDescrition.ts new file mode 100644 index 0000000000..ca0069d9a8 --- /dev/null +++ b/packages/nodes-base/nodes/SendInBlue/SenderDescrition.ts @@ -0,0 +1,195 @@ +import { IExecuteSingleFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow'; + +export const senderOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['sender'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + action: 'Create a sender', + }, + { + name: 'Delete', + value: 'delete', + routing: { + request: { + method: 'DELETE', + url: '=/v3/senders/{{$parameter.id}}', + }, + output: { + postReceive: [ + { + type: 'set', + properties: { + value: '={{ { "success": true } }}', + }, + }, + ], + }, + }, + action: 'Delete a sender', + }, + { + name: 'Get All', + value: 'getAll', + routing: { + request: { + method: 'GET', + url: '/v3/senders', + }, + send: { + paginate: false, + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'senders', + }, + }, + ], + }, + }, + action: 'Get all senders', + }, + ], + default: 'create', + }, +]; + +const senderCreateOperation: INodeProperties[] = [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + displayOptions: { + show: { + resource: ['sender'], + operation: ['create'], + }, + }, + routing: { + request: { + method: 'POST', + url: '/v3/senders', + }, + send: { + property: 'name', + type: 'body', + }, + }, + required: true, + description: 'Name of the sender', + }, + { + displayName: 'Email', + name: 'email', + type: 'string', + placeholder: 'name@email.com', + default: '', + displayOptions: { + show: { + resource: ['sender'], + operation: ['create'], + }, + }, + routing: { + send: { + property: 'email', + type: 'body', + }, + }, + required: true, + description: 'Email of the sender', + }, +]; + +const senderDeleteOperation: INodeProperties[] = [ + { + displayName: 'Sender ID', + name: 'id', + type: 'string', + default: '', + displayOptions: { + show: { + resource: ['sender'], + operation: ['delete'], + }, + }, + description: 'ID of the sender to delete', + }, +]; + +const senderGetAllOperation: INodeProperties[] = [ + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: ['sender'], + operation: ['getAll'], + }, + }, + default: false, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: ['sender'], + operation: ['getAll'], + returnAll: [false], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 1000, + }, + routing: { + output: { + postReceive: [ + { + type: 'limit', + properties: { + maxResults: '={{$value}}', + }, + }, + ], + }, + }, + default: 10, + description: 'Max number of results to return', + }, +]; + +export const senderFields: INodeProperties[] = [ + /* -------------------------------------------------------------------------- */ + /* sender:create */ + /* -------------------------------------------------------------------------- */ + ...senderCreateOperation, + + /* -------------------------------------------------------------------------- */ + /* sender:delete */ + /* -------------------------------------------------------------------------- */ + ...senderDeleteOperation, + + /* -------------------------------------------------------------------------- */ + /* sender:getAll */ + /* -------------------------------------------------------------------------- */ + ...senderGetAllOperation, +]; diff --git a/packages/nodes-base/nodes/SendInBlue/sendinblue.svg b/packages/nodes-base/nodes/SendInBlue/sendinblue.svg new file mode 100644 index 0000000000..7525b9036e --- /dev/null +++ b/packages/nodes-base/nodes/SendInBlue/sendinblue.svg @@ -0,0 +1,20 @@ + + + + + + + \ No newline at end of file diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index b84a21f9c3..0b6f6b3702 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -255,6 +255,7 @@ "dist/credentials/SecurityScorecardApi.credentials.js", "dist/credentials/SegmentApi.credentials.js", "dist/credentials/SendGridApi.credentials.js", + "dist/credentials/SendInBlueApi.credentials.js", "dist/credentials/SendyApi.credentials.js", "dist/credentials/SentryIoApi.credentials.js", "dist/credentials/SentryIoOAuth2Api.credentials.js", @@ -556,6 +557,8 @@ "dist/nodes/Netlify/NetlifyTrigger.node.js", "dist/nodes/NextCloud/NextCloud.node.js", "dist/nodes/NocoDB/NocoDB.node.js", + "dist/nodes/SendInBlue/SendInBlue.node.js", + "dist/nodes/SendInBlue/SendInBlueTrigger.node.js", "dist/nodes/StickyNote/StickyNote.node.js", "dist/nodes/NoOp/NoOp.node.js", "dist/nodes/Onfleet/Onfleet.node.js", diff --git a/packages/nodes-base/tslint.json b/packages/nodes-base/tslint.json index a255e6b0fd..157f638e6f 100644 --- a/packages/nodes-base/tslint.json +++ b/packages/nodes-base/tslint.json @@ -46,7 +46,11 @@ "forin": true, "jsdoc-format": true, "label-position": true, - "indent": [true, "tabs", 2], + "indent": [ + true, + "tabs", + 2 + ], "member-access": [ true, "no-public" @@ -61,12 +65,15 @@ "no-default-export": true, "no-duplicate-variable": true, "no-inferrable-types": true, - "ordered-imports": [true, { - "import-sources-order": "any", - "named-imports-order": "case-insensitive" - }], - "no-namespace": [ + "ordered-imports": [ true, + { + "import-sources-order": "any", + "named-imports-order": "case-insensitive" + } + ], + "no-namespace": [ + false, "allow-declarations" ], "no-reference": true, @@ -90,13 +97,13 @@ "trailing-comma": [ true, { - "multiline": { - "objects": "always", - "arrays": "always", - "functions": "always", - "typeLiterals": "ignore" - }, - "esSpecCompliant": true + "multiline": { + "objects": "always", + "arrays": "always", + "functions": "always", + "typeLiterals": "ignore" + }, + "esSpecCompliant": true } ], "triple-equals": [ diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 05a306f7c7..af84d154c5 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -1111,6 +1111,7 @@ export type PostReceiveAction = response: IN8nHttpFullResponse, ) => Promise) | IPostReceiveBinaryData + | IPostReceiveLimit | IPostReceiveRootProperty | IPostReceiveSet | IPostReceiveSetKeyValue @@ -1151,6 +1152,13 @@ export interface IPostReceiveBinaryData extends IPostReceiveBase { }; } +export interface IPostReceiveLimit extends IPostReceiveBase { + type: 'limit'; + properties: { + maxResults: number | string; + }; +} + export interface IPostReceiveRootProperty extends IPostReceiveBase { type: 'rootProperty'; properties: { diff --git a/packages/workflow/src/RoutingNode.ts b/packages/workflow/src/RoutingNode.ts index f21a99c8fc..c52bfd2d84 100644 --- a/packages/workflow/src/RoutingNode.ts +++ b/packages/workflow/src/RoutingNode.ts @@ -283,6 +283,17 @@ export class RoutingNode { ); } } + if (action.type === 'limit') { + const maxResults = this.getParameterValue( + action.properties.maxResults, + itemIndex, + runIndex, + executeSingleFunctions.getExecuteData(), + { $response: responseData, $value: parameterValue }, + false, + ) as string; + return inputData.slice(0, parseInt(maxResults, 10)); + } if (action.type === 'set') { const { value } = action.properties; // If the value is an expression resolve it