Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
402 changes: 402 additions & 0 deletions api/bun.lock

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions api/controllers/DNSController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { resolveMx } from 'node:dns/promises';
import type { MxRecord } from 'node:dns';

export async function getDNSMXRecords({
domain,
}: {
domain: string;
}): Promise<MxRecord[]> {
try {
return await resolveMx(domain);
} catch (error) {
console.error('Error resolving MX records:', error);
return [];
}
}
Comment on lines +4 to +15
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 handling may mask DNS lookup failures.

Returning an empty array on error makes it impossible for callers to distinguish between legitimate scenarios (no MX records configured) and transient failures (network issues, DNS timeouts). This could lead to false negatives in domain verification flows, where a temporary DNS failure is interpreted as "domain has no MX records."

Consider either:

  • Rethrowing the error and letting callers handle it appropriately, or
  • Returning a result object like { success: boolean, records: MxRecord[], error?: Error }
-export async function getDNSMXRecords({
-  domain,
-}: {
-  domain: string;
-}): Promise<MxRecord[]> {
-  try {
-    return await resolveMx(domain);
-  } catch (error) {
-    console.error('Error resolving MX records:', error);
-    return [];
-  }
-}
+export async function getDNSMXRecords({
+  domain,
+}: {
+  domain: string;
+}): Promise<MxRecord[]> {
+  return await resolveMx(domain);
+}
🤖 Prompt for AI Agents
In api/controllers/DNSController.ts around lines 4 to 15, the current catch
swallows DNS lookup errors by returning an empty array which conflates "no MX
records" with transient failures; change the function to return a discriminated
result object (e.g. Promise<{ success: boolean; records: MxRecord[]; error?:
Error }>) instead of MxRecord[]: on success return { success: true, records },
and on failure return { success: false, records: [], error } (or alternatively
rethrow the caught error if callers should handle it); update the function
signature, adjust the catch block to populate the error field rather than
returning [], and update all callers to handle the new result shape.

98 changes: 98 additions & 0 deletions api/controllers/DomainController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import mongoose, { type HydratedDocument } from "mongoose";
import Domain from "../db/mongo/schemas/Domain";
import { getDNSMXRecords } from "./DNSController";
import type IDomain from "../models/Domain";

export async function createDomain({
organizationId,
name,
}: {
organizationId: string;
name: string;
}) {
const domain = new Domain();
domain.organizationId = new mongoose.Types.ObjectId(organizationId);
domain.name = name;
await domain.save();
return domain;
}
Comment on lines +6 to +18
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

Add input validation and error handling.

The function lacks:

  • Validation for empty or invalid name values
  • Error handling for invalid organizationId (ObjectId conversion can throw)
  • Explicit duplicate checking (though the unique index will enforce this at the DB level, returning a more user-friendly error would improve the API experience)

Consider adding validation and try-catch blocks for database operations.

Run the following script to check if validation is handled at the route layer:


🏁 Script executed:

#!/bin/bash
# Check if domain routes include validation middleware or error handling
fd -e ts -e js "domains" api/routes | xargs rg -A 10 "createDomain|validation|validate"

Length of output: 3631



---

🏁 Script executed:

```shell
#!/bin/bash
# Get the full POST route handler that creates a domain
rg -B 5 -A 20 'router.post.*function|router\.post' api/routes/v1/domains/index.ts | head -80

Length of output: 1227



---

🏁 Script executed:

```shell
#!/bin/bash
# Check for global error handling middleware
rg -n "error|catch|handler|middleware" api/middlewares/ | head -30

Length of output: 266


Add error handling for database operations.

Input validation is already handled at the route layer via express-validator (body("name").isString().notEmpty().trim()). However, the createDomain() function lacks error handling for:

  • Database save failures (e.g., connection errors, validation errors from schema)
  • Duplicate domain index violations (should return a user-friendly error instead of an unhandled exception)

Wrap the domain.save() operation in a try-catch block to handle these cases gracefully.

🤖 Prompt for AI Agents
In api/controllers/DomainController.ts around lines 4 to 16, the createDomain
function performs domain.save() without any error handling; wrap the save call
in a try-catch, catch Mongoose validation errors and duplicate-key errors
(E11000) specifically and throw/return a clear, user-friendly error (e.g.,
"Domain already exists" for duplicate index) while logging the original error
for diagnostics, and rethrow or convert other DB errors into a generic
persistence error so callers can respond appropriately.


