feat: add webhooks (#334)
This commit is contained in:
+171
-171
@@ -1,173 +1,173 @@
|
||||
{
|
||||
"$schema": "https://mintlify.com/docs.json",
|
||||
"theme": "maple",
|
||||
"name": "useSend",
|
||||
"colors": {
|
||||
"primary": "#21453D",
|
||||
"light": "#E6FAF5",
|
||||
"dark": "#21453D"
|
||||
},
|
||||
"background": {
|
||||
"color": {
|
||||
"light": "#F5F5F5",
|
||||
"dark": "#181825"
|
||||
}
|
||||
},
|
||||
"fonts": {
|
||||
"family": "IBM Plex Mono"
|
||||
},
|
||||
"favicon": "/favicon.svg",
|
||||
"navigation": {
|
||||
"tabs": [
|
||||
{
|
||||
"tab": "Documentation",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Getting Started",
|
||||
"pages": [
|
||||
"introduction",
|
||||
"get-started/nodejs",
|
||||
"get-started/python",
|
||||
"get-started/local",
|
||||
"get-started/smtp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Self Hosting",
|
||||
"pages": ["self-hosting/overview", "self-hosting/railway"]
|
||||
},
|
||||
{
|
||||
"group": "Guides",
|
||||
"pages": ["guides/use-with-react-email"]
|
||||
},
|
||||
{
|
||||
"group": "Community SDKs",
|
||||
"pages": ["community-sdk/python", "community-sdk/go"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "API Reference",
|
||||
"groups": [
|
||||
{
|
||||
"group": "API Reference",
|
||||
"pages": ["api-reference/introduction"]
|
||||
},
|
||||
{
|
||||
"group": "Emails",
|
||||
"pages": [
|
||||
"api-reference/emails/get-email",
|
||||
"api-reference/emails/list-emails",
|
||||
"api-reference/emails/send-email",
|
||||
"api-reference/emails/batch-email",
|
||||
"api-reference/emails/update-schedule",
|
||||
"api-reference/emails/cancel-schedule"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Contact Books",
|
||||
"pages": [
|
||||
"api-reference/contacts/list-contact-books",
|
||||
"api-reference/contacts/get-contact-book",
|
||||
"api-reference/contacts/create-contact-book",
|
||||
"api-reference/contacts/update-contact-book",
|
||||
"api-reference/contacts/delete-contact-book"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Contacts",
|
||||
"pages": [
|
||||
"api-reference/contacts/get-contact",
|
||||
"api-reference/contacts/get-contacts",
|
||||
"api-reference/contacts/create-contact",
|
||||
"api-reference/contacts/update-contact",
|
||||
"api-reference/contacts/upsert-contact",
|
||||
"api-reference/contacts/delete-contact"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Domains",
|
||||
"pages": [
|
||||
"api-reference/domains/get-domain",
|
||||
"api-reference/domains/list-domains",
|
||||
"api-reference/domains/create-domain",
|
||||
"api-reference/domains/verify-domain",
|
||||
"api-reference/domains/delete-domain"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Campaigns",
|
||||
"pages": [
|
||||
"api-reference/campaigns/create-campaign",
|
||||
"api-reference/campaigns/get-campaigns",
|
||||
"api-reference/campaigns/get-campaign",
|
||||
"api-reference/campaigns/schedule-campaign",
|
||||
"api-reference/campaigns/pause-campaign",
|
||||
"api-reference/campaigns/resume-campaign"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "Changelog",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Updates",
|
||||
"pages": ["changelog"]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"global": {
|
||||
"anchors": [
|
||||
{
|
||||
"anchor": "GitHub",
|
||||
"href": "https://github.com/usesend/usesend",
|
||||
"icon": "github"
|
||||
},
|
||||
{
|
||||
"anchor": "Community",
|
||||
"href": "https://discord.gg/BU8n8pJv8S",
|
||||
"icon": "discord"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"logo": {
|
||||
"light": "/logo/logo-wordmark.svg",
|
||||
"dark": "/logo/logo-wordmark-dark.svg"
|
||||
},
|
||||
"api": {
|
||||
"playground": {
|
||||
"display": "interactive"
|
||||
},
|
||||
"mdx": {
|
||||
"server": "https://mintlify.com/api",
|
||||
"auth": {
|
||||
"method": "bearer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
"links": [
|
||||
{
|
||||
"label": "Support",
|
||||
"href": "mailto:hey@usesend.com"
|
||||
}
|
||||
],
|
||||
"primary": {
|
||||
"type": "button",
|
||||
"label": "Dashboard",
|
||||
"href": "https://app.usesend.com"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"socials": {
|
||||
"x": "https://x.com/useSend_com",
|
||||
"github": "https://github.com/usesend"
|
||||
}
|
||||
},
|
||||
"contextual": {
|
||||
"options": ["copy", "view", "chatgpt", "claude", "perplexity"]
|
||||
}
|
||||
"$schema": "https://mintlify.com/docs.json",
|
||||
"theme": "maple",
|
||||
"name": "useSend",
|
||||
"colors": {
|
||||
"primary": "#21453D",
|
||||
"light": "#E6FAF5",
|
||||
"dark": "#21453D"
|
||||
},
|
||||
"background": {
|
||||
"color": {
|
||||
"light": "#F5F5F5",
|
||||
"dark": "#181825"
|
||||
}
|
||||
},
|
||||
"fonts": {
|
||||
"family": "IBM Plex Mono"
|
||||
},
|
||||
"favicon": "/favicon.svg",
|
||||
"navigation": {
|
||||
"tabs": [
|
||||
{
|
||||
"tab": "Documentation",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Getting Started",
|
||||
"pages": [
|
||||
"introduction",
|
||||
"get-started/nodejs",
|
||||
"get-started/python",
|
||||
"get-started/local",
|
||||
"get-started/smtp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Self Hosting",
|
||||
"pages": ["self-hosting/overview", "self-hosting/railway"]
|
||||
},
|
||||
{
|
||||
"group": "Guides",
|
||||
"pages": ["guides/webhooks", "guides/use-with-react-email"]
|
||||
},
|
||||
{
|
||||
"group": "Community SDKs",
|
||||
"pages": ["community-sdk/python", "community-sdk/go"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "API Reference",
|
||||
"groups": [
|
||||
{
|
||||
"group": "API Reference",
|
||||
"pages": ["api-reference/introduction"]
|
||||
},
|
||||
{
|
||||
"group": "Emails",
|
||||
"pages": [
|
||||
"api-reference/emails/get-email",
|
||||
"api-reference/emails/list-emails",
|
||||
"api-reference/emails/send-email",
|
||||
"api-reference/emails/batch-email",
|
||||
"api-reference/emails/update-schedule",
|
||||
"api-reference/emails/cancel-schedule"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Contact Books",
|
||||
"pages": [
|
||||
"api-reference/contacts/list-contact-books",
|
||||
"api-reference/contacts/get-contact-book",
|
||||
"api-reference/contacts/create-contact-book",
|
||||
"api-reference/contacts/update-contact-book",
|
||||
"api-reference/contacts/delete-contact-book"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Contacts",
|
||||
"pages": [
|
||||
"api-reference/contacts/get-contact",
|
||||
"api-reference/contacts/get-contacts",
|
||||
"api-reference/contacts/create-contact",
|
||||
"api-reference/contacts/update-contact",
|
||||
"api-reference/contacts/upsert-contact",
|
||||
"api-reference/contacts/delete-contact"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Domains",
|
||||
"pages": [
|
||||
"api-reference/domains/get-domain",
|
||||
"api-reference/domains/list-domains",
|
||||
"api-reference/domains/create-domain",
|
||||
"api-reference/domains/verify-domain",
|
||||
"api-reference/domains/delete-domain"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Campaigns",
|
||||
"pages": [
|
||||
"api-reference/campaigns/create-campaign",
|
||||
"api-reference/campaigns/get-campaigns",
|
||||
"api-reference/campaigns/get-campaign",
|
||||
"api-reference/campaigns/schedule-campaign",
|
||||
"api-reference/campaigns/pause-campaign",
|
||||
"api-reference/campaigns/resume-campaign"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "Changelog",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Updates",
|
||||
"pages": ["changelog"]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"global": {
|
||||
"anchors": [
|
||||
{
|
||||
"anchor": "GitHub",
|
||||
"href": "https://github.com/usesend/usesend",
|
||||
"icon": "github"
|
||||
},
|
||||
{
|
||||
"anchor": "Community",
|
||||
"href": "https://discord.gg/BU8n8pJv8S",
|
||||
"icon": "discord"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"logo": {
|
||||
"light": "/logo/logo-wordmark.svg",
|
||||
"dark": "/logo/logo-wordmark-dark.svg"
|
||||
},
|
||||
"api": {
|
||||
"playground": {
|
||||
"display": "interactive"
|
||||
},
|
||||
"mdx": {
|
||||
"server": "https://mintlify.com/api",
|
||||
"auth": {
|
||||
"method": "bearer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
"links": [
|
||||
{
|
||||
"label": "Support",
|
||||
"href": "mailto:hey@usesend.com"
|
||||
}
|
||||
],
|
||||
"primary": {
|
||||
"type": "button",
|
||||
"label": "Dashboard",
|
||||
"href": "https://app.usesend.com"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"socials": {
|
||||
"x": "https://x.com/useSend_com",
|
||||
"github": "https://github.com/usesend"
|
||||
}
|
||||
},
|
||||
"contextual": {
|
||||
"options": ["copy", "view", "chatgpt", "claude", "perplexity"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
---
|
||||
title: Webhooks
|
||||
description: "Receive real-time notifications when events occur in your useSend account"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Webhooks allow you to receive HTTP POST requests to your server when events occur in useSend, such as when an email is delivered, bounced, or clicked. This enables you to build real-time integrations and automate workflows.
|
||||
|
||||
## Setting up webhooks
|
||||
|
||||
<Steps>
|
||||
<Step title="Create a webhook endpoint">
|
||||
Create an endpoint on your server that can receive POST requests. The endpoint must:
|
||||
|
||||
- Accept POST requests with JSON body
|
||||
- Return a 2xx status code to acknowledge receipt
|
||||
- Respond within 10 seconds
|
||||
|
||||
</Step>
|
||||
<Step title="Add webhook in dashboard">
|
||||
Go to [Webhooks](https://app.usesend.com/webhooks) in your useSend dashboard and create a new webhook:
|
||||
|
||||
- Enter your endpoint URL
|
||||
- Select which events you want to receive
|
||||
- Copy the signing secret for verification
|
||||
|
||||
</Step>
|
||||
<Step title="Verify webhook signatures">
|
||||
Always verify webhook signatures to ensure requests are from useSend. See the [Signature Verification](#signature-verification) section below.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Event types
|
||||
|
||||
### Email events
|
||||
|
||||
| Event | Description |
|
||||
| ------------------------ | ---------------------------------------------------- |
|
||||
| `email.queued` | Email has been queued for sending |
|
||||
| `email.sent` | Email has been sent to the recipient's mail server |
|
||||
| `email.delivered` | Email was successfully delivered |
|
||||
| `email.delivery_delayed` | Email delivery is being retried |
|
||||
| `email.bounced` | Email bounced (permanent or temporary) |
|
||||
| `email.rejected` | Email was rejected |
|
||||
| `email.complained` | Recipient marked email as spam |
|
||||
| `email.failed` | Email failed to send |
|
||||
| `email.cancelled` | Scheduled email was cancelled |
|
||||
| `email.suppressed` | Email was suppressed (recipient on suppression list) |
|
||||
| `email.opened` | Recipient opened the email |
|
||||
| `email.clicked` | Recipient clicked a link in the email |
|
||||
|
||||
### Contact events
|
||||
|
||||
| Event | Description |
|
||||
| ----------------- | ----------------------- |
|
||||
| `contact.created` | New contact was created |
|
||||
| `contact.updated` | Contact was updated |
|
||||
| `contact.deleted` | Contact was deleted |
|
||||
|
||||
### Domain events
|
||||
|
||||
| Event | Description |
|
||||
| ----------------- | ----------------------------- |
|
||||
| `domain.created` | New domain was added |
|
||||
| `domain.verified` | Domain verification completed |
|
||||
| `domain.updated` | Domain settings were updated |
|
||||
| `domain.deleted` | Domain was deleted |
|
||||
|
||||
## Webhook payload
|
||||
|
||||
Each webhook request includes a JSON payload with the following structure. See [Event data details](#event-data-details) for details on the `data` field for each event type.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "call_abc123",
|
||||
"type": "email.delivered",
|
||||
"version": "2026-01-18",
|
||||
"createdAt": "2024-01-15T10:30:00.000Z",
|
||||
"teamId": 123,
|
||||
"data": {
|
||||
"id": "email_123",
|
||||
"status": "DELIVERED",
|
||||
"from": "sender@example.com",
|
||||
"to": ["recipient@example.com"],
|
||||
"subject": "Welcome!",
|
||||
"occurredAt": "2024-01-15T10:30:00Z"
|
||||
},
|
||||
"attempt": 1
|
||||
}
|
||||
```
|
||||
|
||||
### Payload fields
|
||||
|
||||
| Field | Description |
|
||||
| ----------- | ------------------------------------------ |
|
||||
| `id` | Unique identifier for this webhook call |
|
||||
| `type` | The event type (e.g., `email.delivered`) |
|
||||
| `version` | API version for the payload format |
|
||||
| `createdAt` | When the event was created |
|
||||
| `teamId` | Your team ID |
|
||||
| `data` | Event-specific data (varies by event type) |
|
||||
| `attempt` | Delivery attempt number (1-6) |
|
||||
|
||||
## Request headers
|
||||
|
||||
Each webhook request includes the following headers:
|
||||
|
||||
| Header | Description |
|
||||
| --------------------- | -------------------------------------- |
|
||||
| `X-UseSend-Signature` | HMAC-SHA256 signature for verification |
|
||||
| `X-UseSend-Timestamp` | Unix timestamp in milliseconds |
|
||||
| `X-UseSend-Event` | Event type |
|
||||
| `X-UseSend-Call` | Unique webhook call ID |
|
||||
| `X-UseSend-Retry` | `true` if this is a retry attempt |
|
||||
|
||||
## Signature verification
|
||||
|
||||
Always verify webhook signatures to ensure requests are authentic. The signature is computed as:
|
||||
|
||||
```
|
||||
HMAC-SHA256(secret, "${timestamp}.${rawBody}")
|
||||
```
|
||||
|
||||
### Using the SDK (Recommended)
|
||||
|
||||
<CodeGroup>
|
||||
```bash npm
|
||||
npm install usesend
|
||||
```
|
||||
|
||||
```bash yarn
|
||||
yarn add usesend
|
||||
```
|
||||
|
||||
```bash pnpm
|
||||
pnpm add usesend
|
||||
```
|
||||
|
||||
```bash bun
|
||||
bun add usesend
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
### Next.js App Router
|
||||
|
||||
```typescript
|
||||
import { UseSend } from "usesend";
|
||||
|
||||
const usesend = new UseSend("us_your_api_key");
|
||||
const webhooks = usesend.webhooks(process.env.USESEND_WEBHOOK_SECRET!);
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const rawBody = await request.text();
|
||||
const event = webhooks.constructEvent(rawBody, {
|
||||
headers: request.headers,
|
||||
});
|
||||
|
||||
switch (event.type) {
|
||||
case "email.delivered":
|
||||
console.log("Email delivered to:", event.data.to);
|
||||
break;
|
||||
case "email.bounced":
|
||||
console.log("Email bounced:", event.data.id);
|
||||
break;
|
||||
case "email.opened":
|
||||
console.log("Email opened:", event.data.id);
|
||||
break;
|
||||
}
|
||||
|
||||
return new Response("ok");
|
||||
} catch (error) {
|
||||
console.error("Webhook error:", error);
|
||||
return new Response((error as Error).message, { status: 400 });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Express
|
||||
|
||||
```typescript
|
||||
import express from "express";
|
||||
import { Webhooks } from "usesend";
|
||||
|
||||
const webhooks = new Webhooks(process.env.USESEND_WEBHOOK_SECRET!);
|
||||
|
||||
const app = express();
|
||||
|
||||
// Important: Use raw body parser for webhook routes
|
||||
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
|
||||
try {
|
||||
const event = webhooks.constructEvent(req.body, {
|
||||
headers: req.headers,
|
||||
});
|
||||
|
||||
switch (event.type) {
|
||||
case "email.delivered":
|
||||
console.log("Email delivered to:", event.data.to);
|
||||
break;
|
||||
case "email.bounced":
|
||||
console.log("Email bounced:", event.data.id);
|
||||
break;
|
||||
}
|
||||
|
||||
res.status(200).send("ok");
|
||||
} catch (error) {
|
||||
console.error("Webhook error:", error);
|
||||
res.status(400).send((error as Error).message);
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(3000);
|
||||
```
|
||||
|
||||
### Verification only
|
||||
|
||||
If you only need to verify the signature without parsing:
|
||||
|
||||
```typescript
|
||||
const isValid = webhooks.verify(rawBody, { headers: request.headers });
|
||||
|
||||
if (!isValid) {
|
||||
return new Response("Invalid signature", { status: 401 });
|
||||
}
|
||||
```
|
||||
|
||||
### Manual verification
|
||||
|
||||
If you prefer to verify manually without the SDK:
|
||||
|
||||
```typescript
|
||||
import { createHmac, timingSafeEqual } from "crypto";
|
||||
|
||||
function verifyWebhook(
|
||||
secret: string,
|
||||
rawBody: string,
|
||||
signature: string,
|
||||
timestamp: string,
|
||||
): boolean {
|
||||
const expectedSignature = createHmac("sha256", secret)
|
||||
.update(`${timestamp}.${rawBody}`)
|
||||
.digest("hex");
|
||||
|
||||
const expected = Buffer.from(`v1=${expectedSignature}`, "utf8");
|
||||
const received = Buffer.from(signature, "utf8");
|
||||
|
||||
if (expected.length !== received.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return timingSafeEqual(expected, received);
|
||||
}
|
||||
|
||||
// Usage
|
||||
const signature = request.headers.get("X-UseSend-Signature");
|
||||
const timestamp = request.headers.get("X-UseSend-Timestamp");
|
||||
|
||||
const isValid = verifyWebhook(secret, rawBody, signature, timestamp);
|
||||
```
|
||||
|
||||
## Retry behavior
|
||||
|
||||
If your endpoint doesn't return a 2xx response, useSend will retry delivery with exponential backoff:
|
||||
|
||||
| Attempt | Delay |
|
||||
| ------- | ----------- |
|
||||
| 1 | Immediate |
|
||||
| 2 | ~5 seconds |
|
||||
| 3 | ~10 seconds |
|
||||
| 4 | ~20 seconds |
|
||||
| 5 | ~40 seconds |
|
||||
| 6 | ~80 seconds |
|
||||
|
||||
After 6 failed attempts, the webhook call is marked as failed.
|
||||
|
||||
<Warning>
|
||||
If your webhook endpoint fails 30 consecutive times across any calls, the
|
||||
webhook will be automatically disabled to prevent continued failures. You can
|
||||
re-enable it from the dashboard.
|
||||
</Warning>
|
||||
|
||||
## Best practices
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Respond quickly">
|
||||
Return a 2xx response as soon as possible. Process webhook data
|
||||
asynchronously if needed. Requests timeout after 10 seconds.
|
||||
</Accordion>
|
||||
<Accordion title="Handle duplicates">
|
||||
Use the `id` field in the payload to deduplicate events. In rare cases, the
|
||||
same event may be delivered more than once.
|
||||
</Accordion>
|
||||
<Accordion title="Verify signatures">
|
||||
Always verify the `X-UseSend-Signature` header to ensure requests are from
|
||||
useSend and haven't been tampered with.
|
||||
</Accordion>
|
||||
<Accordion title="Check timestamps">
|
||||
The SDK rejects signatures older than 5 minutes by default. This prevents
|
||||
replay attacks.
|
||||
</Accordion>
|
||||
<Accordion title="Use HTTPS">
|
||||
Always use HTTPS endpoints in production to encrypt webhook data in transit.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Testing webhooks
|
||||
|
||||
You can send a test webhook from the dashboard to verify your endpoint is working correctly:
|
||||
|
||||
1. Go to [Webhooks](https://app.usesend.com/webhooks)
|
||||
2. Click on your webhook
|
||||
3. Click "Send Test" to send a test event
|
||||
|
||||
The test event will have type `webhook.test` with the following payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"test": true,
|
||||
"webhookId": "wh_abc123",
|
||||
"sentAt": "2024-01-15T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Webhook not receiving events">
|
||||
- Verify your endpoint URL is correct and publicly accessible - Check that
|
||||
your endpoint returns a 2xx status code - Ensure the webhook is set to
|
||||
ACTIVE status in the dashboard - Check if the webhook was auto-disabled due
|
||||
to consecutive failures
|
||||
</Accordion>
|
||||
<Accordion title="Signature verification failing">
|
||||
- Use the raw request body, not parsed JSON - Ensure you're using the
|
||||
correct webhook secret - Check that the timestamp hasn't expired (5 minute
|
||||
window) - Verify you're computing the HMAC correctly: `HMAC-SHA256(secret,
|
||||
"${timestamp}.${rawBody}")`
|
||||
</Accordion>
|
||||
<Accordion title="Webhook auto-disabled">
|
||||
After 30 consecutive failures, webhooks are automatically disabled. Fix the
|
||||
issue with your endpoint, then re-enable the webhook from the dashboard. The
|
||||
failure counter resets on the next successful delivery.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Event data details
|
||||
|
||||
This section documents the `data` field structure for each event type.
|
||||
|
||||
### Email events
|
||||
|
||||
Most email events share a common base structure:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: string; // Email ID
|
||||
status: string; // Email status (e.g., "DELIVERED", "BOUNCED")
|
||||
from: string; // Sender email address
|
||||
to: string[]; // Recipient email addresses
|
||||
occurredAt: string; // ISO 8601 timestamp
|
||||
subject?: string; // Email subject
|
||||
campaignId?: string; // Campaign ID (if from a campaign)
|
||||
contactId?: string; // Contact ID (if sent to a contact)
|
||||
domainId?: number; // Domain ID
|
||||
templateId?: string; // Template ID (if using a template)
|
||||
metadata?: object; // Custom metadata you attached to the email
|
||||
}
|
||||
```
|
||||
|
||||
#### email.bounced
|
||||
|
||||
Includes additional bounce details:
|
||||
|
||||
```typescript
|
||||
{
|
||||
// ... base email fields
|
||||
bounce: {
|
||||
type: "Transient" | "Permanent" | "Undetermined";
|
||||
subType: "General" | "NoEmail" | "Suppressed" | "OnAccountSuppressionList"
|
||||
| "MailboxFull" | "MessageTooLarge" | "ContentRejected" | "AttachmentRejected";
|
||||
message?: string; // Bounce message from the mail server
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### email.failed
|
||||
|
||||
Includes failure reason:
|
||||
|
||||
```typescript
|
||||
{
|
||||
// ... base email fields
|
||||
failed: {
|
||||
reason: string; // Failure reason
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### email.suppressed
|
||||
|
||||
Includes suppression details:
|
||||
|
||||
```typescript
|
||||
{
|
||||
// ... base email fields
|
||||
suppression: {
|
||||
type: "Bounce" | "Complaint" | "Manual";
|
||||
reason: string; // Why the email was suppressed
|
||||
source?: string; // Source of the suppression
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### email.opened
|
||||
|
||||
Includes open tracking details:
|
||||
|
||||
```typescript
|
||||
{
|
||||
// ... base email fields
|
||||
open: {
|
||||
timestamp: string; // When the email was opened
|
||||
userAgent?: string; // Browser/client user agent
|
||||
ip?: string; // IP address
|
||||
platform?: string; // Detected platform
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### email.clicked
|
||||
|
||||
Includes click tracking details:
|
||||
|
||||
```typescript
|
||||
{
|
||||
// ... base email fields
|
||||
click: {
|
||||
timestamp: string; // When the link was clicked
|
||||
url: string; // The clicked URL
|
||||
userAgent?: string; // Browser/client user agent
|
||||
ip?: string; // IP address
|
||||
platform?: string; // Detected platform
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Contact events
|
||||
|
||||
All contact events (`contact.created`, `contact.updated`, `contact.deleted`) include:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: string; // Contact ID
|
||||
email: string; // Contact email address
|
||||
contactBookId: string; // Contact book ID
|
||||
subscribed: boolean; // Subscription status
|
||||
properties: object; // Custom properties
|
||||
firstName?: string; // First name
|
||||
lastName?: string; // Last name
|
||||
createdAt: string; // ISO 8601 timestamp
|
||||
updatedAt: string; // ISO 8601 timestamp
|
||||
}
|
||||
```
|
||||
|
||||
### Domain events
|
||||
|
||||
All domain events (`domain.created`, `domain.verified`, `domain.updated`, `domain.deleted`) include:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: number; // Domain ID
|
||||
name: string; // Domain name (e.g., "example.com")
|
||||
status: string; // Domain status
|
||||
region: string; // AWS region
|
||||
createdAt: string; // ISO 8601 timestamp
|
||||
updatedAt: string; // ISO 8601 timestamp
|
||||
clickTracking: boolean; // Click tracking enabled
|
||||
openTracking: boolean; // Open tracking enabled
|
||||
subdomain?: string; // Subdomain for tracking
|
||||
dkimStatus?: string; // DKIM verification status
|
||||
spfDetails?: string; // SPF record details
|
||||
dmarcAdded?: boolean; // DMARC record added
|
||||
}
|
||||
```
|
||||
@@ -39,6 +39,7 @@
|
||||
"@trpc/react-query": "^11.1.1",
|
||||
"@trpc/server": "^11.1.1",
|
||||
"@usesend/email-editor": "workspace:*",
|
||||
"@usesend/lib": "workspace:*",
|
||||
"@usesend/ui": "workspace:*",
|
||||
"bullmq": "^5.51.1",
|
||||
"chrono-node": "^2.8.0",
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WebhookStatus" AS ENUM ('ACTIVE', 'PAUSED', 'AUTO_DISABLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WebhookCallStatus" AS ENUM ('PENDING', 'IN_PROGRESS', 'DELIVERED', 'FAILED', 'DISCARDED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Webhook" (
|
||||
"id" TEXT NOT NULL,
|
||||
"teamId" INTEGER NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"secret" TEXT NOT NULL,
|
||||
"status" "WebhookStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
"eventTypes" TEXT[],
|
||||
"apiVersion" TEXT,
|
||||
"consecutiveFailures" INTEGER NOT NULL DEFAULT 0,
|
||||
"lastFailureAt" TIMESTAMP(3),
|
||||
"lastSuccessAt" TIMESTAMP(3),
|
||||
"createdByUserId" INTEGER,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Webhook_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WebhookCall" (
|
||||
"id" TEXT NOT NULL,
|
||||
"webhookId" TEXT NOT NULL,
|
||||
"teamId" INTEGER NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"payload" TEXT NOT NULL,
|
||||
"status" "WebhookCallStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"attempt" INTEGER NOT NULL DEFAULT 0,
|
||||
"nextAttemptAt" TIMESTAMP(3),
|
||||
"lastError" TEXT,
|
||||
"responseStatus" INTEGER,
|
||||
"responseTimeMs" INTEGER,
|
||||
"responseText" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "WebhookCall_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Webhook_teamId_idx" ON "Webhook"("teamId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WebhookCall_teamId_webhookId_status_idx" ON "WebhookCall"("teamId", "webhookId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WebhookCall_createdAt_idx" ON "WebhookCall"("createdAt" DESC);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WebhookCall" ADD CONSTRAINT "WebhookCall_webhookId_fkey" FOREIGN KEY ("webhookId") REFERENCES "Webhook"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WebhookCall" ADD CONSTRAINT "WebhookCall_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -79,17 +79,18 @@ model VerificationToken {
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
name String?
|
||||
email String? @unique
|
||||
emailVerified DateTime?
|
||||
image String?
|
||||
isBetaUser Boolean @default(false)
|
||||
isWaitlisted Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
teamUsers TeamUser[]
|
||||
id Int @id @default(autoincrement())
|
||||
name String?
|
||||
email String? @unique
|
||||
emailVerified DateTime?
|
||||
image String?
|
||||
isBetaUser Boolean @default(false)
|
||||
isWaitlisted Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
teamUsers TeamUser[]
|
||||
webhookEndpoints Webhook[]
|
||||
}
|
||||
|
||||
enum Plan {
|
||||
@@ -122,6 +123,8 @@ model Team {
|
||||
subscription Subscription[]
|
||||
invites TeamInvite[]
|
||||
suppressionList SuppressionList[]
|
||||
webhookEndpoints Webhook[]
|
||||
webhookCalls WebhookCall[]
|
||||
}
|
||||
|
||||
model TeamInvite {
|
||||
@@ -443,3 +446,61 @@ model SuppressionList {
|
||||
|
||||
@@unique([teamId, email])
|
||||
}
|
||||
|
||||
enum WebhookStatus {
|
||||
ACTIVE
|
||||
PAUSED
|
||||
AUTO_DISABLED
|
||||
}
|
||||
|
||||
enum WebhookCallStatus {
|
||||
PENDING
|
||||
IN_PROGRESS
|
||||
DELIVERED
|
||||
FAILED
|
||||
DISCARDED
|
||||
}
|
||||
|
||||
model Webhook {
|
||||
id String @id @default(cuid())
|
||||
teamId Int
|
||||
url String
|
||||
description String?
|
||||
secret String
|
||||
status WebhookStatus @default(ACTIVE)
|
||||
eventTypes String[]
|
||||
apiVersion String?
|
||||
consecutiveFailures Int @default(0)
|
||||
lastFailureAt DateTime?
|
||||
lastSuccessAt DateTime?
|
||||
createdByUserId Int?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
calls WebhookCall[]
|
||||
createdBy User? @relation(fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([teamId])
|
||||
}
|
||||
|
||||
model WebhookCall {
|
||||
id String @id @default(cuid())
|
||||
webhookId String
|
||||
teamId Int
|
||||
type String
|
||||
payload String
|
||||
status WebhookCallStatus @default(PENDING)
|
||||
attempt Int @default(0)
|
||||
nextAttemptAt DateTime?
|
||||
lastError String?
|
||||
responseStatus Int?
|
||||
responseTimeMs Int?
|
||||
responseText String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade)
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([teamId, webhookId, status])
|
||||
@@index([createdAt(sort: Desc)])
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { H1 } from "@usesend/ui";
|
||||
import { SettingsNavButton } from "./settings-nav-button";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
@@ -11,7 +12,7 @@ export default function ApiKeysPage({
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="font-bold text-lg">Developer settings</h1>
|
||||
<H1>Developer Settings</H1>
|
||||
<div className="flex gap-4 mt-4">
|
||||
<SettingsNavButton href="/dev-settings">API Keys</SettingsNavButton>
|
||||
<SettingsNavButton href="/dev-settings/smtp">SMTP</SettingsNavButton>
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
BOUNCE_ERROR_MESSAGES,
|
||||
COMPLAINT_ERROR_MESSAGES,
|
||||
DELIVERY_DELAY_ERRORS,
|
||||
} from "~/lib/constants/ses-errors";
|
||||
} from "@usesend/lib/src/constants/ses-errors";
|
||||
import CancelEmail from "./cancel-email";
|
||||
import { useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
@@ -75,7 +75,7 @@ export default function EmailDetails({ emailId }: { emailId: string }) {
|
||||
<span className="text-sm">
|
||||
{formatDate(
|
||||
emailQuery.data?.scheduledAt,
|
||||
"MMM dd'th', hh:mm a"
|
||||
"MMM dd'th', hh:mm a",
|
||||
)}
|
||||
</span>
|
||||
<div className="ml-4">
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
"use client";
|
||||
|
||||
import { use, useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { api } from "~/trpc/react";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@usesend/ui/src/breadcrumb";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import {
|
||||
Edit3,
|
||||
Key,
|
||||
MoreVertical,
|
||||
Pause,
|
||||
Play,
|
||||
TestTube,
|
||||
CircleEllipsis,
|
||||
} from "lucide-react";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { WebhookInfo } from "./webhook-info";
|
||||
import { WebhookCallsTable } from "./webhook-calls-table";
|
||||
import { WebhookCallDetails } from "./webhook-call-details";
|
||||
import { DeleteWebhook } from "../delete-webhook";
|
||||
import { EditWebhookDialog } from "../webhook-update-dialog";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@usesend/ui/src/popover";
|
||||
import { type Webhook } from "@prisma/client";
|
||||
|
||||
function WebhookDetailActions({
|
||||
webhook,
|
||||
onTest,
|
||||
onEdit,
|
||||
onToggleStatus,
|
||||
onRotateSecret,
|
||||
isTestPending,
|
||||
isToggling,
|
||||
isRotating,
|
||||
}: {
|
||||
webhook: Webhook;
|
||||
onTest: () => void;
|
||||
onEdit: () => void;
|
||||
onToggleStatus: () => void;
|
||||
onRotateSecret: () => void;
|
||||
isTestPending: boolean;
|
||||
isToggling: boolean;
|
||||
isRotating: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const isPaused = webhook.status === "PAUSED";
|
||||
const isAutoDisabled = webhook.status === "AUTO_DISABLED";
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="default" className="gap-1">
|
||||
<MoreVertical className="h-4 -ml-2" />
|
||||
Actions
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 rounded-xl p-1" align="end">
|
||||
<div className="flex flex-col">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="justify-start rounded-lg hover:bg-accent"
|
||||
onClick={() => {
|
||||
onTest();
|
||||
setOpen(false);
|
||||
}}
|
||||
disabled={isTestPending}
|
||||
>
|
||||
<TestTube className="mr-2 h-4 w-4" />
|
||||
Test webhook
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="justify-start rounded-lg hover:bg-accent"
|
||||
onClick={() => {
|
||||
onEdit();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Edit3 className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="justify-start rounded-lg hover:bg-accent"
|
||||
onClick={() => {
|
||||
onToggleStatus();
|
||||
setOpen(false);
|
||||
}}
|
||||
disabled={isToggling || isAutoDisabled}
|
||||
>
|
||||
{isPaused ? (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Resume
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pause className="mr-2 h-4 w-4" />
|
||||
Pause
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="justify-start rounded-lg hover:bg-accent"
|
||||
onClick={() => {
|
||||
onRotateSecret();
|
||||
setOpen(false);
|
||||
}}
|
||||
disabled={isRotating}
|
||||
>
|
||||
<Key className="mr-2 h-4 w-4" />
|
||||
Rotate secret
|
||||
</Button>
|
||||
<DeleteWebhook webhook={webhook} />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WebhookDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ webhookId: string }>;
|
||||
}) {
|
||||
const { webhookId } = use(params);
|
||||
const [selectedCallId, setSelectedCallId] = useState<string | null>(null);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
|
||||
const webhookQuery = api.webhook.getById.useQuery({ id: webhookId });
|
||||
const testWebhook = api.webhook.test.useMutation();
|
||||
const setStatusMutation = api.webhook.setStatus.useMutation();
|
||||
const updateWebhook = api.webhook.update.useMutation();
|
||||
const callsQuery = api.webhook.listCalls.useQuery({
|
||||
webhookId,
|
||||
limit: 50,
|
||||
});
|
||||
const utils = api.useUtils();
|
||||
|
||||
const webhook = webhookQuery.data;
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCallId && callsQuery.data?.items.length) {
|
||||
setSelectedCallId(callsQuery.data.items[0]!.id);
|
||||
}
|
||||
}, [callsQuery.data, selectedCallId]);
|
||||
|
||||
const handleTest = () => {
|
||||
testWebhook.mutate(
|
||||
{ id: webhookId },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await utils.webhook.listCalls.invalidate();
|
||||
toast.success("Test webhook enqueued");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleToggleStatus = (currentStatus: string) => {
|
||||
const newStatus = currentStatus === "ACTIVE" ? "PAUSED" : "ACTIVE";
|
||||
setStatusMutation.mutate(
|
||||
{ id: webhookId, status: newStatus },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await utils.webhook.getById.invalidate();
|
||||
toast.success(
|
||||
`Webhook ${newStatus === "ACTIVE" ? "resumed" : "paused"}`,
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleRotateSecret = () => {
|
||||
updateWebhook.mutate(
|
||||
{ id: webhookId, rotateSecret: true },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await utils.webhook.getById.invalidate();
|
||||
toast.success("Secret rotated successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
if (webhookQuery.isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<p className="text-muted-foreground">Loading webhook...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!webhook) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<p className="text-muted-foreground">Webhook not found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/webhooks" className="text-lg">
|
||||
Webhooks
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="text-lg" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="text-lg max-w-[500px] truncate">
|
||||
{webhook.url}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
<WebhookDetailActions
|
||||
webhook={webhook}
|
||||
onTest={handleTest}
|
||||
onEdit={() => setIsEditDialogOpen(true)}
|
||||
onToggleStatus={() => handleToggleStatus(webhook.status)}
|
||||
onRotateSecret={handleRotateSecret}
|
||||
isTestPending={testWebhook.isPending}
|
||||
isToggling={setStatusMutation.isPending}
|
||||
isRotating={updateWebhook.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<WebhookInfo webhook={webhook} />
|
||||
|
||||
<div className="h-[calc(100vh-280px)] min-h-[600px] flex gap-6">
|
||||
<div className="w-1/2 flex flex-col">
|
||||
<WebhookCallsTable
|
||||
webhookId={webhookId}
|
||||
selectedCallId={selectedCallId}
|
||||
onSelectCall={setSelectedCallId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-1/2 overflow-auto">
|
||||
{selectedCallId ? (
|
||||
<WebhookCallDetails callId={selectedCallId} />
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center border rounded-xl bg-muted/10 border-dashed text-muted-foreground">
|
||||
Select a webhook call to view details
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEditDialogOpen && (
|
||||
<EditWebhookDialog
|
||||
webhook={webhook}
|
||||
open={isEditDialogOpen}
|
||||
onOpenChange={setIsEditDialogOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { formatDate } from "date-fns";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { WebhookCallStatusBadge } from "../webhook-call-status-badge";
|
||||
import { WEBHOOK_EVENT_VERSION } from "@usesend/lib/src/webhook/webhook-events";
|
||||
|
||||
import { CodeDisplay } from "~/components/code-display";
|
||||
|
||||
export function WebhookCallDetails({ callId }: { callId: string }) {
|
||||
const callQuery = api.webhook.getCall.useQuery({ id: callId });
|
||||
const retryMutation = api.webhook.retryCall.useMutation();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const call = callQuery.data;
|
||||
|
||||
if (!call) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex flex-row items-center justify-between mb-4">
|
||||
<h2 className="text-base font-medium">Call Details</h2>
|
||||
</div>
|
||||
<div className="flex-1 rounded-xl border shadow p-6 flex items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Loading call details...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleRetry = () => {
|
||||
retryMutation.mutate(
|
||||
{ id: call.id },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await utils.webhook.listCalls.invalidate();
|
||||
await utils.webhook.getCall.invalidate();
|
||||
toast.success("Webhook call queued for retry");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Reconstruct the full payload that was actually sent to the webhook endpoint
|
||||
const buildFullPayload = () => {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(call.payload);
|
||||
} catch {
|
||||
data = call.payload;
|
||||
}
|
||||
|
||||
return {
|
||||
id: call.id,
|
||||
type: call.type,
|
||||
version: call.webhook?.apiVersion ?? WEBHOOK_EVENT_VERSION,
|
||||
createdAt: new Date(call.createdAt).toISOString(),
|
||||
teamId: call.teamId,
|
||||
data,
|
||||
attempt: call.attempt,
|
||||
};
|
||||
};
|
||||
|
||||
const fullPayload = buildFullPayload();
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<div className="flex flex-row items-center justify-between mb-4">
|
||||
<h2 className="text-base font-medium">Call Details</h2>
|
||||
{call.status === "FAILED" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRetry}
|
||||
disabled={retryMutation.isPending}
|
||||
className="h-8"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto rounded-xl border shadow p-6 space-y-8 no-scrollbar">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
|
||||
Status
|
||||
</span>
|
||||
<div>
|
||||
<WebhookCallStatusBadge status={call.status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
|
||||
Event Type
|
||||
</span>
|
||||
<span className="text-sm font-mono">{call.type}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
|
||||
Timestamp
|
||||
</span>
|
||||
<span className="text-sm font-mono">
|
||||
{formatDate(call.createdAt, "MMM dd, yyyy HH:mm:ss")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
|
||||
Attempt
|
||||
</span>
|
||||
<span className="text-sm font-mono">{call.attempt}</span>
|
||||
</div>
|
||||
|
||||
{call.responseStatus && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
|
||||
Response Status
|
||||
</span>
|
||||
<span className="text-sm font-mono">{call.responseStatus}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{call.responseTimeMs != null && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
|
||||
Duration
|
||||
</span>
|
||||
<span className="text-sm font-mono">{call.responseTimeMs}ms</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{call.lastError && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider text-red-500">
|
||||
Error
|
||||
</span>
|
||||
<div className="text-xs bg-red-500/10 border border-red-500/20 rounded-md p-3 font-mono text-red-600 dark:text-red-400">
|
||||
{call.lastError}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h4 className="font-medium text-sm">Request Payload</h4>
|
||||
<CodeDisplay
|
||||
code={JSON.stringify(fullPayload, null, 2)}
|
||||
language="json"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{call.responseText && (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<h4 className="font-medium text-sm">Response Body</h4>
|
||||
<CodeDisplay code={call.responseText} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { WebhookCallStatus } from "@prisma/client";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@usesend/ui/src/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@usesend/ui/src/select";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import Spinner from "@usesend/ui/src/spinner";
|
||||
import { api } from "~/trpc/react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { WebhookCallStatusBadge } from "../webhook-call-status-badge";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export function WebhookCallsTable({
|
||||
webhookId,
|
||||
selectedCallId,
|
||||
onSelectCall,
|
||||
}: {
|
||||
webhookId: string;
|
||||
selectedCallId: string | null;
|
||||
onSelectCall: (callId: string) => void;
|
||||
}) {
|
||||
const [statusFilter, setStatusFilter] = useState<WebhookCallStatus | "ALL">(
|
||||
"ALL",
|
||||
);
|
||||
const [cursors, setCursors] = useState<string[]>([]);
|
||||
|
||||
const currentCursor = cursors[cursors.length - 1];
|
||||
|
||||
const callsQuery = api.webhook.listCalls.useQuery({
|
||||
webhookId,
|
||||
status: statusFilter === "ALL" ? undefined : statusFilter,
|
||||
limit: PAGE_SIZE,
|
||||
cursor: currentCursor,
|
||||
});
|
||||
|
||||
const calls = callsQuery.data?.items ?? [];
|
||||
const nextCursor = callsQuery.data?.nextCursor;
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (nextCursor) {
|
||||
setCursors([...cursors, nextCursor]);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevPage = () => {
|
||||
setCursors(cursors.slice(0, -1));
|
||||
};
|
||||
|
||||
const handleFilterChange = (value: WebhookCallStatus | "ALL") => {
|
||||
setStatusFilter(value);
|
||||
setCursors([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex flex-row items-center justify-between mb-4">
|
||||
<h2 className="text-base font-medium">Delivery Logs</h2>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange(value as WebhookCallStatus | "ALL")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[150px] h-8 text-xs">
|
||||
<SelectValue placeholder="Filter by status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">All</SelectItem>
|
||||
<SelectItem value={WebhookCallStatus.DELIVERED}>
|
||||
Delivered
|
||||
</SelectItem>
|
||||
<SelectItem value={WebhookCallStatus.FAILED}>Failed</SelectItem>
|
||||
<SelectItem value={WebhookCallStatus.PENDING}>Pending</SelectItem>
|
||||
<SelectItem value={WebhookCallStatus.IN_PROGRESS}>
|
||||
In Progress
|
||||
</SelectItem>
|
||||
<SelectItem value={WebhookCallStatus.DISCARDED}>
|
||||
Discarded
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden rounded-xl border shadow flex flex-col">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted dark:bg-muted/70">
|
||||
<TableHead className="h-9 rounded-tl-xl">Status</TableHead>
|
||||
<TableHead className="h-9">Event Type</TableHead>
|
||||
<TableHead className="h-9 rounded-tr-xl">Time</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</Table>
|
||||
<div className="flex-1 overflow-auto no-scrollbar">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{callsQuery.isLoading ? (
|
||||
<TableRow className="h-32 hover:bg-transparent">
|
||||
<TableCell colSpan={3} className="py-4 text-center">
|
||||
<Spinner
|
||||
className="mx-auto h-6 w-6"
|
||||
innerSvgClass="stroke-primary"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : calls.length === 0 ? (
|
||||
<TableRow className="h-32 hover:bg-transparent">
|
||||
<TableCell colSpan={3} className="py-4 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No webhook calls yet
|
||||
</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
calls.map((call) => (
|
||||
<TableRow
|
||||
key={call.id}
|
||||
className={`cursor-pointer transition-colors ${
|
||||
selectedCallId === call.id
|
||||
? "bg-accent/50 text-accent-foreground"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => onSelectCall(call.id)}
|
||||
>
|
||||
<TableCell className="py-2">
|
||||
<div className="scale-90 origin-left">
|
||||
<WebhookCallStatusBadge status={call.status} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-2 font-mono text-xs">
|
||||
{call.type}
|
||||
</TableCell>
|
||||
<TableCell className="py-2 text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(call.createdAt, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 justify-end mt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handlePrevPage}
|
||||
disabled={cursors.length === 0}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleNextPage} disabled={!nextCursor}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { Webhook, WebhookCallStatus } from "@prisma/client";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Copy, Eye, EyeOff } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Badge } from "@usesend/ui/src/badge";
|
||||
import { WebhookStatusBadge } from "../webhook-status-badge";
|
||||
|
||||
export function WebhookInfo({ webhook }: { webhook: Webhook }) {
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
|
||||
const sevenDaysAgo = new Date();
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
|
||||
const callsQuery = api.webhook.listCalls.useQuery({
|
||||
webhookId: webhook.id,
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
const calls = callsQuery.data?.items ?? [];
|
||||
const last7DaysCalls = calls.filter(
|
||||
(call) => new Date(call.createdAt) >= sevenDaysAgo,
|
||||
);
|
||||
|
||||
const deliveredCount = last7DaysCalls.filter(
|
||||
(c) => c.status === WebhookCallStatus.DELIVERED,
|
||||
).length;
|
||||
const failedCount = last7DaysCalls.filter(
|
||||
(c) => c.status === WebhookCallStatus.FAILED,
|
||||
).length;
|
||||
const pendingCount = last7DaysCalls.filter(
|
||||
(c) =>
|
||||
c.status === WebhookCallStatus.PENDING ||
|
||||
c.status === WebhookCallStatus.IN_PROGRESS,
|
||||
).length;
|
||||
|
||||
const handleCopySecret = () => {
|
||||
navigator.clipboard.writeText(webhook.secret);
|
||||
toast.success("Secret copied to clipboard");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-6 justify-between mt-5 mb-10">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm text-muted-foreground">Events</span>
|
||||
<div className="flex items-center gap-1 flex-wrap font-mono text-sm">
|
||||
{webhook.eventTypes.length === 0 ? (
|
||||
<span className="text-sm">All events</span>
|
||||
) : (
|
||||
<>
|
||||
{webhook.eventTypes.slice(0, 2).map((event) => (
|
||||
<Badge key={event} variant="outline">
|
||||
{event}
|
||||
</Badge>
|
||||
))}
|
||||
{webhook.eventTypes.length > 2 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
+{webhook.eventTypes.length - 2} more
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm text-muted-foreground">Status</span>
|
||||
<div className="flex items-center">
|
||||
<WebhookStatusBadge status={webhook.status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm text-muted-foreground">Created</span>
|
||||
<span className="text-sm">
|
||||
{formatDistanceToNow(webhook.createdAt, { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Signing Secret</span>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowSecret(!showSecret)}
|
||||
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showSecret ? (
|
||||
<EyeOff className="h-3 w-3" />
|
||||
) : (
|
||||
<Eye className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCopySecret}
|
||||
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded font-mono w-[240px] inline-block truncate">
|
||||
{showSecret ? webhook.secret : "whsec_••••••••••••••••••••••••"}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { CodeBlock } from "@usesend/ui/src/code-block";
|
||||
|
||||
interface WebhookPayloadDisplayProps {
|
||||
payload: string;
|
||||
title: string;
|
||||
lang?: "json" | "text";
|
||||
}
|
||||
|
||||
export async function WebhookPayloadDisplay({
|
||||
payload,
|
||||
title,
|
||||
lang = "json",
|
||||
}: WebhookPayloadDisplayProps) {
|
||||
let displayContent = payload;
|
||||
|
||||
// For JSON, try to pretty-print it
|
||||
if (lang === "json") {
|
||||
try {
|
||||
const parsed = JSON.parse(payload);
|
||||
displayContent = JSON.stringify(parsed, null, 2);
|
||||
} catch {
|
||||
// If parsing fails, use as-is
|
||||
displayContent = payload;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h4 className="font-medium text-sm">{title}</h4>
|
||||
<div className="rounded-lg overflow-hidden border">
|
||||
<CodeBlock lang={lang} className="text-xs max-h-[300px] overflow-auto">
|
||||
{displayContent}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@usesend/ui/src/form";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { ChevronDown, Plus } from "lucide-react";
|
||||
import {
|
||||
ContactEvents,
|
||||
DomainEvents,
|
||||
EmailEvents,
|
||||
WebhookEvents,
|
||||
type WebhookEventType,
|
||||
} from "@usesend/lib/src/webhook/webhook-events";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@usesend/ui/src/dropdown-menu";
|
||||
import { LimitReason } from "~/lib/constants/plans";
|
||||
import { useUpgradeModalStore } from "~/store/upgradeModalStore";
|
||||
|
||||
const EVENT_TYPES_ENUM = z.enum(WebhookEvents);
|
||||
|
||||
const webhookSchema = z.object({
|
||||
url: z
|
||||
.string({ required_error: "URL is required" })
|
||||
.url("Please enter a valid URL"),
|
||||
eventTypes: z.array(EVENT_TYPES_ENUM, {
|
||||
required_error: "Select at least one event",
|
||||
}),
|
||||
});
|
||||
|
||||
type WebhookFormValues = z.infer<typeof webhookSchema>;
|
||||
|
||||
const eventGroups: {
|
||||
label: string;
|
||||
events: readonly WebhookEventType[];
|
||||
}[] = [
|
||||
{ label: "Contact events", events: ContactEvents },
|
||||
{ label: "Domain events", events: DomainEvents },
|
||||
{ label: "Email events", events: EmailEvents },
|
||||
];
|
||||
|
||||
export function AddWebhook() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [allEventsSelected, setAllEventsSelected] = useState(false);
|
||||
const createWebhookMutation = api.webhook.create.useMutation();
|
||||
const limitsQuery = api.limits.get.useQuery({ type: LimitReason.WEBHOOK });
|
||||
const { openModal } = useUpgradeModalStore((s) => s.action);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const form = useForm<WebhookFormValues>({
|
||||
resolver: zodResolver(webhookSchema),
|
||||
defaultValues: {
|
||||
url: "",
|
||||
eventTypes: [],
|
||||
},
|
||||
});
|
||||
|
||||
function onOpenChange(nextOpen: boolean) {
|
||||
if (nextOpen && limitsQuery.data?.isLimitReached) {
|
||||
openModal(limitsQuery.data.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(nextOpen);
|
||||
}
|
||||
|
||||
function handleSubmit(values: WebhookFormValues) {
|
||||
if (limitsQuery.data?.isLimitReached) {
|
||||
openModal(limitsQuery.data.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedEvents = values.eventTypes ?? [];
|
||||
|
||||
if (!allEventsSelected && selectedEvents.length === 0) {
|
||||
toast.error("Select at least one event or all events");
|
||||
return;
|
||||
}
|
||||
|
||||
createWebhookMutation.mutate(
|
||||
{
|
||||
url: values.url,
|
||||
eventTypes: allEventsSelected ? [] : selectedEvents,
|
||||
},
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await utils.webhook.list.invalidate();
|
||||
form.reset({
|
||||
url: "",
|
||||
eventTypes: [],
|
||||
});
|
||||
setAllEventsSelected(false);
|
||||
setOpen(false);
|
||||
toast.success("Webhook created successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) =>
|
||||
nextOpen !== open ? onOpenChange(nextOpen) : null
|
||||
}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Add webhook
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a new webhook</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field, formState }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Endpoint URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/webhooks/usesend"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="eventTypes"
|
||||
render={({ field, formState }) => {
|
||||
const selectedEvents = field.value ?? [];
|
||||
const totalEvents = WebhookEvents;
|
||||
|
||||
const selectedCount = allEventsSelected
|
||||
? totalEvents.length
|
||||
: selectedEvents.length;
|
||||
|
||||
const allSelectedLabel =
|
||||
selectedCount === 0
|
||||
? "Select events"
|
||||
: allEventsSelected
|
||||
? "All events"
|
||||
: selectedCount === 1
|
||||
? selectedEvents[0]
|
||||
: `${selectedCount} events selected`;
|
||||
|
||||
const isGroupFullySelected = (
|
||||
groupEvents: readonly WebhookEventType[],
|
||||
) => {
|
||||
if (allEventsSelected) return true;
|
||||
if (selectedEvents.length === 0) return false;
|
||||
return groupEvents.every((event) =>
|
||||
selectedEvents.includes(event),
|
||||
);
|
||||
};
|
||||
|
||||
const handleToggleAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setAllEventsSelected(true);
|
||||
field.onChange([]);
|
||||
} else {
|
||||
setAllEventsSelected(false);
|
||||
field.onChange([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleGroup = (
|
||||
groupEvents: readonly WebhookEventType[],
|
||||
) => {
|
||||
if (allEventsSelected) {
|
||||
const next = totalEvents.filter(
|
||||
(event) => !groupEvents.includes(event),
|
||||
);
|
||||
setAllEventsSelected(false);
|
||||
field.onChange(next);
|
||||
return;
|
||||
}
|
||||
|
||||
const current = new Set(selectedEvents);
|
||||
const fullySelected = groupEvents.every((event) =>
|
||||
current.has(event),
|
||||
);
|
||||
|
||||
if (fullySelected) {
|
||||
groupEvents.forEach((event) => current.delete(event));
|
||||
} else {
|
||||
groupEvents.forEach((event) => current.add(event));
|
||||
}
|
||||
|
||||
field.onChange(Array.from(current));
|
||||
};
|
||||
|
||||
const handleToggleEvent = (event: WebhookEventType) => {
|
||||
if (allEventsSelected) {
|
||||
const next = WebhookEvents.filter((e) => e !== event);
|
||||
setAllEventsSelected(false);
|
||||
field.onChange(next);
|
||||
return;
|
||||
}
|
||||
|
||||
const exists = selectedEvents.includes(event);
|
||||
const next = exists
|
||||
? selectedEvents.filter((e) => e !== event)
|
||||
: [...selectedEvents, event];
|
||||
field.onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Events</FormLabel>
|
||||
<FormControl>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-3 inline-flex w-full items-center justify-between"
|
||||
>
|
||||
<span className="truncate text-left text-sm">
|
||||
{allSelectedLabel}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-[--radix-dropdown-menu-trigger-width] h-[30vh] ">
|
||||
<div className="space-y-3">
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={allEventsSelected}
|
||||
onCheckedChange={(checked) =>
|
||||
handleToggleAll(Boolean(checked))
|
||||
}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
className="font-medium mb-2 px-2"
|
||||
>
|
||||
All events
|
||||
</DropdownMenuCheckboxItem>
|
||||
{eventGroups.map((group) => (
|
||||
<div key={group.label} className="">
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={isGroupFullySelected(group.events)}
|
||||
onCheckedChange={() =>
|
||||
handleToggleGroup(group.events)
|
||||
}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
className="px-2 text-xs font-semibold text-muted-foreground"
|
||||
>
|
||||
{group.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
{group.events.map((event) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={event}
|
||||
checked={
|
||||
allEventsSelected ||
|
||||
selectedEvents.includes(event)
|
||||
}
|
||||
onCheckedChange={() =>
|
||||
handleToggleEvent(event)
|
||||
}
|
||||
onSelect={(event) =>
|
||||
event.preventDefault()
|
||||
}
|
||||
className="pl-3 pr-2 font-mono"
|
||||
>
|
||||
{event}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</FormControl>
|
||||
{formState.errors.eventTypes ? <FormMessage /> : null}
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className="w-[120px]"
|
||||
type="submit"
|
||||
disabled={createWebhookMutation.isPending}
|
||||
>
|
||||
{createWebhookMutation.isPending ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { DeleteResource } from "~/components/DeleteResource";
|
||||
import { api } from "~/trpc/react";
|
||||
import { type Webhook } from "@prisma/client";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { z } from "zod";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
export const DeleteWebhook: React.FC<{
|
||||
webhook: Webhook;
|
||||
}> = ({ webhook }) => {
|
||||
const deleteWebhookMutation = api.webhook.delete.useMutation();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
confirmation: z.string().min(1, "Please type the webhook URL to confirm"),
|
||||
})
|
||||
.refine((data) => data.confirmation === webhook.url, {
|
||||
message: "Webhook URL does not match",
|
||||
path: ["confirmation"],
|
||||
});
|
||||
|
||||
async function onConfirm(values: z.infer<typeof schema>) {
|
||||
deleteWebhookMutation.mutate(
|
||||
{ id: webhook.id },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await utils.webhook.list.invalidate();
|
||||
toast.success("Webhook deleted");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DeleteResource
|
||||
title="Delete webhook"
|
||||
resourceName={webhook.url}
|
||||
schema={schema}
|
||||
isLoading={deleteWebhookMutation.isPending}
|
||||
onConfirm={onConfirm}
|
||||
confirmLabel="Delete webhook"
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start rounded-lg text-red/80 hover:bg-accent hover:text-red"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { H1 } from "@usesend/ui";
|
||||
import { AddWebhook } from "./add-webhook";
|
||||
import { WebhookList } from "./webhook-list";
|
||||
|
||||
export default function WebhooksPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<H1>Webhooks</H1>
|
||||
<AddWebhook />
|
||||
</div>
|
||||
<WebhookList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { WebhookCallStatus } from "@prisma/client";
|
||||
|
||||
export function WebhookCallStatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: WebhookCallStatus;
|
||||
}) {
|
||||
let badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10";
|
||||
let label: string = status;
|
||||
|
||||
switch (status) {
|
||||
case WebhookCallStatus.DELIVERED:
|
||||
badgeColor = "bg-green/15 text-green border border-green/20";
|
||||
label = "Delivered";
|
||||
break;
|
||||
case WebhookCallStatus.FAILED:
|
||||
badgeColor = "bg-red/15 text-red border border-red/20";
|
||||
label = "Failed";
|
||||
break;
|
||||
case WebhookCallStatus.PENDING:
|
||||
badgeColor = "bg-yellow/20 text-yellow border border-yellow/10";
|
||||
label = "Pending";
|
||||
break;
|
||||
case WebhookCallStatus.IN_PROGRESS:
|
||||
badgeColor = "bg-blue/15 text-blue border border-blue/20";
|
||||
label = "In Progress";
|
||||
break;
|
||||
case WebhookCallStatus.DISCARDED:
|
||||
badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10";
|
||||
label = "Discarded";
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`text-center w-[110px] rounded capitalize py-1 text-xs ${badgeColor}`}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@usesend/ui/src/table";
|
||||
import Spinner from "@usesend/ui/src/spinner";
|
||||
import { api } from "~/trpc/react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Edit3, MoreVertical, Pause, Play } from "lucide-react";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import { DeleteWebhook } from "./delete-webhook";
|
||||
import { useState } from "react";
|
||||
import { EditWebhookDialog } from "./webhook-update-dialog";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@usesend/ui/src/popover";
|
||||
import { type Webhook } from "@prisma/client";
|
||||
import { WebhookStatusBadge } from "./webhook-status-badge";
|
||||
|
||||
export function WebhookList() {
|
||||
const webhooksQuery = api.webhook.list.useQuery();
|
||||
const testWebhook = api.webhook.test.useMutation();
|
||||
const setStatusMutation = api.webhook.setStatus.useMutation();
|
||||
const utils = api.useUtils();
|
||||
const router = useRouter();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
const webhooks = webhooksQuery.data ?? [];
|
||||
|
||||
async function handleToggleStatus(webhookId: string, currentStatus: string) {
|
||||
const newStatus = currentStatus === "ACTIVE" ? "PAUSED" : "ACTIVE";
|
||||
setStatusMutation.mutate(
|
||||
{ id: webhookId, status: newStatus },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await utils.webhook.list.invalidate();
|
||||
toast.success(
|
||||
`Webhook ${newStatus === "ACTIVE" ? "resumed" : "paused"}`,
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-10">
|
||||
<div className="rounded-xl border shadow">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/30">
|
||||
<TableHead className="rounded-tl-xl">URL</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Last success</TableHead>
|
||||
<TableHead>Last failure</TableHead>
|
||||
<TableHead className="rounded-tr-xl text-right">
|
||||
Actions
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{webhooksQuery.isLoading ? (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={5} className="py-4 text-center">
|
||||
<Spinner
|
||||
className="mx-auto h-6 w-6"
|
||||
innerSvgClass="stroke-primary"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : webhooks.length === 0 ? (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={5} className="py-4 text-center">
|
||||
<p>No webhooks configured</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
webhooks.map((webhook) => (
|
||||
<TableRow
|
||||
key={webhook.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => router.push(`/webhooks/${webhook.id}`)}
|
||||
>
|
||||
<TableCell className="max-w-xs truncate ">
|
||||
{webhook.url}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<WebhookStatusBadge status={webhook.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{webhook.lastSuccessAt
|
||||
? formatDistanceToNow(webhook.lastSuccessAt, {
|
||||
addSuffix: true,
|
||||
})
|
||||
: "Never"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{webhook.lastFailureAt
|
||||
? formatDistanceToNow(webhook.lastFailureAt, {
|
||||
addSuffix: true,
|
||||
})
|
||||
: "Never"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div
|
||||
className="flex items-center justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<WebhookActions
|
||||
webhook={webhook}
|
||||
onEdit={() => setEditingId(webhook.id)}
|
||||
onToggleStatus={() =>
|
||||
handleToggleStatus(webhook.id, webhook.status)
|
||||
}
|
||||
isToggling={setStatusMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
{editingId === webhook.id ? (
|
||||
<EditWebhookDialog
|
||||
webhook={webhook}
|
||||
open={editingId === webhook.id}
|
||||
onOpenChange={(open) =>
|
||||
setEditingId(open ? webhook.id : null)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WebhookActions({
|
||||
webhook,
|
||||
onEdit,
|
||||
onToggleStatus,
|
||||
isToggling,
|
||||
}: {
|
||||
webhook: Webhook;
|
||||
onEdit: () => void;
|
||||
onToggleStatus: () => void;
|
||||
isToggling: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const isPaused = webhook.status === "PAUSED";
|
||||
const isAutoDisabled = webhook.status === "AUTO_DISABLED";
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48 rounded-xl p-1" align="end">
|
||||
<div className="flex flex-col">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="justify-start rounded-lg hover:bg-accent"
|
||||
onClick={() => {
|
||||
onEdit();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Edit3 className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="justify-start rounded-lg hover:bg-accent"
|
||||
onClick={() => {
|
||||
onToggleStatus();
|
||||
setOpen(false);
|
||||
}}
|
||||
disabled={isToggling || isAutoDisabled}
|
||||
>
|
||||
{isPaused ? (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Resume
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pause className="mr-2 h-4 w-4" />
|
||||
Pause
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<DeleteWebhook webhook={webhook} />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { WebhookStatus } from "@prisma/client";
|
||||
|
||||
export function WebhookStatusBadge({ status }: { status: WebhookStatus }) {
|
||||
let badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10";
|
||||
let label: string = status;
|
||||
|
||||
if (status === WebhookStatus.ACTIVE) {
|
||||
badgeColor = "bg-green/15 text-green border border-green/20";
|
||||
label = "Active";
|
||||
} else if (status === WebhookStatus.PAUSED) {
|
||||
badgeColor = "bg-yellow/15 text-yellow border border-yellow/20";
|
||||
label = "Paused";
|
||||
} else if (status === WebhookStatus.AUTO_DISABLED) {
|
||||
badgeColor = "bg-red/15 text-red border border-red/20";
|
||||
label = "Auto disabled";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`text-center w-[130px] rounded capitalize py-1 text-xs ${badgeColor}`}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@usesend/ui/src/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@usesend/ui/src/form";
|
||||
import { Input } from "@usesend/ui/src/input";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { api } from "~/trpc/react";
|
||||
import {
|
||||
ContactEvents,
|
||||
DomainEvents,
|
||||
EmailEvents,
|
||||
WebhookEvents,
|
||||
type WebhookEventType,
|
||||
} from "@usesend/lib/src/webhook/webhook-events";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@usesend/ui/src/dropdown-menu";
|
||||
import { toast } from "@usesend/ui/src/toaster";
|
||||
import type { Webhook } from "@prisma/client";
|
||||
|
||||
const EVENT_TYPES_ENUM = z.enum(WebhookEvents);
|
||||
|
||||
const editWebhookSchema = z.object({
|
||||
url: z
|
||||
.string({ required_error: "URL is required" })
|
||||
.url("Please enter a valid URL"),
|
||||
eventTypes: z.array(EVENT_TYPES_ENUM, {
|
||||
required_error: "Select at least one event",
|
||||
}),
|
||||
});
|
||||
|
||||
type EditWebhookFormValues = z.infer<typeof editWebhookSchema>;
|
||||
|
||||
const eventGroups: {
|
||||
label: string;
|
||||
events: readonly WebhookEventType[];
|
||||
}[] = [
|
||||
{ label: "Contact events", events: ContactEvents },
|
||||
{ label: "Domain events", events: DomainEvents },
|
||||
{ label: "Email events", events: EmailEvents },
|
||||
];
|
||||
|
||||
export function EditWebhookDialog({
|
||||
webhook,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
webhook: Webhook;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const updateWebhook = api.webhook.update.useMutation();
|
||||
const utils = api.useUtils();
|
||||
const initialHasAllEvents =
|
||||
(webhook.eventTypes as WebhookEventType[]).length === 0;
|
||||
const [allEventsSelected, setAllEventsSelected] =
|
||||
useState(initialHasAllEvents);
|
||||
|
||||
const form = useForm<EditWebhookFormValues>({
|
||||
resolver: zodResolver(editWebhookSchema),
|
||||
defaultValues: {
|
||||
url: webhook.url,
|
||||
eventTypes: initialHasAllEvents
|
||||
? []
|
||||
: (webhook.eventTypes as WebhookEventType[]),
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const hasAllEvents =
|
||||
(webhook.eventTypes as WebhookEventType[]).length === 0;
|
||||
form.reset({
|
||||
url: webhook.url,
|
||||
eventTypes: hasAllEvents
|
||||
? []
|
||||
: (webhook.eventTypes as WebhookEventType[]),
|
||||
});
|
||||
setAllEventsSelected(hasAllEvents);
|
||||
}
|
||||
}, [open, webhook, form]);
|
||||
|
||||
function handleSubmit(values: EditWebhookFormValues) {
|
||||
const selectedEvents = values.eventTypes ?? [];
|
||||
|
||||
if (!allEventsSelected && selectedEvents.length === 0) {
|
||||
toast.error("Select at least one event or all events");
|
||||
return;
|
||||
}
|
||||
|
||||
updateWebhook.mutate(
|
||||
{
|
||||
id: webhook.id,
|
||||
url: values.url,
|
||||
eventTypes: allEventsSelected ? [] : selectedEvents,
|
||||
},
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await utils.webhook.list.invalidate();
|
||||
await utils.webhook.getById.invalidate({ id: webhook.id });
|
||||
toast.success("Webhook updated");
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit webhook</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field, formState }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Endpoint URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/webhooks/usesend"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="eventTypes"
|
||||
render={({ field, formState }) => {
|
||||
const selectedEvents = field.value ?? [];
|
||||
const totalEvents = WebhookEvents;
|
||||
|
||||
const selectedCount = allEventsSelected
|
||||
? totalEvents.length
|
||||
: selectedEvents.length;
|
||||
|
||||
const allSelectedLabel =
|
||||
selectedCount === 0
|
||||
? "Select events"
|
||||
: allEventsSelected
|
||||
? "All events"
|
||||
: selectedCount === 1
|
||||
? selectedEvents[0]
|
||||
: `${selectedCount} events selected`;
|
||||
|
||||
const isGroupFullySelected = (
|
||||
groupEvents: readonly WebhookEventType[],
|
||||
) => {
|
||||
if (allEventsSelected) return true;
|
||||
if (selectedEvents.length === 0) return false;
|
||||
return groupEvents.every((event) =>
|
||||
selectedEvents.includes(event),
|
||||
);
|
||||
};
|
||||
|
||||
const handleToggleAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setAllEventsSelected(true);
|
||||
field.onChange([]);
|
||||
} else {
|
||||
setAllEventsSelected(false);
|
||||
field.onChange([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleGroup = (
|
||||
groupEvents: readonly WebhookEventType[],
|
||||
) => {
|
||||
if (allEventsSelected) {
|
||||
const next = totalEvents.filter(
|
||||
(event) => !groupEvents.includes(event),
|
||||
);
|
||||
setAllEventsSelected(false);
|
||||
field.onChange(next);
|
||||
return;
|
||||
}
|
||||
|
||||
const current = new Set(selectedEvents);
|
||||
const fullySelected = groupEvents.every((event) =>
|
||||
current.has(event),
|
||||
);
|
||||
|
||||
if (fullySelected) {
|
||||
groupEvents.forEach((event) => current.delete(event));
|
||||
} else {
|
||||
groupEvents.forEach((event) => current.add(event));
|
||||
}
|
||||
|
||||
field.onChange(Array.from(current));
|
||||
};
|
||||
|
||||
const handleToggleEvent = (event: WebhookEventType) => {
|
||||
if (allEventsSelected) {
|
||||
const next = WebhookEvents.filter((e) => e !== event);
|
||||
setAllEventsSelected(false);
|
||||
field.onChange(next);
|
||||
return;
|
||||
}
|
||||
|
||||
const exists = selectedEvents.includes(event);
|
||||
const next = exists
|
||||
? selectedEvents.filter((e) => e !== event)
|
||||
: [...selectedEvents, event];
|
||||
field.onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Events</FormLabel>
|
||||
<FormControl>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-3 inline-flex w-full items-center justify-between"
|
||||
>
|
||||
<span className="truncate text-left text-sm">
|
||||
{allSelectedLabel}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-[--radix-dropdown-menu-trigger-width] h-[30vh]">
|
||||
<div className="space-y-3">
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={allEventsSelected}
|
||||
onCheckedChange={(checked) =>
|
||||
handleToggleAll(Boolean(checked))
|
||||
}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
className="font-medium mb-2 px-2"
|
||||
>
|
||||
All events
|
||||
</DropdownMenuCheckboxItem>
|
||||
{eventGroups.map((group) => (
|
||||
<div key={group.label} className="">
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={isGroupFullySelected(group.events)}
|
||||
onCheckedChange={() =>
|
||||
handleToggleGroup(group.events)
|
||||
}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
className="px-2 text-xs font-semibold text-muted-foreground"
|
||||
>
|
||||
{group.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
{group.events.map((event) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={event}
|
||||
checked={
|
||||
allEventsSelected ||
|
||||
selectedEvents.includes(event)
|
||||
}
|
||||
onCheckedChange={() =>
|
||||
handleToggleEvent(event)
|
||||
}
|
||||
onSelect={(event) =>
|
||||
event.preventDefault()
|
||||
}
|
||||
className="pl-3 pr-2 font-mono"
|
||||
>
|
||||
{event}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</FormControl>
|
||||
{formState.errors.eventTypes ? <FormMessage /> : null}
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className="w-[120px]"
|
||||
type="submit"
|
||||
disabled={updateWebhook.isPending}
|
||||
>
|
||||
{updateWebhook.isPending ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
UsersIcon,
|
||||
GaugeIcon,
|
||||
UserRoundX,
|
||||
Webhook,
|
||||
} from "lucide-react";
|
||||
import { signOut } from "next-auth/react";
|
||||
|
||||
@@ -98,6 +99,11 @@ const settingsItems = [
|
||||
url: "/domains",
|
||||
icon: Globe,
|
||||
},
|
||||
{
|
||||
title: "Webhooks",
|
||||
url: "/webhooks",
|
||||
icon: Webhook,
|
||||
},
|
||||
{
|
||||
title: "Developer settings",
|
||||
url: "/dev-settings",
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { BundledLanguage, codeToHtml } from "shiki";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { Button } from "@usesend/ui/src/button";
|
||||
|
||||
interface CodeDisplayProps {
|
||||
code: string;
|
||||
language?: BundledLanguage;
|
||||
className?: string;
|
||||
maxHeight?: string;
|
||||
}
|
||||
|
||||
export function CodeDisplay({
|
||||
code,
|
||||
language = "json",
|
||||
className = "",
|
||||
maxHeight = "300px",
|
||||
}: CodeDisplayProps) {
|
||||
const [html, setHtml] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function highlight() {
|
||||
try {
|
||||
const highlighted = await codeToHtml(code, {
|
||||
lang: language,
|
||||
themes: {
|
||||
dark: "catppuccin-mocha",
|
||||
light: "catppuccin-latte",
|
||||
},
|
||||
decorations: [],
|
||||
cssVariablePrefix: "--shiki-",
|
||||
});
|
||||
|
||||
if (isMounted) {
|
||||
setHtml(highlighted);
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to highlight code:", error);
|
||||
if (isMounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
highlight();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [code, language]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy to clipboard:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="relative rounded-lg overflow-hidden border bg-muted/50">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleCopy}
|
||||
className="absolute top-2 right-2 h-8 w-8 z-10"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<pre
|
||||
className={`text-xs font-mono p-4 overflow-auto ${className}`}
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
<code className="p-2">{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative rounded-lg overflow-hidden border">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleCopy}
|
||||
className="absolute top-2 right-2 h-8 w-8 z-10 bg-background/80 hover:bg-background"
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
<div
|
||||
className={`text-xs overflow-auto ${className} [&_pre]:p-4 [&_pre]:!m-0`}
|
||||
style={{ maxHeight }}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export enum LimitReason {
|
||||
DOMAIN = "DOMAIN",
|
||||
CONTACT_BOOK = "CONTACT_BOOK",
|
||||
TEAM_MEMBER = "TEAM_MEMBER",
|
||||
WEBHOOK = "WEBHOOK",
|
||||
EMAIL_BLOCKED = "EMAIL_BLOCKED",
|
||||
EMAIL_DAILY_LIMIT_REACHED = "EMAIL_DAILY_LIMIT_REACHED",
|
||||
EMAIL_FREE_PLAN_MONTHLY_LIMIT_REACHED = "EMAIL_FREE_PLAN_MONTHLY_LIMIT_REACHED",
|
||||
@@ -17,6 +18,7 @@ export const PLAN_LIMITS: Record<
|
||||
domains: number;
|
||||
contactBooks: number;
|
||||
teamMembers: number;
|
||||
webhooks: number;
|
||||
}
|
||||
> = {
|
||||
FREE: {
|
||||
@@ -25,6 +27,7 @@ export const PLAN_LIMITS: Record<
|
||||
domains: 1,
|
||||
contactBooks: 1,
|
||||
teamMembers: 1,
|
||||
webhooks: 1,
|
||||
},
|
||||
BASIC: {
|
||||
emailsPerMonth: -1, // unlimited
|
||||
@@ -32,5 +35,6 @@ export const PLAN_LIMITS: Record<
|
||||
domains: -1,
|
||||
contactBooks: -1,
|
||||
teamMembers: -1,
|
||||
webhooks: -1,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
export const DELIVERY_DELAY_ERRORS = {
|
||||
InternalFailure: "An internal useSend issue caused the message to be delayed.",
|
||||
General: "A generic failure occurred during the SMTP conversation.",
|
||||
MailboxFull:
|
||||
"The recipient's mailbox is full and is unable to receive additional messages.",
|
||||
SpamDetected:
|
||||
"The recipient's mail server has detected a large amount of unsolicited email from your account.",
|
||||
RecipientServerError:
|
||||
"A temporary issue with the recipient's email server is preventing the delivery of the message.",
|
||||
IPFailure:
|
||||
"The IP address that's sending the message is being blocked or throttled by the recipient's email provider.",
|
||||
TransientCommunicationFailure:
|
||||
"There was a temporary communication failure during the SMTP conversation with the recipient's email provider.",
|
||||
BYOIPHostNameLookupUnavailable:
|
||||
"useSend was unable to look up the DNS hostname for your IP addresses. This type of delay only occurs when you use Bring Your Own IP.",
|
||||
Undetermined:
|
||||
"useSend wasn't able to determine the reason for the delivery delay.",
|
||||
SendingDeferral:
|
||||
"useSend has deemed it appropriate to internally defer the message.",
|
||||
};
|
||||
|
||||
export const BOUNCE_ERROR_MESSAGES = {
|
||||
Undetermined: "useSend was unable to determine a specific bounce reason.",
|
||||
Permanent: {
|
||||
General:
|
||||
"useSend received a general hard bounce. If you receive this type of bounce, you should remove the recipient's email address from your mailing list.",
|
||||
NoEmail:
|
||||
"useSend received a permanent hard bounce because the target email address does not exist. If you receive this type of bounce, you should remove the recipient's email address from your mailing list.",
|
||||
Suppressed:
|
||||
"useSend has suppressed sending to this address because it has a recent history of bouncing as an invalid address. To override the global suppression list, see Using the useSend account-level suppression list.",
|
||||
OnAccountSuppressionList:
|
||||
"useSend has suppressed sending to this address because it is on the account-level suppression list. This does not count toward your bounce rate metric.",
|
||||
},
|
||||
Transient: {
|
||||
General:
|
||||
"useSend received a general bounce. You may be able to successfully send to this recipient in the future.",
|
||||
MailboxFull:
|
||||
"useSend received a mailbox full bounce. You may be able to successfully send to this recipient in the future.",
|
||||
MessageTooLarge:
|
||||
"useSend received a message too large bounce. You may be able to successfully send to this recipient if you reduce the size of the message.",
|
||||
ContentRejected:
|
||||
"useSend received a content rejected bounce. You may be able to successfully send to this recipient if you change the content of the message.",
|
||||
AttachmentRejected:
|
||||
"useSend received an attachment rejected bounce. You may be able to successfully send to this recipient if you remove or change the attachment.",
|
||||
},
|
||||
};
|
||||
|
||||
export const COMPLAINT_ERROR_MESSAGES = {
|
||||
abuse: "Indicates unsolicited email or some other kind of email abuse.",
|
||||
"auth-failure": "Email authentication failure report.",
|
||||
fraud: "Indicates some kind of fraud or phishing activity.",
|
||||
"not-spam":
|
||||
"Indicates that the entity providing the report does not consider the message to be spam. This may be used to correct a message that was incorrectly tagged or categorized as spam.",
|
||||
other:
|
||||
"Indicates any other feedback that does not fit into other registered types.",
|
||||
virus: "Reports that a virus is found in the originating message.",
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import { suppressionRouter } from "./routers/suppression";
|
||||
import { limitsRouter } from "./routers/limits";
|
||||
import { waitlistRouter } from "./routers/waitlist";
|
||||
import { feedbackRouter } from "./routers/feedback";
|
||||
import { webhookRouter } from "./routers/webhook";
|
||||
|
||||
/**
|
||||
* This is the primary router for your server.
|
||||
@@ -36,6 +37,7 @@ export const appRouter = createTRPCRouter({
|
||||
limits: limitsRouter,
|
||||
waitlist: waitlistRouter,
|
||||
feedback: feedbackRouter,
|
||||
webhook: webhookRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
@@ -152,12 +152,13 @@ export const contactsRouter = createTRPCRouter({
|
||||
subscribed: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx: { contactBook }, input }) => {
|
||||
.mutation(async ({ ctx: { contactBook, team }, input }) => {
|
||||
const { contactId, ...contact } = input;
|
||||
const updatedContact = await contactService.updateContactInContactBook(
|
||||
contactId,
|
||||
contactBook.id,
|
||||
contact,
|
||||
team.id,
|
||||
);
|
||||
|
||||
if (!updatedContact) {
|
||||
@@ -172,10 +173,11 @@ export const contactsRouter = createTRPCRouter({
|
||||
|
||||
deleteContact: contactBookProcedure
|
||||
.input(z.object({ contactId: z.string() }))
|
||||
.mutation(async ({ ctx: { contactBook }, input }) => {
|
||||
.mutation(async ({ ctx: { contactBook, team }, input }) => {
|
||||
const deletedContact = await contactService.deleteContactInContactBook(
|
||||
input.contactId,
|
||||
contactBook.id,
|
||||
team.id,
|
||||
);
|
||||
|
||||
if (!deletedContact) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Email, EmailStatus, Prisma } from "@prisma/client";
|
||||
import { format, subDays } from "date-fns";
|
||||
import { z } from "zod";
|
||||
import { DEFAULT_QUERY_LIMIT } from "~/lib/constants";
|
||||
import { BOUNCE_ERROR_MESSAGES } from "~/lib/constants/ses-errors";
|
||||
import { BOUNCE_ERROR_MESSAGES } from "@usesend/lib/src";
|
||||
import type { SesBounce } from "~/types/aws-types";
|
||||
|
||||
import {
|
||||
@@ -95,12 +95,12 @@ export const emailRouter = createTRPCRouter({
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const emails = await db.$queryRaw<Array<Email>>`
|
||||
SELECT
|
||||
id,
|
||||
"createdAt",
|
||||
"latestStatus",
|
||||
subject,
|
||||
"to",
|
||||
SELECT
|
||||
id,
|
||||
"createdAt",
|
||||
"latestStatus",
|
||||
subject,
|
||||
"to",
|
||||
"scheduledAt"
|
||||
FROM "Email"
|
||||
WHERE "teamId" = ${ctx.team.id}
|
||||
@@ -110,9 +110,9 @@ export const emailRouter = createTRPCRouter({
|
||||
${
|
||||
input.search
|
||||
? Prisma.sql`AND (
|
||||
"subject" ILIKE ${`%${input.search}%`}
|
||||
"subject" ILIKE ${`%${input.search}%`}
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM unnest("to") AS email
|
||||
SELECT 1 FROM unnest("to") AS email
|
||||
WHERE email ILIKE ${`%${input.search}%`}
|
||||
)
|
||||
)`
|
||||
@@ -201,7 +201,12 @@ export const emailRouter = createTRPCRouter({
|
||||
} as const;
|
||||
|
||||
if (email.latestStatus !== "BOUNCED" || !email.bounceData) {
|
||||
return { ...base, bounceType: undefined, bounceSubType: undefined, bounceReason: undefined };
|
||||
return {
|
||||
...base,
|
||||
bounceType: undefined,
|
||||
bounceSubType: undefined,
|
||||
bounceReason: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const bounce = ensureBounceObject(email.bounceData);
|
||||
@@ -209,7 +214,9 @@ export const emailRouter = createTRPCRouter({
|
||||
const bounceSubType = bounce?.bounceSubType
|
||||
? bounce.bounceSubType.toString().trim().replace(/\s+/g, "")
|
||||
: undefined;
|
||||
const bounceReason = bounce ? getBounceReasonFromParsed(bounce) : undefined;
|
||||
const bounceReason = bounce
|
||||
? getBounceReasonFromParsed(bounce)
|
||||
: undefined;
|
||||
|
||||
return { ...base, bounceType, bounceSubType, bounceReason };
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ export const limitsRouter = createTRPCRouter({
|
||||
.input(
|
||||
z.object({
|
||||
type: z.nativeEnum(LimitReason),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
switch (input.type) {
|
||||
@@ -18,6 +18,8 @@ export const limitsRouter = createTRPCRouter({
|
||||
return LimitService.checkDomainLimit(ctx.team.id);
|
||||
case LimitReason.TEAM_MEMBER:
|
||||
return LimitService.checkTeamMemberLimit(ctx.team.id);
|
||||
case LimitReason.WEBHOOK:
|
||||
return LimitService.checkWebhookLimit(ctx.team.id);
|
||||
default:
|
||||
// exhaustive guard
|
||||
throw new Error("Unsupported limit type");
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, teamProcedure } from "~/server/api/trpc";
|
||||
import { WebhookCallStatus, WebhookStatus } from "@prisma/client";
|
||||
import { WebhookEvents } from "@usesend/lib/src/webhook/webhook-events";
|
||||
import { WebhookService } from "~/server/service/webhook-service";
|
||||
|
||||
const EVENT_TYPES_ENUM = z.enum(WebhookEvents);
|
||||
|
||||
export const webhookRouter = createTRPCRouter({
|
||||
list: teamProcedure.query(async ({ ctx }) => {
|
||||
return WebhookService.listWebhooks(ctx.team.id);
|
||||
}),
|
||||
|
||||
getById: teamProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return WebhookService.getWebhook({
|
||||
id: input.id,
|
||||
teamId: ctx.team.id,
|
||||
});
|
||||
}),
|
||||
|
||||
create: teamProcedure
|
||||
.input(
|
||||
z.object({
|
||||
url: z.string().url(),
|
||||
description: z.string().optional(),
|
||||
eventTypes: z.array(EVENT_TYPES_ENUM),
|
||||
secret: z.string().min(16).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return WebhookService.createWebhook({
|
||||
teamId: ctx.team.id,
|
||||
userId: ctx.session.user.id,
|
||||
url: input.url,
|
||||
description: input.description,
|
||||
eventTypes: input.eventTypes,
|
||||
secret: input.secret,
|
||||
});
|
||||
}),
|
||||
|
||||
update: teamProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
url: z.string().url().optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
eventTypes: z.array(EVENT_TYPES_ENUM).optional(),
|
||||
rotateSecret: z.boolean().optional(),
|
||||
secret: z.string().min(16).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return WebhookService.updateWebhook({
|
||||
id: input.id,
|
||||
teamId: ctx.team.id,
|
||||
url: input.url,
|
||||
description: input.description,
|
||||
eventTypes: input.eventTypes,
|
||||
rotateSecret: input.rotateSecret,
|
||||
secret: input.secret,
|
||||
});
|
||||
}),
|
||||
|
||||
setStatus: teamProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
status: z.nativeEnum(WebhookStatus),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return WebhookService.setWebhookStatus({
|
||||
id: input.id,
|
||||
teamId: ctx.team.id,
|
||||
status: input.status,
|
||||
});
|
||||
}),
|
||||
|
||||
delete: teamProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return WebhookService.deleteWebhook({
|
||||
id: input.id,
|
||||
teamId: ctx.team.id,
|
||||
});
|
||||
}),
|
||||
|
||||
test: teamProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return WebhookService.testWebhook({
|
||||
webhookId: input.id,
|
||||
teamId: ctx.team.id,
|
||||
});
|
||||
}),
|
||||
|
||||
listCalls: teamProcedure
|
||||
.input(
|
||||
z.object({
|
||||
webhookId: z.string().optional(),
|
||||
status: z.nativeEnum(WebhookCallStatus).optional(),
|
||||
limit: z.number().min(1).max(50).default(20),
|
||||
cursor: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return WebhookService.listWebhookCalls({
|
||||
teamId: ctx.team.id,
|
||||
webhookId: input.webhookId,
|
||||
status: input.status,
|
||||
limit: input.limit,
|
||||
cursor: input.cursor,
|
||||
});
|
||||
}),
|
||||
|
||||
getCall: teamProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return WebhookService.getWebhookCall({
|
||||
id: input.id,
|
||||
teamId: ctx.team.id,
|
||||
});
|
||||
}),
|
||||
|
||||
retryCall: teamProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return WebhookService.retryCall({
|
||||
callId: input.id,
|
||||
teamId: ctx.team.id,
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Queue, Worker } from "bullmq";
|
||||
import { subDays } from "date-fns";
|
||||
import { db } from "~/server/db";
|
||||
import { getRedis } from "~/server/redis";
|
||||
import { DEFAULT_QUEUE_OPTIONS, WEBHOOK_CLEANUP_QUEUE } from "../queue/queue-constants";
|
||||
import { logger } from "../logger/log";
|
||||
|
||||
const WEBHOOK_RETENTION_DAYS = 30;
|
||||
|
||||
const webhookCleanupQueue = new Queue(WEBHOOK_CLEANUP_QUEUE, {
|
||||
connection: getRedis(),
|
||||
});
|
||||
|
||||
const worker = new Worker(
|
||||
WEBHOOK_CLEANUP_QUEUE,
|
||||
async () => {
|
||||
const cutoff = subDays(new Date(), WEBHOOK_RETENTION_DAYS);
|
||||
const result = await db.webhookCall.deleteMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
lt: cutoff,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{ deleted: result.count, cutoff: cutoff.toISOString() },
|
||||
"[WebhookCleanupJob]: Deleted old webhook calls",
|
||||
);
|
||||
},
|
||||
{
|
||||
connection: getRedis(),
|
||||
}
|
||||
);
|
||||
|
||||
await webhookCleanupQueue.upsertJobScheduler(
|
||||
"webhook-cleanup-daily",
|
||||
{
|
||||
pattern: "0 3 * * *", // daily at 03:00 UTC
|
||||
tz: "UTC",
|
||||
},
|
||||
{
|
||||
opts: {
|
||||
...DEFAULT_QUEUE_OPTIONS,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
worker.on("completed", (job) => {
|
||||
logger.info({ jobId: job.id }, "[WebhookCleanupJob]: Job completed");
|
||||
});
|
||||
|
||||
worker.on("failed", (job, err) => {
|
||||
logger.error({ err, jobId: job?.id }, "[WebhookCleanupJob]: Job failed");
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { PublicAPIApp } from "~/server/public-api/hono";
|
||||
import { getTeamFromToken } from "~/server/public-api/auth";
|
||||
import { addOrUpdateContact } from "~/server/service/contact-service";
|
||||
import { getContactBook } from "../../api-utils";
|
||||
|
||||
@@ -55,7 +54,8 @@ function addContact(app: PublicAPIApp) {
|
||||
|
||||
const contact = await addOrUpdateContact(
|
||||
contactBook.id,
|
||||
c.req.valid("json")
|
||||
c.req.valid("json"),
|
||||
team.id,
|
||||
);
|
||||
|
||||
return c.json({ contactId: contact.id });
|
||||
|
||||
@@ -47,6 +47,7 @@ function deleteContactHandler(app: PublicAPIApp) {
|
||||
const deletedContact = await deleteContactInContactBook(
|
||||
contactId,
|
||||
contactBook.id,
|
||||
team.id,
|
||||
);
|
||||
|
||||
if (!deletedContact) {
|
||||
|
||||
@@ -61,6 +61,7 @@ function updateContactInfo(app: PublicAPIApp) {
|
||||
contactId,
|
||||
contactBook.id,
|
||||
c.req.valid("json"),
|
||||
team.id,
|
||||
);
|
||||
|
||||
if (!contact) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { PublicAPIApp } from "~/server/public-api/hono";
|
||||
import { getTeamFromToken } from "~/server/public-api/auth";
|
||||
import { addOrUpdateContact } from "~/server/service/contact-service";
|
||||
import { getContactBook } from "../../api-utils";
|
||||
|
||||
@@ -55,7 +54,8 @@ function upsertContact(app: PublicAPIApp) {
|
||||
|
||||
const contact = await addOrUpdateContact(
|
||||
contactBook.id,
|
||||
c.req.valid("json")
|
||||
c.req.valid("json"),
|
||||
team.id,
|
||||
);
|
||||
|
||||
return c.json({ contactId: contact.id });
|
||||
|
||||
@@ -3,6 +3,8 @@ export const CAMPAIGN_MAIL_PROCESSING_QUEUE = "campaign-emails-processing";
|
||||
export const CONTACT_BULK_ADD_QUEUE = "contact-bulk-add";
|
||||
export const CAMPAIGN_BATCH_QUEUE = "campaign-batch";
|
||||
export const CAMPAIGN_SCHEDULER_QUEUE = "campaign-scheduler";
|
||||
export const WEBHOOK_DISPATCH_QUEUE = "webhook-dispatch";
|
||||
export const WEBHOOK_CLEANUP_QUEUE = "webhook-cleanup";
|
||||
|
||||
export const DEFAULT_QUEUE_OPTIONS = {
|
||||
removeOnComplete: true,
|
||||
|
||||
@@ -97,7 +97,7 @@ class ContactQueueService {
|
||||
}
|
||||
|
||||
async function processContactJob(job: ContactJob) {
|
||||
const { contactBookId, contact } = job.data;
|
||||
const { contactBookId, contact, teamId } = job.data;
|
||||
|
||||
logger.info(
|
||||
{ contactEmail: contact.email, contactBookId },
|
||||
@@ -105,7 +105,7 @@ async function processContactJob(job: ContactJob) {
|
||||
);
|
||||
|
||||
try {
|
||||
await addOrUpdateContact(contactBookId, contact);
|
||||
await addOrUpdateContact(contactBookId, contact, teamId);
|
||||
logger.info(
|
||||
{ contactEmail: contact.email },
|
||||
"[ContactQueueService]: Successfully processed contact job",
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { type Contact } from "@prisma/client";
|
||||
import {
|
||||
type ContactPayload,
|
||||
type ContactWebhookEventType,
|
||||
} from "@usesend/lib/src/webhook/webhook-events";
|
||||
import { db } from "../db";
|
||||
import { ContactQueueService } from "./contact-queue-service";
|
||||
import { WebhookService } from "./webhook-service";
|
||||
import { logger } from "../logger/log";
|
||||
|
||||
export type ContactInput = {
|
||||
email: string;
|
||||
@@ -12,6 +19,7 @@ export type ContactInput = {
|
||||
export async function addOrUpdateContact(
|
||||
contactBookId: string,
|
||||
contact: ContactInput,
|
||||
teamId?: number,
|
||||
) {
|
||||
// Check if contact exists to handle subscribed logic
|
||||
const existingContact = await db.contact.findUnique({
|
||||
@@ -37,7 +45,7 @@ export async function addOrUpdateContact(
|
||||
// All other cases (Yes→No, Yes→Yes, No→No) are allowed naturally
|
||||
}
|
||||
|
||||
const createdContact = await db.contact.upsert({
|
||||
const savedContact = await db.contact.upsert({
|
||||
where: {
|
||||
contactBookId_email: {
|
||||
contactBookId,
|
||||
@@ -60,7 +68,13 @@ export async function addOrUpdateContact(
|
||||
},
|
||||
});
|
||||
|
||||
return createdContact;
|
||||
const eventType: ContactWebhookEventType = existingContact
|
||||
? "contact.updated"
|
||||
: "contact.created";
|
||||
|
||||
await emitContactEvent(savedContact, eventType, teamId);
|
||||
|
||||
return savedContact;
|
||||
}
|
||||
|
||||
export async function getContactInContactBook(
|
||||
@@ -79,6 +93,7 @@ export async function updateContactInContactBook(
|
||||
contactId: string,
|
||||
contactBookId: string,
|
||||
contact: Partial<ContactInput>,
|
||||
teamId?: number,
|
||||
) {
|
||||
const existingContact = await getContactInContactBook(
|
||||
contactId,
|
||||
@@ -89,17 +104,22 @@ export async function updateContactInContactBook(
|
||||
return null;
|
||||
}
|
||||
|
||||
return db.contact.update({
|
||||
const updatedContact = await db.contact.update({
|
||||
where: {
|
||||
id: contactId,
|
||||
},
|
||||
data: contact,
|
||||
});
|
||||
|
||||
await emitContactEvent(updatedContact, "contact.updated", teamId);
|
||||
|
||||
return updatedContact;
|
||||
}
|
||||
|
||||
export async function deleteContactInContactBook(
|
||||
contactId: string,
|
||||
contactBookId: string,
|
||||
teamId?: number,
|
||||
) {
|
||||
const existingContact = await getContactInContactBook(
|
||||
contactId,
|
||||
@@ -110,11 +130,15 @@ export async function deleteContactInContactBook(
|
||||
return null;
|
||||
}
|
||||
|
||||
return db.contact.delete({
|
||||
const deletedContact = await db.contact.delete({
|
||||
where: {
|
||||
id: contactId,
|
||||
},
|
||||
});
|
||||
|
||||
await emitContactEvent(deletedContact, "contact.deleted", teamId);
|
||||
|
||||
return deletedContact;
|
||||
}
|
||||
|
||||
export async function bulkAddContacts(
|
||||
@@ -151,3 +175,53 @@ export async function subscribeContact(contactId: string) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildContactPayload(contact: Contact): ContactPayload {
|
||||
return {
|
||||
id: contact.id,
|
||||
email: contact.email,
|
||||
contactBookId: contact.contactBookId,
|
||||
subscribed: contact.subscribed,
|
||||
properties: (contact.properties ?? {}) as Record<string, unknown>,
|
||||
firstName: contact.firstName,
|
||||
lastName: contact.lastName,
|
||||
createdAt: contact.createdAt.toISOString(),
|
||||
updatedAt: contact.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function emitContactEvent(
|
||||
contact: Contact,
|
||||
type: ContactWebhookEventType,
|
||||
teamId?: number,
|
||||
) {
|
||||
try {
|
||||
const resolvedTeamId =
|
||||
teamId ??
|
||||
(await db.contactBook
|
||||
.findUnique({
|
||||
where: { id: contact.contactBookId },
|
||||
select: { teamId: true },
|
||||
})
|
||||
.then((contactBook) => contactBook?.teamId));
|
||||
|
||||
if (!resolvedTeamId) {
|
||||
logger.warn(
|
||||
{ contactId: contact.id },
|
||||
"[ContactService]: Skipping webhook emission, teamId not found",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await WebhookService.emit(
|
||||
resolvedTeamId,
|
||||
type,
|
||||
buildContactPayload(contact),
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, contactId: contact.id, type },
|
||||
"[ContactService]: Failed to emit contact webhook event",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,13 @@ import { SesSettingsService } from "./ses-settings-service";
|
||||
import { UnsendApiError } from "../public-api/api-error";
|
||||
import { logger } from "../logger/log";
|
||||
import { ApiKey, DomainStatus, type Domain } from "@prisma/client";
|
||||
import {
|
||||
type DomainPayload,
|
||||
type DomainWebhookEventType,
|
||||
} from "@usesend/lib/src/webhook/webhook-events";
|
||||
import { LimitService } from "./limit-service";
|
||||
import type { DomainDnsRecord } from "~/types/domain";
|
||||
import { WebhookService } from "./webhook-service";
|
||||
|
||||
const DOMAIN_STATUS_VALUES = new Set(Object.values(DomainStatus));
|
||||
|
||||
@@ -72,7 +77,7 @@ function buildDnsRecords(domain: Domain): DomainDnsRecord[] {
|
||||
}
|
||||
|
||||
function withDnsRecords<T extends Domain>(
|
||||
domain: T
|
||||
domain: T,
|
||||
): T & { dnsRecords: DomainDnsRecord[] } {
|
||||
return {
|
||||
...domain,
|
||||
@@ -82,6 +87,24 @@ function withDnsRecords<T extends Domain>(
|
||||
|
||||
const dnsResolveTxt = util.promisify(dns.resolveTxt);
|
||||
|
||||
function buildDomainPayload(domain: Domain): DomainPayload {
|
||||
return {
|
||||
id: domain.id,
|
||||
name: domain.name,
|
||||
status: domain.status,
|
||||
region: domain.region,
|
||||
createdAt: domain.createdAt.toISOString(),
|
||||
updatedAt: domain.updatedAt.toISOString(),
|
||||
clickTracking: domain.clickTracking,
|
||||
openTracking: domain.openTracking,
|
||||
subdomain: domain.subdomain,
|
||||
sesTenantId: domain.sesTenantId,
|
||||
dkimStatus: domain.dkimStatus,
|
||||
spfDetails: domain.spfDetails,
|
||||
dmarcAdded: domain.dmarcAdded,
|
||||
};
|
||||
}
|
||||
|
||||
export async function validateDomainFromEmail(email: string, teamId: number) {
|
||||
// Extract email from format like 'Name <email@domain>' this will allow entries such as "Someone @ something <some@domain.com>" to parse correctly as well.
|
||||
const match = email.match(/<([^>]+)>/);
|
||||
@@ -130,7 +153,7 @@ export async function validateDomainFromEmail(email: string, teamId: number) {
|
||||
export async function validateApiKeyDomainAccess(
|
||||
email: string,
|
||||
teamId: number,
|
||||
apiKey: ApiKey & { domain?: { name: string } | null }
|
||||
apiKey: ApiKey & { domain?: { name: string } | null },
|
||||
) {
|
||||
// First validate the domain exists and is verified
|
||||
const domain = await validateDomainFromEmail(email, teamId);
|
||||
@@ -155,7 +178,7 @@ export async function createDomain(
|
||||
teamId: number,
|
||||
name: string,
|
||||
region: string,
|
||||
sesTenantId?: string
|
||||
sesTenantId?: string,
|
||||
) {
|
||||
const domainStr = tldts.getDomain(name);
|
||||
|
||||
@@ -187,7 +210,7 @@ export async function createDomain(
|
||||
name,
|
||||
region,
|
||||
sesTenantId,
|
||||
dkimSelector
|
||||
dkimSelector,
|
||||
);
|
||||
|
||||
const domain = await db.domain.create({
|
||||
@@ -204,6 +227,8 @@ export async function createDomain(
|
||||
},
|
||||
});
|
||||
|
||||
await emitDomainEvent(domain, "domain.created");
|
||||
|
||||
return withDnsRecords(domain);
|
||||
}
|
||||
|
||||
@@ -223,9 +248,10 @@ export async function getDomain(id: number, teamId: number) {
|
||||
}
|
||||
|
||||
if (domain.isVerifying) {
|
||||
const previousStatus = domain.status;
|
||||
const domainIdentity = await ses.getDomainIdentity(
|
||||
domain.name,
|
||||
domain.region
|
||||
domain.region,
|
||||
);
|
||||
|
||||
const dkimStatus = domainIdentity.DkimAttributes?.Status;
|
||||
@@ -268,7 +294,7 @@ export async function getDomain(id: number, teamId: number) {
|
||||
? lastCheckedTime.toISOString()
|
||||
: (lastCheckedTime ?? null);
|
||||
|
||||
return {
|
||||
const response = {
|
||||
...domainWithDns,
|
||||
dkimStatus: normalizedDomain.dkimStatus,
|
||||
spfDetails: normalizedDomain.spfDetails,
|
||||
@@ -276,6 +302,16 @@ export async function getDomain(id: number, teamId: number) {
|
||||
lastCheckedTime: normalizedLastCheckedTime,
|
||||
dmarcAdded: normalizedDomain.dmarcAdded,
|
||||
};
|
||||
|
||||
if (previousStatus !== domainWithDns.status) {
|
||||
const eventType: DomainWebhookEventType =
|
||||
domainWithDns.status === DomainStatus.SUCCESS
|
||||
? "domain.verified"
|
||||
: "domain.updated";
|
||||
await emitDomainEvent(domainWithDns, eventType);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
return withDnsRecords(domain);
|
||||
@@ -283,12 +319,16 @@ export async function getDomain(id: number, teamId: number) {
|
||||
|
||||
export async function updateDomain(
|
||||
id: number,
|
||||
data: { clickTracking?: boolean; openTracking?: boolean }
|
||||
data: { clickTracking?: boolean; openTracking?: boolean },
|
||||
) {
|
||||
return db.domain.update({
|
||||
const updated = await db.domain.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
|
||||
await emitDomainEvent(updated, "domain.updated");
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function deleteDomain(id: number) {
|
||||
@@ -303,7 +343,7 @@ export async function deleteDomain(id: number) {
|
||||
const deleted = await ses.deleteDomain(
|
||||
domain.name,
|
||||
domain.region,
|
||||
domain.sesTenantId ?? undefined
|
||||
domain.sesTenantId ?? undefined,
|
||||
);
|
||||
|
||||
if (!deleted) {
|
||||
@@ -312,12 +352,14 @@ export async function deleteDomain(id: number) {
|
||||
|
||||
const deletedRecord = await db.domain.delete({ where: { id } });
|
||||
|
||||
await emitDomainEvent(domain, "domain.deleted");
|
||||
|
||||
return deletedRecord;
|
||||
}
|
||||
|
||||
export async function getDomains(
|
||||
teamId: number,
|
||||
options?: { domainId?: number }
|
||||
options?: { domainId?: number },
|
||||
) {
|
||||
const domains = await db.domain.findMany({
|
||||
where: {
|
||||
@@ -341,3 +383,14 @@ async function getDmarcRecord(domain: string) {
|
||||
return null; // or handle error as appropriate
|
||||
}
|
||||
}
|
||||
|
||||
async function emitDomainEvent(domain: Domain, type: DomainWebhookEventType) {
|
||||
try {
|
||||
await WebhookService.emit(domain.teamId, type, buildDomainPayload(domain));
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, domainId: domain.id, type },
|
||||
"[DomainService]: Failed to emit domain webhook event",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,36 @@ export class LimitService {
|
||||
};
|
||||
}
|
||||
|
||||
static async checkWebhookLimit(teamId: number): Promise<{
|
||||
isLimitReached: boolean;
|
||||
limit: number;
|
||||
reason?: LimitReason;
|
||||
}> {
|
||||
// Limits only apply in cloud mode
|
||||
if (!env.NEXT_PUBLIC_IS_CLOUD) {
|
||||
return { isLimitReached: false, limit: -1 };
|
||||
}
|
||||
|
||||
const team = await TeamService.getTeamCached(teamId);
|
||||
const currentCount = await db.webhook.count({
|
||||
where: { teamId },
|
||||
});
|
||||
|
||||
const limit = PLAN_LIMITS[getActivePlan(team)].webhooks;
|
||||
if (isLimitExceeded(currentCount, limit)) {
|
||||
return {
|
||||
isLimitReached: true,
|
||||
limit,
|
||||
reason: LimitReason.WEBHOOK,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isLimitReached: false,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
// Checks email sending limits and also triggers usage notifications.
|
||||
// Side effects:
|
||||
// - Sends "warning" emails when nearing daily/monthly limits (rate-limited in TeamService)
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import {
|
||||
EmailStatus,
|
||||
Prisma,
|
||||
UnsubscribeReason,
|
||||
SuppressionReason,
|
||||
UnsubscribeReason,
|
||||
type Email,
|
||||
} from "@prisma/client";
|
||||
import {
|
||||
type EmailBasePayload,
|
||||
type EmailEventPayloadMap,
|
||||
type EmailWebhookEventType,
|
||||
} from "@usesend/lib/src/webhook/webhook-events";
|
||||
import {
|
||||
SesBounce,
|
||||
SesClick,
|
||||
@@ -25,6 +30,7 @@ import {
|
||||
import { getChildLogger, logger, withLogger } from "../logger/log";
|
||||
import { randomUUID } from "crypto";
|
||||
import { SuppressionService } from "./suppression-service";
|
||||
import { WebhookService } from "./webhook-service";
|
||||
|
||||
export async function parseSesHook(data: SesEvent) {
|
||||
const mailStatus = getEmailStatus(data);
|
||||
@@ -295,9 +301,218 @@ export async function parseSesHook(data: SesEvent) {
|
||||
|
||||
logger.info("Email event created");
|
||||
|
||||
try {
|
||||
const occurredAt = data.mail.timestamp
|
||||
? new Date(data.mail.timestamp).toISOString()
|
||||
: new Date().toISOString();
|
||||
|
||||
const metadata = buildEmailMetadata(mailStatus, mailData);
|
||||
|
||||
await WebhookService.emit(
|
||||
email.teamId,
|
||||
emailStatusToEvent(mailStatus),
|
||||
buildEmailWebhookPayload({
|
||||
email,
|
||||
status: mailStatus,
|
||||
occurredAt,
|
||||
eventData: mailData,
|
||||
metadata,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, emailId: email.id, mailStatus },
|
||||
"[SesHookParser]: Failed to emit webhook",
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
type EmailBounceSubType =
|
||||
EmailEventPayloadMap["email.bounced"]["bounce"]["subType"];
|
||||
|
||||
function buildEmailWebhookPayload(params: {
|
||||
email: Email;
|
||||
status: EmailStatus;
|
||||
occurredAt: string;
|
||||
eventData: SesEvent | SesEvent[SesEventDataKey];
|
||||
metadata?: Record<string, unknown>;
|
||||
}): EmailEventPayloadMap[EmailWebhookEventType] {
|
||||
const { email, status, eventData, occurredAt, metadata } = params;
|
||||
|
||||
const basePayload: EmailBasePayload = {
|
||||
id: email.id,
|
||||
status,
|
||||
from: email.from,
|
||||
to: email.to,
|
||||
occurredAt,
|
||||
campaignId: email.campaignId ?? undefined,
|
||||
contactId: email.contactId ?? undefined,
|
||||
domainId: email.domainId ?? null,
|
||||
subject: email.subject,
|
||||
metadata,
|
||||
};
|
||||
|
||||
switch (status) {
|
||||
case EmailStatus.BOUNCED: {
|
||||
const bounce = eventData as SesBounce | undefined;
|
||||
return {
|
||||
...basePayload,
|
||||
bounce: {
|
||||
type: bounce?.bounceType ?? "Undetermined",
|
||||
subType: normalizeBounceSubType(bounce?.bounceSubType),
|
||||
message: bounce?.bouncedRecipients?.[0]?.diagnosticCode,
|
||||
},
|
||||
};
|
||||
}
|
||||
case EmailStatus.OPENED: {
|
||||
const openData = eventData as SesEvent["open"];
|
||||
return {
|
||||
...basePayload,
|
||||
open: {
|
||||
timestamp: openData?.timestamp ?? occurredAt,
|
||||
userAgent: openData?.userAgent,
|
||||
ip: openData?.ipAddress,
|
||||
},
|
||||
};
|
||||
}
|
||||
case EmailStatus.CLICKED: {
|
||||
const clickData = eventData as SesClick | undefined;
|
||||
return {
|
||||
...basePayload,
|
||||
click: {
|
||||
timestamp: clickData?.timestamp ?? occurredAt,
|
||||
url: clickData?.link ?? "",
|
||||
userAgent: clickData?.userAgent,
|
||||
ip: clickData?.ipAddress,
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
return basePayload;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBounceSubType(
|
||||
subType: SesBounce["bounceSubType"] | undefined,
|
||||
): EmailBounceSubType {
|
||||
const normalized = subType?.replace(/\s+/g, "") as
|
||||
| EmailBounceSubType
|
||||
| undefined;
|
||||
|
||||
const validSubTypes: EmailBounceSubType[] = [
|
||||
"General",
|
||||
"NoEmail",
|
||||
"Suppressed",
|
||||
"OnAccountSuppressionList",
|
||||
"MailboxFull",
|
||||
"MessageTooLarge",
|
||||
"ContentRejected",
|
||||
"AttachmentRejected",
|
||||
];
|
||||
|
||||
if (normalized && validSubTypes.includes(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return "General";
|
||||
}
|
||||
|
||||
function emailStatusToEvent(status: EmailStatus): EmailWebhookEventType {
|
||||
switch (status) {
|
||||
case EmailStatus.QUEUED:
|
||||
return "email.queued";
|
||||
case EmailStatus.SENT:
|
||||
return "email.sent";
|
||||
case EmailStatus.DELIVERY_DELAYED:
|
||||
return "email.delivery_delayed";
|
||||
case EmailStatus.DELIVERED:
|
||||
return "email.delivered";
|
||||
case EmailStatus.BOUNCED:
|
||||
return "email.bounced";
|
||||
case EmailStatus.REJECTED:
|
||||
return "email.rejected";
|
||||
case EmailStatus.RENDERING_FAILURE:
|
||||
return "email.rendering_failure";
|
||||
case EmailStatus.COMPLAINED:
|
||||
return "email.complained";
|
||||
case EmailStatus.FAILED:
|
||||
return "email.failed";
|
||||
case EmailStatus.CANCELLED:
|
||||
return "email.cancelled";
|
||||
case EmailStatus.SUPPRESSED:
|
||||
return "email.suppressed";
|
||||
case EmailStatus.OPENED:
|
||||
return "email.opened";
|
||||
case EmailStatus.CLICKED:
|
||||
return "email.clicked";
|
||||
default:
|
||||
return "email.queued";
|
||||
}
|
||||
}
|
||||
|
||||
function buildEmailMetadata(
|
||||
status: EmailStatus,
|
||||
mailData: SesEvent | SesEvent[SesEventDataKey],
|
||||
) {
|
||||
switch (status) {
|
||||
case EmailStatus.BOUNCED: {
|
||||
const bounce = mailData as SesBounce;
|
||||
return {
|
||||
bounceType: bounce.bounceType,
|
||||
bounceSubType: bounce.bounceSubType,
|
||||
diagnosticCode: bounce.bouncedRecipients?.[0]?.diagnosticCode,
|
||||
};
|
||||
}
|
||||
case EmailStatus.COMPLAINED: {
|
||||
const complaintInfo = (mailData as any)?.complaint ?? mailData;
|
||||
return {
|
||||
feedbackType: complaintInfo?.complaintFeedbackType,
|
||||
userAgent: complaintInfo?.userAgent,
|
||||
};
|
||||
}
|
||||
case EmailStatus.OPENED: {
|
||||
const openData = (mailData as any)?.open ?? mailData;
|
||||
return {
|
||||
ipAddress: openData?.ipAddress,
|
||||
userAgent: openData?.userAgent,
|
||||
};
|
||||
}
|
||||
case EmailStatus.CLICKED: {
|
||||
const click = mailData as SesClick;
|
||||
return {
|
||||
ipAddress: click.ipAddress,
|
||||
userAgent: click.userAgent,
|
||||
link: click.link,
|
||||
};
|
||||
}
|
||||
case EmailStatus.RENDERING_FAILURE: {
|
||||
const failure = mailData as SesEvent["renderingFailure"];
|
||||
return {
|
||||
errorMessage: failure?.errorMessage,
|
||||
templateName: failure?.templateName,
|
||||
};
|
||||
}
|
||||
case EmailStatus.DELIVERY_DELAYED: {
|
||||
const deliveryDelay = mailData as SesEvent["deliveryDelay"];
|
||||
return {
|
||||
delayType: deliveryDelay?.delayType,
|
||||
expirationTime: deliveryDelay?.expirationTime,
|
||||
delayedRecipients: deliveryDelay?.delayedRecipients,
|
||||
};
|
||||
}
|
||||
case EmailStatus.REJECTED: {
|
||||
const reject = mailData as SesEvent["reject"];
|
||||
return {
|
||||
reason: reject?.reason,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkUnsubscribe({
|
||||
contactId,
|
||||
campaignId,
|
||||
|
||||
@@ -0,0 +1,819 @@
|
||||
import { WebhookCallStatus, WebhookStatus } from "@prisma/client";
|
||||
import { Queue, Worker } from "bullmq";
|
||||
import { createHmac, randomUUID, randomBytes } from "crypto";
|
||||
import {
|
||||
WebhookEventData,
|
||||
WebhookPayloadData,
|
||||
WEBHOOK_EVENT_VERSION,
|
||||
type WebhookEvent,
|
||||
type WebhookEventPayloadMap,
|
||||
type WebhookEventType,
|
||||
} from "@usesend/lib/src/webhook/webhook-events";
|
||||
import { db } from "../db";
|
||||
import { getRedis } from "../redis";
|
||||
import {
|
||||
DEFAULT_QUEUE_OPTIONS,
|
||||
WEBHOOK_DISPATCH_QUEUE,
|
||||
} from "../queue/queue-constants";
|
||||
import { createWorkerHandler, TeamJob } from "../queue/bullmq-context";
|
||||
import { logger } from "../logger/log";
|
||||
import { LimitService } from "./limit-service";
|
||||
import { UnsendApiError } from "../public-api/api-error";
|
||||
|
||||
const WEBHOOK_DISPATCH_CONCURRENCY = 25;
|
||||
const WEBHOOK_MAX_ATTEMPTS = 6;
|
||||
const WEBHOOK_BASE_BACKOFF_MS = 5_000;
|
||||
const WEBHOOK_LOCK_TTL_MS = 15_000;
|
||||
const WEBHOOK_LOCK_RETRY_DELAY_MS = 2_000;
|
||||
const WEBHOOK_AUTO_DISABLE_THRESHOLD = 30;
|
||||
const WEBHOOK_REQUEST_TIMEOUT_MS = 10_000;
|
||||
const WEBHOOK_RESPONSE_TEXT_LIMIT = 4_096;
|
||||
|
||||
type WebhookCallJobData = {
|
||||
callId: string;
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
type WebhookCallJob = TeamJob<WebhookCallJobData>;
|
||||
|
||||
type WebhookEventInput<TType extends WebhookEventType> =
|
||||
WebhookPayloadData<TType>;
|
||||
|
||||
export class WebhookQueueService {
|
||||
private static queue = new Queue<WebhookCallJobData>(WEBHOOK_DISPATCH_QUEUE, {
|
||||
connection: getRedis(),
|
||||
defaultJobOptions: {
|
||||
...DEFAULT_QUEUE_OPTIONS,
|
||||
attempts: WEBHOOK_MAX_ATTEMPTS,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: WEBHOOK_BASE_BACKOFF_MS,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
private static worker = new Worker(
|
||||
WEBHOOK_DISPATCH_QUEUE,
|
||||
createWorkerHandler(processWebhookCall),
|
||||
{
|
||||
connection: getRedis(),
|
||||
concurrency: WEBHOOK_DISPATCH_CONCURRENCY,
|
||||
},
|
||||
);
|
||||
|
||||
static {
|
||||
this.worker.on("error", (error) => {
|
||||
logger.error({ error }, "[WebhookQueueService]: Worker error");
|
||||
});
|
||||
|
||||
logger.info("[WebhookQueueService]: Initialized webhook queue service");
|
||||
}
|
||||
|
||||
public static async enqueueCall(callId: string, teamId: number) {
|
||||
await this.queue.add(
|
||||
callId,
|
||||
{
|
||||
callId,
|
||||
teamId,
|
||||
},
|
||||
{ jobId: callId },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class WebhookService {
|
||||
public static async emit<TType extends WebhookEventType>(
|
||||
teamId: number,
|
||||
type: TType,
|
||||
payload: WebhookEventInput<TType>,
|
||||
) {
|
||||
const activeWebhooks = await db.webhook.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
status: WebhookStatus.ACTIVE,
|
||||
OR: [
|
||||
{
|
||||
eventTypes: {
|
||||
has: type,
|
||||
},
|
||||
},
|
||||
{
|
||||
eventTypes: {
|
||||
isEmpty: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (activeWebhooks.length === 0) {
|
||||
logger.debug(
|
||||
{ teamId, type },
|
||||
"[WebhookService]: No active webhooks for event type",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadString = stringifyPayload(payload);
|
||||
|
||||
for (const webhook of activeWebhooks) {
|
||||
const call = await db.webhookCall.create({
|
||||
data: {
|
||||
webhookId: webhook.id,
|
||||
teamId: webhook.teamId,
|
||||
type: type,
|
||||
payload: payloadString,
|
||||
status: WebhookCallStatus.PENDING,
|
||||
attempt: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await WebhookQueueService.enqueueCall(call.id, webhook.teamId);
|
||||
}
|
||||
}
|
||||
|
||||
public static async retryCall(params: { callId: string; teamId: number }) {
|
||||
const call = await db.webhookCall.findFirst({
|
||||
where: { id: params.callId, teamId: params.teamId },
|
||||
});
|
||||
|
||||
if (!call) {
|
||||
throw new Error("Webhook call not found");
|
||||
}
|
||||
|
||||
await db.webhookCall.update({
|
||||
where: { id: call.id },
|
||||
data: {
|
||||
status: WebhookCallStatus.PENDING,
|
||||
attempt: 0,
|
||||
nextAttemptAt: null,
|
||||
lastError: null,
|
||||
responseStatus: null,
|
||||
responseTimeMs: null,
|
||||
responseText: null,
|
||||
},
|
||||
});
|
||||
|
||||
await WebhookQueueService.enqueueCall(call.id, params.teamId);
|
||||
|
||||
return call.id;
|
||||
}
|
||||
|
||||
public static async testWebhook(params: {
|
||||
webhookId: string;
|
||||
teamId: number;
|
||||
}) {
|
||||
const webhook = await db.webhook.findFirst({
|
||||
where: { id: params.webhookId, teamId: params.teamId },
|
||||
});
|
||||
|
||||
if (!webhook) {
|
||||
throw new Error("Webhook not found");
|
||||
}
|
||||
|
||||
const payload = {
|
||||
test: true,
|
||||
webhookId: webhook.id,
|
||||
sentAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const call = await db.webhookCall.create({
|
||||
data: {
|
||||
webhookId: webhook.id,
|
||||
teamId: webhook.teamId,
|
||||
type: "webhook.test",
|
||||
payload: stringifyPayload(payload),
|
||||
status: WebhookCallStatus.PENDING,
|
||||
attempt: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await WebhookQueueService.enqueueCall(call.id, webhook.teamId);
|
||||
|
||||
return call.id;
|
||||
}
|
||||
|
||||
public static generateSecret() {
|
||||
return `whsec_${randomBytes(32).toString("hex")}`;
|
||||
}
|
||||
|
||||
public static async listWebhooks(teamId: number) {
|
||||
return db.webhook.findMany({
|
||||
where: { teamId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
public static async getWebhook(params: { id: string; teamId: number }) {
|
||||
const webhook = await db.webhook.findFirst({
|
||||
where: { id: params.id, teamId: params.teamId },
|
||||
});
|
||||
|
||||
if (!webhook) {
|
||||
throw new UnsendApiError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Webhook not found",
|
||||
});
|
||||
}
|
||||
|
||||
return webhook;
|
||||
}
|
||||
|
||||
public static async createWebhook(params: {
|
||||
teamId: number;
|
||||
userId: number;
|
||||
url: string;
|
||||
description?: string;
|
||||
eventTypes: string[];
|
||||
secret?: string;
|
||||
}) {
|
||||
const { isLimitReached, reason } = await LimitService.checkWebhookLimit(
|
||||
params.teamId,
|
||||
);
|
||||
|
||||
if (isLimitReached) {
|
||||
throw new UnsendApiError({
|
||||
code: "FORBIDDEN",
|
||||
message: reason ?? "Webhook limit reached",
|
||||
});
|
||||
}
|
||||
|
||||
const secret = params.secret ?? WebhookService.generateSecret();
|
||||
|
||||
return db.webhook.create({
|
||||
data: {
|
||||
teamId: params.teamId,
|
||||
url: params.url,
|
||||
description: params.description,
|
||||
secret,
|
||||
eventTypes: params.eventTypes,
|
||||
status: WebhookStatus.ACTIVE,
|
||||
createdByUserId: params.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public static async updateWebhook(params: {
|
||||
id: string;
|
||||
teamId: number;
|
||||
url?: string;
|
||||
description?: string | null;
|
||||
eventTypes?: string[];
|
||||
rotateSecret?: boolean;
|
||||
secret?: string;
|
||||
}) {
|
||||
const webhook = await db.webhook.findFirst({
|
||||
where: { id: params.id, teamId: params.teamId },
|
||||
});
|
||||
|
||||
if (!webhook) {
|
||||
throw new UnsendApiError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Webhook not found",
|
||||
});
|
||||
}
|
||||
|
||||
const secret =
|
||||
params.rotateSecret === true
|
||||
? WebhookService.generateSecret()
|
||||
: params.secret;
|
||||
|
||||
return db.webhook.update({
|
||||
where: { id: webhook.id },
|
||||
data: {
|
||||
url: params.url ?? webhook.url,
|
||||
description:
|
||||
params.description === undefined
|
||||
? webhook.description
|
||||
: (params.description ?? null),
|
||||
eventTypes: params.eventTypes ?? webhook.eventTypes,
|
||||
secret: secret ?? webhook.secret,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public static async setWebhookStatus(params: {
|
||||
id: string;
|
||||
teamId: number;
|
||||
status: WebhookStatus;
|
||||
}) {
|
||||
const webhook = await db.webhook.findFirst({
|
||||
where: { id: params.id, teamId: params.teamId },
|
||||
});
|
||||
|
||||
if (!webhook) {
|
||||
throw new UnsendApiError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Webhook not found",
|
||||
});
|
||||
}
|
||||
|
||||
return db.webhook.update({
|
||||
where: { id: webhook.id },
|
||||
data: {
|
||||
status: params.status,
|
||||
consecutiveFailures:
|
||||
params.status === WebhookStatus.ACTIVE
|
||||
? 0
|
||||
: webhook.consecutiveFailures,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public static async deleteWebhook(params: { id: string; teamId: number }) {
|
||||
const webhook = await db.webhook.findFirst({
|
||||
where: { id: params.id, teamId: params.teamId },
|
||||
});
|
||||
|
||||
if (!webhook) {
|
||||
throw new UnsendApiError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Webhook not found",
|
||||
});
|
||||
}
|
||||
|
||||
return db.webhook.delete({
|
||||
where: { id: webhook.id },
|
||||
});
|
||||
}
|
||||
|
||||
public static async listWebhookCalls(params: {
|
||||
teamId: number;
|
||||
webhookId?: string;
|
||||
status?: WebhookCallStatus;
|
||||
limit: number;
|
||||
cursor?: string;
|
||||
}) {
|
||||
const calls = await db.webhookCall.findMany({
|
||||
where: {
|
||||
teamId: params.teamId,
|
||||
webhookId: params.webhookId,
|
||||
status: params.status,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: params.limit + 1,
|
||||
cursor: params.cursor ? { id: params.cursor } : undefined,
|
||||
});
|
||||
|
||||
let nextCursor: string | null = null;
|
||||
if (calls.length > params.limit) {
|
||||
const next = calls.pop();
|
||||
nextCursor = next?.id ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
items: calls,
|
||||
nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
public static async getWebhookCall(params: { id: string; teamId: number }) {
|
||||
const call = await db.webhookCall.findFirst({
|
||||
where: { id: params.id, teamId: params.teamId },
|
||||
include: {
|
||||
webhook: {
|
||||
select: {
|
||||
apiVersion: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!call) {
|
||||
throw new UnsendApiError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Webhook call not found",
|
||||
});
|
||||
}
|
||||
|
||||
return call;
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyPayload(payload: unknown) {
|
||||
if (typeof payload === "string") {
|
||||
return payload;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(payload);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error },
|
||||
"[WebhookService]: Failed to stringify payload, falling back to empty object",
|
||||
);
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
|
||||
async function processWebhookCall(job: WebhookCallJob) {
|
||||
const attempt = job.attemptsMade + 1;
|
||||
const call = await db.webhookCall.findUnique({
|
||||
where: { id: job.data.callId },
|
||||
include: {
|
||||
webhook: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!call) {
|
||||
logger.warn(
|
||||
{ callId: job.data.callId },
|
||||
"[WebhookQueueService]: Call not found",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (call.webhook.status !== WebhookStatus.ACTIVE) {
|
||||
await db.webhookCall.update({
|
||||
where: { id: call.id },
|
||||
data: {
|
||||
status: WebhookCallStatus.DISCARDED,
|
||||
attempt,
|
||||
},
|
||||
});
|
||||
logger.info(
|
||||
{ callId: call.id, webhookId: call.webhookId },
|
||||
"[WebhookQueueService]: Discarded call because webhook is not active",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await db.webhookCall.update({
|
||||
where: { id: call.id },
|
||||
data: {
|
||||
status: WebhookCallStatus.IN_PROGRESS,
|
||||
attempt,
|
||||
},
|
||||
});
|
||||
|
||||
const lockKey = `webhook:lock:${call.webhookId}`;
|
||||
const redis = getRedis();
|
||||
const lockValue = randomUUID();
|
||||
|
||||
const lockAcquired = await acquireLock(redis, lockKey, lockValue);
|
||||
if (!lockAcquired) {
|
||||
await db.webhookCall.update({
|
||||
where: { id: call.id },
|
||||
data: {
|
||||
nextAttemptAt: new Date(Date.now() + WEBHOOK_LOCK_RETRY_DELAY_MS),
|
||||
status: WebhookCallStatus.PENDING,
|
||||
},
|
||||
});
|
||||
// Let BullMQ handle retry timing; this records observability.
|
||||
throw new Error("Webhook lock not acquired");
|
||||
}
|
||||
|
||||
try {
|
||||
const body = buildPayload(call, attempt);
|
||||
const { responseStatus, responseTimeMs, responseText } = await postWebhook({
|
||||
url: call.webhook.url,
|
||||
secret: call.webhook.secret,
|
||||
type: call.type,
|
||||
callId: call.id,
|
||||
body,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Webhook call ${call.id} completed successfully, response status: ${responseStatus}, response time: ${responseTimeMs}ms, `,
|
||||
);
|
||||
|
||||
await db.$transaction([
|
||||
db.webhookCall.update({
|
||||
where: { id: call.id },
|
||||
data: {
|
||||
status: WebhookCallStatus.DELIVERED,
|
||||
attempt,
|
||||
responseStatus,
|
||||
responseTimeMs,
|
||||
lastError: null,
|
||||
nextAttemptAt: null,
|
||||
responseText,
|
||||
},
|
||||
}),
|
||||
db.webhook.update({
|
||||
where: { id: call.webhookId },
|
||||
data: {
|
||||
consecutiveFailures: 0,
|
||||
lastSuccessAt: new Date(),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown webhook error";
|
||||
const responseStatus =
|
||||
error instanceof WebhookHttpError ? error.statusCode : null;
|
||||
const responseTimeMs =
|
||||
error instanceof WebhookHttpError ? error.responseTimeMs : null;
|
||||
const responseText =
|
||||
error instanceof WebhookHttpError ? error.responseText : null;
|
||||
|
||||
const nextAttemptAt =
|
||||
attempt < WEBHOOK_MAX_ATTEMPTS
|
||||
? new Date(Date.now() + computeBackoff(attempt))
|
||||
: null;
|
||||
|
||||
const updatedWebhook = await db.webhook.update({
|
||||
where: { id: call.webhookId },
|
||||
data: {
|
||||
consecutiveFailures: {
|
||||
increment: 1,
|
||||
},
|
||||
lastFailureAt: new Date(),
|
||||
status:
|
||||
call.webhook.consecutiveFailures + 1 >= WEBHOOK_AUTO_DISABLE_THRESHOLD
|
||||
? WebhookStatus.AUTO_DISABLED
|
||||
: call.webhook.status,
|
||||
},
|
||||
});
|
||||
|
||||
await db.webhookCall.update({
|
||||
where: { id: call.id },
|
||||
data: {
|
||||
status:
|
||||
attempt >= WEBHOOK_MAX_ATTEMPTS
|
||||
? WebhookCallStatus.FAILED
|
||||
: WebhookCallStatus.PENDING,
|
||||
attempt,
|
||||
nextAttemptAt,
|
||||
lastError: errorMessage,
|
||||
responseStatus: responseStatus ?? undefined,
|
||||
responseTimeMs: responseTimeMs ?? undefined,
|
||||
responseText: responseText ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const statusLabel =
|
||||
updatedWebhook.status === WebhookStatus.AUTO_DISABLED
|
||||
? "auto-disabled"
|
||||
: "failed";
|
||||
|
||||
logger.warn(
|
||||
{
|
||||
callId: call.id,
|
||||
webhookId: call.webhookId,
|
||||
statusLabel,
|
||||
attempt,
|
||||
responseStatus,
|
||||
nextAttemptAt,
|
||||
error: errorMessage,
|
||||
},
|
||||
"[WebhookQueueService]: Webhook call failure",
|
||||
);
|
||||
|
||||
if (updatedWebhook.status === WebhookStatus.AUTO_DISABLED) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
await releaseLock(redis, lockKey, lockValue);
|
||||
}
|
||||
}
|
||||
|
||||
async function acquireLock(
|
||||
redis: ReturnType<typeof getRedis>,
|
||||
key: string,
|
||||
value: string,
|
||||
) {
|
||||
const result = await redis.set(key, value, "PX", WEBHOOK_LOCK_TTL_MS, "NX");
|
||||
return result === "OK";
|
||||
}
|
||||
|
||||
async function releaseLock(
|
||||
redis: ReturnType<typeof getRedis>,
|
||||
key: string,
|
||||
value: string,
|
||||
) {
|
||||
const script = `
|
||||
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
||||
return redis.call("DEL", KEYS[1])
|
||||
else
|
||||
return 0
|
||||
end
|
||||
`;
|
||||
try {
|
||||
await redis.eval(script, 1, key, value);
|
||||
} catch (error) {
|
||||
logger.error({ error }, "[WebhookQueueService]: Failed to release lock");
|
||||
}
|
||||
}
|
||||
|
||||
function computeBackoff(attempt: number) {
|
||||
const base = WEBHOOK_BASE_BACKOFF_MS * Math.pow(2, attempt - 1);
|
||||
const jitter = base * 0.3 * Math.random();
|
||||
return base + jitter;
|
||||
}
|
||||
|
||||
type WebhookPayload = {
|
||||
id: string;
|
||||
type: string;
|
||||
version: string | null;
|
||||
createdAt: string;
|
||||
teamId: number;
|
||||
data: unknown;
|
||||
attempt: number;
|
||||
};
|
||||
|
||||
function buildPayload(
|
||||
call: {
|
||||
id: string;
|
||||
webhookId: string;
|
||||
teamId: number;
|
||||
type: string;
|
||||
payload: string;
|
||||
createdAt: Date;
|
||||
webhook: { apiVersion: string | null };
|
||||
},
|
||||
attempt: number,
|
||||
): WebhookPayload {
|
||||
let parsed: unknown = call.payload;
|
||||
try {
|
||||
parsed = JSON.parse(call.payload);
|
||||
} catch {
|
||||
// keep string payload as-is
|
||||
}
|
||||
|
||||
return {
|
||||
id: call.id,
|
||||
type: call.type,
|
||||
version: call.webhook.apiVersion ?? WEBHOOK_EVENT_VERSION,
|
||||
createdAt: call.createdAt.toISOString(),
|
||||
teamId: call.teamId,
|
||||
data: parsed,
|
||||
attempt,
|
||||
};
|
||||
}
|
||||
|
||||
class WebhookHttpError extends Error {
|
||||
public statusCode: number | null;
|
||||
public responseTimeMs: number | null;
|
||||
public responseText: string | null;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
statusCode: number | null,
|
||||
responseTimeMs: number | null,
|
||||
responseText: string | null,
|
||||
) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
this.responseTimeMs = responseTimeMs;
|
||||
this.responseText = responseText;
|
||||
}
|
||||
}
|
||||
|
||||
async function postWebhook(params: {
|
||||
url: string;
|
||||
secret: string;
|
||||
type: string;
|
||||
callId: string;
|
||||
body: WebhookPayload;
|
||||
}) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
WEBHOOK_REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
const stringBody = JSON.stringify(params.body);
|
||||
const timestamp = Date.now().toString();
|
||||
const signature = signBody(params.secret, timestamp, stringBody);
|
||||
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "UseSend-Webhook/1.0",
|
||||
"X-UseSend-Event": params.type,
|
||||
"X-UseSend-Call": params.callId,
|
||||
"X-UseSend-Timestamp": timestamp,
|
||||
"X-UseSend-Signature": signature,
|
||||
"X-UseSend-Retry": params.body.attempt > 1 ? "true" : "false",
|
||||
};
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(params.url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: stringBody,
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const responseTimeMs = Date.now() - start;
|
||||
const responseText = await captureResponseText(response);
|
||||
if (response.ok) {
|
||||
return {
|
||||
responseStatus: response.status,
|
||||
responseTimeMs,
|
||||
responseText,
|
||||
};
|
||||
}
|
||||
|
||||
throw new WebhookHttpError(
|
||||
`Non-2xx response: ${response.status}`,
|
||||
response.status,
|
||||
responseTimeMs,
|
||||
responseText,
|
||||
);
|
||||
} catch (error) {
|
||||
const responseTimeMs = Date.now() - start;
|
||||
if (error instanceof WebhookHttpError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
throw new WebhookHttpError(
|
||||
"Webhook request timed out",
|
||||
null,
|
||||
responseTimeMs,
|
||||
null,
|
||||
);
|
||||
}
|
||||
throw new WebhookHttpError(
|
||||
error instanceof Error ? error.message : "Unknown fetch error",
|
||||
null,
|
||||
responseTimeMs,
|
||||
null,
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function signBody(secret: string, timestamp: string, body: string) {
|
||||
const hmac = createHmac("sha256", secret);
|
||||
hmac.update(`${timestamp}.${body}`);
|
||||
return `v1=${hmac.digest("hex")}`;
|
||||
}
|
||||
|
||||
async function captureResponseText(response: Response) {
|
||||
const contentType = response.headers.get("content-type");
|
||||
const isText =
|
||||
contentType?.startsWith("text/") ||
|
||||
contentType?.includes("application/json") ||
|
||||
contentType?.includes("application/xml");
|
||||
|
||||
if (!isText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentLengthHeader = response.headers.get("content-length");
|
||||
const contentLength = contentLengthHeader
|
||||
? Number.parseInt(contentLengthHeader, 10)
|
||||
: null;
|
||||
|
||||
if (contentLength && Number.isFinite(contentLength)) {
|
||||
if (contentLength <= 0) {
|
||||
return "";
|
||||
}
|
||||
if (contentLength > WEBHOOK_RESPONSE_TEXT_LIMIT * 2) {
|
||||
return `<omitted: content-length ${contentLength} exceeds limit ${WEBHOOK_RESPONSE_TEXT_LIMIT}>`;
|
||||
}
|
||||
}
|
||||
|
||||
const body = response.body;
|
||||
|
||||
if (body && typeof body.getReader === "function") {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let received = 0;
|
||||
let chunks = "";
|
||||
let truncated = false;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (value) {
|
||||
const decoded = decoder.decode(value, { stream: true });
|
||||
received += decoded.length;
|
||||
if (received > WEBHOOK_RESPONSE_TEXT_LIMIT) {
|
||||
const sliceRemaining =
|
||||
WEBHOOK_RESPONSE_TEXT_LIMIT - (received - decoded.length);
|
||||
chunks += decoded.slice(0, Math.max(0, sliceRemaining));
|
||||
truncated = true;
|
||||
await reader.cancel();
|
||||
break;
|
||||
} else {
|
||||
chunks += decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (truncated) {
|
||||
return `${chunks}...<truncated>`;
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (text.length > WEBHOOK_RESPONSE_TEXT_LIMIT) {
|
||||
return `${text.slice(0, WEBHOOK_RESPONSE_TEXT_LIMIT)}...<truncated>`;
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
@@ -11,5 +11,6 @@ export default {
|
||||
"./src/**/*.tsx",
|
||||
path.join(here, "../../packages/ui/src/**/*.{ts,tsx}"),
|
||||
path.join(here, "../../packages/email-editor/src/**/*.{ts,tsx}"),
|
||||
path.join(here, "../../packages/lib/src/**/*.{ts,tsx}"),
|
||||
],
|
||||
} satisfies Config;
|
||||
|
||||
Reference in New Issue
Block a user