Skip to content

Conversation

KMKoushik
Copy link
Member

@KMKoushik KMKoushik commented Oct 13, 2025

Summary by cubic

Adds a public Campaign API with create/get/schedule/pause/resume endpoints and makes API-created campaigns read-only in the dashboard. Improves HTML rendering, scheduling, and unsubscribe safety.

  • New Features

    • Endpoints: POST /v1/campaigns, GET /v1/campaigns/{campaignId}, POST /v1/campaigns/{campaignId}/schedule, /pause, /resume.
    • Create accepts content (JSON) or html, validates unsubscribe placeholders, and sanitizes replyTo/cc/bcc (string or array).
    • Scheduling supports sendNow or scheduledAt (ISO or natural language like “tomorrow 9am”).
    • OpenAPI docs added; zod schemas validate inputs and responses.
    • Dashboard disables editing and the editor for isApi campaigns with a helper message.
  • Migration

    • Adds isApi boolean to Campaign. Run database migrations.

Summary by CodeRabbit

  • New Features

    • Introduced Public API endpoints to manage campaigns: create, retrieve, schedule, pause, and resume.
    • Support for immediate send or scheduled sends (with optional batch size) via the API.
    • Dashboard change: campaigns created through the API are read-only in the editor with an informational notice.
  • Documentation

    • OpenAPI documentation updated with new campaign endpoints, request/response schemas, and validations.

Copy link

vercel bot commented Oct 13, 2025

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

Project Deployment Preview Comments Updated (UTC)
unsend-marketing Ready Ready Preview Comment Oct 13, 2025 11:59am

Copy link
Contributor

coderabbitai bot commented Oct 13, 2025

Walkthrough

Adds public API endpoints for campaigns: create (/v1/campaigns), get by ID, schedule, pause, and resume, with OpenAPI specs and Zod schemas. Introduces campaign validation, scheduling date parsing, and response schemas. Updates campaign service to support API-created campaigns, including domain/API key validation, unsubscribe placeholder checks, HTML/content rendering utilities, and per-contact rendering. Exposes createCampaignFromApi and getCampaignForTeam. Extends campaign update router to accept html and return html; duplicate now copies html. UI disables editing for API-created campaigns based on new Campaign.isApi field added via Prisma schema and migration. Public API index wires new routes.

Possibly related PRs

Suggested labels

codex

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title accurately and succinctly summarizes the primary change of adding campaign API endpoints and is directly related to the core changes in this pull request.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch km/2025-10-12-campaign-api

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

Deploying usesend with  Cloudflare Pages  Cloudflare Pages

Latest commit: 64e4f6a
Status: ✅  Deploy successful!
Preview URL: https://6290c179.usesend.pages.dev
Branch Preview URL: https://km-2025-10-12-campaign-api.usesend.pages.dev

View logs

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🧹 Nitpick comments (13)
apps/web/src/server/public-api/api/campaigns/resume-campaign.ts (1)

53-56: Avoid redundant DB read

getCampaignForTeam result is not used. Drop it to save a query.

-    await getCampaignForTeam({
-      campaignId,
-      teamId: team.id,
-    });
+    // No-op fetch removed
apps/docs/api-reference/openapi.json (3)

1914-2014: Document “content OR html” and natural‑language scheduledAt

Align schema with server validation.

       "schema": {
         "type": "object",
         "properties": {
@@
           "scheduledAt": {
             "type": "string",
-            "format": "date-time"
+            "format": "date-time",
+            "description": "ISO 8601 or natural language (e.g., 'tomorrow 9am', 'next monday 10:30')"
           },
@@
         },
         "required": [
           "name",
           "from",
           "subject",
           "contactBookId"
         ],
+        "oneOf": [
+          { "required": ["content"] },
+          { "required": ["html"] }
+        ]
       }

2310-2341: Clarify scheduledAt format for scheduling endpoint

Server accepts natural language; reflect in docs.

           "scheduledAt": {
             "type": "string",
-            "format": "date-time"
+            "format": "date-time",
+            "description": "ISO 8601 or natural language (e.g., 'tomorrow 9am', 'next monday 10:30')"
           },

2157-2437: Consider adding standard error responses (400/403/404) for new campaign endpoints

OpenAPI currently lists only 200. Add error shapes to help clients.

