Skip to content

Conversation

@marcrupt
Copy link
Collaborator

@marcrupt marcrupt commented Nov 12, 2025

Summary by CodeRabbit

  • New Features

    • Domain management with DNS MX verification and unique records; domains UI.
    • Full API key CRUD, inbox deletion, and inbox/detail pages.
    • Multi-recipient sending (to/cc/bcc), labels, attachments, MIME email support, and send/reply rate limiting.
    • Dashboard and pages for API keys, domains, inboxes, messages, login, signup, and inbox message viewer.
    • SDK and OpenAPI updated to match new APIs.
  • Documentation

    • App README and API/SDK docs added/updated.
  • Tests

    • Updated tests to use multi-recipient and query-enabled message APIs.

@linear
Copy link

linear bot commented Nov 12, 2025

RUP-884 Email Agent MVP

  • organizations
  • inboxes
    • threads
      • messages
        • attachments
  • api_keys
  • users
  • webhooks
    • webhook_attempts (with reties)

@coderabbitai
Copy link

coderabbitai bot commented Nov 12, 2025

Walkthrough

Adds domain management with MX DNS verification, multi-recipient messaging and embedded attachments, SES raw MIME sending, Redis-backed rate limiting, schema changes (Message, Domain), API key CRUD with deletion, inbox/message deletion endpoints, a Nuxt frontend, Node SDK + OpenAPI updates, and route/auth changes that attach organization to requests.

Changes

