Skip to content

AI privacy controls

Purpose. Make “your mail is not sent anywhere you did not agree to” a property of the code rather than a promise in a brochure. The customer already supplies the inference; these are the controls over whose mail reaches it, what of it reaches it, and what is recorded afterwards.

The three gates. Between a configured provider and a message being sent, three things must all be true. They answer different questions and belong to different people:

Gate Whose decision Default
1 A provider is configured server admin (owner/admin) off — no configuration, no AI
2 The domain allows it admin, per domain on
3 The user has opted in the mailbox user off

Gate 3 is the one that matters: no mailbox’s contents reach a provider because somebody else switched a feature on. Gate 2 exists because a multi-domain server hosts tenants who do not all answer to the same policy, and it is a veto rather than an enabler — configuring a provider is already a deliberate act, and demanding a second switch per domain would make a working install look broken.

They are evaluated in that order, so GET /ai/availability names the gate to fix first rather than returning a bare false. It also returns canOptIn, which is false when no provider is configured or the domain has AI switched off: offering someone a consent switch that cannot take effect is worse than not offering one. A lapsed licence is reported too, as licence_lapsed right after not_configured (below); consent given meanwhile takes effect on renewal.

Behaviour.

  • Consent is recorded, not inferred. ai_mailbox_opt_in has no row until the user acts, and no row means not opted in. optedInAt records when, so it can be shown back and audited; withdrawing clears it, because there is no “opted in since” for someone who is not. Both directions are written to the audit log.
  • Nobody can consent on another’s behalf. PUT /ai/opt-in resolves the caller’s mailbox — the same resolution the webmail routes use, answering 403 no_mailbox when there is none — so there is no parameter an admin could point at someone else. An admin with a mailbox opts that mailbox in, and nothing else.
  • Attachments are excluded by default and only included where an admin has set allowAttachments for the domain. An attachment is far more likely than a message body to carry something the sender never meant to share with a third party.
  • Exclusion is enforced, not remembered. Features pass attachment text to AiService.run() as a separate argument rather than pre-merged into the prompt, and the service appends it only if the policy allows. A feature cannot include an attachment by forgetting to check. No shipped feature sends attachment text yet: AiRunInput.attachments has no caller — every inbox route passes the template’s request alone — so the rule is enforced over an empty input today. It exists so that the first feature that does send one cannot skip it.
  • One chokepoint. AiService.run() and runStream() are the only ways to reach a provider — provider() is private. The gates, the attachment rule and the call log all live there, so a feature added in six months cannot skip any of them by not knowing about them.
  • Every call is logged, and the log holds no content. ai_call_log has no column that could carry a message: provider, model, feature, prompt version, outcome, error kind, token counts, latency, and a truncated SHA-256 digest of everything that would have been sent. The digest correlates repeated calls and traces a recurring failure to a recurring prompt; the mail is not recoverable from it. Rows are built through auditRecord, which parses through a schema that drops anything not on that list. Each row also records the mailbox id and address it was made for. Connection tests and the copilot’s capability probe send no mail and are not logged. The copilot’s ai_tool_call table does hold content: the arguments of each tool call, such as a recipient searched for. Both are pruned, with the copilot’s proposals, after AI_LOG_RETENTION_DAYS (.env, default 30) by the worker’s ai-log-prune job, hourly (AI_LOG_PRUNE_INTERVAL_MINUTES, default 60, not passed by compose/compose.yaml).
  • Failures and cancellations are logged too. An unrecorded call is the thing to avoid, so a provider error is written with its errorKind and a user’s cancel is written as cancelled.
  • The admin console is not covered by gates 2 and 3. A copilot call reads deliverability and licence telemetry, not anyone’s mailbox, so a per-user consent has nothing to say about it; the route’s role check is the control. Such calls are logged with a null mailbox.

