import * as z from "zod" import { defineAction } from "../../automation/actions" import { getBrevoApi, parseBrevoResponse, } from "@automate.ax/integration-contracts/brevo" const BREVO_ACCOUNT = "brevo" const ATTRIBUTE_CATEGORY_SCHEMA = z.enum([ "normal", "transactional", "category", "calculated", "global", ]) const ATTRIBUTE_TYPE_SCHEMA = z.enum([ "text", "date", "float", "boolean", "id", "category", "multiple-choice", "user", ]) const ATTRIBUTE_OPTION_SCHEMA = z.object({ label: z.string().max(200), value: z.number().int(), }) const ATTRIBUTE_FIELDS_SCHEMA = z.object({ enumeration: ATTRIBUTE_OPTION_SCHEMA.array().optional(), isRecurring: z.boolean().optional(), multiCategoryOptions: z.string().max(200).array().optional(), type: ATTRIBUTE_TYPE_SCHEMA.optional(), value: z.string().optional(), }) const ATTRIBUTE_SCHEMA = z.object({ calculatedValue: z.string().optional(), category: ATTRIBUTE_CATEGORY_SCHEMA, enumeration: z .object({ label: z.string(), value: z.number().int(), valueStr: z.string(), }) .array() .optional(), multiCategoryOptions: z.string().array().optional(), name: z.string(), type: ATTRIBUTE_TYPE_SCHEMA.optional(), }) const CONTACT_ATTRIBUTE_VALUE_SCHEMA = z.union([ z.string(), z.number(), z.boolean(), z.string().array(), ]) const PROCESS_SCHEMA = z.object({ processId: z.number().int().positive() }) const PAGE_INPUT_SCHEMA = z.object({ limit: z.number().int().min(0).max(50).prefault(10), offset: z.number().int().nonnegative().prefault(0), sort: z.enum(["asc", "desc"]).prefault("desc"), }) const FOLDER_SCHEMA = z.object({ id: z.number().int().positive(), name: z.string(), totalBlacklisted: z.number().int().nonnegative().optional(), totalSubscribers: z.number().int().nonnegative().optional(), uniqueSubscribers: z.number().int().nonnegative().optional(), }) const LIST_SCHEMA = z.object({ id: z.number().int().positive(), name: z.string(), totalBlacklisted: z.number().int().nonnegative().optional(), totalSubscribers: z.number().int().nonnegative().optional(), uniqueSubscribers: z.number().int().nonnegative().optional(), }) const CONSENT_MODE_SCHEMA = z.enum(["manual", "automatic"]) const CONSENT_GROUP_SCHEMA = z.object({ contactsCount: z.number().int().nonnegative(), createdAt: z.string(), description: z.string().optional(), id: z.number().int().positive(), isDefault: z.boolean(), name: z.string(), signupMode: CONSENT_MODE_SCHEMA, updatedAt: z.string(), }) /** Lists the contact attributes configured in a Brevo account. */ export const listBrevoContactAttributes = defineAction( "List Brevo contact attributes", ) .describe("Lists normal, transactional, category, and calculated attributes.") .account(BREVO_ACCOUNT) .input(z.object({})) .output(z.object({ attributes: ATTRIBUTE_SCHEMA.array() })) .retry({ replaySafety: "safe" }) .handler( async ({ account }) => await parseBrevoResponse( await getBrevoApi(account.secret).request("contacts/attributes"), z.object({ attributes: ATTRIBUTE_SCHEMA.array() }), ), ) /** Creates one Brevo contact attribute. */ export const createBrevoContactAttribute = defineAction( "Create Brevo contact attribute", ) .describe("Creates a typed contact attribute in a selected category.") .account(BREVO_ACCOUNT) .input( ATTRIBUTE_FIELDS_SCHEMA.extend({ category: ATTRIBUTE_CATEGORY_SCHEMA, name: z.string().min(1), }), ) .output(z.void()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const { category, name, ...fields } = input await getBrevoApi(account.secret).request( `contacts/attributes/${category}/${encodeURIComponent(name)}`, { body: JSON.stringify(fields), method: "POST" }, ) }) /** Updates one Brevo contact attribute. */ export const updateBrevoContactAttribute = defineAction( "Update Brevo contact attribute", ) .describe("Updates category values, a calculated formula, or choice options.") .account(BREVO_ACCOUNT) .input( ATTRIBUTE_FIELDS_SCHEMA.omit({ isRecurring: true, type: true }) .extend({ category: ATTRIBUTE_CATEGORY_SCHEMA.exclude(["transactional"]), name: z.string().min(1), }) .refine( ({ enumeration, multiCategoryOptions, value }) => enumeration !== undefined || multiCategoryOptions !== undefined || value !== undefined, { message: "Provide enumeration, multiCategoryOptions, or value." }, ), ) .output(z.void()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const { category, name, ...fields } = input await getBrevoApi(account.secret).request( `contacts/attributes/${category}/${encodeURIComponent(name)}`, { body: JSON.stringify(fields), method: "PUT" }, ) }) /** Deletes one Brevo contact attribute. */ export const deleteBrevoContactAttribute = defineAction( "Delete Brevo contact attribute", ) .describe("Permanently deletes an account-defined contact attribute.") .account(BREVO_ACCOUNT) .input( z.object({ category: ATTRIBUTE_CATEGORY_SCHEMA, name: z.string().min(1), }), ) .output(z.void()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { await getBrevoApi(account.secret).request( `contacts/attributes/${input.category}/${encodeURIComponent(input.name)}`, { method: "DELETE" }, ) }) /** Deletes one option from a multiple-choice Brevo contact attribute. */ export const deleteBrevoContactAttributeOption = defineAction( "Delete Brevo contact attribute option", ) .describe("Deletes one existing multiple-choice option.") .account(BREVO_ACCOUNT) .input( z.object({ attribute: z.string().min(1), option: z.string().min(1), }), ) .output(z.void()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { await getBrevoApi(account.secret).request( `contacts/attributes/multiple-choice/${encodeURIComponent(input.attribute)}/${encodeURIComponent(input.option)}`, { method: "DELETE" }, ) }) /** Updates multiple existing Brevo contacts in one request. */ export const updateBrevoContactsBatch = defineAction( "Update Brevo contacts batch", ) .describe("Updates attributes, blocklists, and list memberships in bulk.") .account(BREVO_ACCOUNT) .input( z.object({ contacts: z .object({ attributes: z .record(z.string().min(1), CONTACT_ATTRIBUTE_VALUE_SCHEMA) .optional(), email: z.email().optional(), emailBlacklisted: z.boolean().optional(), extId: z.string().min(1).optional(), id: z.number().int().positive().optional(), listIds: z.number().int().positive().array().optional(), sms: z.string().min(1).optional(), smsBlacklisted: z.boolean().optional(), smtpBlacklistSender: z.email().array().optional(), unlinkListIds: z.number().int().positive().array().optional(), }) .refine( ({ email, id, sms }) => [email, id, sms].filter((value) => value !== undefined).length === 1, { message: "Each contact requires exactly one of email, id, or sms.", }, ) .array() .min(1), }), ) .output(z.void()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { await getBrevoApi(account.secret).request("contacts/batch", { body: JSON.stringify({ contacts: input.contacts.map(({ extId, ...contact }) => ({ ...contact, ext_id: extId, })), }), method: "POST", }) }) /** Starts Brevo's double-opt-in flow for one contact. */ export const createBrevoDoubleOptInContact = defineAction( "Create Brevo double-opt-in contact", ) .describe("Creates or updates a contact after sending a DOI confirmation.") .account(BREVO_ACCOUNT) .input( z.object({ attributes: z .record(z.string().min(1), CONTACT_ATTRIBUTE_VALUE_SCHEMA) .optional(), email: z.email(), excludeListIds: z.number().int().positive().array().optional(), includeListIds: z.number().int().positive().array().min(1), redirectionUrl: z.url(), templateId: z.number().int().positive(), }), ) .output(z.void()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { await getBrevoApi(account.secret).request( "contacts/doubleOptinConfirmation", { body: JSON.stringify(input), method: "POST", }, ) }) const IMPORT_SOURCE_SCHEMA = z .object({ fileBody: z.string().max(10_000_000).optional(), fileUrl: z.url().optional(), jsonBody: z .object({ attributes: z.record(z.string(), z.json()).optional(), email: z.email().optional(), }) .refine( ({ attributes, email }) => email !== undefined || attributes !== undefined, { message: "Each JSON contact requires email or attributes.", }, ) .array() .optional(), }) .refine( ({ fileBody, fileUrl, jsonBody }) => [fileBody, fileUrl, jsonBody].filter((value) => value !== undefined) .length === 1, { message: "Provide exactly one of fileBody, fileUrl, or jsonBody." }, ) /** Starts an asynchronous Brevo contact import. */ export const importBrevoContacts = defineAction("Import Brevo contacts") .describe("Imports CSV, URL, or JSON contact data into lists.") .account(BREVO_ACCOUNT) .input( IMPORT_SOURCE_SCHEMA.and( z .object({ consentGroupIds: z.number().int().positive().array().optional(), disableNotification: z.boolean().prefault(false), emailBlacklist: z.boolean().prefault(false), emptyContactsAttributes: z.boolean().prefault(false), listIds: z.number().int().positive().array().optional(), newList: z .object({ folderId: z.number().int().positive(), listName: z.string().min(1), }) .optional(), notifyUrl: z.url().optional(), smsBlacklist: z.boolean().prefault(false), updateExistingContacts: z.boolean().prefault(true), }) .refine( ({ listIds, newList }) => Boolean(listIds?.length) !== Boolean(newList), { message: "Provide either listIds or newList." }, ), ), ) .output(PROCESS_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler( async ({ account, input }) => await parseBrevoResponse( await getBrevoApi(account.secret).request("contacts/import", { body: JSON.stringify(input), method: "POST", }), PROCESS_SCHEMA, ), ) const EXPORT_FILTER_SCHEMA = z .object({ actionForContacts: z .enum([ "allContacts", "subscribed", "unsubscribed", "unsubscribedPerList", ]) .optional(), actionForEmailCampaigns: z .enum([ "openers", "nonOpeners", "clickers", "nonClickers", "unsubscribed", "hardBounces", "softBounces", ]) .optional(), actionForSmsCampaigns: z .enum(["hardBounces", "softBounces", "unsubscribed"]) .optional(), emailCampaignId: z.number().int().positive().optional(), listId: z.number().int().positive().optional(), segmentId: z.number().int().positive().optional(), smsCampaignId: z.number().int().positive().optional(), }) .superRefine((filter, ctx) => { if ( [ filter.actionForContacts, filter.actionForEmailCampaigns, filter.actionForSmsCampaigns, ].filter(Boolean).length !== 1 ) { ctx.addIssue({ code: "custom", message: "Provide exactly one export action.", }) } if ( filter.actionForEmailCampaigns && !["hardBounces", "softBounces"].includes( filter.actionForEmailCampaigns, ) && !filter.emailCampaignId ) { ctx.addIssue({ code: "custom", message: "emailCampaignId is required for an email campaign export.", }) } if (filter.actionForContacts && !filter.listId && !filter.segmentId) { ctx.addIssue({ code: "custom", message: "Contact exports require listId or segmentId.", }) } }) /** Requests an asynchronous CSV export of filtered Brevo contacts. */ export const exportBrevoContacts = defineAction("Export Brevo contacts") .describe("Exports filtered contacts and selected attributes as CSV.") .account(BREVO_ACCOUNT) .input( z.object({ customContactFilter: EXPORT_FILTER_SCHEMA, disableNotification: z.boolean().prefault(false), exportAttributes: z.string().min(1).array().optional(), exportDateInUtc: z.boolean().prefault(false), exportMandatoryAttributes: z.boolean().prefault(true), exportMetadata: z.string().min(1).array().optional(), exportSubscriptionStatus: z .enum(["email_marketing", "sms_marketing"]) .array() .optional(), notifyUrl: z.url().optional(), }), ) .output(PROCESS_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const { exportDateInUtc, ...body } = input return await parseBrevoResponse( await getBrevoApi(account.secret).request("contacts/export", { body: JSON.stringify({ ...body, exportDateInUTC: exportDateInUtc }), method: "POST", }), PROCESS_SCHEMA, ) }) const CAMPAIGN_EVENT_SCHEMA = z.object({ campaignId: z.number().int().positive(), eventTime: z.string(), }) const CONTACT_STATISTICS_SCHEMA = z.object({ clicked: z .object({ campaignId: z.number().int().positive(), links: z .object({ count: z.number().int().nonnegative(), eventTime: z.string(), ip: z.string(), url: z.string(), }) .array(), }) .array() .optional(), complaints: CAMPAIGN_EVENT_SCHEMA.array().optional(), delivered: CAMPAIGN_EVENT_SCHEMA.array().optional(), hardBounces: CAMPAIGN_EVENT_SCHEMA.array().optional(), messagesSent: CAMPAIGN_EVENT_SCHEMA.array().optional(), opened: z .object({ campaignId: z.number().int().positive(), count: z.number().int().nonnegative(), eventTime: z.string(), ip: z.string(), }) .array() .optional(), softBounces: CAMPAIGN_EVENT_SCHEMA.array().optional(), transacAttributes: z.record(z.string(), z.json()).array().optional(), unsubscriptions: z .object({ adminUnsubscription: z .object({ eventTime: z.string(), ip: z.string().optional() }) .array(), userUnsubscription: z .object({ campaignId: z.number().int().positive().nullable(), eventTime: z.string(), ip: z.string().optional(), }) .array(), }) .optional(), }) /** Retrieves campaign activity statistics for one Brevo contact. */ export const getBrevoContactStatistics = defineAction( "Get Brevo contact statistics", ) .describe("Retrieves up to 90 days of campaign events for one contact.") .account(BREVO_ACCOUNT) .input( z .object({ endDate: z.iso.date().optional(), identifier: z.union([z.email(), z.number().int().positive()]), startDate: z.iso.date().optional(), }) .refine( ({ endDate, startDate }) => Boolean(endDate) === Boolean(startDate), { message: "startDate and endDate must be provided together." }, ), ) .output(CONTACT_STATISTICS_SCHEMA) .retry({ replaySafety: "safe" }) .handler( async ({ account, input }) => await parseBrevoResponse( await getBrevoApi(account.secret).request( `contacts/${encodeURIComponent(input.identifier)}/campaignStats`, { query: { endDate: input.endDate, startDate: input.startDate } }, ), CONTACT_STATISTICS_SCHEMA, ), ) /** Lists Brevo contact folders with offset pagination. */ export const listBrevoContactFolders = defineAction( "List Brevo contact folders", ) .describe("Lists folders used to organize contact lists.") .account(BREVO_ACCOUNT) .input(PAGE_INPUT_SCHEMA) .output( z.object({ count: z.number().int().nonnegative(), folders: FOLDER_SCHEMA.array(), }), ) .retry({ replaySafety: "safe" }) .handler( async ({ account, input }) => await parseBrevoResponse( await getBrevoApi(account.secret).request("contacts/folders", { query: input, }), z.object({ count: z.number().int().nonnegative(), folders: FOLDER_SCHEMA.array(), }), ), ) /** Creates an empty Brevo contact folder. */ export const createBrevoContactFolder = defineAction( "Create Brevo contact folder", ) .describe("Creates an empty folder for organizing contact lists.") .account(BREVO_ACCOUNT) .input(z.object({ name: z.string().min(1) })) .output(z.object({ id: z.number().int().positive() })) .retry({ replaySafety: "unsafe" }) .handler( async ({ account, input }) => await parseBrevoResponse( await getBrevoApi(account.secret).request("contacts/folders", { body: JSON.stringify(input), method: "POST", }), z.object({ id: z.number().int().positive() }), ), ) /** Retrieves one Brevo contact folder. */ export const getBrevoContactFolder = defineAction("Get Brevo contact folder") .describe("Retrieves one contact folder and its subscriber counts.") .account(BREVO_ACCOUNT) .input(z.object({ folderId: z.number().int().positive() })) .output(FOLDER_SCHEMA) .retry({ replaySafety: "safe" }) .handler( async ({ account, input }) => await parseBrevoResponse( await getBrevoApi(account.secret).request( `contacts/folders/${input.folderId}`, ), FOLDER_SCHEMA, ), ) /** Renames one Brevo contact folder. */ export const updateBrevoContactFolder = defineAction( "Update Brevo contact folder", ) .describe("Renames an existing contact folder.") .account(BREVO_ACCOUNT) .input( z.object({ folderId: z.number().int().positive(), name: z.string().min(1), }), ) .output(z.void()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { await getBrevoApi(account.secret).request( `contacts/folders/${input.folderId}`, { body: JSON.stringify({ name: input.name }), method: "PUT" }, ) }) /** Deletes a Brevo contact folder and all lists inside it. */ export const deleteBrevoContactFolder = defineAction( "Delete Brevo contact folder", ) .describe("Permanently deletes a folder and every list it contains.") .account(BREVO_ACCOUNT) .input(z.object({ folderId: z.number().int().positive() })) .output(z.void()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { await getBrevoApi(account.secret).request( `contacts/folders/${input.folderId}`, { method: "DELETE" }, ) }) /** Lists contact lists contained by one Brevo folder. */ export const listBrevoContactFolderLists = defineAction( "List Brevo contact folder lists", ) .describe("Lists the contact lists contained by one folder.") .account(BREVO_ACCOUNT) .input(PAGE_INPUT_SCHEMA.extend({ folderId: z.number().int().positive() })) .output( z.object({ count: z.number().int().nonnegative(), lists: LIST_SCHEMA.array(), }), ) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const { folderId, ...query } = input return await parseBrevoResponse( await getBrevoApi(account.secret).request( `contacts/folders/${folderId}/lists`, { query }, ), z.object({ count: z.number().int().nonnegative(), lists: LIST_SCHEMA.array(), }), ) }) /** Lists saved Brevo contact segments. */ export const listBrevoContactSegments = defineAction( "List Brevo contact segments", ) .describe("Lists saved contact segments with pagination.") .account(BREVO_ACCOUNT) .input(PAGE_INPUT_SCHEMA) .output( z.object({ count: z.number().int().nonnegative(), segments: z .object({ categoryName: z.string(), id: z.number().int().positive(), segmentName: z.string(), updatedAt: z.string().optional(), }) .array(), }), ) .retry({ replaySafety: "safe" }) .handler( async ({ account, input }) => await parseBrevoResponse( await getBrevoApi(account.secret).request("contacts/segments", { query: input, }), z.object({ count: z.number().int().nonnegative(), segments: z .object({ categoryName: z.string(), id: z.number().int().positive(), segmentName: z.string(), updatedAt: z.string().optional(), }) .array(), }), ), ) /** Lists Brevo consent groups. */ export const listBrevoConsentGroups = defineAction("List Brevo consent groups") .describe("Lists plan-gated consent groups with filters and pagination.") .account(BREVO_ACCOUNT) .input( z.object({ id: z.number().int().positive().optional(), limit: z.number().int().min(0).max(50).prefault(10), name: z.string().min(1).optional(), offset: z.number().int().nonnegative().prefault(0), signupMode: CONSENT_MODE_SCHEMA.optional(), }), ) .output( z.object({ consentGroups: CONSENT_GROUP_SCHEMA.array(), limit: z.number().int().nonnegative(), offset: z.number().int().nonnegative(), total: z.number().int().nonnegative(), }), ) .retry({ replaySafety: "safe" }) .handler( async ({ account, input }) => await parseBrevoResponse( await getBrevoApi(account.secret).request("contacts/consent-groups", { query: input, }), z.object({ consentGroups: CONSENT_GROUP_SCHEMA.array(), limit: z.number().int().nonnegative(), offset: z.number().int().nonnegative(), total: z.number().int().nonnegative(), }), ), ) /** Creates a plan-gated Brevo consent group. */ export const createBrevoConsentGroup = defineAction( "Create Brevo consent group", ) .describe("Creates a consent group and optionally seeds it from lists.") .account(BREVO_ACCOUNT) .input( z.object({ description: z.string().max(500).optional(), listIds: z.number().int().positive().array().optional(), name: z.string().min(1).max(255), signupMode: CONSENT_MODE_SCHEMA, }), ) .output(CONSENT_GROUP_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler( async ({ account, input }) => await parseBrevoResponse( await getBrevoApi(account.secret).request("contacts/consent-groups", { body: JSON.stringify(input), method: "POST", }), CONSENT_GROUP_SCHEMA, ), ) /** Retrieves one plan-gated Brevo consent group. */ export const getBrevoConsentGroup = defineAction("Get Brevo consent group") .describe("Retrieves one consent group and its contact count.") .account(BREVO_ACCOUNT) .input(z.object({ id: z.number().int().positive() })) .output(CONSENT_GROUP_SCHEMA) .retry({ replaySafety: "safe" }) .handler( async ({ account, input }) => await parseBrevoResponse( await getBrevoApi(account.secret).request( `contacts/consent-groups/${input.id}`, ), CONSENT_GROUP_SCHEMA, ), ) /** Updates one plan-gated Brevo consent group. */ export const updateBrevoConsentGroup = defineAction( "Update Brevo consent group", ) .describe("Updates a consent group's name, description, or signup mode.") .account(BREVO_ACCOUNT) .input( z .object({ description: z.string().max(500).optional(), id: z.number().int().positive(), name: z.string().min(1).max(255).optional(), signupMode: CONSENT_MODE_SCHEMA.optional(), }) .refine( ({ description, name, signupMode }) => description !== undefined || name !== undefined || signupMode !== undefined, { message: "Provide description, name, or signupMode." }, ), ) .output(CONSENT_GROUP_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const { id, ...body } = input return await parseBrevoResponse( await getBrevoApi(account.secret).request( `contacts/consent-groups/${id}`, { body: JSON.stringify(body), method: "PUT" }, ), CONSENT_GROUP_SCHEMA, ) }) /** Deletes one plan-gated Brevo consent group. */ export const deleteBrevoConsentGroup = defineAction( "Delete Brevo consent group", ) .describe("Deletes a consent group and removes it from associated contacts.") .account(BREVO_ACCOUNT) .input(z.object({ id: z.number().int().positive() })) .output(z.void()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { await getBrevoApi(account.secret).request( `contacts/consent-groups/${input.id}`, { method: "DELETE" }, ) })