export async function getDomainsByOrganizationId({
organizationId,
}: {
organizationId: string;
}) {
return await Domain.find({
organizationId: new mongoose.Types.ObjectId(organizationId),
});
}

export async function getDomainByOrganizationIdAndName({
organizationId,
name,
}: {
organizationId: string;
name: string;
}) {
return await Domain.findOne({
organizationId: new mongoose.Types.ObjectId(organizationId),
name,
});
}

export async function getVerifiedDomainByOrganizationIdAndName({
organizationId,
name,
}: {
organizationId: string;
name: string;
}) {
return await Domain.findOne({
organizationId: new mongoose.Types.ObjectId(organizationId),
name: name,
verified: true,
});
}

export async function deleteDomainByOrganizationIdAndName({
organizationId,
name,
}: {
organizationId: string;
name: string;
}) {
return await Domain.findOneAndDelete({
organizationId: new mongoose.Types.ObjectId(organizationId),
name: name,
});
}

export async function verifyDomainDNS({
domain,
}: {
domain: HydratedDocument<IDomain>;
}): Promise<{ verified: boolean; domain: HydratedDocument<IDomain> }> {
const mxRecords = await getDNSMXRecords({ domain: domain.name });

let mxRecordFound;
if (mxRecords && Array.isArray(mxRecords)) {
mxRecordFound = domain.records.find(
(domainRecord) =>
domainRecord.type === "MX" &&
mxRecords.find((dnsRecord) => {
return (
typeof dnsRecord.exchange === "string" &&
dnsRecord.exchange.toLowerCase() === domainRecord.value.toLowerCase()
);
})
);
}

if (mxRecordFound) {
mxRecordFound.status = "verified";
}
domain.verified = mxRecordFound ? true : false;
await domain.save();

return { verified: domain.verified, domain };
}
13 changes: 13 additions & 0 deletions api/controllers/InboxController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,19 @@ export async function getInboxByOrganizationIdAndInboxId({
});
}

export async function deleteInboxByOrganizationIdAndInboxId({
organizationId,
inboxId,
}: {
organizationId: string;
inboxId: string;
}) {
return await Inbox.findOneAndDelete({
organizationId: new mongoose.Types.ObjectId(organizationId),
_id: new mongoose.Types.ObjectId(inboxId),
});
}

