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 40c18d3..53c47aa 100644 Binary files a/resources/html-templates/calendar-rsvp.html.min.gz and b/resources/html-templates/calendar-rsvp.html.min.gz differ 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 1b0af21..72e8bfc 100644 Binary files a/resources/html-templates/login.html.min.gz and b/resources/html-templates/login.html.min.gz differ 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;