Skip to content

Commit ec1bbaa

Browse files
author
Rajat
committed
Mailgun + SendGrid bounces and complains webhook
1 parent 24e91af commit ec1bbaa

225 files changed

Lines changed: 40359 additions & 1307 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Copy this file to .env before starting the stack. Do not commit .env.
2+
# Generate secrets with: openssl rand -base64 48
3+
POSTGRES_PASSWORD=
4+
BETTER_AUTH_SECRET=
5+
PIXEL_SIGNING_SECRET=
6+
# Generate this one with: openssl rand -base64 32
7+
ESP_CREDENTIALS_ENCRYPTION_KEY=
8+
9+
# The initial account and its default team are created by the one-shot init
10+
# service. Its API key is shown exactly once in `docker compose logs init`.
11+
SUPER_ADMIN_EMAIL=admin@example.com
12+
13+
# Public origins. For a local installation, keep these defaults. For a public
14+
# deployment, use the externally reachable HTTPS origins and set PROTOCOL=https.
15+
API_PUBLIC_URL=http://localhost:5000
16+
WEB_CLIENT=http://localhost:3000
17+
PROTOCOL=http
18+
DOMAIN=localhost:5000
19+
20+
# Optional published host ports.
21+
API_PORT=5000
22+
WEB_PORT=3000
23+
24+
# Optional platform SMTP, used for sign-in OTP emails. Team campaign delivery
25+
# credentials are configured in the SendLit dashboard instead.
26+
EMAIL_HOST=
27+
EMAIL_PORT=587
28+
EMAIL_USER=
29+
EMAIL_PASS=
30+
EMAIL_FROM=
31+
32+
# Optional integrations and operational settings.
33+
AUTH_COOKIE_DOMAIN=
34+
ENABLE_TRUST_PROXY=false
35+
PROVISIONING_SECRET=
36+
MEDIALIT_APIKEY=
37+
MEDIALIT_SERVER=https://api.medialit.cloud
38+
MAX_UPLOAD_SIZE=10485760

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Dependencies
22
node_modules/
3+
.npmrc
34

45
# Build
56
dist/

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
- Don't duplicate stuff over and over. Re-use existing code and libraries.
44
- While making changes to the `apps/api` directory, make sure the REST API documentation and MCP server are updated as well.
5-
- For UI components, use shadcn/ui exclusively.
5+
- For UI components, use shadcn/ui exclusively. Always use Shadcn CLI for installing components. Never hand roll standard Shadcn components. Prefer shadcn/ui components over browser-native components.
66

77
## Architecture Tips
88

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,29 @@ analytics, bounce handling and multi-user accounts are still on the roadmap.
4545
4. Build the two shared packages at least once so `apps/web` has something to
4646
import: `pnpm --filter @sendlit/email-editor build && pnpm --filter @sendlit/email-blocks build`
4747
(re-run, or use their `dev` scripts, after changing either package).
48+
49+
## Self-hosting with Docker Compose
50+
51+
The root Compose stack runs PostgreSQL, Redis, the API, and the web dashboard.
52+
It also uses a one-shot `init` service to apply database migrations and create
53+
the first account, its default team, and a team-scoped API key.
54+
55+
```sh
56+
cp .env.example .env
57+
# Set the required secrets in .env (commands are included as comments there).
58+
docker compose up --build -d
59+
docker compose logs init
60+
```
61+
62+
Set `SUPER_ADMIN_EMAIL` before the first start. The `init` logs contain the
63+
initial API key exactly once; save it in a password manager and use it as the
64+
`x-sendlit-apikey` header. If it is lost, create a replacement in the dashboard
65+
or through the authenticated API. Open the dashboard at `WEB_CLIENT` (by
66+
default, `http://localhost:3000`) and API documentation at `API_PUBLIC_URL/docs`.
67+
68+
For an internet-facing deployment, set `API_PUBLIC_URL`, `WEB_CLIENT`,
69+
`PROTOCOL=https`, and `DOMAIN` to the public values before the first start.
70+
Put the API and web ports behind a TLS reverse proxy; set `ENABLE_TRUST_PROXY=true`
71+
when that proxy forwards client IPs. Back up the `postgres-data` volume and
72+
keep the `.env` secrets stable: changing the ESP encryption key makes stored
73+
team SMTP credentials unreadable.

apps/api/.env.example

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@ DB_CONNECTION_STRING=postgres://sendlit:sendlit@localhost:5432/sendlit
55
REDIS_HOST=localhost
66
REDIS_PORT=6379
77

8-
PORT=4000
8+
PORT=5000
99
NODE_ENV=development
1010

11+
# Public URL Better Auth uses for OAuth callbacks, issuers and trusted origins.
12+
API_PUBLIC_URL=http://localhost:5000
13+
1114
# OAuth2 JWT signing key — must be at least 32 bytes. Generate with:
1215
# openssl rand -base64 48
1316
OAUTH_SIGNING_KEY=
@@ -37,6 +40,11 @@ ESP_CREDENTIALS_ENCRYPTION_KEY=
3740
PROTOCOL=https
3841
DOMAIN=
3942

