Skip to content
Merged
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
38 changes: 35 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ The easiest way to start sending AND **receiving** emails at scale.

#### Using the API endpoints

How to create an inbox:

```curl
curl -X POST https://api.sendook.com/v1/inboxes \
-H "Authorization: Bearer your_api_key" \
Expand All @@ -35,10 +37,36 @@ curl -X POST https://api.sendook.com/v1/inboxes \
}'
```

How to send a message:

```curl
curl -X POST https://api.sendook.com/v1/inboxes/{inbox_id}/messages/send \
-H "Authorization: Bearer your_api_key" \
-H "Content-Type: application/json" \
-d '{
"to": ["rupt@sendook.com"],
"subject": "Welcome!",
"text": "Thanks for signing up.",
"html": "<p>Thanks for signing up.</p>"
}'
```

How to create a webhook:

```curl
curl -X POST https://api.sendook.com/v1/webhooks \
-H "Authorization: Bearer your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks/email",
"events": ["message.received"]
}'
```

#### Using the TypeScript SDK

```typescript
import { Sendook } from "@sendook/node-sdk";
import { Sendook } from "@sendook/node";

// Initialize client with API key
const client = new Sendook({ apiKey: "your_api_key" });
Expand All @@ -55,11 +83,15 @@ await client.messages.send({
from: "rupt@sendook.com",
to: ["rupt@sendook.com"],
subject: "Welcome!",
body: "Thanks for signing up.",
text: "Thanks for signing up.",
html: "<p>Thanks for signing up.</p>",
});

// Receive emails via webhook
// Configure webhook endpoint to receive parsed emails as JSON
await client.webhook.create({
url: "https://your-app.com/webhooks/email",
events: ["message.received"],
});
```

**Steps:**
Expand Down
42 changes: 36 additions & 6 deletions api/controllers/WebhookAttemptController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { WebhookEvents } from "../models/Webhook";
import { getWebhooksByOrganizationIdAndEvent } from "./WebhookController";
import type Inbox from "../models/Inbox";
import type Message from "../models/Message";
import type Webhook from "../models/Webhook";
import axios from "axios";

