Compare commits
102
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffcfde0b5a | ||
|
|
f1f05db790 | ||
|
|
e1076a04b2 | ||
|
|
8fc8d94bbc | ||
|
|
0c600a63fa | ||
|
|
f78925b316 | ||
|
|
6ee7ba1b7e | ||
|
|
a992caf810 | ||
|
|
daa486f7e7 | ||
|
|
9c29fb2bea | ||
|
|
abd5811420 | ||
|
|
80051539d5 | ||
|
|
64550ebbd0 | ||
|
|
eea96e8674 | ||
|
|
4c5583e725 | ||
|
|
441ad0b18e | ||
|
|
792ff9d1ee | ||
|
|
af49e94d97 | ||
|
|
37c00b609c | ||
|
|
94a3a762b0 | ||
|
|
9a7d678532 | ||
|
|
823d42d528 | ||
|
|
de514115dd | ||
|
|
0f8816f659 | ||
|
|
b59eebf1e7 | ||
|
|
f4061f542c | ||
|
|
e99f84bd01 | ||
|
|
9653219c53 | ||
|
|
f44382fb09 | ||
|
|
dd73e0ad74 | ||
|
|
7f045c626a | ||
|
|
e99d26de89 | ||
|
|
7f22006e97 | ||
|
|
e35fc3e6d6 | ||
|
|
5f52dad5f1 | ||
|
|
15064d6fd5 | ||
|
|
e0060c9e6e | ||
|
|
a3a36cd5d7 | ||
|
|
8afaee7d21 | ||
|
|
c8280de9c3 | ||
|
|
f8b9df6438 | ||
|
|
92d14fbd60 | ||
|
|
01f6b99631 | ||
|
|
8d5e4ee052 | ||
|
|
213c7f0362 | ||
|
|
9e0aab6b6a | ||
|
|
3eb5a454fd | ||
|
|
dc49bf4d14 | ||
|
|
f7fb115a0f | ||
|
|
3199a6f1fb | ||
|
|
2b45a2e412 | ||
|
|
3fadf82909 | ||
|
|
2a851ea230 | ||
|
|
0502eb45ed | ||
|
|
11ba361c8c | ||
|
|
ba75ab4ecc | ||
|
|
afffa0fc96 | ||
|
|
32b22d0828 | ||
|
|
558b776e9f | ||
|
|
ac3a63973d | ||
|
|
e61a475859 | ||
|
|
6c862e4971 | ||
|
|
beb6c33e63 | ||
|
|
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 | ||
|
|
b1bc5ed6e0 |
@@ -40,6 +40,16 @@ jobs:
|
|||||||
# nothing. CI never sees the difference; a release does.
|
# nothing. CI never sees the difference; a release does.
|
||||||
- if: always()
|
- if: always()
|
||||||
run: python3 tools/fork/context-check.py
|
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
|
||||||
|
# The admin reads each expression field's allowed values and variables
|
||||||
|
# from the schema; they're generated from the registry and must match it.
|
||||||
|
- if: always()
|
||||||
|
run: python3 tools/fork/expr-schema.py --check
|
||||||
|
- if: always()
|
||||||
|
run: python3 -m unittest discover -s tools/fork/tests
|
||||||
|
|
||||||
build:
|
build:
|
||||||
# Either runner (host1 or host2): the build needs no docker socket.
|
# 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/).
|
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
|
## [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.
|
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
+181
-177
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "common"
|
name = "common"
|
||||||
version = "0.16.23"
|
version = "0.16.24"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,14 @@ impl AccessToken {
|
|||||||
mut requested_permissions: Permissions,
|
mut requested_permissions: Permissions,
|
||||||
) -> Result<(), Vec<Permission>> {
|
) -> Result<(), Vec<Permission>> {
|
||||||
requested_permissions.difference(self.permissions_bits());
|
requested_permissions.difference(self.permissions_bits());
|
||||||
|
// inbuxa: journaling, JR-18: whoever sets up journals may give
|
||||||
|
// others (or, through a role, themselves) the reading of them,
|
||||||
|
// which administrators don't hold by default; the role change is
|
||||||
|
// in the audit log
|
||||||
|
if self.has_permission(Permission::SysJournalUpdate) {
|
||||||
|
requested_permissions.clear(Permission::SysJournalSearch as usize);
|
||||||
|
requested_permissions.clear(Permission::SysJournalExport as usize);
|
||||||
|
}
|
||||||
if requested_permissions.is_empty() {
|
if requested_permissions.is_empty() {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
@@ -290,6 +298,33 @@ impl Default for DefaultPermissions {
|
|||||||
default.superuser.push(permission);
|
default.superuser.push(permission);
|
||||||
default.tenant.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: DLP and mail flow rules, and held mail, are the
|
||||||
|
// server's: never a tenant's (dlp-and-mail-flow-rules spec,
|
||||||
|
// settled answer 3)
|
||||||
|
Permission::SysMailRuleGet
|
||||||
|
| Permission::SysMailRuleUpdate
|
||||||
|
| Permission::SysDlpPolicyGet
|
||||||
|
| Permission::SysDlpPolicyUpdate
|
||||||
|
| Permission::SysDlpReviewGet
|
||||||
|
| Permission::SysDlpReviewUpdate
|
||||||
|
// inbuxa: every security check is server-wide (security
|
||||||
|
// to-do list spec)
|
||||||
|
| Permission::SysSecurityAccept => {
|
||||||
|
default.superuser.push(permission);
|
||||||
|
}
|
||||||
|
// inbuxa: journals are the server's; administrators set them
|
||||||
|
// up but read what's journaled only if granted it
|
||||||
|
// (journaling spec, JR-18, settled answer 5)
|
||||||
|
Permission::SysJournalGet | Permission::SysJournalUpdate => {
|
||||||
|
default.superuser.push(permission);
|
||||||
|
}
|
||||||
|
Permission::SysJournalSearch | Permission::SysJournalExport => {}
|
||||||
// inbuxa: AL-12: tenant administrators lock and delegate
|
// inbuxa: AL-12: tenant administrators lock and delegate
|
||||||
// within their tenant
|
// within their tenant
|
||||||
Permission::SysAccountLockGet
|
Permission::SysAccountLockGet
|
||||||
|
|||||||
@@ -46,10 +46,10 @@ pub struct Network {
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct NetworkInfo {
|
pub struct NetworkInfo {
|
||||||
pub pacc: Pacc,
|
/// inbuxa: the document once per combination of legacy protocols off,
|
||||||
/// inbuxa: the same document without IMAP, POP3, SMTP and ManageSieve,
|
/// indexed by `LegacyOff::index` (legacy-protocols LP-7, one switch per
|
||||||
/// served while legacy protocols are off (legacy-protocols LP-7).
|
/// protocol); index 0 is the full document.
|
||||||
pub pacc_jmap_only: Pacc,
|
pub pacc: Vec<Pacc>,
|
||||||
pub mxs: Vec<MailExchanger>,
|
pub mxs: Vec<MailExchanger>,
|
||||||
pub services: VecMap<ServiceProtocol, Service>,
|
pub services: VecMap<ServiceProtocol, Service>,
|
||||||
}
|
}
|
||||||
@@ -333,16 +333,27 @@ impl Network {
|
|||||||
})
|
})
|
||||||
.unwrap()
|
.unwrap()
|
||||||
};
|
};
|
||||||
// inbuxa: legacy-protocols LP-7
|
// inbuxa: legacy-protocols LP-7, one document per combination of
|
||||||
let pacc_jmap_only = {
|
// protocols off, bits as `LegacyOff::index`: IMAP, POP3, ManageSieve,
|
||||||
let mut pacc = pacc.clone();
|
// submission.
|
||||||
pacc.protocols.imap = None;
|
let pacc = (0..16usize)
|
||||||
pacc.protocols.pop3 = None;
|
.map(|off| {
|
||||||
pacc.protocols.smtp = None;
|
let mut pacc = pacc.clone();
|
||||||
pacc.protocols.managesieve = None;
|
if off & 1 != 0 {
|
||||||
split(&pacc)
|
pacc.protocols.imap = None;
|
||||||
};
|
}
|
||||||
let pacc = split(&pacc);
|
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 {
|
let mut network = Network {
|
||||||
node_id: bp.node_id() as u64,
|
node_id: bp.node_id() as u64,
|
||||||
server_name: default_hostname.to_string(),
|
server_name: default_hostname.to_string(),
|
||||||
@@ -358,7 +369,6 @@ impl Network {
|
|||||||
mxs: system.mail_exchangers.into_iter().collect(),
|
mxs: system.mail_exchangers.into_iter().collect(),
|
||||||
services: system.services,
|
services: system.services,
|
||||||
pacc,
|
pacc,
|
||||||
pacc_jmap_only,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -483,8 +483,16 @@ impl Tracers {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Parse webhook events
|
// 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| {
|
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);
|
tracer.interests.set(event_type);
|
||||||
global_interests.set(event_type);
|
global_interests.set(event_type);
|
||||||
}
|
}
|
||||||
@@ -743,6 +751,31 @@ fn tracer_settings(tracer: &Tracer) -> u64 {
|
|||||||
settings_hash(&tracer)
|
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 {
|
fn webhook_settings(hook: &WebHook) -> u64 {
|
||||||
let mut hook = hook.clone();
|
let mut hook = hook.clone();
|
||||||
in_place_reset!(hook);
|
in_place_reset!(hook);
|
||||||
@@ -804,3 +837,61 @@ impl std::fmt::Debug for OtelMetrics {
|
|||||||
.finish()
|
.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,8 @@ pub mod auth;
|
|||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod audit; // inbuxa: the audit log (audit-hold-lock spec, AU)
|
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 hold; // inbuxa: legal holds (audit-hold-lock spec, LH)
|
||||||
|
pub mod privacy; // inbuxa: the personal-data catalog, evaluated
|
||||||
|
pub mod reachability; // inbuxa: whether the outside world reaches each node's ports
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod expr;
|
pub mod expr;
|
||||||
pub mod i18n;
|
pub mod i18n;
|
||||||
@@ -128,6 +130,8 @@ pub const KV_LOCK_QUEUE_MESSAGE: u8 = 21;
|
|||||||
pub const KV_LOCK_TASK: u8 = 23;
|
pub const KV_LOCK_TASK: u8 = 23;
|
||||||
pub const KV_LOCK_DAV: u8 = 25;
|
pub const KV_LOCK_DAV: u8 = 25;
|
||||||
pub const KV_SIEVE_ID: u8 = 26;
|
pub const KV_SIEVE_ID: u8 = 26;
|
||||||
|
// inbuxa: far above upstream's prefixes, so a new one of theirs never collides
|
||||||
|
pub const KV_PORT_REACHABILITY: u8 = 200;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Server {
|
pub struct Server {
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
/*
|
||||||
|
* 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,
|
||||||
|
// dlp-and-mail-flow-rules spec, §2.8: see DLP rules, review held mail
|
||||||
|
Permission::SysDlpPolicyGet,
|
||||||
|
Permission::SysDlpReviewGet,
|
||||||
|
Permission::SysDlpReviewUpdate,
|
||||||
|
// journaling spec, JR-18: see journals, search and export them
|
||||||
|
Permission::SysJournalGet,
|
||||||
|
Permission::SysJournalSearch,
|
||||||
|
Permission::SysJournalExport,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server-level Compliance Officer role the server made, if it has.
|
||||||
|
pub async fn server_role(data: &Store) -> trc::Result<Option<Id>> {
|
||||||
|
recorded(data, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
// Placing holds and reviewing held mail are the officer's
|
||||||
|
// job, not settings (settled answers 2 and 4)
|
||||||
|
let holds = name.starts_with("sysLegalHold") || name.starts_with("sysDlpReview");
|
||||||
|
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::{
|
use registry::{
|
||||||
schema::{
|
schema::{
|
||||||
enums::*,
|
enums::*,
|
||||||
prelude::{ObjectType, SocketAddr},
|
prelude::{Object, ObjectType, SocketAddr},
|
||||||
structs::*,
|
structs::*,
|
||||||
},
|
},
|
||||||
types::{duration::Duration, error::Error, list::List, map::Map},
|
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 {
|
if bp.registry.count_object(ObjectType::Role).await? == 0 {
|
||||||
let permissions = DefaultPermissions::default();
|
let permissions = DefaultPermissions::default();
|
||||||
let mut role_ids = Vec::with_capacity(4);
|
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
|
// inbuxa: administrator roles stored before a permission existed get it once
|
||||||
super::granted_permissions::grant_new_admin_permissions(bp).await?;
|
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
|
if bp
|
||||||
.registry
|
.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
|
// 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
|
// (spam_rules). They load on first boot, and again when the bundled
|
||||||
// version differs from the one last loaded, which only adds what's
|
// rules differ from the ones last loaded: new tags and rules, fixes to
|
||||||
// missing: new tags and rules, never a changed score.
|
// rules nobody edited, never a changed score or an admin's edit.
|
||||||
let rules_url = super::spam_rules::rules_url(
|
let rules_url = super::spam_rules::rules_url(
|
||||||
bp.registry
|
bp.registry
|
||||||
.object::<SpamSettings>(Id::singleton())
|
.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)
|
&& super::spam_rules::applied_version(&bp.data_store)
|
||||||
.await?
|
.await?
|
||||||
.as_deref()
|
.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 {
|
if bp.registry.count_object(ObjectType::SpamRule).await? == 0 || bundled_is_new {
|
||||||
let mut batch = BatchBuilder::new();
|
let mut batch = BatchBuilder::new();
|
||||||
batch.schedule_task(Task::SpamFilterMaintenance(TaskSpamFilterMaintenance {
|
batch.schedule_task(Task::SpamFilterMaintenance(TaskSpamFilterMaintenance {
|
||||||
@@ -560,3 +601,81 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> {
|
|||||||
|
|
||||||
Ok(())
|
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,9 @@ use types::id::Id;
|
|||||||
|
|
||||||
/// Granted to the default administrator roles: "Explain this"
|
/// Granted to the default administrator roles: "Explain this"
|
||||||
/// (ai-explain spec, EX-4: superuser by default), the audit log, account
|
/// (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), and accepting security
|
||||||
|
/// to-do items (security to-do list spec).
|
||||||
const ADMIN_GRANTS: &[Permission] = &[
|
const ADMIN_GRANTS: &[Permission] = &[
|
||||||
Permission::SysAiExplain,
|
Permission::SysAiExplain,
|
||||||
Permission::SysAuditGet,
|
Permission::SysAuditGet,
|
||||||
@@ -44,11 +46,34 @@ const ADMIN_GRANTS: &[Permission] = &[
|
|||||||
Permission::SysLegalHoldCreate,
|
Permission::SysLegalHoldCreate,
|
||||||
Permission::SysLegalHoldUpdate,
|
Permission::SysLegalHoldUpdate,
|
||||||
Permission::SysLegalHoldExport,
|
Permission::SysLegalHoldExport,
|
||||||
|
Permission::SysComplianceGet,
|
||||||
|
Permission::SysMailRuleGet,
|
||||||
|
Permission::SysMailRuleUpdate,
|
||||||
|
Permission::SysDlpPolicyGet,
|
||||||
|
Permission::SysDlpPolicyUpdate,
|
||||||
|
Permission::SysDlpReviewGet,
|
||||||
|
Permission::SysDlpReviewUpdate,
|
||||||
|
Permission::SysJournalGet,
|
||||||
|
Permission::SysJournalUpdate,
|
||||||
|
Permission::SysSecurityAccept,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Granted to the server-level Compliance Officer role once it exists:
|
||||||
|
/// seeing DLP rules and reviewing held mail (dlp-and-mail-flow-rules spec,
|
||||||
|
/// §2.8, settled answer 4). A new install's role has them from the start.
|
||||||
|
const OFFICER_GRANTS: &[Permission] = &[
|
||||||
|
Permission::SysDlpPolicyGet,
|
||||||
|
Permission::SysDlpReviewGet,
|
||||||
|
Permission::SysDlpReviewUpdate,
|
||||||
|
// journaling spec, JR-18: see journals, search and export them
|
||||||
|
Permission::SysJournalGet,
|
||||||
|
Permission::SysJournalSearch,
|
||||||
|
Permission::SysJournalExport,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Granted to the default tenant administrator roles: reading and exporting
|
/// Granted to the default tenant administrator roles: reading and exporting
|
||||||
/// the tenant's audit log (AU-9), and locking and delegating its accounts
|
/// the tenant's audit log (AU-9), locking and delegating its accounts
|
||||||
/// (AL-12).
|
/// (AL-12), and the tenant's slice of the data inventory.
|
||||||
const TENANT_GRANTS: &[Permission] = &[
|
const TENANT_GRANTS: &[Permission] = &[
|
||||||
Permission::SysAuditGet,
|
Permission::SysAuditGet,
|
||||||
Permission::SysAuditExport,
|
Permission::SysAuditExport,
|
||||||
@@ -56,19 +81,23 @@ const TENANT_GRANTS: &[Permission] = &[
|
|||||||
Permission::SysAccountLockCreate,
|
Permission::SysAccountLockCreate,
|
||||||
Permission::SysAccountLockUpdate,
|
Permission::SysAccountLockUpdate,
|
||||||
Permission::SysAccountLockDestroy,
|
Permission::SysAccountLockDestroy,
|
||||||
|
Permission::SysComplianceGet,
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
enum Audience {
|
enum Audience {
|
||||||
Admin,
|
Admin,
|
||||||
Tenant,
|
Tenant,
|
||||||
|
Officer,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn granted_key(permission: Permission, audience: Audience) -> ValueClass {
|
fn granted_key(permission: Permission, audience: Audience) -> ValueClass {
|
||||||
let mut key = b"Pg".to_vec();
|
let mut key = b"Pg".to_vec();
|
||||||
// Admin grants keep the key they were first recorded under
|
// Admin grants keep the key they were first recorded under
|
||||||
if audience == Audience::Tenant {
|
match audience {
|
||||||
key.extend_from_slice(b"tenant:");
|
Audience::Admin => {}
|
||||||
|
Audience::Tenant => key.extend_from_slice(b"tenant:"),
|
||||||
|
Audience::Officer => key.extend_from_slice(b"officer:"),
|
||||||
}
|
}
|
||||||
key.extend_from_slice(permission.as_str().as_bytes());
|
key.extend_from_slice(permission.as_str().as_bytes());
|
||||||
ValueClass::Any(AnyClass {
|
ValueClass::Any(AnyClass {
|
||||||
@@ -79,7 +108,8 @@ fn granted_key(permission: Permission, audience: Audience) -> ValueClass {
|
|||||||
|
|
||||||
pub(crate) async fn grant_new_admin_permissions(bp: &mut Bootstrap) -> trc::Result<()> {
|
pub(crate) async fn grant_new_admin_permissions(bp: &mut Bootstrap) -> trc::Result<()> {
|
||||||
grant(bp, Audience::Admin, ADMIN_GRANTS).await?;
|
grant(bp, Audience::Admin, ADMIN_GRANTS).await?;
|
||||||
grant(bp, Audience::Tenant, TENANT_GRANTS).await
|
grant(bp, Audience::Tenant, TENANT_GRANTS).await?;
|
||||||
|
grant(bp, Audience::Officer, OFFICER_GRANTS).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn grant(bp: &mut Bootstrap, audience: Audience, grants: &[Permission]) -> trc::Result<()> {
|
async fn grant(bp: &mut Bootstrap, audience: Audience, grants: &[Permission]) -> trc::Result<()> {
|
||||||
@@ -98,39 +128,47 @@ async fn grant(bp: &mut Bootstrap, audience: Audience, grants: &[Permission]) ->
|
|||||||
if pending.is_empty() {
|
if pending.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
// An administrator's default roles include the plain User role, which
|
// The officer role is the one the server made, if it has made it yet: a
|
||||||
// every user also holds; only roles that are the audience's alone get it
|
// new install makes it after this, with the permissions already in it
|
||||||
let admin_roles: Vec<Id> = bp
|
let admin_roles: Vec<Id> = if audience == Audience::Officer {
|
||||||
.registry
|
super::compliance_roles::server_role(&bp.data_store)
|
||||||
.object::<Authentication>(Id::singleton())
|
.await?
|
||||||
.await?
|
.into_iter()
|
||||||
.map(|auth| {
|
.collect()
|
||||||
let (own, shared) = match audience {
|
} else {
|
||||||
Audience::Admin => (
|
// An administrator's default roles include the plain User role, which
|
||||||
auth.default_admin_role_ids.as_slice(),
|
// every user also holds; only roles that are the audience's alone get it
|
||||||
[
|
bp.registry
|
||||||
auth.default_user_role_ids.as_slice(),
|
.object::<Authentication>(Id::singleton())
|
||||||
auth.default_group_role_ids.as_slice(),
|
.await?
|
||||||
auth.default_tenant_role_ids.as_slice(),
|
.map(|auth| {
|
||||||
]
|
let (own, shared) = match audience {
|
||||||
.concat(),
|
Audience::Admin => (
|
||||||
),
|
|
||||||
Audience::Tenant => (
|
|
||||||
auth.default_tenant_role_ids.as_slice(),
|
|
||||||
[
|
|
||||||
auth.default_user_role_ids.as_slice(),
|
|
||||||
auth.default_group_role_ids.as_slice(),
|
|
||||||
auth.default_admin_role_ids.as_slice(),
|
auth.default_admin_role_ids.as_slice(),
|
||||||
]
|
[
|
||||||
.concat(),
|
auth.default_user_role_ids.as_slice(),
|
||||||
),
|
auth.default_group_role_ids.as_slice(),
|
||||||
};
|
auth.default_tenant_role_ids.as_slice(),
|
||||||
own.iter()
|
]
|
||||||
.filter(|id| !shared.contains(id))
|
.concat(),
|
||||||
.copied()
|
),
|
||||||
.collect()
|
Audience::Tenant | Audience::Officer => (
|
||||||
})
|
auth.default_tenant_role_ids.as_slice(),
|
||||||
.unwrap_or_default();
|
[
|
||||||
|
auth.default_user_role_ids.as_slice(),
|
||||||
|
auth.default_group_role_ids.as_slice(),
|
||||||
|
auth.default_admin_role_ids.as_slice(),
|
||||||
|
]
|
||||||
|
.concat(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
own.iter()
|
||||||
|
.filter(|id| !shared.contains(id))
|
||||||
|
.copied()
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
// Fetched by id: the registry's listing doesn't reach stored roles
|
// Fetched by id: the registry's listing doesn't reach stored roles
|
||||||
for role_id in admin_roles {
|
for role_id in admin_roles {
|
||||||
let Some(stored) = bp
|
let Some(stored) = bp
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use utils::HttpLimitResponse;
|
|||||||
pub mod application;
|
pub mod application;
|
||||||
pub mod backup;
|
pub mod backup;
|
||||||
pub mod boot;
|
pub mod boot;
|
||||||
|
pub mod compliance_roles; // inbuxa: personal-data catalog, the compliance roles
|
||||||
pub mod console;
|
pub mod console;
|
||||||
pub mod defaults;
|
pub mod defaults;
|
||||||
pub mod first_party;
|
pub mod first_party;
|
||||||
|
|||||||
@@ -12,11 +12,16 @@
|
|||||||
//! and license) and uses it whenever no other source is configured. The rules
|
//! and license) and uses it whenever no other source is configured. The rules
|
||||||
//! URL remains an operator override (`https://` or `file://`).
|
//! URL remains an operator override (`https://` or `file://`).
|
||||||
//!
|
//!
|
||||||
//! Loading rules only ever adds what's missing, never changes an existing rule
|
//! Loading rules adds what's missing and brings an existing rule up to date,
|
||||||
//! or score. They load on first boot, and again whenever the bundled version
|
//! but never touches one an admin edited: every object an update writes is
|
||||||
//! differs from the one last applied, so an upgrade brings new tags (the AI
|
//! fingerprinted, and one that no longer matches its fingerprint is kept as
|
||||||
//! classifier's `LLM_*` scores, say) to an install that already had rules.
|
//! 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 std::io::Read;
|
||||||
use store::{
|
use store::{
|
||||||
SUBSPACE_INBUXA, Store, ValueKey,
|
SUBSPACE_INBUXA, Store, ValueKey,
|
||||||
@@ -27,13 +32,17 @@ use trc::AddContext;
|
|||||||
/// The version of spam-filter the embedded rules come from.
|
/// The version of spam-filter the embedded rules come from.
|
||||||
pub const BUNDLED_SPAM_RULES_VERSION: &str = "3.0.2";
|
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] =
|
static BUNDLED_SPAM_RULES: &[u8] =
|
||||||
include_bytes!("../../../../resources/spam-filter/spam-filter-rules.json.gz");
|
include_bytes!("../../../../resources/spam-filter/spam-filter-rules.json.gz");
|
||||||
|
|
||||||
/// Upstream's default rules source, the value every install created before
|
/// Upstream's default rules source, the value every install created before
|
||||||
/// the rules were bundled has saved. Read only to treat it as unset.
|
/// the rules were bundled has saved. Read only to treat it as unset.
|
||||||
const LEGACY_DEFAULT_URL: &str =
|
const LEGACY_DEFAULT_URL: &str = "https://github.com/stalwartlabs/spam-filter/releases/latest/download/spam-filter-rules.json.gz";
|
||||||
"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
|
/// 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.
|
/// 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>> {
|
pub async fn applied_version(data: &Store) -> trc::Result<Option<String>> {
|
||||||
data.get_value::<String>(ValueKey::from(applied_key()))
|
data.get_value::<String>(ValueKey::from(applied_key()))
|
||||||
.await
|
.await
|
||||||
.caused_by(trc::location!())
|
.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<()> {
|
pub async fn set_applied_version(data: &Store, version: &str) -> trc::Result<()> {
|
||||||
let mut batch = BatchBuilder::new();
|
let mut batch = BatchBuilder::new();
|
||||||
batch.set(applied_key(), version.as_bytes().to_vec());
|
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(|_| ())
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn bundled_rules_parse_and_score_the_ai_tags() {
|
fn bundled_rules_parse_and_score_the_ai_tags() {
|
||||||
let rules: serde_json::Value = serde_json::from_slice(&bundled_rules().unwrap()).unwrap();
|
let rules: serde_json::Value = serde_json::from_slice(&bundled_rules().unwrap()).unwrap();
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -72,11 +75,22 @@ impl Server {
|
|||||||
.acme_certificate_renewal_due(&domains, renew_before, now())
|
.acme_certificate_renewal_due(&domains, renew_before, now())
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
return Err(AcmeError::NotDue(format!(
|
// INBUXA: a certificate already covering these names (one stored by
|
||||||
"Certificate for domain {} is still valid; renewal is not due until {}",
|
// hand before the domain was switched to automatic, say) isn't a
|
||||||
domain.name,
|
// failure: schedule the renewal for when it falls due. Returning
|
||||||
UTCDateTime::from_timestamp(renew_at as i64)
|
// NotDue here ended the task for good, and nothing renewed the
|
||||||
)));
|
// certificate before it expired.
|
||||||
|
trc::event!(
|
||||||
|
Acme(trc::AcmeEvent::RenewBackoff),
|
||||||
|
Domain = domain.name.clone(),
|
||||||
|
Hostname = domains.as_slice(),
|
||||||
|
Details = "A valid certificate already covers these names",
|
||||||
|
NextRetry = trc::Value::Timestamp(renew_at),
|
||||||
|
);
|
||||||
|
return Ok(vec![Task::AcmeRenewal(TaskDomainManagement {
|
||||||
|
domain_id,
|
||||||
|
status: TaskStatus::at(renew_at as i64),
|
||||||
|
})]);
|
||||||
}
|
}
|
||||||
|
|
||||||
let dns_parameters = match &domain.dns_management {
|
let dns_parameters = match &domain.dns_management {
|
||||||
|
|||||||
@@ -6,12 +6,13 @@
|
|||||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
* 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::Reader;
|
||||||
use quick_xml::XmlVersion;
|
use quick_xml::XmlVersion;
|
||||||
use quick_xml::events::Event;
|
use quick_xml::events::Event;
|
||||||
use registry::schema::enums::ServiceProtocol;
|
use registry::schema::{enums::ServiceProtocol, structs::Service};
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
|
use utils::map::vec_map::VecMap;
|
||||||
|
|
||||||
impl Server {
|
impl Server {
|
||||||
pub async fn handle_autodiscover_request(
|
pub async fn handle_autodiscover_request(
|
||||||
@@ -26,89 +27,103 @@ impl Server {
|
|||||||
.details("Failed to parse autodiscover request")
|
.details("Failed to parse autodiscover request")
|
||||||
.ctx(trc::Key::Reason, err)
|
.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
|
// inbuxa: legacy-protocols LP-7, LP-14a
|
||||||
let legacy_off = match emailaddress.rsplit_once('@') {
|
let legacy_off = match emailaddress.rsplit_once('@') {
|
||||||
Some((_, domain)) => self.legacy_protocols_off_for(domain).await?,
|
Some((_, domain)) => self.legacy_off_for(domain).await?,
|
||||||
None => self.legacy_protocols_off_for("").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(
|
Ok(Resource::new(
|
||||||
"application/xml; charset=utf-8",
|
"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> {
|
fn parse_autodiscover_request(bytes: &[u8]) -> Result<String, String> {
|
||||||
if bytes.is_empty() {
|
if bytes.is_empty() {
|
||||||
return Err("Empty request body".to_string());
|
return Err("Empty request body".to_string());
|
||||||
@@ -211,4 +226,79 @@ mod tests {
|
|||||||
"[email protected]"
|
"[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.
|
* 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 registry::schema::enums::ServiceProtocol;
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
use utils::url_params::UrlParams;
|
use utils::url_params::UrlParams;
|
||||||
@@ -31,7 +31,7 @@ impl Server {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// inbuxa: legacy-protocols LP-7, LP-14a
|
// 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
|
// Build XML response
|
||||||
let mut config = String::with_capacity(1024);
|
let mut config = String::with_capacity(1024);
|
||||||
@@ -45,7 +45,7 @@ impl Server {
|
|||||||
"\t\t<displayShortName>{domain}</displayShortName>"
|
"\t\t<displayShortName>{domain}</displayShortName>"
|
||||||
);
|
);
|
||||||
for (protocol, service) in &self.core.network.info.services {
|
for (protocol, service) in &self.core.network.info.services {
|
||||||
if legacy_off && is_legacy_service(protocol) {
|
if legacy_off.service(protocol) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let (protocol, tag, ports) = match protocol {
|
let (protocol, tag, ports) = match protocol {
|
||||||
|
|||||||
@@ -6,11 +6,7 @@
|
|||||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{
|
use crate::{Server, config::network::Pacc, network::dkim::generate_dkim_dns_record};
|
||||||
Server,
|
|
||||||
config::network::Pacc,
|
|
||||||
network::{dkim::generate_dkim_dns_record, legacy::is_legacy_service},
|
|
||||||
};
|
|
||||||
use ahash::{AHashMap, AHashSet};
|
use ahash::{AHashMap, AHashSet};
|
||||||
use base64::{Engine, engine::general_purpose};
|
use base64::{Engine, engine::general_purpose};
|
||||||
use dns_update::{
|
use dns_update::{
|
||||||
@@ -41,7 +37,7 @@ impl Server {
|
|||||||
let default_host = network.server_name.as_str();
|
let default_host = network.server_name.as_str();
|
||||||
let domain_name = domain.name.as_str();
|
let domain_name = domain.name.as_str();
|
||||||
// inbuxa: legacy-protocols LP-7, LP-14a
|
// 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}");
|
let domain_name_suffix = format!(".{domain_name}");
|
||||||
|
|
||||||
for record_type in record_types {
|
for record_type in record_types {
|
||||||
@@ -205,7 +201,7 @@ impl Server {
|
|||||||
// name says "not offered" -- target "." (RFC 6186 section
|
// name says "not offered" -- target "." (RFC 6186 section
|
||||||
// 3.4) -- rather than vanishing, so a client that looks
|
// 3.4) -- rather than vanishing, so a client that looks
|
||||||
// is told, and an old record left in the zone is replaced.
|
// 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 {
|
for (service_name, _) in services {
|
||||||
records.push(NamedDnsRecord {
|
records.push(NamedDnsRecord {
|
||||||
name: format!("_{service_name}._tcp.{domain_name}."),
|
name: format!("_{service_name}._tcp.{domain_name}."),
|
||||||
@@ -307,8 +303,8 @@ impl Server {
|
|||||||
// inbuxa: legacy-protocols LP-7. No TLS pin for a port
|
// inbuxa: legacy-protocols LP-7. No TLS pin for a port
|
||||||
// the switch has closed. Submission's port stays open
|
// the switch has closed. Submission's port stays open
|
||||||
// (the SMTP lock), so its record stays.
|
// (the SMTP lock), so its record stays.
|
||||||
if legacy_off
|
if matches!(protocol, ServiceProtocol::Imap | ServiceProtocol::Pop3)
|
||||||
&& matches!(protocol, ServiceProtocol::Imap | ServiceProtocol::Pop3)
|
&& legacy_off.service(protocol)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -418,11 +414,8 @@ impl Server {
|
|||||||
|
|
||||||
pub async fn get_pacc_for_domain(&self, domain_name: &str) -> trc::Result<String> {
|
pub async fn get_pacc_for_domain(&self, domain_name: &str) -> trc::Result<String> {
|
||||||
// inbuxa: legacy-protocols LP-7, LP-14a
|
// inbuxa: legacy-protocols LP-7, LP-14a
|
||||||
let pacc = if self.legacy_protocols_off_for(domain_name).await? {
|
let off = self.legacy_off_for(domain_name).await?;
|
||||||
&self.core.network.info.pacc_jmap_only
|
let pacc = &self.core.network.info.pacc[off.index()];
|
||||||
} else {
|
|
||||||
&self.core.network.info.pacc
|
|
||||||
};
|
|
||||||
self.get_directory_for_domain(domain_name)
|
self.get_directory_for_domain(domain_name)
|
||||||
.await
|
.await
|
||||||
.caused_by(trc::location!())
|
.caused_by(trc::location!())
|
||||||
|
|||||||
@@ -961,6 +961,20 @@ impl DnsUpdater {
|
|||||||
)
|
)
|
||||||
.map_err(|err| format!("Failed to build DNS updater: {}", err))?,
|
.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 {
|
DnsServer::Safedns(server) => Ok(DnsUpdater {
|
||||||
polling_interval: server.polling_interval.into_inner(),
|
polling_interval: server.polling_interval.into_inner(),
|
||||||
propagation_timeout: server.propagation_timeout.into_inner(),
|
propagation_timeout: server.propagation_timeout.into_inner(),
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ use directory::Credentials;
|
|||||||
use inbuxa_features::security::{
|
use inbuxa_features::security::{
|
||||||
legacy_use::{self, LegacyUse},
|
legacy_use::{self, LegacyUse},
|
||||||
listeners,
|
listeners,
|
||||||
protocol_policy::{self, ProtocolPolicy, SavedListener},
|
protocol_policy::{self, ProtocolPolicy, SUBMISSION, SWITCHED, SavedListener, Switches},
|
||||||
tenant_protocol_policy,
|
tenant_protocol_policy::{self, OffBy, TenantProtocolPolicy},
|
||||||
};
|
};
|
||||||
use registry::schema::enums::ServiceProtocol;
|
use registry::schema::enums::ServiceProtocol;
|
||||||
use registry::types::{error::Error, id::ObjectId};
|
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
|
// this, and a /set that omitted it must not lose the listeners still
|
||||||
// waiting to come back.
|
// waiting to come back.
|
||||||
let previous = self.protocol_policy().await?;
|
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_at = Some(store::write::now() * 1000);
|
||||||
policy.changed_by = changed_by;
|
policy.changed_by = changed_by;
|
||||||
|
policy.normalize();
|
||||||
|
|
||||||
if policy.legacy_protocols.is_disabled() {
|
// Each protocol on its own switch: close what is off now, and put
|
||||||
self.close_legacy_listeners(&mut policy, &mut change).await?;
|
// back what was saved for a protocol that is on again. Either may
|
||||||
} else {
|
// happen in one change, when one protocol goes off as another comes
|
||||||
self.reopen_legacy_listeners(&mut policy, &mut change)
|
// back.
|
||||||
.await?;
|
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?;
|
protocol_policy::set(&self.core.storage.data, &policy).await?;
|
||||||
|
|
||||||
// LP-8. Raised here rather than by the JMAP method, so whatever turns
|
// 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
|
// already where it was asked to be, nothing to close or reopen -- is
|
||||||
// not a change.
|
// not a change.
|
||||||
if previous.legacy_protocols != policy.legacy_protocols || !change.is_empty() {
|
let mut before = previous;
|
||||||
let (moved, direction) = if policy.legacy_protocols.is_disabled() {
|
before.normalize();
|
||||||
(&change.closed, "closed")
|
if before.off() != policy.off() || !change.is_empty() {
|
||||||
} else {
|
// The closed first, then the reopened; `Details` says which.
|
||||||
(&change.reopened, "reopened")
|
let moved = change
|
||||||
};
|
.closed
|
||||||
|
.iter()
|
||||||
|
.chain(change.reopened.iter())
|
||||||
|
.map(|l| l.id.clone());
|
||||||
trc::event!(
|
trc::event!(
|
||||||
Security(trc::SecurityEvent::LegacyProtocolsChanged),
|
Security(trc::SecurityEvent::LegacyProtocolsChanged),
|
||||||
Policy = "server",
|
Policy = "server",
|
||||||
Value = if policy.legacy_protocols.is_disabled() {
|
Value = switches_value(&policy),
|
||||||
"disabled"
|
|
||||||
} else {
|
|
||||||
"enabled"
|
|
||||||
},
|
|
||||||
AccountId = policy.changed_by.clone(),
|
AccountId = policy.changed_by.clone(),
|
||||||
Details = direction,
|
Details = if change.closed.is_empty() {
|
||||||
ListenerId = listener_names(moved.iter().map(|l| l.id.clone())),
|
"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).
|
// Only when a listener could not be put back (LP-5).
|
||||||
Reason = (!change.failed.is_empty()).then(|| listener_names(
|
Reason = (!change.failed.is_empty()).then(|| listener_names(
|
||||||
change
|
change
|
||||||
@@ -165,21 +173,27 @@ impl Server {
|
|||||||
Ok(())
|
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(
|
async fn reopen_legacy_listeners(
|
||||||
&self,
|
&self,
|
||||||
policy: &mut ProtocolPolicy,
|
policy: &mut ProtocolPolicy,
|
||||||
change: &mut PolicyChange,
|
change: &mut PolicyChange,
|
||||||
) -> trc::Result<()> {
|
) -> 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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let saved = std::mem::take(&mut policy.saved_listeners);
|
let (restored, failed) = listeners::reopen(self.registry(), &wanted).await?;
|
||||||
let (restored, failed) = listeners::reopen(self.registry(), &saved).await?;
|
|
||||||
|
|
||||||
// A listener that could not be put back stays saved for another try.
|
// 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;
|
change.failed = failed;
|
||||||
|
|
||||||
if !restored.is_empty() {
|
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
|
/// Names for an event field: the listeners a change closed, reopened or
|
||||||
/// failed to reopen (LP-8).
|
/// failed to reopen (LP-8).
|
||||||
fn listener_names<T: Into<trc::Value>>(names: impl Iterator<Item = T>) -> trc::Value {
|
fn listener_names<T: Into<trc::Value>>(names: impl Iterator<Item = T>) -> trc::Value {
|
||||||
@@ -395,15 +420,18 @@ impl Server {
|
|||||||
credentials: &Credentials,
|
credentials: &Credentials,
|
||||||
) -> trc::Result<()> {
|
) -> trc::Result<()> {
|
||||||
let domain = domain_of(credentials);
|
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));
|
return Err(protocol.refused(RefusalScope::Server, domain));
|
||||||
}
|
}
|
||||||
if let Some(name) = &domain
|
if let Some(name) = &domain
|
||||||
&& let Some(domain) = self.domain(name).await?
|
&& let Some(domain) = self.domain(name).await?
|
||||||
&& let Some(tenant_id) = domain.id_tenant
|
&& 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -422,10 +450,16 @@ impl Server {
|
|||||||
protocol: LegacyProtocol,
|
protocol: LegacyProtocol,
|
||||||
access_token: &AccessToken,
|
access_token: &AccessToken,
|
||||||
) -> trc::Result<()> {
|
) -> trc::Result<()> {
|
||||||
if let Some(tenant_id) = access_token.tenant_id()
|
if let Some(tenant_id) = access_token.tenant_id() {
|
||||||
&& self.tenant_legacy_protocols_off(tenant_id).await?
|
let server = self.protocol_policy().await?;
|
||||||
{
|
let tenant = self.tenant_protocol_policy(tenant_id).await?;
|
||||||
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), None));
|
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(
|
if let Err(err) = legacy_use::record(
|
||||||
&self.core.storage.data,
|
&self.core.storage.data,
|
||||||
@@ -463,31 +497,96 @@ impl Server {
|
|||||||
Ok(recent)
|
Ok(recent)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether legacy protocols are off for this account: the stricter of the
|
/// Which legacy protocols are off for this account: each the stricter of
|
||||||
/// server's switch and its tenant's. What the JMAP session tells the
|
/// 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
|
/// account's apps (legacy-protocols spec, Interfaces), so the webmail can
|
||||||
/// say why a mail app won't connect (LP-19).
|
/// 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,
|
&self,
|
||||||
access_token: &AccessToken,
|
access_token: &AccessToken,
|
||||||
) -> trc::Result<bool> {
|
) -> trc::Result<LegacyOff> {
|
||||||
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
|
let server = self.protocol_policy().await?;
|
||||||
return Ok(true);
|
let tenant = match access_token.tenant_id() {
|
||||||
}
|
Some(tenant_id) => Some(self.tenant_protocol_policy(tenant_id).await?),
|
||||||
match access_token.tenant_id() {
|
None => None,
|
||||||
Some(tenant_id) => self.tenant_legacy_protocols_off(tenant_id).await,
|
};
|
||||||
None => Ok(false),
|
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).
|
/// Whether this configured service must not be offered (LP-7). SMTP here
|
||||||
pub async fn tenant_legacy_protocols_off(&self, tenant_id: u32) -> trc::Result<bool> {
|
/// is submission; inbound mail is never a configured service.
|
||||||
Ok(
|
pub fn service(&self, protocol: &ServiceProtocol) -> bool {
|
||||||
tenant_protocol_policy::get(&self.core.storage.data, tenant_id)
|
match protocol {
|
||||||
.await?
|
ServiceProtocol::Imap => self.imap,
|
||||||
.legacy_protocols
|
ServiceProtocol::Pop3 => self.pop3,
|
||||||
.is_disabled(),
|
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 {
|
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
|
/// 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
|
/// 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.
|
/// 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> {
|
pub async fn legacy_off_for(&self, domain_name: &str) -> trc::Result<LegacyOff> {
|
||||||
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
|
let server = self.protocol_policy().await?;
|
||||||
return Ok(true);
|
let tenant = match self.domain(domain_name).await? {
|
||||||
}
|
|
||||||
match self.domain(domain_name).await? {
|
|
||||||
Some(domain) => match domain.id_tenant {
|
Some(domain) => match domain.id_tenant {
|
||||||
Some(tenant_id) => self.tenant_legacy_protocols_off(tenant_id).await,
|
Some(tenant_id) => Some(self.tenant_protocol_policy(tenant_id).await?),
|
||||||
None => Ok(false),
|
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]
|
#[test]
|
||||||
fn the_domain_comes_from_the_name_given() {
|
fn the_domain_comes_from_the_name_given() {
|
||||||
assert_eq!(domain_of(&basic("[email protected]")), Some("b.test".to_string()));
|
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 {
|
impl BlockedIps {
|
||||||
pub async fn parse(bp: &mut Bootstrap) -> Self {
|
pub async fn parse(bp: &mut Bootstrap) -> Self {
|
||||||
let mut ips = Self::default();
|
let mut ips = Self::default();
|
||||||
|
|||||||
@@ -6,33 +6,79 @@
|
|||||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
use ahash::AHashMap;
|
||||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||||
use p256::{
|
use p256::{
|
||||||
SecretKey,
|
SecretKey,
|
||||||
ecdsa::{Signature, SigningKey, signature::Signer},
|
ecdsa::{Signature, SigningKey, signature::Signer},
|
||||||
pkcs8::{DecodePrivateKey, PrivateKeyInfo, der::SecretDocument},
|
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_TTL: u64 = 12 * 60 * 60;
|
||||||
|
const VAPID_TOKEN_REFRESH: u64 = VAPID_TOKEN_TTL / 2;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Vapid {
|
pub struct Vapid {
|
||||||
key: VapidKey,
|
key: VapidKey,
|
||||||
contact: Option<String>,
|
contact: Option<String>,
|
||||||
|
tokens: Arc<Mutex<AHashMap<String, VapidToken>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VapidToken {
|
||||||
|
authorization: HeaderValue,
|
||||||
|
issued_at: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Vapid {
|
impl Vapid {
|
||||||
pub fn new(key: VapidKey, contact: Option<String>) -> Self {
|
pub fn new(key: VapidKey, contact: Option<String>) -> Self {
|
||||||
Self { key, contact }
|
Self {
|
||||||
|
key,
|
||||||
|
contact,
|
||||||
|
tokens: Arc::default(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn public_key(&self) -> &str {
|
pub fn public_key(&self) -> &str {
|
||||||
self.key.public_key()
|
self.key.public_key()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn authorization(&self, endpoint: &str, now: u64) -> Option<String> {
|
pub fn authorization(&self, endpoint: &str, now: u64) -> Option<HeaderValue> {
|
||||||
self.key
|
let prefix = endpoint_prefix(endpoint)?;
|
||||||
.authorization(endpoint, self.contact.as_deref(), now)
|
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, rest) = url.split_once("://")?;
|
||||||
let scheme = scheme.to_ascii_lowercase();
|
|
||||||
let authority = rest.split(['/', '?', '#']).next()?;
|
let authority = rest.split(['/', '?', '#']).next()?;
|
||||||
let authority = authority
|
url.get(..scheme.len() + "://".len() + authority.len())
|
||||||
.rsplit_once('@')
|
}
|
||||||
.map(|(_, host)| host)
|
|
||||||
.unwrap_or(authority);
|
|
||||||
if authority.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let (host, port) = if let Some(rest) = authority.strip_prefix('[') {
|
fn endpoint_origin(url: &str) -> Option<String> {
|
||||||
let (addr, tail) = rest.split_once(']')?;
|
let origin = Url::parse(url).ok()?.origin();
|
||||||
(
|
origin.is_tuple().then(|| origin.ascii_serialization())
|
||||||
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}")),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn normalize_contact(contact: &str) -> Option<String> {
|
pub fn normalize_contact(contact: &str) -> Option<String> {
|
||||||
@@ -206,7 +226,12 @@ mod tests {
|
|||||||
endpoint_origin("http://[2001:DB8::1]:80/p").unwrap(),
|
endpoint_origin("http://[2001:DB8::1]:80/p").unwrap(),
|
||||||
"http://[2001:db8::1]"
|
"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("not-a-url").is_none());
|
||||||
|
assert!(endpoint_origin("mailto:[email protected]").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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]
|
#[test]
|
||||||
fn authorization_omits_subject_when_no_contact() {
|
fn authorization_omits_subject_when_no_contact() {
|
||||||
let key = test_key();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Whether the outside world can reach each node's ports (settings-reorg,
|
||||||
|
//! Ports: the reachability check).
|
||||||
|
//!
|
||||||
|
//! A server can't answer this about itself: a connection to its own public
|
||||||
|
//! address never leaves the machine, so it passes whatever the firewall in
|
||||||
|
//! front says. In a cluster the other nodes are outside that machine. Every
|
||||||
|
//! ten minutes each node resolves every other active node's hostname, as a
|
||||||
|
//! sender would, and tries a TCP connection to each listener port on each
|
||||||
|
//! address. What it saw goes in the shared in-memory store for an hour, under
|
||||||
|
//! (target, prober), so whichever node the admin asks can report it all.
|
||||||
|
//!
|
||||||
|
//! A single server has no one outside to ask. It reports only whether each
|
||||||
|
//! port is listening, and says so.
|
||||||
|
//!
|
||||||
|
//! A connection is all that's tried: nothing is sent, so no protocol logs a
|
||||||
|
//! session and no rate limit counts it.
|
||||||
|
|
||||||
|
use crate::{KV_PORT_REACHABILITY, Server};
|
||||||
|
use registry::schema::{enums::ClusterNodeStatus, structs::NetworkListener};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::{
|
||||||
|
collections::BTreeSet,
|
||||||
|
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
use store::{dispatch::lookup::KeyValue, write::now};
|
||||||
|
|
||||||
|
/// How often each node probes the others.
|
||||||
|
pub const PROBE_INTERVAL: Duration = Duration::from_secs(600);
|
||||||
|
/// How long one node's view of another is kept: long enough to span a missed round.
|
||||||
|
const KEEP_FOR: u64 = 3600;
|
||||||
|
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Probe {
|
||||||
|
pub port: u16,
|
||||||
|
pub address: String,
|
||||||
|
pub ok: bool,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Report {
|
||||||
|
/// Unix seconds.
|
||||||
|
pub checked_at: u64,
|
||||||
|
pub probes: Vec<Probe>,
|
||||||
|
/// The hostname didn't resolve, so nothing could be tried.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ports a sender or client could reach: every listener's port, leaving
|
||||||
|
/// out listeners bound only to loopback, which are private by design.
|
||||||
|
pub fn public_ports<'x>(listeners: impl IntoIterator<Item = &'x NetworkListener>) -> Vec<u16> {
|
||||||
|
listeners
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|l| l.bind.iter())
|
||||||
|
.map(|addr| addr.0)
|
||||||
|
.filter(|addr| !addr.ip().is_loopback())
|
||||||
|
.map(|addr| addr.port())
|
||||||
|
.collect::<BTreeSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(target: &str, prober: &str) -> Vec<u8> {
|
||||||
|
format!("{target}\n{prober}").into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect(address: SocketAddr) -> Result<(), String> {
|
||||||
|
match tokio::time::timeout(CONNECT_TIMEOUT, tokio::net::TcpStream::connect(address)).await {
|
||||||
|
Ok(Ok(_)) => Ok(()),
|
||||||
|
Ok(Err(err)) => Err(err.to_string()),
|
||||||
|
Err(_) => Err("no answer within 5 seconds".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tries each port on each address `hostname` resolves to.
|
||||||
|
pub async fn probe_host(hostname: &str, ports: &[u16]) -> Report {
|
||||||
|
let checked_at = now();
|
||||||
|
let addresses = match tokio::net::lookup_host((hostname, 0)).await {
|
||||||
|
Ok(found) => found.map(|a| a.ip()).collect::<BTreeSet<_>>(),
|
||||||
|
Err(err) => {
|
||||||
|
return Report {
|
||||||
|
checked_at,
|
||||||
|
probes: vec![],
|
||||||
|
error: Some(format!("{hostname} doesn't resolve: {err}")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let tries = addresses.iter().flat_map(|ip| {
|
||||||
|
ports.iter().map(move |port| {
|
||||||
|
let address = SocketAddr::new(*ip, *port);
|
||||||
|
async move {
|
||||||
|
let result = connect(address).await;
|
||||||
|
Probe {
|
||||||
|
port: *port,
|
||||||
|
address: ip.to_string(),
|
||||||
|
ok: result.is_ok(),
|
||||||
|
error: result.err(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
Report {
|
||||||
|
checked_at,
|
||||||
|
probes: futures::future::join_all(tries).await,
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn listeners(server: &Server) -> trc::Result<Vec<NetworkListener>> {
|
||||||
|
Ok(server
|
||||||
|
.registry()
|
||||||
|
.list::<NetworkListener>()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|l| l.object)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where to knock to see a port listening on this machine: the bound
|
||||||
|
/// address, or loopback of the same family for a wildcard bind.
|
||||||
|
pub fn local_targets<'x>(
|
||||||
|
listeners: impl IntoIterator<Item = &'x NetworkListener>,
|
||||||
|
) -> Vec<SocketAddr> {
|
||||||
|
listeners
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|l| l.bind.iter())
|
||||||
|
.map(|addr| addr.0)
|
||||||
|
.filter(|addr| !addr.ip().is_loopback())
|
||||||
|
.map(|addr| match addr.ip() {
|
||||||
|
IpAddr::V4(ip) if ip.is_unspecified() => {
|
||||||
|
SocketAddr::new(Ipv4Addr::LOCALHOST.into(), addr.port())
|
||||||
|
}
|
||||||
|
IpAddr::V6(ip) if ip.is_unspecified() => {
|
||||||
|
SocketAddr::new(Ipv6Addr::LOCALHOST.into(), addr.port())
|
||||||
|
}
|
||||||
|
_ => addr,
|
||||||
|
})
|
||||||
|
.collect::<BTreeSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One round: this node probes every other active node and records what it saw.
|
||||||
|
pub async fn probe_peers(server: &Server) -> trc::Result<()> {
|
||||||
|
let nodes = server.registry().cluster_node_list().await?;
|
||||||
|
let me = server.registry().node_id() as u64;
|
||||||
|
let Some(prober) = nodes
|
||||||
|
.iter()
|
||||||
|
.find(|n| n.node_id == me)
|
||||||
|
.map(|n| n.hostname.clone())
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let ports = public_ports(&listeners(server).await?);
|
||||||
|
for target in nodes.iter().filter(|n| {
|
||||||
|
n.node_id != me && n.status == ClusterNodeStatus::Active && n.hostname != prober
|
||||||
|
}) {
|
||||||
|
let report = probe_host(&target.hostname, &ports).await;
|
||||||
|
server
|
||||||
|
.in_memory_store()
|
||||||
|
.key_set(
|
||||||
|
KeyValue::with_prefix(
|
||||||
|
KV_PORT_REACHABILITY,
|
||||||
|
key(&target.hostname, &prober),
|
||||||
|
serde_json::to_vec(&report).unwrap_or_default(),
|
||||||
|
)
|
||||||
|
.expires(KEEP_FOR),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `GET /api/ports/check` answers.
|
||||||
|
pub async fn report(server: &Server) -> trc::Result<Value> {
|
||||||
|
let listeners = listeners(server).await?;
|
||||||
|
let ports = public_ports(&listeners);
|
||||||
|
let nodes = if server.core.storage.coordinator.is_enabled() {
|
||||||
|
server.registry().cluster_node_list().await?
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
};
|
||||||
|
let active = nodes
|
||||||
|
.iter()
|
||||||
|
.filter(|n| n.status == ClusterNodeStatus::Active)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
if active.len() < 2 {
|
||||||
|
// No one outside to ask: only whether each port is listening here.
|
||||||
|
let started = Instant::now();
|
||||||
|
let listening = futures::future::join_all(local_targets(&listeners).into_iter().map(
|
||||||
|
|address| async move {
|
||||||
|
let result = connect(address).await;
|
||||||
|
json!({ "port": address.port(), "address": address.ip().to_string(), "listening": result.is_ok() })
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
return Ok(json!({
|
||||||
|
"mode": "local",
|
||||||
|
"ports": ports,
|
||||||
|
"listening": listening,
|
||||||
|
"ms": started.elapsed().as_millis() as u64,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for target in &active {
|
||||||
|
let mut seen_by = Vec::new();
|
||||||
|
for prober in active.iter().filter(|p| p.node_id != target.node_id) {
|
||||||
|
let stored = server
|
||||||
|
.in_memory_store()
|
||||||
|
.key_get::<String>(KeyValue::<()>::build_key(
|
||||||
|
KV_PORT_REACHABILITY,
|
||||||
|
key(&target.hostname, &prober.hostname),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
let report = stored.and_then(|raw| serde_json::from_str::<Report>(&raw).ok());
|
||||||
|
seen_by.push(json!({ "prober": prober.hostname, "report": report }));
|
||||||
|
}
|
||||||
|
out.push(json!({ "hostname": target.hostname, "seenBy": seen_by }));
|
||||||
|
}
|
||||||
|
Ok(json!({
|
||||||
|
"mode": "cluster",
|
||||||
|
"ports": ports,
|
||||||
|
"intervalSeconds": PROBE_INTERVAL.as_secs(),
|
||||||
|
"nodes": out,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn listener(binds: &[&str]) -> NetworkListener {
|
||||||
|
NetworkListener {
|
||||||
|
bind: registry::schema::prelude::Map::new(
|
||||||
|
binds.iter().map(|b| b.parse().unwrap()).collect(),
|
||||||
|
),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_ports_leave_out_loopback_only_listeners() {
|
||||||
|
let listeners = [
|
||||||
|
listener(&["[::]:25"]),
|
||||||
|
listener(&["0.0.0.0:993", "[::]:993"]),
|
||||||
|
listener(&["127.0.0.1:8080"]),
|
||||||
|
listener(&["203.0.113.5:465"]),
|
||||||
|
];
|
||||||
|
assert_eq!(public_ports(listeners.iter()), vec![25, 465, 993]);
|
||||||
|
assert_eq!(
|
||||||
|
local_targets(listeners.iter())
|
||||||
|
.iter()
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["127.0.0.1:993", "203.0.113.5:465", "[::1]:25", "[::1]:993"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn probe_host_reports_open_and_closed_ports() {
|
||||||
|
let open = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let open_port = open.local_addr().unwrap().port();
|
||||||
|
let closed_port = {
|
||||||
|
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
l.local_addr().unwrap().port()
|
||||||
|
};
|
||||||
|
let report = probe_host("127.0.0.1", &[open_port, closed_port]).await;
|
||||||
|
assert_eq!(report.error, None);
|
||||||
|
let ok = |port| report.probes.iter().find(|p| p.port == port).unwrap().ok;
|
||||||
|
assert!(ok(open_port));
|
||||||
|
assert!(!ok(closed_port));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn probe_host_says_when_a_name_does_not_resolve() {
|
||||||
|
let report = probe_host("does-not-exist.invalid", &[25]).await;
|
||||||
|
assert!(report.probes.is_empty());
|
||||||
|
assert!(report.error.unwrap().contains("doesn't resolve"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -156,15 +156,7 @@ async fn post_webhook_events(
|
|||||||
|
|
||||||
// Add HMAC-SHA256 signature
|
// Add HMAC-SHA256 signature
|
||||||
let mut headers = settings.headers.clone();
|
let mut headers = settings.headers.clone();
|
||||||
if !settings.key.is_empty() {
|
sign(&mut headers, &settings.key, &body);
|
||||||
let key = hmac::Key::new(hmac::HMAC_SHA256, settings.key.as_bytes());
|
|
||||||
let tag = hmac::sign(&key, body.as_bytes());
|
|
||||||
|
|
||||||
headers.insert(
|
|
||||||
"X-Signature",
|
|
||||||
STANDARD.encode(tag.as_ref()).parse().unwrap(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send request
|
// Send request
|
||||||
let response = settings
|
let response = settings
|
||||||
@@ -188,3 +180,150 @@ async fn post_webhook_events(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adds the HMAC-SHA256 `X-Signature` a receiver checks, when the webhook has a key.
|
||||||
|
fn sign(headers: &mut hyper::HeaderMap, key: &str, body: &str) {
|
||||||
|
if !key.is_empty() {
|
||||||
|
let key = hmac::Key::new(hmac::HMAC_SHA256, key.as_bytes());
|
||||||
|
let tag = hmac::sign(&key, body.as_bytes());
|
||||||
|
|
||||||
|
headers.insert(
|
||||||
|
"X-Signature",
|
||||||
|
STANDARD.encode(tag.as_ref()).parse().unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: "Send test" for a saved webhook (settings-reorg, Webhooks). One
|
||||||
|
/// sample event, sent the way a real batch is: the same URL, headers, sign-in,
|
||||||
|
/// signature, timeout and certificate checks. The event's type,
|
||||||
|
/// `webhook.test`, is none the server raises, and an `X-Inbuxa-Test` header
|
||||||
|
/// marks it, so a receiver can tell it apart. Answers the HTTP status, or why
|
||||||
|
/// nothing came back.
|
||||||
|
pub async fn send_test(hook: ®istry::schema::structs::WebHook) -> Result<u16, String> {
|
||||||
|
let mut headers = hook
|
||||||
|
.http_auth
|
||||||
|
.build_headers(hook.http_headers.clone(), "application/json".into())
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Unable to build HTTP headers: {err}"))?;
|
||||||
|
let key = hook
|
||||||
|
.signature_key
|
||||||
|
.secret()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Unable to retrieve signature key: {err}"))?
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_owned();
|
||||||
|
|
||||||
|
let created = now();
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"events": [{
|
||||||
|
"id": format!("test-{created}"),
|
||||||
|
"createdAt": mail_parser::DateTime::from_timestamp(created as i64).to_rfc3339(),
|
||||||
|
"type": "webhook.test",
|
||||||
|
"data": { "details": "A test from inbuxa Admin. Nothing happened on the server." },
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
sign(&mut headers, &key, &body);
|
||||||
|
headers.insert("X-Inbuxa-Test", "true".parse().unwrap());
|
||||||
|
|
||||||
|
let response = utils::http::http_client_builder(hook.allow_invalid_certs)
|
||||||
|
.build()
|
||||||
|
.map_err(|err| format!("Unable to build an HTTP client: {err}"))?
|
||||||
|
.post(&hook.url)
|
||||||
|
.timeout(hook.timeout.into_inner())
|
||||||
|
.headers(headers)
|
||||||
|
.body(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Webhook request to {} failed: {err}", hook.url))?;
|
||||||
|
Ok(response.status().as_u16())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use registry::schema::structs::{SecretKeyOptional, SecretKeyValue, WebHook};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
|
/// One request in, the given status out; hands back what was received.
|
||||||
|
async fn receiver(status: &'static str) -> (String, tokio::task::JoinHandle<String>) {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let url = format!("http://{}/hook", listener.local_addr().unwrap());
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
let (mut socket, _) = listener.accept().await.unwrap();
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut chunk = [0u8; 4096];
|
||||||
|
loop {
|
||||||
|
let n = socket.read(&mut chunk).await.unwrap();
|
||||||
|
buf.extend_from_slice(&chunk[..n]);
|
||||||
|
let text = String::from_utf8_lossy(&buf);
|
||||||
|
if let Some(end) = text.find("\r\n\r\n") {
|
||||||
|
let length = text[..end]
|
||||||
|
.lines()
|
||||||
|
.find_map(|l| {
|
||||||
|
l.to_ascii_lowercase()
|
||||||
|
.strip_prefix("content-length:")
|
||||||
|
.map(|v| v.trim().parse::<usize>().unwrap())
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
if buf.len() >= end + 4 + length || n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
socket
|
||||||
|
.write_all(
|
||||||
|
format!("HTTP/1.1 {status}\r\ncontent-length: 0\r\nconnection: close\r\n\r\n")
|
||||||
|
.as_bytes(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
String::from_utf8_lossy(&buf).into_owned()
|
||||||
|
});
|
||||||
|
(url, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn send_test_signs_and_marks_the_sample() {
|
||||||
|
let (url, task) = receiver("204 No Content").await;
|
||||||
|
let hook = WebHook {
|
||||||
|
url,
|
||||||
|
enable: false,
|
||||||
|
signature_key: SecretKeyOptional::Value(SecretKeyValue { secret: "k".into() }),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(send_test(&hook).await, Ok(204));
|
||||||
|
|
||||||
|
let request = task.await.unwrap();
|
||||||
|
let (head, body) = request.split_once("\r\n\r\n").unwrap();
|
||||||
|
let head = head.to_ascii_lowercase();
|
||||||
|
assert!(head.contains("x-inbuxa-test: true"), "{head}");
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(body).unwrap();
|
||||||
|
assert_eq!(parsed["events"][0]["type"], "webhook.test");
|
||||||
|
let tag = hmac::sign(&hmac::Key::new(hmac::HMAC_SHA256, b"k"), body.as_bytes());
|
||||||
|
assert!(
|
||||||
|
head.contains(&format!(
|
||||||
|
"x-signature: {}",
|
||||||
|
STANDARD.encode(tag.as_ref()).to_ascii_lowercase()
|
||||||
|
)),
|
||||||
|
"{head}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn send_test_reports_what_came_back() {
|
||||||
|
let (url, _task) = receiver("403 Forbidden").await;
|
||||||
|
let hook = WebHook {
|
||||||
|
url,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(send_test(&hook).await, Ok(403));
|
||||||
|
|
||||||
|
let hook = WebHook {
|
||||||
|
url: "http://127.0.0.1:9/hook".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(send_test(&hook).await.unwrap_err().contains("failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "coordinator"
|
name = "coordinator"
|
||||||
version = "0.16.23"
|
version = "0.16.24"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "dav-proto"
|
name = "dav-proto"
|
||||||
version = "0.16.23"
|
version = "0.16.24"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "dav"
|
name = "dav"
|
||||||
version = "0.16.23"
|
version = "0.16.24"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "directory"
|
name = "directory"
|
||||||
version = "0.16.23"
|
version = "0.16.24"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "email"
|
name = "email"
|
||||||
version = "0.16.23"
|
version = "0.16.24"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -15,11 +15,19 @@ utils = { path = "../utils" }
|
|||||||
ahash = { version = "0.8.12", features = ["serde"] }
|
ahash = { version = "0.8.12", features = ["serde"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
toml = "1.1"
|
||||||
xxhash-rust = { version = "0.8.18", features = ["xxh3"] }
|
xxhash-rust = { version = "0.8.18", features = ["xxh3"] }
|
||||||
base64 = "0.23"
|
base64 = "0.23"
|
||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
flate2 = "1.1"
|
flate2 = "1.1"
|
||||||
tokio = { version = "1.53", features = ["sync", "rt"] }
|
tokio = { version = "1.53", features = ["sync", "rt"] }
|
||||||
|
# inbuxa: DLP detectors and attachment text (dlp-and-mail-flow-rules spec)
|
||||||
|
regex = "1.13.1"
|
||||||
|
aho-corasick = "1.1"
|
||||||
|
zip = "8.6"
|
||||||
|
quick-xml = "0.41"
|
||||||
|
mail-parser = { version = "0.11", features = ["full_encoding"] }
|
||||||
|
mail-builder = { version = "1.0" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1.53", features = ["macros", "rt"] }
|
tokio = { version = "1.53", features = ["macros", "rt"] }
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Reports on their way to an outside archive (JR-7). Keys, after `J`:
|
||||||
|
//!
|
||||||
|
//! - `o` + the report's queue id: what goes into the built-in journal if
|
||||||
|
//! the archive never takes the report, as JSON. Cleared once it's
|
||||||
|
//! delivered or kept.
|
||||||
|
//! - `w` + journal id (u32): how often that journal's archive didn't take a
|
||||||
|
//! report, and the last time and reason, for the console's warning.
|
||||||
|
|
||||||
|
use super::{FEATURE, Json, entries::Entry};
|
||||||
|
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
|
||||||
|
use store::{
|
||||||
|
SUBSPACE_INBUXA, Serialize, Store, ValueKey,
|
||||||
|
write::{AnyClass, BatchBuilder, ValueClass},
|
||||||
|
};
|
||||||
|
use trc::AddContext;
|
||||||
|
|
||||||
|
const KIND_PENDING: u8 = b'o';
|
||||||
|
const KIND_FAILURES: u8 = b'w';
|
||||||
|
|
||||||
|
/// A report queued to an archive.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Pending {
|
||||||
|
pub address: String,
|
||||||
|
/// The entry, should the archive not take it: its own, with the
|
||||||
|
/// sending journals' retention, whatever else the built-in journal has.
|
||||||
|
pub entry: Entry,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a journal's archive has been taking its reports.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Failures {
|
||||||
|
pub count: u64,
|
||||||
|
/// Seconds.
|
||||||
|
pub last_at: u64,
|
||||||
|
pub last_reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn class(kind: u8, id: &[u8]) -> ValueClass {
|
||||||
|
let mut key = Vec::with_capacity(2 + id.len());
|
||||||
|
key.push(FEATURE);
|
||||||
|
key.push(kind);
|
||||||
|
key.extend_from_slice(id);
|
||||||
|
ValueClass::Any(AnyClass {
|
||||||
|
subspace: SUBSPACE_INBUXA,
|
||||||
|
key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_pending(data: &Store, queue_id: u64, pending: &Pending) -> trc::Result<()> {
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.set(
|
||||||
|
class(KIND_PENDING, &queue_id.to_be_bytes()),
|
||||||
|
Json(pending).serialize()?,
|
||||||
|
);
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pending(data: &Store, queue_id: u64) -> trc::Result<Option<Pending>> {
|
||||||
|
Ok(data
|
||||||
|
.get_value::<Json<Pending>>(ValueKey::from(class(KIND_PENDING, &queue_id.to_be_bytes())))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.map(|Json(pending)| pending))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn clear_pending(data: &Store, queue_id: u64) -> trc::Result<()> {
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.clear(class(KIND_PENDING, &queue_id.to_be_bytes()));
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn failures(data: &Store, journal_id: u32) -> trc::Result<Failures> {
|
||||||
|
Ok(data
|
||||||
|
.get_value::<Json<Failures>>(ValueKey::from(class(
|
||||||
|
KIND_FAILURES,
|
||||||
|
&journal_id.to_be_bytes(),
|
||||||
|
)))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.map(|Json(failures)| failures)
|
||||||
|
.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counts one report an archive didn't take, for each of `journals`.
|
||||||
|
pub async fn record_failure(
|
||||||
|
data: &Store,
|
||||||
|
journals: &[u32],
|
||||||
|
at: u64,
|
||||||
|
reason: &str,
|
||||||
|
) -> trc::Result<()> {
|
||||||
|
for journal_id in journals {
|
||||||
|
let mut failures = failures(data, *journal_id).await?;
|
||||||
|
failures.count += 1;
|
||||||
|
failures.last_at = at;
|
||||||
|
failures.last_reason = reason.chars().take(500).collect();
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.set(
|
||||||
|
class(KIND_FAILURES, &journal_id.to_be_bytes()),
|
||||||
|
Json(&failures).serialize()?,
|
||||||
|
);
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,869 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! The built-in journal (JR-5, JR-6, JR-13). Keys, after `J`:
|
||||||
|
//!
|
||||||
|
//! - `e` + node + seq: a chain link: its seq, the hash of the link before
|
||||||
|
//! it, and the SHA-256 of its entry. One chain per node, as the audit log
|
||||||
|
//! keeps (AU-6), but a link names its entry by hash instead of holding it,
|
||||||
|
//! so an entry can go at the end of its own retention without breaking
|
||||||
|
//! the chain: entries don't expire in chain order.
|
||||||
|
//! - `c` + node + seq: the entry, as JSON; its bytes are what the link's
|
||||||
|
//! hash names.
|
||||||
|
//! - `p` + node + seq: when an entry past its retention was purged. A link
|
||||||
|
//! whose entry is gone without this marker is a broken chain.
|
||||||
|
//! - `t` + time + node + seq: the time index, for search.
|
||||||
|
//! - `x` + expiry + node + seq: the expiry index, for purge.
|
||||||
|
//! - `h` + node: the chain's head: its hash, then its seq as the last eight
|
||||||
|
//! bytes, which each append asserts.
|
||||||
|
//! - `f` + node: where the chain starts after purged links at its start
|
||||||
|
//! were cleared, and the hash the first kept link names.
|
||||||
|
//!
|
||||||
|
//! The report itself is a blob, kept by a temporary link that lasts until
|
||||||
|
//! its entry is purged. Nothing here changes or removes an entry before
|
||||||
|
//! its time; nothing in JMAP can.
|
||||||
|
|
||||||
|
use super::{Direction, FEATURE, Json};
|
||||||
|
use crate::hold::HELD_UNTIL;
|
||||||
|
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::fmt;
|
||||||
|
use store::{
|
||||||
|
BlobStore, Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey,
|
||||||
|
write::{AnyClass, BatchBuilder, BlobLink, BlobOp, ValueClass, assert::AssertValue},
|
||||||
|
};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use trc::AddContext;
|
||||||
|
use types::blob_hash::BlobHash;
|
||||||
|
|
||||||
|
const KIND_LINK: u8 = b'e';
|
||||||
|
const KIND_CONTENT: u8 = b'c';
|
||||||
|
const KIND_PURGED: u8 = b'p';
|
||||||
|
const KIND_TIME: u8 = b't';
|
||||||
|
const KIND_EXPIRY: u8 = b'x';
|
||||||
|
const KIND_HEAD: u8 = b'h';
|
||||||
|
const KIND_FLOOR: u8 = b'f';
|
||||||
|
|
||||||
|
const APPEND_ATTEMPTS: usize = 5;
|
||||||
|
/// Entries purged per batch.
|
||||||
|
const PURGE_BATCH: usize = 100;
|
||||||
|
|
||||||
|
/// Where one entry sits: its node's chain and its place in it.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
|
pub struct EntryId {
|
||||||
|
pub node: u64,
|
||||||
|
pub seq: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EntryId {
|
||||||
|
/// As one number, for JMAP ids: the node in the top 16 bits.
|
||||||
|
pub fn to_u64(&self) -> u64 {
|
||||||
|
(self.node << 48) | (self.seq & ((1 << 48) - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_u64(id: u64) -> Self {
|
||||||
|
EntryId {
|
||||||
|
node: id >> 48,
|
||||||
|
seq: id & ((1 << 48) - 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for EntryId {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}-{}", self.node, self.seq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One journaled message (JR-5).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Entry {
|
||||||
|
pub queue_id: u64,
|
||||||
|
/// Seconds.
|
||||||
|
pub at: u64,
|
||||||
|
pub direction: Direction,
|
||||||
|
pub sender: String,
|
||||||
|
pub authenticated: bool,
|
||||||
|
pub recipients: Vec<String>,
|
||||||
|
pub subject: String,
|
||||||
|
pub message_id: String,
|
||||||
|
/// The people here on either side, whose holds keep the entry.
|
||||||
|
pub accounts: Vec<u32>,
|
||||||
|
pub tenants: Vec<u32>,
|
||||||
|
/// The journals that took it.
|
||||||
|
pub journals: Vec<u32>,
|
||||||
|
pub held: bool,
|
||||||
|
/// The report's blob, hex.
|
||||||
|
pub blob: String,
|
||||||
|
pub size: u64,
|
||||||
|
/// SHA-256 of the report, hex.
|
||||||
|
pub sha256: String,
|
||||||
|
/// Seconds.
|
||||||
|
pub expires_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Entry {
|
||||||
|
pub fn blob_hash(&self) -> Option<BlobHash> {
|
||||||
|
let bytes = unhex(&self.blob)?;
|
||||||
|
BlobHash::try_from_hash_slice(&bytes).ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct Link {
|
||||||
|
seq: u64,
|
||||||
|
prev: String,
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
struct Floor {
|
||||||
|
seq: u64,
|
||||||
|
prev: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
|
struct Head {
|
||||||
|
seq: u64,
|
||||||
|
hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Head {
|
||||||
|
fn to_bytes(&self) -> Vec<u8> {
|
||||||
|
let mut bytes = self.hash.as_bytes().to_vec();
|
||||||
|
bytes.extend_from_slice(&self.seq.to_be_bytes());
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deserialize for Head {
|
||||||
|
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||||
|
let split = bytes.len().checked_sub(8).ok_or_else(|| {
|
||||||
|
trc::StoreEvent::DataCorruption
|
||||||
|
.into_err()
|
||||||
|
.details("Invalid journal chain head")
|
||||||
|
})?;
|
||||||
|
Ok(Head {
|
||||||
|
seq: u64::from_be_bytes(bytes[split..].try_into().unwrap()),
|
||||||
|
hash: String::from_utf8_lossy(&bytes[..split]).into_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Raw(Vec<u8>);
|
||||||
|
|
||||||
|
impl Deserialize for Raw {
|
||||||
|
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||||
|
Ok(Raw(bytes.to_vec()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn class(kind: u8, parts: &[u64]) -> ValueClass {
|
||||||
|
let mut key = Vec::with_capacity(2 + parts.len() * 8);
|
||||||
|
key.push(FEATURE);
|
||||||
|
key.push(kind);
|
||||||
|
for part in parts {
|
||||||
|
key.extend_from_slice(&part.to_be_bytes());
|
||||||
|
}
|
||||||
|
ValueClass::Any(AnyClass {
|
||||||
|
subspace: SUBSPACE_INBUXA,
|
||||||
|
key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(kind: u8, parts: &[u64]) -> ValueKey<ValueClass> {
|
||||||
|
ValueKey::from(class(kind, parts))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where an entry's content is kept, for tests that check tampering shows.
|
||||||
|
pub fn content_key(id: EntryId) -> ValueKey<ValueClass> {
|
||||||
|
key(KIND_CONTENT, &[id.node, id.seq])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The numbers after the kind byte, from the key's tail.
|
||||||
|
fn parse_key(key: &[u8], kind: u8, parts: usize) -> Option<Vec<u64>> {
|
||||||
|
let len = 2 + parts * 8;
|
||||||
|
let tail = key.get(key.len().checked_sub(len)?..)?;
|
||||||
|
(tail[0] == FEATURE && tail[1] == kind).then_some(())?;
|
||||||
|
Some(
|
||||||
|
tail[2..]
|
||||||
|
.chunks_exact(8)
|
||||||
|
.map(|chunk| u64::from_be_bytes(chunk.try_into().unwrap()))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hex(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unhex(value: &str) -> Option<Vec<u8>> {
|
||||||
|
(value.len() % 2 == 0).then_some(())?;
|
||||||
|
(0..value.len())
|
||||||
|
.step_by(2)
|
||||||
|
.map(|i| u8::from_str_radix(value.get(i..i + 2)?, 16).ok())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sha256(bytes: &[u8]) -> String {
|
||||||
|
hex(&Sha256::digest(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn head(data: &Store, node: u64) -> trc::Result<Option<Head>> {
|
||||||
|
data.get_value::<Head>(key(KIND_HEAD, &[node]))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn floor(data: &Store, node: u64) -> trc::Result<Floor> {
|
||||||
|
Ok(data
|
||||||
|
.get_value::<Json<Floor>>(key(KIND_FLOOR, &[node]))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.map(|Json(floor)| floor)
|
||||||
|
.unwrap_or(Floor {
|
||||||
|
seq: 1,
|
||||||
|
prev: String::new(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn nodes(data: &Store) -> trc::Result<Vec<u64>> {
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
data.iterate(
|
||||||
|
IterateParams::new(key(KIND_HEAD, &[0]), key(KIND_HEAD, &[u64::MAX])).no_values(),
|
||||||
|
|key, _| {
|
||||||
|
if let Some(parts) = parse_key(key, KIND_HEAD, 1) {
|
||||||
|
nodes.push(parts[0]);
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
Ok(nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lines up this process's appends; the store's assert settles the rest.
|
||||||
|
static APPENDING: Mutex<()> = Mutex::const_new(());
|
||||||
|
|
||||||
|
/// Adds an entry to this node's chain, and links its report's blob (already
|
||||||
|
/// written) until the entry is purged. An error means nothing was written.
|
||||||
|
pub async fn append(data: &Store, node: u64, entry: &Entry) -> trc::Result<EntryId> {
|
||||||
|
let blob = entry.blob_hash().ok_or_else(|| {
|
||||||
|
trc::StoreEvent::UnexpectedError
|
||||||
|
.into_err()
|
||||||
|
.details("Journal entry without a blob")
|
||||||
|
})?;
|
||||||
|
let content = Json(entry).serialize()?;
|
||||||
|
let content_hash = sha256(&content);
|
||||||
|
let _appending = APPENDING.lock().await;
|
||||||
|
let mut attempt = 0;
|
||||||
|
loop {
|
||||||
|
attempt += 1;
|
||||||
|
let current = head(data, node).await?;
|
||||||
|
let (seq, prev) = current
|
||||||
|
.as_ref()
|
||||||
|
.map_or((1, String::new()), |head| (head.seq + 1, head.hash.clone()));
|
||||||
|
let link = Json(&Link {
|
||||||
|
seq,
|
||||||
|
prev,
|
||||||
|
content: content_hash.clone(),
|
||||||
|
})
|
||||||
|
.serialize()?;
|
||||||
|
let new_head = Head {
|
||||||
|
seq,
|
||||||
|
hash: sha256(&link),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.assert_value(
|
||||||
|
class(KIND_HEAD, &[node]),
|
||||||
|
current.map_or(AssertValue::None, |head| AssertValue::U64(head.seq)),
|
||||||
|
);
|
||||||
|
batch
|
||||||
|
.set(class(KIND_LINK, &[node, seq]), link)
|
||||||
|
.set(class(KIND_CONTENT, &[node, seq]), content.clone())
|
||||||
|
.set(class(KIND_TIME, &[entry.at, node, seq]), vec![])
|
||||||
|
.set(class(KIND_EXPIRY, &[entry.expires_at, node, seq]), vec![])
|
||||||
|
.set(class(KIND_HEAD, &[node]), new_head.to_bytes())
|
||||||
|
.set(
|
||||||
|
BlobOp::Link {
|
||||||
|
hash: blob.clone(),
|
||||||
|
to: BlobLink::Temporary { until: HELD_UNTIL },
|
||||||
|
},
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
|
.set(BlobOp::Commit { hash: blob.clone() }, vec![]);
|
||||||
|
match data.write(batch.build_all()).await {
|
||||||
|
Ok(_) => return Ok(EntryId { node, seq }),
|
||||||
|
Err(err)
|
||||||
|
if attempt < APPEND_ATTEMPTS
|
||||||
|
&& matches!(
|
||||||
|
err.as_ref(),
|
||||||
|
trc::EventType::Store(trc::StoreEvent::AssertValueFailed)
|
||||||
|
) => {}
|
||||||
|
Err(err) => return Err(err.caused_by(trc::location!())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry, unless it was purged.
|
||||||
|
pub async fn get(data: &Store, id: EntryId) -> trc::Result<Option<Entry>> {
|
||||||
|
Ok(data
|
||||||
|
.get_value::<Json<Entry>>(key(KIND_CONTENT, &[id.node, id.seq]))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.map(|Json(entry)| entry))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entries written in `[after, before)` (seconds), newest first, up to
|
||||||
|
/// `limit`.
|
||||||
|
pub async fn list(
|
||||||
|
data: &Store,
|
||||||
|
after: u64,
|
||||||
|
before: u64,
|
||||||
|
limit: usize,
|
||||||
|
) -> trc::Result<Vec<(EntryId, Entry)>> {
|
||||||
|
let mut ids = Vec::new();
|
||||||
|
data.iterate(
|
||||||
|
IterateParams::new(
|
||||||
|
key(KIND_TIME, &[after, 0, 0]),
|
||||||
|
key(KIND_TIME, &[before.saturating_sub(1), u64::MAX, u64::MAX]),
|
||||||
|
)
|
||||||
|
.descending()
|
||||||
|
.no_values(),
|
||||||
|
|key, _| {
|
||||||
|
if let Some(parts) = parse_key(key, KIND_TIME, 3) {
|
||||||
|
ids.push(EntryId {
|
||||||
|
node: parts[1],
|
||||||
|
seq: parts[2],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(ids.len() < limit)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
let mut out = Vec::with_capacity(ids.len());
|
||||||
|
for id in ids {
|
||||||
|
if let Some(entry) = get(data, id).await? {
|
||||||
|
out.push((id, entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Most results one search page returns.
|
||||||
|
pub const MAX_QUERY_LIMIT: usize = 500;
|
||||||
|
|
||||||
|
/// A search of the journal (JR-15): conditions that must all hold.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, SerdeSerialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Filter {
|
||||||
|
/// From this time on, in seconds.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub after: Option<u64>,
|
||||||
|
/// Before this time, in seconds.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub before: Option<u64>,
|
||||||
|
/// Part of the sender's address, ignoring case.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sender: Option<String>,
|
||||||
|
/// Part of any recipient's address, ignoring case.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub recipient: Option<String>,
|
||||||
|
/// Part of the sender's or any recipient's address.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub address: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub direction: Option<Direction>,
|
||||||
|
/// Words that must all appear in the subject, ignoring case.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub text: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub message_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub journal_id: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Filter {
|
||||||
|
pub fn matches(&self, entry: &Entry) -> bool {
|
||||||
|
let has = |value: &str, part: &str| value.to_lowercase().contains(&part.to_lowercase());
|
||||||
|
self.after.is_none_or(|after| entry.at >= after)
|
||||||
|
&& self.before.is_none_or(|before| entry.at < before)
|
||||||
|
&& self.sender.as_deref().is_none_or(|s| has(&entry.sender, s))
|
||||||
|
&& self
|
||||||
|
.recipient
|
||||||
|
.as_deref()
|
||||||
|
.is_none_or(|r| entry.recipients.iter().any(|a| has(a, r)))
|
||||||
|
&& self
|
||||||
|
.address
|
||||||
|
.as_deref()
|
||||||
|
.is_none_or(|a| has(&entry.sender, a) || entry.recipients.iter().any(|r| has(r, a)))
|
||||||
|
&& self
|
||||||
|
.direction
|
||||||
|
.is_none_or(|d| d == Direction::Any || d == entry.direction)
|
||||||
|
&& self.text.as_deref().is_none_or(|text| {
|
||||||
|
let subject = entry.subject.to_lowercase();
|
||||||
|
text.to_lowercase()
|
||||||
|
.split_whitespace()
|
||||||
|
.all(|word| subject.contains(word))
|
||||||
|
})
|
||||||
|
&& self.message_id.as_deref().is_none_or(|id| {
|
||||||
|
entry.message_id.trim_matches(['<', '>']) == id.trim_matches(['<', '>'])
|
||||||
|
})
|
||||||
|
&& self.journal_id.is_none_or(|j| entry.journals.contains(&j))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entries matching `filter`, newest first: a page from `position`, up to
|
||||||
|
/// `limit`, and, when asked, how many match in all.
|
||||||
|
pub async fn query(
|
||||||
|
data: &Store,
|
||||||
|
filter: &Filter,
|
||||||
|
position: usize,
|
||||||
|
limit: usize,
|
||||||
|
count_all: bool,
|
||||||
|
) -> trc::Result<(Vec<EntryId>, usize)> {
|
||||||
|
let after = filter.after.unwrap_or(0);
|
||||||
|
let before = filter.before.unwrap_or(u64::MAX);
|
||||||
|
let mut ids = Vec::new();
|
||||||
|
data.iterate(
|
||||||
|
IterateParams::new(
|
||||||
|
key(KIND_TIME, &[after, 0, 0]),
|
||||||
|
key(KIND_TIME, &[before.saturating_sub(1), u64::MAX, u64::MAX]),
|
||||||
|
)
|
||||||
|
.descending()
|
||||||
|
.no_values(),
|
||||||
|
|key, _| {
|
||||||
|
if let Some(parts) = parse_key(key, KIND_TIME, 3) {
|
||||||
|
ids.push(EntryId {
|
||||||
|
node: parts[1],
|
||||||
|
seq: parts[2],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
let mut page = Vec::new();
|
||||||
|
let mut total = 0;
|
||||||
|
for id in ids {
|
||||||
|
let Some(entry) = get(data, id).await? else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !filter.matches(&entry) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if total >= position && page.len() < limit {
|
||||||
|
page.push(id);
|
||||||
|
}
|
||||||
|
total += 1;
|
||||||
|
if !count_all && page.len() >= limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((page, total))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a purge did.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct Purged {
|
||||||
|
pub removed: usize,
|
||||||
|
/// Past their time, kept for a legal hold.
|
||||||
|
pub kept_for_hold: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes entries past their retention (JR-13), except those `held` keeps:
|
||||||
|
/// the entry, its indexes and its blob's link go; the chain link stays,
|
||||||
|
/// with a purge marker. Then each chain's start moves past purged links.
|
||||||
|
pub async fn purge(
|
||||||
|
data: &Store,
|
||||||
|
now: u64,
|
||||||
|
held: impl Fn(&Entry) -> bool + Sync + Send,
|
||||||
|
) -> trc::Result<Purged> {
|
||||||
|
let mut due = Vec::new();
|
||||||
|
data.iterate(
|
||||||
|
IterateParams::new(
|
||||||
|
key(KIND_EXPIRY, &[0, 0, 0]),
|
||||||
|
key(KIND_EXPIRY, &[now, u64::MAX, u64::MAX]),
|
||||||
|
)
|
||||||
|
.ascending()
|
||||||
|
.no_values(),
|
||||||
|
|key, _| {
|
||||||
|
if let Some(parts) = parse_key(key, KIND_EXPIRY, 3) {
|
||||||
|
due.push((
|
||||||
|
parts[0],
|
||||||
|
EntryId {
|
||||||
|
node: parts[1],
|
||||||
|
seq: parts[2],
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(due.len() < 100_000)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
|
||||||
|
let mut purged = Purged::default();
|
||||||
|
for chunk in due.chunks(PURGE_BATCH) {
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
for (expires_at, id) in chunk {
|
||||||
|
let parts = [id.node, id.seq];
|
||||||
|
let Some(entry) = get(data, *id).await? else {
|
||||||
|
// Its entry is already gone: only the index is left
|
||||||
|
batch.clear(class(KIND_EXPIRY, &[*expires_at, id.node, id.seq]));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if held(&entry) {
|
||||||
|
purged.kept_for_hold += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
batch
|
||||||
|
.clear(class(KIND_CONTENT, &parts))
|
||||||
|
.clear(class(KIND_TIME, &[entry.at, id.node, id.seq]))
|
||||||
|
.clear(class(KIND_EXPIRY, &[*expires_at, id.node, id.seq]))
|
||||||
|
.set(class(KIND_PURGED, &parts), now.to_be_bytes().to_vec());
|
||||||
|
if let Some(blob) = entry.blob_hash() {
|
||||||
|
batch.clear(BlobOp::Link {
|
||||||
|
hash: blob,
|
||||||
|
to: BlobLink::Temporary { until: HELD_UNTIL },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
purged.removed += 1;
|
||||||
|
}
|
||||||
|
if !batch.is_empty() {
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for node in nodes(data).await? {
|
||||||
|
advance_floor(data, node).await?;
|
||||||
|
}
|
||||||
|
Ok(purged)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears the purged links at the start of a node's chain, recording where
|
||||||
|
/// it now starts and the hash that start names.
|
||||||
|
async fn advance_floor(data: &Store, node: u64) -> trc::Result<()> {
|
||||||
|
let start = floor(data, node).await?;
|
||||||
|
let mut cleared: Vec<u64> = Vec::new();
|
||||||
|
let mut next = start.clone();
|
||||||
|
let mut purged_seqs = Vec::new();
|
||||||
|
data.iterate(
|
||||||
|
IterateParams::new(
|
||||||
|
key(KIND_PURGED, &[node, start.seq]),
|
||||||
|
key(KIND_PURGED, &[node, u64::MAX]),
|
||||||
|
)
|
||||||
|
.ascending()
|
||||||
|
.no_values(),
|
||||||
|
|key, _| {
|
||||||
|
if let Some(parts) = parse_key(key, KIND_PURGED, 2) {
|
||||||
|
purged_seqs.push(parts[1]);
|
||||||
|
}
|
||||||
|
Ok(purged_seqs.len() < 100_000)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
for seq in purged_seqs {
|
||||||
|
if seq != next.seq {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let Some(Raw(link)) = data
|
||||||
|
.get_value::<Raw>(key(KIND_LINK, &[node, seq]))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
next = Floor {
|
||||||
|
seq: seq + 1,
|
||||||
|
prev: sha256(&link),
|
||||||
|
};
|
||||||
|
cleared.push(seq);
|
||||||
|
}
|
||||||
|
if cleared.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// The floor moves first: a run cut short leaves links before it, which
|
||||||
|
// the next run clears, never a chain that looks broken
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.set(class(KIND_FLOOR, &[node]), Json(&next).serialize()?);
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
for chunk in cleared.chunks(PURGE_BATCH) {
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
for seq in chunk {
|
||||||
|
batch
|
||||||
|
.clear(class(KIND_LINK, &[node, *seq]))
|
||||||
|
.clear(class(KIND_PURGED, &[node, *seq]));
|
||||||
|
}
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One node's chain, as [`verify`] found it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ChainReport {
|
||||||
|
pub node: u64,
|
||||||
|
pub entries: u64,
|
||||||
|
pub purged: u64,
|
||||||
|
pub first_seq: u64,
|
||||||
|
pub last_seq: u64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub broken_at: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rechecks every node's chain (JR-6): each link names the hash of the one
|
||||||
|
/// before it, seqs run without gaps, the head matches the last link, each
|
||||||
|
/// entry hashes to what its link names or was purged, and, with `blobs`,
|
||||||
|
/// each report is there and hashes to what its entry names.
|
||||||
|
pub async fn verify(data: &Store, blobs: Option<&BlobStore>) -> trc::Result<Vec<ChainReport>> {
|
||||||
|
let mut reports = Vec::new();
|
||||||
|
for node in nodes(data).await? {
|
||||||
|
let start = floor(data, node).await?;
|
||||||
|
let head = head(data, node).await?.unwrap_or_default();
|
||||||
|
let mut report = ChainReport {
|
||||||
|
node,
|
||||||
|
entries: 0,
|
||||||
|
purged: 0,
|
||||||
|
first_seq: start.seq,
|
||||||
|
last_seq: start.seq.saturating_sub(1),
|
||||||
|
broken_at: None,
|
||||||
|
reason: None,
|
||||||
|
};
|
||||||
|
let mut links = Vec::new();
|
||||||
|
data.iterate(
|
||||||
|
IterateParams::new(
|
||||||
|
key(KIND_LINK, &[node, start.seq]),
|
||||||
|
key(KIND_LINK, &[node, u64::MAX]),
|
||||||
|
)
|
||||||
|
.ascending(),
|
||||||
|
|key, value| {
|
||||||
|
if let Some(parts) = parse_key(key, KIND_LINK, 2) {
|
||||||
|
links.push((parts[1], value.to_vec()));
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
|
||||||
|
let mut expected_seq = start.seq;
|
||||||
|
let mut expected_prev = start.prev.clone();
|
||||||
|
for (seq, bytes) in links {
|
||||||
|
let broken = |report: &mut ChainReport, reason: &str| {
|
||||||
|
report.broken_at = Some(EntryId { node, seq }.to_string());
|
||||||
|
report.reason = Some(reason.to_string());
|
||||||
|
};
|
||||||
|
let Ok(Json(link)) = Json::<Link>::deserialize(&bytes) else {
|
||||||
|
broken(&mut report, "The link can't be read.");
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if seq != expected_seq || link.seq != seq {
|
||||||
|
report.broken_at = Some(EntryId { node, seq }.to_string());
|
||||||
|
report.reason = Some(format!(
|
||||||
|
"Entry {expected_seq} is missing; the next one found is {seq}."
|
||||||
|
));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if link.prev != expected_prev {
|
||||||
|
broken(
|
||||||
|
&mut report,
|
||||||
|
"The link doesn't follow from the one before it: one of them was changed.",
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match data
|
||||||
|
.get_value::<Raw>(key(KIND_CONTENT, &[node, seq]))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
{
|
||||||
|
Some(Raw(content)) => {
|
||||||
|
if sha256(&content) != link.content {
|
||||||
|
broken(&mut report, "The entry was changed after it was written.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Some(blobs) = blobs {
|
||||||
|
let Ok(Json(entry)) = Json::<Entry>::deserialize(&content) else {
|
||||||
|
broken(&mut report, "The entry can't be read.");
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let report_bytes = match entry.blob_hash() {
|
||||||
|
Some(hash) => blobs
|
||||||
|
.get_blob(hash.as_slice(), 0..usize::MAX)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
match report_bytes {
|
||||||
|
Some(bytes) if sha256(&bytes) == entry.sha256 => {}
|
||||||
|
Some(_) => {
|
||||||
|
broken(&mut report, "The report doesn't match its entry.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
broken(&mut report, "The report is missing.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report.entries += 1;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if data
|
||||||
|
.get_value::<Raw>(key(KIND_PURGED, &[node, seq]))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
broken(&mut report, "The entry was removed before its time.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
report.purged += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expected_prev = sha256(&bytes);
|
||||||
|
expected_seq = seq + 1;
|
||||||
|
report.last_seq = seq;
|
||||||
|
}
|
||||||
|
|
||||||
|
if report.broken_at.is_none()
|
||||||
|
&& (head.seq != report.last_seq
|
||||||
|
|| (report.last_seq >= report.first_seq && head.hash != expected_prev))
|
||||||
|
{
|
||||||
|
report.broken_at = Some(
|
||||||
|
EntryId {
|
||||||
|
node,
|
||||||
|
seq: report.last_seq,
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
report.reason = Some(
|
||||||
|
"The chain's recorded end doesn't match its last link: entries were removed \
|
||||||
|
or changed at the end."
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
reports.push(report);
|
||||||
|
}
|
||||||
|
Ok(reports)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keys_read_back() {
|
||||||
|
let ValueClass::Any(any) = class(KIND_EXPIRY, &[5, 3, 9]) else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
assert_eq!(parse_key(&any.key, KIND_EXPIRY, 3), Some(vec![5, 3, 9]));
|
||||||
|
let mut with_subspace = vec![SUBSPACE_INBUXA];
|
||||||
|
with_subspace.extend_from_slice(&any.key);
|
||||||
|
assert_eq!(
|
||||||
|
parse_key(&with_subspace, KIND_EXPIRY, 3),
|
||||||
|
Some(vec![5, 3, 9])
|
||||||
|
);
|
||||||
|
assert_eq!(parse_key(&any.key, KIND_TIME, 3), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filters_match() {
|
||||||
|
let entry = Entry {
|
||||||
|
queue_id: 1,
|
||||||
|
at: 100,
|
||||||
|
direction: Direction::Outgoing,
|
||||||
|
sender: "[email protected]".into(),
|
||||||
|
authenticated: true,
|
||||||
|
recipients: vec!["[email protected]".into()],
|
||||||
|
subject: "Q3 figures, final".into(),
|
||||||
|
message_id: "<[email protected]>".into(),
|
||||||
|
accounts: vec![3],
|
||||||
|
tenants: vec![],
|
||||||
|
journals: vec![2],
|
||||||
|
held: false,
|
||||||
|
blob: String::new(),
|
||||||
|
size: 0,
|
||||||
|
sha256: String::new(),
|
||||||
|
expires_at: 0,
|
||||||
|
};
|
||||||
|
let yes = |f: Filter| assert!(f.matches(&entry), "{f:?}");
|
||||||
|
let no = |f: Filter| assert!(!f.matches(&entry), "{f:?}");
|
||||||
|
yes(Filter::default());
|
||||||
|
yes(Filter {
|
||||||
|
sender: Some("alice@".into()),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
yes(Filter {
|
||||||
|
address: Some("BANK".into()),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
yes(Filter {
|
||||||
|
text: Some("final q3".into()),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
yes(Filter {
|
||||||
|
message_id: Some("[email protected]".into()),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
yes(Filter {
|
||||||
|
direction: Some(Direction::Any),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
no(Filter {
|
||||||
|
direction: Some(Direction::Incoming),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
no(Filter {
|
||||||
|
recipient: Some("alice".into()),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
no(Filter {
|
||||||
|
before: Some(100),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
yes(Filter {
|
||||||
|
after: Some(100),
|
||||||
|
journal_id: Some(2),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
no(Filter {
|
||||||
|
journal_id: Some(5),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hex_round_trips() {
|
||||||
|
let bytes = [0u8, 1, 0xab, 0xff];
|
||||||
|
assert_eq!(unhex(&hex(&bytes)), Some(bytes.to_vec()));
|
||||||
|
assert_eq!(unhex("abc"), None);
|
||||||
|
assert_eq!(unhex("zz"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ids_read_back() {
|
||||||
|
let id = EntryId { node: 3, seq: 77 };
|
||||||
|
assert_eq!(EntryId::from_u64(id.to_u64()), id);
|
||||||
|
assert_eq!(id.to_string(), "3-77");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,512 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Journaling (journaling spec, JR-1 to JR-18): a copy of each message the
|
||||||
|
//! server queues, with its envelope, kept where nothing in the product
|
||||||
|
//! changes or removes it before its retention ends.
|
||||||
|
//!
|
||||||
|
//! - this module: journals, what makes one valid, and where they're kept;
|
||||||
|
//! - [`report`]: the journal report around the untouched message (JR-3);
|
||||||
|
//! - [`entries`]: the built-in journal and its chain (JR-5, JR-6, JR-13).
|
||||||
|
//!
|
||||||
|
//! Kept in the fork's subspace (`store::SUBSPACE_INBUXA`). Every key starts
|
||||||
|
//! with `J`; journals are `j` + id (u32), as JSON. There are few, so they're
|
||||||
|
//! read whole.
|
||||||
|
|
||||||
|
pub mod archive;
|
||||||
|
pub mod entries;
|
||||||
|
pub mod report;
|
||||||
|
|
||||||
|
use crate::{hold::Member, mailflow::rules::jmap_ids};
|
||||||
|
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize, de::DeserializeOwned};
|
||||||
|
use std::{
|
||||||
|
sync::{Arc, RwLock},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
use store::{
|
||||||
|
Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey,
|
||||||
|
write::{AnyClass, BatchBuilder, ValueClass, assert::AssertValue},
|
||||||
|
};
|
||||||
|
use trc::AddContext;
|
||||||
|
|
||||||
|
pub(crate) const FEATURE: u8 = b'J';
|
||||||
|
const KIND_JOURNAL: u8 = b'j';
|
||||||
|
const CREATE_ATTEMPTS: usize = 5;
|
||||||
|
|
||||||
|
/// Retention a journal may be given, in days (settled answer 3).
|
||||||
|
pub const MIN_RETENTION_DAYS: u32 = 30;
|
||||||
|
pub const MAX_RETENTION_DAYS: u32 = 3650;
|
||||||
|
/// Most entries in one scope list.
|
||||||
|
const MAX_LIST: usize = 5_000;
|
||||||
|
|
||||||
|
/// Which way a message goes, from this server's side (JR-9).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum Direction {
|
||||||
|
/// From someone here to at least one recipient elsewhere.
|
||||||
|
Outgoing,
|
||||||
|
/// From elsewhere to someone here.
|
||||||
|
Incoming,
|
||||||
|
/// From someone here, to people here only.
|
||||||
|
Internal,
|
||||||
|
Any,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Direction {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Direction::Outgoing => "outgoing",
|
||||||
|
Direction::Incoming => "incoming",
|
||||||
|
Direction::Internal => "internal",
|
||||||
|
Direction::Any => "any",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A message's direction: `Any` is never one.
|
||||||
|
pub fn of(sender_local: bool, any_remote: bool, any_local: bool) -> Direction {
|
||||||
|
match (sender_local, any_remote) {
|
||||||
|
(true, true) => Direction::Outgoing,
|
||||||
|
(true, false) => Direction::Internal,
|
||||||
|
(false, _) if any_local => Direction::Incoming,
|
||||||
|
// Nobody here on either side: relayed mail counts as outgoing
|
||||||
|
(false, _) => Direction::Outgoing,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn includes(&self, direction: Direction) -> bool {
|
||||||
|
*self == Direction::Any || *self == direction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whose mail a journal takes (JR-9): everyone, or people reached through
|
||||||
|
/// their account, domain, group or tenant. Ids are in the JMAP form.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Scope {
|
||||||
|
#[serde(default)]
|
||||||
|
pub everyone: bool,
|
||||||
|
#[serde(default, with = "jmap_ids")]
|
||||||
|
pub accounts: Vec<u32>,
|
||||||
|
#[serde(default, with = "jmap_ids")]
|
||||||
|
pub groups: Vec<u32>,
|
||||||
|
#[serde(default, with = "jmap_ids")]
|
||||||
|
pub domains: Vec<u32>,
|
||||||
|
#[serde(default, with = "jmap_ids")]
|
||||||
|
pub tenants: Vec<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Scope {
|
||||||
|
fn lists(&self) -> [&Vec<u32>; 4] {
|
||||||
|
[&self.accounts, &self.groups, &self.domains, &self.tenants]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this scope reaches one person here.
|
||||||
|
pub fn covers(&self, member: &Member) -> bool {
|
||||||
|
self.everyone
|
||||||
|
|| self.accounts.contains(&member.account)
|
||||||
|
|| member.domains.iter().any(|d| self.domains.contains(d))
|
||||||
|
|| member.groups.iter().any(|g| self.groups.contains(g))
|
||||||
|
|| member.tenant.is_some_and(|t| self.tenants.contains(&t))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A journal (JR-9): what it takes, and how long its entries are kept.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Journal {
|
||||||
|
#[serde(default)]
|
||||||
|
pub id: u32,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
pub direction: Direction,
|
||||||
|
pub scope: Scope,
|
||||||
|
/// How long an entry this journal writes is kept. An entry keeps the
|
||||||
|
/// retention it was written with (JR-12).
|
||||||
|
pub retention_days: u32,
|
||||||
|
/// Whether entries go into the built-in journal (JR-5).
|
||||||
|
#[serde(default = "yes")]
|
||||||
|
pub built_in: bool,
|
||||||
|
/// An outside archive's journal address, sent each report (JR-7).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub archive_address: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_by: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a journal was refused: the property, and what to do.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Invalid {
|
||||||
|
pub property: &'static str,
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid(property: &'static str, reason: impl Into<String>) -> Result<(), Invalid> {
|
||||||
|
Err(Invalid {
|
||||||
|
property,
|
||||||
|
reason: reason.into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Journal {
|
||||||
|
pub fn validate(&self) -> Result<(), Invalid> {
|
||||||
|
if self.name.trim().is_empty() {
|
||||||
|
return invalid("name", "Give the journal a name.");
|
||||||
|
}
|
||||||
|
if self.name.len() > 200 || self.description.len() > 2_000 {
|
||||||
|
return invalid("name", "The name or description is too long.");
|
||||||
|
}
|
||||||
|
if !(MIN_RETENTION_DAYS..=MAX_RETENTION_DAYS).contains(&self.retention_days) {
|
||||||
|
return invalid(
|
||||||
|
"retentionDays",
|
||||||
|
format!("Keep entries between {MIN_RETENTION_DAYS} and {MAX_RETENTION_DAYS} days."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Neither is a journal only rules send mail to (JR-10)
|
||||||
|
let chosen = self.scope.lists().iter().any(|list| !list.is_empty());
|
||||||
|
if self.scope.everyone && chosen {
|
||||||
|
return invalid(
|
||||||
|
"scope",
|
||||||
|
"Journal everyone, or choose accounts, groups, domains or tenants; not both.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !self.built_in && self.archive_address.is_none() {
|
||||||
|
return invalid(
|
||||||
|
"builtIn",
|
||||||
|
"Keep entries in the built-in journal, send them to an archive, or both.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(address) = &self.archive_address
|
||||||
|
&& !is_address(address)
|
||||||
|
{
|
||||||
|
return invalid(
|
||||||
|
"archiveAddress",
|
||||||
|
format!("\"{address}\" isn't an email address."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if self.scope.lists().iter().any(|list| list.len() > MAX_LIST) {
|
||||||
|
return invalid("scope", format!("Choose at most {MAX_LIST} of each."));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this journal takes a message going `direction` with these
|
||||||
|
/// people here on either side.
|
||||||
|
/// Whether only rules send this journal mail (JR-10).
|
||||||
|
pub fn rules_only(&self) -> bool {
|
||||||
|
!self.scope.everyone && self.scope.lists().iter().all(|list| list.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn takes(&self, direction: Direction, members: &[Member]) -> bool {
|
||||||
|
self.enabled
|
||||||
|
&& self.direction.includes(direction)
|
||||||
|
&& (self.scope.everyone || members.iter().any(|m| self.scope.covers(m)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn yes() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An address an archive can be sent to: one `@`, something either side,
|
||||||
|
/// nothing that would break an envelope.
|
||||||
|
fn is_address(address: &str) -> bool {
|
||||||
|
address.len() <= 320
|
||||||
|
&& address.split_once('@').is_some_and(|(local, domain)| {
|
||||||
|
!local.is_empty() && domain.contains('.') && !domain.contains('@')
|
||||||
|
})
|
||||||
|
&& !address
|
||||||
|
.chars()
|
||||||
|
.any(|c| c.is_whitespace() || c.is_control() || matches!(c, '<' | '>' | ',' | ';'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A value stored as JSON.
|
||||||
|
pub(crate) struct Json<T>(pub T);
|
||||||
|
|
||||||
|
impl<T: SerdeSerialize> Serialize for Json<T> {
|
||||||
|
fn serialize(&self) -> trc::Result<Vec<u8>> {
|
||||||
|
serde_json::to_vec(&self.0).map_err(|err| {
|
||||||
|
trc::StoreEvent::UnexpectedError
|
||||||
|
.into_err()
|
||||||
|
.details("Failed to serialize a journal record")
|
||||||
|
.reason(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: DeserializeOwned + Sync + Send> Deserialize for Json<T> {
|
||||||
|
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||||
|
serde_json::from_slice(bytes).map(Json).map_err(|err| {
|
||||||
|
trc::StoreEvent::DataCorruption
|
||||||
|
.into_err()
|
||||||
|
.details("Invalid journal record")
|
||||||
|
.reason(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn class(id: u32) -> ValueClass {
|
||||||
|
let mut key = Vec::with_capacity(6);
|
||||||
|
key.push(FEATURE);
|
||||||
|
key.push(KIND_JOURNAL);
|
||||||
|
key.extend_from_slice(&id.to_be_bytes());
|
||||||
|
ValueClass::Any(AnyClass {
|
||||||
|
subspace: SUBSPACE_INBUXA,
|
||||||
|
key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(id: u32) -> ValueKey<ValueClass> {
|
||||||
|
ValueKey::from(class(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(data: &Store, id: u32) -> trc::Result<Option<Journal>> {
|
||||||
|
Ok(data
|
||||||
|
.get_value::<Json<Journal>>(key(id))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.map(|Json(journal)| journal))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every journal, oldest first.
|
||||||
|
pub async fn all(data: &Store) -> trc::Result<Vec<Journal>> {
|
||||||
|
let mut journals = Vec::new();
|
||||||
|
data.iterate(IterateParams::new(key(0), key(u32::MAX)), |_, value| {
|
||||||
|
if let Ok(Json(journal)) = Json::<Journal>::deserialize(value) {
|
||||||
|
journals.push(journal);
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
journals.sort_by_key(|journal| journal.id);
|
||||||
|
Ok(journals)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a new journal under the next free id, which it returns.
|
||||||
|
pub async fn create(data: &Store, journal: &Journal) -> trc::Result<u32> {
|
||||||
|
let mut attempt = 0;
|
||||||
|
loop {
|
||||||
|
attempt += 1;
|
||||||
|
let id = all(data).await?.iter().map(|j| j.id).max().unwrap_or(0) + 1;
|
||||||
|
let stored = Journal {
|
||||||
|
id,
|
||||||
|
..journal.clone()
|
||||||
|
};
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.assert_value(class(id), AssertValue::None);
|
||||||
|
batch.set(class(id), Json(&stored).serialize()?);
|
||||||
|
match data.write(batch.build_all()).await {
|
||||||
|
Ok(_) => {
|
||||||
|
invalidate();
|
||||||
|
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!())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces a stored journal (same id).
|
||||||
|
pub async fn update(data: &Store, journal: &Journal) -> trc::Result<()> {
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.set(class(journal.id), Json(journal).serialize()?);
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
invalidate();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a journal. Its entries stay, each until its own time.
|
||||||
|
pub async fn delete(data: &Store, id: u32) -> trc::Result<()> {
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.clear(class(id));
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
invalidate();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long a node keeps its copy of the journals before reading them again.
|
||||||
|
pub const TTL: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
type Cached = Option<(Instant, Arc<Vec<Journal>>)>;
|
||||||
|
static CACHE: RwLock<Cached> = RwLock::new(None);
|
||||||
|
|
||||||
|
/// Forgets this node's copy, so the next message reads the journals again.
|
||||||
|
pub fn invalidate() {
|
||||||
|
if let Ok(mut cache) = CACHE.write() {
|
||||||
|
*cache = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The enabled journals, from this node's copy (refreshed every [`TTL`]).
|
||||||
|
pub async fn enabled(data: &Store) -> trc::Result<Arc<Vec<Journal>>> {
|
||||||
|
if let Ok(cache) = CACHE.read()
|
||||||
|
&& let Some((at, journals)) = cache.as_ref()
|
||||||
|
&& at.elapsed() < TTL
|
||||||
|
{
|
||||||
|
return Ok(journals.clone());
|
||||||
|
}
|
||||||
|
let journals = Arc::new(
|
||||||
|
all(data)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|journal| journal.enabled)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
if let Ok(mut cache) = CACHE.write() {
|
||||||
|
*cache = Some((Instant::now(), journals.clone()));
|
||||||
|
}
|
||||||
|
Ok(journals)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn journal(scope: Scope) -> Journal {
|
||||||
|
Journal {
|
||||||
|
id: 1,
|
||||||
|
name: "Finance".into(),
|
||||||
|
description: String::new(),
|
||||||
|
enabled: true,
|
||||||
|
direction: Direction::Any,
|
||||||
|
scope,
|
||||||
|
retention_days: 365,
|
||||||
|
built_in: true,
|
||||||
|
archive_address: None,
|
||||||
|
created_by: String::new(),
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn member(account: u32, groups: Vec<u32>) -> Member {
|
||||||
|
Member {
|
||||||
|
account,
|
||||||
|
domains: vec![1],
|
||||||
|
groups,
|
||||||
|
tenant: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_is_everyone_or_chosen() {
|
||||||
|
assert!(
|
||||||
|
journal(Scope {
|
||||||
|
everyone: true,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.validate()
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
// Nobody chosen: only rules send it mail
|
||||||
|
let rules_only = journal(Scope::default());
|
||||||
|
assert!(rules_only.validate().is_ok());
|
||||||
|
assert!(rules_only.rules_only());
|
||||||
|
assert!(!rules_only.takes(Direction::Any, &[member(3, vec![7])]));
|
||||||
|
let both = Scope {
|
||||||
|
everyone: true,
|
||||||
|
groups: vec![4],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(journal(both).validate().unwrap_err().property, "scope");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn destinations() {
|
||||||
|
let mut j = journal(Scope {
|
||||||
|
everyone: true,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
j.built_in = false;
|
||||||
|
assert_eq!(j.validate().unwrap_err().property, "builtIn");
|
||||||
|
j.archive_address = Some("[email protected]".into());
|
||||||
|
assert!(j.validate().is_ok());
|
||||||
|
for bad in [
|
||||||
|
"archive",
|
||||||
|
"a@b",
|
||||||
|
"a [email protected]",
|
||||||
|
"<[email protected]>",
|
||||||
|
"a@[email protected]",
|
||||||
|
] {
|
||||||
|
j.archive_address = Some(bad.into());
|
||||||
|
assert_eq!(
|
||||||
|
j.validate().unwrap_err().property,
|
||||||
|
"archiveAddress",
|
||||||
|
"{bad}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Stored before destinations existed: the built-in journal
|
||||||
|
let old: Journal = serde_json::from_str(
|
||||||
|
r#"{"name":"Old","direction":"any","scope":{"everyone":true},"retentionDays":30}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(old.built_in && old.archive_address.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retention_has_bounds() {
|
||||||
|
let mut j = journal(Scope {
|
||||||
|
everyone: true,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
j.retention_days = 29;
|
||||||
|
assert_eq!(j.validate().unwrap_err().property, "retentionDays");
|
||||||
|
j.retention_days = 3651;
|
||||||
|
assert!(j.validate().is_err());
|
||||||
|
j.retention_days = 3650;
|
||||||
|
assert!(j.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn takes_by_direction_and_member() {
|
||||||
|
let mut j = journal(Scope {
|
||||||
|
groups: vec![7],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
assert!(j.takes(Direction::Outgoing, &[member(3, vec![7])]));
|
||||||
|
assert!(!j.takes(Direction::Outgoing, &[member(3, vec![8])]));
|
||||||
|
assert!(!j.takes(Direction::Outgoing, &[]));
|
||||||
|
j.direction = Direction::Incoming;
|
||||||
|
assert!(!j.takes(Direction::Outgoing, &[member(3, vec![7])]));
|
||||||
|
j.enabled = false;
|
||||||
|
assert!(!j.takes(Direction::Incoming, &[member(3, vec![7])]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn directions() {
|
||||||
|
assert_eq!(Direction::of(true, true, true), Direction::Outgoing);
|
||||||
|
assert_eq!(Direction::of(true, false, true), Direction::Internal);
|
||||||
|
assert_eq!(Direction::of(false, false, true), Direction::Incoming);
|
||||||
|
assert_eq!(Direction::of(false, true, true), Direction::Incoming);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_ids_are_jmap_ids() {
|
||||||
|
let scope: Scope = serde_json::from_str(r#"{"groups":["b"],"tenants":[7]}"#).unwrap();
|
||||||
|
assert_eq!(scope.groups, vec![1]);
|
||||||
|
assert_eq!(scope.tenants, vec![7]);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&scope).unwrap()["tenants"],
|
||||||
|
serde_json::json!(["h"])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! The journal report (JR-3, JR-4): a message whose first part lists the
|
||||||
|
//! envelope, one field a line, and whose second part is the message as it
|
||||||
|
//! was queued, byte for byte, as `message/rfc822`. Field names are fixed
|
||||||
|
//! English: a report is a record, and scripts read it.
|
||||||
|
|
||||||
|
use super::Direction;
|
||||||
|
use mail_builder::headers::{Header, date::Date, text::Text};
|
||||||
|
use mail_parser::MessageParser;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
/// One envelope recipient, with the address it was given as (a list's, for
|
||||||
|
/// the list's members).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Recipient {
|
||||||
|
pub address: String,
|
||||||
|
pub orcpt: Option<String>,
|
||||||
|
/// The mail flow rule that added or redirected to it.
|
||||||
|
pub added_by: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the queue knows about a message.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Envelope<'x> {
|
||||||
|
pub sender: &'x str,
|
||||||
|
pub authenticated: bool,
|
||||||
|
pub recipients: &'x [Recipient],
|
||||||
|
pub queue_id: u64,
|
||||||
|
/// Seconds.
|
||||||
|
pub received: u64,
|
||||||
|
pub direction: Direction,
|
||||||
|
pub held: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a report says, besides the envelope's own fields.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct Fields {
|
||||||
|
pub subject: String,
|
||||||
|
pub message_id: String,
|
||||||
|
pub to: Vec<String>,
|
||||||
|
pub cc: Vec<String>,
|
||||||
|
/// Envelope recipients in neither To nor Cc, nor reached through a list.
|
||||||
|
pub bcc: Vec<String>,
|
||||||
|
/// A list's address, and its members among the recipients.
|
||||||
|
pub expanded: Vec<(String, Vec<String>)>,
|
||||||
|
/// A rule's name, and the recipients it added.
|
||||||
|
pub added: Vec<(String, Vec<String>)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line's worth of a value: no line breaks, no control characters.
|
||||||
|
fn line(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c.is_control() { ' ' } else { c })
|
||||||
|
.collect::<String>()
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The address an ORCPT names, without its `rfc822;` type.
|
||||||
|
fn orcpt_address(orcpt: &str) -> String {
|
||||||
|
let orcpt = orcpt.trim();
|
||||||
|
let bare = match orcpt.split_once(';') {
|
||||||
|
Some((kind, address)) if kind.eq_ignore_ascii_case("rfc822") => address,
|
||||||
|
_ => orcpt,
|
||||||
|
};
|
||||||
|
bare.trim().to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sorts the envelope's recipients by how they were addressed.
|
||||||
|
pub fn fields(envelope: &Envelope<'_>, original: &[u8]) -> Fields {
|
||||||
|
let parsed = MessageParser::default().parse_headers(original);
|
||||||
|
let headed = |which: Option<&mail_parser::Address<'_>>| -> Vec<String> {
|
||||||
|
which
|
||||||
|
.map(|list| {
|
||||||
|
list.iter()
|
||||||
|
.filter_map(|addr| addr.address())
|
||||||
|
.map(|address| address.to_lowercase())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
let (subject, message_id, header_to, header_cc) = match &parsed {
|
||||||
|
Some(message) => (
|
||||||
|
message.subject().map(line).unwrap_or_default(),
|
||||||
|
message
|
||||||
|
.message_id()
|
||||||
|
.map(|id| format!("<{}>", line(id)))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
headed(message.to()),
|
||||||
|
headed(message.cc()),
|
||||||
|
),
|
||||||
|
None => Default::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut fields = Fields {
|
||||||
|
subject,
|
||||||
|
message_id,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
for rcpt in envelope.recipients {
|
||||||
|
let address = rcpt.address.to_lowercase();
|
||||||
|
let via = rcpt
|
||||||
|
.orcpt
|
||||||
|
.as_deref()
|
||||||
|
.map(orcpt_address)
|
||||||
|
.filter(|via| !via.is_empty() && *via != address);
|
||||||
|
if let Some(rule) = &rcpt.added_by {
|
||||||
|
match fields.added.iter_mut().find(|(name, _)| name == rule) {
|
||||||
|
Some((_, added)) => added.push(line(&rcpt.address)),
|
||||||
|
None => fields.added.push((line(rule), vec![line(&rcpt.address)])),
|
||||||
|
}
|
||||||
|
} else if header_to.contains(&address) {
|
||||||
|
fields.to.push(line(&rcpt.address));
|
||||||
|
} else if header_cc.contains(&address) {
|
||||||
|
fields.cc.push(line(&rcpt.address));
|
||||||
|
} else if let Some(via) = via {
|
||||||
|
match fields.expanded.iter_mut().find(|(list, _)| *list == via) {
|
||||||
|
Some((_, members)) => members.push(line(&rcpt.address)),
|
||||||
|
None => fields
|
||||||
|
.expanded
|
||||||
|
.push((line(&via), vec![line(&rcpt.address)])),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fields.bcc.push(line(&rcpt.address));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fields
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The report's first part.
|
||||||
|
pub fn text(envelope: &Envelope<'_>, fields: &Fields) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut field = |name: &str, value: &str| {
|
||||||
|
if !value.is_empty() {
|
||||||
|
out.push_str(name);
|
||||||
|
out.push_str(": ");
|
||||||
|
out.push_str(value);
|
||||||
|
out.push_str("\r\n");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let sender = if envelope.sender.is_empty() {
|
||||||
|
"<>".to_string()
|
||||||
|
} else {
|
||||||
|
line(envelope.sender)
|
||||||
|
};
|
||||||
|
field("Sender", &sender);
|
||||||
|
field(
|
||||||
|
"Authenticated",
|
||||||
|
if envelope.authenticated { "yes" } else { "no" },
|
||||||
|
);
|
||||||
|
field("Subject", &fields.subject);
|
||||||
|
field("Message-ID", &fields.message_id);
|
||||||
|
field("Queue ID", &format!("{:x}", envelope.queue_id));
|
||||||
|
field(
|
||||||
|
"Received",
|
||||||
|
&mail_parser::DateTime::from_timestamp(envelope.received as i64).to_rfc3339(),
|
||||||
|
);
|
||||||
|
field("Direction", envelope.direction.as_str());
|
||||||
|
field("To", &fields.to.join(", "));
|
||||||
|
field("Cc", &fields.cc.join(", "));
|
||||||
|
field("Bcc", &fields.bcc.join(", "));
|
||||||
|
for (list, members) in &fields.expanded {
|
||||||
|
field("Expanded", &format!("{list} -> {}", members.join(", ")));
|
||||||
|
}
|
||||||
|
for (rule, added) in &fields.added {
|
||||||
|
field("Added by rule", &format!("{rule} -> {}", added.join(", ")));
|
||||||
|
}
|
||||||
|
if envelope.held {
|
||||||
|
field("Held for review", "yes");
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a message can travel as 8bit: no NULs, no line past 998 bytes.
|
||||||
|
fn fits_8bit(message: &[u8]) -> bool {
|
||||||
|
!message.contains(&0) && message.split(|b| *b == b'\n').all(|l| l.len() <= 998)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole report: headers, the fields, then the original untouched.
|
||||||
|
/// `from` is the address the report is from; `host` names the server in its
|
||||||
|
/// Message-ID.
|
||||||
|
pub fn build(
|
||||||
|
envelope: &Envelope<'_>,
|
||||||
|
original: &[u8],
|
||||||
|
from: &str,
|
||||||
|
host: &str,
|
||||||
|
) -> (Vec<u8>, Fields) {
|
||||||
|
let fields = fields(envelope, original);
|
||||||
|
let body = text(envelope, &fields);
|
||||||
|
// A boundary that can't occur in the original
|
||||||
|
let mut boundary = format!("journal-{}", &hex(&Sha256::digest(original))[..32]);
|
||||||
|
while original
|
||||||
|
.windows(boundary.len())
|
||||||
|
.any(|window| window == boundary.as_bytes())
|
||||||
|
{
|
||||||
|
boundary.push('x');
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out: Vec<u8> = Vec::with_capacity(original.len() + body.len() + 1024);
|
||||||
|
out.extend_from_slice(format!("From: Journal <{}>\r\n", line(from)).as_bytes());
|
||||||
|
out.extend_from_slice(b"Date: ");
|
||||||
|
out.extend_from_slice(Date::new(envelope.received as i64).to_rfc822().as_bytes());
|
||||||
|
out.extend_from_slice(b"\r\n");
|
||||||
|
out.extend_from_slice(b"Subject: ");
|
||||||
|
let subject = if fields.subject.is_empty() {
|
||||||
|
"Journal report".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Journal report: {}", fields.subject)
|
||||||
|
};
|
||||||
|
Text::new(subject).write_header(&mut out, "Subject: ".len());
|
||||||
|
out.extend_from_slice(
|
||||||
|
format!(
|
||||||
|
"Message-ID: <journal.{:x}.{}@{}>\r\n",
|
||||||
|
envelope.queue_id,
|
||||||
|
envelope.received,
|
||||||
|
line(host)
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
);
|
||||||
|
out.extend_from_slice(format!("X-Inbuxa-Journal: {:x}\r\n", envelope.queue_id).as_bytes());
|
||||||
|
out.extend_from_slice(b"MIME-Version: 1.0\r\n");
|
||||||
|
out.extend_from_slice(
|
||||||
|
format!("Content-Type: multipart/mixed; boundary=\"{boundary}\"\r\n\r\n").as_bytes(),
|
||||||
|
);
|
||||||
|
out.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
|
||||||
|
out.extend_from_slice(
|
||||||
|
b"Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n",
|
||||||
|
);
|
||||||
|
out.extend_from_slice(body.as_bytes());
|
||||||
|
out.extend_from_slice(format!("\r\n--{boundary}\r\n").as_bytes());
|
||||||
|
out.extend_from_slice(b"Content-Type: message/rfc822\r\n");
|
||||||
|
out.extend_from_slice(b"Content-Disposition: attachment; filename=\"original.eml\"\r\n");
|
||||||
|
out.extend_from_slice(if fits_8bit(original) {
|
||||||
|
b"Content-Transfer-Encoding: 8bit\r\n\r\n".as_slice()
|
||||||
|
} else {
|
||||||
|
b"Content-Transfer-Encoding: binary\r\n\r\n".as_slice()
|
||||||
|
});
|
||||||
|
out.extend_from_slice(original);
|
||||||
|
// The line break before a boundary belongs to the boundary: the
|
||||||
|
// original keeps its own last one
|
||||||
|
out.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
|
||||||
|
(out, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the original starts and ends inside a report [`build`] made.
|
||||||
|
pub fn original(report: &[u8]) -> Option<&[u8]> {
|
||||||
|
let parsed = MessageParser::default().parse(report)?;
|
||||||
|
let part = parsed.attachment(0)?;
|
||||||
|
let start = part.raw_body_offset() as usize;
|
||||||
|
let end = part.raw_end_offset() as usize;
|
||||||
|
report.get(start..end)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const ORIGINAL: &[u8] = b"From: [email protected]\r\n\
|
||||||
|
To: Bank <[email protected]>\r\n\
|
||||||
|
Cc: [email protected]\r\n\
|
||||||
|
Subject: Q3 figures\r\n\
|
||||||
|
Message-ID: <[email protected]>\r\n\
|
||||||
|
\r\n\
|
||||||
|
The figures.\r\n";
|
||||||
|
|
||||||
|
fn rcpt(address: &str, orcpt: Option<&str>) -> Recipient {
|
||||||
|
Recipient {
|
||||||
|
address: address.into(),
|
||||||
|
orcpt: orcpt.map(Into::into),
|
||||||
|
added_by: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn envelope(recipients: &[Recipient]) -> Envelope<'_> {
|
||||||
|
Envelope {
|
||||||
|
sender: "[email protected]",
|
||||||
|
authenticated: true,
|
||||||
|
recipients,
|
||||||
|
queue_id: 0x1a2b,
|
||||||
|
received: 1_790_000_000,
|
||||||
|
direction: Direction::Outgoing,
|
||||||
|
held: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recipients_sorted_by_how_they_were_addressed() {
|
||||||
|
let recipients = [
|
||||||
|
rcpt("[email protected]", None),
|
||||||
|
rcpt("[email protected]", Some("rfc822;[email protected]")),
|
||||||
|
rcpt("[email protected]", None),
|
||||||
|
rcpt("[email protected]", Some("[email protected]")),
|
||||||
|
rcpt("[email protected]", Some("rfc822;[email protected]")),
|
||||||
|
];
|
||||||
|
let fields = fields(&envelope(&recipients), ORIGINAL);
|
||||||
|
assert_eq!(fields.subject, "Q3 figures");
|
||||||
|
assert_eq!(fields.message_id, "<[email protected]>");
|
||||||
|
assert_eq!(fields.to, vec!["[email protected]"]);
|
||||||
|
assert_eq!(fields.cc, vec!["[email protected]"]);
|
||||||
|
assert_eq!(fields.bcc, vec!["[email protected]"]);
|
||||||
|
assert_eq!(
|
||||||
|
fields.expanded,
|
||||||
|
vec![(
|
||||||
|
"[email protected]".to_string(),
|
||||||
|
vec![
|
||||||
|
"[email protected]".to_string(),
|
||||||
|
"[email protected]".to_string()
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn report_carries_the_original_untouched() {
|
||||||
|
let recipients = [
|
||||||
|
rcpt("[email protected]", None),
|
||||||
|
rcpt("[email protected]", None),
|
||||||
|
];
|
||||||
|
let (report, _) = build(
|
||||||
|
&envelope(&recipients),
|
||||||
|
ORIGINAL,
|
||||||
|
"[email protected]",
|
||||||
|
"mx.example.com",
|
||||||
|
);
|
||||||
|
let text = String::from_utf8_lossy(&report);
|
||||||
|
assert!(text.contains("Sender: [email protected]\r\n"));
|
||||||
|
assert!(text.contains("Bcc: [email protected]\r\n"));
|
||||||
|
assert!(text.contains("Queue ID: 1a2b\r\n"));
|
||||||
|
assert!(text.contains("Direction: outgoing\r\n"));
|
||||||
|
assert!(text.contains("Subject: Journal report: Q3 figures\r\n"));
|
||||||
|
assert!(!text.contains("Held for review"));
|
||||||
|
assert_eq!(original(&report), Some(ORIGINAL));
|
||||||
|
let unterminated = &ORIGINAL[..ORIGINAL.len() - 2];
|
||||||
|
let (report, _) = build(
|
||||||
|
&envelope(&recipients),
|
||||||
|
unterminated,
|
||||||
|
"[email protected]",
|
||||||
|
"mx.example.com",
|
||||||
|
);
|
||||||
|
assert_eq!(original(&report), Some(unterminated));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rule_added_recipients_say_so() {
|
||||||
|
let mut copied = rcpt("[email protected]", None);
|
||||||
|
copied.added_by = Some("Copy finance".into());
|
||||||
|
let recipients = [rcpt("[email protected]", None), copied];
|
||||||
|
let env = envelope(&recipients);
|
||||||
|
let fields = fields(&env, ORIGINAL);
|
||||||
|
assert!(fields.bcc.is_empty(), "{fields:?}");
|
||||||
|
assert!(
|
||||||
|
text(&env, &fields).contains("Added by rule: Copy finance -> [email protected]\r\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn values_stay_on_one_line() {
|
||||||
|
let recipients = [rcpt("[email protected]", None)];
|
||||||
|
let mut env = envelope(&recipients);
|
||||||
|
env.sender = "[email protected]\r\nBcc: [email protected]";
|
||||||
|
env.held = true;
|
||||||
|
let body = text(&env, &Fields::default());
|
||||||
|
assert_eq!(body.matches("\r\n").count(), body.lines().count());
|
||||||
|
assert!(body.contains("Sender: [email protected] Bcc: [email protected]\r\n"));
|
||||||
|
assert!(body.contains("Held for review: yes\r\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_sender_is_shown_as_such() {
|
||||||
|
let recipients = [rcpt("[email protected]", None)];
|
||||||
|
let mut env = envelope(&recipients);
|
||||||
|
env.sender = "";
|
||||||
|
assert!(text(&env, &Fields::default()).starts_with("Sender: <>\r\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,8 +22,11 @@ pub mod ai;
|
|||||||
pub mod audit;
|
pub mod audit;
|
||||||
pub mod branding;
|
pub mod branding;
|
||||||
pub mod hold;
|
pub mod hold;
|
||||||
|
pub mod journal;
|
||||||
pub mod lock;
|
pub mod lock;
|
||||||
|
pub mod mailflow;
|
||||||
pub mod masked_email;
|
pub mod masked_email;
|
||||||
|
pub mod privacy;
|
||||||
pub mod security;
|
pub mod security;
|
||||||
pub mod tenancy;
|
pub mod tenancy;
|
||||||
pub mod undelete;
|
pub mod undelete;
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! The compiled rules, kept per node so a message doesn't read the store.
|
||||||
|
//! A change made on this node applies at once; one made on another node
|
||||||
|
//! within [`TTL`], when the copy here is next refreshed.
|
||||||
|
|
||||||
|
use super::{engine::Compiled, rules};
|
||||||
|
use std::{
|
||||||
|
sync::{Arc, RwLock},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
use store::Store;
|
||||||
|
|
||||||
|
/// How long a node keeps its copy before reading the rules again.
|
||||||
|
pub const TTL: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
static CACHE: RwLock<Option<(Instant, Arc<Compiled>)>> = RwLock::new(None);
|
||||||
|
|
||||||
|
/// Forgets the copy, so the next message reads the rules again.
|
||||||
|
pub fn invalidate() {
|
||||||
|
if let Ok(mut cache) = CACHE.write() {
|
||||||
|
*cache = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The enabled rules, compiled. A rule that no longer compiles is left out
|
||||||
|
/// and reported, once per refresh.
|
||||||
|
pub async fn compiled(data: &Store) -> trc::Result<Arc<Compiled>> {
|
||||||
|
if let Ok(cache) = CACHE.read()
|
||||||
|
&& let Some((at, compiled)) = cache.as_ref()
|
||||||
|
&& at.elapsed() < TTL
|
||||||
|
{
|
||||||
|
return Ok(compiled.clone());
|
||||||
|
}
|
||||||
|
let (compiled, skipped) = Compiled::new(&rules::all(data).await?);
|
||||||
|
for (id, reason) in skipped {
|
||||||
|
trc::event!(
|
||||||
|
Store(trc::StoreEvent::DataCorruption),
|
||||||
|
Id = u64::from(id),
|
||||||
|
Reason = reason,
|
||||||
|
Details = "Mail rule skipped: it no longer compiles"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let compiled = Arc::new(compiled);
|
||||||
|
if let Ok(mut cache) = CACHE.write() {
|
||||||
|
*cache = Some((Instant::now(), compiled.clone()));
|
||||||
|
}
|
||||||
|
Ok(compiled)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! African identifiers (§2.3): South Africa's ID number.
|
||||||
|
|
||||||
|
use super::{Detector, Findings, Region, Strength, checks, valid_short_date};
|
||||||
|
use regex::Regex;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
pub static DETECTORS: &[Detector] = &[Detector::new(
|
||||||
|
"za-id",
|
||||||
|
"South Africa: ID number",
|
||||||
|
Region::Africa,
|
||||||
|
Strength::Checked,
|
||||||
|
za_id,
|
||||||
|
)];
|
||||||
|
|
||||||
|
/// Birth date `YYMMDD`, four digits, citizenship (0, 1 or 2), 8 or 9, a Luhn
|
||||||
|
/// check digit. The date and the two fixed digits make it strong enough to
|
||||||
|
/// count alone.
|
||||||
|
static ZA_ID: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"\b(\d{2})(\d{2})(\d{2})\d{4}[012][89]\d\b").expect("detector pattern")
|
||||||
|
});
|
||||||
|
|
||||||
|
fn za_id(text: &str, findings: &mut Findings) {
|
||||||
|
for c in ZA_ID.captures_iter(text) {
|
||||||
|
let n = &c[0];
|
||||||
|
let num = |s: &str| s.parse::<u32>().unwrap_or(0);
|
||||||
|
if valid_short_date(num(&c[1]), num(&c[2]), num(&c[3])) && checks::luhn(n) {
|
||||||
|
findings.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::mailflow::detectors::by_id;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn south_africa() {
|
||||||
|
let detector = by_id("za-id").unwrap();
|
||||||
|
assert_eq!(detector.count("ID 8001015009087"), 1);
|
||||||
|
assert_eq!(detector.count("8001015009088"), 0);
|
||||||
|
assert_eq!(detector.count("8013015009087"), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Identifiers from the Americas outside the US and Canada (§2.3): Brazil's
|
||||||
|
//! CPF and CNPJ, and Mexico's CURP.
|
||||||
|
|
||||||
|
use super::{Detector, Findings, Region, Strength, digit_values, valid_short_date, word_near};
|
||||||
|
use regex::Regex;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
pub static DETECTORS: &[Detector] = &[
|
||||||
|
Detector::new(
|
||||||
|
"br-cpf",
|
||||||
|
"Brazil: CPF",
|
||||||
|
Region::Americas,
|
||||||
|
Strength::Checked,
|
||||||
|
br_cpf,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"br-cnpj",
|
||||||
|
"Brazil: CNPJ",
|
||||||
|
Region::Americas,
|
||||||
|
Strength::Checked,
|
||||||
|
br_cnpj,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"mx-curp",
|
||||||
|
"Mexico: CURP",
|
||||||
|
Region::Americas,
|
||||||
|
Strength::Checked,
|
||||||
|
mx_curp,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn re(pattern: &str) -> Regex {
|
||||||
|
Regex::new(pattern).expect("detector pattern")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Brazil's mod 11 check digit over `digits` with `weights`.
|
||||||
|
fn br_check(digits: &[u32], weights: &[u32]) -> u32 {
|
||||||
|
match digits.iter().zip(weights).map(|(a, w)| a * w).sum::<u32>() % 11 {
|
||||||
|
0 | 1 => 0,
|
||||||
|
r => 11 - r,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `111.444.777-35`, or eleven bare digits.
|
||||||
|
static CPF: LazyLock<Regex> = LazyLock::new(|| re(r"\b\d{3}(\.?)\d{3}(\.?)\d{3}(-?)\d{2}\b"));
|
||||||
|
|
||||||
|
pub fn cpf_valid(n: &str) -> bool {
|
||||||
|
let d = digit_values(n);
|
||||||
|
// A run of one digit passes the arithmetic but is never issued
|
||||||
|
d.len() == 11
|
||||||
|
&& d.iter().any(|x| *x != d[0])
|
||||||
|
&& br_check(&d[..9], &[10, 9, 8, 7, 6, 5, 4, 3, 2]) == d[9]
|
||||||
|
&& br_check(&d[..10], &[11, 10, 9, 8, 7, 6, 5, 4, 3, 2]) == d[10]
|
||||||
|
}
|
||||||
|
|
||||||
|
const CPF_WORDS: &[&str] = &[
|
||||||
|
"cpf",
|
||||||
|
"cadastro de pessoas físicas",
|
||||||
|
"cadastro de pessoa física",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn br_cpf(text: &str, findings: &mut Findings) {
|
||||||
|
for c in CPF.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
let written = &c[1] == "." && &c[2] == "." && &c[3] == "-";
|
||||||
|
let n: String = whole
|
||||||
|
.as_str()
|
||||||
|
.chars()
|
||||||
|
.filter(char::is_ascii_digit)
|
||||||
|
.collect();
|
||||||
|
if cpf_valid(&n) && (written || word_near(text, whole.start(), whole.end(), CPF_WORDS)) {
|
||||||
|
findings.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `11.222.333/0001-81`, or fourteen bare digits.
|
||||||
|
static CNPJ: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| re(r"\b\d{2}(\.?)\d{3}(\.?)\d{3}(/?)\d{4}(-?)\d{2}\b"));
|
||||||
|
|
||||||
|
pub fn cnpj_valid(n: &str) -> bool {
|
||||||
|
let d = digit_values(n);
|
||||||
|
d.len() == 14
|
||||||
|
&& d.iter().any(|x| *x != d[0])
|
||||||
|
&& br_check(&d[..12], &[5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]) == d[12]
|
||||||
|
&& br_check(&d[..13], &[6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]) == d[13]
|
||||||
|
}
|
||||||
|
|
||||||
|
const CNPJ_WORDS: &[&str] = &["cnpj", "cadastro nacional da pessoa jurídica"];
|
||||||
|
|
||||||
|
fn br_cnpj(text: &str, findings: &mut Findings) {
|
||||||
|
for c in CNPJ.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
let written = &c[1] == "." && &c[2] == "." && &c[3] == "/" && &c[4] == "-";
|
||||||
|
let n: String = whole
|
||||||
|
.as_str()
|
||||||
|
.chars()
|
||||||
|
.filter(char::is_ascii_digit)
|
||||||
|
.collect();
|
||||||
|
if cnpj_valid(&n) && (written || word_near(text, whole.start(), whole.end(), CNPJ_WORDS)) {
|
||||||
|
findings.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Four letters, the birth date, sex (H, M or X), the state, three
|
||||||
|
/// consonants, a character that tells the century apart, the check digit.
|
||||||
|
static CURP: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
re(r"(?i)\b[A-Z]{4}(\d{2})(\d{2})(\d{2})[HMX][A-Z]{2}[B-DF-HJ-NP-TV-Z]{3}[A-Z0-9]\d\b")
|
||||||
|
});
|
||||||
|
|
||||||
|
/// RENAPO's check: each character's place in `0-9 A-N Ñ O-Z`, weighted 18
|
||||||
|
/// down to 2; the digit is 10 minus the sum mod 10 (10 becomes 0).
|
||||||
|
pub fn curp_valid(curp: &str) -> bool {
|
||||||
|
const ALPHABET: &str = "0123456789ABCDEFGHIJKLMNÑOPQRSTUVWXYZ";
|
||||||
|
let mut sum = 0u32;
|
||||||
|
for (i, c) in curp.chars().take(17).enumerate() {
|
||||||
|
let Some(value) = ALPHABET.chars().position(|a| a == c) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
sum += value as u32 * (18 - i as u32);
|
||||||
|
}
|
||||||
|
curp.chars().nth(17).and_then(|c| c.to_digit(10)) == Some((10 - sum % 10) % 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mx_curp(text: &str, findings: &mut Findings) {
|
||||||
|
for c in CURP.captures_iter(text) {
|
||||||
|
let curp = c[0].to_ascii_uppercase();
|
||||||
|
if valid_short_date(num(&c[1]), num(&c[2]), num(&c[3])) && curp_valid(&curp) {
|
||||||
|
findings.insert(curp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn num(s: &str) -> u32 {
|
||||||
|
s.parse().unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::mailflow::detectors::by_id;
|
||||||
|
|
||||||
|
fn count(id: &str, text: &str) -> usize {
|
||||||
|
by_id(id).unwrap().count(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn brazil() {
|
||||||
|
assert_eq!(count("br-cpf", "CPF 111.444.777-35"), 1);
|
||||||
|
assert_eq!(count("br-cpf", "111.444.777-36"), 0);
|
||||||
|
assert_eq!(count("br-cpf", "pedido 11144477735"), 0);
|
||||||
|
assert_eq!(count("br-cpf", "cpf: 11144477735"), 1);
|
||||||
|
assert_eq!(count("br-cpf", "CPF 111.111.111-11"), 0);
|
||||||
|
assert_eq!(count("br-cnpj", "11.222.333/0001-81"), 1);
|
||||||
|
assert_eq!(count("br-cnpj", "11.222.333/0001-82"), 0);
|
||||||
|
assert_eq!(count("br-cnpj", "CNPJ 11222333000181"), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mexico() {
|
||||||
|
// python-stdnum's documented example
|
||||||
|
assert_eq!(count("mx-curp", "CURP BOXW310820HNERXN09"), 1);
|
||||||
|
assert_eq!(count("mx-curp", "BOXW310820HNERXN08"), 0);
|
||||||
|
assert_eq!(count("mx-curp", "BOXW311320HNERXN09"), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,511 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Detectors that aren't tied to one country (§2.3, region "Any").
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
Detector, Findings, Region, Strength, checks, digits, stands_alone, valid_date, word_near,
|
||||||
|
};
|
||||||
|
use regex::Regex;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
pub static DETECTORS: &[Detector] = &[
|
||||||
|
Detector::new(
|
||||||
|
"payment-card",
|
||||||
|
"Payment card number",
|
||||||
|
Region::Any,
|
||||||
|
Strength::Checked,
|
||||||
|
payment_card,
|
||||||
|
),
|
||||||
|
Detector::new("iban", "IBAN", Region::Any, Strength::Checked, iban),
|
||||||
|
Detector::new(
|
||||||
|
"swift-bic",
|
||||||
|
"SWIFT/BIC code",
|
||||||
|
Region::Any,
|
||||||
|
Strength::NeedsWord,
|
||||||
|
swift_bic,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"email-addresses",
|
||||||
|
"Email addresses",
|
||||||
|
Region::Any,
|
||||||
|
Strength::Checked,
|
||||||
|
email_addresses,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"phone-numbers",
|
||||||
|
"Phone numbers",
|
||||||
|
Region::Any,
|
||||||
|
Strength::NeedsWord,
|
||||||
|
phone_numbers,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"date-of-birth",
|
||||||
|
"Date of birth",
|
||||||
|
Region::Any,
|
||||||
|
Strength::NeedsWord,
|
||||||
|
date_of_birth,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"passport",
|
||||||
|
"Passport number",
|
||||||
|
Region::Any,
|
||||||
|
Strength::NeedsWord,
|
||||||
|
passport,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"private-key",
|
||||||
|
"Private key",
|
||||||
|
Region::Any,
|
||||||
|
Strength::Checked,
|
||||||
|
private_key,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"credentials",
|
||||||
|
"Cloud and service credentials",
|
||||||
|
Region::Any,
|
||||||
|
Strength::Checked,
|
||||||
|
credentials,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn re(pattern: &str) -> Regex {
|
||||||
|
Regex::new(pattern).expect("detector pattern")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Payment cards --------------------------------------------------------
|
||||||
|
|
||||||
|
/// Issuer prefixes (ISO/IEC 7812 IINs) and the lengths each network issues.
|
||||||
|
fn card_network(number: &str) -> bool {
|
||||||
|
let len = number.len();
|
||||||
|
let prefix = |n: usize| number[..n].parse::<u32>().unwrap_or(0);
|
||||||
|
match number.as_bytes()[0] {
|
||||||
|
// Visa
|
||||||
|
b'4' => matches!(len, 13 | 16 | 19),
|
||||||
|
b'5' => {
|
||||||
|
// Mastercard 51–55; Maestro 50, 56–58
|
||||||
|
(51..=55).contains(&prefix(2)) && len == 16
|
||||||
|
|| matches!(prefix(2), 50 | 56..=58) && (12..=19).contains(&len)
|
||||||
|
}
|
||||||
|
// Mastercard 2221–2720
|
||||||
|
b'2' => (2221..=2720).contains(&prefix(4)) && len == 16,
|
||||||
|
b'3' => {
|
||||||
|
// American Express 34, 37; JCB 3528–3589; Diners 300–305, 36, 38, 39
|
||||||
|
matches!(prefix(2), 34 | 37) && len == 15
|
||||||
|
|| (3528..=3589).contains(&prefix(4)) && (16..=19).contains(&len)
|
||||||
|
|| ((300..=305).contains(&prefix(3)) || matches!(prefix(2), 36 | 38 | 39))
|
||||||
|
&& (14..=19).contains(&len)
|
||||||
|
}
|
||||||
|
// Discover 6011, 644–649, 65; UnionPay 62; Maestro 6x
|
||||||
|
b'6' => (12..=19).contains(&len),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_card(number: &str) -> bool {
|
||||||
|
(12..=19).contains(&number.len()) && card_network(number) && checks::luhn(number)
|
||||||
|
}
|
||||||
|
|
||||||
|
static CARD: LazyLock<Regex> = LazyLock::new(|| re(r"\b\d(?:[ -]?\d){11,18}\b"));
|
||||||
|
|
||||||
|
fn payment_card(text: &str, findings: &mut Findings) {
|
||||||
|
for m in CARD.find_iter(text) {
|
||||||
|
if !stands_alone(text, m.start(), m.end()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let whole = digits(m.as_str());
|
||||||
|
if is_card(&whole) {
|
||||||
|
findings.insert(whole);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Two numbers side by side ("4242 4242 4242 4242 2031"): try each
|
||||||
|
// run of whole groups
|
||||||
|
let groups: Vec<String> = m.as_str().split([' ', '-']).map(digits).collect();
|
||||||
|
'runs: for from in 0..groups.len() {
|
||||||
|
let mut number = String::new();
|
||||||
|
for group in &groups[from..] {
|
||||||
|
number.push_str(group);
|
||||||
|
if is_card(&number) {
|
||||||
|
findings.insert(number);
|
||||||
|
break 'runs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- IBAN -----------------------------------------------------------------
|
||||||
|
|
||||||
|
static IBAN: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| re(r"\b[A-Za-z]{2}\d{2}(?:[ ]?[A-Za-z0-9]){11,30}"));
|
||||||
|
|
||||||
|
fn iban(text: &str, findings: &mut Findings) {
|
||||||
|
// The pattern can run on into the next words, even the next IBAN: after
|
||||||
|
// each hit, look again from where that IBAN ended
|
||||||
|
let mut from = 0;
|
||||||
|
while let Some(m) = IBAN.find_at(text, from) {
|
||||||
|
from = m.start() + 1;
|
||||||
|
let compact = m.as_str().replace(' ', "").to_ascii_uppercase();
|
||||||
|
let Some(len) = checks::iban_length(&compact[..2]) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if compact.len() < len {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Where the country's length ends in the text, spaces counted
|
||||||
|
let mut seen = 0;
|
||||||
|
let Some(end) = m
|
||||||
|
.as_str()
|
||||||
|
.char_indices()
|
||||||
|
.find(|(_, c)| {
|
||||||
|
if *c != ' ' {
|
||||||
|
seen += 1;
|
||||||
|
}
|
||||||
|
seen == len
|
||||||
|
})
|
||||||
|
.map(|(i, c)| m.start() + i + c.len_utf8())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let candidate = &compact[..len];
|
||||||
|
if stands_alone(text, m.start(), end) && checks::iban(candidate) {
|
||||||
|
findings.insert(candidate);
|
||||||
|
from = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SWIFT/BIC ------------------------------------------------------------
|
||||||
|
|
||||||
|
static BIC: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| re(r"\b[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}(?:[A-Z0-9]{3})?\b"));
|
||||||
|
|
||||||
|
const BIC_WORDS: &[&str] = &[
|
||||||
|
"swift",
|
||||||
|
"bic",
|
||||||
|
"swift/bic",
|
||||||
|
"bank",
|
||||||
|
"banque",
|
||||||
|
"bankverbindung",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn swift_bic(text: &str, findings: &mut Findings) {
|
||||||
|
for m in BIC.find_iter(text) {
|
||||||
|
let code = m.as_str();
|
||||||
|
if checks::is_country(&code[4..6]) && word_near(text, m.start(), m.end(), BIC_WORDS) {
|
||||||
|
findings.insert(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Contact lists --------------------------------------------------------
|
||||||
|
|
||||||
|
static EMAIL: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| re(r"(?i)\b[a-z0-9._%+-]+@[a-z0-9-]+(?:\.[a-z0-9-]+)*\.[a-z]{2,}\b"));
|
||||||
|
|
||||||
|
fn email_addresses(text: &str, findings: &mut Findings) {
|
||||||
|
for m in EMAIL.find_iter(text) {
|
||||||
|
findings.insert(m.as_str().to_lowercase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// International form: found alone. National form: only with a word.
|
||||||
|
static PHONE_INTL: LazyLock<Regex> = LazyLock::new(|| re(r"\+\d{1,3}(?:[ .-]?\(?\d{1,4}\)?){2,5}"));
|
||||||
|
static PHONE_NATIONAL: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| re(r"\(?\d{2,4}\)?[ .-]\d{3,4}[ .-]\d{3,4}"));
|
||||||
|
|
||||||
|
const PHONE_WORDS: &[&str] = &[
|
||||||
|
"phone",
|
||||||
|
"tel",
|
||||||
|
"telephone",
|
||||||
|
"mobile",
|
||||||
|
"cell",
|
||||||
|
"fax",
|
||||||
|
"telefon",
|
||||||
|
"téléphone",
|
||||||
|
"teléfono",
|
||||||
|
"telefono",
|
||||||
|
"handy",
|
||||||
|
"portable",
|
||||||
|
"móvil",
|
||||||
|
"cellulare",
|
||||||
|
"mobiel",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn phone_numbers(text: &str, findings: &mut Findings) {
|
||||||
|
let mut international = Vec::new();
|
||||||
|
for m in PHONE_INTL.find_iter(text) {
|
||||||
|
let number = digits(m.as_str());
|
||||||
|
if (8..=15).contains(&number.len()) && stands_alone(text, m.start() + 1, m.end()) {
|
||||||
|
findings.insert(number);
|
||||||
|
international.push(m.range());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for m in PHONE_NATIONAL.find_iter(text) {
|
||||||
|
let number = digits(m.as_str());
|
||||||
|
// Not the tail of an international number already counted
|
||||||
|
if international.iter().any(|r| r.contains(&m.start())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (9..=11).contains(&number.len())
|
||||||
|
&& stands_alone(text, m.start(), m.end())
|
||||||
|
&& !text[..m.start()].ends_with('+')
|
||||||
|
&& word_near(text, m.start(), m.end(), PHONE_WORDS)
|
||||||
|
{
|
||||||
|
findings.insert(number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Date of birth --------------------------------------------------------
|
||||||
|
|
||||||
|
static DATE_ISO: LazyLock<Regex> = LazyLock::new(|| re(r"\b(\d{4})-(\d{2})-(\d{2})\b"));
|
||||||
|
static DATE_NUMERIC: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| re(r"\b(\d{1,2})[./-](\d{1,2})[./-](\d{4})\b"));
|
||||||
|
static DATE_WORDS: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
re(
|
||||||
|
r"(?i)\b(?:(\d{1,2})\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?,?\s+(\d{4})|(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+(\d{1,2}),?\s+(\d{4}))\b",
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
const BIRTH_WORDS: &[&str] = &[
|
||||||
|
"born",
|
||||||
|
"birth",
|
||||||
|
"dob",
|
||||||
|
"d.o.b",
|
||||||
|
"birthday",
|
||||||
|
"birthdate",
|
||||||
|
"geburtsdatum",
|
||||||
|
"geboren",
|
||||||
|
"naissance",
|
||||||
|
"né le",
|
||||||
|
"née le",
|
||||||
|
"nacimiento",
|
||||||
|
"nacido",
|
||||||
|
"nacida",
|
||||||
|
"nascita",
|
||||||
|
"nato il",
|
||||||
|
"nata il",
|
||||||
|
"geboortedatum",
|
||||||
|
"födelsedatum",
|
||||||
|
"fødselsdato",
|
||||||
|
"syntymäaika",
|
||||||
|
"urodzenia",
|
||||||
|
"nascimento",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn month_number(name: &str) -> u32 {
|
||||||
|
const MONTHS: [&str; 12] = [
|
||||||
|
"jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
|
||||||
|
];
|
||||||
|
let name = name.to_lowercase();
|
||||||
|
MONTHS
|
||||||
|
.iter()
|
||||||
|
.position(|m| *m == name)
|
||||||
|
.map_or(0, |i| i as u32 + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn date_of_birth(text: &str, findings: &mut Findings) {
|
||||||
|
let mut add = |start: usize, end: usize, key: String| {
|
||||||
|
if word_near(text, start, end, BIRTH_WORDS) {
|
||||||
|
findings.insert(key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let num = |s: &str| s.parse::<u32>().unwrap_or(0);
|
||||||
|
for c in DATE_ISO.captures_iter(text) {
|
||||||
|
let (y, m, d) = (num(&c[1]), num(&c[2]), num(&c[3]));
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
if valid_date(y, m, d) {
|
||||||
|
add(whole.start(), whole.end(), format!("{y:04}{m:02}{d:02}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for c in DATE_NUMERIC.captures_iter(text) {
|
||||||
|
let (a, b, y) = (num(&c[1]), num(&c[2]), num(&c[3]));
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
// Day first or month first: either reading that is a real date
|
||||||
|
if valid_date(y, b, a) || valid_date(y, a, b) {
|
||||||
|
add(whole.start(), whole.end(), whole.as_str().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for c in DATE_WORDS.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
let (d, m, y) = match (c.get(1), c.get(4)) {
|
||||||
|
(Some(d), _) => (num(d.as_str()), month_number(&c[2]), num(&c[3])),
|
||||||
|
(_, Some(m)) => (num(&c[5]), month_number(m.as_str()), num(&c[6])),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
if valid_date(y, m, d) {
|
||||||
|
add(whole.start(), whole.end(), format!("{y:04}{m:02}{d:02}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Passport -------------------------------------------------------------
|
||||||
|
|
||||||
|
static PASSPORT: LazyLock<Regex> = LazyLock::new(|| re(r"\b[A-Z0-9]{6,9}\b"));
|
||||||
|
|
||||||
|
const PASSPORT_WORDS: &[&str] = &[
|
||||||
|
"passport",
|
||||||
|
"passeport",
|
||||||
|
"reisepass",
|
||||||
|
"pasaporte",
|
||||||
|
"passaporto",
|
||||||
|
"paspoort",
|
||||||
|
"passnummer",
|
||||||
|
"pass-nr",
|
||||||
|
"passport no",
|
||||||
|
"pasaporte n.º",
|
||||||
|
"passaporte",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn passport(text: &str, findings: &mut Findings) {
|
||||||
|
for m in PASSPORT.find_iter(text) {
|
||||||
|
let value = m.as_str();
|
||||||
|
if value.bytes().filter(u8::is_ascii_digit).count() >= 5
|
||||||
|
&& word_near(text, m.start(), m.end(), PASSPORT_WORDS)
|
||||||
|
{
|
||||||
|
findings.insert(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Keys and credentials -------------------------------------------------
|
||||||
|
|
||||||
|
static PRIVATE_KEY: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
re(
|
||||||
|
r"-----BEGIN (?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED|PGP) )?PRIVATE KEY(?: BLOCK)?-----\s*([A-Za-z0-9+/=:\s-]{0,64})",
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
fn private_key(text: &str, findings: &mut Findings) {
|
||||||
|
for c in PRIVATE_KEY.captures_iter(text) {
|
||||||
|
// Each key once, by the start of its body
|
||||||
|
let body: String = c[1].chars().filter(|c| !c.is_whitespace()).collect();
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
findings.insert(if body.is_empty() {
|
||||||
|
format!("@{}", whole.start())
|
||||||
|
} else {
|
||||||
|
body
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Published token formats: AWS access key IDs, GitHub tokens, Slack
|
||||||
|
/// tokens, Stripe live secret and restricted keys, Google API keys.
|
||||||
|
static CREDENTIAL: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
re(concat!(
|
||||||
|
r"\b(?:",
|
||||||
|
r"(?:AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}",
|
||||||
|
r"|gh[pousr]_[A-Za-z0-9]{36}",
|
||||||
|
r"|github_pat_[A-Za-z0-9_]{82}",
|
||||||
|
r"|xox[abposr]-[A-Za-z0-9-]{10,72}",
|
||||||
|
r"|(?:sk|rk)_live_[A-Za-z0-9]{24,99}",
|
||||||
|
r"|AIza[0-9A-Za-z_-]{35}",
|
||||||
|
r")\b"
|
||||||
|
))
|
||||||
|
});
|
||||||
|
|
||||||
|
fn credentials(text: &str, findings: &mut Findings) {
|
||||||
|
for m in CREDENTIAL.find_iter(text) {
|
||||||
|
findings.insert(m.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::mailflow::detectors::by_id;
|
||||||
|
|
||||||
|
fn count(id: &str, text: &str) -> usize {
|
||||||
|
by_id(id).unwrap().count(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn payment_cards() {
|
||||||
|
// Networks' and processors' published test numbers
|
||||||
|
let text = "Visa 4242 4242 4242 4242, MC 5555-5555-5555-4444, Amex 378282246310005, \
|
||||||
|
Discover 6011111111111117, JCB 3566002020360505, Diners 30569309025904, \
|
||||||
|
UnionPay 6200000000000005, Mastercard 2-series 2223003122003222";
|
||||||
|
assert_eq!(count("payment-card", text), 8);
|
||||||
|
// Luhn fails, wrong network length, inside a longer number
|
||||||
|
assert_eq!(count("payment-card", "4242424242424241"), 0);
|
||||||
|
assert_eq!(count("payment-card", "378282246310005 0"), 1);
|
||||||
|
assert_eq!(count("payment-card", "order 94242424242424242 shipped"), 0);
|
||||||
|
// The same number twice counts once
|
||||||
|
assert_eq!(
|
||||||
|
count("payment-card", "4242424242424242 and 4242-4242-4242-4242"),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
// A card followed by a year
|
||||||
|
assert_eq!(count("payment-card", "card 4242 4242 4242 4242 2031"), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ibans() {
|
||||||
|
let text =
|
||||||
|
"Pay GB29 NWBK 6016 1331 9268 19 or de89370400440532013000 (NL91ABNA0417164300).";
|
||||||
|
assert_eq!(count("iban", text), 3);
|
||||||
|
assert_eq!(count("iban", "GB29 NWBK 6016 1331 9268 18"), 0);
|
||||||
|
// Runs into the next word: still found at the country's length
|
||||||
|
assert_eq!(count("iban", "IBAN NL91ABNA0417164300 BIC ABNANL2A"), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn swift_codes_need_a_word() {
|
||||||
|
assert_eq!(count("swift-bic", "SWIFT: DEUTDEFF500"), 1);
|
||||||
|
assert_eq!(count("swift-bic", "BIC NWBKGB2L"), 1);
|
||||||
|
assert_eq!(count("swift-bic", "HAPPYDAYS DEUTDEFF"), 0);
|
||||||
|
// Not a country in positions 5–6
|
||||||
|
assert_eq!(count("swift-bic", "BIC DEUTZZFF"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn email_and_phone_lists() {
|
||||||
|
let list = "[email protected], [email protected], [email protected], [email protected]";
|
||||||
|
assert_eq!(count("email-addresses", list), 3);
|
||||||
|
assert_eq!(
|
||||||
|
count("phone-numbers", "+44 20 7946 0958, +1 (415) 555-2671"),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert_eq!(count("phone-numbers", "call 020 7946 0958"), 0);
|
||||||
|
assert_eq!(count("phone-numbers", "Tel: 020 7946 0958"), 1);
|
||||||
|
assert_eq!(count("phone-numbers", "invoice 020 7946 0958"), 0);
|
||||||
|
// One number, not also its national tail
|
||||||
|
assert_eq!(count("phone-numbers", "Tel: +44 20 7946 0958"), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dates_of_birth() {
|
||||||
|
assert_eq!(count("date-of-birth", "DOB: 1984-02-29"), 1);
|
||||||
|
assert_eq!(count("date-of-birth", "Geburtsdatum 31.12.1970"), 1);
|
||||||
|
assert_eq!(count("date-of-birth", "born on March 3, 1962"), 1);
|
||||||
|
assert_eq!(count("date-of-birth", "date of birth 3 Mar 1962"), 1);
|
||||||
|
// Not a real date, no word, a meeting
|
||||||
|
assert_eq!(count("date-of-birth", "DOB: 1985-02-29"), 0);
|
||||||
|
assert_eq!(count("date-of-birth", "invoice 1984-02-29"), 0);
|
||||||
|
assert_eq!(count("date-of-birth", "Meeting on 12/05/2026"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn passports_need_a_word() {
|
||||||
|
assert_eq!(count("passport", "Passport number: 533380006"), 1);
|
||||||
|
assert_eq!(count("passport", "Reisepass C01X00T47"), 1);
|
||||||
|
assert_eq!(count("passport", "Order 533380006 shipped"), 0);
|
||||||
|
// Mostly letters: a word, not a number
|
||||||
|
assert_eq!(count("passport", "passport PASSWORD"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keys_and_credentials() {
|
||||||
|
let key = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQ\n-----END OPENSSH PRIVATE KEY-----";
|
||||||
|
assert_eq!(count("private-key", key), 1);
|
||||||
|
assert_eq!(count("private-key", "-----BEGIN PUBLIC KEY-----\nMFkw"), 0);
|
||||||
|
// Documentation examples of each format
|
||||||
|
let tokens = "AKIAIOSFODNN7EXAMPLE ghp_0123456789abcdefghijklmnopqrstuvwxyz \
|
||||||
|
AIzaSyA-0123456789abcdefghijklmnopqrstu";
|
||||||
|
assert_eq!(count("credentials", tokens), 3);
|
||||||
|
assert_eq!(count("credentials", "AKIA123 ghp_short"), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Asian identifiers (§2.3): India's Aadhaar and PAN, China's resident ID,
|
||||||
|
//! Japan's My Number, Singapore's NRIC and FIN, and South Korea's resident
|
||||||
|
//! registration number.
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
Detector, Findings, Region, Strength, digit_values, valid_date, valid_short_date, word_near,
|
||||||
|
};
|
||||||
|
use regex::Regex;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
pub static DETECTORS: &[Detector] = &[
|
||||||
|
Detector::new(
|
||||||
|
"in-aadhaar",
|
||||||
|
"India: Aadhaar",
|
||||||
|
Region::Asia,
|
||||||
|
Strength::Checked,
|
||||||
|
in_aadhaar,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"in-pan",
|
||||||
|
"India: PAN",
|
||||||
|
Region::Asia,
|
||||||
|
Strength::NeedsWord,
|
||||||
|
in_pan,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"cn-resident-id",
|
||||||
|
"China: resident ID",
|
||||||
|
Region::Asia,
|
||||||
|
Strength::Checked,
|
||||||
|
cn_resident_id,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"jp-my-number",
|
||||||
|
"Japan: My Number",
|
||||||
|
Region::Asia,
|
||||||
|
Strength::Checked,
|
||||||
|
jp_my_number,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"sg-nric",
|
||||||
|
"Singapore: NRIC and FIN",
|
||||||
|
Region::Asia,
|
||||||
|
Strength::Checked,
|
||||||
|
sg_nric,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"kr-rrn",
|
||||||
|
"South Korea: resident registration number",
|
||||||
|
Region::Asia,
|
||||||
|
Strength::NeedsWord,
|
||||||
|
kr_rrn,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn re(pattern: &str) -> Regex {
|
||||||
|
Regex::new(pattern).expect("detector pattern")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Twelve digits written in fours, or bare.
|
||||||
|
static TWELVE: LazyLock<Regex> = LazyLock::new(|| re(r"\b(\d{4})( ?)(\d{4})( ?)(\d{4})\b"));
|
||||||
|
|
||||||
|
const VERHOEFF_D: [[u8; 10]; 10] = [
|
||||||
|
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||||
|
[1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
|
||||||
|
[2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
|
||||||
|
[3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
|
||||||
|
[4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
|
||||||
|
[5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
|
||||||
|
[6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
|
||||||
|
[7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
|
||||||
|
[8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
|
||||||
|
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
|
||||||
|
];
|
||||||
|
const VERHOEFF_P: [[u8; 10]; 8] = [
|
||||||
|
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||||
|
[1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
|
||||||
|
[5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
|
||||||
|
[8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
|
||||||
|
[9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
|
||||||
|
[4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
|
||||||
|
[2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
|
||||||
|
[7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The Verhoeff check (dihedral group D5).
|
||||||
|
pub fn verhoeff(n: &str) -> bool {
|
||||||
|
let mut c = 0u8;
|
||||||
|
for (i, b) in n.bytes().rev().enumerate() {
|
||||||
|
c = VERHOEFF_D[c as usize][VERHOEFF_P[i % 8][(b - b'0') as usize] as usize];
|
||||||
|
}
|
||||||
|
c == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const AADHAAR_WORDS: &[&str] = &["aadhaar", "aadhar", "uidai", "uid"];
|
||||||
|
|
||||||
|
fn in_aadhaar(text: &str, findings: &mut Findings) {
|
||||||
|
for c in TWELVE.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
let n = format!("{}{}{}", &c[1], &c[3], &c[5]);
|
||||||
|
let written = &c[2] == " " && &c[4] == " ";
|
||||||
|
// Never starts with 0 or 1
|
||||||
|
if !n.starts_with(['0', '1'])
|
||||||
|
&& verhoeff(&n)
|
||||||
|
&& (written || word_near(text, whole.start(), whole.end(), AADHAAR_WORDS))
|
||||||
|
{
|
||||||
|
findings.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Five letters (the fourth names the holder's type), four digits, a letter.
|
||||||
|
static PAN: LazyLock<Regex> = LazyLock::new(|| re(r"\b[A-Z]{3}[ABCFGHLJPTK][A-Z]\d{4}[A-Z]\b"));
|
||||||
|
|
||||||
|
const PAN_WORDS: &[&str] = &["pan", "pan card", "permanent account number", "income tax"];
|
||||||
|
|
||||||
|
fn in_pan(text: &str, findings: &mut Findings) {
|
||||||
|
for m in PAN.find_iter(text) {
|
||||||
|
if word_near(text, m.start(), m.end(), PAN_WORDS) {
|
||||||
|
findings.insert(m.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Region, birth date `YYYYMMDD`, sequence, then the ISO 7064 MOD 11-2
|
||||||
|
/// check (0–9 or X).
|
||||||
|
static CN_ID: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| re(r"(?i)\b[1-8]\d{5}(\d{4})(\d{2})(\d{2})\d{3}[\dX]\b"));
|
||||||
|
|
||||||
|
pub fn cn_id_valid(id: &str) -> bool {
|
||||||
|
const WEIGHTS: [u32; 17] = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
|
||||||
|
const CHECKS: &[u8] = b"10X98765432";
|
||||||
|
let sum: u32 = digit_values(&id[..17])
|
||||||
|
.iter()
|
||||||
|
.zip(WEIGHTS)
|
||||||
|
.map(|(a, w)| a * w)
|
||||||
|
.sum();
|
||||||
|
CHECKS[(sum % 11) as usize] == id.as_bytes()[17].to_ascii_uppercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cn_resident_id(text: &str, findings: &mut Findings) {
|
||||||
|
for c in CN_ID.captures_iter(text) {
|
||||||
|
let id = c[0].to_ascii_uppercase();
|
||||||
|
let (y, m, d) = (num(&c[1]), num(&c[2]), num(&c[3]));
|
||||||
|
if valid_date(y, m, d) && cn_id_valid(&id) {
|
||||||
|
findings.insert(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// My Number: weights 2–7 then 2–6 from the right; a remainder of 0 or 1
|
||||||
|
/// gives 0, else 11 minus it.
|
||||||
|
pub fn my_number_valid(n: &str) -> bool {
|
||||||
|
let d = digit_values(n);
|
||||||
|
let sum: u32 = (1..=11)
|
||||||
|
.map(|i| d[11 - i] * if i <= 6 { i as u32 + 1 } else { i as u32 - 5 })
|
||||||
|
.sum();
|
||||||
|
let check = match sum % 11 {
|
||||||
|
0 | 1 => 0,
|
||||||
|
r => 11 - r,
|
||||||
|
};
|
||||||
|
check == d[11]
|
||||||
|
}
|
||||||
|
|
||||||
|
const MY_NUMBER_WORDS: &[&str] = &[
|
||||||
|
"my number",
|
||||||
|
"mynumber",
|
||||||
|
"マイナンバー",
|
||||||
|
"個人番号",
|
||||||
|
"kojin bango",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn jp_my_number(text: &str, findings: &mut Findings) {
|
||||||
|
for c in TWELVE.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
let n = format!("{}{}{}", &c[1], &c[3], &c[5]);
|
||||||
|
let written = &c[2] == " " && &c[4] == " ";
|
||||||
|
if my_number_valid(&n)
|
||||||
|
&& (written || word_near(text, whole.start(), whole.end(), MY_NUMBER_WORDS))
|
||||||
|
{
|
||||||
|
findings.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static NRIC: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\b([STFGM])(\d{7})([A-Z])\b"));
|
||||||
|
|
||||||
|
/// Weights 2, 7, 6, 5, 4, 3, 2; T and G add 4, M adds 3; each series has its
|
||||||
|
/// own table of check letters.
|
||||||
|
fn nric_valid(prefix: u8, digits: &str, check: u8) -> bool {
|
||||||
|
let sum: u32 = digit_values(digits)
|
||||||
|
.iter()
|
||||||
|
.zip([2, 7, 6, 5, 4, 3, 2])
|
||||||
|
.map(|(a, w)| a * w)
|
||||||
|
.sum::<u32>()
|
||||||
|
+ match prefix {
|
||||||
|
b'T' | b'G' => 4,
|
||||||
|
b'M' => 3,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
let table: &[u8] = match prefix {
|
||||||
|
b'S' | b'T' => b"JZIHGFEDCBA",
|
||||||
|
b'F' | b'G' => b"XWUTRQPNMLK",
|
||||||
|
_ => b"KLJNPQRTUWX",
|
||||||
|
};
|
||||||
|
table[(sum % 11) as usize] == check
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sg_nric(text: &str, findings: &mut Findings) {
|
||||||
|
for c in NRIC.captures_iter(text) {
|
||||||
|
let id = c[0].to_ascii_uppercase();
|
||||||
|
let bytes = id.as_bytes();
|
||||||
|
if nric_valid(bytes[0], &c[2], bytes[8]) {
|
||||||
|
findings.insert(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `YYMMDD-GNNNNNN`, the seventh digit giving sex and century.
|
||||||
|
static RRN: LazyLock<Regex> = LazyLock::new(|| re(r"\b(\d{2})(\d{2})(\d{2})-?([1-8])\d{6}\b"));
|
||||||
|
|
||||||
|
const RRN_WORDS: &[&str] = &["주민등록번호", "주민번호", "resident registration", "rrn"];
|
||||||
|
|
||||||
|
fn kr_rrn(text: &str, findings: &mut Findings) {
|
||||||
|
for c in RRN.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
if valid_short_date(num(&c[1]), num(&c[2]), num(&c[3]))
|
||||||
|
&& word_near(text, whole.start(), whole.end(), RRN_WORDS)
|
||||||
|
{
|
||||||
|
findings.insert(whole.as_str().replace('-', ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn num(s: &str) -> u32 {
|
||||||
|
s.parse().unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::mailflow::detectors::by_id;
|
||||||
|
|
||||||
|
fn count(id: &str, text: &str) -> usize {
|
||||||
|
by_id(id).unwrap().count(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn india() {
|
||||||
|
assert_eq!(count("in-aadhaar", "2345 6789 0124"), 1);
|
||||||
|
assert_eq!(count("in-aadhaar", "2345 6789 0125"), 0);
|
||||||
|
assert_eq!(count("in-aadhaar", "order 234567890124"), 0);
|
||||||
|
assert_eq!(count("in-aadhaar", "Aadhaar 234567890124"), 1);
|
||||||
|
assert_eq!(count("in-pan", "PAN: ABCPE1234F"), 1);
|
||||||
|
assert_eq!(count("in-pan", "ABCPE1234F"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn china_japan() {
|
||||||
|
assert_eq!(count("cn-resident-id", "11010519491231002X"), 1);
|
||||||
|
assert_eq!(count("cn-resident-id", "110105194912310021"), 0);
|
||||||
|
assert_eq!(count("cn-resident-id", "11010519491331002X"), 0);
|
||||||
|
assert_eq!(count("jp-my-number", "1234 5678 9018"), 1);
|
||||||
|
assert_eq!(count("jp-my-number", "1234 5678 9017"), 0);
|
||||||
|
assert_eq!(count("jp-my-number", "マイナンバー 123456789018"), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn singapore_korea() {
|
||||||
|
assert_eq!(count("sg-nric", "S1234567D and T1234567J"), 2);
|
||||||
|
assert_eq!(count("sg-nric", "S1234567E"), 0);
|
||||||
|
assert_eq!(count("kr-rrn", "주민등록번호 800101-1234567"), 1);
|
||||||
|
assert_eq!(count("kr-rrn", "800101-1234567"), 0);
|
||||||
|
assert_eq!(count("kr-rrn", "RRN 801301-1234567"), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Australian identifiers (§2.3): the ATO's Tax File Number and the Medicare
|
||||||
|
//! card number.
|
||||||
|
|
||||||
|
use super::{Detector, Findings, Region, Strength, word_near};
|
||||||
|
use regex::Regex;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
pub static DETECTORS: &[Detector] = &[
|
||||||
|
Detector::new(
|
||||||
|
"au-tfn",
|
||||||
|
"Australian Tax File Number",
|
||||||
|
Region::Australia,
|
||||||
|
Strength::Checked,
|
||||||
|
tfn,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"au-medicare",
|
||||||
|
"Australian Medicare number",
|
||||||
|
Region::Australia,
|
||||||
|
Strength::Checked,
|
||||||
|
medicare,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn re(pattern: &str) -> Regex {
|
||||||
|
Regex::new(pattern).expect("detector pattern")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NNN NNN NNN` stands alone; bare digits (eight or nine) need a word.
|
||||||
|
static TFN: LazyLock<Regex> = LazyLock::new(|| re(r"\b(\d{3})( ?)(\d{3})( ?)(\d{2,3})\b"));
|
||||||
|
|
||||||
|
/// Weighted sum mod 11, with the ATO's weights for 9- and 8-digit numbers.
|
||||||
|
pub fn tfn_valid(n: &str) -> bool {
|
||||||
|
let weights: &[u32] = match n.len() {
|
||||||
|
9 => &[1, 4, 3, 7, 5, 8, 6, 9, 10],
|
||||||
|
8 => &[10, 7, 8, 4, 6, 3, 5, 1],
|
||||||
|
_ => return false,
|
||||||
|
};
|
||||||
|
n.bytes()
|
||||||
|
.zip(weights)
|
||||||
|
.map(|(b, w)| u32::from(b - b'0') * w)
|
||||||
|
.sum::<u32>()
|
||||||
|
% 11
|
||||||
|
== 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const TFN_WORDS: &[&str] = &["tfn", "tax file number", "tax file no"];
|
||||||
|
|
||||||
|
fn tfn(text: &str, findings: &mut Findings) {
|
||||||
|
for c in TFN.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
let n = format!("{}{}{}", &c[1], &c[3], &c[5]);
|
||||||
|
let written = n.len() == 9 && c[2] == *" " && c[4] == *" ";
|
||||||
|
if tfn_valid(&n) && (written || word_near(text, whole.start(), whole.end(), TFN_WORDS)) {
|
||||||
|
findings.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NNNN NNNNN N` (and an optional issue number) stands alone; bare digits
|
||||||
|
/// need a word.
|
||||||
|
static MEDICARE: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| re(r"\b([2-6]\d{3})( ?)(\d{5})( ?)(\d)(?:[ -]?\d)?\b"));
|
||||||
|
|
||||||
|
/// The ninth digit is the weighted sum (1, 3, 7, 9, 1, 3, 7, 9) of the first
|
||||||
|
/// eight, mod 10.
|
||||||
|
pub fn medicare_valid(n: &str) -> bool {
|
||||||
|
let d: Vec<u32> = n.bytes().map(|b| u32::from(b - b'0')).collect();
|
||||||
|
d.len() >= 9
|
||||||
|
&& d[..8]
|
||||||
|
.iter()
|
||||||
|
.zip([1, 3, 7, 9, 1, 3, 7, 9])
|
||||||
|
.map(|(a, w)| a * w)
|
||||||
|
.sum::<u32>()
|
||||||
|
% 10
|
||||||
|
== d[8]
|
||||||
|
}
|
||||||
|
|
||||||
|
const MEDICARE_WORDS: &[&str] = &[
|
||||||
|
"medicare",
|
||||||
|
"medicare card",
|
||||||
|
"medicare no",
|
||||||
|
"medicare number",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn medicare(text: &str, findings: &mut Findings) {
|
||||||
|
for c in MEDICARE.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
let n = format!("{}{}{}", &c[1], &c[3], &c[5]);
|
||||||
|
let written = c[2] == *" " && c[4] == *" ";
|
||||||
|
if medicare_valid(&n)
|
||||||
|
&& (written || word_near(text, whole.start(), whole.end(), MEDICARE_WORDS))
|
||||||
|
{
|
||||||
|
findings.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::mailflow::detectors::by_id;
|
||||||
|
|
||||||
|
fn count(id: &str, text: &str) -> usize {
|
||||||
|
by_id(id).unwrap().count(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tax_file_numbers() {
|
||||||
|
assert_eq!(count("au-tfn", "TFN 123 456 782"), 1);
|
||||||
|
assert_eq!(count("au-tfn", "123 456 789"), 0);
|
||||||
|
assert_eq!(count("au-tfn", "order 123456782"), 0);
|
||||||
|
assert_eq!(count("au-tfn", "tax file number 123456782"), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn medicare_numbers() {
|
||||||
|
assert_eq!(count("au-medicare", "2123 45670 1"), 1);
|
||||||
|
assert_eq!(count("au-medicare", "2123 45671 1"), 0);
|
||||||
|
assert_eq!(count("au-medicare", "ref 2123456701"), 0);
|
||||||
|
assert_eq!(count("au-medicare", "Medicare 2123456701"), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Canadian identifiers (§2.3): the Social Insurance Number.
|
||||||
|
|
||||||
|
use super::{Detector, Findings, Region, Strength, checks, word_near};
|
||||||
|
use regex::Regex;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
pub static DETECTORS: &[Detector] = &[Detector::new(
|
||||||
|
"ca-sin",
|
||||||
|
"Canadian Social Insurance Number",
|
||||||
|
Region::Canada,
|
||||||
|
Strength::Checked,
|
||||||
|
sin,
|
||||||
|
)];
|
||||||
|
|
||||||
|
/// `NNN NNN NNN` or `NNN-NNN-NNN` stands alone; nine bare digits need a word.
|
||||||
|
static SIN: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"\b(\d{3})([ -]?)(\d{3})([ -]?)(\d{3})\b").expect("detector pattern")
|
||||||
|
});
|
||||||
|
|
||||||
|
const SIN_WORDS: &[&str] = &[
|
||||||
|
"sin",
|
||||||
|
"social insurance",
|
||||||
|
"nas",
|
||||||
|
"numéro d'assurance sociale",
|
||||||
|
"assurance sociale",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn sin(text: &str, findings: &mut Findings) {
|
||||||
|
for c in SIN.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
let n = format!("{}{}{}", &c[1], &c[3], &c[5]);
|
||||||
|
let written = !c[2].is_empty() && c[2] == c[4];
|
||||||
|
// 0 and 8 are never issued as a first digit
|
||||||
|
if !n.starts_with(['0', '8'])
|
||||||
|
&& checks::luhn(&n)
|
||||||
|
&& (written || word_near(text, whole.start(), whole.end(), SIN_WORDS))
|
||||||
|
{
|
||||||
|
findings.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::mailflow::detectors::by_id;
|
||||||
|
|
||||||
|
fn count(text: &str) -> usize {
|
||||||
|
by_id("ca-sin").unwrap().count(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn social_insurance_numbers() {
|
||||||
|
assert_eq!(count("130 692 544 and 193-456-787"), 2);
|
||||||
|
assert_eq!(count("130 692 545"), 0);
|
||||||
|
// The government's printed example starts with 0, never issued
|
||||||
|
assert_eq!(count("046 454 286"), 0);
|
||||||
|
assert_eq!(count("order 130692544"), 0);
|
||||||
|
assert_eq!(count("SIN: 130692544"), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Check-digit algorithms, each from its public definition.
|
||||||
|
|
||||||
|
/// The Luhn check (ISO/IEC 7812-1, Annex B) over a string of ASCII digits.
|
||||||
|
pub fn luhn(digits: &str) -> bool {
|
||||||
|
if digits.len() < 2 || !digits.bytes().all(|b| b.is_ascii_digit()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let sum: u32 = digits
|
||||||
|
.bytes()
|
||||||
|
.rev()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, b)| {
|
||||||
|
let d = u32::from(b - b'0');
|
||||||
|
if i % 2 == 1 {
|
||||||
|
let d = d * 2;
|
||||||
|
if d > 9 { d - 9 } else { d }
|
||||||
|
} else {
|
||||||
|
d
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
sum.is_multiple_of(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ISO 13616 IBAN lengths, by country, from the IBAN registry.
|
||||||
|
const IBAN_LENGTHS: &[(&str, usize)] = &[
|
||||||
|
("AD", 24),
|
||||||
|
("AE", 23),
|
||||||
|
("AL", 28),
|
||||||
|
("AT", 20),
|
||||||
|
("AZ", 28),
|
||||||
|
("BA", 20),
|
||||||
|
("BE", 16),
|
||||||
|
("BG", 22),
|
||||||
|
("BH", 22),
|
||||||
|
("BI", 27),
|
||||||
|
("BR", 29),
|
||||||
|
("BY", 28),
|
||||||
|
("CH", 21),
|
||||||
|
("CR", 22),
|
||||||
|
("CY", 28),
|
||||||
|
("CZ", 24),
|
||||||
|
("DE", 22),
|
||||||
|
("DJ", 27),
|
||||||
|
("DK", 18),
|
||||||
|
("DO", 28),
|
||||||
|
("EE", 20),
|
||||||
|
("EG", 29),
|
||||||
|
("ES", 24),
|
||||||
|
("FI", 18),
|
||||||
|
("FK", 18),
|
||||||
|
("FO", 18),
|
||||||
|
("FR", 27),
|
||||||
|
("GB", 22),
|
||||||
|
("GE", 22),
|
||||||
|
("GI", 23),
|
||||||
|
("GL", 18),
|
||||||
|
("GR", 27),
|
||||||
|
("GT", 28),
|
||||||
|
("HN", 28),
|
||||||
|
("HR", 21),
|
||||||
|
("HU", 28),
|
||||||
|
("IE", 22),
|
||||||
|
("IL", 23),
|
||||||
|
("IQ", 23),
|
||||||
|
("IS", 26),
|
||||||
|
("IT", 27),
|
||||||
|
("JO", 30),
|
||||||
|
("KW", 30),
|
||||||
|
("KZ", 20),
|
||||||
|
("LB", 28),
|
||||||
|
("LC", 32),
|
||||||
|
("LI", 21),
|
||||||
|
("LT", 20),
|
||||||
|
("LU", 20),
|
||||||
|
("LV", 21),
|
||||||
|
("LY", 25),
|
||||||
|
("MC", 27),
|
||||||
|
("MD", 24),
|
||||||
|
("ME", 22),
|
||||||
|
("MK", 19),
|
||||||
|
("MN", 20),
|
||||||
|
("MR", 27),
|
||||||
|
("MT", 31),
|
||||||
|
("MU", 30),
|
||||||
|
("NI", 28),
|
||||||
|
("NL", 18),
|
||||||
|
("NO", 15),
|
||||||
|
("OM", 23),
|
||||||
|
("PK", 24),
|
||||||
|
("PL", 28),
|
||||||
|
("PS", 29),
|
||||||
|
("PT", 25),
|
||||||
|
("QA", 29),
|
||||||
|
("RO", 24),
|
||||||
|
("RS", 22),
|
||||||
|
("RU", 33),
|
||||||
|
("SA", 24),
|
||||||
|
("SC", 31),
|
||||||
|
("SD", 18),
|
||||||
|
("SE", 24),
|
||||||
|
("SI", 19),
|
||||||
|
("SK", 24),
|
||||||
|
("SM", 27),
|
||||||
|
("SO", 23),
|
||||||
|
("ST", 25),
|
||||||
|
("SV", 28),
|
||||||
|
("TL", 23),
|
||||||
|
("TN", 24),
|
||||||
|
("TR", 26),
|
||||||
|
("UA", 29),
|
||||||
|
("VA", 22),
|
||||||
|
("VG", 24),
|
||||||
|
("XK", 20),
|
||||||
|
("YE", 30),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The IBAN length for a country code, if the country uses IBANs.
|
||||||
|
pub fn iban_length(country: &str) -> Option<usize> {
|
||||||
|
IBAN_LENGTHS
|
||||||
|
.iter()
|
||||||
|
.find(|(code, _)| *code == country)
|
||||||
|
.map(|(_, len)| *len)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ISO 13616 / ISO 7064 MOD 97-10 over an IBAN with no spaces, upper case:
|
||||||
|
/// move the first four characters to the end, turn letters into 10–35, and
|
||||||
|
/// the number mod 97 must be 1. Also checks the country's length.
|
||||||
|
pub fn iban(iban: &str) -> bool {
|
||||||
|
if iban.len() < 5
|
||||||
|
|| !iban
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if iban_length(&iban[..2]) != Some(iban.len())
|
||||||
|
|| !iban[2..4].bytes().all(|b| b.is_ascii_digit())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut remainder: u32 = 0;
|
||||||
|
for b in iban[4..].bytes().chain(iban[..4].bytes()) {
|
||||||
|
let value = if b.is_ascii_digit() {
|
||||||
|
u32::from(b - b'0')
|
||||||
|
} else {
|
||||||
|
u32::from(b - b'A') + 10
|
||||||
|
};
|
||||||
|
remainder = if value >= 10 {
|
||||||
|
(remainder * 100 + value) % 97
|
||||||
|
} else {
|
||||||
|
(remainder * 10 + value) % 97
|
||||||
|
};
|
||||||
|
}
|
||||||
|
remainder == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ISO 3166-1 alpha-2 country codes, for SWIFT/BIC positions 5–6.
|
||||||
|
const COUNTRIES: &str = "AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ \
|
||||||
|
BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ \
|
||||||
|
DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT \
|
||||||
|
GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN KP KR KW KY \
|
||||||
|
KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX \
|
||||||
|
MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS \
|
||||||
|
RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN \
|
||||||
|
TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS XK YE YT ZA ZM ZW";
|
||||||
|
|
||||||
|
pub fn is_country(code: &str) -> bool {
|
||||||
|
code.len() == 2 && COUNTRIES.split(' ').any(|c| c == code)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn luhn_known_numbers() {
|
||||||
|
// Published test card numbers
|
||||||
|
for good in [
|
||||||
|
"4242424242424242",
|
||||||
|
"5555555555554444",
|
||||||
|
"378282246310005",
|
||||||
|
"79927398713",
|
||||||
|
] {
|
||||||
|
assert!(luhn(good), "{good}");
|
||||||
|
}
|
||||||
|
for bad in ["4242424242424241", "79927398710", "1", "12a4"] {
|
||||||
|
assert!(!luhn(bad), "{bad}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn iban_registry_examples() {
|
||||||
|
// The IBAN registry's own examples
|
||||||
|
for good in [
|
||||||
|
"GB29NWBK60161331926819",
|
||||||
|
"DE89370400440532013000",
|
||||||
|
"FR1420041010050500013M02606",
|
||||||
|
"NL91ABNA0417164300",
|
||||||
|
"BE68539007547034",
|
||||||
|
"NO9386011117947",
|
||||||
|
"CH9300762011623852957",
|
||||||
|
] {
|
||||||
|
assert!(iban(good), "{good}");
|
||||||
|
}
|
||||||
|
for bad in [
|
||||||
|
"GB29NWBK60161331926818", // check fails
|
||||||
|
"GB29NWBK6016133192681", // too short for GB
|
||||||
|
"ZZ29NWBK60161331926819", // no such country
|
||||||
|
"DE8937040044053201300A", // letters where DE has none still fail mod 97
|
||||||
|
] {
|
||||||
|
assert!(!iban(bad), "{bad}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn countries() {
|
||||||
|
assert!(is_country("DE") && is_country("US") && is_country("XK"));
|
||||||
|
assert!(!is_country("ZZ") && !is_country("D"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,646 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! European Union national identifiers (§2.3), each from its issuer's
|
||||||
|
//! published rules. An identifier that is only digits and whose check a
|
||||||
|
//! random number passes often (mod 10, mod 11) counts alone only in its
|
||||||
|
//! written form, and as bare digits only beside a word.
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
Detector, Findings, Region, Strength, checks, digit_values, stands_alone, valid_short_date,
|
||||||
|
word_near,
|
||||||
|
};
|
||||||
|
use regex::Regex;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
pub static DETECTORS: &[Detector] = &[
|
||||||
|
Detector::new(
|
||||||
|
"de-tax-id",
|
||||||
|
"Germany: tax ID (Steuer-ID)",
|
||||||
|
Region::Eu,
|
||||||
|
Strength::Checked,
|
||||||
|
de_tax_id,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"de-id-card",
|
||||||
|
"Germany: ID card number",
|
||||||
|
Region::Eu,
|
||||||
|
Strength::Checked,
|
||||||
|
de_id_card,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"fr-nir",
|
||||||
|
"France: social security number (NIR)",
|
||||||
|
Region::Eu,
|
||||||
|
Strength::Checked,
|
||||||
|
fr_nir,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"es-dni-nie",
|
||||||
|
"Spain: DNI and NIE",
|
||||||
|
Region::Eu,
|
||||||
|
Strength::Checked,
|
||||||
|
es_dni_nie,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"it-codice-fiscale",
|
||||||
|
"Italy: codice fiscale",
|
||||||
|
Region::Eu,
|
||||||
|
Strength::Checked,
|
||||||
|
it_codice_fiscale,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"nl-bsn",
|
||||||
|
"Netherlands: BSN",
|
||||||
|
Region::Eu,
|
||||||
|
Strength::Checked,
|
||||||
|
nl_bsn,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"be-national-number",
|
||||||
|
"Belgium: national number",
|
||||||
|
Region::Eu,
|
||||||
|
Strength::Checked,
|
||||||
|
be_national_number,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"pl-pesel",
|
||||||
|
"Poland: PESEL",
|
||||||
|
Region::Eu,
|
||||||
|
Strength::Checked,
|
||||||
|
pl_pesel,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"se-personnummer",
|
||||||
|
"Sweden: personnummer",
|
||||||
|
Region::Eu,
|
||||||
|
Strength::Checked,
|
||||||
|
se_personnummer,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"dk-cpr",
|
||||||
|
"Denmark: CPR number",
|
||||||
|
Region::Eu,
|
||||||
|
Strength::NeedsWord,
|
||||||
|
dk_cpr,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"fi-hetu",
|
||||||
|
"Finland: personal identity code",
|
||||||
|
Region::Eu,
|
||||||
|
Strength::Checked,
|
||||||
|
fi_hetu,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"ie-pps",
|
||||||
|
"Ireland: PPS number",
|
||||||
|
Region::Eu,
|
||||||
|
Strength::Checked,
|
||||||
|
ie_pps,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"pt-nif",
|
||||||
|
"Portugal: NIF",
|
||||||
|
Region::Eu,
|
||||||
|
Strength::Checked,
|
||||||
|
pt_nif,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"at-svnr",
|
||||||
|
"Austria: social insurance number",
|
||||||
|
Region::Eu,
|
||||||
|
Strength::Checked,
|
||||||
|
at_svnr,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn re(pattern: &str) -> Regex {
|
||||||
|
Regex::new(pattern).expect("detector pattern")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn num(s: &str) -> u32 {
|
||||||
|
s.parse().unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Germany --------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Eleven digits, written `86 095 742 719` on the BZSt's letters.
|
||||||
|
static DE_TAX: LazyLock<Regex> = LazyLock::new(|| re(r"\b\d{2}( ?)\d{3}( ?)\d{3}( ?)\d{3}\b"));
|
||||||
|
|
||||||
|
/// ISO 7064 MOD 11,10; no leading zero; in the first ten digits one digit
|
||||||
|
/// appears two or three times and every other at most once.
|
||||||
|
pub fn de_tax_id_valid(n: &str) -> bool {
|
||||||
|
let d = digit_values(n);
|
||||||
|
if d.len() != 11 || d[0] == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut counts = [0u8; 10];
|
||||||
|
for &x in &d[..10] {
|
||||||
|
counts[x as usize] += 1;
|
||||||
|
}
|
||||||
|
let repeated = counts.iter().filter(|&&c| c >= 2).count();
|
||||||
|
if repeated != 1 || counts.iter().any(|&c| c > 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut product = 10;
|
||||||
|
for &x in &d[..10] {
|
||||||
|
let mut sum = (x + product) % 10;
|
||||||
|
if sum == 0 {
|
||||||
|
sum = 10;
|
||||||
|
}
|
||||||
|
product = (2 * sum) % 11;
|
||||||
|
}
|
||||||
|
let check = match 11 - product {
|
||||||
|
10 => 0,
|
||||||
|
c => c,
|
||||||
|
};
|
||||||
|
check == d[10]
|
||||||
|
}
|
||||||
|
|
||||||
|
const DE_TAX_WORDS: &[&str] = &[
|
||||||
|
"steuer-id",
|
||||||
|
"steueridentifikationsnummer",
|
||||||
|
"steuerliche identifikationsnummer",
|
||||||
|
"idnr",
|
||||||
|
"identifikationsnummer",
|
||||||
|
"tax id",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn de_tax_id(text: &str, findings: &mut Findings) {
|
||||||
|
for c in DE_TAX.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
let written = [&c[1], &c[2], &c[3]].iter().all(|s| *s == " ");
|
||||||
|
let n: String = whole.as_str().replace(' ', "");
|
||||||
|
if de_tax_id_valid(&n)
|
||||||
|
&& (written || word_near(text, whole.start(), whole.end(), DE_TAX_WORDS))
|
||||||
|
{
|
||||||
|
findings.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ID card's document number: a letter from the card's alphabet, eight
|
||||||
|
/// more characters from it, then the check digit.
|
||||||
|
static DE_ID: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| re(r"\b[CFGHJKLMNPRTVWXYZ][CFGHJKLMNPRTVWXYZ0-9]{8}\d\b"));
|
||||||
|
|
||||||
|
/// ICAO 9303 check digit: weights 7, 3, 1; letters A=10 … Z=35.
|
||||||
|
pub fn icao_check(chars: &str, check: u32) -> bool {
|
||||||
|
let value = |c: char| c.to_digit(10).unwrap_or_else(|| c as u32 - 'A' as u32 + 10);
|
||||||
|
let sum: u32 = chars
|
||||||
|
.chars()
|
||||||
|
.zip([7, 3, 1].iter().cycle())
|
||||||
|
.map(|(c, w)| value(c) * w)
|
||||||
|
.sum();
|
||||||
|
sum % 10 == check
|
||||||
|
}
|
||||||
|
|
||||||
|
fn de_id_card(text: &str, findings: &mut Findings) {
|
||||||
|
for m in DE_ID.find_iter(text) {
|
||||||
|
let s = m.as_str();
|
||||||
|
if icao_check(&s[..9], num(&s[9..])) {
|
||||||
|
findings.insert(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- France ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Sex, year, month, department (with Corsica's 2A and 2B), commune, order,
|
||||||
|
/// then the two-digit key, spaces allowed between groups.
|
||||||
|
static FR_NIR: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
re(r"\b([1-478]) ?(\d{2}) ?(\d{2}) ?(\d{2}|2[AB]) ?(\d{3}) ?(\d{3}) ?(\d{2})\b")
|
||||||
|
});
|
||||||
|
|
||||||
|
fn fr_nir(text: &str, findings: &mut Findings) {
|
||||||
|
for c in FR_NIR.captures_iter(text) {
|
||||||
|
let month = num(&c[3]);
|
||||||
|
if !(matches!(month, 1..=12 | 20..=42 | 50..=99)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let department = match &c[4] {
|
||||||
|
"2A" => "19",
|
||||||
|
"2B" => "18",
|
||||||
|
d => d,
|
||||||
|
};
|
||||||
|
let body = format!(
|
||||||
|
"{}{}{}{}{}{}",
|
||||||
|
&c[1], &c[2], &c[3], department, &c[5], &c[6]
|
||||||
|
);
|
||||||
|
let Ok(value) = body.parse::<u64>() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if 97 - value % 97 == u64::from(num(&c[7])) {
|
||||||
|
findings.insert(format!(
|
||||||
|
"{}{}{}{}{}{}{}",
|
||||||
|
&c[1], &c[2], &c[3], &c[4], &c[5], &c[6], &c[7]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Spain ----------------------------------------------------------------
|
||||||
|
|
||||||
|
static ES_ID: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\b([XYZ]?)[ -]?(\d{7,8})[ -]?([A-Z])\b"));
|
||||||
|
|
||||||
|
const DNI_LETTERS: &[u8] = b"TRWAGMYFPDXBNJZSQVHLCKE";
|
||||||
|
|
||||||
|
fn es_dni_nie(text: &str, findings: &mut Findings) {
|
||||||
|
for c in ES_ID.captures_iter(text) {
|
||||||
|
let prefix = c[1].to_ascii_uppercase();
|
||||||
|
let digits = &c[2];
|
||||||
|
// DNI: eight digits; NIE: X, Y or Z and seven digits
|
||||||
|
let number = match (prefix.as_str(), digits.len()) {
|
||||||
|
("", 8) => digits.to_string(),
|
||||||
|
("X", 7) => format!("0{digits}"),
|
||||||
|
("Y", 7) => format!("1{digits}"),
|
||||||
|
("Z", 7) => format!("2{digits}"),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let letter = c[3].to_ascii_uppercase();
|
||||||
|
if DNI_LETTERS[(num(&number) % 23) as usize] == letter.as_bytes()[0] {
|
||||||
|
findings.insert(format!("{prefix}{digits}{letter}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Italy ----------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Surname and name letters, year, month letter, day, place code, check
|
||||||
|
/// letter; digits may be replaced by letters (omocodia).
|
||||||
|
static IT_CF: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
let d = "[0-9LMNPQRSTUV]";
|
||||||
|
re(&format!(
|
||||||
|
r"(?i)\b[A-Z]{{6}}{d}{{2}}[ABCDEHLMPRST]{d}{{2}}[A-Z]{d}{{3}}[A-Z]\b"
|
||||||
|
))
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The Ministry's odd-position values for 0–9 and A–Z.
|
||||||
|
const CF_ODD: [u32; 36] = [
|
||||||
|
1, 0, 5, 7, 9, 13, 15, 17, 19, 21, // 0-9
|
||||||
|
1, 0, 5, 7, 9, 13, 15, 17, 19, 21, 2, 4, 18, 20, 11, 3, 6, 8, 12, 14, 16, 10, 22, 25, 24,
|
||||||
|
23, // A-Z
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn codice_fiscale_valid(cf: &str) -> bool {
|
||||||
|
let index = |c: u8| {
|
||||||
|
if c.is_ascii_digit() {
|
||||||
|
(c - b'0') as usize
|
||||||
|
} else {
|
||||||
|
(c - b'A') as usize + 10
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let even = |c: u8| {
|
||||||
|
if c.is_ascii_digit() {
|
||||||
|
u32::from(c - b'0')
|
||||||
|
} else {
|
||||||
|
u32::from(c - b'A')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let bytes = cf.as_bytes();
|
||||||
|
let sum: u32 = bytes[..15]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, &c)| {
|
||||||
|
if i % 2 == 0 {
|
||||||
|
CF_ODD[index(c)]
|
||||||
|
} else {
|
||||||
|
even(c)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
u32::from(bytes[15] - b'A') == sum % 26
|
||||||
|
}
|
||||||
|
|
||||||
|
fn it_codice_fiscale(text: &str, findings: &mut Findings) {
|
||||||
|
for m in IT_CF.find_iter(text) {
|
||||||
|
let cf = m.as_str().to_ascii_uppercase();
|
||||||
|
if codice_fiscale_valid(&cf) {
|
||||||
|
findings.insert(cf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Netherlands ----------------------------------------------------------
|
||||||
|
|
||||||
|
/// Nine digits, sometimes written `1112.22.333`.
|
||||||
|
static NL_BSN: LazyLock<Regex> = LazyLock::new(|| re(r"\b(\d{4})(\.?)(\d{2})(\.?)(\d{3})\b"));
|
||||||
|
|
||||||
|
/// The eleven test: weights 9 down to 2, and −1 for the last digit.
|
||||||
|
pub fn bsn_valid(n: &str) -> bool {
|
||||||
|
let d = digit_values(n);
|
||||||
|
let sum: i64 = d[..8]
|
||||||
|
.iter()
|
||||||
|
.zip((2..=9).rev())
|
||||||
|
.map(|(a, w)| i64::from(a * w))
|
||||||
|
.sum::<i64>()
|
||||||
|
- i64::from(d[8]);
|
||||||
|
sum != 0 && sum % 11 == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const BSN_WORDS: &[&str] = &[
|
||||||
|
"bsn",
|
||||||
|
"burgerservicenummer",
|
||||||
|
"sofinummer",
|
||||||
|
"sofi-nummer",
|
||||||
|
"citizen service number",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn nl_bsn(text: &str, findings: &mut Findings) {
|
||||||
|
for c in NL_BSN.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
let n = format!("{}{}{}", &c[1], &c[3], &c[5]);
|
||||||
|
let written = &c[2] == "." && &c[4] == ".";
|
||||||
|
if bsn_valid(&n) && (written || word_near(text, whole.start(), whole.end(), BSN_WORDS)) {
|
||||||
|
findings.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Belgium --------------------------------------------------------------
|
||||||
|
|
||||||
|
/// `YY.MM.DD-XXX.CC` or eleven digits.
|
||||||
|
static BE_NN: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| re(r"\b(\d{2})\.?(\d{2})\.?(\d{2})-?(\d{3})\.?(\d{2})\b"));
|
||||||
|
|
||||||
|
fn be_national_number(text: &str, findings: &mut Findings) {
|
||||||
|
for c in BE_NN.captures_iter(text) {
|
||||||
|
let (month, day) = (num(&c[2]), num(&c[3]));
|
||||||
|
// Month 0 and day 0 mean unknown; bis numbers add 20 or 40 to the month
|
||||||
|
if !(month <= 12 || (20..=32).contains(&month) || (40..=52).contains(&month)) || day > 31 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let body = format!("{}{}{}{}", &c[1], &c[2], &c[3], &c[4]);
|
||||||
|
let check = u64::from(num(&c[5]));
|
||||||
|
let before_2000 = 97 - body.parse::<u64>().unwrap_or(0) % 97;
|
||||||
|
let since_2000 = 97 - format!("2{body}").parse::<u64>().unwrap_or(0) % 97;
|
||||||
|
if check == before_2000 || check == since_2000 {
|
||||||
|
findings.insert(format!("{body}{}", &c[5]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Poland ---------------------------------------------------------------
|
||||||
|
|
||||||
|
static ELEVEN: LazyLock<Regex> = LazyLock::new(|| re(r"\b\d{11}\b"));
|
||||||
|
|
||||||
|
/// Weights 1, 3, 7, 9 repeating; the birth date encodes the century in the
|
||||||
|
/// month (+80 for the 1800s, +20 for the 2000s, and so on).
|
||||||
|
pub fn pesel_valid(n: &str) -> bool {
|
||||||
|
let d = digit_values(n);
|
||||||
|
let sum: u32 = d[..10]
|
||||||
|
.iter()
|
||||||
|
.zip([1, 3, 7, 9].iter().cycle())
|
||||||
|
.map(|(a, w)| a * w)
|
||||||
|
.sum();
|
||||||
|
let month = d[2] * 10 + d[3];
|
||||||
|
let (century, month) = match month {
|
||||||
|
81..=92 => (1800, month - 80),
|
||||||
|
1..=12 => (1900, month),
|
||||||
|
21..=32 => (2000, month - 20),
|
||||||
|
41..=52 => (2100, month - 40),
|
||||||
|
_ => return false,
|
||||||
|
};
|
||||||
|
let year = century + d[0] * 10 + d[1];
|
||||||
|
(10 - sum % 10) % 10 == d[10] && (1..=super::days_in(year, month)).contains(&(d[4] * 10 + d[5]))
|
||||||
|
}
|
||||||
|
|
||||||
|
const PESEL_WORDS: &[&str] = &["pesel", "numer pesel", "nr pesel"];
|
||||||
|
|
||||||
|
fn pl_pesel(text: &str, findings: &mut Findings) {
|
||||||
|
for m in ELEVEN.find_iter(text) {
|
||||||
|
if pesel_valid(m.as_str()) && word_near(text, m.start(), m.end(), PESEL_WORDS) {
|
||||||
|
findings.insert(m.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sweden ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/// `YYMMDD-NNNN`, `YYYYMMDD-NNNN` (`+` after 100), or the bare digits.
|
||||||
|
static SE_PNR: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| re(r"\b(?:\d{2})?(\d{2})(\d{2})(\d{2})([-+]?)(\d{4})\b"));
|
||||||
|
|
||||||
|
const SE_WORDS: &[&str] = &[
|
||||||
|
"personnummer",
|
||||||
|
"personnr",
|
||||||
|
"person nr",
|
||||||
|
"samordningsnummer",
|
||||||
|
"pnr",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn se_personnummer(text: &str, findings: &mut Findings) {
|
||||||
|
for c in SE_PNR.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
let (yy, month, day) = (num(&c[1]), num(&c[2]), num(&c[3]));
|
||||||
|
// Coordination numbers add 60 to the day
|
||||||
|
let day = if day > 60 { day - 60 } else { day };
|
||||||
|
let ten = format!("{}{}{}{}", &c[1], &c[2], &c[3], &c[5]);
|
||||||
|
let written = !c[4].is_empty();
|
||||||
|
if valid_short_date(yy, month, day)
|
||||||
|
&& checks::luhn(&ten)
|
||||||
|
&& (written || word_near(text, whole.start(), whole.end(), SE_WORDS))
|
||||||
|
{
|
||||||
|
findings.insert(ten);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Denmark --------------------------------------------------------------
|
||||||
|
|
||||||
|
static DK_CPR: LazyLock<Regex> = LazyLock::new(|| re(r"\b(\d{2})(\d{2})(\d{2})-?(\d{4})\b"));
|
||||||
|
|
||||||
|
const CPR_WORDS: &[&str] = &["cpr", "cpr-nr", "cpr nr", "cpr-nummer", "personnummer"];
|
||||||
|
|
||||||
|
fn dk_cpr(text: &str, findings: &mut Findings) {
|
||||||
|
for c in DK_CPR.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
if valid_short_date(num(&c[3]), num(&c[2]), num(&c[1]))
|
||||||
|
&& word_near(text, whole.start(), whole.end(), CPR_WORDS)
|
||||||
|
{
|
||||||
|
findings.insert(whole.as_str().replace('-', ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Finland --------------------------------------------------------------
|
||||||
|
|
||||||
|
static FI_HETU: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| re(r"(?i)\b(\d{2})(\d{2})(\d{2})[-+ABCDEFYXWVU](\d{3})([0-9A-Y])\b"));
|
||||||
|
|
||||||
|
const HETU_CHECK: &[u8] = b"0123456789ABCDEFHJKLMNPRSTUVWXY";
|
||||||
|
|
||||||
|
fn fi_hetu(text: &str, findings: &mut Findings) {
|
||||||
|
for c in FI_HETU.captures_iter(text) {
|
||||||
|
let (day, month, yy) = (num(&c[1]), num(&c[2]), num(&c[3]));
|
||||||
|
let n: u64 = format!("{}{}{}{}", &c[1], &c[2], &c[3], &c[4])
|
||||||
|
.parse()
|
||||||
|
.unwrap_or(0);
|
||||||
|
let check = c[5].to_ascii_uppercase().as_bytes()[0];
|
||||||
|
if valid_short_date(yy, month, day) && HETU_CHECK[(n % 31) as usize] == check {
|
||||||
|
findings.insert(c[0].to_ascii_uppercase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Ireland --------------------------------------------------------------
|
||||||
|
|
||||||
|
static IE_PPS: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\b(\d{7})([A-W])([ABHW]?)\b"));
|
||||||
|
|
||||||
|
const PPS_CHECK: &[u8] = b"WABCDEFGHIJKLMNOPQRSTUV";
|
||||||
|
|
||||||
|
fn ie_pps(text: &str, findings: &mut Findings) {
|
||||||
|
for c in IE_PPS.captures_iter(text) {
|
||||||
|
let mut sum: u32 = digit_values(&c[1])
|
||||||
|
.iter()
|
||||||
|
.zip((2..=8).rev())
|
||||||
|
.map(|(a, w)| a * w)
|
||||||
|
.sum();
|
||||||
|
// The second letter counts, times 9; W (the old form) counts as 0
|
||||||
|
let second = c[3].to_ascii_uppercase();
|
||||||
|
if let Some(&letter) = second.as_bytes().first()
|
||||||
|
&& letter != b'W'
|
||||||
|
{
|
||||||
|
sum += u32::from(letter - b'A' + 1) * 9;
|
||||||
|
}
|
||||||
|
let check = c[2].to_ascii_uppercase().as_bytes()[0];
|
||||||
|
if PPS_CHECK[(sum % 23) as usize] == check {
|
||||||
|
findings.insert(c[0].to_ascii_uppercase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Portugal -------------------------------------------------------------
|
||||||
|
|
||||||
|
static NINE: LazyLock<Regex> = LazyLock::new(|| re(r"\b\d{9}\b"));
|
||||||
|
|
||||||
|
/// Mod 11 over weights 9 down to 2; a check of 10 or 11 becomes 0.
|
||||||
|
pub fn nif_valid(n: &str) -> bool {
|
||||||
|
let d = digit_values(n);
|
||||||
|
let sum: u32 = d[..8].iter().zip((2..=9).rev()).map(|(a, w)| a * w).sum();
|
||||||
|
let check = match 11 - sum % 11 {
|
||||||
|
10 | 11 => 0,
|
||||||
|
c => c,
|
||||||
|
};
|
||||||
|
matches!(d[0], 1 | 2 | 3 | 5 | 6 | 8 | 9) && check == d[8]
|
||||||
|
}
|
||||||
|
|
||||||
|
const NIF_WORDS: &[&str] = &[
|
||||||
|
"nif",
|
||||||
|
"contribuinte",
|
||||||
|
"número de identificação fiscal",
|
||||||
|
"numero de contribuinte",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn pt_nif(text: &str, findings: &mut Findings) {
|
||||||
|
for m in NINE.find_iter(text) {
|
||||||
|
if nif_valid(m.as_str()) && word_near(text, m.start(), m.end(), NIF_WORDS) {
|
||||||
|
findings.insert(m.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Austria --------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A serial and check digit, then the birth date: `1237 010180`.
|
||||||
|
static AT_SVNR: LazyLock<Regex> = LazyLock::new(|| re(r"\b(\d{3})(\d)( ?)(\d{2})(\d{2})(\d{2})\b"));
|
||||||
|
|
||||||
|
const SVNR_WORDS: &[&str] = &[
|
||||||
|
"sozialversicherungsnummer",
|
||||||
|
"svnr",
|
||||||
|
"sv-nr",
|
||||||
|
"sv-nummer",
|
||||||
|
"versicherungsnummer",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn at_svnr(text: &str, findings: &mut Findings) {
|
||||||
|
for c in AT_SVNR.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
let n = format!("{}{}{}{}{}", &c[1], &c[2], &c[4], &c[5], &c[6]);
|
||||||
|
let d = digit_values(&n);
|
||||||
|
let sum: u32 = d
|
||||||
|
.iter()
|
||||||
|
.zip([3, 7, 9, 0, 5, 8, 4, 2, 1, 6])
|
||||||
|
.map(|(a, w)| a * w)
|
||||||
|
.sum();
|
||||||
|
let written = &c[3] == " ";
|
||||||
|
if d[0] != 0
|
||||||
|
&& sum % 11 == d[3]
|
||||||
|
&& valid_short_date(num(&c[6]), num(&c[5]), num(&c[4]))
|
||||||
|
&& (written || word_near(text, whole.start(), whole.end(), SVNR_WORDS))
|
||||||
|
&& stands_alone(text, whole.start(), whole.end())
|
||||||
|
{
|
||||||
|
findings.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::mailflow::detectors::by_id;
|
||||||
|
|
||||||
|
fn count(id: &str, text: &str) -> usize {
|
||||||
|
by_id(id).unwrap().count(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn germany() {
|
||||||
|
assert_eq!(count("de-tax-id", "86 095 742 719"), 1);
|
||||||
|
assert_eq!(count("de-tax-id", "Steuer-ID: 86095742719"), 1);
|
||||||
|
assert_eq!(count("de-tax-id", "Rechnung 86095742719"), 0);
|
||||||
|
assert_eq!(count("de-tax-id", "86 095 742 718"), 0);
|
||||||
|
// ICAO 9303's German specimen card
|
||||||
|
assert_eq!(count("de-id-card", "Ausweis T220001293"), 1);
|
||||||
|
assert_eq!(count("de-id-card", "T220001294"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn france_spain_italy() {
|
||||||
|
assert_eq!(count("fr-nir", "2 55 08 14 168 025 38"), 1);
|
||||||
|
assert_eq!(count("fr-nir", "255081416802539"), 0);
|
||||||
|
assert_eq!(count("es-dni-nie", "DNI 12345678Z, NIE X-1234567-L"), 2);
|
||||||
|
assert_eq!(count("es-dni-nie", "12345678A"), 0);
|
||||||
|
assert_eq!(count("it-codice-fiscale", "CF: RSSMRA85T10A562S"), 1);
|
||||||
|
assert_eq!(count("it-codice-fiscale", "RSSMRA85T10A562T"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn benelux() {
|
||||||
|
assert_eq!(count("nl-bsn", "1112.22.333"), 1);
|
||||||
|
assert_eq!(count("nl-bsn", "BSN 111222333"), 1);
|
||||||
|
assert_eq!(count("nl-bsn", "order 111222333"), 0);
|
||||||
|
assert_eq!(count("nl-bsn", "BSN 111222334"), 0);
|
||||||
|
assert_eq!(count("be-national-number", "85.07.30-033.28"), 1);
|
||||||
|
assert_eq!(count("be-national-number", "85073003329"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nordics() {
|
||||||
|
assert_eq!(count("se-personnummer", "811218-9876"), 1);
|
||||||
|
assert_eq!(count("se-personnummer", "811218-9875"), 0);
|
||||||
|
assert_eq!(count("se-personnummer", "order 8112189876"), 0);
|
||||||
|
assert_eq!(count("se-personnummer", "personnummer 198112189876"), 1);
|
||||||
|
assert_eq!(count("dk-cpr", "CPR-nr: 010170-1234"), 1);
|
||||||
|
assert_eq!(count("dk-cpr", "010170-1234"), 0);
|
||||||
|
assert_eq!(count("dk-cpr", "CPR 320170-1234"), 0);
|
||||||
|
assert_eq!(count("fi-hetu", "131052-308T"), 1);
|
||||||
|
assert_eq!(count("fi-hetu", "131052-308U"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn poland_ireland_portugal_austria() {
|
||||||
|
assert_eq!(count("pl-pesel", "PESEL 44051401359, pesel 02070803628"), 2);
|
||||||
|
assert_eq!(count("pl-pesel", "PESEL 44051401358"), 0);
|
||||||
|
assert_eq!(count("pl-pesel", "44051401359"), 0);
|
||||||
|
assert_eq!(count("ie-pps", "PPS 1234567T and 1234567FA"), 2);
|
||||||
|
assert_eq!(count("ie-pps", "1234567U"), 0);
|
||||||
|
assert_eq!(count("pt-nif", "NIF 123456789"), 1);
|
||||||
|
assert_eq!(count("pt-nif", "NIF 123456788"), 0);
|
||||||
|
assert_eq!(count("at-svnr", "1237 010180"), 1);
|
||||||
|
assert_eq!(count("at-svnr", "SVNR 1237010180"), 1);
|
||||||
|
assert_eq!(count("at-svnr", "1238 010180"), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! European identifiers outside the EU (§2.3): Norway's national identity
|
||||||
|
//! number and Switzerland's AHV number.
|
||||||
|
|
||||||
|
use super::{Detector, Findings, Region, Strength, digit_values, valid_short_date};
|
||||||
|
use regex::Regex;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
pub static DETECTORS: &[Detector] = &[
|
||||||
|
Detector::new(
|
||||||
|
"no-fnr",
|
||||||
|
"Norway: national identity number",
|
||||||
|
Region::Europe,
|
||||||
|
Strength::Checked,
|
||||||
|
no_fnr,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"ch-ahv",
|
||||||
|
"Switzerland: AHV number",
|
||||||
|
Region::Europe,
|
||||||
|
Strength::Checked,
|
||||||
|
ch_ahv,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
static ELEVEN: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| Regex::new(r"\b\d{6} ?\d{5}\b").expect("detector pattern"));
|
||||||
|
|
||||||
|
/// Two mod 11 check digits over a birth date (D-numbers add 40 to the day,
|
||||||
|
/// H-numbers 40 to the month): strong enough to count alone.
|
||||||
|
pub fn fnr_valid(n: &str) -> bool {
|
||||||
|
let d = digit_values(n);
|
||||||
|
if d.len() != 11 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let check =
|
||||||
|
|weights: &[u32]| match 11 - d.iter().zip(weights).map(|(a, w)| a * w).sum::<u32>() % 11 {
|
||||||
|
11 => Some(0),
|
||||||
|
10 => None,
|
||||||
|
c => Some(c),
|
||||||
|
};
|
||||||
|
let day = d[0] * 10 + d[1];
|
||||||
|
let month = d[2] * 10 + d[3];
|
||||||
|
let day = if day > 40 { day - 40 } else { day };
|
||||||
|
let month = if month > 40 { month - 40 } else { month };
|
||||||
|
valid_short_date(d[4] * 10 + d[5], month, day)
|
||||||
|
&& check(&[3, 7, 6, 1, 8, 9, 4, 5, 2]) == Some(d[9])
|
||||||
|
&& check(&[5, 4, 3, 2, 7, 6, 5, 4, 3, 2]) == Some(d[10])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn no_fnr(text: &str, findings: &mut Findings) {
|
||||||
|
for m in ELEVEN.find_iter(text) {
|
||||||
|
let n = m.as_str().replace(' ', "");
|
||||||
|
if fnr_valid(&n) {
|
||||||
|
findings.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `756.1234.5678.97`: the country prefix, then an EAN-13 check digit.
|
||||||
|
static AHV: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"\b756[. ]?\d{4}[. ]?\d{4}[. ]?\d{2}\b").expect("detector pattern")
|
||||||
|
});
|
||||||
|
|
||||||
|
pub fn ean13_valid(n: &str) -> bool {
|
||||||
|
let d = digit_values(n);
|
||||||
|
if d.len() != 13 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let sum: u32 = d[..12]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, x)| if i % 2 == 0 { *x } else { x * 3 })
|
||||||
|
.sum();
|
||||||
|
(10 - sum % 10) % 10 == d[12]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ch_ahv(text: &str, findings: &mut Findings) {
|
||||||
|
for m in AHV.find_iter(text) {
|
||||||
|
let n: String = m.as_str().chars().filter(char::is_ascii_digit).collect();
|
||||||
|
if ean13_valid(&n) {
|
||||||
|
findings.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::mailflow::detectors::by_id;
|
||||||
|
|
||||||
|
fn count(id: &str, text: &str) -> usize {
|
||||||
|
by_id(id).unwrap().count(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn norway() {
|
||||||
|
assert_eq!(count("no-fnr", "01019000083"), 1);
|
||||||
|
assert_eq!(count("no-fnr", "010190 00083"), 1);
|
||||||
|
assert_eq!(count("no-fnr", "01019000084"), 0);
|
||||||
|
// Not a date
|
||||||
|
assert_eq!(count("no-fnr", "32019000083"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn switzerland() {
|
||||||
|
// The federal example
|
||||||
|
assert_eq!(count("ch-ahv", "AHV 756.9217.0769.85"), 1);
|
||||||
|
assert_eq!(count("ch-ahv", "7569217076985"), 1);
|
||||||
|
assert_eq!(count("ch-ahv", "756.9217.0769.86"), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Detectors (dlp-and-mail-flow-rules spec, §2.3): each finds one kind of
|
||||||
|
//! identifier in text and reports the distinct ones it found.
|
||||||
|
//!
|
||||||
|
//! A detector is one of two strengths:
|
||||||
|
//!
|
||||||
|
//! - **Checked**: the identifier carries a published check digit or
|
||||||
|
//! checksum, so a random number rarely passes; found on its own.
|
||||||
|
//! - **Needs a word**: the format alone is too common, so a candidate counts
|
||||||
|
//! only with a corroborating word within [`WINDOW`] characters either
|
||||||
|
//! side.
|
||||||
|
//!
|
||||||
|
//! Findings are distinct normalized values (digits only, upper case), so the
|
||||||
|
//! same card number pasted twice counts once. They stay in memory: callers
|
||||||
|
//! read only [`Findings::len`].
|
||||||
|
|
||||||
|
pub mod africa;
|
||||||
|
pub mod americas;
|
||||||
|
pub mod any;
|
||||||
|
pub mod asia;
|
||||||
|
pub mod australia;
|
||||||
|
pub mod canada;
|
||||||
|
pub mod checks;
|
||||||
|
pub mod eu;
|
||||||
|
pub mod europe;
|
||||||
|
pub mod templates;
|
||||||
|
pub mod uk;
|
||||||
|
pub mod us;
|
||||||
|
|
||||||
|
use ahash::AHashSet;
|
||||||
|
|
||||||
|
/// How far, in characters, a corroborating word may be from a candidate.
|
||||||
|
pub const WINDOW: usize = 50;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Strength {
|
||||||
|
Checked,
|
||||||
|
NeedsWord,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Region {
|
||||||
|
Any,
|
||||||
|
Us,
|
||||||
|
Uk,
|
||||||
|
Canada,
|
||||||
|
Australia,
|
||||||
|
Eu,
|
||||||
|
Europe,
|
||||||
|
Asia,
|
||||||
|
Americas,
|
||||||
|
Africa,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The distinct values one detector found.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Findings(AHashSet<String>);
|
||||||
|
|
||||||
|
impl Findings {
|
||||||
|
pub fn insert(&mut self, value: impl Into<String>) {
|
||||||
|
self.0.insert(value.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.0.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.0.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Detector {
|
||||||
|
/// Stable id, stored in rules: `payment-card`, `iban`, `us-ssn`.
|
||||||
|
pub id: &'static str,
|
||||||
|
pub name: &'static str,
|
||||||
|
pub region: Region,
|
||||||
|
pub strength: Strength,
|
||||||
|
find: fn(&str, &mut Findings),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Detector {
|
||||||
|
pub const fn new(
|
||||||
|
id: &'static str,
|
||||||
|
name: &'static str,
|
||||||
|
region: Region,
|
||||||
|
strength: Strength,
|
||||||
|
find: fn(&str, &mut Findings),
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
region,
|
||||||
|
strength,
|
||||||
|
find,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds what this detector finds in `text` to `findings`. Call once per
|
||||||
|
/// piece of text (subject, each part, each attachment) with the same
|
||||||
|
/// `findings`, then read its length.
|
||||||
|
pub fn find(&self, text: &str, findings: &mut Findings) {
|
||||||
|
(self.find)(text, findings)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The distinct values found in one text.
|
||||||
|
pub fn count(&self, text: &str) -> usize {
|
||||||
|
let mut findings = Findings::default();
|
||||||
|
self.find(text, &mut findings);
|
||||||
|
findings.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every detector, in the order the console lists them.
|
||||||
|
pub fn all() -> impl Iterator<Item = &'static Detector> {
|
||||||
|
[
|
||||||
|
any::DETECTORS,
|
||||||
|
us::DETECTORS,
|
||||||
|
uk::DETECTORS,
|
||||||
|
canada::DETECTORS,
|
||||||
|
australia::DETECTORS,
|
||||||
|
eu::DETECTORS,
|
||||||
|
europe::DETECTORS,
|
||||||
|
asia::DETECTORS,
|
||||||
|
americas::DETECTORS,
|
||||||
|
africa::DETECTORS,
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn by_id(id: &str) -> Option<&'static Detector> {
|
||||||
|
all().find(|detector| detector.id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether one of `words` appears, as a whole word and ignoring case, within
|
||||||
|
/// [`WINDOW`] characters before `start` or after `end` (byte offsets of the
|
||||||
|
/// candidate in `text`). The window is widened by the longest word, so a
|
||||||
|
/// word that reaches into it still counts whole.
|
||||||
|
pub fn word_near(text: &str, start: usize, end: usize, words: &[&str]) -> bool {
|
||||||
|
let reach = WINDOW + words.iter().map(|w| w.chars().count()).max().unwrap_or(0);
|
||||||
|
let before = text[..start]
|
||||||
|
.char_indices()
|
||||||
|
.rev()
|
||||||
|
.nth(reach - 1)
|
||||||
|
.map_or(0, |(i, _)| i);
|
||||||
|
let after = text[end..]
|
||||||
|
.char_indices()
|
||||||
|
.nth(reach)
|
||||||
|
.map_or(text.len(), |(i, _)| end + i);
|
||||||
|
let window = text[before..after].to_lowercase();
|
||||||
|
words.iter().any(|word| contains_word(&window, word))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `word` (lower case) appears in `haystack` (lower case) with no
|
||||||
|
/// letter or digit on either side.
|
||||||
|
pub fn contains_word(haystack: &str, word: &str) -> bool {
|
||||||
|
haystack.match_indices(word).any(|(i, _)| {
|
||||||
|
let before_ok = haystack[..i]
|
||||||
|
.chars()
|
||||||
|
.next_back()
|
||||||
|
.is_none_or(|c| !c.is_alphanumeric());
|
||||||
|
let after_ok = haystack[i + word.len()..]
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.is_none_or(|c| !c.is_alphanumeric());
|
||||||
|
before_ok && after_ok
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the match at `start..end` stands alone: no digit or letter
|
||||||
|
/// directly before or after it, so `123-45-6789` isn't found inside a
|
||||||
|
/// longer run of digits.
|
||||||
|
pub fn stands_alone(text: &str, start: usize, end: usize) -> bool {
|
||||||
|
let before = text[..start].chars().next_back();
|
||||||
|
let after = text[end..].chars().next();
|
||||||
|
before.is_none_or(|c| !c.is_alphanumeric()) && after.is_none_or(|c| !c.is_alphanumeric())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Days in `month` of `year` (0 for a month that doesn't exist).
|
||||||
|
pub fn days_in(year: u32, month: u32) -> u32 {
|
||||||
|
match month {
|
||||||
|
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||||||
|
4 | 6 | 9 | 11 => 30,
|
||||||
|
2 if year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400)) => {
|
||||||
|
29
|
||||||
|
}
|
||||||
|
2 => 28,
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `year`-`month`-`day` is a real date between 1900 and 2100.
|
||||||
|
pub fn valid_date(year: u32, month: u32, day: u32) -> bool {
|
||||||
|
(1900..=2100).contains(&year) && (1..=days_in(year, month)).contains(&day)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a two-digit year, month and day make a real date in either the
|
||||||
|
/// 1900s or the 2000s.
|
||||||
|
pub fn valid_short_date(yy: u32, month: u32, day: u32) -> bool {
|
||||||
|
valid_date(1900 + yy, month, day) || valid_date(2000 + yy, month, day)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The value of each digit in `s`.
|
||||||
|
pub fn digit_values(s: &str) -> Vec<u32> {
|
||||||
|
s.bytes()
|
||||||
|
.filter(u8::is_ascii_digit)
|
||||||
|
.map(|b| u32::from(b - b'0'))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ASCII digits of `s`.
|
||||||
|
pub fn digits(s: &str) -> String {
|
||||||
|
s.chars().filter(char::is_ascii_digit).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn words_are_whole_and_near() {
|
||||||
|
let text = "Your passport number is X1234567, thanks";
|
||||||
|
let start = text.find("X123").unwrap();
|
||||||
|
assert!(word_near(text, start, start + 8, &["passport"]));
|
||||||
|
assert!(!word_near(text, start, start + 8, &["pass"]));
|
||||||
|
let far = format!("passport{}X1234567", " ".repeat(60));
|
||||||
|
let start = far.find("X123").unwrap();
|
||||||
|
assert!(!word_near(&far, start, start + 8, &["passport"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn near_counts_characters_not_bytes() {
|
||||||
|
// 45 two-byte characters between the word and the candidate: within
|
||||||
|
// 50 characters, though over 50 bytes
|
||||||
|
let text = format!("passport {} X1234567", "é".repeat(45));
|
||||||
|
let start = text.find("X123").unwrap();
|
||||||
|
assert!(word_near(&text, start, start + 8, &["passport"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An ordinary business email: order, invoice and tracking numbers,
|
||||||
|
/// dates, amounts, a street address. Nothing here is an identifier, so
|
||||||
|
/// no detector may fire, except the contact ones on the signature.
|
||||||
|
#[test]
|
||||||
|
fn ordinary_mail_finds_nothing() {
|
||||||
|
let text = "Hi Dana,\n\nThanks for order 4471-2290 placed 2026-09-14. Invoice INV-2026-00917 \
|
||||||
|
for $12,480.00 is due 10/31/2026; PO 7731902 covers lines 1-14. Tracking \
|
||||||
|
1Z999AA10123456784, parcel 3 of 5, 12.5 kg, box 40x30x20 cm. Meeting moved to \
|
||||||
|
Tuesday 9:30-10:15 in room 2B, building 1177. Ticket #5520318, case 20260914-0042. \
|
||||||
|
Version 2026.9.28.4, build 118822, commit 5a73a118. Serial SN-88213-X. \
|
||||||
|
Ship to 1600 Amphitheatre Pkwy, Mountain View, CA 94043. Revenue grew 18% to \
|
||||||
|
1,204,332 units; see figures 3.1-3.4 and table 12.\n\nBest,\nSam\n\
|
||||||
|
Sam Rivera | +1 (415) 555-2671 | sam@example.com";
|
||||||
|
let quiet = ["email-addresses", "phone-numbers"];
|
||||||
|
for detector in all().filter(|d| !quiet.contains(&d.id)) {
|
||||||
|
assert_eq!(
|
||||||
|
detector.count(text),
|
||||||
|
0,
|
||||||
|
"{} fired on ordinary mail",
|
||||||
|
detector.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ids_are_unique() {
|
||||||
|
let mut seen = AHashSet::new();
|
||||||
|
for detector in all() {
|
||||||
|
assert!(seen.insert(detector.id), "duplicate id {}", detector.id);
|
||||||
|
assert!(by_id(detector.id).is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Templates (§2.3): named sets of detectors, so a policy doesn't pick forty
|
||||||
|
//! one at a time. Each is named for what it finds, never for a law, and is a
|
||||||
|
//! starting point: once added to a rule, its detectors can be changed.
|
||||||
|
|
||||||
|
pub struct Template {
|
||||||
|
pub id: &'static str,
|
||||||
|
pub name: &'static str,
|
||||||
|
pub detectors: &'static [&'static str],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub static TEMPLATES: &[Template] = &[
|
||||||
|
Template {
|
||||||
|
id: "payment-and-bank",
|
||||||
|
name: "Payment cards and bank accounts",
|
||||||
|
detectors: &["payment-card", "iban", "swift-bic", "us-aba-routing"],
|
||||||
|
},
|
||||||
|
Template {
|
||||||
|
id: "us-personal",
|
||||||
|
name: "US personal identifiers",
|
||||||
|
detectors: &[
|
||||||
|
"us-ssn",
|
||||||
|
"us-itin",
|
||||||
|
"us-ein",
|
||||||
|
"us-drivers-license",
|
||||||
|
"passport",
|
||||||
|
"date-of-birth",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Template {
|
||||||
|
id: "uk-personal",
|
||||||
|
name: "UK personal identifiers",
|
||||||
|
detectors: &["uk-nino", "uk-utr", "uk-nhs", "passport", "date-of-birth"],
|
||||||
|
},
|
||||||
|
Template {
|
||||||
|
id: "eu-national",
|
||||||
|
name: "EU national identifiers",
|
||||||
|
detectors: &[
|
||||||
|
"de-tax-id",
|
||||||
|
"de-id-card",
|
||||||
|
"fr-nir",
|
||||||
|
"es-dni-nie",
|
||||||
|
"it-codice-fiscale",
|
||||||
|
"nl-bsn",
|
||||||
|
"be-national-number",
|
||||||
|
"pl-pesel",
|
||||||
|
"se-personnummer",
|
||||||
|
"dk-cpr",
|
||||||
|
"fi-hetu",
|
||||||
|
"ie-pps",
|
||||||
|
"pt-nif",
|
||||||
|
"at-svnr",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Template {
|
||||||
|
id: "health",
|
||||||
|
name: "Health identifiers",
|
||||||
|
detectors: &["uk-nhs", "us-mbi", "us-npi", "us-dea", "au-medicare"],
|
||||||
|
},
|
||||||
|
Template {
|
||||||
|
id: "credentials",
|
||||||
|
name: "Credentials and keys",
|
||||||
|
detectors: &["private-key", "credentials"],
|
||||||
|
},
|
||||||
|
Template {
|
||||||
|
id: "contact-lists",
|
||||||
|
name: "Contact lists",
|
||||||
|
detectors: &["email-addresses", "phone-numbers"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn by_id(id: &str) -> Option<&'static Template> {
|
||||||
|
TEMPLATES.iter().find(|template| template.id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_template_names_real_detectors() {
|
||||||
|
for template in TEMPLATES {
|
||||||
|
for id in template.detectors {
|
||||||
|
assert!(
|
||||||
|
super::super::by_id(id).is_some(),
|
||||||
|
"{}: no detector {id}",
|
||||||
|
template.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! United Kingdom identifiers (§2.3): HMRC's National Insurance number and
|
||||||
|
//! Unique Taxpayer Reference, and the NHS number.
|
||||||
|
|
||||||
|
use super::{Detector, Findings, Region, Strength, word_near};
|
||||||
|
use regex::Regex;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
pub static DETECTORS: &[Detector] = &[
|
||||||
|
Detector::new(
|
||||||
|
"uk-nino",
|
||||||
|
"UK National Insurance number",
|
||||||
|
Region::Uk,
|
||||||
|
Strength::Checked,
|
||||||
|
nino,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"uk-nhs",
|
||||||
|
"UK NHS number",
|
||||||
|
Region::Uk,
|
||||||
|
Strength::Checked,
|
||||||
|
nhs,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"uk-utr",
|
||||||
|
"UK Unique Taxpayer Reference",
|
||||||
|
Region::Uk,
|
||||||
|
Strength::NeedsWord,
|
||||||
|
utr,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn re(pattern: &str) -> Regex {
|
||||||
|
Regex::new(pattern).expect("detector pattern")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two letters, six digits (often in pairs), a suffix A–D.
|
||||||
|
static NINO: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| re(r"(?i)\b([A-Z])([A-Z]) ?(\d{2}) ?(\d{2}) ?(\d{2}) ?([A-D])\b"));
|
||||||
|
|
||||||
|
/// HMRC's rules: D, F, I, Q, U and V are never used; O never second; and
|
||||||
|
/// BG, GB, KN, NK, NT, TN and ZZ are never allocated.
|
||||||
|
fn nino_prefix(first: char, second: char) -> bool {
|
||||||
|
const NEVER: &str = "DFIQUV";
|
||||||
|
let pair: String = [first, second].iter().collect();
|
||||||
|
!NEVER.contains(first)
|
||||||
|
&& !NEVER.contains(second)
|
||||||
|
&& second != 'O'
|
||||||
|
&& !["BG", "GB", "KN", "NK", "NT", "TN", "ZZ"].contains(&pair.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nino(text: &str, findings: &mut Findings) {
|
||||||
|
for c in NINO.captures_iter(text) {
|
||||||
|
let first = c[1].to_ascii_uppercase().chars().next().unwrap();
|
||||||
|
let second = c[2].to_ascii_uppercase().chars().next().unwrap();
|
||||||
|
if nino_prefix(first, second) {
|
||||||
|
findings.insert(format!(
|
||||||
|
"{first}{second}{}{}{}{}",
|
||||||
|
&c[3],
|
||||||
|
&c[4],
|
||||||
|
&c[5],
|
||||||
|
c[6].to_ascii_uppercase()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NNN NNN NNNN` stands alone; ten bare digits need a word.
|
||||||
|
static NHS: LazyLock<Regex> = LazyLock::new(|| re(r"\b(\d{3})([ -]?)(\d{3})([ -]?)(\d{4})\b"));
|
||||||
|
|
||||||
|
/// Mod 11: weights 10 down to 2 over the first nine digits; the check digit
|
||||||
|
/// is 11 minus the remainder (11 becomes 0; 10 is never issued).
|
||||||
|
pub fn nhs_valid(n: &str) -> bool {
|
||||||
|
let d: Vec<u32> = n.bytes().map(|b| u32::from(b - b'0')).collect();
|
||||||
|
let sum: u32 = d[..9].iter().zip((2..=10).rev()).map(|(a, w)| a * w).sum();
|
||||||
|
match 11 - sum % 11 {
|
||||||
|
11 => d[9] == 0,
|
||||||
|
10 => false,
|
||||||
|
check => d[9] == check,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const NHS_WORDS: &[&str] = &["nhs", "nhs number", "nhs no"];
|
||||||
|
|
||||||
|
fn nhs(text: &str, findings: &mut Findings) {
|
||||||
|
for c in NHS.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
let n = format!("{}{}{}", &c[1], &c[3], &c[5]);
|
||||||
|
let written = !c[2].is_empty() && c[2] == c[4];
|
||||||
|
if nhs_valid(&n) && (written || word_near(text, whole.start(), whole.end(), NHS_WORDS)) {
|
||||||
|
findings.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static UTR: LazyLock<Regex> = LazyLock::new(|| re(r"\b\d{5} ?\d{5}\b"));
|
||||||
|
|
||||||
|
const UTR_WORDS: &[&str] = &[
|
||||||
|
"utr",
|
||||||
|
"unique taxpayer reference",
|
||||||
|
"tax reference",
|
||||||
|
"self assessment",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn utr(text: &str, findings: &mut Findings) {
|
||||||
|
for m in UTR.find_iter(text) {
|
||||||
|
if word_near(text, m.start(), m.end(), UTR_WORDS) {
|
||||||
|
findings.insert(m.as_str().replace(' ', ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::mailflow::detectors::by_id;
|
||||||
|
|
||||||
|
fn count(id: &str, text: &str) -> usize {
|
||||||
|
by_id(id).unwrap().count(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn national_insurance() {
|
||||||
|
assert_eq!(count("uk-nino", "NI: AB 12 34 56 C, ce123456d"), 2);
|
||||||
|
// Letters never used, pairs never allocated, a suffix past D
|
||||||
|
for bad in [
|
||||||
|
"QQ123456C",
|
||||||
|
"AO123456C",
|
||||||
|
"GB123456A",
|
||||||
|
"AB123456E",
|
||||||
|
"DA123456A",
|
||||||
|
] {
|
||||||
|
assert_eq!(count("uk-nino", bad), 0, "{bad}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nhs_numbers() {
|
||||||
|
// The NHS's own example
|
||||||
|
assert_eq!(count("uk-nhs", "943 476 5919"), 1);
|
||||||
|
assert_eq!(count("uk-nhs", "943 476 5918"), 0);
|
||||||
|
assert_eq!(count("uk-nhs", "order 9434765919"), 0);
|
||||||
|
assert_eq!(count("uk-nhs", "NHS number 9434765919"), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn utr() {
|
||||||
|
assert_eq!(count("uk-utr", "UTR 12345 67890"), 1);
|
||||||
|
assert_eq!(count("uk-utr", "order 1234567890"), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! United States identifiers (§2.3), each from its issuer's published rules:
|
||||||
|
//! the SSA (SSN), the IRS (ITIN, EIN), the ABA (routing numbers), CMS (MBI,
|
||||||
|
//! NPI) and the DEA.
|
||||||
|
|
||||||
|
use super::{Detector, Findings, Region, Strength, checks, digits, stands_alone, word_near};
|
||||||
|
use regex::Regex;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
pub static DETECTORS: &[Detector] = &[
|
||||||
|
Detector::new(
|
||||||
|
"us-ssn",
|
||||||
|
"US Social Security number",
|
||||||
|
Region::Us,
|
||||||
|
Strength::Checked,
|
||||||
|
ssn,
|
||||||
|
),
|
||||||
|
Detector::new("us-itin", "US ITIN", Region::Us, Strength::Checked, itin),
|
||||||
|
Detector::new("us-ein", "US EIN", Region::Us, Strength::NeedsWord, ein),
|
||||||
|
Detector::new(
|
||||||
|
"us-aba-routing",
|
||||||
|
"US bank routing number",
|
||||||
|
Region::Us,
|
||||||
|
Strength::NeedsWord,
|
||||||
|
aba_routing,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"us-drivers-license",
|
||||||
|
"US driver's license",
|
||||||
|
Region::Us,
|
||||||
|
Strength::NeedsWord,
|
||||||
|
drivers_license,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"us-mbi",
|
||||||
|
"US Medicare Beneficiary Identifier",
|
||||||
|
Region::Us,
|
||||||
|
Strength::Checked,
|
||||||
|
mbi,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"us-npi",
|
||||||
|
"US National Provider Identifier",
|
||||||
|
Region::Us,
|
||||||
|
Strength::NeedsWord,
|
||||||
|
npi,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"us-dea",
|
||||||
|
"US DEA registration number",
|
||||||
|
Region::Us,
|
||||||
|
Strength::Checked,
|
||||||
|
dea,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn re(pattern: &str) -> Regex {
|
||||||
|
Regex::new(pattern).expect("detector pattern")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `AAA-GG-SSSS` (dashes or spaces), or nine bare digits.
|
||||||
|
static NINE: LazyLock<Regex> = LazyLock::new(|| re(r"\b(\d{3})([ -]?)(\d{2})([ -]?)(\d{4})\b"));
|
||||||
|
|
||||||
|
/// Numbers the SSA has published as never valid: widely printed examples.
|
||||||
|
const SSN_EXAMPLES: &[&str] = &["078051120", "219099999"];
|
||||||
|
|
||||||
|
fn ssn_rules(area: u32, group: u32, serial: u32) -> bool {
|
||||||
|
area != 0 && area != 666 && area < 900 && group != 0 && serial != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const SSN_WORDS: &[&str] = &["ssn", "social security", "soc sec", "ss#", "ss no"];
|
||||||
|
|
||||||
|
fn ssn(text: &str, findings: &mut Findings) {
|
||||||
|
for c in NINE.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
let (area, group, serial) = (num(&c[1]), num(&c[3]), num(&c[5]));
|
||||||
|
let number = format!("{}{}{}", &c[1], &c[3], &c[5]);
|
||||||
|
// Written form (with both separators, the same one) stands alone;
|
||||||
|
// nine bare digits need a word
|
||||||
|
let written = !c[2].is_empty() && c[2] == c[4];
|
||||||
|
if ssn_rules(area, group, serial)
|
||||||
|
&& !SSN_EXAMPLES.contains(&number.as_str())
|
||||||
|
&& (written || word_near(text, whole.start(), whole.end(), SSN_WORDS))
|
||||||
|
{
|
||||||
|
findings.insert(number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ITINs: 9XX, then a group in the IRS's ranges.
|
||||||
|
fn itin_group(group: u32) -> bool {
|
||||||
|
matches!(group, 50..=65 | 70..=88 | 90..=92 | 94..=99)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ITIN_WORDS: &[&str] = &["itin", "taxpayer identification", "tax id"];
|
||||||
|
|
||||||
|
fn itin(text: &str, findings: &mut Findings) {
|
||||||
|
for c in NINE.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
let written = !c[2].is_empty() && c[2] == c[4];
|
||||||
|
if c[1].starts_with('9')
|
||||||
|
&& itin_group(num(&c[3]))
|
||||||
|
&& (written || word_near(text, whole.start(), whole.end(), ITIN_WORDS))
|
||||||
|
{
|
||||||
|
findings.insert(format!("{}{}{}", &c[1], &c[3], &c[5]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static EIN: LazyLock<Regex> = LazyLock::new(|| re(r"\b(\d{2})-?(\d{7})\b"));
|
||||||
|
|
||||||
|
/// The prefixes the IRS assigns to its campuses and internet EINs.
|
||||||
|
fn ein_prefix(prefix: u32) -> bool {
|
||||||
|
matches!(prefix, 1..=6 | 10..=16 | 20..=27 | 30..=48 | 50..=68 | 71..=77 | 80..=88 | 90..=95 | 98 | 99)
|
||||||
|
}
|
||||||
|
|
||||||
|
const EIN_WORDS: &[&str] = &[
|
||||||
|
"ein",
|
||||||
|
"fein",
|
||||||
|
"employer identification",
|
||||||
|
"tax id",
|
||||||
|
"tin",
|
||||||
|
"federal tax",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn ein(text: &str, findings: &mut Findings) {
|
||||||
|
for c in EIN.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
if ein_prefix(num(&c[1])) && word_near(text, whole.start(), whole.end(), EIN_WORDS) {
|
||||||
|
findings.insert(format!("{}{}", &c[1], &c[2]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static ROUTING: LazyLock<Regex> = LazyLock::new(|| re(r"\b\d{9}\b"));
|
||||||
|
|
||||||
|
/// The ABA check: 3, 7 and 1 weights, mod 10; and a Federal Reserve prefix.
|
||||||
|
pub fn aba_valid(n: &str) -> bool {
|
||||||
|
let d: Vec<u32> = n.bytes().map(|b| u32::from(b - b'0')).collect();
|
||||||
|
let prefix = d[0] * 10 + d[1];
|
||||||
|
matches!(prefix, 0..=12 | 21..=32 | 61..=72 | 80)
|
||||||
|
&& (3 * (d[0] + d[3] + d[6]) + 7 * (d[1] + d[4] + d[7]) + (d[2] + d[5] + d[8]))
|
||||||
|
.is_multiple_of(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROUTING_WORDS: &[&str] = &["routing", "aba", "rtn", "routing number", "transit"];
|
||||||
|
|
||||||
|
fn aba_routing(text: &str, findings: &mut Findings) {
|
||||||
|
for m in ROUTING.find_iter(text) {
|
||||||
|
// One random number in ten passes the check: always needs a word
|
||||||
|
if aba_valid(m.as_str()) && word_near(text, m.start(), m.end(), ROUTING_WORDS) {
|
||||||
|
findings.insert(m.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shapes states issue: up to two letters, then 5–14 digits, dashes
|
||||||
|
/// allowed (Florida and Illinois print them).
|
||||||
|
static LICENSE: LazyLock<Regex> = LazyLock::new(|| re(r"\b[A-Z]{0,2}\d[\d-]{3,16}\d\b"));
|
||||||
|
|
||||||
|
const LICENSE_WORDS: &[&str] = &[
|
||||||
|
"driver's license",
|
||||||
|
"drivers license",
|
||||||
|
"driver license",
|
||||||
|
"driver's licence",
|
||||||
|
"dl",
|
||||||
|
"dl#",
|
||||||
|
"license number",
|
||||||
|
"lic no",
|
||||||
|
"dmv",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn drivers_license(text: &str, findings: &mut Findings) {
|
||||||
|
for m in LICENSE.find_iter(text) {
|
||||||
|
let n = digits(m.as_str());
|
||||||
|
if (5..=14).contains(&n.len()) && word_near(text, m.start(), m.end(), LICENSE_WORDS) {
|
||||||
|
findings.insert(m.as_str().replace('-', ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CMS's MBI: 11 characters in a fixed pattern of digits, letters and
|
||||||
|
/// either, the letters S, L, O, I, B and Z never used; dashes may follow the
|
||||||
|
/// 4th and 7th.
|
||||||
|
static MBI: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
let c = "[AC-HJKMNP-RT-Y]";
|
||||||
|
let an = "[AC-HJKMNP-RT-Y0-9]";
|
||||||
|
re(&format!(
|
||||||
|
r"\b[1-9]{c}{an}[0-9]-?{c}{an}[0-9]-?{c}{c}[0-9][0-9]\b"
|
||||||
|
))
|
||||||
|
});
|
||||||
|
|
||||||
|
fn mbi(text: &str, findings: &mut Findings) {
|
||||||
|
for m in MBI.find_iter(text) {
|
||||||
|
findings.insert(m.as_str().replace('-', ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static TEN: LazyLock<Regex> = LazyLock::new(|| re(r"\b[12]\d{9}\b"));
|
||||||
|
|
||||||
|
const NPI_WORDS: &[&str] = &["npi", "national provider", "provider id", "provider number"];
|
||||||
|
|
||||||
|
/// NPI: Luhn over the ISO card-issuer prefix 80840 and the number.
|
||||||
|
fn npi(text: &str, findings: &mut Findings) {
|
||||||
|
for m in TEN.find_iter(text) {
|
||||||
|
if checks::luhn(&format!("80840{}", m.as_str()))
|
||||||
|
&& word_near(text, m.start(), m.end(), NPI_WORDS)
|
||||||
|
{
|
||||||
|
findings.insert(m.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static DEA: LazyLock<Regex> = LazyLock::new(|| re(r"\b([ABCDEFGHJKLMPRSTUX][A-Z9])(\d{7})\b"));
|
||||||
|
|
||||||
|
/// DEA: (1st + 3rd + 5th) + 2 × (2nd + 4th + 6th) ends in the 7th digit.
|
||||||
|
fn dea(text: &str, findings: &mut Findings) {
|
||||||
|
for c in DEA.captures_iter(text) {
|
||||||
|
let d: Vec<u32> = c[2].bytes().map(|b| u32::from(b - b'0')).collect();
|
||||||
|
if ((d[0] + d[2] + d[4]) + 2 * (d[1] + d[3] + d[5])) % 10 == d[6] {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
if stands_alone(text, whole.start(), whole.end()) {
|
||||||
|
findings.insert(whole.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn num(s: &str) -> u32 {
|
||||||
|
s.parse().unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::mailflow::detectors::by_id;
|
||||||
|
|
||||||
|
fn count(id: &str, text: &str) -> usize {
|
||||||
|
by_id(id).unwrap().count(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ssn() {
|
||||||
|
assert_eq!(count("us-ssn", "SSN 536-22-1234, also 536 22 1235"), 2);
|
||||||
|
// Bare digits: only with a word
|
||||||
|
assert_eq!(count("us-ssn", "ref 536221234"), 0);
|
||||||
|
assert_eq!(count("us-ssn", "social security: 536221234"), 1);
|
||||||
|
// Never issued, the SSA's printed examples, mixed separators
|
||||||
|
for bad in [
|
||||||
|
"000-12-3456",
|
||||||
|
"666-12-3456",
|
||||||
|
"912-12-3456",
|
||||||
|
"123-00-4567",
|
||||||
|
"123-45-0000",
|
||||||
|
"078-05-1120",
|
||||||
|
"536-22 1234",
|
||||||
|
] {
|
||||||
|
assert_eq!(count("us-ssn", bad), 0, "{bad}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn itin_and_ein() {
|
||||||
|
assert_eq!(count("us-itin", "912-70-1234"), 1);
|
||||||
|
assert_eq!(count("us-itin", "912-69-1234"), 0);
|
||||||
|
assert_eq!(count("us-ssn", "912-70-1234"), 0);
|
||||||
|
assert_eq!(count("us-ein", "EIN: 12-3456789"), 1);
|
||||||
|
assert_eq!(count("us-ein", "part 12-3456789"), 0);
|
||||||
|
assert_eq!(count("us-ein", "EIN 07-3456789"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn routing_needs_a_word() {
|
||||||
|
assert_eq!(count("us-aba-routing", "Routing number 011000015"), 1);
|
||||||
|
assert_eq!(count("us-aba-routing", "ABA 021000021"), 1);
|
||||||
|
assert_eq!(count("us-aba-routing", "invoice 011000015"), 0);
|
||||||
|
assert_eq!(count("us-aba-routing", "routing 011000016"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn licenses() {
|
||||||
|
assert_eq!(count("us-drivers-license", "Driver's license: D1234567"), 1);
|
||||||
|
assert_eq!(count("us-drivers-license", "DL# S123-456-78-901-0"), 1);
|
||||||
|
assert_eq!(count("us-drivers-license", "Order D1234567"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn health_identifiers() {
|
||||||
|
// CMS's own MBI example
|
||||||
|
assert_eq!(count("us-mbi", "Medicare 1EG4-TE5-MK73"), 1);
|
||||||
|
assert_eq!(count("us-mbi", "1EG4TE5MK73"), 1);
|
||||||
|
assert_eq!(count("us-mbi", "1EG4-TE5-MK7S"), 0);
|
||||||
|
// CMS's NPI example
|
||||||
|
assert_eq!(count("us-npi", "NPI 1234567893"), 1);
|
||||||
|
assert_eq!(count("us-npi", "NPI 1234567894"), 0);
|
||||||
|
assert_eq!(count("us-npi", "call 1234567893"), 0);
|
||||||
|
assert_eq!(count("us-dea", "DEA AB1234563"), 1);
|
||||||
|
assert_eq!(count("us-dea", "AB1234564"), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,697 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Evaluating rules against a message (§2.1–§2.4). Rules are compiled once,
|
||||||
|
//! when they change: word lists become automata, patterns regexes. A message
|
||||||
|
//! is then checked against every enabled rule in order; each detector runs
|
||||||
|
//! at most once per message, and only when some rule asks for it.
|
||||||
|
//!
|
||||||
|
//! Pure: the caller parses the message, extracts attachment text
|
||||||
|
//! ([`super::extract`]) and knows the sender's groups and tenant. What comes
|
||||||
|
//! back is which rules matched, with each detector's count, and what DLP
|
||||||
|
//! decided; the matched text itself never leaves here (§2.7).
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
detectors::{self, Findings},
|
||||||
|
extract::Extracted,
|
||||||
|
rules::{Action, Condition, Direction, Kind, Rule},
|
||||||
|
words::{Pattern, WordList},
|
||||||
|
};
|
||||||
|
use ahash::AHashMap;
|
||||||
|
use std::borrow::Cow;
|
||||||
|
|
||||||
|
/// Who sent a message, and to whom.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Envelope<'a> {
|
||||||
|
/// Outgoing (an authenticated sender) or incoming.
|
||||||
|
pub outgoing: bool,
|
||||||
|
pub sender: &'a str,
|
||||||
|
pub sender_groups: &'a [u32],
|
||||||
|
pub sender_tenant: Option<u32>,
|
||||||
|
pub recipients: Vec<Recipient<'a>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Recipient<'a> {
|
||||||
|
pub address: &'a str,
|
||||||
|
/// At a domain this server hosts.
|
||||||
|
pub local: bool,
|
||||||
|
pub groups: &'a [u32],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Attachment<'a> {
|
||||||
|
pub name: Option<&'a str>,
|
||||||
|
/// Declared type, or detected where the caller knows better.
|
||||||
|
pub content_type: Cow<'a, str>,
|
||||||
|
pub size: u64,
|
||||||
|
pub extracted: Extracted,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What rules look at.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Content<'a> {
|
||||||
|
pub subject: &'a str,
|
||||||
|
/// Each text and HTML part, as text.
|
||||||
|
pub bodies: Vec<Cow<'a, str>>,
|
||||||
|
pub headers: Vec<(&'a str, &'a str)>,
|
||||||
|
pub attachments: Vec<Attachment<'a>>,
|
||||||
|
pub size: u64,
|
||||||
|
/// Text past the inspection limit wasn't read.
|
||||||
|
pub truncated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Content<'_> {
|
||||||
|
fn texts(&self) -> impl Iterator<Item = &str> {
|
||||||
|
std::iter::once(self.subject)
|
||||||
|
.chain(self.bodies.iter().map(|b| b.as_ref()))
|
||||||
|
.chain(self.attachments.iter().filter_map(|a| match &a.extracted {
|
||||||
|
Extracted::Text(text) => Some(text.as_str()),
|
||||||
|
_ => None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cant_be_inspected(&self) -> bool {
|
||||||
|
self.truncated
|
||||||
|
|| self
|
||||||
|
.attachments
|
||||||
|
.iter()
|
||||||
|
.any(|a| matches!(a.extracted, Extracted::NotInspectable(_)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Check {
|
||||||
|
Plain(Condition),
|
||||||
|
Words(WordList, u32),
|
||||||
|
Pattern(Pattern, u32),
|
||||||
|
Header {
|
||||||
|
name: String,
|
||||||
|
contains: Option<String>,
|
||||||
|
matches: Option<Pattern>,
|
||||||
|
},
|
||||||
|
AttachmentName(Pattern),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CompiledRule {
|
||||||
|
rule: Rule,
|
||||||
|
conditions: Vec<Check>,
|
||||||
|
exceptions: Vec<Check>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The enabled rules, ready to run.
|
||||||
|
pub struct Compiled {
|
||||||
|
rules: Vec<CompiledRule>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A rule reference, for notices and the audit record.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct RuleRef {
|
||||||
|
pub id: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub notice: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Match {
|
||||||
|
pub rule_id: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub kind: Kind,
|
||||||
|
pub actions: Vec<Action>,
|
||||||
|
/// Each detector (or `words`, `pattern`) that counted, and its count.
|
||||||
|
pub counts: Vec<(String, usize)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Outcome {
|
||||||
|
pub matched: Vec<Match>,
|
||||||
|
pub blocks: Vec<RuleRef>,
|
||||||
|
pub holds: Vec<(RuleRef, bool)>,
|
||||||
|
pub warns: Vec<RuleRef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What DLP decided, strictest first (§2.4).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Decision {
|
||||||
|
Pass,
|
||||||
|
Block(Vec<RuleRef>),
|
||||||
|
Hold {
|
||||||
|
rules: Vec<RuleRef>,
|
||||||
|
notify_sender: bool,
|
||||||
|
},
|
||||||
|
Warn(Vec<RuleRef>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Outcome {
|
||||||
|
/// Block beats hold beats warn. An override (§2.5) answers the warnings
|
||||||
|
/// only: a block or hold still applies.
|
||||||
|
pub fn decision(&self, overridden: bool) -> Decision {
|
||||||
|
if !self.blocks.is_empty() {
|
||||||
|
Decision::Block(self.blocks.clone())
|
||||||
|
} else if !self.holds.is_empty() {
|
||||||
|
Decision::Hold {
|
||||||
|
rules: self.holds.iter().map(|(r, _)| r.clone()).collect(),
|
||||||
|
notify_sender: self.holds.iter().any(|(_, notify)| *notify),
|
||||||
|
}
|
||||||
|
} else if !self.warns.is_empty() && !overridden {
|
||||||
|
Decision::Warn(self.warns.clone())
|
||||||
|
} else {
|
||||||
|
Decision::Pass
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compile_check(condition: &Condition) -> Result<Check, String> {
|
||||||
|
Ok(match condition {
|
||||||
|
Condition::Words { words, at_least } => Check::Words(WordList::new(words)?, *at_least),
|
||||||
|
Condition::Pattern { pattern, at_least } => {
|
||||||
|
Check::Pattern(Pattern::new(pattern)?, *at_least)
|
||||||
|
}
|
||||||
|
Condition::Header {
|
||||||
|
name,
|
||||||
|
contains,
|
||||||
|
matches,
|
||||||
|
} => Check::Header {
|
||||||
|
name: name.to_ascii_lowercase(),
|
||||||
|
contains: contains.as_ref().map(|c| c.to_lowercase()),
|
||||||
|
matches: matches.as_deref().map(Pattern::new).transpose()?,
|
||||||
|
},
|
||||||
|
Condition::AttachmentName { pattern } => Check::AttachmentName(Pattern::new(pattern)?),
|
||||||
|
other => Check::Plain(other.clone()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Compiled {
|
||||||
|
/// Compiles the enabled rules; one that no longer compiles (a detector
|
||||||
|
/// renamed since it was saved) is skipped and named in the second list.
|
||||||
|
pub fn new(rules: &[Rule]) -> (Self, Vec<(u32, String)>) {
|
||||||
|
let mut compiled = Vec::new();
|
||||||
|
let mut skipped = Vec::new();
|
||||||
|
for rule in rules.iter().filter(|r| r.enabled) {
|
||||||
|
let result = rule.validate().map_err(|e| e.reason).and_then(|_| {
|
||||||
|
Ok(CompiledRule {
|
||||||
|
rule: rule.clone(),
|
||||||
|
conditions: rule
|
||||||
|
.conditions
|
||||||
|
.iter()
|
||||||
|
.map(compile_check)
|
||||||
|
.collect::<Result<_, _>>()?,
|
||||||
|
exceptions: rule
|
||||||
|
.exceptions
|
||||||
|
.iter()
|
||||||
|
.map(compile_check)
|
||||||
|
.collect::<Result<_, _>>()?,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
match result {
|
||||||
|
Ok(c) => compiled.push(c),
|
||||||
|
Err(reason) => skipped.push((rule.id, reason)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compiled.sort_by_key(|c| (c.rule.priority, c.rule.id));
|
||||||
|
(Self { rules: compiled }, skipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.rules.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether any rule could apply to mail going this way, so a caller can
|
||||||
|
/// skip parsing when none can.
|
||||||
|
pub fn applies_to(&self, outgoing: bool) -> bool {
|
||||||
|
self.rules
|
||||||
|
.iter()
|
||||||
|
.any(|c| direction_matches(c.rule.direction, outgoing))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn evaluate(&self, envelope: &Envelope<'_>, content: &Content<'_>) -> Outcome {
|
||||||
|
let mut state = State {
|
||||||
|
content,
|
||||||
|
detected: AHashMap::new(),
|
||||||
|
};
|
||||||
|
let mut outcome = Outcome::default();
|
||||||
|
for compiled in &self.rules {
|
||||||
|
let rule = &compiled.rule;
|
||||||
|
if !direction_matches(rule.direction, envelope.outgoing) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut counts = Vec::new();
|
||||||
|
let all_match = compiled
|
||||||
|
.conditions
|
||||||
|
.iter()
|
||||||
|
.all(|check| state.check(check, envelope, &mut counts));
|
||||||
|
if !all_match {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut ignored = Vec::new();
|
||||||
|
if compiled
|
||||||
|
.exceptions
|
||||||
|
.iter()
|
||||||
|
.any(|check| state.check(check, envelope, &mut ignored))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for action in &rule.actions {
|
||||||
|
let reference = |notice: &str| RuleRef {
|
||||||
|
id: rule.id,
|
||||||
|
name: rule.name.clone(),
|
||||||
|
notice: notice.to_string(),
|
||||||
|
};
|
||||||
|
match action {
|
||||||
|
Action::Block { notice } => outcome.blocks.push(reference(notice)),
|
||||||
|
Action::Hold {
|
||||||
|
notice,
|
||||||
|
notify_sender,
|
||||||
|
} => outcome.holds.push((reference(notice), *notify_sender)),
|
||||||
|
Action::Warn { notice } => outcome.warns.push(reference(notice)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outcome.matched.push(Match {
|
||||||
|
rule_id: rule.id,
|
||||||
|
name: rule.name.clone(),
|
||||||
|
kind: rule.kind,
|
||||||
|
actions: rule.actions.clone(),
|
||||||
|
counts,
|
||||||
|
});
|
||||||
|
if rule.stop_processing {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outcome
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn direction_matches(direction: Direction, outgoing: bool) -> bool {
|
||||||
|
match direction {
|
||||||
|
Direction::Any => true,
|
||||||
|
Direction::Outgoing => outgoing,
|
||||||
|
Direction::Incoming => !outgoing,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn domain_of(address: &str) -> &str {
|
||||||
|
address.rsplit_once('@').map_or("", |(_, d)| d)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn in_list(value: &str, list: &[String]) -> bool {
|
||||||
|
list.iter().any(|v| v.eq_ignore_ascii_case(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct State<'c, 'a> {
|
||||||
|
content: &'c Content<'a>,
|
||||||
|
/// Each detector's count, run once per message.
|
||||||
|
detected: AHashMap<&'static str, usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State<'_, '_> {
|
||||||
|
fn detector_count(&mut self, id: &str) -> usize {
|
||||||
|
let Some(detector) = detectors::by_id(id) else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
if let Some(count) = self.detected.get(detector.id) {
|
||||||
|
return *count;
|
||||||
|
}
|
||||||
|
let mut findings = Findings::default();
|
||||||
|
for text in self.content.texts() {
|
||||||
|
detector.find(text, &mut findings);
|
||||||
|
}
|
||||||
|
self.detected.insert(detector.id, findings.len());
|
||||||
|
findings.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(
|
||||||
|
&mut self,
|
||||||
|
check: &Check,
|
||||||
|
envelope: &Envelope<'_>,
|
||||||
|
counts: &mut Vec<(String, usize)>,
|
||||||
|
) -> bool {
|
||||||
|
let content = self.content;
|
||||||
|
match check {
|
||||||
|
Check::Words(list, at_least) => {
|
||||||
|
let n: usize = content.texts().map(|t| list.count(t)).sum();
|
||||||
|
counts.push(("words".into(), n));
|
||||||
|
n >= *at_least as usize
|
||||||
|
}
|
||||||
|
Check::Pattern(pattern, at_least) => {
|
||||||
|
let n: usize = content.texts().map(|t| pattern.count(t)).sum();
|
||||||
|
counts.push(("pattern".into(), n));
|
||||||
|
n >= *at_least as usize
|
||||||
|
}
|
||||||
|
Check::Header {
|
||||||
|
name,
|
||||||
|
contains,
|
||||||
|
matches,
|
||||||
|
} => content
|
||||||
|
.headers
|
||||||
|
.iter()
|
||||||
|
.filter(|(n, _)| n.eq_ignore_ascii_case(name))
|
||||||
|
.any(|(_, value)| match (contains, matches) {
|
||||||
|
(Some(needle), _) => value.to_lowercase().contains(needle.as_str()),
|
||||||
|
(_, Some(pattern)) => pattern.count(value) > 0,
|
||||||
|
_ => true,
|
||||||
|
}),
|
||||||
|
Check::AttachmentName(pattern) => content
|
||||||
|
.attachments
|
||||||
|
.iter()
|
||||||
|
.any(|a| a.name.is_some_and(|n| pattern.count(n) > 0)),
|
||||||
|
Check::Plain(condition) => match condition {
|
||||||
|
Condition::SenderAddress { addresses } => in_list(envelope.sender, addresses),
|
||||||
|
Condition::SenderDomain { domains } => in_list(domain_of(envelope.sender), domains),
|
||||||
|
Condition::SenderGroup { groups } => {
|
||||||
|
envelope.sender_groups.iter().any(|g| groups.contains(g))
|
||||||
|
}
|
||||||
|
Condition::SenderTenant { tenants } => {
|
||||||
|
envelope.sender_tenant.is_some_and(|t| tenants.contains(&t))
|
||||||
|
}
|
||||||
|
Condition::RecipientAddress { addresses } => envelope
|
||||||
|
.recipients
|
||||||
|
.iter()
|
||||||
|
.any(|r| in_list(r.address, addresses)),
|
||||||
|
Condition::RecipientDomain { domains } => envelope
|
||||||
|
.recipients
|
||||||
|
.iter()
|
||||||
|
.any(|r| in_list(domain_of(r.address), domains)),
|
||||||
|
Condition::RecipientGroup { groups } => envelope
|
||||||
|
.recipients
|
||||||
|
.iter()
|
||||||
|
.any(|r| r.groups.iter().any(|g| groups.contains(g))),
|
||||||
|
Condition::RecipientOutside => envelope.recipients.iter().any(|r| !r.local),
|
||||||
|
Condition::AttachmentType { types } => content.attachments.iter().any(|a| {
|
||||||
|
let ct = a.content_type.to_ascii_lowercase();
|
||||||
|
types
|
||||||
|
.iter()
|
||||||
|
.any(|t| ct.starts_with(&t.to_ascii_lowercase()))
|
||||||
|
}),
|
||||||
|
Condition::AttachmentExtension { extensions } => {
|
||||||
|
content.attachments.iter().any(|a| {
|
||||||
|
a.name
|
||||||
|
.and_then(|n| n.rsplit_once('.'))
|
||||||
|
.is_some_and(|(_, ext)| {
|
||||||
|
extensions
|
||||||
|
.iter()
|
||||||
|
.any(|e| e.trim_start_matches('.').eq_ignore_ascii_case(ext))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Condition::AttachmentSizeOver { bytes } => {
|
||||||
|
content.attachments.iter().any(|a| a.size > *bytes)
|
||||||
|
}
|
||||||
|
Condition::AttachmentCountOver { count } => {
|
||||||
|
content.attachments.len() > *count as usize
|
||||||
|
}
|
||||||
|
Condition::CantBeInspected => content.cant_be_inspected(),
|
||||||
|
Condition::MessageSizeOver { bytes } => content.size > *bytes,
|
||||||
|
Condition::Detected { detectors } => {
|
||||||
|
let mut any = false;
|
||||||
|
for d in detectors {
|
||||||
|
let n = self.detector_count(&d.id);
|
||||||
|
counts.push((d.id.clone(), n));
|
||||||
|
any |= n >= d.at_least as usize;
|
||||||
|
}
|
||||||
|
any
|
||||||
|
}
|
||||||
|
// Compiled into their own checks
|
||||||
|
Condition::Words { .. }
|
||||||
|
| Condition::Pattern { .. }
|
||||||
|
| Condition::Header { .. }
|
||||||
|
| Condition::AttachmentName { .. } => false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::mailflow::{
|
||||||
|
extract::Why,
|
||||||
|
rules::{DetectorMin, Position},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn rule(id: u32, kind: Kind, conditions: Vec<Condition>, action: Action) -> Rule {
|
||||||
|
Rule {
|
||||||
|
id,
|
||||||
|
name: format!("rule {id}"),
|
||||||
|
description: String::new(),
|
||||||
|
kind,
|
||||||
|
enabled: true,
|
||||||
|
priority: id as i32,
|
||||||
|
direction: if kind == Kind::Dlp {
|
||||||
|
Direction::Outgoing
|
||||||
|
} else {
|
||||||
|
Direction::Any
|
||||||
|
},
|
||||||
|
conditions,
|
||||||
|
exceptions: vec![],
|
||||||
|
actions: vec![action],
|
||||||
|
stop_processing: false,
|
||||||
|
created_by: String::new(),
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn envelope(outside: bool) -> Envelope<'static> {
|
||||||
|
Envelope {
|
||||||
|
outgoing: true,
|
||||||
|
sender: "[email protected]",
|
||||||
|
sender_groups: &[7],
|
||||||
|
sender_tenant: None,
|
||||||
|
recipients: vec![Recipient {
|
||||||
|
address: if outside {
|
||||||
|
"[email protected]"
|
||||||
|
} else {
|
||||||
|
"[email protected]"
|
||||||
|
},
|
||||||
|
local: !outside,
|
||||||
|
groups: &[],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cards(n: usize) -> Content<'static> {
|
||||||
|
let body: String = [
|
||||||
|
"4242 4242 4242 4242",
|
||||||
|
"5555-5555-5555-4444",
|
||||||
|
"378282246310005",
|
||||||
|
"6011111111111117",
|
||||||
|
"3566002020360505",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.take(n)
|
||||||
|
.map(|c| format!("card {c}\n"))
|
||||||
|
.collect();
|
||||||
|
Content {
|
||||||
|
subject: "Numbers",
|
||||||
|
bodies: vec![body.into()],
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn five_cards_outside(action: Action) -> Rule {
|
||||||
|
rule(
|
||||||
|
1,
|
||||||
|
Kind::Dlp,
|
||||||
|
vec![
|
||||||
|
Condition::RecipientOutside,
|
||||||
|
Condition::Detected {
|
||||||
|
detectors: vec![DetectorMin {
|
||||||
|
id: "payment-card".into(),
|
||||||
|
at_least: 5,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
action,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detector_threshold_and_recipients() {
|
||||||
|
let (rules, skipped) = Compiled::new(&[five_cards_outside(Action::Hold {
|
||||||
|
notice: "Held".into(),
|
||||||
|
notify_sender: true,
|
||||||
|
})]);
|
||||||
|
assert!(skipped.is_empty());
|
||||||
|
let outcome = rules.evaluate(&envelope(true), &cards(5));
|
||||||
|
assert_eq!(
|
||||||
|
outcome.matched[0].counts,
|
||||||
|
vec![("payment-card".to_string(), 5)]
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
outcome.decision(false),
|
||||||
|
Decision::Hold {
|
||||||
|
notify_sender: true,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
// Four cards, or everyone inside: nothing
|
||||||
|
assert_eq!(
|
||||||
|
rules.evaluate(&envelope(true), &cards(4)).decision(false),
|
||||||
|
Decision::Pass
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rules.evaluate(&envelope(false), &cards(5)).decision(false),
|
||||||
|
Decision::Pass
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strictest_wins_and_override_answers_warnings_only() {
|
||||||
|
let warn = five_cards_outside(Action::Warn {
|
||||||
|
notice: "Sure?".into(),
|
||||||
|
});
|
||||||
|
let mut block = five_cards_outside(Action::Block {
|
||||||
|
notice: "No".into(),
|
||||||
|
});
|
||||||
|
block.id = 2;
|
||||||
|
let (rules, _) = Compiled::new(&[warn.clone(), block]);
|
||||||
|
let outcome = rules.evaluate(&envelope(true), &cards(5));
|
||||||
|
assert!(matches!(outcome.decision(true), Decision::Block(_)));
|
||||||
|
let (rules, _) = Compiled::new(&[warn]);
|
||||||
|
let outcome = rules.evaluate(&envelope(true), &cards(5));
|
||||||
|
assert!(matches!(outcome.decision(false), Decision::Warn(ref w) if w[0].notice == "Sure?"));
|
||||||
|
assert_eq!(outcome.decision(true), Decision::Pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exceptions_order_and_stop_processing() {
|
||||||
|
let disclaimer = |id| {
|
||||||
|
rule(
|
||||||
|
id,
|
||||||
|
Kind::Transport,
|
||||||
|
vec![Condition::RecipientOutside],
|
||||||
|
Action::AddDisclaimer {
|
||||||
|
text: "t".into(),
|
||||||
|
html: None,
|
||||||
|
position: Position::Bottom,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut first = disclaimer(1);
|
||||||
|
first.stop_processing = true;
|
||||||
|
let (rules, _) = Compiled::new(&[disclaimer(2), first.clone()]);
|
||||||
|
let outcome = rules.evaluate(&envelope(true), &cards(0));
|
||||||
|
assert_eq!(
|
||||||
|
outcome
|
||||||
|
.matched
|
||||||
|
.iter()
|
||||||
|
.map(|m| m.rule_id)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![1]
|
||||||
|
);
|
||||||
|
|
||||||
|
first.stop_processing = false;
|
||||||
|
first.exceptions = vec![Condition::SenderGroup { groups: vec![7] }];
|
||||||
|
let (rules, _) = Compiled::new(&[disclaimer(2), first]);
|
||||||
|
let outcome = rules.evaluate(&envelope(true), &cards(0));
|
||||||
|
assert_eq!(
|
||||||
|
outcome
|
||||||
|
.matched
|
||||||
|
.iter()
|
||||||
|
.map(|m| m.rule_id)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![2]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn content_conditions() {
|
||||||
|
let content = Content {
|
||||||
|
subject: "Project Falcon",
|
||||||
|
bodies: vec!["see attached".into()],
|
||||||
|
headers: vec![("X-Class", "Internal only")],
|
||||||
|
attachments: vec![
|
||||||
|
Attachment {
|
||||||
|
name: Some("plan.docx"),
|
||||||
|
content_type:
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||||
|
.into(),
|
||||||
|
size: 40_000,
|
||||||
|
extracted: Extracted::Text("IBAN GB29 NWBK 6016 1331 9268 19".into()),
|
||||||
|
},
|
||||||
|
Attachment {
|
||||||
|
name: Some("scan.pdf"),
|
||||||
|
content_type: "application/pdf".into(),
|
||||||
|
size: 900_000,
|
||||||
|
extracted: Extracted::NotInspectable(Why::Pdf),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
size: 1_000_000,
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
let block = || Action::Block { notice: "n".into() };
|
||||||
|
let checks = [
|
||||||
|
(
|
||||||
|
Condition::Words {
|
||||||
|
words: vec!["project falcon".into()],
|
||||||
|
at_least: 1,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Condition::Header {
|
||||||
|
name: "x-class".into(),
|
||||||
|
contains: Some("internal".into()),
|
||||||
|
matches: None,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Condition::AttachmentExtension {
|
||||||
|
extensions: vec![".PDF".into()],
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Condition::AttachmentType {
|
||||||
|
types: vec!["image/".into()],
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(Condition::AttachmentSizeOver { bytes: 500_000 }, true),
|
||||||
|
(Condition::AttachmentCountOver { count: 2 }, false),
|
||||||
|
(Condition::CantBeInspected, true),
|
||||||
|
(Condition::MessageSizeOver { bytes: 2_000_000 }, false),
|
||||||
|
(
|
||||||
|
Condition::Detected {
|
||||||
|
detectors: vec![DetectorMin {
|
||||||
|
id: "iban".into(),
|
||||||
|
at_least: 1,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Condition::SenderDomain {
|
||||||
|
domains: vec!["EXAMPLE.com".into()],
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (condition, expected) in checks {
|
||||||
|
let (rules, skipped) =
|
||||||
|
Compiled::new(&[rule(1, Kind::Dlp, vec![condition.clone()], block())]);
|
||||||
|
assert!(skipped.is_empty(), "{condition:?}");
|
||||||
|
let matched = !rules.evaluate(&envelope(true), &content).matched.is_empty();
|
||||||
|
assert_eq!(matched, expected, "{condition:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn direction_and_disabled_rules() {
|
||||||
|
let mut r = five_cards_outside(Action::Block { notice: "n".into() });
|
||||||
|
let (rules, _) = Compiled::new(std::slice::from_ref(&r));
|
||||||
|
assert!(rules.applies_to(true) && !rules.applies_to(false));
|
||||||
|
let mut incoming = envelope(true);
|
||||||
|
incoming.outgoing = false;
|
||||||
|
assert_eq!(
|
||||||
|
rules.evaluate(&incoming, &cards(5)).decision(false),
|
||||||
|
Decision::Pass
|
||||||
|
);
|
||||||
|
r.enabled = false;
|
||||||
|
assert!(Compiled::new(&[r]).0.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,694 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! The text of an attachment, for the detectors (§2.3), or why there isn't
|
||||||
|
//! one.
|
||||||
|
//!
|
||||||
|
//! Read: text files (plain, CSV, JSON, XML, HTML), Office Open XML (DOCX,
|
||||||
|
//! XLSX, PPTX) and OpenDocument (ODT, ODS, ODP) documents, and ZIP archives
|
||||||
|
//! one level deep. **Can't be inspected**: encrypted or password-protected
|
||||||
|
//! files, PDF (settled answer 2), the older binary Office formats, archives
|
||||||
|
//! inside archives, and anything past the limits. Everything else (images,
|
||||||
|
//! audio, programs) has no text to read and is neither.
|
||||||
|
//!
|
||||||
|
//! Office files are ZIP archives of XML, read here with the `zip` and
|
||||||
|
//! `quick-xml` crates the server already uses: no outside converter runs.
|
||||||
|
|
||||||
|
use quick_xml::{Reader, XmlVersion, events::Event};
|
||||||
|
use std::io::{Cursor, Read};
|
||||||
|
|
||||||
|
/// How much may be unpacked from one attachment, and from how many entries.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Limits {
|
||||||
|
pub max_unpacked: u64,
|
||||||
|
pub max_entries: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Limits {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_unpacked: 50 * 1024 * 1024,
|
||||||
|
max_entries: 10_000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Extracted {
|
||||||
|
/// The text to check.
|
||||||
|
Text(String),
|
||||||
|
/// A kind of file with no text in it: nothing to check, nothing missed.
|
||||||
|
NoText,
|
||||||
|
/// A file that may hold text the detectors couldn't read.
|
||||||
|
NotInspectable(Why),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Why {
|
||||||
|
Encrypted,
|
||||||
|
Pdf,
|
||||||
|
LegacyOffice,
|
||||||
|
NestedArchive,
|
||||||
|
TooLarge,
|
||||||
|
Damaged,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Why {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Why::Encrypted => "encrypted",
|
||||||
|
Why::Pdf => "pdf",
|
||||||
|
Why::LegacyOffice => "legacy-office",
|
||||||
|
Why::NestedArchive => "nested-archive",
|
||||||
|
Why::TooLarge => "too-large",
|
||||||
|
Why::Damaged => "damaged",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const OLE_MAGIC: &[u8] = &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
|
||||||
|
const ZIP_MAGIC: &[u8] = b"PK\x03\x04";
|
||||||
|
|
||||||
|
/// What an attachment says, from its declared type, its file name and, above
|
||||||
|
/// all, its first bytes.
|
||||||
|
pub fn extract(
|
||||||
|
content_type: &str,
|
||||||
|
file_name: Option<&str>,
|
||||||
|
data: &[u8],
|
||||||
|
limits: &Limits,
|
||||||
|
) -> Extracted {
|
||||||
|
extract_at(content_type, file_name, data, limits, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_at(
|
||||||
|
content_type: &str,
|
||||||
|
file_name: Option<&str>,
|
||||||
|
data: &[u8],
|
||||||
|
limits: &Limits,
|
||||||
|
depth: u8,
|
||||||
|
) -> Extracted {
|
||||||
|
let content_type = content_type.to_ascii_lowercase();
|
||||||
|
let extension = file_name
|
||||||
|
.and_then(|name| name.rsplit_once('.'))
|
||||||
|
.map(|(_, ext)| ext.to_ascii_lowercase())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if data.len() as u64 > limits.max_unpacked {
|
||||||
|
return Extracted::NotInspectable(Why::TooLarge);
|
||||||
|
}
|
||||||
|
if data.starts_with(b"%PDF-") || content_type == "application/pdf" || extension == "pdf" {
|
||||||
|
return Extracted::NotInspectable(Why::Pdf);
|
||||||
|
}
|
||||||
|
if data.starts_with(OLE_MAGIC) {
|
||||||
|
// An encrypted OOXML file is an OLE container holding the encrypted
|
||||||
|
// package; any other OLE file is a legacy .doc, .xls or .ppt
|
||||||
|
return Extracted::NotInspectable(if has_utf16(data, "EncryptedPackage") {
|
||||||
|
Why::Encrypted
|
||||||
|
} else {
|
||||||
|
Why::LegacyOffice
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if data.starts_with(ZIP_MAGIC) {
|
||||||
|
if depth > 0 {
|
||||||
|
return Extracted::NotInspectable(Why::NestedArchive);
|
||||||
|
}
|
||||||
|
return zip(data, limits);
|
||||||
|
}
|
||||||
|
if is_text(&content_type, &extension) {
|
||||||
|
let text = decode_text(data);
|
||||||
|
return Extracted::Text(
|
||||||
|
if content_type == "text/html" || matches!(extension.as_str(), "html" | "htm") {
|
||||||
|
strip_html(&text)
|
||||||
|
} else {
|
||||||
|
text
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Extracted::NoText
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_text(content_type: &str, extension: &str) -> bool {
|
||||||
|
content_type.starts_with("text/")
|
||||||
|
|| matches!(
|
||||||
|
content_type,
|
||||||
|
"application/json"
|
||||||
|
| "application/xml"
|
||||||
|
| "application/csv"
|
||||||
|
| "application/x-csv"
|
||||||
|
| "message/rfc822"
|
||||||
|
)
|
||||||
|
|| matches!(
|
||||||
|
extension,
|
||||||
|
"txt"
|
||||||
|
| "csv"
|
||||||
|
| "tsv"
|
||||||
|
| "json"
|
||||||
|
| "xml"
|
||||||
|
| "md"
|
||||||
|
| "log"
|
||||||
|
| "html"
|
||||||
|
| "htm"
|
||||||
|
| "eml"
|
||||||
|
| "ics"
|
||||||
|
| "vcf"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UTF-16 with a byte order mark, else UTF-8 (lossy).
|
||||||
|
fn decode_text(data: &[u8]) -> String {
|
||||||
|
let utf16 = |bytes: &[u8], big: bool| {
|
||||||
|
let units: Vec<u16> = bytes
|
||||||
|
.as_chunks::<2>()
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.map(|&c| {
|
||||||
|
if big {
|
||||||
|
u16::from_be_bytes(c)
|
||||||
|
} else {
|
||||||
|
u16::from_le_bytes(c)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
String::from_utf16_lossy(&units)
|
||||||
|
};
|
||||||
|
match data {
|
||||||
|
[0xFF, 0xFE, rest @ ..] => utf16(rest, false),
|
||||||
|
[0xFE, 0xFF, rest @ ..] => utf16(rest, true),
|
||||||
|
[0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(),
|
||||||
|
_ => String::from_utf8_lossy(data).into_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_utf16(data: &[u8], needle: &str) -> bool {
|
||||||
|
let needle: Vec<u8> = needle.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||||
|
data.windows(needle.len()).any(|w| w == needle.as_slice())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tags out, the common entities decoded, block ends as new lines.
|
||||||
|
fn strip_html(html: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(html.len());
|
||||||
|
let mut in_tag = false;
|
||||||
|
let mut skip_until: Option<&str> = None;
|
||||||
|
let lower = html.to_ascii_lowercase();
|
||||||
|
let mut i = 0;
|
||||||
|
let bytes = html.as_bytes();
|
||||||
|
while i < bytes.len() {
|
||||||
|
if let Some(end) = skip_until {
|
||||||
|
match lower[i..].find(end) {
|
||||||
|
Some(at) => {
|
||||||
|
i += at + end.len();
|
||||||
|
skip_until = None;
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let c = bytes[i];
|
||||||
|
if in_tag {
|
||||||
|
if c == b'>' {
|
||||||
|
in_tag = false;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if c == b'<' {
|
||||||
|
if lower[i..].starts_with("<script") {
|
||||||
|
skip_until = Some("</script>");
|
||||||
|
} else if lower[i..].starts_with("<style") {
|
||||||
|
skip_until = Some("</style>");
|
||||||
|
} else {
|
||||||
|
if [
|
||||||
|
"<br", "<p", "</p", "<div", "</div", "<tr", "<li", "<td", "<th",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|t| lower[i..].starts_with(t))
|
||||||
|
{
|
||||||
|
out.push(
|
||||||
|
if lower[i..].starts_with("<td") || lower[i..].starts_with("<th") {
|
||||||
|
'\t'
|
||||||
|
} else {
|
||||||
|
'\n'
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
in_tag = true;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Copy up to the next tag
|
||||||
|
let next = html[i..].find('<').map_or(html.len(), |at| i + at);
|
||||||
|
out.push_str(&html[i..next]);
|
||||||
|
i = next;
|
||||||
|
}
|
||||||
|
for (entity, text) in [
|
||||||
|
(" ", " "),
|
||||||
|
("<", "<"),
|
||||||
|
(">", ">"),
|
||||||
|
(""", "\""),
|
||||||
|
("'", "'"),
|
||||||
|
("&", "&"),
|
||||||
|
] {
|
||||||
|
out = out.replace(entity, text);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A ZIP file: an Office document, an OpenDocument, or an archive.
|
||||||
|
fn zip(data: &[u8], limits: &Limits) -> Extracted {
|
||||||
|
let Ok(mut archive) = zip::ZipArchive::new(Cursor::new(data)) else {
|
||||||
|
return Extracted::NotInspectable(Why::Damaged);
|
||||||
|
};
|
||||||
|
if archive.len() > limits.max_entries {
|
||||||
|
return Extracted::NotInspectable(Why::TooLarge);
|
||||||
|
}
|
||||||
|
let mut names = Vec::with_capacity(archive.len());
|
||||||
|
let mut declared: u64 = 0;
|
||||||
|
for i in 0..archive.len() {
|
||||||
|
let Ok(entry) = archive.by_index_raw(i) else {
|
||||||
|
return Extracted::NotInspectable(Why::Damaged);
|
||||||
|
};
|
||||||
|
if entry.encrypted() {
|
||||||
|
return Extracted::NotInspectable(Why::Encrypted);
|
||||||
|
}
|
||||||
|
declared = declared.saturating_add(entry.size());
|
||||||
|
names.push(entry.name().to_string());
|
||||||
|
}
|
||||||
|
if declared > limits.max_unpacked {
|
||||||
|
return Extracted::NotInspectable(Why::TooLarge);
|
||||||
|
}
|
||||||
|
let mut budget = limits.max_unpacked;
|
||||||
|
let mut read =
|
||||||
|
|archive: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str| -> Result<Vec<u8>, Why> {
|
||||||
|
let entry = archive.by_name(name).map_err(|_| Why::Damaged)?;
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
// Declared sizes can lie: stop at the budget whatever they say
|
||||||
|
entry
|
||||||
|
.take(budget + 1)
|
||||||
|
.read_to_end(&mut bytes)
|
||||||
|
.map_err(|_| Why::Damaged)?;
|
||||||
|
if bytes.len() as u64 > budget {
|
||||||
|
return Err(Why::TooLarge);
|
||||||
|
}
|
||||||
|
budget -= bytes.len() as u64;
|
||||||
|
Ok(bytes)
|
||||||
|
};
|
||||||
|
|
||||||
|
let has = |name: &str| names.iter().any(|n| n == name);
|
||||||
|
let mut text = String::new();
|
||||||
|
let result: Result<(), Why> = (|| {
|
||||||
|
if has("[Content_Types].xml") {
|
||||||
|
// Office Open XML: the parts that hold what a person wrote
|
||||||
|
let mut shared = Vec::new();
|
||||||
|
if has("xl/sharedStrings.xml") {
|
||||||
|
shared = xml_strings(&read(&mut archive, "xl/sharedStrings.xml")?, "si");
|
||||||
|
}
|
||||||
|
for name in names.iter().filter(|n| ooxml_text_part(n)) {
|
||||||
|
let xml = read(&mut archive, name)?;
|
||||||
|
if name.starts_with("xl/worksheets/") {
|
||||||
|
xlsx_sheet(&xml, &mut text);
|
||||||
|
} else {
|
||||||
|
xml_text(&xml, &mut text);
|
||||||
|
}
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
text.extend(shared.iter().map(|s| format!("{s}\n")));
|
||||||
|
} else if names.first().is_some_and(|n| n == "mimetype")
|
||||||
|
&& read(&mut archive, "mimetype")?.starts_with(b"application/vnd.oasis.opendocument")
|
||||||
|
{
|
||||||
|
// OpenDocument: an encrypted one says so in its manifest
|
||||||
|
if has("META-INF/manifest.xml")
|
||||||
|
&& contains(
|
||||||
|
&read(&mut archive, "META-INF/manifest.xml")?,
|
||||||
|
b"encryption-data",
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return Err(Why::Encrypted);
|
||||||
|
}
|
||||||
|
for name in ["content.xml", "styles.xml"] {
|
||||||
|
if has(name) {
|
||||||
|
xml_text(&read(&mut archive, name)?, &mut text);
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// An archive: each file inside, one level deep
|
||||||
|
for name in names.iter().filter(|n| !n.ends_with('/')) {
|
||||||
|
let bytes = read(&mut archive, name)?;
|
||||||
|
match extract_at("", Some(name), &bytes, limits, 1) {
|
||||||
|
Extracted::Text(inner) => {
|
||||||
|
text.push_str(&inner);
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
Extracted::NoText => {}
|
||||||
|
Extracted::NotInspectable(why) => return Err(why),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
match result {
|
||||||
|
Ok(()) => Extracted::Text(text),
|
||||||
|
Err(why) => Extracted::NotInspectable(why),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ooxml_text_part(name: &str) -> bool {
|
||||||
|
let xml = name.ends_with(".xml");
|
||||||
|
xml && (name == "word/document.xml"
|
||||||
|
|| [
|
||||||
|
"word/header",
|
||||||
|
"word/footer",
|
||||||
|
"word/footnotes",
|
||||||
|
"word/endnotes",
|
||||||
|
"word/comments",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|p| name.starts_with(p))
|
||||||
|
|| name.starts_with("xl/worksheets/sheet")
|
||||||
|
|| name.starts_with("ppt/slides/slide")
|
||||||
|
|| name.starts_with("ppt/notesSlides/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
|
||||||
|
haystack.windows(needle.len()).any(|w| w == needle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The local name of a tag, without its namespace prefix.
|
||||||
|
fn local(name: &[u8]) -> &[u8] {
|
||||||
|
name.rsplit(|b| *b == b':').next().unwrap_or(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_entity(entity: &[u8], out: &mut String) {
|
||||||
|
match entity {
|
||||||
|
b"lt" => out.push('<'),
|
||||||
|
b"gt" => out.push('>'),
|
||||||
|
b"amp" => out.push('&'),
|
||||||
|
b"apos" => out.push('\''),
|
||||||
|
b"quot" => out.push('"'),
|
||||||
|
_ => {
|
||||||
|
let code = match entity {
|
||||||
|
[b'#', b'x' | b'X', hex @ ..] => std::str::from_utf8(hex)
|
||||||
|
.ok()
|
||||||
|
.and_then(|h| u32::from_str_radix(h, 16).ok()),
|
||||||
|
[b'#', dec @ ..] => std::str::from_utf8(dec).ok().and_then(|d| d.parse().ok()),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(c) = code.and_then(char::from_u32) {
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every text node, runs joined as written, a new line after each paragraph
|
||||||
|
/// or row and a tab after each cell, so a number split across runs is whole
|
||||||
|
/// again.
|
||||||
|
fn xml_text(xml: &[u8], out: &mut String) {
|
||||||
|
let mut reader = Reader::from_reader(xml);
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
loop {
|
||||||
|
match reader.read_event_into(&mut buf) {
|
||||||
|
Ok(Event::Text(t)) => {
|
||||||
|
if let Ok(text) = t.xml_content(XmlVersion::Implicit1_0) {
|
||||||
|
out.push_str(&text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Event::CData(t)) => out.push_str(&String::from_utf8_lossy(&t)),
|
||||||
|
Ok(Event::GeneralRef(entity)) => push_entity(&entity, out),
|
||||||
|
Ok(Event::End(e)) => match local(e.name().as_ref()) {
|
||||||
|
b"p" | b"h" | b"tr" | b"row" | b"table-row" | b"br" => out.push('\n'),
|
||||||
|
b"tc" | b"c" | b"table-cell" | b"tab" => out.push('\t'),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Ok(Event::Empty(e)) => match local(e.name().as_ref()) {
|
||||||
|
b"br" | b"line-break" => out.push('\n'),
|
||||||
|
b"tab" | b"s" => out.push(' '),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Ok(Event::Eof) | Err(_) => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
buf.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The text of each `item` element (a shared string in XLSX).
|
||||||
|
fn xml_strings(xml: &[u8], item: &str) -> Vec<String> {
|
||||||
|
let mut reader = Reader::from_reader(xml);
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut items = Vec::new();
|
||||||
|
let mut current: Option<String> = None;
|
||||||
|
loop {
|
||||||
|
match reader.read_event_into(&mut buf) {
|
||||||
|
Ok(Event::Start(e)) if local(e.name().as_ref()) == item.as_bytes() => {
|
||||||
|
current = Some(String::new())
|
||||||
|
}
|
||||||
|
Ok(Event::End(e)) if local(e.name().as_ref()) == item.as_bytes() => {
|
||||||
|
items.extend(current.take());
|
||||||
|
}
|
||||||
|
Ok(Event::Text(t)) => {
|
||||||
|
if let (Some(s), Ok(text)) =
|
||||||
|
(current.as_mut(), t.xml_content(XmlVersion::Implicit1_0))
|
||||||
|
{
|
||||||
|
s.push_str(&text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Event::GeneralRef(entity)) => {
|
||||||
|
if let Some(s) = current.as_mut() {
|
||||||
|
push_entity(&entity, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Event::Eof) | Err(_) => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
buf.clear();
|
||||||
|
}
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A worksheet's cell values: numbers and inline strings. Cells holding a
|
||||||
|
/// shared string are skipped here; the shared strings are read whole.
|
||||||
|
fn xlsx_sheet(xml: &[u8], out: &mut String) {
|
||||||
|
let mut reader = Reader::from_reader(xml);
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut shared_cell = false;
|
||||||
|
let mut in_value = false;
|
||||||
|
loop {
|
||||||
|
match reader.read_event_into(&mut buf) {
|
||||||
|
Ok(Event::Start(e)) => match local(e.name().as_ref()) {
|
||||||
|
b"c" => {
|
||||||
|
shared_cell = e
|
||||||
|
.attributes()
|
||||||
|
.flatten()
|
||||||
|
.any(|a| a.key.as_ref() == b"t" && a.value.as_ref() == b"s");
|
||||||
|
}
|
||||||
|
b"v" | b"t" => in_value = true,
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Ok(Event::End(e)) => match local(e.name().as_ref()) {
|
||||||
|
b"v" | b"t" => in_value = false,
|
||||||
|
b"c" => out.push('\t'),
|
||||||
|
b"row" => out.push('\n'),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
// A shared string's cell holds only its index: the string itself
|
||||||
|
// is added with the shared strings
|
||||||
|
Ok(Event::Text(t)) if in_value && !shared_cell => {
|
||||||
|
if let Ok(text) = t.xml_content(XmlVersion::Implicit1_0) {
|
||||||
|
out.push_str(&text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Event::Eof) | Err(_) => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
buf.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Write;
|
||||||
|
use zip::{ZipWriter, write::SimpleFileOptions};
|
||||||
|
|
||||||
|
fn zip_of(files: &[(&str, &str)]) -> Vec<u8> {
|
||||||
|
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
|
||||||
|
for (name, body) in files {
|
||||||
|
zip.start_file(*name, SimpleFileOptions::default()).unwrap();
|
||||||
|
zip.write_all(body.as_bytes()).unwrap();
|
||||||
|
}
|
||||||
|
zip.finish().unwrap().into_inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_of(extracted: Extracted) -> String {
|
||||||
|
match extracted {
|
||||||
|
Extracted::Text(text) => text,
|
||||||
|
other => panic!("expected text, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_text_and_html() {
|
||||||
|
let limits = Limits::default();
|
||||||
|
assert_eq!(
|
||||||
|
text_of(extract("text/plain", None, b"card 4242", &limits)),
|
||||||
|
"card 4242"
|
||||||
|
);
|
||||||
|
let utf16: Vec<u8> = [0xFF, 0xFE]
|
||||||
|
.into_iter()
|
||||||
|
.chain("héllo".encode_utf16().flat_map(u16::to_le_bytes))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
text_of(extract(
|
||||||
|
"application/octet-stream",
|
||||||
|
Some("a.csv"),
|
||||||
|
&utf16,
|
||||||
|
&limits
|
||||||
|
)),
|
||||||
|
"héllo"
|
||||||
|
);
|
||||||
|
let html = "<html><style>p{}</style><p>Card 4242</p><script>x()</script><td>a</td><td>b</td></html>";
|
||||||
|
let text = text_of(extract("text/html", None, html.as_bytes(), &limits));
|
||||||
|
assert!(
|
||||||
|
text.contains("Card 4242") && !text.contains("x()") && !text.contains("p{}"),
|
||||||
|
"{text:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
extract("image/png", Some("a.png"), b"\x89PNG....", &limits),
|
||||||
|
Extracted::NoText
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn docx_joins_split_runs() {
|
||||||
|
let doc = r#"<w:document xmlns:w="w"><w:body><w:p><w:r><w:t>Card 4242 42</w:t></w:r><w:r><w:t>42 4242 4242</w:t></w:r></w:p><w:p><w:r><w:t>A & B</w:t></w:r></w:p></w:body></w:document>"#;
|
||||||
|
let docx = zip_of(&[
|
||||||
|
("[Content_Types].xml", "<Types/>"),
|
||||||
|
("word/document.xml", doc),
|
||||||
|
]);
|
||||||
|
let text = text_of(extract(
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
Some("a.docx"),
|
||||||
|
&docx,
|
||||||
|
&Limits::default(),
|
||||||
|
));
|
||||||
|
assert!(text.contains("Card 4242 4242 4242 4242\nA & B"), "{text:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn xlsx_numbers_and_shared_strings() {
|
||||||
|
let sheet = r#"<worksheet><sheetData><row><c r="A1" t="s"><v>0</v></c><c r="B1"><v>4242424242424242</v></c></row></sheetData></worksheet>"#;
|
||||||
|
let shared = r#"<sst><si><t>IBAN GB29 NWBK 6016 1331 9268 19</t></si></sst>"#;
|
||||||
|
let xlsx = zip_of(&[
|
||||||
|
("[Content_Types].xml", "<Types/>"),
|
||||||
|
("xl/sharedStrings.xml", shared),
|
||||||
|
("xl/worksheets/sheet1.xml", sheet),
|
||||||
|
]);
|
||||||
|
let text = text_of(extract("", Some("book.xlsx"), &xlsx, &Limits::default()));
|
||||||
|
assert!(
|
||||||
|
text.contains("4242424242424242") && text.contains("GB29 NWBK 6016 1331 9268 19"),
|
||||||
|
"{text:?}"
|
||||||
|
);
|
||||||
|
// The shared string's index isn't read as a value
|
||||||
|
assert!(
|
||||||
|
!text.contains("\t0\t") && !text.starts_with('0'),
|
||||||
|
"{text:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opendocument_and_encrypted_opendocument() {
|
||||||
|
let content = r#"<office:document-content xmlns:text="t"><text:p>SSN 078-05-1120</text:p></office:document-content>"#;
|
||||||
|
let odt = zip_of(&[
|
||||||
|
("mimetype", "application/vnd.oasis.opendocument.text"),
|
||||||
|
("content.xml", content),
|
||||||
|
]);
|
||||||
|
assert!(
|
||||||
|
text_of(extract("", Some("a.odt"), &odt, &Limits::default()))
|
||||||
|
.contains("SSN 078-05-1120")
|
||||||
|
);
|
||||||
|
let manifest = r#"<manifest:manifest><manifest:file-entry><manifest:encryption-data/></manifest:file-entry></manifest:manifest>"#;
|
||||||
|
let locked = zip_of(&[
|
||||||
|
("mimetype", "application/vnd.oasis.opendocument.text"),
|
||||||
|
("META-INF/manifest.xml", manifest),
|
||||||
|
("content.xml", "x"),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
extract("", Some("a.odt"), &locked, &Limits::default()),
|
||||||
|
Extracted::NotInspectable(Why::Encrypted)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn archives() {
|
||||||
|
let limits = Limits::default();
|
||||||
|
let archive = zip_of(&[
|
||||||
|
("notes/a.txt", "card 4242424242424242"),
|
||||||
|
("b.png", "\u{89}PNG"),
|
||||||
|
]);
|
||||||
|
assert!(
|
||||||
|
text_of(extract("application/zip", Some("x.zip"), &archive, &limits))
|
||||||
|
.contains("4242424242424242")
|
||||||
|
);
|
||||||
|
let nested = zip_of(&[(
|
||||||
|
"inner.zip",
|
||||||
|
std::str::from_utf8(&[b'P', b'K', 3, 4]).unwrap(),
|
||||||
|
)]);
|
||||||
|
assert_eq!(
|
||||||
|
extract("application/zip", Some("x.zip"), &nested, &limits),
|
||||||
|
Extracted::NotInspectable(Why::NestedArchive)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Password-protected
|
||||||
|
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
|
||||||
|
zip.start_file(
|
||||||
|
"secret.txt",
|
||||||
|
SimpleFileOptions::default().with_aes_encryption(zip::AesMode::Aes256, "pw"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
zip.write_all(b"4242424242424242").unwrap();
|
||||||
|
let locked = zip.finish().unwrap().into_inner();
|
||||||
|
assert_eq!(
|
||||||
|
extract("application/zip", Some("x.zip"), &locked, &limits),
|
||||||
|
Extracted::NotInspectable(Why::Encrypted)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Past the limits
|
||||||
|
let small = Limits {
|
||||||
|
max_unpacked: 10,
|
||||||
|
max_entries: 1,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
extract("application/zip", Some("x.zip"), &archive, &small),
|
||||||
|
Extracted::NotInspectable(Why::TooLarge)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
extract("application/zip", None, b"PK\x03\x04garbage", &limits),
|
||||||
|
Extracted::NotInspectable(Why::Damaged)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_inspectable_kinds() {
|
||||||
|
let limits = Limits::default();
|
||||||
|
assert_eq!(
|
||||||
|
extract("application/octet-stream", None, b"%PDF-1.7 ...", &limits),
|
||||||
|
Extracted::NotInspectable(Why::Pdf)
|
||||||
|
);
|
||||||
|
let mut ole = OLE_MAGIC.to_vec();
|
||||||
|
ole.extend(std::iter::repeat_n(0, 64));
|
||||||
|
assert_eq!(
|
||||||
|
extract("", Some("old.doc"), &ole, &limits),
|
||||||
|
Extracted::NotInspectable(Why::LegacyOffice)
|
||||||
|
);
|
||||||
|
ole.extend("EncryptedPackage".encode_utf16().flat_map(u16::to_le_bytes));
|
||||||
|
assert_eq!(
|
||||||
|
extract("", Some("new.docx"), &ole, &limits),
|
||||||
|
Extracted::NotInspectable(Why::Encrypted)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Mail held for review (dlp-and-mail-flow-rules spec, §2.6).
|
||||||
|
//!
|
||||||
|
//! A held message is queued as any other, but released [`HOLD_SECONDS`]
|
||||||
|
//! from now, the queue's own future-release mechanism: nothing about the
|
||||||
|
//! queue's stored format changes, so a node on an older version reads it
|
||||||
|
//! and simply never sends it. Beside it, a review record under `R` `h` +
|
||||||
|
//! queue id (u64) says why it's held, for the review queue.
|
||||||
|
//!
|
||||||
|
//! A reviewer releases it (it's rescheduled from the queue's settings and
|
||||||
|
//! delivered) or rejects it (it's removed, and the sender told). Unreviewed
|
||||||
|
//! mail is rejected after [`KEEP_DAYS`].
|
||||||
|
|
||||||
|
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize, de::DeserializeOwned};
|
||||||
|
use store::{
|
||||||
|
Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey,
|
||||||
|
write::{AnyClass, BatchBuilder, ValueClass},
|
||||||
|
};
|
||||||
|
use trc::AddContext;
|
||||||
|
|
||||||
|
const FEATURE: u8 = b'R';
|
||||||
|
const KIND_HELD: u8 = b'h';
|
||||||
|
const KIND_SETTINGS: u8 = b's';
|
||||||
|
|
||||||
|
/// How far off a held message's release is set: a century, so it never
|
||||||
|
/// comes due on its own.
|
||||||
|
pub const HOLD_SECONDS: u64 = 100 * 365 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
/// How long unreviewed mail waits before it's rejected, unless the setting
|
||||||
|
/// says otherwise (settled answer 5).
|
||||||
|
pub const KEEP_DAYS: u64 = 7;
|
||||||
|
|
||||||
|
/// `inbuxa:DlpSettings`: how many days held mail waits for a reviewer.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Settings {
|
||||||
|
pub keep_held_days: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Settings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Settings {
|
||||||
|
keep_held_days: KEEP_DAYS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Settings {
|
||||||
|
/// The property at fault and why, or fine.
|
||||||
|
pub fn check(&self) -> Result<(), (&'static str, &'static str)> {
|
||||||
|
if (1..=90).contains(&self.keep_held_days) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(("keepHeldDays", "must be from 1 to 90 days"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A rule that held the message, with its notice.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
pub struct HeldRule {
|
||||||
|
pub name: String,
|
||||||
|
pub notice: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Held {
|
||||||
|
pub queue_id: u64,
|
||||||
|
pub sender: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub account_id: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tenant_id: Option<u32>,
|
||||||
|
pub recipients: Vec<String>,
|
||||||
|
pub subject: String,
|
||||||
|
pub size: u64,
|
||||||
|
pub rules: Vec<HeldRule>,
|
||||||
|
/// Each detector that counted, and its count.
|
||||||
|
#[serde(default)]
|
||||||
|
pub counts: Vec<(String, usize)>,
|
||||||
|
/// Seconds since the epoch.
|
||||||
|
pub held_at: u64,
|
||||||
|
pub expires_at: u64,
|
||||||
|
/// The days it was given, for what the sender is told.
|
||||||
|
#[serde(default = "default_keep_days")]
|
||||||
|
pub keep_days: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_keep_days() -> u64 {
|
||||||
|
KEEP_DAYS
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Held {
|
||||||
|
pub fn is_expired(&self, now: u64) -> bool {
|
||||||
|
now >= self.expires_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Json<T>(T);
|
||||||
|
|
||||||
|
impl<T: SerdeSerialize> Serialize for Json<T> {
|
||||||
|
fn serialize(&self) -> trc::Result<Vec<u8>> {
|
||||||
|
serde_json::to_vec(&self.0).map_err(|err| {
|
||||||
|
trc::StoreEvent::UnexpectedError
|
||||||
|
.into_err()
|
||||||
|
.details("Failed to serialize held message")
|
||||||
|
.reason(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: DeserializeOwned + Sync + Send> Deserialize for Json<T> {
|
||||||
|
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||||
|
serde_json::from_slice(bytes).map(Json).map_err(|err| {
|
||||||
|
trc::StoreEvent::DataCorruption
|
||||||
|
.into_err()
|
||||||
|
.details("Invalid held message")
|
||||||
|
.reason(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn class(queue_id: u64) -> ValueClass {
|
||||||
|
let mut key = Vec::with_capacity(10);
|
||||||
|
key.push(FEATURE);
|
||||||
|
key.push(KIND_HELD);
|
||||||
|
key.extend_from_slice(&queue_id.to_be_bytes());
|
||||||
|
ValueClass::Any(AnyClass {
|
||||||
|
subspace: SUBSPACE_INBUXA,
|
||||||
|
key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(queue_id: u64) -> ValueKey<ValueClass> {
|
||||||
|
ValueKey::from(class(queue_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings_class() -> ValueClass {
|
||||||
|
ValueClass::Any(AnyClass {
|
||||||
|
subspace: SUBSPACE_INBUXA,
|
||||||
|
key: vec![FEATURE, KIND_SETTINGS],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn settings(data: &Store) -> trc::Result<Settings> {
|
||||||
|
Ok(data
|
||||||
|
.get_value::<Json<Settings>>(ValueKey::from(settings_class()))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.map(|Json(settings)| settings)
|
||||||
|
.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_settings(data: &Store, settings: &Settings) -> trc::Result<()> {
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.set(settings_class(), Json(settings).serialize()?);
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(data: &Store, queue_id: u64) -> trc::Result<Option<Held>> {
|
||||||
|
Ok(data
|
||||||
|
.get_value::<Json<Held>>(key(queue_id))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.map(|Json(held)| held))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn is_held(data: &Store, queue_id: u64) -> trc::Result<bool> {
|
||||||
|
get(data, queue_id).await.map(|held| held.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every held message, oldest first.
|
||||||
|
pub async fn all(data: &Store) -> trc::Result<Vec<Held>> {
|
||||||
|
let mut held = Vec::new();
|
||||||
|
data.iterate(IterateParams::new(key(0), key(u64::MAX)), |_, value| {
|
||||||
|
if let Ok(Json(record)) = Json::<Held>::deserialize(value) {
|
||||||
|
held.push(record);
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
held.sort_by_key(|h| (h.held_at, h.queue_id));
|
||||||
|
Ok(held)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(data: &Store, held: &Held) -> trc::Result<()> {
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.set(class(held.queue_id), Json(held).serialize()?);
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(data: &Store, queue_id: u64) -> trc::Result<()> {
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.clear(class(queue_id));
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wire_format_and_expiry() {
|
||||||
|
let held = Held {
|
||||||
|
queue_id: 42,
|
||||||
|
sender: "[email protected]".into(),
|
||||||
|
account_id: Some(7),
|
||||||
|
tenant_id: None,
|
||||||
|
recipients: vec!["[email protected]".into()],
|
||||||
|
subject: "Numbers".into(),
|
||||||
|
size: 900,
|
||||||
|
rules: vec![HeldRule {
|
||||||
|
name: "Cards".into(),
|
||||||
|
notice: "Held for review".into(),
|
||||||
|
}],
|
||||||
|
counts: vec![("payment-card".into(), 5)],
|
||||||
|
held_at: 1_000,
|
||||||
|
expires_at: 1_000 + KEEP_DAYS * 86_400,
|
||||||
|
keep_days: KEEP_DAYS,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(&held).unwrap();
|
||||||
|
assert_eq!(json["heldAt"], 1_000);
|
||||||
|
assert_eq!(serde_json::from_value::<Held>(json).unwrap(), held);
|
||||||
|
assert!(!held.is_expired(1_000 + KEEP_DAYS * 86_400 - 1));
|
||||||
|
assert!(held.is_expired(1_000 + KEEP_DAYS * 86_400));
|
||||||
|
assert!(HOLD_SECONDS > 90 * 365 * 86_400);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_range() {
|
||||||
|
assert_eq!(Settings::default().keep_held_days, 7);
|
||||||
|
assert!(Settings { keep_held_days: 1 }.check().is_ok());
|
||||||
|
assert!(Settings { keep_held_days: 90 }.check().is_ok());
|
||||||
|
assert!(Settings { keep_held_days: 0 }.check().is_err());
|
||||||
|
assert!(Settings { keep_held_days: 91 }.check().is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Data loss prevention and mail flow rules (dlp-and-mail-flow-rules spec).
|
||||||
|
//!
|
||||||
|
//! Mostly pure functions over text and attachment bytes, unit-tested
|
||||||
|
//! without a server:
|
||||||
|
//!
|
||||||
|
//! - [`detectors`]: find identifiers in text (payment cards, IBANs,
|
||||||
|
//! national ID numbers, keys), each by its published format and check
|
||||||
|
//! (§2.3);
|
||||||
|
//! - [`words`]: an organization's own word lists and patterns;
|
||||||
|
//! - [`extract`]: the text of an attachment, or why it can't be read;
|
||||||
|
//! - [`rules`]: what a rule is, its checks, and where rules are kept;
|
||||||
|
//! - [`engine`]: rules compiled and run against a message;
|
||||||
|
//! - [`cache`]: each node's compiled copy;
|
||||||
|
//! - [`rewrite`]: the actions that change a message.
|
||||||
|
//!
|
||||||
|
//! Nothing here writes what it finds anywhere: callers get counts, and the
|
||||||
|
//! matched text never leaves the evaluation (§2.7).
|
||||||
|
|
||||||
|
pub mod cache;
|
||||||
|
pub mod detectors;
|
||||||
|
pub mod engine;
|
||||||
|
pub mod extract;
|
||||||
|
pub mod held;
|
||||||
|
pub mod rewrite;
|
||||||
|
pub mod rules;
|
||||||
|
pub mod words;
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Transport actions that change a message (§2.4): headers, the subject,
|
||||||
|
//! disclaimers. Each takes the raw message and returns the new one, or
|
||||||
|
//! `None` when there's nothing to change.
|
||||||
|
//!
|
||||||
|
//! Only what the action names changes. A disclaimer edits the message's
|
||||||
|
//! main text and HTML bodies (not attachments, not attached messages):
|
||||||
|
//! each is decoded, changed and written back as UTF-8 quoted-printable,
|
||||||
|
//! with its other headers kept. A disclaimer already there isn't added
|
||||||
|
//! again, so a reply thread carries it once.
|
||||||
|
|
||||||
|
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||||
|
use mail_builder::encoders::quoted_printable::QuotedPrintableEncoder;
|
||||||
|
use mail_parser::{HeaderName, MessageParser, PartType};
|
||||||
|
|
||||||
|
use super::rules::Position;
|
||||||
|
|
||||||
|
/// A header value, as an RFC 2047 encoded word when it isn't plain ASCII.
|
||||||
|
pub fn header_value(value: &str) -> String {
|
||||||
|
if value.is_ascii() {
|
||||||
|
value.to_string()
|
||||||
|
} else {
|
||||||
|
format!("=?utf-8?B?{}?=", STANDARD.encode(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Name: value` added at the top of the message.
|
||||||
|
pub fn add_header(message: &[u8], name: &str, value: &str) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(message.len() + name.len() + value.len() + 4);
|
||||||
|
out.extend_from_slice(name.as_bytes());
|
||||||
|
out.extend_from_slice(b": ");
|
||||||
|
out.extend_from_slice(header_value(value).as_bytes());
|
||||||
|
out.extend_from_slice(b"\r\n");
|
||||||
|
out.extend_from_slice(message);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every top-level header called `name` taken out.
|
||||||
|
pub fn remove_header(message: &[u8], name: &str) -> Option<Vec<u8>> {
|
||||||
|
let parsed = MessageParser::new().parse_headers(message)?;
|
||||||
|
let mut ranges: Vec<(usize, usize)> = parsed
|
||||||
|
.headers()
|
||||||
|
.iter()
|
||||||
|
.filter(|h| h.name.as_str().eq_ignore_ascii_case(name))
|
||||||
|
.map(|h| (h.offset_field as usize, h.offset_end as usize))
|
||||||
|
.collect();
|
||||||
|
if ranges.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
ranges.sort_unstable();
|
||||||
|
let mut out = Vec::with_capacity(message.len());
|
||||||
|
let mut at = 0;
|
||||||
|
for (start, end) in ranges {
|
||||||
|
out.extend_from_slice(&message[at..start]);
|
||||||
|
at = end;
|
||||||
|
}
|
||||||
|
out.extend_from_slice(&message[at..]);
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Subject header replaced by `subject` (added if there was none).
|
||||||
|
pub fn set_subject(message: &[u8], subject: &str) -> Vec<u8> {
|
||||||
|
let line = format!("Subject: {}\r\n", header_value(subject));
|
||||||
|
let parsed = MessageParser::new().parse_headers(message);
|
||||||
|
match parsed
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.headers().iter().find(|h| h.name == HeaderName::Subject))
|
||||||
|
{
|
||||||
|
Some(header) => {
|
||||||
|
let mut out = Vec::with_capacity(message.len() + line.len());
|
||||||
|
out.extend_from_slice(&message[..header.offset_field as usize]);
|
||||||
|
out.extend_from_slice(line.as_bytes());
|
||||||
|
out.extend_from_slice(&message[header.offset_end as usize..]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let mut out = line.into_bytes();
|
||||||
|
out.extend_from_slice(message);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `prefix` put before the subject, unless it's already there.
|
||||||
|
pub fn prefix_subject(message: &[u8], prefix: &str) -> Option<Vec<u8>> {
|
||||||
|
let parsed = MessageParser::new().parse_headers(message)?;
|
||||||
|
let subject = parsed.subject().unwrap_or_default();
|
||||||
|
if subject.trim_start().starts_with(prefix.trim()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(set_subject(
|
||||||
|
message,
|
||||||
|
&format!("{} {}", prefix.trim(), subject.trim_start()),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_html(text: &str) -> String {
|
||||||
|
text.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('\n', "<br>\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_text_disclaimer(body: &str, text: &str, position: Position) -> String {
|
||||||
|
let text = text.trim_end();
|
||||||
|
match position {
|
||||||
|
Position::Top => format!("{text}\r\n\r\n{body}"),
|
||||||
|
Position::Bottom => format!("{}\r\n\r\n{text}\r\n", body.trim_end()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_html_disclaimer(body: &str, html: &str, position: Position) -> String {
|
||||||
|
let lower = body.to_ascii_lowercase();
|
||||||
|
match position {
|
||||||
|
Position::Top => match lower
|
||||||
|
.find("<body")
|
||||||
|
.and_then(|at| lower[at..].find('>').map(|end| at + end + 1))
|
||||||
|
{
|
||||||
|
Some(at) => format!("{}{html}{}", &body[..at], &body[at..]),
|
||||||
|
None => format!("{html}{body}"),
|
||||||
|
},
|
||||||
|
Position::Bottom => match lower.rfind("</body>") {
|
||||||
|
Some(at) => format!("{}{html}{}", &body[..at], &body[at..]),
|
||||||
|
None => format!("{body}{html}"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The disclaimer added to each main text and HTML body. `html` is the HTML
|
||||||
|
/// version, or the text escaped when there's none.
|
||||||
|
pub fn add_disclaimer(
|
||||||
|
message: &[u8],
|
||||||
|
text: &str,
|
||||||
|
html: Option<&str>,
|
||||||
|
position: Position,
|
||||||
|
) -> Option<Vec<u8>> {
|
||||||
|
let parsed = MessageParser::new().parse(message)?;
|
||||||
|
let html = html
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| format!("<p>{}</p>", escape_html(text.trim())));
|
||||||
|
let marker = text.trim();
|
||||||
|
|
||||||
|
let mut body_parts: Vec<u32> = parsed
|
||||||
|
.text_body
|
||||||
|
.iter()
|
||||||
|
.chain(parsed.html_body.iter())
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
body_parts.sort_unstable();
|
||||||
|
body_parts.dedup();
|
||||||
|
|
||||||
|
// (start, end, replacement) for each part, applied from the last
|
||||||
|
let mut edits: Vec<(usize, usize, Vec<u8>)> = Vec::new();
|
||||||
|
for id in body_parts {
|
||||||
|
let Some(part) = parsed.parts.get(id as usize) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let (new_body, content_type) = match &part.body {
|
||||||
|
PartType::Text(body) => {
|
||||||
|
if body.contains(marker) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
(with_text_disclaimer(body, text, position), "text/plain")
|
||||||
|
}
|
||||||
|
PartType::Html(body) => {
|
||||||
|
if body.contains(marker) || body.contains(html.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
(with_html_disclaimer(body, &html, position), "text/html")
|
||||||
|
}
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
// The part's own headers, less the two this changes
|
||||||
|
let mut headers = Vec::new();
|
||||||
|
for header in part.headers() {
|
||||||
|
if matches!(
|
||||||
|
header.name,
|
||||||
|
HeaderName::ContentType | HeaderName::ContentTransferEncoding
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
headers.extend_from_slice(
|
||||||
|
&message[header.offset_field as usize..header.offset_end as usize],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
headers.extend_from_slice(
|
||||||
|
format!("Content-Type: {content_type}; charset=utf-8\r\n").as_bytes(),
|
||||||
|
);
|
||||||
|
headers.extend_from_slice(b"Content-Transfer-Encoding: quoted-printable\r\n\r\n");
|
||||||
|
let encoded = QuotedPrintableEncoder::new()
|
||||||
|
.preserve_line_breaks()
|
||||||
|
.encode(new_body.as_bytes())
|
||||||
|
.ok()?;
|
||||||
|
headers.extend_from_slice(&encoded);
|
||||||
|
// A single-part message's headers are the message's: its first
|
||||||
|
// header is where the part starts
|
||||||
|
let start = part.headers().first().map_or(part.offset_header, |h| {
|
||||||
|
h.offset_field.min(part.offset_header)
|
||||||
|
}) as usize;
|
||||||
|
edits.push((start, part.offset_end as usize, headers));
|
||||||
|
}
|
||||||
|
if edits.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
edits.sort_by_key(|(start, _, _)| std::cmp::Reverse(*start));
|
||||||
|
let mut out = message.to_vec();
|
||||||
|
for (start, end, replacement) in edits {
|
||||||
|
out.splice(start..end.min(out.len()), replacement);
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn parse(message: &[u8]) -> mail_parser::Message<'_> {
|
||||||
|
MessageParser::new().parse(message).expect("parses")
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLAIN: &[u8] = b"From: [email protected]\r\nTo: [email protected]\r\nSubject: Hello\r\nContent-Type: text/plain; charset=iso-8859-1\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nCaf=E9 at noon.\r\n";
|
||||||
|
|
||||||
|
const ALTERNATIVE: &[u8] = b"From: [email protected]\r\nSubject: Plans\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"outer\"\r\n\r\n--outer\r\nContent-Type: multipart/alternative; boundary=\"inner\"\r\n\r\n--inner\r\nContent-Type: text/plain\r\n\r\nSee you.\r\n--inner\r\nContent-Type: text/html\r\nContent-Transfer-Encoding: base64\r\n\r\nPGh0bWw+PGJvZHk+PHA+U2VlIHlvdS48L3A+PC9ib2R5PjwvaHRtbD4=\r\n--inner--\r\n--outer\r\nContent-Type: text/plain; name=\"notes.txt\"\r\nContent-Disposition: attachment; filename=\"notes.txt\"\r\n\r\nAttachment text.\r\n--outer--\r\n";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn headers() {
|
||||||
|
let added = add_header(PLAIN, "X-Mail-Rule", "External");
|
||||||
|
assert_eq!(
|
||||||
|
parse(&added).header_raw("X-Mail-Rule").map(str::trim),
|
||||||
|
Some("External")
|
||||||
|
);
|
||||||
|
let removed = remove_header(&added, "x-mail-rule").unwrap();
|
||||||
|
assert_eq!(removed, PLAIN);
|
||||||
|
assert!(remove_header(PLAIN, "X-Absent").is_none());
|
||||||
|
let utf8 = add_header(PLAIN, "X-Note", "Überprüft");
|
||||||
|
// An RFC 2047 word: mail readers decode it, the wire stays ASCII
|
||||||
|
assert_eq!(
|
||||||
|
parse(&utf8).header_raw("X-Note").map(str::trim),
|
||||||
|
Some("=?utf-8?B?w5xiZXJwcsO8ZnQ=?=")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subjects() {
|
||||||
|
let prefixed = prefix_subject(PLAIN, "[External]").unwrap();
|
||||||
|
assert_eq!(parse(&prefixed).subject(), Some("[External] Hello"));
|
||||||
|
assert!(prefix_subject(&prefixed, "[External]").is_none());
|
||||||
|
let accented = set_subject(PLAIN, "Réunion à midi");
|
||||||
|
assert_eq!(parse(&accented).subject(), Some("Réunion à midi"));
|
||||||
|
assert!(accented.is_ascii(), "encoded as an RFC 2047 word");
|
||||||
|
let none = set_subject(b"From: [email protected]\r\n\r\nBody\r\n", "New");
|
||||||
|
assert_eq!(parse(&none).subject(), Some("New"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disclaimer_on_a_single_part() {
|
||||||
|
let out = add_disclaimer(PLAIN, "Sent by Example Co.", None, Position::Bottom).unwrap();
|
||||||
|
let parsed = parse(&out);
|
||||||
|
let body = parsed.body_text(0).unwrap();
|
||||||
|
assert!(body.starts_with("Café at noon."), "{body:?}");
|
||||||
|
assert!(body.trim_end().ends_with("Sent by Example Co."), "{body:?}");
|
||||||
|
assert_eq!(parsed.subject(), Some("Hello"));
|
||||||
|
assert_eq!(
|
||||||
|
parsed.header_raw("To").map(str::trim),
|
||||||
|
Some("[email protected]")
|
||||||
|
);
|
||||||
|
// Once only
|
||||||
|
assert!(add_disclaimer(&out, "Sent by Example Co.", None, Position::Bottom).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disclaimer_on_alternatives_leaves_attachments() {
|
||||||
|
let out = add_disclaimer(
|
||||||
|
ALTERNATIVE,
|
||||||
|
"Confidential.",
|
||||||
|
Some("<p><i>Confidential.</i></p>"),
|
||||||
|
Position::Top,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let parsed = parse(&out);
|
||||||
|
assert!(
|
||||||
|
parsed
|
||||||
|
.body_text(0)
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("Confidential.\r\n\r\nSee you."),
|
||||||
|
"{:?}",
|
||||||
|
parsed.body_text(0)
|
||||||
|
);
|
||||||
|
let html = parsed.body_html(0).unwrap();
|
||||||
|
assert!(
|
||||||
|
html.contains("<body><p><i>Confidential.</i></p><p>See you.</p>"),
|
||||||
|
"{html}"
|
||||||
|
);
|
||||||
|
assert_eq!(parsed.attachment_count(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.attachment(0).unwrap().text_contents(),
|
||||||
|
Some("Attachment text.")
|
||||||
|
);
|
||||||
|
assert!(!String::from_utf8_lossy(&out).contains("Confidential.\r\n\r\nAttachment"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,841 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Mail flow rules and DLP rules (dlp-and-mail-flow-rules spec, §2.2–§2.4):
|
||||||
|
//! what a rule is, what makes one valid, and where it's kept.
|
||||||
|
//!
|
||||||
|
//! Kept in the fork's subspace (`store::SUBSPACE_INBUXA`), never in the
|
||||||
|
//! registry, so an upstream schema import never touches them. Every key
|
||||||
|
//! starts with `R`, then one byte for the kind:
|
||||||
|
//!
|
||||||
|
//! - `r` + rule id (u32): the rule, as JSON.
|
||||||
|
//!
|
||||||
|
//! Numbers are big-endian. There are few rules, so they're read whole.
|
||||||
|
|
||||||
|
use super::{detectors, words};
|
||||||
|
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize, de::DeserializeOwned};
|
||||||
|
use store::{
|
||||||
|
Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey,
|
||||||
|
write::{AnyClass, BatchBuilder, ValueClass, assert::AssertValue},
|
||||||
|
};
|
||||||
|
use trc::AddContext;
|
||||||
|
|
||||||
|
const FEATURE: u8 = b'R';
|
||||||
|
const KIND_RULE: u8 = b'r';
|
||||||
|
const CREATE_ATTEMPTS: usize = 5;
|
||||||
|
|
||||||
|
/// Longest text a rule may carry (a notice, a disclaimer), in bytes.
|
||||||
|
const MAX_TEXT: usize = 16 * 1024;
|
||||||
|
/// Most entries in one list (words, addresses, domains).
|
||||||
|
const MAX_LIST: usize = 5_000;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum Kind {
|
||||||
|
Dlp,
|
||||||
|
Transport,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum Direction {
|
||||||
|
/// Mail an authenticated sender submits, over SMTP or JMAP.
|
||||||
|
Outgoing,
|
||||||
|
/// Everything else the server accepts.
|
||||||
|
Incoming,
|
||||||
|
Any,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum Position {
|
||||||
|
Top,
|
||||||
|
Bottom,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn one() -> u32 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group and tenant ids in the JMAP form clients use (`"b"`, `"c"`…), held
|
||||||
|
/// as numbers for matching. Plain numbers are read too.
|
||||||
|
pub(crate) mod jmap_ids {
|
||||||
|
use serde::{Deserialize, Deserializer, Serializer, de::Error, ser::SerializeSeq};
|
||||||
|
use std::str::FromStr;
|
||||||
|
use types::id::Id;
|
||||||
|
|
||||||
|
pub fn serialize<S: Serializer>(ids: &[u32], serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
let mut seq = serializer.serialize_seq(Some(ids.len()))?;
|
||||||
|
for id in ids {
|
||||||
|
seq.serialize_element(&Id::from(*id).to_string())?;
|
||||||
|
}
|
||||||
|
seq.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum Either {
|
||||||
|
Text(String),
|
||||||
|
Number(u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u32>, D::Error> {
|
||||||
|
Vec::<Either>::deserialize(deserializer)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|id| match id {
|
||||||
|
Either::Number(n) => Ok(n),
|
||||||
|
Either::Text(text) => Id::from_str(&text)
|
||||||
|
.map(|id| id.document_id())
|
||||||
|
.map_err(|_| D::Error::custom(format!("\"{text}\" isn't an id"))),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One id in the same form.
|
||||||
|
pub(crate) mod jmap_id {
|
||||||
|
use serde::{Deserialize, Deserializer, Serializer, de::Error};
|
||||||
|
use std::str::FromStr;
|
||||||
|
use types::id::Id;
|
||||||
|
|
||||||
|
pub fn serialize<S: Serializer>(id: &u32, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
serializer.serialize_str(&Id::from(*id).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum Either {
|
||||||
|
Text(String),
|
||||||
|
Number(u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<u32, D::Error> {
|
||||||
|
match Either::deserialize(deserializer)? {
|
||||||
|
Either::Number(n) => Ok(n),
|
||||||
|
Either::Text(text) => Id::from_str(&text)
|
||||||
|
.map(|id| id.document_id())
|
||||||
|
.map_err(|_| D::Error::custom(format!("\"{text}\" isn't an id"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A detector and the least it must find.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DetectorMin {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default = "one")]
|
||||||
|
pub at_least: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(
|
||||||
|
tag = "type",
|
||||||
|
rename_all = "camelCase",
|
||||||
|
rename_all_fields = "camelCase"
|
||||||
|
)]
|
||||||
|
pub enum Condition {
|
||||||
|
SenderAddress {
|
||||||
|
addresses: Vec<String>,
|
||||||
|
},
|
||||||
|
SenderDomain {
|
||||||
|
domains: Vec<String>,
|
||||||
|
},
|
||||||
|
SenderGroup {
|
||||||
|
#[serde(with = "jmap_ids")]
|
||||||
|
groups: Vec<u32>,
|
||||||
|
},
|
||||||
|
SenderTenant {
|
||||||
|
#[serde(with = "jmap_ids")]
|
||||||
|
tenants: Vec<u32>,
|
||||||
|
},
|
||||||
|
/// Any recipient is one of these.
|
||||||
|
RecipientAddress {
|
||||||
|
addresses: Vec<String>,
|
||||||
|
},
|
||||||
|
RecipientDomain {
|
||||||
|
domains: Vec<String>,
|
||||||
|
},
|
||||||
|
RecipientGroup {
|
||||||
|
#[serde(with = "jmap_ids")]
|
||||||
|
groups: Vec<u32>,
|
||||||
|
},
|
||||||
|
/// Any recipient isn't at a domain this server hosts.
|
||||||
|
RecipientOutside,
|
||||||
|
/// Words or phrases in the subject, body or readable attachments.
|
||||||
|
Words {
|
||||||
|
words: Vec<String>,
|
||||||
|
#[serde(default = "one")]
|
||||||
|
at_least: u32,
|
||||||
|
},
|
||||||
|
/// The organization's regular expression, in the same places.
|
||||||
|
Pattern {
|
||||||
|
pattern: String,
|
||||||
|
#[serde(default = "one")]
|
||||||
|
at_least: u32,
|
||||||
|
},
|
||||||
|
/// A header exists, or its value contains or matches.
|
||||||
|
Header {
|
||||||
|
name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
contains: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
matches: Option<String>,
|
||||||
|
},
|
||||||
|
/// An attachment's declared or detected type starts with one of these.
|
||||||
|
AttachmentType {
|
||||||
|
types: Vec<String>,
|
||||||
|
},
|
||||||
|
AttachmentExtension {
|
||||||
|
extensions: Vec<String>,
|
||||||
|
},
|
||||||
|
AttachmentName {
|
||||||
|
pattern: String,
|
||||||
|
},
|
||||||
|
AttachmentSizeOver {
|
||||||
|
bytes: u64,
|
||||||
|
},
|
||||||
|
AttachmentCountOver {
|
||||||
|
count: u32,
|
||||||
|
},
|
||||||
|
/// An attachment is encrypted, a PDF, a legacy Office file, an archive
|
||||||
|
/// inside an archive, or past the inspection limit.
|
||||||
|
CantBeInspected,
|
||||||
|
MessageSizeOver {
|
||||||
|
bytes: u64,
|
||||||
|
},
|
||||||
|
/// Any of these detectors finds at least its minimum (DLP rules only).
|
||||||
|
Detected {
|
||||||
|
detectors: Vec<DetectorMin>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(
|
||||||
|
tag = "type",
|
||||||
|
rename_all = "camelCase",
|
||||||
|
rename_all_fields = "camelCase"
|
||||||
|
)]
|
||||||
|
pub enum Action {
|
||||||
|
// Transport actions
|
||||||
|
AddDisclaimer {
|
||||||
|
text: String,
|
||||||
|
#[serde(default)]
|
||||||
|
html: Option<String>,
|
||||||
|
position: Position,
|
||||||
|
},
|
||||||
|
AddHeader {
|
||||||
|
name: String,
|
||||||
|
value: String,
|
||||||
|
},
|
||||||
|
RemoveHeader {
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
PrefixSubject {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
AddRecipient {
|
||||||
|
address: String,
|
||||||
|
},
|
||||||
|
Redirect {
|
||||||
|
addresses: Vec<String>,
|
||||||
|
},
|
||||||
|
Refuse {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
Route {
|
||||||
|
queue: String,
|
||||||
|
},
|
||||||
|
/// Journaling spec, JR-10: a copy into this journal, whatever its scope.
|
||||||
|
Journal {
|
||||||
|
#[serde(with = "jmap_id")]
|
||||||
|
journal: u32,
|
||||||
|
},
|
||||||
|
// DLP actions
|
||||||
|
Block {
|
||||||
|
notice: String,
|
||||||
|
},
|
||||||
|
Warn {
|
||||||
|
notice: String,
|
||||||
|
},
|
||||||
|
Hold {
|
||||||
|
notice: String,
|
||||||
|
#[serde(default)]
|
||||||
|
notify_sender: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Action {
|
||||||
|
pub fn is_dlp(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Action::Block { .. } | Action::Warn { .. } | Action::Hold { .. }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Rule {
|
||||||
|
#[serde(default)]
|
||||||
|
pub id: u32,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: String,
|
||||||
|
pub kind: Kind,
|
||||||
|
#[serde(default = "enabled")]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub priority: i32,
|
||||||
|
pub direction: Direction,
|
||||||
|
#[serde(default)]
|
||||||
|
pub conditions: Vec<Condition>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub exceptions: Vec<Condition>,
|
||||||
|
pub actions: Vec<Action>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub stop_processing: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_by: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enabled() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a rule can't be saved: the property at fault, and a sentence.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Invalid {
|
||||||
|
pub property: &'static str,
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid(property: &'static str, reason: impl Into<String>) -> Invalid {
|
||||||
|
Invalid {
|
||||||
|
property,
|
||||||
|
reason: reason.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rule {
|
||||||
|
/// Everything that can be checked without the rest of the server: the
|
||||||
|
/// shape (§2.2, §2.4), the detectors, word lists and patterns.
|
||||||
|
pub fn validate(&self) -> Result<(), Invalid> {
|
||||||
|
if self.name.trim().is_empty() {
|
||||||
|
return Err(invalid("name", "A rule needs a name."));
|
||||||
|
}
|
||||||
|
if self.name.len() > 200 || self.description.len() > MAX_TEXT {
|
||||||
|
return Err(invalid("name", "The name or description is too long."));
|
||||||
|
}
|
||||||
|
if self.actions.is_empty() {
|
||||||
|
return Err(invalid("actions", "A rule needs something to do."));
|
||||||
|
}
|
||||||
|
let dlp_actions = self.actions.iter().filter(|a| a.is_dlp()).count();
|
||||||
|
match self.kind {
|
||||||
|
Kind::Dlp => {
|
||||||
|
if self.direction != Direction::Outgoing {
|
||||||
|
return Err(invalid("direction", "DLP rules check outgoing mail only."));
|
||||||
|
}
|
||||||
|
// One of block, warn or hold; journaling may go with it
|
||||||
|
if dlp_actions != 1
|
||||||
|
|| self
|
||||||
|
.actions
|
||||||
|
.iter()
|
||||||
|
.any(|a| !a.is_dlp() && !matches!(a, Action::Journal { .. }))
|
||||||
|
{
|
||||||
|
return Err(invalid(
|
||||||
|
"actions",
|
||||||
|
"A DLP rule has exactly one action: block, warn or hold, and may also journal the message.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Kind::Transport => {
|
||||||
|
if dlp_actions > 0 {
|
||||||
|
return Err(invalid(
|
||||||
|
"actions",
|
||||||
|
"Block, warn and hold belong to DLP rules.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.conditions
|
||||||
|
.iter()
|
||||||
|
.chain(&self.exceptions)
|
||||||
|
.any(|c| matches!(c, Condition::Detected { .. }))
|
||||||
|
{
|
||||||
|
return Err(invalid("conditions", "Detectors belong to DLP rules."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (property, list) in [
|
||||||
|
("conditions", &self.conditions),
|
||||||
|
("exceptions", &self.exceptions),
|
||||||
|
] {
|
||||||
|
for condition in list {
|
||||||
|
validate_condition(condition).map_err(|reason| invalid(property, reason))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for action in &self.actions {
|
||||||
|
validate_action(action).map_err(|reason| invalid("actions", reason))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nonempty_list<T>(list: &[T], what: &str) -> Result<(), String> {
|
||||||
|
if list.is_empty() {
|
||||||
|
Err(format!("The {what} list is empty."))
|
||||||
|
} else if list.len() > MAX_LIST {
|
||||||
|
Err(format!(
|
||||||
|
"The {what} list is longer than {MAX_LIST} entries."
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header_name(name: &str) -> Result<(), String> {
|
||||||
|
if !name.is_empty()
|
||||||
|
&& name.len() <= 100
|
||||||
|
&& name.bytes().all(|b| b.is_ascii_graphic() && b != b':')
|
||||||
|
{
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("\"{name}\" isn't a header name."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text(value: &str, what: &str) -> Result<(), String> {
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
Err(format!("The {what} is empty."))
|
||||||
|
} else if value.len() > MAX_TEXT {
|
||||||
|
Err(format!("The {what} is longer than {MAX_TEXT} bytes."))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_condition(condition: &Condition) -> Result<(), String> {
|
||||||
|
match condition {
|
||||||
|
Condition::SenderAddress { addresses } | Condition::RecipientAddress { addresses } => {
|
||||||
|
nonempty_list(addresses, "address")
|
||||||
|
}
|
||||||
|
Condition::SenderDomain { domains } | Condition::RecipientDomain { domains } => {
|
||||||
|
nonempty_list(domains, "domain")
|
||||||
|
}
|
||||||
|
Condition::SenderGroup { groups } | Condition::RecipientGroup { groups } => {
|
||||||
|
nonempty_list(groups, "group")
|
||||||
|
}
|
||||||
|
Condition::SenderTenant { tenants } => nonempty_list(tenants, "tenant"),
|
||||||
|
Condition::Words { words, at_least } => {
|
||||||
|
nonempty_list(words, "word")?;
|
||||||
|
if *at_least == 0 {
|
||||||
|
return Err("The least number of words must be 1 or more.".into());
|
||||||
|
}
|
||||||
|
words::WordList::new(words).map(|_| ())
|
||||||
|
}
|
||||||
|
Condition::Pattern { pattern, at_least } => {
|
||||||
|
if *at_least == 0 {
|
||||||
|
return Err("The least number of matches must be 1 or more.".into());
|
||||||
|
}
|
||||||
|
words::Pattern::new(pattern).map(|_| ())
|
||||||
|
}
|
||||||
|
Condition::Header {
|
||||||
|
name,
|
||||||
|
contains,
|
||||||
|
matches,
|
||||||
|
} => {
|
||||||
|
header_name(name)?;
|
||||||
|
if let Some(pattern) = matches {
|
||||||
|
words::Pattern::new(pattern)?;
|
||||||
|
}
|
||||||
|
if contains.is_some() && matches.is_some() {
|
||||||
|
return Err("A header condition is either contains or matches.".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Condition::AttachmentType { types } => nonempty_list(types, "type"),
|
||||||
|
Condition::AttachmentExtension { extensions } => nonempty_list(extensions, "extension"),
|
||||||
|
Condition::AttachmentName { pattern } => words::Pattern::new(pattern).map(|_| ()),
|
||||||
|
Condition::Detected { detectors } => {
|
||||||
|
nonempty_list(detectors, "detector")?;
|
||||||
|
for d in detectors {
|
||||||
|
if detectors::by_id(&d.id).is_none() {
|
||||||
|
return Err(format!("There is no detector \"{}\".", d.id));
|
||||||
|
}
|
||||||
|
if d.at_least == 0 {
|
||||||
|
return Err("A detector's least count must be 1 or more.".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Condition::RecipientOutside
|
||||||
|
| Condition::AttachmentSizeOver { .. }
|
||||||
|
| Condition::AttachmentCountOver { .. }
|
||||||
|
| Condition::CantBeInspected
|
||||||
|
| Condition::MessageSizeOver { .. } => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_action(action: &Action) -> Result<(), String> {
|
||||||
|
match action {
|
||||||
|
Action::AddDisclaimer { text: t, html, .. } => {
|
||||||
|
text(t, "disclaimer")?;
|
||||||
|
html.as_deref()
|
||||||
|
.map_or(Ok(()), |h| text(h, "disclaimer's HTML"))
|
||||||
|
}
|
||||||
|
Action::AddHeader { name, value } => {
|
||||||
|
header_name(name)?;
|
||||||
|
if value.len() > 998 || value.contains(['\r', '\n']) {
|
||||||
|
Err("A header value is one line of at most 998 characters.".into())
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::RemoveHeader { name } => header_name(name),
|
||||||
|
Action::PrefixSubject { text: t } => text(t, "subject prefix"),
|
||||||
|
Action::AddRecipient { address } => {
|
||||||
|
if address.contains('@') {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("\"{address}\" isn't an address."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::Redirect { addresses } => {
|
||||||
|
nonempty_list(addresses, "address")?;
|
||||||
|
match addresses.iter().find(|a| !a.contains('@')) {
|
||||||
|
Some(a) => Err(format!("\"{a}\" isn't an address.")),
|
||||||
|
None => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::Refuse { text: t } => text(t, "refusal text"),
|
||||||
|
Action::Route { queue } => text(queue, "queue"),
|
||||||
|
Action::Journal { .. } => Ok(()),
|
||||||
|
Action::Block { notice } | Action::Warn { notice } | Action::Hold { notice, .. } => {
|
||||||
|
text(notice, "notice")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Storage --------------------------------------------------------------
|
||||||
|
|
||||||
|
struct Json<T>(T);
|
||||||
|
|
||||||
|
impl<T: SerdeSerialize> Serialize for Json<T> {
|
||||||
|
fn serialize(&self) -> trc::Result<Vec<u8>> {
|
||||||
|
serde_json::to_vec(&self.0).map_err(|err| {
|
||||||
|
trc::StoreEvent::UnexpectedError
|
||||||
|
.into_err()
|
||||||
|
.details("Failed to serialize mail rule")
|
||||||
|
.reason(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: DeserializeOwned + Sync + Send> Deserialize for Json<T> {
|
||||||
|
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||||
|
serde_json::from_slice(bytes).map(Json).map_err(|err| {
|
||||||
|
trc::StoreEvent::DataCorruption
|
||||||
|
.into_err()
|
||||||
|
.details("Invalid mail rule")
|
||||||
|
.reason(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn class(id: u32) -> ValueClass {
|
||||||
|
let mut key = Vec::with_capacity(6);
|
||||||
|
key.push(FEATURE);
|
||||||
|
key.push(KIND_RULE);
|
||||||
|
key.extend_from_slice(&id.to_be_bytes());
|
||||||
|
ValueClass::Any(AnyClass {
|
||||||
|
subspace: SUBSPACE_INBUXA,
|
||||||
|
key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(id: u32) -> ValueKey<ValueClass> {
|
||||||
|
ValueKey::from(class(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(data: &Store, id: u32) -> trc::Result<Option<Rule>> {
|
||||||
|
Ok(data
|
||||||
|
.get_value::<Json<Rule>>(key(id))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.map(|Json(rule)| rule))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every rule, in the order they run: by priority, then oldest first.
|
||||||
|
pub async fn all(data: &Store) -> trc::Result<Vec<Rule>> {
|
||||||
|
let mut rules = Vec::new();
|
||||||
|
data.iterate(IterateParams::new(key(0), key(u32::MAX)), |_, value| {
|
||||||
|
if let Ok(Json(rule)) = Json::<Rule>::deserialize(value) {
|
||||||
|
rules.push(rule);
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
rules.sort_by_key(|rule| (rule.priority, rule.id));
|
||||||
|
Ok(rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a new rule under the next free id, which it returns. Two nodes
|
||||||
|
/// creating rules at once can't take the same id: the key must be absent.
|
||||||
|
pub async fn create(data: &Store, rule: &Rule) -> trc::Result<u32> {
|
||||||
|
let mut attempt = 0;
|
||||||
|
loop {
|
||||||
|
attempt += 1;
|
||||||
|
let id = all(data).await?.iter().map(|r| r.id).max().unwrap_or(0) + 1;
|
||||||
|
let stored = Rule { id, ..rule.clone() };
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.assert_value(class(id), AssertValue::None);
|
||||||
|
batch.set(class(id), Json(&stored).serialize()?);
|
||||||
|
match data.write(batch.build_all()).await {
|
||||||
|
Ok(_) => {
|
||||||
|
super::cache::invalidate();
|
||||||
|
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!())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces a stored rule (same id).
|
||||||
|
pub async fn update(data: &Store, rule: &Rule) -> trc::Result<()> {
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.set(class(rule.id), Json(rule).serialize()?);
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
super::cache::invalidate();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(data: &Store, id: u32) -> trc::Result<()> {
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.clear(class(id));
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
super::cache::invalidate();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn rule(kind: Kind, actions: Vec<Action>) -> Rule {
|
||||||
|
Rule {
|
||||||
|
id: 0,
|
||||||
|
name: "Cards outside".into(),
|
||||||
|
description: String::new(),
|
||||||
|
kind,
|
||||||
|
enabled: true,
|
||||||
|
priority: 0,
|
||||||
|
direction: Direction::Outgoing,
|
||||||
|
conditions: vec![Condition::RecipientOutside],
|
||||||
|
exceptions: vec![],
|
||||||
|
actions,
|
||||||
|
stop_processing: false,
|
||||||
|
created_by: String::new(),
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn journal_action_goes_with_either_kind() {
|
||||||
|
let hold = Action::Hold {
|
||||||
|
notice: "Held.".into(),
|
||||||
|
notify_sender: false,
|
||||||
|
};
|
||||||
|
let journal = Action::Journal { journal: 3 };
|
||||||
|
assert!(
|
||||||
|
rule(Kind::Dlp, vec![hold.clone(), journal.clone()])
|
||||||
|
.validate()
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
assert!(rule(Kind::Dlp, vec![journal.clone()]).validate().is_err());
|
||||||
|
assert!(
|
||||||
|
rule(
|
||||||
|
Kind::Dlp,
|
||||||
|
vec![hold, Action::PrefixSubject { text: "x".into() }]
|
||||||
|
)
|
||||||
|
.validate()
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
rule(Kind::Transport, vec![journal.clone()])
|
||||||
|
.validate()
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
let json = serde_json::to_value(&journal).unwrap();
|
||||||
|
assert_eq!(json, serde_json::json!({"type": "journal", "journal": "d"}));
|
||||||
|
let back: Action = serde_json::from_value(json).unwrap();
|
||||||
|
assert_eq!(back, journal);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wire_format() {
|
||||||
|
let json = r#"{"name":"Cards","kind":"dlp","direction":"outgoing",
|
||||||
|
"conditions":[{"type":"recipientOutside"},{"type":"detected","detectors":[{"id":"payment-card","atLeast":5}]}],
|
||||||
|
"actions":[{"type":"hold","notice":"Held for review","notifySender":true}]}"#;
|
||||||
|
let parsed: Rule = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(parsed.enabled);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.conditions[1],
|
||||||
|
Condition::Detected {
|
||||||
|
detectors: vec![DetectorMin {
|
||||||
|
id: "payment-card".into(),
|
||||||
|
at_least: 5
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.actions[0],
|
||||||
|
Action::Hold {
|
||||||
|
notice: "Held for review".into(),
|
||||||
|
notify_sender: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(parsed.validate().is_ok());
|
||||||
|
let back = serde_json::to_value(&parsed).unwrap();
|
||||||
|
assert_eq!(back["actions"][0]["notifySender"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_and_tenant_ids_are_jmap_ids() {
|
||||||
|
let condition: Condition =
|
||||||
|
serde_json::from_str(r#"{"type":"senderGroup","groups":["b", 7]}"#).unwrap();
|
||||||
|
assert_eq!(condition, Condition::SenderGroup { groups: vec![1, 7] });
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&condition).unwrap()["groups"],
|
||||||
|
serde_json::json!(["b", "h"])
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
serde_json::from_str::<Condition>(r#"{"type":"senderTenant","tenants":["!!"]}"#)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dlp_rules_have_one_dlp_action_on_outgoing_mail() {
|
||||||
|
let block = Action::Block {
|
||||||
|
notice: "No.".into(),
|
||||||
|
};
|
||||||
|
assert!(rule(Kind::Dlp, vec![block.clone()]).validate().is_ok());
|
||||||
|
let two = rule(
|
||||||
|
Kind::Dlp,
|
||||||
|
vec![
|
||||||
|
block.clone(),
|
||||||
|
Action::Warn {
|
||||||
|
notice: "Hm.".into(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(two.validate().unwrap_err().property, "actions");
|
||||||
|
let mixed = rule(
|
||||||
|
Kind::Dlp,
|
||||||
|
vec![block.clone(), Action::PrefixSubject { text: "[x]".into() }],
|
||||||
|
);
|
||||||
|
assert_eq!(mixed.validate().unwrap_err().property, "actions");
|
||||||
|
let mut inbound = rule(Kind::Dlp, vec![block.clone()]);
|
||||||
|
inbound.direction = Direction::Incoming;
|
||||||
|
assert_eq!(inbound.validate().unwrap_err().property, "direction");
|
||||||
|
assert_eq!(
|
||||||
|
rule(Kind::Transport, vec![block])
|
||||||
|
.validate()
|
||||||
|
.unwrap_err()
|
||||||
|
.property,
|
||||||
|
"actions"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conditions_and_actions_are_checked() {
|
||||||
|
let disclaimer = Action::AddDisclaimer {
|
||||||
|
text: "Sent from Example Co.".into(),
|
||||||
|
html: None,
|
||||||
|
position: Position::Bottom,
|
||||||
|
};
|
||||||
|
let mut r = rule(Kind::Transport, vec![disclaimer]);
|
||||||
|
assert!(r.validate().is_ok());
|
||||||
|
r.conditions.push(Condition::Detected {
|
||||||
|
detectors: vec![DetectorMin {
|
||||||
|
id: "iban".into(),
|
||||||
|
at_least: 1,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assert_eq!(r.validate().unwrap_err().property, "conditions");
|
||||||
|
|
||||||
|
let mut r = rule(
|
||||||
|
Kind::Dlp,
|
||||||
|
vec![Action::Block {
|
||||||
|
notice: "No.".into(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
r.conditions = vec![Condition::Detected {
|
||||||
|
detectors: vec![DetectorMin {
|
||||||
|
id: "nope".into(),
|
||||||
|
at_least: 1,
|
||||||
|
}],
|
||||||
|
}];
|
||||||
|
assert!(r.validate().unwrap_err().reason.contains("nope"));
|
||||||
|
r.conditions = vec![Condition::Pattern {
|
||||||
|
pattern: "(".into(),
|
||||||
|
at_least: 1,
|
||||||
|
}];
|
||||||
|
assert!(r.validate().is_err());
|
||||||
|
r.conditions = vec![Condition::Words {
|
||||||
|
words: vec![],
|
||||||
|
at_least: 1,
|
||||||
|
}];
|
||||||
|
assert!(r.validate().is_err());
|
||||||
|
r.exceptions = vec![Condition::Header {
|
||||||
|
name: "X-Bad: yes".into(),
|
||||||
|
contains: None,
|
||||||
|
matches: None,
|
||||||
|
}];
|
||||||
|
r.conditions = vec![];
|
||||||
|
assert_eq!(r.validate().unwrap_err().property, "exceptions");
|
||||||
|
|
||||||
|
let header = rule(
|
||||||
|
Kind::Transport,
|
||||||
|
vec![Action::AddHeader {
|
||||||
|
name: "X-Tag".into(),
|
||||||
|
value: "a\r\nBcc: x@y".into(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
assert!(header.validate().is_err());
|
||||||
|
let redirect = rule(
|
||||||
|
Kind::Transport,
|
||||||
|
vec![Action::Redirect {
|
||||||
|
addresses: vec!["nobody".into()],
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
assert!(redirect.validate().is_err());
|
||||||
|
let mut unnamed = rule(
|
||||||
|
Kind::Transport,
|
||||||
|
vec![Action::RemoveHeader {
|
||||||
|
name: "X-Tag".into(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
unnamed.name = " ".into();
|
||||||
|
assert_eq!(unnamed.validate().unwrap_err().property, "name");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! An organization's own word lists and patterns (§2.3). Both count
|
||||||
|
//! occurrences, not distinct values: "confidential" three times is three.
|
||||||
|
|
||||||
|
use aho_corasick::{AhoCorasick, AhoCorasickBuilder, MatchKind};
|
||||||
|
use regex::{Regex, RegexBuilder};
|
||||||
|
|
||||||
|
/// How large a compiled pattern may grow. Keeps a rule someone writes from
|
||||||
|
/// making every message slow to send.
|
||||||
|
const PATTERN_SIZE_LIMIT: usize = 1 << 20;
|
||||||
|
|
||||||
|
/// Words and phrases, matched whole and ignoring case.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct WordList {
|
||||||
|
matcher: AhoCorasick,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WordList {
|
||||||
|
/// Builds a list from words or phrases; empty entries are skipped.
|
||||||
|
pub fn new<I, S>(words: I) -> Result<Self, String>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = S>,
|
||||||
|
S: AsRef<str>,
|
||||||
|
{
|
||||||
|
let words: Vec<String> = words
|
||||||
|
.into_iter()
|
||||||
|
.map(|w| w.as_ref().trim().to_lowercase())
|
||||||
|
.filter(|w| !w.is_empty())
|
||||||
|
.collect();
|
||||||
|
if words.is_empty() {
|
||||||
|
return Err("The list has no words".into());
|
||||||
|
}
|
||||||
|
AhoCorasickBuilder::new()
|
||||||
|
.match_kind(MatchKind::LeftmostLongest)
|
||||||
|
.build(&words)
|
||||||
|
.map(|matcher| Self { matcher })
|
||||||
|
.map_err(|err| err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many times any word of the list appears in `text`.
|
||||||
|
pub fn count(&self, text: &str) -> usize {
|
||||||
|
let text = text.to_lowercase();
|
||||||
|
self.matcher
|
||||||
|
.find_iter(&text)
|
||||||
|
.filter(|m| super::detectors::stands_alone(&text, m.start(), m.end()))
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An organization's regular expression.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Pattern {
|
||||||
|
regex: Regex,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pattern {
|
||||||
|
/// Compiles `pattern`, or says why it can't be used. Matching ignores
|
||||||
|
/// case unless the pattern turns that off with `(?-i)`.
|
||||||
|
pub fn new(pattern: &str) -> Result<Self, String> {
|
||||||
|
RegexBuilder::new(pattern)
|
||||||
|
.case_insensitive(true)
|
||||||
|
.size_limit(PATTERN_SIZE_LIMIT)
|
||||||
|
.build()
|
||||||
|
.map(|regex| Self { regex })
|
||||||
|
.map_err(|err| err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many times the pattern matches in `text`.
|
||||||
|
pub fn count(&self, text: &str) -> usize {
|
||||||
|
self.regex.find_iter(text).filter(|m| !m.is_empty()).count()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn words_whole_and_any_case() {
|
||||||
|
let list = WordList::new(["Project Falcon", "confidential", " "]).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
list.count(
|
||||||
|
"CONFIDENTIAL: project falcon notes. Not confidentiality, not projectfalcon."
|
||||||
|
),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert_eq!(list.count("Confidential, confidential and confidential"), 3);
|
||||||
|
// Non-ASCII case folding
|
||||||
|
let list = WordList::new(["GEHEIM", "Straße"]).unwrap();
|
||||||
|
assert_eq!(list.count("streng geheim, STRASSE ist nicht Straße"), 2);
|
||||||
|
assert!(WordList::new(["", " "]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn patterns() {
|
||||||
|
let pattern = Pattern::new(r"\bPRJ-\d{4}\b").unwrap();
|
||||||
|
assert_eq!(pattern.count("prj-1234 and PRJ-5678, not PRJ-12"), 2);
|
||||||
|
assert!(Pattern::new("(unclosed").is_err());
|
||||||
|
// Too large to compile within the limit
|
||||||
|
assert!(Pattern::new(r"\w{1000}\w{1000}\w{1000}").is_err());
|
||||||
|
// Empty matches don't count
|
||||||
|
assert_eq!(Pattern::new("x*").unwrap().count("abc"), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,280 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Accepted security to-do items (security to-do list spec, SS-23 to SS-26).
|
||||||
|
//!
|
||||||
|
//! The console runs the checks; the server only keeps what an administrator
|
||||||
|
//! accepted, so every administrator sees the same accepted risks. An
|
||||||
|
//! acceptance names the check, what within it (a domain, a certificate…),
|
||||||
|
//! the value the check saw, and why. It holds only while the check still
|
||||||
|
//! sees that value, which the console compares. Acceptances are created and
|
||||||
|
//! removed, never edited.
|
||||||
|
//!
|
||||||
|
//! Kept in the fork's subspace (`store::SUBSPACE_INBUXA`). Every key starts
|
||||||
|
//! with `Q`, then one byte for the kind:
|
||||||
|
//!
|
||||||
|
//! - `a` + acceptance id (u32): the acceptance, as JSON.
|
||||||
|
//!
|
||||||
|
//! Numbers are big-endian. There are at most [`MAX_ACCEPTANCES`], so
|
||||||
|
//! they're read whole.
|
||||||
|
|
||||||
|
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
|
||||||
|
use store::{
|
||||||
|
Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey,
|
||||||
|
write::{AnyClass, BatchBuilder, ValueClass, assert::AssertValue},
|
||||||
|
};
|
||||||
|
use trc::AddContext;
|
||||||
|
|
||||||
|
const FEATURE: u8 = b'Q';
|
||||||
|
const KIND_ACCEPTANCE: u8 = b'a';
|
||||||
|
const CREATE_ATTEMPTS: usize = 5;
|
||||||
|
|
||||||
|
pub const MAX_ACCEPTANCES: usize = 200;
|
||||||
|
/// The checks are SS-1 to SS-18; a few spare for checks added later.
|
||||||
|
const MAX_CHECK: u32 = 40;
|
||||||
|
const MAX_SUBJECT: usize = 255;
|
||||||
|
const MAX_VALUE_BYTES: usize = 4096;
|
||||||
|
const MAX_NOTE: usize = 500;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Acceptance {
|
||||||
|
#[serde(default)]
|
||||||
|
pub id: u32,
|
||||||
|
/// Which check: `SS-1`, `SS-2`…
|
||||||
|
pub check: String,
|
||||||
|
/// What within the check: empty for a server-wide setting, else the
|
||||||
|
/// domain, strategy or certificate it names.
|
||||||
|
#[serde(default)]
|
||||||
|
pub subject: String,
|
||||||
|
/// The value the check saw when it was accepted.
|
||||||
|
#[serde(default)]
|
||||||
|
pub accepted_value: serde_json::Value,
|
||||||
|
/// Why. Required.
|
||||||
|
pub note: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub accepted_by: String,
|
||||||
|
/// Seconds since the epoch.
|
||||||
|
#[serde(default)]
|
||||||
|
pub accepted_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub struct Invalid {
|
||||||
|
pub property: &'static str,
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid(property: &'static str, reason: impl Into<String>) -> Invalid {
|
||||||
|
Invalid {
|
||||||
|
property,
|
||||||
|
reason: reason.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Acceptance {
|
||||||
|
/// What an administrator sends is checked whole before it's kept.
|
||||||
|
pub fn validate(&self) -> Result<(), Invalid> {
|
||||||
|
let check_ok = self
|
||||||
|
.check
|
||||||
|
.strip_prefix("SS-")
|
||||||
|
.and_then(|n| n.parse::<u32>().ok())
|
||||||
|
.is_some_and(|n| (1..=MAX_CHECK).contains(&n));
|
||||||
|
if !check_ok {
|
||||||
|
return Err(invalid("check", "A check is named SS-1, SS-2 and so on."));
|
||||||
|
}
|
||||||
|
if self.subject.chars().count() > MAX_SUBJECT {
|
||||||
|
return Err(invalid(
|
||||||
|
"subject",
|
||||||
|
format!("At most {MAX_SUBJECT} characters."),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let value_bytes = serde_json::to_vec(&self.accepted_value)
|
||||||
|
.map(|v| v.len())
|
||||||
|
.unwrap_or(usize::MAX);
|
||||||
|
if value_bytes > MAX_VALUE_BYTES {
|
||||||
|
return Err(invalid(
|
||||||
|
"acceptedValue",
|
||||||
|
format!("At most {MAX_VALUE_BYTES} bytes."),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let note = self.note.trim();
|
||||||
|
if note.is_empty() {
|
||||||
|
return Err(invalid("note", "Say why this is accepted."));
|
||||||
|
}
|
||||||
|
if note.chars().count() > MAX_NOTE {
|
||||||
|
return Err(invalid("note", format!("At most {MAX_NOTE} characters.")));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Storage --------------------------------------------------------------
|
||||||
|
|
||||||
|
struct Json<T>(T);
|
||||||
|
|
||||||
|
impl<T: SerdeSerialize> Serialize for Json<T> {
|
||||||
|
fn serialize(&self) -> trc::Result<Vec<u8>> {
|
||||||
|
serde_json::to_vec(&self.0).map_err(|err| {
|
||||||
|
trc::StoreEvent::UnexpectedError
|
||||||
|
.into_err()
|
||||||
|
.details("Failed to serialize a security acceptance")
|
||||||
|
.reason(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deserialize for Json<Acceptance> {
|
||||||
|
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||||
|
serde_json::from_slice(bytes).map(Json).map_err(|err| {
|
||||||
|
trc::StoreEvent::DataCorruption
|
||||||
|
.into_err()
|
||||||
|
.details("Invalid security acceptance")
|
||||||
|
.reason(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn class(id: u32) -> ValueClass {
|
||||||
|
let mut key = Vec::with_capacity(6);
|
||||||
|
key.push(FEATURE);
|
||||||
|
key.push(KIND_ACCEPTANCE);
|
||||||
|
key.extend_from_slice(&id.to_be_bytes());
|
||||||
|
ValueClass::Any(AnyClass {
|
||||||
|
subspace: SUBSPACE_INBUXA,
|
||||||
|
key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(id: u32) -> ValueKey<ValueClass> {
|
||||||
|
ValueKey::from(class(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(data: &Store, id: u32) -> trc::Result<Option<Acceptance>> {
|
||||||
|
Ok(data
|
||||||
|
.get_value::<Json<Acceptance>>(key(id))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.map(|Json(acceptance)| acceptance))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every acceptance, oldest first.
|
||||||
|
pub async fn all(data: &Store) -> trc::Result<Vec<Acceptance>> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
data.iterate(IterateParams::new(key(0), key(u32::MAX)), |_, value| {
|
||||||
|
if let Ok(Json(acceptance)) = Json::<Acceptance>::deserialize(value) {
|
||||||
|
out.push(acceptance);
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
out.sort_by_key(|a| a.id);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Created {
|
||||||
|
Id(u32),
|
||||||
|
/// There are already [`MAX_ACCEPTANCES`].
|
||||||
|
Full,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps a new acceptance under the next free id. Two nodes creating at
|
||||||
|
/// once can't take the same id: the key must be absent.
|
||||||
|
pub async fn create(data: &Store, acceptance: &Acceptance) -> trc::Result<Created> {
|
||||||
|
let mut attempt = 0;
|
||||||
|
loop {
|
||||||
|
attempt += 1;
|
||||||
|
let existing = all(data).await?;
|
||||||
|
if existing.len() >= MAX_ACCEPTANCES {
|
||||||
|
return Ok(Created::Full);
|
||||||
|
}
|
||||||
|
let id = existing.iter().map(|a| a.id).max().unwrap_or(0) + 1;
|
||||||
|
let stored = Acceptance {
|
||||||
|
id,
|
||||||
|
..acceptance.clone()
|
||||||
|
};
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.assert_value(class(id), AssertValue::None);
|
||||||
|
batch.set(class(id), Json(&stored).serialize()?);
|
||||||
|
match data.write(batch.build_all()).await {
|
||||||
|
Ok(_) => return Ok(Created::Id(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!())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(data: &Store, id: u32) -> trc::Result<()> {
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.clear(class(id));
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn acceptance() -> Acceptance {
|
||||||
|
Acceptance {
|
||||||
|
id: 0,
|
||||||
|
check: "SS-1".into(),
|
||||||
|
subject: String::new(),
|
||||||
|
accepted_value: serde_json::json!(true),
|
||||||
|
note: "Old clients on the LAN; closed by 2027.".into(),
|
||||||
|
accepted_by: String::new(),
|
||||||
|
accepted_at: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_note_is_required() {
|
||||||
|
assert!(acceptance().validate().is_ok());
|
||||||
|
let blank = Acceptance {
|
||||||
|
note: " ".into(),
|
||||||
|
..acceptance()
|
||||||
|
};
|
||||||
|
assert_eq!(blank.validate().unwrap_err().property, "note");
|
||||||
|
let long = Acceptance {
|
||||||
|
note: "x".repeat(501),
|
||||||
|
..acceptance()
|
||||||
|
};
|
||||||
|
assert_eq!(long.validate().unwrap_err().property, "note");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_named_checks() {
|
||||||
|
for bad in ["", "SS-0", "SS-41", "ss-1", "SS-x", "1"] {
|
||||||
|
let a = Acceptance {
|
||||||
|
check: bad.into(),
|
||||||
|
..acceptance()
|
||||||
|
};
|
||||||
|
assert_eq!(a.validate().unwrap_err().property, "check", "{bad}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subject_and_value_are_bounded() {
|
||||||
|
let a = Acceptance {
|
||||||
|
subject: "d".repeat(256),
|
||||||
|
..acceptance()
|
||||||
|
};
|
||||||
|
assert_eq!(a.validate().unwrap_err().property, "subject");
|
||||||
|
let a = Acceptance {
|
||||||
|
accepted_value: serde_json::json!("v".repeat(4096)),
|
||||||
|
..acceptance()
|
||||||
|
};
|
||||||
|
assert_eq!(a.validate().unwrap_err().property, "acceptedValue");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,9 @@
|
|||||||
//! ships. The legacy-protocols switch is INBUXA's own design, specified in
|
//! ships. The legacy-protocols switch is INBUXA's own design, specified in
|
||||||
//! `legacy-protocols.md`.
|
//! `legacy-protocols.md`.
|
||||||
|
|
||||||
|
pub mod acceptance;
|
||||||
pub mod legacy_use;
|
pub mod legacy_use;
|
||||||
|
pub mod log_files;
|
||||||
pub mod listeners;
|
pub mod listeners;
|
||||||
pub mod protocol_policy;
|
pub mod protocol_policy;
|
||||||
pub mod tenant_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
|
//! (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.
|
//! `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
|
//! 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
|
//! which listeners were taken away to honour it. Closing sockets belongs to
|
||||||
//! `common`, which owns the listener registry, and removing the listener
|
//! `common`, which owns the listener registry, and removing the listener
|
||||||
@@ -59,12 +70,30 @@ pub struct SavedListener {
|
|||||||
pub object: serde_json::Value,
|
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)]
|
#[derive(Debug, Clone, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||||
#[serde(rename_all = "camelCase", default)]
|
#[serde(rename_all = "camelCase", default)]
|
||||||
pub struct ProtocolPolicy {
|
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,
|
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
|
/// With `disabled`, also close SMTP submission (LP-3). The inbound
|
||||||
/// listener on port 25 is never closed, whatever this says.
|
/// listener on port 25 is never closed, whatever this says.
|
||||||
pub close_submission: bool,
|
pub close_submission: bool,
|
||||||
@@ -80,6 +109,9 @@ impl Default for ProtocolPolicy {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
ProtocolPolicy {
|
ProtocolPolicy {
|
||||||
legacy_protocols: LegacyProtocols::Enabled,
|
legacy_protocols: LegacyProtocols::Enabled,
|
||||||
|
imap: None,
|
||||||
|
pop3: None,
|
||||||
|
manage_sieve: None,
|
||||||
close_submission: true,
|
close_submission: true,
|
||||||
saved_listeners: Vec::new(),
|
saved_listeners: Vec::new(),
|
||||||
changed_at: None,
|
changed_at: None,
|
||||||
@@ -91,6 +123,9 @@ impl Default for ProtocolPolicy {
|
|||||||
/// The properties `inbuxa:ProtocolPolicy` has, as they appear over JMAP.
|
/// The properties `inbuxa:ProtocolPolicy` has, as they appear over JMAP.
|
||||||
pub const PROPERTIES: &[&str] = &[
|
pub const PROPERTIES: &[&str] = &[
|
||||||
"legacyProtocols",
|
"legacyProtocols",
|
||||||
|
"imap",
|
||||||
|
"pop3",
|
||||||
|
"manageSieve",
|
||||||
"closeSubmission",
|
"closeSubmission",
|
||||||
"savedListeners",
|
"savedListeners",
|
||||||
"changedAt",
|
"changedAt",
|
||||||
@@ -129,23 +164,137 @@ pub fn is_locked(protocol: &str) -> bool {
|
|||||||
.any(|locked| locked.eq_ignore_ascii_case(protocol))
|
.any(|locked| locked.eq_ignore_ascii_case(protocol))
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProtocolPolicy {
|
/// The switch fields, by protocol name.
|
||||||
/// Whether a listener of this protocol and these ports is one the switch
|
pub trait Switches {
|
||||||
/// closes. A listener bound to port 25 is inbound whatever its name, and
|
/// The kill-all, which an unset per-protocol switch reads as.
|
||||||
/// any other SMTP listener counts as submission (LP-3).
|
fn all(&self) -> LegacyProtocols;
|
||||||
pub fn closes(&self, protocol: &str, ports: &[u16]) -> bool {
|
fn slot(&self, protocol: &str) -> Option<&Option<LegacyProtocols>>;
|
||||||
if !self.legacy_protocols.is_disabled() {
|
fn slot_mut(&mut self, protocol: &str) -> Option<&mut Option<LegacyProtocols>>;
|
||||||
return false;
|
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
|
// The lock is checked first and answers for every caller, so no
|
||||||
// request phrasing can reach past it (LP-21).
|
// request phrasing can reach past it (LP-21).
|
||||||
if is_locked(protocol) {
|
if is_locked(protocol) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if LEGACY_PROTOCOLS.contains(&protocol) {
|
if LEGACY_PROTOCOLS.contains(&protocol) {
|
||||||
return true;
|
return self.is_off(protocol);
|
||||||
}
|
}
|
||||||
protocol.eq_ignore_ascii_case("smtp")
|
protocol.eq_ignore_ascii_case("smtp")
|
||||||
|
&& self.all_off()
|
||||||
&& self.close_submission
|
&& self.close_submission
|
||||||
&& !ports.contains(&INBOUND_SMTP_PORT)
|
&& !ports.contains(&INBOUND_SMTP_PORT)
|
||||||
}
|
}
|
||||||
@@ -258,7 +407,10 @@ mod tests {
|
|||||||
"an unset closeSubmission reads as the default, true"
|
"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 {
|
for property in PROPERTIES {
|
||||||
assert!(json.get(property).is_some(), "{property}");
|
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.
|
/// A saved listener with no id is refused, naming the property.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_nameless_saved_listener_is_refused() {
|
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
|
//! the tenant id in the fork's subspace; a tenant with nothing stored has
|
||||||
//! legacy protocols on.
|
//! 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
|
//! 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
|
//! 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
|
//! 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.
|
//! 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 serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
|
||||||
use store::{
|
use store::{
|
||||||
Deserialize, SUBSPACE_INBUXA, Store, ValueKey,
|
Deserialize, SUBSPACE_INBUXA, Store, ValueKey,
|
||||||
@@ -26,24 +32,92 @@ use trc::AddContext;
|
|||||||
#[derive(Debug, Clone, PartialEq, Default, SerdeSerialize, SerdeDeserialize)]
|
#[derive(Debug, Clone, PartialEq, Default, SerdeSerialize, SerdeDeserialize)]
|
||||||
#[serde(rename_all = "camelCase", default)]
|
#[serde(rename_all = "camelCase", default)]
|
||||||
pub struct TenantProtocolPolicy {
|
pub struct TenantProtocolPolicy {
|
||||||
/// The switch itself.
|
/// The kill-all, as on the server's policy.
|
||||||
pub legacy_protocols: LegacyProtocols,
|
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.
|
/// When it last changed, in milliseconds since the epoch.
|
||||||
pub changed_at: Option<u64>,
|
pub changed_at: Option<u64>,
|
||||||
/// The account that last changed it.
|
/// The account that last changed it.
|
||||||
pub changed_by: Option<String>,
|
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
|
/// A tenant can always turn a protocol off for itself. It can turn one on
|
||||||
/// them back on only while the server has them on: server off means off for
|
/// only while the server has it on: server off means off for everyone.
|
||||||
/// everyone.
|
/// `turned_on` is what the request sets to `enabled`, by protocol name.
|
||||||
pub fn refusal(server: &ProtocolPolicy, requested: LegacyProtocols) -> Option<&'static str> {
|
pub fn refusal(server: &ProtocolPolicy, turned_on: &[&str]) -> Option<String> {
|
||||||
(server.legacy_protocols.is_disabled() && !requested.is_disabled()).then_some(
|
let blocked: Vec<&str> = turned_on
|
||||||
"Legacy mail protocols are off for the whole server (inbuxa:ProtocolPolicy), \
|
.iter()
|
||||||
so they can't be turned back on for one organization.",
|
.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 {
|
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]
|
#[test]
|
||||||
fn a_tenant_starts_with_legacy_protocols_on() {
|
fn a_tenant_starts_with_legacy_protocols_on() {
|
||||||
assert!(
|
assert!(
|
||||||
@@ -127,20 +210,59 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_tenant_can_always_turn_them_off() {
|
fn a_tenant_can_always_turn_them_off() {
|
||||||
for s in [LegacyProtocols::Enabled, LegacyProtocols::Disabled] {
|
for s in [LegacyProtocols::Enabled, LegacyProtocols::Disabled] {
|
||||||
assert_eq!(refusal(&server(s), LegacyProtocols::Disabled), None);
|
assert_eq!(refusal(&server(s), &[]), None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_tenant_can_turn_them_on_only_while_the_server_has_them_on() {
|
fn a_tenant_can_turn_them_on_only_while_the_server_has_them_on() {
|
||||||
// LP-9, acceptance test 9.
|
// LP-9, acceptance test 9.
|
||||||
assert_eq!(
|
assert_eq!(refusal(&server(LegacyProtocols::Enabled), SWITCHED), None);
|
||||||
refusal(&server(LegacyProtocols::Enabled), LegacyProtocols::Enabled),
|
let why = refusal(&server(LegacyProtocols::Disabled), SWITCHED).expect("refused");
|
||||||
None
|
|
||||||
);
|
|
||||||
let why =
|
|
||||||
refusal(&server(LegacyProtocols::Disabled), LegacyProtocols::Enabled).expect("refused");
|
|
||||||
assert!(why.contains("inbuxa:ProtocolPolicy"), "{why}");
|
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]
|
#[test]
|
||||||
@@ -158,6 +280,7 @@ mod tests {
|
|||||||
legacy_protocols: LegacyProtocols::Disabled,
|
legacy_protocols: LegacyProtocols::Disabled,
|
||||||
changed_at: Some(1),
|
changed_at: Some(1),
|
||||||
changed_by: Some("b".into()),
|
changed_by: Some("b".into()),
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
let Json(back) = Json::deserialize(&serde_json::to_vec(&policy).unwrap()).unwrap();
|
let Json(back) = Json::deserialize(&serde_json::to_vec(&policy).unwrap()).unwrap();
|
||||||
assert_eq!(back, policy);
|
assert_eq!(back, policy);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "groupware"
|
name = "groupware"
|
||||||
version = "0.16.23"
|
version = "0.16.24"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "http_proto"
|
name = "http_proto"
|
||||||
version = "0.16.23"
|
version = "0.16.24"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "0.16.23"
|
version = "0.16.24"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
+103
-70
@@ -12,7 +12,7 @@ use common::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
use hyper::body::{Bytes, Frame};
|
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 serde::{Deserialize, Serialize};
|
||||||
use smtp::outbound::{
|
use smtp::outbound::{
|
||||||
client::{SmtpClient, StartTlsResult},
|
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?;
|
tx.send(DeliveryStage::IpLookupStart).await?;
|
||||||
|
|
||||||
let now = Instant::now();
|
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) => {
|
HostOrIp::Host(hostname) => {
|
||||||
match server
|
match server
|
||||||
.ip_lookup(&hostname, IpLookupStrategy::Ipv4thenIpv6, usize::MAX, false)
|
.ip_lookup(
|
||||||
|
&hostname,
|
||||||
|
IpLookupStrategy::Ipv4thenIpv6,
|
||||||
|
usize::MAX,
|
||||||
|
validate_addresses,
|
||||||
|
)
|
||||||
.await
|
.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(_) => {
|
Ok(_) => {
|
||||||
tx.send(DeliveryStage::IpLookupError {
|
tx.send(DeliveryStage::IpLookupError {
|
||||||
reason: "No IP addresses found for host".to_string(),
|
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 {
|
tx.send(DeliveryStage::IpLookupSuccess {
|
||||||
@@ -484,6 +428,95 @@ async fn delivery_diagnose(
|
|||||||
})
|
})
|
||||||
.await?;
|
.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 {
|
for remote_ip in remote_ips {
|
||||||
// Start connection
|
// Start connection
|
||||||
tx.send(DeliveryStage::ConnectionStart { remote_ip })
|
tx.send(DeliveryStage::ConnectionStart { remote_ip })
|
||||||
|
|||||||
@@ -120,6 +120,40 @@ impl ManagementApi for Server {
|
|||||||
jmap::inbuxa::explanation::question(self, &access_token, &subject).await?;
|
jmap::inbuxa::explanation::question(self, &access_token, &subject).await?;
|
||||||
Ok(explain_stream(self.clone(), access_token, question, in_flight))
|
Ok(explain_stream(self.clone(), access_token, question, in_flight))
|
||||||
}
|
}
|
||||||
|
// inbuxa: try a saved directory before anything signs in through it
|
||||||
|
"directory" if is_post && path.get(1).copied() == Some("test") => {
|
||||||
|
let (_in_flight, access_token) = self.authenticate_headers(req, session).await?;
|
||||||
|
jmap::inbuxa::directory_test::assert_allowed(&access_token)?;
|
||||||
|
let request = body
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|body| serde_json::from_slice::<serde_json::Value>(body).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let answer = jmap::inbuxa::directory_test::test(self, &request).await?;
|
||||||
|
Ok(JsonResponse::new(answer).no_cache().into_http_response())
|
||||||
|
}
|
||||||
|
// inbuxa: send one sample event to a saved webhook
|
||||||
|
"webhook" if is_post && path.get(1).copied() == Some("test") => {
|
||||||
|
let (_in_flight, access_token) = self.authenticate_headers(req, session).await?;
|
||||||
|
jmap::inbuxa::webhook_test::assert_allowed(&access_token)?;
|
||||||
|
let request = body
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|body| serde_json::from_slice::<serde_json::Value>(body).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let answer = jmap::inbuxa::webhook_test::test(self, &request).await?;
|
||||||
|
Ok(JsonResponse::new(answer).no_cache().into_http_response())
|
||||||
|
}
|
||||||
|
// inbuxa: whether the outside world reaches each node's ports
|
||||||
|
"ports" if path.get(1).copied() == Some("check") => {
|
||||||
|
let (_in_flight, access_token) = self.authenticate_headers(req, session).await?;
|
||||||
|
if access_token.tenant_id().is_some() {
|
||||||
|
return Err(trc::JmapEvent::Forbidden
|
||||||
|
.into_err()
|
||||||
|
.details("Port checks are for server-level administrators."));
|
||||||
|
}
|
||||||
|
access_token.enforce_permission(Permission::SysNetworkListenerGet)?;
|
||||||
|
let answer = common::reachability::report(self).await?;
|
||||||
|
Ok(JsonResponse::new(answer).no_cache().into_http_response())
|
||||||
|
}
|
||||||
"account" => {
|
"account" => {
|
||||||
// Authenticate request
|
// Authenticate request
|
||||||
let (_in_flight, access_token) = self.authenticate_headers(req, session).await?;
|
let (_in_flight, access_token) = self.authenticate_headers(req, session).await?;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ use hyper::{
|
|||||||
server::conn::http1,
|
server::conn::http1,
|
||||||
service::service_fn,
|
service::service_fn,
|
||||||
};
|
};
|
||||||
use hyper_util::rt::TokioIo;
|
use hyper_util::rt::{TokioIo, TokioTimer};
|
||||||
use jmap::{
|
use jmap::{
|
||||||
api::{
|
api::{
|
||||||
ToJmapHttpResponse, event_source::EventSourceHandler, request::RequestHandler,
|
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();
|
let is_tls = session.stream.is_tls();
|
||||||
|
|
||||||
if let Err(http_err) = http1::Builder::new()
|
if let Err(http_err) = http1::Builder::new()
|
||||||
|
.timer(TokioTimer::new())
|
||||||
.keep_alive(true)
|
.keep_alive(true)
|
||||||
.serve_connection(
|
.serve_connection(
|
||||||
TokioIo::new(session.stream),
|
TokioIo::new(session.stream),
|
||||||
@@ -875,6 +876,7 @@ async fn handle_session<T: SessionStream>(inner: Arc<Inner>, session: SessionDat
|
|||||||
)
|
)
|
||||||
.with_upgrades()
|
.with_upgrades()
|
||||||
.await
|
.await
|
||||||
|
&& !http_err.is_timeout()
|
||||||
{
|
{
|
||||||
if http_err.is_parse() {
|
if http_err.is_parse() {
|
||||||
let server = inner.build_server();
|
let server = inner.build_server();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "imap_proto"
|
name = "imap_proto"
|
||||||
version = "0.16.23"
|
version = "0.16.24"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "imap"
|
name = "imap"
|
||||||
version = "0.16.23"
|
version = "0.16.24"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
+249
-158
@@ -9,6 +9,7 @@ use crate::{
|
|||||||
core::{MailboxId, SelectedMailbox, Session, SessionData},
|
core::{MailboxId, SelectedMailbox, Session, SessionData},
|
||||||
spawn_op,
|
spawn_op,
|
||||||
};
|
};
|
||||||
|
use ahash::AHashMap;
|
||||||
use common::{ipc::PushNotification, network::SessionStream, storage::index::ObjectIndexBuilder};
|
use common::{ipc::PushNotification, network::SessionStream, storage::index::ObjectIndexBuilder};
|
||||||
use email::{
|
use email::{
|
||||||
cache::{MessageCacheFetch, email::MessageCacheAccess},
|
cache::{MessageCacheFetch, email::MessageCacheAccess},
|
||||||
@@ -22,8 +23,13 @@ use email::{
|
|||||||
use imap_proto::{
|
use imap_proto::{
|
||||||
Command, ResponseCode, StatusResponse, protocol::copy_move::Arguments, receiver::Request,
|
Command, ResponseCode, StatusResponse, protocol::copy_move::Arguments, receiver::Request,
|
||||||
};
|
};
|
||||||
|
use rand::RngExt;
|
||||||
use registry::schema::enums::Permission;
|
use registry::schema::enums::Permission;
|
||||||
use std::{sync::Arc, time::Instant};
|
use std::{
|
||||||
|
ops::RangeInclusive,
|
||||||
|
sync::Arc,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
use store::{
|
use store::{
|
||||||
ValueKey,
|
ValueKey,
|
||||||
roaring::RoaringBitmap,
|
roaring::RoaringBitmap,
|
||||||
@@ -36,6 +42,9 @@ use types::{
|
|||||||
type_state::{DataType, StateChange},
|
type_state::{DataType, StateChange},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MAX_MOVE_RETRIES: u32 = 3;
|
||||||
|
const MOVE_RETRY_BACKOFF_MS: RangeInclusive<u64> = 1..=15;
|
||||||
|
|
||||||
impl<T: SessionStream> Session<T> {
|
impl<T: SessionStream> Session<T> {
|
||||||
pub async fn handle_copy_move(
|
pub async fn handle_copy_move(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -236,148 +245,180 @@ impl<T: SessionStream> SessionData<T> {
|
|||||||
// Mailboxes are in the same account
|
// Mailboxes are in the same account
|
||||||
let account_id = src_mailbox.id.account_id;
|
let account_id = src_mailbox.id.account_id;
|
||||||
let dest_mailbox_id = UidMailbox::new_unassigned(dest_mailbox_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 {
|
loop {
|
||||||
// Obtain mailbox tags
|
let mut batch = BatchBuilder::new();
|
||||||
let data_ = if let Some(result) = self
|
copied_ids.clear();
|
||||||
.get_message_data(account_id, id)
|
did_move = false;
|
||||||
.await
|
|
||||||
.imap_ctx(&arguments.tag, trc::location!())?
|
|
||||||
{
|
|
||||||
result
|
|
||||||
} else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Deserialize
|
for (&id, imap_id) in &ids {
|
||||||
let data = data_
|
// Obtain mailbox tags
|
||||||
.to_unarchived::<MessageData>()
|
let data_ = if let Some(result) = self
|
||||||
.imap_ctx(&arguments.tag, trc::location!())?;
|
.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
|
// Deserialize
|
||||||
if !data
|
let data = data_
|
||||||
.inner
|
.to_unarchived::<MessageData>()
|
||||||
.mailboxes
|
.imap_ctx(&arguments.tag, trc::location!())?;
|
||||||
.iter()
|
|
||||||
.any(|mailbox| mailbox.mailbox_id == src_mailbox.id.mailbox_id)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the message is already in the destination mailbox, skip it.
|
// Make sure the message still belongs to this mailbox
|
||||||
if let Some(mailbox) = data
|
if !data
|
||||||
.inner
|
.inner
|
||||||
.mailboxes
|
.mailboxes
|
||||||
.iter()
|
.iter()
|
||||||
.find(|mailbox| mailbox.mailbox_id == dest_mailbox_id.mailbox_id)
|
.any(|mailbox| mailbox.mailbox_id == src_mailbox.id.mailbox_id)
|
||||||
{
|
{
|
||||||
copied_ids.push((imap_id.uid, mailbox.uid.to_native()));
|
// Moved by a chunk of a previous attempt
|
||||||
|
if let Some(&uid) = written_uids.get(&id)
|
||||||
if is_move {
|
&& data.inner.message_uid(dest_mailbox_id.mailbox_id) == Some(uid)
|
||||||
let mut new_data = data.inner.to_builder();
|
{
|
||||||
new_data.remove_mailbox(src_mailbox.id.mailbox_id);
|
copied_ids.push((imap_id.uid, uid));
|
||||||
batch
|
did_move = true;
|
||||||
.with_account_id(account_id)
|
}
|
||||||
.with_collection(Collection::Email)
|
continue;
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
if is_move {
|
||||||
let mut new_data = data.inner.to_builder();
|
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
|
continue;
|
||||||
new_data.add_mailbox(dest_mailbox_id);
|
}
|
||||||
if is_move {
|
|
||||||
new_data.remove_mailbox(src_mailbox.id.mailbox_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assign IMAP UIDs
|
// Prepare changes
|
||||||
let ids = self
|
let mut new_data = data.inner.to_builder();
|
||||||
.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
|
// Add destination folder
|
||||||
.mailboxes
|
new_data.add_mailbox(dest_mailbox_id);
|
||||||
.iter_mut()
|
if is_move {
|
||||||
.filter(|m| m.uid == 0)
|
new_data.remove_mailbox(src_mailbox.id.mailbox_id);
|
||||||
.zip(ids)
|
}
|
||||||
{
|
|
||||||
copied_ids.push((imap_id.uid, uid));
|
|
||||||
uid_mailbox.uid = uid;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare write batch
|
// Assign IMAP UIDs
|
||||||
batch
|
let ids = self
|
||||||
.with_account_id(account_id)
|
.server
|
||||||
.with_collection(Collection::Email)
|
.assign_email_ids(
|
||||||
.with_document(id)
|
account_id,
|
||||||
.custom(
|
new_data
|
||||||
ObjectIndexBuilder::new()
|
.mailboxes
|
||||||
.with_current(data)
|
.iter()
|
||||||
.with_changes(new_data.seal()),
|
.filter(|m| m.uid == 0)
|
||||||
)
|
.map(|m| m.mailbox_id),
|
||||||
.imap_ctx(&arguments.tag, trc::location!())?;
|
false,
|
||||||
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
|
.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!())?;
|
.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();
|
// Write changes
|
||||||
|
match self.server.commit_batch(batch).await {
|
||||||
// Update changelog
|
Ok(_) => break,
|
||||||
if is_move {
|
Err(err) => {
|
||||||
did_move = true;
|
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 {
|
} else {
|
||||||
// Obtain quota for target account
|
// Obtain quota for target account
|
||||||
let src_account_id = src_mailbox.id.account_id;
|
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 train_batch = BatchBuilder::new();
|
||||||
let mut did_train = false;
|
let mut did_train = false;
|
||||||
train_batch.with_account_id(src_account_id);
|
train_batch.with_account_id(src_account_id);
|
||||||
for (id, imap_id) in ids {
|
'next_message: for (id, imap_id) in ids {
|
||||||
match self
|
match self
|
||||||
.server
|
.server
|
||||||
.copy_message(
|
.copy_message(
|
||||||
@@ -448,22 +489,27 @@ impl<T: SessionStream> SessionData<T> {
|
|||||||
{
|
{
|
||||||
copied_ids.push((imap_id.uid, uid));
|
copied_ids.push((imap_id.uid, uid));
|
||||||
} else {
|
} else {
|
||||||
let data_ = if let Some(data_) = self
|
let mut retries = 0;
|
||||||
.get_message_data(dest_account_id, existing_id)
|
|
||||||
.await
|
loop {
|
||||||
.imap_ctx(&arguments.tag, trc::location!())?
|
let data_ = if let Some(data_) = self
|
||||||
{
|
.get_message_data(dest_account_id, existing_id)
|
||||||
data_
|
.await
|
||||||
} else {
|
.imap_ctx(&arguments.tag, trc::location!())?
|
||||||
continue;
|
{
|
||||||
};
|
data_
|
||||||
let data = data_
|
} else {
|
||||||
.to_unarchived::<MessageData>()
|
continue 'next_message;
|
||||||
.imap_ctx(&arguments.tag, trc::location!())?;
|
};
|
||||||
|
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();
|
let mut new_data = data.inner.to_builder();
|
||||||
new_data.add_mailbox(UidMailbox::new_unassigned(dest_mailbox_id));
|
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!())?;
|
.imap_ctx(&arguments.tag, trc::location!())?;
|
||||||
|
|
||||||
dest_change_id = self
|
match self
|
||||||
.server
|
.server
|
||||||
.commit_batch(batch)
|
.commit_batch(batch)
|
||||||
.await
|
.await
|
||||||
.and_then(|ids| ids.last_change_id(dest_account_id))
|
.and_then(|ids| ids.last_change_id(dest_account_id))
|
||||||
.imap_ctx(&arguments.tag, trc::location!())?
|
{
|
||||||
.into();
|
Ok(change_id) => {
|
||||||
|
dest_change_id = change_id.into();
|
||||||
copied_ids.push((imap_id.uid, assigned_uid));
|
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
|
// Untag or delete emails
|
||||||
if !destroy_ids.is_empty() {
|
if !destroy_ids.is_empty() {
|
||||||
let mut batch = BatchBuilder::new();
|
let mut retries = 0;
|
||||||
self.email_untag_or_delete(
|
|
||||||
src_account_id,
|
|
||||||
src_mailbox.id.mailbox_id,
|
|
||||||
&destroy_ids,
|
|
||||||
&mut batch,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.imap_ctx(&arguments.tag, trc::location!())?;
|
|
||||||
|
|
||||||
self.server
|
loop {
|
||||||
.commit_batch(batch)
|
let mut batch = BatchBuilder::new();
|
||||||
|
self.email_untag_or_delete(
|
||||||
|
src_account_id,
|
||||||
|
src_mailbox.id.mailbox_id,
|
||||||
|
&destroy_ids,
|
||||||
|
&mut batch,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.imap_ctx(&arguments.tag, trc::location!())?;
|
.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;
|
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]
|
[package]
|
||||||
name = "jmap_proto"
|
name = "jmap_proto"
|
||||||
version = "0.16.23"
|
version = "0.16.24"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -47,6 +47,18 @@ struct SetErrorInner<P: Property> {
|
|||||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
#[serde(rename = "validationErrors")]
|
#[serde(rename = "validationErrors")]
|
||||||
validation_errors: Vec<ValidationError>,
|
validation_errors: Vec<ValidationError>,
|
||||||
|
|
||||||
|
// inbuxa: DLP (dlp-and-mail-flow-rules spec, §2.5): each rule that
|
||||||
|
// warned or blocked, with its notice
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
|
rules: Vec<DlpRule>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: a DLP rule named in an `inbuxa:dlpWarning` or `inbuxa:dlpBlocked`.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct DlpRule {
|
||||||
|
pub name: String,
|
||||||
|
pub notice: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -127,6 +139,12 @@ pub enum SetErrorType {
|
|||||||
// inbuxa: a create that couldn't run (ai-explain spec: busy, timeout, …)
|
// inbuxa: a create that couldn't run (ai-explain spec: busy, timeout, …)
|
||||||
#[serde(rename = "serverFail")]
|
#[serde(rename = "serverFail")]
|
||||||
ServerFail,
|
ServerFail,
|
||||||
|
// inbuxa: DLP (dlp-and-mail-flow-rules spec, §2.5): a warning the
|
||||||
|
// sender may answer with inbuxa:dlpOverride, and a block
|
||||||
|
#[serde(rename = "inbuxa:dlpWarning")]
|
||||||
|
DlpWarning,
|
||||||
|
#[serde(rename = "inbuxa:dlpBlocked")]
|
||||||
|
DlpBlocked,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SetErrorType {
|
impl SetErrorType {
|
||||||
@@ -166,6 +184,8 @@ impl SetErrorType {
|
|||||||
SetErrorType::PrimaryKeyViolation => "primaryKeyViolation",
|
SetErrorType::PrimaryKeyViolation => "primaryKeyViolation",
|
||||||
SetErrorType::ValidationFailed => "validationFailed",
|
SetErrorType::ValidationFailed => "validationFailed",
|
||||||
SetErrorType::ServerFail => "serverFail",
|
SetErrorType::ServerFail => "serverFail",
|
||||||
|
SetErrorType::DlpWarning => "inbuxa:dlpWarning",
|
||||||
|
SetErrorType::DlpBlocked => "inbuxa:dlpBlocked",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,9 +200,16 @@ impl<T: Property> SetError<T> {
|
|||||||
object_id: None,
|
object_id: None,
|
||||||
linked_objects: Vec::new(),
|
linked_objects: Vec::new(),
|
||||||
validation_errors: Vec::new(),
|
validation_errors: Vec::new(),
|
||||||
|
rules: Vec::new(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: the DLP rules behind a warning or block.
|
||||||
|
pub fn with_dlp_rules(mut self, rules: Vec<DlpRule>) -> Self {
|
||||||
|
self.0.rules = rules;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_description(mut self, description: impl Into<Cow<'static, str>>) -> Self {
|
pub fn with_description(mut self, description: impl Into<Cow<'static, str>>) -> Self {
|
||||||
self.0.description = description.into().into();
|
self.0.description = description.into().into();
|
||||||
self
|
self
|
||||||
@@ -353,6 +380,7 @@ impl From<PatchError> for SetError<registry::schema::properties::Property> {
|
|||||||
object_id: None,
|
object_id: None,
|
||||||
linked_objects: Vec::new(),
|
linked_objects: Vec::new(),
|
||||||
validation_errors: Vec::new(),
|
validation_errors: Vec::new(),
|
||||||
|
rules: Vec::new(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ use crate::{
|
|||||||
email::{EmailProperty, EmailValue},
|
email::{EmailProperty, EmailValue},
|
||||||
},
|
},
|
||||||
request::{
|
request::{
|
||||||
MaybeInvalid,
|
|
||||||
deserialize::{DeserializeArguments, deserialize_request},
|
deserialize::{DeserializeArguments, deserialize_request},
|
||||||
reference::{MaybeIdReference, MaybeResultReference, ResultReference},
|
reference::{MaybeIdReference, MaybeResultReference, ResultReference},
|
||||||
},
|
},
|
||||||
@@ -33,7 +32,7 @@ pub struct ImportEmailRequest {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct ImportEmail {
|
pub struct ImportEmail {
|
||||||
pub blob_id: MaybeInvalid<BlobId>,
|
pub blob_id: MaybeIdReference<BlobId>,
|
||||||
pub mailbox_ids: MaybeResultReference<Vec<MaybeIdReference<Id>>>,
|
pub mailbox_ids: MaybeResultReference<Vec<MaybeIdReference<Id>>>,
|
||||||
pub keywords: Vec<Keyword>,
|
pub keywords: Vec<Keyword>,
|
||||||
pub received_at: Option<UTCDate>,
|
pub received_at: Option<UTCDate>,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -40,6 +42,12 @@ pub enum EmailSubmissionProperty {
|
|||||||
Displayed,
|
Displayed,
|
||||||
DsnBlobIds,
|
DsnBlobIds,
|
||||||
MdnBlobIds,
|
MdnBlobIds,
|
||||||
|
// inbuxa: DLP (dlp-and-mail-flow-rules spec, §2.5): `{"reason": ...}`
|
||||||
|
// to send despite a warning
|
||||||
|
DlpOverride,
|
||||||
|
// inbuxa: in a create's response, true when DLP held the message for
|
||||||
|
// review (§2.6)
|
||||||
|
DlpHeld,
|
||||||
|
|
||||||
Pointer(JsonPointer<EmailSubmissionProperty>),
|
Pointer(JsonPointer<EmailSubmissionProperty>),
|
||||||
}
|
}
|
||||||
@@ -90,6 +98,8 @@ impl Property for EmailSubmissionProperty {
|
|||||||
EmailSubmissionProperty::Id => "id",
|
EmailSubmissionProperty::Id => "id",
|
||||||
EmailSubmissionProperty::IdentityId => "identityId",
|
EmailSubmissionProperty::IdentityId => "identityId",
|
||||||
EmailSubmissionProperty::MdnBlobIds => "mdnBlobIds",
|
EmailSubmissionProperty::MdnBlobIds => "mdnBlobIds",
|
||||||
|
EmailSubmissionProperty::DlpOverride => "inbuxa:dlpOverride",
|
||||||
|
EmailSubmissionProperty::DlpHeld => "inbuxa:held",
|
||||||
EmailSubmissionProperty::SendAt => "sendAt",
|
EmailSubmissionProperty::SendAt => "sendAt",
|
||||||
EmailSubmissionProperty::ThreadId => "threadId",
|
EmailSubmissionProperty::ThreadId => "threadId",
|
||||||
EmailSubmissionProperty::UndoStatus => "undoStatus",
|
EmailSubmissionProperty::UndoStatus => "undoStatus",
|
||||||
@@ -181,6 +191,8 @@ impl EmailSubmissionProperty {
|
|||||||
"displayed" => EmailSubmissionProperty::Displayed,
|
"displayed" => EmailSubmissionProperty::Displayed,
|
||||||
"dsnBlobIds" => EmailSubmissionProperty::DsnBlobIds,
|
"dsnBlobIds" => EmailSubmissionProperty::DsnBlobIds,
|
||||||
"mdnBlobIds" => EmailSubmissionProperty::MdnBlobIds,
|
"mdnBlobIds" => EmailSubmissionProperty::MdnBlobIds,
|
||||||
|
"inbuxa:dlpOverride" => EmailSubmissionProperty::DlpOverride,
|
||||||
|
"inbuxa:held" => EmailSubmissionProperty::DlpHeld,
|
||||||
)
|
)
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
if allow_patch && value.contains('/') {
|
if allow_patch && value.contains('/') {
|
||||||
|
|||||||
@@ -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,153 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:DlpSettings/get` and `/set` under `urn:inbuxa:jmap`: the DLP
|
||||||
|
//! settings singleton (dlp-and-mail-flow-rules spec, §2.6): how many days
|
||||||
|
//! held mail waits for a reviewer before it goes back to the sender.
|
||||||
|
|
||||||
|
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 DlpSettings;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum DlpSettingsProperty {
|
||||||
|
Id,
|
||||||
|
KeepHeldDays,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum DlpSettingsValue {
|
||||||
|
Id(Id),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Property for DlpSettingsProperty {
|
||||||
|
fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||||
|
DlpSettingsProperty::parse(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
DlpSettingsProperty::Id => "id",
|
||||||
|
DlpSettingsProperty::KeepHeldDays => "keepHeldDays",
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DlpSettingsProperty {
|
||||||
|
fn parse(value: &str) -> Option<Self> {
|
||||||
|
hashify::tiny_map!(value.as_bytes(),
|
||||||
|
b"id" => DlpSettingsProperty::Id,
|
||||||
|
b"keepHeldDays" => DlpSettingsProperty::KeepHeldDays,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for DlpSettingsProperty {
|
||||||
|
type Err = ();
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
DlpSettingsProperty::parse(s).ok_or(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element for DlpSettingsValue {
|
||||||
|
type Property = DlpSettingsProperty;
|
||||||
|
|
||||||
|
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||||
|
match key {
|
||||||
|
Key::Property(DlpSettingsProperty::Id) => {
|
||||||
|
Id::from_str(value).ok().map(DlpSettingsValue::Id)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
DlpSettingsValue::Id(id) => id.to_string().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObject for DlpSettings {
|
||||||
|
type Property = DlpSettingsProperty;
|
||||||
|
|
||||||
|
type Element = DlpSettingsValue;
|
||||||
|
|
||||||
|
type Id = Id;
|
||||||
|
|
||||||
|
type Filter = ();
|
||||||
|
|
||||||
|
type Comparator = ();
|
||||||
|
|
||||||
|
type GetArguments = ();
|
||||||
|
|
||||||
|
type SetArguments<'de> = ();
|
||||||
|
|
||||||
|
type QueryArguments = ();
|
||||||
|
|
||||||
|
type CopyArguments = ();
|
||||||
|
|
||||||
|
type ParseArguments = ();
|
||||||
|
|
||||||
|
const ID_PROPERTY: Self::Property = DlpSettingsProperty::Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Id> for DlpSettingsValue {
|
||||||
|
fn from(id: Id) -> Self {
|
||||||
|
DlpSettingsValue::Id(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for DlpSettingsValue {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
match self {
|
||||||
|
DlpSettingsValue::Id(id) => Some(*id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
match self {
|
||||||
|
DlpSettingsValue::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 = DlpSettingsValue::Id(id);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for DlpSettingsProperty {
|
||||||
|
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,213 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:HeldMessage/get` and `/set` under `urn:inbuxa:jmap`: mail held
|
||||||
|
//! for review (dlp-and-mail-flow-rules spec, §2.6). Get lists it; `preview`
|
||||||
|
//! (the text, only when asked for) is recorded as access to someone's mail.
|
||||||
|
//! Set only updates: `{"decision": "release"}`, or `"reject"` with an
|
||||||
|
//! optional `note` for the sender. The call's `reason` goes into the audit
|
||||||
|
//! log and is required.
|
||||||
|
|
||||||
|
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 HeldMessage;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum HeldMessageProperty {
|
||||||
|
Id,
|
||||||
|
Sender,
|
||||||
|
Recipients,
|
||||||
|
Subject,
|
||||||
|
Size,
|
||||||
|
Rules,
|
||||||
|
Counts,
|
||||||
|
HeldAt,
|
||||||
|
ExpiresAt,
|
||||||
|
Preview,
|
||||||
|
Decision,
|
||||||
|
Note,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum HeldMessageValue {
|
||||||
|
Id(Id),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Property for HeldMessageProperty {
|
||||||
|
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||||
|
// Keys inside rules and counts stay plain keys
|
||||||
|
match parent {
|
||||||
|
None => HeldMessageProperty::parse(value),
|
||||||
|
Some(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
HeldMessageProperty::Id => "id",
|
||||||
|
HeldMessageProperty::Sender => "sender",
|
||||||
|
HeldMessageProperty::Recipients => "recipients",
|
||||||
|
HeldMessageProperty::Subject => "subject",
|
||||||
|
HeldMessageProperty::Size => "size",
|
||||||
|
HeldMessageProperty::Rules => "rules",
|
||||||
|
HeldMessageProperty::Counts => "counts",
|
||||||
|
HeldMessageProperty::HeldAt => "heldAt",
|
||||||
|
HeldMessageProperty::ExpiresAt => "expiresAt",
|
||||||
|
HeldMessageProperty::Preview => "preview",
|
||||||
|
HeldMessageProperty::Decision => "decision",
|
||||||
|
HeldMessageProperty::Note => "note",
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HeldMessageProperty {
|
||||||
|
fn parse(value: &str) -> Option<Self> {
|
||||||
|
hashify::tiny_map!(value.as_bytes(),
|
||||||
|
b"id" => HeldMessageProperty::Id,
|
||||||
|
b"sender" => HeldMessageProperty::Sender,
|
||||||
|
b"recipients" => HeldMessageProperty::Recipients,
|
||||||
|
b"subject" => HeldMessageProperty::Subject,
|
||||||
|
b"size" => HeldMessageProperty::Size,
|
||||||
|
b"rules" => HeldMessageProperty::Rules,
|
||||||
|
b"counts" => HeldMessageProperty::Counts,
|
||||||
|
b"heldAt" => HeldMessageProperty::HeldAt,
|
||||||
|
b"expiresAt" => HeldMessageProperty::ExpiresAt,
|
||||||
|
b"preview" => HeldMessageProperty::Preview,
|
||||||
|
b"decision" => HeldMessageProperty::Decision,
|
||||||
|
b"note" => HeldMessageProperty::Note,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for HeldMessageProperty {
|
||||||
|
type Err = ();
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
HeldMessageProperty::parse(s).ok_or(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element for HeldMessageValue {
|
||||||
|
type Property = HeldMessageProperty;
|
||||||
|
|
||||||
|
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||||
|
match key {
|
||||||
|
Key::Property(HeldMessageProperty::Id) => {
|
||||||
|
Id::from_str(value).ok().map(HeldMessageValue::Id)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
HeldMessageValue::Id(id) => id.to_string().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The set call's own argument: why, for the audit log (required).
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct HeldMessageSetArguments {
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> DeserializeArguments<'de> for HeldMessageSetArguments {
|
||||||
|
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 HeldMessage {
|
||||||
|
type Property = HeldMessageProperty;
|
||||||
|
|
||||||
|
type Element = HeldMessageValue;
|
||||||
|
|
||||||
|
type Id = Id;
|
||||||
|
|
||||||
|
type Filter = ();
|
||||||
|
|
||||||
|
type Comparator = ();
|
||||||
|
|
||||||
|
type GetArguments = ();
|
||||||
|
|
||||||
|
type SetArguments<'de> = HeldMessageSetArguments;
|
||||||
|
|
||||||
|
type QueryArguments = ();
|
||||||
|
|
||||||
|
type CopyArguments = ();
|
||||||
|
|
||||||
|
type ParseArguments = ();
|
||||||
|
|
||||||
|
const ID_PROPERTY: Self::Property = HeldMessageProperty::Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Id> for HeldMessageValue {
|
||||||
|
fn from(id: Id) -> Self {
|
||||||
|
HeldMessageValue::Id(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for HeldMessageValue {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
match self {
|
||||||
|
HeldMessageValue::Id(id) => Some(*id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
match self {
|
||||||
|
HeldMessageValue::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 = HeldMessageValue::Id(id);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for HeldMessageProperty {
|
||||||
|
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,217 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:Journal/get` and `/set` under `urn:inbuxa:jmap`: journals
|
||||||
|
//! (journaling spec, JR-9, JR-12). What a journal has taken stays when the
|
||||||
|
//! journal changes or goes; each entry keeps its own retention.
|
||||||
|
|
||||||
|
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 Journal;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum JournalProperty {
|
||||||
|
Id,
|
||||||
|
Name,
|
||||||
|
Description,
|
||||||
|
Enabled,
|
||||||
|
/// `outgoing`, `incoming`, `internal` or `any`.
|
||||||
|
Direction,
|
||||||
|
/// Everyone, or chosen accounts, groups, domains and tenants.
|
||||||
|
Scope,
|
||||||
|
/// How long an entry is kept; each keeps what it was written with.
|
||||||
|
RetentionDays,
|
||||||
|
/// Whether entries go into the built-in journal.
|
||||||
|
BuiltIn,
|
||||||
|
/// An outside archive's journal address.
|
||||||
|
ArchiveAddress,
|
||||||
|
/// Reports the archive didn't take: how many, when and why last.
|
||||||
|
ArchiveFailures,
|
||||||
|
CreatedBy,
|
||||||
|
CreatedAt,
|
||||||
|
UpdatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum JournalValue {
|
||||||
|
Id(Id),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Property for JournalProperty {
|
||||||
|
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||||
|
// Keys inside the scope stay plain keys
|
||||||
|
match parent {
|
||||||
|
None => JournalProperty::parse(value),
|
||||||
|
Some(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
JournalProperty::Id => "id",
|
||||||
|
JournalProperty::Name => "name",
|
||||||
|
JournalProperty::Description => "description",
|
||||||
|
JournalProperty::Enabled => "enabled",
|
||||||
|
JournalProperty::Direction => "direction",
|
||||||
|
JournalProperty::Scope => "scope",
|
||||||
|
JournalProperty::RetentionDays => "retentionDays",
|
||||||
|
JournalProperty::BuiltIn => "builtIn",
|
||||||
|
JournalProperty::ArchiveAddress => "archiveAddress",
|
||||||
|
JournalProperty::ArchiveFailures => "archiveFailures",
|
||||||
|
JournalProperty::CreatedBy => "createdBy",
|
||||||
|
JournalProperty::CreatedAt => "createdAt",
|
||||||
|
JournalProperty::UpdatedAt => "updatedAt",
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JournalProperty {
|
||||||
|
fn parse(value: &str) -> Option<Self> {
|
||||||
|
hashify::tiny_map!(value.as_bytes(),
|
||||||
|
b"id" => JournalProperty::Id,
|
||||||
|
b"name" => JournalProperty::Name,
|
||||||
|
b"description" => JournalProperty::Description,
|
||||||
|
b"enabled" => JournalProperty::Enabled,
|
||||||
|
b"direction" => JournalProperty::Direction,
|
||||||
|
b"scope" => JournalProperty::Scope,
|
||||||
|
b"retentionDays" => JournalProperty::RetentionDays,
|
||||||
|
b"builtIn" => JournalProperty::BuiltIn,
|
||||||
|
b"archiveAddress" => JournalProperty::ArchiveAddress,
|
||||||
|
b"archiveFailures" => JournalProperty::ArchiveFailures,
|
||||||
|
b"createdBy" => JournalProperty::CreatedBy,
|
||||||
|
b"createdAt" => JournalProperty::CreatedAt,
|
||||||
|
b"updatedAt" => JournalProperty::UpdatedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for JournalProperty {
|
||||||
|
type Err = ();
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
JournalProperty::parse(s).ok_or(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element for JournalValue {
|
||||||
|
type Property = JournalProperty;
|
||||||
|
|
||||||
|
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||||
|
match key {
|
||||||
|
Key::Property(JournalProperty::Id) => Id::from_str(value).ok().map(JournalValue::Id),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
JournalValue::Id(id) => id.to_string().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The set call's own argument: why, for the audit log.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct JournalSetArguments {
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> DeserializeArguments<'de> for JournalSetArguments {
|
||||||
|
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 Journal {
|
||||||
|
type Property = JournalProperty;
|
||||||
|
|
||||||
|
type Element = JournalValue;
|
||||||
|
|
||||||
|
type Id = Id;
|
||||||
|
|
||||||
|
type Filter = ();
|
||||||
|
|
||||||
|
type Comparator = ();
|
||||||
|
|
||||||
|
type GetArguments = ();
|
||||||
|
|
||||||
|
type SetArguments<'de> = JournalSetArguments;
|
||||||
|
|
||||||
|
type QueryArguments = ();
|
||||||
|
|
||||||
|
type CopyArguments = ();
|
||||||
|
|
||||||
|
type ParseArguments = ();
|
||||||
|
|
||||||
|
const ID_PROPERTY: Self::Property = JournalProperty::Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Id> for JournalValue {
|
||||||
|
fn from(id: Id) -> Self {
|
||||||
|
JournalValue::Id(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for JournalValue {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
match self {
|
||||||
|
JournalValue::Id(id) => Some(*id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
match self {
|
||||||
|
JournalValue::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 = JournalValue::Id(id);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for JournalProperty {
|
||||||
|
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,340 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! The journal's JMAP objects under `urn:inbuxa:jmap` (journaling spec,
|
||||||
|
//! JR-6, JR-15 to JR-17):
|
||||||
|
//!
|
||||||
|
//! - `inbuxa:JournalEntry/get` and `/query`: what was journaled, read-only.
|
||||||
|
//! `report` (the whole journal report) comes only when asked for.
|
||||||
|
//! - `inbuxa:JournalExport/set`: create one to get a ZIP of the reports a
|
||||||
|
//! filter matches.
|
||||||
|
//! - `inbuxa:JournalVerification/set`: create one to recheck every chain.
|
||||||
|
//!
|
||||||
|
//! They share one set of properties. Nested values (an export's filter, a
|
||||||
|
//! verification's chains) are plain JSON objects.
|
||||||
|
|
||||||
|
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 JournalEntry;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct JournalExport;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct JournalVerification;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum JournalEntryProperty {
|
||||||
|
Id,
|
||||||
|
ReceivedAt,
|
||||||
|
Direction,
|
||||||
|
Sender,
|
||||||
|
Authenticated,
|
||||||
|
Recipients,
|
||||||
|
Subject,
|
||||||
|
MessageId,
|
||||||
|
JournalIds,
|
||||||
|
Held,
|
||||||
|
Size,
|
||||||
|
Sha256,
|
||||||
|
ExpiresAt,
|
||||||
|
Report,
|
||||||
|
Filter,
|
||||||
|
Reason,
|
||||||
|
BlobId,
|
||||||
|
Count,
|
||||||
|
Verified,
|
||||||
|
Chains,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum JournalEntryValue {
|
||||||
|
Id(Id),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Property for JournalEntryProperty {
|
||||||
|
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||||
|
// Keys inside a filter or a chain report stay plain keys
|
||||||
|
match parent {
|
||||||
|
None => JournalEntryProperty::parse(value),
|
||||||
|
Some(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
JournalEntryProperty::Id => "id",
|
||||||
|
JournalEntryProperty::ReceivedAt => "receivedAt",
|
||||||
|
JournalEntryProperty::Direction => "direction",
|
||||||
|
JournalEntryProperty::Sender => "sender",
|
||||||
|
JournalEntryProperty::Authenticated => "authenticated",
|
||||||
|
JournalEntryProperty::Recipients => "recipients",
|
||||||
|
JournalEntryProperty::Subject => "subject",
|
||||||
|
JournalEntryProperty::MessageId => "messageId",
|
||||||
|
JournalEntryProperty::JournalIds => "journalIds",
|
||||||
|
JournalEntryProperty::Held => "held",
|
||||||
|
JournalEntryProperty::Size => "size",
|
||||||
|
JournalEntryProperty::Sha256 => "sha256",
|
||||||
|
JournalEntryProperty::ExpiresAt => "expiresAt",
|
||||||
|
JournalEntryProperty::Report => "report",
|
||||||
|
JournalEntryProperty::Filter => "filter",
|
||||||
|
JournalEntryProperty::Reason => "reason",
|
||||||
|
JournalEntryProperty::BlobId => "blobId",
|
||||||
|
JournalEntryProperty::Count => "count",
|
||||||
|
JournalEntryProperty::Verified => "verified",
|
||||||
|
JournalEntryProperty::Chains => "chains",
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JournalEntryProperty {
|
||||||
|
fn parse(value: &str) -> Option<Self> {
|
||||||
|
hashify::tiny_map!(value.as_bytes(),
|
||||||
|
b"id" => JournalEntryProperty::Id,
|
||||||
|
b"receivedAt" => JournalEntryProperty::ReceivedAt,
|
||||||
|
b"direction" => JournalEntryProperty::Direction,
|
||||||
|
b"sender" => JournalEntryProperty::Sender,
|
||||||
|
b"authenticated" => JournalEntryProperty::Authenticated,
|
||||||
|
b"recipients" => JournalEntryProperty::Recipients,
|
||||||
|
b"subject" => JournalEntryProperty::Subject,
|
||||||
|
b"messageId" => JournalEntryProperty::MessageId,
|
||||||
|
b"journalIds" => JournalEntryProperty::JournalIds,
|
||||||
|
b"held" => JournalEntryProperty::Held,
|
||||||
|
b"size" => JournalEntryProperty::Size,
|
||||||
|
b"sha256" => JournalEntryProperty::Sha256,
|
||||||
|
b"expiresAt" => JournalEntryProperty::ExpiresAt,
|
||||||
|
b"report" => JournalEntryProperty::Report,
|
||||||
|
b"filter" => JournalEntryProperty::Filter,
|
||||||
|
b"reason" => JournalEntryProperty::Reason,
|
||||||
|
b"blobId" => JournalEntryProperty::BlobId,
|
||||||
|
b"count" => JournalEntryProperty::Count,
|
||||||
|
b"verified" => JournalEntryProperty::Verified,
|
||||||
|
b"chains" => JournalEntryProperty::Chains,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for JournalEntryProperty {
|
||||||
|
type Err = ();
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
JournalEntryProperty::parse(s).ok_or(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element for JournalEntryValue {
|
||||||
|
type Property = JournalEntryProperty;
|
||||||
|
|
||||||
|
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||||
|
match key {
|
||||||
|
Key::Property(JournalEntryProperty::Id) => {
|
||||||
|
Id::from_str(value).ok().map(JournalEntryValue::Id)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
JournalEntryValue::Id(id) => id.to_string().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One condition of an `inbuxa:JournalEntry/query` filter. Several in one
|
||||||
|
/// filter object must all hold.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum JournalFilter {
|
||||||
|
/// From this time on (UTC date).
|
||||||
|
After(String),
|
||||||
|
/// Before this time (UTC date).
|
||||||
|
Before(String),
|
||||||
|
/// Part of the sender's address.
|
||||||
|
Sender(String),
|
||||||
|
/// Part of a recipient's address.
|
||||||
|
Recipient(String),
|
||||||
|
/// Part of the sender's or a recipient's address.
|
||||||
|
Address(String),
|
||||||
|
/// `outgoing`, `incoming` or `internal`.
|
||||||
|
Direction(String),
|
||||||
|
/// Words that must all be in the subject.
|
||||||
|
Text(String),
|
||||||
|
MessageId(String),
|
||||||
|
JournalId(Id),
|
||||||
|
_T(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for JournalFilter {
|
||||||
|
fn default() -> Self {
|
||||||
|
JournalFilter::_T(String::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> DeserializeArguments<'de> for JournalFilter {
|
||||||
|
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
|
||||||
|
where
|
||||||
|
A: serde::de::MapAccess<'de>,
|
||||||
|
{
|
||||||
|
hashify::fnc_map!(key.as_bytes(),
|
||||||
|
b"after" => {
|
||||||
|
*self = JournalFilter::After(map.next_value()?);
|
||||||
|
},
|
||||||
|
b"before" => {
|
||||||
|
*self = JournalFilter::Before(map.next_value()?);
|
||||||
|
},
|
||||||
|
b"sender" => {
|
||||||
|
*self = JournalFilter::Sender(map.next_value()?);
|
||||||
|
},
|
||||||
|
b"recipient" => {
|
||||||
|
*self = JournalFilter::Recipient(map.next_value()?);
|
||||||
|
},
|
||||||
|
b"address" => {
|
||||||
|
*self = JournalFilter::Address(map.next_value()?);
|
||||||
|
},
|
||||||
|
b"direction" => {
|
||||||
|
*self = JournalFilter::Direction(map.next_value()?);
|
||||||
|
},
|
||||||
|
b"text" => {
|
||||||
|
*self = JournalFilter::Text(map.next_value()?);
|
||||||
|
},
|
||||||
|
b"messageId" => {
|
||||||
|
*self = JournalFilter::MessageId(map.next_value()?);
|
||||||
|
},
|
||||||
|
b"journalId" => {
|
||||||
|
*self = JournalFilter::JournalId(map.next_value()?);
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
*self = JournalFilter::_T(key.to_string());
|
||||||
|
let _ = map.next_value::<serde::de::IgnoredAny>()?;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entries sort newest first, by `receivedAt`; nothing else.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum JournalComparator {
|
||||||
|
ReceivedAt,
|
||||||
|
_T(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for JournalComparator {
|
||||||
|
fn default() -> Self {
|
||||||
|
JournalComparator::_T(String::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> DeserializeArguments<'de> for JournalComparator {
|
||||||
|
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
|
||||||
|
where
|
||||||
|
A: serde::de::MapAccess<'de>,
|
||||||
|
{
|
||||||
|
if key == "property" {
|
||||||
|
let value = map.next_value::<Cow<str>>()?;
|
||||||
|
*self = if value == "receivedAt" {
|
||||||
|
JournalComparator::ReceivedAt
|
||||||
|
} else {
|
||||||
|
JournalComparator::_T(value.into_owned())
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
let _ = map.next_value::<serde::de::IgnoredAny>()?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! journal_object {
|
||||||
|
($object:ty, $filter:ty, $comparator:ty) => {
|
||||||
|
impl JmapObject for $object {
|
||||||
|
type Property = JournalEntryProperty;
|
||||||
|
|
||||||
|
type Element = JournalEntryValue;
|
||||||
|
|
||||||
|
type Id = Id;
|
||||||
|
|
||||||
|
type Filter = $filter;
|
||||||
|
|
||||||
|
type Comparator = $comparator;
|
||||||
|
|
||||||
|
type GetArguments = ();
|
||||||
|
|
||||||
|
type SetArguments<'de> = ();
|
||||||
|
|
||||||
|
type QueryArguments = ();
|
||||||
|
|
||||||
|
type CopyArguments = ();
|
||||||
|
|
||||||
|
type ParseArguments = ();
|
||||||
|
|
||||||
|
const ID_PROPERTY: Self::Property = JournalEntryProperty::Id;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
journal_object!(JournalEntry, JournalFilter, JournalComparator);
|
||||||
|
journal_object!(JournalExport, (), ());
|
||||||
|
journal_object!(JournalVerification, (), ());
|
||||||
|
|
||||||
|
impl From<Id> for JournalEntryValue {
|
||||||
|
fn from(id: Id) -> Self {
|
||||||
|
JournalEntryValue::Id(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for JournalEntryValue {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
match self {
|
||||||
|
JournalEntryValue::Id(id) => Some(*id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
match self {
|
||||||
|
JournalEntryValue::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 = JournalEntryValue::Id(id);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for JournalEntryProperty {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:MailRule/get` and `/set` under `urn:inbuxa:jmap`: mail flow rules
|
||||||
|
//! and DLP rules (dlp-and-mail-flow-rules spec, §2.2). `kind` says which,
|
||||||
|
//! and which permissions reach it. The set call's `reason` argument, if
|
||||||
|
//! given, goes into the audit log with the change.
|
||||||
|
|
||||||
|
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 MailRule;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum MailRuleProperty {
|
||||||
|
Id,
|
||||||
|
Name,
|
||||||
|
Description,
|
||||||
|
/// `dlp` or `transport`.
|
||||||
|
Kind,
|
||||||
|
Enabled,
|
||||||
|
/// Lower runs first.
|
||||||
|
Priority,
|
||||||
|
/// `outgoing`, `incoming` or `any`.
|
||||||
|
Direction,
|
||||||
|
Conditions,
|
||||||
|
Exceptions,
|
||||||
|
Actions,
|
||||||
|
StopProcessing,
|
||||||
|
CreatedBy,
|
||||||
|
CreatedAt,
|
||||||
|
UpdatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum MailRuleValue {
|
||||||
|
Id(Id),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Property for MailRuleProperty {
|
||||||
|
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||||
|
// Keys inside conditions and actions stay plain keys
|
||||||
|
match parent {
|
||||||
|
None => MailRuleProperty::parse(value),
|
||||||
|
Some(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
MailRuleProperty::Id => "id",
|
||||||
|
MailRuleProperty::Name => "name",
|
||||||
|
MailRuleProperty::Description => "description",
|
||||||
|
MailRuleProperty::Kind => "kind",
|
||||||
|
MailRuleProperty::Enabled => "enabled",
|
||||||
|
MailRuleProperty::Priority => "priority",
|
||||||
|
MailRuleProperty::Direction => "direction",
|
||||||
|
MailRuleProperty::Conditions => "conditions",
|
||||||
|
MailRuleProperty::Exceptions => "exceptions",
|
||||||
|
MailRuleProperty::Actions => "actions",
|
||||||
|
MailRuleProperty::StopProcessing => "stopProcessing",
|
||||||
|
MailRuleProperty::CreatedBy => "createdBy",
|
||||||
|
MailRuleProperty::CreatedAt => "createdAt",
|
||||||
|
MailRuleProperty::UpdatedAt => "updatedAt",
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MailRuleProperty {
|
||||||
|
fn parse(value: &str) -> Option<Self> {
|
||||||
|
hashify::tiny_map!(value.as_bytes(),
|
||||||
|
b"id" => MailRuleProperty::Id,
|
||||||
|
b"name" => MailRuleProperty::Name,
|
||||||
|
b"description" => MailRuleProperty::Description,
|
||||||
|
b"kind" => MailRuleProperty::Kind,
|
||||||
|
b"enabled" => MailRuleProperty::Enabled,
|
||||||
|
b"priority" => MailRuleProperty::Priority,
|
||||||
|
b"direction" => MailRuleProperty::Direction,
|
||||||
|
b"conditions" => MailRuleProperty::Conditions,
|
||||||
|
b"exceptions" => MailRuleProperty::Exceptions,
|
||||||
|
b"actions" => MailRuleProperty::Actions,
|
||||||
|
b"stopProcessing" => MailRuleProperty::StopProcessing,
|
||||||
|
b"createdBy" => MailRuleProperty::CreatedBy,
|
||||||
|
b"createdAt" => MailRuleProperty::CreatedAt,
|
||||||
|
b"updatedAt" => MailRuleProperty::UpdatedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for MailRuleProperty {
|
||||||
|
type Err = ();
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
MailRuleProperty::parse(s).ok_or(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element for MailRuleValue {
|
||||||
|
type Property = MailRuleProperty;
|
||||||
|
|
||||||
|
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||||
|
match key {
|
||||||
|
Key::Property(MailRuleProperty::Id) => Id::from_str(value).ok().map(MailRuleValue::Id),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
MailRuleValue::Id(id) => id.to_string().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The set call's own argument: why, for the audit log.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct MailRuleSetArguments {
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> DeserializeArguments<'de> for MailRuleSetArguments {
|
||||||
|
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 MailRule {
|
||||||
|
type Property = MailRuleProperty;
|
||||||
|
|
||||||
|
type Element = MailRuleValue;
|
||||||
|
|
||||||
|
type Id = Id;
|
||||||
|
|
||||||
|
type Filter = ();
|
||||||
|
|
||||||
|
type Comparator = ();
|
||||||
|
|
||||||
|
type GetArguments = ();
|
||||||
|
|
||||||
|
type SetArguments<'de> = MailRuleSetArguments;
|
||||||
|
|
||||||
|
type QueryArguments = ();
|
||||||
|
|
||||||
|
type CopyArguments = ();
|
||||||
|
|
||||||
|
type ParseArguments = ();
|
||||||
|
|
||||||
|
const ID_PROPERTY: Self::Property = MailRuleProperty::Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Id> for MailRuleValue {
|
||||||
|
fn from(id: Id) -> Self {
|
||||||
|
MailRuleValue::Id(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for MailRuleValue {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
match self {
|
||||||
|
MailRuleValue::Id(id) => Some(*id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
match self {
|
||||||
|
MailRuleValue::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 = MailRuleValue::Id(id);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for MailRuleProperty {
|
||||||
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
pub enum ProtocolPolicyProperty {
|
pub enum ProtocolPolicyProperty {
|
||||||
Id,
|
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,
|
LegacyProtocols,
|
||||||
|
/// Each protocol's own switch: `enabled` or `disabled`.
|
||||||
|
Imap,
|
||||||
|
Pop3,
|
||||||
|
ManageSieve,
|
||||||
/// Whether submission closes with it. Forced false while SMTP is locked.
|
/// Whether submission closes with it. Forced false while SMTP is locked.
|
||||||
CloseSubmission,
|
CloseSubmission,
|
||||||
/// Server-set: the listeners taken away, for LP-5.
|
/// Server-set: the listeners taken away, for LP-5.
|
||||||
@@ -56,6 +61,9 @@ impl Property for ProtocolPolicyProperty {
|
|||||||
match self {
|
match self {
|
||||||
ProtocolPolicyProperty::Id => "id",
|
ProtocolPolicyProperty::Id => "id",
|
||||||
ProtocolPolicyProperty::LegacyProtocols => "legacyProtocols",
|
ProtocolPolicyProperty::LegacyProtocols => "legacyProtocols",
|
||||||
|
ProtocolPolicyProperty::Imap => "imap",
|
||||||
|
ProtocolPolicyProperty::Pop3 => "pop3",
|
||||||
|
ProtocolPolicyProperty::ManageSieve => "manageSieve",
|
||||||
ProtocolPolicyProperty::CloseSubmission => "closeSubmission",
|
ProtocolPolicyProperty::CloseSubmission => "closeSubmission",
|
||||||
ProtocolPolicyProperty::SavedListeners => "savedListeners",
|
ProtocolPolicyProperty::SavedListeners => "savedListeners",
|
||||||
ProtocolPolicyProperty::ChangedAt => "changedAt",
|
ProtocolPolicyProperty::ChangedAt => "changedAt",
|
||||||
@@ -73,6 +81,9 @@ impl ProtocolPolicyProperty {
|
|||||||
hashify::tiny_map!(value.as_bytes(),
|
hashify::tiny_map!(value.as_bytes(),
|
||||||
b"id" => ProtocolPolicyProperty::Id,
|
b"id" => ProtocolPolicyProperty::Id,
|
||||||
b"legacyProtocols" => ProtocolPolicyProperty::LegacyProtocols,
|
b"legacyProtocols" => ProtocolPolicyProperty::LegacyProtocols,
|
||||||
|
b"imap" => ProtocolPolicyProperty::Imap,
|
||||||
|
b"pop3" => ProtocolPolicyProperty::Pop3,
|
||||||
|
b"manageSieve" => ProtocolPolicyProperty::ManageSieve,
|
||||||
b"closeSubmission" => ProtocolPolicyProperty::CloseSubmission,
|
b"closeSubmission" => ProtocolPolicyProperty::CloseSubmission,
|
||||||
b"savedListeners" => ProtocolPolicyProperty::SavedListeners,
|
b"savedListeners" => ProtocolPolicyProperty::SavedListeners,
|
||||||
b"changedAt" => ProtocolPolicyProperty::ChangedAt,
|
b"changedAt" => ProtocolPolicyProperty::ChangedAt,
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:SecurityAcceptance/get` and `/set` under `urn:inbuxa:jmap`: the
|
||||||
|
//! security to-do items an administrator accepted, with why (security
|
||||||
|
//! to-do list spec, SS-23 to SS-26). Created and destroyed, never updated.
|
||||||
|
|
||||||
|
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 SecurityAcceptance;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum SecurityAcceptanceProperty {
|
||||||
|
Id,
|
||||||
|
/// `SS-1` to `SS-18`.
|
||||||
|
Check,
|
||||||
|
Subject,
|
||||||
|
AcceptedValue,
|
||||||
|
Note,
|
||||||
|
AcceptedBy,
|
||||||
|
AcceptedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum SecurityAcceptanceValue {
|
||||||
|
Id(Id),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Property for SecurityAcceptanceProperty {
|
||||||
|
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||||
|
// Keys inside acceptedValue stay plain keys
|
||||||
|
match parent {
|
||||||
|
None => SecurityAcceptanceProperty::parse(value),
|
||||||
|
Some(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
SecurityAcceptanceProperty::Id => "id",
|
||||||
|
SecurityAcceptanceProperty::Check => "check",
|
||||||
|
SecurityAcceptanceProperty::Subject => "subject",
|
||||||
|
SecurityAcceptanceProperty::AcceptedValue => "acceptedValue",
|
||||||
|
SecurityAcceptanceProperty::Note => "note",
|
||||||
|
SecurityAcceptanceProperty::AcceptedBy => "acceptedBy",
|
||||||
|
SecurityAcceptanceProperty::AcceptedAt => "acceptedAt",
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecurityAcceptanceProperty {
|
||||||
|
fn parse(value: &str) -> Option<Self> {
|
||||||
|
hashify::tiny_map!(value.as_bytes(),
|
||||||
|
b"id" => SecurityAcceptanceProperty::Id,
|
||||||
|
b"check" => SecurityAcceptanceProperty::Check,
|
||||||
|
b"subject" => SecurityAcceptanceProperty::Subject,
|
||||||
|
b"acceptedValue" => SecurityAcceptanceProperty::AcceptedValue,
|
||||||
|
b"note" => SecurityAcceptanceProperty::Note,
|
||||||
|
b"acceptedBy" => SecurityAcceptanceProperty::AcceptedBy,
|
||||||
|
b"acceptedAt" => SecurityAcceptanceProperty::AcceptedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for SecurityAcceptanceProperty {
|
||||||
|
type Err = ();
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
SecurityAcceptanceProperty::parse(s).ok_or(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element for SecurityAcceptanceValue {
|
||||||
|
type Property = SecurityAcceptanceProperty;
|
||||||
|
|
||||||
|
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||||
|
match key {
|
||||||
|
Key::Property(SecurityAcceptanceProperty::Id) => {
|
||||||
|
Id::from_str(value).ok().map(SecurityAcceptanceValue::Id)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
SecurityAcceptanceValue::Id(id) => id.to_string().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObject for SecurityAcceptance {
|
||||||
|
type Property = SecurityAcceptanceProperty;
|
||||||
|
|
||||||
|
type Element = SecurityAcceptanceValue;
|
||||||
|
|
||||||
|
type Id = Id;
|
||||||
|
|
||||||
|
type Filter = ();
|
||||||
|
|
||||||
|
type Comparator = ();
|
||||||
|
|
||||||
|
type GetArguments = ();
|
||||||
|
|
||||||
|
type SetArguments<'de> = ();
|
||||||
|
|
||||||
|
type QueryArguments = ();
|
||||||
|
|
||||||
|
type CopyArguments = ();
|
||||||
|
|
||||||
|
type ParseArguments = ();
|
||||||
|
|
||||||
|
const ID_PROPERTY: Self::Property = SecurityAcceptanceProperty::Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Id> for SecurityAcceptanceValue {
|
||||||
|
fn from(id: Id) -> Self {
|
||||||
|
SecurityAcceptanceValue::Id(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for SecurityAcceptanceValue {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
match self {
|
||||||
|
SecurityAcceptanceValue::Id(id) => Some(*id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
match self {
|
||||||
|
SecurityAcceptanceValue::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 = SecurityAcceptanceValue::Id(id);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for SecurityAcceptanceProperty {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,8 +24,13 @@ pub enum TenantProtocolPolicyProperty {
|
|||||||
Id,
|
Id,
|
||||||
/// Server-set: the tenant this is the switch of.
|
/// Server-set: the tenant this is the switch of.
|
||||||
TenantId,
|
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,
|
LegacyProtocols,
|
||||||
|
/// Each protocol's own switch: `enabled` or `disabled`.
|
||||||
|
Imap,
|
||||||
|
Pop3,
|
||||||
|
ManageSieve,
|
||||||
ChangedAt,
|
ChangedAt,
|
||||||
ChangedBy,
|
ChangedBy,
|
||||||
/// Server-set: who signed in over a legacy protocol in the last 30
|
/// 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::Id => "id",
|
||||||
TenantProtocolPolicyProperty::TenantId => "tenantId",
|
TenantProtocolPolicyProperty::TenantId => "tenantId",
|
||||||
TenantProtocolPolicyProperty::LegacyProtocols => "legacyProtocols",
|
TenantProtocolPolicyProperty::LegacyProtocols => "legacyProtocols",
|
||||||
|
TenantProtocolPolicyProperty::Imap => "imap",
|
||||||
|
TenantProtocolPolicyProperty::Pop3 => "pop3",
|
||||||
|
TenantProtocolPolicyProperty::ManageSieve => "manageSieve",
|
||||||
TenantProtocolPolicyProperty::ChangedAt => "changedAt",
|
TenantProtocolPolicyProperty::ChangedAt => "changedAt",
|
||||||
TenantProtocolPolicyProperty::ChangedBy => "changedBy",
|
TenantProtocolPolicyProperty::ChangedBy => "changedBy",
|
||||||
TenantProtocolPolicyProperty::RecentLegacyUse => "recentLegacyUse",
|
TenantProtocolPolicyProperty::RecentLegacyUse => "recentLegacyUse",
|
||||||
@@ -62,6 +70,9 @@ impl TenantProtocolPolicyProperty {
|
|||||||
b"id" => TenantProtocolPolicyProperty::Id,
|
b"id" => TenantProtocolPolicyProperty::Id,
|
||||||
b"tenantId" => TenantProtocolPolicyProperty::TenantId,
|
b"tenantId" => TenantProtocolPolicyProperty::TenantId,
|
||||||
b"legacyProtocols" => TenantProtocolPolicyProperty::LegacyProtocols,
|
b"legacyProtocols" => TenantProtocolPolicyProperty::LegacyProtocols,
|
||||||
|
b"imap" => TenantProtocolPolicyProperty::Imap,
|
||||||
|
b"pop3" => TenantProtocolPolicyProperty::Pop3,
|
||||||
|
b"manageSieve" => TenantProtocolPolicyProperty::ManageSieve,
|
||||||
b"changedAt" => TenantProtocolPolicyProperty::ChangedAt,
|
b"changedAt" => TenantProtocolPolicyProperty::ChangedAt,
|
||||||
b"changedBy" => TenantProtocolPolicyProperty::ChangedBy,
|
b"changedBy" => TenantProtocolPolicyProperty::ChangedBy,
|
||||||
b"recentLegacyUse" => TenantProtocolPolicyProperty::RecentLegacyUse,
|
b"recentLegacyUse" => TenantProtocolPolicyProperty::RecentLegacyUse,
|
||||||
|
|||||||
@@ -23,8 +23,17 @@ pub mod email_submission;
|
|||||||
pub mod fastmail_masked_email; // inbuxa: masked email
|
pub mod fastmail_masked_email; // inbuxa: masked email
|
||||||
pub mod inbuxa_account_lock; // inbuxa: account lock with delegation
|
pub mod inbuxa_account_lock; // inbuxa: account lock with delegation
|
||||||
pub mod inbuxa_ai_limits; // inbuxa: AI spam classification
|
pub mod inbuxa_ai_limits; // inbuxa: AI spam classification
|
||||||
|
pub mod inbuxa_log_settings; // inbuxa: personal-data catalog, D1
|
||||||
|
pub mod inbuxa_dlp_settings; // inbuxa: DLP settings
|
||||||
|
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_audit; // inbuxa: the audit log
|
||||||
pub mod inbuxa_legal_hold; // inbuxa: legal hold
|
pub mod inbuxa_legal_hold; // inbuxa: legal hold
|
||||||
|
pub mod inbuxa_mail_rule; // inbuxa: DLP and mail flow rules
|
||||||
|
pub mod inbuxa_security_acceptance; // inbuxa: accepted security to-do items
|
||||||
|
pub mod inbuxa_journal; // inbuxa: journaling
|
||||||
|
pub mod inbuxa_journal_entry; // inbuxa: journaling, search and export
|
||||||
|
pub mod inbuxa_held_message; // inbuxa: mail held for review
|
||||||
pub mod inbuxa_hold_export; // inbuxa: legal hold exports
|
pub mod inbuxa_hold_export; // inbuxa: legal hold exports
|
||||||
pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model
|
pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model
|
||||||
pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off
|
pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off
|
||||||
|
|||||||
@@ -61,6 +61,18 @@ impl Response<'_> {
|
|||||||
GetResponseMethod::AiLimits(response) => {
|
GetResponseMethod::AiLimits(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
response.eval_jptr(path, &mut results)
|
||||||
}
|
}
|
||||||
|
GetResponseMethod::LogSettings(response) => {
|
||||||
|
response.eval_jptr(path, &mut results)
|
||||||
|
}
|
||||||
|
GetResponseMethod::DlpSettings(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) => {
|
GetResponseMethod::AuditEvent(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
response.eval_jptr(path, &mut results)
|
||||||
}
|
}
|
||||||
@@ -73,6 +85,21 @@ impl Response<'_> {
|
|||||||
GetResponseMethod::LegalHold(response) => {
|
GetResponseMethod::LegalHold(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
response.eval_jptr(path, &mut results)
|
||||||
}
|
}
|
||||||
|
GetResponseMethod::MailRule(response) => {
|
||||||
|
response.eval_jptr(path, &mut results)
|
||||||
|
}
|
||||||
|
GetResponseMethod::SecurityAcceptance(response) => {
|
||||||
|
response.eval_jptr(path, &mut results)
|
||||||
|
}
|
||||||
|
GetResponseMethod::Journal(response) => {
|
||||||
|
response.eval_jptr(path, &mut results)
|
||||||
|
}
|
||||||
|
GetResponseMethod::JournalEntry(response) => {
|
||||||
|
response.eval_jptr(path, &mut results)
|
||||||
|
}
|
||||||
|
GetResponseMethod::HeldMessage(response) => {
|
||||||
|
response.eval_jptr(path, &mut results)
|
||||||
|
}
|
||||||
GetResponseMethod::HoldExport(response) => {
|
GetResponseMethod::HoldExport(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
response.eval_jptr(path, &mut results)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,10 +46,19 @@ impl Response<'_> {
|
|||||||
GetRequestMethod::MaskedEmail(request) => request.resolve_references(self)?,
|
GetRequestMethod::MaskedEmail(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::DeletedAccount(request) => request.resolve_references(self)?,
|
GetRequestMethod::DeletedAccount(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::AiLimits(request) => request.resolve_references(self)?,
|
GetRequestMethod::AiLimits(request) => request.resolve_references(self)?,
|
||||||
|
GetRequestMethod::LogSettings(request) => request.resolve_references(self)?,
|
||||||
|
GetRequestMethod::DlpSettings(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::AuditEvent(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::AuditSettings(request) => request.resolve_references(self)?,
|
GetRequestMethod::AuditSettings(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::AccountLock(request) => request.resolve_references(self)?,
|
GetRequestMethod::AccountLock(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::LegalHold(request) => request.resolve_references(self)?,
|
GetRequestMethod::LegalHold(request) => request.resolve_references(self)?,
|
||||||
|
GetRequestMethod::MailRule(request) => request.resolve_references(self)?,
|
||||||
|
GetRequestMethod::SecurityAcceptance(request) => request.resolve_references(self)?,
|
||||||
|
GetRequestMethod::Journal(request) => request.resolve_references(self)?,
|
||||||
|
GetRequestMethod::JournalEntry(request) => request.resolve_references(self)?,
|
||||||
|
GetRequestMethod::HeldMessage(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::HoldExport(request) => request.resolve_references(self)?,
|
GetRequestMethod::HoldExport(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?,
|
GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::TenantProtocolPolicy(request) => {
|
GetRequestMethod::TenantProtocolPolicy(request) => {
|
||||||
@@ -98,6 +107,12 @@ impl Response<'_> {
|
|||||||
SetRequestMethod::AiLimits(request) => {
|
SetRequestMethod::AiLimits(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
request.resolve_references(self, 1, false)?
|
||||||
}
|
}
|
||||||
|
SetRequestMethod::LogSettings(request) => {
|
||||||
|
request.resolve_references(self, 1, false)?
|
||||||
|
}
|
||||||
|
SetRequestMethod::DlpSettings(request) => {
|
||||||
|
request.resolve_references(self, 1, false)?
|
||||||
|
}
|
||||||
SetRequestMethod::Explanation(request) => {
|
SetRequestMethod::Explanation(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
request.resolve_references(self, 1, false)?
|
||||||
}
|
}
|
||||||
@@ -116,6 +131,24 @@ impl Response<'_> {
|
|||||||
SetRequestMethod::LegalHold(request) => {
|
SetRequestMethod::LegalHold(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
request.resolve_references(self, 1, false)?
|
||||||
}
|
}
|
||||||
|
SetRequestMethod::MailRule(request) => {
|
||||||
|
request.resolve_references(self, 1, false)?
|
||||||
|
}
|
||||||
|
SetRequestMethod::SecurityAcceptance(request) => {
|
||||||
|
request.resolve_references(self, 1, false)?
|
||||||
|
}
|
||||||
|
SetRequestMethod::Journal(request) => {
|
||||||
|
request.resolve_references(self, 1, false)?
|
||||||
|
}
|
||||||
|
SetRequestMethod::JournalExport(request) => {
|
||||||
|
request.resolve_references(self, 1, false)?
|
||||||
|
}
|
||||||
|
SetRequestMethod::JournalVerification(request) => {
|
||||||
|
request.resolve_references(self, 1, false)?
|
||||||
|
}
|
||||||
|
SetRequestMethod::HeldMessage(request) => {
|
||||||
|
request.resolve_references(self, 1, false)?
|
||||||
|
}
|
||||||
SetRequestMethod::HoldExport(request) => {
|
SetRequestMethod::HoldExport(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
request.resolve_references(self, 1, false)?
|
||||||
}
|
}
|
||||||
@@ -388,6 +421,10 @@ impl ResolveReference for ImportEmailRequest {
|
|||||||
fn resolve_references(&mut self, response: &Response<'_>) -> trc::Result<()> {
|
fn resolve_references(&mut self, response: &Response<'_>) -> trc::Result<()> {
|
||||||
// Resolve email mailbox references
|
// Resolve email mailbox references
|
||||||
for email in self.emails.values_mut() {
|
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 {
|
match &mut email.mailbox_ids {
|
||||||
MaybeResultReference::Reference(reference) => {
|
MaybeResultReference::Reference(reference) => {
|
||||||
email.mailbox_ids = MaybeResultReference::Value(
|
email.mailbox_ids = MaybeResultReference::Value(
|
||||||
|
|||||||
@@ -169,6 +169,11 @@ pub struct InbuxaAccountCapabilities {
|
|||||||
/// (legacy-protocols spec, Interfaces; LP-19).
|
/// (legacy-protocols spec, Interfaces; LP-19).
|
||||||
#[serde(rename(serialize = "legacyProtocols"))]
|
#[serde(rename(serialize = "legacyProtocols"))]
|
||||||
pub legacy_protocols: &'static str,
|
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
|
/// Whether the principal may use "Explain this" now: it holds
|
||||||
/// `sysAiExplain`, is server-level, and a model resolves (ai-explain
|
/// `sysAiExplain`, is server-level, and a model resolves (ai-explain
|
||||||
/// spec, EX-1 to EX-4).
|
/// spec, EX-1 to EX-4).
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ pub enum MethodObject {
|
|||||||
DeletedAccount,
|
DeletedAccount,
|
||||||
// inbuxa: AI call limits
|
// inbuxa: AI call limits
|
||||||
AiLimits,
|
AiLimits,
|
||||||
|
LogSettings,
|
||||||
|
DlpSettings,
|
||||||
|
DataInventory,
|
||||||
|
InventorySnapshot,
|
||||||
// inbuxa: "Explain this" with the local model
|
// inbuxa: "Explain this" with the local model
|
||||||
Explanation,
|
Explanation,
|
||||||
// inbuxa: the audit log
|
// inbuxa: the audit log
|
||||||
@@ -62,6 +66,16 @@ pub enum MethodObject {
|
|||||||
LegalHold,
|
LegalHold,
|
||||||
HoldExport,
|
HoldExport,
|
||||||
ProtocolPolicy,
|
ProtocolPolicy,
|
||||||
|
// inbuxa: DLP and mail flow rules
|
||||||
|
MailRule,
|
||||||
|
// inbuxa: accepted security to-do items
|
||||||
|
SecurityAcceptance,
|
||||||
|
HeldMessage,
|
||||||
|
// inbuxa: journaling
|
||||||
|
Journal,
|
||||||
|
JournalEntry,
|
||||||
|
JournalExport,
|
||||||
|
JournalVerification,
|
||||||
TenantProtocolPolicy,
|
TenantProtocolPolicy,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +103,10 @@ impl MethodObject {
|
|||||||
MethodObject::MaskedEmail => Capability::FastmailMaskedEmail,
|
MethodObject::MaskedEmail => Capability::FastmailMaskedEmail,
|
||||||
MethodObject::DeletedAccount => Capability::Inbuxa,
|
MethodObject::DeletedAccount => Capability::Inbuxa,
|
||||||
MethodObject::AiLimits => Capability::Inbuxa,
|
MethodObject::AiLimits => Capability::Inbuxa,
|
||||||
|
MethodObject::LogSettings => Capability::Inbuxa,
|
||||||
|
MethodObject::DlpSettings => Capability::Inbuxa,
|
||||||
|
MethodObject::DataInventory => Capability::Inbuxa,
|
||||||
|
MethodObject::InventorySnapshot => Capability::Inbuxa,
|
||||||
MethodObject::Explanation => Capability::Inbuxa,
|
MethodObject::Explanation => Capability::Inbuxa,
|
||||||
MethodObject::AuditEvent
|
MethodObject::AuditEvent
|
||||||
| MethodObject::AuditSettings
|
| MethodObject::AuditSettings
|
||||||
@@ -96,7 +114,14 @@ impl MethodObject {
|
|||||||
| MethodObject::AuditVerification
|
| MethodObject::AuditVerification
|
||||||
| MethodObject::AccountLock
|
| MethodObject::AccountLock
|
||||||
| MethodObject::LegalHold
|
| MethodObject::LegalHold
|
||||||
| MethodObject::HoldExport => Capability::Inbuxa,
|
| MethodObject::HoldExport
|
||||||
|
| MethodObject::MailRule
|
||||||
|
| MethodObject::SecurityAcceptance
|
||||||
|
| MethodObject::HeldMessage
|
||||||
|
| MethodObject::Journal
|
||||||
|
| MethodObject::JournalEntry
|
||||||
|
| MethodObject::JournalExport
|
||||||
|
| MethodObject::JournalVerification => Capability::Inbuxa,
|
||||||
MethodObject::ProtocolPolicy => Capability::Inbuxa,
|
MethodObject::ProtocolPolicy => Capability::Inbuxa,
|
||||||
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
|
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
|
||||||
}
|
}
|
||||||
@@ -276,6 +301,12 @@ impl MethodName {
|
|||||||
(MethodFunction::Set, MethodObject::DeletedAccount) => "inbuxa:DeletedAccount/set",
|
(MethodFunction::Set, MethodObject::DeletedAccount) => "inbuxa:DeletedAccount/set",
|
||||||
(MethodFunction::Get, MethodObject::AiLimits) => "inbuxa:AiLimits/get",
|
(MethodFunction::Get, MethodObject::AiLimits) => "inbuxa:AiLimits/get",
|
||||||
(MethodFunction::Set, MethodObject::AiLimits) => "inbuxa:AiLimits/set",
|
(MethodFunction::Set, MethodObject::AiLimits) => "inbuxa:AiLimits/set",
|
||||||
|
(MethodFunction::Get, MethodObject::LogSettings) => "inbuxa:LogSettings/get",
|
||||||
|
(MethodFunction::Get, MethodObject::DlpSettings) => "inbuxa:DlpSettings/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::DlpSettings) => "inbuxa:DlpSettings/set",
|
||||||
(MethodFunction::Set, MethodObject::Explanation) => "inbuxa:Explanation/set",
|
(MethodFunction::Set, MethodObject::Explanation) => "inbuxa:Explanation/set",
|
||||||
(MethodFunction::Get, MethodObject::AuditEvent) => "inbuxa:AuditEvent/get",
|
(MethodFunction::Get, MethodObject::AuditEvent) => "inbuxa:AuditEvent/get",
|
||||||
(MethodFunction::Query, MethodObject::AuditEvent) => "inbuxa:AuditEvent/query",
|
(MethodFunction::Query, MethodObject::AuditEvent) => "inbuxa:AuditEvent/query",
|
||||||
@@ -286,6 +317,20 @@ impl MethodName {
|
|||||||
(MethodFunction::Set, MethodObject::AccountLock) => "inbuxa:AccountLock/set",
|
(MethodFunction::Set, MethodObject::AccountLock) => "inbuxa:AccountLock/set",
|
||||||
(MethodFunction::Get, MethodObject::LegalHold) => "inbuxa:LegalHold/get",
|
(MethodFunction::Get, MethodObject::LegalHold) => "inbuxa:LegalHold/get",
|
||||||
(MethodFunction::Set, MethodObject::LegalHold) => "inbuxa:LegalHold/set",
|
(MethodFunction::Set, MethodObject::LegalHold) => "inbuxa:LegalHold/set",
|
||||||
|
(MethodFunction::Get, MethodObject::MailRule) => "inbuxa:MailRule/get",
|
||||||
|
(MethodFunction::Set, MethodObject::MailRule) => "inbuxa:MailRule/set",
|
||||||
|
(MethodFunction::Get, MethodObject::SecurityAcceptance) => "inbuxa:SecurityAcceptance/get",
|
||||||
|
(MethodFunction::Set, MethodObject::SecurityAcceptance) => "inbuxa:SecurityAcceptance/set",
|
||||||
|
(MethodFunction::Get, MethodObject::Journal) => "inbuxa:Journal/get",
|
||||||
|
(MethodFunction::Set, MethodObject::Journal) => "inbuxa:Journal/set",
|
||||||
|
(MethodFunction::Get, MethodObject::JournalEntry) => "inbuxa:JournalEntry/get",
|
||||||
|
(MethodFunction::Query, MethodObject::JournalEntry) => "inbuxa:JournalEntry/query",
|
||||||
|
(MethodFunction::Set, MethodObject::JournalExport) => "inbuxa:JournalExport/set",
|
||||||
|
(MethodFunction::Set, MethodObject::JournalVerification) => {
|
||||||
|
"inbuxa:JournalVerification/set"
|
||||||
|
}
|
||||||
|
(MethodFunction::Get, MethodObject::HeldMessage) => "inbuxa:HeldMessage/get",
|
||||||
|
(MethodFunction::Set, MethodObject::HeldMessage) => "inbuxa:HeldMessage/set",
|
||||||
(MethodFunction::Get, MethodObject::HoldExport) => "inbuxa:HoldExport/get",
|
(MethodFunction::Get, MethodObject::HoldExport) => "inbuxa:HoldExport/get",
|
||||||
(MethodFunction::Set, MethodObject::HoldExport) => "inbuxa:HoldExport/set",
|
(MethodFunction::Set, MethodObject::HoldExport) => "inbuxa:HoldExport/set",
|
||||||
(MethodFunction::Set, MethodObject::AuditVerification) => {
|
(MethodFunction::Set, MethodObject::AuditVerification) => {
|
||||||
@@ -424,6 +469,12 @@ impl MethodName {
|
|||||||
"inbuxa:DeletedAccount/set" => (MethodObject::DeletedAccount, MethodFunction::Set),
|
"inbuxa:DeletedAccount/set" => (MethodObject::DeletedAccount, MethodFunction::Set),
|
||||||
"inbuxa:AiLimits/get" => (MethodObject::AiLimits, MethodFunction::Get),
|
"inbuxa:AiLimits/get" => (MethodObject::AiLimits, MethodFunction::Get),
|
||||||
"inbuxa:AiLimits/set" => (MethodObject::AiLimits, MethodFunction::Set),
|
"inbuxa:AiLimits/set" => (MethodObject::AiLimits, MethodFunction::Set),
|
||||||
|
"inbuxa:LogSettings/get" => (MethodObject::LogSettings, MethodFunction::Get),
|
||||||
|
"inbuxa:DlpSettings/get" => (MethodObject::DlpSettings, MethodFunction::Get),
|
||||||
|
"inbuxa:DataInventory/get" => (MethodObject::DataInventory, MethodFunction::Get),
|
||||||
|
"inbuxa:InventorySnapshot/get" => (MethodObject::InventorySnapshot, MethodFunction::Get),
|
||||||
|
"inbuxa:LogSettings/set" => (MethodObject::LogSettings, MethodFunction::Set),
|
||||||
|
"inbuxa:DlpSettings/set" => (MethodObject::DlpSettings, MethodFunction::Set),
|
||||||
"inbuxa:Explanation/set" => (MethodObject::Explanation, MethodFunction::Set),
|
"inbuxa:Explanation/set" => (MethodObject::Explanation, MethodFunction::Set),
|
||||||
"inbuxa:AuditEvent/get" => (MethodObject::AuditEvent, MethodFunction::Get),
|
"inbuxa:AuditEvent/get" => (MethodObject::AuditEvent, MethodFunction::Get),
|
||||||
"inbuxa:AuditEvent/query" => (MethodObject::AuditEvent, MethodFunction::Query),
|
"inbuxa:AuditEvent/query" => (MethodObject::AuditEvent, MethodFunction::Query),
|
||||||
@@ -434,6 +485,18 @@ impl MethodName {
|
|||||||
"inbuxa:AccountLock/set" => (MethodObject::AccountLock, MethodFunction::Set),
|
"inbuxa:AccountLock/set" => (MethodObject::AccountLock, MethodFunction::Set),
|
||||||
"inbuxa:LegalHold/get" => (MethodObject::LegalHold, MethodFunction::Get),
|
"inbuxa:LegalHold/get" => (MethodObject::LegalHold, MethodFunction::Get),
|
||||||
"inbuxa:LegalHold/set" => (MethodObject::LegalHold, MethodFunction::Set),
|
"inbuxa:LegalHold/set" => (MethodObject::LegalHold, MethodFunction::Set),
|
||||||
|
"inbuxa:MailRule/get" => (MethodObject::MailRule, MethodFunction::Get),
|
||||||
|
"inbuxa:MailRule/set" => (MethodObject::MailRule, MethodFunction::Set),
|
||||||
|
"inbuxa:SecurityAcceptance/get" => (MethodObject::SecurityAcceptance, MethodFunction::Get),
|
||||||
|
"inbuxa:SecurityAcceptance/set" => (MethodObject::SecurityAcceptance, MethodFunction::Set),
|
||||||
|
"inbuxa:Journal/get" => (MethodObject::Journal, MethodFunction::Get),
|
||||||
|
"inbuxa:Journal/set" => (MethodObject::Journal, MethodFunction::Set),
|
||||||
|
"inbuxa:JournalEntry/get" => (MethodObject::JournalEntry, MethodFunction::Get),
|
||||||
|
"inbuxa:JournalEntry/query" => (MethodObject::JournalEntry, MethodFunction::Query),
|
||||||
|
"inbuxa:JournalExport/set" => (MethodObject::JournalExport, MethodFunction::Set),
|
||||||
|
"inbuxa:JournalVerification/set" => (MethodObject::JournalVerification, MethodFunction::Set),
|
||||||
|
"inbuxa:HeldMessage/get" => (MethodObject::HeldMessage, MethodFunction::Get),
|
||||||
|
"inbuxa:HeldMessage/set" => (MethodObject::HeldMessage, MethodFunction::Set),
|
||||||
"inbuxa:HoldExport/get" => (MethodObject::HoldExport, MethodFunction::Get),
|
"inbuxa:HoldExport/get" => (MethodObject::HoldExport, MethodFunction::Get),
|
||||||
"inbuxa:HoldExport/set" => (MethodObject::HoldExport, MethodFunction::Set),
|
"inbuxa:HoldExport/set" => (MethodObject::HoldExport, MethodFunction::Set),
|
||||||
"inbuxa:AuditVerification/set" => (MethodObject::AuditVerification, MethodFunction::Set),
|
"inbuxa:AuditVerification/set" => (MethodObject::AuditVerification, MethodFunction::Set),
|
||||||
@@ -494,6 +557,10 @@ impl Display for MethodObject {
|
|||||||
MethodObject::MaskedEmail => "MaskedEmail",
|
MethodObject::MaskedEmail => "MaskedEmail",
|
||||||
MethodObject::DeletedAccount => "inbuxa:DeletedAccount",
|
MethodObject::DeletedAccount => "inbuxa:DeletedAccount",
|
||||||
MethodObject::AiLimits => "inbuxa:AiLimits",
|
MethodObject::AiLimits => "inbuxa:AiLimits",
|
||||||
|
MethodObject::LogSettings => "inbuxa:LogSettings",
|
||||||
|
MethodObject::DlpSettings => "inbuxa:DlpSettings",
|
||||||
|
MethodObject::DataInventory => "inbuxa:DataInventory",
|
||||||
|
MethodObject::InventorySnapshot => "inbuxa:InventorySnapshot",
|
||||||
MethodObject::Explanation => "inbuxa:Explanation",
|
MethodObject::Explanation => "inbuxa:Explanation",
|
||||||
MethodObject::AuditEvent => "inbuxa:AuditEvent",
|
MethodObject::AuditEvent => "inbuxa:AuditEvent",
|
||||||
MethodObject::AuditSettings => "inbuxa:AuditSettings",
|
MethodObject::AuditSettings => "inbuxa:AuditSettings",
|
||||||
@@ -501,6 +568,13 @@ impl Display for MethodObject {
|
|||||||
MethodObject::AuditVerification => "inbuxa:AuditVerification",
|
MethodObject::AuditVerification => "inbuxa:AuditVerification",
|
||||||
MethodObject::AccountLock => "inbuxa:AccountLock",
|
MethodObject::AccountLock => "inbuxa:AccountLock",
|
||||||
MethodObject::LegalHold => "inbuxa:LegalHold",
|
MethodObject::LegalHold => "inbuxa:LegalHold",
|
||||||
|
MethodObject::MailRule => "inbuxa:MailRule",
|
||||||
|
MethodObject::SecurityAcceptance => "inbuxa:SecurityAcceptance",
|
||||||
|
MethodObject::Journal => "inbuxa:Journal",
|
||||||
|
MethodObject::JournalEntry => "inbuxa:JournalEntry",
|
||||||
|
MethodObject::JournalExport => "inbuxa:JournalExport",
|
||||||
|
MethodObject::JournalVerification => "inbuxa:JournalVerification",
|
||||||
|
MethodObject::HeldMessage => "inbuxa:HeldMessage",
|
||||||
MethodObject::HoldExport => "inbuxa:HoldExport",
|
MethodObject::HoldExport => "inbuxa:HoldExport",
|
||||||
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
|
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
|
||||||
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
|
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
|
||||||
|
|||||||
@@ -116,10 +116,19 @@ pub enum GetRequestMethod {
|
|||||||
MaskedEmail(Box<GetRequest<crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
|
MaskedEmail(Box<GetRequest<crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
|
||||||
DeletedAccount(Box<GetRequest<crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
DeletedAccount(Box<GetRequest<crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
||||||
AiLimits(Box<GetRequest<crate::object::inbuxa_ai_limits::AiLimits>>),
|
AiLimits(Box<GetRequest<crate::object::inbuxa_ai_limits::AiLimits>>),
|
||||||
|
LogSettings(Box<GetRequest<crate::object::inbuxa_log_settings::LogSettings>>),
|
||||||
|
DlpSettings(Box<GetRequest<crate::object::inbuxa_dlp_settings::DlpSettings>>),
|
||||||
|
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>>),
|
AuditEvent(Box<GetRequest<crate::object::inbuxa_audit::AuditEvent>>),
|
||||||
AuditSettings(Box<GetRequest<crate::object::inbuxa_audit::AuditSettings>>),
|
AuditSettings(Box<GetRequest<crate::object::inbuxa_audit::AuditSettings>>),
|
||||||
AccountLock(Box<GetRequest<crate::object::inbuxa_account_lock::AccountLock>>),
|
AccountLock(Box<GetRequest<crate::object::inbuxa_account_lock::AccountLock>>),
|
||||||
LegalHold(Box<GetRequest<crate::object::inbuxa_legal_hold::LegalHold>>),
|
LegalHold(Box<GetRequest<crate::object::inbuxa_legal_hold::LegalHold>>),
|
||||||
|
MailRule(Box<GetRequest<crate::object::inbuxa_mail_rule::MailRule>>),
|
||||||
|
SecurityAcceptance(Box<GetRequest<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>),
|
||||||
|
Journal(Box<GetRequest<crate::object::inbuxa_journal::Journal>>),
|
||||||
|
JournalEntry(Box<GetRequest<crate::object::inbuxa_journal_entry::JournalEntry>>),
|
||||||
|
HeldMessage(Box<GetRequest<crate::object::inbuxa_held_message::HeldMessage>>),
|
||||||
HoldExport(Box<GetRequest<crate::object::inbuxa_hold_export::HoldExport>>),
|
HoldExport(Box<GetRequest<crate::object::inbuxa_hold_export::HoldExport>>),
|
||||||
ProtocolPolicy(Box<GetRequest<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
ProtocolPolicy(Box<GetRequest<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||||
TenantProtocolPolicy(
|
TenantProtocolPolicy(
|
||||||
@@ -148,12 +157,22 @@ pub enum SetRequestMethod<'x> {
|
|||||||
MaskedEmail(Box<SetRequest<'x, crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
|
MaskedEmail(Box<SetRequest<'x, crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
|
||||||
DeletedAccount(Box<SetRequest<'x, crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
DeletedAccount(Box<SetRequest<'x, crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
||||||
AiLimits(Box<SetRequest<'x, crate::object::inbuxa_ai_limits::AiLimits>>),
|
AiLimits(Box<SetRequest<'x, crate::object::inbuxa_ai_limits::AiLimits>>),
|
||||||
|
LogSettings(Box<SetRequest<'x, crate::object::inbuxa_log_settings::LogSettings>>),
|
||||||
|
DlpSettings(Box<SetRequest<'x, crate::object::inbuxa_dlp_settings::DlpSettings>>),
|
||||||
Explanation(Box<SetRequest<'x, crate::object::inbuxa_explanation::Explanation>>),
|
Explanation(Box<SetRequest<'x, crate::object::inbuxa_explanation::Explanation>>),
|
||||||
AuditSettings(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditSettings>>),
|
AuditSettings(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditSettings>>),
|
||||||
AuditExport(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditExport>>),
|
AuditExport(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditExport>>),
|
||||||
AuditVerification(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditVerification>>),
|
AuditVerification(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditVerification>>),
|
||||||
AccountLock(Box<SetRequest<'x, crate::object::inbuxa_account_lock::AccountLock>>),
|
AccountLock(Box<SetRequest<'x, crate::object::inbuxa_account_lock::AccountLock>>),
|
||||||
LegalHold(Box<SetRequest<'x, crate::object::inbuxa_legal_hold::LegalHold>>),
|
LegalHold(Box<SetRequest<'x, crate::object::inbuxa_legal_hold::LegalHold>>),
|
||||||
|
MailRule(Box<SetRequest<'x, crate::object::inbuxa_mail_rule::MailRule>>),
|
||||||
|
SecurityAcceptance(
|
||||||
|
Box<SetRequest<'x, crate::object::inbuxa_security_acceptance::SecurityAcceptance>>,
|
||||||
|
),
|
||||||
|
Journal(Box<SetRequest<'x, crate::object::inbuxa_journal::Journal>>),
|
||||||
|
JournalExport(Box<SetRequest<'x, crate::object::inbuxa_journal_entry::JournalExport>>),
|
||||||
|
JournalVerification(Box<SetRequest<'x, crate::object::inbuxa_journal_entry::JournalVerification>>),
|
||||||
|
HeldMessage(Box<SetRequest<'x, crate::object::inbuxa_held_message::HeldMessage>>),
|
||||||
HoldExport(Box<SetRequest<'x, crate::object::inbuxa_hold_export::HoldExport>>),
|
HoldExport(Box<SetRequest<'x, crate::object::inbuxa_hold_export::HoldExport>>),
|
||||||
ProtocolPolicy(Box<SetRequest<'x, crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
ProtocolPolicy(Box<SetRequest<'x, crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||||
TenantProtocolPolicy(
|
TenantProtocolPolicy(
|
||||||
@@ -187,6 +206,7 @@ pub enum QueryRequestMethod {
|
|||||||
ShareNotification(Box<QueryRequest<ShareNotification>>),
|
ShareNotification(Box<QueryRequest<ShareNotification>>),
|
||||||
Registry(Box<QueryRequest<Registry>>),
|
Registry(Box<QueryRequest<Registry>>),
|
||||||
AuditEvent(Box<QueryRequest<crate::object::inbuxa_audit::AuditEvent>>),
|
AuditEvent(Box<QueryRequest<crate::object::inbuxa_audit::AuditEvent>>),
|
||||||
|
JournalEntry(Box<QueryRequest<crate::object::inbuxa_journal_entry::JournalEntry>>),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|||||||
@@ -169,6 +169,34 @@ impl<'de> Visitor<'de> for CallVisitor {
|
|||||||
return Err(de::Error::invalid_length(1, &self));
|
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::DlpSettings) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::DlpSettings(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() {
|
(MethodFunction::Get, MethodObject::ProtocolPolicy) => match seq.next_element() {
|
||||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::ProtocolPolicy(value)),
|
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::ProtocolPolicy(value)),
|
||||||
Err(err) => RequestMethod::invalid(err),
|
Err(err) => RequestMethod::invalid(err),
|
||||||
@@ -350,6 +378,20 @@ impl<'de> Visitor<'de> for CallVisitor {
|
|||||||
return Err(de::Error::invalid_length(1, &self));
|
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::DlpSettings) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::DlpSettings(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
(MethodFunction::Set, MethodObject::Explanation) => match seq.next_element() {
|
(MethodFunction::Set, MethodObject::Explanation) => match seq.next_element() {
|
||||||
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::Explanation(value)),
|
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::Explanation(value)),
|
||||||
Err(err) => RequestMethod::invalid(err),
|
Err(err) => RequestMethod::invalid(err),
|
||||||
@@ -581,6 +623,94 @@ impl<'de> Visitor<'de> for CallVisitor {
|
|||||||
return Err(de::Error::invalid_length(1, &self));
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// inbuxa: mail held for review
|
||||||
|
(MethodFunction::Get, MethodObject::HeldMessage) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::HeldMessage(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(MethodFunction::Set, MethodObject::HeldMessage) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::HeldMessage(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// inbuxa: DLP and mail flow rules
|
||||||
|
(MethodFunction::Get, MethodObject::MailRule) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::MailRule(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(MethodFunction::Set, MethodObject::MailRule) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::MailRule(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// inbuxa: accepted security to-do items
|
||||||
|
(MethodFunction::Get, MethodObject::SecurityAcceptance) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::SecurityAcceptance(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(MethodFunction::Set, MethodObject::SecurityAcceptance) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::SecurityAcceptance(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// inbuxa: journaling
|
||||||
|
(MethodFunction::Get, MethodObject::JournalEntry) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::JournalEntry(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(MethodFunction::Query, MethodObject::JournalEntry) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Query(QueryRequestMethod::JournalEntry(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(MethodFunction::Set, MethodObject::JournalExport) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::JournalExport(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(MethodFunction::Set, MethodObject::JournalVerification) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::JournalVerification(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(MethodFunction::Get, MethodObject::Journal) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::Journal(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(MethodFunction::Set, MethodObject::Journal) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::Journal(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
// inbuxa: legal hold
|
// inbuxa: legal hold
|
||||||
(MethodFunction::Get, MethodObject::LegalHold) => match seq.next_element() {
|
(MethodFunction::Get, MethodObject::LegalHold) => match seq.next_element() {
|
||||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::LegalHold(value)),
|
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> {
|
impl<T: Default> MaybeResultReference<T> {
|
||||||
pub fn unwrap(self) -> T {
|
pub fn unwrap(self) -> T {
|
||||||
match self {
|
match self {
|
||||||
|
|||||||
@@ -103,10 +103,19 @@ pub enum GetResponseMethod {
|
|||||||
MaskedEmail(GetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>),
|
MaskedEmail(GetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>),
|
||||||
DeletedAccount(GetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>),
|
DeletedAccount(GetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>),
|
||||||
AiLimits(GetResponse<crate::object::inbuxa_ai_limits::AiLimits>),
|
AiLimits(GetResponse<crate::object::inbuxa_ai_limits::AiLimits>),
|
||||||
|
LogSettings(GetResponse<crate::object::inbuxa_log_settings::LogSettings>),
|
||||||
|
DlpSettings(GetResponse<crate::object::inbuxa_dlp_settings::DlpSettings>),
|
||||||
|
DataInventory(GetResponse<crate::object::inbuxa_data_inventory::DataInventory>),
|
||||||
|
InventorySnapshot(GetResponse<crate::object::inbuxa_inventory_snapshot::InventorySnapshot>),
|
||||||
AuditEvent(GetResponse<crate::object::inbuxa_audit::AuditEvent>),
|
AuditEvent(GetResponse<crate::object::inbuxa_audit::AuditEvent>),
|
||||||
AuditSettings(GetResponse<crate::object::inbuxa_audit::AuditSettings>),
|
AuditSettings(GetResponse<crate::object::inbuxa_audit::AuditSettings>),
|
||||||
AccountLock(GetResponse<crate::object::inbuxa_account_lock::AccountLock>),
|
AccountLock(GetResponse<crate::object::inbuxa_account_lock::AccountLock>),
|
||||||
LegalHold(GetResponse<crate::object::inbuxa_legal_hold::LegalHold>),
|
LegalHold(GetResponse<crate::object::inbuxa_legal_hold::LegalHold>),
|
||||||
|
MailRule(GetResponse<crate::object::inbuxa_mail_rule::MailRule>),
|
||||||
|
SecurityAcceptance(GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>),
|
||||||
|
Journal(GetResponse<crate::object::inbuxa_journal::Journal>),
|
||||||
|
JournalEntry(GetResponse<crate::object::inbuxa_journal_entry::JournalEntry>),
|
||||||
|
HeldMessage(GetResponse<crate::object::inbuxa_held_message::HeldMessage>),
|
||||||
HoldExport(GetResponse<crate::object::inbuxa_hold_export::HoldExport>),
|
HoldExport(GetResponse<crate::object::inbuxa_hold_export::HoldExport>),
|
||||||
ProtocolPolicy(GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>),
|
ProtocolPolicy(GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>),
|
||||||
TenantProtocolPolicy(
|
TenantProtocolPolicy(
|
||||||
@@ -136,11 +145,21 @@ pub enum SetResponseMethod {
|
|||||||
MaskedEmail(Box<SetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
|
MaskedEmail(Box<SetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
|
||||||
DeletedAccount(Box<SetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
DeletedAccount(Box<SetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
||||||
AiLimits(Box<SetResponse<crate::object::inbuxa_ai_limits::AiLimits>>),
|
AiLimits(Box<SetResponse<crate::object::inbuxa_ai_limits::AiLimits>>),
|
||||||
|
LogSettings(Box<SetResponse<crate::object::inbuxa_log_settings::LogSettings>>),
|
||||||
|
DlpSettings(Box<SetResponse<crate::object::inbuxa_dlp_settings::DlpSettings>>),
|
||||||
AuditSettings(Box<SetResponse<crate::object::inbuxa_audit::AuditSettings>>),
|
AuditSettings(Box<SetResponse<crate::object::inbuxa_audit::AuditSettings>>),
|
||||||
AuditExport(Box<SetResponse<crate::object::inbuxa_audit::AuditExport>>),
|
AuditExport(Box<SetResponse<crate::object::inbuxa_audit::AuditExport>>),
|
||||||
AuditVerification(Box<SetResponse<crate::object::inbuxa_audit::AuditVerification>>),
|
AuditVerification(Box<SetResponse<crate::object::inbuxa_audit::AuditVerification>>),
|
||||||
AccountLock(Box<SetResponse<crate::object::inbuxa_account_lock::AccountLock>>),
|
AccountLock(Box<SetResponse<crate::object::inbuxa_account_lock::AccountLock>>),
|
||||||
LegalHold(Box<SetResponse<crate::object::inbuxa_legal_hold::LegalHold>>),
|
LegalHold(Box<SetResponse<crate::object::inbuxa_legal_hold::LegalHold>>),
|
||||||
|
MailRule(Box<SetResponse<crate::object::inbuxa_mail_rule::MailRule>>),
|
||||||
|
SecurityAcceptance(
|
||||||
|
Box<SetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>,
|
||||||
|
),
|
||||||
|
Journal(Box<SetResponse<crate::object::inbuxa_journal::Journal>>),
|
||||||
|
JournalExport(Box<SetResponse<crate::object::inbuxa_journal_entry::JournalExport>>),
|
||||||
|
JournalVerification(Box<SetResponse<crate::object::inbuxa_journal_entry::JournalVerification>>),
|
||||||
|
HeldMessage(Box<SetResponse<crate::object::inbuxa_held_message::HeldMessage>>),
|
||||||
HoldExport(Box<SetResponse<crate::object::inbuxa_hold_export::HoldExport>>),
|
HoldExport(Box<SetResponse<crate::object::inbuxa_hold_export::HoldExport>>),
|
||||||
Explanation(Box<SetResponse<crate::object::inbuxa_explanation::Explanation>>),
|
Explanation(Box<SetResponse<crate::object::inbuxa_explanation::Explanation>>),
|
||||||
ProtocolPolicy(Box<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
ProtocolPolicy(Box<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||||
@@ -349,12 +368,48 @@ 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_dlp_settings::DlpSettings>> for ResponseMethod<'x> {
|
||||||
|
fn from(value: GetResponse<crate::object::inbuxa_dlp_settings::DlpSettings>) -> Self {
|
||||||
|
ResponseMethod::Get(GetResponseMethod::DlpSettings(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> {
|
impl<'x> From<SetResponse<crate::object::inbuxa_ai_limits::AiLimits>> for ResponseMethod<'x> {
|
||||||
fn from(value: SetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self {
|
fn from(value: SetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self {
|
||||||
ResponseMethod::Set(SetResponseMethod::AiLimits(Box::new(value)))
|
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_dlp_settings::DlpSettings>> for ResponseMethod<'x> {
|
||||||
|
fn from(value: SetResponse<crate::object::inbuxa_dlp_settings::DlpSettings>) -> Self {
|
||||||
|
ResponseMethod::Set(SetResponseMethod::DlpSettings(Box::new(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<'x> From<SetResponse<crate::object::inbuxa_explanation::Explanation>> for ResponseMethod<'x> {
|
impl<'x> From<SetResponse<crate::object::inbuxa_explanation::Explanation>> for ResponseMethod<'x> {
|
||||||
fn from(value: SetResponse<crate::object::inbuxa_explanation::Explanation>) -> Self {
|
fn from(value: SetResponse<crate::object::inbuxa_explanation::Explanation>) -> Self {
|
||||||
ResponseMethod::Set(SetResponseMethod::Explanation(Box::new(value)))
|
ResponseMethod::Set(SetResponseMethod::Explanation(Box::new(value)))
|
||||||
@@ -771,6 +826,78 @@ impl<'x> From<SetResponse<crate::object::inbuxa_account_lock::AccountLock>> for
|
|||||||
}
|
}
|
||||||
|
|
||||||
// inbuxa: legal hold
|
// inbuxa: legal hold
|
||||||
|
impl<'x> From<GetResponse<crate::object::inbuxa_held_message::HeldMessage>> for ResponseMethod<'x> {
|
||||||
|
fn from(value: GetResponse<crate::object::inbuxa_held_message::HeldMessage>) -> Self {
|
||||||
|
ResponseMethod::Get(GetResponseMethod::HeldMessage(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'x> From<SetResponse<crate::object::inbuxa_held_message::HeldMessage>> for ResponseMethod<'x> {
|
||||||
|
fn from(value: SetResponse<crate::object::inbuxa_held_message::HeldMessage>) -> Self {
|
||||||
|
ResponseMethod::Set(SetResponseMethod::HeldMessage(Box::new(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// inbuxa: accepted security to-do items
|
||||||
|
impl<'x> From<GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>
|
||||||
|
for ResponseMethod<'x>
|
||||||
|
{
|
||||||
|
fn from(value: GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>) -> Self {
|
||||||
|
ResponseMethod::Get(GetResponseMethod::SecurityAcceptance(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'x> From<SetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>
|
||||||
|
for ResponseMethod<'x>
|
||||||
|
{
|
||||||
|
fn from(value: SetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>) -> Self {
|
||||||
|
ResponseMethod::Set(SetResponseMethod::SecurityAcceptance(Box::new(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'x> From<GetResponse<crate::object::inbuxa_mail_rule::MailRule>> for ResponseMethod<'x> {
|
||||||
|
fn from(value: GetResponse<crate::object::inbuxa_mail_rule::MailRule>) -> Self {
|
||||||
|
ResponseMethod::Get(GetResponseMethod::MailRule(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'x> From<SetResponse<crate::object::inbuxa_mail_rule::MailRule>> for ResponseMethod<'x> {
|
||||||
|
fn from(value: SetResponse<crate::object::inbuxa_mail_rule::MailRule>) -> Self {
|
||||||
|
ResponseMethod::Set(SetResponseMethod::MailRule(Box::new(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// inbuxa: journaling
|
||||||
|
impl<'x> From<GetResponse<crate::object::inbuxa_journal_entry::JournalEntry>> for ResponseMethod<'x> {
|
||||||
|
fn from(value: GetResponse<crate::object::inbuxa_journal_entry::JournalEntry>) -> Self {
|
||||||
|
ResponseMethod::Get(GetResponseMethod::JournalEntry(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'x> From<SetResponse<crate::object::inbuxa_journal_entry::JournalExport>> for ResponseMethod<'x> {
|
||||||
|
fn from(value: SetResponse<crate::object::inbuxa_journal_entry::JournalExport>) -> Self {
|
||||||
|
ResponseMethod::Set(SetResponseMethod::JournalExport(Box::new(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'x> From<SetResponse<crate::object::inbuxa_journal_entry::JournalVerification>> for ResponseMethod<'x> {
|
||||||
|
fn from(value: SetResponse<crate::object::inbuxa_journal_entry::JournalVerification>) -> Self {
|
||||||
|
ResponseMethod::Set(SetResponseMethod::JournalVerification(Box::new(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'x> From<GetResponse<crate::object::inbuxa_journal::Journal>> for ResponseMethod<'x> {
|
||||||
|
fn from(value: GetResponse<crate::object::inbuxa_journal::Journal>) -> Self {
|
||||||
|
ResponseMethod::Get(GetResponseMethod::Journal(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'x> From<SetResponse<crate::object::inbuxa_journal::Journal>> for ResponseMethod<'x> {
|
||||||
|
fn from(value: SetResponse<crate::object::inbuxa_journal::Journal>) -> Self {
|
||||||
|
ResponseMethod::Set(SetResponseMethod::Journal(Box::new(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<'x> From<GetResponse<crate::object::inbuxa_legal_hold::LegalHold>> for ResponseMethod<'x> {
|
impl<'x> From<GetResponse<crate::object::inbuxa_legal_hold::LegalHold>> for ResponseMethod<'x> {
|
||||||
fn from(value: GetResponse<crate::object::inbuxa_legal_hold::LegalHold>) -> Self {
|
fn from(value: GetResponse<crate::object::inbuxa_legal_hold::LegalHold>) -> Self {
|
||||||
ResponseMethod::Get(GetResponseMethod::LegalHold(value))
|
ResponseMethod::Get(GetResponseMethod::LegalHold(value))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "jmap"
|
name = "jmap"
|
||||||
version = "0.16.23"
|
version = "0.16.24"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -90,6 +90,13 @@ impl JmapAuthorization for AccessToken {
|
|||||||
GetRequestMethod::DeletedAccount(_) => Permission::SysAccountGet,
|
GetRequestMethod::DeletedAccount(_) => Permission::SysAccountGet,
|
||||||
// inbuxa: AI call limits, with the classifier's permissions
|
// inbuxa: AI call limits, with the classifier's permissions
|
||||||
GetRequestMethod::AiLimits(_) => Permission::SysSpamLlmGet,
|
GetRequestMethod::AiLimits(_) => Permission::SysSpamLlmGet,
|
||||||
|
// inbuxa: log file retention, with the tracers' permissions
|
||||||
|
GetRequestMethod::LogSettings(_) => Permission::SysTracerGet,
|
||||||
|
GetRequestMethod::DlpSettings(_) => Permission::SysDlpPolicyGet,
|
||||||
|
// inbuxa: personal-data catalog, the inventory and its history
|
||||||
|
GetRequestMethod::DataInventory(_) | GetRequestMethod::InventorySnapshot(_) => {
|
||||||
|
Permission::SysComplianceGet
|
||||||
|
}
|
||||||
// inbuxa: the audit log (AU-9)
|
// inbuxa: the audit log (AU-9)
|
||||||
GetRequestMethod::AuditEvent(_) | GetRequestMethod::AuditSettings(_) => {
|
GetRequestMethod::AuditEvent(_) | GetRequestMethod::AuditSettings(_) => {
|
||||||
Permission::SysAuditGet
|
Permission::SysAuditGet
|
||||||
@@ -97,7 +104,25 @@ impl JmapAuthorization for AccessToken {
|
|||||||
// inbuxa: account lock (AL-12)
|
// inbuxa: account lock (AL-12)
|
||||||
GetRequestMethod::AccountLock(_) => Permission::SysAccountLockGet,
|
GetRequestMethod::AccountLock(_) => Permission::SysAccountLockGet,
|
||||||
GetRequestMethod::LegalHold(_) => Permission::SysLegalHoldGet,
|
GetRequestMethod::LegalHold(_) => Permission::SysLegalHoldGet,
|
||||||
|
// inbuxa: DLP and mail flow rules share an object; either
|
||||||
|
// permission reaches it, and the handler shows each kind
|
||||||
|
// only to those who may see it
|
||||||
|
// inbuxa: mail held for review (§2.8)
|
||||||
|
GetRequestMethod::HeldMessage(_) => Permission::SysDlpReviewGet,
|
||||||
|
GetRequestMethod::MailRule(_) => {
|
||||||
|
if self.has_permission(Permission::SysMailRuleGet) {
|
||||||
|
Permission::SysMailRuleGet
|
||||||
|
} else {
|
||||||
|
Permission::SysDlpPolicyGet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// inbuxa: journaling (JR-18)
|
||||||
|
GetRequestMethod::Journal(_) => Permission::SysJournalGet,
|
||||||
|
GetRequestMethod::JournalEntry(_) => Permission::SysJournalSearch,
|
||||||
GetRequestMethod::HoldExport(_) => Permission::SysLegalHoldExport,
|
GetRequestMethod::HoldExport(_) => Permission::SysLegalHoldExport,
|
||||||
|
// inbuxa: accepted security items are read by whoever may
|
||||||
|
// see the server's security settings
|
||||||
|
GetRequestMethod::SecurityAcceptance(_) => Permission::SysSecurityGet,
|
||||||
// inbuxa: legacy protocols off. It takes listeners away and
|
// inbuxa: legacy protocols off. It takes listeners away and
|
||||||
// puts them back, so it takes the listener's permissions
|
// puts them back, so it takes the listener's permissions
|
||||||
GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet,
|
GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet,
|
||||||
@@ -201,6 +226,21 @@ impl JmapAuthorization for AccessToken {
|
|||||||
Permission::SysSpamLlmUpdate,
|
Permission::SysSpamLlmUpdate,
|
||||||
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,
|
||||||
|
),
|
||||||
|
SetRequestMethod::DlpSettings(s) => validate_set(
|
||||||
|
s,
|
||||||
|
self,
|
||||||
|
Permission::SysDlpPolicyUpdate,
|
||||||
|
Permission::SysDlpPolicyUpdate,
|
||||||
|
Permission::SysDlpPolicyUpdate,
|
||||||
|
),
|
||||||
// inbuxa: the audit log (AU-7, AU-9, AU-11)
|
// inbuxa: the audit log (AU-7, AU-9, AU-11)
|
||||||
SetRequestMethod::AuditSettings(s) => validate_set(
|
SetRequestMethod::AuditSettings(s) => validate_set(
|
||||||
s,
|
s,
|
||||||
@@ -233,6 +273,64 @@ impl JmapAuthorization for AccessToken {
|
|||||||
Permission::SysLegalHoldUpdate,
|
Permission::SysLegalHoldUpdate,
|
||||||
Permission::SysLegalHoldUpdate,
|
Permission::SysLegalHoldUpdate,
|
||||||
),
|
),
|
||||||
|
// inbuxa: releasing or rejecting held mail (§2.8)
|
||||||
|
SetRequestMethod::HeldMessage(s) => validate_set(
|
||||||
|
s,
|
||||||
|
self,
|
||||||
|
Permission::SysDlpReviewUpdate,
|
||||||
|
Permission::SysDlpReviewUpdate,
|
||||||
|
Permission::SysDlpReviewUpdate,
|
||||||
|
),
|
||||||
|
// inbuxa: DLP and mail flow rules: either change
|
||||||
|
// permission gets in; the handler checks each rule's kind
|
||||||
|
SetRequestMethod::MailRule(_) => {
|
||||||
|
if self.has_permission(Permission::SysMailRuleUpdate)
|
||||||
|
|| self.has_permission(Permission::SysDlpPolicyUpdate)
|
||||||
|
{
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(trc::JmapEvent::Forbidden
|
||||||
|
.into_err()
|
||||||
|
.details("You are not authorized to change mail rules"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// inbuxa: journaling (JR-18)
|
||||||
|
SetRequestMethod::Journal(s) => validate_set(
|
||||||
|
s,
|
||||||
|
self,
|
||||||
|
Permission::SysJournalUpdate,
|
||||||
|
Permission::SysJournalUpdate,
|
||||||
|
Permission::SysJournalUpdate,
|
||||||
|
),
|
||||||
|
SetRequestMethod::JournalExport(s) => validate_set(
|
||||||
|
s,
|
||||||
|
self,
|
||||||
|
Permission::SysJournalExport,
|
||||||
|
Permission::SysJournalExport,
|
||||||
|
Permission::SysJournalExport,
|
||||||
|
),
|
||||||
|
SetRequestMethod::JournalVerification(s) => validate_set(
|
||||||
|
s,
|
||||||
|
self,
|
||||||
|
Permission::SysJournalGet,
|
||||||
|
Permission::SysJournalGet,
|
||||||
|
Permission::SysJournalGet,
|
||||||
|
),
|
||||||
|
// inbuxa: accepting a security to-do item, or removing
|
||||||
|
// an acceptance; nothing is ever edited
|
||||||
|
SetRequestMethod::SecurityAcceptance(s) => {
|
||||||
|
if s.update.as_ref().is_some_and(|u| !u.is_empty()) {
|
||||||
|
Err(trc::JmapEvent::Forbidden
|
||||||
|
.into_err()
|
||||||
|
.details("An acceptance is replaced, not edited"))
|
||||||
|
} else if self.has_permission(Permission::SysSecurityAccept) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(trc::JmapEvent::Forbidden
|
||||||
|
.into_err()
|
||||||
|
.details("You are not authorized to accept security items"))
|
||||||
|
}
|
||||||
|
}
|
||||||
// inbuxa: LH-12, exporting held data
|
// inbuxa: LH-12, exporting held data
|
||||||
SetRequestMethod::HoldExport(s) => validate_set(
|
SetRequestMethod::HoldExport(s) => validate_set(
|
||||||
s,
|
s,
|
||||||
@@ -382,6 +480,10 @@ impl JmapAuthorization for AccessToken {
|
|||||||
| MethodObject::MaskedEmail
|
| MethodObject::MaskedEmail
|
||||||
| MethodObject::DeletedAccount
|
| MethodObject::DeletedAccount
|
||||||
| MethodObject::AiLimits
|
| MethodObject::AiLimits
|
||||||
|
| MethodObject::LogSettings
|
||||||
|
| MethodObject::DlpSettings
|
||||||
|
| MethodObject::DataInventory
|
||||||
|
| MethodObject::InventorySnapshot
|
||||||
| MethodObject::Explanation
|
| MethodObject::Explanation
|
||||||
| MethodObject::AuditEvent
|
| MethodObject::AuditEvent
|
||||||
| MethodObject::AuditSettings
|
| MethodObject::AuditSettings
|
||||||
@@ -390,6 +492,13 @@ impl JmapAuthorization for AccessToken {
|
|||||||
| MethodObject::AccountLock
|
| MethodObject::AccountLock
|
||||||
| MethodObject::LegalHold
|
| MethodObject::LegalHold
|
||||||
| MethodObject::HoldExport
|
| MethodObject::HoldExport
|
||||||
|
| MethodObject::MailRule
|
||||||
|
| MethodObject::SecurityAcceptance
|
||||||
|
| MethodObject::HeldMessage
|
||||||
|
| MethodObject::Journal
|
||||||
|
| MethodObject::JournalEntry
|
||||||
|
| MethodObject::JournalExport
|
||||||
|
| MethodObject::JournalVerification
|
||||||
| MethodObject::ProtocolPolicy
|
| MethodObject::ProtocolPolicy
|
||||||
| MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges,
|
| MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges,
|
||||||
// inbuxa: x:MaskedEmail/changes reads what /get reads
|
// inbuxa: x:MaskedEmail/changes reads what /get reads
|
||||||
@@ -448,6 +557,8 @@ impl JmapAuthorization for AccessToken {
|
|||||||
QueryRequestMethod::ShareNotification(_) => Permission::JmapShareNotificationQuery,
|
QueryRequestMethod::ShareNotification(_) => Permission::JmapShareNotificationQuery,
|
||||||
// inbuxa: the audit log (AU-9)
|
// inbuxa: the audit log (AU-9)
|
||||||
QueryRequestMethod::AuditEvent(_) => Permission::SysAuditGet,
|
QueryRequestMethod::AuditEvent(_) => Permission::SysAuditGet,
|
||||||
|
// inbuxa: journaling (JR-15)
|
||||||
|
QueryRequestMethod::JournalEntry(_) => Permission::SysJournalSearch,
|
||||||
QueryRequestMethod::Registry(_) => {
|
QueryRequestMethod::Registry(_) => {
|
||||||
let MethodObject::Registry(object_type) = object else {
|
let MethodObject::Registry(object_type) = object else {
|
||||||
unreachable!()
|
unreachable!()
|
||||||
|
|||||||
@@ -261,6 +261,12 @@ impl RequestHandler for Server {
|
|||||||
SetResponseMethod::AiLimits(set_response) => {
|
SetResponseMethod::AiLimits(set_response) => {
|
||||||
set_response.update_created_ids(&mut response);
|
set_response.update_created_ids(&mut response);
|
||||||
}
|
}
|
||||||
|
SetResponseMethod::LogSettings(set_response) => {
|
||||||
|
set_response.update_created_ids(&mut response);
|
||||||
|
}
|
||||||
|
SetResponseMethod::DlpSettings(set_response) => {
|
||||||
|
set_response.update_created_ids(&mut response);
|
||||||
|
}
|
||||||
SetResponseMethod::AuditSettings(set_response) => {
|
SetResponseMethod::AuditSettings(set_response) => {
|
||||||
set_response.update_created_ids(&mut response);
|
set_response.update_created_ids(&mut response);
|
||||||
}
|
}
|
||||||
@@ -276,6 +282,24 @@ impl RequestHandler for Server {
|
|||||||
SetResponseMethod::LegalHold(set_response) => {
|
SetResponseMethod::LegalHold(set_response) => {
|
||||||
set_response.update_created_ids(&mut response);
|
set_response.update_created_ids(&mut response);
|
||||||
}
|
}
|
||||||
|
SetResponseMethod::MailRule(set_response) => {
|
||||||
|
set_response.update_created_ids(&mut response);
|
||||||
|
}
|
||||||
|
SetResponseMethod::SecurityAcceptance(set_response) => {
|
||||||
|
set_response.update_created_ids(&mut response);
|
||||||
|
}
|
||||||
|
SetResponseMethod::Journal(set_response) => {
|
||||||
|
set_response.update_created_ids(&mut response);
|
||||||
|
}
|
||||||
|
SetResponseMethod::JournalExport(set_response) => {
|
||||||
|
set_response.update_created_ids(&mut response);
|
||||||
|
}
|
||||||
|
SetResponseMethod::JournalVerification(set_response) => {
|
||||||
|
set_response.update_created_ids(&mut response);
|
||||||
|
}
|
||||||
|
SetResponseMethod::HeldMessage(set_response) => {
|
||||||
|
set_response.update_created_ids(&mut response);
|
||||||
|
}
|
||||||
SetResponseMethod::HoldExport(set_response) => {
|
SetResponseMethod::HoldExport(set_response) => {
|
||||||
set_response.update_created_ids(&mut response);
|
set_response.update_created_ids(&mut response);
|
||||||
}
|
}
|
||||||
@@ -445,6 +469,34 @@ impl RequestHandler for Server {
|
|||||||
.await?
|
.await?
|
||||||
.into()
|
.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:DlpSettings/get
|
||||||
|
GetRequestMethod::DlpSettings(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
crate::inbuxa::dlp_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)
|
// inbuxa: account lock with delegation (AL-1)
|
||||||
GetRequestMethod::AccountLock(mut req) => {
|
GetRequestMethod::AccountLock(mut req) => {
|
||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
@@ -462,6 +514,34 @@ impl RequestHandler for Server {
|
|||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
crate::inbuxa::legal_hold::get(self, *req).await?.into()
|
crate::inbuxa::legal_hold::get(self, *req).await?.into()
|
||||||
}
|
}
|
||||||
|
// inbuxa: mail held for review
|
||||||
|
GetRequestMethod::HeldMessage(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
crate::inbuxa::held_message::get(self, access_token, *req).await?.into()
|
||||||
|
}
|
||||||
|
// inbuxa: DLP and mail flow rules
|
||||||
|
GetRequestMethod::MailRule(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
crate::inbuxa::mail_rule::get(self, access_token, *req).await?.into()
|
||||||
|
}
|
||||||
|
// inbuxa: accepted security to-do items
|
||||||
|
GetRequestMethod::SecurityAcceptance(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
crate::inbuxa::security_acceptance::get(self, access_token, *req)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
// inbuxa: journaling
|
||||||
|
GetRequestMethod::Journal(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
crate::inbuxa::journal::get(self, access_token, *req).await?.into()
|
||||||
|
}
|
||||||
|
GetRequestMethod::JournalEntry(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
crate::inbuxa::journal_entry::get(self, access_token, session, *req)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
// inbuxa: the audit log (AU-9)
|
// inbuxa: the audit log (AU-9)
|
||||||
GetRequestMethod::AuditEvent(mut req) => {
|
GetRequestMethod::AuditEvent(mut req) => {
|
||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
@@ -658,6 +738,13 @@ impl RequestHandler for Server {
|
|||||||
.await?
|
.await?
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
// inbuxa: journaling (JR-15)
|
||||||
|
QueryRequestMethod::JournalEntry(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
crate::inbuxa::journal_entry::query(self, access_token, session, *req)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
QueryRequestMethod::Registry(mut req) => {
|
QueryRequestMethod::Registry(mut req) => {
|
||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
assert_registry_account(self, method_name.obj, access_token, req.account_id)
|
assert_registry_account(self, method_name.obj, access_token, req.account_id)
|
||||||
@@ -769,6 +856,40 @@ impl RequestHandler for Server {
|
|||||||
.await?
|
.await?
|
||||||
.into()
|
.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: inbuxa:DlpSettings/set
|
||||||
|
SetRequestMethod::DlpSettings(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::dlp_settings::set(self, access_token, req)),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
// inbuxa: the audit log (AU-7, AU-11, AU-6)
|
// inbuxa: the audit log (AU-7, AU-11, AU-6)
|
||||||
SetRequestMethod::AuditSettings(mut req) => {
|
SetRequestMethod::AuditSettings(mut req) => {
|
||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
@@ -869,6 +990,77 @@ impl RequestHandler for Server {
|
|||||||
.await?
|
.await?
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
SetRequestMethod::HeldMessage(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
let reason = req.arguments.reason.clone();
|
||||||
|
crate::inbuxa::audit::recorded(
|
||||||
|
self,
|
||||||
|
access_token,
|
||||||
|
session,
|
||||||
|
&method_name.obj.to_string(),
|
||||||
|
None,
|
||||||
|
reason,
|
||||||
|
*req,
|
||||||
|
|req| Box::pin(crate::inbuxa::held_message::set(self, access_token, req)),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
SetRequestMethod::MailRule(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
let reason = req.arguments.reason.clone();
|
||||||
|
crate::inbuxa::audit::recorded(
|
||||||
|
self,
|
||||||
|
access_token,
|
||||||
|
session,
|
||||||
|
&method_name.obj.to_string(),
|
||||||
|
None,
|
||||||
|
reason,
|
||||||
|
*req,
|
||||||
|
|req| Box::pin(crate::inbuxa::mail_rule::set(self, access_token, req)),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
// inbuxa: SS-26, every acceptance made or removed is in the
|
||||||
|
// audit log
|
||||||
|
SetRequestMethod::SecurityAcceptance(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
crate::inbuxa::audit::recorded(
|
||||||
|
self,
|
||||||
|
access_token,
|
||||||
|
session,
|
||||||
|
&method_name.obj.to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
*req,
|
||||||
|
|req| {
|
||||||
|
Box::pin(crate::inbuxa::security_acceptance::set(
|
||||||
|
self,
|
||||||
|
access_token,
|
||||||
|
req,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
SetRequestMethod::Journal(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
let reason = req.arguments.reason.clone();
|
||||||
|
crate::inbuxa::audit::recorded(
|
||||||
|
self,
|
||||||
|
access_token,
|
||||||
|
session,
|
||||||
|
&method_name.obj.to_string(),
|
||||||
|
None,
|
||||||
|
reason,
|
||||||
|
*req,
|
||||||
|
|req| Box::pin(crate::inbuxa::journal::set(self, access_token, req)),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
SetRequestMethod::AuditExport(mut req) => {
|
SetRequestMethod::AuditExport(mut req) => {
|
||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
crate::inbuxa::audit_log::export_set(self, access_token, session, *req)
|
crate::inbuxa::audit_log::export_set(self, access_token, session, *req)
|
||||||
@@ -881,6 +1073,19 @@ impl RequestHandler for Server {
|
|||||||
.await?
|
.await?
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
// inbuxa: journaling (JR-6, JR-16)
|
||||||
|
SetRequestMethod::JournalExport(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
crate::inbuxa::journal_entry::export_set(self, access_token, session, *req)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
SetRequestMethod::JournalVerification(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
crate::inbuxa::journal_entry::verification_set(self, access_token, session, *req)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
// inbuxa: inbuxa:Explanation/set ("Explain this")
|
// inbuxa: inbuxa:Explanation/set ("Explain this")
|
||||||
SetRequestMethod::Explanation(mut req) => {
|
SetRequestMethod::Explanation(mut req) => {
|
||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
|||||||
@@ -66,12 +66,16 @@ impl SessionHandler for Server {
|
|||||||
Capability::Inbuxa,
|
Capability::Inbuxa,
|
||||||
Capabilities::Empty(EmptyCapabilities::default()),
|
Capabilities::Empty(EmptyCapabilities::default()),
|
||||||
);
|
);
|
||||||
// inbuxa: legacy-protocols, Interfaces: whichever switch is stricter
|
// inbuxa: legacy-protocols, Interfaces: whichever switch is stricter,
|
||||||
let legacy_protocols = if self.legacy_protocols_off_for_account(access_token).await? {
|
// 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"
|
"disabled"
|
||||||
} else {
|
} else {
|
||||||
"enabled"
|
"enabled"
|
||||||
};
|
};
|
||||||
|
let legacy_allowed = legacy_off.allowed();
|
||||||
// inbuxa: ai-explain, EX-1 to EX-4: whether Explain can be offered
|
// inbuxa: ai-explain, EX-1 to EX-4: whether Explain can be offered
|
||||||
let ai_explain = access_token.has_permission(Permission::SysAiExplain)
|
let ai_explain = access_token.has_permission(Permission::SysAiExplain)
|
||||||
&& access_token.tenant_id().is_none()
|
&& access_token.tenant_id().is_none()
|
||||||
@@ -81,6 +85,7 @@ impl SessionHandler for Server {
|
|||||||
Capabilities::Inbuxa(InbuxaAccountCapabilities {
|
Capabilities::Inbuxa(InbuxaAccountCapabilities {
|
||||||
logo,
|
logo,
|
||||||
legacy_protocols,
|
legacy_protocols,
|
||||||
|
legacy_allowed,
|
||||||
ai_explain,
|
ai_explain,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user