43+
# HMAC key for the suppression-list recipient fingerprint (never an unsalted
44+
# hash — see docs/bounces-and-complaints.md#8-suppression-model). At least
45+
# 32 bytes. Generate with: openssl rand -base64 32
46+
SUPPRESSION_HASH_KEY=
47+
4048
# Platform mail transport for system email such as login OTPs. Campaign,
4149
# broadcast, sequence and ESP test mail require each team to configure its own
4250
# ESP in settings.
@@ -75,3 +83,7 @@ DEPLOY_ENV=
7583
# MediaLit integration for file uploads
7684
MEDIALIT_APIKEY=
7785
MEDIALIT_SERVER=https://api.medialit.cloud
86+
87+
# Credentials for sign in with login
88+
GOOGLE_CLIENT_ID=
89+
GOOGLE_CLIENT_SECRET=

apps/api/README.md

Lines changed: 95 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ email provider configuration.
1212
broadcasts.
1313
- Email delivery through BullMQ and nodemailer, using each team's configured
1414
ESP.
15+
- Bounce and complaint webhook ingestion (Resend, Postmark) with a durable
16+
receipt inbox, canonical delivery events, and a per-workspace suppression
17+
list enforced on every send path.
1518
- Open and click tracking, plus unsubscribe handling.
1619
- Better Auth session login, OAuth2 bearer-token authentication, and
1720
team-scoped API key authentication.
@@ -44,20 +47,24 @@ migrations before starting the API. The API image includes:
4447
- `apps/api/drizzle` — generated migration SQL and metadata;
4548
- `apps/api/dist/db/migrate.js` — a one-shot migration runner.
4649

47-
The single-server deployment runs this through a Compose `migrate` service:
50+
The root self-hosted deployment runs this through a Compose `init` service:
4851

4952
```sh
50-
cd single-server-setup/sendlit
51-
docker compose up
53+
cp .env.example .env
54+
# Fill in the required secrets and SUPER_ADMIN_EMAIL.
55+
docker compose up --build -d
56+
docker compose logs init
5257
```
5358

5459
Startup order:
5560

5661
1. Postgres starts and passes its health check.
57-
2. `sendlit-migrate` runs `node apps/api/dist/db/migrate.js`.
58-
3. The migration container exits successfully.
59-
4. `sendlit-api` starts.
60-
5. `sendlit-web` starts.
62+
2. Redis starts and passes its health check.
63+
3. `init` runs `node apps/api/dist/db/migrate.js`, then creates the configured
64+
super-admin account, default team, and one-time API key.
65+
4. The init container exits successfully.
66+
5. `api` starts.
67+
6. `web` starts.
6168

6269
This mirrors an init-container pattern. The API process itself does not apply
6370
migrations on every boot; it only checks that the database is reachable. For
@@ -119,7 +126,7 @@ pnpm --filter @sendlit/api run access-token email@address.com --team team_...
119126
The command prints only the access token. Use it in REST requests as:
120127
121128
```sh
122-
curl http://localhost:4000/contacts \
129+
curl http://localhost:5000/contacts \
123130
-H "Authorization: Bearer <token>" \
124131
-H "X-Sendlit-Team-Id: team_..."
125132
```
@@ -188,12 +195,79 @@ Email automation runs through two loops in `src/automation/start.ts`:
188195
Tag and subscriber-added automations are event-driven through
189196
`automation/fire-event.ts`.
190197

