From cd7c1a6d4c386675f385aeb5d9cb7fdeaa2faeb5 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Fri, 18 Sep 2026 13:18:39 -0700 Subject: [PATCH] Feature specs 4-9: branding and templates, AI spam classification, monitoring, SCIM, scale-out storage, per-domain directories --- docs/spec/SPEC.md | 19 +- docs/spec/contract.md | 4 +- docs/spec/features/ai-spam-classification.md | 462 ++++++++++ docs/spec/features/branding-and-templates.md | 380 +++++++++ docs/spec/features/monitoring.md | 510 +++++++++++ docs/spec/features/multi-tenancy.md | 4 +- docs/spec/features/per-domain-directories.md | 536 ++++++++++++ docs/spec/features/scale-out-storage.md | 523 ++++++++++++ docs/spec/features/scim.md | 841 +++++++++++++++++++ 9 files changed, 3270 insertions(+), 9 deletions(-) create mode 100644 docs/spec/features/ai-spam-classification.md create mode 100644 docs/spec/features/branding-and-templates.md create mode 100644 docs/spec/features/monitoring.md create mode 100644 docs/spec/features/per-domain-directories.md create mode 100644 docs/spec/features/scale-out-storage.md create mode 100644 docs/spec/features/scim.md diff --git a/docs/spec/SPEC.md b/docs/spec/SPEC.md index d37a997..5926766 100644 --- a/docs/spec/SPEC.md +++ b/docs/spec/SPEC.md @@ -171,6 +171,10 @@ each sync's conflicts few and predictable. The `enterprise` Cargo feature and every edition check are removed. There is one edition. +One exception: scale-out storage lives in new AGPL files inside +`crates/store` (`backend/scaleout/`), because the rebuilt-features crate +depends on `store` and can't be called from it (`features/scale-out-storage.md`). + ### 2.4 Name and marks - No "Stalwart" in product names, binaries, images, UI text, packaging or @@ -272,13 +276,14 @@ is written. | # | Feature | What an operator gets | Notes | |---|---|---|---| | 1 | Multi-tenancy | Tenants with their own domains, admins, quotas and queue visibility | Needed for anybody hosting mail for others. ihasmail already has a Tenants screen. Spec'd; hand-off brief in `handoff/multi-tenancy.md`. | -| 2 | Masked email | Per-sender disposable addresses that deliver to the account | Existing addresses must keep delivering (§3.4). | -| 3 | Undelete | Deleted mail held for a set period and restorable | Existing archived items must stay restorable. | -| 4 | Branding and templates | Operator logo, and the text of calendar alarm and invitation emails | INBUXA's branding is the default. | -| 5 | AI spam classification | An optional model's opinion as one spam signal, and a Sieve function that asks a model | Local and auditable model only: no hosted API by default. | -| 6 | Monitoring history, live tracing, alerts | Stored metrics and traces, a live trace view, and threshold alerts | ihasmail's dashboard shows them. | -| 7 | SCIM 2.0 provisioning | Accounts and groups managed by an identity provider | From RFC 7643 and RFC 7644. The largest piece. | -| 8 | Scale-out storage | SQL read replicas; sharded blob and in-memory stores | For large installs only. | +| 2 | Masked email | Per-sender disposable addresses that deliver to the account | Existing addresses must keep delivering (§3.4). Spec: `features/masked-email.md`. | +| 3 | Undelete | Deleted mail held for a set period and restorable | Existing archived items must stay restorable. Spec: `features/undelete.md`. | +| 4 | Branding and templates | Operator logo, and the text of calendar alarm and invitation emails | INBUXA's branding is the default. Spec: `features/branding-and-templates.md`. | +| 5 | AI spam classification | An optional model's opinion as one spam signal, and a Sieve function that asks a model | Local and auditable model only: no hosted API by default. Spec: `features/ai-spam-classification.md`. | +| 6 | Monitoring history, live tracing, alerts | Stored metrics and traces, a live trace view, and threshold alerts | ihasmail's dashboard shows them. Spec: `features/monitoring.md`. | +| 7 | SCIM 2.0 provisioning | Accounts and groups managed by an identity provider | From RFC 7643 and RFC 7644. The largest piece. Spec: `features/scim.md`. | +| 8 | Scale-out storage | SQL read replicas; sharded blob and in-memory stores | For large installs only. Spec: `features/scale-out-storage.md`. | +| 9 | Per-domain directories | A domain signs in against its own LDAP, SQL or OIDC directory | Added 2026-09-18. Signing in through an OIDC provider as the server's directory is already AGPL; only the per-domain choice is Enterprise. Spec: `features/per-domain-directories.md`. | | — | Seat limits, license keys | Nothing: there's no license | Removed, not rebuilt. | ## 5. The web front ends diff --git a/docs/spec/contract.md b/docs/spec/contract.md index e9c1f19..7fd6aac 100644 --- a/docs/spec/contract.md +++ b/docs/spec/contract.md @@ -199,7 +199,9 @@ Each has an ID, and tests name the IDs they check. that token, even though the account could. - **C-19.** ihasmail-inbuxa's own administration (accounts, domains, groups, lists, roles, tenants, the dashboard) uses the scope `inbuxa:account-admin`, - granted only to `ihasmail-inbuxa`, and limited to those object types. + granted only to `ihasmail-inbuxa`, and limited to those object types, plus + `x:Metric` get and query for the dashboard's message cards (monitoring + spec). - **C-20.** Optionally, `x:FrontEnds.adminNetworks` (a list of CIDRs) limits where `inbuxa:admin` requests may come from. Empty means anywhere. - **C-21.** Basic auth never reaches the admin lane outside recovery mode. An diff --git a/docs/spec/features/ai-spam-classification.md b/docs/spec/features/ai-spam-classification.md new file mode 100644 index 0000000..7b0e37f --- /dev/null +++ b/docs/spec/features/ai-spam-classification.md @@ -0,0 +1,462 @@ +# Feature spec: AI spam classification and the LLM Sieve function + +Status: draft, 2026-09-18. Feature 5 in SPEC.md §4. + +## Provenance + +Written for the clean room (SPEC.md §3). Sources, and nothing else: + +| Source | License | Used for | +|---|---|---| +| Stalwart's registry schema: `x:AiModel`, `x:SpamLlm` and `x:SpamLlmProperties`, `x:HttpAuth`, `x:SecretKey`, `x:SpamTag`, the `AiModelType` enum, the `interactAi`, `sysAiModel*` and `sysSpamLlm*` permissions, the `ai.*` events, in `resources/schema/schema.json.gz` and `crates/registry/src/schema/*.rs` at `v0.16.22` | AGPL-3.0-only OR LicenseRef-SEL, taken under the AGPL | The stored records, field meanings, defaults, permissions, events, what upstream flags as Enterprise | +| This repository's AGPL code: `crates/spam-filter` (the scoring order, `llm_result`, the `X-Spam-LLM` header line), `crates/common/src/scripts/plugins` (the `llm_prompt` registration), `crates/common/src/auth/permissions.rs` (default roles), `crates/trc` (event ids) | AGPL-3.0-only OR LicenseRef-SEL, taken under the AGPL | Where the feature hooks in, what already exists, default permissions | +| This repository's shared tests: `tests/src/smtp/inbound/antispam.rs`, `tests/resources/smtp/antispam/llm.test`, `tests/resources/jmap/sieve/test_mailbox.sieve` | AGPL-3.0-only OR LicenseRef-SEL, taken under the AGPL | Expected tags, the mock endpoint's shape, the Sieve call's shape | +| Stalwart documentation (`stalwartlabs/website`): "AI Models", "LLM classifier" (spam filter), "LLM Integration" (Sieve), the `AiModel` and `SpamLlm` object references, "Scores", "Permissions", current and 0.15 versions, and the 2024-10-07 announcement post | Unlicensed public documentation: facts used, prose not copied | Behavior of the classifier, tag names, default scores, the Sieve function's signature and failure result, trusted and untrusted scripts | +| RFC 5321 §4.5.3.2.6, RFC 5322, RFC 2047, RFC 1918, RFC 4193, RFC 8620 | IETF | SMTP time limits, header syntax and encoding, private address ranges, JMAP semantics | +| The OpenAI-compatible chat and text completions request and response shape, as published and as implemented by local servers (llama.cpp's server, Ollama, vLLM, LocalAI) | Public API conventions | The wire format | + +No Enterprise-only file or snippet was used. The author is a fresh session that +has never seen Enterprise code, and writes specs only. Nothing here was +observed against a running Enterprise server yet. Where no public source +settles a behavior, this spec makes a decision of its own, marked +**Decision**, or lists it under "Open questions / to observe". It never fills a +gap by guessing what upstream code does. + +## What it is + +Two uses of a language model the operator runs: + +1. **Spam classification.** The spam filter sends a message's subject and + text to a model with the operator's prompt. The model answers with a + category and a confidence, for example `Unsolicited,High`. That becomes a + tag, such as `LLM_UNSOLICITED_HIGH`, and the tag's score is one more input + to the message's spam score. The model's opinion is one signal among many. + It never decides a message's fate alone. +2. **The Sieve function `llm_prompt`.** A Sieve script sends a prompt to a + named model and gets the answer back as a string, for example to file mail + by topic. + +Both are off until the operator sets them up. inbuxa-server ships no model +and no endpoint. + +**Project policy, which this spec enforces.** AI in INBUXA products must bring +real value and use a local model the operator can audit. There is no hosted +API by default. An operator may point a model at a hosted OpenAI-compatible +endpoint, but nothing is ever preset to one, and no message content leaves +the server unless the operator configured the endpoint it goes to. Docs and UI +text describe the feature as it is, including that it is AI. They never claim +the product has no AI. + +Upstream ships both only in its Enterprise Edition. inbuxa-server ships them to +everybody. The fork left a signpost: `crates/common/src/scripts/plugins/llm_prompt.rs` +registers `llm_prompt` but always returns `false` (`inbuxa:` comment). + +**Non-goals.** The model doesn't train the statistical classifier, move or +delete mail on its own, reply to mail, or see attachments. It isn't a +replacement for the existing filter. + +## Data model + +Unchanged from upstream, so existing settings open as they are (SPEC.md §7). +Both objects are server-level. They have no `memberTenantId`. + +### A model endpoint, `x:AiModel` (many) + +| Field | Type, default | Meaning | +|---|---|---| +| `name` | string, required | Short name. Sieve scripts name the model by it (AI-20) | +| `url` | URI, required | The OpenAI-compatible endpoint, the full path included, e.g. `…/v1/chat/completions` | +| `model` | string, required | The model name sent in each request | +| `modelType` | `AiModelType`, `Chat` | `Chat` (chat completions) or `Text` (text completions) | +| `temperature` | float 0.0–1.0, `0.7` | Default sampling temperature | +| `timeout` | duration, `120000` ms | How long to wait for a response | +| `allowInvalidCerts` | boolean, `false` | Accept an invalid TLS certificate | +| `httpAuth` | `x:HttpAuth` | `Unauthenticated`, `Basic` (`username`, `secret`) or `Bearer` (`bearerToken`). Secrets are `x:SecretKey`: a `Value`, an `EnvironmentVariable` or a `File` | +| `httpHeaders` | map string → string | Extra request headers | + +Permissions: `sysAiModelGet`, `sysAiModelQuery`, `sysAiModelCreate`, +`sysAiModelUpdate`, `sysAiModelDestroy`. The list view shows `model` and +`modelType`, labelled by `name`. + +### The classifier, `x:SpamLlm` (singleton) + +Two variants by `@type`: `Disable` (the default) and `Enable`, which carries +`x:SpamLlmProperties`: + +| Field | Type, default | Meaning | +|---|---|---| +| `modelId` | id of `x:AiModel`, required | The model to ask | +| `prompt` | text, required | The instructions sent with each message | +| `temperature` | float 0.0–1.0, `0.5` | Temperature for classification, overriding the model's | +| `separator` | string, `,` | Splits the answer into fields | +| `responsePosCategory` | unsigned, `0` | Zero-based position of the category | +| `responsePosConfidence` | unsigned or null, `1` | Position of the confidence. Null: the answer has none | +| `responsePosExplanation` | unsigned or null, `2` | Position of the explanation. Null: none | +| `categories` | set of strings, at least 2, `Commercial`, `Harmful`, `Legitimate`, `Unsolicited` | Accepted categories | +| `confidence` | set of strings, `High`, `Low`, `Medium` | Accepted confidence levels | + +Permissions: `sysSpamLlmGet`, `sysSpamLlmUpdate`. + +### Elsewhere + +- **Tags and scores.** The classifier's tags are ordinary spam tags, scored + by `x:SpamTag` entries like every other tag: `Score` (a number), `Discard` + or `Reject`. The documented defaults are `LLM_UNSOLICITED_HIGH` 3.0 and + `LLM_LEGITIMATE_HIGH` −3.0. A tag with no entry scores 0. +- **`interactAi`** permission ("Interact with AI models"): lets an account's + own Sieve scripts call `llm_prompt`. This repository's default roles give it + to users, tenant administrators and superusers + (`crates/common/src/auth/permissions.rs`). The public permissions table + lists it for administrators only, under the older name + `ai-model-interact`. See open question 5. +- **Events:** `ai.llm-response` (id 556, a response arrived) and + `ai.api-error` (id 557, a request failed). +- **Header:** `X-Spam-LLM`, written by the existing AGPL scoring code from the + spam result's `llm_result` (a category string and an explanation) as + `X-Spam-LLM: {category} ({explanation})`. + +### Added by inbuxa-server + +A server-level singleton for the fork's limits, in the fork's own namespace +(name open, SPEC.md §8), so upstream's records stay exactly as upstream wrote +them. **Decision**: these are new, and every default below is this spec's +own. + +| Field | Default | Meaning | +|---|---|---| +| `spamMaxAdded` | `5.0` | Most an LLM tag can add to a message's score (AI-12) | +| `spamMaxSubtracted` | `1.0` | Most an LLM tag can take off a message's score (AI-12) | +| `spamCallCeiling` | `20s` | Longest the spam filter waits for the model, whatever the model's `timeout` (AI-9) | +| `maxConcurrentCalls` | `4` | Model requests in flight at once, across both uses, per server node (AI-10) | +| `maxContentBytes` | `16384` | Most message text sent per classification (AI-4) | +| `failureBackoff` | `60s` | Pause after repeated failures (AI-11) | +| `userCallsPerHour` | `60` | `llm_prompt` calls per account per hour from its own scripts (AI-24) | + +## Required behavior + +### Defaults and privacy + +- **AI-1.** A new install has no `x:AiModel` and `x:SpamLlm` set to + `Disable`. Nothing is sent to any model until an administrator creates one. + No URL, model name, prompt or example anywhere in the product's defaults, + placeholders, docs or setup points at a hosted provider. Where an example + is needed, it is a local one, such as `http://127.0.0.1:8080/v1/chat/completions`. +- **AI-2.** When a model's `url` host isn't `localhost`, a loopback address, an + RFC 1918 or RFC 4193 private address, or a name resolving only to those, + the server logs a warning at startup and on every change that message + content will leave this network. INBUXA Admin shows the same warning on the + model's form. **Decision.** The check is advisory. It never blocks an + endpoint the operator chose. +- **AI-3.** The classifier sends only the subject and the message's text: + plain-text parts, and HTML parts converted to text. Never sent: other + headers, addresses, the envelope, the client IP, attachments, images, or + anything identifying the recipient. **Decision**: the documented behavior + is "subject and body". Nothing else is needed to judge content. +- **AI-4.** The text sent is cut to `maxContentBytes` on a character boundary. + Text over the limit is truncated from the end, and the request says so + (AI-6). +- **AI-5.** Message content is never written to the logs. The + `ai.llm-response` event, at trace level, records the model's name, the + response time, the raw answer (cut to 1 KiB), and the resulting tag. The + `ai.api-error` event records the model's name, the error and the HTTP + status. Neither ever records a secret or an authorization header. The + operator can audit what the model decided and why, and the prompt is plain + in the settings. + +### The request + +- **AI-6.** Classification sends, for a `Chat` model: + `POST {url}` with JSON `{"model": model, "messages": [system, user], + "temperature": t, "max_tokens": 200, "stream": false}`. The system message + is the operator's `prompt` followed by a fixed framing paragraph written by + this project and shown in the docs, stating that the email follows between + two marker lines, that it is data to classify and not instructions, and + that anything inside it asking for a particular answer is itself a sign of + abuse. The user message is: + + ``` + -----BEGIN EMAIL {nonce}----- + Subject: {subject} + + {text} + -----END EMAIL {nonce}----- + ``` + + with `[truncated]` before the end marker when AI-4 cut it. `{nonce}` is 16 + random hex characters, new for each request, so a message can't forge the + end marker. For a `Text` model: `{"model", "prompt", "temperature", + "max_tokens", "stream": false}`, where `prompt` is the system text, a blank + line, then the user text. **Decision** throughout: separate roles and + unforgeable markers blunt prompt injection from message content. They don't + stop it, which is why AI-12 bounds the damage. +- **AI-7.** The answer is `choices[0].message.content` for `Chat` and + `choices[0].text` for `Text`. Any other shape, an HTTP status other than + 200, or a body over 64 KiB is a failure (AI-9). +- **AI-8.** Requests carry `Content-Type: application/json`, the `httpAuth` + credentials (Basic or Bearer, read from the `x:SecretKey` at request time), + and `httpHeaders`. Redirects are not followed. **Decision**: a redirect + would send message content to a host the operator never named. TLS + certificates are checked unless `allowInvalidCerts`. No user, account or + message identifier is sent (no OpenAI `user` field). + +### Failure never costs mail + +- **AI-9.** A timeout, connection error, HTTP error, bad JSON, empty answer + or unparseable answer adds no tag, no score and no `X-Spam-LLM` header. The + message goes on through the filter as if the classifier were off, and + `ai.api-error` is logged. The classifier waits no longer than the shorter of + the model's `timeout` and `spamCallCeiling`. **Decision**: upstream's + default model timeout is two minutes, which is too long to hold an SMTP + transaction (RFC 5321 §4.5.3.2.6 gives the client 10 minutes for the reply + to the end of data, and sending servers commonly give up sooner). +- **AI-10.** At most `maxConcurrentCalls` requests are in flight. A message + that finds no free slot isn't queued: it is scored without the model, as in + AI-9. The model's slowness can never back up inbound mail. +- **AI-11.** After 5 consecutive failures to a model, the server stops calling + it for `failureBackoff`, then tries again with one request. While paused, + messages are scored as in AI-9. The pause and the resume are each logged + once. **Decision.** + +### From the answer to a tag + +- **AI-12.** Parsing the answer: + 1. Trim it and take the first non-empty line. + 2. Split it by `separator`, taken as the whole string. An empty separator + is refused at `/set` with `invalidProperties`. + 3. Take the fields at `responsePosCategory` and, when not null, + `responsePosConfidence`. Trim each of spaces and the characters + `" ' * .`. Match each case-insensitively against `categories` and + `confidence`. A field that's missing or matches nothing means no tag. + 4. The tag is `LLM_` + category, or `LLM_` + category + `_` + confidence + when there is a confidence, using the configured spelling, uppercased, + with every character outside `A-Z` and `0-9` replaced by `_`. So + `Unsolicited` and `high` give `LLM_UNSOLICITED_HIGH`. + 5. When `responsePosExplanation` isn't null, the explanation is the field + at that position. When it's the last position used, it runs to the end of + the line, separators included, since explanations contain commas. + + **Decision** on steps 1, 3 and 5. The documented rule is that answers + outside the configured sets are ignored. +- **AI-13.** One classification gives at most one tag. Its score comes from + `x:SpamTag` as for any tag, then is clamped to `[−spamMaxSubtracted, + +spamMaxAdded]`. A `Discard` or `Reject` entry on a tag starting `LLM_` + counts as no entry (score 0) and logs a warning at load. **Decision**: the + model reads attacker-written text, so its word alone must never refuse or + destroy mail, and it can pull a score down only a little, because "this is + legitimate" is exactly the answer an injected message asks for. The + operator's stored `x:SpamTag` records are left as they are. +- **AI-14.** The classifier's tag and score appear in `X-Spam-Result` like any + other. The model's output never trains the statistical classifier, never + counts toward auto-learn, and plays no part when a user reports mail as spam + or not spam. +- **AI-15.** When a tag was assigned, the message gets one `X-Spam-LLM` + header: `X-Spam-LLM: {TAG} ({explanation})`, or `X-Spam-LLM: {TAG}` with no + explanation. The explanation is cut to 200 characters. Control characters, + CR and LF included, and parentheses are removed. Non-ASCII text is encoded + as RFC 2047 encoded words, and the header is folded to RFC 5322's line + limits. **Decision** on the format, see open question 3. Any `X-Spam-LLM` + header already in an inbound message is removed first, so a sender can't + plant one. + +### When it runs + +- **AI-16.** With `x:SpamLlm` set to `Enable`, the classifier runs on every + message that goes through the spam filter, except: + - mail from an authenticated sender (local users' own mail isn't sent to a + model). **Decision**; + - when the filter has already reached a `Discard` or `Reject` result from + another tag, where the answer can't change anything. **Decision**. +- **AI-17.** It runs after every other analysis step and before user-defined + rules (`x:SpamRule`) and the final score, so rules can test the `LLM_` tags. + **Decision** on the position. +- **AI-18.** A `modelId` pointing at no model is refused at `/set`. Destroying + a model that `x:SpamLlm` names is refused. **Decision**, see open question + 6. A settings change takes effect without a restart. +- **AI-19.** Reloading or changing settings never drops mail in flight. A + classification already under way finishes, or fails as in AI-9. + +### The Sieve function `llm_prompt` + +- **AI-20.** `llm_prompt(model, prompt, temperature)`, available with + `require "vnd.stalwart.expressions"`. `model` names an `x:AiModel` by its + `name`, or failing that by its id. `prompt` is sent as is. `temperature` is + clamped to 0.0–1.0. A value that isn't a number uses the model's own + `temperature`. **Decision** on name first, see open question 4. +- **AI-21.** For a `Chat` model the request is one user message holding the + prompt. For `Text`, the prompt itself. `max_tokens` is 1,000 and + `stream: false`. The same rules as AI-7 and AI-8 apply. The script builds + its own prompt, so no framing is added. +- **AI-22.** It returns the answer, trimmed and cut to 8 KiB, as a string. + On any failure (unknown model, no permission, rate limit, timeout, error) it + returns `false`, as documented, and logs `ai.api-error`. The script carries + on. The answer is always plain data. It's never evaluated as an expression + or as Sieve. +- **AI-23.** Trusted scripts (the system scripts run at SMTP stages) can + always call it. An account's own scripts can only when the account holds + `interactAi`. The prompt is cut to 32 KiB. The wait is the shorter of the + model's `timeout` and `spamCallCeiling` in trusted scripts. In an account's + own scripts it is the model's `timeout`, capped at 60 s. **Decision**. +- **AI-24.** An account's own scripts may make `userCallsPerHour` calls an + hour and one at a time. Calls over the limit return `false` at once. Calls + share the `maxConcurrentCalls` slots and the back-off in AI-10 and AI-11. + **Decision**: without this, any user could keep a CPU-only model busy for + everyone. +- **AI-25.** Every account-script call logs the account, the model's name and + the response time, never the prompt, so an operator can see who uses the + model and how much. + +### Administration + +- **AI-26.** `x:AiModel` and `x:SpamLlm` are available on every server, + answering normally, with no edition check. The Enterprise upsell error in + `crates/jmap/src/registry/mod.rs` no longer applies to them. +- **AI-27.** They're server-level. A tenant administrator can't read or change + them, whatever its role, since a model's URL and secrets are server + configuration. Tenant users' scripts can still call models they know the + name of, subject to AI-23 and AI-24. +- **AI-28.** Secrets in `httpAuth` are never returned by `/get`, as for every + other `x:SecretKey` field. + +## Interfaces + +- **Existing, unchanged:** `x:AiModel/get`, `/query`, `/set`; `x:SpamLlm/get` + and `/set` (singleton); the field names, enums and defaults above; the + permission names; the `ai.llm-response` and `ai.api-error` events; the + `LLM_*` tag names; the `X-Spam-LLM` header name; the Sieve function name, + arity and `false`-on-failure result. +- **Errors:** RFC 8620 `SetError` types. `invalidProperties` names the field + (an empty `separator`, a `categories` set under 2, an out-of-range + `temperature`, a `modelId` that doesn't exist). Destroying a model in use is + refused with the registry's existing error for a linked object, naming + `x:SpamLlm`. +- **New:** the limits singleton, in the fork's namespace. +- **The model's wire format** is AI-6 to AI-8 and AI-21. Any server that speaks + the OpenAI-compatible chat or text completions API works. The docs name + local servers first. + +## ihasmail changes + +These go in the INBUXA fork of ihasmail, ihasmail-inbuxa, never in public +ihasmail, which stays Stalwart-facing (SPEC.md §5). + +- **Reading:** when a message has an `X-Spam-LLM` header, the message details + (and the Junk banner, when the message is in Junk) show "Language model's + opinion" with the tag's category and confidence and the explanation. It's + labelled as one signal among several, never as the reason on its own. +- **Administration:** nothing new. Model and classifier settings are server + configuration, and live in INBUXA Admin (SPEC.md §5.4). ihasmail's dashboard + may link there. +- **Translation:** 2 new strings ("Language model's opinion" and "One of + several signals the spam filter weighed"), each needed in all nine language + catalogues: 18 entries. Categories and explanations come from the server + and aren't translated. + +**INBUXA Admin** (schema-driven, so it picks up both objects with no work) +needs only: the locality warning on the model form (AI-2); a local example +URL as the `url` placeholder; the fork's default prompt prefilled when the +classifier is switched to `Enable` (see below); and a note on the classifier +page that failures never hold up mail. + +**Default prompt.** Upstream's documented prompt isn't copied. The fork's +default, prefilled only when an administrator enables the classifier, is this +project's own: + +> Classify the email below as one of: Unsolicited, Commercial, Harmful, +> Legitimate. Unsolicited: bulk mail the recipient didn't ask for. Commercial: +> selling something. Harmful: phishing, fraud or malware. Legitimate: anything +> else. Then give your confidence: High, Medium or Low. Answer on one line as +> Category,Confidence,Reason with a reason of at most 20 words. + +## Acceptance tests + +Every test runs against inbuxa-server built with no Enterprise code. None +needs a hosted model. Each uses a local stub endpoint on loopback, spawned by +the test (the suite already has `spawn_mock_http_server`), which records what +it received and answers as each test needs. The gated `llm` case in +`tests/src/smtp/inbound/antispam.rs` is re-enabled, with its mock updated to +read the last message rather than the first (AI-6). + +1. Fresh install: no `x:AiModel`, `x:SpamLlm` is `Disable`, and the stub + receives nothing while mail flows (AI-1). +2. The twelve cases in `llm.test`: stub answers `Unsolicited,High,Test` and + the rest give `LLM_UNSOLICITED_HIGH` and so on (AI-12). +3. Stub answers `unsolicited , HIGH , Lots of commas, here` gives + `LLM_UNSOLICITED_HIGH`, and the header's explanation is + `Lots of commas, here` (AI-12, AI-15). +4. Stub answers `Maybe,High,x`, an empty body, and text with no separator: no + tag, no header, message delivered (AI-9, AI-12). +5. `responsePosConfidence: null` gives `LLM_UNSOLICITED` (AI-12). +6. The request the stub receives: system message is the prompt plus framing; + user message carries the subject, the text, and markers with a fresh nonce + each time; no addresses, other headers or attachment content anywhere + (AI-3, AI-6, AI-8). +7. A 100 KiB body: the stub receives at most `maxContentBytes` of text and + `[truncated]` (AI-4). +8. Stub never answers: the message is delivered within `spamCallCeiling` + plus normal processing time, with no tag (AI-9). +9. Stub down for 5 messages: the next messages are scored without calling it + until `failureBackoff` ends, then one probe request (AI-11). +10. `maxConcurrentCalls` 1, stub slow, two messages at once: one is + classified, the other is delivered without a tag (AI-10). +11. `LLM_UNSOLICITED_HIGH` scored 50: the message's score rises by 5.0. + `LLM_LEGITIMATE_HIGH` scored −50: it falls by 1.0. + `LLM_HARMFUL_HIGH` set to `Reject`: the message isn't rejected (AI-13). +12. Stub's explanation contains CRLF, a fake header and non-ASCII text: one + well-formed `X-Spam-LLM` header, RFC 2047-encoded. An inbound message + carrying its own `X-Spam-LLM` loses it (AI-15). +13. An authenticated submission: the stub receives nothing (AI-16). +14. A `SpamRule` testing `LLM_HARMFUL_HIGH` fires (AI-17). +15. Stub replies with a 302 to another loopback port: not followed, counted + as a failure (AI-8). +16. Bearer and Basic auth reach the stub, read from `Value`, + `EnvironmentVariable` and `File` secrets. `/get` doesn't return them. The + logs don't contain them (AI-5, AI-8, AI-28). +17. `llm_prompt('echo-test', 'hello world', 0.5)` in `test_mailbox.sieve`, + with an `x:AiModel` named `echo-test` pointing at an echoing stub, returns + `hello world` (AI-20 to AI-22). +18. `llm_prompt` with an unknown model, a failing stub, and from an account + without `interactAi`: each returns `false` and delivery continues (AI-22, + AI-23). +19. The 61st call in an hour from one account's script returns `false` + without reaching the stub (AI-24). +20. A tenant administrator gets `forbidden` on `x:AiModel/get` and + `x:SpamLlm/set` (AI-27). +21. A model on `https://mail.example.net/…` logs the locality warning. One on + `127.0.0.1` or `10.0.0.5` doesn't (AI-2). +22. **(compat)** INBUXA's `x:AiModel` and `x:SpamLlm` records, if any, and + its `LLM_*` `x:SpamTag` entries read back unchanged after cutover. + +## Open questions / to observe + +To check read-only against INBUXA's live Enterprise server later, as the +throwaway account and an administrator, with no settings changed: + +1. Whether INBUXA has any `x:AiModel` (`/query`) or `x:SpamLlm` set to + `Enable`, and where any model points. If none, compat test 22 has nothing + to carry. +2. The `LLM_*` entries in `x:SpamTag/query`, and their scores. They show + upstream's defaults for the ten tags not documented. +3. The exact `X-Spam-LLM` format upstream writes, from any message in the + mail store that carries one. Only possible if INBUXA ever ran the + classifier. AI-15 is a **Decision** until then. +4. Whether `llm_prompt` names a model by `name` or by id. The shared Sieve + test uses `echo-test`, which reads like a name. Where upstream's + `echo-test` model comes from in its tests isn't settled by any allowed + source, so test 17 creates it. +5. Whether ordinary accounts hold `interactAi`, from an account's effective + permissions. This repository's defaults say yes; the public table says + administrators only. +6. What upstream does when a model that `x:SpamLlm` uses is destroyed (only + observable by changing settings, so it needs the operator's approval and a + stub model). AI-18 is a **Decision** meanwhile. +7. Whether upstream classifies authenticated submissions, and what it does on + a timeout (again, needs a temporary stub model and the operator's + approval). + +Not for observation, but open: + +8. A per-account or per-domain opt-out from classification, for users who + don't want their mail read by a model even locally. Not in upstream's + schema. A later addition in the fork's namespace if asked for. +9. A "test this model" action for INBUXA Admin. Useful, not required. +10. The name of the fork's limits singleton, with the namespace (SPEC.md §8). diff --git a/docs/spec/features/branding-and-templates.md b/docs/spec/features/branding-and-templates.md new file mode 100644 index 0000000..60ed572 --- /dev/null +++ b/docs/spec/features/branding-and-templates.md @@ -0,0 +1,380 @@ +# Feature spec: branding and templates + +Status: draft, 2026-09-18. Feature 4 in SPEC.md §4. + +## Provenance + +Written for the clean room (SPEC.md §3). Sources, and nothing else: + +| Source | License | Used for | +|---|---|---| +| Stalwart's registry schema: `crates/registry/src/schema/*.rs` and `resources/schema/schema.json.gz`, as imported into this repository (v0.16.22) | AGPL-3.0-only OR LicenseRef-SEL, taken under the AGPL | The five flagged fields, their types, descriptions and defaults, the related settings, permission prefixes | +| This repository's shared code: the template syntax (`crates/utils/src/template.rs`), the variable names (`crates/common/src/config/groupware.rs`), the alarm and iMIP senders (`crates/services/src/task_manager/alarm.rs`, `imip.rs`), the RSVP API types (`crates/groupware/src/calendar/itip.rs`), the HTTP routes (`crates/http/src/request.rs`), the logo cache and its invalidation (`crates/common/src/lib.rs`, `cache/invalidate.rs`), and the default pages and templates (`resources/html-templates/`, `resources/branding/`) | AGPL-3.0-only OR LicenseRef-SEL, with Enterprise-only parts already stripped; the pages and logo are the fork's own | What the built-in templates receive, how they're rendered and escaped, what the pages call, where hooks go | +| Stalwart documentation: "Branding" (`docs/management/webui/branding.md` and the 0.15 `webadmin/branding.md`), "Scheduling" (`docs/collaboration/scheduling.md`, sections HTTP RSVP and Branding and templating), the Enterprise and CalendarAlarm object references | Unlicensed public documentation: facts used, prose not copied | The logo order, hostname-based selection, the RSVP page contract, which templates exist | +| RFC 2397 (`data:` URLs), RFC 2392 (`cid:` URLs), RFC 5546 (iTIP), RFC 6047 (iMIP), RFC 8620 | IETF | Logo value forms, how the email logo is referenced, the messages the templates dress, `/set` errors | + +No Enterprise-only file or snippet was used. The author is a fresh session +that has never seen Enterprise code. No server was probed for this spec. Where +no public source settles a behavior, this spec makes a decision of its own, +marked **Decision**, or lists it under "Open questions / to observe". It never +fills a gap from memory of upstream code. + +## What it is + +An operator can put its own logo, and its own layout, on what the server +shows and sends to people: + +- **Logos.** One server-wide logo, one per tenant, one per domain. The most + specific one that applies is used on the server's sign-in page, the RSVP + page, calendar alarm and invitation emails, and in ihasmail. +- **Templates.** The HTML of calendar alarm emails, of iMIP invitation emails + (invitations, updates, cancellations and replies), and the whole HTTP RSVP + page. + +Upstream ships this only in its Enterprise Edition. inbuxa-server ships it to +everybody. INBUXA's own branding is the default: the fork already ships +rebranded built-in templates, an INBUXA email logo +(`resources/branding/email-logo.png`), and `INBUXA Calendar` as the alarm +sender name. + +Today, in the stripped tree, the five fields read and write normally but do +nothing: the built-in templates are always used, `logo_resource` always +answers none, and there's no `/logo` route. The login and RSVP pages already +ask `/logo` and fall back to their built-in logo when it fails. + +Tenant logos (`x:Tenant.logo`) and the per-principal logo over JMAP are in +multi-tenancy MT-22 and MT-23. This spec uses them and doesn't repeat them. + +## Data model + +Unchanged from upstream, so existing data opens as it is (SPEC.md §7). All +five flagged fields are nullable strings. Null means "use the built-in". + +| Object | Field | Schema type | Meaning | +|---|---|---|---| +| `x:Enterprise` (singleton) | `logoUrl` | `Uri?` | The server-wide default logo | +| `x:Domain` | `logo` | `String?` | The domain's logo: "URL or base64-encoded image" | +| `x:CalendarAlarm` (singleton) | `template` | `Html?` | Replaces the built-in alarm email | +| `x:CalendarScheduling` (singleton) | `emailTemplate` | `Html?` | Replaces the built-in iMIP email | +| `x:CalendarScheduling` (singleton) | `httpRsvpTemplate` | `Html?` | Replaces the built-in RSVP page. "Served verbatim", and responsible for calling `/api/calendar/rsvp` itself | + +Related, not flagged, and unchanged: + +- `x:Tenant.logo` (multi-tenancy spec). +- `x:CalendarAlarm`: `enable`, `fromName` (default `INBUXA Calendar` in the + fork), `fromEmail`, `allowExternalRcpts`, `minTriggerInterval`. +- `x:CalendarScheduling`: `enable`, `httpRsvpEnable` (default true), + `httpRsvpUrl`, `httpRsvpLinkExpiry` (default 90 days), `autoAddInvitations`, + `itipMaxSize`, `maxRecipients`. +- `x:Enterprise.licenseKey` and `apiKey`. There's no license (SPEC.md §4). + **Decision**: they stay readable and writable so existing data round-trips, + and are ignored. +- `x:OAuthClient.logo`, shown on the consent page (contract C-9). Not part of + this feature, but it follows BT-3 and BT-7 too. + +Permissions, all existing: `sysEnterpriseGet` and `sysEnterpriseUpdate`, +`sysCalendarAlarmGet` and `sysCalendarAlarmUpdate`, +`sysCalendarSchedulingGet` and `sysCalendarSchedulingUpdate`, and +`sysDomainUpdate` for a domain's logo. The names stay as they are, "Enterprise" +included: they're protocol identifiers (SPEC.md §2.4). + +## Required behavior + +Each requirement has an ID, and tests name the IDs they check. + +### Logos: which one applies + +- **BT-1.** Logos are resolved for a domain name. For a name D: + 1. the `logo` of the `x:Domain` whose `name` or `aliases` match D; + 2. else the `logo` of that domain's tenant (`memberTenantId`); + 3. else `x:Enterprise.logoUrl`; + 4. else the built-in INBUXA logo. + This is the order upstream documents. **Decision** on matching: exact match + first, then D with its leftmost label removed, repeated while at least two + labels remain, so `mail.example.com` finds `example.com`. Matching is + case-insensitive. +- **BT-2.** Where each surface gets its domain: + - the sign-in page and the RSVP page: the `domain` query parameter of + `/logo` (BT-5), else the request's `Host` without its port, as upstream + documents; + - alarm emails: the domain of the account's primary address; + - iMIP emails: the domain of the message's `From` address; + - ihasmail: the signed-in principal's domain (multi-tenancy MT-22). + **Decision**: MT-22's chain ends at "none". This spec extends it with steps + 3 and 4, so ihasmail and the server show the same logo. The multi-tenancy + spec should be updated to point here. +- **BT-3.** A logo value is one of: + - an `https:` URL; + - a `data:` URL (RFC 2397), base64, with type `image/png`, `image/jpeg`, + `image/gif`, `image/webp` or `image/svg+xml`. + **Decision** on writes: anything else is refused with `invalidProperties` + naming the field, and so is a data URL whose decoded image is over 256 KiB + or whose bytes don't match its declared type. `logoUrl` takes the same two + forms: a data URL is a valid URI. +- **BT-4.** Stored values that predate the fork are read as they are, never + rewritten, and never refused on read. **Decision** on odd ones: a bare + base64 string (the schema says "base64-encoded image") is treated as a data + URL whose type is sniffed from its first bytes, and an `http:` URL is used + like an `https:` one. A value that is none of these is skipped as if unset, + with a `registry.build-warning` event naming the object. + +### Logos: serving and embedding + +- **BT-5.** `GET /logo`, anonymous, rate-limited like the other anonymous + endpoints. It resolves a logo per BT-1 and BT-2 and answers: + - a data-URL logo: `200` with the decoded bytes and their type; + - a URL logo: `302` to that URL; + - no custom logo at any level: `404`, and the page draws its own. + Every answer carries `Cache-Control: public, max-age=300`, + `X-Content-Type-Options: nosniff`, and `Access-Control-Allow-Origin: *`. + **Decision** on the shape (see open question 3). +- **BT-6.** An unknown domain answers exactly as a known domain with no logo + of its own would: the server-wide logo or `404`. **Decision**: `/logo` isn't + a way to test which domains are hosted, beyond what a domain's own logo + shows. +- **BT-7.** The server never fetches a logo URL, for any purpose: not to serve + it, not to check it, not to embed it. Only browsers and mail clients fetch + URL logos, and ihasmail through its image proxy (MT-23). +- **BT-8.** An SVG served by `/logo` is sent with + `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox`. + **Decision**, a security requirement: a tenant administrator can set its + domain's logo, and `/logo` is on the server's origin, so a scripted SVG must + never run there. +- **BT-9.** Emails embed the logo as an inline MIME part inside the + `multipart/related` part, and the template reaches it through `{{logo_cid}}`, + a `cid:` URL (RFC 2392). This is what the shared code already does. Only a + PNG, JPEG or GIF data-URL logo is embedded. **Decision**: a URL logo is never + embedded (BT-7), and SVG and WebP are skipped because many mail clients + can't show them. Resolution then goes on down BT-1's chain for the first logo + that can be embedded, ending at the built-in PNG. +- **BT-10.** A logo change takes effect without a restart, on every node of a + cluster, through the existing `DomainLogo` and `TenantLogo` cache + invalidations. A change to `logoUrl` clears the whole logo cache. + +### Email templates + +- **BT-11.** A template set in `x:CalendarAlarm.template` replaces the + built-in alarm email, and one set in `x:CalendarScheduling.emailTemplate` + replaces the built-in iMIP email. Null restores the built-in. Both are + server-wide: there's no per-tenant or per-domain template. The logo is what + varies (BT-9). +- **BT-12.** The template language is the one the built-in templates already + use (`crates/utils/src/template.rs`): + - `{{name}}` inserts a value, HTML-escaped (`& < > " '`); + - `{{#if name}}…{{/if name}}` keeps its content only when `name` is set; + - `{{#each name}}…{{/each name}}` repeats its content once per entry of a + list, and inside it `{{name}}` and `{{#if name}}` refer to the entry. + A token can't span lines. `#each` can't be nested. +- **BT-13.** The variables. What the server sets for each template: + + | Variable | Kind | Alarm | iMIP | + |---|---|---|---| + | `page_title` | value | the subject | the subject | + | `lang`, `dir` | value | recipient's locale and direction | same | + | `logo_cid` | value | `cid:` of the logo part | same | + | `header` | value | the alarm heading | on update, cancel and reply: what happened | + | `color` | value | — | `info`, `warning` or `danger`, with `header` | + | `event_title`, `event_description` | value | if the event has them | same | + | `event_details` | list of `key`, `value`, `link`?; iMIP also `changed`?, `old_value`? | start, end, location, conference, organizer | summary, description, when, location, conference; the old value when it changed | + | `attendees_title` | value | always | when there are attendees | + | `attendees` | list of `key` (name), `value` (address) | when there are guests | when there are attendees | + | `action_name`, `action_url` | value | "open" label and the event's webcal link | — | + | `rsvp` | value | — | "reply as …", when RSVP links are on | + | `actions` | list of `action_name`, `action_url`, `color` | — | yes, no and maybe, when RSVP links are on | + | `footer` | alarm: value; iMIP: list of `key` | the footer line | two footer lines | + + `link` is set only for `https`, `http`, `tel`, `sip`, `sips` and `xmpp` + values, so a template can't be made to link `javascript:`. The labels are the + server's own translations for the recipient's locale. A template can't add + translated text of its own. +- **BT-14.** **Decision**, a security requirement: values are always escaped + in operator templates. The shared syntax also has `{{!name}}` for raw output. + A write that uses it is refused (BT-15). A stored template that uses it + (from before the fork) is rendered with those values escaped too. Event titles, + descriptions and attendee names come from whoever sent the invitation, often + from outside the server, so raw output would let a stranger put HTML into + mail the server sends under its own name. +- **BT-15.** A write of `template` or `emailTemplate` is checked, and refused + with `invalidProperties` naming the field and the first problem, when: + - it doesn't parse (unbalanced block, block end that doesn't match, token + across lines, nested `#each`); + - it names a variable not in BT-13; + - it uses `{{!…}}`; + - it's over 256 KiB. + **Decision** on all four. A variable used outside the scope where it's set + (e.g. `{{key}}` outside a list) renders empty. That's allowed, not refused. +- **BT-16.** The server fetches nothing a template references, and doesn't + rewrite it. A remote image in an operator's template is the recipient's mail + client's business. The plain-text part of each email is still generated from + the rendered HTML, as today. +- **BT-17.** Subjects, sender names and addresses aren't templated. They stay + as the shared code builds them, with `fromName` and `fromEmail` for alarms. +- **BT-18.** A template change takes effect for the next email rendered, with + no restart and no settings reload. **Decision**, matching undelete UD-6a: + whether upstream needs a reload is open question 4. +- **BT-19.** A stored template that fails BT-15's parse on load (data written + before the fork) never stops the server. The built-in is used instead, and a + `registry.build-warning` event names the field and the error. It's reported + again whenever settings are reloaded until it's fixed. + +### The RSVP page + +- **BT-20.** When `httpRsvpEnable` is true, `GET /calendar/rsvp` serves + `httpRsvpTemplate` if set, else the built-in page. A custom page is served + byte for byte: no variables, no substitution. `{{` in it is plain text. + Setting `httpRsvpEnable` to false turns off both the page and the API, as + upstream documents. +- **BT-21.** Whichever page is served, the answer carries: + - `Content-Type: text/html; charset=utf-8` and `Cache-Control: no-store`; + - `Referrer-Policy: no-referrer`, because the token is in the query string; + - `Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`. + **Decision**, a security requirement: the page holds a live RSVP token. It + may show remote images (URL logos, BT-5), but it can't send the token + anywhere but the server. +- **BT-22.** A write of `httpRsvpTemplate` is refused with `invalidProperties` + when it's over 1 MiB or isn't valid UTF-8. **Decision**. Its content isn't + otherwise checked: it's the operator's own page, and only a server-level + administrator can set it (BT-24). +- **BT-23.** The API a page calls is unchanged: `POST /api/calendar/rsvp` with + `{token}` to load the invitation, and `{token, partstat, comment}` to reply. + The answer is `invitation`, `recorded` or `error`, with localized `labels`, a + `language` and a `dir`, as the scheduling documentation describes. This + spec adds nothing to it. + +### Who can change what + +- **BT-24.** The three templates and `logoUrl` live on server-wide singletons. + Only a principal in no tenant, holding the matching `sys*Update` + permission, can change them. A principal in a tenant can't, whatever its + permissions (multi-tenancy MT-2). +- **BT-25.** A domain's `logo` is changed with `sysDomainUpdate`. Inside a + tenant, the tenant's administrator can set the logo of its own domains + (multi-tenancy MT-11). A tenant's `logo` is server-level (MT-12). + +### Built-in pages + +- **BT-26.** The built-in sign-in and RSVP pages load the logo with an image + element pointing at `/logo` (with `?domain=` as they do now), not with + `fetch`. On an error they keep their built-in logo. **Decision**: `fetch` + can't follow BT-5's redirect to another origin without that origin's CORS + headers, and an image element can. + +## Interfaces + +- **Existing, unchanged:** `x:Enterprise/get` and `/set`, + `x:CalendarAlarm/get` and `/set`, `x:CalendarScheduling/get` and `/set`, + `x:Domain/get` and `/set`, their permissions; `GET /calendar/rsvp`; + `POST /api/calendar/rsvp`. +- **New:** `GET /logo` (BT-5), with an optional `domain` parameter. The fork's + own pages already call it. +- **Per principal:** the applicable logo over JMAP, as proposed in + multi-tenancy MT-22, following BT-1's full chain (BT-2). +- **Errors:** RFC 8620 `invalidProperties`, naming the field, with the parse + error or limit in `description`, so ihasmail and INBUXA Admin can show it. +- **Events:** `registry.build-warning` for BT-4 and BT-19. + +## ihasmail changes + +These go in the INBUXA fork of ihasmail, ihasmail-inbuxa, never in public +ihasmail, which stays Stalwart-facing (SPEC.md §5). + +- **Administration, Domains:** a logo field on each domain. Upload a PNG, JPEG + or GIF, stored as a data URL, with a preview and the 256 KiB limit checked + before sending. A note says that SVG, WebP and URL logos show on the web but + aren't used in email (BT-9). Tenant logos stay where multi-tenancy puts them. +- **Show the applicable logo** for the signed-in user (MT-22, BT-2). URL logos + go through the image proxy (MT-23). +- **Not in ihasmail:** the server-wide logo and the three templates are + server-level settings. INBUXA Admin's schema-driven forms already cover them + (SPEC.md §5.4). ihasmail links there, and doesn't grow a template editor. +- New strings: the logo field's label, its help text, the email note, and the + size and type errors. That's about six strings, new translation work for + each of ihasmail's nine languages. + +INBUXA Admin needs no new screen. It shows the `invalidProperties` +descriptions from BT-15 and BT-22 as it shows any validation error. + +## Acceptance tests + +Every test runs against inbuxa-server built with no Enterprise code. The one +marked **(compat)** also runs against a copy of INBUXA's data. + +1. Nothing set: `/logo` answers `404`, and alarm and invite emails carry the + built-in INBUXA PNG, referenced by `cid:` (BT-1, BT-5, BT-9). +2. `logoUrl` set to a PNG data URL: `/logo` answers `200` `image/png` with the + bytes, and emails embed it (BT-1, BT-5, BT-9). +3. Tenant T with a logo, its domain without one: `/logo?domain=` for the + domain gives T's logo. With the domain's own logo set, the domain's wins + (BT-1). +4. `/logo?domain=mail.example.com` finds `example.com`'s logo. No parameter, + `Host: mail.example.com`: the same (BT-1, BT-2). +5. A domain logo that's an `https:` URL: `/logo` answers `302` to it, the + server makes no outbound request (watched at the network), and emails fall + back to the next logo that can be embedded (BT-5, BT-7, BT-9). +6. An unknown domain and a known domain with no logo give identical answers + (BT-6). +7. An SVG logo with a `