A locked account can't sign in (it fails as a wrong password does), its sessions end on every node, refresh tokens stop working, and its Sieve scripts forward and reply to nothing. Mail keeps arriving. Delegates get real ACL grants on the account's mailboxes, calendars, address books and files at read, organize or full, with the rights they replaced restored on unlock. Folders made later are granted after the create and in a daily sweep. Organize delegates can't destroy; send-as needs organize or full. The JMAP session marks delegated accounts in urn:inbuxa:jmap. New inbuxa:AccountLock object with get/set, permissions 665-668, and a Compliance > Locked Accounts entry in the schema. Lock, unlock and delegate changes need a reason and are audited; delegate access and writes are audited too (audit-hold-lock spec AL-1 to AL-12).
1249 lines
58 KiB
Rust
1249 lines
58 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
|
*
|
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
|
*/
|
|
|
|
use crate::{
|
|
blob::download::BlobDownload,
|
|
changes::state::JmapCacheState,
|
|
email::{PatchResult, handle_email_patch, ingested_into_object},
|
|
};
|
|
use common::{
|
|
Server, auth::AccessToken, ipc::PushNotification, storage::index::ObjectIndexBuilder,
|
|
};
|
|
use email::message::headers::{BuildHeader, ValueToHeader};
|
|
use email::{
|
|
cache::{MessageCacheFetch, email::MessageCacheAccess, mailbox::MailboxCacheAccess},
|
|
mailbox::{JUNK_ID, TRASH_ID, UidMailbox},
|
|
message::{
|
|
delete::EmailDeletion,
|
|
ingest::{EmailIngest, IngestEmail, IngestSource},
|
|
metadata::MessageData,
|
|
},
|
|
};
|
|
use http_proto::HttpSessionData;
|
|
use jmap_proto::{
|
|
error::set::{SetError, SetErrorType},
|
|
method::set::{SetRequest, SetResponse},
|
|
object::email::{Email, EmailProperty, EmailValue},
|
|
references::resolve::ResolveCreatedReference,
|
|
request::MaybeInvalid,
|
|
types::state::State,
|
|
};
|
|
use jmap_tools::{Key, Value};
|
|
use mail_builder::{
|
|
MessageBuilder,
|
|
headers::{
|
|
HeaderType, address::Address, content_type::ContentType, date::Date, message_id::MessageId,
|
|
raw::Raw, text::Text,
|
|
},
|
|
mime::{BodyPart, MimePart},
|
|
};
|
|
use mail_parser::MessageParser;
|
|
use std::future::Future;
|
|
use std::{borrow::Cow, collections::HashMap};
|
|
use store::{
|
|
ValueKey,
|
|
ahash::AHashMap,
|
|
roaring::RoaringBitmap,
|
|
write::{AlignedBytes, Archive, BatchBuilder},
|
|
};
|
|
use trc::AddContext;
|
|
use types::{
|
|
acl::Acl,
|
|
collection::{Collection, SyncCollection, VanishedCollection},
|
|
id::Id,
|
|
keyword::{ArchivedKeyword, Keyword},
|
|
type_state::{DataType, StateChange},
|
|
};
|
|
|
|
pub trait EmailSet: Sync + Send {
|
|
fn email_set(
|
|
&self,
|
|
request: SetRequest<'_, Email>,
|
|
access_token: &AccessToken,
|
|
session: &HttpSessionData,
|
|
) -> impl Future<Output = trc::Result<SetResponse<Email>>> + Send;
|
|
}
|
|
|
|
impl EmailSet for Server {
|
|
async fn email_set(
|
|
&self,
|
|
mut request: SetRequest<'_, Email>,
|
|
access_token: &AccessToken,
|
|
session: &HttpSessionData,
|
|
) -> trc::Result<SetResponse<Email>> {
|
|
// Prepare response
|
|
let account_id = request.account_id.document_id();
|
|
let cache = self.get_cached_messages(account_id).await?;
|
|
let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?
|
|
.with_state(cache.assert_state(false, &request.if_in_state)?);
|
|
|
|
// Obtain mailboxIds
|
|
let (can_add_mailbox_ids, can_delete_mailbox_ids, can_modify_mailbox_ids) =
|
|
if access_token.is_shared(account_id) {
|
|
(
|
|
cache.shared_mailboxes(access_token, Acl::AddItems).into(),
|
|
cache
|
|
.shared_mailboxes(access_token, Acl::RemoveItems)
|
|
.into(),
|
|
cache
|
|
.shared_mailboxes(access_token, Acl::ModifyItems)
|
|
.into(),
|
|
)
|
|
} else {
|
|
(None, None, None)
|
|
};
|
|
|
|
// Obtain import access token
|
|
let import_access_token = if account_id != access_token.account_id() {
|
|
#[cfg(feature = "test_mode")]
|
|
{
|
|
AccessToken::from_id_maybe_invalid(account_id).into()
|
|
}
|
|
|
|
#[cfg(not(feature = "test_mode"))]
|
|
{
|
|
use common::auth::BuildAccessToken;
|
|
self.access_token(account_id)
|
|
.await
|
|
.caused_by(trc::location!())?
|
|
.build()
|
|
.into()
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let mut last_change_id = None;
|
|
let will_destroy = response.collect_will_destroy(request.unwrap_destroy());
|
|
|
|
// Process creates
|
|
'create: for (id, object) in request.unwrap_create() {
|
|
let Value::Object(mut object) = object else {
|
|
continue;
|
|
};
|
|
|
|
let has_body_structure =
|
|
object.contains_key(&Key::Property(EmailProperty::BodyStructure));
|
|
let mut builder = MessageBuilder::new();
|
|
let mut mailboxes = Vec::new();
|
|
let mut keywords = Vec::new();
|
|
let mut received_at = None;
|
|
|
|
// Parse body values
|
|
let body_values = object
|
|
.remove(&Key::Property(EmailProperty::BodyValues))
|
|
.and_then(|obj| obj.into_object())
|
|
.and_then(|obj| {
|
|
let mut values = HashMap::with_capacity(obj.len());
|
|
for (key, value) in obj.into_vec() {
|
|
let id = key.into_string();
|
|
if let Value::Object(mut bv) = value {
|
|
values.insert(
|
|
id,
|
|
bv.remove(&Key::Property(EmailProperty::Value))?
|
|
.into_string()?,
|
|
);
|
|
} else {
|
|
return None;
|
|
}
|
|
}
|
|
Some(values)
|
|
});
|
|
let mut size_attachments = 0;
|
|
|
|
// Parse properties
|
|
for (property, mut value) in object.into_vec() {
|
|
if let Err(err) = response.resolve_self_references(&mut value, 0, false) {
|
|
response.not_created.append(id, err);
|
|
continue 'create;
|
|
};
|
|
let Key::Property(property) = property else {
|
|
response.invalid_property_create(id, property.into_owned());
|
|
continue 'create;
|
|
};
|
|
|
|
match (property, value) {
|
|
(EmailProperty::MailboxIds, Value::Object(ids)) => {
|
|
mailboxes = ids
|
|
.into_expanded_boolean_set()
|
|
.filter_map(|id| {
|
|
id.try_into_property()?.try_into_id()?.document_id().into()
|
|
})
|
|
.collect();
|
|
}
|
|
(EmailProperty::Keywords, Value::Object(keywords_)) => {
|
|
keywords = keywords_
|
|
.into_expanded_boolean_set()
|
|
.filter_map(|id| id.try_into_property()?.try_into_keyword())
|
|
.collect();
|
|
}
|
|
(EmailProperty::Pointer(pointer), value) => {
|
|
match handle_email_patch(&pointer, value) {
|
|
PatchResult::SetKeyword(keyword) => {
|
|
if !keywords.contains(keyword) {
|
|
keywords.push(keyword.clone());
|
|
}
|
|
}
|
|
PatchResult::RemoveKeyword(keyword) => {
|
|
keywords.retain(|k| k != keyword);
|
|
}
|
|
PatchResult::AddMailbox(id) => {
|
|
if !mailboxes.contains(&id) {
|
|
mailboxes.push(id);
|
|
}
|
|
}
|
|
PatchResult::RemoveMailbox(id) => {
|
|
mailboxes.retain(|mid| mid != &id);
|
|
}
|
|
PatchResult::Invalid(set_error) => {
|
|
response.not_created.append(id, set_error);
|
|
continue 'create;
|
|
}
|
|
}
|
|
}
|
|
(
|
|
header @ (EmailProperty::MessageId
|
|
| EmailProperty::InReplyTo
|
|
| EmailProperty::References),
|
|
Value::Array(values),
|
|
) => {
|
|
builder = builder.header(
|
|
header.as_rfc_header(),
|
|
MessageId {
|
|
id: values
|
|
.into_iter()
|
|
.filter_map(|value| value.into_string())
|
|
.collect(),
|
|
},
|
|
);
|
|
}
|
|
|
|
(
|
|
header @ (EmailProperty::Sender
|
|
| EmailProperty::From
|
|
| EmailProperty::To
|
|
| EmailProperty::Cc
|
|
| EmailProperty::Bcc
|
|
| EmailProperty::ReplyTo),
|
|
value,
|
|
) => {
|
|
if let Some(addresses) = value.try_into_address_list() {
|
|
builder =
|
|
builder.header(header.as_rfc_header(), Address::List(addresses));
|
|
} else {
|
|
response.invalid_property_create(id, header);
|
|
continue 'create;
|
|
}
|
|
}
|
|
(EmailProperty::Subject, Value::Str(value)) => {
|
|
builder = builder.subject(value);
|
|
}
|
|
|
|
(EmailProperty::ReceivedAt, Value::Element(EmailValue::Date(value))) => {
|
|
received_at = (value.timestamp() as u64).into();
|
|
}
|
|
|
|
(EmailProperty::SentAt, Value::Element(EmailValue::Date(value))) => {
|
|
builder = builder.date(Date::new(value.timestamp()));
|
|
}
|
|
|
|
(
|
|
property @ (EmailProperty::TextBody
|
|
| EmailProperty::HtmlBody
|
|
| EmailProperty::Attachments
|
|
| EmailProperty::BodyStructure),
|
|
value,
|
|
) => {
|
|
// Validate request
|
|
let (values, expected_content_type) = match property {
|
|
EmailProperty::BodyStructure => (vec![value], None),
|
|
EmailProperty::TextBody | EmailProperty::HtmlBody
|
|
if !has_body_structure =>
|
|
{
|
|
let values = value.into_array().unwrap_or_default();
|
|
if values.len() <= 1 {
|
|
(
|
|
values,
|
|
Some(match property {
|
|
EmailProperty::TextBody => "text/plain",
|
|
EmailProperty::HtmlBody => "text/html",
|
|
_ => unreachable!(),
|
|
}),
|
|
)
|
|
} else {
|
|
response.not_created.append(
|
|
id,
|
|
SetError::invalid_properties()
|
|
.with_property(property)
|
|
.with_description("Only one part is allowed."),
|
|
);
|
|
continue 'create;
|
|
}
|
|
}
|
|
EmailProperty::Attachments if !has_body_structure => {
|
|
(value.into_array().unwrap_or_default(), None)
|
|
}
|
|
_ => {
|
|
response.not_created.append(
|
|
id,
|
|
SetError::invalid_properties()
|
|
.with_properties([property, EmailProperty::BodyStructure])
|
|
.with_description(
|
|
"Cannot set both properties on a same request.",
|
|
),
|
|
);
|
|
continue 'create;
|
|
}
|
|
};
|
|
|
|
// Iterate parts
|
|
let mut values_stack = Vec::new();
|
|
let mut values = values.into_iter();
|
|
let mut parts = Vec::new();
|
|
loop {
|
|
while let Some(value) = values.next() {
|
|
let mut blob_id = None;
|
|
let mut part_id = None;
|
|
let mut content_type = None;
|
|
let mut content_disposition = None;
|
|
let mut name = None;
|
|
let mut charset = None;
|
|
let mut subparts = None;
|
|
let mut has_size = false;
|
|
let mut headers: Vec<(Cow<str>, HeaderType)> = Vec::new();
|
|
|
|
if let Some(obj) = value.into_object() {
|
|
for (body_property, value) in obj.into_vec() {
|
|
let Key::Property(body_property) = body_property else {
|
|
continue;
|
|
};
|
|
|
|
match (body_property, value) {
|
|
(EmailProperty::Type, Value::Str(value)) => {
|
|
content_type = value.into_owned().into();
|
|
}
|
|
(EmailProperty::PartId, Value::Str(value)) => {
|
|
part_id = value.into_owned().into();
|
|
}
|
|
(
|
|
EmailProperty::BlobId,
|
|
Value::Element(EmailValue::BlobId(value)),
|
|
) => {
|
|
blob_id = value.into();
|
|
}
|
|
(EmailProperty::Disposition, Value::Str(value)) => {
|
|
content_disposition = value.into_owned().into();
|
|
}
|
|
(EmailProperty::Name, Value::Str(value)) => {
|
|
name = value.into_owned().into();
|
|
}
|
|
(EmailProperty::Charset, Value::Str(value)) => {
|
|
charset = value.into_owned().into();
|
|
}
|
|
(EmailProperty::Language, Value::Array(values)) => {
|
|
headers.push((
|
|
"Content-Language".into(),
|
|
Text::new(
|
|
values
|
|
.into_iter()
|
|
.filter_map(|v| v.into_string())
|
|
.fold(
|
|
String::with_capacity(64),
|
|
|mut h, v| {
|
|
if !h.is_empty() {
|
|
h.push_str(", ");
|
|
}
|
|
h.push_str(&v);
|
|
h
|
|
},
|
|
),
|
|
)
|
|
.into(),
|
|
));
|
|
}
|
|
(EmailProperty::Cid, Value::Str(value)) => {
|
|
headers.push((
|
|
"Content-ID".into(),
|
|
MessageId::new(value).into(),
|
|
));
|
|
}
|
|
(EmailProperty::Location, Value::Str(value)) => {
|
|
headers.push((
|
|
"Content-Location".into(),
|
|
Text::new(value).into(),
|
|
));
|
|
}
|
|
(EmailProperty::Header(header), Value::Str(value))
|
|
if !header.header.eq_ignore_ascii_case(
|
|
"content-transfer-encoding",
|
|
) =>
|
|
{
|
|
headers.push((
|
|
header.header.into(),
|
|
Raw::from(value).into(),
|
|
));
|
|
}
|
|
(
|
|
EmailProperty::Header(header),
|
|
Value::Array(values),
|
|
) if !header.header.eq_ignore_ascii_case(
|
|
"content-transfer-encoding",
|
|
) =>
|
|
{
|
|
for value in values {
|
|
if let Some(value) = value.into_string() {
|
|
headers.push((
|
|
header.header.clone().into(),
|
|
Raw::from(value).into(),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
(EmailProperty::Headers, _) => {
|
|
response.not_created.append(
|
|
id,
|
|
SetError::invalid_properties()
|
|
.with_property((
|
|
property,
|
|
EmailProperty::Headers,
|
|
))
|
|
.with_description(
|
|
"Headers have to be set individually.",
|
|
),
|
|
);
|
|
continue 'create;
|
|
}
|
|
(EmailProperty::Size, _) => {
|
|
has_size = true;
|
|
}
|
|
(EmailProperty::SubParts, Value::Array(values)) => {
|
|
subparts = values.into();
|
|
}
|
|
(body_property, value) if value != Value::Null => {
|
|
response.not_created.append(
|
|
id,
|
|
SetError::invalid_properties()
|
|
.with_property((property, body_property))
|
|
.with_description("Cannot set property."),
|
|
);
|
|
continue 'create;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate content-type
|
|
let content_type =
|
|
content_type.unwrap_or_else(|| "text/plain".to_string());
|
|
let is_multipart = content_type.starts_with("multipart/");
|
|
if is_multipart {
|
|
if !matches!(property, EmailProperty::BodyStructure) {
|
|
response.not_created.append(
|
|
id,
|
|
SetError::invalid_properties()
|
|
.with_property((property, EmailProperty::Type))
|
|
.with_description("Multiparts can only be set with bodyStructure."),
|
|
);
|
|
continue 'create;
|
|
}
|
|
} else if expected_content_type
|
|
.as_ref()
|
|
.is_some_and(|v| v != &content_type)
|
|
{
|
|
response.not_created.append(
|
|
id,
|
|
SetError::invalid_properties()
|
|
.with_property((property, EmailProperty::Type))
|
|
.with_description(format!(
|
|
"Expected one body part of type \"{}\"",
|
|
expected_content_type.unwrap()
|
|
)),
|
|
);
|
|
continue 'create;
|
|
}
|
|
|
|
// Validate partId/blobId
|
|
match (blob_id.is_some(), part_id.is_some()) {
|
|
(true, true) if !is_multipart => {
|
|
response.not_created.append(
|
|
id,
|
|
SetError::invalid_properties()
|
|
.with_properties([(property.clone(), EmailProperty::BlobId), (property, EmailProperty::PartId)])
|
|
.with_description(
|
|
"Cannot specify both \"partId\" and \"blobId\".",
|
|
),
|
|
);
|
|
continue 'create;
|
|
}
|
|
(false, false) if !is_multipart => {
|
|
response.not_created.append(
|
|
id,
|
|
SetError::invalid_properties()
|
|
.with_description("Expected a \"partId\" or \"blobId\" field in body part."),
|
|
);
|
|
continue 'create;
|
|
}
|
|
(false, true) if !is_multipart && has_size => {
|
|
response.not_created.append(
|
|
id,
|
|
SetError::invalid_properties()
|
|
.with_property((property, EmailProperty::Size))
|
|
.with_description(
|
|
"Cannot specify \"size\" when providing a \"partId\".",
|
|
),
|
|
);
|
|
continue 'create;
|
|
}
|
|
(true, _) | (_, true) if is_multipart => {
|
|
response.not_created.append(
|
|
id,
|
|
SetError::invalid_properties()
|
|
.with_properties([(property.clone(), EmailProperty::BlobId), (property, EmailProperty::PartId)])
|
|
.with_description(
|
|
"Cannot specify \"partId\" or \"blobId\" in multipart body parts.",
|
|
),
|
|
);
|
|
continue 'create;
|
|
}
|
|
_ => (),
|
|
}
|
|
|
|
// Set Content-Type and Content-Disposition
|
|
let mut content_type = ContentType::new(content_type);
|
|
if !is_multipart {
|
|
if let Some(charset) = charset {
|
|
if part_id.is_none() {
|
|
content_type
|
|
.attributes
|
|
.push(("charset".into(), charset.into()));
|
|
} else {
|
|
response.not_created.append(
|
|
id,
|
|
SetError::invalid_properties()
|
|
.with_property((property, EmailProperty::Charset))
|
|
.with_description(
|
|
"Cannot specify a character set when providing a \"partId\".",
|
|
),
|
|
);
|
|
continue 'create;
|
|
}
|
|
} else if part_id.is_some() {
|
|
content_type
|
|
.attributes
|
|
.push(("charset".into(), "utf-8".into()));
|
|
}
|
|
match (content_disposition, name) {
|
|
(Some(disposition), Some(filename)) => {
|
|
headers.push((
|
|
"Content-Disposition".into(),
|
|
ContentType::new(disposition)
|
|
.attribute("filename", filename)
|
|
.into(),
|
|
));
|
|
}
|
|
(Some(disposition), None) => {
|
|
headers.push((
|
|
"Content-Disposition".into(),
|
|
ContentType::new(disposition).into(),
|
|
));
|
|
}
|
|
(None, Some(filename)) => {
|
|
content_type
|
|
.attributes
|
|
.push(("name".into(), filename.into()));
|
|
}
|
|
(None, None) => (),
|
|
};
|
|
}
|
|
headers.push(("Content-Type".into(), content_type.into()));
|
|
|
|
// In test, sort headers to avoid randomness
|
|
#[cfg(feature = "test_mode")]
|
|
{
|
|
headers.sort_unstable_by(|a, b| match a.0.cmp(&b.0) {
|
|
std::cmp::Ordering::Equal => a.1.cmp(&b.1),
|
|
ord => ord,
|
|
});
|
|
}
|
|
// Retrieve contents
|
|
parts.push(MimePart {
|
|
headers,
|
|
contents: if !is_multipart {
|
|
if let Some(blob_id) = blob_id {
|
|
match self.blob_download(&blob_id, access_token).await? {
|
|
Some(contents) => {
|
|
BodyPart::Binary(contents.into())
|
|
}
|
|
None => {
|
|
response.not_created.append(
|
|
id,
|
|
SetError::new(SetErrorType::BlobNotFound).with_description(
|
|
format!("blobId {blob_id} does not exist on this server.")
|
|
),
|
|
);
|
|
continue 'create;
|
|
}
|
|
}
|
|
} else if let Some(part_id) = part_id {
|
|
if let Some(contents) =
|
|
body_values.as_ref().and_then(|bv| bv.get(&part_id))
|
|
{
|
|
BodyPart::Text(contents.as_ref().into())
|
|
} else {
|
|
response.not_created.append(
|
|
id,
|
|
SetError::invalid_properties()
|
|
.with_property((property, EmailProperty::PartId))
|
|
.with_description(format!(
|
|
"Missing body value for partId {part_id:?}"
|
|
)),
|
|
);
|
|
continue 'create;
|
|
}
|
|
} else {
|
|
unreachable!()
|
|
}
|
|
} else {
|
|
BodyPart::Multipart(vec![])
|
|
},
|
|
});
|
|
|
|
// Check attachment sizes
|
|
if !is_multipart {
|
|
size_attachments += parts.last().unwrap().size();
|
|
if self.core.email.mail_attachments_max_size > 0
|
|
&& size_attachments
|
|
> self.core.email.mail_attachments_max_size
|
|
{
|
|
response.not_created.append(
|
|
id,
|
|
SetError::invalid_properties()
|
|
.with_property(property)
|
|
.with_description(format!(
|
|
"Message exceeds maximum size of {} bytes.",
|
|
self.core.email.mail_attachments_max_size
|
|
)),
|
|
);
|
|
continue 'create;
|
|
}
|
|
} else if let Some(subparts) = subparts {
|
|
values_stack.push((values, parts));
|
|
parts = Vec::with_capacity(subparts.len());
|
|
values = subparts.into_iter();
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if let Some((prev_values, mut prev_parts)) = values_stack.pop() {
|
|
values = prev_values;
|
|
prev_parts.last_mut().unwrap().contents =
|
|
BodyPart::Multipart(parts);
|
|
parts = prev_parts;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
match property {
|
|
EmailProperty::TextBody => {
|
|
builder.text_body = parts.pop();
|
|
}
|
|
EmailProperty::HtmlBody => {
|
|
builder.html_body = parts.pop();
|
|
}
|
|
EmailProperty::Attachments => {
|
|
builder.attachments = parts.into();
|
|
}
|
|
_ => {
|
|
builder.body = parts.pop();
|
|
}
|
|
}
|
|
}
|
|
|
|
(EmailProperty::Header(header), value) => {
|
|
match builder.build_header(header, value) {
|
|
Ok(builder_) => {
|
|
builder = builder_;
|
|
}
|
|
Err(header) => {
|
|
response.invalid_property_create(id, EmailProperty::Header(header));
|
|
continue 'create;
|
|
}
|
|
}
|
|
}
|
|
|
|
(_, Value::Null) => (),
|
|
|
|
(property, _) => {
|
|
response.invalid_property_create(id, property);
|
|
continue 'create;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Make sure message belongs to at least one mailbox
|
|
if mailboxes.is_empty() {
|
|
response.not_created.append(
|
|
id,
|
|
SetError::invalid_properties()
|
|
.with_property(EmailProperty::MailboxIds)
|
|
.with_description("Message has to belong to at least one mailbox."),
|
|
);
|
|
continue 'create;
|
|
}
|
|
|
|
// Verify that the mailboxIds are valid
|
|
for mailbox_id in &mailboxes {
|
|
if !cache.has_mailbox_id(mailbox_id) {
|
|
response.not_created.append(
|
|
id,
|
|
SetError::invalid_properties()
|
|
.with_property(EmailProperty::MailboxIds)
|
|
.with_description(format!(
|
|
"mailboxId {} does not exist.",
|
|
Id::from(*mailbox_id)
|
|
)),
|
|
);
|
|
continue 'create;
|
|
} else if can_add_mailbox_ids
|
|
.as_ref()
|
|
.is_some_and(|ids| !ids.contains(*mailbox_id))
|
|
{
|
|
response.not_created.append(
|
|
id,
|
|
SetError::forbidden().with_description(format!(
|
|
"You are not allowed to add messages to mailbox {}.",
|
|
Id::from(*mailbox_id)
|
|
)),
|
|
);
|
|
continue 'create;
|
|
}
|
|
}
|
|
|
|
// Make sure the message is not empty
|
|
if builder.headers.is_empty()
|
|
&& builder.body.is_none()
|
|
&& builder.html_body.is_none()
|
|
&& builder.text_body.is_none()
|
|
&& builder.attachments.is_none()
|
|
{
|
|
response.not_created.append(
|
|
id,
|
|
SetError::invalid_properties()
|
|
.with_description("Message has to have at least one header or body part."),
|
|
);
|
|
continue 'create;
|
|
}
|
|
|
|
match builder
|
|
.headers
|
|
.iter()
|
|
.position(|(name, _)| name.eq_ignore_ascii_case("Message-ID"))
|
|
{
|
|
Some(pos) => {
|
|
builder.headers[pos].0 = Cow::Borrowed("Message-ID");
|
|
}
|
|
None => {
|
|
builder = builder.message_id(self.core.network.message_id());
|
|
}
|
|
}
|
|
|
|
// In test, sort headers to avoid randomness
|
|
#[cfg(feature = "test_mode")]
|
|
{
|
|
builder
|
|
.headers
|
|
.sort_unstable_by(|a, b| match a.0.cmp(&b.0) {
|
|
std::cmp::Ordering::Equal => a.1.cmp(&b.1),
|
|
ord => ord,
|
|
});
|
|
}
|
|
|
|
// Build message
|
|
let mut raw_message = Vec::with_capacity((4 * size_attachments / 3) + 1024);
|
|
builder.write_to(&mut raw_message).unwrap_or_default();
|
|
|
|
// Ingest message
|
|
match self
|
|
.email_ingest(IngestEmail {
|
|
raw_message: &raw_message,
|
|
message: MessageParser::new().parse(&raw_message),
|
|
blob_hash: None,
|
|
access_token: import_access_token.as_ref().unwrap_or(access_token),
|
|
mailbox_ids: mailboxes,
|
|
keywords,
|
|
received_at,
|
|
source: IngestSource::Jmap {
|
|
train_classifier: true,
|
|
},
|
|
session_id: session.session_id,
|
|
})
|
|
.await
|
|
{
|
|
Ok(message) => {
|
|
last_change_id = message.change_id.into();
|
|
response
|
|
.created
|
|
.insert(id, ingested_into_object(message).into());
|
|
}
|
|
Err(err) if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) => {
|
|
response.not_created.append(
|
|
id,
|
|
SetError::new(SetErrorType::OverQuota)
|
|
.with_description("You have exceeded your disk quota."),
|
|
);
|
|
}
|
|
Err(err) => return Err(err),
|
|
}
|
|
}
|
|
|
|
// Process updates
|
|
let mut batch = BatchBuilder::new();
|
|
let mut changed_mailboxes: AHashMap<u32, Vec<u32>> = AHashMap::new();
|
|
let mut will_update = Vec::with_capacity(request.update.as_ref().map_or(0, |u| u.len()));
|
|
'update: for (id, object) in request.unwrap_update() {
|
|
let id = match id {
|
|
MaybeInvalid::Value(id) => id,
|
|
invalid => {
|
|
response.not_updated.append(invalid, SetError::not_found());
|
|
continue 'update;
|
|
}
|
|
};
|
|
// Make sure id won't be destroyed
|
|
if will_destroy.contains(&id) {
|
|
response.not_updated.append(id, SetError::will_destroy());
|
|
continue 'update;
|
|
}
|
|
|
|
// Obtain message data
|
|
let document_id = id.document_id();
|
|
let data_ = match self
|
|
.store()
|
|
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
|
account_id,
|
|
Collection::Email,
|
|
document_id,
|
|
))
|
|
.await?
|
|
{
|
|
Some(data) => data,
|
|
None => {
|
|
response.not_updated.append(id, SetError::not_found());
|
|
continue 'update;
|
|
}
|
|
};
|
|
let data = data_
|
|
.to_unarchived::<MessageData>()
|
|
.caused_by(trc::location!())?;
|
|
let mut new_data = data.inner.to_builder();
|
|
|
|
for (property, mut value) in object.into_expanded_object() {
|
|
if let Err(err) = response.resolve_self_references(&mut value, 0, false) {
|
|
response.not_updated.append(id, err);
|
|
continue 'update;
|
|
};
|
|
|
|
match (property, value) {
|
|
(Key::Property(EmailProperty::MailboxIds), Value::Object(ids)) => {
|
|
new_data.set_mailboxes(
|
|
ids.into_expanded_boolean_set()
|
|
.filter_map(|id| {
|
|
UidMailbox::new_unassigned(
|
|
id.try_into_property()?.try_into_id()?.document_id(),
|
|
)
|
|
.into()
|
|
})
|
|
.collect(),
|
|
);
|
|
}
|
|
(Key::Property(EmailProperty::Keywords), Value::Object(keywords_)) => {
|
|
new_data.set_keywords(
|
|
keywords_
|
|
.into_expanded_boolean_set()
|
|
.filter_map(|keyword| {
|
|
keyword.try_into_property()?.try_into_keyword()
|
|
})
|
|
.collect(),
|
|
);
|
|
}
|
|
(Key::Property(EmailProperty::Pointer(pointer)), value) => {
|
|
match handle_email_patch(&pointer, value) {
|
|
PatchResult::SetKeyword(keyword) => {
|
|
new_data.add_keyword(keyword.clone());
|
|
}
|
|
PatchResult::RemoveKeyword(keyword) => {
|
|
new_data.remove_keyword(keyword);
|
|
}
|
|
PatchResult::AddMailbox(id) => {
|
|
new_data.add_mailbox(UidMailbox::new_unassigned(id));
|
|
}
|
|
PatchResult::RemoveMailbox(id) => {
|
|
new_data.remove_mailbox(id);
|
|
}
|
|
PatchResult::Invalid(set_error) => {
|
|
response.not_updated.append(id, set_error);
|
|
continue 'update;
|
|
}
|
|
}
|
|
}
|
|
(Key::Property(EmailProperty::Id), value) => {
|
|
if !crate::matches_id(&value, id) {
|
|
response.not_updated.append(
|
|
id,
|
|
SetError::invalid_properties()
|
|
.with_property(EmailProperty::Id)
|
|
.with_description("The id property is immutable."),
|
|
);
|
|
continue 'update;
|
|
}
|
|
}
|
|
(property, _) => {
|
|
response.invalid_property_update(id, property.into_owned());
|
|
continue 'update;
|
|
}
|
|
}
|
|
}
|
|
|
|
let has_keyword_changes = new_data.has_keyword_changes(data.inner);
|
|
let has_mailbox_changes = new_data.has_mailbox_changes(data.inner);
|
|
if !has_keyword_changes && !has_mailbox_changes {
|
|
response.updated.append(id, None);
|
|
continue 'update;
|
|
}
|
|
|
|
// Process keywords
|
|
let mut train_spam = None;
|
|
if has_keyword_changes {
|
|
// Verify permissions on shared accounts
|
|
if can_modify_mailbox_ids.as_ref().is_some_and(|ids| {
|
|
!new_data
|
|
.mailboxes
|
|
.iter()
|
|
.any(|mb| ids.contains(mb.mailbox_id))
|
|
}) {
|
|
response.not_updated.append(
|
|
id,
|
|
SetError::forbidden()
|
|
.with_description("You are not allowed to modify keywords."),
|
|
);
|
|
continue 'update;
|
|
}
|
|
|
|
// Process keyword changes
|
|
let mut changed_seen = false;
|
|
for keyword in new_data.added_keywords(data.inner) {
|
|
match keyword {
|
|
Keyword::Seen => {
|
|
changed_seen = true;
|
|
}
|
|
Keyword::Junk => {
|
|
train_spam = Some(true);
|
|
}
|
|
Keyword::NotJunk => {
|
|
train_spam = Some(false);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
for keyword in new_data.removed_keywords(data.inner) {
|
|
match keyword {
|
|
ArchivedKeyword::Seen => {
|
|
changed_seen = true;
|
|
}
|
|
ArchivedKeyword::Junk if train_spam.is_none() => {
|
|
train_spam = Some(false);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// Set all current mailboxes as changed if the Seen tag changed
|
|
if changed_seen {
|
|
for mailbox_id in new_data.mailboxes.iter() {
|
|
changed_mailboxes.insert(mailbox_id.mailbox_id, Vec::new());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process mailboxes
|
|
if has_mailbox_changes {
|
|
// Make sure the message is at least in one mailbox
|
|
if new_data.mailboxes.is_empty() {
|
|
response.not_updated.append(
|
|
id,
|
|
SetError::invalid_properties()
|
|
.with_property(EmailProperty::MailboxIds)
|
|
.with_description("Message has to belong to at least one mailbox."),
|
|
);
|
|
continue 'update;
|
|
}
|
|
|
|
// Make sure all new mailboxIds are valid
|
|
for mailbox_id in new_data.added_mailboxes(data.inner) {
|
|
if cache.has_mailbox_id(&mailbox_id.mailbox_id) {
|
|
// Verify permissions on shared accounts
|
|
if can_add_mailbox_ids
|
|
.as_ref()
|
|
.is_none_or(|ids| ids.contains(mailbox_id.mailbox_id))
|
|
{
|
|
if mailbox_id.mailbox_id == JUNK_ID {
|
|
train_spam = Some(true);
|
|
}
|
|
|
|
changed_mailboxes.insert(mailbox_id.mailbox_id, Vec::new());
|
|
} else {
|
|
response.not_updated.append(
|
|
id,
|
|
SetError::forbidden().with_description(format!(
|
|
"You are not allowed to add messages to mailbox {}.",
|
|
Id::from(mailbox_id.mailbox_id)
|
|
)),
|
|
);
|
|
continue 'update;
|
|
}
|
|
} else {
|
|
response.not_updated.append(
|
|
id,
|
|
SetError::invalid_properties()
|
|
.with_property(EmailProperty::MailboxIds)
|
|
.with_description(format!(
|
|
"mailboxId {} does not exist.",
|
|
Id::from(mailbox_id.mailbox_id)
|
|
)),
|
|
);
|
|
continue 'update;
|
|
}
|
|
}
|
|
|
|
// Add all removed mailboxes to change list
|
|
for mailbox_id in new_data.removed_mailboxes(data.inner) {
|
|
// Verify permissions on shared accounts
|
|
if can_delete_mailbox_ids
|
|
.as_ref()
|
|
.is_none_or(|ids| ids.contains(u32::from(mailbox_id.mailbox_id)))
|
|
{
|
|
if mailbox_id.mailbox_id == JUNK_ID
|
|
&& !new_data
|
|
.mailboxes
|
|
.iter()
|
|
.any(|mb| mb.mailbox_id == TRASH_ID)
|
|
{
|
|
train_spam = Some(false);
|
|
}
|
|
|
|
changed_mailboxes
|
|
.entry(mailbox_id.mailbox_id.to_native())
|
|
.or_default()
|
|
.push(mailbox_id.uid.to_native());
|
|
} else {
|
|
response.not_updated.append(
|
|
id,
|
|
SetError::forbidden().with_description(format!(
|
|
"You are not allowed to delete messages from mailbox {}.",
|
|
mailbox_id.mailbox_id
|
|
)),
|
|
);
|
|
continue 'update;
|
|
}
|
|
}
|
|
|
|
// Obtain IMAP UIDs for added mailboxes
|
|
let ids = self
|
|
.assign_email_ids(
|
|
account_id,
|
|
new_data
|
|
.mailboxes
|
|
.iter()
|
|
.filter(|m| m.uid == 0)
|
|
.map(|m| m.mailbox_id),
|
|
false,
|
|
)
|
|
.await
|
|
.caused_by(trc::location!())?;
|
|
for (uid_mailbox, uid) in new_data
|
|
.mailboxes
|
|
.iter_mut()
|
|
.filter(|m| m.uid == 0)
|
|
.zip(ids)
|
|
{
|
|
uid_mailbox.uid = uid;
|
|
}
|
|
}
|
|
|
|
// Write changes
|
|
batch
|
|
.with_account_id(account_id)
|
|
.with_collection(Collection::Email)
|
|
.with_document(document_id)
|
|
.custom(
|
|
ObjectIndexBuilder::new()
|
|
.with_current(data)
|
|
.with_changes(new_data.seal()),
|
|
)
|
|
.caused_by(trc::location!())?;
|
|
|
|
if let Some(train_spam) = train_spam {
|
|
self.add_account_spam_sample(
|
|
&mut batch,
|
|
account_id,
|
|
document_id,
|
|
train_spam,
|
|
session.session_id,
|
|
)
|
|
.await
|
|
.caused_by(trc::location!())?;
|
|
}
|
|
|
|
batch.commit_point();
|
|
will_update.push(id);
|
|
}
|
|
|
|
if !batch.is_empty() {
|
|
// Log mailbox changes
|
|
for (parent_id, deleted_uids) in changed_mailboxes {
|
|
batch.log_container_property_change(SyncCollection::Email, parent_id);
|
|
for deleted_uid in deleted_uids {
|
|
batch.log_vanished_item(VanishedCollection::Email, (parent_id, deleted_uid));
|
|
}
|
|
}
|
|
|
|
match self
|
|
.commit_batch(batch)
|
|
.await
|
|
.and_then(|ids| ids.last_change_id(account_id))
|
|
{
|
|
Ok(change_id) => {
|
|
last_change_id = change_id.into();
|
|
|
|
// Add to updated list
|
|
for id in will_update {
|
|
response.updated.append(id, None);
|
|
}
|
|
}
|
|
Err(err) if err.is_assertion_failure() => {
|
|
for id in will_update {
|
|
response.not_updated.append(
|
|
id,
|
|
SetError::forbidden().with_description(
|
|
"Another process modified this message, please try again.",
|
|
),
|
|
);
|
|
}
|
|
}
|
|
Err(err) => {
|
|
return Err(err.caused_by(trc::location!()));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process deletions
|
|
// inbuxa: AL-6: a delegate below full may move mail, never delete it
|
|
if !will_destroy.is_empty()
|
|
&& access_token
|
|
.delegation(account_id)
|
|
.is_some_and(|delegation| !delegation.access.may_destroy())
|
|
{
|
|
for destroy_id in will_destroy {
|
|
response.not_destroyed.append(
|
|
destroy_id,
|
|
SetError::forbidden()
|
|
.with_description("A delegate at this level can move mail but not delete it."),
|
|
);
|
|
}
|
|
} else if !will_destroy.is_empty() {
|
|
let email_ids = cache.email_document_ids();
|
|
let can_destroy_message_ids = if access_token.is_shared(account_id) {
|
|
cache.shared_messages(access_token, Acl::RemoveItems).into()
|
|
} else {
|
|
None
|
|
};
|
|
let mut destroy_ids = RoaringBitmap::new();
|
|
for destroy_id in will_destroy {
|
|
let document_id = destroy_id.document_id();
|
|
|
|
if email_ids.contains(document_id) {
|
|
if !matches!(&can_destroy_message_ids, Some(ids) if !ids.contains(document_id))
|
|
{
|
|
destroy_ids.insert(document_id);
|
|
response.destroyed.push(destroy_id);
|
|
} else {
|
|
response.not_destroyed.append(
|
|
destroy_id,
|
|
SetError::forbidden()
|
|
.with_description("You are not allowed to delete this message."),
|
|
);
|
|
}
|
|
} else {
|
|
response
|
|
.not_destroyed
|
|
.append(destroy_id, SetError::not_found());
|
|
}
|
|
}
|
|
|
|
if !destroy_ids.is_empty() {
|
|
// Batch delete messages
|
|
let mut batch = BatchBuilder::new();
|
|
let not_destroyed = self
|
|
.emails_delete(
|
|
account_id,
|
|
access_token.tenant_id(),
|
|
&mut batch,
|
|
destroy_ids,
|
|
)
|
|
.await?;
|
|
if !batch.is_empty() {
|
|
last_change_id = self
|
|
.commit_batch(batch)
|
|
.await
|
|
.and_then(|ids| ids.last_change_id(account_id))
|
|
.caused_by(trc::location!())?
|
|
.into();
|
|
self.notify_task_queue();
|
|
}
|
|
|
|
// Mark messages that were not found as not destroyed (this should not occur in practice)
|
|
if !not_destroyed.is_empty() {
|
|
let mut destroyed = Vec::with_capacity(response.destroyed.len());
|
|
|
|
for destroy_id in response.destroyed {
|
|
if not_destroyed.contains(destroy_id.document_id()) {
|
|
response
|
|
.not_destroyed
|
|
.append(destroy_id, SetError::not_found());
|
|
} else {
|
|
destroyed.push(destroy_id);
|
|
}
|
|
}
|
|
|
|
response.destroyed = destroyed;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update state
|
|
if let Some(change_id) = last_change_id {
|
|
if response.updated.is_empty() && response.destroyed.is_empty() {
|
|
// Message ingest does not broadcast state changes
|
|
self.broadcast_push_notification(PushNotification::StateChange(
|
|
StateChange::new(account_id)
|
|
.with_change_id(change_id)
|
|
.with_change(DataType::Email)
|
|
.with_change(DataType::Mailbox)
|
|
.with_change(DataType::Thread),
|
|
))
|
|
.await;
|
|
}
|
|
|
|
response.new_state = State::Exact(change_id).into();
|
|
}
|
|
|
|
Ok(response)
|
|
}
|
|
}
|