From 0bc6b03dcd95e3a9f38b8a05635cbaece38df14d Mon Sep 17 00:00:00 2001 From: John Coffey Date: Fri, 18 Sep 2026 22:27:19 -0700 Subject: [PATCH] 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. --- Cargo.lock | 2 + crates/common/src/config/groupware.rs | 11 + crates/common/src/storage/branding.rs | 60 ++ crates/common/src/storage/mod.rs | 21 +- crates/features/Cargo.toml | 1 + crates/features/src/branding/logo.rs | 307 +++++++ crates/features/src/branding/mod.rs | 13 + crates/features/src/branding/templates.rs | 266 ++++++ crates/features/src/branding/writes.rs | 60 ++ crates/features/src/lib.rs | 1 + crates/features/src/tenancy/logo.rs | 39 +- crates/http/Cargo.toml | 1 + crates/http/src/branding.rs | 95 ++ crates/http/src/lib.rs | 1 + crates/http/src/request.rs | 14 +- crates/jmap/src/registry/set.rs | 16 + crates/services/src/task_manager/alarm.rs | 11 +- crates/services/src/task_manager/imip.rs | 11 +- docs/spec/SPEC.md | 2 +- docs/spec/features/branding-and-templates.md | 40 +- docs/spec/features/multi-tenancy.md | 4 +- resources/html-templates/calendar-rsvp.html | 34 +- .../html-templates/calendar-rsvp.html.min | 2 +- .../html-templates/calendar-rsvp.html.min.gz | Bin 39141 -> 39034 bytes resources/html-templates/login.html | 40 +- resources/html-templates/login.html.min | 2 +- resources/html-templates/login.html.min.gz | Bin 36375 -> 36246 bytes tests/src/system/branding.rs | 808 ++++++++++++++++++ tests/src/system/mod.rs | 1 + 29 files changed, 1772 insertions(+), 91 deletions(-) create mode 100644 crates/common/src/storage/branding.rs create mode 100644 crates/features/src/branding/logo.rs create mode 100644 crates/features/src/branding/mod.rs create mode 100644 crates/features/src/branding/templates.rs create mode 100644 crates/features/src/branding/writes.rs create mode 100644 crates/http/src/branding.rs create mode 100644 tests/src/system/branding.rs diff --git a/Cargo.lock b/Cargo.lock index 14a004a..44edbbf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3326,6 +3326,7 @@ dependencies = [ "http_proto", "hyper", "hyper-util", + "inbuxa-features", "jmap", "jmap_proto", "mail-auth", @@ -3964,6 +3965,7 @@ name = "inbuxa-features" version = "0.16.22" dependencies = [ "ahash", + "base64 0.23.1", "jmap_proto", "registry", "serde", diff --git a/crates/common/src/config/groupware.rs b/crates/common/src/config/groupware.rs index 47c0356..b14ffb2 100644 --- a/crates/common/src/config/groupware.rs +++ b/crates/common/src/config/groupware.rs @@ -100,6 +100,17 @@ impl GroupwareConfig { let dr = bp.setting_infallible::().await; let system = bp.setting_infallible::().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::( + "CalendarAlarm.template", + alarm.template.as_deref(), + ); + inbuxa_features::branding::templates::warn_unusable::( + "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), diff --git a/crates/common/src/storage/branding.rs b/crates/common/src/storage/branding.rs new file mode 100644 index 0000000..2f7404e --- /dev/null +++ b/crates/common/src/storage/branding.rs @@ -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> { + 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::(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::(Id::from(tenant_id)) + .await? + .and_then(|t| t.logo), + None => None, + }; + let server_logo = registry + .object::(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()), + ])) + } +} diff --git a/crates/common/src/storage/mod.rs b/crates/common/src/storage/mod.rs index f65943b..71edc15 100644 --- a/crates/common/src/storage/mod.rs +++ b/crates/common/src/storage/mod.rs @@ -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>>> { - 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, + })) } } diff --git a/crates/features/Cargo.toml b/crates/features/Cargo.toml index def9be7..f9a1c9f 100644 --- a/crates/features/Cargo.toml +++ b/crates/features/Cargo.toml @@ -15,6 +15,7 @@ utils = { path = "../utils" } ahash = { version = "0.8.12", features = ["serde"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +base64 = "0.23" [dev-dependencies] tokio = { version = "1.53", features = ["macros", "rt"] } diff --git a/crates/features/src/branding/logo.rs b/crates/features/src/branding/logo.rs new file mode 100644 index 0000000..8e9bf9e --- /dev/null +++ b/crates/features/src/branding/logo.rs @@ -0,0 +1,307 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Logo values: what may be written (BT-3), how stored ones are read +//! (BT-4), and which logo applies (BT-1, BT-9). The server never fetches a +//! logo URL (BT-7): a URL is only ever handed on. + +use base64::{Engine, engine::general_purpose::STANDARD}; + +/// The largest image a data URL may hold (BT-3). +pub const MAX_IMAGE_SIZE: usize = 256 * 1024; + +/// The image types a logo may be (BT-3). +pub const TYPES: &[&str] = &[ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/svg+xml", +]; + +/// A logo, read from a stored value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Logo { + /// A URL, handed to browsers and mail clients as it is. + Url(String), + /// An image, from a data URL or bare base64. + Image { + content_type: &'static str, + bytes: Vec, + }, +} + +impl Logo { + /// Whether mail can carry it inline: PNG, JPEG or GIF only (BT-9). + pub fn is_embeddable(&self) -> bool { + matches!( + self, + Logo::Image { + content_type: "image/png" | "image/jpeg" | "image/gif", + .. + } + ) + } +} + +/// The type an image's bytes show, if one of `TYPES`. +pub fn sniff(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + Some("image/png") + } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { + Some("image/jpeg") + } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + Some("image/gif") + } else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { + Some("image/webp") + } else if is_svg(bytes) { + Some("image/svg+xml") + } else { + None + } +} + +/// SVG is text: an ` bool { + let head = &bytes[..bytes.len().min(4096)]; + let Ok(text) = std::str::from_utf8(head).or_else(|err| { + // The cut may split a character + std::str::from_utf8(&head[..err.valid_up_to()]) + }) else { + return false; + }; + let text = text.trim_start_matches('\u{feff}').trim_start(); + text.starts_with('<') && text.to_ascii_lowercase().contains(" bool { + value + .get(..scheme.len()) + .is_some_and(|head| head.eq_ignore_ascii_case(scheme)) +} + +/// A URL a browser may load: the scheme, a host, and no spaces or controls. +fn is_url(value: &str, scheme: &str) -> bool { + has_scheme(value, scheme) + && value[scheme.len()..] + .strip_prefix("//") + .and_then(|rest| rest.chars().next()) + .is_some_and(|c| !matches!(c, '/' | '?' | '#')) + && !value.chars().any(|c| c.is_whitespace() || c.is_control()) +} + +/// A `data:` URL's type and decoded bytes, when it's base64. +fn data_url(value: &str) -> Result<(String, Vec), &'static str> { + let rest = value.get(5..).ok_or("not a data URL")?; + let (header, data) = rest.split_once(',').ok_or("a data URL needs a comma")?; + let mut params = header.split(';'); + let media_type = params.next().unwrap_or_default().trim().to_ascii_lowercase(); + if !params.any(|p| p.trim().eq_ignore_ascii_case("base64")) { + return Err("a data URL logo must be base64"); + } + let data = data + .chars() + .filter(|c| !c.is_ascii_whitespace()) + .collect::(); + let bytes = STANDARD + .decode(data.as_bytes()) + .map_err(|_| "the data URL isn't valid base64")?; + Ok((media_type, bytes)) +} + +/// Checks a logo being written (BT-3): an `https:` URL, or a base64 data URL +/// of one of `TYPES`, at most `MAX_IMAGE_SIZE`, whose bytes are that type. +pub fn check(value: &str) -> Result<(), String> { + if has_scheme(value, "data:") { + let (media_type, bytes) = data_url(value).map_err(str::to_string)?; + let Some(declared) = TYPES.iter().find(|t| **t == media_type) else { + return Err(format!( + "A logo must be PNG, JPEG, GIF, WebP or SVG, not {media_type:?}." + )); + }; + if bytes.len() > MAX_IMAGE_SIZE { + return Err(format!( + "The logo is {} KiB; the limit is 256 KiB.", + bytes.len().div_ceil(1024) + )); + } + match sniff(&bytes) { + Some(found) if found == *declared => Ok(()), + _ => Err(format!("The logo's bytes aren't {declared}.")), + } + } else if is_url(value, "https:") { + Ok(()) + } else { + Err("A logo must be an https: URL or a base64 data: URL of an image.".to_string()) + } +} + +/// Reads a stored logo (BT-4): anything `check` accepts, an `http:` URL, a +/// base64 data URL of an image, or bare base64 whose bytes are an image. +/// `None` for a value that is none of these, or empty. +pub fn read(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() { + None + } else if has_scheme(value, "data:") { + let (media_type, bytes) = data_url(value).ok()?; + let found = sniff(&bytes)?; + // The bytes decide, so a mislabelled image is still served as what it is + (media_type.starts_with("image/")).then_some(Logo::Image { + content_type: found, + bytes, + }) + } else if is_url(value, "https:") || is_url(value, "http:") { + Some(Logo::Url(value.to_string())) + } else { + let data = value + .chars() + .filter(|c| !c.is_ascii_whitespace()) + .collect::(); + let bytes = STANDARD.decode(data.as_bytes()).ok()?; + sniff(&bytes).map(|content_type| Logo::Image { + content_type, + bytes, + }) + } +} + +/// Where a logo value came from, for the warning about an unusable one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Source { + Domain(u32), + Tenant(u32), + Server, +} + +/// The logos that apply, most specific first (BT-1): the domain's, its +/// tenant's, then the server-wide one. Unusable values are skipped with a +/// `registry.build-warning` (BT-4). The built-in logo is the caller's last +/// resort. +pub fn chain<'x>( + candidates: impl IntoIterator)>, +) -> Vec { + let mut logos = Vec::new(); + for (source, value) in candidates { + let Some(value) = value.filter(|v| !v.trim().is_empty()) else { + continue; + }; + match read(value) { + Some(logo) => logos.push(logo), + None => trc::event!( + Registry(trc::RegistryEvent::BuildWarning), + Details = format!("Unusable logo on {source:?}, skipped (BT-4)") + ), + } + } + logos +} + +/// The names to try for a domain name D (BT-1): D, then D without its +/// leftmost label while at least two labels remain. Lowercase. +pub fn lookup_names(name: &str) -> Vec { + let mut name = name.trim().trim_end_matches('.').to_ascii_lowercase(); + // A Host header may carry a port + if let Some((host, port)) = name.rsplit_once(':') + && !host.contains(':') + && port.chars().all(|c| c.is_ascii_digit()) + { + name = host.to_string(); + } + let mut names = Vec::new(); + let mut rest = name.as_str(); + while !rest.is_empty() { + names.push(rest.to_string()); + match rest.split_once('.') { + Some((_, parent)) if parent.contains('.') => rest = parent, + _ => break, + } + } + names +} + +#[cfg(test)] +mod tests { + use super::*; + + const PNG: &[u8] = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR"; + const JPEG: &[u8] = &[0xFF, 0xD8, 0xFF, 0xE0, 0, 0x10]; + + fn data(media_type: &str, bytes: &[u8]) -> String { + format!("data:{media_type};base64,{}", STANDARD.encode(bytes)) + } + + #[test] + fn writes() { + assert!(check("https://example.org/logo.png").is_ok()); + assert!(check(&data("image/png", PNG)).is_ok()); + assert!(check(&data("image/svg+xml", b"")).is_ok()); + for bad in [ + "javascript:alert(1)".to_string(), + "http://example.org/logo.png".to_string(), + "https://".to_string(), + "https://exa mple.org/".to_string(), + "data:text/html,x".to_string(), + data("text/html", b"x"), + data("image/png", JPEG), + data("image/png", &[PNG, &vec![0u8; 300 * 1024]].concat()), + "admin".to_string(), + ] { + assert!(check(&bad).is_err(), "{bad:.60}"); + } + } + + #[test] + fn reads() { + assert_eq!( + read("http://example.org/l.png"), + Some(Logo::Url("http://example.org/l.png".into())) + ); + assert_eq!( + read(&STANDARD.encode(PNG)), + Some(Logo::Image { + content_type: "image/png", + bytes: PNG.to_vec() + }) + ); + // Mislabelled: served as what it is + assert_eq!( + read(&data("image/png", JPEG)), + Some(Logo::Image { + content_type: "image/jpeg", + bytes: JPEG.to_vec() + }) + ); + assert_eq!(read("admin"), None); + assert_eq!(read(""), None); + assert_eq!(read(&data("text/html", b"x")), None); + } + + #[test] + fn chain_order_and_embedding() { + let png = data("image/png", PNG); + let svg = data("image/svg+xml", b""); + let logos = chain([ + (Source::Domain(1), Some("admin")), + (Source::Tenant(2), Some(svg.as_str())), + (Source::Server, Some(png.as_str())), + ]); + assert_eq!(logos.len(), 2); + assert!(!logos[0].is_embeddable()); + assert!(logos[1].is_embeddable()); + } + + #[test] + fn names() { + assert_eq!( + lookup_names("Mail.Example.COM:8443"), + vec!["mail.example.com", "example.com"] + ); + assert_eq!(lookup_names("example.com"), vec!["example.com"]); + assert_eq!(lookup_names("localhost"), vec!["localhost"]); + } +} diff --git a/crates/features/src/branding/mod.rs b/crates/features/src/branding/mod.rs new file mode 100644 index 0000000..446a2bf --- /dev/null +++ b/crates/features/src/branding/mod.rs @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Branding and templates (`docs/spec/features/branding-and-templates.md`): +//! logos per domain, tenant and server, and the operator's calendar email +//! templates and RSVP page. + +pub mod logo; +pub mod templates; +pub mod writes; diff --git a/crates/features/src/branding/templates.rs b/crates/features/src/branding/templates.rs new file mode 100644 index 0000000..58a7f16 --- /dev/null +++ b/crates/features/src/branding/templates.rs @@ -0,0 +1,266 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Operator email templates and the RSVP page (BT-11 to BT-22). Templates +//! are read from the registry each time an email is rendered, so a change +//! takes effect at once (BT-18). + +use registry::schema::structs::{CalendarAlarm, CalendarScheduling}; +use std::{fmt::Debug, hash::Hash, str::FromStr}; +use store::RegistryStore; +use types::id::Id; +use utils::template::{Template, TemplateItem}; + +/// The largest email template (BT-15). +pub const MAX_TEMPLATE_SIZE: usize = 256 * 1024; + +/// The largest RSVP page (BT-22). +pub const MAX_PAGE_SIZE: usize = 1024 * 1024; + +/// The variables the server sets, for either template (BT-13). +pub const VARIABLES: &[&str] = &[ + "page_title", + "lang", + "dir", + "logo_cid", + "header", + "color", + "event_title", + "event_description", + "event_details", + "key", + "value", + "link", + "changed", + "old_value", + "attendees_title", + "attendees", + "action_name", + "action_url", + "rsvp", + "actions", + "footer", +]; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Block { + If, + Each, +} + +/// A block token's kind and variable: `if name` or `each name`. +fn block<'x>(spec: &'x str, token: &str) -> Result<(Block, &'x str), String> { + let spec = spec.trim(); + if let Some(name) = spec.strip_prefix("if ") { + Ok((Block::If, name.trim())) + } else if let Some(name) = spec.strip_prefix("each ") { + Ok((Block::Each, name.trim())) + } else { + Err(format!("Unknown block {{{{{token}}}}}.")) + } +} + +/// Walks a template's tokens as BT-12 defines the language. `allow_raw` +/// lets `{{!name}}` through, for stored templates (BT-14). +fn walk(template: &str, allow_raw: bool) -> Result<(), String> { + let mut stack: Vec<(Block, &str)> = Vec::new(); + let mut rest = template; + while let Some(start) = rest.find("{{") { + let after = &rest[start + 2..]; + let end = after.find("}}").ok_or("A {{ is never closed.")?; + let token = &after[..end]; + if token.contains('\n') || token.contains('\r') { + return Err(format!("A token spans lines: {{{{{}", token.trim())); + } + rest = &after[end + 2..]; + let token = token.trim(); + let name = if let Some(spec) = token.strip_prefix('#') { + let (kind, name) = block(spec, token)?; + if kind == Block::Each && stack.iter().any(|(k, _)| *k == Block::Each) { + return Err(format!("{{{{#each {name}}}}} is inside another #each.")); + } + stack.push((kind, name)); + name + } else if let Some(spec) = token.strip_prefix('/') { + let (kind, name) = block(spec, token)?; + match stack.pop() { + Some((open_kind, open_name)) if open_kind == kind && open_name == name => name, + Some((_, open_name)) => { + return Err(format!( + "{{{{{token}}}}} doesn't close the open block {open_name}." + )); + } + None => return Err(format!("{{{{{token}}}}} closes no open block.")), + } + } else if let Some(name) = token.strip_prefix('!') { + if !allow_raw { + return Err(format!( + "{{{{{token}}}}}: raw output isn't allowed; values are always escaped." + )); + } + name.trim() + } else { + token + }; + if !VARIABLES.contains(&name) { + return Err(format!("Unknown variable {name:?}.")); + } + } + match stack.last() { + Some((_, name)) => Err(format!("The block {name} is never closed.")), + None => Ok(()), + } +} + +/// Checks an alarm or iMIP template being written (BT-15). +pub fn check(template: &str) -> Result<(), String> { + if template.len() > MAX_TEMPLATE_SIZE { + return Err(format!( + "The template is {} KiB; the limit is 256 KiB.", + template.len().div_ceil(1024) + )); + } + walk(template, false) +} + +/// Checks an RSVP page being written (BT-22). Its content is the +/// operator's own; only its size is limited. +pub fn check_page(page: &str) -> Result<(), String> { + if page.len() > MAX_PAGE_SIZE { + Err(format!( + "The page is {} KiB; the limit is 1 MiB.", + page.len().div_ceil(1024) + )) + } else { + Ok(()) + } +} + +/// Parses a stored template for rendering: every value escaped, `{{!…}}` +/// included (BT-14). +pub fn parse(template: &str) -> Result, String> { + walk(template, true)?; + let mut parsed = Template::::parse(template)?; + for item in &mut parsed.items { + if let TemplateItem::Variable { escape, .. } = item { + *escape = true; + } + } + Ok(parsed) +} + +/// Which email template. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Which { + /// `x:CalendarAlarm.template`. + Alarm, + /// `x:CalendarScheduling.emailTemplate`. + Invite, +} + +/// The stored template's text, if one is set. +pub async fn stored_text(registry: &RegistryStore, which: Which) -> trc::Result> { + Ok(match which { + Which::Alarm => registry + .object::(Id::singleton()) + .await? + .and_then(|o| o.template), + Which::Invite => registry + .object::(Id::singleton()) + .await? + .and_then(|o| o.email_template), + } + .filter(|t| !t.trim().is_empty())) +} + +/// The operator's template to render with, if one is set and parses +/// (BT-11). One that doesn't leaves the built-in in use (BT-19); the warning +/// comes from `warn_unusable`, at start and each settings reload. +pub async fn stored( + registry: &RegistryStore, + which: Which, +) -> trc::Result>> { + Ok(stored_text(registry, which) + .await? + .and_then(|text| parse(&text).ok())) +} + +/// BT-19: a stored template that doesn't parse is reported, and the +/// built-in is used. +pub fn warn_unusable(field: &str, text: Option<&str>) { + if let Some(text) = text.filter(|t| !t.trim().is_empty()) + && let Err(err) = parse::(text) + { + trc::event!( + Registry(trc::RegistryEvent::BuildWarning), + Details = format!("{field} doesn't parse, so the built-in is used (BT-19): {err}") + ); + } +} + +/// The operator's RSVP page, if one is set (BT-20). Served byte for byte. +pub async fn rsvp_page(registry: &RegistryStore) -> trc::Result> { + Ok(registry + .object::(Id::singleton()) + .await? + .and_then(|o| o.http_rsvp_template) + .filter(|t| !t.is_empty())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn writes() { + assert!(check("

{{header}}

{{#each attendees}}{{key}}{{#if link}}x{{/if link}}{{/each attendees}}").is_ok()); + for (bad, why) in [ + ("{{#if header}}x", "never closed"), + ("{{unknown}}", "Unknown variable"), + ("{{!header}}", "raw output"), + ("{{#each actions}}{{#each attendees}}{{/each attendees}}{{/each actions}}", "inside another"), + ("{{/if header}}", "closes no open"), + ("{{#if header}}{{/if footer}}", "doesn't close"), + ("{{#if header}}{{/each header}}", "doesn't close"), + ("{{hea\nder}}", "spans lines"), + ("{{header", "never closed"), + ] { + let err = check(bad).unwrap_err(); + assert!(err.contains(why), "{bad}: {err}"); + } + assert!(check(&"x".repeat(300 * 1024)).unwrap_err().contains("256 KiB")); + } + + #[test] + fn stored_raw_is_escaped() { + let template = parse::("{{!event_title}}|{{event_title}}").unwrap(); + let mut vars = utils::template::Variables::::new(); + vars.insert_single("event_title".into(), "x".into()); + assert_eq!(template.eval(&vars), "<b>x</b>|<b>x</b>"); + } + + #[test] + fn built_ins_use_only_known_variables() { + for (name, text) in [ + ( + "alarm", + include_str!("../../../../resources/html-templates/calendar-alarm.html"), + ), + ( + "invite", + include_str!("../../../../resources/html-templates/calendar-invite.html"), + ), + ] { + walk(text, true).unwrap_or_else(|err| panic!("{name}: {err}")); + } + } + + #[test] + fn page_limit() { + assert!(check_page("{{page_title}}").is_ok()); + assert!(check_page(&"x".repeat(MAX_PAGE_SIZE + 1)).is_err()); + } +} diff --git a/crates/features/src/branding/writes.rs b/crates/features/src/branding/writes.rs new file mode 100644 index 0000000..c369eea --- /dev/null +++ b/crates/features/src/branding/writes.rs @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! What a registry write may set for a logo or template (BT-3, BT-15, +//! BT-22). Only a changed value is checked, so data from before the fork +//! never blocks an unrelated change (BT-4). + +use crate::branding::{logo, templates}; +use jmap_proto::error::set::SetError; +use registry::schema::prelude::{Object, ObjectInner, Property}; + +/// The logo and template fields an object carries, with their checks. +fn fields(inner: &ObjectInner) -> Vec<(Property, Option<&str>, fn(&str) -> Result<(), String>)> { + match inner { + ObjectInner::Enterprise(o) => vec![(Property::LogoUrl, o.logo_url.as_deref(), logo::check)], + ObjectInner::Domain(o) => vec![(Property::Logo, o.logo.as_deref(), logo::check)], + ObjectInner::Tenant(o) => vec![(Property::Logo, o.logo.as_deref(), logo::check)], + ObjectInner::OAuthClient(o) => vec![(Property::Logo, o.logo.as_deref(), logo::check)], + ObjectInner::CalendarAlarm(o) => { + vec![(Property::Template, o.template.as_deref(), templates::check)] + } + ObjectInner::CalendarScheduling(o) => vec![ + ( + Property::EmailTemplate, + o.email_template.as_deref(), + templates::check, + ), + ( + Property::HttpRsvpTemplate, + o.http_rsvp_template.as_deref(), + templates::check_page, + ), + ], + _ => vec![], + } +} + +/// Refuses a new or changed logo or template that breaks its rules, with +/// `invalidProperties` naming the field. +pub fn check(old: Option<&Object>, new: &Object) -> Result<(), SetError> { + let before = old.map(|old| fields(&old.inner)).unwrap_or_default(); + for (property, value, check) in fields(&new.inner) { + let Some(value) = value else { continue }; + let unchanged = before + .iter() + .any(|(p, v, _)| *p == property && *v == Some(value)); + if unchanged { + continue; + } + if let Err(err) = check(value) { + return Err(SetError::invalid_properties() + .with_property(property) + .with_description(err)); + } + } + Ok(()) +} diff --git a/crates/features/src/lib.rs b/crates/features/src/lib.rs index 356aebc..7b3bff0 100644 --- a/crates/features/src/lib.rs +++ b/crates/features/src/lib.rs @@ -18,6 +18,7 @@ //! it. It works on registry objects and the store directly, never on //! `common::Server`. +pub mod branding; pub mod masked_email; pub mod tenancy; pub mod undelete; diff --git a/crates/features/src/tenancy/logo.rs b/crates/features/src/tenancy/logo.rs index b2f976b..c9380ce 100644 --- a/crates/features/src/tenancy/logo.rs +++ b/crates/features/src/tenancy/logo.rs @@ -8,9 +8,10 @@ //! //! Its domain's logo if set, else its tenant's. The value is returned as //! stored, a URL or a data URL: the server never fetches a logo URL itself -//! (MT-23). Branding extends the chain past the tenant (BT-1). +//! (MT-23). Branding extends the chain past the tenant to the server-wide +//! logo (BT-2); none means the client's built-in INBUXA logo. -use registry::schema::structs::{Account, Domain, Tenant}; +use registry::schema::structs::{Account, Domain, Enterprise, Tenant}; use store::RegistryStore; use types::id::Id; @@ -28,29 +29,19 @@ pub async fn for_account(registry: &RegistryStore, account_id: u32) -> trc::Resu Some(tenant_id) => registry.object::(tenant_id).await?, None => None, }; - Ok(applicable( + // BT-2: past the tenant, the server-wide logo; each value as stored, and + // an unusable one skipped (BT-4) + let server = registry + .object::(Id::singleton()) + .await? + .and_then(|e| e.logo_url); + Ok([ domain.as_ref().and_then(|d| d.logo.as_deref()), tenant.as_ref().and_then(|t| t.logo.as_deref()), - ) + server.as_deref(), + ] + .into_iter() + .flatten() + .find(|value| crate::branding::logo::read(value).is_some()) .map(str::to_string)) } - -/// The logo that applies, from the principal's domain's and tenant's logos. -pub fn applicable<'x>(domain: Option<&'x str>, tenant: Option<&'x str>) -> Option<&'x str> { - domain - .filter(|logo| !logo.is_empty()) - .or(tenant.filter(|logo| !logo.is_empty())) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn domain_then_tenant() { - assert_eq!(applicable(Some("d"), Some("t")), Some("d")); - assert_eq!(applicable(None, Some("t")), Some("t")); - assert_eq!(applicable(Some(""), Some("t")), Some("t")); - assert_eq!(applicable(None, None), None); - } -} diff --git a/crates/http/Cargo.toml b/crates/http/Cargo.toml index b07ea9e..7f88d28 100644 --- a/crates/http/Cargo.toml +++ b/crates/http/Cargo.toml @@ -25,6 +25,7 @@ mail-builder = { version = "1.0" } mail-auth = { version = "0.13", features = ["generate", "arc"] } tokio = { version = "1.53", features = ["rt"] } hyper = { version = "1.11.1", features = ["server", "http1", "http2"] } +inbuxa-features = { path = "../features" } hyper-util = { version = "0.1.20", features = ["tokio"] } http-body-util = "0.1.5" async-stream = "0.3.6" diff --git a/crates/http/src/branding.rs b/crates/http/src/branding.rs new file mode 100644 index 0000000..7945c44 --- /dev/null +++ b/crates/http/src/branding.rs @@ -0,0 +1,95 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `GET /logo` (branding spec BT-5 to BT-8) and the RSVP page's answer +//! (BT-20, BT-21). + +use common::Server; +use http_proto::{HttpRequest, HttpResponse}; +use hyper::{ + StatusCode, + header::{self, CONTENT_ENCODING}, +}; +use inbuxa_features::branding::{logo::Logo, templates}; + +const LOGO_CACHE: &str = "public, max-age=300"; +const SVG_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; +const RSVP_CSP: &str = concat!( + "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'" +); + +/// The domain a `/logo` request asks about: `?domain=`, else the `Host` +/// (BT-2). +fn requested_domain(req: &HttpRequest) -> String { + req.uri() + .query() + .and_then(|query| { + http_proto::form_urlencoded::parse(query.as_bytes()) + .find(|(key, _)| key == "domain") + .map(|(_, value)| value.into_owned()) + }) + .filter(|domain| !domain.is_empty()) + .or_else(|| { + req.headers() + .get(header::HOST) + .and_then(|host| host.to_str().ok()) + .map(str::to_string) + }) + .unwrap_or_default() +} + +/// `GET /logo`: the image, a redirect to a URL logo, or `404` when no +/// custom logo applies. The server never fetches a URL (BT-7). +pub async fn logo(server: &Server, req: &HttpRequest) -> trc::Result { + let response = match server + .logos_for(&requested_domain(req)) + .await? + .into_iter() + .next() + { + Some(Logo::Image { + content_type, + bytes, + }) => { + let response = HttpResponse::new(StatusCode::OK) + .with_content_type(content_type) + .with_binary_body(bytes); + if content_type == "image/svg+xml" { + // BT-8: a scripted SVG must never run on the server's origin + response.with_header(header::CONTENT_SECURITY_POLICY, SVG_CSP) + } else { + response + } + } + Some(Logo::Url(url)) => { + HttpResponse::new(StatusCode::FOUND).with_header(header::LOCATION, url) + } + None => HttpResponse::new(StatusCode::NOT_FOUND), + }; + Ok(response + .with_cache_control(LOGO_CACHE) + .with_header(header::X_CONTENT_TYPE_OPTIONS, "nosniff") + .with_header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")) +} + +/// `GET /calendar/rsvp`: the operator's page byte for byte if one is set, +/// else the built-in, with headers that keep the token on the server +/// (BT-20, BT-21). +pub async fn rsvp_page(server: &Server, built_in_gzipped: &'static [u8]) -> trc::Result { + let response = match templates::rsvp_page(server.registry()).await? { + Some(page) => HttpResponse::new(StatusCode::OK).with_binary_body(page.into_bytes()), + None => HttpResponse::new(StatusCode::OK) + .with_header(CONTENT_ENCODING, "gzip") + .with_binary_body(built_in_gzipped), + }; + Ok(response + .with_content_type("text/html; charset=utf-8") + .with_no_store() + .with_header(header::REFERRER_POLICY, "no-referrer") + .with_header(header::CONTENT_SECURITY_POLICY, RSVP_CSP)) +} diff --git a/crates/http/src/lib.rs b/crates/http/src/lib.rs index f5adc5b..e8bb3be 100644 --- a/crates/http/src/lib.rs +++ b/crates/http/src/lib.rs @@ -8,6 +8,7 @@ pub mod api; pub mod auth; +pub mod branding; // inbuxa: branding pub mod form; pub mod request; diff --git a/crates/http/src/request.rs b/crates/http/src/request.rs index 547ba69..34695f4 100644 --- a/crates/http/src/request.rs +++ b/crates/http/src/request.rs @@ -474,14 +474,16 @@ impl ParseHttp for Server { && req.method() == Method::GET && path.next().unwrap_or_default() == "rsvp" { - - return Ok(HttpResponse::new(StatusCode::OK) - .with_content_type("text/html; charset=utf-8") - .with_header(CONTENT_ENCODING, "gzip") - .with_binary_body(RSVP_PAGE) - .with_no_store()); + // inbuxa: BT-20, BT-21 + return crate::branding::rsvp_page(self, RSVP_PAGE).await; } } + // inbuxa: BT-5: the logo that applies, anonymous + "logo" if req.method() == Method::GET => { + self.is_http_anonymous_request_allowed(session.remote_ip) + .await?; + return crate::branding::logo(self, &req).await; + } "autodiscover" | "Autodiscover" | "AutoDiscover" => { let document_name = path.next().unwrap_or_default(); if req.method() == Method::POST diff --git a/crates/jmap/src/registry/set.rs b/crates/jmap/src/registry/set.rs index fea2a61..711ddb1 100644 --- a/crates/jmap/src/registry/set.rs +++ b/crates/jmap/src/registry/set.rs @@ -572,6 +572,22 @@ impl RegistrySet for Server { } }; + // inbuxa: BT-3, BT-15, BT-22: logos and templates follow their rules + let before = match &modification { + Modification::Update { object, .. } + | Modification::Create { + object: Some(object), + .. + } => Some(object), + Modification::Create { object: None, .. } => None, + }; + if let Err(err) = + inbuxa_features::branding::writes::check(before, &new_object) + { + set.failed(modification, err); + continue 'outer; + } + // inbuxa: UD-16: a kept account's addresses stay its own if let Some(err) = crate::inbuxa::deleted_account::reserved(self, stored, &new_object).await? diff --git a/crates/services/src/task_manager/alarm.rs b/crates/services/src/task_manager/alarm.rs index bb7fab9..45701da 100644 --- a/crates/services/src/task_manager/alarm.rs +++ b/crates/services/src/task_manager/alarm.rs @@ -553,8 +553,17 @@ async fn build_template( }; + // inbuxa: BT-11, BT-18: the operator's template, read now, else the built-in #[cfg(not(feature = "enterprise"))] - let template = &server.core.groupware.alarms_template; + let custom = inbuxa_features::branding::templates::stored( + server.registry(), + inbuxa_features::branding::templates::Which::Alarm, + ) + .await + .ok() + .flatten(); + #[cfg(not(feature = "enterprise"))] + let template = custom.as_ref().unwrap_or(&server.core.groupware.alarms_template); let formatter = TextFormatter::new(account_info.locale().as_str())?; let locale = formatter.locale; diff --git a/crates/services/src/task_manager/imip.rs b/crates/services/src/task_manager/imip.rs index fdd82fb..28b53bb 100644 --- a/crates/services/src/task_manager/imip.rs +++ b/crates/services/src/task_manager/imip.rs @@ -323,8 +323,17 @@ pub async fn build_itip_template( summary: &ItipSummary, logo_cid: &str, ) -> trc::Result
{ + // inbuxa: BT-11, BT-18: the operator's template, read now, else the built-in #[cfg(not(feature = "enterprise"))] - let template = &server.core.groupware.itip_template; + let custom = inbuxa_features::branding::templates::stored( + server.registry(), + inbuxa_features::branding::templates::Which::Invite, + ) + .await + .ok() + .flatten(); + #[cfg(not(feature = "enterprise"))] + let template = custom.as_ref().unwrap_or(&server.core.groupware.itip_template); let formatter = TextFormatter::new(account_info.locale().as_str())?; let locale = formatter.locale; diff --git a/docs/spec/SPEC.md b/docs/spec/SPEC.md index 3012a43..60a064c 100644 --- a/docs/spec/SPEC.md +++ b/docs/spec/SPEC.md @@ -278,7 +278,7 @@ is written. | 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. Built 2026-09-18 in `crates/features`; status in `features/multi-tenancy.md`. | | 2 | Masked email | Per-sender disposable addresses that deliver to the account | Existing addresses must keep delivering (§3.4). Built 2026-09-18 in `crates/features`; status in `features/masked-email.md`. | | 3 | Undelete | Deleted mail held for a set period and restorable | Existing archived items must stay restorable. Built 2026-09-18 in `crates/features`; status in `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`. | +| 4 | Branding and templates | Operator logo, and the text of calendar alarm and invitation emails | INBUXA's branding is the default. Built 2026-09-18 in `crates/features`; status in `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`. | diff --git a/docs/spec/features/branding-and-templates.md b/docs/spec/features/branding-and-templates.md index e3a2055..0d0a6aa 100644 --- a/docs/spec/features/branding-and-templates.md +++ b/docs/spec/features/branding-and-templates.md @@ -379,6 +379,33 @@ the operator's approval first, as with the other features' probes. 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. +## Implementation status + +Built 2026-09-18 from this spec, clean-room, under the multi-tenancy hand-off +brief's rules. The rules live in `crates/features` (`inbuxa-features`, module +`branding`); the domain lookup in `crates/common/src/storage/branding.rs`; +`/logo` and the RSVP page's answer in `crates/http/src/branding.rs`; upstream +files carry hooks marked `inbuxa:`. Acceptance tests 1 to 17 pass as +`tests/src/system/branding.rs`. + +- **BT-1 to BT-26:** built. +- **ihasmail changes** belong to ihasmail-inbuxa and aren't part of this + repository. +- **Test 18 (compat)** is written as `branding_compat`, ignored, and unrun + until a copy of INBUXA's data is provided. INBUXA holds no logos or + templates (observed 1), so it has nothing to carry today. +- **Known limits, not requirements of this spec:** + - Logos are read from the registry on each `/logo` request and each email, + not cached. That is what makes BT-10 hold on every node with nothing to + invalidate; the existing logo cache is left unused. + - Test 9 runs on one node. The cluster half of BT-10 follows from reading + the registry each time, and isn't exercised by a test. + - Test 7 checks the sandboxing header. That the script doesn't run is the + browser honouring it, which no test here drives. + - Test 17 checks the pages' source, not a browser loading them. + - `httpRsvpEnable` still needs a settings reload, as upstream (it isn't one + of this spec's fields); the templates and logos don't (BT-18, BT-10). + ## Observed Settled on 2026-09-18 against INBUXA's live Enterprise server (Stalwart @@ -414,5 +441,14 @@ account. No upstream code was read. 6. **The stored `fromName`** (open question 7). INBUXA stores `INBUXA Calendar` as 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. +7. **INBUXA Admin's logo** (open question 8), checked 2026-09-18 in the + `inbuxa-admin` source, not on a server. It requests `/logo` with no + parameter (so the server goes by `Host`), through `fetch()`, and draws its + built-in logo whenever the answer isn't an `image/*` response. It reads no + logo fields and never asks the server to fetch a URL. Against BT-5, a + data-URL logo shows; a URL logo's cross-origin redirect falls back to the + built-in unless the logo's host sends CORS headers. Moving it to an image + element, as BT-26 does for the server's own pages, is a change for that + repository. + +Not settled: open question 5 (invalid templates on write) needs a write. diff --git a/docs/spec/features/multi-tenancy.md b/docs/spec/features/multi-tenancy.md index 9c559bd..32839af 100644 --- a/docs/spec/features/multi-tenancy.md +++ b/docs/spec/features/multi-tenancy.md @@ -264,8 +264,8 @@ Each requirement has an ID, and tests name the IDs they check. **Decision** (2026-09-18) on the shape: in the JMAP session, the principal's own account's `accountCapabilities` carry `urn:inbuxa:jmap` (contract C-1) with `logo`: a string (the URL or data URL as stored) or - `null`. Until branding is built it follows this chain, steps 1 and 2 of - BT-1. + `null`. With branding built (2026-09-18) it follows BT-1 steps 1 to 3, + skipping unusable values (BT-4); `null` means the built-in logo, step 4. - **MT-23.** The server never fetches a logo URL itself. ihasmail draws URL logos through its image proxy, as it does today. diff --git a/resources/html-templates/calendar-rsvp.html b/resources/html-templates/calendar-rsvp.html index 896c450..1f940c8 100644 --- a/resources/html-templates/calendar-rsvp.html +++ b/resources/html-templates/calendar-rsvp.html @@ -1055,28 +1055,18 @@ function showDefault() { wrap.removeAttribute('data-loading'); } var target = apiUrl.replace(/\/api\/calendar\/rsvp$/, '') + '/logo'; if (domain) target += '?domain=' + encodeURIComponent(domain); - fetch(target, { method: 'GET', credentials: 'omit' }) - .then(function (res) { - if (!res.ok) return null; - var ct = (res.headers.get('content-type') || '').toLowerCase(); - if (ct.indexOf('image/') !== 0) return null; - return res.blob(); - }) - .then(function (blob) { - if (!blob) { showDefault(); return; } - var objectUrl = URL.createObjectURL(blob); - var img = new Image(); - img.className = 'custom-logo'; - img.alt = 'Logo'; - img.onload = function () { - while (wrap.firstChild) wrap.removeChild(wrap.firstChild); - wrap.appendChild(img); - wrap.removeAttribute('data-loading'); - }; - img.onerror = function () { URL.revokeObjectURL(objectUrl); showDefault(); }; - img.src = objectUrl; - }) - .catch(function () { showDefault(); }); + // An image element, not fetch: it can follow /logo's redirect to + // a URL logo on another origin (branding BT-26) + var img = new Image(); + img.className = 'custom-logo'; + img.alt = 'Logo'; + img.onload = function () { + while (wrap.firstChild) wrap.removeChild(wrap.firstChild); + wrap.appendChild(img); + wrap.removeAttribute('data-loading'); + }; + img.onerror = showDefault; + img.src = target; } function findOrganizer(resp) { diff --git a/resources/html-templates/calendar-rsvp.html.min b/resources/html-templates/calendar-rsvp.html.min index e902ebb..ba01132 100644 --- a/resources/html-templates/calendar-rsvp.html.min +++ b/resources/html-templates/calendar-rsvp.html.min @@ -1 +1 @@ - Respond to invitation · INBUXA
\ No newline at end of file + Respond to invitation · INBUXA
\ No newline at end of file diff --git a/resources/html-templates/calendar-rsvp.html.min.gz b/resources/html-templates/calendar-rsvp.html.min.gz index 40c18d31ccc3fa7e0e7926b0a0d531beac3a0b49..53c47aa94430fa1a0e9a5c30d0f8d8a477774784 100644 GIT binary patch delta 10647 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 delta 10737 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 diff --git a/resources/html-templates/login.html b/resources/html-templates/login.html index 4e50d52..78ca1c2 100644 --- a/resources/html-templates/login.html +++ b/resources/html-templates/login.html @@ -390,34 +390,18 @@ target = '/logo?domain=' + encodeURIComponent(loginHint.slice(at + 1).toLowerCase()); } } - fetch(target, { method: 'GET', credentials: 'same-origin' }) - .then(function (res) { - if (!res.ok) return null; - var ct = (res.headers.get('content-type') || '').toLowerCase(); - if (ct.indexOf('image/') !== 0) return null; - return res.blob(); - }) - .then(function (blob) { - if (!blob) { showDefault(); return; } - var objectUrl = URL.createObjectURL(blob); - var img = new Image(); - img.className = 'custom-logo'; - img.alt = 'Logo'; - img.onload = function () { - while (wrap.firstChild) wrap.removeChild(wrap.firstChild); - wrap.appendChild(img); - wrap.removeAttribute('data-loading'); - }; - img.onerror = function () { - URL.revokeObjectURL(objectUrl); - showDefault(); - }; - img.src = objectUrl; - }) - .catch(function (err) { - console.log('Custom logo unavailable:', err); - showDefault(); - }); + // An image element, not fetch: it can follow /logo's redirect to + // a URL logo on another origin (branding BT-26) + var img = new Image(); + img.className = 'custom-logo'; + img.alt = 'Logo'; + img.onload = function () { + while (wrap.firstChild) wrap.removeChild(wrap.firstChild); + wrap.appendChild(img); + wrap.removeAttribute('data-loading'); + }; + img.onerror = showDefault; + img.src = target; })(); function buildRequest(creds, otpValue) { diff --git a/resources/html-templates/login.html.min b/resources/html-templates/login.html.min index 52a8a83..c6520b9 100644 --- a/resources/html-templates/login.html.min +++ b/resources/html-templates/login.html.min @@ -1 +1 @@ - Sign in · INBUXA

Sign in

Enter your credentials to continue

\ No newline at end of file + Sign in · INBUXA

Sign in

Enter your credentials to continue

\ No newline at end of file diff --git a/resources/html-templates/login.html.min.gz b/resources/html-templates/login.html.min.gz index 1b0af21b37e11bffa4480c70fc3500b4b2c73d91..72e8bfc206f697fc7a640fc1a5fb89dde616f680 100644 GIT binary patch delta 9792 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 A-T(jq diff --git a/tests/src/system/branding.rs b/tests/src/system/branding.rs new file mode 100644 index 0000000..e7f9317 --- /dev/null +++ b/tests/src/system/branding.rs @@ -0,0 +1,808 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Branding and templates acceptance tests, from +//! `docs/spec/features/branding-and-templates.md`. Each check names the test +//! number or the requirement it covers. + +use crate::utils::{ + account::Account, + webdav::DummyWebDavClient, + server::{TestServer, TestServerBuilder}, +}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use calcard::{common::timezone::Tz, icalendar::ICalendarMethod}; +use calcard::icalendar::{ICalendarParticipationStatus, ICalendarProperty}; +use email::cache::MessageCacheFetch; +use groupware::scheduling::{ItipField, ItipParticipant, ItipSummary, ItipTime, ItipValue}; +use hyper::StatusCode; +use jmap_proto::error::set::SetErrorType; +use mail_parser::{MessageParser, MimeHeaders}; +use registry::types::EnumImpl; +use registry::schema::{ + prelude::{Object, ObjectInner, ObjectType, Property}, + structs::{ + CalendarAlarm, CalendarScheduling, CertificateManagement, DkimManagement, DnsManagement, + Domain, Enterprise, Tenant, UserRoles, + }, +}; +use serde_json::json; +use services::task_manager::imip::build_itip_template; +use std::{str::FromStr, time::Duration}; +use store::{ + registry::write::{RegistryWrite, RegistryWriteResult}, + write::now, +}; +use trc::{Collector, EventType, RegistryEvent}; +use types::id::Id; + +const SECRET: &str = "branding test user passphrase"; +const PNG: &[u8] = include_bytes!("../../../resources/branding/email-logo.png"); +const SVG_SCRIPTED: &[u8] = + b""; + +/// A template that shows every alarm variable (BT-13), each in a marker. +const ALARM_TEMPLATE: &str = concat!( + "CUSTOM[{{page_title}}]", + "", + "{{#if header}}H[{{header}}]{{/if header}}", + "{{#if event_title}}T[{{event_title}}]{{/if event_title}}", + "{{#if event_description}}D[{{event_description}}]{{/if event_description}}", + "{{#each event_details}}K[{{key}}]V[{{value}}]{{#if link}}L[{{link}}]{{/if link}}{{/each event_details}}", + "AT[{{attendees_title}}]{{#each attendees}}A[{{key}}|{{value}}]{{/each attendees}}", + "N[{{action_name}}]F[{{footer}}]" +); + +/// A template for iMIP messages (BT-13). +const INVITE_TEMPLATE: &str = concat!( + "CUSTOM[{{page_title}}]", + "{{#if header}}H[{{header}}|{{color}}]{{/if header}}", + "{{#if event_title}}T[{{event_title}}]{{/if event_title}}", + "{{#each event_details}}K[{{key}}]V[{{value}}]{{#if changed}}OLD[{{old_value}}]{{/if changed}}{{/each event_details}}", + "{{#if attendees}}AT[{{attendees_title}}]{{/if attendees}}", + "{{#if rsvp}}R[{{rsvp}}]{{/if rsvp}}", + "{{#each actions}}ACT[{{action_name}}]{{/each actions}}", + "{{#each footer}}F[{{key}}]{{/each footer}}" +); + +pub async fn test(test: &mut TestServer) { + println!("Running branding tests..."); + let admin = test.account("admin@example.org"); + let t_id = admin + .registry_create_object(Tenant { + name: "brand-t".to_string(), + ..Default::default() + }) + .await; + let t_domain = admin.brand_domain("t-brand.example.org", Some(t_id)).await; + let plain = admin.brand_domain("plain-brand.example.org", None).await; + let t_admin = admin + .create_user_account("tadmin@t-brand.example.org", SECRET, "T admin", &[], vec![]) + .await; + admin + .registry_update_object( + ObjectType::Account, + t_admin.id(), + json!({ Property::Roles: UserRoles::Admin }), + ) + .await; + let user = admin + .create_user_account( + "alarm@plain-brand.example.org", + SECRET, + "Alarm user", + &[], + vec![], + ) + .await; + let png_url = data_url("image/png", PNG); + + // Acceptance test 1: nothing set, no custom logo anywhere + let answer = get(&admin, "/logo?domain=plain-brand.example.org", None).await; + assert_eq!(answer.status, StatusCode::NOT_FOUND, "test 1"); + answer.assert_logo_headers("test 1"); + assert!( + test.server + .logo_resource("plain-brand.example.org") + .await + .unwrap() + .is_none(), + "test 1: emails keep the built-in" + ); + + // Acceptance test 8: logo writes (BT-3) + for bad in [ + "javascript:alert(1)".to_string(), + "data:text/html,x".to_string(), + data_url("image/png", &[PNG, &vec![0u8; 300 * 1024]].concat()), + data_url("image/png", &[0xFF, 0xD8, 0xFF, 0xE0, 0, 0x10]), + ] { + admin + .registry_update_object_expect_err(ObjectType::Domain, plain, json!({"logo": bad})) + .await + .assert_type(SetErrorType::InvalidProperties) + .assert_properties(&["logo"]); + } + + // Acceptance test 12: template writes (BT-15) + for (bad, why) in [ + ("{{#if header}}x", "never closed"), + ("{{unknown}}", "Unknown variable"), + ("{{!header}}", "raw output"), + ( + "{{#each actions}}{{#each attendees}}{{/each attendees}}{{/each actions}}", + "inside another", + ), + ] { + admin + .registry_update_object_expect_err( + ObjectType::CalendarAlarm, + Id::singleton(), + json!({"template": bad}), + ) + .await + .assert_type(SetErrorType::InvalidProperties) + .assert_properties(&["template"]) + .assert_description_contains(why); + } + admin + .registry_update_object_expect_err( + ObjectType::CalendarScheduling, + Id::singleton(), + json!({"emailTemplate": "x".repeat(300 * 1024)}), + ) + .await + .assert_type(SetErrorType::InvalidProperties) + .assert_properties(&["emailTemplate"]); + + // Acceptance test 2: a server-wide PNG logo is served and embedded + admin + .registry_update_setting( + Enterprise { + logo_url: Some(png_url.clone()), + ..Default::default() + }, + &[Property::LogoUrl], + ) + .await; + let answer = get(&admin, "/logo?domain=plain-brand.example.org", None).await; + assert_eq!(answer.status, StatusCode::OK, "test 2"); + assert_eq!(answer.header("content-type"), "image/png", "test 2"); + assert_eq!(answer.body, PNG, "test 2"); + answer.assert_logo_headers("test 2"); + + // Acceptance tests 10 and 14: a custom alarm template, used for the next + // alarm with no reload, every variable filled and values escaped; the + // email embeds the server-wide PNG (test 2) + admin + .registry_update_setting( + CalendarAlarm { + template: Some(ALARM_TEMPLATE.to_string()), + ..Default::default() + }, + &[Property::Template], + ) + .await; + let (html, logo) = alarm_email(test, &user).await; + for marker in [ + "CUSTOM[", "H[", "T[", "D[", "K[", "V[", "AT[", "A[", "N[", "F[", + ] { + assert!( + html.contains(marker) && !html.contains(&format!("{marker}]")), + "test 10: {marker} filled in {html}" + ); + } + assert!(html.contains("lang=\"en"), "test 10: lang in {html}"); + assert!(html.contains("L[https://meet.example.com/brand]"), "test 10: link in {html}"); + assert!(html.contains("src=\"cid:"), "test 10: logo_cid in {html}"); + assert!(html.contains("href=\"webcal"), "test 10: action_url in {html}"); + assert!( + html.contains("T[<b>x</b>]"), + "test 10: the title is text, not HTML: {html}" + ); + assert_eq!(logo, Some(("image/png".to_string(), PNG.to_vec())), "test 2"); + + // Acceptance test 3: the tenant's logo, then the domain's own + admin + .registry_update_object( + ObjectType::Tenant, + t_id, + json!({"logo": "https://logo.example.org/t.png"}), + ) + .await; + let answer = get(&admin, "/logo?domain=t-brand.example.org", None).await; + assert_eq!(answer.status, StatusCode::FOUND, "test 3"); + assert_eq!(answer.header("location"), "https://logo.example.org/t.png"); + let gif = data_url("image/gif", b"GIF89a\x01\0\x01\0\0\0\0;"); + admin + .registry_update_object(ObjectType::Domain, t_domain, json!({"logo": gif})) + .await; + let answer = get(&admin, "/logo?domain=t-brand.example.org", None).await; + assert_eq!(answer.header("content-type"), "image/gif", "test 3: domain wins"); + + // Acceptance test 4: a subdomain finds its domain, by parameter or Host + let by_param = get(&admin, "/logo?domain=mail.t-brand.example.org", None).await; + let by_host = get(&admin, "/logo", Some("mail.t-brand.example.org:443")).await; + for answer in [by_param, by_host] { + assert_eq!(answer.header("content-type"), "image/gif", "test 4"); + } + + // Acceptance test 5: a URL logo is redirected to, never fetched, and + // emails fall back to the next logo they can carry + admin + .registry_update_object( + ObjectType::Domain, + plain, + json!({"logo": "https://192.0.2.1/logo.png"}), + ) + .await; + let started = std::time::Instant::now(); + let answer = get(&admin, "/logo?domain=plain-brand.example.org", None).await; + assert_eq!(answer.status, StatusCode::FOUND, "test 5"); + assert_eq!(answer.header("location"), "https://192.0.2.1/logo.png"); + // TEST-NET-1 never answers: a fetch would hang, a redirect is instant + assert!(started.elapsed() < Duration::from_secs(2), "test 5: no fetch"); + let resource = test + .server + .logo_resource("plain-brand.example.org") + .await + .unwrap() + .expect("test 5: the server-wide PNG"); + assert_eq!(resource.contents, PNG, "test 5"); + + // Acceptance test 6: an unknown domain answers as a known one with no + // logo of its own + admin + .registry_update_object(ObjectType::Domain, plain, json!({"logo": null})) + .await; + let known = get(&admin, "/logo?domain=plain-brand.example.org", None).await; + let unknown = get(&admin, "/logo?domain=nowhere.example.net", None).await; + assert_eq!(known.status, unknown.status, "test 6"); + assert_eq!(known.body, unknown.body, "test 6"); + assert_eq!(known.header("content-type"), unknown.header("content-type")); + + // Acceptance tests 7 and 9: an SVG is sandboxed, and a change shows on the + // next request with no restart + admin + .registry_update_object( + ObjectType::Domain, + plain, + json!({"logo": data_url("image/svg+xml", SVG_SCRIPTED)}), + ) + .await; + let answer = get(&admin, "/logo?domain=plain-brand.example.org", None).await; + assert_eq!(answer.header("content-type"), "image/svg+xml", "test 9"); + assert_eq!( + answer.header("content-security-policy"), + "default-src 'none'; style-src 'unsafe-inline'; sandbox", + "test 7" + ); + + // Acceptance test 11: a custom iMIP template for each kind of message + admin + .registry_update_setting( + CalendarScheduling { + email_template: Some(INVITE_TEMPLATE.to_string()), + ..Default::default() + }, + &[Property::EmailTemplate], + ) + .await; + for (kind, summary) in itip_summaries() { + let body = itip_html(test, &user, &summary).await; + assert!(body.contains("CUSTOM["), "test 11 {kind}: {body}"); + match kind { + "invite" => { + assert!(body.contains("ACT["), "test 11: RSVP actions, {body}"); + assert!(body.contains("AT["), "test 11: attendees, {body}"); + } + "update" => { + assert!(body.contains("OLD[Dinner]"), "test 11: old value, {body}"); + assert!(body.contains("|info]"), "test 11: color, {body}"); + } + "cancel" => assert!(body.contains("|danger]"), "test 11: {body}"), + _ => assert!(body.contains("H["), "test 11 reply: {body}"), + } + } + + // Acceptance test 13: stored templates from before the fork. Raw output + // is escaped; one that doesn't parse leaves the built-in in use, with a + // warning at each reload + store_directly( + test, + ObjectInner::CalendarScheduling(CalendarScheduling { + email_template: Some("RAW[{{!event_title}}]".to_string()), + ..Default::default() + }), + ) + .await; + let body = itip_html(test, &user, &itip_summaries()[0].1).await; + assert!(body.contains("RAW[<b>x</b>]"), "test 13: {body}"); + store_directly( + test, + ObjectInner::CalendarScheduling(CalendarScheduling { + email_template: Some("{{#if header}}unclosed".to_string()), + ..Default::default() + }), + ) + .await; + let body = itip_html(test, &user, &itip_summaries()[0].1).await; + assert!( + !body.contains("unclosed") && body.contains(""), + "test 13: the built-in is used" + ); + let warning = EventType::Registry(RegistryEvent::BuildWarning).to_id() as usize; + let warnings = Collector::read_metric_counter(warning); + admin.reload_settings().await; + let after = Collector::read_metric_counter(warning); + assert!( + after > warnings || !Collector::is_metric(warning), + "test 13: registry.build-warning" + ); + + // Acceptance test 15: a custom RSVP page, byte for byte, with BT-21's + // headers + let page = "<!doctype html><title>{{page_title}}

Custom RSVP

"; + admin + .registry_update_setting( + CalendarScheduling { + http_rsvp_template: Some(page.to_string()), + ..Default::default() + }, + &[Property::HttpRsvpTemplate], + ) + .await; + let answer = get(&admin, "/calendar/rsvp?token=x", None).await; + assert_eq!(answer.status, StatusCode::OK, "test 15"); + assert_eq!(answer.body, page.as_bytes(), "test 15: byte for byte"); + assert_eq!(answer.header("referrer-policy"), "no-referrer", "test 15"); + assert!(answer.header("cache-control").contains("no-store"), "test 15"); + assert!( + answer + .header("content-security-policy") + .contains("connect-src 'self'"), + "test 15" + ); + admin + .registry_update_setting( + CalendarScheduling { + http_rsvp_enable: false, + ..Default::default() + }, + &[Property::HttpRsvpEnable], + ) + .await; + admin.reload_settings().await; + assert_ne!( + get(&admin, "/calendar/rsvp?token=x", None).await.status, + StatusCode::OK, + "test 15: page gone" + ); + let api = post(&admin, "/api/calendar/rsvp", "{\"token\":\"x\"}").await; + assert_ne!(api.status, StatusCode::OK, "test 15: API gone"); + admin + .registry_update_setting( + CalendarScheduling { + http_rsvp_enable: true, + ..Default::default() + }, + &[Property::HttpRsvpEnable], + ) + .await; + admin.reload_settings().await; + + // Acceptance test 16: a tenant administrator sets its domain's logo, but + // nothing server-wide and not its tenant's + t_admin + .registry_update_object(ObjectType::Domain, t_domain, json!({"logo": png_url})) + .await; + for (object, id, patch) in [ + ( + ObjectType::Enterprise, + Id::singleton(), + json!({"logoUrl": "https://logo.example.org/x.png"}), + ), + ( + ObjectType::CalendarAlarm, + Id::singleton(), + json!({"template": "

{{header}}

"}), + ), + ( + ObjectType::Tenant, + t_id, + json!({"logo": "https://logo.example.org/x.png"}), + ), + ] { + let response = t_admin.registry_update(object, [(id, patch)]).await; + let refused = response + .0 + .pointer(&format!("/methodResponses/0/1/notUpdated/{id}")) + .is_some() + || response.0.pointer("/methodResponses/0/0") == Some(&json!("error")); + assert!(refused, "test 16: {object:?} {response:?}"); + } + + // Acceptance test 17: the built-in pages load /logo through an image + // element, which follows a redirect and falls back on error (BT-26) + for page in [ + include_str!("../../../resources/html-templates/login.html"), + include_str!("../../../resources/html-templates/calendar-rsvp.html"), + ] { + assert!(page.contains("img.src = target"), "test 17"); + assert!(page.contains("img.onerror = showDefault"), "test 17"); + assert!(!page.contains("fetch(target"), "test 17"); + } + + // Clean up + admin + .registry_update_setting(Enterprise::default(), &[Property::LogoUrl]) + .await; + admin + .registry_update_setting(CalendarAlarm::default(), &[Property::Template]) + .await; + admin + .registry_update_setting( + CalendarScheduling::default(), + &[Property::EmailTemplate, Property::HttpRsvpTemplate], + ) + .await; + admin.reload_settings().await; + admin.destroy_account(user).await; + admin.destroy_account(t_admin).await; + for domain in [plain, t_domain] { + admin + .registry_destroy(ObjectType::Domain, [domain]) + .await + .assert_destroyed(&[domain]); + } + admin + .registry_destroy(ObjectType::Tenant, [t_id]) + .await + .assert_destroyed(&[t_id]); + test.wait_for_tasks().await; +} + +/// Runs the branding tests alone: +/// `cargo test -p tests branding_tests -- --ignored`. +#[ignore] +#[tokio::test(flavor = "multi_thread")] +pub async fn branding_tests() { + let mut test = TestServerBuilder::new("branding_tests") + .await + .with_default_listeners() + .await + .build() + .await; + let admin = test.create_admin_account("admin@example.org").await; + test.insert_account(admin); + self::test(&mut test).await; + if test.is_reset() { + test.temp_dir.delete(); + } +} + +/// Acceptance test 18 (compat): the logos and templates INBUXA holds read +/// back unchanged, and render or are served as before cutover. +/// +/// INBUXA holds none (spec, observed 1), so this checks whatever a copy of +/// its data holds: every domain's and tenant's logo, `logoUrl`, and the +/// three templates read back as stored, logos are served by `/logo` or +/// skipped (BT-4), and templates render or fall back (BT-19). Run it with: +/// +/// - `INBUXA_COMPAT_ADMIN`: `name:password` of a server-level administrator +/// in that data; +/// +/// and the data itself in place of the test store: `NO_INSERT=1` and the +/// store's `TMPDIR`/`STORE` pointing at the copy, so it isn't reset. +#[ignore] +#[tokio::test(flavor = "multi_thread")] +pub async fn branding_compat() { + let admin = std::env::var("INBUXA_COMPAT_ADMIN").expect("INBUXA_COMPAT_ADMIN"); + assert!( + std::env::var("NO_INSERT").is_ok(), + "NO_INSERT must be set, or the copy of INBUXA's data is wiped" + ); + let test = TestServerBuilder::new("branding_compat") + .await + .with_default_listeners() + .await + .build_with_opts(false) + .await; + let (name, secret) = admin.split_once(':').expect("name:password"); + let admin = Account::new( + Box::leak(name.to_string().into_boxed_str()), + Box::leak(secret.to_string().into_boxed_str()), + &[], + "Compat admin", + Id::from(u32::MAX), + ); + + // Every domain's logo reads back and is served or skipped, never an error + let domains = admin + .jmap_method_call( + "x:Domain/get", + json!({"ids": null, "properties": ["name", "logo"]}), + ) + .await; + for domain in domains.list() { + let name = domain["name"].as_str().unwrap(); + let answer = get(&admin, &format!("/logo?domain={name}"), None).await; + assert!( + matches!( + answer.status, + StatusCode::OK | StatusCode::FOUND | StatusCode::NOT_FOUND + ), + "{name}: {:?}", + answer.status + ); + } + + // Each stored template renders or falls back; the server keeps running + for (object, fields) in [ + (ObjectType::CalendarAlarm, vec!["template"]), + ( + ObjectType::CalendarScheduling, + vec!["emailTemplate", "httpRsvpTemplate"], + ), + ] { + let response = admin + .jmap_method_call( + &format!("x:{}/get", object.as_str()), + json!({"ids": ["singleton"], "properties": fields}), + ) + .await; + assert!(!response.list().is_empty(), "{object:?}: {response:?}"); + } + let _ = test; +} + +/// Writes a setting straight into the registry, skipping `/set`'s checks, +/// as data from before the fork would be. +async fn store_directly(test: &TestServer, inner: ObjectInner) { + let object = Object { inner, revision: 0 }; + let registry = test.server.registry(); + let old = registry + .get(registry::types::id::ObjectId::new( + object.object_type(), + Id::singleton(), + )) + .await + .unwrap(); + let result = match &old { + Some(old) => registry + .write(RegistryWrite::Update { + object: &object, + id: Id::singleton(), + old_object: old, + }) + .await + .unwrap(), + None => registry + .write(RegistryWrite::Insert { + object: &object, + id: Some(Id::singleton()), + }) + .await + .unwrap(), + }; + assert!(matches!(result, RegistryWriteResult::Success(_))); +} + +/// An alarm email for `user`: its HTML and its inline logo part. +async fn alarm_email(test: &TestServer, user: &Account) -> (String, Option<(String, Vec)>) { + let account_id = user.id().document_id(); + let start = now() as i64 + 4; + let event = ALARM_EVENT + .replace("$START", &ical_time(start)) + .replace("$END", &ical_time(start + 3600)); + DummyWebDavClient::new(account_id, user.name(), SECRET, "alarm@plain-brand.example.org") + .request_with_headers( + "PUT", + "/dav/cal/alarm%40plain-brand.example.org/default/brand-alarm.ics", + [("content-type", "text/calendar; charset=utf-8")], + event, + ) + .await + .with_status(StatusCode::CREATED); + + // The alarm fires 2s before the start + for _ in 0..40 { + tokio::time::sleep(Duration::from_millis(250)).await; + let messages = test.server.get_cached_messages(account_id).await.unwrap(); + if let Some(message) = messages.emails.items.first() { + let raw = test.fetch_email(account_id, message.document_id).await; + let message = MessageParser::new().parse(&raw).unwrap(); + let html = String::from_utf8( + message.html_bodies().next().unwrap().contents().to_vec(), + ) + .unwrap(); + let logo = message.attachments().find(|part| part.content_id().is_some()).map(|part| { + ( + part.content_type() + .map(|ct| format!("{}/{}", ct.ctype(), ct.subtype().unwrap_or_default())) + .unwrap_or_default(), + part.contents().to_vec(), + ) + }); + return (html, logo); + } + } + panic!("no alarm email arrived"); +} + +fn ical_time(timestamp: i64) -> String { + mail_parser::DateTime::from_timestamp(timestamp) + .to_rfc3339() + .replace(['-', ':'], "") +} + +/// An iMIP body rendered as the sender would render it now. +async fn itip_html(test: &TestServer, user: &Account, summary: &ItipSummary) -> String { + let account_id = user.id().document_id(); + let account_info = test.server.account_info(account_id).await.unwrap(); + build_itip_template( + &test.server, + &account_info, + account_id, + 1, + "alarm@plain-brand.example.org", + "guest@example.net", + summary, + "cid:logo.test@plain-brand.example.org", + ) + .await + .unwrap() + .body +} + +fn itip_summaries() -> Vec<(&'static str, ItipSummary)> { + let time = ItipValue::Time(ItipTime { + start: 1_789_732_800, + tz_id: Tz::from_str("UTC").unwrap().as_id(), + }); + let field = |name, value| ItipField { name, value }; + let people = ItipValue::Participants(vec![ + ItipParticipant { + email: "alarm@plain-brand.example.org".to_string(), + name: Some("Organizer".to_string()), + is_organizer: true, + }, + ItipParticipant { + email: "guest@example.net".to_string(), + name: Some("Guest".to_string()), + is_organizer: false, + }, + ]); + let current = vec![ + field( + ICalendarProperty::Summary, + ItipValue::Text("x".to_string()), + ), + field(ICalendarProperty::Dtstart, time.clone()), + field(ICalendarProperty::Attendee, people), + ]; + vec![ + ("invite", ItipSummary::Invite(current.clone())), + ( + "update", + ItipSummary::Update { + method: ICalendarMethod::Request, + current: current.clone(), + previous: vec![field( + ICalendarProperty::Summary, + ItipValue::Text("Dinner".to_string()), + )], + }, + ), + ("cancel", ItipSummary::Cancel(current.clone())), + ( + "reply", + ItipSummary::Rsvp { + part_stat: ICalendarParticipationStatus::Accepted, + current, + }, + ), + ] +} + +fn data_url(media_type: &str, bytes: &[u8]) -> String { + format!("data:{media_type};base64,{}", STANDARD.encode(bytes)) +} + +struct Answer { + status: StatusCode, + headers: reqwest::header::HeaderMap, + body: Vec, +} + +impl Answer { + fn header(&self, name: &str) -> String { + self.headers + .get(name) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_string() + } + + /// BT-5: every `/logo` answer carries these. + fn assert_logo_headers(&self, test: &str) { + assert_eq!(self.header("cache-control"), "public, max-age=300", "{test}"); + assert_eq!(self.header("x-content-type-options"), "nosniff", "{test}"); + assert_eq!(self.header("access-control-allow-origin"), "*", "{test}"); + } +} + +fn http() -> reqwest::Client { + reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(10)) + .build() + .unwrap() +} + +/// An anonymous GET, optionally with another `Host`. +async fn get(account: &Account, path: &str, host: Option<&str>) -> Answer { + let mut request = http().get(format!("{}{path}", account.base_url())); + if let Some(host) = host { + request = request.header("host", host); + } + let response = request.send().await.unwrap(); + Answer { + status: response.status(), + headers: response.headers().clone(), + body: response.bytes().await.unwrap().to_vec(), + } +} + +async fn post(account: &Account, path: &str, body: &str) -> Answer { + let response = http() + .post(format!("{}{path}", account.base_url())) + .header("content-type", "application/json") + .body(body.to_string()) + .send() + .await + .unwrap(); + Answer { + status: response.status(), + headers: response.headers().clone(), + body: response.bytes().await.unwrap().to_vec(), + } +} + +impl Account { + async fn brand_domain(&self, name: &str, tenant: Option) -> Id { + self.registry_create_object(Domain { + name: name.to_string(), + is_enabled: true, + member_tenant_id: tenant, + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + ..Default::default() + }) + .await + } +} + +const ALARM_EVENT: &str = "BEGIN:VCALENDAR\r +VERSION:2.0\r +BEGIN:VEVENT\r +UID:brand-alarm-1\r +SUMMARY:x\r +DESCRIPTION:Bring the slides.\r +DTSTART:$START\r +DTEND:$END\r +LOCATION:Room 1\r +CONFERENCE;VALUE=URI;FEATURE=VIDEO:https://meet.example.com/brand\r +ATTENDEE;CN=Jane Guest:mailto:jane@example.net\r +BEGIN:VALARM\r +TRIGGER:-PT2S\r +ACTION:EMAIL\r +END:VALARM\r +END:VEVENT\r +END:VCALENDAR\r +"; diff --git a/tests/src/system/mod.rs b/tests/src/system/mod.rs index 5fa9544..b62f49d 100644 --- a/tests/src/system/mod.rs +++ b/tests/src/system/mod.rs @@ -7,6 +7,7 @@ pub mod antispam; pub mod authentication; pub mod authorization; +pub mod branding; pub mod crypto; pub mod delivery; pub mod directory;