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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 99 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

<a href="https://github.com/DeveloperSarim/raymail/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/DeveloperSarim/raymail/ci.yml?branch=main&style=for-the-badge&label=build&color=3FA981&labelColor=0C0C0F"></a>
<a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-E8A33D?style=for-the-badge&labelColor=0C0C0F"></a>
<a href="https://github.com/DeveloperSarim/raymail/releases"><img alt="Version" src="https://img.shields.io/badge/version-1.0.0-F4F4F6?style=for-the-badge&labelColor=0C0C0F"></a>
<a href="https://github.com/DeveloperSarim/raymail/releases"><img alt="Version" src="https://img.shields.io/badge/version-1.1.0-F4F4F6?style=for-the-badge&labelColor=0C0C0F"></a>
<a href="https://stalw.art"><img alt="Stalwart" src="https://img.shields.io/badge/stalwart-v0.16-5B9DD9?style=for-the-badge&labelColor=0C0C0F"></a>
<a href="https://nextjs.org"><img alt="Next.js" src="https://img.shields.io/badge/next.js-15-B4B4C0?style=for-the-badge&labelColor=0C0C0F"></a>

Expand All @@ -26,7 +26,7 @@

<br><br>

**[Quick start](#-quick-start)** · **[Features](#-features)** · **[Architecture](#-architecture)** · **[Configuration](#%EF%B8%8F-configuration)** · **[Deliverability](#-deliverability)** · **[Contributing](#-contributing)** · **[Discussions](https://github.com/DeveloperSarim/raymail/discussions)**
**[Quick start](#-quick-start)** · **[Features](#-features)** · **[Webhooks](#-webhooks)** · **[Architecture](#-architecture)** · **[Configuration](#%EF%B8%8F-configuration)** · **[Deliverability](#-deliverability)** · **[Contributing](#-contributing)** · **[Discussions](https://github.com/DeveloperSarim/raymail/discussions)**

</div>

Expand Down Expand Up @@ -135,6 +135,7 @@ sudo ./deploy/setup-tls.sh
- Open/click rates, bounce tracking
- Per-message audit trail with IP and user agent
- Document vault indexing every attachment
- **Signed outbound webhooks** with retries
- All of it in local SQLite

</td>
Expand Down Expand Up @@ -175,6 +176,89 @@ sudo ./deploy/setup-tls.sh

---

## 🔔 Webhooks

Telemetry that cannot leave the box is only useful to someone staring at a
dashboard. RayMail pushes every delivery event to your own systems — a bounce
suppresses a contact in your CRM, a click notifies Slack, a delivery closes the
loop in your warehouse.

| Event | Fires when |
|---|---|
| `message.sent` | Accepted by the MTA for delivery |
| `message.delivered` | The receiving server accepted it |
| `message.opened` | The tracking pixel was loaded |
| `message.clicked` | A tracked link was followed |
| `message.bounced` | Permanently rejected |

Add an endpoint under **Admin → Webhooks**, pick your events, and store the
signing secret it shows you once.

<details>
<summary><b>Payload and signature verification</b></summary>

Every request carries a Stripe-style signature header:

```
X-RayMail-Event: message.opened
X-RayMail-Signature: t=1699999999,v1=6f3a...
Content-Type: application/json
```

```json
{
"id": "evt_9f2c1a7b4e6d8c0a3b5f",
"type": "message.opened",
"createdAt": "2026-09-03T09:14:22.108Z",
"data": {
"trackedId": "df4acac7ffc44ab8b16e",
"occurredAt": "2026-09-03T09:14:22.108Z",
"ip": "182.189.95.30",
"userAgent": "Mozilla/5.0 ...",
"openCount": 2
}
}
```

Verify before trusting anything in it. The timestamp is inside the signed
material, so a captured request cannot be replayed later:

```js
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(secret, rawBody, header, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
if (!t || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;

const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(parts.v1);
return a.length === b.length && timingSafeEqual(a, b);
}
```

Use the **raw** request body, not a re-serialised object — key order changes the hash.

**Delivery guarantees.** Events are queued to SQLite, never sent inline, so a
slow receiver can never delay a tracking pixel. Failures retry five times with
backoff (1m, 5m, 25m, 2h, 10h) before being marked failed, and every attempt is
visible in the admin panel with its response code.

Drain on a schedule if you want sub-minute delivery:

```
* * * * * curl -s -X POST http://127.0.0.1:3880/api/webhooks/drain
```

**A note on the URL.** Endpoints are resolved before being accepted and refused
if they point at a private or loopback address — otherwise a signed-in user
could turn the server into a proxy into your internal network.

</details>

---

## 🏗 Architecture

```mermaid
Expand Down Expand Up @@ -357,17 +441,29 @@ Contributions are welcome — issues, features and documentation alike.

- Server-side JMAP search (the list currently filters client-side)
- Bounce ingestion from the Stalwart queue into the telemetry pipeline
- Webhook event replay from the admin panel
- Multi-account support in the webmail
- A nginx and a Caddy variant of `deploy/setup-tls.sh`
- Thread grouping in the message list

</details>

### Contributors

<div align="center">
<br>

<a href="https://github.com/DeveloperSarim/raymail/graphs/contributors">
<img src="https://contrib.rocks/image?repo=DeveloperSarim/raymail" alt="Contributors to RayMail" />
</a>

<br><br>

<a href="https://github.com/DeveloperSarim/raymail/graphs/contributors"><img alt="Contributors" src="https://img.shields.io/github/contributors/DeveloperSarim/raymail?style=for-the-badge&color=E8A33D&labelColor=0C0C0F"></a>
<a href="https://github.com/DeveloperSarim/raymail/pulls"><img alt="Pull requests welcome" src="https://img.shields.io/badge/PRs-welcome-3FA981?style=for-the-badge&labelColor=0C0C0F"></a>
<a href="https://github.com/DeveloperSarim/raymail/commits/main"><img alt="Last commit" src="https://img.shields.io/github/last-commit/DeveloperSarim/raymail?style=for-the-badge&color=5B9DD9&labelColor=0C0C0F"></a>

<sub>Your avatar goes here — see <a href="CONTRIBUTING.md">CONTRIBUTING.md</a>.</sub>

</div>

---
Expand Down
8 changes: 6 additions & 2 deletions web/src/app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
ArrowLeft, HardDrive, Search, Sparkles, Activity, Send, Download,
FileText, Image as ImageIcon, RefreshCw, Server,
FileText, Image as ImageIcon, RefreshCw, Server, Webhook,
} from "lucide-react";
import { Logo } from "@/components/Logo";
import { StageBadge } from "@/components/ui/StageBadge";
import { MetricTile, Funnel, Engagement } from "@/components/admin/Charts";
import { ServerConsole } from "@/components/admin/ServerConsole";
import { WebhooksPanel } from "@/components/admin/WebhooksPanel";
import { AttachmentPreview, isPreviewable } from "@/components/AttachmentPreview";
import { useAiStatus } from "@/hooks/useAi";
import { bytes, relativeDate } from "@/lib/format";
Expand All @@ -36,12 +37,13 @@ async function get<T>(url: string): Promise<T> {
return res.json() as Promise<T>;
}

type Tab = "overview" | "delivery" | "vault" | "server";
type Tab = "overview" | "delivery" | "vault" | "webhooks" | "server";

const TABS: { id: Tab; label: string; icon: typeof Activity }[] = [
{ id: "overview", label: "Overview", icon: Activity },
{ id: "delivery", label: "Delivery", icon: Send },
{ id: "vault", label: "Vault", icon: HardDrive },
{ id: "webhooks", label: "Webhooks", icon: Webhook },
{ id: "server", label: "Mail server", icon: Server },
];

Expand Down Expand Up @@ -340,6 +342,8 @@ export default function Admin() {
</section>
)}

{tab === "webhooks" && <WebhooksPanel />}

{tab === "server" && <ServerConsole />}
</div>

Expand Down
9 changes: 9 additions & 0 deletions web/src/app/api/send/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { listMailboxes, sendEmail } from "@/services/jmap";
import { prepareOutgoingHtml } from "@/lib/outgoing";
import { newTrackingId } from "@/lib/telemetry";
import { db } from "@/lib/db";
import { enqueue } from "@/lib/webhooks";
import type { EmailAddress } from "@/types/mail";

export const dynamic = "force-dynamic";
Expand Down Expand Up @@ -72,5 +73,13 @@ export async function POST(req: Request) {
SET stage='sent', message_id=?, submission_id=?, last_event_at=? WHERE id=?`,
).run(result.emailId, result.submissionId, new Date().toISOString(), trackedId);

enqueue("message.sent", {
trackedId,
messageId: result.emailId,
subject: body.subject,
recipients: recipients.map((r) => r.email),
sentAt: now,
});

return NextResponse.json({ ok: true, trackedId, emailId: result.emailId });
}
6 changes: 6 additions & 0 deletions web/src/app/api/t/c/[token]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { db, advanceStage } from "@/lib/db";
import { readClickToken } from "@/lib/telemetry";
import type { DeliveryStage } from "@/types/telemetry";
import { enqueue } from "@/lib/webhooks";

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -38,6 +39,11 @@ export async function GET(
SET click_count = click_count + 1, stage = ?, last_event_at = ?
WHERE id = ?`,
).run(advanceStage(row.stage as DeliveryStage, "clicked"), now, parsed.id);

enqueue("message.clicked", {
trackedId: parsed.id, occurredAt: now, url: parsed.url,
ip, userAgent: req.headers.get("user-agent"),
});
}
} catch {
// Never strand the recipient because telemetry failed.
Expand Down
8 changes: 8 additions & 0 deletions web/src/app/api/t/o/[token]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { db, advanceStage } from "@/lib/db";
import { readOpenToken } from "@/lib/telemetry";
import { PIXEL_GIF } from "@/lib/outgoing";
import { enqueue } from "@/lib/webhooks";
import type { DeliveryStage } from "@/types/telemetry";

export const dynamic = "force-dynamic";
Expand Down Expand Up @@ -40,6 +41,13 @@ export async function GET(
last_event_at = ?
WHERE id = ?`,
).run(advanceStage(row.stage as DeliveryStage, "opened"), now, now, trackedId);