Cohort / File(s) Change Summary
API Key Management
api/controllers/ApiKeyController.ts, api/routes/v1/api_keys/index.ts
Controller method signature changed to accept destructured params; added deleteApiKey({ apiKeyId, organizationId }); new v1 router exposes list/create/get/delete endpoints.
Domain & DNS
api/controllers/DomainController.ts, api/controllers/DNSController.ts, api/db/mongo/schemas/Domain.ts, api/models/Domain.ts, api/routes/v1/domains/index.ts
New DomainController with CRUD + verifyDomainDNS using getDNSMXRecords; Domain schema adds records and a compound unique index (organizationId + name); new domains v1 router with verify endpoint.
Message System & Schema
api/controllers/MessageController.ts, api/db/mongo/schemas/Message.ts, api/models/Message.ts, api/tests/MessageController.spec.ts
Message schema: tostring[]; added cc, bcc, labels; attachments embedded as { content, name?, contentType? }; getMessages({ inboxId, query? }) replaces listing; added deleteMessageById and deleteMessagesByInboxId; tests updated to use arrays.
SES / MIME Sending
api/controllers/SESController.ts
Switched to SendRawEmailCommand; builds MIME via nodemailer MailComposer; sendSESMessage accepts to/cc/bcc arrays and attachments; sends RawMessage with headers/tags.
Inbox Management & Routes
api/controllers/InboxController.ts, api/routes/v1/inboxes/index.ts, api/routes/v1/inboxes/messages.ts, api/routes/v1/inboxes/threads.ts
Added deleteInboxByOrganizationIdAndInboxId; inbox creation can reference verified domain; DELETE cascades message deletes and emits webhook; routes now use req.organization and apply Redis-backed rate limiting for send/reply.
Auth, Routing & Types
api/routes/organizations.ts, api/routes/v1/index.ts, api/routes/organizations/apiKeys.ts (removed), api/types/environment.d.ts, api/controllers/PassportController.ts
v1 router enforces bearer auth and attaches req.organization; old org apiKeys route removed; Express Request augmented with organization; passport strategy now spreads user.toJSON().
Attachments & Models
api/models/Attachment.ts (deleted)
Deleted standalone Attachment model; attachments are now embedded within Message documents.
Redis client & Rate Limiting
api/db/redis/index.ts, api/routes/v1/inboxes/messages.ts
Added ioredis client export and integrated Redis-backed rate limiting for message send/reply endpoints.
Dependencies
api/package.json
Added nodemailer, @types/nodemailer, ioredis, and rate-limit-redis.
Node SDK & API Spec
node-sdk/index.ts, node-sdk/tests/*.spec.ts, node-sdk/package.json, node-sdk/openapi.yaml
Added apiKey and domain namespaces (CRUD/verify); inbox delete; inbox.message.send now accepts to: string[], optional cc, bcc, labels; list supports optional query; package metadata updated; OpenAPI spec added.
Frontend — Nuxt app
app/** (pages, components, composables, config, styles, package.json, tsconfig, etc.)
New Nuxt frontend: auth + dashboard layouts, pages for login/signup, inboxes, messages, domains, api-keys; DashboardShell component; useRequireAuth composable; Tailwind, ESLint, PostCSS and nuxt config included.
Frontend Assets & Config
app/app/assets/css/tailwind.css, app/layouts/*.vue, app/public/robots.txt, app/tailwind.config.ts, app/nuxt.config.ts, app/postcss.config.js
Added Tailwind base, layouts (auth/default), robots.txt, Tailwind/Nuxt/PostCSS config and site assets.
Docs / Landing
landing/content/**
Added API docs pages for Overview, API Keys, Domains, Inboxes, Messages, Threads.
Misc / Tests
api/routes/webhooks/ses.ts, node-sdk/tests/*.spec.ts, api/tests/MessageController.spec.ts
Minor logging added for SES webhook; tests updated to accommodate recipient-array and new list signature.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant V1Router as v1 Router
    participant OrgMiddleware as Org Middleware
    participant DomainController
    participant DNSController
    participant Database
    participant DNS as DNS Resolver

    Client->>V1Router: POST /v1/domains { name }
    V1Router->>OrgMiddleware: authenticate + attach organization
    OrgMiddleware->>DomainController: createDomain({ organizationId, name })
    DomainController->>Database: insert Domain
    Database-->>DomainController: domain created
    DomainController-->>Client: 201 { domain }

    Client->>V1Router: POST /v1/domains/:domainId/verify
    V1Router->>DomainController: verifyDomainDNS({ domain })
    DomainController->>DNSController: getDNSMXRecords(domain.name)
    DNSController->>DNS: resolveMx(domain.name)
    DNS-->>DNSController: MX records
    DNSController-->>DomainController: MX records array
    DomainController->>Database: update domain.records and verified
    DomainController-->>Client: { verified, domain }
Loading
sequenceDiagram
    participant Client
    participant InboxRoute as Inbox Route
    participant RateLimiter as Redis RateLimiter
    participant MessageController
    participant SESController
    participant MailComposer
    participant AWSSES as AWS SES
    participant Database

    Client->>InboxRoute: POST /v1/inboxes/:id/send { to[], cc[], bcc[], attachments[] }
    InboxRoute->>RateLimiter: check allowance
    RateLimiter-->>InboxRoute: allowed
    InboxRoute->>MessageController: createMessage(payload)
    MessageController->>Database: persist message (multi-recipient, attachments)
    Database-->>MessageController: saved
    InboxRoute->>SESController: sendSESMessage({ to[], cc[], bcc[], attachments[] })
    SESController->>MailComposer: build MIME with attachments
    MailComposer-->>SESController: MIME bytes
    SESController->>AWSSES: SendRawEmailCommand(RawMessage)
    AWSSES-->>SESController: sent
    SESController-->>Client: 200 OK
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~70 minutes

Areas needing extra attention:

  • Route migrations from req.user → req.organization (all call sites and typings).
  • SES raw MIME building (MailComposer encoding, RawMessage shape, headers).
  • Mongo schema migrations (Message.to array, Domain.records, index creation).
  • Redis client initialization and rate-limiter integration (env and error handling).
  • Frontend auth composable and cookie/session flows.

Possibly related PRs

  • Landing page #4 — Overlaps landing-site and frontend additions (pages, components, config).
  • inbounds and parsing #3 — Overlapping edits touching MessageController, SES, and inbox/message/thread flows.

Suggested reviewers

  • ahmedmawiri

Poem

🐇 I hopped through code and stitched the strands anew,

Keys and domains now dance in rules and queues,
MIME letters fly while MX checks the line,
Redis counts the hops and Nuxt lays out the shine,
A tiny rabbit cheers: "Push, review, then dine!"

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
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.
Title check ❓ Inconclusive The title broadly encompasses multiple major changes (APP MVP, domains, messages, attachments, tests) but lacks specificity about the primary change, making it difficult to understand the core purpose at a glance. Consider a more specific title that highlights the main objective, such as 'Add domain management and multi-recipient message support' or narrow the scope if the PR addresses one primary goal.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch marc/rup-884-email-agent-mvp-v5

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

@marcrupt marcrupt changed the title APP MVP + all of v4 changes APP MVP + all of v4 changes (Domains, messages, attachments, tests) Nov 12, 2025
Copy link

@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: 11

Caution

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

⚠️ Outside diff range comments (3)
api/tests/MessageController.spec.ts (1)

41-59: Fix the assertion to match the new array-based to field.

The test now passes to as an array [toEmail] (line 47), but line 56 still asserts message.to against the string toEmail. Since to is now string[], this assertion will fail.

Apply this diff to fix the assertion:

       expect(message).toBeDefined();
       expect(message.organizationId.toString()).toBe(organization.id);
       expect(message.inboxId.toString()).toBe(inbox.id);
       expect(message.from).toBe(inbox.email);
-      expect(message.to).toBe(toEmail);
+      expect(message.to).toEqual([toEmail]);
       expect(message.subject).toBe(subject);
       expect(message.text).toBe(text);
       expect(message.html).toBe(html);
node-sdk/index.ts (2)

114-132: Expose the new domain parameter in inbox.create.

The API now accepts an optional domain name when creating an inbox, but the SDK still drops it, so clients can’t assign domains through the SDK. Please thread the domain field through.

Apply this diff to include the parameter:

-    create: async ({
-      name,
-      email,
-    }: {
-      name: string;
-      email?: string;
-    }) => {
-      const response = await axios.post(`${this.apiUrl}/v1/inboxes`, {
-        name,
-        email,
-      }, {
+    create: async ({
+      name,
+      email,
+      domain,
+    }: {
+      name: string;
+      email?: string;
+      domain?: string;
+    }) => {
+      const response = await axios.post(`${this.apiUrl}/v1/inboxes`, {
+        name,
+        email,
+        domain,
+      }, {

157-189: Support attachments in message.send.

Server-side now handles attachments, but the SDK neither accepts nor forwards them, so attachments can’t be sent through the SDK. Please add an attachments parameter and include it in the POST body.

Apply this diff to wire the field through:

-      send: async ({
-        inboxId,
-        to,
-        cc,
-        bcc,
-        labels,
-        subject,
-        text,
-        html,
-      }: {
-        inboxId: string;
-        to: string[];
-        cc?: string[];
-        bcc?: string[];
-        labels?: string[];
-        subject: string;
-        text: string;
-        html: string;
-      }) => {
+      send: async ({
+        inboxId,
+        to,
+        cc,
+        bcc,
+        labels,
+        subject,
+        text,
+        html,
+        attachments,
+      }: {
+        inboxId: string;
+        to: string[];
+        cc?: string[];
+        bcc?: string[];
+        labels?: string[];
+        subject: string;
+        text: string;
+        html: string;
+        attachments?: {
+          content: string;
+          name?: string;
+          contentType?: string;
+        }[];
+      }) => {
         const response = await axios.post(`${this.apiUrl}/v1/inboxes/${inboxId}/messages/send`, {
           to,
           cc,
           bcc,
           labels,
           subject,
           text,
           html,
+          attachments,
         }, {
🧹 Nitpick comments (8)
app/README.md (1)

1-75: Consider customizing the README for project-specific details.

The current README is the default Nuxt starter template. Consider adding project-specific information such as:

  • API URL configuration
  • Environment variables required
  • Project structure overview
  • Links to related API documentation
app/layouts/default.vue (1)

16-26: Verify whether disabled navigation items should be removed.

Most navigation items are marked as disabled with the is-disabled class. If these features aren't implemented yet, consider either removing them from the UI entirely or adding a clearer indicator (e.g., "Coming Soon" badge) to set user expectations.

app/app/pages/api-keys.vue (1)

173-203: Consider more specific error handling in loadKeys.

The error handling catches all errors and displays a generic message. For better UX, consider handling different HTTP status codes separately (e.g., 401 for unauthorized, 403 for forbidden, 404 for organization not found).

Example:

if (!response.ok) {
  if (response.status === 401) {
    errorMessage.value = 'Session expired. Please log in again.';
  } else if (response.status === 403) {
    errorMessage.value = 'You do not have permission to view API keys.';
  } else {
    errorMessage.value = `Unable to fetch API keys: ${response.status}`;
  }
  return;
}
app/app/assets/css/tailwind.css (1)

1-1: Consider privacy implications of Google Fonts import.

The direct import from fonts.googleapis.com causes browsers to make requests to Google servers, which may have GDPR/privacy implications depending on your jurisdiction and user base.

Consider:

  1. Self-hosting the Inter font files to avoid external requests
  2. Adding appropriate privacy disclosures if keeping the Google Fonts CDN
  3. Using font-display: swap (already present) is good for performance

If GDPR compliance is required, self-hosting is the safest approach.

api/controllers/DNSController.ts (1)

4-15: Consider distinguishing between DNS errors and missing records.

The current error handling returns an empty array for both DNS resolution failures and domains with no MX records. This makes it difficult for callers (e.g., verifyDomainDNS) to distinguish between verification failure due to misconfiguration versus a DNS error.

Consider one of these approaches:

Option 1: Re-throw DNS errors

 export async function getDNSMXRecords({
   domain,
 }: {
   domain: string;
 }): Promise<MxRecord[]> {
   try {
     return await resolveMx(domain);
   } catch (error) {
-    console.error('Error resolving MX records:', error);
-    return [];
+    console.error('Error resolving MX records:', error);
+    throw error;
   }
 }

Option 2: Return a result object

export async function getDNSMXRecords({
  domain,
}: {
  domain: string;
}): Promise<{ records: MxRecord[]; error?: Error }> {
  try {
    const records = await resolveMx(domain);
    return { records };
  } catch (error) {
    console.error('Error resolving MX records:', error);
    return { records: [], error: error as Error };
  }
}
app/app/pages/signup.vue (1)

50-61: Consider adding password strength validation.

While the current implementation uses type="password" and the required attribute, consider adding client-side password strength validation to improve security and user experience by guiding users to create stronger passwords.

app/app/pages/inboxes.vue (1)

55-123: Label the dialogs for assistive tech

Both modal overlays use role="dialog" but lack an accessible name, so screen readers announce an unlabeled window. Tie the dialog container to its <h2> via aria-labelledby (and optionally the body copy with aria-describedby) to meet ARIA dialog guidance. (developer.mozilla.org)

Apply this diff:

-      <div v-if="showDialog" class="dialog-backdrop" role="dialog" aria-modal="true">
-        <div class="dialog-card">
-          <header class="dialog-header">
-            <h2>Create inbox</h2>
+      <div
+        v-if="showDialog"
+        class="dialog-backdrop"
+        role="dialog"
+        aria-modal="true"
+        aria-labelledby="create-inbox-title"
+        aria-describedby="create-inbox-desc"
+      >
+        <div class="dialog-card">
+          <header class="dialog-header">
+            <h2 id="create-inbox-title">Create inbox</h2>
...
-            <p>
+            <p id="create-inbox-desc">
...
-      <div v-if="showDeleteDialog" class="dialog-backdrop" role="dialog" aria-modal="true">
-        <div class="dialog-card">
-          <header class="dialog-header">
-            <h2>Delete inbox</h2>
+      <div
+        v-if="showDeleteDialog"
+        class="dialog-backdrop"
+        role="dialog"
+        aria-modal="true"
+        aria-labelledby="delete-inbox-title"
+        aria-describedby="delete-inbox-desc"
+      >
+        <div class="dialog-card">
+          <header class="dialog-header">
+            <h2 id="delete-inbox-title">Delete inbox</h2>
...
-            <p>
+            <p id="delete-inbox-desc">
app/app/pages/domains.vue (1)

62-125: Provide accessible names for the domain dialogs

For parity with ARIA dialog requirements, hook each modal’s role="dialog" container up to its heading so assistive tech announces “Add domain” / “Delete domain” when focus enters. Add matching aria-labelledby (and aria-describedby for the explanatory text). (developer.mozilla.org)

Apply this diff:

-      <div v-if="showDialog" class="dialog-backdrop" role="dialog" aria-modal="true">
-        <div class="dialog-card">
-          <header class="dialog-header">
-            <h2>Add domain</h2>
+      <div
+        v-if="showDialog"
+        class="dialog-backdrop"
+        role="dialog"
+        aria-modal="true"
+        aria-labelledby="add-domain-title"
+        aria-describedby="add-domain-desc"
+      >
+        <div class="dialog-card">
+          <header class="dialog-header">
+            <h2 id="add-domain-title">Add domain</h2>
...
-            <p>
+            <p id="add-domain-desc">
...
-      <div v-if="showDeleteDialog" class="dialog-backdrop" role="dialog" aria-modal="true">
-        <div class="dialog-card">
-          <header class="dialog-header">
-            <h2>Delete domain</h2>
+      <div
+        v-if="showDeleteDialog"
+        class="dialog-backdrop"
+        role="dialog"
+        aria-modal="true"
+        aria-labelledby="delete-domain-title"
+        aria-describedby="delete-domain-desc"
+      >
+        <div class="dialog-card">
+          <header class="dialog-header">
+            <h2 id="delete-domain-title">Delete domain</h2>
...
-            <p>
+            <p id="delete-domain-desc">
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2bc53f6 and b0f49df.

⛔ Files ignored due to path filters (4)
  • .DS_Store is excluded by !**/.DS_Store
  • api/bun.lock is excluded by !**/*.lock
  • app/public/favicon.ico is excluded by !**/*.ico
  • app/yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (50)
  • api/controllers/ApiKeyController.ts (2 hunks)
  • api/controllers/DNSController.ts (1 hunks)
  • api/controllers/DomainController.ts (1 hunks)
  • api/controllers/InboxController.ts (1 hunks)
  • api/controllers/MessageController.ts (3 hunks)
  • api/controllers/PassportController.ts (1 hunks)
  • api/controllers/SESController.ts (3 hunks)
  • api/db/mongo/schemas/Domain.ts (1 hunks)
  • api/db/mongo/schemas/Message.ts (2 hunks)
  • api/models/Attachment.ts (0 hunks)
  • api/models/Domain.ts (1 hunks)
  • api/models/Message.ts (1 hunks)
  • api/package.json (2 hunks)
  • api/routes/auth.ts (1 hunks)
  • api/routes/organizations.ts (1 hunks)
  • api/routes/organizations/apiKeys.ts (0 hunks)
  • api/routes/v1/api_keys/index.ts (1 hunks)
  • api/routes/v1/domains/index.ts (1 hunks)
  • api/routes/v1/inboxes/index.ts (4 hunks)
  • api/routes/v1/inboxes/messages.ts (8 hunks)
  • api/routes/v1/inboxes/threads.ts (2 hunks)
  • api/routes/v1/index.ts (1 hunks)
  • api/tests/MessageController.spec.ts (2 hunks)
  • api/types/environment.d.ts (1 hunks)
  • app/.gitignore (1 hunks)
  • app/README.md (1 hunks)
  • app/app/app.vue (1 hunks)
  • app/app/assets/css/tailwind.css (1 hunks)
  • app/app/components/dashboard/DashboardShell.vue (1 hunks)
  • app/app/composables/useRequireAuth.ts (1 hunks)
  • app/app/pages/api-keys.vue (1 hunks)
  • app/app/pages/domains.vue (1 hunks)
  • app/app/pages/inboxes.vue (1 hunks)
  • app/app/pages/index.vue (1 hunks)
  • app/app/pages/login.vue (1 hunks)
  • app/app/pages/messages.vue (1 hunks)
  • app/app/pages/signup.vue (1 hunks)
  • app/eslint.config.mjs (1 hunks)
  • app/layouts/auth.vue (1 hunks)
  • app/layouts/default.vue (1 hunks)
  • app/nuxt.config.ts (1 hunks)
  • app/package.json (1 hunks)
  • app/postcss.config.js (1 hunks)
  • app/public/robots.txt (1 hunks)
  • app/tailwind.config.ts (1 hunks)
  • app/tsconfig.json (1 hunks)
  • node-sdk/index.ts (3 hunks)
  • node-sdk/tests/domainAPI.spec.ts (1 hunks)
  • node-sdk/tests/messageAPI.spec.ts (4 hunks)
  • node-sdk/tests/threadAPI.spec.ts (2 hunks)
