Principal storage format improvements

This commit is contained in:
mdecimus 2025-10-21 11:23:30 +02:00
parent 107561297d
commit 4620a92fd8
22 changed files with 287 additions and 117 deletions

View file

@ -73,7 +73,14 @@ impl Server {
object_quota[typ as usize] = quota;
}
PrincipalData::Description(v) => description = Some(v),
PrincipalData::Email(v) => {
PrincipalData::PrimaryEmail(v) => {
if emails.is_empty() {
emails.push(v);
} else {
emails.insert(0, v);
}
}
PrincipalData::EmailAlias(v) => {
emails.push(v);
}
PrincipalData::Locale(v) => locale = Some(v),
@ -129,13 +136,7 @@ impl Server {
.caused_by(trc::location!())?
&& group.typ == Type::Group
{
emails.extend(group.data.into_iter().filter_map(|data| {
if let PrincipalData::Email(email) = data {
Some(email)
} else {
None
}
}));
emails.extend(group.into_email_addresses());
}
}

View file

@ -131,7 +131,14 @@ impl PrincipalPropFind for Server {
PrincipalData::Description(desc) => {
description = Some(desc);
}
PrincipalData::Email(email) => {
PrincipalData::PrimaryEmail(email) => {
if emails.is_empty() {
emails.push(email);
} else {
emails.insert(0, email);
}
}
PrincipalData::EmailAlias(email) => {
emails.push(email);
}
_ => {}

View file

@ -159,7 +159,9 @@ impl DirectoryStore for Store {
for account_id in self.get_members(list_id).await? {
if let Some(email) = self.get_principal(account_id).await?.and_then(|p| {
p.data.into_iter().find_map(|data| {
if let PrincipalData::Email(email) = data {
if let PrincipalData::PrimaryEmail(email) | PrincipalData::EmailAlias(email) =
data
{
Some(email)
} else {
None

View file

@ -206,6 +206,7 @@ impl ManageDirectory for Store {
.assert_value(name_key.clone(), ())
.create_document(principal_id);
build_search_index(&mut batch, principal_id, None, Some(&principal));
principal.sort();
batch
.set(
name_key,
@ -377,18 +378,19 @@ impl ManageDirectory for Store {
// Set fields
principal_create.name = name;
if let Some(description) = principal_set.take_str(PrincipalField::Description) {
principal_create
.data
.push(PrincipalData::Description(description));
}
for secret in principal_set
.take_str_array(PrincipalField::Secrets)
.unwrap_or_default()
{
principal_create.data.push(PrincipalData::Secret(secret));
}
if let Some(description) = principal_set.take_str(PrincipalField::Description) {
principal_create
.data
.push(PrincipalData::Description(description));
}
if let Some(picture) = principal_set.take_str(PrincipalField::Picture) {
principal_create.data.push(PrincipalData::Picture(picture));
}
@ -518,9 +520,11 @@ impl ManageDirectory for Store {
// Make sure the e-mail is not taken and validate domain
if principal_create.typ != Type::OauthClient {
for email in principal_set
for (idx, email) in principal_set
.take_str_array(PrincipalField::Emails)
.unwrap_or_default()
.into_iter()
.enumerate()
{
let email = email.to_lowercase();
if self.rcpt(&email).await.caused_by(trc::location!())? != RcptType::Invalid {
@ -535,7 +539,13 @@ impl ManageDirectory for Store {
.filter(|v| v.typ == Type::Domain && v.has_tenant_access(tenant_id))
.ok_or_else(|| not_found(domain.to_string()))?;
}
principal_create.data.push(PrincipalData::Email(email));
if idx == 0 {
principal_create
.data
.push(PrincipalData::PrimaryEmail(email));
} else {
principal_create.data.push(PrincipalData::EmailAlias(email));
}
}
}
@ -564,6 +574,7 @@ impl ManageDirectory for Store {
}
// Serialize
principal_create.sort();
let archiver = Archiver::new(principal_create);
let principal_bytes = archiver.serialize().caused_by(trc::location!())?;
let principal_create = archiver.into_inner();
@ -592,7 +603,7 @@ impl ManageDirectory for Store {
);
// Write email to id mapping
for email in principal_create.emails() {
for email in principal_create.email_addresses() {
batch.set(
ValueClass::Directory(DirectoryClass::EmailToId(email.as_bytes().to_vec())),
pinfo_email.serialize(),
@ -830,7 +841,9 @@ impl ManageDirectory for Store {
.clear(DirectoryClass::UsedQuota(principal_id));
for email in principal.data.iter() {
if let ArchivedPrincipalData::Email(email) = email {
if let ArchivedPrincipalData::PrimaryEmail(email)
| ArchivedPrincipalData::EmailAlias(email) = email
{
batch.clear(DirectoryClass::EmailToId(email.as_bytes().to_vec()));
}
}
@ -1160,25 +1173,9 @@ impl ManageDirectory for Store {
PrincipalValue::String(secret),
) => {
if !principal.secrets().any(|v| *v == secret) {
if secret.is_otp_auth() {
// Add OTP Auth URLs to the beginning of the list
principal.data.insert(0, PrincipalData::Secret(secret));
// Password changed, update changed principals
changed_principals.add_change(
principal_id,
principal_type,
change.field,
);
} else {
principal.data.push(PrincipalData::Secret(secret));
// Password changed, update changed principals
changed_principals.add_change(
principal_id,
principal_type,
change.field,
);
}
principal.data.push(PrincipalData::Secret(secret));
// Password changed, update changed principals
changed_principals.add_change(principal_id, principal_type, change.field);
}
}
(
@ -1307,7 +1304,7 @@ impl ManageDirectory for Store {
.map(|v| v.to_lowercase())
.collect::<Vec<_>>();
for email in &emails {
if !principal.emails().any(|v| v == email) {
if !principal.email_addresses().any(|v| v == email) {
if validate_emails {
self.validate_email(email, tenant_id, params.create_domains)
.await?;
@ -1321,7 +1318,7 @@ impl ManageDirectory for Store {
}
}
for email in principal.emails() {
for email in principal.email_addresses() {
if !emails.contains(email) {
batch.clear(ValueClass::Directory(DirectoryClass::EmailToId(
email.as_bytes().to_vec(),
@ -1332,11 +1329,18 @@ impl ManageDirectory for Store {
// Emails changed, update changed principals
changed_principals.add_change(principal_id, principal_type, change.field);
principal
.data
.retain(|v| !matches!(v, PrincipalData::Email(_)));
for email in emails {
principal.data.push(PrincipalData::Email(email));
principal.data.retain(|v| {
!matches!(
v,
PrincipalData::PrimaryEmail(_) | PrincipalData::EmailAlias(_)
)
});
for (idx, email) in emails.into_iter().enumerate() {
if idx == 0 {
principal.data.push(PrincipalData::PrimaryEmail(email));
} else {
principal.data.push(PrincipalData::EmailAlias(email));
}
}
}
(
@ -1345,7 +1349,11 @@ impl ManageDirectory for Store {
PrincipalValue::String(email),
) => {
let email = email.to_lowercase();
if !principal.emails().any(|v| v == &email) {
let mut emails_iter = principal.email_addresses().peekable();
let has_emails = emails_iter.peek().is_some();
let email_exists = emails_iter.any(|v| v == &email);
drop(emails_iter);
if !email_exists {
if validate_emails {
self.validate_email(&email, tenant_id, params.create_domains)
.await?;
@ -1356,7 +1364,11 @@ impl ManageDirectory for Store {
)),
pinfo_email.clone(),
);
principal.data.push(PrincipalData::Email(email));
if has_emails {
principal.data.push(PrincipalData::EmailAlias(email));
} else {
principal.data.push(PrincipalData::PrimaryEmail(email));
}
// Emails changed, update changed principals
changed_principals.add_change(principal_id, principal_type, change.field);
@ -1368,15 +1380,33 @@ impl ManageDirectory for Store {
PrincipalValue::String(email),
) => {
let email = email.to_lowercase();
if principal.emails().any(|v| v == &email) {
if principal.email_addresses().any(|v| v == &email) {
let mut deleted_primary = false;
principal.data.retain(|v| match v {
PrincipalData::Email(v) => v != &email,
PrincipalData::EmailAlias(v) => v != &email,
PrincipalData::PrimaryEmail(v) => {
if v == &email {
deleted_primary = true;
false
} else {
true
}
}
_ => true,
});
batch.clear(ValueClass::Directory(DirectoryClass::EmailToId(
email.as_bytes().to_vec(),
)));
if deleted_primary {
for data in &mut principal.data {
if let PrincipalData::EmailAlias(email) = data {
*data = PrincipalData::PrimaryEmail(std::mem::take(email));
break;
}
}
}
// Emails changed, update changed principals
changed_principals.add_change(principal_id, principal_type, change.field);
}
@ -2294,7 +2324,7 @@ impl ManageDirectory for Store {
result.append_str(PrincipalField::Secrets, secret);
}
}
PrincipalData::Email(email) => {
PrincipalData::PrimaryEmail(email) | PrincipalData::EmailAlias(email) => {
if fields.is_empty() || fields.contains(&PrincipalField::Emails) {
result.append_str(PrincipalField::Emails, email);
}

View file

@ -431,6 +431,7 @@ impl LdapMappings {
let mut role = ROLE_USER;
let mut member_of = vec![];
let mut description = None;
let mut has_primary_email = false;
for (attr, value) in entry.attrs {
if self.attr_name.contains(&attr) {
@ -438,9 +439,16 @@ impl LdapMappings {
principal.name = value.into_iter().next().unwrap_or_default();
} else {
for (idx, item) in value.into_iter().enumerate() {
principal
.data
.insert(0, PrincipalData::Email(item.to_lowercase()));
if !has_primary_email {
has_primary_email = true;
principal
.data
.push(PrincipalData::PrimaryEmail(item.to_lowercase()));
} else {
principal
.data
.push(PrincipalData::EmailAlias(item.to_lowercase()));
}
if idx == 0 {
principal.name = item;
}
@ -461,15 +469,22 @@ impl LdapMappings {
}
} else if self.attr_email_address.contains(&attr) {
for item in value {
principal
.data
.insert(0, PrincipalData::Email(item.to_lowercase()));
if !has_primary_email {
has_primary_email = true;
principal
.data
.push(PrincipalData::PrimaryEmail(item.to_lowercase()));
} else {
principal
.data
.push(PrincipalData::EmailAlias(item.to_lowercase()));
}
}
} else if self.attr_email_alias.contains(&attr) {
for item in value {
principal
.data
.push(PrincipalData::Email(item.to_lowercase()));
.push(PrincipalData::EmailAlias(item.to_lowercase()));
}
} else if let Some(idx) = self.attr_description.iter().position(|a| a == &attr) {
if (description.is_none() || idx == 0)

View file

@ -108,9 +108,15 @@ impl MemoryDirectory {
directory.domains.insert(domain.to_lowercase());
}
principal
.data
.push(PrincipalData::Email(email.to_lowercase()));
if pos == 0 {
principal
.data
.push(PrincipalData::PrimaryEmail(email.to_lowercase()));
} else {
principal
.data
.push(PrincipalData::EmailAlias(email.to_lowercase()));
}
}
// Parse mailing lists

View file

@ -80,8 +80,8 @@ impl MemoryDirectory {
if let EmailType::List(uid) = item {
for principal in &self.principals {
if principal.id == *uid {
if let Some(addr) = principal.emails().next() {
result.push(addr.clone())
if let Some(addr) = principal.primary_email() {
result.push(addr.to_string())
}
break;
}

View file

@ -183,7 +183,7 @@ impl BuildPrincipal for OpenIdResponse {
// Build principal
let mut data = Vec::with_capacity(3);
data.push(PrincipalData::Email(email));
data.push(PrincipalData::PrimaryEmail(email));
if let Some(name) = full_name {
data.push(PrincipalData::Description(name));
}

View file

@ -158,20 +158,29 @@ impl SqlDirectory {
// Obtain emails
if !self.mappings.query_emails.is_empty() {
let rows = self
let mut rows = self
.sql_store
.sql_query::<Rows>(
&self.mappings.query_emails,
vec![external_principal.name().into()],
)
.await
.caused_by(trc::location!())?;
external_principal.data.extend(
rows.rows
.into_iter()
.flat_map(|v| v.values.into_iter().map(|v| v.into_lower_string()))
.map(PrincipalData::Email),
);
.caused_by(trc::location!())?
.rows
.into_iter()
.flat_map(|v| v.values.into_iter().map(|v| v.into_lower_string()));
if external_principal.primary_email().is_none()
&& let Some(email) = rows.next()
{
external_principal
.data
.push(PrincipalData::PrimaryEmail(email));
}
external_principal
.data
.extend(rows.map(PrincipalData::EmailAlias));
}
// Obtain account ID if not available
@ -271,6 +280,7 @@ impl SqlMappings {
let mut principal = Principal::new(u32::MAX, Type::Individual);
let mut role = ROLE_USER;
let mut has_primary_email = false;
if let Some(row) = rows.rows.into_iter().next() {
for (name, value) in rows.names.into_iter().zip(row.values) {
@ -300,9 +310,16 @@ impl SqlMappings {
}
} else if name.eq_ignore_ascii_case(&self.column_email) {
if let Value::Text(text) = value {
principal
.data
.push(PrincipalData::Email(text.to_lowercase()));
if !has_primary_email {
has_primary_email = true;
principal
.data
.push(PrincipalData::PrimaryEmail(text.to_lowercase()));
} else {
principal
.data
.push(PrincipalData::EmailAlias(text.to_lowercase()));
}
}
} else if name.eq_ignore_ascii_case(&self.column_quota)
&& let Value::Integer(quota) = value

View file

@ -7,7 +7,9 @@
use crate::{
ArchivedPrincipal, ArchivedPrincipalData, FALLBACK_ADMIN_ID, Permission, PermissionGrant,
Principal, PrincipalData, ROLE_ADMIN, Type,
backend::internal::{PrincipalField, PrincipalSet, PrincipalUpdate, PrincipalValue},
backend::internal::{
PrincipalField, PrincipalSet, PrincipalUpdate, PrincipalValue, SpecialSecrets,
},
};
use ahash::AHashSet;
use nlp::tokenizers::word::WordTokenizer;
@ -104,18 +106,64 @@ impl Principal {
}
pub fn secrets(&self) -> impl Iterator<Item = &String> {
self.data.iter().filter_map(|item| {
if let PrincipalData::Secret(secret) = item {
Some(secret)
let mut found_secret = false;
self.data
.iter()
.take_while(move |item| {
if matches!(item, PrincipalData::Secret(_)) {
found_secret = true;
true
} else {
!found_secret
}
})
.filter_map(|item| {
if let PrincipalData::Secret(secret) = item {
Some(secret)
} else {
None
}
})
}
pub fn primary_email(&self) -> Option<&str> {
self.data.iter().find_map(|item| {
if let PrincipalData::PrimaryEmail(email) = item {
Some(email.as_str())
} else {
None
}
})
}
pub fn emails(&self) -> impl Iterator<Item = &String> {
self.data.iter().filter_map(|item| {
if let PrincipalData::Email(email) = item {
pub fn email_addresses(&self) -> impl Iterator<Item = &String> {
let mut found_email = false;
self.data
.iter()
.take_while(move |item| {
if matches!(
item,
PrincipalData::PrimaryEmail(_) | PrincipalData::EmailAlias(_)
) {
found_email = true;
true
} else {
!found_email
}
})
.filter_map(|item| {
if let PrincipalData::PrimaryEmail(email) | PrincipalData::EmailAlias(email) = item
{
Some(email)
} else {
None
}
})
}
pub fn into_primary_email(self) -> Option<String> {
self.data.into_iter().find_map(|item| {
if let PrincipalData::PrimaryEmail(email) = item {
Some(email)
} else {
None
@ -123,9 +171,9 @@ impl Principal {
})
}
pub fn into_emails(self) -> impl Iterator<Item = String> {
pub fn into_email_addresses(self) -> impl Iterator<Item = String> {
self.data.into_iter().filter_map(|item| {
if let PrincipalData::Email(email) = item {
if let PrincipalData::PrimaryEmail(email) | PrincipalData::EmailAlias(email) = item {
Some(email)
} else {
None
@ -325,15 +373,27 @@ impl Principal {
}
// Update emails
if update_list(external.emails(), self.emails()) {
if update_list(external.email_addresses(), self.email_addresses()) {
if overwrite_emails {
let mut new_emails = Vec::new();
self.data.retain(|item| {
!matches!(
item,
PrincipalData::PrimaryEmail(_) | PrincipalData::EmailAlias(_)
)
});
self.data
.retain(|item| !matches!(item, PrincipalData::Email(_)));
self.data.extend(external.emails().map(|email| {
new_emails.push(email.to_string());
PrincipalData::Email(email.to_string())
}));
.extend(external.data.iter().filter_map(|v| match v {
PrincipalData::PrimaryEmail(email) => {
new_emails.push(email.clone());
Some(PrincipalData::PrimaryEmail(email.clone()))
}
PrincipalData::EmailAlias(email) => {
new_emails.push(email.clone());
Some(PrincipalData::EmailAlias(email.clone()))
}
_ => None,
}));
updates.push(PrincipalUpdate::set(
PrincipalField::Emails,
PrincipalValue::StringList(new_emails),
@ -341,16 +401,16 @@ impl Principal {
} else {
// Missing emails are appended to avoid overwriting locally defined aliases
// This means that old email addresses need to be deleted either manually or using the API
let current_emails = self.emails().collect::<AHashSet<_>>();
let current_emails = self.email_addresses().collect::<AHashSet<_>>();
let mut new_emails = Vec::new();
for email in external.emails() {
for email in external.email_addresses() {
let email = email.to_lowercase();
if !current_emails.contains(&email) {
updates.push(PrincipalUpdate::add_item(
PrincipalField::Emails,
PrincipalValue::String(email.clone()),
));
new_emails.push(PrincipalData::Email(email));
new_emails.push(PrincipalData::EmailAlias(email));
}
}
self.data.extend(new_emails);
@ -396,6 +456,27 @@ impl Principal {
],
}
}
pub fn sort(&mut self) {
self.data.sort_unstable_by_key(|d| d.rank());
}
}
impl PrincipalData {
fn rank(&self) -> usize {
match self {
PrincipalData::Secret(v) => {
if v.is_otp_auth() {
0
} else {
1
}
}
PrincipalData::PrimaryEmail(_) => 2,
PrincipalData::EmailAlias(_) => 3,
_ => 4,
}
}
}
fn update_list<'x>(
@ -432,7 +513,8 @@ impl PrincipalData {
match self {
PrincipalData::Secret(v)
| PrincipalData::Description(v)
| PrincipalData::Email(v)
| PrincipalData::PrimaryEmail(v)
| PrincipalData::EmailAlias(v)
| PrincipalData::Picture(v)
| PrincipalData::ExternalMember(v)
| PrincipalData::Url(v)
@ -955,8 +1037,9 @@ pub(crate) fn build_search_index(
for word in [Some(current.name.as_str())]
.into_iter()
.chain(current.data.iter().map(|s| match s {
ArchivedPrincipalData::Description(v) => Some(v.as_str()),
ArchivedPrincipalData::Email(v) => Some(v.as_str()),
ArchivedPrincipalData::Description(v)
| ArchivedPrincipalData::PrimaryEmail(v)
| ArchivedPrincipalData::EmailAlias(v) => Some(v.as_str()),
_ => None,
}))
.flatten()
@ -969,8 +1052,9 @@ pub(crate) fn build_search_index(
for word in [Some(new.name.as_str())]
.into_iter()
.chain(new.data.iter().map(|s| match s {
PrincipalData::Description(v) => Some(v.as_str()),
PrincipalData::Email(v) => Some(v.as_str()),
PrincipalData::Description(v)
| PrincipalData::PrimaryEmail(v)
| PrincipalData::EmailAlias(v) => Some(v.as_str()),
_ => None,
}))
.flatten()

View file

@ -60,7 +60,8 @@ pub enum PrincipalData {
// Profile data
Description(String),
Email(String),
PrimaryEmail(String),
EmailAlias(String),
Picture(String),
ExternalMember(String),
Url(String),

View file

@ -124,7 +124,7 @@ impl SieveScriptIngest for Server {
.caused_by(trc::location!())?
.and_then(|p| {
instance.set_user_full_name(p.description().unwrap_or_else(|| p.name()));
p.into_emails().next()
p.into_primary_email()
});
// Set account address

View file

@ -210,8 +210,7 @@ impl Autoconfig for Server {
.query(QueryParams::id(id).with_return_member_of(false))
.await
&& principal
.emails()
.next()
.primary_email()
.is_some_and(|email| email.eq_ignore_ascii_case(emailaddress))
{
account_name = principal.name;

View file

@ -167,7 +167,7 @@ impl IdentityGet for Server {
let mut description = None;
for data in principal.data {
match data {
PrincipalData::Email(v) => emails.push(v),
PrincipalData::PrimaryEmail(v) | PrincipalData::EmailAlias(v) => emails.push(v),
PrincipalData::Description(v) => description = Some(v),
_ => {}
}

View file

@ -68,7 +68,7 @@ impl IdentitySet for Server {
.directory()
.query(QueryParams::id(account_id).with_return_member_of(false))
.await?
.is_none_or(|p| !p.emails().any(|e| e == &identity.email))
.is_none_or(|p| !p.email_addresses().any(|e| e == &identity.email))
{
response.not_created.append(
id,

View file

@ -144,7 +144,7 @@ impl ParticipantIdentityGet for Server {
let mut description = None;
for data in principal.data {
match data {
PrincipalData::Email(v) => emails.push(v),
PrincipalData::PrimaryEmail(v) | PrincipalData::EmailAlias(v) => emails.push(v),
PrincipalData::Description(v) => description = Some(v),
_ => {}
}

View file

@ -58,7 +58,7 @@ impl ParticipantIdentitySet for Server {
.directory()
.query(QueryParams::id(account_id).with_return_member_of(false))
.await?
.map(|p| p.into_emails().collect::<AHashSet<_>>())
.map(|p| p.into_email_addresses().collect::<AHashSet<_>>())
.unwrap_or_default();
// Process creates

View file

@ -123,8 +123,7 @@ impl PrincipalGet for Server {
.map(|v| Value::Str(v.to_string().into()))
.unwrap_or(Value::Null),
PrincipalProperty::Email => principal
.emails()
.next()
.primary_email()
.map(|email| Value::Str(email.to_string().into()))
.unwrap_or(Value::Null),
PrincipalProperty::Accounts => Value::Object(Map::from(vec![(
@ -175,8 +174,7 @@ impl PrincipalGet for Server {
Key::Borrowed("calendarAddress"),
Value::Str(
principal
.emails()
.next()
.primary_email()
.map(|email| format!("mailto:{}", email))
.unwrap_or_default()
.into(),

View file

@ -189,11 +189,21 @@ impl FromLegacy for Principal {
{
principal.data.push(PrincipalData::Secret(secret));
}
for email in legacy
for (idx, email) in legacy
.take_str_array(PrincipalField::Emails)
.unwrap_or_default()
.into_iter()
.enumerate()
{
principal.data.push(PrincipalData::Email(email));
if idx == 0 {
principal
.data
.push(PrincipalData::PrimaryEmail(email.clone()));
} else {
principal
.data
.push(PrincipalData::EmailAlias(email.clone()));
}
}
if let Some(picture) = legacy.take_str(PrincipalField::Picture) {
principal.data.push(PrincipalData::Picture(picture));
@ -361,7 +371,7 @@ pub(crate) fn build_search_index(batch: &mut BatchBuilder, principal_id: u32, ne
for word in [Some(new.name.as_str()), new.description()]
.into_iter()
.chain(new.emails().map(|s| Some(s.as_str())))
.chain(new.email_addresses().map(|s| Some(s.as_str())))
.flatten()
{
new_words.extend(WordTokenizer::new(word, MAX_TOKEN_LENGTH).map(|t| t.word));

View file

@ -595,7 +595,7 @@ impl From<Principal> for TestPrincipal {
roles: value.roles().map(|v| v.to_string()).collect(),
lists: value.lists().map(|v| v.to_string()).collect(),
secrets: value.secrets().map(|v| v.to_string()).collect(),
emails: value.emails().map(|v| v.to_string()).collect(),
emails: value.email_addresses().map(|v| v.to_string()).collect(),
description: value.description().map(|v| v.to_string()),
name: value.name,
}

View file

@ -132,7 +132,7 @@ async fn oidc_directory() {
.unwrap();
assert_eq!(principal.name(), "jdoe");
assert_eq!(
principal.emails().next().map(|s| s.as_str()),
principal.email_addresses().next().map(|s| s.as_str()),
Some("john@example.org")
);
assert_eq!(principal.description(), Some("John Doe"));

View file

@ -80,7 +80,7 @@ async fn jmap_tests() {
server::webhooks::test(&mut params).await;
/*mail::get::test(&mut params).await;
mail::get::test(&mut params).await;
mail::set::test(&mut params).await;
mail::parse::test(&mut params).await;
mail::query::test(&mut params, delete).await;
@ -116,7 +116,7 @@ async fn jmap_tests() {
files::acl::test(&mut params).await;
calendar::calendars::test(&mut params).await;
calendar::event::test(&mut params).await;*/
calendar::event::test(&mut params).await;
calendar::notification::test(&mut params).await;
calendar::alarm::test(&mut params).await;