// Queued, never sent inline: the pixel has to return in milliseconds
// regardless of how slow the receiver is.
enqueue("message.opened", {
trackedId, occurredAt: now, ip, userAgent: ua,
openCount: row.open_count + 1,
});
}
} catch {
// Telemetry is best-effort; never fail the image response.
Expand Down
6 changes: 6 additions & 0 deletions web/src/app/api/telemetry/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { requireSession } from "@/lib/guard";
import { db, advanceStage } from "@/lib/db";
import { getSubmissionStatus } from "@/services/jmap";
import { enqueue } from "@/lib/webhooks";
import type { TelemetrySummary, TrackedMessage } from "@/types/telemetry";

export const dynamic = "force-dynamic";
Expand Down Expand Up @@ -36,13 +37,18 @@ export async function GET(req: Request) {
d.prepare(
`INSERT INTO telemetry_event (tracked_id, type, occurred_at) VALUES (?, 'delivered', ?)`,
).run(p.id, now);
enqueue("message.delivered", { trackedId: p.id, occurredAt: now });
} else if (st === "no") {
d.prepare(
"UPDATE tracked_message SET stage='bounced', bounce_reason=?, last_event_at=? WHERE id=?",
).run("Rejected by the receiving server", now, p.id);
d.prepare(
`INSERT INTO telemetry_event (tracked_id, type, occurred_at) VALUES (?, 'bounced', ?)`,
).run(p.id, now);
enqueue("message.bounced", {
trackedId: p.id, occurredAt: now,
reason: "Rejected by the receiving server",
});
}
}
}
Expand Down
27 changes: 27 additions & 0 deletions web/src/app/api/webhooks/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { requireSession } from "@/lib/guard";
import { setEndpointEnabled, deleteEndpoint } from "@/lib/webhooks";

