Where the front ends are, set over JMAP: inbuxa:FrontEnds #195

Merged
jcoffey-dev merged 1 commits from feat/front-ends into main 2026-10-12 13:23:01 +00:00
21 changed files with 1300 additions and 61 deletions

No files matched your search

+4 -1
View File
@@ -451,7 +451,10 @@ impl Http {
let cors_origins = if use_permissive_cors {
Vec::new()
} else {
crate::manager::first_party::front_end_origins()
let stored = inbuxa_features::security::front_ends::get(&bp.data_store)
.await
.unwrap_or_default();
crate::manager::first_party::front_end_origins(&stored)
.into_iter()
.filter_map(|origin| hyper::header::HeaderValue::from_str(&origin).ok())
.collect()
+144 -51
View File
@@ -17,15 +17,19 @@
//! - inbuxa-webmail, as the confidential client `ihasmail-inbuxa`, when
//! `INBUXA_WEBMAIL_URL` and `INBUXA_WEBMAIL_CLIENT_SECRET` are set.
//!
//! inbuxa: the environment variables stand in for `x:FrontEnds` (C-4) until
//! that object exists; the installer and INBUXA Admin's setup wizard will set
//! it instead.
//! inbuxa: `inbuxa:FrontEnds` (C-4) holds the same addresses, set over JMAP
//! by the installer (`inbuxa join`) or INBUXA Admin; the environment
//! variables keep working beside it, and both are registered. Where both
//! give the webmail a secret, the environment's wins, so a server configured
//! through its environment is never changed by what was stored.
//!
//! A missing client is created. An existing one gains any redirect URI it
//! lacks and, for inbuxa-webmail, the configured secret; nothing an operator
//! added is removed.
//! added is removed. This runs on every start, and again whenever
//! `inbuxa:FrontEnds` is set.
use directory::core::secret::{hash_secret, verify_secret_hash};
use inbuxa_features::security::front_ends::{self, FrontEnds};
use registry::{
schema::{
enums::{PasswordHashAlgorithm, ServiceProtocol},
@@ -52,16 +56,27 @@ pub struct FirstPartyClient {
pub client_id: String,
pub description: String,
pub redirect_uris: Vec<String>,
pub secret: Option<String>,
pub secret: Option<ClientSecret>,
}
/// A confidential client's secret: given in the clear (the environment), or
/// already hashed (`inbuxa:FrontEnds`, which never keeps it in the clear).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClientSecret {
Plain(String),
Hash(String),
}
/// The first-party clients this server should have, from its applications and
/// the front-end addresses it was given.
/// the front-end addresses it was given. Each front end may have several
/// addresses (the environment's and `inbuxa:FrontEnds`'), each a redirect URI
/// of the same client.
pub fn first_party_clients(
base_url: &str,
applications: &[Application],
admin_url: Option<&str>,
webmail: Option<(&str, &str)>,
admin_urls: &[&str],
webmail_urls: &[&str],
webmail_secret: Option<ClientSecret>,
) -> Vec<FirstPartyClient> {
let base_url = base_url.trim_end_matches('/');
let mut clients: Vec<FirstPartyClient> = Vec::new();
@@ -102,25 +117,41 @@ pub fn first_party_clients(
}
}
if let Some(url) = admin_url.map(|url| url.trim().trim_end_matches('/')).filter(|url| !url.is_empty()) {
let uris = |urls: &[&str], path: &str| {
let mut uris: Vec<String> = Vec::new();
for url in urls {
let url = url.trim().trim_end_matches('/');
let uri = format!("{url}{path}");
if !url.is_empty() && !uris.contains(&uri) {
uris.push(uri);
}
}
uris
};
let admin = uris(admin_urls, "/oauth/callback");
if !admin.is_empty() {
clients.push(FirstPartyClient {
client_id: ADMIN_CLIENT_ID.to_string(),
description: "inbuxa Admin".to_string(),
redirect_uris: vec![format!("{url}/oauth/callback")],
redirect_uris: admin,
secret: None,
});
}
if let Some((url, secret)) = webmail {
let url = url.trim().trim_end_matches('/');
if !url.is_empty() && !secret.is_empty() {
clients.push(FirstPartyClient {
client_id: WEBMAIL_CLIENT_ID.to_string(),
description: "ihasmail webmail".to_string(),
redirect_uris: vec![format!("{url}/api/auth/callback")],
secret: Some(secret.to_string()),
});
}
let webmail = uris(webmail_urls, "/api/auth/callback");
let webmail_secret = webmail_secret.filter(|secret| match secret {
ClientSecret::Plain(s) | ClientSecret::Hash(s) => !s.is_empty(),
});
if !webmail.is_empty()
&& let Some(secret) = webmail_secret
{
clients.push(FirstPartyClient {
client_id: WEBMAIL_CLIENT_ID.to_string(),
description: "ihasmail webmail".to_string(),
redirect_uris: webmail,
secret: Some(secret),
});
}
clients
@@ -166,12 +197,17 @@ pub fn origin_of(url: &str) -> Option<String> {
}
/// The origins allowed to make cross-origin requests (contract C-14): INBUXA
/// Admin's, the webmail's, and `INBUXA_CORS_EXTRA_ORIGINS` (comma-separated).
///
/// inbuxa: read from the environment until `x:FrontEnds` exists (C-4).
pub fn front_end_origins() -> Vec<String> {
/// Admin's, the webmail's, and `INBUXA_CORS_EXTRA_ORIGINS` (comma-separated),
/// from the environment and from `inbuxa:FrontEnds` alike.
pub fn front_end_origins(stored: &FrontEnds) -> Vec<String> {
let mut origins = Vec::new();
for url in [env("ADMIN_URL"), env("WEBMAIL_URL")].into_iter().flatten() {
for url in [env("ADMIN_URL"), env("WEBMAIL_URL")]
.into_iter()
.flatten()
.chain(stored.admin_url.clone())
.chain(stored.webmail_url.clone())
.chain(stored.extra_origins.iter().cloned())
{
origins.extend(origin_of(&url));
}
if let Some(extra) = env("CORS_EXTRA_ORIGINS") {
@@ -182,6 +218,12 @@ pub fn front_end_origins() -> Vec<String> {
origins
}
/// Whether the environment gives the webmail its secret. `inbuxa:FrontEnds`
/// can't set one then: the environment's would be put back at every start.
pub fn webmail_secret_from_environment() -> bool {
env("WEBMAIL_CLIENT_SECRET").is_some()
}
fn env(name: &str) -> Option<String> {
types::branding::env_var(name)
.ok()
@@ -189,7 +231,9 @@ fn env(name: &str) -> Option<String> {
.filter(|value| !value.is_empty())
}
pub(crate) async fn ensure_first_party_clients(bp: &mut Bootstrap) -> trc::Result<()> {
/// Registers the first-party clients: at every start, and again whenever
/// `inbuxa:FrontEnds` is set.
pub async fn ensure_first_party_clients(bp: &mut Bootstrap) -> trc::Result<()> {
retire_legacy_web_interface_client(bp).await?;
let system = bp.setting_infallible::<SystemSettings>().await;
let base_url = base_url(bp, &system);
@@ -199,18 +243,22 @@ pub(crate) async fn ensure_first_party_clients(bp: &mut Bootstrap) -> trc::Resul
.into_iter()
.map(|app| app.object)
.collect::<Vec<_>>();
let admin_url = env("ADMIN_URL");
let webmail_url = env("WEBMAIL_URL");
let webmail_secret = env("WEBMAIL_CLIENT_SECRET");
if webmail_url.is_some() && webmail_secret.is_none() {
let stored = front_ends::get(&bp.data_store).await?;
let admin_urls: Vec<String> = env("ADMIN_URL").into_iter().chain(stored.admin_url.clone()).collect();
let webmail_urls: Vec<String> = env("WEBMAIL_URL").into_iter().chain(stored.webmail_url.clone()).collect();
let webmail_secret = env("WEBMAIL_CLIENT_SECRET")
.map(ClientSecret::Plain)
.or_else(|| stored.webmail_secret_hash.clone().map(ClientSecret::Hash));
if !webmail_urls.is_empty() && webmail_secret.is_none() {
trc::event!(
Auth(trc::AuthEvent::Error),
Details = "INBUXA_WEBMAIL_URL is set without INBUXA_WEBMAIL_CLIENT_SECRET; the webmail client was not registered."
Details = "The webmail's address is set without its client secret; the webmail client was not registered."
);
}
let webmail = webmail_url.as_deref().zip(webmail_secret.as_deref());
let admin_urls: Vec<&str> = admin_urls.iter().map(String::as_str).collect();
let webmail_urls: Vec<&str> = webmail_urls.iter().map(String::as_str).collect();
for client in first_party_clients(&base_url, &applications, admin_url.as_deref(), webmail) {
for client in first_party_clients(&base_url, &applications, &admin_urls, &webmail_urls, webmail_secret) {
ensure_client(bp, client).await?;
}
@@ -295,19 +343,27 @@ async fn ensure_client(bp: &mut Bootstrap, client: FirstPartyClient) -> trc::Res
updated.redirect_uris.push(uri.clone());
}
}
if let Some(secret) = &client.secret {
let matches = match updated.secret.as_deref() {
Some(hash) if !hash.is_empty() => {
verify_secret_hash(hash, secret.as_bytes()).await?
match &client.secret {
Some(ClientSecret::Plain(secret)) => {
let matches = match updated.secret.as_deref() {
Some(hash) if !hash.is_empty() => {
verify_secret_hash(hash, secret.as_bytes()).await?
}
_ => false,
};
if !matches {
updated.secret = Some(
hash_secret(PasswordHashAlgorithm::Argon2id, secret.as_bytes().to_vec())
.await?,
);
}
_ => false,
};
if !matches {
updated.secret = Some(
hash_secret(PasswordHashAlgorithm::Argon2id, secret.as_bytes().to_vec())
.await?,
);
}
Some(ClientSecret::Hash(hash)) => {
if updated.secret.as_deref() != Some(hash.as_str()) {
updated.secret = Some(hash.clone());
}
}
None => {}
}
if updated == current {
return Ok(());
@@ -318,9 +374,10 @@ async fn ensure_client(bp: &mut Bootstrap, client: FirstPartyClient) -> trc::Res
.await?
} else {
let secret = match &client.secret {
Some(secret) => Some(
Some(ClientSecret::Plain(secret)) => Some(
hash_secret(PasswordHashAlgorithm::Argon2id, secret.as_bytes().to_vec()).await?,
),
Some(ClientSecret::Hash(hash)) => Some(hash.clone()),
None => None,
};
bp.registry
@@ -365,7 +422,7 @@ mod tests {
#[test]
fn web_interface_gets_one_uri_per_prefix() {
let clients = first_party_clients("https://mail.example.org/", &[web_interface()], None, None);
let clients = first_party_clients("https://mail.example.org/", &[web_interface()], &[], &[], None);
assert_eq!(
clients,
vec![FirstPartyClient {
@@ -387,7 +444,7 @@ mod tests {
let mut named = web_interface();
named.oauth_client_id = Some("custom".to_string());
named.url_prefix = Map::new(vec!["portal".into()]);
let clients = first_party_clients("https://h", &[disabled, named], None, None);
let clients = first_party_clients("https://h", &[disabled, named], &[], &[], None);
assert_eq!(clients.len(), 1);
assert_eq!(clients[0].client_id, "custom");
assert_eq!(clients[0].redirect_uris, vec!["https://h/portal/oauth/callback"]);
@@ -398,8 +455,9 @@ mod tests {
let clients = first_party_clients(
"https://h",
&[],
Some("https://admin.example.org/"),
Some(("https://webmail.example.org", "s3cret")),
&["https://admin.example.org/"],
&["https://webmail.example.org"],
Some(ClientSecret::Plain("s3cret".into())),
);
assert_eq!(clients.len(), 2);
assert_eq!(clients[0].client_id, ADMIN_CLIENT_ID);
@@ -407,7 +465,7 @@ mod tests {
assert_eq!(clients[0].secret, None);
assert_eq!(clients[1].client_id, WEBMAIL_CLIENT_ID);
assert_eq!(clients[1].redirect_uris, vec!["https://webmail.example.org/api/auth/callback"]);
assert_eq!(clients[1].secret.as_deref(), Some("s3cret"));
assert_eq!(clients[1].secret, Some(ClientSecret::Plain("s3cret".into())));
}
#[test]
@@ -423,7 +481,42 @@ mod tests {
#[test]
fn webmail_needs_a_secret() {
let clients = first_party_clients("https://h", &[], Some(" "), Some(("https://w", "")));
let clients = first_party_clients("https://h", &[], &[" "], &["https://w"], Some(ClientSecret::Plain(String::new())));
assert!(clients.is_empty());
let clients = first_party_clients("https://h", &[], &[], &["https://w"], None);
assert!(clients.is_empty());
}
#[test]
fn both_addresses_of_a_front_end_are_registered() {
// The environment's and inbuxa:FrontEnds', once each
let clients = first_party_clients(
"https://h",
&[],
&["https://admin.a.example", "https://admin.b.example/", "https://admin.a.example/"],
&["https://webmail.b.example"],
Some(ClientSecret::Hash("$argon2id$x".into())),
);
assert_eq!(
clients[0].redirect_uris,
vec![
"https://admin.a.example/oauth/callback",
"https://admin.b.example/oauth/callback"
]
);
assert_eq!(clients[1].secret, Some(ClientSecret::Hash("$argon2id$x".into())));
}
#[test]
fn stored_front_ends_are_allowed_origins() {
let stored = FrontEnds {
admin_url: Some("https://Admin.Example.org/console".into()),
extra_origins: vec!["http://localhost:5173".into(), "not a url".into()],
..Default::default()
};
let origins = front_end_origins(&stored);
assert!(origins.contains(&"https://admin.example.org".to_string()));
assert!(origins.contains(&"http://localhost:5173".to_string()));
assert!(!origins.iter().any(|o| o.contains("not a url")));
}
}
+157
View File
@@ -0,0 +1,157 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:FrontEnds`, where the server's front ends are (contract C-4): the
//! console's address, the webmail's and its client secret, and any further
//! origins allowed to call the server from a browser.
//!
//! It stands beside the `INBUXA_ADMIN_URL`, `INBUXA_WEBMAIL_URL` and
//! `INBUXA_WEBMAIL_CLIENT_SECRET` environment variables rather than replacing
//! them: a server configured through its environment keeps working, and what
//! is stored here is added to it. That is what lets a second machine add its
//! front ends over JMAP (the installer's `inbuxa join`) without touching the
//! server's environment.
//!
//! The webmail's secret is kept only as its Argon2 hash, the form the OAuth
//! client holds it in, so nothing here can be read back as the secret.
//!
//! Stored as JSON in the fork's subspace under `F` + `e`; unset reads as
//! empty, which changes nothing.
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
use store::{
Deserialize, SUBSPACE_INBUXA, Store, ValueKey,
write::{AnyClass, BatchBuilder, ValueClass},
};
use trc::AddContext;
#[derive(Debug, Clone, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub struct FrontEnds {
/// Where INBUXA Admin is served: registers `inbuxa-admin`'s redirect URI
/// and allows its origin (C-6, C-14).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub admin_url: Option<String>,
/// Where inbuxa-webmail is served: registers `ihasmail-inbuxa`'s redirect
/// URI and allows its origin.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub webmail_url: Option<String>,
/// The Argon2 hash of the webmail's client secret.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub webmail_secret_hash: Option<String>,
/// Further origins allowed to call the server from a browser.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extra_origins: Vec<String>,
/// Seconds since the epoch, and who: the console shows them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub changed_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub changed_by: Option<String>,
}
fn key() -> ValueClass {
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key: b"Fe".to_vec(),
})
}
struct Json(FrontEnds);
impl Deserialize for Json {
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
serde_json::from_slice(bytes).map(Json).map_err(|err| {
trc::StoreEvent::DataCorruption
.caused_by(trc::location!())
.reason(err)
})
}
}
/// What is stored, or the empty default.
pub async fn get(data: &Store) -> trc::Result<FrontEnds> {
Ok(data
.get_value::<Json>(ValueKey::from(key()))
.await
.caused_by(trc::location!())?
.map(|Json(front_ends)| front_ends)
.unwrap_or_default())
}
/// Stores it.
pub async fn set(data: &Store, front_ends: &FrontEnds) -> trc::Result<()> {
let bytes = serde_json::to_vec(front_ends).map_err(|err| {
trc::StoreEvent::UnexpectedError
.caused_by(trc::location!())
.reason(err)
})?;
let mut batch = BatchBuilder::new();
batch.set(key(), bytes);
data.write(batch.build_all())
.await
.caused_by(trc::location!())
.map(|_| ())
}
/// Why an address can't be a front end's, or `None` when it can: an
/// absolute `http` or `https` URL with a host, and nothing after the path a
/// redirect URI could be built on.
pub fn invalid_url(url: &str) -> Option<&'static str> {
let url = url.trim();
let rest = if let Some(rest) = url.strip_prefix("https://") {
rest
} else if let Some(rest) = url.strip_prefix("http://") {
rest
} else {
return Some("must start with https:// or http://");
};
let host = rest.split(['/', '?', '#']).next().unwrap_or_default();
if host.is_empty() || host.starts_with(':') {
return Some("must name a host");
}
if rest.contains('?') || rest.contains('#') {
return Some("must not have a query or a fragment");
}
if rest.chars().any(char::is_whitespace) {
return Some("must not contain spaces");
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_is_its_own() {
let ValueClass::Any(k) = key() else { panic!() };
assert_eq!(k.key, b"Fe");
}
#[test]
fn urls() {
assert_eq!(invalid_url("https://admin.example.org"), None);
assert_eq!(invalid_url("http://127.0.0.1:8082/"), None);
assert_eq!(invalid_url("https://example.org/console"), None);
assert!(invalid_url("admin.example.org").is_some());
assert!(invalid_url("ftp://example.org").is_some());
assert!(invalid_url("https://").is_some());
assert!(invalid_url("https://:8080").is_some());
assert!(invalid_url("https://example.org/?x=1").is_some());
assert!(invalid_url("https://exa mple.org").is_some());
}
#[test]
fn the_hash_is_never_called_a_secret() {
let json = serde_json::to_string(&FrontEnds {
webmail_secret_hash: Some("$argon2id$...".into()),
..Default::default()
})
.unwrap();
assert!(json.contains("webmailSecretHash"));
assert!(!json.contains("\"webmailClientSecret\""));
}
}
+1
View File
@@ -11,6 +11,7 @@
//! `legacy-protocols.md`.
pub mod acceptance;
pub mod front_ends;
pub mod legacy_use;
pub mod log_files;
pub mod listeners;
@@ -0,0 +1,207 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:FrontEnds/get` and `/set` under `urn:inbuxa:jmap`: where the
//! server's front ends are (contract C-4). A singleton, id `singleton`.
//!
//! `webmailClientSecret` is write-only. The `environment*` properties,
//! `webmailClientSecretSet`, `changedAt` and `changedBy` are the server's to
//! say; a client that sets them is answered with `invalidProperties`.
use crate::object::{AnyId, JmapObject, JmapObjectId};
use jmap_tools::{Element, Key, Property};
use std::{borrow::Cow, str::FromStr};
use types::id::Id;
#[derive(Debug, Clone, Default)]
pub struct FrontEnds;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum FrontEndsProperty {
Id,
/// Where INBUXA Admin is served, or null.
AdminUrl,
/// Where inbuxa-webmail is served, or null.
WebmailUrl,
/// Write-only: the webmail's client secret. Always null when read.
WebmailClientSecret,
/// Server-set: whether a webmail client secret is stored.
WebmailClientSecretSet,
/// Further origins allowed to call the server from a browser.
ExtraOrigins,
/// Server-set: `INBUXA_ADMIN_URL` on this node, or null.
EnvironmentAdminUrl,
/// Server-set: `INBUXA_WEBMAIL_URL` on this node, or null.
EnvironmentWebmailUrl,
/// Server-set: whether `INBUXA_WEBMAIL_CLIENT_SECRET` is set on this node.
EnvironmentWebmailClientSecret,
ChangedAt,
ChangedBy,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum FrontEndsValue {
Id(Id),
}
impl Property for FrontEndsProperty {
fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
FrontEndsProperty::parse(value)
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
FrontEndsProperty::Id => "id",
FrontEndsProperty::AdminUrl => "adminUrl",
FrontEndsProperty::WebmailUrl => "webmailUrl",
FrontEndsProperty::WebmailClientSecret => "webmailClientSecret",
FrontEndsProperty::WebmailClientSecretSet => "webmailClientSecretSet",
FrontEndsProperty::ExtraOrigins => "extraOrigins",
FrontEndsProperty::EnvironmentAdminUrl => "environmentAdminUrl",
FrontEndsProperty::EnvironmentWebmailUrl => "environmentWebmailUrl",
FrontEndsProperty::EnvironmentWebmailClientSecret => "environmentWebmailClientSecret",
FrontEndsProperty::ChangedAt => "changedAt",
FrontEndsProperty::ChangedBy => "changedBy",
}
.into()
}
}
impl FrontEndsProperty {
fn parse(value: &str) -> Option<Self> {
hashify::tiny_map!(value.as_bytes(),
b"id" => FrontEndsProperty::Id,
b"adminUrl" => FrontEndsProperty::AdminUrl,
b"webmailUrl" => FrontEndsProperty::WebmailUrl,
b"webmailClientSecret" => FrontEndsProperty::WebmailClientSecret,
b"webmailClientSecretSet" => FrontEndsProperty::WebmailClientSecretSet,
b"extraOrigins" => FrontEndsProperty::ExtraOrigins,
b"environmentAdminUrl" => FrontEndsProperty::EnvironmentAdminUrl,
b"environmentWebmailUrl" => FrontEndsProperty::EnvironmentWebmailUrl,
b"environmentWebmailClientSecret" => FrontEndsProperty::EnvironmentWebmailClientSecret,
b"changedAt" => FrontEndsProperty::ChangedAt,
b"changedBy" => FrontEndsProperty::ChangedBy,
)
}
}
impl FrontEndsProperty {
/// Whether this property is the server's to say. A client that sets one
/// is answered with `invalidProperties`.
pub fn is_server_set(&self) -> bool {
matches!(
self,
FrontEndsProperty::WebmailClientSecretSet
| FrontEndsProperty::EnvironmentAdminUrl
| FrontEndsProperty::EnvironmentWebmailUrl
| FrontEndsProperty::EnvironmentWebmailClientSecret
| FrontEndsProperty::ChangedAt
| FrontEndsProperty::ChangedBy
)
}
}
impl FromStr for FrontEndsProperty {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
FrontEndsProperty::parse(s).ok_or(())
}
}
impl Element for FrontEndsValue {
type Property = FrontEndsProperty;
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
match key {
Key::Property(FrontEndsProperty::Id) => {
Id::from_str(value).ok().map(FrontEndsValue::Id)
}
_ => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
FrontEndsValue::Id(id) => id.to_string().into(),
}
}
}
impl JmapObject for FrontEnds {
type Property = FrontEndsProperty;
type Element = FrontEndsValue;
type Id = Id;
type Filter = ();
type Comparator = ();
type GetArguments = ();
type SetArguments<'de> = ();
type QueryArguments = ();
type CopyArguments = ();
type ParseArguments = ();
const ID_PROPERTY: Self::Property = FrontEndsProperty::Id;
}
impl From<Id> for FrontEndsValue {
fn from(id: Id) -> Self {
FrontEndsValue::Id(id)
}
}
impl JmapObjectId for FrontEndsValue {
fn as_id(&self) -> Option<Id> {
match self {
FrontEndsValue::Id(id) => Some(*id),
}
}
fn as_any_id(&self) -> Option<AnyId> {
match self {
FrontEndsValue::Id(id) => Some(AnyId::Id(*id)),
}
}
fn as_id_ref(&self) -> Option<&str> {
None
}
fn try_set_id(&mut self, new_id: AnyId) -> bool {
if let AnyId::Id(id) = new_id {
*self = FrontEndsValue::Id(id);
true
} else {
false
}
}
}
impl JmapObjectId for FrontEndsProperty {
fn as_id(&self) -> Option<Id> {
None
}
fn as_any_id(&self) -> Option<AnyId> {
None
}
fn as_id_ref(&self) -> Option<&str> {
None
}
fn try_set_id(&mut self, _: AnyId) -> bool {
false
}
}
+1
View File
@@ -44,6 +44,7 @@ pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model
pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off
pub mod inbuxa_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant
pub mod inbuxa_sharing_policy; // inbuxa: MA-C, who may share mail
pub mod inbuxa_front_ends; // inbuxa: C-4, where the front ends are
pub mod inbuxa_deleted_account; // inbuxa: undelete
pub mod file_node;
pub mod identity;
+3
View File
@@ -127,6 +127,9 @@ impl Response<'_> {
GetResponseMethod::SharingPolicy(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::FrontEnds(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::Principal(response) => {
response.eval_jptr(path, &mut results)
}
@@ -72,6 +72,7 @@ impl Response<'_> {
GetRequestMethod::SharingPolicy(request) => {
request.resolve_references(self)?
}
GetRequestMethod::FrontEnds(request) => request.resolve_references(self)?,
GetRequestMethod::Principal(request) => request.resolve_references(self)?,
GetRequestMethod::Quota(request) => request.resolve_references(self)?,
GetRequestMethod::Blob(request) => request.resolve_references(self)?,
@@ -184,6 +185,9 @@ impl Response<'_> {
SetRequestMethod::SharingPolicy(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::FrontEnds(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::AddressBook(request) => {
request.resolve_references(self, 1, false)?
}
+7
View File
@@ -84,6 +84,7 @@ pub enum MethodObject {
JournalVerification,
TenantProtocolPolicy,
SharingPolicy,
FrontEnds,
}
impl MethodObject {
@@ -137,6 +138,7 @@ impl MethodObject {
MethodObject::ProtocolPolicy => Capability::Inbuxa,
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
MethodObject::SharingPolicy => Capability::Inbuxa,
MethodObject::FrontEnds => Capability::Inbuxa,
}
}
}
@@ -373,6 +375,8 @@ impl MethodName {
(MethodFunction::Set, MethodObject::SharingPolicy) => {
"inbuxa:SharingPolicy/set"
}
(MethodFunction::Get, MethodObject::FrontEnds) => "inbuxa:FrontEnds/get",
(MethodFunction::Set, MethodObject::FrontEnds) => "inbuxa:FrontEnds/set",
(method, MethodObject::Registry(obj)) => {
return Cow::Owned(format!("x:{}/{}", obj.as_str(), method.as_str()));
}
@@ -545,6 +549,8 @@ impl MethodName {
"inbuxa:TenantProtocolPolicy/set" => (MethodObject::TenantProtocolPolicy, MethodFunction::Set),
"inbuxa:SharingPolicy/get" => (MethodObject::SharingPolicy, MethodFunction::Get),
"inbuxa:SharingPolicy/set" => (MethodObject::SharingPolicy, MethodFunction::Set),
"inbuxa:FrontEnds/get" => (MethodObject::FrontEnds, MethodFunction::Get),
"inbuxa:FrontEnds/set" => (MethodObject::FrontEnds, MethodFunction::Set),
).or_else(|| {
let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?;
@@ -625,6 +631,7 @@ impl Display for MethodObject {
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
MethodObject::SharingPolicy => "inbuxa:SharingPolicy",
MethodObject::FrontEnds => "inbuxa:FrontEnds",
MethodObject::Registry(obj) => {
f.write_str("x:")?;
return f.write_str(obj.as_str());
+2
View File
@@ -142,6 +142,7 @@ pub enum GetRequestMethod {
SharingPolicy(
Box<GetRequest<crate::object::inbuxa_sharing_policy::SharingPolicy>>,
),
FrontEnds(Box<GetRequest<crate::object::inbuxa_front_ends::FrontEnds>>),
}
#[derive(Debug)]
@@ -194,6 +195,7 @@ pub enum SetRequestMethod<'x> {
SharingPolicy(
Box<SetRequest<'x, crate::object::inbuxa_sharing_policy::SharingPolicy>>,
),
FrontEnds(Box<SetRequest<'x, crate::object::inbuxa_front_ends::FrontEnds>>),
}
#[derive(Debug)]
+14
View File
@@ -222,6 +222,13 @@ impl<'de> Visitor<'de> for CallVisitor {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Get, MethodObject::FrontEnds) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::FrontEnds(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Get, MethodObject::VacationResponse) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::VacationResponse(value)),
Err(err) => RequestMethod::invalid(err),
@@ -433,6 +440,13 @@ impl<'de> Visitor<'de> for CallVisitor {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Set, MethodObject::FrontEnds) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::FrontEnds(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Set, MethodObject::VacationResponse) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::VacationResponse(value)),
Err(err) => RequestMethod::invalid(err),
+14
View File
@@ -129,6 +129,7 @@ pub enum GetResponseMethod {
SharingPolicy(
GetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>,
),
FrontEnds(GetResponse<crate::object::inbuxa_front_ends::FrontEnds>),
}
#[derive(Debug, serde::Serialize)]
@@ -182,6 +183,7 @@ pub enum SetResponseMethod {
SharingPolicy(
Box<SetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>>,
),
FrontEnds(Box<SetResponse<crate::object::inbuxa_front_ends::FrontEnds>>),
}
#[derive(Debug, serde::Serialize)]
@@ -398,6 +400,18 @@ impl<'x> From<SetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>>
}
}
impl<'x> From<GetResponse<crate::object::inbuxa_front_ends::FrontEnds>> for ResponseMethod<'x> {
fn from(value: GetResponse<crate::object::inbuxa_front_ends::FrontEnds>) -> Self {
ResponseMethod::Get(GetResponseMethod::FrontEnds(value))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_front_ends::FrontEnds>> for ResponseMethod<'x> {
fn from(value: SetResponse<crate::object::inbuxa_front_ends::FrontEnds>) -> Self {
ResponseMethod::Set(SetResponseMethod::FrontEnds(Box::new(value)))
}
}
impl<'x> From<GetResponse<crate::object::inbuxa_ai_limits::AiLimits>> for ResponseMethod<'x> {
fn from(value: GetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self {
ResponseMethod::Get(GetResponseMethod::AiLimits(value))
+14 -1
View File
@@ -143,6 +143,9 @@ impl JmapAuthorization for AccessToken {
// manages their tenant's, so the domain's permissions; the
// server's own also needs sysSharingUpdate (see the method)
GetRequestMethod::SharingPolicy(_) => Permission::SysDomainGet,
// inbuxa: C-4, where the front ends are: the HTTP settings'
// (the method also refuses anyone inside a tenant)
GetRequestMethod::FrontEnds(_) => Permission::SysHttpGet,
GetRequestMethod::Principal(_) => Permission::JmapPrincipalGet,
GetRequestMethod::Quota(_) => Permission::JmapQuotaGet,
GetRequestMethod::Blob(_) => Permission::JmapBlobGet,
@@ -431,6 +434,15 @@ impl JmapAuthorization for AccessToken {
Permission::SysDomainUpdate,
Permission::SysDomainUpdate,
),
// inbuxa: C-4, where the front ends are: the HTTP
// settings' (the method also needs sysOAuthClientUpdate)
SetRequestMethod::FrontEnds(s) => validate_set(
s,
self,
Permission::SysHttpUpdate,
Permission::SysHttpUpdate,
Permission::SysHttpUpdate,
),
SetRequestMethod::VacationResponse(s) => validate_set(
s,
self,
@@ -567,7 +579,8 @@ impl JmapAuthorization for AccessToken {
| MethodObject::JournalVerification
| MethodObject::ProtocolPolicy
| MethodObject::TenantProtocolPolicy
| MethodObject::SharingPolicy => Permission::JmapEmailChanges,
| MethodObject::SharingPolicy
| MethodObject::FrontEnds => Permission::JmapEmailChanges,
// inbuxa: x:MaskedEmail/changes reads what /get reads
MethodObject::Registry(object_type) => object_type.get_permission(),
},
+27
View File
@@ -335,6 +335,9 @@ impl RequestHandler for Server {
SetResponseMethod::SharingPolicy(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::FrontEnds(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::AddressBook(set_response) => {
set_response.update_created_ids(&mut response);
}
@@ -631,6 +634,13 @@ impl RequestHandler for Server {
.await?
.into()
}
// inbuxa: inbuxa:FrontEnds/get (C-4, where the front ends are)
GetRequestMethod::FrontEnds(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::front_ends::get(self, access_token, *req)
.await?
.into()
}
GetRequestMethod::Principal(req) => {
self.principal_get(*req, access_token).await?.into()
}
@@ -1300,6 +1310,23 @@ impl RequestHandler for Server {
.await?
.into()
}
// inbuxa: inbuxa:FrontEnds/set (C-4, where the front ends are)
SetRequestMethod::FrontEnds(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
// inbuxa: AU-1.2, AU-3
crate::inbuxa::audit::recorded(
self,
access_token,
session,
&method_name.obj.to_string(),
None,
None,
*req,
|req| Box::pin(crate::inbuxa::front_ends::set(self, access_token, req)),
)
.await?
.into()
}
SetRequestMethod::AddressBook(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
access_token.assert_has_access(req.account_id, Collection::AddressBook)?;
+1
View File
@@ -445,6 +445,7 @@ impl IntermediateChangesResponse {
| MethodObject::ProtocolPolicy
| MethodObject::TenantProtocolPolicy
| MethodObject::SharingPolicy
| MethodObject::FrontEnds
| MethodObject::Registry(_) => unreachable!(),
})
}
+311
View File
@@ -0,0 +1,311 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:FrontEnds/get` and `/set`: where the server's front ends are
//! (contract C-4). A server setting, so a server administrator's only: the
//! HTTP settings' permissions, and `sysOAuthClientUpdate` to change it, since
//! setting it registers OAuth clients. Inside a tenant it doesn't exist.
//!
//! Setting it registers the front ends' OAuth clients at once (C-6) and
//! reloads the HTTP settings here and on every node, which is what puts the
//! new origins on the cross-origin allowlist (C-14). The environment
//! variables keep working beside it; `get` shows what they say, so whoever
//! reads it can tell which address came from where.
//!
//! It's stored in the registry's store, which is the one start-up and every
//! reload read it from.
use common::{Server, auth::AccessToken, manager::first_party};
use directory::core::secret::hash_secret;
use inbuxa_features::security::front_ends::{self, FrontEnds as Stored, invalid_url};
use jmap_proto::{
error::set::SetError,
method::{
get::{GetRequest, GetResponse},
set::{SetRequest, SetResponse},
},
object::inbuxa_front_ends::{FrontEnds, FrontEndsProperty as P, FrontEndsValue},
request::IntoValid,
};
use jmap_tools::{Key, Map, Value};
use registry::schema::{
enums::{PasswordHashAlgorithm, Permission},
prelude::ObjectType,
};
use store::registry::bootstrap::Bootstrap;
use trc::AddContext;
use types::id::Id;
type PValue = Value<'static, P, FrontEndsValue>;
const ALL: &[P] = &[
P::Id,
P::AdminUrl,
P::WebmailUrl,
P::WebmailClientSecret,
P::WebmailClientSecretSet,
P::ExtraOrigins,
P::EnvironmentAdminUrl,
P::EnvironmentWebmailUrl,
P::EnvironmentWebmailClientSecret,
P::ChangedAt,
P::ChangedBy,
];
fn env(name: &str) -> Option<String> {
types::branding::env_var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn opt_str(value: Option<&String>) -> PValue {
value
.map(|v| Value::Str(v.clone().into()))
.unwrap_or(Value::Null)
}
fn to_value(stored: &Stored, properties: &[P]) -> PValue {
let mut out = Map::with_capacity(properties.len());
for property in properties {
let value = match property {
P::Id => Value::Element(FrontEndsValue::Id(Id::singleton())),
P::AdminUrl => opt_str(stored.admin_url.as_ref()),
P::WebmailUrl => opt_str(stored.webmail_url.as_ref()),
// Write-only: the stored form is a hash, and even that stays in
P::WebmailClientSecret => Value::Null,
P::WebmailClientSecretSet => Value::Bool(stored.webmail_secret_hash.is_some()),
P::ExtraOrigins => Value::Array(
stored
.extra_origins
.iter()
.map(|o| Value::Str(o.clone().into()))
.collect(),
),
P::EnvironmentAdminUrl => opt_str(env("ADMIN_URL").as_ref()),
P::EnvironmentWebmailUrl => opt_str(env("WEBMAIL_URL").as_ref()),
P::EnvironmentWebmailClientSecret => {
Value::Bool(first_party::webmail_secret_from_environment())
}
P::ChangedAt => stored
.changed_at
.map(|at| Value::Number(at.into()))
.unwrap_or(Value::Null),
P::ChangedBy => opt_str(stored.changed_by.as_ref()),
};
out.insert_unchecked(Key::Property(property.clone()), value);
}
Value::Object(out)
}
/// `inbuxa:FrontEnds/get`.
pub async fn get(
server: &Server,
access_token: &AccessToken,
mut request: GetRequest<FrontEnds>,
) -> trc::Result<GetResponse<FrontEnds>> {
let properties = request.unwrap_properties(ALL);
let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?;
let mut response = GetResponse {
account_id: request.account_id.into(),
state: None,
list: Vec::new(),
not_found,
};
let wanted = ids.unwrap_or_else(|| vec![Id::singleton()]);
let bp = Bootstrap::new(server.registry().clone()).await;
for id in wanted {
if id.is_singleton() && access_token.tenant_id().is_none() {
let stored = front_ends::get(&bp.data_store).await?;
response.list.push(to_value(&stored, &properties));
} else {
response.push_not_found(id);
}
}
Ok(response)
}
fn invalid(key: Key<'_, P>, why: impl Into<String>) -> SetError<P> {
SetError::invalid_properties()
.with_property(key.into_owned())
.with_description(why.into())
}
/// A URL property: a front end's address, or null to clear it.
fn parse_url(
key: &Key<'_, P>,
value: &Value<'_, P, FrontEndsValue>,
) -> Result<Option<String>, SetError<P>> {
match value {
Value::Null => Ok(None),
Value::Str(s) => match invalid_url(s) {
None => Ok(Some(s.trim().trim_end_matches('/').to_string())),
Some(why) => Err(invalid(key.clone(), why)),
},
_ => Err(invalid(key.clone(), "must be a URL or null")),
}
}
/// `inbuxa:FrontEnds/set`: updates the singleton. `null` clears a property.
pub async fn set(
server: &Server,
access_token: &AccessToken,
mut request: SetRequest<'_, FrontEnds>,
) -> trc::Result<SetResponse<FrontEnds>> {
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
for (client_id, _) in request.unwrap_create() {
response.not_created.append(
client_id,
SetError::forbidden()
.with_description("inbuxa:FrontEnds is a singleton that always exists."),
);
}
for id in request.unwrap_destroy().into_valid() {
response.not_destroyed.append(
id,
SetError::forbidden()
.with_description("inbuxa:FrontEnds is a singleton that always exists."),
);
}
let mut bp = Bootstrap::new(server.registry().clone()).await;
let mut changed = false;
for (id, value) in request.unwrap_update().into_valid() {
if !id.is_singleton() || access_token.tenant_id().is_some() {
response.not_updated.append(id, SetError::not_found());
continue;
}
// Setting it registers OAuth clients, so it takes that permission too
if !access_token.has_permission(Permission::SysOAuthClientUpdate) {
response.not_updated.append(
id,
SetError::forbidden().with_description(
"Changing the front ends registers OAuth clients, which needs sysOAuthClientUpdate.",
),
);
continue;
}
let previous = front_ends::get(&bp.data_store).await?;
let mut next = previous.clone();
let mut error = None;
for (key, value) in value.into_expanded_object() {
let result: Result<(), SetError<P>> = match &key {
Key::Property(P::AdminUrl) => parse_url(&key, &value).map(|v| next.admin_url = v),
Key::Property(P::WebmailUrl) => {
parse_url(&key, &value).map(|v| next.webmail_url = v)
}
Key::Property(P::WebmailClientSecret) => match &value {
Value::Null => {
next.webmail_secret_hash = None;
Ok(())
}
Value::Str(_) if first_party::webmail_secret_from_environment() => {
Err(SetError::forbidden().with_description(
"This server takes the webmail's client secret from \
INBUXA_WEBMAIL_CLIENT_SECRET, which would replace this one at \
every start. Remove it from the server's environment first.",
))
}
Value::Str(s) if s.len() >= 32 => {
next.webmail_secret_hash = Some(
hash_secret(PasswordHashAlgorithm::Argon2id, s.as_bytes().to_vec())
.await?,
);
Ok(())
}
Value::Str(_) => Err(invalid(key.clone(), "must be at least 32 characters")),
_ => Err(invalid(key.clone(), "must be a string or null")),
},
Key::Property(P::ExtraOrigins) => match value {
Value::Null => {
next.extra_origins.clear();
Ok(())
}
Value::Array(items) => {
let mut origins = Vec::with_capacity(items.len());
let mut bad = None;
for item in items {
match item {
Value::Str(s) => match first_party::origin_of(&s) {
Some(origin) if !origins.contains(&origin) => {
origins.push(origin)
}
Some(_) => {}
None => {
bad = Some(format!("{s:?} is not an http or https origin"))
}
},
_ => bad = Some("must be a list of origins".to_string()),
}
}
match bad {
Some(why) => Err(invalid(key.clone(), why)),
None => {
next.extra_origins = origins;
Ok(())
}
}
}
_ => Err(invalid(key.clone(), "must be a list of origins or null")),
},
Key::Property(P::Id) => Err(invalid(key.clone(), "is immutable")),
Key::Property(property) if property.is_server_set() => {
Err(invalid(key.clone(), "is set by the server"))
}
_ => Err(invalid(
key.clone(),
"is not a property of inbuxa:FrontEnds",
)),
};
if let Err(err) = result {
error = Some(err);
break;
}
}
if let Some(error) = error {
response.not_updated.append(id, error);
continue;
}
if next.webmail_url.is_some()
&& next.webmail_secret_hash.is_none()
&& !first_party::webmail_secret_from_environment()
{
response.not_updated.append(
id,
SetError::invalid_properties()
.with_property(Key::Property(P::WebmailClientSecret))
.with_description("The webmail needs a client secret to be registered."),
);
continue;
}
let mut compare = previous.clone();
compare.changed_at = next.changed_at;
compare.changed_by = next.changed_by.clone();
if compare != next {
next.changed_at = Some(store::write::now());
next.changed_by = Some(Id::from(access_token.account_id()).to_string());
front_ends::set(&bp.data_store, &next)
.await
.caused_by(trc::location!())?;
changed = true;
}
response.updated.append(id, None);
}
if changed {
// The OAuth clients now, so the front end can sign in at once (C-6)
first_party::ensure_first_party_clients(&mut bp)
.await
.caused_by(trc::location!())?;
// The allowlist is built with the HTTP settings, here and on every
// node (C-14). The value is stored whatever the reload says, as for
// any registry write; a refused reload is logged by the reload.
let _ = server.reload_after_write(ObjectType::Http).await;
}
Ok(response)
}
+1
View File
@@ -31,6 +31,7 @@ pub mod explanation;
pub mod protocol_policy;
pub mod tenant_protocol_policy;
pub mod sharing_policy;
pub mod front_ends;
pub mod deleted_account;
pub mod fastmail;
pub mod masked_email;
+28 -8
View File
@@ -89,6 +89,23 @@ Each has an ID, and tests name the IDs they check.
client registrations (C-6), the cross-origin allowlist (C-14) and the admin
lane (C-18). The installer sets it, and so does INBUXA Admin's setup wizard.
**Built, 2026-10-11, as `inbuxa:FrontEnds`.** A fork JMAP object under
`urn:inbuxa:jmap` rather than a registry object (open question 1), kept in
the fork's subspace (`Fe`), like the other `inbuxa:` singletons; id
`singleton`. Properties: `adminUrl`, `webmailUrl`, `webmailClientSecret`
(write-only, at least 32 characters, stored only as its Argon2 hash;
`webmailClientSecretSet` says whether one is), `extraOrigins`, and,
server-set, `environmentAdminUrl`, `environmentWebmailUrl`,
`environmentWebmailClientSecret`, `changedAt`, `changedBy`. `/get` needs
`sysHttpGet`; `/set` needs `sysHttpUpdate` and `sysOAuthClientUpdate`;
neither exists inside a tenant. A set registers the clients at once (C-6)
and reloads the HTTP settings on every node (C-14); it's audited. The
environment variables below keep working beside it and both are honored;
where both give the webmail a secret the environment's wins, so setting
one is refused while `INBUXA_WEBMAIL_CLIENT_SECRET` is set. The installer's
`inbuxa join` sets it from a second machine. C-1 discovery and C-18's
admin lane don't read it yet. Tests: `system::front_ends::front_ends_tests`.
### Sign-in
- **C-5.** INBUXA requires client registration: `requireClientRegistration`
@@ -126,8 +143,8 @@ Each has an ID, and tests name the IDs they check.
**Built (interim), 2026-09-18.** C-5's defaults are in the server, and
`crates/common/src/manager/first_party.rs` registers the clients on every
start. Until `x:FrontEnds` exists, three environment variables stand in for
it: `INBUXA_ADMIN_URL`, `INBUXA_WEBMAIL_URL` and
start, and again whenever `inbuxa:FrontEnds` is set (C-4). Beside it,
three environment variables give the same addresses: `INBUXA_ADMIN_URL`, `INBUXA_WEBMAIL_URL` and
`INBUXA_WEBMAIL_CLIENT_SECRET` (the webmail client is registered only when
both of its variables are set). A web interface the server serves itself
(none on a new install since SPEC.md §5.3; possible on one upgraded from
@@ -206,9 +223,10 @@ Each has an ID, and tests name the IDs they check.
`Access-Control-Allow-Origin` with `Vary: Origin`, never `*`, and sends
nothing for any other origin.
**Built (interim), 2026-09-18.** Until `x:FrontEnds` exists, the allowed
origins come from `INBUXA_ADMIN_URL`, `INBUXA_WEBMAIL_URL` and
`INBUXA_CORS_EXTRA_ORIGINS` (comma-separated). Each is reduced to its origin,
**Built (interim), 2026-09-18.** The allowed origins come from
`INBUXA_ADMIN_URL`, `INBUXA_WEBMAIL_URL` and `INBUXA_CORS_EXTRA_ORIGINS`
(comma-separated), and since 2026-10-11 also from `inbuxa:FrontEnds`'s
`adminUrl`, `webmailUrl` and `extraOrigins` (C-4). Each is reduced to its origin,
lowercased and without a default port, and anything that isn't an `http` or
`https` URL is skipped. Every response carries `Vary: Origin`. A matching
origin gets `Access-Control-Allow-Origin` echoed with the allowed headers and
@@ -396,9 +414,11 @@ client and reload settings. This is the state C-5 and C-6 make the default.
## Open questions
1. Whether `x:FrontEnds` is a new registry object (a schema addition in the
fork's namespace) or kept in the fork's own store. A registry object is
editable in INBUXA Admin for free, since the admin is schema-driven.
1. ~~Whether `x:FrontEnds` is a new registry object (a schema addition in the
fork's namespace) or kept in the fork's own store.~~ Settled 2026-10-11:
the fork's own store, as `inbuxa:FrontEnds` (C-4), like the other `inbuxa:`
singletons, which keeps upstream's generated schema untouched. INBUXA Admin
needs a page for it, as it has for the sharing policy.
2. The consent page's wording and whether it remembers a decision per client.
3. API keys and app passwords with explicit scopes (C-21): what upstream's
`x:ApiKey` already supports, to observe before specifying.
+11
View File
@@ -296,6 +296,17 @@ retention = "unbounded"
[object."inbuxa:SharingPolicy".properties]
changedBy = ["identifier"]
[object."inbuxa:FrontEnds"]
file = "inbuxa_front_ends.rs"
default = "none"
whose = ["administrator"]
where = ["data-store"]
scope = "server"
retention = "unbounded"
[object."inbuxa:FrontEnds".properties]
webmailClientSecret = ["credential"]
changedBy = ["identifier"]
[object."inbuxa:AiLimits"]
file = "inbuxa_ai_limits.rs"
default = "none"
+348
View File
@@ -0,0 +1,348 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Where the front ends are (contract C-4): `inbuxa:FrontEnds` registers the
//! console's and the webmail's OAuth clients and puts their origins on the
//! cross-origin allowlist, at once and without touching the server's
//! environment. That is what the installer's `inbuxa join` relies on.
use crate::utils::{account::Account, server::TestServerBuilder};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use registry::schema::structs::OAuthClient;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::time::Duration;
const USING: &[&str] = &["urn:ietf:params:jmap:core", "urn:inbuxa:jmap"];
const ADMIN: &str = "https://admin.joined.example";
const WEBMAIL: &str = "https://webmail.joined.example";
const SECRET: &str = "a-webmail-secret-of-more-than-thirty-two-characters";
const VERIFIER: &str = "a-pkce-code-verifier-of-forty-three-or-more-characters";
impl Account {
async fn front_ends_call(&self, method: &str, arguments: Value) -> Value {
let response = self
.jmap_request(USING, json!([[method, arguments, "0"]]))
.await;
response
.0
.pointer("/methodResponses/0/1")
.cloned()
.unwrap_or_else(|| panic!("{method}: {}", response.0))
}
async fn front_ends(&self) -> Value {
self.front_ends_call(
"inbuxa:FrontEnds/get",
json!({"accountId": self.id_string()}),
)
.await["list"][0]
.clone()
}
async fn set_front_ends(&self, update: Value) -> Value {
self.front_ends_call(
"inbuxa:FrontEnds/set",
json!({"accountId": self.id_string(), "update": {"singleton": update}}),
)
.await
}
/// The `Access-Control-Allow-Origin` a browser at `origin` gets on the
/// preflight it sends before an authorized JMAP request, if any.
async fn allowed_origin(&self, origin: &str) -> Option<String> {
let response = http()
.request(
reqwest::Method::OPTIONS,
format!("{}/jmap/session", self.base_url()),
)
.header("origin", origin)
.header("access-control-request-method", "GET")
.header("access-control-request-headers", "authorization")
.send()
.await
.unwrap();
response
.headers()
.get("access-control-allow-origin")
.and_then(|v| v.to_str().ok())
.map(str::to_string)
}
/// An authorization code for this account, for the webmail at WEBMAIL,
/// the way the server's own sign-in page gets one.
async fn webmail_code(&self) -> String {
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(VERIFIER.as_bytes()));
let response: Value = http()
.post(format!("{}/api/auth", self.base_url()))
.json(&json!({
"type": "authCode",
"accountName": self.name(),
"accountSecret": self.secret(),
"clientId": "ihasmail-inbuxa",
"redirectUri": format!("{WEBMAIL}/api/auth/callback"),
"codeChallenge": challenge,
"codeChallengeMethod": "S256",
}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
["clientCode", "client_code", "code"]
.iter()
.find_map(|k| response.get(*k).and_then(Value::as_str))
.unwrap_or_else(|| panic!("C-6: no code for the webmail: {response}"))
.to_string()
}
/// Exchanges a code at the token endpoint as the webmail, with `secret`.
async fn exchange(&self, code: &str, secret: &str) -> Value {
http()
.post(format!("{}/auth/token", self.base_url()))
.form(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", &format!("{WEBMAIL}/api/auth/callback")),
("client_id", "ihasmail-inbuxa"),
("client_secret", secret),
("code_verifier", VERIFIER),
])
.send()
.await
.unwrap()
.json()
.await
.unwrap()
}
async fn client(&self, client_id: &str) -> Option<OAuthClient> {
self.registry_get_all::<OAuthClient>()
.await
.into_iter()
.map(|(_, client)| client)
.find(|client| client.client_id == client_id)
}
}
fn http() -> reqwest::Client {
reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(10))
.build()
.unwrap()
}
fn updated(response: &Value) -> bool {
response["updated"]["singleton"].is_null() && response["updated"].is_object()
}
fn refusal(response: &Value) -> String {
response["notUpdated"]["singleton"]["type"]
.as_str()
.unwrap_or_default()
.to_string()
}
/// Runs these tests alone: `cargo test -p tests front_ends_tests -- --ignored`.
#[ignore]
#[tokio::test(flavor = "multi_thread")]
pub async fn front_ends_tests() {
let mut test = TestServerBuilder::new("front_ends_tests")
.await
.with_default_listeners()
.await
.build()
.await;
let admin = test.create_admin_account("[email protected]").await;
let ann = admin
.create_user_account("[email protected]", "ann-secret-7710", "Ann", &[], vec![])
.await;
println!("Running front ends tests...");
// Unset, it says so, and changes nothing
let current = admin.front_ends().await;
assert_eq!(current["id"], "singleton", "{current}");
assert!(current["adminUrl"].is_null(), "{current}");
assert_eq!(current["webmailClientSecretSet"], false, "{current}");
assert_eq!(
current["environmentWebmailClientSecret"], false,
"{current}"
);
assert_eq!(
admin.allowed_origin(ADMIN).await,
None,
"C-14: allowed before it was set"
);
// Someone who isn't a server administrator can't change it
let response = ann.set_front_ends(json!({"adminUrl": ADMIN})).await;
assert!(
!updated(&response),
"C-4: an ordinary account changed the front ends: {response}"
);
// What isn't a front end's address is refused, with the property named
for (property, value) in [
("adminUrl", json!("admin.joined.example")),
("webmailUrl", json!("ftp://webmail.joined.example")),
("webmailClientSecret", json!("short")),
("extraOrigins", json!(["not an origin"])),
("webmailClientSecretSet", json!(true)),
("environmentAdminUrl", json!(ADMIN)),
] {
let response = admin.set_front_ends(json!({property: value})).await;
assert_eq!(
refusal(&response),
"invalidProperties",
"{property}: {response}"
);
}
// A webmail's address without its secret would register nothing
let response = admin.set_front_ends(json!({"webmailUrl": WEBMAIL})).await;
assert_eq!(refusal(&response), "invalidProperties", "{response}");
// A singleton: never created or destroyed
let response = admin
.front_ends_call(
"inbuxa:FrontEnds/set",
json!({"accountId": admin.id_string(), "create": {"x": {}}, "destroy": ["singleton"]}),
)
.await;
assert_eq!(
response["notCreated"]["x"]["type"], "forbidden",
"{response}"
);
assert_eq!(
response["notDestroyed"]["singleton"]["type"], "forbidden",
"{response}"
);
// Set: the clients are registered and the origins allowed at once
let response = admin
.set_front_ends(json!({
"adminUrl": format!("{ADMIN}/"),
"webmailUrl": WEBMAIL,
"webmailClientSecret": SECRET,
"extraOrigins": ["http://localhost:5173/some/path"],
}))
.await;
assert!(updated(&response), "C-4: {response}");
let current = admin.front_ends().await;
assert_eq!(current["adminUrl"], ADMIN, "trailing slash kept: {current}");
assert_eq!(current["webmailUrl"], WEBMAIL, "{current}");
assert!(
current["webmailClientSecret"].is_null(),
"the secret was read back: {current}"
);
assert_eq!(current["webmailClientSecretSet"], true, "{current}");
assert_eq!(
current["extraOrigins"],
json!(["http://localhost:5173"]),
"{current}"
);
assert!(current["changedAt"].is_number(), "{current}");
let console = admin
.client("inbuxa-admin")
.await
.expect("C-6: no inbuxa-admin client");
assert!(
console
.redirect_uris
.iter()
.any(|uri| uri == &format!("{ADMIN}/oauth/callback")),
"C-6: {:?}",
console.redirect_uris
);
let webmail = admin
.client("ihasmail-inbuxa")
.await
.expect("C-6: no ihasmail-inbuxa client");
assert!(
webmail
.redirect_uris
.iter()
.any(|uri| uri == &format!("{WEBMAIL}/api/auth/callback")),
"C-6: {:?}",
webmail.redirect_uris
);
// The secret is read back redacted, as it should be, so it's proved the
// way the webmail uses it: a code for Ann, exchanged as the webmail.
let code = ann.webmail_code().await;
assert_eq!(
ann.exchange(&code, "not-the-secret").await["error"],
"invalid_client",
"C-6: a wrong webmail secret was taken"
);
let granted = ann.exchange(&code, SECRET).await;
assert!(
granted["access_token"].is_string(),
"C-6: the webmail's secret was refused: {granted}"
);
assert_eq!(
admin.allowed_origin(ADMIN).await.as_deref(),
Some(ADMIN),
"C-14"
);
assert_eq!(
admin.allowed_origin(WEBMAIL).await.as_deref(),
Some(WEBMAIL),
"C-14"
);
assert_eq!(
admin
.allowed_origin("http://localhost:5173")
.await
.as_deref(),
Some("http://localhost:5173"),
"C-14: extra origin"
);
assert_eq!(
admin.allowed_origin("https://evil.example").await,
None,
"C-14"
);
// Setting it again with the same values changes nothing, secret aside
let response = admin.set_front_ends(json!({"adminUrl": ADMIN})).await;
assert!(updated(&response), "{response}");
// Cleared, the console's origin is no longer allowed
let response = admin
.set_front_ends(json!({"adminUrl": null, "extraOrigins": null}))
.await;
assert!(updated(&response), "{response}");
assert_eq!(
admin.allowed_origin(ADMIN).await,
None,
"C-14: still allowed after clearing"
);
assert_eq!(
admin.allowed_origin(WEBMAIL).await.as_deref(),
Some(WEBMAIL),
"C-14"
);
// AU-1.2: every change is in the audit log
let query = admin
.front_ends_call(
"inbuxa:AuditEvent/query",
json!({"accountId": admin.id_string(), "filter": {"targetKind": "inbuxa:FrontEnds"}}),
)
.await;
assert!(
query["ids"].as_array().is_some_and(|ids| ids.len() >= 2),
"AU-1.2: {query}"
);
if test.is_reset() {
test.temp_dir.delete();
}
}
+1
View File
@@ -13,6 +13,7 @@ pub mod ai_calibration;
pub mod ai_explain;
pub mod account_lock; // inbuxa: account lock with delegation
pub mod sharing_policy; // inbuxa: MA-C, who may share mail
pub mod front_ends; // inbuxa: C-4, where the front ends are
pub mod legal_hold; // inbuxa: legal hold
pub mod compliance; // inbuxa: the compliance roles
pub mod mail_rules; // inbuxa: DLP and mail flow rules