CardDAV working with Thunderbird and Apple Contacts

This commit is contained in:
mdecimus
2025-04-18 13:55:55 +02:00
parent ce27cecded
commit 10ae19f2eb
53 changed files with 1544 additions and 1233 deletions

View File

@@ -7,7 +7,7 @@
use common::storage::index::{
IndexItem, IndexValue, IndexableAndSerializableObject, IndexableObject,
};
use jmap_proto::types::value::AclGrant;
use jmap_proto::types::{collection::Collection, value::AclGrant};
use store::SerializeInfallible;
use crate::{IDX_CARD_UID, IDX_NAME};
@@ -89,6 +89,10 @@ impl IndexableObject for ContactCard {
+ self.size,
},
IndexValue::LogChild { prefix: None },
IndexValue::LogParent {
collection: Collection::AddressBook.into(),
ids: self.names.iter().map(|n| n.parent_id).collect(),
},
]
.into_iter()
}
@@ -116,6 +120,10 @@ impl IndexableObject for &ArchivedContactCard {
+ self.size,
},
IndexValue::LogChild { prefix: None },
IndexValue::LogParent {
collection: Collection::AddressBook.into(),
ids: self.names.iter().map(|n| n.parent_id.to_native()).collect(),
},
]
.into_iter()
}

View File

@@ -5,6 +5,7 @@
*/
pub mod index;
pub mod storage;
use calcard::vcard::VCard;

View File