export const dynamic = "force-dynamic";

export async function PATCH(req: Request, ctx: { params: Promise<{ id: string }> }) {
const auth = await requireSession();
if (!auth.ok) return auth.response;

const { id } = await ctx.params;
const { enabled } = (await req.json()) as { enabled?: boolean };
if (typeof enabled !== "boolean") {
return NextResponse.json({ error: "enabled must be a boolean" }, { status: 400 });
}
setEndpointEnabled(id, enabled);
return NextResponse.json({ ok: true });
}

export async function DELETE(_req: Request, ctx: { params: Promise<{ id: string }> }) {
const auth = await requireSession();
if (!auth.ok) return auth.response;

const { id } = await ctx.params;
deleteEndpoint(id);
return NextResponse.json({ ok: true });
}
36 changes: 36 additions & 0 deletions web/src/app/api/webhooks/drain/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { requireSession } from "@/lib/guard";
import { drain, enqueue } from "@/lib/webhooks";

export const dynamic = "force-dynamic";
// Delivering a batch can outlive the default budget when receivers are slow.
export const maxDuration = 60;

/** Delivers everything currently due.
*
* Called by the admin UI, and intended to be called on a schedule:
* * * * * * curl -s -X POST http://127.0.0.1:3880/api/webhooks/drain
*
* `?test=1` first queues a synthetic event, so an operator can prove an
* endpoint works without waiting for real mail. */
export async function POST(req: Request) {
const auth = await requireSession();
if (!auth.ok) return auth.response;

if (new URL(req.url).searchParams.get("test") === "1") {
const queued = enqueue("message.sent", {
trackedId: "test_0000000000000000",
subject: "RayMail webhook test",
recipients: ["test@example.com"],
sentAt: new Date().toISOString(),
test: true,
});
if (queued === 0) {
return NextResponse.json(
{ error: "No enabled endpoint is subscribed to message.sent" }, { status: 400 },
);
}
}

return NextResponse.json(await drain(50));
}
Loading
Loading