Skip to content

feat: add domain-based access control for API keys - #198

Merged
KMKoushik merged 5 commits into
usesend:api-key-with-domain-access-controlfrom
enemyrr:feat/api-key-domain-access-control
Sep 10, 2025
Merged

feat: add domain-based access control for API keys#198
KMKoushik merged 5 commits into
usesend:api-key-with-domain-access-controlfrom
enemyrr:feat/api-key-domain-access-control

Conversation

@enemyrr

@enemyrrenemyrr commented Aug 22, 2025

Copy link
Copy Markdown
Contributor

Summary

  • Adds domain-based access control for API keys to enhance security
  • API keys can now be restricted to specific domains or granted access to all domains
  • Maintains backward compatibility with existing API keys

Changes

  • Database: Added domainId field to ApiKey model with foreign key relation to Domain
  • Frontend: Added domain selection dropdown in API key creation form
  • Frontend: Updated API keys table to display domain access information
  • Backend: Enhanced API key validation to check domain access permissions
  • API: Updated public API endpoints to respect domain-based access controls

Features

  • Domain Selection: When creating an API key, users can choose "All Domains" or select a specific verified domain
  • Access Control: API requests are validated against the API key's domain permissions
  • Backward Compatibility: Existing API keys automatically have access to all domains (domainId = null)
  • Security: API keys with domain restrictions can only send emails from their assigned domain
  • Error Handling: Clear error messages when API key lacks domain access

Test plan

  • Database migration runs successfully
  • API key creation with domain selection works
  • API keys table shows domain access correctly
  • Domain validation works in email sending
  • Public API respects domain restrictions
  • Backward compatibility maintained for existing keys

Summary by CodeRabbit

  • New Features

    • Option to restrict API keys to a specific domain or allow access to all domains.
    • Domain Access selector when creating API keys; API keys list shows each key’s domain access.
    • Database migration adding optional domain association for API keys.
  • Behavior Changes

    • Public API domain listing and email endpoints now respect an API key’s domain scope.
    • Email sending and related operations enforce domain-level access and surface clearer errors when access is denied.

Add functionality to limit API key usage to specific domains for enhanced security:
- Add domainId field to ApiKey model with foreign key to Domain
- Update API key creation to support domain selection (defaults to "All Domains")
- Add domain access dropdown in frontend with verified domains list
- Display domain access in API keys table
- Implement domain validation for API requests
- Filter domain endpoints based on API key access
- Maintain backward compatibility (existing keys have access to all domains)
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 22, 2025

Copy link
Copy Markdown
Contributor

Walkthrough

Adds optional domain scoping for API keys: DB migration adds nullable ApiKey.domainId with FK; Prisma schema and service/route code propagate and validate domain restrictions; public API/context includes API key domain info; dashboard UI gains domain select and list column; email/public routes enforce domain access.

Changes

Cohort / File(s)Summary
Database migration & Prisma schema
apps/web/prisma/migrations/20250822125136_add_domain_access_to_api_keys/migration.sql, apps/web/prisma/schema.prisma
Adds nullable ApiKey.domainId column and FK ApiKey_domainId_fkeyDomain(id) (ON DELETE SET NULL, ON UPDATE CASCADE). Prisma: Domain.apiKeys added; ApiKey.domainId (Int?) and ApiKey.domain relation added (onDelete: Cascade in schema).
Dashboard UI — create & list
apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx, apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx
Add Domain access select to API key creation form (option "All Domains"); wire domainId into create payload when a specific domain chosen. Add Domain Access column to API keys table and adjust colspans/row rendering.
tRPC router / API endpoints
apps/web/src/server/api/routers/api.ts
createToken input now accepts optional domainId (number) and forwards it to addApiKey; getApiKeys selects domainId and nested domain.name. Permission enum switched to Prisma native enum.
Server service logic
apps/web/src/server/service/api-service.ts, apps/web/src/server/service/domain-service.ts, apps/web/src/server/service/email-service.ts
addApiKey accepts optional domainId, validates ownership (throws on not found), and persists it. New validateApiKeyDomainAccess(...) enforces API-key domain restrictions. sendEmail branches to validate domain via API key when apiKeyId present; getTeamAndApiKey includes domain relation.
Public API auth, types & runtime env
apps/web/src/server/public-api/auth.ts, apps/web/src/server/public-api/hono.ts
getTeamFromToken augmented to include apiKey: { domainId } in returned team/context. AppEnv.Variables.team typing extended to include `apiKey: { domainId: number
Public API routes & utilities
apps/web/src/server/public-api/api/domains/get-domains.ts, apps/web/src/server/public-api/api/domains/verify-domain.ts, apps/web/src/server/public-api/api/emails/*, apps/web/src/server/public-api/api-utils.ts
Domain listing/verify endpoints and email routes now respect API-key domain scoping: if team.apiKey.domainId set, restrict queries/actions to that domain. verify-domain adds access check + explicit 403/404 behaviors. New helper checkIsValidEmailIdWithDomainRestriction(...) added and used across email handlers; list-emails filtering precedence adjusted to prefer API-key domain restriction.
Selections & returns
apps/web/src/server/api/routers/api.ts, various server handlers
Several handlers include domainId in selects/returns and propagate domain info through responses where applicable.

Sequence Diagram(s)

sequenceDiagram
autonumber
actor User
participant UI as Dashboard UI
participant API as tRPC Router
participant SVC as Api Service
participant DB as Prisma/DB
User->>UI: Submit create API Key (name, permission, domainId?)
UI->>API: createToken({ name, permission, domainId? })
API->>SVC: addApiKey({ teamId, name, permission, domainId? })
SVC->>DB: INSERT ApiKey { teamId, name, permission, domainId? }
DB-->>SVC: Created ApiKey (includes domainId)
SVC-->>API: ApiKey
API-->>UI: Created key (with domain info if present)
Loading
sequenceDiagram
autonumber
actor Client
participant Pub as Public API
participant Dom as Domains Route
participant DB as Prisma/DB
Client->>Pub: GET /domains (with API key)
Pub->>DB: resolve team + apiKey (domainId?)
alt apiKey has domainId
Dom->>DB: findMany({ teamId, id: domainId })
else no domain restriction
Dom->>DB: findMany({ teamId })
end
DB-->>Dom: Domains
Dom-->>Client: Domain list (scoped if restricted)
Loading
sequenceDiagram
autonumber
actor Client
participant Email as Public Email Endpoint
participant DB as Prisma/DB
participant Svc as Domain Service
Client->>Email: POST /send (from, teamId, apiKeyId?)
alt apiKeyId provided
Email->>DB: getTeamAndApiKey(apiKeyId) (includes domain)
opt API key not found
Email-->>Client: 400 BAD_REQUEST (Invalid API key)
end
Email->>Svc: validateApiKeyDomainAccess(from, teamId, apiKey)
else no apiKeyId
Email->>Svc: validateDomainFromEmail(from, teamId)
end
Svc-->>Email: Resolved domain
Email-->>Client: Proceed with send using domain.id
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Pre-merge checks (2 passed, 1 warning)

❌ Failed checks (1 warning)
Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 6.67% which is insufficient. The required threshold is 80.00%.You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check nameStatusExplanation
Title Check✅ PassedThe title succinctly describes the primary change of adding domain-based access control for API keys, which matches the core objective of the pull request. It is concise and omits extraneous details, enabling reviewers to immediately grasp the main feature. The use of the conventional “feat:” prefix aligns with common repository naming conventions without introducing noise.
Description Check✅ PassedThe description thoroughly outlines the pull request’s goals and changes, covering database schema updates, frontend form and table adjustments, backend validation enhancements, API modifications, and the test plan, all of which correspond directly to the summarized changes. It remains focused and on-topic, providing relevant context and features without diverging into unrelated areas. This level of detail clearly communicates the scope and intent of the PR for reviewers.

Poem

I nibble at migrations and hop through the trees,
Linking keys with domains on a soft summer breeze.
I check each envelope, nose twitching with pride,
One domain or many — the rules now decide.
A carrot, a hop, and a tidy schema stride. 🥕

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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

@coderabbitaicoderabbitaiBot 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.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
apps/web/src/server/service/email-service.ts (2)

490-499: Use domain-aware validation when recording suppressed bulk emails.

Even when all TOs are suppressed, we still create an Email row. Use the same domain resolution rules (respecting apiKeyId) to avoid creating records under the wrong domain.

- // Validate domain for suppressed email too- const domain = await validateDomainFromEmail(from, teamId);+ // Validate domain for suppressed email with API key awareness+ let domain: Awaited<ReturnType<typeof validateDomainFromEmail>>;+ if (apiKeyId) {+ const apiKey = await db.apiKey.findUnique({+ where: { id: apiKeyId },+ include: { domain: true },+ });+ if (!apiKey) {+ throw new UnsendApiError({ code: "BAD_REQUEST", message: "Invalid API key" });+ }+ domain = await validateApiKeyDomainAccess(from, teamId, apiKey);+ } else {+ domain = await validateDomainFromEmail(from, teamId);+ }

Also applies to: 522-554


1-10: Fix invalid findUnique call in validateDomainFromEmail

The call in apps/web/src/server/service/domain-service.ts at line 37 uses:

constdomain=awaitdb.domain.findUnique({where: {name: fromDomain, teamId },});

However, your Prisma schema defines name as globally unique (String @unique) and does not define a composite unique constraint on (teamId, name). As a result, findUnique({ where: { name, teamId } }) is an invalid call and will fail at runtime.

Choose one of the following resolutions:

• Quick fix with runtime check
– Change to
ts const domain = await db.domain.findUnique({ where: { name: fromDomain }, }); if (!domain || domain.teamId !== teamId) { throw new UnsendApiError("Domain not found or access denied"); }
– No schema changes required.

• Schema-backed composite unique

  1. In apps/web/prisma/schema.prisma, update the Domain model:
    model Domain {
    id Int @id @default(autoincrement())
    name String @unique
    teamId Int
    + @@unique([teamId, name])
    // …other fields…
    }
  2. Run prisma migrate dev to add the composite index.
  3. Use a named unique input for findUnique, e.g.:
    constdomain=awaitdb.domain.findUnique({where: {teamId_name: { teamId,name: fromDomain}},});

Alternatively, you can opt for findFirst({ where: { name: fromDomain, teamId } }) if you prefer not to modify the schema—though without a composite index this may be less efficient.

Impacted locations:

  • apps/web/src/server/service/domain-service.ts @ line 37 (findUnique usage in validateDomainFromEmail)
  • (If choosing the schema-backed approach) apps/web/prisma/schema.prisma – add @@unique([teamId, name])
apps/web/src/server/service/domain-service.ts (1)

13-55: Critical: Prisma Domain model missing composite unique on (name, teamId)

I inspected apps/web/prisma/schema.prisma and found that the Domain model declares name String @unique but does not include a composite unique constraint on (name, teamId). As written, the call:

awaitdb.domain.findUnique({where: {name: fromDomain, teamId },});

will fail to compile under Prisma’s generated typings (and error at runtime), since DomainWhereUniqueInput only accepts unique fields (id or name), not both name and teamId.

Please address this by either:

  • Adding a composite unique index to the Domain model:

    model Domain {
    id Int @id @default(autoincrement())
    name String
    teamId Int
    … 

Then run `prisma migrate dev` (or equivalent) to apply the change.
- **Or** changing the query to use a non-unique finder, for example:
```ts
const domain = await db.domain.findFirst({
where: { name: fromDomain, teamId },
});

Whichever approach you choose, ensure the Prisma schema and client typings align with the intended lookup.

🧹 Nitpick comments (16)
apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx (2)

56-58: Handle deleted or missing domains explicitly; keep wording consistent.

  • With ON DELETE SET NULL, a formerly restricted key can display “All Domains” after its domain is deleted, which may mask a privilege broadening. Prefer showing an explicit state.
  • Consistency: either “All domains” or “All Domains” across the UI.

Apply a safer display fallback:

- <TableCell>- {apiKey.domainId ? apiKey.domain?.name : "All Domains"}- </TableCell>+ <TableCell>+ {apiKey.domainId+ ? apiKey.domain?.name ?? "Domain removed"+ : "All domains"}+ </TableCell>

Also consider a small tooltip: “All domains: this key can send from any verified domain.”


60-62: Consistent relative time formatting.

createdAt uses addSuffix; lastUsed doesn’t. Align for consistency.

- {apiKey.lastUsed- ? formatDistanceToNow(apiKey.lastUsed)- : "Never"}+ {apiKey.lastUsed+ ? formatDistanceToNow(apiKey.lastUsed, { addSuffix: true })+ : "Never"}
apps/web/src/server/service/email-service.ts (1)

73-94: Type the domain variable to satisfy strict TS and prevent implicit any.

With let domain; TypeScript infers any. Annotate with the Prisma Domain type (or the return type of the validator) for safety.

-import { EmailRenderer } from "@unsend/email-editor/src/renderer";+import { EmailRenderer } from "@unsend/email-editor/src/renderer";+import type { Domain as PrismaDomain } from "@prisma/client";
@@
- let domain;+ let domain: PrismaDomain;

Alternatively:

- let domain;+ let domain: Awaited<ReturnType<typeof validateDomainFromEmail>>;
apps/web/src/server/public-api/hono.ts (1)

25-38: Minor typing cleanup: avoid casting to any.

c as any hides type errors. getTeamFromToken can accept Context and return the sanitized type so the cast isn’t needed.

Proposed adjustment (sketch):

  • Change getTeamFromToken signature to accept Context.
  • Here: const team = await getTeamFromToken(c);
apps/web/src/server/service/api-service.ts (1)

51-53: Avoid over-fetching: select only needed domain fields

You only use domain metadata downstream; fetching the entire domain object is unnecessary. Narrow the include to selected fields.

