Skip to content

Conversation

@marcrupt
Copy link
Collaborator

@marcrupt marcrupt commented Nov 7, 2025

Summary by CodeRabbit

  • New Features

    • Inbox creation can omit email (generated when absent)
    • Messages support threads, external IDs, and a new "received" status
    • SES inbound/outbound flows added; emits a "message.received" webhook
  • API Changes

    • Inbox routes moved under /v1/inboxes with dedicated message and thread endpoints
    • SDK exposes an inbox surface (create/list/get) with nested message/thread methods and updated base URL
  • Tests

    • New E2E and controller tests for inboxes, messages, threads; some legacy tests removed

@marcrupt marcrupt requested a review from ahmedmawiri November 7, 2025 16:03
@linear
Copy link

linear bot commented Nov 7, 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 7, 2025

Walkthrough

Move inbox/message routes under a versioned /v1 router; make inbox email optional with generation fallback; add threads and thread linkage to messages (threadId, externalMessageId, "received" status); refactor SES handling into inbound/outbound handlers; update schemas, controllers, Node SDK, and tests.

Changes

Cohort / File(s) Summary
Routing / API surface
api/index.ts, api/routes/inboxes.ts (removed), api/routes/organizations.ts, api/routes/organizations/inboxes.ts (removed), api/routes/v1/index.ts, api/routes/v1/inboxes/index.ts, api/routes/v1/inboxes/messages.ts, api/routes/v1/inboxes/threads.ts
Replace legacy inbox routes with a versioned /v1 router; move inbox/message/thread endpoints under /v1/inboxes; remove old organization-level inbox subroutes; enforce API-key auth and derive organization from req.user.
Inbox controller & tests
api/controllers/AuthController.ts, api/controllers/InboxController.ts, api/tests/InboxController.spec.ts, api/tests/MessageController.spec.ts
Make createInbox email optional (no longer precomputing a random email on create); update/add tests to create inboxes without providing email and to create messages linked to threads.
Message model, controller & schema
api/models/Message.ts, api/controllers/MessageController.ts, api/db/mongo/schemas/Message.ts
Add "received" to MessageStatus; require threadId, add optional externalMessageId; extend createMessage to accept/assign threadId, externalMessageId, and status; add helpers getMessagesByInboxId and getMessageByExternalMessageId.
Thread utilities & schema
api/controllers/ThreadController.ts, api/db/mongo/schemas/Thread.ts
Add thread controller functions (createThread, addMessageToThread, getThreadsByInboxId, getThreadById); introduce/rename Thread schema/model and store message references.
SES handling & webhooks
api/controllers/SESController.ts, api/routes/webhooks/ses.ts, api/models/SES.ts, api/models/Webhook.ts
Refactor delivery notification flow into handleInboundSESMessage and handleOutboundSESMessage; inbound parses base64 content, creates/associates inbox/thread/message and emits message.received; outbound updates message status on SES events and emits mapped webhook events; add content to SNSMessage type and "message.received" to webhook events; remove debug logs.
Dependencies
api/package.json
Add email-reply-parser, mailparser and their @types packages.
Node SDK surface & tests
node-sdk/index.ts, node-sdk/tests/InboxAPI.spec.ts, node-sdk/tests/InboxController.spec.ts (removed), node-sdk/tests/messageAPI.spec.ts, node-sdk/tests/threadAPI.spec.ts
Replace flat createInbox method with inbox namespace (create, list, get, nested message and thread methods); change default base URL to https://api.sendook.com and use /v1 in endpoints; update/add SDK tests.
Misc tests & infra
api/tests/*, node-sdk/tests/*
Add and remove tests to reflect API/SDK surface changes and new thread/message workflows; adjust DB/test setup to new flows.

Sequence Diagram(s)

sequenceDiagram
    participant SNS as AWS SNS
    participant WebhookRoute as /webhooks/ses
    participant SESCtrl as SESController
    participant InboxCtrl as InboxController
    participant ThreadCtrl as ThreadController
    participant MsgCtrl as MessageController
    participant DB as Database
    participant Emit as WebhookEmitter

    SNS->>WebhookRoute: POST Notification
    WebhookRoute->>SESCtrl: handleDeliveryNotification(notification)
    SESCtrl->>SESCtrl: inspect notification & tags
    alt inbound (no messageId tag)
        SESCtrl->>InboxCtrl: ensure/create inbox (email optional)
        SESCtrl->>ThreadCtrl: resolve/create thread (reply refs)
        SESCtrl->>MsgCtrl: createMessage(..., status: "received", externalMessageId?)
        MsgCtrl->>DB: persist message (with threadId)
        SESCtrl->>ThreadCtrl: addMessageToThread(threadId, messageId)
        SESCtrl->>Emit: sendWebhookEvent("message.received")
    else outbound (has messageId tag)
        SESCtrl->>MsgCtrl: getMessageByExternalMessageId/messageId
        SESCtrl->>SESCtrl: map SES event => message status/event
        SESCtrl->>MsgCtrl: update message.status
        SESCtrl->>Emit: sendWebhookEvent(mapped_event)
    end
    WebhookRoute-->>SNS: 200 OK
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

  • Areas needing extra attention:
    • api/controllers/SESController.ts — inbound parsing, thread resolution, status mapping, webhook emissions.
    • api/models/Message.ts and api/db/mongo/schemas/Message.ts — schema/typing changes for threadId and externalMessageId.
    • api/controllers/MessageController.ts — new parameters, persistence, and fetch helpers.
    • Routing changes — v1 wiring, removed legacy routes, and authentication usage via req.user.
    • node-sdk/index.ts — public API surface and base URL/path handling.
    • Tests — DB setup/teardown and correctness of new inbox/email generation flows.

Poem

🐇 I hopped through code and found a door,

v1 nests threads where messages explore,
SES leaves spun to inbox and thread,
SDK hums with inboxes newly freed,
I twitch my whiskers — updated, adored!

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 'inbounds and parsing' is vague and does not clearly convey the specific changes made. While it hints at inbound handling and parsing, it lacks specificity about what the PR accomplishes (e.g., adding SES webhook handling, refactoring inbox routing to v1 API, or thread/message management). Revise the title to be more descriptive and specific, such as 'Add SES webhook inbound/outbound handling and refactor inbox API to v1 routing' or 'Implement email parsing and thread management for inbound messages'.
✅ 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-v3

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 797d6bd and a44ea0d.

📒 Files selected for processing (1)
  • api/routes/v1/inboxes/messages.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
api/routes/v1/inboxes/messages.ts (6)
api/models/Organization.ts (1)
  • Organization (3-12)
api/controllers/InboxController.ts (1)
  • getInboxByOrganizationIdAndInboxId (32-43)
api/controllers/ThreadController.ts (2)
  • createThread (4-17)
  • addMessageToThread (19-33)
api/controllers/MessageController.ts (3)
  • createMessage (5-47)
  • getMessagesByInboxId (53-55)
  • getMessageById (49-51)
api/controllers/SESController.ts (1)
  • sendSESMessage (36-83)
api/controllers/WebhookAttemptController.ts (1)
  • sendWebhookEvent (10-55)

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

@ahmedmawiri
Copy link
Contributor

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Nov 7, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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: 4

Caution

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

⚠️ Outside diff range comments (1)
api/models/SES.ts (1)

92-103: Consider making the content field optional.

The content field is added as required, but SES notifications don't always include message content (e.g., delivery, bounce, and complaint notifications typically lack content). This could cause runtime errors when processing outbound SES events that don't contain the full message body.

Apply this diff to make the field optional:

 export type SNSMessage = {
   notificationType?: NotificationType;
   eventType?: NotificationType;
   mail: SNSMessageMail;
   bounce?: SNSMessageBounce<BounceType>;
   complaint?: SNSMessageComplaint;
   delivery?: SNSMessageDelivery;
   reject?: any;
   failure?: any;
   deliveryDelay?: any;
-  content: string;
+  content?: string;
 };
🧹 Nitpick comments (1)
api/tests/InboxController.spec.ts (1)

3-4: Consider adding type definitions instead of using @ts-ignore.

Using @ts-ignore suppresses type checking for the bun:test import. Consider adding type definitions for bun:test or using @ts-expect-error with a comment explaining why types are unavailable.

Apply this diff:

-// @ts-ignore
-import { expect, beforeAll, afterAll, describe, it } from "bun:test";
+// @ts-expect-error - Bun test types not yet available in @types
+import { expect, beforeAll, afterAll, describe, it } from "bun:test";
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1538bc0 and a7b2890.

⛔ Files ignored due to path filters (1)
  • api/bun.lock is excluded by !**/*.lock
