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.featuresis a JSONB map{feature: bool}holding only what was explicitly switched.app/core/features.pylists 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.featuresandGET /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 inapp/api/v1/router.py(/tickets,/escalation-> tickets;/mail-> mail) and answers403when the tenant lacks it. The dashboard hides the matching navigation, pages (featureindefinePageMeta), home widgets and quick actions from/me'sfeatures, 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 storesbody_textplusbody_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_tenantsruns everyMAIL_SYNC_INTERVAL_MINUTES(scheduler jobmail-sync, one process at a time), andPOST /mail/syncruns it for one tenant now. The IMAP folder is opened read-only and fetched withPEEK(nothing is marked read on the server). The cursor isimap_uidvalidity+imap_last_uid; a changed UIDVALIDITY restarts it. The first sync takes only the newest 50 messages. Re-reading is safe:Message-IDis unique. - Threading follows
In-Reply-To/Referencesto an already stored message; anything else starts a new thread. - Sending:
POST /mail/threads(compose) andPOST /mail/threads/{id}/replysend through the tenant's SMTP, setMessage-ID/In-Reply-To/References, and store the message as outbound. A failed send stores nothing and answers502. - Sender lookup: the counterparty's address is matched to a
Customer(case-insensitiveCustomer.email) and, failing that, to live lines whoseSubscriber.extra["email"]carries it. The thread detail returns the customer and their lines;customer_idis 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.