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
135 changes: 135 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# AGENTS.md — ByteSend

Guidelines for AI coding agents (Claude Code, Copilot, etc.) working in this repository.

---

## Repository Overview

ByteSend is a self-hostable transactional and marketing email platform built as a pnpm monorepo.

| App / Package | Path | Purpose |
|---|---|---|
| Web app | `apps/web/` | Next.js 15 dashboard + tRPC API + Hono public REST API |
| SMTP server | `apps/smtp-server/` | Lightweight SMTP bridge that proxies to the ByteSend API |
| Docs | `apps/docs/` | Product documentation site |
| TypeScript SDK | `packages/sdk/` | Official TS/JS client library |
| Python SDK | `packages/python-sdk/` | Official Python client library |
| Go SDK | `packages/go-sdk/` | Official Go client library |
| ESLint config | `packages/eslint-config/` | Shared lint rules |

**Primary stack (web app):** Next.js · TypeScript · tRPC · Hono · Prisma · PostgreSQL · Redis · AWS SES · NextAuth v4

---

## Development Commands

```bash
pnpm install # install all workspace deps
pnpm dev # start all apps in watch mode
pnpm build # production build
pnpm lint # eslint across all packages
pnpm test:web # run web unit tests (vitest)
pnpm test:web:all # unit + integration tests
pnpm db:migrate-dev # apply pending Prisma migrations
pnpm db:studio # open Prisma Studio
pnpm dx:up # start local Docker infra (Postgres, Redis, etc.)
pnpm dx:down # stop local Docker infra
```

All commands run from the repo root via Turbo unless otherwise noted.

---

## Architecture Notes

### Web app (`apps/web/src/`)

```
server/
api/ tRPC routers and context (trpc.ts is the source of truth for auth layers)
public-api/ Hono REST API — authenticated via API keys (Bearer token)
service/ Business logic (LimitService, EmailService, etc.)
aws/ SES + SNS clients
auth.ts NextAuth configuration (providers, session/jwt callbacks)
db.ts Prisma client singleton

app/
(dashboard)/ Authenticated Next.js App Router pages
(marketing)/ Public marketing pages
api/ Next.js API routes (auth, webhooks, etc.)
```

### Auth layers (tRPC, in order of strictness)

1. `publicProcedure` — no auth
2. `authedProcedure` — valid session + not banned (DB check on every call)
3. `protectedProcedure` — extends authed
4. `twoFactorProtectedProcedure` — extends protected, requires 2FA cookie
5. `teamProcedure` — extends protected, resolves active team from `x-team-id` header
6. `teamAdminProcedure` — team ADMIN role required
7. `adminProcedure` — platform admin required (`isAdmin` flag or env var)
8. `founderProcedure` — founder only (`FOUNDER_EMAIL` env var)

### Public REST API (Hono)

All routes authenticate via `getTeamFromToken()` in `src/server/public-api/auth.ts`. This validates the `bs_<clientId>_<token>` API key format and checks that no ADMIN team member is banned.

### Session / JWT

- `isBanned`, `isAdmin`, `isFounder`, `isEnvAdmin`, `isBetaUser` are all surfaced on `session.user`
- `isBanned` is also embedded in the JWT so the Edge middleware can redirect without a DB call
- The middleware (`middleware.ts`) protects `/dashboard`, `/broadcasts`, `/campaigns`

---

## Key Conventions

- **No comments explaining what code does** — name things clearly instead. Only add a comment for a non-obvious *why* (hidden constraint, workaround, invariant).
- **Conventional Commits** — prefix messages: `fix(scope):`, `feat(scope):`, `chore(scope):`, `docs(scope):`
- **Branch from `develop`** — PRs target `develop`, not `main`
- **tRPC for internal API calls** — do not add raw fetch calls between dashboard and the backend; use tRPC
- **Hono for the public REST API** — do not mix tRPC into the public API surface
- **Prisma migrations** — always generate and commit migration files; never edit the DB directly in production
- **Environment variables** — all vars must be declared in `apps/web/src/env.ts` (t3-env); failing to do so will cause a build error

---

## What NOT to Do

- Do not add `console.log` to production code — use the structured logger (`import { logger } from "~/server/logger/log"`)
- Do not use `db.user.findUnique` without a `select` — always select only the fields you need
- Do not skip `isBanned` checks in new auth paths — every new procedure that accepts a session must sit behind `authedProcedure` or higher
- Do not add fields to Prisma `select` that don't exist in `schema.prisma` — TypeScript will infer the wrong type silently
- Do not bypass the `LimitService` when sending emails — it enforces plan quotas and the `isBlocked` team flag
- Do not create new pages under `(dashboard)/` without ensuring they are protected by the middleware matcher
- Do not run `pnpm install` with `--ignore-scripts` — some AWS SDK packages require postinstall scripts to populate their `commands/` directories

