diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..97f5f22 --- /dev/null +++ b/AGENTS.md @@ -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__` 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index eaafb1c..32e2b46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` messages map + +--- + ## [0.3.1] - 2026-06-03 ### Added diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 0ce1fe1..d0f304c 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -14,6 +14,7 @@ export async function middleware(request: NextRequest) { const publicPaths = [ "/login", "/signup", + "/banned", "/api", "/auth", "/", @@ -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 diff --git a/apps/web/src/app/(dashboard)/admin/domains/page.tsx b/apps/web/src/app/(dashboard)/admin/domains/page.tsx index 287c222..f157e9e 100644 --- a/apps/web/src/app/(dashboard)/admin/domains/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/domains/page.tsx @@ -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"; @@ -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"; @@ -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 (
@@ -81,25 +98,26 @@ export default function AdminDomainsPage() { Region Status Tracking - Added + Added + Actions {domainsQuery.isLoading ? ( - + ) : domainsQuery.isError ? ( - + Failed to load domains. ) : !domainsQuery.data?.domains.length ? ( - + No domains found. @@ -137,6 +155,35 @@ export default function AdminDomainsPage() { {formatDistanceToNow(new Date(domain.createdAt), { addSuffix: true })} + +
+ {domain.status !== "SUCCESS" && ( + + )} + +
+
)) )} diff --git a/apps/web/src/app/(dashboard)/admin/teams/page.tsx b/apps/web/src/app/(dashboard)/admin/teams/page.tsx index 2d06202..fab5c2b 100644 --- a/apps/web/src/app/(dashboard)/admin/teams/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/teams/page.tsx @@ -91,6 +91,8 @@ const updateSchema = z.object({ // -1 = unlimited override, 0 = use plan default, positive = custom cap dailyEmailLimit: z.coerce.number().int().min(-1).max(10_000_000), isBlocked: z.boolean(), + extraDomainSlots: z.coerce.number().int().min(0).max(1_000), + extraMemberSlots: z.coerce.number().int().min(0).max(1_000), }); const assignSchema = z.object({ @@ -102,7 +104,7 @@ type AssignInput = z.infer; export default function AdminTeamsPage() { const { data: session } = useSession(); - const canAssignPlans = Boolean(session?.user?.isEnvAdmin); + const canAssignPlans = Boolean(session?.user?.isAdmin); const [team, setTeam] = useState(null); const [hasSearched, setHasSearched] = useState(false); const [teamsPage, setTeamsPage] = useState(1); @@ -122,6 +124,8 @@ export default function AdminTeamsPage() { apiRateLimit: 1, dailyEmailLimit: 0, isBlocked: false, + extraDomainSlots: 0, + extraMemberSlots: 0, }, }); @@ -140,6 +144,8 @@ export default function AdminTeamsPage() { apiRateLimit: team.apiRateLimit, dailyEmailLimit: isUnlimited ? -1 : team.dailyEmailLimit, isBlocked: team.isBlocked, + extraDomainSlots: team.extraDomainSlots, + extraMemberSlots: team.extraMemberSlots, }); setGeneratedCheckoutUrl(null); } @@ -189,7 +195,7 @@ export default function AdminTeamsPage() { onSuccess: (result) => { if (result.method === "complimentary" && result.team) { setTeam(result.team); - toast.success(`Plan assigned as complimentary`); + toast.success("Plan assigned complimentarily"); } else if (result.method === "checkout_link" && result.url) { setGeneratedCheckoutUrl(result.url); toast.success("Checkout link generated"); @@ -200,6 +206,17 @@ export default function AdminTeamsPage() { }, }); + const deleteTeam = api.admin.adminDeleteTeam.useMutation({ + onSuccess: () => { + setTeam(null); + setHasSearched(false); + toast.success("Team deleted"); + }, + onError: (error) => { + toast.error(error.message ?? "Failed to delete team"); + }, + }); + const onSearchSubmit = (values: SearchInput) => { setTeam(null); setHasSearched(false); @@ -211,7 +228,13 @@ export default function AdminTeamsPage() { updateTeam.mutate({ teamId: team.id, ...values, plan: team.plan }); }; - const onAssignSubmit = (values: AssignInput) => { + const onAssignComplimentary = (values: AssignInput) => { + if (!team) return; + setGeneratedCheckoutUrl(null); + assignPlan.mutate({ teamId: team.id, plan: values.plan, method: "complimentary" }); + }; + + const onAssignCheckout = (values: AssignInput) => { if (!team) return; setGeneratedCheckoutUrl(null); assignPlan.mutate({ teamId: team.id, plan: values.plan, method: "checkout_link" }); @@ -427,6 +450,48 @@ export default function AdminTeamsPage() { )} /> + ( + + Extra domain slots + + field.onChange(Number(event.target.value))} + disabled={updateTeam.isPending} + /> + + + + )} + /> + ( + + Extra member slots + + field.onChange(Number(event.target.value))} + disabled={updateTeam.isPending} + /> + + + + )} + />
+
+ + +
{generatedCheckoutUrl && ( @@ -504,6 +584,27 @@ export default function AdminTeamsPage() { )}
) : null} + +
+
+

Danger zone

+

+ Permanently delete this team and all its data. This cannot be undone. +

+
+ +
) : null} diff --git a/apps/web/src/app/api/unsubscribe-oneclick/route.ts b/apps/web/src/app/api/unsubscribe-oneclick/route.ts index b2ace16..612fe89 100644 --- a/apps/web/src/app/api/unsubscribe-oneclick/route.ts +++ b/apps/web/src/app/api/unsubscribe-oneclick/route.ts @@ -2,6 +2,20 @@ import { NextRequest, NextResponse } from "next/server"; import { unsubscribeContactFromLink } from "~/server/service/campaign-service"; import { logger } from "~/server/logger/log"; +// Redirect click-based clients (Outlook etc.) to the user-facing unsubscribe page. +// Mailbox providers that support RFC 8058 one-click will use POST below instead. +export async function GET(request: NextRequest) { + const url = new URL(request.url); + const id = url.searchParams.get("id"); + const hash = url.searchParams.get("hash"); + + const dest = new URL("/unsubscribe", url.origin); + if (id) dest.searchParams.set("id", id); + if (hash) dest.searchParams.set("hash", hash); + + return NextResponse.redirect(dest, { status: 302 }); +} + export async function POST(request: NextRequest) { try { const url = new URL(request.url); diff --git a/apps/web/src/app/banned/page.tsx b/apps/web/src/app/banned/page.tsx new file mode 100644 index 0000000..c81b00d --- /dev/null +++ b/apps/web/src/app/banned/page.tsx @@ -0,0 +1,49 @@ +import Link from "next/link"; +import { FaDiscord } from "react-icons/fa6"; + +export default function BannedPage() { + return ( +
+ {/* Background orbs */} +
+
+ +
+
+ + Ban + + + Ban + +
+ +
+