📒 Files selected for processing (22)
  • api/controllers/AuthController.ts (0 hunks)
  • api/controllers/InboxController.ts (1 hunks)
  • api/controllers/MessageController.ts (4 hunks)
  • api/controllers/SESController.ts (3 hunks)
  • api/index.ts (2 hunks)
  • api/models/Message.ts (1 hunks)
  • api/models/SES.ts (1 hunks)
  • api/models/Webhook.ts (1 hunks)
  • api/package.json (2 hunks)
  • api/routes/inboxes.ts (0 hunks)
  • api/routes/organizations.ts (0 hunks)
  • api/routes/organizations/inboxes.ts (0 hunks)
  • api/routes/v1/inboxes/index.ts (1 hunks)
  • api/routes/v1/inboxes/messages.ts (3 hunks)
  • api/routes/v1/index.ts (1 hunks)
  • api/routes/webhooks/ses.ts (0 hunks)
  • api/tests/InboxController.spec.ts (1 hunks)
  • api/tests/MessageController.spec.ts (0 hunks)
  • node-sdk/index.ts (1 hunks)
  • node-sdk/tests/InboxAPI.spec.ts (1 hunks)
  • node-sdk/tests/InboxController.spec.ts (0 hunks)
  • node-sdk/tests/messageAPI.spec.ts (1 hunks)
💤 Files with no reviewable changes (7)
  • api/routes/inboxes.ts
  • api/routes/organizations/inboxes.ts
  • api/routes/organizations.ts
  • api/controllers/AuthController.ts
  • node-sdk/tests/InboxController.spec.ts
  • api/tests/MessageController.spec.ts
  • api/routes/webhooks/ses.ts
🧰 Additional context used
🧬 Code graph analysis (6)
api/routes/v1/inboxes/index.ts (4)
api/models/Organization.ts (1)
  • Organization (3-12)
api/controllers/InboxController.ts (3)
  • getInboxesByOrganizationId (26-30)
  • createInbox (4-24)
  • getInboxByOrganizationIdAndInboxId (32-43)
api/middlewares/expressValidatorMiddleware.ts (1)
  • expressValidatorMiddleware (4-16)
api/controllers/WebhookAttemptController.ts (1)
  • sendWebhookEvent (10-55)
api/controllers/MessageController.ts (1)
api/models/Message.ts (1)
  • MessageStatus (3-10)
api/tests/InboxController.spec.ts (2)
api/controllers/AuthController.ts (1)
  • register (21-45)
api/controllers/InboxController.ts (1)
  • createInbox (4-24)
api/controllers/InboxController.ts (1)
api/models/Inbox.ts (1)
  • Inbox (3-11)
api/routes/v1/inboxes/messages.ts (3)
api/models/Organization.ts (1)
  • Organization (3-12)
api/controllers/InboxController.ts (1)
  • getInboxByOrganizationIdAndInboxId (32-43)
api/controllers/MessageController.ts (1)
  • createMessage (5-41)
api/controllers/SESController.ts (6)
api/models/SES.ts (1)
  • SNSMessage (92-103)
api/controllers/InboxController.ts (1)
  • getInboxByEmail (45-47)
api/controllers/MessageController.ts (2)
  • createMessage (5-41)
  • getMessageById (43-45)
api/controllers/WebhookAttemptController.ts (1)
  • sendWebhookEvent (10-55)
api/models/Message.ts (1)
  • MessageStatus (3-10)
api/models/Webhook.ts (1)
  • WebhookEvents (3-13)
🔇 Additional comments (11)
api/package.json (1)

10-11: Dependencies verified as valid and secure.

All four packages have been confirmed to exist on npm with no active security vulnerabilities or compatibility issues: email-reply-parser@2.1.0, mailparser@3.9.0, @types/email-reply-parser@1.4.2, and @types/mailparser@3.4.6 are properly specified and ready for use.

api/models/Message.ts (1)

3-10: LGTM! The new "received" status is well-positioned.

The addition of "received" to the MessageStatus array is correct and semantically positioned between "sent" and "delivered", which aligns well with the message lifecycle for inbound messages.

api/models/Webhook.ts (1)

3-13: LGTM! The new webhook event aligns with the "received" status.

The addition of "message.received" to WebhookEvents is consistent with the naming pattern and complements the new "received" status added to MessageStatus.

api/tests/InboxController.spec.ts (1)

17-35: LGTM! The test validates the optional email behavior.

The test properly verifies that createInbox generates a random email when none is provided, and all assertions are appropriate for validating the inbox creation flow.

api/controllers/MessageController.ts (2)

3-3: LGTM! Type import added for status parameter.

The type import is correctly added to support the new optional status parameter.


15-15: LGTM! Status parameter implementation is correct.

The optional status parameter is properly typed, threaded through the function signature, and assigned to the message object. The implementation maintains type safety and allows for flexible message creation with or without a status.

Also applies to: 26-26, 38-38

api/controllers/InboxController.ts (1)

13-13: LGTM! Optional email parameter is correctly implemented.

The email parameter is now optional, and the fallback to getNewRandomInboxEmail is properly handled with the logical OR operator. This provides flexibility while maintaining backward compatibility.

Also applies to: 21-21

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

7-8: The route structure is correct—no conflicts exist.

The review comment's concern is based on a false premise. The messagesRouter is mounted only once at /inboxes/:inboxId/messages at the top level. The inboxesRouter does not mount it—the relevant line is commented out:

// router.use("/:inboxId/messages", messageRouter);

The actual route structure is clean and distinct:

  • /inboxes and its sub-routes (from inboxesRouter)
  • /inboxes/:inboxId/messages (from messagesRouter)

No redundancy or conflict occurs. The commented line indicates this alternative was considered but intentionally not used.

Likely an incorrect or invalid review comment.

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

13-48: Strong org scoping via API key.

Casting req.user to the org doc and driving all fetch/create calls from organization._id ensures callers can’t enumerate other tenants just by tweaking the path. Nice to see the webhook hooked in right after creation.

api/routes/v1/inboxes/messages.ts (1)

29-65: Scoped message creation looks solid.

Reusing req.user to resolve the org keeps every downstream call multi-tenant safe, and the webhook dispatch stays aligned with the message lifecycle.

api/controllers/SESController.ts (1)

131-155: Inbound parsing flow looks great.

Parsing the raw SES payload and storing a received message while firing message.received keeps the pipeline symmetrical with outbound events. Nice touch with EmailReplyParser to trim quoted replies.

import organizationsRouter from "./routes/organizations";
import webhooksRouter from "./routes/webhooks";
import inboxesRouter from "./routes/inboxes";
import v1Router from "./routes/v1/index";
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

Breaking API change: Document the migration path.

Replacing the /inboxes endpoint with /v1 is a breaking change that will affect existing API consumers. Consider maintaining both endpoints during a transition period or providing clear migration documentation.

Would you like me to generate a migration guide or help implement backward compatibility with a deprecation notice?

