Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
558b776e9f | ||
|
|
6c862e4971 | ||
|
|
5a73a1183a | ||
|
|
ac204078eb | ||
|
|
728586998b | ||
|
|
cca49de92c | ||
|
|
b20b09f81a | ||
|
|
7bbbff0648 | ||
|
|
a8fb10458b | ||
|
|
c61497e2b2 | ||
|
|
7285b3e38a | ||
|
|
9893452ca2 | ||
|
|
63adb4e2b8 | ||
|
|
d107c1b2bb | ||
|
|
1d5a49409f | ||
|
|
80d6c09c59 | ||
|
|
480d93f4d6 | ||
|
|
f47371b3a1 | ||
|
|
bdd97c5828 | ||
|
|
a0ffdb8071 | ||
|
|
18b28fad27 | ||
|
|
a8dde68800 | ||
|
|
09c55ba503 | ||
|
|
eac3db34e9 | ||
|
|
6945714aa9 | ||
|
|
b2453d066b | ||
|
|
f59b084ce5 | ||
|
|
85ea0c80e9 | ||
|
|
a588a8aa7d | ||
|
|
35cf3f405f | ||
|
|
de275bac60 | ||
|
|
305406a331 | ||
|
|
f5888d79b0 | ||
|
|
8e9cedbe97 | ||
|
|
5ba54e8fb7 | ||
|
|
db817dd507 | ||
|
|
b7e3a765ca | ||
|
|
7ba9ec9fa0 | ||
|
|
4c07779c16 | ||
|
|
e1e8a9aeb0 | ||
|
|
5c506b9d2b | ||
|
|
3978cf5785 | ||
|
|
815a642cc4 | ||
|
|
1d5f4a2cd3 | ||
|
|
68dd749291 | ||
|
|
b1bc5ed6e0 |
@@ -40,6 +40,12 @@ jobs:
|
||||
# nothing. CI never sees the difference; a release does.
|
||||
- if: always()
|
||||
run: python3 tools/fork/context-check.py
|
||||
# The personal-data catalog must classify every object and field the
|
||||
# schema has, and name nothing that is gone.
|
||||
- if: always()
|
||||
run: python3 tools/fork/privacy-check.py
|
||||
- if: always()
|
||||
run: python3 -m unittest discover -s tools/fork/tests
|
||||
|
||||
build:
|
||||
# Either runner (host1 or host2): the build needs no docker socket.
|
||||
|
||||
@@ -2,6 +2,39 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [0.16.24] - 2026-09-27
|
||||
|
||||
If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions.
|
||||
|
||||
## Added
|
||||
- DNS: PowerDNS Authoritative provider for automatic DNS record management.
|
||||
|
||||
## Changed
|
||||
|
||||
## Fixed
|
||||
- Troubleshoot tool: `TLSA` records are looked up for every MX host, including hosts whose zone is not DNSSEC signed.
|
||||
- Spam filter:
|
||||
- OpenPhish and PhishTank entries containing uppercase characters never match, since message URLs are lowercased while HTTP lookup entries keep their original case. HTTP lookups now match keys case-insensitively.
|
||||
- URL shortener links are followed using the lowercased URL, so case-sensitive short links resolve to the wrong destination or not at all.
|
||||
- Incremental training never advances its position past the first run, so every retained sample added since then is trained again, and counted again in the reservoir, on each run until it expires.
|
||||
- Updating the rules only adds new objects, so upstream changes to existing rules, DNSBL servers, HTTP lookups, lookup keys and file extensions never reach an existing installation.
|
||||
- Updating the rules reports success when objects fail to import, or when a configuration error stops the updated settings from being activated.
|
||||
- JMAP:
|
||||
- A `PushSubscription` created within the verification rate limit window of another one on the same account never receives its `PushVerification`, since the blocked verification is dropped instead of being sent once the window expires.
|
||||
- A push notification retried after a failed delivery can report an older state than a change queued during the failed attempt, since the older state changes are merged last and overwrite the newer ones.
|
||||
- Changes made while a push request is in flight are not delivered until the next change reaches the same subscription, since a successful delivery cancels the pending retry.
|
||||
- The VAPID `aud` claim is derived from a hand-written parse of the push URL, so a crafted push URL can make the server sign a token for a push service other than the one the request is sent to.
|
||||
- `Email/import` rejects a `blobId` that refers to a `Blob/upload` creation id in the same request (`"#u0"`) with `Invalid blob id.`.
|
||||
- `Email/set` with a full `mailboxIds` object identical to the current mailboxes, together with a keyword change, stores the message with IMAP UID 0, so IMAP clients stop seeing it.
|
||||
- MTA:
|
||||
- A node without the `outboundMta` role stops replying to `DATA` and to JMAP submissions once about 1024 messages have been queued on it.
|
||||
- MX records are resolved through the DNSSEC-validating resolver even when DANE is disabled.
|
||||
- A `DATA` stage Sieve script does not see headers added by milters or MTA hooks, and discards every milter and MTA hook change when it edits the message.
|
||||
- MySQL: Range deletions and search index removals start with a single unbounded `DELETE` and switch to chunks only after a timeout.
|
||||
- IMAP: `COPY` and `MOVE` fail with `NO [CONTACTADMIN]` when another session changes the same message at the same time.
|
||||
- Autodiscover: Implicit TLS ports (993, 995, 465) are advertised with `<Encryption>TLS</Encryption>`, which Outlook reads as STARTTLS.
|
||||
- HTTP: Idle keep-alive connections are never closed.
|
||||
|
||||
## [0.16.23] - 2026-09-21
|
||||
|
||||
If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions.
|
||||
|
||||
Generated
+177
-177
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "common"
|
||||
version = "0.16.23"
|
||||
version = "0.16.24"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@@ -290,6 +290,12 @@ impl Default for DefaultPermissions {
|
||||
default.superuser.push(permission);
|
||||
default.tenant.push(permission);
|
||||
}
|
||||
// inbuxa: personal-data catalog: the data inventory, the
|
||||
// server's or, inside a tenant, the tenant's slice
|
||||
Permission::SysComplianceGet => {
|
||||
default.superuser.push(permission);
|
||||
default.tenant.push(permission);
|
||||
}
|
||||
// inbuxa: AL-12: tenant administrators lock and delegate
|
||||
// within their tenant
|
||||
Permission::SysAccountLockGet
|
||||
|
||||
@@ -46,10 +46,10 @@ pub struct Network {
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct NetworkInfo {
|
||||
pub pacc: Pacc,
|
||||
/// inbuxa: the same document without IMAP, POP3, SMTP and ManageSieve,
|
||||
/// served while legacy protocols are off (legacy-protocols LP-7).
|
||||
pub pacc_jmap_only: Pacc,
|
||||
/// inbuxa: the document once per combination of legacy protocols off,
|
||||
/// indexed by `LegacyOff::index` (legacy-protocols LP-7, one switch per
|
||||
/// protocol); index 0 is the full document.
|
||||
pub pacc: Vec<Pacc>,
|
||||
pub mxs: Vec<MailExchanger>,
|
||||
pub services: VecMap<ServiceProtocol, Service>,
|
||||
}
|
||||
@@ -333,16 +333,27 @@ impl Network {
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
// inbuxa: legacy-protocols LP-7
|
||||
let pacc_jmap_only = {
|
||||
let mut pacc = pacc.clone();
|
||||
pacc.protocols.imap = None;
|
||||
pacc.protocols.pop3 = None;
|
||||
pacc.protocols.smtp = None;
|
||||
pacc.protocols.managesieve = None;
|
||||
split(&pacc)
|
||||
};
|
||||
let pacc = split(&pacc);
|
||||
// inbuxa: legacy-protocols LP-7, one document per combination of
|
||||
// protocols off, bits as `LegacyOff::index`: IMAP, POP3, ManageSieve,
|
||||
// submission.
|
||||
let pacc = (0..16usize)
|
||||
.map(|off| {
|
||||
let mut pacc = pacc.clone();
|
||||
if off & 1 != 0 {
|
||||
pacc.protocols.imap = None;
|
||||
}
|
||||
if off & 2 != 0 {
|
||||
pacc.protocols.pop3 = None;
|
||||
}
|
||||
if off & 4 != 0 {
|
||||
pacc.protocols.managesieve = None;
|
||||
}
|
||||
if off & 8 != 0 {
|
||||
pacc.protocols.smtp = None;
|
||||
}
|
||||
split(&pacc)
|
||||
})
|
||||
.collect();
|
||||
let mut network = Network {
|
||||
node_id: bp.node_id() as u64,
|
||||
server_name: default_hostname.to_string(),
|
||||
@@ -358,7 +369,6 @@ impl Network {
|
||||
mxs: system.mail_exchangers.into_iter().collect(),
|
||||
services: system.services,
|
||||
pacc,
|
||||
pacc_jmap_only,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -483,8 +483,16 @@ impl Tracers {
|
||||
};
|
||||
|
||||
// Parse webhook events
|
||||
// inbuxa: personal-data catalog, finding 1: an include list is
|
||||
// sent as named; otherwise a webhook honors its level as a
|
||||
// tracer does, and never sends a protocol's raw input or
|
||||
// output (whole messages)
|
||||
let level = Level::from(hook.level);
|
||||
let named = (hook.events_policy == EventPolicy::Include)
|
||||
.then(|| hook.events.iter().copied().collect::<AHashSet<_>>())
|
||||
.unwrap_or_default();
|
||||
apply_events(hook.events, hook.events_policy, |event_type| {
|
||||
if event_type != EventType::Telemetry(TelemetryEvent::WebhookError) {
|
||||
if webhook_wants(event_type, level, &custom_levels, &named) {
|
||||
tracer.interests.set(event_type);
|
||||
global_interests.set(event_type);
|
||||
}
|
||||
@@ -743,6 +751,31 @@ fn tracer_settings(tracer: &Tracer) -> u64 {
|
||||
settings_hash(&tracer)
|
||||
}
|
||||
|
||||
/// inbuxa: whether a webhook at `level` receives this event type. Its own
|
||||
/// error event never, or a failing webhook would report itself to itself.
|
||||
/// An event `named` in an include list always: naming it is the choice.
|
||||
/// Otherwise (the exclude policy, the default) only events at or above its
|
||||
/// level, as for a tracer, and never a protocol's raw input or output, which
|
||||
/// carries whole messages and credentials.
|
||||
fn webhook_wants(
|
||||
event_type: EventType,
|
||||
level: Level,
|
||||
custom_levels: &AHashMap<EventType, Level>,
|
||||
named: &AHashSet<EventType>,
|
||||
) -> bool {
|
||||
if event_type == EventType::Telemetry(TelemetryEvent::WebhookError) {
|
||||
return false;
|
||||
}
|
||||
if named.contains(&event_type) {
|
||||
return true;
|
||||
}
|
||||
let event_level = custom_levels
|
||||
.get(&event_type)
|
||||
.copied()
|
||||
.unwrap_or(event_type.level());
|
||||
level.is_contained(event_level) && !event_type.is_raw_io()
|
||||
}
|
||||
|
||||
fn webhook_settings(hook: &WebHook) -> u64 {
|
||||
let mut hook = hook.clone();
|
||||
in_place_reset!(hook);
|
||||
@@ -804,3 +837,61 @@ impl std::fmt::Debug for OtelMetrics {
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use trc::{AuthEvent, SmtpEvent};
|
||||
|
||||
fn wants(event: EventType, level: Level, named: &[EventType]) -> bool {
|
||||
webhook_wants(
|
||||
event,
|
||||
level,
|
||||
&AHashMap::new(),
|
||||
&named.iter().copied().collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_webhook_honors_its_level() {
|
||||
let success = EventType::Auth(AuthEvent::Success);
|
||||
assert!(wants(success, Level::Info, &[]));
|
||||
assert!(!wants(success, Level::Error, &[]), "info is below error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_io_goes_out_only_when_named() {
|
||||
let raw = EventType::Smtp(SmtpEvent::RawInput);
|
||||
assert!(raw.is_raw_io());
|
||||
// Not with the exclude policy, even at trace
|
||||
assert!(!wants(raw, Level::Info, &[]));
|
||||
assert!(!wants(raw, Level::Trace, &[]));
|
||||
// Named in an include list, whatever the level
|
||||
assert!(wants(raw, Level::Info, &[raw]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_event_is_sent_whatever_its_level() {
|
||||
let start = EventType::Smtp(SmtpEvent::ConnectionStart);
|
||||
assert!(!Level::Info.is_contained(start.level()), "below info");
|
||||
assert!(!wants(start, Level::Info, &[]));
|
||||
assert!(wants(start, Level::Info, &[start]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_custom_level_counts() {
|
||||
let start = EventType::Smtp(SmtpEvent::ConnectionStart);
|
||||
let custom = [(start, Level::Info)].into_iter().collect::<AHashMap<_, _>>();
|
||||
assert!(webhook_wants(start, Level::Info, &custom, &AHashSet::new()));
|
||||
// Raw I/O raised to info still needs naming
|
||||
let raw = EventType::Smtp(SmtpEvent::RawInput);
|
||||
let custom = [(raw, Level::Info)].into_iter().collect::<AHashMap<_, _>>();
|
||||
assert!(!webhook_wants(raw, Level::Info, &custom, &AHashSet::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_webhook_never_hears_its_own_errors() {
|
||||
let own = EventType::Telemetry(TelemetryEvent::WebhookError);
|
||||
assert!(!wants(own, Level::Trace, &[own]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ pub mod auth;
|
||||
pub mod cache;
|
||||
pub mod audit; // inbuxa: the audit log (audit-hold-lock spec, AU)
|
||||
pub mod hold; // inbuxa: legal holds (audit-hold-lock spec, LH)
|
||||
pub mod privacy; // inbuxa: the personal-data catalog, evaluated
|
||||
pub mod config;
|
||||
pub mod expr;
|
||||
pub mod i18n;
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! The compliance roles (personal-data catalog spec, §7; settled
|
||||
//! 2026-09-28): a server-level Compliance Officer, and one Compliance
|
||||
//! Officer role in each tenant. A tenant's accounts can hold only roles of
|
||||
//! their own tenant (MT-3), so the tenant role is made per tenant: once for
|
||||
//! each tenant a server already has, and whenever a tenant is created.
|
||||
//!
|
||||
//! Each creation is recorded under `P` `c` in the fork's subspace, so a
|
||||
//! role an administrator deletes stays deleted. A tenant's role, while
|
||||
//! nobody holds it, is removed with the tenant so it doesn't block the
|
||||
//! delete.
|
||||
//!
|
||||
//! Both read what compliance work needs and change no server setting. The
|
||||
//! server-level officer also places, widens, releases and exports legal
|
||||
//! holds: that is the job, and each is audited with its reason. A tenant's
|
||||
//! role has no holds, which are server-level only (LH-13), and the tenant
|
||||
//! ceiling keeps it within the tenant. Each role carries a user's own
|
||||
//! permissions too (signing in, mail), since roles given to a person replace
|
||||
//! the default user role, and a tenant's accounts can't hold the
|
||||
//! server-level User role.
|
||||
|
||||
use registry::schema::{
|
||||
enums::Permission,
|
||||
prelude::ObjectType,
|
||||
structs::{Role, Tenant},
|
||||
};
|
||||
use registry::types::map::Map;
|
||||
use store::{
|
||||
RegistryStore, SUBSPACE_INBUXA, Store, ValueKey,
|
||||
registry::write::{RegistryWrite, RegistryWriteResult},
|
||||
write::{AnyClass, BatchBuilder, ValueClass},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::id::Id;
|
||||
|
||||
/// The role's name, in the server's roles and in each tenant's.
|
||||
pub const NAME: &str = "Compliance Officer";
|
||||
|
||||
/// Reading who and what records refer to, for both roles.
|
||||
const READS: &[Permission] = &[
|
||||
Permission::SysAccountGet,
|
||||
Permission::SysAccountQuery,
|
||||
Permission::SysMailingListGet,
|
||||
Permission::SysMailingListQuery,
|
||||
Permission::SysDomainGet,
|
||||
Permission::SysDomainQuery,
|
||||
Permission::SysTenantGet,
|
||||
Permission::SysTenantQuery,
|
||||
Permission::SysRoleGet,
|
||||
Permission::SysRoleQuery,
|
||||
];
|
||||
|
||||
/// What the server-level officer holds besides [`READS`].
|
||||
const OFFICER: &[Permission] = &[
|
||||
Permission::SysComplianceGet,
|
||||
Permission::SysAuditGet,
|
||||
Permission::SysAuditExport,
|
||||
Permission::SysLegalHoldGet,
|
||||
Permission::SysLegalHoldCreate,
|
||||
Permission::SysLegalHoldUpdate,
|
||||
Permission::SysLegalHoldExport,
|
||||
Permission::SysAccountLockGet,
|
||||
];
|
||||
|
||||
/// What a tenant's officer holds besides [`READS`].
|
||||
const TENANT_OFFICER: &[Permission] = &[
|
||||
Permission::SysComplianceGet,
|
||||
Permission::SysAuditGet,
|
||||
Permission::SysAuditExport,
|
||||
Permission::SysAccountLockGet,
|
||||
];
|
||||
|
||||
fn role(own: &[Permission], tenant: Option<Id>) -> Role {
|
||||
let mut permissions = crate::auth::permissions::DefaultPermissions::default().user;
|
||||
for permission in own.iter().chain(READS) {
|
||||
if !permissions.contains(permission) {
|
||||
permissions.push(*permission);
|
||||
}
|
||||
}
|
||||
Role {
|
||||
description: NAME.into(),
|
||||
enabled_permissions: Map::new(permissions),
|
||||
member_tenant_id: tenant,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The server-level Compliance Officer role.
|
||||
pub fn officer_role() -> Role {
|
||||
role(OFFICER, None)
|
||||
}
|
||||
|
||||
/// A tenant's Compliance Officer role.
|
||||
pub fn tenant_role(tenant: Id) -> Role {
|
||||
role(TENANT_OFFICER, Some(tenant))
|
||||
}
|
||||
|
||||
/// Where a creation is recorded: the server's role, or a tenant's. The value
|
||||
/// is the role's id.
|
||||
fn created_key(tenant: Option<Id>) -> ValueClass {
|
||||
let mut key = b"Pc".to_vec();
|
||||
if let Some(tenant) = tenant {
|
||||
key.extend_from_slice(&tenant.id().to_be_bytes());
|
||||
}
|
||||
ValueClass::Any(AnyClass {
|
||||
subspace: SUBSPACE_INBUXA,
|
||||
key,
|
||||
})
|
||||
}
|
||||
|
||||
async fn recorded(data: &Store, tenant: Option<Id>) -> trc::Result<Option<Id>> {
|
||||
Ok(data
|
||||
.get_value::<u64>(ValueKey::from(created_key(tenant)))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.map(Id::from))
|
||||
}
|
||||
|
||||
async fn record(data: &Store, tenant: Option<Id>, role: Option<Id>) -> trc::Result<()> {
|
||||
let mut batch = BatchBuilder::new();
|
||||
match role {
|
||||
Some(role) => batch.set(created_key(tenant), role.id().to_be_bytes().to_vec()),
|
||||
None => batch.clear(created_key(tenant)),
|
||||
};
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Creates a role, unless one was created for this place before, and records
|
||||
/// it. Returns the new role's id.
|
||||
async fn create_once(
|
||||
registry: &RegistryStore,
|
||||
data: &Store,
|
||||
tenant: Option<Id>,
|
||||
role: Role,
|
||||
) -> trc::Result<Option<Id>> {
|
||||
if recorded(data, tenant).await?.is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
match registry.write(RegistryWrite::insert(&role.into())).await? {
|
||||
RegistryWriteResult::Success(id) => {
|
||||
record(data, tenant, Some(id)).await?;
|
||||
Ok(Some(id))
|
||||
}
|
||||
err => {
|
||||
trc::error!(
|
||||
trc::EventType::Registry(trc::RegistryEvent::ValidationError)
|
||||
.into_err()
|
||||
.details(format!("Failed to create the {NAME} role: {err}"))
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Once per server: the officer role, and one in each tenant it already has.
|
||||
pub async fn ensure_compliance_roles(registry: &RegistryStore, data: &Store) -> trc::Result<()> {
|
||||
create_once(registry, data, None, officer_role()).await?;
|
||||
for tenant in registry.list::<Tenant>().await? {
|
||||
let tenant = Id::from(tenant.id.id());
|
||||
create_once(registry, data, Some(tenant), tenant_role(tenant)).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A new tenant gets its Compliance Officer role.
|
||||
pub async fn tenant_created(registry: &RegistryStore, data: &Store, tenant: Id) -> trc::Result<()> {
|
||||
create_once(registry, data, Some(tenant), tenant_role(tenant)).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// Before a tenant is deleted: removes its Compliance Officer role if nobody
|
||||
/// holds it, so the role doesn't block the delete. Returns whether it did,
|
||||
/// so a delete refused for another reason can put it back.
|
||||
pub async fn tenant_deleting(registry: &RegistryStore, data: &Store, tenant: Id) -> trc::Result<bool> {
|
||||
let Some(role) = recorded(data, Some(tenant)).await? else {
|
||||
return Ok(false);
|
||||
};
|
||||
match registry
|
||||
.write(RegistryWrite::delete(ObjectType::Role.id(role)))
|
||||
.await?
|
||||
{
|
||||
RegistryWriteResult::Success(_) | RegistryWriteResult::NotFound { .. } => {
|
||||
record(data, Some(tenant), None).await?;
|
||||
Ok(true)
|
||||
}
|
||||
// Held by someone: the tenant's delete is refused for that anyway
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// A tenant's delete was refused after its role went: the role comes back.
|
||||
pub async fn tenant_kept(registry: &RegistryStore, data: &Store, tenant: Id) -> trc::Result<()> {
|
||||
tenant_created(registry, data, tenant).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use registry::types::EnumImpl;
|
||||
|
||||
fn permissions(role: &Role) -> Vec<Permission> {
|
||||
role.enabled_permissions.iter().copied().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neither_role_changes_a_setting() {
|
||||
let user = crate::auth::permissions::DefaultPermissions::default().user;
|
||||
for role in [officer_role(), tenant_role(Id::from(7u64))] {
|
||||
let all = permissions(&role);
|
||||
for permission in user.iter() {
|
||||
assert!(all.contains(permission), "a user's own {permission:?}");
|
||||
}
|
||||
// Beyond what any user holds for their own account
|
||||
for permission in all.into_iter().filter(|p| !user.contains(p)) {
|
||||
let name = permission.as_str();
|
||||
let holds = name.starts_with("sysLegalHold");
|
||||
assert!(
|
||||
!(name.ends_with("Update") && !holds)
|
||||
&& !(name.ends_with("Create") && !holds)
|
||||
&& !name.ends_with("Destroy")
|
||||
&& permission != Permission::Impersonate
|
||||
&& permission != Permission::FetchAnyBlob,
|
||||
"{} holds {name}",
|
||||
role.description
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_officer_places_and_releases_holds_a_tenants_does_not() {
|
||||
let officer = permissions(&officer_role());
|
||||
let tenant = tenant_role(Id::from(7u64));
|
||||
assert_eq!(tenant.member_tenant_id, Some(Id::from(7u64)));
|
||||
let tenant = permissions(&tenant);
|
||||
for hold in [
|
||||
Permission::SysLegalHoldGet,
|
||||
Permission::SysLegalHoldCreate,
|
||||
Permission::SysLegalHoldUpdate,
|
||||
Permission::SysLegalHoldExport,
|
||||
] {
|
||||
assert!(officer.contains(&hold));
|
||||
assert!(!tenant.contains(&hold));
|
||||
}
|
||||
for both in [Permission::SysComplianceGet, Permission::SysAuditGet, Permission::SysAccountGet] {
|
||||
assert!(officer.contains(&both) && tenant.contains(&both));
|
||||
}
|
||||
assert!(!officer.contains(&Permission::SysAuditSettingsUpdate));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_are_per_place() {
|
||||
let ValueClass::Any(server) = created_key(None) else { panic!() };
|
||||
let ValueClass::Any(a) = created_key(Some(Id::from(1u64))) else { panic!() };
|
||||
let ValueClass::Any(b) = created_key(Some(Id::from(2u64))) else { panic!() };
|
||||
assert_eq!(server.key, b"Pc");
|
||||
assert_ne!(a.key, b.key);
|
||||
assert!(a.key.starts_with(b"Pc"));
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ use aws_lc_rs::{
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::*,
|
||||
prelude::{ObjectType, SocketAddr},
|
||||
prelude::{Object, ObjectType, SocketAddr},
|
||||
structs::*,
|
||||
},
|
||||
types::{duration::Duration, error::Error, list::List, map::Map},
|
||||
@@ -388,6 +388,45 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// inbuxa: personal-data catalog, defaults D2, D3, D4 and D6 (settled
|
||||
// 2026-09-28): privacy-leaning values, for new installs only. A server
|
||||
// with roles is not new, and keeps its settings whether saved or left at
|
||||
// the default. Each singleton is read, changed and written back whole, so
|
||||
// anything already in it stays.
|
||||
#[cfg(not(feature = "test_mode"))]
|
||||
if bp.registry.count_object(ObjectType::Role).await? == 0 {
|
||||
let mut security = bp.setting_infallible::<Security>().await;
|
||||
let mut classifier = bp.setting_infallible::<SpamClassifier>().await;
|
||||
let mut pyzor = bp.setting_infallible::<SpamPyzor>().await;
|
||||
let mut retention = bp.setting_infallible::<DataRetention>().await;
|
||||
new_install_privacy_defaults(&mut security, &mut classifier, &mut pyzor, &mut retention);
|
||||
for object in [
|
||||
Object::from(security),
|
||||
classifier.into(),
|
||||
pyzor.into(),
|
||||
retention.into(),
|
||||
] {
|
||||
bp.registry.write(RegistryWrite::insert(&object)).await?;
|
||||
}
|
||||
|
||||
// D5: the blocklist sent hashed email addresses starts off; the
|
||||
// rules load later, from a task, which acts on this note
|
||||
super::spam_rules::mark_new_install(&bp.data_store).await?;
|
||||
|
||||
// D1: rotated log files are kept 30 days (a fork-owned setting,
|
||||
// since x:TracerLog is also stored inside x:Bootstrap)
|
||||
use inbuxa_features::security::log_files;
|
||||
if !log_files::is_set(&bp.data_store).await? {
|
||||
log_files::set(
|
||||
&bp.data_store,
|
||||
&log_files::LogSettings {
|
||||
keep_for_days: Some(log_files::NEW_INSTALL_KEEP_DAYS),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
if bp.registry.count_object(ObjectType::Role).await? == 0 {
|
||||
let permissions = DefaultPermissions::default();
|
||||
let mut role_ids = Vec::with_capacity(4);
|
||||
@@ -447,6 +486,8 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> {
|
||||
|
||||
// inbuxa: administrator roles stored before a permission existed get it once
|
||||
super::granted_permissions::grant_new_admin_permissions(bp).await?;
|
||||
// inbuxa: personal-data catalog: the compliance roles, once per server
|
||||
super::compliance_roles::ensure_compliance_roles(&bp.registry, &bp.data_store).await?;
|
||||
|
||||
if bp
|
||||
.registry
|
||||
@@ -535,8 +576,8 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> {
|
||||
|
||||
// inbuxa: rules are always to hand, since a copy ships with the server
|
||||
// (spam_rules). They load on first boot, and again when the bundled
|
||||
// version differs from the one last loaded, which only adds what's
|
||||
// missing: new tags and rules, never a changed score.
|
||||
// rules differ from the ones last loaded: new tags and rules, fixes to
|
||||
// rules nobody edited, never a changed score or an admin's edit.
|
||||
let rules_url = super::spam_rules::rules_url(
|
||||
bp.registry
|
||||
.object::<SpamSettings>(Id::singleton())
|
||||
@@ -547,7 +588,7 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> {
|
||||
&& super::spam_rules::applied_version(&bp.data_store)
|
||||
.await?
|
||||
.as_deref()
|
||||
!= Some(super::spam_rules::BUNDLED_SPAM_RULES_VERSION);
|
||||
!= Some(super::spam_rules::BUNDLED_SPAM_RULES_APPLIED);
|
||||
if bp.registry.count_object(ObjectType::SpamRule).await? == 0 || bundled_is_new {
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.schedule_task(Task::SpamFilterMaintenance(TaskSpamFilterMaintenance {
|
||||
@@ -560,3 +601,81 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// inbuxa: the new-install values of defaults D2, D3, D4 and D6 from the
|
||||
/// personal-data catalog spec. Automatic IP bans expire after 30 days instead
|
||||
/// of never; spam training samples are kept 90 days instead of 180; Pyzor,
|
||||
/// which sends a digest of each message's text to a public server, is off;
|
||||
/// delivery history is kept 14 days instead of 30.
|
||||
fn new_install_privacy_defaults(
|
||||
security: &mut Security,
|
||||
classifier: &mut SpamClassifier,
|
||||
pyzor: &mut SpamPyzor,
|
||||
retention: &mut DataRetention,
|
||||
) {
|
||||
const DAY: u64 = 24 * 60 * 60 * 1000;
|
||||
let ban_period = Some(Duration::from_millis(30 * DAY));
|
||||
security.auth_ban_period = ban_period;
|
||||
security.abuse_ban_period = ban_period;
|
||||
security.loiter_ban_period = ban_period;
|
||||
security.scan_ban_period = ban_period;
|
||||
classifier.hold_samples_for = Duration::from_millis(90 * DAY);
|
||||
pyzor.enable = false;
|
||||
retention.hold_traces_for = Some(Duration::from_millis(14 * DAY));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const DAY: u64 = 24 * 60 * 60 * 1000;
|
||||
|
||||
#[test]
|
||||
fn new_installs_get_the_privacy_defaults() {
|
||||
let (mut security, mut classifier, mut pyzor, mut retention) = (
|
||||
Security::default(),
|
||||
SpamClassifier::default(),
|
||||
SpamPyzor::default(),
|
||||
DataRetention::default(),
|
||||
);
|
||||
// What an install gets without them: bans that never lift, 180-day
|
||||
// samples, Pyzor on, 30-day traces.
|
||||
assert_eq!(security.auth_ban_period, None);
|
||||
assert!(pyzor.enable);
|
||||
|
||||
new_install_privacy_defaults(&mut security, &mut classifier, &mut pyzor, &mut retention);
|
||||
|
||||
for period in [
|
||||
security.auth_ban_period,
|
||||
security.abuse_ban_period,
|
||||
security.loiter_ban_period,
|
||||
security.scan_ban_period,
|
||||
] {
|
||||
assert_eq!(period.map(|p| p.into_inner().as_millis() as u64), Some(30 * DAY));
|
||||
}
|
||||
assert_eq!(classifier.hold_samples_for.into_inner().as_millis() as u64, 90 * DAY);
|
||||
assert!(!pyzor.enable);
|
||||
assert_eq!(
|
||||
retention.hold_traces_for.map(|p| p.into_inner().as_millis() as u64),
|
||||
Some(14 * DAY)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everything_else_in_the_settings_stays() {
|
||||
let mut retention = DataRetention {
|
||||
archive_deleted_items_for: Some(Duration::from_millis(7 * DAY)),
|
||||
..Default::default()
|
||||
};
|
||||
let before = retention.clone();
|
||||
new_install_privacy_defaults(
|
||||
&mut Security::default(),
|
||||
&mut SpamClassifier::default(),
|
||||
&mut SpamPyzor::default(),
|
||||
&mut retention,
|
||||
);
|
||||
assert_eq!(retention.archive_deleted_items_for, before.archive_deleted_items_for);
|
||||
assert_eq!(retention.hold_metrics_for, before.hold_metrics_for);
|
||||
assert_eq!(retention.expunge_trash_after, before.expunge_trash_after);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@ use types::id::Id;
|
||||
|
||||
/// Granted to the default administrator roles: "Explain this"
|
||||
/// (ai-explain spec, EX-4: superuser by default), the audit log, account
|
||||
/// locks and legal holds (audit-hold-lock spec, AU-9, AL-12, LH-13).
|
||||
/// locks and legal holds (audit-hold-lock spec, AU-9, AL-12, LH-13), and
|
||||
/// the data inventory (personal-data catalog spec).
|
||||
const ADMIN_GRANTS: &[Permission] = &[
|
||||
Permission::SysAiExplain,
|
||||
Permission::SysAuditGet,
|
||||
@@ -44,11 +45,12 @@ const ADMIN_GRANTS: &[Permission] = &[
|
||||
Permission::SysLegalHoldCreate,
|
||||
Permission::SysLegalHoldUpdate,
|
||||
Permission::SysLegalHoldExport,
|
||||
Permission::SysComplianceGet,
|
||||
];
|
||||
|
||||
/// Granted to the default tenant administrator roles: reading and exporting
|
||||
/// the tenant's audit log (AU-9), and locking and delegating its accounts
|
||||
/// (AL-12).
|
||||
/// the tenant's audit log (AU-9), locking and delegating its accounts
|
||||
/// (AL-12), and the tenant's slice of the data inventory.
|
||||
const TENANT_GRANTS: &[Permission] = &[
|
||||
Permission::SysAuditGet,
|
||||
Permission::SysAuditExport,
|
||||
@@ -56,6 +58,7 @@ const TENANT_GRANTS: &[Permission] = &[
|
||||
Permission::SysAccountLockCreate,
|
||||
Permission::SysAccountLockUpdate,
|
||||
Permission::SysAccountLockDestroy,
|
||||
Permission::SysComplianceGet,
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
|
||||
@@ -18,6 +18,7 @@ use utils::HttpLimitResponse;
|
||||
pub mod application;
|
||||
pub mod backup;
|
||||
pub mod boot;
|
||||
pub mod compliance_roles; // inbuxa: personal-data catalog, the compliance roles
|
||||
pub mod console;
|
||||
pub mod defaults;
|
||||
pub mod first_party;
|
||||
|
||||
@@ -12,11 +12,16 @@
|
||||
//! and license) and uses it whenever no other source is configured. The rules
|
||||
//! URL remains an operator override (`https://` or `file://`).
|
||||
//!
|
||||
//! Loading rules only ever adds what's missing, never changes an existing rule
|
||||
//! or score. They load on first boot, and again whenever the bundled version
|
||||
//! differs from the one last applied, so an upgrade brings new tags (the AI
|
||||
//! classifier's `LLM_*` scores, say) to an install that already had rules.
|
||||
//! Loading rules adds what's missing and brings an existing rule up to date,
|
||||
//! but never touches one an admin edited: every object an update writes is
|
||||
//! fingerprinted, and one that no longer matches its fingerprint is kept as
|
||||
//! it is. Tags (scores) are never replaced. Switching a rule on or off isn't
|
||||
//! an edit, and is kept either way. They load on first boot, and again
|
||||
//! whenever the bundled rules differ from the ones last applied, so an
|
||||
//! upgrade brings new tags (the AI classifier's `LLM_*` scores, say) and
|
||||
//! fixed rules to an install that already had rules.
|
||||
|
||||
use registry::{schema::prelude::ObjectType, types::EnumImpl};
|
||||
use std::io::Read;
|
||||
use store::{
|
||||
SUBSPACE_INBUXA, Store, ValueKey,
|
||||
@@ -27,13 +32,17 @@ use trc::AddContext;
|
||||
/// The version of spam-filter the embedded rules come from.
|
||||
pub const BUNDLED_SPAM_RULES_VERSION: &str = "3.0.2";
|
||||
|
||||
/// What's recorded once the bundled rules are loaded: their version, then the
|
||||
/// fork's own generation of the update, so a change to how an update applies
|
||||
/// runs it once more. Generation 2 fingerprints (upstream v0.16.24).
|
||||
pub const BUNDLED_SPAM_RULES_APPLIED: &str = "3.0.2+2";
|
||||
|
||||
static BUNDLED_SPAM_RULES: &[u8] =
|
||||
include_bytes!("../../../../resources/spam-filter/spam-filter-rules.json.gz");
|
||||
|
||||
/// Upstream's default rules source, the value every install created before
|
||||
/// the rules were bundled has saved. Read only to treat it as unset.
|
||||
const LEGACY_DEFAULT_URL: &str =
|
||||
"https://github.com/stalwartlabs/spam-filter/releases/latest/download/spam-filter-rules.json.gz";
|
||||
const LEGACY_DEFAULT_URL: &str = "https://github.com/stalwartlabs/spam-filter/releases/latest/download/spam-filter-rules.json.gz";
|
||||
|
||||
/// The URL to fetch rules from, or `None` for the bundled rules. An empty
|
||||
/// setting and upstream's old default both mean the bundled rules.
|
||||
@@ -57,14 +66,49 @@ fn applied_key() -> ValueClass {
|
||||
})
|
||||
}
|
||||
|
||||
/// The bundled version last loaded into the registry, if any.
|
||||
fn fingerprint_key(object: ObjectType, id: u64) -> ValueClass {
|
||||
let mut key = b"Sf".to_vec();
|
||||
key.extend_from_slice(object.as_str().as_bytes());
|
||||
key.push(0);
|
||||
key.extend_from_slice(&id.to_be_bytes());
|
||||
ValueClass::Any(AnyClass {
|
||||
subspace: SUBSPACE_INBUXA,
|
||||
key,
|
||||
})
|
||||
}
|
||||
|
||||
/// The fingerprint of what a rules update last wrote to this object, if one
|
||||
/// did.
|
||||
pub async fn fingerprint(data: &Store, object: ObjectType, id: u64) -> trc::Result<Option<String>> {
|
||||
data.get_value::<String>(ValueKey::from(fingerprint_key(object, id)))
|
||||
.await
|
||||
.caused_by(trc::location!())
|
||||
}
|
||||
|
||||
/// Records the fingerprint of what a rules update wrote to this object.
|
||||
pub async fn set_fingerprint(
|
||||
data: &Store,
|
||||
object: ObjectType,
|
||||
id: u64,
|
||||
fingerprint: &str,
|
||||
) -> trc::Result<()> {
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.set(fingerprint_key(object, id), fingerprint.as_bytes().to_vec());
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// The bundled rules last loaded into the registry, if any
|
||||
/// ([`BUNDLED_SPAM_RULES_APPLIED`]'s form).
|
||||
pub async fn applied_version(data: &Store) -> trc::Result<Option<String>> {
|
||||
data.get_value::<String>(ValueKey::from(applied_key()))
|
||||
.await
|
||||
.caused_by(trc::location!())
|
||||
}
|
||||
|
||||
/// Records that the bundled rules of this version have been loaded.
|
||||
/// Records that the bundled rules have been loaded.
|
||||
pub async fn set_applied_version(data: &Store, version: &str) -> trc::Result<()> {
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.set(applied_key(), version.as_bytes().to_vec());
|
||||
@@ -74,6 +118,78 @@ pub async fn set_applied_version(data: &Store, version: &str) -> trc::Result<()>
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// The blocklists a new install starts with switched off (personal-data
|
||||
/// catalog spec, default D5, settled 2026-09-28): the one that is sent a
|
||||
/// hash of every email address it's asked about.
|
||||
pub const NEW_INSTALL_OFF: &[&str] = &["STWT_MSBL_EBL_EMAIL"];
|
||||
|
||||
fn new_install_key() -> ValueClass {
|
||||
ValueClass::Any(AnyClass {
|
||||
subspace: SUBSPACE_INBUXA,
|
||||
key: b"Sn".to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Notes, on a new install's first boot, that [`NEW_INSTALL_OFF`] is to be
|
||||
/// switched off once the rules are in: they load later, from a task.
|
||||
pub async fn mark_new_install(data: &Store) -> trc::Result<()> {
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.set(new_install_key(), b"D5".to_vec());
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// After rules load: on a new install, switches [`NEW_INSTALL_OFF`] off and
|
||||
/// forgets the note, so it happens once. Returns whether anything changed.
|
||||
/// An existing server has no note, and keeps every blocklist as it is.
|
||||
pub async fn apply_new_install(
|
||||
registry: &store::RegistryStore,
|
||||
data: &Store,
|
||||
) -> trc::Result<bool> {
|
||||
use registry::schema::{prelude::Object, structs::SpamDnsblServer};
|
||||
use store::registry::write::RegistryWrite;
|
||||
|
||||
if data
|
||||
.get_value::<String>(ValueKey::from(new_install_key()))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.is_none()
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
let mut changed = false;
|
||||
for server in registry.list::<SpamDnsblServer>().await? {
|
||||
let mut updated = server.object.clone();
|
||||
let SpamDnsblServer::Email(email) = &mut updated else {
|
||||
continue;
|
||||
};
|
||||
if !NEW_INSTALL_OFF.contains(&email.name.as_str()) || !email.enable {
|
||||
continue;
|
||||
}
|
||||
email.enable = false;
|
||||
let old = Object {
|
||||
inner: server.object.into(),
|
||||
revision: server.revision,
|
||||
};
|
||||
let new = Object {
|
||||
inner: updated.into(),
|
||||
revision: server.revision,
|
||||
};
|
||||
registry
|
||||
.write(RegistryWrite::update(types::id::Id::from(server.id.id()), &new, &old))
|
||||
.await?;
|
||||
changed = true;
|
||||
}
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.clear(new_install_key());
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -90,6 +206,15 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_marker_names_the_bundled_version() {
|
||||
assert!(
|
||||
BUNDLED_SPAM_RULES_APPLIED
|
||||
.strip_prefix(BUNDLED_SPAM_RULES_VERSION)
|
||||
.is_some_and(|generation| generation.starts_with('+'))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_rules_parse_and_score_the_ai_tags() {
|
||||
let rules: serde_json::Value = serde_json::from_slice(&bundled_rules().unwrap()).unwrap();
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::{Server, manager::application::Resource, network::legacy::is_legacy_service};
|
||||
use crate::{Server, manager::application::Resource};
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::XmlVersion;
|
||||
use quick_xml::events::Event;
|
||||
use registry::schema::enums::ServiceProtocol;
|
||||
use registry::schema::{enums::ServiceProtocol, structs::Service};
|
||||
use std::fmt::Write;
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
impl Server {
|
||||
pub async fn handle_autodiscover_request(
|
||||
@@ -26,89 +27,103 @@ impl Server {
|
||||
.details("Failed to parse autodiscover request")
|
||||
.ctx(trc::Key::Reason, err)
|
||||
})?;
|
||||
let default_host = &self.core.network.server_name;
|
||||
|
||||
// Build XML response
|
||||
let mut config = String::with_capacity(1024);
|
||||
let _ = writeln!(&mut config, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"<Autodiscover xmlns=\"http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006\">"
|
||||
);
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"\t<Response xmlns=\"http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a\">"
|
||||
);
|
||||
let _ = writeln!(&mut config, "\t\t<User>");
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"\t\t\t<DisplayName>{emailaddress}</DisplayName>"
|
||||
);
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"\t\t\t<AutoDiscoverSMTPAddress>{emailaddress}</AutoDiscoverSMTPAddress>"
|
||||
);
|
||||
// DeploymentId is a required field of User but we are not a MS Exchange server so use a random value
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"\t\t\t<DeploymentId>644560b8-a1ce-429c-8ace-23395843f701</DeploymentId>"
|
||||
);
|
||||
let _ = writeln!(&mut config, "\t\t</User>");
|
||||
let _ = writeln!(&mut config, "\t\t<Account>");
|
||||
let _ = writeln!(&mut config, "\t\t\t<AccountType>email</AccountType>");
|
||||
let _ = writeln!(&mut config, "\t\t\t<Action>settings</Action>");
|
||||
// inbuxa: legacy-protocols LP-7, LP-14a
|
||||
let legacy_off = match emailaddress.rsplit_once('@') {
|
||||
Some((_, domain)) => self.legacy_protocols_off_for(domain).await?,
|
||||
None => self.legacy_protocols_off_for("").await?,
|
||||
Some((_, domain)) => self.legacy_off_for(domain).await?,
|
||||
None => self.legacy_off_for("").await?,
|
||||
};
|
||||
for (protocol, service) in &self.core.network.info.services {
|
||||
if legacy_off && is_legacy_service(protocol) {
|
||||
continue;
|
||||
}
|
||||
let (protocol, ports) = match protocol {
|
||||
ServiceProtocol::Imap => ("IMAP", [143, 993]),
|
||||
ServiceProtocol::Pop3 => ("POP3", [110, 995]),
|
||||
ServiceProtocol::Smtp => ("SMTP", [587, 465]),
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
for (is_tls, port) in ports.into_iter().enumerate() {
|
||||
if is_tls == 1 || service.cleartext {
|
||||
let server_name = service.hostname.as_deref().unwrap_or(default_host);
|
||||
let _ = writeln!(&mut config, "\t\t\t<Protocol>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<Type>{protocol}</Type>",);
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<Server>{server_name}</Server>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<Port>{port}</Port>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<LoginName>{emailaddress}</LoginName>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<AuthRequired>on</AuthRequired>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<DirectoryPort>0</DirectoryPort>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<ReferralPort>0</ReferralPort>");
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"\t\t\t\t<SSL>{}</SSL>",
|
||||
if is_tls == 1 { "on" } else { "off" }
|
||||
);
|
||||
if is_tls == 1 {
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<Encryption>TLS</Encryption>");
|
||||
}
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<SPA>off</SPA>");
|
||||
let _ = writeln!(&mut config, "\t\t\t</Protocol>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = writeln!(&mut config, "\t\t</Account>");
|
||||
let _ = writeln!(&mut config, "\t</Response>");
|
||||
let _ = writeln!(&mut config, "</Autodiscover>");
|
||||
|
||||
Ok(Resource::new(
|
||||
"application/xml; charset=utf-8",
|
||||
config.into_bytes(),
|
||||
build_autodiscover_response(
|
||||
&emailaddress,
|
||||
&self.core.network.server_name,
|
||||
&self.core.network.info.services,
|
||||
|protocol| legacy_off.service(protocol),
|
||||
)
|
||||
.into_bytes(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn build_autodiscover_response(
|
||||
emailaddress: &str,
|
||||
default_host: &str,
|
||||
services: &VecMap<ServiceProtocol, Service>,
|
||||
switched_off: impl Fn(&ServiceProtocol) -> bool,
|
||||
) -> String {
|
||||
// Build XML response
|
||||
let mut config = String::with_capacity(1024);
|
||||
let _ = writeln!(&mut config, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"<Autodiscover xmlns=\"http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006\">"
|
||||
);
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"\t<Response xmlns=\"http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a\">"
|
||||
);
|
||||
let _ = writeln!(&mut config, "\t\t<User>");
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"\t\t\t<DisplayName>{emailaddress}</DisplayName>"
|
||||
);
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"\t\t\t<AutoDiscoverSMTPAddress>{emailaddress}</AutoDiscoverSMTPAddress>"
|
||||
);
|
||||
// DeploymentId is a required field of User but we are not a MS Exchange server so use a random value
|
||||
let _ = writeln!(
|
||||
&mut config,
|
||||
"\t\t\t<DeploymentId>644560b8-a1ce-429c-8ace-23395843f701</DeploymentId>"
|
||||
);
|
||||
let _ = writeln!(&mut config, "\t\t</User>");
|
||||
let _ = writeln!(&mut config, "\t\t<Account>");
|
||||
let _ = writeln!(&mut config, "\t\t\t<AccountType>email</AccountType>");
|
||||
let _ = writeln!(&mut config, "\t\t\t<Action>settings</Action>");
|
||||
for (protocol, service) in services {
|
||||
if switched_off(protocol) {
|
||||
continue;
|
||||
}
|
||||
let (protocol, ports) = match protocol {
|
||||
ServiceProtocol::Imap => ("IMAP", [(993, true), (143, false)]),
|
||||
ServiceProtocol::Pop3 => ("POP3", [(995, true), (110, false)]),
|
||||
ServiceProtocol::Smtp => ("SMTP", [(465, true), (587, false)]),
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// Implicit TLS is listed first so that it is preferred (RFC 8314)
|
||||
for (port, is_tls) in ports {
|
||||
if is_tls || service.cleartext {
|
||||
let server_name = service.hostname.as_deref().unwrap_or(default_host);
|
||||
let _ = writeln!(&mut config, "\t\t\t<Protocol>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<Type>{protocol}</Type>",);
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<Server>{server_name}</Server>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<Port>{port}</Port>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<LoginName>{emailaddress}</LoginName>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<AuthRequired>on</AuthRequired>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<DirectoryPort>0</DirectoryPort>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<ReferralPort>0</ReferralPort>");
|
||||
let (ssl, encryption) = if is_tls {
|
||||
("on", "SSL")
|
||||
} else {
|
||||
("off", "TLS")
|
||||
};
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<SSL>{ssl}</SSL>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<Encryption>{encryption}</Encryption>");
|
||||
let _ = writeln!(&mut config, "\t\t\t\t<SPA>off</SPA>");
|
||||
let _ = writeln!(&mut config, "\t\t\t</Protocol>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = writeln!(&mut config, "\t\t</Account>");
|
||||
let _ = writeln!(&mut config, "\t</Response>");
|
||||
let _ = writeln!(&mut config, "</Autodiscover>");
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
fn parse_autodiscover_request(bytes: &[u8]) -> Result<String, String> {
|
||||
if bytes.is_empty() {
|
||||
return Err("Empty request body".to_string());
|
||||
@@ -211,4 +226,79 @@ mod tests {
|
||||
"[email protected]"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autodiscover_encryption() {
|
||||
use registry::schema::{enums::ServiceProtocol, structs::Service};
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
fn tag<'x>(block: &'x str, name: &str) -> &'x str {
|
||||
block
|
||||
.split_once(&format!("<{name}>"))
|
||||
.and_then(|(_, rest)| rest.split_once(&format!("</{name}>")))
|
||||
.map(|(value, _)| value)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
for (cleartext, expected) in [
|
||||
(
|
||||
false,
|
||||
vec![
|
||||
("IMAP", "993", "on", "SSL"),
|
||||
("POP3", "995", "on", "SSL"),
|
||||
("SMTP", "465", "on", "SSL"),
|
||||
],
|
||||
),
|
||||
(
|
||||
true,
|
||||
vec![
|
||||
("IMAP", "993", "on", "SSL"),
|
||||
("IMAP", "143", "off", "TLS"),
|
||||
("POP3", "995", "on", "SSL"),
|
||||
("POP3", "110", "off", "TLS"),
|
||||
("SMTP", "465", "on", "SSL"),
|
||||
("SMTP", "587", "off", "TLS"),
|
||||
],
|
||||
),
|
||||
] {
|
||||
let services: VecMap<ServiceProtocol, Service> = [
|
||||
ServiceProtocol::Imap,
|
||||
ServiceProtocol::Pop3,
|
||||
ServiceProtocol::Smtp,
|
||||
ServiceProtocol::Jmap,
|
||||
]
|
||||
.into_iter()
|
||||
.map(|protocol| {
|
||||
(
|
||||
protocol,
|
||||
Service {
|
||||
hostname: None,
|
||||
cleartext,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let response = super::build_autodiscover_response(
|
||||
"[email protected]",
|
||||
"mail.example.com",
|
||||
&services,
|
||||
|_| false,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
response
|
||||
.split("<Protocol>")
|
||||
.skip(1)
|
||||
.map(|block| (
|
||||
tag(block, "Type"),
|
||||
tag(block, "Port"),
|
||||
tag(block, "SSL"),
|
||||
tag(block, "Encryption"),
|
||||
))
|
||||
.collect::<Vec<_>>(),
|
||||
expected,
|
||||
"cleartext: {cleartext}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::{Server, manager::application::Resource, network::legacy::is_legacy_service};
|
||||
use crate::{Server, manager::application::Resource};
|
||||
use registry::schema::enums::ServiceProtocol;
|
||||
use std::fmt::Write;
|
||||
use utils::url_params::UrlParams;
|
||||
@@ -31,7 +31,7 @@ impl Server {
|
||||
};
|
||||
|
||||
// inbuxa: legacy-protocols LP-7, LP-14a
|
||||
let legacy_off = self.legacy_protocols_off_for(domain).await?;
|
||||
let legacy_off = self.legacy_off_for(domain).await?;
|
||||
|
||||
// Build XML response
|
||||
let mut config = String::with_capacity(1024);
|
||||
@@ -45,7 +45,7 @@ impl Server {
|
||||
"\t\t<displayShortName>{domain}</displayShortName>"
|
||||
);
|
||||
for (protocol, service) in &self.core.network.info.services {
|
||||
if legacy_off && is_legacy_service(protocol) {
|
||||
if legacy_off.service(protocol) {
|
||||
continue;
|
||||
}
|
||||
let (protocol, tag, ports) = match protocol {
|
||||
|
||||
@@ -6,11 +6,7 @@
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
Server,
|
||||
config::network::Pacc,
|
||||
network::{dkim::generate_dkim_dns_record, legacy::is_legacy_service},
|
||||
};
|
||||
use crate::{Server, config::network::Pacc, network::dkim::generate_dkim_dns_record};
|
||||
use ahash::{AHashMap, AHashSet};
|
||||
use base64::{Engine, engine::general_purpose};
|
||||
use dns_update::{
|
||||
@@ -41,7 +37,7 @@ impl Server {
|
||||
let default_host = network.server_name.as_str();
|
||||
let domain_name = domain.name.as_str();
|
||||
// inbuxa: legacy-protocols LP-7, LP-14a
|
||||
let legacy_off = self.legacy_protocols_off_for(domain_name).await?;
|
||||
let legacy_off = self.legacy_off_for(domain_name).await?;
|
||||
let domain_name_suffix = format!(".{domain_name}");
|
||||
|
||||
for record_type in record_types {
|
||||
@@ -205,7 +201,7 @@ impl Server {
|
||||
// name says "not offered" -- target "." (RFC 6186 section
|
||||
// 3.4) -- rather than vanishing, so a client that looks
|
||||
// is told, and an old record left in the zone is replaced.
|
||||
if legacy_off && is_legacy_service(protocol) {
|
||||
if legacy_off.service(protocol) {
|
||||
for (service_name, _) in services {
|
||||
records.push(NamedDnsRecord {
|
||||
name: format!("_{service_name}._tcp.{domain_name}."),
|
||||
@@ -307,8 +303,8 @@ impl Server {
|
||||
// inbuxa: legacy-protocols LP-7. No TLS pin for a port
|
||||
// the switch has closed. Submission's port stays open
|
||||
// (the SMTP lock), so its record stays.
|
||||
if legacy_off
|
||||
&& matches!(protocol, ServiceProtocol::Imap | ServiceProtocol::Pop3)
|
||||
if matches!(protocol, ServiceProtocol::Imap | ServiceProtocol::Pop3)
|
||||
&& legacy_off.service(protocol)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -418,11 +414,8 @@ impl Server {
|
||||
|
||||
pub async fn get_pacc_for_domain(&self, domain_name: &str) -> trc::Result<String> {
|
||||
// inbuxa: legacy-protocols LP-7, LP-14a
|
||||
let pacc = if self.legacy_protocols_off_for(domain_name).await? {
|
||||
&self.core.network.info.pacc_jmap_only
|
||||
} else {
|
||||
&self.core.network.info.pacc
|
||||
};
|
||||
let off = self.legacy_off_for(domain_name).await?;
|
||||
let pacc = &self.core.network.info.pacc[off.index()];
|
||||
self.get_directory_for_domain(domain_name)
|
||||
.await
|
||||
.caused_by(trc::location!())
|
||||
|
||||
@@ -961,6 +961,20 @@ impl DnsUpdater {
|
||||
)
|
||||
.map_err(|err| format!("Failed to build DNS updater: {}", err))?,
|
||||
}),
|
||||
DnsServer::PowerDns(server) => Ok(DnsUpdater {
|
||||
polling_interval: server.polling_interval.into_inner(),
|
||||
propagation_timeout: server.propagation_timeout.into_inner(),
|
||||
propagation_delay: server.propagation_delay.map(|d| d.into_inner()),
|
||||
ttl: server.ttl.into_inner(),
|
||||
core,
|
||||
updater: dns_update::DnsUpdater::new_pdns(
|
||||
server.api_key.secret().await?,
|
||||
server.endpoint,
|
||||
server.server_id,
|
||||
server.timeout.into_inner().into(),
|
||||
)
|
||||
.map_err(|err| format!("Failed to build DNS updater: {}", err))?,
|
||||
}),
|
||||
DnsServer::Safedns(server) => Ok(DnsUpdater {
|
||||
polling_interval: server.polling_interval.into_inner(),
|
||||
propagation_timeout: server.propagation_timeout.into_inner(),
|
||||
|
||||
@@ -35,8 +35,8 @@ use directory::Credentials;
|
||||
use inbuxa_features::security::{
|
||||
legacy_use::{self, LegacyUse},
|
||||
listeners,
|
||||
protocol_policy::{self, ProtocolPolicy, SavedListener},
|
||||
tenant_protocol_policy,
|
||||
protocol_policy::{self, ProtocolPolicy, SUBMISSION, SWITCHED, SavedListener, Switches},
|
||||
tenant_protocol_policy::{self, OffBy, TenantProtocolPolicy},
|
||||
};
|
||||
use registry::schema::enums::ServiceProtocol;
|
||||
use registry::types::{error::Error, id::ObjectId};
|
||||
@@ -97,40 +97,48 @@ impl Server {
|
||||
// this, and a /set that omitted it must not lose the listeners still
|
||||
// waiting to come back.
|
||||
let previous = self.protocol_policy().await?;
|
||||
policy.saved_listeners = previous.saved_listeners;
|
||||
policy.saved_listeners = previous.saved_listeners.clone();
|
||||
policy.changed_at = Some(store::write::now() * 1000);
|
||||
policy.changed_by = changed_by;
|
||||
policy.normalize();
|
||||
|
||||
if policy.legacy_protocols.is_disabled() {
|
||||
self.close_legacy_listeners(&mut policy, &mut change).await?;
|
||||
} else {
|
||||
self.reopen_legacy_listeners(&mut policy, &mut change)
|
||||
.await?;
|
||||
}
|
||||
// Each protocol on its own switch: close what is off now, and put
|
||||
// back what was saved for a protocol that is on again. Either may
|
||||
// happen in one change, when one protocol goes off as another comes
|
||||
// back.
|
||||
self.close_legacy_listeners(&mut policy, &mut change)
|
||||
.await?;
|
||||
self.reopen_legacy_listeners(&mut policy, &mut change)
|
||||
.await?;
|
||||
|
||||
protocol_policy::set(&self.core.storage.data, &policy).await?;
|
||||
|
||||
// LP-8. Raised here rather than by the JMAP method, so whatever turns
|
||||
// the switch is reported. A /set that changed nothing -- the switch
|
||||
// a switch is reported. A /set that changed nothing -- every switch
|
||||
// already where it was asked to be, nothing to close or reopen -- is
|
||||
// not a change.
|
||||
if previous.legacy_protocols != policy.legacy_protocols || !change.is_empty() {
|
||||
let (moved, direction) = if policy.legacy_protocols.is_disabled() {
|
||||
(&change.closed, "closed")
|
||||
} else {
|
||||
(&change.reopened, "reopened")
|
||||
};
|
||||
let mut before = previous;
|
||||
before.normalize();
|
||||
if before.off() != policy.off() || !change.is_empty() {
|
||||
// The closed first, then the reopened; `Details` says which.
|
||||
let moved = change
|
||||
.closed
|
||||
.iter()
|
||||
.chain(change.reopened.iter())
|
||||
.map(|l| l.id.clone());
|
||||
trc::event!(
|
||||
Security(trc::SecurityEvent::LegacyProtocolsChanged),
|
||||
Policy = "server",
|
||||
Value = if policy.legacy_protocols.is_disabled() {
|
||||
"disabled"
|
||||
} else {
|
||||
"enabled"
|
||||
},
|
||||
Value = switches_value(&policy),
|
||||
AccountId = policy.changed_by.clone(),
|
||||
Details = direction,
|
||||
ListenerId = listener_names(moved.iter().map(|l| l.id.clone())),
|
||||
Details = if change.closed.is_empty() {
|
||||
"reopened"
|
||||
} else if change.reopened.is_empty() {
|
||||
"closed"
|
||||
} else {
|
||||
"closed and reopened"
|
||||
},
|
||||
ListenerId = listener_names(moved),
|
||||
// Only when a listener could not be put back (LP-5).
|
||||
Reason = (!change.failed.is_empty()).then(|| listener_names(
|
||||
change
|
||||
@@ -165,21 +173,27 @@ impl Server {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Puts back every saved listener and starts it again (LP-5).
|
||||
/// Puts back every saved listener whose protocol is on again, and starts
|
||||
/// it (LP-5). The rest stay saved.
|
||||
async fn reopen_legacy_listeners(
|
||||
&self,
|
||||
policy: &mut ProtocolPolicy,
|
||||
change: &mut PolicyChange,
|
||||
) -> trc::Result<()> {
|
||||
if policy.saved_listeners.is_empty() {
|
||||
let (wanted, still_closed): (Vec<_>, Vec<_>) = std::mem::take(&mut policy.saved_listeners)
|
||||
.into_iter()
|
||||
.partition(|saved| !policy.closes(&saved.protocol, &saved.ports));
|
||||
policy.saved_listeners = still_closed;
|
||||
if wanted.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let saved = std::mem::take(&mut policy.saved_listeners);
|
||||
let (restored, failed) = listeners::reopen(self.registry(), &saved).await?;
|
||||
let (restored, failed) = listeners::reopen(self.registry(), &wanted).await?;
|
||||
|
||||
// A listener that could not be put back stays saved for another try.
|
||||
policy.saved_listeners = failed.iter().map(|(listener, _)| listener.clone()).collect();
|
||||
policy
|
||||
.saved_listeners
|
||||
.extend(failed.iter().map(|(listener, _)| listener.clone()));
|
||||
change.failed = failed;
|
||||
|
||||
if !restored.is_empty() {
|
||||
@@ -254,6 +268,17 @@ impl Server {
|
||||
}
|
||||
}
|
||||
|
||||
/// The switches as an event value: `disabled` or `enabled` when all three
|
||||
/// agree, otherwise which are off, such as `pop3 disabled` (LP-8).
|
||||
pub fn switches_value(policy: &impl Switches) -> String {
|
||||
let off = policy.off();
|
||||
match off.len() {
|
||||
0 => "enabled".to_string(),
|
||||
n if n == SWITCHED.len() => "disabled".to_string(),
|
||||
_ => format!("{} disabled", off.join(", ")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Names for an event field: the listeners a change closed, reopened or
|
||||
/// failed to reopen (LP-8).
|
||||
fn listener_names<T: Into<trc::Value>>(names: impl Iterator<Item = T>) -> trc::Value {
|
||||
@@ -395,15 +420,18 @@ impl Server {
|
||||
credentials: &Credentials,
|
||||
) -> trc::Result<()> {
|
||||
let domain = domain_of(credentials);
|
||||
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
|
||||
let server = self.protocol_policy().await?;
|
||||
if server.is_off(protocol.as_str()) {
|
||||
return Err(protocol.refused(RefusalScope::Server, domain));
|
||||
}
|
||||
if let Some(name) = &domain
|
||||
&& let Some(domain) = self.domain(name).await?
|
||||
&& let Some(tenant_id) = domain.id_tenant
|
||||
&& self.tenant_legacy_protocols_off(tenant_id).await?
|
||||
{
|
||||
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), Some(name.clone())));
|
||||
let tenant = self.tenant_protocol_policy(tenant_id).await?;
|
||||
if tenant_protocol_policy::off_by(&server, Some(&tenant), protocol.as_str()).is_some() {
|
||||
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), Some(name.clone())));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -422,10 +450,16 @@ impl Server {
|
||||
protocol: LegacyProtocol,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<()> {
|
||||
if let Some(tenant_id) = access_token.tenant_id()
|
||||
&& self.tenant_legacy_protocols_off(tenant_id).await?
|
||||
{
|
||||
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), None));
|
||||
if let Some(tenant_id) = access_token.tenant_id() {
|
||||
let server = self.protocol_policy().await?;
|
||||
let tenant = self.tenant_protocol_policy(tenant_id).await?;
|
||||
match tenant_protocol_policy::off_by(&server, Some(&tenant), protocol.as_str()) {
|
||||
Some(OffBy::Server) => return Err(protocol.refused(RefusalScope::Server, None)),
|
||||
Some(OffBy::Tenant) => {
|
||||
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), None));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if let Err(err) = legacy_use::record(
|
||||
&self.core.storage.data,
|
||||
@@ -463,31 +497,96 @@ impl Server {
|
||||
Ok(recent)
|
||||
}
|
||||
|
||||
/// Whether legacy protocols are off for this account: the stricter of the
|
||||
/// server's switch and its tenant's. What the JMAP session tells the
|
||||
/// Which legacy protocols are off for this account: each the stricter of
|
||||
/// the server's switch and its tenant's. What the JMAP session tells the
|
||||
/// account's apps (legacy-protocols spec, Interfaces), so the webmail can
|
||||
/// say why a mail app won't connect (LP-19).
|
||||
pub async fn legacy_protocols_off_for_account(
|
||||
pub async fn legacy_off_for_account(
|
||||
&self,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<bool> {
|
||||
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
|
||||
return Ok(true);
|
||||
}
|
||||
match access_token.tenant_id() {
|
||||
Some(tenant_id) => self.tenant_legacy_protocols_off(tenant_id).await,
|
||||
None => Ok(false),
|
||||
) -> trc::Result<LegacyOff> {
|
||||
let server = self.protocol_policy().await?;
|
||||
let tenant = match access_token.tenant_id() {
|
||||
Some(tenant_id) => Some(self.tenant_protocol_policy(tenant_id).await?),
|
||||
None => None,
|
||||
};
|
||||
Ok(LegacyOff::of(&server, tenant.as_ref()))
|
||||
}
|
||||
|
||||
/// A tenant's switches, or all on when it has never set them (LP-10).
|
||||
pub async fn tenant_protocol_policy(
|
||||
&self,
|
||||
tenant_id: u32,
|
||||
) -> trc::Result<TenantProtocolPolicy> {
|
||||
tenant_protocol_policy::get(&self.core.storage.data, tenant_id).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Which legacy protocols are off, for one account or one domain: the server's
|
||||
/// switches and the tenant's together. Submission is off only when all three
|
||||
/// are.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct LegacyOff {
|
||||
pub imap: bool,
|
||||
pub pop3: bool,
|
||||
pub manage_sieve: bool,
|
||||
pub submission: bool,
|
||||
}
|
||||
|
||||
impl LegacyOff {
|
||||
pub fn of(server: &ProtocolPolicy, tenant: Option<&TenantProtocolPolicy>) -> Self {
|
||||
let off = |protocol| tenant_protocol_policy::off_by(server, tenant, protocol).is_some();
|
||||
LegacyOff {
|
||||
imap: off("imap"),
|
||||
pop3: off("pop3"),
|
||||
manage_sieve: off("manageSieve"),
|
||||
submission: off(SUBMISSION),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a tenant has turned legacy protocols off for itself (LP-10).
|
||||
pub async fn tenant_legacy_protocols_off(&self, tenant_id: u32) -> trc::Result<bool> {
|
||||
Ok(
|
||||
tenant_protocol_policy::get(&self.core.storage.data, tenant_id)
|
||||
.await?
|
||||
.legacy_protocols
|
||||
.is_disabled(),
|
||||
)
|
||||
/// Whether this configured service must not be offered (LP-7). SMTP here
|
||||
/// is submission; inbound mail is never a configured service.
|
||||
pub fn service(&self, protocol: &ServiceProtocol) -> bool {
|
||||
match protocol {
|
||||
ServiceProtocol::Imap => self.imap,
|
||||
ServiceProtocol::Pop3 => self.pop3,
|
||||
ServiceProtocol::Managesieve => self.manage_sieve,
|
||||
ServiceProtocol::Smtp => self.submission,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether anything is off.
|
||||
pub fn any(&self) -> bool {
|
||||
self.imap || self.pop3 || self.manage_sieve || self.submission
|
||||
}
|
||||
|
||||
/// Whether everything is off: the kill-all's effect.
|
||||
pub fn all(&self) -> bool {
|
||||
self.imap && self.pop3 && self.manage_sieve && self.submission
|
||||
}
|
||||
|
||||
/// An index for answers prepared once per combination (the PACC
|
||||
/// document): one bit per protocol.
|
||||
pub fn index(&self) -> usize {
|
||||
(self.imap as usize)
|
||||
| (self.pop3 as usize) << 1
|
||||
| (self.manage_sieve as usize) << 2
|
||||
| (self.submission as usize) << 3
|
||||
}
|
||||
|
||||
/// The protocols that are still allowed, by JMAP name, for the session.
|
||||
pub fn allowed(&self) -> Vec<&'static str> {
|
||||
[
|
||||
("imap", self.imap),
|
||||
("pop3", self.pop3),
|
||||
("manageSieve", self.manage_sieve),
|
||||
(SUBMISSION, self.submission),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(_, off)| !off)
|
||||
.map(|(name, _)| name)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -505,21 +604,20 @@ pub fn is_legacy_service(protocol: &ServiceProtocol) -> bool {
|
||||
}
|
||||
|
||||
impl Server {
|
||||
/// Whether legacy services are off for this domain, for the answers that
|
||||
/// Which legacy services are off for this domain, for the answers that
|
||||
/// must stop offering them: off for the whole server (LP-7), or for the
|
||||
/// tenant the domain belongs to (LP-14a). Read per answer, as sign-in
|
||||
/// reads it. A name that is no domain here answers for the server alone.
|
||||
pub async fn legacy_protocols_off_for(&self, domain_name: &str) -> trc::Result<bool> {
|
||||
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
|
||||
return Ok(true);
|
||||
}
|
||||
match self.domain(domain_name).await? {
|
||||
pub async fn legacy_off_for(&self, domain_name: &str) -> trc::Result<LegacyOff> {
|
||||
let server = self.protocol_policy().await?;
|
||||
let tenant = match self.domain(domain_name).await? {
|
||||
Some(domain) => match domain.id_tenant {
|
||||
Some(tenant_id) => self.tenant_legacy_protocols_off(tenant_id).await,
|
||||
None => Ok(false),
|
||||
Some(tenant_id) => Some(self.tenant_protocol_policy(tenant_id).await?),
|
||||
None => None,
|
||||
},
|
||||
None => Ok(false),
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
Ok(LegacyOff::of(&server, tenant.as_ref()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -619,6 +717,36 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_is_off_for_one_account_or_domain() {
|
||||
use inbuxa_features::security::protocol_policy::LegacyProtocols;
|
||||
let mut server = ProtocolPolicy::default();
|
||||
server.set("pop3", LegacyProtocols::Disabled);
|
||||
let mut tenant = TenantProtocolPolicy::default();
|
||||
tenant.set("manageSieve", LegacyProtocols::Disabled);
|
||||
|
||||
let off = LegacyOff::of(&server, Some(&tenant));
|
||||
assert!(off.pop3 && off.manage_sieve && !off.imap && !off.submission);
|
||||
assert!(off.service(&ServiceProtocol::Pop3));
|
||||
assert!(!off.service(&ServiceProtocol::Imap));
|
||||
assert!(
|
||||
!off.service(&ServiceProtocol::Smtp),
|
||||
"sending is still offered"
|
||||
);
|
||||
assert!(!off.service(&ServiceProtocol::Jmap));
|
||||
assert_eq!(off.allowed(), vec!["imap", "submission"]);
|
||||
assert!(off.any() && !off.all());
|
||||
|
||||
let off = LegacyOff::of(&server, None);
|
||||
assert_eq!(off.index(), 0b0010);
|
||||
|
||||
server.set_all(LegacyProtocols::Disabled);
|
||||
let off = LegacyOff::of(&server, None);
|
||||
assert!(off.all());
|
||||
assert_eq!(off.index(), 0b1111);
|
||||
assert!(off.allowed().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_domain_comes_from_the_name_given() {
|
||||
assert_eq!(domain_of(&basic("[email protected]")), Some("b.test".to_string()));
|
||||
|
||||
@@ -426,6 +426,37 @@ impl Server {
|
||||
}
|
||||
}
|
||||
|
||||
impl Server {
|
||||
/// inbuxa: personal-data catalog, D2: removes bans whose period is over.
|
||||
/// They already stop blocking when they expire, and go when settings are
|
||||
/// next loaded; the daily clean-up makes sure a server that seldom
|
||||
/// reloads doesn't keep them.
|
||||
pub async fn purge_expired_blocked_ips(&self) -> trc::Result<()> {
|
||||
let now = now() as i64;
|
||||
let mut expired = Vec::new();
|
||||
for ip in self.registry().list::<BlockedIp>().await? {
|
||||
if ip.object.expires_at.as_ref().is_some_and(|at| at.timestamp() <= now) {
|
||||
let address = ip.object.address.clone();
|
||||
let object = Object {
|
||||
inner: ip.object.into(),
|
||||
revision: ip.revision,
|
||||
};
|
||||
self.registry()
|
||||
.write(RegistryWrite::delete_object(ip.id, &object))
|
||||
.await?;
|
||||
expired.push(trc::Value::from(address.into_inner().0));
|
||||
}
|
||||
}
|
||||
if !expired.is_empty() {
|
||||
trc::event!(
|
||||
Security(trc::SecurityEvent::IpBlockExpired),
|
||||
Details = expired
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl BlockedIps {
|
||||
pub async fn parse(bp: &mut Bootstrap) -> Self {
|
||||
let mut ips = Self::default();
|
||||
|
||||
@@ -6,33 +6,79 @@
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use ahash::AHashMap;
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use p256::{
|
||||
SecretKey,
|
||||
ecdsa::{Signature, SigningKey, signature::Signer},
|
||||
pkcs8::{DecodePrivateKey, PrivateKeyInfo, der::SecretDocument},
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
use reqwest::{Url, header::HeaderValue};
|
||||
use std::sync::Arc;
|
||||
|
||||
const VAPID_TOKEN_TTL: u64 = 12 * 60 * 60;
|
||||
const VAPID_TOKEN_REFRESH: u64 = VAPID_TOKEN_TTL / 2;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Vapid {
|
||||
key: VapidKey,
|
||||
contact: Option<String>,
|
||||
tokens: Arc<Mutex<AHashMap<String, VapidToken>>>,
|
||||
}
|
||||
|
||||
struct VapidToken {
|
||||
authorization: HeaderValue,
|
||||
issued_at: u64,
|
||||
}
|
||||
|
||||
impl Vapid {
|
||||
pub fn new(key: VapidKey, contact: Option<String>) -> Self {
|
||||
Self { key, contact }
|
||||
Self {
|
||||
key,
|
||||
contact,
|
||||
tokens: Arc::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn public_key(&self) -> &str {
|
||||
self.key.public_key()
|
||||
}
|
||||
|
||||
pub fn authorization(&self, endpoint: &str, now: u64) -> Option<String> {
|
||||
self.key
|
||||
.authorization(endpoint, self.contact.as_deref(), now)
|
||||
pub fn authorization(&self, endpoint: &str, now: u64) -> Option<HeaderValue> {
|
||||
let prefix = endpoint_prefix(endpoint)?;
|
||||
if let Some(token) = self
|
||||
.tokens
|
||||
.lock()
|
||||
.get(prefix)
|
||||
.filter(|token| token.is_fresh(now))
|
||||
{
|
||||
return Some(token.authorization.clone());
|
||||
}
|
||||
|
||||
let authorization = HeaderValue::try_from(self.key.authorization(
|
||||
endpoint,
|
||||
self.contact.as_deref(),
|
||||
now,
|
||||
)?)
|
||||
.ok()?;
|
||||
let mut tokens = self.tokens.lock();
|
||||
tokens.retain(|_, token| token.is_fresh(now));
|
||||
tokens.insert(
|
||||
prefix.to_string(),
|
||||
VapidToken {
|
||||
authorization: authorization.clone(),
|
||||
issued_at: now,
|
||||
},
|
||||
);
|
||||
Some(authorization)
|
||||
}
|
||||
}
|
||||
|
||||
impl VapidToken {
|
||||
fn is_fresh(&self, now: u64) -> bool {
|
||||
now.checked_sub(self.issued_at)
|
||||
.is_some_and(|age| age < VAPID_TOKEN_REFRESH)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,41 +151,15 @@ impl VapidKey {
|
||||
}
|
||||
}
|
||||
|
||||
fn endpoint_origin(url: &str) -> Option<String> {
|
||||
fn endpoint_prefix(url: &str) -> Option<&str> {
|
||||
let (scheme, rest) = url.split_once("://")?;
|
||||
let scheme = scheme.to_ascii_lowercase();
|
||||
let authority = rest.split(['/', '?', '#']).next()?;
|
||||
let authority = authority
|
||||
.rsplit_once('@')
|
||||
.map(|(_, host)| host)
|
||||
.unwrap_or(authority);
|
||||
if authority.is_empty() {
|
||||
return None;
|
||||
}
|
||||
url.get(..scheme.len() + "://".len() + authority.len())
|
||||
}
|
||||
|
||||
let (host, port) = if let Some(rest) = authority.strip_prefix('[') {
|
||||
let (addr, tail) = rest.split_once(']')?;
|
||||
(
|
||||
format!("[{}]", addr.to_ascii_lowercase()),
|
||||
tail.strip_prefix(':').filter(|port| !port.is_empty()),
|
||||
)
|
||||
} else if let Some((host, port)) = authority.rsplit_once(':') {
|
||||
(
|
||||
host.to_ascii_lowercase(),
|
||||
Some(port).filter(|p| !p.is_empty()),
|
||||
)
|
||||
} else {
|
||||
(authority.to_ascii_lowercase(), None)
|
||||
};
|
||||
|
||||
match port {
|
||||
Some(port)
|
||||
if !((scheme == "https" && port == "443") || (scheme == "http" && port == "80")) =>
|
||||
{
|
||||
Some(format!("{scheme}://{host}:{port}"))
|
||||
}
|
||||
_ => Some(format!("{scheme}://{host}")),
|
||||
}
|
||||
fn endpoint_origin(url: &str) -> Option<String> {
|
||||
let origin = Url::parse(url).ok()?.origin();
|
||||
origin.is_tuple().then(|| origin.ascii_serialization())
|
||||
}
|
||||
|
||||
pub fn normalize_contact(contact: &str) -> Option<String> {
|
||||
@@ -206,7 +226,12 @@ mod tests {
|
||||
endpoint_origin("http://[2001:DB8::1]:80/p").unwrap(),
|
||||
"http://[2001:db8::1]"
|
||||
);
|
||||
assert_eq!(
|
||||
endpoint_origin("https://attacker.example\\@fcm.googleapis.com/fcm/send/x").unwrap(),
|
||||
"https://attacker.example"
|
||||
);
|
||||
assert!(endpoint_origin("not-a-url").is_none());
|
||||
assert!(endpoint_origin("mailto:[email protected]").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -336,6 +361,47 @@ B4yDfR2rGOd2H6Kv3fQNHPj9Nu5Tks8QYMLzrX8ONCNoFnNUQl9S0r0QS6phVqD0
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorization_is_reused_per_endpoint_prefix() {
|
||||
let vapid = Vapid::new(test_key(), None);
|
||||
let now = 1_700_000_000;
|
||||
let token = vapid
|
||||
.authorization("https://push.example.com/push/a", now)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
vapid
|
||||
.authorization("https://push.example.com/push/b?x=1", now + 60)
|
||||
.unwrap(),
|
||||
token
|
||||
);
|
||||
assert_ne!(
|
||||
vapid
|
||||
.authorization("https://other.example.com/push/a", now)
|
||||
.unwrap(),
|
||||
token
|
||||
);
|
||||
assert_ne!(
|
||||
vapid
|
||||
.authorization("https://push.example.com/push/a", now - 1)
|
||||
.unwrap(),
|
||||
token
|
||||
);
|
||||
let refreshed = vapid
|
||||
.authorization("https://push.example.com/push/a", now + VAPID_TOKEN_REFRESH)
|
||||
.unwrap();
|
||||
assert_ne!(refreshed, token);
|
||||
assert_eq!(
|
||||
vapid
|
||||
.authorization(
|
||||
"https://push.example.com/push/c",
|
||||
now + VAPID_TOKEN_REFRESH + 1
|
||||
)
|
||||
.unwrap(),
|
||||
refreshed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorization_omits_subject_when_no_contact() {
|
||||
let key = test_key();
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! The live facts the personal-data catalog is evaluated against
|
||||
//! (personal-data catalog spec, §6): which sources are switched on, what
|
||||
//! bounds each one's retention, which stores and endpoints are elsewhere.
|
||||
//! Read from the registry on each request, so every node answers alike.
|
||||
|
||||
use crate::Server;
|
||||
use inbuxa_features::privacy::{
|
||||
self, Days, Inventory, LiveFacts, is_loopback,
|
||||
snapshot::{self, Snapshot, Trigger},
|
||||
};
|
||||
use registry::schema::{
|
||||
prelude::Object,
|
||||
structs::{
|
||||
AiModel, BlobStore, DataRetention, DataStore, InMemoryStore, Jmap, MtaHook, MtaMilter,
|
||||
MtaRoute, Search, SearchStore, SpamClassifier, SpamClassifierModel, SpamDnsblServer,
|
||||
SpamLlm, SpamPyzor, Tracer, TracingStore, WebHook,
|
||||
},
|
||||
};
|
||||
use registry::types::duration::Duration;
|
||||
use serde_json::Value;
|
||||
use types::id::Id;
|
||||
|
||||
/// The objects [`Server::privacy_facts`] reads: a write to one may change
|
||||
/// the inventory.
|
||||
pub const INVENTORY_OBJECTS: &[&str] = &[
|
||||
"x:DataRetention",
|
||||
"x:SpamClassifier",
|
||||
"x:Jmap",
|
||||
"x:TracingStore",
|
||||
"x:Search",
|
||||
"x:Tracer",
|
||||
"x:WebHook",
|
||||
"x:AiModel",
|
||||
"x:SpamLlm",
|
||||
"x:SpamDnsblServer",
|
||||
"x:SpamPyzor",
|
||||
"x:MtaMilter",
|
||||
"x:MtaHook",
|
||||
"x:MtaRoute",
|
||||
"x:DataStore",
|
||||
"x:BlobStore",
|
||||
"x:SearchStore",
|
||||
"x:InMemoryStore",
|
||||
"inbuxa:AuditSettings",
|
||||
"inbuxa:LogSettings",
|
||||
"inbuxa:AiLimits",
|
||||
];
|
||||
|
||||
/// A store or endpoint object's type and host, from its JSON: local types
|
||||
/// stay on the host.
|
||||
fn remote_host(value: &Value) -> Option<String> {
|
||||
let kind = value.get("@type").and_then(Value::as_str).unwrap_or_default();
|
||||
if matches!(kind, "" | "RocksDb" | "Sqlite" | "FileSystem" | "Default" | "Disabled") {
|
||||
return None;
|
||||
}
|
||||
for key in ["host", "url", "endpoint", "address", "hostname"] {
|
||||
if let Some(host) = value.get(key).and_then(Value::as_str).filter(|h| !h.is_empty()) {
|
||||
return Some(host.to_string());
|
||||
}
|
||||
}
|
||||
// A list of URLs, as an array or as a map keyed by URL
|
||||
match value.get("urls") {
|
||||
Some(Value::Array(urls)) => {
|
||||
if let Some(url) = urls.first().and_then(Value::as_str) {
|
||||
return Some(url.to_string());
|
||||
}
|
||||
}
|
||||
Some(Value::Object(urls)) => {
|
||||
if let Some(url) = urls.keys().next() {
|
||||
return Some(url.clone());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Some(kind.to_string())
|
||||
}
|
||||
|
||||
fn days(duration: Option<&Duration>) -> Days {
|
||||
match duration {
|
||||
Some(d) => Days::Days(d.into_inner().as_secs().div_ceil(86_400)),
|
||||
None => Days::Unbounded,
|
||||
}
|
||||
}
|
||||
|
||||
/// The zones a DNSBL's zone expression can query: each quoted literal that
|
||||
/// starts with a dot, in any branch (`ip_reverse + '.zen.spamhaus.org'`).
|
||||
fn zone_hosts(value: &Value) -> Vec<String> {
|
||||
let mut hosts = Vec::new();
|
||||
let mut texts = Vec::new();
|
||||
fn collect<'a>(value: &'a Value, texts: &mut Vec<&'a str>) {
|
||||
match value {
|
||||
Value::String(s) => texts.push(s),
|
||||
Value::Array(items) => items.iter().for_each(|v| collect(v, texts)),
|
||||
Value::Object(map) => map.values().for_each(|v| collect(v, texts)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
collect(value, &mut texts);
|
||||
for text in texts {
|
||||
for literal in text.split('\'').skip(1).step_by(2) {
|
||||
if let Some(zone) = literal.strip_prefix('.')
|
||||
&& zone.contains('.')
|
||||
&& !hosts.iter().any(|h| h == zone)
|
||||
{
|
||||
hosts.push(zone.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
hosts
|
||||
}
|
||||
|
||||
impl Server {
|
||||
async fn singleton<T: registry::types::ObjectImpl + From<Object> + Default>(&self) -> trc::Result<T> {
|
||||
Ok(self.registry().object::<T>(Id::singleton()).await?.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// The facts the catalog is evaluated against, from the live settings.
|
||||
pub async fn privacy_facts(&self) -> trc::Result<LiveFacts> {
|
||||
let mut facts = LiveFacts::default();
|
||||
let data = &self.core.storage.data;
|
||||
let endpoint = |facts: &mut LiveFacts, id: &str, url: String| {
|
||||
if !url.is_empty() && !is_loopback(&url) {
|
||||
facts.endpoints.entry(id.to_string()).or_default().push(url);
|
||||
}
|
||||
};
|
||||
|
||||
// Retention
|
||||
let retention = self.singleton::<DataRetention>().await?;
|
||||
for (name, value) in [
|
||||
("x:DataRetention.holdTracesFor", &retention.hold_traces_for),
|
||||
("x:DataRetention.holdMetricsFor", &retention.hold_metrics_for),
|
||||
("x:DataRetention.holdMtaReportsFor", &retention.hold_mta_reports_for),
|
||||
("x:DataRetention.archiveDeletedItemsFor", &retention.archive_deleted_items_for),
|
||||
("x:DataRetention.archiveDeletedAccountsFor", &retention.archive_deleted_accounts_for),
|
||||
("x:DataRetention.expungeTrashAfter", &retention.expunge_trash_after),
|
||||
("x:DataRetention.expungeSubmissionsAfter", &retention.expunge_submissions_after),
|
||||
] {
|
||||
facts.durations.insert(name.into(), days(value.as_ref()));
|
||||
}
|
||||
let classifier = self.singleton::<SpamClassifier>().await?;
|
||||
facts.durations.insert(
|
||||
"x:SpamClassifier.holdSamplesFor".into(),
|
||||
days(Some(&classifier.hold_samples_for)),
|
||||
);
|
||||
let jmap = self.singleton::<Jmap>().await?;
|
||||
facts
|
||||
.durations
|
||||
.insert("x:Jmap.uploadTtl".into(), days(Some(&jmap.upload_ttl)));
|
||||
let audit = inbuxa_features::audit::log::settings(data).await?;
|
||||
facts.durations.insert(
|
||||
"inbuxa:AuditSettings.keepForDays".into(),
|
||||
Days::Days(audit.keep_for_secs.div_ceil(86_400)),
|
||||
);
|
||||
let logs = inbuxa_features::security::log_files::get(data).await?;
|
||||
facts.durations.insert(
|
||||
"inbuxa:LogSettings.keepForDays".into(),
|
||||
logs.keep_for_days.map_or(Days::Unbounded, Days::Days),
|
||||
);
|
||||
|
||||
// What's switched on
|
||||
let tracing = self.singleton::<TracingStore>().await?;
|
||||
let tracing_on = !matches!(tracing, TracingStore::Disabled);
|
||||
let search = self.singleton::<Search>().await?;
|
||||
for id in ["x:Trace", "x:TraceEvent", "x:TraceKeyValue", "x:TraceValueIpAddr", "x:TraceValueString"] {
|
||||
facts.collected.insert(id.into(), tracing_on);
|
||||
}
|
||||
facts
|
||||
.collected
|
||||
.insert("trace-index".into(), tracing_on && search.index_telemetry);
|
||||
facts.collected.insert(
|
||||
"full-text-index".into(),
|
||||
search.index_email || search.index_calendar || search.index_contacts,
|
||||
);
|
||||
let archive_on = retention.archive_deleted_items_for.is_some();
|
||||
for id in [
|
||||
"x:ArchivedEmail",
|
||||
"x:ArchivedFileNode",
|
||||
"x:ArchivedCalendarEvent",
|
||||
"x:ArchivedContactCard",
|
||||
"x:ArchivedSieveScript",
|
||||
] {
|
||||
facts.collected.insert(id.into(), archive_on);
|
||||
}
|
||||
facts.collected.insert(
|
||||
"inbuxa:DeletedAccount".into(),
|
||||
retention.archive_deleted_accounts_for.is_some(),
|
||||
);
|
||||
let reports_on = retention.hold_mta_reports_for.is_some();
|
||||
for id in [
|
||||
"x:ArfExternalReport",
|
||||
"x:ArfFeedbackReport",
|
||||
"x:DmarcExternalReport",
|
||||
"x:DmarcReport",
|
||||
"x:DmarcReportRecord",
|
||||
"x:TlsExternalReport",
|
||||
"x:TlsReport",
|
||||
"x:TlsFailureDetails",
|
||||
] {
|
||||
facts.collected.insert(id.into(), reports_on);
|
||||
}
|
||||
let classifier_on = !matches!(classifier.model, SpamClassifierModel::Disabled);
|
||||
facts
|
||||
.collected
|
||||
.insert("x:SpamTrainingSample".into(), classifier_on);
|
||||
facts
|
||||
.collected
|
||||
.insert("spam-trainer-state".into(), classifier_on);
|
||||
|
||||
// Tracers
|
||||
let (mut log_on, mut console_on, mut otel_on) = (false, false, false);
|
||||
for tracer in self.registry().list::<Tracer>().await? {
|
||||
match tracer.object {
|
||||
Tracer::Log(t) => log_on |= t.enable,
|
||||
Tracer::Stdout(t) => console_on |= t.enable,
|
||||
Tracer::Journal(t) => console_on |= t.enable,
|
||||
Tracer::OtelHttp(t) if t.enable => {
|
||||
otel_on = true;
|
||||
endpoint(&mut facts, "otel-tracer", t.endpoint);
|
||||
}
|
||||
Tracer::OtelGrpc(t) if t.enable => {
|
||||
otel_on = true;
|
||||
endpoint(&mut facts, "otel-tracer", t.endpoint.unwrap_or_default());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
facts.collected.insert("log-file".into(), log_on);
|
||||
facts.collected.insert("x:Log".into(), log_on);
|
||||
facts.collected.insert("console-and-journal".into(), console_on);
|
||||
facts.collected.insert("otel-tracer".into(), otel_on);
|
||||
|
||||
// Webhooks
|
||||
let mut hooks_on = false;
|
||||
for hook in self.registry().list::<WebHook>().await? {
|
||||
if hook.object.enable {
|
||||
hooks_on = true;
|
||||
endpoint(&mut facts, "webhooks", hook.object.url);
|
||||
}
|
||||
}
|
||||
facts.collected.insert("webhooks".into(), hooks_on);
|
||||
|
||||
// AI: the classifier's model, and Explain's
|
||||
let models = self.registry().list::<AiModel>().await?;
|
||||
let model_url = |id: Id| {
|
||||
models
|
||||
.iter()
|
||||
.find(|m| Id::from(m.id.id()) == id)
|
||||
.map(|m| m.object.url.clone())
|
||||
};
|
||||
let llm_on = match self.singleton::<SpamLlm>().await? {
|
||||
SpamLlm::Enable(props) => {
|
||||
if let Some(url) = model_url(props.model_id) {
|
||||
endpoint(&mut facts, "spam-llm", url);
|
||||
}
|
||||
true
|
||||
}
|
||||
SpamLlm::Disable => false,
|
||||
};
|
||||
facts.collected.insert("spam-llm".into(), llm_on);
|
||||
let limits = self.ai_limits().await;
|
||||
let explain = self.ai_explain_model(&limits).await;
|
||||
if let Some((_, model)) = &explain {
|
||||
endpoint(&mut facts, "inbuxa:Explanation", model.url.clone());
|
||||
}
|
||||
facts
|
||||
.collected
|
||||
.insert("explain-cache".into(), explain.is_some());
|
||||
facts
|
||||
.collected
|
||||
.insert("inbuxa:Explanation".into(), explain.is_some());
|
||||
|
||||
// Spam lookups off the host
|
||||
let mut dnsbl_on = false;
|
||||
for server in self.registry().list::<SpamDnsblServer>().await? {
|
||||
let value = serde_json::to_value(&server.object).unwrap_or_default();
|
||||
if value.get("enable").and_then(Value::as_bool).unwrap_or(false) {
|
||||
dnsbl_on = true;
|
||||
for zone in value.get("zone").map(zone_hosts).unwrap_or_default() {
|
||||
endpoint(&mut facts, "spam-dnsbl", zone);
|
||||
}
|
||||
}
|
||||
}
|
||||
facts.collected.insert("spam-dnsbl".into(), dnsbl_on);
|
||||
let pyzor = self.singleton::<SpamPyzor>().await?;
|
||||
if pyzor.enable {
|
||||
endpoint(&mut facts, "spam-pyzor", format!("{}:{}", pyzor.host, pyzor.port));
|
||||
}
|
||||
facts.collected.insert("spam-pyzor".into(), pyzor.enable);
|
||||
|
||||
// Mail handed to others
|
||||
let mut hooks = false;
|
||||
for milter in self.registry().list::<MtaMilter>().await? {
|
||||
hooks = true;
|
||||
endpoint(
|
||||
&mut facts,
|
||||
"mta-milter-and-hooks",
|
||||
format!("{}:{}", milter.object.hostname, milter.object.port),
|
||||
);
|
||||
}
|
||||
for hook in self.registry().list::<MtaHook>().await? {
|
||||
hooks = true;
|
||||
endpoint(&mut facts, "mta-milter-and-hooks", hook.object.url);
|
||||
}
|
||||
facts.collected.insert("mta-milter-and-hooks".into(), hooks);
|
||||
let mut relays = false;
|
||||
for route in self.registry().list::<MtaRoute>().await? {
|
||||
if let MtaRoute::Relay(relay) = route.object {
|
||||
relays = true;
|
||||
endpoint(&mut facts, "relay", format!("{}:{}", relay.address, relay.port));
|
||||
}
|
||||
}
|
||||
facts.collected.insert("relay".into(), relays);
|
||||
|
||||
// Stores elsewhere
|
||||
let stores = [
|
||||
("data-store", serde_json::to_value(self.singleton::<DataStore>().await.ok()).unwrap_or_default()),
|
||||
("blob-store", serde_json::to_value(self.singleton::<BlobStore>().await?).unwrap_or_default()),
|
||||
("search-store", serde_json::to_value(self.singleton::<SearchStore>().await?).unwrap_or_default()),
|
||||
("in-memory-store", serde_json::to_value(self.singleton::<InMemoryStore>().await?).unwrap_or_default()),
|
||||
];
|
||||
for (place, value) in stores {
|
||||
if let Some(host) = remote_host(&value) {
|
||||
facts.remote_stores.insert(place.into(), host);
|
||||
}
|
||||
}
|
||||
if let Some(host) = remote_host(&serde_json::to_value(&tracing).unwrap_or_default()) {
|
||||
for id in ["x:Trace", "x:TraceEvent", "x:TraceKeyValue", "x:TraceValueIpAddr", "x:TraceValueString"] {
|
||||
endpoint(&mut facts, id, host.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(facts)
|
||||
}
|
||||
|
||||
/// The server's inventory, or a tenant's slice of it.
|
||||
pub async fn data_inventory(&self, tenant_only: bool) -> trc::Result<Inventory> {
|
||||
let facts = self.privacy_facts().await?;
|
||||
Ok(privacy::evaluate(privacy::catalog(), &facts, tenant_only))
|
||||
}
|
||||
|
||||
/// Records a snapshot of the server's inventory if it differs from the
|
||||
/// newest one, or if there is none: the history shows when what the
|
||||
/// server holds changed, not a copy a day. Returns whether it recorded.
|
||||
pub async fn inventory_snapshot(&self, trigger: Trigger) -> trc::Result<bool> {
|
||||
let data = &self.core.storage.data;
|
||||
let inventory = self.data_inventory(false).await?;
|
||||
if let Some(latest) = snapshot::latest(data).await?
|
||||
&& let Some(previous) = snapshot::get(data, latest).await?
|
||||
&& previous.inventory == inventory
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
snapshot::record(
|
||||
data,
|
||||
&Snapshot {
|
||||
taken_at: store::write::now(),
|
||||
trigger,
|
||||
summary: inventory.summary(),
|
||||
inventory,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// A snapshot after a registry write, when the object is one the
|
||||
/// inventory reads. Failures are logged: a snapshot is history, not
|
||||
/// worth failing the write over.
|
||||
pub async fn inventory_snapshot_after(&self, object: &str) {
|
||||
if !INVENTORY_OBJECTS.contains(&object) {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = self
|
||||
.inventory_snapshot(Trigger::SettingChanged {
|
||||
setting: object.to_string(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
trc::error!(err.details("Failed to record an inventory snapshot"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes snapshots past the audit log's retention (settled
|
||||
/// 2026-09-28: snapshots are kept as long as audit records).
|
||||
pub async fn purge_inventory_snapshots(&self) -> trc::Result<usize> {
|
||||
let data = &self.core.storage.data;
|
||||
let keep = inbuxa_features::audit::log::settings(data).await?.keep_for_secs;
|
||||
snapshot::purge(data, store::write::now().saturating_sub(keep)).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn local_stores_stay_and_others_name_their_host() {
|
||||
assert_eq!(remote_host(&json!({"@type": "RocksDb", "path": "/var/lib"})), None);
|
||||
assert_eq!(remote_host(&json!({"@type": "Default"})), None);
|
||||
assert_eq!(
|
||||
remote_host(&json!({"@type": "PostgreSql", "host": "db.example.net"})),
|
||||
Some("db.example.net".into())
|
||||
);
|
||||
assert_eq!(
|
||||
remote_host(&json!({"@type": "ElasticSearch", "url": "https://es.example.net:9200"})),
|
||||
Some("https://es.example.net:9200".into())
|
||||
);
|
||||
assert_eq!(remote_host(&json!({"@type": "S3", "bucket": "mail"})), Some("S3".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zones_come_from_every_branch() {
|
||||
let zone = json!({"else": "false", "match": {"0": {"if": "location == 'tcp'",
|
||||
"then": "ip_reverse + '.rep.mailspike.net'"}}});
|
||||
assert_eq!(zone_hosts(&zone), vec!["rep.mailspike.net"]);
|
||||
let zone = json!({"else": "hash(email, 'sha1') + '.ebl.msbl.org'", "match": {}});
|
||||
assert_eq!(zone_hosts(&zone), vec!["ebl.msbl.org"], "not 'sha1'");
|
||||
assert!(zone_hosts(&json!({"else": "false"})).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn days_round_up() {
|
||||
assert_eq!(days(Some(&Duration::from_millis(86_400_000))), Days::Days(1));
|
||||
assert_eq!(days(Some(&Duration::from_millis(3_600_000))), Days::Days(1));
|
||||
assert_eq!(days(None), Days::Unbounded);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "coordinator"
|
||||
version = "0.16.23"
|
||||
version = "0.16.24"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "dav-proto"
|
||||
version = "0.16.23"
|
||||
version = "0.16.24"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "dav"
|
||||
version = "0.16.23"
|
||||
version = "0.16.24"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "directory"
|
||||
version = "0.16.23"
|
||||
version = "0.16.24"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "email"
|
||||
version = "0.16.23"
|
||||
version = "0.16.24"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -15,6 +15,7 @@ utils = { path = "../utils" }
|
||||
ahash = { version = "0.8.12", features = ["serde"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
toml = "1.1"
|
||||
xxhash-rust = { version = "0.8.18", features = ["xxh3"] }
|
||||
base64 = "0.23"
|
||||
sha2 = "0.11"
|
||||
|
||||
@@ -108,6 +108,45 @@ impl Keeping {
|
||||
const FEATURE: u8 = b'H';
|
||||
const KIND_HOLD: u8 = b'h';
|
||||
const KIND_ORIGINAL: u8 = b'o';
|
||||
const KIND_EXPORT: u8 = b'e';
|
||||
|
||||
/// How far a hold export has got (LH-12).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ExportStatus {
|
||||
Running,
|
||||
Ready,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// A collection of what a hold keeps, as a ZIP (LH-12).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Export {
|
||||
pub id: u32,
|
||||
pub hold_id: u32,
|
||||
/// The accounts asked for; empty for every account the hold covers.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub accounts: Vec<u32>,
|
||||
pub reason: String,
|
||||
pub created_at: u64,
|
||||
pub created_by: String,
|
||||
/// Whose blob the ZIP is, so only they download it.
|
||||
pub created_by_id: u32,
|
||||
pub status: ExportStatus,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub finished_at: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub blob_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub size: u64,
|
||||
#[serde(default)]
|
||||
pub items: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sha256: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// How many times creating a hold retries when another node took its id.
|
||||
const CREATE_ATTEMPTS: usize = 5;
|
||||
@@ -402,6 +441,70 @@ pub async fn set_original_deadline(data: &Store, item_id: u64, until: Option<u64
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
fn export_class(id: u32) -> ValueClass {
|
||||
let mut key = Vec::with_capacity(6);
|
||||
key.push(FEATURE);
|
||||
key.push(KIND_EXPORT);
|
||||
key.extend_from_slice(&id.to_be_bytes());
|
||||
ValueClass::Any(AnyClass {
|
||||
subspace: SUBSPACE_INBUXA,
|
||||
key,
|
||||
})
|
||||
}
|
||||
|
||||
/// Every hold export, oldest first.
|
||||
pub async fn exports(data: &Store) -> trc::Result<Vec<Export>> {
|
||||
let mut exports = Vec::new();
|
||||
data.iterate(
|
||||
IterateParams::new(ValueKey::from(export_class(0)), ValueKey::from(export_class(u32::MAX))),
|
||||
|_, value| {
|
||||
if let Ok(Json(export)) = Json::<Export>::deserialize(value) {
|
||||
exports.push(export);
|
||||
}
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
Ok(exports)
|
||||
}
|
||||
|
||||
/// Writes a new export under the next free id, which it returns.
|
||||
pub async fn create_export(data: &Store, export: &Export) -> trc::Result<u32> {
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
attempt += 1;
|
||||
let id = exports(data).await?.iter().map(|e| e.id).max().unwrap_or(0) + 1;
|
||||
let stored = Export {
|
||||
id,
|
||||
..export.clone()
|
||||
};
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.assert_value(export_class(id), AssertValue::None);
|
||||
batch.set(export_class(id), Json(&stored).serialize()?);
|
||||
match data.write(batch.build_all()).await {
|
||||
Ok(_) => return Ok(id),
|
||||
Err(err)
|
||||
if attempt < CREATE_ATTEMPTS
|
||||
&& matches!(
|
||||
err.as_ref(),
|
||||
trc::EventType::Store(trc::StoreEvent::AssertValueFailed)
|
||||
) => {}
|
||||
Err(err) => return Err(err.caused_by(trc::location!())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves an export's progress.
|
||||
pub async fn update_export(data: &Store, export: &Export) -> trc::Result<()> {
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.set(export_class(export.id), Json(export).serialize()?);
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// One hold, released or not.
|
||||
pub async fn get(data: &Store, id: u32) -> trc::Result<Option<Hold>> {
|
||||
Ok(data
|
||||
|
||||
@@ -24,6 +24,7 @@ pub mod branding;
|
||||
pub mod hold;
|
||||
pub mod lock;
|
||||
pub mod masked_email;
|
||||
pub mod privacy;
|
||||
pub mod security;
|
||||
pub mod tenancy;
|
||||
pub mod undelete;
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! The personal-data catalog, evaluated (personal-data catalog spec, §6).
|
||||
//!
|
||||
//! `resources/privacy/catalog.toml` says what the server *can* hold; this
|
||||
//! module turns it into what *this* server holds, given the live facts the
|
||||
//! caller gathers from its settings ([`LiveFacts`]). Facts in, facts out:
|
||||
//! nothing here judges, and nothing here reads the store, so every
|
||||
//! configuration can be tested with made-up facts.
|
||||
|
||||
pub mod snapshot;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// The catalog, as shipped with this build.
|
||||
pub const CATALOG: &str = include_str!("../../../../resources/privacy/catalog.toml");
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Retention {
|
||||
Word(String),
|
||||
Setting { setting: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct ObjectEntry {
|
||||
#[serde(default)]
|
||||
pub whose: Vec<String>,
|
||||
#[serde(default, rename = "where")]
|
||||
pub location: Vec<String>,
|
||||
pub scope: Option<String>,
|
||||
pub retention: Option<Retention>,
|
||||
#[serde(default)]
|
||||
pub properties: BTreeMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct SourceEntry {
|
||||
#[serde(default)]
|
||||
pub categories: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub whose: Vec<String>,
|
||||
#[serde(default, rename = "where")]
|
||||
pub location: Vec<String>,
|
||||
pub scope: Option<String>,
|
||||
pub retention: Option<Retention>,
|
||||
#[serde(default)]
|
||||
pub enabled_by: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub captures: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub leaves_host: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct Catalog {
|
||||
#[serde(default)]
|
||||
pub object: BTreeMap<String, ObjectEntry>,
|
||||
#[serde(default)]
|
||||
pub source: BTreeMap<String, SourceEntry>,
|
||||
}
|
||||
|
||||
/// The shipped catalog, parsed once. It is checked in CI
|
||||
/// (`tools/fork/privacy-check.py`), so a parse failure is a build bug.
|
||||
pub fn catalog() -> &'static Catalog {
|
||||
static CATALOG_PARSED: OnceLock<Catalog> = OnceLock::new();
|
||||
CATALOG_PARSED.get_or_init(|| toml::from_str(CATALOG).expect("resources/privacy/catalog.toml parses"))
|
||||
}
|
||||
|
||||
/// A duration setting's live value.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Days {
|
||||
/// Set, in whole days (rounded up).
|
||||
Days(u64),
|
||||
/// Unset: nothing bounds it.
|
||||
Unbounded,
|
||||
}
|
||||
|
||||
/// Everything the evaluation needs from the running server.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LiveFacts {
|
||||
/// Duration settings by name (`x:DataRetention.holdTracesFor`,
|
||||
/// `inbuxa:AuditSettings.keepForDays` ...). A setting not here is
|
||||
/// reported by name, without a value.
|
||||
pub durations: BTreeMap<String, Days>,
|
||||
/// Whether each source or object is collected at all, by catalog id. An
|
||||
/// id not here is taken as collected.
|
||||
pub collected: BTreeMap<String, bool>,
|
||||
/// The endpoints each source or object sends to, by catalog id: hosts or
|
||||
/// URLs as configured. Loopback endpoints are left out: what goes there
|
||||
/// stays on the host ([`is_loopback`]).
|
||||
pub endpoints: BTreeMap<String, Vec<String>>,
|
||||
/// Stores pointed at a remote backend, by location (`data-store`,
|
||||
/// `blob-store`, `search-store`, `in-memory-store`), with the host.
|
||||
pub remote_stores: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RetentionOut {
|
||||
/// `unbounded`, `days`, `object-life`, `receiver`, or `setting` (named but
|
||||
/// not evaluated).
|
||||
pub kind: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub days: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub setting: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Item {
|
||||
pub id: String,
|
||||
/// `object` or `source`.
|
||||
pub kind: String,
|
||||
pub categories: Vec<String>,
|
||||
pub whose: Vec<String>,
|
||||
#[serde(rename = "where")]
|
||||
pub location: Vec<String>,
|
||||
pub scope: String,
|
||||
pub collected: bool,
|
||||
pub retention: RetentionOut,
|
||||
pub leaves_host: bool,
|
||||
pub controlled_by: Vec<String>,
|
||||
pub endpoints: Vec<String>,
|
||||
}
|
||||
|
||||
/// A host that receives personal data: a candidate processor, since whether
|
||||
/// it is one in law is the operator's determination.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Processor {
|
||||
pub host: String,
|
||||
pub receives: Vec<String>,
|
||||
pub sources: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Inventory {
|
||||
pub items: Vec<Item>,
|
||||
pub processors: Vec<Processor>,
|
||||
}
|
||||
|
||||
/// Counts for a snapshot's summary and the Overview.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Summary {
|
||||
pub collected: u64,
|
||||
pub unbounded: u64,
|
||||
pub leaving_host: u64,
|
||||
pub processors: u64,
|
||||
}
|
||||
|
||||
impl Inventory {
|
||||
pub fn summary(&self) -> Summary {
|
||||
let collected = self.items.iter().filter(|i| i.collected);
|
||||
Summary {
|
||||
collected: collected.clone().count() as u64,
|
||||
unbounded: collected
|
||||
.clone()
|
||||
.filter(|i| i.retention.kind == "unbounded")
|
||||
.count() as u64,
|
||||
leaving_host: collected.filter(|i| i.leaves_host).count() as u64,
|
||||
processors: self.processors.len() as u64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The host part of an endpoint as configured: a URL's host, or the string
|
||||
/// itself when it is a bare host or zone.
|
||||
pub fn host_of(endpoint: &str) -> String {
|
||||
let rest = endpoint.split_once("://").map_or(endpoint, |(_, rest)| rest);
|
||||
let rest = rest.rsplit_once('@').map_or(rest, |(_, host)| host);
|
||||
let host = rest.split(['/', '?', '#']).next().unwrap_or(rest);
|
||||
let host = if host.starts_with('[') {
|
||||
host.split_once(']').map_or(host, |(h, _)| h.trim_start_matches('['))
|
||||
} else {
|
||||
host.rsplit_once(':')
|
||||
.filter(|(_, port)| port.chars().all(|c| c.is_ascii_digit()))
|
||||
.map_or(host, |(h, _)| h)
|
||||
};
|
||||
host.trim().trim_end_matches('.').to_ascii_lowercase()
|
||||
}
|
||||
|
||||
/// Whether an endpoint is this host: what is sent there stays here.
|
||||
pub fn is_loopback(endpoint: &str) -> bool {
|
||||
let host = host_of(endpoint);
|
||||
host == "localhost"
|
||||
|| host.ends_with(".localhost")
|
||||
|| host == "::1"
|
||||
|| host.parse::<std::net::IpAddr>().is_ok_and(|ip| ip.is_loopback())
|
||||
}
|
||||
|
||||
fn retention_out(retention: Option<&Retention>, facts: &LiveFacts) -> RetentionOut {
|
||||
match retention {
|
||||
Some(Retention::Setting { setting }) => match facts.durations.get(setting) {
|
||||
Some(Days::Days(days)) => RetentionOut {
|
||||
kind: "days".into(),
|
||||
days: Some(*days),
|
||||
setting: Some(setting.clone()),
|
||||
},
|
||||
Some(Days::Unbounded) => RetentionOut {
|
||||
kind: "unbounded".into(),
|
||||
days: None,
|
||||
setting: Some(setting.clone()),
|
||||
},
|
||||
None => RetentionOut {
|
||||
kind: "setting".into(),
|
||||
days: None,
|
||||
setting: Some(setting.clone()),
|
||||
},
|
||||
},
|
||||
Some(Retention::Word(word)) => RetentionOut {
|
||||
kind: word.clone(),
|
||||
days: None,
|
||||
setting: None,
|
||||
},
|
||||
None => RetentionOut {
|
||||
kind: "object-life".into(),
|
||||
days: None,
|
||||
setting: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// What the catalog says `id` holds, evaluated against `facts`. Objects with
|
||||
/// nothing personal are left out. `tenant_only` keeps the entries a tenant
|
||||
/// can be told about: tenant-scoped, and none of the server's processors.
|
||||
pub fn evaluate(catalog: &Catalog, facts: &LiveFacts, tenant_only: bool) -> Inventory {
|
||||
let mut items = Vec::new();
|
||||
let mut add = |id: &str,
|
||||
kind: &str,
|
||||
categories: Vec<String>,
|
||||
whose: &[String],
|
||||
location: &[String],
|
||||
scope: Option<&String>,
|
||||
retention: Option<&Retention>,
|
||||
controlled_by: Vec<String>,
|
||||
leaves: bool| {
|
||||
let scope = scope.cloned().unwrap_or_else(|| "server".into());
|
||||
if tenant_only && scope != "tenant" {
|
||||
return;
|
||||
}
|
||||
let mut endpoints: Vec<String> = facts.endpoints.get(id).cloned().unwrap_or_default();
|
||||
// Anything sent to an endpoint off this host leaves it
|
||||
let mut leaves_host = leaves || !endpoints.is_empty();
|
||||
for place in location {
|
||||
if let Some(host) = facts.remote_stores.get(place) {
|
||||
leaves_host = true;
|
||||
endpoints.push(host.clone());
|
||||
}
|
||||
}
|
||||
endpoints.sort();
|
||||
endpoints.dedup();
|
||||
items.push(Item {
|
||||
id: id.to_string(),
|
||||
kind: kind.to_string(),
|
||||
categories,
|
||||
whose: whose.to_vec(),
|
||||
location: location.to_vec(),
|
||||
scope,
|
||||
collected: facts.collected.get(id).copied().unwrap_or(true),
|
||||
retention: retention_out(retention, facts),
|
||||
leaves_host,
|
||||
controlled_by,
|
||||
endpoints,
|
||||
});
|
||||
};
|
||||
|
||||
for (id, entry) in &catalog.source {
|
||||
let controlled_by = entry
|
||||
.enabled_by
|
||||
.iter()
|
||||
.chain(&entry.captures)
|
||||
.cloned()
|
||||
.collect();
|
||||
add(
|
||||
id,
|
||||
"source",
|
||||
entry.categories.clone(),
|
||||
&entry.whose,
|
||||
&entry.location,
|
||||
entry.scope.as_ref(),
|
||||
entry.retention.as_ref(),
|
||||
controlled_by,
|
||||
entry.leaves_host,
|
||||
);
|
||||
}
|
||||
for (id, entry) in &catalog.object {
|
||||
if entry.properties.is_empty() || entry.whose.is_empty() {
|
||||
// Nothing personal, or a credential field of a configuration
|
||||
// object with no place of its own in the inventory
|
||||
continue;
|
||||
}
|
||||
let categories: BTreeSet<String> = entry.properties.values().flatten().cloned().collect();
|
||||
let leaves = entry.location.iter().any(|place| place == "external");
|
||||
add(
|
||||
id,
|
||||
"object",
|
||||
categories.into_iter().collect(),
|
||||
&entry.whose,
|
||||
&entry.location,
|
||||
entry.scope.as_ref(),
|
||||
entry.retention.as_ref(),
|
||||
Vec::new(),
|
||||
leaves,
|
||||
);
|
||||
}
|
||||
|
||||
// Candidate processors: each host that receives something, once
|
||||
let mut processors: BTreeMap<String, (BTreeSet<String>, BTreeSet<String>)> = BTreeMap::new();
|
||||
if !tenant_only {
|
||||
for item in items.iter().filter(|i| i.collected && i.leaves_host) {
|
||||
for endpoint in &item.endpoints {
|
||||
let entry = processors.entry(host_of(endpoint)).or_default();
|
||||
entry.0.extend(item.categories.iter().cloned());
|
||||
entry.1.insert(item.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Inventory {
|
||||
items,
|
||||
processors: processors
|
||||
.into_iter()
|
||||
.filter(|(host, _)| !host.is_empty())
|
||||
.map(|(host, (receives, sources))| Processor {
|
||||
host,
|
||||
receives: receives.into_iter().collect(),
|
||||
sources: sources.into_iter().collect(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn facts() -> LiveFacts {
|
||||
LiveFacts::default()
|
||||
}
|
||||
|
||||
fn item<'a>(inventory: &'a Inventory, id: &str) -> &'a Item {
|
||||
inventory
|
||||
.items
|
||||
.iter()
|
||||
.find(|i| i.id == id)
|
||||
.unwrap_or_else(|| panic!("{id} not in the inventory"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shipped_catalog_parses() {
|
||||
let catalog = catalog();
|
||||
assert!(catalog.source.contains_key("log-file"));
|
||||
assert!(catalog.object.contains_key("x:UserAccount"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_a_new_install_would_report() {
|
||||
let mut facts = facts();
|
||||
facts
|
||||
.durations
|
||||
.insert("x:DataRetention.holdTracesFor".into(), Days::Days(14));
|
||||
facts
|
||||
.durations
|
||||
.insert("inbuxa:LogSettings.keepForDays".into(), Days::Days(30));
|
||||
facts.endpoints.insert("spam-pyzor".into(), vec!["public.pyzor.org:24441".into()]);
|
||||
facts.collected.insert("spam-pyzor".into(), false);
|
||||
facts.endpoints.insert(
|
||||
"spam-dnsbl".into(),
|
||||
vec!["zen.spamhaus.org".into(), "bl.spamcop.net".into()],
|
||||
);
|
||||
let inventory = evaluate(catalog(), &facts, false);
|
||||
|
||||
let trace = item(&inventory, "x:Trace");
|
||||
assert_eq!(trace.retention.kind, "days");
|
||||
assert_eq!(trace.retention.days, Some(14));
|
||||
assert!(!trace.leaves_host);
|
||||
assert_eq!(item(&inventory, "log-file").retention.days, Some(30));
|
||||
// Pyzor off: listed, not collected, not a processor
|
||||
assert!(!item(&inventory, "spam-pyzor").collected);
|
||||
let hosts: Vec<_> = inventory.processors.iter().map(|p| p.host.as_str()).collect();
|
||||
assert_eq!(hosts, vec!["bl.spamcop.net", "zen.spamhaus.org"]);
|
||||
// Nothing personal isn't listed
|
||||
assert!(inventory.items.iter().all(|i| i.id != "x:Http"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_external_store_makes_what_lives_there_leave_the_host() {
|
||||
let mut facts = facts();
|
||||
facts
|
||||
.remote_stores
|
||||
.insert("blob-store".into(), "https://s3.example.net/mail".into());
|
||||
let inventory = evaluate(catalog(), &facts, false);
|
||||
let archived = item(&inventory, "x:ArchivedEmail");
|
||||
assert!(archived.leaves_host);
|
||||
assert_eq!(archived.endpoints, vec!["https://s3.example.net/mail"]);
|
||||
assert!(inventory.processors.iter().any(|p| p.host == "s3.example.net"
|
||||
&& p.sources.contains(&"x:ArchivedEmail".to_string())));
|
||||
// What lives only in the data store stays
|
||||
assert!(!item(&inventory, "x:UserAccount").leaves_host);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hosted_ai_endpoint_is_a_processor_of_content() {
|
||||
let mut facts = facts();
|
||||
facts.collected.insert("spam-llm".into(), true);
|
||||
facts
|
||||
.endpoints
|
||||
.insert("spam-llm".into(), vec!["https://api.example-ai.com/v1".into()]);
|
||||
let inventory = evaluate(catalog(), &facts, false);
|
||||
let ai = inventory
|
||||
.processors
|
||||
.iter()
|
||||
.find(|p| p.host == "api.example-ai.com")
|
||||
.expect("the AI endpoint is listed");
|
||||
assert_eq!(ai.receives, vec!["content"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telemetry_off_is_reported_as_not_collected() {
|
||||
let mut facts = facts();
|
||||
for id in ["x:Trace", "trace-index", "log-file"] {
|
||||
facts.collected.insert(id.into(), false);
|
||||
}
|
||||
let inventory = evaluate(catalog(), &facts, false);
|
||||
for id in ["x:Trace", "trace-index", "log-file"] {
|
||||
assert!(!item(&inventory, id).collected, "{id}");
|
||||
}
|
||||
assert_eq!(item(&inventory, "log-file").retention.kind, "setting");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tenant_sees_its_slice_and_no_processors() {
|
||||
let mut facts = facts();
|
||||
facts.endpoints.insert("spam-dnsbl".into(), vec!["zen.spamhaus.org".into()]);
|
||||
let inventory = evaluate(catalog(), &facts, true);
|
||||
assert!(inventory.items.iter().all(|i| i.scope == "tenant"));
|
||||
assert!(inventory.items.iter().any(|i| i.id == "x:UserAccount"));
|
||||
assert!(inventory.items.iter().all(|i| i.id != "log-file"));
|
||||
assert!(inventory.processors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosts_are_read_from_urls_and_bare_names() {
|
||||
assert_eq!(host_of("https://user:[email protected]:8443/path?x"), "hooks.example.com");
|
||||
assert_eq!(host_of("public.pyzor.org:24441"), "public.pyzor.org");
|
||||
assert_eq!(host_of("zen.spamhaus.org."), "zen.spamhaus.org");
|
||||
assert_eq!(host_of("http://[::1]:11434/v1"), "::1");
|
||||
assert_eq!(host_of("postgres://db.internal:5432/mail"), "db.internal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopback_stays_on_the_host() {
|
||||
assert!(is_loopback("http://127.0.0.1:11434/v1"));
|
||||
assert!(is_loopback("http://localhost:8080"));
|
||||
assert!(is_loopback("http://[::1]:11434"));
|
||||
assert!(!is_loopback("http://10.77.0.2:11434"), "another node leaves the host");
|
||||
assert!(!is_loopback("https://api.example-ai.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_configured_endpoint_means_it_leaves() {
|
||||
let mut facts = facts();
|
||||
facts.endpoints.insert("x:Trace".into(), vec!["postgres://traces.example.net".into()]);
|
||||
let inventory = evaluate(catalog(), &facts, false);
|
||||
assert!(item(&inventory, "x:Trace").leaves_host);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_summary_counts_what_is_collected() {
|
||||
let mut facts = facts();
|
||||
facts.collected.insert("log-file".into(), true);
|
||||
let inventory = evaluate(catalog(), &facts, false);
|
||||
let summary = inventory.summary();
|
||||
assert!(summary.collected > 10);
|
||||
assert!(summary.unbounded >= 1, "sources the catalog marks unbounded");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! Dated copies of the evaluated inventory (personal-data catalog spec, §6,
|
||||
//! `inbuxa:InventorySnapshot`), so the Overview can show when and why what
|
||||
//! the server holds changed. Stored as JSON under `C` `i` and the time taken
|
||||
//! (seconds, big-endian) in the fork's subspace; kept as long as the audit
|
||||
//! log keeps its records (settled 2026-09-28).
|
||||
|
||||
use super::{Inventory, Summary};
|
||||
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
|
||||
use store::{
|
||||
Deserialize, IterateParams, SUBSPACE_INBUXA, Store, U64_LEN, ValueKey,
|
||||
write::{AnyClass, BatchBuilder, ValueClass, key::DeserializeBigEndian},
|
||||
};
|
||||
use trc::AddContext;
|
||||
|
||||
const PREFIX: &[u8] = b"Ci";
|
||||
|
||||
/// Why a snapshot was taken.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum Trigger {
|
||||
/// A setting the catalog names changed: the object type that changed.
|
||||
SettingChanged { setting: String },
|
||||
/// The daily snapshot.
|
||||
Daily,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Snapshot {
|
||||
/// Seconds since the epoch; also the snapshot's id.
|
||||
pub taken_at: u64,
|
||||
pub trigger: Trigger,
|
||||
pub summary: Summary,
|
||||
pub inventory: Inventory,
|
||||
}
|
||||
|
||||
fn key(taken_at: u64) -> Vec<u8> {
|
||||
let mut key = PREFIX.to_vec();
|
||||
key.extend_from_slice(&taken_at.to_be_bytes());
|
||||
key
|
||||
}
|
||||
|
||||
fn class(taken_at: u64) -> ValueClass {
|
||||
ValueClass::Any(AnyClass {
|
||||
subspace: SUBSPACE_INBUXA,
|
||||
key: key(taken_at),
|
||||
})
|
||||
}
|
||||
|
||||
struct Json(Snapshot);
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores a snapshot. Two in the same second: the later one wins.
|
||||
pub async fn record(data: &Store, snapshot: &Snapshot) -> trc::Result<()> {
|
||||
let bytes = serde_json::to_vec(snapshot).map_err(|err| {
|
||||
trc::StoreEvent::UnexpectedError
|
||||
.caused_by(trc::location!())
|
||||
.reason(err)
|
||||
})?;
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.set(class(snapshot.taken_at), bytes);
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// One snapshot, by the time it was taken.
|
||||
pub async fn get(data: &Store, taken_at: u64) -> trc::Result<Option<Snapshot>> {
|
||||
Ok(data
|
||||
.get_value::<Json>(ValueKey::from(class(taken_at)))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.map(|Json(snapshot)| snapshot))
|
||||
}
|
||||
|
||||
/// The times snapshots were taken between `after` and `before` (inclusive,
|
||||
/// seconds), newest first.
|
||||
pub async fn list(data: &Store, after: u64, before: u64) -> trc::Result<Vec<u64>> {
|
||||
let mut times = Vec::new();
|
||||
data.iterate(
|
||||
IterateParams::new(
|
||||
ValueKey::from(class(after)),
|
||||
ValueKey::from(class(before)),
|
||||
)
|
||||
.no_values(),
|
||||
|key, _| {
|
||||
times.push(key.deserialize_be_u64(key.len() - U64_LEN)?);
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
times.reverse();
|
||||
Ok(times)
|
||||
}
|
||||
|
||||
/// The newest snapshot's time, if any.
|
||||
pub async fn latest(data: &Store) -> trc::Result<Option<u64>> {
|
||||
Ok(list(data, 0, u64::MAX).await?.first().copied())
|
||||
}
|
||||
|
||||
/// Removes snapshots taken before `before` (seconds). Returns how many went.
|
||||
pub async fn purge(data: &Store, before: u64) -> trc::Result<usize> {
|
||||
let old = list(data, 0, before.saturating_sub(1)).await?;
|
||||
if old.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut batch = BatchBuilder::new();
|
||||
for taken_at in &old {
|
||||
batch.clear(class(*taken_at));
|
||||
}
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
Ok(old.len())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keys_sort_by_time() {
|
||||
assert!(key(1) < key(2));
|
||||
assert!(key(255) < key(256));
|
||||
assert_eq!(&key(7)[..2], PREFIX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trigger_reads_as_json_names_it() {
|
||||
let changed = serde_json::to_value(Trigger::SettingChanged {
|
||||
setting: "x:DataRetention".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(changed["kind"], "settingChanged");
|
||||
assert_eq!(changed["setting"], "x:DataRetention");
|
||||
assert_eq!(serde_json::to_value(Trigger::Daily).unwrap()["kind"], "daily");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:LogSettings`, how long rotated log files are kept (personal-data
|
||||
//! catalog spec, default D1, settled 2026-09-28). Stored as JSON under `T` +
|
||||
//! `l` in the fork's subspace, not on `x:TracerLog`: that object is also
|
||||
//! stored inside `x:Bootstrap` with fields after it, so a new field there
|
||||
//! would change `x:Bootstrap`'s stored format.
|
||||
//!
|
||||
//! Unset, files are kept as they always were: forever. A new install sets
|
||||
//! 30 days. Each node deletes its own files, since log files are local.
|
||||
|
||||
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
use store::{
|
||||
Deserialize, SUBSPACE_INBUXA, Store, ValueKey,
|
||||
write::{AnyClass, BatchBuilder, ValueClass},
|
||||
};
|
||||
use trc::AddContext;
|
||||
|
||||
/// The fewest days a limit may keep, so a typo can't empty the log directory
|
||||
/// of what an incident needs.
|
||||
pub const MIN_KEEP_DAYS: u64 = 1;
|
||||
|
||||
/// The days a new install keeps (D1).
|
||||
pub const NEW_INSTALL_KEEP_DAYS: u64 = 30;
|
||||
|
||||
/// Rung when the settings change here, so this node purges at once; other
|
||||
/// nodes read the settings again within the hour.
|
||||
pub static CHANGED: tokio::sync::Notify = tokio::sync::Notify::const_new();
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct LogSettings {
|
||||
/// Rotated log files older than this many days are deleted; `None`
|
||||
/// keeps them all.
|
||||
pub keep_for_days: Option<u64>,
|
||||
}
|
||||
|
||||
/// The properties `inbuxa:LogSettings` has, as they appear over JMAP.
|
||||
pub const PROPERTIES: &[&str] = &["keepForDays"];
|
||||
|
||||
impl LogSettings {
|
||||
/// What's wrong with these values, naming the property.
|
||||
pub fn check(&self) -> Result<(), (&'static str, String)> {
|
||||
match self.keep_for_days {
|
||||
Some(days) if days < MIN_KEEP_DAYS => Err((
|
||||
"keepForDays",
|
||||
format!("must be at least {MIN_KEEP_DAYS}, or null to keep every file"),
|
||||
)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn key() -> ValueClass {
|
||||
ValueClass::Any(AnyClass {
|
||||
subspace: SUBSPACE_INBUXA,
|
||||
key: b"Tl".to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
struct Json(LogSettings);
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The settings in force; unset reads as keep everything.
|
||||
pub async fn get(data: &Store) -> trc::Result<LogSettings> {
|
||||
Ok(data
|
||||
.get_value::<Json>(ValueKey::from(key()))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.map(|Json(settings)| settings)
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Whether anything was ever stored: a new install writes its default only
|
||||
/// when nothing is there.
|
||||
pub async fn is_set(data: &Store) -> trc::Result<bool> {
|
||||
Ok(data
|
||||
.get_value::<Json>(ValueKey::from(key()))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.is_some())
|
||||
}
|
||||
|
||||
/// Stores new settings.
|
||||
pub async fn set(data: &Store, settings: &LogSettings) -> trc::Result<()> {
|
||||
let bytes = serde_json::to_vec(settings).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(|_| ())
|
||||
}
|
||||
|
||||
/// A file in a log directory: its path, name, and when it last changed.
|
||||
pub struct LogFile {
|
||||
pub path: PathBuf,
|
||||
pub name: String,
|
||||
pub modified: SystemTime,
|
||||
pub is_file: bool,
|
||||
}
|
||||
|
||||
/// The files to delete: regular files named `<prefix>.<something>`, whose
|
||||
/// last change is more than `keep` ago. The file being written changes all
|
||||
/// the time, so it is never old enough; anything not named for this log is
|
||||
/// never touched.
|
||||
pub fn expired<'a>(
|
||||
files: &'a [LogFile],
|
||||
prefix: &str,
|
||||
keep: Duration,
|
||||
now: SystemTime,
|
||||
) -> impl Iterator<Item = &'a Path> + 'a {
|
||||
let lead = format!("{prefix}.");
|
||||
files.iter().filter_map(move |file| {
|
||||
(file.is_file
|
||||
&& file.name.starts_with(&lead)
|
||||
&& now
|
||||
.duration_since(file.modified)
|
||||
.is_ok_and(|age| age > keep))
|
||||
.then_some(file.path.as_path())
|
||||
})
|
||||
}
|
||||
|
||||
/// Deletes this log's expired files in `dir`, returning how many went.
|
||||
pub fn purge(dir: &Path, prefix: &str, keep: Duration) -> std::io::Result<usize> {
|
||||
let mut files = Vec::new();
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let meta = entry.metadata()?;
|
||||
files.push(LogFile {
|
||||
path: entry.path(),
|
||||
name: entry.file_name().to_string_lossy().into_owned(),
|
||||
modified: meta.modified()?,
|
||||
is_file: meta.is_file(),
|
||||
});
|
||||
}
|
||||
let mut removed = 0;
|
||||
for path in expired(&files, prefix, keep, SystemTime::now()) {
|
||||
std::fs::remove_file(path)?;
|
||||
removed += 1;
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const DAY: Duration = Duration::from_secs(86_400);
|
||||
|
||||
fn file(name: &str, age_days: u64, now: SystemTime) -> LogFile {
|
||||
LogFile {
|
||||
path: PathBuf::from(format!("/var/log/inbuxa/{name}")),
|
||||
name: name.to_string(),
|
||||
modified: now - DAY * age_days as u32,
|
||||
is_file: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_this_logs_old_files_go() {
|
||||
let now = SystemTime::now();
|
||||
let files = [
|
||||
file("inbuxa.log.2026-08-01", 58, now),
|
||||
file("inbuxa.log.2026-09-27", 1, now),
|
||||
file("inbuxa.log", 0, now),
|
||||
file("other.log.2026-01-01", 270, now),
|
||||
file("inbuxa.logs.old", 90, now),
|
||||
LogFile {
|
||||
is_file: false,
|
||||
..file("inbuxa.log.dir", 90, now)
|
||||
},
|
||||
];
|
||||
let gone: Vec<_> = expired(&files, "inbuxa.log", 30 * DAY, now)
|
||||
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(gone, vec!["inbuxa.log.2026-08-01"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unset_keeps_everything_and_zero_is_refused() {
|
||||
assert_eq!(LogSettings::default().keep_for_days, None);
|
||||
assert!(LogSettings::default().check().is_ok());
|
||||
let zero = LogSettings {
|
||||
keep_for_days: Some(0),
|
||||
};
|
||||
assert_eq!(zero.check().unwrap_err().0, "keepForDays");
|
||||
let json: LogSettings = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(json, LogSettings::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purge_deletes_on_disk() {
|
||||
let dir = std::env::temp_dir().join(format!("inbuxa-log-purge-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let old = dir.join("inbuxa.log.2020-01-01");
|
||||
let new = dir.join("inbuxa.log.today");
|
||||
let other = dir.join("keep-me.txt");
|
||||
for path in [&old, &new, &other] {
|
||||
std::fs::write(path, b"x").unwrap();
|
||||
}
|
||||
let long_ago = SystemTime::now() - 60 * DAY;
|
||||
std::fs::File::options()
|
||||
.write(true)
|
||||
.open(&old)
|
||||
.unwrap()
|
||||
.set_modified(long_ago)
|
||||
.unwrap();
|
||||
std::fs::File::options()
|
||||
.write(true)
|
||||
.open(&other)
|
||||
.unwrap()
|
||||
.set_modified(long_ago)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(purge(&dir, "inbuxa.log", 30 * DAY).unwrap(), 1);
|
||||
assert!(!old.exists());
|
||||
assert!(new.exists());
|
||||
assert!(other.exists(), "a file not named for the log is never touched");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
//! `legacy-protocols.md`.
|
||||
|
||||
pub mod legacy_use;
|
||||
pub mod log_files;
|
||||
pub mod listeners;
|
||||
pub mod protocol_policy;
|
||||
pub mod tenant_protocol_policy;
|
||||
|
||||
@@ -8,6 +8,17 @@
|
||||
//! (legacy-protocols spec, data model and LP-1 to LP-8). Stored as JSON under
|
||||
//! `P` + `p` in the fork's subspace; unset fields read as the defaults.
|
||||
//!
|
||||
//! Each mail-app protocol has its own switch (legacy-protocols spec,
|
||||
//! "Revisit: one switch per protocol"): IMAP, POP3 and ManageSieve.
|
||||
//! `legacyProtocols` is the kill-all: setting it sets all three, and it reads
|
||||
//! `disabled` exactly when all three are off. A policy stored before the
|
||||
//! per-protocol switches has only `legacyProtocols`, and reads as all three
|
||||
//! at that value.
|
||||
//!
|
||||
//! SMTP submission has no switch of its own here: sign-in over it is refused
|
||||
//! only when all three are off, as it was by the single switch (LP-6), so
|
||||
//! turning off one protocol never stops a mail app sending.
|
||||
//!
|
||||
//! This module is the fact, not the act. It holds what the operator chose and
|
||||
//! which listeners were taken away to honour it. Closing sockets belongs to
|
||||
//! `common`, which owns the listener registry, and removing the listener
|
||||
@@ -59,12 +70,30 @@ pub struct SavedListener {
|
||||
pub object: serde_json::Value,
|
||||
}
|
||||
|
||||
/// The server-wide switch.
|
||||
/// The protocols with a switch of their own, as the schema and JMAP spell
|
||||
/// them.
|
||||
pub const SWITCHED: &[&str] = &["imap", "pop3", "manageSieve"];
|
||||
|
||||
/// The name sign-in uses for SMTP AUTH, which follows the kill-all.
|
||||
pub const SUBMISSION: &str = "submission";
|
||||
|
||||
/// The server-wide switches.
|
||||
#[derive(Debug, Clone, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct ProtocolPolicy {
|
||||
/// The switch itself.
|
||||
/// The kill-all: `disabled` exactly when all three protocols are off,
|
||||
/// once [`ProtocolPolicy::normalize`] has run. In a policy stored before
|
||||
/// the per-protocol switches, it is the value of all three.
|
||||
pub legacy_protocols: LegacyProtocols,
|
||||
/// IMAP's switch. Unset reads as `legacy_protocols`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub imap: Option<LegacyProtocols>,
|
||||
/// POP3's switch. Unset reads as `legacy_protocols`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pop3: Option<LegacyProtocols>,
|
||||
/// ManageSieve's switch. Unset reads as `legacy_protocols`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub manage_sieve: Option<LegacyProtocols>,
|
||||
/// With `disabled`, also close SMTP submission (LP-3). The inbound
|
||||
/// listener on port 25 is never closed, whatever this says.
|
||||
pub close_submission: bool,
|
||||
@@ -80,6 +109,9 @@ impl Default for ProtocolPolicy {
|
||||
fn default() -> Self {
|
||||
ProtocolPolicy {
|
||||
legacy_protocols: LegacyProtocols::Enabled,
|
||||
imap: None,
|
||||
pop3: None,
|
||||
manage_sieve: None,
|
||||
close_submission: true,
|
||||
saved_listeners: Vec::new(),
|
||||
changed_at: None,
|
||||
@@ -91,6 +123,9 @@ impl Default for ProtocolPolicy {
|
||||
/// The properties `inbuxa:ProtocolPolicy` has, as they appear over JMAP.
|
||||
pub const PROPERTIES: &[&str] = &[
|
||||
"legacyProtocols",
|
||||
"imap",
|
||||
"pop3",
|
||||
"manageSieve",
|
||||
"closeSubmission",
|
||||
"savedListeners",
|
||||
"changedAt",
|
||||
@@ -129,23 +164,137 @@ pub fn is_locked(protocol: &str) -> bool {
|
||||
.any(|locked| locked.eq_ignore_ascii_case(protocol))
|
||||
}
|
||||
|
||||
impl ProtocolPolicy {
|
||||
/// Whether a listener of this protocol and these ports is one the switch
|
||||
/// closes. A listener bound to port 25 is inbound whatever its name, and
|
||||
/// any other SMTP listener counts as submission (LP-3).
|
||||
pub fn closes(&self, protocol: &str, ports: &[u16]) -> bool {
|
||||
if !self.legacy_protocols.is_disabled() {
|
||||
return false;
|
||||
/// The switch fields, by protocol name.
|
||||
pub trait Switches {
|
||||
/// The kill-all, which an unset per-protocol switch reads as.
|
||||
fn all(&self) -> LegacyProtocols;
|
||||
fn slot(&self, protocol: &str) -> Option<&Option<LegacyProtocols>>;
|
||||
fn slot_mut(&mut self, protocol: &str) -> Option<&mut Option<LegacyProtocols>>;
|
||||
fn set_all_field(&mut self, value: LegacyProtocols);
|
||||
|
||||
/// One protocol's switch. `submission` follows the kill-all: it is off
|
||||
/// only when all three are. Anything else has no switch and is on.
|
||||
fn switch(&self, protocol: &str) -> LegacyProtocols {
|
||||
if protocol == SUBMISSION {
|
||||
return if self.all_off() {
|
||||
LegacyProtocols::Disabled
|
||||
} else {
|
||||
LegacyProtocols::Enabled
|
||||
};
|
||||
}
|
||||
match self.slot(protocol) {
|
||||
Some(value) => value.unwrap_or(self.all()),
|
||||
None => LegacyProtocols::Enabled,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this protocol is off.
|
||||
fn is_off(&self, protocol: &str) -> bool {
|
||||
self.switch(protocol).is_disabled()
|
||||
}
|
||||
|
||||
/// Whether all three protocols are off.
|
||||
fn all_off(&self) -> bool {
|
||||
SWITCHED.iter().all(|protocol| {
|
||||
self.slot(protocol)
|
||||
.and_then(|value| *value)
|
||||
.unwrap_or(self.all())
|
||||
.is_disabled()
|
||||
})
|
||||
}
|
||||
|
||||
/// Sets one protocol's switch; false if it has none.
|
||||
fn set(&mut self, protocol: &str, value: LegacyProtocols) -> bool {
|
||||
match self.slot_mut(protocol) {
|
||||
Some(slot) => {
|
||||
*slot = Some(value);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The kill-all: all three at once.
|
||||
fn set_all(&mut self, value: LegacyProtocols) {
|
||||
for protocol in SWITCHED {
|
||||
self.set(protocol, value);
|
||||
}
|
||||
self.set_all_field(value);
|
||||
}
|
||||
|
||||
/// Writes out every switch and derives the kill-all from them, so what is
|
||||
/// stored and shown never depends on how it was reached.
|
||||
fn normalize(&mut self) {
|
||||
let values: Vec<_> = SWITCHED.iter().map(|p| self.switch(p)).collect();
|
||||
for (protocol, value) in SWITCHED.iter().zip(values) {
|
||||
self.set(protocol, value);
|
||||
}
|
||||
let all = if self.all_off() {
|
||||
LegacyProtocols::Disabled
|
||||
} else {
|
||||
LegacyProtocols::Enabled
|
||||
};
|
||||
self.set_all_field(all);
|
||||
}
|
||||
|
||||
/// The protocols that are off.
|
||||
fn off(&self) -> Vec<&'static str> {
|
||||
SWITCHED
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|p| self.is_off(p))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! switches {
|
||||
($t:ty) => {
|
||||
impl Switches for $t {
|
||||
fn all(&self) -> LegacyProtocols {
|
||||
self.legacy_protocols
|
||||
}
|
||||
fn slot(&self, protocol: &str) -> Option<&Option<LegacyProtocols>> {
|
||||
match protocol {
|
||||
"imap" => Some(&self.imap),
|
||||
"pop3" => Some(&self.pop3),
|
||||
"manageSieve" => Some(&self.manage_sieve),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn slot_mut(&mut self, protocol: &str) -> Option<&mut Option<LegacyProtocols>> {
|
||||
match protocol {
|
||||
"imap" => Some(&mut self.imap),
|
||||
"pop3" => Some(&mut self.pop3),
|
||||
"manageSieve" => Some(&mut self.manage_sieve),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn set_all_field(&mut self, value: LegacyProtocols) {
|
||||
self.legacy_protocols = value;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
pub(crate) use switches;
|
||||
|
||||
switches!(ProtocolPolicy);
|
||||
|
||||
impl ProtocolPolicy {
|
||||
/// Whether a listener of this protocol and these ports is one the
|
||||
/// switches close. A listener bound to port 25 is inbound whatever its
|
||||
/// name, and any other SMTP listener counts as submission (LP-3), closed
|
||||
/// only with all three off and `closeSubmission`.
|
||||
pub fn closes(&self, protocol: &str, ports: &[u16]) -> bool {
|
||||
// The lock is checked first and answers for every caller, so no
|
||||
// request phrasing can reach past it (LP-21).
|
||||
if is_locked(protocol) {
|
||||
return false;
|
||||
}
|
||||
if LEGACY_PROTOCOLS.contains(&protocol) {
|
||||
return true;
|
||||
return self.is_off(protocol);
|
||||
}
|
||||
protocol.eq_ignore_ascii_case("smtp")
|
||||
&& self.all_off()
|
||||
&& self.close_submission
|
||||
&& !ports.contains(&INBOUND_SMTP_PORT)
|
||||
}
|
||||
@@ -258,7 +407,10 @@ mod tests {
|
||||
"an unset closeSubmission reads as the default, true"
|
||||
);
|
||||
|
||||
let json = serde_json::to_value(&policy).unwrap();
|
||||
// As shown: normalized, every switch written out.
|
||||
let mut shown = policy.clone();
|
||||
shown.normalize();
|
||||
let json = serde_json::to_value(&shown).unwrap();
|
||||
for property in PROPERTIES {
|
||||
assert!(json.get(property).is_some(), "{property}");
|
||||
}
|
||||
@@ -410,6 +562,72 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A policy stored before the per-protocol switches reads as all three
|
||||
/// at its one value.
|
||||
#[test]
|
||||
fn an_old_policy_reads_as_all_three() {
|
||||
let old: ProtocolPolicy =
|
||||
serde_json::from_str(r#"{"legacyProtocols": "disabled"}"#).unwrap();
|
||||
for p in SWITCHED {
|
||||
assert!(old.is_off(p), "{p}");
|
||||
}
|
||||
assert!(old.all_off() && old.is_off(SUBMISSION));
|
||||
let old: ProtocolPolicy =
|
||||
serde_json::from_str(r#"{"legacyProtocols": "enabled"}"#).unwrap();
|
||||
assert!(old.off().is_empty() && !old.is_off(SUBMISSION));
|
||||
}
|
||||
|
||||
/// One protocol off closes only its listeners, and leaves sending alone.
|
||||
#[test]
|
||||
fn one_protocol_off() {
|
||||
let mut policy = ProtocolPolicy::default();
|
||||
policy.set("pop3", LegacyProtocols::Disabled);
|
||||
policy.normalize();
|
||||
assert!(policy.closes("pop3", &[995]));
|
||||
assert!(!policy.closes("imap", &[993]));
|
||||
assert!(!policy.closes("manageSieve", &[4190]));
|
||||
assert!(!policy.is_off(SUBMISSION), "sending goes on");
|
||||
assert_eq!(policy.legacy_protocols, LegacyProtocols::Enabled);
|
||||
assert_eq!(policy.off(), vec!["pop3"]);
|
||||
let json = serde_json::to_value(&policy).unwrap();
|
||||
assert_eq!(json["pop3"], "disabled");
|
||||
assert_eq!(json["imap"], "enabled");
|
||||
}
|
||||
|
||||
/// Turning the three off one at a time is the kill-all, and the kill-all
|
||||
/// back on turns all three on.
|
||||
#[test]
|
||||
fn the_kill_all_is_all_three() {
|
||||
let mut policy = ProtocolPolicy::default();
|
||||
for p in SWITCHED {
|
||||
policy.set(p, LegacyProtocols::Disabled);
|
||||
}
|
||||
policy.normalize();
|
||||
assert!(policy.legacy_protocols.is_disabled());
|
||||
assert!(policy.is_off(SUBMISSION));
|
||||
|
||||
policy.set_all(LegacyProtocols::Enabled);
|
||||
policy.normalize();
|
||||
assert!(policy.off().is_empty());
|
||||
assert!(!policy.legacy_protocols.is_disabled());
|
||||
|
||||
// The kill-all then one back on: no longer all off.
|
||||
policy.set_all(LegacyProtocols::Disabled);
|
||||
policy.set("imap", LegacyProtocols::Enabled);
|
||||
policy.normalize();
|
||||
assert!(!policy.legacy_protocols.is_disabled());
|
||||
assert_eq!(policy.off(), vec!["pop3", "manageSieve"]);
|
||||
}
|
||||
|
||||
/// Protocols without a switch are never off.
|
||||
#[test]
|
||||
fn unswitched_protocols_are_on() {
|
||||
let policy = disabled();
|
||||
for p in ["smtp", "http", "lmtp", "jmap"] {
|
||||
assert!(!policy.is_off(p), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved listener with no id is refused, naming the property.
|
||||
#[test]
|
||||
fn a_nameless_saved_listener_is_refused() {
|
||||
|
||||
@@ -9,12 +9,18 @@
|
||||
//! the tenant id in the fork's subspace; a tenant with nothing stored has
|
||||
//! legacy protocols on.
|
||||
//!
|
||||
//! A tenant has the same three switches as the server (IMAP, POP3,
|
||||
//! ManageSieve) and the same kill-all; a protocol off server-wide is off for
|
||||
//! every tenant whatever the tenant's own switch says.
|
||||
//!
|
||||
//! A tenant's switch closes no port -- other tenants share them (LP-13). It
|
||||
//! refuses sign-in on the tenant's domains, and keeps client configuration
|
||||
//! for them from offering what's refused. That is all it is: one fact per
|
||||
//! tenant, easy to turn back, touching no listener, role or permission.
|
||||
|
||||
use crate::security::protocol_policy::{LegacyProtocols, ProtocolPolicy};
|
||||
use crate::security::protocol_policy::{
|
||||
LegacyProtocols, ProtocolPolicy, SUBMISSION, SWITCHED, Switches, switches,
|
||||
};
|
||||
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
|
||||
use store::{
|
||||
Deserialize, SUBSPACE_INBUXA, Store, ValueKey,
|
||||
@@ -26,24 +32,92 @@ use trc::AddContext;
|
||||
#[derive(Debug, Clone, PartialEq, Default, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct TenantProtocolPolicy {
|
||||
/// The switch itself.
|
||||
/// The kill-all, as on the server's policy.
|
||||
pub legacy_protocols: LegacyProtocols,
|
||||
/// IMAP's switch. Unset reads as `legacy_protocols`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub imap: Option<LegacyProtocols>,
|
||||
/// POP3's switch. Unset reads as `legacy_protocols`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pop3: Option<LegacyProtocols>,
|
||||
/// ManageSieve's switch. Unset reads as `legacy_protocols`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub manage_sieve: Option<LegacyProtocols>,
|
||||
/// When it last changed, in milliseconds since the epoch.
|
||||
pub changed_at: Option<u64>,
|
||||
/// The account that last changed it.
|
||||
pub changed_by: Option<String>,
|
||||
}
|
||||
|
||||
/// Why a tenant's switch can't be set this way, if it can't (LP-9).
|
||||
switches!(TenantProtocolPolicy);
|
||||
|
||||
/// Why a tenant's switches can't be set this way, if they can't (LP-9).
|
||||
///
|
||||
/// A tenant can always turn legacy protocols off for itself. It can turn
|
||||
/// them back on only while the server has them on: server off means off for
|
||||
/// everyone.
|
||||
pub fn refusal(server: &ProtocolPolicy, requested: LegacyProtocols) -> Option<&'static str> {
|
||||
(server.legacy_protocols.is_disabled() && !requested.is_disabled()).then_some(
|
||||
"Legacy mail protocols are off for the whole server (inbuxa:ProtocolPolicy), \
|
||||
so they can't be turned back on for one organization.",
|
||||
)
|
||||
/// A tenant can always turn a protocol off for itself. It can turn one on
|
||||
/// only while the server has it on: server off means off for everyone.
|
||||
/// `turned_on` is what the request sets to `enabled`, by protocol name.
|
||||
pub fn refusal(server: &ProtocolPolicy, turned_on: &[&str]) -> Option<String> {
|
||||
let blocked: Vec<&str> = turned_on
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|protocol| server.is_off(protocol))
|
||||
.collect();
|
||||
(!blocked.is_empty()).then(|| {
|
||||
format!(
|
||||
"{} off for the whole server (inbuxa:ProtocolPolicy), so {} can't be turned \
|
||||
back on for one organization.",
|
||||
names(&blocked),
|
||||
if blocked.len() == 1 { "it" } else { "they" }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Protocol names as people read them: "IMAP and POP3 are", "POP3 is".
|
||||
fn names(protocols: &[&str]) -> String {
|
||||
let named: Vec<&str> = protocols
|
||||
.iter()
|
||||
.map(|p| match *p {
|
||||
"imap" => "IMAP",
|
||||
"pop3" => "POP3",
|
||||
"manageSieve" => "ManageSieve",
|
||||
other => other,
|
||||
})
|
||||
.collect();
|
||||
let list = match named.as_slice() {
|
||||
[one] => one.to_string(),
|
||||
[rest @ .., last] => format!("{} and {last}", rest.join(", ")),
|
||||
[] => String::new(),
|
||||
};
|
||||
format!("{list} {}", if named.len() == 1 { "is" } else { "are" })
|
||||
}
|
||||
|
||||
/// Whose switch turns a protocol off, if any.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OffBy {
|
||||
Server,
|
||||
Tenant,
|
||||
}
|
||||
|
||||
/// Whether this protocol is off for an account or domain, and by whose
|
||||
/// switch: the server's first (LP-6), then the tenant's (LP-10). Submission
|
||||
/// is off when all three protocols are, counting both switches together.
|
||||
pub fn off_by(
|
||||
server: &ProtocolPolicy,
|
||||
tenant: Option<&TenantProtocolPolicy>,
|
||||
protocol: &str,
|
||||
) -> Option<OffBy> {
|
||||
if server.is_off(protocol) {
|
||||
return Some(OffBy::Server);
|
||||
}
|
||||
let tenant = tenant?;
|
||||
let off = if protocol == SUBMISSION {
|
||||
SWITCHED
|
||||
.iter()
|
||||
.all(|p| server.is_off(p) || tenant.is_off(p))
|
||||
} else {
|
||||
tenant.is_off(protocol)
|
||||
};
|
||||
off.then_some(OffBy::Tenant)
|
||||
}
|
||||
|
||||
fn key(tenant_id: u32) -> ValueClass {
|
||||
@@ -115,6 +189,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn tenant_off(protocols: &[&str]) -> TenantProtocolPolicy {
|
||||
let mut policy = TenantProtocolPolicy::default();
|
||||
for p in protocols {
|
||||
policy.set(p, LegacyProtocols::Disabled);
|
||||
}
|
||||
policy.normalize();
|
||||
policy
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tenant_starts_with_legacy_protocols_on() {
|
||||
assert!(
|
||||
@@ -127,20 +210,59 @@ mod tests {
|
||||
#[test]
|
||||
fn a_tenant_can_always_turn_them_off() {
|
||||
for s in [LegacyProtocols::Enabled, LegacyProtocols::Disabled] {
|
||||
assert_eq!(refusal(&server(s), LegacyProtocols::Disabled), None);
|
||||
assert_eq!(refusal(&server(s), &[]), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tenant_can_turn_them_on_only_while_the_server_has_them_on() {
|
||||
// LP-9, acceptance test 9.
|
||||
assert_eq!(
|
||||
refusal(&server(LegacyProtocols::Enabled), LegacyProtocols::Enabled),
|
||||
None
|
||||
);
|
||||
let why =
|
||||
refusal(&server(LegacyProtocols::Disabled), LegacyProtocols::Enabled).expect("refused");
|
||||
assert_eq!(refusal(&server(LegacyProtocols::Enabled), SWITCHED), None);
|
||||
let why = refusal(&server(LegacyProtocols::Disabled), SWITCHED).expect("refused");
|
||||
assert!(why.contains("inbuxa:ProtocolPolicy"), "{why}");
|
||||
assert!(
|
||||
why.starts_with("IMAP, POP3 and ManageSieve are off"),
|
||||
"{why}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tenant_can_turn_on_what_the_server_allows() {
|
||||
// The server has only POP3 off: IMAP may come back, POP3 may not.
|
||||
let mut s = ProtocolPolicy::default();
|
||||
s.set("pop3", LegacyProtocols::Disabled);
|
||||
assert_eq!(refusal(&s, &["imap"]), None);
|
||||
let why = refusal(&s, &["imap", "pop3"]).expect("refused");
|
||||
assert!(why.starts_with("POP3 is off"), "{why}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whose_switch_turns_a_protocol_off() {
|
||||
let mut s = ProtocolPolicy::default();
|
||||
s.set("pop3", LegacyProtocols::Disabled);
|
||||
let t = tenant_off(&["imap"]);
|
||||
assert_eq!(off_by(&s, Some(&t), "pop3"), Some(OffBy::Server));
|
||||
assert_eq!(off_by(&s, Some(&t), "imap"), Some(OffBy::Tenant));
|
||||
assert_eq!(off_by(&s, Some(&t), "manageSieve"), None);
|
||||
assert_eq!(off_by(&s, None, "imap"), None);
|
||||
// Sending goes on while any protocol is still allowed.
|
||||
assert_eq!(off_by(&s, Some(&t), SUBMISSION), None);
|
||||
// Between them, all three off: submission follows (LP-6, LP-10).
|
||||
let t = tenant_off(&["imap", "manageSieve"]);
|
||||
assert_eq!(off_by(&s, Some(&t), SUBMISSION), Some(OffBy::Tenant));
|
||||
assert_eq!(
|
||||
off_by(&server(LegacyProtocols::Disabled), None, SUBMISSION),
|
||||
Some(OffBy::Server)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_tenant_policy_reads_as_all_three() {
|
||||
let Json(old) = Json::deserialize(br#"{"legacyProtocols":"disabled"}"#).unwrap();
|
||||
for p in SWITCHED {
|
||||
assert!(old.is_off(p), "{p}");
|
||||
}
|
||||
assert!(old.is_off(SUBMISSION));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -158,6 +280,7 @@ mod tests {
|
||||
legacy_protocols: LegacyProtocols::Disabled,
|
||||
changed_at: Some(1),
|
||||
changed_by: Some("b".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let Json(back) = Json::deserialize(&serde_json::to_vec(&policy).unwrap()).unwrap();
|
||||
assert_eq!(back, policy);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "groupware"
|
||||
version = "0.16.23"
|
||||
version = "0.16.24"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "http_proto"
|
||||
version = "0.16.23"
|
||||
version = "0.16.24"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "http"
|
||||
version = "0.16.23"
|
||||
version = "0.16.24"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
+103
-70
@@ -12,7 +12,7 @@ use common::{
|
||||
},
|
||||
};
|
||||
use hyper::body::{Bytes, Frame};
|
||||
use mail_auth::{IpLookupStrategy, mta_sts::TlsRpt};
|
||||
use mail_auth::{DnssecStatus, IpLookupStrategy, mta_sts::TlsRpt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use smtp::outbound::{
|
||||
client::{SmtpClient, StartTlsResult},
|
||||
@@ -382,81 +382,25 @@ async fn delivery_diagnose(
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch TLSA record
|
||||
tx.send(DeliveryStage::TlsaLookupStart).await?;
|
||||
|
||||
let now = Instant::now();
|
||||
let dane_policy = match server.tlsa_lookup(format!("_25._tcp.{hostname}.")).await {
|
||||
Ok(TlsaResult::Secure(tlsa)) if tlsa.has_end_entities => {
|
||||
tx.send(DeliveryStage::TlsaLookupSuccess {
|
||||
record: tlsa.as_ref().clone(),
|
||||
elapsed: now.elapsed_ms(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
Some(tlsa)
|
||||
}
|
||||
Ok(TlsaResult::Secure(_)) => {
|
||||
tx.send(DeliveryStage::TlsaLookupError {
|
||||
elapsed: now.elapsed_ms(),
|
||||
reason: "TLSA record does not have end entities".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
None
|
||||
}
|
||||
Ok(TlsaResult::Bogus) => {
|
||||
tx.send(DeliveryStage::TlsaLookupError {
|
||||
elapsed: now.elapsed_ms(),
|
||||
reason: "Bogus TLSA record".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
continue 'outer;
|
||||
}
|
||||
Ok(TlsaResult::Missing) => {
|
||||
tx.send(DeliveryStage::TlsaNotFound {
|
||||
elapsed: now.elapsed_ms(),
|
||||
reason: "No TLSA DNSSEC records found".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
None
|
||||
}
|
||||
Err(err) => {
|
||||
if matches!(
|
||||
&err,
|
||||
mail_auth::Error::Dns(mail_auth::DnsError::RecordNotFound(_))
|
||||
) {
|
||||
tx.send(DeliveryStage::TlsaNotFound {
|
||||
elapsed: now.elapsed_ms(),
|
||||
reason: "No TLSA records found for MX".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
None
|
||||
} else {
|
||||
tx.send(DeliveryStage::TlsaLookupError {
|
||||
elapsed: now.elapsed_ms(),
|
||||
reason: err.to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
tx.send(DeliveryStage::IpLookupStart).await?;
|
||||
|
||||
let now = Instant::now();
|
||||
let remote_ips = match host.fqdn_hostname() {
|
||||
let validate_addresses = server.core.smtp.resolvers.dnssec_available
|
||||
&& host.dnssec_status() == DnssecStatus::Secure;
|
||||
let (remote_ips, addresses_dnssec_status) = match host.fqdn_hostname() {
|
||||
HostOrIp::Host(hostname) => {
|
||||
match server
|
||||
.ip_lookup(&hostname, IpLookupStrategy::Ipv4thenIpv6, usize::MAX, false)
|
||||
.ip_lookup(
|
||||
&hostname,
|
||||
IpLookupStrategy::Ipv4thenIpv6,
|
||||
usize::MAX,
|
||||
validate_addresses,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((remote_ips, _)) if !remote_ips.is_empty() => remote_ips,
|
||||
Ok((remote_ips, dnssec_status)) if !remote_ips.is_empty() => {
|
||||
(remote_ips, dnssec_status)
|
||||
}
|
||||
Ok(_) => {
|
||||
tx.send(DeliveryStage::IpLookupError {
|
||||
reason: "No IP addresses found for host".to_string(),
|
||||
@@ -475,7 +419,7 @@ async fn delivery_diagnose(
|
||||
}
|
||||
}
|
||||
}
|
||||
HostOrIp::Ip(ip) => vec![ip],
|
||||
HostOrIp::Ip(ip) => (vec![ip], DnssecStatus::Indeterminate),
|
||||
};
|
||||
|
||||
tx.send(DeliveryStage::IpLookupSuccess {
|
||||
@@ -484,6 +428,95 @@ async fn delivery_diagnose(
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Fetch TLSA record
|
||||
tx.send(DeliveryStage::TlsaLookupStart).await?;
|
||||
|
||||
let now = Instant::now();
|
||||
let dane_policy = match host.dane_status(addresses_dnssec_status) {
|
||||
(DnssecStatus::Secure, _) => {
|
||||
match server.tlsa_lookup(format!("_25._tcp.{hostname}.")).await {
|
||||
Ok(TlsaResult::Secure(tlsa)) if tlsa.has_end_entities => {
|
||||
tx.send(DeliveryStage::TlsaLookupSuccess {
|
||||
record: tlsa.as_ref().clone(),
|
||||
elapsed: now.elapsed_ms(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
Some(tlsa)
|
||||
}
|
||||
Ok(TlsaResult::Secure(_)) => {
|
||||
tx.send(DeliveryStage::TlsaLookupError {
|
||||
elapsed: now.elapsed_ms(),
|
||||
reason: "TLSA record does not have end entities".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
None
|
||||
}
|
||||
Ok(TlsaResult::Bogus) => {
|
||||
tx.send(DeliveryStage::TlsaLookupError {
|
||||
elapsed: now.elapsed_ms(),
|
||||
reason: "Bogus TLSA record".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
continue 'outer;
|
||||
}
|
||||
Ok(TlsaResult::Missing) => {
|
||||
tx.send(DeliveryStage::TlsaNotFound {
|
||||
elapsed: now.elapsed_ms(),
|
||||
reason: "No TLSA DNSSEC records found".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
None
|
||||
}
|
||||
Err(err) => {
|
||||
if matches!(
|
||||
&err,
|
||||
mail_auth::Error::Dns(mail_auth::DnsError::RecordNotFound(_))
|
||||
) {
|
||||
tx.send(DeliveryStage::TlsaNotFound {
|
||||
elapsed: now.elapsed_ms(),
|
||||
reason: "No TLSA records found for MX".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
None
|
||||
} else {
|
||||
tx.send(DeliveryStage::TlsaLookupError {
|
||||
elapsed: now.elapsed_ms(),
|
||||
reason: err.to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(DnssecStatus::Bogus, dnssec_entity) => {
|
||||
tx.send(DeliveryStage::TlsaLookupError {
|
||||
elapsed: now.elapsed_ms(),
|
||||
reason: format!("Bogus {dnssec_entity} records were found"),
|
||||
})
|
||||
.await?;
|
||||
|
||||
continue 'outer;
|
||||
}
|
||||
(_, dnssec_entity) => {
|
||||
tx.send(DeliveryStage::TlsaNotFound {
|
||||
elapsed: now.elapsed_ms(),
|
||||
reason: format!(
|
||||
"{dnssec_entity} records are not DNSSEC signed, DANE does not apply"
|
||||
),
|
||||
})
|
||||
.await?;
|
||||
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
for remote_ip in remote_ips {
|
||||
// Start connection
|
||||
tx.send(DeliveryStage::ConnectionStart { remote_ip })
|
||||
|
||||
@@ -36,7 +36,7 @@ use hyper::{
|
||||
server::conn::http1,
|
||||
service::service_fn,
|
||||
};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use hyper_util::rt::{TokioIo, TokioTimer};
|
||||
use jmap::{
|
||||
api::{
|
||||
ToJmapHttpResponse, event_source::EventSourceHandler, request::RequestHandler,
|
||||
@@ -690,6 +690,7 @@ async fn handle_session<T: SessionStream>(inner: Arc<Inner>, session: SessionDat
|
||||
let is_tls = session.stream.is_tls();
|
||||
|
||||
if let Err(http_err) = http1::Builder::new()
|
||||
.timer(TokioTimer::new())
|
||||
.keep_alive(true)
|
||||
.serve_connection(
|
||||
TokioIo::new(session.stream),
|
||||
@@ -875,6 +876,7 @@ async fn handle_session<T: SessionStream>(inner: Arc<Inner>, session: SessionDat
|
||||
)
|
||||
.with_upgrades()
|
||||
.await
|
||||
&& !http_err.is_timeout()
|
||||
{
|
||||
if http_err.is_parse() {
|
||||
let server = inner.build_server();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "imap_proto"
|
||||
version = "0.16.23"
|
||||
version = "0.16.24"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "imap"
|
||||
version = "0.16.23"
|
||||
version = "0.16.24"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
+249
-158
@@ -9,6 +9,7 @@ use crate::{
|
||||
core::{MailboxId, SelectedMailbox, Session, SessionData},
|
||||
spawn_op,
|
||||
};
|
||||
use ahash::AHashMap;
|
||||
use common::{ipc::PushNotification, network::SessionStream, storage::index::ObjectIndexBuilder};
|
||||
use email::{
|
||||
cache::{MessageCacheFetch, email::MessageCacheAccess},
|
||||
@@ -22,8 +23,13 @@ use email::{
|
||||
use imap_proto::{
|
||||
Command, ResponseCode, StatusResponse, protocol::copy_move::Arguments, receiver::Request,
|
||||
};
|
||||
use rand::RngExt;
|
||||
use registry::schema::enums::Permission;
|
||||
use std::{sync::Arc, time::Instant};
|
||||
use std::{
|
||||
ops::RangeInclusive,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use store::{
|
||||
ValueKey,
|
||||
roaring::RoaringBitmap,
|
||||
@@ -36,6 +42,9 @@ use types::{
|
||||
type_state::{DataType, StateChange},
|
||||
};
|
||||
|
||||
const MAX_MOVE_RETRIES: u32 = 3;
|
||||
const MOVE_RETRY_BACKOFF_MS: RangeInclusive<u64> = 1..=15;
|
||||
|
||||
impl<T: SessionStream> Session<T> {
|
||||
pub async fn handle_copy_move(
|
||||
&mut self,
|
||||
@@ -236,148 +245,180 @@ impl<T: SessionStream> SessionData<T> {
|
||||
// Mailboxes are in the same account
|
||||
let account_id = src_mailbox.id.account_id;
|
||||
let dest_mailbox_id = UidMailbox::new_unassigned(dest_mailbox_id);
|
||||
let mut batch = BatchBuilder::new();
|
||||
let mut written_uids = AHashMap::with_capacity(ids.len());
|
||||
let mut retries = 0;
|
||||
|
||||
for (id, imap_id) in ids {
|
||||
// Obtain mailbox tags
|
||||
let data_ = if let Some(result) = self
|
||||
.get_message_data(account_id, id)
|
||||
.await
|
||||
.imap_ctx(&arguments.tag, trc::location!())?
|
||||
{
|
||||
result
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
loop {
|
||||
let mut batch = BatchBuilder::new();
|
||||
copied_ids.clear();
|
||||
did_move = false;
|
||||
|
||||
// Deserialize
|
||||
let data = data_
|
||||
.to_unarchived::<MessageData>()
|
||||
.imap_ctx(&arguments.tag, trc::location!())?;
|
||||
for (&id, imap_id) in &ids {
|
||||
// Obtain mailbox tags
|
||||
let data_ = if let Some(result) = self
|
||||
.get_message_data(account_id, id)
|
||||
.await
|
||||
.imap_ctx(&arguments.tag, trc::location!())?
|
||||
{
|
||||
result
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Make sure the message still belongs to this mailbox
|
||||
if !data
|
||||
.inner
|
||||
.mailboxes
|
||||
.iter()
|
||||
.any(|mailbox| mailbox.mailbox_id == src_mailbox.id.mailbox_id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Deserialize
|
||||
let data = data_
|
||||
.to_unarchived::<MessageData>()
|
||||
.imap_ctx(&arguments.tag, trc::location!())?;
|
||||
|
||||
// If the message is already in the destination mailbox, skip it.
|
||||
if let Some(mailbox) = data
|
||||
.inner
|
||||
.mailboxes
|
||||
.iter()
|
||||
.find(|mailbox| mailbox.mailbox_id == dest_mailbox_id.mailbox_id)
|
||||
{
|
||||
copied_ids.push((imap_id.uid, mailbox.uid.to_native()));
|
||||
|
||||
if is_move {
|
||||
let mut new_data = data.inner.to_builder();
|
||||
new_data.remove_mailbox(src_mailbox.id.mailbox_id);
|
||||
batch
|
||||
.with_account_id(account_id)
|
||||
.with_collection(Collection::Email)
|
||||
.with_document(id)
|
||||
.custom(
|
||||
ObjectIndexBuilder::new()
|
||||
.with_current(data)
|
||||
.with_changes(new_data.seal()),
|
||||
)
|
||||
.imap_ctx(&arguments.tag, trc::location!())?
|
||||
.log_vanished_item(
|
||||
VanishedCollection::Email,
|
||||
(src_mailbox.id.mailbox_id, imap_id.uid),
|
||||
)
|
||||
.commit_point();
|
||||
did_move = true;
|
||||
// Make sure the message still belongs to this mailbox
|
||||
if !data
|
||||
.inner
|
||||
.mailboxes
|
||||
.iter()
|
||||
.any(|mailbox| mailbox.mailbox_id == src_mailbox.id.mailbox_id)
|
||||
{
|
||||
// Moved by a chunk of a previous attempt
|
||||
if let Some(&uid) = written_uids.get(&id)
|
||||
&& data.inner.message_uid(dest_mailbox_id.mailbox_id) == Some(uid)
|
||||
{
|
||||
copied_ids.push((imap_id.uid, uid));
|
||||
did_move = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
// If the message is already in the destination mailbox, skip it.
|
||||
if let Some(mailbox) = data
|
||||
.inner
|
||||
.mailboxes
|
||||
.iter()
|
||||
.find(|mailbox| mailbox.mailbox_id == dest_mailbox_id.mailbox_id)
|
||||
{
|
||||
let uid = mailbox.uid.to_native();
|
||||
copied_ids.push((imap_id.uid, uid));
|
||||
|
||||
// Prepare changes
|
||||
let mut new_data = data.inner.to_builder();
|
||||
if is_move {
|
||||
let mut new_data = data.inner.to_builder();
|
||||
new_data.remove_mailbox(src_mailbox.id.mailbox_id);
|
||||
batch
|
||||
.with_account_id(account_id)
|
||||
.with_collection(Collection::Email)
|
||||
.with_document(id)
|
||||
.custom(
|
||||
ObjectIndexBuilder::new()
|
||||
.with_current(data)
|
||||
.with_changes(new_data.seal()),
|
||||
)
|
||||
.imap_ctx(&arguments.tag, trc::location!())?
|
||||
.log_vanished_item(
|
||||
VanishedCollection::Email,
|
||||
(src_mailbox.id.mailbox_id, imap_id.uid),
|
||||
)
|
||||
.commit_point();
|
||||
written_uids.insert(id, uid);
|
||||
did_move = true;
|
||||
}
|
||||
|
||||
// Add destination folder
|
||||
new_data.add_mailbox(dest_mailbox_id);
|
||||
if is_move {
|
||||
new_data.remove_mailbox(src_mailbox.id.mailbox_id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Assign IMAP UIDs
|
||||
let ids = self
|
||||
.server
|
||||
.assign_email_ids(
|
||||
account_id,
|
||||
new_data
|
||||
.mailboxes
|
||||
.iter()
|
||||
.filter(|m| m.uid == 0)
|
||||
.map(|m| m.mailbox_id),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
// Prepare changes
|
||||
let mut new_data = data.inner.to_builder();
|
||||
|
||||
for (uid_mailbox, uid) in new_data
|
||||
.mailboxes
|
||||
.iter_mut()
|
||||
.filter(|m| m.uid == 0)
|
||||
.zip(ids)
|
||||
{
|
||||
copied_ids.push((imap_id.uid, uid));
|
||||
uid_mailbox.uid = uid;
|
||||
}
|
||||
// Add destination folder
|
||||
new_data.add_mailbox(dest_mailbox_id);
|
||||
if is_move {
|
||||
new_data.remove_mailbox(src_mailbox.id.mailbox_id);
|
||||
}
|
||||
|
||||
// Prepare write batch
|
||||
batch
|
||||
.with_account_id(account_id)
|
||||
.with_collection(Collection::Email)
|
||||
.with_document(id)
|
||||
.custom(
|
||||
ObjectIndexBuilder::new()
|
||||
.with_current(data)
|
||||
.with_changes(new_data.seal()),
|
||||
)
|
||||
.imap_ctx(&arguments.tag, trc::location!())?;
|
||||
if is_move {
|
||||
batch.log_vanished_item(
|
||||
VanishedCollection::Email,
|
||||
(src_mailbox.id.mailbox_id, imap_id.uid),
|
||||
);
|
||||
}
|
||||
|
||||
// Add message to training queue
|
||||
if dest_mailbox_id.mailbox_id == JUNK_ID {
|
||||
self.server
|
||||
.add_account_spam_sample(&mut batch, account_id, id, true, self.session_id)
|
||||
.await
|
||||
.imap_ctx(&arguments.tag, trc::location!())?;
|
||||
} else if src_mailbox.id.mailbox_id == JUNK_ID
|
||||
&& dest_mailbox_id.mailbox_id != TRASH_ID
|
||||
{
|
||||
self.server
|
||||
.add_account_spam_sample(&mut batch, account_id, id, false, self.session_id)
|
||||
// Assign IMAP UIDs
|
||||
let ids = self
|
||||
.server
|
||||
.assign_email_ids(
|
||||
account_id,
|
||||
new_data
|
||||
.mailboxes
|
||||
.iter()
|
||||
.filter(|m| m.uid == 0)
|
||||
.map(|m| m.mailbox_id),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
for (uid_mailbox, uid) in new_data
|
||||
.mailboxes
|
||||
.iter_mut()
|
||||
.filter(|m| m.uid == 0)
|
||||
.zip(ids)
|
||||
{
|
||||
copied_ids.push((imap_id.uid, uid));
|
||||
written_uids.insert(id, uid);
|
||||
uid_mailbox.uid = uid;
|
||||
}
|
||||
|
||||
// Prepare write batch
|
||||
batch
|
||||
.with_account_id(account_id)
|
||||
.with_collection(Collection::Email)
|
||||
.with_document(id)
|
||||
.custom(
|
||||
ObjectIndexBuilder::new()
|
||||
.with_current(data)
|
||||
.with_changes(new_data.seal()),
|
||||
)
|
||||
.imap_ctx(&arguments.tag, trc::location!())?;
|
||||
if is_move {
|
||||
batch.log_vanished_item(
|
||||
VanishedCollection::Email,
|
||||
(src_mailbox.id.mailbox_id, imap_id.uid),
|
||||
);
|
||||
}
|
||||
|
||||
// Add message to training queue
|
||||
if dest_mailbox_id.mailbox_id == JUNK_ID {
|
||||
self.server
|
||||
.add_account_spam_sample(
|
||||
&mut batch,
|
||||
account_id,
|
||||
id,
|
||||
true,
|
||||
self.session_id,
|
||||
)
|
||||
.await
|
||||
.imap_ctx(&arguments.tag, trc::location!())?;
|
||||
} else if src_mailbox.id.mailbox_id == JUNK_ID
|
||||
&& dest_mailbox_id.mailbox_id != TRASH_ID
|
||||
{
|
||||
self.server
|
||||
.add_account_spam_sample(
|
||||
&mut batch,
|
||||
account_id,
|
||||
id,
|
||||
false,
|
||||
self.session_id,
|
||||
)
|
||||
.await
|
||||
.imap_ctx(&arguments.tag, trc::location!())?;
|
||||
}
|
||||
|
||||
batch.commit_point();
|
||||
|
||||
// Update changelog
|
||||
if is_move {
|
||||
did_move = true;
|
||||
}
|
||||
}
|
||||
|
||||
batch.commit_point();
|
||||
|
||||
// Update changelog
|
||||
if is_move {
|
||||
did_move = true;
|
||||
// Write changes
|
||||
match self.server.commit_batch(batch).await {
|
||||
Ok(_) => break,
|
||||
Err(err) => {
|
||||
retry_after_conflict(err, &mut retries, &arguments.tag, trc::location!())
|
||||
.await?
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write changes
|
||||
self.server
|
||||
.commit_batch(batch)
|
||||
.await
|
||||
.imap_ctx(&arguments.tag, trc::location!())?;
|
||||
} else {
|
||||
// Obtain quota for target account
|
||||
let src_account_id = src_mailbox.id.account_id;
|
||||
@@ -400,7 +441,7 @@ impl<T: SessionStream> SessionData<T> {
|
||||
let mut train_batch = BatchBuilder::new();
|
||||
let mut did_train = false;
|
||||
train_batch.with_account_id(src_account_id);
|
||||
for (id, imap_id) in ids {
|
||||
'next_message: for (id, imap_id) in ids {
|
||||
match self
|
||||
.server
|
||||
.copy_message(
|
||||
@@ -448,22 +489,27 @@ impl<T: SessionStream> SessionData<T> {
|
||||
{
|
||||
copied_ids.push((imap_id.uid, uid));
|
||||
} else {
|
||||
let data_ = if let Some(data_) = self
|
||||
.get_message_data(dest_account_id, existing_id)
|
||||
.await
|
||||
.imap_ctx(&arguments.tag, trc::location!())?
|
||||
{
|
||||
data_
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let data = data_
|
||||
.to_unarchived::<MessageData>()
|
||||
.imap_ctx(&arguments.tag, trc::location!())?;
|
||||
let mut retries = 0;
|
||||
|
||||
loop {
|
||||
let data_ = if let Some(data_) = self
|
||||
.get_message_data(dest_account_id, existing_id)
|
||||
.await
|
||||
.imap_ctx(&arguments.tag, trc::location!())?
|
||||
{
|
||||
data_
|
||||
} else {
|
||||
continue 'next_message;
|
||||
};
|
||||
let data = data_
|
||||
.to_unarchived::<MessageData>()
|
||||
.imap_ctx(&arguments.tag, trc::location!())?;
|
||||
|
||||
if let Some(uid) = data.inner.message_uid(dest_mailbox_id) {
|
||||
copied_ids.push((imap_id.uid, uid));
|
||||
break;
|
||||
}
|
||||
|
||||
if let Some(uid) = data.inner.message_uid(dest_mailbox_id) {
|
||||
copied_ids.push((imap_id.uid, uid));
|
||||
} else {
|
||||
let mut new_data = data.inner.to_builder();
|
||||
new_data.add_mailbox(UidMailbox::new_unassigned(dest_mailbox_id));
|
||||
|
||||
@@ -504,15 +550,27 @@ impl<T: SessionStream> SessionData<T> {
|
||||
)
|
||||
.imap_ctx(&arguments.tag, trc::location!())?;
|
||||
|
||||
dest_change_id = self
|
||||
match self
|
||||
.server
|
||||
.commit_batch(batch)
|
||||
.await
|
||||
.and_then(|ids| ids.last_change_id(dest_account_id))
|
||||
.imap_ctx(&arguments.tag, trc::location!())?
|
||||
.into();
|
||||
|
||||
copied_ids.push((imap_id.uid, assigned_uid));
|
||||
{
|
||||
Ok(change_id) => {
|
||||
dest_change_id = change_id.into();
|
||||
copied_ids.push((imap_id.uid, assigned_uid));
|
||||
break;
|
||||
}
|
||||
Err(err) => {
|
||||
retry_after_conflict(
|
||||
err,
|
||||
&mut retries,
|
||||
&arguments.tag,
|
||||
trc::location!(),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -554,21 +612,33 @@ impl<T: SessionStream> SessionData<T> {
|
||||
|
||||
// Untag or delete emails
|
||||
if !destroy_ids.is_empty() {
|
||||
let mut batch = BatchBuilder::new();
|
||||
self.email_untag_or_delete(
|
||||
src_account_id,
|
||||
src_mailbox.id.mailbox_id,
|
||||
&destroy_ids,
|
||||
&mut batch,
|
||||
)
|
||||
.await
|
||||
.imap_ctx(&arguments.tag, trc::location!())?;
|
||||
let mut retries = 0;
|
||||
|
||||
self.server
|
||||
.commit_batch(batch)
|
||||
loop {
|
||||
let mut batch = BatchBuilder::new();
|
||||
self.email_untag_or_delete(
|
||||
src_account_id,
|
||||
src_mailbox.id.mailbox_id,
|
||||
&destroy_ids,
|
||||
&mut batch,
|
||||
)
|
||||
.await
|
||||
.imap_ctx(&arguments.tag, trc::location!())?;
|
||||
|
||||
match self.server.commit_batch(batch).await {
|
||||
Ok(_) => break,
|
||||
Err(err) => {
|
||||
retry_after_conflict(
|
||||
err,
|
||||
&mut retries,
|
||||
&arguments.tag,
|
||||
trc::location!(),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
did_move = true;
|
||||
}
|
||||
|
||||
@@ -731,3 +801,24 @@ impl<T: SessionStream> SessionData<T> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn retry_after_conflict(
|
||||
err: trc::Error,
|
||||
retries: &mut u32,
|
||||
tag: &str,
|
||||
location: &'static str,
|
||||
) -> trc::Result<()> {
|
||||
if !err.is_assertion_failure() {
|
||||
Err(err).imap_ctx(tag, location)
|
||||
} else if *retries < MAX_MOVE_RETRIES {
|
||||
*retries += 1;
|
||||
let backoff = rand::rng().random_range(MOVE_RETRY_BACKOFF_MS);
|
||||
tokio::time::sleep(Duration::from_millis(backoff)).await;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(trc::ImapEvent::Error
|
||||
.into_err()
|
||||
.details("Some messages were modified by another process.")
|
||||
.id(tag.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "jmap_proto"
|
||||
version = "0.16.23"
|
||||
version = "0.16.24"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -12,7 +12,6 @@ use crate::{
|
||||
email::{EmailProperty, EmailValue},
|
||||
},
|
||||
request::{
|
||||
MaybeInvalid,
|
||||
deserialize::{DeserializeArguments, deserialize_request},
|
||||
reference::{MaybeIdReference, MaybeResultReference, ResultReference},
|
||||
},
|
||||
@@ -33,7 +32,7 @@ pub struct ImportEmailRequest {
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ImportEmail {
|
||||
pub blob_id: MaybeInvalid<BlobId>,
|
||||
pub blob_id: MaybeIdReference<BlobId>,
|
||||
pub mailbox_ids: MaybeResultReference<Vec<MaybeIdReference<Id>>>,
|
||||
pub keywords: Vec<Keyword>,
|
||||
pub received_at: Option<UTCDate>,
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:DataInventory/get` under `urn:inbuxa:jmap`: the personal-data
|
||||
//! catalog evaluated against this server's live settings (personal-data
|
||||
//! catalog spec, §6). A singleton, id `singleton`; read-only.
|
||||
|
||||
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 DataInventory;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum DataInventoryProperty {
|
||||
Id,
|
||||
EvaluatedAt,
|
||||
CatalogVersion,
|
||||
Summary,
|
||||
Items,
|
||||
Processors,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum DataInventoryValue {
|
||||
Id(Id),
|
||||
}
|
||||
|
||||
impl Property for DataInventoryProperty {
|
||||
fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||
DataInventoryProperty::parse(value)
|
||||
}
|
||||
|
||||
fn to_cow(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
DataInventoryProperty::Id => "id",
|
||||
DataInventoryProperty::EvaluatedAt => "evaluatedAt",
|
||||
DataInventoryProperty::CatalogVersion => "catalogVersion",
|
||||
DataInventoryProperty::Summary => "summary",
|
||||
DataInventoryProperty::Items => "items",
|
||||
DataInventoryProperty::Processors => "processors",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl DataInventoryProperty {
|
||||
fn parse(value: &str) -> Option<Self> {
|
||||
hashify::tiny_map!(value.as_bytes(),
|
||||
b"id" => DataInventoryProperty::Id,
|
||||
b"evaluatedAt" => DataInventoryProperty::EvaluatedAt,
|
||||
b"catalogVersion" => DataInventoryProperty::CatalogVersion,
|
||||
b"summary" => DataInventoryProperty::Summary,
|
||||
b"items" => DataInventoryProperty::Items,
|
||||
b"processors" => DataInventoryProperty::Processors,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for DataInventoryProperty {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
DataInventoryProperty::parse(s).ok_or(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for DataInventoryValue {
|
||||
type Property = DataInventoryProperty;
|
||||
|
||||
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||
match key {
|
||||
Key::Property(DataInventoryProperty::Id) => {
|
||||
Id::from_str(value).ok().map(DataInventoryValue::Id)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_cow(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
DataInventoryValue::Id(id) => id.to_string().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObject for DataInventory {
|
||||
type Property = DataInventoryProperty;
|
||||
|
||||
type Element = DataInventoryValue;
|
||||
|
||||
type Id = Id;
|
||||
|
||||
type Filter = ();
|
||||
|
||||
type Comparator = ();
|
||||
|
||||
type GetArguments = ();
|
||||
|
||||
type SetArguments<'de> = ();
|
||||
|
||||
type QueryArguments = ();
|
||||
|
||||
type CopyArguments = ();
|
||||
|
||||
type ParseArguments = ();
|
||||
|
||||
const ID_PROPERTY: Self::Property = DataInventoryProperty::Id;
|
||||
}
|
||||
|
||||
impl From<Id> for DataInventoryValue {
|
||||
fn from(id: Id) -> Self {
|
||||
DataInventoryValue::Id(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for DataInventoryValue {
|
||||
fn as_id(&self) -> Option<Id> {
|
||||
match self {
|
||||
DataInventoryValue::Id(id) => Some(*id),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
match self {
|
||||
DataInventoryValue::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 = DataInventoryValue::Id(id);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for DataInventoryProperty {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:HoldExport/get` and `/set` under `urn:inbuxa:jmap`: collecting
|
||||
//! what a legal hold keeps as a ZIP (audit-hold-lock spec, LH-12). Creating
|
||||
//! one starts it; it runs in the background, and `get` says when it's ready
|
||||
//! and which blob to download. The set call's `reason` says why (AU-12).
|
||||
|
||||
use crate::{
|
||||
object::{AnyId, JmapObject, JmapObjectId},
|
||||
request::deserialize::DeserializeArguments,
|
||||
};
|
||||
use jmap_tools::{Element, Key, Property};
|
||||
use std::{borrow::Cow, str::FromStr};
|
||||
use types::id::Id;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HoldExport;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum HoldExportProperty {
|
||||
Id,
|
||||
HoldId,
|
||||
AccountIds,
|
||||
Reason,
|
||||
Status,
|
||||
CreatedAt,
|
||||
CreatedBy,
|
||||
FinishedAt,
|
||||
BlobId,
|
||||
Size,
|
||||
Items,
|
||||
Sha256,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum HoldExportValue {
|
||||
Id(Id),
|
||||
}
|
||||
|
||||
impl Property for HoldExportProperty {
|
||||
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||
match parent {
|
||||
None => HoldExportProperty::parse(value),
|
||||
Some(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_cow(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
HoldExportProperty::Id => "id",
|
||||
HoldExportProperty::HoldId => "holdId",
|
||||
HoldExportProperty::AccountIds => "accountIds",
|
||||
HoldExportProperty::Reason => "reason",
|
||||
HoldExportProperty::Status => "status",
|
||||
HoldExportProperty::CreatedAt => "createdAt",
|
||||
HoldExportProperty::CreatedBy => "createdBy",
|
||||
HoldExportProperty::FinishedAt => "finishedAt",
|
||||
HoldExportProperty::BlobId => "blobId",
|
||||
HoldExportProperty::Size => "size",
|
||||
HoldExportProperty::Items => "items",
|
||||
HoldExportProperty::Sha256 => "sha256",
|
||||
HoldExportProperty::Error => "error",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl HoldExportProperty {
|
||||
fn parse(value: &str) -> Option<Self> {
|
||||
hashify::tiny_map!(value.as_bytes(),
|
||||
b"id" => HoldExportProperty::Id,
|
||||
b"holdId" => HoldExportProperty::HoldId,
|
||||
b"accountIds" => HoldExportProperty::AccountIds,
|
||||
b"reason" => HoldExportProperty::Reason,
|
||||
b"status" => HoldExportProperty::Status,
|
||||
b"createdAt" => HoldExportProperty::CreatedAt,
|
||||
b"createdBy" => HoldExportProperty::CreatedBy,
|
||||
b"finishedAt" => HoldExportProperty::FinishedAt,
|
||||
b"blobId" => HoldExportProperty::BlobId,
|
||||
b"size" => HoldExportProperty::Size,
|
||||
b"items" => HoldExportProperty::Items,
|
||||
b"sha256" => HoldExportProperty::Sha256,
|
||||
b"error" => HoldExportProperty::Error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for HoldExportProperty {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
HoldExportProperty::parse(s).ok_or(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for HoldExportValue {
|
||||
type Property = HoldExportProperty;
|
||||
|
||||
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||
match key {
|
||||
Key::Property(HoldExportProperty::Id) => Id::from_str(value).ok().map(HoldExportValue::Id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_cow(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
HoldExportValue::Id(id) => id.to_string().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The set call's own arguments: why (AU-12).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HoldExportSetArguments {
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
impl<'de> DeserializeArguments<'de> for HoldExportSetArguments {
|
||||
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
|
||||
where
|
||||
A: serde::de::MapAccess<'de>,
|
||||
{
|
||||
if key == "reason" {
|
||||
self.reason = map.next_value()?;
|
||||
} else {
|
||||
let _ = map.next_value::<serde::de::IgnoredAny>()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObject for HoldExport {
|
||||
type Property = HoldExportProperty;
|
||||
|
||||
type Element = HoldExportValue;
|
||||
|
||||
type Id = Id;
|
||||
|
||||
type Filter = ();
|
||||
|
||||
type Comparator = ();
|
||||
|
||||
type GetArguments = ();
|
||||
|
||||
type SetArguments<'de> = HoldExportSetArguments;
|
||||
|
||||
type QueryArguments = ();
|
||||
|
||||
type CopyArguments = ();
|
||||
|
||||
type ParseArguments = ();
|
||||
|
||||
const ID_PROPERTY: Self::Property = HoldExportProperty::Id;
|
||||
}
|
||||
|
||||
impl From<Id> for HoldExportValue {
|
||||
fn from(id: Id) -> Self {
|
||||
HoldExportValue::Id(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for HoldExportValue {
|
||||
fn as_id(&self) -> Option<Id> {
|
||||
match self {
|
||||
HoldExportValue::Id(id) => Some(*id),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
match self {
|
||||
HoldExportValue::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 = HoldExportValue::Id(id);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for HoldExportProperty {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:InventorySnapshot/get` under `urn:inbuxa:jmap`: dated copies of
|
||||
//! the evaluated inventory (personal-data catalog spec, §6). The id is the
|
||||
//! time taken; `ids: null` lists every snapshot kept, newest first.
|
||||
|
||||
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 InventorySnapshot;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum InventorySnapshotProperty {
|
||||
Id,
|
||||
TakenAt,
|
||||
Trigger,
|
||||
Summary,
|
||||
Inventory,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum InventorySnapshotValue {
|
||||
Id(Id),
|
||||
}
|
||||
|
||||
impl Property for InventorySnapshotProperty {
|
||||
fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||
InventorySnapshotProperty::parse(value)
|
||||
}
|
||||
|
||||
fn to_cow(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
InventorySnapshotProperty::Id => "id",
|
||||
InventorySnapshotProperty::TakenAt => "takenAt",
|
||||
InventorySnapshotProperty::Trigger => "trigger",
|
||||
InventorySnapshotProperty::Summary => "summary",
|
||||
InventorySnapshotProperty::Inventory => "inventory",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl InventorySnapshotProperty {
|
||||
fn parse(value: &str) -> Option<Self> {
|
||||
hashify::tiny_map!(value.as_bytes(),
|
||||
b"id" => InventorySnapshotProperty::Id,
|
||||
b"takenAt" => InventorySnapshotProperty::TakenAt,
|
||||
b"trigger" => InventorySnapshotProperty::Trigger,
|
||||
b"summary" => InventorySnapshotProperty::Summary,
|
||||
b"inventory" => InventorySnapshotProperty::Inventory,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for InventorySnapshotProperty {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
InventorySnapshotProperty::parse(s).ok_or(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for InventorySnapshotValue {
|
||||
type Property = InventorySnapshotProperty;
|
||||
|
||||
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||
match key {
|
||||
Key::Property(InventorySnapshotProperty::Id) => {
|
||||
Id::from_str(value).ok().map(InventorySnapshotValue::Id)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_cow(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
InventorySnapshotValue::Id(id) => id.to_string().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObject for InventorySnapshot {
|
||||
type Property = InventorySnapshotProperty;
|
||||
|
||||
type Element = InventorySnapshotValue;
|
||||
|
||||
type Id = Id;
|
||||
|
||||
type Filter = ();
|
||||
|
||||
type Comparator = ();
|
||||
|
||||
type GetArguments = ();
|
||||
|
||||
type SetArguments<'de> = ();
|
||||
|
||||
type QueryArguments = ();
|
||||
|
||||
type CopyArguments = ();
|
||||
|
||||
type ParseArguments = ();
|
||||
|
||||
const ID_PROPERTY: Self::Property = InventorySnapshotProperty::Id;
|
||||
}
|
||||
|
||||
impl From<Id> for InventorySnapshotValue {
|
||||
fn from(id: Id) -> Self {
|
||||
InventorySnapshotValue::Id(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for InventorySnapshotValue {
|
||||
fn as_id(&self) -> Option<Id> {
|
||||
match self {
|
||||
InventorySnapshotValue::Id(id) => Some(*id),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
match self {
|
||||
InventorySnapshotValue::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 = InventorySnapshotValue::Id(id);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for InventorySnapshotProperty {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:LogSettings/get` and `/set` under `urn:inbuxa:jmap`: how long
|
||||
//! rotated log files are kept (personal-data catalog spec, D1). A singleton,
|
||||
//! id `singleton`.
|
||||
|
||||
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 LogSettings;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum LogSettingsProperty {
|
||||
Id,
|
||||
KeepForDays,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum LogSettingsValue {
|
||||
Id(Id),
|
||||
}
|
||||
|
||||
impl Property for LogSettingsProperty {
|
||||
fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||
LogSettingsProperty::parse(value)
|
||||
}
|
||||
|
||||
fn to_cow(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
LogSettingsProperty::Id => "id",
|
||||
LogSettingsProperty::KeepForDays => "keepForDays",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl LogSettingsProperty {
|
||||
fn parse(value: &str) -> Option<Self> {
|
||||
hashify::tiny_map!(value.as_bytes(),
|
||||
b"id" => LogSettingsProperty::Id,
|
||||
b"keepForDays" => LogSettingsProperty::KeepForDays,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for LogSettingsProperty {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
LogSettingsProperty::parse(s).ok_or(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for LogSettingsValue {
|
||||
type Property = LogSettingsProperty;
|
||||
|
||||
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||
match key {
|
||||
Key::Property(LogSettingsProperty::Id) => {
|
||||
Id::from_str(value).ok().map(LogSettingsValue::Id)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_cow(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
LogSettingsValue::Id(id) => id.to_string().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObject for LogSettings {
|
||||
type Property = LogSettingsProperty;
|
||||
|
||||
type Element = LogSettingsValue;
|
||||
|
||||
type Id = Id;
|
||||
|
||||
type Filter = ();
|
||||
|
||||
type Comparator = ();
|
||||
|
||||
type GetArguments = ();
|
||||
|
||||
type SetArguments<'de> = ();
|
||||
|
||||
type QueryArguments = ();
|
||||
|
||||
type CopyArguments = ();
|
||||
|
||||
type ParseArguments = ();
|
||||
|
||||
const ID_PROPERTY: Self::Property = LogSettingsProperty::Id;
|
||||
}
|
||||
|
||||
impl From<Id> for LogSettingsValue {
|
||||
fn from(id: Id) -> Self {
|
||||
LogSettingsValue::Id(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for LogSettingsValue {
|
||||
fn as_id(&self) -> Option<Id> {
|
||||
match self {
|
||||
LogSettingsValue::Id(id) => Some(*id),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
match self {
|
||||
LogSettingsValue::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 = LogSettingsValue::Id(id);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for LogSettingsProperty {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,13 @@ pub struct ProtocolPolicy;
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum ProtocolPolicyProperty {
|
||||
Id,
|
||||
/// The switch: `enabled` or `disabled`.
|
||||
/// The kill-all: `enabled` or `disabled`; reads `disabled` when all
|
||||
/// three protocols are off, and sets all three.
|
||||
LegacyProtocols,
|
||||
/// Each protocol's own switch: `enabled` or `disabled`.
|
||||
Imap,
|
||||
Pop3,
|
||||
ManageSieve,
|
||||
/// Whether submission closes with it. Forced false while SMTP is locked.
|
||||
CloseSubmission,
|
||||
/// Server-set: the listeners taken away, for LP-5.
|
||||
@@ -56,6 +61,9 @@ impl Property for ProtocolPolicyProperty {
|
||||
match self {
|
||||
ProtocolPolicyProperty::Id => "id",
|
||||
ProtocolPolicyProperty::LegacyProtocols => "legacyProtocols",
|
||||
ProtocolPolicyProperty::Imap => "imap",
|
||||
ProtocolPolicyProperty::Pop3 => "pop3",
|
||||
ProtocolPolicyProperty::ManageSieve => "manageSieve",
|
||||
ProtocolPolicyProperty::CloseSubmission => "closeSubmission",
|
||||
ProtocolPolicyProperty::SavedListeners => "savedListeners",
|
||||
ProtocolPolicyProperty::ChangedAt => "changedAt",
|
||||
@@ -73,6 +81,9 @@ impl ProtocolPolicyProperty {
|
||||
hashify::tiny_map!(value.as_bytes(),
|
||||
b"id" => ProtocolPolicyProperty::Id,
|
||||
b"legacyProtocols" => ProtocolPolicyProperty::LegacyProtocols,
|
||||
b"imap" => ProtocolPolicyProperty::Imap,
|
||||
b"pop3" => ProtocolPolicyProperty::Pop3,
|
||||
b"manageSieve" => ProtocolPolicyProperty::ManageSieve,
|
||||
b"closeSubmission" => ProtocolPolicyProperty::CloseSubmission,
|
||||
b"savedListeners" => ProtocolPolicyProperty::SavedListeners,
|
||||
b"changedAt" => ProtocolPolicyProperty::ChangedAt,
|
||||
|
||||
@@ -24,8 +24,13 @@ pub enum TenantProtocolPolicyProperty {
|
||||
Id,
|
||||
/// Server-set: the tenant this is the switch of.
|
||||
TenantId,
|
||||
/// The switch: `enabled` or `disabled`.
|
||||
/// The kill-all: `enabled` or `disabled`; reads `disabled` when all
|
||||
/// three protocols are off, and sets all three.
|
||||
LegacyProtocols,
|
||||
/// Each protocol's own switch: `enabled` or `disabled`.
|
||||
Imap,
|
||||
Pop3,
|
||||
ManageSieve,
|
||||
ChangedAt,
|
||||
ChangedBy,
|
||||
/// Server-set: who signed in over a legacy protocol in the last 30
|
||||
@@ -48,6 +53,9 @@ impl Property for TenantProtocolPolicyProperty {
|
||||
TenantProtocolPolicyProperty::Id => "id",
|
||||
TenantProtocolPolicyProperty::TenantId => "tenantId",
|
||||
TenantProtocolPolicyProperty::LegacyProtocols => "legacyProtocols",
|
||||
TenantProtocolPolicyProperty::Imap => "imap",
|
||||
TenantProtocolPolicyProperty::Pop3 => "pop3",
|
||||
TenantProtocolPolicyProperty::ManageSieve => "manageSieve",
|
||||
TenantProtocolPolicyProperty::ChangedAt => "changedAt",
|
||||
TenantProtocolPolicyProperty::ChangedBy => "changedBy",
|
||||
TenantProtocolPolicyProperty::RecentLegacyUse => "recentLegacyUse",
|
||||
@@ -62,6 +70,9 @@ impl TenantProtocolPolicyProperty {
|
||||
b"id" => TenantProtocolPolicyProperty::Id,
|
||||
b"tenantId" => TenantProtocolPolicyProperty::TenantId,
|
||||
b"legacyProtocols" => TenantProtocolPolicyProperty::LegacyProtocols,
|
||||
b"imap" => TenantProtocolPolicyProperty::Imap,
|
||||
b"pop3" => TenantProtocolPolicyProperty::Pop3,
|
||||
b"manageSieve" => TenantProtocolPolicyProperty::ManageSieve,
|
||||
b"changedAt" => TenantProtocolPolicyProperty::ChangedAt,
|
||||
b"changedBy" => TenantProtocolPolicyProperty::ChangedBy,
|
||||
b"recentLegacyUse" => TenantProtocolPolicyProperty::RecentLegacyUse,
|
||||
|
||||
@@ -23,8 +23,12 @@ pub mod email_submission;
|
||||
pub mod fastmail_masked_email; // inbuxa: masked email
|
||||
pub mod inbuxa_account_lock; // inbuxa: account lock with delegation
|
||||
pub mod inbuxa_ai_limits; // inbuxa: AI spam classification
|
||||
pub mod inbuxa_log_settings; // inbuxa: personal-data catalog, D1
|
||||
pub mod inbuxa_data_inventory; // inbuxa: personal-data catalog
|
||||
pub mod inbuxa_inventory_snapshot; // inbuxa: personal-data catalog
|
||||
pub mod inbuxa_audit; // inbuxa: the audit log
|
||||
pub mod inbuxa_legal_hold; // inbuxa: legal hold
|
||||
pub mod inbuxa_hold_export; // inbuxa: legal hold exports
|
||||
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
|
||||
|
||||
@@ -61,6 +61,15 @@ impl Response<'_> {
|
||||
GetResponseMethod::AiLimits(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::LogSettings(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::DataInventory(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::InventorySnapshot(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::AuditEvent(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
@@ -73,6 +82,9 @@ impl Response<'_> {
|
||||
GetResponseMethod::LegalHold(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::HoldExport(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::ProtocolPolicy(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
|
||||
@@ -46,10 +46,14 @@ impl Response<'_> {
|
||||
GetRequestMethod::MaskedEmail(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::DeletedAccount(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::AiLimits(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::LogSettings(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::DataInventory(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::InventorySnapshot(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::AuditEvent(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::AuditSettings(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::AccountLock(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::LegalHold(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::HoldExport(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::TenantProtocolPolicy(request) => {
|
||||
request.resolve_references(self)?
|
||||
@@ -97,6 +101,9 @@ impl Response<'_> {
|
||||
SetRequestMethod::AiLimits(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
SetRequestMethod::LogSettings(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
SetRequestMethod::Explanation(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
@@ -115,6 +122,9 @@ impl Response<'_> {
|
||||
SetRequestMethod::LegalHold(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
SetRequestMethod::HoldExport(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
SetRequestMethod::ProtocolPolicy(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
@@ -384,6 +394,10 @@ impl ResolveReference for ImportEmailRequest {
|
||||
fn resolve_references(&mut self, response: &Response<'_>) -> trc::Result<()> {
|
||||
// Resolve email mailbox references
|
||||
for email in self.emails.values_mut() {
|
||||
if let MaybeIdReference::Reference(ir) = &email.blob_id {
|
||||
email.blob_id = MaybeIdReference::Id(response.eval_blob_id_reference(ir)?);
|
||||
}
|
||||
|
||||
match &mut email.mailbox_ids {
|
||||
MaybeResultReference::Reference(reference) => {
|
||||
email.mailbox_ids = MaybeResultReference::Value(
|
||||
|
||||
@@ -169,6 +169,11 @@ pub struct InbuxaAccountCapabilities {
|
||||
/// (legacy-protocols spec, Interfaces; LP-19).
|
||||
#[serde(rename(serialize = "legacyProtocols"))]
|
||||
pub legacy_protocols: &'static str,
|
||||
/// The legacy protocols still allowed for the principal, each the
|
||||
/// stricter of the two switches: `imap`, `pop3`, `manageSieve`,
|
||||
/// `submission` (legacy-protocols spec, one switch per protocol).
|
||||
#[serde(rename(serialize = "legacyAllowed"))]
|
||||
pub legacy_allowed: Vec<&'static str>,
|
||||
/// Whether the principal may use "Explain this" now: it holds
|
||||
/// `sysAiExplain`, is server-level, and a model resolves (ai-explain
|
||||
/// spec, EX-1 to EX-4).
|
||||
|
||||
@@ -49,6 +49,9 @@ pub enum MethodObject {
|
||||
DeletedAccount,
|
||||
// inbuxa: AI call limits
|
||||
AiLimits,
|
||||
LogSettings,
|
||||
DataInventory,
|
||||
InventorySnapshot,
|
||||
// inbuxa: "Explain this" with the local model
|
||||
Explanation,
|
||||
// inbuxa: the audit log
|
||||
@@ -60,6 +63,7 @@ pub enum MethodObject {
|
||||
AccountLock,
|
||||
// inbuxa: legal hold
|
||||
LegalHold,
|
||||
HoldExport,
|
||||
ProtocolPolicy,
|
||||
TenantProtocolPolicy,
|
||||
}
|
||||
@@ -88,13 +92,17 @@ impl MethodObject {
|
||||
MethodObject::MaskedEmail => Capability::FastmailMaskedEmail,
|
||||
MethodObject::DeletedAccount => Capability::Inbuxa,
|
||||
MethodObject::AiLimits => Capability::Inbuxa,
|
||||
MethodObject::LogSettings => Capability::Inbuxa,
|
||||
MethodObject::DataInventory => Capability::Inbuxa,
|
||||
MethodObject::InventorySnapshot => Capability::Inbuxa,
|
||||
MethodObject::Explanation => Capability::Inbuxa,
|
||||
MethodObject::AuditEvent
|
||||
| MethodObject::AuditSettings
|
||||
| MethodObject::AuditExport
|
||||
| MethodObject::AuditVerification
|
||||
| MethodObject::AccountLock
|
||||
| MethodObject::LegalHold => Capability::Inbuxa,
|
||||
| MethodObject::LegalHold
|
||||
| MethodObject::HoldExport => Capability::Inbuxa,
|
||||
MethodObject::ProtocolPolicy => Capability::Inbuxa,
|
||||
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
|
||||
}
|
||||
@@ -274,6 +282,10 @@ impl MethodName {
|
||||
(MethodFunction::Set, MethodObject::DeletedAccount) => "inbuxa:DeletedAccount/set",
|
||||
(MethodFunction::Get, MethodObject::AiLimits) => "inbuxa:AiLimits/get",
|
||||
(MethodFunction::Set, MethodObject::AiLimits) => "inbuxa:AiLimits/set",
|
||||
(MethodFunction::Get, MethodObject::LogSettings) => "inbuxa:LogSettings/get",
|
||||
(MethodFunction::Get, MethodObject::DataInventory) => "inbuxa:DataInventory/get",
|
||||
(MethodFunction::Get, MethodObject::InventorySnapshot) => "inbuxa:InventorySnapshot/get",
|
||||
(MethodFunction::Set, MethodObject::LogSettings) => "inbuxa:LogSettings/set",
|
||||
(MethodFunction::Set, MethodObject::Explanation) => "inbuxa:Explanation/set",
|
||||
(MethodFunction::Get, MethodObject::AuditEvent) => "inbuxa:AuditEvent/get",
|
||||
(MethodFunction::Query, MethodObject::AuditEvent) => "inbuxa:AuditEvent/query",
|
||||
@@ -284,6 +296,8 @@ impl MethodName {
|
||||
(MethodFunction::Set, MethodObject::AccountLock) => "inbuxa:AccountLock/set",
|
||||
(MethodFunction::Get, MethodObject::LegalHold) => "inbuxa:LegalHold/get",
|
||||
(MethodFunction::Set, MethodObject::LegalHold) => "inbuxa:LegalHold/set",
|
||||
(MethodFunction::Get, MethodObject::HoldExport) => "inbuxa:HoldExport/get",
|
||||
(MethodFunction::Set, MethodObject::HoldExport) => "inbuxa:HoldExport/set",
|
||||
(MethodFunction::Set, MethodObject::AuditVerification) => {
|
||||
"inbuxa:AuditVerification/set"
|
||||
}
|
||||
@@ -420,6 +434,10 @@ impl MethodName {
|
||||
"inbuxa:DeletedAccount/set" => (MethodObject::DeletedAccount, MethodFunction::Set),
|
||||
"inbuxa:AiLimits/get" => (MethodObject::AiLimits, MethodFunction::Get),
|
||||
"inbuxa:AiLimits/set" => (MethodObject::AiLimits, MethodFunction::Set),
|
||||
"inbuxa:LogSettings/get" => (MethodObject::LogSettings, MethodFunction::Get),
|
||||
"inbuxa:DataInventory/get" => (MethodObject::DataInventory, MethodFunction::Get),
|
||||
"inbuxa:InventorySnapshot/get" => (MethodObject::InventorySnapshot, MethodFunction::Get),
|
||||
"inbuxa:LogSettings/set" => (MethodObject::LogSettings, MethodFunction::Set),
|
||||
"inbuxa:Explanation/set" => (MethodObject::Explanation, MethodFunction::Set),
|
||||
"inbuxa:AuditEvent/get" => (MethodObject::AuditEvent, MethodFunction::Get),
|
||||
"inbuxa:AuditEvent/query" => (MethodObject::AuditEvent, MethodFunction::Query),
|
||||
@@ -430,6 +448,8 @@ impl MethodName {
|
||||
"inbuxa:AccountLock/set" => (MethodObject::AccountLock, MethodFunction::Set),
|
||||
"inbuxa:LegalHold/get" => (MethodObject::LegalHold, MethodFunction::Get),
|
||||
"inbuxa:LegalHold/set" => (MethodObject::LegalHold, MethodFunction::Set),
|
||||
"inbuxa:HoldExport/get" => (MethodObject::HoldExport, MethodFunction::Get),
|
||||
"inbuxa:HoldExport/set" => (MethodObject::HoldExport, MethodFunction::Set),
|
||||
"inbuxa:AuditVerification/set" => (MethodObject::AuditVerification, MethodFunction::Set),
|
||||
"inbuxa:ProtocolPolicy/get" => (MethodObject::ProtocolPolicy, MethodFunction::Get),
|
||||
"inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set),
|
||||
@@ -488,6 +508,9 @@ impl Display for MethodObject {
|
||||
MethodObject::MaskedEmail => "MaskedEmail",
|
||||
MethodObject::DeletedAccount => "inbuxa:DeletedAccount",
|
||||
MethodObject::AiLimits => "inbuxa:AiLimits",
|
||||
MethodObject::LogSettings => "inbuxa:LogSettings",
|
||||
MethodObject::DataInventory => "inbuxa:DataInventory",
|
||||
MethodObject::InventorySnapshot => "inbuxa:InventorySnapshot",
|
||||
MethodObject::Explanation => "inbuxa:Explanation",
|
||||
MethodObject::AuditEvent => "inbuxa:AuditEvent",
|
||||
MethodObject::AuditSettings => "inbuxa:AuditSettings",
|
||||
@@ -495,6 +518,7 @@ impl Display for MethodObject {
|
||||
MethodObject::AuditVerification => "inbuxa:AuditVerification",
|
||||
MethodObject::AccountLock => "inbuxa:AccountLock",
|
||||
MethodObject::LegalHold => "inbuxa:LegalHold",
|
||||
MethodObject::HoldExport => "inbuxa:HoldExport",
|
||||
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
|
||||
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
|
||||
MethodObject::Registry(obj) => {
|
||||
|
||||
@@ -116,10 +116,14 @@ pub enum GetRequestMethod {
|
||||
MaskedEmail(Box<GetRequest<crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
|
||||
DeletedAccount(Box<GetRequest<crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
||||
AiLimits(Box<GetRequest<crate::object::inbuxa_ai_limits::AiLimits>>),
|
||||
LogSettings(Box<GetRequest<crate::object::inbuxa_log_settings::LogSettings>>),
|
||||
DataInventory(Box<GetRequest<crate::object::inbuxa_data_inventory::DataInventory>>),
|
||||
InventorySnapshot(Box<GetRequest<crate::object::inbuxa_inventory_snapshot::InventorySnapshot>>),
|
||||
AuditEvent(Box<GetRequest<crate::object::inbuxa_audit::AuditEvent>>),
|
||||
AuditSettings(Box<GetRequest<crate::object::inbuxa_audit::AuditSettings>>),
|
||||
AccountLock(Box<GetRequest<crate::object::inbuxa_account_lock::AccountLock>>),
|
||||
LegalHold(Box<GetRequest<crate::object::inbuxa_legal_hold::LegalHold>>),
|
||||
HoldExport(Box<GetRequest<crate::object::inbuxa_hold_export::HoldExport>>),
|
||||
ProtocolPolicy(Box<GetRequest<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||
TenantProtocolPolicy(
|
||||
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||
@@ -147,12 +151,14 @@ pub enum SetRequestMethod<'x> {
|
||||
MaskedEmail(Box<SetRequest<'x, crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
|
||||
DeletedAccount(Box<SetRequest<'x, crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
||||
AiLimits(Box<SetRequest<'x, crate::object::inbuxa_ai_limits::AiLimits>>),
|
||||
LogSettings(Box<SetRequest<'x, crate::object::inbuxa_log_settings::LogSettings>>),
|
||||
Explanation(Box<SetRequest<'x, crate::object::inbuxa_explanation::Explanation>>),
|
||||
AuditSettings(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditSettings>>),
|
||||
AuditExport(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditExport>>),
|
||||
AuditVerification(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditVerification>>),
|
||||
AccountLock(Box<SetRequest<'x, crate::object::inbuxa_account_lock::AccountLock>>),
|
||||
LegalHold(Box<SetRequest<'x, crate::object::inbuxa_legal_hold::LegalHold>>),
|
||||
HoldExport(Box<SetRequest<'x, crate::object::inbuxa_hold_export::HoldExport>>),
|
||||
ProtocolPolicy(Box<SetRequest<'x, crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||
TenantProtocolPolicy(
|
||||
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||
|
||||
@@ -169,6 +169,27 @@ impl<'de> Visitor<'de> for CallVisitor {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
(MethodFunction::Get, MethodObject::LogSettings) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::LogSettings(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
Ok(None) => {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
(MethodFunction::Get, MethodObject::DataInventory) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::DataInventory(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
Ok(None) => {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
(MethodFunction::Get, MethodObject::InventorySnapshot) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::InventorySnapshot(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
Ok(None) => {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
(MethodFunction::Get, MethodObject::ProtocolPolicy) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::ProtocolPolicy(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
@@ -350,6 +371,13 @@ impl<'de> Visitor<'de> for CallVisitor {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
(MethodFunction::Set, MethodObject::LogSettings) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::LogSettings(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
Ok(None) => {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
(MethodFunction::Set, MethodObject::Explanation) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::Explanation(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
@@ -566,6 +594,21 @@ impl<'de> Visitor<'de> for CallVisitor {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
// inbuxa: legal hold exports
|
||||
(MethodFunction::Get, MethodObject::HoldExport) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::HoldExport(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
Ok(None) => {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
(MethodFunction::Set, MethodObject::HoldExport) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::HoldExport(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
Ok(None) => {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
// inbuxa: legal hold
|
||||
(MethodFunction::Get, MethodObject::LegalHold) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::LegalHold(value)),
|
||||
|
||||
@@ -105,6 +105,12 @@ impl<V: Default> Default for MaybeResultReference<V> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<V: FromStr> Default for MaybeIdReference<V> {
|
||||
fn default() -> Self {
|
||||
MaybeIdReference::Invalid(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Default> MaybeResultReference<T> {
|
||||
pub fn unwrap(self) -> T {
|
||||
match self {
|
||||
|
||||
@@ -103,10 +103,14 @@ pub enum GetResponseMethod {
|
||||
MaskedEmail(GetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>),
|
||||
DeletedAccount(GetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>),
|
||||
AiLimits(GetResponse<crate::object::inbuxa_ai_limits::AiLimits>),
|
||||
LogSettings(GetResponse<crate::object::inbuxa_log_settings::LogSettings>),
|
||||
DataInventory(GetResponse<crate::object::inbuxa_data_inventory::DataInventory>),
|
||||
InventorySnapshot(GetResponse<crate::object::inbuxa_inventory_snapshot::InventorySnapshot>),
|
||||
AuditEvent(GetResponse<crate::object::inbuxa_audit::AuditEvent>),
|
||||
AuditSettings(GetResponse<crate::object::inbuxa_audit::AuditSettings>),
|
||||
AccountLock(GetResponse<crate::object::inbuxa_account_lock::AccountLock>),
|
||||
LegalHold(GetResponse<crate::object::inbuxa_legal_hold::LegalHold>),
|
||||
HoldExport(GetResponse<crate::object::inbuxa_hold_export::HoldExport>),
|
||||
ProtocolPolicy(GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>),
|
||||
TenantProtocolPolicy(
|
||||
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
|
||||
@@ -135,11 +139,13 @@ pub enum SetResponseMethod {
|
||||
MaskedEmail(Box<SetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
|
||||
DeletedAccount(Box<SetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
||||
AiLimits(Box<SetResponse<crate::object::inbuxa_ai_limits::AiLimits>>),
|
||||
LogSettings(Box<SetResponse<crate::object::inbuxa_log_settings::LogSettings>>),
|
||||
AuditSettings(Box<SetResponse<crate::object::inbuxa_audit::AuditSettings>>),
|
||||
AuditExport(Box<SetResponse<crate::object::inbuxa_audit::AuditExport>>),
|
||||
AuditVerification(Box<SetResponse<crate::object::inbuxa_audit::AuditVerification>>),
|
||||
AccountLock(Box<SetResponse<crate::object::inbuxa_account_lock::AccountLock>>),
|
||||
LegalHold(Box<SetResponse<crate::object::inbuxa_legal_hold::LegalHold>>),
|
||||
HoldExport(Box<SetResponse<crate::object::inbuxa_hold_export::HoldExport>>),
|
||||
Explanation(Box<SetResponse<crate::object::inbuxa_explanation::Explanation>>),
|
||||
ProtocolPolicy(Box<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||
TenantProtocolPolicy(
|
||||
@@ -347,12 +353,36 @@ impl<'x> From<GetResponse<crate::object::inbuxa_ai_limits::AiLimits>> for Respon
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<GetResponse<crate::object::inbuxa_log_settings::LogSettings>> for ResponseMethod<'x> {
|
||||
fn from(value: GetResponse<crate::object::inbuxa_log_settings::LogSettings>) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::LogSettings(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<GetResponse<crate::object::inbuxa_data_inventory::DataInventory>> for ResponseMethod<'x> {
|
||||
fn from(value: GetResponse<crate::object::inbuxa_data_inventory::DataInventory>) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::DataInventory(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<GetResponse<crate::object::inbuxa_inventory_snapshot::InventorySnapshot>> for ResponseMethod<'x> {
|
||||
fn from(value: GetResponse<crate::object::inbuxa_inventory_snapshot::InventorySnapshot>) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::InventorySnapshot(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<SetResponse<crate::object::inbuxa_ai_limits::AiLimits>> for ResponseMethod<'x> {
|
||||
fn from(value: SetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self {
|
||||
ResponseMethod::Set(SetResponseMethod::AiLimits(Box::new(value)))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<SetResponse<crate::object::inbuxa_log_settings::LogSettings>> for ResponseMethod<'x> {
|
||||
fn from(value: SetResponse<crate::object::inbuxa_log_settings::LogSettings>) -> Self {
|
||||
ResponseMethod::Set(SetResponseMethod::LogSettings(Box::new(value)))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<SetResponse<crate::object::inbuxa_explanation::Explanation>> for ResponseMethod<'x> {
|
||||
fn from(value: SetResponse<crate::object::inbuxa_explanation::Explanation>) -> Self {
|
||||
ResponseMethod::Set(SetResponseMethod::Explanation(Box::new(value)))
|
||||
@@ -780,3 +810,16 @@ impl<'x> From<SetResponse<crate::object::inbuxa_legal_hold::LegalHold>> for Resp
|
||||
ResponseMethod::Set(SetResponseMethod::LegalHold(Box::new(value)))
|
||||
}
|
||||
}
|
||||
|
||||
// inbuxa: legal hold exports
|
||||
impl<'x> From<GetResponse<crate::object::inbuxa_hold_export::HoldExport>> for ResponseMethod<'x> {
|
||||
fn from(value: GetResponse<crate::object::inbuxa_hold_export::HoldExport>) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::HoldExport(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<SetResponse<crate::object::inbuxa_hold_export::HoldExport>> for ResponseMethod<'x> {
|
||||
fn from(value: SetResponse<crate::object::inbuxa_hold_export::HoldExport>) -> Self {
|
||||
ResponseMethod::Set(SetResponseMethod::HoldExport(Box::new(value)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "jmap"
|
||||
version = "0.16.23"
|
||||
version = "0.16.24"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
@@ -39,6 +39,7 @@ base64 = "0.23"
|
||||
p256 = { version = "0.13", features = ["ecdh"] }
|
||||
sha1 = "0.11"
|
||||
sha2 = "0.11"
|
||||
zip = "8.6" # inbuxa: legal hold exports (LH-12)
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2"]}
|
||||
tokio-tungstenite = "0.30"
|
||||
tungstenite = "0.30"
|
||||
|
||||
@@ -90,6 +90,12 @@ impl JmapAuthorization for AccessToken {
|
||||
GetRequestMethod::DeletedAccount(_) => Permission::SysAccountGet,
|
||||
// inbuxa: AI call limits, with the classifier's permissions
|
||||
GetRequestMethod::AiLimits(_) => Permission::SysSpamLlmGet,
|
||||
// inbuxa: log file retention, with the tracers' permissions
|
||||
GetRequestMethod::LogSettings(_) => Permission::SysTracerGet,
|
||||
// inbuxa: personal-data catalog, the inventory and its history
|
||||
GetRequestMethod::DataInventory(_) | GetRequestMethod::InventorySnapshot(_) => {
|
||||
Permission::SysComplianceGet
|
||||
}
|
||||
// inbuxa: the audit log (AU-9)
|
||||
GetRequestMethod::AuditEvent(_) | GetRequestMethod::AuditSettings(_) => {
|
||||
Permission::SysAuditGet
|
||||
@@ -97,6 +103,7 @@ impl JmapAuthorization for AccessToken {
|
||||
// inbuxa: account lock (AL-12)
|
||||
GetRequestMethod::AccountLock(_) => Permission::SysAccountLockGet,
|
||||
GetRequestMethod::LegalHold(_) => Permission::SysLegalHoldGet,
|
||||
GetRequestMethod::HoldExport(_) => Permission::SysLegalHoldExport,
|
||||
// inbuxa: legacy protocols off. It takes listeners away and
|
||||
// puts them back, so it takes the listener's permissions
|
||||
GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet,
|
||||
@@ -200,6 +207,14 @@ impl JmapAuthorization for AccessToken {
|
||||
Permission::SysSpamLlmUpdate,
|
||||
Permission::SysSpamLlmUpdate,
|
||||
),
|
||||
// inbuxa: log file retention, with the tracers' permissions
|
||||
SetRequestMethod::LogSettings(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
Permission::SysTracerUpdate,
|
||||
Permission::SysTracerUpdate,
|
||||
Permission::SysTracerUpdate,
|
||||
),
|
||||
// inbuxa: the audit log (AU-7, AU-9, AU-11)
|
||||
SetRequestMethod::AuditSettings(s) => validate_set(
|
||||
s,
|
||||
@@ -232,6 +247,14 @@ impl JmapAuthorization for AccessToken {
|
||||
Permission::SysLegalHoldUpdate,
|
||||
Permission::SysLegalHoldUpdate,
|
||||
),
|
||||
// inbuxa: LH-12, exporting held data
|
||||
SetRequestMethod::HoldExport(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
Permission::SysLegalHoldExport,
|
||||
Permission::SysLegalHoldExport,
|
||||
Permission::SysLegalHoldExport,
|
||||
),
|
||||
SetRequestMethod::AuditVerification(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
@@ -373,6 +396,9 @@ impl JmapAuthorization for AccessToken {
|
||||
| MethodObject::MaskedEmail
|
||||
| MethodObject::DeletedAccount
|
||||
| MethodObject::AiLimits
|
||||
| MethodObject::LogSettings
|
||||
| MethodObject::DataInventory
|
||||
| MethodObject::InventorySnapshot
|
||||
| MethodObject::Explanation
|
||||
| MethodObject::AuditEvent
|
||||
| MethodObject::AuditSettings
|
||||
@@ -380,6 +406,7 @@ impl JmapAuthorization for AccessToken {
|
||||
| MethodObject::AuditVerification
|
||||
| MethodObject::AccountLock
|
||||
| MethodObject::LegalHold
|
||||
| MethodObject::HoldExport
|
||||
| MethodObject::ProtocolPolicy
|
||||
| MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges,
|
||||
// inbuxa: x:MaskedEmail/changes reads what /get reads
|
||||
|
||||
@@ -261,6 +261,9 @@ impl RequestHandler for Server {
|
||||
SetResponseMethod::AiLimits(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::LogSettings(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::AuditSettings(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
@@ -276,6 +279,9 @@ impl RequestHandler for Server {
|
||||
SetResponseMethod::LegalHold(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::HoldExport(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::Explanation(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
@@ -442,6 +448,27 @@ impl RequestHandler for Server {
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
// inbuxa: inbuxa:LogSettings/get
|
||||
GetRequestMethod::LogSettings(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::log_settings::get(self, access_token, *req)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
// inbuxa: inbuxa:DataInventory/get
|
||||
GetRequestMethod::DataInventory(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::data_inventory::inventory_get(self, access_token, *req)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
// inbuxa: inbuxa:InventorySnapshot/get
|
||||
GetRequestMethod::InventorySnapshot(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::data_inventory::snapshot_get(self, access_token, *req)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
// inbuxa: account lock with delegation (AL-1)
|
||||
GetRequestMethod::AccountLock(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
@@ -450,6 +477,11 @@ impl RequestHandler for Server {
|
||||
.into()
|
||||
}
|
||||
// inbuxa: legal hold (LH-1)
|
||||
// inbuxa: legal hold exports (LH-12)
|
||||
GetRequestMethod::HoldExport(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::hold_export_api::get(self, *req).await?.into()
|
||||
}
|
||||
GetRequestMethod::LegalHold(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::legal_hold::get(self, *req).await?.into()
|
||||
@@ -761,6 +793,23 @@ impl RequestHandler for Server {
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
// inbuxa: inbuxa:LogSettings/set
|
||||
SetRequestMethod::LogSettings(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::log_settings::set(self, access_token, req)),
|
||||
)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
// inbuxa: the audit log (AU-7, AU-11, AU-6)
|
||||
SetRequestMethod::AuditSettings(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
@@ -807,6 +856,34 @@ impl RequestHandler for Server {
|
||||
}
|
||||
// inbuxa: legal hold (LH-1), each change recorded with its
|
||||
// reason (AU-12)
|
||||
// inbuxa: legal hold exports, recorded with their reason
|
||||
// (AU-1.9, AU-12)
|
||||
SetRequestMethod::HoldExport(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
let reason = req.arguments.reason.clone().or_else(|| {
|
||||
req.create.as_ref().and_then(|create| {
|
||||
create.values().find_map(|value| {
|
||||
serde_json::to_value(value)
|
||||
.ok()?
|
||||
.get("reason")?
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
})
|
||||
})
|
||||
});
|
||||
crate::inbuxa::audit::recorded(
|
||||
self,
|
||||
access_token,
|
||||
session,
|
||||
&method_name.obj.to_string(),
|
||||
None,
|
||||
reason,
|
||||
*req,
|
||||
|req| Box::pin(crate::inbuxa::hold_export_api::set(self, access_token, req)),
|
||||
)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
SetRequestMethod::LegalHold(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
let reason = req.arguments.reason.clone().or_else(|| {
|
||||
|
||||
@@ -66,12 +66,16 @@ impl SessionHandler for Server {
|
||||
Capability::Inbuxa,
|
||||
Capabilities::Empty(EmptyCapabilities::default()),
|
||||
);
|
||||
// inbuxa: legacy-protocols, Interfaces: whichever switch is stricter
|
||||
let legacy_protocols = if self.legacy_protocols_off_for_account(access_token).await? {
|
||||
// inbuxa: legacy-protocols, Interfaces: whichever switch is stricter,
|
||||
// per protocol. `legacyProtocols` stays for older webmail builds:
|
||||
// `disabled` only when every protocol is off.
|
||||
let legacy_off = self.legacy_off_for_account(access_token).await?;
|
||||
let legacy_protocols = if legacy_off.all() {
|
||||
"disabled"
|
||||
} else {
|
||||
"enabled"
|
||||
};
|
||||
let legacy_allowed = legacy_off.allowed();
|
||||
// inbuxa: ai-explain, EX-1 to EX-4: whether Explain can be offered
|
||||
let ai_explain = access_token.has_permission(Permission::SysAiExplain)
|
||||
&& access_token.tenant_id().is_none()
|
||||
@@ -81,6 +85,7 @@ impl SessionHandler for Server {
|
||||
Capabilities::Inbuxa(InbuxaAccountCapabilities {
|
||||
logo,
|
||||
legacy_protocols,
|
||||
legacy_allowed,
|
||||
ai_explain,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -418,6 +418,9 @@ impl IntermediateChangesResponse {
|
||||
| MethodObject::MaskedEmail
|
||||
| MethodObject::DeletedAccount
|
||||
| MethodObject::AiLimits
|
||||
| MethodObject::LogSettings
|
||||
| MethodObject::DataInventory
|
||||
| MethodObject::InventorySnapshot
|
||||
| MethodObject::Explanation
|
||||
| MethodObject::AuditEvent
|
||||
| MethodObject::AuditSettings
|
||||
@@ -425,6 +428,7 @@ impl IntermediateChangesResponse {
|
||||
| MethodObject::AuditVerification
|
||||
| MethodObject::AccountLock
|
||||
| MethodObject::LegalHold
|
||||
| MethodObject::HoldExport
|
||||
| MethodObject::ProtocolPolicy
|
||||
| MethodObject::TenantProtocolPolicy
|
||||
| MethodObject::Registry(_) => unreachable!(),
|
||||
|
||||
@@ -18,7 +18,7 @@ use jmap_proto::{
|
||||
error::set::{SetError, SetErrorType},
|
||||
method::import::{ImportEmailRequest, ImportEmailResponse},
|
||||
object::email::EmailProperty,
|
||||
request::MaybeInvalid,
|
||||
request::reference::MaybeIdReference,
|
||||
types::state::State,
|
||||
};
|
||||
use mail_parser::{HeaderName, MessageParser};
|
||||
@@ -128,7 +128,7 @@ impl EmailImport for Server {
|
||||
}
|
||||
}
|
||||
|
||||
let MaybeInvalid::Value(blob_id) = email.blob_id else {
|
||||
let MaybeIdReference::Id(blob_id) = email.blob_id else {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
|
||||
@@ -854,8 +854,11 @@ impl EmailSet for Server {
|
||||
new_data.set_mailboxes(
|
||||
ids.into_expanded_boolean_set()
|
||||
.filter_map(|id| {
|
||||
UidMailbox::new_unassigned(
|
||||
id.try_into_property()?.try_into_id()?.document_id(),
|
||||
let mailbox_id =
|
||||
id.try_into_property()?.try_into_id()?.document_id();
|
||||
UidMailbox::new(
|
||||
mailbox_id,
|
||||
data.inner.message_uid(mailbox_id).unwrap_or(0),
|
||||
)
|
||||
.into()
|
||||
})
|
||||
|
||||
@@ -206,6 +206,8 @@ pub async fn set(
|
||||
Some(error) => response.not_updated.append(id, error),
|
||||
None => {
|
||||
limits::set(data, &limits).await?;
|
||||
// inbuxa: personal-data catalog: the inventory's history
|
||||
server.inventory_snapshot_after("inbuxa:AiLimits").await;
|
||||
response.updated.append(id, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,21 +376,36 @@ async fn full_name(server: &Server, object: &str, value: &Value, name: Option<St
|
||||
}
|
||||
|
||||
async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> Option<Value> {
|
||||
use inbuxa_features::{ai::limits, audit::log, security};
|
||||
use inbuxa_features::{
|
||||
ai::limits,
|
||||
audit::log,
|
||||
security::{self, protocol_policy::Switches},
|
||||
};
|
||||
let data = server.store();
|
||||
match object {
|
||||
"inbuxa:AuditSettings" => log::settings(data)
|
||||
.await
|
||||
.ok()
|
||||
.map(|settings| serde_json::json!({"keepForDays": settings.keep_for_secs / 86_400})),
|
||||
"inbuxa:LogSettings" => security::log_files::get(data)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|settings| serde_json::to_value(settings).ok()),
|
||||
"inbuxa:AiLimits" => limits::get(data)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|limits| serde_json::to_value(limits).ok()),
|
||||
"inbuxa:ProtocolPolicy" => security::protocol_policy::get(data)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|policy| serde_json::to_value(policy).ok()),
|
||||
// Normalized, so every switch reads before and after, even from a
|
||||
// policy stored before the per-protocol switches
|
||||
"inbuxa:ProtocolPolicy" => {
|
||||
security::protocol_policy::get(data)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|mut policy| {
|
||||
policy.normalize();
|
||||
serde_json::to_value(policy).ok()
|
||||
})
|
||||
}
|
||||
// LH-1: a hold as the API shows it, so a change reads before/after
|
||||
"inbuxa:LegalHold" => match id {
|
||||
MaybeInvalid::Value(id) => {
|
||||
@@ -424,7 +439,10 @@ async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> O
|
||||
security::tenant_protocol_policy::get(data, id.document_id())
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|policy| serde_json::to_value(policy).ok())
|
||||
.and_then(|mut policy| {
|
||||
policy.normalize();
|
||||
serde_json::to_value(policy).ok()
|
||||
})
|
||||
}
|
||||
MaybeInvalid::Invalid(_) => None,
|
||||
},
|
||||
|
||||
@@ -392,6 +392,8 @@ pub async fn settings_set(
|
||||
Some(error) => response.not_updated.append(id, error),
|
||||
None => {
|
||||
log::set_settings(server.store(), &settings).await?;
|
||||
// Audit retention is also the inventory's (personal-data catalog)
|
||||
server.inventory_snapshot_after("inbuxa:AuditSettings").await;
|
||||
response.updated.append(id, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:DataInventory/get` and `inbuxa:InventorySnapshot/get`: the
|
||||
//! personal-data catalog evaluated against this server, and its history
|
||||
//! (personal-data catalog spec, §6). Read-only, with `sysComplianceGet`.
|
||||
//!
|
||||
//! Inside a tenant both answer with the tenant's slice: tenant-scoped
|
||||
//! sources only, and none of the server's processors, which describe the
|
||||
//! whole server. The same holds for snapshots, which are taken of the whole
|
||||
//! server and cut to the slice when read.
|
||||
|
||||
use common::{Server, auth::AccessToken};
|
||||
use inbuxa_features::privacy::{Inventory, snapshot};
|
||||
use jmap_proto::{
|
||||
method::get::{GetRequest, GetResponse},
|
||||
object::{
|
||||
inbuxa_data_inventory::{DataInventory, DataInventoryProperty as P, DataInventoryValue},
|
||||
inbuxa_inventory_snapshot::{
|
||||
InventorySnapshot, InventorySnapshotProperty as S, InventorySnapshotValue,
|
||||
},
|
||||
},
|
||||
};
|
||||
use jmap_tools::{Element, Key, Map, Property, Value};
|
||||
use std::borrow::Cow;
|
||||
use types::{brand_version, id::Id};
|
||||
|
||||
fn json_to_value<Pr: Property, E: Element<Property = Pr>>(
|
||||
json: serde_json::Value,
|
||||
) -> Value<'static, Pr, E> {
|
||||
match json {
|
||||
serde_json::Value::Null => Value::Null,
|
||||
serde_json::Value::Bool(b) => Value::Bool(b),
|
||||
serde_json::Value::Number(n) => {
|
||||
if let Some(n) = n.as_u64() {
|
||||
Value::Number(n.into())
|
||||
} else if let Some(n) = n.as_i64() {
|
||||
Value::Number(n.into())
|
||||
} else {
|
||||
Value::Number(n.as_f64().unwrap_or_default().into())
|
||||
}
|
||||
}
|
||||
serde_json::Value::String(s) => Value::Str(Cow::Owned(s)),
|
||||
serde_json::Value::Array(items) => {
|
||||
Value::Array(items.into_iter().map(json_to_value).collect())
|
||||
}
|
||||
serde_json::Value::Object(map) => {
|
||||
let mut out = Map::with_capacity(map.len());
|
||||
for (key, value) in map {
|
||||
out.insert_unchecked(Key::Owned(key), json_to_value(value));
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn to_json<T: serde::Serialize>(value: &T) -> serde_json::Value {
|
||||
serde_json::to_value(value).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn utc(seconds: u64) -> String {
|
||||
jmap_proto::types::date::UTCDate::from_timestamp(seconds as i64).to_string()
|
||||
}
|
||||
|
||||
/// A snapshot's inventory, cut to a tenant's slice when asked from one.
|
||||
fn slice(mut inventory: Inventory, tenant_only: bool) -> Inventory {
|
||||
if tenant_only {
|
||||
inventory.items.retain(|item| item.scope == "tenant");
|
||||
inventory.processors.clear();
|
||||
}
|
||||
inventory
|
||||
}
|
||||
|
||||
/// `inbuxa:DataInventory/get`.
|
||||
pub async fn inventory_get(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
mut request: GetRequest<DataInventory>,
|
||||
) -> trc::Result<GetResponse<DataInventory>> {
|
||||
let properties = request.unwrap_properties(&[
|
||||
P::Id,
|
||||
P::EvaluatedAt,
|
||||
P::CatalogVersion,
|
||||
P::Summary,
|
||||
P::Items,
|
||||
P::Processors,
|
||||
]);
|
||||
let (ids, not_found) = request.unwrap_ids(1)?;
|
||||
let mut response = GetResponse {
|
||||
account_id: request.account_id.into(),
|
||||
state: None,
|
||||
list: Vec::new(),
|
||||
not_found,
|
||||
};
|
||||
let wanted = match ids {
|
||||
None => true,
|
||||
Some(ids) => {
|
||||
let mut wanted = false;
|
||||
for id in ids {
|
||||
if id.is_singleton() {
|
||||
wanted = true;
|
||||
} else {
|
||||
response.push_not_found(id);
|
||||
}
|
||||
}
|
||||
wanted
|
||||
}
|
||||
};
|
||||
if wanted {
|
||||
let inventory = server
|
||||
.data_inventory(access_token.tenant_id().is_some())
|
||||
.await?;
|
||||
let mut out = Map::with_capacity(properties.len());
|
||||
for property in &properties {
|
||||
let value = match property {
|
||||
P::Id => Value::Element(DataInventoryValue::Id(Id::singleton())),
|
||||
P::EvaluatedAt => Value::Str(utc(store::write::now()).into()),
|
||||
P::CatalogVersion => Value::Str(brand_version!().into()),
|
||||
P::Summary => json_to_value(to_json(&inventory.summary())),
|
||||
P::Items => json_to_value(to_json(&inventory.items)),
|
||||
P::Processors => json_to_value(to_json(&inventory.processors)),
|
||||
};
|
||||
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||
}
|
||||
response.list.push(Value::Object(out));
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// `inbuxa:InventorySnapshot/get`: by id (the time taken, as an id), or
|
||||
/// `ids: null` for every snapshot kept, newest first. `inventory` is the
|
||||
/// whole evaluated inventory; leave it out of `properties` for the list.
|
||||
pub async fn snapshot_get(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
mut request: GetRequest<InventorySnapshot>,
|
||||
) -> trc::Result<GetResponse<InventorySnapshot>> {
|
||||
let tenant_only = access_token.tenant_id().is_some();
|
||||
let data = &server.core.storage.data;
|
||||
let properties =
|
||||
request.unwrap_properties(&[S::Id, S::TakenAt, S::Trigger, S::Summary, S::Inventory]);
|
||||
let times: Vec<u64> = match request.ids.take() {
|
||||
None => snapshot::list(data, 0, u64::MAX).await?,
|
||||
Some(_) => {
|
||||
let (ids, _) = request.unwrap_ids(server.core.jmap.get_max_objects)?;
|
||||
ids.unwrap_or_default().into_iter().map(|id| id.id()).collect()
|
||||
}
|
||||
};
|
||||
let mut response = GetResponse {
|
||||
account_id: request.account_id.into(),
|
||||
state: None,
|
||||
list: Vec::new(),
|
||||
not_found: vec![],
|
||||
};
|
||||
for taken_at in times {
|
||||
let Some(found) = snapshot::get(data, taken_at).await? else {
|
||||
response.push_not_found(Id::from(taken_at));
|
||||
continue;
|
||||
};
|
||||
let inventory = slice(found.inventory, tenant_only);
|
||||
let summary = if tenant_only { inventory.summary() } else { found.summary };
|
||||
let mut out = Map::with_capacity(properties.len());
|
||||
for property in &properties {
|
||||
let value = match property {
|
||||
S::Id => Value::Element(InventorySnapshotValue::Id(Id::from(taken_at))),
|
||||
S::TakenAt => Value::Str(utc(found.taken_at).into()),
|
||||
S::Trigger => json_to_value(to_json(&found.trigger)),
|
||||
S::Summary => json_to_value(to_json(&summary)),
|
||||
S::Inventory => json_to_value(to_json(&inventory)),
|
||||
};
|
||||
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||
}
|
||||
response.list.push(Value::Object(out));
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! Collecting what a legal hold keeps, as a ZIP (audit-hold-lock spec,
|
||||
//! LH-12). For each account the hold covers (or those asked for): its mail,
|
||||
//! calendars, contacts and files, and the deleted items the hold keeps, each
|
||||
//! with its SHA-256 in `manifest.csv`, and the manifest's own hash beside
|
||||
//! it. The hold's date range applies as it does to what's kept (LH-3).
|
||||
//! Anything the hold covers that can't be read goes in `exceptions.csv`
|
||||
//! with the reason, never silently left out; the file is always there, so an
|
||||
//! empty one says nothing was missed.
|
||||
|
||||
use common::{Server, hold::kept_member};
|
||||
use email::{
|
||||
cache::MessageCacheFetch,
|
||||
message::metadata::{MESSAGE_RECEIVED_MASK, MessageMetadata},
|
||||
};
|
||||
use groupware::{cache::GroupwareCache, calendar::CalendarEvent, contact::ContactCard, file::FileNode};
|
||||
use inbuxa_features::{
|
||||
hold::{Hold, Keeping, is_held_until},
|
||||
undelete::records,
|
||||
};
|
||||
use registry::schema::{prelude::ObjectType, structs::ArchivedItem};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::{Cursor, Write};
|
||||
use store::{
|
||||
ValueKey,
|
||||
registry::RegistryQuery,
|
||||
write::{AlignedBytes, Archive},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{
|
||||
collection::{Collection, SyncCollection},
|
||||
field::EmailField,
|
||||
id::Id,
|
||||
};
|
||||
use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
|
||||
|
||||
/// The largest ZIP built in memory. A bigger collection is refused with a
|
||||
/// clear error rather than taking the node down; export fewer accounts.
|
||||
pub const MAX_EXPORT: u64 = 2 * 1024 * 1024 * 1024;
|
||||
|
||||
/// One line of `manifest.csv`.
|
||||
struct Entry {
|
||||
path: String,
|
||||
account: String,
|
||||
kind: &'static str,
|
||||
folder: String,
|
||||
date: Option<i64>,
|
||||
archived: bool,
|
||||
size: usize,
|
||||
sha256: String,
|
||||
}
|
||||
|
||||
/// One line of `exceptions.csv`: an item the hold covers that couldn't be
|
||||
/// read, where it would have gone and why.
|
||||
struct Missing {
|
||||
path: String,
|
||||
account: String,
|
||||
kind: &'static str,
|
||||
folder: String,
|
||||
date: Option<i64>,
|
||||
archived: bool,
|
||||
reason: &'static str,
|
||||
}
|
||||
|
||||
const NO_BLOB: &str = "content not found in the blob store";
|
||||
const NO_RECORD: &str = "stored record not found";
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
fn csv(field: &str) -> String {
|
||||
if field.contains([',', '"', '\n', '\r']) {
|
||||
format!("\"{}\"", field.replace('"', "\"\""))
|
||||
} else {
|
||||
field.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// A path segment that's safe in a ZIP: no separators, no leading dots.
|
||||
fn segment(name: &str) -> String {
|
||||
let cleaned: String = name
|
||||
.chars()
|
||||
.map(|c| if c == '/' || c == '\\' || c.is_control() { '_' } else { c })
|
||||
.collect();
|
||||
let trimmed = cleaned.trim_start_matches('.').trim();
|
||||
if trimmed.is_empty() { "_".into() } else { trimmed.chars().take(120).collect() }
|
||||
}
|
||||
|
||||
fn date_text(at: Option<i64>) -> String {
|
||||
at.map(|at| jmap_proto::types::date::UTCDate::from_timestamp(at).to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
struct Builder {
|
||||
zip: ZipWriter<Cursor<Vec<u8>>>,
|
||||
entries: Vec<Entry>,
|
||||
missing: Vec<Missing>,
|
||||
written: u64,
|
||||
}
|
||||
|
||||
impl Builder {
|
||||
fn new() -> Self {
|
||||
Builder {
|
||||
zip: ZipWriter::new(Cursor::new(Vec::new())),
|
||||
entries: Vec::new(),
|
||||
missing: Vec::new(),
|
||||
written: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn add(
|
||||
&mut self,
|
||||
path: String,
|
||||
bytes: &[u8],
|
||||
account: &str,
|
||||
kind: &'static str,
|
||||
folder: &str,
|
||||
date: Option<i64>,
|
||||
archived: bool,
|
||||
) -> trc::Result<()> {
|
||||
self.written += bytes.len() as u64;
|
||||
if self.written > MAX_EXPORT {
|
||||
return Err(trc::StoreEvent::UnexpectedError
|
||||
.into_err()
|
||||
.details("The collection is larger than one export can hold (2 GB). Export fewer accounts at a time."));
|
||||
}
|
||||
// Unique within the ZIP, however names collide
|
||||
let mut name = path.clone();
|
||||
let mut n = 1;
|
||||
while self.entries.iter().any(|e| e.path == name) {
|
||||
n += 1;
|
||||
name = match path.rsplit_once('.') {
|
||||
Some((stem, ext)) if !stem.ends_with('/') => format!("{stem} ({n}).{ext}"),
|
||||
_ => format!("{path} ({n})"),
|
||||
};
|
||||
}
|
||||
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
|
||||
self.zip
|
||||
.start_file(name.as_str(), options)
|
||||
.and_then(|_| self.zip.write_all(bytes).map_err(Into::into))
|
||||
.map_err(|err| {
|
||||
trc::StoreEvent::UnexpectedError
|
||||
.into_err()
|
||||
.details("Failed to write the export")
|
||||
.reason(err)
|
||||
})?;
|
||||
self.entries.push(Entry {
|
||||
path: name,
|
||||
account: account.to_string(),
|
||||
kind,
|
||||
folder: folder.to_string(),
|
||||
date,
|
||||
archived,
|
||||
size: bytes.len(),
|
||||
sha256: hex(&Sha256::digest(bytes)),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Records an item the hold covers that couldn't be read.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn missing(
|
||||
&mut self,
|
||||
path: String,
|
||||
account: &str,
|
||||
kind: &'static str,
|
||||
folder: &str,
|
||||
date: Option<i64>,
|
||||
archived: bool,
|
||||
reason: &'static str,
|
||||
) {
|
||||
self.missing.push(Missing {
|
||||
path,
|
||||
account: account.to_string(),
|
||||
kind,
|
||||
folder: folder.to_string(),
|
||||
date,
|
||||
archived,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
/// Closes the ZIP with its manifest, the exceptions and both hashes.
|
||||
/// Returns the bytes and how many items went in.
|
||||
fn finish(mut self) -> trc::Result<(Vec<u8>, usize)> {
|
||||
let mut manifest = String::from("path,account,kind,folder,date,archived,size,sha256\n");
|
||||
for e in &self.entries {
|
||||
manifest.push_str(&format!(
|
||||
"{},{},{},{},{},{},{},{}\n",
|
||||
csv(&e.path),
|
||||
csv(&e.account),
|
||||
e.kind,
|
||||
csv(&e.folder),
|
||||
date_text(e.date),
|
||||
e.archived,
|
||||
e.size,
|
||||
e.sha256
|
||||
));
|
||||
}
|
||||
let mut exceptions = String::from("path,account,kind,folder,date,archived,reason\n");
|
||||
for m in &self.missing {
|
||||
exceptions.push_str(&format!(
|
||||
"{},{},{},{},{},{},{}\n",
|
||||
csv(&m.path),
|
||||
csv(&m.account),
|
||||
m.kind,
|
||||
csv(&m.folder),
|
||||
date_text(m.date),
|
||||
m.archived,
|
||||
csv(m.reason)
|
||||
));
|
||||
}
|
||||
let manifest_hash = hex(&Sha256::digest(manifest.as_bytes()));
|
||||
let exceptions_hash = hex(&Sha256::digest(exceptions.as_bytes()));
|
||||
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
|
||||
let fail = |err: zip::result::ZipError| {
|
||||
trc::StoreEvent::UnexpectedError
|
||||
.into_err()
|
||||
.details("Failed to write the export")
|
||||
.reason(err)
|
||||
};
|
||||
self.zip.start_file("manifest.csv", options).map_err(fail)?;
|
||||
self.zip.write_all(manifest.as_bytes()).map_err(|e| fail(e.into()))?;
|
||||
self.zip.start_file("exceptions.csv", options).map_err(fail)?;
|
||||
self.zip.write_all(exceptions.as_bytes()).map_err(|e| fail(e.into()))?;
|
||||
self.zip.start_file("manifest.sha256", options).map_err(fail)?;
|
||||
self.zip
|
||||
.write_all(format!("{manifest_hash} manifest.csv\n{exceptions_hash} exceptions.csv\n").as_bytes())
|
||||
.map_err(|e| fail(e.into()))?;
|
||||
let items = self.entries.len();
|
||||
let bytes = self.zip.finish().map_err(fail)?.into_inner();
|
||||
Ok((bytes, items))
|
||||
}
|
||||
}
|
||||
|
||||
/// The accounts to collect: those asked for that the hold covers, or every
|
||||
/// account it covers, deleted ones it keeps included.
|
||||
async fn accounts(server: &Server, hold: &Hold, asked: &[u32]) -> trc::Result<Vec<u32>> {
|
||||
let mut covered = Vec::new();
|
||||
for id in server
|
||||
.registry()
|
||||
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Account))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
{
|
||||
let account_id = id.document_id();
|
||||
if let Some(member) = server.member_of(account_id).await
|
||||
&& hold.scope.covers(&member)
|
||||
{
|
||||
covered.push(account_id);
|
||||
}
|
||||
}
|
||||
for (account_id, kept) in inbuxa_features::undelete::data::kept_accounts(server.store()).await? {
|
||||
if hold.scope.covers(&kept_member(account_id, &kept)) {
|
||||
covered.push(account_id);
|
||||
}
|
||||
}
|
||||
covered.sort_unstable();
|
||||
covered.dedup();
|
||||
if !asked.is_empty() {
|
||||
covered.retain(|id| asked.contains(id));
|
||||
}
|
||||
Ok(covered)
|
||||
}
|
||||
|
||||
async fn blob(server: &Server, hash: &[u8]) -> trc::Result<Option<Vec<u8>>> {
|
||||
server.blob_store().get_blob(hash, 0..usize::MAX).await
|
||||
}
|
||||
|
||||
/// Collects `accounts` under `hold` into a ZIP. Returns its bytes and item
|
||||
/// count.
|
||||
pub async fn build(server: &Server, hold: &Hold, asked: &[u32]) -> trc::Result<(Vec<u8>, usize)> {
|
||||
let keeping = Keeping::new(None, std::slice::from_ref(hold));
|
||||
let data = server.store();
|
||||
let mut out = Builder::new();
|
||||
|
||||
for account_id in accounts(server, hold, asked).await? {
|
||||
let address = server.audit_account_name(account_id).await;
|
||||
let base = format!("{}/", segment(&address));
|
||||
let live = server.account(account_id).await.is_ok();
|
||||
|
||||
if live {
|
||||
// Mail, by the folder it's in
|
||||
let cache = server
|
||||
.get_cached_messages(account_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
for message in cache.emails.items.iter() {
|
||||
let mail_path = |folder: &str| {
|
||||
format!(
|
||||
"{base}mail/{}/{}.eml",
|
||||
folder.split('/').map(segment).collect::<Vec<_>>().join("/"),
|
||||
Id::from(message.document_id)
|
||||
)
|
||||
};
|
||||
let folder = message
|
||||
.mailboxes
|
||||
.first()
|
||||
.and_then(|m| cache.mailboxes.items.iter().find(|b| b.document_id == m.mailbox_id))
|
||||
.map(|b| b.path.clone())
|
||||
.unwrap_or_default();
|
||||
let Some(metadata_) = data
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::property(
|
||||
account_id,
|
||||
Collection::Email,
|
||||
message.document_id,
|
||||
EmailField::Metadata,
|
||||
))
|
||||
.await?
|
||||
else {
|
||||
// No date to check against the hold's range, so it's listed
|
||||
out.missing(mail_path(&folder), &address, "email", &folder, None, false, NO_RECORD);
|
||||
continue;
|
||||
};
|
||||
let metadata = metadata_
|
||||
.unarchive::<MessageMetadata>()
|
||||
.caused_by(trc::location!())?;
|
||||
let received = metadata.rcvd_attach.to_native() & MESSAGE_RECEIVED_MASK;
|
||||
if !keeping.covers(Some(received)) {
|
||||
continue;
|
||||
}
|
||||
let hash = types::blob_hash::BlobHash::from(&metadata.blob_hash);
|
||||
match blob(server, hash.as_slice()).await? {
|
||||
Some(bytes) => {
|
||||
out.add(mail_path(&folder), &bytes, &address, "email", &folder, Some(received as i64), false)?
|
||||
}
|
||||
None => out.missing(
|
||||
mail_path(&folder),
|
||||
&address,
|
||||
"email",
|
||||
&folder,
|
||||
Some(received as i64),
|
||||
false,
|
||||
NO_BLOB,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// Calendars, contacts and files, by their DAV paths
|
||||
for (sync, kind) in [
|
||||
(SyncCollection::Calendar, "event"),
|
||||
(SyncCollection::AddressBook, "contact"),
|
||||
(SyncCollection::FileNode, "file"),
|
||||
] {
|
||||
let resources = server
|
||||
.fetch_dav_resources(account_id, account_id, sync)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
for path in resources.paths.iter() {
|
||||
let Some(resource) = resources.resources.get(path.resource_idx) else {
|
||||
continue;
|
||||
};
|
||||
let folder = path.path.rsplit_once('/').map(|(f, _)| f).unwrap_or_default();
|
||||
let zip_path = |ext: Option<&str>| {
|
||||
let mut p = format!(
|
||||
"{base}{}/{}",
|
||||
match kind {
|
||||
"event" => "calendar",
|
||||
"contact" => "contacts",
|
||||
_ => "files",
|
||||
},
|
||||
path.path.split('/').map(segment).collect::<Vec<_>>().join("/")
|
||||
);
|
||||
if let Some(ext) = ext
|
||||
&& !p.ends_with(ext)
|
||||
{
|
||||
p.push_str(ext);
|
||||
}
|
||||
p
|
||||
};
|
||||
use common::DavResourceMetadata as M;
|
||||
match &resource.data {
|
||||
M::CalendarEvent { start, .. } => {
|
||||
if !keeping.covers_event(Some((*start).max(0) as u64)) {
|
||||
continue;
|
||||
}
|
||||
let Some(event_) = data
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||
account_id,
|
||||
Collection::CalendarEvent,
|
||||
resource.document_id,
|
||||
))
|
||||
.await?
|
||||
else {
|
||||
out.missing(zip_path(Some(".ics")), &address, kind, folder, Some(*start), false, NO_RECORD);
|
||||
continue;
|
||||
};
|
||||
let event = event_.unarchive::<CalendarEvent>().caused_by(trc::location!())?;
|
||||
let text = event.data.event.to_string();
|
||||
out.add(zip_path(Some(".ics")), text.as_bytes(), &address, kind, folder, Some(*start), false)?;
|
||||
}
|
||||
M::ContactCard { .. } => {
|
||||
let Some(card_) = data
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||
account_id,
|
||||
Collection::ContactCard,
|
||||
resource.document_id,
|
||||
))
|
||||
.await?
|
||||
else {
|
||||
out.missing(zip_path(Some(".vcf")), &address, kind, folder, None, false, NO_RECORD);
|
||||
continue;
|
||||
};
|
||||
let card = card_.unarchive::<ContactCard>().caused_by(trc::location!())?;
|
||||
let mut text = String::with_capacity(256);
|
||||
let _ = card.card.write_to(&mut text, server.core.groupware.vcard_version);
|
||||
out.add(zip_path(Some(".vcf")), text.as_bytes(), &address, kind, folder, None, false)?;
|
||||
}
|
||||
M::File { size: Some(_), .. } => {
|
||||
let Some(file_) = data
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||
account_id,
|
||||
Collection::FileNode,
|
||||
resource.document_id,
|
||||
))
|
||||
.await?
|
||||
else {
|
||||
out.missing(zip_path(None), &address, kind, folder, None, false, NO_RECORD);
|
||||
continue;
|
||||
};
|
||||
let file = file_.unarchive::<FileNode>().caused_by(trc::location!())?;
|
||||
let Some(props) = file.file.as_ref() else {
|
||||
out.missing(zip_path(None), &address, kind, folder, None, false, NO_RECORD);
|
||||
continue;
|
||||
};
|
||||
let hash = types::blob_hash::BlobHash::from(&props.blob_hash);
|
||||
match blob(server, hash.as_slice()).await? {
|
||||
Some(bytes) => out.add(zip_path(None), &bytes, &address, kind, folder, None, false)?,
|
||||
None => out.missing(zip_path(None), &address, kind, folder, None, false, NO_BLOB),
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What the hold keeps of what was deleted
|
||||
for (id, item) in records::of_account(data, server.registry(), account_id).await? {
|
||||
if !is_held_until(item.archived_until().timestamp().max(0) as u64) {
|
||||
continue;
|
||||
}
|
||||
let (kind, ext, date) = match &item {
|
||||
ArchivedItem::Email(e) => ("email", ".eml", Some(e.received_at.timestamp())),
|
||||
ArchivedItem::CalendarEvent(e) => ("event", ".ics", e.start_time.map(|t| t.timestamp())),
|
||||
ArchivedItem::ContactCard(_) => ("contact", ".vcf", None),
|
||||
ArchivedItem::FileNode(_) => ("file", "", None),
|
||||
ArchivedItem::SieveScript(_) => ("sieve", ".sieve", None),
|
||||
};
|
||||
// Kept by this hold, not only by another one over the same account
|
||||
let in_range = match kind {
|
||||
"event" => keeping.covers_event(date.map(|d| d.max(0) as u64)),
|
||||
_ => keeping.covers(date.map(|d| d.max(0) as u64)),
|
||||
};
|
||||
if !in_range {
|
||||
continue;
|
||||
}
|
||||
let name = match &item {
|
||||
ArchivedItem::FileNode(f) => segment(&f.name),
|
||||
ArchivedItem::SieveScript(s) => format!("{}{ext}", segment(&s.name)),
|
||||
_ => format!("{id}{ext}"),
|
||||
};
|
||||
let path = format!("{base}archived/{kind}/{name}");
|
||||
match blob(server, item.blob_id().hash.as_slice()).await? {
|
||||
Some(bytes) => out.add(path, &bytes, &address, kind, "", date, true)?,
|
||||
None => out.missing(path, &address, kind, "", date, true, NO_BLOB),
|
||||
}
|
||||
}
|
||||
}
|
||||
out.finish()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn names_are_safe_in_a_zip() {
|
||||
assert_eq!(segment("../etc/passwd"), "_etc_passwd");
|
||||
assert_eq!(segment(" "), "_");
|
||||
assert_eq!(segment("Q3 report.pdf"), "Q3 report.pdf");
|
||||
assert_eq!(csv("a,b"), "\"a,b\"");
|
||||
assert_eq!(csv("say \"hi\""), "\"say \"\"hi\"\"\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zip_carries_its_manifest_and_its_hash() {
|
||||
let mut b = Builder::new();
|
||||
b.add("[email protected]/mail/INBOX/b.eml".into(), b"Subject: x\r\n\r\ny", "[email protected]", "email", "INBOX", Some(0), false)
|
||||
.unwrap();
|
||||
b.add("[email protected]/mail/INBOX/b.eml".into(), b"other", "[email protected]", "email", "INBOX", None, true)
|
||||
.unwrap();
|
||||
let (bytes, items) = b.finish().unwrap();
|
||||
assert_eq!(items, 2);
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
let mut manifest = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("manifest.csv").unwrap(), &mut manifest).unwrap();
|
||||
assert!(manifest.contains("[email protected]/mail/INBOX/b (2).eml"), "{manifest}");
|
||||
let mut hash = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("manifest.sha256").unwrap(), &mut hash).unwrap();
|
||||
assert!(hash.starts_with(&hex(&Sha256::digest(manifest.as_bytes()))));
|
||||
// Nothing missed, and the file says so
|
||||
let mut exceptions = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("exceptions.csv").unwrap(), &mut exceptions).unwrap();
|
||||
assert_eq!(exceptions, "path,account,kind,folder,date,archived,reason\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_cant_be_read_is_listed_not_dropped() {
|
||||
let mut b = Builder::new();
|
||||
b.add("[email protected]/mail/INBOX/1.eml".into(), b"Subject: x\r\n\r\ny", "[email protected]", "email", "INBOX", Some(0), false)
|
||||
.unwrap();
|
||||
b.missing("[email protected]/mail/INBOX/2.eml".into(), "[email protected]", "email", "INBOX", Some(0), false, NO_BLOB);
|
||||
let (bytes, items) = b.finish().unwrap();
|
||||
assert_eq!(items, 1, "a missing item isn't counted as collected");
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
assert!(zip.by_name("[email protected]/mail/INBOX/2.eml").is_err());
|
||||
let mut exceptions = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("exceptions.csv").unwrap(), &mut exceptions).unwrap();
|
||||
assert!(
|
||||
exceptions.contains("[email protected]/mail/INBOX/2.eml,[email protected],email,INBOX,") && exceptions.contains(NO_BLOB),
|
||||
"{exceptions}"
|
||||
);
|
||||
let mut hash = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("manifest.sha256").unwrap(), &mut hash).unwrap();
|
||||
assert!(hash.contains(&format!("{} exceptions.csv", hex(&Sha256::digest(exceptions.as_bytes())))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:HoldExport` (audit-hold-lock spec, LH-12): starting a collection
|
||||
//! of what a hold keeps, and seeing how it went. The ZIP is the creator's
|
||||
//! blob, to download once it's ready. Creating one is audited, with its
|
||||
//! reason (AU-1.9, AU-12).
|
||||
|
||||
use common::{Server, auth::AccessToken};
|
||||
use inbuxa_features::hold::{self, Export, ExportStatus};
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
method::{
|
||||
get::{GetRequest, GetResponse},
|
||||
set::{SetRequest, SetResponse},
|
||||
},
|
||||
object::inbuxa_hold_export::{
|
||||
HoldExport, HoldExportProperty as P, HoldExportSetArguments, HoldExportValue,
|
||||
},
|
||||
types::date::UTCDate,
|
||||
};
|
||||
use jmap_tools::{Key, Map, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::str::FromStr;
|
||||
use store::write::now;
|
||||
use types::id::Id;
|
||||
|
||||
type LValue = Value<'static, P, HoldExportValue>;
|
||||
|
||||
const ALL: &[P] = &[
|
||||
P::Id,
|
||||
P::HoldId,
|
||||
P::AccountIds,
|
||||
P::Reason,
|
||||
P::Status,
|
||||
P::CreatedAt,
|
||||
P::CreatedBy,
|
||||
P::FinishedAt,
|
||||
P::BlobId,
|
||||
P::Size,
|
||||
P::Items,
|
||||
P::Sha256,
|
||||
P::Error,
|
||||
];
|
||||
|
||||
fn date(seconds: u64) -> LValue {
|
||||
Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into())
|
||||
}
|
||||
|
||||
fn opt_text(value: &Option<String>) -> LValue {
|
||||
value.as_ref().map_or(Value::Null, |v| Value::Str(v.clone().into()))
|
||||
}
|
||||
|
||||
fn to_value(export: &Export, properties: &[P]) -> LValue {
|
||||
let mut out = Map::with_capacity(properties.len());
|
||||
for property in properties {
|
||||
let value = match property {
|
||||
P::Id => Value::Element(HoldExportValue::Id(Id::from(export.id))),
|
||||
P::HoldId => Value::Str(Id::from(export.hold_id).to_string().into()),
|
||||
P::AccountIds => Value::Array(
|
||||
export
|
||||
.accounts
|
||||
.iter()
|
||||
.map(|id| Value::Str(Id::from(*id).to_string().into()))
|
||||
.collect(),
|
||||
),
|
||||
P::Reason => Value::Str(export.reason.clone().into()),
|
||||
P::Status => Value::Str(
|
||||
match export.status {
|
||||
ExportStatus::Running => "running",
|
||||
ExportStatus::Ready => "ready",
|
||||
ExportStatus::Failed => "failed",
|
||||
}
|
||||
.into(),
|
||||
),
|
||||
P::CreatedAt => date(export.created_at),
|
||||
P::CreatedBy => Value::Str(export.created_by.clone().into()),
|
||||
P::FinishedAt => export.finished_at.map_or(Value::Null, date),
|
||||
P::BlobId => opt_text(&export.blob_id),
|
||||
P::Size => Value::Number(export.size.into()),
|
||||
P::Items => Value::Number(export.items.into()),
|
||||
P::Sha256 => opt_text(&export.sha256),
|
||||
P::Error => opt_text(&export.error),
|
||||
};
|
||||
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
/// `inbuxa:HoldExport/get`: every export, newest first.
|
||||
pub async fn get(
|
||||
server: &Server,
|
||||
mut request: GetRequest<HoldExport>,
|
||||
) -> trc::Result<GetResponse<HoldExport>> {
|
||||
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 mut exports = hold::exports(server.store()).await?;
|
||||
exports.reverse();
|
||||
match ids {
|
||||
None => response
|
||||
.list
|
||||
.extend(exports.iter().map(|e| to_value(e, &properties))),
|
||||
Some(ids) => {
|
||||
for id in ids {
|
||||
match exports.iter().find(|e| u64::from(e.id) == id.id()) {
|
||||
Some(export) => response.list.push(to_value(export, &properties)),
|
||||
None => response.push_not_found(id),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn invalid(property: P, why: &str) -> SetError<P> {
|
||||
SetError::invalid_properties()
|
||||
.with_property(property)
|
||||
.with_description(why.to_string())
|
||||
}
|
||||
|
||||
/// `inbuxa:HoldExport/set`: create starts an export; nothing else is
|
||||
/// allowed. The request layer records it with its reason.
|
||||
pub async fn set(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
mut request: SetRequest<'_, HoldExport>,
|
||||
) -> trc::Result<SetResponse<HoldExport>> {
|
||||
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
|
||||
let arguments: HoldExportSetArguments = std::mem::take(&mut request.arguments);
|
||||
let data = server.store();
|
||||
let actor = server.audit_actor(access_token).await;
|
||||
|
||||
'create: for (client_id, value) in request.unwrap_create() {
|
||||
let mut hold_id = None;
|
||||
let mut accounts = Vec::new();
|
||||
let mut reason = arguments.reason.clone();
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
match (&key, &value) {
|
||||
(Key::Property(P::HoldId), Value::Str(id)) => {
|
||||
hold_id = Id::from_str(id).ok().and_then(|id| u32::try_from(id.id()).ok())
|
||||
}
|
||||
(Key::Property(P::AccountIds), Value::Array(items)) => {
|
||||
for item in items {
|
||||
match item {
|
||||
Value::Str(id) => match Id::from_str(id) {
|
||||
Ok(id) => accounts.push(id.document_id()),
|
||||
Err(_) => {
|
||||
response.not_created.append(
|
||||
client_id,
|
||||
invalid(P::AccountIds, "accountIds must be account ids."),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
response.not_created.append(
|
||||
client_id,
|
||||
invalid(P::AccountIds, "accountIds must be account ids."),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(Key::Property(P::Reason), Value::Str(r)) => reason = Some(r.to_string()),
|
||||
_ => {
|
||||
response.not_created.append(
|
||||
client_id,
|
||||
SetError::invalid_properties().with_property(key.clone().into_owned()),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(reason) = reason
|
||||
.map(|r| r.trim().chars().take(500).collect::<String>())
|
||||
.filter(|r| !r.is_empty())
|
||||
else {
|
||||
response.not_created.append(
|
||||
client_id,
|
||||
invalid(P::Reason, "Say why: a reason is required and is kept in the audit log."),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let hold = match hold_id {
|
||||
Some(id) => hold::get(data, id).await?,
|
||||
None => None,
|
||||
};
|
||||
let Some(hold) = hold else {
|
||||
response
|
||||
.not_created
|
||||
.append(client_id, invalid(P::HoldId, "No such legal hold."));
|
||||
continue;
|
||||
};
|
||||
if !hold.is_active() {
|
||||
response.not_created.append(
|
||||
client_id,
|
||||
invalid(P::HoldId, "That hold was released; export while a hold is in place."),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let Some(created_by_id) = actor.account_id else {
|
||||
response
|
||||
.not_created
|
||||
.append(client_id, SetError::forbidden().with_description("Sign in as a person to export."));
|
||||
continue;
|
||||
};
|
||||
accounts.sort_unstable();
|
||||
accounts.dedup();
|
||||
let export = Export {
|
||||
id: 0,
|
||||
hold_id: hold.id,
|
||||
accounts,
|
||||
reason,
|
||||
created_at: now(),
|
||||
created_by: actor.name.clone(),
|
||||
created_by_id,
|
||||
status: ExportStatus::Running,
|
||||
finished_at: None,
|
||||
blob_id: None,
|
||||
size: 0,
|
||||
items: 0,
|
||||
sha256: None,
|
||||
error: None,
|
||||
};
|
||||
let id = hold::create_export(data, &export).await?;
|
||||
let export = Export { id, ..export };
|
||||
|
||||
// The collection runs on its own; get says when it's ready
|
||||
let server = server.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut done = export.clone();
|
||||
match crate::inbuxa::hold_export::build(&server, &hold, &export.accounts).await {
|
||||
Ok((bytes, items)) => match server.put_jmap_blob(export.created_by_id, &bytes).await {
|
||||
Ok(blob) => {
|
||||
done.status = ExportStatus::Ready;
|
||||
done.blob_id = Some(blob.to_string());
|
||||
done.size = bytes.len() as u64;
|
||||
done.items = items as u64;
|
||||
done.sha256 = Some(
|
||||
Sha256::digest(&bytes).iter().map(|b| format!("{b:02x}")).collect(),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
done.status = ExportStatus::Failed;
|
||||
done.error = Some(err.to_string());
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
done.status = ExportStatus::Failed;
|
||||
done.error = Some(
|
||||
err.value_as_str(trc::Key::Details)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| err.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
done.finished_at = Some(now());
|
||||
if let Err(err) = hold::update_export(server.store(), &done).await {
|
||||
trc::error!(err.details("Failed to save a legal hold export's result"));
|
||||
}
|
||||
});
|
||||
|
||||
let mut out = Map::with_capacity(1);
|
||||
out.insert_unchecked(
|
||||
Key::Property(P::Id),
|
||||
Value::Element(HoldExportValue::Id(Id::from(id))),
|
||||
);
|
||||
response.created.insert(client_id, Value::Object(out));
|
||||
}
|
||||
|
||||
for (id, _) in request.unwrap_update() {
|
||||
response.not_updated.append(
|
||||
id,
|
||||
SetError::forbidden().with_description("An export can't be changed; start a new one."),
|
||||
);
|
||||
}
|
||||
for id in request.unwrap_destroy() {
|
||||
response.not_destroyed.append(
|
||||
id,
|
||||
SetError::forbidden().with_description("Exports stay listed; the file expires on its own."),
|
||||
);
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:LogSettings/get` and `/set`: how long rotated log files are kept
|
||||
//! (personal-data catalog spec, D1). Server-level: log files belong to the
|
||||
//! server, not to a tenant. `null` restores the default, which keeps every
|
||||
//! file.
|
||||
|
||||
use common::{Server, auth::AccessToken};
|
||||
use inbuxa_features::security::log_files::{self, LogSettings as Settings};
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
method::{
|
||||
get::{GetRequest, GetResponse},
|
||||
set::{SetRequest, SetResponse},
|
||||
},
|
||||
object::inbuxa_log_settings::{LogSettings, LogSettingsProperty as P, LogSettingsValue},
|
||||
request::IntoValid,
|
||||
};
|
||||
use jmap_tools::{Key, Map, Value};
|
||||
use types::id::Id;
|
||||
|
||||
type LValue = Value<'static, P, LogSettingsValue>;
|
||||
|
||||
const ALL: &[P] = &[P::Id, P::KeepForDays];
|
||||
|
||||
fn assert_server_level(access_token: &AccessToken) -> trc::Result<()> {
|
||||
if access_token.tenant_id().is_some() {
|
||||
Err(trc::JmapEvent::Forbidden
|
||||
.into_err()
|
||||
.details("Log file settings are server-level."))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn to_value(settings: &Settings, properties: &[P]) -> LValue {
|
||||
let mut out = Map::with_capacity(properties.len());
|
||||
for property in properties {
|
||||
let value = match property {
|
||||
P::Id => Value::Element(LogSettingsValue::Id(Id::singleton())),
|
||||
P::KeepForDays => settings
|
||||
.keep_for_days
|
||||
.map_or(Value::Null, |days| Value::Number(days.into())),
|
||||
};
|
||||
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
/// `inbuxa:LogSettings/get`.
|
||||
pub async fn get(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
mut request: GetRequest<LogSettings>,
|
||||
) -> trc::Result<GetResponse<LogSettings>> {
|
||||
assert_server_level(access_token)?;
|
||||
let properties = request.unwrap_properties(ALL);
|
||||
let (ids, not_found) = request.unwrap_ids(1)?;
|
||||
let mut response = GetResponse {
|
||||
account_id: request.account_id.into(),
|
||||
state: None,
|
||||
list: Vec::new(),
|
||||
not_found,
|
||||
};
|
||||
let settings = log_files::get(&server.core.storage.data).await?;
|
||||
match ids {
|
||||
None => response.list.push(to_value(&settings, &properties)),
|
||||
Some(ids) => {
|
||||
for id in ids {
|
||||
if id.is_singleton() {
|
||||
response.list.push(to_value(&settings, &properties));
|
||||
} else {
|
||||
response.push_not_found(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn apply(
|
||||
settings: &mut Settings,
|
||||
property: &P,
|
||||
value: &Value<'_, P, LogSettingsValue>,
|
||||
) -> Result<(), String> {
|
||||
match property {
|
||||
P::KeepForDays => match value {
|
||||
Value::Null => settings.keep_for_days = None,
|
||||
value => {
|
||||
settings.keep_for_days = Some(
|
||||
value
|
||||
.as_u64()
|
||||
.ok_or_else(|| "must be a whole number of days, or null".to_string())?,
|
||||
)
|
||||
}
|
||||
},
|
||||
P::Id => return Err("is immutable".to_string()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `inbuxa:LogSettings/set`: updates the singleton.
|
||||
pub async fn set(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
mut request: SetRequest<'_, LogSettings>,
|
||||
) -> trc::Result<SetResponse<LogSettings>> {
|
||||
assert_server_level(access_token)?;
|
||||
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::singleton());
|
||||
}
|
||||
for id in request.unwrap_destroy().into_valid() {
|
||||
response.not_destroyed.append(id, SetError::singleton());
|
||||
}
|
||||
let data = &server.core.storage.data;
|
||||
for (id, value) in request.unwrap_update().into_valid() {
|
||||
if !id.is_singleton() {
|
||||
response.not_updated.append(id, SetError::not_found());
|
||||
continue;
|
||||
}
|
||||
let mut settings = log_files::get(data).await?;
|
||||
let mut error = None;
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
let Key::Property(property) = &key else {
|
||||
error = Some(SetError::invalid_properties().with_property(key.into_owned()));
|
||||
break;
|
||||
};
|
||||
if let Err(why) = apply(&mut settings, property, &value) {
|
||||
error = Some(
|
||||
SetError::invalid_properties()
|
||||
.with_property(property.clone())
|
||||
.with_description(why),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if error.is_none()
|
||||
&& let Err((property, why)) = settings.check()
|
||||
{
|
||||
error = Some(
|
||||
SetError::invalid_properties()
|
||||
.with_property(property.parse::<P>().unwrap_or(P::Id))
|
||||
.with_description(format!("{property} {why}.")),
|
||||
);
|
||||
}
|
||||
match error {
|
||||
Some(error) => response.not_updated.append(id, error),
|
||||
None => {
|
||||
log_files::set(data, &settings).await?;
|
||||
// This node purges now; the others within the hour
|
||||
log_files::CHANGED.notify_one();
|
||||
server.inventory_snapshot_after("inbuxa:LogSettings").await;
|
||||
response.updated.append(id, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
@@ -10,9 +10,13 @@
|
||||
pub mod access;
|
||||
pub mod account_lock;
|
||||
pub mod legal_hold;
|
||||
pub mod hold_export;
|
||||
pub mod hold_export_api;
|
||||
pub mod audit;
|
||||
pub mod audit_log;
|
||||
pub mod ai_limits;
|
||||
pub mod log_settings;
|
||||
pub mod data_inventory;
|
||||
pub mod explanation;
|
||||
pub mod protocol_policy;
|
||||
pub mod tenant_protocol_policy;
|
||||
|
||||
@@ -26,7 +26,9 @@ use common::{
|
||||
};
|
||||
use inbuxa_features::security::{
|
||||
listeners,
|
||||
protocol_policy::{LOCKED_PROTOCOLS, LegacyProtocols, ProtocolPolicy as Policy, SavedListener},
|
||||
protocol_policy::{
|
||||
LOCKED_PROTOCOLS, LegacyProtocols, ProtocolPolicy as Policy, SavedListener, Switches,
|
||||
},
|
||||
};
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
@@ -48,6 +50,9 @@ type PValue = Value<'static, P, ProtocolPolicyValue>;
|
||||
const ALL: &[P] = &[
|
||||
P::Id,
|
||||
P::LegacyProtocols,
|
||||
P::Imap,
|
||||
P::Pop3,
|
||||
P::ManageSieve,
|
||||
P::CloseSubmission,
|
||||
P::SavedListeners,
|
||||
P::ChangedAt,
|
||||
@@ -91,22 +96,49 @@ fn listener_value(listener: &SavedListener) -> PValue {
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
/// A switch as JMAP spells it.
|
||||
pub(crate) fn switch_str(value: LegacyProtocols) -> &'static str {
|
||||
match value {
|
||||
LegacyProtocols::Enabled => "enabled",
|
||||
LegacyProtocols::Disabled => "disabled",
|
||||
}
|
||||
}
|
||||
|
||||
/// A switch from JMAP.
|
||||
pub(crate) fn parse_switch(value: Option<&str>) -> Result<LegacyProtocols, String> {
|
||||
match value {
|
||||
Some("enabled") => Ok(LegacyProtocols::Enabled),
|
||||
Some("disabled") => Ok(LegacyProtocols::Disabled),
|
||||
_ => Err(r#"must be "enabled" or "disabled""#.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The JMAP name of a per-protocol switch property.
|
||||
pub(crate) fn switch_name(property: &P) -> Option<&'static str> {
|
||||
match property {
|
||||
P::Imap => Some("imap"),
|
||||
P::Pop3 => Some("pop3"),
|
||||
P::ManageSieve => Some("manageSieve"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_value(
|
||||
policy: &Policy,
|
||||
would_close: &[SavedListener],
|
||||
recent: &[RecentUse],
|
||||
properties: &[P],
|
||||
) -> PValue {
|
||||
let mut policy = policy.clone();
|
||||
policy.normalize();
|
||||
let policy = &policy;
|
||||
let mut out = Map::with_capacity(properties.len());
|
||||
for property in properties {
|
||||
let value = match property {
|
||||
P::Id => Value::Element(ProtocolPolicyValue::Id(Id::singleton())),
|
||||
P::LegacyProtocols => Value::Str(
|
||||
match policy.legacy_protocols {
|
||||
LegacyProtocols::Enabled => "enabled",
|
||||
LegacyProtocols::Disabled => "disabled",
|
||||
}
|
||||
.into(),
|
||||
P::LegacyProtocols => Value::Str(switch_str(policy.legacy_protocols).into()),
|
||||
P::Imap | P::Pop3 | P::ManageSieve => Value::Str(
|
||||
switch_str(policy.switch(switch_name(property).unwrap_or_default())).into(),
|
||||
),
|
||||
P::CloseSubmission => Value::Bool(policy.close_submission),
|
||||
P::SavedListeners => Value::Array(
|
||||
@@ -176,10 +208,11 @@ where
|
||||
)
|
||||
}
|
||||
|
||||
/// The listeners turning the switch on would close, whatever it is now.
|
||||
/// The listeners turning every protocol off would close, whatever the switches
|
||||
/// are now; each names its protocol, so the console shows one protocol's.
|
||||
async fn would_close(server: &Server, policy: &Policy) -> trc::Result<Vec<SavedListener>> {
|
||||
let mut hypothetical = policy.clone();
|
||||
hypothetical.legacy_protocols = LegacyProtocols::Disabled;
|
||||
hypothetical.set_all(LegacyProtocols::Disabled);
|
||||
hypothetical.apply_locks();
|
||||
listeners::would_close(server.registry(), &hypothetical).await
|
||||
}
|
||||
@@ -238,12 +271,12 @@ fn apply(
|
||||
value: &Value<'_, P, ProtocolPolicyValue>,
|
||||
) -> Result<(), String> {
|
||||
match property {
|
||||
P::LegacyProtocols => {
|
||||
policy.legacy_protocols = match value.as_str().as_deref() {
|
||||
Some("enabled") => LegacyProtocols::Enabled,
|
||||
Some("disabled") => LegacyProtocols::Disabled,
|
||||
_ => return Err(r#"must be "enabled" or "disabled""#.to_string()),
|
||||
}
|
||||
// The kill-all sets all three; a protocol named in the same /set is
|
||||
// applied after it (see `set`), so it wins.
|
||||
P::LegacyProtocols => policy.set_all(parse_switch(value.as_str().as_deref())?),
|
||||
P::Imap | P::Pop3 | P::ManageSieve => {
|
||||
let value = parse_switch(value.as_str().as_deref())?;
|
||||
policy.set(switch_name(property).unwrap_or_default(), value);
|
||||
}
|
||||
P::CloseSubmission => {
|
||||
policy.close_submission = value
|
||||
@@ -262,7 +295,13 @@ fn apply(
|
||||
/// Puts a property back to its default (a `null` in `/set`).
|
||||
fn reset(policy: &mut Policy, property: &P, defaults: &Policy) -> Result<(), String> {
|
||||
match property {
|
||||
P::LegacyProtocols => policy.legacy_protocols = defaults.legacy_protocols,
|
||||
P::LegacyProtocols => policy.set_all(defaults.legacy_protocols),
|
||||
P::Imap | P::Pop3 | P::ManageSieve => {
|
||||
policy.set(
|
||||
switch_name(property).unwrap_or_default(),
|
||||
LegacyProtocols::Enabled,
|
||||
);
|
||||
}
|
||||
P::CloseSubmission => policy.close_submission = defaults.close_submission,
|
||||
P::Id => return Err("is immutable".to_string()),
|
||||
other if other.is_server_set() => return Err("is set by the server".to_string()),
|
||||
@@ -312,10 +351,14 @@ pub async fn set(
|
||||
}
|
||||
|
||||
let mut policy = server.protocol_policy().await?;
|
||||
policy.normalize();
|
||||
let defaults = Policy::default();
|
||||
let mut error = None;
|
||||
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
// The kill-all first, so a protocol named beside it overrides it.
|
||||
let mut entries: Vec<_> = value.into_expanded_object().collect();
|
||||
entries.sort_by_key(|(key, _)| !matches!(key, Key::Property(P::LegacyProtocols)));
|
||||
for (key, value) in entries {
|
||||
let Key::Property(property) = &key else {
|
||||
error = Some(SetError::invalid_properties().with_property(key.into_owned()));
|
||||
break;
|
||||
@@ -393,21 +436,30 @@ fn listener_refusal(policy: &Policy, listener: &NetworkListener) -> Option<(Prop
|
||||
let protocol = listeners::protocol_name(listener.protocol);
|
||||
// A submission listener closes because of its port, not its protocol
|
||||
// (LP-3), so the port is what would have to change.
|
||||
let property = if protocol == "smtp" {
|
||||
Property::Bind
|
||||
let (property, what) = if protocol == "smtp" {
|
||||
(Property::Bind, "Legacy mail protocols are".to_string())
|
||||
} else {
|
||||
Property::Protocol
|
||||
(Property::Protocol, format!("{} is", display_name(protocol)))
|
||||
};
|
||||
Some((
|
||||
property,
|
||||
format!(
|
||||
"Legacy mail protocols are off (inbuxa:ProtocolPolicy), and this {protocol} \
|
||||
listener would reopen a port the switch keeps closed. Turn legacy protocols \
|
||||
back on first."
|
||||
"{what} off (inbuxa:ProtocolPolicy), and this {protocol} listener would reopen \
|
||||
a port the switch keeps closed. Turn it back on first."
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
/// A protocol's name as people read it.
|
||||
fn display_name(protocol: &str) -> &str {
|
||||
match protocol {
|
||||
"imap" => "IMAP",
|
||||
"pop3" => "POP3",
|
||||
"manageSieve" => "ManageSieve",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -461,6 +513,36 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_protocol_off_refuses_only_its_listeners() {
|
||||
let mut policy = Policy::default();
|
||||
policy.set("pop3", LegacyProtocols::Disabled);
|
||||
policy.normalize();
|
||||
let (property, why) = listener_refusal(
|
||||
&policy,
|
||||
&listener(NetworkListenerProtocol::Pop3, "[::]:995"),
|
||||
)
|
||||
.expect("refused");
|
||||
assert_eq!(property, Property::Protocol);
|
||||
assert!(why.starts_with("POP3 is off"), "{why}");
|
||||
assert!(
|
||||
listener_refusal(
|
||||
&policy,
|
||||
&listener(NetworkListenerProtocol::Imap, "[::]:993")
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_switch_reads_and_parses_as_jmap_spells_it() {
|
||||
assert_eq!(switch_str(LegacyProtocols::Disabled), "disabled");
|
||||
assert_eq!(parse_switch(Some("enabled")), Ok(LegacyProtocols::Enabled));
|
||||
assert!(parse_switch(Some("off")).is_err());
|
||||
assert_eq!(switch_name(&P::ManageSieve), Some("manageSieve"));
|
||||
assert_eq!(switch_name(&P::CloseSubmission), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_still_allows_what_the_switch_never_closes() {
|
||||
// Locked (LP-21) and inbound (LP-3): the switch doesn't close them,
|
||||
|
||||
@@ -12,16 +12,22 @@
|
||||
//! with no ids answers with it, and any other id is `notFound`. At server
|
||||
//! level, `/get` with no ids answers with every tenant's.
|
||||
//!
|
||||
//! Turning it off never needs the server's leave; turning it back on is
|
||||
//! refused with `forbidden` while the server has legacy protocols off (LP-9).
|
||||
//! Each of IMAP, POP3 and ManageSieve has its own switch, and
|
||||
//! `legacyProtocols` is the kill-all, as on the server's policy. Turning one
|
||||
//! off never needs the server's leave; turning one back on is refused with
|
||||
//! `forbidden` while the server has that protocol off (LP-9).
|
||||
//! A tenant's switch closes no port (LP-13) -- sign-in and client
|
||||
//! configuration read it (LP-10, LP-14a).
|
||||
|
||||
use crate::inbuxa::protocol_policy::recent_value;
|
||||
use common::{Server, auth::AccessToken, network::legacy::RecentUse};
|
||||
use crate::inbuxa::protocol_policy::{parse_switch, recent_value, switch_str};
|
||||
use common::{
|
||||
Server,
|
||||
auth::AccessToken,
|
||||
network::legacy::{RecentUse, switches_value},
|
||||
};
|
||||
use inbuxa_features::{
|
||||
security::{
|
||||
protocol_policy::LegacyProtocols,
|
||||
protocol_policy::{LegacyProtocols, SWITCHED, Switches},
|
||||
tenant_protocol_policy::{self, TenantProtocolPolicy as Policy, refusal},
|
||||
},
|
||||
tenancy::quota::all_tenants,
|
||||
@@ -46,6 +52,9 @@ const ALL: &[P] = &[
|
||||
P::Id,
|
||||
P::TenantId,
|
||||
P::LegacyProtocols,
|
||||
P::Imap,
|
||||
P::Pop3,
|
||||
P::ManageSieve,
|
||||
P::ChangedAt,
|
||||
P::ChangedBy,
|
||||
P::RecentLegacyUse,
|
||||
@@ -60,19 +69,29 @@ async fn reachable(server: &Server, access_token: &AccessToken) -> trc::Result<V
|
||||
}
|
||||
}
|
||||
|
||||
/// The JMAP name of a per-protocol switch property.
|
||||
fn switch_name(property: &P) -> Option<&'static str> {
|
||||
match property {
|
||||
P::Imap => Some("imap"),
|
||||
P::Pop3 => Some("pop3"),
|
||||
P::ManageSieve => Some("manageSieve"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_value(tenant_id: u32, policy: &Policy, recent: &[RecentUse], properties: &[P]) -> PValue {
|
||||
let mut policy = policy.clone();
|
||||
policy.normalize();
|
||||
let policy = &policy;
|
||||
let mut out = Map::with_capacity(properties.len());
|
||||
for property in properties {
|
||||
let value = match property {
|
||||
P::Id | P::TenantId => {
|
||||
Value::Element(TenantProtocolPolicyValue::Id(Id::from(tenant_id)))
|
||||
}
|
||||
P::LegacyProtocols => Value::Str(
|
||||
match policy.legacy_protocols {
|
||||
LegacyProtocols::Enabled => "enabled",
|
||||
LegacyProtocols::Disabled => "disabled",
|
||||
}
|
||||
.into(),
|
||||
P::LegacyProtocols => Value::Str(switch_str(policy.legacy_protocols).into()),
|
||||
P::Imap | P::Pop3 | P::ManageSieve => Value::Str(
|
||||
switch_str(policy.switch(switch_name(property).unwrap_or_default())).into(),
|
||||
),
|
||||
P::ChangedAt => policy
|
||||
.changed_at
|
||||
@@ -165,29 +184,41 @@ pub async fn set(
|
||||
let data = &server.core.storage.data;
|
||||
let previous = tenant_protocol_policy::get(data, tenant_id).await?;
|
||||
let mut policy = previous.clone();
|
||||
policy.normalize();
|
||||
let mut error = None;
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
// What this request sets to `enabled`, for LP-9.
|
||||
let mut turned_on: Vec<&'static str> = Vec::new();
|
||||
// The kill-all first, so a protocol named beside it overrides it.
|
||||
let mut entries: Vec<_> = value.into_expanded_object().collect();
|
||||
entries.sort_by_key(|(key, _)| !matches!(key, Key::Property(P::LegacyProtocols)));
|
||||
for (key, value) in entries {
|
||||
// `null` puts a switch back to its default, on.
|
||||
let parsed = match value {
|
||||
Value::Null => Ok(LegacyProtocols::Enabled),
|
||||
value => parse_switch(value.as_str().as_deref()),
|
||||
};
|
||||
let result = match &key {
|
||||
Key::Property(P::LegacyProtocols) => match value {
|
||||
Value::Null => {
|
||||
policy.legacy_protocols = LegacyProtocols::Enabled;
|
||||
Ok(())
|
||||
Key::Property(P::LegacyProtocols) => parsed.map(|value| {
|
||||
policy.set_all(value);
|
||||
if !value.is_disabled() {
|
||||
turned_on.extend(SWITCHED.iter().copied());
|
||||
} else {
|
||||
turned_on.clear();
|
||||
}
|
||||
value => match value.as_str().as_deref() {
|
||||
Some("enabled") => {
|
||||
policy.legacy_protocols = LegacyProtocols::Enabled;
|
||||
Ok(())
|
||||
}),
|
||||
Key::Property(property @ (P::Imap | P::Pop3 | P::ManageSieve)) => {
|
||||
parsed.map(|value| {
|
||||
let name = switch_name(property).unwrap_or_default();
|
||||
policy.set(name, value);
|
||||
turned_on.retain(|p| *p != name);
|
||||
if !value.is_disabled() {
|
||||
turned_on.push(name);
|
||||
}
|
||||
Some("disabled") => {
|
||||
policy.legacy_protocols = LegacyProtocols::Disabled;
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(r#"must be "enabled" or "disabled""#),
|
||||
},
|
||||
},
|
||||
Key::Property(P::Id) => Err("is immutable"),
|
||||
Key::Property(_) => Err("is set by the server"),
|
||||
_ => Err("is not a property of inbuxa:TenantProtocolPolicy"),
|
||||
})
|
||||
}
|
||||
Key::Property(P::Id) => Err("is immutable".to_string()),
|
||||
Key::Property(_) => Err("is set by the server".to_string()),
|
||||
_ => Err("is not a property of inbuxa:TenantProtocolPolicy".to_string()),
|
||||
};
|
||||
if let Err(why) = result {
|
||||
error = Some(
|
||||
@@ -203,15 +234,18 @@ pub async fn set(
|
||||
continue;
|
||||
}
|
||||
|
||||
// LP-9: server off means off for everyone.
|
||||
if let Some(why) = refusal(&server.protocol_policy().await?, policy.legacy_protocols) {
|
||||
// LP-9: server off means off for everyone, protocol by protocol.
|
||||
if let Some(why) = refusal(&server.protocol_policy().await?, &turned_on) {
|
||||
response
|
||||
.not_updated
|
||||
.append(id, SetError::forbidden().with_description(why));
|
||||
continue;
|
||||
}
|
||||
|
||||
if policy.legacy_protocols != previous.legacy_protocols {
|
||||
policy.normalize();
|
||||
let mut before = previous;
|
||||
before.normalize();
|
||||
if policy.off() != before.off() {
|
||||
policy.changed_at = Some(store::write::now() * 1000);
|
||||
policy.changed_by = Some(Id::from(access_token.account_id()).to_string());
|
||||
tenant_protocol_policy::set(data, tenant_id, &policy).await?;
|
||||
@@ -221,11 +255,7 @@ pub async fn set(
|
||||
Security(trc::SecurityEvent::LegacyProtocolsChanged),
|
||||
Policy = "tenant",
|
||||
Id = tenant_id,
|
||||
Value = if policy.legacy_protocols.is_disabled() {
|
||||
"disabled"
|
||||
} else {
|
||||
"enabled"
|
||||
},
|
||||
Value = switches_value(&policy),
|
||||
AccountId = policy.changed_by.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -641,6 +641,7 @@ fn map_dns_server(dns_server: &DnsServerBootstrap) -> Option<registry::schema::s
|
||||
DnsServerBootstrap::Ns1(inner) => DnsServer::Ns1(inner.clone()).into(),
|
||||
DnsServerBootstrap::OracleCloud(inner) => DnsServer::OracleCloud(inner.clone()).into(),
|
||||
DnsServerBootstrap::Plesk(inner) => DnsServer::Plesk(inner.clone()).into(),
|
||||
DnsServerBootstrap::PowerDns(inner) => DnsServer::PowerDns(inner.clone()).into(),
|
||||
DnsServerBootstrap::Safedns(inner) => DnsServer::Safedns(inner.clone()).into(),
|
||||
DnsServerBootstrap::Scaleway(inner) => DnsServer::Scaleway(inner.clone()).into(),
|
||||
DnsServerBootstrap::TencentCloud(inner) => DnsServer::TencentCloud(inner.clone()).into(),
|
||||
|
||||
@@ -747,6 +747,16 @@ impl RegistrySet for Server {
|
||||
if let ObjectInner::MaskedEmail(mask) = &new_object.inner {
|
||||
crate::inbuxa::masked_email::created(self, id, mask).await?;
|
||||
}
|
||||
// inbuxa: personal-data catalog: a new tenant gets its
|
||||
// Compliance Officer role
|
||||
if matches!(new_object.inner, ObjectInner::Tenant(_)) {
|
||||
common::manager::compliance_roles::tenant_created(
|
||||
self.registry(),
|
||||
&self.core.storage.data,
|
||||
id,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
response.object.insert(Property::Id, RegistryValue::Id(id));
|
||||
set.response
|
||||
.created
|
||||
@@ -800,6 +810,15 @@ impl RegistrySet for Server {
|
||||
&& object.inner.account_id() != Some(Id::from(set.account_id))))
|
||||
})
|
||||
{
|
||||
// inbuxa: personal-data catalog: a tenant's compliance
|
||||
// role, while nobody holds it, goes first
|
||||
let role_released = matches!(object.inner, ObjectInner::Tenant(_))
|
||||
&& common::manager::compliance_roles::tenant_deleting(
|
||||
self.registry(),
|
||||
&self.core.storage.data,
|
||||
id,
|
||||
)
|
||||
.await?;
|
||||
match self
|
||||
.registry()
|
||||
.write(RegistryWrite::Delete {
|
||||
@@ -863,6 +882,15 @@ impl RegistrySet for Server {
|
||||
set.response.destroyed.push(id);
|
||||
}
|
||||
err => {
|
||||
// inbuxa: refused for another reason: the role comes back
|
||||
if role_released {
|
||||
common::manager::compliance_roles::tenant_kept(
|
||||
self.registry(),
|
||||
&self.core.storage.data,
|
||||
id,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
set.response.not_destroyed.append(id, map_write_error(err));
|
||||
}
|
||||
}
|
||||
@@ -874,7 +902,20 @@ impl RegistrySet for Server {
|
||||
// Finalize cache invalidation
|
||||
self.invalidate_caches(cache_invalidator).await?;
|
||||
|
||||
Ok(set.into_response())
|
||||
// inbuxa: personal-data catalog: what the server holds may
|
||||
// have changed, so the inventory's history is brought up to date
|
||||
let response = set.into_response();
|
||||
if !response.created.is_empty()
|
||||
|| !response.updated.is_empty()
|
||||
|| !response.destroyed.is_empty()
|
||||
{
|
||||
self.inventory_snapshot_after(&format!(
|
||||
"x:{}",
|
||||
registry::types::EnumImpl::as_str(&object_type)
|
||||
))
|
||||
.await;
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
ObjectType::ArfExternalReport
|
||||
| ObjectType::DmarcExternalReport
|
||||
|
||||
@@ -7,7 +7,7 @@ keywords = ["imap", "jmap", "smtp", "email", "mail", "webdav", "server"]
|
||||
categories = ["email"]
|
||||
# Upstream offers AGPL-3.0-only OR LicenseRef-SEL; inbuxa takes the AGPL only.
|
||||
license = "AGPL-3.0-only"
|
||||
version = "0.16.23"
|
||||
version = "0.16.24"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "managesieve"
|
||||
version = "0.16.23"
|
||||
version = "0.16.24"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "migration"
|
||||
version = "0.16.23"
|
||||
version = "0.16.24"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "nlp"
|
||||
version = "0.16.23"
|
||||
version = "0.16.24"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "pop3"
|
||||
version = "0.16.23"
|
||||
version = "0.16.24"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "registry"
|
||||
version = "0.16.23"
|
||||
version = "0.16.24"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -620,6 +620,7 @@ pub enum DnsServerBootstrapType {
|
||||
Vultr = 68,
|
||||
WebSupport = 69,
|
||||
YandexCloud = 70,
|
||||
PowerDns = 71,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
@@ -696,6 +697,7 @@ pub enum DnsServerType {
|
||||
Vultr = 67,
|
||||
WebSupport = 68,
|
||||
YandexCloud = 69,
|
||||
PowerDns = 70,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
@@ -1744,6 +1746,8 @@ pub enum Permission {
|
||||
SysLegalHoldCreate = 670,
|
||||
SysLegalHoldUpdate = 671,
|
||||
SysLegalHoldExport = 672,
|
||||
// inbuxa: personal-data catalog, the data inventory and compliance overview
|
||||
SysComplianceGet = 673,
|
||||
SysAccountGet = 219,
|
||||
SysAccountCreate = 220,
|
||||
SysAccountUpdate = 221,
|
||||
|
||||
@@ -3023,6 +3023,7 @@ impl EnumImpl for DnsServerBootstrapType {
|
||||
b"Vultr" => DnsServerBootstrapType::Vultr,
|
||||
b"WebSupport" => DnsServerBootstrapType::WebSupport,
|
||||
b"YandexCloud" => DnsServerBootstrapType::YandexCloud,
|
||||
b"PowerDns" => DnsServerBootstrapType::PowerDns,
|
||||
}
|
||||
.copied()
|
||||
}
|
||||
@@ -3100,6 +3101,7 @@ impl EnumImpl for DnsServerBootstrapType {
|
||||
DnsServerBootstrapType::Vultr => "Vultr",
|
||||
DnsServerBootstrapType::WebSupport => "WebSupport",
|
||||
DnsServerBootstrapType::YandexCloud => "YandexCloud",
|
||||
DnsServerBootstrapType::PowerDns => "PowerDns",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3180,11 +3182,12 @@ impl EnumImpl for DnsServerBootstrapType {
|
||||
68 => Some(DnsServerBootstrapType::Vultr),
|
||||
69 => Some(DnsServerBootstrapType::WebSupport),
|
||||
70 => Some(DnsServerBootstrapType::YandexCloud),
|
||||
71 => Some(DnsServerBootstrapType::PowerDns),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
const COUNT: usize = 71;
|
||||
const COUNT: usize = 72;
|
||||
}
|
||||
|
||||
impl serde::Serialize for DnsServerBootstrapType {
|
||||
@@ -3281,6 +3284,7 @@ impl EnumImpl for DnsServerType {
|
||||
b"Vultr" => DnsServerType::Vultr,
|
||||
b"WebSupport" => DnsServerType::WebSupport,
|
||||
b"YandexCloud" => DnsServerType::YandexCloud,
|
||||
b"PowerDns" => DnsServerType::PowerDns,
|
||||
}
|
||||
.copied()
|
||||
}
|
||||
@@ -3357,6 +3361,7 @@ impl EnumImpl for DnsServerType {
|
||||
DnsServerType::Vultr => "Vultr",
|
||||
DnsServerType::WebSupport => "WebSupport",
|
||||
DnsServerType::YandexCloud => "YandexCloud",
|
||||
DnsServerType::PowerDns => "PowerDns",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3436,11 +3441,12 @@ impl EnumImpl for DnsServerType {
|
||||
67 => Some(DnsServerType::Vultr),
|
||||
68 => Some(DnsServerType::WebSupport),
|
||||
69 => Some(DnsServerType::YandexCloud),
|
||||
70 => Some(DnsServerType::PowerDns),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
const COUNT: usize = 70;
|
||||
const COUNT: usize = 71;
|
||||
}
|
||||
|
||||
impl serde::Serialize for DnsServerType {
|
||||
@@ -7084,6 +7090,7 @@ impl EnumImpl for Permission {
|
||||
b"sysLegalHoldCreate" => Permission::SysLegalHoldCreate,
|
||||
b"sysLegalHoldUpdate" => Permission::SysLegalHoldUpdate,
|
||||
b"sysLegalHoldExport" => Permission::SysLegalHoldExport,
|
||||
b"sysComplianceGet" => Permission::SysComplianceGet,
|
||||
b"sysAccountGet" => Permission::SysAccountGet,
|
||||
b"sysAccountCreate" => Permission::SysAccountCreate,
|
||||
b"sysAccountUpdate" => Permission::SysAccountUpdate,
|
||||
@@ -7773,6 +7780,7 @@ impl EnumImpl for Permission {
|
||||
Permission::SysLegalHoldCreate => "sysLegalHoldCreate",
|
||||
Permission::SysLegalHoldUpdate => "sysLegalHoldUpdate",
|
||||
Permission::SysLegalHoldExport => "sysLegalHoldExport",
|
||||
Permission::SysComplianceGet => "sysComplianceGet",
|
||||
Permission::SysAccountGet => "sysAccountGet",
|
||||
Permission::SysAccountCreate => "sysAccountCreate",
|
||||
Permission::SysAccountUpdate => "sysAccountUpdate",
|
||||
@@ -8455,6 +8463,7 @@ impl EnumImpl for Permission {
|
||||
670 => Some(Permission::SysLegalHoldCreate),
|
||||
671 => Some(Permission::SysLegalHoldUpdate),
|
||||
672 => Some(Permission::SysLegalHoldExport),
|
||||
673 => Some(Permission::SysComplianceGet),
|
||||
219 => Some(Permission::SysAccountGet),
|
||||
220 => Some(Permission::SysAccountCreate),
|
||||
221 => Some(Permission::SysAccountUpdate),
|
||||
@@ -8899,7 +8908,7 @@ impl EnumImpl for Permission {
|
||||
}
|
||||
}
|
||||
|
||||
const COUNT: usize = 673;
|
||||
const COUNT: usize = 674;
|
||||
}
|
||||
|
||||
impl serde::Serialize for Permission {
|
||||
|
||||
@@ -1042,6 +1042,7 @@ pub enum Property {
|
||||
SentinelUsername = 914,
|
||||
Separator = 97,
|
||||
ServerHostname = 121,
|
||||
ServerId = 934,
|
||||
Servers = 308,
|
||||
ServiceAccountJson = 316,
|
||||
ServiceName = 913,
|
||||
|
||||
@@ -1195,6 +1195,7 @@ impl EnumImpl for Property {
|
||||
b"sentinelUsername" => Property::SentinelUsername,
|
||||
b"separator" => Property::Separator,
|
||||
b"serverHostname" => Property::ServerHostname,
|
||||
b"serverId" => Property::ServerId,
|
||||
b"servers" => Property::Servers,
|
||||
b"serviceAccountJson" => Property::ServiceAccountJson,
|
||||
b"serviceName" => Property::ServiceName,
|
||||
@@ -2134,6 +2135,7 @@ impl EnumImpl for Property {
|
||||
Property::SentinelUsername => "sentinelUsername",
|
||||
Property::Separator => "separator",
|
||||
Property::ServerHostname => "serverHostname",
|
||||
Property::ServerId => "serverId",
|
||||
Property::Servers => "servers",
|
||||
Property::ServiceAccountJson => "serviceAccountJson",
|
||||
Property::ServiceName => "serviceName",
|
||||
@@ -3077,6 +3079,7 @@ impl EnumImpl for Property {
|
||||
914 => Some(Property::SentinelUsername),
|
||||
97 => Some(Property::Separator),
|
||||
121 => Some(Property::ServerHostname),
|
||||
934 => Some(Property::ServerId),
|
||||
308 => Some(Property::Servers),
|
||||
316 => Some(Property::ServiceAccountJson),
|
||||
913 => Some(Property::ServiceName),
|
||||
@@ -3227,7 +3230,7 @@ impl EnumImpl for Property {
|
||||
}
|
||||
}
|
||||
|
||||
const COUNT: usize = 934;
|
||||
const COUNT: usize = 935;
|
||||
}
|
||||
|
||||
impl serde::Serialize for Property {
|
||||
@@ -4493,6 +4496,7 @@ impl ObjectInner {
|
||||
ObjectInner::DnsServer(DnsServer::Vultr(obj)) => obj.member_tenant_id,
|
||||
ObjectInner::DnsServer(DnsServer::WebSupport(obj)) => obj.member_tenant_id,
|
||||
ObjectInner::DnsServer(DnsServer::YandexCloud(obj)) => obj.member_tenant_id,
|
||||
ObjectInner::DnsServer(DnsServer::PowerDns(obj)) => obj.member_tenant_id,
|
||||
ObjectInner::Domain(obj) => obj.member_tenant_id,
|
||||
ObjectInner::MailingList(obj) => obj.member_tenant_id,
|
||||
ObjectInner::OAuthClient(obj) => obj.member_tenant_id,
|
||||
@@ -4595,6 +4599,7 @@ impl ObjectInner {
|
||||
ObjectInner::DnsServer(DnsServer::Vultr(obj)) => obj.member_tenant_id = Some(id),
|
||||
ObjectInner::DnsServer(DnsServer::WebSupport(obj)) => obj.member_tenant_id = Some(id),
|
||||
ObjectInner::DnsServer(DnsServer::YandexCloud(obj)) => obj.member_tenant_id = Some(id),
|
||||
ObjectInner::DnsServer(DnsServer::PowerDns(obj)) => obj.member_tenant_id = Some(id),
|
||||
ObjectInner::Domain(obj) => obj.member_tenant_id = Some(id),
|
||||
ObjectInner::MailingList(obj) => obj.member_tenant_id = Some(id),
|
||||
ObjectInner::OAuthClient(obj) => obj.member_tenant_id = Some(id),
|
||||
|
||||
@@ -1457,6 +1457,7 @@ pub enum DnsServer {
|
||||
Vultr(DnsServerCloud),
|
||||
WebSupport(DnsServerWebSupport),
|
||||
YandexCloud(DnsServerYandexCloud),
|
||||
PowerDns(DnsServerPowerDns),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -1672,6 +1673,7 @@ pub enum DnsServerBootstrap {
|
||||
Vultr(DnsServerCloud),
|
||||
WebSupport(DnsServerWebSupport),
|
||||
YandexCloud(DnsServerYandexCloud),
|
||||
PowerDns(DnsServerPowerDns),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -2435,6 +2437,31 @@ pub struct DnsServerPorkbun {
|
||||
pub propagation_delay: Option<Duration>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct DnsServerPowerDns {
|
||||
#[serde(rename = "apiKey")]
|
||||
pub api_key: SecretKey,
|
||||
#[serde(rename = "endpoint")]
|
||||
pub endpoint: Option<String>,
|
||||
#[serde(rename = "serverId")]
|
||||
pub server_id: Option<String>,
|
||||
#[serde(rename = "description")]
|
||||
pub description: String,
|
||||
#[serde(rename = "memberTenantId")]
|
||||
pub member_tenant_id: Option<Id>,
|
||||
#[serde(rename = "timeout")]
|
||||
pub timeout: Duration,
|
||||
#[serde(rename = "ttl")]
|
||||
pub ttl: Duration,
|
||||
#[serde(rename = "pollingInterval")]
|
||||
pub polling_interval: Duration,
|
||||
#[serde(rename = "propagationTimeout")]
|
||||
pub propagation_timeout: Duration,
|
||||
#[serde(rename = "propagationDelay")]
|
||||
pub propagation_delay: Option<Duration>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct DnsServerRoute53 {
|
||||
|
||||
@@ -10559,6 +10559,7 @@ impl ObjectImpl for DnsServer {
|
||||
DnsServer::Vultr(inner) => inner.validate(errors),
|
||||
DnsServer::WebSupport(inner) => inner.validate(errors),
|
||||
DnsServer::YandexCloud(inner) => inner.validate(errors),
|
||||
DnsServer::PowerDns(inner) => inner.validate(errors),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10772,6 +10773,9 @@ impl ObjectImpl for DnsServer {
|
||||
DnsServer::YandexCloud(object) => {
|
||||
object.index(i);
|
||||
}
|
||||
DnsServer::PowerDns(object) => {
|
||||
object.index(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11064,6 +11068,10 @@ impl Pickle for DnsServer {
|
||||
69u16.pickle(out);
|
||||
inner.pickle(out);
|
||||
}
|
||||
DnsServer::PowerDns(inner) => {
|
||||
70u16.pickle(out);
|
||||
inner.pickle(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11139,6 +11147,7 @@ impl Pickle for DnsServer {
|
||||
67 => Pickle::unpickle(stream).map(DnsServer::Vultr),
|
||||
68 => Pickle::unpickle(stream).map(DnsServer::WebSupport),
|
||||
69 => Pickle::unpickle(stream).map(DnsServer::YandexCloud),
|
||||
70 => Pickle::unpickle(stream).map(DnsServer::PowerDns),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -11635,6 +11644,13 @@ impl IntoValue for DnsServer {
|
||||
.insert_unchecked(Property::Type, JmapValue::Str("YandexCloud".into()));
|
||||
obj
|
||||
}
|
||||
DnsServer::PowerDns(obj) => {
|
||||
let mut obj = obj.into_value();
|
||||
obj.as_object_mut()
|
||||
.unwrap()
|
||||
.insert_unchecked(Property::Type, JmapValue::Str("PowerDns".into()));
|
||||
obj
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11719,6 +11735,7 @@ impl RegistryJsonPatch for DnsServer {
|
||||
DnsServerType::Vultr => *self = DnsServer::Vultr(Default::default()),
|
||||
DnsServerType::WebSupport => *self = DnsServer::WebSupport(Default::default()),
|
||||
DnsServerType::YandexCloud => *self = DnsServer::YandexCloud(Default::default()),
|
||||
DnsServerType::PowerDns => *self = DnsServer::PowerDns(Default::default()),
|
||||
}
|
||||
}
|
||||
match self {
|
||||
@@ -11792,6 +11809,7 @@ impl RegistryJsonPatch for DnsServer {
|
||||
DnsServer::Vultr(inner) => inner.patch(pointer, value),
|
||||
DnsServer::WebSupport(inner) => inner.patch(pointer, value),
|
||||
DnsServer::YandexCloud(inner) => inner.patch(pointer, value),
|
||||
DnsServer::PowerDns(inner) => inner.patch(pointer, value),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11869,6 +11887,7 @@ impl DnsServer {
|
||||
DnsServer::Vultr(_) => DnsServerType::Vultr,
|
||||
DnsServer::WebSupport(_) => DnsServerType::WebSupport,
|
||||
DnsServer::YandexCloud(_) => DnsServerType::YandexCloud,
|
||||
DnsServer::PowerDns(_) => DnsServerType::PowerDns,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12704,6 +12723,7 @@ impl DnsServerBootstrap {
|
||||
DnsServerBootstrap::Vultr(inner) => inner.validate(errors),
|
||||
DnsServerBootstrap::WebSupport(inner) => inner.validate(errors),
|
||||
DnsServerBootstrap::YandexCloud(inner) => inner.validate(errors),
|
||||
DnsServerBootstrap::PowerDns(inner) => inner.validate(errors),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12999,6 +13019,10 @@ impl Pickle for DnsServerBootstrap {
|
||||
70u16.pickle(out);
|
||||
inner.pickle(out);
|
||||
}
|
||||
DnsServerBootstrap::PowerDns(inner) => {
|
||||
71u16.pickle(out);
|
||||
inner.pickle(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13075,6 +13099,7 @@ impl Pickle for DnsServerBootstrap {
|
||||
68 => Pickle::unpickle(stream).map(DnsServerBootstrap::Vultr),
|
||||
69 => Pickle::unpickle(stream).map(DnsServerBootstrap::WebSupport),
|
||||
70 => Pickle::unpickle(stream).map(DnsServerBootstrap::YandexCloud),
|
||||
71 => Pickle::unpickle(stream).map(DnsServerBootstrap::PowerDns),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -13576,6 +13601,13 @@ impl IntoValue for DnsServerBootstrap {
|
||||
.insert_unchecked(Property::Type, JmapValue::Str("YandexCloud".into()));
|
||||
obj
|
||||
}
|
||||
DnsServerBootstrap::PowerDns(obj) => {
|
||||
let mut obj = obj.into_value();
|
||||
obj.as_object_mut()
|
||||
.unwrap()
|
||||
.insert_unchecked(Property::Type, JmapValue::Str("PowerDns".into()));
|
||||
obj
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13793,6 +13825,9 @@ impl RegistryJsonPatch for DnsServerBootstrap {
|
||||
DnsServerBootstrapType::YandexCloud => {
|
||||
*self = DnsServerBootstrap::YandexCloud(Default::default())
|
||||
}
|
||||
DnsServerBootstrapType::PowerDns => {
|
||||
*self = DnsServerBootstrap::PowerDns(Default::default())
|
||||
}
|
||||
}
|
||||
}
|
||||
match self {
|
||||
@@ -13867,6 +13902,7 @@ impl RegistryJsonPatch for DnsServerBootstrap {
|
||||
DnsServerBootstrap::Vultr(inner) => inner.patch(pointer, value),
|
||||
DnsServerBootstrap::WebSupport(inner) => inner.patch(pointer, value),
|
||||
DnsServerBootstrap::YandexCloud(inner) => inner.patch(pointer, value),
|
||||
DnsServerBootstrap::PowerDns(inner) => inner.patch(pointer, value),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13945,6 +13981,7 @@ impl DnsServerBootstrap {
|
||||
DnsServerBootstrap::Vultr(_) => DnsServerBootstrapType::Vultr,
|
||||
DnsServerBootstrap::WebSupport(_) => DnsServerBootstrapType::WebSupport,
|
||||
DnsServerBootstrap::YandexCloud(_) => DnsServerBootstrapType::YandexCloud,
|
||||
DnsServerBootstrap::PowerDns(_) => DnsServerBootstrapType::PowerDns,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18228,6 +18265,148 @@ impl RegistryJsonPropertyPatch for DnsServerPorkbun {
|
||||
}
|
||||
}
|
||||
|
||||
impl DnsServerPowerDns {
|
||||
fn validate(&self, errors: &mut Vec<ValidationError>) -> bool {
|
||||
let neb = errors.len();
|
||||
let value = &self.api_key;
|
||||
value.validate(errors);
|
||||
if let Some(value) = &self.endpoint {
|
||||
if value.is_empty() {
|
||||
errors.push(ValidationError::required(Property::Endpoint));
|
||||
}
|
||||
}
|
||||
if let Some(value) = &self.server_id {
|
||||
if value.is_empty() {
|
||||
errors.push(ValidationError::required(Property::ServerId));
|
||||
}
|
||||
}
|
||||
let value = &self.description;
|
||||
if value.is_empty() {
|
||||
errors.push(ValidationError::required(Property::Description));
|
||||
}
|
||||
if let Some(value) = &self.member_tenant_id {
|
||||
if !value.is_valid() {
|
||||
errors.push(ValidationError::required(Property::MemberTenantId));
|
||||
}
|
||||
}
|
||||
errors.len() == neb
|
||||
}
|
||||
|
||||
fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) {
|
||||
i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None);
|
||||
if let Some(value) = &self.member_tenant_id {
|
||||
i.search(Property::MemberTenantId, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Pickle for DnsServerPowerDns {
|
||||
fn pickle(&self, out: &mut Vec<u8>) {
|
||||
self.api_key.pickle(out);
|
||||
self.endpoint.pickle(out);
|
||||
self.server_id.pickle(out);
|
||||
self.description.pickle(out);
|
||||
self.member_tenant_id.pickle(out);
|
||||
self.timeout.pickle(out);
|
||||
self.ttl.pickle(out);
|
||||
self.polling_interval.pickle(out);
|
||||
self.propagation_timeout.pickle(out);
|
||||
self.propagation_delay.pickle(out);
|
||||
}
|
||||
|
||||
fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option<Self> {
|
||||
let mut this = Self::default();
|
||||
this.api_key = Pickle::unpickle(stream)?;
|
||||
this.endpoint = Pickle::unpickle(stream)?;
|
||||
this.server_id = Pickle::unpickle(stream)?;
|
||||
this.description = Pickle::unpickle(stream)?;
|
||||
this.member_tenant_id = Pickle::unpickle(stream)?;
|
||||
this.timeout = Pickle::unpickle(stream)?;
|
||||
this.ttl = Pickle::unpickle(stream)?;
|
||||
this.polling_interval = Pickle::unpickle(stream)?;
|
||||
this.propagation_timeout = Pickle::unpickle(stream)?;
|
||||
this.propagation_delay = Pickle::unpickle(stream)?;
|
||||
Some(this)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DnsServerPowerDns {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
api_key: Default::default(),
|
||||
endpoint: Default::default(),
|
||||
server_id: Default::default(),
|
||||
description: Default::default(),
|
||||
member_tenant_id: Default::default(),
|
||||
timeout: Duration::from_millis(30000),
|
||||
ttl: Duration::from_millis(300000),
|
||||
polling_interval: Duration::from_millis(15000),
|
||||
propagation_timeout: Duration::from_millis(60000),
|
||||
propagation_delay: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoValue for DnsServerPowerDns {
|
||||
fn into_value(self) -> JmapValue<'static> {
|
||||
let mut map = jmap_tools::Map::with_capacity(12);
|
||||
map.insert_unchecked(Property::ApiKey, self.api_key.into_value());
|
||||
map.insert_unchecked(Property::Endpoint, self.endpoint.into_value());
|
||||
map.insert_unchecked(Property::ServerId, self.server_id.into_value());
|
||||
map.insert_unchecked(Property::Description, self.description.into_value());
|
||||
map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value());
|
||||
map.insert_unchecked(Property::Timeout, self.timeout.into_value());
|
||||
map.insert_unchecked(Property::Ttl, self.ttl.into_value());
|
||||
map.insert_unchecked(
|
||||
Property::PollingInterval,
|
||||
self.polling_interval.into_value(),
|
||||
);
|
||||
map.insert_unchecked(
|
||||
Property::PropagationTimeout,
|
||||
self.propagation_timeout.into_value(),
|
||||
);
|
||||
map.insert_unchecked(
|
||||
Property::PropagationDelay,
|
||||
self.propagation_delay.into_value(),
|
||||
);
|
||||
JmapValue::Object(map)
|
||||
}
|
||||
}
|
||||
|
||||
impl RegistryJsonPropertyPatch for DnsServerPowerDns {
|
||||
fn patch_property<'x>(
|
||||
&mut self,
|
||||
mut pointer: JsonPointerPatch<'_>,
|
||||
value: JmapValue<'x>,
|
||||
) -> PatchResult<'x> {
|
||||
match pointer.next_property() {
|
||||
Some(Property::ApiKey) => self.api_key.patch(pointer, value),
|
||||
Some(Property::Endpoint) => self
|
||||
.endpoint
|
||||
.patch(pointer.with_validators(&[StringValidator::Trim]), value),
|
||||
Some(Property::ServerId) => self
|
||||
.server_id
|
||||
.patch(pointer.with_validators(&[StringValidator::Trim]), value),
|
||||
Some(Property::Description) => self
|
||||
.description
|
||||
.patch(pointer.with_validators(&[StringValidator::Trim]), value),
|
||||
Some(Property::MemberTenantId) => self
|
||||
.member_tenant_id
|
||||
.patch(pointer.assert_can_set_tenant()?, value),
|
||||
Some(Property::Timeout) => self.timeout.patch(pointer, value),
|
||||
Some(Property::Ttl) => self.ttl.patch(pointer, value),
|
||||
Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value),
|
||||
Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value),
|
||||
Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value),
|
||||
Some(Property::Type) => Ok(MaybeUnpatched::Unpatched {
|
||||
property: Property::Type,
|
||||
value,
|
||||
}),
|
||||
_ => Err(PatchError::new(pointer, "Invalid property")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DnsServerRoute53 {
|
||||
fn validate(&self, errors: &mut Vec<ValidationError>) -> bool {
|
||||
let neb = errors.len();
|
||||
@@ -47353,7 +47532,9 @@ impl Default for WebHook {
|
||||
level: TracingLevel::Info,
|
||||
lossy: false,
|
||||
events: Default::default(),
|
||||
events_policy: EventPolicy::Exclude,
|
||||
// inbuxa: personal-data catalog, D7: a new webhook sends nothing
|
||||
// until its events are chosen
|
||||
events_policy: EventPolicy::Include,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod dkim;
|
||||
pub mod http;
|
||||
pub mod report;
|
||||
pub mod secret;
|
||||
pub mod spam;
|
||||
pub mod task;
|
||||
|
||||
impl Roles {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::schema::prelude::{SpamDnsblServer, SpamRule};
|
||||
|
||||
impl SpamRule {
|
||||
pub fn enable(&self) -> bool {
|
||||
match self {
|
||||
SpamRule::Any(rule) => rule.enable,
|
||||
SpamRule::Url(rule) => rule.enable,
|
||||
SpamRule::Domain(rule) => rule.enable,
|
||||
SpamRule::Email(rule) => rule.enable,
|
||||
SpamRule::Ip(rule) => rule.enable,
|
||||
SpamRule::Header(rule) => rule.enable,
|
||||
SpamRule::Body(rule) => rule.enable,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_enable(&mut self, enable: bool) {
|
||||
match self {
|
||||
SpamRule::Any(rule) => rule.enable = enable,
|
||||
SpamRule::Url(rule) => rule.enable = enable,
|
||||
SpamRule::Domain(rule) => rule.enable = enable,
|
||||
SpamRule::Email(rule) => rule.enable = enable,
|
||||
SpamRule::Ip(rule) => rule.enable = enable,
|
||||
SpamRule::Header(rule) => rule.enable = enable,
|
||||
SpamRule::Body(rule) => rule.enable = enable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SpamDnsblServer {
|
||||
pub fn enable(&self) -> bool {
|
||||
match self {
|
||||
SpamDnsblServer::Any(server) => server.enable,
|
||||
SpamDnsblServer::Url(server) => server.enable,
|
||||
SpamDnsblServer::Domain(server) => server.enable,
|
||||
SpamDnsblServer::Email(server) => server.enable,
|
||||
SpamDnsblServer::Ip(server) => server.enable,
|
||||
SpamDnsblServer::Header(server) => server.enable,
|
||||
SpamDnsblServer::Body(server) => server.enable,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_enable(&mut self, enable: bool) {
|
||||
match self {
|
||||
SpamDnsblServer::Any(server) => server.enable = enable,
|
||||
SpamDnsblServer::Url(server) => server.enable = enable,
|
||||
SpamDnsblServer::Domain(server) => server.enable = enable,
|
||||
SpamDnsblServer::Email(server) => server.enable = enable,
|
||||
SpamDnsblServer::Ip(server) => server.enable = enable,
|
||||
SpamDnsblServer::Header(server) => server.enable = enable,
|
||||
SpamDnsblServer::Body(server) => server.enable = enable,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "scim-proto"
|
||||
version = "0.16.23"
|
||||
version = "0.16.24"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "scim"
|
||||
version = "0.16.23"
|
||||
version = "0.16.24"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "services"
|
||||
version = "0.16.23"
|
||||
version = "0.16.24"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! Deletes rotated log files past `inbuxa:LogSettings.keepForDays`
|
||||
//! (personal-data catalog spec, D1). Log files are local, so every node
|
||||
//! cleans its own: hourly, and at once when the settings change here.
|
||||
|
||||
use common::{BuildServer, Inner, Server};
|
||||
use inbuxa_features::security::log_files;
|
||||
use registry::schema::structs::Tracer;
|
||||
use std::{path::PathBuf, sync::Arc, time::Duration};
|
||||
|
||||
const EVERY: Duration = Duration::from_secs(3600);
|
||||
|
||||
pub fn spawn_log_retention(inner: Arc<Inner>) {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let server = inner.build_server();
|
||||
if let Err(err) = purge(&server).await {
|
||||
trc::error!(err.details("Failed to delete old log files"));
|
||||
}
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(EVERY) => {}
|
||||
_ = log_files::CHANGED.notified() => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn purge(server: &Server) -> trc::Result<()> {
|
||||
let Some(days) = log_files::get(&server.core.storage.data)
|
||||
.await?
|
||||
.keep_for_days
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let keep = Duration::from_secs(days.max(log_files::MIN_KEEP_DAYS) * 86_400);
|
||||
for tracer in server.registry().list::<Tracer>().await? {
|
||||
let Tracer::Log(log) = tracer.object else {
|
||||
continue;
|
||||
};
|
||||
if !log.enable || log.path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (dir, prefix) = (PathBuf::from(&log.path), log.prefix.clone());
|
||||
let result = tokio::task::spawn_blocking(move || log_files::purge(&dir, &prefix, keep))
|
||||
.await
|
||||
.map_err(|err| trc::EventType::Server(trc::ServerEvent::ThreadError).reason(err))?;
|
||||
if let Err(err) = result {
|
||||
trc::event!(
|
||||
Telemetry(trc::TelemetryEvent::LogError),
|
||||
Details = "Failed to delete old log files",
|
||||
Path = log.path.clone(),
|
||||
Reason = err.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -25,6 +25,7 @@ use crate::task_manager::{manager::spawn_task_manager, scheduler::spawn_task_sch
|
||||
pub mod broadcast;
|
||||
// inbuxa: AL-5, delegations end at their date
|
||||
pub mod inbuxa_lock_expiry;
|
||||
pub mod inbuxa_log_retention; // inbuxa: personal-data catalog, D1
|
||||
pub mod state_manager;
|
||||
pub mod task_manager;
|
||||
|
||||
@@ -70,6 +71,9 @@ impl SpawnServices for IpcReceivers {
|
||||
// inbuxa: AL-5, end delegations at their `until`
|
||||
inbuxa_lock_expiry::spawn_lock_expiry(inner.clone());
|
||||
|
||||
// inbuxa: personal-data catalog, D1: old log files go, per node
|
||||
inbuxa_log_retention::spawn_log_retention(inner.clone());
|
||||
|
||||
// Spawn task scheduler
|
||||
spawn_task_scheduler(inner);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use super::{
|
||||
ece::{ECE_WEBPUSH_MAX_PLAINTEXT_SIZE, WEBPUSH_MAX_BODY_SIZE, ece_encrypt},
|
||||
email_push::build_email_push_object,
|
||||
};
|
||||
use crate::state_manager::PushRegistration;
|
||||
use crate::state_manager::{PushBatch, PushRegistration};
|
||||
use calcard::jscalendar::JSCalendarDateTime;
|
||||
use common::{Server, ipc::PushNotification, network::webpush::Vapid};
|
||||
use email::push::{PushSubscription, Urgency};
|
||||
@@ -29,7 +29,7 @@ use std::time::{Duration, Instant};
|
||||
use store::write::now;
|
||||
use tokio::sync::mpsc;
|
||||
use trc::PushSubscriptionEvent;
|
||||
use types::{id::Id, type_state::DataType};
|
||||
use types::id::Id;
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
const MAX_ERROR_RESPONSE_LEN: usize = 1024;
|
||||
@@ -48,34 +48,29 @@ impl PushRegistration {
|
||||
pub fn send(
|
||||
&mut self,
|
||||
id: Id,
|
||||
push_client: &Client,
|
||||
push_tx: mpsc::Sender<Event>,
|
||||
push_timeout: Duration,
|
||||
server: Server,
|
||||
) {
|
||||
let subscription = self.server.clone();
|
||||
let push_client = self.client.clone();
|
||||
let notifications = std::mem::take(&mut self.notifications);
|
||||
let push_client = push_client.clone();
|
||||
let batch = std::mem::take(&mut self.pending);
|
||||
|
||||
self.in_flight = true;
|
||||
self.last_request = Instant::now();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut changed: VecMap<Id, VecMap<DataType, State>> = VecMap::new();
|
||||
let vapid = server.core.jmap.vapid.as_ref();
|
||||
let mut email_pushes: VecMap<Id, EmailPushObject> = VecMap::new();
|
||||
|
||||
let mut failed_state_change = false;
|
||||
let mut failed = PushBatch::default();
|
||||
let mut failed_email_pushes = Vec::new();
|
||||
let mut failed_calendar_alerts = Vec::new();
|
||||
|
||||
for notification in ¬ifications {
|
||||
for notification in &batch.notifications {
|
||||
match notification {
|
||||
PushNotification::StateChange(state_change) => {
|
||||
for type_state in state_change.types {
|
||||
changed
|
||||
.get_mut_or_insert(state_change.account_id.into())
|
||||
.set(type_state, State::Exact(state_change.change_id));
|
||||
}
|
||||
}
|
||||
PushNotification::StateChange(_) => {}
|
||||
PushNotification::CalendarAlert(calendar_alert) => {
|
||||
let payload = PushObject::CalendarAlert {
|
||||
account_id: calendar_alert.account_id.into(),
|
||||
@@ -86,12 +81,12 @@ impl PushRegistration {
|
||||
}),
|
||||
alert_id: calendar_alert.alert_id.clone(),
|
||||
};
|
||||
if !http_request(
|
||||
if !post_object(
|
||||
&push_client,
|
||||
&subscription,
|
||||
serde_json::to_string(&payload).unwrap().into_bytes(),
|
||||
&payload,
|
||||
push_timeout,
|
||||
server.core.jmap.vapid.as_ref(),
|
||||
vapid,
|
||||
Urgency::Normal,
|
||||
)
|
||||
.await
|
||||
@@ -155,18 +150,23 @@ impl PushRegistration {
|
||||
}
|
||||
}
|
||||
|
||||
if !changed.is_empty() {
|
||||
failed_state_change = !http_request(
|
||||
if !batch.state_changes.is_empty() {
|
||||
let payload = PushObject::StateChange {
|
||||
changed: batch.state_changes,
|
||||
};
|
||||
if !post_object(
|
||||
&push_client,
|
||||
&subscription,
|
||||
serde_json::to_string(&PushObject::StateChange { changed })
|
||||
.unwrap()
|
||||
.into_bytes(),
|
||||
&payload,
|
||||
push_timeout,
|
||||
server.core.jmap.vapid.as_ref(),
|
||||
vapid,
|
||||
Urgency::Normal,
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
&& let PushObject::StateChange { changed } = payload
|
||||
{
|
||||
failed.state_changes = changed;
|
||||
}
|
||||
}
|
||||
|
||||
for (account_id, email_push) in email_pushes {
|
||||
@@ -180,12 +180,12 @@ impl PushRegistration {
|
||||
state: email_push.change_id.map(State::Exact),
|
||||
};
|
||||
|
||||
if !http_request(
|
||||
if !post_object(
|
||||
&push_client,
|
||||
&subscription,
|
||||
serde_json::to_string(&payload).unwrap().into_bytes(),
|
||||
&payload,
|
||||
push_timeout,
|
||||
server.core.jmap.vapid.as_ref(),
|
||||
vapid,
|
||||
email_push.urgency,
|
||||
)
|
||||
.await
|
||||
@@ -194,44 +194,26 @@ impl PushRegistration {
|
||||
}
|
||||
}
|
||||
|
||||
let result = if !failed_state_change
|
||||
let result = if failed.state_changes.is_empty()
|
||||
&& failed_email_pushes.is_empty()
|
||||
&& failed_calendar_alerts.is_empty()
|
||||
{
|
||||
Event::DeliverySuccess { id }
|
||||
} else {
|
||||
let mut failed_notifications = Vec::with_capacity(
|
||||
failed_state_change as usize
|
||||
+ failed_email_pushes.len()
|
||||
+ failed_calendar_alerts.len(),
|
||||
);
|
||||
|
||||
for notification in notifications {
|
||||
match ¬ification {
|
||||
PushNotification::StateChange(_) => {
|
||||
if failed_state_change {
|
||||
failed_notifications.push(notification);
|
||||
}
|
||||
}
|
||||
failed.notifications = batch
|
||||
.notifications
|
||||
.into_iter()
|
||||
.filter(|notification| match notification {
|
||||
PushNotification::StateChange(_) => false,
|
||||
PushNotification::EmailPush(email_push) => {
|
||||
if failed_email_pushes.contains(&email_push.account_id) {
|
||||
failed_notifications.push(notification);
|
||||
}
|
||||
failed_email_pushes.contains(&email_push.account_id)
|
||||
}
|
||||
PushNotification::CalendarAlert(calendar_alert) => {
|
||||
if failed_calendar_alerts
|
||||
.contains(&(calendar_alert.account_id, calendar_alert.event_id))
|
||||
{
|
||||
failed_notifications.push(notification);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
PushNotification::CalendarAlert(calendar_alert) => failed_calendar_alerts
|
||||
.contains(&(calendar_alert.account_id, calendar_alert.event_id)),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Event::DeliveryFailure {
|
||||
id,
|
||||
notifications: failed_notifications,
|
||||
}
|
||||
Event::DeliveryFailure { id, failed }
|
||||
};
|
||||
|
||||
push_tx.send(result).await.ok();
|
||||
@@ -239,6 +221,38 @@ impl PushRegistration {
|
||||
}
|
||||
}
|
||||
|
||||
async fn post_object(
|
||||
push_client: &Client,
|
||||
subscription: &PushSubscription,
|
||||
object: &PushObject,
|
||||
push_timeout: Duration,
|
||||
vapid: Option<&Vapid>,
|
||||
urgency: Urgency,
|
||||
) -> bool {
|
||||
match serde_json::to_vec(object) {
|
||||
Ok(body) => {
|
||||
http_request(
|
||||
push_client,
|
||||
subscription,
|
||||
body,
|
||||
push_timeout,
|
||||
vapid,
|
||||
urgency,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
PushSubscription(PushSubscriptionEvent::Error),
|
||||
Details = "Failed to serialize push object",
|
||||
Url = subscription.url.to_string(),
|
||||
Reason = err.to_string()
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_push_client() -> Client {
|
||||
utils::http::http_client_builder(cfg!(feature = "test_mode"))
|
||||
.redirect(Policy::custom(|attempt| match attempt.previous().last() {
|
||||
|
||||
@@ -14,7 +14,7 @@ use common::{
|
||||
};
|
||||
use std::{sync::Arc, time::Instant};
|
||||
use store::ahash::AHashMap;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::mpsc::{self, error::TrySendError};
|
||||
use trc::ServerEvent;
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -99,7 +99,7 @@ pub fn spawn_push_router(inner: Arc<Inner>, mut change_rx: mpsc::Receiver<PushEv
|
||||
} => {
|
||||
// Publish event to cluster
|
||||
if broadcast
|
||||
&& let Some(broadcast_tx) = &inner.ipc.broadcast_tx.clone()
|
||||
&& let Some(broadcast_tx) = inner.ipc.broadcast_tx.as_ref()
|
||||
&& broadcast_tx
|
||||
.send(BroadcastEvent::PushNotification(notification.clone()))
|
||||
.await
|
||||
@@ -117,26 +117,30 @@ pub fn spawn_push_router(inner: Arc<Inner>, mut change_rx: mpsc::Receiver<PushEv
|
||||
for subscriber in &subscribers.ipc {
|
||||
if let Some(notification) = notification.filter_types(&subscriber.types)
|
||||
{
|
||||
if subscriber.is_valid() {
|
||||
let subscriber_tx = subscriber.tx.clone();
|
||||
match subscriber.tx.try_send(notification) {
|
||||
Ok(()) => {}
|
||||
Err(TrySendError::Full(notification)) => {
|
||||
let subscriber_tx = subscriber.tx.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Timeout after 500ms in case there is a blocked client
|
||||
if subscriber_tx
|
||||
.send_timeout(notification, SEND_TIMEOUT)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
trc::event!(
|
||||
Server(ServerEvent::ThreadError),
|
||||
Details =
|
||||
"Error sending state change to subscriber.",
|
||||
CausedBy = trc::location!()
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
purge_needed = true;
|
||||
tokio::spawn(async move {
|
||||
// Timeout after 500ms in case there is a blocked client
|
||||
if subscriber_tx
|
||||
.send_timeout(notification, SEND_TIMEOUT)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
trc::event!(
|
||||
Server(ServerEvent::ThreadError),
|
||||
Details =
|
||||
"Error sending state change to subscriber.",
|
||||
CausedBy = trc::location!()
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(TrySendError::Closed(_)) => {
|
||||
purge_needed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,7 +163,7 @@ pub fn spawn_push_router(inner: Arc<Inner>, mut change_rx: mpsc::Receiver<PushEv
|
||||
} => {
|
||||
// Publish event to cluster
|
||||
if broadcast
|
||||
&& let Some(broadcast_tx) = &inner.ipc.broadcast_tx.clone()
|
||||
&& let Some(broadcast_tx) = inner.ipc.broadcast_tx.as_ref()
|
||||
&& broadcast_tx
|
||||
.send(BroadcastEvent::PushServerUpdate(account_id))
|
||||
.await
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user