---

## Testing

- Unit tests live alongside source files (`*.unit.test.ts`)
- Integration tests require local Docker infra (`pnpm dx:up` first): `*.integration.test.ts`
- Test runner: Vitest
- Do not mock the database in integration tests — they run against a real local Postgres instance

---

## Reference Docs

Internal references live in `.references/`:

- `smtp-auth-and-operations.md` — SMTP server auth flow
- `webhook-architecture.md` — webhook delivery and retry model
- `notification-integration.md` — in-app notification provider system
- `repository-governance.md` — PR/issue template maintenance checklist
- `release-playbook.md` — how to cut a release

---

## Security

- Report vulnerabilities privately per `.github/SECURITY.md` — do not open public issues for security bugs
- Never commit secrets or `.env` files
- All user-facing input is validated at the tRPC/Hono boundary via Zod schemas
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

---

## [0.3.2] - 2026-06-23

### Added

#### Security & Account Management
- **Ban enforcement across all layers** — the existing `isBanned` flag on `User` is now fully enforced: login is blocked at the `signIn` callback with a redirect to `/login?error=banned`; the JWT token embeds `isBanned` so middleware can gate all protected routes; every `authedProcedure` tRPC call re-validates against the database; and the public REST API blocks requests where the team's admin is banned
- **Banned user page** (`/banned`) — dedicated page shown to banned users with account suspension message and a "Join our Discord" link for false-ban appeals
- **AGENTS.md** — documentation file at the repo root describing project architecture, dev commands, auth layers, conventions, and testing notes for AI coding agents

#### Email Reputation & Deliverability
- **Bounce/complaint rate enforcement** — 7-day rolling hard-bounce rate > 2% or complaint rate > 0.1% (Google/Yahoo thresholds) auto-blocks a team's sending with a Discord alert; warns at 1.5% / 0.08%; minimum volume of 100 emails required before enforcement kicks in; uses `LimitReason.BOUNCE_RATE_EXCEEDED` / `LimitReason.COMPLAINT_RATE_EXCEEDED`
- **RFC 8058 one-click unsubscribe compliance** — `List-Unsubscribe` header now lists the POST-capable one-click endpoint first (required by Gmail/Yahoo), with the click-based `/unsubscribe` URL as a fallback for Outlook; `List-Unsubscribe-Post: List-Unsubscribe=One-Click` header added automatically for marketing emails; the one-click route now also accepts GET requests with a 302 redirect to the user-facing unsubscribe page for click-based mail clients
- **`unsubOneClickUrl` threading** — one-click URL generated per email in `executeEmail()` and passed through `sendRawEmail()` → `buildHeaders()`

#### Notifications
- **Team-scoped bounce/complaint notifications** — SES hook parser now routes bounce and complaint events through `NotificationProviderService.broadcastNotification()` to the team's own configured providers instead of the platform-level admin Discord; notification content omits raw recipient addresses (count only)
- **`ADMIN_DISCORD_WEBHOOK_URL` env var** — optional platform-level observer webhook; when set, `broadcastNotification()` fans out a copy of every team event to this URL after dispatching to team providers; teams cannot register this URL as their own provider
- **Admin webhook collision guard** — `validateConfig()` in `NotificationProviderService` rejects any Discord or Slack webhook URL that matches `ADMIN_DISCORD_WEBHOOK_URL`

#### Admin Panel
- **Domain force-verify** — admins can set a domain's status to `SUCCESS` directly from the Domains admin page (useful for stuck verifications); shield icon button appears on non-verified domains
- **Domain delete** — admins can permanently delete any domain from the Domains admin page
- **Team delete** — permanent team deletion with browser confirmation dialog from the team detail view's new Danger Zone section
- **Extra domain/member slots** — team settings form now exposes `extraDomainSlots` and `extraMemberSlots` fields so admins can grant bonus capacity without changing a team's plan
- **Complimentary plan assignment for all admins** — `adminAssignPlan` no longer requires `isEnvAdmin`; any admin can now assign plans complimentarily (no charge) or generate Stripe checkout links; plan assignment UI visible to all `isAdmin` users (previously only `isEnvAdmin`)
- **Dual plan assignment buttons** — teams page now shows both "Assign complimentary" (immediate, no charge) and "Checkout link" (Stripe) buttons; checkout link button disabled for FREE plan

### Changed
- **`updateTeamSettings`** — removed `isEnvAdmin` guard for plan changes; all admins can update team settings including plan; added `extraDomainSlots` and `extraMemberSlots` to the mutation input and DB update
- **`teamAdminSelection`** — now includes `isActive`, `extraDomainSlots`, and `extraMemberSlots` so the admin UI reflects the full team state
- **`authedProcedure`** — now performs a fresh database ban check on every call rather than relying solely on the JWT token

