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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(®istry, &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(®istry, &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(®istry, &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(®istry, &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(®istry, &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(®istry, &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(®istry, &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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
)))
|
||||
}
|
||||
}
|
||||
@@ -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))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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!(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user