import * as z from "zod" import { defineAction } from "../../../automation/actions" import { integrationScope as scope } from "../../../automation/integrations" import { apolloAccountOptions, getApolloApi, type ApolloApi } from "../lib/api" import { resolveApolloUser } from "../lib/references" import { APOLLO_PROVIDER_PAGINATION_SCHEMA, APOLLO_PROVIDER_SEQUENCE_SCHEMA, APOLLO_REFERENCE_SCHEMA, APOLLO_SEQUENCE_SCHEMA, toApolloSequence, } from "../lib/schemas" const STEP_TYPE_SCHEMA = z.enum([ "action_item", "auto_email", "call", "linkedin_step_connect", "linkedin_step_interact_post", "linkedin_step_message", "linkedin_step_view_profile", "manual_email", ]) const WAIT_MODE_SCHEMA = z.enum(["day", "hour", "minute"]) const CREATE_TOUCH_SCHEMA = z.object({ attachmentIds: z.string().array().optional(), includeSignature: z.boolean().optional(), status: z.enum(["approved", "to_be_reviewed"]).optional(), template: z .object({ bodyHtml: z.string().optional(), id: z.string().optional(), subject: z.string().optional(), }) .optional(), type: z.enum(["new_thread", "reply_to_thread"]).optional(), }) const CREATE_STEP_SCHEMA = z.object({ autoSkipAfterDays: z.number().int().positive().optional(), exactAt: z.iso.datetime({ offset: true }).optional(), maxEmailsPerDay: z.number().int().nonnegative().optional(), note: z.string().optional(), priority: z.enum(["high", "low", "medium"]).optional(), touches: CREATE_TOUCH_SCHEMA.array().optional(), type: STEP_TYPE_SCHEMA, waitMode: WAIT_MODE_SCHEMA.optional(), waitTime: z.number().int().nonnegative().optional(), }) const ACCESS_TYPE_SCHEMA = z.enum(["canView", "canEdit", "fullAccess"]) const SHARING_TARGET_TYPE_SCHEMA = z.enum(["user", "team", "subteam"]) const SHARING_PERMISSION_SCHEMA = z.object({ accessType: ACCESS_TYPE_SCHEMA.optional(), addedAccesses: z .object({ accessType: ACCESS_TYPE_SCHEMA, targetId: z.string().trim().min(1), targetType: SHARING_TARGET_TYPE_SCHEMA, }) .array() .optional(), deletedAccessIds: z.string().trim().min(1).array().optional(), updatedAccesses: z .object({ accessType: ACCESS_TYPE_SCHEMA, targetId: z.string().trim().min(1), }) .array() .optional(), visibility: z.enum(["everyone", "restricted"]).optional(), }) const UPDATE_TOUCH_SCHEMA = z.object({ attachmentIds: z.string().array().optional(), generationOptions: z .object({ additionalPrompt: z.string().optional(), fieldName: z.string().optional(), signals: z.string().array().optional(), type: z.string().optional(), }) .array() .optional(), generationTone: z .object({ sample: z.string().optional(), type: z.string().optional() }) .optional(), id: z.string().optional(), status: z.enum(["approved", "to_be_reviewed"]).optional(), template: z .object({ bodyHtml: z.string().optional(), subject: z.string().optional() }) .optional(), }) const UPDATE_STEP_SCHEMA = z .object({ autoSkipAfterDays: z.number().int().positive().optional(), id: z.string().optional(), position: z.number().int().positive().optional(), touches: UPDATE_TOUCH_SCHEMA.array().optional(), type: STEP_TYPE_SCHEMA.optional(), waitMode: WAIT_MODE_SCHEMA.optional(), waitTime: z.number().int().nonnegative().optional(), }) .superRefine((step, context) => { if (!step.id && !step.type) { context.addIssue({ code: "custom", message: "A new sequence step requires type.", path: ["type"], }) } if ((step.waitMode === undefined) !== (step.waitTime === undefined)) { context.addIssue({ code: "custom", message: "Provide waitMode and waitTime together.", path: [step.waitMode === undefined ? "waitMode" : "waitTime"], }) } }) const APOLLO_PROVIDER_STEP_SCHEMA = z.looseObject({ emailer_campaign_id: z.string().nullish(), id: z.string(), note: z.string().nullish(), position: z.number().int().nullish(), step_type: z.string().nullish(), type: z.string().nullish(), wait_mode: z.string().nullish(), wait_time: z.number().nullish(), }) const APOLLO_PROVIDER_TOUCH_SCHEMA = z.looseObject({ emailer_step_id: z.string().nullish(), emailer_template_id: z.string().nullish(), id: z.string(), status: z.string().nullish(), type: z.string().nullish(), }) const APOLLO_SEQUENCE_WRITE_RESPONSE_SCHEMA = z.looseObject({ emailer_campaign: APOLLO_PROVIDER_SEQUENCE_SCHEMA, emailer_steps: APOLLO_PROVIDER_STEP_SCHEMA.array().prefault([]), emailer_touches: APOLLO_PROVIDER_TOUCH_SCHEMA.array().prefault([]), }) const APOLLO_SEQUENCE_WRITE_RESULT_SCHEMA = z.object({ sequence: APOLLO_SEQUENCE_SCHEMA, steps: z .object({ id: z.string(), note: z.string().optional(), position: z.number().int().optional(), sequenceId: z.string().optional(), type: z.string().optional(), waitMode: z.string().optional(), waitTime: z.number().optional(), }) .array(), touches: z .object({ id: z.string(), status: z.string().optional(), stepId: z.string().optional(), templateId: z.string().optional(), type: z.string().optional(), }) .array(), }) const SEQUENCE_PAGE_SCHEMA = z.looseObject({ emailer_campaigns: APOLLO_PROVIDER_SEQUENCE_SCHEMA.array().prefault([]), pagination: APOLLO_PROVIDER_PAGINATION_SCHEMA, }) const CREATE_SEQUENCE_INPUT_SCHEMA = z .object({ active: z.boolean().prefault(false), createTaskAfterEmailOpens: z.boolean().optional(), emailOpenThreshold: z.number().int().positive().optional(), excludeAccountStageIds: z.string().array().optional(), excludeContactStageIds: z.string().array().optional(), folderId: z.string().optional(), ignoreGlobalBounceList: z.boolean().optional(), labelNames: z.string().array().optional(), markFinishedAfterClick: z.boolean().optional(), markFinishedAfterInterest: z.boolean().optional(), markFinishedAfterReply: z.boolean().optional(), markPausedAfterOutOfOffice: z.boolean().optional(), maxEmailsPerDay: z.number().int().nonnegative().optional(), name: z.string().trim().min(1), replyDelayDays: z.number().int().min(0).max(1000).optional(), responseTimeoutDays: z.number().int().positive().optional(), scheduleId: z.string().optional(), sequenceByExactTime: z.boolean().prefault(false), steps: CREATE_STEP_SCHEMA.array().optional(), }) .superRefine((input, context) => { if (input.active && !input.steps?.length) { context.addIssue({ code: "custom", message: "An active sequence requires at least one step.", path: ["steps"], }) } for (const [index, step] of (input.steps ?? []).entries()) { if ( input.sequenceByExactTime ? !step.exactAt : step.waitTime === undefined || !step.waitMode ) { context.addIssue({ code: "custom", message: input.sequenceByExactTime ? "Exact-time sequences require exactAt on every step." : "Relative sequences require waitTime and waitMode on every step.", path: ["steps", index], }) } } }) /** Creates an Apollo sequence, including ordered steps and email touches. */ export const createApolloSequence = defineAction("Create Apollo sequence") .describe( "Creates a sequence with optional steps, email touches, and activation.", ) .account("apollo", apolloAccountOptions("emailer_campaigns_create")) .input(CREATE_SEQUENCE_INPUT_SCHEMA) .output(APOLLO_SEQUENCE_WRITE_RESULT_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const result = await getApolloApi(account).request("sequences", { body: { active: input.active, create_task_if_email_open: input.createTaskAfterEmailOpens, email_open_trigger_task_threshold: input.emailOpenThreshold, emailer_schedule_id: input.scheduleId, emailer_steps: input.steps?.map(toProviderStep), excluded_account_stage_ids: input.excludeAccountStageIds, excluded_contact_stage_ids: input.excludeContactStageIds, folder_id: input.folderId, ignore_apollo_global_email_bounce_list: input.ignoreGlobalBounceList, label_names: input.labelNames, mark_finished_if_click: input.markFinishedAfterClick, mark_finished_if_interested: input.markFinishedAfterInterest, mark_finished_if_reply: input.markFinishedAfterReply, mark_paused_if_ooo: input.markPausedAfterOutOfOffice, max_emails_per_day: input.maxEmailsPerDay, name: input.name, same_account_reply_delay_days: input.replyDelayDays, days_to_wait_before_mark_as_response: input.responseTimeoutDays, sequence_by_exact_daytime: input.sequenceByExactTime, }, responseSchema: APOLLO_SEQUENCE_WRITE_RESPONSE_SCHEMA, }) return { sequence: toApolloSequence(result.emailer_campaign), steps: result.emailer_steps.map(toPublicStep), touches: result.emailer_touches.map(toPublicTouch), } }) /** Updates an Apollo sequence and optionally replaces its ordered steps. */ export const updateApolloSequence = defineAction("Update Apollo sequence") .describe( "Updates sequence settings and replaces steps when steps are supplied.", ) .account( "apollo", apolloAccountOptions( scope.and( "emailer_campaigns_update", "emailer_campaigns_search", "users_list", ), ), ) .input( z .object({ active: z.boolean().optional(), bccEmails: z.email().array().optional(), ccEmails: z.email().array().optional(), creationType: z.enum(["aiAssistant", "draft", "new"]).optional(), labelNames: z.string().array().optional(), maxEmailsPerDay: z.number().int().nonnegative().optional(), name: z.string().trim().min(1).optional(), replyDelayDays: z.number().int().min(0).max(1000).optional(), scheduleId: z.string().optional(), sequence: APOLLO_REFERENCE_SCHEMA, sharingPermission: SHARING_PERMISSION_SCHEMA.optional(), steps: UPDATE_STEP_SCHEMA.array().optional(), user: APOLLO_REFERENCE_SCHEMA.optional(), }) .refine( (input) => input.active !== undefined || input.bccEmails !== undefined || input.ccEmails !== undefined || input.creationType !== undefined || input.labelNames !== undefined || input.maxEmailsPerDay !== undefined || input.name !== undefined || input.replyDelayDays !== undefined || input.scheduleId !== undefined || input.sharingPermission !== undefined || input.steps !== undefined || input.user !== undefined, "Provide at least one sequence field to update.", ), ) .output(APOLLO_SEQUENCE_WRITE_RESULT_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const api = getApolloApi(account) const [sequenceId, userId] = await Promise.all([ resolveApolloSequence(api, input.sequence), input.user ? resolveApolloUser(api, input.user) : undefined, ]) const result = await api.request( `sequences/${encodeURIComponent(sequenceId)}`, { body: { active: input.active, bcc_emails: input.bccEmails?.join(","), cc_emails: input.ccEmails?.join(","), creation_type: input.creationType === "aiAssistant" ? "ai_assistant" : input.creationType, emailer_schedule_id: input.scheduleId, emailer_steps: input.steps?.map(toProviderUpdateStep), label_names: input.labelNames, max_emails_per_day: input.maxEmailsPerDay, name: input.name, sharing_permission: input.sharingPermission ? toProviderSharingPermission(input.sharingPermission) : undefined, same_account_reply_delay_days: input.replyDelayDays, user_id: userId, }, method: "PUT", responseSchema: APOLLO_SEQUENCE_WRITE_RESPONSE_SCHEMA, }, ) return { sequence: toApolloSequence(result.emailer_campaign), steps: result.emailer_steps.map(toPublicStep), touches: result.emailer_touches.map(toPublicTouch), } }) /** * Resolves a sequence by explicit ID, ID, or unique exact name. * * @param api - Authenticated Apollo client. * @param reference - Semantic or explicit sequence reference. */ async function resolveApolloSequence( api: ApolloApi, reference: z.output, ) { if (typeof reference !== "string") return reference.id const matches = (await listAllApolloSequences(api)).filter( ({ id, name }) => id === reference || name.localeCompare(reference, undefined, { sensitivity: "accent" }) === 0, ) if (matches.length === 1) return matches[0]!.id throw new Error( matches.length === 0 ? `Apollo sequence not found: ${reference}` : `Apollo sequence reference is ambiguous: ${reference}. Matching IDs: ${matches.map(({ id }) => id).join(", ")}`, ) } /** * Lists every Apollo sequence for semantic reference resolution. * * @param api - Authenticated Apollo client. */ async function listAllApolloSequences(api: ApolloApi) { const sequences: z.output[] = [] let page = 1 let totalPages = 1 do { const result = await api.request("emailer_campaigns/search", { method: "POST", query: { page, per_page: 100 }, responseSchema: SEQUENCE_PAGE_SCHEMA, }) sequences.push(...result.emailer_campaigns) totalPages = result.pagination.total_pages page += 1 } while (page <= totalPages) return sequences } /** * Converts one public sequence step to Apollo's provider shape. * * @param step - Public sequence step. */ function toProviderStep(step: z.output) { return { auto_skip_in_x_days: step.autoSkipAfterDays, emailer_touches: step.touches?.map((touch) => ({ attachment_ids: touch.attachmentIds, emailer_template: touch.template && { body_html: touch.template.bodyHtml, id: touch.template.id, subject: touch.template.subject, }, include_signature: touch.includeSignature, status: touch.status, type: touch.type, })), exact_datetime: step.exactAt, max_emails_per_day: step.maxEmailsPerDay, note: step.note, priority: step.priority, type: step.type, wait_mode: step.waitMode, wait_time: step.waitTime, } } /** * Converts one public sequence update step to Apollo's provider shape. * * @param step - Public update step. */ function toProviderUpdateStep(step: z.output) { return { auto_skip_in_x_days: step.autoSkipAfterDays, emailer_touches: step.touches?.map((touch) => ({ attachment_ids: touch.attachmentIds, emailer_template: touch.template && { body_html: touch.template.bodyHtml, subject: touch.template.subject, }, generation_options: touch.generationOptions?.map((option) => ({ additional_prompt: option.additionalPrompt, field_name: option.fieldName, signals: option.signals, type: option.type, })), generation_tone: touch.generationTone && { tone_sample: touch.generationTone.sample, tone_type: touch.generationTone.type, }, id: touch.id, status: touch.status, })), id: step.id, position: step.position, type: step.type, wait_mode: step.waitMode, wait_time: step.waitTime, } } /** * Converts public sequence sharing settings to Apollo's provider shape. * * @param permission - Public sharing settings. */ function toProviderSharingPermission( permission: z.output, ) { return { access_type: permission.accessType ? toProviderAccessType(permission.accessType) : undefined, added_sharing_accesses: permission.addedAccesses?.map((access) => ({ access_type: toProviderAccessType(access.accessType), user_or_team_id: access.targetId, user_or_team_type: access.targetType, })), deleted_sharing_accesses: permission.deletedAccessIds?.map((targetId) => ({ user_or_team_id: targetId, })), updated_sharing_accesses: permission.updatedAccesses?.map((access) => ({ access_type: toProviderAccessType(access.accessType), user_or_team_id: access.targetId, })), visibility: permission.visibility, } } /** * Converts a public sequence access level to Apollo's provider spelling. * * @param value - Public access level. */ function toProviderAccessType(value: z.output) { return value === "canView" ? "can_view" : value === "canEdit" ? "can_edit" : "full_access" } /** * Converts one provider sequence step to its public representation. * * @param value - Provider sequence step. */ function toPublicStep(value: z.output) { return { id: value.id, note: value.note ?? undefined, position: value.position ?? undefined, sequenceId: value.emailer_campaign_id ?? undefined, type: value.type ?? value.step_type ?? undefined, waitMode: value.wait_mode ?? undefined, waitTime: value.wait_time ?? undefined, } } /** * Converts one provider sequence touch to its public representation. * * @param value - Provider sequence touch. */ function toPublicTouch(value: z.output) { return { id: value.id, status: value.status ?? undefined, stepId: value.emailer_step_id ?? undefined, templateId: value.emailer_template_id ?? undefined, type: value.type ?? undefined, } }