💤 Files with no reviewable changes (2)
  • api/routes/organizations/apiKeys.ts
  • api/models/Attachment.ts
🧰 Additional context used
🧬 Code graph analysis (9)
api/types/environment.d.ts (1)
api/models/Organization.ts (1)
  • Organization (3-12)
api/controllers/DomainController.ts (2)
api/models/Domain.ts (1)
  • Domain (3-15)
api/controllers/DNSController.ts (1)
  • getDNSMXRecords (4-15)
app/app/composables/useRequireAuth.ts (1)
api/models/Organization.ts (1)
  • Organization (3-12)
api/controllers/InboxController.ts (1)
api/models/Inbox.ts (1)
  • Inbox (3-11)
api/controllers/ApiKeyController.ts (1)
api/models/ApiKey.ts (1)
  • ApiKey (3-11)
api/routes/organizations.ts (1)
api/controllers/OrganizationController.ts (1)
  • getOrganizationById (43-45)
api/routes/v1/inboxes/index.ts (5)
api/controllers/InboxController.ts (3)
  • createInbox (4-24)
  • getInboxByOrganizationIdAndInboxId (32-43)
  • deleteInboxByOrganizationIdAndInboxId (45-56)
api/middlewares/expressValidatorMiddleware.ts (1)
  • expressValidatorMiddleware (4-16)
api/controllers/DomainController.ts (1)
  • getVerifiedDomainByOrganizationIdAndName (43-55)
api/controllers/WebhookAttemptController.ts (1)
  • sendWebhookEvent (10-55)
api/controllers/MessageController.ts (1)
  • deleteMessagesByInboxId (120-124)
api/controllers/MessageController.ts (1)
api/models/Message.ts (2)
  • MessageStatus (3-10)
  • Message (12-36)
api/routes/v1/index.ts (1)
api/models/Organization.ts (1)
  • Organization (3-12)
🪛 ast-grep (0.39.9)
app/app/composables/useRequireAuth.ts

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

(regexp-from-variable)

🔇 Additional comments (42)
app/public/robots.txt (1)

1-2: LGTM! Appropriate robots.txt for MVP.

The permissive policy (allow-all crawling) is well-suited for a new MVP app that wants search engine visibility. As the application grows and introduces protected endpoints, you can refine this file to disallow paths like /admin, /api, etc.

app/app/app.vue (1)

1-5: LGTM!

Standard Nuxt application shell that correctly uses NuxtLayout and NuxtPage for nested routing.

app/tsconfig.json (1)

1-18: LGTM!

Standard Nuxt TypeScript configuration using project references to delegate to generated config files.

app/.gitignore (1)

1-24: LGTM!

Comprehensive gitignore rules that properly exclude build artifacts, dependencies, logs, and sensitive environment files while preserving .env.example.

app/postcss.config.js (1)

1-7: LGTM!

Standard PostCSS configuration with Tailwind CSS and Autoprefixer using default settings.

api/routes/auth.ts (1)

76-76: LGTM!

The last_name validation is correctly added and consistent with the existing first_name field validation pattern.

api/package.json (1)

12-12: No action required—nodemailer 7.0.10 is current and secure.

Verification confirms that nodemailer 7.0.10 is the latest version and contains no known vulnerabilities. All identified security advisories affect earlier versions and have been patched.

api/controllers/InboxController.ts (1)

45-56: No action required — cascade deletion is properly implemented.

The code correctly handles related messages when an inbox is deleted. The route handler explicitly calls deleteMessagesByInboxId() after the inbox deletion succeeds (line 102 in api/routes/v1/inboxes/index.ts), ensuring messages are cleaned up rather than orphaned.

api/controllers/ApiKeyController.ts (1)

28-36: LGTM! Signature change improves readability.

The refactor to object destructuring makes the function signature more maintainable and clearer at call sites.

api/routes/v1/inboxes/threads.ts (2)

19-20: LGTM! Organization scoping is correct.

The change to use req.organization._id.toString() aligns with the centralized authentication pattern established in the v1 router.


40-40: LGTM! Thread ownership check is correct.

The organization ID comparison properly validates that the thread belongs to the requesting organization.

app/eslint.config.mjs (1)

1-6: LGTM! Standard Nuxt ESLint configuration.

This follows the recommended Nuxt ESLint setup pattern by extending the generated configuration. The structure allows for easy addition of custom rules in the future.

app/app/pages/api-keys.vue (3)

46-46: Verify API key display security implications.

API keys are displayed in plaintext in the UI ({{ key.key ?? '••••••••••••••••' }}). While this may be intentional for user convenience, ensure this aligns with your security requirements. Consider:

  1. Only showing keys immediately after creation (one-time display)
  2. Requiring re-authentication to view existing keys
  3. Showing only a prefix/suffix of the key with masked middle characters

If plaintext display is acceptable for your use case, this is fine. Otherwise, consider implementing one of the above patterns.


247-251: LGTM! Proper cleanup of copy timeout.

The onBeforeUnmount hook correctly clears the copy timeout to prevent memory leaks and potential race conditions.


334-342: LGTM! Reactive data loading with proper dependencies.

The watcher correctly reloads API keys when the organization ID or token changes, with proper immediate execution on mount.

node-sdk/tests/threadAPI.spec.ts (2)

18-18: LGTM! Correctly updated to array format.

The change from a single recipient string to an array aligns with the multi-recipient support introduced in this PR.


37-37: LGTM! Consistent with multi-recipient API changes.

app/app/assets/css/tailwind.css (1)

3-17: LGTM! Clean Tailwind and base styles setup.

The Tailwind directives, dark color scheme, and font configuration with proper fallbacks are well-structured.

app/package.json (1)

12-25: Remove speculative stability concerns; version pinning strategy is subjective best practice.

Nuxt 4.2.1 (released Nov 6, 2025) is a patch on the current stable major line and is intended for production use. No known unpatched vulnerabilities affect Nuxt 4.2.1 or Vue 3.5.24.

The mixed version pinning (exact vs. caret ranges) is a valid stylistic choice. For consistency, consider standardizing on either exact pinning or caret ranges; exact pinning offers more predictable builds, but caret ranges allow security patch updates. Both approaches are common in production projects.

Likely an incorrect or invalid review comment.

api/controllers/PassportController.ts (1)

19-22: LGTM! Consistent serialization pattern.

Using toJSON() for the user payload aligns with the existing pattern used for organization serialization (line 41), ensuring consistent data shapes across authentication strategies.

app/layouts/auth.vue (1)

1-88: LGTM! Well-structured authentication layout.

The component provides a clean, modern authentication shell with proper:

  • Responsive design for different viewports
  • Visual hierarchy with z-indexed elements
  • Consistent styling with blur effects and gradients
  • Reusable slot pattern for nested content
api/models/Domain.ts (1)

8-12: LGTM! Clear structure for DNS record tracking.

The records field provides a well-defined structure for storing DNS verification data with appropriate string types for type, value, and status properties.

app/app/pages/messages.vue (1)

1-99: LGTM! Well-structured messages page.

The component follows Vue best practices with:

  • Proper authentication enforcement via useRequireAuth
  • Clear component naming via defineOptions
  • Responsive layout with appropriate breakpoints
  • Clear placeholder messaging for future functionality
  • Consistent styling patterns
app/tailwind.config.ts (1)

1-40: LGTM! Comprehensive Tailwind configuration.

The configuration is properly:

  • Typed with Config and uses satisfies for type safety
  • Scoped with appropriate content globs covering all Vue components
  • Extended with custom theme values (sendook colors, glass shadow, Inter font)
  • Structured for maintainability
api/routes/v1/index.ts (1)

11-21: API key middleware and context wiring look solid.

Auth gating the v1 router and projecting req.organization gives the downstream routers what they expect. Nice cleanup.

app/nuxt.config.ts (1)

1-32: Nuxt config defaults look good.

The API URL fallback and module wiring align with the new app’s needs.

node-sdk/tests/domainAPI.spec.ts (1)

8-43: Nice coverage for the domain client.

Exercising create/get/delete/verify in one flow gives us confidence the SDK wiring matches the API.

api/models/Message.ts (1)

28-32: LGTM: Attachments structure is well-defined.

The inline attachment object structure with content, name, and contentType fields is appropriate for storing attachment metadata and aligns with the schema definition.

app/app/composables/useRequireAuth.ts (3)

25-31: Static analysis false positive on RegExp construction.

The static analysis tool flagged line 29 as a potential ReDoS vulnerability. However, the name parameter is only ever called with the static string 'sendook_token' (line 99), so this is not a security risk.


53-93: LGTM: Well-implemented single-flight pattern with proper error handling.

The debouncing via fetchPromise prevents duplicate concurrent user fetches, and the error handling appropriately clears the session and redirects on authentication failures.


95-122: LGTM: Clean composable API with reactive state.

The composable properly initializes authentication state on mount and exposes a clean API with token, user, organizationId, loading state, and a refreshUser method.

node-sdk/tests/messageAPI.spec.ts (2)

18-18: LGTM: Test updates align with multi-recipient API changes.

The updates to pass to as an array of strings consistently across all test cases align with the API changes for multi-recipient support.

Also applies to: 35-35, 54-54, 73-73


40-40: No changes required—SDK implementation correctly accepts the second parameter.

The inbox.message.list() method in node-sdk/index.ts is properly implemented with signature list: async (inboxId: string, query?: string), accepting the second parameter and passing it as a query parameter in the axios request. The test usage at line 40 is consistent with this implementation.

api/db/mongo/schemas/Message.ts (2)

40-55: Schema fields correctly defined for multi-recipient support.

The schema properly defines to as a required array, and cc, bcc, labels as optional arrays. However, note that the corresponding model interface (api/models/Message.ts) has inconsistencies that need to be fixed—see my comments on that file.


68-75: LGTM: Attachments schema matches the model structure.

The embedded object structure for attachments with content, name, and contentType fields correctly aligns with the model definition.

api/routes/v1/domains/index.ts (3)

25-45: LGTM: Proper domain validation and organization scoping.

The domain name regex validation at line 31 appropriately validates domain format, and the route correctly scopes domain creation to the requesting organization.


47-63: LGTM: Consistent organization scoping and error handling.

