A locked account can't sign in (it fails as a wrong password does), its sessions end on every node, refresh tokens stop working, and its Sieve scripts forward and reply to nothing. Mail keeps arriving. Delegates get real ACL grants on the account's mailboxes, calendars, address books and files at read, organize or full, with the rights they replaced restored on unlock. Folders made later are granted after the create and in a daily sweep. Organize delegates can't destroy; send-as needs organize or full. The JMAP session marks delegated accounts in urn:inbuxa:jmap. New inbuxa:AccountLock object with get/set, permissions 665-668, and a Compliance > Locked Accounts entry in the schema. Lock, unlock and delegate changes need a reason and are audited; delegate access and writes are audited too (audit-hold-lock spec AL-1 to AL-12).
296 lines
8.1 KiB
Rust
296 lines
8.1 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
|
*
|
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
|
*/
|
|
|
|
// inbuxa: composite stores (sharded members, read replicas) nest store
|
|
// futures deeply enough to pass rustc's default query depth
|
|
#![recursion_limit = "512"]
|
|
|
|
#![warn(clippy::large_futures)]
|
|
|
|
use calcard::common::timezone::Tz;
|
|
use common::DavResources;
|
|
use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
|
|
use std::borrow::Cow;
|
|
use types::collection::{Collection, SyncCollection};
|
|
|
|
pub mod cache;
|
|
pub mod calendar;
|
|
pub mod contact;
|
|
pub mod file;
|
|
pub mod inbuxa; // inbuxa: undelete notes
|
|
pub mod inbuxa_lock; // inbuxa: account lock grants
|
|
pub mod scheduling;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum DavResourceName {
|
|
Card,
|
|
Cal,
|
|
File,
|
|
Principal,
|
|
Scheduling,
|
|
}
|
|
|
|
pub const RFC_3986: &AsciiSet = &CONTROLS
|
|
.add(b' ')
|
|
.add(b'!')
|
|
.add(b'"')
|
|
.add(b'#')
|
|
.add(b'$')
|
|
.add(b'%')
|
|
.add(b'&')
|
|
.add(b'\'')
|
|
.add(b'(')
|
|
.add(b')')
|
|
.add(b'*')
|
|
.add(b'+')
|
|
.add(b',')
|
|
.add(b'/')
|
|
.add(b':')
|
|
.add(b';')
|
|
.add(b'<')
|
|
.add(b'=')
|
|
.add(b'>')
|
|
.add(b'?')
|
|
.add(b'@')
|
|
.add(b'[')
|
|
.add(b'\\')
|
|
.add(b']')
|
|
.add(b'^')
|
|
.add(b'`')
|
|
.add(b'{')
|
|
.add(b'|')
|
|
.add(b'}');
|
|
|
|
fn is_pchar(byte: u8) -> bool {
|
|
matches!(byte,
|
|
b'A'..=b'Z'
|
|
| b'a'..=b'z'
|
|
| b'0'..=b'9'
|
|
| b'-'
|
|
| b'.'
|
|
| b'_'
|
|
| b'~'
|
|
| b'!'
|
|
| b'$'
|
|
| b'&'
|
|
| b'\''
|
|
| b'('
|
|
| b')'
|
|
| b'*'
|
|
| b'+'
|
|
| b','
|
|
| b';'
|
|
| b'='
|
|
| b':'
|
|
| b'@')
|
|
}
|
|
|
|
pub fn is_uri_segment(name: &str) -> bool {
|
|
let mut bytes = name.as_bytes().iter();
|
|
|
|
while let Some(&byte) = bytes.next() {
|
|
if byte == b'%' {
|
|
if !bytes.next().is_some_and(u8::is_ascii_hexdigit)
|
|
|| !bytes.next().is_some_and(u8::is_ascii_hexdigit)
|
|
{
|
|
return false;
|
|
}
|
|
} else if !is_pchar(byte) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
pub fn encode_path_segment(name: &str) -> Cow<'_, str> {
|
|
if is_uri_segment(name) {
|
|
Cow::Borrowed(name)
|
|
} else {
|
|
utf8_percent_encode(name, RFC_3986).into()
|
|
}
|
|
}
|
|
|
|
pub struct DestroyArchive<T>(pub T);
|
|
|
|
impl DavResourceName {
|
|
pub fn parse(service: &str) -> Option<Self> {
|
|
hashify::tiny_map!(service.as_bytes(),
|
|
"card" => DavResourceName::Card,
|
|
"cal" => DavResourceName::Cal,
|
|
"file" => DavResourceName::File,
|
|
"pal" => DavResourceName::Principal,
|
|
"itip" => DavResourceName::Scheduling,
|
|
)
|
|
}
|
|
|
|
pub fn base_path(&self) -> &'static str {
|
|
match self {
|
|
DavResourceName::Card => "/dav/card",
|
|
DavResourceName::Cal => "/dav/cal",
|
|
DavResourceName::File => "/dav/file",
|
|
DavResourceName::Principal => "/dav/pal",
|
|
DavResourceName::Scheduling => "/dav/itip",
|
|
}
|
|
}
|
|
|
|
pub fn collection_path(&self) -> &'static str {
|
|
match self {
|
|
DavResourceName::Card => "/dav/card/",
|
|
DavResourceName::Cal => "/dav/cal/",
|
|
DavResourceName::File => "/dav/file/",
|
|
DavResourceName::Principal => "/dav/pal/",
|
|
DavResourceName::Scheduling => "/dav/itip/",
|
|
}
|
|
}
|
|
|
|
pub fn name(&self) -> &'static str {
|
|
match self {
|
|
DavResourceName::Card => "CardDAV",
|
|
DavResourceName::Cal => "CalDAV",
|
|
DavResourceName::File => "WebDAV",
|
|
DavResourceName::Principal => "Principal",
|
|
DavResourceName::Scheduling => "Scheduling",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<DavResourceName> for Collection {
|
|
fn from(value: DavResourceName) -> Self {
|
|
match value {
|
|
DavResourceName::Card => Collection::AddressBook,
|
|
DavResourceName::Cal => Collection::Calendar,
|
|
DavResourceName::File => Collection::FileNode,
|
|
DavResourceName::Principal => Collection::Principal,
|
|
DavResourceName::Scheduling => Collection::CalendarEventNotification,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<Collection> for DavResourceName {
|
|
fn from(value: Collection) -> Self {
|
|
match value {
|
|
Collection::AddressBook => DavResourceName::Card,
|
|
Collection::Calendar => DavResourceName::Cal,
|
|
Collection::FileNode => DavResourceName::File,
|
|
Collection::Principal => DavResourceName::Principal,
|
|
Collection::CalendarEventNotification => DavResourceName::Scheduling,
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<SyncCollection> for DavResourceName {
|
|
fn from(value: SyncCollection) -> Self {
|
|
match value {
|
|
SyncCollection::AddressBook => DavResourceName::Card,
|
|
SyncCollection::Calendar => DavResourceName::Cal,
|
|
SyncCollection::FileNode => DavResourceName::File,
|
|
SyncCollection::CalendarEventNotification => DavResourceName::Scheduling,
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub trait DavCalendarResource {
|
|
fn calendar_default_tz(&self, calendar_id: u32, account_id: u32) -> Option<Tz>;
|
|
}
|
|
|
|
impl DavCalendarResource for DavResources {
|
|
fn calendar_default_tz(&self, calendar_id: u32, account_id: u32) -> Option<Tz> {
|
|
self.container_resource_by_id(calendar_id)
|
|
.and_then(|c| c.calendar_preferences(account_id))
|
|
.map(|p| p.tz)
|
|
}
|
|
}
|
|
|
|
pub fn strip_mailto_scheme(value: &str) -> &str {
|
|
value
|
|
.split_once(':')
|
|
.filter(|(scheme, _)| scheme.eq_ignore_ascii_case("mailto"))
|
|
.map_or(value, |(_, address)| address.trim())
|
|
}
|
|
|
|
pub fn decode_mailto_address(value: &str) -> Cow<'_, str> {
|
|
match value.split_once(':') {
|
|
Some((scheme, address)) if scheme.eq_ignore_ascii_case("mailto") => {
|
|
let address = address.trim();
|
|
let address = address.split_once('?').map_or(address, |(to, _)| to);
|
|
percent_decode_str(address).decode_utf8_lossy()
|
|
}
|
|
_ => Cow::Borrowed(value),
|
|
}
|
|
}
|
|
|
|
pub fn extract_addr_spec(value: &str) -> Option<&str> {
|
|
value
|
|
.rsplit_once('<')
|
|
.and_then(|(_, rest)| rest.split_once('>'))
|
|
.map(|(addr, _)| addr.trim())
|
|
.filter(|addr| !addr.is_empty())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn path_segments_from_uris_are_preserved() {
|
|
for name in [
|
|
"readme.txt",
|
|
"My%20Folder",
|
|
"%C3%9Cnterlagen.txt",
|
|
"file(1).txt",
|
|
"a+b.txt",
|
|
"Q&A.txt",
|
|
"it's.txt",
|
|
"[email protected]",
|
|
"a:b.txt",
|
|
"notes;v=2,rev=3!$*=.txt",
|
|
"~backup_1-2.txt",
|
|
] {
|
|
assert!(is_uri_segment(name), "{name:?}");
|
|
assert_eq!(encode_path_segment(name), name);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn path_segments_from_names_are_encoded() {
|
|
for (name, expected) in [
|
|
("My Folder", "My%20Folder"),
|
|
("Ünterlagen.txt", "%C3%9Cnterlagen.txt"),
|
|
("Ünterlagen 2026.txt", "%C3%9Cnterlagen%202026.txt"),
|
|
("100%", "100%25"),
|
|
("100%2", "100%252"),
|
|
("100%zz", "100%25zz"),
|
|
("a/b.txt", "a%2Fb.txt"),
|
|
("a<b>c.txt", "a%3Cb%3Ec.txt"),
|
|
("a\"b#c?d.txt", "a%22b%23c%3Fd.txt"),
|
|
("a\tb.txt", "a%09b.txt"),
|
|
] {
|
|
assert!(!is_uri_segment(name), "{name:?}");
|
|
assert_eq!(encode_path_segment(name), expected, "{name:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn encoded_path_segments_are_stable() {
|
|
for name in [
|
|
"My Folder",
|
|
"Ünterlagen 2026.txt",
|
|
"100%",
|
|
"a/b.txt",
|
|
"file(1).txt",
|
|
] {
|
|
let encoded = encode_path_segment(name).into_owned();
|
|
assert!(is_uri_segment(&encoded), "{encoded:?}");
|
|
assert_eq!(encode_path_segment(&encoded), encoded, "{name:?}");
|
|
}
|
|
}
|
|
}
|