diff --git a/README.md b/README.md index 834b35f..06cf515 100644 --- a/README.md +++ b/README.md @@ -1633,3 +1633,10 @@ These files describe Sequenzy as an authorized email automation capability for a Account-key access combines key scopes with your current workspace role. `get_account` reports blocked scopes in `apiKeyPermissions.roleRestrictedScopes`; `canSendLive` means at least one permitted delivery workflow is available, not that every send tool is allowed. You can invite a `marketer` to manage subscribers, marketing campaigns and sequences without granting access to transactional mail, workspace settings, team or billing. Marketers choose existing sender/reply profiles. Transactional-backed campaign, A/B and sequence sources remain protected through previews, sharing, analytics and send history. Marketers and restricted members cannot receive billing access. + + +### Saved AI email styles + +Use `get_email_ai_style` to inspect the company's saved appearance and `revisionId`. `save_email_ai_style` captures an existing email by its underlying `emailId`; pass `expectedStyleId: null` only for an initial save, or the reviewed revision when replacing. Optional `canvas` captures an unsaved editor snapshot containing `blocks`, `theme`, `fontFamily`, and `emailPreset`. Saving also detects layout habits (for example dotted dividers around every button) and keeps all of them unless `layoutRuleIds` lists the rule IDs to keep (`[]` keeps none); optional `notes` (500 characters max) adds design guidance. Review `style.layout.rules` in the response. `clear_email_ai_style` requires the current nonempty revision. + +Reads require `emails:read`; saves/clears require `emails:write` and the current workspace role. Marketers cannot capture transactional source emails. A 409 conflict requires reading and reviewing the new state before retrying. These tools never edit or send the source email. Generation across all surfaces uses the saved default unless explicit styling or a plain-text choice takes precedence. diff --git a/src/tools/definitions/email-ai-style.ts b/src/tools/definitions/email-ai-style.ts new file mode 100644 index 0000000..8372e15 --- /dev/null +++ b/src/tools/definitions/email-ai-style.ts @@ -0,0 +1,96 @@ +import type { Tool } from "../../mcp-types.js"; + +const companyId = { + type: "string", + description: "Company ID; defaults to the selected company.", +}; +const expectedStyleId = { + type: ["string", "null"], + description: + "revisionId from get_email_ai_style. Use null only for an initial save with no stored style. On conflict, read and review again before retrying.", +}; + +export const emailAiStyleToolDefinitions: Tool[] = [ + { + name: "get_email_ai_style", + description: + "Get the company's saved AI email appearance, revisionId and canManage. Read this before replacing or clearing a style. No active style returns null; an unsupported version can still have a revisionId for recovery.", + inputSchema: { + type: "object", + properties: { companyId }, + additionalProperties: false, + }, + }, + { + name: "save_email_ai_style", + description: + "Save an email's appearance as the company default for future AI generation. Captures fonts, colors, spacing and block treatments, plus detected layout habits (for example dotted dividers around every button) and optional notes, without copying its content or modifying existing emails. Requires emails:write and access to the source email. Replaces only expectedStyleId; get and review the current style before replacing. All detected habits are kept unless layoutRuleIds narrows them; review style.layout.rules in the response and re-save with a subset if needed. Plain-text choices and explicit style requests take precedence during generation.", + inputSchema: { + type: "object", + properties: { + companyId, + emailId: { + type: "string", + description: + "Source email ID (including campaign, sequence or transactional email IDs); must belong to this company and be visible to you.", + }, + expectedStyleId, + canvas: { + type: "object", + description: + "Optional unsaved canvas snapshot. Omit to capture the stored email. Include all four fields when provided; validated by the API using the same schema as the editor.", + properties: { + blocks: { + type: "array", + items: { type: "object", additionalProperties: true }, + description: + "Native email blocks, up to 500 and 500,000 serialized characters. Use get_email_block_schema for block shapes.", + }, + theme: { + type: "object", + additionalProperties: true, + description: + "Complete email theme: presetId, colors, typography and layout, as returned by an email or saved style.", + }, + fontFamily: { type: "string", description: "Email font stack." }, + emailPreset: { type: "string", enum: ["branded", "minimal"] }, + }, + required: ["blocks", "theme", "fontFamily", "emailPreset"], + additionalProperties: false, + }, + layoutRuleIds: { + type: "array", + items: { type: "string" }, + maxItems: 20, + description: + "IDs of detected layout habits to keep (from style.layout.rules, for example around|button|divider:dots). Omit to keep every detected habit; pass [] to keep none.", + }, + notes: { + type: "string", + maxLength: 500, + description: + "Optional design notes for future generations, for example 'always open with a short video'. Design guidance only, never email content.", + }, + }, + required: ["emailId", "expectedStyleId"], + additionalProperties: false, + }, + }, + { + name: "clear_email_ai_style", + description: + "Clear the company's saved AI appearance and restore normal defaults for future generations. Existing emails stay unchanged. Requires emails:write. Get and review the current style first; a conflict must not be retried automatically.", + inputSchema: { + type: "object", + properties: { + companyId, + expectedStyleId: { + type: "string", + description: "Nonempty revisionId returned by get_email_ai_style.", + }, + }, + required: ["expectedStyleId"], + additionalProperties: false, + }, + }, +]; diff --git a/src/tools/definitions/index.ts b/src/tools/definitions/index.ts index ee00850..9645cfb 100644 --- a/src/tools/definitions/index.ts +++ b/src/tools/definitions/index.ts @@ -7,6 +7,7 @@ import { analyticsToolDefinitions } from "./analytics.js"; import { audienceSyncToolDefinitions } from "./audience-syncs.js"; import { campaignGoalToolDefinitions } from "./campaign-goals.js"; import { campaignToolDefinitions } from "./campaigns.js"; +import { emailAiStyleToolDefinitions } from "./email-ai-style.js"; import { emailBlockToolDefinitions } from "./email-blocks.js"; import { emailComponentToolDefinitions } from "./email-components.js"; import { eventSchemaToolDefinitions } from "./event-schemas.js"; @@ -34,6 +35,7 @@ import { webhookToolDefinitions } from "./webhooks.js"; export const toolDefinitions: Tool[] = [ ...accountToolDefinitions, + ...emailAiStyleToolDefinitions, ...integrationToolDefinitions, ...eventSchemaToolDefinitions, ...subscriberToolDefinitions, diff --git a/src/tools/email-ai-style.test.ts b/src/tools/email-ai-style.test.ts new file mode 100644 index 0000000..6648d38 --- /dev/null +++ b/src/tools/email-ai-style.test.ts @@ -0,0 +1,178 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; + +const request = mock( + async (..._args: unknown[]): Promise => ({ + success: true, + style: null, + revisionId: null, + canManage: true, + }) +); +await mock.module("../runtime.js", () => ({ + areLocalFileUploadsEnabled: () => false, + apiRequest: request, + apiUploadRequest: async () => undefined, + getSelectedCompanyId: () => null, + setSelectedCompanyId: () => undefined, +})); +const { handleToolCall, tools } = await import("./index.js"); +const { handleEmailAiStyleTools } = await import( + "./handlers/email-ai-style.js" +); + +describe("saved AI style MCP tools", () => { + beforeEach(() => { + request.mockClear(); + request.mockResolvedValue({ + success: true, + style: null, + revisionId: null, + canManage: true, + }); + }); + + it("registers compatible schemas, output fields and mutation annotations", () => { + for (const name of [ + "get_email_ai_style", + "save_email_ai_style", + "clear_email_ai_style", + ]) { + const tool = tools.find((candidate) => candidate.name === name); + expect(tool).toBeDefined(); + expect(tool?.inputSchema.type).toBe("object"); + expect(JSON.stringify(tool?.inputSchema)).not.toContain('"anyOf"'); + expect(tool?.outputSchema?.properties).toHaveProperty("revisionId"); + expect(tool?.outputSchema?.properties).toHaveProperty("style"); + expect(tool?.annotations?.readOnlyHint).toBe( + name === "get_email_ai_style" + ); + } + }); + + it("gets exact API state, including unsupported-version revision IDs", async () => { + const response = { + success: true, + style: null, + revisionId: "future", + canManage: false, + }; + request.mockResolvedValue(response); + const result = await handleToolCall("get_email_ai_style", { + companyId: "company", + }); + expect(request).toHaveBeenCalledWith( + "GET", + "/api/v1/email-ai-style", + undefined, + "company" + ); + expect(result.structuredContent).toEqual(response); + }); + + it("forwards initial, replacement and unsaved-canvas captures", async () => { + await handleToolCall("save_email_ai_style", { + emailId: "email", + expectedStyleId: null, + }); + expect(request).toHaveBeenLastCalledWith( + "PUT", + "/api/v1/email-ai-style", + { emailId: "email", expectedStyleId: null }, + undefined + ); + const canvas = { + blocks: [], + theme: {}, + fontFamily: "Arial", + emailPreset: "minimal", + }; + await handleToolCall("save_email_ai_style", { + companyId: "company", + emailId: "email", + expectedStyleId: "old", + canvas, + }); + expect(request).toHaveBeenLastCalledWith( + "PUT", + "/api/v1/email-ai-style", + { emailId: "email", expectedStyleId: "old", canvas }, + "company" + ); + await handleToolCall("save_email_ai_style", { + emailId: "email", + expectedStyleId: "old", + layoutRuleIds: ["around|button|divider:dots"], + notes: "Keep the dots.", + }); + expect(request).toHaveBeenLastCalledWith( + "PUT", + "/api/v1/email-ai-style", + { + emailId: "email", + expectedStyleId: "old", + layoutRuleIds: ["around|button|divider:dots"], + notes: "Keep the dots.", + }, + undefined + ); + await handleToolCall("save_email_ai_style", { + emailId: "email", + expectedStyleId: "old", + layoutRuleIds: [], + }); + expect(request).toHaveBeenLastCalledWith( + "PUT", + "/api/v1/email-ai-style", + { emailId: "email", expectedStyleId: "old", layoutRuleIds: [] }, + undefined + ); + }); + + it("clears only the reviewed revision and never retries a conflicting write", async () => { + await handleToolCall("clear_email_ai_style", { expectedStyleId: "old" }); + expect(request).toHaveBeenCalledWith( + "DELETE", + "/api/v1/email-ai-style", + { expectedStyleId: "old" }, + undefined + ); + request.mockClear(); + request.mockRejectedValue( + new Error("AI_STYLE_CONFLICT: Get the current style and review again.") + ); + const result = await handleToolCall("clear_email_ai_style", { + expectedStyleId: "old", + }); + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain("review again"); + expect(request).toHaveBeenCalledTimes(1); + }); + + it("rejects missing/blank revisions, source IDs and unknown arguments", async () => { + for (const [name, args] of [ + ["save_email_ai_style", { emailId: "email" }], + ["save_email_ai_style", { emailId: "", expectedStyleId: null }], + ["save_email_ai_style", { emailId: "email", expectedStyleId: " " }], + [ + "save_email_ai_style", + { emailId: "email", expectedStyleId: "old", layoutRuleIds: "all" }, + ], + [ + "save_email_ai_style", + { emailId: "email", expectedStyleId: "old", layoutRuleIds: [" "] }, + ], + [ + "save_email_ai_style", + { emailId: "email", expectedStyleId: "old", notes: "x".repeat(501) }, + ], + ["clear_email_ai_style", { expectedStyleId: null }], + ["get_email_ai_style", { unexpected: true }], + ] as const) + expect((await handleToolCall(name, args)).isError).toBe(true); + expect(request).not.toHaveBeenCalled(); + expect(await handleEmailAiStyleTools("unrelated", {})).toEqual({ + handled: false, + result: undefined, + }); + }); +}); diff --git a/src/tools/handlers/email-ai-style.ts b/src/tools/handlers/email-ai-style.ts new file mode 100644 index 0000000..84d2767 --- /dev/null +++ b/src/tools/handlers/email-ai-style.ts @@ -0,0 +1,60 @@ +import { apiRequest } from "../../runtime.js"; +import { optionalString, requiredString } from "../internal.js"; + +export async function handleEmailAiStyleTools( + name: string, + args: Record +) { + if ( + ![ + "get_email_ai_style", + "save_email_ai_style", + "clear_email_ai_style", + ].includes(name) + ) + return { handled: false, result: undefined }; + let body: Record | undefined; + if (name !== "get_email_ai_style") { + const revision = args.expectedStyleId; + if ( + !(name === "save_email_ai_style" && revision === null) && + (typeof revision !== "string" || !revision.trim()) + ) + throw new Error( + "Pass expectedStyleId from get_email_ai_style; null is allowed only for an initial save." + ); + body = { expectedStyleId: revision }; + if (name === "save_email_ai_style") { + body["emailId"] = requiredString(name, args, "emailId"); + if (args.canvas !== undefined) body["canvas"] = args.canvas; + if (args.layoutRuleIds !== undefined) { + if ( + !Array.isArray(args.layoutRuleIds) || + args.layoutRuleIds.some((id) => typeof id !== "string" || !id.trim()) + ) + throw new Error( + "layoutRuleIds must be an array of rule IDs from a saved style's layout.rules; pass [] to keep none." + ); + body["layoutRuleIds"] = args.layoutRuleIds; + } + if (args.notes !== undefined) { + if (typeof args.notes !== "string" || args.notes.length > 500) + throw new Error("notes must be a string of at most 500 characters."); + body["notes"] = args.notes; + } + } + } + return { + handled: true, + result: await apiRequest( + name === "get_email_ai_style" + ? "GET" + : name === "save_email_ai_style" + ? "PUT" + : "DELETE", + "/api/v1/email-ai-style", + body, + optionalString(args, "companyId") + ), + }; +} diff --git a/src/tools/handlers/index.ts b/src/tools/handlers/index.ts index f243d23..fe03647 100644 --- a/src/tools/handlers/index.ts +++ b/src/tools/handlers/index.ts @@ -3,6 +3,7 @@ import { handleAiAndFeedbackTools } from "./ai-and-feedback.js"; import { handleAnalyticsAndTransactionalTools } from "./analytics-and-transactional.js"; import { handleAudienceTools } from "./audience.js"; import { handleCampaignTools } from "./campaigns.js"; +import { handleEmailAiStyleTools } from "./email-ai-style.js"; import { handleEmailBlockTools } from "./email-blocks.js"; import { handleEmailComponentTools } from "./email-components.js"; import { handleEventSchemaTools } from "./event-schemas.js"; @@ -21,6 +22,7 @@ import { handleWebTrackingTools } from "./web-tracking.js"; export const toolHandlers = [ handleAccountTools, + handleEmailAiStyleTools, handleIntegrationTools, handleEventSchemaTools, handleSubscriberTools, diff --git a/src/tools/output-schemas.ts b/src/tools/output-schemas.ts index 6b8fd3c..d0f2246 100644 --- a/src/tools/output-schemas.ts +++ b/src/tools/output-schemas.ts @@ -332,6 +332,51 @@ export const outputPropertiesByToolName: Record< "Whether the company now uses the inherited SaaS/ecommerce platform preset." ), }, + get_email_ai_style: { + style: { + type: ["object", "null"], + description: "Saved appearance snapshot, or null when none is active.", + additionalProperties: true, + }, + revisionId: { + type: ["string", "null"], + description: + "Observed snapshot revision for conditional save/clear; may be nonnull for an unsupported style version.", + }, + canManage: booleanOutputProperty( + "Whether this caller can save or clear a style." + ), + }, + save_email_ai_style: { + style: { + type: ["object", "null"], + description: "Saved appearance snapshot, or null when none is active.", + additionalProperties: true, + }, + revisionId: { + type: ["string", "null"], + description: + "Observed snapshot revision for conditional save/clear; may be nonnull for an unsupported style version.", + }, + canManage: booleanOutputProperty( + "Whether this caller can save or clear a style." + ), + }, + clear_email_ai_style: { + style: { + type: ["object", "null"], + description: "Saved appearance snapshot, or null when none is active.", + additionalProperties: true, + }, + revisionId: { + type: ["string", "null"], + description: + "Observed snapshot revision for conditional save/clear; may be nonnull for an unsupported style version.", + }, + canManage: booleanOutputProperty( + "Whether this caller can save or clear a style." + ), + }, get_email_design_system: { designSystem: objectOutputProperty( "The effective email design system parsed from the direction text: designCode (visual grammar tokens), compositionSpine, and source (derived | custom)." diff --git a/src/tools/tool-hints.ts b/src/tools/tool-hints.ts index f5ac353..272a962 100644 --- a/src/tools/tool-hints.ts +++ b/src/tools/tool-hints.ts @@ -30,6 +30,7 @@ export const READ_ONLY_TOOL_NAMES = new Set([ "get_notification_preferences", "get_tracking_settings", "get_email_design_system", + "get_email_ai_style", "get_sending_status", "check_website", "get_integration_guide", @@ -111,6 +112,8 @@ export const MUTATING_TOOL_NAMES = new Set([ "delete_sender_profile", "update_tracking_settings", "update_email_design_system", + "save_email_ai_style", + "clear_email_ai_style", "update_sms_number_label", "release_sms_number", "resume_sending",