From 9af68abcbc13ffdcb8748d6f919866a1fc0eb9b6 Mon Sep 17 00:00:00 2001 From: Md Mozammil Khan Date: Fri, 3 Jul 2026 17:46:38 +0530 Subject: [PATCH] Add Mailchimp (Actions) cloud destination New cloud-mode destination for Mailchimp Marketing API v3.0 with native v2 Journeys compatibility. Supports two P0 actions: - Add or Update Audience Member: upserts a subscriber via PUT /lists/{list_id}/members/{subscriber_hash}, mapping traits to merge fields. Uses status_if_new to protect existing consent status. Batching via POST /lists/{list_id} (up to 500 members, update_existing). - Add or Remove Member Tags: applies/removes tags via POST /lists/{list_id}/members/{subscriber_hash}/tags with active/inactive status, plus tags_to_add / tags_to_remove convenience inputs. Surfaces a clear error when the member does not exist (404). Auth is API key over HTTP Basic Auth; the datacenter prefix is resolved from the API key suffix and overridable via a setting. Subscriber hash is the MD5 of the lowercased email (via the shared hashing util). NOTE: not registered in destinations/index.ts yet - the production metadata ID is assigned when the destination is created in Segment production and must match across environments. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__snapshots__/snapshot.test.ts.snap | 55 ++ .../actions-mailchimp/__tests__/index.test.ts | 34 + .../__tests__/snapshot.test.ts | 71 ++ .../addOrRemoveTags/__tests__/index.test.ts | 74 ++ .../addOrRemoveTags/generated-types.ts | 33 + .../addOrRemoveTags/index.ts | 78 +++ .../addOrUpdateMember/__tests__/index.test.ts | 87 +++ .../addOrUpdateMember/generated-types.ts | 50 ++ .../addOrUpdateMember/index.ts | 113 ++++ .../actions-mailchimp/constants.ts | 32 + .../actions-mailchimp/generated-types.ts | 16 + .../destinations/actions-mailchimp/index.ts | 80 +++ .../actions-mailchimp/metadata.json | 634 ++++++++++++++++++ .../destinations/actions-mailchimp/types.ts | 57 ++ .../destinations/actions-mailchimp/utils.ts | 156 +++++ 15 files changed, 1570 insertions(+) create mode 100644 packages/destination-actions/src/destinations/actions-mailchimp/__tests__/__snapshots__/snapshot.test.ts.snap create mode 100644 packages/destination-actions/src/destinations/actions-mailchimp/__tests__/index.test.ts create mode 100644 packages/destination-actions/src/destinations/actions-mailchimp/__tests__/snapshot.test.ts create mode 100644 packages/destination-actions/src/destinations/actions-mailchimp/addOrRemoveTags/__tests__/index.test.ts create mode 100644 packages/destination-actions/src/destinations/actions-mailchimp/addOrRemoveTags/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/actions-mailchimp/addOrRemoveTags/index.ts create mode 100644 packages/destination-actions/src/destinations/actions-mailchimp/addOrUpdateMember/__tests__/index.test.ts create mode 100644 packages/destination-actions/src/destinations/actions-mailchimp/addOrUpdateMember/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/actions-mailchimp/addOrUpdateMember/index.ts create mode 100644 packages/destination-actions/src/destinations/actions-mailchimp/constants.ts create mode 100644 packages/destination-actions/src/destinations/actions-mailchimp/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/actions-mailchimp/index.ts create mode 100644 packages/destination-actions/src/destinations/actions-mailchimp/metadata.json create mode 100644 packages/destination-actions/src/destinations/actions-mailchimp/types.ts create mode 100644 packages/destination-actions/src/destinations/actions-mailchimp/utils.ts diff --git a/packages/destination-actions/src/destinations/actions-mailchimp/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/actions-mailchimp/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 00000000000..c256fa39f2a --- /dev/null +++ b/packages/destination-actions/src/destinations/actions-mailchimp/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,55 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for actions-mailchimp destination: addOrRemoveTags action - all fields 1`] = ` +Object { + "tags": Array [ + Object { + "name": "f&(J*rH9Ln#F6", + "status": "inactive", + }, + Object { + "name": "f&(J*rH9Ln#F6", + "status": "active", + }, + Object { + "name": "f&(J*rH9Ln#F6", + "status": "inactive", + }, + ], +} +`; + +exports[`Testing snapshot for actions-mailchimp destination: addOrRemoveTags action - required fields 1`] = ` +Object { + "tags": Array [ + Object { + "name": "Test Event", + "status": "active", + }, + ], +} +`; + +exports[`Testing snapshot for actions-mailchimp destination: addOrUpdateMember action - all fields 1`] = ` +Object { + "email_address": "ek@sopperi.ru", + "language": "A!F#*Q0UC", + "merge_fields": Object { + "testType": "A!F#*Q0UC", + }, + "status": "unsubscribed", + "status_if_new": "unsubscribed", + "tags": Array [ + "A!F#*Q0UC", + ], + "vip": true, +} +`; + +exports[`Testing snapshot for actions-mailchimp destination: addOrUpdateMember action - required fields 1`] = ` +Object { + "email_address": "ek@sopperi.ru", + "language": "en-US", + "status_if_new": "unsubscribed", +} +`; diff --git a/packages/destination-actions/src/destinations/actions-mailchimp/__tests__/index.test.ts b/packages/destination-actions/src/destinations/actions-mailchimp/__tests__/index.test.ts new file mode 100644 index 00000000000..4277456689d --- /dev/null +++ b/packages/destination-actions/src/destinations/actions-mailchimp/__tests__/index.test.ts @@ -0,0 +1,34 @@ +import nock from 'nock' +import { createTestIntegration } from '@segment/actions-core' +import Destination from '../index' + +const testDestination = createTestIntegration(Destination) + +describe('actions-mailchimp', () => { + afterEach(() => { + nock.cleanAll() + }) + + describe('testAuthentication', () => { + it('validates the API key against the resolved datacenter root', async () => { + nock('https://us6.api.mailchimp.com/3.0').get('/').reply(200, {}) + + const settings = { apiKey: 'test-api-key-us6', audienceId: 'list123' } + await expect(testDestination.testAuthentication(settings)).resolves.not.toThrow() + }) + + it('honors an explicit datacenter override', async () => { + nock('https://us1.api.mailchimp.com/3.0').get('/').reply(200, {}) + + const settings = { apiKey: 'test-api-key-us6', dataCenter: 'us1', audienceId: 'list123' } + await expect(testDestination.testAuthentication(settings)).resolves.not.toThrow() + }) + + it('throws when authentication fails', async () => { + nock('https://us6.api.mailchimp.com/3.0').get('/').reply(401, { title: 'API Key Invalid' }) + + const settings = { apiKey: 'test-api-key-us6', audienceId: 'list123' } + await expect(testDestination.testAuthentication(settings)).rejects.toThrow() + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/actions-mailchimp/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/actions-mailchimp/__tests__/snapshot.test.ts new file mode 100644 index 00000000000..9cf4b6db1c7 --- /dev/null +++ b/packages/destination-actions/src/destinations/actions-mailchimp/__tests__/snapshot.test.ts @@ -0,0 +1,71 @@ +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 destinationSlug = 'actions-mailchimp' + +describe(`Testing snapshot for ${destinationSlug} destination:`, () => { + for (const actionSlug in destination.actions) { + it(`${actionSlug} action - required fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + 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 }) + const responses = await testDestination.testAction(actionSlug, { + event, + mapping: event.properties, + settings: settingsData, + auth: undefined, + useDefaultMappings: true + }) + + 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(`${actionSlug} action - all fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + 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 }) + const responses = await testDestination.testAction(actionSlug, { + event, + mapping: event.properties, + settings: settingsData, + auth: undefined, + useDefaultMappings: true + }) + + 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/actions-mailchimp/addOrRemoveTags/__tests__/index.test.ts b/packages/destination-actions/src/destinations/actions-mailchimp/addOrRemoveTags/__tests__/index.test.ts new file mode 100644 index 00000000000..fdf98c49ae4 --- /dev/null +++ b/packages/destination-actions/src/destinations/actions-mailchimp/addOrRemoveTags/__tests__/index.test.ts @@ -0,0 +1,74 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' +import { getSubscriberHash } from '../../utils' + +const testDestination = createTestIntegration(Destination) + +const settings = { + apiKey: 'test-api-key-us6', + audienceId: 'list123' +} + +const BASE_URL = 'https://us6.api.mailchimp.com/3.0' + +describe('actions-mailchimp addOrRemoveTags', () => { + afterEach(() => { + nock.cleanAll() + }) + + it('posts active and inactive tags for the member', async () => { + const email = 'jane.doe@example.com' + const hash = getSubscriberHash(email) + + nock(BASE_URL) + .post(`/lists/list123/members/${hash}/tags`, { + tags: [ + { name: 'Pro Plan', status: 'active' }, + { name: 'Trial Converted', status: 'active' }, + { name: 'Free Trial', status: 'inactive' } + ] + }) + .reply(204) + + const event = createTestEvent({ + type: 'track', + event: 'Trial Converted', + properties: { email } + }) + + const responses = await testDestination.testAction('addOrRemoveTags', { + event, + settings, + mapping: { + email: { '@path': '$.properties.email' }, + tags: [{ name: 'Pro Plan', status: 'active' }], + tags_to_add: { '@path': '$.event' }, + tags_to_remove: ['Free Trial'] + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(204) + }) + + it('surfaces a clear error when the member does not exist (404)', async () => { + const email = 'ghost@example.com' + const hash = getSubscriberHash(email) + + nock(BASE_URL).post(`/lists/list123/members/${hash}/tags`).reply(404, { title: 'Resource Not Found' }) + + const event = createTestEvent({ type: 'track', event: 'Some Event', properties: { email } }) + + await expect( + testDestination.testAction('addOrRemoveTags', { + event, + settings, + mapping: { + email: { '@path': '$.properties.email' }, + tags_to_add: ['Some Event'] + } + }) + ).rejects.toThrow('Audience member not found') + }) +}) diff --git a/packages/destination-actions/src/destinations/actions-mailchimp/addOrRemoveTags/generated-types.ts b/packages/destination-actions/src/destinations/actions-mailchimp/addOrRemoveTags/generated-types.ts new file mode 100644 index 00000000000..0d5bd53d691 --- /dev/null +++ b/packages/destination-actions/src/destinations/actions-mailchimp/addOrRemoveTags/generated-types.ts @@ -0,0 +1,33 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The email address of the audience member whose tags will be updated. Used to identify the member (hashed for the API endpoint). + */ + email: string + /** + * The Mailchimp Audience (List) ID the member belongs to. Defaults to the Audience ID configured in settings. + */ + list_id?: string + /** + * Tags to apply or remove, each with a name and a status of "active" (add) or "inactive" (remove). + */ + tags?: { + /** + * The name of the tag. + */ + name: string + /** + * Whether to add ("active") or remove ("inactive") the tag. + */ + status: string + }[] + /** + * A convenience list of tag names to add (status "active"). Defaults to the event name. + */ + tags_to_add?: string[] + /** + * A convenience list of tag names to remove (status "inactive"). + */ + tags_to_remove?: string[] +} diff --git a/packages/destination-actions/src/destinations/actions-mailchimp/addOrRemoveTags/index.ts b/packages/destination-actions/src/destinations/actions-mailchimp/addOrRemoveTags/index.ts new file mode 100644 index 00000000000..846747bb91c --- /dev/null +++ b/packages/destination-actions/src/destinations/actions-mailchimp/addOrRemoveTags/index.ts @@ -0,0 +1,78 @@ +import { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { updateMemberTags } from '../utils' +import { TAG_STATUSES } from '../constants' + +const action: ActionDefinition = { + title: 'Add or Remove Member Tags', + description: + 'Apply or remove Mailchimp audience tags on an existing member based on behavioral events. The member must already exist in the audience.', + defaultSubscription: 'type = "track"', + fields: { + email: { + label: 'Email Address', + description: + 'The email address of the audience member whose tags will be updated. Used to identify the member (hashed for the API endpoint).', + type: 'string', + format: 'email', + required: true, + default: { + '@if': { + exists: { '@path': '$.properties.email' }, + then: { '@path': '$.properties.email' }, + else: { '@path': '$.traits.email' } + } + } + }, + list_id: { + label: 'Audience ID', + description: + 'The Mailchimp Audience (List) ID the member belongs to. Defaults to the Audience ID configured in settings.', + type: 'string', + required: false + }, + tags: { + label: 'Tags', + description: 'Tags to apply or remove, each with a name and a status of "active" (add) or "inactive" (remove).', + type: 'object', + multiple: true, + required: false, + properties: { + name: { + label: 'Tag Name', + description: 'The name of the tag.', + type: 'string', + required: true + }, + status: { + label: 'Status', + description: 'Whether to add ("active") or remove ("inactive") the tag.', + type: 'string', + required: true, + choices: TAG_STATUSES.map((value) => ({ value, label: value })) + } + } + }, + tags_to_add: { + label: 'Tags to Add', + description: 'A convenience list of tag names to add (status "active"). Defaults to the event name.', + type: 'string', + multiple: true, + required: false, + default: { '@path': '$.event' } + }, + tags_to_remove: { + label: 'Tags to Remove', + description: 'A convenience list of tag names to remove (status "inactive").', + type: 'string', + multiple: true, + required: false + } + }, + perform: (request, { settings, payload }) => { + return updateMemberTags(request, settings, payload) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/actions-mailchimp/addOrUpdateMember/__tests__/index.test.ts b/packages/destination-actions/src/destinations/actions-mailchimp/addOrUpdateMember/__tests__/index.test.ts new file mode 100644 index 00000000000..6935dc2cc32 --- /dev/null +++ b/packages/destination-actions/src/destinations/actions-mailchimp/addOrUpdateMember/__tests__/index.test.ts @@ -0,0 +1,87 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' +import { getSubscriberHash } from '../../utils' + +const testDestination = createTestIntegration(Destination) + +const settings = { + apiKey: 'test-api-key-us6', + audienceId: 'list123' +} + +const BASE_URL = 'https://us6.api.mailchimp.com/3.0' + +describe('actions-mailchimp addOrUpdateMember', () => { + afterEach(() => { + nock.cleanAll() + }) + + it('upserts a member via PUT with the MD5 subscriber hash', async () => { + const email = 'jane.doe@example.com' + const hash = getSubscriberHash(email) + + nock(BASE_URL) + .put(`/lists/list123/members/${hash}`, { + email_address: email, + status_if_new: 'subscribed', + merge_fields: { FNAME: 'Jane', LNAME: 'Doe' } + }) + .reply(200, { id: hash, email_address: email, status: 'subscribed' }) + + const event = createTestEvent({ + type: 'identify', + traits: { email, firstName: 'Jane', lastName: 'Doe' } + }) + + const responses = await testDestination.testAction('addOrUpdateMember', { + event, + settings, + mapping: { + email_address: { '@path': '$.traits.email' }, + status_if_new: 'subscribed', + merge_fields: { FNAME: 'Jane', LNAME: 'Doe' } + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) + + it('throws a PayloadValidationError when the email is missing', async () => { + const event = createTestEvent({ type: 'identify', traits: {} }) + + await expect( + testDestination.testAction('addOrUpdateMember', { + event, + settings, + mapping: { + email_address: '', + status_if_new: 'subscribed' + } + }) + ).rejects.toThrow("The root value is missing the required field 'email_address'.") + }) + + it('sends a batch of members to POST /lists/{list_id}', async () => { + nock(BASE_URL).post('/lists/list123').reply(200, { total_created: 2, total_updated: 0, error_count: 0, errors: [] }) + + const events = [ + createTestEvent({ type: 'identify', traits: { email: 'a@example.com' } }), + createTestEvent({ type: 'identify', traits: { email: 'b@example.com' } }) + ] + + const responses = await testDestination.testBatchAction('addOrUpdateMember', { + events, + settings, + mapping: { + email_address: { '@path': '$.traits.email' }, + status_if_new: 'subscribed', + enable_batching: true + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) +}) diff --git a/packages/destination-actions/src/destinations/actions-mailchimp/addOrUpdateMember/generated-types.ts b/packages/destination-actions/src/destinations/actions-mailchimp/addOrUpdateMember/generated-types.ts new file mode 100644 index 00000000000..e8b010ef60f --- /dev/null +++ b/packages/destination-actions/src/destinations/actions-mailchimp/addOrUpdateMember/generated-types.ts @@ -0,0 +1,50 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The email address of the audience member. Used to identify the member (hashed for the API endpoint). + */ + email_address: string + /** + * The Mailchimp Audience (List) ID to add the member to. Defaults to the Audience ID configured in settings. + */ + list_id?: string + /** + * The subscription status to apply only when creating a new member. Protects the consent status of existing members. + */ + status_if_new: string + /** + * The subscription status to apply to the member. Applies to existing members too — use with care to respect consent. + */ + status?: string + /** + * Mailchimp merge fields (e.g. FNAME, LNAME, and custom fields), keyed by the Mailchimp merge tag. + */ + merge_fields?: { + [k: string]: unknown + } + /** + * The member's language (e.g. en). + */ + language?: string + /** + * Whether the member is a VIP. + */ + vip?: boolean + /** + * Tags to apply to the member on creation or update. + */ + tags?: string[] + /** + * When enabled, sends events to Mailchimp in batches. + */ + enable_batching?: boolean + /** + * Maximum number of events to include in each batch. Actual batch sizes may be lower. + */ + batch_size?: number + /** + * The keys to use for batching the events. + */ + batch_keys?: string[] +} diff --git a/packages/destination-actions/src/destinations/actions-mailchimp/addOrUpdateMember/index.ts b/packages/destination-actions/src/destinations/actions-mailchimp/addOrUpdateMember/index.ts new file mode 100644 index 00000000000..b3d261f1908 --- /dev/null +++ b/packages/destination-actions/src/destinations/actions-mailchimp/addOrUpdateMember/index.ts @@ -0,0 +1,113 @@ +import { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { upsertMember, upsertMemberBatch } from '../utils' +import { SUBSCRIPTION_STATUSES, DEFAULT_BATCH_SIZE } from '../constants' + +const action: ActionDefinition = { + title: 'Add or Update Audience Member', + description: + 'Add a new member to a Mailchimp audience or update an existing member, mapping Segment traits to merge fields.', + defaultSubscription: 'type = "identify"', + fields: { + email_address: { + label: 'Email Address', + description: + 'The email address of the audience member. Used to identify the member (hashed for the API endpoint).', + type: 'string', + format: 'email', + required: true, + default: { + '@if': { + exists: { '@path': '$.traits.email' }, + then: { '@path': '$.traits.email' }, + else: { '@path': '$.properties.email' } + } + } + }, + list_id: { + label: 'Audience ID', + description: + 'The Mailchimp Audience (List) ID to add the member to. Defaults to the Audience ID configured in settings.', + type: 'string', + required: false + }, + status_if_new: { + label: 'Status (if new)', + description: + 'The subscription status to apply only when creating a new member. Protects the consent status of existing members.', + type: 'string', + required: true, + default: 'subscribed', + choices: SUBSCRIPTION_STATUSES.map((value) => ({ value, label: value })) + }, + status: { + label: 'Status', + description: + 'The subscription status to apply to the member. Applies to existing members too — use with care to respect consent.', + type: 'string', + required: false, + choices: SUBSCRIPTION_STATUSES.map((value) => ({ value, label: value })) + }, + merge_fields: { + label: 'Merge Fields', + description: 'Mailchimp merge fields (e.g. FNAME, LNAME, and custom fields), keyed by the Mailchimp merge tag.', + type: 'object', + required: false, + defaultObjectUI: 'keyvalue' + }, + language: { + label: 'Language', + description: "The member's language (e.g. en).", + type: 'string', + required: false, + default: { '@path': '$.context.locale' } + }, + vip: { + label: 'VIP', + description: 'Whether the member is a VIP.', + type: 'boolean', + required: false + }, + tags: { + label: 'Tags', + description: 'Tags to apply to the member on creation or update.', + type: 'string', + multiple: true, + required: false + }, + enable_batching: { + label: 'Batch Data', + description: 'When enabled, sends events to Mailchimp in batches.', + type: 'boolean', + default: true + }, + batch_size: { + label: 'Batch Size', + description: 'Maximum number of events to include in each batch. Actual batch sizes may be lower.', + type: 'number', + required: false, + unsafe_hidden: true, + default: DEFAULT_BATCH_SIZE, + minimum: 1, + maximum: DEFAULT_BATCH_SIZE + }, + batch_keys: { + label: 'Batch Keys', + description: 'The keys to use for batching the events.', + type: 'string', + unsafe_hidden: true, + required: false, + multiple: true, + default: ['list_id'] + } + }, + perform: (request, { settings, payload }) => { + return upsertMember(request, settings, payload) + }, + performBatch: (request, { settings, payload }) => { + return upsertMemberBatch(request, settings, payload) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/actions-mailchimp/constants.ts b/packages/destination-actions/src/destinations/actions-mailchimp/constants.ts new file mode 100644 index 00000000000..4ba17e9fc74 --- /dev/null +++ b/packages/destination-actions/src/destinations/actions-mailchimp/constants.ts @@ -0,0 +1,32 @@ +// Mailchimp Marketing API v3.0 constants + +// Base URL is datacenter-specific. The datacenter prefix (e.g. `us6`) is the +// suffix of the API key after the final `-`, or supplied explicitly via settings. +export const getBaseUrl = (dataCenter: string): string => `https://${dataCenter}.api.mailchimp.com/3.0` + +// Resolve the datacenter prefix from the API key suffix (e.g. `abc123-us6` -> `us6`). +export const resolveDataCenter = (apiKey: string, override?: string): string => { + if (override && override.trim() !== '') { + return override.trim() + } + const parts = apiKey.split('-') + return parts[parts.length - 1] +} + +// Endpoint path builders (relative to the datacenter base URL). +export const PING_PATH = '/' +export const memberPath = (listId: string, subscriberHash: string): string => + `/lists/${listId}/members/${subscriberHash}` +export const batchMembersPath = (listId: string): string => `/lists/${listId}` +export const memberTagsPath = (listId: string, subscriberHash: string): string => + `/lists/${listId}/members/${subscriberHash}/tags` + +// Subscription status enum accepted by the Mailchimp member endpoints. +export const SUBSCRIPTION_STATUSES = ['subscribed', 'unsubscribed', 'pending', 'cleaned'] as const +export type SubscriptionStatus = typeof SUBSCRIPTION_STATUSES[number] + +// Tag status enum accepted by the member tags endpoint. +export const TAG_STATUSES = ['active', 'inactive'] as const +export type TagStatus = typeof TAG_STATUSES[number] + +export const DEFAULT_BATCH_SIZE = 500 diff --git a/packages/destination-actions/src/destinations/actions-mailchimp/generated-types.ts b/packages/destination-actions/src/destinations/actions-mailchimp/generated-types.ts new file mode 100644 index 00000000000..0150a45e08f --- /dev/null +++ b/packages/destination-actions/src/destinations/actions-mailchimp/generated-types.ts @@ -0,0 +1,16 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Your Mailchimp Marketing API key. Used as the password for HTTP Basic Auth. Find it under Account > Extras > API keys. + */ + apiKey: string + /** + * The datacenter prefix for your Mailchimp account (e.g. us6). If left blank, it is resolved automatically from the suffix of your API key. + */ + dataCenter?: string + /** + * The default Mailchimp Audience (List) ID to send events to. Find it under Audience > Settings > Audience name and defaults ("Audience ID"). + */ + audienceId: string +} diff --git a/packages/destination-actions/src/destinations/actions-mailchimp/index.ts b/packages/destination-actions/src/destinations/actions-mailchimp/index.ts new file mode 100644 index 00000000000..4a2c2a9ea11 --- /dev/null +++ b/packages/destination-actions/src/destinations/actions-mailchimp/index.ts @@ -0,0 +1,80 @@ +import { DestinationDefinition, defaultValues } from '@segment/actions-core' +import type { Settings } from './generated-types' +import { getBaseUrl, resolveDataCenter, PING_PATH } from './constants' + +import addOrUpdateMember from './addOrUpdateMember' +import addOrRemoveTags from './addOrRemoveTags' + +const destination: DestinationDefinition = { + name: 'Mailchimp (Actions)', + slug: 'actions-mailchimp', + mode: 'cloud', + description: 'Sync Segment audience members and behavioral tags to Mailchimp audiences (lists).', + authentication: { + scheme: 'custom', + fields: { + apiKey: { + label: 'API Key', + description: + 'Your Mailchimp Marketing API key. Used as the password for HTTP Basic Auth. Find it under Account > Extras > API keys.', + type: 'password', + required: true + }, + dataCenter: { + label: 'Datacenter Prefix', + description: + 'The datacenter prefix for your Mailchimp account (e.g. us6). If left blank, it is resolved automatically from the suffix of your API key.', + type: 'string', + required: false + }, + audienceId: { + label: 'Audience ID', + description: + 'The default Mailchimp Audience (List) ID to send events to. Find it under Audience > Settings > Audience name and defaults ("Audience ID").', + type: 'string', + required: true + } + }, + testAuthentication: (request, { settings }) => { + const baseUrl = getBaseUrl(resolveDataCenter(settings.apiKey, settings.dataCenter)) + return request(`${baseUrl}${PING_PATH}`, { + method: 'get' + }) + } + }, + + extendRequest({ settings }) { + // Mailchimp uses HTTP Basic Auth: any non-empty username + the API key as password. + const token = Buffer.from(`anystring:${settings.apiKey}`).toString('base64') + return { + headers: { + Authorization: `Basic ${token}`, + 'Content-Type': 'application/json' + } + } + }, + + presets: [ + { + name: 'Add or Update Audience Member', + subscribe: 'type = "identify"', + partnerAction: 'addOrUpdateMember', + mapping: defaultValues(addOrUpdateMember.fields), + type: 'automatic' + }, + { + name: 'Add or Remove Member Tags', + subscribe: 'type = "track"', + partnerAction: 'addOrRemoveTags', + mapping: defaultValues(addOrRemoveTags.fields), + type: 'automatic' + } + ], + + actions: { + addOrUpdateMember, + addOrRemoveTags + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/actions-mailchimp/metadata.json b/packages/destination-actions/src/destinations/actions-mailchimp/metadata.json new file mode 100644 index 00000000000..7a0061245c4 --- /dev/null +++ b/packages/destination-actions/src/destinations/actions-mailchimp/metadata.json @@ -0,0 +1,634 @@ +{ + "slug": "actions-mailchimp", + "name": "Mailchimp (Actions)", + "mode": "cloud", + "description": "Sync Segment audience members and behavioral tags to Mailchimp audiences (lists).", + "authentication": { + "scheme": "custom", + "fields": { + "apiKey": { + "label": "API Key", + "description": "Your Mailchimp Marketing API key. Used as the password for HTTP Basic Auth. Find it under Account > Extras > API keys.", + "type": "password", + "required": true, + "multiple": false, + "choices": null, + "default": null, + "depends_on": null + }, + "dataCenter": { + "label": "Datacenter Prefix", + "description": "The datacenter prefix for your Mailchimp account (e.g. us6). If left blank, it is resolved automatically from the suffix of your API key.", + "type": "string", + "required": false, + "multiple": false, + "choices": null, + "default": null, + "depends_on": null + }, + "audienceId": { + "label": "Audience ID", + "description": "The default Mailchimp Audience (List) ID to send events to. Find it under Audience > Settings > Audience name and defaults (\"Audience ID\").", + "type": "string", + "required": true, + "multiple": false, + "choices": null, + "default": null, + "depends_on": null + } + } + }, + "audienceConfig": null, + "actions": { + "addOrUpdateMember": { + "title": "Add or Update Audience Member", + "description": "Add a new member to a Mailchimp audience or update an existing member, mapping Segment traits to merge fields.", + "platform": "cloud", + "defaultSubscription": "type = \"identify\"", + "hidden": false, + "hasPerformBatch": true, + "syncMode": null, + "hooks": null, + "dynamicFields": null, + "fields": { + "email_address": { + "label": "Email Address", + "description": "The email address of the audience member. Used to identify the member (hashed for the API endpoint).", + "type": "string", + "required": true, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@if": { + "exists": { + "@path": "$.traits.email" + }, + "then": { + "@path": "$.traits.email" + }, + "else": { + "@path": "$.properties.email" + } + } + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": "email", + "additionalProperties": false + }, + "list_id": { + "label": "Audience ID", + "description": "The Mailchimp Audience (List) ID to add the member to. Defaults to the Audience ID configured in settings.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "status_if_new": { + "label": "Status (if new)", + "description": "The subscription status to apply only when creating a new member. Protects the consent status of existing members.", + "type": "string", + "required": true, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": "subscribed", + "choices": [ + { + "value": "subscribed", + "label": "subscribed" + }, + { + "value": "unsubscribed", + "label": "unsubscribed" + }, + { + "value": "pending", + "label": "pending" + }, + { + "value": "cleaned", + "label": "cleaned" + } + ], + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "status": { + "label": "Status", + "description": "The subscription status to apply to the member. Applies to existing members too — use with care to respect consent.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": [ + { + "value": "subscribed", + "label": "subscribed" + }, + { + "value": "unsubscribed", + "label": "unsubscribed" + }, + { + "value": "pending", + "label": "pending" + }, + { + "value": "cleaned", + "label": "cleaned" + } + ], + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "merge_fields": { + "label": "Merge Fields", + "description": "Mailchimp merge fields (e.g. FNAME, LNAME, and custom fields), keyed by the Mailchimp merge tag.", + "type": "object", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": "keyvalue", + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "language": { + "label": "Language", + "description": "The member's language (e.g. en).", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.context.locale" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "vip": { + "label": "VIP", + "description": "Whether the member is a VIP.", + "type": "boolean", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "tags": { + "label": "Tags", + "description": "Tags to apply to the member on creation or update.", + "type": "string", + "required": false, + "multiple": true, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "enable_batching": { + "label": "Batch Data", + "description": "When enabled, sends events to Mailchimp in batches.", + "type": "boolean", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": true, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "batch_size": { + "label": "Batch Size", + "description": "Maximum number of events to include in each batch. Actual batch sizes may be lower.", + "type": "number", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": 500, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": true, + "minimum": 1, + "maximum": 500, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "batch_keys": { + "label": "Batch Keys", + "description": "The keys to use for batching the events.", + "type": "string", + "required": false, + "multiple": true, + "allowNull": false, + "dynamic": false, + "default": [ + "list_id" + ], + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": true, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + } + } + }, + "addOrRemoveTags": { + "title": "Add or Remove Member Tags", + "description": "Apply or remove Mailchimp audience tags on an existing member based on behavioral events. The member must already exist in the audience.", + "platform": "cloud", + "defaultSubscription": "type = \"track\"", + "hidden": false, + "hasPerformBatch": false, + "syncMode": null, + "hooks": null, + "dynamicFields": null, + "fields": { + "email": { + "label": "Email Address", + "description": "The email address of the audience member whose tags will be updated. Used to identify the member (hashed for the API endpoint).", + "type": "string", + "required": true, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@if": { + "exists": { + "@path": "$.properties.email" + }, + "then": { + "@path": "$.properties.email" + }, + "else": { + "@path": "$.traits.email" + } + } + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": "email", + "additionalProperties": false + }, + "list_id": { + "label": "Audience ID", + "description": "The Mailchimp Audience (List) ID the member belongs to. Defaults to the Audience ID configured in settings.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "tags": { + "label": "Tags", + "description": "Tags to apply or remove, each with a name and a status of \"active\" (add) or \"inactive\" (remove).", + "type": "object", + "required": false, + "multiple": true, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": { + "name": { + "label": "Tag Name", + "description": "The name of the tag.", + "type": "string", + "required": true, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "status": { + "label": "Status", + "description": "Whether to add (\"active\") or remove (\"inactive\") the tag.", + "type": "string", + "required": true, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": [ + { + "value": "active", + "label": "active" + }, + { + "value": "inactive", + "label": "inactive" + } + ], + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + } + }, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "tags_to_add": { + "label": "Tags to Add", + "description": "A convenience list of tag names to add (status \"active\"). Defaults to the event name.", + "type": "string", + "required": false, + "multiple": true, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.event" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "tags_to_remove": { + "label": "Tags to Remove", + "description": "A convenience list of tag names to remove (status \"inactive\").", + "type": "string", + "required": false, + "multiple": true, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + } + } + } + }, + "presets": [ + { + "name": "Add or Update Audience Member", + "type": "automatic", + "partnerAction": "addOrUpdateMember", + "subscribe": "type = \"identify\"", + "mapping": { + "email_address": { + "@if": { + "exists": { + "@path": "$.traits.email" + }, + "then": { + "@path": "$.traits.email" + }, + "else": { + "@path": "$.properties.email" + } + } + }, + "status_if_new": "subscribed", + "language": { + "@path": "$.context.locale" + }, + "enable_batching": true, + "batch_size": 500, + "batch_keys": [ + "list_id" + ] + }, + "eventSlug": null + }, + { + "name": "Add or Remove Member Tags", + "type": "automatic", + "partnerAction": "addOrRemoveTags", + "subscribe": "type = \"track\"", + "mapping": { + "email": { + "@if": { + "exists": { + "@path": "$.properties.email" + }, + "then": { + "@path": "$.properties.email" + }, + "else": { + "@path": "$.traits.email" + } + } + }, + "tags_to_add": { + "@path": "$.event" + } + }, + "eventSlug": null + } + ] +} diff --git a/packages/destination-actions/src/destinations/actions-mailchimp/types.ts b/packages/destination-actions/src/destinations/actions-mailchimp/types.ts new file mode 100644 index 00000000000..a1e4030a41a --- /dev/null +++ b/packages/destination-actions/src/destinations/actions-mailchimp/types.ts @@ -0,0 +1,57 @@ +import type { SubscriptionStatus, TagStatus } from './constants' + +// ---- Add or Update Audience Member ---- + +// Request body for PUT /lists/{list_id}/members/{subscriber_hash} +export interface UpsertMemberRequest { + email_address: string + status_if_new: SubscriptionStatus + status?: SubscriptionStatus + merge_fields?: Record + language?: string + vip?: boolean + tags?: string[] +} + +// A single member entry in the batch upsert body. +export interface BatchMemberOperation extends UpsertMemberRequest {} + +// Request body for POST /lists/{list_id} (batch subscribe/unsubscribe). +export interface BatchMembersRequest { + members: BatchMemberOperation[] + update_existing: boolean +} + +// ---- Add or Remove Member Tags ---- + +export interface MemberTag { + name: string + status: TagStatus +} + +// Request body for POST /lists/{list_id}/members/{subscriber_hash}/tags +export interface MemberTagsRequest { + tags: MemberTag[] +} + +// ---- Responses ---- + +export interface MemberResponse { + id?: string + email_address?: string + status?: string +} + +// A single error entry returned in the batch member upsert response. +export interface BatchMemberError { + email_address?: string + error?: string + error_code?: string +} + +export interface BatchMembersResponse { + total_created?: number + total_updated?: number + error_count?: number + errors?: BatchMemberError[] +} diff --git a/packages/destination-actions/src/destinations/actions-mailchimp/utils.ts b/packages/destination-actions/src/destinations/actions-mailchimp/utils.ts new file mode 100644 index 00000000000..b534532ebb3 --- /dev/null +++ b/packages/destination-actions/src/destinations/actions-mailchimp/utils.ts @@ -0,0 +1,156 @@ +import { RequestClient, PayloadValidationError, APIError, ModifiedResponse } from '@segment/actions-core' +import type { Settings } from './generated-types' +import type { Payload as MemberPayload } from './addOrUpdateMember/generated-types' +import type { Payload as TagsPayload } from './addOrRemoveTags/generated-types' +import { processHashing } from '../../lib/hashing-utils' +import { + getBaseUrl, + resolveDataCenter, + memberPath, + batchMembersPath, + memberTagsPath, + DEFAULT_BATCH_SIZE +} from './constants' +import type { + UpsertMemberRequest, + BatchMembersRequest, + BatchMemberOperation, + MemberTagsRequest, + MemberTag, + MemberResponse, + BatchMembersResponse +} from './types' + +// Mailchimp uses the MD5 hash of the lowercased email as the subscriber hash in URLs. +export const getSubscriberHash = (email: string): string => + processHashing(email, 'md5', 'hex', (value) => value.trim().toLowerCase()) + +const baseUrlFor = (settings: Settings): string => getBaseUrl(resolveDataCenter(settings.apiKey, settings.dataCenter)) + +const resolveEmail = (email?: string): string => { + if (!email || email.trim() === '') { + throw new PayloadValidationError('An email address is required to identify the audience member.') + } + return email +} + +const resolveListId = (settings: Settings, payloadListId?: string): string => { + const listId = payloadListId && payloadListId.trim() !== '' ? payloadListId : settings.audienceId + if (!listId || listId.trim() === '') { + throw new PayloadValidationError('A Mailchimp Audience (List) ID is required.') + } + return listId +} + +// ---- Add or Update Audience Member ---- + +const buildMemberBody = (payload: MemberPayload): UpsertMemberRequest => { + const body: UpsertMemberRequest = { + email_address: payload.email_address, + status_if_new: payload.status_if_new + } + if (payload.status) body.status = payload.status + if (payload.merge_fields && Object.keys(payload.merge_fields).length > 0) body.merge_fields = payload.merge_fields + if (payload.language) body.language = payload.language + if (typeof payload.vip === 'boolean') body.vip = payload.vip + if (payload.tags && payload.tags.length > 0) body.tags = payload.tags + return body +} + +export const upsertMember = async ( + request: RequestClient, + settings: Settings, + payload: MemberPayload +): Promise> => { + const email = resolveEmail(payload.email_address) + const listId = resolveListId(settings, payload.list_id) + const subscriberHash = getSubscriberHash(email) + const url = `${baseUrlFor(settings)}${memberPath(listId, subscriberHash)}` + + return request(url, { + method: 'PUT', + json: buildMemberBody(payload) + }) +} + +export const upsertMemberBatch = async ( + request: RequestClient, + settings: Settings, + payloads: MemberPayload[] +): Promise | void> => { + // All events in a batch share the same list (grouped via batch_keys on list_id). + const valid = payloads.filter((p) => p.email_address && p.email_address.trim() !== '') + if (valid.length === 0) { + throw new PayloadValidationError('No audience members with a valid email address in the batch.') + } + + const listId = resolveListId(settings, valid[0].list_id) + const members: BatchMemberOperation[] = valid.map((p) => buildMemberBody(p)) + const body: BatchMembersRequest = { + members: members.slice(0, DEFAULT_BATCH_SIZE), + update_existing: true + } + const url = `${baseUrlFor(settings)}${batchMembersPath(listId)}` + + return request(url, { + method: 'POST', + json: body + }) +} + +// ---- Add or Remove Member Tags ---- + +const buildTagsBody = (payload: TagsPayload): MemberTagsRequest => { + const tags: MemberTag[] = [] + + if (payload.tags && payload.tags.length > 0) { + for (const tag of payload.tags) { + if (tag && tag.name && tag.name.trim() !== '') { + tags.push({ name: tag.name, status: tag.status }) + } + } + } + if (payload.tags_to_add) { + for (const name of payload.tags_to_add) { + if (name && name.trim() !== '') tags.push({ name, status: 'active' }) + } + } + if (payload.tags_to_remove) { + for (const name of payload.tags_to_remove) { + if (name && name.trim() !== '') tags.push({ name, status: 'inactive' }) + } + } + + if (tags.length === 0) { + throw new PayloadValidationError('At least one tag to add or remove is required.') + } + return { tags } +} + +export const updateMemberTags = async ( + request: RequestClient, + settings: Settings, + payload: TagsPayload +): Promise> => { + const email = resolveEmail(payload.email) + const listId = resolveListId(settings, payload.list_id) + const subscriberHash = getSubscriberHash(email) + const body = buildTagsBody(payload) + const url = `${baseUrlFor(settings)}${memberTagsPath(listId, subscriberHash)}` + + try { + return await request(url, { + method: 'POST', + json: body + }) + } catch (error) { + const status = (error as { response?: { status?: number }; status?: number })?.response?.status + if (status === 404) { + throw new APIError( + 'Audience member not found. Run the "Add or Update Audience Member" action to create the member before applying tags.', + 404 + ) + } + throw error + } +}