Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

@BilalG1
Copy link
Contributor

@BilalG1 BilalG1 commented Aug 18, 2025

https://www.loom.com/share/cc379c5372244a169f3ae1d2cc91eae5?sid=ec5bc438-56d8-4cca-9bbc-6cf6c6d313ad


Important

Introduce email draft functionality with AI assistance, including creation, editing, and sending capabilities, and update APIs and UI components accordingly.

  • Features:
    • Add email draft functionality: create, list, edit, preview, and send drafts.
    • Integrate AI-assisted draft generation with emailDraftAdapter in email-draft-adapter.ts.
    • Update send-email and render-email APIs to support drafts.
  • Backend:
    • Add EmailDraft model in schema.prisma.
    • Implement draft CRUD operations in email-drafts/route.tsx and email-drafts/[id]/route.tsx.
    • Update render-email/route.tsx and send-email/route.tsx to handle draft inputs.
  • Frontend:
    • Add email draft UI in email-drafts/page-client.tsx and email-drafts/[draftId]/page-client.tsx.
    • Implement theme selection with EmailThemeSelector in email-theme-selector.tsx.
    • Update sidebar navigation in sidebar-layout.tsx to include drafts.
  • Tests:
    • Add E2E tests for draft lifecycle and sending in email-drafts.test.ts and send-email.test.ts.
  • Misc:
    • Update admin-interface.ts and server-interface.ts to support draft operations.
    • Add XOR type utility in types.tsx for exclusive option handling.

This description was created by Ellipsis for c7ebb00. You can customize this summary. It will automatically update as commits are pushed.


Summary by CodeRabbit

  • New Features

    • Email Drafts: create, list, view, edit, preview, theme-select, AI-assisted draft generation, send (marks draft as sent) and dashboard UI for drafting + recipient selection.
  • Improvements

    • Send/render APIs accept html, template, or draft inputs and an all-users option; per-recipient delivery/reporting, unified theme selector, expanded chat context for drafts, clearer schema validation errors, breadcrumb updates.
  • Tests

    • E2E coverage for draft lifecycle, draft-based send, all-users flow, and updated schema-validation snapshots.

@vercel
Copy link

vercel bot commented Aug 18, 2025

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

Project Deployment Preview Comments Updated (UTC)
stack-backend Ready Ready Preview Comment Sep 10, 2025 10:56pm
stack-dashboard Ready Ready Preview Comment Sep 10, 2025 10:56pm
stack-demo Ready Ready Preview Comment Sep 10, 2025 10:56pm
stack-docs Ready Ready Preview Comment Sep 10, 2025 10:56pm

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 18, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

Adds EmailDraft DB model and migration; tenancy-scoped CRUD API and dashboard UI for drafts; draft-aware render/send handlers and utilities; AI adapter/tool for TSX draft generation; type/interface updates (XOR, sendEmail options); and e2e tests with updated validation snapshots.

Changes

Cohort / File(s) Summary
Database schema & migration
apps/backend/prisma/migrations/.../migration.sql, apps/backend/prisma/schema.prisma
Add DraftThemeMode enum and new EmailDraft model with composite PK (tenancyId,id), defaults for themeMode and createdAt.
Public email APIs (render/send)
apps/backend/src/app/api/latest/emails/render-email/route.tsx, apps/backend/src/app/api/latest/emails/send-email/route.tsx
Replace flat request schemas with union shapes; accept content by id or TSX and theme by id or TSX; add draft_id and all_users paths; resolve sources via tenancy maps and getEmailDraft; per-user send flow records per-user errors and marks draft sentAt.
Internal draft CRUD routes
apps/backend/src/app/api/latest/internal/email-drafts/route.tsx, apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx
Add hidden tenancy-scoped endpoints: list/create (GET/POST) and get/update (GET/PATCH); use per-tenant Prisma client; map theme id ↔ DraftThemeMode.
Email rendering core
apps/backend/src/lib/email-rendering.tsx
First param renamed to templateOrDraftComponent; ensure variables defaults to {}; bundler mapping uses updated param.
Draft utilities
apps/backend/src/lib/email-drafts.tsx
New getEmailDraft(prisma, tenancyId, draftId) and converters templateThemeIdToThemeMode / themeModeToTemplateThemeId.
AI chat integration
apps/backend/src/lib/ai-chat/adapter-registry.ts, apps/backend/src/lib/ai-chat/email-draft-adapter.ts, apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx
Add "email-draft" context, register emailDraftAdapter exposing createEmailTemplate tool (zod schema + system prompt); accept new context_type.
Dashboard: drafts pages & client UI
apps/dashboard/.../email-drafts/page.tsx, .../page-client.tsx, .../[draftId]/page.tsx, .../[draftId]/page-client.tsx
Add listing and detail pages and client UI for creating/editing TSX drafts, live preview, theme selector, assistant chat/tool UI, and send flow supporting all_users or specific userIds.
Dashboard nav & breadcrumbs
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx
Add Drafts nav item, change Templates icon, add dynamic breadcrumb for draft detail resolving displayName via admin app.
Dashboard components & editor tooling
apps/dashboard/src/components/email-theme-selector.tsx, .../email-preview.tsx, .../assistant-ui/thread.tsx, .../vibe-coding/draft-tool-components.tsx, .../vibe-coding/code-editor.tsx, .../vibe-coding/chat-adapters.ts
New EmailThemeSelector; change themeId types to allow undefined; remove mounting of welcome suggestions; add EmailDraftUI tool UI to inject generated TSX; default generic Props type in editor; accept "email-draft" in chat adapter types.
Template editor tweak
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx
Replace local ThemeSelector with EmailThemeSelector and simplify preview theme prop handling.
Shared interfaces (admin/server)
packages/stack-shared/src/interface/admin-interface.ts, packages/stack-shared/src/interface/server-interface.ts
Add admin methods to list/create/update internal email drafts; extend sendChatMessage contextType to include "email-draft"; server sendEmail options now allow optional userIds, allUsers, and draftId.
Template app APIs & types
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts, packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts, packages/template/src/lib/stack-app/email/index.ts
Add emailDrafts AsyncStore and use/list/create/update methods; expand sendChatMessage contextType; rewrite SendEmailOptions to use new XOR type enforcing exclusive recipient and content choices (adds draftId).
Types utility
packages/stack-shared/src/utils/types.tsx
Add exported XOR<T> type to express exclusive-or over tuple members.
E2E & tests
apps/e2e/tests/backend/.../render-email.test.ts, .../send-email.test.ts, apps/e2e/tests/js/email.test.ts, apps/e2e/tests/backend/endpoints/api/v1/internal/email-drafts.test.ts
Update snapshots/expectations to multi-schema validation error format; add all_users send tests; add internal email-drafts e2e coverage; adjust expected messages for missing/invalid ids and path validations.
Misc
examples/supabase/package.json
Start script now passes --port 8115 to next start.

Sequence Diagram(s)

sequenceDiagram
  rect rgba(220,235,255,0.6)
  participant UI as Dashboard (create)
  participant AdminApp as StackAdminApp
  participant API as /internal/email-drafts
  participant DB as Prisma
  UI->>AdminApp: createEmailDraft({ displayName, themeId?, tsxSource? })
  AdminApp->>API: POST /internal/email-drafts
  API->>DB: create EmailDraft (tenancyId,...)
  DB-->>API: created
  API-->>AdminApp: { id }
  AdminApp-->>UI: navigate to /email-drafts/{id}
  end
Loading
sequenceDiagram
  rect rgba(235,245,220,0.6)
  participant UI as Dashboard (send)
  participant AdminApp as StackServerInterface
  participant PublicAPI as POST /api/v1/emails/send-email
  participant Core as Send Handler
  participant DB as Prisma
  participant Mail as Email Service
  UI->>AdminApp: sendEmail({ draftId, allUsers|userIds, themeId? })
  AdminApp->>PublicAPI: { draft_id, all_users|user_ids, theme_id? }
  PublicAPI->>Core: validate union body
  Core->>DB: getEmailDraft(tenancyId,draftId)
  Core->>Core: resolve theme via draft.themeMode/themeId
  Core->>Mail: render per-user and send (per-user handling)
  Mail-->>Core: per-user results
  Core-->>PublicAPI: 200 { results[...] }
  PublicAPI-->>AdminApp: results
  end
Loading
sequenceDiagram
  rect rgba(255,245,220,0.6)
  participant ChatUI as Assistant Chat
  participant Adapter as email-draft adapter
  participant Editor as Code Editor
  ChatUI->>Adapter: contextType="email-draft"
  Adapter-->>ChatUI: exposes tool createEmailTemplate
  ChatUI->>Adapter: tool-call -> { content: "<TSX...>" }
  Adapter-->>Editor: tool result { content }
  Editor->>Editor: setCurrentCode(content)
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • N2D4

Pre-merge checks (2 passed, 1 warning)

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title Check ✅ Passed The title "Email Drafts" is concise, clear, and directly summarizes the primary change set (introducing email-draft functionality across backend, frontend, tests, and AI tooling). It is short and readable, avoids noise like file lists or emojis, and will be understandable to teammates scanning PR history. While slightly more specific wording could mention CRUD+AI+send flow, the current title suitably captures the main intent.
Description Check ✅ Passed The PR description is detailed and structured: it includes a Loom demo link, a clear summary of features, backend/front-end/test changes, and miscellaneous notes (including the migration and type changes), which gives reviewers a good overview of scope and intent. The repository's provided template is minimal (a CONTRIBUTING.md pointer), so the current description satisfies and exceeds that requirement by providing implementation and testing details. If desired, the author could add an explicit "How to test" checklist or a short list of breaking changes, but those are optional enhancements.

Poem

I nibble code in moonlit drafts,
I stitch TSX with careful crafts, ✨
Themes: default, none, or custom too,
I hop, I render, then I send to you —
Inbox-bound with a cheerful chew. 🐇✉️

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch email-drafts

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

❤️ Share

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

@claude
Copy link
Contributor

claude bot commented Aug 18, 2025

Claude finished @BilalG1's task —— View job


Code Review Complete ✅

Reviewing PR #849: Email Drafts functionality

Review Tasks:

  • Analyze database schema and migration
  • Review API endpoints and security
  • Check React components and UI patterns
  • Examine AI integration and email rendering
  • Assess test coverage
  • Provide comprehensive feedback

🔍 Overall Assessment

This is a well-implemented feature that adds email draft functionality with AI assistance. The code generally follows good practices, but there are several areas for improvement.

✅ What's Working Well

Database Design:

  • Clean schema with proper UUID keys and constraints
  • Good separation of theme modes (PROJECT_DEFAULT, NONE, CUSTOM)
  • Proper foreign key relationships and cascading deletes

Test Coverage:

  • Comprehensive E2E tests covering various scenarios
  • Good error handling test cases
  • Tests for invalid data, missing users, disabled notifications

Architecture:

  • Clear separation between API routes, business logic, and UI
  • Consistent use of smart route handlers
  • Proper validation schemas using Yup

⚠️ Areas for Improvement

1. Security Concerns

Code Injection Risk (apps/backend/src/lib/ai-chat/email-draft-adapter.ts:16):

parameters: z.object({
  content: z.string().describe("A react component that renders the email template"),
}),

The AI can generate arbitrary React components that get executed. Consider:

  • Implementing a TSX sanitizer/validator
  • Using a whitelist of allowed imports/components
  • Sandboxing the email rendering process

Missing Input Validation (apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx:72-73):

themeMode: (body.theme_id === undefined) ? undefined : ((body.theme_id === null) ? ("PROJECT_DEFAULT" as any) : (body.theme_id === false) ? ("NONE" as any) : ("CUSTOM" as any)),
themeId: body.theme_id === undefined ? undefined : (typeof body.theme_id === 'string' ? body.theme_id : null),

Complex ternary logic without proper validation could lead to unexpected behavior.

2. Code Quality Issues

Type Safety (apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx:35-40):

theme_id: ((): any => {
  if (d.themeMode === "CUSTOM") return d.themeId;
  if (d.themeMode === "NONE") return false;
  return null;
})(),

Using any type defeats TypeScript's benefits. Should use proper union types.

Error Handling (apps/backend/src/app/api/latest/emails/send-email/route.tsx:193-195):

} catch {
  userSendErrors.set(user.projectUserId, "Failed to send email");
}

Generic error handling loses important debugging information. Should log the actual error.

Performance (apps/backend/src/app/api/latest/emails/send-email/route.tsx:96-106):
The code queries all users when body.user_ids is provided, but should use a more efficient query:

where: {
  tenancyId: auth.tenancy.id,
  projectUserId: { in: body.user_ids }  // This could be optimized
}

3. Potential Bugs

Logic Error (apps/backend/src/lib/email-drafts.tsx:32):

return themeId === null ? undefined : themeId;

This function can return undefined for CUSTOM theme mode, which seems incorrect.

Missing Validation (apps/backend/src/app/api/latest/emails/send-email/route.tsx:107-110):

const missingUserIds = body.user_ids?.filter(userId => !users.some(user => user.projectUserId === userId));

This only works when user_ids is provided, but the logic assumes it exists in all contexts.

4. User Experience Issues

No Confirmation for Destructive Actions: The UI allows sending emails immediately without confirmation dialogs for bulk operations.

Limited Error Feedback (apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx:108):

} else {
  toast({ title: "Failed to send email", variant: "destructive", description: "Unknown error" });
}

Generic error messages don't help users understand what went wrong.

🚀 Recommended Improvements

  1. Add TSX validation/sanitization for AI-generated components
  2. Replace any types with proper TypeScript types
  3. Improve error logging and user-facing error messages
  4. Add confirmation dialogs for bulk email operations
  5. Optimize database queries for better performance
  6. Consider rate limiting for email sending operations

📊 Test Coverage Assessment

Excellent test coverage with comprehensive E2E tests covering:

  • Invalid requests and authentication
  • Validation errors
  • All user scenarios
  • Template-based emails
  • Notification categories

The tests are well-structured and cover both happy paths and error cases effectively.


Despite the areas for improvement, this is a solid implementation of email drafts functionality with good architecture and comprehensive testing. The security concerns around AI-generated code should be addressed before merging to production.

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

Greptile Summary

This PR implements a comprehensive email drafts feature for the Stack Auth dashboard, allowing users to create, edit, and manage email templates before sending them. The implementation includes:

Backend Infrastructure:

  • New EmailDraft database table with theme configuration, TSX source code storage, and sent status tracking
  • API endpoints for CRUD operations on email drafts (/internal/email-drafts and /internal/email-drafts/[id])
  • Extended the existing /emails/send-email endpoint to support sending from drafts via draft_id parameter
  • Added all_users functionality for bulk email sending to entire user bases
  • New AI chat adapter for draft creation with system prompts and tools for AI-assisted template generation

Frontend Features:

  • New dashboard pages at /email-drafts (list view) and /email-drafts/[draftId] (editor view)
  • Two-stage workflow: editing stage with code editor and AI chat assistance, then sending stage with recipient selection
  • Extracted and created reusable EmailThemeSelector component for consistent theme selection across templates and drafts
  • Added sidebar navigation with breadcrumb support for draft management
  • Integration with existing TeamMemberSearchTable for user selection during send process

Type System & API Extensions:

  • Extended admin interface with listInternalEmailDrafts, createEmailDraft, and updateEmailDraft methods
  • Modified sendEmail to support optional userIds, new allUsers flag, and draftId parameter
  • Added XOR type utility for mutually exclusive option combinations
  • Updated email rendering system to handle draft content alongside existing template rendering

AI Integration:

  • Extended chat context types to include 'email-draft' across multiple adapter systems
  • Created draft-specific UI components for AI-generated content with undo functionality
  • Email drafts can be created and edited through AI assistance similar to existing theme and template workflows

The feature follows established patterns throughout the codebase, maintaining consistency with existing email template functionality while providing a more flexible drafting workflow for one-time email compositions.

Confidence score: 3/5

  • This PR introduces significant new functionality with potential runtime risks due to incomplete validation and error handling in several areas
  • Score reflects concerns about missing foreign key constraints in database schema, 'any' type usage, and inconsistent theme handling logic across components
  • Pay close attention to apps/backend/src/app/api/latest/emails/send-email/route.tsx, apps/backend/prisma/migrations/20250815012830_email_drafts/migration.sql, and apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx

30 files reviewed, 14 comments

Edit Code Review Bot Settings | Greptile

@recurseml
Copy link

recurseml bot commented Aug 18, 2025

Review by RecurseML

🔍 Review performed on 2c237ef..060335c

✨ No bugs found, your code is sparkling clean

✅ Files analyzed, no issues (5)

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx
apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx
apps/backend/src/app/api/latest/internal/email-drafts/route.tsx
apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx

⏭️ Files skipped (low suspicion) (25)

apps/backend/prisma/migrations/20250815012830_email_drafts/migration.sql
apps/backend/prisma/schema.prisma
apps/backend/src/app/api/latest/emails/render-email/route.tsx
apps/backend/src/app/api/latest/emails/send-email/route.tsx
apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx
apps/backend/src/lib/ai-chat/adapter-registry.ts
apps/backend/src/lib/ai-chat/email-draft-adapter.ts
apps/backend/src/lib/email-drafts.tsx
apps/backend/src/lib/email-rendering.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx
apps/dashboard/src/components/assistant-ui/thread.tsx
apps/dashboard/src/components/email-preview.tsx
apps/dashboard/src/components/email-theme-selector.tsx
apps/dashboard/src/components/vibe-coding/chat-adapters.ts
apps/dashboard/src/components/vibe-coding/code-editor.tsx
apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx
packages/stack-shared/src/interface/admin-interface.ts
packages/stack-shared/src/interface/server-interface.ts
packages/stack-shared/src/utils/types.tsx
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts
packages/template/src/lib/stack-app/email/index.ts

Need help? Join our Discord

@patched-codes
Copy link

patched-codes bot commented Aug 18, 2025

stack-app.mdx

The documentation for StackAdminApp needs to be updated to reflect recent changes in its functionality. Please make the following modifications:

  1. Expand the existing brief mention of StackAdminApp to include new email drafts functionality.

  2. Update line 46 to read:

    "There is also a third type, StackAdminApp, but it is rarely used. You can use it for automation or internal tools, and can edit your project's configuration. It includes functionality for managing email templates, themes, and drafts for your project."

  3. Consider adding a new section or subsection that details the following changes to the StackAdminApp interface:

    • Addition of a new emailDrafts async store property
    • Removal of useEmailTemplates and listEmailTemplates methods
    • Update to the sendChatMessage method to support a new email-draft contextType
    • Addition of two new methods: createEmailDraft and updateEmailDraft

Please ensure these changes are reflected in the documentation to accurately represent the current functionality of StackAdminApp.

@claude
Copy link
Contributor

claude bot commented Aug 18, 2025

Claude finished @BilalG1's task —— View job


Code Review Complete ✅

Reviewing PR #849: Email Drafts functionality

Review Tasks:

  • Analyze database schema and migration
  • Review API endpoints and security
  • Check React components and UI patterns
  • Examine AI integration and email rendering
  • Assess test coverage
  • Provide comprehensive feedback

🔍 Overall Assessment

This is a well-implemented feature that adds email draft functionality with AI assistance. The code generally follows good practices, but there are several areas for improvement.

✅ What's Working Well

Database Design:

  • Clean schema with proper UUID keys and constraints
  • Good separation of theme modes (PROJECT_DEFAULT, NONE, CUSTOM)
  • Proper foreign key relationships and cascading deletes

Test Coverage:

  • Comprehensive E2E tests covering various scenarios
  • Good error handling test cases
  • Tests for invalid data, missing users, disabled notifications

Architecture:

  • Clear separation between API routes, business logic, and UI
  • Consistent use of smart route handlers
  • Proper validation schemas using Yup

⚠️ Areas for Improvement

1. Security Concerns

Code Injection Risk (apps/backend/src/lib/ai-chat/email-draft-adapter.ts:16):

parameters: z.object({
  content: z.string().describe("A react component that renders the email template"),
}),

The AI can generate arbitrary React components that get executed. Consider:

  • Implementing a TSX sanitizer/validator
  • Using a whitelist of allowed imports/components
  • Sandboxing the email rendering process

Missing Input Validation (apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx:72-73):

themeMode: (body.theme_id === undefined) ? undefined : ((body.theme_id === null) ? ("PROJECT_DEFAULT" as any) : (body.theme_id === false) ? ("NONE" as any) : ("CUSTOM" as any)),
themeId: body.theme_id === undefined ? undefined : (typeof body.theme_id === 'string' ? body.theme_id : null),

Complex ternary logic without proper validation could lead to unexpected behavior.

2. Code Quality Issues

Type Safety (apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx:35-40):

theme_id: ((): any => {
  if (d.themeMode === "CUSTOM") return d.themeId;
  if (d.themeMode === "NONE") return false;
  return null;
})(),

Using any type defeats TypeScript's benefits. Should use proper union types.

Error Handling (apps/backend/src/app/api/latest/emails/send-email/route.tsx:193-195):

} catch {
  userSendErrors.set(user.projectUserId, "Failed to send email");
}

Generic error handling loses important debugging information. Should log the actual error.

Performance (apps/backend/src/app/api/latest/emails/send-email/route.tsx:96-106):
The code queries all users when body.user_ids is provided, but should use a more efficient query:

where: {
  tenancyId: auth.tenancy.id,
  projectUserId: { in: body.user_ids }  // This could be optimized
}

3. Potential Bugs

Logic Error (apps/backend/src/lib/email-drafts.tsx:32):

return themeId === null ? undefined : themeId;

This function can return undefined for CUSTOM theme mode, which seems incorrect.

Missing Validation (apps/backend/src/app/api/latest/emails/send-email/route.tsx:107-110):

const missingUserIds = body.user_ids?.filter(userId => !users.some(user => user.projectUserId === userId));

This only works when user_ids is provided, but the logic assumes it exists in all contexts.

4. User Experience Issues

No Confirmation for Destructive Actions: The UI allows sending emails immediately without confirmation dialogs for bulk operations.

Limited Error Feedback (apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx:108):

} else {
  toast({ title: "Failed to send email", variant: "destructive", description: "Unknown error" });
}

Generic error messages don't help users understand what went wrong.

🚀 Recommended Improvements

  1. Add TSX validation/sanitization for AI-generated components
  2. Replace any types with proper TypeScript types
  3. Improve error logging and user-facing error messages
  4. Add confirmation dialogs for bulk email operations
  5. Optimize database queries for better performance
  6. Consider rate limiting for email sending operations

📊 Test Coverage Assessment

Excellent test coverage with comprehensive E2E tests covering:

  • Invalid requests and authentication
  • Validation errors
  • All user scenarios
  • Template-based emails
  • Notification categories

The tests are well-structured and cover both happy paths and error cases effectively.


Despite the areas for improvement, this is a solid implementation of email drafts functionality with good architecture and comprehensive testing. The security concerns around AI-generated code should be addressed before merging to production.


@claude
Copy link
Contributor

claude bot commented Aug 18, 2025

Claude encountered an error —— View job


Code Review in Progress

Reviewing PR #849: Email Drafts functionality

Review Tasks:

  • Analyze database schema and migration
  • Review API endpoints and security
  • Check React components and UI patterns
  • Examine AI integration and email rendering
  • Assess test coverage
  • Provide comprehensive feedback

View job

@claude
Copy link
Contributor

claude bot commented Aug 18, 2025

Claude is reviewing PR #849: Email Drafts

Review Tasks:

  • Analyze database schema and migration
  • Review API endpoints and security
  • Check React components and UI patterns
  • Examine AI integration and email rendering
  • Assess test coverage
  • Provide comprehensive feedback

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 10

🔭 Outside diff range comments (2)
apps/dashboard/src/components/vibe-coding/code-editor.tsx (1)

84-92: Defaulting Props to never makes variables impossible to pass

With T = never, the variables prop becomes never, which no value can satisfy. This will surface false-positive type errors in the editor. Use a permissive default.