export async function sendWebhookEvent({
Expand All @@ -18,7 +19,7 @@ export async function sendWebhookEvent({
event: (typeof WebhookEvents)[number];
inboxId?: string;
messageId?: string;
payload: HydratedDocument<Inbox | Message>;
payload: HydratedDocument<Inbox | Message> | { test: string };
}) {
const webhooks = await getWebhooksByOrganizationIdAndEvent({
organizationId,
Expand All @@ -28,13 +29,26 @@ export async function sendWebhookEvent({
return;
}

for (const webhook of webhooks) {
const webhooksWithoutDuplicates = webhooks.filter(
(webhook, index, self) =>
index === self.findIndex((t) => t.url === webhook.url)
);

for (const webhook of webhooksWithoutDuplicates) {
const webhookAttempt = new WebhookAttempt();
webhookAttempt.organizationId = new mongoose.Types.ObjectId(organizationId);
webhookAttempt.webhookId = webhook._id;
webhookAttempt.inboxId = inboxId ? new mongoose.Types.ObjectId(inboxId) : undefined;
webhookAttempt.messageId = messageId ? new mongoose.Types.ObjectId(messageId) : undefined;
webhookAttempt.payload = payload;
webhookAttempt.inboxId = inboxId
? new mongoose.Types.ObjectId(inboxId)
: undefined;
webhookAttempt.messageId = messageId
? new mongoose.Types.ObjectId(messageId)
: undefined;
webhookAttempt.payload = {
event,
payload,
};
webhookAttempt.timestamp = new Date();

try {
const response = await axios.post(webhook.url, {
Expand All @@ -47,9 +61,25 @@ export async function sendWebhookEvent({
webhookAttempt.response = response.data;
} catch (error) {
webhookAttempt.status = 500;
webhookAttempt.error = error instanceof Error ? error.message : "Unknown error";
webhookAttempt.error =
error instanceof Error ? error.message : "Unknown error";
}

await webhookAttempt.save();
}
}

export async function getWebhookAttemptsByOrganizationIdAndWebhookId({
organizationId,
webhookId,
}: {
organizationId: string;
webhookId: string;
}) {
return await WebhookAttempt.find({
organizationId: new mongoose.Types.ObjectId(organizationId),
webhookId: new mongoose.Types.ObjectId(webhookId),
})
.sort({ timestamp: -1 })
.limit(10);
}
41 changes: 41 additions & 0 deletions api/controllers/WebhookController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,34 @@ export async function createWebhook({
webhook.url = url;
webhook.events = events;
await webhook.save();
return webhook;
}

export async function getWebhookById(id: string) {
return await Webhook.findById(id);
}

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

export async function getWebhookByOrganizationIdAndId({
organizationId,
id,
}: {
organizationId: string;
id: string;
}) {
return await Webhook.findOne({
organizationId: new mongoose.Types.ObjectId(organizationId),
_id: new mongoose.Types.ObjectId(id.toString()),
});
}

export async function getWebhooksByOrganizationIdAndEvent({
Expand All @@ -30,3 +58,16 @@ export async function getWebhooksByOrganizationIdAndEvent({
events: { $in: [event] },
});
}

export async function deleteWebhookByOrganizationIdAndId({
organizationId,
id,
}: {
organizationId: string;
id: string;
}) {
return await Webhook.findOneAndDelete({
organizationId: new mongoose.Types.ObjectId(organizationId),
_id: new mongoose.Types.ObjectId(id),
});
}
2 changes: 2 additions & 0 deletions api/routes/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import inboxesRouter from "./v1/inboxes";
import domainsRouter from "./v1/domains";
import { getOrganizationById } from "../controllers/OrganizationController";
import passport from "passport";
import webhooksRouter from "./v1/webhooks";

const router = Router();

Expand All @@ -26,5 +27,6 @@ router.use("/:organizationId", async (req, res, next) => {
router.use("/:organizationId/api_keys", apiKeyRouter);
router.use("/:organizationId/inboxes", inboxesRouter);
router.use("/:organizationId/domains", domainsRouter);
router.use("/:organizationId/webhooks", webhooksRouter);

export default router;
12 changes: 9 additions & 3 deletions api/routes/v1/domains/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,17 +112,23 @@ router.get(
return res.json([
{
type: "MX",
name: "@",
name: domain.name.includes('.') && domain.name.split('.').length > 2
? domain.name.replace(/\.[^.]+\.[^.]+$/, '')
: "@",
value: "inbound-smtp.us-east-2.amazonaws.com",
},
{
type: "TXT",
name: "@",
name: domain.name.includes('.') && domain.name.split('.').length > 2
? domain.name.replace(/\.[^.]+\.[^.]+$/, '')
: "@",
value: "v=spf1 include:amazonses.com ~all",
},
{
type: "TXT",
name: "_dmarc",
name: `${domain.name.includes('.') && domain.name.split('.').length > 2
? `_dmarc.${domain.name.replace(/\.[^.]+\.[^.]+$/, '')}`
: "_dmarc"}`,
value: "v=DMARC1; p=reject;",
},
...verifiedDomainDkimAttributes.DkimTokens.map((token) => ({
Expand Down
2 changes: 2 additions & 0 deletions api/routes/v1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import passport from "passport";
import type { HydratedDocument } from "mongoose";
import type Organization from "../../models/Organization";
import apiKeyRouter from "./api_keys";
import webhooksRouter from "./webhooks";

const router = Router({ mergeParams: true });

Expand All @@ -19,5 +20,6 @@ router.use(
router.use("/api_keys", apiKeyRouter);
router.use("/inboxes", inboxesRouter);
router.use("/domains", domainsRouter);
router.use("/webhooks", webhooksRouter);

export default router;
24 changes: 24 additions & 0 deletions api/routes/v1/webhooks/attempts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Router } from "express";
import type { Request, Response } from "express";
import { getWebhookAttemptsByOrganizationIdAndWebhookId } from "../../../controllers/WebhookAttemptController";

const router = Router({ mergeParams: true });

router.get(
"/",
async (
req: Request<
{ organizationId: string; webhookId: string }
>,
res: Response
) => {
const webhookAttempts = await getWebhookAttemptsByOrganizationIdAndWebhookId({
organizationId: req.organization._id.toString(),
webhookId: req.params.webhookId,
});

return res.json(webhookAttempts);
}
);
Comment on lines +7 to +22
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

Add error handling and validation for the route handler.

The async handler lacks error handling, which could result in unhandled promise rejections. Additionally, req.organization is accessed without type checking, and webhookId is not validated before querying the database.

Apply this diff to add proper error handling:

 router.get(
   "/",
   async (
     req: Request<
       { organizationId: string; webhookId: string }
     >,
     res: Response
   ) => {
+    try {
+      if (!req.organization?._id) {
+        return res.status(401).json({ error: 'Unauthorized' });
+      }
+
+      if (!req.params.webhookId) {
+        return res.status(400).json({ error: 'Webhook ID is required' });
+      }
+
       const webhookAttempts = await getWebhookAttemptsByOrganizationIdAndWebhookId({
         organizationId: req.organization._id.toString(),
         webhookId: req.params.webhookId,
       });
 
       return res.json(webhookAttempts);
+    } catch (error) {
+      console.error('Failed to fetch webhook attempts:', error);
+      return res.status(500).json({ 
+        error: 'Failed to fetch webhook attempts',
+        message: error instanceof Error ? error.message : 'Unknown 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(
"/",
async (
req: Request<
{ organizationId: string; webhookId: string }
>,
res: Response
) => {
const webhookAttempts = await getWebhookAttemptsByOrganizationIdAndWebhookId({
organizationId: req.organization._id.toString(),
webhookId: req.params.webhookId,
});
return res.json(webhookAttempts);
}
);
router.get(
"/",
async (
req: Request<
{ organizationId: string; webhookId: string }
>,
res: Response
) => {
try {
if (!req.organization?._id) {
return res.status(401).json({ error: 'Unauthorized' });
}
if (!req.params.webhookId) {
return res.status(400).json({ error: 'Webhook ID is required' });
}
const webhookAttempts = await getWebhookAttemptsByOrganizationIdAndWebhookId({
organizationId: req.organization._id.toString(),
webhookId: req.params.webhookId,
});
return res.json(webhookAttempts);
} catch (error) {
console.error('Failed to fetch webhook attempts:', error);
return res.status(500).json({
error: 'Failed to fetch webhook attempts',
message: error instanceof Error ? error.message : 'Unknown error'
});
}
}
);
🤖 Prompt for AI Agents
In api/routes/v1/webhooks/attempts.ts around lines 7 to 22, the GET handler must
validate inputs and handle errors: first check that req.organization exists and
has a valid _id and that req.params.webhookId is present and well-formed (e.g.
non-empty or matches expected ID pattern); if validation fails send a 400
response. Wrap the async call to getWebhookAttemptsByOrganizationIdAndWebhookId
in a try-catch, log the caught error, and return a 500 response on unexpected
failures. Ensure you return after sending responses to avoid double-sends and
keep the handler async/await flow consistent.


export default router;
114 changes: 114 additions & 0 deletions api/routes/v1/webhooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { Router } from "express";
import type { Request, Response } from "express";
import { body } from "express-validator";
import { expressValidatorMiddleware } from "../../../middlewares/expressValidatorMiddleware";
import {
createWebhook,
deleteWebhookByOrganizationIdAndId,
getWebhookById,
getWebhookByOrganizationIdAndId,
getWebhooksByOrganizationId,
} from "../../../controllers/WebhookController";
import { WebhookEvents } from "../../../models/Webhook";
import { sendWebhookEvent } from "../../../controllers/WebhookAttemptController";
import attemptsRouter from "./attempts";

const router = Router({ mergeParams: true });

router.get(
"/",
async (req: Request<{ organizationId: string }, {}, {}>, res: Response) => {
const webhooks = await getWebhooksByOrganizationId({
organizationId: req.organization._id.toString(),
});
return res.json(webhooks);
}
);

router.post(
"/",
body("url").isString().trim(),
body("events").isArray().notEmpty(),
expressValidatorMiddleware,
async (
req: Request<
{ organizationId: string },
{},
{ url: string; events: (typeof WebhookEvents)[number][] }
>,
res: Response
) => {
const webhook = await createWebhook({
organizationId: req.organization._id.toString(),
url: req.body.url,
events: req.body.events,
});
return res.json(webhook);
}
);
Comment on lines +28 to +48
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

Tighten test route targeting and creation semantics for webhooks

A few points in this router worth adjusting:

  1. POST /:webhookId/test hits all webhooks for the event

    As noted in the controller review, this route verifies the target webhook exists, but then calls:

    await sendWebhookEvent({
      organizationId: req.organization._id.toString(),
      event: "message.received",
      payload: { test: "test" },
    });

    Since sendWebhookEvent selects webhooks by { organizationId, event }, this will send a test payload to every "message.received" webhook in the organization, not just :webhookId. That’s surprising given the route shape and can cause unintended traffic to unrelated endpoints.

    Suggestion: after fetching webhook by organization + id, either:

    • Pass an explicit webhook filter into sendWebhookEvent (after updating its signature to support that), or
    • Call a dedicated helper that posts only to this webhook.url and records a single WebhookAttempt.
  2. POST / should ideally return 201 for creation

    createWebhook returns a newly created resource, but the route currently does return res.json(webhook); (status 200). For a creation endpoint, using res.status(201).json(webhook); would be more idiomatic and match the OpenAPI spec’s 201 response for createWebhook.

  3. Optional: validate events against WebhookEvents

    The body validation only checks events is a non-empty array:

    body("events").isArray().notEmpty(),

    but doesn’t enforce that each entry is one of WebhookEvents. Adding a custom validator (e.g., .custom((events) => events.every(e => WebhookEvents.includes(e)))) would prevent storing unsupported event types that will never fire.

Items (2) and (3) are nice-to-have refactors; item (1) is the important behavioral fix.

Also applies to: 49-88, 90-109


router.get(
"/:webhookId",
async (
req: Request<{ organizationId: string; webhookId: string }, {}, {}>,
res: Response
) => {
const webhook = await getWebhookByOrganizationIdAndId({
organizationId: req.organization._id.toString(),
id: req.params.webhookId,
});
if (!webhook) {
return res.status(404).json({ error: "Webhook not found" });
}
return res.json(webhook);
}
);

router.post(
"/:webhookId/test",
async (
req: Request<{ organizationId: string; webhookId: string }, {}, {}>,
res: Response
) => {
const webhook = await getWebhookByOrganizationIdAndId({
organizationId: req.organization._id.toString(),
id: req.params.webhookId,
});
if (!webhook) {
return res.status(404).json({ error: "Webhook not found" });
}
await sendWebhookEvent({
organizationId: req.organization._id.toString(),
event: "message.received",
payload: {
test: "test",
}
});
return res.json({ success: true });
}
);

router.delete(
"/:webhookId",
async (
req: Request<{ organizationId: string; webhookId: string }, {}, {}>,
res: Response
) => {
const webhook = await getWebhookByOrganizationIdAndId({
organizationId: req.organization._id.toString(),
id: req.params.webhookId,
});
if (!webhook) {
return res.status(404).json({ error: "Webhook not found" });
}
await deleteWebhookByOrganizationIdAndId({
organizationId: req.organization._id.toString(),
id: req.params.webhookId,
});
return res.json(webhook);
}
);

router.use("/:webhookId/attempts", attemptsRouter);

export default router;
Loading