diff --git a/packages/destination-actions/src/destinations/sendgrid/index.ts b/packages/destination-actions/src/destinations/sendgrid/index.ts index 011620ebe5..448c454f80 100644 --- a/packages/destination-actions/src/destinations/sendgrid/index.ts +++ b/packages/destination-actions/src/destinations/sendgrid/index.ts @@ -3,6 +3,8 @@ import type { Settings } from './generated-types' import updateUserProfile from './updateUserProfile' +import sendEmail from './sendEmail' + const destination: DestinationDefinition = { name: 'SendGrid Marketing Campaigns', slug: 'actions-sendgrid', @@ -37,7 +39,8 @@ const destination: DestinationDefinition = { }, actions: { - updateUserProfile + updateUserProfile, + sendEmail } } diff --git a/packages/destination-actions/src/destinations/sendgrid/sendEmail/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/sendgrid/sendEmail/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..09ed8fa242 --- /dev/null +++ b/packages/destination-actions/src/destinations/sendgrid/sendEmail/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,93 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Sendgrid's sendEmail destination action: all fields 1`] = ` +Object { + "asm": Object { + "group_id": 343234, + }, + "categories": Array [ + "!s3p^", + ], + "from": Object { + "email": "from@gmail.com", + }, + "ip_pool_name": "!s3p^", + "mail_settings": Object { + "sandbox_mode": true, + }, + "personalizations": Array [ + Object { + "bcc": Array [ + Object { + "email": "oz@ogmeftik.fj", + "name": "!s3p^", + }, + ], + "cc": Array [ + Object { + "email": "oz@ogmeftik.fj", + "name": "!s3p^", + }, + ], + "custom_args": Object { + "testType": "!s3p^", + }, + "dynamic_template_data": Object { + "testType": "!s3p^", + }, + "headers": Object { + "testType": "!s3p^", + }, + "to": Array [ + Object { + "email": "oz@ogmeftik.fj", + "name": "!s3p^", + }, + ], + }, + ], + "reply_to": Object { + "email": "from@gmail.com", + }, + "template_id": "d-1234567890", + "tracking_settings": Object { + "ganalytics": Object { + "enable": true, + "utm_campaign": "!s3p^", + "utm_content": "!s3p^", + "utm_medium": "!s3p^", + "utm_source": "!s3p^", + "utm_term": "!s3p^", + }, + "subscription_tracking": Object { + "enable": true, + "html": "!s3p^", + "substitution_tag": "!s3p^", + "text": "!s3p^", + }, + }, +} +`; + +exports[`Testing snapshot for Sendgrid's sendEmail destination action: required fields 1`] = ` +Object { + "from": Object { + "email": "oz@ogmeftik.fj", + }, + "personalizations": Array [ + Object { + "headers": Object {}, + "to": Array [ + Object { + "email": "oz@ogmeftik.fj", + }, + ], + }, + ], + "reply_to": Object { + "email": "oz@ogmeftik.fj", + }, + "template_id": "d-1234567890", + "tracking_settings": Object {}, +} +`; diff --git a/packages/destination-actions/src/destinations/sendgrid/sendEmail/__tests__/index.test.ts b/packages/destination-actions/src/destinations/sendgrid/sendEmail/__tests__/index.test.ts new file mode 100644 index 0000000000..4856ae4923 --- /dev/null +++ b/packages/destination-actions/src/destinations/sendgrid/sendEmail/__tests__/index.test.ts @@ -0,0 +1,284 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration, SegmentEvent, PayloadValidationError } from '@segment/actions-core' +import Definition from '../../index' +import { Settings } from '../../generated-types' +import { RESERVED_HEADERS } from '../constants' + +let testDestination = createTestIntegration(Definition) + +const timestamp = '2024-01-08T13:52:50.212Z' +const settings: Settings = { + sendGridApiKey: 'test-api-key' +} +const validPayload = { + timestamp: timestamp, + event: 'Send Email From Template', + messageId: 'aaa-bbb-ccc', + type: 'track', + userId: 'user_id_1', + context: { + campaign: { + source: 'source1', + medium: 'medium1', + term: 'term1', + content: 'content1', + name: 'name1' + } + }, + properties: { + from: { + email: 'billyjoe@yellowstone.com', + name: 'Billy Joe' + }, + to: { + email: 'maryjane@yellowstone.com', + name: 'Mary Jane' + }, + cc: [ + { + email: 'cc1@gmail.com', + name: 'CC 1' + }, + { + email: 'cc2@gmail.com', + name: 'CC 2' + } + ], + bcc: [ + { + email: 'bcc1@gmail.com', + name: 'BCC 1' + }, + { + email: 'bcc2@gmail.com', + name: 'BCC 2' + } + ], + headers: { + testHeader1: 'testHeaderValue1' + }, + dynamic_template_data: { + stringVal: 'stringVal', + numVal: 123456, + boolVal: true, + objVal: { + key1: 'value1', + key2: 'value2' + }, + arrayVal: ['value1', 'value2'] + }, + template_id: 'd-1234567890', + custom_args: { + custom_arg1: 'custom_arg_value1', + custom_arg2: 'custom_arg_value2' + }, + reply_to: { + reply_to_equals_from: true + }, + subscription_tracking: { + enable: false + }, + categories: ['category1', 'category2'], + google_analytics: { + enable: true + }, + ip_pool_name: 'ip_pool_name1', + group_id: '123 - blah', + sandbox_mode: false + } +} as Partial +const mapping = { + from: { '@path': '$.properties.from' }, + to: { '@path': '$.properties.to' }, + cc: { '@path': '$.properties.cc' }, + bcc: { '@path': '$.properties.bcc' }, + headers: { '@path': '$.properties.headers' }, + dynamic_template_data: { '@path': '$.properties.dynamic_template_data' }, + template_id: { '@path': '$.properties.template_id' }, + custom_args: { '@path': '$.properties.custom_args' }, + reply_to: { '@path': '$.properties.reply_to' }, + subscription_tracking: { '@path': '$.properties.subscription_tracking' }, + categories: { '@path': '$.properties.categories' }, + google_analytics: { + enable: { '@path': '$.properties.google_analytics.enable' }, + utm_source: { '@path': '$.context.campaign.source' }, + utm_medium: { '@path': '$.context.campaign.medium' }, + utm_term: { '@path': '$.context.campaign.term' }, + utm_content: { '@path': '$.context.campaign.content' }, + utm_campaign: { '@path': '$.context.campaign.name' } + }, + ip_pool_name: { '@path': '$.properties.ip_pool_name' }, + group_id: { '@path': '$.properties.group_id' }, + sandbox_mode: { '@path': '$.properties.sandbox_mode' }, + send_at: { '@path': '$.properties.send_at' } +} +const expectedSendgridPayload = { + personalizations: [ + { + to: [ + { + email: 'maryjane@yellowstone.com', + name: 'Mary Jane' + } + ], + cc: [ + { + email: 'cc1@gmail.com', + name: 'CC 1' + }, + { + email: 'cc2@gmail.com', + name: 'CC 2' + } + ], + bcc: [ + { + email: 'bcc1@gmail.com', + name: 'BCC 1' + }, + { + email: 'bcc2@gmail.com', + name: 'BCC 2' + } + ], + headers: { + testHeader1: 'testHeaderValue1' + }, + dynamic_template_data: { + stringVal: 'stringVal', + numVal: 123456, + boolVal: true, + objVal: { + key1: 'value1', + key2: 'value2' + }, + arrayVal: ['value1', 'value2'] + }, + custom_args: { + custom_arg1: 'custom_arg_value1', + custom_arg2: 'custom_arg_value2' + } + } + ], + from: { + email: 'billyjoe@yellowstone.com', + name: 'Billy Joe' + }, + reply_to: { + email: 'billyjoe@yellowstone.com', + name: 'Billy Joe' + }, + template_id: 'd-1234567890', + categories: ['category1', 'category2'], + asm: { + group_id: 123 + }, + ip_pool_name: 'ip_pool_name1', + tracking_settings: { + subscription_tracking: { + enable: false + }, + ganalytics: { + enable: true, + utm_source: 'source1', + utm_medium: 'medium1', + utm_term: 'term1', + utm_content: 'content1', + utm_campaign: 'name1' + } + }, + mail_settings: { + sandbox_mode: false + } +} + +beforeEach((done) => { + testDestination = createTestIntegration(Definition) + nock.cleanAll() + done() +}) + +describe('Sendgrid.sendEmail', () => { + it('should send an email', async () => { + const event = createTestEvent(validPayload) + // send email via Sendgrid + nock('https://api.sendgrid.com').post('/v3/mail/send', expectedSendgridPayload).reply(200, {}) + const responses = await testDestination.testAction('sendEmail', { + event, + settings, + useDefaultMappings: true, + mapping + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) + + it('should throw error if bad headers', async () => { + const badPayload = { + ...validPayload, + properties: { + ...validPayload.properties, + headers: { + testHeader1: 'testHeaderValue1', + 'dkim-signature': 'baaaad illegal header' + } + } + } + const event = createTestEvent(badPayload) + await expect( + testDestination.testAction('sendEmail', { + event, + settings, + useDefaultMappings: true, + mapping + }) + ).rejects.toThrowError( + new PayloadValidationError( + `Headers cannot contain any of the following reserved headers: ${RESERVED_HEADERS.join(', ')}` + ) + ) + }) + + it('should throw error if bad template ID', async () => { + const badPayload = { ...validPayload, properties: { ...validPayload.properties, template_id: '1234567890' } } + const event = createTestEvent(badPayload) + await expect( + testDestination.testAction('sendEmail', { + event, + settings, + useDefaultMappings: true, + mapping + }) + ).rejects.toThrowError( + new PayloadValidationError(`Template ID must refer to a Dynamic Template. Dynamic Template IDs start with "d-"`) + ) + }) + + it('should throw error if send_at more than 72h in future', async () => { + const send_at = new Date(Date.now() + 73 * 60 * 60 * 1000) + const badPayload = { ...validPayload, properties: { ...validPayload.properties, send_at: send_at.toISOString() } } + const event = createTestEvent(badPayload) + await expect( + testDestination.testAction('sendEmail', { + event, + settings, + useDefaultMappings: true, + mapping + }) + ).rejects.toThrowError(new PayloadValidationError(`send_at should be less than 72 hours from now`)) + }) + + it('should throw error if send_at in the past', async () => { + const send_at = new Date(Date.now() - 100 * 60 * 60 * 1000) + const badPayload = { ...validPayload, properties: { ...validPayload.properties, send_at: send_at.toISOString() } } + const event = createTestEvent(badPayload) + await expect( + testDestination.testAction('sendEmail', { + event, + settings, + useDefaultMappings: true, + mapping + }) + ).rejects.toThrowError(new PayloadValidationError(`send_at should be less than 72 hours from now`)) + }) +}) diff --git a/packages/destination-actions/src/destinations/sendgrid/sendEmail/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/sendgrid/sendEmail/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..82d4fa1ba0 --- /dev/null +++ b/packages/destination-actions/src/destinations/sendgrid/sendEmail/__tests__/snapshot.test.ts @@ -0,0 +1,91 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'sendEmail' +const destinationSlug = 'Sendgrid' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + event.properties = { + ...event.properties, + template_id: 'd-1234567890', + send_at: '', + group_id: '' + } + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + event.properties = { + ...event.properties, + template_id: 'd-1234567890', + send_at: '', + group_id: '343234 - hello', + from: { email: 'from@gmail.com' }, + domain: 'gmail.com' + } + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: { ...event.properties, group_id: { '@path': '$.properties.group_id' } }, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/sendgrid/sendEmail/constants.ts b/packages/destination-actions/src/destinations/sendgrid/sendEmail/constants.ts new file mode 100644 index 0000000000..b3c5aef801 --- /dev/null +++ b/packages/destination-actions/src/destinations/sendgrid/sendEmail/constants.ts @@ -0,0 +1,32 @@ +export const RESERVED_HEADERS = [ + 'x-sg-id', + 'x-sg-eid', + 'received', + 'dkim-signature', + 'Content-Type', + 'Content-Transfer-Encoding', + 'To', + 'From', + 'Subject', + 'Reply-To', + 'CC', + 'BCC' +] + +export const MAX_CATEGORY_LENGTH = 255 + +export const MIN_IP_POOL_NAME_LENGTH = 2 + +export const MAX_IP_POOL_NAME_LENGTH = 64 + +export const SEND_EMAIL_URL = 'https://api.sendgrid.com/v3/mail/send' + +export const GET_TEMPLATES_URL = 'https://api.sendgrid.com/v3/templates?generations=dynamic&page_size=200' + +export const TRUNCATE_CHAR_LENGTH = 25 + +export const GET_IP_POOLS_URL = 'https://api.sendgrid.com/v3/ips/pools' + +export const GET_VALID_DOMAINS_URL = 'https://api.sendgrid.com/v3/whitelabel/domains?limit=200' + +export const GET_GROUP_IDS_URL = 'https://api.sendgrid.com/v3/asm/groups' diff --git a/packages/destination-actions/src/destinations/sendgrid/sendEmail/dynamic-fields.ts b/packages/destination-actions/src/destinations/sendgrid/sendEmail/dynamic-fields.ts new file mode 100644 index 0000000000..a294452ffd --- /dev/null +++ b/packages/destination-actions/src/destinations/sendgrid/sendEmail/dynamic-fields.ts @@ -0,0 +1,213 @@ +import { RequestClient } from '@segment/actions-core' +import { DynamicFieldResponse } from '@segment/actions-core' +import { + GET_TEMPLATES_URL, + TRUNCATE_CHAR_LENGTH, + GET_IP_POOLS_URL, + GET_VALID_DOMAINS_URL, + GET_GROUP_IDS_URL +} from './constants' + +interface ResultError { + response: { + status: number + data: { + errors: { + message: string + }[] + } + } +} + +export async function dynamicGroupId(request: RequestClient): Promise { + interface ResultItem { + id: string + name: string + description: string + } + + interface ResponseType { + data: ResultItem[] + } + + try { + const response: ResponseType = await request(GET_GROUP_IDS_URL, { + method: 'GET', + skipResponseCloning: true + }) + + return { + choices: response.data.map((group: ResultItem) => { + return { + label: `${group.id} - ${group.name}`, + value: `${group.id} - ${group.name}` + } + }) + } + } catch (err) { + const error = err as ResultError + const code = String(error?.response?.status ?? 500) + + return { + choices: [], + error: { + message: + error?.response?.data?.errors.map((error) => error.message).join(';') ?? 'Unknown error: dynamicGroupId', + code: code + } + } + } +} + +export async function dynamicDomain(request: RequestClient): Promise { + interface ResultItem { + id: string + subdomain?: string + domain: string + username: string + valid: boolean + } + + interface ResponseType { + data: ResultItem[] + } + + try { + const response: ResponseType = await request(GET_VALID_DOMAINS_URL, { + method: 'GET', + skipResponseCloning: true + }) + + return { + choices: response.data + .filter((domain: ResultItem) => domain.valid === true) + .map((domain: ResultItem) => { + return { + label: `${domain.subdomain}.${domain.domain}`, + value: `${domain.subdomain}.${domain.domain}` + } + }) + } + } catch (err) { + const error = err as ResultError + const code = String(error?.response?.status ?? 500) + + return { + choices: [], + error: { + message: + error?.response?.data?.errors.map((error) => error.message).join(';') ?? 'Unknown error: dynamicDomain', + code: code + } + } + } +} + +export async function dynamicTemplateId(request: RequestClient): Promise { + interface ResultItem { + id: string + name: string + generation: string + versions: Version[] + } + + interface Version { + id: string + template_id: string + active: number + name: string + subject: string + } + + interface ResponseType { + data: { + result: ResultItem[] + } + } + + try { + const response: ResponseType = await request(GET_TEMPLATES_URL, { + method: 'GET', + skipResponseCloning: true + }) + + return { + choices: response.data.result + .filter((template: ResultItem) => template.generation === 'dynamic') + .map((template: ResultItem) => { + return template.versions + .filter((version: Version) => version.active === 1) + .map((version: Version) => { + const truncatedTemplateName = + template.name.length > TRUNCATE_CHAR_LENGTH + ? `${template.name.slice(0, TRUNCATE_CHAR_LENGTH)}...` + : template.name + const truncatedVersionName = + version.name.length > TRUNCATE_CHAR_LENGTH + ? `${version.name.slice(0, TRUNCATE_CHAR_LENGTH)}...` + : version.name + const truncatedSubject = + version.subject.length > TRUNCATE_CHAR_LENGTH + ? `${version.subject.slice(0, TRUNCATE_CHAR_LENGTH)}...` + : version.subject + + return { + label: `${truncatedTemplateName} - ${truncatedVersionName} - ${truncatedSubject}`, + value: version.template_id + } + }) + }) + .flat() + } + } catch (err) { + const error = err as ResultError + const code = String(error?.response?.status ?? 500) + + return { + choices: [], + error: { + message: + error?.response?.data?.errors.map((error) => error.message).join(';') ?? 'Unknown error: dynamicGetTemplates', + code: code + } + } + } +} + +export async function dynamicIpPoolNames(request: RequestClient): Promise { + interface ResponseType { + data: ResultItem[] + } + + interface ResultItem { + name: string + } + + try { + const response: ResponseType = await request(GET_IP_POOLS_URL, { + method: 'GET', + skipResponseCloning: true + }) + + return { + choices: response.data.map((item) => { + return { + label: item.name, + value: item.name + } + }) + } + } catch (err) { + const error = err as ResultError + const code = String(error?.response?.status ?? 500) + + return { + choices: [], + error: { + message: + error?.response?.data?.errors.map((error) => error.message).join(';') ?? 'Unknown error: dynamicIpPoolNames', + code: code + } + } + } +} diff --git a/packages/destination-actions/src/destinations/sendgrid/sendEmail/fields.ts b/packages/destination-actions/src/destinations/sendgrid/sendEmail/fields.ts new file mode 100644 index 0000000000..bf647f2f94 --- /dev/null +++ b/packages/destination-actions/src/destinations/sendgrid/sendEmail/fields.ts @@ -0,0 +1,296 @@ +import { InputField } from '@segment/actions-core' + +export const fields: Record = { + domain: { + label: 'Validated Domain', + description: + 'The domain to use for the email. This field is optional but recommended. If you do not provide a domain, Sendgrid will attempt to send the email based on the from address, and may fail if the domain in the from address is not validated.', + type: 'string', + required: false, + dynamic: true + }, + from: { + label: 'From', + description: 'From details.', + type: 'object', + required: true, + additionalProperties: false, + defaultObjectUI: 'keyvalue', + properties: { + email: { + label: 'Email', + description: 'The email address of the sender.', + type: 'string', + required: true + }, + name: { + label: 'Name', + description: 'From name of the sender, displayed to the recipient.', + type: 'string', + required: false + } + } + }, + to: { + label: 'To', + description: 'Recipient details.', + type: 'object', + multiple: true, + required: true, + additionalProperties: false, + defaultObjectUI: 'keyvalue', + properties: { + email: { + label: 'Email', + description: 'The email address of the recipient.', + type: 'string', + required: true + }, + name: { + label: 'Name', + description: 'The name of the recipient.', + type: 'string', + required: false + } + } + }, + cc: { + label: 'CC', + description: 'CC recipient details', + type: 'object', + multiple: true, + required: false, + additionalProperties: false, + defaultObjectUI: 'keyvalue', + properties: { + email: { + label: 'Email', + description: 'The email address of the CC recipient.', + type: 'string', + required: true + }, + name: { + label: 'Name', + description: 'The name of the CC recipient.', + type: 'string', + required: false + } + } + }, + bcc: { + label: 'BCC', + description: 'BCC recipient details', + type: 'object', + multiple: true, + required: false, + additionalProperties: false, + defaultObjectUI: 'keyvalue', + properties: { + email: { + label: 'Email', + description: 'The email address of the BCC recipient.', + type: 'string', + required: true + }, + name: { + label: 'Name', + description: 'The name of the BCC recipient.', + type: 'string', + required: false + } + } + }, + headers: { + label: 'Headers', + description: 'Headers for the email.', + type: 'object', + required: false, + additionalProperties: true, + defaultObjectUI: 'keyvalue' + }, + dynamic_template_data: { + label: 'Dynamic Template Data', + description: + 'A collection of property names that will be substituted by their corresponding property values in the subject, reply-to and content portions of a SendGrid Dynamic Template.', + type: 'object', + required: false, + defaultObjectUI: 'keyvalue', + additionalProperties: true + }, + template_id: { + label: 'Template ID', + description: + "The template ID to use for the email. This must be for a Dynamic Template and should start with a 'd-'", + type: 'string', + required: true, + dynamic: true + }, + custom_args: { + label: 'Custom Args', + description: 'Custom arguments for the email.', + type: 'object', + required: false, + additionalProperties: true, + defaultObjectUI: 'keyvalue' + }, + send_at: { + label: 'Send At', + description: + 'The time to send the email. ISO 8601 format. E.g. 2024-09-23T12:00:00Z. A send cannot be scheduled more than 72 hours in advance.', + type: 'string', + format: 'date-time', + required: false + }, + reply_to: { + label: 'Reply To', + description: 'Reply to details.', + type: 'object', + required: true, + additionalProperties: false, + defaultObjectUI: 'keyvalue', + properties: { + reply_to_equals_from: { + label: 'Reply To Equals From', + description: 'Whether "reply to" settings are the same as "from"', + type: 'boolean', + required: true + }, + email: { + label: 'Email', + description: 'The email to reply to.', + type: 'string', + required: false + }, + name: { + label: 'Name', + description: 'The name to reply to.', + type: 'string', + required: false + } + }, + default: { + reply_to_equals_from: true + } + }, + subscription_tracking: { + label: 'Subscription Tracking', + description: + 'Allows you to insert a subscription management link at the bottom of the text and HTML bodies of your email.', + type: 'object', + required: false, + additionalProperties: false, + defaultObjectUI: 'keyvalue', + properties: { + enable: { + label: 'Enabled', + description: 'Indicates if this setting is enabled', + type: 'boolean', + required: true + }, + text: { + label: 'Text', + description: 'Text to be appended to the email with the subscription tracking link.', + type: 'string', + required: false + }, + html: { + label: 'HTML', + description: 'HTML to be appended to the email with the subscription tracking link.', + type: 'string', + required: false + }, + substitution_tag: { + label: 'Substitution Tag', + description: + 'A tag that will be replaced with the unsubscribe URL. If this property is used, it will override both the text and html properties.', + type: 'string', + required: false + } + }, + default: { + enable: false + } + }, + categories: { + label: 'Categories', + description: 'Categories for the email.', + type: 'string', + multiple: true, + required: false + }, + google_analytics: { + label: 'Google Analytics', + description: 'Allows you to enable tracking provided by Google Analytics.', + type: 'object', + required: false, + defaultObjectUI: 'keyvalue', + additionalProperties: false, + properties: { + enable: { + label: 'Enabled', + description: 'Indicates if this setting is enabled', + type: 'boolean', + required: true + }, + utm_source: { + label: 'UTM Source', + description: 'Name of the referrer source. (e.g., Google, SomeDomain.com, or Marketing Email)', + type: 'string', + required: false + }, + utm_medium: { + label: 'UTM Medium', + description: 'Name of the marketing medium. (e.g., Email)', + type: 'string', + required: false + }, + utm_term: { + label: 'UTM Term', + description: 'Used to identify any paid keywords.', + type: 'string', + required: false + }, + utm_content: { + label: 'UTM Content', + description: 'Used to differentiate your campaign from advertisements.', + type: 'string', + required: false + }, + utm_campaign: { + label: 'UTM Campaign', + description: 'The name of the campaign.', + type: 'string', + required: false + } + }, + default: { + enable: true, + utm_source: { '@path': '$.context.campaign.source' }, + utm_medium: { '@path': '$.context.campaign.medium' }, + utm_term: { '@path': '$.context.campaign.term' }, + utm_content: { '@path': '$.context.campaign.content' }, + utm_campaign: { '@path': '$.context.campaign.name' } + } + }, + ip_pool_name: { + label: 'IP Pool', + description: 'Send email with an ip pool.', + type: 'string', + required: false, + dynamic: true + }, + group_id: { + label: 'Group ID', + description: 'Specify a Group ID', + type: 'string', + required: false, + dynamic: true + }, + sandbox_mode: { + label: 'Sandbox Mode', + description: + 'Sandbox Mode allows you to send a test email to ensure that your request body is valid and formatted correctly.', + type: 'boolean', + required: false + } +} diff --git a/packages/destination-actions/src/destinations/sendgrid/sendEmail/generated-types.ts b/packages/destination-actions/src/destinations/sendgrid/sendEmail/generated-types.ts new file mode 100644 index 0000000000..9b558e6b9c --- /dev/null +++ b/packages/destination-actions/src/destinations/sendgrid/sendEmail/generated-types.ts @@ -0,0 +1,169 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The domain to use for the email. This field is optional but recommended. If you do not provide a domain, Sendgrid will attempt to send the email based on the from address, and may fail if the domain in the from address is not validated. + */ + domain?: string + /** + * From details. + */ + from: { + /** + * The email address of the sender. + */ + email: string + /** + * From name of the sender, displayed to the recipient. + */ + name?: string + } + /** + * Recipient details. + */ + to: { + /** + * The email address of the recipient. + */ + email: string + /** + * The name of the recipient. + */ + name?: string + }[] + /** + * CC recipient details + */ + cc?: { + /** + * The email address of the CC recipient. + */ + email: string + /** + * The name of the CC recipient. + */ + name?: string + }[] + /** + * BCC recipient details + */ + bcc?: { + /** + * The email address of the BCC recipient. + */ + email: string + /** + * The name of the BCC recipient. + */ + name?: string + }[] + /** + * Headers for the email. + */ + headers?: { + [k: string]: unknown + } + /** + * A collection of property names that will be substituted by their corresponding property values in the subject, reply-to and content portions of a SendGrid Dynamic Template. + */ + dynamic_template_data?: { + [k: string]: unknown + } + /** + * The template ID to use for the email. This must be for a Dynamic Template and should start with a 'd-' + */ + template_id: string + /** + * Custom arguments for the email. + */ + custom_args?: { + [k: string]: unknown + } + /** + * The time to send the email. ISO 8601 format. E.g. 2024-09-23T12:00:00Z. A send cannot be scheduled more than 72 hours in advance. + */ + send_at?: string + /** + * Reply to details. + */ + reply_to: { + /** + * Whether "reply to" settings are the same as "from" + */ + reply_to_equals_from: boolean + /** + * The email to reply to. + */ + email?: string + /** + * The name to reply to. + */ + name?: string + } + /** + * Allows you to insert a subscription management link at the bottom of the text and HTML bodies of your email. + */ + subscription_tracking?: { + /** + * Indicates if this setting is enabled + */ + enable: boolean + /** + * Text to be appended to the email with the subscription tracking link. + */ + text?: string + /** + * HTML to be appended to the email with the subscription tracking link. + */ + html?: string + /** + * A tag that will be replaced with the unsubscribe URL. If this property is used, it will override both the text and html properties. + */ + substitution_tag?: string + } + /** + * Categories for the email. + */ + categories?: string[] + /** + * Allows you to enable tracking provided by Google Analytics. + */ + google_analytics?: { + /** + * Indicates if this setting is enabled + */ + enable: boolean + /** + * Name of the referrer source. (e.g., Google, SomeDomain.com, or Marketing Email) + */ + utm_source?: string + /** + * Name of the marketing medium. (e.g., Email) + */ + utm_medium?: string + /** + * Used to identify any paid keywords. + */ + utm_term?: string + /** + * Used to differentiate your campaign from advertisements. + */ + utm_content?: string + /** + * The name of the campaign. + */ + utm_campaign?: string + } + /** + * Send email with an ip pool. + */ + ip_pool_name?: string + /** + * Specify a Group ID + */ + group_id?: string + /** + * Sandbox Mode allows you to send a test email to ensure that your request body is valid and formatted correctly. + */ + sandbox_mode?: boolean +} diff --git a/packages/destination-actions/src/destinations/sendgrid/sendEmail/index.ts b/packages/destination-actions/src/destinations/sendgrid/sendEmail/index.ts new file mode 100644 index 0000000000..a081d28fbc --- /dev/null +++ b/packages/destination-actions/src/destinations/sendgrid/sendEmail/index.ts @@ -0,0 +1,31 @@ +import { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { send } from './utils' +import { fields } from './fields' +import { dynamicTemplateId, dynamicIpPoolNames, dynamicDomain, dynamicGroupId } from './dynamic-fields' + +const action: ActionDefinition = { + title: 'Send email with Dynamic Template', + description: 'Send email to recipient(s) using a Dynamic Template in Sendgrid', + fields, + dynamicFields: { + template_id: async (request) => { + return await dynamicTemplateId(request) + }, + ip_pool_name: async (request) => { + return await dynamicIpPoolNames(request) + }, + domain: async (request) => { + return await dynamicDomain(request) + }, + group_id: async (request) => { + return await dynamicGroupId(request) + } + }, + perform: async (request, { payload }) => { + return await send(request, payload) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/sendgrid/sendEmail/types.ts b/packages/destination-actions/src/destinations/sendgrid/sendEmail/types.ts new file mode 100644 index 0000000000..2887be0cf7 --- /dev/null +++ b/packages/destination-actions/src/destinations/sendgrid/sendEmail/types.ts @@ -0,0 +1,52 @@ +interface EmailDetails { + email: string + name: string | undefined +} + +interface StringObject { + [key: string]: string +} + +export interface SendEmailReq { + domain?: string + personalizations: [ + { + to: EmailDetails[] + cc?: EmailDetails[] + bcc?: EmailDetails[] + headers?: StringObject + dynamic_template_data?: { + [k: string]: unknown + } + custom_args?: StringObject + send_at?: number + } + ] + from: EmailDetails + reply_to?: EmailDetails + template_id: string + categories?: string[] + asm?: { + group_id: number + } + ip_pool_name?: string + tracking_settings?: { + subscription_tracking?: { + enable: boolean + text?: string + html?: string + substitution_tag?: string + } + ganalytics?: { + enable: boolean + utm_source?: string + utm_medium?: string + utm_term?: string + utm_content?: string + utm_campaign?: string + } + } + mail_settings?: { + sandbox_mode?: boolean + } +} diff --git a/packages/destination-actions/src/destinations/sendgrid/sendEmail/utils.ts b/packages/destination-actions/src/destinations/sendgrid/sendEmail/utils.ts new file mode 100644 index 0000000000..a62d5a7fd4 --- /dev/null +++ b/packages/destination-actions/src/destinations/sendgrid/sendEmail/utils.ts @@ -0,0 +1,129 @@ +import { RequestClient, PayloadValidationError } from '@segment/actions-core' +import type { Payload } from './generated-types' +import { SendEmailReq } from './types' +import { + RESERVED_HEADERS, + MAX_CATEGORY_LENGTH, + MIN_IP_POOL_NAME_LENGTH, + MAX_IP_POOL_NAME_LENGTH, + SEND_EMAIL_URL +} from './constants' + +export async function send(request: RequestClient, payload: Payload) { + validate(payload) + + const groupId = getIntFromString(payload.group_id) + + const json: SendEmailReq = { + personalizations: [ + { + to: payload.to.map((to) => ({ email: to.email, name: to?.name ?? undefined })), + cc: payload.cc?.map((cc) => ({ email: cc.email, name: cc?.name ?? undefined })) ?? undefined, + bcc: payload.bcc?.map((bcc) => ({ email: bcc.email, name: bcc?.name ?? undefined })) ?? undefined, + headers: + Object.entries(payload?.headers ?? {}).reduce((acc, [key, value]) => { + acc[key] = String(value) + return acc + }, {} as Record) || undefined, + dynamic_template_data: payload.dynamic_template_data, + custom_args: + payload?.custom_args && Object.keys(payload.custom_args).length > 0 + ? Object.fromEntries(Object.entries(payload.custom_args).map(([key, value]) => [key, String(value)])) + : undefined, + send_at: toUnixTS(payload.send_at) ?? undefined + } + ], + from: { email: payload.from.email, name: payload.from?.name ?? undefined }, + reply_to: { + email: (payload.reply_to.reply_to_equals_from ? payload.from.email : payload.reply_to.email) as string, + name: payload.reply_to.reply_to_equals_from ? payload.from.name : payload.reply_to.name + }, + template_id: payload.template_id, + categories: payload.categories, + asm: typeof groupId === 'number' ? { group_id: groupId } : undefined, + ip_pool_name: payload.ip_pool_name, + tracking_settings: { + subscription_tracking: payload.subscription_tracking ?? undefined, + ganalytics: payload.google_analytics ?? undefined + }, + mail_settings: typeof payload.sandbox_mode === 'boolean' ? { sandbox_mode: payload.sandbox_mode } : undefined + } + + return await request(SEND_EMAIL_URL, { + method: 'post', + json + }) +} + +function toUnixTS(date: string | undefined): number | undefined { + if (typeof date === 'undefined' || date === null || date === '') { + return undefined + } + + return new Date(date).getTime() +} + +function getIntFromString(value: string | undefined): number | undefined { + const regex = /^\d+/ + const match = regex.exec(value ?? '') + if (match) { + const maybeInt = parseInt(match[0], 10) + if (!isNaN(maybeInt)) { + return maybeInt + } + } + return undefined +} + +function validate(payload: Payload) { + if (payload.group_id && typeof getIntFromString(payload.group_id) !== 'number') { + throw new PayloadValidationError('Group ID value must be a numberic (integer) string') + } + + if (payload.domain && !payload.from.email.endsWith(payload.domain)) { + throw new PayloadValidationError('From email must be from the selected domain') + } + + if (!payload.template_id.startsWith('d-')) { + throw new PayloadValidationError( + 'Template ID must refer to a Dynamic Template. Dynamic Template IDs start with "d-"' + ) + } + + if (!payload.reply_to.reply_to_equals_from && !payload.reply_to.email) { + throw new PayloadValidationError("'Reply To >> Email' must be provided if 'Reply To Equals From' is set to true") + } + + if (Object.keys(payload?.headers ?? {}).some((key) => RESERVED_HEADERS.includes(key))) { + throw new PayloadValidationError( + `Headers cannot contain any of the following reserved headers: ${RESERVED_HEADERS.join(', ')}` + ) + } + + payload?.categories?.forEach((category) => { + if (category.length > MAX_CATEGORY_LENGTH) { + throw new PayloadValidationError( + `Category with name ${category} exceeds the max length of ${MAX_CATEGORY_LENGTH} characters` + ) + } + }) + + if (payload.send_at) { + const sendAt = new Date(payload.send_at) + const now = new Date() + if (sendAt.getTime() < now.getTime() || sendAt.getTime() > now.getTime() + 72 * 60 * 60 * 1000) { + throw new PayloadValidationError('send_at should be less than 72 hours from now') + } + } + + if ( + payload.ip_pool_name && + (payload.ip_pool_name.length >= MAX_IP_POOL_NAME_LENGTH || payload.ip_pool_name.length <= MIN_IP_POOL_NAME_LENGTH) + ) { + throw new PayloadValidationError( + `IP Pool Name should at least ${MIN_IP_POOL_NAME_LENGTH} characters and at most ${MAX_IP_POOL_NAME_LENGTH} characters in length` + ) + } + + return +}