-          type Props<T = never> = {
+          type Props<T = Record<string, unknown>> = {
             variables: T;
             project: {
               displayName: string;
             };
             user: {
               displayName: string | null;
             };
           };
apps/backend/src/app/api/latest/emails/send-email/route.tsx (1)

54-61: Expose per-user errors in API response (schema).

Augment the response schema to include an optional error string per result.

   body: yupObject({
     results: yupArray(yupObject({
       user_id: yupString().defined(),
       user_email: yupString().optional(),
+      error: yupString().optional(),
     })).defined(),
   }).defined(),
♻️ Duplicate comments (8)
apps/dashboard/src/components/email-theme-selector.tsx (1)

10-15: Keep the null/undefined coercion; it’s intentional and correct for your tri-state mapping

The JSON encode/decode approach correctly:

  • maps undefined -> "null" (via stringify) -> undefined on parse (via ?? undefined),
  • maps false -> "false" -> false,
  • maps string IDs -> quoted JSON string -> the original string.

This contradicts the earlier suggestion to drop the coalescing; that would change semantics and break PROJECT_DEFAULT handling.

apps/backend/prisma/migrations/20250815012830_email_drafts/migration.sql (1)

5-17: Add pragmatic indexes and an integrity CHECK; optionally add a tenancy FK if consistent with your schema policy

For typical list/filter patterns, indexes materially help. A CHECK constraint keeps themeMode/themeId coherent. Re: Tenancy FK—many of your models omit a direct FK to Tenancy for flexibility; if you want cascade guarantees, add it here for consistency across the codebase.

Append these statements after the CREATE TABLE block:

 CREATE TABLE "EmailDraft" (
@@
     CONSTRAINT "EmailDraft_pkey" PRIMARY KEY ("tenancyId","id")
 );
+
+-- Useful indexes for common queries
+CREATE INDEX "EmailDraft_tenancyId_createdAt_idx" ON "EmailDraft" ("tenancyId", "createdAt" DESC);
+CREATE INDEX "EmailDraft_tenancyId_sentAt_idx" ON "EmailDraft" ("tenancyId", "sentAt");
+CREATE INDEX "EmailDraft_tenancyId_displayName_idx" ON "EmailDraft" ("tenancyId", "displayName");
+
+-- Optional: enforce themeMode/themeId consistency
+ALTER TABLE "EmailDraft"
+ADD CONSTRAINT "EmailDraft_theme_consistency"
+CHECK (
+  ("themeMode" = 'CUSTOM' AND "themeId" IS NOT NULL)
+  OR ("themeMode" <> 'CUSTOM' AND "themeId" IS NULL)
+);
+
+-- Optional: add FK if you want referential integrity to Tenancy with cascade
+-- ALTER TABLE "EmailDraft"
+--   ADD CONSTRAINT "EmailDraft_tenancy_fkey"
+--   FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") ON DELETE CASCADE;

If you prefer, I can generate a follow-up migration with these statements.

apps/backend/src/app/api/latest/emails/send-email/route.tsx (1)

96-106: Fix all_users handling: query currently filters by undefined list.

When all_users is true, the current query still adds projectUserId: { in: body.user_ids }, which will result in no matches (or an error) because body.user_ids is undefined.

Apply this diff to conditionally include the projectUserId filter only when user_ids is provided:

-    const users = await prisma.projectUser.findMany({
-      where: {
-        tenancyId: auth.tenancy.id,
-        projectUserId: {
-          in: body.user_ids
-        },
-      },
-      include: {
-        contactChannels: true,
-      },
-    });
+    const users = await prisma.projectUser.findMany({
+      where: {
+        tenancyId: auth.tenancy.id,
+        ...(body.user_ids && {
+          projectUserId: { in: body.user_ids },
+        }),
+      },
+      include: {
+        contactChannels: true,
+      },
+    });
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx (1)

35-35: Avoid any — define a proper EmailDraft shape.

Using any here loses type safety. Define a minimal interface that matches useEmailDrafts() and use it.

For example (adjust fields as needed):

-      {drafts.map((draft: any) => (
+      type EmailDraftListItem = { id: string, displayName: string };
+      {drafts.map((draft: EmailDraftListItem) => (
packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts (1)

37-37: Unify themeId type: prefer string | null | false for consistency

Current: string | undefined | false. Elsewhere (render preview, server interface), null represents “project theme”. Recommend aligning this store property to string | null | false.

Apply this diff:

-  & AsyncStoreProperty<"emailDrafts", [], { id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[], true>
+  & AsyncStoreProperty<"emailDrafts", [], { id: string, displayName: string, themeId: string | null | false, tsxSource: string, sentAt: Date | null }[], true>
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1)

24-27: Initialize editor state when the draft loads (async hook timing)

If the draft arrives after mount, currentCode/selectedThemeId remain empty. Seed state once when the draft becomes available to avoid editing a blank buffer.

Apply this diff:

   const [currentCode, setCurrentCode] = useState<string>(draft?.tsxSource ?? "");
   const [stage, setStage] = useState<"edit" | "send">("edit");
   const [selectedThemeId, setSelectedThemeId] = useState<string | undefined | false>(draft?.themeId);
+  const [didInitFromDraft, setDidInitFromDraft] = useState(false);
+
+  useEffect(() => {
+    if (!didInitFromDraft && draft) {
+      setCurrentCode(draft.tsxSource ?? "");
+      setSelectedThemeId(draft.themeId);
+      setDidInitFromDraft(true);
+    }
+  }, [draft, didInitFromDraft]);
packages/stack-shared/src/interface/admin-interface.ts (2)

155-167: Standardize updateEmailDraft theme_id type

Keep theme_id consistent with list/create.

Apply this diff:

-  async updateEmailDraft(id: string, data: { display_name?: string, theme_id?: string | null | false, tsx_source?: string, sent_at_millis?: number | null }): Promise<void> {
+  async updateEmailDraft(id: string, data: { display_name?: string, theme_id?: string | null | false, tsx_source?: string, sent_at_millis?: number | null }): Promise<void> {

140-153: Allow null on create to explicitly select “project theme”

Creating a draft currently can’t explicitly set project theme. Align with update/list and other email APIs by allowing null.

Apply this diff:

-  async createEmailDraft(options: { display_name?: string, theme_id?: string | false, tsx_source?: string }): Promise<{ id: string }> {
+  async createEmailDraft(options: { display_name?: string, theme_id?: string | null | false, tsx_source?: string }): Promise<{ id: string }> {
🧹 Nitpick comments (33)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (2)

202-221: Make draft breadcrumbs resilient to nested routes (e.g., /edit, /send).

Current regex only matches the base detail path. If detail pages have subroutes, breadcrumbs won’t render. Broaden both the match and regex to include optional trailing segments.

Apply this diff:

-      const match = pathname.match(/^\/projects\/[^\/]+\/email-drafts\/([^\/]+)$/);
+      const match = pathname.match(/^\/projects\/[^\/]+\/email-drafts\/([^\/]+)(?:\/.*)?$/);
@@
-    regex: /^\/projects\/[^\/]+\/email-drafts\/[^\/]+$/,
+    regex: /^\/projects\/[^\/]+\/email-drafts\/[^\/]+(?:\/.*)?$/,

352-361: Provide a stable fallback for draft breadcrumb text while data loads.

When the draft isn’t found yet, the crumb renders empty. Fallback to the draftId to avoid a blank label.

Confirm that useEmailDrafts() always returns an array (not undefined). If it can be undefined, add a default (e.g., const drafts = stackAdminApp.useEmailDrafts() ?? []).

Apply this diff:

 function DraftBreadcrumbItem(props: { draftId: string }) {
   const stackAdminApp = useAdminApp();
   const drafts = stackAdminApp.useEmailDrafts();
   const draft = drafts.find((d) => d.id === props.draftId);
-  if (!draft) {
-    return null;
-  }
-  return draft.displayName;
+  return draft?.displayName ?? props.draftId;
 }
apps/dashboard/src/components/assistant-ui/thread.tsx (2)

81-81: Welcome suggestions hidden — confirm UX intent or remove dead code

You’ve disabled the welcome suggestions. If this is a permanent change, consider removing the unused component to avoid dead code and keep bundle size lean.


87-112: ThreadWelcomeSuggestions is now unused — delete to reduce bundle size

This component isn’t referenced anymore. Safe to remove.

-const ThreadWelcomeSuggestions: FC = () => {
-  return (
-    <div className="mt-3 flex w-full items-stretch justify-center gap-4">
-      <ThreadPrimitive.Suggestion
-        className="hover:bg-muted/80 flex max-w-sm grow basis-0 flex-col items-center justify-center rounded-lg border p-3 transition-colors ease-in"
-        prompt="Make a modern email theme with a blue color scheme"
-        method="replace"
-        autoSend
-      >
-        <span className="line-clamp-2 text-ellipsis text-sm font-semibold">
-          Make a blue theme
-        </span>
-      </ThreadPrimitive.Suggestion>
-      <ThreadPrimitive.Suggestion
-        className="hover:bg-muted/80 flex max-w-sm grow basis-0 flex-col items-center justify-center rounded-lg border p-3 transition-colors ease-in"
-        prompt="Make a modern email theme in dark mode"
-        method="replace"
-        autoSend
-      >
-        <span className="line-clamp-2 text-ellipsis text-sm font-semibold">
-          Make a dark theme
-        </span>
-      </ThreadPrimitive.Suggestion>
-    </div>
-  );
-};
apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx (1)

31-36: Schema consistency: mark auth.tenancy as defined() in POST like PATCH/GET

POST’s auth.tenancy currently omits .defined(), unlike PATCH/GET. For consistency and clearer validations, define it here too.

-    auth: yupObject({
-      type: yupString().oneOf(["admin"]).defined(),
-      tenancy: adaptSchema,
-    }),
+    auth: yupObject({
+      type: yupString().oneOf(["admin"]).defined(),
+      tenancy: adaptSchema.defined(),
+    }),
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx (1)

41-45: Simplify undefined→null conversion

Minor nit: coalesce to null more succinctly without changing behavior.

-      await stackAdminApp.updateEmailTemplate(props.templateId, currentCode, selectedThemeId === undefined ? null : selectedThemeId);
+      await stackAdminApp.updateEmailTemplate(props.templateId, currentCode, selectedThemeId ?? null);
apps/backend/prisma/schema.prisma (2)

672-687: Solid model addition; consider adding tenant-scoped indexes for list/sort and query patterns

EmailDraft looks consistent with your multi-tenant composite PK pattern. To keep list pages and queries snappy, add a couple of pragmatic indexes commonly used in dashboards (createdAt desc, sentAt filter) and optionally by displayName.

Apply this diff to add useful indexes:

 model EmailDraft {
   tenancyId String @db.Uuid

   id String @default(uuid()) @db.Uuid

   displayName String
   themeMode   DraftThemeMode @default(PROJECT_DEFAULT)
   themeId     String?
   tsxSource   String
   sentAt      DateTime?

   createdAt DateTime @default(now())
   updatedAt DateTime @updatedAt

+  @@index([tenancyId, createdAt(sort: Desc)], name: "EmailDraft_createdAt_desc")
+  @@index([tenancyId, sentAt], name: "EmailDraft_sentAt")
+  @@index([tenancyId, displayName], name: "EmailDraft_displayName")
   @@id([tenancyId, id])
 }

672-687: Optional: Enforce themeMode/themeId consistency at the DB level (CHECK constraint via SQL migration)

App logic maps PROJECT_DEFAULT -> themeId NULL/undefined, NONE -> themeId NULL/false, CUSTOM -> themeId non-null. To prevent bad states, add a CHECK constraint in the migration (Prisma doesn’t support CHECK directly in schema). See the migration file comment for the exact SQL.

I can generate a follow-up migration with the CHECK constraint if you’d like.

apps/dashboard/src/components/email-theme-selector.tsx (1)

21-37: Minor optional: avoid JSON for value encoding to reduce cognitive overhead

Current JSON sentinel encoding is safe (quoted IDs won’t collide with "null"/"false"). If you want a simpler mental model, consider explicit tags:

  • "none" => No theme
  • "project" => Project theme
  • "id:" => Custom theme

Apply this refactor if desired:

-function themeIdToSelectString(themeId: string | undefined | false): string {
-  return JSON.stringify(themeId ?? null);
-}
-function selectStringToThemeId(value: string): string | undefined | false {
-  return JSON.parse(value) ?? undefined;
-}
+function themeIdToSelectString(themeId: string | undefined | false): string {
+  if (themeId === undefined) return "project";
+  if (themeId === false) return "none";
+  return `id:${themeId}`;
+}
+function selectStringToThemeId(value: string): string | undefined | false {
+  if (value === "project") return undefined;
+  if (value === "none") return false;
+  if (value.startsWith("id:")) return value.slice(3);
+  return undefined;
+}
@@
-        <SelectItem value={"false"}>No theme</SelectItem>
-        <SelectItem value={"null"}>Project theme</SelectItem>
+        <SelectItem value="none">No theme</SelectItem>
+        <SelectItem value="project">Project theme</SelectItem>
@@
-          <SelectItem key={theme.id} value={JSON.stringify(theme.id)}>
+          <SelectItem key={theme.id} value={`id:${theme.id}`}>
             {theme.displayName}
           </SelectItem>
apps/backend/src/lib/ai-chat/email-draft-adapter.ts (1)

10-20: Nit: unused context parameter

context is not used. Either prefix with underscore to signal intent or leverage it (e.g., tenancy/thread scoping in a future execute handler).

Apply this minimal tidy:

-export const emailDraftAdapter = (context: ChatAdapterContext) => ({
+export const emailDraftAdapter = (_context: ChatAdapterContext) => ({
apps/backend/src/lib/email-drafts.tsx (1)

25-33: Optional guard for inconsistent CUSTOM-without-themeId cases

If the DB ever contains CUSTOM with themeId null, you currently coerce to undefined. Consider asserting/logging to detect data issues early.

Apply this defensive (non-breaking) tweak:

 export const themeModeToTemplateThemeId = (themeMode: DraftThemeMode, themeId: string | null): string | false | undefined => {
   if (themeMode === DraftThemeMode.PROJECT_DEFAULT) {
     return undefined;
   }
   if (themeMode === DraftThemeMode.NONE) {
     return false;
   }
-  return themeId === null ? undefined : themeId;
+  if (themeId === null) {
+    // CUSTOM without themeId should not happen; surface as default and log in dev
+    if (process.env.NODE_ENV !== "production") {
+      // eslint-disable-next-line no-console
+      console.warn("EmailDraft CUSTOM themeMode without themeId; defaulting to undefined");
+    }
+    return undefined;
+  }
+  return themeId;
 };
packages/stack-shared/src/interface/server-interface.ts (1)

799-809: Enforce recipient XOR at the client boundary (and consider typing it).

Allowing both userIds and allUsers (or neither) to flow to the server shifts an avoidable error to runtime. Add a quick XOR guard (and optionally encode via overloads) to fail fast.

Example minimal runtime guard:

   async sendEmail(options: {
     userIds?: string[],
     allUsers?: true,
     themeId?: string | null | false,
     html?: string,
     subject?: string,
     notificationCategoryName?: string,
     templateId?: string,
     variables?: Record<string, any>,
     draftId?: string,
   }): Promise<Result<void, KnownErrors["RequiresCustomEmailServer"] | KnownErrors["SchemaError"] | KnownErrors["UserIdDoesNotExist"]>> {
+    if (!!options.userIds === !!options.allUsers) {
+      return Result.error(new KnownErrors.SchemaError("Exactly one of userIds or allUsers must be provided"));
+    }

If you want compile-time guarantees, we can switch to overloads/XOR types to represent:

  • one of { html } | { templateId (+variables?) } | { draftId }
  • and exactly one of { userIds } | { allUsers: true }
apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx (2)

10-25: Avoid re-creating ToolUI on every render (wrap in useMemo).

makeAssistantToolUI returns a component; creating it inside the render path can cause unnecessary re-instantiation and lost internal state. Memoize it based on setCurrentCode.

Apply this diff within the selected lines:

-  const ToolUI = makeAssistantToolUI<
-    { content: string },
-    "success"
-  >({
-    toolName: "createEmailTemplate",
-    render: ({ args }) => {
-      return (
-        <Card className="flex items-center gap-2 p-4 justify-between">
-          <span className="text-sm">Created draft</span>
-          <Button variant="ghost" size="icon" onClick={() => setCurrentCode(args.content)}>
-            <Undo2 className="size-4" />
-          </Button>
-        </Card>
-      );
-    },
-  });
+  const ToolUI = useMemo(() => makeAssistantToolUI<
+    { content: string },
+    "success"
+  >({
+    toolName: "createEmailTemplate",
+    render: ({ args }) => (
+      <Card className="flex items-center gap-2 p-4 justify-between">
+        <span className="text-sm">Created draft</span>
+        <Button variant="ghost" size="icon" aria-label="Apply draft to editor" title="Apply draft to editor" onClick={() => setCurrentCode(args.content)}>
+          <Undo2 className="size-4" />
+        </Button>
+      </Card>
+    ),
+  }), [setCurrentCode]);

Additionally add the import (outside the selected range):

import { useMemo } from "react";

19-21: Add accessible label to the icon-only button.

Icon-only buttons should have an aria-label/title for screen readers. Also, “Undo” might be semantically confusing for “apply draft”; label clarifies intent.

Apply this diff:

-          <Button variant="ghost" size="icon" onClick={() => setCurrentCode(args.content)}>
+          <Button
+            variant="ghost"
+            size="icon"
+            aria-label="Apply draft to editor"
+            title="Apply draft to editor"
+            onClick={() => setCurrentCode(args.content)}
+          >
             <Undo2 className="size-4" />
           </Button>
apps/e2e/tests/js/email.test.ts (1)

180-191: Reduce brittleness of schema error assertion (avoid giant inline snapshot).

The union-validator’s error formatting can change; asserting the entire deindented block is brittle. Prefer targeted contains on stable substrings.

Apply this diff to replace the inline snapshot with focused assertions:

-    expect(result.error.message).toMatchInlineSnapshot(`
-      deindent\`
-        Request validation failed on POST /api/v1/emails/send-email:
-          - body is not matched by any of the provided schemas:
-            Schema 0:
-              body.html must be defined
-            Schema 1:
-              body.template_id must be defined
-            Schema 2:
-              body.draft_id must be defined
-      \`
-    `);
+    expect(result.error.message).toContain("Request validation failed on POST /api/v1/emails/send-email:");
+    expect(result.error.message).toContain("body is not matched by any of the provided schemas:");
+    expect(result.error.message).toContain("body.html must be defined");
+    expect(result.error.message).toContain("body.template_id must be defined");
+    expect(result.error.message).toContain("body.draft_id must be defined");

If you’d like, I can add a new test for the draftId happy-path as part of this suite.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx (1)

7-10: Fix Page props typing; params is not a Promise and async/await is unnecessary.

Next.js App Router provides params synchronously. Cleaning this up avoids confusion and matches framework conventions.

Apply this diff:

-export default async function Page(props: { params: Promise<{ draftId: string }> }) {
-  const params = await props.params;
-  return <PageClient draftId={params.draftId} />;
-}
+export default function Page({ params }: { params: { draftId: string } }) {
+  return <PageClient draftId={params.draftId} />;
+}
apps/e2e/tests/backend/endpoints/api/v1/render-email.test.ts (1)

69-105: Prefer targeted assertions over full inline snapshot for schema errors.

Inline snapshots of long, structured schema errors are fragile. Assert key fields and a couple of substrings to keep tests resilient to formatting/order changes.

Apply this diff within the selected lines:

-      "body": {
-        "code": "SCHEMA_ERROR",
-        "details": {
-          "message": deindent\`
-            Request validation failed on POST /api/v1/emails/render-email:
-              - body is not matched by any of the provided schemas:
-                Schema 0:
-                  body.template_id must be defined
-                  body contains unknown properties: theme_tsx_source, template_tsx_source
-                Schema 1:
-                  body.template_id must be defined
-                  body contains unknown properties: theme_id, template_tsx_source
-                Schema 2:
-                  body contains unknown properties: theme_tsx_source
-                Schema 3:
-                  body contains unknown properties: theme_id
-          \`,
-        },
-        "error": deindent\`
-          Request validation failed on POST /api/v1/emails/render-email:
-            - body is not matched by any of the provided schemas:
-              Schema 0:
-                body.template_id must be defined
-                body contains unknown properties: theme_tsx_source, template_tsx_source
-              Schema 1:
-                body.template_id must be defined
-                body contains unknown properties: theme_id, template_tsx_source
-              Schema 2:
-                body contains unknown properties: theme_tsx_source
-              Schema 3:
-                body contains unknown properties: theme_id
-        \`,
-      },
-      "headers": Headers {
-        "x-stack-known-error": "SCHEMA_ERROR",
-        <some fields may have been hidden>,
-      },
+      "body": expect.objectContaining({
+        code: "SCHEMA_ERROR",
+        details: expect.objectContaining({
+          message: expect.stringContaining("body is not matched by any of the provided schemas:")
+        }),
+      }),
+      "headers": expect.anything(),

If you prefer not to change snapshot style, consider at least snapshotting only response.body and headers key presence separately.

packages/stack-shared/src/utils/types.tsx (1)

69-74: Consider documenting and adding compile-time assertions for XOR.

A brief JSDoc and a couple of typeAssertIs examples will prevent regressions and clarify intended usage (especially around disjoint keys).

Example assertions you could add nearby:

// XOR over two shapes
type A = { html: string };
type B = { templateId: string; variables?: Record<string, any> };
typeAssertIs<XOR<[A, B]>, (A & { [K in keyof B]?: never }) | (B & { [K in keyof A]?: never })>()();

// XOR over three shapes
type C = { draftId: string };
typeAssertIs<XOR<[A, B, C]>, A | B | C>()();

And a short doc comment:

/**
 * Exclusive-or over a tuple of object types.
 * Ensures exactly one shape is present by disallowing the keys of the others (via `?: never`).
 * Note: Works best when shapes have disjoint keys.
 */
apps/dashboard/src/components/email-preview.tsx (1)

47-51: Nit: optional already implies undefined — simplify type.

themeId is an optional prop, so explicitly including undefined in the union is redundant. Recommend simplifying for readability.

Apply this diff:

-  themeId?: string | undefined | false,
+  themeId?: string | false,
apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts (2)

437-531: Solid coverage for all_users path; consider reducing snapshot brittleness.

The new tests cover mutual exclusivity and broadcast behavior well. However, long deindent() inline snapshots can be fragile if error aggregation text changes. Prefer partial structural assertions for stability.

For example, in the “both user_ids and all_users” test:

-    expect(response).toMatchInlineSnapshot(`
-      NiceResponse {
-        "status": 400,
-        "body": {
-          "code": "SCHEMA_ERROR",
-          "details": { "message": "Exactly one of user_ids or all_users must be provided" },
-          "error": "Exactly one of user_ids or all_users must be provided",
-        },
-        "headers": Headers {
-          "x-stack-known-error": "SCHEMA_ERROR",
-          <some fields may have been hidden>,
-        },
-      }
-    `);
+    expect(response.status).toBe(400);
+    expect(response.body).toMatchObject({
+      code: "SCHEMA_ERROR",
+      details: { message: "Exactly one of user_ids or all_users must be provided" },
+    });

533-633: Template/draft mode validation snapshots are precise; consider adding a draft-based happy-path test.

You already validate invalid cases and html/template modes thoroughly. Adding one e2e for draft_id content mode would close the loop on the new “third” input schema.

If helpful, I can draft a test that:

  • creates a draft via internal API (admin),
  • updates its tsx_source,
  • sends via /api/v1/emails/send-email with { all_users: true, draft_id, subject },
  • asserts results and mailbox contents.
packages/template/src/lib/stack-app/email/index.ts (2)

26-29: Tighten variables typing to avoid any leaks

Using unknown instead of any improves type safety without reducing flexibility for callers.

Apply this diff:

       {
         templateId: string,
-        variables?: Record<string, any>,
+        variables?: Record<string, unknown>,
       },

12-16: Standardize themeId union type across layers

There are multiple definitions of themeId in the codebase—some allow undefined, some null, and some omit either. This inconsistency can lead to subtle bugs when passing values between UI, app implementations, and public/server interfaces. Please align all occurrences to a single union (e.g. string | null | false) and map undefinednull at the UI boundary.

Key locations with divergent themeId types:

  • packages/stack-shared/src/interface/server-interface.ts:
    themeId?: string | null | false
  • packages/stack-shared/src/interface/admin-interface.ts:
    renderEmailPreview(… options: { themeId?: string | null | false, … })
  • packages/template/src/lib/stack-app/email/index.ts:
    type SendEmailOptionsBase { themeId?: string | null | false; … }
  • packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts:
    createEmailDraft(options: { … themeId?: string | undefined | false, … })
    updateEmailDraft(…, data: { … themeId?: string | undefined | false, … })
    AsyncStoreProperty<"emailDrafts", … { themeId: string | undefined | false, … }[]>
  • packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts:
    async createEmailDraft(options: { … themeId?: string | false, … })
    async updateEmailDraft(…, data: { … themeId?: string | undefined | false, … })
  • apps/dashboard/src/components/email-preview.tsx:
    themeId?: string | undefined | false
  • packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts:
    AsyncStoreProperty<"emailPreview", … { themeId?: string | null | false, … }>
  • packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts:
    getEmailPreview(… options: { themeId?: string | null | false, … })
    useEmailPreview({ themeId?: string | null | false, … })

Suggested approach:

  1. Choose string | null | false as the canonical type.
  2. Update all UI and admin-app interfaces/implementations to use themeId?: string | null | false.
  3. Where callers may pass undefined, convert to null before invoking the shared/public APIs.
packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts (1)

78-79: Align draft API surface to a single themeId representation

Both methods use string | undefined | false. Recommend switching to string | null | false to align with backend/admin interfaces and EmailPreview, and avoid undefined inside unions for optional props.

Apply this diff:

-    createEmailDraft(options: { displayName?: string, themeId?: string | undefined | false, tsxSource?: string }): Promise<{ id: string }>,
-    updateEmailDraft(id: string, data: { displayName?: string, themeId?: string | undefined | false, tsxSource?: string }): Promise<void>,
+    createEmailDraft(options: { displayName?: string, themeId?: string | null | false, tsxSource?: string }): Promise<{ id: string }>,
+    updateEmailDraft(id: string, data: { displayName?: string, themeId?: string | null | false, tsxSource?: string }): Promise<void>,
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (3)

18-21: Drop local EmailDraft type and cast; rely on inferred hook return type

Avoid shape duplication that can drift from the source. The hook already carries the precise type, so you can remove the alias and cast.

Apply this diff:

-  const { toast } = useToast();
-
-  type EmailDraft = { id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null };
-  const drafts = stackAdminApp.useEmailDrafts() as EmailDraft[];
+  // Prefer the hook's inferred type to avoid duplication
+  const drafts = stackAdminApp.useEmailDrafts();

18-19: Use a single toast API consistently

Both toast import and useToast() are present; you only need one. Since toast is imported and used elsewhere, drop the hook to reduce duplication.

Apply this diff:

-  const { toast } = useToast();

70-71: Guard “Next” button when nothing changed

Prevents a no-op PATCH and stage transition when code/theme are unchanged.

Apply this diff:

-                  <Button onClick={handleNext}>Next</Button>
+                  <Button
+                    disabled={!!draft && currentCode === (draft.tsxSource ?? "") && selectedThemeId === draft.themeId}
+                    onClick={handleNext}
+                  >
+                    Next
+                  </Button>
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (2)

304-315: Return type alignment for draft themeId

This exposes themeId: string | undefined | false. Consider returning string | null | false to align with server/public email types and reduce conversions at call sites.

Apply this diff:

-  useEmailDrafts(): { id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[] {
+  useEmailDrafts(): { id: string, displayName: string, themeId: string | null | false, tsxSource: string, sentAt: Date | null }[] {

Note: if you adopt this, also adjust draft.theme_id mapping to coerce undefined to null where appropriate.


335-344: List API: mirror useEmailDrafts themeId union

Same rationale as above; align to a single representation and let UI map to its local preference.

Apply this diff:

-  async listEmailDrafts(): Promise<{ id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[]> {
+  async listEmailDrafts(): Promise<{ id: string, displayName: string, themeId: string | null | false, tsxSource: string, sentAt: Date | null }[]> {
apps/backend/src/app/api/latest/emails/render-email/route.tsx (4)

47-49: Avoid building Maps for config lookups; use direct object access

Constructing Maps here is unnecessary and adds overhead. The config objects are already key-addressable. This also aligns the membership check with how getEmailThemeForTemplate resolves themes internally.

Apply this diff:

-    const templateList = new Map(Object.entries(tenancy.config.emails.templates));
-    const themeList = new Map(Object.entries(tenancy.config.emails.themes));
+    const templateList = tenancy.config.emails.templates;
+    const themeList = tenancy.config.emails.themes;

-      if (typeof body.theme_id === "string" && !themeList.has(body.theme_id)) {
+      if (typeof body.theme_id === "string" && !Object.prototype.hasOwnProperty.call(themeList, body.theme_id)) {
         throw new StatusError(400, "No theme found with given id");
       }
       themeSource = getEmailThemeForTemplate(tenancy, body.theme_id);
...
-      const template = templateList.get(body.template_id);
+      const template = templateList[body.template_id];

Also applies to: 53-56, 63-63


53-56: Use a consistent error type for invalid IDs (SchemaError vs raw 400)

For consistency with the other schema-related failure (Line 69) and downstream error handling, prefer throwing KnownErrors.SchemaError instead of a generic StatusError(400, ...) when the provided IDs don’t resolve.

Apply this diff:

-      if (typeof body.theme_id === "string" && !Object.prototype.hasOwnProperty.call(themeList, body.theme_id)) {
-        throw new StatusError(400, "No theme found with given id");
+      if (typeof body.theme_id === "string" && !Object.prototype.hasOwnProperty.call(themeList, body.theme_id)) {
+        throw new KnownErrors.SchemaError("No theme found with given id");
       }
...
-      if (!template) {
-        throw new StatusError(400, "No template found with given id");
+      if (!template) {
+        throw new KnownErrors.SchemaError("No template found with given id");
       }

Also applies to: 64-66


38-45: Optionally include plain-text in the response

renderEmailWithTemplate returns text, but the API omits it. Including it helps clients with plain-text fallbacks and parity with renderer output.

Apply this diff:

   response: yupObject({
     statusCode: yupNumber().oneOf([200]).defined(),
     bodyType: yupString().oneOf(["json"]).defined(),
     body: yupObject({
       html: yupString().defined(),
+      text: yupString(),
       subject: yupString(),
       notification_category: yupString(),
     }).defined(),
   }),
...
     return {
       statusCode: 200,
       bodyType: "json",
       body: {
         html: result.data.html,
+        text: result.data.text,
         subject: result.data.subject,
         notification_category: result.data.notificationCategory,
       },
     };

Also applies to: 86-91


9-12: Tighten metadata wording

The route renders an email (template + theme), not just a theme. Minor wording tweak improves clarity in generated docs.

Apply this diff:

-    summary: "Render email theme",
-    description: "Renders HTML content using the specified email theme",
+    summary: "Render email (template + theme)",
+    description: "Renders HTML using a template and theme (or direct TSX sources) for preview",
📜 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 2c237ef and 7461754.

📒 Files selected for processing (32)
  • apps/backend/prisma/migrations/20250815012830_email_drafts/migration.sql (1 hunks)
  • apps/backend/prisma/schema.prisma (2 hunks)
  • apps/backend/src/app/api/latest/emails/render-email/route.tsx (3 hunks)
  • apps/backend/src/app/api/latest/emails/send-email/route.tsx (4 hunks)
  • apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx (1 hunks)
  • apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx (1 hunks)
  • apps/backend/src/app/api/latest/internal/email-drafts/route.tsx (1 hunks)
  • apps/backend/src/lib/ai-chat/adapter-registry.ts (2 hunks)
  • apps/backend/src/lib/ai-chat/email-draft-adapter.ts (1 hunks)
  • apps/backend/src/lib/email-drafts.tsx (1 hunks)
  • apps/backend/src/lib/email-rendering.tsx (5 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx (1 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx (1 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx (1 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx (3 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (4 hunks)
  • apps/dashboard/src/components/assistant-ui/thread.tsx (1 hunks)
  • apps/dashboard/src/components/email-preview.tsx (2 hunks)
  • apps/dashboard/src/components/email-theme-selector.tsx (1 hunks)
  • apps/dashboard/src/components/vibe-coding/chat-adapters.ts (1 hunks)
  • apps/dashboard/src/components/vibe-coding/code-editor.tsx (1 hunks)
  • apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx (1 hunks)
  • apps/e2e/tests/backend/endpoints/api/v1/render-email.test.ts (1 hunks)
  • apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts (6 hunks)
  • apps/e2e/tests/js/email.test.ts (1 hunks)
  • packages/stack-shared/src/interface/admin-interface.ts (2 hunks)
  • packages/stack-shared/src/interface/server-interface.ts (2 hunks)
  • packages/stack-shared/src/utils/types.tsx (1 hunks)
  • packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (4 hunks)
  • packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts (3 hunks)
  • packages/template/src/lib/stack-app/email/index.ts (2 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
apps/backend/src/app/api/latest/**/*

📄 CodeRabbit Inference Engine (CLAUDE.md)

apps/backend/src/app/api/latest/**/*: Main API routes are located in /apps/backend/src/app/api/latest
The project uses a custom route handler system in the backend for consistent API responses

Files:

  • apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx
  • apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx
  • apps/backend/src/app/api/latest/internal/email-drafts/route.tsx
  • apps/backend/src/app/api/latest/emails/render-email/route.tsx
  • apps/backend/src/app/api/latest/emails/send-email/route.tsx
apps/backend/prisma/schema.prisma

📄 CodeRabbit Inference Engine (CLAUDE.md)

Database models use Prisma

Files:

  • apps/backend/prisma/schema.prisma
🧠 Learnings (1)
📚 Learning: 2025-08-04T22:25:51.260Z
Learnt from: CR
PR: stack-auth/stack-auth#0
File: CLAUDE.md:0-0
Timestamp: 2025-08-04T22:25:51.260Z
Learning: Applies to apps/backend/prisma/schema.prisma : Database models use Prisma

Applied to files:

  • apps/backend/prisma/schema.prisma
  • apps/backend/src/lib/email-rendering.tsx
🧬 Code Graph Analysis (19)
apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx (1)
packages/stack-ui/src/components/ui/button.tsx (1)
  • Button (92-92)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx (3)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx (2)
  • metadata (3-5)
  • Page (7-10)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx (1)
  • PageClient (13-80)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1)
  • PageClient (15-88)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx (6)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
  • useAdminApp (27-34)
apps/dashboard/src/components/router.tsx (1)
  • useRouter (15-33)
packages/stack-ui/src/components/ui/alert.tsx (3)
  • Alert (60-60)
  • AlertTitle (60-60)
  • AlertDescription (60-60)
packages/stack-ui/src/components/action-dialog.tsx (1)
  • ActionDialog (31-135)
apps/dashboard/src/components/form-dialog.tsx (1)
  • FormDialog (53-133)
apps/dashboard/src/components/form-fields.tsx (1)
  • InputField (59-97)
apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx (1)
packages/stack-shared/src/schema-fields.ts (1)
  • yupString (185-188)
apps/backend/src/lib/ai-chat/email-draft-adapter.ts (1)
apps/backend/src/lib/ai-chat/adapter-registry.ts (1)
  • ChatAdapterContext (7-10)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx (3)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx (2)
  • metadata (3-5)
  • Page (7-11)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx (1)
  • PageClient (21-98)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1)
  • PageClient (15-88)
apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx (4)
apps/backend/src/app/api/latest/internal/email-drafts/route.tsx (1)
  • GET (8-50)
packages/stack-shared/src/schema-fields.ts (4)
  • yupObject (245-249)
  • yupString (185-188)
  • yupNumber (189-192)
  • templateThemeIdSchema (477-477)
apps/backend/src/prisma-client.tsx (1)
  • getPrismaClientForTenancy (51-53)
apps/backend/src/lib/email-drafts.tsx (2)
  • themeModeToTemplateThemeId (25-33)
  • templateThemeIdToThemeMode (15-23)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (11)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx (1)
  • PageClient (21-98)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
  • useAdminApp (27-34)
apps/dashboard/src/components/router.tsx (1)
  • useRouterConfirm (35-37)
apps/dashboard/src/components/vibe-coding/chat-adapters.ts (3)
  • ToolCallContent (9-9)
  • createHistoryAdapter (63-76)
  • createChatAdapter (15-61)
apps/dashboard/src/components/vibe-coding/vibe-code-layout.tsx (1)
  • VibeCodeLayout (13-37)
apps/dashboard/src/components/email-preview.tsx (1)
  • EmailPreview (91-122)
apps/dashboard/src/components/vibe-coding/code-editor.tsx (1)
  • CodeEditor (15-144)
apps/dashboard/src/components/email-theme-selector.tsx (1)
  • EmailThemeSelector (17-39)
apps/dashboard/src/components/vibe-coding/assistant-chat.tsx (1)
  • AssistantChat (16-34)
apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx (1)
  • EmailDraftUI (9-28)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/[themeId]/page-client.tsx (1)
  • PageClient (18-78)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx (2)
apps/dashboard/src/components/email-preview.tsx (1)
  • EmailPreview (91-122)
apps/dashboard/src/components/email-theme-selector.tsx (1)
  • EmailThemeSelector (17-39)
apps/backend/src/lib/email-rendering.tsx (1)
packages/stack-shared/src/utils/results.tsx (1)
  • error (36-41)
apps/backend/src/app/api/latest/internal/email-drafts/route.tsx (6)
apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx (1)
  • GET (6-41)
apps/backend/src/route-handlers/smart-route-handler.tsx (1)
  • createSmartRouteHandler (209-294)
packages/stack-shared/src/schema-fields.ts (4)
  • yupObject (245-249)
  • yupString (185-188)
  • yupNumber (189-192)
  • templateThemeIdSchema (477-477)
apps/backend/src/prisma-client.tsx (1)
  • getPrismaClientForTenancy (51-53)
apps/backend/src/lib/email-drafts.tsx (2)
  • themeModeToTemplateThemeId (25-33)
  • templateThemeIdToThemeMode (15-23)
packages/stack-shared/src/utils/strings.tsx (1)
  • deindent (235-238)
apps/backend/src/lib/ai-chat/adapter-registry.ts (3)
apps/backend/src/lib/ai-chat/email-theme-adapter.ts (1)
  • emailThemeAdapter (7-18)
apps/backend/src/lib/ai-chat/email-template-adapter.ts (1)
  • emailTemplateAdapter (10-20)
apps/backend/src/lib/ai-chat/email-draft-adapter.ts (1)
  • emailDraftAdapter (10-20)
apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts (1)
apps/e2e/tests/backend/backend-helpers.ts (1)
  • niceBackendFetch (107-165)
packages/template/src/lib/stack-app/email/index.ts (1)
packages/stack-shared/src/utils/types.tsx (1)
  • XOR (69-73)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (1)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
  • useAdminApp (27-34)
packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts (1)
packages/template/src/lib/stack-app/common.ts (1)
  • AsyncStoreProperty (8-10)
apps/backend/src/app/api/latest/emails/render-email/route.tsx (3)
packages/stack-shared/src/schema-fields.ts (4)
  • yupUnion (255-279)
  • yupObject (245-249)
  • yupString (185-188)
  • templateThemeIdSchema (477-477)
apps/backend/src/lib/email-rendering.tsx (2)
  • getEmailThemeForTemplate (23-32)
  • renderEmailWithTemplate (45-129)
packages/stack-shared/src/known-errors.tsx (2)
  • KnownErrors (1490-1492)
  • KnownErrors (1494-1609)
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (1)
packages/template/src/lib/stack-app/apps/implementations/common.ts (2)
  • createCache (22-27)
  • useAsyncCache (145-190)
apps/backend/src/app/api/latest/emails/send-email/route.tsx (5)
packages/stack-shared/src/schema-fields.ts (9)
  • yupObject (245-249)
  • yupArray (211-214)
  • yupString (185-188)
  • yupBoolean (193-196)
  • serverOrHigherAuthTypeSchema (429-429)
  • adaptSchema (328-328)
  • yupUnion (255-279)
  • yupRecord (281-320)
  • yupMixed (208-210)
apps/backend/src/prisma-client.tsx (1)
  • getPrismaClientForTenancy (51-53)
apps/backend/src/lib/notification-categories.ts (2)
  • getNotificationCategoryByName (25-27)
  • hasNotificationEnabled (29-48)
apps/backend/src/lib/email-rendering.tsx (3)
  • getEmailThemeForTemplate (23-32)
  • createTemplateComponentFromHtml (34-43)
  • renderEmailWithTemplate (45-129)
apps/backend/src/lib/email-drafts.tsx (2)
  • getEmailDraft (3-13)
  • themeModeToTemplateThemeId (25-33)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (9)
  • GitHub Check: all-good
  • GitHub Check: docker
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: docker
  • GitHub Check: build (22.x)
  • GitHub Check: build (22.x)
  • GitHub Check: restart-dev-and-test
  • GitHub Check: setup-tests
  • GitHub Check: Security Check
🔇 Additional comments (35)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (3)

27-31: Icon imports align with usage; LGTM.

Switching to FilePen for Drafts and LayoutTemplate for Templates is appropriate and consistent with the new nav items.

If our lucide-react version is pinned, please confirm it includes FilePen and LayoutTemplate to avoid runtime icon import errors.


181-187: New “Drafts” nav item is correctly wired.

Route, regex, and icon are consistent with the rest of the sidebar. Selection logic will work with the existing trailing-slash handling.


192-192: Template icon update is correct.

LayoutTemplate better reflects Templates; no other changes needed here.

apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx (1)

40-40: Adding "email-draft" context_type — LGTM

Schema now accepts “email-draft”, aligning with the new adapter. Looks good.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx (2)

65-66: Pass-through of selectedThemeId to EmailPreview — LGTM

Prop contract matches the new EmailPreview typing. Looks good.


73-79: Switch to shared EmailThemeSelector — LGTM

Good reuse of shared component; keeps the page thinner and consistent across the app.

apps/backend/src/lib/email-rendering.tsx (3)

3-3: Unused import removal — LGTM

Removing an unused env import keeps the module clean.


46-47: Parameter rename to templateOrDraftComponent — LGTM

Name clarifies usage with drafts; no functional change.


123-128: Catch-and-sanitize Freestyle execution errors — LGTM

Good move to avoid leaking engine internals while still surfacing a clear failure.

apps/backend/prisma/schema.prisma (2)

689-694: Enum is clear and aligned with the UI semantics

The DraftThemeMode values match the frontend union (undefined/null => PROJECT_DEFAULT, false => NONE, string => CUSTOM). No issues.


751-769: Subscription block reflow looks non-functional; LGTM

Field order/formatting change only; types/constraints remain intact.

apps/backend/src/lib/ai-chat/email-draft-adapter.ts (2)

33-49: Escaping inside the example is correct; nice

The escaped backticks and ${…} prevent unintended interpolation inside the template string. This will render properly in the system prompt.


13-20: Verify createEmailTemplate Execute Handler Wiring
I didn’t find any execute handlers for the createEmailTemplate tool or any adapter wiring in the chat runtime. Without an execute implementation, calls to this tool will go unhandled.

Please confirm that:

  • The createEmailTemplate tool is registered with an execute function (either inline in its definition or via a handler lookup).
  • The email-draft-adapter (or equivalent) is imported and wired into your chat runtime’s tool registry.
  • The adapter’s key (e.g. "email-draft") matches what your runtime expects for dispatching tool calls.
apps/backend/src/lib/email-drafts.tsx (2)

3-13: Fetcher looks right for the composite ID; LGTM

findUnique on tenancyId_id matches the @@id([tenancyId, id]) in the schema. Returning null is fine; let callers decide 404 vs. silent.


15-23: ThemeId -> ThemeMode mapping is clear and matches UI

undefined => PROJECT_DEFAULT, false => NONE, string => CUSTOM. No changes needed.

packages/stack-shared/src/interface/server-interface.ts (1)

817-827: LGTM: payload aligns with backend contract.

user_ids, all_users, and draft_id are correctly forwarded. JSON.stringify will omit undefined, so optional fields won’t accidentally send null-like values.

apps/backend/src/app/api/latest/internal/email-drafts/route.tsx (3)

29-49: GET drafts: solid tenancy scoping and projection.

Good use of tenancy-scoped Prisma, fixed page size, and proper mapping to API shape (including theme conversion via themeModeToTemplateThemeId).


53-67: Default draft source is reasonable and safe.

Sensible starter template using React Email components. Keeping it internal/hidden mitigates exposure risk.


69-106: POST draft: correct theme mode/id mapping and defaults.

templateThemeIdToThemeMode + selective themeId persistence is consistent with the conversion helpers; falling back to defaultDraftSource is a good DX touch.

apps/backend/src/app/api/latest/emails/send-email/route.tsx (3)

39-51: Body union shape is clear and future-proof.

The union of html/template/draft modes atop a shared base reads cleanly and matches the described API. Nice use of yupUnion.


70-72: LGTM: recipient XOR validation.

Server-side check correctly enforces “exactly one of user_ids or all_users”.


74-94: Theme selection precedence is correct.

Initializing from request theme_id and only overriding with draft’s theme when theme_id is undefined matches the intended semantics.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx (1)

3-11: LGTM: page wrapper matches App Router conventions.

Static metadata and a thin server component delegating to PageClient is consistent with the rest of the app.

apps/dashboard/src/components/vibe-coding/chat-adapters.ts (1)

18-19: LGTM: context type widened to include "email-draft".

Type-only extension; runtime remains stable. Aligns frontend with new backend adapter.

apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx (1)

14-15: ToolName alignment verified for “createEmailTemplate”

All occurrences of createEmailTemplate are consistent between the UI and backend adapters:

  • apps/backend/src/lib/ai-chat/email-draft-adapter.ts → tools: { createEmailTemplate: tool(…) }
  • apps/backend/src/lib/ai-chat/email-template-adapter.ts → tools: { createEmailTemplate: tool(…) }
  • apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx → toolName: "createEmailTemplate"
  • apps/dashboard/src/components/vibe-coding/template-tool-components.tsx → toolName: "createEmailTemplate"

No action required.

packages/stack-shared/src/utils/types.tsx (1)

69-74: XOR type looks correct and is broadly useful.

The tuple-folding XOR works for 2+ mutually exclusive shapes and matches the email options use cases with disjoint keys. Nice addition.

apps/backend/src/lib/ai-chat/adapter-registry.ts (1)

5-5: Adapter registration looks correct.

  • New "email-draft" context added to the union and wired into CHAT_ADAPTERS.
  • getChatAdapter continues to be type-safe and straightforward.

No issues spotted here.

Also applies to: 17-17, 22-22

apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx (1)

26-40: LGTM on GET route contract and helper usage.

  • Validates admin tenancy and id, uses the per-tenancy prisma client, and translates theme fields via themeModeToTemplateThemeId.
  • Response schema aligns with other internal routes.

No action needed.

apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts (1)

89-98: Good: explicit Project.createAndSwitch() before shared-config test.

This ensures a valid project context and aligns with how niceBackendFetch expects headers. Looks good.

packages/template/src/lib/stack-app/email/index.ts (1)

19-31: Solid XOR typing for target and content shapes

Nice upgrade. The XOR composition cleanly enforces mutual exclusivity for recipient targeting and content sources, and the draftId variant integrates well with the existing shapes.

packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts (1)

66-66: Chat context extension looks good

Adding "email-draft" keeps the adapters in sync with new drafting tooling.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1)

95-103: sendEmail returns a Result and won’t throw on known errors—no change needed
The sendEmail implementation uses sendServerRequestAndCatchKnownError to catch known errors and returns a Result<void, KnownErrors> via Result.ok/Result.error. Checking result.status === "ok" is safe and won’t be skipped due to thrown errors.

Likely an incorrect or invalid review comment.

packages/stack-shared/src/interface/admin-interface.ts (1)

352-352: Chat context union update LGTM

“email-draft” addition is consistent with adapters and UI.

packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (2)

46-48: Good: dedicated drafts cache

Local cache mirrors existing patterns and is refreshed on mutations below.


496-503: Chat context passthrough LGTM

Consistent with updated interface and dashboard adapters.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

♻️ Duplicate comments (5)
apps/backend/src/lib/ai-chat/email-draft-adapter.ts (1)

33-49: Critical: Unescaped backticks and ${} in template literal will break at runtime

The example block is inside a JS template string. The raw ``` fences and ${user.displayName} will prematurely terminate/interpolate the outer template, causing syntax errors or ReferenceError at module load. Escape the backticks or remove the fences, and escape ${.

This also addresses the prior bot comment about escaping in the Subject line.

Apply one of the following fixes.

Option A — Remove code fences and escape the ${} in the example:

-Here is an example of a valid email draft:
-```tsx
+Here is an example of a valid email draft:
 import { Container } from "@react-email/components";
 import { Subject, NotificationCategory, Props } from "@stackframe/emails";

 export function EmailTemplate({ user, project }: Props) {
   return (
     <Container>
-      <Subject value={`Hello ${user.displayName}!`} />
+      <Subject value={\`Hello \\\${user.displayName}!\`} />
       <NotificationCategory value="Transactional" />
       <div className="font-bold">Hi {user.displayName}!</div>
       <br />
     </Container>
   );
 }
-```

Option B — Keep fences by escaping backticks and ${}:

-```tsx
+\`\`\`tsx
@@
-      <Subject value={`Hello ${user.displayName}!`} />
+      <Subject value={\`Hello \\\${user.displayName}!\`} />
@@
-```
+\`\`\`
apps/backend/prisma/migrations/20250815012830_email_drafts/migration.sql (1)

5-17: Add missing foreign key constraint to Tenancy (and enforce themeMode/themeId consistency).

There’s no FK from EmailDraft.tenancyId to Tenancy(id). This risks orphaned rows and inconsistent cleanup on tenancy deletion. Prior feedback already flagged this.

Prefer adding explicit constraints after the CREATE TABLE to keep the migration minimally invasive:

 CREATE TABLE "EmailDraft" (
   "tenancyId" UUID NOT NULL,
   "id" UUID NOT NULL,
   "displayName" TEXT NOT NULL,
   "themeMode" "DraftThemeMode" NOT NULL DEFAULT 'PROJECT_DEFAULT',
   "themeId" TEXT,
   "tsxSource" TEXT NOT NULL,
   "sentAt" TIMESTAMP(3),
   "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
   "updatedAt" TIMESTAMP(3) NOT NULL,
 
   CONSTRAINT "EmailDraft_pkey" PRIMARY KEY ("tenancyId","id")
 );
+
+-- Enforce tenancy referential integrity
+ALTER TABLE "EmailDraft"
+  ADD CONSTRAINT "EmailDraft_tenancyId_fkey"
+  FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id")
+  ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- Enforce that themeId presence matches themeMode semantics
+ALTER TABLE "EmailDraft"
+  ADD CONSTRAINT "EmailDraft_theme_consistency_ck" CHECK (
+    ("themeMode" = 'CUSTOM' AND "themeId" IS NOT NULL)
+    OR ("themeMode" IN ('PROJECT_DEFAULT', 'NONE') AND "themeId" IS NULL)
+  );

Optional follow-up (if your schema supports it): add an FK to EmailTheme with the appropriate composite key, e.g., (tenancyId, themeId) → EmailTheme(tenancyId, id). If themes are cross-tenant or denormalized, skip this.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx (1)

35-35: Avoid any for draft items; add a minimal typed shape.

This has been flagged before. Replace the any and type the drafts list.

-      {drafts.map((draft: any) => (
+      {drafts.map((draft) => (

Add near the drafts declaration to preserve type safety:

type EmailDraftListItem = { id: string; displayName: string };
const drafts = stackAdminApp.useEmailDrafts() as EmailDraftListItem[];
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1)

24-27: Initialize local state when draft finishes loading to prevent accidental overwrite

If draft loads after mount, currentCode stays "", selectedThemeId stays undefined. Pressing “Next” would overwrite the server draft with empty content. Hydrate state once when the draft becomes available.

Apply this diff to hydrate safely:

   const [currentCode, setCurrentCode] = useState<string>(draft?.tsxSource ?? "");
   const [stage, setStage] = useState<"edit" | "send">("edit");
   const [selectedThemeId, setSelectedThemeId] = useState<string | undefined | false>(draft?.themeId);
 
+  // Hydrate state when draft loads (avoid clobbering user edits)
+  useEffect(() => {
+    if (!draft || stage !== "edit") return;
+    // Only hydrate when the editor is blank and theme unchosen,
+    // which indicates initial load rather than user edits.
+    if (currentCode === "" && selectedThemeId === undefined) {
+      setCurrentCode(draft.tsxSource);
+      setSelectedThemeId(draft.themeId);
+    }
+  }, [draft, stage]); // intentionally exclude currentCode/selectedThemeId
apps/backend/src/app/api/latest/emails/send-email/route.tsx (1)

96-106: Fix: all_users=true breaks Prisma query (in: undefined). Add conditional filter.

When all_users is true, body.user_ids is undefined and Prisma will choke on { in: undefined }. Guard the filter.

Apply this diff:

-    const users = await prisma.projectUser.findMany({
-      where: {
-        tenancyId: auth.tenancy.id,
-        projectUserId: {
-          in: body.user_ids
-        },
-      },
-      include: {
-        contactChannels: true,
-      },
-    });
+    const users = await prisma.projectUser.findMany({
+      where: {
+        tenancyId: auth.tenancy.id,
+        ...(body.user_ids && {
+          projectUserId: {
+            in: body.user_ids,
+          },
+        }),
+      },
+      include: {
+        contactChannels: true,
+      },
+    });
🧹 Nitpick comments (42)
apps/dashboard/src/components/assistant-ui/thread.tsx (1)

81-81: Avoid commented-out JSX; conditionally render to keep symbol referenced and satisfy linters

Commenting out the JSX leaves ThreadWelcomeSuggestions unused and likely triggers no-unused-vars. Prefer a conditional render so the symbol remains referenced and can be toggled easily.

Apply this diff:

-        {/* <ThreadWelcomeSuggestions /> */}
+        {false && <ThreadWelcomeSuggestions />}
apps/dashboard/src/components/email-preview.tsx (3)

47-51: Simplify optional prop type: remove redundant undefined from union

Since themeId is already optional, including undefined in the union is redundant. This simplifies the type without changing runtime behavior.

-  themeId?: string | undefined | false,
+  themeId?: string | false,

70-79: Align prop variant with optional semantics to avoid “present but undefined” ambiguity

In the “themeId” variant, you’ve made themeId required but allowed undefined in the union. That allows cases where the key is present with value undefined, which can accidentally exclude the alternative variant that expects themeId to be absent. If that distinction isn’t intentional, prefer making it optional here too.

-  | ({
-    themeId: string | undefined | false,
-    themeTsxSource?: undefined,
-  } | {
+  | ({
+    themeId?: string | false,
+    themeTsxSource?: undefined,
+  } | {
     themeId?: undefined,
     themeTsxSource: string,
   })

If you do need to distinguish presence vs. absence, consider documenting that in a comment to avoid confusion for future contributors.


70-89: Consider XOR to enforce exactly-one-of for theme/template sources

You already added a generic XOR utility in shared types. Using it here would clearly enforce the “exactly one” constraint for theme and template sources, reducing edge cases around presence vs. undefined.

For example:

import type { XOR } from "@stackframe/stack-shared/dist/utils/types";

type ThemeSource = XOR<[
  { themeId: string | false },
  { themeTsxSource: string }
]>;

type TemplateSource = XOR<[
  { templateId: string },
  { templateTsxSource: string }
]>;

type EmailPreviewProps = ThemeSource & TemplateSource & {
  disableResizing?: boolean;
};
apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx (2)

19-21: Add accessible label/tooltip to icon-only button

Icon-only buttons should have an accessible name. Add aria-label (and optionally title) to improve a11y.

-          <Button variant="ghost" size="icon" onClick={() => setCurrentCode(args.content)}>
+          <Button
+            variant="ghost"
+            size="icon"
+            aria-label="Insert generated draft into editor"
+            title="Insert generated draft into editor"
+            onClick={() => setCurrentCode(args.content)}
+          >
             <Undo2 className="size-4" />
           </Button>

17-22: Icon choice may be confusing for “apply/insert” action

“Undo” glyph suggests reverting a prior change; here it applies the generated draft. Consider a more semantically aligned icon (e.g., Check, Replace, ClipboardCheck) to reduce ambiguity. Not blocking.

packages/stack-shared/src/utils/types.tsx (1)

69-74: XOR utility looks good; consider documenting constraints and adding compile-time examples

The recursive XOR is solid. Two small improvements:

  • Document that it’s intended for object-like members (key-based exclusion) to avoid surprising results with primitives.
  • Add type-level assertions (like others in this file) to lock behavior.
-export type XOR<T extends readonly any[]> = T extends readonly [infer A, infer B, ...infer Rest]
+/**
+ * Exclusive-or across a tuple of object-like types.
+ * For two elements A and B, yields:
+ *   (A with keys of B forbidden) | (B with keys of A forbidden)
+ * Recursively folds for >2 elements.
+ * Note: Intended for object-like members; behavior with primitives may be unintuitive.
+ */
+export type XOR<T extends readonly any[]> = T extends readonly [infer A, infer B, ...infer Rest]
   ? Rest extends []
   ? (A & { [K in keyof B]?: never }) | (B & { [K in keyof A]?: never })
   : XOR<[(A & { [K in keyof B]?: never }) | (B & { [K in keyof A]?: never }), ...Rest]>
   : T[0];

Additionally (outside this hunk), consider adding examples alongside existing assertions:

// Place near other typeAssert* calls:
typeAssertIs<XOR<[ { a: number }, { b: string } ]>, ({ a: number; b?: never } | { b: string; a?: never })>()();
typeAssertIs<XOR<[ { a: number }, { b: string }, { c: boolean } ]>, (
  | { a: number; b?: never; c?: never }
  | { b: string; a?: never; c?: never }
  | { c: boolean; a?: never; b?: never }
)>()();
apps/backend/prisma/schema.prisma (2)

678-681: Enforce invariants between themeMode and themeId

To prevent invalid states:

  • When themeMode = CUSTOM, themeId should be non-null.
  • When themeMode ∈ {PROJECT_DEFAULT, NONE}, themeId should be null.

Prisma can’t express conditional constraints directly; consider:

  • Adding an app-level validation when creating/updating drafts, and/or
  • A DB CHECK constraint in the SQL migration to enforce this invariant.

683-687: Add indexes to support common query patterns (list/sort/filter)

Drafts are often listed by recency or filtered by send status. Add tenancy-scoped indexes to keep these queries fast as data grows.

Proposed Prisma additions:

 model EmailDraft {
   tenancyId String @db.Uuid

   id String @default(uuid()) @db.Uuid

   displayName String
   themeMode   DraftThemeMode @default(PROJECT_DEFAULT)
   themeId     String?
   tsxSource   String
   sentAt      DateTime?

   createdAt DateTime @default(now())
   updatedAt DateTime @updatedAt

   @@id([tenancyId, id])
+  @@index([tenancyId, createdAt(sort: Desc)])
+  @@index([tenancyId, updatedAt(sort: Desc)])
+  @@index([tenancyId, sentAt(sort: Desc)])
 }
apps/backend/src/lib/ai-chat/email-draft-adapter.ts (2)

13-19: Tool naming: consider createEmailDraft (alias createEmailTemplate for BC)

For clarity and discoverability in the “email-draft” context, prefer a tool named createEmailDraft (keep createEmailTemplate as an alias to avoid breaking clients).

Proposed change:

   tools: {
-    createEmailTemplate: tool({
+    createEmailDraft: tool({
       description: CREATE_EMAIL_DRAFT_TOOL_DESCRIPTION(),
       parameters: z.object({
         content: z.string().describe("A react component that renders the email template"),
       }),
     }),
+    // Optional alias for backwards-compatibility:
+    createEmailTemplate: tool({
+      description: CREATE_EMAIL_DRAFT_TOOL_DESCRIPTION(),
+      parameters: z.object({
+        content: z.string().describe("A react component that renders the email template"),
+      }),
+    }),
   },

16-17: Nit: wording “template” → “draft” in parameter description

This tool is for drafts; align wording to reduce confusion.

-        content: z.string().describe("A react component that renders the email template"),
+        content: z.string().describe("A React component that renders the email draft"),
apps/backend/src/lib/email-rendering.tsx (1)

123-128: Preserve underlying error details from Freestyle for easier debugging

Catching and mapping to a generic message hides useful context. Return the original message when available; also align the cast with optional subject/notificationCategory.

-  try {
-    const output = await freestyle.executeScript(result.data, { nodeModules });
-    return Result.ok(output.result as { html: string, text: string, subject: string, notificationCategory: string });
-  } catch (error) {
-    return Result.error("Unable to render email");
-  }
+  try {
+    const output = await freestyle.executeScript(result.data, { nodeModules });
+    return Result.ok(output.result as { html: string; text: string; subject?: string; notificationCategory?: string });
+  } catch (error) {
+    const message = (error && (error as any).message) || (typeof error === "string" ? error : null);
+    return Result.error(message ?? "Unable to render email");
+  }
apps/e2e/tests/backend/endpoints/api/v1/render-email.test.ts (1)

69-105: Schema-error expectation looks correct; consider asserting minimally to reduce snapshot brittleness

The new SCHEMA_ERROR shape and x-stack-known-error header are good and align with the union validation. Inline snapshots of long, deindented messages tend to be brittle across minor wording/ordering changes. Prefer asserting the status, header, and code, plus a couple of stable substrings.

Proposed change:

-  expect(response).toMatchInlineSnapshot(`
-    NiceResponse {
-      "status": 400,
-      "body": {
-        "code": "SCHEMA_ERROR",
-        "details": {
-          "message": deindent\`
-            Request validation failed on POST /api/v1/emails/render-email:
-              - body is not matched by any of the provided schemas:
-                Schema 0:
-                  body.template_id must be defined
-                  body contains unknown properties: theme_tsx_source, template_tsx_source
-                Schema 1:
-                  body.template_id must be defined
-                  body contains unknown properties: theme_id, template_tsx_source
-                Schema 2:
-                  body contains unknown properties: theme_tsx_source
-                Schema 3:
-                  body contains unknown properties: theme_id
-          \`,
-        },
-        "error": deindent\`
-          Request validation failed on POST /api/v1/emails/render-email:
-            - body is not matched by any of the provided schemas:
-              Schema 0:
-                body.template_id must be defined
-                body contains unknown properties: theme_tsx_source, template_tsx_source
-              Schema 1:
-                body.template_id must be defined
-                body contains unknown properties: theme_id, template_tsx_source
-              Schema 2:
-                body contains unknown properties: theme_tsx_source
-              Schema 3:
-                body contains unknown properties: theme_id
-        \`,
-      },
-      "headers": Headers {
-        "x-stack-known-error": "SCHEMA_ERROR",
-        <some fields may have been hidden>,
-      },
-    }
-  `);
+  expect(response.status).toBe(400);
+  expect(response.headers.get("x-stack-known-error")).toBe("SCHEMA_ERROR");
+  expect(response.body).toMatchObject({ code: "SCHEMA_ERROR" });
+  // Keep just a stable substring to avoid brittleness:
+  expect(
+    typeof response.body?.details?.message === "string" ? response.body.details.message : ""
+  ).toContain("body is not matched by any of the provided schemas");
apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx (2)

32-36: Make tenancy required for POST to match PATCH/GET and avoid accidental undefined tenancy

PATCH and GET use adaptSchema.defined() for auth.tenancy, while POST currently allows it to be undefined. For consistency and to prevent accidental missing tenancy in the OpenAI flow, mark it as defined here too.

-    auth: yupObject({
-      type: yupString().oneOf(["admin"]).defined(),
-      tenancy: adaptSchema,
-    }),
+    auth: yupObject({
+      type: yupString().oneOf(["admin"]).defined(),
+      tenancy: adaptSchema.defined(),
+    }),

63-81: Optionally include actual tool call results or omit the field to avoid misleading “success”

You currently set result: "success" on every returned tool-call block. If consumers rely on this field, a literal "success" can be misleading. Consider either:

  • Propagating the actual tool result (if available in result.steps), or
  • Omitting result if there is no useful payload to return.

If tool results are available in your generateText step data, map them through; otherwise, consider:

-        contentBlocks.push({
-          type: "tool-call",
-          toolName: toolCall.toolName,
-          toolCallId: toolCall.toolCallId,
-          args: toolCall.args,
-          argsText: JSON.stringify(toolCall.args),
-          result: "success",
-        });
+        contentBlocks.push({
+          type: "tool-call",
+          toolName: toolCall.toolName,
+          toolCallId: toolCall.toolCallId,
+          args: toolCall.args,
+          argsText: JSON.stringify(toolCall.args),
+          // Optional: only include result if meaningful
+          // result: toolCall.result ?? "success",
+        });
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx (1)

1-5: Type metadata for editor support and consistency with Next.js conventions

Annotating metadata with Metadata improves type safety and IDE hints.

+import type { Metadata } from "next";
 
-export const metadata = {
+export const metadata: Metadata = {
   title: "Email Drafts",
 };
apps/e2e/tests/js/email.test.ts (1)

180-191: Assertion is correct; prefer resilient check over full inline snapshot

The unionized validation text can change ordering/wording. A substring check keeps intent while reducing churn.

-    expect(result.error.message).toMatchInlineSnapshot(`
-      deindent\`
-        Request validation failed on POST /api/v1/emails/send-email:
-          - body is not matched by any of the provided schemas:
-            Schema 0:
-              body.html must be defined
-            Schema 1:
-              body.template_id must be defined
-            Schema 2:
-              body.draft_id must be defined
-      \`
-    `);
+    expect(result.error.message).toContain("body is not matched by any of the provided schemas");
apps/dashboard/src/components/vibe-coding/chat-adapters.ts (1)

43-49: Trigger all tool-call callbacks, not just the first

If the model/tooling returns multiple tool calls in one response, only the first currently triggers onToolCall. Iterate and notify for all.

-        if (response.content.some(isToolCall)) {
-          const toolCall = response.content.find(isToolCall);
-          if (toolCall) {
-            onToolCall(toolCall);
-          }
-        }
+        for (const toolCall of response.content.filter(isToolCall)) {
+          onToolCall(toolCall);
+        }
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx (2)

3-5: Optional: Strongly type metadata to catch future shape regressions.

This helps TS flag invalid metadata fields early.

Apply this diff:

+import type { Metadata } from "next";
 
 export const metadata = {
   title: "Email Draft",
-};
+} satisfies Metadata;

7-10: Drop Promise typing and async/await for route params in email-drafts page

The App Router always passes params synchronously. You can simplify the signature and remove the unnecessary Promise and await:

 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx
-export default async function Page(props: { params: Promise<{ draftId: string }> }) {
-  const params = await props.params;
-  return <PageClient draftId={params.draftId} />;
-}
+export default function Page({ params }: { params: { draftId: string } }) {
+  return <PageClient draftId={params.draftId} />;
+}

To identify and clean up other occurrences across the repo, you can run:

rg -nP -g '*.ts' -g '*.tsx' '\bparams\s*:\s*Promise<\s*{' -C2

And apply similar diffs to pages, layouts, and route handlers under apps/… and packages/… that type params (or searchParams) as a Promise.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (1)

352-361: Provide a non-null fallback for missing draft names.

Returning null can render a blank crumb. A neutral fallback prevents odd UI in edge cases (e.g., drafts not yet loaded).

Apply this diff:

 function DraftBreadcrumbItem(props: { draftId: string }) {
   const stackAdminApp = useAdminApp();
   const drafts = stackAdminApp.useEmailDrafts();
   const draft = drafts.find((d) => d.id === props.draftId);
-  if (!draft) {
-    return null;
-  }
-  return draft.displayName;
+  if (!draft) {
+    return "Draft";
+  }
+  return draft.displayName;
 }
apps/dashboard/src/components/email-theme-selector.tsx (1)

10-15: Clarify the null/undefined/false mapping with a brief comment.

The JSON encode/decode roundtrip is deliberate to normalize:

  • undefined → project default (stored as "null" in the Select value)
  • false → no theme
  • string → custom theme id

A short comment will prevent regressions and unnecessary future refactors.

Apply this diff:

-function themeIdToSelectString(themeId: string | undefined | false): string {
-  return JSON.stringify(themeId ?? null);
-}
-function selectStringToThemeId(value: string): string | undefined | false {
-  return JSON.parse(value) ?? undefined;
-}
+// Mapping conventions:
+// - undefined: "Project theme" (default) → encoded as "null" for the Select value
+// - false: "No theme" → encoded as "false"
+// - string: Custom theme id → encoded as the JSON string of the id
+function themeIdToSelectString(themeId: string | undefined | false): string {
+  return JSON.stringify(themeId ?? null);
+}
+function selectStringToThemeId(value: string): string | undefined | false {
+  // Decode from Select value; convert null back to undefined to match UI state
+  return JSON.parse(value) ?? undefined;
+}
packages/stack-shared/src/interface/server-interface.ts (1)

799-809: Consider strengthening the sendEmail options type with XOR to prevent invalid combinations at compile time.

Right now both recipients and content fields are optional. Using the shared XOR utility would align the client interface with the template package type and reduce drift.

For example:

import { XOR } from "../utils/types";

type SendEmailRecipient = XOR<[
  { userIds: string[] },
  { allUsers: true }
]>;

type SendEmailContent = XOR<[
  { html: string },
  { templateId: string, variables?: Record<string, any> },
  { draftId: string }
]>;

type SendEmailOptions = SendEmailRecipient & SendEmailContent & {
  themeId?: string | null | false,
  subject?: string,
  notificationCategoryName?: string,
};

async sendEmail(options: SendEmailOptions) { /* ... */ }
apps/backend/src/lib/email-drafts.tsx (2)

25-33: Return-to-template mapping handles null safety; minor edge-case clarification.

For CUSTOM with a null themeId, returning undefined gracefully avoids leaking inconsistent DB state. Optional: assert consistency upstream to prevent CUSTOM + null pairs entering storage.


3-13: Nit: add an explicit return type for getEmailDraft for clarity.

Helps with call-site expectations (nullable result) and tooling.

-export async function getEmailDraft(prisma: PrismaClient, tenancyId: string, draftId: string) {
+export async function getEmailDraft(
+  prisma: PrismaClient,
+  tenancyId: string,
+  draftId: string
+): Promise<Awaited<ReturnType<PrismaClient['emailDraft']['findUnique']>>> {
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx (1)

21-79: Optional: defensive close before navigation in dialog.

Not required, but closing the dialog before push avoids stale state if navigation is intercepted.

apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx (1)

28-28: Minor: prefer findUniqueOrThrow with the composite key for precision.

Current findFirstOrThrow works but is semantically broader than necessary.

-    const d = await prisma.emailDraft.findFirstOrThrow({ where: { tenancyId: tenancy.id, id: params.id } });
+    const d = await prisma.emailDraft.findUniqueOrThrow({
+      where: { tenancyId_id: { tenancyId: tenancy.id, id: params.id } },
+    });
apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts (2)

586-631: Reduce duplication in schema error messaging (cosmetic)

The validation details list “body contains unknown properties: html” twice for Schema 1 and Schema 2. It’s harmless but noisy. Consider deduplicating per-schema unknown properties to keep error output concise. No change required in tests; this is a route-side improvement.


52-87: Rename test to reflect behavior (misleading title)

The test name suggests a “user not found” error, but the scenario creates a valid user and expects a 200 with results. Please rename to avoid confusion (e.g., “should return 200 and include result for valid user”).

apps/backend/src/app/api/latest/internal/email-drafts/route.tsx (1)

31-35: Result capping and sort look good

Limiting to 50 most recently updated drafts per tenancy is reasonable for admin UI listing. Consider making 50 a shared constant if used elsewhere, but not required.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1)

39-50: Reset navigation guard after successful save

After updating the draft, clear the confirm flag so moving to the Send stage doesn’t keep prompting.

Apply this diff:

   const handleNext = async () => {
     try {
       await stackAdminApp.updateEmailDraft(draftId, { tsxSource: currentCode, themeId: selectedThemeId });
+      setNeedConfirm(false);
       setStage("send");
     } catch (error) {
packages/stack-shared/src/interface/admin-interface.ts (2)

134-139: Remove redundant | undefined in optional fields

In TypeScript, ? already implies undefined. Simplify the types and keep API shape consistent.

Apply this diff:

-  async listInternalEmailDrafts(): Promise<{ id: string, display_name: string, theme_id?: string | undefined | false, tsx_source: string, sent_at_millis?: number | null }[]> {
+  async listInternalEmailDrafts(): Promise<{ id: string, display_name: string, theme_id?: string | false, tsx_source: string, sent_at_millis?: number | null }[]> {
     const response = await this.sendAdminRequest(`/internal/email-drafts`, {}, null);
-    const result = await response.json() as { drafts: { id: string, display_name: string, theme_id?: string | undefined | false, tsx_source: string, sent_at_millis?: number | null }[] };
+    const result = await response.json() as { drafts: { id: string, display_name: string, theme_id?: string | false, tsx_source: string, sent_at_millis?: number | null }[] };
     return result.drafts;
   }

155-167: Unify theme_id nullability across create/update

Update allows null (often meaning “project default”), while create did not. With the change above, create stays string | false, which is fine; for update, keep null to support resetting to project default. Ensure backend normalizes null correctly (see route suggestion).

If you decide to avoid null entirely, change this signature to string | false and have clients omit the field for project-default. Otherwise, keep as-is and ensure the route coerces null to undefined before conversion (suggested in backend comment).

packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts (2)

37-38: Use null instead of undefined for themeId across app types (consistency)

Other parts of the codebase model themeId as string | null | false. Aligning here avoids casting and mismatches in consumers.

Apply this diff:

-  & AsyncStoreProperty<"emailDrafts", [], { id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[], true>
+  & AsyncStoreProperty<"emailDrafts", [], { id: string, displayName: string, themeId: string | null | false, tsxSource: string, sentAt: Date | null }[], true>

78-80: Align draft APIs’ themeId type with store (string | null | false)

This keeps API, store, and backend expectations consistent and avoids downstream casting.

Apply this diff:

-    createEmailDraft(options: { displayName?: string, themeId?: string | undefined | false, tsxSource?: string }): Promise<{ id: string }>,
-    updateEmailDraft(id: string, data: { displayName?: string, themeId?: string | undefined | false, tsxSource?: string }): Promise<void>,
+    createEmailDraft(options: { displayName?: string, themeId?: string | null | false, tsxSource?: string }): Promise<{ id: string }>,
+    updateEmailDraft(id: string, data: { displayName?: string, themeId?: string | null | false, tsxSource?: string }): Promise<void>,
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (4)

304-315: useEmailDrafts: solid mapping; consider extracting a mapper to avoid duplication

The field transforms mirror templates and are consistent with interfaces. To reduce duplication with listEmailDrafts, consider extracting a private mapper method.

Apply this diff to use a mapper, and add the mapper near the other “fromCrud” helpers:

-  useEmailDrafts(): { id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[] {
+  useEmailDrafts(): { id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[] {
     const crud = useAsyncCache(this._adminEmailDraftsCache, [], "useEmailDrafts()");
     return useMemo(() => {
-      return crud.map((draft) => ({
-        id: draft.id,
-        displayName: draft.display_name,
-        themeId: draft.theme_id,
-        tsxSource: draft.tsx_source,
-        sentAt: draft.sent_at_millis ? new Date(draft.sent_at_millis) : null,
-      }));
+      return crud.map((draft) => this._adminEmailDraftFromCrud(draft));
     }, [crud]);
   }

Additional code (outside the selected lines) to add once in this class:

private _adminEmailDraftFromCrud(draft: { id: string, display_name: string, theme_id: string | undefined | false, tsx_source: string, sent_at_millis: number | null }) {
  return {
    id: draft.id,
    displayName: draft.display_name,
    themeId: draft.theme_id,
    tsxSource: draft.tsx_source,
    sentAt: draft.sent_at_millis ? new Date(draft.sent_at_millis) : null,
  };
}

335-344: listEmailDrafts: mirrors hook mapping; consider reusing the same mapper

Same suggestion as the hook: reuse a single mapper to keep mappings consistent and reduce maintenance overhead.

Apply this diff:

-  async listEmailDrafts(): Promise<{ id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[]> {
+  async listEmailDrafts(): Promise<{ id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[]> {
     const crud = Result.orThrow(await this._adminEmailDraftsCache.getOrWait([], "write-only"));
-    return crud.map((draft) => ({
-      id: draft.id,
-      displayName: draft.display_name,
-      themeId: draft.theme_id,
-      tsxSource: draft.tsx_source,
-      sentAt: draft.sent_at_millis ? new Date(draft.sent_at_millis) : null,
-    }));
+    return crud.map((draft) => this._adminEmailDraftFromCrud(draft));
   }

477-485: Create draft: align themeId type with backend (allow null)

Server-side templateThemeIdSchema allows null in addition to undefined/false/uuid. Consider permitting null here to avoid unnecessary client-side coercion.

Apply this diff:

-  async createEmailDraft(options: { displayName?: string, themeId?: string | false, tsxSource?: string }): Promise<{ id: string }> {
+  async createEmailDraft(options: { displayName?: string, themeId?: string | false | null, tsxSource?: string }): Promise<{ id: string }> {
     const result = await this._interface.createEmailDraft({
       display_name: options.displayName,
       theme_id: options.themeId,
       tsx_source: options.tsxSource,
     });

487-495: Update draft: mirror themeId union and support null

Same rationale as create. Keeps client/server types in sync.

Apply this diff:

-  async updateEmailDraft(id: string, data: { displayName?: string, themeId?: string | undefined | false, tsxSource?: string }): Promise<void> {
+  async updateEmailDraft(id: string, data: { displayName?: string, themeId?: string | false | null | undefined, tsxSource?: string }): Promise<void> {
     await this._interface.updateEmailDraft(id, {
       display_name: data.displayName,
       theme_id: data.themeId,
       tsx_source: data.tsxSource,
     });
apps/backend/src/app/api/latest/emails/send-email/route.tsx (3)

39-50: Validate draft_id as UUID for parity with template_id

If draft IDs are UUIDs, tighten validation for early, consistent failures.

Apply this diff:

-      bodyBase.concat(yupObject({
-        draft_id: yupString().defined(),
-      })),
+      bodyBase.concat(yupObject({
+        draft_id: yupString().uuid().defined(),
+      })),

124-145: Avoid double-render when notification_category_name is explicitly provided

You only need the first pass to derive the category from the template if the caller didn’t specify one.

Apply this diff:

-      if (!("html" in body)) {
+      if (!("html" in body) && !body.notification_category_name) {
         // We have to render email twice in this case, first pass is to get the notification category

198-201: Consider returning only successful recipients or surfacing per-user errors

Currently, results includes all targeted users regardless of render/send failures; userSendErrors is unused. If callers rely on results to reflect success, consider filtering or adding an errors array. This changes API semantics, so confirm expectation first.

If filtering successes is desired without changing the schema, apply:

-    const results: UserResult[] = Array.from(userMap.values()).map((user) => ({
-      user_id: user.projectUserId,
-      user_email: userPrimaryEmails.get(user.projectUserId) ?? user.contactChannels.find((c) => c.isPrimary === "TRUE")?.value,
-    }));
+    const results: UserResult[] = Array.from(userMap.values())
+      .filter((user) => !userSendErrors.has(user.projectUserId))
+      .map((user) => ({
+        user_id: user.projectUserId,
+        user_email: userPrimaryEmails.get(user.projectUserId) ?? user.contactChannels.find((c) => c.isPrimary === "TRUE")?.value,
+      }));

Alternatively, if you want to keep all recipients and include failure info, we can extend the response type and update clients—say the word and I’ll draft it.

📜 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 2c237ef and 7461754.

📒 Files selected for processing (32)
  • apps/backend/prisma/migrations/20250815012830_email_drafts/migration.sql (1 hunks)
  • apps/backend/prisma/schema.prisma (2 hunks)
  • apps/backend/src/app/api/latest/emails/render-email/route.tsx (3 hunks)
  • apps/backend/src/app/api/latest/emails/send-email/route.tsx (4 hunks)
  • apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx (1 hunks)
  • apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx (1 hunks)
  • apps/backend/src/app/api/latest/internal/email-drafts/route.tsx (1 hunks)
  • apps/backend/src/lib/ai-chat/adapter-registry.ts (2 hunks)
  • apps/backend/src/lib/ai-chat/email-draft-adapter.ts (1 hunks)
  • apps/backend/src/lib/email-drafts.tsx (1 hunks)
  • apps/backend/src/lib/email-rendering.tsx (5 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx (1 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx (1 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx (1 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx (3 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (4 hunks)
  • apps/dashboard/src/components/assistant-ui/thread.tsx (1 hunks)
  • apps/dashboard/src/components/email-preview.tsx (2 hunks)
  • apps/dashboard/src/components/email-theme-selector.tsx (1 hunks)
  • apps/dashboard/src/components/vibe-coding/chat-adapters.ts (1 hunks)
  • apps/dashboard/src/components/vibe-coding/code-editor.tsx (1 hunks)
  • apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx (1 hunks)
  • apps/e2e/tests/backend/endpoints/api/v1/render-email.test.ts (1 hunks)
  • apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts (6 hunks)
  • apps/e2e/tests/js/email.test.ts (1 hunks)
  • packages/stack-shared/src/interface/admin-interface.ts (2 hunks)
  • packages/stack-shared/src/interface/server-interface.ts (2 hunks)
  • packages/stack-shared/src/utils/types.tsx (1 hunks)
  • packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (4 hunks)
  • packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts (3 hunks)
  • packages/template/src/lib/stack-app/email/index.ts (2 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
apps/backend/src/app/api/latest/**/*

📄 CodeRabbit Inference Engine (CLAUDE.md)

apps/backend/src/app/api/latest/**/*: Main API routes are located in /apps/backend/src/app/api/latest
The project uses a custom route handler system in the backend for consistent API responses

Files:

  • apps/backend/src/app/api/latest/internal/email-drafts/route.tsx
  • apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx
  • apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx
  • apps/backend/src/app/api/latest/emails/render-email/route.tsx
  • apps/backend/src/app/api/latest/emails/send-email/route.tsx
apps/backend/prisma/schema.prisma

📄 CodeRabbit Inference Engine (CLAUDE.md)

Database models use Prisma

Files:

  • apps/backend/prisma/schema.prisma
🧠 Learnings (1)
📚 Learning: 2025-08-04T22:25:51.260Z
Learnt from: CR
PR: stack-auth/stack-auth#0
File: CLAUDE.md:0-0
Timestamp: 2025-08-04T22:25:51.260Z
Learning: Applies to apps/backend/prisma/schema.prisma : Database models use Prisma

Applied to files:

  • apps/backend/prisma/schema.prisma
  • apps/backend/src/lib/email-rendering.tsx
🧬 Code Graph Analysis (18)
apps/backend/src/app/api/latest/internal/email-drafts/route.tsx (5)
apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx (1)
  • GET (6-41)
packages/stack-shared/src/schema-fields.ts (4)
  • yupObject (245-249)
  • yupString (185-188)
  • yupNumber (189-192)
  • templateThemeIdSchema (477-477)
apps/backend/src/prisma-client.tsx (1)
  • getPrismaClientForTenancy (51-53)
apps/backend/src/lib/email-drafts.tsx (2)
  • themeModeToTemplateThemeId (25-33)
  • templateThemeIdToThemeMode (15-23)
apps/backend/src/app/api/latest/emails/send-email/route.tsx (1)
  • POST (29-209)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx (3)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx (2)
  • metadata (3-5)
  • Page (7-11)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx (1)
  • PageClient (21-98)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1)
  • PageClient (15-88)
apps/backend/src/lib/ai-chat/email-draft-adapter.ts (1)
apps/backend/src/lib/ai-chat/adapter-registry.ts (1)
  • ChatAdapterContext (7-10)
apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx (1)
packages/stack-shared/src/schema-fields.ts (1)
  • yupString (185-188)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx (4)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
  • useAdminApp (27-34)
apps/dashboard/src/components/router.tsx (1)
  • useRouter (15-33)
apps/dashboard/src/components/form-fields.tsx (1)
  • InputField (59-97)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx (1)
  • PageClient (21-77)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (1)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
  • useAdminApp (27-34)
apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx (4)
apps/backend/src/route-handlers/smart-route-handler.tsx (1)
  • createSmartRouteHandler (209-294)
packages/stack-shared/src/schema-fields.ts (4)
  • yupObject (245-249)
  • yupString (185-188)
  • yupNumber (189-192)
  • templateThemeIdSchema (477-477)
apps/backend/src/prisma-client.tsx (1)
  • getPrismaClientForTenancy (51-53)
apps/backend/src/lib/email-drafts.tsx (2)
  • themeModeToTemplateThemeId (25-33)
  • templateThemeIdToThemeMode (15-23)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx (2)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx (2)
  • metadata (3-5)
  • Page (7-10)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx (1)
  • PageClient (13-80)
apps/dashboard/src/components/email-theme-selector.tsx (2)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
  • useAdminApp (27-34)
packages/stack-ui/src/components/ui/select.tsx (5)
  • Select (160-160)
  • SelectTrigger (160-160)
  • SelectValue (160-160)
  • SelectContent (160-160)
  • SelectItem (160-160)
apps/backend/src/lib/ai-chat/adapter-registry.ts (3)
apps/backend/src/lib/ai-chat/email-theme-adapter.ts (1)
  • emailThemeAdapter (7-18)
apps/backend/src/lib/ai-chat/email-template-adapter.ts (1)
  • emailTemplateAdapter (10-20)
apps/backend/src/lib/ai-chat/email-draft-adapter.ts (1)
  • emailDraftAdapter (10-20)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx (2)
apps/dashboard/src/components/email-preview.tsx (1)
  • EmailPreview (91-122)
apps/dashboard/src/components/email-theme-selector.tsx (1)
  • EmailThemeSelector (17-39)
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (1)
packages/template/src/lib/stack-app/apps/implementations/common.ts (2)
  • createCache (22-27)
  • useAsyncCache (145-190)
apps/backend/src/lib/email-rendering.tsx (2)
docs/scripts/generate-docs.js (1)
  • result (100-100)
packages/stack-shared/src/utils/results.tsx (1)
  • error (36-41)
apps/backend/src/app/api/latest/emails/render-email/route.tsx (2)
packages/stack-shared/src/schema-fields.ts (3)
  • yupObject (245-249)
  • yupString (185-188)
  • templateThemeIdSchema (477-477)
packages/stack-shared/src/known-errors.tsx (2)
  • KnownErrors (1490-1492)
  • KnownErrors (1494-1609)
packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts (1)
packages/template/src/lib/stack-app/common.ts (1)
  • AsyncStoreProperty (8-10)
packages/template/src/lib/stack-app/email/index.ts (1)
packages/stack-shared/src/utils/types.tsx (1)
  • XOR (69-73)
apps/backend/src/app/api/latest/emails/send-email/route.tsx (3)
packages/stack-shared/src/schema-fields.ts (1)
  • templateThemeIdSchema (477-477)
apps/backend/src/prisma-client.tsx (1)
  • getPrismaClientForTenancy (51-53)
apps/backend/src/lib/email-drafts.tsx (2)
  • getEmailDraft (3-13)
  • themeModeToTemplateThemeId (25-33)
apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts (1)
apps/e2e/tests/backend/backend-helpers.ts (1)
  • niceBackendFetch (107-165)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (9)
  • GitHub Check: all-good
  • GitHub Check: docker
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: docker
  • GitHub Check: build (22.x)
  • GitHub Check: build (22.x)
  • GitHub Check: restart-dev-and-test
  • GitHub Check: setup-tests
  • GitHub Check: Security Check
🔇 Additional comments (31)
apps/dashboard/src/components/vibe-coding/code-editor.tsx (1)

84-85: LGTM: defaulting Props generic to never is a sensible DX improvement

This allows omitting the generic when no variables are expected while keeping variables unusable by default, which avoids accidental usage. Nice touch.

apps/backend/prisma/schema.prisma (2)

672-687: Model EmailDraft looks correct and aligns with the migration

Composite PK, timestamps, and fields look good. The enum-based theme selection is clear and matches the rest of the PR.


689-694: Enum DraftThemeMode is well-scoped

Values cover the expected theme resolution modes and match the helpers used elsewhere.

apps/backend/src/lib/email-rendering.tsx (1)

83-87: Good guard for optional variablesSchema

The conditional call avoids runtime errors when templates omit variablesSchema and aligns with the new draft-or-template input.

apps/backend/src/app/api/latest/emails/render-email/route.tsx (3)

18-35: Schema union for the four valid combinations is a solid simplification

The yupUnion approach reduces branching and aligns with the new template/draft dual path. Good upgrade.


53-57: Theme existence check is correct and allows NONE/PROJECT_DEFAULT via templateThemeIdSchema

The guard only validates string IDs and defers false/null cases to getEmailThemeForTemplate, which matches the schema contract.


72-79: No changes needed for project display name mapping

I’ve verified that in the backend API and Prisma model, the project object exposes display_name (snake_case), and that you’re correctly mapping it to the displayName property when calling renderEmailWithTemplate. There’s no project.displayName on the raw Tenancy payload, so using tenancy.project.display_name here is correct. Keep the code as-is.

apps/backend/src/lib/ai-chat/adapter-registry.ts (1)

17-23: Adapter wiring for “email-draft” looks correct

ContextType updated, registry entry added, and getChatAdapter remains exhaustive via the ContextType keying.

apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx (1)

40-40: Allowing "email-draft" context_type is correct and consistent with the new adapter

This aligns with the registry and dashboard changes. No concerns with the new union member.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx (1)

3-5: LGTM: Metadata title matches the route purpose

The page metadata is concise and correct.

apps/dashboard/src/components/vibe-coding/chat-adapters.ts (1)

18-19: LGTM: Added "email-draft" contextType is aligned with backend and interfaces

Type union correctly reflects the new chat context.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx (2)

65-66: EmailPreview prop wiring looks correct.

Passing selectedThemeId directly aligns with the updated EmailPreview props (string | undefined | false) and avoids awkward null conversions.


74-79: Nice refactor to EmailThemeSelector.

Centralizing theme selection logic reduces duplication and keeps the template editor consistent with drafts.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (3)

27-31: Icon updates look good.

Switching Templates to LayoutTemplate and importing FilePen aligns iconography with semantics.


181-187: Good addition: Drafts nav item.

Route, icon, and regex align with the new drafts feature.


202-221: Breadcrumb wiring for drafts is consistent.

Hidden item generates a two-level crumb and mirrors the patterns used for users/templates/themes.

apps/dashboard/src/components/email-theme-selector.tsx (1)

17-38: Selector implementation looks solid and consistent.

  • Uses centralized themes source via useEmailThemes.
  • Values are consistently JSON-encoded; decoding normalizes nullundefined.
  • Options cover No theme, Project theme, and per-theme entries.
packages/template/src/lib/stack-app/email/index.ts (2)

19-31: Good use of XOR to enforce mutual exclusivity for recipients and content variants (including draftId).

This shape is clear, prevents invalid combinations at compile time, and aligns with backend expectations for the new draft path.


1-1: Import path consistency verified
All existing imports from @stackframe/stack-shared in this repo reference the dist/... subpath (e.g., in packages/template/src/lib/stack-app/api-keys/index.ts and apps/backend/src/lib/events.tsx). Your new import aligns with the established pattern—no change needed here.

packages/stack-shared/src/interface/server-interface.ts (1)

817-827: Payload mapping for all_users and draft_id looks correct and consistent with the backend route.

Relying on JSON.stringify to drop undefined values is fine here, and matches how other endpoints omit absent fields.

apps/backend/src/lib/email-drafts.tsx (1)

15-23: Theme mode ←→ theme id mapping is correct and matches the public API semantics.

  • undefined → PROJECT_DEFAULT
  • false → NONE
  • string → CUSTOM

Round-tripping behavior is as expected.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx (1)

21-79: Confirm all “email-drafts/” routes and hrefs
I’ve located several additional occurrences—please verify each resolves to the correct /projects/[projectId]/email-drafts/[id] route (or adjust to use the proper relative/absolute path):

• apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx
– line 209: href = \/email-drafts/${match[1]}``

• apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx
– line 48: router.push(\email-drafts/${draft.id}`) – line 65: router.push(`email-drafts/${sharedSmtpWarningDialogOpen}`) – line 88: router.push(`email-drafts/${draft.id}`)`

• packages/stack-shared/src/interface/admin-interface.ts
– line 157: `/internal/email-drafts/${id}` (backend API endpoint—ensure this is intentional)

Please review and update any paths that should include the projectId segment or use leading slashes/relative segments appropriately.

apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx (1)

26-41: GET mapping and use of theme conversion helpers look good.

Using themeModeToTemplateThemeId ensures response consistency with the public API and the shared schema.

apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts (2)

90-90: Correct environment setup for shared SMTP scenario

Adding Project.createAndSwitch() ensures the test runs under a tenancy using the shared email server, aligning with the expected REQUIRES_CUSTOM_EMAIL_SERVER error.


437-531: Great coverage for all_users flow and XOR constraint

Both tests look solid: you validate mutual exclusivity with user_ids and all_users, and you verify one email per user for the all_users path. This mirrors the route’s XOR semantics.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1)

95-110: Send-stage UX logic looks good

  • Correct XOR between userIds and allUsers passed to sendEmail.
  • Properly disables Send when no users selected under “users” scope.
  • Clear, actionable toasts for known versus unknown errors.
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (2)

46-48: Drafts cache wiring looks correct

Cache is consistent with existing pattern (no deps + explicit refresh after mutations). Good addition.


498-503: Chat context extended with "email-draft": good

The public surface matches the backend addition and maintains the same call signature. Looks good.

apps/backend/src/app/api/latest/emails/send-email/route.tsx (3)

19-27: Body base shape is sound and openapi meta on theme_id is helpful

Optional subject and notification_category_name are sensible; XOR with all_users is enforced later. Good structure.


70-72: XOR check between user_ids and all_users is correct

Prevents ambiguous targeting. Keep as-is.


77-94: I’d like to confirm the full implementation of getEmailThemeForTemplate to verify how it handles null or false. Could you run:

sed -n '1,200p' apps/backend/src/lib/email-rendering.tsx

and share the output around the getEmailThemeForTemplate definition? This will help ensure whether explicitly coalescing null to undefined or checking for null in the send-email handler is actually necessary.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🔭 Outside diff range comments (1)
apps/dashboard/src/components/email-preview.tsx (1)

72-89: Remove null from themeId unions across downstream code

The recent change in apps/dashboard/src/components/email-preview.tsx updated themeId to string | undefined | false, but there are still type definitions and call‐sites elsewhere that include string | null | false. Please update these to use undefined instead of null (and adjust any code passing null accordingly):

• packages/template/src/lib/stack-app/email/index.ts:13
• packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts:519, 523, 540
• packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts:35, 72
• packages/stack-shared/src/interface/server-interface.ts:802
• packages/stack-shared/src/interface/admin-interface.ts:394, 452

♻️ Duplicate comments (9)
packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts (2)

37-37: Align themeId nullability with existing conventions (duplicate of earlier feedback).

Elsewhere in the codebase (e.g., templateThemeIdSchema), themeId allows string | null | false. Using undefined here is inconsistent. Recommend switching to null to match the rest.

Apply this diff:

-  & AsyncStoreProperty<"emailDrafts", [], { id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[], true>
+  & AsyncStoreProperty<"emailDrafts", [], { id: string, displayName: string, themeId: string | null | false, tsxSource: string, sentAt: Date | null }[], true>

78-79: Unify themeId optional type with null instead of undefined (duplicate of earlier feedback).

Maintain consistency with other APIs that use string | null | false.

Apply this diff:

-    createEmailDraft(options: { displayName?: string, themeId?: string | undefined | false, tsxSource?: string }): Promise<{ id: string }>,
-    updateEmailDraft(id: string, data: { displayName?: string, themeId?: string | undefined | false, tsxSource?: string }): Promise<void>,
+    createEmailDraft(options: { displayName?: string, themeId?: string | null | false, tsxSource?: string }): Promise<{ id: string }>,
+    updateEmailDraft(id: string, data: { displayName?: string, themeId?: string | null | false, tsxSource?: string }): Promise<void>,
packages/stack-shared/src/interface/admin-interface.ts (2)

134-138: Drop redundant “| undefined” and allow null for theme_id (duplicate of earlier feedback).

  • Optional properties don’t need “| undefined”.
  • Use string | null | false consistently to match templateThemeIdSchema and related handlers.

Apply this diff:

-  async listInternalEmailDrafts(): Promise<{ id: string, display_name: string, theme_id?: string | undefined | false, tsx_source: string, sent_at_millis?: number | null }[]> {
+  async listInternalEmailDrafts(): Promise<{ id: string, display_name: string, theme_id?: string | null | false, tsx_source: string, sent_at_millis?: number | null }[]> {
     const response = await this.sendAdminRequest(`/internal/email-drafts`, {}, null);
-    const result = await response.json() as { drafts: { id: string, display_name: string, theme_id?: string | undefined | false, tsx_source: string, sent_at_millis?: number | null }[] };
+    const result = await response.json() as { drafts: { id: string, display_name: string, theme_id?: string | null | false, tsx_source: string, sent_at_millis?: number | null }[] };
     return result.drafts;
   }

140-153: Create shape should accept null for theme_id to match update/list (duplicate of earlier feedback).

Currently: createEmailDraft(theme_id?: string | false) vs update/theme_id?: string | null | false.

Apply this diff:

-  async createEmailDraft(options: { display_name?: string, theme_id?: string | false, tsx_source?: string }): Promise<{ id: string }> {
+  async createEmailDraft(options: { display_name?: string, theme_id?: string | null | false, tsx_source?: string }): Promise<{ id: string }> {
apps/backend/src/app/api/latest/emails/send-email/route.tsx (1)

96-106: Bug: all_users path breaks Prisma query (filters by undefined user_ids) — handle both modes in query

When all_users is true, body.user_ids is undefined and Prisma will receive in: undefined, which is invalid. Omit the projectUserId filter when all_users is true to fetch all users in the tenancy.

Apply this diff to make the query robust:

-    const users = await prisma.projectUser.findMany({
-      where: {
-        tenancyId: auth.tenancy.id,
-        projectUserId: {
-          in: body.user_ids
-        },
-      },
-      include: {
-        contactChannels: true,
-      },
-    });
+    const users = await prisma.projectUser.findMany({
+      where: {
+        tenancyId: auth.tenancy.id,
+        ...(body.user_ids ? {
+          projectUserId: {
+            in: body.user_ids,
+          },
+        } : {}),
+      },
+      include: {
+        contactChannels: true,
+      },
+    });
apps/backend/prisma/migrations/20250815012830_email_drafts/migration.sql (1)

5-17: Add missing foreign key to Tenancy (consistent with rest of the schema)

EmailDraft.tenancyId should reference Tenancy(id) with ON DELETE CASCADE / ON UPDATE CASCADE. This preserves referential integrity and mirrors other multi-tenant tables.

You can add a follow-up migration:

ALTER TABLE "EmailDraft"
  ADD CONSTRAINT "EmailDraft_tenancyId_fkey"
  FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id")
  ON DELETE CASCADE ON UPDATE CASCADE;
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx (1)

18-19: Replace any with a minimal draft type for safety and readability

Avoid any here; annotate the drafts list items with the minimal shape you use (id/displayName). This improves type safety without over-typing the hook.

-  const drafts = stackAdminApp.useEmailDrafts();
+  type DraftListItem = { id: string; displayName: string };
+  const drafts = stackAdminApp.useEmailDrafts() as DraftListItem[];
-      {drafts.map((draft: any) => (
+      {drafts.map((draft) => (

Also applies to: 35-35

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1)

11-11: Initialize editor state after async draft load to avoid empty/stale form

If the draft arrives after mount, currentCode/selectedThemeId remain at their initial empty values. Initialize them once when the draft becomes available.

-import { Suspense, useEffect, useMemo, useState } from "react";
+import { Suspense, useEffect, useMemo, useRef, useState } from "react";
   const drafts = stackAdminApp.useEmailDrafts() as EmailDraft[];
   const draft = useMemo(() => drafts.find((d) => d.id === draftId), [drafts, draftId]);
 
   const [currentCode, setCurrentCode] = useState<string>(draft?.tsxSource ?? "");
   const [stage, setStage] = useState<"edit" | "send">("edit");
   const [selectedThemeId, setSelectedThemeId] = useState<string | undefined | false>(draft?.themeId);
+  const initializedFromDraft = useRef(false);
+
+  useEffect(() => {
+    if (!draft || initializedFromDraft.current) return;
+    setCurrentCode(draft.tsxSource);
+    setSelectedThemeId(draft.themeId);
+    initializedFromDraft.current = true;
+  }, [draft]);

Also applies to: 23-27

apps/backend/src/lib/ai-chat/email-draft-adapter.ts (1)

41-41: Escape template interpolation inside the backticked description string

The ${user.displayName} within a backtick string will be interpolated at runtime, causing a ReferenceError. Escape it so it renders literally in the tool description.

-      <Subject value={`Hello ${user.displayName}!`} />
+      <Subject value={`Hello \${user.displayName}!`} />
🧹 Nitpick comments (30)
apps/dashboard/src/components/assistant-ui/thread.tsx (1)

81-81: Commented-out welcome suggestions: consider adding a TODO or feature flag

If this is a temporary removal for the drafts UX, add context so it’s not lost. Alternatively, gate it behind a flag for quick re-enable during testing.

Apply this diff to add a clarifying TODO:

-        {/* <ThreadWelcomeSuggestions /> */}
+        {/* TODO(email-drafts): temporarily disabled while we redesign onboarding/suggestions for draft workflows. Re-enable or remove once finalized. */}
+        {/* <ThreadWelcomeSuggestions /> */}
apps/dashboard/src/components/vibe-coding/code-editor.tsx (1)

84-84: Defaulting Props to never makes props.variables unusable; prefer unknown

With type Props<T = never>, variables: T resolves to never when no type arg is provided, making variables impossible to pass/reference in templates authored in the editor. Using unknown keeps it safe by default while allowing templates to specify Props<MyVars> for strong typing.

Consider this tweak:

-          type Props<T = never> = {
+          type Props<T = unknown> = {
             variables: T;
             project: {
               displayName: string;
             };
             user: {
               displayName: string | null;
             };
           };

Verification suggestion:

  • Open a simple template in the editor using React.FC<Props> without a type parameter and reference props.variables. With never, the editor surfaces type errors; with unknown, it remains usable until narrowed by a schema.
packages/stack-shared/src/utils/types.tsx (1)

69-74: XOR implementation is overly strict on overlapping keys; use the Without-pattern

The current (A & { [K in keyof B]?: never }) forbids all keys of B (including shared keys), which can make otherwise valid shapes impossible if A and B share keys. The conventional XOR uses Without to only disallow non-overlapping keys.

Apply this diff:

-export type XOR<T extends readonly any[]> = T extends readonly [infer A, infer B, ...infer Rest]
-  ? Rest extends []
-  ? (A & { [K in keyof B]?: never }) | (B & { [K in keyof A]?: never })
-  : XOR<[(A & { [K in keyof B]?: never }) | (B & { [K in keyof A]?: never }), ...Rest]>
-  : T[0];
+type Without<T, U> = { [K in Exclude<keyof T, keyof U>]?: never };
+type _XOR2<A, B> = (A & Without<B, A>) | (B & Without<A, B>);
+export type XOR<T extends readonly any[]> =
+  T extends readonly [infer A, infer B, ...infer Rest]
+    ? Rest extends []
+      ? _XOR2<A, B>
+      : XOR<[_XOR2<A, B>, ...Rest]>
+    : T[0];

Optionally, add compile-time assertions (outside this hunk) to lock behavior:

// Sanity checks
typeAssertExtends<XOR<[ { a: number }, { b: string } ]>, { a: number; b?: never } | { b: string; a?: never }>()();
typeAssertExtends<XOR<[ { a: number; c?: string }, { a?: number; b: boolean } ]>, any>()(); // overlapping 'a' allowed if compatible
apps/backend/src/lib/email-rendering.tsx (1)

124-128: Preserve the error message for better diagnosability

Swallowing the execution error into a generic string makes debugging hard (especially in preview). Returning the error’s message keeps UX informative without leaking stack traces.

Apply this diff:

-  try {
-    const output = await freestyle.executeScript(result.data, { nodeModules });
-    return Result.ok(output.result as { html: string, text: string, subject: string, notificationCategory: string });
-  } catch (error) {
-    return Result.error("Unable to render email");
-  }
+  try {
+    const output = await freestyle.executeScript(result.data, { nodeModules });
+    return Result.ok(output.result as { html: string, text: string, subject: string, notificationCategory: string });
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Unable to render email";
+    return Result.error(message);
+  }
packages/stack-shared/src/interface/server-interface.ts (1)

800-809: Consider encoding recipient/content XOR at the interface boundary

The server will validate, but we can catch invalid combinations earlier by adding overloads or a helper type for options that reflects the XOR constraints (e.g., userIds vs allUsers, html vs templateId vs draftId). This improves DX and reduces runtime 400s.

Lightweight runtime guard (optional) to fail fast client-side:

   async sendEmail(options: {
     userIds?: string[],
     allUsers?: true,
     themeId?: string | null | false,
     html?: string,
     subject?: string,
     notificationCategoryName?: string,
     templateId?: string,
     variables?: Record<string, any>,
     draftId?: string,
   }): Promise<Result<void, KnownErrors["RequiresCustomEmailServer"] | KnownErrors["SchemaError"] | KnownErrors["UserIdDoesNotExist"]>> {
+    if (options.allUsers && options.userIds?.length) {
+      throw new StackAssertionError("Provide either allUsers or userIds, not both");
+    }
+    if (
+      [options.html, options.templateId, options.draftId].filter(v => v !== undefined).length !== 1
+    ) {
+      throw new StackAssertionError("Provide exactly one of html, templateId, or draftId");
+    }

Verification suggestion:

  • Ensure the backend route expects the same wire fields: all_users, draft_id. The mapping here looks correct.

Also applies to: 818-827

packages/template/src/lib/stack-app/email/index.ts (1)

1-1: Consider importing XOR from the package root (optional).

To avoid mixing import styles across the repo, consider importing from the package root (re-export) instead of the /dist path, if available.

apps/backend/src/app/api/latest/emails/send-email/route.tsx (3)

24-26: Redundant .nullable() on templateThemeIdSchema

templateThemeIdSchema already accepts undefined, false, null, and a UUID string (per shared snippet). Applying .nullable() again is redundant and can be dropped to simplify.

-  theme_id: templateThemeIdSchema.nullable().meta({
+  theme_id: templateThemeIdSchema.meta({
     openapiField: { description: "The theme to use for the email. If not specified, the default theme will be used." }
   }),

115-122: Dead code: userSendErrors is populated but never read or returned

You collect per-user errors in userSendErrors (e.g., missing primary email, rendering error, notifications disabled, send failure), but never surface them in results or logs. This adds overhead without value and obscures failures.

Two reasonable options:

  • Keep results minimal: remove userSendErrors entirely and prune the set() calls (continue statements already control flow).
  • Expose per-user errors: extend response schema and results with an optional error field, and include values from userSendErrors. This improves observability but requires updating response typing and tests.

198-201: Prefer using the computed userPrimaryEmails map consistently

You already filled userPrimaryEmails earlier. The fallback search across contactChannels repeats work and can hide subtle inconsistencies. Use the map exclusively for consistency and simpler code.

-    const results: UserResult[] = Array.from(userMap.values()).map((user) => ({
-      user_id: user.projectUserId,
-      user_email: userPrimaryEmails.get(user.projectUserId) ?? user.contactChannels.find((c) => c.isPrimary === "TRUE")?.value,
-    }));
+    const results: UserResult[] = Array.from(userMap.values()).map((user) => ({
+      user_id: user.projectUserId,
+      user_email: userPrimaryEmails.get(user.projectUserId),
+    }));
apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts (1)

592-624: Snapshot brittleness: duplicated “unknown properties: html” lines

The schema details repeat “unknown properties: html” within the same schema block. If the validator output gets de-duplicated later, this snapshot will become brittle.

Suggestion: assert key fragments (e.g., “theme_id is invalid”, “template_id must be defined”) with partial string match or toMatchObject for code and header, and a looser check on details.message contents rather than a strict inline snapshot of the whole block.

apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx (1)

40-40: De-duplicate ContextType literals across API and adapter-registry

Avoid hardcoding the union in multiple places. Export a single source of truth (a const array) from the adapter-registry and reference it here to keep validation in sync with the registry.

Apply this diff here:

-import { adaptSchema, yupArray, yupMixed, yupNumber, yupObject, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields";
+import { adaptSchema, yupArray, yupMixed, yupNumber, yupObject, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields";
+import { CONTEXT_TYPES } from "@/lib/ai-chat/adapter-registry";
-      context_type: yupString().oneOf(["email-theme", "email-template", "email-draft"]).defined(),
+      context_type: yupString().oneOf(CONTEXT_TYPES as unknown as string[]).defined(),

Additionally add this in apps/backend/src/lib/ai-chat/adapter-registry.ts (outside this file’s ranges):

// co-locate with ContextType
export const CONTEXT_TYPES = ["email-theme", "email-template", "email-draft"] as const satisfies readonly ContextType[];
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx (1)

7-10: Fix Page props typing; params is not a Promise in Next.js App Router

params is provided as a plain object. Awaiting it works at runtime but is misleading and unnecessary. Also, the function doesn’t need to be async.

Apply this diff:

-export default async function Page(props: { params: Promise<{ draftId: string }> }) {
-  const params = await props.params;
-  return <PageClient draftId={params.draftId} />;
-}
+export default function Page({ params }: { params: { draftId: string } }) {
+  return <PageClient draftId={params.draftId} />;
+}
apps/backend/prisma/migrations/20250815012830_email_drafts/migration.sql (1)

5-17: Optional constraints and indexes to support correctness and query patterns

  • Consider indexing (tenancyId, createdAt) for list/sort queries, and createdAt for admin views.
  • If an EmailTheme table exists, consider a composite FK (tenancyId, themeId) to prevent cross-tenant references.
  • Consider CHECK constraints for themeMode/themeId consistency:
    • themeMode = 'NONE' => themeId IS NULL
    • themeMode = 'PROJECT_DEFAULT' => themeId IS NULL
    • themeMode = 'CUSTOM' => themeId IS NOT NULL

Example follow-up migration:

-- Indexes
CREATE INDEX IF NOT EXISTS "EmailDraft_tenancyId_createdAt_idx" ON "EmailDraft" ("tenancyId", "createdAt");
CREATE INDEX IF NOT EXISTS "EmailDraft_createdAt_idx" ON "EmailDraft" ("createdAt");

-- Optional: constrain theme relationship (adjust table/column types as needed)
-- ALTER TABLE "EmailDraft"
--   ADD CONSTRAINT "EmailDraft_theme_fkey"
--   FOREIGN KEY ("tenancyId", "themeId") REFERENCES "EmailTheme"("tenancyId","id")
--   ON DELETE SET NULL ON UPDATE CASCADE;

-- Optional: enforce themeMode <-> themeId consistency
ALTER TABLE "EmailDraft" ADD CONSTRAINT "EmailDraft_theme_mode_ck" CHECK (
  ("themeMode" = 'NONE' AND "themeId" IS NULL) OR
  ("themeMode" = 'PROJECT_DEFAULT' AND "themeId" IS NULL) OR
  ("themeMode" = 'CUSTOM' AND "themeId" IS NOT NULL)
);
apps/backend/src/lib/ai-chat/adapter-registry.ts (1)

17-23: Export a single source of truth for context types

Export a runtime array next to the ContextType union, so API validation (yup.oneOf) can depend on it without duplicating literals.

Apply this diff:

 type ContextType = "email-theme" | "email-template" | "email-draft";
 
+// Keep Runtime and Type in sync
+export const CONTEXT_TYPES = ["email-theme", "email-template", "email-draft"] as const satisfies readonly ContextType[];
+
 const CHAT_ADAPTERS: Record<ContextType, (context: ChatAdapterContext) => ChatAdapter> = {

Optionally, defensively guard unexpected keys if this function is ever used outside validated routes:

 export function getChatAdapter(contextType: ContextType, tenancy: Tenancy, threadId: string): ChatAdapter {
-  const adapter = CHAT_ADAPTERS[contextType];
-  return adapter({ tenancy, threadId });
+  const adapter = CHAT_ADAPTERS[contextType];
+  return adapter({ tenancy, threadId });
 }

(With ContextType + route validation this is already safe; the guard would be redundant.)

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx (2)

27-27: Initialize selectedThemeId safely when template.themeId can be null

If template.themeId is null for “project default”, map it to undefined to match EmailThemeSelector/EmailPreview conventions (project theme = undefined, no theme = false).

Apply this diff:

-  const [selectedThemeId, setSelectedThemeId] = useState<string | undefined | false>(template?.themeId);
+  const [selectedThemeId, setSelectedThemeId] = useState<string | undefined | false>(
+    (template?.themeId ?? undefined) as string | undefined | false
+  );

80-84: Normalize theme comparison for the Save button’s disabled state

Ensure “project theme” (null/undefined) comparisons work and that “no theme” (false) remains distinct.

Apply this diff:

-              <Button
-                disabled={currentCode === template.tsxSource && selectedThemeId === template.themeId}
+              <Button
+                disabled={
+                  currentCode === template.tsxSource &&
+                  ((selectedThemeId ?? null) === ((template.themeId as unknown as string | undefined | false) ?? null))
+                }
                 onClick={handleSaveTemplate}
               >
                 Save
               </Button>

Alternatively, define a small helper above the return:

const normalizeThemeId = (v: string | undefined | null | false) => (v === null ? undefined : v);
const isUnchanged =
  currentCode === template.tsxSource &&
  normalizeThemeId(selectedThemeId) === normalizeThemeId(template.themeId as any);

and use disabled={isUnchanged}.

apps/backend/prisma/schema.prisma (2)

672-687: Add tenancy-scoped indexes for common list/sort queries on EmailDraft

Drafts will often be listed and sorted per tenancy; adding indexes will keep those queries snappy at scale.

Apply this diff to add pragmatic indexes:

 model EmailDraft {
   tenancyId String @db.Uuid

   id String @default(uuid()) @db.Uuid

   displayName String
   themeMode   DraftThemeMode @default(PROJECT_DEFAULT)
   themeId     String?
   tsxSource   String
   sentAt      DateTime?

   createdAt DateTime @default(now())
   updatedAt DateTime @updatedAt

   @@id([tenancyId, id])
+  @@index([tenancyId, createdAt(sort: Desc)], name: "EmailDraft_createdAt_desc")
+  @@index([tenancyId, displayName(sort: Asc)], name: "EmailDraft_displayName_asc")
 }

679-680: Enforce themeMode/themeId invariant (CUSTOM requires themeId; others must not)

To avoid inconsistent state:

  • themeMode = CUSTOM ⇒ themeId must be set
  • themeMode in {PROJECT_DEFAULT, NONE} ⇒ themeId must be null

Prisma can’t natively express conditional CHECK constraints; enforce this in create/update handlers and in email-drafts.tsx utilities.

Would you like me to add Zod validators to the internal email-drafts routes? I can open an issue or provide a patch.

apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx (1)

19-21: Add an aria-label to the icon-only button for accessibility

Screen readers won’t announce intent on an icon-only button.

Apply this diff:

-          <Button variant="ghost" size="icon" onClick={() => setCurrentCode(args.content)}>
+          <Button
+            variant="ghost"
+            size="icon"
+            aria-label="Insert generated draft into editor"
+            onClick={() => setCurrentCode(args.content)}
+          >
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (2)

203-221: Dynamic breadcrumb for draft detail: good addition

The two-part crumb improves navigation. Consider a fallback label when the draft isn’t yet loaded so the crumb doesn’t render empty.

You can simplify the fallback by returning a static label when not found:

-      if (match) {
-        item = <DraftBreadcrumbItem key='draft-display-name' draftId={match[1]} />;
+      if (match) {
+        item = <DraftBreadcrumbItem key='draft-display-name' draftId={match[1]} />;
         href = `/email-drafts/${match[1]}`;
       } else {
         item = "Draft";
         href = "";
       }

And inside DraftBreadcrumbItem (below) return "Draft" if not found.


352-360: Prefer a fallback label in DraftBreadcrumbItem to avoid empty crumb

If the drafts list hasn’t loaded yet or the ID is unknown, returning null yields a blank crumb.

Apply this minimal change:

 function DraftBreadcrumbItem(props: { draftId: string }) {
   const stackAdminApp = useAdminApp();
   const drafts = stackAdminApp.useEmailDrafts();
   const draft = drafts.find((d) => d.id === props.draftId);
-  if (!draft) {
-    return null;
-  }
-  return draft.displayName;
+  if (!draft) {
+    return "Draft";
+  }
+  return draft.displayName;
 }
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx (1)

96-98: Tighten validation: require non-empty name

.defined() allows empty strings. Use .trim().min(1) to enforce a non-empty name and better UX.

-      formSchema={yup.object({
-        name: yup.string().defined(),
-      })}
+      formSchema={yup.object({
+        name: yup.string().trim().min(1, "Name is required").defined(),
+      })}
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (2)

12-14: Optional: Show a friendly “not found” state if the draft id is invalid

Helps users understand why the editor is empty. Add a simple fallback.

 import { useAdminApp } from "../../use-admin-app";
 import { EmailDraftUI } from "@/components/vibe-coding/draft-tool-components";
+import { PageLayout } from "../../page-layout";
   return (
-    <>
+    <>
+      {!draft ? (
+        <PageLayout title="Email Draft Not Found" />
+      ) : null}
       {stage === "edit" ? (

Also applies to: 52-55


18-19: Avoid name shadowing between imported toast and useToast().toast

Shadowing can confuse readers and some linters. Rename the local binding for clarity.

-  const { toast } = useToast();
+  const { toast: pushToast } = useToast();
-      if (error instanceof KnownErrors.EmailRenderingError) {
-        toast({ title: "Failed to save draft", variant: "destructive", description: error.message });
+      if (error instanceof KnownErrors.EmailRenderingError) {
+        pushToast({ title: "Failed to save draft", variant: "destructive", description: error.message });
         return;
       }
-      toast({ title: "Failed to save draft", variant: "destructive", description: "Unknown error" });
+      pushToast({ title: "Failed to save draft", variant: "destructive", description: "Unknown error" });

Also applies to: 44-49

packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (2)

304-315: DRY up draft mapping with a shared helper to keep types/logic consistent

The draft-to-public mapping is duplicated between useEmailDrafts() and listEmailDrafts(). Create a single helper (like _adminEmailTemplateFromCrud) and reuse it to minimize drift and make future changes safer.

Apply diffs to reuse a helper:

@@
-  useEmailDrafts(): { id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[] {
-    const crud = useAsyncCache(this._adminEmailDraftsCache, [], "useEmailDrafts()");
-    return useMemo(() => {
-      return crud.map((draft) => ({
-        id: draft.id,
-        displayName: draft.display_name,
-        themeId: draft.theme_id,
-        tsxSource: draft.tsx_source,
-        sentAt: draft.sent_at_millis ? new Date(draft.sent_at_millis) : null,
-      }));
-    }, [crud]);
-  }
+  useEmailDrafts(): { id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[] {
+    const crud = useAsyncCache(this._adminEmailDraftsCache, [], "useEmailDrafts()");
+    return useMemo(() => crud.map((d) => this._adminEmailDraftFromCrud(d)), [crud]);
+  }
@@
-  async listEmailDrafts(): Promise<{ id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[]> {
-    const crud = Result.orThrow(await this._adminEmailDraftsCache.getOrWait([], "write-only"));
-    return crud.map((draft) => ({
-      id: draft.id,
-      displayName: draft.display_name,
-      themeId: draft.theme_id,
-      tsxSource: draft.tsx_source,
-      sentAt: draft.sent_at_millis ? new Date(draft.sent_at_millis) : null,
-    }));
-  }
+  async listEmailDrafts(): Promise<{ id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[]> {
+    const crud = Result.orThrow(await this._adminEmailDraftsCache.getOrWait([], "write-only"));
+    return crud.map((d) => this._adminEmailDraftFromCrud(d));
+  }

Add this helper inside the class (near other CRUD mappers):

// Converts internal draft JSON to the public shape used by the template layer
protected _adminEmailDraftFromCrud(draft: {
  id: string;
  display_name: string;
  tsx_source: string;
  theme_id: string | false | undefined | null;
  sent_at_millis?: number | null;
}): { id: string; displayName: string; themeId: string | false | undefined; tsxSource: string; sentAt: Date | null } {
  return {
    id: draft.id,
    displayName: draft.display_name,
    // Normalize possible `null` from backend to `undefined` in the public shape
    themeId: draft.theme_id === null ? undefined : draft.theme_id,
    tsxSource: draft.tsx_source,
    sentAt: draft.sent_at_millis ? new Date(draft.sent_at_millis) : null,
  };
}

Also applies to: 335-344


487-495: Unify themeId null/undefined semantics across surfaces

In this client layer, themeId uses string | false | undefined, while other places (e.g., email preview options allow null; backend schema allows null). This can cause subtle mismatches.

  • Consider normalizing to: string | false | undefined (where undefined means “project default”) and avoid null entirely on the client/public surface.
  • If null must be supported by some APIs, normalize null -> undefined at the edges.

Would you like me to send a follow-up PR to harmonize these types and add null->undefined normalization where needed?

Also applies to: 304-315, 335-344, 519-526

apps/backend/src/app/api/latest/internal/email-drafts/route.tsx (4)

31-35: Reduce payloads and query cost on list: select explicit fields and consider excluding tsx_source in list

Returning tsx_source for up to 50 drafts can be heavy. If the UI only needs metadata for listing, omit tsx_source here and fetch it via GET /email-drafts/:id when editing. Also, select only needed fields to avoid loading large columns.

@@
-    const items = await prisma.emailDraft.findMany({
-      where: { tenancyId: tenancy.id },
-      orderBy: { updatedAt: "desc" },
-      take: 50,
-    });
+    const items = await prisma.emailDraft.findMany({
+      where: { tenancyId: tenancy.id },
+      orderBy: { updatedAt: "desc" },
+      take: 50,
+      select: {
+        id: true,
+        displayName: true,
+        // tsxSource: false, // Consider omitting from the list response
+        tsxSource: true,     // set to false if the UI does not need it here
+        themeMode: true,
+        themeId: true,
+        sentAt: true,
+      },
+    });
@@
-          tsx_source: d.tsxSource,
+          tsx_source: d.tsxSource,

If you decide to omit tsx_source from the list, update the response schema accordingly and rely on the existing GET /internal/email-drafts/[id] to fetch the source for the selected draft.

Also applies to: 40-46


31-35: Pagination: hard-coded take: 50 may truncate results

For tenants with many drafts, a fixed take: 50 without cursor/offset will silently truncate. Consider cursor-based pagination (id or updatedAt) and expose query params for page size.


20-27: Schema allows null for theme_id, but responses never return null

themeModeToTemplateThemeId never emits null; it returns undefined/false/string. Keeping response schema aligned with actual values reduces confusion. Optional: narrow schema to exclude null for the response.


53-67: Default draft source looks fine; consider a slimmer template

The default includes both Subject and NotificationCategory imports. If you want the lightest possible starter, you can drop NotificationCategory or comment it out to reduce noise for new drafts. Optional.

📜 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 2c237ef and 7461754.

📒 Files selected for processing (32)
  • apps/backend/prisma/migrations/20250815012830_email_drafts/migration.sql (1 hunks)
  • apps/backend/prisma/schema.prisma (2 hunks)
  • apps/backend/src/app/api/latest/emails/render-email/route.tsx (3 hunks)
  • apps/backend/src/app/api/latest/emails/send-email/route.tsx (4 hunks)
  • apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx (1 hunks)
  • apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx (1 hunks)
  • apps/backend/src/app/api/latest/internal/email-drafts/route.tsx (1 hunks)
  • apps/backend/src/lib/ai-chat/adapter-registry.ts (2 hunks)
  • apps/backend/src/lib/ai-chat/email-draft-adapter.ts (1 hunks)
  • apps/backend/src/lib/email-drafts.tsx (1 hunks)
  • apps/backend/src/lib/email-rendering.tsx (5 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx (1 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx (1 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx (1 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx (3 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (4 hunks)
  • apps/dashboard/src/components/assistant-ui/thread.tsx (1 hunks)
  • apps/dashboard/src/components/email-preview.tsx (2 hunks)
  • apps/dashboard/src/components/email-theme-selector.tsx (1 hunks)
  • apps/dashboard/src/components/vibe-coding/chat-adapters.ts (1 hunks)
  • apps/dashboard/src/components/vibe-coding/code-editor.tsx (1 hunks)
  • apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx (1 hunks)
  • apps/e2e/tests/backend/endpoints/api/v1/render-email.test.ts (1 hunks)
  • apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts (6 hunks)
  • apps/e2e/tests/js/email.test.ts (1 hunks)
  • packages/stack-shared/src/interface/admin-interface.ts (2 hunks)
  • packages/stack-shared/src/interface/server-interface.ts (2 hunks)
  • packages/stack-shared/src/utils/types.tsx (1 hunks)
  • packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (4 hunks)
  • packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts (3 hunks)
  • packages/template/src/lib/stack-app/email/index.ts (2 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
apps/backend/src/app/api/latest/**/*

📄 CodeRabbit Inference Engine (CLAUDE.md)

apps/backend/src/app/api/latest/**/*: Main API routes are located in /apps/backend/src/app/api/latest
The project uses a custom route handler system in the backend for consistent API responses

Files:

  • apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx
  • apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx
  • apps/backend/src/app/api/latest/emails/send-email/route.tsx
  • apps/backend/src/app/api/latest/internal/email-drafts/route.tsx
  • apps/backend/src/app/api/latest/emails/render-email/route.tsx
apps/backend/prisma/schema.prisma

📄 CodeRabbit Inference Engine (CLAUDE.md)

Database models use Prisma

Files:

  • apps/backend/prisma/schema.prisma
🧠 Learnings (1)
📚 Learning: 2025-08-04T22:25:51.260Z
Learnt from: CR
PR: stack-auth/stack-auth#0
File: CLAUDE.md:0-0
Timestamp: 2025-08-04T22:25:51.260Z
Learning: Applies to apps/backend/prisma/schema.prisma : Database models use Prisma

Applied to files:

  • apps/backend/prisma/schema.prisma
  • apps/backend/src/lib/email-rendering.tsx
🧬 Code Graph Analysis (19)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx (2)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx (2)
  • metadata (3-5)
  • Page (7-10)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx (1)
  • PageClient (13-80)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page.tsx (3)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx (2)
  • metadata (3-5)
  • Page (7-11)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx (1)
  • PageClient (21-98)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1)
  • PageClient (15-88)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx (5)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
  • useAdminApp (27-34)
apps/dashboard/src/components/router.tsx (1)
  • useRouter (15-33)
packages/stack-ui/src/components/action-dialog.tsx (1)
  • ActionDialog (31-135)
apps/dashboard/src/components/form-dialog.tsx (1)
  • FormDialog (53-133)
apps/dashboard/src/components/form-fields.tsx (1)
  • InputField (59-97)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (11)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx (1)
  • PageClient (21-98)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
  • useAdminApp (27-34)
packages/stack-ui/src/components/ui/use-toast.tsx (2)
  • useToast (195-195)
  • toast (195-195)
apps/dashboard/src/components/vibe-coding/chat-adapters.ts (3)
  • ToolCallContent (9-9)
  • createHistoryAdapter (63-76)
  • createChatAdapter (15-61)
apps/dashboard/src/components/vibe-coding/vibe-code-layout.tsx (1)
  • VibeCodeLayout (13-37)
apps/dashboard/src/components/email-preview.tsx (1)
  • EmailPreview (91-122)
apps/dashboard/src/components/vibe-coding/code-editor.tsx (1)
  • CodeEditor (15-144)
apps/dashboard/src/components/email-theme-selector.tsx (1)
  • EmailThemeSelector (17-39)
apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx (1)
  • EmailDraftUI (9-28)
apps/dashboard/src/components/data-table/team-member-search-table.tsx (1)
  • TeamMemberSearchTable (9-68)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/[themeId]/page-client.tsx (1)
  • PageClient (18-78)
apps/backend/src/lib/ai-chat/email-draft-adapter.ts (1)
apps/backend/src/lib/ai-chat/adapter-registry.ts (1)
  • ChatAdapterContext (7-10)
apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx (1)
packages/stack-shared/src/schema-fields.ts (1)
  • yupString (185-188)
apps/dashboard/src/components/email-theme-selector.tsx (2)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
  • useAdminApp (27-34)
packages/stack-ui/src/components/ui/select.tsx (5)
  • Select (160-160)
  • SelectTrigger (160-160)
  • SelectValue (160-160)
  • SelectContent (160-160)
  • SelectItem (160-160)
apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx (4)
apps/backend/src/app/api/latest/internal/email-drafts/route.tsx (1)
  • GET (8-50)
packages/stack-shared/src/schema-fields.ts (4)
  • yupObject (245-249)
  • yupString (185-188)
  • yupNumber (189-192)
  • templateThemeIdSchema (477-477)
apps/backend/src/prisma-client.tsx (1)
  • getPrismaClientForTenancy (51-53)
apps/backend/src/lib/email-drafts.tsx (2)
  • themeModeToTemplateThemeId (25-33)
  • templateThemeIdToThemeMode (15-23)
apps/backend/src/lib/ai-chat/adapter-registry.ts (3)
apps/backend/src/lib/ai-chat/email-theme-adapter.ts (1)
  • emailThemeAdapter (7-18)
apps/backend/src/lib/ai-chat/email-template-adapter.ts (1)
  • emailTemplateAdapter (10-20)
apps/backend/src/lib/ai-chat/email-draft-adapter.ts (1)
  • emailDraftAdapter (10-20)
packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts (1)
packages/template/src/lib/stack-app/common.ts (1)
  • AsyncStoreProperty (8-10)
apps/backend/src/app/api/latest/emails/send-email/route.tsx (3)
packages/stack-shared/src/schema-fields.ts (1)
  • templateThemeIdSchema (477-477)
apps/backend/src/prisma-client.tsx (1)
  • getPrismaClientForTenancy (51-53)
apps/backend/src/lib/email-drafts.tsx (2)
  • getEmailDraft (3-13)
  • themeModeToTemplateThemeId (25-33)
apps/backend/src/app/api/latest/internal/email-drafts/route.tsx (5)
apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx (1)
  • GET (6-41)
packages/stack-shared/src/schema-fields.ts (4)
  • yupObject (245-249)
  • yupString (185-188)
  • yupNumber (189-192)
  • templateThemeIdSchema (477-477)
apps/backend/src/prisma-client.tsx (1)
  • getPrismaClientForTenancy (51-53)
apps/backend/src/lib/email-drafts.tsx (2)
  • themeModeToTemplateThemeId (25-33)
  • templateThemeIdToThemeMode (15-23)
packages/stack-shared/src/utils/strings.tsx (1)
  • deindent (235-238)
packages/template/src/lib/stack-app/email/index.ts (1)
packages/stack-shared/src/utils/types.tsx (1)
  • XOR (69-73)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx (2)
apps/dashboard/src/components/email-preview.tsx (1)
  • EmailPreview (91-122)
apps/dashboard/src/components/email-theme-selector.tsx (1)
  • EmailThemeSelector (17-39)
apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts (4)
packages/template/src/lib/stack-app/projects/index.ts (1)
  • Project (9-13)
apps/e2e/tests/helpers.ts (1)
  • it (10-10)
packages/template/src/lib/stack-app/users/index.ts (1)
  • User (236-236)
apps/e2e/tests/backend/backend-helpers.ts (1)
  • niceBackendFetch (107-165)
apps/backend/src/lib/email-rendering.tsx (1)
packages/stack-shared/src/utils/results.tsx (1)
  • error (36-41)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (1)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
  • useAdminApp (27-34)
apps/backend/src/app/api/latest/emails/render-email/route.tsx (2)
packages/stack-shared/src/schema-fields.ts (3)
  • yupObject (245-249)
  • yupString (185-188)
  • templateThemeIdSchema (477-477)
packages/stack-shared/src/known-errors.tsx (2)
  • KnownErrors (1490-1492)
  • KnownErrors (1494-1609)
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (1)
packages/template/src/lib/stack-app/apps/implementations/common.ts (2)
  • createCache (22-27)
  • useAsyncCache (145-190)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (7)
  • GitHub Check: all-good
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: build (22.x)
  • GitHub Check: build (22.x)
  • GitHub Check: restart-dev-and-test
  • GitHub Check: setup-tests
  • GitHub Check: Security Check
🔇 Additional comments (37)
apps/backend/src/lib/email-rendering.tsx (2)

3-3: Import cleanup LGTM

Using only getEnvVariable is fine; the removal of unused env helpers keeps deps tight.


46-46: Parameter rename and bundler mapping align with draft/content support

templateOrDraftComponent and the /template.tsx mapping make the content source explicit and draft-friendly. Good change.

Also applies to: 71-71

packages/template/src/lib/stack-app/email/index.ts (2)

12-16: Good factoring of common fields into SendEmailOptionsBase.

This keeps the XOR groups clean and reduces duplication.


19-31: XOR-based exclusivity for recipients and content is solid; draftId path looks correct.

  • Recipient XOR: exactly one of userIds or allUsers.
  • Content XOR: exactly one of html, templateId(+variables), or draftId.
packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts (1)

66-66: Adding "email-draft" to chat contextType looks correct.

Matches the backend adapter registry and new draft tooling.

packages/stack-shared/src/interface/admin-interface.ts (2)

155-167: LGTM on updateEmailDraft signature and request wiring.

Accepts nullable sent_at_millis and nullable/false theme_id as expected; consistent with backend routes.


352-352: Extending chat contextType with "email-draft" is consistent with UI/back-end adapters.

No issues found.

apps/dashboard/src/components/vibe-coding/chat-adapters.ts (1)

18-18: Adapter now accepts "email-draft" context — looks good.

Flow remains unchanged; forwards to adminApp.sendChatMessage with the new context type.

apps/backend/src/app/api/latest/emails/render-email/route.tsx (5)

4-4: Good: using schema helpers (union/object/string) from the shared schema module.

Keeps API route validation consistent with the project’s route handler system.


59-71: Template resolution and errors look correct.

  • Accepts direct TSX or resolves by id.
  • Returns 400 for unknown template id; defensive SchemaError for missing content source is fine.

73-73: renderEmailWithTemplate call matches the resolved sources.

Passing contentSource and themeSource aligns with the updated rendering flow.


80-80: Correctly mapping renderer errors into KnownErrors.EmailRenderingError.

Integrates with the standardized error handling model.


47-57: Theme resolution logic confirmed.
getEmailThemeForTemplate correctly handles all inputs:

  • For a valid string ID, returns that theme’s tsxSource.
  • For false, returns emptyEmailTheme.
  • For undefined or null, falls back to the active (default) theme.

No changes required.

apps/e2e/tests/backend/endpoints/api/v1/render-email.test.ts (1)

69-105: Snapshot update aligns with new multi-schema validation shape

The structured SCHEMA_ERROR (with details.message and error using deindent) matches the new union-based validation in the render-email route. Looks good.

apps/backend/src/app/api/latest/emails/send-email/route.tsx (3)

39-50: Body schema union is clear and enforces mutual exclusivity

The three-mode union (html | template_id+variables | draft_id) built from bodyBase reads cleanly and pairs well with the behavioral guard later. Good use of a shared base to keep common fields centralized.


70-73: Good guard: enforce exactly one of user_ids or all_users

This ensures mutually exclusive recipient selection and returns a KnownErrors.SchemaError for invalid combinations. Matches the new e2e coverage.


85-92: Draft flow is correct and respects explicit theme_id

  • Fetches the draft by tenancy/id (with a clear 400 on not found).
  • Derives template theme via themeModeToTemplateThemeId.
  • Only overrides theme when body.theme_id is undefined, preserving explicit caller intent. This is the right precedence.
apps/e2e/tests/js/email.test.ts (1)

180-191: Update to multi-schema error message is correct

The assertion now matches the union-based validation (html | template_id | draft_id). Clear and aligned with backend changes.

apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts (4)

90-91: Good setup fix for shared-config scenario

Creating and switching the project before the “shared email config” test ensures the proper configuration context. This resolves potential flakiness.


376-398: Validation snapshot matches union-based schema

The SCHEMA_ERROR block listing all three schema requirements is accurate and matches the route’s yupUnion enforcement.


437-531: Great coverage for all_users

  • 400 when both selectors present confirms the guard behavior.
  • 200 for all_users exercises the broadcast path and mailbox verification. This will pass once the backend query drops the in: undefined bug.

After merging the backend fix suggested in route.tsx, please re-run this suite to confirm the all_users tests pass consistently.


548-559: Message wording change is consistent with backend

Switching to "No template found with given template_id" aligns with the new error text in the route. Looks good.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page.tsx (1)

1-11: Minimal, correct App Router page

Static metadata and delegating to PageClient is idiomatic. No issues.

apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx (1)

40-40: Accepting "email-draft" looks correct and aligns with the new adapter

The new context_type is validated and will route correctly via getChatAdapter.

apps/backend/src/lib/ai-chat/adapter-registry.ts (1)

5-5: Registering the email-draft adapter is correct

Import and registration are consistent with the expanded union and routing.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx (1)

41-52: No change required — updateEmailTemplate accepts false for “No theme”

I verified the codebase: updateEmailTemplate's themeId parameter is typed as string | null | false, so passing selectedThemeId === false is supported. Evidence:

  • packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts — updateEmailTemplate(..., themeId: string | null | false)
  • packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts — async updateEmailTemplate(..., themeId: string | null | false) (forwards to the interface)
  • packages/stack-shared/src/interface/admin-interface.ts — updateEmailTemplate(..., themeId: string | null | false)
  • Call site: apps/dashboard/src/app/.../page-client.tsx (current call at line ~43)

No coercion to null is necessary; keep the call as-is.

apps/backend/prisma/schema.prisma (2)

689-694: DraftThemeMode enum looks good and matches the intended semantics

Values align with the conversion helpers noted in the PR (inherit/default vs none vs custom).


752-757: Subscription model change appears formatting-only

Field order changes don’t affect Prisma migrations; no behavioral impact expected.

apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx (1)

14-15: No action required: createEmailTemplate and success event match adapter
The dashboard UI’s toolName: "createEmailTemplate" aligns with the emailTemplateAdapter in the backend, and it emits a "success" event carrying the { content } payload. The makeAssistantToolUI hook will trigger correctly.

apps/dashboard/src/components/email-preview.tsx (1)

47-51: Type change to string | undefined | false is consistent with EmailThemeSelector

No runtime changes; this harmonizes the themeId contract across components.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (2)

26-43: Icon updates align with new IA

Using FilePen for Drafts and LayoutTemplate for Templates reads well in the sidebar.


181-187: New “Drafts” nav entry: LGTM

Route, icon, and regex match the drafts index page. No gating is applied; if needed you can wrap with your dev feature flag later.

apps/dashboard/src/components/email-theme-selector.tsx (2)

10-15: Correct null/undefined/false mapping for Select interop

Using JSON.stringify/parse is a pragmatic way to shuttle string | undefined | false through a string-only Select value, and ?? undefined appropriately normalizes null back to undefined while preserving false.


29-37: Option set covers all states (“No theme”, “Project theme”, explicit theme)

The values map cleanly back to the union via JSON.parse. No changes needed.

packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (3)

46-48: Email drafts cache wiring — LGTM

Cache is correctly scoped and uses the existing AsyncCache pattern to hit listInternalEmailDrafts(). No concerns.


477-485: Create draft mutation + cache refresh — LGTM

The mapping of public -> wire fields and post-mutation cache invalidation are correct.


496-503: ContextType alignment verified

“I’ve confirmed that the "email-draft" literal appears in:

  • packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts
  • packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
  • packages/stack-shared/src/interface/admin-interface.ts
  • Front-end chat adapters (apps/dashboard/.../chat-adapters.ts)
  • The email-draft page client (page-client.tsx)
  • Backend adapter registry (apps/backend/src/lib/ai-chat/adapter-registry.ts)
  • API route validation (apps/backend/src/app/api/.../route.tsx)

All layers accept this union member, so no additional changes are needed.

@claude
Copy link
Contributor

claude bot commented Aug 18, 2025

Claude finished @BilalG1's task —— View job


Code Review in Progress

Reviewing PR #849: Email Drafts functionality

Review Tasks:

  • Analyze database schema and migration
  • Review API endpoints and security
  • Check React components and UI patterns
  • Examine AI integration and email rendering
  • Assess test coverage
  • Provide comprehensive feedback

🔍 Overall Assessment

This is a well-implemented feature that adds email draft functionality with AI assistance. The code generally follows good practices, but there are several areas for improvement.

✅ What's Working Well

Database Design:

  • Clean schema with proper UUID keys and constraints
  • Good separation of theme modes (PROJECT_DEFAULT, NONE, CUSTOM)
  • Proper foreign key relationships and cascading deletes

Test Coverage:

  • Comprehensive E2E tests covering various scenarios
  • Good error handling test cases
  • Tests for invalid data, missing users, disabled notifications

Architecture:

  • Clear separation between API routes, business logic, and UI
  • Consistent use of smart route handlers
  • Proper validation schemas using Yup

⚠️ Areas for Improvement

1. Security Concerns

Code Injection Risk (apps/backend/src/lib/ai-chat/email-draft-adapter.ts:16):

parameters: z.object({
  content: z.string().describe("A react component that renders the email template"),
}),

The AI can generate arbitrary React components that get executed. Consider:

  • Implementing a TSX sanitizer/validator
  • Using a whitelist of allowed imports/components
  • Sandboxing the email rendering process

Missing Input Validation (apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx:68-70):

themeMode: templateThemeIdToThemeMode(body.theme_id),
themeId: body.theme_id === false ? null : body.theme_id,

This unconditionally updates theme fields even when not provided in PATCH requests, potentially overwriting existing data.

2. Code Quality Issues

Bug in User Query (apps/backend/src/app/api/latest/emails/send-email/route.tsx:96-106):

const users = await prisma.projectUser.findMany({
  where: {
    tenancyId: auth.tenancy.id,
    projectUserId: {
      in: body.user_ids  // This will be undefined when all_users is true
    },
  },

When all_users is true, body.user_ids is undefined, causing the query to fail.

Error Handling (apps/backend/src/app/api/latest/emails/send-email/route.tsx:193-195):

} catch {
  userSendErrors.set(user.projectUserId, "Failed to send email");
}

Generic error handling loses important debugging information. Should log the actual error.

Unused Error Collection:
The code collects userSendErrors but never returns them to the client, making it impossible to distinguish successful from failed email sends.

3. Potential Bugs

State Initialization Issue (apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx:23-25):

const [currentCode, setCurrentCode] = useState<string>(draft?.tsxSource ?? "");
const [selectedThemeId, setSelectedThemeId] = useState<string | undefined | false>(draft?.themeId);

If the draft loads after component mount, these states remain empty, leading to potential data loss.

4. User Experience Issues

No Confirmation for Destructive Actions: The UI allows sending emails immediately without confirmation dialogs for bulk operations.

Limited Error Feedback: Generic error messages don't help users understand what went wrong.

🚀 Recommended Improvements

  1. Add TSX validation/sanitization for AI-generated components
  2. Fix the user query bug for all_users mode
  3. Implement proper PATCH semantics to avoid overwriting unchanged fields
  4. Return per-user error details in the API response
  5. Add confirmation dialogs for bulk email operations
  6. Initialize component state properly when draft data loads asynchronously
  7. Consider rate limiting for email sending operations

📊 Test Coverage Assessment

Excellent test coverage with comprehensive E2E tests covering:

  • Invalid requests and authentication
  • Validation errors
  • All user scenarios
  • Template-based emails
  • Notification categories

The tests are well-structured and cover both happy paths and error cases effectively.


Despite the areas for improvement, this is a solid implementation of email drafts functionality with good architecture and comprehensive testing. The security concerns around AI-generated code should be addressed before merging to production.

@BilalG1 BilalG1 assigned N2D4 and unassigned BilalG1 Aug 19, 2025
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🔭 Outside diff range comments (1)
apps/backend/src/app/api/latest/emails/send-email/route.tsx (1)

57-60: Update response schema to include error field.

The response schema should be updated to match the suggested UserResult type changes for proper API documentation.

      results: yupArray(yupObject({
        user_id: yupString().defined(),
        user_email: yupString().optional(),
+       error: yupString().optional(),
      })).defined(),
♻️ Duplicate comments (4)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1)

27-35: Unsaved-changes guard can remain stuck; compute “dirty” and tie to stage

The effect early-returns without resetting the confirm flag, which can leave confirmation enabled after save or stage change. Prior review suggested deriving a “dirty” flag and setting needConfirm deterministically.

Apply this diff:

-useEffect(() => {
-  if (!draft) return;
-  if (draft.tsxSource === currentCode && draft.themeId === selectedThemeId) return;
-  if (stage !== "edit") return;
-
-  setNeedConfirm(true);
-  return () => setNeedConfirm(false);
-}, [setNeedConfirm, draft, currentCode, selectedThemeId, stage]);
+useEffect(() => {
+  const isDirty = !!draft && (draft.tsxSource !== currentCode || draft.themeId !== selectedThemeId);
+  setNeedConfirm(stage === "edit" && isDirty);
+  return () => setNeedConfirm(false);
+}, [setNeedConfirm, draft, currentCode, selectedThemeId, stage]);
apps/backend/src/app/api/latest/emails/send-email/route.tsx (3)

70-72: Fix the database query to properly handle the all_users case.

When all_users is true, body.user_ids is undefined, but the query still tries to filter by it. This will cause the query to return no users.

Apply this fix to handle both cases correctly:

    const users = await prisma.projectUser.findMany({
      where: {
        tenancyId: auth.tenancy.id,
-       projectUserId: {
-         in: body.user_ids
-       },
+       ...(body.user_ids && {
+         projectUserId: {
+           in: body.user_ids
+         },
+       }),
      },
      include: {
        contactChannels: true,
      },
    });

14-17: Expose per-user errors in the API response.

The function collects userSendErrors but never surfaces them to the caller. This makes it impossible to distinguish between successful and failed recipients in partial failure scenarios.

Update the type to include errors:

 type UserResult = {
   user_id: string,
   user_email?: string,
+  error?: string,
 };

198-201: Include per-user errors in the response.

Complete the error reporting by including the captured errors in the results.

    const results: UserResult[] = Array.from(userMap.values()).map((user) => ({
      user_id: user.projectUserId,
      user_email: userPrimaryEmails.get(user.projectUserId) ?? user.contactChannels.find((c) => c.isPrimary === "TRUE")?.value,
+     error: userSendErrors.get(user.projectUserId),
    }));
🧹 Nitpick comments (3)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (3)

61-73: Disable Next until loaded/dirty and show saving state

Prevents accidental overwrite and double clicks.

Apply this diff:

-  <Button onClick={handleNext}>Next</Button>
+  <Button
+    onClick={handleNext}
+    disabled={!draft || !initializedFromDraft || saving || !dirty}
+  >
+    {saving ? "Saving..." : "Next"}
+  </Button>

Optionally, render a lightweight loading state while the draft is loading to avoid showing an empty editor:

if (!draft) {
  return (
    <div className="p-4">
      <Skeleton className="h-64" />
    </div>
  );
}

36-38: Confirm intended UX: auto-applying tool results may clobber in-progress edits

handleToolUpdate applies tool output immediately, while EmailDraftUI also exposes an “apply” button. This dual behavior can overwrite user edits without confirmation.

If the intent is manual application only, consider no-op’ing onToolCall and relying on the Tool UI:

-const handleToolUpdate = (toolCall: ToolCallContent) => {
-  setCurrentCode(toolCall.args.content);
-};
+const handleToolUpdate = () => {};

Or gate auto-apply behind a confirmation or only when not dirty.

Also applies to: 77-81


96-111: Avoid duplicate sends: add isSending gate

The Send button is clickable repeatedly; add an isSending flag and disable while in-flight.

Apply this diff:

 function SendStage({ draftId }: { draftId: string }) {
   const stackAdminApp = useAdminApp();
   const [scope, setScope] = useState<"all" | "users">("all");
   const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
+  const [isSending, setIsSending] = useState(false);

   const handleSubmit = async () => {
-    const result = await stackAdminApp.sendEmail(
+    setIsSending(true);
+    try {
+      const result = await stackAdminApp.sendEmail(
       scope === "users"
         ? { draftId, userIds: selectedUserIds }
         : { draftId, allUsers: true }
     );
     if (result.status === "ok") {
       toast({ title: "Email sent", variant: "success" });
       return;
     }
     if (result.error instanceof KnownErrors.RequiresCustomEmailServer) {
       toast({ title: "Action requires custom email server", variant: "destructive", description: "Please setup a custom email server and try again." });
     } else {
       toast({ title: "Failed to send email", variant: "destructive", description: "Unknown error" });
     }
+    } finally {
+      setIsSending(false);
+    }
   };
-            <Button
-              disabled={scope === "users" && selectedUserIds.length === 0}
-              onClick={handleSubmit}
-            >
-              Send
-            </Button>
+            <Button
+              disabled={isSending || (scope === "users" && selectedUserIds.length === 0)}
+              onClick={handleSubmit}
+            >
+              {isSending ? "Sending..." : "Send"}
+            </Button>

Also applies to: 157-163

📜 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 7461754 and e654986.

📒 Files selected for processing (5)
  • apps/backend/src/app/api/latest/emails/send-email/route.tsx (4 hunks)
  • apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx (1 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1 hunks)
  • packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (4 hunks)
  • packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts
  • apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx
  • packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
🧰 Additional context used
📓 Path-based instructions (1)
apps/backend/src/app/api/latest/**/*

📄 CodeRabbit Inference Engine (CLAUDE.md)

apps/backend/src/app/api/latest/**/*: Main API routes are located in /apps/backend/src/app/api/latest
The project uses a custom route handler system in the backend for consistent API responses

Files:

  • apps/backend/src/app/api/latest/emails/send-email/route.tsx
🧬 Code Graph Analysis (2)
apps/backend/src/app/api/latest/emails/send-email/route.tsx (5)
packages/stack-shared/src/schema-fields.ts (10)
  • yupObject (245-249)
  • yupArray (211-214)
  • yupString (185-188)
  • yupBoolean (193-196)
  • templateThemeIdSchema (477-477)
  • serverOrHigherAuthTypeSchema (429-429)
  • adaptSchema (328-328)
  • yupUnion (255-279)
  • yupRecord (281-320)
  • yupMixed (208-210)
apps/backend/src/prisma-client.tsx (1)
  • getPrismaClientForTenancy (51-53)
apps/backend/src/lib/notification-categories.ts (2)
  • getNotificationCategoryByName (25-27)
  • hasNotificationEnabled (29-48)
apps/backend/src/lib/email-rendering.tsx (3)
  • getEmailThemeForTemplate (23-32)
  • createTemplateComponentFromHtml (34-43)
  • renderEmailWithTemplate (45-129)
apps/backend/src/lib/email-drafts.tsx (2)
  • getEmailDraft (3-13)
  • themeModeToTemplateThemeId (25-33)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (9)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
  • useAdminApp (27-34)
packages/stack-ui/src/components/ui/use-toast.tsx (2)
  • useToast (195-195)
  • toast (195-195)
apps/dashboard/src/components/vibe-coding/chat-adapters.ts (3)
  • ToolCallContent (9-9)
  • createHistoryAdapter (63-76)
  • createChatAdapter (15-61)
apps/dashboard/src/components/vibe-coding/vibe-code-layout.tsx (1)
  • VibeCodeLayout (13-37)
apps/dashboard/src/components/email-preview.tsx (1)
  • EmailPreview (91-122)
apps/dashboard/src/components/vibe-coding/code-editor.tsx (1)
  • CodeEditor (15-144)
apps/dashboard/src/components/email-theme-selector.tsx (1)
  • EmailThemeSelector (17-39)
apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx (1)
  • EmailDraftUI (9-28)
apps/dashboard/src/components/data-table/team-member-search-table.tsx (1)
  • TeamMemberSearchTable (9-68)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (9)
  • GitHub Check: Security Check
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: all-good
  • GitHub Check: setup-tests
  • GitHub Check: build (22.x)
  • GitHub Check: restart-dev-and-test
  • GitHub Check: build (22.x)
  • GitHub Check: docker
  • GitHub Check: docker
🔇 Additional comments (2)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1)

10-10: Confirmed: toast Is Exported by the Package

I checked use-toast.tsx and saw:

  • export { useToast, toast }; in packages/stack-ui/src/components/ui/use-toast.tsx
  • The root index.ts includes export * from "./components/ui/use-toast";

Therefore, toast is available from "@stackframe/stack-ui", and the existing imports are correct. No changes are needed here.

apps/backend/src/app/api/latest/emails/send-email/route.tsx (1)

85-94: LGTM! Clean implementation of draft support.

The draft integration is well-structured:

  • Properly retrieves the draft using the helper function
  • Correctly resolves theme based on draft settings
  • Falls back to body.theme_id when provided
  • Has appropriate error handling for missing drafts

@claude
Copy link
Contributor

claude bot commented Aug 19, 2025

Claude finished @BilalG1's task —— View job


I'll analyze this and get back to you.

@claude
Copy link
Contributor

claude bot commented Aug 19, 2025

Claude encountered an error —— View job


I'll analyze this and get back to you.

@BilalG1 BilalG1 assigned N2D4 and unassigned BilalG1 Aug 26, 2025
@BilalG1 BilalG1 requested a review from N2D4 August 26, 2025 17:55
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (2)
packages/stack-shared/src/interface/admin-interface.ts (2)

134-138: Standardize theme_id type; remove redundant | undefined.

Align with prior feedback and the rest of the file: use string | null | false and drop the redundant | undefined.

-  async listInternalEmailDrafts(): Promise<{ id: string, display_name: string, theme_id?: string | undefined | false, tsx_source: string, sent_at_millis?: number | null }[]> {
+  async listInternalEmailDrafts(): Promise<{ id: string, display_name: string, theme_id?: string | null | false, tsx_source: string, sent_at_millis?: number | null }[]> {
     const response = await this.sendAdminRequest(`/internal/email-drafts`, {}, null);
-    const result = await response.json() as { drafts: { id: string, display_name: string, theme_id?: string | undefined | false, tsx_source: string, sent_at_millis?: number | null }[] };
+    const result = await response.json() as { drafts: { id: string, display_name: string, theme_id?: string | null | false, tsx_source: string, sent_at_millis?: number | null }[] };
     return result.drafts;
   }

Optional: introduce a named type (e.g., EmailDraftListItem) to avoid duplicating this inline shape in both places.


140-153: Make display name required and use camelCase options with snake_case payload mapping.

  • Make display_name required (as per backend) — incorporates earlier feedback.
  • Keep union consistent: themeId?: string | null | false.
  • Match surrounding style: accept camelCase in the TS API and map to snake_case for the request body.
-  async createEmailDraft(options: { display_name?: string, theme_id?: string | false, tsx_source?: string }): Promise<{ id: string }> {
+  async createEmailDraft(options: { displayName: string, themeId?: string | null | false, tsxSource?: string }): Promise<{ id: string }> {
     const response = await this.sendAdminRequest(
       `/internal/email-drafts`,
       {
         method: "POST",
         headers: {
           "content-type": "application/json",
         },
-        body: JSON.stringify(options),
+        body: JSON.stringify({
+          display_name: options.displayName,
+          theme_id: options.themeId,
+          tsx_source: options.tsxSource,
+        }),
       },
       null,
     );
     return await response.json();
   }
#!/bin/bash
# Verify callsites to update for the new camelCase options and required displayName.
rg -nP -C2 '\bcreateEmailDraft\s*\('

# Confirm backend accepts `null` for project-default theme and the fields we send.
rg -nP -C3 'email-drafts|DraftThemeMode|theme_id|display_name|tsx_source' 
🧹 Nitpick comments (1)
packages/stack-shared/src/interface/admin-interface.ts (1)

155-168: Use camelCase for the TS API surface and map to snake_case in updateEmailDraft; backend already supports sent_at_millis

  • In packages/stack-shared/src/interface/admin-interface.ts:
    • Change the method signature to

    async updateEmailDraft(
      id: string,
      data: {
        displayName?: string;
        themeId?: string | null | false;
        tsxSource?: string;
        sentAtMillis?: number | null;
      }
    ): Promise<void>

    • Replace JSON.stringify(data) with an explicit mapping to snake_case keys in the request body.

  • In packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts:
    • Update the updateEmailDraft signature to match the new camelCase props (and include sentAtMillis?: number | null if needed).

  • In packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts:
    • Remove the manual snake_case mapping and simply forward your camelCase props to the shared interface:

    await this._interface.updateEmailDraft(id, {
      displayName: data.displayName,
      themeId: data.themeId,
      tsxSource: data.tsxSource,
      // include sentAtMillis if applicable
    });
  • Confirmed via the backend PATCH handler (apps/backend/src/app/api/latest/internal/email-drafts/[id]/route.tsx) that sent_at_millis is declared optional in the Yup schema and mapped in the handler, so the API supports overriding the timestamp.

Optional refactor diff for admin-interface.ts:

--- a/packages/stack-shared/src/interface/admin-interface.ts
+++ b/packages/stack-shared/src/interface/admin-interface.ts
@@ -155,7 +155,14 @@
-  async updateEmailDraft(id: string, data: { display_name?: string, theme_id?: string | null | false, tsx_source?: string, sent_at_millis?: number | null }): Promise<void> {
-    await this.sendAdminRequest(
-      `/internal/email-drafts/${id}`,
-      {
-        method: "PATCH",
-        headers: { "content-type": "application/json" },
-        body: JSON.stringify(data),
-      },
-      null,
-    );
-  }
+  async updateEmailDraft(
+    id: string,
+    data: {
+      displayName?: string;
+      themeId?: string | null | false;
+      tsxSource?: string;
+      sentAtMillis?: number | null;
+    }
+  ): Promise<void> {
+    await this.sendAdminRequest(
+      `/internal/email-drafts/${id}`,
+      {
+        method: "PATCH",
+        headers: { "content-type": "application/json" },
+        body: JSON.stringify({
+          display_name: data.displayName,
+          theme_id: data.themeId,
+          tsx_source: data.tsxSource,
+          sent_at_millis: data.sentAtMillis,
+        }),
+      },
+      null,
+    );
+  }
📜 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 c7985ff and 8fc939d.

📒 Files selected for processing (5)
  • apps/backend/prisma/schema.prisma (1 hunks)
  • packages/stack-shared/src/interface/admin-interface.ts (2 hunks)
  • packages/stack-shared/src/utils/types.tsx (1 hunks)
  • packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (4 hunks)
  • packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/stack-shared/src/utils/types.tsx
  • apps/backend/prisma/schema.prisma
  • packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
  • packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Prefer ES6 Map over Record where feasible

Files:

  • packages/stack-shared/src/interface/admin-interface.ts
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (9)
  • GitHub Check: all-good
  • GitHub Check: setup-tests
  • GitHub Check: docker
  • GitHub Check: docker
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: restart-dev-and-test
  • GitHub Check: build (22.x)
  • GitHub Check: build (22.x)
  • GitHub Check: Security Check
🔇 Additional comments (1)
packages/stack-shared/src/interface/admin-interface.ts (1)

369-369: Adding "email-draft" context to chat: LGTM.

Union extension is consistent with existing values.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (1)
examples/supabase/package.json (1)

9-9: Standardize next start scripts to use -p and respect PORT env across the monorepo

Our search found that nearly every package.json in examples/, apps/, and docs/ hardcodes a port via --port <number> rather than the shorter -p flag, and none honor the PORT environment variable.
To improve consistency and PaaS compatibility, we should:

  • Prefer the short flag -p (as documented in the Next.js CLI).
  • Use PORT=${PORT:-<fallback>} so deployers can inject their port at runtime.
  • Apply this change to all affected package.json start scripts.

Affected files include (but may not be limited to):

• examples/supabase/package.json
• examples/middleware/package.json
• examples/partial-prerendering/package.json
• examples/docs-examples/package.json
• examples/demo/package.json
• examples/e-commerce/package.json
• examples/cjs-test/package.json
• apps/dashboard/package.json
• apps/backend/package.json
• docs/package.json

For example, in examples/supabase/package.json:

-    "start": "next start --port 8115"
+    "start": "PORT=${PORT:-8115} next start -p \"$PORT\""

Please apply a similar pattern in each start script to ensure consistency and avoid hardcoding ports.
For Windows environments, you may need to use cross-env or maintain separate start:local scripts as appropriate.

📜 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 8fc939d and 76c45cf.

📒 Files selected for processing (1)
  • examples/supabase/package.json (1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (9)
  • GitHub Check: build (22.x)
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: setup-tests
  • GitHub Check: restart-dev-and-test
  • GitHub Check: all-good
  • GitHub Check: docker
  • GitHub Check: build (22.x)
  • GitHub Check: docker
  • GitHub Check: Security Check

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (2)
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (2)

493-501: Let create accept null to intentionally select project theme; coalesce undefined → null

Prevents accidental “unset” vs “project theme” ambiguity and matches backend.

Apply:

-  async createEmailDraft(options: { displayName: string, themeId?: string | false, tsxSource?: string }): Promise<{ id: string }> {
+  async createEmailDraft(options: { displayName: string, themeId?: string | null | false, tsxSource?: string }): Promise<{ id: string }> {
     const result = await this._interface.createEmailDraft({
       display_name: options.displayName,
-      theme_id: options.themeId,
+      // use null for “project theme”; preserve false/string
+      theme_id: options.themeId ?? null,
       tsx_source: options.tsxSource,
     });
     await this._adminEmailDraftsCache.refresh([]);
     return result;
   }

503-510: Standardize update to use string | null | false; don’t send theme_id when absent

Removes undefined from the union, uses null for “project theme”, and avoids overwriting when the field is omitted.

Apply:

-  async updateEmailDraft(id: string, data: { displayName?: string, themeId?: string | undefined | false, tsxSource?: string }): Promise<void> {
-    await this._interface.updateEmailDraft(id, {
-      display_name: data.displayName,
-      theme_id: data.themeId,
-      tsx_source: data.tsxSource,
-    });
+  async updateEmailDraft(id: string, data: { displayName?: string, themeId?: string | null | false, tsxSource?: string }): Promise<void> {
+    const payload: {
+      display_name?: string;
+      tsx_source?: string;
+      theme_id?: string | null | false;
+    } = {
+      display_name: data.displayName,
+      tsx_source: data.tsxSource,
+    };
+    // Only include when provided; use null to explicitly select project theme
+    if (Object.prototype.hasOwnProperty.call(data, "themeId")) {
+      payload.theme_id = data.themeId ?? null;
+    }
+    await this._interface.updateEmailDraft(id, payload);
     await this._adminEmailDraftsCache.refresh([]);
   }

Run to spot remaining undefined unions for themeId across the repo:

#!/bin/bash
set -euo pipefail

echo "Occurrences of themeId typed with undefined|false:"
rg -nP --type ts --type tsx -C2 '\bthemeId\?\s*:\s*string\s*\|\s*undefined\s*\|\s*false'

echo
echo "Occurrences of drafts returning themeId with undefined:"
rg -nP --type ts --type tsx -C2 'listEmailDrafts\(\).*themeId: string \| undefined \| false|useEmailDrafts\(\).*themeId: string \| undefined \| false'

echo
echo "Occurrences already using null (for reference):"
rg -nP --type ts --type tsx -C2 '\bthemeId\?\s*:\s*string\s*\|\s*null\s*\|\s*false'
📜 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 76c45cf and 856c0c5.

📒 Files selected for processing (3)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (5 hunks)
  • packages/stack-shared/src/interface/admin-interface.ts (2 hunks)
  • packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (4 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/stack-shared/src/interface/admin-interface.ts
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Prefer ES6 Map over Record where feasible

Files:

  • packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
🧬 Code graph analysis (1)
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (1)
packages/template/src/lib/stack-app/apps/implementations/common.ts (2)
  • createCache (22-27)
  • useAsyncCache (145-190)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (9)
  • GitHub Check: build (22.x)
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: restart-dev-and-test
  • GitHub Check: setup-tests
  • GitHub Check: docker
  • GitHub Check: all-good
  • GitHub Check: build (22.x)
  • GitHub Check: docker
  • GitHub Check: Security Check
🔇 Additional comments (2)
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (2)

45-47: Drafts cache wiring looks good

Cache creation aligns with existing patterns and uses the correct interface method.


514-514: Add “email-draft” to chat context — LGTM

Matches new draft workflows and keeps typing aligned.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (1)
apps/backend/prisma/schema.prisma (1)

676-691: Add DB-level CHECK to enforce themeMode/themeId consistency

Mirror the business rule (CUSTOM => themeId IS NOT NULL; PROJECT_DEFAULT/NONE => themeId IS NULL) with a PostgreSQL CHECK in the migration to prevent inconsistent rows. Also keep app-layer validation.

Example migration snippet:

ALTER TABLE "EmailDraft"
ADD CONSTRAINT "emaildraft_theme_consistency" CHECK (
  ("themeMode" = 'CUSTOM' AND "themeId" IS NOT NULL)
  OR ("themeMode" IN ('PROJECT_DEFAULT','NONE') AND "themeId" IS NULL)
);
🧹 Nitpick comments (2)
apps/backend/prisma/schema.prisma (1)

676-691: Add helpful indexes for common queries (list/sort/filter)

If you list drafts per tenancy ordered by recency or filter by sent/unsent, these indexes help.

 model EmailDraft {
   tenancyId String @db.Uuid

   id String @default(uuid()) @db.Uuid

   displayName String
   themeMode   DraftThemeMode @default(PROJECT_DEFAULT)
   themeId     String?
   tsxSource   String
   sentAt      DateTime?

   createdAt DateTime @default(now())
   updatedAt DateTime @updatedAt

-  @@id([tenancyId, id])
+  @@id([tenancyId, id])
+  @@index([tenancyId, updatedAt(sort: Desc)], name: "EmailDraft_updatedAt_desc")
+  @@index([tenancyId, sentAt], name: "EmailDraft_sentAt")
 }
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (1)

203-222: Prevent blank breadcrumb when draft isn’t loaded

If useEmailDrafts() hasn’t resolved or draft is missing, the crumb renders empty. Fall back to “Draft”.

 function DraftBreadcrumbItem(props: { draftId: string }) {
   const stackAdminApp = useAdminApp();
   const drafts = stackAdminApp.useEmailDrafts();
   const draft = drafts.find((d) => d.id === props.draftId);
-  if (!draft) {
-    return null;
-  }
-  return draft.displayName;
+  return draft?.displayName ?? "Draft";
 }

Also applies to: 365-373

📜 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 856c0c5 and fa1bc34.

📒 Files selected for processing (6)
  • apps/backend/prisma/schema.prisma (1 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (5 hunks)
  • examples/supabase/package.json (1 hunks)
  • packages/stack-shared/src/interface/admin-interface.ts (2 hunks)
  • packages/stack-shared/src/interface/server-interface.ts (2 hunks)
  • packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (4 hunks)
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/stack-shared/src/interface/server-interface.ts
  • packages/stack-shared/src/interface/admin-interface.ts
  • packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
  • examples/supabase/package.json
🧰 Additional context used
📓 Path-based instructions (3)
{apps/dashboard,apps/dev-launchpad,packages/stack-ui,packages/react}/**/*.{tsx,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

For blocking alerts and errors in UI, do not use toast notifications; use alerts instead

Files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

Prefer ES6 Map over Record when representing key–value collections

Files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx
{apps/dashboard,apps/dev-launchpad,packages/stack-ui,packages/react}/**/*.{tsx,jsx,css}

📄 CodeRabbit inference engine (AGENTS.md)

Keep hover/click animations snappy; avoid pre-transition delays on hover and apply transitions after the action (e.g., fade-out on hover end)

Files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx
🧬 Code graph analysis (1)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (1)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
  • useAdminApp (27-34)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (9)
  • GitHub Check: setup-tests
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: build (22.x)
  • GitHub Check: restart-dev-and-test
  • GitHub Check: all-good
  • GitHub Check: docker
  • GitHub Check: build (22.x)
  • GitHub Check: docker
  • GitHub Check: Security Check
🔇 Additional comments (5)
apps/backend/prisma/schema.prisma (2)

693-697: Enum looks good

Default(PROJECT_DEFAULT) matches the intended behavior.


683-683: Drop UUID annotation suggestion for themeId
themeId references themes in tenancy.config.emails.themes (not a Prisma model), so keeping it as String? is correct.

Likely an incorrect or invalid review comment.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (3)

27-27: Icon import updates are correct

New icons are referenced below; imports are consistent.

Also applies to: 30-30, 42-42


182-188: Sidebar: Add “Drafts” under Emails — LGTM

Route/regex and icon are consistent with existing items.


193-193: Templates icon swap — LGTM

LayoutTemplate is clearer for templates.

@N2D4 N2D4 assigned BilalG1 and unassigned N2D4 Sep 10, 2025
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (1)
apps/e2e/tests/js/email.test.ts (1)

165-171: Pragmatic cast to any to exercise XOR runtime check — OK; add lint guard to avoid CI noise.

The as any cast is appropriate here to bypass TS XOR types and assert runtime SchemaError. If your ESLint rules forbid explicit any, add a one-line suppression.

Apply this minimal diff:

   await expect(serverApp.sendEmail({
     userIds: [user.id],
     html: "<p>Test Email</p>",
     templateId: DEFAULT_TEMPLATE_IDS.sign_in_invitation,
     subject: "Test Email",
-  } as any)).rejects.toThrow(KnownErrors.SchemaError);
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  } as any)).rejects.toThrow(KnownErrors.SchemaError);

Optional: if you prefer avoiding inline disables, introduce a tiny test helper once and reuse:

// test-utils/assert.ts
export const unsafe = <T>(v: unknown) => v as any as T;

Then call with unsafe<SomeSendEmailInput>({ ...invalidPayload }).

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fa1bc34 and 9594392.

📒 Files selected for processing (7)
  • apps/backend/prisma/schema.prisma (1 hunks)
  • apps/backend/src/lib/email-rendering.tsx (3 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx (5 hunks)
  • apps/e2e/tests/js/email.test.ts (1 hunks)
  • examples/supabase/package.json (1 hunks)
  • packages/stack-shared/src/interface/server-interface.ts (2 hunks)
  • packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (4 hunks)
🚧 Files skipped from review as they are similar to previous changes (6)
  • apps/backend/prisma/schema.prisma
  • apps/backend/src/lib/email-rendering.tsx
  • examples/supabase/package.json
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx
  • packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
  • packages/stack-shared/src/interface/server-interface.ts
🧰 Additional context used
📓 Path-based instructions (2)
**/*.test.{ts,tsx,js}

📄 CodeRabbit inference engine (AGENTS.md)

In tests, prefer .toMatchInlineSnapshot where possible; refer to snapshot-serializer.ts for snapshot formatting and handling of non-deterministic values

Files:

  • apps/e2e/tests/js/email.test.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

Prefer ES6 Map over Record when representing key–value collections

Files:

  • apps/e2e/tests/js/email.test.ts
🧬 Code graph analysis (1)
apps/e2e/tests/js/email.test.ts (1)
packages/stack-shared/src/known-errors.tsx (2)
  • KnownErrors (1570-1572)
  • KnownErrors (1574-1696)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (9)
  • GitHub Check: setup-tests
  • GitHub Check: build (22.x)
  • GitHub Check: build (22.x)
  • GitHub Check: docker
  • GitHub Check: docker
  • GitHub Check: restart-dev-and-test
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: all-good
  • GitHub Check: Security Check

@BilalG1 BilalG1 enabled auto-merge (squash) September 10, 2025 22:42
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

♻️ Duplicate comments (3)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (3)

27-34: Make unsaved-changes guard reflect “edit + dirty” only

Avoid sticky confirms by deriving from dirty and stage.

-useEffect(() => {
-  if (!draft) return;
-  if (draft.tsxSource === currentCode && draft.themeId === selectedThemeId) return;
-  if (stage !== "edit") return;
-
-  setNeedConfirm(true);
-  return () => setNeedConfirm(false);
-}, [setNeedConfirm, draft, currentCode, selectedThemeId, stage]);
+useEffect(() => {
+  setNeedConfirm(stage === "edit" && !!dirty);
+  return () => setNeedConfirm(false);
+}, [setNeedConfirm, stage, dirty]);

23-26: Fix async hydration and themeId null typing to prevent data loss

currentCode/selectedThemeId are only initialized at mount; if the draft arrives later, clicking Next can overwrite server content with empty code. Also, EmailThemeSelector returns null (“Project theme”) but the state excludes null, causing type/logic mismatches. Add one-time hydration when draft loads, include null in the union, and introduce saving/saveError + a derived dirty flag.

-  const [currentCode, setCurrentCode] = useState<string>(draft?.tsxSource ?? "");
-  const [stage, setStage] = useState<"edit" | "send">("edit");
-  const [selectedThemeId, setSelectedThemeId] = useState<string | undefined | false>(draft?.themeId);
+  const [currentCode, setCurrentCode] = useState<string>(draft?.tsxSource ?? "");
+  const [stage, setStage] = useState<"edit" | "send">("edit");
+  // Allow null (project theme), false (no theme), undefined (uninitialized)
+  const [selectedThemeId, setSelectedThemeId] = useState<string | null | false | undefined>(draft?.themeId);
+  const [initializedFromDraft, setInitializedFromDraft] = useState(false);
+  const [saving, setSaving] = useState(false);
+  const [saveError, setSaveError] = useState<string | null>(null);
+
+  // hydrate once when the draft becomes available to avoid overwriting server content
+  useEffect(() => {
+    if (!draft || initializedFromDraft) return;
+    setCurrentCode(draft.tsxSource);
+    setSelectedThemeId(draft.themeId);
+    setInitializedFromDraft(true);
+  }, [draft, initializedFromDraft]);
+
+  // local edits vs server draft
+  const dirty = useMemo(
+    () => !!draft && (currentCode !== draft.tsxSource || selectedThemeId !== draft.themeId),
+    [draft, currentCode, selectedThemeId]
+  );

40-51: Guard save before load, prevent double submits, and use inline alerts (no toasts) for blocking errors

  • If draft hasn’t loaded, bail.
  • Prevent duplicate submissions with a saving flag.
  • Per dashboard UI guideline, show blocking errors as alerts, not toasts.
-const handleNext = async () => {
-  try {
-    await stackAdminApp.updateEmailDraft(draftId, { tsxSource: currentCode, themeId: selectedThemeId });
-    setStage("send");
-  } catch (error) {
-    if (error instanceof KnownErrors.EmailRenderingError) {
-      toast({ title: "Failed to save draft", variant: "destructive", description: error.message });
-      return;
-    }
-    toast({ title: "Failed to save draft", variant: "destructive", description: "Unknown error" });
-  }
-};
+const handleNext = async () => {
+  if (!draft || saving) return;
+  setSaveError(null);
+  setSaving(true);
+  try {
+    await stackAdminApp.updateEmailDraft(draftId, { tsxSource: currentCode, themeId: selectedThemeId });
+    setStage("send");
+  } catch (error) {
+    if (error instanceof KnownErrors.EmailRenderingError) {
+      setSaveError(error.message);
+      return;
+    }
+    setSaveError("Failed to save draft due to an unknown error.");
+  } finally {
+    setSaving(false);
+  }
+};
🧹 Nitpick comments (1)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1)

10-10: Clean up toast usage and imports per guideline

  • Add Alert for inline error display.
  • Drop useToast to avoid shadowing the imported toast and unused var after switching to alerts here.
-import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Skeleton, toast, Typography, useToast } from "@stackframe/stack-ui";
+import { Alert, Badge, Button, Card, CardContent, CardHeader, CardTitle, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Skeleton, toast, Typography } from "@stackframe/stack-ui";
-  const { toast } = useToast();
+  // switched to inline <Alert> for blocking errors

Also applies to: 18-18

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9594392 and c7ebb00.

📒 Files selected for processing (1)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (1 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
{apps/dashboard,apps/dev-launchpad,packages/stack-ui,packages/react}/**/*.{tsx,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

For blocking alerts and errors in UI, do not use toast notifications; use alerts instead

Files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

Prefer ES6 Map over Record when representing key–value collections

Files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx
{apps/dashboard,apps/dev-launchpad,packages/stack-ui,packages/react}/**/*.{tsx,jsx,css}

📄 CodeRabbit inference engine (AGENTS.md)

Keep hover/click animations snappy; avoid pre-transition delays on hover and apply transitions after the action (e.g., fade-out on hover end)

Files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx
🧬 Code graph analysis (1)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (9)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
  • useAdminApp (27-34)
apps/dashboard/src/components/router.tsx (1)
  • useRouterConfirm (35-37)
apps/dashboard/src/components/vibe-coding/chat-adapters.ts (3)
  • ToolCallContent (9-9)
  • createHistoryAdapter (63-76)
  • createChatAdapter (15-61)
apps/dashboard/src/components/vibe-coding/vibe-code-layout.tsx (1)
  • VibeCodeLayout (13-37)
apps/dashboard/src/components/email-preview.tsx (1)
  • EmailPreview (91-122)
apps/dashboard/src/components/vibe-coding/code-editor.tsx (1)
  • CodeEditor (15-144)
apps/dashboard/src/components/email-theme-selector.tsx (1)
  • EmailThemeSelector (17-39)
apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx (1)
  • EmailDraftUI (9-28)
apps/dashboard/src/components/data-table/team-member-search-table.tsx (1)
  • TeamMemberSearchTable (9-68)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (9)
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: setup-tests
  • GitHub Check: restart-dev-and-test
  • GitHub Check: docker
  • GitHub Check: all-good
  • GitHub Check: docker
  • GitHub Check: build (22.x)
  • GitHub Check: build (22.x)
  • GitHub Check: Security Check

@BilalG1 BilalG1 merged commit 5da45d8 into dev Sep 10, 2025
26 checks passed
@BilalG1 BilalG1 deleted the email-drafts branch September 10, 2025 22:57
@coderabbitai coderabbitai bot mentioned this pull request Oct 16, 2025
@coderabbitai coderabbitai bot mentioned this pull request Oct 28, 2025
@coderabbitai coderabbitai bot mentioned this pull request Nov 19, 2025
@coderabbitai coderabbitai bot mentioned this pull request Dec 11, 2025
@coderabbitai coderabbitai bot mentioned this pull request Jan 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants