import * as z from "zod" import { defineAction } from "../../automation/actions" import { getBrevoApi, parseBrevoResponse, } from "@automate.ax/integration-contracts/brevo" const BREVO_ACCOUNT = "brevo" const CAMPAIGN_ID_SCHEMA = z.number().int().positive() const CAMPAIGN_STATUS_SCHEMA = z.enum([ "draft", "sent", "archive", "queued", "suspended", "in_process", "in_review", "cancelling", "cancelled", ]) const STATUS_UPDATE_SCHEMA = z.enum([ "suspended", "archive", "darchive", "sent", "queued", "replicate", "replicateTemplate", "cancel", "draft", ]) const SENDER_SCHEMA = z .object({ email: z.email().optional(), id: z.number().int().positive().optional(), name: z.string().min(1).optional(), }) .refine(({ email, id }) => (email === undefined) !== (id === undefined), { message: "Provide either sender.email or sender.id.", }) const RECIPIENTS_SCHEMA = z.object({ exclusionListIds: z.number().int().positive().array().optional(), exclusionSegmentIds: z.number().int().positive().array().optional(), listIds: z.number().int().positive().array().optional(), segmentIds: z.number().int().positive().array().optional(), }) const EXPIRATION_SCHEMA = z .object({ duration: z.number().int().min(1), unit: z.enum(["days", "weeks", "months"]), }) .superRefine(({ duration, unit }, ctx) => { const maximum = unit === "days" ? 3600 : unit === "weeks" ? 480 : 120 if (duration > maximum) { ctx.addIssue({ code: "custom", message: `duration cannot exceed ${maximum} ${unit}.`, }) } }) const CAMPAIGN_FIELDS = { abTesting: z.boolean().optional(), attachmentUrl: z.url().optional(), emailExpirationDate: EXPIRATION_SCHEMA.optional(), footer: z.string().optional(), header: z.string().optional(), htmlContent: z.string().min(11).optional(), htmlUrl: z.url().optional(), increaseRate: z.number().int().min(0).max(100).optional(), initialQuota: z.number().int().min(2).optional(), inlineImageActivation: z.boolean().optional(), ipWarmupEnable: z.boolean().optional(), mirrorActive: z.boolean().optional(), name: z.string().min(1).optional(), params: z.record(z.string(), z.json()).optional(), previewText: z.string().optional(), recipients: RECIPIENTS_SCHEMA.optional(), replyTo: z.email().optional(), scheduledAt: z.iso.datetime({ offset: true }).optional(), sendAtBestTime: z.boolean().optional(), sender: SENDER_SCHEMA.optional(), splitRule: z.number().int().min(1).max(50).optional(), subject: z.string().min(1).optional(), subjectA: z.string().min(1).optional(), subjectB: z.string().min(1).optional(), tag: z.string().optional(), templateId: z.number().int().positive().optional(), toField: z.string().optional(), unsubscriptionPageId: z.string().optional(), updateFormId: z.string().optional(), utmCampaign: z.string().optional(), utmContent: z.string().optional(), utmTerm: z.string().optional(), winnerCriteria: z.enum(["open", "click"]).optional(), winnerDelay: z.number().int().min(1).max(168).optional(), } const validateCampaign = ( input: z.infer>, ctx: z.RefinementCtx, ) => { if ( [input.htmlContent, input.htmlUrl, input.templateId].filter( (value) => value !== undefined, ).length > 1 ) { ctx.addIssue({ code: "custom", message: "Use only one of htmlContent, htmlUrl, or templateId.", }) } if (input.abTesting) { if (!input.subjectA || !input.subjectB) { ctx.addIssue({ code: "custom", message: "subjectA and subjectB are required for A/B testing.", }) } if (input.sendAtBestTime) { ctx.addIssue({ code: "custom", message: "A/B testing cannot use sendAtBestTime.", }) } } if (input.ipWarmupEnable && input.initialQuota === undefined) { ctx.addIssue({ code: "custom", message: "initialQuota is required for IP warmup.", }) } if (input.ipWarmupEnable && input.increaseRate === undefined) { ctx.addIssue({ code: "custom", message: "increaseRate is required for IP warmup.", }) } if (input.abTesting && input.subjectA === input.subjectB) { ctx.addIssue({ code: "custom", message: "subjectA and subjectB must differ.", }) } if (input.abTesting && input.recipients && input.splitRule === undefined) { ctx.addIssue({ code: "custom", message: "splitRule is required for A/B recipients.", }) } if ( input.splitRule !== undefined && input.splitRule < 50 && (!input.winnerCriteria || input.winnerDelay === undefined) ) { ctx.addIssue({ code: "custom", message: "winnerCriteria and winnerDelay are required when splitRule is below 50.", }) } } const CREATE_CAMPAIGN_SCHEMA = z .object({ ...CAMPAIGN_FIELDS, name: z.string().min(1), sender: SENDER_SCHEMA, }) .superRefine((input, ctx) => { validateCampaign(input, ctx) if (!input.htmlContent && !input.htmlUrl && !input.templateId) { ctx.addIssue({ code: "custom", message: "Provide htmlContent, htmlUrl, or templateId.", }) } if (!input.abTesting && !input.subject) { ctx.addIssue({ code: "custom", message: "subject is required." }) } if ( input.scheduledAt && !input.recipients?.listIds?.length && !input.recipients?.segmentIds?.length ) { ctx.addIssue({ code: "custom", message: "Scheduled campaigns require recipient listIds or segmentIds.", }) } }) const UPDATE_CAMPAIGN_SCHEMA = z .object({ ...CAMPAIGN_FIELDS, campaignId: CAMPAIGN_ID_SCHEMA, recurring: z.boolean().optional(), }) .superRefine((input, ctx) => validateCampaign(input, ctx)) const CAMPAIGN_SCHEMA = z.object({ abTesting: z.boolean().optional(), attachmentFile: z.string().optional(), attachmentUrl: z.string().optional(), createdAt: z.string(), emailExpirationDate: EXPIRATION_SCHEMA.optional(), footer: z.string(), header: z.string(), htmlContent: z.string(), id: CAMPAIGN_ID_SCHEMA, inlineImageActivation: z.boolean().optional(), mirrorActive: z.boolean().optional(), modifiedAt: z.string(), name: z.string(), previewText: z.string().optional(), recipients: z.object({ exclusionLists: z.number().int().array().optional(), lists: z.number().int().array().optional(), }), replyTo: z.string(), scheduledAt: z.string().optional(), sender: z.object({ email: z.email(), id: z.number().int(), name: z.string(), }), shareLink: z.string().optional(), statistics: z.record(z.string(), z.json()), status: CAMPAIGN_STATUS_SCHEMA, subject: z.string().optional(), subjectA: z.string().optional(), subjectB: z.string().optional(), tags: z.string().array().optional(), testSent: z.boolean(), type: z.enum(["classic", "trigger"]), }) const PROCESS_SCHEMA = z.object({ processId: z.number().int().positive() }) const ID_RESPONSE_SCHEMA = z.object({ id: CAMPAIGN_ID_SCHEMA }) const REPORT_SCHEMA = z.object({ email: z.object({ body: z.string(), to: z.email().array().min(1) }), language: z.enum(["fr", "es", "pt", "it", "de", "en"]).prefault("fr"), }) /** Lists Brevo email campaigns with recipients and statistics. */ export const listBrevoEmailCampaigns = defineAction( "List Brevo email campaigns", ) .account(BREVO_ACCOUNT) .input( z .object({ endDate: z.iso.datetime({ offset: true }).optional(), excludeHtmlContent: z.boolean().optional(), excludePdfAttachment: z.boolean().optional(), limit: z.number().int().min(1).max(1000).prefault(500), offset: z.number().int().nonnegative().prefault(0), sort: z.enum(["asc", "desc"]).prefault("desc"), startDate: z.iso.datetime({ offset: true }).optional(), statistics: z .enum(["globalStats", "linksStats", "statsByDomain"]) .optional(), status: z .enum([ "suspended", "archive", "sent", "queued", "draft", "inProcess", "inReview", "cancelling", "cancelled", ]) .optional(), type: z.enum(["classic", "trigger"]).optional(), }) .superRefine(({ endDate, startDate, status }, ctx) => { if (Boolean(endDate) !== Boolean(startDate)) { ctx.addIssue({ code: "custom", message: "startDate and endDate must be provided together.", }) return } if (!endDate || !startDate) return const end = new Date(endDate).getTime() const start = new Date(startDate).getTime() if (status && status !== "sent") { ctx.addIssue({ code: "custom", message: "Date filters require status sent or no status.", }) } if (end < start || end > Date.now() || end - start > 63_115_200_000) { ctx.addIssue({ code: "custom", message: "Use a non-future date range of at most two years.", }) } }), ) .output( z.object({ campaigns: CAMPAIGN_SCHEMA.array(), count: z.number().int().nonnegative(), }), ) .retry({ replaySafety: "safe" }) .handler( async ({ account, input }) => await parseBrevoResponse( await getBrevoApi(account.secret).request("emailCampaigns", { query: input, }), z.object({ campaigns: CAMPAIGN_SCHEMA.array(), count: z.number().int().nonnegative(), }), ), ) /** Creates a Brevo email campaign. */ export const createBrevoEmailCampaign = defineAction( "Create Brevo email campaign", ) .account(BREVO_ACCOUNT) .input(CREATE_CAMPAIGN_SCHEMA) .output(ID_RESPONSE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler( async ({ account, input }) => await parseBrevoResponse( await getBrevoApi(account.secret).request("emailCampaigns", { body: JSON.stringify(input), method: "POST", }), ID_RESPONSE_SCHEMA, ), ) /** Retrieves one Brevo email campaign. */ export const getBrevoEmailCampaign = defineAction("Get Brevo email campaign") .account(BREVO_ACCOUNT) .input( z.object({ campaignId: CAMPAIGN_ID_SCHEMA, excludeHtmlContent: z.boolean().optional(), statistics: z .enum([ "globalStats", "linksStats", "statsByDomain", "statsByDevice", "statsByBrowser", ]) .optional(), }), ) .output(CAMPAIGN_SCHEMA) .retry({ replaySafety: "safe" }) .handler( async ({ account, input: { campaignId, ...query } }) => await parseBrevoResponse( await getBrevoApi(account.secret).request( `emailCampaigns/${campaignId}`, { query }, ), CAMPAIGN_SCHEMA, ), ) /** Updates a Brevo email campaign. */ export const updateBrevoEmailCampaign = defineAction( "Update Brevo email campaign", ) .account(BREVO_ACCOUNT) .input(UPDATE_CAMPAIGN_SCHEMA) .output(z.void()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const { campaignId, ...body } = input await getBrevoApi(account.secret).request(`emailCampaigns/${campaignId}`, { body: JSON.stringify(body), method: "PUT", }) }) /** Deletes a Brevo email campaign. */ export const deleteBrevoEmailCampaign = defineAction( "Delete Brevo email campaign", ) .account(BREVO_ACCOUNT) .input(z.object({ campaignId: CAMPAIGN_ID_SCHEMA })) .output(z.void()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { await getBrevoApi(account.secret).request( `emailCampaigns/${input.campaignId}`, { method: "DELETE" }, ) }) /** Uploads a URL-hosted image to Brevo's campaign gallery. */ export const uploadBrevoCampaignImage = defineAction( "Upload Brevo campaign image", ) .account(BREVO_ACCOUNT) .input(z.object({ imageUrl: z.url(), name: z.string().min(1).optional() })) .output(z.object({ url: z.url().optional() })) .retry({ replaySafety: "unsafe" }) .handler( async ({ account, input }) => await parseBrevoResponse( await getBrevoApi(account.secret).request("emailCampaigns/images", { body: JSON.stringify(input), method: "POST", }), z.object({ url: z.url().optional() }), ), ) const VERSION_STATS_SCHEMA = z.object({ "Version A": z.string(), "Version B": z.string(), }) const LINK_SCHEMA = z.object({ clickRate: z.string(), clicksCount: z.number().int().nonnegative(), link: z.string(), }) const AB_RESULT_SCHEMA = z.object({ clickRate: z.string().optional(), clickedLinks: z .object({ "Version A": LINK_SCHEMA.array(), "Version B": LINK_SCHEMA.array(), }) .optional(), openRate: z.string().optional(), statistics: z .object({ clicks: VERSION_STATS_SCHEMA, complaints: VERSION_STATS_SCHEMA, hardBounces: VERSION_STATS_SCHEMA, openers: VERSION_STATS_SCHEMA, softBounces: VERSION_STATS_SCHEMA, unsubscribed: VERSION_STATS_SCHEMA, }) .optional(), winningCriteria: z.enum(["Open", "Click"]).optional(), winningSubjectLine: z.string().optional(), winningVersion: z .enum(["notAvailable", "pending", "tie", "A", "B"]) .optional(), winningVersionRate: z.string().optional(), }) /** Gets the current result of an A/B email campaign. */ export const getBrevoEmailCampaignAbResult = defineAction( "Get Brevo email campaign A/B result", ) .account(BREVO_ACCOUNT) .input(z.object({ campaignId: CAMPAIGN_ID_SCHEMA })) .output(AB_RESULT_SCHEMA) .retry({ replaySafety: "safe" }) .handler( async ({ account, input }) => await parseBrevoResponse( await getBrevoApi(account.secret).request( `emailCampaigns/${input.campaignId}/abTestCampaignResult`, ), AB_RESULT_SCHEMA, ), ) /** Starts a recipient export for a sent email campaign. */ export const exportBrevoEmailCampaignRecipients = defineAction( "Export Brevo email campaign recipients", ) .account(BREVO_ACCOUNT) .input( z.object({ campaignId: CAMPAIGN_ID_SCHEMA, notifyURL: z.url().optional(), recipientsType: z.enum([ "all", "nonClickers", "nonOpeners", "clickers", "openers", "softBounces", "hardBounces", "unsubscribed", ]), }), ) .output(PROCESS_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const { campaignId, ...body } = input return await parseBrevoResponse( await getBrevoApi(account.secret).request( `emailCampaigns/${campaignId}/exportRecipients`, { body: JSON.stringify(body), method: "POST" }, ), PROCESS_SCHEMA, ) }) const voidCampaignAction = (name: string, suffix: string) => defineAction(name) .account(BREVO_ACCOUNT) .input(z.object({ campaignId: CAMPAIGN_ID_SCHEMA })) .output(z.void()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { await getBrevoApi(account.secret).request( `emailCampaigns/${input.campaignId}/${suffix}`, { method: "POST" }, ) }) /** Sends an email campaign immediately. */ export const sendBrevoEmailCampaignNow = voidCampaignAction( "Send Brevo email campaign now", "sendNow", ) /** Sends an email campaign test. */ export const sendBrevoEmailCampaignTest = defineAction( "Send Brevo email campaign test", ) .account(BREVO_ACCOUNT) .input( z.object({ campaignId: CAMPAIGN_ID_SCHEMA, emailTo: z.email().array().max(50).prefault([]), }), ) .output(z.void()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { await getBrevoApi(account.secret).request( `emailCampaigns/${input.campaignId}/sendTest`, { body: JSON.stringify({ emailTo: input.emailTo }), method: "POST" }, ) }) /** Emails a PDF report for an email campaign. */ export const sendBrevoEmailCampaignReport = defineAction( "Send Brevo email campaign report", ) .account(BREVO_ACCOUNT) .input(REPORT_SCHEMA.extend({ campaignId: CAMPAIGN_ID_SCHEMA })) .output(z.void()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const { campaignId, ...body } = input await getBrevoApi(account.secret).request( `emailCampaigns/${campaignId}/sendReport`, { body: JSON.stringify(body), method: "POST" }, ) }) /** Gets the cross-account sharing URL for an email campaign. */ export const getBrevoEmailCampaignSharedUrl = defineAction( "Get Brevo email campaign shared URL", ) .account(BREVO_ACCOUNT) .input(z.object({ campaignId: CAMPAIGN_ID_SCHEMA })) .output(z.object({ sharedUrl: z.url() })) .retry({ replaySafety: "safe" }) .handler( async ({ account, input }) => await parseBrevoResponse( await getBrevoApi(account.secret).request( `emailCampaigns/${input.campaignId}/sharedUrl`, ), z.object({ sharedUrl: z.url() }), ), ) /** Changes a Brevo email campaign's lifecycle status. */ export const updateBrevoEmailCampaignStatus = defineAction( "Update Brevo email campaign status", ) .account(BREVO_ACCOUNT) .input( z.object({ campaignId: CAMPAIGN_ID_SCHEMA, status: STATUS_UPDATE_SCHEMA }), ) .output(z.void()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { await getBrevoApi(account.secret).request( `emailCampaigns/${input.campaignId}/status`, { body: JSON.stringify({ status: input.status }), method: "PUT" }, ) })