diff --git a/README.md b/README.md index 96433c9..22dd35f 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Connect Sequenzy to Claude Desktop, Claude Code, Codex, Cursor, Windsurf, VS Cod - Supply localized template variants or queue AI translation for enabled locales. - Create, edit, publish, unpublish, and delete landing pages. - Create list-scoped saved signup forms and return client-safe static-site embeds. +- Create, edit, publish, unpublish, and delete saved signup popups, and return client-safe embed recipes. - Connect and verify custom domains for published landing pages. - Manage team invitations, inbox conversations, and outbound webhook endpoints. - Generate email copy, subject lines, and multi-step sequences. @@ -237,7 +238,7 @@ build a list as well as create it. Imports that apply `listIds` also need ## Tools -This server currently exposes 194 MCP tools. +This server currently exposes 202 MCP tools. Tools reject arguments they do not declare instead of silently ignoring them. Errors name the unsupported fields, list the supported arguments, and provide @@ -250,7 +251,7 @@ sort options. | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | | `get_account` | Get account info, available companies, current key permissions, and the API Keys management URL. | | `select_company` | Set the active company for future tool calls. | -| `get_app_urls` | Build dashboard URLs for campaigns, landing pages, sequences, emails, settings, domains, and sent email details. | +| `get_app_urls` | Build dashboard URLs for campaigns, landing pages, popups, sequences, emails, settings, domains, and sent email details. | | `create_company` | Create a new company or brand. | | `get_company` | Read company details, product info, brand context, localization, reply-tracking settings, and current From/Reply-To defaults. | | `update_company` | Edit product info, brand context, email theme, reply tracking, and account-wide From/Reply-To profile defaults or names. | @@ -695,6 +696,30 @@ into the current theme. Pass an empty `tagIds` array to clear tags or an empty a complete replacement, so read the current content with `list_forms` first and retain exactly one email field and one submit button. +### Saved Popups + +| Tool | Description | +| ----------------- | ------------------------------------------------------------------------------------------------------------------- | +| `list_popups` | List saved popups with complete content, status, triggers, targeting, metrics, and dashboard URLs. | +| `get_popup` | Get one popup's complete builder content, lifecycle state, and metrics. | +| `create_popup` | Create and immediately publish a popup from a built-in template or complete content. | +| `update_popup` | Rename a popup or replace its complete content. | +| `publish_popup` | Publish a popup, optionally saving edits first, and return client-safe embed recipes. | +| `unpublish_popup` | Return a popup to draft, optionally saving edits first. | +| `delete_popup` | Permanently delete a popup; existing deployed embeds stop working immediately. | +| `get_popup_embed` | Return the versioned script URL and HTML, React, WordPress, and Shopify installation recipes for a published popup. | + +Start with `list_popups` or `get_popup` before replacing `content`: popup +content updates replace the complete editor-compatible object instead of +merging nested fields. `create_popup` accepts either a built-in template or +complete content, never both. The service validates required form blocks, +click selectors, redirect URLs, and that targeted lists and tags belong to the +selected company. + +Embed recipes contain only public URLs and the opaque popup ID, never a +Sequenzy API key. A popup must be published before `get_popup_embed` returns +installation code. + ### Landing Pages | Tool | Description | diff --git a/src/app-urls.test.ts b/src/app-urls.test.ts index fea7803..8c1e118 100644 --- a/src/app-urls.test.ts +++ b/src/app-urls.test.ts @@ -9,6 +9,7 @@ describe("buildSequenzyAppUrls", () => { companyId: "comp_123", campaignId: "camp_123", landingPageId: "lp_123", + popupId: "popup_123", sequenceId: "seq_123", templateId: "email_123", emailSendId: "send_123", @@ -25,6 +26,9 @@ describe("buildSequenzyAppUrls", () => { expect(appUrls.urls.landingPage).toBe( "https://app.example.com/dashboard/company/comp_123/landing-pages/lp_123" ); + expect(appUrls.urls.popup).toBe( + "https://app.example.com/dashboard/company/comp_123/popups/popup_123" + ); expect(appUrls.urls.sequence).toBe( "https://app.example.com/dashboard/company/comp_123/sequences/seq_123" ); @@ -46,6 +50,9 @@ describe("buildSequenzyAppUrls", () => { expect(appUrls.routeTemplates.landingPage).toBe( "/dashboard/company/{companyId}/landing-pages/{landingPageId}" ); + expect(appUrls.routeTemplates.popup).toBe( + "/dashboard/company/{companyId}/popups/{popupId}" + ); expect(appUrls.settingsTabValues).toContain("integrations"); }); }); diff --git a/src/app-urls.ts b/src/app-urls.ts index 3638e91..d98e481 100644 --- a/src/app-urls.ts +++ b/src/app-urls.ts @@ -29,6 +29,8 @@ export const routeTemplates = { campaignList: "/dashboard/company/{companyId}/campaign/list/{status}", landingPages: "/dashboard/company/{companyId}/landing-pages", landingPage: "/dashboard/company/{companyId}/landing-pages/{landingPageId}", + popups: "/dashboard/company/{companyId}/popups", + popup: "/dashboard/company/{companyId}/popups/{popupId}", sequences: "/dashboard/company/{companyId}/sequences", sequence: "/dashboard/company/{companyId}/sequences/{sequenceId}", sequenceList: "/dashboard/company/{companyId}/sequences/list/{status}", @@ -53,6 +55,7 @@ export interface AppUrlInput { companyId?: string | null; campaignId?: string | null; landingPageId?: string | null; + popupId?: string | null; sequenceId?: string | null; emailId?: string | null; templateId?: string | null; @@ -136,6 +139,7 @@ export function buildSequenzyAppUrls( urls.dashboard = joinUrl(appUrl, companyPath(companyId)); urls.campaigns = joinUrl(appUrl, companyPath(companyId, "/campaign")); urls.landingPages = joinUrl(appUrl, companyPath(companyId, "/landing-pages")); + urls.popups = joinUrl(appUrl, companyPath(companyId, "/popups")); urls.sequences = joinUrl(appUrl, companyPath(companyId, "/sequences")); urls.settings = joinUrl(appUrl, companyPath(companyId, "/settings")); urls.emails = joinUrl(appUrl, companyPath(companyId, "/emails")); @@ -165,6 +169,14 @@ export function buildSequenzyAppUrls( ); } + const popupId = clean(input.popupId); + if (popupId) { + urls.popup = joinUrl( + appUrl, + companyPath(companyId, `/popups/${pathSegment(popupId)}`) + ); + } + const sequenceId = clean(input.sequenceId); if (sequenceId) { urls.sequence = joinUrl( diff --git a/src/tools/definitions/account.ts b/src/tools/definitions/account.ts index 27e6fab..366a88d 100644 --- a/src/tools/definitions/account.ts +++ b/src/tools/definitions/account.ts @@ -35,7 +35,7 @@ The response shows 'companies' (all available) and 'selectedCompanyId' (currentl { name: "get_app_urls", description: - "Generate Sequenzy dashboard URLs for known resource IDs. Use this when the user asks where to review or edit a generated sequence, campaign, template, or company settings. If companyId is omitted, the selected/current company is used when available.", + "Generate Sequenzy dashboard URLs for known resource IDs. Use this when the user asks where to review or edit a generated sequence, campaign, landing page, popup, template, or company settings. If companyId is omitted, the selected/current company is used when available.", inputSchema: { type: "object", properties: { @@ -52,6 +52,10 @@ The response shows 'companies' (all available) and 'selectedCompanyId' (currentl type: "string", description: "Landing page ID for the landing page editor URL.", }, + popupId: { + type: "string", + description: "Popup ID for the popup editor URL.", + }, sequenceId: { type: "string", description: "Sequence ID for the sequence editor URL.", @@ -664,7 +668,7 @@ The response shows 'companies' (all available) and 'selectedCompanyId' (currentl { name: "list_websites", description: - "List configured sending domains with separate DNS verification, selected home-transport readiness, and SPF, DKIM, and MAIL FROM status", + "List configured sending domains with separate DNS verification, sending readiness, and SPF, DKIM, and MAIL FROM status", inputSchema: { type: "object", properties: { @@ -724,7 +728,7 @@ The response shows 'companies' (all available) and 'selectedCompanyId' (currentl { name: "check_website", description: - "Read a sending domain's separate DNS verification, selected home-transport readiness, and SPF, DKIM, MAIL FROM diagnostics. Use verify_sending_domain to run a fresh DNS check.", + "Read a sending domain's separate DNS verification, sending readiness, and SPF, DKIM, MAIL FROM diagnostics. Use verify_sending_domain to run a fresh DNS check.", inputSchema: { type: "object", properties: { @@ -744,7 +748,7 @@ The response shows 'companies' (all available) and 'selectedCompanyId' (currentl { name: "verify_sending_domain", description: - "Run a fresh DNS check for a configured sending domain and return DNS verification separately from selected home-transport readiness. A DNS-verified domain may still be activating in SES.", + "Run a fresh DNS check for a configured sending domain and return DNS verification separately from sending readiness. A DNS-verified domain may still be activating; when readyToSend is false, read readiness.reason rather than the DKIM/SPF/MAIL FROM record statuses, which describe DNS only.", inputSchema: { type: "object", properties: { @@ -1024,7 +1028,7 @@ Use cases: - 'event_tracking': Tracking CUSTOM events only (not payment events - those come from the integration) - 'ecommerce': Connecting a custom e-commerce platform via the Commerce API (sync products, push orders/checkouts, power abandoned cart + back-in-stock automations) -Before protected server-side API work, use create_api_key and save the key to .env as SEQUENZY_API_KEY. Static-site saved forms are the exception: use list_forms/create_form/get_form_embed and never place a secret key in browser code.`, +Before protected server-side API work, use create_api_key and save the key to .env as SEQUENZY_API_KEY. Saved forms and popups are the exceptions: use list_forms/create_form/get_form_embed or list_popups/create_popup/get_popup_embed, and never place a secret key in browser code.`, inputSchema: { type: "object", properties: { diff --git a/src/tools/definitions/index.ts b/src/tools/definitions/index.ts index 0f7fa5c..4e44ab6 100644 --- a/src/tools/definitions/index.ts +++ b/src/tools/definitions/index.ts @@ -12,6 +12,7 @@ import { imageAssetToolDefinitions } from "./image-assets.js"; import { inboxToolDefinitions } from "./inbox.js"; import { integrationToolDefinitions } from "./integrations.js"; import { landingPageToolDefinitions } from "./landing-pages.js"; +import { savedPopupToolDefinitions } from "./popups.js"; import { productToolDefinitions } from "./products.js"; import { renderToolDefinitions } from "./render.js"; import { sequenceGoalToolDefinitions } from "./sequence-goals.js"; @@ -39,6 +40,7 @@ export const toolDefinitions: Tool[] = [ ...campaignToolDefinitions, ...renderToolDefinitions, ...savedFormToolDefinitions, + ...savedPopupToolDefinitions, ...landingPageToolDefinitions, ...imageAssetToolDefinitions, ...sequenceBasicToolDefinitions, diff --git a/src/tools/definitions/popups.ts b/src/tools/definitions/popups.ts new file mode 100644 index 0000000..54a3dee --- /dev/null +++ b/src/tools/definitions/popups.ts @@ -0,0 +1,157 @@ +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; + +import { + popupContentDescription, + popupTemplateDescription, +} from "../internal.js"; + +const companyIdProperty = { + type: "string", + description: + "Company ID. If not provided, uses the currently selected company.", +} as const; + +const popupIdProperty = { + type: "string", + description: "Saved popup ID returned by list_popups or create_popup.", +} as const; + +const popupContentProperty = { + type: "object", + description: popupContentDescription, + additionalProperties: true, +} as const; + +const popupMutationProperties = { + companyId: companyIdProperty, + popupId: popupIdProperty, + name: { + type: "string", + description: "Optional popup name update.", + }, + content: popupContentProperty, +} as const; + +export const savedPopupToolDefinitions: Tool[] = [ + { + name: "list_popups", + description: + "List saved signup popups with their complete content, trigger, targeting, status, metrics, and dashboard URLs.", + inputSchema: { + type: "object", + properties: { companyId: companyIdProperty }, + additionalProperties: false, + }, + }, + { + name: "get_popup", + description: + "Get one saved popup's complete content, trigger, targeting, status, metrics, and dashboard URL.", + inputSchema: { + type: "object", + properties: { + companyId: companyIdProperty, + popupId: popupIdProperty, + }, + required: ["popupId"], + additionalProperties: false, + }, + }, + { + name: "create_popup", + description: + "Create and immediately publish a saved signup popup. Start from a template or provide complete popup content, but not both. The returned embed is client-safe and contains no API key.", + inputSchema: { + type: "object", + properties: { + companyId: companyIdProperty, + name: { + type: "string", + description: "Internal popup name.", + }, + template: { + type: "string", + enum: [ + "newsletter-modal", + "discount-offer", + "countdown-launch", + "minimal-slide-in", + "exit-lead-magnet", + "live-demo", + "launch-modal", + "paper-digest", + "stark-takeover", + "top-bar", + "announcement-bar", + "fullscreen-welcome", + ], + description: popupTemplateDescription, + }, + content: popupContentProperty, + }, + required: ["name"], + additionalProperties: false, + }, + }, + { + name: "update_popup", + description: + "Update a popup's name or replace its complete content. Read the current popup first before changing content.", + inputSchema: { + type: "object", + properties: popupMutationProperties, + required: ["popupId"], + additionalProperties: false, + }, + }, + { + name: "publish_popup", + description: + "Publish a popup, optionally saving a name or complete content update first. Returns client-safe embed code.", + inputSchema: { + type: "object", + properties: popupMutationProperties, + required: ["popupId"], + additionalProperties: false, + }, + }, + { + name: "unpublish_popup", + description: + "Unpublish a popup, optionally saving a name or complete content update first. Existing embeds remain installed but load a no-op script until republished.", + inputSchema: { + type: "object", + properties: popupMutationProperties, + required: ["popupId"], + additionalProperties: false, + }, + }, + { + name: "delete_popup", + description: + "Permanently delete a popup, including a published popup. Existing deployed embeds stop working immediately.", + inputSchema: { + type: "object", + properties: { + companyId: companyIdProperty, + popupId: popupIdProperty, + }, + required: ["popupId"], + additionalProperties: false, + }, + }, + { + name: "get_popup_embed", + description: + "Get a published popup's versioned script URL and HTML, React, WordPress, and Shopify embed recipes. The snippets are client-safe and contain no API key.", + inputSchema: { + type: "object", + properties: { + companyId: companyIdProperty, + popupId: popupIdProperty, + }, + required: ["popupId"], + additionalProperties: false, + }, + }, +]; diff --git a/src/tools/descriptions.ts b/src/tools/descriptions.ts index 9a63727..b7c110f 100644 --- a/src/tools/descriptions.ts +++ b/src/tools/descriptions.ts @@ -94,6 +94,12 @@ export const landingPageContentDescription = export const landingPageTemplateDescription = "Optional template key for default content, such as from-scratch, waitlist, lead-magnet, launch, demo-request, webinar, newsletter, product-hunt, pricing-offer, agency-lead-gen, or feature-announcement."; +export const popupTemplateDescription = + "Optional popup template key: newsletter-modal, discount-offer, countdown-launch, minimal-slide-in, exit-lead-magnet, live-demo, launch-modal, paper-digest, stark-takeover, top-bar, announcement-bar, or fullscreen-welcome."; + +export const popupContentDescription = + 'Complete Sequenzy popup content JSON. Use this for an exact full replacement after reading the current content with get_popup. It must include version: 1, surface: "popup", template, presentation, placement, theme, settings, trigger, targeting, schedule, frequency, visual, and blocks. The main form must retain exactly one required email field and one submit button. Click triggers require trigger.clickSelector. settings.listIds and tagIds must belong to the selected company, and redirect success actions require an HTTP or HTTPS URL.'; + export const ADD_SUBSCRIBERS_TO_LIST_EMAIL_LIMIT = 500; export const SEQUENCE_ENROLLMENT_TARGET_LIMIT = 500; diff --git a/src/tools/handlers/account.ts b/src/tools/handlers/account.ts index eb8d9a7..ab53123 100644 --- a/src/tools/handlers/account.ts +++ b/src/tools/handlers/account.ts @@ -65,6 +65,7 @@ export async function handleAccountTools( companyId, campaignId: optionalString(args, "campaignId"), landingPageId: optionalString(args, "landingPageId"), + popupId: optionalString(args, "popupId"), sequenceId: optionalString(args, "sequenceId"), emailId: optionalString(args, "emailId") ?? optionalString(args, "templateId"), diff --git a/src/tools/handlers/index.ts b/src/tools/handlers/index.ts index c642078..effbd6f 100644 --- a/src/tools/handlers/index.ts +++ b/src/tools/handlers/index.ts @@ -7,6 +7,7 @@ import { handleSavedFormTools } from "./forms.js"; import { handleImageAssetTools } from "./image-assets.js"; import { handleIntegrationTools } from "./integrations.js"; import { handleLandingPageTools } from "./landing-pages.js"; +import { handleSavedPopupTools } from "./popups.js"; import { handleProductTools } from "./products.js"; import { handleRenderTools } from "./render.js"; import { handleSequenceTools } from "./sequences.js"; @@ -24,6 +25,7 @@ export const toolHandlers = [ handleCampaignTools, handleRenderTools, handleSavedFormTools, + handleSavedPopupTools, handleLandingPageTools, handleImageAssetTools, handleSequenceTools, diff --git a/src/tools/handlers/popups.ts b/src/tools/handlers/popups.ts new file mode 100644 index 0000000..6e2927e --- /dev/null +++ b/src/tools/handlers/popups.ts @@ -0,0 +1,126 @@ +import { apiRequest } from "../../runtime.js"; +import { isRecord, requiredString } from "../internal.js"; + +function popupMutationBody( + toolName: string, + args: Record, + requireUpdate: boolean +): Record { + if (args.content !== undefined && !isRecord(args.content)) { + throw new Error( + `\`content\` must be an object when calling \`${toolName}\`.` + ); + } + + const body = { + ...(args.name !== undefined && { name: args.name }), + ...(args.content !== undefined && { content: args.content }), + }; + if (requireUpdate && Object.keys(body).length === 0) { + throw new Error( + `Provide at least one of \`name\` or \`content\` when calling \`${toolName}\`.` + ); + } + return body; +} + +export async function handleSavedPopupTools( + name: string, + args: Record +): Promise<{ handled: boolean; result: unknown }> { + const companyId = args.companyId as string | undefined; + let result: unknown; + + switch (name) { + case "list_popups": + result = await apiRequest("GET", "/api/v1/popups", undefined, companyId); + break; + + case "get_popup": { + const popupId = requiredString(name, args, "popupId"); + result = await apiRequest( + "GET", + `/api/v1/popups/${encodeURIComponent(popupId)}`, + undefined, + companyId + ); + break; + } + + case "create_popup": { + const popupName = requiredString(name, args, "name"); + if (args.template !== undefined && args.content !== undefined) { + throw new Error( + "Provide either `template` or `content` when calling `create_popup`, not both." + ); + } + if (args.content !== undefined && !isRecord(args.content)) { + throw new Error( + "`content` must be an object when calling `create_popup`." + ); + } + result = await apiRequest( + "POST", + "/api/v1/popups", + { + name: popupName, + ...(args.template !== undefined && { template: args.template }), + ...(args.content !== undefined && { content: args.content }), + }, + companyId + ); + break; + } + + case "update_popup": { + const popupId = requiredString(name, args, "popupId"); + result = await apiRequest( + "PATCH", + `/api/v1/popups/${encodeURIComponent(popupId)}`, + popupMutationBody(name, args, true), + companyId + ); + break; + } + + case "publish_popup": + case "unpublish_popup": { + const popupId = requiredString(name, args, "popupId"); + const action = name === "publish_popup" ? "publish" : "unpublish"; + result = await apiRequest( + "POST", + `/api/v1/popups/${encodeURIComponent(popupId)}/${action}`, + popupMutationBody(name, args, false), + companyId + ); + break; + } + + case "delete_popup": { + const popupId = requiredString(name, args, "popupId"); + result = await apiRequest( + "DELETE", + `/api/v1/popups/${encodeURIComponent(popupId)}`, + undefined, + companyId + ); + break; + } + + case "get_popup_embed": { + const popupId = requiredString(name, args, "popupId"); + result = await apiRequest( + "GET", + `/api/v1/popups/embed/${encodeURIComponent(popupId)}`, + undefined, + companyId + ); + break; + } + + default: + return { handled: false, result: undefined }; + } + + return { handled: true, result }; +} diff --git a/src/tools/index.test.ts b/src/tools/index.test.ts index 4aa0b43..01c8701 100644 --- a/src/tools/index.test.ts +++ b/src/tools/index.test.ts @@ -4391,6 +4391,188 @@ describe("saved form tools", () => { }); }); +describe("saved popup tools", () => { + beforeEach(() => { + mockApiRequest.mockClear(); + }); + + it("publishes the complete popup lifecycle with plain schemas and safety hints", () => { + const toolNames = tools.map((tool) => tool.name); + for (const name of [ + "list_popups", + "get_popup", + "create_popup", + "update_popup", + "publish_popup", + "unpublish_popup", + "delete_popup", + "get_popup_embed", + ]) { + expect(toolNames).toContain(name); + } + + const createTool = tools.find((tool) => tool.name === "create_popup"); + const updateTool = tools.find((tool) => tool.name === "update_popup"); + const publishTool = tools.find((tool) => tool.name === "publish_popup"); + const deleteTool = tools.find((tool) => tool.name === "delete_popup"); + const embedTool = tools.find((tool) => tool.name === "get_popup_embed"); + + expect(createTool?.inputSchema).toMatchObject({ + type: "object", + required: ["name"], + additionalProperties: false, + }); + expect(createTool?.inputSchema.properties).toHaveProperty("template"); + expect(createTool?.inputSchema.properties).toHaveProperty("content"); + expect(updateTool?.inputSchema.required).toEqual(["popupId"]); + expect(updateTool?.inputSchema.additionalProperties).toBe(false); + expect(embedTool?.annotations?.readOnlyHint).toBe(true); + expect(createTool?.annotations).toMatchObject({ + readOnlyHint: false, + destructiveHint: false, + openWorldHint: true, + }); + expect(publishTool?.annotations).toMatchObject({ + readOnlyHint: false, + destructiveHint: false, + openWorldHint: true, + }); + expect(deleteTool?.annotations).toMatchObject({ + readOnlyHint: false, + destructiveHint: true, + }); + }); + + it("routes every popup lifecycle tool with encoded popup IDs", async () => { + const popup = { id: "popup/123", status: "published" }; + const embed = { + scriptUrl: "https://sequenzy.com/embed/popups/popup%2F123", + }; + mockApiRequest + .mockResolvedValueOnce({ success: true, popups: [] }) + .mockResolvedValueOnce({ success: true, popup }) + .mockResolvedValueOnce({ success: true, popup, embed }) + .mockResolvedValueOnce({ success: true, popup, embed }) + .mockResolvedValueOnce({ success: true, popup, embed }) + .mockResolvedValueOnce({ + success: true, + popup: { ...popup, status: "draft" }, + }) + .mockResolvedValueOnce({ success: true, popupId: popup.id }) + .mockResolvedValueOnce({ success: true, popup, embed }); + + await handleToolCall("list_popups", { companyId: "comp_123" }); + await handleToolCall("get_popup", { + companyId: "comp_123", + popupId: popup.id, + }); + await handleToolCall("create_popup", { + companyId: "comp_123", + name: "Launch popup", + template: "launch-modal", + }); + await handleToolCall("update_popup", { + companyId: "comp_123", + popupId: popup.id, + name: "Updated popup", + }); + await handleToolCall("publish_popup", { + companyId: "comp_123", + popupId: popup.id, + }); + await handleToolCall("unpublish_popup", { + companyId: "comp_123", + popupId: popup.id, + content: { version: 1, surface: "popup" }, + }); + await handleToolCall("delete_popup", { + companyId: "comp_123", + popupId: popup.id, + }); + await handleToolCall("get_popup_embed", { + companyId: "comp_123", + popupId: popup.id, + }); + + expect(mockApiRequest.mock.calls).toEqual([ + ["GET", "/api/v1/popups", undefined, "comp_123"], + ["GET", "/api/v1/popups/popup%2F123", undefined, "comp_123"], + [ + "POST", + "/api/v1/popups", + { name: "Launch popup", template: "launch-modal" }, + "comp_123", + ], + [ + "PATCH", + "/api/v1/popups/popup%2F123", + { name: "Updated popup" }, + "comp_123", + ], + ["POST", "/api/v1/popups/popup%2F123/publish", {}, "comp_123"], + [ + "POST", + "/api/v1/popups/popup%2F123/unpublish", + { content: { version: 1, surface: "popup" } }, + "comp_123", + ], + ["DELETE", "/api/v1/popups/popup%2F123", undefined, "comp_123"], + ["GET", "/api/v1/popups/embed/popup%2F123", undefined, "comp_123"], + ]); + }); + + it("rejects ambiguous creation and empty updates before calling the API", async () => { + const ambiguous = await handleToolCall("create_popup", { + name: "Ambiguous popup", + template: "launch-modal", + content: { version: 1 }, + }); + const emptyUpdate = await handleToolCall("update_popup", { + popupId: "popup_123", + }); + + expect(ambiguous.isError).toBe(true); + expect(ambiguous.content[0]?.text).toContain( + "Provide either `template` or `content`" + ); + expect(emptyUpdate.isError).toBe(true); + expect(emptyUpdate.content[0]?.text).toContain( + "Provide at least one of `name` or `content`" + ); + expect(mockApiRequest).not.toHaveBeenCalled(); + }); + + it("returns every popup embed recipe without secrets", async () => { + mockApiRequest.mockResolvedValueOnce({ + success: true, + popup: { id: "popup_123", status: "published" }, + embed: { + scriptUrl: "https://sequenzy.com/embed/popups/popup_123?v=runtime", + html: '', + react: "export function SequenzyPopupEmbed() {}", + wordpress: "add_shortcode('popup_123', 'popup_123');", + shopify: "{% comment %} Sequenzy popup {% endcomment %}", + }, + }); + + const result = await handleToolCall("get_popup_embed", { + popupId: "popup_123", + }); + + expect(result.isError).toBeUndefined(); + expect(result.structuredContent?.["embed"]).toMatchObject({ + html: expect.stringContaining(" { beforeEach(() => { mockApiRequest.mockClear(); @@ -7783,6 +7965,7 @@ describe("dashboard URL helpers", () => { sequenceId: "seq_123", campaignId: "camp_123", landingPageId: "lp_123", + popupId: "popup_123", emailSendId: "send_123", settingsTab: "integrations", }); @@ -7793,12 +7976,14 @@ describe("dashboard URL helpers", () => { sequence: string; campaign: string; landingPage: string; + popup: string; emailSend: string; settingsTab: string; }; }; expect(urlSchema?.properties).toHaveProperty("landingPageId"); + expect(urlSchema?.properties).toHaveProperty("popupId"); expect(payload.urls.sequence).toBe( "https://sequenzy.com/dashboard/company/comp_123/sequences/seq_123" ); @@ -7808,6 +7993,9 @@ describe("dashboard URL helpers", () => { expect(payload.urls.landingPage).toBe( "https://sequenzy.com/dashboard/company/comp_123/landing-pages/lp_123" ); + expect(payload.urls.popup).toBe( + "https://sequenzy.com/dashboard/company/comp_123/popups/popup_123" + ); expect(payload.urls.emailSend).toBe( "https://sequenzy.com/dashboard/company/comp_123/sent-emails/send_123" ); diff --git a/src/tools/output-schemas.ts b/src/tools/output-schemas.ts index 2ea22e7..76384ec 100644 --- a/src/tools/output-schemas.ts +++ b/src/tools/output-schemas.ts @@ -322,7 +322,7 @@ export const outputPropertiesByToolName: Record< }, list_websites: { websites: resourceListOutputProperty( - "sending domain, including DNS verification and readyToSend home-transport readiness" + "sending domain, including DNS verification and readyToSend sending readiness" ), }, list_integrations: { @@ -460,7 +460,7 @@ export const outputPropertiesByToolName: Record< }, list_sender_profiles: { senderProfiles: resourceListOutputProperty( - "sender (From) profile, including the sending domain behind it, its DNS verification status, and whether DNS plus the home transport allow the address to send" + "sender (From) profile, including the sending domain behind it, its DNS verification status, and whether the address is fully ready to send" ), replyProfiles: resourceListOutputProperty("reply-to profile"), defaultSenderProfileId: nullableStringOutputProperty( @@ -594,17 +594,17 @@ export const outputPropertiesByToolName: Record< }, check_website: { website: resourceOutputProperty( - "sending domain with separate DNS verification and readyToSend home-transport readiness" + "sending domain with separate DNS verification and readyToSend sending readiness; readiness.reason explains a domain that cannot send yet" ), ready: booleanOutputProperty("Whether the sender website is ready."), status: stringOutputProperty("Current processing or verification status."), }, verify_sending_domain: { website: resourceOutputProperty( - "Sending domain with current DNS verification, readyToSend home-transport readiness, SPF, DKIM, and MAIL FROM details." + "Sending domain with current DNS verification, readyToSend sending readiness, SPF, DKIM, and MAIL FROM details. When readyToSend is false, readiness.reason carries why: activation runs after the DNS records are correct, so it can still be pending while dkim.status reads verified." ), verified: booleanOutputProperty( - "Whether the sending domain passed the fresh DNS verification check. This does not by itself mean SES or MTA is ready." + "Whether the sending domain passed the fresh DNS verification check. Correct DNS alone does not mean the domain can send yet." ), readyToSend: booleanOutputProperty( "Whether DNS and the selected home transport are both ready for sending." @@ -1005,6 +1005,42 @@ export const outputPropertiesByToolName: Record< "Public action URL plus JavaScript, native form, and fetch snippets." ), }, + list_popups: { + popups: resourceListOutputProperty("saved popup"), + }, + get_popup: { + popup: resourceOutputProperty("saved popup"), + }, + create_popup: { + popup: resourceOutputProperty("saved popup"), + embed: objectOutputProperty( + "Versioned script URL plus HTML, React, WordPress, and Shopify snippets." + ), + }, + update_popup: { + popup: resourceOutputProperty("saved popup"), + embed: objectOutputProperty( + "Versioned script URL plus framework snippets for a published popup." + ), + }, + publish_popup: { + popup: resourceOutputProperty("saved popup"), + embed: objectOutputProperty( + "Versioned script URL plus HTML, React, WordPress, and Shopify snippets." + ), + }, + unpublish_popup: { + popup: resourceOutputProperty("saved popup"), + }, + delete_popup: { + popupId: stringOutputProperty("Deleted popup ID."), + }, + get_popup_embed: { + popup: resourceOutputProperty("saved popup"), + embed: objectOutputProperty( + "Versioned script URL plus HTML, React, WordPress, and Shopify snippets." + ), + }, list_landing_pages: { landingPages: resourceListOutputProperty("landing page"), }, diff --git a/src/tools/tool-hints.ts b/src/tools/tool-hints.ts index d3739b3..b83a3d4 100644 --- a/src/tools/tool-hints.ts +++ b/src/tools/tool-hints.ts @@ -49,6 +49,9 @@ export const READ_ONLY_TOOL_NAMES = new Set([ "get_email_send", "list_forms", "get_form_embed", + "list_popups", + "get_popup", + "get_popup_embed", "get_recipient_suppression", "list_landing_pages", "get_landing_page", @@ -158,6 +161,11 @@ export const MUTATING_TOOL_NAMES = new Set([ "resend_campaign_to_non_openers", "create_form", "update_form", + "create_popup", + "update_popup", + "publish_popup", + "unpublish_popup", + "delete_popup", "create_landing_page", "update_landing_page", "duplicate_landing_page", @@ -214,6 +222,8 @@ export const OPEN_WORLD_TOOL_NAMES = new Set([ "schedule_campaign", "resume_campaign", "publish_landing_page", + "create_popup", + "publish_popup", "connect_landing_page_domain", "update_landing_page_domain_settings", "add_sending_domain", @@ -261,6 +271,8 @@ export const DESTRUCTIVE_TOOL_NAMES = new Set([ "delete_campaign", "delete_landing_page", "unpublish_landing_page", + "delete_popup", + "unpublish_popup", // Disabling sync stops an integration feeding the account, in the same way // disabling a sequence stops it running. Reversible, but worth a confirm. "set_integration_sync_enabled",