CoreSwift CRM — Admin Guide

System Overview

CoreSwift CRM is the central CRM and automation platform. It manages contacts, deals, pipelines, campaigns, and email sequences with full template-based email delivery.

Quick Reference

Email Templates (New)

All transactional emails use database-stored templates in the email_templates table. Templates support {{variable}} placeholders for dynamic content.

Template Types

TypeWhen UsedAvailable Merge Fields
welcomeAccount creation{{name}}, {{email}}, {{password}}, {{app_url}}
purchase_confirmedSuccessful payment{{name}}, {{plan_name}}, {{app_url}}
password_resetPassword reset request{{name}}, {{token}}, {{app_url}}
email_changeCustomer asks to move their sign-in address{{name}}, {{new_email}}, {{link}}, {{token}}, {{app_url}}

API Endpoints

MethodPathDescription
GET/api/email-templatesList all templates (with pagination + template_type filter)
POST/api/email-templatesCreate a new template
GET/api/email-templates/:idGet a single template
PUT/api/email-templates/:idUpdate a template (partial fields)
DELETE/api/email-templates/:idDelete a template
GET/api/email-templates/merge-fieldsList available merge fields by type

Template Fields

How It Works

  1. When a flow triggers (e.g. forgot password, registration, billing), it calls send_template_email() with the template type and variable map
  2. The system looks up a matching DB template — tenant-specific first, then fallback to is_default = true
  3. If no DB template exists, a hardcoded inline template is used
  4. The rendered email is queued to outbound_messages for async delivery
  5. A background worker picks up queued messages and sends via SMTP

Admin UI

The admin interface includes a dedicated Email Templates page with:

Default Templates (Seeded)

Three default templates are seeded on first migration:

Calendar addresses: one per workspace, plus its own domain if it has one

Every workspace gets a working public calendar address the moment it exists: <workspace>.coreswiftcrm.com. Nothing has to be switched on, and no plan gates it — a brand-new workspace can open its own address and see its name on it straight away. The address shows that workspace’s active booking calendars, their slot types, how many places are left on each, and a booking form the visitor can send without an account.

Where the owner changes it: Calendar in the console, the card Your public calendar address. Two things are editable and one is not:

What the operator should know when a customer asks: the calendar page is served on a wildcard vhost for *.coreswiftcrm.com. The live named hosts (coreswiftcrm.com, www, app, admin, inbox, pmail) are matched first and are untouched; an address that belongs to no workspace answers “No calendar at this address” and writes nothing. Only the workspace’s OWN active calendars appear — the workspace is resolved from the address the visitor called, never from a value in the URL, so one customer’s address cannot be made to show another’s calendar. A booking made on a hosted calendar lands in that workspace’s own bookings, with the same capacity rule, scoring event and round-robin hand-off as any other booking.

Plan note: the hosted page is part of the Calendar/Bookings module, so a plan that does not include bookings still shows the workspace’s name and address but no places to reserve.

MultiDirectory Integration (Booking Slots)

CoreSwift powers the Booking CTA slot on MultiDirectory business listing pages.

Flow:

  1. Business owner enables Booking integration in their MultiDirectory dashboard (Integrations tab)
  2. They select a CTA label from a controlled dropdown: "Book Appointment", "Schedule a Consultation", "Book Now", "Reserve a Table", "Claim Your Slot", "Book a Tour"
  3. The CTA button renders on the business's public listing page
  4. Clicking opens CoreSwift's booking widget/modal (inline date picker + time slot selection)
  5. Booking data flows into CoreSwift's contact and deal tracking pipelines

Controlled vocabulary only — business owners cannot type custom CTA text. This keeps directory branding consistent.

Entering a customer's workspace (impersonation)

Support sometimes needs to see exactly what a customer sees. The Tenants screen carries an Impersonate button on every row; only a platform operator is offered it, because the server refuses anyone else.

Pressing it asks the server for a short-lived token scoped to that workspace (15 minutes), keeps your own session under a separate key, swaps the session, and raises a red banner naming the workspace — every screen below it belongs to the customer, not to you. The banner survives a page reload, so a refresh cannot leave you looking at customer data with nothing on screen saying whose.

Stop impersonating on the banner ends the session on the server, puts your own session back and returns you to your own workspace. Signing out also clears it, so the banner can never appear over the login screen. If the 15 minutes run out, reload and sign in again.

When nobody can pay: the dashboard says so

This app has no card checkout of its own — POST /billing/checkout/create and its provider webhooks were retired on 2026-09-25 and no payment provider exists in the integration catalogue. The only arm a buyer can be charged on is a plan's own hosted payment page (plans.checkout_url), which the upgrade screen offers as Open payment page. So the operator's Dashboard now asks whether any of those pages actually opens, and paints a red Buyers cannot pay card when none does — with the button that opens Plans & Billing.

That distinction is the point: on 2026-11-11 all four plan links on this install answered 404 to a real browser request (a real slug and an invented one answered identically), so every buyer who pressed Upgrade landed on “No Data Found.” while the catalogue looked sellable. A count of rows cannot see that.

The card is painted only from a read that succeeded and that found nothing to sell. A failed read paints nothing; a link whose host did not answer in time is reported as unknown, not dead, so a slow payment host can never be shown as a shut till; and the card clears only when every active paid plan carries a page that opened — one working page beside a missing or dead one still says Not every plan can be bought and names the plan, because the buyer picks the plan. The read is GET /api/billing/readiness (platform admins only, cached for five minutes): it lists the active paid plans, opens each plan's page separately, and probes up to six of them concurrently with a four-second bound, following up to three redirects. Its answer carries a verdict per plan (ok, no_link, not_https, dead or unknown) and the list of plans that block a sale. It refuses to fetch anything that is not https or that points at this machine or a private network.

Giving a plan a working payment page

This app has no card checkout of its own, so a plan is sold through the payment page you paste onto it. Four steps, about five minutes per plan:

  1. In your payment provider (Stripe, PayPal, or any service that makes a hosted payment page), create one payment link per plan you want to sell, for the amount shown against that plan in Plans & Billing.
  2. Open Plans & Billing in this console and find Plan payment links.
  3. Paste the link into that plan's box and press Save. It must start with https://.
  4. Leave Free empty: a free plan takes no money, so it needs no page.

Reload the Dashboard. The red card clears only when every paid plan's page opens — not when one does. Until then it names the plans that cannot be bought, and the Plan payment links panel shows, under each saved link, whether that page actually answered (the check is cached for five minutes, so reload after a few minutes). When a payment lands, switch the workspace onto the plan by hand on the Tenants screen — nothing in this app hears the provider's confirmation, so the money and the plan are two separate steps. The gap is no longer silent: pressing Open payment page files the buyer's plan request in the same queue as Request this plan, naming the plan, its price and the workspace, so the money that lands and the switch that must follow it meet in one place.

