Skip to content

feat(marketing): add campaign suite with drips, blasts and email co-pilot - #137

Open
github-actions[bot] wants to merge 59 commits into
mainfrom
lewis/marketing-suite
Open

feat(marketing): add campaign suite with drips, blasts and email co-pilot#137
github-actions[bot] wants to merge 59 commits into
mainfrom
lewis/marketing-suite

Conversation

@github-actions

@github-actions github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Opened automatically when lewis/marketing-suite was pushed.

The title is written from the diff and rewritten as you push, because this is squashed onto main and the title becomes the commit subject and the changelog line. Retitle it yourself and it is yours — the automation stops touching it.


Summary by cubic

Adds a full marketing suite (drips, blasts, segments, templates, shells) with an agent co‑pilot and a secure Chrome‑based email review. Also fixes Windows agent startup by lazy‑loading puppeteer-core and tightens preview security with strict origin/DNS checks.

  • Adds agent tools for campaigns and content: create/stage campaigns, write/update graphs and nodes, preview/save segments and templates, read shells/templates/engagement, enrol contacts, send one‑off emails, and report campaign stats. A branding pass auto‑fills logo and color when marketing connects.
  • Email review renders in real Chrome via gated EMAIL_PREVIEW with desktop/mobile viewports; measures above‑the‑fold content and blocks off‑origin or unsafe requests (allows APP_URL origin and data/about URLs; DNS‑checks public hosts). Visual read uses the AI Gateway when available.
  • Resolves CHROME_EXECUTABLE_PATH with sensible defaults; the preview tool disables itself when no browser is found. Moves puppeteer-core import to point of use and marks it external in the agent build.
  • Wires the API for marketing: registers MarketingModule, verifies RESEND_WEBHOOK_SECRET, and enqueues a brand pass on connect. Adds integration/unit tests for capabilities, email review, marketing graphs, staging, and preambles.

Rollout

  • Optional: set CHROME_EXECUTABLE_PATH to enable previews; otherwise the review tool is disabled and everything else works.
  • Set APP_URL so previews can load same‑origin assets; off‑origin requests are blocked.
  • Set RESEND_WEBHOOK_SECRET and point the Resend webhook to the marketing endpoint. If API_URL changes, reconnect Resend (redirect URI: API_URL + /api/marketing/resend/callback).
  • Optional: set AI_GATEWAY_API_KEY to enable the vision‑based read; structural measurements run without it.

Written for commit 5a56c75. Summary will update on new commits.

Review in cubic

…and the eve co-pilot

The design record and the build order for Campaigns, Templates, Segments and
Settings, rewritten after the Growth call on 11 August 2026.

A blast and a drip are one table with a kind, and a blast is a campaign with
exactly one email node. Content lives on nodes, so every number in the product
is per-node with no special case — which is what analytics on every touchpoint
requires.

Branching and A/B were deferred in the first draft and are not any more. A/B is
a SPLIT node with weighted edges: no variant table, no experiment object, and it
tests whole paths rather than subject lines. Exit rules sweep continuously
rather than firing before a step, so a deal closing between touch 4 and touch 5
pulls somebody out then. Entry and exit are bulk set operations — two queries
per drip per tick at any population.

Re-entry exists, is off, and needs a cooldown and a maxPasses to turn on. Drips
can drain: stop new entries, let the people inside finish. Archive refuses while
anybody is walking.

The co-pilot is eve through the existing agent panel, on three new record kinds
— campaign, segment and template — with approval as an eve policy rather than a
flag. Not a second agent and not a second transcript store.

Mock-ups are in Paper, file CRM, page /marketing.
…nd the renderer

Phase 0 of docs/plan/marketing-suite-build.md.

Twelve models and fourteen enums. A blast is a campaign with exactly one EMAIL
node, so content lives on nodes and every number is per-node with no special
case. @@unique([nodeId, recipientId, pass]) is the idempotency of everything —
the pass column is what lets somebody walk a drip a second time without the
constraint silently swallowing every send.

@crm/db/marketing:

- segments.ts exports exactly two functions. matches() is one line over
  compile(), so the per-contact evaluator a branch needs cannot drift from the
  set query a segment needs.
- graph.ts refuses a cycle, a second root, a branch with a missing arm, and a
  branch on "opened" when open tracking is off — that arm can never fire, so it
  is dead code rather than a warning. Split assignment is hashed off the
  enrolment so a retried tick cannot reassign an arm.
- drips.ts sweeps exits and entries as bulk set operations, two queries per drip
  per tick at any population, and stamps exitKind so re-entry can tell a goal
  from a suppression.
- queue.ts claims with FOR UPDATE SKIP LOCKED and leases, so two drains send
  each row once.

@crm/email renders once, on the server, for both the preview and the transport.
The unsubscribe link and the postal address are appended by the compiler and are
not blocks, so no author can delete them. Merge tags resolve to strings before
the element tree exists, so nothing model-authored reaches a component.

normalizeEmail moves to @crm/db/values and the API re-exports it. One
canonicaliser, now reachable from the packages that need it.

TrackedEvent gains its visitor relation, which the visited-a-path facet needs.
Phases 1 and 4 of the build plan, and the parts of 6 the engine needs.

Sending lives in Nest, beside the mailbox pipeline. Resend is a mail provider,
not a data vendor: it decides nothing about a person, so this is not the
intelligence rule bending.

- resend.service.ts checks result.error and never try/catches the SDK's own
  errors into silence. A missing key returns null — an absent capability, not a
  thrown error.
- marketing-compose.service.ts is the only place a send becomes bytes. It
  appends the postal address and the unsubscribe link, and emits the
  List-Unsubscribe and List-Unsubscribe-Post headers bulk senders now need.
  No APP_URL means no send, rather than a link to nowhere.
- marketing-drain.service.ts runs the five ordered stages: replies, exits,
  entries, advance, send. Replies land first so an exit rule can see one that
  arrived thirty seconds ago. Attachments route to single sends; everything
  else batches at 100.
- POST /internal/marketing/drain takes CRON_SECRET or AGENT_BRIDGE_SECRET.
  Neither set refuses rather than opening.
- The webhook reads the raw body and verifies through the SDK. An event for a
  send we do not have is dropped and logged, not an error.

Four routers: marketing, marketingCampaigns, marketingSegments,
marketingTemplates. A campaign is created with its first node in one
transaction, so no code downstream has to handle a campaign with no nodes.

Archiving refuses while anybody is mid-flow, resume restarts the clocks rather
than firing weeks of backlog, and declaring an A/B winner reports how many
people stay on the losing arm rather than moving them.
…d setup

Phases 1, 3, 4 and 7 of the build plan, built against the Paper mock-ups.

Navigation follows §2: nothing redirects. The app layout resolves marketing
status once, on the WorkspaceHeader pattern already in that file, and hands the
icon rail a boolean — the Marketing link points at the wizard until setup is
finished. The read is caught and defaults to onboarded, because the rail renders
above every screen in the CRM and must not fail with this feature.

Three layouts, and the rule is editor-not-list. The four list pages keep the
marketing sidebar; the drip canvas drops it and keeps the icon rail, because a
canvas, a chat rail and a node sheet all want width. The wizard is a sibling
route group under app/, so it inherits no chrome at all.

The canvas is @xyflow/react wrapped in packages/ui. Every --xy-* variable is
mapped to a token in flow-tokens.css and nowhere else, including the
selected-edge stroke and the handle colours — the states a screenshot misses.
Five node components on the shadcn primitives; dragging persists x/y.

Clicking a node opens a wide sheet, and half of it is the live render from the
server — the same renderEmail the transport calls, in an iframe, with
desktop/mobile/plain-text. The block list is bookended by two locked shell rows,
because "not editable" and "not shown" are different things.

The co-pilot rail is the existing agent panel on a new record kind. agent-record
gains campaign, segment and template, so every rule in docs/agent-panel.md
applies unchanged rather than being rediscovered.

New-record actions are sheets, matching the rest of the CRM.

Prisma's recursive JsonValue is bounded at the API edge, and the recursive
filter tree is bounded at the wire and parsed in the service. Both were making
tRPC inference blow the depth limit in the client.
The graph specs cover the two rules that were risks in the plan: a branch on
"opened" is a hard error when open tracking is off, because that arm can never
fire and is dead code, and a warning when it is on, because Apple prefetches.
Split assignment is asserted stable across calls and roughly even across a
population.

The engine specs run against Postgres and found a real bug. dueAt is a naive
timestamp, so comparing it to now() inside the claim query resolved against the
session timezone and claimed nothing. The clock is now bound as a parameter, the
same way the column is written.

They also assert the re-entry table row by row, because each one is a way to
mail somebody twice: an active enrolment blocks a second, maxPasses holds, a
suppressed exit blocks re-entry forever, and pass 2 may send the touch pass 1
already sent — the assertion that proves the column earns its place.

The seed writes a default header, a default footer and two starter templates, so
the first preview is not an empty box.

docs/api.md now names mail as the second documented exception to "no vendor
client in Nest", and says why: a mail provider carries a message somebody wrote,
a data vendor forms an opinion about somebody. The rule is about the second.
…s record kinds

Two fixes, one of them mine.

