import * as z from "zod" import { defineAction } from "../../automation/actions" import { hasEmailBody, normalizeEmailBody, OPTIONAL_EMAIL_BODY_SCHEMA, } from "../../lib/email" import { getBrevoApi, parseBrevoResponse, } from "@automate.ax/integration-contracts/brevo" const BREVO_ACCOUNT = "brevo" const RECIPIENT_SCHEMA = z.object({ /** Whether this recipient consented to pixel-based email tracking. */ contactPixelTrackingConsent: z.boolean().optional(), /** Recipient email address. */ email: z.email(), /** Optional display name. */ name: z.string().min(1).optional(), }) const SENDER_SCHEMA = z .object({ /** Registered Brevo sender email. */ email: z.email().optional(), /** Registered Brevo sender ID. */ id: z.number().int().positive().optional(), /** Sender display name. */ name: z.string().min(1).optional(), }) .refine(({ email, id }) => (email === undefined) !== (id === undefined), { message: "Provide either sender.email or sender.id.", }) const REPLY_TO_SCHEMA = z.object({ /** Reply-to email address. */ email: z.email(), /** Optional reply-to display name. */ name: z.string().min(1).optional(), }) const ATTACHMENT_SCHEMA = z.union([ z.object({ /** Base64-encoded file content. */ content: z.string().min(1), /** Filename shown to recipients. */ name: z.string().min(1), }), z.object({ /** Optional filename override. */ name: z.string().min(1).optional(), /** Public URL Brevo should fetch. */ url: z.url(), }), ]) const MESSAGE_VERSION_SCHEMA = z.object({ ...OPTIONAL_EMAIL_BODY_SCHEMA.shape, /** Blind-copy recipients for this version. */ bcc: RECIPIENT_SCHEMA.array().min(1).optional(), /** Carbon-copy recipients for this version. */ cc: RECIPIENT_SCHEMA.array().min(1).optional(), /** Template variables for this version. */ params: z.record(z.string(), z.json()).optional(), /** Reply-to override for this version. */ replyTo: REPLY_TO_SCHEMA.optional(), /** Subject override for this version. */ subject: z.string().min(1).optional(), /** Template override for this version. */ templateId: z.number().int().positive().optional(), /** Primary recipients for this version. */ to: RECIPIENT_SCHEMA.array().min(1).max(99), }) const SEND_EMAIL_SCHEMA = z .object({ ...OPTIONAL_EMAIL_BODY_SCHEMA.shape, /** URL or base64 attachments supported by Brevo. */ attachment: ATTACHMENT_SCHEMA.array().optional(), /** UUID used to manage a scheduled batch. */ batchId: z.uuid().optional(), /** Blind-copy recipients. */ bcc: RECIPIENT_SCHEMA.array().min(1).optional(), /** Carbon-copy recipients. */ cc: RECIPIENT_SCHEMA.array().min(1).optional(), /** Custom non-standard email headers. */ headers: z .record(z.string().min(1), z.union([z.string(), z.number()])) .optional(), /** Per-recipient versions, up to Brevo's total recipient limit. */ messageVersions: MESSAGE_VERSION_SCHEMA.array().min(1).optional(), /** Template or inline-content variables. */ params: z.record(z.string(), z.json()).optional(), /** Address that should receive replies. */ replyTo: REPLY_TO_SCHEMA.optional(), /** UTC delivery time with timezone information. */ scheduledAt: z.iso.datetime({ offset: true }).optional(), /** Registered sender; inherited when a template supplies one. */ sender: SENDER_SCHEMA.optional(), /** Subject line; inherited when a template supplies one. */ subject: z.string().min(1).optional(), /** Reporting tags. */ tags: z.string().min(1).array().optional(), /** Active transactional template ID. */ templateId: z.number().int().positive().optional(), /** Primary recipients when messageVersions is omitted. */ to: RECIPIENT_SCHEMA.array().min(1).optional(), }) .superRefine((input, ctx) => { if (!input.to && !input.messageVersions) { ctx.addIssue({ code: "custom", message: "to is required." }) } if (input.templateId === undefined) { if (!input.sender) { ctx.addIssue({ code: "custom", message: "sender is required." }) } if (!input.subject) { ctx.addIssue({ code: "custom", message: "subject is required." }) } if (!hasEmailBody(input)) { ctx.addIssue({ code: "custom", message: "html, markdown, or text is required.", }) } } if ( input.messageVersions && input.messageVersions.reduce( (total, version) => total + version.to.length, 0, ) > 2_000 ) { ctx.addIssue({ code: "custom", message: "messageVersions cannot contain more than 2,000 recipients.", }) } }) const SEND_EMAIL_RESPONSE_SCHEMA = z.union([ z.object({ /** Message ID for a single-version send. */ messageId: z.string(), }), z.object({ /** Ordered message IDs for a multi-version send. */ messageIds: z.string().array().min(1), }), ]) const EMAIL_EVENT_SCHEMA = z.object({ date: z.string(), email: z.email(), event: z.string(), from: z.email().optional(), messageId: z.string(), reason: z.string().optional(), tag: z.string().optional(), templateId: z.number().int().optional(), }) const EMAIL_CONTENT_WIRE_SCHEMA = z.object({ attachmentCount: z.number().int().nonnegative().optional(), body: z.string().optional(), date: z.string(), email: z.email(), events: z .object({ ip: z.string().optional(), link: z.string().optional(), name: z.string(), subject: z.string().optional(), time: z.string(), }) .array() .optional(), subject: z.string(), templateId: z.number().int().nullable().optional(), }) const EMAIL_CONTENT_SCHEMA = EMAIL_CONTENT_WIRE_SCHEMA.extend({ events: EMAIL_CONTENT_WIRE_SCHEMA.shape.events.unwrap(), }) const TEMPLATE_SCHEMA = z.object({ createdAt: z.string(), customTemplateId: z.string().optional(), doiTemplate: z.boolean().optional(), htmlContent: z.string(), id: z.number().int().positive(), isActive: z.boolean(), modifiedAt: z.string(), name: z.string(), replyTo: z.string().nullable().optional(), sender: z.object({ email: z.email().optional(), id: z.union([z.string(), z.number()]).optional(), name: z.string().nullable().optional(), }), subject: z.string(), tag: z.string().nullable().optional(), testSent: z.boolean().optional(), toField: z.string().optional(), }) const TEMPLATE_FIELDS_SCHEMA = z.object({ /** Public attachment URL. */ attachmentUrl: z.url().optional(), /** Inline HTML body; must exceed Brevo's ten-character minimum. */ htmlContent: z.string().min(11).optional(), /** Public URL containing the template HTML. */ htmlUrl: z.url().optional(), /** Whether the template can be used for sends. */ isActive: z.boolean().optional(), /** Email address that receives replies. */ replyTo: z.email().optional(), /** Registered sender. */ sender: SENDER_SCHEMA.optional(), /** Template subject line. */ subject: z.string().min(1).optional(), /** Reporting tag. */ tag: z.string().min(1).optional(), /** Human-readable template name. */ templateName: z.string().min(1).optional(), /** Personalized recipient-name expression. */ toField: z.string().optional(), }) const SEND_SMS_RESPONSE_SCHEMA = z.object({ /** Provider message ID. */ messageId: z.union([z.string(), z.number()]).transform(String), /** Provider reference used to correlate delivery events. */ reference: z.string(), /** Remaining SMS credits after the send. */ remainingCredits: z.number().optional(), /** Number of SMS parts sent. */ smsCount: z.number().int().positive().optional(), /** SMS credits consumed by each part. */ usedCredits: z.number().optional(), }) /** * Sends or schedules a transactional Brevo email. * * Supports inline content, active templates, personalization, attachments, * message versions, headers, tags, and scheduled batch correlation. */ export const sendBrevoEmail = defineAction("Send Brevo email") .describe("Sends or schedules a rich transactional email through Brevo.") .account(BREVO_ACCOUNT) .input(SEND_EMAIL_SCHEMA) .output(SEND_EMAIL_RESPONSE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler( async ({ account, input }) => await parseBrevoResponse( await getBrevoApi(account.secret).request("smtp/email", { body: JSON.stringify(toBrevoEmail(input)), method: "POST", }), SEND_EMAIL_RESPONSE_SCHEMA, ), ) /** Sends a transactional or opt-out-aware marketing SMS through Brevo. */ export const sendBrevoSms = defineAction("Send Brevo SMS") .describe("Sends a transactional SMS with delivery callback options.") .account(BREVO_ACCOUNT) .input( z .object({ /** SMS body; long content may consume multiple SMS credits. */ content: z.string().trim().min(1).optional(), /** Brand prefix prepended to the content. */ organizationPrefix: z.string().trim().min(1).optional(), /** Template variables used when templateId is provided. */ params: z.record(z.string(), z.json()).optional(), /** Recipient phone number including country code. */ recipient: z .string() .trim() .regex(/^\+?\d{6,15}$/), /** Alphanumeric or numeric sender ID. */ sender: z .string() .trim() .refine( (sender) => /^\d{1,15}$/.test(sender) || /^[A-Za-z0-9]{1,11}$/.test(sender), "Sender must be up to 11 letters or digits, or up to 15 digits.", ), /** Reporting tag or tags. */ tags: z .union([z.string().min(1), z.string().min(1).array().min(1).max(10)]) .optional(), /** SMS template ID; overrides content when provided. */ templateId: z.number().int().positive().optional(), /** Message classification. Marketing content must include opt-out rules. */ type: z.enum(["transactional", "marketing"]).prefault("transactional"), /** Treat message content as Unicode. */ unicodeEnabled: z.boolean().prefault(false), /** URL Brevo calls for delivery events. */ webhookUrl: z.url().optional(), }) .refine(({ content, templateId }) => content || templateId, { message: "Provide content or templateId.", }), ) .output(SEND_SMS_RESPONSE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler( async ({ account, input }) => await parseBrevoResponse( await getBrevoApi(account.secret).request("transactionalSMS/sms", { body: JSON.stringify({ content: input.content, organisationPrefix: input.organizationPrefix, params: input.params, recipient: input.recipient, sender: input.sender, tag: input.tags, templateId: input.templateId, type: input.type, unicodeEnabled: input.unicodeEnabled, webUrl: input.webhookUrl, }), method: "POST", }), SEND_SMS_RESPONSE_SCHEMA, ), ) /** Retrieves the personalized content and event history of one sent email. */ export const getBrevoEmailContent = defineAction("Get Brevo email content") .describe("Retrieves personalized content and events for one sent email.") .account(BREVO_ACCOUNT) .input( z.object({ /** Email UUID or provider message ID from a send result. */ emailId: z.string().min(1), }), ) .output(EMAIL_CONTENT_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const brevo = getBrevoApi(account.secret) // Resolve once because a provider message ID requires a preliminary lookup. const uuid = z.uuid().safeParse(input.emailId).success ? input.emailId : z.object({ messageId: z.string(), uuid: z.uuid() }).parse( ( await parseBrevoResponse( await brevo.request("smtp/emails", { query: { limit: 1, messageId: input.emailId }, }), z.object({ transactionalEmails: z .object({ messageId: z.string(), uuid: z.uuid() }) .array() .min(1), }), ) ).transactionalEmails[0], ).uuid const result = await parseBrevoResponse( await brevo.request(`smtp/emails/${encodeURIComponent(uuid)}`), EMAIL_CONTENT_WIRE_SCHEMA, ) return { ...result, events: result.events ?? [] } }) /** Lists granular transactional email delivery events. */ export const listBrevoEmailEvents = defineAction("List Brevo email events") .describe("Lists filtered transactional email delivery events.") .account(BREVO_ACCOUNT) .input( z .object({ /** Number of previous days to include, up to 90. */ days: z.number().int().min(1).max(90).optional(), /** Recipient address filter. */ email: z.email().optional(), /** Inclusive end date in YYYY-MM-DD form. */ endDate: z.iso.date().optional(), /** Provider event-name filter. */ event: z .enum([ "bounces", "hardBounces", "softBounces", "delivered", "spam", "requests", "opened", "clicks", "invalid", "deferred", "blocked", "unsubscribed", "error", "loadedByProxy", ]) .optional(), /** Maximum events to return. */ limit: z.number().int().min(1).max(5_000).prefault(250), /** Provider message ID filter. */ messageId: z.string().min(1).optional(), /** Events to skip. */ offset: z.number().int().nonnegative().prefault(0), /** Provider sort order. */ sort: z.enum(["asc", "desc"]).prefault("desc"), /** Inclusive start date in YYYY-MM-DD form. */ startDate: z.iso.date().optional(), /** Reporting tag filter. */ tags: z.string().min(1).array().min(1).optional(), /** Template ID filter. */ templateId: z.number().int().positive().optional(), }) .refine( ({ days, endDate, startDate }) => days ? startDate === undefined && endDate === undefined : Boolean(startDate) === Boolean(endDate), { message: "Use days or provide both startDate and endDate, but not both.", }, ), ) .output(z.object({ events: EMAIL_EVENT_SCHEMA.array() })) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const { tags, ...query } = input return await parseBrevoResponse( await getBrevoApi(account.secret).request("smtp/statistics/events", { query: { ...query, tags: tags ? JSON.stringify(tags) : undefined }, }), z.object({ events: EMAIL_EVENT_SCHEMA.array() }), ) }) /** Lists Brevo transactional email templates with pagination and filters. */ export const listBrevoEmailTemplates = defineAction( "List Brevo email templates", ) .describe("Lists transactional email templates and their current content.") .account(BREVO_ACCOUNT) .input( z.object({ /** Restrict results to Brevo's rich-text editor templates. */ editorType: z.literal("richTextEditor").optional(), /** Number of templates to return. */ limit: z.number().int().min(1).max(1_000).prefault(50), /** Templates to skip. */ offset: z.number().int().nonnegative().prefault(0), /** Provider sort order. */ sort: z.enum(["asc", "desc"]).prefault("desc"), /** Filter by active or inactive state. */ templateStatus: z.boolean().optional(), }), ) .output( z.object({ count: z.number().int(), templates: TEMPLATE_SCHEMA.array() }), ) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const result = await parseBrevoResponse( await getBrevoApi(account.secret).request("smtp/templates", { query: input, }), z.object({ count: z.number().int().optional(), templates: TEMPLATE_SCHEMA.array().optional(), }), ) return { count: result.count ?? 0, templates: result.templates ?? [] } }) /** Retrieves one Brevo transactional email template by ID. */ export const getBrevoEmailTemplate = defineAction("Get Brevo email template") .describe("Retrieves one transactional email template and its content.") .account(BREVO_ACCOUNT) .input( z.object({ /** Numeric or custom Brevo template identifier. */ templateId: z.union([z.number().int().positive(), z.string().min(1)]), }), ) .output(TEMPLATE_SCHEMA) .retry({ replaySafety: "safe" }) .handler( async ({ account, input }) => await parseBrevoResponse( await getBrevoApi(account.secret).request( `smtp/templates/${encodeURIComponent(input.templateId)}`, ), TEMPLATE_SCHEMA, ), ) /** Creates a Brevo transactional email template. */ export const createBrevoEmailTemplate = defineAction( "Create Brevo email template", ) .describe("Creates a transactional email template from HTML or an HTML URL.") .account(BREVO_ACCOUNT) .input( TEMPLATE_FIELDS_SCHEMA.required({ sender: true, subject: true, templateName: true, }).refine( ({ htmlContent, htmlUrl }) => Boolean(htmlContent) !== Boolean(htmlUrl), { message: "Provide either htmlContent or htmlUrl.", }, ), ) .output(z.object({ id: z.number().int().positive() })) .retry({ replaySafety: "unsafe" }) .handler( async ({ account, input }) => await parseBrevoResponse( await getBrevoApi(account.secret).request("smtp/templates", { body: JSON.stringify(input), method: "POST", }), z.object({ id: z.number().int().positive() }), ), ) /** Updates selected properties of a Brevo transactional email template. */ export const updateBrevoEmailTemplate = defineAction( "Update Brevo email template", ) .describe("Updates selected properties of a transactional email template.") .account(BREVO_ACCOUNT) .input( TEMPLATE_FIELDS_SCHEMA.extend({ /** Numeric or custom Brevo template identifier. */ templateId: z.union([z.number().int().positive(), z.string().min(1)]), }), ) .output(z.void()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const { templateId, ...patch } = input await getBrevoApi(account.secret).request( `smtp/templates/${encodeURIComponent(templateId)}`, { body: JSON.stringify(patch), method: "PUT" }, ) }) /** Activates a Brevo transactional email template. */ export const activateBrevoEmailTemplate = defineTemplateStatusAction(true) /** Deactivates a Brevo transactional email template. */ export const deactivateBrevoEmailTemplate = defineTemplateStatusAction(false) /** * Defines a fixed template-status action. * * @param isActive - Provider state written by the action. */ function defineTemplateStatusAction(isActive: boolean) { return defineAction( `${isActive ? "Activate" : "Deactivate"} Brevo email template`, ) .describe( `${isActive ? "Activates" : "Deactivates"} a transactional email template.`, ) .account(BREVO_ACCOUNT) .input( z.object({ /** Numeric or custom Brevo template identifier. */ templateId: z.union([z.number().int().positive(), z.string().min(1)]), }), ) .output(z.void()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { await getBrevoApi(account.secret).request( `smtp/templates/${encodeURIComponent(input.templateId)}`, { body: JSON.stringify({ isActive }), method: "PUT" }, ) }) } /** * Maps code-first email fields to Brevo's transactional send shape. * * @param input - Validated email action input. */ function toBrevoEmail(input: z.output) { const { html, markdown, messageVersions, text, ...email } = input return { ...email, ...toBrevoBody({ html, markdown, text }), messageVersions: messageVersions?.map(toBrevoMessageVersion), } } /** * Maps one code-first message version to Brevo's provider field names. * * @param input - Validated message version. */ function toBrevoMessageVersion(input: z.output) { const { html, markdown, text, ...version } = input return { ...version, ...toBrevoBody({ html, markdown, text }), } } /** * Produces Brevo's HTML and plain-text content fields when supplied. * * @param body - Optional body formats to normalize. */ function toBrevoBody(body: z.output) { if (!hasEmailBody(body)) { return {} } const { html, text } = normalizeEmailBody(body) return { htmlContent: html, textContent: text } }