Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de275bac60 | ||
|
|
305406a331 | ||
|
|
f5888d79b0 | ||
|
|
8e9cedbe97 | ||
|
|
5ba54e8fb7 | ||
|
|
db817dd507 | ||
|
|
b7e3a765ca | ||
|
|
7ba9ec9fa0 | ||
|
|
4c07779c16 | ||
|
|
e1e8a9aeb0 | ||
|
|
5c506b9d2b | ||
|
|
3978cf5785 | ||
|
|
815a642cc4 | ||
|
|
1d5f4a2cd3 | ||
|
|
68dd749291 | ||
|
|
b1bc5ed6e0 |
Generated
+2
@@ -4258,6 +4258,7 @@ dependencies = [
|
|||||||
"tungstenite 0.30.0",
|
"tungstenite 0.30.0",
|
||||||
"types",
|
"types",
|
||||||
"utils",
|
"utils",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8624,6 +8625,7 @@ dependencies = [
|
|||||||
"types",
|
"types",
|
||||||
"utils",
|
"utils",
|
||||||
"x509-parser",
|
"x509-parser",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
// submission.
|
||||||
|
let pacc = (0..16usize)
|
||||||
|
.map(|off| {
|
||||||
let mut pacc = pacc.clone();
|
let mut pacc = pacc.clone();
|
||||||
|
if off & 1 != 0 {
|
||||||
pacc.protocols.imap = None;
|
pacc.protocols.imap = None;
|
||||||
|
}
|
||||||
|
if off & 2 != 0 {
|
||||||
pacc.protocols.pop3 = None;
|
pacc.protocols.pop3 = None;
|
||||||
pacc.protocols.smtp = None;
|
}
|
||||||
|
if off & 4 != 0 {
|
||||||
pacc.protocols.managesieve = None;
|
pacc.protocols.managesieve = None;
|
||||||
|
}
|
||||||
|
if off & 8 != 0 {
|
||||||
|
pacc.protocols.smtp = None;
|
||||||
|
}
|
||||||
split(&pacc)
|
split(&pacc)
|
||||||
};
|
})
|
||||||
let pacc = 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,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 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;
|
||||||
@@ -59,11 +59,11 @@ impl Server {
|
|||||||
let _ = writeln!(&mut config, "\t\t\t<Action>settings</Action>");
|
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 {
|
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, ports) = match protocol {
|
let (protocol, ports) = match protocol {
|
||||||
|
|||||||
@@ -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!())
|
||||||
|
|||||||
@@ -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
|
||||||
|
// back.
|
||||||
|
self.close_legacy_listeners(&mut policy, &mut change)
|
||||||
|
.await?;
|
||||||
self.reopen_legacy_listeners(&mut policy, &mut change)
|
self.reopen_legacy_listeners(&mut policy, &mut change)
|
||||||
.await?;
|
.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,16 +420,19 @@ 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?
|
|
||||||
{
|
{
|
||||||
|
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())));
|
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), Some(name.clone())));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,11 +450,17 @@ 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?;
|
||||||
|
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));
|
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,
|
||||||
access_token.account_id(),
|
access_token.account_id(),
|
||||||
@@ -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?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
Ok(LegacyOff::of(&server, tenant.as_ref()))
|
||||||
}
|
}
|
||||||
match access_token.tenant_id() {
|
|
||||||
Some(tenant_id) => self.tenant_legacy_protocols_off(tenant_id).await,
|
/// A tenant's switches, or all on when it has never set them (LP-10).
|
||||||
None => Ok(false),
|
pub async fn tenant_protocol_policy(
|
||||||
|
&self,
|
||||||
|
tenant_id: u32,
|
||||||
|
) -> trc::Result<TenantProtocolPolicy> {
|
||||||
|
tenant_protocol_policy::get(&self.core.storage.data, tenant_id).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a tenant has turned legacy protocols off for itself (LP-10).
|
/// Which legacy protocols are off, for one account or one domain: the server's
|
||||||
pub async fn tenant_legacy_protocols_off(&self, tenant_id: u32) -> trc::Result<bool> {
|
/// switches and the tenant's together. Submission is off only when all three
|
||||||
Ok(
|
/// are.
|
||||||
tenant_protocol_policy::get(&self.core.storage.data, tenant_id)
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
.await?
|
pub struct LegacyOff {
|
||||||
.legacy_protocols
|
pub imap: bool,
|
||||||
.is_disabled(),
|
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 this configured service must not be offered (LP-7). SMTP here
|
||||||
|
/// is submission; inbound mail is never a configured service.
|
||||||
|
pub fn service(&self, protocol: &ServiceProtocol) -> bool {
|
||||||
|
match protocol {
|
||||||
|
ServiceProtocol::Imap => self.imap,
|
||||||
|
ServiceProtocol::Pop3 => self.pop3,
|
||||||
|
ServiceProtocol::Managesieve => self.manage_sieve,
|
||||||
|
ServiceProtocol::Smtp => self.submission,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether anything is off.
|
||||||
|
pub fn any(&self) -> bool {
|
||||||
|
self.imap || self.pop3 || self.manage_sieve || self.submission
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether everything is off: the kill-all's effect.
|
||||||
|
pub fn all(&self) -> bool {
|
||||||
|
self.imap && self.pop3 && self.manage_sieve && self.submission
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An index for answers prepared once per combination (the PACC
|
||||||
|
/// document): one bit per protocol.
|
||||||
|
pub fn index(&self) -> usize {
|
||||||
|
(self.imap as usize)
|
||||||
|
| (self.pop3 as usize) << 1
|
||||||
|
| (self.manage_sieve as usize) << 2
|
||||||
|
| (self.submission as usize) << 3
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The protocols that are still allowed, by JMAP name, for the session.
|
||||||
|
pub fn allowed(&self) -> Vec<&'static str> {
|
||||||
|
[
|
||||||
|
("imap", self.imap),
|
||||||
|
("pop3", self.pop3),
|
||||||
|
("manageSieve", self.manage_sieve),
|
||||||
|
(SUBMISSION, self.submission),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, off)| !off)
|
||||||
|
.map(|(name, _)| name)
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,21 +604,20 @@ pub fn is_legacy_service(protocol: &ServiceProtocol) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Server {
|
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()));
|
||||||
|
|||||||
@@ -108,6 +108,45 @@ impl Keeping {
|
|||||||
const FEATURE: u8 = b'H';
|
const FEATURE: u8 = b'H';
|
||||||
const KIND_HOLD: u8 = b'h';
|
const KIND_HOLD: u8 = b'h';
|
||||||
const KIND_ORIGINAL: u8 = b'o';
|
const KIND_ORIGINAL: u8 = b'o';
|
||||||
|
const KIND_EXPORT: u8 = b'e';
|
||||||
|
|
||||||
|
/// How far a hold export has got (LH-12).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum ExportStatus {
|
||||||
|
Running,
|
||||||
|
Ready,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A collection of what a hold keeps, as a ZIP (LH-12).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Export {
|
||||||
|
pub id: u32,
|
||||||
|
pub hold_id: u32,
|
||||||
|
/// The accounts asked for; empty for every account the hold covers.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub accounts: Vec<u32>,
|
||||||
|
pub reason: String,
|
||||||
|
pub created_at: u64,
|
||||||
|
pub created_by: String,
|
||||||
|
/// Whose blob the ZIP is, so only they download it.
|
||||||
|
pub created_by_id: u32,
|
||||||
|
pub status: ExportStatus,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub finished_at: Option<u64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub blob_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub size: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub items: u64,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sha256: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// How many times creating a hold retries when another node took its id.
|
/// How many times creating a hold retries when another node took its id.
|
||||||
const CREATE_ATTEMPTS: usize = 5;
|
const CREATE_ATTEMPTS: usize = 5;
|
||||||
@@ -402,6 +441,70 @@ pub async fn set_original_deadline(data: &Store, item_id: u64, until: Option<u64
|
|||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn export_class(id: u32) -> ValueClass {
|
||||||
|
let mut key = Vec::with_capacity(6);
|
||||||
|
key.push(FEATURE);
|
||||||
|
key.push(KIND_EXPORT);
|
||||||
|
key.extend_from_slice(&id.to_be_bytes());
|
||||||
|
ValueClass::Any(AnyClass {
|
||||||
|
subspace: SUBSPACE_INBUXA,
|
||||||
|
key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every hold export, oldest first.
|
||||||
|
pub async fn exports(data: &Store) -> trc::Result<Vec<Export>> {
|
||||||
|
let mut exports = Vec::new();
|
||||||
|
data.iterate(
|
||||||
|
IterateParams::new(ValueKey::from(export_class(0)), ValueKey::from(export_class(u32::MAX))),
|
||||||
|
|_, value| {
|
||||||
|
if let Ok(Json(export)) = Json::<Export>::deserialize(value) {
|
||||||
|
exports.push(export);
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
Ok(exports)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a new export under the next free id, which it returns.
|
||||||
|
pub async fn create_export(data: &Store, export: &Export) -> trc::Result<u32> {
|
||||||
|
let mut attempt = 0;
|
||||||
|
loop {
|
||||||
|
attempt += 1;
|
||||||
|
let id = exports(data).await?.iter().map(|e| e.id).max().unwrap_or(0) + 1;
|
||||||
|
let stored = Export {
|
||||||
|
id,
|
||||||
|
..export.clone()
|
||||||
|
};
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.assert_value(export_class(id), AssertValue::None);
|
||||||
|
batch.set(export_class(id), Json(&stored).serialize()?);
|
||||||
|
match data.write(batch.build_all()).await {
|
||||||
|
Ok(_) => return Ok(id),
|
||||||
|
Err(err)
|
||||||
|
if attempt < CREATE_ATTEMPTS
|
||||||
|
&& matches!(
|
||||||
|
err.as_ref(),
|
||||||
|
trc::EventType::Store(trc::StoreEvent::AssertValueFailed)
|
||||||
|
) => {}
|
||||||
|
Err(err) => return Err(err.caused_by(trc::location!())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves an export's progress.
|
||||||
|
pub async fn update_export(data: &Store, export: &Export) -> trc::Result<()> {
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.set(export_class(export.id), Json(export).serialize()?);
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
/// One hold, released or not.
|
/// One hold, released or not.
|
||||||
pub async fn get(data: &Store, id: u32) -> trc::Result<Option<Hold>> {
|
pub async fn get(data: &Store, id: u32) -> trc::Result<Option<Hold>> {
|
||||||
Ok(data
|
Ok(data
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:HoldExport/get` and `/set` under `urn:inbuxa:jmap`: collecting
|
||||||
|
//! what a legal hold keeps as a ZIP (audit-hold-lock spec, LH-12). Creating
|
||||||
|
//! one starts it; it runs in the background, and `get` says when it's ready
|
||||||
|
//! and which blob to download. The set call's `reason` says why (AU-12).
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
object::{AnyId, JmapObject, JmapObjectId},
|
||||||
|
request::deserialize::DeserializeArguments,
|
||||||
|
};
|
||||||
|
use jmap_tools::{Element, Key, Property};
|
||||||
|
use std::{borrow::Cow, str::FromStr};
|
||||||
|
use types::id::Id;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct HoldExport;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum HoldExportProperty {
|
||||||
|
Id,
|
||||||
|
HoldId,
|
||||||
|
AccountIds,
|
||||||
|
Reason,
|
||||||
|
Status,
|
||||||
|
CreatedAt,
|
||||||
|
CreatedBy,
|
||||||
|
FinishedAt,
|
||||||
|
BlobId,
|
||||||
|
Size,
|
||||||
|
Items,
|
||||||
|
Sha256,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum HoldExportValue {
|
||||||
|
Id(Id),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Property for HoldExportProperty {
|
||||||
|
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||||
|
match parent {
|
||||||
|
None => HoldExportProperty::parse(value),
|
||||||
|
Some(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
HoldExportProperty::Id => "id",
|
||||||
|
HoldExportProperty::HoldId => "holdId",
|
||||||
|
HoldExportProperty::AccountIds => "accountIds",
|
||||||
|
HoldExportProperty::Reason => "reason",
|
||||||
|
HoldExportProperty::Status => "status",
|
||||||
|
HoldExportProperty::CreatedAt => "createdAt",
|
||||||
|
HoldExportProperty::CreatedBy => "createdBy",
|
||||||
|
HoldExportProperty::FinishedAt => "finishedAt",
|
||||||
|
HoldExportProperty::BlobId => "blobId",
|
||||||
|
HoldExportProperty::Size => "size",
|
||||||
|
HoldExportProperty::Items => "items",
|
||||||
|
HoldExportProperty::Sha256 => "sha256",
|
||||||
|
HoldExportProperty::Error => "error",
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HoldExportProperty {
|
||||||
|
fn parse(value: &str) -> Option<Self> {
|
||||||
|
hashify::tiny_map!(value.as_bytes(),
|
||||||
|
b"id" => HoldExportProperty::Id,
|
||||||
|
b"holdId" => HoldExportProperty::HoldId,
|
||||||
|
b"accountIds" => HoldExportProperty::AccountIds,
|
||||||
|
b"reason" => HoldExportProperty::Reason,
|
||||||
|
b"status" => HoldExportProperty::Status,
|
||||||
|
b"createdAt" => HoldExportProperty::CreatedAt,
|
||||||
|
b"createdBy" => HoldExportProperty::CreatedBy,
|
||||||
|
b"finishedAt" => HoldExportProperty::FinishedAt,
|
||||||
|
b"blobId" => HoldExportProperty::BlobId,
|
||||||
|
b"size" => HoldExportProperty::Size,
|
||||||
|
b"items" => HoldExportProperty::Items,
|
||||||
|
b"sha256" => HoldExportProperty::Sha256,
|
||||||
|
b"error" => HoldExportProperty::Error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for HoldExportProperty {
|
||||||
|
type Err = ();
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
HoldExportProperty::parse(s).ok_or(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element for HoldExportValue {
|
||||||
|
type Property = HoldExportProperty;
|
||||||
|
|
||||||
|
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||||
|
match key {
|
||||||
|
Key::Property(HoldExportProperty::Id) => Id::from_str(value).ok().map(HoldExportValue::Id),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
HoldExportValue::Id(id) => id.to_string().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The set call's own arguments: why (AU-12).
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct HoldExportSetArguments {
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> DeserializeArguments<'de> for HoldExportSetArguments {
|
||||||
|
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
|
||||||
|
where
|
||||||
|
A: serde::de::MapAccess<'de>,
|
||||||
|
{
|
||||||
|
if key == "reason" {
|
||||||
|
self.reason = map.next_value()?;
|
||||||
|
} else {
|
||||||
|
let _ = map.next_value::<serde::de::IgnoredAny>()?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObject for HoldExport {
|
||||||
|
type Property = HoldExportProperty;
|
||||||
|
|
||||||
|
type Element = HoldExportValue;
|
||||||
|
|
||||||
|
type Id = Id;
|
||||||
|
|
||||||
|
type Filter = ();
|
||||||
|
|
||||||
|
type Comparator = ();
|
||||||
|
|
||||||
|
type GetArguments = ();
|
||||||
|
|
||||||
|
type SetArguments<'de> = HoldExportSetArguments;
|
||||||
|
|
||||||
|
type QueryArguments = ();
|
||||||
|
|
||||||
|
type CopyArguments = ();
|
||||||
|
|
||||||
|
type ParseArguments = ();
|
||||||
|
|
||||||
|
const ID_PROPERTY: Self::Property = HoldExportProperty::Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Id> for HoldExportValue {
|
||||||
|
fn from(id: Id) -> Self {
|
||||||
|
HoldExportValue::Id(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for HoldExportValue {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
match self {
|
||||||
|
HoldExportValue::Id(id) => Some(*id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
match self {
|
||||||
|
HoldExportValue::Id(id) => Some(AnyId::Id(*id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_id_ref(&self) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_set_id(&mut self, new_id: AnyId) -> bool {
|
||||||
|
if let AnyId::Id(id) = new_id {
|
||||||
|
*self = HoldExportValue::Id(id);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for HoldExportProperty {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_id_ref(&self) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_set_id(&mut self, _: AnyId) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ 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_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_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
|
||||||
pub mod inbuxa_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant
|
pub mod inbuxa_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ impl Response<'_> {
|
|||||||
GetResponseMethod::LegalHold(response) => {
|
GetResponseMethod::LegalHold(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
response.eval_jptr(path, &mut results)
|
||||||
}
|
}
|
||||||
|
GetResponseMethod::HoldExport(response) => {
|
||||||
|
response.eval_jptr(path, &mut results)
|
||||||
|
}
|
||||||
GetResponseMethod::ProtocolPolicy(response) => {
|
GetResponseMethod::ProtocolPolicy(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
response.eval_jptr(path, &mut results)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ impl Response<'_> {
|
|||||||
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::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) => {
|
||||||
request.resolve_references(self)?
|
request.resolve_references(self)?
|
||||||
@@ -115,6 +116,9 @@ impl Response<'_> {
|
|||||||
SetRequestMethod::LegalHold(request) => {
|
SetRequestMethod::LegalHold(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
request.resolve_references(self, 1, false)?
|
||||||
}
|
}
|
||||||
|
SetRequestMethod::HoldExport(request) => {
|
||||||
|
request.resolve_references(self, 1, false)?
|
||||||
|
}
|
||||||
SetRequestMethod::ProtocolPolicy(request) => {
|
SetRequestMethod::ProtocolPolicy(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
request.resolve_references(self, 1, false)?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ pub enum MethodObject {
|
|||||||
AccountLock,
|
AccountLock,
|
||||||
// inbuxa: legal hold
|
// inbuxa: legal hold
|
||||||
LegalHold,
|
LegalHold,
|
||||||
|
HoldExport,
|
||||||
ProtocolPolicy,
|
ProtocolPolicy,
|
||||||
TenantProtocolPolicy,
|
TenantProtocolPolicy,
|
||||||
}
|
}
|
||||||
@@ -94,7 +95,8 @@ impl MethodObject {
|
|||||||
| MethodObject::AuditExport
|
| MethodObject::AuditExport
|
||||||
| MethodObject::AuditVerification
|
| MethodObject::AuditVerification
|
||||||
| MethodObject::AccountLock
|
| MethodObject::AccountLock
|
||||||
| MethodObject::LegalHold => Capability::Inbuxa,
|
| MethodObject::LegalHold
|
||||||
|
| MethodObject::HoldExport => Capability::Inbuxa,
|
||||||
MethodObject::ProtocolPolicy => Capability::Inbuxa,
|
MethodObject::ProtocolPolicy => Capability::Inbuxa,
|
||||||
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
|
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
|
||||||
}
|
}
|
||||||
@@ -284,6 +286,8 @@ 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::HoldExport) => "inbuxa:HoldExport/get",
|
||||||
|
(MethodFunction::Set, MethodObject::HoldExport) => "inbuxa:HoldExport/set",
|
||||||
(MethodFunction::Set, MethodObject::AuditVerification) => {
|
(MethodFunction::Set, MethodObject::AuditVerification) => {
|
||||||
"inbuxa:AuditVerification/set"
|
"inbuxa:AuditVerification/set"
|
||||||
}
|
}
|
||||||
@@ -430,6 +434,8 @@ 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:HoldExport/get" => (MethodObject::HoldExport, MethodFunction::Get),
|
||||||
|
"inbuxa:HoldExport/set" => (MethodObject::HoldExport, MethodFunction::Set),
|
||||||
"inbuxa:AuditVerification/set" => (MethodObject::AuditVerification, MethodFunction::Set),
|
"inbuxa:AuditVerification/set" => (MethodObject::AuditVerification, MethodFunction::Set),
|
||||||
"inbuxa:ProtocolPolicy/get" => (MethodObject::ProtocolPolicy, MethodFunction::Get),
|
"inbuxa:ProtocolPolicy/get" => (MethodObject::ProtocolPolicy, MethodFunction::Get),
|
||||||
"inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set),
|
"inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set),
|
||||||
@@ -495,6 +501,7 @@ 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::HoldExport => "inbuxa:HoldExport",
|
||||||
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
|
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
|
||||||
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
|
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
|
||||||
MethodObject::Registry(obj) => {
|
MethodObject::Registry(obj) => {
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ pub enum GetRequestMethod {
|
|||||||
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>>),
|
||||||
|
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(
|
||||||
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||||
@@ -153,6 +154,7 @@ pub enum SetRequestMethod<'x> {
|
|||||||
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>>),
|
||||||
|
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(
|
||||||
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||||
|
|||||||
@@ -566,6 +566,21 @@ impl<'de> Visitor<'de> for CallVisitor {
|
|||||||
return Err(de::Error::invalid_length(1, &self));
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// inbuxa: legal hold exports
|
||||||
|
(MethodFunction::Get, MethodObject::HoldExport) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::HoldExport(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(MethodFunction::Set, MethodObject::HoldExport) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::HoldExport(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
// inbuxa: legal hold
|
// 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)),
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ pub enum GetResponseMethod {
|
|||||||
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>),
|
||||||
|
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(
|
||||||
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
|
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
|
||||||
@@ -140,6 +141,7 @@ pub enum SetResponseMethod {
|
|||||||
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>>),
|
||||||
|
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>>),
|
||||||
TenantProtocolPolicy(
|
TenantProtocolPolicy(
|
||||||
@@ -780,3 +782,16 @@ impl<'x> From<SetResponse<crate::object::inbuxa_legal_hold::LegalHold>> for Resp
|
|||||||
ResponseMethod::Set(SetResponseMethod::LegalHold(Box::new(value)))
|
ResponseMethod::Set(SetResponseMethod::LegalHold(Box::new(value)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: legal hold exports
|
||||||
|
impl<'x> From<GetResponse<crate::object::inbuxa_hold_export::HoldExport>> for ResponseMethod<'x> {
|
||||||
|
fn from(value: GetResponse<crate::object::inbuxa_hold_export::HoldExport>) -> Self {
|
||||||
|
ResponseMethod::Get(GetResponseMethod::HoldExport(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'x> From<SetResponse<crate::object::inbuxa_hold_export::HoldExport>> for ResponseMethod<'x> {
|
||||||
|
fn from(value: SetResponse<crate::object::inbuxa_hold_export::HoldExport>) -> Self {
|
||||||
|
ResponseMethod::Set(SetResponseMethod::HoldExport(Box::new(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ base64 = "0.23"
|
|||||||
p256 = { version = "0.13", features = ["ecdh"] }
|
p256 = { version = "0.13", features = ["ecdh"] }
|
||||||
sha1 = "0.11"
|
sha1 = "0.11"
|
||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
|
zip = "8.6" # inbuxa: legal hold exports (LH-12)
|
||||||
reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2"]}
|
reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2"]}
|
||||||
tokio-tungstenite = "0.30"
|
tokio-tungstenite = "0.30"
|
||||||
tungstenite = "0.30"
|
tungstenite = "0.30"
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ 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,
|
||||||
|
GetRequestMethod::HoldExport(_) => Permission::SysLegalHoldExport,
|
||||||
// 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,
|
||||||
@@ -232,6 +233,14 @@ impl JmapAuthorization for AccessToken {
|
|||||||
Permission::SysLegalHoldUpdate,
|
Permission::SysLegalHoldUpdate,
|
||||||
Permission::SysLegalHoldUpdate,
|
Permission::SysLegalHoldUpdate,
|
||||||
),
|
),
|
||||||
|
// inbuxa: LH-12, exporting held data
|
||||||
|
SetRequestMethod::HoldExport(s) => validate_set(
|
||||||
|
s,
|
||||||
|
self,
|
||||||
|
Permission::SysLegalHoldExport,
|
||||||
|
Permission::SysLegalHoldExport,
|
||||||
|
Permission::SysLegalHoldExport,
|
||||||
|
),
|
||||||
SetRequestMethod::AuditVerification(s) => validate_set(
|
SetRequestMethod::AuditVerification(s) => validate_set(
|
||||||
s,
|
s,
|
||||||
self,
|
self,
|
||||||
@@ -380,6 +389,7 @@ impl JmapAuthorization for AccessToken {
|
|||||||
| MethodObject::AuditVerification
|
| MethodObject::AuditVerification
|
||||||
| MethodObject::AccountLock
|
| MethodObject::AccountLock
|
||||||
| MethodObject::LegalHold
|
| MethodObject::LegalHold
|
||||||
|
| MethodObject::HoldExport
|
||||||
| 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
|
||||||
|
|||||||
@@ -276,6 +276,9 @@ 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::HoldExport(set_response) => {
|
||||||
|
set_response.update_created_ids(&mut response);
|
||||||
|
}
|
||||||
SetResponseMethod::Explanation(set_response) => {
|
SetResponseMethod::Explanation(set_response) => {
|
||||||
set_response.update_created_ids(&mut response);
|
set_response.update_created_ids(&mut response);
|
||||||
}
|
}
|
||||||
@@ -450,6 +453,11 @@ impl RequestHandler for Server {
|
|||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
// inbuxa: legal hold (LH-1)
|
// inbuxa: legal hold (LH-1)
|
||||||
|
// inbuxa: legal hold exports (LH-12)
|
||||||
|
GetRequestMethod::HoldExport(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
crate::inbuxa::hold_export_api::get(self, *req).await?.into()
|
||||||
|
}
|
||||||
GetRequestMethod::LegalHold(mut req) => {
|
GetRequestMethod::LegalHold(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::legal_hold::get(self, *req).await?.into()
|
crate::inbuxa::legal_hold::get(self, *req).await?.into()
|
||||||
@@ -807,6 +815,34 @@ impl RequestHandler for Server {
|
|||||||
}
|
}
|
||||||
// inbuxa: legal hold (LH-1), each change recorded with its
|
// inbuxa: legal hold (LH-1), each change recorded with its
|
||||||
// reason (AU-12)
|
// reason (AU-12)
|
||||||
|
// inbuxa: legal hold exports, recorded with their reason
|
||||||
|
// (AU-1.9, AU-12)
|
||||||
|
SetRequestMethod::HoldExport(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
let reason = req.arguments.reason.clone().or_else(|| {
|
||||||
|
req.create.as_ref().and_then(|create| {
|
||||||
|
create.values().find_map(|value| {
|
||||||
|
serde_json::to_value(value)
|
||||||
|
.ok()?
|
||||||
|
.get("reason")?
|
||||||
|
.as_str()
|
||||||
|
.map(str::to_string)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
crate::inbuxa::audit::recorded(
|
||||||
|
self,
|
||||||
|
access_token,
|
||||||
|
session,
|
||||||
|
&method_name.obj.to_string(),
|
||||||
|
None,
|
||||||
|
reason,
|
||||||
|
*req,
|
||||||
|
|req| Box::pin(crate::inbuxa::hold_export_api::set(self, access_token, req)),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
SetRequestMethod::LegalHold(mut req) => {
|
SetRequestMethod::LegalHold(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)?;
|
||||||
let reason = req.arguments.reason.clone().or_else(|| {
|
let reason = req.arguments.reason.clone().or_else(|| {
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -425,6 +425,7 @@ impl IntermediateChangesResponse {
|
|||||||
| MethodObject::AuditVerification
|
| MethodObject::AuditVerification
|
||||||
| MethodObject::AccountLock
|
| MethodObject::AccountLock
|
||||||
| MethodObject::LegalHold
|
| MethodObject::LegalHold
|
||||||
|
| MethodObject::HoldExport
|
||||||
| MethodObject::ProtocolPolicy
|
| MethodObject::ProtocolPolicy
|
||||||
| MethodObject::TenantProtocolPolicy
|
| MethodObject::TenantProtocolPolicy
|
||||||
| MethodObject::Registry(_) => unreachable!(),
|
| MethodObject::Registry(_) => unreachable!(),
|
||||||
|
|||||||
@@ -376,7 +376,11 @@ async fn full_name(server: &Server, object: &str, value: &Value, name: Option<St
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> Option<Value> {
|
async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> Option<Value> {
|
||||||
use inbuxa_features::{ai::limits, audit::log, security};
|
use inbuxa_features::{
|
||||||
|
ai::limits,
|
||||||
|
audit::log,
|
||||||
|
security::{self, protocol_policy::Switches},
|
||||||
|
};
|
||||||
let data = server.store();
|
let data = server.store();
|
||||||
match object {
|
match object {
|
||||||
"inbuxa:AuditSettings" => log::settings(data)
|
"inbuxa:AuditSettings" => log::settings(data)
|
||||||
@@ -387,10 +391,17 @@ async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> O
|
|||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|limits| serde_json::to_value(limits).ok()),
|
.and_then(|limits| serde_json::to_value(limits).ok()),
|
||||||
"inbuxa:ProtocolPolicy" => security::protocol_policy::get(data)
|
// Normalized, so every switch reads before and after, even from a
|
||||||
|
// policy stored before the per-protocol switches
|
||||||
|
"inbuxa:ProtocolPolicy" => {
|
||||||
|
security::protocol_policy::get(data)
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|policy| serde_json::to_value(policy).ok()),
|
.and_then(|mut policy| {
|
||||||
|
policy.normalize();
|
||||||
|
serde_json::to_value(policy).ok()
|
||||||
|
})
|
||||||
|
}
|
||||||
// LH-1: a hold as the API shows it, so a change reads before/after
|
// LH-1: a hold as the API shows it, so a change reads before/after
|
||||||
"inbuxa:LegalHold" => match id {
|
"inbuxa:LegalHold" => match id {
|
||||||
MaybeInvalid::Value(id) => {
|
MaybeInvalid::Value(id) => {
|
||||||
@@ -424,7 +435,10 @@ async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> O
|
|||||||
security::tenant_protocol_policy::get(data, id.document_id())
|
security::tenant_protocol_policy::get(data, id.document_id())
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|policy| serde_json::to_value(policy).ok())
|
.and_then(|mut policy| {
|
||||||
|
policy.normalize();
|
||||||
|
serde_json::to_value(policy).ok()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
MaybeInvalid::Invalid(_) => None,
|
MaybeInvalid::Invalid(_) => None,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,535 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Collecting what a legal hold keeps, as a ZIP (audit-hold-lock spec,
|
||||||
|
//! LH-12). For each account the hold covers (or those asked for): its mail,
|
||||||
|
//! calendars, contacts and files, and the deleted items the hold keeps, each
|
||||||
|
//! with its SHA-256 in `manifest.csv`, and the manifest's own hash beside
|
||||||
|
//! it. The hold's date range applies as it does to what's kept (LH-3).
|
||||||
|
//! Anything the hold covers that can't be read goes in `exceptions.csv`
|
||||||
|
//! with the reason, never silently left out; the file is always there, so an
|
||||||
|
//! empty one says nothing was missed.
|
||||||
|
|
||||||
|
use common::{Server, hold::kept_member};
|
||||||
|
use email::{
|
||||||
|
cache::MessageCacheFetch,
|
||||||
|
message::metadata::{MESSAGE_RECEIVED_MASK, MessageMetadata},
|
||||||
|
};
|
||||||
|
use groupware::{cache::GroupwareCache, calendar::CalendarEvent, contact::ContactCard, file::FileNode};
|
||||||
|
use inbuxa_features::{
|
||||||
|
hold::{Hold, Keeping, is_held_until},
|
||||||
|
undelete::records,
|
||||||
|
};
|
||||||
|
use registry::schema::{prelude::ObjectType, structs::ArchivedItem};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::io::{Cursor, Write};
|
||||||
|
use store::{
|
||||||
|
ValueKey,
|
||||||
|
registry::RegistryQuery,
|
||||||
|
write::{AlignedBytes, Archive},
|
||||||
|
};
|
||||||
|
use trc::AddContext;
|
||||||
|
use types::{
|
||||||
|
collection::{Collection, SyncCollection},
|
||||||
|
field::EmailField,
|
||||||
|
id::Id,
|
||||||
|
};
|
||||||
|
use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
|
||||||
|
|
||||||
|
/// The largest ZIP built in memory. A bigger collection is refused with a
|
||||||
|
/// clear error rather than taking the node down; export fewer accounts.
|
||||||
|
pub const MAX_EXPORT: u64 = 2 * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// One line of `manifest.csv`.
|
||||||
|
struct Entry {
|
||||||
|
path: String,
|
||||||
|
account: String,
|
||||||
|
kind: &'static str,
|
||||||
|
folder: String,
|
||||||
|
date: Option<i64>,
|
||||||
|
archived: bool,
|
||||||
|
size: usize,
|
||||||
|
sha256: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line of `exceptions.csv`: an item the hold covers that couldn't be
|
||||||
|
/// read, where it would have gone and why.
|
||||||
|
struct Missing {
|
||||||
|
path: String,
|
||||||
|
account: String,
|
||||||
|
kind: &'static str,
|
||||||
|
folder: String,
|
||||||
|
date: Option<i64>,
|
||||||
|
archived: bool,
|
||||||
|
reason: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_BLOB: &str = "content not found in the blob store";
|
||||||
|
const NO_RECORD: &str = "stored record not found";
|
||||||
|
|
||||||
|
fn hex(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn csv(field: &str) -> String {
|
||||||
|
if field.contains([',', '"', '\n', '\r']) {
|
||||||
|
format!("\"{}\"", field.replace('"', "\"\""))
|
||||||
|
} else {
|
||||||
|
field.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A path segment that's safe in a ZIP: no separators, no leading dots.
|
||||||
|
fn segment(name: &str) -> String {
|
||||||
|
let cleaned: String = name
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c == '/' || c == '\\' || c.is_control() { '_' } else { c })
|
||||||
|
.collect();
|
||||||
|
let trimmed = cleaned.trim_start_matches('.').trim();
|
||||||
|
if trimmed.is_empty() { "_".into() } else { trimmed.chars().take(120).collect() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn date_text(at: Option<i64>) -> String {
|
||||||
|
at.map(|at| jmap_proto::types::date::UTCDate::from_timestamp(at).to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Builder {
|
||||||
|
zip: ZipWriter<Cursor<Vec<u8>>>,
|
||||||
|
entries: Vec<Entry>,
|
||||||
|
missing: Vec<Missing>,
|
||||||
|
written: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Builder {
|
||||||
|
fn new() -> Self {
|
||||||
|
Builder {
|
||||||
|
zip: ZipWriter::new(Cursor::new(Vec::new())),
|
||||||
|
entries: Vec::new(),
|
||||||
|
missing: Vec::new(),
|
||||||
|
written: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn add(
|
||||||
|
&mut self,
|
||||||
|
path: String,
|
||||||
|
bytes: &[u8],
|
||||||
|
account: &str,
|
||||||
|
kind: &'static str,
|
||||||
|
folder: &str,
|
||||||
|
date: Option<i64>,
|
||||||
|
archived: bool,
|
||||||
|
) -> trc::Result<()> {
|
||||||
|
self.written += bytes.len() as u64;
|
||||||
|
if self.written > MAX_EXPORT {
|
||||||
|
return Err(trc::StoreEvent::UnexpectedError
|
||||||
|
.into_err()
|
||||||
|
.details("The collection is larger than one export can hold (2 GB). Export fewer accounts at a time."));
|
||||||
|
}
|
||||||
|
// Unique within the ZIP, however names collide
|
||||||
|
let mut name = path.clone();
|
||||||
|
let mut n = 1;
|
||||||
|
while self.entries.iter().any(|e| e.path == name) {
|
||||||
|
n += 1;
|
||||||
|
name = match path.rsplit_once('.') {
|
||||||
|
Some((stem, ext)) if !stem.ends_with('/') => format!("{stem} ({n}).{ext}"),
|
||||||
|
_ => format!("{path} ({n})"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
|
||||||
|
self.zip
|
||||||
|
.start_file(name.as_str(), options)
|
||||||
|
.and_then(|_| self.zip.write_all(bytes).map_err(Into::into))
|
||||||
|
.map_err(|err| {
|
||||||
|
trc::StoreEvent::UnexpectedError
|
||||||
|
.into_err()
|
||||||
|
.details("Failed to write the export")
|
||||||
|
.reason(err)
|
||||||
|
})?;
|
||||||
|
self.entries.push(Entry {
|
||||||
|
path: name,
|
||||||
|
account: account.to_string(),
|
||||||
|
kind,
|
||||||
|
folder: folder.to_string(),
|
||||||
|
date,
|
||||||
|
archived,
|
||||||
|
size: bytes.len(),
|
||||||
|
sha256: hex(&Sha256::digest(bytes)),
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records an item the hold covers that couldn't be read.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn missing(
|
||||||
|
&mut self,
|
||||||
|
path: String,
|
||||||
|
account: &str,
|
||||||
|
kind: &'static str,
|
||||||
|
folder: &str,
|
||||||
|
date: Option<i64>,
|
||||||
|
archived: bool,
|
||||||
|
reason: &'static str,
|
||||||
|
) {
|
||||||
|
self.missing.push(Missing {
|
||||||
|
path,
|
||||||
|
account: account.to_string(),
|
||||||
|
kind,
|
||||||
|
folder: folder.to_string(),
|
||||||
|
date,
|
||||||
|
archived,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes the ZIP with its manifest, the exceptions and both hashes.
|
||||||
|
/// Returns the bytes and how many items went in.
|
||||||
|
fn finish(mut self) -> trc::Result<(Vec<u8>, usize)> {
|
||||||
|
let mut manifest = String::from("path,account,kind,folder,date,archived,size,sha256\n");
|
||||||
|
for e in &self.entries {
|
||||||
|
manifest.push_str(&format!(
|
||||||
|
"{},{},{},{},{},{},{},{}\n",
|
||||||
|
csv(&e.path),
|
||||||
|
csv(&e.account),
|
||||||
|
e.kind,
|
||||||
|
csv(&e.folder),
|
||||||
|
date_text(e.date),
|
||||||
|
e.archived,
|
||||||
|
e.size,
|
||||||
|
e.sha256
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut exceptions = String::from("path,account,kind,folder,date,archived,reason\n");
|
||||||
|
for m in &self.missing {
|
||||||
|
exceptions.push_str(&format!(
|
||||||
|
"{},{},{},{},{},{},{}\n",
|
||||||
|
csv(&m.path),
|
||||||
|
csv(&m.account),
|
||||||
|
m.kind,
|
||||||
|
csv(&m.folder),
|
||||||
|
date_text(m.date),
|
||||||
|
m.archived,
|
||||||
|
csv(m.reason)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let manifest_hash = hex(&Sha256::digest(manifest.as_bytes()));
|
||||||
|
let exceptions_hash = hex(&Sha256::digest(exceptions.as_bytes()));
|
||||||
|
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
|
||||||
|
let fail = |err: zip::result::ZipError| {
|
||||||
|
trc::StoreEvent::UnexpectedError
|
||||||
|
.into_err()
|
||||||
|
.details("Failed to write the export")
|
||||||
|
.reason(err)
|
||||||
|
};
|
||||||
|
self.zip.start_file("manifest.csv", options).map_err(fail)?;
|
||||||
|
self.zip.write_all(manifest.as_bytes()).map_err(|e| fail(e.into()))?;
|
||||||
|
self.zip.start_file("exceptions.csv", options).map_err(fail)?;
|
||||||
|
self.zip.write_all(exceptions.as_bytes()).map_err(|e| fail(e.into()))?;
|
||||||
|
self.zip.start_file("manifest.sha256", options).map_err(fail)?;
|
||||||
|
self.zip
|
||||||
|
.write_all(format!("{manifest_hash} manifest.csv\n{exceptions_hash} exceptions.csv\n").as_bytes())
|
||||||
|
.map_err(|e| fail(e.into()))?;
|
||||||
|
let items = self.entries.len();
|
||||||
|
let bytes = self.zip.finish().map_err(fail)?.into_inner();
|
||||||
|
Ok((bytes, items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The accounts to collect: those asked for that the hold covers, or every
|
||||||
|
/// account it covers, deleted ones it keeps included.
|
||||||
|
async fn accounts(server: &Server, hold: &Hold, asked: &[u32]) -> trc::Result<Vec<u32>> {
|
||||||
|
let mut covered = Vec::new();
|
||||||
|
for id in server
|
||||||
|
.registry()
|
||||||
|
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Account))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
{
|
||||||
|
let account_id = id.document_id();
|
||||||
|
if let Some(member) = server.member_of(account_id).await
|
||||||
|
&& hold.scope.covers(&member)
|
||||||
|
{
|
||||||
|
covered.push(account_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (account_id, kept) in inbuxa_features::undelete::data::kept_accounts(server.store()).await? {
|
||||||
|
if hold.scope.covers(&kept_member(account_id, &kept)) {
|
||||||
|
covered.push(account_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
covered.sort_unstable();
|
||||||
|
covered.dedup();
|
||||||
|
if !asked.is_empty() {
|
||||||
|
covered.retain(|id| asked.contains(id));
|
||||||
|
}
|
||||||
|
Ok(covered)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn blob(server: &Server, hash: &[u8]) -> trc::Result<Option<Vec<u8>>> {
|
||||||
|
server.blob_store().get_blob(hash, 0..usize::MAX).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collects `accounts` under `hold` into a ZIP. Returns its bytes and item
|
||||||
|
/// count.
|
||||||
|
pub async fn build(server: &Server, hold: &Hold, asked: &[u32]) -> trc::Result<(Vec<u8>, usize)> {
|
||||||
|
let keeping = Keeping::new(None, std::slice::from_ref(hold));
|
||||||
|
let data = server.store();
|
||||||
|
let mut out = Builder::new();
|
||||||
|
|
||||||
|
for account_id in accounts(server, hold, asked).await? {
|
||||||
|
let address = server.audit_account_name(account_id).await;
|
||||||
|
let base = format!("{}/", segment(&address));
|
||||||
|
let live = server.account(account_id).await.is_ok();
|
||||||
|
|
||||||
|
if live {
|
||||||
|
// Mail, by the folder it's in
|
||||||
|
let cache = server
|
||||||
|
.get_cached_messages(account_id)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
for message in cache.emails.items.iter() {
|
||||||
|
let mail_path = |folder: &str| {
|
||||||
|
format!(
|
||||||
|
"{base}mail/{}/{}.eml",
|
||||||
|
folder.split('/').map(segment).collect::<Vec<_>>().join("/"),
|
||||||
|
Id::from(message.document_id)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let folder = message
|
||||||
|
.mailboxes
|
||||||
|
.first()
|
||||||
|
.and_then(|m| cache.mailboxes.items.iter().find(|b| b.document_id == m.mailbox_id))
|
||||||
|
.map(|b| b.path.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let Some(metadata_) = data
|
||||||
|
.get_value::<Archive<AlignedBytes>>(ValueKey::property(
|
||||||
|
account_id,
|
||||||
|
Collection::Email,
|
||||||
|
message.document_id,
|
||||||
|
EmailField::Metadata,
|
||||||
|
))
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
// No date to check against the hold's range, so it's listed
|
||||||
|
out.missing(mail_path(&folder), &address, "email", &folder, None, false, NO_RECORD);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let metadata = metadata_
|
||||||
|
.unarchive::<MessageMetadata>()
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
let received = metadata.rcvd_attach.to_native() & MESSAGE_RECEIVED_MASK;
|
||||||
|
if !keeping.covers(Some(received)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let hash = types::blob_hash::BlobHash::from(&metadata.blob_hash);
|
||||||
|
match blob(server, hash.as_slice()).await? {
|
||||||
|
Some(bytes) => {
|
||||||
|
out.add(mail_path(&folder), &bytes, &address, "email", &folder, Some(received as i64), false)?
|
||||||
|
}
|
||||||
|
None => out.missing(
|
||||||
|
mail_path(&folder),
|
||||||
|
&address,
|
||||||
|
"email",
|
||||||
|
&folder,
|
||||||
|
Some(received as i64),
|
||||||
|
false,
|
||||||
|
NO_BLOB,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calendars, contacts and files, by their DAV paths
|
||||||
|
for (sync, kind) in [
|
||||||
|
(SyncCollection::Calendar, "event"),
|
||||||
|
(SyncCollection::AddressBook, "contact"),
|
||||||
|
(SyncCollection::FileNode, "file"),
|
||||||
|
] {
|
||||||
|
let resources = server
|
||||||
|
.fetch_dav_resources(account_id, account_id, sync)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
for path in resources.paths.iter() {
|
||||||
|
let Some(resource) = resources.resources.get(path.resource_idx) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let folder = path.path.rsplit_once('/').map(|(f, _)| f).unwrap_or_default();
|
||||||
|
let zip_path = |ext: Option<&str>| {
|
||||||
|
let mut p = format!(
|
||||||
|
"{base}{}/{}",
|
||||||
|
match kind {
|
||||||
|
"event" => "calendar",
|
||||||
|
"contact" => "contacts",
|
||||||
|
_ => "files",
|
||||||
|
},
|
||||||
|
path.path.split('/').map(segment).collect::<Vec<_>>().join("/")
|
||||||
|
);
|
||||||
|
if let Some(ext) = ext
|
||||||
|
&& !p.ends_with(ext)
|
||||||
|
{
|
||||||
|
p.push_str(ext);
|
||||||
|
}
|
||||||
|
p
|
||||||
|
};
|
||||||
|
use common::DavResourceMetadata as M;
|
||||||
|
match &resource.data {
|
||||||
|
M::CalendarEvent { start, .. } => {
|
||||||
|
if !keeping.covers_event(Some((*start).max(0) as u64)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(event_) = data
|
||||||
|
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||||
|
account_id,
|
||||||
|
Collection::CalendarEvent,
|
||||||
|
resource.document_id,
|
||||||
|
))
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
out.missing(zip_path(Some(".ics")), &address, kind, folder, Some(*start), false, NO_RECORD);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let event = event_.unarchive::<CalendarEvent>().caused_by(trc::location!())?;
|
||||||
|
let text = event.data.event.to_string();
|
||||||
|
out.add(zip_path(Some(".ics")), text.as_bytes(), &address, kind, folder, Some(*start), false)?;
|
||||||
|
}
|
||||||
|
M::ContactCard { .. } => {
|
||||||
|
let Some(card_) = data
|
||||||
|
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||||
|
account_id,
|
||||||
|
Collection::ContactCard,
|
||||||
|
resource.document_id,
|
||||||
|
))
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
out.missing(zip_path(Some(".vcf")), &address, kind, folder, None, false, NO_RECORD);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let card = card_.unarchive::<ContactCard>().caused_by(trc::location!())?;
|
||||||
|
let mut text = String::with_capacity(256);
|
||||||
|
let _ = card.card.write_to(&mut text, server.core.groupware.vcard_version);
|
||||||
|
out.add(zip_path(Some(".vcf")), text.as_bytes(), &address, kind, folder, None, false)?;
|
||||||
|
}
|
||||||
|
M::File { size: Some(_), .. } => {
|
||||||
|
let Some(file_) = data
|
||||||
|
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||||
|
account_id,
|
||||||
|
Collection::FileNode,
|
||||||
|
resource.document_id,
|
||||||
|
))
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
out.missing(zip_path(None), &address, kind, folder, None, false, NO_RECORD);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let file = file_.unarchive::<FileNode>().caused_by(trc::location!())?;
|
||||||
|
let Some(props) = file.file.as_ref() else {
|
||||||
|
out.missing(zip_path(None), &address, kind, folder, None, false, NO_RECORD);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let hash = types::blob_hash::BlobHash::from(&props.blob_hash);
|
||||||
|
match blob(server, hash.as_slice()).await? {
|
||||||
|
Some(bytes) => out.add(zip_path(None), &bytes, &address, kind, folder, None, false)?,
|
||||||
|
None => out.missing(zip_path(None), &address, kind, folder, None, false, NO_BLOB),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the hold keeps of what was deleted
|
||||||
|
for (id, item) in records::of_account(data, server.registry(), account_id).await? {
|
||||||
|
if !is_held_until(item.archived_until().timestamp().max(0) as u64) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (kind, ext, date) = match &item {
|
||||||
|
ArchivedItem::Email(e) => ("email", ".eml", Some(e.received_at.timestamp())),
|
||||||
|
ArchivedItem::CalendarEvent(e) => ("event", ".ics", e.start_time.map(|t| t.timestamp())),
|
||||||
|
ArchivedItem::ContactCard(_) => ("contact", ".vcf", None),
|
||||||
|
ArchivedItem::FileNode(_) => ("file", "", None),
|
||||||
|
ArchivedItem::SieveScript(_) => ("sieve", ".sieve", None),
|
||||||
|
};
|
||||||
|
// Kept by this hold, not only by another one over the same account
|
||||||
|
let in_range = match kind {
|
||||||
|
"event" => keeping.covers_event(date.map(|d| d.max(0) as u64)),
|
||||||
|
_ => keeping.covers(date.map(|d| d.max(0) as u64)),
|
||||||
|
};
|
||||||
|
if !in_range {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = match &item {
|
||||||
|
ArchivedItem::FileNode(f) => segment(&f.name),
|
||||||
|
ArchivedItem::SieveScript(s) => format!("{}{ext}", segment(&s.name)),
|
||||||
|
_ => format!("{id}{ext}"),
|
||||||
|
};
|
||||||
|
let path = format!("{base}archived/{kind}/{name}");
|
||||||
|
match blob(server, item.blob_id().hash.as_slice()).await? {
|
||||||
|
Some(bytes) => out.add(path, &bytes, &address, kind, "", date, true)?,
|
||||||
|
None => out.missing(path, &address, kind, "", date, true, NO_BLOB),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn names_are_safe_in_a_zip() {
|
||||||
|
assert_eq!(segment("../etc/passwd"), "_etc_passwd");
|
||||||
|
assert_eq!(segment(" "), "_");
|
||||||
|
assert_eq!(segment("Q3 report.pdf"), "Q3 report.pdf");
|
||||||
|
assert_eq!(csv("a,b"), "\"a,b\"");
|
||||||
|
assert_eq!(csv("say \"hi\""), "\"say \"\"hi\"\"\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_zip_carries_its_manifest_and_its_hash() {
|
||||||
|
let mut b = Builder::new();
|
||||||
|
b.add("[email protected]/mail/INBOX/b.eml".into(), b"Subject: x\r\n\r\ny", "[email protected]", "email", "INBOX", Some(0), false)
|
||||||
|
.unwrap();
|
||||||
|
b.add("[email protected]/mail/INBOX/b.eml".into(), b"other", "[email protected]", "email", "INBOX", None, true)
|
||||||
|
.unwrap();
|
||||||
|
let (bytes, items) = b.finish().unwrap();
|
||||||
|
assert_eq!(items, 2);
|
||||||
|
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||||
|
let mut manifest = String::new();
|
||||||
|
std::io::Read::read_to_string(&mut zip.by_name("manifest.csv").unwrap(), &mut manifest).unwrap();
|
||||||
|
assert!(manifest.contains("[email protected]/mail/INBOX/b (2).eml"), "{manifest}");
|
||||||
|
let mut hash = String::new();
|
||||||
|
std::io::Read::read_to_string(&mut zip.by_name("manifest.sha256").unwrap(), &mut hash).unwrap();
|
||||||
|
assert!(hash.starts_with(&hex(&Sha256::digest(manifest.as_bytes()))));
|
||||||
|
// Nothing missed, and the file says so
|
||||||
|
let mut exceptions = String::new();
|
||||||
|
std::io::Read::read_to_string(&mut zip.by_name("exceptions.csv").unwrap(), &mut exceptions).unwrap();
|
||||||
|
assert_eq!(exceptions, "path,account,kind,folder,date,archived,reason\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn what_cant_be_read_is_listed_not_dropped() {
|
||||||
|
let mut b = Builder::new();
|
||||||
|
b.add("[email protected]/mail/INBOX/1.eml".into(), b"Subject: x\r\n\r\ny", "[email protected]", "email", "INBOX", Some(0), false)
|
||||||
|
.unwrap();
|
||||||
|
b.missing("[email protected]/mail/INBOX/2.eml".into(), "[email protected]", "email", "INBOX", Some(0), false, NO_BLOB);
|
||||||
|
let (bytes, items) = b.finish().unwrap();
|
||||||
|
assert_eq!(items, 1, "a missing item isn't counted as collected");
|
||||||
|
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||||
|
assert!(zip.by_name("[email protected]/mail/INBOX/2.eml").is_err());
|
||||||
|
let mut exceptions = String::new();
|
||||||
|
std::io::Read::read_to_string(&mut zip.by_name("exceptions.csv").unwrap(), &mut exceptions).unwrap();
|
||||||
|
assert!(
|
||||||
|
exceptions.contains("[email protected]/mail/INBOX/2.eml,[email protected],email,INBOX,") && exceptions.contains(NO_BLOB),
|
||||||
|
"{exceptions}"
|
||||||
|
);
|
||||||
|
let mut hash = String::new();
|
||||||
|
std::io::Read::read_to_string(&mut zip.by_name("manifest.sha256").unwrap(), &mut hash).unwrap();
|
||||||
|
assert!(hash.contains(&format!("{} exceptions.csv", hex(&Sha256::digest(exceptions.as_bytes())))));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:HoldExport` (audit-hold-lock spec, LH-12): starting a collection
|
||||||
|
//! of what a hold keeps, and seeing how it went. The ZIP is the creator's
|
||||||
|
//! blob, to download once it's ready. Creating one is audited, with its
|
||||||
|
//! reason (AU-1.9, AU-12).
|
||||||
|
|
||||||
|
use common::{Server, auth::AccessToken};
|
||||||
|
use inbuxa_features::hold::{self, Export, ExportStatus};
|
||||||
|
use jmap_proto::{
|
||||||
|
error::set::SetError,
|
||||||
|
method::{
|
||||||
|
get::{GetRequest, GetResponse},
|
||||||
|
set::{SetRequest, SetResponse},
|
||||||
|
},
|
||||||
|
object::inbuxa_hold_export::{
|
||||||
|
HoldExport, HoldExportProperty as P, HoldExportSetArguments, HoldExportValue,
|
||||||
|
},
|
||||||
|
types::date::UTCDate,
|
||||||
|
};
|
||||||
|
use jmap_tools::{Key, Map, Value};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::str::FromStr;
|
||||||
|
use store::write::now;
|
||||||
|
use types::id::Id;
|
||||||
|
|
||||||
|
type LValue = Value<'static, P, HoldExportValue>;
|
||||||
|
|
||||||
|
const ALL: &[P] = &[
|
||||||
|
P::Id,
|
||||||
|
P::HoldId,
|
||||||
|
P::AccountIds,
|
||||||
|
P::Reason,
|
||||||
|
P::Status,
|
||||||
|
P::CreatedAt,
|
||||||
|
P::CreatedBy,
|
||||||
|
P::FinishedAt,
|
||||||
|
P::BlobId,
|
||||||
|
P::Size,
|
||||||
|
P::Items,
|
||||||
|
P::Sha256,
|
||||||
|
P::Error,
|
||||||
|
];
|
||||||
|
|
||||||
|
fn date(seconds: u64) -> LValue {
|
||||||
|
Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn opt_text(value: &Option<String>) -> LValue {
|
||||||
|
value.as_ref().map_or(Value::Null, |v| Value::Str(v.clone().into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_value(export: &Export, properties: &[P]) -> LValue {
|
||||||
|
let mut out = Map::with_capacity(properties.len());
|
||||||
|
for property in properties {
|
||||||
|
let value = match property {
|
||||||
|
P::Id => Value::Element(HoldExportValue::Id(Id::from(export.id))),
|
||||||
|
P::HoldId => Value::Str(Id::from(export.hold_id).to_string().into()),
|
||||||
|
P::AccountIds => Value::Array(
|
||||||
|
export
|
||||||
|
.accounts
|
||||||
|
.iter()
|
||||||
|
.map(|id| Value::Str(Id::from(*id).to_string().into()))
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
P::Reason => Value::Str(export.reason.clone().into()),
|
||||||
|
P::Status => Value::Str(
|
||||||
|
match export.status {
|
||||||
|
ExportStatus::Running => "running",
|
||||||
|
ExportStatus::Ready => "ready",
|
||||||
|
ExportStatus::Failed => "failed",
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
P::CreatedAt => date(export.created_at),
|
||||||
|
P::CreatedBy => Value::Str(export.created_by.clone().into()),
|
||||||
|
P::FinishedAt => export.finished_at.map_or(Value::Null, date),
|
||||||
|
P::BlobId => opt_text(&export.blob_id),
|
||||||
|
P::Size => Value::Number(export.size.into()),
|
||||||
|
P::Items => Value::Number(export.items.into()),
|
||||||
|
P::Sha256 => opt_text(&export.sha256),
|
||||||
|
P::Error => opt_text(&export.error),
|
||||||
|
};
|
||||||
|
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||||
|
}
|
||||||
|
Value::Object(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `inbuxa:HoldExport/get`: every export, newest first.
|
||||||
|
pub async fn get(
|
||||||
|
server: &Server,
|
||||||
|
mut request: GetRequest<HoldExport>,
|
||||||
|
) -> trc::Result<GetResponse<HoldExport>> {
|
||||||
|
let properties = request.unwrap_properties(ALL);
|
||||||
|
let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?;
|
||||||
|
let mut response = GetResponse {
|
||||||
|
account_id: request.account_id.into(),
|
||||||
|
state: None,
|
||||||
|
list: Vec::new(),
|
||||||
|
not_found,
|
||||||
|
};
|
||||||
|
let mut exports = hold::exports(server.store()).await?;
|
||||||
|
exports.reverse();
|
||||||
|
match ids {
|
||||||
|
None => response
|
||||||
|
.list
|
||||||
|
.extend(exports.iter().map(|e| to_value(e, &properties))),
|
||||||
|
Some(ids) => {
|
||||||
|
for id in ids {
|
||||||
|
match exports.iter().find(|e| u64::from(e.id) == id.id()) {
|
||||||
|
Some(export) => response.list.push(to_value(export, &properties)),
|
||||||
|
None => response.push_not_found(id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid(property: P, why: &str) -> SetError<P> {
|
||||||
|
SetError::invalid_properties()
|
||||||
|
.with_property(property)
|
||||||
|
.with_description(why.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `inbuxa:HoldExport/set`: create starts an export; nothing else is
|
||||||
|
/// allowed. The request layer records it with its reason.
|
||||||
|
pub async fn set(
|
||||||
|
server: &Server,
|
||||||
|
access_token: &AccessToken,
|
||||||
|
mut request: SetRequest<'_, HoldExport>,
|
||||||
|
) -> trc::Result<SetResponse<HoldExport>> {
|
||||||
|
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
|
||||||
|
let arguments: HoldExportSetArguments = std::mem::take(&mut request.arguments);
|
||||||
|
let data = server.store();
|
||||||
|
let actor = server.audit_actor(access_token).await;
|
||||||
|
|
||||||
|
'create: for (client_id, value) in request.unwrap_create() {
|
||||||
|
let mut hold_id = None;
|
||||||
|
let mut accounts = Vec::new();
|
||||||
|
let mut reason = arguments.reason.clone();
|
||||||
|
for (key, value) in value.into_expanded_object() {
|
||||||
|
match (&key, &value) {
|
||||||
|
(Key::Property(P::HoldId), Value::Str(id)) => {
|
||||||
|
hold_id = Id::from_str(id).ok().and_then(|id| u32::try_from(id.id()).ok())
|
||||||
|
}
|
||||||
|
(Key::Property(P::AccountIds), Value::Array(items)) => {
|
||||||
|
for item in items {
|
||||||
|
match item {
|
||||||
|
Value::Str(id) => match Id::from_str(id) {
|
||||||
|
Ok(id) => accounts.push(id.document_id()),
|
||||||
|
Err(_) => {
|
||||||
|
response.not_created.append(
|
||||||
|
client_id,
|
||||||
|
invalid(P::AccountIds, "accountIds must be account ids."),
|
||||||
|
);
|
||||||
|
continue 'create;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
response.not_created.append(
|
||||||
|
client_id,
|
||||||
|
invalid(P::AccountIds, "accountIds must be account ids."),
|
||||||
|
);
|
||||||
|
continue 'create;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Key::Property(P::Reason), Value::Str(r)) => reason = Some(r.to_string()),
|
||||||
|
_ => {
|
||||||
|
response.not_created.append(
|
||||||
|
client_id,
|
||||||
|
SetError::invalid_properties().with_property(key.clone().into_owned()),
|
||||||
|
);
|
||||||
|
continue 'create;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(reason) = reason
|
||||||
|
.map(|r| r.trim().chars().take(500).collect::<String>())
|
||||||
|
.filter(|r| !r.is_empty())
|
||||||
|
else {
|
||||||
|
response.not_created.append(
|
||||||
|
client_id,
|
||||||
|
invalid(P::Reason, "Say why: a reason is required and is kept in the audit log."),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let hold = match hold_id {
|
||||||
|
Some(id) => hold::get(data, id).await?,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let Some(hold) = hold else {
|
||||||
|
response
|
||||||
|
.not_created
|
||||||
|
.append(client_id, invalid(P::HoldId, "No such legal hold."));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !hold.is_active() {
|
||||||
|
response.not_created.append(
|
||||||
|
client_id,
|
||||||
|
invalid(P::HoldId, "That hold was released; export while a hold is in place."),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(created_by_id) = actor.account_id else {
|
||||||
|
response
|
||||||
|
.not_created
|
||||||
|
.append(client_id, SetError::forbidden().with_description("Sign in as a person to export."));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
accounts.sort_unstable();
|
||||||
|
accounts.dedup();
|
||||||
|
let export = Export {
|
||||||
|
id: 0,
|
||||||
|
hold_id: hold.id,
|
||||||
|
accounts,
|
||||||
|
reason,
|
||||||
|
created_at: now(),
|
||||||
|
created_by: actor.name.clone(),
|
||||||
|
created_by_id,
|
||||||
|
status: ExportStatus::Running,
|
||||||
|
finished_at: None,
|
||||||
|
blob_id: None,
|
||||||
|
size: 0,
|
||||||
|
items: 0,
|
||||||
|
sha256: None,
|
||||||
|
error: None,
|
||||||
|
};
|
||||||
|
let id = hold::create_export(data, &export).await?;
|
||||||
|
let export = Export { id, ..export };
|
||||||
|
|
||||||
|
// The collection runs on its own; get says when it's ready
|
||||||
|
let server = server.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut done = export.clone();
|
||||||
|
match crate::inbuxa::hold_export::build(&server, &hold, &export.accounts).await {
|
||||||
|
Ok((bytes, items)) => match server.put_jmap_blob(export.created_by_id, &bytes).await {
|
||||||
|
Ok(blob) => {
|
||||||
|
done.status = ExportStatus::Ready;
|
||||||
|
done.blob_id = Some(blob.to_string());
|
||||||
|
done.size = bytes.len() as u64;
|
||||||
|
done.items = items as u64;
|
||||||
|
done.sha256 = Some(
|
||||||
|
Sha256::digest(&bytes).iter().map(|b| format!("{b:02x}")).collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
done.status = ExportStatus::Failed;
|
||||||
|
done.error = Some(err.to_string());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(err) => {
|
||||||
|
done.status = ExportStatus::Failed;
|
||||||
|
done.error = Some(
|
||||||
|
err.value_as_str(trc::Key::Details)
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| err.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done.finished_at = Some(now());
|
||||||
|
if let Err(err) = hold::update_export(server.store(), &done).await {
|
||||||
|
trc::error!(err.details("Failed to save a legal hold export's result"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut out = Map::with_capacity(1);
|
||||||
|
out.insert_unchecked(
|
||||||
|
Key::Property(P::Id),
|
||||||
|
Value::Element(HoldExportValue::Id(Id::from(id))),
|
||||||
|
);
|
||||||
|
response.created.insert(client_id, Value::Object(out));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (id, _) in request.unwrap_update() {
|
||||||
|
response.not_updated.append(
|
||||||
|
id,
|
||||||
|
SetError::forbidden().with_description("An export can't be changed; start a new one."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for id in request.unwrap_destroy() {
|
||||||
|
response.not_destroyed.append(
|
||||||
|
id,
|
||||||
|
SetError::forbidden().with_description("Exports stay listed; the file expires on its own."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
pub mod access;
|
pub mod access;
|
||||||
pub mod account_lock;
|
pub mod account_lock;
|
||||||
pub mod legal_hold;
|
pub mod legal_hold;
|
||||||
|
pub mod hold_export;
|
||||||
|
pub mod hold_export_api;
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
pub mod audit_log;
|
pub mod audit_log;
|
||||||
pub mod ai_limits;
|
pub mod ai_limits;
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ use common::{
|
|||||||
};
|
};
|
||||||
use inbuxa_features::security::{
|
use inbuxa_features::security::{
|
||||||
listeners,
|
listeners,
|
||||||
protocol_policy::{LOCKED_PROTOCOLS, LegacyProtocols, ProtocolPolicy as Policy, SavedListener},
|
protocol_policy::{
|
||||||
|
LOCKED_PROTOCOLS, LegacyProtocols, ProtocolPolicy as Policy, SavedListener, Switches,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use jmap_proto::{
|
use jmap_proto::{
|
||||||
error::set::SetError,
|
error::set::SetError,
|
||||||
@@ -48,6 +50,9 @@ type PValue = Value<'static, P, ProtocolPolicyValue>;
|
|||||||
const ALL: &[P] = &[
|
const ALL: &[P] = &[
|
||||||
P::Id,
|
P::Id,
|
||||||
P::LegacyProtocols,
|
P::LegacyProtocols,
|
||||||
|
P::Imap,
|
||||||
|
P::Pop3,
|
||||||
|
P::ManageSieve,
|
||||||
P::CloseSubmission,
|
P::CloseSubmission,
|
||||||
P::SavedListeners,
|
P::SavedListeners,
|
||||||
P::ChangedAt,
|
P::ChangedAt,
|
||||||
@@ -91,22 +96,49 @@ fn listener_value(listener: &SavedListener) -> PValue {
|
|||||||
Value::Object(out)
|
Value::Object(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A switch as JMAP spells it.
|
||||||
|
pub(crate) fn switch_str(value: LegacyProtocols) -> &'static str {
|
||||||
|
match value {
|
||||||
|
LegacyProtocols::Enabled => "enabled",
|
||||||
|
LegacyProtocols::Disabled => "disabled",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A switch from JMAP.
|
||||||
|
pub(crate) fn parse_switch(value: Option<&str>) -> Result<LegacyProtocols, String> {
|
||||||
|
match value {
|
||||||
|
Some("enabled") => Ok(LegacyProtocols::Enabled),
|
||||||
|
Some("disabled") => Ok(LegacyProtocols::Disabled),
|
||||||
|
_ => Err(r#"must be "enabled" or "disabled""#.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The JMAP name of a per-protocol switch property.
|
||||||
|
pub(crate) fn switch_name(property: &P) -> Option<&'static str> {
|
||||||
|
match property {
|
||||||
|
P::Imap => Some("imap"),
|
||||||
|
P::Pop3 => Some("pop3"),
|
||||||
|
P::ManageSieve => Some("manageSieve"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn to_value(
|
fn to_value(
|
||||||
policy: &Policy,
|
policy: &Policy,
|
||||||
would_close: &[SavedListener],
|
would_close: &[SavedListener],
|
||||||
recent: &[RecentUse],
|
recent: &[RecentUse],
|
||||||
properties: &[P],
|
properties: &[P],
|
||||||
) -> PValue {
|
) -> PValue {
|
||||||
|
let mut policy = policy.clone();
|
||||||
|
policy.normalize();
|
||||||
|
let policy = &policy;
|
||||||
let mut out = Map::with_capacity(properties.len());
|
let mut out = Map::with_capacity(properties.len());
|
||||||
for property in properties {
|
for property in properties {
|
||||||
let value = match property {
|
let value = match property {
|
||||||
P::Id => Value::Element(ProtocolPolicyValue::Id(Id::singleton())),
|
P::Id => Value::Element(ProtocolPolicyValue::Id(Id::singleton())),
|
||||||
P::LegacyProtocols => Value::Str(
|
P::LegacyProtocols => Value::Str(switch_str(policy.legacy_protocols).into()),
|
||||||
match policy.legacy_protocols {
|
P::Imap | P::Pop3 | P::ManageSieve => Value::Str(
|
||||||
LegacyProtocols::Enabled => "enabled",
|
switch_str(policy.switch(switch_name(property).unwrap_or_default())).into(),
|
||||||
LegacyProtocols::Disabled => "disabled",
|
|
||||||
}
|
|
||||||
.into(),
|
|
||||||
),
|
),
|
||||||
P::CloseSubmission => Value::Bool(policy.close_submission),
|
P::CloseSubmission => Value::Bool(policy.close_submission),
|
||||||
P::SavedListeners => Value::Array(
|
P::SavedListeners => Value::Array(
|
||||||
@@ -176,10 +208,11 @@ where
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The listeners turning the switch on would close, whatever it is now.
|
/// The listeners turning every protocol off would close, whatever the switches
|
||||||
|
/// are now; each names its protocol, so the console shows one protocol's.
|
||||||
async fn would_close(server: &Server, policy: &Policy) -> trc::Result<Vec<SavedListener>> {
|
async fn would_close(server: &Server, policy: &Policy) -> trc::Result<Vec<SavedListener>> {
|
||||||
let mut hypothetical = policy.clone();
|
let mut hypothetical = policy.clone();
|
||||||
hypothetical.legacy_protocols = LegacyProtocols::Disabled;
|
hypothetical.set_all(LegacyProtocols::Disabled);
|
||||||
hypothetical.apply_locks();
|
hypothetical.apply_locks();
|
||||||
listeners::would_close(server.registry(), &hypothetical).await
|
listeners::would_close(server.registry(), &hypothetical).await
|
||||||
}
|
}
|
||||||
@@ -238,12 +271,12 @@ fn apply(
|
|||||||
value: &Value<'_, P, ProtocolPolicyValue>,
|
value: &Value<'_, P, ProtocolPolicyValue>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
match property {
|
match property {
|
||||||
P::LegacyProtocols => {
|
// The kill-all sets all three; a protocol named in the same /set is
|
||||||
policy.legacy_protocols = match value.as_str().as_deref() {
|
// applied after it (see `set`), so it wins.
|
||||||
Some("enabled") => LegacyProtocols::Enabled,
|
P::LegacyProtocols => policy.set_all(parse_switch(value.as_str().as_deref())?),
|
||||||
Some("disabled") => LegacyProtocols::Disabled,
|
P::Imap | P::Pop3 | P::ManageSieve => {
|
||||||
_ => return Err(r#"must be "enabled" or "disabled""#.to_string()),
|
let value = parse_switch(value.as_str().as_deref())?;
|
||||||
}
|
policy.set(switch_name(property).unwrap_or_default(), value);
|
||||||
}
|
}
|
||||||
P::CloseSubmission => {
|
P::CloseSubmission => {
|
||||||
policy.close_submission = value
|
policy.close_submission = value
|
||||||
@@ -262,7 +295,13 @@ fn apply(
|
|||||||
/// Puts a property back to its default (a `null` in `/set`).
|
/// Puts a property back to its default (a `null` in `/set`).
|
||||||
fn reset(policy: &mut Policy, property: &P, defaults: &Policy) -> Result<(), String> {
|
fn reset(policy: &mut Policy, property: &P, defaults: &Policy) -> Result<(), String> {
|
||||||
match property {
|
match property {
|
||||||
P::LegacyProtocols => policy.legacy_protocols = defaults.legacy_protocols,
|
P::LegacyProtocols => policy.set_all(defaults.legacy_protocols),
|
||||||
|
P::Imap | P::Pop3 | P::ManageSieve => {
|
||||||
|
policy.set(
|
||||||
|
switch_name(property).unwrap_or_default(),
|
||||||
|
LegacyProtocols::Enabled,
|
||||||
|
);
|
||||||
|
}
|
||||||
P::CloseSubmission => policy.close_submission = defaults.close_submission,
|
P::CloseSubmission => policy.close_submission = defaults.close_submission,
|
||||||
P::Id => return Err("is immutable".to_string()),
|
P::Id => return Err("is immutable".to_string()),
|
||||||
other if other.is_server_set() => return Err("is set by the server".to_string()),
|
other if other.is_server_set() => return Err("is set by the server".to_string()),
|
||||||
@@ -312,10 +351,14 @@ pub async fn set(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut policy = server.protocol_policy().await?;
|
let mut policy = server.protocol_policy().await?;
|
||||||
|
policy.normalize();
|
||||||
let defaults = Policy::default();
|
let defaults = Policy::default();
|
||||||
let mut error = None;
|
let mut error = None;
|
||||||
|
|
||||||
for (key, value) in value.into_expanded_object() {
|
// The kill-all first, so a protocol named beside it overrides it.
|
||||||
|
let mut entries: Vec<_> = value.into_expanded_object().collect();
|
||||||
|
entries.sort_by_key(|(key, _)| !matches!(key, Key::Property(P::LegacyProtocols)));
|
||||||
|
for (key, value) in entries {
|
||||||
let Key::Property(property) = &key else {
|
let Key::Property(property) = &key else {
|
||||||
error = Some(SetError::invalid_properties().with_property(key.into_owned()));
|
error = Some(SetError::invalid_properties().with_property(key.into_owned()));
|
||||||
break;
|
break;
|
||||||
@@ -393,21 +436,30 @@ fn listener_refusal(policy: &Policy, listener: &NetworkListener) -> Option<(Prop
|
|||||||
let protocol = listeners::protocol_name(listener.protocol);
|
let protocol = listeners::protocol_name(listener.protocol);
|
||||||
// A submission listener closes because of its port, not its protocol
|
// A submission listener closes because of its port, not its protocol
|
||||||
// (LP-3), so the port is what would have to change.
|
// (LP-3), so the port is what would have to change.
|
||||||
let property = if protocol == "smtp" {
|
let (property, what) = if protocol == "smtp" {
|
||||||
Property::Bind
|
(Property::Bind, "Legacy mail protocols are".to_string())
|
||||||
} else {
|
} else {
|
||||||
Property::Protocol
|
(Property::Protocol, format!("{} is", display_name(protocol)))
|
||||||
};
|
};
|
||||||
Some((
|
Some((
|
||||||
property,
|
property,
|
||||||
format!(
|
format!(
|
||||||
"Legacy mail protocols are off (inbuxa:ProtocolPolicy), and this {protocol} \
|
"{what} off (inbuxa:ProtocolPolicy), and this {protocol} listener would reopen \
|
||||||
listener would reopen a port the switch keeps closed. Turn legacy protocols \
|
a port the switch keeps closed. Turn it back on first."
|
||||||
back on first."
|
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A protocol's name as people read it.
|
||||||
|
fn display_name(protocol: &str) -> &str {
|
||||||
|
match protocol {
|
||||||
|
"imap" => "IMAP",
|
||||||
|
"pop3" => "POP3",
|
||||||
|
"manageSieve" => "ManageSieve",
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -461,6 +513,36 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_protocol_off_refuses_only_its_listeners() {
|
||||||
|
let mut policy = Policy::default();
|
||||||
|
policy.set("pop3", LegacyProtocols::Disabled);
|
||||||
|
policy.normalize();
|
||||||
|
let (property, why) = listener_refusal(
|
||||||
|
&policy,
|
||||||
|
&listener(NetworkListenerProtocol::Pop3, "[::]:995"),
|
||||||
|
)
|
||||||
|
.expect("refused");
|
||||||
|
assert_eq!(property, Property::Protocol);
|
||||||
|
assert!(why.starts_with("POP3 is off"), "{why}");
|
||||||
|
assert!(
|
||||||
|
listener_refusal(
|
||||||
|
&policy,
|
||||||
|
&listener(NetworkListenerProtocol::Imap, "[::]:993")
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_switch_reads_and_parses_as_jmap_spells_it() {
|
||||||
|
assert_eq!(switch_str(LegacyProtocols::Disabled), "disabled");
|
||||||
|
assert_eq!(parse_switch(Some("enabled")), Ok(LegacyProtocols::Enabled));
|
||||||
|
assert!(parse_switch(Some("off")).is_err());
|
||||||
|
assert_eq!(switch_name(&P::ManageSieve), Some("manageSieve"));
|
||||||
|
assert_eq!(switch_name(&P::CloseSubmission), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn off_still_allows_what_the_switch_never_closes() {
|
fn off_still_allows_what_the_switch_never_closes() {
|
||||||
// Locked (LP-21) and inbound (LP-3): the switch doesn't close them,
|
// Locked (LP-21) and inbound (LP-3): the switch doesn't close them,
|
||||||
|
|||||||
@@ -12,16 +12,22 @@
|
|||||||
//! with no ids answers with it, and any other id is `notFound`. At server
|
//! with no ids answers with it, and any other id is `notFound`. At server
|
||||||
//! level, `/get` with no ids answers with every tenant's.
|
//! level, `/get` with no ids answers with every tenant's.
|
||||||
//!
|
//!
|
||||||
//! Turning it off never needs the server's leave; turning it back on is
|
//! Each of IMAP, POP3 and ManageSieve has its own switch, and
|
||||||
//! refused with `forbidden` while the server has legacy protocols off (LP-9).
|
//! `legacyProtocols` is the kill-all, as on the server's policy. Turning one
|
||||||
|
//! off never needs the server's leave; turning one back on is refused with
|
||||||
|
//! `forbidden` while the server has that protocol off (LP-9).
|
||||||
//! A tenant's switch closes no port (LP-13) -- sign-in and client
|
//! A tenant's switch closes no port (LP-13) -- sign-in and client
|
||||||
//! configuration read it (LP-10, LP-14a).
|
//! configuration read it (LP-10, LP-14a).
|
||||||
|
|
||||||
use crate::inbuxa::protocol_policy::recent_value;
|
use crate::inbuxa::protocol_policy::{parse_switch, recent_value, switch_str};
|
||||||
use common::{Server, auth::AccessToken, network::legacy::RecentUse};
|
use common::{
|
||||||
|
Server,
|
||||||
|
auth::AccessToken,
|
||||||
|
network::legacy::{RecentUse, switches_value},
|
||||||
|
};
|
||||||
use inbuxa_features::{
|
use inbuxa_features::{
|
||||||
security::{
|
security::{
|
||||||
protocol_policy::LegacyProtocols,
|
protocol_policy::{LegacyProtocols, SWITCHED, Switches},
|
||||||
tenant_protocol_policy::{self, TenantProtocolPolicy as Policy, refusal},
|
tenant_protocol_policy::{self, TenantProtocolPolicy as Policy, refusal},
|
||||||
},
|
},
|
||||||
tenancy::quota::all_tenants,
|
tenancy::quota::all_tenants,
|
||||||
@@ -46,6 +52,9 @@ const ALL: &[P] = &[
|
|||||||
P::Id,
|
P::Id,
|
||||||
P::TenantId,
|
P::TenantId,
|
||||||
P::LegacyProtocols,
|
P::LegacyProtocols,
|
||||||
|
P::Imap,
|
||||||
|
P::Pop3,
|
||||||
|
P::ManageSieve,
|
||||||
P::ChangedAt,
|
P::ChangedAt,
|
||||||
P::ChangedBy,
|
P::ChangedBy,
|
||||||
P::RecentLegacyUse,
|
P::RecentLegacyUse,
|
||||||
@@ -60,19 +69,29 @@ async fn reachable(server: &Server, access_token: &AccessToken) -> trc::Result<V
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The JMAP name of a per-protocol switch property.
|
||||||
|
fn switch_name(property: &P) -> Option<&'static str> {
|
||||||
|
match property {
|
||||||
|
P::Imap => Some("imap"),
|
||||||
|
P::Pop3 => Some("pop3"),
|
||||||
|
P::ManageSieve => Some("manageSieve"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn to_value(tenant_id: u32, policy: &Policy, recent: &[RecentUse], properties: &[P]) -> PValue {
|
fn to_value(tenant_id: u32, policy: &Policy, recent: &[RecentUse], properties: &[P]) -> PValue {
|
||||||
|
let mut policy = policy.clone();
|
||||||
|
policy.normalize();
|
||||||
|
let policy = &policy;
|
||||||
let mut out = Map::with_capacity(properties.len());
|
let mut out = Map::with_capacity(properties.len());
|
||||||
for property in properties {
|
for property in properties {
|
||||||
let value = match property {
|
let value = match property {
|
||||||
P::Id | P::TenantId => {
|
P::Id | P::TenantId => {
|
||||||
Value::Element(TenantProtocolPolicyValue::Id(Id::from(tenant_id)))
|
Value::Element(TenantProtocolPolicyValue::Id(Id::from(tenant_id)))
|
||||||
}
|
}
|
||||||
P::LegacyProtocols => Value::Str(
|
P::LegacyProtocols => Value::Str(switch_str(policy.legacy_protocols).into()),
|
||||||
match policy.legacy_protocols {
|
P::Imap | P::Pop3 | P::ManageSieve => Value::Str(
|
||||||
LegacyProtocols::Enabled => "enabled",
|
switch_str(policy.switch(switch_name(property).unwrap_or_default())).into(),
|
||||||
LegacyProtocols::Disabled => "disabled",
|
|
||||||
}
|
|
||||||
.into(),
|
|
||||||
),
|
),
|
||||||
P::ChangedAt => policy
|
P::ChangedAt => policy
|
||||||
.changed_at
|
.changed_at
|
||||||
@@ -165,29 +184,41 @@ pub async fn set(
|
|||||||
let data = &server.core.storage.data;
|
let data = &server.core.storage.data;
|
||||||
let previous = tenant_protocol_policy::get(data, tenant_id).await?;
|
let previous = tenant_protocol_policy::get(data, tenant_id).await?;
|
||||||
let mut policy = previous.clone();
|
let mut policy = previous.clone();
|
||||||
|
policy.normalize();
|
||||||
let mut error = None;
|
let mut error = None;
|
||||||
for (key, value) in value.into_expanded_object() {
|
// What this request sets to `enabled`, for LP-9.
|
||||||
|
let mut turned_on: Vec<&'static str> = Vec::new();
|
||||||
|
// The kill-all first, so a protocol named beside it overrides it.
|
||||||
|
let mut entries: Vec<_> = value.into_expanded_object().collect();
|
||||||
|
entries.sort_by_key(|(key, _)| !matches!(key, Key::Property(P::LegacyProtocols)));
|
||||||
|
for (key, value) in entries {
|
||||||
|
// `null` puts a switch back to its default, on.
|
||||||
|
let parsed = match value {
|
||||||
|
Value::Null => Ok(LegacyProtocols::Enabled),
|
||||||
|
value => parse_switch(value.as_str().as_deref()),
|
||||||
|
};
|
||||||
let result = match &key {
|
let result = match &key {
|
||||||
Key::Property(P::LegacyProtocols) => match value {
|
Key::Property(P::LegacyProtocols) => parsed.map(|value| {
|
||||||
Value::Null => {
|
policy.set_all(value);
|
||||||
policy.legacy_protocols = LegacyProtocols::Enabled;
|
if !value.is_disabled() {
|
||||||
Ok(())
|
turned_on.extend(SWITCHED.iter().copied());
|
||||||
|
} else {
|
||||||
|
turned_on.clear();
|
||||||
}
|
}
|
||||||
value => match value.as_str().as_deref() {
|
}),
|
||||||
Some("enabled") => {
|
Key::Property(property @ (P::Imap | P::Pop3 | P::ManageSieve)) => {
|
||||||
policy.legacy_protocols = LegacyProtocols::Enabled;
|
parsed.map(|value| {
|
||||||
Ok(())
|
let name = switch_name(property).unwrap_or_default();
|
||||||
|
policy.set(name, value);
|
||||||
|
turned_on.retain(|p| *p != name);
|
||||||
|
if !value.is_disabled() {
|
||||||
|
turned_on.push(name);
|
||||||
}
|
}
|
||||||
Some("disabled") => {
|
})
|
||||||
policy.legacy_protocols = LegacyProtocols::Disabled;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
_ => Err(r#"must be "enabled" or "disabled""#),
|
Key::Property(P::Id) => Err("is immutable".to_string()),
|
||||||
},
|
Key::Property(_) => Err("is set by the server".to_string()),
|
||||||
},
|
_ => Err("is not a property of inbuxa:TenantProtocolPolicy".to_string()),
|
||||||
Key::Property(P::Id) => Err("is immutable"),
|
|
||||||
Key::Property(_) => Err("is set by the server"),
|
|
||||||
_ => Err("is not a property of inbuxa:TenantProtocolPolicy"),
|
|
||||||
};
|
};
|
||||||
if let Err(why) = result {
|
if let Err(why) = result {
|
||||||
error = Some(
|
error = Some(
|
||||||
@@ -203,15 +234,18 @@ pub async fn set(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// LP-9: server off means off for everyone.
|
// LP-9: server off means off for everyone, protocol by protocol.
|
||||||
if let Some(why) = refusal(&server.protocol_policy().await?, policy.legacy_protocols) {
|
if let Some(why) = refusal(&server.protocol_policy().await?, &turned_on) {
|
||||||
response
|
response
|
||||||
.not_updated
|
.not_updated
|
||||||
.append(id, SetError::forbidden().with_description(why));
|
.append(id, SetError::forbidden().with_description(why));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if policy.legacy_protocols != previous.legacy_protocols {
|
policy.normalize();
|
||||||
|
let mut before = previous;
|
||||||
|
before.normalize();
|
||||||
|
if policy.off() != before.off() {
|
||||||
policy.changed_at = Some(store::write::now() * 1000);
|
policy.changed_at = Some(store::write::now() * 1000);
|
||||||
policy.changed_by = Some(Id::from(access_token.account_id()).to_string());
|
policy.changed_by = Some(Id::from(access_token.account_id()).to_string());
|
||||||
tenant_protocol_policy::set(data, tenant_id, &policy).await?;
|
tenant_protocol_policy::set(data, tenant_id, &policy).await?;
|
||||||
@@ -221,11 +255,7 @@ pub async fn set(
|
|||||||
Security(trc::SecurityEvent::LegacyProtocolsChanged),
|
Security(trc::SecurityEvent::LegacyProtocolsChanged),
|
||||||
Policy = "tenant",
|
Policy = "tenant",
|
||||||
Id = tenant_id,
|
Id = tenant_id,
|
||||||
Value = if policy.legacy_protocols.is_disabled() {
|
Value = switches_value(&policy),
|
||||||
"disabled"
|
|
||||||
} else {
|
|
||||||
"enabled"
|
|
||||||
},
|
|
||||||
AccountId = policy.changed_by.clone(),
|
AccountId = policy.changed_by.clone(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* 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 common::{
|
use common::{
|
||||||
@@ -13,6 +15,8 @@ use mail_auth::{
|
|||||||
MX, RecordSet,
|
MX, RecordSet,
|
||||||
common::resolver::ToFqdn,
|
common::resolver::ToFqdn,
|
||||||
hickory_resolver::{
|
hickory_resolver::{
|
||||||
|
TokioResolver,
|
||||||
|
lookup::Lookup,
|
||||||
net::{DnsError, NetError},
|
net::{DnsError, NetError},
|
||||||
proto::{
|
proto::{
|
||||||
dnssec::Proof,
|
dnssec::Proof,
|
||||||
@@ -83,16 +87,15 @@ impl TlsaLookup for Server {
|
|||||||
return mail_auth::common::resolver::mock_resolve(key.as_ref());
|
return mail_auth::common::resolver::mock_resolve(key.as_ref());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mx_lookup = match self
|
let (mx_lookup, forced_insecure) = match validated_lookup(
|
||||||
.core
|
&self.core.smtp.resolvers.dnssec.resolver,
|
||||||
.smtp
|
self.core.smtp.resolvers.dns.resolver(),
|
||||||
.resolvers
|
Name::from_str_relaxed::<&str>(key.as_ref())?,
|
||||||
.dnssec
|
RecordType::MX,
|
||||||
.resolver
|
)
|
||||||
.mx_lookup(Name::from_str_relaxed::<&str>(key.as_ref())?)
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(mx_lookup) => mx_lookup,
|
Ok(validated) => (validated.lookup, validated.insecure),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if let Some(denial) = NegativeAnswer::from_error(&err)
|
if let Some(denial) = NegativeAnswer::from_error(&err)
|
||||||
&& denial.response_code == ResponseCode::NoError
|
&& denial.response_code == ResponseCode::NoError
|
||||||
@@ -144,7 +147,11 @@ impl TlsaLookup for Server {
|
|||||||
.collect::<Arc<[MX]>>();
|
.collect::<Arc<[MX]>>();
|
||||||
let records = RecordSet {
|
let records = RecordSet {
|
||||||
rrset,
|
rrset,
|
||||||
dnssec_status: dnssec_status.unwrap_or(DnssecStatus::Indeterminate),
|
dnssec_status: if forced_insecure {
|
||||||
|
DnssecStatus::Insecure
|
||||||
|
} else {
|
||||||
|
dnssec_status.unwrap_or(DnssecStatus::Indeterminate)
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
self.inner
|
self.inner
|
||||||
@@ -285,16 +292,15 @@ impl TlsaLookup for Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let name = Name::from_str_relaxed::<&str>(key.as_ref())?;
|
let name = Name::from_str_relaxed::<&str>(key.as_ref())?;
|
||||||
let lookup = match self
|
let (lookup, forced_insecure) = match validated_lookup(
|
||||||
.core
|
&self.core.smtp.resolvers.dnssec.resolver,
|
||||||
.smtp
|
self.core.smtp.resolvers.dns.resolver(),
|
||||||
.resolvers
|
name.clone(),
|
||||||
.dnssec
|
RecordType::A,
|
||||||
.resolver
|
)
|
||||||
.ipv4_lookup(name.clone())
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(lookup) => lookup,
|
Ok(validated) => (validated.lookup, validated.insecure),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if let Some(denial) = NegativeAnswer::from_error(&err)
|
if let Some(denial) = NegativeAnswer::from_error(&err)
|
||||||
&& denial.response_code == ResponseCode::NoError
|
&& denial.response_code == ResponseCode::NoError
|
||||||
@@ -325,7 +331,11 @@ impl TlsaLookup for Server {
|
|||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect::<Arc<[Ipv4Addr]>>(),
|
.collect::<Arc<[Ipv4Addr]>>(),
|
||||||
dnssec_status: tlsa_base_status(&name, answers, RecordType::A),
|
dnssec_status: if forced_insecure {
|
||||||
|
DnssecStatus::Insecure
|
||||||
|
} else {
|
||||||
|
tlsa_base_status(&name, answers, RecordType::A)
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
self.inner
|
self.inner
|
||||||
@@ -363,16 +373,15 @@ impl TlsaLookup for Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let name = Name::from_str_relaxed::<&str>(key.as_ref())?;
|
let name = Name::from_str_relaxed::<&str>(key.as_ref())?;
|
||||||
let lookup = match self
|
let (lookup, forced_insecure) = match validated_lookup(
|
||||||
.core
|
&self.core.smtp.resolvers.dnssec.resolver,
|
||||||
.smtp
|
self.core.smtp.resolvers.dns.resolver(),
|
||||||
.resolvers
|
name.clone(),
|
||||||
.dnssec
|
RecordType::AAAA,
|
||||||
.resolver
|
)
|
||||||
.ipv6_lookup(name.clone())
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(lookup) => lookup,
|
Ok(validated) => (validated.lookup, validated.insecure),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if let Some(denial) = NegativeAnswer::from_error(&err)
|
if let Some(denial) = NegativeAnswer::from_error(&err)
|
||||||
&& denial.response_code == ResponseCode::NoError
|
&& denial.response_code == ResponseCode::NoError
|
||||||
@@ -403,7 +412,11 @@ impl TlsaLookup for Server {
|
|||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect::<Arc<[Ipv6Addr]>>(),
|
.collect::<Arc<[Ipv6Addr]>>(),
|
||||||
dnssec_status: tlsa_base_status(&name, answers, RecordType::AAAA),
|
dnssec_status: if forced_insecure {
|
||||||
|
DnssecStatus::Insecure
|
||||||
|
} else {
|
||||||
|
tlsa_base_status(&name, answers, RecordType::AAAA)
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
self.inner
|
self.inner
|
||||||
@@ -415,6 +428,115 @@ impl TlsaLookup for Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: hickory 0.26.3 calls some valid answers bogus, and the queue then
|
||||||
|
// retries those hosts until the message expires. Two cases seen in production:
|
||||||
|
//
|
||||||
|
// - A zone delegated beneath an unsigned zone, such as `l.google.com` under
|
||||||
|
// `google.com`. To prove the delegation insecure, hickory wants an SOA
|
||||||
|
// record in the DS reply, and public resolvers often send none.
|
||||||
|
// - A signed CNAME to a signed name without the record type queried. Hickory
|
||||||
|
// checks the denial of existence against the name first asked for, not the
|
||||||
|
// target's, and rejects it.
|
||||||
|
//
|
||||||
|
// When hickory says bogus, check the answer again with lookups it gets right.
|
||||||
|
// A signed CNAME is followed and the lookup repeated at its target. Otherwise
|
||||||
|
// the name's zone and its parents are looked up, nearest first. If one
|
||||||
|
// validates as unsigned, nothing below it can be signed, so the plain resolver
|
||||||
|
// answers and the result is insecure. If one validates as signed first, the
|
||||||
|
// verdict stands.
|
||||||
|
|
||||||
|
const MAX_BOGUS_ALIASES: usize = 8;
|
||||||
|
|
||||||
|
struct ValidatedLookup {
|
||||||
|
lookup: Lookup,
|
||||||
|
insecure: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BogusRecheck {
|
||||||
|
Alias(Name),
|
||||||
|
Insecure,
|
||||||
|
Bogus,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validated_lookup(
|
||||||
|
dnssec: &TokioResolver,
|
||||||
|
plain: &TokioResolver,
|
||||||
|
name: Name,
|
||||||
|
record_type: RecordType,
|
||||||
|
) -> Result<ValidatedLookup, NetError> {
|
||||||
|
let mut query = name;
|
||||||
|
let mut aliases = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let err = match dnssec.lookup(query.clone(), record_type).await {
|
||||||
|
Ok(lookup) => {
|
||||||
|
return Ok(ValidatedLookup {
|
||||||
|
lookup,
|
||||||
|
insecure: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(err @ NetError::Dns(DnsError::DnssecBogus)) => err,
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
};
|
||||||
|
|
||||||
|
match recheck_bogus(dnssec, &query).await {
|
||||||
|
BogusRecheck::Alias(target) if aliases < MAX_BOGUS_ALIASES => {
|
||||||
|
aliases += 1;
|
||||||
|
query = target;
|
||||||
|
}
|
||||||
|
BogusRecheck::Insecure => {
|
||||||
|
return plain
|
||||||
|
.lookup(query, record_type)
|
||||||
|
.await
|
||||||
|
.map(|lookup| ValidatedLookup {
|
||||||
|
lookup,
|
||||||
|
insecure: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
BogusRecheck::Alias(_) | BogusRecheck::Bogus => return Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn recheck_bogus(dnssec: &TokioResolver, name: &Name) -> BogusRecheck {
|
||||||
|
if let Ok(lookup) = dnssec.lookup(name.clone(), RecordType::CNAME).await
|
||||||
|
&& let Some(target) = secure_alias(name, lookup.answers())
|
||||||
|
{
|
||||||
|
return BogusRecheck::Alias(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut zone = name.clone();
|
||||||
|
while !zone.is_root() {
|
||||||
|
if let Ok(lookup) = dnssec.lookup(zone.clone(), RecordType::SOA).await {
|
||||||
|
match apex_status(&zone, lookup.answers()) {
|
||||||
|
Some(DnssecStatus::Insecure) => return BogusRecheck::Insecure,
|
||||||
|
Some(DnssecStatus::Secure) => return BogusRecheck::Bogus,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
zone = zone.base_name();
|
||||||
|
}
|
||||||
|
|
||||||
|
BogusRecheck::Bogus
|
||||||
|
}
|
||||||
|
|
||||||
|
fn secure_alias(query: &Name, answers: &[Record]) -> Option<Name> {
|
||||||
|
answers.iter().find_map(|record| match &record.data {
|
||||||
|
RData::CNAME(target) if &record.name == query && record.proof.is_secure() => {
|
||||||
|
Some(target.0.clone())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apex_status(zone: &Name, answers: &[Record]) -> Option<DnssecStatus> {
|
||||||
|
answers
|
||||||
|
.iter()
|
||||||
|
.filter(|record| record.record_type() == RecordType::SOA && &record.name == zone)
|
||||||
|
.map(|record| proof_to_dnssec_status(record.proof))
|
||||||
|
.reduce(least_secure)
|
||||||
|
}
|
||||||
|
|
||||||
struct NegativeAnswer {
|
struct NegativeAnswer {
|
||||||
response_code: ResponseCode,
|
response_code: ResponseCode,
|
||||||
dnssec_status: DnssecStatus,
|
dnssec_status: DnssecStatus,
|
||||||
@@ -511,7 +633,7 @@ pub(crate) fn least_secure(a: DnssecStatus, b: DnssecStatus) -> DnssecStatus {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use mail_auth::hickory_resolver::proto::rr::rdata::{A, CNAME};
|
use mail_auth::hickory_resolver::proto::rr::rdata::{A, CNAME, SOA};
|
||||||
use std::net::Ipv4Addr;
|
use std::net::Ipv4Addr;
|
||||||
|
|
||||||
fn name(value: &str) -> Name {
|
fn name(value: &str) -> Name {
|
||||||
@@ -624,4 +746,127 @@ mod tests {
|
|||||||
DnssecStatus::Insecure
|
DnssecStatus::Insecure
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn soa(owner: &str, proof: Proof) -> Record {
|
||||||
|
let mut record = Record::from_rdata(
|
||||||
|
name(owner),
|
||||||
|
3600,
|
||||||
|
RData::SOA(SOA::new(
|
||||||
|
name("ns1.example.org."),
|
||||||
|
name("hostmaster.example.org."),
|
||||||
|
1,
|
||||||
|
900,
|
||||||
|
900,
|
||||||
|
1800,
|
||||||
|
60,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
record.proof = proof;
|
||||||
|
record
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secure_alias_follows_signed_cname() {
|
||||||
|
assert_eq!(
|
||||||
|
secure_alias(
|
||||||
|
&name("mail.example.org."),
|
||||||
|
&[alias("mail.example.org.", "mx.example.net.", Proof::Secure)]
|
||||||
|
),
|
||||||
|
Some(name("mx.example.net."))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secure_alias_ignores_unsigned_or_other_cname() {
|
||||||
|
let query = name("mail.example.org.");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
secure_alias(
|
||||||
|
&query,
|
||||||
|
&[alias(
|
||||||
|
"mail.example.org.",
|
||||||
|
"mx.example.net.",
|
||||||
|
Proof::Insecure
|
||||||
|
)]
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
secure_alias(
|
||||||
|
&query,
|
||||||
|
&[alias(
|
||||||
|
"other.example.org.",
|
||||||
|
"mx.example.net.",
|
||||||
|
Proof::Secure
|
||||||
|
)]
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apex_status_reads_the_zone_soa() {
|
||||||
|
let zone = name("example.com.");
|
||||||
|
|
||||||
|
for (proof, expected) in [
|
||||||
|
(Proof::Secure, Some(DnssecStatus::Secure)),
|
||||||
|
(Proof::Insecure, Some(DnssecStatus::Insecure)),
|
||||||
|
(Proof::Bogus, Some(DnssecStatus::Bogus)),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
apex_status(&zone, &[soa("example.com.", proof)]),
|
||||||
|
expected,
|
||||||
|
"proof {proof}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apex_status_ignores_other_records() {
|
||||||
|
assert_eq!(
|
||||||
|
apex_status(
|
||||||
|
&name("example.com."),
|
||||||
|
&[
|
||||||
|
soa("sub.example.com.", Proof::Insecure),
|
||||||
|
address("example.com.", Proof::Insecure),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Needs the network: a signed MX pointing into a zone delegated beneath an
|
||||||
|
// unsigned one. Run with `--ignored` to check a hickory upgrade.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn validated_lookup_proves_delegation_below_unsigned_zone() {
|
||||||
|
use mail_auth::hickory_resolver::{
|
||||||
|
config::{CLOUDFLARE, ResolverConfig, ResolverOpts},
|
||||||
|
net::runtime::TokioRuntimeProvider,
|
||||||
|
};
|
||||||
|
|
||||||
|
let build = |validate: bool| {
|
||||||
|
// Same options as the server's DNSSEC resolver; hickory fails
|
||||||
|
// validation with concurrent requests.
|
||||||
|
let mut opts = ResolverOpts::default();
|
||||||
|
opts.validate = validate;
|
||||||
|
opts.num_concurrent_reqs = 1;
|
||||||
|
opts.cache_size = 0;
|
||||||
|
TokioResolver::builder_with_config(
|
||||||
|
ResolverConfig::udp_and_tcp(&CLOUDFLARE),
|
||||||
|
TokioRuntimeProvider::default(),
|
||||||
|
)
|
||||||
|
.with_options(opts)
|
||||||
|
.build()
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
let (dnssec, plain) = (build(true), build(false));
|
||||||
|
|
||||||
|
let validated =
|
||||||
|
validated_lookup(&dnssec, &plain, name("aspmx.l.google.com."), RecordType::A)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(validated.insecure);
|
||||||
|
assert!(!validated.lookup.answers().is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ use mail_auth::{
|
|||||||
common::verify::VerifySignature,
|
common::verify::VerifySignature,
|
||||||
dkim2::Dkim2Output,
|
dkim2::Dkim2Output,
|
||||||
dmarc::{self},
|
dmarc::{self},
|
||||||
report::{AuthFailureType, IdentityAlignment, PolicyPublished, Record, SPFDomainScope},
|
report::{
|
||||||
|
ActionDisposition, AuthFailureType, IdentityAlignment, PolicyPublished, Record, Report,
|
||||||
|
SPFDomainScope,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use registry::{
|
use registry::{
|
||||||
schema::{
|
schema::{
|
||||||
@@ -459,7 +462,7 @@ impl DmarcReporting for Server {
|
|||||||
.await
|
.await
|
||||||
.unwrap_or_else(|| "MAILER-DAEMON@localhost".to_compact_string());
|
.unwrap_or_else(|| "MAILER-DAEMON@localhost".to_compact_string());
|
||||||
let mut message = Vec::with_capacity(2048);
|
let mut message = Vec::with_capacity(2048);
|
||||||
let _ = mail_auth::report::Report::from(report.report).write_rfc5322(
|
let _ = with_compatible_dispositions(Report::from(report.report)).write_rfc5322(
|
||||||
&self
|
&self
|
||||||
.eval_if(
|
.eval_if(
|
||||||
&self.core.smtp.report.submitter,
|
&self.core.smtp.report.submitter,
|
||||||
@@ -710,3 +713,55 @@ impl DmarcReporting for Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: RFC 9990 added "pass" to the evaluated disposition for mail that
|
||||||
|
// passed DMARC under an enforcing policy. Cloudflare's report intake rejects
|
||||||
|
// the whole report with "555 5.7.1 invalid_report_schema" when it sees that
|
||||||
|
// value, and older parsers built on the RFC 7489 schema do the same. "none"
|
||||||
|
// (no action taken) is valid under both and says the same thing, so reports
|
||||||
|
// go out with that instead.
|
||||||
|
fn with_compatible_dispositions(mut report: Report) -> Report {
|
||||||
|
for record in &mut report.record {
|
||||||
|
let disposition = &mut record.row.policy_evaluated.disposition;
|
||||||
|
if *disposition == ActionDisposition::Pass {
|
||||||
|
*disposition = ActionDisposition::None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use mail_auth::report::DmarcResult;
|
||||||
|
|
||||||
|
fn record(disposition: ActionDisposition) -> Record {
|
||||||
|
Record::new()
|
||||||
|
.with_source_ip("192.0.2.1".parse().unwrap())
|
||||||
|
.with_count(1)
|
||||||
|
.with_action_disposition(disposition)
|
||||||
|
.with_dmarc_dkim_result(DmarcResult::Pass)
|
||||||
|
.with_dmarc_spf_result(DmarcResult::Fail)
|
||||||
|
.with_header_from("example.org")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pass_disposition_is_reported_as_none() {
|
||||||
|
let xml = with_compatible_dispositions(
|
||||||
|
Report::new()
|
||||||
|
.with_domain("example.org")
|
||||||
|
.with_record(record(ActionDisposition::Pass))
|
||||||
|
.with_record(record(ActionDisposition::Quarantine))
|
||||||
|
.with_record(record(ActionDisposition::Reject)),
|
||||||
|
)
|
||||||
|
.to_xml();
|
||||||
|
|
||||||
|
assert!(!xml.contains("<disposition>pass</disposition>"), "{xml}");
|
||||||
|
assert!(xml.contains("<disposition>none</disposition>"), "{xml}");
|
||||||
|
assert!(
|
||||||
|
xml.contains("<disposition>quarantine</disposition>"),
|
||||||
|
"{xml}"
|
||||||
|
);
|
||||||
|
assert!(xml.contains("<disposition>reject</disposition>"), "{xml}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ fn legacy_setting(name: &str, is_set: impl Fn(&str) -> bool) -> Option<String> {
|
|||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! brand_version {
|
macro_rules! brand_version {
|
||||||
() => {
|
() => {
|
||||||
"2026.9.28"
|
"2026.9.28.3"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -86,6 +86,7 @@ dns-update = { version = "0.5", features = ["test_provider"] }
|
|||||||
x509-parser = "0.18"
|
x509-parser = "0.18"
|
||||||
rcgen = "0.14"
|
rcgen = "0.14"
|
||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
|
zip = "8.6" # inbuxa: reading legal hold exports
|
||||||
time = "0.3"
|
time = "0.3"
|
||||||
testcontainers = { version = "0.28", features = ["reusable-containers"] }
|
testcontainers = { version = "0.28", features = ["reusable-containers"] }
|
||||||
rust-s3 = { version = "0.37", default-features = false, features = ["tokio-rustls-tls"] }
|
rust-s3 = { version = "0.37", default-features = false, features = ["tokio-rustls-tls"] }
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ account which way its switches point (test 13), and the impact panel's
|
|||||||
list names who signed in over what: every account at server scope, only the
|
list names who signed in over what: every account at server scope, only the
|
||||||
tenant's own at tenant scope, rewritten at most once an hour (LP-15).
|
tenant's own at tenant scope, rewritten at most once an hour (LP-15).
|
||||||
|
|
||||||
|
Then one switch per protocol: POP3 alone off closes only POP3's port and
|
||||||
|
refuses only POP3 sign-in, sending and IMAP go on, and only POP3 stops being
|
||||||
|
advertised; one /set can close one protocol and reopen another. And a
|
||||||
|
tenant turning POP3 off for itself, and not able to turn IMAP back on while
|
||||||
|
the server has IMAP off.
|
||||||
|
|
||||||
Passwords are generated into files under target/e2e and never printed.
|
Passwords are generated into files under target/e2e and never printed.
|
||||||
Everything is removed afterwards unless KEEP=1.
|
Everything is removed afterwards unless KEEP=1.
|
||||||
"""
|
"""
|
||||||
@@ -380,6 +386,37 @@ def tenant_checks(admin, admin_pw, account):
|
|||||||
"and its user signs in over IMAP again")
|
"and its user signs in over IMAP again")
|
||||||
check(session_flag(tu, user_pw) == "enabled", "and its session says enabled again (test 13)")
|
check(session_flag(tu, user_pw) == "enabled", "and its session says enabled again (test 13)")
|
||||||
|
|
||||||
|
# One protocol at a time, for a tenant.
|
||||||
|
tone = lambda update: one(ta, tadmin_pw, "inbuxa:TenantProtocolPolicy/set",
|
||||||
|
{"accountId": tacct, "update": {t: update}})
|
||||||
|
res = tone({"pop3": "disabled"})
|
||||||
|
check(t in (res[1].get("updated") or {}), "a tenant admin turns POP3 alone off")
|
||||||
|
check(pop3_login(PORTS["pop3"], tu, user_pw) ==
|
||||||
|
"-ERR [AUTH] Your organization allows only inbuxa webmail and JMAP apps. "
|
||||||
|
"This mail app can't sign in.", "the tenant's user is refused over POP3")
|
||||||
|
check(imap_login(PORTS["imap"], tu, user_pw).startswith("OK"),
|
||||||
|
"and still signs in over IMAP")
|
||||||
|
check(smtp_auths(PORTS["submissions"], tu, [user_pw])[0].startswith("235"),
|
||||||
|
"and still sends")
|
||||||
|
check(pop3_login(PORTS["pop3"], admin, admin_pw).startswith("+OK"),
|
||||||
|
"an account outside the tenant still signs in over POP3")
|
||||||
|
check(session_allowed(tu, user_pw) == ["imap", "manageSieve", "submission"],
|
||||||
|
"the tenant user's session leaves POP3 out")
|
||||||
|
one(admin, admin_pw, "inbuxa:ProtocolPolicy/set",
|
||||||
|
{"accountId": account, "update": {"singleton": {"imap": "disabled"}}})
|
||||||
|
res = tone({"imap": "enabled"})
|
||||||
|
refused = (res[1].get("notUpdated") or {}).get(t) or {}
|
||||||
|
check(refused.get("type") == "forbidden"
|
||||||
|
and (refused.get("description") or "").startswith("IMAP is off"),
|
||||||
|
"with IMAP off server-wide, the tenant can't turn IMAP on, and is told which (LP-9)")
|
||||||
|
res = tone({"pop3": "enabled"})
|
||||||
|
check(t in (res[1].get("updated") or {}), "but it can turn its own POP3 back on")
|
||||||
|
check(session_allowed(tu, user_pw) == ["pop3", "manageSieve", "submission"],
|
||||||
|
"the session follows: IMAP off by the server, POP3 back")
|
||||||
|
one(admin, admin_pw, "inbuxa:ProtocolPolicy/set",
|
||||||
|
{"accountId": account, "update": {"singleton": {"imap": "enabled"}}})
|
||||||
|
check(settle(PORTS["imap"], True), "IMAP back after the server's switch returns")
|
||||||
|
|
||||||
# A deleted tenant's switch goes with it, so a tenant that later gets the
|
# A deleted tenant's switch goes with it, so a tenant that later gets the
|
||||||
# same id doesn't start with legacy protocols off.
|
# same id doesn't start with legacy protocols off.
|
||||||
sget = lambda ids: one(admin, admin_pw, "inbuxa:TenantProtocolPolicy/get",
|
sget = lambda ids: one(admin, admin_pw, "inbuxa:TenantProtocolPolicy/get",
|
||||||
@@ -406,6 +443,67 @@ def session_flag(user, password):
|
|||||||
return sess["accounts"][acct]["accountCapabilities"].get(INBUXA, {}).get("legacyProtocols")
|
return sess["accounts"][acct]["accountCapabilities"].get(INBUXA, {}).get("legacyProtocols")
|
||||||
|
|
||||||
|
|
||||||
|
def session_allowed(user, password):
|
||||||
|
"""legacyAllowed from the account's urn:inbuxa:jmap capability."""
|
||||||
|
sess = session(user, password)
|
||||||
|
acct = sess["primaryAccounts"].get(INBUXA) or list(sess["accounts"])[0]
|
||||||
|
return sess["accounts"][acct]["accountCapabilities"].get(INBUXA, {}).get("legacyAllowed")
|
||||||
|
|
||||||
|
|
||||||
|
def per_protocol_checks(admin, admin_pw, account):
|
||||||
|
"""One switch per protocol, server-wide."""
|
||||||
|
pset = lambda update: one(admin, admin_pw, "inbuxa:ProtocolPolicy/set",
|
||||||
|
{"accountId": account, "update": {"singleton": update}})
|
||||||
|
pget = lambda: one(admin, admin_pw, "inbuxa:ProtocolPolicy/get",
|
||||||
|
{"accountId": account, "ids": None})[1]["list"][0]
|
||||||
|
changes = len(events("security.legacy-protocols-changed"))
|
||||||
|
|
||||||
|
res = pset({"pop3": "disabled"})
|
||||||
|
check("singleton" in (res[1].get("updated") or {}), "POP3 alone can be turned off")
|
||||||
|
check(settle(PORTS["pop3"], False), "POP3 stopped accepting")
|
||||||
|
check(accepts(PORTS["imap"]), "IMAP still accepts with only POP3 off")
|
||||||
|
policy = pget()
|
||||||
|
check((policy["imap"], policy["pop3"], policy["manageSieve"], policy["legacyProtocols"])
|
||||||
|
== ("enabled", "disabled", "enabled", "enabled"),
|
||||||
|
"the switches read back: POP3 off, the rest on, the kill-all not set")
|
||||||
|
check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"),
|
||||||
|
"IMAP sign-in works with only POP3 off")
|
||||||
|
check(smtp_auths(PORTS["submissions"], admin, [admin_pw])[0].startswith("235"),
|
||||||
|
"submission sign-in works: sending goes on while any protocol is allowed")
|
||||||
|
check(session_allowed(admin, admin_pw) == ["imap", "manageSieve", "submission"],
|
||||||
|
"the session lists what is still allowed")
|
||||||
|
check(session_flag(admin, admin_pw) == "enabled",
|
||||||
|
"and the old legacyProtocols flag still says enabled")
|
||||||
|
during = advertised(admin, admin_pw)
|
||||||
|
check(during["autoconfig"] == {"imap", "smtp"}, "autoconfig drops POP3 only")
|
||||||
|
check("pop3" not in during["pacc"] and {"imap", "smtp"} <= during["pacc"],
|
||||||
|
"PACC drops POP3 only")
|
||||||
|
check(during["srv"].get("_pop3s._tcp") == "." and during["srv"].get("_imaps._tcp") != ".",
|
||||||
|
"the zone marks POP3 not offered and still offers IMAP")
|
||||||
|
changed = events("security.legacy-protocols-changed")[changes:]
|
||||||
|
check(len(changed) == 1 and 'value = "pop3 disabled"' in changed[0]
|
||||||
|
and 'details = "closed"' in changed[0],
|
||||||
|
"turning POP3 off is one event naming it")
|
||||||
|
if len(changed) != 1:
|
||||||
|
print(" events:", changed)
|
||||||
|
|
||||||
|
# One /set can close one protocol and bring another back.
|
||||||
|
pset({"pop3": "enabled", "imap": "disabled"})
|
||||||
|
check(settle(PORTS["imap"], False), "IMAP closes in the same change")
|
||||||
|
check(settle(PORTS["pop3"], True), "that brings POP3 back")
|
||||||
|
check(pop3_login(PORTS["pop3"], admin, admin_pw).startswith("+OK"),
|
||||||
|
"POP3 sign-in works again")
|
||||||
|
changed = events("security.legacy-protocols-changed")[changes + 1:]
|
||||||
|
check(len(changed) == 1 and 'details = "closed and reopened"' in changed[0],
|
||||||
|
"and it is one event, closed and reopened")
|
||||||
|
|
||||||
|
pset({"imap": "enabled"})
|
||||||
|
check(settle(PORTS["imap"], True), "IMAP back on")
|
||||||
|
policy = pget()
|
||||||
|
check(not policy["savedListeners"] and policy["legacyProtocols"] == "enabled",
|
||||||
|
"nothing left saved once every protocol is on")
|
||||||
|
|
||||||
|
|
||||||
def events_matching(name, *parts):
|
def events_matching(name, *parts):
|
||||||
return any(all(p in line for p in parts) for line in events(name))
|
return any(all(p in line for p in parts) for line in events(name))
|
||||||
|
|
||||||
@@ -636,6 +734,9 @@ def main():
|
|||||||
check(after["autoconfig"] == before["autoconfig"] and after["srv"] == before["srv"],
|
check(after["autoconfig"] == before["autoconfig"] and after["srv"] == before["srv"],
|
||||||
"autoconfig and the suggested zone offer them again once back on")
|
"autoconfig and the suggested zone offer them again once back on")
|
||||||
|
|
||||||
|
# One switch per protocol.
|
||||||
|
per_protocol_checks(admin, admin_pw, account)
|
||||||
|
|
||||||
# A tenant's own switch (LP-9 to LP-14a).
|
# A tenant's own switch (LP-9 to LP-14a).
|
||||||
tenant_checks(admin, admin_pw, account)
|
tenant_checks(admin, admin_pw, account)
|
||||||
|
|
||||||
|
|||||||
@@ -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::{
|
||||||
@@ -174,7 +176,8 @@ async fn report_dmarc() {
|
|||||||
let source_ip = record.source_ip().unwrap();
|
let source_ip = record.source_ip().unwrap();
|
||||||
if source_ip == "192.168.1.2".parse::<IpAddr>().unwrap() {
|
if source_ip == "192.168.1.2".parse::<IpAddr>().unwrap() {
|
||||||
assert_eq!(record.count(), 2);
|
assert_eq!(record.count(), 2);
|
||||||
assert_eq!(record.action_disposition(), ActionDisposition::Pass);
|
// inbuxa: "pass" goes out as "none" for RFC 7489 parsers
|
||||||
|
assert_eq!(record.action_disposition(), ActionDisposition::None);
|
||||||
assert_eq!(record.envelope_from(), "[email protected]");
|
assert_eq!(record.envelope_from(), "[email protected]");
|
||||||
assert_eq!(record.header_from(), "[email protected]");
|
assert_eq!(record.header_from(), "[email protected]");
|
||||||
assert_eq!(record.envelope_to().unwrap(), "[email protected]");
|
assert_eq!(record.envelope_to().unwrap(), "[email protected]");
|
||||||
|
|||||||
@@ -436,6 +436,96 @@ pub async fn test(test: &mut TestServer) {
|
|||||||
names.sort_unstable();
|
names.sort_unstable();
|
||||||
assert_eq!(names, vec!["Matter 7001", "Matter 7002"], "LH-14: {response}");
|
assert_eq!(names, vec!["Matter 7001", "Matter 7002"], "LH-14: {response}");
|
||||||
|
|
||||||
|
// LH-12: collect what the hold keeps, as a ZIP with its manifest
|
||||||
|
import(&frozen_client, "Still in the inbox", None).await;
|
||||||
|
let (_, response) = admin
|
||||||
|
.hold_call(
|
||||||
|
"inbuxa:HoldExport/set",
|
||||||
|
json!({"create": {"x": {"holdId": first, "accountIds": [frozen.id_string()]}}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
response["notCreated"]["x"]["type"], "invalidProperties",
|
||||||
|
"AU-12: an export without a reason: {response}"
|
||||||
|
);
|
||||||
|
let (_, response) = admin
|
||||||
|
.hold_call(
|
||||||
|
"inbuxa:HoldExport/set",
|
||||||
|
json!({"reason": "Production to opposing counsel",
|
||||||
|
"create": {"x": {"holdId": first,
|
||||||
|
"accountIds": [frozen.id_string(), admin.id_string()]}}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let export_id = response["created"]["x"]["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_else(|| panic!("LH-12: not started: {response}"))
|
||||||
|
.to_string();
|
||||||
|
let mut export = Value::Null;
|
||||||
|
for _ in 0..60 {
|
||||||
|
let (_, got) = admin
|
||||||
|
.hold_call("inbuxa:HoldExport/get", json!({"ids": [export_id]}))
|
||||||
|
.await;
|
||||||
|
export = got["list"][0].clone();
|
||||||
|
if export["status"] != "running" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||||
|
}
|
||||||
|
assert_eq!(export["status"], "ready", "LH-12: {export}");
|
||||||
|
let bytes = admin
|
||||||
|
.jmap_client()
|
||||||
|
.await
|
||||||
|
.download(export["blobId"].as_str().unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(export["size"].as_u64(), Some(bytes.len() as u64), "{export}");
|
||||||
|
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||||
|
let names = (0..zip.len())
|
||||||
|
.map(|i| zip.by_index(i).unwrap().name().to_string())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert!(
|
||||||
|
names.iter().any(|n| n.starts_with("[email protected]/mail/") && n.ends_with(".eml")),
|
||||||
|
"LH-12: live mail missing: {names:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
names.iter().any(|n| n.starts_with("[email protected]/archived/email/")),
|
||||||
|
"LH-12: the kept deleted mail is missing: {names:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
names
|
||||||
|
.iter()
|
||||||
|
.all(|n| n.starts_with("[email protected]/") || n.starts_with("manifest.") || n == "exceptions.csv"),
|
||||||
|
"LH-12: an account the hold doesn't cover was exported: {names:?}"
|
||||||
|
);
|
||||||
|
let mut manifest = String::new();
|
||||||
|
std::io::Read::read_to_string(&mut zip.by_name("manifest.csv").unwrap(), &mut manifest).unwrap();
|
||||||
|
let mut hash = String::new();
|
||||||
|
std::io::Read::read_to_string(&mut zip.by_name("manifest.sha256").unwrap(), &mut hash).unwrap();
|
||||||
|
use sha2::Digest;
|
||||||
|
let expected: String = sha2::Sha256::digest(manifest.as_bytes())
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{b:02x}"))
|
||||||
|
.collect();
|
||||||
|
assert!(hash.starts_with(&expected), "LH-12: the manifest's hash doesn't match");
|
||||||
|
assert!(manifest.contains(",true,"), "LH-12: nothing marked archived: {manifest}");
|
||||||
|
let mut exceptions = String::new();
|
||||||
|
std::io::Read::read_to_string(&mut zip.by_name("exceptions.csv").unwrap(), &mut exceptions).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
exceptions, "path,account,kind,folder,date,archived,reason\n",
|
||||||
|
"LH-12: items the hold covers couldn't be read"
|
||||||
|
);
|
||||||
|
// LH-13: only sysLegalHoldExport starts one
|
||||||
|
let (_, response) = frozen
|
||||||
|
.hold_call(
|
||||||
|
"inbuxa:HoldExport/set",
|
||||||
|
json!({"reason": "Mine", "create": {"x": {"holdId": first}}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
response.to_string().contains("forbidden") && response["created"].is_null(),
|
||||||
|
"LH-13: a user exported a hold: {response}"
|
||||||
|
);
|
||||||
|
|
||||||
let (_, response) = frozen
|
let (_, response) = frozen
|
||||||
.hold_call("x:ArchivedItem/set", json!({"destroy": [item_id]}))
|
.hold_call("x:ArchivedItem/set", json!({"destroy": [item_id]}))
|
||||||
.await;
|
.await;
|
||||||
@@ -470,6 +560,16 @@ pub async fn test(test: &mut TestServer) {
|
|||||||
.await;
|
.await;
|
||||||
let item = archived(frozen.archived_items().await);
|
let item = archived(frozen.archived_items().await);
|
||||||
assert!(!is_held(&item), "LH-10: the last release left it held: {item}");
|
assert!(!is_held(&item), "LH-10: the last release left it held: {item}");
|
||||||
|
let (_, response) = admin
|
||||||
|
.hold_call(
|
||||||
|
"inbuxa:HoldExport/set",
|
||||||
|
json!({"reason": "Too late", "create": {"x": {"holdId": first}}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
response["notCreated"]["x"]["type"], "invalidProperties",
|
||||||
|
"LH-12: a released hold was exported: {response}"
|
||||||
|
);
|
||||||
let until = item["archivedUntil"].as_str().unwrap_or_default().to_string();
|
let until = item["archivedUntil"].as_str().unwrap_or_default().to_string();
|
||||||
let grace = chrono::Utc::now() + chrono::Duration::days(29);
|
let grace = chrono::Utc::now() + chrono::Duration::days(29);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -574,6 +674,22 @@ pub async fn test(test: &mut TestServer) {
|
|||||||
for reason in ["Counsel's letter", "Counsel widened the matter", "Matter settled"] {
|
for reason in ["Counsel's letter", "Counsel widened the matter", "Matter settled"] {
|
||||||
assert!(reasons.contains(&reason), "AU-12: {reason:?} not recorded: {reasons:?}");
|
assert!(reasons.contains(&reason), "AU-12: {reason:?} not recorded: {reasons:?}");
|
||||||
}
|
}
|
||||||
|
// AU-1.9: an export is recorded with its reason
|
||||||
|
let (_, query) = admin
|
||||||
|
.hold_call(
|
||||||
|
"inbuxa:AuditEvent/query",
|
||||||
|
json!({"filter": {"targetKind": "inbuxa:HoldExport"}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (_, exports) = admin
|
||||||
|
.hold_call("inbuxa:AuditEvent/get", json!({"ids": query["ids"].clone()}))
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
exports["list"]
|
||||||
|
.as_array()
|
||||||
|
.is_some_and(|l| l.iter().any(|r| r["reason"] == "Production to opposing counsel")),
|
||||||
|
"AU-1.9: the export isn't recorded: {exports}"
|
||||||
|
);
|
||||||
// A release is recorded under the hold's name, from before to after
|
// A release is recorded under the hold's name, from before to after
|
||||||
let list = records["list"].as_array().cloned().unwrap_or_default();
|
let list = records["list"].as_array().cloned().unwrap_or_default();
|
||||||
let release = list
|
let release = list
|
||||||
|
|||||||
Reference in New Issue
Block a user