The unsubscribe page no longer opts somebody out on load. Gmail, Outlook and
corporate link scanners fetch every URL in a message before a human sees it, so
a GET that writes unsubscribes people who never clicked — and they then stop
getting mail they asked for with nobody the wiser. The page now shows one button
that does it. That is not an "are you sure"; it is the difference between a
person acting and a robot acting. The List-Unsubscribe header still points at
the POST, which writes immediately and returns an empty 200, because scanners do
not POST. docs/plan/marketing-suite.md §15 is corrected to match.

The conversations service only knew contact, company and deal, so the co-pilot
rail could not list or start a thread against a campaign. The record scope is
now six kinds through every site that threads it — the list filter, the save
path, the ownership check and the select. I caused this by adding the record
kinds to the client without the server half.
Phase 9. The co-pilot rail already rendered; now it can do something.

Every write goes through @crm/db/marketing, so no tool reimplements a rule and
none holds a transport. write_template refuses lint errors back to the model and
write_campaign_graph refuses validateGraph's problems, both with the problems
attached — that loop is the whole reason the linter is a function rather than a
screen.

Approval is an eve policy, not a flag. write_campaign_graph is silent on a
draft, asks a person on a live drip, and denies an autonomous principal with a
reason rather than parking a run nobody can answer. send_email and enrol_contact
use the sensitiveWrite helper that already existed for this.

There is no activate_campaign tool. The absence is the control — an approval
prompt can be clicked through and a missing tool cannot.

The skill carries what the tool descriptions cannot: four or five touches over
two to three weeks, branch once near the middle, never branch on an open because
Apple opens everything, set the exit rules, and preview a segment before saving
it so the rep hears the count.

Also adds the plan for the remaining work in docs/plan/marketing-suite-remaining.md.
…ility pause

The rail was 360px and did not bound its own height, so a long answer — an
ask_question with four option cards, which is exactly what the co-pilot does
when it needs to know the audience — pushed the submit button off the bottom
with no way to reach it. It is 440px now and clips its overflow, so the panel's
own scroller does its job.

The node sheet had the same unbounded height and is fixed the same way.

list_segments and list_templates took an empty object as their input schema,
which some model APIs will not call. Both now take a limit, which they should
have had anyway.

pauseUnhealthy stops a campaign that is bouncing over 5% or drawing complaints
past 0.3%, skips its queued sends, and records why in words a marketer can act
on. It ignores anything under fifty sends, because a rate over three recipients
is not a rate. This is the one place the product overrules the marketer: the
damage lands on the domain every other campaign shares and outlasts the campaign
doing it. Three specs, including that a healthy campaign and a tiny one are both
left alone.
… RSC bug

STEPS was a plain array exported from a "use client" module and imported by the
server page. Only components survive that boundary, so STEPS.some was not a
function and every setup URL threw. It lives in its own module now, imported by
both halves.

Twenty-five properties in the allowlist first, then the rollup that fills them.
Counts and booleans only — never a subject, a segment name, a rule, a recipient,
a domain, a key, and never an open or click rate, only totals. The tool
allowlist spec caught the fourteen new agent tools, which is the check working.

marketing_drip_nodes_max, _with_branch and _with_split exist to answer whether
the canvas and the two un-deferred features earn their keep, and
marketing_copilot_graphs is the only honest measure of whether the chat is the
feature or the decoration.

docs/marketing.md is the operating doc a future change reads first, and it is in
the AGENTS.md index. It carries the rules that break quietly: one compiler and
why a per-contact tree walker must not be written, entry and exit as set
operations, the shell not being the author's, Resend's answer beating ours, the
two numbers that lie, and why claimDueSends binds the clock rather than calling
now().

The drip skill gains what the linter cannot encode: which rules are already
enforced so a refusal is fixed in one go, and that a bought list and "they are
in a segment" are never consent.
…scale

The wizard's Test step sends an email now. It goes through queueDirect and the
drain — the same path a campaign takes, the same renderer, the same footer — and
Continue stays disabled until it lands. Finish stamping the date without proving
anything was a lie the moment somebody trusted it.

The segment builder: a rule tree in packages/ui over the facet whitelist, the
live count as you edit, twenty sample people, and the line saying that editing
rules changes who enters next rather than removing anybody already in a drip.
Rows on the list open it.

Text follows the app's scale. The CRM uses text-xs, text-sm and PageShell's
title sizes; only the landing pages use literal pixel sizes, and I had picked up
that habit for seventeen spots in the marketing suite. All of them are on the
scale now.

The wizard's logo is the real Logo component and sits on the stepper line.

Two more Prisma JsonValue leaks bounded at the API edge. Recursive JsonValue
reaching a client component blows tRPC's inference depth, and it will keep
happening on every new JSON column that crosses the wire.
Body copy, empty states, wizard blurbs, node stats, settings descriptions and
the unsubscribe page all sit on text-xs now. Headings keep their size — a
heading at text-xs is not a heading — but the wizard's title drops its md:text-3xl
step and the unsubscribe title comes down to text-lg.
… dead-end

Two things the wizard got wrong.

You can use a domain you already verified in Resend. The step lists what Resend
has and lets you pick one; adding a new subdomain is the secondary path, behind
a disclosure. Forcing domains.create on somebody who already sends from a
verified domain was making them create a second one for no reason. The
three-label subdomain check is gone with it — that was our opinion, not Resend's
rule.

Continue on the Domain step now waits for the domain to be verified, with a
Check again button and the records to paste. Letting somebody walk past a broken
step and then telling them at the end to go back is worse than stopping them
where the problem is.

The template editor: block list with add, reorder, edit and delete, beside the
live server render and the lint panel. The shell shows as two locked rows at the
top and bottom, the same as the node sheet, so what the email opens with is
visible in the place somebody would otherwise try to change it. Rows on the
templates list open it.

Two more JsonValue leaks bounded — templates.byId and segments.byId.
…heets

The wizard asked for a from address on the Identity step and then asked which
domain to send from on the next one, so anybody who had a stale sending domain
saved got told their address was wrong for a domain they had not chosen yet.
Domain now comes second and Identity third, the from address is pre-filled as
hello@<the domain you just picked>, and the field says which domain Resend will
accept.

The check itself was too strict as well: it demanded an exact domain match and
refused a subdomain of the sending domain, which Resend accepts.

Segments and templates get create sheets, so the two lists lead somewhere. Both
land you straight in the editor.
A campaign with kind BLAST opens the composer rather than the canvas: segment
picker, subject with the mobile-cut count, preheader, the block editor with the
locked shell rows, and the live server render beside it.

The audience footer is the sentence the plan asked for — how many will receive
it, how many are excluded, and how many are in the segment — computed live from
the segment rather than from totalRecipients, which is zero until the moment you
schedule. Send now is disabled while there are lint errors, no segment, or
unsaved edits, so the button cannot lie about what it will do.

Results replaces the composer once it has sent: delivered, bounced and complaint
rates with the thresholds called out in red when they are crossed, then opened,
clicked, replied and unsubscribed. The Apple caveat sits under the open rate
rather than in a tooltip, because a number shown without it is a number somebody
makes a decision on.
…o ask

Pause, resume, drain and archive on the canvas, behind one menu.

Resume asks about the clocks, because it has to. A drip paused for three weeks
has people whose next email is overdue, and the obvious implementation sends all
of them on the next tick — a spike into a spam folder. Restart is the default
and the dialog says what the other choice does.

Archive shows how many people are still walking it and offers the two real
answers: let them finish, or stop them now and cancel the emails they have not
had. It does not offer a force flag, because "archive anyway" is not a decision
anybody can make well without knowing the count.
The domain step was a picker that fought you. Whatever was already saved kept
winning, and there was no way to just say "use this one".

It is a text field now. Type a domain, press Use it, and setDomain looks it up
in Resend by name: if Resend has it we adopt it and we are done; if Resend does
not, we say so and offer to add it and show the records. The field starts from
whatever is currently saved so you can see what it is and change it.

Domains Resend already has are one-click chips under the field rather than a
select that decides for you. I caused this.
…actions

The split inspector shows both arms with their sends, clicks and replies, and
the three readiness states the plan specified: too early under a hundred per
arm, inside the noise when they are close, and a winner when reply rate
separates them. Declaring one reweights the edges and says how many people stay
on the losing arm rather than moving them. Judged on replies then clicks, never
on opens — the note saying why is on the panel.

Re-entry has a UI. It is off, turning it on asks for both a cooldown and a
maximum, and the copy explains that maxPasses is the one that stops the
disaster: a cooldown alone lets somebody who drifts in and out of a segment
receive the campaign forever, with every individual send looking correct.

Edges carry their in-flight count, so the canvas answers "how many people are
between touch three and four" by looking at it.

Contacts get a Marketing menu: send one email from a template, or enrol in a
live drip. Both refuse with the reason when the address is suppressed, and only
activated campaigns are listed.
Attachments upload raw bytes to Blob keyed by content hash, enforce the 40 MB
total at upload, and are absent rather than broken without
BLOB_READ_WRITE_TOKEN. The drain carries them to Resend as single sends,
because the batch endpoint refuses attachments — which is why the linter warns
about attaching anything to a send over fifty people.