🤖 Prompt for AI Agents
In api/index.ts around line 10, the import indicates the router was switched to
/v1 which is a breaking change for existing /inboxes consumers; restore backward
compatibility by mounting the existing v1Router under both routes during a
transition (mount /inboxes to delegate to the same router or a thin proxy that
emits a deprecation response header/body), add a server-side deprecation warning
(e.g., Deprecation and Sunset headers or a JSON notice) for /inboxes, and update
API docs and changelog to include a clear migration path and timeline for
removing /inboxes.

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

Caution

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

⚠️ Outside diff range comments (1)
api/db/mongo/schemas/Thread.ts (1)

16-20: Allow empty messages on new threads.

Right now messages is marked required: true, and Mongoose treats a required array as invalid if it’s empty. Fresh threads (e.g. createThread() in ThreadController.ts) persist with zero messages, so every save trips a ValidationError: Thread validation failed: messages: Path "messages" is required. Drop the required flag and give the field a default empty array so new threads can be created before the first message arrives.

    messages: {
      type: [mongoose.Schema.Types.ObjectId],
      ref: "Message",
-     required: true,
+     default: [],
    },
♻️ Duplicate comments (1)
api/controllers/SESController.ts (1)

88-88: Remove logging of full notification payload (duplicate concern).

This console.log was flagged in a previous review. The notification object contains the full SES payload including raw message content and headers, which may include PII. Please remove this log statement.

Apply this diff:

-    console.log("notification", notification);
🧹 Nitpick comments (5)
api/controllers/MessageController.ts (2)

32-47: Consider validating the status parameter.

The status parameter is assigned directly without runtime validation. While TypeScript provides compile-time type safety, consider adding runtime validation to ensure the status value is valid, especially if this function might be called from untyped contexts.

