Branding and templates: per-domain, tenant and server logos, /logo, operator calendar email templates and RSVP page (BT-1 to BT-26)

Logos resolve domain, then tenant, then server-wide, then the built-in, with
subdomains finding their domain. GET /logo serves a data-URL image, redirects
to a URL logo without fetching it, sandboxes SVG, and answers 404 when no
custom logo applies. Emails embed the first PNG, JPEG or GIF logo. Logo and
template writes are checked; stored templates are read at send time, always
escaped, and fall back to the built-in with a build warning when they don't
parse. The RSVP page is served byte for byte with a CSP and no-referrer. The
sign-in and RSVP pages load the logo through an image element. MT-22's
session logo follows the chain to the server-wide logo.
Acceptance tests 1 to 17; test 18 written as the ignored branding_compat.
This commit is contained in:
2026-09-18 22:27:19 -07:00
parent ecbdfd533b
commit 0bc6b03dcd
29 changed files with 1772 additions and 91 deletions
+11
View File
@@ -100,6 +100,17 @@ impl GroupwareConfig {
let dr = bp.setting_infallible::<DataRetention>().await;
let system = bp.setting_infallible::<SystemSettings>().await;
// inbuxa: BT-19: a stored template that doesn't parse is reported at
// start and on each reload; the built-in is used meanwhile
inbuxa_features::branding::templates::warn_unusable::<CalendarTemplateVariable>(
"CalendarAlarm.template",
alarm.template.as_deref(),
);
inbuxa_features::branding::templates::warn_unusable::<CalendarTemplateVariable>(
"CalendarScheduling.emailTemplate",
sched.email_template.as_deref(),
);
GroupwareConfig {
max_request_size: dav.request_max_size as usize,
dead_property_size: dav.dead_property_max_size.map(|v| v as usize),
+60
View File
@@ -0,0 +1,60 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Which logo applies to a domain name (branding spec BT-1, BT-2). The rules
//! live in `inbuxa_features::branding::logo`; this finds the domain through
//! the server's domain cache and reads the three levels from the registry
//! each time, so a change shows at once on every node (BT-10).
use crate::Server;
use inbuxa_features::branding::logo::{self, Logo, Source};
use registry::schema::structs::{Domain, Enterprise, Tenant};
use types::id::Id;
impl Server {
/// The logos that apply to a domain name, most specific first. An unknown
/// name gets what a known domain with no logo of its own gets (BT-6).
pub async fn logos_for(&self, name: &str) -> trc::Result<Vec<Logo>> {
let mut domain = None;
for candidate in logo::lookup_names(name) {
if let Some(found) = self.domain(&candidate).await? {
domain = Some(found);
break;
}
}
let registry = self.registry();
let domain_logo = match &domain {
Some(domain) => registry
.object::<Domain>(Id::from(domain.id))
.await?
.and_then(|d| d.logo),
None => None,
};
let tenant_id = domain.as_ref().and_then(|d| d.id_tenant);
let tenant_logo = match tenant_id {
Some(tenant_id) => registry
.object::<Tenant>(Id::from(tenant_id))
.await?
.and_then(|t| t.logo),
None => None,
};
let server_logo = registry
.object::<Enterprise>(Id::singleton())
.await?
.and_then(|e| e.logo_url);
Ok(logo::chain([
(
Source::Domain(domain.as_ref().map_or(u32::MAX, |d| d.id)),
domain_logo.as_deref(),
),
(
Source::Tenant(tenant_id.unwrap_or(u32::MAX)),
tenant_logo.as_deref(),
),
(Source::Server, server_logo.as_deref()),
]))
}
}
+19 -2
View File
@@ -18,6 +18,7 @@ use store::{BlobStore, InMemoryStore, RegistryStore, SearchStore, Store};
pub mod archive;
pub mod blob;
pub mod branding; // inbuxa: branding BT-1, BT-2
pub mod dav;
pub mod document;
pub mod encryption;
@@ -95,11 +96,27 @@ impl Server {
self.registry().count_object(ObjectType::Domain).await
}
// inbuxa: BT-9: the first logo mail can carry inline; none leaves the
// built-in INBUXA logo
#[cfg(not(feature = "enterprise"))]
pub async fn logo_resource(
&self,
_: &str,
domain: &str,
) -> trc::Result<Option<crate::manager::application::Resource<Vec<u8>>>> {
Ok(None)
Ok(self
.logos_for(domain)
.await?
.into_iter()
.find(|logo| logo.is_embeddable())
.and_then(|logo| match logo {
inbuxa_features::branding::logo::Logo::Image {
content_type,
bytes,
} => Some(crate::manager::application::Resource::new(
content_type,
bytes,
)),
inbuxa_features::branding::logo::Logo::Url(_) => None,
}))
}
}