The brand pass is a new marketing-brand DIRECT_KIND, queued when the Resend key
is saved. It reads the workspace's own domain through Context.dev, mirrors the
logo to Blob, refuses an SVG because Outlook will not draw one, and darkens the
brand colour until it clears 4.5:1 on white. Every step is optional: no Context
key, no logo, an SVG-only brand, or a colour that cannot be fixed each fall back
to the wordmark and none of them throw.
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
crm-agent Ready Ready Preview Aug 14, 2026 12:10am
crm-api Ready Ready Preview Aug 14, 2026 12:10am
crm-app Ready Ready Preview Aug 14, 2026 12:10am

Request Review

…ip to look at

Branch conditions are editable by hand. The inspector uses the same rule tree
the segment builder uses — a branch condition is a segment applied to one
person, so it is the same component over the same facets — and warns when
somebody picks an open, because Apple opens everything.

Attachments have an upload panel: pick a file, see the running total against the
40 MB Resend refuses, and the warning that attachments cannot be batched once
the audience is over fifty.

The facet map moves to apps/app/lib so both editors share one list rather than
drifting into two.

A ten-node branching drip is seeded — A/B split, two first touches, a wait, a
branch on clicked, two second touches, an exit and a third touch. It validates
with zero errors and zero warnings under both tracking states, and a contract
check confirms every edge lands on a handle its node component actually renders
and no two nodes share a position. That is the canvas exercised against real
rows rather than assumed.
… opening

MarketingEvent joins the existing tracking retention sweep at 90 days, with
BOUNCED, COMPLAINED and UNSUBSCRIBED exempt — CASL wants three years of those
and they are the evidence behind a suppression. Two specs: the three survive a
sweep that removes opens, clicks and deliveries, and anything inside the window
is untouched.

The overview stopped being a second copy of the campaigns table. It answers the
four questions somebody opening the section has: what is running, how many
people are mid-drip, how much went out in thirty days, and is the bounce rate a
problem. Below that, the five most recently touched campaigns, and the setup
prompt when sending is not configured yet.

Open rate is deliberately absent from it. Apple opens every email before a
person does, so a headline open rate is a number nobody should plan against —
the note on the page says so rather than leaving somebody to wonder where it
went.
@crm/email declared react as a dependency, so adding it to apps/agent pulled
react into the agent's install graph. It is a peer now — the app and the API
render and provide it; the agent only imports the document schema, the linter
and the theme, none of which touch react.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

15 issues found across 179 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/app/lib/agent-record.ts">

<violation number="1" location="apps/app/lib/agent-record.ts:70">
P1: Campaign co-pilot conversations cannot be saved: this mapping emits `campaignId`, but the save path drops that field and submits an unscoped conversation, which the API rejects. The new record scope fields need to be propagated through `recordFilter` and the conversation save payload (including segment and template scopes).</violation>
</file>

<file name="apps/agent/agent/tools/send_email.ts">

<violation number="1" location="apps/agent/agent/tools/send_email.ts:17">
P1: This tool can queue a direct send before marketing setup is complete, despite the documented send gate. With no API key the due row remains queued and can be sent unexpectedly when setup is completed later; checking `assertSendable` before queueing would return the setup reason instead.</violation>
</file>

<file name="apps/agent/agent/tools/write_campaign_graph.ts">

<violation number="1" location="apps/agent/agent/tools/write_campaign_graph.ts:18">
P1: An `EMAIL` with `document: {}` is accepted and can be sent as a blank email because graph validation only checks object truthiness and rendering falls back to an empty document. Validating the document with the shared email-document schema (and linting it) before persistence would reject malformed campaign content.</violation>

<violation number="2" location="apps/agent/agent/tools/write_campaign_graph.ts:60">
P1: Automated agents can rewrite a `DRAINING` campaign without approval even though active enrolments may still be standing on nodes that the replacement deletes. Protect draining and other non-draft states, and preserve or move active enrolments before replacing their nodes.</violation>

<violation number="3" location="apps/agent/agent/tools/write_campaign_graph.ts:71">
P1: A graph write can mutate a node belonging to another campaign when a supplied ID comes from that campaign, causing cross-campaign content and graph corruption. Checking every existing node ID belongs to `campaignId` and scoping updates to that campaign would prevent this.</violation>
</file>

<file name="apps/app/app/(app)/[slug]/(marketing-editor)/marketing/segments/[segmentId]/facets.ts">

<violation number="1" location="apps/app/app/(app)/[slug]/(marketing-editor)/marketing/segments/[segmentId]/facets.ts:161">
P1: Editing a segment with a nested `all`/`any` or `not` filter silently drops that branch because loading only supports direct facet children. The editor should round-trip the recursive filter tree or prevent saving definitions it cannot represent.</violation>
</file>

<file name="packages/db/src/marketing/graph.ts">

<violation number="1" location="packages/db/src/marketing/graph.ts:184">
P1: Template-only email nodes can activate but cannot be rendered by the drip executor: `templateId` is discarded before the send is created, leaving subject and document null. Activation and execution should use the same contract by resolving the template in the executor or rejecting template-only nodes here.</violation>

<violation number="2" location="packages/db/src/marketing/graph.ts:258">
P1: A negated opened condition is incorrectly rejected when open tracking is off. Tracking-off validation should account for filter polarity (or at least allow `not(opened)`), because the runtime evaluates it as true for every contact rather than making the yes path unreachable.</violation>
</file>

<file name="packages/db/src/marketing/settings.ts">

<violation number="1" location="packages/db/src/marketing/settings.ts:5">
P1: Campaigns can exceed the configured daily cap and send during quiet hours because these settings are persisted but never enforced by the send path. The settings contract should be wired into materialisation/claiming before exposing these controls as effective limits.</violation>
</file>

<file name="packages/db/src/marketing/recipients.ts">

<violation number="1" location="packages/db/src/marketing/recipients.ts:85">
P1: Opting out does not stop already-queued marketing sends: the drain can still claim and deliver blast or direct rows after `suppress` or `unsubscribeByToken` marks the recipient non-sendable. Cancelling queued rows as part of both state transitions, and rechecking recipient status when claiming, would prevent post-opt-out delivery.</violation>
</file>

<file name="apps/api/src/marketing/marketing-templates.service.ts">

<violation number="1" location="apps/api/src/marketing/marketing-templates.service.ts:108">
P1: Creating a template with malformed document JSON succeeds, and sending it produces an empty body instead of refusing the template. Validate with `emailDocument.safeParse` in `create` and persist the parsed document, matching `update`.</violation>
</file>

<file name="packages/db/prisma/migrations/20260812003719_tracked_event_visitor_relation/migration.sql">

<violation number="1" location="packages/db/prisma/migrations/20260812003719_tracked_event_visitor_relation/migration.sql:2">
P1: Anonymous page-view and click collection will stop storing events after this migration because their `visitorId` has no corresponding `trackedVisitor` row. The relation needs to be made compatible with the ingestion path by creating/upserting the visitor before `createMany`, or by removing/relaxing this constraint after cleaning or backfilling existing orphan events.</violation>
</file>

<file name="apps/app/app/(app)/[slug]/(marketing-editor)/marketing/segments/[segmentId]/segment-builder.tsx">

<violation number="1" location="apps/app/app/(app)/[slug]/(marketing-editor)/marketing/segments/[segmentId]/segment-builder.tsx:106">
P1: Removing all rules does not actually clear the saved segment definition: the UI reports success, but reopening the segment restores the old rules. The update path should explicitly persist a JSON null/clear the definition when `definition` is null.</violation>
</file>

<file name="apps/app/app/(app)/[slug]/(marketing-editor)/marketing/campaigns/[campaignId]/logic-sheet.tsx">

<violation number="1" location="apps/app/app/(app)/[slug]/(marketing-editor)/marketing/campaigns/[campaignId]/logic-sheet.tsx:43">
P1: Switching between wait nodes can save the previously selected node’s delay onto the newly selected node, changing the wrong campaign step; syncing `hours` when `node.id` or `node.delayHours` changes would keep the editor bound to the selected node.</violation>
</file>

<file name="packages/db/src/marketing/queue.ts">

<violation number="1" location="packages/db/src/marketing/queue.ts:107">
P1: Configured daily send caps are ignored, allowing a campaign to queue recipients who already exhausted the workspace's daily limit. Materialisation should apply the configured cap before marking a row `QUEUED` and preserve the `daily-cap` reason for excluded rows.</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

