Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
96 changes: 96 additions & 0 deletions src/tools/definitions/email-ai-style.ts
Original file line number Diff line number Diff line change
@@ -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,
},
},
];
2 changes: 2 additions & 0 deletions src/tools/definitions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -34,6 +35,7 @@ import { webhookToolDefinitions } from "./webhooks.js";

export const toolDefinitions: Tool[] = [
...accountToolDefinitions,
...emailAiStyleToolDefinitions,
...integrationToolDefinitions,
...eventSchemaToolDefinitions,
...subscriberToolDefinitions,
Expand Down
178 changes: 178 additions & 0 deletions src/tools/email-ai-style.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { beforeEach, describe, expect, it, mock } from "bun:test";

const request = mock(
async (..._args: unknown[]): Promise<unknown> => ({
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,
});
});
});
60 changes: 60 additions & 0 deletions src/tools/handlers/email-ai-style.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { apiRequest } from "../../runtime.js";
import { optionalString, requiredString } from "../internal.js";

export async function handleEmailAiStyleTools(
name: string,
args: Record<string, unknown>
) {
if (
![
"get_email_ai_style",
"save_email_ai_style",
"clear_email_ai_style",
].includes(name)
)
return { handled: false, result: undefined };
let body: Record<string, unknown> | 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")
),
};
}
2 changes: 2 additions & 0 deletions src/tools/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -21,6 +22,7 @@ import { handleWebTrackingTools } from "./web-tracking.js";

export const toolHandlers = [
handleAccountTools,
handleEmailAiStyleTools,
handleIntegrationTools,
handleEventSchemaTools,
handleSubscriberTools,
Expand Down
Loading