# App domain addition This doc captures how the custom domain flow was wired in the app UI, so it can be restored later. ## Where the UI lives * `apps/app/src/components/settings/team-inbox-settings.tsx` * Previously contained a "Domains" card and action buttons. * Uses `useInboxSettingsParams()` to open a sheet via query params. * `apps/app/src/components/settings/inbox-domain-sheet.tsx` * The sheet UI for adding/editing the domain. * Uses `trpc.inbox.settings.get` (read) + `trpc.inbox.settings.update` (write). * `apps/app/src/hooks/use-inbox-settings-params.ts` * `domainSheet` query param (`"create"` or `"edit"`) controls the sheet. ## Data flow 1. **Owner check**: `trpc.team.current` provides role and gates actions. 2. **Current domain**: `trpc.inbox.settings.get` supplies `settings.domain`. 3. **Save**: `trpc.inbox.settings.update` persists a domain string or `null`. 4. **Refresh**: invalidate `inbox.settings.get` + `inbox.mailboxes.list`. ## Validation rules * Domain must match `/^[a-z0-9.-]+$/i`. * Empty input maps to `null` (shared domain). * Only team owners can save. ## How to re-enable in the app 1. **Restore the Domains card** in `apps/app/src/components/settings/team-inbox-settings.tsx`: * Add a "Domains" `Card` before the mailboxes section. * Include the "Add/Edit domain" button that calls: `setParams({ domainSheet: "create" | "edit", mailboxSheet: null, mailboxId: null })`. * Display the current domain + status and updated date. 2. **Render the sheet**: * Re-add `` in `TeamInboxSettings`. 3. **Keep query params**: * `useInboxSettingsParams()` already exposes `domainSheet`. ## Notes * This doc only covers the `apps/app` UI. The API/worker flows (`apps/api`, `apps/inbox`) were left intact. # E2E: pull latest email Use the `GET /v1/messages/latest` endpoint to fetch the most recent email that matches your filters. This endpoint is designed for E2E polling and ignores the `limit` parameter. ## Typical flow **Send the email** Trigger the action that sends the email (login, invite, password reset, etc.). **Poll for the latest message** Filter by mailbox, tag, or subject to reduce noise. **Extract the token** Parse `data.textContent` or `data.htmlContent` and capture the OTP or link. ## Example curl Local development: ```bash curl -H "Authorization: Bearer " \ "http://localhost:3003/v1/messages/latest?mailbox=qa&tag=login&since=2025-12-24T00:00:00.000Z" ``` Production: ```bash curl -H "Authorization: Bearer " \ "https://api.plop.email/v1/messages/latest?mailbox=qa&tag=login&since=2025-12-24T00:00:00.000Z" ``` ## Playwright: wait for email + extract OTP ```ts import { expect, test, type APIRequestContext } from "@playwright/test"; const OTP_REGEX = /\b(\d{6})\b/; async function latestOtp(request: APIRequestContext) { const apiUrl = process.env.PLOP_API_URL ?? "http://localhost:3003"; // Set PLOP_API_URL=https://api.plop.email for production runs. const apiKey = process.env.PLOP_API_KEY ?? ""; const qs = new URLSearchParams({ mailbox: "qa", tag: "login", since: new Date().toISOString(), }); const res = await request.get( `${apiUrl}/v1/messages/latest?${qs.toString()}`, { headers: { Authorization: `Bearer ${apiKey}` } }, ); if (res.status() === 404) return null; if (!res.ok()) throw new Error(`Email API error: ${res.status()}`); const { data } = (await res.json()) as { data: { textContent: string | null; htmlContent: string | null }; }; const body = data.textContent ?? data.htmlContent ?? ""; return body.match(OTP_REGEX)?.[1] ?? null; } test("login via OTP", async ({ page, request }) => { // trigger email here (signup/login flow) let otp: string | null = null; await expect .poll(async () => { otp = await latestOtp(request); return otp; }, { timeout: 60_000, intervals: [1000, 2000, 3000] }) .toMatch(/\d{6}/); await page.fill("[name=otp]", otp!); }); ``` ## Example response fields * `data.subject` * `data.textContent` * `data.htmlContent` * `data.receivedAt` ## Notes * Prefer `since` to avoid picking up older messages from previous tests. * If you control tags, use `support+login@...` and filter by `tag=login`. * Mailbox scoped keys must match the mailbox filter (if provided). * Messages are retained per plan (Starter: 14 days, Pro: 90 days, Enterprise: custom). # Environment & secrets ## API (`apps/api`) * `PORT` — API port (defaults to `3003`). * `ALLOWED_API_ORIGINS` — comma-separated CORS allowlist (defaults to `https://app.plop.email`). * `APP_URL` — canonical app URL (defaults to `https://app.plop.email`, used for billing redirects). * `NEXT_PUBLIC_SUPABASE_URL` — Supabase project URL. * `SUPABASE_SECRET_KEY` — Supabase secret key (`sb_secret_...` in prod, local `service_role` JWT in dev). * `DATABASE_PRIMARY_URL` — primary Postgres connection string. * `DATABASE_LHR_URL` — read-replica connection string (use primary in dev). * `DATABASE_SESSION_POOLER` — session pooler connection string (use primary in dev). * `FLY_REGION` — optional region hint for replica routing (Fly.io). * `LOG_LEVEL` — logging level for database/replica routing (`debug`, `info`, `warn`, `error`). * `UPSTASH_REDIS_REST_URL` — Upstash Redis REST URL (cache for RLS + replication). * `UPSTASH_REDIS_REST_TOKEN` — Upstash Redis REST token. * `KV_DISABLED` — set to `true` to disable Redis cache (falls back to in-memory). * `INBOX_ROOT_DOMAIN` — shared inbound domain (defaults to `in.plop.email`). * `INBOX_WEBHOOK_SECRET` — bearer token expected by `/webhooks/inbox`. * `RESEND_API_KEY` — Resend API key for outbound emails (team invites + auth hook). * `RESEND_FROM` — sender address for outbound emails (example: `Plop `). * `POLAR_ACCESS_TOKEN` — Polar API token (required to enable billing flows). * `POLAR_ENVIRONMENT` — `production` or `sandbox` (defaults to `sandbox`). * `POLAR_WEBHOOK_SECRET` — webhook signing secret for Polar (required to validate webhooks). * `POLAR_STARTER_MONTHLY_PRODUCT_ID` — Polar product id for starter monthly plan. * `POLAR_STARTER_YEARLY_PRODUCT_ID` — Polar product id for starter yearly plan. * `POLAR_PRO_MONTHLY_PRODUCT_ID` — Polar product id for pro monthly plan. * `POLAR_PRO_YEARLY_PRODUCT_ID` — Polar product id for pro yearly plan. * `POLAR_ENTERPRISE_MONTHLY_PRODUCT_ID` — Polar product id for enterprise monthly plan. * `POLAR_ENTERPRISE_YEARLY_PRODUCT_ID` — Polar product id for enterprise yearly plan. ### Local `.env` example ```bash # apps/api/.env PORT=3003 # optional (defaults to 3003) ALLOWED_API_ORIGINS=http://localhost:3000 # CORS allowlist for the app APP_URL=http://localhost:3000 # app base URL for billing redirects # Supabase (required) # For local dev: use local Supabase CLI values (sb_secret_...) # For prod: use hosted Supabase secret key (sb_secret_...) NEXT_PUBLIC_SUPABASE_URL=... SUPABASE_SECRET_KEY=... # Database (required) DATABASE_PRIMARY_URL=postgres://... DATABASE_LHR_URL=postgres://... # use primary in dev DATABASE_SESSION_POOLER=postgres://... # use primary in dev LOG_LEVEL=info # optional # Cache (required unless KV_DISABLED=true) UPSTASH_REDIS_REST_URL=... UPSTASH_REDIS_REST_TOKEN=... KV_DISABLED=true # optional; disable Redis cache for local-only dev # Inbox ingestion (required for worker webhooks) INBOX_ROOT_DOMAIN=in.plop.email INBOX_WEBHOOK_SECRET=... # must match apps/inbox WEBHOOK_AUTH_TOKEN # Email (team invites + auth hook) RESEND_API_KEY=... RESEND_FROM="Plop " # Billing (optional; required only if Polar is enabled) POLAR_ACCESS_TOKEN=... POLAR_ENVIRONMENT=sandbox POLAR_WEBHOOK_SECRET=... POLAR_STARTER_MONTHLY_PRODUCT_ID=... POLAR_STARTER_YEARLY_PRODUCT_ID=... POLAR_PRO_MONTHLY_PRODUCT_ID=... POLAR_PRO_YEARLY_PRODUCT_ID=... POLAR_ENTERPRISE_MONTHLY_PRODUCT_ID=... POLAR_ENTERPRISE_YEARLY_PRODUCT_ID=... ``` ## App (`apps/app`) * `NEXT_PUBLIC_API_URL` — base URL for the API (`/trpc` + REST, defaults to `https://api.plop.email`). * `NEXT_PUBLIC_SUPABASE_URL` — Supabase project URL. * `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` — Supabase publishable key (`sb_publishable_...` in prod, local `anon` JWT in dev). * `SUPABASE_SECRET_KEY` — required by `@plop/supabase` server client (server-only; use `sb_secret_...` in prod/dev). * `RESEND_API_KEY` — Resend API key for outbound email (auth hook + invites). * `UPSTASH_REDIS_REST_URL` — Upstash Redis REST URL. * `UPSTASH_REDIS_REST_TOKEN` — Upstash Redis REST token. * `NEXT_PUBLIC_OPENPANEL_CLIENT_ID` — OpenPanel public client id. * `OPENPANEL_SECRET_KEY` — OpenPanel server secret. * `NEXT_PUBLIC_SENTRY_DSN` — Sentry DSN (client + server). * `PORT` — Next.js dev port (defaults to `3000`). * `VERCEL_URL` — Vercel deployment URL (used for absolute links). ### Local `.env` example ```bash # apps/app/.env NEXT_PUBLIC_API_URL=http://localhost:3003 # API base URL # For local dev: use local Supabase CLI values (sb_publishable_... or anon JWT) # For prod: use hosted Supabase publishable key (sb_publishable_...) NEXT_PUBLIC_SUPABASE_URL=... NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=... SUPABASE_SECRET_KEY=... # sb_secret_... in prod/dev RESEND_API_KEY=... UPSTASH_REDIS_REST_URL=... UPSTASH_REDIS_REST_TOKEN=... # Optional observability NEXT_PUBLIC_OPENPANEL_CLIENT_ID=... OPENPANEL_SECRET_KEY=... NEXT_PUBLIC_SENTRY_DSN=... PORT=3000 # optional (defaults to 3000) ``` ## Supabase functions (`packages/supabase`) * `RESEND_API_KEY` — Resend API key used by the auth email hook. * `SEND_EMAIL_HOOK_SECRET` — Supabase hook secret for verifying requests (raw base64 value, no `whsec_` prefix). * `EMAIL_FROM` — sender address (example: `Plop `). * `APP_URL` — base URL used for logo links and auth redirects. Supabase functions read secrets via `supabase secrets set` (see the Supabase auth email guide in this docs site). ## Web (`apps/web`) * `NEXT_PUBLIC_SITE_URL` — canonical marketing site URL (defaults to `https://plop.email`). * `NEXT_PUBLIC_APP_URL` — app URL used in marketing links (defaults to `https://app.plop.email`). * `NEXT_PUBLIC_LOOPS_FORM_ID` — Loops newsletter form id. * `NEXT_PUBLIC_OPENPANEL_CLIENT_ID` — OpenPanel public client id. * `OPENPANEL_SECRET_KEY` — OpenPanel server secret. * `NEXT_PUBLIC_CAL_LINK` — Cal.com scheduling link. ### Local `.env` example ```bash # apps/web/.env NEXT_PUBLIC_SITE_URL=http://localhost:3001 NEXT_PUBLIC_APP_URL=http://localhost:3000 # Optional marketing integrations NEXT_PUBLIC_LOOPS_FORM_ID=... NEXT_PUBLIC_OPENPANEL_CLIENT_ID=... OPENPANEL_SECRET_KEY=... NEXT_PUBLIC_CAL_LINK=... ``` ## Docs (`apps/docs`) * `NEXT_PUBLIC_SITE_URL` — base URL for metadata + OG images (defaults to `https://docs.plop.email`). ### Local `.env` example ```bash # apps/docs/.env NEXT_PUBLIC_SITE_URL=http://localhost:3002 ``` ## Inbox worker (`apps/inbox`) Wrangler vars: * `EMAIL_DOMAIN` — inbound root domain (for example `in.plop.email`). * `EMAIL_WORKER_NAME` — deployed worker name (used for routing rules). * `CLOUDFLARE_ZONE_ID` — Cloudflare zone id for the apex domain. * `WEBHOOK_URL` — API endpoint to post inbound email payloads. * `WEBHOOK_TIMEOUT_MS` — optional timeout override. Bindings: * `INBOX_STORAGE` — R2 bucket binding for stored emails. Secrets: * `ADMIN_TOKEN` — admin API auth for the worker. * `CLOUDFLARE_API_TOKEN` — Cloudflare token used to manage routing + DNS. * `WEBHOOK_AUTH_TOKEN` — bearer token sent to the API (must match `INBOX_WEBHOOK_SECRET`). Keep `WEBHOOK_AUTH_TOKEN` and `INBOX_WEBHOOK_SECRET` identical, or webhooks will be rejected with 401. ### Local dev notes * Update `WEBHOOK_URL` in `apps/inbox/wrangler.toml` to `http://localhost:3003/webhooks/inbox` when testing with the local API. * Set `ADMIN_TOKEN`, `CLOUDFLARE_API_TOKEN`, and `WEBHOOK_AUTH_TOKEN` via `wrangler secret put` (or in CI). ## Testing helpers * `PLOP_API_URL` — optional base URL for tests (defaults to `https://api.plop.email`). * `PLOP_API_KEY` — API key used by E2E polling helpers. # Fly.io deployment ## What gets deployed Fly.io is used to run the API service (`apps/api`) as a container built from the monorepo. The deployment uses Turbo prune to keep the image lean while still including shared packages. ## Files to know * `apps/api/fly.toml` — Fly app configuration (region, port, health checks, scaling). * `apps/api/Dockerfile` — multi-stage Bun build using `turbo prune @plop/api --docker`. * `apps/api/.dockerignore` — trims the build context. ## Prerequisites * Install the Fly CLI (`flyctl`) and log in. * Confirm you have all required API secrets (see `env-and-secrets`). ## Configure the Fly app Update `apps/api/fly.toml` before the first deploy: * `app` — set this to your Fly app name. * `primary_region` — pick the region closest to your database or users. * `min_machines_running` / `[[vm]]` — adjust for your availability + budget. * `PORT` / `internal_port` — keep this aligned with the API port (defaults to `3003`). ## Set required secrets From the repo root, set the API environment variables on Fly. Replace the values below with your production secrets (for local dev, use the `.env` examples in `env-and-secrets`). ```bash fly secrets set --config apps/api/fly.toml \ APP_URL=https://app.plop.email \ NEXT_PUBLIC_SUPABASE_URL=... \ SUPABASE_SECRET_KEY=... \ DATABASE_PRIMARY_URL=... \ DATABASE_LHR_URL=... \ DATABASE_SESSION_POOLER=... \ UPSTASH_REDIS_REST_URL=... \ UPSTASH_REDIS_REST_TOKEN=... \ INBOX_ROOT_DOMAIN=in.plop.email \ INBOX_WEBHOOK_SECRET=... \ ALLOWED_API_ORIGINS=https://app.plop.email ``` Optional billing variables (only if Polar billing is enabled): ```bash fly secrets set --config apps/api/fly.toml \ POLAR_ACCESS_TOKEN=... \ POLAR_ENVIRONMENT=production \ POLAR_WEBHOOK_SECRET=... \ POLAR_STARTER_MONTHLY_PRODUCT_ID=... \ POLAR_STARTER_YEARLY_PRODUCT_ID=... \ POLAR_PRO_MONTHLY_PRODUCT_ID=... \ POLAR_PRO_YEARLY_PRODUCT_ID=... \ POLAR_ENTERPRISE_MONTHLY_PRODUCT_ID=... \ POLAR_ENTERPRISE_YEARLY_PRODUCT_ID=... ``` Fly automatically injects `FLY_REGION` at runtime. The API uses this to route reads to a nearby replica when available. ## Deploy Run the deploy from the repo root: ```bash fly deploy --config apps/api/fly.toml ``` The Dockerfile sets `NODE_ENV=production` and runs the API with Bun. ## Verify ```bash fly status --config apps/api/fly.toml fly logs --config apps/api/fly.toml curl https://.fly.dev/health ``` ## Scaling and regions Common operations: ```bash fly scale count 2 --config apps/api/fly.toml fly scale memory 1024 --config apps/api/fly.toml fly regions add lhr --config apps/api/fly.toml ``` If you add regions, ensure your read replica URLs (`DATABASE_LHR_URL`, `DATABASE_SESSION_POOLER`) are set appropriately. Keep `INBOX_WEBHOOK_SECRET` and the worker `WEBHOOK_AUTH_TOKEN` identical, or webhook delivery will return 401s. # Inbox domains & mailboxes This system supports both a shared root domain and per‑team custom domains. ## Domain layout * **Root inbound domain** is set via `INBOX_ROOT_DOMAIN` (API) and `EMAIL_DOMAIN` (worker). * **Custom domain** is stored in `teamInboxSettings.domain`. * Each organisation may also use a subdomain under the root (for example `company1.in.plop.email`). ## Mailbox resolution rules 1. **Custom domain configured** * `apps/api` resolves the team by `teamInboxSettings.domain = `. * If a mailbox doesn't exist for the team, it is created on first inbound message. 2. **No custom domain** * If the inbound domain matches `INBOX_ROOT_DOMAIN`, the API looks up the mailbox by `{ domain: root, name: mailbox }`. * If no mailbox exists for the root domain, the webhook returns **404**. 3. **Unknown non‑root domain** * If the domain is not the root and there's no `teamInboxSettings` match, the webhook returns **404**. ## Tags and mailbox names * `support+billing@...` stores the message under mailbox `support` with tag `billing`. * `mailboxWithTag` is preserved as the full local‑part (`support+billing`). * Tags are lower‑cased and searchable via the Messages API. Mailbox names are always normalized to lowercase. Keep mailbox naming consistent in test fixtures and docs. # Inbox pipeline This is the end‑to‑end flow for inbound email. ## High‑level flow **Cloudflare Email Routing** Inbound email is routed to the Cloudflare Worker (`apps/inbox`) via catch‑all rules. **Worker storage** The worker stores the raw `.eml` in R2 at `raw///.eml` and metadata in `messages/unprocessed///.json`. **Webhook to API** If `WEBHOOK_URL` is set, the worker posts a webhook payload to `apps/api` (`/webhooks/inbox`). If the response is 2xx, metadata moves to `messages/processed/...`. **API persistence** `apps/api` validates the payload, resolves the team/mailbox, and inserts a record into `inboxMessages`. **UI consumption** `apps/app` reads the data through API/trpc and renders message content and mailbox metadata. ## What gets stored * Raw message: `.eml` blob in R2. * Metadata: sender, recipients, subject, received time, headers, plus HTML/text content (best effort). ## Failure modes * If the webhook fails, the raw message still exists in R2 and metadata stays in `unprocessed`. * The worker can retry the webhook via `POST /admin/inboxes/:localPart/messages/:id/webhook`. If you change webhook auth or the webhook URL, failed messages will not move to `processed` until a successful retry occurs. # Inbox webhook contract The inbox worker POSTs to `apps/api` at `POST /webhooks/inbox`. ## Auth The API requires a bearer token that matches `INBOX_WEBHOOK_SECRET`. ``` Authorization: Bearer ``` ## Payload Required fields (trimmed and normalized by the API): * `event` — `email.received` * `id` — UUID * `domain` — full recipient domain * `tenantSubdomain` — optional * `mailbox` — base local part (lowercased) * `mailboxWithTag` — full local part (including tag) * `tag` — optional * `from`, `to`, `subject` * `receivedAt` — ISO timestamp * `headers` — `{ name, value }[]` * `rawContent` — HTML (best effort) * `plainContent` — text/plain (best effort) ### Example ```json { "event": "email.received", "id": "b3b9c8b1-9d2a-4f1c-9b0f-2d4d2a2f2b3a", "domain": "in.plop.email", "tenantSubdomain": null, "mailbox": "qa", "mailboxWithTag": "qa+login", "tag": "login", "from": "no-reply@example.com", "to": "qa+login@in.plop.email", "subject": "Your login token", "receivedAt": "2025-12-24T12:34:56.000Z", "headers": [ { "name": "Subject", "value": "Your login token" } ], "rawContent": "...", "plainContent": "Your login token is 123456" } ``` `receivedAt` must be a valid ISO timestamp. Invalid dates will return a 422. # Inbox worker admin API All admin endpoints require: ``` Authorization: Bearer ``` ## Mailboxes * `GET /admin/inboxes` — list mailboxes * `GET /admin/inboxes?domain=` — list mailboxes for a subdomain * `GET /admin/inboxes?status=processed` — list processed mailboxes ```bash curl "https:///admin/inboxes?limit=200" \ -H "Authorization: Bearer " ``` ## Messages * `GET /admin/inboxes/:localPart/messages` — list messages * `GET /admin/inboxes/:localPart/messages/:id` — parsed content + eml URL * `GET /admin/inboxes/:localPart/messages/:id/raw` — download raw `.eml` ```bash curl "https:///admin/inboxes/qa/messages?limit=50" \ -H "Authorization: Bearer " ``` ## Webhook retry Repost a message to the main API and mark processed on success: ```bash curl -X POST "https:///admin/inboxes/qa/messages//webhook" \ -H "Authorization: Bearer " ``` ## Routing helpers * `POST /admin/catch-all/worker` — enable routing to the worker * `GET /admin/email-routing/dns` — list required DNS records * `POST /admin/email-routing/dns/enable` — add + lock DNS records * `POST /admin/subdomains` — enable a subdomain (builds `.`) ```bash curl -X POST "https:///admin/catch-all/worker" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ --data '{"enabled":true}' ``` # Inbox worker deployment ## Prerequisites * A Cloudflare zone for your apex domain (example: `plop.email`). * Email Routing enabled for the zone. * Cloudflare is the authoritative DNS provider for the zone. * An R2 bucket for raw email storage. * A Cloudflare API token with: * Zone -> Email Routing -> Edit * Zone -> DNS -> Edit ## Configure `wrangler.toml` Update `apps/inbox/wrangler.toml` before deploy: * `EMAIL_DOMAIN` - inbound root domain (example: `in.plop.email`). * `EMAIL_WORKER_NAME` - deployed worker name used by the catch-all rule. * `CLOUDFLARE_ZONE_ID` - zone id for the apex domain (`plop.email`). * `WEBHOOK_URL` - full URL to the API webhook (example: `https://api.plop.email/webhooks/inbox`). * `WEBHOOK_TIMEOUT_MS` - optional timeout override. * `INBOX_STORAGE` bucket binding - match the R2 bucket name. `workers_dev = true` keeps a `workers.dev` URL available for the admin API. If you disable it, add a custom domain or route or the admin endpoints will not be reachable. ## Create the R2 bucket ```bash cd apps/inbox bunx wrangler r2 bucket create inbox-emails ``` ## Set secrets ```bash cd apps/inbox bunx wrangler secret put ADMIN_TOKEN bunx wrangler secret put CLOUDFLARE_API_TOKEN bunx wrangler secret put WEBHOOK_AUTH_TOKEN ``` `WEBHOOK_AUTH_TOKEN` must match `INBOX_WEBHOOK_SECRET` on the API or webhook delivery will return 401. ## Deploy ```bash cd apps/inbox bun run deploy ``` ## Bootstrap routing and DNS After deploy, configure the catch-all rule and DNS records using the admin API helper: ```bash cd apps/inbox WORKER_URL='https://' \ ADMIN_TOKEN='' \ EMAIL_DOMAIN='in.plop.email' \ ./scripts/bootstrap.sh # Optional: enable a subdomain under the inbox root WORKER_URL='https://' \ ADMIN_TOKEN='' \ SUBDOMAIN='company1' \ ./scripts/bootstrap.sh ``` ## Verify * Health check: `curl https:///health` * Admin access: `curl -H "Authorization: Bearer " "https:///admin/inboxes?limit=10"` * Send a test email to `hello@in.plop.email` and confirm it is stored in R2. ## Common gotchas * `EMAIL_WORKER_NAME` must match the deployed worker name, including any env suffix. * `WEBHOOK_URL` must include the scheme (`https://`). * DNS must be managed by Cloudflare or Email Routing will not activate. # Welcome This site collects product documentation, internal playbooks, and platform references for Plop (plop.email). Use it to track UI flows, data contracts, and operational notes in one place. *** ## Getting Started Get started testing emails in 5 minutes with code examples for Playwright, Cypress, Jest, and pytest. Common patterns for testing magic links, OTPs, password resets, and transactional emails. Full API reference for listing and fetching inbox messages. Advanced polling patterns with Playwright and retry logic. Install @plop-email/sdk and use waitFor() to poll for emails in your tests. Install plop-sdk with sync and async clients for pytest and beyond. *** ## Platform Architecture Understand how the apps and packages fit together. Follow the inbound email flow from Cloudflare to the UI. Root vs custom domains, tags, and mailbox resolution. Security protections against phishing and impersonation. *** ## Contributing * Add new docs under `apps/docs/content/docs`. * Update `apps/docs/content/docs/meta.json` to order pages and sections. * Keep docs focused on decisions, flows, and operational steps. # Messages API The REST API exposes message retrieval for polling, search, and E2E flows. Messages are retained based on your plan (Starter: 14 days, Pro: 90 days, Enterprise: custom). Older messages are not available. ## Endpoints ### List messages `GET /v1/messages` Returns the newest message summaries first. Use this when you need filters before fetching full content. Query params: * `mailbox` — local part or full address * `tag` / `tags` — tag filters * `q` — free‑text search * `limit` — 1–200 (default 50) * `start` / `end` — date range (YYYY‑MM‑DD) * `since` — ISO timestamp (preferred for polling) * `to`, `from`, `subject` — partial matches ### Latest matching message `GET /v1/messages/latest` Returns the single most recent match with full content. **Limit is ignored**. This is the recommended endpoint for E2E polling. ### Message by id `GET /v1/messages/:id` Fetch full content for a specific message. ## Scopes All message endpoints require one of: * `api.full` * `email.full` * `email.mailbox` Mailbox‑scoped keys must match the `mailbox` filter if you provide it. ## Response shape The list endpoint returns `data: MessageSummary[]`. The detail endpoints return `data: MessageDetail` with: * `headers` * `htmlContent` / `textContent` * `domain`, `tenantSubdomain` * `mailbox`, `mailboxWithTag`, `tag`, `receivedAt` ## Example ```bash curl -H "Authorization: Bearer " \ "https://api.plop.email/v1/messages?mailbox=qa&tag=login&limit=10" ``` # Onboarding & Team/Mailbox Generation Plop uses intelligent email and team name generation to create personalized, secure, and collision-free mailbox addresses during onboarding. ## Smart Mailbox Generation ### Consumer vs Business Email Detection The system automatically detects **consumer email providers** (Gmail, Yahoo, Outlook, etc.) and generates appropriate mailbox names: ```typescript // Input: john.doe@gmail.com // Mailbox: john.doe-x7k2@in.plop.email extractMailboxSeedFromEmail('john.doe@gmail.com') // Returns: 'john.doe-x7k2' (username + random suffix) ``` **Why?** Using "gmail" as the mailbox would be generic and cause collisions. The username + random suffix ensures uniqueness while remaining personal. ```typescript // Input: sarah@acme.com // Mailbox: acme@in.plop.email extractMailboxSeedFromEmail('sarah@acme.com') // Returns: 'acme' (domain prefix) ``` **Why?** Business domains represent company identity, so using the domain name creates professional, recognizable mailboxes. ### Consumer Email Providers (24+) The following domains trigger consumer email handling: * **Google**: gmail.com, googlemail.com * **Microsoft**: outlook.com, hotmail.com, live.com, msn.com * **Yahoo**: yahoo.com, ymail.com, yahoo.co.uk, yahoo.ca, yahoo.com.au * **Apple**: icloud.com, me.com, mac.com * **Others**: aol.com, protonmail.com, proton.me, mail.com, zoho.com, fastmail.com, hey.com, pm.me, tutanota.com, gmx.com, gmx.net ### Random Suffix Generation All random suffixes use **cryptographically secure** generation via `crypto.getRandomValues()`: ```typescript generateRandomSuffix(4) // Returns: 'a3f8', 'x7k2', etc. ``` * **Length**: 4 characters (alphanumeric) * **Space**: 1.7M+ combinations (36^4) * **Security**: Prevents prediction of mailbox names ## Smart Team Naming Team names are generated with a **priority-based system** for personalization: **Priority 1: User's Full Name** ```typescript // User: "John Doe" (from session.user.full_name) extractTeamNameFromEmail('john@gmail.com', 'John Doe') // Returns: "John's Team" ``` Most personal and professional option. **Priority 2: Business Domain** ```typescript // No full name, business email extractTeamNameFromEmail('sarah@acme.com') // Returns: "Acme" ``` Uses the domain name capitalized for company branding. **Priority 3: Name-like Username** ```typescript // No full name, consumer email with name pattern extractTeamNameFromEmail('john.doe@gmail.com') // Returns: "John's Team" ``` Extracts and capitalizes names from usernames like "john.doe", "john\_smith", etc. **Priority 4: Fallback** ```typescript // No full name, non-name username extractTeamNameFromEmail('jd123456@gmail.com') // Returns: "My Team" ``` Generic fallback for unrecognizable patterns. ### Name Extraction Logic The system intelligently parses usernames: ```typescript // Recognizes name patterns extractNameFromUsername('john.doe') // "John" extractNameFromUsername('john_smith') // "John" extractNameFromUsername('john-doe') // "John" // Ignores numeric suffixes extractNameFromUsername('john123') // "John" // Rejects non-name patterns extractNameFromUsername('jd123456') // null extractNameFromUsername('user') // null ``` ## Implementation Details ### Key Functions All helper functions are in `apps/api/src/trpc/routers/team.ts` and `inbox.ts` #### `isConsumerEmailDomain(domain: string): boolean` Checks if a domain is in the consumer provider list. #### `extractMailboxSeedFromEmail(email: string): string` Returns appropriate mailbox seed based on email type. #### `extractTeamNameFromEmail(email: string, fullName?: string): string` Priority-based team name generation with personalization. #### `generateRandomSuffix(length: number): string` Cryptographically secure random suffix for uniqueness. #### `getFirstName(fullName: string): string | null` Extracts first name from full name string. #### `extractNameFromUsername(username: string): string | null` Parses name-like patterns from email usernames. ### Mailbox Generation Flow ### Team Generation Flow ## Onboarding State Management ### Fresh Start on Signup The system automatically **clears localStorage** when users sign up to ensure a clean onboarding experience: ```typescript // apps/app/src/utils/onboarding-storage.ts export function clearOnboardingState() { if (typeof window === 'undefined') return localStorage.removeItem(WELCOME_DISMISSED_KEY) } ``` This prevents: * Stale welcome banners from previous sessions * Confusion when testing with multiple accounts * Cookie/state pollution across sign-ups ### Integration Points The cleanup is called in **both authentication flows**: ```typescript // apps/app/src/components/sign-up-form.tsx const handleSubmit = async (e) => { // ... signup logic ... // Clear stale state clearOnboardingState() setPreferredAuthCookie("password") router.push(`/sign-up-success?email=...`) } ``` ```typescript // apps/app/src/components/google-signin.tsx const handleSignin = () => { // Clear stale state BEFORE OAuth redirect clearOnboardingState() const redirectTo = new URL("/api/auth/callback", ...) supabase.auth.signInWithOAuth({ provider: "google", options: { redirectTo: redirectTo.toString() } }) } ``` ## UX Benefits ### Before vs After **Consumer Email**: `john@gmail.com` **Mailbox**: `gmail@in.plop.email` ❌ **Team**: "Gmail" ❌ **Experience**: Generic, confusing, collision-prone **Consumer Email**: `john@gmail.com` **Mailbox**: `john-x7k2@in.plop.email` ✅ **Team**: "John's Team" ✅ **Experience**: Personal, unique, professional ### Key Improvements 1. **Personalization**: Names feel personal, not generic 2. **Uniqueness**: Random suffixes prevent collisions 3. **Clarity**: Business users get company-branded names 4. **Security**: Protected from impersonation (see Reserved Names docs) 5. **Fresh Start**: Clean onboarding every signup ## Testing ### Test Cases ```typescript // Consumer with full name expect(extractTeamNameFromEmail('john@gmail.com', 'John Doe')) .toBe("John's Team") // Consumer with name-like username expect(extractTeamNameFromEmail('john.doe@gmail.com')) .toBe("John's Team") // Consumer with non-name username expect(extractTeamNameFromEmail('jd123456@gmail.com')) .toBe("My Team") // Business email expect(extractTeamNameFromEmail('sarah@acme.com')) .toBe("Acme") // Consumer mailbox generation expect(extractMailboxSeedFromEmail('john@gmail.com')) .toMatch(/^john-[a-z0-9]{4}$/) // Business mailbox generation expect(extractMailboxSeedFromEmail('sarah@acme.com')) .toBe('acme') ``` ## API Integration ### Auto-Setup Flow The `team.autoSetup` procedure uses these helpers automatically: ```typescript // apps/api/src/trpc/routers/team.ts autoSetup: protectedProcedure .mutation(async ({ ctx, input }) => { const email = ctx.user?.email ?? '' const fullName = ctx.session?.user?.full_name // Generate personalized team name const teamName = extractTeamNameFromEmail(email, fullName) // Create team const teamId = await createTeam(teamName, plan) // Generate smart mailbox seed const mailboxSeed = extractMailboxSeedFromEmail(email) const candidates = buildMailboxCandidates(mailboxSeed) // Try candidates until one succeeds for (const candidate of candidates) { // ... create mailbox ... } }) ``` ### Starter Plan Flow Similar logic applies to `inbox.ensureStarterMailbox`: ```typescript // apps/api/src/trpc/routers/inbox.ts ensureStarterMailbox: teamProcedure .mutation(async ({ ctx }) => { const team = await getTeam(ctx.teamId) const candidates = buildMailboxCandidates(team.name) // Uses isGenericTeamName() to detect consumer domains // Falls back to random patterns if team name is generic }) ``` **Generic Team Names**: If a team name is "Gmail", "Yahoo", etc., the system automatically uses `inbox-{random}` patterns to avoid confusion. ## Related Documentation * [Reserved Mailbox Names](/reserved-mailbox-names) - Security and blocked names * [Inbox Domains & Mailboxes](/inbox-domains-mailboxes) - Domain resolution * [System Overview](/system-overview) - Architecture overview # Python SDK The `plop-sdk` package provides sync and async clients for the Plop API. It uses `httpx` for HTTP and `pydantic` for typed responses. ## Installation ```bash pip install plop-sdk ``` ## Quick start ```python from plop_sdk import Plop plop = Plop() # reads PLOP_API_KEY env var # List mailboxes mailboxes = plop.mailboxes.list() # Fetch latest message latest = plop.messages.latest(mailbox="qa", tag="signup") print(latest.subject) print(latest.text_content) ``` ## Wait for an email The `wait_for` method polls the API until a matching message arrives or the timeout expires: ```python email = plop.messages.wait_for( mailbox="qa", tag="verification", timeout=30, # seconds interval=1.0, # poll interval ) import re otp = re.search(r"\d{6}", email.text_content).group() ``` Raises `PlopTimeoutError` if no match is found within the timeout. ## pytest example ```python import pytest from plop_sdk import Plop @pytest.fixture def plop(): return Plop() def test_welcome_email(plop): # trigger your app to send the email, then: email = plop.messages.wait_for( mailbox="qa", tag="welcome", timeout=30, ) assert "Welcome" in email.subject assert "Get Started" in email.html_content ``` ## Async client ```python import asyncio from plop_sdk import AsyncPlop async def main(): async with AsyncPlop() as plop: email = await plop.messages.wait_for( mailbox="qa", tag="login", ) print(email.subject) asyncio.run(main()) ``` ## List and filter messages ```python messages = plop.messages.list( mailbox="qa", tag="login", since="2026-01-01T00:00:00Z", limit=10, ) for msg in messages: print(f"{msg.from_address}: {msg.subject}") ``` ## Get a message by ID ```python message = plop.messages.get("uuid-here") print(message.html_content) ``` ## Verify webhook signatures ```python is_valid = plop.webhooks.verify( secret="whsec_...", signature=request.headers["x-plop-signature"], body=raw_body, ) ``` ## Manage mailboxes ```python mailbox = plop.mailboxes.create(name="staging") plop.mailboxes.update(mailbox.id, name="staging-v2") plop.mailboxes.delete(mailbox.id) ``` ## Delete a message ```python result = plop.messages.delete("uuid-here") ``` ## Stream messages (SSE) ```python for message in plop.messages.stream(mailbox="qa"): print(f"New: {message.subject} from {message.from_address}") ``` ## Manage webhooks ```python created = plop.webhooks.create(url="https://example.com/webhook") print(created.secret) # shown once endpoints = plop.webhooks.list() plop.webhooks.toggle(endpoints[0].id, active=False) ``` ## Rotate API key ```python result = plop.api_keys.rotate() print(result.key) # new key — update your env ``` ## Error handling Methods raise typed exceptions: ```python from plop_sdk import Plop, PlopAuthError, PlopNotFoundError plop = Plop() try: message = plop.messages.get("nonexistent-id") except PlopNotFoundError: print("Message not found") except PlopAuthError: print("Invalid API key") ``` | Exception | HTTP Status | | -------------------- | ------------------- | | `PlopAuthError` | 401 | | `PlopForbiddenError` | 403 | | `PlopNotFoundError` | 404 | | `PlopTimeoutError` | — (polling timeout) | ## Configuration ```python plop = Plop( api_key="plop_...", # or set PLOP_API_KEY env var base_url="https://api.plop.email", # default ) ``` ## API reference | Method | Description | | -------------------------------------- | -------------------------------- | | `plop.mailboxes.list(**params)` | List mailboxes | | `plop.messages.list(**params)` | List messages with filters | | `plop.messages.get(id)` | Get message by ID | | `plop.messages.latest(**params)` | Get most recent matching message | | `plop.messages.wait_for(**params)` | Poll until a message arrives | | `plop.mailboxes.create(name=...)` | Create a mailbox | | `plop.mailboxes.update(id, name=...)` | Rename a mailbox | | `plop.mailboxes.delete(id)` | Delete a mailbox | | `plop.messages.delete(id)` | Delete a message | | `plop.messages.stream(**params)` | Stream new messages (SSE) | | `plop.webhooks.list()` | List webhook endpoints | | `plop.webhooks.create(url=...)` | Create a webhook endpoint | | `plop.webhooks.delete(id)` | Delete a webhook endpoint | | `plop.webhooks.toggle(id, active=...)` | Enable/disable a webhook | | `plop.webhooks.deliveries(id)` | List webhook deliveries | | `plop.webhooks.verify(**opts)` | Verify webhook HMAC signature | | `plop.api_keys.rotate()` | Rotate the current API key | ## Links * [PyPI package](https://pypi.org/project/plop-sdk/) * [GitHub](https://github.com/plop-email/plop-python) * [TypeScript SDK](/typescript-sdk) # Quick Start Add email testing to your application in minutes. No mail servers to configure—just API calls. ## 1. Get Your API Key Sign up at [plop.email](https://app.plop.email) and create an API key from your dashboard. ## 2. Choose Your Integration ```typescript import { test, expect } from '@playwright/test'; test('signup sends welcome email', async ({ page, request }) => { const testEmail = `signup+${Date.now()}@in.plop.email`; // Trigger signup await page.goto('/signup'); await page.fill('[name="email"]', testEmail); await page.fill('[name="password"]', 'SecurePass123!'); await page.click('button[type="submit"]'); // Wait and fetch email await page.waitForTimeout(2000); const response = await request.get( 'https://api.plop.email/v1/messages/latest', { params: { to: testEmail }, headers: { Authorization: `Bearer ${process.env.PLOP_API_KEY}` }, } ); const email = await response.json(); // Assert expect(email.data.subject).toContain('Welcome'); expect(email.data.htmlContent).toContain('Get Started'); }); ``` ```typescript // cypress/support/commands.ts Cypress.Commands.add('getLatestEmail', (to: string) => { return cy.request({ method: 'GET', url: 'https://api.plop.email/v1/messages/latest', headers: { Authorization: `Bearer ${Cypress.env('PLOP_API_KEY')}` }, qs: { to }, }).its('body.data'); }); // cypress/e2e/signup.cy.ts it('sends welcome email', () => { const testEmail = `signup+${Date.now()}@in.plop.email`; cy.visit('/signup'); cy.get('[name="email"]').type(testEmail); cy.get('[name="password"]').type('SecurePass123!'); cy.get('form').submit(); cy.wait(2000); cy.getLatestEmail(testEmail).then((email) => { expect(email.subject).to.include('Welcome'); }); }); ``` ```typescript import { sendWelcomeEmail } from '../src/email'; async function fetchEmail(to: string) { const response = await fetch( `https://api.plop.email/v1/messages/latest?to=${encodeURIComponent(to)}`, { headers: { Authorization: `Bearer ${process.env.PLOP_API_KEY}` } } ); return (await response.json()).data; } test('sends welcome email', async () => { const testEmail = `welcome+${Date.now()}@in.plop.email`; await sendWelcomeEmail({ to: testEmail, name: 'Test User' }); await new Promise(r => setTimeout(r, 2000)); const email = await fetchEmail(testEmail); expect(email.subject).toBe('Welcome to Our App!'); expect(email.htmlContent).toContain('Test User'); }); ``` ```python import pytest import requests import time import os @pytest.fixture def plop_client(): class PlopClient: def get_latest(self, to: str): response = requests.get( "https://api.plop.email/v1/messages/latest", params={"to": to}, headers={"Authorization": f"Bearer {os.environ['PLOP_API_KEY']}"}, ) return response.json()["data"] return PlopClient() def test_welcome_email(plop_client): test_email = f"pytest+{int(time.time())}@in.plop.email" # Send email from your app send_welcome_email(to=test_email, name="Test User") time.sleep(2) # Verify email = plop_client.get_latest(test_email) assert "Welcome" in email["subject"] assert "Test User" in email["htmlContent"] ``` ```bash # Fetch latest email for a specific address curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://api.plop.email/v1/messages/latest?to=test@in.plop.email" # Response { "data": { "id": "msg_abc123", "subject": "Welcome!", "from": "hello@yourapp.com", "to": "test@in.plop.email", "htmlContent": "...", "textContent": "Welcome to our app!", "receivedAt": "2025-01-01T12:00:00.000Z" } } ``` **Using an SDK?** The official [TypeScript SDK](/typescript-sdk) and [Python SDK](/python-sdk) handle polling and error handling for you — no manual `fetch` or `waitForTimeout` needed. ## 3. Key Concepts ### Unique Test Addresses Generate a unique email for each test to avoid conflicts: ```typescript const testEmail = `signup+${Date.now()}@in.plop.email`; // Result: signup+1704067200000@in.plop.email ``` The `+tag` part (plus-addressing) isolates each test run while using the same mailbox. ### Polling for Email Arrival Emails may take 1-3 seconds to arrive. Use polling with retry logic: ```typescript async function waitForEmail(to: string, timeout = 10000) { const start = Date.now(); while (Date.now() - start < timeout) { const response = await fetch( `https://api.plop.email/v1/messages/latest?to=${to}`, { headers: { Authorization: `Bearer ${process.env.PLOP_API_KEY}` } } ); if (response.ok) { const { data } = await response.json(); if (data) return data; } await new Promise(r => setTimeout(r, 1000)); } throw new Error(`No email received for ${to}`); } ``` **Tip:** The official SDKs handle polling automatically with `plop.messages.waitFor()` (TypeScript) or `plop.messages.wait_for()` (Python). See [TypeScript SDK](/typescript-sdk) or [Python SDK](/python-sdk). ### Extract Links and Tokens Common patterns for extracting verification links and OTPs: ```typescript // Extract link from HTML const linkMatch = email.htmlContent.match(/href="([^"]*verify[^"]*)"/); const verifyLink = linkMatch?.[1]; // Extract 6-digit OTP from text const otpMatch = email.textContent.match(/\b(\d{6})\b/); const otp = otpMatch?.[1]; ``` ## Environment Setup ### CI/CD (GitHub Actions) ```yaml - name: Run E2E tests run: npm run test:e2e env: PLOP_API_KEY: ${{ secrets.PLOP_API_KEY }} ``` ### Local Development ```bash # .env PLOP_API_KEY=your_api_key_here ``` ## Response Fields The API returns these fields for each message: | Field | Type | Description | | ------------- | ------ | ------------------ | | `id` | string | Unique message ID | | `subject` | string | Email subject line | | `from` | string | Sender address | | `to` | string | Recipient address | | `htmlContent` | string | HTML body content | | `textContent` | string | Plain text content | | `receivedAt` | string | ISO timestamp | | `headers` | object | Email headers | ## Next Steps * [Messages API Reference](/messages-api) — Full endpoint documentation * [E2E Email Polling](/e2e-email-polling) — Advanced polling patterns * [Inbox Domains & Mailboxes](/inbox-domains-mailboxes) — Custom domains and tags # Reserved Mailbox Names Plop reserves **180+ mailbox names** to prevent security threats including phishing, Business Email Compromise (BEC), and brand impersonation. ## Security Model ### Threat Prevention Executive titles (CEO, CFO, Director) blocked to prevent social engineering attacks targeting authority figures. Government agencies, payment services, and shipping companies protected against common phishing campaigns. Major tech brands and service providers reserved to prevent spoofing attacks. Authority figures and common service accounts blocked to prevent manipulation attacks. ## Reserved Categories ### RFC 2142 Standard (8 names) **Internet standard addresses** required by email protocols: ``` abuse, admin, administrator, hostmaster, mailer-daemon, postmaster, root, webmaster ``` These addresses are expected by mail servers and should never be used for user mailboxes. *** ### System & Technical (11 names) **System operations** and technical infrastructure: ``` daemon, system, sys, sysadmin, devops, ops, noc, it, tech, technical ``` Prevents confusion with actual system addresses and operational tooling. *** ### Executive & Authority (14 names) 🚨 **Critical for BEC prevention** - blocks common authority impersonation: ``` ceo, cfo, cto, coo, ciso, president, vp, director, manager, executive, leadership, board, founder, owner ``` **Business Email Compromise (BEC)** attacks often impersonate executives to authorize fraudulent transactions. Blocking these names is critical for security. **Example Attack Prevented:** ``` From: ceo@in.plop.email Subject: Urgent wire transfer needed ``` *** ### Government/Authority (11 names) 🚨 **Authority impersonation prevention** - blocks government agencies: ``` irs, fbi, cia, nsa, government, gov, federal, state, police, court ``` **Common Scams Prevented:** * IRS tax fraud notifications * FBI investigation threats * Court summons phishing * Government grant scams *** ### Payment Services (8 names) 🚨 **Financial phishing prevention** - protects payment platforms: ``` paypal, stripe, venmo, cashapp, zelle, bank, banking ``` **Example Attack Prevented:** ``` From: paypal@in.plop.email Subject: Your account has been suspended Link: http://phishing-site.com/paypal-login ``` *** ### Common Service Providers (9 names) **Brand protection** - major tech companies: ``` google, gmail, microsoft, outlook, yahoo, apple, icloud, amazon, aws ``` Prevents verification phishing and account recovery scams. *** ### Shipping/Logistics (7 names) 🚨 **Delivery scam prevention** - common phishing targets: ``` fedex, ups, usps, dhl, shipping, delivery, tracking ``` **Common Scams Prevented:** * Package delivery failed notifications * Customs fees payment requests * Tracking link phishing * Address confirmation scams *** ### Social Media (7 names) **Account verification phishing** prevention: ``` facebook, twitter, x, instagram, linkedin, youtube, tiktok ``` *** ### Service Accounts (13 names) **Automated sender addresses** with common variations: ``` no-reply, noreply, no_reply, donotreply, do-not-reply, bounce, bounces, notifications, notification, alerts, alert, newsletter, newsletters, digest ``` Multiple variations (hyphens, underscores) ensure comprehensive coverage. *** ### Customer Service & Support (10 names) ``` support, help, helpdesk, service, contact, info, information, inquiries, inquiry, feedback, questions ``` *** ### Sales & Marketing (8 names) ``` sales, marketing, business, partner, partners, affiliate, affiliates, reseller ``` *** ### Financial & Billing (9 names) ``` billing, finance, accounting, accounts, payment, payments, payroll, invoice, invoices ``` *** ### Legal & Compliance (8 names) ``` legal, compliance, privacy, gdpr, dpo, terms, dmca, copyright ``` *** ### Security (8 names) ``` security, abuse, spam, phishing, fraud, cert, csirt ``` *** ### API & Development (10 names) ``` api, webhook, webhooks, developer, dev, staging, test, testing, demo, sandbox ``` *** ### Human Resources (9 names) ``` hr, humanresources, human-resources, recruiting, recruitment, careers, jobs, hiring ``` *** ### Brand Protection - Plop (8 names) **Internal brand** protection: ``` plop, team, teams, company, official, staff, employee, employees ``` *** ### Common Generic Terms (10 names) **Prevent confusion** with generic addresses: ``` all, everyone, nobody, default, example, sample, mail, email, inbox, outbox ``` *** ### Temporary & Testing (9 names) ``` temp, temporary, tmp, trash, junk, spam, test, testing, qa ``` *** ### Special Values (6 names) ``` null, undefined, none, unknown, anonymous, guest ``` *** ### News & Updates (6 names) ``` news, updates, announcements, press, media, pr ``` *** ### Operations (9 names) ``` operations, deployment, deploy, release, releases, status, uptime, monitoring, logs ``` *** ### Reserved for Future (5 names) ``` reserved, future, coming-soon, beta, alpha ``` ## Implementation ### Location ``` packages/billing/src/plans.ts ``` ### Usage The `isReservedMailboxName()` function checks all reserved names: ```typescript import { isReservedMailboxName } from '@plop/billing' // Check if a mailbox name is reserved if (isReservedMailboxName('ceo')) { throw new Error('Mailbox name is reserved') } // Validation in mailbox creation const mailboxNameSchema = z .string() .transform((value) => value.toLowerCase()) .refine((value) => !isReservedMailboxName(value), { message: 'Mailbox name is reserved.' }) ``` ### Validation Points Reserved names are checked at: 1. **Mailbox creation** (`inbox.mailboxes.create`) 2. **Mailbox update** (`inbox.mailboxes.update`) 3. **Auto-setup flow** (`team.autoSetup`) 4. **Starter mailbox** (`inbox.mailboxes.ensureStarterMailbox`) All checks are **case-insensitive** - "CEO", "ceo", and "Ceo" are all blocked. ## Adding Reserved Names To add new reserved names, edit `RESERVED_MAILBOX_NAMES` in `packages/billing/src/plans.ts`: ```typescript export const RESERVED_MAILBOX_NAMES = new Set([ // ... existing names ... // New Category "new-reserved-name", "another-reserved", // ... more names ... ]) ``` **Identify the category** Place new names in the appropriate category (or create a new one). **Add with comment** Include category comment explaining the security rationale. **Consider variations** Add common variations (hyphens, underscores, plurals). **Test validation** Ensure the name is properly blocked in mailbox creation. ### Recommended Additions **Future considerations** for expanding the reserved list: * **Cryptocurrency**: coinbase, binance, crypto, bitcoin, ethereum * **Healthcare**: doctor, hospital, medical, patient, healthcare * **Education**: university, college, student, admissions, registrar * **Regional Government**: specific country agencies (hmrc, cra, ato, etc.) ## Security Best Practices ### Why Reserve Names? ❌ **Vulnerable System:** ``` Attacker creates: ceo@in.plop.email Sends to employee: "Urgent wire transfer needed" Employee trusts @in.plop.email domain Result: $50,000 fraudulent transfer ``` ✅ **Protected System:** ``` Attacker attempts: ceo@in.plop.email System blocks: "Mailbox name is reserved" Attack prevented at source Result: Employee safe, no fraud ``` ### Defense in Depth Reserved names are **one layer** of security: 1. **Reserved Names** - Prevent malicious mailbox creation 2. **Email Authentication** - SPF, DKIM, DMARC validation 3. **User Education** - Training on phishing awareness 4. **Rate Limiting** - Prevent bulk mailbox creation 5. **Monitoring** - Detect suspicious patterns Reserved names prevent **internal** impersonation. External attackers can still use fake domains. Always verify sender domains and use email authentication. ## Real-World Attack Examples ### BEC (Business Email Compromise) **Attack Vector:** ``` From: cfo@in.plop.email (BLOCKED ✅) To: accounts@company.com Subject: Urgent: Wire Transfer Required Please transfer $75,000 to this account immediately. This is time-sensitive for the acquisition deal. - CFO ``` **Prevention:** `cfo` is reserved, attack fails at mailbox creation. *** ### Phishing - Government **Attack Vector:** ``` From: irs@in.plop.email (BLOCKED ✅) To: taxpayer@gmail.com Subject: Tax Refund Pending Click here to verify your identity and receive your $2,400 refund. ``` **Prevention:** `irs` is reserved, prevents government impersonation. *** ### Phishing - Delivery **Attack Vector:** ``` From: fedex@in.plop.email (BLOCKED ✅) To: customer@example.com Subject: Package Delivery Failed Your package could not be delivered. Click to reschedule and pay $4.95 customs fee. ``` **Prevention:** `fedex` is reserved, stops delivery scams. *** ### Brand Impersonation **Attack Vector:** ``` From: paypal@in.plop.email (BLOCKED ✅) To: user@example.com Subject: Account Limited Your PayPal account has been limited. Click to verify your identity. ``` **Prevention:** `paypal` is reserved, blocks financial phishing. ## Statistics **180+ names** Expanded from 26 original names **17 categories** Organized by threat type **BEC, Phishing, Impersonation** Multi-layered protection ## Related Documentation * [Onboarding & Team/Mailbox Generation](/onboarding-team-mailbox-generation) - Name generation logic * [Inbox Domains & Mailboxes](/inbox-domains-mailboxes) - Domain resolution * [System Overview](/system-overview) - Architecture overview # Supabase auth emails Plop ships a Supabase `send-email` Edge Function that replaces the default Auth emails with Plop-branded templates (confirm email, magic link, reset password). ## What you need * A Resend account and verified sending domain. * The Supabase CLI configured for your project. ## Dependencies Supabase Edge Functions read dependencies from a per-function `deno.json`. We keep `packages/supabase/supabase/functions/send-email/deno.json` even though imports are pinned via `npm:` specifiers in the function code. ## Deploy the Edge Function From the repo root: ```bash supabase functions deploy send-email --project-ref \ --workdir packages/supabase ``` The function is configured with `verify_jwt = false` so Supabase Auth can call it without a user JWT. The config lives in `packages/supabase/supabase/config.toml` under `[functions.send-email]`. Then set secrets for the function (Supabase stores secrets encrypted and makes them available to Edge Functions at runtime): ```bash supabase secrets set --project-ref \ RESEND_API_KEY=... \ SEND_EMAIL_HOOK_SECRET=... \ EMAIL_FROM="Plop " \ APP_URL="https://app.plop.email" ``` ## Connect the Supabase Auth hook In the Supabase dashboard: 1. Go to **Auth > Hooks**. 2. Enable **Send email** and set the URL to: `https://.supabase.co/functions/v1/send-email` 3. Copy the hook secret and set it as `SEND_EMAIL_HOOK_SECRET` (see above). Use the raw base64 secret value (strip the `whsec_` prefix if Supabase shows one). 4. Ensure **Auth > URL Configuration** includes your app URL in the allowlist. ## Local development * Ensure `auth.site_url` and `auth.additional_redirect_urls` in `packages/supabase/supabase/config.toml` include `http://localhost:3000`. * Run local Supabase with `bun dev:supabase`. * Use the Auth UI to trigger a reset or signup flow and confirm the hook is called by inspecting the function logs. ## System emails (team invites) Team invites are sent from the API using the same templates. Configure in `apps/api/.env`: ```bash RESEND_API_KEY=... RESEND_FROM="Plop " ``` Invites link to `${APP_URL}/teams?invite=`, and the invitee accepts inside the app after signing in. # System overview This monorepo ships a product app, public marketing site, API, and an inbox worker. The key workflows are split across these surfaces, with shared code in `packages/*`. ## Apps at a glance * `apps/app` — product UI (Next.js). Primary interface for teams and inbox settings. * `apps/api` — API + webhook intake (Hono + tRPC). * `apps/inbox` — Cloudflare Email Routing worker that receives inbound email. * `apps/web` — marketing site. ## Shared packages * `packages/db` — Drizzle schema and query helpers. * `packages/supabase` — Supabase client utilities + schema/migrations. * `packages/ui` — shared UI primitives and Tailwind config. * `packages/kv` — rate limiting helpers. ## Core workflows **Inbound email ingestion** Cloudflare Email Routing forwards inbound email to `apps/inbox`, which stores the raw `.eml` in R2 and posts a webhook to `apps/api`. **Webhook storage** `apps/api` validates the webhook, resolves the team + mailbox, and writes records into the database. **UI display** `apps/app` reads inbox data via API/trpc to show messages and mailbox configuration. The inbox worker and API are intentionally decoupled. If the webhook fails, the worker retains the message in R2 and can retry later via the admin endpoint. ## Where to look next * Inbox pipeline details: `inbox-pipeline`. * Domain + mailbox rules: `inbox-domains-mailboxes`. * Pulling email in E2E tests: `e2e-email-polling`. # Testing Patterns Practical patterns for testing common email workflows in your application. ## Authentication Flows ### Magic Link Authentication Test passwordless login with magic links: ```typescript test('magic link grants access', async ({ page, request }) => { const testEmail = `magic+${Date.now()}@in.plop.email`; // Request magic link await page.goto('/login'); await page.fill('[name="email"]', testEmail); await page.click('text=Send Magic Link'); // Fetch email await page.waitForTimeout(2000); const response = await request.get( 'https://api.plop.email/v1/messages/latest', { params: { to: testEmail }, headers: { Authorization: `Bearer ${process.env.PLOP_API_KEY}` }, } ); const { data: email } = await response.json(); // Extract and use magic link const linkMatch = email.htmlContent.match(/href="([^"]*magic[^"]*)"/); expect(linkMatch).toBeTruthy(); await page.goto(linkMatch[1]); await expect(page.locator('text=Dashboard')).toBeVisible(); }); ``` ### OTP / 2FA Verification Extract and submit one-time passwords: ```typescript test('2FA login with OTP', async ({ page, request }) => { const testEmail = `otp+${Date.now()}@in.plop.email`; // Login with credentials await page.goto('/login'); await page.fill('[name="email"]', testEmail); await page.fill('[name="password"]', 'SecurePass123!'); await page.click('button[type="submit"]'); // Wait for 2FA prompt await expect(page.locator('text=Enter verification code')).toBeVisible(); // Fetch OTP email const response = await request.get( 'https://api.plop.email/v1/messages/latest', { params: { to: testEmail }, headers: { Authorization: `Bearer ${process.env.PLOP_API_KEY}` }, } ); const { data: email } = await response.json(); // Extract 6-digit OTP const otpMatch = email.textContent.match(/\b(\d{6})\b/); expect(otpMatch).toBeTruthy(); // Submit OTP await page.fill('[name="otp"]', otpMatch[1]); await page.click('button:has-text("Verify")'); await expect(page.locator('text=Dashboard')).toBeVisible(); }); ``` ### Password Reset Test the complete password reset flow: ```typescript test('password reset email works', async ({ page, request }) => { const testEmail = `reset+${Date.now()}@in.plop.email`; // Request reset await page.goto('/forgot-password'); await page.fill('[name="email"]', testEmail); await page.click('button[type="submit"]'); // Fetch reset email await page.waitForTimeout(2000); const response = await request.get( 'https://api.plop.email/v1/messages/latest', { params: { to: testEmail }, headers: { Authorization: `Bearer ${process.env.PLOP_API_KEY}` }, } ); const { data: email } = await response.json(); // Verify and extract reset link expect(email.subject).toContain('Reset'); const resetLink = email.htmlContent.match(/href="([^"]*reset[^"]*)"/)?.[1]; expect(resetLink).toBeDefined(); // Complete reset await page.goto(resetLink); await page.fill('[name="password"]', 'NewSecurePass123!'); await page.fill('[name="confirmPassword"]', 'NewSecurePass123!'); await page.click('button[type="submit"]'); await expect(page.locator('text=Password updated')).toBeVisible(); }); ``` ## Transactional Emails ### Order Confirmation Verify order details in confirmation emails: ```typescript test('order confirmation has correct details', async () => { const orderEmail = `order+${Date.now()}@in.plop.email`; const orderData = { items: [ { name: 'Pro Plan', price: 29.00 }, { name: 'Extra Seats', price: 30.00 }, ], total: 59.00, }; // Trigger order await api.createOrder({ email: orderEmail, ...orderData }); await new Promise(r => setTimeout(r, 2000)); // Fetch confirmation const response = await fetch( `https://api.plop.email/v1/messages/latest?to=${orderEmail}`, { headers: { Authorization: `Bearer ${process.env.PLOP_API_KEY}` } } ); const { data: email } = await response.json(); // Verify content expect(email.subject).toContain('Order Confirmation'); expect(email.htmlContent).toContain('Pro Plan'); expect(email.htmlContent).toContain('Extra Seats'); expect(email.htmlContent).toMatch(/\$59\.00/); }); ``` ### Welcome Email with Personalization Test dynamic content rendering: ```typescript test('welcome email is personalized', async () => { const testEmail = `welcome+${Date.now()}@in.plop.email`; const userName = "Sarah O'Brien"; await sendWelcomeEmail({ to: testEmail, name: userName }); await new Promise(r => setTimeout(r, 2000)); const response = await fetch( `https://api.plop.email/v1/messages/latest?to=${testEmail}`, { headers: { Authorization: `Bearer ${process.env.PLOP_API_KEY}` } } ); const { data: email } = await response.json(); // Verify personalization and escaping expect(email.subject).toContain(userName); expect(email.htmlContent).toContain(userName); expect(email.htmlContent).toContain('Get Started'); }); ``` ## Newsletter & Marketing ### Double Opt-in Flow Test confirmation and welcome sequence: ```typescript test('newsletter double opt-in', async ({ page, request }) => { const testEmail = `newsletter+${Date.now()}@in.plop.email`; // Subscribe await page.goto('/'); await page.fill('[name="newsletter-email"]', testEmail); await page.click('button:has-text("Subscribe")'); await expect(page.locator('text=Check your email')).toBeVisible(); // Get confirmation email await page.waitForTimeout(2000); let response = await request.get( 'https://api.plop.email/v1/messages/latest', { params: { to: testEmail }, headers: { Authorization: `Bearer ${process.env.PLOP_API_KEY}` }, } ); let { data: email } = await response.json(); expect(email.subject).toContain('Confirm'); // Click confirmation link const confirmLink = email.htmlContent.match(/href="([^"]*confirm[^"]*)"/)?.[1]; await page.goto(confirmLink); await expect(page.locator('text=Subscription confirmed')).toBeVisible(); // Verify welcome email arrives await page.waitForTimeout(2000); response = await request.get( 'https://api.plop.email/v1/messages/latest', { params: { to: testEmail }, headers: { Authorization: `Bearer ${process.env.PLOP_API_KEY}` }, } ); ({ data: email } = await response.json()); expect(email.subject).toContain('Welcome'); expect(email.htmlContent).toMatch(/unsubscribe/i); }); ``` ## Testing Best Practices ### Unique Addresses Per Test Always generate unique emails to prevent test interference: ```typescript // Good: Unique per test const testEmail = `test+${Date.now()}@in.plop.email`; const testEmail = `test+${crypto.randomUUID()}@in.plop.email`; // Bad: Shared across tests (causes race conditions) const testEmail = 'test@in.plop.email'; ``` ### Use `since` Parameter for Isolation Prevent fetching old emails from previous test runs: ```typescript const since = new Date().toISOString(); // Trigger email... const response = await fetch( `https://api.plop.email/v1/messages/latest?to=${testEmail}&since=${since}`, { headers: { Authorization: `Bearer ${process.env.PLOP_API_KEY}` } } ); ``` ### Robust Polling with Timeout Handle variable email delivery times: ```typescript async function waitForEmail(to: string, options: { timeout?: number; subject?: RegExp; } = {}) { const { timeout = 10000, subject } = options; const start = Date.now(); while (Date.now() - start < timeout) { const response = await fetch( `https://api.plop.email/v1/messages/latest?to=${encodeURIComponent(to)}`, { headers: { Authorization: `Bearer ${process.env.PLOP_API_KEY}` } } ); if (response.ok) { const { data } = await response.json(); if (data && (!subject || subject.test(data.subject))) { return data; } } await new Promise(r => setTimeout(r, 1000)); } throw new Error(`Timeout waiting for email to ${to}`); } // Usage const email = await waitForEmail(testEmail, { timeout: 15000, subject: /Reset/, }); ``` ### CI/CD Environment Variables Store API keys securely: ```yaml # GitHub Actions - name: Run E2E tests env: PLOP_API_KEY: ${{ secrets.PLOP_API_KEY }} TEST_MAILBOX: ci-${{ github.run_id }} ``` ```bash # GitLab CI variables: PLOP_API_KEY: $PLOP_API_KEY TEST_MAILBOX: ci-${CI_PIPELINE_ID} ``` ## Related Documentation * [Quick Start](/quick-start) — Get started in 5 minutes * [Messages API](/messages-api) — Full API reference * [E2E Email Polling](/e2e-email-polling) — Advanced polling with Playwright # TypeScript SDK The `@plop-email/sdk` package provides a typed, zero-dependency client for the Plop API. It works with Node.js 18+ and any test framework. ## Installation ```bash npm install @plop-email/sdk ``` ```bash pnpm add @plop-email/sdk ``` ```bash bun add @plop-email/sdk ``` ```bash yarn add @plop-email/sdk ``` ## Quick start ```typescript import { Plop } from "@plop-email/sdk"; const plop = new Plop(); // reads PLOP_API_KEY env var // List mailboxes const { data: mailboxes } = await plop.mailboxes.list(); // Fetch latest message matching filters const { data: email } = await plop.messages.latest({ mailbox: "qa", tag: "signup", }); console.log(email.subject); // "Verify your email" console.log(email.textContent); // plain text body ``` ## Wait for an email The `waitFor` method polls the API until a matching message arrives or the timeout expires. This is the recommended pattern for E2E tests: ```typescript // Polls GET /v1/messages/latest every second until a match or 30s timeout const email = await plop.messages.waitFor( { mailbox: "qa", tag: "verification" }, { timeout: 30_000, interval: 1_000 }, ); // Extract OTP const otp = email.textContent?.match(/\d{6}/)?.[0]; ``` `waitFor` throws a `PlopError` with message `"Timeout waiting for message"` if no match is found within the timeout. ## Playwright example ```typescript import { test, expect } from "@playwright/test"; import { Plop } from "@plop-email/sdk"; const plop = new Plop(); test("user can verify email", async ({ page }) => { await page.goto("/signup"); await page.fill('[name="email"]', "qa+verify@in.plop.email"); await page.click('button[type="submit"]'); const email = await plop.messages.waitFor( { mailbox: "qa", tag: "verify" }, { timeout: 30_000 }, ); const otp = email.textContent?.match(/\d{6}/)?.[0]; await page.fill('[name="otp"]', otp!); await expect(page.locator('[data-testid="success"]')).toBeVisible(); }); ``` ## Cypress example ```typescript import { Plop } from "@plop-email/sdk"; const plop = new Plop(); Cypress.Commands.add("waitForEmail", (filters) => { return cy.wrap( plop.messages.waitFor(filters, { timeout: 30_000 }), ); }); it("sends verification email", () => { cy.visit("/signup"); cy.get('[name="email"]').type("qa+test@in.plop.email"); cy.get("form").submit(); cy.waitForEmail({ mailbox: "qa", tag: "test" }).then((email) => { expect(email.subject).to.include("Verify"); }); }); ``` ## List and filter messages ```typescript const { data: messages } = await plop.messages.list({ mailbox: "qa", tag: "login", since: "2026-01-01T00:00:00Z", limit: 10, }); for (const msg of messages) { console.log(`${msg.from}: ${msg.subject}`); } ``` ## Get a message by ID ```typescript const { data: message } = await plop.messages.get("uuid-here"); console.log(message.htmlContent); ``` ## Verify webhook signatures ```typescript const isValid = plop.webhooks.verify({ secret: "whsec_...", signature: req.headers["x-plop-signature"], body: rawBody, }); ``` ## Manage mailboxes ```typescript const { data: mailbox } = await plop.mailboxes.create({ name: "staging" }); await plop.mailboxes.update(mailbox.id, { name: "staging-v2" }); await plop.mailboxes.delete(mailbox.id); ``` ## Delete a message ```typescript const { data } = await plop.messages.delete("uuid-here"); ``` ## Stream messages (SSE) ```typescript for await (const message of plop.messages.stream({ mailbox: "qa" })) { console.log(`New: ${message.subject} from ${message.from}`); } ``` ## Manage webhooks ```typescript const { data: created } = await plop.webhooks.create({ url: "https://example.com/webhook", }); console.log(created.secret); // shown once — store securely const { data: endpoints } = await plop.webhooks.list(); await plop.webhooks.toggle(endpoints[0].id, false); // disable ``` ## Rotate API key ```typescript const { data } = await plop.apiKeys.rotate(); console.log(data.key); // new key — update your env ``` ## Cursor pagination ```typescript let afterId: string | undefined; do { const { data: page } = await plop.messages.list({ limit: 50, after_id: afterId, }); for (const msg of page.data) { /* process */ } afterId = page.data.at(-1)?.id; if (!page.has_more) break; } while (true); ``` ## Error handling All methods return `{ data, error }`. On failure, `data` is `null` and `error` contains details: ```typescript const { data, error } = await plop.messages.list(); if (error) { console.error(error.message); // "Unauthorized" console.error(error.status); // 401 } ``` ## Configuration ```typescript const plop = new Plop({ apiKey: "plop_...", // or set PLOP_API_KEY env var baseUrl: "https://api.plop.email", // default }); ``` ## API reference | Method | Description | | ------------------------------------- | -------------------------------- | | `plop.mailboxes.list(params?)` | List mailboxes | | `plop.messages.list(params?)` | List messages with filters | | `plop.messages.get(id)` | Get message by ID | | `plop.messages.latest(params?)` | Get most recent matching message | | `plop.messages.waitFor(params, opts)` | Poll until a message arrives | | `plop.mailboxes.create({ name })` | Create a mailbox | | `plop.mailboxes.update(id, { name })` | Rename a mailbox | | `plop.mailboxes.delete(id)` | Delete a mailbox | | `plop.messages.delete(id)` | Delete a message | | `plop.messages.stream(params?)` | Stream new messages (SSE) | | `plop.webhooks.list()` | List webhook endpoints | | `plop.webhooks.create({ url })` | Create a webhook endpoint | | `plop.webhooks.delete(id)` | Delete a webhook endpoint | | `plop.webhooks.toggle(id, active)` | Enable/disable a webhook | | `plop.webhooks.deliveries(id)` | List webhook deliveries | | `plop.webhooks.verify(opts)` | Verify webhook HMAC signature | | `plop.apiKeys.rotate()` | Rotate the current API key | ## Links * [npm package](https://www.npmjs.com/package/@plop-email/sdk) * [GitHub](https://github.com/plop-email/plop-ts) * [Python SDK](/python-sdk) # Webhooks Webhooks let your server receive real-time `POST` requests when events happen in your mailbox. Instead of polling the Messages API, you register an HTTPS endpoint and plop delivers events as they occur. Webhooks are available on **Team** plans and above. ## Supported events | Event | Description | | ---------------- | -------------------------------------------------- | | `email.received` | A new email was delivered to one of your mailboxes | ## Setup 1. Go to **Settings → Webhooks** in the dashboard. 2. Click **Create endpoint** and enter an HTTPS URL. 3. Copy the signing secret (`whsec_…`). It is shown once — store it securely. The endpoint must return a `2xx` status within **10 seconds** or the delivery is marked as failed. ## Payload Every webhook `POST` includes a JSON body: ```json { "event": "email.received", "timestamp": "2026-02-06T12:00:00.000Z", "data": { "id": "msg_abc123", "mailbox": "qa", "mailboxWithTag": "qa+signup", "tag": "signup", "from": "sender@example.com", "to": "qa+signup@in.plop.email", "subject": "Welcome to Acme", "receivedAt": "2026-02-06T11:59:58.000Z", "domain": "in.plop.email" } } ``` ## Signature verification Every request includes an `X-Plop-Signature` header: ``` X-Plop-Signature: t=1738843200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8f9 ``` The signature is an HMAC-SHA256 of `${timestamp}.${body}` using your signing secret. Always verify the signature before processing the payload. ### Node.js example ```ts import { createHmac, timingSafeEqual } from "node:crypto"; function verifyWebhook( body: string, header: string, secret: string, ): boolean { const [tPart, v1Part] = header.split(","); const timestamp = tPart.replace("t=", ""); const receivedSig = v1Part.replace("v1=", ""); // Reject signatures older than 5 minutes const age = Math.floor(Date.now() / 1000) - Number(timestamp); if (age > 300) return false; const expected = createHmac("sha256", secret) .update(`${timestamp}.${body}`) .digest("hex"); return timingSafeEqual( Buffer.from(expected), Buffer.from(receivedSig), ); } ``` ## Delivery behavior * **Retries**: Failed deliveries are retried up to **3 times** with exponential backoff. * **Timeout**: Your endpoint must respond within **10 seconds**. * **Logs**: View delivery attempts, status codes, and latency in **Settings → Webhooks → Deliveries**. ## Manage via API You can also manage webhooks programmatically using the REST API or SDKs. ### REST API | Method | Endpoint | Description | | ------ | ------------------------------ | -------------------------- | | GET | `/v1/webhooks` | List all webhook endpoints | | POST | `/v1/webhooks` | Create a new endpoint | | DELETE | `/v1/webhooks/{id}` | Delete an endpoint | | PATCH | `/v1/webhooks/{id}` | Toggle active/inactive | | GET | `/v1/webhooks/{id}/deliveries` | List delivery attempts | All endpoints require an API key with `api.full` scope. ### SDK example ```typescript import { Plop } from "@plop-email/sdk"; const plop = new Plop(); const { data } = await plop.webhooks.create({ url: "https://example.com/webhook", }); console.log(data.secret); // store this securely ``` ## Plan requirement Webhooks require the **Team** plan or higher. Starter plan users can upgrade from the billing page.