@@ -53,6 +65,45 @@ const COPY: Record<AgentRecordKind, RecordCopy> = {
"What is the risk here?",
],
},
campaign: {
header: "x-crm-campaign",
field: "campaignId",

@cubic-dev-ai cubic-dev-ai Bot Aug 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Campaign co-pilot conversations cannot be saved: this mapping emits campaignId, but the save path drops that field and submits an unscoped conversation, which the API rejects. The new record scope fields need to be propagated through recordFilter and the conversation save payload (including segment and template scopes).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/app/lib/agent-record.ts, line 70:

<comment>Campaign co-pilot conversations cannot be saved: this mapping emits `campaignId`, but the save path drops that field and submits an unscoped conversation, which the API rejects. The new record scope fields need to be propagated through `recordFilter` and the conversation save payload (including segment and template scopes).</comment>

<file context>
@@ -53,6 +65,45 @@ const COPY: Record<AgentRecordKind, RecordCopy> = {
 	},
+	campaign: {
+		header: "x-crm-campaign",
+		field: "campaignId",
+		title: "Build this campaign",
+		blurb:
</file context>
Fix with cubic

Comment thread apps/agent/agent/lib/marketing.ts Outdated
Comment thread packages/db/src/marketing/recipients.ts Outdated
const normalized = normalizeEmail(address);
if (!normalized) return;

await db.marketingRecipient.updateMany({

@cubic-dev-ai cubic-dev-ai Bot Aug 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Opting out does not stop already-queued marketing sends: the drain can still claim and deliver blast or direct rows after suppress or unsubscribeByToken marks the recipient non-sendable. Cancelling queued rows as part of both state transitions, and rechecking recipient status when claiming, would prevent post-opt-out delivery.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/db/src/marketing/recipients.ts, line 85:

<comment>Opting out does not stop already-queued marketing sends: the drain can still claim and deliver blast or direct rows after `suppress` or `unsubscribeByToken` marks the recipient non-sendable. Cancelling queued rows as part of both state transitions, and rechecking recipient status when claiming, would prevent post-opt-out delivery.</comment>

<file context>
@@ -0,0 +1,116 @@
+	const normalized = normalizeEmail(address);
+	if (!normalized) return;
+
+	await db.marketingRecipient.updateMany({
+		where: { address: normalized },
+		data: { status, statusReason: reason ?? null, statusAt: new Date() },
</file context>
Fix with cubic

Comment thread packages/db/src/marketing/queue.ts Outdated
select: { status: true },
});

if (campaign?.status !== "ACTIVE") return "not-applicable";

@cubic-dev-ai cubic-dev-ai Bot Aug 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Automated agents can rewrite a DRAINING campaign without approval even though active enrolments may still be standing on nodes that the replacement deletes. Protect draining and other non-draft states, and preserve or move active enrolments before replacing their nodes.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/tools/write_campaign_graph.ts, line 60:

<comment>Automated agents can rewrite a `DRAINING` campaign without approval even though active enrolments may still be standing on nodes that the replacement deletes. Protect draining and other non-draft states, and preserve or move active enrolments before replacing their nodes.</comment>

<file context>
@@ -0,0 +1,77 @@
+			select: { status: true },
+		});
+
+		if (campaign?.status !== "ACTIVE") return "not-applicable";
+
+		return isAutomated(session)
</file context>
Fix with cubic

Comment thread apps/api/src/marketing/marketing.controller.ts
Comment thread apps/api/src/marketing/resend.service.ts Outdated
Comment thread packages/email/src/document.ts Outdated
match,
rules: list
.map((entry, index) =>
toRule((entry as { facet?: unknown } | null)?.facet, index),

@cubic-dev-ai cubic-dev-ai Bot Aug 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Editing a segment with a nested all/any or not filter silently drops that branch because loading only supports direct facet children. The editor should round-trip the recursive filter tree or prevent saving definitions it cannot represent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/app/app/(app)/[slug]/(marketing-editor)/marketing/segments/[segmentId]/facets.ts, line 161:

<comment>Editing a segment with a nested `all`/`any` or `not` filter silently drops that branch because loading only supports direct facet children. The editor should round-trip the recursive filter tree or prevent saving definitions it cannot represent.</comment>

<file context>
@@ -0,0 +1,185 @@
+			match,
+			rules: list
+				.map((entry, index) =>
+					toRule((entry as { facet?: unknown } | null)?.facet, index),
+				)
+				.filter((rule): rule is RuleRow => rule !== null),
</file context>
Fix with cubic

…a real email

POST /internal/marketing/drain had no @AllowAnonymous(), so better-auth's global
guard returned 401 before the controller's own secret check ever ran. Cron could
never have called it, and neither could the agent's poke. The route now reaches
its own check, which is the thing that actually decides.

With that fixed the whole path is proven against the live service: a test send
queued through queueDirect, composed by the same renderer the preview uses, and
accepted by Resend with a provider id. Domain mail.trycomp.ai verified, tracking
off, postal address and unsubscribe link present in the bytes, merge tags
resolved, List-Unsubscribe and List-Unsubscribe-Post emitted.

Attachments are on the drip node sheet as well as the blast composer.
Eight specs that server-render every node kind the canvas draws. They check the
things that would be silently wrong: an email node turns 604 sent and 21 replies
into "51% open" and "3% reply" rather than raw counts, an empty node says "No
subject yet" and shows an em dash instead of dividing by zero, a branch renders
handles named yes and no, a split renders a and b, and an exit renders no source
handle because it is the end.

That is the piece I had built and never seen produce markup. It produces markup.
…itor and to the agent

The logo is drawn by the renderer from marketingLogoUrl, not by the header
document, so the document really is empty and the agent was right to say so —
while the preview showed a logo. Both now tell the same story.

The header editor shows a locked Brand line row above Add block, naming the
logo or the wordmark. read_shell returns what the compiler already draws, and
the preamble says an empty document is normal and correct: never add a second
logo, never a second unsubscribe link.
…ritten down

The block union was undiscriminated, so every bad document came back as
'blocks.0 — Invalid input'. An agent had no way to learn that a text field is
an array of runs rather than a string, and probed the linter twenty times
without ever being told.

The union is discriminated on type, documentProblems reports the Zod path and
message, and write_template, write_campaign_graph and write_shell return those
problems with BLOCK_SHAPES. A new writing-an-email skill holds the vocabulary
and every write tool names it in its first sentence.
The indicator read the dirty flag, which is false on load and false again after
every autosave — so an editor you had not touched said Saved, and one you had
finished editing said Saved forever.

useSaveStatus follows the mutation instead: Saving while it is in flight, Saved
for two seconds after it lands, and nothing the rest of the time.
…ut it

The hook watched the draft object by reference, and the editors build a fresh
one every render. Each save invalidated its query, which re-rendered, which
looked like a change, which saved again — forever, with a Saved toast each time.

It compares a stable key of the draft instead, and only writes when that key
actually moves. The success toasts are gone: an autosave is silent and the
inline Saving/Saved indicator is the whole feedback.
The Add step dropdown is gone from the header. Right-click anywhere on the
drip canvas instead: Email, Wait, Branch or Exit, appended after the selected
node or the end of the chain, through the same validator the co-pilot uses.

Adds a shared ContextMenu to packages/ui, and FlowCanvas takes a menu so any
canvas can carry one.
… canvas

Introduces a new results view in the CampaignCanvas component, allowing users to toggle between flow and results. The results view displays a summary of the campaign, including nodes, edges, and any warnings. Additionally, a new AgentGraphResult component is created to present the campaign summary, enhancing the user experience in the marketing editor.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 37 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/app/app/(app)/[slug]/(marketing-editor)/marketing/templates/[templateId]/template-editor.tsx">

<violation number="1" location="apps/app/app/(app)/[slug]/(marketing-editor)/marketing/templates/[templateId]/template-editor.tsx:201">
P1: After the first edit in a template, this status-only replacement leaves no working persistence path. `useAutosave` treats the transition from `dirty=false` to `dirty=true` as its initial baseline, so one edit is never saved unless the user makes another edit. Initialize the autosave baseline from the loaded value or flush on the first dirty transition before removing the manual save path.</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic
Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<span className="text-muted-foreground text-xs">

@cubic-dev-ai cubic-dev-ai Bot Aug 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: After the first edit in a template, this status-only replacement leaves no working persistence path. useAutosave treats the transition from dirty=false to dirty=true as its initial baseline, so one edit is never saved unless the user makes another edit. Initialize the autosave baseline from the loaded value or flush on the first dirty transition before removing the manual save path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/app/app/(app)/[slug]/(marketing-editor)/marketing/templates/[templateId]/template-editor.tsx, line 201:

<comment>After the first edit in a template, this status-only replacement leaves no working persistence path. `useAutosave` treats the transition from `dirty=false` to `dirty=true` as its initial baseline, so one edit is never saved unless the user makes another edit. Initialize the autosave baseline from the loaded value or flush on the first dirty transition before removing the manual save path.</comment>

<file context>
@@ -193,22 +198,9 @@ export function TemplateEditor({ templateId }: { templateId: string }) {
-						{save.isPending ? <Spinner /> : null}
-						Save
-					</Button>
+					<span className="text-muted-foreground text-xs">
+						{saveLabel(status)}
+					</span>
</file context>
Fix with cubic

Comment thread packages/ui/src/hooks/use-autosave.ts Outdated
Comment thread packages/ui/src/hooks/use-autosave.ts Outdated
Comment thread packages/email/src/document.ts Outdated
Comment thread packages/ui/test/autosave.spec.ts Outdated
Comment thread packages/ui/test/autosave.spec.ts Outdated
Comment thread apps/api/src/marketing/marketing-templates.service.ts Outdated
- Updated the campaign creation and update processes to support multiple segment IDs and exclusion lists, improving audience targeting capabilities.
- Refactored the marketing campaign and segment services to handle new segment structures, including mode handling for inclusion and exclusion.
- Introduced a new MarketingActivityService to log email sends, ensuring better tracking of marketing activities.
- Added new endpoints for connecting to Resend, including OAuth flow for better integration with marketing tools.
- Enhanced the marketing segments service to provide detailed campaign usage information, including modes for each segment.
- Implemented image upload functionality for marketing templates, allowing users to include images directly in their campaigns.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 existing issues remain and 11 new issues found across 92 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/app/app/(app)/[slug]/(marketing-editor)/marketing/campaigns/[campaignId]/campaign-enrolments.tsx">

<violation number="1" location="apps/app/app/(app)/[slug]/(marketing-editor)/marketing/campaigns/[campaignId]/campaign-enrolments.tsx:135">
P2: Clicking `Entered` toggles the table's sort direction, but the enrolments endpoint always returns `enrolledAt` descending. Remove `sortable: true` until the endpoint honors the requested sort, or update the endpoint to apply `input.dir`.</violation>
</file>

<file name="apps/app/app/(app)/[slug]/(marketing-editor)/marketing/campaigns/[campaignId]/campaign-recipients.tsx">

<violation number="1" location="apps/app/app/(app)/[slug]/(marketing-editor)/marketing/campaigns/[campaignId]/campaign-recipients.tsx:98">
P2: Clicking Sent or choosing it in the Sort menu changes the query state, but the recipients API always orders by `createdAt` descending. Apply `input.sort` and `input.dir` in the API or remove `sortable` until server-side sorting is implemented.</violation>
</file>

<file name="apps/app/app/(app)/[slug]/(marketing-editor)/marketing/campaigns/[campaignId]/schedule-send.tsx">

<violation number="1" location="apps/app/app/(app)/[slug]/(marketing-editor)/marketing/campaigns/[campaignId]/schedule-send.tsx:71">
P1: When a campaign is already `SCHEDULED`, this still exposes “Send now,” but the existing queued rows keep their original future `dueAt` because materialisation skips duplicates. Hide or disable this control for scheduled campaigns, or update existing queued rows when changing the schedule, so “Send now” does not report success while sending later.</violation>
</file>

<file name="packages/ui/src/lib/csv.ts">

<violation number="1" location="packages/ui/src/lib/csv.ts:22">
P1: When a contact or segment field begins with `=`, `+`, `-`, or `@`, Excel or similar spreadsheet software can interpret the exported cell as a formula when the CSV is opened. Prefix formula-leading cell text with an apostrophe before applying CSV quoting, or otherwise neutralize these prefixes.</violation>
</file>

<file name="apps/app/lib/marketing-facets.ts">

<violation number="1" location="apps/app/lib/marketing-facets.ts:435">
P2: When a user selects a non-text custom field, this editor writes a rule that the compiler checks only against `fieldValues.text`, so the valid-looking rule matches nobody. Restrict the choices to text-compatible fields, or add type-aware compilation for the other field types.</violation>
</file>

<file name="apps/app/lib/campaign-graph.ts">

<violation number="1" location="apps/app/lib/campaign-graph.ts:139">
P2: When deleting the last node on a `BRANCH` arm, `withoutNode` removes its incoming edge without creating a replacement arm. `writeGraph` rejects the result with `branch-missing-arm`, so the canvas cannot delete terminal branch arms; preserve both handles or replace the removed arm with a valid `EXIT` path.</violation>

<violation number="2" location="apps/app/lib/campaign-graph.ts:168">
P2: When the removed node is the sole root `BRANCH` or `SPLIT`, `start` includes every child, so the returned graph has multiple roots. `writeGraph` rejects it with `many-roots`; preserve one valid root or explicitly orphan or replace the other arms before saving.</violation>
</file>

<file name="packages/db/prisma/schema.prisma">

<violation number="1" location="packages/db/prisma/schema.prisma:1325">
P2: When two users or tabs start Resend OAuth concurrently, the second start overwrites the first flow's state and PKCE verifier, so one connection fails and its callback can clear the other flow. Store pending state and verifier per authorization attempt or session, and consume them atomically instead of putting them on the singleton `AppSetting` row.</violation>
</file>

<file name="apps/app/app/(app)/[slug]/(marketing)/marketing/campaigns/campaigns-table.tsx">

<violation number="1" location="apps/app/app/(app)/[slug]/(marketing)/marketing/campaigns/campaigns-table.tsx:187">
P2: Campaigns in `PENDING_APPROVAL`, `SCHEDULED`, `DRAINING`, `CANCELLED`, or `FAILED` appear in this table but cannot be selected in the new Status facet. Add those statuses to the facet and to `CAMPAIGN_STATUS_FILTERS` so every displayed status is filterable.</violation>
</file>

<file name="apps/api/src/marketing/marketing-settings.service.ts">

<violation number="1" location="apps/api/src/marketing/marketing-settings.service.ts:103">
P2: When a token refresh overlaps disconnect, the refresh can write credentials back after `disconnect()` clears them, so the workspace becomes connected again with revoked credentials. Serialize refresh and disconnect, or reject stale refresh writes with a generation check.</violation>

<violation number="2" location="apps/api/src/marketing/marketing-settings.service.ts:116">
P1: OAuth-only connections never send queued email: `connectFinish()` stores the OAuth grant, but `MarketingDrainService.drainSends()` still requires `resendApiKey`. Remove the API-key-only guard and gate sending on the OAuth-aware connection/client instead.</violation>
</file>

Requires human review: Auto-approval blocked because this review re-detected 12 unresolved issues already reported by Cubic.
Tip: instead of fixing issues one by one fix them all with cubic
Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

}

async connectFinish(code: string, state: string): Promise<void> {
await this.oauth.finish(code, state);

@cubic-dev-ai cubic-dev-ai Bot Aug 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: OAuth-only connections never send queued email: connectFinish() stores the OAuth grant, but MarketingDrainService.drainSends() still requires resendApiKey. Remove the API-key-only guard and gate sending on the OAuth-aware connection/client instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/marketing/marketing-settings.service.ts, line 116:

<comment>OAuth-only connections never send queued email: `connectFinish()` stores the OAuth grant, but `MarketingDrainService.drainSends()` still requires `resendApiKey`. Remove the API-key-only guard and gate sending on the OAuth-aware connection/client instead.</comment>

<file context>
@@ -94,13 +100,23 @@ export class MarketingSettingsService {
+	}
+
+	async connectFinish(code: string, state: string): Promise<void> {
+		await this.oauth.finish(code, state);
+		await this.agent.marketingConnected().catch(() => {});
+	}
</file context>
Fix with cubic

Comment thread apps/app/components/marketing/use-marketing-facets.ts Outdated
Comment thread packages/ui/src/components/email-blocks.tsx Outdated
Comment thread apps/api/src/marketing/marketing-campaigns.service.ts Outdated
Comment thread apps/api/src/marketing/marketing-templates.service.ts Outdated
Comment thread apps/api/test/marketing-resend-oauth.integration.spec.ts
Comment thread apps/api/src/marketing/marketing-campaigns.service.ts
…suite

# Conflicts:
#	apps/api/src/conversations/conversations.service.ts
- Added a new tool to render emails in a real browser, allowing users to preview how emails will appear to recipients.
- Introduced the `CHROME_EXECUTABLE_PATH` environment variable to specify the location of the Chrome binary for rendering.
- Enhanced capabilities to include email preview checks, measuring first screen visibility and reporting on image coverage and text visibility.
- Updated related configurations and documentation to support the new email review feature, ensuring a seamless integration into the existing marketing workflow.
- Refactored existing code to accommodate the new email review logic and improve overall maintainability.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 existing issues remain and 9 new issues found across 110 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/agent/agent/lib/email-review.ts">

<violation number="1" location="apps/agent/agent/lib/email-review.ts:417">
P1: When a draft or saved email contains an image URL for an internal or private host, `reviewEmail` makes the server-side browser fetch it during capture. Restrict image loads to approved public origins or block loopback, link-local, private, and other non-public addresses before rendering.</violation>
</file>

<file name="apps/app/components/marketing/use-marketing-facets.ts">

<violation number="1" location="apps/app/components/marketing/use-marketing-facets.ts:22">
P2: When a campaign is created, renamed, or archived while this facet query is mounted, the campaign selectors keep stale options because this new query key is never invalidated. Invalidate `marketingSegments.campaignOptions` from campaign mutations or update this cache alongside campaign writes.</violation>
</file>

<file name="apps/api/src/marketing/marketing-activity.service.ts">

<violation number="1" location="apps/api/src/marketing/marketing-activity.service.ts:39">
P2: When `ActivityStampService.recomputeAll` runs, this new link treats every marketing send as company activity and sets `company.lastActivityAt` from its `createdAt`. `file()` intentionally skips stamping automated sends, so recomputation can reset a company’s quiet window unexpectedly; exclude marketing rows from company restamping or decouple the timeline link from activity stamps.</violation>
</file>

<file name="apps/app/lib/agent-attachments.ts">

<violation number="1" location="apps/app/lib/agent-attachments.ts:30">
P2: When a selected or pasted file cannot be read, `toDraftAttachment` rejects but `addFiles` has no rejection handler, leaving the user with no feedback and an unhandled promise rejection. Catch this failure in `addFiles` and show the attachment-read error toast.</violation>
</file>

<file name="apps/agent/test/email-review.integration.spec.ts">

<violation number="1" location="apps/agent/test/email-review.integration.spec.ts:43">
P2: This integration suite writes to the shared, global singleton row (`SETTINGS_ID`) in `beforeAll` (`upsert` overwriting `marketingPostalAddress`), then in `afterAll` deletes that row and recreates it from a snapshot. If this file runs concurrently with other tests in `apps/agent/test` that read marketing settings (the global `bun test` runs files in parallel against the same database), it clobbers their settings mid-run, and the delete/recreate can lose updates written between `beforeAll` and `afterAll`. Scope the setting change (e.g., use a dedicated test DB/namespace) or skip restoring when a concurrent row exists.</violation>
</file>

<file name="apps/agent/tsconfig.json">

<violation number="1" location="apps/agent/tsconfig.json:8">
P3: `jsx: react-jsx` is a no-op here: there are no `.tsx` files under apps/agent, and the tsconfig `include` only matches `agent/**/*.ts` and `evals/**/*.ts`. The option enables JSX syntax that nothing in this project consumes, and it will silently go stale. Remove it unless JSX files are actually being added.</violation>
</file>

<file name="packages/db/prisma/schema.prisma">

<violation number="1" location="packages/db/prisma/schema.prisma:524">
P1: When a campaign graph save removes a node, PostgreSQL cascades into every `AgentConversation` linked through `campaignNodeId`, permanently deleting its chat history. The graph service checks only active enrolments before deleting nodes, so use `onDelete: SetNull` and update the migration constraint to preserve conversations.</violation>
</file>

<file name="apps/app/app/(app)/[slug]/(marketing-editor)/marketing/campaigns/[campaignId]/blast-composer.tsx">

<violation number="1" location="apps/app/app/(app)/[slug]/(marketing-editor)/marketing/campaigns/[campaignId]/blast-composer.tsx:182">
P2: When a blast is already `SCHEDULED`, this condition removes both rescheduling and “Send now”, but no other app UI exposes a non-destructive cancellation or rescheduling action. Keep a scheduled-state action available, or add explicit reschedule/cancel controls instead of hiding `ScheduleSend` entirely.</violation>
</file>

<file name="packages/db/src/marketing/segments.ts">

<violation number="1" location="packages/db/src/marketing/segments.ts:348">
P2: When an archived custom segment has a default name such as `All contacts`, this branch resurrects it and marks it default. Only unarchive clashes already marked `isDefault`; preserve archived non-default collisions or handle the name conflict explicitly.</violation>
</file>

Requires human review: Auto-approval blocked because this review re-detected 2 unresolved issues already reported by Cubic.
Tip: instead of fixing issues one by one fix them all with cubic
Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

let screens: Screen[];

try {
screens = await captureEmail(

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a draft or saved email contains an image URL for an internal or private host, reviewEmail makes the server-side browser fetch it during capture. Restrict image loads to approved public origins or block loopback, link-local, private, and other non-public addresses before rendering.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/lib/email-review.ts, line 417:

<comment>When a draft or saved email contains an image URL for an internal or private host, `reviewEmail` makes the server-side browser fetch it during capture. Restrict image loads to approved public origins or block loopback, link-local, private, and other non-public addresses before rendering.</comment>

<file context>
@@ -0,0 +1,444 @@
+	let screens: Screen[];
+
+	try {
+		screens = await captureEmail(
+			composed.html,
+			[
</file context>
Fix with cubic

Comment thread apps/app/app/eve/v1/[...path]/route.ts Outdated
Comment thread apps/api/src/marketing/resend-oauth.service.ts
Comment thread packages/db/prisma/schema.prisma Outdated
campaignId String?
campaign MarketingCampaign? @relation(fields: [campaignId], references: [id], onDelete: Cascade)
campaignNodeId String?
campaignNode MarketingCampaignNode? @relation(fields: [campaignNodeId], references: [id], onDelete: Cascade)

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a campaign graph save removes a node, PostgreSQL cascades into every AgentConversation linked through campaignNodeId, permanently deleting its chat history. The graph service checks only active enrolments before deleting nodes, so use onDelete: SetNull and update the migration constraint to preserve conversations.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/db/prisma/schema.prisma, line 524:

<comment>When a campaign graph save removes a node, PostgreSQL cascades into every `AgentConversation` linked through `campaignNodeId`, permanently deleting its chat history. The graph service checks only active enrolments before deleting nodes, so use `onDelete: SetNull` and update the migration constraint to preserve conversations.</comment>

<file context>
@@ -518,12 +518,16 @@ model AgentConversation {
+  campaignId     String?
+  campaign       MarketingCampaign?     @relation(fields: [campaignId], references: [id], onDelete: Cascade)
+  campaignNodeId String?
+  campaignNode   MarketingCampaignNode? @relation(fields: [campaignNodeId], references: [id], onDelete: Cascade)
+  segmentId      String?
+  segment        MarketingSegment?      @relation(fields: [segmentId], references: [id], onDelete: Cascade)
</file context>
Fix with cubic

Comment thread apps/api/src/marketing/marketing-drain.service.ts
Comment thread apps/app/components/crm/agent-panel.tsx Outdated
Comment thread apps/app/app/(app)/[slug]/(marketing)/marketing/overview.tsx Outdated
Comment thread apps/api/test/conversations.spec.ts
Comment thread packages/ui/src/components/email-blocks.tsx
Comment thread apps/agent/test/email-review.integration.spec.ts Outdated
…ct logic

- Introduced a new module for handling email request origins, including functions to determine allowed origins and verdicts for requests based on their URLs.
- Added configuration for DNS timeout and own origin variables to enhance security and control over external requests.
- Updated the email review logic to incorporate blocked requests, improving the handling of requests that cannot be fetched due to origin restrictions.
- Enhanced integration with existing email screenshot functionality to ensure compliance with allowed origins during email rendering.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

22 issues found across 50 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/db/src/marketing/segments.ts">

<violation number="1" location="packages/db/src/marketing/segments.ts:357">
P3: When two `ensureDefaultSegments` calls race, the second upsert updates the row created by the first but still increments `made`. Return the actual number of inserted rows, or otherwise make the count race-safe.</violation>
</file>

<file name="apps/app/lib/agent-attachments.ts">

<violation number="1" location="apps/app/lib/agent-attachments.ts:39">
P2: Pasting an unsupported image now silently does nothing. The paste handlers gate `addFiles` with `isImage`, so `isUnsupportedImage` never shows the new error; invoke `addFiles` for image files before filtering, or add a separate paste-specific unsupported-image notification path.</violation>
</file>

<file name="apps/api/src/marketing/marketing-drain.service.ts">

<violation number="1" location="apps/api/src/marketing/marketing-drain.service.ts:243">
P1: If the process dies after Resend accepts this batch but before `record` settles it, lease recovery can retry with a different batch key and duplicate emails. Preserve `send/<send.id>` idempotency for each message or persist a stable batch key across retries.</violation>
</file>

<file name="apps/api/src/conversations/conversations.service.ts">

<violation number="1" location="apps/api/src/conversations/conversations.service.ts:932">
P2: When a campaign step is deleted after `withLiveNode` but before an existing conversation is updated, this branch rejects the save instead of continuing under the campaign. Handle the lost-node case for existing conversations too, clearing `campaignNodeId` and retrying the update.</violation>
</file>

<file name="packages/ui/src/components/email-blocks.tsx">

<violation number="1" location="packages/ui/src/components/email-blocks.tsx:211">
P2: When an upload completes after its image is removed, `moved` is `-1` and this fallback updates the image at the old index. Ignore stale completions or track a stable block ID instead of falling back to an index.</violation>
</file>

<file name="apps/agent/agent/skills/creating-a-template.md">

<violation number="1" location="apps/agent/agent/skills/creating-a-template.md:74">
P3: The doc says linter problems each carry "a `code`, a `message` and the `blockIndex` it applies to", but `blockIndex` is optional on `LintFinding` (packages/email/src/lint.ts) and is absent for non-block findings such as `subject-too-long`, `no-preheader`, `image-heavy`, `no-links` and `too-many-links`. A rep who reads only this sentence will expect a `blockIndex` on every error and be confused when it is missing on subject/preheader/overall-image errors. Qualify it as "a `blockIndex` when it applies to a block".</violation>
</file>

<file name="apps/app/app/(app)/[slug]/(marketing-editor)/marketing/campaigns/[campaignId]/campaign-actions.tsx">

<violation number="1" location="apps/app/app/(app)/[slug]/(marketing-editor)/marketing/campaigns/[campaignId]/campaign-actions.tsx:96">
P2: After cancellation, invalidate `marketingCampaigns.list` as well as the detail query; otherwise returning to Campaigns within the 30-second cache window can display the cancelled campaign as still scheduled.</violation>

<violation number="2" location="apps/app/app/(app)/[slug]/(marketing-editor)/marketing/campaigns/[campaignId]/campaign-actions.tsx:213">
P1: When the sender has already claimed a scheduled email, this action marks the campaign cancelled but leaves the `SENDING` row deliverable, so an email can go out after the confirmation says nothing will. Make cancellation atomically prevent claims/settlement, or reject cancellation once sending has started.</violation>
</file>

<file name="apps/api/src/marketing/marketing-campaigns.service.ts">

<violation number="1" location="apps/api/src/marketing/marketing-campaigns.service.ts:1044">
P2: When an overdue blast is rescheduled, the drain can claim an old queued send during `materialise` before this update runs, so that send is delivered at the previous time. Coordinate the due-date update and materialisation under the same transaction or row-locking boundary.</violation>

<violation number="2" location="apps/api/src/marketing/marketing-campaigns.service.ts:1573">
P2: When a contact email contains leading or trailing whitespace, this fallback misses the normalized recipient and the contact page reports no marketing status. Normalize the email with `normalizeEmail` before the lookup, matching recipient creation.</violation>
</file>

<file name="apps/agent/agent/lib/email-review.ts">

<violation number="1" location="apps/agent/agent/lib/email-review.ts:272">
P3: This finding fires for every blocked request, but `gate` in email-screenshot.ts also blocks non-visible subresources: non-http(s) schemes, unparseable URLs, and anything on a non-public host (fonts, stylesheets, favicons, tracking pixels, iframes). For those, the claim that "a reader... see[s] a gap" is inaccurate, and a fully-rendering email can still get a spurious `address-not-loaded` warning. Restrict the finding to visible content (e.g. images/iframes that actually render) or reword the message so it does not assert a visible gap for every blocked URL.</violation>
</file>

<file name="apps/api/src/marketing/resend-oauth.service.ts">

<violation number="1" location="apps/api/src/marketing/resend-oauth.service.ts:186">
P2: Concurrent callback requests can both pass `pending()` and exchange the same one-time authorization code. `pending()` reads without claiming, while `finish()` spends afterward; serialize the attempt or add an in-progress state that supports safe transient retries.</violation>
</file>

<file name="apps/api/src/marketing/marketing-lock.ts">

<violation number="1" location="apps/api/src/marketing/marketing-lock.ts:12">
P2: When another `underLock` call holds the advisory lock, `maxWait` does not enforce `lock.waitMs`; the second call can block for `holdMs` while occupying a pool connection. Set a PostgreSQL `lock_timeout` before acquiring the advisory lock, or use a try-lock loop.</violation>

<violation number="2" location="apps/api/src/marketing/marketing-lock.ts:13">
P1: When `work()` lasts longer than `holdMs`, Prisma can expire this transaction and release the advisory lock while the outer-client work continues, allowing a second refresh or onboarding operation to overlap the first. Ensure work completes before the transaction deadline or use a lock mechanism whose lifetime covers the full operation.</violation>
</file>

<file name="apps/agent/agent/lib/email-requests.ts">

<violation number="1" location="apps/agent/agent/lib/email-requests.ts:12">
P2: When `APP_URL` contains the documented comma-separated origins, `ownOrigins` does not whitelist any of them reliably. Split each environment value on commas and parse each trimmed origin separately.</violation>

<violation number="2" location="apps/agent/agent/lib/email-requests.ts:47">
P1: A DNS-rebinding hostname can resolve publicly during `resolvesToPublicHost` and privately when Chromium continues the request, bypassing this SSRF boundary. Pin the validated address for the actual fetch or route requests through an SSRF-safe proxy that revalidates each connection.</violation>
</file>

<file name="apps/api/src/marketing/resend.service.ts">

<violation number="1" location="apps/api/src/marketing/resend.service.ts:67">
P2: Because `batchKey` sorts IDs while the request preserves array order, reordered retries reuse the key with a different payload and can hit an idempotency conflict. Sort the messages before sending or derive the key from the exact request order.</violation>

<violation number="2" location="apps/api/src/marketing/resend.service.ts:291">
P2: When a caller supplies `attachments` to `sendBatch`, this mapping silently drops them even though `SendOne` accepts attachments. Route attachment-bearing messages through `sendOne` or reject them instead of sending incomplete emails.</violation>
</file>

<file name="apps/agent/agent/skills/writing-an-email.md">

<violation number="1" location="apps/agent/agent/skills/writing-an-email.md:89">
P3: This documentation describes the write-refusal return shape for three tools — write_template, write_campaign_graph (on an EMAIL node), and write_shell — but the on-can-read path it describes only matches write_template. In write_campaign_graph, an unreadable document comes back as problems carrying `code: "email-unreadable-document"`, `message` (path embedded in it) and `nodeId` — there is no `path` field. A readable body the linter refuses comes back with `code`, `message` and `nodeId`, not `blockIndex`. The agent will look for `path` and `blockIndex` fields that write_campaign_graph never returns. Additionally `blockIndex` is absent even in write_template for subject-level errors (`subject-empty`, `subject-too-long`), and write_shell never runs the linter at all. Scope the described shapes per tool or correct the field names for the graph path.</violation>
</file>

<file name="apps/agent/agent/lib/email-screenshot.ts">

<violation number="1" location="apps/agent/agent/lib/email-screenshot.ts:83">
P2: When a blocked non-image request is still awaiting DNS, `waitForImages` returns without waiting for it and `measure` snapshots `blockedRequests` too early. Track and await all pending `gate` calls before measuring so `address-not-loaded` reliably reports blocked resources.</violation>
</file>

<file name="apps/api/test/conversations.spec.ts">

<violation number="1" location="apps/api/test/conversations.spec.ts:962">
P3: The race-condition test mocks the private `withLiveNode` via `Object.assign`, but the test cannot detect if the mock ever stops applying (e.g. the method is renamed or removed). With a real or missing `withLiveNode`, `save()` sees `doomed.id` as already deleted and drops the node before the create, producing the same expected `{ campaignId, campaignNodeId: null }` without ever hitting the FK-violation retry path the test is meant to exercise. The test therefore passes identically whether or not the race path runs, so it silently degrades to duplicating the adjacent 'already gone' test. Assert that the mock actually ran with `campaignNodeId` intact to keep the test meaningful.</violation>
</file>

<file name="apps/app/components/crm/record-sheet/contact-marketing.tsx">

<violation number="1" location="apps/app/components/crm/record-sheet/contact-marketing.tsx:123">
P2: The new `written` branch copy claims "Another contact holds the same address," but the condition only checks that sends exist while `recipient` is null. In forContact, recipientOf already resolves the same-address case via an email-match fallback, so when another contact truly holds the address `recipient` is non-null and this branch is not reached. This branch actually shows when a send exists but no owned or matching recipient is on record (e.g. the contact's email changed after sending, or the recipient was removed), so the copy misstates the cause. Reword to what the condition verifies, e.g. "This contact has marketing sends but no subscription status on record."</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic
Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

) {
const slice = batchable.slice(index, index + MARKETING.send.batchSize);

const outcomes = await this.resend.sendBatch(

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: If the process dies after Resend accepts this batch but before record settles it, lease recovery can retry with a different batch key and duplicate emails. Preserve send/<send.id> idempotency for each message or persist a stable batch key across retries.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/marketing/marketing-drain.service.ts, line 243:

<comment>If the process dies after Resend accepts this batch but before `record` settles it, lease recovery can retry with a different batch key and duplicate emails. Preserve `send/<send.id>` idempotency for each message or persist a stable batch key across retries.</comment>

<file context>
@@ -191,30 +216,45 @@ export class MarketingDrainService implements OnModuleInit, OnModuleDestroy {
+		) {
+			const slice = batchable.slice(index, index + MARKETING.send.batchSize);
+
+			const outcomes = await this.resend.sendBatch(
+				slice.map(({ send, body }) => ({
+					...this.messageFor(send, body),
</file context>
Fix with cubic

<AlertDialogFooter>
<AlertDialogCancel>Keep it scheduled</AlertDialogCancel>
<AlertDialogAction
onClick={() => cancel.mutate({ id: campaignId })}

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When the sender has already claimed a scheduled email, this action marks the campaign cancelled but leaves the SENDING row deliverable, so an email can go out after the confirmation says nothing will. Make cancellation atomically prevent claims/settlement, or reject cancellation once sending has started.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/app/app/(app)/[slug]/(marketing-editor)/marketing/campaigns/[campaignId]/campaign-actions.tsx, line 213:

<comment>When the sender has already claimed a scheduled email, this action marks the campaign cancelled but leaves the `SENDING` row deliverable, so an email can go out after the confirmation says nothing will. Make cancellation atomically prevent claims/settlement, or reject cancellation once sending has started.</comment>

<file context>
@@ -174,6 +194,30 @@ export function CampaignActions({
+					<AlertDialogFooter>
+						<AlertDialogCancel>Keep it scheduled</AlertDialogCancel>
+						<AlertDialogAction
+							onClick={() => cancel.mutate({ id: campaignId })}
+						>
+							Cancel the send
</file context>
Fix with cubic

return db.$transaction(
async (tx) => {
await lockIdempotencyKey(tx, lock.key);
return work();

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When work() lasts longer than holdMs, Prisma can expire this transaction and release the advisory lock while the outer-client work continues, allowing a second refresh or onboarding operation to overlap the first. Ensure work completes before the transaction deadline or use a lock mechanism whose lifetime covers the full operation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/marketing/marketing-lock.ts, line 13:

<comment>When `work()` lasts longer than `holdMs`, Prisma can expire this transaction and release the advisory lock while the outer-client work continues, allowing a second refresh or onboarding operation to overlap the first. Ensure work completes before the transaction deadline or use a lock mechanism whose lifetime covers the full operation.</comment>

<file context>
@@ -0,0 +1,17 @@
+	return db.$transaction(
+		async (tx) => {
+			await lockIdempotencyKey(tx, lock.key);
+			return work();
+		},
+		{ maxWait: lock.waitMs, timeout: lock.holdMs },
</file context>
Fix with cubic


if (allowedOrigins.includes(target.origin)) return { allowed: true };

const isPublic = await resolvesToPublicHost(

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: A DNS-rebinding hostname can resolve publicly during resolvesToPublicHost and privately when Chromium continues the request, bypassing this SSRF boundary. Pin the validated address for the actual fetch or route requests through an SSRF-safe proxy that revalidates each connection.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/lib/email-requests.ts, line 47:

<comment>A DNS-rebinding hostname can resolve publicly during `resolvesToPublicHost` and privately when Chromium continues the request, bypassing this SSRF boundary. Pin the validated address for the actual fetch or route requests through an SSRF-safe proxy that revalidates each connection.</comment>

<file context>
@@ -0,0 +1,60 @@
+
+	if (allowedOrigins.includes(target.origin)) return { allowed: true };
+
+	const isPublic = await resolvesToPublicHost(
+		target.hostname,
+		EMAIL_REVIEW.requests.dnsTimeoutMs,
</file context>
Fix with cubic

}

export function isImage(file: File): boolean {
return isSupportedImageType(file.type);

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Pasting an unsupported image now silently does nothing. The paste handlers gate addFiles with isImage, so isUnsupportedImage never shows the new error; invoke addFiles for image files before filtering, or add a separate paste-specific unsupported-image notification path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/app/lib/agent-attachments.ts, line 39:

<comment>Pasting an unsupported image now silently does nothing. The paste handlers gate `addFiles` with `isImage`, so `isUnsupportedImage` never shows the new error; invoke `addFiles` for image files before filtering, or add a separate paste-specific unsupported-image notification path.</comment>

<file context>
@@ -12,8 +27,20 @@ export type DraftAttachment = {
+
 export function isImage(file: File): boolean {
-	return file.type.startsWith("image/");
+	return isSupportedImageType(file.type);
+}
+
</file context>
Fix with cubic

continue;
}

await db.marketingSegment.upsert({

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: When two ensureDefaultSegments calls race, the second upsert updates the row created by the first but still increments made. Return the actual number of inserted rows, or otherwise make the count race-safe.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/db/src/marketing/segments.ts, line 357:

<comment>When two `ensureDefaultSegments` calls race, the second upsert updates the row created by the first but still increments `made`. Return the actual number of inserted rows, or otherwise make the count race-safe.</comment>

<file context>
@@ -354,8 +354,10 @@ export async function ensureDefaultSegments(db: Db): Promise<number> {
 
-		await db.marketingSegment.create({
-			data: {
+		await db.marketingSegment.upsert({
+			where: { name: segment.name },
+			update: { isDefault: true, archivedAt: null },
</file context>
Fix with cubic

## Before you save

`write_template` lints first and refuses errors back as `problems`, each with
a `code`, a `message` and the `blockIndex` it applies to — fix them and call

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The doc says linter problems each carry "a code, a message and the blockIndex it applies to", but blockIndex is optional on LintFinding (packages/email/src/lint.ts) and is absent for non-block findings such as subject-too-long, no-preheader, image-heavy, no-links and too-many-links. A rep who reads only this sentence will expect a blockIndex on every error and be confused when it is missing on subject/preheader/overall-image errors. Qualify it as "a blockIndex when it applies to a block".

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/skills/creating-a-template.md, line 74:

<comment>The doc says linter problems each carry "a `code`, a `message` and the `blockIndex` it applies to", but `blockIndex` is optional on `LintFinding` (packages/email/src/lint.ts) and is absent for non-block findings such as `subject-too-long`, `no-preheader`, `image-heavy`, `no-links` and `too-many-links`. A rep who reads only this sentence will expect a `blockIndex` on every error and be confused when it is missing on subject/preheader/overall-image errors. Qualify it as "a `blockIndex` when it applies to a block".</comment>

<file context>
@@ -71,6 +71,8 @@ every send. `read_shell` reports which brand line applies.
-a `path` and a `message` — fix them and call again. When updating, pass the
-existing `templateId`; leaving it out creates a second template and the rep
-keeps looking at the old one.
+a `code`, a `message` and the `blockIndex` it applies to — fix them and call
+again. A body it cannot read at all is refused before the linter, and those
+`problems` carry a `path` and a `message` instead, plus `shapes`: the whole
</file context>
Suggested change
a `code`, a `message` and the `blockIndex` it applies to — fix them and call
a `code` and a `message`, plus a `blockIndex` when the problem sits in a block — fix them and call
Fix with cubic

}
}

const blocked = measurements.blockedRequests;

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This finding fires for every blocked request, but gate in email-screenshot.ts also blocks non-visible subresources: non-http(s) schemes, unparseable URLs, and anything on a non-public host (fonts, stylesheets, favicons, tracking pixels, iframes). For those, the claim that "a reader... see[s] a gap" is inaccurate, and a fully-rendering email can still get a spurious address-not-loaded warning. Restrict the finding to visible content (e.g. images/iframes that actually render) or reword the message so it does not assert a visible gap for every blocked URL.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/lib/email-review.ts, line 272:

<comment>This finding fires for every blocked request, but `gate` in email-screenshot.ts also blocks non-visible subresources: non-http(s) schemes, unparseable URLs, and anything on a non-public host (fonts, stylesheets, favicons, tracking pixels, iframes). For those, the claim that "a reader... see[s] a gap" is inaccurate, and a fully-rendering email can still get a spurious `address-not-loaded` warning. Restrict the finding to visible content (e.g. images/iframes that actually render) or reword the message so it does not assert a visible gap for every blocked URL.</comment>

<file context>
@@ -269,6 +269,22 @@ export function findingsFor(measurements: ViewMeasurements): Finding[] {
 		}
 	}
 
+	const blocked = measurements.blockedRequests;
+
+	if (blocked.length > 0) {
</file context>
Fix with cubic

tool cannot read at all is refused before the linter: each problem is a `path`
and a `message`, and `shapes` comes back with them, which is this vocabulary. A
body it can read and the linter refuses comes back with a `code`, a `message`
and the `blockIndex` the problem applies to. One corrected call is the

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This documentation describes the write-refusal return shape for three tools — write_template, write_campaign_graph (on an EMAIL node), and write_shell — but the on-can-read path it describes only matches write_template. In write_campaign_graph, an unreadable document comes back as problems carrying code: "email-unreadable-document", message (path embedded in it) and nodeId — there is no path field. A readable body the linter refuses comes back with code, message and nodeId, not blockIndex. The agent will look for path and blockIndex fields that write_campaign_graph never returns. Additionally blockIndex is absent even in write_template for subject-level errors (subject-empty, subject-too-long), and write_shell never runs the linter at all. Scope the described shapes per tool or correct the field names for the graph path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/skills/writing-an-email.md, line 89:

<comment>This documentation describes the write-refusal return shape for three tools — write_template, write_campaign_graph (on an EMAIL node), and write_shell — but the on-can-read path it describes only matches write_template. In write_campaign_graph, an unreadable document comes back as problems carrying `code: "email-unreadable-document"`, `message` (path embedded in it) and `nodeId` — there is no `path` field. A readable body the linter refuses comes back with `code`, `message` and `nodeId`, not `blockIndex`. The agent will look for `path` and `blockIndex` fields that write_campaign_graph never returns. Additionally `blockIndex` is absent even in write_template for subject-level errors (`subject-empty`, `subject-too-long`), and write_shell never runs the linter at all. Scope the described shapes per tool or correct the field names for the graph path.</comment>

<file context>
@@ -82,9 +82,12 @@ It warns about a subject over 50 characters, and about a missing preheader.
+tool cannot read at all is refused before the linter: each problem is a `path`
+and a `message`, and `shapes` comes back with them, which is this vocabulary. A
+body it can read and the linter refuses comes back with a `code`, a `message`
+and the `blockIndex` the problem applies to. One corrected call is the
+expectation, not ten probes.
 
</file context>
Fix with cubic

await db.marketingCampaignNode.delete({ where: { id: doomed.id } });

const racing = new ConversationsService(db);
Object.assign(racing, {

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The race-condition test mocks the private withLiveNode via Object.assign, but the test cannot detect if the mock ever stops applying (e.g. the method is renamed or removed). With a real or missing withLiveNode, save() sees doomed.id as already deleted and drops the node before the create, producing the same expected { campaignId, campaignNodeId: null } without ever hitting the FK-violation retry path the test is meant to exercise. The test therefore passes identically whether or not the race path runs, so it silently degrades to duplicating the adjacent 'already gone' test. Assert that the mock actually ran with campaignNodeId intact to keep the test meaningful.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/test/conversations.spec.ts, line 962:

<comment>The race-condition test mocks the private `withLiveNode` via `Object.assign`, but the test cannot detect if the mock ever stops applying (e.g. the method is renamed or removed). With a real or missing `withLiveNode`, `save()` sees `doomed.id` as already deleted and drops the node before the create, producing the same expected `{ campaignId, campaignNodeId: null }` without ever hitting the FK-violation retry path the test is meant to exercise. The test therefore passes identically whether or not the race path runs, so it silently degrades to duplicating the adjacent 'already gone' test. Assert that the mock actually ran with `campaignNodeId` intact to keep the test meaningful.</comment>

<file context>
@@ -942,15 +951,59 @@ describe("node-scoped conversations", () => {
+		await db.marketingCampaignNode.delete({ where: { id: doomed.id } });
+
+		const racing = new ConversationsService(db);
+		Object.assign(racing, {
+			withLiveNode: (input: ConversationSaveInput) => Promise.resolve(input),
+		});
</file context>
Fix with cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant