CoreSwift CRM is the central CRM and automation platform. It manages contacts, deals, pipelines, campaigns, and email sequences with full template-based email delivery.
coreswiftcrmcoreswift_crm databaseadmin.coreswiftcrm.comAll transactional emails use database-stored templates in the email_templates table. Templates support {{variable}} placeholders for dynamic content.
| Type | When Used | Available Merge Fields |
|---|---|---|
welcome | Account creation | {{name}}, {{email}}, {{password}}, {{app_url}} |
purchase_confirmed | Successful payment | {{name}}, {{plan_name}}, {{app_url}} |
password_reset | Password reset request | {{name}}, {{token}}, {{app_url}} |
email_change | Customer asks to move their sign-in address | {{name}}, {{new_email}}, {{link}}, {{token}}, {{app_url}} |
| Method | Path | Description |
|---|---|---|
| GET | /api/email-templates | List all templates (with pagination + template_type filter) |
| POST | /api/email-templates | Create a new template |
| GET | /api/email-templates/:id | Get a single template |
| PUT | /api/email-templates/:id | Update a template (partial fields) |
| DELETE | /api/email-templates/:id | Delete a template |
| GET | /api/email-templates/merge-fields | List available merge fields by type |
welcome, purchase_confirmed, password_reset{{variable}} interpolation){{variable}} interpolation){{variable}} interpolation)html_body; otherwise plain bodysend_template_email() with the template type and variable mapis_default = trueoutbound_messages for async deliveryThe admin interface includes a dedicated Email Templates page with:
{{variable}} placeholdersThree default templates are seeded on first migration:
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:
.coreswiftcrm.com. Letters, numbers and hyphens, 3 to 63 characters. The server refuses a name another workspace already holds (“already in use”), a name the platform reserves (app, admin, inbox, mail, api, calendar and the rest of the system's own hostnames), and anything that is not a legal DNS label. Uniqueness is enforced by the database, so two workspaces can never share an address even if two people save at the same moment.calendar.yourbusiness.com), saves it, then creates one CNAME record pointing at the address the panel prints (<name>.coreswiftcrm.com) and presses Check now. The check asks the domain itself which workspace answers there: a domain that reaches this workspace is marked domain verified, and one that answers nothing, or answers for a different workspace, stays waiting for DNS with the reason printed underneath. A real visit to a correctly pointed domain marks it verified on its own, so the owner does not have to press anything twice.<name>.coreswiftcrm.com; a domain is an addition, not a replacement, so a mistake in DNS can never take a customer’s calendar away.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.
CoreSwift powers the Booking CTA slot on MultiDirectory business listing pages.
Flow:
Controlled vocabulary only — business owners cannot type custom CTA text. This keeps directory branding consistent.
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.
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.
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:
https://.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.
| Frontend Page | Route | Methods |
|---|---|---|
| Dashboard | /api/dashboard/stats | GET |
| Contacts | /api/contacts | GET, POST |
| Companies | /api/companies | GET, POST |
| Deals | /api/pipelines/:pipeline_id/opportunities | GET, POST |
| Campaigns | /api/campaigns | GET, POST |
| Email Templates | /api/email-templates | GET, POST |
| Message Templates | /api/comms/templates | GET, POST |
| Plans | /api/billing/plans | GET, POST |
| Account plan (operator) | /api/admin/tenants/:id/plan | PUT |
| Audit | /api/audit | GET |
docker logs coreswiftcrm --since 10mcurl http://localhost:8084/api/healtha psql session on the CoreSwift databaseCoreSwift 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:
| Event | Call |
|---|---|
| Tenant moves to a paid plan | POST {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 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.
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.
| Endpoint | Method | Description |
|---|---|---|
/api/v1/private-email/admin/limits | GET | List all tenants that have custom email limit overrides |
/api/v1/private-email/admin/limits/:tenant_id | GET | View a tenant's plan defaults, your overrides, and effective limits |
/api/v1/private-email/admin/limits/:tenant_id | PATCH | Set 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.
/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:
null (or omit the field entirely) to fall back to what the tenant's plan provides/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:
plan_defaults — what the tenant's purchased plan provides (from the plans.features JSON column)overrides — the full override row you've set for this tenant (null if no overrides exist)effective_* — what's actually enforced: your override wins if set, otherwise the plan defaultsells_catch_all — whether the tenant's current plan includes catch-all (the entitlement the inbound receiver asks at delivery time)catch_all_domains_enabled — how many of this workspace's domains still carry the remembered catch-all switchcatch_all_domains_off_plan — how many of those are refused right now because the current plan does not sell catch-all (non-zero is the ordinary residue of a downgrade; see "Catch-all is part of the plan" below)/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.
When a tenant tries to add a domain or create a mailbox, the system checks in this order:
tenant_email_limits table for a row matching the tenantnull), the tenant's plan features JSON is used"Domain limit reached (X/Y)""Mailbox limit reached (X/Y)"private_email: false in the plan features, the tenant gets "Private Email not available on your plan" for any operationCatch-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:
GET /api/private-email/domains) reports the stored switch as catch_all_enabled, what the receiver actually does as catch_all_effective, and catch_all_off_plan plus a catch_all_hint sentence when the two differ. The customer console paints "off — not on your plan" in that state, never "on".GET /api/private-email/admin/limits/:tenant_id) carries sells_catch_all, catch_all_domains_enabled and catch_all_domains_off_plan; the limits table on this console shows those as its Catch-all column, so a workspace whose stored switch is being refused is visible instead of inferred.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.
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.
database (the credential saved here), environment (the server variables), or database+environment (the row is filled in part and the environment completes it).EMAIL_API_URL and EMAIL_API_KEY are present (presence only, never values).| Field | Meaning |
|---|---|
| Provider | The 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 name | Display name on the message |
| From address | Must be an address on the sending domain, or receivers reject the message |
| API key | The provider credential. Leave the dots to keep the stored key; clear the field to remove it |
| Operator alert address | Where "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).
admin_settings row keyed email;EMAIL_* variable for that same field;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.
| Method | Route | Purpose |
|---|---|---|
| GET | /api/admin/email-config | Masked transport: status, which store carries it, key length — never the key |
| PUT | /api/admin/email-config | Save provider / api_url / api_key / from_address / from_name / operator_alert_email |
| DELETE | /api/admin/email-config | Remove the saved credential, return to EMAIL_* |
| POST | /api/admin/email-config/test | Send 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.
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.
role = owner, the same role a self-serve signup gets, so the owner can invite people, open Settings and manage the workspace;settings.account_tenant_id), so the row always names the account it belongs to and the company stays in the operator’s portfolio list;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.
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.
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.
{ "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.
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.
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):
| Flag | Feature | Module | Notes |
|---|---|---|---|
campaigns | Campaigns | campaigns | Sequenced email campaigns |
automation | Automations | automation | Trigger/action engine |
checklists | Checklists | checklists | Onboarding/process checklists |
ai_enabled | AI scoring & helpers | ai_enabled | Lead scoring and AI helpers |
tickets | Support tickets | tickets | In-house ticketing + email-to-ticket |
native_apps | Native app connectors | native_apps | FunnelSwift, ADASwift, MissedCall, WorkflowSwift, Multi-Directory |
telnyx | SMS & voice (Telnyx) | telnyx | SMS, number management, call tracking |
round_robin | Round-robin routing | round_robin | Fair lead distribution across a team |
events | Event system | events | Internal event bus |
monitoring | Monitoring & health | monitoring | Account health scoring and thresholds |
bookings | Bookings & scheduling | bookings | Calendar booking pages |
google_calendar | Google Calendar sync | google_calendar | Two-way calendar sync |
api_access | API access | api_access | Per-tenant API keys |
webhooks | Webhooks | webhooks | Outbound webhook delivery |
integrations | Integrations | integrations | n8n and third-party integrations |
provider_keys | Provider keys | provider_keys | Bring-your-own provider credentials |
support_widgets | Support widgets | support_widgets | Embeddable support surfaces |
tracked_links | Tracked links | tracked_links | Click tracking links |
private_email | Private mailbox | private_email | Own domain + mailboxes (also has its own limits) |
email_sending | Email sending | private_email | Whether this plan may SEND mail from its mailboxes (receiving is always allowed) |
portfolio | Portfolio sync | portfolio | Cross-tenant portfolio management |
account_match | Account matching | account_match | |
notifications | Notifications | notifications | |
notifications_in_app | In-app notifications | notifications | |
notifications_email | Email notifications | notifications | |
notifications_sms | SMS notifications | notifications | |
notifications_whatsapp | WhatsApp notifications | notifications |
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.
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:
| Route | Method | Who | What it does |
|---|---|---|---|
/api/admin/tenants/:id/plan | PUT | platform admin only | Puts 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.
| Answer | When |
|---|---|
| 200 | written; created_row is true when the account had no plan row before |
| 403 | the caller is not a platform admin — a tenant owner, or a role string such as agency_admin whose users.is_platform_admin is false |
| 404 | no account with that id, or no plan with the slug / id you sent (the answer names the value it looked for) |
| 409 | the 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 |
| 422 | a 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).
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 key | Module | Ceiling | Read by |
|---|---|---|---|
limit_max_widgets | limits | support widgets per account | POST /api/widgets |
limit_max_industries | limits | industry dashboards | POST /api/industries |
limit_max_contacts | limits | contacts | POST /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_users | limits | active 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_pipelines | limits | pipelines | POST /api/pipelines |
limit_integrations | limits | connected integrations | POST /api/integrations |
limit_api_calls_per_day | limits | authenticated 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_domains | private_email | own sending domains | adding a Private Email domain |
email_mailboxes | private_email | mailboxes | provisioning 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.
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 arm | Who feeds it |
|---|---|
POST /api/external/contacts | the 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/contacts | Multi-Directory / ZaarHub lead sync (internal key) |
POST /api/v1/webhooks/cross-app/tag-sync | FunnelSwift lead + tag sync (internal key) |
POST /api/v1/internal/tag-provision | FunnelSwift 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-sync | satellite apps pushing surveyed contacts (satellite key) |
POST /api/v1/webhooks/mailgun/inbound | an inbound email to a Private Email mailbox |
POST /api/webhook/{token}/contacts.create | a 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 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 arm | Who reaches it |
|---|---|
GET /s/<tenant-id>/widget.js | the embed widget a workspace's own customers load on their site — served on every plan |
GET /s/<tenant-id>/support | the customer portal page and its request form — served on every plan |
POST /s/<tenant-id>/ticket | the 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.
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 key | Why |
|---|---|
limit_storage_gb | CoreSwift 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_credits | duplicate 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.
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.
limit_max_users, counted on users.is_active — the same expression GET /api/auth/me/usage reports as users. At the ceiling the invitation answers 402 with a sentence that names the plan and price that add seats (or says the account is on the largest allowance and should free a seat). On this install, free = 1 person, starter = 3, professional = 15, enterprise = 20, agency = 100.private_email_messages.assigned_user_id) and inboxes (private_email_boxes.user_id) are unassigned back to the workspace, their personal API keys are deactivated, and password_changed_at is stamped so an old token cannot be replayed if the row is ever reactivated. The answer reports each count; nothing is destroyed.private_email_messages.ticket_id — there is no second ticket-side owner to drift. A ticket raised by hand (no conversation behind it) has no member assignment and says so.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:
admin_settings.provision_from_tags_enabled. Ships ON (code default — a fresh install has the door open), so a tagged lead gets an account immediately; turn it off here and the receiver answers 403 and creates nothing.admin_settings.provision_entry_plan_slug, default free. Only this app's own free, active plans are listed; a plan that carries a price can never be seated on a tag-provisioned account.Both are read by the receiver and written through GET|PUT /api/admin/provisioning-config (platform admins only).
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.
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.
| Piece | Where |
|---|---|
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 BYTES | tenant_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 hook | src/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. |
logo_url is written only by the logo endpoints;
a settings write that OMITS it inherits the stored value, so a stale panel echo cannot un-reference a
logo that is still stored. An explicit "" clears it.image_store::MAX_IMAGE_BYTES = 2,031,616
bytes (2 MB minus the multipart envelope). The per-route DefaultBodyLimit is inert here —
body_deadline::body_read_deadline_middleware reads the body OUTSIDE every route layer, with
axum's own 2 MB default — so the envelope (image + framing) is what has to fit. The picker checks the
same number the server does.nosniff.outbound_messages.is_html is set by the
queue, and the Mailgun arm sends an html part as well as text when it is true.
Measured 2026-10-08: before this, every HTML transactional mail this app sent went out in the
text part, which is why the branding header (HTML) could not render in a client.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.
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.
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.
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).
| Topic | What it is, in one sentence | Where the customer does it | What the operator needs to know |
|---|---|---|---|
| Domain and Mailgun | The 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 & Sending | Nothing 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 keys | The 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 & Sending | Both are stored sealed. A key Mailgun rejects shows as Needs attention on that one domain and nothing else in the app breaks. |
| Catch-all | Accept 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 & Sending | Sold by the plan and checked at delivery time, not at switch time — see Catch-all is part of the plan below. |
| Mailboxes | An 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 seats | A seat is one person who can sign in, the owner included. | Settings → Team & Invites | Ceiling: 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. |
| Storage | The allowance that bounds a mailbox — never a clock. | The meter on the mail screen | Free 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 expiry | Thirty 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 CRM | Nothing 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.
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.
| Question | Options that really work | What it needs |
|---|---|---|
| Sending | Mailgun (their own account) or any SMTP service — Amazon SES, SendGrid, Postmark, Google Workspace | Mailgun: their API key (or a saved key). SMTP: host, port, username, password |
| Receiving | Mailgun, or SendGrid Inbound Parse, or nowhere | Mailgun: 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.
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.
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.
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.
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.
POST /api/account/branding/logo): decoded with the image crate, fitted inside 440 x 176 px (2x the 220 x 88 px it is drawn at, so it stays sharp on retina screens), never upscaled, and re-encoded as PNG — Outlook does not render WebP, so the format is forced rather than trusting the upload. The pixel size is stored in tenant_logos.width_px/height_px so the HTML can carry width/height attributes (Outlook sizes an image from the attributes before it loads). The response reports source_bytes, bytes, width_px, height_px; the storage quota is charged the RESIZED size.tenant_email_signatures holds the workspace default (name / title / company / phone / line), and private_email_boxes holds each address's signature_mode (inherit | custom | off) plus its own five fields. custom falls back to the workspace value per field; off prints nothing, including the legacy free-text private_email_boxes.signature column, which is still appended when it is set on an address that is not off.send_handler::deliver — the one place mail leaves an account — so compose, reply, reply-all and forward all get it and no second sender can skip it. include_signature: false on POST /api/private-email/send or …/messages/:id/reply drops it for one message (absent means yes).cid:coreswift-logo.png and the bytes travel in the message (Mailgun: hand-built multipart with the part named inline and the Content-ID derived from the filename; SMTP: multipart/alternative whose HTML arm is multipart/related with Content-ID). That is why deleting or replacing a logo cannot break already-sent mail, and why the logo draws even where remote images are blocked.text on Mailgun, a text/plain part over SMTP). Before this the typed plain text was sent AS the html part.GET /api/private-email/signature?mailbox=info@example.com — what Compose shows that address: resolved fields, mode, and the server-rendered html (public logo URL, not cid:).GET /api/private-email/signature/workspace — the workspace default plus its rendered preview.PUT /api/private-email/signature — one address's mode and five fields. PUT /api/private-email/signature/workspace — the workspace default, and its ONLY writer.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.
Two routes had no caller until kanban t_2b371936; both now live on the domain row of
Email Domains & Sending in the workspace console.
| Control | Route | What it does |
|---|---|---|
| DNS records | GET /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 domain | DELETE /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.