191-
Each team can configure its own SMTP-compatible ESP, such as SendGrid, Mailgun,
192-
Postmark, SES, Resend, or a custom SMTP server. Credentials are encrypted at
193-
rest with AES-256-GCM in `src/utils/secret-crypto.ts` and are never returned to
194-
clients. Campaign, broadcast, sequence, and ESP test mail require the team to
195-
have an ESP configured. Platform SMTP environment variables are reserved for
196-
system email such as login OTPs.
198+
Each team can configure one or more of its own SMTP-compatible ESPs (`esp_configs`),
199+
such as SendGrid, Mailgun, Postmark, SES, Resend, or a custom SMTP server, via
200+
`GET/POST /settings/esps` and `GET/PATCH/DELETE /settings/esps/:espId`. One
201+
user ESP is the team's default; a sequence, broadcast, or transactional send
202+
may pin a different one with `espId`. Credentials are encrypted at rest with
203+
AES-256-GCM in `src/utils/secret-crypto.ts` and are never returned to clients.
204+
`/settings/esp` (singular) remains as a backward-compatible alias over the
205+
team's default user ESP.
206+
207+
Every sequence and transactional email persists an internal `deliveryRoute`
208+
(`custom` or `platform`) alongside the resolved user ESP's internal id
209+
(`outboxId`). `custom` is the only route currently reachable — it always
210+
pins a specific team-owned `esp_configs` row and is required before
211+
transactional mail can be queued or a sequence/broadcast can be marked
212+
active; `platform` is reserved for a future deployment-level SendLit-provided
213+
transport (resolved from deployment configuration, never from `esp_configs`)
214+
and isn't exposed by any endpoint yet. User-managed/custom ESP delivery never
215+
consults or increments the account's SendLit mail quota — those counters are
216+
reserved for the future platform route.
217+
218+
## Bounce And Complaint Feedback
219+
220+
Full design in [`docs/bounces-and-complaints.md`](./docs/bounces-and-complaints.md).
221+
Summary of what's implemented:
222+
223+
- **Outbound ledger.** Every custom-route send (campaign or transactional)
224+
gets an `outbound_messages` row before transport, carrying a generated RFC
225+
`Message-ID`, the pinned ESP snapshot, and (once accepted) the transport's
226+
response. `src/delivery-feedback/outbound-send.ts` /
227+
`outbound-queries.ts`.
228+
- **Feedback connections.** Each user ESP with a reviewed provider adapter
229+
(Resend, Postmark, SendGrid, and Mailgun — see `feedbackCapableProviders` in
230+
`src/config/constants.ts`) can configure its own webhook connection under
231+
`GET/PUT /settings/esps/:espId/feedback`, `POST .../feedback/rotate`,
232+
`POST .../feedback/test`, `DELETE .../feedback`. Every configuration has
233+
its own opaque `whc_...` connection id/URL, credential, and health —
234+
switching the team's default ESP never moves or copies feedback config.
235+
Changing an ESP's provider retires the old connection and a later `PUT`
236+
creates a new one; deleting an eligible ESP retires its connection while
237+
preserving historical events/suppressions.
238+
- **Public webhook route.** `POST /webhooks/esp/:provider/:connectionId`
239+
(`src/delivery-feedback/webhook-route.ts`) is mounted before global JSON
240+
parsing and before any session/API-key middleware — it authenticates
241+
purely via the resolved connection's provider adapter (Svix signature for
242+
Resend, a shared secret for Postmark, ECDSA Signed Event Webhook for
243+
SendGrid, HMAC over timestamp+token for Mailgun), commits a durable,
244+
encrypted receipt (`esp_webhook_receipts`) before returning `200`, and
245+
never itself normalizes the payload inline.
246+
- **Async processing.** A BullMQ `esp-feedback` queue/worker
247+
(`feedback-queue.ts`/`feedback-worker.ts`) plus a recovery poller
248+
(`poller.ts`, 30s interval) drain receipts through
249+
`process-receipt.ts`: provider adapter → canonical `email_delivery_events`
250+
(idempotent per `(connectionId, providerEventKey)`) → correlate to an
251+
`outbound_messages` row (`correlation.ts`) → delivery-state projection
252+
(`projection.ts`) → suppression side effects. A committed receipt is
253+
always the recovery source of truth if Redis is unavailable.
254+
- **Suppression.** `email_suppressions` is workspace-wide and
255+
route-independent, keyed by an HMAC (`SUPPRESSION_HASH_KEY`) over the
256+
normalized address — never derived from `contacts.subscribed`, so it
257+
survives contact deletion/reimport. Checked before enqueue (transactional
258+
`422 recipient_suppressed`; sequence sends skip the recipient without
259+
counting it as sent) and again immediately before transport in the worker.
260+
`GET/POST /suppressions*` exposes the list and owner release action;
261+
complaint suppressions can only be released by a `sendlit_operator` actor.
262+
- **Retention.** `retention.ts` (hourly loop) purges raw receipt payloads
263+
after 30 days and delivery events after 13 months, in bounded batches.
264+
- **Not yet implemented:** the Amazon SES adapter (its own PRD phase,
265+
[`docs/aws-bounces-and-complaints.md`](./docs/aws-bounces-and-complaints.md) —
266+
SNS signature/subscription handling is materially different from a direct
267+
signed webhook); a provider without an adapter is never presented as
268+
feedback-capable. Observability is limited
269+
to the existing `captureEvent`/`captureError` posthog pattern — dedicated
270+
dashboards/alerting and load/chaos testing are deployment-level follow-ups.
197271

198272
## MCP Server
199273

@@ -215,7 +289,13 @@ OAuth client registration, authorization, token, introspection, revocation, and
215289
userinfo endpoints are served by Better Auth under `/api/auth/oauth2/*`.
216290

217291
MCP tools live in `src/mcp/tools/*` and cover contacts, templates, sequences,
218-
ESP settings, teams, and API keys.
292+
ESP settings (both the default-ESP singleton tools and the multi-ESP
293+
collection tools — `list_esps`/`create_esp`/`get_esp`/`update_esp`/
294+
`delete_esp`/`test_esp`), teams, API keys, and bounce/complaint delivery
295+
feedback (`get_esp_feedback_connection`/`upsert_esp_feedback_connection`/
296+
`test_esp_feedback_connection`/`delete_esp_feedback_connection`,
297+
`list_delivery_events`/`get_delivery_event`,
298+
`list_suppressions`/`get_suppression`/`release_suppression`).
219299

220300
## More Context
221301

0 commit comments

Comments
 (0)