Operate

Running a hosted service

Plans, Stripe billing and operator setup — how app.squadmail.dev runs. Self-hosted instances don't need any of this.

Squadmail Cloud at app.squadmail.dev runs the same code as every self-hosted deployment. Setting STRIPE_SECRET_KEY switches the instance into hosted mode:

  • every organization needs a plan (Hobby, Starter, Team, Business — defined in src/lib/plans.ts),
  • the plan decides limits and features through getEntitlements(orgId),
  • there are no shared inbox domains: every workspace receives and sends on its own domain(s), so one customer’s traffic never touches another’s domain reputation,
  • every workspace sends through its own SES tenant (see below),
  • instance setup (Amazon SES) is no longer possible from the dashboard — the operator does it once with two CLIs.

Without STRIPE_SECRET_KEY none of this exists: no plans, no paywall, no Stripe calls. Self-hosting stays lean.

1. Amazon SES

node --env-file=.env.production scripts/ses-setup.ts   # pnpm ses:setup

Needs AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, APP_URL and BETTER_AUTH_SECRET (same value as the Worker secret). It creates the S3 bucket, the SNS topic with its HTTPS subscription and the SES receipt rule. Re-running is safe.

Reputation isolation

One AWS account sends for every customer, so one bad sender must not get the whole account paused. Squadmail uses SES tenants:

  • When a workspace adds a domain, Squadmail creates the tenant sqm-<workspace id> with its own configuration set and its own suppression list, and links the domain to it.
  • Every message the workspace sends (sends, approved agent mail, forwards) carries that tenant, so SES tracks bounce and complaint rates per workspace.
  • New tenants get SES’s standard reputation policy: on high-severity findings SES pauses only that tenant. Sends then fail with 403 sending_paused, the dashboard shows a banner under Settings → Domains, and receiving keeps working.
  • Domain reputation is isolated anyway — every workspace sends from its own DKIM-signed domain.
  • Hobby’s daily send cap (20) limits how much damage a new account can do before SES has enough volume to judge it.

Review findings and resume a tenant in the SES console (Tenants) or with aws sesv2 update-reputation-entity-customer-managed-status. SES also emits Sending Status Disabled and Advisor Recommendation Status Open events to EventBridge — route them to email/Slack for alerting. Tenants cost nothing for the first 1,000 per region, then $0.005 per tenant and month.

Tenants isolate enforcement, not the account entirely: their combined bounce and complaint rates still count towards your account’s standing. Keep an eye on the SES account dashboard.

2. Stripe

STRIPE_SECRET_KEY=sk_live_… APP_URL=https://app.squadmail.dev pnpm stripe:setup

Idempotent. It creates one product per plan (squadmail_<plan>) with monthly and yearly prices (lookup keys squadmail_<plan>_<monthly|yearly>), the webhook endpoint APP_URL/hooks/stripe and the customer-portal configuration (switch plans with proration, cancel at period end, invoices, payment methods). When a price changes in plans.ts, run it again — the lookup key moves to the new price and existing subscriptions keep theirs until changed.

Then set the Worker secrets:

wrangler secret put STRIPE_SECRET_KEY
wrangler secret put STRIPE_WEBHOOK_SECRET   # printed by stripe:setup on first run

Optional vars: STRIPE_AUTOMATIC_TAX=true once Stripe Tax is configured, STRIPE_PORTAL_CONFIGURATION if the script asks for it.

How billing works

  • Sign-up from the pricing page carries ?plan=…&interval=… straight into Stripe Checkout; afterwards the dashboard asks for the workspace’s domain.
  • Stripe webhooks (checkout.session.completed, customer.subscription.*) mirror the subscription into D1. active, trialing and past_due (grace while Stripe retries) keep the plan.
  • Without a live plan the dashboard only shows Settings → Plan & billing and the API answers 402 plan_required.
  • Monthly quotas count from the start of the billing period; Hobby additionally has daily caps. Hitting a quota returns 429 limit_reached with the limit.
  • Retention follows the plan (3 / 30 / 90 / 365 days).

When a plan ends

WhenWhat happens
Day 0The workspace’s domains go onto squadmail-suspended-N receipt rules (Stop action) at the top of the SES rule set — no more mail, no S3 object, no Worker run. Sending is blocked. Owners and admins get an email.
Day 23Final reminder with the deletion date.
Day 30Cleanup: SES identities, tenant and configuration set, inboxes, emails, attachments and raw mail in R2, API keys, webhooks, outbox, events and usage counters are deleted. Accounts, the workspace, labels and settings stay. A last email confirms it.

Choosing a plan again before day 30 lifts the block immediately and everything continues where it stopped. The cron (*/15) drives all steps; reminders need MAIL_FROM.

Suggested variables

{
	"SIGNUP_MODE": "open",
	"REQUIRE_EMAIL_VERIFICATION": "true",
	"MAIL_FROM": "Squadmail <noreply@squadmail.dev>",
	"TURNSTILE_SITE_KEY": "0x4AAAA…", // + secret TURNSTILE_SECRET
	"MAX_ATTACHMENT_MB": "10",
	"API_RATE_PER_MIN": "300",
	"LABEL_GLOBAL_DAILY_BUDGET": "200000", // kill switch for Workers AI spend
	"AI_GATEWAY_ID": "squadmail"
}

Monitoring

  • Workers Observability is enabled in wrangler.jsonc; rejected mail, failed jobs and billing changes are written to the event log.
  • Route Clef through AI Gateway to see spend per day and add caching or rate limits without redeploying.
Edit this page on GitHub