25 KiB
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(defaultINBUXA Calendarin the fork),fromEmail,allowExternalRcpts,minTriggerInterval.x:CalendarScheduling:enable,httpRsvpEnable(default true),httpRsvpUrl,httpRsvpLinkExpiry(default 90 days),autoAddInvitations,itipMaxSize,maxRecipients.x:Enterprise.licenseKeyandapiKey. 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:
- the
logoof thex:Domainwhosenameoraliasesmatch D; - else the
logoof that domain's tenant (memberTenantId); - else
x:Enterprise.logoUrl; - 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.comfindsexample.com. Matching is case-insensitive.
- the
- BT-2. Where each surface gets its domain:
- the sign-in page and the RSVP page: the
domainquery parameter of/logo(BT-5), else the request'sHostwithout its port, as upstream documents; - alarm emails: the domain of the account's primary address;
- iMIP emails: the domain of the message's
Fromaddress; - 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.
- the sign-in page and the RSVP page: the
- BT-3. A logo value is one of:
- an
https:URL; - a
data:URL (RFC 2397), base64, with typeimage/png,image/jpeg,image/gif,image/webporimage/svg+xml. Decision on writes: anything else is refused withinvalidPropertiesnaming the field, and so is a data URL whose decoded image is over 256 KiB or whose bytes don't match its declared type.logoUrltakes the same two forms: a data URL is a valid URI.
- an
- 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 anhttps:one. A value that is none of these is skipped as if unset, with aregistry.build-warningevent 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:
200with the decoded bytes and their type; - a URL logo:
302to that URL; - no custom logo at any level:
404, and the page draws its own. Every answer carriesCache-Control: public, max-age=300,X-Content-Type-Options: nosniff, andAccess-Control-Allow-Origin: *. Decision on the shape (see open question 3).
- a data-URL logo:
- 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:/logoisn'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
/logois sent withContent-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox. Decision, a security requirement: a tenant administrator can set its domain's logo, and/logois 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/relatedpart, and the template reaches it through{{logo_cid}}, acid: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
DomainLogoandTenantLogocache invalidations. A change tologoUrlclears the whole logo cache.
Email templates
-
BT-11. A template set in
x:CalendarAlarm.templatereplaces the built-in alarm email, and one set inx:CalendarScheduling.emailTemplatereplaces 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 whennameis 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.#eachcan't be nested.
-
BT-13. The variables. What the server sets for each template:
Variable Kind Alarm iMIP page_titlevalue the subject the subject lang,dirvalue recipient's locale and direction same logo_cidvalue cid:of the logo partsame headervalue the alarm heading on update, cancel and reply: what happened colorvalue — info,warningordanger, withheaderevent_title,event_descriptionvalue if the event has them same event_detailslist of key,value,link?; iMIP alsochanged?,old_value?start, end, location, conference, organizer summary, description, when, location, conference; the old value when it changed attendees_titlevalue always when there are attendees attendeeslist of key(name),value(address)when there are guests when there are attendees action_name,action_urlvalue "open" label and the event's webcal link — rsvpvalue — "reply as …", when RSVP links are on actionslist of action_name,action_url,color— yes, no and maybe, when RSVP links are on footeralarm: value; iMIP: list of keythe footer line two footer lines linkis set only forhttps,http,tel,sip,sipsandxmppvalues, so a template can't be made to linkjavascript:. 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
templateoremailTemplateis checked, and refused withinvalidPropertiesnaming 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.
- it doesn't parse (unbalanced block, block end that doesn't match, token
across lines, nested
-
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
fromNameandfromEmailfor 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-warningevent names the field and the error. It's reported again whenever settings are reloaded until it's fixed.
The RSVP page
- BT-20. When
httpRsvpEnableis true,GET /calendar/rsvpserveshttpRsvpTemplateif set, else the built-in page. A custom page is served byte for byte: no variables, no substitution.{{in it is plain text. SettinghttpRsvpEnableto 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-8andCache-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
httpRsvpTemplateis refused withinvalidPropertieswhen 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/rsvpwith{token}to load the invitation, and{token, partstat, comment}to reply. The answer isinvitation,recordedorerror, with localizedlabels, alanguageand adir, as the scheduling documentation describes. This spec adds nothing to it.
Who can change what
- BT-24. The three templates and
logoUrllive on server-wide singletons. Only a principal in no tenant, holding the matchingsys*Updatepermission, can change them. A principal in a tenant can't, whatever its permissions (multi-tenancy MT-2). - BT-25. A domain's
logois changed withsysDomainUpdate. Inside a tenant, the tenant's administrator can set the logo of its own domains (multi-tenancy MT-11). A tenant'slogois 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 withfetch. On an error they keep their built-in logo. Decision:fetchcan'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/getand/set,x:CalendarAlarm/getand/set,x:CalendarScheduling/getand/set,x:Domain/getand/set, their permissions;GET /calendar/rsvp;POST /api/calendar/rsvp. - New:
GET /logo(BT-5), with an optionaldomainparameter. 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 indescription, so ihasmail and INBUXA Admin can show it. - Events:
registry.build-warningfor 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.
- Nothing set:
/logoanswers404, and alarm and invite emails carry the built-in INBUXA PNG, referenced bycid:(BT-1, BT-5, BT-9). logoUrlset to a PNG data URL:/logoanswers200image/pngwith the bytes, and emails embed it (BT-1, BT-5, BT-9).- 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). /logo?domain=mail.example.comfindsexample.com's logo. No parameter,Host: mail.example.com: the same (BT-1, BT-2).- A domain logo that's an
https:URL:/logoanswers302to 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). - An unknown domain and a known domain with no logo give identical answers (BT-6).
- An SVG logo with a
<script>:/logosends the sandboxing CSP, and the script doesn't run when the URL is opened directly (BT-8). - Logo writes:
javascript:alert(1),data:text/html,…, a 300 KiB PNG, anddata:image/pngholding JPEG bytes are each refusedinvalidProperties(BT-3). - Changing a domain's logo shows on the next
/logorequest on another cluster node, without a restart (BT-10). - A custom alarm template renders with every BT-13 alarm variable filled.
An event titled
<b>x</b>shows as text, not bold (BT-12, BT-13, BT-14). - A custom iMIP template for an invitation, an update (with
changedandold_value), a cancellation and a reply each render, with RSVP actions only when RSVP is on (BT-13). - Template writes: unbalanced
{{#if header}},{{unknown}},{{!header}}, nested#each, and 300 KiB are each refusedinvalidProperties, naming the field (BT-15). - A template stored directly in the registry with
{{!event_title}}renders the title escaped. One that doesn't parse leaves the server running, uses the built-in, and emitsregistry.build-warning(BT-14, BT-19). - A template change is used for the next alarm, with no reload (BT-18).
- A custom RSVP page containing
{{page_title}}is served byte for byte, with BT-21's headers. WithhttpRsvpEnablefalse, the page and the API are both gone (BT-20, BT-21). - A tenant administrator can set its domain's logo, but can't change
logoUrl, any template, or its tenant's logo (BT-24, BT-25). - The built-in sign-in and RSVP pages show a URL logo through
/logo's redirect, and their built-in logo when/logois404(BT-26). - (compat) Every logo and template INBUXA holds reads back unchanged, and each renders or is served as it did before cutover (BT-4, BT-19).
Open questions / to observe
To check read-only against INBUXA's live Enterprise server before implementation. None blocks the spec. Items 2 and 5 need a write, so they need the operator's approval first, as with the other features' probes.
- What INBUXA holds. Read
x:Enterprise.logoUrl, everyx:Domain.logoandx:Tenant.logo, and the three template fields. If all are null, test 18 has nothing to carry over. Note the forms any logos take (URL, data URL, bare base64), which BT-4 depends on. - URL logos in email. Does upstream embed a URL logo in alarm and invite emails (which would mean it fetches it), reference it remotely, or skip it? Look at the MIME structure of an invite already in a mailbox before sending a new one. BT-7 and BT-9 stand either way. This only says how far the fork differs.
- Upstream's
/logo. Its exact path and parameters, whether it honorsdomain=andHost, and its status, content type and caching for data URLs, URL logos and no logo. The fork's own pages already call/logo?domain=. A plainGETsettles this, and BT-5 is aligned to it where nothing more important is at stake. - Reload. Does a change to a template or a logo take effect upstream without a settings reload (BT-10, BT-18)?
- Invalid templates on write. Does upstream refuse a template that doesn't parse, or store it and fall back? It matters only for how much invalid data INBUXA might already hold (BT-19).
- RSVP page headers. What headers upstream sends with a custom RSVP page, so BT-21 is known as a difference or not.
- The stored
fromName. Whether INBUXA'sx:CalendarAlarmstoresStalwart Calendaras a value or relies on the default. Decision until known: stored values are never rewritten (SPEC.md §7). If it's stored, the operator changes it once, by hand. (The packagedschema.json.gzcarried upstream's default,Stalwart Calendar, until 2026-09-18. It now saysINBUXA Calendar, matching the code.) - INBUXA Admin's logo. Upstream documents that its web interface picks its
logo by request hostname. Check in
inbuxa-admin(an ordinary AGPL fork, SPEC.md §5) whether it calls/logo, so it keeps working against BT-5.
Observed
Settled on 2026-09-18 against INBUXA's live Enterprise server (Stalwart 0.16.22), read-only, as a server-level administrator and the throwaway test account. No upstream code was read.
- What INBUXA holds (open question 1).
x:Enterprise.logoUrl, all three templates and every tenant logo are null. Six of nine domains had a logo: four held the samehttps:URL, pointing at a retired website that now answers with an HTML page, and two held the literal stringadmin. With the operator's approval, the four URL logos were removed the same day (a write). The twoadminvalues were removed too, also with approval, so INBUXA holds no logos at all and compat test 18 has nothing to carry. - Upstream fetches URL logos itself (open questions 2 and 3). While the
URL logos were set,
GET /logoreturned200 text/html: the retired site's page, served from the mail server's own origin with noX-Content-Type-Optionsand no CSP. So upstream fetches the URL on the server side and passes on whatever comes back, content type included. BT-7 (never fetch) and BT-8 (sandbox what is served) are deliberate differences, and this is why. /logoshape./logowith no parameter used the request's host, and?domain=selected a domain./logo/<domain>answered the same as/logo, so the path segment is ignored. An unknown domain, a domain with an unusable value, and (after the removal) every domain answered404withapplication/problem+json. No caching headers were sent. BT-5 keeps/logo?domain=and404, and adds its own headers.- Reload (open question 4). Removing the logos took effect on the very
next
/logorequest, with no settings reload. - RSVP page headers (open question 6).
GET /calendar/rsvpsends onlyContent-Type: text/html; charset=utf-8andCache-Control: no-store, no-cache, must-revalidate: no CSP and noReferrer-Policy. BT-21's headers are a difference. - The stored
fromName(open question 7). INBUXA storesINBUXA Calendaras a value already. Nothing to change at cutover.
Not settled: open question 5 (invalid templates on write) needs a write, and
question 8 is a check in inbuxa-admin, not on the server.