- include: {- domain: true,- },+ include: {+ domain: {+ select: { id: true, name: true },+ },+ },
apps/web/src/server/api/routers/api.ts (1)

13-17: Harden input schema: use native Prisma enum and stronger number constraints

  • Replace string literal enum with z.nativeEnum(ApiPermission) to avoid drift if enum values change in Prisma.
  • Constrain domainId as an integer and positive.
-import { z } from "zod";+import { z } from "zod";+import { ApiPermission } from "@prisma/client";
@@
- z.object({ - name: z.string(), - permission: z.enum(["FULL", "SENDING"]),- domainId: z.number().optional()- })+ z.object({+ name: z.string(),+ permission: z.nativeEnum(ApiPermission),+ domainId: z.number().int().positive().optional(),+ })
apps/web/prisma/migrations/20250822125136_add_domain_access_to_api_keys/migration.sql (1)

1-5: Consider indexing ApiKey.domainId

Queries that filter or join by domainId (e.g., audits, admin views) will benefit from an index. Postgres doesn’t auto-index FKs.

 ALTER TABLE "ApiKey" ADD COLUMN "domainId" INTEGER;
@@
ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_domainId_fkey" FOREIGN KEY ("domainId") REFERENCES "Domain"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+-- Optional: add index for faster filters/joins on domainId+CREATE INDEX IF NOT EXISTS "ApiKey_domainId_idx" ON "ApiKey" ("domainId");
apps/web/src/server/service/domain-service.ts (1)

9-9: Minor: Narrow the parameter type or use the embedded domain

You don’t use apiKey.domain here. Either drop it from the type to just ApiKey, or incorporate it in the error message (e.g., to show the key’s allowed domain on mismatch). Keeping the surface minimal reduces confusion.

apps/web/src/server/public-api/api/domains/get-domains.ts (2)

26-38: Simplify branching and avoid implicit any on domains

let domains; infers any under strict settings. You can keep strong typing and readability by computing a single const with a conditional.

- // If API key is restricted to a specific domain, only return that domain- let domains;- if (team.apiKey.domainId) {- domains = await db.domain.findMany({ - where: { - teamId: team.id,- id: team.apiKey.domainId - } - });- } else {- // If API key has access to all domains, return all team domains- domains = await db.domain.findMany({ where: { teamId: team.id } });- }+ // If API key is restricted to a specific domain, only return that domain; else return all team domains+ const domains = team.apiKey.domainId+ ? await db.domain.findMany({+ where: { teamId: team.id, id: team.apiKey.domainId },+ })+ : await db.domain.findMany({ where: { teamId: team.id } });

7-20: Minor: OpenAPI description mismatch

The 200 response description says “Retrieve the user”. This route returns domains. Update the description to avoid confusing SDK generators or consumers.

apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx (6)

30-36: Reorder imports to follow repo import grouping/alphabetization guidelines.

Per guidelines, group external libs (react, zod, react-hook-form, lucide-react), then internal packages (e.g., @unsend/ui, ~/trpc/*), and alphabetize within groups. This improves readability and reduces churn in future diffs.


42-42: Tighten schema: restrict domainId to 'all' or a numeric ID string.

Strengthens validation against unexpected values while keeping the form’s string-based Select model intact.

Apply this diff:

- domainId: z.string().optional(),+ // "all" means unrestricted; otherwise a numeric id (as string)+ domainId: z+ .union([z.literal("all"), z.string().regex(/^\d+$/, "Invalid domain id")])+ .optional(),

52-53: Handle domains query loading/error and confirm verified-domain filtering.

  • UX: When the domains query is loading or errors, the Select should reflect that state (disabled + inline item).
  • Data: PR objective mentions “specific verified domain.” Verify this query only surfaces verified domains (or filter client-side if the API doesn’t yet). Also consider empty-state messaging when there are no domains.

Would you confirm whether api.domain.domains.useQuery() returns only verified domains? If not, should we filter to verified in the UI until the API adds a filter?


69-69: Safer conversion and guard for undefined values.

Prevents accidental NaN if domainId is ever undefined and makes the intent explicit.

Apply this diff:

- domainId: values.domainId === "all" ? undefined : Number(values.domainId),+ domainId:+ values.domainId && values.domainId !== "all"+ ? Number.parseInt(values.domainId, 10)+ : undefined,

64-79: Add error handling for API key creation to surface failures to the user.

Currently only onSuccess is handled; add onError to show a toast and keep the dialog open. This improves UX and aligns with the error-handling guideline.

You can update the mutation call like this (illustrative snippet outside the changed lines):

createApiKeyMutation.mutate({name: values.name,permission: "FULL",domainId:
values.domainId&&values.domainId!=="all"
? Number.parseInt(values.domainId,10)
: undefined,},{onSuccess: (data)=>{utils.apiKey.invalidate();setApiKey(data);apiKeyForm.reset();},onError: (err)=>{toast.error(err?.message??"Failed to create API key");},});

81-87: Handle clipboard write errors to avoid silent failures.

navigator.clipboard.writeText can reject (permissions, insecure context). Catch and notify the user.

You can wrap it as:

asyncfunctionhandleCopy(){try{awaitnavigator.clipboard.writeText(apiKey);setIsCopied(true);setTimeout(()=>setIsCopied(false),2000);}catch{toast.error("Could not copy to clipboard. Please copy manually.");}}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between a895db7 and 80a2118.

📒 Files selected for processing (11)
  • apps/web/prisma/migrations/20250822125136_add_domain_access_to_api_keys/migration.sql (1 hunks)
  • apps/web/prisma/schema.prisma (2 hunks)
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx (4 hunks)
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx (4 hunks)
  • apps/web/src/server/api/routers/api.ts (2 hunks)
  • apps/web/src/server/public-api/api/domains/get-domains.ts (1 hunks)
  • apps/web/src/server/public-api/auth.ts (1 hunks)
  • apps/web/src/server/public-api/hono.ts (1 hunks)
  • apps/web/src/server/service/api-service.ts (3 hunks)
  • apps/web/src/server/service/domain-service.ts (2 hunks)
  • apps/web/src/server/service/email-service.ts (2 hunks)
🧰 Additional context used
📓 Path-based instructions (6)
{apps,packages}/**/*.{js,jsx,ts,tsx,css,scss,md,mdx}

📄 CodeRabbit inference engine (CLAUDE.md)

Use Prettier with the Tailwind plugin for code formatting

Files:

  • apps/web/src/server/service/email-service.ts
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx
  • apps/web/src/server/public-api/api/domains/get-domains.ts
  • apps/web/src/server/public-api/hono.ts
  • apps/web/src/server/api/routers/api.ts
  • apps/web/src/server/public-api/auth.ts
  • apps/web/src/server/service/api-service.ts
  • apps/web/src/server/service/domain-service.ts
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx
{apps,packages}/**/*.{js,jsx,ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

{apps,packages}/**/*.{js,jsx,ts,tsx}: Group imports by source (internal/external) and alphabetize them
Use camelCase for variables and functions, PascalCase for components and classes
Use try/catch with specific error types for error handling

Files:

  • apps/web/src/server/service/email-service.ts
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx
  • apps/web/src/server/public-api/api/domains/get-domains.ts
  • apps/web/src/server/public-api/hono.ts
  • apps/web/src/server/api/routers/api.ts
  • apps/web/src/server/public-api/auth.ts
  • apps/web/src/server/service/api-service.ts
  • apps/web/src/server/service/domain-service.ts
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx
{apps,packages}/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

{apps,packages}/**/*.{ts,tsx}: Use strong typing in TypeScript, avoid any, and use Zod for validation
Follow Vercel style guides with strict TypeScript

Files:

  • apps/web/src/server/service/email-service.ts
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx
  • apps/web/src/server/public-api/api/domains/get-domains.ts
  • apps/web/src/server/public-api/hono.ts
  • apps/web/src/server/api/routers/api.ts
  • apps/web/src/server/public-api/auth.ts
  • apps/web/src/server/service/api-service.ts
  • apps/web/src/server/service/domain-service.ts
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx
apps/web/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Use tRPC for internal API endpoints

Files:

  • apps/web/src/server/service/email-service.ts
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx
  • apps/web/src/server/public-api/api/domains/get-domains.ts
  • apps/web/src/server/public-api/hono.ts
  • apps/web/src/server/api/routers/api.ts
  • apps/web/src/server/public-api/auth.ts
  • apps/web/src/server/service/api-service.ts
  • apps/web/src/server/service/domain-service.ts
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx
**/*.{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/service/email-service.ts
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx
  • apps/web/src/server/public-api/api/domains/get-domains.ts
  • apps/web/src/server/public-api/hono.ts
  • apps/web/src/server/api/routers/api.ts
  • apps/web/src/server/public-api/auth.ts
  • apps/web/src/server/service/api-service.ts
  • apps/web/src/server/service/domain-service.ts
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx
{apps,packages}/**/*.{jsx,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

{apps,packages}/**/*.{jsx,tsx}: Use functional React components with hooks and group related hooks together
In React components, structure code with props at the top, hooks next, helper functions, then JSX

Files:

  • apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx
🧠 Learnings (1)
📚 Learning: 2025-08-09T10:37:58.146Z
Learnt from: CR
PR: unsend-dev/unsend#0
File: CLAUDE.md:0-0
Timestamp: 2025-08-09T10:37:58.146Z
Learning: Applies to apps/smtp-server/**/*.{ts,tsx} : Use Hono for public API endpoints

Applied to files:

  • apps/web/src/server/public-api/hono.ts
🧬 Code graph analysis (6)
apps/web/src/server/service/email-service.ts (2)
apps/web/src/server/public-api/api-error.ts (1)
  • UnsendApiError (62-75)
apps/web/src/server/service/domain-service.ts (2)
  • validateApiKeyDomainAccess (58-80)
  • validateDomainFromEmail (13-56)
apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx (1)
packages/ui/src/table.tsx (2)
  • TableHead (113-113)
  • TableCell (115-115)
apps/web/src/server/public-api/api/domains/get-domains.ts (1)
apps/web/src/server/db.ts (1)
  • db (20-20)
apps/web/src/server/api/routers/api.ts (1)
apps/web/src/server/service/api-service.ts (1)
  • addApiKey (8-42)
apps/web/src/server/service/domain-service.ts (1)
apps/web/src/server/public-api/api-error.ts (1)
  • UnsendApiError (62-75)
apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx (2)
packages/ui/src/form.tsx (5)
  • FormField (176-176)
  • FormItem (171-171)
  • FormLabel (172-172)
  • FormControl (173-173)
  • FormDescription (174-174)
packages/ui/src/select.tsx (5)
  • Select (150-150)
  • SelectTrigger (153-153)
  • SelectValue (152-152)
  • SelectContent (154-154)
  • SelectItem (156-156)
🔇 Additional comments (5)
apps/web/prisma/schema.prisma (1)

193-194: Relation back-reference looks correct.

Domain.apiKeys establishes the expected 1:N link to ApiKey and aligns with the PR’s intent.

apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx (2)

28-28: Good UX addition.

The “Domain Access” column provides clear visibility into scoping.


37-37: Colspan updates are correct.

colSpan=7 matches the new column count in the header.

Also applies to: 46-46

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

40-45: LGTM: exposing domain scope in list payload

Selecting domainId and domain.name enables the UI to render “All Domains” vs the specific domain cleanly. This aligns with the PR objective and keeps sensitive fields out.

apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx (1)

60-60: Good default: preserves backward compatibility.

Defaulting to "all" aligns with the objective that existing keys remain unrestricted by domain.

Comment on lines +208 to 215
domainId Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastUsed DateTime?
teamId Int
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull)
}

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.

💡 Verification agent

🧩 Analysis chain

Prevent cross-team association and clarify deletion semantics for security.

  • Today, ApiKey.domainId references Domain(id) without asserting the same team across both sides. An ApiKey could (accidentally) reference a Domain from another team, and validateApiKeyDomainAccess only compares IDs, not team membership. Enforce the invariant teamId(apiKey) === teamId(domain) at DB or service layer.
  • ON DELETE SET NULL silently broadens a restricted key to “All Domains” if its domain is deleted. That’s a surprising privilege escalation.

Actionable suggestions:

  • If you can accept a composite relation, model the relation on [domainId, teamId] → [id, teamId] and check team membership at the DB level. Otherwise, add a strict service-level check in key creation/update to ensure selected domain belongs to the same team.
  • Consider ON DELETE RESTRICT (or disabling the key) instead of SET NULL to avoid unexpectedly broadening access. If SET NULL is desired, surface this clearly in the UI and audit logs.

Apply an index for query performance and prepare for service-side checks:

 model ApiKey {
id Int @id @default(autoincrement())
clientId String @unique
tokenHash String
partialToken String
name String
permission ApiPermission @default(SENDING)
domainId Int?
+ @@index([domainId])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastUsed DateTime?
teamId Int
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
- domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull)+ domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull)
}

If you prefer DB-level invariants and can tolerate a composite optional relation, we can draft the exact Prisma schema changes for [domainId, teamId][id, teamId].


🏁 Script executed:

#!/bin/bashset -e
echo"=== Finding ApiKey model in Prisma schema ==="
rg -n -C3 "model ApiKey" apps/web/prisma/schema.prisma ||trueechoecho"=== Displaying lines 180–260 around ApiKey definition ==="
sed -n '180,260p' apps/web/prisma/schema.prisma ||trueechoecho"=== Searching for validateApiKeyDomainAccess usage ==="
rg -n -C3 "validateApiKeyDomainAccess" -g "*.ts"||trueechoecho"=== Searching for any domain.teamId equality checks ==="
rg -n -C3 "domain.*teamId" -g "*.ts"||true

Length of output: 8491


🏁 Script executed:

#!/bin/bashset -e
echo"=== Domain model definition ==="
rg -n -C5 "model Domain" apps/web/prisma/schema.prisma ||trueechoecho"=== validateDomainFromEmail implementation ==="
sed -n '1,200p' apps/web/src/server/service/domain-service.ts ||trueechoecho"=== Listing public API endpoints ==="
find apps/web/src/server/public-api/api -type f -maxdepth 2 ||trueechoecho"=== Searching for ApiKey in public API endpoints ==="
rg -n -C5 "ApiKey" -g "apps/web/src/server/public-api/api/**"||trueechoecho"=== Searching for domainId in ApiKey endpoints ==="
rg -n -C5 "domainId" -g "apps/web/src/server/public-api/api/**"||true

Length of output: 14073


Mandatory: Enforce domain-team invariant and tighten deletion semantics for ApiKey

The current schema and service code still allow three gaps:

  1. DB allows an ApiKey’s domainId to be set to any Domain.id (even from another team).
  2. ON DELETE SET NULL silently lifts a formerly restricted key to unrestricted if its domain is deleted.
  3. No index on domainId means domain-scoped lookups won’t use an index.

To address these:

• schema.prisma (apps/web/prisma/schema.prisma):
– Add an index on domainId for lookup performance.
– Change the referential action on domain from SetNull to Restrict (or NoAction) to avoid privilege escalation.
(Optional, if you can tolerate the complexity) introduce a composite relation on [domainId, teamId] → [id, teamId] to enforce at the DB level that every ApiKey.domain must belong to the same team.

 model ApiKey {
id Int @id @default(autoincrement())
clientId String @unique
tokenHash String
partialToken String
name String
permission ApiPermission @default(SENDING)
domainId Int?
+ @@index([domainId])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastUsed DateTime?
teamId Int
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
- domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull)+ domain Domain? @relation(fields: [domainId], references: [id], onDelete: Restrict)
}

• Service layer (in your ApiKey-creation/​update endpoints):
Before persisting a new or updated ApiKey, explicitly verify domainId, if provided, belongs to the same teamId. For example, reuse validateDomainFromEmail (or add a similar check) in createApiKey/updateApiKey to throw if the selected domain’s teamId mismatches the ApiKey’s teamId.

• Runtime enforcement (already in place):
validateApiKeyDomainAccess calls validateDomainFromEmail(email, teamId) to ensure team membership, then checks apiKey.domainId === domain.id. This is good, but it only runs when sending mail—creation and other operations still need safeguards.

Please update the schema and service code accordingly to close these security gaps.

📝 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
domainId Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastUsed DateTime?
teamId Int
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull)
}
modelApiKey {
id Int @id@default(autoincrement())
clientId String @unique
tokenHash String
partialToken String
name String
permission ApiPermission @default(SENDING)
domainId Int?
@@index([domainId])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastUsed DateTime?
teamId Int
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
domain Domain? @relation(fields: [domainId], references: [id], onDelete: Restrict)
}
🤖 Prompt for AI Agents
In apps/web/prisma/schema.prisma around lines 208 to 215, the ApiKey model
currently allows domainId from any Domain, uses onDelete: SetNull (which can
escalate privileges), and lacks an index on domainId; update the schema to add
an index on domainId, change the domain relation's onDelete from SetNull to
Restrict (or NoAction) to prevent silent privilege escalation, and optionally
add a composite relation/constraint tying [domainId, teamId] → [id, teamId] to
enforce at the DB level that the domain belongs to the same team; additionally,
update the ApiKey create/update service endpoints to explicitly validate that
the provided domainId (when present) belongs to the same teamId before
persisting (reuse validateDomainFromEmail or add an equivalent check) so
mismatched domain/team combinations are rejected.