@@ -0,0 +1,231 @@
use common::{Server, auth::AccessToken, storage::index::ObjectIndexBuilder};
use jmap_proto::types::collection::Collection;
use store::write::{Archive, BatchBuilder, now};
use trc::AddContext;
use crate::DestroyArchive;
use super::{AddressBook, ArchivedAddressBook, ArchivedContactCard, ContactCard};
impl ContactCard {
pub fn update<'x>(
self,
access_token: &AccessToken,
card: Archive<&ArchivedContactCard>,
account_id: u32,
document_id: u32,
batch: &'x mut BatchBuilder,
) -> trc::Result<&'x mut BatchBuilder> {
let mut new_card = self;
// Build card
new_card.modified = now() as i64;
// Prepare write batch
batch
.with_account_id(account_id)
.with_collection(Collection::ContactCard)
.update_document(document_id)
.custom(
ObjectIndexBuilder::new()
.with_current(card)
.with_changes(new_card)
.with_tenant_id(access_token),
)
.map(|b| b.commit_point())
}
pub fn insert<'x>(
self,
access_token: &AccessToken,
account_id: u32,
document_id: u32,
batch: &'x mut BatchBuilder,
) -> trc::Result<&'x mut BatchBuilder> {
// Build card
let mut card = self;
let now = now() as i64;
card.modified = now;
card.created = now;
// Prepare write batch
batch
.with_account_id(account_id)
.with_collection(Collection::ContactCard)
.create_document(document_id)
.custom(
ObjectIndexBuilder::<(), _>::new()
.with_changes(card)
.with_tenant_id(access_token),
)
.map(|b| b.commit_point())
}
}
impl AddressBook {
pub fn insert<'x>(
self,
access_token: &AccessToken,
account_id: u32,
document_id: u32,
batch: &'x mut BatchBuilder,
) -> trc::Result<&'x mut BatchBuilder> {
// Build address book
let mut book = self;
let now = now() as i64;
book.modified = now;
book.created = now;
// Prepare write batch
batch
.with_account_id(account_id)
.with_collection(Collection::AddressBook)
.create_document(document_id)
.custom(
ObjectIndexBuilder::<(), _>::new()
.with_changes(book)
.with_tenant_id(access_token),
)
.map(|b| b.commit_point())
}
pub fn update<'x>(
self,
access_token: &AccessToken,
book: Archive<&ArchivedAddressBook>,
account_id: u32,
document_id: u32,
batch: &'x mut BatchBuilder,
) -> trc::Result<&'x mut BatchBuilder> {
// Build address book
let mut new_book = self;
new_book.modified = now() as i64;
// Prepare write batch
batch
.with_account_id(account_id)
.with_collection(Collection::AddressBook)
.update_document(document_id)
.custom(
ObjectIndexBuilder::new()
.with_current(book)
.with_changes(new_book)
.with_tenant_id(access_token),
)
.map(|b| b.commit_point())
}
}
impl DestroyArchive<Archive<&ArchivedAddressBook>> {
pub async fn delete_with_cards(
self,
server: &Server,
access_token: &AccessToken,
account_id: u32,
document_id: u32,
children_ids: Vec<u32>,
batch: &mut BatchBuilder,
) -> trc::Result<()> {
// Process deletions
let addressbook_id = document_id;
for document_id in children_ids {
if let Some(card_) = server
.get_archive(account_id, Collection::ContactCard, document_id)
.await?
{
DestroyArchive(
card_
.to_unarchived::<ContactCard>()
.caused_by(trc::location!())?,
)
.delete(
access_token,
account_id,
document_id,
addressbook_id,
batch,
)?;
}
}
self.delete(access_token, account_id, document_id, batch)
}
pub fn delete(
self,
access_token: &AccessToken,
account_id: u32,
document_id: u32,
batch: &mut BatchBuilder,
) -> trc::Result<()> {
let book = self.0;
// Delete addressbook
batch
.with_account_id(account_id)
.with_collection(Collection::AddressBook)
.delete_document(document_id)
.custom(
ObjectIndexBuilder::<_, ()>::new()
.with_tenant_id(access_token)
.with_current(book),
)
.caused_by(trc::location!())?
.commit_point();
Ok(())
}
}
impl DestroyArchive<Archive<&ArchivedContactCard>> {
pub fn delete(
self,
access_token: &AccessToken,
account_id: u32,
document_id: u32,
addressbook_id: u32,
batch: &mut BatchBuilder,
) -> trc::Result<()> {
let card = self.0;
if let Some(delete_idx) = card
.inner
.names
.iter()
.position(|name| name.parent_id == addressbook_id)
{
batch
.with_account_id(account_id)
.with_collection(Collection::ContactCard);
if card.inner.names.len() > 1 {
// Unlink addressbook id from card
let mut new_card = card
.deserialize::<ContactCard>()
.caused_by(trc::location!())?;
new_card.names.swap_remove(delete_idx);
batch
.update_document(document_id)
.custom(
ObjectIndexBuilder::new()
.with_tenant_id(access_token)
.with_current(card)
.with_changes(new_card),
)
.caused_by(trc::location!())?;
} else {
// Delete card
batch
.delete_document(document_id)
.custom(
ObjectIndexBuilder::<_, ()>::new()
.with_tenant_id(access_token)
.with_current(card),
)
.caused_by(trc::location!())?;
}
batch.commit_point();
}
Ok(())
}
}

View File

@@ -5,7 +5,7 @@
*/
pub mod index;
pub mod storage;
use dav_proto::schema::request::DeadProperty;
use jmap_proto::types::value::AclGrant;

View File