On 2026-11-11 the four links this install carried (https://mintbird.com/checkout/crm-swift-…) were removed. They pointed at a third-party site that answers “No Data Found.” to every address on it, so a buyer who pressed Open payment page reached nothing and nothing was charged. With no link, the upgrade screen offers only Request this plan, which reaches the operator — worse than a sale, but honest, and the Dashboard keeps saying so until a real link is pasted.

Route Mapping

Frontend PageRouteMethods
Dashboard/api/dashboard/statsGET
Contacts/api/contactsGET, POST
Companies/api/companiesGET, POST
Deals/api/pipelines/:pipeline_id/opportunitiesGET, POST
Campaigns/api/campaignsGET, POST
Email Templates/api/email-templatesGET, POST
Message Templates/api/comms/templatesGET, POST
Plans/api/billing/plansGET, POST
Account plan (operator)/api/admin/tenants/:id/planPUT
Audit/api/auditGET

Monitoring & Logs

Affiliate Products & Commissions

CoreSwift does not push its plans to FunnelSwift. The commissionable product catalogue lives in FunnelSwift's affiliate_products table and is owned there — FunnelSwift's own plan-derived products, plus one platform-wide entry per sibling service (for example CoreSwift Free, source_app = 'coreswift'), each with its own price and commission rate. Nothing in this app writes to that table, so creating, renaming or deleting a plan here does not change the affiliate portal's catalogue. Products are managed in the FunnelSwift admin (Affiliate Products), which is the source of truth for what an affiliate can promote.

What CoreSwift does report is the commission trigger: when a tenant's subscription moves onto a paid plan, src/billing/handlers.rs posts the account owner's email, the plan name and its price to FunnelSwift, fire-and-forget inside a tokio::spawn so billing is never delayed:

EventCall
Tenant moves to a paid planPOST {FUNNELSWIFT_URL}/api/v1/internal/affiliate/upgrade-event, with the shared x-internal-key header

FunnelSwift matches that email back to the affiliate lead, credits the commission against the product whose source_app is coreswift, and ignores a repeat of the same event id, so nothing is credited twice. Attribution is by the email address the referred business signed up with, and only a move onto a paid plan counts. See "Conversion Tracking" in the user guide for the full chain.

Environment variables: FUNNELSWIFT_URL (default http://localhost:8080) and INTERNAL_SYNC_KEY — the shared x-internal-key value the receiver checks. If FUNNELSWIFT_URL is empty, or the tenant has no owner email, the call is skipped and the rest of the app is unaffected.

CoreSwift carries no affiliate system of its own (kanban t_3d81b041): the duplicate local affiliate routes, the CRM-side profile / payout / product-board code, the plan flag that gated them and the hub actions that exposed them to integrator webhook tokens were all retired, so the FunnelSwift programme above is the only affiliate system in the fleet. The duplicate, orphaned schema went with them: migration 108_retire_dead_affiliate_schema.sql (applied 2026-10-02) dropped those tables from this app's database — none of them exists here any more, verified live with to_regclass(), and nothing in the app reads or writes them. The commissionable catalogue the paragraph above describes is FunnelSwift's, in FunnelSwift's database, and is untouched by this app.

The mail screens a customer uses

The Shared Inbox tab of the console is where a customer reads their mail, and the Shared Inbox host (inbox.<their domain>) carries the same mail in a fuller client. Both read the same records, so a message a support call is about is in both places. The branded name is matched to the product that owns the domain before anything is served: one inbox.* front door covers the whole fleet, and a name that belongs to no app answers 404 instead of somebody else's screens (see The mail front door, and a customer's branded address in the ADASwift admin guide for how the map is built).

What the customer sees first. Signing in to the CRM lands on the Dashboard, not on their mail. Mail is two routes from there: the Shared Inbox tab lists every address with its status, message count and a LOG IN button that opens that one address in the mail client; and signing in at the mail client itself (inbox.<their-domain> or inbox.coreswiftcrm.com) opens Choose a mailbox — one card per address, each carrying the address and how many messages it holds, and the chosen address travels in the URL so a link can open one directly. So "I only see a list of addresses" on a support call is the chooser working, not a missing inbox; the address becomes a tick away, not a page they failed to reach.

The console screen offers: a rail of views (Inbox, Unread, Favorites, Drafts, Sent, Archive, Spam, Trash) each with a live count; an address picker (All, each created address, and Everything else / MISC for catch-all arrivals that match no mailbox row); a star that files a message under Favorites; and account-level labels with a name, a colour and a usage count, filtered server-side so the count and the rows cannot disagree.

The three-dot menu beside a folder carries Create subfolder, Mark all as read, Mark all as unread and Sort alphabetically (a client-side order over the page shown). Archive adds Move all to Inbox and Delete all messages, the second of which MOVES mail to Trash — it destroys nothing. Spam and Trash add Empty folder, which names the exact number of messages before it destroys them.

Deleting one message from Spam or Trash destroys it immediately and never moves it to Trash, because those two folders are the last stop. Mail deleted anywhere else moves to Trash first. If a customer says mail vanished, check whether they emptied Spam or Trash: nothing in the app can restore from either.

Summarise appears on an open message only when the account has an AI provider key, and a provider failure is shown in that same spot as the provider’s own sentence rather than a silent no-op.

Private Email — Admin Controls

Overview

As agency_admin or owner, you can override email limits per tenant beyond what their plan allows. This is useful for giving specific tenants extra domains, more mailboxes, or higher alias limits without changing their plan.

Tenant Email Limits API

EndpointMethodDescription
/api/v1/private-email/admin/limitsGETList all tenants that have custom email limit overrides
/api/v1/private-email/admin/limits/:tenant_idGETView a tenant's plan defaults, your overrides, and effective limits
/api/v1/private-email/admin/limits/:tenant_idPATCHSet custom email limits for a tenant (upsert — creates or updates)

All admin routes require platform-admin authority, resolved from the database by token subject (users.is_platform_admin). agency_admin and owner are TENANT roles held by ordinary customers and grant nothing here; requests without platform authority receive a 403 Forbidden.

Setting Limits (PATCH /api/v1/private-email/admin/limits/:tenant_id)

Send a JSON body with any combination of fields you want to override:

{
  "max_domains": 5,
  "max_mailboxes": 50,
  "max_aliases_per_mailbox": null
}

Rules:

Reading Limits (GET /api/v1/private-email/admin/limits/:tenant_id)

Example response:

{
  "tenant_id": "550e8400-e29b-41d4-a716-446655440000",
  "plan_defaults": {
    "private_email": true,
    "max_domains": 3,
    "max_mailboxes": 10,
    "max_aliases_per_mailbox": 5,
    "catch_all_enabled": false
  },
  "overrides": {
    "id": "660e8400-e29b-41d4-a716-446655440001",
    "tenant_id": "550e8400-e29b-41d4-a716-446655440000",
    "max_domains": null,
    "max_mailboxes": 20,
    "max_aliases_per_mailbox": null,
    "created_at": "2026-07-01T00:00:00Z",
    "updated_at": "2026-07-15T00:00:00Z"
  },
  "effective_max_domains": 3,
  "effective_max_mailboxes": 20,
  "effective_max_aliases_per_mailbox": 5, "sells_catch_all": false, "catch_all_domains_enabled": 2, "catch_all_domains_off_plan": 2
}

Field meanings:

Listing All Overrides (GET /api/v1/private-email/admin/limits)

Returns an array of all tenant_email_limits rows, sorted by newest first. Only returns tenants that have at least one override set.

How Enforcement Works

When a tenant tries to add a domain or create a mailbox, the system checks in this order:

  1. Admin override first — looks up the tenant_email_limits table for a row matching the tenant
  2. Plan defaults fallback — if no override exists for a given field (or it's null), the tenant's plan features JSON is used
  3. Hard block on limit — if the tenant has hit the effective limit, they receive a clear error message:
  1. Feature disabled — if private_email: false in the plan features, the tenant gets "Private Email not available on your plan" for any operation

Catch-all is part of the plan, not the domain

Catch-all — accepting mail for any address on a workspace's domain that nobody created — is sold by the plan: Professional, Agency and Enterprise carry it, Free and Starter do not. The switch on a domain row is the workspace's preference; the plan is the entitlement. The inbound receiver asks the plan every time a delivery needs it, so these two facts are both true and are reported separately:

Removing Overrides

To remove an override and let a tenant revert to their plan defaults, PATCH with null for all fields:

{
  "max_domains": null,
  "max_mailboxes": null,
  "max_aliases_per_mailbox": null
}

The override row remains but all fields are null, meaning the plan defaults apply for every limit.

System Email (Platform Mail Transport)

Where: Admin console → Platform Email → System Email (admin.coreswiftcrm.com).

This is the mail transport CoreSwift itself sends through — the provider and credential used by every workspace that has NOT configured its own sending identity. Until this panel existed the credential lived only in the container environment (EMAIL_API_URL / EMAIL_API_KEY / EMAIL_FROM), so it could not be seen or rotated without a shell edit of the CoreSwift service environment file plus a container recreate. It is now stored in the admin_settings row keyed email and edited from the panel.

What the panel shows

What the panel edits

FieldMeaning
ProviderThe platform transport CoreSwift carries (mailgun)
Send endpoint (api_url)Full send endpoint, e.g. https://api.mailgun.net/v3/mg.example.com/messages. Empty = use EMAIL_API_URL
From nameDisplay name on the message
From addressMust be an address on the sending domain, or receivers reject the message
API keyThe provider credential. Leave the dots to keep the stored key; clear the field to remove it
Operator alert addressWhere "a customer wrote to support" alerts are emailed. The public contact page files every request into Support → Tickets whether or not this is set; with it empty, no alert is sent and nobody is told to open the queue

Buttons: Save credential, Send test email (a real send, recorded in the Communications Log with the provider's own answer), Use server environment (removes the saved credential so the EMAIL_* variables carry mail again).

Resolution order

  1. the field in the admin_settings row keyed email;
  2. the EMAIL_* variable for that same field;
  3. nothing — which is reported as not configured.

The order is per field, so a half-filled row still sends (a rotated key with no endpoint of its own rides on EMAIL_API_URL), and an empty row is not "broken" — it means the environment carries mail.

API

MethodRoutePurpose
GET/api/admin/email-configMasked transport: status, which store carries it, key length — never the key
PUT/api/admin/email-configSave provider / api_url / api_key / from_address / from_name / operator_alert_email
DELETE/api/admin/email-configRemove the saved credential, return to EMAIL_*
POST/api/admin/email-config/testSend one real message through the platform transport

All four are platform-admin only. The credential is sealed at rest with the app's enc:v1: envelope (the same one provider_keys uses, under a scope of its own), so a database dump yields no usable key, and no read route returns the key or a digest of it.

Sync New Company — giving a company its own account

Where: Admin console → Portfolio Companies → 🔄 Sync New Company. This is the one press that turns a business into a real workspace with a real login — either a brand-new client, or a portfolio record that never had an account behind it.

What one press does

  1. creates the company’s workspace and puts it on a plan — the entry plan, or the tier picked in the dialog;
  2. creates its owner login with role = owner, the same role a self-serve signup gets, so the owner can invite people, open Settings and manage the workspace;
  3. writes the portfolio entry — or converts the record that is already there — and stores the account’s id on it (settings.account_tenant_id), so the row always names the account it belongs to and the company stays in the operator’s portfolio list;
  4. emails the login details and the password to the owner address: the same welcome mail a signup receives.

The dialog says afterwards which of those happened. An account that was created with a mailed password reports that the mail went out; an account whose mail failed says so plainly rather than promising an inbox that stays empty.

Running it again

A company that already has an account answers already exists: its name, contact address and description are refreshed and nothing else happens — no second workspace, no second login, no second email. Two presses cannot make two accounts.

A record created with + Add Company is a plain record, not an account. Pressing Sync on it converts it: the workspace and the owner login are created and the record gains the account link. The same press on a company that is already converted never mints a second account.

If the email does not go out

The account still exists and the row is marked as having a password nobody has seen. The owner can set their own first password with Forgot password? on the sign-in page, or use the reset link they receive. Pressing Sync again does not re-issue an existing account’s password — for a mail problem, Forgot password? is the door.

What is refused, and why

API

{ "name": "Xride Drivers", "email": "owner@example.com", "description": "", "plan_slug": "agency" }

POST /api/admin/portfolio-sync, platform-admin only. plan_slug is optional — omitted, the workspace is seated on the entry plan (free). id is optional too: a sister app sends its own sync id, the console sends none (the company’s own slug is the key). The answer carries status (synced or already_exists), tenant_id/account_id — the company’s account, never the operator’s own workspace — user_id, plan, and credentials_emailed, the flag the dialog reads to tell the truth about whether the password left the box.

Deployment

Rebuild and redeploy the CoreSwift service with its deploy script. The build is serialised (one job at a time) so a release compile does not starve the rest of the box.

Plans & Feature Access

Every feature the admin can switch on/off per plan (source of truth: the module registry in the database — modules / module_features, the switches GET /api/admin/modules renders):

FlagFeatureModuleNotes
campaignsCampaignscampaignsSequenced email campaigns
automationAutomationsautomationTrigger/action engine
checklistsChecklistschecklistsOnboarding/process checklists
ai_enabledAI scoring & helpersai_enabledLead scoring and AI helpers
ticketsSupport ticketsticketsIn-house ticketing + email-to-ticket
native_appsNative app connectorsnative_appsFunnelSwift, ADASwift, MissedCall, WorkflowSwift, Multi-Directory
telnyxSMS & voice (Telnyx)telnyxSMS, number management, call tracking
round_robinRound-robin routinground_robinFair lead distribution across a team
eventsEvent systemeventsInternal event bus
monitoringMonitoring & healthmonitoringAccount health scoring and thresholds
bookingsBookings & schedulingbookingsCalendar booking pages
google_calendarGoogle Calendar syncgoogle_calendarTwo-way calendar sync
api_accessAPI accessapi_accessPer-tenant API keys
webhooksWebhookswebhooksOutbound webhook delivery
integrationsIntegrationsintegrationsn8n and third-party integrations
provider_keysProvider keysprovider_keysBring-your-own provider credentials
support_widgetsSupport widgetssupport_widgetsEmbeddable support surfaces
tracked_linksTracked linkstracked_linksClick tracking links
private_emailPrivate mailboxprivate_emailOwn domain + mailboxes (also has its own limits)
email_sendingEmail sendingprivate_emailWhether this plan may SEND mail from its mailboxes (receiving is always allowed)
portfolioPortfolio syncportfolioCross-tenant portfolio management
account_matchAccount matchingaccount_match
notificationsNotificationsnotifications
notifications_in_appIn-app notificationsnotifications
notifications_emailEmail notificationsnotifications
notifications_smsSMS notificationsnotifications
notifications_whatsappWhatsApp notificationsnotifications

Behaviour: an explicit per-tenant entry in tenant_plans.feature_overrides wins, then the tenant's active plan's assigned modules (plan_modules / plan_module_features). A flag that resolves to nothing is denied — the module returns 402 Payment Required — so a module can no longer ship ungated and a renamed key can no longer silently stop enforcing. Toggle them per plan in the admin console. The one tolerance left — and the console now says it out loud instead of leaving it to be inferred: a workspace with no plan row at all is granted EVERY module (the resolver answers source = no_plan and the gate lets it through), and none of its numeric ceilings apply. It is not on the free tier; it is on the unlimited one. The account card in Modules & Plans → Per-tenant plan & overrides prints no plan row — all modules allowed with the denied list empty, and Assign plan on that same card (or PUT /api/admin/tenants/:id/plan) ends the state — one press, and that press is the only way it ends. Measured 2026-10-02: 14 of the 17 workspaces on this deployment were in it, and all of them are operator-owned or minted by an ingest arm, never a signup. That state is now a FIXED population: every creation path seats the platform default (free) in the same transaction as the tenant insert, so no route can mint a new row-less workspace — five did not before (the two cross-app ingest sinks, the portfolio internal sync, POST /api/account and the admin portfolio-sync; kanban t_6f225dd4). The workspaces above are the ones already in it, and Assign plan is what ends each of them.

Putting an account on a plan (operator action)

Assigning a plan is an operator action, not a tenant self-service one. POST / PATCH /api/billing/subscription are both platform-gated and both take the tenant from the caller's own token, so they can only ever write the operator's own workspace — and POST additionally answers 409 for any account that already holds a row, which every signup writes. The instrument that acts on a target account is:

RouteMethodWhoWhat it does
/api/admin/tenants/:id/planPUTplatform admin onlyPuts the account in the path on a plan, optionally with a billing cycle

Body — plan_slug (preferred: it is unique in plans) or plan_id, plus an optional billing_cycle of monthly or yearly. Omitting the cycle keeps the account's current one, and a first-time assignment defaults to monthly.

{ "plan_slug": "starter", "billing_cycle": "monthly" }

It works whether or not the account already has a tenant_plans row: an existing row is updated, and an account with no row gets one created — created_row in the answer says which happened. Every creation path writes a free row as of 2026-10-02, so an account with no row is one minted before that. The answer echoes the plan and the cycle the server actually wrote, and every call is recorded in the audit log as subscription.plan_assigned, naming the operator.

In the admin console: Modules & Plans → Per-tenant plan & overrides — pick the account, pick the plan and the cycle, press Assign plan. The line under the button prints what the server wrote and whether the row was created or updated, and the entitlements underneath are re-read.

AnswerWhen
200written; created_row is true when the account had no plan row before
403the caller is not a platform admin — a tenant owner, or a role string such as agency_admin whose users.is_platform_admin is false
404no account with that id, or no plan with the slug / id you sent (the answer names the value it looked for)
409the plan exists but is not on sale (it has no working payment page — Inbox ships that way): the answer names the plan and nothing is changed
422a billing_cycle outside the schema's vocabulary (monthly or yearly)

What it deliberately does not do: it decides no pricing and no plan contents (no plans, plan_modules or plan_module_features row is touched), it never writes tenant_plans.feature_overrides — the per-account entitlement instrument stays the platform-gated POST /api/admin/tenants/:id/overrides — and it never writes credits. A paid assignment posts the same affiliate attribution as the subscription writers (see Affiliate Products & Commissions).

Numeric limits (the limits module, plus the two on private_email)

The same matrix carries the numeric ceilings: one module_features row of kind limit per quota, rendered as a number box instead of a tick. Clearing the box switches that limit OFF for the plan — the gate reads that as 0, i.e. "not available on your plan", and denies with 402. A negative number is the unlimited sentinel. An enabled limit with no number has no ceiling. The top tier (the most expensive active row) grants every one of them.

Feature keyModuleCeilingRead by
limit_max_widgetslimitssupport widgets per accountPOST /api/widgets
limit_max_industrieslimitsindustry dashboardsPOST /api/industries
limit_max_contactslimitscontactsPOST /api/contacts, POST /api/csv/import/contacts — the paths where the workspace's own user adds a contact. A lead another app captured is never refused, on any arm including the external api-key surface (see Capture over the ceiling below)
limit_max_userslimitsactive users (users.is_active)POST /api/auth/team/invite (the console's “Add a teammate” form), POST /api/auth/register (accepting an invite link) and the automation webhook's users.invite action — all three mint through the same writer
limit_pipelineslimitspipelinesPOST /api/pipelines
limit_integrationslimitsconnected integrationsPOST /api/integrations
limit_api_calls_per_daylimitsauthenticated api-key requests per UTC day (GET /api/external/lists, POST /api/external/contacts)external_api::resolve_key — every request that resolves a personal API key
email_domainsprivate_emailown sending domainsadding a Private Email domain
email_mailboxesprivate_emailmailboxesprovisioning a mailbox

A ceiling is checked only on the route that adds the counted row — listing, editing and deleting keep working at the ceiling, so a workspace can always free a slot. The number a customer sees in GET /api/auth/me/usage is the same COUNT(*) the gate compares. Note two consequences of the authored numbers: on free a workspace sells 0 connected integrations (limit_integrations) and 1 active user, so connecting an integration — or inviting a second member — answers 402 until the plan changes. Raise those numbers here if that is not the intent.

One ceiling counts a flow rather than a collection of rows: limit_api_calls_per_day meters the api-key surface. The count lives in the api_call_usage table, one row per workspace per UTC day — the day boundary is the primary key, so the quota resets by itself at 00:00 UTC and there is no job to run. It is incremented on GET /api/external/lists and POST /api/external/contacts — the two endpoints the Integration Centre hands to a spoke app — and only there: the console's own routes are never counted, so a workspace that reaches its daily ceiling keeps full use of the app and only its API answers 402 until the next UTC day. Both endpoints are metered, so a client that re-pushes the same lead (which costs no contact) still consumes calls. The number GET /api/auth/me/usage renders as api_calls_today is the same row the ceiling is compared against. A refused call is not counted, and a workspace with no active plan row is not capped.

Capture over the ceiling — why a lead from another app is never refused

limit_max_contacts is a hard stop only where the workspace's own user adds a contact: Add Contact in the console and the CSV import. The seven ingest arms that accept a lead another app has already captured are deliberately not gated:

Ingest armWho feeds it
POST /api/external/contactsthe spoke ingest — the endpoint the Integration Centre hands a sibling app. FunnelSwift (spawn_lead_push), ADASwift, WorkflowSwift, IncentiveSwift, MissedCall Respondr and Multi-Directory push the leads they captured here (personal API key)
POST /api/internal/contactsMulti-Directory / ZaarHub lead sync (internal key)
POST /api/v1/webhooks/cross-app/tag-syncFunnelSwift lead + tag sync (internal key)
POST /api/v1/internal/tag-provisionFunnelSwift tag provisioning (internal key) — it mints its own workspace per call, so it can never be over a ceiling
POST /inbound/v3/{key_prefix}/contact-syncsatellite apps pushing surveyed contacts (satellite key)
POST /api/v1/webhooks/mailgun/inboundan inbound email to a Private Email mailbox
POST /api/webhook/{token}/contacts.createa workspace's own configured webhook (Zapier / n8n)

The reason is a measurement, not a preference: those callers are fire-and-forget. FunnelSwift's push into the hub is a tokio::spawn whose only failure handling is a warn! log line (src/coreswift.rs, spawn_lead_push) — there is no retry and no queue — so a 402 there does not upsell. It silently drops a lead the sibling already captured, and because the caller never reads the response, nobody ever sees the refusal.

The external api-key surface (POST /api/external/contacts) was listed here as a tenant-facing add until 2026-10-02 (kanban t_e2364c41). Measuring its callers — the six sibling spokes above, with the fire-and-forget push — and reading this guide's own description of it as the endpoint the Integration Centre hands to a spoke app showed that classification was wrong: a key-holding workspace could lose every satellite lead it captured, silently, the moment it reached its ceiling. Its new-row arm now behaves like every other ingest arm. A re-delivered lead already resolved to its row and still does, at any count.

What happens instead: the lead lands, the workspace's count goes past its ceiling, and the console says so. The workspace dropdown prints Usage: 101/100 contacts ⚠ over plan limit and the Dashboard shows an Over your plan's contact limit card naming the real numbers. GET /api/auth/me/usage carries the two fields the notice is built from (contacts_limit and contacts_over_limit), so the readout a tenant sees and the number the gate enforces can never disagree.

The ceiling still enforces: while the workspace is over, a contact the tenant adds in the console or by CSV import is refused with 402 Contact limit reached (n/m). Upgrade your plan for more contacts. Reading, editing and deleting contacts keeps working at every count, so a workspace can always get back under the limit. (Measured on this deployment 2026-10-02: the three workspaces that hold contacts — 122 / 81 / 78 rows — have no active plan row, so they are uncapped; the only plan-attached workspaces are Free-plan probes. The ceiling is a live upsell signal on the add paths and a visible advisory on captures.)

If you would rather the ingest arms refuse as well, that is a product change with a prerequisite: every sibling app needs a retry queue before the refusal can be safe, or captured leads are lost at the boundary with no error surfaced to anyone.

The help desk repeats the line — a customer's request is never refused either

The tickets module is the second instance of the same doctrine, and it is a hard stop only on the tenant router: the ticket queue and the admin support panel refuse a workspace whose plan does not grant the module with 402 (GET /api/tickets answers Support tickets is not available on your current plan. Upgrade to access it.), which is why Support Tickets → 💬 My Support is unavailable on that plan too. The three public intake arms are deliberately not gated:

Intake armWho reaches it
GET /s/<tenant-id>/widget.jsthe embed widget a workspace's own customers load on their site — served on every plan
GET /s/<tenant-id>/supportthe customer portal page and its request form — served on every plan
POST /s/<tenant-id>/ticketthe visitor's request itself — accepted and stored on every plan

The reason is the line above in a harder form: the caller here is the workspace's own customer, not a sibling app. A 402 would upsell nothing — the visitor is a third party to the plan, so the refusal would throw the request away before the plan can change and leave the workspace no way to explain it — and the workspace would lose the request and still have to answer the customer. Nothing is lost by accepting: the request lands on every plan, and the stored rows appear in Support Tickets the moment the plan includes the module.

What a workspace gets instead of silence: GET /api/auth/me/usage carries the three fields the notice is built from (tickets, tickets_readable, support_intake_unreadable), the workspace dropdown flags them (⚠ cannot open), and the Dashboard shows a Customer requests you cannot open card naming how many requests are waiting, with a See plans button. The count the notice names is the same COUNT(*) the readout renders, and the readable flag is the gate's own answer, so the state a tenant is shown and the gate the queue enforces can never disagree. A workspace with no active plan row takes the gate's no_plan grant — the queue opens — and the readout agrees.

One consequence for the operator: never gate the three public arms to make the upsell bite. The refusal lands on the workspace's customer, who is not a party to the plan, and the request is gone before the plan can change. If you would rather the public intake refuse as well, that is a product change, not a flag.

Limits that are NOT in the matrix any more

Two of the eleven limit rows have no enforceable quantity behind them and are absent from the matrix rather than left as a number that does nothing:

Retired keyWhy
limit_storage_gbCoreSwift stores no files, and this is permanent. No bytea/blob column exists anywhere in the schema (the only one is the migration ledger's own checksum), there is no upload or attach endpoint, and the only per-workspace content is CRM rows — measured at 210 bytes per contact on this box. At each tier's OWN contact ceiling that is ~0.02% of the GB ceiling it was priced against (agency: 50,000 contacts ≈ 10.5 MB against 50 GB), and the whole database — every workspace, 106 tables, indexes and all — is 22 MB, below even the free tier's 0.1 GB. A size source could not fire before the contact cap did, so the key stays retired. account_health.storage_mb is declared and written by no code path: do not wire it.
limit_monthly_creditsduplicate of plans.monthly_credits, which the credit engine reads

limit_api_calls_per_day was retired with them in kanban t_f49e4299 and re-registered once its counter existed (see the table above). The authored numbers survive in plan data (plans.features carries storage_gb / api_calls_per_day; plans.monthly_credits is untouched), so a retired key comes back the same way once the quantity it counts is measurable.

Team seats (a real second login)

Tenant console → Team & Invites adds a person to the workspace BY EMAIL: the owner types the address and a role, the server generates the password, hashes it once and mails it through the workspace's own credentials template. The console carries no password field on this path, and the owner never learns the password. Three routes back it: POST /api/auth/team/invite, GET /api/auth/team/members and DELETE /api/auth/team/members/:id. The roster read is available to every signed-in member (the Inbox's assignment picker is the same list); invite and remove are owner/admin only and take the workspace from the SESSION, never from the body.

Tag → free account (admin-configurable)

FunnelSwift can create a free CoreSwift CRM account when one of its leads is tagged with CoreSwift. It posts to POST /api/v1/internal/provision-free-account with the shared internal key; this app resolves the entry plan in-app and mints its own account for the lead's address through the same signup core the public signup uses — the workspace, its owner user and an entry-plan row on the chosen free tier — then mails the generated credentials. A second call for the same address answers already_exists and mints nothing.

The switch is in the admin console under Provisioning → Tag Provisioning:

Both are read by the receiver and written through GET|PUT /api/admin/provisioning-config (platform admins only).

Workspaces (per account)

Every account holds one or more workspace NAMES (workspaces, migration 124) through GET|POST /api/workspaces, PATCH|DELETE /api/workspaces/<id> and POST /api/workspaces/<id>/default, all scoped to the caller’s own claims.aid. Before this the five routes existed with no screen at all; the customer console now offers them in Settings → Workspaces.

Rules the server enforces, which the panel prints rather than restates: a name is unique per account (case-insensitive, 120 characters), the first workspace of an account becomes the default, deleting the default promotes the oldest remaining workspace, an account can never be deleted below one workspace, and a plan declaring max_workspaces in plans.features refuses further creations with the plan-upgrade sentence. No plan declares that cap today, so the limit is currently unbounded. Note the one measured gap: a brand-new signup is minted with no workspace row (migration 124 only backfilled the accounts that already existed), so a fresh account’s panel opens on its empty state and the first workspace the customer adds becomes the default.

Email branding (per workspace)

A workspace sets its own brand display name, colour and logo, and every transactional mail it sends carries them. Keyed by the tenant id (email_templates.aid), so it is per WORKSPACE, not per app.

The pane shows what your recipients see by asking the mailer itself: GET /api/account/branding/preview-body renders the welcome message through the SAME path a real send uses — template selection, merge fields, the branded header and the support footer — and returns it as {sent, to, subject, html, text, branded, logo_is_own} without mailing anything. The console paints that answer in a sandboxed frame and names the subject, so the pane cannot describe a message this app never emits and an uploaded logo is visible in the body. It shows the saved branding: editing the fields marks the preview unsaved until Save branding runs. Purple #4f46e5 is the fallback rule colour when the saved value is not a hex colour, exactly as the mail paints it.

PieceWhere
brand_name, brand_color, logo_url the workspace's OWN tenants.settings row, key email_branding — the store GET|PATCH /api/account/:id/settings already reads and writes. No new column.
the logo BYTEStenant_logos (migration 116), one row per tenant, tenant_id PK → tenants ON DELETE CASCADE. The container binds only its binary and migrations/, so a run-time file would die on the next restart.
POST|DELETE /api/account/branding/logo authenticated; the workspace id is the JWT aid, never the path. The ONLY writers of logo_url (version-stamped ?v=<epoch>).
GET /api/branding/logo/:tenant_id PUBLIC (in auth::route_policy::PUBLIC_ROUTES): a mail client fetches the <img src> with no credential. 404 when the workspace has no logo.
GET /api/account/branding/preview-body authenticated and read-only: renders the welcome mail with this workspace's SAVED branding through the real send path and returns {sent: false, to, subject, html, text, branded, logo_is_own}. Mails nothing. In an operator impersonation session it renders the workspace being viewed (the branding id is the JWT aid; the recipient fields are the caller's own row, never the request).
the render hooksrc/email.rs::queue_outbound_message — the ONE funnel both the email_templates row and the inline fallback pass through. Binds {{brand_name}}/{{logo_url}} per account, prepends the header block to the HTML part and the brand name to the text part. A workspace with nothing configured is NOT unbranded: every send resolves to this app's own header (/logo.png and the CoreSwift name), and a workspace's own name, colour and logo win field by field.

Rules and limits

Your profile picture

Any signed-in user can add a picture from their own profile screen: the file picker under Profile picture takes the file and uploads it immediately. Once set, the picture is shown in the console header and on the account's own profile; with none set the header shows the account's initial.

Accepted are PNG, JPEG, GIF or WebP, up to 2 MB — the same hint the screen prints. The type is decided from the file's own bytes rather than its name, so a renamed file cannot get past the check, and an oversized image is refused with the limit in the message instead of being stored truncated. Choosing a new file replaces the previous picture.

Routes: POST /api/profile/avatar stores the caller's own picture (behind the profile router's auth), and GET /api/avatars/<user id> streams it back — deliberately anonymous, because an <img src> fetch carries no credential, and it answers only the picture for that unguessable id, 404 when there is none.

Changing a sign-in email address

A customer can now move their own sign-in address with no support ticket: Profile in their workspace → Sign-in email → type the new address and press Send confirmation link. The app emails a confirmation link to the new address, and that address only becomes the sign-in identity when the link is opened — so a typo cannot lock anyone out, the old address keeps working, and nothing at all changes if the mail is never opened. The link works once and expires in two hours.

This console's own Profile screen carries the same card, for your account. There is deliberately no screen for moving a customer's address from here: point them at their own Profile, and if they cannot sign in at all, use Impersonate on the Tenants screen and do it from the customer's own Profile — the app still mails the confirmation link to the new address, and only whoever controls that mailbox can confirm it.

Routes: POST /api/auth/email-change (authenticated; acts on the session's own row, writes one pending row and queues the confirmation mail) and POST /api/auth/email-change/confirm (anonymous by necessity — the token in the link is the credential and is redeemed once). The mailed link lands on /confirm-email-change, which the app vhost serves from its own page. Pending requests live in the email_changes table. A reserved test address (the RFC-2606 names and the fleet's own harness domains) is refused before any row is written, because the mailer suppresses those destinations — so a mistyped test address can never retire a real pending request.

Private Email — the free inbox (30-day trial) and the $1 Inbox line (not on sale yet)

Every Free account may create one mailbox on their own domain with their own Mailgun account (bring-your-own-key). It is fully working — send and receive — for 30 days. When the window closes the mailbox does not stop: it drops to receive-only. Mail keeps arriving, nothing is deleted, and the address keeps working. Sending is what needs a paid plan.

The 30-day clock starts when the first mailbox is created on a verified domain, never at signup. A customer waiting on DNS loses nothing. There is no CoreSwift-hosted trial address: no free customer's mail passes through our domain or our Mailgun account.

The Shared Inbox tab is the setup: add the domain plus that customer's own Mailgun API key and webhook signing key, add the records the page lists (each with a Copy button, one sentence saying what it does and a live Active / Waiting / Needs attention badge), press Check again, then create the inbox. The create button stays disabled until the domain is verified and both the receiving and the sending-proof records are valid; the API refuses independently. The page also lists the DMARC record Mailgun does not return.

The branded address is its own record. A fourth group carries one CNAME, inbox → inbox.coreswiftcrm.com, which makes inbox.<their-domain> open the mail client on the customer's own name. It must be Proxied (orange cloud) at their Cloudflare — the reverse of every mail record, which must be grey — because their certificate carries the padlock. The server MEASURES the name (it asks it for the mail client) and answers active, waiting or attention; attention means the name resolves but serves something else, which in practice is Cloudflare's SSL/TLS mode set to Full (strict) refusing our origin certificate. The Shared Inbox tab's LOG IN button opens the branded name when it is active and inbox.coreswiftcrm.com while it is not, so the button is never a dead link.

Nothing is charged and no mailbox exists until verification passes. The 30-day trial starts at the customer's FIRST VERIFIED MAILBOX, not at signup; a later inbox on the same account inherits that window rather than restarting it. When the plan's inbox allowance is used up, the refusal names the plan that unlocks more and its price (read from the plan registry, so it can never promise an allowance the plan does not sell) and says plainly that nothing was charged and no mail was deleted.

All the numbers are registry data on the private_email module, editable in this console without a redeploy: email_mailboxes (Free 1), email_domains (Free 1), email_sending (Free off — the trial grants it per mailbox), email_trial_days (30), email_outbound_per_day (200, a fair-use ceiling that protects the sending domain) and email_storage_mb (free 2048 MB, then 5120 / 10240 / 51200 / 102400 / 256000 MB up the ladder).

The mailbox carries its own state: trial → receive_only → paid. A sweep runs hourly at :07 and once at every boot, idempotent, and writes exactly one audit_logs row per transition (action mailbox.trial_expired). It never deletes a message or a mailbox. Because the send path also reads the plan and the trial deadline live, moving a workspace onto a paid plan restores sending on the very next request, and that same request settles the mailbox's stored state to paid. The sweep never expires a mailbox whose plan sells sending, so a paying customer never collects a “trial expired” audit row.

The Inbox plan ($1/month, $10/year) exists with one mailbox, one domain, unlimited history and sending on — and no CRM: every other module is an explicit false row for it, so a CRM route answers the normal plan refusal. It ships inactive until a working payment page is pasted onto it, so no customer can reach a button that cannot take money; the plan screen still shows it, with its annual price and a Request path.

Upgrading a workspace off Inbox ends the $1 in the same action. Every workspace holds exactly ONE open plan line: the plan write closes the old line and opens the new one in a single statement, and the database refuses a second open line for the same workspace, so a customer can never be billed $1 and $29 at once. Plans & Billing now opens with Your bill, which states the single amount due, says there is one subscription, names the line that ended when the workspace last moved, and gives the date it ended. Once the Inbox row is reactivated, moving a workspace back down to Inbox puts it on $1 again and leaves its mailbox and every message untouched; while it is inactive the plan writer refuses it as not on sale, so no workspace can move onto it. The ledger is the append-only plan_lines table — a closed line keeps its own price, cycle and dates — and the trigger on tenant_plans is its only writer, so even a hand-run plan correction is recorded.

The inbox in plain language — what the customer does, and what you do

Every customer brings their own domain and their own Mailgun account: no customer’s mail ever passes through a CoreSwift domain or a CoreSwift Mailgun account. Your job is not to set any of it up for them — it is to know each step well enough to answer a support call in one sentence, and to keep the registry numbers right (see Private Email — the free inbox and Mailbox storage below).

TopicWhat it is, in one sentenceWhere the customer does itWhat the operator needs to know
Domain and MailgunThe customer points a domain they already own at their own Mailgun account, and the app walks them through the records it needs.Shared Inbox (the three-step setup page) and Inbox → Email Domains & SendingNothing is charged and no mailbox exists until the records verify. A customer stuck on DNS loses nothing: the 30-day trial has not started.
The two keysThe API key is the sending half — Mailgun → Settings → API Keys → Private API key, beginning key-; the webhook signing key is the receiving half — Mailgun → Send → Domains → the domain → Webhook signing key — and without it the domain is send-only.The setup page, and the domain row on Email Domains & SendingBoth are stored sealed. A key Mailgun rejects shows as Needs attention on that one domain and nothing else in the app breaks.
Catch-allAccept mail for addresses that do not exist on the domain; the arrivals land in the Everything else (MISC) bucket.The Catch-all column on Email Domains & SendingSold by the plan and checked at delivery time, not at switch time — see Catch-all is part of the plan below.
MailboxesAn address the customer can sign into, read and send from.Created on Email Domains & Sending; read in Shared Inbox or at inbox.<their-domain>Counted per plan: Free 1, Inbox 1**, Starter 3, Pro 10**, Professional 10, Enterprise 50, Agency 50 (**Inbox and Pro are not on sale — plans.is_active = false — so no customer can be seated on them today; the limits stay listed because the registry carries them). At the ceiling the refusal names the plan that adds more and charges nothing.
Team and seatsA seat is one person who can sign in, the owner included.Settings → Team & InvitesCeiling: Free 1, Starter 3, Pro 5**, Professional 15, Enterprise 20, Agency 100 (**Pro is not on sale; the $1 Inbox line is not on sale either and sells no second seat). The app mints and emails the password; removal deactivates, never deletes.
StorageThe allowance that bounds a mailbox — never a clock.The meter on the mail screenFree 2 GB, Inbox 5 GB**, Starter 10 GB, Professional 50 GB, Enterprise 100 GB, Agency 250 GB (**Inbox and Pro are not on sale; Pro’s 10 GB is the same allowance Starter sells). Banner and one email per rung at 50 / 75 / 99%, at most once per rung per calendar month.
Trial expiryThirty days after the first verified mailbox the address keeps receiving and stops sending.The mail screen offers Keep inbox — $1/mo (not on sale yet) and Upgrade CRMNothing is deleted. A plan move restores sending on the customer’s next request and the hourly sweep settles the stored state to paid.

Which of these rungs a customer can actually buy. The tables above list every plan row the registry carries, because an operator can seat a workspace and the limits are real. Only Free, Starter, Professional, Enterprise and Agency are on sale: Inbox and Pro carry plans.is_active = false, so GET /api/billing/plans never returns them and every plan writer refuses them as “not on sale” until the row is reactivated. Re-activating a row puts it back on the buyable list with no guide edit, which is why this operator table keeps the row while the customer guide does not.

The three record groups, so you can name them on a call. MX (two records, mxa.mailgun.org and mxb.mailgun.org) is where mail is delivered — without it nothing arrives. SPF and DKIM (v=spf1 include:mailgun.org ~all plus the DKIM value the customer’s own Mailgun mints) is proof the mail really is from them — without it receiving servers distrust their outbound mail. The CNAME (inbox → inbox.coreswiftcrm.com) is the branded address — it makes inbox.their-domain open the mail client under their own name and padlock. Mail records must be DNS only (grey cloud) at Cloudflare; only that CNAME is Proxied (orange cloud). A customer who has put everything on orange has broken their own mail, and it presents as “Mailgun says the domain is verified but nothing arrives”.

Where the trial clock actually starts. At the first mailbox created on a verified domain — not at signup and not at the first domain. A later mailbox on the same account inherits that window rather than restarting it. The hourly sweep at :07 (and once at boot) moves a mailbox trial → receive_only and writes exactly one audit row per transition; it never touches a mailbox whose plan sells sending.

How a customer gets sending back. Keep inbox — $1/mo buys the mailbox alone; Upgrade CRM buys the platform. Both are a plan move, so sending works on the very next request — there is no job to wait for, and no message is lost or re-sent.

Sending and receiving are separate choices (Mailgun is no longer the only receiver)

A customer can send through one service and receive through another. The inbox setup page asks two independent questions, and the server stores them in two columns: provider_type answers how the domain SENDS (mailgun over Mailgun's API, or smtp for Amazon SES / SendGrid / Postmark / Google Workspace / any SMTP server) and inbound_provider answers how it RECEIVES (mailgun, sendgrid, or none for a sending-only domain). Before this, one column answered both, so a customer who wanted to keep their own sender was told to move their inbound mail.

QuestionOptions that really workWhat it needs
SendingMailgun (their own account) or any SMTP service — Amazon SES, SendGrid, Postmark, Google WorkspaceMailgun: their API key (or a saved key). SMTP: host, port, username, password
ReceivingMailgun, or SendGrid Inbound Parse, or nowhereMailgun: its webhook signing key plus the domain in their Mailgun account (for records and the re-check). SendGrid: one MX record at their DNS host plus our Inbound Parse URL pasted into their SendGrid account

The combinations, and what each one means. Mailgun+Mailgun is the original setup. SMTP+Mailgun is the customer who keeps Amazon SES for outbound and receives through Mailgun: the mailbox route IS created in their Mailgun account, and the Mailgun API key is stored only so the records page and Check again can read that domain — sending still goes over SMTP. Mailgun+SendGrid and SMTP+SendGrid need no Mailgun receiver at all: no route is created (the account's route quota is 5), and the domain's MX points at mx.sendgrid.net.

The SendGrid receiver

POST /api/v1/webhooks/sendgrid/inbound/<token> — the token is per domain (private_email_domains.inbound_token, 32 hex characters, minted on write and shown only on that domain's records response as inbound_parse_url). SendGrid Inbound Parse signs nothing: there is no HMAC and no shared-secret header, so the unguessable URL is the credential — the same shape as /api/webhook/<token>/<action>. Three refusals matter on a support call: an unknown token is 401 and writes nothing; a domain whose receiving provider is not SendGrid is refused with a sentence naming the receiver it does use; and a delivery whose recipient belongs to a different domain than the token's is answered received: false (a valid token is not a key into another workspace's inbox).

Verifying a SendGrid domain is DNS, not an API. SendGrid publishes no endpoint that says "this domain is mine", so Check again and the records page ask DNS-over-HTTPS whether the domain's MX names mx.sendgrid.net. dig is not installed on this box; the check runs over DoH deliberately. Promotion only, exactly like the Mailgun arm: one ambiguous read never demotes a working domain.

What the setup page shows for a SendGrid receiver. Two rows are ours: the MX (mx.sendgrid.net, priority 10) and the Inbound Parse URL with a copy button. Sending records are NOT invented — for an SMTP sender the page carries one row saying where they come from (the sending service), and for a Mailgun sender it shows Mailgun's own sending records. The DMARC row and the branded inbox.<domain> CNAME appear in both shapes.

Amazon SES inbound is deliberately NOT offered. It arrives as an SNS notification pointing at an S3 object, so it needs its own signature verification (SNS certificate fetch and validation) plus a raw-message fetch — a bigger job than the SendGrid shape — and no customer is blocked without it: SES sending already works over SMTP, and receiving is covered by Mailgun and SendGrid. No dropdown entry claims it.

Switching receiving later is PATCH /api/private-email/domains/:id with receiving_provider. It never touches sending, never deletes a message, and mints the inbound token when SendGrid is chosen and none exists. The console offers it under the domain on the setup page.

Catch-all is part of the plan, not a domain setting

What it does. With catch-all on, the inbound receiver accepts a message for any address on that domain, including one no mailbox row exists for, and stores it in the workspace’s Everything else (MISC) bucket. With it off — and with it on under a plan that does not sell it — the delivery is refused, byte-identically to a domain that never had it, so the sender gets a bounce and the workspace sees nothing.

Who it is sold to. On this install plans.features.catch_all is true for Pro, Professional, Enterprise and Agency, and absent on Free, Inbox and Starter — but Pro is not on sale (plans.is_active = false, deactivated 2026-11-10) and Inbox is a $1 add-on, not a plan on the ladder, so the plans a customer can actually buy that include catch-all are Professional ($79), Enterprise ($119) and Agency ($149). The switch is per domain (private_email_domains.catch_all_enabled), but the plan is asked at delivery time: the switch is a preference and the plan is the entitlement. A workspace downgraded off a plan that sells it keeps the switch remembered and receives nothing on it until it comes back up, and the console paints off — not on your plan with a Clear button rather than showing a switch that lies.

Reading it per tenant. GET /api/v1/private-email/admin/limits/:tenant_id answers sells_catch_all (the entitlement), catch_all_domains_enabled (how many domains carry the remembered switch) and catch_all_domains_off_plan (how many are being refused right now). A non-zero third figure after a downgrade is ordinary residue, not a fault — and it is the exact answer to “I turned catch-all on and mail still bounces”.

What it is not. It creates no mailbox: a catch-all arrival has no address to sign into and starts no trial. It also never becomes a support ticket by itself — a person files it, or a rule does.

The refusal sentence is read from the plan catalogue. Pressing Enable catch-all on a plan that does not sell it answers with the cheapest active plan that does sell it, and that plan’s price — today “Catch-all requires the Professional plan ($79/month) or higher”. Nothing in the sentence is typed by hand: feature_gate::catch_all_refusal reads plans (active only, cheapest first) joined to the private_email module and the same features.catch_all flag the delivery gate reads. That coupling is the point: re-pricing a tier, giving catch-all to a cheaper plan or re-activating Pro changes the answer with no redeploy and no guide edit. The console prints the server’s sentence verbatim, and the same clause builds the off — not on your plan hint on the domain list, so the refusal and the hint cannot disagree.

Mailbox storage: the quota, the reminder ledger, and the retention backstop

Storage, not a clock, is what bounds a mailbox (B206, David 2026-11-11 revised). Every account has an allowance from the private_email module registry key email_storage_mb. This install ships: free 2048 MB (2 GB), inbox 5120 MB (5 GB), starter 10240 MB (10 GB), professional 51200 MB (50 GB), enterprise 102400 MB (100 GB), agency 256000 MB (250 GB). The ladder is monotonic with price, so an upgrade always buys more storage; every plan now carries an explicit number (the paid plans used to be NULL = uncapped, which meant their meter could never warn). A per-account tenant_plans.feature_overrides entry ({"email_storage_mb": {"enabled": true, "limit_value": 2048}}) overrides one account without a redeploy.

What the figure counts: every stored message in private_email_messages.size_bytes (inbound and outbound), plus tenant_logos.bytes and user_avatars.bytes. One function (private_email::storage::usage) computes it, and the meter, the in-app banner, the reminder email and the 100% refusal all read it, so the number a customer is shown and the number that refuses a file cannot disagree. GET /api/private-email/messages/counts answers it as storage{used_bytes, quota_bytes, percent, level, used_human, quota_human, banner{...}} plus retention{...}.

The three rungs and the reminder ledger. At 50%, 75% and 99% the mail screen shows a banner that stays until space is freed, and the account owner is emailed once per rung per month. The dedupe is the table private_email_storage_notices, unique on (tenant_id, threshold_pct, period) where period is the UTC month: the row is claimed (INSERT .. ON CONFLICT DO NOTHING RETURNING id) BEFORE the mail is queued, so a provider retry, a burst of inbound deliveries, and the ten-minute sweep cannot send a second message for the same rung in the same month. The email is rendered by the storage_warning template type (inline plain-text body, editable per workspace through the template registry); it states the actual figures, the action, the retention backstop and the two links. A queue failure releases the claim so a later pass retries instead of silently skipping the month. The rungs are evaluated on every counts read (a customer who opens their mail is told immediately) and by a sweep every ten minutes at :13 for owners who never open it.

At 100% (and above) nothing is refused except a new file. Inbound mail is still stored (the quota never touches the receive path), sending still works, and no row is deleted to make room. A new logo or profile picture is refused with 402 and a sentence naming both figures and both ways out: delete/archive mail (Trash and Spam count until emptied), or upgrade. The refusal is one function (storage::enforce_new_file_room) called by the two upload doors. A full mailbox therefore never loses the owner business a bounce would.

Time-based retention is a far backstop, not the rule. tenant_email_limits.retention_days defaults to 3650 (ten years) since migration 142, and every existing row shorter than that was raised, so this deploy deletes no customer mail on a clock. The daily 03:00 sweep removes rows older than the window from private_email_messages, outbound_messages and email events; its answer reports inbox_messages_deleted separately. The admin retention route keeps the 30..3650 control and now reports the backstop when an account has no explicit row. The mail screen states the window that applies, with the date the oldest stored message would reach it, so nothing is ever removed silently.

Logo and signature on a customer's mail

Each workspace has one email logo and one default signature, both set by the customer on Email Branding in the tenant console; the per-mailbox override is in the mail client's Signature button, not in this console. This is unchanged by the operator: there is no platform-level switch that turns a customer's signature on or off.

What the server does

Reading it back / proving it

If a customer reports a missing logo, check in this order: select content_type, width_px, height_px, octet_length(bytes) from tenant_logos where tenant_id=… (the row exists), select settings->'email_branding'->>'logo_url' from tenants where id=… (the pointer the mailer reads), then the address's signature_mode — an address set to off sends nothing at all, logo included, by design.

Private email: the DNS-records and domain-removal controls

Two routes had no caller until kanban t_2b371936; both now live on the domain row of Email Domains & Sending in the workspace console.

ControlRouteWhat it does
DNS recordsGET /api/private-email/domains/:id/records Reads the checklist from the customer's own provider and renders it grouped as receiving / sending proof, each row with type, name, value, priority, a live Active / Waiting / Needs attention badge and a Copy button. Before this change the console read it ONLY while the workspace had no mailbox, so an established customer adding a second domain could never see its records; a provider that refuses (an SMTP domain, a bad key) is shown as its own sentence in the same window, never an empty table.
Remove this domainDELETE /api/private-email/domains/:id Deletes the domain row (tenant-scoped). The database cascades its mailboxes and auto-replies; message rows keep their own copy of the mail and only lose the mailbox link. A typed confirmation of the domain name is required and any other answer is refused with "Nothing was removed — the name did not match".

Verification, in order: 1) the domain row shows the DNS records button; 2) pressing it answers 200 on a verified Mailgun domain and paints real MX / SPF / DKIM rows with Copy buttons; 3) on an SMTP domain the same window shows the provider's refusal sentence; 4) Remove this domain with a wrong name removes nothing and says so, and with the exact name answers 200 and the row disappears.