### Fixed
- **`UpgradeModal` type error** — added missing `CONTACTS`, `BOUNCE_RATE_EXCEEDED`, and `COMPLAINT_RATE_EXCEEDED` entries to the `Record<LimitReason, string>` messages map

---

## [0.3.1] - 2026-06-03

### Added
Expand Down
6 changes: 6 additions & 0 deletions apps/web/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export async function middleware(request: NextRequest) {
const publicPaths = [
"/login",
"/signup",
"/banned",
"/api",
"/auth",
"/",
Expand Down Expand Up @@ -42,6 +43,11 @@ export async function middleware(request: NextRequest) {
return NextResponse.redirect(new URL("/login", request.url));
}

// Redirect banned users before any other checks
if (token.isBanned) {
return NextResponse.redirect(new URL("/banned", request.url));
}

// User is authenticated. Check if they have 2FA enabled.
// We can't query the DB from middleware easily, so we rely on:
// 1. The 2FA cookie being set after verification
Expand Down
57 changes: 52 additions & 5 deletions apps/web/src/app/(dashboard)/admin/domains/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { ChevronLeft, ChevronRight, ShieldCheck, Trash2 } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { Button } from "@bytesend/ui/src/button";
import { Input } from "@bytesend/ui/src/input";
Expand All @@ -15,6 +15,7 @@ import {
TableRow,
} from "@bytesend/ui/src/table";
import { Badge } from "@bytesend/ui/src/badge";
import { toast } from "@bytesend/ui/src/toaster";
import { api } from "~/trpc/react";
import { isCloud } from "~/utils/common";
import { DomainStatusBadge } from "../../domains/domain-badge";
Expand All @@ -34,6 +35,22 @@ export default function AdminDomainsPage() {
{ placeholderData: (prev) => prev },
);

const forceVerify = api.admin.adminForceVerifyDomain.useMutation({
onSuccess: () => {
toast.success("Domain force-verified");
void domainsQuery.refetch();
},
onError: (error) => toast.error(error.message ?? "Failed to verify domain"),
});

const deleteDomain = api.admin.adminDeleteDomain.useMutation({
onSuccess: () => {
toast.success("Domain deleted");
void domainsQuery.refetch();
},
onError: (error) => toast.error(error.message ?? "Failed to delete domain"),
});

if (!isCloud()) {
return (
<div className="rounded-lg border bg-muted/30 p-6 text-sm text-muted-foreground">
Expand Down Expand Up @@ -81,25 +98,26 @@ export default function AdminDomainsPage() {
<TableHead>Region</TableHead>
<TableHead>Status</TableHead>
<TableHead>Tracking</TableHead>
<TableHead className="rounded-tr-xl">Added</TableHead>
<TableHead>Added</TableHead>
<TableHead className="rounded-tr-xl text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{domainsQuery.isLoading ? (
<TableRow className="h-32">
<TableCell colSpan={6} className="py-4 text-center">
<TableCell colSpan={7} className="py-4 text-center">
<Spinner className="mx-auto h-6 w-6" innerSvgClass="stroke-primary" />
</TableCell>
</TableRow>
) : domainsQuery.isError ? (
<TableRow className="h-32">
<TableCell colSpan={6} className="py-4 text-center text-destructive">
<TableCell colSpan={7} className="py-4 text-center text-destructive">
Failed to load domains.
</TableCell>
</TableRow>
) : !domainsQuery.data?.domains.length ? (
<TableRow className="h-32">
<TableCell colSpan={6} className="py-4 text-center">
<TableCell colSpan={7} className="py-4 text-center">
No domains found.
</TableCell>
</TableRow>
Expand Down Expand Up @@ -137,6 +155,35 @@ export default function AdminDomainsPage() {
<TableCell className="text-sm text-muted-foreground">
{formatDistanceToNow(new Date(domain.createdAt), { addSuffix: true })}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
{domain.status !== "SUCCESS" && (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-emerald-600 hover:text-emerald-700 hover:bg-emerald-500/10"
disabled={forceVerify.isPending}
title="Force verify"
onClick={() => forceVerify.mutate({ domainId: domain.id })}
>
<ShieldCheck className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-destructive hover:text-destructive hover:bg-destructive/10"
disabled={deleteDomain.isPending}
title="Delete domain"
onClick={() => {
if (!window.confirm(`Delete domain "${domain.name}"? This is permanent.`)) return;
deleteDomain.mutate({ domainId: domain.id });
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
Expand Down
Loading
Loading