import * as z from "zod" import { defineAction } from "../../automation/actions" import { integrationScope as scope } from "../../automation/integrations" import { hasEmailBody, normalizeEmailBody, OPTIONAL_EMAIL_BODY_SCHEMA, } from "../../lib/email" import { getResendApi, RESEND_FULL_ACCESS_ACCOUNT_OPTIONS } from "./lib" const RESEND_ACCOUNT = "resend" const RESEND_BROADCAST_SEND_ACCOUNT_OPTIONS = { connections: [ { connectionMethodId: "oauth", requiredScope: scope.any("full_access", "emails:send"), }, { connectionMethodId: "api-key", requiredScope: "full_access" }, ], } as const const BROADCAST_STATUS_SCHEMA = z.enum(["draft", "sent", "queued"]) const BROADCAST_EVENT_TYPE_SCHEMA = z.enum([ "sent", "delivered", "opened", "clicked", "bounced", "complained", "unsubscribed", "suppressed", ]) const BOUNCE_TYPE_SCHEMA = z.enum(["permanent", "transient", "undetermined"]) const BOUNCE_TYPE_WIRE_SCHEMA = z .string() .toLowerCase() .pipe(BOUNCE_TYPE_SCHEMA) const BROADCAST_ID_SCHEMA = z.object({ id: z.string() }) const BROADCAST_MUTATION_SCHEMA = BROADCAST_ID_SCHEMA.extend({ object: z.literal("broadcast"), }) const BROADCAST_DELETE_SCHEMA = BROADCAST_MUTATION_SCHEMA.extend({ deleted: z.boolean(), }) const BROADCAST_WIRE_SCHEMA = z.object({ audience_id: z.string().nullable().optional(), created_at: z.string(), from: z.string().nullable(), html: z.string().nullable(), id: z.string(), name: z.string(), object: z.literal("broadcast"), preview_text: z.string().nullable(), reply_to: z.string().array().nullable(), scheduled_at: z.string().nullable(), segment_id: z.string().nullable(), sent_at: z.string().nullable(), status: BROADCAST_STATUS_SCHEMA, subject: z.string().nullable(), text: z.string().nullable(), topic_id: z.string().nullable().optional(), }) const BROADCAST_SCHEMA = BROADCAST_WIRE_SCHEMA.transform( ({ audience_id: _audienceId, created_at: createdAt, preview_text: previewText, reply_to: replyTo, scheduled_at: scheduledAt, segment_id: segmentId, sent_at: sentAt, topic_id: topicId, ...broadcast }) => ({ ...broadcast, createdAt, previewText, replyTo, scheduledAt, segmentId, sentAt, topicId, }), ) const BROADCAST_SUMMARY_WIRE_SCHEMA = BROADCAST_WIRE_SCHEMA.pick({ audience_id: true, created_at: true, id: true, name: true, scheduled_at: true, segment_id: true, sent_at: true, status: true, topic_id: true, }) const BROADCAST_SUMMARY_SCHEMA = BROADCAST_SUMMARY_WIRE_SCHEMA.transform( ({ audience_id: _audienceId, created_at: createdAt, scheduled_at: scheduledAt, segment_id: segmentId, sent_at: sentAt, topic_id: topicId, ...broadcast }) => ({ ...broadcast, createdAt, scheduledAt, segmentId, sentAt, topicId, }), ) const BROADCAST_LIST_WIRE_SCHEMA = z.object({ data: BROADCAST_SUMMARY_WIRE_SCHEMA.array(), has_more: z.boolean(), object: z.literal("list"), }) const BROADCAST_LIST_SCHEMA = z .object({ data: BROADCAST_SUMMARY_SCHEMA.array(), has_more: z.boolean(), object: z.literal("list"), }) .transform(({ has_more: hasMore, ...result }) => ({ ...result, hasMore })) const PAGINATION_FIELDS = { after: z.string().min(1).optional(), before: z.string().min(1).optional(), limit: z.number().int().min(1).max(100).prefault(20), } const PAGINATION_SCHEMA = z .object(PAGINATION_FIELDS) .refine(({ after, before }) => !(after && before), { message: "after and before cannot be used together.", }) const REPLY_TO_SCHEMA = z .union([z.string().min(1), z.string().min(1).array().min(1)]) .transform((value) => (Array.isArray(value) ? value : [value])) const BROADCAST_CONTENT_FIELDS = { ...OPTIONAL_EMAIL_BODY_SCHEMA.shape, from: z.string().trim().min(1), name: z.string().min(1).optional(), previewText: z.string().optional(), replyTo: REPLY_TO_SCHEMA.optional(), segmentId: z.string().min(1), subject: z.string().min(1), topicId: z.string().min(1).nullable().optional(), } const CREATE_BROADCAST_SCHEMA = z .object({ ...BROADCAST_CONTENT_FIELDS, scheduledAt: z.string().min(1).optional(), send: z.boolean().prefault(false), }) .superRefine((input, ctx) => { if (!hasEmailBody(input)) { ctx.addIssue({ code: "custom", message: "html, markdown, or text is required.", }) } if (input.scheduledAt && !input.send) { ctx.addIssue({ code: "custom", message: "scheduledAt requires send to be true.", }) } }) const UPDATE_BROADCAST_PATCH_SCHEMA = z .object({ ...BROADCAST_CONTENT_FIELDS, from: BROADCAST_CONTENT_FIELDS.from.optional(), segmentId: BROADCAST_CONTENT_FIELDS.segmentId.optional(), subject: BROADCAST_CONTENT_FIELDS.subject.optional(), }) .refine((patch) => Object.keys(patch).length > 0, { message: "Provide at least one broadcast field to update.", }) const CLICKED_LINK_WIRE_SCHEMA = z.object({ clicks: z.number().int().nonnegative(), id: z.string(), unique_clicks: z.number().int().nonnegative(), url: z.string(), }) const CLICKED_LINK_SCHEMA = CLICKED_LINK_WIRE_SCHEMA.transform( ({ unique_clicks: uniqueClicks, ...link }) => ({ ...link, uniqueClicks }), ) const CLICKED_LINK_LIST_WIRE_SCHEMA = z.object({ data: CLICKED_LINK_WIRE_SCHEMA.array(), has_more: z.boolean(), object: z.literal("list"), }) const CLICKED_LINK_LIST_SCHEMA = z .object({ data: CLICKED_LINK_SCHEMA.array(), has_more: z.boolean(), object: z.literal("list"), }) .transform(({ has_more: hasMore, ...result }) => ({ ...result, hasMore })) const RECIPIENT_WIRE_SCHEMA = z.object({ bounce_type: BOUNCE_TYPE_WIRE_SCHEMA.nullable().optional(), clicked_links: z .object({ clicks: z.number().int().nonnegative(), url: z.string() }) .array() .optional(), contact_id: z.string().nullable(), count: z.number().int().nonnegative().optional(), email: z.email(), id: z.string(), }) const RECIPIENT_SCHEMA = RECIPIENT_WIRE_SCHEMA.transform( ({ bounce_type: bounceType, clicked_links: clickedLinks, contact_id: contactId, ...recipient }) => ({ ...recipient, bounceType, clickedLinks, contactId }), ) const RECIPIENT_LIST_WIRE_SCHEMA = z.object({ data: RECIPIENT_WIRE_SCHEMA.array(), has_more: z.boolean(), object: z.literal("list"), }) const RECIPIENT_LIST_SCHEMA = z .object({ data: RECIPIENT_SCHEMA.array(), has_more: z.boolean(), object: z.literal("list"), }) .transform(({ has_more: hasMore, ...result }) => ({ ...result, hasMore })) /** Creates a draft, scheduled, or immediately sent Resend broadcast. */ export const createResendBroadcast = defineAction("Create Resend broadcast") .describe("Creates a broadcast for a segment and optionally sends it.") .account(RESEND_ACCOUNT, RESEND_FULL_ACCESS_ACCOUNT_OPTIONS) .input(CREATE_BROADCAST_SCHEMA) .output(BROADCAST_MUTATION_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { if (!hasEmailBody(input)) throw new Error("Broadcast content is required.") return await getResendApi(account).call("broadcasts", { body: toBroadcastWire(input), httpMethod: "POST", responseSchema: BROADCAST_MUTATION_SCHEMA, }) }) /** Lists Resend broadcasts with cursor pagination. */ export const listResendBroadcasts = defineAction("List Resend broadcasts") .describe("Lists Resend broadcasts with their delivery states.") .account(RESEND_ACCOUNT, RESEND_FULL_ACCESS_ACCOUNT_OPTIONS) .input(PAGINATION_SCHEMA) .output(BROADCAST_LIST_SCHEMA) .retry({ replaySafety: "safe" }) .handler( async ({ account, input }) => await getResendApi(account).call("broadcasts", { query: input, responseSchema: BROADCAST_LIST_WIRE_SCHEMA, }), ) /** Retrieves one Resend broadcast. */ export const getResendBroadcast = defineAction("Get Resend broadcast") .describe("Retrieves content and delivery state for one Resend broadcast.") .account(RESEND_ACCOUNT, RESEND_FULL_ACCESS_ACCOUNT_OPTIONS) .input(z.object({ broadcastId: z.string().min(1) })) .output(BROADCAST_SCHEMA) .retry({ replaySafety: "safe" }) .handler( async ({ account, input }) => await getResendApi(account).call( `broadcasts/${encodeURIComponent(input.broadcastId)}`, { responseSchema: BROADCAST_WIRE_SCHEMA }, ), ) /** Updates a draft Resend broadcast. */ export const updateResendBroadcast = defineAction("Update Resend broadcast") .describe("Updates content, metadata, or audience for a draft broadcast.") .account(RESEND_ACCOUNT, RESEND_FULL_ACCESS_ACCOUNT_OPTIONS) .input( z.object({ broadcastId: z.string().min(1), patch: UPDATE_BROADCAST_PATCH_SCHEMA, }), ) .output(BROADCAST_MUTATION_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { return await getResendApi(account).call( `broadcasts/${encodeURIComponent(input.broadcastId)}`, { body: toBroadcastPatchWire(input.patch), httpMethod: "PATCH", responseSchema: BROADCAST_MUTATION_SCHEMA, }, ) }) /** Permanently deletes a draft Resend broadcast. */ export const deleteResendBroadcast = defineAction("Delete Resend broadcast") .describe("Permanently deletes a draft Resend broadcast.") .account(RESEND_ACCOUNT, RESEND_FULL_ACCESS_ACCOUNT_OPTIONS) .input(z.object({ broadcastId: z.string().min(1) })) .output(BROADCAST_DELETE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler( async ({ account, input }) => await getResendApi(account).call( `broadcasts/${encodeURIComponent(input.broadcastId)}`, { httpMethod: "DELETE", responseSchema: BROADCAST_DELETE_SCHEMA }, ), ) /** Sends a draft Resend broadcast now or at a future time. */ export const sendResendBroadcast = defineAction("Send Resend broadcast") .describe("Sends or schedules an existing draft Resend broadcast.") .account(RESEND_ACCOUNT, RESEND_BROADCAST_SEND_ACCOUNT_OPTIONS) .input( z.object({ broadcastId: z.string().min(1), scheduledAt: z.string().min(1).optional(), }), ) .output(BROADCAST_ID_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler( async ({ account, input }) => await getResendApi(account).call( `broadcasts/${encodeURIComponent(input.broadcastId)}/send`, { body: input.scheduledAt === undefined ? {} : { scheduled_at: input.scheduledAt }, httpMethod: "POST", responseSchema: BROADCAST_ID_SCHEMA, }, ), ) /** Cancels a scheduled Resend broadcast. */ export const cancelResendBroadcast = defineAction("Cancel Resend broadcast") .describe("Cancels a Resend broadcast that has not started sending.") .account(RESEND_ACCOUNT, RESEND_FULL_ACCESS_ACCOUNT_OPTIONS) .input(z.object({ broadcastId: z.string().min(1) })) .output(BROADCAST_MUTATION_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler( async ({ account, input }) => await getResendApi(account).call( `broadcasts/${encodeURIComponent(input.broadcastId)}/cancel`, { httpMethod: "POST", responseSchema: BROADCAST_MUTATION_SCHEMA, }, ), ) /** Lists recipients matching one event for a Resend broadcast. */ export const listResendBroadcastRecipients = defineAction( "List Resend broadcast recipients", ) .describe("Lists broadcast recipients filtered by one delivery event.") .account(RESEND_ACCOUNT, RESEND_FULL_ACCESS_ACCOUNT_OPTIONS) .input( z .object({ ...PAGINATION_FIELDS, bounceType: BOUNCE_TYPE_SCHEMA.optional(), broadcastId: z.string().min(1), email: z.string().min(1).optional(), type: BROADCAST_EVENT_TYPE_SCHEMA, }) .superRefine((input, ctx) => { if (input.after && input.before) { ctx.addIssue({ code: "custom", message: "after and before cannot be used together.", }) } if (input.bounceType && input.type !== "bounced") { ctx.addIssue({ code: "custom", message: "bounceType can only be used with type bounced.", }) } }), ) .output(RECIPIENT_LIST_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const { bounceType, broadcastId, ...query } = input return await getResendApi(account).call( `broadcasts/${encodeURIComponent(broadcastId)}/recipients`, { query: { ...query, bounce_type: bounceType }, responseSchema: RECIPIENT_LIST_WIRE_SCHEMA, }, ) }) /** Lists clicked URLs and counts for a Resend broadcast. */ export const listResendBroadcastClickedLinks = defineAction( "List Resend broadcast clicked links", ) .describe("Lists clicked URLs and total and unique click counts.") .account(RESEND_ACCOUNT, RESEND_FULL_ACCESS_ACCOUNT_OPTIONS) .input(PAGINATION_SCHEMA.and(z.object({ broadcastId: z.string().min(1) }))) .output(CLICKED_LINK_LIST_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const { broadcastId, ...query } = input return await getResendApi(account).call( `broadcasts/${encodeURIComponent(broadcastId)}/clicked-links`, { query, responseSchema: CLICKED_LINK_LIST_WIRE_SCHEMA }, ) }) /** * Converts a broadcast create input to Resend's wire format. * * @param input - Validated broadcast create input. * @throws {Error} When broadcast content is absent. */ function toBroadcastWire(input: z.output) { if (!hasEmailBody(input)) throw new Error("Broadcast content is required.") const { from, name, previewText, replyTo, scheduledAt, segmentId, send, subject, topicId, } = input return { ...normalizeEmailBody(input), from, segment_id: segmentId, send, subject, ...(name === undefined ? {} : { name }), ...(previewText === undefined ? {} : { preview_text: previewText }), ...(replyTo === undefined ? {} : { reply_to: replyTo }), ...(scheduledAt === undefined ? {} : { scheduled_at: scheduledAt }), ...(topicId === undefined ? {} : { topic_id: topicId }), } } /** * Converts a broadcast patch to Resend's wire format. * * @param patch - Validated broadcast patch. */ function toBroadcastPatchWire( patch: z.output, ) { const { from, name, previewText, replyTo, segmentId, subject, topicId } = patch return { ...(hasEmailBody(patch) ? normalizeEmailBody(patch) : {}), ...(from === undefined ? {} : { from }), ...(name === undefined ? {} : { name }), ...(previewText === undefined ? {} : { preview_text: previewText }), ...(replyTo === undefined ? {} : { reply_to: replyTo }), ...(segmentId === undefined ? {} : { segment_id: segmentId }), ...(subject === undefined ? {} : { subject }), ...(topicId === undefined ? {} : { topic_id: topicId }), } }