All domain retrieval, verification, and deletion endpoints properly scope operations to the requesting organization and return 404 for missing domains.

Also applies to: 65-83, 85-101


15-22: Verify req.organization middleware is properly configured.

This route relies on req.organization being set by authentication middleware. Ensure the parent v1 router properly attaches this middleware before mounting the domains routes (same verification as api_keys).

app/app/pages/signup.vue (2)

97-139: LGTM: Robust form submission with proper error handling.

The signup handler appropriately validates the response, stores the token securely, and handles errors gracefully with user-friendly messages.


9-35: LGTM: Proper name field implementation with accessibility.

The name fields use appropriate autocomplete attributes (given-name, family-name) and responsive grid layout for improved user experience.

api/db/mongo/schemas/Domain.ts (1)

36-36: LGTM: Compound unique index ensures domain uniqueness per organization.

The compound index on organizationId and name appropriately prevents duplicate domains within the same organization while allowing the same domain name across different organizations.

api/routes/v1/api_keys/index.ts (1)

9-18: Middleware is properly configured—no action needed.

Verification confirms that req.organization is set by authentication middleware in api/routes/v1/index.ts (line 14: req.organization = req.user as HydratedDocument<Organization>;) before the api_keys routes are mounted (line 19: router.use("/api_keys", apiKeyRouter);). The middleware is correctly positioned to execute before route handlers.

