From 13002db95f7640b48405a571dd428572417cf8f7 Mon Sep 17 00:00:00 2001 From: Shawn McClelland Date: Thu, 22 Oct 2026 21:02:34 -0400 Subject: [PATCH 01/11] Add HubSpot HTML export and email authoring to Design --- .changeset/bounded-agent-team-waits.md | 7 + .../core/src/a2a/task-store-lifecycle.spec.ts | 1 + packages/core/src/agent/execution-fence.ts | 10 +- packages/core/src/agent/production-agent.ts | 9 +- .../core/src/agent/reaped-run-thread.spec.ts | 1 + .../run-store.foreground-self-chain.spec.ts | 1 + .../run-store.reconcile-convergence.spec.ts | 1 + packages/core/src/agent/run-store.spec.ts | 1 + packages/core/src/db/client.ts | 65 +- .../agent-chat/browser-team-tools.spec.ts | 203 ++++- .../server/agent-chat/browser-team-tools.ts | 71 +- .../agent-teams-fenced-write.pglite.spec.ts | 178 +++++ .../src/server/agent-teams-run-queue.spec.ts | 1 + .../.agents/skills/design-generation/SKILL.md | 16 +- .../.agents/skills/export-handoff/SKILL.md | 13 +- .../skills/hubspot-email-review/SKILL.md | 84 ++ .../.agents/skills/hubspot-email/SKILL.md | 146 ++++ templates/design/AGENTS.md | 45 +- .../apply-source-edit.interleave.spec.ts | 78 ++ templates/design/actions/create-file.spec.ts | 32 + templates/design/actions/create-file.ts | 44 +- templates/design/actions/edit-design.ts | 2 +- .../actions/export-hubspot-html.test.ts | 166 ++++ .../design/actions/export-hubspot-html.ts | 110 +++ templates/design/actions/export-png.spec.ts | 21 + templates/design/actions/export-png.ts | 45 +- .../design/actions/generate-design.spec.ts | 30 + templates/design/actions/generate-design.ts | 112 ++- .../actions/get-design-snapshot.spec.ts | 1 + .../design/actions/get-design-snapshot.ts | 6 +- .../actions/get-design-surface-index.ts | 1 + .../actions/take-design-screenshot.spec.ts | 35 +- .../design/actions/take-design-screenshot.ts | 333 +++++--- templates/design/actions/update-file.ts | 9 + templates/design/actions/view-screen.test.ts | 17 +- templates/design/actions/view-screen.ts | 17 +- .../app/components/design/BreakpointBar.tsx | 1 + .../app/components/design/DesignCanvas.tsx | 9 +- .../app/components/design/EditPanel.tsx | 7 +- .../components/design/MultiScreenCanvas.tsx | 6 +- .../content-size-report.integration.spec.ts | 90 +++ .../design-canvas/content-size-report.ts | 16 +- .../design/design-canvas/interact-document.ts | 13 +- .../design/edit-panel/frame-presets-panel.tsx | 18 +- templates/design/app/i18n/ar-SA.ts | 15 + templates/design/app/i18n/de-DE.ts | 15 + templates/design/app/i18n/en-US.ts | 15 + templates/design/app/i18n/es-ES.ts | 15 + templates/design/app/i18n/fr-FR.ts | 15 + templates/design/app/i18n/hi-IN.ts | 14 + templates/design/app/i18n/ja-JP.ts | 15 + templates/design/app/i18n/ko-KR.ts | 15 + templates/design/app/i18n/pt-BR.ts | 15 + templates/design/app/i18n/zh-CN.ts | 14 + templates/design/app/i18n/zh-TW.ts | 14 + .../commands/create-screen-frame.ts | 20 +- .../commands/download-hubspot-html.spec.ts | 143 ++++ .../commands/download-hubspot-html.ts | 86 +++ .../commands/duplicate-screen.spec.ts | 1 + .../design-editor/design-editor-view.tsx | 1 + .../domains/use-editor-canvas-and-screens.ts | 13 +- .../domains/use-editor-export-and-handoff.ts | 18 + .../domains/use-editor-screen-rendering.tsx | 1 + .../domains/use-hubspot-html-export.test.tsx | 182 +++++ .../domains/use-hubspot-html-export.ts | 124 +++ .../publish-agent-selection-context.ts | 6 + .../design-editor/view/editor-dialogs.tsx | 10 + .../view/export-submenu-content.test.tsx | 8 +- .../view/export-submenu-content.tsx | 11 + .../view/hubspot-email-conversion-dialog.tsx | 54 ++ ...frames-rebuild-existing-frames-for-emai.md | 6 + ...ports-can-verify-that-original-link-des.md | 6 + ...e-an-independent-content-and-mobile-lay.md | 6 + ...show-their-full-content-in-the-canvas-a.md | 6 + ...s-with-sample-recipient-names-and-butto.md | 6 + ...se-local-chromium-when-available-for-fa.md | 6 + ...rt-requests-finish-without-requiring-un.md | 6 + .../server/lib/design-response-guard.spec.ts | 25 + .../server/lib/design-response-guard.ts | 30 +- .../design/server/lib/hubl-interpolation.ts | 48 ++ .../server/lib/hubspot-email-export.test.ts | 638 +++++++++++++++ .../design/server/lib/hubspot-email-export.ts | 725 ++++++++++++++++++ .../server/lib/hubspot-email-preview.spec.ts | 135 ++++ .../server/lib/hubspot-email-preview.ts | 153 ++++ .../server/lib/playwright-runtime.spec.ts | 51 +- .../design/server/lib/playwright-runtime.ts | 12 +- .../screenshot-readiness.integration.spec.ts | 127 +++ .../server/lib/screenshot-readiness.spec.ts | 35 + .../design/server/lib/screenshot-readiness.ts | 91 +++ templates/design/server/plugins/agent-chat.ts | 4 + .../server/source-workspace.batch.spec.ts | 34 + templates/design/server/source-workspace.ts | 22 + templates/design/shared/frame-size-presets.ts | 5 + .../shared/hubspot-email-authoring.spec.ts | 15 + .../design/shared/hubspot-email-authoring.ts | 35 + templates/design/shared/hubspot-email.ts | 64 ++ 96 files changed, 4923 insertions(+), 280 deletions(-) create mode 100644 .changeset/bounded-agent-team-waits.md create mode 100644 packages/core/src/server/agent-teams-fenced-write.pglite.spec.ts create mode 100644 templates/design/.agents/skills/hubspot-email-review/SKILL.md create mode 100644 templates/design/.agents/skills/hubspot-email/SKILL.md create mode 100644 templates/design/actions/export-hubspot-html.test.ts create mode 100644 templates/design/actions/export-hubspot-html.ts create mode 100644 templates/design/app/components/design/design-canvas/content-size-report.integration.spec.ts create mode 100644 templates/design/app/pages/design-editor/commands/download-hubspot-html.spec.ts create mode 100644 templates/design/app/pages/design-editor/commands/download-hubspot-html.ts create mode 100644 templates/design/app/pages/design-editor/domains/use-hubspot-html-export.test.tsx create mode 100644 templates/design/app/pages/design-editor/domains/use-hubspot-html-export.ts create mode 100644 templates/design/app/pages/design-editor/view/hubspot-email-conversion-dialog.tsx create mode 100644 templates/design/changelog/2026-10-22-create-hubspot-email-frames-rebuild-existing-frames-for-emai.md create mode 100644 templates/design/changelog/2026-10-22-hubspot-conversion-exports-can-verify-that-original-link-des.md create mode 100644 templates/design/changelog/2026-10-22-hubspot-emails-receive-an-independent-content-and-mobile-lay.md create mode 100644 templates/design/changelog/2026-10-22-hug-frames-expand-to-show-their-full-content-in-the-canvas-a.md create mode 100644 templates/design/changelog/2026-10-22-preview-hubspot-emails-with-sample-recipient-names-and-butto.md create mode 100644 templates/design/changelog/2026-10-22-screenshot-previews-use-local-chromium-when-available-for-fa.md create mode 100644 templates/design/changelog/2026-10-22-verification-and-export-requests-finish-without-requiring-un.md create mode 100644 templates/design/server/lib/hubl-interpolation.ts create mode 100644 templates/design/server/lib/hubspot-email-export.test.ts create mode 100644 templates/design/server/lib/hubspot-email-export.ts create mode 100644 templates/design/server/lib/hubspot-email-preview.spec.ts create mode 100644 templates/design/server/lib/hubspot-email-preview.ts create mode 100644 templates/design/server/lib/screenshot-readiness.integration.spec.ts create mode 100644 templates/design/server/lib/screenshot-readiness.spec.ts create mode 100644 templates/design/server/lib/screenshot-readiness.ts create mode 100644 templates/design/shared/hubspot-email-authoring.spec.ts create mode 100644 templates/design/shared/hubspot-email-authoring.ts create mode 100644 templates/design/shared/hubspot-email.ts diff --git a/.changeset/bounded-agent-team-waits.md b/.changeset/bounded-agent-team-waits.md new file mode 100644 index 00000000000..4a9524c315a --- /dev/null +++ b/.changeset/bounded-agent-team-waits.md @@ -0,0 +1,7 @@ +--- +"@agent-native/core": patch +--- + +Allow bounded, abortable waits for agent task status and results, and keep volatile read-only task checks fresh throughout an agent turn. + +Save sub-agent results within their active task transaction while retaining execution lease checks and atomic rollback. diff --git a/packages/core/src/a2a/task-store-lifecycle.spec.ts b/packages/core/src/a2a/task-store-lifecycle.spec.ts index 98c6937e5e5..bffad24f9c8 100644 --- a/packages/core/src/a2a/task-store-lifecycle.spec.ts +++ b/packages/core/src/a2a/task-store-lifecycle.spec.ts @@ -26,6 +26,7 @@ const dbExec: DbExec = { vi.mock("../db/client.js", () => ({ getDbExec: () => dbExec, + getScopedTransactionDbExec: () => undefined, isProductionServerlessFunctionRuntime: () => false, })); diff --git a/packages/core/src/agent/execution-fence.ts b/packages/core/src/agent/execution-fence.ts index d6da4c1fbff..f85cea11a30 100644 --- a/packages/core/src/agent/execution-fence.ts +++ b/packages/core/src/agent/execution-fence.ts @@ -1,6 +1,10 @@ import { AsyncLocalStorage } from "node:async_hooks"; -import { getDbExec, type DbExec } from "../db/client.js"; +import { + getDbExec, + getScopedTransactionDbExec, + type DbExec, +} from "../db/client.js"; export interface ExecutionFence { threadId: string; @@ -161,6 +165,10 @@ export async function withFencedExecutionWrite( if (!scopedFence) { throw new StaleExecutionFenceError(fence); } + if (getScopedTransactionDbExec() === client) { + await lockAndAssertCurrentExecutionFences(client); + return write(client); + } if (!client.transaction) { throw new Error("Fenced execution writes require transaction support"); } diff --git a/packages/core/src/agent/production-agent.ts b/packages/core/src/agent/production-agent.ts index 5f46e735e86..37241f6f0b0 100644 --- a/packages/core/src/agent/production-agent.ts +++ b/packages/core/src/agent/production-agent.ts @@ -8721,11 +8721,10 @@ export async function runAgentLoop(opts: { actionEntry && intent?.valid ? actionCallIsRetrySafe(actionEntry, preparedCallArguments(intent)) : false; - const repeatGuardStopFromThisCall = noteRepeatedToolCall( - toolCall.name, - toolCall.input, - toolCall.id, - ); + const repeatGuardStopFromThisCall = + actionIsReadOnly && actionEntry?.dedupe === false + ? null + : noteRepeatedToolCall(toolCall.name, toolCall.input, toolCall.id); const rollbackRepeatedToolCall = () => { const repeatKey = toolCallCacheKey(toolCall.name, toolCall.input); const repeatCall = inFlightRepeatToolCalls.get(toolCall.id); diff --git a/packages/core/src/agent/reaped-run-thread.spec.ts b/packages/core/src/agent/reaped-run-thread.spec.ts index 3822cd47cab..2565acaa5f9 100644 --- a/packages/core/src/agent/reaped-run-thread.spec.ts +++ b/packages/core/src/agent/reaped-run-thread.spec.ts @@ -27,6 +27,7 @@ const client = { vi.mock("../db/client.js", () => ({ getDbExec: () => client, + getScopedTransactionDbExec: () => undefined, isProductionServerlessFunctionRuntime: () => false, retryOnDdlRace: (fn: () => any) => fn(), })); diff --git a/packages/core/src/agent/run-store.foreground-self-chain.spec.ts b/packages/core/src/agent/run-store.foreground-self-chain.spec.ts index b9a0ebfd259..7cf3af9a1b8 100644 --- a/packages/core/src/agent/run-store.foreground-self-chain.spec.ts +++ b/packages/core/src/agent/run-store.foreground-self-chain.spec.ts @@ -41,6 +41,7 @@ rawClient.transaction = async ( vi.mock("../db/client.js", () => ({ getDbExec: () => rawClient, + getScopedTransactionDbExec: () => undefined, isProductionServerlessFunctionRuntime: () => false, retryOnDdlRace: (fn: () => any) => fn(), })); diff --git a/packages/core/src/agent/run-store.reconcile-convergence.spec.ts b/packages/core/src/agent/run-store.reconcile-convergence.spec.ts index 6518a071364..cfca04b2404 100644 --- a/packages/core/src/agent/run-store.reconcile-convergence.spec.ts +++ b/packages/core/src/agent/run-store.reconcile-convergence.spec.ts @@ -39,6 +39,7 @@ client.transaction = async (run: (tx: any) => Promise) => { vi.mock("../db/client.js", () => ({ getDbExec: () => client, + getScopedTransactionDbExec: () => undefined, isProductionServerlessFunctionRuntime: () => false, retryOnDdlRace: (fn: () => any) => fn(), })); diff --git a/packages/core/src/agent/run-store.spec.ts b/packages/core/src/agent/run-store.spec.ts index 43fd7f52881..85aab2763d0 100644 --- a/packages/core/src/agent/run-store.spec.ts +++ b/packages/core/src/agent/run-store.spec.ts @@ -330,6 +330,7 @@ const mockCaptureError = vi.fn(); vi.mock("../db/client.js", () => ({ getDbExec: () => mockDb, + getScopedTransactionDbExec: () => undefined, })); vi.mock("../db/ddl-guard.js", () => ({ diff --git a/packages/core/src/db/client.ts b/packages/core/src/db/client.ts index f4f03c95bc8..05aaefcae94 100644 --- a/packages/core/src/db/client.ts +++ b/packages/core/src/db/client.ts @@ -2310,16 +2310,32 @@ export function annotateMissingTable(err: unknown, sql: unknown): unknown { } const scopedDbExec = new AsyncLocalStorage(); -const transactionCallbacks = new AsyncLocalStorage<{ active: boolean }>(); +const transactionCallbacks = new AsyncLocalStorage<{ + active: boolean; + executors: WeakSet; +}>(); export function withDbExec(exec: DbExec, run: () => T): T { - return scopedDbExec.run(guardSchemaMutations(exec), run); + const guarded = guardSchemaMutations(exec); + const callback = transactionCallbacks.getStore(); + if (callback?.active && callback.executors.has(exec)) { + callback.executors.add(guarded); + } + return scopedDbExec.run(guarded, run); } export function getScopedDbExec(): DbExec | undefined { return scopedDbExec.getStore(); } +export function getScopedTransactionDbExec(): DbExec | undefined { + const scoped = getScopedDbExec(); + const callback = transactionCallbacks.getStore(); + return scoped && callback?.active && callback.executors.has(scoped) + ? scoped + : undefined; +} + /** * True while a `getDbExec().transaction()` callback runs. That callback leaves * `getScopedDbExec()` unset, so code that must not run inside a caller's @@ -2329,8 +2345,11 @@ export function insideDbTransactionCallback(): boolean { return transactionCallbacks.getStore()?.active === true; } -async function inTransactionCallback(run: () => Promise): Promise { - const callback = { active: true }; +async function inTransactionCallback( + exec: DbExec, + run: () => Promise, +): Promise { + const callback = { active: true, executors: new WeakSet([exec]) }; try { return await transactionCallbacks.run(callback, run); } finally { @@ -2405,21 +2424,27 @@ export function getDbExec(): DbExec { throw new Error("This database does not support transactions."); } return currentExec.transaction((tx) => - inTransactionCallback(() => - fn({ - execute: (s) => { - assertSchemaMutationAllowed(s); - return tx.execute(sanitize(s)); - }, - transaction: tx.transaction?.bind(tx), - }), - ), + runTransactionCallback(tx, fn), ); } : undefined, }; } + function runTransactionCallback( + tx: DbExec, + fn: (tx: DbExec) => Promise, + ): Promise { + const exec: DbExec = { + execute: (s) => { + assertSchemaMutationAllowed(s); + return tx.execute(sanitize(s)); + }, + transaction: tx.transaction?.bind(tx), + }; + return inTransactionCallback(exec, () => fn(exec)); + } + const proxy: DbExec = { async execute(sql) { assertSchemaMutationAllowed(sql); @@ -2431,17 +2456,7 @@ export function getDbExec(): DbExec { const exec = await initializeProxy(); Object.assign(proxy, createInitializedProxy(exec)); if (exec.transaction) { - return exec.transaction((tx) => - inTransactionCallback(() => - fn({ - execute: (s) => { - assertSchemaMutationAllowed(s); - return tx.execute(sanitize(s)); - }, - transaction: tx.transaction?.bind(tx), - }), - ), - ); + return exec.transaction((tx) => runTransactionCallback(tx, fn)); } if (exec.atomicBatch) { throw new Error( @@ -2450,7 +2465,7 @@ export function getDbExec(): DbExec { } const wrapper = createInitializedProxy(exec); return explicitTransaction(wrapper.execute.bind(wrapper))((tx) => - inTransactionCallback(() => fn(tx)), + inTransactionCallback(tx, () => fn(tx)), ); }, async atomicBatch(statements) { diff --git a/packages/core/src/server/agent-chat/browser-team-tools.spec.ts b/packages/core/src/server/agent-chat/browser-team-tools.spec.ts index 20ae4953382..9584e19840e 100644 --- a/packages/core/src/server/agent-chat/browser-team-tools.spec.ts +++ b/packages/core/src/server/agent-chat/browser-team-tools.spec.ts @@ -1,7 +1,13 @@ import { createHash } from "node:crypto"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AgentEngine } from "../../agent/engine/types.js"; +import { + actionsToEngineTools, + runAgentLoop, +} from "../../agent/production-agent.js"; +import { runWithRequestContext } from "../request-context.js"; import { createBuilderBrowserTool, createTeamTools, @@ -233,6 +239,201 @@ describe("createTeamTools Plan-mode effects", () => { }); }); +describe("createTeamTools bounded task reads", () => { + const task = { + taskId: "task-1", + threadId: "child-1", + parentThreadId: "parent-1", + status: "running", + description: "Review email", + summary: "", + preview: "Inspecting", + }; + function teamTool() { + return createTeamTools({ + getOwner: () => "owner@example.test", + getSystemPrompt: () => "", + getActions: () => ({}), + getEngine: () => ({}) as AgentEngine, + getModel: () => "test-model", + getParentThreadId: () => "parent-1", + getSend: () => null, + })["agent-teams"]; + } + beforeEach(() => { + mocks.getTask.mockReset().mockResolvedValue(task); + mocks.getAgentTeamRunDispatchState.mockReset().mockResolvedValue(null); + }); + afterEach(() => { + mocks.getTask.mockReset(); + mocks.getAgentTeamRunDispatchState.mockReset(); + }); + + it("waits for a terminal result and keeps every read in the caller's scope", async () => { + mocks.getTask.mockResolvedValueOnce(task).mockResolvedValue({ + ...task, + status: "completed", + summary: "Reviewed unchanged hash; no issues.", + }); + const result = await runWithRequestContext( + { userEmail: "owner@example.test", orgId: "org-1" }, + () => + teamTool().run({ + action: "read-result", + taskId: "task-1", + waitMs: 1000, + }), + ); + expect(JSON.parse(result)).toMatchObject({ + status: "completed", + summary: "Reviewed unchanged hash; no issues.", + }); + expect(mocks.getTask.mock.calls).toEqual([ + ["task-1", { ownerEmail: "owner@example.test", orgId: "org-1" }], + ["task-1", { ownerEmail: "owner@example.test", orgId: "org-1" }], + ]); + }); + + it("returns an honest queued snapshot when the wait expires", async () => { + mocks.getAgentTeamRunDispatchState.mockResolvedValue({ status: "queued" }); + const result = JSON.parse( + await teamTool().run({ + action: "read-result", + taskId: "task-1", + waitMs: 20, + }), + ); + expect(result).toMatchObject({ status: "queued", taskId: "task-1" }); + expect(result).not.toHaveProperty("summary"); + expect(mocks.getTask).toHaveBeenCalledTimes(2); + }); + + it("does not wait or expose queue state for a foreign or missing task", async () => { + mocks.getTask.mockResolvedValue(undefined); + expect( + JSON.parse( + await teamTool().run({ + action: "status", + taskId: "foreign", + waitMs: 20000, + }), + ), + ).toEqual({ error: "Task not found" }); + expect(mocks.getTask).toHaveBeenCalledTimes(1); + expect(mocks.getAgentTeamRunDispatchState).not.toHaveBeenCalled(); + }); + + it("preserves an explicit personal scope instead of falling back to the ambient organization", async () => { + mocks.getTask.mockResolvedValue(undefined); + await runWithRequestContext( + { userEmail: "owner@example.test", orgId: "org-1" }, + () => + teamTool().run( + { action: "status", taskId: "task-1" }, + { caller: "agent", orgId: null }, + ), + ); + expect(mocks.getTask).toHaveBeenCalledWith("task-1", { + ownerEmail: "owner@example.test", + orgId: null, + }); + }); + + it("aborts an active wait without another task read", async () => { + const controller = new AbortController(); + const waiting = teamTool().run( + { action: "status", taskId: "task-1", waitMs: 1000 }, + { caller: "agent", signal: controller.signal }, + ); + const outcome = waiting.then( + () => null, + (error: unknown) => error, + ); + await new Promise((resolve) => setTimeout(resolve, 10)); + controller.abort(); + expect(await outcome).toBeInstanceOf(Error); + expect(mocks.getTask).toHaveBeenCalledTimes(1); + }); + + it.each([-1, 20001, 1.5, "1000"])( + "rejects invalid wait bounds (%s)", + async (waitMs) => { + await expect( + teamTool().run({ action: "status", taskId: "task-1", waitMs }), + ).rejects.toThrow("waitMs"); + expect(mocks.getTask).not.toHaveBeenCalled(); + }, + ); + + it("observes changed task status across identical calls in one agent turn", async () => { + let reads = 0; + mocks.getTask.mockImplementation(async () => + ++reads < 10 ? task : { ...task, status: "completed", summary: "Clear" }, + ); + const entry = teamTool(); + let calls = 0; + const observed: string[] = []; + const engine: AgentEngine = { + name: "test", + label: "Test", + capabilities: { + thinking: false, + promptCaching: false, + vision: false, + computerUse: false, + parallelToolCalls: false, + }, + defaultModel: "test-model", + supportedModels: ["test-model"], + async *stream(opts) { + const last = opts.messages[opts.messages.length - 1]; + for (const part of last?.content ?? []) + if (part.type === "tool-result") observed.push(part.content); + if (++calls <= 10) { + yield { + type: "assistant-content", + parts: [ + { + type: "tool-call", + id: `status-${calls}`, + name: "agent-teams", + input: { action: "status", taskId: "task-1" }, + }, + ], + }; + yield { type: "stop", reason: "tool_use" }; + } else { + yield { + type: "assistant-content", + parts: [{ type: "text", text: "Review complete." }], + }; + yield { type: "stop", reason: "end_turn" }; + } + }, + }; + await runAgentLoop({ + engine, + model: "test-model", + systemPrompt: "Observe task state", + tools: actionsToEngineTools({ "agent-teams": entry }), + messages: [ + { + role: "user", + content: [{ type: "text", text: "Check the task twice" }], + }, + ], + actions: { "agent-teams": entry }, + send: () => {}, + signal: new AbortController().signal, + }); + expect(mocks.getTask).toHaveBeenCalledTimes(10); + expect(observed.map((content) => JSON.parse(content).status)).toEqual([ + ...Array(9).fill("running"), + "completed", + ]); + }); +}); + describe("createTeamTools progress cards", () => { it("projects only task progress results from visible coordination actions", async () => { const entry = createTeamTools({ diff --git a/packages/core/src/server/agent-chat/browser-team-tools.ts b/packages/core/src/server/agent-chat/browser-team-tools.ts index 3692cba3a66..05775dd378a 100644 --- a/packages/core/src/server/agent-chat/browser-team-tools.ts +++ b/packages/core/src/server/agent-chat/browser-team-tools.ts @@ -1,9 +1,11 @@ import { createHash } from "node:crypto"; +import { setTimeout as delay } from "node:timers/promises"; import { ACTION_CHAT_UI_AGENT_TEAM_PROGRESS_RENDERER, normalizeAgentTeamProgressResult, } from "../../action-ui.js"; +import { fail, type ActionRunContext } from "../../action.js"; import type { AgentEngine } from "../../agent/engine/types.js"; import type { ActionEntry } from "../../agent/production-agent.js"; import { getActiveFileUploadProviderForRequest } from "../../file-upload/registry.js"; @@ -16,7 +18,7 @@ import { getBuilderBrowserConnectUrlForOwner, resolveBuilderBranchProjectId, } from "../builder-browser.js"; -import { getRequestUserEmail } from "../request-context.js"; +import { getRequestOrgId, getRequestUserEmail } from "../request-context.js"; import { getMcpManagerForCurrentRequest } from "./mcp-glue.js"; const MAX_EXTENSION_PROMOTION_CONTENT_CHARS = 200_000; @@ -494,8 +496,40 @@ export function createTeamTools(deps: { | ((event: import("../../agent/types.js").AgentChatEvent) => void) | null; }): Record { + const readTask = async ( + taskId: string, + waitMs: number, + context?: ActionRunContext, + ) => { + if (!Number.isInteger(waitMs) || waitMs < 0 || waitMs > 20_000) + fail("waitMs must be an integer between 0 and 20000.", { + errorCode: "invalid_input", + statusCode: 422, + }); + const scope = { + ownerEmail: deps.getOwner(), + orgId: + context?.orgId !== undefined + ? context.orgId + : (getRequestOrgId() ?? null), + }; + const signal = context?.signal; + signal?.throwIfAborted(); + const { getTask } = await import("../agent-teams.js"); + const deadline = Date.now() + waitMs; + let task = await getTask(taskId, scope); + signal?.throwIfAborted(); + while (task?.status === "running" && Date.now() < deadline) { + await delay(Math.min(500, deadline - Date.now()), undefined, { signal }); + signal?.throwIfAborted(); + task = await getTask(taskId, scope); + signal?.throwIfAborted(); + } + return task; + }; return { "agent-teams": { + dedupe: false, tool: { description: "Manage background tasks that run in their own task thread. Use action 'spawn' to start one, 'status' to check progress, 'read-result' to get a finished task's output, 'send' to message a running task, or 'list' to see all tasks. A successful spawn only means the task started and is running; do not report it as finished until status/read-result shows a terminal status. This is a background task, not a source-control branch.", @@ -536,6 +570,13 @@ export function createTeamTools(deps: { type: "string", description: "(send) Message to send to the sub-agent", }, + waitMs: { + type: "integer", + minimum: 0, + maximum: 20000, + description: + "(status, read-result) Wait up to this many milliseconds for a terminal task. Defaults to 0 (immediate snapshot). A deadline can return queued/running; it does not mean completion or resume the parent automatically.", + }, }, required: ["action"], }, @@ -560,7 +601,19 @@ export function createTeamTools(deps: { description: "Plan mode allows listing tasks and reading their status or results.", }, - run: async (args: Record) => { + run: async ( + args: { + action: string; + task?: string; + instructions?: string; + name?: string; + agent?: string; + taskId?: string; + message?: string; + waitMs?: number; + }, + context?: ActionRunContext, + ) => { const action = args.action; if (action === "spawn") { @@ -627,8 +680,11 @@ export function createTeamTools(deps: { if (action === "status") { if (!args.taskId) throw new Error("'taskId' is required for status"); - const { getTask } = await import("../agent-teams.js"); - const task = await getTask(args.taskId); + const task = await readTask( + args.taskId, + args.waitMs === undefined ? 0 : args.waitMs, + context, + ); if (!task) return JSON.stringify({ error: "Task not found" }); return JSON.stringify({ taskId: task.taskId, @@ -646,8 +702,11 @@ export function createTeamTools(deps: { if (action === "read-result") { if (!args.taskId) throw new Error("'taskId' is required for read-result"); - const { getTask } = await import("../agent-teams.js"); - const task = await getTask(args.taskId); + const task = await readTask( + args.taskId, + args.waitMs === undefined ? 0 : args.waitMs, + context, + ); if (!task) return JSON.stringify({ error: "Task not found" }); if (task.status === "running") { return JSON.stringify({ diff --git a/packages/core/src/server/agent-teams-fenced-write.pglite.spec.ts b/packages/core/src/server/agent-teams-fenced-write.pglite.spec.ts new file mode 100644 index 00000000000..3404253d679 --- /dev/null +++ b/packages/core/src/server/agent-teams-fenced-write.pglite.spec.ts @@ -0,0 +1,178 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { + StaleExecutionFenceError, + withExecutionFence, + withFencedExecutionWrite, + type ExecutionFence, +} from "../agent/execution-fence.js"; +import { claimTaskExecutionLease } from "../agent/run-store.js"; +import { closeDbExec, getDbExec, withDbExec } from "../db/client.js"; +import { + claimAgentTeamRun, + completeAgentTeamRun, + enqueueAgentTeamRun, + getAgentTeamRunDispatchState, + withCurrentAgentTeamRunAttempt, +} from "./agent-teams-run-queue.js"; + +let previousDatabaseUrl: string | undefined; + +beforeAll(async () => { + previousDatabaseUrl = process.env.DATABASE_URL; + process.env.DATABASE_URL = "pglite:memory"; + await closeDbExec(); + await getDbExec().execute( + "CREATE TABLE review_results (task_id TEXT PRIMARY KEY, result TEXT NOT NULL)", + ); +}); + +afterAll(async () => { + await closeDbExec(); + if (previousDatabaseUrl === undefined) delete process.env.DATABASE_URL; + else process.env.DATABASE_URL = previousDatabaseUrl; +}); + +async function taskAttempt(taskId: string) { + const threadId = `thread-${taskId}`; + const turnId = `turn-${taskId}`; + const runId = `run-${taskId}`; + await enqueueAgentTeamRun({ + taskId, + threadId, + runId, + payload: { description: "Read-only email review", turnId }, + }); + const claimed = await claimAgentTeamRun(taskId); + if (!claimed) throw new Error("Task attempt was not claimed"); + const fence = await claimTaskExecutionLease(threadId, turnId, runId); + return { attempts: claimed.attempts, fence }; +} + +function saveResult(taskId: string, fence: ExecutionFence) { + return withFencedExecutionWrite( + (tx) => + tx.execute({ + sql: "INSERT INTO review_results (task_id, result) VALUES (?, ?)", + args: [taskId, "Reviewed the current email"], + }), + { threadId: fence.threadId }, + ); +} + +async function results(taskId: string) { + return ( + await getDbExec().execute({ + sql: "SELECT result FROM review_results WHERE task_id = ?", + args: [taskId], + }) + ).rows; +} + +describe("fenced writes inside an Agent Teams attempt transaction", () => { + it("commits the result and terminal task state together", async () => { + const taskId = "review-commit"; + const { attempts, fence } = await taskAttempt(taskId); + const outcome = await withExecutionFence(fence, () => + withCurrentAgentTeamRunAttempt(taskId, attempts, async () => { + await saveResult(taskId, fence); + return completeAgentTeamRun(taskId, "done", attempts); + }), + ); + expect(outcome).toEqual({ current: true, value: true }); + expect(await results(taskId)).toEqual([ + { result: "Reviewed the current email" }, + ]); + expect((await getAgentTeamRunDispatchState(taskId))?.status).toBe("done"); + }); + + it("rolls back the result and terminal state when the caller fails", async () => { + const taskId = "review-rollback"; + const { attempts, fence } = await taskAttempt(taskId); + await expect( + withExecutionFence(fence, () => + withCurrentAgentTeamRunAttempt(taskId, attempts, async () => { + await saveResult(taskId, fence); + await completeAgentTeamRun(taskId, "done", attempts); + throw new Error("Result projection failed"); + }), + ), + ).rejects.toThrow("Result projection failed"); + expect(await results(taskId)).toEqual([]); + expect((await getAgentTeamRunDispatchState(taskId))?.status).toBe( + "running", + ); + }); + + it("checks every inherited fence before reusing the transaction", async () => { + const taskId = "review-stale-parent"; + const { attempts, fence } = await taskAttempt(taskId); + const parent = await claimTaskExecutionLease( + "parent-thread", + "parent-turn", + "parent-run", + ); + await getDbExec().execute({ + sql: "UPDATE agent_execution_leases SET lease_expires_at = clock_timestamp() - INTERVAL '1 millisecond' WHERE thread_id = ? AND turn_id = ?", + args: [parent.threadId, parent.turnId], + }); + await claimTaskExecutionLease( + "parent-thread", + "parent-turn", + "new-parent-run", + ); + await expect( + withExecutionFence(parent, () => + withExecutionFence(fence, () => + withCurrentAgentTeamRunAttempt(taskId, attempts, () => + saveResult(taskId, fence), + ), + ), + ), + ).rejects.toBeInstanceOf(StaleExecutionFenceError); + expect(await results(taskId)).toEqual([]); + }); + + it("does not borrow an active transaction for an unrelated scoped executor", async () => { + const taskId = "review-unrelated-scope"; + const { fence } = await taskAttempt(taskId); + let queries = 0; + await getDbExec().transaction!(async () => { + await expect( + withDbExec( + { + execute: async () => { + queries += 1; + return { rows: [], rowsAffected: 0 }; + }, + }, + () => withExecutionFence(fence, () => saveResult(taskId, fence)), + ), + ).rejects.toThrow("Fenced execution writes require transaction support"); + }); + expect(queries).toBe(0); + expect(await results(taskId)).toEqual([]); + }); + + it("does not reuse a scoped transaction after its callback has settled", async () => { + const taskId = "review-detached"; + const { fence } = await taskAttempt(taskId); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + let detached!: Promise; + await getDbExec().transaction!(async (tx) => { + withDbExec(tx, () => { + detached = gate.then(() => + withExecutionFence(fence, () => saveResult(taskId, fence)), + ); + }); + }); + release(); + await expect(detached).rejects.toThrow( + "Fenced execution writes require transaction support", + ); + expect(await results(taskId)).toEqual([]); + }); +}); diff --git a/packages/core/src/server/agent-teams-run-queue.spec.ts b/packages/core/src/server/agent-teams-run-queue.spec.ts index 1e939767df8..ac58291c56b 100644 --- a/packages/core/src/server/agent-teams-run-queue.spec.ts +++ b/packages/core/src/server/agent-teams-run-queue.spec.ts @@ -309,6 +309,7 @@ const mockDb = { vi.mock("../db/client.js", () => ({ getDbExec: () => mockDb, + getScopedTransactionDbExec: () => undefined, withDbExec: (_exec: unknown, fn: () => unknown) => fn(), retryOnDdlRace: (fn: () => unknown) => fn(), })); diff --git a/templates/design/.agents/skills/design-generation/SKILL.md b/templates/design/.agents/skills/design-generation/SKILL.md index 7418e45a671..1b3ff222497 100644 --- a/templates/design/.agents/skills/design-generation/SKILL.md +++ b/templates/design/.agents/skills/design-generation/SKILL.md @@ -1,18 +1,24 @@ --- name: design-generation description: >- - Generate or refine complete interactive HTML prototypes in Design. Use when - creating screens, variants, Alpine/Tailwind prototypes, tweaks, or visual - refinements from a prompt or selected design. + Generate or refine Design web prototypes. Use when creating screens, + variants, or applying visual refinements. For HubSpot email requests, + read hubspot-email instead. --- # Design Generation -How to generate complete, interactive HTML prototypes using Alpine.js + Tailwind CSS (via CDN). This is the core skill for the design agent. +Generate complete web prototypes through Design actions. + +## HubSpot emails + +For HubSpot email creation, alignment, token edits, conversion or HTML handoff, +read `hubspot-email` before applying the web workflow below. It owns email-safe +authoring and completion checks, which replace the web stack defaults. ## Technology Stack -Every generated design uses: +Web prototypes use: - **Tailwind CSS v4** — via `@tailwindcss/browser@4` CDN (NOT the old v3 CDN) - **Alpine.js 3.x** — via `alpinejs@3.15.11` CDN with `defer` attribute - **Google Fonts** — for distinctive typography (never Inter/Roboto/Arial) diff --git a/templates/design/.agents/skills/export-handoff/SKILL.md b/templates/design/.agents/skills/export-handoff/SKILL.md index 0ff637cbb67..4514ebadf50 100644 --- a/templates/design/.agents/skills/export-handoff/SKILL.md +++ b/templates/design/.agents/skills/export-handoff/SKILL.md @@ -2,7 +2,7 @@ name: export-handoff description: >- Export Design work and produce developer handoff materials. Use when the user - wants HTML, PNG, SVG, ZIP/code exports, implementation notes, or a coding + wants HubSpot email HTML, PNG, SVG, ZIP/code exports, implementation notes, or a coding handoff for a selected design. --- @@ -62,6 +62,17 @@ How to export designs and generate handoff documentation for developers converti ## Export Formats +### HubSpot email HTML + +`export-hubspot-html` validates any HTML frame and returns `{ html, filename, +imageCount }` for manual import; no special frame type is required and saved +source is unchanged. It inlines supported CSS and validates email markup, +HubL, visible footer text and public images. For target resolution, authoring, +conversion and completion checks, read `hubspot-email`; it is the single email +workflow owner. An `invalid_hubspot_email` result identifies content to repair, +not a missing frame category. Validation and sample browser previews do not +prove HubSpot compilation or inbox rendering. + ### HTML Export Bundles all design files into a single standalone HTML file with Tailwind CSS and Alpine.js CDN included. diff --git a/templates/design/.agents/skills/hubspot-email-review/SKILL.md b/templates/design/.agents/skills/hubspot-email-review/SKILL.md new file mode 100644 index 00000000000..f24c81ed342 --- /dev/null +++ b/templates/design/.agents/skills/hubspot-email-review/SKILL.md @@ -0,0 +1,84 @@ +--- +name: hubspot-email-review +description: >- + Independently review HubSpot email copy, brand, links and responsive previews. + Use when the user explicitly requests an independent review or delegates a + read-only review of converted frames or personalized content. +--- + +# HubSpot Email Review + +Return an independent review of the specified saved email. Read the +`hubspot-email` contract for email requirements, then perform this review-only +workflow. Do not spawn another reviewer. + +## Establish the reviewed source + +Read the assigned target, original content and brand references using +`get-design-snapshot`. Read fresh `get-design-surface-index` results for each +explicit `designId` and `fileId`, with `includeNodes: false` and +`includeReview: false`. Verify `index.source.sourceRef` and compare +`index.source.contentHash` against the expected fingerprints. This value is an +opaque source fingerprint, not a SHA-256 digest or `versionHash`. Missing targets, +missing campaign facts that prevent a judgment, or changed hashes require an +incomplete result, not clearance. + +This assignment authorizes reads and in-memory previews only. Do not edit, +create or delete frames, change settings, publish, send messages, or run a +conversion. Snapshot `nextRequiredAction` or edit hints describe capabilities; +they do not authorize writes. Tool access is inherited, so do not claim these +instructions technically enforce read-only access. + +## Inspect recipient experience + +Call `take-design-screenshot` on the exact target with `templatePreview: +"hubspot-email"` and paired 600px/375px widths. Inspect the returned images and +diagnostics; zero diagnostic counts alone do not establish visual quality. Add +320px for dense cards, long words or personalized content. For personalized +copy or CTA fields, use the action's in-memory `sampleContactValues` array to +check defaults, normal and long/unbroken recipient values. Confirm +`preview.usedContactFields` and `preview.unresolvedTokens`. Never persist sample +values or QA copies. Distinguish raw HubL text in the canvas from resolved +recipient layout; unresolved expressions in the opted-in preview remain a gap. + +Review the source and actual pixels against the campaign facts and references: + +- Recipient copy carries the intended message. Implementation notes, validation + instructions, fake test values and unsupported rendering guarantees do not + belong in the delivered email. +- CTA labels and destinations serve the same purpose. Compare conversions with + original content links and labels. A reachable URL alone is insufficient. + Video/play claims, thumbnails and durations require evidence of that actual + video destination; a static graphic linked to a general landing page is not + a video. Use approved sources or official research to resolve uncertainty. +- Alt text matches the actual visible image, including reused assets. Check for + distorted, broken or misleading graphics and invented brand marks. +- Brand, message, hierarchy and content survive conversion. Compare wordmarks, + palette, typography, image treatment and spacing with the supplied reference; + preserve original business purposes rather than substituting generic links. +- Desktop columns, mobile stacking, reading order, card padding, button labels, + long-word wrapping, readable type and footer remain usable at tested widths. + Check clipping and overflow visually, not only through counts. +- Company information and a visible unsubscribe link remain present. Exported + source retains supported HubL; browser previews use sample values and do not + establish HubSpot compilation or real inbox fidelity. + +Do not repair an issue yourself. Return concrete findings with the affected +copy, URL, image or viewport and the change needed. If capture or a necessary +source read fails, report the missing evidence instead of inferring a pass. + +## Return a source-bound result + +Read fresh surface indexes again for the target and references after inspection. +Any changed fingerprint makes the review stale. Return a concise report with: + +- The reviewed design/file IDs and opaque source fingerprint. +- The reference IDs/roles and whether their fingerprints stayed unchanged. +- Actual inspected widths and recipient scenarios. +- Concrete issues supported by source or pixels, separating defects from + optional polish, or a clear finding when the inspected checks pass. +- Any missing evidence or incomplete checks, including the boundary between + sample browser previews and real HubSpot/inbox testing. + +Do not call an incomplete or stale review clear. Return findings to the parent; +the parent owns requested repairs, revalidation and the user handoff. diff --git a/templates/design/.agents/skills/hubspot-email/SKILL.md b/templates/design/.agents/skills/hubspot-email/SKILL.md new file mode 100644 index 00000000000..d9c450bfb13 --- /dev/null +++ b/templates/design/.agents/skills/hubspot-email/SKILL.md @@ -0,0 +1,146 @@ +--- +name: hubspot-email +description: >- + Create, convert, personalize, and validate HubSpot marketing email HTML. + Use for HubSpot email creation, alignment, personalization tokens, + conversion, or HTML handoff, including follow-ups about existing email frames. +--- + +# HubSpot Email + +Use this workflow for HubSpot email requests, including “make sure this aligns +to HubSpot emails” and “add HubSpot tokens.” It replaces the web stack, +interaction, typography, CSS-variable and animation defaults in +`design-generation`. Any HTML frame can export when its content passes +validation; a special frame type or internal marker is not required. + +For a review-only assignment, read `hubspot-email-review` and follow its review +workflow directly. Do not author content or delegate another reviewer. + +Resolve the relevant design and frame(s) from conversation references, frame +names, and current application state. Use `view-screen` for current navigation, +selection and screens, then `get-design-snapshot` for the relevant source. Do +not require manual frame selection when that context identifies the target. +Apply requests about multiple named frames to those frames; ask for a target +only when the available context leaves materially different choices. + +- Create the design if needed, then author complete email HTML with + `create-file` (`fileType: "html"`) or `generate-design` (`devices: []`, a + 600px-wide `canvasFrames` placement). Use optional `frameType: "hubspot-email"` + with `` in the + head to retain email authoring defaults on future edits. Include the leading + HubSpot header comment containing `templateType: email` and + `isAvailableForNewContent: true` so new emails are selectable. The leading + `templateType: email` is required; availability controls template selection + and is not a compilation requirement. +- Build a fluid 600px container using presentation tables with `cellpadding="0"`, + `cellspacing="0"`, explicit widths and table-cell padding. Use inline styles + or simple class rules the exporter can inline. Media-query overrides may + remain for mobile. Use email-safe font fallback stacks matching the brand. + Keep a multi-column desktop layout intact at 600px: put its stacking + breakpoint below 600px. Let long and unbroken words wrap within constrained + table cells instead of widening the email. + Do not include scripts, Tailwind/Alpine, external stylesheets, CSS variables, + flex/grid, absolute positioning, forms, or browser-only interactions. +- Add a nonempty hidden preheader element with `data-email-preheader`. Include + visible company footer text with `{{ site_settings.company_name }}`, + `{{ site_settings.company_street_address_1 }}`, `{{ site_settings.company_city }}`, + and `{{ site_settings.company_state }}`. Include a visible anchor whose href + is exactly `{{ unsubscribe_link }}` for both Unsubscribe and Preferences links; + HubSpot uses it for the subscription preferences page. Do not invent a + `subscription_preferences_url` alias. Optional `unsubscribe_link_single` and + `unsubscribe_link_all` URLs opt out of this email type or all email; + `view_as_page_url` opens the web version. Use the + [official HubL variable reference](https://developers.hubspot.com/docs/cms/reference/hubl/variables). + Keep footer text and a nonempty unsubscribe label visible outside the preheader, + including after inherited/class styles. Prefer readable 14px or larger footer + type with sufficient contrast; 14px is a HubSpot recommendation, not an export + minimum. Preserve these tokens on follow-ups. +- Use `{{ personalization_token('contact.firstname', 'there') }}` for contact + personalization, with a literal default and no filters. This export requires + the function form and rejects direct `contact.*` tokens. HubSpot does not + support personalization filters in email; CMS/blog examples do not establish + email support. The + [official function reference](https://developers.hubspot.com/docs/cms/reference/hubl/functions#personalization-token) + documents the default argument, and the + [filter reference](https://developers.hubspot.com/docs/cms/reference/hubl/filters) + excludes personalization filters in emails. Dynamic CTA defaults must be + actual absolute HTTP(S), mailto, or tel URLs; empty strings, plain text and + script/data URLs are not fallbacks. Preserve HubL in exported HTML, never + fictional customer values. HubL control blocks (`{% ... %}`) are unsupported. +- Verify CTA destinations against the user's approved sources, the referenced + repository, or official product documentation. Confirm the destination serves + the CTA's stated purpose; a plausible-looking or reachable URL is not enough. + Research missing destinations before reporting a remaining user decision; + do not invent a repository or signup URL and call it finished. +- Hero images are optional. Images you include require publicly reachable HTTPS + PNG/JPEG/GIF sources, explicit positive pixel width and height, and alt text + (empty alt only for decorative images). Describe what the rendered image + actually shows; verify inherited alt text against the preview rather than + copying a stale description. Private app/blob URLs, SVGs, data/blob + URLs, relative paths, local assets and CSS background images cannot be handed + off as reliable email assets; obtain a public email-safe asset or omit it. + +To convert an ordinary web frame, use the resolved `designId` and `fileId` to +read its current snapshot. Keep its approved copy, content hierarchy and +branding, and re-author its layout using the email contract above. Save a new +sibling HTML file in the same design with a unique filename; preserve the +source frame. Adding HubSpot tokens or an internal marker alone does not +convert browser layout. Existing email-safe content, including unmarked HTML, +can be refined in place with `edit-design` after a fresh snapshot. Re-read the +saved frame and confirm the original is unchanged after creating a sibling. + +After saving, call `export-hubspot-html` with `id` (design id) and the saved +`fileId`. For a conversion that keeps the original business links, pass +`preserveLinksFromFileId` with the original content frame's ID, not a branding +reference or the converted frame itself. This checks destination preservation; +still review link labels and media meaning against the original content. +Fix reported markup/token/image/link problems and retry until validation +succeeds. Then call `take-design-screenshot` once for that exact `designId` and +`fileId`, with `widths: [600, 375]` and `templatePreview: "hubspot-email"`. +This returns both images for visual inspection from one browser allocation. +This opt-in preview uses clearly fake company values and literal contact defaults +in an in-memory HTML copy; source and exported HTML keep their HubL tokens. +Inspect returned `preview.unresolvedTokens` and resolve unsupported expressions +rather than treating their literal text as final layout. This is sample browser +layout evidence, not HubSpot compilation or inbox-rendering proof. +For personalized content, use `sampleContactValues` on this same preview action +to test normal and deliberately long/unbroken names and populated dynamic CTA +fields. Pass an array such as `[{field: "contact.firstname", value: "Alex"}]`, +with at most 8 unique fields and values up to 500 characters. Each `contact.field` +must match a supported `personalization_token` call in the frame. Omit a +field to inspect its literal default. Check `preview.usedContactFields` and +`preview.unresolvedTokens`. Samples are in-memory only: do not create QA frame +copies just to test recipient variants. Include a 320px preview for complex +layouts or long/unbroken text, then repair the email CSS and repeat the affected +samples before the final default 600/375 pass. +Inspect both returned images and their diagnostics +for overflow, clipped copy, broken or distorted images, and unreadable content; +repair problems and render both widths again after layout changes. A successful +HTML export alone does not complete these visual checks. If rendering is +unavailable, report that check as unfinished instead of calling the email ready. + +## Optional independent review + +After successful export validation and desktop/mobile visual checks, hand off +the HTML with truthful limits. Independent review is optional and runs when the +user explicitly requests it. + +For a requested independent review, use `agent-teams` with a short read-only +review task. Put the target/reference IDs, campaign facts, approved destinations +and fresh `index.source.contentHash` fingerprints from +`get-design-surface-index` in the child instructions. These fingerprints are +opaque values, not SHA-256 digests or `versionHash` values. Ask the reviewer to +read `hubspot-email-review`. Child tool access is inherited; read-only is an +instruction rather than an enforced restriction. + +Use `read-result` with `waitMs: 20000` to await the requested review, continuing +bounded waits while it is queued or running. Read current target/reference +fingerprints again before relying on its findings. Report an incomplete or +stale review honestly; do not present it as a completed review. The parent owns +any requested repairs and repeats export/affected previews after changes. + +Finish concisely with the saved email and verified HTML handoff. Mention actual +remaining gaps or researched CTA decisions; omit unrequested audit/publishing +checklists, canvas coordinates and style inventories. Offer manual import +without claiming HubSpot compilation or inbox fidelity. diff --git a/templates/design/AGENTS.md b/templates/design/AGENTS.md index 36e7cf08ed0..b3a418e3b6f 100644 --- a/templates/design/AGENTS.md +++ b/templates/design/AGENTS.md @@ -1,34 +1,33 @@ # Design — Agent Guide -Design agents build prototypes, systems, variants, and handoffs through -actions against shared SQL state. +Build prototypes, systems, variants, and handoffs through actions and shared SQL. ## Skills -Read `.agents/skills//SKILL.md` before deeper work: -- `design-generation` — generation, adaptation, and readiness checks. +Read `.agents/skills//SKILL.md` as needed: +- `design-generation` — web prototype generation and readiness. +- `hubspot-email` — email creation, alignment, personalization, conversion and handoff. - `design-templates` — reusing existing Design work. - `responsive-breakpoints` — breakpoint editing. - `design-systems` — tokens, brand extraction, or Figma. - `creative-context` — cross-app sources and governed context. - `design-review-feedback` — persisted review comments. -- `export-handoff` — exports and coding handoffs. +- `export-handoff` — exports, HubSpot email HTML validation, and coding handoffs. - `full-app-build` — fusion-backed app builds. - `native-effects` — WebGPU fills/effects, source edits, and PNG. - `journey-storyboards` — onboarding-journey storyboards. -Also read `.agents/skills//SKILL.md` for shared guides: `actions`, +Shared guides: `actions`, `adding-a-feature`, `storing-data`, `security`, `secrets`, `sharing`, `frontend-design`, `shadcn-ui`, `real-time-sync`, `context-awareness`, `delegate-to-agent`, `agent-native-docs`, `agent-native-toolkit`, `customizing-agent-native`, `client-side-routing`, `reliable-mutations`, `performance`, `external-agents`, `portability`, `self-modifying-code`, -`turn-into-skill`, and `workspace-conventions`. Each guide is at -`.agents/skills//SKILL.md`. +`turn-into-skill`, and `workspace-conventions`. ## Framework Docs -Search local docs with `pnpm action docs-search --query ""`; read by slug with `pnpm action docs-search --slug ""`. +Use `pnpm action docs-search --query ""` or `--slug ""`. ## Actions @@ -43,24 +42,24 @@ Search local docs with `pnpm action docs-search --query ""`; read by slug | `open-visual-edit` | Open localhost screens | | `get-visual-edit-collaboration` / `update-visual-edit-collaboration` | Read/set collaboration opt-in | | `add-localhost-screens` / `update-screen-source` | Add screens; change source mode | -| `add-session-replay-screenshots-to-board` | Add private Analytics replay screenshots to a Design board | -| `stage-journey-canvas-frames` | Stage native PNGs in Design-owned private blob storage in batches | -| `discard-journey-canvas-frame-import` | Discard staged import; queue its private blobs for cleanup | -| `create-journey-canvas` | Create provenance-backed storyboard with stubs, same-recording links, and explicit recording-gap references; no cohort metrics | +| `add-session-replay-screenshots-to-board` | Add private replay screenshots | +| `stage-journey-canvas-frames` | Stage storyboard PNGs | +| `discard-journey-canvas-frame-import` | Discard staged storyboard import | +| `create-journey-canvas` | Create provenance-backed storyboard; see journey-storyboards | | `add-breakpoint` / `remove-breakpoint` | Manage responsive frames | | `edit-design` | Adapt a design/screen | | `apply-visual-edit` | Apply deterministic layer edits | -| `get-motion-timeline` / `apply-motion-edit` / `remove-motion-timeline` | Read, source-sync, and remove editable motion timelines | +| `get-motion-timeline` / `apply-motion-edit` / `remove-motion-timeline` | Read/edit/remove motion timelines | | `create-design` / `generate-design` | Start empty design / generate a fresh screen | | `present-design-variants` | Generate 2–5 variants | | `view-screen` / `navigate` | Read current screen / move UI | -| `get-view-settings` / `update-view-settings` | Read/update view toggles (grid, snap, rulers, cursors, hidden comments) | -| `get-shader` / `edit-native-shader` | Inspect curated native effects; CAS-edit one inline scene | -| `get-native-shader-approvals` | Read viewer-scoped exact effect approvals for accessible Design previews | +| `get-view-settings` / `update-view-settings` | Read/update view toggles | +| `get-shader` / `edit-native-shader` | Read/edit native effects | +| `get-native-shader-approvals` | Read effect approvals | | `prepare-native-scene-export` | Pin scene for PNG | -| `get-native-render-contexts` / `request-native-local-export` / `get-native-local-export` | Request foreground native PNG and inspect attachment | -| `export-png` | Export PNG | -| `export-html` / `export-zip` / `export-coding-handoff` / `export-design-as-figma-svg` | Export finished work | +| `get-native-render-contexts` / `request-native-local-export` / `get-native-local-export` | Export native PNG | +| `take-design-screenshot` / `export-png` | Inspect responsive images / export PNG | +| `export-html` / `export-hubspot-html` / `export-zip` / `export-coding-handoff` / `export-design-as-figma-svg` | Export finished work | ## Core Rules @@ -68,7 +67,7 @@ Search local docs with `pnpm action docs-search --query ""`; read by slug - Use actions for design data and writes; never write design rows directly with SQL. - For external integrations, inspect the workspace/provider connection catalog first; reuse its scoped resolver. - `[Reprompt selection]` is preview-only: only `propose-node-rewrite` may mutate. `[Selection question]` is read-only; answer without content-writing actions. -- Generated files are complete standalone HTML (Alpine.js + Tailwind CDN), rendered without a build step. Follow `design-generation` for its quality/audit pass and `data-agent-native-locked="true"` for locked subtrees. +- Web files use standalone Alpine/Tailwind HTML. HubSpot email requests, including alignment and token edits, follow `hubspot-email`, which replaces web defaults. Preserve `data-agent-native-locked="true"` subtrees. - Source modes are `inline`, `localhost`, and `fusion` (`full-app-build`). `/design/:id` is read-only; `/visual-edit/:id` allows DOM-only localhost edits. Source writes and snapshot publishing require editor access. - `capability:visual-edit` scopes handoff actions. External agents use `get-visual-edit-pending`; browser agents use the page-local tool. @@ -77,7 +76,7 @@ Search local docs with `pnpm action docs-search --query ""`; read by slug - `navigation`: current view, design/file id, and related UI state. - `visual-edit`: last project/connection; same-connection opens resume unless `newDesign` is true. - `navigate`: transient request to move the requesting tab; deleted after consumption. -- `design-selection`: active screen/element, overview, inspector, zoom, screen list, and `layoutGrid`; `motion` carries timeline id, ms playhead/duration, playback mode, auto-keyframe, and selected track/keyframe keys. +- `design-selection`: active screen/element, `activeFrameType` and screen `frameType` (`hubspot-email` or null), overview, inspector, zoom, `layoutGrid`; `motion` holds timeline, playback, auto-keyframe and selection. - `design-generation-session:`, `show-questions`, `guided-questions`: generation planning and variant choice; see `design-generation`. - `design-reprompt-pending::` and `design-reprompt-proposal:::` must be present and matched before `propose-node-rewrite`. @@ -85,4 +84,4 @@ Search local docs with `pnpm action docs-search --query ""`; read by slug Before building common workspace or agent UI, read `agent-native-toolkit`; read `customizing-agent-native` before adapting shared UI. Editor behavior lives in `app/pages/design-editor/commands/*.ts`; read `design-editor-architecture` before changing it. -Search with `rg --hidden --follow`; read the exact linked guide before deeper work. +Search with `rg --hidden --follow`. diff --git a/templates/design/actions/apply-source-edit.interleave.spec.ts b/templates/design/actions/apply-source-edit.interleave.spec.ts index 836d579d7f4..021b53711ee 100644 --- a/templates/design/actions/apply-source-edit.interleave.spec.ts +++ b/templates/design/actions/apply-source-edit.interleave.spec.ts @@ -411,7 +411,9 @@ import { writeInlineSourceFile, } from "../server/source-workspace.js"; import { ensureCodeLayerNodeIdsInHtml } from "../shared/code-layer.js"; +import { createHubSpotEmailHtml } from "../shared/hubspot-email.js"; import { sourceContentHash } from "../shared/source-workspace.js"; +import applySourceEditAction from "./apply-source-edit.js"; import updateFileAction from "./update-file.js"; function buildDoc(bodyExtra = ""): string { @@ -479,6 +481,82 @@ beforeEach(() => { }); describe("HTML integrity write boundary", () => { + it("rejects removing an email frame marker through apply-source-edit without changing live or stored content", async () => { + const before = createHubSpotEmailHtml(); + seedFile(before); + await applyText(FILE_ID, before); + const live = await readLiveSourceFile(currentFileRef()); + await expect( + applySourceEditAction.run({ + designId: DESIGN_ID, + fileId: FILE_ID, + expectedVersionHash: live.versionHash, + edit: { + kind: "exact-replace", + search: + '', + replace: "", + }, + }), + ).rejects.toThrow("Preserve the HubSpot email frame marker"); + expect(currentFileRef().content).toBe(before); + expect(getOrCreateDoc(FILE_ID).getText("content").toString()).toBe(before); + }); + + it.each(["shared source", "update-file"])( + "%s rejects a live no-op that would lose the persisted email frame type", + async (writer) => { + const before = createHubSpotEmailHtml(); + seedFile(before); + const unmarked = before.replace( + '', + "", + ); + await applyText(FILE_ID, unmarked); + const live = await readLiveSourceFile(currentFileRef()); + await expect( + writer === "shared source" + ? writeInlineSourceFile({ + designId: DESIGN_ID, + file: currentFileRef(), + content: live.content, + expectedVersionHash: live.versionHash, + }) + : updateFileAction.run({ + id: FILE_ID, + content: live.content, + expectedVersionHash: live.versionHash, + }), + ).rejects.toThrow("Preserve the HubSpot email frame marker"); + expect(currentFileRef().content).toBe(before); + expect(getOrCreateDoc(FILE_ID).getText("content").toString()).toBe( + unmarked, + ); + }, + ); + + it("persists an ordinary source edit through apply-source-edit", async () => { + const before = buildDoc(); + const live = await readLiveSourceFile(currentFileRef()); + const result = await applySourceEditAction.run({ + designId: DESIGN_ID, + fileId: FILE_ID, + expectedVersionHash: live.versionHash, + edit: { + kind: "exact-replace", + search: "Hello world", + replace: "Hello again", + }, + }); + expect(result.changed).toBe(true); + expect(currentFileRef().content).toBe( + before.replace("Hello world", "Hello again"), + ); + expect(getOrCreateDoc(FILE_ID).getText("content").toString()).toBe( + currentFileRef().content, + ); + }); + it("rejects malformed managed-style source and leaves live + SQL content unchanged", async () => { const before = buildDoc(); const live = await readLiveSourceFile(currentFileRef()); diff --git a/templates/design/actions/create-file.spec.ts b/templates/design/actions/create-file.spec.ts index daf9497ba74..ae7863a5a8a 100644 --- a/templates/design/actions/create-file.spec.ts +++ b/templates/design/actions/create-file.spec.ts @@ -5,6 +5,8 @@ import { QueryClient } from "@tanstack/react-query"; import { transformSync } from "esbuild"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createHubSpotEmailHtml } from "../shared/hubspot-email.js"; + const mocks = vi.hoisted(() => { let existingRows: Array> = []; let restoreClaimRows: Array> = []; @@ -978,6 +980,36 @@ describe("create-file: canvas placement and landing URL", () => { ); }); + it("persists a semantic HubSpot email at email width and preserves the starter tokens", async () => { + const result = await action.run({ + designId: "design-1", + filename: "welcome-email.html", + content: createHubSpotEmailHtml({ title: "Welcome" }), + fileType: "html", + frameType: "hubspot-email", + }); + expect(result.frameType).toBe("hubspot-email"); + expect(mocks.getDesignData().canvasFrames).toMatchObject({ + [result.id]: { width: 600, height: 800 }, + }); + expect(mocks.insertValues.mock.calls[0]![0].content).toContain( + "{{ unsubscribe_link }}", + ); + }); + + it("rejects an ordinary web page requested as an email frame before writing", async () => { + await expect( + action.run({ + designId: "design-1", + filename: "email.html", + content: "
A web page
", + fileType: "html", + frameType: "hubspot-email", + }), + ).rejects.toThrow("requires { const result = await action.run({ designId: "design-1", diff --git a/templates/design/actions/create-file.ts b/templates/design/actions/create-file.ts index ae918b893b7..47cec742f14 100644 --- a/templates/design/actions/create-file.ts +++ b/templates/design/actions/create-file.ts @@ -26,6 +26,8 @@ import { assertDesignHtmlCreateIntegrity, describeDesignHtmlIntegrityIssue, } from "../shared/html-integrity.js"; +import { assertHubSpotEmailFrameContent } from "../shared/hubspot-email-authoring.js"; +import { isHubSpotEmailHtml } from "../shared/hubspot-email.js"; import { getResponsiveBreakpointWidths } from "../shared/responsive-frame-layout.js"; import { annotateScreenHtmlForPersist } from "../shared/screen-annotation.js"; import { assertDesignWidgetWriteScope } from "./widget-write-scope.js"; @@ -92,11 +94,17 @@ function screenRestoreSnapshotMatches( export default defineAction({ description: "Add a new file to a design project. Validates that the design exists and " + - "the user has editor access. Returns the new file's ID, filename, and design URL path when the file is renderable.", + "the user has editor access. For HubSpot conversion, save email-safe HTML as a new sibling while preserving its source. Optional frameType=hubspot-email retains email authoring metadata; it is not required for export. Returns the new file's ID, filename, and design URL path when the file is renderable.", schema: z.object({ designId: z.string().describe("Design project ID to add the file to"), filename: z.string().describe("Filename (e.g. 'index.html', 'styles.css')"), content: z.string().describe("File content"), + frameType: z + .literal("hubspot-email") + .optional() + .describe( + "Optional internal email authoring type; when supplied, include the HubSpot email marker in email-safe HTML. Any HTML frame can use export-hubspot-html, which validates actual compatibility.", + ), restoreClaimId: z .string() .optional() @@ -122,6 +130,7 @@ export default defineAction({ designId, filename, content, + frameType, fileType, restoreClaimId, duplicateSourceFileId, @@ -163,6 +172,7 @@ export default defineAction({ const requestedId = nanoid(); const now = new Date().toISOString(); + assertHubSpotEmailFrameContent({ content, fileType, frameType }); const annotatedContent = annotateScreenHtmlForPersist(content, fileType); const restoredContentHashes = screenRestoreContentHashes( annotatedContent, @@ -500,13 +510,36 @@ export default defineAction({ }, }, ), - width: CREATED_SCREEN_WIDTH, - height: CREATED_SCREEN_HEIGHT, + width: isHubSpotEmailHtml(createdFile.content) + ? 600 + : CREATED_SCREEN_WIDTH, + height: isHubSpotEmailHtml(createdFile.content) + ? 800 + : CREATED_SCREEN_HEIGHT, }, ], resolveFileId: (placement) => placement.fileId, }); - return { ...current, canvasFrames: merged.canvasFrames }; + return { + ...current, + canvasFrames: merged.canvasFrames, + ...(isHubSpotEmailHtml(createdFile.content) + ? { + screenMetadata: { + ...(isRecord(current.screenMetadata) + ? current.screenMetadata + : {}), + [id]: { + width: 600, + height: 800, + heightMode: "hug", + heightPinned: false, + breakpointWidths: [], + }, + }, + } + : {}), + }; }, isApplied: (current) => Boolean(parseCanvasFrameGeometryById(current.canvasFrames)[id]), @@ -529,6 +562,9 @@ export default defineAction({ designId, filename, fileType: resolvedFileType, + frameType: isHubSpotEmailHtml(createdFile.content) + ? "hubspot-email" + : null, createdAt: createdFile.createdAt, renderable, _receipt: designOutputWriteReceipt(designId, createdFile.created ? 1 : 0), diff --git a/templates/design/actions/edit-design.ts b/templates/design/actions/edit-design.ts index c068e390377..968b3571781 100644 --- a/templates/design/actions/edit-design.ts +++ b/templates/design/actions/edit-design.ts @@ -171,7 +171,7 @@ export default defineAction({ "from `get-design-snapshot` first, then call `index-design-tokens` and reuse " + "the design's existing tokens so the edited screen matches its siblings; " + "introduce values the design does not already use only when asked. " + - "Use `generate-design` instead only for brand-new files.", + "When refining email HTML, preserve email-safe tables, inline-compatible styles, required HubL footer tokens and any existing internal marker; validate its handoff with export-hubspot-html, which accepts any HTML frame. To rebuild an ordinary frame as a HubSpot email, read it first and use create-file for a new sibling rather than overwriting the source. Use `generate-design` instead only for brand-new files.", schema: z .object({ designId: z.string().describe("Design project ID"), diff --git a/templates/design/actions/export-hubspot-html.test.ts b/templates/design/actions/export-hubspot-html.test.ts new file mode 100644 index 00000000000..c642b78efda --- /dev/null +++ b/templates/design/actions/export-hubspot-html.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { createHubSpotEmailHtml } from "../shared/hubspot-email.js"; +import { sourceContentHash } from "../shared/source-workspace.js"; + +const mocks = vi.hoisted(() => { + const query = { from: vi.fn(), where: vi.fn(), limit: vi.fn() }; + query.from.mockReturnValue(query); + query.where.mockReturnValue(query); + return { + query, + select: vi.fn(() => query), + resolveAccess: vi.fn(), + exportEmail: vi.fn(), + }; +}); +vi.mock("@agent-native/core/sharing", () => ({ + resolveAccess: mocks.resolveAccess, +})); +vi.mock("../server/db/index.js", () => ({ + getDb: () => ({ select: mocks.select }), + schema: { designFiles: { designId: "designId", id: "id" } }, +})); +vi.mock("../server/lib/hubspot-email-export.js", () => ({ + exportHubSpotEmailHtml: mocks.exportEmail, +})); +vi.mock("../server/lib/design-export.js", () => ({ + exportFilename: (title: string, extension: string) => `${title}.${extension}`, +})); + +import exportAction from "./export-hubspot-html.js"; + +describe("export-hubspot-html access and snapshot boundary", () => { + const content = createHubSpotEmailHtml().replace( + '', + "", + ); + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveAccess.mockResolvedValue({ + resource: { title: "Email design" }, + role: "viewer", + }); + mocks.query.limit.mockResolvedValue([ + { + id: "email-file", + filename: "announcement.html", + fileType: "html", + content, + }, + ]); + mocks.exportEmail.mockResolvedValue({ html: "exported", imageCount: 0 }); + }); + + it("denies inaccessible designs before reading child files or verifying image URLs", async () => { + mocks.resolveAccess.mockResolvedValue(null); + await expect( + exportAction.run({ id: "private-design", fileId: "email-file" }), + ).rejects.toThrow("inaccessible"); + expect(mocks.select).not.toHaveBeenCalled(); + expect(mocks.exportEmail).not.toHaveBeenCalled(); + }); + + it("rejects an absent or foreign child frame", async () => { + mocks.query.limit.mockResolvedValue([]); + await expect( + exportAction.run({ id: "design", fileId: "foreign-file" }), + ).rejects.toThrow("not found in this design"); + expect(mocks.exportEmail).not.toHaveBeenCalled(); + }); + + it("rejects stale editor content before exporting the saved snapshot", async () => { + await expect( + exportAction.run({ + id: "design", + fileId: "email-file", + expectedVersionHash: sourceContentHash(content + "changed"), + }), + ).rejects.toThrow("unsaved or concurrent changes"); + expect(mocks.exportEmail).not.toHaveBeenCalled(); + }); + + it("reads the unmarked HTML snapshot unchanged and returns a downloadable filename", async () => { + const result = await exportAction.run({ + id: "design", + fileId: "email-file", + expectedVersionHash: sourceContentHash(content), + }); + expect(mocks.exportEmail).toHaveBeenCalledWith(content); + expect((await mocks.query.limit.mock.results[0].value)[0].content).toBe( + content, + ); + expect(result).toEqual({ + html: "exported", + imageCount: 0, + filename: "announcement-hubspot.html", + }); + }); + + it("checks a distinct original HTML frame before forwarding unchanged snapshots for link preservation", async () => { + const original = 'Invite'; + const sourceFile = { + id: "original-file", + fileType: "html", + content: original, + }; + mocks.query.limit + .mockResolvedValueOnce([ + { id: "email-file", filename: "email.html", fileType: "html", content }, + ]) + .mockResolvedValueOnce([sourceFile]); + await exportAction.run({ + id: "design", + fileId: "email-file", + preserveLinksFromFileId: "original-file", + }); + expect(mocks.exportEmail).toHaveBeenCalledWith(content, { + preserveLinksFromHtml: original, + }); + expect(sourceFile.content).toBe(original); + }); + + it("rejects a trivial self-comparison before exporting", async () => { + await expect( + exportAction.run({ + id: "design", + fileId: "email-file", + preserveLinksFromFileId: "email-file", + }), + ).rejects.toThrowError( + expect.objectContaining({ errorCode: "invalid_hubspot_email" }), + ); + expect(mocks.exportEmail).not.toHaveBeenCalled(); + expect(mocks.query.limit).toHaveBeenCalledTimes(1); + }); + + it.each( + [ + [], + [{ fileType: "css", content: "body{}" }], + [{ fileType: "html", content: "" }], + ].map((sourceRows) => [sourceRows]), + )( + "rejects missing, foreign or unreadable original HTML (%s)", + async (sourceRows) => { + mocks.query.limit + .mockResolvedValueOnce([ + { + id: "email-file", + filename: "email.html", + fileType: "html", + content, + }, + ]) + .mockResolvedValueOnce(sourceRows); + await expect( + exportAction.run({ + id: "design", + fileId: "email-file", + preserveLinksFromFileId: "missing-or-foreign", + }), + ).rejects.toThrow("original HTML content frame"); + expect(mocks.exportEmail).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/templates/design/actions/export-hubspot-html.ts b/templates/design/actions/export-hubspot-html.ts new file mode 100644 index 00000000000..e35934e6919 --- /dev/null +++ b/templates/design/actions/export-hubspot-html.ts @@ -0,0 +1,110 @@ +import { defineAction, fail } from "@agent-native/core/action"; +import { resolveAccess } from "@agent-native/core/sharing"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb, schema } from "../server/db/index.js"; +import { exportFilename } from "../server/lib/design-export.js"; +import { exportHubSpotEmailHtml } from "../server/lib/hubspot-email-export.js"; +import { sourceContentHash } from "../shared/source-workspace.js"; + +export default defineAction({ + description: + "Validate and export any HTML frame as HubSpot email HTML with inline styles, preserved responsive CSS and HubL tokens, and validated public images. Returns html, filename, and imageCount; fails on unsupported browser content, hidden/missing email requirements, or contact personalization filters (use personalization_token with a literal default and no filters). Use preserveLinksFromFileId to check that a converted frame retains its original business link destinations. Read hubspot-email for the complete authoring and handoff workflow. This action validates HTML and images only; it does not establish semantic review, HubSpot compilation, or inbox rendering.", + schema: z.object({ + id: z.string().describe("Design ID containing the HTML frame"), + fileId: z + .string() + .describe( + "File ID of any HTML frame to validate and export; no special frame type is required", + ), + preserveLinksFromFileId: z + .string() + .optional() + .describe( + "Optional original content frame ID in this same design, distinct from fileId. Rejects export when approved source anchor destinations are missing or rewritten, including literal contact URL defaults. Use when converting a frame while retaining its business links; this does not verify link labels or media semantics.", + ), + expectedVersionHash: z + .string() + .optional() + .describe( + "Optional sourceContentHash of the editor's current frame HTML; rejects an unsaved or concurrently changed source with HTTP 409", + ), + }), + readOnly: true, + dedupe: false, + run: async ({ id, fileId, expectedVersionHash, preserveLinksFromFileId }) => { + const access = await resolveAccess("design", id); + if (!access) + fail("Design not found or inaccessible.", { + errorCode: "not_found", + statusCode: 404, + }); + const [file] = await getDb() + .select() + .from(schema.designFiles) + .where( + and( + eq(schema.designFiles.designId, id), + eq(schema.designFiles.id, fileId), + ), + ) + .limit(1); + if (!file || file.fileType !== "html" || !file.content) + fail("The selected HTML frame was not found in this design.", { + errorCode: "not_found", + statusCode: 404, + }); + if ( + expectedVersionHash !== undefined && + expectedVersionHash !== sourceContentHash(file.content) + ) + fail( + "Save your changes before exporting; this frame has unsaved or concurrent changes.", + { errorCode: "hubspot_email_source_changed", statusCode: 409 }, + ); + let sourceHtml: string | undefined; + if (preserveLinksFromFileId !== undefined) { + if (preserveLinksFromFileId === fileId) + fail( + "Choose the distinct original content frame for link preservation, not the exported frame itself.", + { + errorCode: "invalid_hubspot_email", + statusCode: 422, + }, + ); + const [source] = await getDb() + .select() + .from(schema.designFiles) + .where( + and( + eq(schema.designFiles.designId, id), + eq(schema.designFiles.id, preserveLinksFromFileId), + ), + ) + .limit(1); + if (!source || source.fileType !== "html" || !source.content) + fail( + "The original HTML content frame for link preservation was not found in this design.", + { + errorCode: "not_found", + statusCode: 404, + }, + ); + sourceHtml = source.content; + } + const result = + sourceHtml === undefined + ? await exportHubSpotEmailHtml(file.content) + : await exportHubSpotEmailHtml(file.content, { + preserveLinksFromHtml: sourceHtml, + }); + return { + ...result, + filename: exportFilename( + file.filename.replace(/\.html?$/i, "") + "-hubspot", + "html", + ), + }; + }, +}); diff --git a/templates/design/actions/export-png.spec.ts b/templates/design/actions/export-png.spec.ts index e6681144499..699e5c4ff02 100644 --- a/templates/design/actions/export-png.spec.ts +++ b/templates/design/actions/export-png.spec.ts @@ -21,6 +21,12 @@ describe("export-png", () => { true, ); expect(action.schema.safeParse({ fileId: "file_1" }).success).toBe(true); + expect( + action.schema.safeParse({ + fileId: "file_1", + sampleContactValues: [{ field: "contact.firstname", value: "Alex" }], + }).success, + ).toBe(false); }); it("exports one selected screen through the screenshot renderer", async () => { @@ -39,6 +45,12 @@ describe("export-png", () => { fileId: "file_2", filename: "Quarterly results.html", capturedAt: "2026-09-09T00:00:00.000Z", + preview: { + mode: "hubspot-email", + sampleValues: true, + substitutedTokens: ["{{ site_settings.company_name }}"], + unresolvedTokens: [], + }, screenshots: [ { viewport: { label: "desktop-900", widthPx: 900, heightPx: 600 }, @@ -56,6 +68,8 @@ describe("export-png", () => { filename: "index.html", width: 900, height: 600, + templatePreview: "hubspot-email", + sampleContactValues: [{ field: "contact.firstname", value: "Alex" }], }, { caller: "mcp" }, ); @@ -66,6 +80,8 @@ describe("export-png", () => { filename: "index.html", widths: [900], heights: [600], + templatePreview: "hubspot-email", + sampleContactValues: [{ field: "contact.firstname", value: "Alex" }], }, { caller: "mcp" }, ); @@ -78,6 +94,11 @@ describe("export-png", () => { mimeType: "image/png", diagnostics, _agentImages: agentImages, + preview: { + mode: "hubspot-email", + sampleValues: true, + unresolvedTokens: [], + }, }); }); diff --git a/templates/design/actions/export-png.ts b/templates/design/actions/export-png.ts index fc3a3aa8c08..3a8115b5712 100644 --- a/templates/design/actions/export-png.ts +++ b/templates/design/actions/export-png.ts @@ -6,6 +6,10 @@ import { trackDesignExported, withDesignExportFailureTracking, } from "../server/lib/design-export-tracking.js"; +import { + sampleContactValuesSchema, + type SampleContactValues, +} from "../server/lib/hubspot-email-preview.js"; import takeDesignScreenshot, { getScreenshotPngData, } from "./take-design-screenshot.js"; @@ -26,6 +30,8 @@ const trackPngExportFailures = withDesignExportFailureTracking<{ filename: string; width?: number; height?: number; + templatePreview?: "hubspot-email"; + sampleContactValues?: SampleContactValues; }>("png"); export default defineAction({ @@ -37,7 +43,7 @@ export default defineAction({ "request-native-local-export action in an open editor tab; this " + "headless action fails typed for those screens rather than returning " + "pixels without the effect. Other screens render at 1440px wide " + - "by default and full-page height.", + "by default and full-page height. Optional templatePreview=hubspot-email uses fake sample template values for browser-layout proof, not HubSpot compilation or inbox rendering; source and HTML export remain unchanged.", schema: z .object({ designId: z @@ -57,6 +63,17 @@ export default defineAction({ .describe( "Screen filename when fileId is not provided. Defaults to index.html.", ), + templatePreview: z + .literal("hubspot-email") + .optional() + .describe( + "Optional hubspot-email sample preview using supported template tokens/contact defaults. Omit to render raw HTML unchanged.", + ), + sampleContactValues: sampleContactValuesSchema + .optional() + .describe( + "Optional in-memory recipient samples, only with templatePreview=hubspot-email: an array of at most 8 unique {field, value} entries, with contact.field names and 500 characters per value. Fields must match supported personalization_token calls; unknown or unused fields fail. Omit fields to use literal defaults; saved HTML is unchanged.", + ), width: z.coerce .number() .int() @@ -77,17 +94,37 @@ export default defineAction({ .refine(({ designId, fileId }) => Boolean(designId || fileId), { message: "Provide designId or fileId to select a screen.", path: ["designId"], - }), + }) + .refine( + ({ templatePreview, sampleContactValues }) => + sampleContactValues === undefined || + templatePreview === "hubspot-email", + { + message: + 'sampleContactValues requires templatePreview="hubspot-email".', + path: ["sampleContactValues"], + }, + ), readOnly: false, http: { method: "POST" }, run: trackPngExportFailures(async (args, ctx, facts) => { - const { designId, fileId, filename, width, height } = args; + const { + designId, + fileId, + filename, + width, + height, + templatePreview, + sampleContactValues, + } = args; const result = await takeDesignScreenshot.run( { ...(designId ? { designId } : {}), ...(fileId ? { fileId } : {}), filename, widths: [width ?? 1440], + ...(templatePreview ? { templatePreview } : {}), + ...(sampleContactValues === undefined ? {} : { sampleContactValues }), ...(height === undefined ? {} : { heights: [height] }), }, ctx, @@ -179,6 +216,8 @@ export default defineAction({ viewport: screenshot.viewport, diagnostics: screenshot.diagnostics, capturedAt: result.capturedAt, + captureTimings: result.captureTimings, + ...(result.preview ? { preview: result.preview } : {}), }; }), }); diff --git a/templates/design/actions/generate-design.spec.ts b/templates/design/actions/generate-design.spec.ts index 6abac5566c1..178c04f2791 100644 --- a/templates/design/actions/generate-design.spec.ts +++ b/templates/design/actions/generate-design.spec.ts @@ -833,6 +833,36 @@ describe("generate-design: new-file creation path", () => { resetDesignDataMutation(); }); + it("gives semantic email output a fluid email viewport without generated web breakpoints", async () => { + const result = await action.run({ + designId: "design-1", + prompt: "Create a HubSpot email template", + files: [ + { + filename: "email.html", + fileType: "html", + frameType: "hubspot-email", + content: + '
Hello
', + }, + ], + }); + const fileId = result.savedFiles[0]!.id; + expect(mocks.getDesignData()).toMatchObject({ + canvasFrames: { [fileId]: { width: 600, height: 800 } }, + screenMetadata: { + [fileId]: { + width: 600, + height: 800, + heightMode: "hug", + heightPinned: false, + breakpointWidths: [], + }, + }, + }); + expect(mocks.getDesignData().breakpointSet).toBeUndefined(); + }); + it("creates a brand-new file via insert + seedFromText, with no pre-existing base to race against", async () => { const result = await action.run({ designId: "design-1", diff --git a/templates/design/actions/generate-design.ts b/templates/design/actions/generate-design.ts index 9a66dbe8cbc..a4c5bce94ef 100644 --- a/templates/design/actions/generate-design.ts +++ b/templates/design/actions/generate-design.ts @@ -65,6 +65,8 @@ import { inspectDesignHtmlDocumentIntegrity, isDesignHtmlIntegrityError, } from "../shared/html-integrity.js"; +import { assertHubSpotEmailFrameContent } from "../shared/hubspot-email-authoring.js"; +import { isHubSpotEmailHtml } from "../shared/hubspot-email.js"; import { assertLockedLayersPreserved } from "../shared/locked-layers.js"; import { derivePromptTitle } from "../shared/prompt-title.js"; import { widthToPrefix } from "../shared/responsive-classes.js"; @@ -456,6 +458,7 @@ const generateDesignAgentParameters = { description: "JSON array of files to save. Pass one compact, complete, renderable index.html first, e.g. " + '[{"filename":"index.html","fileType":"html","content":"..."}]. ' + + 'For HubSpot email HTML author email-safe tables; optional frameType:"hubspot-email" requires its internal meta marker but is not required for export. ' + "Do not use generate-design to replace a selected variant screen after a variant pick; snapshot that fileId and use edit-design instead.", }, designSystemId: { @@ -567,7 +570,7 @@ const generateDesignAction = defineAction({ "Do not report a design as ready until this action succeeds. " + "When adding multiple screens or states, pass canvasFrames with filenames " + "and numeric x/y/width/height so the new screens appear placed on the " + - "overview canvas.", + "overview canvas. For HubSpot email output, author email-safe table HTML without browser runtimes; optional frameType hubspot-email requires its internal meta marker. Any HTML frame can be validated with export-hubspot-html before reporting compatibility.", schema: z.object({ designId: z.string().describe("Design project ID to save content to"), prompt: z @@ -584,6 +587,12 @@ const generateDesignAction = defineAction({ z.object({ filename: z.string().describe("Filename (e.g. 'index.html')"), content: z.string().min(1).describe("File content"), + frameType: z + .literal("hubspot-email") + .optional() + .describe( + "Optional internal email authoring type; when supplied, include its meta marker and email-safe HTML. Any HTML frame can be validated and exported with export-hubspot-html.", + ), fileType: z .enum(["html", "css", "jsx", "asset"]) .optional() @@ -845,6 +854,14 @@ const generateDesignAction = defineAction({ const existingByName = new Map(existingFiles.map((f) => [f.filename, f])); + for (const file of files) { + assertHubSpotEmailFrameContent({ + content: file.content, + fileType: file.fileType ?? "html", + frameType: file.frameType, + }); + } + const annotatedFiles = files.map((file) => ({ ...file, content: annotateScreenHtmlForPersist(file.content, file.fileType), @@ -1009,6 +1026,7 @@ const generateDesignAction = defineAction({ height: number; breakpointWidths: number[] | undefined; fixedSize: boolean; + contentDriven: boolean; }> = []; const normalizedTweaks = tweaks?.map((tweak) => ({ ...tweak, @@ -1029,13 +1047,18 @@ const generateDesignAction = defineAction({ files.some((file) => file.filename === existingFile.filename), ) && isFixedArtworkCopyFollowUp(prompt); - const requestedDevices = exactFixedCanvasOutput - ? [] - : userRequestedDeviceVariants.length > 0 - ? userRequestedDeviceVariants - : devices !== undefined && fixedArtworkFollowUp - ? [] - : devices; + const renderableFiles = files.filter(isRenderableDesignScreenFile); + const emailOnlyOutput = + renderableFiles.length > 0 && + renderableFiles.every((file) => isHubSpotEmailHtml(file.content)); + const requestedDevices = + emailOnlyOutput || exactFixedCanvasOutput + ? [] + : userRequestedDeviceVariants.length > 0 + ? userRequestedDeviceVariants + : devices !== undefined && fixedArtworkFollowUp + ? [] + : devices; const ignoredModelDevices = exactFixedCanvasOutput ? (devices ?? []) : []; const explicitDeviceSelection = requestedDevices !== undefined && requestedDevices.length > 0; @@ -1160,7 +1183,9 @@ const generateDesignAction = defineAction({ ? (rawMetadata as Record) : {}; const frame = merged.canvasFrames[file.id]; + const emailFrame = isHubSpotEmailHtml(source.content); const width = + (emailFrame ? 600 : undefined) ?? promptCanvasDimensions?.width ?? (explicitDeviceSelection ? typeof frame?.width === "number" && frame.width > 0 @@ -1172,6 +1197,11 @@ const generateDesignAction = defineAction({ ? frame.width : viewport.width); const height = + (emailFrame + ? typeof metadata.height === "number" + ? metadata.height + : (frame?.height ?? 800) + : undefined) ?? promptCanvasDimensions?.height ?? (explicitDeviceSelection ? typeof frame?.height === "number" && frame.height > 0 @@ -1211,19 +1241,21 @@ const generateDesignAction = defineAction({ existingWidth === width && existingHeight === height && !isFixedCanvasFormatConversion(prompt); - const breakpointWidths = explicitDeviceSelection - ? fixedCanvasOutput - ? generatedBreakpointSet.map((breakpoint) => breakpoint.widthPx) - : undefined - : sameFixedCanvasSize - ? (existingBreakpointWidths ?? []) - : fixedCanvasOutput || explicitlyEmptyDeviceSelection - ? [] - : existingBreakpointWidths !== undefined - ? existingBreakpointWidths - : generatedBreakpointSet.length === 0 - ? [] - : undefined; + const breakpointWidths = emailFrame + ? [] + : explicitDeviceSelection + ? fixedCanvasOutput + ? generatedBreakpointSet.map((breakpoint) => breakpoint.widthPx) + : undefined + : sameFixedCanvasSize + ? (existingBreakpointWidths ?? []) + : fixedCanvasOutput || explicitlyEmptyDeviceSelection + ? [] + : existingBreakpointWidths !== undefined + ? existingBreakpointWidths + : generatedBreakpointSet.length === 0 + ? [] + : undefined; const nextMetadata: Record = { ...metadata, width, @@ -1234,7 +1266,12 @@ const generateDesignAction = defineAction({ } else { delete nextMetadata.breakpointWidths; } - if (fixedCanvasOutput) { + if (emailFrame) { + nextMetadata.heightPinned = false; + nextMetadata.heightMode = "hug"; + if (frame) + merged.canvasFrames[file.id] = { ...frame, width, height }; + } else if (fixedCanvasOutput) { nextMetadata.heightPinned = true; nextMetadata.heightMode = "fixed"; } @@ -1245,7 +1282,8 @@ const generateDesignAction = defineAction({ width, height, breakpointWidths, - fixedSize: fixedCanvasOutput, + fixedSize: fixedCanvasOutput && !emailFrame, + contentDriven: emailFrame, }); } } @@ -1385,6 +1423,7 @@ const generateDesignAction = defineAction({ ); if (!source || !isRenderableDesignScreenFile(source)) continue; const current = merged.canvasFrames[file.id] ?? {}; + const emailFrame = isHubSpotEmailHtml(source.content); if ( preExistingFrameIds.has(file.id) && !exactSizeResizedFileIds.has(file.id) && @@ -1395,10 +1434,16 @@ const generateDesignAction = defineAction({ ) { continue; } - const width = - current.width ?? promptCanvasDimensions?.width ?? viewport.width; + const width = emailFrame + ? 600 + : (current.width ?? + promptCanvasDimensions?.width ?? + viewport.width); const height = - current.height ?? promptCanvasDimensions?.height ?? viewport.height; + current.height ?? + (emailFrame ? 800 : undefined) ?? + promptCanvasDimensions?.height ?? + viewport.height; let x = current.x ?? nextX; let y = current.y ?? 0; let candidateRect = rectOf( @@ -1563,7 +1608,14 @@ const generateDesignAction = defineAction({ ? (current.screenMetadata as Record) : {}; const screenMetadataApplied = screenMetadataUpdates.every( - ({ fileId, width, height, breakpointWidths, fixedSize }) => { + ({ + fileId, + width, + height, + breakpointWidths, + fixedSize, + contentDriven, + }) => { const metadata = currentMetadata[fileId]; return ( metadata && @@ -1580,7 +1632,11 @@ const generateDesignAction = defineAction({ )) && (!fixedSize || ((metadata as Record).heightPinned === true && - (metadata as Record).heightMode === "fixed")) + (metadata as Record).heightMode === + "fixed")) && + (!contentDriven || + ((metadata as Record).heightPinned === false && + (metadata as Record).heightMode === "hug")) ); }, ); diff --git a/templates/design/actions/get-design-snapshot.spec.ts b/templates/design/actions/get-design-snapshot.spec.ts index b13dd7bdcfb..f514eed25f3 100644 --- a/templates/design/actions/get-design-snapshot.spec.ts +++ b/templates/design/actions/get-design-snapshot.spec.ts @@ -80,6 +80,7 @@ describe("get-design-snapshot", () => { id: "file-b", filename: "variant-b.html", fileType: "html", + frameType: null, content: "B", source: "collab", }, diff --git a/templates/design/actions/get-design-snapshot.ts b/templates/design/actions/get-design-snapshot.ts index 2f07f65f972..10cec7681a6 100644 --- a/templates/design/actions/get-design-snapshot.ts +++ b/templates/design/actions/get-design-snapshot.ts @@ -10,6 +10,7 @@ import { parseDesignTemplateData, readDesignTemplateSource, } from "../server/lib/design-template-data.js"; +import { isHubSpotEmailHtml } from "../shared/hubspot-email.js"; import { lockedLayerSnapshots } from "../shared/locked-layers.js"; import getDesignSystem from "./get-design-system.js"; import "../server/db/index.js"; @@ -48,6 +49,7 @@ export default defineAction({ ), }), readOnly: true, + dedupe: false, capabilityScopes: ["visual-edit"], http: { method: "GET" }, publicAgent: { expose: true, readOnly: true, requiresAuth: true }, @@ -132,6 +134,7 @@ export default defineAction({ id: f.id, filename: f.filename, fileType: f.fileType, + frameType: isHubSpotEmailHtml(f.content) ? "hubspot-email" : null, content: f.content, source: f.source, })), @@ -157,7 +160,8 @@ export default defineAction({ filename: boundedFile.filename, }, nextRequiredAction: - `Call edit-design exactly once with designId ${designId} and fileId ${boundedFile.id} (${boundedFile.filename}). ` + + `For an in-place refinement, Call edit-design exactly once with designId ${designId} and fileId ${boundedFile.id} (${boundedFile.filename}). ` + + "For a requested HubSpot email rebuild, use this source to author a new email-safe HTML sibling with create-file frameType=hubspot-email and preserve the original. " + "Do not call delete-file or get-design-snapshot again unless edit-design fails with a concrete missing context error.", } : {}), diff --git a/templates/design/actions/get-design-surface-index.ts b/templates/design/actions/get-design-surface-index.ts index fa1b92cd6d6..5e64e4ddb0e 100644 --- a/templates/design/actions/get-design-surface-index.ts +++ b/templates/design/actions/get-design-surface-index.ts @@ -420,6 +420,7 @@ export default defineAction({ ), }), readOnly: true, + dedupe: false, http: { method: "GET" }, run: async ({ designId, diff --git a/templates/design/actions/take-design-screenshot.spec.ts b/templates/design/actions/take-design-screenshot.spec.ts index 388cbc68eca..9b61db4b986 100644 --- a/templates/design/actions/take-design-screenshot.spec.ts +++ b/templates/design/actions/take-design-screenshot.spec.ts @@ -65,6 +65,9 @@ vi.mock("../server/lib/playwright-runtime.js", async (importOriginal) => { launchChromium: playwrightMocks.launchChromium, }; }); +vi.mock("../server/lib/screenshot-readiness.js", () => ({ + prepareScreenshotPage: vi.fn(async (page, html) => page.setContent(html)), +})); vi.mock("../server/source-workspace.js", () => ({ readLiveSourceFile: vi.fn(async () => ({ content: @@ -88,6 +91,18 @@ import action, { } from "./take-design-screenshot.js"; describe("public design screenshot access", () => { + it("rejects contact samples for raw screenshots before reading a file", async () => { + const args = { + fileId: "file_1", + sampleContactValues: [{ field: "contact.firstname", value: "Alex" }], + }; + expect(action.schema.safeParse(args).success).toBe(false); + mockGetDb.mockClear(); + await expect(action.run(args)).rejects.toThrow( + 'sampleContactValues requires templatePreview="hubspot-email"', + ); + expect(mockGetDb).not.toHaveBeenCalled(); + }); it("includes public link visibility when reading a specific design file", async () => { mockAccessFilter.mockClear(); const result = await action.run( @@ -137,10 +152,7 @@ describe("screenshot image handoff", () => { const page = { on: vi.fn(), setContent: vi.fn().mockResolvedValue(undefined), - evaluate: vi - .fn() - .mockResolvedValueOnce(undefined) - .mockResolvedValue(diagnostics), + evaluate: vi.fn().mockResolvedValue(diagnostics), waitForFunction: vi.fn().mockResolvedValue(undefined), screenshot: vi.fn().mockResolvedValue(png), }; @@ -161,7 +173,7 @@ describe("screenshot image handoff", () => { url: "https://files.example.test/screen.png", } as never); - const args = { fileId: "file_1", widths: [900] }; + const args = { fileId: "file_1", widths: [600, 375, 500, 400, 300] }; const screenshotResult = await action.run(args, { caller: "tool", actionName: "take-design-screenshot", @@ -181,7 +193,18 @@ describe("screenshot image handoff", () => { expect(renderedHtml).toContain("background: #00c248"); expect(renderedHtml).not.toContain("oklch("); - expect(screenshotResult).not.toHaveProperty("_agentImages"); + expect(screenshotResult._agentImages).toHaveLength(4); + expect(screenshotResult._agentImages?.slice(0, 2)).toEqual([ + { + url: "https://files.example.test/screen.png", + label: "index.html (600px)", + }, + { + url: "https://files.example.test/screen.png", + label: "index.html (375px)", + }, + ]); + expect(httpExportResult).not.toHaveProperty("_agentImages"); expect(JSON.stringify(screenshotResult)).not.toContain( png.toString("base64"), ); diff --git a/templates/design/actions/take-design-screenshot.ts b/templates/design/actions/take-design-screenshot.ts index d7ec0e22574..7568ae19479 100644 --- a/templates/design/actions/take-design-screenshot.ts +++ b/templates/design/actions/take-design-screenshot.ts @@ -1,4 +1,5 @@ import { defineAction, fail } from "@agent-native/core/action"; +import { MAX_TOOL_RESULT_IMAGES } from "@agent-native/core/agent/tool-result-images"; import { uploadFile } from "@agent-native/core/file-upload"; import { getRequestUserEmail } from "@agent-native/core/server/request-context"; import { accessFilter } from "@agent-native/core/sharing"; @@ -7,6 +8,11 @@ import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { isAllowedFigmaSvgRenderRequest } from "../server/lib/design-to-figma-svg.js"; +import { + createHubSpotEmailPreview, + sampleContactValuesSchema, +} from "../server/lib/hubspot-email-preview.js"; +import { prepareScreenshotPage } from "../server/lib/screenshot-readiness.js"; import { readLiveSourceFile } from "../server/source-workspace.js"; import "../server/db/index.js"; @@ -407,48 +413,81 @@ export default defineAction({ "the diagnostics are actionable immediately, and screenshots are saved " + "at durable URLs for review. Requires a headless Chromium binary; in hosted/serverless " + "deploys where one isn't available, returns `{ ok: false, reason }` " + - "instead of throwing — fall back to run-design-audit in that case.", - schema: z.object({ - designId: z - .string() - .optional() - .describe( - "Design project id. Required unless fileId is provided. Combined with filename to resolve the screen.", - ), - fileId: z - .string() - .optional() - .describe( - "Specific design_files.id to screenshot. Takes priority over designId/filename.", - ), - filename: z - .string() - .optional() - .default("index.html") - .describe( - "Filename to screenshot when fileId is not provided. Defaults to index.html.", - ), - widths: z - .array(z.number().int().min(200).max(3840)) - .optional() - .describe( - "Viewport widths in px to render. Defaults to [1280, 375] (desktop + mobile). " + - "Height is derived per width using standard device aspect ratios.", - ), - heights: z - .array(z.number().int().min(200).max(4096)) - .optional() - .describe( - "Exact viewport heights in px, matched index-for-index against `widths`. " + - "Use when the caller needs the screenshot's aspect ratio to match a " + - "known on-screen rect exactly (e.g. compositing an overlay on top) " + - "instead of the device-heuristic default. Omit an index to fall back " + - "to the standard derived height for that width.", - ), - }), + "instead of throwing — fall back to run-design-audit in that case. Agent/MCP calls include screenshot image attachments for visual inspection. Prefer one call with widths [600,375] for the HubSpot desktop/mobile completion pass. Optional templatePreview=hubspot-email renders documented tokens with fake sample values/contact defaults for browser-layout checks only, not HubSpot compilation or inbox proof; source and HTML export stay unchanged.", + schema: z + .object({ + designId: z + .string() + .optional() + .describe( + "Design project id. Required unless fileId is provided. Combined with filename to resolve the screen.", + ), + fileId: z + .string() + .optional() + .describe( + "Specific design_files.id to screenshot. Takes priority over designId/filename.", + ), + filename: z + .string() + .optional() + .default("index.html") + .describe( + "Filename to screenshot when fileId is not provided. Defaults to index.html.", + ), + templatePreview: z + .literal("hubspot-email") + .optional() + .describe( + "Optional hubspot-email sample browser preview; substitutes supported template tokens in a temporary HTML copy and reports unresolved tokens. Omit for unchanged raw HTML rendering.", + ), + sampleContactValues: sampleContactValuesSchema + .optional() + .describe( + "Optional in-memory recipient samples for templatePreview=hubspot-email only: an array of at most 8 unique {field, value} entries, with contact.field names and values of at most 500 characters. For example, [{field: 'contact.firstname', value: 'Alex'}]. Fields must match supported personalization_token calls in this frame; unknown or unused fields fail. Omit a field to use its literal default. No source or QA frame is saved.", + ), + widths: z + .array(z.number().int().min(200).max(3840)) + .optional() + .describe( + "Viewport widths in px to render. Defaults to [1280, 375] (desktop + mobile). " + + "Height is derived per width using standard device aspect ratios.", + ), + heights: z + .array(z.number().int().min(200).max(4096)) + .optional() + .describe( + "Exact viewport heights in px, matched index-for-index against `widths`. " + + "Use when the caller needs the screenshot's aspect ratio to match a " + + "known on-screen rect exactly (e.g. compositing an overlay on top) " + + "instead of the device-heuristic default. Omit an index to fall back " + + "to the standard derived height for that width.", + ), + }) + .refine( + ({ templatePreview, sampleContactValues }) => + sampleContactValues === undefined || + templatePreview === "hubspot-email", + { + message: + 'sampleContactValues requires templatePreview="hubspot-email".', + path: ["sampleContactValues"], + }, + ), readOnly: true, http: { method: "POST" }, - run: async ({ designId, fileId, filename, widths, heights }, ctx) => { + run: async ( + { + designId, + fileId, + filename, + widths, + heights, + templatePreview, + sampleContactValues, + }, + ctx, + ) => { if (!designId && !fileId) { throw new Error("designId or fileId is required."); } @@ -498,6 +537,11 @@ export default defineAction({ } const html = await liveContent(file.id, file.content ?? ""); + const emailPreview = + templatePreview === "hubspot-email" + ? createHubSpotEmailPreview(html, sampleContactValues) + : undefined; + const renderHtml = emailPreview?.html ?? html; const native = parseEffectsFromHtml(html); if ( native.errors.length || @@ -509,19 +553,68 @@ export default defineAction({ }); } + const captureTimings: Array<{ + stage: string; + durationMs: number; + viewportWidth?: number; + status: "completed" | "failed"; + error?: { name: string; message: string }; + }> = []; + const measure = async ( + stage: string, + work: () => Promise, + viewportWidth?: number, + ): Promise => { + const startedAt = Date.now(); + console.info("[design-screenshot] stage started", { + stage, + designId: file.designId, + fileId: file.id, + ...(viewportWidth === undefined ? {} : { viewportWidth }), + }); + let status: "completed" | "failed" = "failed"; + let failure: { name: string; message: string } | undefined; + try { + const result = await work(); + status = "completed"; + return result; + } catch (error) { + failure = { + name: error instanceof Error ? error.name : "Error", + message: error instanceof Error ? error.message : String(error), + }; + throw error; + } finally { + captureTimings.push({ + stage, + durationMs: Date.now() - startedAt, + ...(viewportWidth === undefined ? {} : { viewportWidth }), + status, + ...(failure ? { error: failure } : {}), + }); + if (status === "failed") + console.error("[design-screenshot] stage failed", { + designId: file.designId, + fileId: file.id, + captureTimings, + }); + } + }; let playwright: PlaywrightModule; try { - playwright = await importPlaywright(); + playwright = await measure("load-renderer", () => importPlaywright()); } catch (err) { - return { ok: false, reason: chromiumUnavailableReason(err) }; + return { ok: false as const, reason: chromiumUnavailableReason(err) }; } const viewports = resolveViewports(widths, heights); let browser: import("@playwright/test").Browser | undefined; try { - browser = await launchChromium(playwright.chromium); + browser = await measure("allocate-browser", () => + launchChromium(playwright.chromium), + ); } catch (err) { - return { ok: false, reason: chromiumUnavailableReason(err) }; + return { ok: false as const, reason: chromiumUnavailableReason(err) }; } const ownerEmail = getRequestUserEmail() ?? undefined; @@ -530,22 +623,33 @@ export default defineAction({ try { for (const viewport of viewports) { if (ctx?.signal?.aborted) break; - const context = await browser.newContext({ - viewport: { width: viewport.widthPx, height: viewport.heightPx }, - deviceScaleFactor: 2, - }); - await context.addInitScript( - "globalThis.__name = globalThis.__name || function (value) { return value; };", + const context = await measure( + "setup-context", + () => + browser!.newContext({ + viewport: { width: viewport.widthPx, height: viewport.heightPx }, + deviceScaleFactor: 2, + }), + viewport.widthPx, + ); + const page = await measure( + "setup-page", + async () => { + await context.addInitScript( + "globalThis.__name = globalThis.__name || function (value) { return value; };", + ); + await context.route("**/*", async (route) => { + if (await isAllowedFigmaSvgRenderRequest(route.request().url())) { + await route.continue(); + } else { + await route.abort("blockedbyclient"); + } + }); + await context.routeWebSocket("**/*", () => {}); + return context.newPage(); + }, + viewport.widthPx, ); - await context.route("**/*", async (route) => { - if (await isAllowedFigmaSvgRenderRequest(route.request().url())) { - await route.continue(); - } else { - await route.abort("blockedbyclient"); - } - }); - await context.routeWebSocket("**/*", () => {}); - const page = await context.newPage(); const consoleErrors: string[] = []; const fontLoadFailures: string[] = []; page.on("console", (msg) => { @@ -568,64 +672,38 @@ export default defineAction({ }); try { - await page.setContent(degradeWideColorsInHtml(html), { - waitUntil: "networkidle", - }); - await page - .evaluate(async () => { - const fontsReady = document.fonts?.ready; - if (!fontsReady) return; - await Promise.race([ - fontsReady, - new Promise((resolve) => setTimeout(resolve, 4_000)), - ]); - }) - .catch(() => {}); - await page - .waitForFunction( - () => { - const win = window as unknown as { - __anExportRuleCounts?: number[]; - __anExportRuleStart?: number; - }; - win.__anExportRuleStart ??= Date.now(); - const count = Array.from(document.styleSheets).reduce( - (sum, sheet) => { - try { - return sum + (sheet.cssRules?.length ?? 0); - } catch { - return sum + 1; - } - }, - 0, - ); - const history = [ - ...(win.__anExportRuleCounts ?? []).slice(-5), - count, - ]; - win.__anExportRuleCounts = history; - return ( - history.length >= 6 && - history.every((value) => value === history[0]) && - Date.now() - win.__anExportRuleStart >= 600 - ); - }, - { timeout: 2500, polling: 100 }, - ) - .catch(() => {}); - - const pageDiagnostics = await page.evaluate(collectPageDiagnostics); - const png = await page.screenshot({ type: "png", fullPage: true }); + await measure( + "ready", + () => + prepareScreenshotPage(page, degradeWideColorsInHtml(renderHtml)), + viewport.widthPx, + ); + const pageDiagnostics = await measure( + "diagnostics", + () => page.evaluate(collectPageDiagnostics), + viewport.widthPx, + ); + const png = await measure( + "capture", + () => + page.screenshot({ type: "png", fullPage: true, timeout: 30_000 }), + viewport.widthPx, + ); let uploaded: Awaited> | null = null; let uploadError: ScreenshotUploadError | undefined; try { - uploaded = await uploadFile({ - data: png, - mimeType: "image/png", - filename: `design-${file.designId}-${file.filename}-${viewport.label}.png`, - ownerEmail, - }); + uploaded = await measure( + "upload", + () => + uploadFile({ + data: png, + mimeType: "image/png", + filename: `design-${file.designId}-${file.filename}-${viewport.label}.png`, + ownerEmail, + }), + viewport.widthPx, + ); } catch { uploadError = { code: "file_upload_failed", @@ -663,11 +741,20 @@ export default defineAction({ screenshotPngs.set(screenshot, png); } } finally { - await context.close().catch(() => {}); + await measure( + "cleanup-context", + () => context.close(), + viewport.widthPx, + ).catch(() => {}); } } } finally { - await browser.close().catch(() => {}); + await measure("cleanup-browser", () => browser!.close()).catch(() => {}); + console.info("[design-screenshot] stage timings", { + designId: file.designId, + fileId: file.id, + captureTimings, + }); } const totalIssues = screenshots.reduce((sum, s) => { @@ -685,11 +772,25 @@ export default defineAction({ }, 0); return { - ok: true, + ok: true as const, designId: file.designId, fileId: file.id, filename: file.filename, capturedAt: new Date().toISOString(), + captureTimings, + ...(emailPreview ? { preview: emailPreview.preview } : {}), + ...((ctx?.caller === "tool" || ctx?.caller === "mcp") && + screenshots.some((screenshot) => screenshot.url.startsWith("https://")) + ? { + _agentImages: screenshots + .filter((screenshot) => screenshot.url.startsWith("https://")) + .slice(0, MAX_TOOL_RESULT_IMAGES) + .map((screenshot) => ({ + url: screenshot.url, + label: `${file.filename} (${screenshot.viewport.widthPx}px)`, + })), + } + : {}), screenshots, summary: { viewportsRendered: screenshots.length, diff --git a/templates/design/actions/update-file.ts b/templates/design/actions/update-file.ts index 762a7f30298..b2f79109e99 100644 --- a/templates/design/actions/update-file.ts +++ b/templates/design/actions/update-file.ts @@ -29,6 +29,8 @@ import { } from "../server/source-workspace.js"; import { designOutputWriteReceipt } from "../shared/generation-output-receipt.js"; import { assertDesignHtmlEditIntegrity } from "../shared/html-integrity.js"; +import { assertHubSpotEmailFrameContent } from "../shared/hubspot-email-authoring.js"; +import { isHubSpotEmailHtml } from "../shared/hubspot-email.js"; import { assertLockedLayersPreserved } from "../shared/locked-layers.js"; import { applySourceContentPatch, @@ -441,6 +443,13 @@ export default defineAction({ contentPatch && expectedVersionHash ? contentFromPatch(liveContent, expectedVersionHash, contentPatch) : requestedContent; + assertHubSpotEmailFrameContent({ + content: content ?? liveContent, + fileType: fileType ?? persistedFile.fileType ?? "html", + previousContent: isHubSpotEmailHtml(liveContent) + ? liveContent + : persistedFile.content, + }); if (content !== undefined) { assertDesignHtmlEditIntegrity({ previousContent: liveContent, diff --git a/templates/design/actions/view-screen.test.ts b/templates/design/actions/view-screen.test.ts index b32e640a353..e95d991ce9f 100644 --- a/templates/design/actions/view-screen.test.ts +++ b/templates/design/actions/view-screen.test.ts @@ -6,10 +6,15 @@ const mocks = vi.hoisted(() => { where: vi.fn(), }; selectChain.from.mockReturnValue(selectChain); + const contentSelectChain = { from: vi.fn(), where: vi.fn(), limit: vi.fn() }; + contentSelectChain.from.mockReturnValue(contentSelectChain); + contentSelectChain.where.mockReturnValue(contentSelectChain); return { getDb: vi.fn(() => ({ - select: vi.fn(() => selectChain), + select: vi.fn((columns) => + columns?.content ? contentSelectChain : selectChain, + ), })), parseCanvasFrameGeometryById: vi.fn((value) => value ?? []), listAppState: vi.fn(), @@ -21,6 +26,7 @@ const mocks = vi.hoisted(() => { queryReviewComments: vi.fn(), eq: vi.fn((left, right) => ({ left, right })), selectChain, + contentSelectChain, getDesignSystemRun: vi.fn(async ({ id }: { id: string }) => ({ id, title: "Acme", @@ -80,6 +86,7 @@ vi.mock("../server/db/index.js", () => ({ fileType: "designFiles.fileType", updatedAt: "designFiles.updatedAt", designId: "designFiles.designId", + content: "designFiles.content", }, }, })); @@ -104,6 +111,7 @@ describe("view-screen", () => { mocks.getReviewThreadSummary.mockReset(); mocks.queryReviewComments.mockReset(); mocks.selectChain.where.mockReset(); + mocks.contentSelectChain.limit.mockResolvedValue([]); mocks.resolveAccess.mockResolvedValue({ role: "viewer", resource: { @@ -353,6 +361,12 @@ describe("view-screen", () => { }); it("uses active file before overview multi-selection", async () => { + mocks.contentSelectChain.limit.mockResolvedValue([ + { + content: + 'Email', + }, + ]); mocks.readAppStateForCurrentTab .mockResolvedValueOnce({ view: "editor", @@ -385,6 +399,7 @@ describe("view-screen", () => { expect(result.design.activeScreen).toMatchObject({ id: "file_index", filename: "index.html", + frameType: "hubspot-email", }); }); diff --git a/templates/design/actions/view-screen.ts b/templates/design/actions/view-screen.ts index 2a64d018c47..ac483a4bcab 100644 --- a/templates/design/actions/view-screen.ts +++ b/templates/design/actions/view-screen.ts @@ -25,6 +25,7 @@ import { parseCanvasFrameGeometryById } from "../shared/canvas-frames.js"; import { isOverviewScreenFile } from "../shared/design-files.js"; import { getDesignTemplatePreset } from "../shared/design-template-presets.js"; import { designGenerationSessionKey } from "../shared/generation-session.js"; +import { isHubSpotEmailHtml } from "../shared/hubspot-email.js"; import { DESIGN_REPROMPT_PENDING_STATE_PREFIX, DESIGN_REPROMPT_PROPOSAL_STATE_PREFIX, @@ -340,6 +341,18 @@ export default defineAction({ navigation, designSelection, ); + let activeFrameType: "hubspot-email" | null = null; + if (activeScreen) { + const [activeFile] = await db + .select({ content: schema.designFiles.content }) + .from(schema.designFiles) + .where(eq(schema.designFiles.id, activeScreen.id)) + .limit(1); + activeFrameType = + activeFile && isHubSpotEmailHtml(activeFile.content) + ? "hubspot-email" + : null; + } const linkedDesignSystem = await loadAgentDesignSystemContext( typeof (access.resource as { designSystemId?: unknown }) .designSystemId === "string" @@ -360,7 +373,9 @@ export default defineAction({ : null, designSystem: linkedDesignSystem, screens: overviewScreens, - activeScreen, + activeScreen: activeScreen + ? { ...activeScreen, frameType: activeFrameType } + : null, activeCodeFile: resolveActiveCodeFile(files, designSelection), canvasFrames: parseCanvasFrameGeometryById(data.canvasFrames), }; diff --git a/templates/design/app/components/design/BreakpointBar.tsx b/templates/design/app/components/design/BreakpointBar.tsx index 31b47cabd26..972638e701d 100644 --- a/templates/design/app/components/design/BreakpointBar.tsx +++ b/templates/design/app/components/design/BreakpointBar.tsx @@ -53,6 +53,7 @@ export function extraBreakpointWidthPresets( const out: Array<{ name: string; widthPx: number }> = []; for (const category of FRAME_SIZE_PRESET_CATEGORIES) { for (const preset of category.presets) { + if (preset.frameType) continue; if (taken.has(preset.width) || seen.has(preset.width)) continue; if (preset.width < 320 || preset.width > 3840) continue; seen.add(preset.width); diff --git a/templates/design/app/components/design/DesignCanvas.tsx b/templates/design/app/components/design/DesignCanvas.tsx index be4beb3a759..3636fcc2008 100644 --- a/templates/design/app/components/design/DesignCanvas.tsx +++ b/templates/design/app/components/design/DesignCanvas.tsx @@ -726,6 +726,7 @@ interface DesignCanvasProps { boardSurface?: boolean; nativeAnimationEpochMs?: number; fitRootBodyToFrame?: boolean; + screenHeightMode?: "auto" | "fixed" | "hug"; runtimeReplacementContent?: string; authoredSourceContent?: string; runtimeReplacementKey?: string; @@ -1510,6 +1511,7 @@ export function DesignCanvas({ boardSurface = false, nativeAnimationEpochMs, fitRootBodyToFrame, + screenHeightMode, runtimeReplacementContent, authoredSourceContent, runtimeReplacementKey, @@ -3633,6 +3635,7 @@ export function DesignCanvas({ contentOffsetX: embeddedFrame?.contentOffsetX ?? 0, contentOffsetY: embeddedFrame?.contentOffsetY ?? 0, fitBodyToFrame: fitRootBodyToFrame ?? !boardSurface, + heightMode: screenHeightMode, }); return inlineDesignStylesheets(interactDocument, designStylesheets); } @@ -3720,7 +3723,10 @@ export function DesignCanvas({ } frameDocument = inlineDesignStylesheets(frameDocument, designStylesheets); if (isEmbeddedFrame) { - frameDocument = appendContentSizeReporter(frameDocument); + frameDocument = appendContentSizeReporter( + frameDocument, + screenHeightMode, + ); } return injectSessionReplayIframeBootstrap(frameDocument); // editorChromeScaleX/Y are intentionally NOT deps: they only seed the initial @@ -3734,6 +3740,7 @@ export function DesignCanvas({ boardSurface, designStylesheets, fitRootBodyToFrame, + screenHeightMode, rawExternalPreviewUrl, isEmbeddedFrame, embeddedFrameBackground, diff --git a/templates/design/app/components/design/EditPanel.tsx b/templates/design/app/components/design/EditPanel.tsx index 3776cf0e8d6..76e04e77193 100644 --- a/templates/design/app/components/design/EditPanel.tsx +++ b/templates/design/app/components/design/EditPanel.tsx @@ -505,11 +505,7 @@ interface EditPanelProps { aiActions?: ReactNode; activeTool?: string; scaleToolControls?: ScaleToolControls; - onCreateScreenFromPreset?: (preset: { - name: string; - width: number; - height: number; - }) => void; + onCreateScreenFromPreset?: (preset: FrameSizePreset) => void; onDisableAutoLayout?: (nodeId: string) => void; onApplyLayoutFlow?: ApplyLayoutFlowHandler; onAlignSelection?: ( @@ -1077,6 +1073,7 @@ function ScreenSizePresetPicker({
{ onPick(preset); setOpen(false); diff --git a/templates/design/app/components/design/MultiScreenCanvas.tsx b/templates/design/app/components/design/MultiScreenCanvas.tsx index a5cb53c642f..b9508956e71 100644 --- a/templates/design/app/components/design/MultiScreenCanvas.tsx +++ b/templates/design/app/components/design/MultiScreenCanvas.tsx @@ -134,7 +134,7 @@ import { import { CanvasRulers, type CanvasRulersHandle } from "./CanvasRulers"; import { CONTENT_SIZE_REPORT_MESSAGE_TYPE, - CONTENT_SIZE_REPORT_BRIDGE, + contentSizeReportBridge, resolveStableContentSizeSample, type ContentSizeSample, } from "./design-canvas/content-size-report"; @@ -14346,11 +14346,11 @@ const Screen = memo(function Screen({ }), STATIC_PREVIEW_TWEAK_BRIDGE + hitTestResponderMarkup(screen.content) + - CONTENT_SIZE_REPORT_BRIDGE, + contentSizeReportBridge(metadata.heightMode), ), ); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fitBodyToFrame, screen.content, staticSrcdocNeeded]); + }, [fitBodyToFrame, metadata.heightMode, screen.content, staticSrcdocNeeded]); const privateScreenshotPreview = useMemo( () => preparePrivateReplayScreenshotPreviewDocument(srcdocWithHitTest, { diff --git a/templates/design/app/components/design/design-canvas/content-size-report.integration.spec.ts b/templates/design/app/components/design/design-canvas/content-size-report.integration.spec.ts new file mode 100644 index 00000000000..64f268577ce --- /dev/null +++ b/templates/design/app/components/design/design-canvas/content-size-report.integration.spec.ts @@ -0,0 +1,90 @@ +import { chromium, type Browser } from "playwright"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { resolveAutoFitScreenHeight } from "../multi-screen/screen-height"; +import { appendContentSizeReporter } from "./content-size-report"; +import { buildInteractPreviewDocument } from "./interact-document"; + +let browser: Browser; +beforeAll(async () => { + browser = await chromium.launch({ chromiumSandbox: true }); +}); +afterAll(async () => { + await browser?.close(); +}); + +const SOURCE = + '
Content
Footer
'; + +describe("resolved frame height in Chromium", () => { + it.each(["editor", "interact"] as const)( + "reports natural height for unmarked hug HTML in %s while fixed geometry remains unchanged", + async (surface) => { + const context = await browser.newContext(); + try { + const page = await context.newPage(); + for (const mode of ["hug", "fixed"] as const) { + const html = + surface === "editor" + ? appendContentSizeReporter(SOURCE, mode) + : buildInteractPreviewDocument({ + content: SOURCE, + fitBodyToFrame: mode !== "hug", + heightMode: mode, + }); + await page.setContent( + '
', + ); + await page.evaluate((content) => { + const reports: Array<{ naturalHeight: number | null }> = []; + (window as unknown as { reports: typeof reports }).reports = + reports; + window.addEventListener("message", (event) => { + if ( + event.source === + document.querySelector("iframe")?.contentWindow && + event.data?.type === "agent-native:content-size" + ) + reports.push(event.data); + }); + document.querySelector("iframe")!.srcdoc = content; + }, html); + if (surface === "interact" && mode === "fixed") { + expect(html).not.toContain("data-agent-native-content-size-bridge"); + continue; + } + await page.waitForFunction( + () => + (window as unknown as { reports: unknown[] }).reports.length > 0, + undefined, + { timeout: 2000 }, + ); + const report = await page.evaluate(() => { + const reports = ( + window as unknown as { + reports: Array<{ naturalHeight: number | null }>; + } + ).reports; + return reports[reports.length - 1]; + }); + expect(report.naturalHeight).toBe(mode === "hug" ? 1080 : null); + const frameHeight = resolveAutoFitScreenHeight({ + mode, + width: 600, + currentHeight: 800, + measuredHeight: report.naturalHeight ?? 1080, + }); + expect(frameHeight).toBe(mode === "hug" ? 1080 : 800); + expect( + await page + .frameLocator("iframe") + .locator("meta[data-agent-native-screen-height-mode]") + .count(), + ).toBe(0); + } + } finally { + await context.close(); + } + }, + ); +}); diff --git a/templates/design/app/components/design/design-canvas/content-size-report.ts b/templates/design/app/components/design/design-canvas/content-size-report.ts index d92f2bf6bd0..763d1714a05 100644 --- a/templates/design/app/components/design/design-canvas/content-size-report.ts +++ b/templates/design/app/components/design/design-canvas/content-size-report.ts @@ -1,6 +1,9 @@ import { injectDocumentMarkup } from "@agent-native/core/shared"; -export const CONTENT_SIZE_REPORT_BRIDGE = ` +import type { ScreenHeightMode } from "../multi-screen/screen-height"; + +export function contentSizeReportBridge(heightMode?: ScreenHeightMode): string { + return ` ", `${css}`); +} + +function findElement( + html: string, + id: string, +): DefaultTreeAdapterTypes.Element { + function find( + node: DefaultTreeAdapterTypes.Node, + ): DefaultTreeAdapterTypes.Element | undefined { + if ( + "tagName" in node && + node.attrs.some((attr) => attr.name === "id" && attr.value === id) + ) + return node; + if ("childNodes" in node) { + for (const child of node.childNodes) { + const found = find(child); + if (found) return found; + } + } + } + const element = find(parse(html)); + expect(element).toBeDefined(); + return element!; +} + +describe("HubSpot email HTML export", () => { + beforeEach(() => { + safeFetch.mockReset(); + safeFetch.mockResolvedValue( + new Response(null, { headers: { "content-type": "image/png" } }), + ); + }); + + it("exports unmarked email HTML with the selector cascade, inline precedence, responsive rules, and literal HubL", async () => { + const result = await exportHubSpotEmailHtml( + withContent( + '{{ personalization_token(\'contact.lastname\', \'friend\') }}', + "span {font-size:12px;color:black} .copy {font-size:14px;color:green!important} p > span.copy {font-size:16px} #greeting {color:red!important} @media (max-width:400px) { .copy {font-size:20px!important} }", + ), + ); + const style = findElement(result.html, "greeting").attrs.find( + (attr) => attr.name === "style", + )!.value; + expect(style).toBe( + "font-size:12px;color:black;font-size:14px;color:green!important;font-size:16px;color:red!important;font-size:18px;color:navy", + ); + const contentParagraph = findElement(result.html, "greeting") + .parentNode as DefaultTreeAdapterTypes.Element; + const contentCell = + contentParagraph.parentNode as DefaultTreeAdapterTypes.Element; + expect(contentCell.tagName).toBe("td"); + expect( + contentCell.attrs.find((attr) => attr.name === "style")?.value, + ).toContain("font-family: Arial, sans-serif;"); + expect(result.html).toContain("@media (max-width:400px)"); + expect(result.html).not.toContain("p > span.copy"); + expect(result.html).toContain( + "{{ personalization_token('contact.lastname', 'friend') }}", + ); + expect(result.html).toContain('href="{{ unsubscribe_link }}"'); + expect(result.html).toContain( + "{{ site_settings.company_street_address_1 }}", + ); + expect(result.imageCount).toBe(0); + expect(safeFetch).not.toHaveBeenCalled(); + }); + + it("checks deduplicated HTTPS images through the SSRF-safe fetcher without forwarding credentials", async () => { + const result = await exportHubSpotEmailHtml( + withContent( + 'Announcement', + ), + ); + expect(result.imageCount).toBe(2); + expect(safeFetch).toHaveBeenCalledTimes(1); + expect(safeFetch).toHaveBeenCalledWith( + "https://images.example.com/banner.png", + expect.objectContaining({ + method: "GET", + signal: expect.any(AbortSignal), + headers: { Accept: "image/png,image/jpeg,image/gif" }, + }), + { httpsOnly: true, requireDispatcher: true, maxRedirects: 3 }, + ); + }); + + it.each([ + ["script", "", "Remove ', + ), + ).toThrow("Remove ", + ); + expect(result.preview.substitutedTokens).toEqual([]); + expect(result.preview.unresolvedTokens).toHaveLength(2); + expect(result.html).toContain("{{ site_settings.company_name }}"); + }); + + it("uses recipient samples in text and URL attributes while retaining omitted defaults", () => { + const source = `

{{ personalization_token('contact.firstname', 'there') }}

Open`; + expect(text(parse(createHubSpotEmailPreview(source).html))).toBe( + "thereOpen", + ); + for (const name of [ + "Alex", + "A".repeat(500), + 'Alex & "Guest"', + ]) { + const result = createHubSpotEmailPreview(source, [ + { field: "contact.firstname", value: name }, + { + field: "contact.website", + value: 'https://example.com/sample?a=1&b=" onmouseover="alert(1)', + }, + ]); + expect(text(parse(result.html))).toBe(`${name}Open`); + expect(result.html).toContain("" onmouseover="alert(1)"); + expect(result.preview.usedContactFields).toEqual([ + "contact.firstname", + "contact.website", + ]); + expect(result.preview.unresolvedTokens).toEqual([]); + } + }); + + it.each( + [ + [{ field: "contact.unknown", value: "Alex" }], + [{ field: "company.name", value: "Example" }], + [{ field: "contact.firstname", value: "A".repeat(501) }], + Array.from({ length: 9 }, (_, i) => ({ + field: `contact.field${i}`, + value: "Example", + })), + [ + { field: "contact.firstname", value: "Alex" }, + { field: "contact.firstname", value: "Other" }, + ], + ].map((samples) => [samples]), + )( + "rejects unused, duplicate or invalid recipient samples (%s)", + (samples) => { + expect(() => + createHubSpotEmailPreview( + "

{{ personalization_token('contact.firstname', 'there') }}

", + samples, + ), + ).toThrowError( + expect.objectContaining({ + errorCode: "invalid_hubspot_preview_samples", + }), + ); + }, + ); +}); diff --git a/templates/design/server/lib/hubspot-email-preview.ts b/templates/design/server/lib/hubspot-email-preview.ts new file mode 100644 index 00000000000..337b07e65ea --- /dev/null +++ b/templates/design/server/lib/hubspot-email-preview.ts @@ -0,0 +1,153 @@ +import { fail } from "@agent-native/core/action"; +import { parse, serialize, type DefaultTreeAdapterTypes } from "parse5"; +import { z } from "zod"; + +import { parseHubLInterpolation } from "./hubl-interpolation.js"; + +export const sampleContactValuesSchema = z + .array( + z.object({ + field: z.string().regex(/^contact\.\w+(?:\.\w+)*$/), + value: z.string().max(500), + }), + ) + .max(8) + .refine( + (values) => + new Set(values.map(({ field }) => field)).size === values.length, + "Sample contact fields must be unique.", + ); + +export type SampleContactValues = z.infer; + +const SAMPLE_VALUES: Record = { + "site_settings.company_name": "Example Company", + "site_settings.company_street_address_1": "123 Example Street", + "site_settings.company_city": "Example City", + "site_settings.company_state": "EX", + unsubscribe_link: "https://example.invalid/preferences", + unsubscribe_link_all: "https://example.invalid/unsubscribe-all", + unsubscribe_link_single: "https://example.invalid/unsubscribe", + view_as_page_url: "https://example.invalid/email-preview", + subscription_confirmation_url: "https://example.invalid/confirmation", + "content.absolute_url": "https://example.invalid/email-preview", +}; +function previewValue( + token: string, + samples: Record, + used: Set, +): string | undefined { + const parsed = parseHubLInterpolation(token); + if (!parsed) return undefined; + if (Object.prototype.hasOwnProperty.call(SAMPLE_VALUES, parsed.name)) + return SAMPLE_VALUES[parsed.name]; + if (!/^contact\.[\w.]+$/.test(parsed.name)) return undefined; + if ( + parsed.personalization && + !parsed.filtered && + Object.prototype.hasOwnProperty.call(samples, parsed.name) + ) { + used.add(parsed.name); + return samples[parsed.name]; + } + return parsed.fallback; +} + +export function createHubSpotEmailPreview( + source: string, + sampleContactValues?: SampleContactValues, +): { + html: string; + preview: { + mode: "hubspot-email"; + sampleValues: true; + substitutedTokens: string[]; + unresolvedTokens: string[]; + usedContactFields?: string[]; + }; +} { + const validated = sampleContactValuesSchema.safeParse( + sampleContactValues ?? [], + ); + if (!validated.success) + fail( + "Recipient samples require at most 8 unique {field, value} entries with contact.field names and string values of at most 500 characters.", + { errorCode: "invalid_hubspot_preview_samples", statusCode: 422 }, + ); + const usedContactFields = new Set(); + const samples = Object.fromEntries( + validated.data.map(({ field, value }) => [field, value]), + ); + let prefix = "ANEMAILPREVIEW"; + while (source.includes(prefix)) prefix += "_"; + const tokens: string[] = []; + const protectedHtml = source.replace( + /{{[\s\S]*?}}|{%[\s\S]*?%}/g, + (token) => { + tokens.push(token); + return `${prefix}${tokens.length - 1}TOKEN`; + }, + ); + const substitutedTokens = new Set(); + const unresolvedTokens = new Set(); + const restore = (value: string, substitute: boolean): string => + value.replace( + new RegExp(`${prefix}(\\d+)TOKEN`, "g"), + (_, index: string) => { + const token = tokens[Number(index)]; + const sample = substitute + ? previewValue(token, samples, usedContactFields) + : undefined; + if (sample === undefined) { + unresolvedTokens.add(token); + return token; + } + substitutedTokens.add(token); + return sample; + }, + ); + const document = parse(protectedHtml); + const visit = ( + node: DefaultTreeAdapterTypes.Node, + substitute = true, + ): void => { + if ("tagName" in node) { + substitute = substitute && !["script", "style"].includes(node.tagName); + for (const attr of node.attrs) + attr.value = restore(attr.value, substitute); + } + if (node.nodeName === "#text") { + const text = node as DefaultTreeAdapterTypes.TextNode; + text.value = restore(text.value, substitute); + } + if (node.nodeName === "#comment") { + const comment = node as DefaultTreeAdapterTypes.CommentNode; + comment.data = restore(comment.data, false); + } + if ("childNodes" in node) + for (const child of node.childNodes) visit(child, substitute); + if ("tagName" in node && node.tagName === "template") + visit((node as DefaultTreeAdapterTypes.Template).content, substitute); + }; + visit(document); + const unused = Object.keys(samples).filter( + (field) => !usedContactFields.has(field), + ); + if (unused.length) + fail( + `Recipient sample fields have no supported personalization_token interpolation in rendered text or attributes: ${unused.join(", ")}.`, + { errorCode: "invalid_hubspot_preview_samples", statusCode: 422 }, + ); + return { + html: serialize(document), + preview: { + mode: "hubspot-email", + sampleValues: true, + substitutedTokens: [...substitutedTokens], + unresolvedTokens: [...unresolvedTokens], + ...(sampleContactValues === undefined + ? {} + : { usedContactFields: [...usedContactFields] }), + }, + }; +} diff --git a/templates/design/server/lib/playwright-runtime.spec.ts b/templates/design/server/lib/playwright-runtime.spec.ts index 3e375fa785c..d4413db3a5f 100644 --- a/templates/design/server/lib/playwright-runtime.spec.ts +++ b/templates/design/server/lib/playwright-runtime.spec.ts @@ -23,7 +23,7 @@ describe("importPlaywright", () => { const imported = { chromium: { connectOverCDP, - launch: vi.fn(), + launch: vi.fn().mockResolvedValue(browser), } as unknown as PlaywrightModule["chromium"], }; const attempts: string[] = []; @@ -40,17 +40,16 @@ describe("importPlaywright", () => { expect(result).toBe(imported); expect(attempts).toEqual(["playwright", "playwright-core"]); await expect(launchChromium(result.chromium)).resolves.toBe(browser); - expect(connectOverCDP).toHaveBeenCalledWith( - "wss://browser.example.test/cdp", - ); + expect(serverMocks.requestBuilderBrowserConnection).not.toHaveBeenCalled(); + expect(connectOverCDP).not.toHaveBeenCalled(); }); }); describe("launchChromium", () => { - it("uses the Builder Browser connection", async () => { + it("uses sandboxed local Chromium without requesting a remote browser when available", async () => { const browser = {}; const connectOverCDP = vi.fn().mockResolvedValue(browser); - const launch = vi.fn(); + const launch = vi.fn().mockResolvedValue(browser); const chromium = { connectOverCDP, launch, @@ -61,27 +60,36 @@ describe("launchChromium", () => { await expect(launchChromium(chromium)).resolves.toBe(browser); - expect(serverMocks.requestBuilderBrowserConnection).toHaveBeenCalledWith({ - sessionId: expect.stringMatching(/^design-render-/), - }); - expect(connectOverCDP).toHaveBeenCalledWith( - "wss://browser.example.test/cdp", - ); - expect(launch).not.toHaveBeenCalled(); + expect(launch).toHaveBeenCalledWith({ chromiumSandbox: true }); + expect(serverMocks.requestBuilderBrowserConnection).not.toHaveBeenCalled(); + expect(connectOverCDP).not.toHaveBeenCalled(); }); - it("falls back to sandboxed local Chromium outside CI", async () => { + it("uses a bounded Builder Browser connection when local Chromium is unavailable", async () => { const browser = {}; - const launch = vi.fn().mockResolvedValue(browser); + const launch = vi + .fn() + .mockRejectedValue(new Error("Executable doesn't exist")); + const connectOverCDP = vi.fn().mockResolvedValue(browser); const chromium = { - connectOverCDP: vi.fn(), + connectOverCDP, launch, } as unknown as PlaywrightModule["chromium"]; - serverMocks.requestBuilderBrowserConnection.mockRejectedValue( - new Error("Builder Browser unavailable"), - ); + serverMocks.requestBuilderBrowserConnection.mockResolvedValue({ + wsUrl: "wss://browser.example.test/cdp", + }); await expect(launchChromium(chromium)).resolves.toBe(browser); expect(launch).toHaveBeenCalledWith({ chromiumSandbox: true }); + expect(serverMocks.requestBuilderBrowserConnection).toHaveBeenCalledWith({ + sessionId: expect.stringMatching(/^design-render-/), + }); + expect(connectOverCDP).toHaveBeenCalledWith( + "wss://browser.example.test/cdp", + { timeout: 30_000 }, + ); + expect(launch.mock.invocationCallOrder[0]).toBeLessThan( + serverMocks.requestBuilderBrowserConnection.mock.invocationCallOrder[0], + ); }); it("fails closed with a typed error when no safe renderer is available", async () => { @@ -102,6 +110,11 @@ describe("launchChromium", () => { code: "chromium_unavailable", message: expect.stringContaining("Chromium unavailable:"), }); + await expect(launchPromise).rejects.toMatchObject({ + message: expect.stringContaining( + "Builder Browser unavailable: Builder Browser unavailable; sandboxed local Chromium unavailable: sandbox unavailable", + ), + }); expect( isMissingBrowserError( new ChromiumUnavailableError(new Error("connection unavailable")), diff --git a/templates/design/server/lib/playwright-runtime.ts b/templates/design/server/lib/playwright-runtime.ts index 805b80005a6..4a9ae34038b 100644 --- a/templates/design/server/lib/playwright-runtime.ts +++ b/templates/design/server/lib/playwright-runtime.ts @@ -61,22 +61,22 @@ async function connectBuilderBrowser( }); const wsUrl = typeof connection.wsUrl === "string" ? connection.wsUrl : ""; if (!wsUrl.trim()) throw new Error("Builder Browser did not return wsUrl."); - return chromium.connectOverCDP(wsUrl); + return chromium.connectOverCDP(wsUrl, { timeout: 30_000 }); } export async function launchChromium( chromium: import("@playwright/test").BrowserType, ): Promise { - let builderBrowserError: unknown; + let localBrowserError: unknown; try { - return await connectBuilderBrowser(chromium); + return await chromium.launch({ chromiumSandbox: true }); } catch (error) { - builderBrowserError = error; + localBrowserError = error; } try { - return await chromium.launch({ chromiumSandbox: true }); - } catch (localBrowserError) { + return await connectBuilderBrowser(chromium); + } catch (builderBrowserError) { const describe = (error: unknown) => error instanceof Error ? error.message : String(error); console.error( diff --git a/templates/design/server/lib/screenshot-readiness.integration.spec.ts b/templates/design/server/lib/screenshot-readiness.integration.spec.ts new file mode 100644 index 00000000000..0bf058f147b --- /dev/null +++ b/templates/design/server/lib/screenshot-readiness.integration.spec.ts @@ -0,0 +1,127 @@ +import { chromium, type Browser } from "playwright"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { createHubSpotEmailPreview } from "./hubspot-email-preview.js"; +import { prepareScreenshotPage } from "./screenshot-readiness.js"; + +let browser: Browser; +beforeAll(async () => { + browser = await chromium.launch({ chromiumSandbox: true }); +}); +afterAll(async () => { + await browser?.close(); +}); + +describe("screenshot readiness in Chromium", () => { + it("captures ready content while an unrelated network request remains open", async () => { + const context = await browser.newContext(); + try { + const page = await context.newPage(); + let pendingRequest = false; + await page.route("https://example.test/keep-open", () => { + pendingRequest = true; + }); + await prepareScreenshotPage( + page, + '

Ready email

', + ); + expect(pendingRequest).toBe(true); + expect(await page.locator("h1").textContent()).toBe("Ready email"); + const png = await page.screenshot({ timeout: 2_000 }); + expect(png.subarray(1, 4).toString()).toBe("PNG"); + } finally { + await context.close(); + } + }); + + it("removes literal token overflow in a 375px sample preview while preserving genuine fixed-width overflow", async () => { + const context = await browser.newContext({ + viewport: { width: 375, height: 812 }, + }); + try { + const page = await context.newPage(); + const source = + '
{{ site_settings.company_street_address_1 }}
'; + await prepareScreenshotPage(page, source); + expect( + await page.evaluate(() => document.documentElement.scrollWidth), + ).toBeGreaterThan(375); + const preview = createHubSpotEmailPreview(source); + await prepareScreenshotPage(page, preview.html); + expect( + await page.evaluate(() => document.documentElement.scrollWidth), + ).toBe(375); + const png = await page.screenshot({ timeout: 2_000 }); + expect(png.subarray(1, 4).toString()).toBe("PNG"); + await prepareScreenshotPage( + page, + createHubSpotEmailPreview(source.replace('width="100%"', 'width="600"')) + .html, + ); + expect( + await page.evaluate(() => document.documentElement.scrollWidth), + ).toBeGreaterThanOrEqual(600); + } finally { + await context.close(); + } + }); + + it("keeps a settled failed image inspectable in the rendered capture", async () => { + const context = await browser.newContext(); + try { + const page = await context.newPage(); + await page.route("https://example.test/missing.png", (route) => + route.abort(), + ); + await prepareScreenshotPage( + page, + 'Hero', + ); + const brokenImages = await page.evaluate(() => + Array.from(document.images) + .filter((image) => image.complete && image.naturalWidth === 0) + .map((image) => image.getAttribute("src")), + ); + expect(brokenImages).toEqual(["https://example.test/missing.png"]); + const png = await page.screenshot({ timeout: 2_000 }); + expect(png.subarray(1, 4).toString()).toBe("PNG"); + } finally { + await context.close(); + } + }); + + it("renders recipient samples and exposes real long-name overflow without modifying raw HTML", async () => { + const context = await browser.newContext({ + viewport: { width: 375, height: 812 }, + }); + try { + const page = await context.newPage(); + const source = `

{{ personalization_token('contact.firstname', 'there') }}

`; + for (const samples of [ + undefined, + [{ field: "contact.firstname", value: "Alex" }], + [{ field: "contact.firstname", value: "A".repeat(80) }], + ]) { + await prepareScreenshotPage( + page, + createHubSpotEmailPreview(source, samples).html, + ); + expect(await page.locator("p").textContent()).toBe( + samples?.[0].value ?? "there", + ); + const width = await page.evaluate( + () => document.documentElement.scrollWidth, + ); + if (samples?.[0].value.length === 80) + expect(width).toBeGreaterThan(375); + else expect(width).toBe(375); + } + await prepareScreenshotPage(page, source); + expect(await page.locator("p").textContent()).toContain( + "personalization_token('contact.firstname', 'there')", + ); + } finally { + await context.close(); + } + }); +}); diff --git a/templates/design/server/lib/screenshot-readiness.spec.ts b/templates/design/server/lib/screenshot-readiness.spec.ts new file mode 100644 index 00000000000..89f3efe5d7d --- /dev/null +++ b/templates/design/server/lib/screenshot-readiness.spec.ts @@ -0,0 +1,35 @@ +import type { Page } from "@playwright/test"; +import { describe, expect, it, vi } from "vitest"; + +import { prepareScreenshotPage } from "./screenshot-readiness.js"; + +function pageWithPendingImage(error: Error) { + return { + setContent: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue(["https://example.test/pending.png"]), + waitForFunction: vi.fn().mockRejectedValue(error), + } as unknown as Page; +} + +describe("screenshot readiness failure reporting", () => { + it("preserves a non-timeout browser failure instead of reporting an image timeout", async () => { + const failure = new Error("Execution context was destroyed"); + await expect( + prepareScreenshotPage(pageWithPendingImage(failure), ""), + ).rejects.toBe(failure); + }); + + it("reports actual pending images when image readiness times out", async () => { + const failure = new Error("Polling timed out"); + failure.name = "TimeoutError"; + await expect( + prepareScreenshotPage(pageWithPendingImage(failure), ""), + ).rejects.toMatchObject({ + errorCode: "screenshot_images_unready", + details: { + pendingImages: ["https://example.test/pending.png"], + cause: "Polling timed out", + }, + }); + }); +}); diff --git a/templates/design/server/lib/screenshot-readiness.ts b/templates/design/server/lib/screenshot-readiness.ts new file mode 100644 index 00000000000..25c9f0c8c2f --- /dev/null +++ b/templates/design/server/lib/screenshot-readiness.ts @@ -0,0 +1,91 @@ +import { fail } from "@agent-native/core/action"; +import type { Page } from "@playwright/test"; + +export async function prepareScreenshotPage( + page: Page, + html: string, +): Promise { + await page.setContent(html, { + waitUntil: "domcontentloaded", + timeout: 30_000, + }); + const pendingImages = () => + page.evaluate(() => + Array.from(document.images) + .filter((image) => !image.complete) + .map( + (image) => + image.currentSrc || + image.getAttribute("src") || + "(image without src)", + ), + ); + if ((await pendingImages()).length > 0) { + try { + await page.waitForFunction( + () => Array.from(document.images).every((image) => image.complete), + undefined, + { timeout: 5_000, polling: 100 }, + ); + } catch (error) { + if (!(error instanceof Error) || error.name !== "TimeoutError") + throw error; + const pending = await pendingImages(); + if (pending.length) + fail("Screenshot images did not finish loading within 5 seconds.", { + errorCode: "screenshot_images_unready", + statusCode: 422, + details: { pendingImages: pending, cause: error.message }, + }); + } + } + const fontsReady = await page.evaluate(async () => { + if (!document.fonts) return true; + return Promise.race([ + document.fonts.ready.then(() => true), + new Promise((resolve) => + setTimeout(() => resolve(false), 4_000), + ), + ]); + }); + if (!fontsReady) { + fail("Screenshot fonts did not finish loading within 4 seconds.", { + errorCode: "screenshot_fonts_unready", + statusCode: 422, + }); + } + try { + await page.waitForFunction( + () => { + const win = window as unknown as { + __anExportRuleCounts?: number[]; + __anExportRuleStart?: number; + }; + win.__anExportRuleStart ??= Date.now(); + const count = Array.from(document.styleSheets).reduce((sum, sheet) => { + try { + return sum + (sheet.cssRules?.length ?? 0); + } catch { + return sum + 1; + } + }, 0); + const history = [...(win.__anExportRuleCounts ?? []).slice(-5), count]; + win.__anExportRuleCounts = history; + return ( + history.length >= 6 && + history.every((value) => value === history[0]) && + Date.now() - win.__anExportRuleStart >= 600 + ); + }, + undefined, + { timeout: 2_500, polling: 100 }, + ); + } catch (error) { + if (!(error instanceof Error) || error.name !== "TimeoutError") throw error; + fail("Screenshot styles did not settle within 2.5 seconds.", { + errorCode: "screenshot_styles_unready", + statusCode: 422, + details: { cause: error.message }, + }); + } +} diff --git a/templates/design/server/plugins/agent-chat.ts b/templates/design/server/plugins/agent-chat.ts index 2aba7e73f88..d5babf46766 100644 --- a/templates/design/server/plugins/agent-chat.ts +++ b/templates/design/server/plugins/agent-chat.ts @@ -127,6 +127,8 @@ const INITIAL_TOOL_NAMES = [ "update-file", "rename-screen", "export-png", + "take-design-screenshot", + "export-hubspot-html", "navigate", ]; @@ -328,6 +330,8 @@ When a user message begins with [Selection question], answer about the captured When the user asks for a new design and the current navigation view is list, settings, design-systems, or otherwise has no designId, create a new design first. Do not reuse, delete screens from, or edit a previous design unless the user explicitly names that design or the current navigation state is an editor/present view with that designId. +For HubSpot email creation, alignment, token edits, conversion or HTML handoff, read hubspot-email; it owns the email workflow and replaces web defaults. + App and website UI is responsive by default when the user does not specify exact pixel dimensions. Use mobile-first CSS, a viewport meta tag, and responsive layout changes for narrow widths; never ship a fixed-width desktop shell. Static artwork such as ads, banners, social posts, thumbnails, email headers, flyers, and posters uses one fixed canvas with no mobile or tablet frames, including on follow-up edits and resizes; each additional size or version is its own exact-size call. Honor exact pixel dimensions and platform presets in the original request. Desktop is the default primary artboard for responsive UI; use a 1440×1024 canvas frame (or primaryViewport "desktop") unless the user explicitly asks for a mobile- or tablet-primary design. Keep each generation action prompt to one distinct exact size; make separate calls scoped to each screen when a request includes different sizes. For responsive UI, inspect desktop and mobile screenshots and correct overflow or broken reflow. For fixed artwork, inspect one screenshot at the requested canvas size. Treat explicit visual direction, requested content, named pages, and page counts as acceptance criteria. When the user asks for multiple distinct pages or states, call generate-screens with every requested page before generating their files; do not substitute responsive breakpoint frames for requested pages. Verify the saved design contains each requested page before reporting completion. diff --git a/templates/design/server/source-workspace.batch.spec.ts b/templates/design/server/source-workspace.batch.spec.ts index 37e2f694890..5828edafea7 100644 --- a/templates/design/server/source-workspace.batch.spec.ts +++ b/templates/design/server/source-workspace.batch.spec.ts @@ -40,6 +40,7 @@ import { import { closeDbExec, getDbExec } from "@agent-native/core/db"; import { eq, sql } from "drizzle-orm"; +import { createHubSpotEmailHtml } from "../shared/hubspot-email.js"; import { sourceContentHash } from "../shared/source-workspace.js"; import { getDb, schema } from "./db/index.js"; import { @@ -194,6 +195,39 @@ afterAll(async () => { }); describe("writeInlineSourceFilesBatch", () => { + it("rejects a batch that removes an email marker before changing any file", async () => { + const email = createHubSpotEmailHtml(); + await execute("UPDATE design_files SET content = ? WHERE id = ?", [ + email, + DESTINATION_ID, + ]); + await applyText(DESTINATION_ID, email); + const before = await persistedFiles(); + await expect( + writeInlineSourceFilesBatch({ + designId: DESIGN_ID, + files: [ + { + file: sourceFile(SOURCE_ID, "source.html", SOURCE_BASE), + content: SOURCE_NEXT, + expectedVersionHash: sourceContentHash(SOURCE_BASE), + }, + { + file: sourceFile(DESTINATION_ID, "destination.html", email), + content: email.replace( + '', + "", + ), + expectedVersionHash: sourceContentHash(email), + }, + ], + }), + ).rejects.toThrow("Preserve the HubSpot email frame marker"); + expect(await persistedFiles()).toEqual(before); + expect(await getText(SOURCE_ID)).toBe(SOURCE_BASE); + expect(await getText(DESTINATION_ID)).toBe(email); + }); + it("rolls back prepared collaboration state with the source transaction", async () => { await expect( withPreparedSourceFileMutation(SOURCE_ID, "agent", (lease) => diff --git a/templates/design/server/source-workspace.ts b/templates/design/server/source-workspace.ts index 082ab9568b1..5a2652f3166 100644 --- a/templates/design/server/source-workspace.ts +++ b/templates/design/server/source-workspace.ts @@ -18,6 +18,8 @@ import { assertDesignHtmlEditIntegrity, isDesignHtmlIntegrityError, } from "../shared/html-integrity.js"; +import { assertHubSpotEmailFrameContent } from "../shared/hubspot-email-authoring.js"; +import { isHubSpotEmailHtml } from "../shared/hubspot-email.js"; import { assertLockedLayersPreserved } from "../shared/locked-layers.js"; import { designSourceTypeFromData } from "../shared/source-mode.js"; import type { DesignSourceType } from "../shared/source-mode.js"; @@ -576,6 +578,14 @@ export async function writeInlineSourceFile(args: { content: liveContent, versionHash: sourceContentHash(liveContent), }; + const assertEmailCandidate = (content: string) => + assertHubSpotEmailFrameContent({ + content, + fileType: currentFile.fileType ?? args.file.fileType ?? "html", + previousContent: isHubSpotEmailHtml(current.content) + ? current.content + : (currentFile.content ?? ""), + }); const validatePreparedNoop = async () => { const preparedCollaboration = await lockPreparedSourceCollaboration( getDesignSourceMutationExec(tx), @@ -710,6 +720,7 @@ export async function writeInlineSourceFile(args: { } } + assertEmailCandidate(args.content); const changed = args.content !== current.content; const updatedAt = new Date().toISOString(); if (!changed && !identityOnly) { @@ -762,6 +773,7 @@ export async function writeInlineSourceFile(args: { const authoritativeContent = readPreparedSourceText(lease); try { + assertEmailCandidate(authoritativeContent); if (identityOnly && authoritativeContent !== args.content) { throw new SourceWorkspaceEditConflictError( "A concurrent source edit prevented identity-only publication.", @@ -1023,6 +1035,11 @@ export async function writeInlineSourceFilesBatch(args: { ); } assertLockedLayersPreserved(liveContent, content); + assertHubSpotEmailFrameContent({ + content, + fileType: currentFile.fileType ?? file.fileType ?? "html", + previousContent: liveContent, + }); assertDesignHtmlEditIntegrity({ previousContent: liveContent, nextContent: content, @@ -1100,6 +1117,11 @@ export async function writeInlineSourceFilesBatch(args: { ); } assertLockedLayersPreserved(liveContent, item.content); + assertHubSpotEmailFrameContent({ + content: item.content, + fileType: current.fileType ?? "html", + previousContent: liveContent, + }); assertDesignHtmlEditIntegrity({ previousContent: liveContent, nextContent: item.content, diff --git a/templates/design/shared/frame-size-presets.ts b/templates/design/shared/frame-size-presets.ts index 3a5e9574233..a77aef902c5 100644 --- a/templates/design/shared/frame-size-presets.ts +++ b/templates/design/shared/frame-size-presets.ts @@ -12,7 +12,10 @@ * untranslated in every locale. */ +import { HUBSPOT_EMAIL_FRAME_TYPE } from "./hubspot-email.js"; + export type FrameSizePresetCategoryKey = + | "email" | "phone" | "tablet" | "desktop" @@ -26,6 +29,8 @@ export interface FrameSizePreset { name: string; width: number; height: number; + frameType?: typeof HUBSPOT_EMAIL_FRAME_TYPE; + nameKey?: string; } export interface FrameSizePresetCategory { diff --git a/templates/design/shared/hubspot-email-authoring.spec.ts b/templates/design/shared/hubspot-email-authoring.spec.ts new file mode 100644 index 00000000000..7415effafb7 --- /dev/null +++ b/templates/design/shared/hubspot-email-authoring.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; + +import { assertHubSpotEmailFrameContent } from "./hubspot-email-authoring.js"; +import { createHubSpotEmailHtml } from "./hubspot-email.js"; + +describe("HubSpot email frame authoring contract", () => { + it("rejects changing a marked email into a non-HTML file", () => { + expect(() => + assertHubSpotEmailFrameContent({ + content: createHubSpotEmailHtml(), + fileType: "jsx", + }), + ).toThrow("must be HTML files"); + }); +}); diff --git a/templates/design/shared/hubspot-email-authoring.ts b/templates/design/shared/hubspot-email-authoring.ts new file mode 100644 index 00000000000..327e64c285c --- /dev/null +++ b/templates/design/shared/hubspot-email-authoring.ts @@ -0,0 +1,35 @@ +import { fail } from "@agent-native/core/action"; + +import { isHubSpotEmailHtml } from "./hubspot-email.js"; + +export function assertHubSpotEmailFrameContent({ + content, + fileType, + frameType, + previousContent, +}: { + content: string; + fileType: string; + frameType?: "hubspot-email"; + previousContent?: string; +}): void { + const marked = isHubSpotEmailHtml(content); + if (frameType === "hubspot-email" && !marked) { + fail( + 'A HubSpot email frame requires in its HTML head. Rebuild the content using the HubSpot email specification before saving.', + { statusCode: 422, errorCode: "hubspot_email_marker_required" }, + ); + } + if (previousContent && isHubSpotEmailHtml(previousContent) && !marked) { + fail("Preserve the HubSpot email frame marker when editing this frame.", { + statusCode: 422, + errorCode: "hubspot_email_marker_required", + }); + } + if (marked && fileType !== "html") { + fail("HubSpot email frames must be HTML files.", { + statusCode: 422, + errorCode: "hubspot_email_html_required", + }); + } +} diff --git a/templates/design/shared/hubspot-email.ts b/templates/design/shared/hubspot-email.ts new file mode 100644 index 00000000000..48d7d945f88 --- /dev/null +++ b/templates/design/shared/hubspot-email.ts @@ -0,0 +1,64 @@ +export const HUBSPOT_EMAIL_FRAME_TYPE = "hubspot-email"; + +export function isHubSpotEmailHtml(content: string): boolean { + return /]*\bname\s*=\s*["']agent-native:frame-type["'])(?=[^>]*\bcontent\s*=\s*["']hubspot-email["'])[^>]*>/i.test( + content, + ); +} + +export function createHubSpotEmailHtml({ + title = "Email", +}: { title?: string } = {}): string { + const escapedTitle = title.replace(/[&<>"']/g, (character) => { + return { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }[character]!; + }); + return ` + + + + + + + ${escapedTitle} + + + +
A short preview of your email.
+
+ + + + +
+ +`; +} From 2f2cae957b5998aadcf8aee2e50b485e7b5d0aa7 Mon Sep 17 00:00:00 2001 From: Shawn McClelland Date: Thu, 22 Oct 2026 21:13:06 -0400 Subject: [PATCH 02/11] Fix HubSpot export validation and agent context guards --- templates/design/AGENTS.md | 2 +- .../design/server/lib/hubspot-email-export.ts | 23 ++++++++----------- templates/design/server/plugins/agent-chat.ts | 2 -- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/templates/design/AGENTS.md b/templates/design/AGENTS.md index b3a418e3b6f..208c1c8d761 100644 --- a/templates/design/AGENTS.md +++ b/templates/design/AGENTS.md @@ -27,7 +27,7 @@ Shared guides: `actions`, ## Framework Docs -Use `pnpm action docs-search --query ""` or `--slug ""`. +Use `pnpm action docs-search --query ""` or `pnpm action docs-search --slug ""`. ## Actions diff --git a/templates/design/server/lib/hubspot-email-export.ts b/templates/design/server/lib/hubspot-email-export.ts index 00153cece68..bbf56308c4e 100644 --- a/templates/design/server/lib/hubspot-email-export.ts +++ b/templates/design/server/lib/hubspot-email-export.ts @@ -27,19 +27,16 @@ const HUBSPOT_EMAIL_URL_VARIABLES = new Set([ function isLiteralLinkDestination(value: string | undefined): boolean { const destination = value?.trim(); if (!destination) return false; - try { - const url = new URL(destination); - if (url.protocol === "http:" || url.protocol === "https:") - return ( - /^https?:\/\//i.test(destination) && - !!url.hostname && - !url.username && - !url.password - ); - return ["mailto:", "tel:"].includes(url.protocol) && !!url.pathname.trim(); - } catch { - return false; - } + if (!URL.canParse(destination)) return false; + const url = new URL(destination); + if (url.protocol === "http:" || url.protocol === "https:") + return ( + /^https?:\/\//i.test(destination) && + !!url.hostname && + !url.username && + !url.password + ); + return ["mailto:", "tel:"].includes(url.protocol) && !!url.pathname.trim(); } function validateContactInterpolation(token: string): void { diff --git a/templates/design/server/plugins/agent-chat.ts b/templates/design/server/plugins/agent-chat.ts index d5babf46766..c27042994be 100644 --- a/templates/design/server/plugins/agent-chat.ts +++ b/templates/design/server/plugins/agent-chat.ts @@ -127,8 +127,6 @@ const INITIAL_TOOL_NAMES = [ "update-file", "rename-screen", "export-png", - "take-design-screenshot", - "export-hubspot-html", "navigate", ]; From 13112c3ea5be3ac2f213798d585d1d0bc94d3a42 Mon Sep 17 00:00:00 2001 From: Shawn McClelland Date: Thu, 22 Oct 2026 21:23:06 -0400 Subject: [PATCH 03/11] Update automation history fixture for scoped transactions --- packages/core/src/jobs/run-history.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/jobs/run-history.spec.ts b/packages/core/src/jobs/run-history.spec.ts index 86293f1273b..791f25d867d 100644 --- a/packages/core/src/jobs/run-history.spec.ts +++ b/packages/core/src/jobs/run-history.spec.ts @@ -17,6 +17,7 @@ vi.mock("../db/client.js", async () => { }; return { getDbExec: () => db, + getScopedTransactionDbExec: () => undefined, }; }); From 612904db2c7b6c68110001d216593599b8c1bd5d Mon Sep 17 00:00:00 2001 From: Shawn McClelland Date: Thu, 22 Oct 2026 21:51:00 -0400 Subject: [PATCH 04/11] Harden HubSpot export validation and align regression tests --- .../core/src/agent/production-agent.spec.ts | 109 ++-------- .../core/src/server/agents-bundle.spec.ts | 11 +- .../.agents/skills/hubspot-email/SKILL.md | 4 +- .../app/components/design/BreakpointBar.tsx | 1 - .../app/components/design/EditPanel.tsx | 1 - .../design/edit-panel/frame-presets-panel.tsx | 18 +- templates/design/app/i18n/ar-SA.ts | 2 - templates/design/app/i18n/de-DE.ts | 2 - templates/design/app/i18n/en-US.ts | 2 - templates/design/app/i18n/es-ES.ts | 2 - templates/design/app/i18n/fr-FR.ts | 2 - templates/design/app/i18n/hi-IN.ts | 2 - templates/design/app/i18n/ja-JP.ts | 2 - templates/design/app/i18n/ko-KR.ts | 2 - templates/design/app/i18n/pt-BR.ts | 2 - templates/design/app/i18n/zh-CN.ts | 2 - templates/design/app/i18n/zh-TW.ts | 2 - .../commands/create-screen-frame.ts | 20 +- .../domains/use-editor-canvas-and-screens.ts | 10 +- .../server/lib/hubspot-email-export.test.ts | 146 +++++++++++++ .../design/server/lib/hubspot-email-export.ts | 194 +++++++++++++++--- templates/design/shared/frame-size-presets.ts | 5 - 22 files changed, 338 insertions(+), 203 deletions(-) diff --git a/packages/core/src/agent/production-agent.spec.ts b/packages/core/src/agent/production-agent.spec.ts index 20d1b232635..7b2af8aa0a8 100644 --- a/packages/core/src/agent/production-agent.spec.ts +++ b/packages/core/src/agent/production-agent.spec.ts @@ -12169,7 +12169,7 @@ describe("runAgentLoop", () => { ); }); - it("does not cache repeated reads when the action opts out of deduping", async () => { + it("keeps polling fresh results past the repeat threshold when the action opts out of deduping", async () => { let streamCalls = 0; const engine: AgentEngine = { name: "test", @@ -12185,7 +12185,7 @@ describe("runAgentLoop", () => { }, async *stream(): AsyncIterable { streamCalls += 1; - if (streamCalls <= 2) { + if (streamCalls <= 9) { yield { type: "assistant-content", parts: [ @@ -12207,10 +12207,10 @@ describe("runAgentLoop", () => { yield { type: "stop", reason: "end_turn" }; }, }; - const pollAction = vi - .fn() - .mockResolvedValueOnce({ status: "running" }) - .mockResolvedValueOnce({ status: "complete" }); + let polls = 0; + const pollAction = vi.fn(async () => ({ + status: ++polls === 9 ? "complete" : "running", + })); const events: any[] = []; await runAgentLoop({ @@ -12230,8 +12230,13 @@ describe("runAgentLoop", () => { signal: new AbortController().signal, }); - expect(pollAction).toHaveBeenCalledTimes(2); + expect(pollAction).toHaveBeenCalledTimes(9); expect(JSON.stringify(events)).not.toContain("Skipped duplicate read-only"); + const completedPoll = events.find( + (event) => event.type === "tool_done" && event.id === "poll-9", + ); + expect(completedPoll).toBeDefined(); + expect(JSON.parse(completedPoll.result)).toEqual({ status: "complete" }); expect(events).toContainEqual({ type: "text", text: "complete" }); }); @@ -13459,96 +13464,6 @@ describe("runAgentLoop", () => { ); }); - it("stops a turn that keeps issuing the same successful tool call with identical arguments", async () => { - let streamCalls = 0; - const run = vi.fn(async () => "same answer every time"); - const engine: AgentEngine = { - name: "test", - label: "Test", - defaultModel: "test-model", - supportedModels: ["test-model"], - capabilities: { - thinking: false, - promptCaching: false, - vision: false, - computerUse: false, - parallelToolCalls: false, - }, - async *stream(options: EngineStreamOptions): AsyncIterable { - streamCalls += 1; - if (isLoopBreakerCloseout(options)) { - yield { - type: "assistant-content", - parts: [ - { - type: "text" as const, - text: "Stopped after eight repeated reads; nothing remains.", - }, - ], - }; - yield { type: "stop", reason: "end_turn" }; - return; - } - yield { - type: "assistant-content", - parts: [ - { - type: "tool-call" as const, - id: `tool-loop-${streamCalls}`, - name: "spinning-read", - input: { q: "same" }, - }, - ], - }; - yield { type: "stop", reason: "tool_use" }; - }, - }; - const events: any[] = []; - - await runAgentLoop({ - engine, - model: "test-model", - systemPrompt: "system", - tools: [], - messages: [{ role: "user", content: [{ type: "text", text: "go" }] }], - actions: { - "spinning-read": { - ...actionEntry({ readOnly: true }), - dedupe: false, - run, - }, - }, - send: (event) => events.push(event), - signal: new AbortController().signal, - maxIterations: MAX_IDENTICAL_TOOL_CALLS, - }); - - expect(streamCalls).toBe(MAX_IDENTICAL_TOOL_CALLS + 1); - expect(run).toHaveBeenCalledTimes(MAX_IDENTICAL_TOOL_CALLS); - expect(events).toContainEqual( - expect.objectContaining({ - type: "tool_done", - tool: "spinning-read", - result: "same answer every time", - }), - ); - expect(events).toContainEqual( - expect.objectContaining({ - type: "text", - text: "Stopped after eight repeated reads; nothing remains.", - }), - ); - expect(events).not.toContainEqual( - expect.objectContaining({ type: "error" }), - ); - expect(events).toContainEqual( - expect.objectContaining({ type: "done", reason: "loop_breaker" }), - ); - expect(events).not.toContainEqual( - expect.objectContaining({ type: "loop_limit" }), - ); - }); - it("allows nine identical reads when each successful write changes the deck", async () => { let streamCalls = 0; let reads = 0; diff --git a/packages/core/src/server/agents-bundle.spec.ts b/packages/core/src/server/agents-bundle.spec.ts index 1111a4732d4..782d87962d2 100644 --- a/packages/core/src/server/agents-bundle.spec.ts +++ b/packages/core/src/server/agents-bundle.spec.ts @@ -680,8 +680,11 @@ describe("readAgentsBundleFromFs", () => { "design-generation", "design-systems", "export-handoff", + "hubspot-email", + "hubspot-email-review", "visual-edit", ]; + const promptBlock = generateSkillsPromptBlock(bundle); for (const name of expected) { const skill = runtimeSkills.find((candidate) => { @@ -689,13 +692,9 @@ describe("readAgentsBundleFromFs", () => { }); expect(skill, `expected runtime skill ${name}`).toBeDefined(); expect(skill!.meta.description.trim()).toContain("Use when"); + expect(promptBlock).toContain(skill!.meta.description.trim()); + expect(promptBlock).toContain(`[skill-${name}]`); } - - const promptBlock = generateSkillsPromptBlock(bundle); - expect(promptBlock).toContain("Generate or refine complete interactive"); - expect(promptBlock).toContain("Export Design work"); - expect(promptBlock).toContain("[skill-design-generation]"); - expect(promptBlock).toContain("[skill-visual-edit]"); }); it("exposes build-an-app to the Chat runtime skill picker", () => { diff --git a/templates/design/.agents/skills/hubspot-email/SKILL.md b/templates/design/.agents/skills/hubspot-email/SKILL.md index d9c450bfb13..8a217000a70 100644 --- a/templates/design/.agents/skills/hubspot-email/SKILL.md +++ b/templates/design/.agents/skills/hubspot-email/SKILL.md @@ -2,8 +2,8 @@ name: hubspot-email description: >- Create, convert, personalize, and validate HubSpot marketing email HTML. - Use for HubSpot email creation, alignment, personalization tokens, - conversion, or HTML handoff, including follow-ups about existing email frames. + Use when creating emails, aligning existing frames with HubSpot, adding + personalization tokens, or exporting HTML, including follow-ups. --- # HubSpot Email diff --git a/templates/design/app/components/design/BreakpointBar.tsx b/templates/design/app/components/design/BreakpointBar.tsx index 972638e701d..31b47cabd26 100644 --- a/templates/design/app/components/design/BreakpointBar.tsx +++ b/templates/design/app/components/design/BreakpointBar.tsx @@ -53,7 +53,6 @@ export function extraBreakpointWidthPresets( const out: Array<{ name: string; widthPx: number }> = []; for (const category of FRAME_SIZE_PRESET_CATEGORIES) { for (const preset of category.presets) { - if (preset.frameType) continue; if (taken.has(preset.width) || seen.has(preset.width)) continue; if (preset.width < 320 || preset.width > 3840) continue; seen.add(preset.width); diff --git a/templates/design/app/components/design/EditPanel.tsx b/templates/design/app/components/design/EditPanel.tsx index 76e04e77193..97033d9a9ce 100644 --- a/templates/design/app/components/design/EditPanel.tsx +++ b/templates/design/app/components/design/EditPanel.tsx @@ -1073,7 +1073,6 @@ function ScreenSizePresetPicker({
{ onPick(preset); setOpen(false); diff --git a/templates/design/app/components/design/edit-panel/frame-presets-panel.tsx b/templates/design/app/components/design/edit-panel/frame-presets-panel.tsx index 46fffe9c18e..386fb0bf3d1 100644 --- a/templates/design/app/components/design/edit-panel/frame-presets-panel.tsx +++ b/templates/design/app/components/design/edit-panel/frame-presets-panel.tsx @@ -19,7 +19,6 @@ function FramePresetCategoryGroup({ defaultOpen?: boolean; onPick: (preset: FrameSizePreset) => void; }) { - const t = useT(); const [open, setOpen] = useState(defaultOpen); return ( @@ -43,13 +42,13 @@ function FramePresetCategoryGroup({
{presets.map((preset) => (
- {FRAME_SIZE_PRESET_CATEGORIES.filter( - (category) => - includeFrameTypes || - category.presets.some((preset) => !preset.frameType), - ).map((category, index) => ( + {FRAME_SIZE_PRESET_CATEGORIES.map((category, index) => ( includeFrameTypes || !preset.frameType, - )} + presets={category.presets} defaultOpen={index === 0} onPick={onPick} /> diff --git a/templates/design/app/i18n/ar-SA.ts b/templates/design/app/i18n/ar-SA.ts index 64382e32c98..37afd950d4b 100644 --- a/templates/design/app/i18n/ar-SA.ts +++ b/templates/design/app/i18n/ar-SA.ts @@ -980,9 +980,7 @@ export default { framePresets: { title: "الإطار", applyToFrame: "تغيير الحجم إلى إعداد مسبق", - hubspotEmail: "بريد HubSpot الإلكتروني", categories: { - email: "البريد الإلكتروني", phone: "الهاتف", tablet: "الجهاز اللوحي", desktop: "سطح المكتب", diff --git a/templates/design/app/i18n/de-DE.ts b/templates/design/app/i18n/de-DE.ts index adf7d1923e8..bb660731d7c 100644 --- a/templates/design/app/i18n/de-DE.ts +++ b/templates/design/app/i18n/de-DE.ts @@ -998,9 +998,7 @@ export default { framePresets: { title: "Rahmen", applyToFrame: "Auf Voreinstellung anpassen", - hubspotEmail: "HubSpot-E-Mail", categories: { - email: "E-Mail", phone: "Telefon", tablet: "Tablet", desktop: "Desktop", diff --git a/templates/design/app/i18n/en-US.ts b/templates/design/app/i18n/en-US.ts index efa68c45c2a..1d6073cd9c5 100644 --- a/templates/design/app/i18n/en-US.ts +++ b/templates/design/app/i18n/en-US.ts @@ -914,9 +914,7 @@ export default { framePresets: { title: "Frame", applyToFrame: "Resize to preset", - hubspotEmail: "HubSpot email", categories: { - email: "Email", phone: "Phone", tablet: "Tablet", desktop: "Desktop", diff --git a/templates/design/app/i18n/es-ES.ts b/templates/design/app/i18n/es-ES.ts index 225592034fc..03e292e5728 100644 --- a/templates/design/app/i18n/es-ES.ts +++ b/templates/design/app/i18n/es-ES.ts @@ -995,9 +995,7 @@ export default { framePresets: { title: "Marco", applyToFrame: "Cambiar a tamaño predefinido", - hubspotEmail: "Correo de HubSpot", categories: { - email: "Correo electrónico", phone: "Teléfono", tablet: "Tableta", desktop: "Escritorio", diff --git a/templates/design/app/i18n/fr-FR.ts b/templates/design/app/i18n/fr-FR.ts index 7540d9d2d20..47fa944e8f1 100644 --- a/templates/design/app/i18n/fr-FR.ts +++ b/templates/design/app/i18n/fr-FR.ts @@ -995,9 +995,7 @@ export default { framePresets: { title: "Cadre", applyToFrame: "Redimensionner selon un préréglage", - hubspotEmail: "E-mail HubSpot", categories: { - email: "E-mail", phone: "Téléphone", tablet: "Tablette", desktop: "Bureau", diff --git a/templates/design/app/i18n/hi-IN.ts b/templates/design/app/i18n/hi-IN.ts index 890a69d2af3..6da4cc0b000 100644 --- a/templates/design/app/i18n/hi-IN.ts +++ b/templates/design/app/i18n/hi-IN.ts @@ -981,9 +981,7 @@ export default { framePresets: { title: "फ़्रेम", applyToFrame: "प्रीसेट आकार में बदलें", - hubspotEmail: "HubSpot ईमेल", categories: { - email: "ईमेल", phone: "फ़ोन", tablet: "टैबलेट", desktop: "डेस्कटॉप", diff --git a/templates/design/app/i18n/ja-JP.ts b/templates/design/app/i18n/ja-JP.ts index e677cfed973..13343c732dd 100644 --- a/templates/design/app/i18n/ja-JP.ts +++ b/templates/design/app/i18n/ja-JP.ts @@ -995,9 +995,7 @@ export default { framePresets: { title: "フレーム", applyToFrame: "プリセットサイズに変更", - hubspotEmail: "HubSpot メール", categories: { - email: "メール", phone: "電話", tablet: "タブレット", desktop: "デスクトップ", diff --git a/templates/design/app/i18n/ko-KR.ts b/templates/design/app/i18n/ko-KR.ts index e14148743cc..c0153135306 100644 --- a/templates/design/app/i18n/ko-KR.ts +++ b/templates/design/app/i18n/ko-KR.ts @@ -983,9 +983,7 @@ export default { framePresets: { title: "프레임", applyToFrame: "사전 설정 크기로 조정", - hubspotEmail: "HubSpot 이메일", categories: { - email: "이메일", phone: "휴대폰", tablet: "태블릿", desktop: "데스크톱", diff --git a/templates/design/app/i18n/pt-BR.ts b/templates/design/app/i18n/pt-BR.ts index 0af5f92a83d..f1c44705f1b 100644 --- a/templates/design/app/i18n/pt-BR.ts +++ b/templates/design/app/i18n/pt-BR.ts @@ -992,9 +992,7 @@ export default { framePresets: { title: "Quadro", applyToFrame: "Redimensionar para predefinição", - hubspotEmail: "E-mail do HubSpot", categories: { - email: "E-mail", phone: "Celular", tablet: "Tablet", desktop: "Área de trabalho", diff --git a/templates/design/app/i18n/zh-CN.ts b/templates/design/app/i18n/zh-CN.ts index 97de9bdf333..3c2fc98e4b3 100644 --- a/templates/design/app/i18n/zh-CN.ts +++ b/templates/design/app/i18n/zh-CN.ts @@ -969,9 +969,7 @@ export default { framePresets: { title: "画框", applyToFrame: "调整为预设尺寸", - hubspotEmail: "HubSpot 邮件", categories: { - email: "邮件", phone: "手机", tablet: "平板", desktop: "桌面", diff --git a/templates/design/app/i18n/zh-TW.ts b/templates/design/app/i18n/zh-TW.ts index 41b25965028..0e4454dd2d0 100644 --- a/templates/design/app/i18n/zh-TW.ts +++ b/templates/design/app/i18n/zh-TW.ts @@ -1077,9 +1077,7 @@ export default { framePresets: { title: "外框", applyToFrame: "調整為預設尺寸", - hubspotEmail: "HubSpot 郵件", categories: { - email: "郵件", phone: "手機", tablet: "平板", desktop: "桌面", diff --git a/templates/design/app/pages/design-editor/commands/create-screen-frame.ts b/templates/design/app/pages/design-editor/commands/create-screen-frame.ts index f05e71879e4..37ad110734a 100644 --- a/templates/design/app/pages/design-editor/commands/create-screen-frame.ts +++ b/templates/design/app/pages/design-editor/commands/create-screen-frame.ts @@ -1,9 +1,5 @@ import { useActionMutation } from "@agent-native/core/client/hooks"; import type { CanvasFrameGeometryById } from "@shared/canvas-frames"; -import { - createHubSpotEmailHtml, - HUBSPOT_EMAIL_FRAME_TYPE, -} from "@shared/hubspot-email"; import type { QueryClient } from "@tanstack/react-query"; import type { RefObject } from "react"; import { toast } from "sonner"; @@ -60,22 +56,11 @@ export function runCreateScreenFrame( t, writeFrameGeometrySnapshot, }: CreateScreenFrameArgs, - geometry: { - x: number; - y: number; - width: number; - height: number; - frameType?: typeof HUBSPOT_EMAIL_FRAME_TYPE; - }, + geometry: { x: number; y: number; width: number; height: number }, ) { if (!id || !canEditDesign) return; const filename = nextBlankScreenFilename(files); - const content = - geometry.frameType === HUBSPOT_EMAIL_FRAME_TYPE - ? createHubSpotEmailHtml({ - title: t("editPanel.framePresets.hubspotEmail"), - }) - : blankScreenHtml(prettyScreenName(filename)); + const content = blankScreenHtml(prettyScreenName(filename)); const nextGeometry = { x: Math.round(geometry.x), y: Math.round(geometry.y), @@ -88,7 +73,6 @@ export function runCreateScreenFrame( filename, content, fileType: "html", - ...(geometry.frameType ? { frameType: geometry.frameType } : {}), } as any, { onSuccess: (result: any) => { diff --git a/templates/design/app/pages/design-editor/domains/use-editor-canvas-and-screens.ts b/templates/design/app/pages/design-editor/domains/use-editor-canvas-and-screens.ts index 67c255aca0b..8f7a924beba 100644 --- a/templates/design/app/pages/design-editor/domains/use-editor-canvas-and-screens.ts +++ b/templates/design/app/pages/design-editor/domains/use-editor-canvas-and-screens.ts @@ -555,13 +555,7 @@ export function useEditorCanvasAndScreens({ ); const handleCreateScreenFrame = useCallback( - (geometry: { - x: number; - y: number; - width: number; - height: number; - frameType?: FrameSizePreset["frameType"]; - }) => + (geometry: { x: number; y: number; width: number; height: number }) => runCreateScreenFrame( { canEditDesign, @@ -612,7 +606,7 @@ export function useEditorCanvasAndScreens({ height: preset.height, } : { x: 0, y: 0, width: preset.width, height: preset.height }; - handleCreateScreenFrame({ ...geometry, frameType: preset.frameType }); + handleCreateScreenFrame(geometry); }, [ boardFileId, diff --git a/templates/design/server/lib/hubspot-email-export.test.ts b/templates/design/server/lib/hubspot-email-export.test.ts index 8818676703b..6c5040934d7 100644 --- a/templates/design/server/lib/hubspot-email-export.test.ts +++ b/templates/design/server/lib/hubspot-email-export.test.ts @@ -258,6 +258,55 @@ describe("HubSpot email HTML export", () => { ).toThrow("fallback"); }); + it("preserves double-quoted HubL arguments and quoted defaults in text", () => { + const urlToken = + '{{ personalization_token("contact.website", "https://example.com/docs") }}'; + const textToken = `{{ personalization_token('contact.firstname', 'Bob "the builder"') }}`; + const { html } = prepareHubSpotEmailHtml( + withContent(`${textToken}`), + ); + expect(html).toContain(urlToken); + expect(html).toContain(textToken); + expect( + findElement( + html.replace(urlToken, "https://example.com/docs"), + "quoted-token", + ).attrs, + ).toContainEqual({ name: "href", value: "https://example.com/docs" }); + }); + + it.each([ + [ + "href", + String.raw`{{ personalization_token('contact.website', 'https://example.com/" onmouseover="injected') }}`, + ], + [ + "href", + String.raw`{{ personalization_token("contact.website", "https://example.com/\"quoted\"") }}`, + ], + [ + "title", + `{{ personalization_token('contact.firstname', 'Bob "the builder"') }}`, + ], + ])( + "rejects quote-bearing personalization defaults in %s attributes", + (attribute, token) => { + const attrs = + attribute === "href" + ? `href="${token}"` + : `href="https://example.com" title="${token}"`; + expect(() => + prepareHubSpotEmailHtml(withContent(`Open`)), + ).toThrowError( + expect.objectContaining({ + errorCode: "invalid_hubspot_email", + statusCode: 422, + message: expect.stringContaining("literal double quotes"), + }), + ); + }, + ); + it.each([ "{{ contact.firstname|default('there', true) }}", "{{ contact.firstname|escape_html }}", @@ -356,6 +405,103 @@ describe("HubSpot email HTML export", () => { ); }); + it.each([ + [".email-footer", "display:none!important"], + ['table[role="presentation"]', "opacity:0%!important"], + [".email-footer p", "font-size:0px!important"], + [".email-footer a", "visibility:hidden!important"], + [".email-footer a", "visibility:collapse!important"], + [".email-footer", "font:0/0 Arial!important"], + [".email-footer", "all:unset!important"], + ])( + "rejects responsive visibility changes affecting required content (%s %s)", + (selector, css) => { + expect(() => + prepareHubSpotEmailHtml( + withContent("Hello", `@media(max-width:620px){${selector}{${css}}}`), + ), + ).toThrow("Responsive CSS may hide required email footer content"); + }, + ); + + it("checks each company token and the unsubscribe label independently", () => { + const html = withContent( + "

{{ unsubscribe_link }}

", + "@media(max-width:620px){.company-name,.email-footer a{display:none!important}}", + ).replace( + "{{ site_settings.company_name }}", + '{{ site_settings.company_name }}', + ); + expect(() => prepareHubSpotEmailHtml(html)).toThrow( + "site_settings.company_name", + ); + expect(() => + prepareHubSpotEmailHtml(html.replace(".company-name,", "")), + ).toThrow("unsubscribe_link"); + }); + + it.each([ + ["display:block!important", "display:none!important"], + ["display:block", "display:none"], + ["", "display:none!important;display:table-cell!important"], + ])( + "respects inline and within-rule precedence for responsive visibility (%s / %s)", + (inline, css) => { + const html = withContent( + "Hello", + `@media(max-width:620px){.email-footer{${css}}}`, + ).replace( + '', + ``, + ); + expect(prepareHubSpotEmailHtml(html).html).toContain("@media"); + }, + ); + + it("preserves responsive spacing and unrelated conditional artwork", () => { + const html = withContent( + '
Decoration
', + "@media(max-width:620px){.email-footer{padding:12px;font-size:14px}.art{display:none!important}}", + ); + expect(prepareHubSpotEmailHtml(html).html).toContain( + ".art{display:none!important}", + ); + }); + + it("accepts an always-visible duplicate footer but rejects mutually conditional copies", () => { + const html = withContent( + "Hello", + "@media(max-width:620px){.email-footer{display:none!important}}", + ); + const footer = html + .match(/[\s\S]*?<\/td>/)![0] + .replace('class="email-footer"', 'class="always-footer"'); + const duplicated = html.replace( + "", + `${footer}
`, + ); + expect(prepareHubSpotEmailHtml(duplicated).html).toContain("always-footer"); + expect(() => + prepareHubSpotEmailHtml( + duplicated.replace( + "", + "@media(min-width:621px){.always-footer{display:none!important}}", + ), + ), + ).toThrow("Responsive CSS may hide required email footer content"); + }); + + it("rejects conditional visibility selectors it cannot safely match", () => { + expect(() => + prepareHubSpotEmailHtml( + withContent( + "Hello", + "@media(max-width:620px){.email-footer:not(.keep){display:none!important}}", + ), + ), + ).toThrow("cannot be inlined"); + }); + it("requires the email annotation before document markup, without requiring availability", () => { const html = withContent("Hello"); expect(() => diff --git a/templates/design/server/lib/hubspot-email-export.ts b/templates/design/server/lib/hubspot-email-export.ts index bbf56308c4e..8e0d8274c14 100644 --- a/templates/design/server/lib/hubspot-email-export.ts +++ b/templates/design/server/lib/hubspot-email-export.ts @@ -11,6 +11,17 @@ type Specificity = [number, number, number]; type SelectorPart = { compound: string; combinator: " " | ">" | "+" | "~" }; type ImageReference = { url: string; location: string }; type EmailExportOptions = { preserveLinksFromHtml?: string }; +type ConditionalVisibilityRule = { + parts: SelectorPart[]; + declarations: Declaration[]; +}; + +const REQUIRED_FOOTER_TOKENS = [ + "company_name", + "company_street_address_1", + "company_city", + "company_state", +].map((name) => `site_settings.${name}`); function invalid(message: string): never { return fail(message, { errorCode: "invalid_hubspot_email", statusCode: 422 }); @@ -51,6 +62,15 @@ function validateContactInterpolation(token: string): void { ); } +function validateAttributeInterpolations(value: string, at: string): void { + for (const token of value.match(/{{[\s\S]*?}}/g) ?? []) { + if (parseHubLInterpolation(token)?.fallback?.includes('"')) + invalid( + `Personalization defaults in attributes at ${at} cannot contain literal double quotes. Use an encoded URL or an HTML entity in text attributes.`, + ); + } +} + function validateLinkDestination(value: string, at: string): void { const destination = value.trim(); if (destination.startsWith("#") || isLiteralLinkDestination(destination)) @@ -323,16 +343,54 @@ function parseStyles(css: string) { } } -function visibleTextByElement(document: Node): Map { - const result = new Map(); +function winningDeclarations(declarations: Declaration[]) { + const result = new Map(); + for (const declaration of declarations) { + const property = declaration.prop.toLowerCase(); + const previous = result.get(property); + if (!previous?.important || declaration.important) + result.set(property, declaration); + } + return result; +} + +function inlineDeclarations(element: Element) { + const declarations: Declaration[] = []; + parseStyles(`email { ${attribute(element, "style") ?? ""} }`).walkDecls( + (declaration) => { + declarations.push(declaration); + }, + ); + return winningDeclarations(declarations); +} + +function mayHideText(declaration: Declaration): boolean { + const property = declaration.prop.toLowerCase(); + const value = declaration.value.trim().toLowerCase(); + return ( + (property === "display" && value === "none") || + (property === "visibility" && ["hidden", "collapse"].includes(value)) || + (property === "opacity" && Number(value.replace(/%$/, "")) === 0) || + (property === "font-size" && + /^[+-]?(?:0*\.)?0+(?:[a-z%]+)?$/.test(value)) || + ["all", "font"].includes(property) + ); +} + +function visibleTextByElement(document: Node): Map { + const result = new Map(); const visit = ( node: Node, inherited = { visibility: "visible", zeroFont: false }, ): string => { - if (node.nodeName === "#text") - return inherited.visibility === "visible" && !inherited.zeroFont - ? (node as DefaultTreeAdapterTypes.TextNode).value - : ""; + if (node.nodeName === "#text") { + const text = + inherited.visibility === "visible" && !inherited.zeroFont + ? (node as DefaultTreeAdapterTypes.TextNode).value + : ""; + result.set(node, text); + return text; + } let state = inherited; if ("tagName" in node) { if ( @@ -341,15 +399,7 @@ function visibleTextByElement(document: Node): Map { attribute(node, "data-email-preheader") !== undefined ) return ""; - const declarations = new Map(); - parseStyles(`email { ${attribute(node, "style") ?? ""} }`).walkDecls( - (declaration) => { - const property = declaration.prop.toLowerCase(); - const previous = declarations.get(property); - if (!previous?.important || declaration.important) - declarations.set(property, declaration); - }, - ); + const declarations = inlineDeclarations(node); const value = (property: string) => declarations.get(property)?.value.trim().toLowerCase(); const opacity = value("opacity"); @@ -389,6 +439,67 @@ function visibleTextByElement(document: Node): Map { return result; } +function assertResponsiveFooterVisibility( + visibleText: Map, + rules: ConditionalVisibilityRule[], + restoreTokens: (value: string) => string, +): void { + if (!rules.length) return; + const candidates = new Map( + [...REQUIRED_FOOTER_TOKENS, "unsubscribe_link"].map((name) => [ + name, + [] as Element[][], + ]), + ); + for (const [node, text] of visibleText) { + if (node.nodeName !== "#text" || !text.trim()) continue; + const path: Element[] = []; + let parent = "parentNode" in node ? node.parentNode : undefined; + while (parent && "tagName" in parent) { + path.push(parent); + parent = parent.parentNode; + } + for (const token of restoreTokens(text).match(/{{[\s\S]*?}}/g) ?? []) { + const parsed = parseHubLInterpolation(token); + if (parsed && REQUIRED_FOOTER_TOKENS.includes(parsed.name)) + candidates.get(parsed.name)?.push(path); + } + if ( + path.some((element) => { + if (element.tagName !== "a") return false; + const parsed = parseHubLInterpolation( + restoreTokens(attribute(element, "href") ?? ""), + ); + return parsed?.name === "unsubscribe_link" && !parsed.personalization; + }) + ) + candidates.get("unsubscribe_link")!.push(path); + } + const styles = new Map>(); + for (const [name, paths] of candidates) { + const alwaysVisible = paths.some((path) => + path.every((element) => { + if (!styles.has(element)) + styles.set(element, inlineDeclarations(element)); + return rules.every( + (rule) => + !matchesSelector(element, rule.parts) || + rule.declarations.every((declaration) => { + const inline = styles + .get(element)! + .get(declaration.prop.toLowerCase()); + return inline?.important || (!!inline && !declaration.important); + }), + ); + }), + ); + if (!alwaysVisible) + invalid( + `Responsive CSS may hide required email footer content (${name}). Keep an always-visible footer and unsubscribe label outside conditional hide/show sections.`, + ); + } +} + function imageReference(url: string, at: string): ImageReference { let parsed: URL; try { @@ -467,6 +578,8 @@ export function prepareHubSpotEmailHtml( `Remove <${element.tagName}> at ${at}; email exports cannot depend on browser scripts, external styles, or interactive content.`, ); for (const attr of element.attrs) { + const value = restoreTokens(attr.value); + validateAttributeInterpolations(value, `${at}[${attr.name}]`); if ( /^(?:on|x-|@|:)/.test(attr.name) || ["srcdoc", "srcset", "background", "action", "formaction"].includes( @@ -476,8 +589,7 @@ export function prepareHubSpotEmailHtml( invalid( `Attribute "${attr.name}" at ${at} is not supported in an email. Use static HTML and HTTPS elements.`, ); - if (attr.name === "href") - validateLinkDestination(restoreTokens(attr.value), at); + if (attr.name === "href") validateLinkDestination(value, at); } if (element.tagName === "img") { if (attribute(element, "alt") === undefined) @@ -536,6 +648,7 @@ export function prepareHubSpotEmailHtml( css: string; order: number; }> = []; + const conditionalVisibilityRules: ConditionalVisibilityRule[] = []; let order = 0; for (const element of all.filter((entry) => entry.tagName === "style")) { const root = parseStyles(textNodes(element.childNodes[0] ?? element)); @@ -548,8 +661,33 @@ export function prepareHubSpotEmailHtml( }); const media: string[] = []; for (const node of root.nodes) { - if (node.type === "atrule") media.push(node.toString()); - else if (node.type === "rule") { + if (node.type === "atrule") { + node.walkRules((rule) => { + const declarations: Declaration[] = []; + rule.walkDecls((declaration) => { + declarations.push(declaration); + }); + const hiding = [...winningDeclarations(declarations).values()].filter( + mayHideText, + ); + if (!hiding.length) return; + if ( + rule.parent !== node || + rule.nodes.some( + (child) => child.type !== "decl" && child.type !== "comment", + ) + ) + invalid( + "Nested responsive visibility rules cannot be validated for email footer content.", + ); + for (const selector of rule.selectors) + conditionalVisibilityRules.push({ + parts: parseSelector(selector).parts, + declarations: hiding, + }); + }); + media.push(node.toString()); + } else if (node.type === "rule") { if ( node.nodes.some( (child) => child.type !== "decl" && child.type !== "comment", @@ -611,17 +749,10 @@ export function prepareHubSpotEmailHtml( const footerTokens = ( restoreTokens(visibleText.get(body) ?? "").match(/{{[\s\S]*?}}/g) ?? [] ).map(parseHubLInterpolation); - for (const token of [ - "company_name", - "company_street_address_1", - "company_city", - "company_state", - ]) { - if ( - !footerTokens.some((parsed) => parsed?.name === `site_settings.${token}`) - ) + for (const token of REQUIRED_FOOTER_TOKENS) { + if (!footerTokens.some((parsed) => parsed?.name === token)) invalid( - `The visible footer is missing {{ site_settings.${token} }} outside the preheader.`, + `The visible footer is missing {{ ${token} }} outside the preheader.`, ); } if ( @@ -637,6 +768,11 @@ export function prepareHubSpotEmailHtml( invalid( 'The email needs a visible unsubscribe link with href="{{ unsubscribe_link }}" and a non-empty text label outside the preheader.', ); + assertResponsiveFooterVisibility( + visibleText, + conditionalVisibilityRules, + restoreTokens, + ); const leadingComments = content .replace(/^\uFEFF/, "") diff --git a/templates/design/shared/frame-size-presets.ts b/templates/design/shared/frame-size-presets.ts index a77aef902c5..3a5e9574233 100644 --- a/templates/design/shared/frame-size-presets.ts +++ b/templates/design/shared/frame-size-presets.ts @@ -12,10 +12,7 @@ * untranslated in every locale. */ -import { HUBSPOT_EMAIL_FRAME_TYPE } from "./hubspot-email.js"; - export type FrameSizePresetCategoryKey = - | "email" | "phone" | "tablet" | "desktop" @@ -29,8 +26,6 @@ export interface FrameSizePreset { name: string; width: number; height: number; - frameType?: typeof HUBSPOT_EMAIL_FRAME_TYPE; - nameKey?: string; } export interface FrameSizePresetCategory { From e2283fa9a3e2a1d05df183578bca01139aee6bc5 Mon Sep 17 00:00:00 2001 From: Shawn McClelland Date: Thu, 22 Oct 2026 22:10:45 -0400 Subject: [PATCH 05/11] Validate transparent HubSpot footers and HubL escapes --- .../server/lib/email-text-color.spec.ts | 65 ++++++++++++ .../design/server/lib/email-text-color.ts | 85 ++++++++++++++++ .../server/lib/hubl-interpolation.spec.ts | 44 +++++++++ .../design/server/lib/hubl-interpolation.ts | 21 +++- .../server/lib/hubspot-email-export.test.ts | 98 +++++++++++++++++++ .../design/server/lib/hubspot-email-export.ts | 69 ++++++++++--- 6 files changed, 364 insertions(+), 18 deletions(-) create mode 100644 templates/design/server/lib/email-text-color.spec.ts create mode 100644 templates/design/server/lib/email-text-color.ts create mode 100644 templates/design/server/lib/hubl-interpolation.spec.ts diff --git a/templates/design/server/lib/email-text-color.spec.ts b/templates/design/server/lib/email-text-color.spec.ts new file mode 100644 index 00000000000..bf86ef544f3 --- /dev/null +++ b/templates/design/server/lib/email-text-color.spec.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { classifyEmailTextColor } from "./email-text-color.js"; + +describe("email text color alpha", () => { + it.each([ + ["RebeccaPurple", 1], + ["#abc", 1], + ["#abcdef", 1], + ["transparent", 0], + ["TRANSPARENT /* footer */", 0], + ["#1230", 0], + ["#11223300", 0], + ["#1238", 8 / 15], + ["#11223380", 128 / 255], + ["rgb(1, 2, 3)", 1], + ["rgba(1, 2, 3, 0)", 0], + ["rgba(1, 2, 3, 25%)", 0.25], + ["rgb(100% 0% 0% / 0%)", 0], + ["rgb(1 2 3 / .5)", 0.5], + ["hsl(210, 50%, 40%)", 1], + ["hsla(210deg, 50%, 40%, 0)", 0], + ["hsl(.5turn 50% 40% / 0%)", 0], + ["hwb(120 0% 0% / .25)", 0.25], + ["lab(50% 0 0 / 0)", 0], + ["lch(50% 40 120 / 50%)", 0.5], + ["oklab(50% .1 .1 / 0)", 0], + ["oklch(50% .1 120 / 25%)", 0.25], + ["color(display-p3 1 0 0 / 0%)", 0], + ["color(srgb 1 0 0)", 1], + ["rgb(1 2 3 / -50%)", 0], + ["rgba(1, 2, 3, 2)", 1], + ])("classifies absolute color %s", (value, alpha) => { + expect(classifyEmailTextColor(value)).toEqual({ kind: "resolved", alpha }); + }); + + it.each([undefined, "", "currentColor", "inherit /* footer */", "unset"])( + "preserves inheritance for %s", + (value) => { + expect(classifyEmailTextColor(value)).toEqual({ kind: "inherit" }); + }, + ); + + it("distinguishes the initial color reset from inheritance", () => { + expect(classifyEmailTextColor("initial")).toEqual({ kind: "initial" }); + }); + + it.each([ + "revert", + "revert-layer", + "notacolor", + "#12345", + "rgb(1 2)", + "red blue", + "rgb(1 2 3 /)", + "rgb(1 2 3 / none)", + "rgb(1 2 3 / calc(1 - 1))", + "rgb(from red r g b / 0)", + "var(--footer-color)", + "color-mix(in srgb, transparent, transparent)", + "light-dark(transparent, transparent)", + ])("does not turn an unresolved color into visible text (%s)", (value) => { + expect(classifyEmailTextColor(value)).toEqual({ kind: "unsupported" }); + }); +}); diff --git a/templates/design/server/lib/email-text-color.ts b/templates/design/server/lib/email-text-color.ts new file mode 100644 index 00000000000..dd4067cbe97 --- /dev/null +++ b/templates/design/server/lib/email-text-color.ts @@ -0,0 +1,85 @@ +import { lexer, parse, type CssNode } from "css-tree"; + +export type EmailTextColor = + | { kind: "resolved"; alpha: number } + | { kind: "inherit" } + | { kind: "initial" } + | { kind: "unsupported" }; + +const ABSOLUTE_COLOR_FUNCTIONS = new Set([ + "rgb", + "rgba", + "hsl", + "hsla", + "hwb", + "lab", + "lch", + "oklab", + "oklch", + "color", +]); + +function alphaValue(node: CssNode): EmailTextColor { + if (node.type !== "Number" && node.type !== "Percentage") + return { kind: "unsupported" }; + const alpha = Number(node.value) / (node.type === "Percentage" ? 100 : 1); + return Number.isFinite(alpha) + ? { kind: "resolved", alpha: Math.max(0, Math.min(1, alpha)) } + : { kind: "unsupported" }; +} + +export function classifyEmailTextColor( + value: string | undefined, +): EmailTextColor { + if (!value?.trim()) return { kind: "inherit" }; + try { + const ast = parse(value, { context: "value" }); + if (ast.type !== "Value" || ast.children.size !== 1) + return { kind: "unsupported" }; + const node = ast.children.first!; + if (node.type === "Identifier") { + const name = node.name.toLowerCase(); + if (["currentcolor", "inherit", "unset"].includes(name)) + return { kind: "inherit" }; + if (name === "initial") return { kind: "initial" }; + if (name === "transparent") return { kind: "resolved", alpha: 0 }; + } + if (!lexer.matchType("color", ast).matched) return { kind: "unsupported" }; + if (node.type === "Identifier") return { kind: "resolved", alpha: 1 }; + if (node.type === "Hash") { + const alpha = + node.value.length === 4 + ? Number.parseInt(node.value.slice(-1), 16) / 15 + : node.value.length === 8 + ? Number.parseInt(node.value.slice(-2), 16) / 255 + : 1; + return { kind: "resolved", alpha }; + } + if ( + node.type !== "Function" || + !ABSOLUTE_COLOR_FUNCTIONS.has(node.name.toLowerCase()) + ) + return { kind: "unsupported" }; + const parts = node.children.toArray(); + if ( + parts.some( + (part) => + part.type === "Function" || + (part.type === "Identifier" && part.name.toLowerCase() === "from"), + ) + ) + return { kind: "unsupported" }; + const slash = parts.findIndex( + (part) => part.type === "Operator" && part.value === "/", + ); + if (slash !== -1) return alphaValue(parts[slash + 1]); + const commas = parts.filter( + (part) => part.type === "Operator" && part.value === ",", + ).length; + return commas === 3 + ? alphaValue(parts[parts.length - 1]) + : { kind: "resolved", alpha: 1 }; + } catch { + return { kind: "unsupported" }; + } +} diff --git a/templates/design/server/lib/hubl-interpolation.spec.ts b/templates/design/server/lib/hubl-interpolation.spec.ts new file mode 100644 index 00000000000..e86a610f8b1 --- /dev/null +++ b/templates/design/server/lib/hubl-interpolation.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { parseHubLInterpolation } from "./hubl-interpolation.js"; + +function expressions(literal: string): string[] { + return [ + `{{ personalization_token('contact.firstname', '${literal}') }}`, + `{{ personalization_token("contact.firstname", "${literal}") }}`, + `{{ contact.firstname|default('${literal}', true) }}`, + `{{ contact.firstname|default("${literal}", true) }}`, + ]; +} + +describe("HubL string literals", () => { + it.each([ + [String.raw`\'`, "'"], + [String.raw`\"`, '"'], + [String.raw`\\`, "\\"], + [String.raw`\n`, "\n"], + [String.raw`\t`, "\t"], + [String.raw`\b`, "\b"], + [String.raw`\f`, "\f"], + [String.raw`\r`, "\r"], + [String.raw`\\n`, String.raw`\n`], + ["", ""], + ])("decodes Jinjava's supported escape %j", (literal, fallback) => { + for (const token of expressions(literal)) + expect(parseHubLInterpolation(token)).toMatchObject({ + name: "contact.firstname", + fallback, + }); + }); + + it.each([ + String.raw`\u0022`, + String.raw`\x22`, + String.raw`\042`, + String.raw`\v`, + String.raw`\/`, + ])("rejects escape %j unsupported by Jinjava", (literal) => { + for (const token of expressions(literal)) + expect(parseHubLInterpolation(token)).toBeUndefined(); + }); +}); diff --git a/templates/design/server/lib/hubl-interpolation.ts b/templates/design/server/lib/hubl-interpolation.ts index 95b7786a3b4..55f8ae5670b 100644 --- a/templates/design/server/lib/hubl-interpolation.ts +++ b/templates/design/server/lib/hubl-interpolation.ts @@ -1,13 +1,28 @@ -const LITERAL = "('(?:[^'\\\\]|\\\\.)*'|\"(?:[^\"\\\\]|\\\\.)*\")"; +const LITERAL = String.raw`('(?:[^'\\]|\\['"\\ntbfr])*'|"(?:[^"\\]|\\['"\\ntbfr])*")`; +const LITERAL_ESCAPES: Record = { + "'": "'", + '"': '"', + "\\": "\\", + n: "\n", + t: "\t", + b: "\b", + f: "\f", + r: "\r", +}; const DEFAULT_FILTER = new RegExp( `^\\s*\\|\\s*default\\s*\\(\\s*${LITERAL}(?:\\s*,\\s*(?:true|false))?\\s*\\)`, ); const PERSONALIZATION = new RegExp( - `^personalization_token\\s*\\(\\s*(['\"])(contact\\.[\\w.]+)\\1\\s*,\\s*${LITERAL}\\s*\\)`, + `^personalization_token\\s*\\(\\s*(['"])(contact\\.[\\w.]+)\\1\\s*,\\s*${LITERAL}\\s*\\)`, ); function literalValue(literal: string): string { - return literal.slice(1, -1).replace(/\\(['"\\])/g, "$1"); + return literal + .slice(1, -1) + .replace( + /\\(['"\\ntbfr])/g, + (_, escape: string) => LITERAL_ESCAPES[escape], + ); } export function parseHubLInterpolation(token: string): diff --git a/templates/design/server/lib/hubspot-email-export.test.ts b/templates/design/server/lib/hubspot-email-export.test.ts index 6c5040934d7..e24389fc778 100644 --- a/templates/design/server/lib/hubspot-email-export.test.ts +++ b/templates/design/server/lib/hubspot-email-export.test.ts @@ -468,6 +468,104 @@ describe("HubSpot email HTML export", () => { ); }); + it.each([ + "transparent", + "#1230", + "#11223300", + "rgba(0,0,0,0)", + "rgb(10 20 30 / 0%)", + "hsl(210 50% 40% / 0)", + "color-mix(in srgb, transparent, transparent)", + ])("rejects invisible footer text colors (%s)", (color) => { + expect(() => + prepareHubSpotEmailHtml( + withContent("Hello", `.email-footer {color:${color}}`), + ), + ).toThrow("visible footer"); + }); + + it.each(["inherit", "currentColor", "unset"])( + "inherits transparent footer text through %s", + (color) => { + const html = withContent("Hello", ".email-footer {color:transparent}") + .replace( + '', + `
`, + ) + .replace("\n ", "
\n "); + expect(() => prepareHubSpotEmailHtml(html)).toThrow("visible footer"); + }, + ); + + it.each(["#333", "initial"])( + "allows a visible child color reset (%s)", + (color) => { + const html = withContent( + "Hello", + `.email-footer {color:transparent}.email-footer p,.email-footer a {color:${color}}`, + ); + expect(prepareHubSpotEmailHtml(html).html).toContain(`color:${color}`); + }, + ); + + it("requires visible unsubscribe text even when company tokens remain visible", () => { + expect(() => + prepareHubSpotEmailHtml( + withContent("Hello", ".email-footer a {color:transparent}"), + ), + ).toThrow("visible unsubscribe"); + }); + + it.each([ + [".email-footer", "transparent"], + ["body", "rgba(0,0,0,0)"], + [".email-footer a", "#0000"], + [".email-footer p", "hsl(0 0% 0% / 0%)"], + ])( + "rejects responsive transparent footer colors (%s / %s)", + (selector, color) => { + expect(() => + prepareHubSpotEmailHtml( + withContent( + "Hello", + `@media(max-width:620px){${selector}{color:${color}!important}}`, + ), + ), + ).toThrow("Responsive CSS may hide required email footer content"); + }, + ); + + it("respects opaque inline important colors over responsive transparency", () => { + const html = withContent( + "Hello", + "@media(max-width:620px){.email-footer{color:transparent!important}}", + ).replace( + '', + '', + ); + expect(prepareHubSpotEmailHtml(html).html).toContain( + "color:#333!important", + ); + }); + + it("allows opaque child colors beneath responsive transparent ancestors", () => { + const html = withContent( + "Hello", + ".email-footer p,.email-footer a{color:#333}@media(max-width:620px){.email-footer{color:transparent!important}}", + ); + expect(prepareHubSpotEmailHtml(html).html).toContain("color:#333"); + }); + + it("rejects a responsive inheritance reset that exposes a transparent ancestor", () => { + const html = withContent( + "Hello", + ".email-footer{color:transparent}.email-footer p,.email-footer a{color:#333}@media(max-width:620px){.email-footer p{color:inherit!important}}", + ); + expect(() => prepareHubSpotEmailHtml(html)).toThrow( + "Responsive CSS may hide required email footer content", + ); + }); + it("accepts an always-visible duplicate footer but rejects mutually conditional copies", () => { const html = withContent( "Hello", diff --git a/templates/design/server/lib/hubspot-email-export.ts b/templates/design/server/lib/hubspot-email-export.ts index 8e0d8274c14..71a9776f712 100644 --- a/templates/design/server/lib/hubspot-email-export.ts +++ b/templates/design/server/lib/hubspot-email-export.ts @@ -3,6 +3,7 @@ import { ssrfSafeFetch } from "@agent-native/core/extensions/url-safety"; import { parse, serialize, type DefaultTreeAdapterTypes } from "parse5"; import postcss, { type Declaration } from "postcss"; +import { classifyEmailTextColor } from "./email-text-color.js"; import { parseHubLInterpolation } from "./hubl-interpolation.js"; type Element = DefaultTreeAdapterTypes.Element; @@ -367,6 +368,12 @@ function inlineDeclarations(element: Element) { function mayHideText(declaration: Declaration): boolean { const property = declaration.prop.toLowerCase(); const value = declaration.value.trim().toLowerCase(); + if (property === "color") { + const color = classifyEmailTextColor(value); + return color.kind === "resolved" + ? color.alpha === 0 + : color.kind !== "initial"; + } return ( (property === "display" && value === "none") || (property === "visibility" && ["hidden", "collapse"].includes(value)) || @@ -377,15 +384,34 @@ function mayHideText(declaration: Declaration): boolean { ); } +function textColorVisible( + value: string | undefined, + inherited: boolean, +): boolean { + const color = classifyEmailTextColor(value); + switch (color.kind) { + case "inherit": + return inherited; + case "initial": + return true; + case "resolved": + return color.alpha > 0; + case "unsupported": + return false; + } +} + function visibleTextByElement(document: Node): Map { const result = new Map(); const visit = ( node: Node, - inherited = { visibility: "visible", zeroFont: false }, + inherited = { visibility: "visible", zeroFont: false, colorVisible: true }, ): string => { if (node.nodeName === "#text") { const text = - inherited.visibility === "visible" && !inherited.zeroFont + inherited.visibility === "visible" && + !inherited.zeroFont && + inherited.colorVisible ? (node as DefaultTreeAdapterTypes.TextNode).value : ""; result.set(node, text); @@ -426,6 +452,7 @@ function visibleTextByElement(document: Node): Map { ? "hidden" : "visible", zeroFont, + colorVisible: textColorVisible(value("color"), inherited.colorVisible), }; } const text = @@ -477,22 +504,34 @@ function assertResponsiveFooterVisibility( } const styles = new Map>(); for (const [name, paths] of candidates) { - const alwaysVisible = paths.some((path) => - path.every((element) => { + const alwaysVisible = paths.some((path) => { + let colorVisible = true; + for (const element of [...path].reverse()) { if (!styles.has(element)) styles.set(element, inlineDeclarations(element)); - return rules.every( - (rule) => - !matchesSelector(element, rule.parts) || - rule.declarations.every((declaration) => { - const inline = styles - .get(element)! - .get(declaration.prop.toLowerCase()); - return inline?.important || (!!inline && !declaration.important); - }), + const inline = styles.get(element)!; + const parentColorVisible = colorVisible; + colorVisible = textColorVisible( + inline.get("color")?.value, + parentColorVisible, ); - }), - ); + for (const rule of rules) { + if (!matchesSelector(element, rule.parts)) continue; + for (const declaration of rule.declarations) { + const property = declaration.prop.toLowerCase(); + const winner = inline.get(property); + if (winner?.important || (!!winner && !declaration.important)) + continue; + if (property !== "color") return false; + colorVisible &&= textColorVisible( + declaration.value, + parentColorVisible, + ); + } + } + } + return colorVisible; + }); if (!alwaysVisible) invalid( `Responsive CSS may hide required email footer content (${name}). Keep an always-visible footer and unsubscribe label outside conditional hide/show sections.`, From 7807c7635ec91aafe205cf26dc84692da266700c Mon Sep 17 00:00:00 2001 From: Shawn McClelland Date: Thu, 22 Oct 2026 22:35:19 -0400 Subject: [PATCH 06/11] Harden HubSpot CSS validation and agent task handling --- ...roduction-agent.approved-tool-gone.spec.ts | 40 +++- .../core/src/agent/production-agent.spec.ts | 205 +++++++++++++----- packages/core/src/agent/production-agent.ts | 39 +++- .../server/lib/email-css-values.spec.ts | 40 ++++ .../design/server/lib/email-css-values.ts | 36 +++ .../server/lib/hubspot-email-export.test.ts | 50 ++++- .../design/server/lib/hubspot-email-export.ts | 40 +++- templates/design/shared/hubspot-email.spec.ts | 56 +++++ templates/design/shared/hubspot-email.ts | 34 ++- 9 files changed, 458 insertions(+), 82 deletions(-) create mode 100644 templates/design/server/lib/email-css-values.spec.ts create mode 100644 templates/design/server/lib/email-css-values.ts create mode 100644 templates/design/shared/hubspot-email.spec.ts diff --git a/packages/core/src/agent/production-agent.approved-tool-gone.spec.ts b/packages/core/src/agent/production-agent.approved-tool-gone.spec.ts index edf6e8dbc62..1ccd4b9adba 100644 --- a/packages/core/src/agent/production-agent.approved-tool-gone.spec.ts +++ b/packages/core/src/agent/production-agent.approved-tool-gone.spec.ts @@ -1,7 +1,14 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + import { mockEvent } from "h3"; -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { closeDbExec } from "../db/client.js"; import { withMigrationRuntime } from "../db/migration-runtime.js"; +import { SCHEMA_SNAPSHOT } from "../db/schema-snapshot.generated.js"; +import { __setSchemaSnapshotForTests } from "../db/store-registry.js"; import { runWithRequestContext } from "../server/request-context.js"; import type { AgentEngine, @@ -25,7 +32,36 @@ vi.mock("./tool-approval-store.js", async (importOriginal) => ({ const { createProductionAgentHandler, toolCallCacheKey } = await import("./production-agent.js"); -beforeAll(() => withMigrationRuntime(ensureRunTables)); +const previousDatabaseUrl = process.env.DATABASE_URL; +let databaseDirectory: string; + +beforeAll(async () => { + await closeDbExec(); + databaseDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), "approved-tool-gone-"), + ); + process.env.DATABASE_URL = `pglite:${databaseDirectory}`; + // Retain release-owned inbox schema while leaving the thread store cold. + __setSchemaSnapshotForTests({ + ...SCHEMA_SNAPSHOT, + statements: SCHEMA_SNAPSHOT.statements.filter( + (statement) => !/\bchat_threads\b/.test(statement), + ), + tables: SCHEMA_SNAPSHOT.tables.filter((table) => table !== "chat_threads"), + migrations: SCHEMA_SNAPSHOT.migrations.filter( + ([storeId]) => storeId !== "chat_threads", + ), + }); + await withMigrationRuntime(ensureRunTables); +}); +afterAll(async () => { + __setSchemaSnapshotForTests(undefined); + await closeDbExec(); + if (previousDatabaseUrl === undefined) delete process.env.DATABASE_URL; + else process.env.DATABASE_URL = previousDatabaseUrl; + if (databaseDirectory) + fs.rmSync(databaseDirectory, { recursive: true, force: true }); +}); const EMAIL = { to: "customer@example.com", body: "Your refund is approved." }; diff --git a/packages/core/src/agent/production-agent.spec.ts b/packages/core/src/agent/production-agent.spec.ts index 7b2af8aa0a8..67fe2e432e0 100644 --- a/packages/core/src/agent/production-agent.spec.ts +++ b/packages/core/src/agent/production-agent.spec.ts @@ -12169,76 +12169,165 @@ describe("runAgentLoop", () => { ); }); - it("keeps polling fresh results past the repeat threshold when the action opts out of deduping", async () => { - let streamCalls = 0; - const engine: AgentEngine = { - name: "test", - label: "Test", - defaultModel: "test-model", - supportedModels: ["test-model"], - capabilities: { - thinking: false, - promptCaching: false, - vision: false, - computerUse: false, - parallelToolCalls: true, - }, - async *stream(): AsyncIterable { - streamCalls += 1; - if (streamCalls <= 9) { + it.each(["status", "read-result"])( + "keeps bounded task %s waits fresh past the repeat threshold", + async (action) => { + let streamCalls = 0; + const engine: AgentEngine = { + name: "test", + label: "Test", + defaultModel: "test-model", + supportedModels: ["test-model"], + capabilities: { + thinking: false, + promptCaching: false, + vision: false, + computerUse: false, + parallelToolCalls: true, + }, + async *stream(): AsyncIterable { + streamCalls += 1; + if (streamCalls <= 9) { + yield { + type: "assistant-content", + parts: [ + { + type: "tool-call" as const, + id: `poll-${streamCalls}`, + name: "agent-teams", + input: { action, taskId: "task-1", waitMs: 20_000 }, + }, + ], + }; + yield { type: "stop", reason: "tool_use" }; + return; + } + yield { + type: "assistant-content", + parts: [{ type: "text" as const, text: "complete" }], + }; + yield { type: "stop", reason: "end_turn" }; + }, + }; + let polls = 0; + const pollAction = vi.fn(async () => ({ + status: ++polls === 9 ? "complete" : "running", + })); + const events: any[] = []; + + await runAgentLoop({ + engine, + model: "test-model", + systemPrompt: "system", + tools: [], + messages: [{ role: "user", content: [{ type: "text", text: "wait" }] }], + actions: { + "agent-teams": { + ...actionEntry({ + planMode: { + effect: (args) => + args.action === "status" || args.action === "read-result" + ? "read" + : "write", + }, + }), + dedupe: false, + run: pollAction, + }, + }, + send: (event) => events.push(event), + signal: new AbortController().signal, + }); + + expect(pollAction).toHaveBeenCalledTimes(9); + expect(JSON.stringify(events)).not.toContain( + "Skipped duplicate read-only", + ); + const completedPoll = events.find( + (event) => event.type === "tool_done" && event.id === "poll-9", + ); + expect(completedPoll).toBeDefined(); + expect(JSON.parse(completedPoll.result)).toEqual({ status: "complete" }); + expect(events).toContainEqual({ type: "text", text: "complete" }); + }, + ); + + it.each([ + ["export-hubspot-html", { designId: "design-1", frameId: "frame-1" }], + ["agent-teams", { action: "status", taskId: "task-1", waitMs: 0 }], + ["agent-teams", { action: "read-result", taskId: "task-1" }], + ["agent-teams", { action: "list", waitMs: 20_000 }], + ["agent-teams", { action: "status", taskId: "task-1", waitMs: 20_001 }], + ])( + "stops repeated uncached %s reads without a bounded task wait (%j)", + async (name, input) => { + let streamCalls = 0; + const run = vi.fn(async () => "fresh result"); + const events: any[] = []; + const engine: AgentEngine = { + name: "test", + label: "Test", + defaultModel: "test-model", + supportedModels: ["test-model"], + capabilities: { + thinking: false, + promptCaching: false, + vision: false, + computerUse: false, + parallelToolCalls: false, + }, + async *stream(options): AsyncIterable { + streamCalls += 1; + if (isLoopBreakerCloseout(options) || streamCalls > 9) { + yield { + type: "assistant-content", + parts: [{ type: "text" as const, text: "Finished." }], + }; + yield { type: "stop", reason: "end_turn" }; + return; + } yield { type: "assistant-content", parts: [ { type: "tool-call" as const, - id: `poll-${streamCalls}`, - name: "poll-run", - input: { id: "run-1" }, + id: `read-${streamCalls}`, + name, + input, }, ], }; yield { type: "stop", reason: "tool_use" }; - return; - } - yield { - type: "assistant-content", - parts: [{ type: "text" as const, text: "complete" }], - }; - yield { type: "stop", reason: "end_turn" }; - }, - }; - let polls = 0; - const pollAction = vi.fn(async () => ({ - status: ++polls === 9 ? "complete" : "running", - })); - const events: any[] = []; + }, + }; - await runAgentLoop({ - engine, - model: "test-model", - systemPrompt: "system", - tools: [], - messages: [{ role: "user", content: [{ type: "text", text: "wait" }] }], - actions: { - "poll-run": { - ...actionEntry({ readOnly: true }), - dedupe: false, - run: pollAction, + await runAgentLoop({ + engine, + model: "test-model", + systemPrompt: "system", + tools: [], + messages: [ + { role: "user", content: [{ type: "text", text: "check" }] }, + ], + actions: { + [name]: { ...actionEntry({ readOnly: true }), dedupe: false, run }, }, - }, - send: (event) => events.push(event), - signal: new AbortController().signal, - }); + send: (event) => events.push(event), + signal: new AbortController().signal, + }); - expect(pollAction).toHaveBeenCalledTimes(9); - expect(JSON.stringify(events)).not.toContain("Skipped duplicate read-only"); - const completedPoll = events.find( - (event) => event.type === "tool_done" && event.id === "poll-9", - ); - expect(completedPoll).toBeDefined(); - expect(JSON.parse(completedPoll.result)).toEqual({ status: "complete" }); - expect(events).toContainEqual({ type: "text", text: "complete" }); - }); + expect(run).toHaveBeenCalledTimes(MAX_IDENTICAL_TOOL_CALLS); + expect(events.filter((event) => event.type === "tool_done")).toHaveLength( + MAX_IDENTICAL_TOOL_CALLS, + ); + expect(events).toContainEqual( + expect.objectContaining({ type: "done", reason: "loop_breaker" }), + ); + expect(events).not.toContainEqual( + expect.objectContaining({ type: "error" }), + ); + }, + ); it("does not treat a suffix collision as the visible cached read result", () => { const contextMessages: EngineMessage[] = [ diff --git a/packages/core/src/agent/production-agent.ts b/packages/core/src/agent/production-agent.ts index 37241f6f0b0..68cfbf91765 100644 --- a/packages/core/src/agent/production-agent.ts +++ b/packages/core/src/agent/production-agent.ts @@ -6713,6 +6713,23 @@ function intentIsReadOnly(intent: ActionCallIntent | undefined): boolean { return intent?.valid === true && intent.readOnly === true; } +function isBoundedTaskWait(toolName: string, input: unknown): boolean { + if (toolName !== "agent-teams" || !input || typeof input !== "object") { + return false; + } + const args = input as Record; + // Only these operations honor the task reader's bounded wait contract. + return ( + (args.action === "status" || args.action === "read-result") && + typeof args.taskId === "string" && + args.taskId.trim().length > 0 && + typeof args.waitMs === "number" && + Number.isInteger(args.waitMs) && + args.waitMs > 0 && + args.waitMs <= 20_000 + ); +} + function normalizeOptionalToolPlaceholders( schema: RawJsonSchema | undefined, input: unknown, @@ -8722,7 +8739,10 @@ export async function runAgentLoop(opts: { ? actionCallIsRetrySafe(actionEntry, preparedCallArguments(intent)) : false; const repeatGuardStopFromThisCall = - actionIsReadOnly && actionEntry?.dedupe === false + actionIsReadOnly && + actionEntry?.dedupe === false && + intent?.valid === true && + isBoundedTaskWait(toolCall.name, preparedCallArguments(intent)) ? null : noteRepeatedToolCall(toolCall.name, toolCall.input, toolCall.id); const rollbackRepeatedToolCall = () => { @@ -14859,6 +14879,11 @@ export function createProductionAgentHandler( !queuedMessageClaimId && !isBackgroundWorker, ); + const queuedSubmissionStore = shouldPreserveQueuedOrder + ? await import("../chat-threads/store.js") + : undefined; + // The inbox read runs under the slot lock, but its schema must be ready first. + await queuedSubmissionStore?.ensureChatThreadTables(); slot = await tryClaimRunSlot(threadId, runId, undefined, { turnId: effectiveTurnId, ...(turnInitiator ? { turnInitiator } : {}), @@ -14875,13 +14900,13 @@ export function createProductionAgentHandler( ? { dispatchPayload: durableDispatchPayload } : {}), ...(continueOf ? { continueOf } : {}), - ...(shouldPreserveQueuedOrder + ...(queuedSubmissionStore ? { - hasPendingQueuedSubmission: async (tx) => { - const { hasPendingQueuedSubmissionReceipt } = - await import("../chat-threads/store.js"); - return hasPendingQueuedSubmissionReceipt(tx, runThreadId!); - }, + hasPendingQueuedSubmission: (tx) => + queuedSubmissionStore.hasPendingQueuedSubmissionReceipt( + tx, + runThreadId, + ), } : {}), }); diff --git a/templates/design/server/lib/email-css-values.spec.ts b/templates/design/server/lib/email-css-values.spec.ts new file mode 100644 index 00000000000..742a4105199 --- /dev/null +++ b/templates/design/server/lib/email-css-values.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { inspectEmailCssValue } from "./email-css-values.js"; + +describe("email CSS value inspection", () => { + it.each([ + "#faf9f5", + "rgba(10, 20, 30, 0.5)", + "linear-gradient(#fff, rgb(10 20 30 / 50%))", + "radial-gradient(circle, red, blue)", + '"url(https://example.test/pixel.png)"', + '"image-set(pixel.png 1x)"', + ])("preserves static CSS value %s", (value) => { + expect(inspectEmailCssValue(value)).toBe("supported"); + }); + + it.each([ + "url(https://example.test/pixel.png)", + 'url("https://example.test/pixel.png")', + 'image-set("https://example.test/pixel.png" 1x)', + '-WEBKIT-IMAGE-SET("https://example.test/pixel.png" 1x)', + 'cross-fade(red, image-set("https://example.test/pixel.png" 1x))', + 'image("https://example.test/pixel.png")', + "expression(alert(1))", + "linear-gradient(red, var(--brand))", + 'var(--image, image-set("https://example.test/pixel.png" 1x))', + ])("identifies browser or asset value %s", (value) => { + expect(inspectEmailCssValue(value)).toBe("browser"); + }); + + it.each([ + "", + ")", + "rgb(]", + "red; display:none", + "url/**/(https://example.test/pixel.png)", + ])("reports invalid CSS value %s distinctly", (value) => { + expect(inspectEmailCssValue(value)).toBe("invalid"); + }); +}); diff --git a/templates/design/server/lib/email-css-values.ts b/templates/design/server/lib/email-css-values.ts new file mode 100644 index 00000000000..1eba2b18310 --- /dev/null +++ b/templates/design/server/lib/email-css-values.ts @@ -0,0 +1,36 @@ +import { parse, walk } from "css-tree"; + +const BROWSER_FUNCTIONS = new Set([ + "var", + "expression", + "image", + "image-set", + "-webkit-image-set", +]); + +export function inspectEmailCssValue( + value: string, +): "supported" | "browser" | "invalid" { + try { + const ast = parse(value, { + context: "value", + parseCustomProperty: true, + onParseError(error) { + throw error; + }, + }); + if (ast.type !== "Value" || ast.children.isEmpty) return "invalid"; + let browser = false; + walk(ast, (node) => { + if ( + node.type === "Url" || + (node.type === "Function" && + BROWSER_FUNCTIONS.has(node.name.toLowerCase())) + ) + browser = true; + }); + return browser ? "browser" : "supported"; + } catch { + return "invalid"; + } +} diff --git a/templates/design/server/lib/hubspot-email-export.test.ts b/templates/design/server/lib/hubspot-email-export.test.ts index e24389fc778..045669570ce 100644 --- a/templates/design/server/lib/hubspot-email-export.test.ts +++ b/templates/design/server/lib/hubspot-email-export.test.ts @@ -198,12 +198,52 @@ describe("HubSpot email HTML export", () => { ["import", '@import "https://example.com/a.css";', "@import"], ["dynamic selector", ".copy:hover {color:red}", "cannot be inlined"], ["nested rules", ".copy { p {color:red} }", "Nested CSS"], + [ + "font shorthand", + ".email-footer {font:0/0 Arial}", + "explicit text styles", + ], + ["font reset", ".email-footer {all:unset}", "explicit text styles"], + [ + "offscreen text", + ".email-footer {text-indent:-9999px}", + "explicit text styles", + ], + [ + "responsive font shorthand", + "@media(max-width:620px){.email-footer{font:0/0 Arial!important}}", + "explicit text styles", + ], + [ + "responsive font reset", + "@media(max-width:620px){.email-footer{all:unset!important}}", + "explicit text styles", + ], + [ + "invalid visibility", + ".email-footer {visibility:hidden}.email-footer p,.email-footer a {visibility:bogus}", + "visibility", + ], ])("rejects unsupported %s styles", (_, css, message) => { expect(() => prepareHubSpotEmailHtml(withContent("hello", css))).toThrow( message, ); }); + it.each([ + 'image-set("https://images.example.com/banner.png" 1x)', + '-webkit-image-set("https://images.example.com/banner.png" 1x)', + ])( + "rejects unchecked CSS image functions in stylesheets and inline styles (%s)", + (value) => { + for (const html of [ + withContent("Hello", `.email-container{background-image:${value}}`), + withContent(`Hello`), + ]) + expect(() => prepareHubSpotEmailHtml(html)).toThrow("HTTPS "); + }, + ); + it.each([ ["footer", "{{ site_settings.company_name }}", "", "company_name"], [ @@ -411,8 +451,6 @@ describe("HubSpot email HTML export", () => { [".email-footer p", "font-size:0px!important"], [".email-footer a", "visibility:hidden!important"], [".email-footer a", "visibility:collapse!important"], - [".email-footer", "font:0/0 Arial!important"], - [".email-footer", "all:unset!important"], ])( "rejects responsive visibility changes affecting required content (%s %s)", (selector, css) => { @@ -468,6 +506,14 @@ describe("HubSpot email HTML export", () => { ); }); + it("preserves explicit font properties, zero indentation, and visibility resets", () => { + const html = withContent( + "Hello", + ".email-footer{font-family:Arial;font-size:14px;text-indent:0px;visibility:hidden}.email-footer p,.email-footer a{visibility:initial}", + ); + expect(prepareHubSpotEmailHtml(html).html).toContain("text-indent:0px"); + }); + it.each([ "transparent", "#1230", diff --git a/templates/design/server/lib/hubspot-email-export.ts b/templates/design/server/lib/hubspot-email-export.ts index 71a9776f712..00f5e6d3477 100644 --- a/templates/design/server/lib/hubspot-email-export.ts +++ b/templates/design/server/lib/hubspot-email-export.ts @@ -3,6 +3,7 @@ import { ssrfSafeFetch } from "@agent-native/core/extensions/url-safety"; import { parse, serialize, type DefaultTreeAdapterTypes } from "parse5"; import postcss, { type Declaration } from "postcss"; +import { inspectEmailCssValue } from "./email-css-values.js"; import { classifyEmailTextColor } from "./email-text-color.js"; import { parseHubLInterpolation } from "./hubl-interpolation.js"; @@ -308,11 +309,34 @@ function compareSpecificity(a: Specificity, b: Specificity): number { function validateDeclaration(declaration: Declaration): void { const property = declaration.prop.toLowerCase(); - const value = declaration.value.toLowerCase(); + const value = declaration.value.trim().toLowerCase(); + if (/[\\]/.test(property + value)) + invalid( + "Escaped CSS must be replaced with plain email-safe declarations before export.", + ); + const inspection = inspectEmailCssValue(declaration.value); + if (inspection === "invalid") + invalid( + `CSS "${property}: ${declaration.value}" contains an invalid value.`, + ); if ( - property.startsWith("--") || - /var\s*\(|expression\s*\(|url\s*\(/i.test(value) - ) { + ["font", "all"].includes(property) || + (property === "text-indent" && + !/^[+-]?(?:0*\.)?0+(?:[a-z%]+)?$/.test(value)) + ) + invalid( + `CSS "${property}: ${declaration.value}" cannot be validated for email text visibility. Use explicit text styles (font-size, font-family, line-height) and zero text indentation.`, + ); + if ( + property === "visibility" && + !["visible", "hidden", "collapse", "inherit", "unset", "initial"].includes( + value, + ) + ) + invalid( + `CSS visibility value "${declaration.value}" cannot be validated for email text visibility.`, + ); + if (property.startsWith("--") || inspection === "browser") { invalid( `CSS "${property}: ${declaration.value}" requires browser behavior or background assets. Use resolved values and HTTPS elements.`, ); @@ -328,10 +352,6 @@ function validateDeclaration(declaration: Declaration): void { `CSS "${property}: ${declaration.value}" is not supported for this email export. Rebuild the layout with presentation tables.`, ); } - if (/[\\]/.test(property + value)) - invalid( - "Escaped CSS must be replaced with plain email-safe declarations before export.", - ); } function parseStyles(css: string) { @@ -378,9 +398,7 @@ function mayHideText(declaration: Declaration): boolean { (property === "display" && value === "none") || (property === "visibility" && ["hidden", "collapse"].includes(value)) || (property === "opacity" && Number(value.replace(/%$/, "")) === 0) || - (property === "font-size" && - /^[+-]?(?:0*\.)?0+(?:[a-z%]+)?$/.test(value)) || - ["all", "font"].includes(property) + (property === "font-size" && /^[+-]?(?:0*\.)?0+(?:[a-z%]+)?$/.test(value)) ); } diff --git a/templates/design/shared/hubspot-email.spec.ts b/templates/design/shared/hubspot-email.spec.ts new file mode 100644 index 00000000000..0204440cae6 --- /dev/null +++ b/templates/design/shared/hubspot-email.spec.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { createHubSpotEmailHtml, isHubSpotEmailHtml } from "./hubspot-email.js"; + +const MARKER = ''; + +function documentWithHead(head: string, body = "Ordinary frame") { + return `${head}${body}`; +} + +describe("HubSpot email frame classification", () => { + it("recognizes the authored email document", () => { + expect(isHubSpotEmailHtml(createHubSpotEmailHtml())).toBe(true); + }); + + it.each([ + MARKER, + "", + "", + '', + ])("recognizes actual metadata with valid HTML attributes (%s)", (marker) => { + expect(isHubSpotEmailHtml(documentWithHead(marker))).toBe(true); + }); + + it.each([ + ``, + ``, + ``, + `${MARKER}`, + ``, + ``, + '', + "{{ \"\" }}", + "{{ \"}} \" }}", + ])("does not classify inert marker lookalikes (%s)", (head) => { + expect(isHubSpotEmailHtml(documentWithHead(head))).toBe(false); + }); + + it("requires the metadata to belong to the head", () => { + expect(isHubSpotEmailHtml(documentWithHead("", MARKER))).toBe(false); + }); + + it("does not let HubL quoted defaults corrupt head parsing", () => { + const head = + ' }} there") }}">' + + MARKER; + expect(isHubSpotEmailHtml(documentWithHead(head))).toBe(true); + }); + + it("rejects ordinary metadata", () => { + const source = documentWithHead( + '', + ); + expect(isHubSpotEmailHtml(source)).toBe(false); + }); +}); diff --git a/templates/design/shared/hubspot-email.ts b/templates/design/shared/hubspot-email.ts index 48d7d945f88..5b3cd415f5e 100644 --- a/templates/design/shared/hubspot-email.ts +++ b/templates/design/shared/hubspot-email.ts @@ -1,8 +1,38 @@ +import { parse } from "parse5"; + export const HUBSPOT_EMAIL_FRAME_TYPE = "hubspot-email"; export function isHubSpotEmailHtml(content: string): boolean { - return /]*\bname\s*=\s*["']agent-native:frame-type["'])(?=[^>]*\bcontent\s*=\s*["']hubspot-email["'])[^>]*>/i.test( - content, + // HubL string literals may contain HTML attribute delimiters or closing braces. + const document = parse( + content.replace( + /{{(?:'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"|[^'"}]|}(?!}))*}}/g, + "ANHUBL", + ), + ); + const html = document.childNodes.find( + (node) => "tagName" in node && node.tagName === "html", + ); + const head = + html && "childNodes" in html + ? html.childNodes.find( + (node) => "tagName" in node && node.tagName === "head", + ) + : undefined; + return !!( + head && + "childNodes" in head && + head.childNodes.some( + (node) => + "tagName" in node && + node.tagName === "meta" && + node.attrs + .find((attribute) => attribute.name === "name") + ?.value.toLowerCase() === "agent-native:frame-type" && + node.attrs + .find((attribute) => attribute.name === "content") + ?.value.toLowerCase() === HUBSPOT_EMAIL_FRAME_TYPE, + ) ); } From e8f6f94265ca99e07dcf70513037a4d4e16e5d9d Mon Sep 17 00:00:00 2001 From: Shawn McClelland Date: Thu, 22 Oct 2026 22:46:17 -0400 Subject: [PATCH 07/11] Align task status regression with bounded polling --- .../server/agent-chat/browser-team-tools.spec.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/core/src/server/agent-chat/browser-team-tools.spec.ts b/packages/core/src/server/agent-chat/browser-team-tools.spec.ts index 9584e19840e..60a604ea138 100644 --- a/packages/core/src/server/agent-chat/browser-team-tools.spec.ts +++ b/packages/core/src/server/agent-chat/browser-team-tools.spec.ts @@ -365,13 +365,12 @@ describe("createTeamTools bounded task reads", () => { }, ); - it("observes changed task status across identical calls in one agent turn", async () => { - let reads = 0; + it("observes changed task status across identical bounded waits in one agent turn", async () => { + let calls = 0; mocks.getTask.mockImplementation(async () => - ++reads < 10 ? task : { ...task, status: "completed", summary: "Clear" }, + calls < 10 ? task : { ...task, status: "completed", summary: "Clear" }, ); const entry = teamTool(); - let calls = 0; const observed: string[] = []; const engine: AgentEngine = { name: "test", @@ -397,7 +396,7 @@ describe("createTeamTools bounded task reads", () => { type: "tool-call", id: `status-${calls}`, name: "agent-teams", - input: { action: "status", taskId: "task-1" }, + input: { action: "status", taskId: "task-1", waitMs: 1 }, }, ], }; @@ -419,14 +418,14 @@ describe("createTeamTools bounded task reads", () => { messages: [ { role: "user", - content: [{ type: "text", text: "Check the task twice" }], + content: [{ type: "text", text: "Wait until the task completes" }], }, ], actions: { "agent-teams": entry }, send: () => {}, signal: new AbortController().signal, }); - expect(mocks.getTask).toHaveBeenCalledTimes(10); + expect(mocks.getTask.mock.calls.length).toBeGreaterThanOrEqual(10); expect(observed.map((content) => JSON.parse(content).status)).toEqual([ ...Array(9).fill("running"), "completed", From ebbb1230f05b4e82b38de86be0ad655bb68714a3 Mon Sep 17 00:00:00 2001 From: Shawn McClelland Date: Thu, 22 Oct 2026 23:01:46 -0400 Subject: [PATCH 08/11] Validate HubSpot image data and preserve responsive styles --- .../publish-agent-selection-context.spec.ts | 41 +++++++ .../publish-agent-selection-context.ts | 19 +++- .../server/lib/email-image-bytes.spec.ts | 101 ++++++++++++++++++ .../design/server/lib/email-image-bytes.ts | 87 +++++++++++++++ .../server/lib/hubspot-email-export.test.ts | 53 ++++++++- .../design/server/lib/hubspot-email-export.ts | 91 ++++++++++++---- 6 files changed, 365 insertions(+), 27 deletions(-) create mode 100644 templates/design/server/lib/email-image-bytes.spec.ts create mode 100644 templates/design/server/lib/email-image-bytes.ts diff --git a/templates/design/app/pages/design-editor/effects/publish-agent-selection-context.spec.ts b/templates/design/app/pages/design-editor/effects/publish-agent-selection-context.spec.ts index 140dcec9a14..8c6eda914d2 100644 --- a/templates/design/app/pages/design-editor/effects/publish-agent-selection-context.spec.ts +++ b/templates/design/app/pages/design-editor/effects/publish-agent-selection-context.spec.ts @@ -1,5 +1,6 @@ // @vitest-environment happy-dom +import * as hubspotEmail from "@shared/hubspot-email"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ElementInfo } from "@/components/design/types"; @@ -27,6 +28,7 @@ function argsFor(overrides: Partial = {}): Args { activeFile: { id: "screen-1", filename: "index.html", + content: "Welcome", } as Args["activeFile"], activeLeftPanel: null, activeTool: "move" as Args["activeTool"], @@ -207,6 +209,43 @@ describe("runPublishAgentSelectionContext inspector", () => { }); }); +describe("runPublishAgentSelectionContext frame classification", () => { + afterEach(() => vi.restoreAllMocks()); + + it("reuses unchanged screen classifications while reflecting edits and replacement files", () => { + const classify = vi.spyOn(hubspotEmail, "isHubSpotEmailHtml"); + const first = argsFor().activeFile!; + const second = { ...first, id: "screen-2", filename: "second.html" }; + const args = argsFor({ activeFile: first, files: [first, second] }); + + runPublishAgentSelectionContext(args); + expect(publishedSelection().activeFrameType).toBeNull(); + expect(classify).toHaveBeenCalledTimes(2); + + runPublishAgentSelectionContext({ ...args, zoom: 60 }); + runPublishAgentSelectionContext({ ...args, activeFile: second }); + expect(classify).toHaveBeenCalledTimes(2); + + second.content = hubspotEmail.createHubSpotEmailHtml(); + runPublishAgentSelectionContext({ ...args, activeFile: second }); + expect(publishedSelection().activeFrameType).toBe("hubspot-email"); + expect(publishedSelection().screens).toEqual([ + expect.objectContaining({ id: "screen-1", frameType: null }), + expect.objectContaining({ id: "screen-2", frameType: "hubspot-email" }), + ]); + expect(classify).toHaveBeenCalledTimes(3); + + const replacement = { ...second, content: first.content }; + runPublishAgentSelectionContext({ + ...args, + activeFile: replacement, + files: [first, replacement], + }); + expect(publishedSelection().activeFrameType).toBeNull(); + expect(classify).toHaveBeenCalledTimes(4); + }); +}); + function deepScreenElement(): ElementInfo { const dataUrl = `data:image/png;base64,${"A".repeat(200_000)}`; return { @@ -260,6 +299,7 @@ describe("runPublishAgentSelectionContext application state write", () => { id: `screen-${index}`, filename: `nested-${index}.html`, fileType: "html", + content: "Nested screen", })) as Args["files"]; runPublishAgentSelectionContext( argsFor({ files, selectedElement: deepScreenElement() }), @@ -316,6 +356,7 @@ describe("runPublishAgentSelectionContext application state write", () => { id: `screen-${index}`, filename: `${"long-screen-name-".repeat(4)}${index}.html`, fileType: "html", + content: "Screen", })) as Args["files"]; runPublishAgentSelectionContext(argsFor({ files })); diff --git a/templates/design/app/pages/design-editor/effects/publish-agent-selection-context.ts b/templates/design/app/pages/design-editor/effects/publish-agent-selection-context.ts index 84612cb88a5..211bb9b0939 100644 --- a/templates/design/app/pages/design-editor/effects/publish-agent-selection-context.ts +++ b/templates/design/app/pages/design-editor/effects/publish-agent-selection-context.ts @@ -57,6 +57,18 @@ const MAX_SUMMARY_CLASSES = 40; const MAX_SUMMARY_CLASS_LENGTH = 200; const MAX_SUMMARY_TEXT_LENGTH = 200; const SELECTION_PUBLISH_RETRY_DELAYS_MS = [1_000, 2_000, 4_000]; +const frameTypeByFile = new WeakMap< + DesignFile, + { content: string; frameType: "hubspot-email" | null } +>(); + +function frameTypeForFile(file: DesignFile): "hubspot-email" | null { + const cached = frameTypeByFile.get(file); + if (cached && cached.content === file.content) return cached.frameType; + const frameType = isHubSpotEmailHtml(file.content) ? "hubspot-email" : null; + frameTypeByFile.set(file, { content: file.content, frameType }); + return frameType; +} // The agent targets an element by these ids; its subtree style snapshot stays // in the frame, where copy reads it on demand. @@ -181,17 +193,14 @@ export function runPublishAgentSelectionContext({ designTitle: design?.title ?? null, activeFileId: activeFile?.id ?? null, activeFilename: activeFile?.filename ?? null, - activeFrameType: - activeFile && isHubSpotEmailHtml(activeFile.content) - ? "hubspot-email" - : null, + activeFrameType: activeFile ? frameTypeForFile(activeFile) : null, viewMode, zoom, screens: files.map((file) => ({ id: file.id, filename: file.filename, fileType: file.fileType, - frameType: isHubSpotEmailHtml(file.content) ? "hubspot-email" : null, + frameType: frameTypeForFile(file), })), selectedScreenIds, selectedElement, diff --git a/templates/design/server/lib/email-image-bytes.spec.ts b/templates/design/server/lib/email-image-bytes.spec.ts new file mode 100644 index 00000000000..bfc46101da5 --- /dev/null +++ b/templates/design/server/lib/email-image-bytes.spec.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; + +import { + MAX_EMAIL_IMAGE_BYTES, + verifyEmailImageResponse, +} from "./email-image-bytes.js"; + +const PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + "base64", +); +const GIF = Buffer.from( + "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", + "base64", +); +const JPEG = Buffer.from( + "/9j/4AAQSkZJRgABAQAAAQABAAD/4gHYSUNDX1BST0ZJTEUAAQEAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADb/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAn/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFAEBAAAAAAAAAAAAAAAAAAAAAP/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/AJVAA//Z", + "base64", +); + +function response(bytes: Uint8Array): Response { + return new Response(new Uint8Array(bytes)); +} + +describe("email image response bytes", () => { + it.each([ + ["image/png", PNG], + ["image/jpeg", JPEG], + ["image/gif", GIF], + ])("accepts complete %s bytes", async (mediaType, bytes) => { + await expect( + verifyEmailImageResponse(response(bytes), mediaType), + ).resolves.toEqual({ kind: "valid", mediaType }); + }); + + it("distinguishes an advertised type from the actual image format", async () => { + await expect( + verifyEmailImageResponse(response(GIF), "image/png"), + ).resolves.toEqual({ kind: "mime-mismatch", actual: "image/gif" }); + }); + + it.each([new Uint8Array(), Buffer.from("Sign in")])( + "rejects empty or non-image bytes despite an image label", + async (bytes) => { + await expect( + verifyEmailImageResponse(response(bytes), "image/png"), + ).resolves.toEqual({ kind: "invalid" }); + }, + ); + + it.each([ + ["image/png", PNG.subarray(0, -1)], + ["image/jpeg", JPEG.subarray(0, -1)], + ["image/gif", GIF.subarray(0, -1)], + ])("distinguishes incomplete %s bytes", async (mediaType, bytes) => { + await expect( + verifyEmailImageResponse(response(bytes), mediaType), + ).resolves.toEqual({ kind: "truncated" }); + }); + + it("rejects a complete container with invalid image dimensions", async () => { + const bytes = Buffer.from(PNG); + bytes.writeUInt32BE(0, 16); + await expect( + verifyEmailImageResponse(response(bytes), "image/png"), + ).resolves.toEqual({ kind: "invalid" }); + }); + + it("does not turn a failed body read into a valid or empty image", async () => { + const body = new ReadableStream({ + pull(controller) { + controller.error(new Error("Connection interrupted")); + }, + }); + await expect( + verifyEmailImageResponse(new Response(body), "image/png"), + ).resolves.toEqual({ kind: "unreadable" }); + }); + + it.each([true, false])( + "cancels oversized bodies with an advertised size of %s", + async (advertised) => { + let cancelled = false; + const body = new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array(MAX_EMAIL_IMAGE_BYTES + 1)); + }, + cancel() { + cancelled = true; + }, + }); + const headers = advertised + ? { "content-length": String(MAX_EMAIL_IMAGE_BYTES + 1) } + : undefined; + await expect( + verifyEmailImageResponse(new Response(body, { headers }), "image/png"), + ).resolves.toEqual({ kind: "too-large" }); + expect(cancelled).toBe(true); + }, + ); +}); diff --git a/templates/design/server/lib/email-image-bytes.ts b/templates/design/server/lib/email-image-bytes.ts new file mode 100644 index 00000000000..d63f6a43825 --- /dev/null +++ b/templates/design/server/lib/email-image-bytes.ts @@ -0,0 +1,87 @@ +import { imageSize } from "image-size"; + +export const MAX_EMAIL_IMAGE_BYTES = 5 * 1024 * 1024; + +type EmailImageMediaType = "image/png" | "image/jpeg" | "image/gif"; + +export type EmailImageBytesVerdict = + | { kind: "valid"; mediaType: EmailImageMediaType } + | { kind: "invalid" | "truncated" | "unreadable" | "too-large" } + | { kind: "mime-mismatch"; actual: EmailImageMediaType }; + +const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); +const PNG_END = Buffer.from([73, 69, 78, 68, 174, 66, 96, 130]); +const JPEG_END = Buffer.from([255, 217]); + +export async function verifyEmailImageResponse( + response: Response, + declaredType: string, +): Promise { + const declaredLength = response.headers.get("content-length"); + if ( + declaredLength !== null && + /^\d+$/.test(declaredLength) && + Number(declaredLength) > MAX_EMAIL_IMAGE_BYTES + ) { + try { + await response.body?.cancel(); + } catch { + return { kind: "unreadable" }; + } + return { kind: "too-large" }; + } + const reader = response.body?.getReader(); + if (!reader) return { kind: "invalid" }; + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_EMAIL_IMAGE_BYTES) { + await reader.cancel(); + return { kind: "too-large" }; + } + chunks.push(value); + } + } catch { + return { kind: "unreadable" }; + } finally { + reader.releaseLock(); + } + const bytes = Buffer.concat(chunks, total); + let format: "png" | "jpg" | "gif"; + let mediaType: EmailImageMediaType; + if (bytes.subarray(0, 8).equals(PNG_SIGNATURE)) { + format = "png"; + mediaType = "image/png"; + if (!bytes.subarray(-8).equals(PNG_END)) return { kind: "truncated" }; + } else if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) { + format = "jpg"; + mediaType = "image/jpeg"; + if (!bytes.subarray(-1024).includes(JPEG_END)) return { kind: "truncated" }; + } else if (/^GIF8[79]a$/.test(bytes.subarray(0, 6).toString("ascii"))) { + format = "gif"; + mediaType = "image/gif"; + if (bytes[bytes.length - 1] !== 59) return { kind: "truncated" }; + } else { + return { kind: "invalid" }; + } + try { + const dimensions = imageSize(bytes); + if ( + dimensions.type !== format || + !Number.isSafeInteger(dimensions.width) || + !Number.isSafeInteger(dimensions.height) || + dimensions.width <= 0 || + dimensions.height <= 0 + ) + return { kind: "invalid" }; + } catch { + return { kind: "invalid" }; + } + return mediaType === declaredType + ? { kind: "valid", mediaType } + : { kind: "mime-mismatch", actual: mediaType }; +} diff --git a/templates/design/server/lib/hubspot-email-export.test.ts b/templates/design/server/lib/hubspot-email-export.test.ts index 045669570ce..b15a7851556 100644 --- a/templates/design/server/lib/hubspot-email-export.test.ts +++ b/templates/design/server/lib/hubspot-email-export.test.ts @@ -13,6 +13,11 @@ import { prepareHubSpotEmailHtml, } from "./hubspot-email-export.js"; +const PNG_BYTES = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + "base64", +); + function withContent(content: string, css = ""): string { return createHubSpotEmailHtml() .replace( @@ -50,8 +55,11 @@ function findElement( describe("HubSpot email HTML export", () => { beforeEach(() => { safeFetch.mockReset(); - safeFetch.mockResolvedValue( - new Response(null, { headers: { "content-type": "image/png" } }), + safeFetch.mockImplementation( + async () => + new Response(new Uint8Array(PNG_BYTES), { + headers: { "content-type": "image/png" }, + }), ); }); @@ -110,6 +118,16 @@ describe("HubSpot email HTML export", () => { it.each([ ["script", "", "Remove