Import upstream v0.16.22, stripped

Upstream commit: 474dd0229cb20cf513036619781ed97bd8073c3f
Enterprise-only files removed or emptied: 63
Enterprise-only snippets removed: 117 in 50 files
Dangling module declarations removed: 5
Cargo edits turning enterprise off: 14
Verification: clean
Enterprise feature gates left for rebuilt features: 19 in 18 files

Produced by tools/fork/strip.py. The full report is in docs/fork/strip-reports/ on main.
This commit is contained in:
2026-09-18 10:21:56 -07:00
commit 7dae9b29fd
1650 changed files with 485521 additions and 0 deletions
+402
View File
@@ -0,0 +1,402 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::registry::{
EnterpriseRegistry,
mapping::{
RegistryGetResponse, account::account_get, bootstrap::bootstrap_get,
cluster::cluster_node_get, log::log_get, queued_message::queued_message_get,
report::report_get, spam_sample::spam_sample_get, task::task_get,
},
};
use common::{Server, auth::AccessToken, network::dkim::generate_dkim_public_key};
use jmap_proto::{
method::get::{GetRequest, GetResponse},
object::registry::Registry,
};
use jmap_tools::Key;
use registry::{
jmap::{IntoValue, JmapValue, RegistryValue},
schema::{
enums::Permission,
prelude::{
OBJ_FILTER_ACCOUNT, OBJ_FILTER_TENANT, OBJ_SINGLETON, Object, ObjectInner, ObjectType,
Property,
},
structs::Account,
},
types::id::ObjectId,
};
use store::{ahash::AHashSet, registry::RegistryQuery};
use trc::AddContext;
use types::id::Id;
use utils::map::vec_map::VecMap;
pub trait RegistryGet: Sync + Send {
fn registry_get(
&self,
object_type: ObjectType,
request: GetRequest<Registry>,
access_token: &AccessToken,
) -> impl Future<Output = trc::Result<GetResponse<Registry>>> + Send;
}
impl RegistryGet for Server {
async fn registry_get(
&self,
object_type: ObjectType,
mut request: GetRequest<Registry>,
access_token: &AccessToken,
) -> trc::Result<GetResponse<Registry>> {
// Initial assertions
if self.registry().is_bootstrap_mode() && !matches!(object_type, ObjectType::Bootstrap) {
return Err(trc::JmapEvent::Forbidden.into_err().details(concat!(
"The server is in bootstrap mode. Only the 'Bootstrap' object type ",
"can be accessed until the bootstrap process is complete.",
)));
}
self.assert_enterprise_object(object_type)?;
let object_flags = object_type.flags();
let is_tenant_filtered =
(object_flags & OBJ_FILTER_TENANT) != 0 && access_token.tenant_id().is_some();
let is_account_filtered = (object_flags & OBJ_FILTER_ACCOUNT) != 0
&& !access_token.has_permission(Permission::Impersonate);
let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?;
let has_properties = request.properties.is_some();
let mut get = RegistryGetResponse {
access_token,
server: self,
account_id: request.account_id.document_id(),
object_type,
ids,
properties: request
.properties
.take()
.map(|p| p.unwrap())
.unwrap_or_default()
.into_iter()
.filter_map(|prop| prop.try_unwrap())
.collect::<AHashSet<_>>(),
response: GetResponse {
account_id: request.account_id.into(),
state: None,
list: vec![],
not_found: not_found_ids,
},
object_flags,
is_tenant_filtered,
is_account_filtered,
};
if has_properties {
get.properties.insert(Property::Id);
}
match object_type {
ObjectType::AcmeProvider
| ObjectType::AddressBook
| ObjectType::AiModel
| ObjectType::Alert
| ObjectType::AllowedIp
| ObjectType::Application
| ObjectType::Asn
| ObjectType::Authentication
| ObjectType::BlobStore
| ObjectType::BlockedIp
| ObjectType::Cache
| ObjectType::Calendar
| ObjectType::CalendarAlarm
| ObjectType::CalendarScheduling
| ObjectType::Certificate
| ObjectType::Coordinator
| ObjectType::DataRetention
| ObjectType::DataStore
| ObjectType::Directory
| ObjectType::DkimReportSettings
| ObjectType::DmarcReportSettings
| ObjectType::DnsResolver
| ObjectType::DnsServer
| ObjectType::Email
| ObjectType::Enterprise
| ObjectType::EventTracingLevel
| ObjectType::FileStorage
| ObjectType::Http
| ObjectType::HttpForm
| ObjectType::HttpLookup
| ObjectType::Imap
| ObjectType::InMemoryStore
| ObjectType::Jmap
| ObjectType::SystemSettings
| ObjectType::MemoryLookupKey
| ObjectType::MemoryLookupKeyValue
| ObjectType::Metrics
| ObjectType::MetricsStore
| ObjectType::MtaConnectionStrategy
| ObjectType::MtaDeliverySchedule
| ObjectType::MtaExtensions
| ObjectType::MtaHook
| ObjectType::MtaInboundSession
| ObjectType::MtaInboundThrottle
| ObjectType::MtaMilter
| ObjectType::MtaOutboundStrategy
| ObjectType::MtaOutboundThrottle
| ObjectType::MtaQueueQuota
| ObjectType::MtaRoute
| ObjectType::MtaStageAuth
| ObjectType::MtaStageConnect
| ObjectType::MtaStageData
| ObjectType::MtaStageEhlo
| ObjectType::MtaStageMail
| ObjectType::MtaStageRcpt
| ObjectType::MtaSts
| ObjectType::MtaTlsStrategy
| ObjectType::MtaVirtualQueue
| ObjectType::NetworkListener
| ObjectType::ClusterRole
| ObjectType::OidcProvider
| ObjectType::ReportSettings
| ObjectType::Search
| ObjectType::SearchStore
| ObjectType::Security
| ObjectType::SenderAuth
| ObjectType::Sharing
| ObjectType::SieveSystemInterpreter
| ObjectType::SieveSystemScript
| ObjectType::SieveUserInterpreter
| ObjectType::SieveUserScript
| ObjectType::SpamClassifier
| ObjectType::SpamDnsblServer
| ObjectType::SpamDnsblSettings
| ObjectType::SpamFileExtension
| ObjectType::SpamLlm
| ObjectType::SpamPyzor
| ObjectType::SpamRule
| ObjectType::SpamSettings
| ObjectType::SpamTag
| ObjectType::SpfReportSettings
| ObjectType::StoreLookup
| ObjectType::TaskManager
| ObjectType::TlsReportSettings
| ObjectType::Tracer
| ObjectType::TracingStore
| ObjectType::WebDav
| ObjectType::WebHook
| ObjectType::Account
| ObjectType::DsnReportSettings
| ObjectType::MailingList
| ObjectType::OAuthClient
| ObjectType::Role
| ObjectType::Tenant
| ObjectType::MaskedEmail
| ObjectType::PublicKey
| ObjectType::DkimSignature
| ObjectType::Domain => {
let is_singleton = (get.object_flags & OBJ_SINGLETON) != 0;
let ids = if let Some(ids) = get.ids.take() {
ids
} else {
self.registry()
.query::<Vec<Id>>(
RegistryQuery::new(object_type)
.with_tenant(access_token.tenant_id())
.with_account_opt(is_account_filtered.then_some(get.account_id))
.with_limit(self.core.jmap.get_max_objects),
)
.await
.caused_by(trc::location!())?
};
get.response.list.reserve(ids.len());
for id in ids {
let object = if let Some(object) = self
.registry()
.get(ObjectId::new(object_type, id))
.await
.caused_by(trc::location!())?
{
if (is_tenant_filtered
&& access_token.tenant_id().map(Id::from)
!= object.inner.member_tenant_id())
|| (is_account_filtered
&& object.inner.account_id() != Some(Id::from(get.account_id)))
{
get.not_found(id);
continue;
}
object
} else if id.is_singleton() && is_singleton {
Object::from(object_type)
} else {
get.not_found(id);
continue;
};
let mut extra_properties: VecMap<Property, _> = VecMap::new();
match &object.inner {
ObjectInner::DkimSignature(obj)
if get.properties.is_empty()
|| get.properties.contains(&Property::PublicKey) =>
{
if let Ok(public_key) = generate_dkim_public_key(obj).await {
extra_properties
.append(Property::PublicKey, JmapValue::Str(public_key.into()));
}
}
ObjectInner::Account(obj) => {
if get.properties.is_empty()
|| get.properties.contains(&Property::UsedDiskQuota)
{
let quota = self.get_used_quota_account(id.document_id()).await?;
extra_properties.append(
Property::UsedDiskQuota,
JmapValue::Number(quota.into()),
);
}
if get.properties.is_empty()
|| get.properties.contains(&Property::EmailAddress)
{
let (name, domain_id) = match &obj {
Account::User(obj) => (obj.name.as_str(), obj.domain_id),
Account::Group(obj) => (obj.name.as_str(), obj.domain_id),
};
let domain = self.domain_by_id(domain_id.document_id()).await?;
let email = format!(
"{}@{}",
name,
domain.as_ref().map(|d| d.name()).unwrap_or_default()
);
extra_properties
.append(Property::EmailAddress, JmapValue::Str(email.into()));
}
}
ObjectInner::MailingList(obj)
if get.properties.is_empty()
|| get.properties.contains(&Property::EmailAddress) =>
{
let domain = self.domain_by_id(obj.domain_id.document_id()).await?;
let email = format!(
"{}@{}",
obj.name,
domain.as_ref().map(|d| d.name()).unwrap_or_default()
);
extra_properties
.append(Property::EmailAddress, JmapValue::Str(email.into()));
}
ObjectInner::Tenant(obj)
if get.properties.is_empty()
|| get.properties.contains(&Property::UsedDiskQuota) =>
{
let quota = self.get_used_quota_tenant(id.document_id()).await?;
extra_properties
.append(Property::UsedDiskQuota, JmapValue::Number(quota.into()));
}
ObjectInner::Domain(obj)
if get.properties.is_empty()
|| get.properties.contains(&Property::DnsZoneFile) =>
{
extra_properties.append(
Property::DnsZoneFile,
JmapValue::Str(self.build_bind_dns_records(id, obj).await?.into()),
);
}
ObjectInner::AcmeProvider(obj)
if get.properties.is_empty()
|| get.properties.contains(&Property::Description) =>
{
let mut description = obj.directory.clone();
let account = obj
.account_uri
.rsplit('/')
.find(|segment| !segment.is_empty())
.unwrap_or(obj.account_uri.as_str());
if !account.is_empty() {
description.push_str(" (");
description.push_str(account);
description.push(')');
}
extra_properties
.append(Property::Description, JmapValue::Str(description.into()));
}
_ => {}
}
let mut object = object.into_value();
if !extra_properties.is_empty()
&& let JmapValue::Object(obj) = &mut object
{
for (key, value) in extra_properties {
obj.insert_unchecked(key, value);
}
}
get.insert(id, object);
}
Ok(get.into_response())
}
ObjectType::QueuedMessage => {
queued_message_get(get).await.map(|get| get.into_response())
}
ObjectType::Task => task_get(get).await.map(|get| get.into_response()),
ObjectType::ClusterNode => cluster_node_get(get).await.map(|get| get.into_response()),
ObjectType::ArfExternalReport
| ObjectType::DmarcExternalReport
| ObjectType::TlsExternalReport
| ObjectType::DmarcInternalReport
| ObjectType::TlsInternalReport => report_get(get).await.map(|get| get.into_response()),
ObjectType::SpamTrainingSample => {
spam_sample_get(get).await.map(|get| get.into_response())
}
ObjectType::Log => log_get(get).await.map(|get| get.into_response()),
ObjectType::Bootstrap => bootstrap_get(get).await.map(|get| get.into_response()),
ObjectType::AccountSettings
| ObjectType::ApiKey
| ObjectType::AccountPassword
| ObjectType::AppPassword => account_get(get).await.map(|get| get.into_response()),
ObjectType::Action => Ok(get.not_found_any().into_response()),
#[cfg(not(feature = "enterprise"))]
_ => Ok(get.not_found_any().into_response()),
}
}
}
impl RegistryGetResponse<'_> {
pub fn insert(&mut self, id: Id, mut object: JmapValue<'static>) {
let object_map = object.as_object_mut().unwrap();
if self.is_tenant_filtered && self.access_token.tenant_id().is_some() {
object_map.remove(&Key::Property(Property::MemberTenantId));
} else if self.is_account_filtered {
object_map.remove(&Key::Property(Property::AccountId));
}
object_map.insert_unchecked(Property::Id, RegistryValue::Id(id));
if !self.properties.is_empty() {
object_map.as_mut_vec().retain_mut(|(prop, _)| {
prop.as_property()
.is_some_and(|prop| self.properties.contains(prop))
});
}
self.response.list.push(object);
}
pub fn not_found(&mut self, id: Id) {
self.response.push_not_found(id);
}
pub fn not_found_any(mut self) -> Self {
for id in self.ids.take().unwrap_or_default() {
self.response.push_not_found(id);
}
self
}
pub fn into_response(self) -> GetResponse<Registry> {
self.response
}
}
+974
View File
@@ -0,0 +1,974 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
api::query::QueryResponseBuilder,
registry::{
mapping::{
RegistryGetResponse, RegistryQueryResponse, RegistrySetResponse,
principal::build_set_error,
},
query::RegistryQueryFilters,
set::map_write_error,
},
};
use common::{
Server,
auth::{
AccessToken, Permissions,
credential::{ApiKey, AppPassword},
permissions::BuildPermissions,
},
cache::invalidate::CacheInvalidationBuilder,
ipc::CacheInvalidation,
storage::encryption::{EncryptionMethod, parse_public_key},
};
use directory::core::secret::{SecretVerificationResult, hash_secret, verify_mfa_secret_hash};
use jmap_proto::{error::set::SetError, request::MaybeInvalid, types::state::State};
use jmap_tools::{JsonPointer, JsonPointerItem, Key, Map, Value};
use registry::{
jmap::{IntoValue, JsonPointerPatch, MaybeUnpatched, RegistryJsonPatch, RegistryValue},
schema::{
enums::{CredentialType, StorageQuota},
prelude::{MASKED_PASSWORD, Object, ObjectInner, ObjectType, Property},
structs::{
Account, AccountPassword, AccountSettings, Credential, CredentialPermissions,
EncryptionAtRest, OtpAuth, PublicKey, SecondaryCredential,
},
},
types::{datetime::UTCDateTime, id::ObjectId},
};
use std::str::FromStr;
use store::{
registry::{
RegistryFilterOp,
write::{RegistryWrite, RegistryWriteResult},
},
write::now,
};
use trc::AddContext;
use types::id::Id;
use utils::map::vec_map::VecMap;
pub(crate) async fn account_set(
mut set: RegistrySetResponse<'_>,
) -> trc::Result<RegistrySetResponse<'_>> {
let item_id = Id::from(set.account_id);
let Some(object) = set
.server
.registry()
.get(ObjectId::new(ObjectType::Account, item_id))
.await?
else {
set.fail_all(SetError::not_found());
return Ok(set);
};
let revision = object.revision;
let old_account = if let ObjectInner::Account(Account::User(account)) = object.inner {
account
} else {
set.fail_all(SetError::not_found());
return Ok(set);
};
let mut account = old_account.clone();
match set.object_type {
ObjectType::AccountSettings => {
'outer: for (id, value) in set.update.drain(..) {
if id != Id::singleton() {
set.response.not_updated.append(id, SetError::not_found());
}
for (key, value) in value.into_expanded_object() {
if let Key::Property(
property @ (Property::EncryptionAtRest
| Property::Locale
| Property::Description
| Property::TimeZone),
) = key
{
let ptr =
JsonPointer::new(vec![JsonPointerItem::Key(Key::Property(property))]);
if let Err(err) =
account.patch(JsonPointerPatch::new(&ptr).with_create(false), value)
{
set.response.not_updated.append(id, err.into());
break 'outer;
}
} else {
set.response.not_updated.append(
id,
SetError::invalid_properties().with_property(key.into_owned()),
);
break 'outer;
}
}
if account.encryption_at_rest != old_account.encryption_at_rest
&& let Some(algorithm) =
unsupported_pgp_algorithm(set.server, &account.encryption_at_rest).await?
{
account = old_account.clone();
set.response.not_updated.append(
id,
SetError::invalid_properties()
.with_property(Property::EncryptionAtRest)
.with_description(format!(
"{algorithm} is only supported for S/MIME encryption, but the selected public key is an OpenPGP key."
)),
);
break 'outer;
}
set.response.updated.append(id, None);
}
}
ObjectType::AccountPassword => {
if let Some(old_credential) = account.credentials.values_mut().find_map(|credential| {
if let Credential::Password(pass) = credential {
Some(pass)
} else {
None
}
}) {
'outer: for (id, value) in set.update.drain(..) {
if id != Id::singleton() {
set.response.not_updated.append(id, SetError::not_found());
}
let mut account_pass = AccountPassword {
secret: None,
current_secret: None,
otp_auth: OtpAuth {
otp_code: None,
otp_url: if old_credential.otp_auth.is_some() {
Some(MASKED_PASSWORD.to_string())
} else {
None
},
},
};
for (key, value) in value.into_expanded_object() {
let ptr = match key {
Key::Property(prop) => {
JsonPointer::new(vec![JsonPointerItem::Key(Key::Property(prop))])
}
Key::Borrowed(other) => JsonPointer::parse(other),
Key::Owned(other) => JsonPointer::parse(&other),
};
match account_pass
.patch(JsonPointerPatch::new(&ptr).with_create(false), value)
{
Ok(MaybeUnpatched::Patched) => {}
Ok(MaybeUnpatched::Unpatched { .. })
| Ok(MaybeUnpatched::UnpatchedMany { .. }) => {
set.response
.not_updated
.append(id, SetError::invalid_properties());
continue 'outer;
}
Err(err) => {
set.response.not_updated.append(id, err.into());
continue 'outer;
}
}
}
let is_empty_secret = account_pass
.secret
.as_ref()
.is_none_or(|secret| secret == MASKED_PASSWORD);
let is_empty_otp = account_pass.otp_auth.otp_url.as_deref()
== Some(MASKED_PASSWORD)
|| (account_pass.otp_auth.otp_url.is_none()
&& old_credential.otp_auth.is_none());
if !is_empty_secret || !is_empty_otp {
let user_provided_secret = if !is_empty_secret {
account_pass.secret.as_ref().unwrap()
} else {
old_credential.secret.as_str()
};
if is_empty_otp {
account_pass.otp_auth.otp_url = old_credential.otp_auth.clone();
}
// Password changes are not supported when using external directories
if (user_provided_secret != old_credential.secret
|| account_pass.otp_auth.otp_url != old_credential.otp_auth)
&& set
.server
.domain_by_id(account.domain_id.document_id())
.await?
.and_then(|domain| {
set.server.get_directory_for_cached_domain(&domain)
})
.is_some()
{
set.response.not_updated.append(
id,
SetError::forbidden().with_description("Operation not allowed."),
);
continue 'outer;
}
if user_provided_secret != old_credential.secret
|| account_pass.otp_auth.otp_url != old_credential.otp_auth
{
if old_credential.secret.is_empty() {
set.response.not_updated.append(
id,
SetError::forbidden().with_description(
"Cannot set a password or OTP auth on an account that doesn't have one.",
),
);
continue 'outer;
}
let current_otp_code = account_pass.otp_auth.otp_code;
if let Some(current_secret) = account_pass.current_secret {
match verify_mfa_secret_hash(
old_credential.otp_auth.as_deref(),
current_otp_code.as_deref(),
&old_credential.secret,
current_secret.as_ref(),
)
.await?
{
SecretVerificationResult::Valid => {}
SecretVerificationResult::Invalid => {
let account = set.server.account(set.account_id).await?;
if set.server.has_auth_fail2ban()
&& set
.server
.is_auth_fail2banned(
set.remote_ip,
account.name().into(),
)
.await?
{
return Err(trc::SecurityEvent::AuthenticationBan
.into_err()
.details(
"Too many failed password change attempts.",
)
.ctx(trc::Key::RemoteIp, set.remote_ip)
.ctx(
trc::Key::AccountName,
account.name().to_string(),
));
} else {
set.response.not_updated.append(
id,
SetError::forbidden().with_description(
"Current secret is incorrect.",
),
);
continue 'outer;
}
}
SecretVerificationResult::MissingMfaToken => {
set.response.not_updated.append(
id,
SetError::forbidden().with_description(
"Current OTP code is required to change the password or OTP auth.",
),
);
continue 'outer;
}
}
if user_provided_secret != old_credential.secret {
if let Err(err) =
set.server.is_secure_password(user_provided_secret, &[])
{
set.response.not_updated.append(
id,
SetError::invalid_properties()
.with_property(Property::Secret)
.with_description(err),
);
continue 'outer;
}
if let Some(expires_at) =
set.server.core.network.security.password_default_expiration
{
old_credential.expires_at =
Some(UTCDateTime::from_timestamp(
(now() + expires_at) as i64,
));
} else if old_credential
.expires_at
.is_some_and(|exp| exp.timestamp() <= now() as i64)
{
old_credential.expires_at = None;
}
old_credential.secret = hash_secret(
set.server.core.network.security.password_hash_algorithm,
user_provided_secret.as_bytes().to_vec(),
)
.await
.caused_by(trc::location!())?;
}
if account_pass.otp_auth.otp_url != old_credential.otp_auth {
old_credential.otp_auth = account_pass.otp_auth.otp_url;
}
} else {
set.response.not_updated.append(
id,
SetError::forbidden().with_description(
"Current secret must be provided to change the password or OTP auth.",
),
);
continue 'outer;
}
}
}
set.response.updated.append(id, None);
break;
}
} else {
set.fail_all(
SetError::forbidden()
.with_description("Your account does not support password changes"),
);
}
}
ObjectType::AppPassword | ObjectType::ApiKey => {
// Process creations
if !set.create.is_empty() {
let account_cache = set.server.account(set.account_id).await?;
let app_pass_quota = set
.server
.object_quota(account_cache.object_quotas(), StorageQuota::MaxAppPasswords);
let api_key_quota = set
.server
.object_quota(account_cache.object_quotas(), StorageQuota::MaxApiKeys);
let mut last_credential_id = 0;
let mut app_pass_total = 0;
let mut api_key_total = 0;
for credential in account.credentials.values() {
match credential {
Credential::Password(c) => {
let credential_id = c.credential_id.id();
if credential_id > last_credential_id {
last_credential_id = credential_id;
}
}
Credential::AppPassword(c) => {
let credential_id = c.credential_id.id();
if credential_id > last_credential_id {
last_credential_id = credential_id;
}
app_pass_total += 1;
}
Credential::ApiKey(c) => {
let credential_id = c.credential_id.id();
if credential_id > last_credential_id {
last_credential_id = credential_id;
}
api_key_total += 1;
}
}
}
'outer: for (id, value) in set.create.drain() {
let mut credential = SecondaryCredential::default();
// Patch object
match credential.patch(
JsonPointerPatch::new(&JsonPointer::new(vec![])).with_create(true),
value,
) {
Ok(MaybeUnpatched::Patched) => {}
Ok(
MaybeUnpatched::Unpatched { .. } | MaybeUnpatched::UnpatchedMany { .. },
) => {
set.response.not_created.append(
id,
SetError::invalid_properties()
.with_description("Cannot set property during creation."),
);
continue 'outer;
}
Err(err) => {
set.response.not_created.append(id, err.into());
continue 'outer;
}
}
// Validate credential
match set.object_type {
ObjectType::AppPassword => {
if app_pass_total >= app_pass_quota {
set.response.not_created.append(
id,
SetError::over_quota().with_description(format!(
"You have exceeded your quota of {} app passwords.",
app_pass_quota
)),
);
continue 'outer;
}
if let Err(err) =
validate_credential_permissions(set.access_token, &credential)
{
set.response.not_created.append(id, err);
continue 'outer;
}
// Assign id
last_credential_id += 1;
app_pass_total += 1;
credential.credential_id = last_credential_id.into();
// Generate App password and hash secret
let app_pass = AppPassword::new(last_credential_id as u32);
credential.secret = hash_secret(
set.server.core.network.security.password_hash_algorithm,
app_pass.secret.to_vec(),
)
.await
.caused_by(trc::location!())?;
// Add credential to account
account
.credentials
.push(Credential::AppPassword(credential));
set.response.created.insert(
id,
Value::Object(Map::from(vec![
(
Key::Property(Property::Id),
Value::Element(RegistryValue::Id(
last_credential_id.into(),
)),
),
(
Key::Property(Property::Secret),
Value::Str(app_pass.build().into()),
),
])),
);
}
ObjectType::ApiKey => {
if api_key_total >= api_key_quota {
set.response.not_created.append(
id,
SetError::over_quota().with_description(format!(
"You have exceeded your quota of {} API keys.",
api_key_quota
)),
);
continue 'outer;
}
if let Err(err) =
validate_credential_permissions(set.access_token, &credential)
{
set.response.not_created.append(id, err);
continue 'outer;
}
// Assign id
last_credential_id += 1;
api_key_total += 1;
credential.credential_id = last_credential_id.into();
// Generate API key and hash secret
let api_key = ApiKey::new(set.account_id, last_credential_id as u32);
credential.secret = hash_secret(
set.server.core.network.security.password_hash_algorithm,
api_key.secret.to_vec(),
)
.await
.caused_by(trc::location!())?;
// Add credential to account
account.credentials.push(Credential::ApiKey(credential));
set.response.created.insert(
id,
Value::Object(Map::from(vec![
(
Key::Property(Property::Id),
Value::Element(RegistryValue::Id(
last_credential_id.into(),
)),
),
(
Key::Property(Property::Secret),
Value::Str(api_key.build().into()),
),
])),
);
}
_ => unreachable!(),
}
}
}
// Process updates
'outer: for (id, value) in set.update.drain(..) {
if let Some(mut old_credential) = account
.credentials
.values_mut()
.find(|credential| credential.credential_id() == id)
{
let mut credential = old_credential.clone();
let mut unpatched_properties = VecMap::new();
for (key, value) in value.into_expanded_object() {
let ptr = match key {
Key::Property(prop) => {
JsonPointer::new(vec![JsonPointerItem::Key(Key::Property(prop))])
}
Key::Borrowed(other) => JsonPointer::parse(other),
Key::Owned(other) => JsonPointer::parse(&other),
};
match credential
.patch(JsonPointerPatch::new(&ptr).with_create(false), value)
{
Ok(MaybeUnpatched::Patched) => {}
Ok(MaybeUnpatched::Unpatched { property, value }) => {
unpatched_properties.append(property, value);
}
Ok(MaybeUnpatched::UnpatchedMany { properties }) => {
if unpatched_properties.is_empty() {
unpatched_properties = properties;
} else {
unpatched_properties.extend(properties);
}
}
Err(err) => {
set.response.not_updated.append(id, err.into());
continue 'outer;
}
}
}
if &credential == old_credential {
set.response.updated.append(id, None);
continue 'outer;
}
match (&mut credential, &mut old_credential) {
(
Credential::AppPassword(credential),
Credential::AppPassword(old_credential),
)
| (Credential::ApiKey(credential), Credential::ApiKey(old_credential))
if credential.secret != old_credential.secret =>
{
// Paranoid check, this is verified in the patch implementation
set.response.not_updated.append(
id,
SetError::forbidden().with_description(
"Cannot change the value of an app password or API key.",
),
);
continue 'outer;
}
_ => {}
}
if let Credential::AppPassword(new_sc) | Credential::ApiKey(new_sc) =
&credential
&& let Credential::AppPassword(old_sc) | Credential::ApiKey(old_sc) =
&*old_credential
&& old_sc.permissions != new_sc.permissions
&& let Err(err) = validate_credential_permissions(set.access_token, new_sc)
{
set.response.not_updated.append(id, err);
continue 'outer;
}
*old_credential = credential;
set.response.updated.append(id, None);
} else {
set.response.not_updated.append(id, SetError::not_found());
}
}
// Process deletions
for id in set.destroy.drain(..) {
if let Some(idx) = account
.credentials
.0
.inner
.iter_mut()
.position(|c| c.value.credential_id() == id)
{
let credentials = &mut account.credentials.inner_mut().inner;
if !matches!(credentials[idx].value, Credential::Password(_)) {
credentials.remove(idx);
set.response.destroyed.push(id);
} else {
set.response.not_destroyed.append(
id,
SetError::forbidden().with_description(
"Users are not allowed to destroy their own credentials.",
),
);
}
} else {
set.response.not_destroyed.append(id, SetError::not_found());
}
}
}
_ => unreachable!(),
}
if account != old_account {
let mut cache_invalidator = CacheInvalidationBuilder::default();
if account.encryption_at_rest != old_account.encryption_at_rest
|| account.description != old_account.description
|| account.locale != old_account.locale
{
cache_invalidator.invalidate(CacheInvalidation::Account(set.account_id));
}
if account.credentials != old_account.credentials {
cache_invalidator.invalidate(CacheInvalidation::AccessToken(set.account_id));
}
let object = Object::new(ObjectInner::Account(Account::User(account)));
let old_object = Object::with_revision(
ObjectInner::Account(Account::User(old_account.clone())),
revision,
);
match set
.server
.registry()
.write(RegistryWrite::Update {
object: &object,
id: item_id,
old_object: &old_object,
})
.await?
{
RegistryWriteResult::Success(_) => {
// Invalidate caches
set.server.invalidate_caches(cache_invalidator).await?;
}
err => {
let err = map_write_error(err);
let failed_create = set
.response
.created
.into_keys()
.map(|id| (id, err.clone()))
.collect::<Vec<_>>();
let failed_update = set
.response
.updated
.into_keys()
.map(|id| (MaybeInvalid::Value(id), err.clone()))
.collect::<Vec<_>>();
let failed_delete = set
.response
.destroyed
.into_iter()
.map(|id| (MaybeInvalid::Value(id), err.clone()))
.collect::<Vec<_>>();
set.response.not_created.extend(failed_create);
set.response.not_updated.extend(failed_update);
set.response.not_destroyed.extend(failed_delete);
set.response.created = Default::default();
set.response.updated = Default::default();
set.response.destroyed = Default::default();
}
}
}
Ok(set)
}
pub(crate) async fn account_get(
mut get: RegistryGetResponse<'_>,
) -> trc::Result<RegistryGetResponse<'_>> {
let Some(Account::User(account)) = get
.server
.registry()
.object::<Account>(get.account_id.into())
.await?
else {
return Ok(get.not_found_any());
};
match get.object_type {
ObjectType::AccountSettings => {
let mut ids = get
.ids
.take()
.unwrap_or_else(|| vec![Id::singleton()])
.into_iter();
for id in ids.by_ref() {
if id == Id::singleton() {
get.insert(
id,
AccountSettings {
encryption_at_rest: account.encryption_at_rest,
locale: account.locale,
description: account.description,
time_zone: account.time_zone,
}
.into_value(),
);
break;
} else {
get.not_found(id);
}
}
get.response.not_found.extend(ids.map(MaybeInvalid::Value));
}
ObjectType::AccountPassword => {
let mut ids = get
.ids
.take()
.unwrap_or_else(|| vec![Id::singleton()])
.into_iter();
for id in ids.by_ref() {
if id == Id::singleton()
&& let Some(pass) = account.credentials.iter().find_map(|pass| {
if let Credential::Password(pass) = pass {
Some(pass)
} else {
None
}
})
{
get.insert(
id,
AccountPassword {
current_secret: None,
otp_auth: OtpAuth {
otp_code: None,
otp_url: if pass.otp_auth.is_some() {
MASKED_PASSWORD.to_string().into()
} else {
None
},
},
secret: MASKED_PASSWORD.to_string().into(),
}
.into_value(),
);
break;
} else {
get.not_found(id);
}
}
get.response.not_found.extend(ids.map(MaybeInvalid::Value));
}
ObjectType::ApiKey | ObjectType::AppPassword => {
let mut ids = if let Some(ids) = get.ids.take() {
ids
} else {
account
.credentials
.values()
.map(|credential| credential.credential_id())
.collect::<Vec<_>>()
};
for credential in account.credentials {
match (credential, get.object_type) {
(Credential::AppPassword(pass), ObjectType::AppPassword)
| (Credential::ApiKey(pass), ObjectType::ApiKey)
if ids.contains(&pass.credential_id) =>
{
let id = pass.credential_id;
let mut credential = pass.into_value();
credential
.as_object_mut()
.unwrap()
.as_mut_vec()
.retain(|(k, _)| !matches!(k, Key::Property(Property::CredentialId)));
get.insert(id, credential);
ids.retain(|i| i != &id);
}
_ => {}
}
}
for id in ids {
get.not_found(id);
}
}
_ => unreachable!(),
}
Ok(get)
}
pub(crate) async fn credential_query(
mut query: RegistryQueryResponse<'_>,
) -> trc::Result<QueryResponseBuilder> {
let Some(Account::User(account)) = query
.server
.registry()
.object::<Account>(query.request.account_id)
.await?
else {
return Err(trc::JmapEvent::Forbidden
.into_err()
.details("Account not found."));
};
let credential_type = match query.object_type {
ObjectType::AppPassword => CredentialType::AppPassword,
ObjectType::ApiKey => CredentialType::ApiKey,
_ => unreachable!(),
};
let mut expires_at_filter = None;
query
.request
.extract_filters(|property, op, value| match property {
Property::ExpiresAt => {
if let Some(value) = value
.as_str()
.and_then(|value| UTCDateTime::from_str(value).ok())
{
expires_at_filter = Some((op, value));
true
} else {
false
}
}
_ => false,
})?;
let mut matches = Vec::new();
for credential in account.credentials.iter() {
if credential.object_type() == credential_type {
let (credential_id, expires_at) = match credential {
Credential::AppPassword(credential) => {
(credential.credential_id, credential.expires_at)
}
Credential::ApiKey(credential) => (credential.credential_id, credential.expires_at),
_ => unreachable!(),
};
if expires_at_filter.is_none_or(|(op, filter_value)| {
expires_at.is_some_and(|expires_at| match op {
RegistryFilterOp::Equal => expires_at == filter_value,
RegistryFilterOp::GreaterThan => expires_at > filter_value,
RegistryFilterOp::GreaterEqualThan => expires_at >= filter_value,
RegistryFilterOp::LowerThan => expires_at < filter_value,
RegistryFilterOp::LowerEqualThan => expires_at <= filter_value,
RegistryFilterOp::TextMatch => false,
})
}) {
matches.push((credential_id, expires_at));
}
}
}
let params = query
.request
.extract_parameters(query.server.core.jmap.query_max_results, None)?;
match params.sort_by {
Property::ExpiresAt => {
if params.sort_ascending {
matches.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
} else {
matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
}
}
Property::Id => {
if params.sort_ascending {
matches.sort_by_key(|a| a.0);
} else {
matches.sort_by_key(|b| std::cmp::Reverse(b.0));
}
}
property => {
return Err(trc::JmapEvent::UnsupportedSort.into_err().details(format!(
"Property {} is not supported for sorting",
property
)));
}
}
// Build response
let mut response = QueryResponseBuilder::new(
matches.len(),
query.server.core.jmap.query_max_results,
State::Initial,
&query.request,
);
for (id, _) in matches {
if !response.add_id(id) {
break;
}
}
Ok(response)
}
async fn unsupported_pgp_algorithm(
server: &Server,
encryption_at_rest: &EncryptionAtRest,
) -> trc::Result<Option<&'static str>> {
let (settings, algorithm) = match encryption_at_rest {
EncryptionAtRest::Aes256Gcm(settings) => (settings, "AES-256-GCM"),
EncryptionAtRest::ChaCha20Poly1305(settings) => (settings, "ChaCha20-Poly1305"),
_ => return Ok(None),
};
if let Some(public_key) = server
.registry()
.object::<PublicKey>(settings.public_key)
.await
.caused_by(trc::location!())?
&& matches!(
parse_public_key(&public_key),
Ok(Some(params)) if params.method == EncryptionMethod::PGP
)
{
Ok(Some(algorithm))
} else {
Ok(None)
}
}
pub(crate) fn validate_credential_permissions(
access_token: &AccessToken,
credential: &SecondaryCredential,
) -> Result<(), SetError<Property>> {
let effective = match &credential.permissions {
CredentialPermissions::Inherit => access_token.account_permissions().clone(),
CredentialPermissions::Disable(list) => {
let mut effective = access_token.account_permissions().clone();
effective.clear_many(&Permissions::from_permission(list.permissions.as_slice()));
effective
}
CredentialPermissions::Replace(list) => {
Permissions::from_permission(list.permissions.as_slice())
}
};
access_token
.can_grant_permissions(effective)
.map_err(build_set_error)
}
+575
View File
@@ -0,0 +1,575 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::registry::mapping::{RegistrySetResponse, map_bootstrap_error};
use common::{
Server,
config::mailstore::spamfilter::SpamFilterAction,
ipc::{BroadcastEvent, QueueEvent, RegistryChange},
};
use jmap_proto::error::set::{SetError, SetErrorType};
use jmap_tools::{JsonPointer, Key};
use mail_auth::{
AuthenticatedMessage, Dkim2Result, DkimResult, DmarcResult, dkim2::Envelope as Dkim2Envelope,
dmarc::verify::DmarcParameters, spf::verify::SpfParameters,
};
use mail_parser::MessageParser;
use registry::{
jmap::{IntoValue, JsonPointerPatch, RegistryJsonPatch},
schema::{
enums::{SpamClassifyParameters, SpamClassifyResult, SpamClassifyTagDisposition},
prelude::{ObjectType, Property},
structs::{Action, DmarcTroubleshoot, SpamClassify, SpamClassifyTag},
},
types::{EnumImpl, ObjectImpl},
};
use smtp_proto::{MAIL_BODY_7BIT, MAIL_BODY_8BITMIME, MAIL_BODY_BINARYMIME, MAIL_SMTPUTF8};
use spam_filter::{
SpamFilterInput,
analysis::{init::SpamFilterInit, score::SpamFilterAnalyzeScore},
};
use std::time::Instant;
use store::{registry::bootstrap::Bootstrap, write::now};
use utils::map::vec_map::VecMap;
pub(crate) async fn action_set(
mut set: RegistrySetResponse<'_>,
) -> trc::Result<RegistrySetResponse<'_>> {
// Actions cannot be uodated or destroyed, so we fail all updates and destroys.
set.fail_all_update("Actions cannot be updated");
set.fail_all_destroy("Actions cannot be destroyed");
// Process creations
'outer: for (id, value) in set.create.drain() {
let mut action = Action::default();
if let Err(err) = action.patch(
JsonPointerPatch::new(&JsonPointer::new(vec![])).with_create(true),
value,
) {
set.response.not_created.append(id, err.into());
continue 'outer;
}
let mut validation_errors = Vec::new();
if !action.validate(&mut validation_errors) {
set.response.not_created.append(
id,
SetError::new(SetErrorType::ValidationFailed)
.with_validation_errors(validation_errors),
);
continue 'outer;
}
if !set.access_token.has_permission(action.permission()) {
set.response.not_created.append(
id,
SetError::forbidden().with_description(format!(
"Insufficient permissions to perform action of type {}",
action.object_type().as_str()
)),
);
continue 'outer;
}
match action {
Action::ReloadSettings
| Action::ReloadTlsCertificates
| Action::ReloadLookupStores
| Action::ReloadBlockedIps => {
let object = match action {
Action::ReloadSettings => ObjectType::DataStore,
Action::ReloadTlsCertificates => ObjectType::Certificate,
Action::ReloadLookupStores => ObjectType::StoreLookup,
Action::ReloadBlockedIps => ObjectType::BlockedIp,
_ => unreachable!(),
};
let result =
Box::pin(set.server.reload_registry(RegistryChange::Reload(object))).await?;
if !result.has_errors() {
set.server
.cluster_broadcast(BroadcastEvent::RegistryChange(RegistryChange::Reload(
object,
)))
.await;
set.response.created(id, now());
} else {
set.response
.not_created
.append(id, map_bootstrap_error(result.errors));
}
}
Action::InvalidateCaches => {
set.server.invalidate_all_local_caches();
set.server
.cluster_broadcast(BroadcastEvent::CacheInvalidateAll)
.await;
set.response.created(id, now());
}
Action::InvalidateNegativeCaches => {
set.server.invalidate_all_local_negative_caches();
set.server
.cluster_broadcast(BroadcastEvent::CacheInvalidateNegative)
.await;
set.response.created(id, now());
}
Action::PauseMtaQueue => {
let _ = set
.server
.inner
.ipc
.queue_tx
.send(QueueEvent::Paused(true))
.await;
set.server
.cluster_broadcast(BroadcastEvent::MtaQueueStatus { is_running: false })
.await;
set.response.created(id, now());
}
Action::ResumeMtaQueue => {
let _ = set
.server
.inner
.ipc
.queue_tx
.send(QueueEvent::Paused(false))
.await;
set.server
.cluster_broadcast(BroadcastEvent::MtaQueueStatus { is_running: true })
.await;
set.response.created(id, now());
}
Action::TroubleshootDmarc(troubleshoot) => {
if let Some(result) = dmarc_troubleshoot(set.server, troubleshoot).await {
let mut result = result.into_value();
result
.as_object_mut()
.unwrap()
.as_mut_vec()
.retain(|(k, _)| {
!matches!(
k,
Key::Property(
Property::Message
| Property::RemoteIp
| Property::EhloDomain
| Property::MailFrom
| Property::To
)
)
});
set.response.created.insert(id, result);
} else {
set.response.not_created.append(
id,
SetError::invalid_properties()
.with_property(Property::Body)
.with_description(
"Failed to parse the message for DMARC troubleshooting".to_string(),
),
);
}
}
Action::ClassifySpam(classify) => {
if let Some(result) = classify_spam(set.server, classify).await {
let mut result = result.into_value();
result
.as_object_mut()
.unwrap()
.as_mut_vec()
.retain(|(k, _)| {
!matches!(
k,
Key::Property(
Property::Message
| Property::RemoteIp
| Property::EhloDomain
| Property::AuthenticatedAs
| Property::IsTls
| Property::EnvFrom
| Property::EnvFromParameters
| Property::EnvRcptTo
)
)
});
set.response.created.insert(id, result);
} else {
set.response.not_created.append(
id,
SetError::invalid_properties()
.with_property(Property::Message)
.with_description(
"Failed to parse the message for spam classification".to_string(),
),
);
}
}
Action::UpdateApps => {
let mut bp = Bootstrap::new_uninitialized(set.server.registry().clone());
set.server.inner.data.applications.reload(&mut bp).await;
if bp.errors.is_empty() {
set.server
.inner
.data
.applications
.unpack_all(set.server, true)
.await;
set.server
.cluster_broadcast(BroadcastEvent::RegistryChange(RegistryChange::Reload(
ObjectType::Application,
)))
.await;
set.response.created(id, now());
} else {
set.response
.not_created
.append(id, map_bootstrap_error(bp.errors));
}
}
}
}
Ok(set)
}
async fn classify_spam(server: &Server, mut request: SpamClassify) -> Option<SpamClassify> {
// Built spam filter input
let raw_message = request.message.as_bytes();
let message = MessageParser::new()
.parse(raw_message)
.filter(|m| m.root_part().headers().iter().any(|h| !h.name.is_other()))?;
let remote_ip = request.remote_ip.into_inner();
let ehlo_domain = request.ehlo_domain.to_lowercase();
let mail_from = request.env_from.to_lowercase();
let mail_from_domain = mail_from.rsplit_once('@').map(|(_, domain)| domain);
let local_host = &server.core.network.server_name;
let spf_ehlo_result = server
.core
.smtp
.resolvers
.dns
.verify_spf(
server
.inner
.cache
.build_auth_parameters(SpfParameters::verify_ehlo(
remote_ip,
&ehlo_domain,
local_host,
)),
)
.await;
let iprev_result = server
.core
.smtp
.resolvers
.dns
.verify_iprev(server.inner.cache.build_auth_parameters(remote_ip))
.await;
let spf_mail_from_result = if let Some(mail_from_domain) = mail_from_domain {
server
.core
.smtp
.resolvers
.dns
.check_host(server.inner.cache.build_auth_parameters(SpfParameters::new(
remote_ip,
mail_from_domain,
&ehlo_domain,
local_host,
&mail_from,
)))
.await
} else {
server
.core
.smtp
.resolvers
.dns
.check_host(server.inner.cache.build_auth_parameters(SpfParameters::new(
remote_ip,
&ehlo_domain,
&ehlo_domain,
local_host,
&format!("postmaster@{ehlo_domain}"),
)))
.await
};
let auth_message = AuthenticatedMessage::from_parsed(&message, raw_message, true);
let dkim_output = server
.core
.smtp
.resolvers
.dns
.verify_dkim(server.inner.cache.build_auth_parameters(&auth_message))
.await;
let arc_output = server
.core
.smtp
.resolvers
.dns
.verify_arc(server.inner.cache.build_auth_parameters(&auth_message))
.await;
let dkim2_output = server
.core
.smtp
.resolvers
.dns
.verify_dkim2(
server.inner.cache.build_auth_parameters(&auth_message),
Dkim2Envelope {
mail_from: &mail_from,
rcpt_to: request.env_rcpt_to.iter(),
},
)
.await;
let dmarc_output = server
.core
.smtp
.resolvers
.dns
.verify_dmarc(server.inner.cache.build_auth_parameters(DmarcParameters {
message: &auth_message,
dkim_output: &dkim_output,
dkim2_output: Some(&dkim2_output),
rfc5321_mail_from_domain: mail_from_domain.unwrap_or(ehlo_domain.as_str()),
spf_output: &spf_mail_from_result,
}))
.await;
let dmarc_result = dmarc_output.result();
let dmarc_policy = dmarc_output.policy();
let asn_geo = server.lookup_asn_country(remote_ip).await;
let input = SpamFilterInput {
message: &message,
span_id: 0,
arc_result: Some(&arc_output),
spf_ehlo_result: Some(&spf_ehlo_result),
spf_mail_from_result: Some(&spf_mail_from_result),
dkim_result: dkim_output.as_slice(),
dkim2_result: Some(&dkim2_output),
dmarc_result: Some(&dmarc_result),
dmarc_policy: Some(&dmarc_policy),
iprev_result: Some(&iprev_result),
remote_ip,
ehlo_domain: Some(ehlo_domain.as_str()),
authenticated_as: request.authenticated_as.as_deref(),
asn: asn_geo.asn.as_ref().map(|a| a.id),
country: asn_geo.country.as_ref().map(|c| c.as_str()),
is_tls: request.is_tls,
env_from: &request.env_from,
env_from_flags: match request.env_from_parameters {
Some(SpamClassifyParameters::Bit7) => MAIL_BODY_7BIT,
Some(SpamClassifyParameters::Bit8Mime8BitMIMEMessageContent) => MAIL_BODY_BINARYMIME,
Some(SpamClassifyParameters::BinaryMime) => MAIL_BODY_8BITMIME,
Some(SpamClassifyParameters::SmtpUtf8) => MAIL_SMTPUTF8,
None => 0,
},
env_rcpt_orig_to: request.env_rcpt_to.iter().map(String::as_str).collect(),
env_rcpt_rewritten_to: request.env_rcpt_to.iter().map(String::as_str).collect(),
is_test: true,
is_train: false,
};
// Classify
let mut ctx = server.spam_filter_init(input);
let result = server.spam_filter_classify(&mut ctx).await;
// Build response
request.result = match result {
SpamFilterAction::Allow(result) => {
request.score = (result.score as f64).into();
if result.is_spam {
SpamClassifyResult::Spam
} else {
SpamClassifyResult::Ham
}
}
SpamFilterAction::Discard => SpamClassifyResult::Discard,
SpamFilterAction::Reject | SpamFilterAction::Disabled => SpamClassifyResult::Reject,
};
request.tags = VecMap::with_capacity(ctx.result.tags.len());
for tag in ctx.result.tags {
let (score, disposition) = match server.core.spam.lists.scores.get(&tag) {
Some(SpamFilterAction::Allow(score)) => (*score, SpamClassifyTagDisposition::Score),
Some(SpamFilterAction::Discard) => (0.0, SpamClassifyTagDisposition::Discard),
_ => (0.0, SpamClassifyTagDisposition::Reject),
};
request.tags.append(
tag,
SpamClassifyTag {
disposition,
score: (score as f64).into(),
},
);
}
Some(request)
}
async fn dmarc_troubleshoot(
server: &Server,
mut request: DmarcTroubleshoot,
) -> Option<DmarcTroubleshoot> {
let remote_ip = request.remote_ip.into_inner();
let ehlo_domain = request.ehlo_domain.to_lowercase();
let mail_from = request.mail_from.to_lowercase();
let mail_from_domain = mail_from.rsplit_once('@').map(|(_, domain)| domain);
let local_host = &server.core.network.server_name;
let now = Instant::now();
let ehlo_spf_output = server
.core
.smtp
.resolvers
.dns
.verify_spf(
server
.inner
.cache
.build_auth_parameters(SpfParameters::verify_ehlo(
remote_ip,
&ehlo_domain,
local_host,
)),
)
.await;
let iprev = server
.core
.smtp
.resolvers
.dns
.verify_iprev(server.inner.cache.build_auth_parameters(remote_ip))
.await;
let mail_spf_output = if let Some(mail_from_domain) = mail_from_domain {
server
.core
.smtp
.resolvers
.dns
.check_host(server.inner.cache.build_auth_parameters(SpfParameters::new(
remote_ip,
mail_from_domain,
&ehlo_domain,
local_host,
&mail_from,
)))
.await
} else {
server
.core
.smtp
.resolvers
.dns
.check_host(server.inner.cache.build_auth_parameters(SpfParameters::new(
remote_ip,
&ehlo_domain,
&ehlo_domain,
local_host,
&format!("postmaster@{ehlo_domain}"),
)))
.await
};
let body = request
.message
.take()
.unwrap_or_else(|| format!("From: {mail_from}\r\nSubject: test\r\n\r\ntest"));
let auth_message = AuthenticatedMessage::parse_with_opts(body.as_bytes(), None, true)?;
let dkim_output = server
.core
.smtp
.resolvers
.dns
.verify_dkim(server.inner.cache.build_auth_parameters(&auth_message))
.await;
let dkim_pass = dkim_output
.iter()
.any(|d| matches!(d.result(), DkimResult::Pass));
let dkim2_output = server
.core
.smtp
.resolvers
.dns
.verify_dkim2(
server.inner.cache.build_auth_parameters(&auth_message),
Dkim2Envelope {
mail_from: &mail_from,
rcpt_to: request.to.iter(),
},
)
.await;
let dkim2_pass = matches!(dkim2_output.result(), Dkim2Result::Pass);
let arc_output = server
.core
.smtp
.resolvers
.dns
.verify_arc(server.inner.cache.build_auth_parameters(&auth_message))
.await;
let dmarc_output = server
.core
.smtp
.resolvers
.dns
.verify_dmarc(server.inner.cache.build_auth_parameters(DmarcParameters {
message: &auth_message,
dkim_output: &dkim_output,
dkim2_output: Some(&dkim2_output),
rfc5321_mail_from_domain: mail_from_domain.unwrap_or(ehlo_domain.as_str()),
spf_output: &mail_spf_output,
}))
.await;
let dmarc_result = dmarc_output.result();
let dmarc_pass = dmarc_result == DmarcResult::Pass;
request.spf_ehlo_domain = ehlo_spf_output.domain().to_string();
request.spf_ehlo_result = (&ehlo_spf_output).into();
request.spf_mail_from_domain = mail_spf_output.domain().to_string();
request.spf_mail_from_result = (&mail_spf_output).into();
request.ip_rev_ptr = iprev
.ptr
.as_ref()
.map(|ptr| {
ptr.iter()
.map(|label| label.to_string())
.collect::<Vec<_>>()
})
.unwrap_or_default()
.into();
request.ip_rev_result = (&iprev).into();
request.dkim_pass = dkim_pass;
request.dkim2_result = dkim2_output.result().into();
request.dkim2_pass = dkim2_pass;
request.dkim_results = dkim_output
.iter()
.map(|result| result.result().into())
.collect();
request.arc_result = arc_output.result().into();
request.dmarc_result = (&dmarc_result).into();
request.dmarc_policy = (&dmarc_output.policy()).into();
request.dmarc_pass = dmarc_pass;
request.elapsed = now.elapsed().into();
Some(request)
}
@@ -0,0 +1,691 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::registry::{
mapping::{RegistryGetResponse, RegistrySetResponse, map_bootstrap_error},
set::map_write_error,
};
use common::{
DATABASE_SCHEMA_VERSION, Server, config::storage::Storage,
network::acme::account::acme_create_account, psl,
};
use directory::core::secret::hash_secret;
use jmap_proto::{
error::set::{SetError, SetErrorType},
request::MaybeInvalid,
};
use jmap_tools::{JsonPointer, JsonPointerItem, Key};
use rand::{RngExt, distr::Alphanumeric, rng};
use registry::{
jmap::{IntoValue, JmapValue, JsonPointerPatch, RegistryJsonPatch},
schema::{
enums::{AcmeChallengeType, DnsRecordType},
prelude::{Object, Property},
structs::{
Account, AcmeProvider, BlobStore, Bootstrap, CertificateManagement,
CertificateManagementProperties, Credential, DataStore, Directory, DirectoryBootstrap,
DkimManagement, DkimManagementProperties, DnsManagement, DnsManagementProperties,
DnsServer, DnsServerBootstrap, Domain, InMemoryStore, PasswordCredential, RocksDbStore,
SearchStore, SystemSettings, Task, TaskDnsManagement, TaskDomainManagement, TaskStatus,
Tracer, TracerLog, UserAccount, UserRoles,
},
},
types::{ObjectImpl, list::List, map::Map},
};
use std::time::Duration;
use store::{
RegistryStore, SUBSPACE_PROPERTY, Store,
registry::write::{RegistryWrite, RegistryWriteResult},
write::{AnyKey, BatchBuilder},
};
use types::id::Id;
use utils::{DomainPart, is_valid_domain};
pub(crate) async fn bootstrap_get(
mut get: RegistryGetResponse<'_>,
) -> trc::Result<RegistryGetResponse<'_>> {
if !get.server.registry().is_bootstrap_mode() {
get.not_found(Id::singleton());
return Ok(get);
}
let mut ids = get
.ids
.take()
.unwrap_or_else(|| vec![Id::singleton()])
.into_iter();
for id in ids.by_ref() {
if id == Id::singleton() {
get.insert(
Id::singleton(),
build_default_bootstrap(get.server).into_value(),
);
break;
} else {
get.not_found(id);
}
}
get.response.not_found.extend(ids.map(MaybeInvalid::Value));
Ok(get)
}
pub(crate) async fn bootstrap_set(
mut set: RegistrySetResponse<'_>,
) -> trc::Result<RegistrySetResponse<'_>> {
if !set.server.registry().is_bootstrap_mode() {
set.fail_all_create("This operation is only allowed bootstrap mode");
set.fail_all_update("This operation is only allowed bootstrap mode");
set.fail_all_destroy("This operation is only allowed bootstrap mode");
return Ok(set);
}
set.fail_all_create("Bootstrap objects can only be updated");
set.fail_all_destroy("Bootstrap objects cannot be deleted");
let mut bootstrap = build_default_bootstrap(set.server);
'outer: for (id, value) in set.update.drain(..) {
if id != Id::singleton() {
set.response.not_updated.append(id, SetError::not_found());
continue;
}
for (key, value) in value.into_expanded_object() {
if let Key::Property(property) = key {
let ptr = JsonPointer::new(vec![JsonPointerItem::Key(Key::Property(property))]);
if let Err(err) =
bootstrap.patch(JsonPointerPatch::new(&ptr).with_create(false), value)
{
set.response.not_updated.append(id, err.into());
break 'outer;
}
} else {
set.response.not_updated.append(
id,
SetError::invalid_properties().with_property(key.into_owned()),
);
break 'outer;
}
}
let mut validation_errors = Vec::new();
if !bootstrap.validate(&mut validation_errors) {
set.response.not_updated.append(
id,
SetError::new(SetErrorType::ValidationFailed)
.with_validation_errors(validation_errors),
);
break;
}
// Validate domain name and hostname
let server_hostname = bootstrap
.server_hostname
.trim()
.to_lowercase()
.to_ascii_domain()
.map(|hostname| hostname.into_owned())
.unwrap_or_default();
let domain_name = bootstrap
.default_domain
.trim()
.to_lowercase()
.to_ascii_domain()
.map(|domain| domain.into_owned())
.unwrap_or_default();
if !is_valid_domain(&server_hostname) {
set.response.not_updated.append(
id,
SetError::invalid_properties()
.with_property(Property::ServerHostname)
.with_description("Invalid server hostname"),
);
break;
}
if !is_valid_domain(&domain_name) {
set.response.not_updated.append(
id,
SetError::invalid_properties()
.with_property(Property::DefaultDomain)
.with_description("Invalid default domain"),
);
break;
}
// Build store
let store = match Store::build(bootstrap.data_store.clone()).await {
Ok(store) => store,
Err(err) => {
set.response.not_updated.append(
id,
SetError::invalid_properties()
.with_property(Property::DataStore)
.with_description(err),
);
break;
}
};
// Create tables (SQL only)
if let Err(err) = store.create_tables().await {
set.response.not_updated.append(
id,
SetError::invalid_properties()
.with_property(Property::DataStore)
.with_description(format!("Failed to initialize data store: {err}")),
);
break;
}
// Make sure this is blank deployment
let probe = store.get_value::<u32>(AnyKey {
subspace: SUBSPACE_PROPERTY,
key: vec![0u8],
});
match tokio::time::timeout(Duration::from_secs(30), probe).await {
Ok(Ok(None)) => {}
Ok(Ok(Some(DATABASE_SCHEMA_VERSION))) => {
set.response.not_updated.append(
id,
SetError::invalid_properties()
.with_property(Property::DataStore)
.with_description("The selected data store has already been initialized."),
);
break;
}
Ok(Ok(Some(_))) => {
set.response.not_updated.append(
id,
SetError::invalid_properties()
.with_property(Property::DataStore)
.with_description(concat!(
"The selected data store contains information from an older version. ",
"Please follow the upgrade instructions at ",
"https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md"
)),
);
break;
}
Ok(Err(err)) => {
trc::error!(err.caused_by(trc::location!()));
set.response.not_updated.append(
id,
SetError::invalid_properties()
.with_property(Property::DataStore)
.with_description(
"Failed to initialize data store, check logs for details.",
),
);
break;
}
Err(_elapsed) => {
set.response.not_updated.append(
id,
SetError::invalid_properties()
.with_property(Property::DataStore)
.with_description(concat!(
"Timed out probing the data store after 30 seconds. ",
"Check that the backend is reachable: for FoundationDB verify ",
"the cluster file points at reachable coordinators, for SQL ",
"verify the host and credentials, and for S3 verify the endpoint ",
"and bucket. See the server logs for details."
)),
);
break;
}
};
// Validate stores and registry
let tmp_registry = set.server.registry();
for (property, object) in [
(
Property::BlobStore,
Some(bootstrap.blob_store.clone().into()),
),
(
Property::SearchStore,
Some(bootstrap.search_store.clone().into()),
),
(
Property::InMemoryStore,
Some(bootstrap.in_memory_store.clone().into()),
),
(
Property::Directory,
map_directory(&bootstrap.directory).map(Into::into),
),
(
Property::DnsServer,
map_dns_server(&bootstrap.dns_server).map(Into::into),
),
(Property::Tracer, Some(bootstrap.tracer.clone().into())),
] {
if let Some(object) = object {
match write_object(tmp_registry, &object).await {
Ok(_) => {}
Err(err) => {
set.response
.not_updated
.append(id, err.with_property(property));
break 'outer;
}
}
}
}
let mut bp_check =
store::registry::bootstrap::Bootstrap::new_uninitialized(tmp_registry.clone())
.with_data_store(store.clone());
let _ = Storage::parse(&mut bp_check).await;
if !bp_check.errors.is_empty() {
set.response
.not_updated
.append(id, map_bootstrap_error(bp_check.errors));
break 'outer;
}
// Create inner store
let registry =
RegistryStore::from_inner_bootstrapped(set.server.registry().initialize_inner(store));
// Save datastore
if let Err(err) = registry.write_data_store(&bootstrap.data_store).await {
let details = format!("Failed to save data store settings: {err}");
trc::error!(err.caused_by(trc::location!()));
set.response.not_updated.append(
id,
SetError::invalid_properties()
.with_property(Property::DataStore)
.with_description(details),
);
break;
}
// Write stores and traces to registry
for (property, object) in [
(Property::BlobStore, bootstrap.blob_store.into()),
(Property::SearchStore, bootstrap.search_store.into()),
(Property::InMemoryStore, bootstrap.in_memory_store.into()),
(Property::Tracer, bootstrap.tracer.into()),
] {
match write_object(&registry, &object).await {
Ok(_) => {}
Err(err) => {
set.response
.not_updated
.append(id, err.with_property(property));
break 'outer;
}
}
}
// Write directory and dns server to registry
let mut directory_id = None;
let mut dns_server_id = None;
if let Some(directory) = map_directory(&bootstrap.directory) {
match write_object(&registry, &directory.into()).await {
Ok(id) => {
directory_id = Some(id);
}
Err(err) => {
set.response
.not_updated
.append(id, err.with_property(Property::Directory));
break 'outer;
}
}
}
if let Some(dns_server) = map_dns_server(&bootstrap.dns_server) {
match write_object(&registry, &dns_server.into()).await {
Ok(id) => {
dns_server_id = Some(id);
}
Err(err) => {
set.response
.not_updated
.append(id, err.with_property(Property::DnsServer));
break 'outer;
}
}
}
// Create ACME provider if needed
let mut acme_provider_id = None;
if bootstrap.request_tls_certificate {
let mut acme_provider = AcmeProvider {
challenge_type: if dns_server_id.is_some() {
AcmeChallengeType::Dns01
} else {
AcmeChallengeType::TlsAlpn01
},
contact: Map::new(vec![format!("postmaster@{domain_name}")]),
#[cfg(not(feature = "dev_mode"))]
directory: "https://acme-v02.api.letsencrypt.org/directory".to_string(),
#[cfg(feature = "dev_mode")]
directory: "https://localhost:14000/dir".to_string(),
..Default::default()
};
if let Err(err) = acme_create_account(&mut acme_provider, None).await {
trc::error!(trc::ResourceEvent::Error.into_err().reason(err));
} else {
match write_object(&registry, &acme_provider.into()).await {
Ok(id) => {
acme_provider_id = Some(id);
}
Err(err) => {
set.response
.not_updated
.append(id, err.with_property(Property::DataStore));
break 'outer;
}
}
}
}
// Create domain
let publish_records = Map::new(vec![
DnsRecordType::Dkim,
DnsRecordType::Spf,
DnsRecordType::Dmarc,
DnsRecordType::Srv,
DnsRecordType::MtaSts,
DnsRecordType::TlsRpt,
DnsRecordType::AutoConfig,
DnsRecordType::AutoConfigLegacy,
DnsRecordType::AutoDiscover,
]);
let domain = Domain {
name: domain_name.clone(),
is_enabled: true,
certificate_management: if let Some(acme_provider_id) = acme_provider_id {
CertificateManagement::Automatic(CertificateManagementProperties {
acme_provider_id,
subject_alternative_names: Default::default(),
})
} else {
CertificateManagement::Manual
},
dkim_management: if bootstrap.generate_dkim_keys {
DkimManagement::Automatic(DkimManagementProperties::default())
} else {
DkimManagement::Manual
},
dns_management: if let Some(dns_server_id) = dns_server_id {
DnsManagement::Automatic(DnsManagementProperties {
dns_server_id,
origin: None,
publish_records: publish_records.clone(),
})
} else {
DnsManagement::Manual
},
directory_id,
..Default::default()
};
let domain_id = match write_object(&registry, &domain.into()).await {
Ok(id) => id,
Err(err) => {
set.response
.not_updated
.append(id, err.with_property(Property::DefaultDomain));
break 'outer;
}
};
// Write system settings
let system_settings = SystemSettings {
default_hostname: bootstrap.server_hostname,
default_domain_id: domain_id,
..Default::default()
};
match write_object(&registry, &system_settings.into()).await {
Ok(_) => {}
Err(err) => {
set.response
.not_updated
.append(id, err.with_property(Property::DefaultDomain));
break 'outer;
}
}
// Create tasks
let mut batch = BatchBuilder::new();
if dns_server_id.is_some() {
batch.schedule_task(Task::DnsManagement(TaskDnsManagement {
domain_id,
update_records: publish_records,
on_success_renew_certificate: acme_provider_id.is_some(),
status: TaskStatus::now(),
}));
} else if acme_provider_id.is_some() {
batch.schedule_task(Task::AcmeRenewal(TaskDomainManagement {
domain_id,
status: TaskStatus::now(),
}));
}
if bootstrap.generate_dkim_keys {
batch.schedule_task(Task::DkimManagement(TaskDomainManagement {
domain_id,
status: TaskStatus::now(),
}));
}
if !batch.is_empty() {
match registry.store().write(batch.build_all()).await {
Ok(_) => {}
Err(err) => {
trc::error!(err.caused_by(trc::location!()));
}
}
}
// Create admin account
let mut response = None;
if directory_id.is_none() {
let secret = rng()
.sample_iter(Alphanumeric)
.take(16)
.map(char::from)
.collect::<String>();
let account = Account::User(UserAccount {
name: "admin".to_string(),
domain_id,
credentials: List::from_iter([Credential::Password(PasswordCredential {
credential_id: Id::new(0),
secret: hash_secret(
set.server.core.network.security.password_hash_algorithm,
secret.clone().into_bytes(),
)
.await
.unwrap_or_default(),
..Default::default()
})]),
roles: UserRoles::Admin,
description: "System administrator".to_string().into(),
..Default::default()
});
match write_object(&registry, &account.into()).await {
Ok(_) => {
response = Some(JmapValue::Object(jmap_tools::Map::from_iter([
(
Key::Property(Property::Username),
JmapValue::Str(format!("admin@{domain_name}").into()),
),
(
Key::Property(Property::Secret),
JmapValue::Str(secret.into()),
),
])));
}
Err(err) => {
set.response
.not_updated
.append(id, err.with_property(Property::DefaultDomain));
break 'outer;
}
}
}
set.response.updated.append(id, response);
break;
}
Ok(set)
}
async fn write_object(registry: &RegistryStore, object: &Object) -> Result<Id, SetError<Property>> {
match registry.write(RegistryWrite::insert(object)).await {
Ok(RegistryWriteResult::Success(id)) => Ok(id),
Ok(err) => Err(map_write_error(err)),
Err(err) => {
let details = format!("Failed to save settings: {err}");
trc::error!(err.caused_by(trc::location!()));
Err(SetError::invalid_properties().with_description(details))
}
}
}
fn map_directory(directory: &DirectoryBootstrap) -> Option<Directory> {
match directory {
DirectoryBootstrap::Internal => None,
DirectoryBootstrap::Ldap(ldap_directory) => Directory::Ldap(ldap_directory.clone()).into(),
DirectoryBootstrap::Sql(sql_directory) => Directory::Sql(sql_directory.clone()).into(),
DirectoryBootstrap::Oidc(oidc_directory) => Directory::Oidc(oidc_directory.clone()).into(),
}
}
fn map_dns_server(dns_server: &DnsServerBootstrap) -> Option<registry::schema::structs::DnsServer> {
match dns_server {
DnsServerBootstrap::Manual | DnsServerBootstrap::Deprecated1 => None,
DnsServerBootstrap::Tsig(dns_server_tsig) => {
DnsServer::Tsig(dns_server_tsig.clone()).into()
}
DnsServerBootstrap::Cloudflare(dns_server_cloudflare) => {
DnsServer::Cloudflare(dns_server_cloudflare.clone()).into()
}
DnsServerBootstrap::DigitalOcean(dns_server_cloud) => {
DnsServer::DigitalOcean(dns_server_cloud.clone()).into()
}
DnsServerBootstrap::DeSEC(dns_server_cloud) => {
DnsServer::DeSEC(dns_server_cloud.clone()).into()
}
DnsServerBootstrap::Ovh(dns_server_ovh) => DnsServer::Ovh(dns_server_ovh.clone()).into(),
DnsServerBootstrap::Bunny(dns_server_cloud) => {
DnsServer::Bunny(dns_server_cloud.clone()).into()
}
DnsServerBootstrap::Porkbun(dns_server_porkbun) => {
DnsServer::Porkbun(dns_server_porkbun.clone()).into()
}
DnsServerBootstrap::Dnsimple(dns_server_dnsimple) => {
DnsServer::Dnsimple(dns_server_dnsimple.clone()).into()
}
DnsServerBootstrap::Spaceship(dns_server_spaceship) => {
DnsServer::Spaceship(dns_server_spaceship.clone()).into()
}
DnsServerBootstrap::Route53(dns_server_route53) => {
DnsServer::Route53(dns_server_route53.clone()).into()
}
DnsServerBootstrap::GoogleCloudDns(dns_server_google_cloud_dns) => {
DnsServer::GoogleCloudDns(dns_server_google_cloud_dns.clone()).into()
}
DnsServerBootstrap::Alidns(inner) => DnsServer::Alidns(inner.clone()).into(),
DnsServerBootstrap::ArvanCloud(inner) => DnsServer::ArvanCloud(inner.clone()).into(),
DnsServerBootstrap::Autodns(inner) => DnsServer::Autodns(inner.clone()).into(),
DnsServerBootstrap::AzureDns(inner) => DnsServer::AzureDns(inner.clone()).into(),
DnsServerBootstrap::BaiduCloud(inner) => DnsServer::BaiduCloud(inner.clone()).into(),
DnsServerBootstrap::BluecatV2(inner) => DnsServer::BluecatV2(inner.clone()).into(),
DnsServerBootstrap::ClouDns(inner) => DnsServer::ClouDns(inner.clone()).into(),
DnsServerBootstrap::Constellix(inner) => DnsServer::Constellix(inner.clone()).into(),
DnsServerBootstrap::Cpanel(inner) => DnsServer::Cpanel(inner.clone()).into(),
DnsServerBootstrap::Ddnss(inner) => DnsServer::Ddnss(inner.clone()).into(),
DnsServerBootstrap::DnsMadeEasy(inner) => DnsServer::DnsMadeEasy(inner.clone()).into(),
DnsServerBootstrap::Domeneshop(inner) => DnsServer::Domeneshop(inner.clone()).into(),
DnsServerBootstrap::Dreamhost(inner) => DnsServer::Dreamhost(inner.clone()).into(),
DnsServerBootstrap::DuckDns(inner) => DnsServer::DuckDns(inner.clone()).into(),
DnsServerBootstrap::Dynu(inner) => DnsServer::Dynu(inner.clone()).into(),
DnsServerBootstrap::EasyDns(inner) => DnsServer::EasyDns(inner.clone()).into(),
DnsServerBootstrap::EdgeDns(inner) => DnsServer::EdgeDns(inner.clone()).into(),
DnsServerBootstrap::Exoscale(inner) => DnsServer::Exoscale(inner.clone()).into(),
DnsServerBootstrap::FreeMyIp(inner) => DnsServer::FreeMyIp(inner.clone()).into(),
DnsServerBootstrap::GandiV5(inner) => DnsServer::GandiV5(inner.clone()).into(),
DnsServerBootstrap::Gcore(inner) => DnsServer::Gcore(inner.clone()).into(),
DnsServerBootstrap::Glesys(inner) => DnsServer::Glesys(inner.clone()).into(),
DnsServerBootstrap::Godaddy(inner) => DnsServer::Godaddy(inner.clone()).into(),
DnsServerBootstrap::Hetzner(inner) => DnsServer::Hetzner(inner.clone()).into(),
DnsServerBootstrap::HostingDe(inner) => DnsServer::HostingDe(inner.clone()).into(),
DnsServerBootstrap::Hostinger(inner) => DnsServer::Hostinger(inner.clone()).into(),
DnsServerBootstrap::HuaweiCloud(inner) => DnsServer::HuaweiCloud(inner.clone()).into(),
DnsServerBootstrap::Hurricane(inner) => DnsServer::Hurricane(inner.clone()).into(),
DnsServerBootstrap::IbmCloud(inner) => DnsServer::IbmCloud(inner.clone()).into(),
DnsServerBootstrap::Infoblox(inner) => DnsServer::Infoblox(inner.clone()).into(),
DnsServerBootstrap::Infomaniak(inner) => DnsServer::Infomaniak(inner.clone()).into(),
DnsServerBootstrap::Inwx(inner) => DnsServer::Inwx(inner.clone()).into(),
DnsServerBootstrap::Ionos(inner) => DnsServer::Ionos(inner.clone()).into(),
DnsServerBootstrap::Ipv64(inner) => DnsServer::Ipv64(inner.clone()).into(),
DnsServerBootstrap::Joker(inner) => DnsServer::Joker(inner.clone()).into(),
DnsServerBootstrap::Lightsail(inner) => DnsServer::Lightsail(inner.clone()).into(),
DnsServerBootstrap::Linode(inner) => DnsServer::Linode(inner.clone()).into(),
DnsServerBootstrap::LuaDns(inner) => DnsServer::LuaDns(inner.clone()).into(),
DnsServerBootstrap::MythicBeasts(inner) => DnsServer::MythicBeasts(inner.clone()).into(),
DnsServerBootstrap::Namecheap(inner) => DnsServer::Namecheap(inner.clone()).into(),
DnsServerBootstrap::NameDotCom(inner) => DnsServer::NameDotCom(inner.clone()).into(),
DnsServerBootstrap::NameSilo(inner) => DnsServer::NameSilo(inner.clone()).into(),
DnsServerBootstrap::Netcup(inner) => DnsServer::Netcup(inner.clone()).into(),
DnsServerBootstrap::Netlify(inner) => DnsServer::Netlify(inner.clone()).into(),
DnsServerBootstrap::Nifcloud(inner) => DnsServer::Nifcloud(inner.clone()).into(),
DnsServerBootstrap::Ns1(inner) => DnsServer::Ns1(inner.clone()).into(),
DnsServerBootstrap::OracleCloud(inner) => DnsServer::OracleCloud(inner.clone()).into(),
DnsServerBootstrap::Plesk(inner) => DnsServer::Plesk(inner.clone()).into(),
DnsServerBootstrap::Safedns(inner) => DnsServer::Safedns(inner.clone()).into(),
DnsServerBootstrap::Scaleway(inner) => DnsServer::Scaleway(inner.clone()).into(),
DnsServerBootstrap::TencentCloud(inner) => DnsServer::TencentCloud(inner.clone()).into(),
DnsServerBootstrap::Transip(inner) => DnsServer::Transip(inner.clone()).into(),
DnsServerBootstrap::UltraDns(inner) => DnsServer::UltraDns(inner.clone()).into(),
DnsServerBootstrap::Vercel(inner) => DnsServer::Vercel(inner.clone()).into(),
DnsServerBootstrap::Volcengine(inner) => DnsServer::Volcengine(inner.clone()).into(),
DnsServerBootstrap::Vultr(inner) => DnsServer::Vultr(inner.clone()).into(),
DnsServerBootstrap::WebSupport(inner) => DnsServer::WebSupport(inner.clone()).into(),
DnsServerBootstrap::YandexCloud(inner) => DnsServer::YandexCloud(inner.clone()).into(),
}
}
// FreeBSD keeps variable application data under /var/db (hier(7))
// rather than FHS /var/lib.
const DEFAULT_DATA_PATH: &str = if cfg!(target_os = "freebsd") {
"/var/db/stalwart/"
} else {
"/var/lib/stalwart/"
};
fn build_default_bootstrap(server: &Server) -> Bootstrap {
let server_hostname = server.registry().local_hostname().to_string();
let default_domain = psl::domain_str(&server_hostname)
.unwrap_or("example.org")
.to_string();
Bootstrap {
data_store: DataStore::RocksDb(RocksDbStore {
path: DEFAULT_DATA_PATH.to_string(),
..Default::default()
}),
blob_store: BlobStore::Default,
search_store: SearchStore::Default,
in_memory_store: InMemoryStore::Default,
directory: DirectoryBootstrap::Internal,
tracer: Tracer::Log(TracerLog {
path: "/var/log/stalwart/".to_string(),
prefix: "stalwart".to_string(),
ansi: true,
enable: true,
..Default::default()
}),
server_hostname,
default_domain,
request_tls_certificate: true,
generate_dkim_keys: true,
dns_server: DnsServerBootstrap::Manual,
}
}
@@ -0,0 +1,78 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use jmap_proto::{object::registry::RegistryComparator, types::state::State};
use registry::{jmap::IntoValue, schema::prelude::Property};
use store::ahash::AHashSet;
use crate::{
api::query::QueryResponseBuilder,
registry::mapping::{RegistryGetResponse, RegistryQueryResponse},
};
pub(crate) async fn cluster_node_get(
mut get: RegistryGetResponse<'_>,
) -> trc::Result<RegistryGetResponse<'_>> {
let nodes = get.server.registry().cluster_node_list().await?;
let mut ids = get
.ids
.take()
.unwrap_or_default()
.into_iter()
.map(|id| id.id())
.collect::<AHashSet<_>>();
for node in nodes {
if ids.is_empty() || ids.remove(&node.node_id) {
get.insert(node.node_id.into(), node.into_value());
}
}
for id in ids {
get.not_found(id.into());
}
Ok(get)
}
pub(crate) async fn cluster_node_query(
req: RegistryQueryResponse<'_>,
) -> trc::Result<QueryResponseBuilder> {
if req
.request
.sort
.as_ref()
.and_then(|sort| sort.first())
.is_some_and(|comp| {
!matches!(
comp.property,
RegistryComparator::Property(Property::NodeId)
)
})
{
return Err(trc::JmapEvent::UnsupportedSort
.into_err()
.details("Only sorting by 'nodeId' is supported for cluster nodes".to_string()));
}
let nodes = req.server.registry().cluster_node_list().await?;
// Build response
let mut response = QueryResponseBuilder::new(
nodes.len(),
req.server.core.jmap.query_max_results,
State::Initial,
&req.request,
);
for node in nodes {
if !response.add_id(node.node_id.into()) {
break;
}
}
Ok(response)
}
+47
View File
@@ -0,0 +1,47 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::registry::mapping::{
ObjectResponse, RegistrySetResponse, ValidationResult, principal::validate_tenant_quota,
};
use common::config::smtp::auth::DkimSigners;
use jmap_proto::error::set::SetError;
use registry::schema::{enums::TenantStorageQuota, structs::DkimSignature};
pub(crate) async fn validate_dkim_signature(
set: &RegistrySetResponse<'_>,
key: &mut DkimSignature,
old_key: Option<&DkimSignature>,
) -> ValidationResult {
let response = if old_key.is_none() {
match validate_tenant_quota(
set.server,
set.access_token,
TenantStorageQuota::MaxDkimKeys,
)
.await?
{
Ok(response) => response,
Err(err) => {
return Ok(Err(err));
}
}
} else {
ObjectResponse::default()
};
if old_key.is_none_or(|old_key| old_key.private_key() != key.private_key())
&& let Err(err) = DkimSigners::default()
.insert("example.com".to_string(), key.clone())
.await
{
return Ok(Err(SetError::invalid_properties().with_description(
format!("Failed to validate DKIM signature: {err}"),
)));
}
Ok(Ok(response))
}
+209
View File
@@ -0,0 +1,209 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::registry::mapping::{
ObjectResponse, RegistrySetResponse, ValidationResult, principal::validate_tenant_quota,
};
use common::network::{dkim::generate_dkim_selector, dns::update::DnsUpdater};
use jmap_proto::error::set::{SetError, SetErrorType};
use registry::{
schema::{
enums::{AcmeChallengeType, DkimSignatureType, DnsRecordType, TenantStorageQuota},
prelude::{ObjectType, Property},
structs::{
AcmeProvider, CertificateManagement, DkimManagement, DkimManagementProperties,
DnsManagement, DnsServer, Domain, Task, TaskDnsManagement, TaskDomainManagement,
TaskStatus,
},
},
types::map::Map,
};
use types::id::Id;
pub(crate) async fn validate_domain(
set: &RegistrySetResponse<'_>,
domain: &mut Domain,
old_domain: Option<&Domain>,
tasks: &mut Vec<Task>,
) -> ValidationResult {
let response = if old_domain.is_none() {
match validate_tenant_quota(set.server, set.access_token, TenantStorageQuota::MaxDomains)
.await?
{
Ok(response) => response,
Err(err) => {
return Ok(Err(err));
}
}
} else {
ObjectResponse::default()
};
// Validate DKIM selector template
if let DkimManagement::Automatic(DkimManagementProperties {
selector_template, ..
}) = &domain.dkim_management
&& old_domain.is_none_or(|old| {
matches!(
&old.dkim_management,
DkimManagement::Automatic(DkimManagementProperties {
selector_template: old_selector_template,
..
}) if old_selector_template != selector_template
)
})
&& let Err(err) =
generate_dkim_selector(selector_template, DkimSignatureType::Dkim1RsaSha256)
{
return Ok(Err(SetError::invalid_properties()
.with_property(Property::SelectorTemplate)
.with_description(err)));
}
// Validate that names and aliases do not collide with another domain
let registry = set.server.registry();
if old_domain.is_none_or(|old| old.name != domain.name)
&& let Some(existing) = registry
.primary_key(
ObjectType::Domain.into(),
Property::Aliases,
domain.name.as_bytes().to_vec(),
)
.await?
{
return Ok(Err(SetError::new(SetErrorType::PrimaryKeyViolation)
.with_property(Property::Name)
.with_object_id(existing)));
}
for alias in domain.aliases.iter() {
if alias == &domain.name
|| old_domain.is_some_and(|old| old.aliases.contains(alias) || &old.name == alias)
{
continue;
}
for index in [Property::Name, Property::Aliases] {
if let Some(existing) = registry
.primary_key(ObjectType::Domain.into(), index, alias.as_bytes().to_vec())
.await?
{
return Ok(Err(SetError::new(SetErrorType::PrimaryKeyViolation)
.with_property(Property::Aliases)
.with_object_id(existing)));
}
}
}
// Schedule DNS update task
let will_trigger_dkim = matches!(domain.dkim_management, DkimManagement::Automatic(_))
&& old_domain
.is_none_or(|old| !matches!(old.dkim_management, DkimManagement::Automatic(_)));
let will_trigger_acme = if let DnsManagement::Automatic(details) = &domain.dns_management
&& old_domain.is_none_or(|old| !matches!(old.dns_management, DnsManagement::Automatic(_)))
{
let on_success_renew_certificate = old_domain.is_none()
&& matches!(
domain.certificate_management,
CertificateManagement::Automatic(_)
);
tasks.push(Task::DnsManagement(TaskDnsManagement {
domain_id: Id::default(),
update_records: Map::new(
details
.publish_records
.iter()
.filter(|&&r| r != DnsRecordType::Dkim || !will_trigger_dkim)
.copied()
.collect(),
),
on_success_renew_certificate,
status: TaskStatus::now(),
}));
on_success_renew_certificate
} else {
false
};
// Schedule DKIM key rotation task
if will_trigger_dkim {
tasks.push(Task::DkimManagement(TaskDomainManagement {
domain_id: Id::default(),
status: TaskStatus::now(),
}));
}
// Schedule ACME renewal task if needed
if !will_trigger_acme
&& let CertificateManagement::Automatic(details) = &domain.certificate_management
&& old_domain.is_none_or(|old| {
!matches!(
old.certificate_management,
CertificateManagement::Automatic(_)
)
})
{
let Some(provider) = set
.server
.registry()
.object::<AcmeProvider>(details.acme_provider_id)
.await?
else {
return Ok(Err(SetError::invalid_properties()
.with_property(Property::AcmeProviderId)
.with_description("ACME provider not found")));
};
if matches!(provider.challenge_type, AcmeChallengeType::Dns01)
&& !matches!(domain.dns_management, DnsManagement::Automatic(_))
{
return Ok(Err(SetError::invalid_properties()
.with_property(Property::AcmeProviderId)
.with_description(
"ACME provider requires automatic DNS management",
)));
}
tasks.push(Task::AcmeRenewal(TaskDomainManagement {
domain_id: Id::default(),
status: TaskStatus::now(),
}));
}
Ok(Ok(response))
}
pub(crate) async fn validate_dns_server(
set: &RegistrySetResponse<'_>,
dns: &mut DnsServer,
old_dns: Option<&DnsServer>,
) -> ValidationResult {
let response = if old_dns.is_none() {
match validate_tenant_quota(
set.server,
set.access_token,
TenantStorageQuota::MaxDnsServers,
)
.await?
{
Ok(response) => response,
Err(err) => {
return Ok(Err(err));
}
}
} else {
ObjectResponse::default()
};
if old_dns.is_none_or(|old_dns| old_dns != dns)
&& let Err(err) = DnsUpdater::build(dns.clone(), set.server.core.clone()).await
{
return Ok(Err(SetError::invalid_properties()
.with_description(format!("Failed to build DNS server: {err}"))));
}
Ok(Ok(response))
}
+510
View File
@@ -0,0 +1,510 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
api::query::QueryResponseBuilder,
registry::{
mapping::{RegistryGetResponse, RegistryQueryResponse},
query::RegistryQueryFilters,
},
};
use chrono::DateTime;
use jmap_proto::types::state::State;
use registry::{
jmap::IntoValue,
schema::{enums::TracingLevel, prelude::Property, structs::Log},
types::{EnumImpl, datetime::UTCDateTime},
};
use std::{
borrow::Cow,
fs::{self, File},
io::{self, BufRead, BufReader, Read, Seek, SeekFrom},
path::Path,
};
use store::ahash::AHashMap;
use tokio::sync::oneshot;
use trc::EventType;
use types::id::Id;
pub(crate) async fn log_get(
mut get: RegistryGetResponse<'_>,
) -> trc::Result<RegistryGetResponse<'_>> {
let Some(path) = get.server.core.metrics.log_path.clone() else {
return Err(trc::JmapEvent::InvalidArguments
.into_err()
.details("No log tracers configured on the server"));
};
let ids = get.ids.take();
if ids.as_ref().is_none_or(|ids| !ids.is_empty()) {
// TODO: Use worker pool
let limit = get.server.core.jmap.get_max_objects;
let (tx, rx) = oneshot::channel();
tokio::task::spawn_blocking(move || {
let _ = tx.send(read_log_entries(path, ids, limit));
});
rx.await
.map_err(|err| {
trc::EventType::Server(trc::ServerEvent::ThreadError)
.reason(err)
.caused_by(trc::location!())
})?
.map_err(|err| {
trc::EventType::Telemetry(trc::TelemetryEvent::LogError)
.reason(err)
.details("Failed to read log files")
.caused_by(trc::location!())
})?
.into_iter()
.for_each(|(id, log)| {
get.insert(id, log.into_value());
});
}
Ok(get)
}
pub(crate) async fn log_query(
mut req: RegistryQueryResponse<'_>,
) -> trc::Result<QueryResponseBuilder> {
let Some(path) = req.server.core.metrics.log_path.clone() else {
return Err(trc::JmapEvent::InvalidArguments
.into_err()
.details("No log tracers configured on the server"));
};
let mut filter = None;
req.request
.extract_filters(|property, _, value| match property {
Property::Text => {
if let serde_json::Value::String(due) = value {
filter = Some(due);
true
} else {
false
}
}
_ => false,
})?;
let anchor = req.request.anchor.map(|id| id.id()).unwrap_or(0);
let limit = std::cmp::min(
req.request.limit.unwrap_or(usize::MAX),
req.server.core.jmap.query_max_results,
);
let params = req
.request
.extract_parameters(req.server.core.jmap.query_max_results, Property::Id.into())?;
if params.sort_by != Property::Id {
return Err(trc::JmapEvent::UnsupportedSort
.into_err()
.details("Only sorting by 'id' is supported for logs"));
}
if req.request.position.unwrap_or(0) != 0 {
return Err(trc::JmapEvent::InvalidArguments
.into_err()
.details("Pagination is only possible using anchors for logs"));
}
let (tx, rx) = oneshot::channel();
tokio::task::spawn_blocking(move || {
let _ = tx.send(read_log_offsets(path, filter.as_deref(), anchor, limit));
});
// Build response
let mut response = QueryResponseBuilder::new(
req.server.core.jmap.query_max_results,
req.server.core.jmap.query_max_results,
State::Initial,
&req.request,
);
response.response.ids = rx
.await
.map_err(|err| {
trc::EventType::Server(trc::ServerEvent::ThreadError)
.reason(err)
.caused_by(trc::location!())
})?
.map_err(|err| {
trc::EventType::Telemetry(trc::TelemetryEvent::LogError)
.reason(err)
.details("Failed to read log files")
.caused_by(trc::location!())
})?;
response.anchor_found = true;
Ok(response)
}
fn read_log_offsets(
path: impl AsRef<Path>,
filter: Option<&str>,
anchor: u64,
limit: usize,
) -> io::Result<Vec<Id>> {
let mut logs = fs::read_dir(path)?.collect::<Result<Vec<_>, _>>()?;
logs.sort_by_key(|b| std::cmp::Reverse(b.file_name()));
let mut entries = Vec::with_capacity(limit);
let mut file_number = 0u64;
let mut found_anchor = anchor == 0;
let file_anchor = anchor >> 48;
'outer: for log in logs.into_iter() {
if !log.file_type()?.is_file() {
continue;
}
if !found_anchor && file_anchor != file_number {
file_number += 1;
continue;
}
let file = File::open(log.path())?;
let file_size = file.metadata()?.len();
let mut rev_lines = RevLines::new(file);
rev_lines.0.init_reader()?;
let mut offset = file_size;
for line in rev_lines {
let line = line?;
offset = offset.saturating_sub(line.len() as u64 + 1); // +1 for the newline character
if !is_log_header(&line) {
continue;
}
let id = (file_number << 48) | offset;
if !found_anchor {
found_anchor = id == anchor;
continue;
}
if filter.is_none_or(|filter| line.contains(filter)) {
entries.push(Id::from(id));
if entries.len() == limit {
break 'outer;
}
}
}
file_number += 1;
}
Ok(entries)
}
fn read_log_entries(
path: impl AsRef<Path>,
ids: Option<Vec<Id>>,
limit: usize,
) -> io::Result<Vec<(Id, Log)>> {
let path = path.as_ref();
let ids = if let Some(mut ids) = ids {
ids.truncate(limit);
ids
} else {
read_log_offsets(path, None, 0, limit)?
};
let mut logs = fs::read_dir(path)?.collect::<Result<Vec<_>, _>>()?;
// Sort the entries by file name in reverse order.
logs.sort_by_key(|b| std::cmp::Reverse(b.file_name()));
let mut entries = Vec::with_capacity(ids.len());
// Group files and offsets
let mut offset_map = AHashMap::new();
let total_ids = ids.len();
for id in ids {
let file_number = id.id() >> 48;
let offset = id.id() & 0xFFFFFFFFFFFF;
offset_map
.entry(file_number)
.or_insert_with(Vec::new)
.push(offset);
}
let mut file_number = 0u64;
let mut line = String::with_capacity(256);
'outer: for log in logs.into_iter() {
if !log.file_type()?.is_file() {
continue;
}
if let Some(offsets) = offset_map.get(&file_number) {
let mut reader = BufReader::new(File::open(log.path())?);
for offset in offsets {
// seek to the offset and read the line
reader.seek(SeekFrom::Start(*offset))?;
line.clear();
reader.read_line(&mut line)?;
if let Some(log) = log_from_line(&line) {
entries.push((Id::from((file_number << 48) | *offset), log));
if entries.len() == total_ids {
break 'outer;
}
}
}
}
file_number += 1;
}
Ok(entries)
}
fn is_log_header(line: &str) -> bool {
let line = strip_ansi(line);
let bytes = line.as_bytes();
if bytes.is_empty() || !bytes[0].is_ascii_digit() {
return false;
}
let Some((timestamp, _)) = line.split_once(' ') else {
return false;
};
DateTime::parse_from_rfc3339(timestamp).is_ok()
}
fn log_from_line(line: &str) -> Option<Log> {
let line = strip_ansi(line);
let (timestamp, rest) = line.split_once(' ')?;
let timestamp = DateTime::parse_from_rfc3339(timestamp).ok()?;
let (level, rest) = rest.trim().split_once(' ')?;
let (_, rest) = rest.trim().split_once(" (")?;
let (event_id, details) = rest.split_once(")")?;
Some(Log {
timestamp: UTCDateTime::from_timestamp(timestamp.timestamp()),
level: TracingLevel::parse(&level.to_ascii_lowercase()).unwrap_or(TracingLevel::Info),
event: EventType::parse(event_id)?,
details: details.trim().to_string(),
})
}
fn strip_ansi(line: &str) -> Cow<'_, str> {
if !line.contains('\x1b') {
return Cow::Borrowed(line);
}
let mut out = String::with_capacity(line.len());
let mut chars = line.chars();
while let Some(c) = chars.next() {
if c != '\x1b' {
out.push(c);
continue;
}
match chars.next() {
Some('[') => {
for c in chars.by_ref() {
if matches!(c as u32, 0x40..=0x7e) {
break;
}
}
}
Some(']') => {
while let Some(c) = chars.next() {
if c == '\x07' {
break;
}
if c == '\x1b' {
chars.next();
break;
}
}
}
_ => {}
}
}
Cow::Owned(out)
}
/*
* SPDX-FileCopyrightText: 2017 Michael Coyne <[email protected]>
*
* SPDX-License-Identifier: MIT
*/
// Adapted from https://github.com/mjc-gh/rev_lines/blob/main/src/lib.rs
static DEFAULT_SIZE: usize = 4096;
static LF_BYTE: u8 = b'\n';
/// `RevLines` struct
pub struct RawRevLines<R> {
reader: BufReader<R>,
reader_cursor: u64,
buffer: Vec<u8>,
buffer_end: usize,
read_len: usize,
}
impl<R: Seek + Read> RawRevLines<R> {
/// Create a new `RawRevLines` struct from a Reader.
/// Internal buffering for iteration will default to 4096 bytes at a time.
pub fn new(reader: R) -> RawRevLines<R> {
RawRevLines::with_capacity(DEFAULT_SIZE, reader)
}
/// Create a new `RawRevLines` struct from a Reader`.
/// Internal buffering for iteration will use `cap` bytes at a time.
pub fn with_capacity(cap: usize, reader: R) -> RawRevLines<R> {
RawRevLines {
reader: BufReader::new(reader),
reader_cursor: u64::MAX,
buffer: vec![0; cap],
buffer_end: 0,
read_len: 0,
}
}
pub fn init_reader(&mut self) -> io::Result<()> {
// Move cursor to the end of the file and store the cursor position
self.reader_cursor = self.reader.seek(SeekFrom::End(0))?;
// Next read will be the full buffer size or the remaining bytes in the file
self.read_len = std::cmp::min(self.buffer.len(), self.reader_cursor as usize);
// Move cursor just before the next bytes to read
self.reader.seek_relative(-(self.read_len as i64))?;
// Update the cursor position
self.reader_cursor -= self.read_len as u64;
self.read_to_buffer()?;
// Handle any trailing new line characters for the reader
// so the first next call does not return Some("")
if self.buffer_end > 0
&& let Some(last_byte) = self.buffer.get(self.buffer_end - 1)
&& *last_byte == LF_BYTE
{
self.buffer_end -= 1;
}
Ok(())
}
fn read_to_buffer(&mut self) -> io::Result<()> {
// Read the next bytes into the buffer, self.read_len was already prepared for that
self.reader.read_exact(&mut self.buffer[0..self.read_len])?;
// Specify which part of the buffer is valid
self.buffer_end = self.read_len;
// Determine what the next read length will be
let next_read_len = std::cmp::min(self.buffer.len(), self.reader_cursor as usize);
// Move the cursor just in front of the next read
self.reader
.seek_relative(-((self.read_len + next_read_len) as i64))?;
// Update cursor position
self.reader_cursor -= next_read_len as u64;
// Store the next read length, it'll be used in the next call
self.read_len = next_read_len;
Ok(())
}
fn next_line(&mut self) -> io::Result<Option<Vec<u8>>> {
// Reader cursor will only ever be u64::MAX if the reader has not been initialized
// If by some chance the reader is initialized with a file of length u64::MAX this will still work,
// as some read length value is subtracted from the cursor position right away
if self.reader_cursor == u64::MAX {
self.init_reader()?;
}
// For most sane scenarios, where size of the buffer is greater than the length of the line,
// the result will only contain one and at most two elements, making the flattening trivial.
// At the same time, instead of pushing one element at a time, it allows us to copy a subslice of the buffer,
// which is very performant on modern architectures.
let mut result: Vec<Vec<u8>> = Vec::new();
'outer: loop {
// Current buffer was read to completion, read new contents
if self.buffer_end == 0 {
// Read the of minimum between the desired
// buffer size or remaining length of the reader
self.read_to_buffer()?;
}
// If buffer_end is still 0, it means the reader is empty
if self.buffer_end == 0 {
if result.is_empty() {
return Ok(None);
} else {
break;
}
}
let buffer_length = self.buffer_end;
for ch in self.buffer[..self.buffer_end].iter().rev() {
self.buffer_end -= 1;
// Found a new line character to break on
if *ch == LF_BYTE {
result.push(self.buffer[self.buffer_end + 1..buffer_length].to_vec());
break 'outer;
}
}
result.push(self.buffer[..buffer_length].to_vec());
}
Ok(Some(result.into_iter().rev().flatten().collect()))
}
}
impl<R: Read + Seek> Iterator for RawRevLines<R> {
type Item = io::Result<Vec<u8>>;
fn next(&mut self) -> Option<io::Result<Vec<u8>>> {
self.next_line().transpose()
}
}
pub struct RevLines<R>(RawRevLines<R>);
impl<R: Read + Seek> RevLines<R> {
/// Create a new `RawRevLines` struct from a Reader.
/// Internal buffering for iteration will default to 4096 bytes at a time.
pub fn new(reader: R) -> RevLines<R> {
RevLines(RawRevLines::new(reader))
}
/// Create a new `RawRevLines` struct from a Reader`.
/// Internal buffering for iteration will use `cap` bytes at a time.
pub fn with_capacity(cap: usize, reader: R) -> RevLines<R> {
RevLines(RawRevLines::with_capacity(cap, reader))
}
}
impl<R: Read + Seek> Iterator for RevLines<R> {
type Item = Result<String, std::io::Error>;
fn next(&mut self) -> Option<Result<String, std::io::Error>> {
let line = match self.0.next_line().transpose()? {
Ok(line) => line,
Err(error) => return Some(Err(error)),
};
Some(
String::from_utf8(line)
.map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "Invalid UTF-8")),
)
}
}
+115
View File
@@ -0,0 +1,115 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use common::{Server, auth::AccessToken};
use jmap_proto::{
error::set::{SetError, SetErrorType},
method::{get::GetResponse, query::QueryRequest, set::SetResponse},
object::registry::Registry,
};
use jmap_tools::Map;
use registry::{
jmap::{JmapValue, RegistryValue},
schema::prelude::{ObjectType, Property},
types::error::Error,
};
use std::net::IpAddr;
use store::ahash::AHashSet;
use types::id::Id;
use utils::map::vec_map::VecMap;
pub mod account;
pub mod action;
pub mod bootstrap;
pub mod cluster;
pub mod dkim;
pub mod domain;
pub mod log;
pub mod principal;
pub mod public_key;
pub mod queued_message;
pub mod report;
pub mod sieve;
pub mod spam_sample;
pub mod task;
pub mod tls;
pub(crate) struct RegistryGetResponse<'x> {
pub server: &'x Server,
pub access_token: &'x AccessToken,
pub account_id: u32,
pub ids: Option<Vec<Id>>,
pub properties: AHashSet<Property>,
pub response: GetResponse<Registry>,
pub object_type: ObjectType,
pub object_flags: u64,
pub is_tenant_filtered: bool,
pub is_account_filtered: bool,
}
pub(crate) struct RegistrySetResponse<'x> {
pub server: &'x Server,
pub remote_ip: IpAddr,
pub access_token: &'x AccessToken,
pub account_id: u32,
pub create: VecMap<String, JmapValue<'x>>,
pub update: Vec<(Id, JmapValue<'x>)>,
pub destroy: Vec<Id>,
pub response: SetResponse<Registry>,
pub object_type: ObjectType,
pub is_tenant_filtered: bool,
pub is_account_filtered: bool,
}
pub(crate) struct RegistryQueryResponse<'x> {
pub server: &'x Server,
pub access_token: &'x AccessToken,
pub object_type: ObjectType,
pub request: QueryRequest<Registry>,
}
pub type ValidationResult = trc::Result<Result<ObjectResponse, SetError<Property>>>;
pub struct ObjectResponse {
pub id: Option<Id>,
pub object: Map<'static, Property, RegistryValue>,
}
impl ObjectResponse {
pub fn new(id: Id, object: Map<'static, Property, RegistryValue>) -> Self {
Self {
id: Some(id),
object,
}
}
}
impl Default for ObjectResponse {
fn default() -> Self {
Self {
id: None,
object: Map::with_capacity(1),
}
}
}
pub(crate) fn map_bootstrap_error(error: Vec<Error>) -> SetError<Property> {
match error.into_iter().next().unwrap() {
Error::Validation { object_id, errors } => SetError::new(SetErrorType::ValidationFailed)
.with_validation_errors(errors)
.with_object_id(object_id),
Error::Build { object_id, message } => SetError::new(SetErrorType::ValidationFailed)
.with_description(message)
.with_object_id(object_id),
Error::Internal { object_id, error } => SetError::new(SetErrorType::Forbidden)
.with_description(error.to_string())
.with_object_id_opt(object_id),
Error::NotFound { object_id } => {
SetError::new(SetErrorType::NotFound).with_object_id(object_id)
}
}
}
@@ -0,0 +1,440 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::registry::mapping::{ObjectResponse, ValidationResult};
use common::{
Server,
auth::{AccessToken, Permissions, PermissionsGroup, permissions::BuildPermissions},
};
use directory::core::secret::{hash_secret, is_password_hash};
use jmap_proto::error::set::SetError;
use registry::{schema::structs::TaskStatus, types::datetime::UTCDateTime};
use registry::{
schema::{
enums::{AccountType, Permission, TenantStorageQuota},
prelude::{MASKED_PASSWORD, ObjectType, Property},
structs::{Account, Credential, Role, Task, TaskDestroyAccount},
},
types::EnumImpl,
};
use store::{
registry::{RegistryObjectCounter, RegistryQuery},
write::{BatchBuilder, RegistryClass, ValueClass, now},
};
use trc::AddContext;
use types::id::Id;
#[derive(Clone, Copy)]
pub enum AccountUpdate<'x> {
Update(&'x Account),
Create(&'x str),
}
pub async fn validate_account(
server: &Server,
access_token: &AccessToken,
mut account: &mut Account,
old_account: AccountUpdate<'_>,
) -> ValidationResult {
let is_external_directory = if let Account::User(account) = account {
server
.domain_by_id(account.domain_id.document_id())
.await?
.and_then(|domain| server.get_directory_for_cached_domain(&domain))
.is_some()
} else {
false
};
let recover_account_id = if server.registry().is_recovery_mode()
&& let AccountUpdate::Create(client_id) = old_account
&& let Some(account_id) = client_id
.strip_prefix("restore-")
.and_then(|id| id.parse::<u32>().ok())
{
Some(account_id)
} else {
None
};
let validate_permissions = match (&mut account, old_account) {
(Account::User(account), AccountUpdate::Update(Account::User(old_account))) => {
// Validate credentials
let has_password = account.credentials.values().any(|credential| {
matches!(credential, Credential::Password(credential) if credential.credential_id.is_valid())
});
let mut max_credential_id = 0;
let mut has_new_credentials = false;
for credential in account.credentials.values_mut() {
let credential_id = credential.credential_id();
if credential_id.is_valid() && credential_id.id() > max_credential_id {
max_credential_id = credential_id.id();
}
if let Some(old_credential) = old_account
.credentials
.values()
.find(|c| c.credential_id() == credential_id)
{
if credential != old_credential {
match (credential, old_credential) {
(
Credential::Password(credential),
Credential::Password(old_credential),
) => {
if is_external_directory {
return Ok(Err(SetError::forbidden().with_description(
"Cannot change credentials for accounts in an external directory.",
)));
}
// Reset the original password if the client accidentally sent the masked password
if credential.secret == MASKED_PASSWORD {
credential.secret = old_credential.secret.clone();
}
if credential
.otp_auth
.as_ref()
.is_some_and(|otp_auth| otp_auth == MASKED_PASSWORD)
{
credential.otp_auth = old_credential.otp_auth.clone();
}
if credential.secret != old_credential.secret {
if credential.expires_at == old_credential.expires_at
&& credential
.expires_at
.is_some_and(|exp| exp.timestamp() <= now() as i64)
&& let Some(expires_at) =
server.core.network.security.password_default_expiration
{
credential.expires_at = Some(UTCDateTime::from_timestamp(
(now() + expires_at) as i64,
));
}
if !(matches!(
credential.secret.as_bytes().first(),
Some(&b'$' | &b'{')
) && is_password_hash(&credential.secret))
{
if let Err(err) =
server.is_secure_password(&credential.secret, &[])
{
return Ok(Err(SetError::invalid_properties()
.with_property(Property::Secret)
.with_description(err)));
}
credential.secret = hash_secret(
server.core.network.security.password_hash_algorithm,
std::mem::take(&mut credential.secret).into_bytes(),
)
.await
.caused_by(trc::location!())?;
}
}
}
(
Credential::AppPassword(credential),
Credential::AppPassword(old_credential),
)
| (
Credential::ApiKey(credential),
Credential::ApiKey(old_credential),
) => {
// Reset the original password if the client accidentally sent the masked password
if credential.secret == MASKED_PASSWORD {
credential.secret = old_credential.secret.clone();
}
if credential.secret != old_credential.secret {
return Ok(Err(SetError::forbidden().with_description(
"Cannot change app password or API credentials through this method.",
)));
}
}
_ => {
return Ok(Err(SetError::invalid_properties()
.with_property(Property::Credentials)
.with_description("Credential type cannot be changed.")));
}
}
}
} else if let Err(err) = validate_credential_creation(
server,
credential,
is_external_directory,
has_password,
)
.await?
{
return Ok(Err(err));
} else {
has_new_credentials = true;
}
}
if has_new_credentials {
for credential in account.credentials.values_mut() {
if !credential.credential_id().is_valid() {
max_credential_id += 1;
credential.set_credential_id(Id::from(max_credential_id));
}
}
}
account.permissions != old_account.permissions || account.roles != old_account.roles
}
(Account::Group(account), AccountUpdate::Update(Account::Group(old_account))) => {
account.permissions != old_account.permissions || account.roles != old_account.roles
}
(Account::User(account), AccountUpdate::Create(_)) => {
// Validate tenant quotas
if let Err(err) =
validate_tenant_quota(server, access_token, TenantStorageQuota::MaxAccounts).await?
{
return Ok(Err(err));
}
// Validate credentials
for (index, credential) in account.credentials.values_mut().enumerate() {
if let Err(err) = validate_credential_creation(
server,
credential,
is_external_directory,
index > 0,
)
.await?
{
return Ok(Err(err));
}
credential.set_credential_id(Id::from(index as u64));
}
true
}
(Account::Group(_), AccountUpdate::Create(_)) => {
// Validate tenant quotas
if let Err(err) =
validate_tenant_quota(server, access_token, TenantStorageQuota::MaxGroups).await?
{
return Ok(Err(err));
}
true
}
(Account::User(_), AccountUpdate::Update(Account::Group(_)))
| (Account::Group(_), AccountUpdate::Update(Account::User(_))) => {
return Ok(Err(SetError::invalid_properties()
.with_property(Property::Type)
.with_description(
"Cannot change the type of an existing account.",
)));
}
};
let mut result = if validate_permissions {
Ok(server
.can_set_permissions(access_token, account)
.await?
.map(|_| ObjectResponse::default())
.map_err(build_set_error))
} else {
Ok(Ok(ObjectResponse::default()))
};
if let Some(account_id) = recover_account_id
&& let Ok(Ok(result)) = &mut result
{
restore_account_id(server, account_id).await?;
result.id = Some(account_id.into());
}
result
}
async fn validate_credential_creation(
server: &Server,
credential: &mut Credential,
is_external_directory: bool,
has_password: bool,
) -> trc::Result<Result<(), SetError<Property>>> {
match credential {
Credential::Password(credential) => {
if is_external_directory {
return Ok(Err(SetError::forbidden().with_description(
"Cannot set credentials for accounts in an external directory.",
)));
} else if has_password {
return Ok(Err(SetError::invalid_properties()
.with_property(Property::Credentials)
.with_description("Only one password credential is allowed.")));
}
if credential.expires_at.is_none()
&& let Some(expires_at) = server.core.network.security.password_default_expiration
{
credential.expires_at =
Some(UTCDateTime::from_timestamp((now() + expires_at) as i64));
}
if matches!(credential.secret.as_bytes().first(), Some(&b'$' | &b'{'))
&& is_password_hash(&credential.secret)
{
Ok(Ok(()))
} else if let Err(err) = server.is_secure_password(&credential.secret, &[]) {
Ok(Err(SetError::invalid_properties()
.with_property(Property::Secret)
.with_description(err)))
} else {
credential.secret = hash_secret(
server.core.network.security.password_hash_algorithm,
std::mem::take(&mut credential.secret).into_bytes(),
)
.await
.caused_by(trc::location!())?;
Ok(Ok(()))
}
}
Credential::AppPassword(_) | Credential::ApiKey(_) => {
Ok(Err(SetError::invalid_properties()
.with_property(Property::Credentials)
.with_description(
"Secondary credentials cannot be set directly.",
)))
}
}
}
pub(crate) async fn validate_role(
server: &Server,
access_token: &AccessToken,
role: &mut Role,
old_role: Option<&Role>,
) -> ValidationResult {
if old_role.is_none() {
// Validate tenant quotas
if let Err(err) =
validate_tenant_quota(server, access_token, TenantStorageQuota::MaxRoles).await?
{
return Ok(Err(err));
}
}
if old_role.is_none_or(|old_role| {
old_role.enabled_permissions != role.enabled_permissions
|| old_role.disabled_permissions != role.disabled_permissions
|| old_role.role_ids != role.role_ids
}) {
Ok(access_token
.can_grant_permissions(
PermissionsGroup {
enabled: Permissions::from_permission(role.enabled_permissions.as_slice()),
disabled: Permissions::from_permission(role.disabled_permissions.as_slice()),
merge: false,
}
.finalize(),
)
.map(|_| ObjectResponse::default())
.map_err(build_set_error))
} else {
Ok(Ok(ObjectResponse::default()))
}
}
#[cfg(not(feature = "enterprise"))]
pub async fn validate_tenant_quota(
_server: &Server,
_access_token: &AccessToken,
_quota: TenantStorageQuota,
) -> ValidationResult {
ValidationResult::Ok(Ok(ObjectResponse::default()))
}
pub async fn schedule_account_destruction(
server: &Server,
account_id: Id,
account: &Account,
) -> trc::Result<()> {
#[cfg(not(feature = "enterprise"))]
let status = TaskStatus::now();
let (account_domain_id, account_name, account_type) = match account {
Account::User(account) => (account.domain_id, account.name.clone(), AccountType::User),
Account::Group(account) => (account.domain_id, account.name.clone(), AccountType::Group),
};
let mut batch = BatchBuilder::new();
batch.schedule_task(Task::DestroyAccount(TaskDestroyAccount {
account_domain_id,
account_id,
account_name,
account_type,
status,
}));
server.store().write(batch.build_all()).await?;
server.notify_task_queue();
Ok(())
}
pub(crate) fn build_set_error(permissions: Vec<Permission>) -> SetError<Property> {
let mut missing_permissions = String::with_capacity(16);
let mut total_missing = permissions.len();
for permission in permissions.into_iter().take(5) {
if !missing_permissions.is_empty() {
missing_permissions.push_str(", ");
}
missing_permissions.push_str(permission.as_str());
total_missing -= 1;
}
if total_missing > 0 {
missing_permissions.push_str(&format!(" and {} more", total_missing));
}
SetError::forbidden().with_description(format!(
"You are not authorized to grant permissions: {}",
missing_permissions
))
}
async fn restore_account_id(server: &Server, id: u32) -> trc::Result<()> {
// Obtain current counter value
let object_id = ObjectType::Account.to_id();
let last_id = server
.store()
.get_counter(ValueClass::Registry(RegistryClass::IdCounter { object_id }))
.await
.caused_by(trc::location!())?
.cast_unsigned() as u32;
if last_id < id {
let mut id_batch = BatchBuilder::new();
id_batch.add_and_get(
ValueClass::Registry(RegistryClass::IdCounter { object_id }),
(id - last_id) as i64,
);
let last_id = server
.store()
.write(id_batch.build_all())
.await
.and_then(|v| v.last_counter_id())?;
if last_id < id as i64 {
return Err(trc::StoreEvent::UnexpectedError
.into_err()
.details("Failed to update id counter")
.caused_by(trc::location!()));
}
}
Ok(())
}
@@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::registry::mapping::{ObjectResponse, RegistrySetResponse, ValidationResult};
use common::storage::encryption::parse_public_key;
use jmap_proto::error::set::SetError;
use registry::schema::{
enums::StorageQuota,
prelude::{ObjectType, Property},
structs::PublicKey,
};
use store::registry::{RegistryObjectCounter, RegistryQuery};
pub(crate) async fn validate_public_key(
set: &RegistrySetResponse<'_>,
key: &mut PublicKey,
old_key: Option<&PublicKey>,
) -> ValidationResult {
let response = ObjectResponse::default();
if let Some(old_key) = old_key {
if key.key == old_key.key {
return Ok(Ok(response));
}
} else {
// Validate quotas
let num_keys = set
.server
.registry()
.query::<RegistryObjectCounter>(
RegistryQuery::new(ObjectType::PublicKey).with_account(set.account_id),
)
.await?
.0 as u32;
let account = set.server.account(set.account_id).await?;
let key_quota = set
.server
.object_quota(account.object_quotas(), StorageQuota::MaxPublicKeys);
if num_keys >= key_quota {
return Ok(Err(SetError::over_quota().with_description(format!(
"You have exceeded your quota of {} public keys.",
key_quota
))));
}
}
if !key.key.ends_with('\n') {
key.key.push('\n');
}
match parse_public_key(key) {
Ok(Some(_)) => Ok(Ok(response)),
Ok(None) => Ok(Err(SetError::invalid_properties()
.with_property(Property::Key)
.with_description("No valid public key found."))),
Err(err) => Ok(Err(SetError::invalid_properties()
.with_property(Property::Key)
.with_description(err.into_owned()))),
}
}
@@ -0,0 +1,762 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
api::query::QueryResponseBuilder,
registry::{
mapping::{RegistryGetResponse, RegistryQueryResponse, RegistrySetResponse},
query::RegistryQueryFilters,
},
};
use common::{
Server,
config::smtp::queue::{ArchivedQueueExpiry, QueueName},
ipc::QueueEvent,
};
use jmap_proto::{error::set::SetError, object::registry::RegistryComparator, types::state::State};
use jmap_tools::{JsonPointer, JsonPointerItem, Key};
use registry::{
jmap::{IntoValue, JsonPointerPatch, RegistryJsonPatch},
schema::{
enums::{DeliveryErrorType, MessageFlag, RecipientFlag},
prelude::{ObjectType, Property},
structs::{
DeliveryError, QueueExpiry, QueueExpiryAttempts, QueueExpiryTtl, QueuedMessage,
QueuedRecipient, RecipientStatus, ServerResponse,
},
},
types::{datetime::UTCDateTime, ipaddr::IpAddr, map::Map},
};
use smtp::queue::{
self, ArchivedError, ArchivedErrorDetails, ArchivedMessage, ArchivedStatus, ErrorDetails,
FROM_AUTHENTICATED, FROM_AUTOGENERATED, FROM_DSN, FROM_REPORT, FROM_UNAUTHENTICATED,
FROM_UNAUTHENTICATED_DMARC, Message, MessageWrapper, RCPT_DSN_SENT, Schedule, Status,
rcpt_spam_percentage, spool::SmtpSpool,
};
use std::str::FromStr;
use store::{
Deserialize, IterateParams, U64_LEN, ValueKey,
ahash::AHashSet,
registry::{RegistryFilterOp, RegistryQuery},
write::{AlignedBytes, Archive, QueueClass, ValueClass, key::DeserializeBigEndian, now},
};
use trc::AddContext;
use types::{blob::BlobId, blob_hash::BlobHash, id::Id};
use utils::{DomainPart, map::vec_map::VecMap};
pub(crate) async fn queued_message_set(
mut set: RegistrySetResponse<'_>,
) -> trc::Result<RegistrySetResponse<'_>> {
// Fail all create operations
set.fail_all_create("Queued messages cannot be created");
// Obtain tenant domains
let tenant_domains = if let Some(tenant_id) = set.access_token.tenant_id() {
Some(tenant_domains(set.server, tenant_id).await?)
} else {
None
};
// Process update operations
let mut refresh_queue = false;
'outer: for (id, value) in set.update.drain(..) {
let queue_id = id.id();
let Some(archive) = set.server.read_message_archive(queue_id).await? else {
set.response.not_updated.append(id, SetError::not_found());
continue;
};
let archived_message = archive.to_unarchived::<Message>()?;
if !tenant_domains.as_ref().is_none_or(|domains| {
archived_message
.inner
.return_path
.try_domain_part()
.is_some_and(|domain| domains.contains(domain))
}) {
set.response.not_updated.append(id, SetError::not_found());
continue;
}
// Process patches
let mut message = map_message(archived_message.inner);
message.next_retry = None;
for (key, value) in value.into_expanded_object() {
let ptr = match key {
Key::Property(prop) => {
JsonPointer::new(vec![JsonPointerItem::Key(Key::Property(prop))])
}
Key::Borrowed(other) => JsonPointer::parse(other),
Key::Owned(other) => JsonPointer::parse(&other),
};
if let Err(err) = message.patch(JsonPointerPatch::new(&ptr).with_create(false), value) {
set.response.not_updated.append(id, err.into());
continue 'outer;
}
}
let set_next_retry = message.next_retry;
// Process changes
let mut has_changes = false;
let mut modified_rcpts = AHashSet::new();
let mut queued_message = archived_message.deserialize()?;
let prev_events = queued_message.next_events();
if queued_message.env_id.as_deref() != message.env_id.as_deref() {
queued_message.env_id = message.env_id.as_deref().map(|v| v.into());
has_changes = true;
}
if queued_message.priority as i64 != message.priority {
queued_message.priority = message.priority as i16;
has_changes = true;
}
for (idx, rcpt) in queued_message.recipients.iter_mut().enumerate() {
if !message
.recipients
.iter()
.any(|(address, _)| address.as_str() == rcpt.address.as_ref())
{
rcpt.status = Status::PermanentFailure(ErrorDetails {
entity: "localhost".into(),
details: queue::Error::Io("Delivery canceled.".into()),
});
has_changes = true;
modified_rcpts.insert(idx);
}
}
for (address, rcpt) in message.recipients.into_iter() {
let Some((idx, queued_rcpt)) = queued_message
.recipients
.iter_mut()
.enumerate()
.find(|(_, r)| r.address.as_ref() == address.as_str())
else {
set.response.not_updated.append(
id,
SetError::invalid_properties()
.with_description(format!("Recipient '{address}' does not exist")),
);
continue 'outer;
};
let mut changed = false;
if rcpt.orcpt.as_deref() != queued_rcpt.orcpt.as_deref() {
queued_rcpt.orcpt = rcpt.orcpt.as_deref().map(|v| v.into());
changed = true;
}
let expiry = match rcpt.expires {
QueueExpiry::Ttl(ttl) => common::config::smtp::queue::QueueExpiry::Ttl(
(ttl.expires_at.timestamp() as u64).saturating_sub(queued_message.created),
),
QueueExpiry::Attempts(attempts) => {
common::config::smtp::queue::QueueExpiry::Attempts(
attempts.expires_attempts as u32,
)
}
};
if expiry != queued_rcpt.expires {
queued_rcpt.expires = expiry;
changed = true;
}
for (due, count, field) in [
(rcpt.retry_due, rcpt.retry_count, &mut queued_rcpt.retry),
(rcpt.notify_due, rcpt.notify_count, &mut queued_rcpt.notify),
] {
let schedule = Schedule {
due: due.timestamp() as u64,
inner: count as u32,
};
if schedule != *field {
*field = schedule;
changed = true;
}
}
if let Some(next_retry) = set_next_retry
&& !matches!(queued_rcpt.status, Status::PermanentFailure(_))
{
let new_due = next_retry.timestamp() as u64;
if queued_rcpt.retry.due != new_due {
queued_rcpt.retry.due = new_due;
changed = true;
}
}
if matches!(rcpt.status, RecipientStatus::Scheduled)
&& !matches!(queued_rcpt.status, Status::Scheduled)
{
queued_rcpt.status = Status::Scheduled;
changed = true;
}
if changed {
has_changes = true;
modified_rcpts.insert(idx);
}
}
if has_changes {
// Delete message if there are no pending deliveries
let message = MessageWrapper::new(queued_message, queue_id, QueueName::default());
let is_success = if message.message.recipients.iter().any(|recipient| {
matches!(
recipient.status,
Status::TemporaryFailure(_) | Status::Scheduled
)
}) {
message
.save_registry_changes(set.server, prev_events, modified_rcpts)
.await
} else {
message.remove_registry(set.server, prev_events).await
};
if !is_success {
set.response.not_updated.append(
id,
SetError::forbidden().with_description("Queue update operation failed"),
);
continue;
}
refresh_queue = true;
}
set.response.updated.append(id, None);
}
if refresh_queue {
let _ = set
.server
.inner
.ipc
.queue_tx
.send(QueueEvent::Refresh)
.await;
}
// Process destroy operations
for id in set.destroy.drain(..) {
let Some(message) = set.server.read_message(id.id(), QueueName::default()).await else {
set.response.not_destroyed.append(id, SetError::not_found());
continue;
};
if tenant_domains.as_ref().is_none_or(|domains| {
message
.message
.return_path
.try_domain_part()
.is_some_and(|domain| domains.contains(domain))
}) {
if message.remove(set.server, None).await {
set.response.destroyed.push(id);
} else {
set.response.not_destroyed.append(
id,
SetError::forbidden().with_description("Queue delete operation failed"),
);
}
} else {
set.response.not_destroyed.append(id, SetError::not_found());
}
}
Ok(set)
}
pub(crate) async fn queued_message_get(
mut get: RegistryGetResponse<'_>,
) -> trc::Result<RegistryGetResponse<'_>> {
let client_ids = get.ids.is_some();
let ids = if let Some(ids) = get.ids.take() {
ids
} else {
queued_ids(get.server, get.server.core.jmap.get_max_objects)
.await?
.into_iter()
.map(Id::from)
.collect()
};
// Obtain tenant domains
let tenant_domains = if let Some(tenant_id) = get.access_token.tenant_id() {
Some(tenant_domains(get.server, tenant_id).await?)
} else {
None
};
for id in ids {
let Some(message_archive) = get.server.read_message_archive(id.id()).await? else {
if client_ids {
get.not_found(id);
}
continue;
};
let message_in = message_archive.unarchive::<Message>()?;
if tenant_domains.as_ref().is_none_or(|domains| {
message_in
.return_path
.try_domain_part()
.is_some_and(|domain| domains.contains(domain))
}) {
get.insert(id, map_message(message_in).into_value());
} else if client_ids {
get.not_found(id);
}
}
Ok(get)
}
pub(crate) async fn queued_message_query(
mut req: RegistryQueryResponse<'_>,
) -> trc::Result<QueryResponseBuilder> {
let mut due_from = 0u64;
let mut due_to = u64::MAX;
let mut queue_name = None;
let mut filter_text = None;
let mut filter_from = None;
let mut filter_to = None;
// Obtain tenant domains
let tenant_domains = if let Some(tenant_id) = req.access_token.tenant_id() {
Some(tenant_domains(req.server, tenant_id).await?)
} else {
None
};
req.request
.extract_filters(|property, op, value| match property {
Property::Due => {
if let Some(due) = value.as_str().and_then(|s| UTCDateTime::from_str(s).ok()) {
let due = due.timestamp() as u64;
let (from, to) = match op {
RegistryFilterOp::Equal => (due, due),
RegistryFilterOp::GreaterThan => (due + 1, u64::MAX),
RegistryFilterOp::GreaterEqualThan => (due, u64::MAX),
RegistryFilterOp::LowerThan => (0, due - 1),
RegistryFilterOp::LowerEqualThan => (0, due),
_ => return false,
};
// Intersect with existing range
due_from = due_from.max(from);
due_to = due_to.min(to);
due_from <= due_to
} else {
false
}
}
Property::QueueName => {
if let Some(value) = value.as_str().and_then(QueueName::new) {
queue_name = Some(value);
true
} else {
false
}
}
Property::ReturnPath => {
if let serde_json::Value::String(name) = value {
filter_from = Some(name);
true
} else {
false
}
}
Property::To => {
if let serde_json::Value::String(name) = value {
filter_to = Some(name);
true
} else {
false
}
}
Property::Text => {
if let serde_json::Value::String(name) = value {
filter_text = Some(name);
true
} else {
false
}
}
_ => false,
})?;
if req
.request
.sort
.as_ref()
.and_then(|sort| sort.first())
.is_some_and(|comp| !matches!(comp.property, RegistryComparator::Property(Property::Due)))
{
return Err(trc::JmapEvent::UnsupportedSort
.into_err()
.details("Only sorting by 'due' is supported for queued messages".to_string()));
}
let params = req
.request
.extract_parameters(req.server.core.jmap.query_max_results, None)?;
let has_filters = filter_text.is_some() || filter_from.is_some() || filter_to.is_some();
if has_filters || tenant_domains.is_some() {
let from_key = ValueKey::from(ValueClass::Queue(QueueClass::Message(0)));
let to_key = ValueKey::from(ValueClass::Queue(QueueClass::Message(u64::MAX)));
let mut results = Vec::with_capacity(8);
req.server
.core
.storage
.data
.iterate(
IterateParams::new(from_key, to_key).ascending(),
|key, value| {
let message_ = <Archive<AlignedBytes> as Deserialize>::deserialize(value)
.add_context(|ctx| ctx.ctx(trc::Key::Key, key))?;
let message = message_
.unarchive::<queue::Message>()
.add_context(|ctx| ctx.ctx(trc::Key::Key, key))?;
if let Some(due) = message.next_delivery_event(queue_name)
&& tenant_domains
.as_ref()
.is_none_or(|domains| message.has_domain(domains))
&& (due_from..=due_to).contains(&due)
&& queue_name
.as_ref()
.is_none_or(|q| message.recipients.iter().any(|r| &r.queue == q))
&& (!has_filters
|| (filter_text
.as_ref()
.map(|text| {
message.return_path.contains(text)
|| message
.recipients
.iter()
.any(|r| r.address().contains(text))
})
.unwrap_or_else(|| {
filter_from
.as_ref()
.is_none_or(|from| message.return_path.contains(from))
&& filter_to.as_ref().is_none_or(|to| {
message
.recipients
.iter()
.any(|r| r.address().contains(to))
})
})))
{
results.push((key.deserialize_be_u64(0)?, due));
}
Ok(true)
},
)
.await
.caused_by(trc::location!())?;
// Build response
let mut response = QueryResponseBuilder::new(
results.len(),
req.server.core.jmap.query_max_results,
State::Initial,
&req.request,
);
if params.sort_ascending {
results.sort_by_key(|(_, due)| *due);
} else {
results.sort_by_key(|(_, due)| u64::MAX - *due);
}
for (id, _) in results {
if !response.add_id(id.into()) {
break;
}
}
Ok(response)
} else {
// Build response
let mut response = QueryResponseBuilder::new(
req.server.core.jmap.query_max_results,
req.server.core.jmap.query_max_results,
State::Initial,
&req.request,
);
let mut total = 0;
if let Some(anchor) = req.request.anchor {
let anchor_id = anchor.id();
if let Some(archive) = req.server.read_message_archive(anchor_id).await?
&& let Ok(archived) = archive.unarchive::<Message>()
&& let Some(anchor_due) = archived.next_delivery_event(queue_name)
&& anchor_due >= due_from
&& anchor_due <= due_to
{
if params.sort_ascending {
due_from = anchor_due;
} else {
due_to = anchor_due;
}
}
}
let from_key = ValueKey::from(ValueClass::Queue(QueueClass::MessageEvent(
store::write::QueueEvent {
due: due_from,
queue_id: 0,
queue_name: [0; 8],
},
)));
let to_key = ValueKey::from(ValueClass::Queue(QueueClass::MessageEvent(
store::write::QueueEvent {
due: due_to,
queue_id: u64::MAX,
queue_name: [u8::MAX; 8],
},
)));
let mut seen_ids = AHashSet::with_capacity(8);
req.server
.store()
.iterate(
IterateParams::new(from_key, to_key)
.set_ascending(params.sort_ascending)
.no_values(),
|key, _| {
let id = key.deserialize_be_u64(U64_LEN)?;
if queue_name.is_none_or(|queue_name| {
queue_name.as_slice() == key.get(U64_LEN * 2..).unwrap_or_default()
}) && seen_ids.insert(id)
{
total += 1;
if response.response.total.is_some() {
if !response.is_full() {
response.add_id(id.into());
}
Ok(true)
} else {
Ok(response.add_id(id.into()))
}
} else {
Ok(true)
}
},
)
.await
.caused_by(trc::location!())?;
if response.response.total.is_some() {
response.response.total = Some(total);
}
if let Some(limit) = response.response.limit
&& total < limit
{
response.response.limit = None;
}
Ok(response)
}
}
#[cfg(not(feature = "enterprise"))]
async fn tenant_domains(_server: &Server, _tenant_id: u32) -> trc::Result<AHashSet<String>> {
Ok(AHashSet::new())
}
fn map_message(message_in: &ArchivedMessage) -> QueuedMessage {
let mut message_out = QueuedMessage {
blob_id: BlobId::new(BlobHash::from(&message_in.blob_hash), Default::default()),
created_at: UTCDateTime::from_timestamp(message_in.created.to_native() as i64),
env_id: message_in.env_id.as_ref().map(|v| v.to_string()),
flags: Map::with_capacity(1),
priority: message_in.priority.to_native() as i64,
received_from_ip: IpAddr(message_in.received_from_ip.as_ipaddr()),
received_via_port: message_in.received_via_port.to_native() as u64,
recipients: VecMap::with_capacity(message_in.recipients.len()),
return_path: if !message_in.return_path.is_empty() {
message_in.return_path.to_string()
} else {
"<>".to_string()
},
size: message_in.size.to_native(),
next_retry: UTCDateTime::from_timestamp(
message_in
.next_delivery_event(None)
.unwrap_or_else(now)
.cast_signed(),
)
.into(),
next_notify: message_in
.next_notify_event(None)
.map(|ts| UTCDateTime::from_timestamp(ts.cast_signed())),
};
// Parse flags
let flags = message_in.flags.to_native();
for (bit, flag) in [
(FROM_AUTHENTICATED, MessageFlag::Authenticated),
(FROM_UNAUTHENTICATED, MessageFlag::Unauthenticated),
(
FROM_UNAUTHENTICATED_DMARC,
MessageFlag::UnauthenticatedDmarc,
),
(FROM_DSN, MessageFlag::Dsn),
(FROM_REPORT, MessageFlag::Report),
(FROM_AUTOGENERATED, MessageFlag::Autogenerated),
] {
if flags & bit != 0 {
message_out.flags.push(flag);
}
}
// Parse recipients
for rcpt_in in message_in.recipients.iter() {
let mut rcpt_out = QueuedRecipient {
expires: match &rcpt_in.expires {
ArchivedQueueExpiry::Ttl(ttl) => QueueExpiry::Ttl(QueueExpiryTtl {
expires_at: UTCDateTime::from_timestamp(
message_in.created.to_native() as i64 + ttl.to_native() as i64,
),
}),
ArchivedQueueExpiry::Attempts(attempts) => {
QueueExpiry::Attempts(QueueExpiryAttempts {
expires_attempts: attempts.to_native() as u64,
})
}
},
flags: Default::default(),
notify_count: rcpt_in.notify.inner.to_native() as u64,
notify_due: UTCDateTime::from_timestamp(rcpt_in.notify.due.to_native() as i64),
orcpt: rcpt_in.orcpt.as_ref().map(|v| v.to_string()),
queue_name: rcpt_in.queue.as_str().to_string(),
retry_count: rcpt_in.retry.inner.to_native() as u64,
retry_due: UTCDateTime::from_timestamp(rcpt_in.retry.due.to_native() as i64),
status: match &rcpt_in.status {
ArchivedStatus::Scheduled => RecipientStatus::Scheduled,
ArchivedStatus::Completed(status) => RecipientStatus::Completed(ServerResponse {
response_code: (status.response.code.to_native() as u64).into(),
response_enhanced: build_enhanced_code(&status.response.esc).into(),
response_hostname: status.hostname.to_string().into(),
response_message: status.response.message.to_string().into(),
}),
ArchivedStatus::TemporaryFailure(status) => {
RecipientStatus::TemporaryFailure(map_error_details(status))
}
ArchivedStatus::PermanentFailure(status) => {
RecipientStatus::PermanentFailure(map_error_details(status))
}
},
};
// Parse recipient flags
let rcpt_flags = rcpt_in.flags.to_native();
for (bit, flag) in [(RCPT_DSN_SENT, RecipientFlag::DsnSent)] {
if rcpt_flags & bit != 0 {
rcpt_out.flags.push(flag);
}
}
if rcpt_spam_percentage(rcpt_flags).is_some_and(|percentage| percentage >= 50) {
rcpt_out.flags.push(RecipientFlag::SpamPayload);
}
message_out
.recipients
.append(rcpt_in.address.to_string(), rcpt_out);
}
message_out
}
fn map_error_details(err_in: &ArchivedErrorDetails) -> DeliveryError {
let mut err_out = DeliveryError {
response_hostname: err_in.entity.to_string().into(),
..Default::default()
};
match &err_in.details {
ArchivedError::DnsError(e) => {
err_out.error_type = DeliveryErrorType::DnsError;
err_out.error_message = e.to_string().into();
}
ArchivedError::UnexpectedResponse(e) => {
err_out.error_type = DeliveryErrorType::UnexpectedResponse;
err_out.error_command = e.command.to_string().into();
err_out.response_code = (e.response.code.to_native() as u64).into();
err_out.response_enhanced = build_enhanced_code(&e.response.esc).into();
err_out.response_message = e.response.message.to_string().into();
}
ArchivedError::ConnectionError(e) => {
err_out.error_type = DeliveryErrorType::ConnectionError;
err_out.error_message = e.to_string().into();
}
ArchivedError::TlsError(e) => {
err_out.error_type = DeliveryErrorType::TlsError;
err_out.error_message = e.to_string().into();
}
ArchivedError::DaneError(e) => {
err_out.error_type = DeliveryErrorType::DaneError;
err_out.error_message = e.to_string().into();
}
ArchivedError::MtaStsError(e) => {
err_out.error_type = DeliveryErrorType::MtaStsError;
err_out.error_message = e.to_string().into();
}
ArchivedError::RateLimited => {
err_out.error_type = DeliveryErrorType::RateLimited;
}
ArchivedError::ConcurrencyLimited => {
err_out.error_type = DeliveryErrorType::ConcurrencyLimited;
}
ArchivedError::Io(e) => {
err_out.error_type = DeliveryErrorType::Io;
err_out.error_message = e.to_string().into();
}
}
err_out
}
fn build_enhanced_code(esc: &[u8; 3]) -> String {
format!("{}.{}.{}", esc[0], esc[1], esc[2])
}
async fn queued_ids(server: &Server, max_results: usize) -> trc::Result<AHashSet<u64>> {
let mut events = AHashSet::with_capacity(8);
let from_key = ValueKey::from(ValueClass::Queue(QueueClass::MessageEvent(
store::write::QueueEvent {
due: 0,
queue_id: 0,
queue_name: [0; 8],
},
)));
let to_key = ValueKey::from(ValueClass::Queue(QueueClass::MessageEvent(
store::write::QueueEvent {
due: u64::MAX,
queue_id: u64::MAX,
queue_name: [u8::MAX; 8],
},
)));
server
.store()
.iterate(
IterateParams::new(from_key, to_key).ascending().no_values(),
|key, _| {
events.insert(key.deserialize_be_u64(U64_LEN)?);
Ok(events.len() < max_results)
},
)
.await
.caused_by(trc::location!())
.map(|_| events)
}
+421
View File
@@ -0,0 +1,421 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
api::query::QueryResponseBuilder,
registry::{
mapping::{RegistryGetResponse, RegistryQueryResponse, RegistrySetResponse},
query::RegistryQueryFilters,
},
};
use jmap_proto::{error::set::SetError, types::state::State};
use jmap_tools::{Key, Value};
use registry::{
jmap::IntoValue,
schema::prelude::{Object, ObjectInner, ObjectType, Property},
types::{EnumImpl, datetime::UTCDateTime},
};
use smtp::reporting::index::{ExternalReportIndex, InternalReportIndex};
use std::str::FromStr;
use store::{
U64_LEN, ValueKey,
registry::{RegistryFilter, RegistryFilterValue, RegistryQuery},
write::{BatchBuilder, RegistryClass, ValueClass, key::KeySerializer},
};
use trc::AddContext;
use types::id::Id;
pub(crate) async fn report_set(
mut set: RegistrySetResponse<'_>,
) -> trc::Result<RegistrySetResponse<'_>> {
let object_id = set.object_type.to_id();
// Reports cannot be created
set.fail_all_create("Reports cannot be created");
let mut batch = BatchBuilder::new();
if matches!(
set.object_type,
ObjectType::DmarcInternalReport | ObjectType::TlsInternalReport
) {
let now = UTCDateTime::now();
'outer: for (id, value) in set.update.drain(..) {
// Extract new deliverAt value
let mut deliver_at = None;
for (key, value) in value.into_expanded_object() {
match (key, value) {
(Key::Property(Property::DeliverAt), Value::Str(deliver_at_)) => {
deliver_at = UTCDateTime::from_str(deliver_at_.as_ref())
.ok()
.filter(|da| *da > now);
if deliver_at.is_none() {
set.response.not_updated.append(
id,
SetError::invalid_patch()
.with_property(Property::DeliverAt)
.with_description("Invalid value for property"),
);
continue 'outer;
}
}
(Key::Property(Property::Id), _) => {}
(key, _) => {
set.response.not_updated.append(
id,
SetError::invalid_properties().with_property(key.into_owned()),
);
continue 'outer;
}
}
}
let Some(deliver_at) = deliver_at else {
set.response.not_updated.append(
id,
SetError::invalid_patch()
.with_property(Key::Property(Property::DeliverAt))
.with_description("Missing required property"),
);
continue;
};
let item_id = id.id();
let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id });
if let Some(mut report_obj) = set
.server
.store()
.get_value::<Object>(ValueKey::from(key.clone()))
.await?
{
match &mut report_obj.inner {
ObjectInner::DmarcInternalReport(report) => {
report.reschedule_ops(&mut batch, item_id, report_obj.revision, deliver_at);
}
ObjectInner::TlsInternalReport(report) => {
report.reschedule_ops(&mut batch, item_id, report_obj.revision, deliver_at);
}
_ => {}
}
batch.commit_point();
set.response.updated.append(id, None);
} else {
set.response.not_updated.append(id, SetError::not_found());
}
}
} else {
// External reports cannot be updated
set.fail_all_update("External reports cannot be updated");
}
// Process reports to destroy
let tenant_id = set.access_token.tenant_id().map(Id::from);
for id in set.destroy.drain(..) {
let item_id = id.id();
let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id });
if let Some(report) = set
.server
.store()
.get_value::<Object>(ValueKey::from(key))
.await?
.filter(|report| {
!set.is_tenant_filtered || report.inner.member_tenant_id() == tenant_id
})
{
match &report.inner {
ObjectInner::DmarcExternalReport(report) => {
report.write_ops(&mut batch, item_id, false);
}
ObjectInner::TlsExternalReport(report) => {
report.write_ops(&mut batch, item_id, false);
}
ObjectInner::ArfExternalReport(report) => {
report.write_ops(&mut batch, item_id, false);
}
ObjectInner::DmarcInternalReport(report) => {
report.write_ops(&mut batch, item_id, false);
}
ObjectInner::TlsInternalReport(report) => {
report.write_ops(&mut batch, item_id, false);
}
_ => {}
}
batch.commit_point();
set.response.destroyed.push(id);
} else {
set.response.not_destroyed.append(id, SetError::not_found());
}
}
if !batch.is_empty() {
set.server
.store()
.write(batch.build_all())
.await
.caused_by(trc::location!())?;
}
Ok(set)
}
pub(crate) async fn report_get(
mut get: RegistryGetResponse<'_>,
) -> trc::Result<RegistryGetResponse<'_>> {
let object_id = get.object_type.to_id();
let ids = if let Some(ids) = get.ids.take() {
ids
} else if matches!(
get.object_type,
ObjectType::DmarcExternalReport
| ObjectType::TlsExternalReport
| ObjectType::ArfExternalReport
) {
if get.is_tenant_filtered {
get.server.registry().query::<Vec<Id>>(
RegistryQuery::new(get.object_type)
.with_tenant(get.access_token.tenant_id())
.with_limit(get.server.core.jmap.get_max_objects),
)
} else {
get.server.registry().query::<Vec<Id>>(
RegistryQuery::new(get.object_type)
.greater_than(Property::ExpiresAt, 0u64)
.with_limit(get.server.core.jmap.get_max_objects),
)
}
.await?
} else {
get.server
.registry()
.query::<Vec<Id>>(
RegistryQuery::new(get.object_type)
.filter(RegistryFilter::greater_than(
Property::Domain,
RegistryFilterValue::Bytes(vec![]),
true,
))
.with_limit(get.server.core.jmap.get_max_objects),
)
.await?
};
let tenant_id = get.access_token.tenant_id().map(Id::from);
for id in ids {
if let Some(report) = get
.server
.store()
.get_value::<Object>(ValueKey::from(ValueClass::Registry(RegistryClass::Item {
object_id,
item_id: id.id(),
})))
.await?
.filter(|report| {
!get.is_tenant_filtered || report.inner.member_tenant_id() == tenant_id
})
{
get.insert(id, report.into_value());
} else {
get.not_found(id);
}
}
Ok(get)
}
pub(crate) async fn report_query(
mut req: RegistryQueryResponse<'_>,
) -> trc::Result<QueryResponseBuilder> {
let mut query = store::registry::RegistryQuery::new(req.object_type)
.with_tenant(req.access_token.tenant_id());
let is_internal = matches!(
req.object_type,
ObjectType::DmarcInternalReport | ObjectType::TlsInternalReport
);
req.request
.extract_filters(|property, op, value| match property {
Property::Domain => {
if let serde_json::Value::String(value) = value {
match req.object_type {
ObjectType::DmarcInternalReport => {
query.filters.push(RegistryFilter::greater_than_or_equal(
property,
RegistryFilterValue::Bytes(
KeySerializer::new(value.len() + U64_LEN)
.write(value.as_str())
.write(0u64)
.finalize(),
),
true,
));
query.filters.push(RegistryFilter::less_than_or_equal(
property,
RegistryFilterValue::Bytes(
KeySerializer::new(value.len() + U64_LEN)
.write(value.as_str())
.write(u64::MAX)
.finalize(),
),
true,
));
true
}
ObjectType::TlsInternalReport => {
query
.filters
.push(RegistryFilter::equal(property, value, true));
true
}
_ => false,
}
} else {
false
}
}
Property::Text if !is_internal => {
if let serde_json::Value::String(value) = value {
query.filters.push(RegistryFilter::text(property, value));
true
} else {
false
}
}
Property::MemberTenantId if !is_internal => {
if req.access_token.tenant_id().is_none()
&& let Some(id) = value.as_str().and_then(|s| Id::from_str(s).ok())
{
query
.filters
.push(RegistryFilter::equal(property, id.id(), false));
true
} else {
false
}
}
Property::TotalFailedSessions | Property::TotalSuccessfulSessions if !is_internal => {
if let Some(value) = value.as_u64() {
query.filters.push(store::registry::RegistryFilter {
property,
op,
value: value.into(),
is_pk: false,
});
true
} else {
false
}
}
Property::ExpiresAt if !is_internal => {
if let Some(value) = value
.as_str()
.and_then(|value| UTCDateTime::from_str(value).ok())
{
query.filters.push(store::registry::RegistryFilter {
property,
op,
value: (value.timestamp() as u64).into(),
is_pk: false,
});
true
} else {
false
}
}
_ => false,
})?;
let params = req
.request
.extract_parameters(req.server.core.jmap.query_max_results, Some(Property::Id))?;
if !query.has_filters() {
if is_internal {
query.filters.push(RegistryFilter::greater_than(
Property::Domain,
RegistryFilterValue::Bytes(vec![]),
true,
));
} else {
query.filters.push(RegistryFilter::greater_than(
Property::ExpiresAt,
0u64,
false,
));
}
}
if let Some(limit) = params.limit {
query = query.with_limit(limit);
if let Some(anchor) = params.anchor {
query = query.with_anchor(anchor);
} else if let Some(position) = params.position {
query = query.with_index_start(position);
}
}
let matches = req.server.registry().query::<Vec<Id>>(query).await?;
let results = match params.sort_by {
Property::Id => {
let mut results = matches;
if !params.sort_ascending {
results.sort_unstable_by(|a, b| b.cmp(a));
}
results
}
Property::Domain if is_internal => {
if !matches.is_empty() {
req.server
.registry()
.sort_by_pk(
req.object_type,
Property::Domain,
Some(matches),
params.sort_ascending,
)
.await?
} else {
vec![]
}
}
Property::ExpiresAt if !is_internal => {
if !matches.is_empty() {
req.server
.registry()
.sort_by_index(
req.object_type,
Property::ExpiresAt,
Some(matches),
params.sort_ascending,
)
.await?
} else {
vec![]
}
}
property => {
return Err(trc::JmapEvent::UnsupportedSort.into_err().details(format!(
"Property {} is not supported for sorting",
property
)));
}
};
// Build response
let mut response = QueryResponseBuilder::new(
results.len(),
req.server.core.jmap.query_max_results,
State::Initial,
&req.request,
);
for id in results {
if !response.add_id(id) {
break;
}
}
Ok(response)
}
+49
View File
@@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::registry::mapping::{ObjectResponse, ValidationResult};
use common::Server;
use jmap_proto::error::set::SetError;
use registry::schema::prelude::Property;
pub(crate) async fn validate_sieve_script(
server: &Server,
script: &str,
old_script: Option<&str>,
is_system_script: bool,
) -> ValidationResult {
if old_script.is_none_or(|old_script| old_script != script) {
if is_system_script {
if let Err(err) = server
.core
.sieve
.trusted_compiler
.compile(script.as_bytes())
{
return Ok(Err(SetError::invalid_properties()
.with_property(Property::Contents)
.with_description(format!(
"Failed to compile system Sieve script: {err}"
))));
}
} else {
if let Err(err) = server
.core
.sieve
.untrusted_compiler
.compile(script.as_bytes())
{
return Ok(Err(SetError::invalid_properties()
.with_property(Property::Contents)
.with_description(format!(
"Failed to compile user Sieve script: {err}"
))));
}
}
}
Ok(Ok(ObjectResponse::default()))
}
@@ -0,0 +1,360 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
api::query::QueryResponseBuilder,
blob::download::BlobDownload,
registry::{
mapping::{RegistryGetResponse, RegistryQueryResponse, RegistrySetResponse},
query::RegistryQueryFilters,
},
};
use jmap_proto::{error::set::SetError, types::state::State};
use jmap_tools::JsonPointer;
use mail_parser::{MessageParser, parsers::fields::thread::thread_name};
use registry::{
jmap::{IntoValue, JsonPointerPatch, RegistryJsonPatch},
schema::{
enums::Permission,
prelude::{ObjectType, Property},
structs::SpamTrainingSample,
},
types::{EnumImpl, ObjectImpl, datetime::UTCDateTime, id::ObjectId},
};
use std::str::FromStr;
use store::{
SerializeInfallible, ValueKey,
registry::RegistryQuery,
write::{BatchBuilder, BlobLink, BlobOp, RegistryClass, ValueClass, now},
};
use trc::AddContext;
use types::{blob::BlobClass, id::Id};
pub(crate) async fn spam_sample_set(
mut set: RegistrySetResponse<'_>,
) -> trc::Result<RegistrySetResponse<'_>> {
// Spam samples cannot be modified
set.fail_all_update("Spam training samples cannot be modified.");
let mut batch = BatchBuilder::new();
let object_id = set.object_type.to_id();
// Process samples to create
let hold_samples_for = set
.server
.core
.spam
.classifier
.as_ref()
.map(|config| config.hold_samples_for);
let now = now();
'outer: for (id, value) in set.create.drain() {
let mut sample = SpamTrainingSample::default();
let Some(expires_at) = hold_samples_for.map(|d| now + d) else {
set.response.not_created.append(
id,
SetError::forbidden()
.with_description("Spam classifier is not configured on the server"),
);
continue;
};
if let Err(err) = sample.patch(
JsonPointerPatch::new(&JsonPointer::new(vec![]))
.with_create(true)
.with_can_set_account(!set.is_account_filtered),
value,
) {
set.response.not_created.append(id, err.into());
continue 'outer;
};
if sample.blob_id.hash.is_empty() {
set.response.not_created.append(
id,
SetError::invalid_properties()
.with_property(Property::BlobId)
.with_description("blobId is required"),
);
continue;
}
let Some(bytes) = set
.server
.blob_download(&sample.blob_id, set.access_token)
.await?
else {
set.response.not_created.append(
id,
SetError::invalid_properties()
.with_property(Property::BlobId)
.with_description("blobId does not exist or is not accessible"),
);
continue;
};
if bytes.len() > set.server.core.email.mail_max_size {
set.response.not_created.append(
id,
SetError::invalid_properties()
.with_property(Property::BlobId)
.with_description(format!(
"blob size exceeds maximum of {} bytes",
set.server.core.email.mail_max_size
)),
);
continue;
}
let Some(message) = MessageParser::new().parse(&bytes) else {
set.response.not_created.append(
id,
SetError::invalid_properties()
.with_property(Property::BlobId)
.with_description("Blob content is not a valid email message"),
);
continue;
};
let subject = message.subject().map(thread_name).unwrap_or_default();
let from = message
.from()
.and_then(|from| from.first().and_then(|addr| addr.address()))
.unwrap_or_default();
if subject.is_empty() && from.is_empty() {
set.response.not_created.append(
id,
SetError::invalid_properties()
.with_property(Property::BlobId)
.with_description("Email message must have a subject or a from header"),
);
continue;
}
sample.subject = subject.to_string();
sample.from = from.to_lowercase();
sample.expires_at = UTCDateTime::from_timestamp(expires_at as i64);
if set.is_account_filtered {
sample.account_id = Some(set.account_id.into());
}
// Write sample to store
let item_id = set.server.registry().assign_id();
batch
.set(
BlobOp::Link {
hash: sample.blob_id.hash.clone(),
to: BlobLink::Temporary { until: expires_at },
},
ObjectId::new(ObjectType::SpamTrainingSample, item_id.into()).serialize(),
)
.set(
ValueClass::Registry(RegistryClass::Index {
index_id: Property::AccountId.to_id(),
object_id,
item_id,
key: sample
.account_id
.map(|id| id.id())
.unwrap_or(u32::MAX as u64)
.serialize(),
}),
vec![],
)
.set(
ValueClass::Registry(RegistryClass::Item { object_id, item_id }),
sample.to_pickled_vec(),
);
set.response.created(id, item_id);
}
// Process samples to destroy
for id in set.destroy.drain(..) {
let item_id = id.id();
if let Some(sample) = set
.server
.store()
.get_value::<SpamTrainingSample>(ValueKey::from(ValueClass::Registry(
RegistryClass::Item {
object_id,
item_id: id.id(),
},
)))
.await?
.filter(|sample| {
!set.is_account_filtered
|| sample
.account_id
.is_some_and(|account_id| account_id.document_id() == set.account_id)
})
{
let account_id = sample
.account_id
.map(|id| id.document_id())
.unwrap_or(u32::MAX);
batch
.with_account_id(account_id)
.clear(BlobOp::Link {
hash: sample.blob_id.hash,
to: BlobLink::Temporary {
until: sample.expires_at.timestamp() as u64,
},
})
.clear(ValueClass::Registry(RegistryClass::Item {
object_id,
item_id,
}))
.clear(ValueClass::Registry(RegistryClass::Index {
index_id: Property::AccountId.to_id(),
object_id,
item_id,
key: (account_id as u64).serialize(),
}))
.commit_point();
set.response.destroyed.push(id);
} else {
set.response.not_destroyed.append(id, SetError::not_found());
}
}
if !batch.is_empty() {
set.server
.store()
.write(batch.build_all())
.await
.caused_by(trc::location!())?;
}
Ok(set)
}
pub(crate) async fn spam_sample_get(
mut get: RegistryGetResponse<'_>,
) -> trc::Result<RegistryGetResponse<'_>> {
let object_id = get.object_type.to_id();
let ids = if let Some(ids) = get.ids.take() {
ids
} else {
let query = if !get.is_account_filtered {
RegistryQuery::new(get.object_type).greater_than_or_equal(Property::AccountId, 0u64)
} else {
RegistryQuery::new(get.object_type).with_account(get.account_id)
}
.with_limit(get.server.core.jmap.get_max_objects);
get.server.registry().query::<Vec<Id>>(query).await?
};
for id in ids {
if let Some(mut sample) = get
.server
.store()
.get_value::<SpamTrainingSample>(ValueKey::from(ValueClass::Registry(
RegistryClass::Item {
object_id,
item_id: id.id(),
},
)))
.await?
.filter(|sample| {
!get.is_account_filtered
|| sample
.account_id
.is_some_and(|account_id| account_id.document_id() == get.account_id)
})
{
if get.is_account_filtered {
sample.blob_id.class = BlobClass::Reserved {
account_id: get.account_id,
expires: sample.expires_at.timestamp() as u64,
};
}
get.insert(id, sample.into_value());
} else {
get.not_found(id);
}
}
Ok(get)
}
pub(crate) async fn spam_sample_query(
mut req: RegistryQueryResponse<'_>,
) -> trc::Result<QueryResponseBuilder> {
let can_impersonate = req.access_token.has_permission(Permission::Impersonate);
let mut account_id = None;
req.request
.extract_filters(|property, _, value| match property {
Property::AccountId if can_impersonate => {
if let Some(id) = value.as_str().and_then(|s| Id::from_str(s).ok()) {
account_id = Some(id);
true
} else {
false
}
}
_ => false,
})?;
let mut query = if let Some(account_id) = account_id {
RegistryQuery::new(req.object_type).with_account(account_id.document_id())
} else if !can_impersonate {
RegistryQuery::new(req.object_type).with_account(req.request.account_id.document_id())
} else {
RegistryQuery::new(req.object_type).greater_than_or_equal(Property::AccountId, 0u64)
};
let params = req
.request
.extract_parameters(req.server.core.jmap.query_max_results, Some(Property::Id))?;
if let Some(limit) = params.limit {
query = query.with_limit(limit);
if let Some(anchor) = params.anchor {
query = query.with_anchor(anchor);
} else if let Some(position) = params.position {
query = query.with_index_start(position);
}
}
let mut results = req.server.registry().query::<Vec<Id>>(query).await?;
match params.sort_by {
Property::Id => {
if !params.sort_ascending {
results.sort_unstable_by(|a, b| b.cmp(a));
}
}
property => {
return Err(trc::JmapEvent::UnsupportedSort.into_err().details(format!(
"Property {} is not supported for sorting",
property
)));
}
}
// Build response
let mut response = QueryResponseBuilder::new(
results.len(),
req.server.core.jmap.query_max_results,
State::Initial,
&req.request,
);
for id in results {
if !response.add_id(id) {
break;
}
}
Ok(response)
}
+527
View File
@@ -0,0 +1,527 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
api::query::QueryResponseBuilder,
registry::{
mapping::{RegistryGetResponse, RegistryQueryResponse, RegistrySetResponse},
query::RegistryQueryFilters,
},
};
use common::Server;
use jmap_proto::{
error::set::{SetError, SetErrorType},
object::registry::RegistryComparator,
types::state::State,
};
use jmap_tools::{JsonPointer, JsonPointerItem, Key};
use registry::{
jmap::{IntoValue, JsonPointerPatch, RegistryJsonPatch},
schema::{
enums::{TaskStatusType, TaskType},
prelude::Property,
structs::Task,
},
types::{
EnumImpl, ObjectImpl,
datetime::UTCDateTime,
index::{IndexBuilder, IndexKey},
},
};
use services::task_manager::lock::TaskLockManager;
use std::str::FromStr;
use store::{
IterateParams, SerializeInfallible, U64_LEN, ValueKey,
registry::RegistryFilterOp,
write::{BatchBuilder, RegistryClass, TaskQueueClass, ValueClass, key::DeserializeBigEndian},
};
use trc::AddContext;
use types::id::Id;
pub(crate) async fn task_set(
mut set: RegistrySetResponse<'_>,
) -> trc::Result<RegistrySetResponse<'_>> {
let mut batch = BatchBuilder::new();
let mut locked_tasks = Vec::new();
// Process creations
'outer: for (id, value) in set.create.drain() {
let mut task = Task::default();
if let Err(err) = task.patch(
JsonPointerPatch::new(&JsonPointer::new(vec![]))
.with_create(true)
.with_can_set_account(true),
value,
) {
set.response.not_created.append(id, err.into());
continue 'outer;
}
let mut validation_errors = Vec::new();
if !task.validate(&mut validation_errors) {
set.response.not_created.append(
id,
SetError::new(SetErrorType::ValidationFailed)
.with_validation_errors(validation_errors),
);
continue 'outer;
}
if !set.access_token.has_permission(task.permission()) {
set.response.not_created.append(
id,
SetError::forbidden().with_description(format!(
"Insufficient permissions to create task of type {}",
task.object_type().as_str()
)),
);
continue 'outer;
}
let task_type = task.object_type();
match task_type {
TaskType::IndexDocument
| TaskType::UnindexDocument
| TaskType::IndexTrace
| TaskType::AccountMaintenance
| TaskType::TenantMaintenance
| TaskType::StoreMaintenance
| TaskType::SpamFilterMaintenance
| TaskType::AcmeRenewal
| TaskType::DkimManagement
| TaskType::DnsManagement => {
let mut index = IndexBuilder::default();
task.index(&mut index);
// Validate foreign keys
for key in index.keys {
if let IndexKey::ForeignKey {
object_id: foreign_id,
..
} = key
&& !set
.server
.store()
.key_exists(ValueKey::from(ValueClass::Registry(
RegistryClass::IndexId {
object_id: foreign_id.object().to_id(),
item_id: foreign_id.id().id(),
},
)))
.await
.caused_by(trc::location!())?
{
set.response.not_created.append(
id,
SetError::new(SetErrorType::InvalidForeignKey)
.with_object_id(foreign_id),
);
continue 'outer;
}
}
let task_id = set.server.registry().assign_id();
batch.schedule_task_with_id(task_id, task).commit_point();
set.response.created(id, task_id);
}
TaskType::CalendarAlarmEmail
| TaskType::CalendarAlarmNotification
| TaskType::CalendarItipMessage
| TaskType::MergeThreads
| TaskType::DmarcReport
| TaskType::TlsReport
| TaskType::DestroyAccount
| TaskType::RestoreArchivedItem => {
set.response.not_created.append(
id,
SetError::forbidden().with_description(format!(
"{} is an internal task type that cannot be created by clients",
task_type.as_str()
)),
);
}
}
}
// Process updates
'outer: for (id, value) in set.update.drain(..) {
let task_id = id.id();
let Some(mut task) = set
.server
.store()
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(
TaskQueueClass::Task { id: task_id },
)))
.await?
else {
set.response.not_updated.append(id, SetError::not_found());
continue;
};
if !set.access_token.has_permission(task.permission()) {
set.response.not_updated.append(
id,
SetError::forbidden().with_description(format!(
"Insufficient permissions to update task of type {}",
task.object_type().as_str()
)),
);
continue 'outer;
}
if !set.server.try_lock_task(task_id).await {
set.response.not_updated.append(
id,
SetError::forbidden().with_description(
"Task is currently being processed and cannot be updated".to_string(),
),
);
continue;
}
locked_tasks.push(task_id);
let old_timestamp = task.due_timestamp();
let old_status = task.status().clone();
for (key, value) in value.into_expanded_object() {
let ptr = match key {
Key::Property(prop) => {
JsonPointer::new(vec![JsonPointerItem::Key(Key::Property(prop))])
}
Key::Borrowed(other) => JsonPointer::parse(other),
Key::Owned(other) => JsonPointer::parse(&other),
};
if let Err(err) = task.patch(
JsonPointerPatch::new(&ptr)
.with_create(false)
.with_can_set_account(true),
value,
) {
set.response.not_updated.append(id, err.into());
continue 'outer;
}
}
if task.status() != &old_status {
let timestamp = task.due_timestamp();
if timestamp != old_timestamp {
batch
.clear(ValueClass::TaskQueue(TaskQueueClass::Due {
id: task_id,
due: old_timestamp,
}))
.set(
ValueClass::TaskQueue(TaskQueueClass::Due {
id: task_id,
due: timestamp,
}),
task.object_type().to_id().serialize(),
);
}
batch
.set(
ValueClass::TaskQueue(TaskQueueClass::Task { id: task_id }),
task.to_pickled_vec(),
)
.commit_point();
}
set.response.updated.append(id, None);
}
// Process destructions
for id in set.destroy.drain(..) {
let task_id = id.id();
let Some(task) = set
.server
.store()
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(
TaskQueueClass::Task { id: task_id },
)))
.await?
else {
set.response.not_destroyed.append(id, SetError::not_found());
continue;
};
if !set.access_token.has_permission(task.permission()) {
set.response.not_destroyed.append(
id,
SetError::forbidden().with_description(format!(
"Insufficient permissions to destroy task of type {}",
task.object_type().as_str()
)),
);
continue;
}
locked_tasks.push(task_id);
let due = task.due_timestamp();
#[cfg(not(feature = "enterprise"))]
if let Task::DestroyAccount(_) = task {
set.response.not_destroyed.append(
id,
SetError::forbidden().with_description(
"Account recovery is not supported in this deployment".to_string(),
),
);
continue;
}
batch
.clear(ValueClass::TaskQueue(TaskQueueClass::Task { id: task_id }))
.clear(ValueClass::TaskQueue(TaskQueueClass::Due {
id: task_id,
due,
}))
.commit_point();
set.response.destroyed.push(id);
}
let has_changes = !batch.is_empty();
if has_changes {
set.server
.store()
.write(batch.build_all())
.await
.caused_by(trc::location!())?;
}
for task_id in locked_tasks {
set.server.remove_index_lock(task_id).await;
}
if has_changes {
set.server.notify_task_queue();
}
Ok(set)
}
pub(crate) async fn task_get(
mut get: RegistryGetResponse<'_>,
) -> trc::Result<RegistryGetResponse<'_>> {
let ids = if let Some(ids) = get.ids.take() {
ids
} else {
task_ids(get.server, get.server.core.jmap.get_max_objects).await?
};
let has_due_field = get.properties.is_empty() || get.properties.contains(&Property::Due);
for id in ids {
if let Some(task) = get
.server
.store()
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(
TaskQueueClass::Task { id: id.id() },
)))
.await?
{
let due = task.due_timestamp();
let mut task = task.into_value();
if has_due_field && due != u64::MAX {
task.as_object_mut().unwrap().insert_unchecked(
Property::Due,
UTCDateTime::from_timestamp(due as i64).into_value(),
);
}
get.insert(id, task);
} else {
get.not_found(id);
}
}
Ok(get)
}
pub(crate) async fn task_query(
mut req: RegistryQueryResponse<'_>,
) -> trc::Result<QueryResponseBuilder> {
let mut due_from = 1u64;
let mut due_to = u64::MAX;
let mut typ = None;
req.request
.extract_filters(|property, op, value| match property {
Property::Due => {
if let Some(due) = value.as_str().and_then(|s| UTCDateTime::from_str(s).ok()) {
let due = due.timestamp() as u64;
let (from, to) = match op {
RegistryFilterOp::Equal => (due, due),
RegistryFilterOp::GreaterThan => (due + 1, u64::MAX),
RegistryFilterOp::GreaterEqualThan => (due, u64::MAX),
RegistryFilterOp::LowerThan => (0, due - 1),
RegistryFilterOp::LowerEqualThan => (0, due),
_ => return false,
};
// Intersect with existing range
due_from = due_from.max(from);
due_to = due_to.min(to);
due_from <= due_to
} else {
false
}
}
Property::Status => {
if let Some(typ) = value.as_str().and_then(TaskStatusType::parse) {
if typ == TaskStatusType::Failed {
due_from = u64::MAX;
due_to = u64::MAX;
}
true
} else {
false
}
}
Property::Type => {
if let Some(typ_) = value.as_str().and_then(TaskType::parse) {
typ = Some(typ_);
true
} else {
false
}
}
_ => false,
})?;
let anchor_id = req.request.anchor.map(|anchor| anchor.id());
if req
.request
.sort
.as_ref()
.and_then(|sort| sort.first())
.is_some_and(|comp| !matches!(comp.property, RegistryComparator::Property(Property::Due)))
{
return Err(trc::JmapEvent::UnsupportedSort
.into_err()
.details("Only sorting by 'due' is supported for tasks".to_string()));
}
let params = req
.request
.extract_parameters(req.server.core.jmap.query_max_results, None)?;
let mut from_id = 0u64;
let mut to_id = u64::MAX;
if let Some(anchor_id) = anchor_id
&& let Some(anchor_task) = req
.server
.store()
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(
TaskQueueClass::Task { id: anchor_id },
)))
.await
.caused_by(trc::location!())?
{
let anchor_due = anchor_task.due_timestamp();
if anchor_due >= due_from && anchor_due <= due_to {
if params.sort_ascending {
due_from = anchor_due;
from_id = anchor_id;
} else {
due_to = anchor_due;
to_id = anchor_id;
}
}
}
// Build response
let mut response = QueryResponseBuilder::new(
req.server.core.jmap.query_max_results + 1,
req.server.core.jmap.query_max_results,
State::Initial,
&req.request,
);
let mut total = 0;
let from_key = ValueKey::from(ValueClass::TaskQueue(TaskQueueClass::Due {
id: from_id,
due: due_from,
}));
let to_key = ValueKey::from(ValueClass::TaskQueue(TaskQueueClass::Due {
id: to_id,
due: due_to,
}));
req.server
.store()
.iterate(
IterateParams::new(from_key, to_key)
.set_ascending(params.sort_ascending)
.set_values(typ.is_some()),
|key, value| {
if let Some(typ) = typ {
let task_type =
TaskType::from_id(value.deserialize_be_u16(0)?).ok_or_else(|| {
trc::StoreEvent::DataCorruption
.into_err()
.ctx(trc::Key::Key, key.to_vec())
.ctx(trc::Key::Value, value.to_vec())
.caused_by(trc::location!())
})?;
if task_type != typ {
return Ok(true);
}
}
let id = key.deserialize_be_u64(U64_LEN)?;
total += 1;
if response.response.total.is_some() {
if !response.is_full() {
response.add_id(id.into());
}
Ok(true)
} else {
Ok(response.add_id(id.into()))
}
},
)
.await
.caused_by(trc::location!())?;
if response.response.total.is_some() {
response.response.total = Some(total);
}
if let Some(limit) = response.response.limit
&& total < limit
{
response.response.limit = None;
}
Ok(response)
}
async fn task_ids(server: &Server, max_results: usize) -> trc::Result<Vec<Id>> {
let mut tasks = Vec::with_capacity(8);
let from_key = ValueKey::from(ValueClass::TaskQueue(TaskQueueClass::Due { id: 0, due: 1 }));
let to_key = ValueKey::from(ValueClass::TaskQueue(TaskQueueClass::Due {
id: u64::MAX,
due: u64::MAX,
}));
server
.store()
.iterate(
IterateParams::new(from_key, to_key).ascending().no_values(),
|key, _| {
tasks.push(key.deserialize_be_u64(U64_LEN)?.into());
Ok(tasks.len() < max_results)
},
)
.await
.caused_by(trc::location!())
.map(|_| tasks)
}
+112
View File
@@ -0,0 +1,112 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::registry::mapping::{
ObjectResponse, RegistrySetResponse, ValidationResult, principal::validate_tenant_quota,
};
use common::network::acme::{
ParsedCert,
account::{EabSettings, acme_create_account},
};
use jmap_proto::error::set::SetError;
use registry::{
jmap::JmapValue,
schema::{
enums::TenantStorageQuota,
prelude::Property,
structs::{AcmeProvider, Certificate},
},
types::{datetime::UTCDateTime, map::Map},
};
use utils::map::vec_map::VecMap;
pub(crate) async fn validate_acme_provider(
set: &RegistrySetResponse<'_>,
provider: &mut AcmeProvider,
unpatched_properties: VecMap<Property, JmapValue<'_>>,
) -> ValidationResult {
let response = match validate_tenant_quota(
set.server,
set.access_token,
TenantStorageQuota::MaxAcmeProviders,
)
.await?
{
Ok(response) => response,
Err(err) => {
return Ok(Err(err));
}
};
// Obtain EAB credentials
let mut eab_key_id = None;
let mut eab_hmac_key = None;
for (key, value) in unpatched_properties {
match (key, value) {
(Property::EabKeyId, JmapValue::Str(value)) => {
eab_key_id = Some(value);
}
(Property::EabHmacKey, JmapValue::Str(value)) => {
eab_hmac_key = Some(value);
}
(_, JmapValue::Null) => {}
_ => {
return Ok(Err(SetError::invalid_properties().with_property(key)));
}
}
}
let eab = if let (Some(key_id), Some(hmac_key)) = (eab_key_id, eab_hmac_key) {
match EabSettings::new(key_id.into_owned(), hmac_key.as_ref()) {
Ok(eab) => Some(eab),
Err(err) => {
return Ok(Err(SetError::invalid_properties()
.with_property(Property::EabKeyId)
.with_property(Property::EabHmacKey)
.with_description(format!("Invalid EAB credentials: {err}"))));
}
}
} else {
None
};
match acme_create_account(provider, eab).await {
Ok(_) => Ok(Ok(response)),
Err(err) => Ok(Err(SetError::invalid_properties()
.with_property(Property::Directory)
.with_description(format!("Failed to create ACME account: {err}")))),
}
}
pub(crate) async fn validate_certificate(
cert: &mut Certificate,
old_cert: Option<&Certificate>,
) -> ValidationResult {
if old_cert.is_none_or(|old_cert| old_cert.certificate != cert.certificate) {
match cert.certificate.value().await {
Ok(pem) => match ParsedCert::parse(pem.as_ref()) {
Ok(parsed) => {
cert.not_valid_after =
UTCDateTime::from_timestamp(parsed.valid_not_after.timestamp());
cert.not_valid_before =
UTCDateTime::from_timestamp(parsed.valid_not_before.timestamp());
cert.issuer = parsed.issuer;
cert.subject_alternative_names = Map::new(parsed.sans);
Ok(Ok(ObjectResponse::default()))
}
Err(err) => Ok(Err(SetError::invalid_properties()
.with_property(Property::Certificate)
.with_description(format!("Failed to read certificate: {err}")))),
},
Err(err) => Ok(Err(SetError::invalid_properties()
.with_property(Property::Certificate)
.with_description(format!("Failed to read certificate: {err}")))),
}
} else {
Ok(Ok(ObjectResponse::default()))
}
}
+37
View File
@@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use common::Server;
use registry::schema::prelude::ObjectType;
pub mod get;
pub mod mapping;
pub mod query;
pub mod set;
pub trait EnterpriseRegistry {
fn assert_enterprise_object(&self, object_type: ObjectType) -> trc::Result<()>;
}
impl EnterpriseRegistry for Server {
fn assert_enterprise_object(&self, object_type: ObjectType) -> trc::Result<()> {
if !matches!(
object_type,
ObjectType::MaskedEmail
| ObjectType::ArchivedItem
| ObjectType::Metric
| ObjectType::Trace
) {
return Ok(());
}
Err(trc::JmapEvent::Forbidden.into_err().details(concat!(
"This feature is only available in the Enterprise edition. ",
"Obtain your trial license at https://license.stalw.art/trial."
)))
}
}
+416
View File
@@ -0,0 +1,416 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
api::query::QueryResponseBuilder,
registry::{
EnterpriseRegistry,
mapping::{
RegistryQueryResponse, account::credential_query, cluster::cluster_node_query,
log::log_query, queued_message::queued_message_query, report::report_query,
spam_sample::spam_sample_query, task::task_query,
},
},
};
use common::{Server, auth::AccessToken};
use jmap_proto::{
method::query::{Comparator, Filter, QueryRequest, QueryResponse},
object::registry::{Registry, RegistryComparator, RegistryFilter, RegistryFilterOperator},
types::state::State,
};
use registry::{
schema::{
enums::{AccountType, Permission},
prelude::{ObjectType, Property},
},
types::{
EnumImpl,
index::{IndexSchemaType, IndexSchemaValueType},
ipmask::IpAddrOrMask,
},
};
use std::str::FromStr;
use store::registry::{RegistryFilterOp, RegistryFilterValue};
use types::id::Id;
pub trait RegistryQuery: Sync + Send {
fn registry_query(
&self,
object_type: ObjectType,
request: QueryRequest<Registry>,
access_token: &AccessToken,
) -> impl Future<Output = trc::Result<QueryResponse>> + Send;
}
impl RegistryQuery for Server {
async fn registry_query(
&self,
object_type: ObjectType,
mut request: QueryRequest<Registry>,
access_token: &AccessToken,
) -> trc::Result<QueryResponse> {
// Initial assertions
if self.registry().is_bootstrap_mode() {
return Err(trc::JmapEvent::Forbidden.into_err().details(concat!(
"The server is in bootstrap mode. Only the 'Bootstrap' object type ",
"can be accessed until the bootstrap process is complete.",
)));
}
self.assert_enterprise_object(object_type)?;
match object_type {
ObjectType::ArfExternalReport
| ObjectType::DmarcExternalReport
| ObjectType::TlsExternalReport
| ObjectType::DmarcInternalReport
| ObjectType::TlsInternalReport => report_query(RegistryQueryResponse {
server: self,
access_token,
object_type,
request,
})
.await
.and_then(|response| response.build()),
ObjectType::SpamTrainingSample => spam_sample_query(RegistryQueryResponse {
server: self,
access_token,
object_type,
request,
})
.await
.and_then(|response| response.build()),
ObjectType::QueuedMessage => queued_message_query(RegistryQueryResponse {
server: self,
access_token,
object_type,
request,
})
.await
.and_then(|response| response.build()),
ObjectType::ClusterNode => cluster_node_query(RegistryQueryResponse {
server: self,
access_token,
object_type,
request,
})
.await
.and_then(|response| response.build()),
ObjectType::ApiKey | ObjectType::AppPassword => {
credential_query(RegistryQueryResponse {
server: self,
access_token,
object_type,
request,
})
.await
.and_then(|response| response.build())
}
ObjectType::Task => task_query(RegistryQueryResponse {
server: self,
access_token,
object_type,
request,
})
.await
.and_then(|response| response.build()),
ObjectType::Log => log_query(RegistryQueryResponse {
server: self,
access_token,
object_type,
request,
})
.await
.and_then(|response| response.build()),
ObjectType::Action => Err(trc::JmapEvent::InvalidArguments
.into_err()
.details("Actions cannot be queried")),
_ => {
let mut query = store::registry::RegistryQuery::new(object_type)
.with_tenant(access_token.tenant_id());
let can_impersonate = access_token.has_permission(Permission::Impersonate);
if !can_impersonate {
query = query.with_account(request.account_id.document_id());
}
let indexes = object_type.indexes();
request.extract_filters(|property, op, value| match property {
Property::MemberTenantId if access_token.tenant_id().is_some() => true,
Property::AccountId if !can_impersonate => true,
property => {
let Some(index) = indexes.iter().find(|i| i.prop == property) else {
return false;
};
let is_pk = index.typ == IndexSchemaType::Unique;
let value = match (index.value, value) {
(IndexSchemaValueType::Keyword, serde_json::Value::String(value)) => {
Some(RegistryFilterValue::from(value))
}
(IndexSchemaValueType::Text, serde_json::Value::String(value)) => {
query.push_text(property, value);
return true;
}
(IndexSchemaValueType::Number, serde_json::Value::Number(value)) => {
value
.as_i64()
.map(|value| RegistryFilterValue::from(value as u64))
}
(IndexSchemaValueType::Enum, serde_json::Value::String(value))
if (property == Property::Type
&& object_type == ObjectType::Account) =>
{
AccountType::parse(&value)
.map(|id| RegistryFilterValue::from(id.to_id()))
}
(IndexSchemaValueType::Boolean, serde_json::Value::Bool(value)) => {
Some(RegistryFilterValue::from(value))
}
(IndexSchemaValueType::Id, serde_json::Value::String(value)) => {
Id::from_str(&value)
.ok()
.map(|id| RegistryFilterValue::from(id.id()))
}
(IndexSchemaValueType::IpMask, serde_json::Value::String(value)) => {
IpAddrOrMask::from_str(&value)
.ok()
.map(|ip| RegistryFilterValue::Bytes(ip.to_index_key()))
}
_ => None,
};
if let Some(value) = value {
query.filters.push(store::registry::RegistryFilter {
property,
op,
value,
is_pk,
});
true
} else {
false
}
}
})?;
let params = request
.extract_parameters(self.core.jmap.query_max_results, Some(Property::Id))?;
if let Some(limit) = params.limit {
query = query.with_limit(limit);
if let Some(anchor) = params.anchor {
query = query.with_anchor(anchor);
} else if let Some(position) = params.position {
query = query.with_index_start(position);
}
}
let matches = if query.has_filters() || params.sort_by == Property::Id {
let matches = self.registry().query::<Vec<Id>>(query).await?;
if matches.is_empty() {
return QueryResponseBuilder::new(
0,
self.core.jmap.query_max_results,
State::Initial,
&request,
)
.build();
}
matches.into()
} else {
None
};
let results = match params.sort_by {
Property::Id => {
let mut results = matches.unwrap();
if !params.sort_ascending {
results.sort_unstable_by(|a, b| b.cmp(a));
}
results
}
property => {
let Some(index) = indexes
.iter()
.find(|i| i.prop == property && i.value != IndexSchemaValueType::Text)
else {
return Err(trc::JmapEvent::UnsupportedSort.into_err().details(
format!("Property {} is not supported for sorting", property),
));
};
if index.typ == IndexSchemaType::Search {
self.registry()
.sort_by_index(
object_type,
index.prop,
matches,
params.sort_ascending,
)
.await?
} else {
self.registry()
.sort_by_pk(object_type, index.prop, matches, params.sort_ascending)
.await?
}
}
};
// Build response
let mut response = QueryResponseBuilder::new(
results.len(),
self.core.jmap.query_max_results,
State::Initial,
&request,
);
for id in results {
if !response.add_id(id) {
break;
}
}
response.build()
}
}
}
}
pub(crate) trait RegistryQueryFilters {
fn extract_filters(
&mut self,
cb: impl FnMut(Property, RegistryFilterOp, serde_json::Value) -> bool,
) -> trc::Result<()>;
fn extract_parameters(
&mut self,
max_results: usize,
external_filter: Option<Property>,
) -> trc::Result<RegistryQueryParameters>;
}
pub(crate) struct RegistryQueryParameters {
pub sort_by: Property,
pub sort_ascending: bool,
pub anchor: Option<u64>,
pub position: Option<u64>,
pub limit: Option<usize>,
}
impl RegistryQueryFilters for QueryRequest<Registry> {
fn extract_filters(
&mut self,
mut cb: impl FnMut(Property, RegistryFilterOp, serde_json::Value) -> bool,
) -> trc::Result<()> {
for cond in std::mem::take(&mut self.filter) {
match cond {
Filter::Property(cond) => match cond {
RegistryFilter::Property {
property,
operator,
value,
} => {
let operator = match operator {
RegistryFilterOperator::Equal => RegistryFilterOp::Equal,
RegistryFilterOperator::GreaterThan => RegistryFilterOp::GreaterThan,
RegistryFilterOperator::GreaterThanOrEqual => {
RegistryFilterOp::GreaterEqualThan
}
RegistryFilterOperator::LessThan => RegistryFilterOp::LowerThan,
RegistryFilterOperator::LessThanOrEqual => {
RegistryFilterOp::LowerEqualThan
}
};
if !cb(property, operator, value) {
return Err(trc::JmapEvent::UnsupportedFilter.into_err().details(
format!(
"Filter on property {} is not supported or invalid",
property
),
));
}
}
RegistryFilter::_T(other) => {
return Err(trc::JmapEvent::UnsupportedFilter
.into_err()
.details(other.to_string()));
}
},
Filter::And | Filter::Close => {}
Filter::Or | Filter::Not => {
return Err(trc::JmapEvent::UnsupportedFilter
.into_err()
.details("Only AND is supported in filters".to_string()));
}
}
}
Ok(())
}
fn extract_parameters(
&mut self,
max_results: usize,
external_filter: Option<Property>,
) -> trc::Result<RegistryQueryParameters> {
#[cfg(feature = "test_mode")]
let comparator = self
.sort
.take()
.unwrap_or_default()
.into_iter()
.next()
.unwrap_or_else(|| Comparator::ascending(RegistryComparator::Property(Property::Id)));
#[cfg(not(feature = "test_mode"))]
let comparator = self
.sort
.take()
.unwrap_or_default()
.into_iter()
.next()
.unwrap_or_else(|| Comparator::descending(RegistryComparator::Property(Property::Id)));
match comparator.property {
RegistryComparator::Property(property) => {
if external_filter.is_some_and(|f| f == property)
&& !self.calculate_total.unwrap_or(false)
&& self.anchor_offset.is_none_or(|offset| offset == 0)
&& self.position.is_none_or(|pos| pos > 0)
{
Ok(RegistryQueryParameters {
sort_by: property,
sort_ascending: comparator.is_ascending,
anchor: self.anchor.take().map(|anchor| anchor.id()),
position: self.position.take().map(|pos| pos as u64),
limit: self
.limit
.take()
.map(|limit| std::cmp::min(limit, max_results))
.unwrap_or(max_results)
.into(),
})
} else {
Ok(RegistryQueryParameters {
sort_by: property,
sort_ascending: comparator.is_ascending,
anchor: None,
position: None,
limit: None,
})
}
}
RegistryComparator::_T(other) => Err(trc::JmapEvent::UnsupportedSort
.into_err()
.details(format!("Property {} is not supported for sorting", other))),
}
}
}
+946
View File
@@ -0,0 +1,946 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::registry::{
EnterpriseRegistry,
mapping::{
ObjectResponse, RegistrySetResponse,
account::account_set,
action::action_set,
bootstrap::bootstrap_set,
dkim::validate_dkim_signature,
domain::{validate_dns_server, validate_domain},
map_bootstrap_error,
principal::{
AccountUpdate, schedule_account_destruction, validate_account, validate_role,
validate_tenant_quota,
},
public_key::validate_public_key,
queued_message::queued_message_set,
report::report_set,
sieve::validate_sieve_script,
spam_sample::spam_sample_set,
task::task_set,
tls::{validate_acme_provider, validate_certificate},
},
};
use common::{
Server, auth::AccessToken, cache::invalidate::CacheInvalidationBuilder,
expr::if_block::BootstrapExprExt, ipc::CacheInvalidation,
manager::application::WebApplicationManager,
};
use directory::core::secret::{hash_secret, is_password_hash};
use http_proto::HttpSessionData;
use jmap_proto::{
error::set::{SetError, SetErrorType},
method::set::{SetRequest, SetResponse},
object::registry::Registry,
references::resolve::ResolveCreatedReference,
request::{IntoValid, MaybeInvalid},
};
use jmap_tools::{JsonPointer, JsonPointerItem, Key};
use registry::{
jmap::{JmapValue, JsonPointerPatch, MaybeUnpatched, RegistryValue},
schema::{
enums::{Permission, TenantStorageQuota},
prelude::{
OBJ_FILTER_ACCOUNT, OBJ_FILTER_TENANT, OBJ_SINGLETON, Object, ObjectInner, ObjectType,
Property,
},
structs::{
Certificate, DkimSignature, DnsServer, Domain, PublicKey, Role, SieveSystemScript,
SieveUserScript, Task,
},
},
types::id::ObjectId,
};
use std::borrow::Cow;
use store::{
registry::{
bootstrap::Bootstrap,
write::{RegistryWrite, RegistryWriteResult},
},
write::BatchBuilder,
};
use trc::AddContext;
use types::id::Id;
use utils::map::vec_map::VecMap;
pub trait RegistrySet: Sync + Send {
fn registry_set(
&self,
object_type: ObjectType,
request: SetRequest<'_, Registry>,
access_token: &AccessToken,
session: &HttpSessionData,
) -> impl Future<Output = trc::Result<SetResponse<Registry>>> + Send;
}
#[allow(clippy::large_enum_variant)]
enum Modification {
Create {
client_id: String,
object: Option<Object>,
},
Update {
id: Id,
object: Object,
},
}
impl RegistrySet for Server {
async fn registry_set(
&self,
object_type: ObjectType,
mut request: SetRequest<'_, Registry>,
access_token: &AccessToken,
session: &HttpSessionData,
) -> trc::Result<SetResponse<Registry>> {
// Initial assertions
if self.registry().is_bootstrap_mode() && !matches!(object_type, ObjectType::Bootstrap) {
return Err(trc::JmapEvent::Forbidden.into_err().details(concat!(
"The server is in bootstrap mode. Only the 'Bootstrap' object type ",
"can be modified until the bootstrap process is complete.",
)));
}
self.assert_enterprise_object(object_type)?;
let object_flags = object_type.flags();
let is_singleton = (object_flags & OBJ_SINGLETON) != 0;
let has_account_id = (object_flags & OBJ_FILTER_ACCOUNT) != 0;
let is_tenant_filtered =
(object_flags & OBJ_FILTER_TENANT) != 0 && access_token.tenant_id().is_some();
let can_set_tenant = access_token.tenant_id().is_none();
let can_set_account = access_token.has_permission(Permission::Impersonate);
let is_account_filtered = has_account_id && !can_set_account;
// Build response
let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?;
// Initial create validation for singletons
let create = request.unwrap_create();
// Initial destroy validation for singletons
let mut destroy = request.unwrap_destroy().into_valid().collect::<Vec<_>>();
if is_singleton && !destroy.is_empty() {
response.not_destroyed.extend(
destroy
.drain(..)
.map(|id| (MaybeInvalid::Value(id), SetError::singleton())),
);
}
// Update validation for willDestroy
let update = request
.unwrap_update()
.into_valid()
.filter_map(|(id, value)| {
if is_singleton {
if id.is_singleton() {
Some((id, value))
} else {
response.not_updated.append(id, SetError::not_found());
None
}
} else if !destroy.contains(&id) {
Some((id, value))
} else {
response.not_updated.append(id, SetError::will_destroy());
None
}
})
.collect::<Vec<_>>();
let mut set = RegistrySetResponse {
access_token,
server: self,
remote_ip: session.remote_ip,
account_id: request.account_id.document_id(),
object_type,
response,
is_tenant_filtered,
is_account_filtered,
create,
update,
destroy,
};
match object_type {
ObjectType::AddressBook
| ObjectType::Asn
| ObjectType::Authentication
| ObjectType::BlobStore
| ObjectType::Cache
| ObjectType::Calendar
| ObjectType::CalendarAlarm
| ObjectType::CalendarScheduling
| ObjectType::Coordinator
| ObjectType::DataRetention
| ObjectType::DataStore
| ObjectType::DkimReportSettings
| ObjectType::DmarcReportSettings
| ObjectType::DnsResolver
| ObjectType::Email
| ObjectType::Enterprise
| ObjectType::FileStorage
| ObjectType::Http
| ObjectType::HttpForm
| ObjectType::Imap
| ObjectType::InMemoryStore
| ObjectType::Jmap
| ObjectType::SystemSettings
| ObjectType::Metrics
| ObjectType::MetricsStore
| ObjectType::MtaConnectionStrategy
| ObjectType::MtaExtensions
| ObjectType::MtaInboundSession
| ObjectType::MtaOutboundStrategy
| ObjectType::MtaOutboundThrottle
| ObjectType::MtaStageAuth
| ObjectType::MtaStageConnect
| ObjectType::MtaStageData
| ObjectType::MtaStageEhlo
| ObjectType::MtaStageMail
| ObjectType::MtaStageRcpt
| ObjectType::MtaSts
| ObjectType::OidcProvider
| ObjectType::ReportSettings
| ObjectType::Search
| ObjectType::SearchStore
| ObjectType::Security
| ObjectType::SenderAuth
| ObjectType::Sharing
| ObjectType::SieveSystemInterpreter
| ObjectType::SieveUserInterpreter
| ObjectType::SpamClassifier
| ObjectType::SpamDnsblSettings
| ObjectType::SpamLlm
| ObjectType::SpamPyzor
| ObjectType::SpamSettings
| ObjectType::SpfReportSettings
| ObjectType::TaskManager
| ObjectType::TlsReportSettings
| ObjectType::TracingStore
| ObjectType::WebDav
| ObjectType::DsnReportSettings
| ObjectType::AcmeProvider
| ObjectType::AiModel
| ObjectType::Alert
| ObjectType::AllowedIp
| ObjectType::Application
| ObjectType::BlockedIp
| ObjectType::Certificate
| ObjectType::Directory
| ObjectType::DnsServer
| ObjectType::EventTracingLevel
| ObjectType::HttpLookup
| ObjectType::MemoryLookupKey
| ObjectType::MemoryLookupKeyValue
| ObjectType::MtaVirtualQueue
| ObjectType::MtaQueueQuota
| ObjectType::MtaRoute
| ObjectType::MtaDeliverySchedule
| ObjectType::MtaInboundThrottle
| ObjectType::MtaTlsStrategy
| ObjectType::MtaMilter
| ObjectType::MtaHook
| ObjectType::NetworkListener
| ObjectType::ClusterRole
| ObjectType::SieveSystemScript
| ObjectType::SieveUserScript
| ObjectType::SpamDnsblServer
| ObjectType::SpamFileExtension
| ObjectType::SpamRule
| ObjectType::SpamTag
| ObjectType::StoreLookup
| ObjectType::Tracer
| ObjectType::WebHook
| ObjectType::PublicKey
| ObjectType::DkimSignature
| ObjectType::MaskedEmail
| ObjectType::Account
| ObjectType::MailingList
| ObjectType::OAuthClient
| ObjectType::Role
| ObjectType::Tenant
| ObjectType::Domain => {
// Bundle modifications together
let mut modifications = Vec::with_capacity(set.create.len() + set.update.len());
for (id, value) in set.create.drain() {
if is_singleton
&& let Some(object) = self
.registry()
.get(ObjectId::new(object_type, Id::singleton()))
.await
.caused_by(trc::location!())?
{
modifications.push((
Modification::Create {
client_id: id,
object: Some(object),
},
value,
Object::from(set.object_type),
));
} else {
modifications.push((
Modification::Create {
client_id: id,
object: None,
},
value,
Object::from(set.object_type),
));
}
}
for (id, value) in set.update.drain(..) {
if let Some(object) = self
.registry()
.get(ObjectId::new(object_type, id))
.await
.caused_by(trc::location!())?
{
if (is_tenant_filtered
&& access_token.tenant_id().map(Id::from)
!= object.inner.member_tenant_id())
|| (is_account_filtered
&& object.inner.account_id() != Some(Id::from(set.account_id)))
{
set.response.not_updated.append(id, SetError::not_found());
continue;
}
modifications.push((
Modification::Update {
id,
object: object.clone(),
},
value,
object,
));
} else if is_singleton {
modifications.push((
Modification::Update {
id,
object: Object::from(set.object_type),
},
value,
Object::from(set.object_type),
));
} else {
set.response.not_updated.append(id, SetError::not_found());
}
}
// Process modifications
let mut cache_invalidator = CacheInvalidationBuilder::default();
'outer: for (modification, mut value, mut new_object) in modifications {
// Initial validations
let is_create = matches!(modification, Modification::Create { .. });
let mut unpatched_properties = VecMap::new();
if let Err(err) = set.response.resolve_self_references(&mut value, 0, true) {
set.failed(modification, err);
continue 'outer;
};
if is_create
|| value
.as_object()
.unwrap()
.get(&Key::Property(Property::Type))
.and_then(|v| v.as_str())
.is_some_and(|t| new_object.object_variant().is_some_and(|v| v != t))
{
// Patch object
match new_object.patch(
JsonPointerPatch::new(&JsonPointer::new(vec![]))
.with_create(true)
.with_can_set_tenant(can_set_tenant)
.with_can_set_account(can_set_account),
value,
) {
Ok(MaybeUnpatched::Patched) => {}
Ok(MaybeUnpatched::Unpatched { property, value }) => {
unpatched_properties.append(property, value);
}
Ok(MaybeUnpatched::UnpatchedMany { properties }) => {
unpatched_properties = properties;
}
Err(err) => {
set.failed(modification, err.into());
continue 'outer;
}
}
// Add tenantId for tenant filtered objects
if is_tenant_filtered && let Some(tenant_id) = set.access_token.tenant_id()
{
new_object.inner.set_member_tenant_id(tenant_id.into());
}
// Add accountId
if has_account_id {
new_object.inner.set_account_id(set.account_id.into());
}
} else {
for (key, value) in value.into_expanded_object() {
let ptr = match key {
Key::Property(Property::Type) => {
continue;
}
Key::Property(prop) => {
JsonPointer::new(vec![JsonPointerItem::Key(Key::Property(
prop,
))])
}
Key::Borrowed(other) => JsonPointer::parse(other),
Key::Owned(other) => JsonPointer::parse(&other),
};
// Patch object
match new_object.patch(
JsonPointerPatch::new(&ptr)
.with_create(false)
.with_can_set_tenant(can_set_tenant)
.with_can_set_account(can_set_account),
value,
) {
Ok(MaybeUnpatched::Patched) => {}
Ok(MaybeUnpatched::Unpatched { property, value }) => {
unpatched_properties.append(property, value);
}
Ok(MaybeUnpatched::UnpatchedMany { properties }) => {
if unpatched_properties.is_empty() {
unpatched_properties = properties;
} else {
unpatched_properties.extend(properties);
}
}
Err(err) => {
set.failed(modification, err.into());
continue 'outer;
}
}
}
}
// Validate objects
let mut tasks = Vec::new();
let result = match &mut new_object.inner {
ObjectInner::Account(account) => {
validate_account(self, access_token, account, modification.as_account())
.await?
}
ObjectInner::Role(role) => {
validate_role(self, access_token, role, modification.as_role()).await?
}
ObjectInner::PublicKey(key) => {
validate_public_key(&set, key, modification.as_public_key()).await?
}
ObjectInner::DkimSignature(key) => {
validate_dkim_signature(&set, key, modification.as_dkim_signature())
.await?
}
ObjectInner::Domain(domain) => {
validate_domain(&set, domain, modification.as_domain(), &mut tasks)
.await?
}
ObjectInner::DnsServer(dns) => {
validate_dns_server(&set, dns, modification.as_dns_server()).await?
}
ObjectInner::MailingList(_) if is_create => {
validate_tenant_quota(
self,
access_token,
TenantStorageQuota::MaxMailingLists,
)
.await?
}
ObjectInner::OAuthClient(client) => {
if let Some(secret) = client.secret.as_mut()
&& !secret.is_empty()
&& !(matches!(secret.as_bytes().first(), Some(&b'$' | &b'{'))
&& is_password_hash(secret))
{
*secret = hash_secret(
set.server.core.network.security.password_hash_algorithm,
std::mem::take(secret).into_bytes(),
)
.await
.caused_by(trc::location!())?;
}
if is_create {
validate_tenant_quota(
self,
access_token,
TenantStorageQuota::MaxOauthClients,
)
.await?
} else {
Ok(ObjectResponse::default())
}
}
ObjectInner::Directory(_) if is_create => {
validate_tenant_quota(
self,
access_token,
TenantStorageQuota::MaxDirectories,
)
.await?
}
ObjectInner::AcmeProvider(provider) if is_create => {
validate_acme_provider(&set, provider, unpatched_properties).await?
}
ObjectInner::Certificate(cert) => {
validate_certificate(cert, modification.as_certificate()).await?
}
ObjectInner::SieveUserScript(SieveUserScript { contents, .. }) => {
validate_sieve_script(
set.server,
contents,
modification.as_sieve_script(),
false,
)
.await?
}
ObjectInner::SieveSystemScript(SieveSystemScript { contents, .. }) => {
validate_sieve_script(
set.server,
contents,
modification.as_sieve_script(),
true,
)
.await?
}
_ => Ok(ObjectResponse::default()),
};
let mut response = match result {
Ok(response) => response,
Err(err) => {
set.failed(modification, err);
continue 'outer;
}
};
// Validate expressions
if let Some(expressions) = new_object.inner.expression_ctxs() {
let mut bp = Bootstrap::new_uninitialized(self.registry().clone());
for expression in expressions {
bp.compile_expr(ObjectId::new(object_type, 0u64.into()), &expression);
if !bp.errors.is_empty() {
set.failed(
modification,
map_bootstrap_error(bp.errors)
.with_object_id_opt(None)
.with_property(expression.property),
);
continue 'outer;
}
}
}
// Save object
let result = match &modification {
Modification::Create { client_id, object } => {
if let Some(object) = object {
if object.inner != new_object.inner {
self.registry()
.write(RegistryWrite::update(
Id::singleton(),
&new_object,
object,
))
.await?
} else {
set.response.created(client_id.to_string(), Id::singleton());
continue;
}
} else {
self.registry()
.write(RegistryWrite::Insert {
object: &new_object,
id: response.id,
})
.await?
}
}
Modification::Update { id, object } => {
if object.inner != new_object.inner {
if !(is_singleton && object.revision == 0) {
self.registry()
.write(RegistryWrite::update(*id, &new_object, object))
.await?
} else {
self.registry()
.write(RegistryWrite::insert(&new_object))
.await?
}
} else {
set.response.updated.append(*id, None);
continue;
}
}
};
let object_id = match (modification, result) {
(Modification::Update { id, object }, RegistryWriteResult::Success(_)) => {
cache_invalidator.process_update(id, &object, &new_object);
if let (
ObjectInner::Application(previous),
ObjectInner::Application(updated),
) = (&object.inner, &new_object.inner)
&& previous.resource_url != updated.resource_url
&& let Err(err) =
WebApplicationManager::delete_bundle(self, id).await
{
trc::error!(
err.details("Failed to delete cached application bundle")
);
}
set.response.updated.append(
id,
if !response.object.is_empty() {
Some(JmapValue::Object(response.object))
} else {
None
},
);
Some(id)
}
(
Modification::Create { client_id, .. },
RegistryWriteResult::Success(id),
) => {
cache_invalidator.process_create(&new_object);
response.object.insert(Property::Id, RegistryValue::Id(id));
set.response
.created
.insert(client_id, JmapValue::Object(response.object));
Some(id)
}
(Modification::Update { id, .. }, err) => {
set.response.not_updated.append(id, map_write_error(err));
None
}
(Modification::Create { client_id, .. }, err) => {
set.response
.not_created
.append(client_id, map_write_error(err));
None
}
};
// Dispatch tasks
if !tasks.is_empty()
&& let Some(object_id) = object_id
{
let mut batch = BatchBuilder::new();
for mut task in tasks.drain(..) {
match &mut task {
Task::AcmeRenewal(task) => task.domain_id = object_id,
Task::DkimManagement(task) => task.domain_id = object_id,
Task::DnsManagement(task) => task.domain_id = object_id,
_ => unreachable!(),
}
batch.schedule_task(task);
}
set.server.store().write(batch.build_all()).await?;
set.server.notify_task_queue();
}
}
// Process destroy
for id in set.destroy.drain(..) {
let object_id = ObjectId::new(object_type, id);
if let Some(object) = self
.registry()
.get(object_id)
.await
.caused_by(trc::location!())?
.filter(|object| {
!((is_tenant_filtered
&& access_token.tenant_id().map(Id::from)
!= object.inner.member_tenant_id())
|| (is_account_filtered
&& object.inner.account_id() != Some(Id::from(set.account_id))))
})
{
match self
.registry()
.write(RegistryWrite::Delete {
object_id,
object: Some(&object),
allowed_orphan_types: if object_type == ObjectType::Account {
&[ObjectType::PublicKey, ObjectType::MaskedEmail]
} else {
&[]
},
})
.await?
{
RegistryWriteResult::Success(_) => {
if let ObjectInner::Account(account) = &object.inner {
for sharee_id in self
.store()
.acl_revoke_all(id.document_id())
.await
.caused_by(trc::location!())?
{
cache_invalidator
.invalidate(CacheInvalidation::AccessToken(sharee_id));
}
schedule_account_destruction(set.server, id, account).await?;
}
if matches!(object.inner, ObjectInner::Application(_))
&& let Err(err) =
WebApplicationManager::delete_bundle(self, id).await
{
trc::error!(
err.details("Failed to delete cached application bundle")
);
}
cache_invalidator.process_delete(id, &object);
set.response.destroyed.push(id);
}
err => {
set.response.not_destroyed.append(id, map_write_error(err));
}
}
} else {
set.response.not_destroyed.append(id, SetError::not_found());
}
}
// Finalize cache invalidation
self.invalidate_caches(cache_invalidator).await?;
Ok(set.into_response())
}
ObjectType::ArfExternalReport
| ObjectType::DmarcExternalReport
| ObjectType::TlsExternalReport
| ObjectType::DmarcInternalReport
| ObjectType::TlsInternalReport => report_set(set).await.map(|set| set.into_response()),
ObjectType::SpamTrainingSample => {
spam_sample_set(set).await.map(|set| set.into_response())
}
ObjectType::AccountSettings
| ObjectType::ApiKey
| ObjectType::AccountPassword
| ObjectType::AppPassword => Box::pin(account_set(set))
.await
.map(|set| set.into_response()),
ObjectType::QueuedMessage => {
queued_message_set(set).await.map(|set| set.into_response())
}
ObjectType::Task => task_set(set).await.map(|set| set.into_response()),
ObjectType::Action => Box::pin(action_set(set))
.await
.map(|set| set.into_response()),
ObjectType::Bootstrap => Box::pin(bootstrap_set(set))
.await
.map(|set| set.into_response()),
ObjectType::Log | ObjectType::Metric | ObjectType::Trace | ObjectType::ClusterNode => {
set.fail_all_create("Telemetry objects cannot be created");
set.fail_all_update("Telemetry objects cannot be modified");
set.fail_all_destroy("Telemetry objects cannot be deleted");
Ok(set.into_response())
}
#[cfg(not(feature = "enterprise"))]
_ => {
set.fail_all_create("Enterprise objects cannot be created");
set.fail_all_update("Enterprise objects cannot be modified");
set.fail_all_destroy("Enterprise objects cannot be deleted");
Ok(set.into_response())
}
}
}
}
impl RegistrySetResponse<'_> {
fn failed(&mut self, modification: Modification, error: SetError<Property>) {
match modification {
Modification::Create { client_id, .. } => {
self.response.not_created.append(client_id, error)
}
Modification::Update { id, .. } => self.response.not_updated.append(id, error),
}
}
pub fn fail_all(&mut self, error: SetError<Property>) {
for (client_id, _) in self.create.drain() {
self.response.not_created.append(client_id, error.clone());
}
for (id, _) in self.update.drain(..) {
self.response.not_updated.append(id, error.clone());
}
for id in self.destroy.drain(..) {
self.response.not_destroyed.append(id, error.clone());
}
}
pub fn fail_all_create(&mut self, error: impl Into<Cow<'static, str>>) {
let error = error.into();
for (client_id, _) in self.create.drain() {
self.response.not_created.append(
client_id,
SetError::forbidden().with_description(error.clone()),
);
}
}
pub fn fail_all_update(&mut self, error: impl Into<Cow<'static, str>>) {
let error = error.into();
for (id, _) in self.update.drain(..) {
self.response
.not_updated
.append(id, SetError::forbidden().with_description(error.clone()));
}
}
pub fn fail_all_destroy(&mut self, error: impl Into<Cow<'static, str>>) {
let error = error.into();
for id in self.destroy.drain(..) {
self.response
.not_destroyed
.append(id, SetError::forbidden().with_description(error.clone()));
}
}
fn into_response(self) -> SetResponse<Registry> {
self.response
}
}
impl Modification {
fn as_account(&self) -> AccountUpdate<'_> {
match self {
Modification::Create { client_id, .. } => AccountUpdate::Create(client_id),
Modification::Update { object, .. } => match &object.inner {
ObjectInner::Account(account) => AccountUpdate::Update(account),
_ => unreachable!(),
},
}
}
fn as_role(&self) -> Option<&Role> {
match self {
Modification::Create { .. } => None,
Modification::Update { object, .. } => match &object.inner {
ObjectInner::Role(role) => Some(role),
_ => None,
},
}
}
fn as_public_key(&self) -> Option<&PublicKey> {
match self {
Modification::Create { .. } => None,
Modification::Update { object, .. } => match &object.inner {
ObjectInner::PublicKey(key) => Some(key),
_ => None,
},
}
}
fn as_dkim_signature(&self) -> Option<&DkimSignature> {
match self {
Modification::Create { .. } => None,
Modification::Update { object, .. } => match &object.inner {
ObjectInner::DkimSignature(key) => Some(key),
_ => None,
},
}
}
fn as_domain(&self) -> Option<&Domain> {
match self {
Modification::Create { .. } => None,
Modification::Update { object, .. } => match &object.inner {
ObjectInner::Domain(domain) => Some(domain),
_ => None,
},
}
}
fn as_dns_server(&self) -> Option<&DnsServer> {
match self {
Modification::Create { .. } => None,
Modification::Update { object, .. } => match &object.inner {
ObjectInner::DnsServer(dns) => Some(dns),
_ => None,
},
}
}
fn as_certificate(&self) -> Option<&Certificate> {
match self {
Modification::Create { .. } => None,
Modification::Update { object, .. } => match &object.inner {
ObjectInner::Certificate(cert) => Some(cert),
_ => None,
},
}
}
fn as_sieve_script(&self) -> Option<&str> {
match self {
Modification::Create { .. } => None,
Modification::Update { object, .. } => match &object.inner {
ObjectInner::SieveUserScript(SieveUserScript { contents, .. })
| ObjectInner::SieveSystemScript(SieveSystemScript { contents, .. }) => {
Some(contents.as_str())
}
_ => None,
},
}
}
}
pub(crate) fn map_write_error(err: RegistryWriteResult) -> SetError<Property> {
match err {
RegistryWriteResult::CannotDeleteLinked {
object_id,
linked_objects,
} => SetError::new(SetErrorType::ObjectIsLinked)
.with_object_id(object_id)
.with_linked_objects(linked_objects),
RegistryWriteResult::InvalidSingletonId => SetError::invalid_properties()
.with_property(Property::Id)
.with_description("Invalid singleton id"),
RegistryWriteResult::CannotDeleteSingleton => {
SetError::forbidden().with_description("Singleton objects cannot be deleted")
}
RegistryWriteResult::InvalidForeignKey { object_id } => {
SetError::new(SetErrorType::InvalidForeignKey).with_object_id(object_id)
}
RegistryWriteResult::PrimaryKeyConflict {
property,
existing_id,
} => SetError::new(SetErrorType::PrimaryKeyViolation)
.with_property(property)
.with_object_id(existing_id),
RegistryWriteResult::ValidationError { errors } => {
SetError::new(SetErrorType::ValidationFailed).with_validation_errors(errors)
}
RegistryWriteResult::NotSupported => SetError::forbidden()
.with_description("The requested action is not supported by the registry store"),
RegistryWriteResult::NotFound { .. } => SetError::not_found(),
RegistryWriteResult::Success(_) => unreachable!(),
}
}