Skip to main content

Feature flags and the shared mailbox

Code: app/core/features.py, app/models/mail.py, app/schemas/mail.py, app/services/mail.py, app/services/mail_transport.py, app/api/v1/routes/mail.py.

Feature flags​

A tenant can use a product area only while a superadmin has it switched on for that tenant (e.g. "Celtic" has no tickets, "Hibs" does).

  • Tenant.features is a JSONB map {feature: bool} holding only what was explicitly switched. app/core/features.py lists the features (tickets, mail) and their defaults (tickets on, mail off), so a new feature needs no data migration and existing tenants keep what they had.
  • Switching is PATCH /tenants/{id} with {"features": {"mail": true}} - the same superadmin-only route as renaming a tenant (tenants:manage), audited with the change.
  • TenantRead.features and GET /me (features, for the request's tenant) always carry the effective map.
  • Enforcement is on the API: require_feature(Feature.X) is mounted on the feature's routers in app/api/v1/router.py (/tickets, /escalation -> tickets; /mail -> mail) and answers 403 when the tenant lacks it. The dashboard hides the matching navigation, pages (feature in definePageMeta), home widgets and quick actions from /me's features, but that is a courtesy, not the control.
  • A feature is independent of permissions: a user needs the permission and the tenant needs the feature. Platform admins see a tenant exactly as its users do.

Mail​

Settings -> Comms​

mail_settings holds one live row per tenant: config (validated by MailConfig) with SMTP (host, port, ssl/starttls/none, username, password, from address and name) and, optionally, IMAP (host, port, security, username, password, folder). Without IMAP the tenant can send but not receive. The two passwords are encrypted at rest (EncryptedSecretsJSON) and are never returned - the API reports smtp_password_set / imap_password_set. Saving merges over the stored config: an omitted password is kept, an explicit null clears it.

Because the server connects to whatever host an admin types in, hosts must resolve to public addresses unless ENVIRONMENT=local or MAIL_ALLOW_PRIVATE_HOSTS=true (checked on save and on every connection). The settings routes need mail:manage; POST /mail/settings/check tests each leg separately.

Inbox​

  • mail_threads - a conversation (subject, counterparty, unread count, snippet, customer_id, ticket_id); mail_messages - one email, inbound or outbound, unique per (tenant_id, message_id). A message stores body_text plus body_html, which is run through an allow-list sanitizer on ingest (sanitize_html, no remote images) so it can be rendered; HTML-only mail also gets a text body derived from it.
  • Receiving: mail.sync_all_tenants runs every MAIL_SYNC_INTERVAL_MINUTES (scheduler job mail-sync, one process at a time), and POST /mail/sync runs it for one tenant now. The IMAP folder is opened read-only and fetched with PEEK (nothing is marked read on the server). The cursor is imap_uidvalidity + imap_last_uid; a changed UIDVALIDITY restarts it. The first sync takes only the newest 50 messages. Re-reading is safe: Message-ID is unique.
  • Threading follows In-Reply-To / References to an already stored message; anything else starts a new thread.
  • Sending: POST /mail/threads (compose) and POST /mail/threads/{id}/reply send through the tenant's SMTP, set Message-ID / In-Reply-To / References, and store the message as outbound. A failed send stores nothing and answers 502.
  • Sender lookup: the counterparty's address is matched to a Customer (case-insensitive Customer.email) and, failing that, to live lines whose Subscriber.extra["email"] carries it. The thread detail returns the customer and their lines; customer_id is cached on the thread.

Ticket from an email​

POST /mail/threads/{id}/ticket creates a ticket (needs tickets:write and the tickets feature): subject from the thread, description quoting the latest message (its sanitized HTML, or its escaped text), customer from the lookup, author = the caller. The thread keeps ticket_id; a second attempt answers 409.

Permissions​

mail:read (inbox), mail:write (send, reply, sync, create tickets), mail:manage (Comms settings). The pre-configured operator role has read/write; tenant_admin and superadmin have all three. Roles are data, so the migration c7b9d1f3a5c7 grants them to the existing system roles; custom roles are granted in Settings -> Roles.