+ Your account has been suspended +

+

+ Access to ByteSend has been restricted. If you believe this is a + mistake, please reach out to us on Discord and we'll look into it. +

+
+ +
+ + + Join our Discord + +
+ +

+ © {new Date().getFullYear()} NodeByte LTD +

+
+
+ ); +} diff --git a/apps/web/src/components/payments/UpgradeModal.tsx b/apps/web/src/components/payments/UpgradeModal.tsx index 70d1348..cfdf8e0 100644 --- a/apps/web/src/components/payments/UpgradeModal.tsx +++ b/apps/web/src/components/payments/UpgradeModal.tsx @@ -45,6 +45,12 @@ export const UpgradeModal = () => { "You have reached your monthly sending limit.", [LimitReason.MARKETING_NOT_AVAILABLE]: "This marketing capability is not available on your current plan.", + [LimitReason.CONTACTS]: + "You have reached the contact limit for your current plan.", + [LimitReason.BOUNCE_RATE_EXCEEDED]: + "Email sending is paused because your bounce rate is too high.", + [LimitReason.COMPLAINT_RATE_EXCEEDED]: + "Email sending is paused because your complaint rate is too high.", }; return reason ? `${messages[reason] ?? ""} Upgrade to continue.` diff --git a/apps/web/src/env.js b/apps/web/src/env.js index 3925f84..ff6484a 100644 --- a/apps/web/src/env.js +++ b/apps/web/src/env.js @@ -52,6 +52,7 @@ export const env = createEnv({ ADMIN_EMAIL: z.string().optional(), FOUNDER_EMAIL: z.string().optional(), DISCORD_WEBHOOK_URL: z.string().optional(), + ADMIN_DISCORD_WEBHOOK_URL: z.string().url().optional(), REDIS_URL: z.string(), REDIS_KEY_PREFIX: z.string().default(""), S3_COMPATIBLE_ACCESS_KEY: z.string().optional(), @@ -120,6 +121,7 @@ export const env = createEnv({ ADMIN_EMAIL: process.env.ADMIN_EMAIL, FOUNDER_EMAIL: process.env.FOUNDER_EMAIL, DISCORD_WEBHOOK_URL: process.env.DISCORD_WEBHOOK_URL, + ADMIN_DISCORD_WEBHOOK_URL: process.env.ADMIN_DISCORD_WEBHOOK_URL, REDIS_URL: process.env.REDIS_URL, REDIS_KEY_PREFIX: process.env.REDIS_KEY_PREFIX, FROM_EMAIL: process.env.FROM_EMAIL, diff --git a/apps/web/src/lib/constants/plans.ts b/apps/web/src/lib/constants/plans.ts index 403c65c..e19f574 100644 --- a/apps/web/src/lib/constants/plans.ts +++ b/apps/web/src/lib/constants/plans.ts @@ -12,6 +12,8 @@ export enum LimitReason { EMAIL_DAILY_LIMIT_REACHED = "EMAIL_DAILY_LIMIT_REACHED", EMAIL_FREE_PLAN_MONTHLY_LIMIT_REACHED = "EMAIL_FREE_PLAN_MONTHLY_LIMIT_REACHED", MARKETING_NOT_AVAILABLE = "MARKETING_NOT_AVAILABLE", + BOUNCE_RATE_EXCEEDED = "BOUNCE_RATE_EXCEEDED", + COMPLAINT_RATE_EXCEEDED = "COMPLAINT_RATE_EXCEEDED", } export const PLAN_LIMITS: Record< diff --git a/apps/web/src/server/api/routers/admin.ts b/apps/web/src/server/api/routers/admin.ts index c44df3c..5e85fe0 100644 --- a/apps/web/src/server/api/routers/admin.ts +++ b/apps/web/src/server/api/routers/admin.ts @@ -43,7 +43,10 @@ const teamAdminSelection = { apiRateLimit: true, dailyEmailLimit: true, isBlocked: true, + isActive: true, billingEmail: true, + extraDomainSlots: true, + extraMemberSlots: true, createdAt: true, teamUsers: { select: { @@ -434,27 +437,20 @@ export const adminRouter = createTRPCRouter({ dailyEmailLimit: z.number().int().min(-1).max(10_000_000), isBlocked: z.boolean(), plan: z.enum(["FREE", "HOBBY", "LITE", "BASIC", "LIFETIME"]), + extraDomainSlots: z.number().int().min(0).max(1_000).optional(), + extraMemberSlots: z.number().int().min(0).max(1_000).optional(), }), ) - .mutation(async ({ ctx, input }) => { + .mutation(async ({ input }) => { const { teamId, ...data } = input; - if (isCloud()) { - const existingTeam = await db.team.findUnique({ - where: { id: teamId }, - select: { plan: true }, - }); - - if (!existingTeam) { - throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" }); - } + const existingTeam = await db.team.findUnique({ + where: { id: teamId }, + select: { id: true }, + }); - if (existingTeam.plan !== data.plan && !ctx.session.user.isEnvAdmin) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Only founder/admin env accounts can change team plans.", - }); - } + if (!existingTeam) { + throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" }); } const updatedTeam = await db.team.update({ @@ -490,13 +486,6 @@ export const adminRouter = createTRPCRouter({ }); } - if (!ctx.session.user.isEnvAdmin) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Only founder/admin env accounts can assign plans or generate payment links.", - }); - } - if (method === "complimentary") { const updatedTeam = await db.team.update({ where: { id: teamId }, @@ -669,6 +658,53 @@ export const adminRouter = createTRPCRouter({ return { teams, total, page, pageSize }; }), + adminForceVerifyDomain: adminProcedure + .input(z.object({ domainId: z.number() })) + .mutation(async ({ input }) => { + const domain = await db.domain.findUnique({ where: { id: input.domainId } }); + if (!domain) { + throw new TRPCError({ code: "NOT_FOUND", message: "Domain not found" }); + } + const updated = await db.domain.update({ + where: { id: input.domainId }, + data: { + status: "SUCCESS", + isVerifying: false, + errorMessage: null, + }, + select: adminDomainSelection, + }); + logger.info({ domainId: input.domainId }, "[AdminRouter]: Domain force-verified"); + return updated; + }), + + adminDeleteDomain: adminProcedure + .input(z.object({ domainId: z.number() })) + .mutation(async ({ input }) => { + const domain = await db.domain.findUnique({ where: { id: input.domainId } }); + if (!domain) { + throw new TRPCError({ code: "NOT_FOUND", message: "Domain not found" }); + } + await db.domain.delete({ where: { id: input.domainId } }); + logger.info({ domainId: input.domainId }, "[AdminRouter]: Domain deleted by admin"); + return { success: true }; + }), + + adminDeleteTeam: adminProcedure + .input(z.object({ teamId: z.number() })) + .mutation(async ({ input }) => { + const team = await db.team.findUnique({ + where: { id: input.teamId }, + select: { id: true, name: true }, + }); + if (!team) { + throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" }); + } + await db.team.delete({ where: { id: input.teamId } }); + logger.info({ teamId: input.teamId, teamName: team.name }, "[AdminRouter]: Team deleted by admin"); + return { success: true }; + }), + getEmailAnalytics: adminProcedure .input( z.object({ diff --git a/apps/web/src/server/api/trpc.ts b/apps/web/src/server/api/trpc.ts index 6ae8b99..bbd03a4 100644 --- a/apps/web/src/server/api/trpc.ts +++ b/apps/web/src/server/api/trpc.ts @@ -116,11 +116,23 @@ export const publicProcedure = t.procedure; * * Ensures a session exists. Useful for flows where users need access. */ -export const authedProcedure = t.procedure.use(({ ctx, next }) => { +export const authedProcedure = t.procedure.use(async ({ ctx, next }) => { if (!ctx.session || !ctx.session.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } + const user = await db.user.findUnique({ + where: { id: ctx.session.user.id }, + select: { isBanned: true }, + }); + + if (user?.isBanned) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Your account has been suspended. Please contact support via Discord.", + }); + } + return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts index 5893d0f..0aece91 100644 --- a/apps/web/src/server/auth.ts +++ b/apps/web/src/server/auth.ts @@ -34,6 +34,7 @@ declare module "next-auth" { isAdmin: boolean; isFounder: boolean; isEnvAdmin?: boolean; + isBanned: boolean; // ...other properties // role: UserRole; } & DefaultSession["user"]; @@ -46,10 +47,18 @@ declare module "next-auth" { isAdmin: boolean; isFounder: boolean; isEnvAdmin?: boolean; + isBanned: boolean; image?: string | null; } } +declare module "next-auth/jwt" { + // eslint-disable-next-line no-unused-vars + interface JWT { + isBanned?: boolean; + } +} + function normalizeEmail(email?: string | null) { return email?.trim().toLowerCase(); } @@ -179,8 +188,7 @@ function getProviders() { image: true, isBetaUser: true, isAdmin: true, - isFounder: true, - isEnvAdmin: true, + isBanned: true, }, }); @@ -188,6 +196,10 @@ function getProviders() { throw new Error("User not found"); } + if (user.isBanned) { + throw new Error("BANNED"); + } + return user; }, }) @@ -208,6 +220,18 @@ function getProviders() { export const authOptions: NextAuthOptions = { callbacks: { signIn: async ({ user, account, profile, credentials }) => { + // Block banned users from signing in + const userId = typeof user.id === "string" ? parseInt(user.id, 10) : user.id; + if (userId) { + const dbUser = await db.user.findUnique({ + where: { id: userId }, + select: { isBanned: true }, + }); + if (dbUser?.isBanned) { + return "/login?error=banned"; + } + } + // For email provider with code verification if (account?.provider === "email" && credentials?.code) { try { @@ -240,7 +264,7 @@ export const authOptions: NextAuthOptions = { if (freshImage && freshImage !== user.image) { try { await db.user.update({ - where: { id: user.id }, + where: { id: typeof user.id === "string" ? parseInt(user.id, 10) : user.id }, data: { image: freshImage }, }); // Reflect immediately so the session callback sees the new value @@ -253,6 +277,12 @@ export const authOptions: NextAuthOptions = { return true; }, + jwt: async ({ token, user }) => { + if (user) { + token.isBanned = user.isBanned ?? false; + } + return token; + }, session: ({ session, user }) => { const userEmail = normalizeEmail(user.email); const founderEmail = normalizeEmail(env.FOUNDER_EMAIL); @@ -274,6 +304,7 @@ export const authOptions: NextAuthOptions = { isAdmin, isFounder, isEnvAdmin, + isBanned: user.isBanned ?? false, // Explicitly forward the DB image so it always reaches the client, // even when session.user was built before the image update above. image: user.image ?? session.user.image, diff --git a/apps/web/src/server/aws/ses.ts b/apps/web/src/server/aws/ses.ts index 7507560..95572f0 100644 --- a/apps/web/src/server/aws/ses.ts +++ b/apps/web/src/server/aws/ses.ts @@ -242,6 +242,7 @@ export async function sendRawEmail({ region, configurationSetName, unsubUrl, + unsubOneClickUrl, isBulk, inReplyToMessageId, emailId, @@ -256,6 +257,7 @@ export async function sendRawEmail({ replyTo?: string[]; to?: string[]; unsubUrl?: string; + unsubOneClickUrl?: string; isBulk?: boolean; inReplyToMessageId?: string; emailId?: string; @@ -282,6 +284,7 @@ export async function sendRawEmail({ emailId, headers, unsubUrl, + unsubOneClickUrl, isBulk, inReplyToMessageId, }), diff --git a/apps/web/src/server/public-api/auth.ts b/apps/web/src/server/public-api/auth.ts index 68aace2..ed5cf70 100644 --- a/apps/web/src/server/public-api/auth.ts +++ b/apps/web/src/server/public-api/auth.ts @@ -45,6 +45,23 @@ export const getTeamFromToken = async (c: Context) => { }); } + // Block API access if any admin on the team is banned + const bannedAdmin = await db.teamUser.findFirst({ + where: { + teamId: team.id, + role: "ADMIN", + user: { isBanned: true }, + }, + select: { userId: true }, + }); + + if (bannedAdmin) { + throw new ByteSendApiError({ + code: "FORBIDDEN", + message: "Account suspended. Please contact support via Discord: https://discord.com/invite/BU8n8pJv8S", + }); + } + // No await so it won't block the request. Need to be moved to a queue in future db.apiKey .update({ diff --git a/apps/web/src/server/service/email-queue-service.ts b/apps/web/src/server/service/email-queue-service.ts index f098181..4a41963 100644 --- a/apps/web/src/server/service/email-queue-service.ts +++ b/apps/web/src/server/service/email-queue-service.ts @@ -13,6 +13,7 @@ import { LimitService } from "./limit-service"; import { sanitizeCustomHeaders } from "~/server/utils/email-headers"; import { getStripe } from "../billing/payments"; import { METER_EVENT_NAMES } from "@bytesend/lib"; +import { createOneClickUnsubUrl } from "./campaign-service"; // Notifications about limits are handled inside LimitService. // SES error names that are transient and should be retried @@ -407,6 +408,13 @@ async function executeEmail(job: QueueEmailJob) { const unsubUrl = job.data.unsubUrl; const isBulk = job.data.isBulk; + // Derive one-click unsubscribe URL for tracked marketing emails (campaign sends). + // The regular unsubUrl (HTML link) is kept for click-based clients (Outlook etc.). + const unsubOneClickUrl = + email.contactId && email.campaignId + ? createOneClickUnsubUrl(email.contactId, email.campaignId) + : undefined; + const text = email.text ? email.text : email.campaignId && email.html @@ -467,6 +475,7 @@ async function executeEmail(job: QueueEmailJob) { configurationSetName, attachments: attachments.length > 0 ? attachments : undefined, unsubUrl, + unsubOneClickUrl, isBulk, inReplyToMessageId, emailId: email.id, diff --git a/apps/web/src/server/service/limit-service.ts b/apps/web/src/server/service/limit-service.ts index 39c53be..fa179e8 100644 --- a/apps/web/src/server/service/limit-service.ts +++ b/apps/web/src/server/service/limit-service.ts @@ -6,6 +6,15 @@ import { withCache } from "../redis"; import { db } from "../db"; import { logger } from "../logger/log"; import { Plan, Team } from "@prisma/client"; +import { sendToDiscord } from "./notification-service"; + +// Thresholds aligned with Google/Yahoo sender requirements +const BOUNCE_RATE_HARD_LIMIT = 0.02; // 2% — block sending +const BOUNCE_RATE_WARN_LIMIT = 0.015; // 1.5% — warn only +const COMPLAINT_RATE_HARD_LIMIT = 0.001; // 0.1% — block sending +const COMPLAINT_RATE_WARN_LIMIT = 0.0008; // 0.08% — warn only +// Only enforce when the team has enough volume to produce a meaningful rate +const BOUNCE_RATE_MIN_VOLUME = 100; function isLimitExceeded(current: number, limit: number): boolean { if (limit === -1) return false; // unlimited @@ -247,6 +256,28 @@ export class LimitService { }; } + private static async getRecentBounceStats(teamId: number): Promise<{ + delivered: number; + hardBounced: number; + complained: number; + }> { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - 7); + // DailyEmailUsage.date is stored as 'YYYY-MM-DD' string; ISO prefix comparison is correct + const cutoffDate = cutoff.toISOString().slice(0, 10); + + const result = await db.dailyEmailUsage.aggregate({ + where: { teamId, date: { gte: cutoffDate } }, + _sum: { delivered: true, hardBounced: true, complained: true }, + }); + + return { + delivered: result._sum.delivered ?? 0, + hardBounced: result._sum.hardBounced ?? 0, + complained: result._sum.complained ?? 0, + }; + } + // Checks email sending limits and also triggers usage notifications. // Side effects: // - Sends "warning" emails when nearing daily/monthly limits (rate-limited in TeamService) @@ -282,6 +313,54 @@ export class LimitService { }; } + // Bounce / complaint rate enforcement (7-day rolling window) + const recentStats = await LimitService.getRecentBounceStats(teamId); + if (recentStats.delivered >= BOUNCE_RATE_MIN_VOLUME) { + const bounceRate = recentStats.hardBounced / recentStats.delivered; + const complaintRate = recentStats.complained / recentStats.delivered; + + if (bounceRate >= BOUNCE_RATE_HARD_LIMIT) { + logger.warn( + { teamId, bounceRate, recentStats }, + "[LimitService]: Team blocked — bounce rate exceeds 2%", + ); + sendToDiscord( + `⚠️ **Sending blocked** — team \`${teamId}\` hard-bounce rate: **${(bounceRate * 100).toFixed(2)}%** (7-day). Threshold: 2%.`, + ).catch(() => void 0); + return { + isLimitReached: true, + limit: 0, + reason: LimitReason.BOUNCE_RATE_EXCEEDED, + }; + } + + if (complaintRate >= COMPLAINT_RATE_HARD_LIMIT) { + logger.warn( + { teamId, complaintRate, recentStats }, + "[LimitService]: Team blocked — complaint rate exceeds 0.1%", + ); + sendToDiscord( + `⚠️ **Sending blocked** — team \`${teamId}\` complaint rate: **${(complaintRate * 100).toFixed(3)}%** (7-day). Threshold: 0.1%.`, + ).catch(() => void 0); + return { + isLimitReached: true, + limit: 0, + reason: LimitReason.COMPLAINT_RATE_EXCEEDED, + }; + } + + // Warn when approaching thresholds + if (bounceRate >= BOUNCE_RATE_WARN_LIMIT || complaintRate >= COMPLAINT_RATE_WARN_LIMIT) { + logger.warn( + { teamId, bounceRate, complaintRate, recentStats }, + "[LimitService]: Team approaching bounce/complaint rate limits", + ); + sendToDiscord( + `⚠️ **Reputation warning** — team \`${teamId}\` is approaching limits. Bounce: **${(bounceRate * 100).toFixed(2)}%**, Complaints: **${(complaintRate * 100).toFixed(3)}%** (7-day).`, + ).catch(() => void 0); + } + } + const activePlan = getActivePlan(team); // Block marketing emails on plans where they are not available (e.g. FREE) diff --git a/apps/web/src/server/service/notification-provider-service.ts b/apps/web/src/server/service/notification-provider-service.ts index 3360c7c..39d3ca4 100644 --- a/apps/web/src/server/service/notification-provider-service.ts +++ b/apps/web/src/server/service/notification-provider-service.ts @@ -7,6 +7,7 @@ import { createHmac, randomUUID } from "crypto"; import { db } from "../db"; import { logger } from "../logger/log"; import { ByteSendApiError } from "../public-api/api-error"; +import { env } from "~/env"; export type DiscordConfig = { webhookUrl: string; @@ -339,10 +340,6 @@ export class NotificationProviderService { }, }); - if (providers.length === 0) { - return { sent: 0, failed: 0 }; - } - let sent = 0; let failed = 0; @@ -355,6 +352,32 @@ export class NotificationProviderService { } } + // Fan-out to admin observer webhook if configured and not already a team provider + const adminUrl = env.ADMIN_DISCORD_WEBHOOK_URL; + if (adminUrl) { + const alreadyCovered = providers.some((p) => { + const cfg = p.config as Record; + return cfg?.webhookUrl === adminUrl || cfg?.url === adminUrl; + }); + + if (!alreadyCovered) { + try { + await this.sendToDiscord({ webhookUrl: adminUrl }, { + ...message, + fields: [ + ...(message.fields ?? []), + { name: "Team ID", value: String(teamId), inline: true }, + ], + }); + } catch (error) { + logger.warn( + { error: error instanceof Error ? error.message : error }, + "Failed to send notification to admin webhook" + ); + } + } + } + logger.info( { teamId, eventType, sent, failed }, "Broadcast notifications completed" @@ -609,6 +632,16 @@ export class NotificationProviderService { /** * Validate provider configuration */ + private static assertNotAdminWebhook(webhookUrl: string) { + const adminUrl = env.ADMIN_DISCORD_WEBHOOK_URL; + if (adminUrl && webhookUrl === adminUrl) { + throw new ByteSendApiError( + "BAD_REQUEST", + "This webhook URL is reserved and cannot be used as a team notification provider." + ); + } + } + private static validateConfig( type: NotificationProviderType, config: NotificationProviderConfig @@ -621,6 +654,7 @@ export class NotificationProviderService { "Discord webhook URL is required" ); } + this.assertNotAdminWebhook(config.webhookUrl); this.assertAllowedWebhookUrl(config.webhookUrl, "Discord", [ "discord.com", "discordapp.com", @@ -634,6 +668,7 @@ export class NotificationProviderService { "Slack webhook URL is required" ); } + this.assertNotAdminWebhook(config.webhookUrl); this.assertAllowedWebhookUrl(config.webhookUrl, "Slack", [ "hooks.slack.com", ]); diff --git a/apps/web/src/server/service/ses-hook-parser.ts b/apps/web/src/server/service/ses-hook-parser.ts index 4c89aae..9b7d419 100644 --- a/apps/web/src/server/service/ses-hook-parser.ts +++ b/apps/web/src/server/service/ses-hook-parser.ts @@ -31,7 +31,7 @@ import { getChildLogger, logger, withLogger } from "../logger/log"; import { randomUUID } from "crypto"; import { SuppressionService } from "./suppression-service"; import { WebhookService } from "./webhook-service"; -import { sendToDiscord } from "./notification-service"; +import { NotificationProviderService } from "./notification-provider-service"; export async function parseSesHook(data: SesEvent) { const mailStatus = getEmailStatus(data); @@ -190,12 +190,26 @@ export async function parseSesHook(data: SesEvent) { ); } - const eventLabel = isHardBounced ? "Hard bounce" : "Complaint"; - const recipients = isHardBounced - ? (data.bounce?.bouncedRecipients?.map((r) => r.emailAddress) ?? []) - : (data.complaint?.complainedRecipients?.map((r) => r.emailAddress) ?? []); - sendToDiscord( - `**⚠️ ${eventLabel} detected**\n**Email ID:** ${email.id}\n**Subject:** ${email.subject ?? "—"}\n**Recipient(s):** ${recipients.join(", ") || email.to}\n**Team ID:** ${email.teamId}`, + const eventLabel = isHardBounced ? "Hard Bounce" : "Complaint"; + const recipientCount = isHardBounced + ? (data.bounce?.bouncedRecipients?.length ?? email.to.length) + : (data.complaint?.complainedRecipients?.length ?? email.to.length); + const eventType = isHardBounced ? "EMAIL_BOUNCED" : "EMAIL_COMPLAINED"; + + NotificationProviderService.broadcastNotification( + email.teamId, + eventType as import("@prisma/client").NotificationEventType, + { + title: `⚠️ ${eventLabel} Detected`, + description: `A ${eventLabel.toLowerCase()} was triggered for an outgoing email.`, + color: isHardBounced ? "#EF4444" : "#F97316", + fields: [ + { name: "Email ID", value: email.id, inline: true }, + { name: "Recipients Affected", value: String(recipientCount), inline: true }, + { name: "Subject", value: email.subject ?? "—", inline: false }, + ], + timestamp: true, + } ).catch(() => void 0); } diff --git a/apps/web/src/server/utils/email-headers.ts b/apps/web/src/server/utils/email-headers.ts index 4018826..e99a400 100644 --- a/apps/web/src/server/utils/email-headers.ts +++ b/apps/web/src/server/utils/email-headers.ts @@ -64,12 +64,16 @@ export function buildHeaders({ emailId, headers, unsubUrl, + unsubOneClickUrl, isBulk, inReplyToMessageId, }: { emailId?: string | undefined; headers?: Record | undefined; + /** User-facing unsubscribe page URL (GET link in email body / fallback). */ unsubUrl?: string; + /** RFC 8058 one-click endpoint URL (accepts HTTP POST from mailbox providers). */ + unsubOneClickUrl?: string; isBulk?: boolean; inReplyToMessageId?: string | undefined; }) { @@ -88,14 +92,18 @@ export function buildHeaders({ defaultHeaders["X-ByteSend-Email-ID"] = emailId; } - if (unsubUrl) { - if (!sanitizedHeaderNames.has("list-unsubscribe")) { - defaultHeaders["List-Unsubscribe"] = `<${unsubUrl}>`; + if ((unsubUrl ?? unsubOneClickUrl) && !sanitizedHeaderNames.has("list-unsubscribe")) { + if (unsubOneClickUrl && unsubUrl) { + // RFC 8058: one-click URL first (mailbox providers POST to the first URL), + // regular page second (fallback for click-based clients like Outlook). + defaultHeaders["List-Unsubscribe"] = `<${unsubOneClickUrl}>, <${unsubUrl}>`; + } else { + defaultHeaders["List-Unsubscribe"] = `<${unsubOneClickUrl ?? unsubUrl}>`; } + } - if (!sanitizedHeaderNames.has("list-unsubscribe-post")) { - defaultHeaders["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"; - } + if (unsubOneClickUrl && !sanitizedHeaderNames.has("list-unsubscribe-post")) { + defaultHeaders["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"; } if (isBulk && !sanitizedHeaderNames.has("precedence")) {