Comment on lines 46 to 48
export async function deleteApiKey(apiKeyId: string) {
return await ApiKey.findByIdAndDelete(apiKeyId);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Critical: Missing organization ownership check in deleteApiKey.

The deleteApiKey function uses findByIdAndDelete without verifying that the API key belongs to the requesting organization. This allows an authenticated user to delete API keys from other organizations if they know the key ID.

Apply this diff to add organization ownership verification:

-export async function deleteApiKey(apiKeyId: string) {
-  return await ApiKey.findByIdAndDelete(apiKeyId);
+export async function deleteApiKey({
+  apiKeyId,
+  organizationId,
+}: {
+  apiKeyId: string;
+  organizationId: string;
+}) {
+  return await ApiKey.findOneAndDelete({ _id: apiKeyId, organizationId });
 }

Then update the route handler in api/routes/v1/api_keys/index.ts to pass the organizationId:

await deleteApiKey({ 
  apiKeyId: req.params.apiKeyId, 
  organizationId: req.organization._id.toString() 
});
🤖 Prompt for AI Agents
In api/controllers/ApiKeyController.ts around lines 46 to 48, the deleteApiKey
function currently calls findByIdAndDelete without checking organization
ownership; change the function signature to accept an object ({ apiKeyId:
string, organizationId: string }) and replace findByIdAndDelete with a scoped
findOneAndDelete query using {_id: apiKeyId, organization: organizationId} so
only keys belonging to that organization are removed; also update any callers
(per review, update api/routes/v1/api_keys/index.ts) to pass
req.organization._id.toString() as organizationId when invoking deleteApiKey.

Comment on lines 67 to 106
// Create the MIME message using nodemailer's MailComposer
const mailOptions: any = {
from: fromName ? `"${fromName}" <${from}>` : from,
to: (to ?? []).join(", "),
cc: (cc ?? []).join(", "),
bcc: (bcc ?? []).join(", "),
subject,
text,
html,
attachments: (attachments ?? []).map(att => ({
filename: att.name,
content: att.content,
contentType: att.contentType,
encoding: "base64",
})),
headers: {
"X-SES-CONFIGURATION-SET": "sendook-config-set",
"X-SES-MESSAGE-TAGS": `message=${messageId}`,
},
};

const composer = new MailComposer(mailOptions);
const mimeMessage = await new Promise<Buffer>((resolve, reject) => {
composer.compile().build((err, message) => {
if (err) return reject(err);
resolve(message);
});
});

const command = new SendRawEmailCommand({
RawMessage: { Data: mimeMessage },
Tags: [
{
Name: "message",
Value: messageId,
},
],
ConfigurationSetName: "sendook-config-set",
});
return await ses.send(command);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Fix BCC delivery in the raw SES payload

MailComposer strips the Bcc header by default. Because we rely on that header to tell SES who the BCC recipients are (and we don’t pass Destinations), those contacts never receive the message. We need to keep the BCC info when building the MIME blob and/or pass the combined recipients explicitly. (nodemailer.com)
Please update the composer usage to preserve BCC and send the recipient list with the command.

Apply this diff:

-  const composer = new MailComposer(mailOptions);
-  const mimeMessage = await new Promise<Buffer>((resolve, reject) => {
-    composer.compile().build((err, message) => {
+  const composer = new MailComposer(mailOptions);
+  const compiled = composer.compile();
+  compiled.keepBcc = true;
+
+  const mimeMessage = await new Promise<Buffer>((resolve, reject) => {
+    compiled.build((err, message) => {
       if (err) return reject(err);
       resolve(message);
     });
   });
 
+  const destinations = [...(to ?? []), ...(cc ?? []), ...(bcc ?? [])].filter(Boolean);
+
   const command = new SendRawEmailCommand({
     RawMessage: { Data: mimeMessage },
+    Destinations: destinations.length ? destinations : undefined,
     Tags: [
       {
         Name: "message",
         Value: messageId,
       },

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

🤖 Prompt for AI Agents
In api/controllers/SESController.ts around lines 67 to 106, MailComposer strips
the Bcc header and you also don’t pass explicit recipients to SES, so BCC
recipients never get the message; fix by building a combined recipients list
(to, cc, bcc flattened and deduplicated), pass that list as the MailComposer
envelope.to (so the MIME blob contains explicit envelope recipients) and also
include that combined list as the SendRawEmailCommand's Destinations array;
ensure you still set the from/envelope.from correctly and keep the existing
headers/attachments mapping.

Comment on lines +19 to +29
records: {
type: [mongoose.Schema.Types.Mixed],
default: [
{
type: "MX",
name: "@",
value: "inbound-smtp.us-east-2.amazonaws.com",
status: "pending",
},
],
},
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify the hardcoded AWS SES endpoint region.

The default MX record points to inbound-smtp.us-east-2.amazonaws.com (line 25). Ensure this region (us-east-2) matches your AWS SES configuration and that it's appropriate to hardcode this value rather than making it configurable via environment variables.


🏁 Script executed:

#!/bin/bash
# Description: Check if AWS region configuration exists elsewhere in the codebase

# Search for AWS region references
rg -nP 'us-east-2|AWS.*region|SES.*region' --type ts

# Search for environment variable usage for AWS configuration
rg -nP 'process\.env\.AWS' --type ts

Length of output: 464



Hardcoded AWS SES region prevents deployment flexibility across regions.

The us-east-2 region is hardcoded in 3 locations (SESController.ts lines 19 and 152; Domain.ts line 25), while AWS credentials are already environment-configurable (SESController.ts lines 21-22). Extract the region into an environment variable (e.g., AWS_REGION or SES_REGION) to maintain consistency and enable multi-region deployments.

🤖 Prompt for AI Agents
In api/db/mongo/schemas/Domain.ts around lines 19 to 29, the SES inbound host
"inbound-smtp.us-east-2.amazonaws.com" is hardcoded; extract the AWS region into
an environment variable (e.g., SES_REGION or AWS_REGION) and replace the literal
with a constructed host using that variable (e.g.,
`inbound-smtp.${region}.amazonaws.com`). Read the region from process.env with a
sensible default/fallback, validate it if appropriate, and update the default
record accordingly; also ensure the same env variable is used consistently in
the other referenced locations (SESController.ts lines ~19 and ~152) so region
configuration is centralized and deployment-region-agnostic.

Comment on lines +21 to +24
to?: string[];
cc: string[];
bcc: string[];
labels: string[];
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Fix inconsistencies between model interface and schema.

The required/optional modifiers on these fields don't match the database schema (api/db/mongo/schemas/Message.ts):

  • Line 21: to?: string[] is marked optional here, but the schema has required: true (line 42).
  • Lines 22-24: cc, bcc, labels are marked as required (no ?), but the schema has required: false (lines 46, 50, 54).

Apply this diff to align the model with the schema:

-  to?: string[];
-  cc: string[];
-  bcc: string[];
-  labels: string[];
+  to: string[];
+  cc?: string[];
+  bcc?: string[];
+  labels?: string[];
📝 Committable suggestion

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

Suggested change
to?: string[];
cc: string[];
bcc: string[];
labels: string[];
to: string[];
cc?: string[];
bcc?: string[];
labels?: string[];
🤖 Prompt for AI Agents
In api/models/Message.ts around lines 21 to 24, the TypeScript interface
modifiers don't match the Mongo schema: change `to?: string[]` to required (`to:
string[]`) to match schema's required: true, and change `cc`, `bcc`, and
`labels` to optional (`cc?: string[]`, `bcc?: string[]`, `labels?: string[]`) to
match schema's required: false; update any related type references if necessary
to accept the optional fields.

Comment on lines +17 to +24
router.use("/:organizationId", async (req, res, next) => {
const organization = await getOrganizationById(req.params.organizationId!);
if (!organization) {
return res.status(404).json({ error: "Organization not found" });
}
req.organization = organization;
next();
});
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Handle errors from getOrganizationById.

getOrganizationById can reject (DB/network issues). Because this middleware is async, an unhandled rejection skips Express’s error pipeline in v4 and can crash the process. Please wrap the lookup in try/catch and forward failures to next(err).

Apply this diff:

-router.use("/:organizationId", async (req, res, next) => {
-  const organization = await getOrganizationById(req.params.organizationId!);
-  if (!organization) {
-    return res.status(404).json({ error: "Organization not found" });
-  }
-  req.organization = organization;
-  next();
-});
+router.use("/:organizationId", async (req, res, next) => {
+  try {
+    const organization = await getOrganizationById(req.params.organizationId!);
+    if (!organization) {
+      return res.status(404).json({ error: "Organization not found" });
+    }
+    req.organization = organization;
+    return next();
+  } catch (error) {
+    return next(error);
+  }
+});
📝 Committable suggestion

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

Suggested change
router.use("/:organizationId", async (req, res, next) => {
const organization = await getOrganizationById(req.params.organizationId!);
if (!organization) {
return res.status(404).json({ error: "Organization not found" });
}
req.organization = organization;
next();
});
router.use("/:organizationId", async (req, res, next) => {
try {
const organization = await getOrganizationById(req.params.organizationId!);
if (!organization) {
return res.status(404).json({ error: "Organization not found" });
}
req.organization = organization;
return next();
} catch (error) {
return next(error);
}
});
🤖 Prompt for AI Agents
In api/routes/organizations.ts around lines 17 to 24, the middleware calls the
async getOrganizationById without catching rejections; wrap the await call in a
try/catch, on success keep the existing not-found check and assignment to
req.organization and call next(), and on error call next(err) to forward
DB/network errors into Express error handling so the process won’t crash.

Comment on lines 172 to 187
const replyToMessage = await getMessageById(req.params.messageId);
if (
!replyToMessage ||
replyToMessage.organizationId.toString() !== organization._id.toString()
replyToMessage.organizationId.toString() !== req.organization._id.toString()
) {
return res.status(404).json({ error: "Message not found" });
}

const message = await createMessage({
organizationId: organization._id.toString(),
organizationId: req.organization._id.toString(),
inboxId: req.params.inboxId,
threadId: replyToMessage.threadId.toString(),
fromInboxId: req.params.inboxId,
from: inbox.email,
to: replyToMessage.from,
to: [replyToMessage.from],
subject: `Re: ${replyToMessage.subject}`,
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Guard replies to the owning inbox only.

We only verify the organization match before replying, so a client can call POST /inboxes/{otherInboxId}/messages/{messageId}/reply and we’ll happily create a reply under the wrong inbox while reusing the original thread. That mixes records across inboxes and leaks the other inbox’s conversation context. Please ensure replyToMessage.inboxId matches req.params.inboxId (return 404 otherwise) before creating the reply.

Apply this diff to enforce the guard:

   const replyToMessage = await getMessageById(req.params.messageId);
   if (
     !replyToMessage ||
     replyToMessage.organizationId.toString() !== req.organization._id.toString()
   ) {
     return res.status(404).json({ error: "Message not found" });
   }
+  if (replyToMessage.inboxId.toString() !== req.params.inboxId) {
+    return res.status(404).json({ error: "Message not found" });
+  }
📝 Committable suggestion

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

Suggested change
const replyToMessage = await getMessageById(req.params.messageId);
if (
!replyToMessage ||
replyToMessage.organizationId.toString() !== organization._id.toString()
replyToMessage.organizationId.toString() !== req.organization._id.toString()
) {
return res.status(404).json({ error: "Message not found" });
}
const message = await createMessage({
organizationId: organization._id.toString(),
organizationId: req.organization._id.toString(),
inboxId: req.params.inboxId,
threadId: replyToMessage.threadId.toString(),
fromInboxId: req.params.inboxId,
from: inbox.email,
to: replyToMessage.from,
to: [replyToMessage.from],
subject: `Re: ${replyToMessage.subject}`,
const replyToMessage = await getMessageById(req.params.messageId);
if (
!replyToMessage ||
replyToMessage.organizationId.toString() !== req.organization._id.toString()
) {
return res.status(404).json({ error: "Message not found" });
}
if (replyToMessage.inboxId.toString() !== req.params.inboxId) {
return res.status(404).json({ error: "Message not found" });
}
const message = await createMessage({
organizationId: req.organization._id.toString(),
inboxId: req.params.inboxId,
threadId: replyToMessage.threadId.toString(),
fromInboxId: req.params.inboxId,
from: inbox.email,
to: [replyToMessage.from],
subject: `Re: ${replyToMessage.subject}`,
🤖 Prompt for AI Agents
In api/routes/v1/inboxes/messages.ts around lines 172 to 187, the code only
checks organization ownership before creating a reply which allows creating a
reply under a different inbox; add a guard to verify replyToMessage.inboxId
matches req.params.inboxId (use string comparison, e.g.
replyToMessage.inboxId.toString() !== req.params.inboxId) and return
res.status(404).json({ error: "Message not found" }) if it doesn’t match, before
calling createMessage.

Comment on lines +18 to +20
interface Request {
organization: HydratedDocument<Organization>;
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Missing import for Organization type.

The Request interface references HydratedDocument<Organization>, but the Organization type is not imported. This will cause a TypeScript compilation error.

Apply this diff to add the missing import:

 import type { HydratedDocument } from "mongoose";
 import type User from "../models/User";
+import type Organization from "../models/Organization";

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

🤖 Prompt for AI Agents
In api/types/environment.d.ts around lines 18 to 20, the Request interface uses
HydratedDocument<Organization> but the Organization type is not imported; add an
import for Organization (from its module, e.g., models/organization or the
correct path in this codebase) at the top of the file and ensure
HydratedDocument is imported from mongoose if not already present so the Request
interface compiles correctly.

Comment on lines +21 to +55
<div class="search-field">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M4 6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v11a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 4 17.5v-11Z"
fill="none"
stroke="currentColor"
stroke-width="1.5"
/>
<path
d="M5 7.5 11.45 12a1 1 0 0 0 1.1 0L19 7.5"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<input type="search" placeholder="Search messages">
</div>
<select>
<option>Last 15 days</option>
<option>Last 30 days</option>
<option>Last 90 days</option>
</select>
<select>
<option>All statuses</option>
<option>Delivered</option>
<option>Queued</option>
<option>Bounced</option>
</select>
<select>
<option>All API keys</option>
<option>Production</option>
<option>Staging</option>
</select>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add accessible labels for the search and filter controls.

The search field and all three <select> elements have no associated label or ARIA name. Placeholders and visible context aren’t announced by screen readers, so these controls surface as “edit text” / “combo box” with no description, which blocks non-visual users from applying filters. Please add explicit labels or ARIA attributes.

Apply this diff so each control exposes an accessible name:

-          <input type="search" placeholder="Search messages">
+          <input
+            type="search"
+            placeholder="Search messages"
+            aria-label="Search messages"
+          >
-        <select>
+        <select aria-label="Date range">
           <option>Last 15 days</option>
           <option>Last 30 days</option>
           <option>Last 90 days</option>
         </select>
-        <select>
+        <select aria-label="Delivery status">
           <option>All statuses</option>
           <option>Delivered</option>
           <option>Queued</option>
           <option>Bounced</option>
         </select>
-        <select>
+        <select aria-label="API key scope">
           <option>All API keys</option>
           <option>Production</option>
           <option>Staging</option>
         </select>
📝 Committable suggestion

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

Suggested change
<div class="search-field">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M4 6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v11a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 4 17.5v-11Z"
fill="none"
stroke="currentColor"
stroke-width="1.5"
/>
<path
d="M5 7.5 11.45 12a1 1 0 0 0 1.1 0L19 7.5"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<input type="search" placeholder="Search messages">
</div>
<select>
<option>Last 15 days</option>
<option>Last 30 days</option>
<option>Last 90 days</option>
</select>
<select>
<option>All statuses</option>
<option>Delivered</option>
<option>Queued</option>
<option>Bounced</option>
</select>
<select>
<option>All API keys</option>
<option>Production</option>
<option>Staging</option>
</select>
<div class="search-field">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M4 6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v11a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 4 17.5v-11Z"
fill="none"
stroke="currentColor"
stroke-width="1.5"
/>
<path
d="M5 7.5 11.45 12a1 1 0 0 0 1.1 0L19 7.5"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<input
type="search"
placeholder="Search messages"
aria-label="Search messages"
>
</div>
<select aria-label="Date range">
<option>Last 15 days</option>
<option>Last 30 days</option>
<option>Last 90 days</option>
</select>
<select aria-label="Delivery status">
<option>All statuses</option>
<option>Delivered</option>
<option>Queued</option>
<option>Bounced</option>
</select>
<select aria-label="API key scope">
<option>All API keys</option>
<option>Production</option>
<option>Staging</option>
</select>
🤖 Prompt for AI Agents
In app/app/pages/index.vue around lines 21 to 55, the search input and three
select elements lack accessible names; add explicit associations by giving each
control a unique id and either a visible <label for="..."> (using a
visually-hidden CSS class if you don't want it shown) or an appropriate
aria-label/aria-labelledby attribute. For example, assign ids like
search-messages, date-range, message-status, api-key-env and add labels or
aria-labels such as "Search messages", "Date range", "Message status", and "API
key environment" so screen readers announce each control; keep existing
placeholders/options unchanged.

Comment on lines +86 to +104
const tokenCookie = useCookie('sendook_token', {
maxAge: 60 * 60 * 24 * 7,
sameSite: 'lax'
});
tokenCookie.value = response.token;
await navigateTo('/');
} catch (error: unknown) {
if (error && typeof error === 'object' && 'data' in error) {
const fetchError = error as { data?: { message?: string } };
errorMessage.value = fetchError.data?.message ?? 'Unable to sign in. Please try again.';
} else if (error instanceof Error) {
errorMessage.value = error.message;
} else {
errorMessage.value = 'Unable to sign in. Please try again.';
}
} finally {
loading.value = false;
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Set the session cookie with Secure outside of development

Right now the auth token cookie is created without the Secure flag, so any HTTP access to the same host can leak it. Because this token authenticates every subsequent call, that’s a security problem once the app is served over HTTPS. Please ensure the cookie is marked secure whenever you’re not in dev (and keep it disabled locally so the cookie still sets over plain HTTP). (nuxt.com)

Apply this diff:

-    const tokenCookie = useCookie('sendook_token', {
-      maxAge: 60 * 60 * 24 * 7,
-      sameSite: 'lax'
-    });
+    const tokenCookie = useCookie('sendook_token', {
+      maxAge: 60 * 60 * 24 * 7,
+      sameSite: 'lax',
+      secure: !import.meta.dev
+    });
📝 Committable suggestion

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

Suggested change
const tokenCookie = useCookie('sendook_token', {
maxAge: 60 * 60 * 24 * 7,
sameSite: 'lax'
});
tokenCookie.value = response.token;
await navigateTo('/');
} catch (error: unknown) {
if (error && typeof error === 'object' && 'data' in error) {
const fetchError = error as { data?: { message?: string } };
errorMessage.value = fetchError.data?.message ?? 'Unable to sign in. Please try again.';
} else if (error instanceof Error) {
errorMessage.value = error.message;
} else {
errorMessage.value = 'Unable to sign in. Please try again.';
}
} finally {
loading.value = false;
}
const tokenCookie = useCookie('sendook_token', {
maxAge: 60 * 60 * 24 * 7,
sameSite: 'lax',
secure: !import.meta.dev
});
tokenCookie.value = response.token;
await navigateTo('/');
} catch (error: unknown) {
if (error && typeof error === 'object' && 'data' in error) {
const fetchError = error as { data?: { message?: string } };
errorMessage.value = fetchError.data?.message ?? 'Unable to sign in. Please try again.';
} else if (error instanceof Error) {
errorMessage.value = error.message;
} else {
errorMessage.value = 'Unable to sign in. Please try again.';
}
} finally {
loading.value = false;
}
🤖 Prompt for AI Agents
In app/app/pages/login.vue around lines 86 to 104, the auth token cookie is
created without the Secure flag; update the useCookie options to include a
secure property that is true when not running in development (for example
secure: process.env.NODE_ENV !== 'development' or using Nuxt's runtime/dev
flag), so the cookie is marked Secure in production/HTTPS but remains unset for
local HTTP development; keep all other options (maxAge, sameSite) as-is and
ensure the secure value is computed before assigning tokenCookie.value.

Comment on lines +29 to +32
<div class="user-avatar">M</div>
<div class="user-details">
<p class="user-name">Marc Whitbread</p>
<p class="user-email">marc@sendook.dev</p>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Critical: Hardcoded user data must be replaced with dynamic values.

The user avatar, name, and email are hardcoded in the template. This will display the same information for all users and is a blocker for production deployment.

Replace the hardcoded values with dynamic user data from your authentication system:

<div class="user-avatar">{{ userInitial }}</div>
<div class="user-details">
  <p class="user-name">{{ userName }}</p>
  <p class="user-email">{{ userEmail }}</p>
</div>

Then add computed properties or props in your script section to provide these values from useRequireAuth() or a similar composable.

🤖 Prompt for AI Agents
In app/layouts/default.vue around lines 29 to 32 the template currently contains
hardcoded user info (avatar "M", name "Marc Whitbread", email
"marc@sendook.dev"); replace these with dynamic template bindings (e.g.
userAvatar/initial, userName, userEmail) and wire them up in the script section
by importing your auth composable (useRequireAuth or equivalent), deriving
userName and userEmail from the returned user object and computing userInitial
(first letter of name or email) as a computed property; ensure the template uses
the computed values and that the composable is awaited/handled appropriately so
no undefined values render.

Copy link

@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)
api/controllers/SESController.ts (1)

64-100: Restore BCC delivery in the SES MIME flow.
MailComposer never receives a bcc field, so BCC recipients are dropped. Even if we add it, the composer strips the header unless we call compiled.keepBcc = true, and SES still refuses to deliver unless we pass the flattened recipient list as Destinations. Right now every BCC recipient silently misses the email. Please wire the BCC data through and keep it in the built MIME.

Apply this diff to preserve BCC recipients:

 const mailOptions: any = {
   from: fromName ? `"${fromName}" <${from}>` : from,
   to: (to ?? []).join(", "),
   cc: (cc ?? []).join(", "),
+  bcc: (bcc ?? []).join(", "),
   subject,
   text,
   html,
@@
-  const composer = new MailComposer(mailOptions);
-  const mimeMessage = await new Promise<Buffer>((resolve, reject) => {
-    composer.compile().build((err, message) => {
+  const composer = new MailComposer(mailOptions);
+  const compiled = composer.compile();
+  compiled.keepBcc = true;
+
+  const mimeMessage = await new Promise<Buffer>((resolve, reject) => {
+    compiled.build((err, message) => {
       if (err) return reject(err);
       resolve(message);
     });
   });
 
+  const destinations = Array.from(
+    new Set([...(to ?? []), ...(cc ?? []), ...(bcc ?? [])].filter(Boolean))
+  );
+
   const command = new SendRawEmailCommand({
     RawMessage: { Data: mimeMessage },
+    Destinations: destinations.length ? destinations : undefined,
     Tags: [
       {
         Name: "message",
api/routes/v1/inboxes/messages.ts (1)

193-204: Block cross-inbox replies.
We still only check organization ownership here, so a client can reply to a message that belongs to a different inbox under the same org and we’ll file the response under the wrong inbox/thread. That mixes conversations and leaks context. Please reject when replyToMessage.inboxId doesn’t match the requested inbox.

Apply this diff to enforce the guard:

   if (
     !replyToMessage ||
     replyToMessage.organizationId.toString() !== req.organization._id.toString()
   ) {
     return res.status(404).json({ error: "Message not found" });
   }
+  if (replyToMessage.inboxId.toString() !== req.params.inboxId) {
+    return res.status(404).json({ error: "Message not found" });
+  }
🧹 Nitpick comments (3)
app/app/pages/domains.vue (3)

91-96: Add domain name format validation.

The domain input only has the required attribute but doesn't validate the domain format. Users could submit invalid values like "not a domain" or "example..com", leading to backend errors.

Add pattern validation to the input:

 <input
   v-model="form.name"
   type="text"
   placeholder="example.com"
   required
+  pattern="^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$"
+  title="Please enter a valid domain name (e.g., example.com)"
   :disabled="saving">

Alternatively, add validation in handleCreateDomain before the API call:

const domainPattern = /^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
if (!domainPattern.test(form.name.trim())) {
  errorMessage.value = 'Please enter a valid domain name (e.g., example.com)';
  return;
}

196-226: Domain loading errors are not visible to users.

When loadDomains fails, the error is logged to the console and sets errorMessage.value (line 221), but this error is not displayed anywhere in the UI. The errorMessage is only shown in the create domain dialog, not in the main domains view.

Add a dedicated state and UI element for loading errors:

 const errorMessage = ref('');
+const loadError = ref('');

 const loadDomains = async () => {
   // ... existing code ...
+  loadError.value = '';
   
   try {
     // ... existing code ...
   } catch (error) {
     console.error('Failed to load domains', error);
-    errorMessage.value = 'Unable to load domains. Please try again.';
+    loadError.value = 'Unable to load domains. Please try again.';
     domains.value = [];
   } finally {
     loading.value = false;
   }
 };

Then display it in the template:

<template v-else>
+  <div v-if="loadError" class="banner banner-error" role="alert">
+    {{ loadError }}
+  </div>
  <div v-if="verificationMessage" class="banner banner-success">
    {{ verificationMessage }}
  </div>
  <!-- ... -->
</template>

217-218: Consider simplifying response type handling.

The nested ternary operator for handling different response formats works correctly but is hard to read.

Extract into a helper function for clarity:

+const normalizeDomains = (data: unknown): Domain[] => {
+  if (Array.isArray(data)) return data;
+  if (data && typeof data === 'object' && 'data' in data && Array.isArray(data.data)) {
+    return data.data;
+  }
+  return [];
+};

 const response = await fetch(/* ... */);
 const data = await response.json();
-domains.value = Array.isArray(data) ? data : Array.isArray(data?.data) ? data.data : [];
+domains.value = normalizeDomains(data);
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b0f49df and 5c255cf.

⛔ Files ignored due to path filters (1)
  • api/bun.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • api/controllers/ApiKeyController.ts (2 hunks)
  • api/controllers/SESController.ts (3 hunks)
  • api/db/redis/index.ts (1 hunks)
  • api/package.json (2 hunks)
  • api/routes/v1/api_keys/index.ts (1 hunks)
  • api/routes/v1/inboxes/messages.ts (8 hunks)
  • app/app/pages/domains.vue (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • api/controllers/ApiKeyController.ts
  • api/package.json
  • api/routes/v1/api_keys/index.ts
🧰 Additional context used
🧬 Code graph analysis (1)
api/routes/v1/inboxes/messages.ts (5)
api/db/redis/index.ts (1)
  • redis (3-3)
api/controllers/InboxController.ts (1)
  • getInboxByOrganizationIdAndInboxId (32-43)
api/controllers/ThreadController.ts (1)
  • createThread (4-17)
api/controllers/MessageController.ts (1)
  • getMessages (86-110)
api/controllers/SESController.ts (1)
  • sendSESMessage (37-101)
🔇 Additional comments (1)
app/app/pages/domains.vue (1)

146-383: Well-structured component with proper security practices.

The component demonstrates good practices:

  • Proper use of Vue 3 Composition API
  • Secure API calls with bearer token authentication
  • Appropriate error handling and loading states
  • Reactive session-based data loading
  • Clean separation of concerns

</section>

<section v-else class="domains-grid">
<article v-for="domain in domains" :key="domain._id ?? domain.name" class="domain-card">
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Inconsistent domain identifier usage across operations.

The code uses different fallback orders for domain identifiers:

  • Line 37: Key uses domain._id ?? domain.name
  • Lines 68-69, 253, 266: Uses domain.name ?? domain._id
  • Line 338, 349: Uses only pendingDelete.value?._id

This inconsistency could cause issues if the backend expects a specific identifier type (name vs _id) in API endpoints. For example, the verify endpoint might receive a domain name while the delete endpoint receives an _id for the same domain.

Standardize identifier usage across all operations. Determine whether the backend expects name or _id in URL paths, then use that consistently:

-const domainId = domain.name ?? domain._id;
+// If backend expects name:
+const domainId = domain.name;
+// OR if backend expects _id:
+const domainId = domain._id;

// Also standardize the key in the v-for:
-:key="domain._id ?? domain.name"
+:key="domain._id"

Additionally, add validation to ensure the required identifier exists before making API calls.

Also applies to: 68-69, 253-253, 266-266, 338-338, 349-349

Comment on lines +63 to +64
<footer v-if="!domain.verified">
<span>{{ domain.verified ? 'Verified' : 'DNS pending verification' }}</span>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Dead code: 'Verified' text is unreachable.

The footer only renders when !domain.verified (line 63), but line 64 includes a ternary that checks domain.verified. The "Verified" branch will never execute.

Simplify to remove the dead code:

 <footer v-if="!domain.verified">
-  <span>{{ domain.verified ? 'Verified' : 'DNS pending verification' }}</span>
+  <span>DNS pending verification</span>
📝 Committable suggestion

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

Suggested change
<footer v-if="!domain.verified">
<span>{{ domain.verified ? 'Verified' : 'DNS pending verification' }}</span>
<footer v-if="!domain.verified">
<span>DNS pending verification</span>
🤖 Prompt for AI Agents
In app/app/pages/domains.vue around lines 63 to 64, the footer is only rendered
when !domain.verified but the span contains a ternary that includes a "Verified"
branch which is unreachable; simplify by removing the ternary and render the
appropriate static text (e.g., "DNS pending verification") or adjust the
conditional so the span reflects the correct state (either show the span only
when not verified with static "DNS pending verification", or render a separate
footer for the verified case).

Copy link

@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

🧹 Nitpick comments (2)
app/app/pages/inbox/[inboxId].vue (2)

86-88: Remove commented code.

The router import is commented out and unused. Clean up dead code to improve maintainability.

 const route = useRoute();
-// const router = useRouter();
 const session = useRequireAuth();

190-192: Consider structured error logging over console.error.

Using console.error directly logs errors only to the browser console. For production environments, consider using a structured logging service or error tracking tool (e.g., Sentry) for better observability.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5c255cf and ba71474.

📒 Files selected for processing (3)
  • app/app/components/dashboard/DashboardShell.vue (1 hunks)
  • app/app/pages/inbox/[inboxId].vue (1 hunks)
  • app/app/pages/inboxes.vue (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/app/pages/inboxes.vue
🔇 Additional comments (8)
app/app/pages/inbox/[inboxId].vue (4)

119-119: Verify fallback to _id for inbox address display.

Falling back to the MongoDB _id when email is unavailable will display a cryptic ObjectId string to users. Consider whether a more user-friendly fallback (e.g., "No email configured" or omitting the display) would be better.


121-131: LGTM!

The client-side search filtering logic correctly handles optional fields, filters falsy values, and performs case-insensitive matching across multiple message properties.


133-147: LGTM!

The date formatting helper correctly validates input, handles invalid dates gracefully, and provides locale-aware formatting.


198-206: LGTM!

The watcher correctly tracks all dependencies and refetches messages when organization, inbox, or auth token changes, with immediate execution on mount.

app/app/components/dashboard/DashboardShell.vue (4)

60-74: LGTM!

The user display name logic correctly prioritizes first/last name over email, with proper type narrowing and filtering of empty values.


78-83: LGTM!

The active link detection correctly handles the root path with exact matching and nested paths with prefix matching.


85-90: LGTM!

The logout handler correctly clears the auth cookie client-side and redirects using replace to prevent navigating back to authenticated state. The typeof document check ensures SSR safety.


94-111: Verify sidebar width and shell padding alignment.

The dashboard shell has padding-left: 240px (line 96) but the sidebar width is 200px (line 108), creating a 40px gap. Verify whether this spacing is intentional or if the values should match.

Comment on lines +232 to +251
@media (max-width: 1024px) {
.dashboard-shell {
grid-template-columns: 1fr;
}
.dashboard-sidebar {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.dashboard-sidebar ul {
display: flex;
flex-wrap: wrap;
}
.dashboard-main {
padding: 1.75rem;
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Critical: Responsive layout is broken.

The media query attempts to set grid-template-columns: 1fr on .dashboard-shell (line 234), but the shell doesn't use CSS Grid layout—it uses padding-left positioning. This rule has no effect, and the responsive layout won't work correctly.

Fix the responsive layout by removing the fixed positioning and adjusting the shell padding:

 @media (max-width: 1024px) {
   .dashboard-shell {
-    grid-template-columns: 1fr;
+    padding-left: 0;
   }
 
   .dashboard-sidebar {
+    position: relative;
+    width: 100%;
+    height: auto;
     flex-direction: row;
     align-items: center;
     justify-content: space-between;
   }

You'll also need to adjust the sidebar layout significantly for mobile, as the current fixed positioning with transformed flex-direction won't provide a usable mobile experience.

📝 Committable suggestion

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

Suggested change
@media (max-width: 1024px) {
.dashboard-shell {
grid-template-columns: 1fr;
}
.dashboard-sidebar {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.dashboard-sidebar ul {
display: flex;
flex-wrap: wrap;
}
.dashboard-main {
padding: 1.75rem;
}
}
@media (max-width: 1024px) {
.dashboard-shell {
padding-left: 0;
}
.dashboard-sidebar {
position: relative;
width: 100%;
height: auto;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.dashboard-sidebar ul {
display: flex;
flex-wrap: wrap;
}
.dashboard-main {
padding: 1.75rem;
}
}
🤖 Prompt for AI Agents
In app/app/components/dashboard/DashboardShell.vue around lines 232 to 251, the
media query sets grid-template-columns on .dashboard-shell even though the shell
uses fixed positioning and padding-left for layout; remove/override the fixed
positioning and left padding rules for mobile, switch the shell to a
single-column flow (e.g., make it static/position:initial and remove the left
padding so content can stack), and update .dashboard-sidebar to be a normal
block-level section that spans full width (remove transforms/fixed behavior),
change the sidebar list to a vertical column (no wrap, display: block or column
flex) and adjust .dashboard-main padding to the provided smaller value so
content is usable on small screens; ensure all position/transform/fixed styles
applied elsewhere for desktop are overridden inside this media query so the
mobile layout stacks correctly.

Comment on lines +24 to +32
<section v-if="loading" class="placeholder-card">
<h2>Loading messages…</h2>
<p>Fetching the conversation history for this inbox.</p>
</section>

<section v-else-if="messages.length === 0" class="placeholder-card">
<h2>No messages yet</h2>
<p>Send a test email to this inbox or wait for new activity to appear here.</p>
</section>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Error state is tracked but never displayed.

The component tracks an error ref (line 111) and populates it in the catch block (line 192), but there's no corresponding error display in the template. Users won't see fetch failures.

Add an error display section:

+    <section v-else-if="error" class="placeholder-card error-card">
+      <h2>Unable to load messages</h2>
+      <p>{{ error }}</p>
+    </section>
+
     <section v-else-if="messages.length === 0" class="placeholder-card">
       <h2>No messages yet</h2>
       <p>Send a test email to this inbox or wait for new activity to appear here.</p>
     </section>

You'll also need to add error styling:

.error-card {
  border-color: rgba(248, 113, 113, 0.2);
  background: rgba(248, 113, 113, 0.05);
}

.error-card h2 {
  color: #fecaca;
}
🤖 Prompt for AI Agents
In app/app/pages/inbox/[inboxId].vue around lines 24 to 32, the template never
shows the tracked error ref; insert an error display section (e.g., <section
v-else-if="error" class="error-card"> with an h2 and a paragraph showing a short
message and the error text) between the loading and empty states so the order
becomes: loading -> error -> no-messages -> messages; also add the provided CSS
rules (.error-card and .error-card h2) to the component/style block so the error
state is visually distinct.

Copy link

@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 (2)
node-sdk/openapi.yaml (2)

50-59: Add pagination parameters and maxItems constraints to list endpoint responses.

Array responses lack maxItems constraints, and list endpoints (/v1/api_keys, /v1/inboxes, /v1/inboxes/{inboxId}/messages, /v1/inboxes/{inboxId}/threads) have no pagination parameters documented. Consider adding:

  • Query parameters for pagination (e.g., limit, offset, or page, pageSize)
  • maxItems constraint on array schemas to prevent unbounded responses
  • Clearer documentation of how clients should handle large result sets

For example, update listApiKeys to include pagination:

   get:
     tags:
       - API Keys
     summary: List API keys
     operationId: listApiKeys
+    parameters:
+      - name: limit
+        in: query
+        required: false
+        description: Maximum number of items to return
+        schema:
+          type: integer
+          default: 25
+          maximum: 100
+      - name: offset
+        in: query
+        required: false
+        description: Number of items to skip
+        schema:
+          type: integer
+          default: 0
     responses:
       "200":
         description: A collection of API keys
         content:
           application/json:
             schema:
               type: object
               properties:
                 data:
                   type: array
+                  maxItems: 100
                   items:
                     $ref: '#/components/schemas/ApiKey'

Also applies to: 220-224, 339-342, 387-390


555-561: Reconsider strict requirement for both text and html in message requests.

Both MessageSendRequest and MessageReplyRequest require both text and html fields. In practice, many email APIs allow either/or or just one. If your backend supports sending text-only or HTML-only messages, adjust the schema to reflect that flexibility:

 MessageSendRequest:
   type: object
-  required:
-    - to
-    - subject
-    - text
-    - html
+  required:
+    - to
+    - subject
   properties:
     to:
       type: array
       items:
         type: string
         format: email
       description: Primary recipients.
     cc:
       type: array
       items:
         type: string
         format: email
     bcc:
       type: array
       items:
         type: string
         format: email
     labels:
       type: array
       items:
         type: string
     subject:
       type: string
     text:
       type: string
+      nullable: true
     html:
       type: string
+      nullable: true

(Adjust based on your actual backend constraints.)

Also applies to: 589-598

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b72cda8 and e89de3a.

📒 Files selected for processing (1)
  • node-sdk/openapi.yaml (1 hunks)
🧰 Additional context used
🪛 Checkov (3.2.334)
node-sdk/openapi.yaml

[medium] 57-61: Ensure that arrays have a maximum number of items

(CKV_OPENAPI_21)

🔇 Additional comments (2)
node-sdk/openapi.yaml (2)

555-588: Missing attachment support in message schemas.

The PR summary mentions "multi-recipient messages with embedded attachments," but the MessageSendRequest and MessageReplyRequest schemas lack an attachments field. Verify whether attachments are intended to be part of these payloads or handled separately, and update the schema accordingly.


1-70: Well-structured and comprehensive OpenAPI specification.

The overall organization, endpoint design, consistent parameter naming, and proper HTTP status codes align well with REST conventions. The spec clearly documents the API contract for all five resource groups and will be useful for SDK generation and client integration.

Copy link

@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

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e89de3a and bca9687.

⛔ Files ignored due to path filters (1)
  • landing/bun.lockb is excluded by !**/bun.lockb
📒 Files selected for processing (6)
  • landing/content/1.docs/3.api/1.index.md (1 hunks)
  • landing/content/1.docs/3.api/2.api-keys.md (1 hunks)
  • landing/content/1.docs/3.api/3.domains.md (1 hunks)
  • landing/content/1.docs/3.api/4.inboxes.md (1 hunks)
  • landing/content/1.docs/3.api/5.messages.md (1 hunks)
  • landing/content/1.docs/3.api/6.threads.md (1 hunks)
✅ Files skipped from review due to trivial changes (2)
  • landing/content/1.docs/3.api/2.api-keys.md
  • landing/content/1.docs/3.api/1.index.md
🧰 Additional context used
🪛 markdownlint-cli2 (0.18.1)
landing/content/1.docs/3.api/4.inboxes.md

96-96: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🔇 Additional comments (4)
landing/content/1.docs/3.api/4.inboxes.md (1)

1-95: Documentation structure and content look good.

The inbox API documentation is comprehensive, well-organized, and clearly describes each operation with appropriate request/response examples. Field descriptions are helpful, and the delete endpoint properly communicates side effects.

Also applies to: 99-101

landing/content/1.docs/3.api/3.domains.md (1)

1-76: Domain API documentation is complete and well-structured.

The documentation clearly describes the domain lifecycle from creation through verification, with appropriate status values and async handling via polling. The post-creation guidance about DKIM/SPF is helpful for onboarding flows.

Also applies to: 80-99

landing/content/1.docs/3.api/5.messages.md (1)

1-137: Message API documentation is comprehensive and well-organized.

The endpoint descriptions, request/response examples, and status codes are clear and accurate. The async send pattern with 202 Accepted is properly documented, and the querying/filtering mechanism is well-explained.

landing/content/1.docs/3.api/6.threads.md (1)

1-81: Thread API documentation is clear and complete.

The documentation effectively explains the thread concept and provides clear examples of both list and retrieve operations. The note about message ordering (newest→oldest) and guidance on accessing full message metadata is helpful for API consumers building conversational UIs.

Comment on lines +77 to +79
```
<empty>
```
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Specify language tag for empty code block.

Line 77 contains a fenced code block without a language specification. The formatting should be updated to resolve the markdown linting warning.

Apply this diff to resolve the linting issue:

 ### Response `204 No Content`
 
-```
-<empty>
-```
+```text
+<empty>
+```
🤖 Prompt for AI Agents
In landing/content/1.docs/3.api/3.domains.md around lines 77 to 79, the fenced
code block on line 77 is missing a language tag; update the fence to include a
language (use "text") so the block becomes ```text, keep the existing empty
content inside the block, and close with ```, ensuring the diff replaces the
empty ``` with ```text and the content remains unchanged.

Comment on lines +96 to +98
```
<empty>
```
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Specify language tag for empty code block.

Line 96 contains a fenced code block without a language specification, which triggers markdown linting warnings. While the empty response is semantically correct for 204 No Content, the formatting should be updated.

Apply this diff to resolve the linting issue:

 ### Response `204 No Content`
 
-```
-<empty>
-```
+```text
+<empty>
+```
🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

96-96: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
In landing/content/1.docs/3.api/4.inboxes.md around lines 96-98 there is a
fenced code block for an empty response that lacks a language tag, causing
markdown lint warnings; update the opening fence to include a language tag (use
"text") so the block reads as a fenced code block with language "text", keep the
"<empty>" line inside and preserve the closing fence.

Comment on lines +138 to +150
## Message schema

The Message schema includes the following core fields (additional metadata may be present):

| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | string | Unique message identifier. |
| `subject` | string | Email subject line. |
| `text` | string | Plain-text content. |
| `html` | string | HTML content. |
| `labels` | string[] | Custom labels assigned to the message. |
| `createdAt` | string (date-time) | Timestamp when the message was created. |

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add missing fields to Message schema table.

The Message schema table is incomplete. It documents only id, subject, text, html, labels, and createdAt, but the request/response examples throughout the documentation include to, cc, bcc fields that should be documented in the schema. Additionally, the AI summary references embedded attachments, which should also be included if supported by the API.

Expand the schema table to include all documented message fields:

 | Field | Type | Description |
 | ----- | ---- | ----------- |
+| `to` | string[] | Primary recipient email addresses. |
+| `cc` | string[] | Carbon copy recipient email addresses. |
+| `bcc` | string[] | Blind carbon copy recipient email addresses. |
 | `id` | string | Unique message identifier. |
 | `subject` | string | Email subject line. |
 | `text` | string | Plain-text content. |
 | `html` | string | HTML content. |
 | `labels` | string[] | Custom labels assigned to the message. |
+| `attachments` | object[] | Embedded attachments with content, name, and contentType. |
 | `createdAt` | string (date-time) | Timestamp when the message was created. |
📝 Committable suggestion

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

Suggested change
## Message schema
The Message schema includes the following core fields (additional metadata may be present):
| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | string | Unique message identifier. |
| `subject` | string | Email subject line. |
| `text` | string | Plain-text content. |
| `html` | string | HTML content. |
| `labels` | string[] | Custom labels assigned to the message. |
| `createdAt` | string (date-time) | Timestamp when the message was created. |
## Message schema
The Message schema includes the following core fields (additional metadata may be present):
| Field | Type | Description |
| ----- | ---- | ----------- |
| `to` | string[] | Primary recipient email addresses. |
| `cc` | string[] | Carbon copy recipient email addresses. |
| `bcc` | string[] | Blind carbon copy recipient email addresses. |
| `id` | string | Unique message identifier. |
| `subject` | string | Email subject line. |
| `text` | string | Plain-text content. |
| `html` | string | HTML content. |
| `labels` | string[] | Custom labels assigned to the message. |
| `attachments` | object[] | Embedded attachments with content, name, and contentType. |
| `createdAt` | string (date-time) | Timestamp when the message was created. |
🤖 Prompt for AI Agents
In landing/content/1.docs/3.api/5.messages.md around lines 138 to 150, the
Message schema table is missing documented fields; update the table to add the
`to`, `cc`, and `bcc` recipient fields and the `attachments` field (and any
other message fields shown in examples) with appropriate types and short
descriptions (e.g., `to`, `cc`, `bcc` as array of recipient objects or strings;
`attachments` as array of attachment objects with
filename/content-type/size/URL), ensuring the types match the request/response
examples and include notes about optionality and structure.

Copy link

@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

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bca9687 and 9479386.

📒 Files selected for processing (1)
  • api/routes/webhooks/ses.ts (1 hunks)

const router = Router();

router.post("/", async (req, res) => {
console.log("Received SES delivery notification", req.body);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid logging potentially sensitive data in production.

SES delivery notifications may contain email addresses, recipient information, bounce details, or message content. Logging the entire req.body could expose PII and create compliance risks (GDPR, CCPA).

Consider one of these approaches:

-  console.log("Received SES delivery notification", req.body);
+  // Log only the notification type and timestamp, not the full payload
+  console.log("Received SES delivery notification", { type: payload?.Type, timestamp: new Date().toISOString() });

Or remove the log entirely and rely on structured observability tools that support PII redaction.

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

🤖 Prompt for AI Agents
In api/routes/webhooks/ses.ts around line 7, the code logs the entire req.body
which can expose PII; remove the raw console.log and either (1) redact sensitive
fields before logging (only include non-PII fields like eventType, timestamp,
messageId), (2) switch to structured logging at a debug level so it’s disabled
in production, or (3) remove the log entirely and rely on observability tooling
with PII redaction; implement one of these options and ensure no raw req.body is
written to logs.


router.post("/", async (req, res) => {
console.log("Received SES delivery notification", req.body);
const payload = JSON.parse(req.body);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify JSON.parse usage and add error handling.

If Express body-parser middleware is configured for this route, req.body will already be parsed as an object, and JSON.parse() will throw a TypeError. However, SES sends notifications with Content-Type: text/plain, so raw body parsing may be intentional.

Please verify the middleware configuration and add error handling:

Regardless of the outcome, add error handling to prevent uncaught exceptions:

-  const payload = JSON.parse(req.body);
+  let payload;
+  try {
+    payload = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
+  } catch (error) {
+    console.error("Failed to parse SES notification", error);
+    return res.status(400).send();
+  }

🏁 Script executed:

#!/bin/bash
# Description: Check for body-parser or express.json() middleware configuration
# and verify how this webhook route is mounted

# Look for middleware configuration
rg -n "express\.json|bodyParser|express\.text" --type=ts -C3

# Find where the SES webhook route is mounted
rg -n "webhooks/ses" --type=ts -C3

Length of output: 225



---

🏁 Script executed:

```shell
#!/bin/bash
# Get the full webhooks.ts file to see how sesRouter is mounted
cat api/routes/webhooks.ts

Length of output: 155


🏁 Script executed:

#!/bin/bash
# Get the full ses.ts file to see current implementation
cat api/routes/webhooks/ses.ts

Length of output: 474


🏁 Script executed:

#!/bin/bash
# Search for express app setup and middleware configuration
rg -n "app\.use|express\.json|bodyParser" --type=ts -C2

Length of output: 1045


🏁 Script executed:

#!/bin/bash
# Find where webhooks router is mounted in the main app
rg -n "webhooks" --type=ts -C2

Length of output: 1686



---

🏁 Script executed:

```shell
#!/bin/bash
# Check imports in api/index.ts to confirm text() is from express
head -20 api/index.ts | grep -E "^import|^const"

Length of output: 544


Add error handling for JSON.parse failure.

The text() middleware is applied globally (api/index.ts:28), so req.body is a string and JSON.parse() is correct. However, without error handling, malformed JSON from SES will throw an uncaught exception.

  const payload = JSON.parse(req.body);

Add try-catch to handle parsing failures:

-  const payload = JSON.parse(req.body);
+  let payload;
+  try {
+    payload = JSON.parse(req.body);
+  } catch (error) {
+    console.error("Failed to parse SES notification", error);
+    return res.status(400).send();
+  }
📝 Committable suggestion

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

Suggested change
const payload = JSON.parse(req.body);
let payload;
try {
payload = JSON.parse(req.body);
} catch (error) {
console.error("Failed to parse SES notification", error);
return res.status(400).send();
}
🤖 Prompt for AI Agents
In api/routes/webhooks/ses.ts around line 8, JSON.parse(req.body) can throw on
malformed SES payloads; wrap the parse in a try-catch, log the parse error
(using the existing logger) and return an immediate HTTP 400 (or other
appropriate error response) to the caller instead of allowing an uncaught
exception to crash the handler; ensure the successful path uses the parsed
payload variable after the try block.

@ahmedmawiri ahmedmawiri merged commit 3688f38 into main Nov 13, 2025
1 check passed
@ahmedmawiri ahmedmawiri deleted the marc/rup-884-email-agent-mvp-v5 branch November 13, 2025 17:23
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