export async function getInboxByEmail(email: string) {
return await Inbox.findOne({ email });
}
Expand Down
77 changes: 71 additions & 6 deletions api/controllers/MessageController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@ export async function createMessage({
toInboxId,
from,
to,
cc,
bcc,
labels,
externalMessageId,
subject,
text,
html,
attachments,
status,
}: {
organizationId: string;
Expand All @@ -22,25 +26,54 @@ export async function createMessage({
fromInboxId?: string;
toInboxId?: string;
from: string;
to: string;
to?: string[];
cc?: string[];
bcc?: string[];
labels?: string[];
externalMessageId?: string;
subject: string;
text: string;
html: string;
attachments?: {
content: string;
name?: string;
contentType?: string;
}[];
status?: (typeof MessageStatus)[number];
}) {
const message = new Message();
message.organizationId = new mongoose.Types.ObjectId(organizationId);
message.inboxId = new mongoose.Types.ObjectId(inboxId);
message.threadId = new mongoose.Types.ObjectId(threadId);
message.fromInboxId = fromInboxId ? new mongoose.Types.ObjectId(fromInboxId) : undefined;
message.toInboxId = toInboxId ? new mongoose.Types.ObjectId(toInboxId) : undefined;
message.fromInboxId = fromInboxId
? new mongoose.Types.ObjectId(fromInboxId)
: undefined;
message.toInboxId = toInboxId
? new mongoose.Types.ObjectId(toInboxId)
: undefined;
message.from = from;
message.to = to;
message.to = to ?? [];
message.cc = cc ?? [];
message.bcc = bcc ?? [];
message.labels = labels ?? [];
message.externalMessageId = externalMessageId;
message.subject = subject;
message.text = text;
message.html = html;
message.attachments = new mongoose.Types.Array<{
content: string;
name?: string;
contentType?: string;
}>();
if (attachments) {
for (const attachment of attachments) {
message.attachments.push({
content: attachment.content,
name: attachment.name,
contentType: attachment.contentType,
});
}
}
message.status = status;
await message.save();
return message;
Expand All @@ -50,10 +83,42 @@ export async function getMessageById(messageId: string) {
return await Message.findById(messageId);
}

export async function getMessagesByInboxId(inboxId: string) {
return await Message.find({ inboxId: new mongoose.Types.ObjectId(inboxId) });
export async function getMessages({
inboxId,
query,
}: {
inboxId: string;
query?: string;
}) {
const filter: any = {
inboxId: new mongoose.Types.ObjectId(inboxId),
};

if (query) {
filter.$or = [
{ subject: { $regex: query, $options: "i" } },
{ text: { $regex: query, $options: "i" } },
{ html: { $regex: query, $options: "i" } },
{ to: { $regex: query, $options: "i" } },
{ cc: { $regex: query, $options: "i" } },
{ bcc: { $regex: query, $options: "i" } },
{ labels: { $regex: query, $options: "i" } },
];
}

return await Message.find(filter);
}

export async function getMessageByExternalMessageId(externalMessageId: string) {
return await Message.findOne({ externalMessageId });
}

export async function deleteMessageById(messageId: string) {
return await Message.findByIdAndDelete(messageId);
}

export async function deleteMessagesByInboxId(inboxId: string) {
return await Message.deleteMany({
inboxId: new mongoose.Types.ObjectId(inboxId),
});
}
71 changes: 47 additions & 24 deletions api/controllers/SESController.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import {
GetIdentityDkimAttributesCommand,
SendEmailCommand,
SendRawEmailCommand,
SESClient,
VerifyDomainDkimCommand,
} from "@aws-sdk/client-ses";
import MailComposer from "nodemailer/lib/mail-composer";
import type { SNSMessage } from "../models/SES";
import { createMessage, getMessageByExternalMessageId, getMessageById } from "./MessageController";
import type { MessageStatus } from "../models/Message";
Expand Down Expand Up @@ -38,46 +39,68 @@ export async function sendSESMessage({
from,
fromName,
to,
cc,
bcc,
subject,
text,
html,
attachments,
}: {
messageId: string;
from: string;
fromName?: string;
to: string;
to?: string[];
cc?: string[];
bcc?: string[];
subject: string;
text: string;
html: string;
attachments?: {
content: string;
name?: string;
contentType?: string;
}[];
}) {
const command = new SendEmailCommand({
Source: fromName ? `${fromName} <${from}>` : from,
Destination: {
ToAddresses: [to],
},
Message: {
Subject: {
Data: subject,
Charset: "UTF-8",
},
Body: {
Html: {
Data: html,
Charset: "UTF-8",
},
Text: {
Data: text,
Charset: "UTF-8",
},
},
// SES SendEmailCommand does NOT support attachments. To include attachments, you must use SendRawEmailCommand and manually build a MIME-encoded message.
// Here's one way to do it with attachments:

// 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",
})),
Comment on lines +76 to +81
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 attachment size validation.

The code maps attachments without validating their size. Since attachments are base64-encoded and stored in MongoDB (16MB document limit), large attachments could cause failures. Add validation to prevent oversized attachments.

Add validation before the MailComposer:

+  // Validate total attachment size (MongoDB 16MB limit, leave buffer for other fields)
+  const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10MB
+  const totalAttachmentSize = (attachments ?? []).reduce((sum, att) => {
+    // Base64 encoded size is ~4/3 of original
+    const decodedSize = (att.content.length * 3) / 4;
+    return sum + decodedSize;
+  }, 0);
+  
+  if (totalAttachmentSize > MAX_ATTACHMENT_SIZE) {
+    throw new Error(`Total attachment size exceeds ${MAX_ATTACHMENT_SIZE} bytes`);
+  }

   const mailOptions: any = {
     // ... rest of the code

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

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);
}
Expand Down Expand Up @@ -150,7 +173,7 @@ export async function handleInboundSESMessage({
threadId,
from: notification.mail.source,
fromInboxId: fromInboxId?.id,
to: notification.mail.destination[0],
to: [notification.mail.destination[0]],
toInboxId: inbox.id,
subject: notification.mail.commonHeaders?.subject,
text: content.getVisibleText(),
Expand Down
13 changes: 13 additions & 0 deletions api/db/mongo/schemas/Domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,23 @@ const domainSchema = new mongoose.Schema(
type: Boolean,
default: false,
},
records: {
type: [mongoose.Schema.Types.Mixed],
default: [
{
type: "MX",
name: "@",
value: "inbound-smtp.us-east-2.amazonaws.com",
status: "pending",
},
],
},
},
{
timestamps: true,
}
);

domainSchema.index({ organizationId: 1, name: 1 }, { unique: true });

export default mongoose.model<IDomain>("Domain", domainSchema);
Loading