import * as z from "zod" import { defineAction } from "../../automation/actions" import { getBrevoApi, parseBrevoResponse, } from "@automate.ax/integration-contracts/brevo" const BREVO_ACCOUNT = "brevo" const BREVO_REFERENCE_SCHEMA = z.union([ z.number().int().positive(), z.string().trim().min(1), ]) const IDENTIFIER_TYPE_SCHEMA = z.enum([ "email_id", "contact_id", "ext_id", "phone_id", "whatsapp_id", "landline_number_id", ]) const CONTACT_ATTRIBUTES_SCHEMA = z .record( z.string().trim().min(1), z.union([z.string(), z.number(), z.boolean(), z.string().array()]), ) .transform((attributes) => Object.fromEntries( Object.entries(attributes).map(([name, value]) => [ name.toUpperCase(), value, ]), ), ) const CONTACT_SCHEMA = z.object({ /** Account-defined contact attributes. */ attributes: z.record(z.string(), z.json()), /** Consent groups returned when the account enables that feature. */ consentGroups: z.json().array().optional(), /** Contact creation timestamp. */ createdAt: z.string(), /** Contact email address, when the record has one. */ email: z.email().optional(), /** Whether email marketing is blocked for the contact. */ emailBlacklisted: z.boolean(), /** Brevo numeric contact ID. */ id: z.number().int().positive(), /** Lists containing the contact. */ listIds: z.number().int().positive().array(), /** Lists from which the contact unsubscribed. */ listUnsubscribed: z.number().int().positive().array().optional(), /** Contact modification timestamp. */ modifiedAt: z.string(), /** Whether SMS marketing is blocked for the contact. */ smsBlacklisted: z.boolean(), /** Recent contact campaign statistics, when requested. */ statistics: z.json().optional(), /** Whether WhatsApp marketing is blocked for the contact. */ whatsappBlacklisted: z.boolean(), }) const CONTACT_LIST_SCHEMA = z.object({ /** Contact list ID. */ folderId: z.number().int().positive(), /** Contact list ID. */ id: z.number().int().positive(), /** Contact list name. */ name: z.string(), /** Number of blacklisted contacts reported by Brevo. */ totalBlacklisted: z.number().int().nonnegative().optional(), /** Number of contacts reported by Brevo. */ totalSubscribers: z.number().int().nonnegative().optional(), /** Number of unique contacts in the list. */ uniqueSubscribers: z.number().int().nonnegative(), }) const CONTACT_LIST_DETAILS_SCHEMA = CONTACT_LIST_SCHEMA.extend({ /** Campaign statistics associated with this list. */ campaignStats: z.json().array().optional(), /** Contact list creation timestamp. */ createdAt: z.string(), /** Whether Brevo maintains the list dynamically. */ dynamicList: z.boolean().optional(), }) const CONTACT_LIST_PAGE_SCHEMA = z.object({ count: z.number().int().nonnegative(), lists: CONTACT_LIST_SCHEMA.array(), }) const CONTACT_FOLDER_SCHEMA = z.object({ id: z.number().int().positive(), name: z.string(), }) const CONTACT_FOLDER_PAGE_SCHEMA = z.object({ count: z.number().int().nonnegative(), folders: CONTACT_FOLDER_SCHEMA.array(), }) const MEMBERSHIP_WIRE_SCHEMA = z.object({ contacts: z .object({ failure: z.email().array().optional(), success: z.email().array().optional(), }) .optional(), failure: z.email().array().optional(), success: z.email().array().optional(), }) const MEMBERSHIP_SCHEMA = z.object({ /** Contacts Brevo could not add or remove. */ failure: z.email().array(), /** Contacts Brevo added or removed. */ success: z.email().array(), }) const LIST_CONTACTS_INPUT_SCHEMA = z.object({ /** Maximum contacts to return. */ limit: z.number().int().min(1).max(500).prefault(50), /** Contacts modified after this UTC timestamp. */ modifiedSince: z.iso.datetime({ offset: true }).optional(), /** Contacts to skip. */ offset: z.number().int().nonnegative().prefault(0), /** Provider sort order. */ sort: z.enum(["asc", "desc"]).prefault("desc"), }) /** * Creates or updates a Brevo contact by email address. * * Attribute names are normalized to Brevo's required uppercase form. */ export const upsertBrevoContact = defineAction("Upsert Brevo contact") .describe("Creates or updates a contact, attributes, lists, and blocklists.") .account(BREVO_ACCOUNT) .input( z.object({ /** Account-defined contact attributes. */ attributes: CONTACT_ATTRIBUTES_SCHEMA.optional(), /** Contact email address used as the stable identifier. */ email: z.email(), /** Whether email marketing is blocked for the contact. */ emailBlacklisted: z.boolean().optional(), /** External application-owned contact ID. */ extId: z.string().min(1).optional(), /** Merge identifier collisions into the newest contact. */ forceMerge: z.boolean().prefault(false), /** Contact list names or IDs to add the contact to. */ lists: BREVO_REFERENCE_SCHEMA.array().optional(), /** Transactional senders forbidden for this contact. */ smtpBlacklistSender: z.email().array().optional(), /** Whether SMS marketing is blocked for the contact. */ smsBlacklisted: z.boolean().optional(), }), ) .output( z.object({ /** Created or updated Brevo contact ID. */ id: z.number().int().positive(), }), ) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const brevo = getBrevoApi(account.secret) const result = z.object({ id: z.number().int().positive() }).safeParse( await ( await brevo.request("contacts", { body: JSON.stringify({ ...input, ext_id: input.extId, extId: undefined, getId: true, listIds: input.lists ? await resolveBrevoListIds(brevo, input.lists) : undefined, lists: undefined, updateEnabled: true, }), method: "POST", }) ) .json() .catch(() => undefined), ) if (result.success) return result.data return await parseBrevoResponse( await brevo.request(`contacts/${encodeURIComponent(input.email)}`, { query: { identifierType: "email_id" }, }), z.object({ id: z.number().int().positive() }), ) }) /** Retrieves one Brevo contact by any supported provider identifier. */ export const getBrevoContact = defineAction("Get Brevo contact") .describe("Retrieves one Brevo contact by email, ID, phone, or external ID.") .account(BREVO_ACCOUNT) .input( z .object({ /** Inclusive campaign-statistics end date. */ endDate: z.iso.date().optional(), /** Email, contact ID, phone number, or external ID. */ identifier: z.union([z.string().min(1), z.number().int().positive()]), /** Explicit interpretation of the supplied identifier. */ identifierType: IDENTIFIER_TYPE_SCHEMA.optional(), /** Inclusive campaign-statistics start date. */ startDate: z.iso.date().optional(), }) .refine( ({ endDate, startDate }) => Boolean(endDate) === Boolean(startDate), { message: "startDate and endDate must be provided together." }, ), ) .output(CONTACT_SCHEMA) .retry({ replaySafety: "safe" }) .handler( async ({ account, input }) => await parseBrevoResponse( await getBrevoApi(account.secret).request( `contacts/${encodeURIComponent(input.identifier)}`, { query: { endDate: input.endDate, identifierType: input.identifierType, startDate: input.startDate, }, }, ), CONTACT_SCHEMA, ), ) /** Lists Brevo contacts with provider-native filters and pagination. */ export const listBrevoContacts = defineAction("List Brevo contacts") .describe("Lists filtered Brevo contacts with offset pagination.") .account(BREVO_ACCOUNT) .input( z .object({ /** Contacts created after this UTC timestamp. */ createdSince: z.iso.datetime({ offset: true }).optional(), /** Brevo attribute equals-filter expression. */ filter: z.string().min(1).optional(), /** Restrict results to up to 20 contact IDs. */ ids: z.number().int().positive().array().max(20).optional(), /** Maximum contacts to return. */ limit: z.number().int().min(1).max(1_000).prefault(50), /** Restrict results to these contact list names or IDs. */ lists: BREVO_REFERENCE_SCHEMA.array().optional(), /** Contacts modified after this UTC timestamp. */ modifiedSince: z.iso.datetime({ offset: true }).optional(), /** Contacts to skip. */ offset: z.number().int().nonnegative().prefault(0), /** Restrict results to this segment. */ segmentId: z.number().int().positive().optional(), /** Provider sort order. */ sort: z.enum(["asc", "desc"]).prefault("desc"), }) .refine(({ lists, segmentId }) => !(lists && segmentId), { message: "lists and segmentId cannot be used together.", }), ) .output( z.object({ /** Total matching contacts. */ count: z.number().int().nonnegative(), /** Contacts in this page. */ contacts: CONTACT_SCHEMA.array(), }), ) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const brevo = getBrevoApi(account.secret) const { lists, ...query } = input return await parseBrevoResponse( await brevo.request("contacts", { query: { ...query, listIds: lists ? await resolveBrevoListIds(brevo, lists) : undefined, }, }), z.object({ contacts: CONTACT_SCHEMA.array(), count: z.number().int().nonnegative(), }), ) }) /** Permanently deletes one Brevo contact. */ export const deleteBrevoContact = defineAction("Delete Brevo contact") .describe("Permanently deletes one contact by any supported identifier.") .account(BREVO_ACCOUNT) .input( z.object({ /** Email, contact ID, phone number, or external ID. */ identifier: z.union([z.string().min(1), z.number().int().positive()]), /** Explicit interpretation of the supplied identifier. */ identifierType: IDENTIFIER_TYPE_SCHEMA.optional(), }), ) .output(z.void()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { await getBrevoApi(account.secret).request( `contacts/${encodeURIComponent(input.identifier)}`, { method: "DELETE", query: { identifierType: input.identifierType }, }, ) }) /** Lists Brevo contact lists with offset pagination. */ export const listBrevoContactLists = defineAction("List Brevo contact lists") .describe("Lists the contact lists available in a Brevo account.") .account(BREVO_ACCOUNT) .input( z.object({ /** Maximum lists to return. */ limit: z.number().int().min(1).max(50).prefault(10), /** Lists to skip. */ offset: z.number().int().nonnegative().prefault(0), /** Provider sort order. */ sort: z.enum(["asc", "desc"]).prefault("desc"), }), ) .output( z.object({ /** Total contact lists. */ count: z.number().int().nonnegative(), /** Contact lists in this page. */ lists: CONTACT_LIST_SCHEMA.array(), }), ) .retry({ replaySafety: "safe" }) .handler( async ({ account, input }) => await parseBrevoResponse( await getBrevoApi(account.secret).request("contacts/lists", { query: input, }), z.object({ count: z.number().int().nonnegative(), lists: CONTACT_LIST_SCHEMA.array(), }), ), ) /** Retrieves one Brevo contact list and its campaign statistics. */ export const getBrevoContactList = defineAction("Get Brevo contact list") .describe("Retrieves details and optional campaign statistics for one list.") .account(BREVO_ACCOUNT) .input( z .object({ /** Inclusive campaign-statistics end date. */ endDate: z.iso.date().optional(), /** Brevo contact list name or ID. */ list: BREVO_REFERENCE_SCHEMA, /** Inclusive campaign-statistics start date. */ startDate: z.iso.date().optional(), }) .refine( ({ endDate, startDate }) => Boolean(endDate) === Boolean(startDate), { message: "startDate and endDate must be provided together." }, ), ) .output(CONTACT_LIST_DETAILS_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const brevo = getBrevoApi(account.secret) return await parseBrevoResponse( await brevo.request( `contacts/lists/${await resolveBrevoListId(brevo, input.list)}`, { query: { endDate: input.endDate, startDate: input.startDate }, }, ), CONTACT_LIST_DETAILS_SCHEMA, ) }) /** Creates an empty Brevo contact list inside a folder. */ export const createBrevoContactList = defineAction("Create Brevo contact list") .describe("Creates an empty contact list inside an existing Brevo folder.") .account(BREVO_ACCOUNT) .input( z.object({ /** Parent Brevo folder name or ID. */ folder: BREVO_REFERENCE_SCHEMA, /** Contact list name. */ name: z.string().min(1), }), ) .output( z.object({ /** Created Brevo contact list ID. */ id: z.number().int().positive(), }), ) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const brevo = getBrevoApi(account.secret) return await parseBrevoResponse( await brevo.request("contacts/lists", { body: JSON.stringify({ folderId: await resolveBrevoFolderId(brevo, input.folder), name: input.name, }), method: "POST", }), z.object({ id: z.number().int().positive() }), ) }) /** Renames or moves a Brevo contact list. */ export const updateBrevoContactList = defineAction("Update Brevo contact list") .describe("Renames a contact list, moves it to another folder, or both.") .account(BREVO_ACCOUNT) .input( z .object({ /** New parent Brevo folder name or ID. */ folder: BREVO_REFERENCE_SCHEMA.optional(), /** Brevo contact list name or ID. */ list: BREVO_REFERENCE_SCHEMA, /** New contact list name. */ name: z.string().min(1).optional(), }) .refine(({ folder, name }) => folder !== undefined || name, { message: "Provide folder or name.", }), ) .output(z.void()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const brevo = getBrevoApi(account.secret) await brevo.request( `contacts/lists/${await resolveBrevoListId(brevo, input.list)}`, { body: JSON.stringify({ folderId: input.folder ? await resolveBrevoFolderId(brevo, input.folder) : undefined, name: input.name, }), method: "PUT", }, ) }) /** Deletes a Brevo contact list without deleting its contacts. */ export const deleteBrevoContactList = defineAction("Delete Brevo contact list") .describe("Deletes a contact list while preserving the contacts it held.") .account(BREVO_ACCOUNT) .input( z.object({ /** Brevo contact list name or ID. */ list: BREVO_REFERENCE_SCHEMA, }), ) .output(z.void()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const brevo = getBrevoApi(account.secret) await brevo.request( `contacts/lists/${await resolveBrevoListId(brevo, input.list)}`, { method: "DELETE" }, ) }) /** Lists the contacts currently belonging to one Brevo list. */ export const listBrevoListContacts = defineAction("List Brevo list contacts") .describe("Lists contacts currently belonging to one Brevo contact list.") .account(BREVO_ACCOUNT) .input( LIST_CONTACTS_INPUT_SCHEMA.extend({ /** Brevo contact list name or ID. */ list: BREVO_REFERENCE_SCHEMA, }), ) .output( z.object({ /** Total contacts in the list. */ count: z.number().int().nonnegative(), /** Contacts in this page. */ contacts: CONTACT_SCHEMA.array(), }), ) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const brevo = getBrevoApi(account.secret) const { list, ...query } = input return await parseBrevoResponse( await brevo.request( `contacts/lists/${await resolveBrevoListId(brevo, list)}/contacts`, { query }, ), z.object({ contacts: CONTACT_SCHEMA.array(), count: z.number().int().nonnegative(), }), ) }) /** Adds existing Brevo contacts to a contact list. */ export const addBrevoListContacts = defineListMembershipAction("add") /** Removes existing Brevo contacts from a contact list. */ export const removeBrevoListContacts = defineListMembershipAction("remove") /** * Resolves one exact Brevo contact-list name or ID. * * @param brevo - Authenticated Brevo API client. * @param reference - Contact-list name or numeric ID. */ async function resolveBrevoListId( brevo: ReturnType, reference: z.output, ) { return (await resolveBrevoListIds(brevo, [reference]))[0]! } /** * Resolves exact Brevo contact-list names or IDs with one paginated listing. * * @param brevo - Authenticated Brevo API client. * @param references - Contact-list names or numeric IDs. */ async function resolveBrevoListIds( brevo: ReturnType, references: z.output[], ) { if (references.every((reference) => typeof reference === "number")) { return references.flatMap((reference) => typeof reference === "number" ? [reference] : [], ) } const lists: z.output[] = [] let offset = 0 let count: number do { const page = await parseBrevoResponse( await brevo.request("contacts/lists", { query: { limit: 50, offset, sort: "asc" }, }), CONTACT_LIST_PAGE_SCHEMA, ) lists.push(...page.lists) count = page.count offset += page.lists.length } while (offset < count) return references.map((reference) => { if (typeof reference === "number") return reference const normalizedReference = reference.trim().toLowerCase() const nameMatches = lists.filter( ({ name }) => name.trim().toLowerCase() === normalizedReference, ) if (nameMatches.length === 1) return nameMatches[0]!.id if (nameMatches.length > 1) { throw new Error( `Brevo contact list "${reference}" is ambiguous; matching IDs: ${nameMatches.map(({ id }) => id).join(", ")}.`, ) } throw new Error(`Brevo contact list "${reference}" was not found.`) }) } /** * Resolves one exact Brevo contact-folder name or ID. * * @param brevo - Authenticated Brevo API client. * @param reference - Contact-folder name or numeric ID. */ async function resolveBrevoFolderId( brevo: ReturnType, reference: z.output, ) { if (typeof reference === "number") return reference const folders: z.output[] = [] let offset = 0 let count: number do { const page = await parseBrevoResponse( await brevo.request("contacts/folders", { query: { limit: 50, offset, sort: "asc" }, }), CONTACT_FOLDER_PAGE_SCHEMA, ) folders.push(...page.folders) count = page.count offset += page.folders.length } while (offset < count) const normalizedReference = reference.trim().toLowerCase() const nameMatches = folders.filter( ({ name }) => name.trim().toLowerCase() === normalizedReference, ) if (nameMatches.length === 1) return nameMatches[0]!.id if (nameMatches.length > 1) { throw new Error( `Brevo contact folder "${reference}" is ambiguous; matching IDs: ${nameMatches.map(({ id }) => id).join(", ")}.`, ) } throw new Error(`Brevo contact folder "${reference}" was not found.`) } /** * Defines a Brevo list-membership mutation. * * @param operation - Membership operation represented by the endpoint suffix. */ function defineListMembershipAction(operation: "add" | "remove") { return defineAction( `${operation === "add" ? "Add" : "Remove"} Brevo list contacts`, ) .describe( `${operation === "add" ? "Adds" : "Removes"} existing contacts ${operation === "add" ? "to" : "from"} a Brevo list.`, ) .account(BREVO_ACCOUNT) .input( z.object({ /** Existing Brevo contact email addresses. */ emails: z.email().array().min(1), /** Brevo contact list name or ID. */ list: BREVO_REFERENCE_SCHEMA, }), ) .output(MEMBERSHIP_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const brevo = getBrevoApi(account.secret) const response = await parseBrevoResponse( await brevo.request( `contacts/lists/${await resolveBrevoListId(brevo, input.list)}/contacts/${operation}`, { body: JSON.stringify({ emails: input.emails }), method: "POST" }, ), MEMBERSHIP_WIRE_SCHEMA, ) return { failure: response.failure ?? response.contacts?.failure ?? [], success: response.success ?? response.contacts?.success ?? [], } }) }