Comment on lines +195 to +221
<FormField
control={apiKeyForm.control}
name="domainId"
render={({ field }) => (
<FormItem>
<FormLabel>Domain access</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select domain access" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="all">All Domains</SelectItem>
{domainsQuery.data?.map((domain: { id: number; name: string }) => (
<SelectItem key={domain.id} value={domain.id.toString()}>
{domain.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Choose which domain this API key can send emails from.
</FormDescription>
</FormItem>
)}
/>

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.

🛠️ Refactor suggestion

Make Select controlled (not defaultValue) to ensure form resets work reliably.

Using defaultValue makes the Select uncontrolled; after apiKeyForm.reset() the UI can show a stale value, confusing users. Control it via value, disable during loading/error, and add basic loading/empty states.

Apply this diff:

- <Select onValueChange={field.onChange} defaultValue={field.value}>+ <Select+ onValueChange={field.onChange}+ value={field.value ?? "all"}+ disabled={domainsQuery.isLoading || domainsQuery.isError}+ >
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select domain access" />
</SelectTrigger>
</FormControl>
- <SelectContent>- <SelectItem value="all">All Domains</SelectItem>- {domainsQuery.data?.map((domain: { id: number; name: string }) => (- <SelectItem key={domain.id} value={domain.id.toString()}>- {domain.name}- </SelectItem>- ))}- </SelectContent>+ <SelectContent>+ <SelectItem value="all">All Domains</SelectItem>+ {domainsQuery.isLoading && (+ <SelectItem value="loading" disabled>+ Loading domains…+ </SelectItem>+ )}+ {domainsQuery.isError && (+ <SelectItem value="error" disabled>+ Failed to load domains+ </SelectItem>+ )}+ {!domainsQuery.isLoading &&+ !domainsQuery.isError &&+ (domainsQuery.data?.length ?? 0) === 0 && (+ <SelectItem value="none" disabled>+ No domains available+ </SelectItem>+ )}+ {domainsQuery.data?.map((domain: { id: number; name: string }) => (+ <SelectItem key={domain.id} value={domain.id.toString()}>+ {domain.name}+ </SelectItem>+ ))}+ </SelectContent>
</Select>
- <FormDescription>- Choose which domain this API key can send emails from.- </FormDescription>+ {apiKeyForm.formState.errors.domainId ? (+ <FormMessage />+ ) : (+ <FormDescription>+ Choose which domain this API key can send emails from.+ </FormDescription>+ )}
📝 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
<FormField
control={apiKeyForm.control}
name="domainId"
render={({ field })=>(
<FormItem>
<FormLabel>Domain access</FormLabel>
<SelectonValueChange={field.onChange}defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValueplaceholder="Select domain access"/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItemvalue="all">All Domains</SelectItem>
{domainsQuery.data?.map((domain: {id: number;name: string})=>(
<SelectItemkey={domain.id}value={domain.id.toString()}>
{domain.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
ChoosewhichdomainthisAPIkeycansendemailsfrom.
</FormDescription>
</FormItem>
)}
/>
<FormField
control={apiKeyForm.control}
name="domainId"
render={({ field })=>(
<FormItem>
<FormLabel>Domain access</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value??"all"}
disabled={domainsQuery.isLoading||domainsQuery.isError}
>
<FormControl>
<SelectTrigger>
<SelectValueplaceholder="Select domain access"/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItemvalue="all">All Domains</SelectItem>
{domainsQuery.isLoading&&(
<SelectItemvalue="loading"disabled>
Loadingdomains…
</SelectItem>
)}
{domainsQuery.isError&&(
<SelectItemvalue="error"disabled>
Failedtoloaddomains
</SelectItem>
)}
{!domainsQuery.isLoading&&
!domainsQuery.isError&&
(domainsQuery.data?.length??0)===0&&(
<SelectItemvalue="none"disabled>
Nodomainsavailable
</SelectItem>
)}
{domainsQuery.data?.map((domain: {id: number;name: string})=>(
<SelectItemkey={domain.id}value={domain.id.toString()}>
{domain.name}
</SelectItem>
))}
</SelectContent>
</Select>
{apiKeyForm.formState.errors.domainId ? (
<FormMessage/>
) : (
<FormDescription>
ChoosewhichdomainthisAPIkeycansendemailsfrom.
</FormDescription>
)}
</FormItem>
)}
/>

Comment on lines 20 to 26
return addApiKey({
name: input.name,
permission: input.permission,
teamId: ctx.team.id,
domainId: input.domainId,
});
}),

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.

🛠️ Refactor suggestion

⚠️ Potential issue

Validate domain ownership (and optionally verification) at the API boundary

Before calling addApiKey, ensure the provided domainId exists and belongs to ctx.team.id. Without this, a client can associate a key with another team’s domain; the FK won’t stop that. Also return a clear 400/403 instead of a generic 500 from a DB FK violation.

+import { TRPCError } from "@trpc/server";
@@
- .mutation(async ({ ctx, input }) => {- return addApiKey({+ .mutation(async ({ ctx, input }) => {+ // Validate domain ownership (and optionally verification)+ if (input.domainId !== undefined) {+ const domain = await ctx.db.domain.findUnique({+ where: { id: input.domainId },+ select: { id: true, teamId: true, status: true },+ });+ if (!domain || domain.teamId !== ctx.team.id) {+ throw new TRPCError({+ code: "BAD_REQUEST",+ message: "Invalid domainId for this team",+ });+ }+ // Optional: enforce only verified domains can be scoped+ // if (domain.status !== "SUCCESS") {+ // throw new TRPCError({ code: "BAD_REQUEST", message: "Domain is not verified" });+ // }+ }+ return addApiKey({
name: input.name,
permission: input.permission,
teamId: ctx.team.id,
domainId: input.domainId,
});
}),

If you’d like, I can add unit/integration tests that cover:

  • Creating a key with another team’s domainId → 400
  • Creating a key with non-existent domainId → 400
  • Creating a key with verified domainId → 200
  • (Optional) Unverified domainId → 400
📝 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
returnaddApiKey({
name: input.name,
permission: input.permission,
teamId: ctx.team.id,
domainId: input.domainId,
});
}),
// At the top of the file, alongside your existing imports:
import{TRPCError}from"@trpc/server";
...
// In your router definition:
.mutation("createApiKey",async({ ctx, input })=>{
// Validate domain ownership (and optionally verification)
if(input.domainId!==undefined){
constdomain=awaitctx.db.domain.findUnique({
where: {id: input.domainId},
select: {id: true,teamId: true,status: true},
});
if(!domain||domain.teamId!==ctx.team.id){
thrownewTRPCError({
code: "BAD_REQUEST",
message: "Invalid domainId for this team",
});
}
// Optional: enforce only verified domains can be scoped
// if (domain.status !== "SUCCESS") {
// throw new TRPCError({ code: "BAD_REQUEST", message: "Domain is not verified" });
// }
}
returnaddApiKey({
name: input.name,
permission: input.permission,
teamId: ctx.team.id,
domainId: input.domainId,
});
}),

);

return { ...team, apiKeyId: apiKey.id };
return { ...team, apiKeyId: apiKey.id, apiKey };

@coderabbitaicoderabbitaiBotAug 22, 2025

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.

🛠️ Refactor suggestion

Avoid exposing sensitive ApiKey fields in request context.

c.set("team", ...) now carries the full ApiKey, which includes tokenHash. If any handler logs c.var.team or accidentally serializes it, secrets may leak.

Recommend returning a safe, minimal shape (id, name, permission, domainId, domain?.name) and update hono.ts typing accordingly.

Apply this localized sanitization here:

- return { ...team, apiKeyId: apiKey.id, apiKey };+ const safeApiKey = {+ id: apiKey.id,+ name: apiKey.name,+ permission: apiKey.permission,+ domainId: apiKey.domainId,+ domain: apiKey.domain ? { name: apiKey.domain.name } : null,+ } as const;+ return { ...team, apiKeyId: apiKey.id, apiKey: safeApiKey };

Optional: also validate the scheme prefix to harden auth parsing.

- const token = authHeader.split(" ")[1];+ const [scheme, token] = authHeader.split(" ");+ if (scheme?.toLowerCase() !== "bearer" || !token) {+ throw new UnsendApiError({ code: "UNAUTHORIZED", message: "Invalid Authorization header" });+ }
🤖 Prompt for AI Agents
In apps/web/src/server/public-api/auth.ts around line 62, the code currently
sets c.set("team", ...) with the full ApiKey (including tokenHash); replace this
with a sanitized team object containing only { id, name, permission, domainId,
domainName } (or domain?.name as domainName) so no secret fields are attached to
the request context, and update the hono.ts request/context typing to reflect
the new minimal shape; additionally, optionally validate the authorization
scheme prefix (e.g., "Bearer ") before parsing the token to harden auth parsing.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@enemyrr can you take a look at this, seems like valida comment, should only have need fields in context

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.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

import { isSelfHosted } from "~/utils/common";
import { UnsendApiError } from "./api-error";
import { Team } from "@prisma/client";
import { Team, ApiKey } from "@prisma/client";

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.

🛠️ Refactor suggestion

Don’t expose full ApiKey type to route handlers; define a safe shape.

Using ApiKey in AppEnv makes tokenHash and other internals available to every handler. Adopt a sanitized type consistent with auth.ts.

-import { Team, ApiKey } from "@prisma/client";+import { Team } from "@prisma/client";+type PublicApiKey = {+ id: number;+ name: string;+ permission: import("@prisma/client").ApiPermission;+ domainId: number | null;+ domain: { name: string } | null;+};
@@
- team: Team & { apiKeyId: number; apiKey: ApiKey & { domain?: { name: string } | null } };+ team: Team & { apiKeyId: number; apiKey: PublicApiKey };

Follow-up: this pairs with the sanitization in apps/web/src/server/public-api/auth.ts.

Also applies to: 16-16

🤖 Prompt for AI Agents
In apps/web/src/server/public-api/hono.ts around lines 10 and 16, the AppEnv
currently imports and exposes the full ApiKey Prisma type (which includes
tokenHash and other sensitive internals) to route handlers; replace that with a
sanitized ApiKey shape (only the public fields used by handlers) that matches
the sanitization defined in apps/web/src/server/public-api/auth.ts, update the
import/definition to use that safe type instead of ApiKey, and ensure AppEnv
references the sanitized type so tokenHash and other internals are not
accessible to handlers.

Comment on lines +58 to +80
export async function validateApiKeyDomainAccess(
email: string,
teamId: number,
apiKey: ApiKey & { domain?: { name: string } | null }
) {
// First validate the domain exists and is verified
const domain = await validateDomainFromEmail(email, teamId);

// If API key has no domain restriction (domainId is null), allow all domains
if (!apiKey.domainId) {
return domain;
}

// If API key is restricted to a specific domain, check if it matches
if (apiKey.domainId !== domain.id) {
throw new UnsendApiError({
code: "FORBIDDEN",
message: `API key does not have access to domain: ${domain.name}`,
});
}

return domain;
}

@coderabbitaicoderabbitaiBotAug 22, 2025

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.

🛠️ Refactor suggestion

Add team ownership assertion for the provided API key

validateApiKeyDomainAccess trusts the caller to pass an API key that belongs to teamId. Add a guard to prevent accidental cross-team use if this function ever gets reused elsewhere.

 export async function validateApiKeyDomainAccess(
email: string,
teamId: number,
apiKey: ApiKey & { domain?: { name: string } | null }
) {
// First validate the domain exists and is verified
const domain = await validateDomainFromEmail(email, teamId);
+ // Ensure the API key belongs to the same team (defense-in-depth)+ if (apiKey.teamId !== teamId) {+ throw new UnsendApiError({+ code: "FORBIDDEN",+ message: "API key does not belong to this team",+ });+ }+
// If API key has no domain restriction (domainId is null), allow all domains
if (!apiKey.domainId) {
return domain;
}
// If API key is restricted to a specific domain, check if it matches
if (apiKey.domainId !== domain.id) {
throw new UnsendApiError({
code: "FORBIDDEN",
message: `API key does not have access to domain: ${domain.name}`,
});
}
return domain;
}
📝 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
exportasyncfunctionvalidateApiKeyDomainAccess(
email: string,
teamId: number,
apiKey: ApiKey&{domain?: {name: string}|null}
){
// First validate the domain exists and is verified
constdomain=awaitvalidateDomainFromEmail(email,teamId);
// If API key has no domain restriction (domainId is null), allow all domains
if(!apiKey.domainId){
returndomain;
}
// If API key is restricted to a specific domain, check if it matches
if(apiKey.domainId!==domain.id){
thrownewUnsendApiError({
code: "FORBIDDEN",
message: `API key does not have access to domain: ${domain.name}`,
});
}
returndomain;
}
exportasyncfunctionvalidateApiKeyDomainAccess(
email: string,
teamId: number,
apiKey: ApiKey&{domain?: {name: string}|null}
){
// First validate the domain exists and is verified
constdomain=awaitvalidateDomainFromEmail(email,teamId);
// Ensure the API key belongs to the same team (defense-in-depth)
if(apiKey.teamId!==teamId){
thrownewUnsendApiError({
code: "FORBIDDEN",
message: "API key does not belong to this team",
});
}
// If API key has no domain restriction (domainId is null), allow all domains
if(!apiKey.domainId){
returndomain;
}
// If API key is restricted to a specific domain, check if it matches
if(apiKey.domainId!==domain.id){
thrownewUnsendApiError({
code: "FORBIDDEN",
message: `API key does not have access to domain: ${domain.name}`,
});
}
returndomain;
}
🤖 Prompt for AI Agents
In apps/web/src/server/service/domain-service.ts around lines 58 to 80, the
function validateApiKeyDomainAccess does not assert that the passed apiKey
belongs to the provided teamId, allowing potential cross-team use; add an early
guard after fetching/validating the domain (or immediately at function start)
that checks apiKey.teamId === teamId and if not, throw an UnsendApiError with a
FORBIDDEN code and a clear message about cross-team API key usage; keep the
existing domainId/null logic unchanged and place this ownership assertion before
any domain-specific access checks.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nah, it's already checked in validateDomainFromEmail

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.

@KMKoushik, understood! You're right that the domain ownership is validated in validateDomainFromEmail. I acknowledge your decision to keep the current implementation as is.

Comment threadapps/web/src/server/service/email-service.ts
- Fix critical Prisma query issue: use findFirst instead of findUnique for Domain lookup
- Remove unused imports and improve type safety
- Update OpenAPI description for domains endpoint
- Improve error handling for deleted domains in UI
- Optimize API service to select only needed domain fields
- Add consistent time formatting and better validation schemas

@coderabbitaicoderabbitaiBot 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.

Actionable comments posted: 1

♻️ Duplicate comments (2)
apps/web/src/server/service/domain-service.ts (1)

58-80: Add team-ownership assertion to prevent cross-team API key usage.

This function trusts that apiKey belongs to teamId. Add an early guard to block cross-team use before any domain checks. This mirrors feedback from an earlier review.

 export async function validateApiKeyDomainAccess(
email: string,
teamId: number,
apiKey: ApiKey & { domain?: { name: string } | null }
) {
- // First validate the domain exists and is verified+ // Ensure the API key belongs to the same team (defense-in-depth)+ if (apiKey.teamId !== teamId) {+ throw new UnsendApiError({+ code: "FORBIDDEN",+ message: "API key does not belong to this team",+ });+ }++ // First validate the domain exists and is verified
const domain = await validateDomainFromEmail(email, teamId);

Follow-up tests to add (happy to draft):

  • apiKey.teamId ≠ teamId → throws FORBIDDEN
  • apiKey.domainId set and not equal to resolved domain → throws FORBIDDEN
  • apiKey.domainId unset → any verified team domain allowed
apps/web/src/server/api/routers/api.ts (1)

20-27: Add server-side validation for domainId (ownership and optionally verification).

Validate that input.domainId belongs to ctx.team.id before calling addApiKey. This prevents cross-team association and yields a clean 400/403 instead of a generic failure. This was raised previously as well.

+import { TRPCError } from "@trpc/server";
@@
createToken: teamProcedure
.input(
z.object({
name: z.string(),
permission: z.nativeEnum(ApiPermission),
domainId: z.number().int().positive().optional(),
})
)
- .mutation(async ({ ctx, input }) => {- return addApiKey({+ .mutation(async ({ ctx, input }) => {+ // Validate domain ownership (and optional verification) at the boundary+ if (input.domainId !== undefined) {+ const domain = await ctx.db.domain.findUnique({+ where: { id: input.domainId },+ select: { id: true, teamId: true, status: true },+ });+ if (!domain || domain.teamId !== ctx.team.id) {+ throw new TRPCError({+ code: "BAD_REQUEST",+ message: "Invalid domainId for this team",+ });+ }+ // Optional: only allow verified domains to be scoped+ // if (domain.status !== "SUCCESS") {+ // throw new TRPCError({ code: "BAD_REQUEST", message: "Domain is not verified" });+ // }+ }+ return addApiKey({
name: input.name,
permission: input.permission,
teamId: ctx.team.id,
domainId: input.domainId,
});
}),

If you want stricter semantics, flip the optional check to require verified domains when domainId is provided.

🧹 Nitpick comments (2)
apps/web/src/server/public-api/api/domains/get-domains.ts (1)

25-30: Unify query (minor) and consider exposing only verified domains.

  • Minor: build a single where and call findMany once; keeps things simpler.
  • Optional: if this endpoint is used by API clients to select “sendable” domains, consider filtering to verified domains only (status: "SUCCESS").
- // If API key is restricted to a specific domain, only return that domain; else return all team domains- const domains = team.apiKey.domainId- ? await db.domain.findMany({- where: { teamId: team.id, id: team.apiKey.domainId },- })- : await db.domain.findMany({ where: { teamId: team.id } });+ // If API key is restricted to a specific domain, only return that domain; else return all team domains+ const where = {+ teamId: team.id,+ ...(team.apiKey.domainId ? { id: team.apiKey.domainId } : {}),+ // Optional: uncomment to return only verified domains+ // status: "SUCCESS",+ };+ const domains = await db.domain.findMany({ where });
apps/web/src/server/service/domain-service.ts (1)

37-39: Case-insensitive match and normalization for domain lookup.

Email domains are case-insensitive. Use a case-insensitive comparison (and normalize input) to avoid false negatives. If you routinely query by (teamId, name), consider a composite unique index for integrity and performance.

- const domain = await db.domain.findFirst({- where: { name: fromDomain, teamId },- });+ const domain = await db.domain.findFirst({+ where: {+ teamId,+ name: { equals: fromDomain.toLowerCase(), mode: "insensitive" },+ },+ });

Architecture note:

  • Add a Prisma unique constraint (migration) to enforce uniqueness per team:
    @@unique([teamId, name], name: "domains_teamId_name_key")
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 80a2118 and f190453.

📒 Files selected for processing (6)
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx (4 hunks)
  • apps/web/src/server/api/routers/api.ts (3 hunks)
  • apps/web/src/server/public-api/api/domains/get-domains.ts (2 hunks)
  • apps/web/src/server/service/api-service.ts (3 hunks)
  • apps/web/src/server/service/domain-service.ts (3 hunks)
  • apps/web/src/server/service/email-service.ts (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx
  • apps/web/src/server/service/email-service.ts
🧰 Additional context used
📓 Path-based instructions (5)
{apps,packages}/**/*.{js,jsx,ts,tsx,css,scss,md,mdx}

📄 CodeRabbit inference engine (CLAUDE.md)

Use Prettier with the Tailwind plugin for code formatting

Files:

  • apps/web/src/server/service/domain-service.ts
  • apps/web/src/server/service/api-service.ts
  • apps/web/src/server/api/routers/api.ts
  • apps/web/src/server/public-api/api/domains/get-domains.ts
{apps,packages}/**/*.{js,jsx,ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

{apps,packages}/**/*.{js,jsx,ts,tsx}: Group imports by source (internal/external) and alphabetize them
Use camelCase for variables and functions, PascalCase for components and classes
Use try/catch with specific error types for error handling

Files:

  • apps/web/src/server/service/domain-service.ts
  • apps/web/src/server/service/api-service.ts
  • apps/web/src/server/api/routers/api.ts
  • apps/web/src/server/public-api/api/domains/get-domains.ts
{apps,packages}/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

{apps,packages}/**/*.{ts,tsx}: Use strong typing in TypeScript, avoid any, and use Zod for validation
Follow Vercel style guides with strict TypeScript

Files:

  • apps/web/src/server/service/domain-service.ts
  • apps/web/src/server/service/api-service.ts
  • apps/web/src/server/api/routers/api.ts
  • apps/web/src/server/public-api/api/domains/get-domains.ts
apps/web/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Use tRPC for internal API endpoints

Files:

  • apps/web/src/server/service/domain-service.ts
  • apps/web/src/server/service/api-service.ts
  • apps/web/src/server/api/routers/api.ts
  • apps/web/src/server/public-api/api/domains/get-domains.ts
**/*.{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/service/domain-service.ts
  • apps/web/src/server/service/api-service.ts
  • apps/web/src/server/api/routers/api.ts
  • apps/web/src/server/public-api/api/domains/get-domains.ts
🧬 Code graph analysis (3)
apps/web/src/server/service/domain-service.ts (1)
apps/web/src/server/public-api/api-error.ts (1)
  • UnsendApiError (62-75)
apps/web/src/server/api/routers/api.ts (1)
apps/web/src/server/service/api-service.ts (1)
  • addApiKey (8-42)
apps/web/src/server/public-api/api/domains/get-domains.ts (1)
apps/web/src/server/db.ts (1)
  • db (20-20)
🔇 Additional comments (5)
apps/web/src/server/public-api/api/domains/get-domains.ts (1)

16-16: Good update to the route description.

Accurately reflects the new domain-scoped behavior.

apps/web/src/server/service/domain-service.ts (1)

9-9: Type import is appropriate.

Using ApiKey from Prisma here clarifies the contract of validateApiKeyDomainAccess.

apps/web/src/server/service/api-service.ts (1)

51-55: Including domain info with the API key is useful.

This enables downstream handlers (e.g., public API context) to enforce domain-scoped behavior without extra queries.

apps/web/src/server/api/routers/api.ts (2)

2-18: Good switch to z.nativeEnum(ApiPermission) and optional domainId input.

Type safety improves and matches the Prisma enum; the input contract is clear.


41-46: LGTM on enriching list output with domain info.

Selecting domainId and domain.name will make the UI “Domain Access” column straightforward to render.

Comment threadapps/web/src/server/service/api-service.ts
- Implement detailed error handling for domain ownership and verification during API key creation.
- Introduce TRPCError responses for better client feedback on domain-related issues.
- Update addApiKey function to validate domain ownership and status before proceeding with key creation.

@coderabbitaicoderabbitaiBot 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.

Actionable comments posted: 0

♻️ Duplicate comments (1)
apps/web/src/server/service/api-service.ts (1)

20-35: Great defense-in-depth: ownership + verification checks added (resolves prior feedback).

Validating domainId and enforcing verified domains here is the right call and aligns with the PR objectives. This addresses the earlier recommendation to guard at the service layer as well.

🧹 Nitpick comments (7)
apps/web/src/server/service/api-service.ts (4)

22-29: Fold team ownership into the DB predicate (simpler and avoids a second check).

Query the domain with both id and teamId so the DB enforces ownership in one step. This reduces branching and keeps the “doesn’t exist vs not owned” ambiguity intact.

Apply this diff:

- const domain = await db.domain.findUnique({- where: { id: domainId },- select: { id: true, teamId: true, status: true },- });-- if (!domain || domain.teamId !== teamId) {- throw new Error("DOMAIN_NOT_OWNED");- }+ const domain = await db.domain.findFirst({+ where: { id: domainId, teamId },+ select: { id: true, status: true },+ });+ if (!domain) {+ throw new Error("DOMAIN_NOT_OWNED");+ }

31-34: Avoid magic strings for status; use the Prisma enum for type-safety.

Leverage DomainStatus.SUCCESS to prevent typos and benefit from compiler checks.

Apply this diff within the selected lines:

- if (domain.status !== "SUCCESS") {+ if (domain.status !== DomainStatus.SUCCESS) {
throw new Error("DOMAIN_NOT_VERIFIED");
}

Add this import (outside the selected range):

// at top of fileimport{ApiPermission,DomainStatus}from"@prisma/client";

20-35: Prefer typed service errors over string-matching downstream.

Throw a typed error so the router can instanceof check instead of matching .message. This follows the guideline to use specific error types.

Apply this diff within the selected lines:

- throw new Error("DOMAIN_NOT_OWNED");+ throw new ApiServiceError("DOMAIN_NOT_OWNED");
@@
- throw new Error("DOMAIN_NOT_VERIFIED");+ throw new ApiServiceError("DOMAIN_NOT_VERIFIED");

And define the error class (outside the selected range):

// near the top of the fileexportclassApiServiceErrorextendsError{constructor(publiccode: "DOMAIN_NOT_OWNED"|"DOMAIN_NOT_VERIFIED"){super(code);this.name="ApiServiceError";}}

61-63: Early-validate API key format before hitting the DB.

Guard against malformed keys to avoid unnecessary queries and potential undefined access.

Apply this diff:

 export async function getTeamAndApiKey(apiKey: string) {
- const [, clientId, token] = apiKey.split("_") as [string, string, string];+ const parts = apiKey.split("_");+ if (parts.length !== 3 || parts[0] !== "us") return null;+ const [, clientId, token] = parts as [string, string, string];
apps/web/src/server/api/routers/api.ts (3)

1-3: Order imports per guideline and prepare for typed error mapping.

Group externals and alphabetize; also import the typed service error for robust error handling.

Apply this diff:

-import { z } from "zod";-import { ApiPermission } from "@prisma/client";-import { TRPCError } from "@trpc/server";+import { ApiPermission } from "@prisma/client";+import { TRPCError } from "@trpc/server";+import { z } from "zod";+import { ApiServiceError } from "~/server/service/api-service";

15-19: Tighten Zod validation for name.

Protect against empty or overly long names at the boundary.

Apply this diff:

- z.object({- name: z.string(),+ z.object({+ name: z.string().min(1).max(64),
permission: z.nativeEnum(ApiPermission),
domainId: z.number().int().positive().optional(),
})

21-45: Use typed error mapping instead of string matching; default to sanitized 500.

Avoid brittle .message checks. Map ApiServiceError codes, and return a generic 500 for unknown errors.

Apply this diff:

- .mutation(async ({ ctx, input }) => {- try {- return await addApiKey({+ .mutation(async ({ ctx, input }) => {+ try {+ return await addApiKey({
name: input.name,
permission: input.permission,
teamId: ctx.team.id,
domainId: input.domainId,
});
- } catch (error) {- if (error instanceof Error) {- if (error.message === "DOMAIN_NOT_OWNED") {- throw new TRPCError({- code: "BAD_REQUEST",- message: "Domain does not belong to your team",- });- }- if (error.message === "DOMAIN_NOT_VERIFIED") {- throw new TRPCError({- code: "BAD_REQUEST", - message: "Domain is not verified. Only verified domains can be used for API key restrictions",- });- }- }- throw error;- }+ } catch (error) {+ if (error instanceof ApiServiceError) {+ if (error.code === "DOMAIN_NOT_OWNED") {+ throw new TRPCError({+ code: "BAD_REQUEST",+ message: "Domain does not belong to your team",+ });+ }+ if (error.code === "DOMAIN_NOT_VERIFIED") {+ throw new TRPCError({+ code: "BAD_REQUEST",+ message:+ "Domain is not verified. Only verified domains can be used for API key restrictions",+ });+ }+ }+ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });+ }
}),
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between f190453 and 280f83d.

📒 Files selected for processing (2)
  • apps/web/src/server/api/routers/api.ts (3 hunks)
  • apps/web/src/server/service/api-service.ts (3 hunks)
🧰 Additional context used
📓 Path-based instructions (5)
{apps,packages}/**/*.{js,jsx,ts,tsx,css,scss,md,mdx}

📄 CodeRabbit inference engine (CLAUDE.md)

Use Prettier with the Tailwind plugin for code formatting

Files:

  • apps/web/src/server/api/routers/api.ts
  • apps/web/src/server/service/api-service.ts
{apps,packages}/**/*.{js,jsx,ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

{apps,packages}/**/*.{js,jsx,ts,tsx}: Group imports by source (internal/external) and alphabetize them
Use camelCase for variables and functions, PascalCase for components and classes
Use try/catch with specific error types for error handling

Files:

  • apps/web/src/server/api/routers/api.ts
  • apps/web/src/server/service/api-service.ts
{apps,packages}/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

{apps,packages}/**/*.{ts,tsx}: Use strong typing in TypeScript, avoid any, and use Zod for validation
Follow Vercel style guides with strict TypeScript

Files:

  • apps/web/src/server/api/routers/api.ts
  • apps/web/src/server/service/api-service.ts
apps/web/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Use tRPC for internal API endpoints

Files:

  • apps/web/src/server/api/routers/api.ts
  • apps/web/src/server/service/api-service.ts
**/*.{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/api/routers/api.ts
  • apps/web/src/server/service/api-service.ts
🧬 Code graph analysis (1)
apps/web/src/server/api/routers/api.ts (1)
apps/web/src/server/service/api-service.ts (1)
  • addApiKey (8-59)
🔇 Additional comments (3)
apps/web/src/server/service/api-service.ts (2)

48-48: LGTM: domain association is persisted correctly.

Passing domainId through ensures the key is scoped as intended; omitting it keeps existing “all domains” behavior.


68-72: LGTM: eager-loading domain context is useful for callers.

Selecting only id and name is lean and sufficient for display/use.

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

60-65: LGTM: exposing domain context in listings.

Returning both domainId and domain.name is exactly what the UI needs to show scope clearly.

Comment threadapps/web/prisma/schema.prisma Outdated
lastUsed DateTime?
teamId Int
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this might be a security problem, i'd rather delete the api key

);

return { ...team, apiKeyId: apiKey.id };
return { ...team, apiKeyId: apiKey.id, apiKey };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@enemyrr can you take a look at this, seems like valida comment, should only have need fields in context

// Validate domain ownership if domainId is provided
if (domainId !== undefined) {
const domain = await db.domain.findUnique({
where: { id: domainId },

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@enemyrr instead, teamId should be added to the where condition and error should just domainId not found instead of DOMAIN_NOT_OWNED, this will give out that the domain id is valid. i don't want to leak that out

}

// Only allow verified domains to be scoped
if (domain.status !== "SUCCESS") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

not sure if we really need this,

});
} catch (error) {
if (error instanceof Error) {
if (error.message === "DOMAIN_NOT_OWNED") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

yep, i don't want this error to be show to user

}

const domain = await db.domain.findUnique({
const domain = await db.domain.findFirst({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@enemyrr why is this changed

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

@KMKoushik The change from findUnique to findFirst was necessary because findUnique can only be used with fields that have a unique constraint.

In this case, we're querying with { name: fromDomain, teamId }, while name is unique, the combination of name + teamId is not defined as a unique constraint in the schema.

Using findUnique with non-unique field combinations causes Prisma runtime errors. findFirst is the correct method when filtering by multiple fields that don't form a unique constraint.

Comment on lines +58 to +80
export async function validateApiKeyDomainAccess(
email: string,
teamId: number,
apiKey: ApiKey & { domain?: { name: string } | null }
) {
// First validate the domain exists and is verified
const domain = await validateDomainFromEmail(email, teamId);

// If API key has no domain restriction (domainId is null), allow all domains
if (!apiKey.domainId) {
return domain;
}

// If API key is restricted to a specific domain, check if it matches
if (apiKey.domainId !== domain.id) {
throw new UnsendApiError({
code: "FORBIDDEN",
message: `API key does not have access to domain: ${domain.name}`,
});
}

return domain;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nah, it's already checked in validateDomainFromEmail

@@ -2,7 +2,6 @@ import { createRoute, z } from "@hono/zod-openapi";
import { DomainSchema } from "~/lib/zod/domain-schema";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@enemyrr verify-domain api should also verify this!

Comment threadapps/web/src/server/service/email-service.ts
@@ -2,7 +2,6 @@ import { createRoute, z } from "@hono/zod-openapi";
import { DomainSchema } from "~/lib/zod/domain-schema";
import { PublicAPIApp } from "~/server/public-api/hono";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

along with email apis should also be restricted, we are now allowing to fetch emails by other domains as well.

get-email
list-emails
update-email
cancel-email

- Introduce checkIsValidEmailIdWithDomainRestriction function to validate email access based on API key domain restrictions.
- Update email-related API endpoints to utilize the new validation function, ensuring proper domain checks for email actions.
- Modify API key model to include domainId and adjust related logic for domain verification during email operations.
- Enhance error handling for domain access issues in email APIs, providing clearer feedback for unauthorized access.

@coderabbitaicoderabbitaiBot 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.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
apps/web/src/server/public-api/api/emails/list-emails.ts (1)

115-124: Bug: createdAt filter overwritten — combine gte/lte instead of replacing

Setting whereClause.createdAt twice drops the first bound. Merge both conditions.

Apply:

- if (startDate) {- whereClause.createdAt = {- gte: new Date(startDate),- };- }- if (endDate) {- whereClause.createdAt = {- lte: new Date(endDate),- };- }+ if (startDate || endDate) {+ whereClause.createdAt = {+ ...(startDate ? { gte: new Date(startDate) } : {}),+ ...(endDate ? { lte: new Date(endDate) } : {}),+ };+ }
apps/web/src/server/public-api/api/emails/update-email.ts (1)

35-43: Fix OpenAPI description (copy-paste error)

The response description says “Retrieve the user” for an email scheduling endpoint. Update to reflect the actual operation for accurate public API docs.

- description: "Retrieve the user",+ description: "Update the email's scheduled time",
apps/web/src/server/public-api/api/emails/cancel-email.ts (1)

24-33: Fix OpenAPI description (copy-paste error)

This endpoint cancels a scheduled email, but the description says “Retrieve the user.” Update to reflect the correct operation.

- description: "Retrieve the user",+ description: "Cancel a scheduled email",
♻️ Duplicate comments (2)
apps/web/prisma/schema.prisma (1)

208-215: Optional: Enforce domain–team invariant at DB or service layer

Currently nothing prevents ApiKey.domainId from referencing a Domain of another team purely at the DB level. You already validate at service/router layers elsewhere; consider strengthening:

  • DB-level (requires composite relation): [domainId, teamId] -> Domain.[id, teamId] with a corresponding unique/index on Domain(teamId, id) or @@id([id, teamId]).
  • Or keep service-layer validation but add explicit tests.

If you want a DB-level approach, I can draft the exact Prisma schema changes in a follow-up.

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

22-27: Wrap service errors and validate domainId at the API boundary to return proper 400s (and use TRPCError import)

Currently this blindly forwards to addApiKey, and any validation error bubbles up as a generic 500. Per guidelines (“Use try/catch with specific error types”) and product comment not to surface raw errors, wrap and normalize them. Also, validate domainId belongs to ctx.team.id up front to fail fast.

Apply:

- .mutation(async ({ ctx, input }) => {- return await addApiKey({- name: input.name,- permission: input.permission,- teamId: ctx.team.id,- domainId: input.domainId,- });- }),+ .mutation(async ({ ctx, input }) => {+ try {+ if (input.domainId !== undefined) {+ const domain = await ctx.db.domain.findFirst({+ where: { id: input.domainId, teamId: ctx.team.id },+ select: { id: true },+ });+ if (!domain) {+ throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid domainId for this team" });+ }+ }+ return await addApiKey({+ name: input.name,+ permission: input.permission,+ teamId: ctx.team.id,+ domainId: input.domainId,+ });+ } catch (err: any) {+ // Map known service sentinel errors+ if (err?.message === "DOMAIN_NOT_FOUND") {+ throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid domainId for this team" });+ }+ throw err;+ }+ }),

Note: This also makes the TRPCError import used. If you choose not to handle here, remove the import. Also, return await is only needed because we catch; otherwise it’s redundant.

🧹 Nitpick comments (7)
apps/web/src/server/public-api/api/domains/verify-domain.ts (5)

28-29: Nit: clarify description to match response message.

The success message says “Domain verification started”. Consider making the description consistent.

- description: "Verify domain",+ description: "Start domain verification",

59-80: Early-return on domain restriction to avoid unnecessary DB work and simplify logic.

Short-circuit when the API key is scoped to a different domain. This also removes the need for the mutable let domain = null; (which widens to any unless annotated).

- let domain = null;-- if (team.apiKey.domainId) {- // If API key is restricted to a specific domain, verify the requested domain matches- if (domainId === team.apiKey.domainId) {- domain = await db.domain.findFirst({- where: { - teamId: team.id, - id: domainId- },- });- }- // If domainId doesn't match the API key's restriction, domain remains null- } else {- // API key has access to all team domains- domain = await db.domain.findFirst({ - where: { - teamId: team.id, - id: domainId - } - });- }+ const restrictedDomainId = team.apiKey.domainId;+ if (restrictedDomainId != null && domainId !== restrictedDomainId) {+ return c.json({ error: "API key doesn't have access to this domain" }, 403);+ }+ const domain = await db.domain.findFirst({+ where: { teamId: team.id, id: domainId },+ });

90-93: Scope the update by teamId (defense-in-depth) and verify affected rows.

We validated team ownership earlier, but scoping the write and checking the result prevents accidental cross-tenant updates in case of future refactors and avoids TOCTOU edges.

- await db.domain.update({- where: { id: domainId },- data: { isVerifying: true },- });+ const { count } = await db.domain.updateMany({+ where: { id: domainId, teamId: team.id },+ data: { isVerifying: true },+ });+ if (count === 0) {+ return c.json({ error: "Domain not found" }, 404);+ }

54-98: Add structured error handling for Prisma errors.

Wrap DB calls in try/catch and map known Prisma errors to 4xx vs 5xx. This follows the repo guideline to use try/catch with specific error types and prevents leaking internals on exceptions.

// At top-level imports (illustrative, outside this hunk)import{Prisma}from"@prisma/client";// Inside handlertry{// existing handler logic...}catch(err){if(errinstanceofPrisma.PrismaClientKnownRequestError){// e.g., map P2025 (record not found) if it can surface herereturnc.json({error: "Domain not found"},404);}// fallbackreturnc.json({error: "Internal server error"},500);}

58-80: Consider centralizing domain-access checks in a shared service to keep behavior consistent across routes.

The PR adds a domain-access validation pathway in the domain service. Reusing it here would reduce duplication and ensure consistent 403/404 semantics across public API routes.

Happy to refactor this handler to use the shared validator if you point me to the exact helper signature you want standardized.

apps/web/src/server/public-api/api-utils.ts (1)

29-35: Optional: Unify email-id validation helpers

To reduce duplication and ensure consistent domain enforcement, have checkIsValidEmailId call the new function with apiKeyDomainId = null.

Proposed (outside current range):

exportconstcheckIsValidEmailId=async(emailId: string,teamId: number)=>{awaitcheckIsValidEmailIdWithDomainRestriction(emailId,teamId,null);};
apps/web/src/server/public-api/api/emails/update-email.ts (1)

27-29: Optional: validate schedule is in the future and pass a Date to the service

To prevent accidental back-dating, validate scheduledAt is in the future. If updateEmail expects a Date, convert here.

- scheduledAt: z.string().datetime(),+ scheduledAt: z+ .string()+ .datetime()+ .refine((s) => !Number.isNaN(Date.parse(s)) && new Date(s) > new Date(), {+ message: "scheduledAt must be a future datetime (ISO 8601)",+ }),
- await updateEmail(emailId, {- scheduledAt: c.req.valid("json").scheduledAt,- });+ const { scheduledAt } = c.req.valid("json");+ await updateEmail(emailId, {+ // If updateEmail accepts string, keep it as-is; otherwise convert to Date:+ scheduledAt: new Date(scheduledAt),+ });

Confirm what type updateEmail(...).scheduledAt expects. If it’s a string today, I’ll adjust the diff to avoid the Date conversion and only keep the future-date validation.

Also applies to: 54-55

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 280f83d and 3288090.

📒 Files selected for processing (11)
  • apps/web/prisma/schema.prisma (2 hunks)
  • apps/web/src/server/api/routers/api.ts (3 hunks)
  • apps/web/src/server/public-api/api-utils.ts (1 hunks)
  • apps/web/src/server/public-api/api/domains/verify-domain.ts (1 hunks)
  • apps/web/src/server/public-api/api/emails/cancel-email.ts (2 hunks)
  • apps/web/src/server/public-api/api/emails/get-email.ts (1 hunks)
  • apps/web/src/server/public-api/api/emails/list-emails.ts (1 hunks)
  • apps/web/src/server/public-api/api/emails/update-email.ts (2 hunks)
  • apps/web/src/server/public-api/auth.ts (1 hunks)
  • apps/web/src/server/public-api/hono.ts (1 hunks)
  • apps/web/src/server/service/api-service.ts (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • apps/web/src/server/public-api/auth.ts
  • apps/web/src/server/public-api/hono.ts
  • apps/web/src/server/service/api-service.ts
🧰 Additional context used
📓 Path-based instructions (5)
{apps,packages}/**/*.{js,jsx,ts,tsx,css,scss,md,mdx}

📄 CodeRabbit inference engine (CLAUDE.md)

Use Prettier with the Tailwind plugin for code formatting

Files:

  • apps/web/src/server/public-api/api/emails/get-email.ts
  • apps/web/src/server/public-api/api/emails/list-emails.ts
  • apps/web/src/server/public-api/api/domains/verify-domain.ts
  • apps/web/src/server/public-api/api-utils.ts
  • apps/web/src/server/public-api/api/emails/update-email.ts
  • apps/web/src/server/public-api/api/emails/cancel-email.ts
  • apps/web/src/server/api/routers/api.ts
{apps,packages}/**/*.{js,jsx,ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

{apps,packages}/**/*.{js,jsx,ts,tsx}: Group imports by source (internal/external) and alphabetize them
Use camelCase for variables and functions, PascalCase for components and classes
Use try/catch with specific error types for error handling

Files:

  • apps/web/src/server/public-api/api/emails/get-email.ts
  • apps/web/src/server/public-api/api/emails/list-emails.ts
  • apps/web/src/server/public-api/api/domains/verify-domain.ts
  • apps/web/src/server/public-api/api-utils.ts
  • apps/web/src/server/public-api/api/emails/update-email.ts
  • apps/web/src/server/public-api/api/emails/cancel-email.ts
  • apps/web/src/server/api/routers/api.ts
{apps,packages}/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

{apps,packages}/**/*.{ts,tsx}: Use strong typing in TypeScript, avoid any, and use Zod for validation
Follow Vercel style guides with strict TypeScript

Files:

  • apps/web/src/server/public-api/api/emails/get-email.ts
  • apps/web/src/server/public-api/api/emails/list-emails.ts
  • apps/web/src/server/public-api/api/domains/verify-domain.ts
  • apps/web/src/server/public-api/api-utils.ts
  • apps/web/src/server/public-api/api/emails/update-email.ts
  • apps/web/src/server/public-api/api/emails/cancel-email.ts
  • apps/web/src/server/api/routers/api.ts
apps/web/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Use tRPC for internal API endpoints

Files:

  • apps/web/src/server/public-api/api/emails/get-email.ts
  • apps/web/src/server/public-api/api/emails/list-emails.ts
  • apps/web/src/server/public-api/api/domains/verify-domain.ts
  • apps/web/src/server/public-api/api-utils.ts
  • apps/web/src/server/public-api/api/emails/update-email.ts
  • apps/web/src/server/public-api/api/emails/cancel-email.ts
  • apps/web/src/server/api/routers/api.ts
**/*.{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/emails/get-email.ts
  • apps/web/src/server/public-api/api/emails/list-emails.ts
  • apps/web/src/server/public-api/api/domains/verify-domain.ts
  • apps/web/src/server/public-api/api-utils.ts
  • apps/web/src/server/public-api/api/emails/update-email.ts
  • apps/web/src/server/public-api/api/emails/cancel-email.ts
  • apps/web/src/server/api/routers/api.ts
🧠 Learnings (1)
📚 Learning: 2025-08-09T10:37:58.146Z
Learnt from: CR
PR: unsend-dev/unsend#0
File: CLAUDE.md:0-0
Timestamp: 2025-08-09T10:37:58.146Z
Learning: Applies to apps/smtp-server/**/*.{ts,tsx} : Use Hono for public API endpoints

Applied to files:

  • apps/web/src/server/public-api/api/emails/update-email.ts
  • apps/web/src/server/public-api/api/emails/cancel-email.ts
🧬 Code graph analysis (6)
apps/web/src/server/public-api/api/emails/get-email.ts (1)
apps/web/src/server/db.ts (1)
  • db (20-20)
apps/web/src/server/public-api/api/domains/verify-domain.ts (2)
apps/web/src/server/public-api/hono.ts (1)
  • PublicAPIApp (136-136)
apps/web/src/server/db.ts (1)
  • db (20-20)
apps/web/src/server/public-api/api-utils.ts (2)
apps/web/src/server/db.ts (1)
  • db (20-20)
apps/web/src/server/public-api/api-error.ts (1)
  • UnsendApiError (62-75)
apps/web/src/server/public-api/api/emails/update-email.ts (1)
apps/web/src/server/public-api/api-utils.ts (1)
  • checkIsValidEmailIdWithDomainRestriction (37-58)
apps/web/src/server/public-api/api/emails/cancel-email.ts (1)
apps/web/src/server/public-api/api-utils.ts (1)
  • checkIsValidEmailIdWithDomainRestriction (37-58)
apps/web/src/server/api/routers/api.ts (1)
apps/web/src/server/service/api-service.ts (1)
  • addApiKey (8-57)
🔇 Additional comments (5)
apps/web/prisma/schema.prisma (1)

193-194: LGTM: Back-reference from Domain to ApiKeys is correct

The apiKeys: ApiKey[] relation is consistent and improves discoverability from Domain. No issues here.

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

42-47: LGTM: Exposing domain context in getApiKeys

Selecting domainId and domain.name is useful for the UI; preserves backward compatibility when domainId is null.

apps/web/src/server/public-api/api/emails/list-emails.ts (1)

126-129: LGTM: Domain scoping precedence

When the API key is domain-scoped, you correctly force whereClause.domainId = apiKey.domainId; otherwise, you honor the query filter. Matches the access-control intent.

apps/web/src/server/public-api/api/emails/update-email.ts (1)

5-5: LGTM: switched to domain-aware validator

Importing checkIsValidEmailIdWithDomainRestriction aligns this endpoint with the new domain-scoped access control.

apps/web/src/server/public-api/api/emails/cancel-email.ts (1)

5-5: LGTM: switched to domain-aware validator

The import aligns this endpoint with domain-scoped access control.

Comment on lines +208 to 215
domainId Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastUsed DateTime?
teamId Int
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
domain Domain? @relation(fields: [domainId], references: [id], onDelete: Cascade)
}

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.

🛠️ Refactor suggestion

Index missing for ApiKey.domainId; add for query performance and future constraints

Domain-scoped lookups (listing keys by domain, enforcing access, etc.) will benefit from an index. Add @@index([domainId]) to ApiKey.

Apply:

 model ApiKey {
id Int @id @default(autoincrement())
clientId String @unique
tokenHash String
partialToken String
name String
permission ApiPermission @default(SENDING)
domainId Int?
+ @@index([domainId])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastUsed DateTime?
teamId Int
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
domain Domain? @relation(fields: [domainId], references: [id], onDelete: Cascade)
}

Additionally, given frequent filters by teamId and domainId (and sorts by createdAt), consider in a follow-up migration:

modelEmail {// ...@@index([teamId, domainId, createdAt(sort: Desc)])}
🤖 Prompt for AI Agents
In apps/web/prisma/schema.prisma around lines 208 to 215, the ApiKey model is
missing an index on domainId which will hurt domain-scoped lookups and future
constraints; add an @@index([domainId]) declaration to the ApiKey model (and
then run the Prisma migration to apply it). Also consider as a follow-up adding
a composite index for Email on teamId + domainId + createdAt (sorted Desc) to
optimize common filtered-and-sorted queries.

lastUsed DateTime?
teamId Int
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
domain Domain? @relation(fields: [domainId], references: [id], onDelete: Cascade)

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.

🛠️ Refactor suggestion

Schema/migration mismatch on ApiKey.domain referential action (Cascade vs SetNull) — pick one and align everywhere

The Prisma schema uses onDelete: Cascade for ApiKey.domain, but the migration (20250822125136...) sets the FK to ON DELETE SET NULL. This discrepancy can lead to drift warnings and unexpected runtime behavior. Given the prior product preference (“i'd rather delete the api key”), prefer Cascade and update the migration accordingly. Otherwise, switch the schema back to SetNull.

Action:

  • Update the existing migration or create a follow-up migration to align the FK action with the schema choice.
  • Add an entry in the changelog describing the semantics on domain deletion.

Apply this change in the Prisma schema only if you decide to keep SetNull instead:

- domain Domain? @relation(fields: [domainId], references: [id], onDelete: Cascade)+ domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull)

If keeping Cascade (recommended per product comment), alter the FK in the migration to ON DELETE CASCADE and re-generate the client.

📝 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
domain Domain? @relation(fields: [domainId], references: [id], onDelete: Cascade)
domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull)
🤖 Prompt for AI Agents
In apps/web/prisma/schema.prisma around line 214 there is a referential-action
mismatch: the schema currently declares ApiKey.domain with onDelete: Cascade
while the existing migration SQL uses ON DELETE SET NULL; choose one and align
everywhere — recommended: keep Cascade, update the migration to use ON DELETE
CASCADE (or if you prefer SetNull, change the schema back to SetNull), then
either edit the existing migration SQL to replace SET NULL with CASCADE or
create a new follow-up migration that changes the FK to ON DELETE CASCADE, run
prisma migrate/resolve as appropriate, regenerate the Prisma client (prisma
generate), and add a changelog entry describing the domain-deletion semantics
for ApiKey.

Comment on lines +37 to +58
export const checkIsValidEmailIdWithDomainRestriction = async (
emailId: string,
teamId: number,
apiKeyDomainId?: number
) => {
const whereClause: { id: string; teamId: number; domainId?: number } = {
id: emailId,
teamId,
};

if (apiKeyDomainId !== undefined) {
whereClause.domainId = apiKeyDomainId;
}

const email = await db.email.findUnique({ where: whereClause });

if (!email) {
throw new UnsendApiError({ code: "NOT_FOUND", message: "Email not found" });
}

return email;
};

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.

⚠️ Potential issue

Fix two issues: (1) null vs undefined check causes unintended filtering; (2) findUnique with non-unique where

  • If callers pass null (they do: team.apiKey.domainId is number | null), the current !== undefined check sets domainId: null, restricting to emails with NULL domainId — wrong.
  • findUnique({ where: { id, teamId, domainId? }}) is invalid unless there’s a composite unique; use findFirst.

Apply:

-export const checkIsValidEmailIdWithDomainRestriction = async (- emailId: string, - teamId: number, - apiKeyDomainId?: number-) => {- const whereClause: { id: string; teamId: number; domainId?: number } = {- id: emailId,- teamId,- };-- if (apiKeyDomainId !== undefined) {- whereClause.domainId = apiKeyDomainId;- }-- const email = await db.email.findUnique({ where: whereClause });+export const checkIsValidEmailIdWithDomainRestriction = async (+ emailId: string,+ teamId: number,+ apiKeyDomainId: number | null+) => {+ const whereClause = {+ id: emailId,+ teamId,+ ...(apiKeyDomainId !== null ? { domainId: apiKeyDomainId } : {}),+ };++ const email = await db.email.findFirst({ where: whereClause });
if (!email) {
throw new UnsendApiError({ code: "NOT_FOUND", message: "Email not found" });
}
return email;
};

Follow-up: consider refactoring existing checkIsValidEmailId to delegate to this function for consistency.

📝 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
exportconstcheckIsValidEmailIdWithDomainRestriction=async(
emailId: string,
teamId: number,
apiKeyDomainId?: number
)=>{
constwhereClause: {id: string;teamId: number;domainId?: number}={
id: emailId,
teamId,
};
if(apiKeyDomainId!==undefined){
whereClause.domainId=apiKeyDomainId;
}
constemail=awaitdb.email.findUnique({where: whereClause});
if(!email){
thrownewUnsendApiError({code: "NOT_FOUND",message: "Email not found"});
}
returnemail;
};
exportconstcheckIsValidEmailIdWithDomainRestriction=async(
emailId: string,
teamId: number,
apiKeyDomainId: number|null
)=>{
constwhereClause={
id: emailId,
teamId,
...(apiKeyDomainId!==null ? {domainId: apiKeyDomainId} : {}),
};
constemail=awaitdb.email.findFirst({where: whereClause});
if(!email){
thrownewUnsendApiError({code: "NOT_FOUND",message: "Email not found"});
}
returnemail;
};
🤖 Prompt for AI Agents
In apps/web/src/server/public-api/api-utils.ts around lines 37 to 58, the
function incorrectly treats a null apiKeyDomainId as a valid filter and uses
findUnique with a non-unique where clause; update the guard to only add domainId
when apiKeyDomainId is not null or undefined (e.g., if (apiKeyDomainId != null)
whereClause.domainId = apiKeyDomainId), and replace db.email.findUnique(...)
with db.email.findFirst(...) so the query works without a composite unique;
afterwards consider refactoring checkIsValidEmailId to call this function for
consistency.

Comment on lines +28 to 49
description: "Verify domain",
},
403: {
content: {
"application/json": {
schema: z.object({
error: z.string(),
}),
},
},
description: "Forbidden - API key doesn't have access to this domain",
},
404: {
content: {
"application/json": {
schema: z.object({
error: z.string(),
}),
},
},
description: "Domain not found",
},

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.

💡 Verification agent

❓ Verification inconclusive

OpenAPI declares 403 but handler never returns it — align the contract and implementation.

You document a 403 (“Forbidden - API key doesn't have access to this domain”), but the handler currently always returns 404 in the failure branch. Either change the handler to return 403 for access-denied or remove the 403 from the spec. I’ve proposed a handler fix in another comment (Lines 82–88).


Align OpenAPI 403 response with handler implementation

The OpenAPI spec in apps/web/src/server/public-api/api/domains/verify-domain.ts (lines 28–49) declares a 403 (“Forbidden – API key doesn’t have access to this domain”), but the handler’s error branch (around lines 82–88) always returns a 404. To keep contract and code in sync, please choose one:

  • Update the handler so that when the API key lacks access it returns a 403 status with the { error: string } payload as documented.
  • Or, if access checks are consolidated elsewhere, remove the 403 entry from the OpenAPI definition.

Files to update:

  • apps/web/src/server/public-api/api/domains/verify-domain.ts
    • Remove or adjust the 403 response schema (lines 28–49)
    • Change the error branch to res.status(403).send({ error }) (around lines 82–88)
🤖 Prompt for AI Agents
In apps/web/src/server/public-api/api/domains/verify-domain.ts around lines
28–49 and the handler around lines 82–88, the OpenAPI spec declares a 403
response but the handler currently always returns 404 for access-denied cases;
fix by either (A) updating the handler error branch to return
res.status(403).send({ error }) when the API key lacks access so it matches the
documented 403 schema, or (B) removing the 403 response block from the OpenAPI
definition if access checks are enforced elsewhere; apply the corresponding
change in this file so the contract and implementation are consistent.

Comment on lines +82 to +88
if (!domain) {
return c.json({
error: team.apiKey.domainId
? "API key doesn't have access to this domain"
: "Domain not found"
}, 404);
}

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.

🛠️ Refactor suggestion

Return 403 for access-denied and 404 for missing domain.

This matches the OpenAPI and provides clearer semantics.

- if (!domain) {- return c.json({- error: team.apiKey.domainId - ? "API key doesn't have access to this domain" - : "Domain not found"- }, 404);- }+ if (!domain) {+ const restricted = team.apiKey.domainId != null;+ return c.json(+ {+ error: restricted+ ? "API key doesn't have access to this domain"+ : "Domain not found",+ },+ restricted ? 403 : 404,+ );+ }
📝 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(!domain){
returnc.json({
error: team.apiKey.domainId
? "API key doesn't have access to this domain"
: "Domain not found"
},404);
}
if(!domain){
constrestricted=team.apiKey.domainId!=null;
returnc.json(
{
error: restricted
? "API key doesn't have access to this domain"
: "Domain not found",
},
restricted ? 403 : 404,
);
}
🤖 Prompt for AI Agents
In apps/web/src/server/public-api/api/domains/verify-domain.ts around lines 82
to 88, the handler currently returns a 404 for both missing domains and
access-denied cases; update the response so that if domain is falsy and
team.apiKey.domainId is set you return a 403 status with the access-denied
message, otherwise return a 404 with the "Domain not found" message, preserving
the same JSON shape.

const team = c.var.team;
const emailId = c.req.param("emailId");
await checkIsValidEmailId(emailId, team.id);
await checkIsValidEmailIdWithDomainRestriction(emailId, team.id, team.apiKey.domainId);

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.

🛠️ Refactor suggestion

⚠️ Potential issue

Bug: same null domainId handling and validator misuse apply here

This call will also pass null for legacy keys, which the current validator treats as an explicit domainId = null filter, denying access to emails for verified domains. Please apply the helper fix proposed in the other comment to restore backward compatibility and to emit a proper 403 when access is denied by domain.

🤖 Prompt for AI Agents
In apps/web/src/server/public-api/api/emails/cancel-email.ts around line 40, the
call to checkIsValidEmailIdWithDomainRestriction(emailId, team.id,
team.apiKey.domainId) will pass null for legacy keys which the validator treats
as an explicit null filter and denies access; replace the direct pass with the
same helper used in the other comment that converts legacy null domainId to
undefined (restoring backward compatibility), and ensure the validator's
rejection is caught and converted to an HTTP 403 response when access is denied
by domain.

Comment on lines +63 to 74
const whereClause: { id: string; teamId: number; domainId?: number } = {
id: emailId,
teamId: team.id,
};

if (team.apiKey.domainId !== null) {
whereClause.domainId = team.apiKey.domainId;
}

const email = await db.email.findUnique({
where: {
id: emailId,
teamId: team.id,
},
where: whereClause,
select: {

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.

⚠️ Potential issue

Bug: Using findUnique with non-unique composite where (id + teamId [+ domainId]) will not compile/run

findUnique only accepts a unique selector (e.g., { id }) or a declared composite unique. Adding teamId/domainId makes it an invalid UniqueWhere. Use findFirst with a normal where filter and preserve domain scoping.

Apply:

- const whereClause: { id: string; teamId: number; domainId?: number } = {- id: emailId,- teamId: team.id,- };-- if (team.apiKey.domainId !== null) {- whereClause.domainId = team.apiKey.domainId;- }+ const whereClause = {+ id: emailId,+ teamId: team.id,+ ...(team.apiKey.domainId !== null ? { domainId: team.apiKey.domainId } : {}),+ };
@@
- const email = await db.email.findUnique({- where: whereClause,+ const email = await db.email.findFirst({+ where: whereClause,
select: {

Optional: import and use Prisma.EmailWhereInput for stricter typing of whereClause.

📝 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
constwhereClause: {id: string;teamId: number;domainId?: number}={
id: emailId,
teamId: team.id,
};
if(team.apiKey.domainId!==null){
whereClause.domainId=team.apiKey.domainId;
}
constemail=awaitdb.email.findUnique({
where: {
id: emailId,
teamId: team.id,
},
where: whereClause,
select: {
constwhereClause={
id: emailId,
teamId: team.id,
...(team.apiKey.domainId!==null ? {domainId: team.apiKey.domainId} : {}),
};
constemail=awaitdb.email.findFirst({
where: whereClause,
select: {
🤖 Prompt for AI Agents
In apps/web/src/server/public-api/api/emails/get-email.ts around lines 63–74,
the code builds a composite whereClause (id + teamId [+ domainId]) but calls
db.email.findUnique, which requires a unique selector; change to
db.email.findFirst and pass the built whereClause as the normal where filter so
non-unique composite filters compile and run correctly. Keep the domain scoping
logic (set domainId only when team.apiKey.domainId !== null), and optionally
import and type whereClause as Prisma.EmailWhereInput for stricter typing.

const emailId = c.req.param("emailId");

await checkIsValidEmailId(emailId, team.id);
await checkIsValidEmailIdWithDomainRestriction(emailId, team.id, team.apiKey.domainId);

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.

🛠️ Refactor suggestion

⚠️ Potential issue

Bug: null domainId is treated as a filter (breaking “All Domains”) and validator likely misuses Prisma findUnique

Passing team.apiKey.domainId directly will be null for legacy “All Domains” keys. The current validator (see api-utils.ts snippet in the PR) checks apiKeyDomainId !== undefined, which is true for null and will set whereClause.domainId = null. That restricts queries to emails with domainId IS NULL, inadvertently denying access to emails belonging to any domain and breaking backward compatibility.

Additionally, the validator uses db.email.findUnique({ where: { id, teamId, domainId? } }). In Prisma, findUnique only accepts a unique selector (e.g., by id, or a defined composite unique). If id is already unique, adding teamId/domainId will not match the UniqueWhereInput type, causing a type error or being rejected by the client. The correct approach is either findFirst with a where filter, or fetch by { id, teamId } and then enforce domain.

Action:

  • Treat null the same as “no domain restriction” (don’t add domainId to the filter).
  • Replace findUnique with findFirst (or fetch by { id, teamId } then check domain).
  • Return a clear 403/“FORBIDDEN” error when the email exists but the key’s domain doesn’t match, to meet the PR objective “returns clear error messages when a key lacks domain access.”

Proposed fix for apps/web/src/server/public-api/api-utils.ts:

-export const checkIsValidEmailIdWithDomainRestriction = async (- emailId: string, - teamId: number, - apiKeyDomainId?: number-) => {- const whereClause: { id: string; teamId: number; domainId?: number } = {- id: emailId,- teamId,- };-- if (apiKeyDomainId !== undefined) {- whereClause.domainId = apiKeyDomainId;- }-- const email = await db.email.findUnique({ where: whereClause });-- if (!email) {- throw new UnsendApiError({ code: "NOT_FOUND", message: "Email not found" });- }-- return email;-};+export const checkIsValidEmailIdWithDomainRestriction = async (+ emailId: string,+ teamId: number,+ apiKeyDomainId?: number | null+) => {+ // Fetch by id+teamId first (single query) then enforce domain restriction.+ const email = await db.email.findFirst({+ where: { id: emailId, teamId },+ select: { id: true, teamId: true, domainId: true },+ });++ if (!email) {+ throw new UnsendApiError({ code: "NOT_FOUND", message: "Email not found" });+ }++ // Only enforce domain check when the API key is actually scoped.+ if (apiKeyDomainId != null && email.domainId !== apiKeyDomainId) {+ throw new UnsendApiError({+ code: "FORBIDDEN",+ message: "API key does not have access to this email's domain",+ });+ }++ return email;+};

No changes are needed at this call site after updating the helper.

I can push a follow-up commit with this fix and add tests covering: legacy key (null domain), scoped key allowed domain, scoped key disallowed domain (403), and non-existent email (404).

📝 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
awaitcheckIsValidEmailIdWithDomainRestriction(emailId,team.id,team.apiKey.domainId);
// apps/web/src/server/public-api/api-utils.ts
exportconstcheckIsValidEmailIdWithDomainRestriction=async(
emailId: string,
teamId: number,
apiKeyDomainId?: number|null
)=>{
// Fetch by id+teamId first (single query) then enforce domain restriction.
constemail=awaitdb.email.findFirst({
where: {id: emailId, teamId },
select: {id: true,teamId: true,domainId: true},
});
if(!email){
thrownewUnsendApiError({code: "NOT_FOUND",message: "Email not found"});
}
// Only enforce domain check when the API key is actually scoped.
if(apiKeyDomainId!=null&&email.domainId!==apiKeyDomainId){
thrownewUnsendApiError({
code: "FORBIDDEN",
message: "API key does not have access to this email's domain",
});
}
returnemail;
};

@vercel

vercelBot commented Sep 9, 2025

Copy link
Copy Markdown

@enemyrr is attempting to deploy a commit to the kmkoushik's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitaicoderabbitaiBot 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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/web/src/server/service/email-service.ts (2)

490-492: Suppressed-in-bulk path should also respect key domain restrictions.

Even for suppressed records, validate domain via the API key when provided to avoid unauthorized domain associations.

- // Validate domain for suppressed email too- const domain = await validateDomainFromEmail(from, teamId);+ // Validate domain for suppressed email too (key-aware)+ let domain: Awaited<ReturnType<typeof validateDomainFromEmail>>;+ if (apiKeyId) {+ const apiKey = await db.apiKey.findUnique({+ where: { id: apiKeyId },+ include: { domain: true },+ });+ if (!apiKey) {+ throw new UnsendApiError({ code: "BAD_REQUEST", message: "Invalid API key" });+ }+ domain = await validateApiKeyDomainAccess(from, teamId, apiKey);+ } else {+ domain = await validateDomainFromEmail(from, teamId);+ }

339-339: Rename chancelEmail to cancelEmail in EmailQueueService and update usages
In apps/web/src/server/service/email-queue-service.ts (line 271) rename the static method from chancelEmail to cancelEmail, and update the call in apps/web/src/server/service/email-service.ts (line 339) plus any other references accordingly.

♻️ Duplicate comments (2)
apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx (1)

196-222: Make Select controlled and handle loading/empty/error states (repeat of prior feedback)

Keeps UI in sync after form reset and improves UX. Mirrors the earlier review suggestion.

Apply:

- <Select onValueChange={field.onChange} defaultValue={field.value}>+ <Select+ onValueChange={field.onChange}+ value={field.value ?? "all"}+ disabled={domainsQuery.isLoading || domainsQuery.isError}+ >
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select domain access" />
</SelectTrigger>
</FormControl>
- <SelectContent>- <SelectItem value="all">All Domains</SelectItem>- {domainsQuery.data?.map((domain: { id: number; name: string }) => (- <SelectItem key={domain.id} value={domain.id.toString()}>- {domain.name}- </SelectItem>- ))}- </SelectContent>+ <SelectContent>+ <SelectItem value="all">All Domains</SelectItem>+ {domainsQuery.isLoading && (+ <SelectItem value="loading" disabled>+ Loading domains…+ </SelectItem>+ )}+ {domainsQuery.isError && (+ <SelectItem value="error" disabled>+ Failed to load domains+ </SelectItem>+ )}+ {!domainsQuery.isLoading &&+ !domainsQuery.isError &&+ (domainsQuery.data?.length ?? 0) === 0 && (+ <SelectItem value="none" disabled>+ No domains available+ </SelectItem>+ )}+ {domainsQuery.data?.map((domain: { id: number; name: string }) => (+ <SelectItem key={domain.id} value={domain.id.toString()}>+ {domain.name}+ </SelectItem>+ ))}+ </SelectContent>
</Select>
- <FormDescription>- Choose which domain this API key can send emails from.- </FormDescription>+ {apiKeyForm.formState.errors.domainId ? (+ <FormMessage />+ ) : (+ <FormDescription>+ Choose which domain this API key can send emails from.+ </FormDescription>+ )}
apps/web/src/server/service/email-service.ts (1)

584-601: Bulk path bypasses domain restrictions — restricted keys can send from other domains.

sendBulkEmails groups by from and calls validateDomainFromEmail only. This lets a key restricted to Domain A send from Domain B in bulk. Preload keys and validate per (from, apiKeyId) group using validateApiKeyDomainAccess.

Apply:

@@
-import { validateDomainFromEmail, validateApiKeyDomainAccess } from "./domain-service";+import { validateDomainFromEmail, validateApiKeyDomainAccess } from "./domain-service";+import type { ApiKey } from "@prisma/client";
@@
- // Group emails by domain to minimize domain validations- const emailsByDomain = new Map<- string,- {- domain: Awaited<ReturnType<typeof validateDomainFromEmail>>;- emails: typeof filteredEmailContents;- }- >();+ // Preload unique API keys (include domain) to avoid N+1+ const uniqueApiKeyIds = Array.from(+ new Set(+ filteredEmailContents+ .map((c) => c.apiKeyId)+ .filter((id): id is number => typeof id === "number")+ )+ );+ const apiKeys: (ApiKey & { domain: any | null })[] = uniqueApiKeyIds.length+ ? await db.apiKey.findMany({+ where: { id: { in: uniqueApiKeyIds } },+ include: { domain: true },+ })+ : [];+ const apiKeyById = new Map(apiKeys.map((k) => [k.id, k]));++ // Group by (from, apiKeyId) to preserve domain restrictions+ const emailsByGroup = new Map<+ string,+ {+ domain: Awaited<ReturnType<typeof validateDomainFromEmail>>;+ emails: typeof filteredEmailContents;+ }+ >();
@@
- // First pass: validate domains and group emails- for (const content of filteredEmailContents) {- const { from } = content;- if (!emailsByDomain.has(from)) {- const domain = await validateDomainFromEmail(from, content.teamId);- emailsByDomain.set(from, { domain, emails: [] });- }- emailsByDomain.get(from)?.emails.push(content);- }+ // First pass: resolve domains with key awareness and group+ for (const content of filteredEmailContents) {+ const { from, teamId, apiKeyId } = content;+ const key = `${from}#${apiKeyId ?? "none"}`;+ if (!emailsByGroup.has(key)) {+ let domain: Awaited<ReturnType<typeof validateDomainFromEmail>>;+ if (apiKeyId) {+ const apiKey = apiKeyById.get(apiKeyId);+ if (!apiKey) {+ throw new UnsendApiError({ code: "BAD_REQUEST", message: "Invalid API key" });+ }+ domain = await validateApiKeyDomainAccess(from, teamId, apiKey);+ } else {+ domain = await validateDomainFromEmail(from, teamId);+ }+ emailsByGroup.set(key, { domain, emails: [] });+ }+ emailsByGroup.get(key)!.emails.push(content);+ }
@@
- // Process each domain group- for (const { domain, emails } of emailsByDomain.values()) {+ // Process each group+ for (const { domain, emails } of emailsByGroup.values()) {

Also applies to: 612-737

🧹 Nitpick comments (7)
apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx (2)

28-28: Column added — minor copy casing check

"Domain Access" is Title Case while "Last used" / "Created at" are sentence case. Consider standardizing across headers for consistency.


56-60: Avoid falsy trap on domainId and unify copy

If domainId could ever be 0, the truthy check misclassifies it. Prefer explicit nullish check and align copy with the form ("All Domains").

Apply:

- <TableCell>- {apiKey.domainId- ? apiKey.domain?.name ?? "Domain removed"- : "All domains"}- </TableCell>+ <TableCell>+ {apiKey.domainId != null+ ? apiKey.domain?.name ?? "Domain removed"+ : "All Domains"}+ </TableCell>
apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx (2)

43-44: Tighten domainId validation (union of "all" or numeric id) and set default

Prevents invalid values and aligns with downstream Number(parse) usage.

Apply:

- domainId: z.string().optional(),+ domainId: z+ .union([z.literal("all"), z.string().regex(/^\d+$/, "Invalid domain id")])+ .default("all"),

Also applies to: 61-62


70-71: Prefer explicit base when parsing domainId

Use parseInt with radix 10 to avoid edge cases.

Apply:

- domainId: values.domainId === "all" ? undefined : Number(values.domainId),+ domainId:+ values.domainId === "all" ? undefined : parseInt(values.domainId, 10),
apps/web/src/server/service/email-service.ts (3)

238-242: Validate scheduledAt.

Protect against invalid dates before computing delay.

- const scheduledAtDate = scheduledAt ? new Date(scheduledAt) : undefined;+ const scheduledAtDate = scheduledAt ? new Date(scheduledAt) : undefined;+ if (scheduledAt && Number.isNaN(scheduledAtDate!.getTime())) {+ throw new UnsendApiError({ code: "BAD_REQUEST", message: "Invalid scheduledAt date" });+ }

688-692: Apply the same scheduledAt validation in bulk.

- const scheduledAtDate = scheduledAt ? new Date(scheduledAt) : undefined;+ const scheduledAtDate = scheduledAt ? new Date(scheduledAt) : undefined;+ if (scheduledAt && Number.isNaN(scheduledAtDate!.getTime())) {+ throw new UnsendApiError({+ code: "BAD_REQUEST",+ message: `Invalid scheduledAt date for email to ${to}`,+ });+ }

446-458: Avoid logging full recipient lists for suppressed emails.

Consider logging counts only or gating behind debug to reduce PII in logs.

- suppressedEmails: suppressedEmailsInfo.map((info) => ({- to: info.content.to,- suppressedAddresses: info.suppressedEmails,- })),+ suppressedEmailsCountByItem: suppressedEmailsInfo.map((info) => ({+ toCount: Array.isArray(info.content.to) ? info.content.to.length : 1,+ suppressedCount: info.suppressedEmails.length,+ })),
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3288090 and db35031.

📒 Files selected for processing (6)
  • apps/web/prisma/schema.prisma (2 hunks)
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx (4 hunks)
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx (4 hunks)
  • apps/web/src/server/public-api/hono.ts (1 hunks)
  • apps/web/src/server/service/domain-service.ts (3 hunks)
  • apps/web/src/server/service/email-service.ts (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • apps/web/src/server/service/domain-service.ts
  • apps/web/prisma/schema.prisma
  • apps/web/src/server/public-api/hono.ts
🧰 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/app/(dashboard)/dev-settings/api-keys/api-list.tsx
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx
  • apps/web/src/server/service/email-service.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Use 2-space indentation in TypeScript code (enforced by Prettier)
Use semicolons in TypeScript code (enforced by Prettier)
Do not use dynamic imports

Files:

  • apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx
  • apps/web/src/server/service/email-service.ts
**/*.{ts,tsx,md}

📄 CodeRabbit inference engine (AGENTS.md)

Format code with Prettier 3 via pnpm format for TypeScript and Markdown files

Files:

  • apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx
  • apps/web/src/server/service/email-service.ts
**/*.tsx

📄 CodeRabbit inference engine (AGENTS.md)

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

Files:

  • apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx
apps/web/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

In apps/web, use the / alias for src imports (e.g., import { x } from "/utils/x")

Files:

  • apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx
  • apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx
  • apps/web/src/server/service/email-service.ts
🧬 Code graph analysis (3)
apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx (1)
packages/ui/src/table.tsx (2)
  • TableHead (113-113)
  • TableCell (115-115)
apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx (2)
packages/ui/src/form.tsx (5)
  • FormField (176-176)
  • FormItem (171-171)
  • FormLabel (172-172)
  • FormControl (173-173)
  • FormDescription (174-174)
packages/ui/src/select.tsx (5)
  • Select (150-150)
  • SelectTrigger (153-153)
  • SelectValue (152-152)
  • SelectContent (154-154)
  • SelectItem (156-156)
apps/web/src/server/service/email-service.ts (3)
apps/web/src/server/service/domain-service.ts (2)
  • validateDomainFromEmail (14-57)
  • validateApiKeyDomainAccess (59-81)
apps/web/src/server/db.ts (1)
  • db (20-20)
apps/web/src/server/public-api/api-error.ts (1)
  • UnsendApiError (62-75)
🔇 Additional comments (4)
apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx (2)

37-37: colSpan bumped to 7 — matches new column

Counts align with 7 headers. Looks good.

Also applies to: 46-46


62-64: Confirm lastUsed type for date-fns

Ensure apiKey.lastUsed is a Date (not an ISO string). If it’s a string, wrap with new Date(...) to avoid runtime issues.

Alternative:

- ? formatDistanceToNow(apiKey.lastUsed, { addSuffix: true })+ ? formatDistanceToNow(new Date(apiKey.lastUsed), { addSuffix: true })
apps/web/src/server/service/email-service.ts (2)

73-93: Single-send now enforces domain restrictions — good.

Branching to validateApiKeyDomainAccess when apiKeyId is present correctly gates by the key’s domain. Looks solid.


77-88: Remove redundant team-scoping guard in email-service
Authentication middleware loads the API key’s team and exposes only that apiKeyId in context—every call to sendEmail already receives teamId and apiKeyId from the verified key, so no additional check in the service is needed.

Comment on lines +30 to +36
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@unsend/ui/src/select";

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.

⚠️ Potential issue

Incorrect import path breaks build

Select components import from "@unsend/..." (typo). Should be "@usesend/...".

Apply:

-} from "@unsend/ui/src/select";+} from "@usesend/ui/src/select";
📝 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
import{
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
}from"@unsend/ui/src/select";
import{
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
}from"@usesend/ui/src/select";
🤖 Prompt for AI Agents
In apps/web/src/app/(dashboard)/dev-settings/api-keys/add-api-key.tsx around
lines 30 to 36 the Select component imports use the incorrect package scope
"@unsend/..." which breaks the build; update the import specifier to
"@usesend/ui/src/select" for all imported symbols (Select, SelectContent,
SelectItem, SelectTrigger, SelectValue) so the module resolves correctly.

@KMKoushik
KMKoushik changed the base branch from main to api-key-with-domain-access-controlSeptember 10, 2025 11:23
@KMKoushik

Copy link
Copy Markdown
Member

need some minor fixes, will merge and fix it in myself, TYSM

@KMKoushik
KMKoushik self-requested a review September 10, 2025 11:29
@KMKoushik
KMKoushik merged commit b868667 into usesend:api-key-with-domain-access-controlSep 10, 2025
1 of 2 checks passed
KMKoushik pushed a commit that referenced this pull request Sep 10, 2025
Co-authored-by: Claude <noreply@anthropic.com>
@coderabbitaicoderabbitaiBot mentioned this pull request Oct 13, 2025
@coderabbitaicoderabbitaiBot mentioned this pull request Dec 6, 2025
@coderabbitaicoderabbitaiBot mentioned this pull request Jan 4, 2026
Sign up for freeto 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.

2 participants

@enemyrr@KMKoushik