apps/web/src/server/public-api/api/campaigns/schedule-campaign.ts (2)

65-68: Drop unused fetch to save a query

getCampaignForTeam is called but unused.

-    await getCampaignForTeam({
-      campaignId,
-      teamId: team.id,
-    });
+    // No-op fetch removed

29-36: Optional: allow omitting body for “send now”

You can set required: false to permit no body; {} still validates today.

-    body: {
-      required: true,
+    body: {
+      required: false,
       content: {
apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx (2)

182-184: Prefer readOnly over disabled to allow text selection/copy

Disabled inputs are unfocusable and can hurt accessibility. Use readOnly (with aria-readonly) and keep visual style.

-            disabled={isApiCampaign}
-            readOnly={isApiCampaign}
+            readOnly={isApiCampaign}
+            aria-readonly={isApiCampaign}

Apply similarly to Subject, From, Reply To, and Preview fields.

Also applies to: 260-262, 299-301, 335-337, 374-376


431-453: Nice gating; consider linking to API docs

Add a link to the API docs to guide users on how to update API-created campaigns.

apps/web/src/server/public-api/schemas/campaign-schema.ts (1)

44-51: Cap scheduledAt length to avoid heavy parsing

Limit input length in schemas (and optionally validate in parser).

Based on learnings

-    scheduledAt: z
-      .string()
+    scheduledAt: z
+      .string()
+      .max(256)
       .optional()
@@
 export const campaignScheduleSchema = z.object({
-  scheduledAt: z
-    .string()
+  scheduledAt: z
+    .string()
+    .max(256)
     .optional()

Optionally add a defensive check in parseScheduledAt:

 export const parseScheduledAt = (scheduledAt?: string): Date | undefined => {
   if (!scheduledAt) return undefined;
+  if (scheduledAt.length > 256) {
+    throw new UnsendApiError({
+      code: "BAD_REQUEST",
+      message: "scheduledAt is too long",
+    });
+  }

Also applies to: 57-65

apps/web/src/server/service/campaign-service.ts (4)

32-36: Precompile regexes and avoid re-creating them on replace

Current pattern rebuilds RegExp objects during replacement. Precompile with "gi" and reuse to reduce overhead. Static analysis warning is benign here (constants), but this also silences it.

-const CAMPAIGN_UNSUB_PLACEHOLDER_REGEXES =
-  CAMPAIGN_UNSUB_PLACEHOLDER_TOKENS.map((placeholder) => {
-    const inner = placeholder.replace(/[{}]/g, "").trim();
-    return new RegExp(`\\{\\{\\s*${inner}\\s*\\}}`, "i");
-  });
+const CAMPAIGN_UNSUB_PLACEHOLDER_REGEXES =
+  CAMPAIGN_UNSUB_PLACEHOLDER_TOKENS.map((placeholder) => {
+    const inner = placeholder.replace(/[{}]/g, "").trim();
+    return new RegExp(`\\{\\{\\s*${inner}\\s*\\}}`, "gi");
+  });
 
-function replaceUnsubscribePlaceholders(html: string, url: string) {
-  return CAMPAIGN_UNSUB_PLACEHOLDER_REGEXES.reduce((acc, regex) => {
-    return acc.replace(new RegExp(regex.source, "gi"), url);
-  }, html);
-}
+function replaceUnsubscribePlaceholders(html: string, url: string) {
+  const safeUrl = escapeHtml(url);
+  return CAMPAIGN_UNSUB_PLACEHOLDER_REGEXES.reduce(
+    (acc, regex) => acc.replace(regex, safeUrl),
+    html
+  );
+}

Note: escapeHtml is introduced below.

Also applies to: 49-53


246-256: Message clarity for API create-time validation

The error says “before sending” but this is enforced at creation. Consider: “Campaign must include an unsubscribe link.”

-      message: "Campaign must include an unsubscribe link before sending",
+      message: "Campaign must include an unsubscribe link",

258-276: Validate batchSize bounds

Enforce positive integer and reasonable upper bound to avoid overload.

-      ...(typeof batchSize === "number" ? { batchSize } : {}),
+      ...(typeof batchSize === "number" ? { batchSize } : {}),

Add pre-validation above create:

if (typeof batchSize === "number") {
  if (!Number.isInteger(batchSize) || batchSize <= 0 || batchSize > 10000) {
    throw new UnsendApiError({
      code: "BAD_REQUEST",
      message: "batchSize must be a positive integer <= 10000",
    });
  }
}

119-151: Avoid per-contact JSON.parse/renderer instantiation (hot path)

This runs for every contact. Consider parsing content once per campaign batch and reusing the JSON object (or a renderer instance) for render calls.

E.g., parse in CampaignBatchService before the loop and pass parsedContent into processContactEmail, or cache by campaign.id within the batch execution.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e631f16 and 64e4f6a.

📒 Files selected for processing (13)
  • apps/docs/api-reference/openapi.json (1 hunks)
  • apps/web/prisma/migrations/20251013114734_add_api_to_campaign/migration.sql (1 hunks)
  • apps/web/prisma/schema.prisma (1 hunks)
  • apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx (13 hunks)
  • apps/web/src/server/api/routers/campaign.ts (3 hunks)
  • apps/web/src/server/public-api/api/campaigns/create-campaign.ts (1 hunks)
  • apps/web/src/server/public-api/api/campaigns/get-campaign.ts (1 hunks)
  • apps/web/src/server/public-api/api/campaigns/pause-campaign.ts (1 hunks)
  • apps/web/src/server/public-api/api/campaigns/resume-campaign.ts (1 hunks)
  • apps/web/src/server/public-api/api/campaigns/schedule-campaign.ts (1 hunks)
  • apps/web/src/server/public-api/index.ts (2 hunks)
  • apps/web/src/server/public-api/schemas/campaign-schema.ts (1 hunks)
  • apps/web/src/server/service/campaign-service.ts (4 hunks)
🧰 Additional context used
📓 Path-based instructions (5)
**/*.{js,jsx,ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/general.mdc)

Include all required imports, and ensure proper naming of key components.

Files:

  • apps/web/src/server/public-api/api/campaigns/create-campaign.ts
  • apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx
  • apps/web/src/server/public-api/api/campaigns/schedule-campaign.ts
  • apps/web/src/server/public-api/schemas/campaign-schema.ts
  • apps/web/src/server/public-api/api/campaigns/resume-campaign.ts
  • apps/web/src/server/public-api/api/campaigns/get-campaign.ts
  • apps/web/src/server/api/routers/campaign.ts
  • apps/web/src/server/service/campaign-service.ts
  • apps/web/src/server/public-api/index.ts
  • apps/web/src/server/public-api/api/campaigns/pause-campaign.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: TypeScript-first: use .ts/.tsx for source code (avoid JavaScript source files)
Use 2-space indentation and semicolons (Prettier 3 enforces these)
Adhere to @usesend/eslint-config; fix all ESLint warnings (CI fails on warnings)
Do not use dynamic imports; always place imports at the top of the module

Files:

  • apps/web/src/server/public-api/api/campaigns/create-campaign.ts
  • apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx
  • apps/web/src/server/public-api/api/campaigns/schedule-campaign.ts
  • apps/web/src/server/public-api/schemas/campaign-schema.ts
  • apps/web/src/server/public-api/api/campaigns/resume-campaign.ts
  • apps/web/src/server/public-api/api/campaigns/get-campaign.ts
  • apps/web/src/server/api/routers/campaign.ts
  • apps/web/src/server/service/campaign-service.ts
  • apps/web/src/server/public-api/index.ts
  • apps/web/src/server/public-api/api/campaigns/pause-campaign.ts
**/*.{ts,tsx,md}

📄 CodeRabbit inference engine (AGENTS.md)

Format code with Prettier 3 (run pnpm format)

Files:

  • apps/web/src/server/public-api/api/campaigns/create-campaign.ts
  • apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx
  • apps/web/src/server/public-api/api/campaigns/schedule-campaign.ts
  • apps/web/src/server/public-api/schemas/campaign-schema.ts
  • apps/web/src/server/public-api/api/campaigns/resume-campaign.ts
  • apps/web/src/server/public-api/api/campaigns/get-campaign.ts
  • apps/web/src/server/api/routers/campaign.ts
  • apps/web/src/server/service/campaign-service.ts
  • apps/web/src/server/public-api/index.ts
  • apps/web/src/server/public-api/api/campaigns/pause-campaign.ts
apps/web/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

apps/web/**/*.{ts,tsx}: In apps/web, use the / alias for src imports (e.g., import { x } from "/utils/x")
Prefer using tRPC in apps/web unless explicitly asked otherwise

Files:

  • apps/web/src/server/public-api/api/campaigns/create-campaign.ts
  • apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx
  • apps/web/src/server/public-api/api/campaigns/schedule-campaign.ts
  • apps/web/src/server/public-api/schemas/campaign-schema.ts
  • apps/web/src/server/public-api/api/campaigns/resume-campaign.ts
  • apps/web/src/server/public-api/api/campaigns/get-campaign.ts
  • apps/web/src/server/api/routers/campaign.ts
  • apps/web/src/server/service/campaign-service.ts
  • apps/web/src/server/public-api/index.ts
  • apps/web/src/server/public-api/api/campaigns/pause-campaign.ts
**/*.{tsx,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

Name React component files in PascalCase (e.g., AppSideBar.tsx)

Files:

  • apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx
🧬 Code graph analysis (10)
apps/web/src/server/public-api/api/campaigns/create-campaign.ts (3)
apps/web/src/server/public-api/schemas/campaign-schema.ts (4)
  • campaignCreateSchema (31-55)
  • campaignResponseSchema (70-97)
  • CampaignCreateInput (67-67)
  • parseScheduledAt (10-29)
apps/web/src/server/public-api/hono.ts (1)
  • PublicAPIApp (136-136)
apps/web/src/server/service/campaign-service.ts (3)
  • createCampaignFromApi (163-280)
  • scheduleCampaign (384-475)
  • getCampaignForTeam (282-329)
apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx (1)
packages/email-editor/src/editor.tsx (1)
  • Editor (74-116)
apps/web/src/server/public-api/api/campaigns/schedule-campaign.ts (3)
apps/web/src/server/public-api/schemas/campaign-schema.ts (3)
  • campaignScheduleSchema (57-65)
  • CampaignScheduleInput (68-68)
  • parseScheduledAt (10-29)
apps/web/src/server/service/campaign-service.ts (2)
  • scheduleCampaign (384-475)
  • getCampaignForTeam (282-329)
apps/web/src/server/public-api/hono.ts (1)
  • PublicAPIApp (136-136)
apps/web/src/server/public-api/schemas/campaign-schema.ts (1)
apps/web/src/server/public-api/api-error.ts (1)
  • UnsendApiError (62-75)
apps/web/src/server/public-api/api/campaigns/resume-campaign.ts (2)
apps/web/src/server/service/campaign-service.ts (2)
  • resumeCampaign (503-534)
  • getCampaignForTeam (282-329)
apps/web/src/server/public-api/hono.ts (1)
  • PublicAPIApp (136-136)
apps/web/src/server/public-api/api/campaigns/get-campaign.ts (3)
apps/web/src/server/public-api/schemas/campaign-schema.ts (1)
  • campaignResponseSchema (70-97)
apps/web/src/server/public-api/hono.ts (1)
  • PublicAPIApp (136-136)
apps/web/src/server/service/campaign-service.ts (1)
  • getCampaignForTeam (282-329)
apps/web/src/server/api/routers/campaign.ts (1)
packages/email-editor/src/renderer.tsx (1)
  • EmailRenderer (172-774)
apps/web/src/server/service/campaign-service.ts (2)
packages/email-editor/src/renderer.tsx (1)
  • EmailRenderer (172-774)
apps/web/src/server/service/domain-service.ts (2)
  • validateApiKeyDomainAccess (130-152)
  • validateDomainFromEmail (85-128)
apps/web/src/server/public-api/index.ts (1)
apps/web/src/server/service/campaign-service.ts (3)
  • scheduleCampaign (384-475)
  • pauseCampaign (477-501)
  • resumeCampaign (503-534)
apps/web/src/server/public-api/api/campaigns/pause-campaign.ts (2)
apps/web/src/server/service/campaign-service.ts (2)
  • pauseCampaign (477-501)
  • getCampaignForTeam (282-329)
apps/web/src/server/public-api/hono.ts (1)
  • PublicAPIApp (136-136)
🪛 ast-grep (0.39.6)
apps/web/src/server/service/campaign-service.ts

[warning] 34-34: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(\\{\\{\\s*${inner}\\s*\\}}, "i")
Note: [CWE-1333] Inefficient Regular Expression Complexity [REFERENCES]
- https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
- https://cwe.mitre.org/data/definitions/1333.html

(regexp-from-variable)


[warning] 50-50: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(regex.source, "gi")
Note: [CWE-1333] Inefficient Regular Expression Complexity [REFERENCES]
- https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
- https://cwe.mitre.org/data/definitions/1333.html

(regexp-from-variable)

🔇 Additional comments (6)
apps/web/src/server/public-api/api/campaigns/create-campaign.ts (1)

39-79: LGTM: create-and-optional-schedule flow is sound

Validation, scheduling precedence (sendNow over scheduledAt), and response shaping look good.

apps/web/src/server/api/routers/campaign.ts (1)

243-252: LGTM: duplicate now copies html too

Copying html ensures visual fidelity of duplicated campaigns.

apps/web/src/server/service/campaign-service.ts (4)

340-356: LGTM: robust unsubscribe placeholder validation before sending

Using prepared HTML + content ensures both editor JSON and raw HTML paths are covered.


405-414: LGTM: error mapping for invalid content in scheduling

Good propagation of parse/render errors into BAD_REQUEST.


743-747: LGTM: per-contact rendering integrates unsubscribe + variables

Correctly leverages the new renderer utility for contact-specific HTML.


211-214: Prisma composite unique verification
Ensure the Prisma schema declares a composite unique key or compound primary key on ContactBook(id, teamId). If it exists, findUnique({ where: { id: contactBookId, teamId } }) is valid; otherwise use findFirst or add the composite constraint.

Comment on lines 127 to 131
.mutation(async ({ ctx: { db, team, campaign: campaignOld }, input }) => {
const { campaignId, ...data } = input;
const { campaignId, html: htmlInput, ...data } = input;
if (data.contactBookId) {
const contactBook = await db.contactBook.findUnique({
where: { id: data.contactBookId },
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Use ctx.campaign id instead of input; avoid id forgery and missing input

Rely on campaignOld.id from context (already authorized) instead of input.campaignId.

As per coding guidelines

-      const { campaignId, html: htmlInput, ...data } = input;
+      const { html: htmlInput, ...data } = input;
@@
-      const campaign = await db.campaign.update({
-        where: { id: campaignId },
-        data: campaignUpdateData,
-      });
+      const campaign = await db.campaign.update({
+        where: { id: campaignOld.id },
+        data: campaignUpdateData,
+      });

Also applies to: 167-170

🤖 Prompt for AI Agents
In apps/web/src/server/api/routers/campaign.ts around lines 127-131 (and
similarly at lines 167-170), the mutation uses input.campaignId which allows id
forgery or missing input; replace uses of input.campaignId with the authorized
campaign id from the context (campaignOld.id or ctx.campaign.id) everywhere in
this handler so the DB queries and updates always use the server-provided,
authorized campaign id; remove or ignore campaignId from input and adjust
variable extraction accordingly.

Comment on lines 149 to +156
if (data.content) {
const jsonContent = data.content ? JSON.parse(data.content) : null;

const renderer = new EmailRenderer(jsonContent);
html = await renderer.render();
htmlToSave = await renderer.render();
} else if (typeof htmlInput === "string") {
htmlToSave = htmlInput;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Guard JSON parsing and drop unnecessary await

Prevent crashes on invalid JSON; render() is synchronous.

-      if (data.content) {
-        const jsonContent = data.content ? JSON.parse(data.content) : null;
-
-        const renderer = new EmailRenderer(jsonContent);
-        htmlToSave = await renderer.render();
-      } else if (typeof htmlInput === "string") {
+      if (data.content) {
+        let jsonContent: unknown = null;
+        try {
+          jsonContent = JSON.parse(data.content);
+        } catch {
+          throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid content JSON" });
+        }
+        const renderer = new EmailRenderer(jsonContent as any);
+        htmlToSave = renderer.render();
+      } else if (typeof htmlInput === "string") {
         htmlToSave = htmlInput;
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (data.content) {
const jsonContent = data.content ? JSON.parse(data.content) : null;
const renderer = new EmailRenderer(jsonContent);
html = await renderer.render();
htmlToSave = await renderer.render();
} else if (typeof htmlInput === "string") {
htmlToSave = htmlInput;
}
if (data.content) {
let jsonContent: unknown = null;
try {
jsonContent = JSON.parse(data.content);
} catch {
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid content JSON" });
}
const renderer = new EmailRenderer(jsonContent as any);
htmlToSave = renderer.render();
} else if (typeof htmlInput === "string") {
htmlToSave = htmlInput;
}
🤖 Prompt for AI Agents
In apps/web/src/server/api/routers/campaign.ts around lines 149 to 156, the code
currently unconditionally JSON.parse(data.content) which can throw on invalid
JSON and calls await on renderer.render() though render() is synchronous; update
it to first check data.content is a non-empty string, then try to JSON.parse
inside a try/catch and handle parse errors (e.g., set jsonContent = null or
return a validation error), instantiate EmailRenderer with the parsed or null
value, and call renderer.render() without await (assign directly) so no
unnecessary await is used.

Comment on lines +1 to +55
import { createRoute, z } from "@hono/zod-openapi";
import { PublicAPIApp } from "~/server/public-api/hono";
import {
getCampaignForTeam,
pauseCampaign as pauseCampaignService,
} from "~/server/service/campaign-service";
import { campaignResponseSchema } from "~/server/public-api/schemas/campaign-schema";

const route = createRoute({
method: "post",
path: "/v1/campaigns/{campaignId}/pause",
request: {
params: z.object({
campaignId: z
.string()
.min(1)
.openapi({
param: {
name: "campaignId",
in: "path",
},
example: "cmp_123",
}),
}),
},
responses: {
200: {
description: "Pause a campaign",
content: {
"application/json": {
schema: z.object({
success: z.boolean(),
}),
},
},
},
},
});

function pauseCampaign(app: PublicAPIApp) {
app.openapi(route, async (c) => {
const team = c.var.team;
const campaignId = c.req.param("campaignId");

await pauseCampaignService({
campaignId,
teamId: team.id,
});

await getCampaignForTeam({
campaignId,
teamId: team.id,
});

return c.json({ success: true });
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Remove the unused import and redundant query

campaignResponseSchema is imported but never used, which ESLint treats as an error in this repo. Additionally, the extra getCampaignForTeam call only repeats the database lookup and its result is discarded. Please drop both to keep the handler lint-clean and avoid the needless query.

-import {
-  getCampaignForTeam,
-  pauseCampaign as pauseCampaignService,
-} from "~/server/service/campaign-service";
-import { campaignResponseSchema } from "~/server/public-api/schemas/campaign-schema";
+import { pauseCampaign as pauseCampaignService } from "~/server/service/campaign-service";
@@
-    await pauseCampaignService({
-      campaignId,
-      teamId: team.id,
-    });
-
-    await getCampaignForTeam({
-      campaignId,
-      teamId: team.id,
-    });
+    await pauseCampaignService({
+      campaignId,
+      teamId: team.id,
+    });
🤖 Prompt for AI Agents
In apps/web/src/server/public-api/api/campaigns/pause-campaign.ts around lines
1-55, remove the unused import campaignResponseSchema from the top of the file
and delete the redundant await getCampaignForTeam(...) call inside the route
handler (the call’s result is never used); keep the pauseCampaignService call
and the final return c.json({ success: true }) so the handler behavior remains
the same and ESLint no longer flags the unused import or unnecessary DB query.

Comment on lines +8 to +10
campaignResponseSchema,
parseScheduledAt,
} from "~/server/public-api/schemas/campaign-schema";
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Remove unused imports to satisfy ESLint and reduce bundle

campaignResponseSchema and parseScheduledAt are not used.

As per coding guidelines

-import {
-  campaignResponseSchema,
-  parseScheduledAt,
-} from "~/server/public-api/schemas/campaign-schema";
+// (no imports needed from campaign-schema here)

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/web/src/server/public-api/api/campaigns/resume-campaign.ts around lines
8 to 10, the imports campaignResponseSchema and parseScheduledAt are unused;
remove them from the import list (or from the file entirely if only referenced
in that import) to satisfy ESLint and reduce bundle size, then run the
linter/build to confirm no remaining references or type errors.

Comment on lines +4 to +8
campaignScheduleSchema,
CampaignScheduleInput,
campaignResponseSchema,
parseScheduledAt,
} from "~/server/public-api/schemas/campaign-schema";
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Remove unused import

campaignResponseSchema isn’t used.

As per coding guidelines

-import {
-  campaignScheduleSchema,
-  CampaignScheduleInput,
-  campaignResponseSchema,
-  parseScheduledAt,
-} from "~/server/public-api/schemas/campaign-schema";
+import {
+  campaignScheduleSchema,
+  CampaignScheduleInput,
+  parseScheduledAt,
+} from "~/server/public-api/schemas/campaign-schema";
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
campaignScheduleSchema,
CampaignScheduleInput,
campaignResponseSchema,
parseScheduledAt,
} from "~/server/public-api/schemas/campaign-schema";
import {
campaignScheduleSchema,
CampaignScheduleInput,
parseScheduledAt,
} from "~/server/public-api/schemas/campaign-schema";
🤖 Prompt for AI Agents
In apps/web/src/server/public-api/api/campaigns/schedule-campaign.ts around
lines 4 to 8, the import list includes campaignResponseSchema which is not used;
remove campaignResponseSchema from the import statement (leaving
campaignScheduleSchema, CampaignScheduleInput, parseScheduledAt) so the file no
longer imports an unused symbol and adheres to coding guidelines.

Comment on lines +55 to +75
function replaceContactVariables(html: string, contact: Contact) {
return html.replace(
CONTACT_VARIABLE_REGEX,
(_, key: string, fallback?: string) => {
const valueMap: Record<string, string | null | undefined> = {
email: contact.email,
firstname: contact.firstName,
lastname: contact.lastName,
};

const normalizedKey = key.toLowerCase();
const contactValue = valueMap[normalizedKey];

if (contactValue && contactValue.length > 0) {
return contactValue;
}

return fallback ?? "";
}
);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Escape contact variables to prevent HTML injection in raw-HTML path

Raw replacements can inject arbitrary HTML if contact data contains angle brackets/quotes. Escape both contact values and fallbacks.

-function replaceContactVariables(html: string, contact: Contact) {
-  return html.replace(
-    CONTACT_VARIABLE_REGEX,
-    (_, key: string, fallback?: string) => {
-      const valueMap: Record<string, string | null | undefined> = {
-        email: contact.email,
-        firstname: contact.firstName,
-        lastname: contact.lastName,
-      };
-
-      const normalizedKey = key.toLowerCase();
-      const contactValue = valueMap[normalizedKey];
-
-      if (contactValue && contactValue.length > 0) {
-        return contactValue;
-      }
-
-      return fallback ?? "";
-    }
-  );
-}
+function replaceContactVariables(html: string, contact: Contact) {
+  return html.replace(
+    CONTACT_VARIABLE_REGEX,
+    (_: unknown, key: string, fallback?: string) => {
+      const valueMap: Record<string, string | null | undefined> = {
+        email: contact.email,
+        firstname: contact.firstName,
+        lastname: contact.lastName,
+      };
+      const normalizedKey = key.toLowerCase();
+      const contactValue = valueMap[normalizedKey];
+      const valueToUse =
+        contactValue && contactValue.length > 0 ? contactValue : fallback ?? "";
+      return escapeHtml(valueToUse);
+    }
+  );
+}

Add this helper (outside the above range):

function escapeHtml(s: string) {
  return s
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#039;");
}
🤖 Prompt for AI Agents
In apps/web/src/server/service/campaign-service.ts around lines 55 to 75,
contact variable replacements are inserted as raw HTML which allows HTML
injection if contact values or fallbacks contain special characters; add the
provided escapeHtml helper (placed outside the shown range) and call it on both
the resolved contactValue and the fallback before returning them from the
replace callback so that all returned strings are HTML-escaped (use
escapeHtml(contactValue) and escapeHtml(fallback) or empty string).

Copy link

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

Choose a reason for hiding this comment

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

4 issues found across 13 files

Prompt for AI agents (all 4 issues)

Understand the root cause of the following 4 issues and fix them.


<file name="apps/docs/api-reference/openapi.json">

<violation number="1" location="apps/docs/api-reference/openapi.json:1918">
Document that either content or html must be supplied so the OpenAPI spec matches backend validation.</violation>

<violation number="2" location="apps/docs/api-reference/openapi.json:2000">
Adjust the schedule endpoint schema to describe the broader accepted scheduledAt values instead of the restrictive date-time format.</violation>
</file>

<file name="apps/web/src/server/service/campaign-service.ts">

<violation number="1" location="apps/web/src/server/service/campaign-service.ts:72">
HTML-escape contact values and fallbacks before inserting into raw HTML to prevent injection when data contains special characters</violation>
</file>

<file name="apps/web/src/server/public-api/api/campaigns/pause-campaign.ts">

<violation number="1" location="apps/web/src/server/public-api/api/campaigns/pause-campaign.ts:50">
Remove this redundant database call; the result is unused and only adds overhead</violation>
</file>

React with 👍 or 👎 to teach cubic. Mention @cubic-dev-ai to give feedback, ask questions, or re-run the review.

},
"scheduledAt": {
"type": "string",
"format": "date-time"
Copy link

@cubic-dev-ai cubic-dev-ai bot Oct 13, 2025

Choose a reason for hiding this comment

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

Adjust the schedule endpoint schema to describe the broader accepted scheduledAt values instead of the restrictive date-time format.

Prompt for AI agents
Address the following comment on apps/docs/api-reference/openapi.json at line 2000:

<comment>Adjust the schedule endpoint schema to describe the broader accepted scheduledAt values instead of the restrictive date-time format.</comment>

<file context>
@@ -1910,6 +1910,531 @@
+                  },
+                  &quot;scheduledAt&quot;: {
+                    &quot;type&quot;: &quot;string&quot;,
+                    &quot;format&quot;: &quot;date-time&quot;
+                  },
+                  &quot;batchSize&quot;: {
</file context>
Suggested change
"format": "date-time"
"description": "Timestamp in ISO 8601 format or natural language (e.g., 'tomorrow 9am')"
Fix with Cubic

"post": {
"requestBody": {
"required": true,
"content": {
Copy link

@cubic-dev-ai cubic-dev-ai bot Oct 13, 2025

Choose a reason for hiding this comment

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

Document that either content or html must be supplied so the OpenAPI spec matches backend validation.

Prompt for AI agents
Address the following comment on apps/docs/api-reference/openapi.json at line 1918:

<comment>Document that either content or html must be supplied so the OpenAPI spec matches backend validation.</comment>

<file context>
@@ -1910,6 +1910,531 @@
+      &quot;post&quot;: {
+        &quot;requestBody&quot;: {
+          &quot;required&quot;: true,
+          &quot;content&quot;: {
+            &quot;application/json&quot;: {
+              &quot;schema&quot;: {
</file context>
Fix with Cubic

return contactValue;
}

return fallback ?? "";
Copy link

@cubic-dev-ai cubic-dev-ai bot Oct 13, 2025

Choose a reason for hiding this comment

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

HTML-escape contact values and fallbacks before inserting into raw HTML to prevent injection when data contains special characters

Prompt for AI agents
Address the following comment on apps/web/src/server/service/campaign-service.ts at line 72:

<comment>HTML-escape contact values and fallbacks before inserting into raw HTML to prevent injection when data contains special characters</comment>

<file context>
@@ -19,52 +19,339 @@ import { logger } from &quot;../logger/log&quot;;
+        return contactValue;
+      }
+
+      return fallback ?? &quot;&quot;;
+    }
+  );
</file context>
Fix with Cubic

teamId: team.id,
});

await getCampaignForTeam({
Copy link

@cubic-dev-ai cubic-dev-ai bot Oct 13, 2025

Choose a reason for hiding this comment

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

Remove this redundant database call; the result is unused and only adds overhead

Prompt for AI agents
Address the following comment on apps/web/src/server/public-api/api/campaigns/pause-campaign.ts at line 50:

<comment>Remove this redundant database call; the result is unused and only adds overhead</comment>

<file context>
@@ -0,0 +1,59 @@
+      teamId: team.id,
+    });
+
+    await getCampaignForTeam({
+      campaignId,
+      teamId: team.id,
</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