Branding and templates: per-domain, tenant and server logos, /logo, operator calendar email templates and RSVP page (BT-1 to BT-26)
Logos resolve domain, then tenant, then server-wide, then the built-in, with subdomains finding their domain. GET /logo serves a data-URL image, redirects to a URL logo without fetching it, sandboxes SVG, and answers 404 when no custom logo applies. Emails embed the first PNG, JPEG or GIF logo. Logo and template writes are checked; stored templates are read at send time, always escaped, and fall back to the built-in with a build warning when they don't parse. The RSVP page is served byte for byte with a CSP and no-referrer. The sign-in and RSVP pages load the logo through an image element. MT-22's session logo follows the chain to the server-wide logo. Acceptance tests 1 to 17; test 18 written as the ignored branding_compat.
This commit is contained in:
Generated
+2
@@ -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",
|
||||
|
||||
@@ -100,6 +100,17 @@ impl GroupwareConfig {
|
||||
let dr = bp.setting_infallible::<DataRetention>().await;
|
||||
let system = bp.setting_infallible::<SystemSettings>().await;
|
||||
|
||||
// inbuxa: BT-19: a stored template that doesn't parse is reported at
|
||||
// start and on each reload; the built-in is used meanwhile
|
||||
inbuxa_features::branding::templates::warn_unusable::<CalendarTemplateVariable>(
|
||||
"CalendarAlarm.template",
|
||||
alarm.template.as_deref(),
|
||||
);
|
||||
inbuxa_features::branding::templates::warn_unusable::<CalendarTemplateVariable>(
|
||||
"CalendarScheduling.emailTemplate",
|
||||
sched.email_template.as_deref(),
|
||||
);
|
||||
|
||||
GroupwareConfig {
|
||||
max_request_size: dav.request_max_size as usize,
|
||||
dead_property_size: dav.dead_property_max_size.map(|v| v as usize),
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! Which logo applies to a domain name (branding spec BT-1, BT-2). The rules
|
||||
//! live in `inbuxa_features::branding::logo`; this finds the domain through
|
||||
//! the server's domain cache and reads the three levels from the registry
|
||||
//! each time, so a change shows at once on every node (BT-10).
|
||||
|
||||
use crate::Server;
|
||||
use inbuxa_features::branding::logo::{self, Logo, Source};
|
||||
use registry::schema::structs::{Domain, Enterprise, Tenant};
|
||||
use types::id::Id;
|
||||
|
||||
impl Server {
|
||||
/// The logos that apply to a domain name, most specific first. An unknown
|
||||
/// name gets what a known domain with no logo of its own gets (BT-6).
|
||||
pub async fn logos_for(&self, name: &str) -> trc::Result<Vec<Logo>> {
|
||||
let mut domain = None;
|
||||
for candidate in logo::lookup_names(name) {
|
||||
if let Some(found) = self.domain(&candidate).await? {
|
||||
domain = Some(found);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let registry = self.registry();
|
||||
let domain_logo = match &domain {
|
||||
Some(domain) => registry
|
||||
.object::<Domain>(Id::from(domain.id))
|
||||
.await?
|
||||
.and_then(|d| d.logo),
|
||||
None => None,
|
||||
};
|
||||
let tenant_id = domain.as_ref().and_then(|d| d.id_tenant);
|
||||
let tenant_logo = match tenant_id {
|
||||
Some(tenant_id) => registry
|
||||
.object::<Tenant>(Id::from(tenant_id))
|
||||
.await?
|
||||
.and_then(|t| t.logo),
|
||||
None => None,
|
||||
};
|
||||
let server_logo = registry
|
||||
.object::<Enterprise>(Id::singleton())
|
||||
.await?
|
||||
.and_then(|e| e.logo_url);
|
||||
Ok(logo::chain([
|
||||
(
|
||||
Source::Domain(domain.as_ref().map_or(u32::MAX, |d| d.id)),
|
||||
domain_logo.as_deref(),
|
||||
),
|
||||
(
|
||||
Source::Tenant(tenant_id.unwrap_or(u32::MAX)),
|
||||
tenant_logo.as_deref(),
|
||||
),
|
||||
(Source::Server, server_logo.as_deref()),
|
||||
]))
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ use store::{BlobStore, InMemoryStore, RegistryStore, SearchStore, Store};
|
||||
|
||||
pub mod archive;
|
||||
pub mod blob;
|
||||
pub mod branding; // inbuxa: branding BT-1, BT-2
|
||||
pub mod dav;
|
||||
pub mod document;
|
||||
pub mod encryption;
|
||||
@@ -95,11 +96,27 @@ impl Server {
|
||||
self.registry().count_object(ObjectType::Domain).await
|
||||
}
|
||||
|
||||
// inbuxa: BT-9: the first logo mail can carry inline; none leaves the
|
||||
// built-in INBUXA logo
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
pub async fn logo_resource(
|
||||
&self,
|
||||
_: &str,
|
||||
domain: &str,
|
||||
) -> trc::Result<Option<crate::manager::application::Resource<Vec<u8>>>> {
|
||||
Ok(None)
|
||||
Ok(self
|
||||
.logos_for(domain)
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|logo| logo.is_embeddable())
|
||||
.and_then(|logo| match logo {
|
||||
inbuxa_features::branding::logo::Logo::Image {
|
||||
content_type,
|
||||
bytes,
|
||||
} => Some(crate::manager::application::Resource::new(
|
||||
content_type,
|
||||
bytes,
|
||||
)),
|
||||
inbuxa_features::branding::logo::Logo::Url(_) => None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
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 `<svg` element near the start, after any BOM, XML
|
||||
/// declaration, comments or doctype.
|
||||
fn is_svg(bytes: &[u8]) -> 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("<svg")
|
||||
}
|
||||
|
||||
fn has_scheme(value: &str, scheme: &str) -> 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<u8>), &'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::<String>();
|
||||
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<Logo> {
|
||||
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::<String>();
|
||||
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<Item = (Source, Option<&'x str>)>,
|
||||
) -> Vec<Logo> {
|
||||
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<String> {
|
||||
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"<?xml version=\"1.0\"?><svg/>")).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,<b>x</b>".to_string(),
|
||||
data("text/html", b"<b>x</b>"),
|
||||
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"<b>x</b>")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chain_order_and_embedding() {
|
||||
let png = data("image/png", PNG);
|
||||
let svg = data("image/svg+xml", b"<svg xmlns='http://www.w3.org/2000/svg'/>");
|
||||
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"]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<T: FromStr + Eq + Hash + Debug>(template: &str) -> Result<Template<T>, String> {
|
||||
walk(template, true)?;
|
||||
let mut parsed = Template::<T>::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<Option<String>> {
|
||||
Ok(match which {
|
||||
Which::Alarm => registry
|
||||
.object::<CalendarAlarm>(Id::singleton())
|
||||
.await?
|
||||
.and_then(|o| o.template),
|
||||
Which::Invite => registry
|
||||
.object::<CalendarScheduling>(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<T: FromStr + Eq + Hash + Debug>(
|
||||
registry: &RegistryStore,
|
||||
which: Which,
|
||||
) -> trc::Result<Option<Template<T>>> {
|
||||
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<T: FromStr + Eq + Hash + Debug>(field: &str, text: Option<&str>) {
|
||||
if let Some(text) = text.filter(|t| !t.trim().is_empty())
|
||||
&& let Err(err) = parse::<T>(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<Option<String>> {
|
||||
Ok(registry
|
||||
.object::<CalendarScheduling>(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("<p>{{header}}</p>{{#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::<String>("{{!event_title}}|{{event_title}}").unwrap();
|
||||
let mut vars = utils::template::Variables::<String, String>::new();
|
||||
vars.insert_single("event_title".into(), "<b>x</b>".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());
|
||||
}
|
||||
}
|
||||
@@ -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<Property>> {
|
||||
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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>(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::<Enterprise>(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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<HttpResponse> {
|
||||
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<HttpResponse> {
|
||||
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))
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
pub mod api;
|
||||
pub mod auth;
|
||||
pub mod branding; // inbuxa: branding
|
||||
pub mod form;
|
||||
pub mod request;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -323,8 +323,17 @@ pub async fn build_itip_template(
|
||||
summary: &ItipSummary,
|
||||
logo_cid: &str,
|
||||
) -> trc::Result<Details> {
|
||||
// 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;
|
||||
|
||||
|
||||
+1
-1
@@ -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`. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -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) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -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"<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert(1)</script></svg>";
|
||||
|
||||
/// A template that shows every alarm variable (BT-13), each in a marker.
|
||||
const ALARM_TEMPLATE: &str = concat!(
|
||||
"<html lang=\"{{lang}}\" dir=\"{{dir}}\"><body>CUSTOM[{{page_title}}]",
|
||||
"<img src=\"{{logo_cid}}\">",
|
||||
"{{#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}}",
|
||||
"<a href=\"{{action_url}}\">N[{{action_name}}]</a>F[{{footer}}]</body></html>"
|
||||
);
|
||||
|
||||
/// A template for iMIP messages (BT-13).
|
||||
const INVITE_TEMPLATE: &str = concat!(
|
||||
"<html lang=\"{{lang}}\">CUSTOM[{{page_title}}]<img src=\"{{logo_cid}}\">",
|
||||
"{{#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}}</html>"
|
||||
);
|
||||
|
||||
pub async fn test(test: &mut TestServer) {
|
||||
println!("Running branding tests...");
|
||||
let admin = test.account("[email protected]");
|
||||
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("[email protected]", 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(
|
||||
"[email protected]",
|
||||
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,<b>x</b>".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("<title>"),
|
||||
"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}}</title><p>Custom RSVP</p>";
|
||||
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": "<p>{{header}}</p>"}),
|
||||
),
|
||||
(
|
||||
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("[email protected]").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<u8>)>) {
|
||||
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, "[email protected]")
|
||||
.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,
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
summary,
|
||||
"cid:[email protected]",
|
||||
)
|
||||
.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: "[email protected]".to_string(),
|
||||
name: Some("Organizer".to_string()),
|
||||
is_organizer: true,
|
||||
},
|
||||
ItipParticipant {
|
||||
email: "[email protected]".to_string(),
|
||||
name: Some("Guest".to_string()),
|
||||
is_organizer: false,
|
||||
},
|
||||
]);
|
||||
let current = vec![
|
||||
field(
|
||||
ICalendarProperty::Summary,
|
||||
ItipValue::Text("<b>x</b>".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<u8>,
|
||||
}
|
||||
|
||||
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>) -> 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:<b>x</b>\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:[email protected]\r
|
||||
BEGIN:VALARM\r
|
||||
TRIGGER:-PT2S\r
|
||||
ACTION:EMAIL\r
|
||||
END:VALARM\r
|
||||
END:VEVENT\r
|
||||
END:VCALENDAR\r
|
||||
";
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user