Apply this diff to add runtime validation:

 }) {
+  if (status && !MessageStatus.includes(status as any)) {
+    throw new Error(`Invalid status: ${status}`);
+  }
   const message = new Message();

53-55: Consider adding pagination to prevent performance issues.

getMessagesByInboxId returns all messages for an inbox without pagination. This could become a performance bottleneck as the number of messages grows.

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

14-29: Add input validation for request body fields.

The route accepts to, subject, text, and html from the request body without validation. Consider adding validation to ensure required fields are present and properly formatted (e.g., email format for to).

Example validation:

if (!req.body.to || !req.body.subject) {
  return res.status(400).json({ error: "Missing required fields" });
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(req.body.to)) {
  return res.status(400).json({ error: "Invalid email format" });
}

45-70: Optimize message creation to avoid multiple saves.

The message is created (line 45-55), then the externalMessageId is updated (lines 67-70), requiring two database writes. Consider refactoring to include externalMessageId in the initial createMessage call once the SES message is sent, or batch the operations differently.

Apply this diff to refactor:

-    const message = await createMessage({
+    const sesMessage = await sendSESMessage({
+      messageId: "temp-id",
+      from: inbox.email,
+      fromName: inbox.name,
+      to: req.body.to,
+      subject: req.body.subject,
+      text: req.body.text,
+      html: req.body.html,
+    });
+
+    const message = await createMessage({
       organizationId: organization._id.toString(),
       inboxId: req.params.inboxId,
       threadId: thread.id,
       fromInboxId: req.params.inboxId,
       from: inbox.email,
       to: req.body.to,
+      externalMessageId: sesMessage.MessageId,
       subject: req.body.subject,
       text: req.body.text,
       html: req.body.html,
     });
-
-    const sesMessage = await sendSESMessage({
-      messageId: message.id,
-      from: inbox.email,
-      fromName: inbox.name,
-      to: req.body.to,
-      subject: req.body.subject,
-      text: req.body.text,
-      html: req.body.html,
-    });
-    
-    if (sesMessage.MessageId) {
-      message.externalMessageId = sesMessage.MessageId;
-      await message.save();
-    }

Note: You'll need to handle the messageId tagging differently in sendSESMessage.

node-sdk/index.ts (1)

72-72: Remove or clarify the stray comment.

The //reply comment on line 72 appears to be a leftover TODO or planning note. Either remove it or expand it into proper documentation if it indicates planned functionality.

-      //reply
       list: async (inboxId: string) => {
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a7b2890 and f6f05a9.

📒 Files selected for processing (10)
  • api/controllers/MessageController.ts (1 hunks)
  • api/controllers/SESController.ts (2 hunks)
  • api/controllers/ThreadController.ts (1 hunks)
  • api/db/mongo/schemas/Message.ts (2 hunks)
  • api/db/mongo/schemas/Thread.ts (2 hunks)
  • api/models/Message.ts (2 hunks)
  • api/routes/v1/inboxes/messages.ts (1 hunks)
  • api/tests/MessageController.spec.ts (2 hunks)
  • node-sdk/index.ts (1 hunks)
  • node-sdk/tests/messageAPI.spec.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • node-sdk/tests/messageAPI.spec.ts
🧰 Additional context used
🧬 Code graph analysis (5)
api/controllers/ThreadController.ts (1)
api/models/Thread.ts (1)
  • Thread (3-10)
api/controllers/MessageController.ts (1)
api/models/Message.ts (2)
  • MessageStatus (3-10)
  • Message (12-29)
api/tests/MessageController.spec.ts (2)
api/controllers/ThreadController.ts (1)
  • createThread (4-17)
api/controllers/MessageController.ts (1)
  • createMessage (5-47)
api/routes/v1/inboxes/messages.ts (6)
api/models/Organization.ts (1)
  • Organization (3-12)
api/controllers/InboxController.ts (1)
  • getInboxByOrganizationIdAndInboxId (32-43)
api/controllers/ThreadController.ts (2)
  • createThread (4-17)
  • addMessageToThread (19-33)
api/controllers/MessageController.ts (3)
  • createMessage (5-47)
  • getMessagesByInboxId (53-55)
  • getMessageById (49-51)
api/controllers/SESController.ts (1)
  • sendSESMessage (36-83)
api/controllers/WebhookAttemptController.ts (1)
  • sendWebhookEvent (10-55)
api/controllers/SESController.ts (6)
api/models/SES.ts (1)
  • SNSMessage (92-103)
api/controllers/InboxController.ts (1)
  • getInboxByEmail (45-47)
api/controllers/MessageController.ts (3)
  • getMessageByExternalMessageId (57-59)
  • createMessage (5-47)
  • getMessageById (49-51)
api/controllers/ThreadController.ts (2)
  • createThread (4-17)
  • addMessageToThread (19-33)
api/models/Message.ts (1)
  • MessageStatus (3-10)
api/models/Webhook.ts (1)
  • WebhookEvents (3-13)
🔇 Additional comments (7)
api/routes/v1/inboxes/messages.ts (2)

88-105: LGTM!

The inbox ownership validation and message retrieval logic are correct. Pagination concerns are addressed at the controller level.


107-120: LGTM!

The authorization check correctly validates that the message belongs to the authenticated organization before returning it.

api/controllers/SESController.ts (1)

175-220: LGTM!

The outbound SES message handling correctly maps SES event types to message statuses and webhook events, with proper error handling for missing messages.

node-sdk/index.ts (3)

7-10: LGTM!

The base URL change combined with explicit /v1 prefixes in endpoint calls maintains the same effective URLs while providing clearer version separation.


12-45: LGTM! Note the breaking change.

The restructured inbox API is cleaner and more consistent. The email parameter is now optional in create, which aligns with the auto-generation feature. Note that this is a breaking change for SDK users who were using the old createInbox method.

Consider documenting this breaking change in a migration guide or changelog.


46-90: LGTM!

The nested message operations follow a consistent pattern with proper authorization headers and clear parameter types.

api/controllers/MessageController.ts (1)

8-8: No issues found—threadId is provided at all call sites.

The search found 3 call sites for createMessage, and all of them pass threadId:

  • api/tests/MessageController.spec.ts:41 with thread.id
  • api/routes/v1/inboxes/messages.ts:45 with thread.id
  • api/controllers/SESController.ts:147 with threadId variable

The breaking change has been properly accommodated across the codebase.

Comment on lines +147 to +159
const message = await createMessage({
organizationId: inbox.organizationId.toString(),
inboxId: inbox.id,
threadId,
from: notification.mail.source,
fromInboxId: fromInboxId?.id,
to: notification.mail.destination[0],
toInboxId: inbox.id,
subject: notification.mail.commonHeaders?.subject,
text: content.getVisibleText(),
html: content.getVisibleText(),
status: "received",
});
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

HTML field should contain HTML content, not plain text.

Line 157 sets the html field to content.getVisibleText(), which returns plain text. This should either use the parsed HTML from the email or properly convert the text to HTML.

Consider this fix:

     text: content.getVisibleText(),
-    html: content.getVisibleText(),
+    html: mail.html || content.getVisibleText(),
     status: "received",

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

🤖 Prompt for AI Agents
In api/controllers/SESController.ts around lines 147 to 159, the message html
field is being set to plain text via content.getVisibleText(); change it to use
the parsed HTML from the email (e.g. content.getHtml() / content.getHTML() or
the parser method that returns HTML) and, if no HTML part exists, fall back to a
safe conversion of the visible text (escape HTML entities and replace newlines
with <br/> and wrap in a container) before assigning to html; ensure you use the
HTML-returning method when available and only convert text as a fallback.

Comment on lines +40 to +43
const thread = await createThread({
organizationId: organization._id.toString(),
inboxId: req.params.inboxId,
});
Copy link

@coderabbitai coderabbitai bot Nov 7, 2025

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Creating a new thread for every sent message may be incorrect.

The current implementation creates a new thread for every message sent. This means reply messages won't be associated with the original conversation thread. Consider checking if the message is a reply (e.g., via In-Reply-To or conversation context) and reusing the existing thread instead.

🤖 Prompt for AI Agents
In api/routes/v1/inboxes/messages.ts around lines 40–43, the code
unconditionally creates a new thread for every outgoing message; instead detect
when the incoming message is a reply or belongs to an existing conversation
(check request fields like inReplyTo, parentMessageId, conversationId, or the
In-Reply-To HTTP/email header) and attempt to load the existing thread (e.g.,
findThreadByMessageId/ conversationId/ inReplyTo) and use that threadId when
saving the message; only call createThread when no matching thread is found,
then proceed to attach the message to the resolved thread.

Copy link
Contributor

Choose a reason for hiding this comment

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

@marcrupt agree here. this seems to create a new thread for every message.

Copy link

Choose a reason for hiding this comment

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

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

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

♻️ Duplicate comments (2)
api/routes/v1/inboxes/messages.ts (1)

40-43: Creating a new thread for every sent message may be incorrect.

The current implementation creates a new thread for every message sent, which means reply messages won't be associated with the original conversation thread. Consider checking if the message is a reply (e.g., via an inReplyTo or threadId field in the request body) and reusing the existing thread instead of always creating a new one.

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

17-17: Critical: Inconsistent inbox identifier persists despite past fix.

Despite a previous review comment being marked as addressed in commit f6f05a9, all test methods still use newInbox._id (lines 17, 34, 40, 53, 59) instead of newInbox.id. Since inbox.create returns id, these calls will pass undefined, causing requests to /v1/inboxes/undefined/messages and all tests to fail.

Apply this diff to fix all occurrences:

 describe("sendMessage", function () {
   it("should send a message", async function () {
     const sendook = new Sendook(process.env.API_KEY, process.env.API_URL);
     const name = faker.person.fullName();
     const newInbox = await sendook.inbox.create({ name });
     const message = await sendook.inbox.message.send({
-      inboxId: newInbox._id,
+      inboxId: newInbox.id,
       to: "marc@rupt.dev",
       subject: "Test Subject",
       text: "Test Text",
       html: "<p>Test HTML</p>",
     });

 describe("listMessages", function () {
   it("should list messages", async function () {
     const sendook = new Sendook(process.env.API_KEY, process.env.API_URL);
     const name = faker.person.fullName();
     const newInbox = await sendook.inbox.create({ name });
     const newMessage = await sendook.inbox.message.send({
-      inboxId: newInbox._id,
+      inboxId: newInbox.id,
       to: "marc@rupt.dev",
       subject: "Test Subject",
       text: "Test Text",
       html: "<p>Test HTML</p>",
     });
-    const messages = await sendook.inbox.message.list(newInbox._id);
+    const messages = await sendook.inbox.message.list(newInbox.id);

 describe("getMessage", function () {
   it("should get a message", async function () {
     const sendook = new Sendook(process.env.API_KEY, process.env.API_URL);
     const name = faker.person.fullName();
     const newInbox = await sendook.inbox.create({ name });
     const newMessage = await sendook.inbox.message.send({
-      inboxId: newInbox._id,
+      inboxId: newInbox.id,
       to: "marc@rupt.dev",
       subject: "Test Subject",
       text: "Test Text",
       html: "<p>Test HTML</p>",
     });
-    const message = await sendook.inbox.message.get(newInbox._id, newMessage._id);
+    const message = await sendook.inbox.message.get(newInbox.id, newMessage._id);

Also applies to: 34-34, 40-40, 53-53, 59-59

🧹 Nitpick comments (5)
api/routes/v1/inboxes/messages.ts (2)

40-84: Consider adding error handling for external operations.

Multiple operations (thread creation, message creation, SES sending, webhook dispatch) can fail. Consider wrapping critical operations in try-catch blocks to provide meaningful error responses. Additionally, webhook dispatch failures (lines 77-82) should not block the successful message response.

Example pattern:

try {
  const thread = await createThread({ ... });
  const message = await createMessage({ ... });
  const sesMessage = await sendSESMessage({ ... });
  
  if (sesMessage.MessageId) {
    message.externalMessageId = sesMessage.MessageId;
    await message.save();
  }
  
  await addMessageToThread({ ... });
  
  // Fire and forget for webhook
  sendWebhookEvent({ ... }).catch(err => 
    console.error('Webhook failed:', err)
  );
  
  return res.json(message);
} catch (error) {
  console.error('Send message failed:', error);
  return res.status(500).json({ error: 'Failed to send message' });
}

88-105: Consider adding pagination support.

This endpoint fetches all messages for an inbox without pagination, which could cause performance issues and large payload sizes for inboxes with many messages. Consider adding query parameters for limit, offset, or cursor-based pagination.

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

1-10: Clean up unused imports.

Several imports are not used in this file:

  • createMessage, getMessageById, getMessagesByInboxId from MessageController (line 5)
  • sendWebhookEvent from WebhookAttemptController (line 6)
  • sendSESMessage from SESController (line 7)

These appear to be copied from messages.ts. Remove unused imports or implement the routes that would use them.

node-sdk/index.ts (2)

72-72: Address or remove the incomplete comment.

The //reply comment suggests unfinished work. If reply functionality is planned, track it in an issue; otherwise, remove the comment.

Do you want me to open an issue to track the reply functionality implementation?


47-59: Make text and html mutually optional to reduce redundancy.

Both text and html are currently required throughout the stack (SDK, controller, schema, and SES sending function). However, the backend code shows redundancy—in handleInboundSESMessage (SESController.ts, line 160), both are set to the same plain text value. Since email APIs typically accept either format, consider making one optional to avoid forcing callers to provide duplicate content.

Changes would be needed in:

  • node-sdk/index.ts (SDK interface)
  • api/controllers/MessageController.ts (function signature)
  • api/db/mongo/schemas/Message.ts (schema)
  • api/controllers/SESController.ts (sending logic)
  • Route handlers in api/routes/
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f6f05a9 and 723c50b.

📒 Files selected for processing (7)
  • api/routes/v1/inboxes/index.ts (1 hunks)
  • api/routes/v1/inboxes/messages.ts (1 hunks)
  • api/routes/v1/inboxes/threads.ts (1 hunks)
  • api/routes/v1/index.ts (1 hunks)
  • node-sdk/index.ts (1 hunks)
  • node-sdk/tests/messageAPI.spec.ts (1 hunks)
  • node-sdk/tests/threadAPI.spec.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • api/routes/v1/index.ts
🧰 Additional context used
🧬 Code graph analysis (2)
api/routes/v1/inboxes/messages.ts (6)
api/models/Organization.ts (1)
  • Organization (3-12)
api/controllers/InboxController.ts (1)
  • getInboxByOrganizationIdAndInboxId (32-43)
api/controllers/ThreadController.ts (2)
  • createThread (4-17)
  • addMessageToThread (19-33)
api/controllers/MessageController.ts (3)
  • createMessage (5-47)
  • getMessagesByInboxId (53-55)
  • getMessageById (49-51)
api/controllers/SESController.ts (1)
  • sendSESMessage (36-83)
api/controllers/WebhookAttemptController.ts (1)
  • sendWebhookEvent (10-55)
api/routes/v1/inboxes/threads.ts (1)
api/models/Organization.ts (1)
  • Organization (3-12)
🔇 Additional comments (4)
api/routes/v1/inboxes/messages.ts (1)

108-121: LGTM!

The message retrieval endpoint properly validates ownership by comparing message.organizationId with the authenticated organization ID, ensuring proper access control.

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

15-67: Well-structured inbox routes.

The route implementations are clean and follow good patterns:

  • Proper authentication on all routes
  • Input validation on POST with express-validator
  • Consistent error handling with 404 responses
  • Webhook emission after inbox creation

69-70: LGTM!

The sub-router mounts correctly delegate to the messages and threads modules, enabling the nested routes under each inbox.


37-40: No changes required—parameter naming is correct.

The createInbox function in api/controllers/InboxController.ts explicitly expects organization_id (snake_case) as a parameter, which is what line 38 provides. The function internally converts this to organizationId (camelCase) for the model property. This is a standard pattern where API function parameters use snake_case while internal properties use camelCase. The code is already consistent with the function's contract.

Comment on lines +14 to +29
router.post(
"/send",
passport.authenticate("api_key", { session: false }),
async (
req: Request<
{ organizationId: string; inboxId: string },
{},
{
to: string;
subject: string;
text: string;
html: string;
}
>,
res: Response
) => {
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Add input validation for the request body.

The endpoint lacks validation for required fields (to, subject, text, html). Consider adding validation middleware (e.g., express-validator) to ensure:

  • to is a valid email address
  • subject, text, and html are non-empty strings
  • Input is properly sanitized to prevent injection attacks

Apply this pattern similar to the POST / route in index.ts:

router.post(
  "/send",
  body("to").isEmail().normalizeEmail(),
  body("subject").isString().notEmpty().trim(),
  body("text").isString().notEmpty(),
  body("html").isString().notEmpty(),
  expressValidatorMiddleware,
  passport.authenticate("api_key", { session: false }),
  async (req, res) => {
    // ... handler logic
  }
);
🤖 Prompt for AI Agents
In api/routes/v1/inboxes/messages.ts around lines 14 to 29, the POST /send route
currently has no validation of the request body; add express-validator checks
before passport.authenticate to enforce and sanitize inputs: validate
body("to").isEmail().normalizeEmail(),
body("subject").isString().notEmpty().trim(),
body("text").isString().notEmpty(), body("html").isString().notEmpty(), then
include the existing expressValidatorMiddleware to short-circuit and return
errors; keep passport.authenticate and the async handler afterwards and adjust
request typing if necessary to reflect validated fields.

Comment on lines 12 to 108
public inbox = {
create: async ({
name,
email,
}, {
headers: {
"Authorization": `Bearer ${this.apiSecret}`,
}: {
name: string;
email?: string;
}) => {
const response = await axios.post(`${this.apiUrl}/v1/inboxes`, {
name,
email,
}, {
headers: {
"Authorization": `Bearer ${this.apiSecret}`,
},
});
return response.data;
},
list: async () => {
const response = await axios.get(`${this.apiUrl}/v1/inboxes`, {
headers: {
"Authorization": `Bearer ${this.apiSecret}`,
},
});
return response.data;
},
get: async (inboxId: string) => {
const response = await axios.get(`${this.apiUrl}/v1/inboxes/${inboxId}`, {
headers: {
"Authorization": `Bearer ${this.apiSecret}`,
},
});
return response.data;
},
message: {
send: async ({
inboxId,
to,
subject,
text,
html,
}: {
inboxId: string;
to: string;
subject: string;
text: string;
html: string;
}) => {
const response = await axios.post(`${this.apiUrl}/v1/inboxes/${inboxId}/messages/send`, {
to,
subject,
text,
html,
}, {
headers: {
"Authorization": `Bearer ${this.apiSecret}`,
},
});
return response.data;
},
});
return response.data;
}
//reply
list: async (inboxId: string) => {
const response = await axios.get(`${this.apiUrl}/v1/inboxes/${inboxId}/messages`, {
headers: {
"Authorization": `Bearer ${this.apiSecret}`,
},
});
return response.data;
},
get: async (inboxId: string, messageId: string) => {
const response = await axios.get(`${this.apiUrl}/v1/inboxes/${inboxId}/messages/${messageId}`, {
headers: {
"Authorization": `Bearer ${this.apiSecret}`,
},
});
return response.data;
},
},
thread: {
list: async (inboxId: string) => {
const response = await axios.get(`${this.apiUrl}/v1/inboxes/${inboxId}/threads`, {
headers: {
"Authorization": `Bearer ${this.apiSecret}`,
},
});
return response.data;
},
get: async (inboxId: string, threadId: string) => {
const response = await axios.get(`${this.apiUrl}/v1/inboxes/${inboxId}/threads/${threadId}`, {
headers: {
"Authorization": `Bearer ${this.apiSecret}`,
},
});
return response.data;
},
},
};
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Extract repeated authorization logic to reduce duplication.

The Authorization header construction is repeated in every method (8 times). This violates DRY and makes maintenance harder if the auth mechanism changes.

Consider extracting a private helper method:

+  private async request(method: 'GET' | 'POST', path: string, data?: any) {
+    const config = {
+      headers: {
+        Authorization: `Bearer ${this.apiSecret}`,
+      },
+    };
+    const url = `${this.apiUrl}${path}`;
+    const response = method === 'GET' 
+      ? await axios.get(url, config)
+      : await axios.post(url, data, config);
+    return response.data;
+  }

  public inbox = {
    create: async ({ name, email }: { name: string; email?: string }) => {
-      const response = await axios.post(`${this.apiUrl}/v1/inboxes`, {
-        name,
-        email,
-      }, {
-        headers: {
-          Authorization: `Bearer ${this.apiSecret}`,
-        },
-      });
-      return response.data;
+      return this.request('POST', '/v1/inboxes', { name, email });
    },

Apply similar changes to all other methods.

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

🤖 Prompt for AI Agents
node-sdk/index.ts lines 12 to 108: the Authorization header is duplicated across
every inbox method; add a single private helper (e.g. getAuthHeaders() that
returns { Authorization: `Bearer ${this.apiSecret}` } or create a preconfigured
axios instance on the class with baseURL and auth header) and replace each
inline headers block with a call to that helper (or use the instance) for all
inbox methods (create, list, get, message.*, thread.*) to remove duplication and
centralize auth header construction.

const newInbox = await sendook.inbox.create({
name,
});
const threads = await sendook.inbox.thread.list(newInbox.id);
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 inconsistent inbox identifier usage.

Line 16 uses newInbox.id, but lines 29 and 35 use newInbox._id. Based on the past review of messageAPI.spec.ts, inbox.create returns id, not _id. This inconsistency will cause lines 29 and 35 to pass undefined to the API calls, resulting in requests to /v1/inboxes/undefined/messages and /v1/inboxes/undefined/threads.

Apply this diff to use the correct identifier consistently:

-      const newMessage = await sendook.inbox.message.send({
-        inboxId: newInbox._id,
+      const newMessage = await sendook.inbox.message.send({
+        inboxId: newInbox.id,

-      const thread = await sendook.inbox.thread.get(newInbox._id, newMessage.threadId);
+      const thread = await sendook.inbox.thread.get(newInbox.id, newMessage.threadId);

Also applies to: 29-29, 35-35

🤖 Prompt for AI Agents
In node-sdk/tests/threadAPI.spec.ts around lines 16, 29, and 35, the test mixes
inbox identifier properties: line 16 correctly uses newInbox.id but lines 29 and
35 use newInbox._id which is undefined; update the references on lines 29 and 35
to use newInbox.id so all API calls consistently pass the returned id (replace
newInbox._id with newInbox.id in those two locations).

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: 6

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 723c50b and 4c62361.

📒 Files selected for processing (6)
  • api/controllers/ThreadController.ts (1 hunks)
  • api/routes/v1/inboxes/messages.ts (1 hunks)
  • api/routes/v1/inboxes/threads.ts (1 hunks)
  • api/routes/webhooks/ses.ts (0 hunks)
  • node-sdk/tests/InboxAPI.spec.ts (1 hunks)
  • node-sdk/tests/threadAPI.spec.ts (1 hunks)
💤 Files with no reviewable changes (1)
  • api/routes/webhooks/ses.ts
✅ Files skipped from review due to trivial changes (1)
  • node-sdk/tests/threadAPI.spec.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • node-sdk/tests/InboxAPI.spec.ts
  • api/routes/v1/inboxes/messages.ts
🧰 Additional context used
🧬 Code graph analysis (2)
api/routes/v1/inboxes/threads.ts (3)
api/models/Organization.ts (1)
  • Organization (3-12)
api/controllers/InboxController.ts (1)
  • getInboxByOrganizationIdAndInboxId (32-43)
api/controllers/ThreadController.ts (2)
  • getThreadsByInboxId (35-38)
  • getThreadById (40-43)
api/controllers/ThreadController.ts (1)
api/models/Thread.ts (1)
  • Thread (3-10)

Comment on lines +4 to +17
export async function createThread({
organizationId,
inboxId,
}: {
organizationId: string;
inboxId: string;
}) {
const thread = new Thread();
thread.organizationId = new mongoose.Types.ObjectId(organizationId);
thread.inboxId = new mongoose.Types.ObjectId(inboxId);
thread.messages = new mongoose.Types.Array<mongoose.Types.ObjectId>();
await thread.save();
return thread;
}
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 error handling and input validation.

This function lacks try-catch error handling and input validation. Invalid ObjectId strings will throw unhandled exceptions that could crash the service or leak error details.

Wrap in try-catch and validate inputs:

 export async function createThread({
   organizationId,
   inboxId,
 }: {
   organizationId: string;
   inboxId: string;
 }) {
+  try {
+    // Validate ObjectId format
+    if (!mongoose.Types.ObjectId.isValid(organizationId) || !mongoose.Types.ObjectId.isValid(inboxId)) {
+      throw new Error("Invalid organizationId or inboxId format");
+    }
+
     const thread = new Thread();
     thread.organizationId = new mongoose.Types.ObjectId(organizationId);
     thread.inboxId = new mongoose.Types.ObjectId(inboxId);
     thread.messages = new mongoose.Types.Array<mongoose.Types.ObjectId>();
     await thread.save();
     return thread;
+  } catch (error) {
+    console.error("Error creating thread:", error);
+    throw 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
export async function createThread({
organizationId,
inboxId,
}: {
organizationId: string;
inboxId: string;
}) {
const thread = new Thread();
thread.organizationId = new mongoose.Types.ObjectId(organizationId);
thread.inboxId = new mongoose.Types.ObjectId(inboxId);
thread.messages = new mongoose.Types.Array<mongoose.Types.ObjectId>();
await thread.save();
return thread;
}
export async function createThread({
organizationId,
inboxId,
}: {
organizationId: string;
inboxId: string;
}) {
try {
// Validate ObjectId format
if (!mongoose.Types.ObjectId.isValid(organizationId) || !mongoose.Types.ObjectId.isValid(inboxId)) {
throw new Error("Invalid organizationId or inboxId format");
}
const thread = new Thread();
thread.organizationId = new mongoose.Types.ObjectId(organizationId);
thread.inboxId = new mongoose.Types.ObjectId(inboxId);
thread.messages = new mongoose.Types.Array<mongoose.Types.ObjectId>();
await thread.save();
return thread;
} catch (error) {
console.error("Error creating thread:", error);
throw error;
}
}
🤖 Prompt for AI Agents
In api/controllers/ThreadController.ts around lines 4–17, validate that
organizationId and inboxId are non-empty strings and pass
mongoose.Types.ObjectId.isValid before constructing ObjectId instances; if
validation fails, throw a controlled error (e.g., BadRequest) with a safe
message. Wrap the creation and thread.save() in a try-catch, log a concise error
message (without leaking stack or raw input) and rethrow or return a controlled
error response so callers can handle it; ensure thread.messages is initialized
as a plain array of ObjectId placeholders and only create ObjectId instances
after validation.

Comment on lines +19 to +33
export async function addMessageToThread({
threadId,
messageId,
}: {
threadId: string;
messageId: string;
}) {
const thread = await Thread.findById(threadId);
if (!thread) {
return null;
}
thread.messages.push(new mongoose.Types.ObjectId(messageId));
await thread.save();
return thread;
}
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 error handling, input validation, and duplicate check.

This function has several issues:

  1. No try-catch error handling for invalid ObjectIds or database errors
  2. No validation of input ID formats
  3. No check for duplicate messages in the thread
  4. Potential race condition with concurrent addMessageToThread calls

Apply these improvements:

 export async function addMessageToThread({
   threadId,
   messageId,
 }: {
   threadId: string;
   messageId: string;
 }) {
+  try {
+    if (!mongoose.Types.ObjectId.isValid(threadId) || !mongoose.Types.ObjectId.isValid(messageId)) {
+      throw new Error("Invalid threadId or messageId format");
+    }
+
     const thread = await Thread.findById(threadId);
     if (!thread) {
       return null;
     }
+    
+    // Check for duplicate
+    const messageObjectId = new mongoose.Types.ObjectId(messageId);
+    if (thread.messages.some(id => id.equals(messageObjectId))) {
+      return thread; // Already exists
+    }
+    
-    thread.messages.push(new mongoose.Types.ObjectId(messageId));
+    thread.messages.push(messageObjectId);
     await thread.save();
     return thread;
+  } catch (error) {
+    console.error("Error adding message to thread:", error);
+    throw error;
+  }
 }

Comment on lines +35 to +38
export async function getThreadsByInboxId(inboxId: string) {
const threads = await Thread.find({ inboxId: new mongoose.Types.ObjectId(inboxId) });
return threads;
}
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 error handling and consider pagination.

This function lacks error handling and could return large result sets without pagination.

Add error handling:

 export async function getThreadsByInboxId(inboxId: string) {
+  try {
+    if (!mongoose.Types.ObjectId.isValid(inboxId)) {
+      throw new Error("Invalid inboxId format");
+    }
+
     const threads = await Thread.find({ inboxId: new mongoose.Types.ObjectId(inboxId) });
     return threads;
+  } catch (error) {
+    console.error("Error fetching threads:", error);
+    throw error;
+  }
 }

Consider adding pagination parameters (limit, skip) to handle inboxes with many threads.

🤖 Prompt for AI Agents
In api/controllers/ThreadController.ts around lines 35 to 38, the function has
no error handling and can return unbounded results; wrap the DB call in a
try/catch that validates inboxId and rethrows or returns a controlled error, and
add pagination parameters (e.g., limit and skip or page and size) with sane
defaults and a maximum cap to prevent huge responses; apply
.limit(limit).skip(skip) to the query and optionally return metadata (total
count, page) or throw a 400 for invalid input and a 500 for DB errors.

Comment on lines +40 to +43
export async function getThreadById(threadId: string) {
const thread = await Thread.findById(threadId).populate("messages");
return thread;
}
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 error handling and input validation.

Missing error handling and input validation could cause unhandled exceptions.

 export async function getThreadById(threadId: string) {
+  try {
+    if (!mongoose.Types.ObjectId.isValid(threadId)) {
+      throw new Error("Invalid threadId format");
+    }
+
     const thread = await Thread.findById(threadId).populate("messages");
     return thread;
+  } catch (error) {
+    console.error("Error fetching thread:", error);
+    throw 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
export async function getThreadById(threadId: string) {
const thread = await Thread.findById(threadId).populate("messages");
return thread;
}
export async function getThreadById(threadId: string) {
try {
if (!mongoose.Types.ObjectId.isValid(threadId)) {
throw new Error("Invalid threadId format");
}
const thread = await Thread.findById(threadId).populate("messages");
return thread;
} catch (error) {
console.error("Error fetching thread:", error);
throw error;
}
}
🤖 Prompt for AI Agents
In api/controllers/ThreadController.ts around lines 40 to 43, the function lacks
input validation and error handling; validate the threadId (e.g., ensure it's
present and a valid Mongo ObjectId using mongoose.Types.ObjectId.isValid) before
calling Thread.findById, and wrap the database call in a try/catch to handle and
log DB errors and return a controlled response (null or throw a descriptive
error) instead of letting exceptions bubble up.

Comment on lines +14 to +34
router.get(
"/",
passport.authenticate("api_key", { session: false }),
async (
req: Request<{ organizationId: string; inboxId: string }>,
res: Response
) => {
const organization = req.user as HydratedDocument<Organization>;

const inbox = await getInboxByOrganizationIdAndInboxId({
organizationId: organization._id.toString(),
inboxId: req.params.inboxId!,
});
if (!inbox) {
return res.status(404).json({ error: "Inbox not found" });
}

const threads = await getThreadsByInboxId(req.params.inboxId);
return res.json(threads);
}
);
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

Good implementation, but add error handling.

The route is now properly implemented (addressing previous review comments), and correctly validates inbox ownership before returning threads. However, it lacks try-catch error handling.

Add error handling:

 router.get(
   "/",
   passport.authenticate("api_key", { session: false }),
   async (
     req: Request<{ organizationId: string; inboxId: string }>,
     res: Response
   ) => {
+    try {
       const organization = req.user as HydratedDocument<Organization>;
 
       const inbox = await getInboxByOrganizationIdAndInboxId({
         organizationId: organization._id.toString(),
         inboxId: req.params.inboxId!,
       });
       if (!inbox) {
         return res.status(404).json({ error: "Inbox not found" });
       }
 
       const threads = await getThreadsByInboxId(req.params.inboxId);
       return res.json(threads);
+    } catch (error) {
+      console.error("Error fetching threads:", error);
+      return res.status(500).json({ error: "Internal server 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.get(
"/",
passport.authenticate("api_key", { session: false }),
async (
req: Request<{ organizationId: string; inboxId: string }>,
res: Response
) => {
const organization = req.user as HydratedDocument<Organization>;
const inbox = await getInboxByOrganizationIdAndInboxId({
organizationId: organization._id.toString(),
inboxId: req.params.inboxId!,
});
if (!inbox) {
return res.status(404).json({ error: "Inbox not found" });
}
const threads = await getThreadsByInboxId(req.params.inboxId);
return res.json(threads);
}
);
router.get(
"/",
passport.authenticate("api_key", { session: false }),
async (
req: Request<{ organizationId: string; inboxId: string }>,
res: Response
) => {
try {
const organization = req.user as HydratedDocument<Organization>;
const inbox = await getInboxByOrganizationIdAndInboxId({
organizationId: organization._id.toString(),
inboxId: req.params.inboxId!,
});
if (!inbox) {
return res.status(404).json({ error: "Inbox not found" });
}
const threads = await getThreadsByInboxId(req.params.inboxId);
return res.json(threads);
} catch (error) {
console.error("Error fetching threads:", error);
return res.status(500).json({ error: "Internal server error" });
}
}
);
🤖 Prompt for AI Agents
In api/routes/v1/inboxes/threads.ts around lines 14 to 34, the async route
handler lacks try/catch error handling; wrap the body of the handler in a try
block that awaits getInboxByOrganizationIdAndInboxId and getThreadsByInboxId,
add a catch block that logs the caught error (use the project's logger) and
returns res.status(500).json({ error: "Internal server error" }) so unexpected
exceptions don't crash the process and callers receive a clear 500 response.

Comment on lines 36 to 55
router.get(
"/:threadId",
passport.authenticate("api_key", { session: false }),
async (
req: Request<{ organizationId: string; inboxId: string; threadId: string }>,
res: Response
) => {
const organization = req.user as HydratedDocument<Organization>;

const thread = await getThreadById(req.params.threadId);
if (
!thread ||
thread.organizationId.toString() !== organization._id.toString()
) {
return res.status(404).json({ error: "Thread not found" });
}

return res.json(thread);
}
);
Copy link

@coderabbitai coderabbitai bot Nov 8, 2025

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Type fixed correctly, but missing inbox validation and error handling.

Good fixes from previous review (type parameter now uses threadId instead of messageId, and route is now functional). However:

  1. The route validates thread ownership but doesn't verify the thread belongs to the inbox specified in the URL path (req.params.inboxId)
  2. Missing try-catch error handling

Apply this diff:

 router.get(
   "/:threadId",
   passport.authenticate("api_key", { session: false }),
   async (
     req: Request<{ organizationId: string; inboxId: string; threadId: string }>,
     res: Response
   ) => {
+    try {
       const organization = req.user as HydratedDocument<Organization>;
 
       const thread = await getThreadById(req.params.threadId);
       if (
         !thread ||
-        thread.organizationId.toString() !== organization._id.toString()
+        thread.organizationId.toString() !== organization._id.toString() ||
+        thread.inboxId.toString() !== req.params.inboxId
       ) {
         return res.status(404).json({ error: "Thread not found" });
       }
 
       return res.json(thread);
+    } catch (error) {
+      console.error("Error fetching thread:", error);
+      return res.status(500).json({ error: "Internal server error" });
+    }
   }
 );
🤖 Prompt for AI Agents
In api/routes/v1/inboxes/threads.ts around lines 36 to 55, the handler currently
checks organization ownership but misses validating that the thread belongs to
the inbox from req.params.inboxId and lacks error handling; wrap the async logic
in a try-catch, call getThreadById inside the try, on error log/return
res.status(500).json({ error: "Internal server error" }) (or include the error
message per project conventions), then after fetching the thread verify thread
&& thread.organizationId.toString() === organization._id.toString() &&
thread.inboxId.toString() === req.params.inboxId, returning 404 if any check
fails, otherwise return the thread.

Copy link
Contributor

Choose a reason for hiding this comment

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

@marcrupt a valid improvement ☝️

Copy link

Choose a reason for hiding this comment

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

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

Copy link
Contributor

@ahmedmawiri ahmedmawiri left a comment

Choose a reason for hiding this comment

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

The thread creation appears to be a bug the other check to verify message security is probably a must too.

Comment on lines +40 to +43
const thread = await createThread({
organizationId: organization._id.toString(),
inboxId: req.params.inboxId,
});
Copy link
Contributor

Choose a reason for hiding this comment

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

@marcrupt agree here. this seems to create a new thread for every message.

Comment on lines 36 to 55
router.get(
"/:threadId",
passport.authenticate("api_key", { session: false }),
async (
req: Request<{ organizationId: string; inboxId: string; threadId: string }>,
res: Response
) => {
const organization = req.user as HydratedDocument<Organization>;

const thread = await getThreadById(req.params.threadId);
if (
!thread ||
thread.organizationId.toString() !== organization._id.toString()
) {
return res.status(404).json({ error: "Thread not found" });
}

return res.json(thread);
}
);
Copy link
Contributor

Choose a reason for hiding this comment

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

@marcrupt a valid improvement ☝️

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: 4

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4c62361 and 797d6bd.

📒 Files selected for processing (4)
  • api/routes/v1/inboxes/messages.ts (1 hunks)
  • api/routes/v1/inboxes/threads.ts (1 hunks)
  • node-sdk/index.ts (1 hunks)
  • node-sdk/tests/messageAPI.spec.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • node-sdk/tests/messageAPI.spec.ts
🧰 Additional context used
🧬 Code graph analysis (1)
api/routes/v1/inboxes/messages.ts (6)
api/models/Organization.ts (1)
  • Organization (3-12)
api/controllers/InboxController.ts (1)
  • getInboxByOrganizationIdAndInboxId (32-43)
api/controllers/ThreadController.ts (2)
  • createThread (4-17)
  • addMessageToThread (19-33)
api/controllers/MessageController.ts (3)
  • createMessage (5-47)
  • getMessagesByInboxId (53-55)
  • getMessageById (49-51)
api/controllers/SESController.ts (1)
  • sendSESMessage (36-83)
api/controllers/WebhookAttemptController.ts (1)
  • sendWebhookEvent (10-55)

Comment on lines +130 to +136
const message = await getMessageById(req.params.messageId);
if (
!message ||
message.organizationId.toString() !== organization._id.toString()
) {
return res.status(404).json({ error: "Message not found" });
}
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

Enforce inbox scoping when fetching a message.

This endpoint only checks the organization, so callers can request /inboxes/{otherInboxId}/messages/{messageId} and still see the message if it belongs to the org. Please add an inboxId equality check to keep resources aligned with the URL.

     if (
       !message ||
-      message.organizationId.toString() !== organization._id.toString()
+      message.organizationId.toString() !== organization._id.toString() ||
+      message.inboxId.toString() !== req.params.inboxId
     ) {
       return res.status(404).json({ error: "Message not found" });
     }
🤖 Prompt for AI Agents
In api/routes/v1/inboxes/messages.ts around lines 130 to 136, the current check
only verifies organization ownership so a message from another inbox in the same
org can be returned; add an inboxId equality check by comparing message.inboxId
(toString()) with req.params.inboxId (toString()) and return 404 when they do
not match, keeping the existing org check; ensure both IDs are normalized to
strings before comparison.

Comment on lines +163 to +169
const replyToMessage = await getMessageById(req.params.messageId);
if (
!replyToMessage ||
replyToMessage.organizationId.toString() !== organization._id.toString()
) {
return res.status(404).json({ error: "Message not found" });
}
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

Also validate inbox ownership before replying.

The reply guard mirrors the GET handler and needs the same inboxId check; otherwise a caller can reply to a message that belongs to another inbox in the org, producing cross-inbox thread corruption.

     if (
       !replyToMessage ||
-      replyToMessage.organizationId.toString() !== organization._id.toString()
+      replyToMessage.organizationId.toString() !== organization._id.toString() ||
+      replyToMessage.inboxId.toString() !== req.params.inboxId
     ) {
       return res.status(404).json({ error: "Message not found" });
     }
🤖 Prompt for AI Agents
In api/routes/v1/inboxes/messages.ts around lines 163 to 169, the reply guard
checks message existence and org ownership but misses verifying that the message
belongs to the requested inbox; add a check that replyToMessage.inboxId
(stringified) matches req.params.inboxId (or the loaded inbox._id) and return
404 if it does not match so replies cannot target messages from a different
inbox in the same org.

Comment on lines +9 to 71
this.apiUrl = apiUrl || "https://api.sendook.com";
}

async createInbox({
name,
email,
}: {
name: string;
email?: string;
}) {
const response = await axios.post(`${this.apiUrl}/inboxes`, {
public inbox = {
create: async ({
name,
email,
}, {
headers: {
"Authorization": `Bearer ${this.apiSecret}`,
}: {
name: string;
email?: string;
}) => {
const response = await axios.post(`${this.apiUrl}/v1/inboxes`, {
name,
email,
}, {
headers: {
"Authorization": `Bearer ${this.apiSecret}`,
},
});
return response.data;
},
list: async () => {
const response = await axios.get(`${this.apiUrl}/v1/inboxes`, {
headers: {
"Authorization": `Bearer ${this.apiSecret}`,
},
});
return response.data;
},
get: async (inboxId: string) => {
const response = await axios.get(`${this.apiUrl}/v1/inboxes/${inboxId}`, {
headers: {
"Authorization": `Bearer ${this.apiSecret}`,
},
});
return response.data;
},
message: {
send: async ({
inboxId,
to,
subject,
text,
html,
}: {
inboxId: string;
to: string;
subject: string;
text: string;
html: string;
}) => {
const response = await axios.post(`${this.apiUrl}/v1/inboxes/${inboxId}/messages/send`, {
to,
subject,
text,
html,
}, {
headers: {
"Authorization": `Bearer ${this.apiSecret}`,
},
});
return response.data;
},
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

Fix regression when apiUrl already contains /v1.

Previously consumers could pass apiUrl values like https://api-dev.sendook.com/v1. With the new default plus the hardcoded "/v1/..." paths, those same clients now hit /v1/v1/... and get 404s. Please normalize the supplied base URL (or centralize URL building) so that existing custom apiUrl values keep working.

-    this.apiUrl = apiUrl || "https://api.sendook.com";
+    this.apiUrl = (apiUrl || "https://api.sendook.com").replace(/\/+$/, "");
+
+  private buildUrl(path: string) {
+    return new URL(path, `${this.apiUrl}/`).toString();
+  }

Then call this.buildUrl("/v1/inboxes"), this.buildUrl("/v1/inboxes/${inboxId}"), etc., to avoid duplicating the version.

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

🤖 Prompt for AI Agents
In node-sdk/index.ts around lines 9 to 71, the client currently concatenates
hardcoded "/v1/..." paths onto the supplied apiUrl which can already include
"/v1", causing duplicated "/v1/v1" and 404s; add a small helper method (e.g.,
this.buildUrl(path: string)) that normalizes the base URL and path by: trimming
any trailing slash from apiUrl, removing a trailing "/v1" if present from the
base, ensuring the path begins with a single "/", and then returning
`${cleanBase}/v1${path}`; replace all direct `${this.apiUrl}/v1/...` usages with
calls to this.buildUrl("/inboxes"), this.buildUrl(`/inboxes/${inboxId}`),
this.buildUrl(`/inboxes/${inboxId}/messages/send`) etc., so custom apiUrl values
that already include "/v1" work correctly and URL construction is centralized.

@ahmedmawiri ahmedmawiri merged commit 2bc53f6 into main Nov 11, 2025
1 check passed
@ahmedmawiri ahmedmawiri deleted the marc/rup-884-email-agent-mvp-v3 branch November 11, 2025 15:48
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