From 6098384a30e52e7fff14037fa8e129093947c997 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Tue, 13 Oct 2020 04:48:20 -0400 Subject: [PATCH] :sparkles: Add Sendy-Node (#1013) * :sparkles: Sendy-Node * :sparkles: Improvements * :zap: Small improvement --- .../credentials/SendyApi.credentials.ts | 24 ++ .../nodes/Sendy/CampaignDescription.ts | 240 +++++++++++++ .../nodes/Sendy/GenericFunctions.ts | 48 +++ packages/nodes-base/nodes/Sendy/Sendy.node.ts | 319 ++++++++++++++++++ .../nodes/Sendy/SubscriberDescription.ts | 292 ++++++++++++++++ packages/nodes-base/nodes/Sendy/sendy.png | Bin 0 -> 7326 bytes packages/nodes-base/package.json | 2 + 7 files changed, 925 insertions(+) create mode 100644 packages/nodes-base/credentials/SendyApi.credentials.ts create mode 100644 packages/nodes-base/nodes/Sendy/CampaignDescription.ts create mode 100644 packages/nodes-base/nodes/Sendy/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Sendy/Sendy.node.ts create mode 100644 packages/nodes-base/nodes/Sendy/SubscriberDescription.ts create mode 100644 packages/nodes-base/nodes/Sendy/sendy.png diff --git a/packages/nodes-base/credentials/SendyApi.credentials.ts b/packages/nodes-base/credentials/SendyApi.credentials.ts new file mode 100644 index 0000000000..fcca6677d8 --- /dev/null +++ b/packages/nodes-base/credentials/SendyApi.credentials.ts @@ -0,0 +1,24 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class SendyApi implements ICredentialType { + name = 'sendyApi'; + displayName = 'Sendy API'; + properties = [ + { + displayName: 'URL', + name: 'url', + type: 'string' as NodePropertyTypes, + default: '', + placeholder: 'https://yourdomain.com', + }, + { + displayName: 'API Key', + name: 'apiKey', + type: 'string' as NodePropertyTypes, + default: '', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Sendy/CampaignDescription.ts b/packages/nodes-base/nodes/Sendy/CampaignDescription.ts new file mode 100644 index 0000000000..e0402af8a8 --- /dev/null +++ b/packages/nodes-base/nodes/Sendy/CampaignDescription.ts @@ -0,0 +1,240 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const campaignOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'campaign', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a campaign', + }, + ], + default: 'create', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const campaignFields = [ + +/* -------------------------------------------------------------------------- */ +/* campaign:create */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'From Name', + name: 'fromName', + type: 'string', + displayOptions: { + show: { + resource: [ + 'campaign', + ], + operation: [ + 'create', + ], + }, + }, + default: '', + description: `The 'From name' of your campaign`, + }, + { + displayName: 'From Email', + name: 'fromEmail', + type: 'string', + displayOptions: { + show: { + resource: [ + 'campaign', + ], + operation: [ + 'create', + ], + }, + }, + default: '', + description: `The 'From email' of your campaign`, + }, + { + displayName: 'Reply To', + name: 'replyTo', + type: 'string', + displayOptions: { + show: { + resource: [ + 'campaign', + ], + operation: [ + 'create', + ], + }, + }, + default: '', + description: `The 'Reply to' of your campaign`, + }, + { + displayName: 'Title', + name: 'title', + type: 'string', + displayOptions: { + show: { + resource: [ + 'campaign', + ], + operation: [ + 'create', + ], + }, + }, + default: '', + description: `The 'Title' of your campaign`, + }, + { + displayName: 'Subject', + name: 'subject', + type: 'string', + displayOptions: { + show: { + resource: [ + 'campaign', + ], + operation: [ + 'create', + ], + }, + }, + default: '', + description: `The 'Subject' of your campaign`, + }, + { + displayName: 'HTML Text', + name: 'htmlText', + type: 'string', + displayOptions: { + show: { + resource: [ + 'campaign', + ], + operation: [ + 'create', + ], + }, + }, + default: '', + }, + { + displayName: 'Send Campaign', + name: 'sendCampaign', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'campaign', + ], + operation: [ + 'create', + ], + }, + }, + default: false, + description: `Set to true if you want to send the campaign as well and not just create a draft. Default is false.`, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'campaign', + ], + operation: [ + 'create', + ], + }, + }, + options: [ + { + displayName: 'Brand ID', + name: 'brandId', + type: 'string', + displayOptions: { + show: { + '/sendCampaign': [ + false, + ], + }, + }, + default: '', + }, + { + displayName: 'Exclude List IDs', + name: 'excludeListIds', + type: 'string', + default: '', + description: ` Lists to exclude from your campaign. List IDs should be single or comma-separated`, + }, + { + displayName: 'Exclude Segment IDs', + name: 'excludeSegmentIds', + type: 'string', + default: '', + description: `Segments to exclude from your campaign. Segment IDs should be single or comma-separated.`, + }, + { + displayName: 'List IDs', + name: 'listIds', + type: 'string', + default: '', + description: `List IDs should be single or comma-separated`, + }, + { + displayName: 'Plain Text', + name: 'plainText', + type: 'string', + default: '', + description: `The 'Plain text version' of your campaign` + }, + { + displayName: 'Querystring', + name: 'queryString', + type: 'string', + default: '', + description: `Google Analytics tags`, + }, + { + displayName: 'Segment IDs', + name: 'segmentIds', + type: 'string', + default: '', + description: `Segment IDs should be single or comma-separated.`, + }, + { + displayName: 'Track Clicks', + name: 'trackClicks', + type: 'boolean', + default: true, + description: ` Set to false if you want to disable clicks tracking. Default is true.`, + }, + { + displayName: 'Track Opens', + name: 'trackOpens', + type: 'boolean', + default: true, + description: `Set to false if you want to disable opens tracking. Default is true.`, + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Sendy/GenericFunctions.ts b/packages/nodes-base/nodes/Sendy/GenericFunctions.ts new file mode 100644 index 0000000000..79c9cbe31a --- /dev/null +++ b/packages/nodes-base/nodes/Sendy/GenericFunctions.ts @@ -0,0 +1,48 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +export async function sendyApiRequest(this: IExecuteFunctions | ILoadOptionsFunctions, method: string, path: string, body: any = {}, qs: IDataObject = {}, option = {}): Promise { // tslint:disable-line:no-any + + const credentials = this.getCredentials('sendyApi') as IDataObject; + + body.api_key = credentials.apiKey; + + body.boolean = true; + + const options: OptionsWithUri = { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + method, + form: body, + qs, + uri: `${credentials.url}${path}`, + }; + + try { + //@ts-ignore + return await this.helpers.request.call(this, options); + } catch (error) { + if (error.response && error.response.body && error.response.body.error) { + + let errors = error.response.body.error.errors; + + errors = errors.map((e: IDataObject) => e.message); + // Try to return the error prettier + throw new Error( + `Sendy error response [${error.statusCode}]: ${errors.join('|')}` + ); + } + throw error; + } +} diff --git a/packages/nodes-base/nodes/Sendy/Sendy.node.ts b/packages/nodes-base/nodes/Sendy/Sendy.node.ts new file mode 100644 index 0000000000..87e0dd1939 --- /dev/null +++ b/packages/nodes-base/nodes/Sendy/Sendy.node.ts @@ -0,0 +1,319 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { + sendyApiRequest, +} from './GenericFunctions'; + +import { + campaignFields, + campaignOperations, +} from './CampaignDescription'; + +import { + subscriberFields, + subscriberOperations, +} from './SubscriberDescription'; + +export class Sendy implements INodeType { + description: INodeTypeDescription = { + displayName: 'Sendy', + name: 'sendy', + icon: 'file:sendy.png', + group: ['input'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume Sendy API.', + defaults: { + name: 'Sendy', + color: '#000000', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'sendyApi', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Campaign', + value: 'campaign', + }, + { + name: 'Subscriber', + value: 'subscriber', + }, + ], + default: 'subscriber', + description: 'The resource to operate on.' + }, + ...campaignOperations, + ...campaignFields, + ...subscriberOperations, + ...subscriberFields, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + const length = (items.length as unknown) as number; + const qs: IDataObject = {}; + let responseData; + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + for (let i = 0; i < length; i++) { + + if (resource === 'campaign') { + if (operation === 'create') { + + const fromName = this.getNodeParameter('fromName', i) as string; + + const fromEmail = this.getNodeParameter('fromEmail', i) as string; + + const replyTo = this.getNodeParameter('replyTo', i) as string; + + const title = this.getNodeParameter('title', i) as string; + + const subject = this.getNodeParameter('subject', i) as string; + + const htmlText = this.getNodeParameter('htmlText', i) as boolean; + + const sendCampaign = this.getNodeParameter('sendCampaign', i) as boolean; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + const body: IDataObject = { + from_name: fromName, + from_email: fromEmail, + reply_to: replyTo, + title, + subject, + send_campaign: sendCampaign, + html_text: htmlText, + }; + + if (additionalFields.plainText) { + body.plain_text = additionalFields.plainText; + } + + if (additionalFields.listIds) { + body.list_ids = additionalFields.listIds as string; + } + + if (additionalFields.segmentIds) { + body.segment_ids = additionalFields.segmentIds as string; + } + + if (additionalFields.excludeListIds) { + body.exclude_list_ids = additionalFields.excludeListIds as string; + } + + if (additionalFields.excludeSegmentIds) { + body.exclude_segments_ids = additionalFields.excludeSegmentIds as string; + } + + if (additionalFields.brandId) { + body.brand_id = additionalFields.brandId as string; + } + + if (additionalFields.queryString) { + body.query_string = additionalFields.queryString as string; + } + + if (additionalFields.trackOpens) { + body.track_opens = additionalFields.trackOpens as boolean; + } + + if (additionalFields.trackClicks) { + body.track_clicks = additionalFields.trackClicks as boolean; + } + + responseData = await sendyApiRequest.call( + this, + 'POST', + '/api/campaigns/create.php', + body, + ); + + const success = [ + 'Campaign created', + 'Campaign created and now sending', + ]; + + if (success.includes(responseData)) { + responseData = { message: responseData }; + } else { + throw new Error(`Sendy error response [${400}]: ${responseData}`); + } + } + } + + if (resource === 'subscriber') { + if (operation === 'add') { + + const email = this.getNodeParameter('email', i) as string; + + const listId = this.getNodeParameter('listId', i) as string; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + const body: IDataObject = { + email, + list: listId, + }; + + Object.assign(body, additionalFields); + + responseData = await sendyApiRequest.call( + this, + 'POST', + '/subscribe', + body, + ); + + if (responseData === '1') { + responseData = { success: true }; + } else { + throw new Error(`Sendy error response [${400}]: ${responseData}`); + } + } + + if (operation === 'count') { + + const listId = this.getNodeParameter('listId', i) as string; + + const body: IDataObject = { + list_id: listId, + }; + + responseData = await sendyApiRequest.call( + this, + 'POST', + '/api/subscribers/active-subscriber-count.php', + body, + ); + + const errors = [ + 'No data passed', + 'API key not passed', + 'Invalid API key', + 'List ID not passed', + 'List does not exist', + ]; + + if (!errors.includes(responseData)) { + responseData = { count: responseData }; + } else { + throw new Error(`Sendy error response [${400}]: ${responseData}`); + } + } + + if (operation === 'delete') { + + const email = this.getNodeParameter('email', i) as string; + + const listId = this.getNodeParameter('listId', i) as string; + + const body: IDataObject = { + email, + list_id: listId, + }; + + responseData = await sendyApiRequest.call( + this, + 'POST', + '/api/subscribers/delete.php', + body, + ); + + if (responseData === '1') { + responseData = { success: true }; + } else { + throw new Error(`Sendy error response [${400}]: ${responseData}`); + } + } + + if (operation === 'remove') { + + const email = this.getNodeParameter('email', i) as string; + + const listId = this.getNodeParameter('listId', i) as string; + + const body: IDataObject = { + email, + list: listId, + }; + + responseData = await sendyApiRequest.call( + this, + 'POST', + '/unsubscribe', + body, + ); + + if (responseData === '1') { + responseData = { success: true }; + } else { + throw new Error(`Sendy error response [${400}]: ${responseData}`); + } + } + + if (operation === 'status') { + + const email = this.getNodeParameter('email', i) as string; + + const listId = this.getNodeParameter('listId', i) as string; + + const body: IDataObject = { + email, + list_id: listId, + }; + + responseData = await sendyApiRequest.call( + this, + 'POST', + '/api/subscribers/subscription-status.php', + body, + ); + + const status = [ + 'Subscribed', + 'Unsubscribed', + 'Unconfirmed', + 'Bounced', + 'Soft bounced', + 'Complained', + ]; + + if (status.includes(responseData)) { + responseData = { status: responseData }; + } else { + throw new Error(`Sendy error response [${400}]: ${responseData}`); + } + } + } + } + if (Array.isArray(responseData)) { + returnData.push.apply(returnData, responseData as IDataObject[]); + } else if (responseData !== undefined) { + returnData.push(responseData as IDataObject); + } + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/Sendy/SubscriberDescription.ts b/packages/nodes-base/nodes/Sendy/SubscriberDescription.ts new file mode 100644 index 0000000000..ce391ea963 --- /dev/null +++ b/packages/nodes-base/nodes/Sendy/SubscriberDescription.ts @@ -0,0 +1,292 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const subscriberOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'subscriber', + ], + }, + }, + options: [ + { + name: 'Add', + value: 'add', + description: 'Add a subscriber to a list', + }, + { + name: 'Count', + value: 'count', + description: 'Count subscribers', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a subscriber from a list', + }, + { + name: 'Remove', + value: 'remove', + description: 'Unsubscribe user from a list', + }, + { + name: 'Status', + value: 'status', + description: 'Get the status of subscriber', + }, + ], + default: 'add', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const subscriberFields = [ + +/* -------------------------------------------------------------------------- */ +/* subscriber:add */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Email', + name: 'email', + type: 'string', + displayOptions: { + show: { + resource: [ + 'subscriber', + ], + operation: [ + 'add', + ], + }, + }, + default: '', + description: 'Email address of the subscriber.', + }, + { + displayName: 'List ID', + name: 'listId', + type: 'string', + displayOptions: { + show: { + resource: [ + 'subscriber', + ], + operation: [ + 'add', + ], + }, + }, + default: '', + description: `The list id you want to subscribe a user to.
+ This encrypted & hashed id can be found under View all lists section named ID.`, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'subscriber', + ], + operation: [ + 'add', + ], + }, + }, + options: [ + { + displayName: 'Country', + name: 'country', + type: 'string', + default: '', + description: `User's 2 letter country code`, + }, + { + displayName: 'GDRP', + name: 'gdpr', + type: 'boolean', + default: false, + description: `If you're signing up EU users in a GDPR compliant manner, set this to "true"`, + }, + { + displayName: 'Honeypot', + name: 'hp', + type: 'boolean', + default: false, + description: `Include this 'honeypot' field to prevent spambots from signing up via this API call. When spambots fills in this field, this API call will exit, preventing them from signing up fake addresses to your form. This parameter is only supported in Sendy 3.0 onwards.`, + }, + { + displayName: 'IP Address', + name: 'ipaddress', + type: 'string', + default: '', + description: `User's IP address`, + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + description: `User's name`, + }, + { + displayName: 'Referrer', + name: 'referrer', + type: 'string', + default: '', + description: `The URL where the user signed up from`, + }, + { + displayName: 'Silent', + name: 'silent', + type: 'boolean', + default: false, + description: `Set to "true" if your list is 'Double opt-in' but you want to bypass that and signup the user to the list as 'Single Opt-in instead' (optional)`, + }, + ], + }, +/* -------------------------------------------------------------------------- */ +/* subscriber:count */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'List ID', + name: 'listId', + type: 'string', + displayOptions: { + show: { + resource: [ + 'subscriber', + ], + operation: [ + 'count', + ], + }, + }, + default: '', + description: `The list id you want to subscribe a user to.
+ This encrypted & hashed id can be found under View all lists section named ID.`, + }, +/* -------------------------------------------------------------------------- */ +/* subscriber:delete */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Email', + name: 'email', + type: 'string', + displayOptions: { + show: { + resource: [ + 'subscriber', + ], + operation: [ + 'delete', + ], + }, + }, + default: '', + description: 'Email address of the subscriber.', + }, + { + displayName: 'List ID', + name: 'listId', + type: 'string', + displayOptions: { + show: { + resource: [ + 'subscriber', + ], + operation: [ + 'delete', + ], + }, + }, + default: '', + description: `The list id you want to subscribe a user to.
+ This encrypted & hashed id can be found under View all lists section named ID.`, + }, +/* -------------------------------------------------------------------------- */ +/* subscriber:remove */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Email', + name: 'email', + type: 'string', + displayOptions: { + show: { + resource: [ + 'subscriber', + ], + operation: [ + 'remove', + ], + }, + }, + default: '', + description: 'Email address of the subscriber.', + }, + { + displayName: 'List ID', + name: 'listId', + type: 'string', + displayOptions: { + show: { + resource: [ + 'subscriber', + ], + operation: [ + 'remove', + ], + }, + }, + default: '', + description: `The list id you want to subscribe a user to.
+ This encrypted & hashed id can be found under View all lists section named ID.`, + }, +/* -------------------------------------------------------------------------- */ +/* subscriber:status */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Email', + name: 'email', + type: 'string', + displayOptions: { + show: { + resource: [ + 'subscriber', + ], + operation: [ + 'status', + ], + }, + }, + default: '', + description: 'Email address of the subscriber.', + }, + { + displayName: 'List ID', + name: 'listId', + type: 'string', + displayOptions: { + show: { + resource: [ + 'subscriber', + ], + operation: [ + 'status', + ], + }, + }, + default: '', + description: `The list id you want to subscribe a user to.
+ This encrypted & hashed id can be found under View all lists section named ID.`, + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Sendy/sendy.png b/packages/nodes-base/nodes/Sendy/sendy.png new file mode 100644 index 0000000000000000000000000000000000000000..f9c01b585c1d6ea13081c4dbc31fe71afcce27d0 GIT binary patch literal 7326 zcmaiZ1zgkJ7xzX>DnpPSAOaGjh0%_#k`0hRBWHgGS62xBz|EiJ%J+?WJF2&4w!Uy$HF03ZW^@YfgsPzN&pHP!

=%OmdVcGPa?{fK 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 mJZevWvD`8rlbYF|c+r