@@ -0,0 +1,133 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use common::{Server, auth::AccessToken, storage::index::ObjectIndexBuilder};
use jmap_proto::types::collection::Collection;
use store::write::{Archive, BatchBuilder, now};
use trc::AddContext;
use crate::DestroyArchive;
use super::{ArchivedFileNode, FileNode};
impl FileNode {
pub fn insert<'x>(
self,
access_token: &AccessToken,
account_id: u32,
document_id: u32,
batch: &'x mut BatchBuilder,
) -> trc::Result<&'x mut BatchBuilder> {
// Build node
let mut node = self;
let now = now() as i64;
node.modified = now;
node.created = now;
// Prepare write batch
batch
.with_account_id(account_id)
.with_collection(Collection::FileNode)
.create_document(document_id)
.custom(
ObjectIndexBuilder::<(), _>::new()
.with_changes(node)
.with_tenant_id(access_token),
)
.map(|b| b.commit_point())
}
pub fn update<'x>(
self,
access_token: &AccessToken,
node: Archive<&ArchivedFileNode>,
account_id: u32,
document_id: u32,
batch: &'x mut BatchBuilder,
) -> trc::Result<&'x mut BatchBuilder> {
// Build node
let mut new_node = self;
new_node.modified = now() as i64;
batch
.with_account_id(account_id)
.with_collection(Collection::FileNode)
.update_document(document_id)
.custom(
ObjectIndexBuilder::new()
.with_current(node)
.with_changes(new_node)
.with_tenant_id(access_token),
)
.map(|b| b.commit_point())
}
}
impl DestroyArchive<Archive<&ArchivedFileNode>> {
pub fn delete(
self,
access_token: &AccessToken,
account_id: u32,
document_id: u32,
batch: &mut BatchBuilder,
) -> trc::Result<()> {
// Prepare write batch
batch
.with_account_id(account_id)
.with_collection(Collection::FileNode)
.delete_document(document_id)
.custom(
ObjectIndexBuilder::<_, ()>::new()
.with_current(self.0)
.with_tenant_id(access_token),
)?
.commit_point();
Ok(())
}
}
impl DestroyArchive<Vec<u32>> {
pub async fn delete(
self,
server: &Server,
access_token: &AccessToken,
account_id: u32,
) -> trc::Result<()> {
// Process deletions
let mut batch = BatchBuilder::new();
batch
.with_account_id(account_id)
.with_collection(Collection::FileNode);
for document_id in self.0 {
if let Some(node) = server
.get_archive(account_id, Collection::FileNode, document_id)
.await?
{
// Delete record
batch
.delete_document(document_id)
.custom(
ObjectIndexBuilder::<_, ()>::new()
.with_tenant_id(access_token)
.with_current(
node.to_unarchived::<FileNode>()
.caused_by(trc::location!())?,
),
)
.caused_by(trc::location!())?
.commit_point();
}
}
// Write changes
if !batch.is_empty() {
server
.commit_batch(batch)
.await
.caused_by(trc::location!())?;
}
Ok(())
}
}

View File