The licence. From the licence’s degraded stage on, AI is off for the whole server, the admin copilot included: GET /ai/availability answers licence_lapsed right after not_configured, and every call is refused 402 license_degraded before anything is sent. Like domain_disabled, it is not the reader’s to fix: the webmail says nothing in the AI panel, and its licence notice says why.

The daily limit. Separately from the gates, each mailbox may make a set number of AI requests per day — every summary, reply suggestion, draft or explanation is one, because each is billed by the provider. The server-wide default is 200 until an administrator changes it, and blank switches it off (Providers & limits → Limits); it is kept when the provider is cleared or replaced. A domain can have its own number, on the domain’s page under AI, and a mailbox its own on its page, where its use today and over the last 30 days is shown; a mailbox’s beats its domain’s, and 0 means no AI requests at all — reported as its own gate, admin_disabled (a 403, which the webmail does not explain to the reader, as for domain_disabled), because it does not come back at midnight. The day is UTC, and the count is the mailbox’s rows in the call log since midnight, whatever their outcome, since a failed call reached the provider too. Past the limit, GET /ai/availability answers daily_limit with dailyLimit: { limit, used, resetsAt }, the webmail says when requests come back, and a call is refused with 429 ai_daily_limit before anything is sent. The admin copilot is not limited: it is the administrators’ own tool and reads telemetry, not mail.

Configuration. ai_domain_policy and ai_mailbox_opt_in (migration 0017); the daily limit in ai_settings (one row per server, apart from the provider’s so that turning AI off and on keeps it) and ai_limit_override (migration 0042). Neither needs a row to behave correctly — the defaults in the table above are what an absent row means.

API.

GET/PUT /ai/domains/:id/policy Admin. enabled, allowAttachments.
GET/PUT /ai/opt-in The caller’s own consent. Any authenticated mailbox.
GET /ai/availability What the webmail client asks before rendering anything AI: available, the closed gate, whether attachments are permitted, whether the user could opt in.
GET /ai/log Admin. The call log, newest first, paginated.
GET/PUT /ai/limit Admin. perUser: the daily limit per mailbox, null for none. Kept apart from the provider, so it can be set with none configured.
GET/PUT/DELETE /ai/domains/:id/limit, /ai/mailboxes/:id/limit Admin. { dailyLimit } sets an override, DELETE removes it; each answers { override, effective, source }.
GET /ai/mailboxes/:id/usage Admin. Requests today and over the last 30 days, with the limit that applies.

Limits & edge cases.

  • The licence gate is the server’s, not a gate the user can open. From degraded every call is refused (The licence, above); the connection test on the AI settings page is not a call on anyone’s behalf and is not refused by it, only, from restricted, as an administrator’s write (Licensing).
  • What each feature sends, beyond the mail itself: the thread rendering carries each message’s From, To, Date and Subject; reply suggestions and every Draft also send the mailbox’s display name, a new message included, so the draft can sign off.
  • Every AI feature goes through this. The chokepoint, the gates and the log are built before the things they govern, deliberately — retrofitting enforcement after the first feature means the first feature shapes it. apps/api/src/ai.test.ts exercises run() and runStream() directly.
  • A withdrawn consent stops future calls, not past ones. Nothing is retained to delete: the log holds no content, and we never had a copy of the mail.
  • What the provider does with the request is theirs. These controls govern what leaves the server. A customer who needs a guarantee about retention or training either reads their provider’s terms or runs Ollama locally, where nothing leaves at all.
  • Two calls racing at the limit can both pass. The count is read, not locked, so the limit can be overshot by a request or two under concurrency; a lock on every AI call would cost more than it saves.
  • The domain policy is per domain, not per mailbox. A single mailbox that must never be processed is served by that user not opting in; there is no admin-side per-mailbox block.
  • Attachment text, not bytes. Only extracted text is ever sent, and only when allowed. Nothing extracts it yet — the inbox features shipped without attachments — so today allowAttachments changes nothing that is sent.
  • The prompt digest covers what would have been sent, including the attachment text when the policy allows it — so two otherwise identical calls that differ in attachments are distinguishable in the log.