@@ -6,28 +6,45 @@
use std::sync::Arc;
use common::{DavResource, DavResourceId, DavResources, Server};
use common::{DavResource, DavResourceId, DavResources, Server, auth::AccessToken};
use directory::backend::internal::manage::ManageDirectory;
use jmap_proto::types::collection::Collection;
use percent_encoding::NON_ALPHANUMERIC;
use store::{
Deserialize, IndexKey, IterateParams, SerializeInfallible, U32_LEN, ahash::AHashMap,
write::key::DeserializeBigEndian,
Deserialize, IndexKey, IndexKeyPrefix, IterateParams, SerializeInfallible, U32_LEN,
ahash::AHashMap,
write::{BatchBuilder, key::DeserializeBigEndian},
};
use trc::AddContext;
use utils::bimap::IdBimap;
use crate::{DavName, IDX_NAME, file::FileNode};
use crate::{DavName, DavResourceName, IDX_NAME, contact::AddressBook, file::FileNode};
pub trait DavHierarchy: Sync + Send {
fn fetch_dav_resources(
&self,
access_token: &AccessToken,
account_id: u32,
collection: Collection,
) -> impl Future<Output = trc::Result<Arc<DavResources>>> + Send;
fn create_default_addressbook(
&self,
access_token: &AccessToken,
account_id: u32,
) -> impl Future<Output = trc::Result<()>> + Send;
fn create_default_calendar(
&self,
access_token: &AccessToken,
account_id: u32,
) -> impl Future<Output = trc::Result<()>> + Send;
}
impl DavHierarchy for Server {
async fn fetch_dav_resources(
&self,
access_token: &AccessToken,
account_id: u32,
collection: Collection,
) -> trc::Result<Arc<DavResources>> {
@@ -51,7 +68,23 @@ impl DavHierarchy for Server {
} else {
let mut files = match collection {
Collection::Calendar | Collection::AddressBook => {
build_hierarchy(self, account_id, collection).await?
let files = build_hierarchy(self, account_id, collection).await?;
if files.paths.is_empty() {
match collection {
Collection::Calendar => {
self.create_default_calendar(access_token, account_id)
.await?
}
Collection::AddressBook => {
self.create_default_addressbook(access_token, account_id)
.await?
}
_ => unreachable!(),
}
build_hierarchy(self, account_id, collection).await?
} else {
files
}
}
Collection::FileNode => build_file_hierarchy(self, account_id).await?,
_ => unreachable!(),
@@ -64,6 +97,38 @@ impl DavHierarchy for Server {
Ok(files)
}
}
async fn create_default_addressbook(
&self,
access_token: &AccessToken,
account_id: u32,
) -> trc::Result<()> {
if let Some(name) = &self.core.dav.default_addressbook_name {
let mut batch = BatchBuilder::new();
let document_id = self
.store()
.assign_document_ids(account_id, Collection::AddressBook, 1)
.await?;
AddressBook {
name: name.clone(),
display_name: self.core.dav.default_addressbook_display_name.clone(),
is_default: true,
..Default::default()
}
.insert(access_token, account_id, document_id, &mut batch)?;
self.commit_batch(batch).await?;
}
Ok(())
}
async fn create_default_calendar(
&self,
access_token: &AccessToken,
account_id: u32,
) -> trc::Result<()> {
todo!()
}
}
async fn build_hierarchy(
@@ -71,6 +136,7 @@ async fn build_hierarchy(
account_id: u32,
collection: Collection,
) -> trc::Result<DavResources> {
let base_path = DavResourceName::from(collection).base_path();
let collection = u8::from(collection);
let mut containers: AHashMap<u32, String> = AHashMap::with_capacity(16);
let mut resources: AHashMap<u32, Vec<DavName>> = AHashMap::with_capacity(16);
@@ -99,7 +165,7 @@ async fn build_hierarchy(
|key, _| {
let document_id = key.deserialize_be_u32(key.len() - U32_LEN)?;
let value = key
.get(key.len() - (U32_LEN * 2)..key.len() - U32_LEN)
.get(IndexKeyPrefix::len()..key.len() - U32_LEN)
.ok_or_else(|| trc::Error::corrupted_key(key, None, trc::location!()))?;
let key_collection = key
.get(U32_LEN)
@@ -126,10 +192,22 @@ async fn build_hierarchy(
.await
.caused_by(trc::location!())?;
let name = server
.store()
.get_principal_name(account_id)
.await
.caused_by(trc::location!())?
.unwrap_or_else(|| format!("_{account_id}"));
let mut files = DavResources {
paths: IdBimap::with_capacity(containers.len() + resources.len()),
size: std::mem::size_of::<DavResources>() as u64,
modseq: None,
base_path: format!(
"{}/{}/",
base_path,
percent_encoding::utf8_percent_encode(&name, NON_ALPHANUMERIC),
),
};
for (document_id, dav_names) in resources {
@@ -172,7 +250,18 @@ async fn build_file_hierarchy(server: &Server, account_id: u32) -> trc::Result<D
.fetch_folders::<FileNode>(account_id, Collection::FileNode)
.await
.caused_by(trc::location!())?;
let name = server
.store()
.get_principal_name(account_id)
.await
.caused_by(trc::location!())?
.unwrap_or_else(|| format!("_{account_id}"));
let mut files = DavResources {
base_path: format!(
"{}/{}/",
DavResourceName::Card.base_path(),
percent_encoding::utf8_percent_encode(&name, NON_ALPHANUMERIC),
),
paths: IdBimap::with_capacity(list.len()),
size: std::mem::size_of::<DavResources>() as u64,
modseq: None,

View File

@@ -4,6 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use jmap_proto::types::collection::Collection;
use store::{Deserialize, SerializeInfallible, write::key::KeySerializer};
use utils::codec::leb128::Leb128Reader;
@@ -15,6 +16,16 @@ pub mod hierarchy;
pub const IDX_NAME: u8 = 0;
pub const IDX_CARD_UID: u8 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DavResourceName {
Card,
Cal,
File,
Principal,
}
pub struct DestroyArchive<T>(pub T);
#[derive(
rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq,
)]
@@ -69,3 +80,55 @@ impl Deserialize for DavName {
Ok(DavName { name, parent_id })
}
}
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,
)
}
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",
}
}
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/",
}
}
}
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,
}
}
}
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,
_ => unreachable!(),
}
}
}