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,272 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
changes::state::JmapCacheState,
|
||||
email::{PatchResult, handle_email_patch, ingested_into_object},
|
||||
};
|
||||
use common::{Server, auth::AccessToken};
|
||||
use email::{
|
||||
cache::{MessageCacheFetch, email::MessageCacheAccess, mailbox::MailboxCacheAccess},
|
||||
message::copy::{CopyMessageError, EmailCopy},
|
||||
};
|
||||
use http_proto::HttpSessionData;
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
method::{
|
||||
copy::{CopyRequest, CopyResponse},
|
||||
set::SetRequest,
|
||||
},
|
||||
object::email::{Email, EmailProperty, EmailValue},
|
||||
request::{
|
||||
Call, IntoValid, MaybeInvalid, RequestMethod, SetRequestMethod,
|
||||
method::{MethodFunction, MethodName, MethodObject},
|
||||
reference::MaybeResultReference,
|
||||
},
|
||||
};
|
||||
use jmap_tools::{Key, Value};
|
||||
use std::future::Future;
|
||||
use trc::AddContext;
|
||||
use types::acl::Acl;
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
pub trait JmapEmailCopy: Sync + Send {
|
||||
fn email_copy<'x>(
|
||||
&self,
|
||||
request: CopyRequest<'x, Email>,
|
||||
access_token: &AccessToken,
|
||||
next_call: &mut Option<Call<RequestMethod<'x>>>,
|
||||
session: &HttpSessionData,
|
||||
) -> impl Future<Output = trc::Result<CopyResponse<Email>>> + Send;
|
||||
}
|
||||
|
||||
impl JmapEmailCopy for Server {
|
||||
async fn email_copy<'x>(
|
||||
&self,
|
||||
request: CopyRequest<'x, Email>,
|
||||
access_token: &AccessToken,
|
||||
next_call: &mut Option<Call<RequestMethod<'x>>>,
|
||||
session: &HttpSessionData,
|
||||
) -> trc::Result<CopyResponse<Email>> {
|
||||
let account_id = request.account_id.document_id();
|
||||
let from_account_id = request.from_account_id.document_id();
|
||||
|
||||
if account_id == from_account_id {
|
||||
return Err(trc::JmapEvent::InvalidArguments
|
||||
.into_err()
|
||||
.details("From accountId is equal to fromAccountId"));
|
||||
}
|
||||
let cache = self.get_cached_messages(account_id).await?;
|
||||
let old_state = cache.assert_state(false, &request.if_in_state)?;
|
||||
let mut response = CopyResponse {
|
||||
from_account_id: request.from_account_id,
|
||||
account_id: request.account_id,
|
||||
new_state: old_state.clone(),
|
||||
old_state,
|
||||
created: VecMap::with_capacity(request.create.len()),
|
||||
not_created: VecMap::new(),
|
||||
};
|
||||
|
||||
let from_cache = self
|
||||
.get_cached_messages(from_account_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
let from_message_ids = if access_token.is_member(from_account_id) {
|
||||
from_cache.email_document_ids()
|
||||
} else {
|
||||
from_cache.shared_messages(access_token, Acl::ReadItems)
|
||||
};
|
||||
|
||||
let can_add_mailbox_ids = if access_token.is_shared(account_id) {
|
||||
cache.shared_mailboxes(access_token, Acl::AddItems).into()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let on_success_delete = request.on_success_destroy_original.unwrap_or(false);
|
||||
let mut destroy_ids = Vec::new();
|
||||
|
||||
'create: for (id, create) in request.create.into_valid() {
|
||||
let mut from_message_id = None;
|
||||
let mut mailboxes = Vec::new();
|
||||
let mut keywords = Vec::new();
|
||||
let mut received_at = None;
|
||||
|
||||
for (property, value) in create.into_expanded_object() {
|
||||
match (property, value) {
|
||||
(Key::Property(EmailProperty::Id), Value::Element(EmailValue::Id(src))) => {
|
||||
from_message_id = Some(src);
|
||||
}
|
||||
(Key::Property(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();
|
||||
}
|
||||
(Key::Property(EmailProperty::Keywords), Value::Object(keywords_)) => {
|
||||
keywords = keywords_
|
||||
.into_expanded_boolean_set()
|
||||
.filter_map(|id| id.try_into_property()?.try_into_keyword())
|
||||
.collect();
|
||||
}
|
||||
(Key::Property(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
(
|
||||
Key::Property(EmailProperty::ReceivedAt),
|
||||
Value::Element(EmailValue::Date(value)),
|
||||
) => {
|
||||
received_at = value.into();
|
||||
}
|
||||
(property, _) => {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(property.into_owned())
|
||||
.with_description("Invalid property or value.".to_string()),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(from_message_id) = from_message_id else {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(EmailProperty::Id)
|
||||
.with_description("Missing or invalid \"id\" property."),
|
||||
);
|
||||
continue 'create;
|
||||
};
|
||||
if !from_message_ids.contains(from_message_id.document_id()) {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::not_found().with_description(format!(
|
||||
"Item {} not found in account {}.",
|
||||
id, response.from_account_id
|
||||
)),
|
||||
);
|
||||
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 {mailbox_id} does not exist.")),
|
||||
);
|
||||
continue 'create;
|
||||
} else if matches!(&can_add_mailbox_ids, Some(ids) if !ids.contains(*mailbox_id)) {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::forbidden().with_description(format!(
|
||||
"You are not allowed to add messages to mailbox {mailbox_id}."
|
||||
)),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
}
|
||||
|
||||
// Add response
|
||||
match self
|
||||
.copy_message(
|
||||
from_account_id,
|
||||
from_message_id.document_id(),
|
||||
account_id,
|
||||
mailboxes,
|
||||
keywords,
|
||||
received_at.map(|dt| dt.timestamp() as u64),
|
||||
session.session_id,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Ok(email) => {
|
||||
response
|
||||
.created
|
||||
.append(id, ingested_into_object(email).into());
|
||||
}
|
||||
Err(err) => {
|
||||
response.not_created.append(
|
||||
id,
|
||||
match err {
|
||||
CopyMessageError::NotFound => SetError::not_found()
|
||||
.with_description("Message not found in account."),
|
||||
CopyMessageError::OverQuota => SetError::over_quota(),
|
||||
CopyMessageError::AlreadyExists(existing) => SetError::already_exists()
|
||||
.with_existing_id(types::id::Id::from(existing)),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add to destroy list
|
||||
if on_success_delete {
|
||||
destroy_ids.push(MaybeInvalid::Value(from_message_id));
|
||||
}
|
||||
}
|
||||
|
||||
// Update state
|
||||
if !response.created.is_empty() {
|
||||
response.new_state = self.get_cached_messages(account_id).await?.get_state(false);
|
||||
}
|
||||
|
||||
// Destroy ids
|
||||
if on_success_delete && !destroy_ids.is_empty() {
|
||||
*next_call = Call {
|
||||
id: String::new(),
|
||||
name: MethodName::new(MethodObject::Email, MethodFunction::Set),
|
||||
method: RequestMethod::Set(SetRequestMethod::Email(Box::new(SetRequest {
|
||||
account_id: request.from_account_id,
|
||||
if_in_state: request.destroy_from_if_in_state,
|
||||
create: None,
|
||||
update: None,
|
||||
destroy: MaybeResultReference::Value(destroy_ids).into(),
|
||||
arguments: Default::default(),
|
||||
}))),
|
||||
}
|
||||
.into();
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::changes::state::JmapCacheState;
|
||||
use common::{Server, auth::AccessToken};
|
||||
use email::{
|
||||
cache::{MessageCacheFetch, email::MessageCacheAccess},
|
||||
message::{
|
||||
body::{ToBodyPart, truncate_html, truncate_plain},
|
||||
headers::{HeaderToValue, IntoForm},
|
||||
metadata::{
|
||||
ArchivedMetadataPartType, MESSAGE_HAS_ATTACHMENT, MESSAGE_RECEIVED_MASK,
|
||||
MessageMetadata, MetadataHeaderName, PART_ENCODING_PROBLEM,
|
||||
},
|
||||
},
|
||||
};
|
||||
use jmap_proto::{
|
||||
method::get::{GetRequest, GetResponse},
|
||||
object::email::{Email, EmailProperty, EmailValue, HeaderForm},
|
||||
request::IntoValid,
|
||||
types::date::UTCDate,
|
||||
};
|
||||
use jmap_tools::{Key, Map, Value};
|
||||
use mail_parser::HeaderValue;
|
||||
use std::future::Future;
|
||||
use store::{
|
||||
ValueKey,
|
||||
write::{AlignedBytes, Archive},
|
||||
};
|
||||
use trc::{AddContext, StoreEvent};
|
||||
use types::{
|
||||
acl::Acl,
|
||||
blob::{BlobClass, BlobId},
|
||||
blob_hash::BlobHash,
|
||||
collection::Collection,
|
||||
field::EmailField,
|
||||
id::Id,
|
||||
};
|
||||
use utils::chained_bytes::ChainedBytes;
|
||||
|
||||
pub trait EmailGet: Sync + Send {
|
||||
fn email_get(
|
||||
&self,
|
||||
request: GetRequest<Email>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<GetResponse<Email>>> + Send;
|
||||
}
|
||||
|
||||
impl EmailGet for Server {
|
||||
async fn email_get(
|
||||
&self,
|
||||
mut request: GetRequest<Email>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<GetResponse<Email>> {
|
||||
let (ids, not_found_ids) = request.unwrap_ids(self.core.jmap.get_max_objects)?;
|
||||
let properties = request.unwrap_properties(&[
|
||||
EmailProperty::Id,
|
||||
EmailProperty::BlobId,
|
||||
EmailProperty::ThreadId,
|
||||
EmailProperty::MailboxIds,
|
||||
EmailProperty::Keywords,
|
||||
EmailProperty::Size,
|
||||
EmailProperty::ReceivedAt,
|
||||
EmailProperty::MessageId,
|
||||
EmailProperty::InReplyTo,
|
||||
EmailProperty::References,
|
||||
EmailProperty::Sender,
|
||||
EmailProperty::From,
|
||||
EmailProperty::To,
|
||||
EmailProperty::Cc,
|
||||
EmailProperty::Bcc,
|
||||
EmailProperty::ReplyTo,
|
||||
EmailProperty::Subject,
|
||||
EmailProperty::SentAt,
|
||||
EmailProperty::HasAttachment,
|
||||
EmailProperty::Preview,
|
||||
EmailProperty::BodyValues,
|
||||
EmailProperty::TextBody,
|
||||
EmailProperty::HtmlBody,
|
||||
EmailProperty::Attachments,
|
||||
]);
|
||||
let body_properties = request
|
||||
.arguments
|
||||
.body_properties
|
||||
.map(|v| v.into_valid().collect())
|
||||
.unwrap_or_else(|| {
|
||||
vec![
|
||||
EmailProperty::PartId,
|
||||
EmailProperty::BlobId,
|
||||
EmailProperty::Size,
|
||||
EmailProperty::Name,
|
||||
EmailProperty::Type,
|
||||
EmailProperty::Charset,
|
||||
EmailProperty::Disposition,
|
||||
EmailProperty::Cid,
|
||||
EmailProperty::Language,
|
||||
EmailProperty::Location,
|
||||
]
|
||||
});
|
||||
let fetch_text_body_values = request.arguments.fetch_text_body_values.unwrap_or(false);
|
||||
let fetch_html_body_values = request.arguments.fetch_html_body_values.unwrap_or(false);
|
||||
let fetch_all_body_values = request.arguments.fetch_all_body_values.unwrap_or(false);
|
||||
let max_body_value_bytes = request.arguments.max_body_value_bytes.unwrap_or(0);
|
||||
|
||||
let account_id = request.account_id.document_id();
|
||||
let cache = self
|
||||
.get_cached_messages(account_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
let message_ids = if access_token.is_member(account_id) {
|
||||
cache.email_document_ids()
|
||||
} else {
|
||||
cache.shared_messages(access_token, Acl::ReadItems)
|
||||
};
|
||||
|
||||
let ids = if let Some(ids) = ids {
|
||||
ids
|
||||
} else {
|
||||
cache
|
||||
.emails
|
||||
.items
|
||||
.iter()
|
||||
.take(self.core.jmap.get_max_objects)
|
||||
.map(|item| Id::from_parts(item.thread_id, item.document_id))
|
||||
.collect()
|
||||
};
|
||||
let mut response = GetResponse {
|
||||
account_id: request.account_id.into(),
|
||||
state: cache.get_state(false).into(),
|
||||
list: Vec::with_capacity(ids.len()),
|
||||
not_found: not_found_ids,
|
||||
};
|
||||
|
||||
// Check if we need to fetch the raw headers or body
|
||||
let mut needs_body = false;
|
||||
for property in &properties {
|
||||
if matches!(
|
||||
property,
|
||||
EmailProperty::BodyValues
|
||||
| EmailProperty::TextBody
|
||||
| EmailProperty::HtmlBody
|
||||
| EmailProperty::Attachments
|
||||
| EmailProperty::BodyStructure
|
||||
) {
|
||||
needs_body = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for id in ids {
|
||||
// Obtain the email object
|
||||
if !message_ids.contains(id.document_id()) {
|
||||
response.push_not_found(id);
|
||||
continue;
|
||||
}
|
||||
let metadata_ = match self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::property(
|
||||
account_id,
|
||||
Collection::Email,
|
||||
id.document_id(),
|
||||
EmailField::Metadata,
|
||||
))
|
||||
.await?
|
||||
{
|
||||
Some(metadata) => metadata,
|
||||
None => {
|
||||
response.push_not_found(id);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let metadata = metadata_
|
||||
.unarchive::<MessageMetadata>()
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
// Obtain message data
|
||||
let data = match cache.email_by_id(&id.document_id()) {
|
||||
Some(data) => data,
|
||||
None => {
|
||||
response.push_not_found(id);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Retrieve raw message if needed
|
||||
let blob_hash = BlobHash::from(&metadata.blob_hash);
|
||||
let raw_body;
|
||||
let mut raw_message = ChainedBytes::new(metadata.raw_headers.as_ref());
|
||||
if needs_body {
|
||||
raw_body = self
|
||||
.blob_store()
|
||||
.get_blob(blob_hash.as_slice(), 0..usize::MAX)
|
||||
.await?;
|
||||
|
||||
if let Some(raw_body) = &raw_body {
|
||||
raw_message.append(
|
||||
raw_body
|
||||
.get(metadata.blob_body_offset.to_native() as usize..)
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
} else {
|
||||
trc::event!(
|
||||
Store(StoreEvent::NotFound),
|
||||
AccountId = account_id,
|
||||
DocumentId = id.document_id(),
|
||||
Collection = Collection::Email,
|
||||
BlobId = blob_hash.to_hex(),
|
||||
Details = "Blob not found.",
|
||||
CausedBy = trc::location!(),
|
||||
);
|
||||
|
||||
response.push_not_found(id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let blob_id = BlobId {
|
||||
hash: blob_hash,
|
||||
class: BlobClass::Linked {
|
||||
account_id,
|
||||
collection: Collection::Email.into(),
|
||||
document_id: id.document_id(),
|
||||
},
|
||||
section: None,
|
||||
};
|
||||
|
||||
// Prepare response
|
||||
let mut email: Map<'_, EmailProperty, EmailValue> =
|
||||
Map::with_capacity(properties.len());
|
||||
let contents = &metadata.contents[0];
|
||||
let root_part = &contents.parts[0];
|
||||
let blob_body_offset = metadata.blob_body_offset.to_native() as isize
|
||||
- root_part.offset_body.to_native() as isize;
|
||||
for property in &properties {
|
||||
match property {
|
||||
EmailProperty::Id => {
|
||||
email.insert_unchecked(EmailProperty::Id, Id::from(*id));
|
||||
}
|
||||
EmailProperty::ThreadId => {
|
||||
email.insert_unchecked(EmailProperty::ThreadId, Id::from(id.prefix_id()));
|
||||
}
|
||||
EmailProperty::BlobId => {
|
||||
email.insert_unchecked(EmailProperty::BlobId, blob_id.clone());
|
||||
}
|
||||
EmailProperty::MailboxIds => {
|
||||
let mut obj = Map::with_capacity(data.mailboxes.len());
|
||||
for id in data.mailboxes.iter() {
|
||||
debug_assert!(id.uid != 0);
|
||||
obj.insert_unchecked(
|
||||
EmailProperty::IdValue(Id::from(id.mailbox_id)),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
email.insert_unchecked(property.clone(), Value::Object(obj));
|
||||
}
|
||||
EmailProperty::Keywords => {
|
||||
let mut obj = Map::with_capacity(2);
|
||||
for keyword in cache.expand_keywords(data) {
|
||||
obj.insert_unchecked(EmailProperty::Keyword(keyword), true);
|
||||
}
|
||||
email.insert_unchecked(property.clone(), Value::Object(obj));
|
||||
}
|
||||
EmailProperty::Size => {
|
||||
email.insert_unchecked(EmailProperty::Size, data.size);
|
||||
}
|
||||
EmailProperty::ReceivedAt => {
|
||||
email.insert_unchecked(
|
||||
EmailProperty::ReceivedAt,
|
||||
EmailValue::Date(UTCDate::from_timestamp(
|
||||
(metadata.rcvd_attach.to_native() & MESSAGE_RECEIVED_MASK) as i64,
|
||||
)),
|
||||
);
|
||||
}
|
||||
EmailProperty::Preview => {
|
||||
if !metadata.preview.is_empty() {
|
||||
email.insert_unchecked(
|
||||
EmailProperty::Preview,
|
||||
metadata.preview.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
EmailProperty::HasAttachment => {
|
||||
email.insert_unchecked(
|
||||
EmailProperty::HasAttachment,
|
||||
(metadata.rcvd_attach.to_native() & MESSAGE_HAS_ATTACHMENT) != 0,
|
||||
);
|
||||
}
|
||||
EmailProperty::Subject => {
|
||||
email.insert_unchecked(
|
||||
EmailProperty::Subject,
|
||||
root_part
|
||||
.header_value(&MetadataHeaderName::Subject)
|
||||
.map(|value| HeaderValue::from(value).into_form(&HeaderForm::Text))
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
EmailProperty::SentAt => {
|
||||
email.insert_unchecked(
|
||||
EmailProperty::SentAt,
|
||||
root_part
|
||||
.header_value(&MetadataHeaderName::Date)
|
||||
.map(|value| HeaderValue::from(value).into_form(&HeaderForm::Date))
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
EmailProperty::MessageId
|
||||
| EmailProperty::InReplyTo
|
||||
| EmailProperty::References => {
|
||||
email.insert_unchecked(
|
||||
property.clone(),
|
||||
root_part
|
||||
.header_value(&match property {
|
||||
EmailProperty::MessageId => MetadataHeaderName::MessageId,
|
||||
EmailProperty::InReplyTo => MetadataHeaderName::InReplyTo,
|
||||
EmailProperty::References => MetadataHeaderName::References,
|
||||
_ => unreachable!(),
|
||||
})
|
||||
.map(|value| {
|
||||
HeaderValue::from(value).into_form(&HeaderForm::MessageIds)
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
|
||||
EmailProperty::Sender
|
||||
| EmailProperty::From
|
||||
| EmailProperty::To
|
||||
| EmailProperty::Cc
|
||||
| EmailProperty::Bcc
|
||||
| EmailProperty::ReplyTo => {
|
||||
email.insert_unchecked(
|
||||
property.clone(),
|
||||
root_part
|
||||
.header_value(&match property {
|
||||
EmailProperty::Sender => MetadataHeaderName::Sender,
|
||||
EmailProperty::From => MetadataHeaderName::From,
|
||||
EmailProperty::To => MetadataHeaderName::To,
|
||||
EmailProperty::Cc => MetadataHeaderName::Cc,
|
||||
EmailProperty::Bcc => MetadataHeaderName::Bcc,
|
||||
EmailProperty::ReplyTo => MetadataHeaderName::ReplyTo,
|
||||
_ => unreachable!(),
|
||||
})
|
||||
.map(|value| {
|
||||
HeaderValue::from(value).into_form(&HeaderForm::Addresses)
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
EmailProperty::Header(_) => {
|
||||
email.insert_unchecked(
|
||||
property.clone(),
|
||||
root_part.header_to_value(property, &raw_message),
|
||||
);
|
||||
}
|
||||
EmailProperty::Headers => {
|
||||
email.insert_unchecked(
|
||||
EmailProperty::Headers,
|
||||
root_part.headers_to_value(&raw_message),
|
||||
);
|
||||
}
|
||||
EmailProperty::TextBody
|
||||
| EmailProperty::HtmlBody
|
||||
| EmailProperty::Attachments => {
|
||||
let list = match property {
|
||||
EmailProperty::TextBody => &contents.text_body,
|
||||
EmailProperty::HtmlBody => &contents.html_body,
|
||||
EmailProperty::Attachments => &contents.attachments,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
.iter();
|
||||
email.insert_unchecked(
|
||||
property.clone(),
|
||||
list.map(|part_id| {
|
||||
contents.to_body_part(
|
||||
u16::from(part_id) as u32,
|
||||
&body_properties,
|
||||
&raw_message,
|
||||
&blob_id,
|
||||
blob_body_offset,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
EmailProperty::BodyStructure => {
|
||||
email.insert_unchecked(
|
||||
EmailProperty::BodyStructure,
|
||||
contents.to_body_part(
|
||||
0,
|
||||
&body_properties,
|
||||
&raw_message,
|
||||
&blob_id,
|
||||
blob_body_offset,
|
||||
),
|
||||
);
|
||||
}
|
||||
EmailProperty::BodyValues => {
|
||||
let mut body_values = Map::with_capacity(contents.parts.len());
|
||||
for (part_id, part) in contents.parts.iter().enumerate() {
|
||||
if part.is_text_mime_type()
|
||||
&& (fetch_all_body_values
|
||||
|| (fetch_html_body_values
|
||||
&& contents.is_html_part(part_id as u16))
|
||||
|| (fetch_text_body_values
|
||||
&& contents.is_text_part(part_id as u16)))
|
||||
{
|
||||
let contents = part.decode_contents(&raw_message);
|
||||
|
||||
let (is_truncated, value) = match &part.body {
|
||||
ArchivedMetadataPartType::Text => {
|
||||
truncate_plain(contents.as_str(), max_body_value_bytes)
|
||||
}
|
||||
ArchivedMetadataPartType::Html => {
|
||||
truncate_html(contents.as_str(), max_body_value_bytes)
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
body_values.insert_unchecked(
|
||||
Key::Owned(part_id.to_string()),
|
||||
Map::with_capacity(3)
|
||||
.with_key_value(
|
||||
EmailProperty::IsEncodingProblem,
|
||||
(part.flags & PART_ENCODING_PROBLEM) != 0,
|
||||
)
|
||||
.with_key_value(EmailProperty::IsTruncated, is_truncated)
|
||||
.with_key_value(EmailProperty::Value, value),
|
||||
);
|
||||
}
|
||||
}
|
||||
email.insert_unchecked(EmailProperty::BodyValues, body_values);
|
||||
}
|
||||
|
||||
_ => {
|
||||
return Err(trc::JmapEvent::InvalidArguments
|
||||
.into_err()
|
||||
.details(format!("Invalid property {property:?}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
response.list.push(email.into());
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
blob::download::BlobDownload, changes::state::JmapCacheState, email::ingested_into_object,
|
||||
};
|
||||
use common::{Server, auth::AccessToken, ipc::PushNotification};
|
||||
use email::{
|
||||
cache::{MessageCacheFetch, mailbox::MailboxCacheAccess},
|
||||
mailbox::JUNK_ID,
|
||||
message::ingest::{EmailIngest, IngestEmail, IngestSource},
|
||||
};
|
||||
use http_proto::HttpSessionData;
|
||||
use jmap_proto::{
|
||||
error::set::{SetError, SetErrorType},
|
||||
method::import::{ImportEmailRequest, ImportEmailResponse},
|
||||
object::email::EmailProperty,
|
||||
request::MaybeInvalid,
|
||||
types::state::State,
|
||||
};
|
||||
use mail_parser::{HeaderName, MessageParser};
|
||||
use std::future::Future;
|
||||
use types::{
|
||||
acl::Acl,
|
||||
id::Id,
|
||||
keyword::Keyword,
|
||||
type_state::{DataType, StateChange},
|
||||
};
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
pub trait EmailImport: Sync + Send {
|
||||
fn email_import(
|
||||
&self,
|
||||
request: ImportEmailRequest,
|
||||
access_token: &AccessToken,
|
||||
session: &HttpSessionData,
|
||||
) -> impl Future<Output = trc::Result<ImportEmailResponse>> + Send;
|
||||
}
|
||||
|
||||
impl EmailImport for Server {
|
||||
async fn email_import(
|
||||
&self,
|
||||
request: ImportEmailRequest,
|
||||
access_token: &AccessToken,
|
||||
session: &HttpSessionData,
|
||||
) -> trc::Result<ImportEmailResponse> {
|
||||
// Validate state
|
||||
let account_id = request.account_id.document_id();
|
||||
let cache = self.get_cached_messages(account_id).await?;
|
||||
let old_state: State = cache.assert_state(false, &request.if_in_state)?;
|
||||
let can_add_mailbox_ids = if access_token.is_shared(account_id) {
|
||||
cache.shared_mailboxes(access_token, Acl::AddItems).into()
|
||||
} else {
|
||||
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;
|
||||
use trc::AddContext;
|
||||
self.access_token(account_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.build()
|
||||
.into()
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut response = ImportEmailResponse {
|
||||
account_id: request.account_id,
|
||||
new_state: old_state.clone(),
|
||||
old_state: old_state.into(),
|
||||
created: VecMap::with_capacity(request.emails.len()),
|
||||
not_created: VecMap::new(),
|
||||
};
|
||||
|
||||
let mut last_change_id = None;
|
||||
'outer: for (id, email) in request.emails {
|
||||
// Validate mailboxIds
|
||||
let mailbox_ids = email
|
||||
.mailbox_ids
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.filter_map(|m| m.try_unwrap().map(|m| m.document_id()))
|
||||
.collect::<Vec<_>>();
|
||||
if mailbox_ids.is_empty() {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(EmailProperty::MailboxIds)
|
||||
.with_description("Message must belong to at least one mailbox."),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for mailbox_id in &mailbox_ids {
|
||||
if !cache.has_mailbox_id(mailbox_id) {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(EmailProperty::MailboxIds)
|
||||
.with_description(format!(
|
||||
"Mailbox {} does not exist.",
|
||||
Id::from(*mailbox_id)
|
||||
)),
|
||||
);
|
||||
continue 'outer;
|
||||
} else if matches!(&can_add_mailbox_ids, Some(ids) if !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 'outer;
|
||||
}
|
||||
}
|
||||
|
||||
let MaybeInvalid::Value(blob_id) = email.blob_id else {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(EmailProperty::BlobId)
|
||||
.with_description("Invalid blob id."),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
// Fetch raw message to import
|
||||
let raw_message = match self.blob_download(&blob_id, access_token).await? {
|
||||
Some(raw_message) => raw_message,
|
||||
None => {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::new(SetErrorType::BlobNotFound)
|
||||
.with_description(format!("BlobId {} not found.", blob_id)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Import message
|
||||
let parsed = MessageParser::new().parse(&raw_message);
|
||||
let is_valid_message = parsed.as_ref().is_some_and(|message| {
|
||||
message
|
||||
.headers()
|
||||
.iter()
|
||||
.any(|header| !matches!(header.name, HeaderName::Other(_)))
|
||||
});
|
||||
if !is_valid_message {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::new(SetErrorType::InvalidEmail)
|
||||
.with_description("Blob does not contain a valid RFC 5322 message."),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
match self
|
||||
.email_ingest(IngestEmail {
|
||||
raw_message: &raw_message,
|
||||
message: parsed,
|
||||
blob_hash: Some(&blob_id.hash),
|
||||
access_token: import_access_token.as_ref().unwrap_or(access_token),
|
||||
source: IngestSource::Jmap {
|
||||
train_classifier: email
|
||||
.keywords
|
||||
.iter()
|
||||
.any(|k| matches!(k, Keyword::Junk | Keyword::NotJunk))
|
||||
|| mailbox_ids.contains(&JUNK_ID),
|
||||
},
|
||||
mailbox_ids,
|
||||
keywords: email.keywords,
|
||||
received_at: email.received_at.map(|r| r.into()),
|
||||
session_id: session.session_id,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(email) => {
|
||||
last_change_id = Some(email.change_id);
|
||||
response
|
||||
.created
|
||||
.append(id, ingested_into_object(email).into());
|
||||
}
|
||||
Err(mut err) => match err.as_ref() {
|
||||
trc::EventType::Limit(trc::LimitEvent::Quota) => {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::new(SetErrorType::OverQuota)
|
||||
.with_description("You have exceeded your disk quota."),
|
||||
);
|
||||
}
|
||||
trc::EventType::MessageIngest(trc::MessageIngestEvent::Error) => {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::new(SetErrorType::InvalidEmail).with_description(
|
||||
err.take_value(trc::Key::Reason)
|
||||
.and_then(|v| v.into_string())
|
||||
.unwrap(),
|
||||
),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
return Err(err);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Update state
|
||||
if let Some(change_id) = last_change_id {
|
||||
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 = self.get_cached_messages(account_id).await?.get_state(false);
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use email::message::ingest::IngestedEmail;
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
object::email::{EmailProperty, EmailValue},
|
||||
};
|
||||
use jmap_tools::{JsonPointer, JsonPointerItem, Key, Map, Value};
|
||||
use types::{id::Id, keyword::Keyword};
|
||||
|
||||
pub mod copy;
|
||||
pub mod get;
|
||||
pub mod import;
|
||||
pub mod parse;
|
||||
pub mod query;
|
||||
pub mod set;
|
||||
pub mod snippet;
|
||||
|
||||
fn ingested_into_object(email: IngestedEmail) -> Map<'static, EmailProperty, EmailValue> {
|
||||
Map::with_capacity(3)
|
||||
.with_key_value(
|
||||
EmailProperty::Id,
|
||||
Id::from_parts(email.thread_id, email.document_id),
|
||||
)
|
||||
.with_key_value(EmailProperty::ThreadId, Id::from(email.thread_id))
|
||||
.with_key_value(EmailProperty::BlobId, email.blob_id)
|
||||
.with_key_value(EmailProperty::Size, email.size)
|
||||
}
|
||||
|
||||
pub(crate) enum PatchResult<'x> {
|
||||
SetKeyword(&'x Keyword),
|
||||
RemoveKeyword(&'x Keyword),
|
||||
AddMailbox(u32),
|
||||
RemoveMailbox(u32),
|
||||
Invalid(SetError<EmailProperty>),
|
||||
}
|
||||
|
||||
pub(crate) fn handle_email_patch<'x>(
|
||||
pointer: &'x JsonPointer<EmailProperty>,
|
||||
value: Value<'_, EmailProperty, EmailValue>,
|
||||
) -> PatchResult<'x> {
|
||||
let mut pointer_iter = pointer.iter();
|
||||
|
||||
match (pointer_iter.next(), pointer_iter.next()) {
|
||||
(
|
||||
Some(JsonPointerItem::Key(Key::Property(EmailProperty::Keywords))),
|
||||
Some(JsonPointerItem::Key(Key::Property(EmailProperty::Keyword(keyword)))),
|
||||
) => match value {
|
||||
Value::Bool(true) => return PatchResult::SetKeyword(keyword),
|
||||
Value::Bool(false) | Value::Null => return PatchResult::RemoveKeyword(keyword),
|
||||
_ => (),
|
||||
},
|
||||
(
|
||||
Some(JsonPointerItem::Key(Key::Property(EmailProperty::MailboxIds))),
|
||||
Some(JsonPointerItem::Key(Key::Property(EmailProperty::IdValue(id)))),
|
||||
) => match value {
|
||||
Value::Bool(true) => return PatchResult::AddMailbox(id.document_id()),
|
||||
Value::Bool(false) | Value::Null => {
|
||||
return PatchResult::RemoveMailbox(id.document_id());
|
||||
}
|
||||
_ => (),
|
||||
},
|
||||
_ => (),
|
||||
}
|
||||
|
||||
PatchResult::Invalid(
|
||||
SetError::invalid_properties()
|
||||
.with_property(EmailProperty::Pointer(pointer.clone()))
|
||||
.with_description("Invalid patch value".to_string()),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::blob::download::BlobDownload;
|
||||
use common::{Server, auth::AccessToken};
|
||||
use email::message::index::PREVIEW_LENGTH;
|
||||
use email::message::{
|
||||
body::{ToBodyPart, TruncateBody},
|
||||
headers::HeaderToValue,
|
||||
};
|
||||
use jmap_proto::{
|
||||
method::parse::{ParseRequest, ParseResponse},
|
||||
object::email::{Email, EmailProperty},
|
||||
request::{IntoValid, MaybeInvalid, reference::MaybeIdReference},
|
||||
};
|
||||
use jmap_tools::{Key, Map, Value};
|
||||
use mail_parser::{
|
||||
HeaderName, MessageParser, MimeHeaders, PartType, decoders::html::html_to_text,
|
||||
parsers::preview::preview_text,
|
||||
};
|
||||
use std::future::Future;
|
||||
use utils::{chained_bytes::ChainedBytes, map::vec_map::VecMap};
|
||||
|
||||
pub trait EmailParse: Sync + Send {
|
||||
fn email_parse(
|
||||
&self,
|
||||
request: ParseRequest<Email>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<ParseResponse<Email>>> + Send;
|
||||
}
|
||||
|
||||
impl EmailParse for Server {
|
||||
async fn email_parse(
|
||||
&self,
|
||||
request: ParseRequest<Email>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<ParseResponse<Email>> {
|
||||
if request.blob_ids.len() > self.core.jmap.mail_parse_max_items {
|
||||
return Err(trc::JmapEvent::RequestTooLarge.into_err());
|
||||
}
|
||||
let properties = request
|
||||
.properties
|
||||
.map(|v| v.into_valid().collect())
|
||||
.unwrap_or_else(|| {
|
||||
vec![
|
||||
EmailProperty::BlobId,
|
||||
EmailProperty::Size,
|
||||
EmailProperty::ReceivedAt,
|
||||
EmailProperty::MessageId,
|
||||
EmailProperty::InReplyTo,
|
||||
EmailProperty::References,
|
||||
EmailProperty::Sender,
|
||||
EmailProperty::From,
|
||||
EmailProperty::To,
|
||||
EmailProperty::Cc,
|
||||
EmailProperty::Bcc,
|
||||
EmailProperty::ReplyTo,
|
||||
EmailProperty::Subject,
|
||||
EmailProperty::SentAt,
|
||||
EmailProperty::HasAttachment,
|
||||
EmailProperty::Preview,
|
||||
EmailProperty::BodyValues,
|
||||
EmailProperty::TextBody,
|
||||
EmailProperty::HtmlBody,
|
||||
EmailProperty::Attachments,
|
||||
]
|
||||
});
|
||||
let body_properties = request
|
||||
.arguments
|
||||
.body_properties
|
||||
.map(|v| v.into_valid().collect())
|
||||
.unwrap_or_else(|| {
|
||||
vec![
|
||||
EmailProperty::PartId,
|
||||
EmailProperty::BlobId,
|
||||
EmailProperty::Size,
|
||||
EmailProperty::Name,
|
||||
EmailProperty::Type,
|
||||
EmailProperty::Charset,
|
||||
EmailProperty::Disposition,
|
||||
EmailProperty::Cid,
|
||||
EmailProperty::Language,
|
||||
EmailProperty::Location,
|
||||
]
|
||||
});
|
||||
let fetch_text_body_values = request.arguments.fetch_text_body_values.unwrap_or(false);
|
||||
let fetch_html_body_values = request.arguments.fetch_html_body_values.unwrap_or(false);
|
||||
let fetch_all_body_values = request.arguments.fetch_all_body_values.unwrap_or(false);
|
||||
let max_body_value_bytes = request.arguments.max_body_value_bytes.unwrap_or(0);
|
||||
|
||||
let mut response = ParseResponse {
|
||||
account_id: request.account_id,
|
||||
parsed: VecMap::with_capacity(request.blob_ids.len()),
|
||||
not_parsable: vec![],
|
||||
not_found: vec![],
|
||||
};
|
||||
|
||||
for blob_id in request.blob_ids {
|
||||
let blob_id = match blob_id {
|
||||
MaybeIdReference::Id(blob_id) => blob_id,
|
||||
MaybeIdReference::Invalid(s) | MaybeIdReference::Reference(s) => {
|
||||
response.not_found.push(MaybeInvalid::Invalid(s));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Fetch raw message to parse
|
||||
let raw_message = match self.blob_download(&blob_id, access_token).await? {
|
||||
Some(raw_message) => raw_message,
|
||||
None => {
|
||||
response.not_found.push(MaybeInvalid::Value(blob_id));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let message = match MessageParser::new().parse(&raw_message).filter(|message| {
|
||||
message
|
||||
.root_part()
|
||||
.headers()
|
||||
.iter()
|
||||
.any(|header| !matches!(header.name, HeaderName::Other(_)))
|
||||
}) {
|
||||
Some(message) => message,
|
||||
None => {
|
||||
response.not_parsable.push(blob_id);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let raw_message = ChainedBytes::new(&raw_message);
|
||||
|
||||
// Prepare response
|
||||
let mut email = Map::with_capacity(properties.len());
|
||||
for property in &properties {
|
||||
match property {
|
||||
EmailProperty::BlobId => {
|
||||
email.insert_unchecked(EmailProperty::BlobId, blob_id.clone());
|
||||
}
|
||||
|
||||
EmailProperty::Size => {
|
||||
email.insert_unchecked(
|
||||
EmailProperty::Size,
|
||||
Value::Number(raw_message.len().into()),
|
||||
);
|
||||
}
|
||||
EmailProperty::HasAttachment => {
|
||||
email.insert_unchecked(
|
||||
EmailProperty::HasAttachment,
|
||||
Value::Bool(message.parts.iter().enumerate().any(|(part_id, part)| {
|
||||
let part_id = part_id as u32;
|
||||
match &part.body {
|
||||
PartType::Html(_) | PartType::Text(_) => {
|
||||
!message.text_body.contains(&part_id)
|
||||
&& !message.html_body.contains(&part_id)
|
||||
}
|
||||
PartType::Binary(_) | PartType::Message(_) => true,
|
||||
_ => false,
|
||||
}
|
||||
})),
|
||||
);
|
||||
}
|
||||
EmailProperty::Preview => {
|
||||
email.insert_unchecked(
|
||||
EmailProperty::Preview,
|
||||
match message
|
||||
.text_body
|
||||
.first()
|
||||
.or_else(|| message.html_body.first())
|
||||
.and_then(|idx| message.parts.get(*idx as usize))
|
||||
.map(|part| &part.body)
|
||||
{
|
||||
Some(PartType::Text(text)) => {
|
||||
preview_text(text.replace('\r', "").into(), PREVIEW_LENGTH)
|
||||
.into()
|
||||
}
|
||||
Some(PartType::Html(html)) => preview_text(
|
||||
html_to_text(html).replace('\r', "").into(),
|
||||
PREVIEW_LENGTH,
|
||||
)
|
||||
.into(),
|
||||
_ => Value::Null,
|
||||
},
|
||||
);
|
||||
}
|
||||
EmailProperty::MessageId
|
||||
| EmailProperty::InReplyTo
|
||||
| EmailProperty::References
|
||||
| EmailProperty::Sender
|
||||
| EmailProperty::From
|
||||
| EmailProperty::To
|
||||
| EmailProperty::Cc
|
||||
| EmailProperty::Bcc
|
||||
| EmailProperty::ReplyTo
|
||||
| EmailProperty::Subject
|
||||
| EmailProperty::SentAt
|
||||
| EmailProperty::Header(_) => {
|
||||
email.insert_unchecked(
|
||||
property.clone(),
|
||||
message.parts[0]
|
||||
.headers
|
||||
.header_to_value(property, &raw_message),
|
||||
);
|
||||
}
|
||||
EmailProperty::Headers => {
|
||||
email.insert_unchecked(
|
||||
EmailProperty::Headers,
|
||||
message.parts[0].headers.headers_to_value(&raw_message),
|
||||
);
|
||||
}
|
||||
EmailProperty::TextBody
|
||||
| EmailProperty::HtmlBody
|
||||
| EmailProperty::Attachments => {
|
||||
let list = match property {
|
||||
EmailProperty::TextBody => &message.text_body,
|
||||
EmailProperty::HtmlBody => &message.html_body,
|
||||
EmailProperty::Attachments => &message.attachments,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
.iter();
|
||||
email.insert_unchecked(
|
||||
property.clone(),
|
||||
list.map(|part_id| {
|
||||
message.parts.to_body_part(
|
||||
*part_id,
|
||||
&body_properties,
|
||||
&raw_message,
|
||||
&blob_id,
|
||||
0,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
EmailProperty::BodyStructure => {
|
||||
email.insert_unchecked(
|
||||
EmailProperty::BodyStructure,
|
||||
message.parts.to_body_part(
|
||||
0,
|
||||
&body_properties,
|
||||
&raw_message,
|
||||
&blob_id,
|
||||
0,
|
||||
),
|
||||
);
|
||||
}
|
||||
EmailProperty::BodyValues => {
|
||||
let mut body_values = Map::with_capacity(message.parts.len());
|
||||
for (part_id, part) in message.parts.iter().enumerate() {
|
||||
let part_id = part_id as u32;
|
||||
if part.is_text()
|
||||
&& part
|
||||
.content_type()
|
||||
.is_none_or(|ct| ct.ctype().eq_ignore_ascii_case("text"))
|
||||
&& (fetch_all_body_values
|
||||
|| (fetch_html_body_values
|
||||
&& message.html_body.contains(&part_id))
|
||||
|| (fetch_text_body_values
|
||||
&& message.text_body.contains(&part_id)))
|
||||
{
|
||||
let (is_truncated, value) =
|
||||
part.body.truncate(max_body_value_bytes);
|
||||
body_values.insert_unchecked(
|
||||
Key::Owned(part_id.to_string()),
|
||||
Map::with_capacity(3)
|
||||
.with_key_value(
|
||||
EmailProperty::IsEncodingProblem,
|
||||
part.is_encoding_problem,
|
||||
)
|
||||
.with_key_value(EmailProperty::IsTruncated, is_truncated)
|
||||
.with_key_value(EmailProperty::Value, value),
|
||||
);
|
||||
}
|
||||
}
|
||||
email.insert_unchecked(EmailProperty::BodyValues, body_values);
|
||||
}
|
||||
EmailProperty::Id
|
||||
| EmailProperty::ThreadId
|
||||
| EmailProperty::Keywords
|
||||
| EmailProperty::MailboxIds
|
||||
| EmailProperty::ReceivedAt => {
|
||||
email.insert_unchecked(property.clone(), Value::Null);
|
||||
}
|
||||
|
||||
_ => {
|
||||
return Err(trc::JmapEvent::InvalidArguments
|
||||
.into_err()
|
||||
.details(format!("Invalid property {property:?}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
response.parsed.append(blob_id, email.into());
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{api::query::QueryResponseBuilder, changes::state::JmapCacheState};
|
||||
use common::{MessageStoreCache, Server, auth::AccessToken};
|
||||
use email::cache::{MessageCacheFetch, email::MessageCacheAccess};
|
||||
use jmap_proto::{
|
||||
method::query::{Filter, QueryRequest, QueryResponse},
|
||||
object::email::{Email, EmailComparator, EmailFilter},
|
||||
};
|
||||
use mail_parser::HeaderName;
|
||||
use nlp::language::Language;
|
||||
use std::future::Future;
|
||||
use store::{
|
||||
ahash::{AHashMap, AHashSet},
|
||||
roaring::RoaringBitmap,
|
||||
search::{
|
||||
EmailSearchField, SearchComparator, SearchFilter, SearchOperator, SearchQuery, SearchValue,
|
||||
},
|
||||
write::SearchIndex,
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{acl::Acl, keyword::Keyword};
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
pub trait EmailQuery: Sync + Send {
|
||||
fn email_query(
|
||||
&self,
|
||||
request: QueryRequest<Email>,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<QueryResponse>> + Send;
|
||||
}
|
||||
|
||||
impl EmailQuery for Server {
|
||||
async fn email_query(
|
||||
&self,
|
||||
mut request: QueryRequest<Email>,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<QueryResponse> {
|
||||
let account_id = request.account_id.document_id();
|
||||
let mut filters = Vec::with_capacity(request.filter.len());
|
||||
let cached_messages = self
|
||||
.get_cached_messages(account_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
for filter in std::mem::take(&mut request.filter) {
|
||||
match filter {
|
||||
Filter::Property(cond) => match cond {
|
||||
EmailFilter::Text(text) => {
|
||||
let (text, language) =
|
||||
Language::detect(text, self.core.email.default_language);
|
||||
|
||||
filters.push(SearchFilter::Or);
|
||||
filters.push(SearchFilter::has_text(
|
||||
EmailSearchField::From,
|
||||
&text,
|
||||
Language::None,
|
||||
));
|
||||
filters.push(SearchFilter::has_text(
|
||||
EmailSearchField::To,
|
||||
&text,
|
||||
Language::None,
|
||||
));
|
||||
filters.push(SearchFilter::has_text(
|
||||
EmailSearchField::Cc,
|
||||
&text,
|
||||
Language::None,
|
||||
));
|
||||
filters.push(SearchFilter::has_text(
|
||||
EmailSearchField::Bcc,
|
||||
&text,
|
||||
Language::None,
|
||||
));
|
||||
filters.push(SearchFilter::has_text(
|
||||
EmailSearchField::Subject,
|
||||
&text,
|
||||
language,
|
||||
));
|
||||
filters.push(SearchFilter::has_text(
|
||||
EmailSearchField::Body,
|
||||
&text,
|
||||
language,
|
||||
));
|
||||
filters.push(SearchFilter::has_text(
|
||||
EmailSearchField::Attachment,
|
||||
text,
|
||||
language,
|
||||
));
|
||||
filters.push(SearchFilter::End);
|
||||
}
|
||||
EmailFilter::From(text) => filters.push(SearchFilter::has_text(
|
||||
EmailSearchField::From,
|
||||
text,
|
||||
Language::None,
|
||||
)),
|
||||
EmailFilter::To(text) => filters.push(SearchFilter::has_text(
|
||||
EmailSearchField::To,
|
||||
text,
|
||||
Language::None,
|
||||
)),
|
||||
EmailFilter::Cc(text) => filters.push(SearchFilter::has_text(
|
||||
EmailSearchField::Cc,
|
||||
text,
|
||||
Language::None,
|
||||
)),
|
||||
EmailFilter::Bcc(text) => filters.push(SearchFilter::has_text(
|
||||
EmailSearchField::Bcc,
|
||||
text,
|
||||
Language::None,
|
||||
)),
|
||||
EmailFilter::Subject(text) => filters.push(SearchFilter::has_text_detect(
|
||||
EmailSearchField::Subject,
|
||||
text,
|
||||
self.core.email.default_language,
|
||||
)),
|
||||
EmailFilter::Body(text) => filters.push(SearchFilter::has_text_detect(
|
||||
EmailSearchField::Body,
|
||||
text,
|
||||
self.core.email.default_language,
|
||||
)),
|
||||
EmailFilter::Header(header) => {
|
||||
let mut header = header.into_iter();
|
||||
let header_name = header.next().ok_or_else(|| {
|
||||
trc::JmapEvent::InvalidArguments
|
||||
.into_err()
|
||||
.details("Header name is missing.".to_string())
|
||||
})?;
|
||||
|
||||
if let Some(header_name) = HeaderName::parse(header_name) {
|
||||
let value = header.next();
|
||||
let op = if matches!(
|
||||
header_name,
|
||||
HeaderName::MessageId
|
||||
| HeaderName::InReplyTo
|
||||
| HeaderName::References
|
||||
| HeaderName::ResentMessageId
|
||||
) || value.is_none()
|
||||
{
|
||||
SearchOperator::Equal
|
||||
} else {
|
||||
SearchOperator::Contains
|
||||
};
|
||||
|
||||
filters.push(SearchFilter::cond(
|
||||
EmailSearchField::Headers,
|
||||
op,
|
||||
SearchValue::KeyValues(VecMap::with_capacity(1).with_append(
|
||||
header_name.as_str().to_lowercase(),
|
||||
value.unwrap_or_default(),
|
||||
)),
|
||||
));
|
||||
}
|
||||
}
|
||||
EmailFilter::InMailbox(mailbox) => {
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter(
|
||||
cached_messages
|
||||
.in_mailbox(mailbox.document_id())
|
||||
.map(|item| item.document_id),
|
||||
)))
|
||||
}
|
||||
EmailFilter::InMailboxOtherThan(mailboxes) => {
|
||||
let mailboxes = mailboxes
|
||||
.into_iter()
|
||||
.map(|m| m.document_id())
|
||||
.collect::<AHashSet<_>>();
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter(
|
||||
cached_messages.emails.items.iter().filter_map(|item| {
|
||||
if item
|
||||
.mailboxes
|
||||
.iter()
|
||||
.any(|mb| !mailboxes.contains(&mb.mailbox_id))
|
||||
{
|
||||
Some(item.document_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
)));
|
||||
}
|
||||
EmailFilter::Before(date) => filters.push(SearchFilter::lt(
|
||||
EmailSearchField::ReceivedAt,
|
||||
date.timestamp(),
|
||||
)),
|
||||
EmailFilter::After(date) => filters.push(SearchFilter::gt(
|
||||
EmailSearchField::ReceivedAt,
|
||||
date.timestamp(),
|
||||
)),
|
||||
EmailFilter::MinSize(size) => {
|
||||
filters.push(SearchFilter::ge(EmailSearchField::Size, size))
|
||||
}
|
||||
EmailFilter::MaxSize(size) => {
|
||||
filters.push(SearchFilter::lt(EmailSearchField::Size, size))
|
||||
}
|
||||
EmailFilter::AllInThreadHaveKeyword(keyword) => filters.push(
|
||||
SearchFilter::is_in_set(thread_keywords(&cached_messages, keyword, true)),
|
||||
),
|
||||
EmailFilter::SomeInThreadHaveKeyword(keyword) => filters.push(
|
||||
SearchFilter::is_in_set(thread_keywords(&cached_messages, keyword, false)),
|
||||
),
|
||||
EmailFilter::NoneInThreadHaveKeyword(keyword) => {
|
||||
filters.push(SearchFilter::Not);
|
||||
filters.push(SearchFilter::is_in_set(thread_keywords(
|
||||
&cached_messages,
|
||||
keyword,
|
||||
false,
|
||||
)));
|
||||
filters.push(SearchFilter::End);
|
||||
}
|
||||
EmailFilter::HasKeyword(keyword) => {
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter(
|
||||
cached_messages
|
||||
.with_keyword(&keyword)
|
||||
.map(|item| item.document_id),
|
||||
)));
|
||||
}
|
||||
EmailFilter::NotKeyword(keyword) => {
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter(
|
||||
cached_messages
|
||||
.without_keyword(&keyword)
|
||||
.map(|item| item.document_id),
|
||||
)));
|
||||
}
|
||||
EmailFilter::HasAttachment(has_attach) => {
|
||||
filters.push(SearchFilter::eq(
|
||||
EmailSearchField::HasAttachment,
|
||||
has_attach,
|
||||
));
|
||||
}
|
||||
|
||||
// Non-standard
|
||||
EmailFilter::Id(ids) => {
|
||||
let mut set = RoaringBitmap::new();
|
||||
for id in ids {
|
||||
set.insert(id.document_id());
|
||||
}
|
||||
filters.push(SearchFilter::is_in_set(set));
|
||||
}
|
||||
EmailFilter::SentBefore(date) => {
|
||||
filters.push(SearchFilter::lt(EmailSearchField::SentAt, date.timestamp()))
|
||||
}
|
||||
EmailFilter::SentAfter(date) => {
|
||||
filters.push(SearchFilter::gt(EmailSearchField::SentAt, date.timestamp()))
|
||||
}
|
||||
EmailFilter::InThread(id) => {
|
||||
filters.push(SearchFilter::is_in_set(RoaringBitmap::from_iter(
|
||||
cached_messages
|
||||
.in_thread(id.document_id())
|
||||
.map(|item| item.document_id),
|
||||
)))
|
||||
}
|
||||
other => {
|
||||
return Err(trc::JmapEvent::UnsupportedFilter
|
||||
.into_err()
|
||||
.details(other.to_string()));
|
||||
}
|
||||
},
|
||||
Filter::And => {
|
||||
filters.push(SearchFilter::And);
|
||||
}
|
||||
Filter::Or => {
|
||||
filters.push(SearchFilter::Or);
|
||||
}
|
||||
Filter::Not => {
|
||||
filters.push(SearchFilter::Not);
|
||||
}
|
||||
Filter::Close => {
|
||||
filters.push(SearchFilter::End);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse sort criteria
|
||||
let mut comparators = Vec::with_capacity(request.sort.as_ref().map_or(1, |s| s.len()));
|
||||
for comparator in request
|
||||
.sort
|
||||
.take()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_default()
|
||||
{
|
||||
comparators.push(match comparator.property {
|
||||
EmailComparator::ReceivedAt => {
|
||||
SearchComparator::field(EmailSearchField::ReceivedAt, comparator.is_ascending)
|
||||
}
|
||||
EmailComparator::Size => {
|
||||
SearchComparator::field(EmailSearchField::Size, comparator.is_ascending)
|
||||
}
|
||||
EmailComparator::From => {
|
||||
SearchComparator::field(EmailSearchField::From, comparator.is_ascending)
|
||||
}
|
||||
EmailComparator::To => {
|
||||
SearchComparator::field(EmailSearchField::To, comparator.is_ascending)
|
||||
}
|
||||
EmailComparator::Subject => {
|
||||
SearchComparator::field(EmailSearchField::Subject, comparator.is_ascending)
|
||||
}
|
||||
EmailComparator::SentAt => {
|
||||
SearchComparator::field(EmailSearchField::SentAt, comparator.is_ascending)
|
||||
}
|
||||
EmailComparator::HasKeyword(keyword) => SearchComparator::set(
|
||||
RoaringBitmap::from_iter(
|
||||
cached_messages
|
||||
.with_keyword(&keyword)
|
||||
.map(|item| item.document_id),
|
||||
),
|
||||
comparator.is_ascending,
|
||||
),
|
||||
EmailComparator::AllInThreadHaveKeyword(keyword) => SearchComparator::set(
|
||||
thread_keywords(&cached_messages, keyword, true),
|
||||
comparator.is_ascending,
|
||||
),
|
||||
EmailComparator::SomeInThreadHaveKeyword(keyword) => SearchComparator::set(
|
||||
thread_keywords(&cached_messages, keyword, false),
|
||||
comparator.is_ascending,
|
||||
),
|
||||
// Non-standard
|
||||
EmailComparator::Cc => {
|
||||
SearchComparator::field(EmailSearchField::Cc, comparator.is_ascending)
|
||||
}
|
||||
|
||||
other => {
|
||||
return Err(trc::JmapEvent::UnsupportedSort
|
||||
.into_err()
|
||||
.details(other.to_string()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let results = self
|
||||
.search_store()
|
||||
.query_account(
|
||||
SearchQuery::new(SearchIndex::Email)
|
||||
.with_filters(filters)
|
||||
.with_comparators(comparators)
|
||||
.with_account_id(account_id)
|
||||
.with_mask(if access_token.is_shared(account_id) {
|
||||
cached_messages.shared_messages(access_token, Acl::ReadItems)
|
||||
} else {
|
||||
cached_messages
|
||||
.emails
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| item.document_id)
|
||||
.collect()
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let collapse_threads = request.arguments.collapse_threads.unwrap_or(false);
|
||||
let total_results = if collapse_threads {
|
||||
let mut seen_thread_ids = AHashSet::new();
|
||||
results
|
||||
.iter()
|
||||
.filter_map(|document_id| {
|
||||
cached_messages
|
||||
.email_by_id(document_id)
|
||||
.map(|email| email.thread_id)
|
||||
})
|
||||
.filter(|thread_id| seen_thread_ids.insert(*thread_id))
|
||||
.count()
|
||||
} else {
|
||||
results.len()
|
||||
};
|
||||
|
||||
let mut response = QueryResponseBuilder::new(
|
||||
total_results,
|
||||
self.core.jmap.query_max_results,
|
||||
cached_messages.get_state(false),
|
||||
&request,
|
||||
);
|
||||
|
||||
if !results.is_empty() {
|
||||
let mut seen_thread_ids = AHashSet::new();
|
||||
|
||||
for document_id in results {
|
||||
let Some(thread_id) = cached_messages
|
||||
.email_by_id(&document_id)
|
||||
.map(|email| email.thread_id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if collapse_threads && !seen_thread_ids.insert(thread_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !response.add(thread_id, document_id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response.build()
|
||||
}
|
||||
}
|
||||
|
||||
fn thread_keywords(cache: &MessageStoreCache, keyword: Keyword, match_all: bool) -> RoaringBitmap {
|
||||
let keyword_doc_ids =
|
||||
RoaringBitmap::from_iter(cache.with_keyword(&keyword).map(|item| item.document_id));
|
||||
if keyword_doc_ids.is_empty() {
|
||||
return keyword_doc_ids;
|
||||
}
|
||||
let mut not_matched_ids = RoaringBitmap::new();
|
||||
let mut matched_ids = RoaringBitmap::new();
|
||||
|
||||
let mut thread_map: AHashMap<u32, RoaringBitmap> = AHashMap::new();
|
||||
|
||||
for item in &cache.emails.items {
|
||||
thread_map
|
||||
.entry(item.thread_id)
|
||||
.or_default()
|
||||
.insert(item.document_id);
|
||||
}
|
||||
|
||||
for item in &cache.emails.items {
|
||||
let keyword_doc_id = item.document_id;
|
||||
if !keyword_doc_ids.contains(keyword_doc_id)
|
||||
|| matched_ids.contains(keyword_doc_id)
|
||||
|| not_matched_ids.contains(keyword_doc_id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(thread_doc_ids) = thread_map.get(&item.thread_id) {
|
||||
let mut thread_tag_intersection = thread_doc_ids.clone();
|
||||
thread_tag_intersection &= &keyword_doc_ids;
|
||||
|
||||
if (match_all && &thread_tag_intersection == thread_doc_ids)
|
||||
|| (!match_all && !thread_tag_intersection.is_empty())
|
||||
{
|
||||
matched_ids |= thread_doc_ids;
|
||||
} else if !thread_tag_intersection.is_empty() {
|
||||
not_matched_ids |= &thread_tag_intersection;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
matched_ids
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,263 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::{Server, auth::AccessToken};
|
||||
use email::{
|
||||
cache::{MessageCacheFetch, email::MessageCacheAccess},
|
||||
message::metadata::{
|
||||
ArchivedMetadataPartType, DecodedPartContent, MessageMetadata, MetadataHeaderName,
|
||||
},
|
||||
};
|
||||
use jmap_proto::{
|
||||
method::{
|
||||
query::Filter,
|
||||
search_snippet::{GetSearchSnippetRequest, GetSearchSnippetResponse, SearchSnippet},
|
||||
},
|
||||
object::email::EmailFilter,
|
||||
request::MaybeInvalid,
|
||||
};
|
||||
use mail_parser::decoders::html::html_to_text;
|
||||
use nlp::language::{Language, search_snippet::generate_snippet, stemmer::Stemmer};
|
||||
use std::future::Future;
|
||||
use store::{
|
||||
ValueKey,
|
||||
backend::MAX_TOKEN_LENGTH,
|
||||
write::{AlignedBytes, Archive},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{acl::Acl, collection::Collection, field::EmailField};
|
||||
use utils::chained_bytes::ChainedBytes;
|
||||
|
||||
pub trait EmailSearchSnippet: Sync + Send {
|
||||
fn email_search_snippet(
|
||||
&self,
|
||||
request: GetSearchSnippetRequest,
|
||||
access_token: &AccessToken,
|
||||
) -> impl Future<Output = trc::Result<GetSearchSnippetResponse>> + Send;
|
||||
}
|
||||
|
||||
impl EmailSearchSnippet for Server {
|
||||
async fn email_search_snippet(
|
||||
&self,
|
||||
request: GetSearchSnippetRequest,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<GetSearchSnippetResponse> {
|
||||
let mut filter_stack = vec![];
|
||||
let mut include_term = true;
|
||||
let mut terms = vec![];
|
||||
let mut is_exact = false;
|
||||
let mut language = self.core.email.default_language;
|
||||
|
||||
for cond in request.filter {
|
||||
match cond {
|
||||
Filter::Property(cond) => {
|
||||
if let EmailFilter::Text(text)
|
||||
| EmailFilter::Subject(text)
|
||||
| EmailFilter::Body(text) = cond
|
||||
&& include_term
|
||||
{
|
||||
let (text, language_) =
|
||||
Language::detect(text, self.core.email.default_language);
|
||||
language = language_;
|
||||
if (text.starts_with('"') && text.ends_with('"'))
|
||||
|| (text.starts_with('\'') && text.ends_with('\''))
|
||||
{
|
||||
for token in language.tokenize_text(&text, MAX_TOKEN_LENGTH) {
|
||||
terms.push(token.word.into_owned());
|
||||
}
|
||||
is_exact = true;
|
||||
} else {
|
||||
for token in Stemmer::new(&text, language, MAX_TOKEN_LENGTH) {
|
||||
terms.push(token.word.into_owned());
|
||||
if let Some(stemmed_word) = token.stemmed_word {
|
||||
terms.push(stemmed_word.into_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Filter::And | Filter::Or => {
|
||||
filter_stack.push(cond);
|
||||
}
|
||||
Filter::Not => {
|
||||
filter_stack.push(cond);
|
||||
include_term = !include_term;
|
||||
}
|
||||
Filter::Close => {
|
||||
if matches!(filter_stack.pop(), Some(Filter::Not)) {
|
||||
include_term = !include_term;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let account_id = request.account_id.document_id();
|
||||
let cached_messages = self
|
||||
.get_cached_messages(account_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
let document_ids = if access_token.is_member(account_id) {
|
||||
cached_messages.email_document_ids()
|
||||
} else {
|
||||
cached_messages.shared_messages(access_token, Acl::ReadItems)
|
||||
};
|
||||
|
||||
let email_ids = request.email_ids.unwrap();
|
||||
let mut response = GetSearchSnippetResponse {
|
||||
account_id: request.account_id,
|
||||
list: Vec::with_capacity(email_ids.len()),
|
||||
not_found: None,
|
||||
};
|
||||
let mut not_found = Vec::new();
|
||||
|
||||
if email_ids.len() > self.core.jmap.snippet_max_results {
|
||||
return Err(trc::JmapEvent::RequestTooLarge.into_err());
|
||||
}
|
||||
|
||||
for email_id in email_ids {
|
||||
let email_id = match email_id {
|
||||
MaybeInvalid::Value(email_id) => email_id,
|
||||
invalid => {
|
||||
not_found.push(invalid);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let document_id = email_id.document_id();
|
||||
let mut snippet = SearchSnippet {
|
||||
email_id,
|
||||
subject: None,
|
||||
preview: None,
|
||||
};
|
||||
if !document_ids.contains(document_id) {
|
||||
not_found.push(MaybeInvalid::Value(email_id));
|
||||
continue;
|
||||
} else if terms.is_empty() {
|
||||
response.list.push(snippet);
|
||||
continue;
|
||||
}
|
||||
let metadata_ = match self
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::property(
|
||||
account_id,
|
||||
Collection::Email,
|
||||
document_id,
|
||||
EmailField::Metadata,
|
||||
))
|
||||
.await?
|
||||
{
|
||||
Some(metadata) => metadata,
|
||||
None => {
|
||||
not_found.push(MaybeInvalid::Value(email_id));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let metadata = metadata_
|
||||
.unarchive::<MessageMetadata>()
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
// Add subject snippet
|
||||
let contents = &metadata.contents[0];
|
||||
if let Some(subject) = contents
|
||||
.root_part()
|
||||
.header_value(&MetadataHeaderName::Subject)
|
||||
.and_then(|v| v.as_text())
|
||||
.and_then(|v| generate_snippet(v, &terms, language, is_exact))
|
||||
{
|
||||
snippet.subject = subject.into();
|
||||
}
|
||||
|
||||
// Download message
|
||||
let raw_body = if let Some(raw_body) = self
|
||||
.blob_store()
|
||||
.get_blob(metadata.blob_hash.0.as_slice(), 0..usize::MAX)
|
||||
.await?
|
||||
{
|
||||
raw_body
|
||||
} else {
|
||||
trc::event!(
|
||||
Store(trc::StoreEvent::NotFound),
|
||||
AccountId = account_id,
|
||||
DocumentId = email_id.document_id(),
|
||||
Collection = Collection::Email,
|
||||
BlobId = metadata.blob_hash.0.as_slice(),
|
||||
Details = "Blob not found.",
|
||||
CausedBy = trc::location!(),
|
||||
);
|
||||
|
||||
not_found.push(MaybeInvalid::Value(email_id));
|
||||
continue;
|
||||
};
|
||||
let raw_message = ChainedBytes::new(metadata.raw_headers.as_ref()).with_last(
|
||||
raw_body
|
||||
.get(metadata.blob_body_offset.to_native() as usize..)
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
|
||||
// Find a matching part
|
||||
'outer: for part in contents.parts.iter() {
|
||||
match &part.body {
|
||||
ArchivedMetadataPartType::Text => {
|
||||
let text = match part.decode_contents(&raw_message) {
|
||||
DecodedPartContent::Text(text) => text,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
if let Some(body) = generate_snippet(&text, &terms, language, is_exact) {
|
||||
snippet.preview = body.into();
|
||||
break;
|
||||
}
|
||||
}
|
||||
ArchivedMetadataPartType::Html => {
|
||||
let text = match part.decode_contents(&raw_message) {
|
||||
DecodedPartContent::Text(html) => html_to_text(&html),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
if let Some(body) = generate_snippet(&text, &terms, language, is_exact) {
|
||||
snippet.preview = body.into();
|
||||
break;
|
||||
}
|
||||
}
|
||||
ArchivedMetadataPartType::Message(message) => {
|
||||
for part in metadata.contents[u16::from(message) as usize].parts.iter() {
|
||||
if let ArchivedMetadataPartType::Text | ArchivedMetadataPartType::Html =
|
||||
part.body
|
||||
{
|
||||
let text = match (part.decode_contents(&raw_message), &part.body) {
|
||||
(
|
||||
DecodedPartContent::Text(text),
|
||||
ArchivedMetadataPartType::Text,
|
||||
) => text,
|
||||
(
|
||||
DecodedPartContent::Text(html),
|
||||
ArchivedMetadataPartType::Html,
|
||||
) => html_to_text(&html).into(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
if let Some(body) =
|
||||
generate_snippet(&text, &terms, language, is_exact)
|
||||
{
|
||||
snippet.preview = body.into();
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
//}
|
||||
|
||||
response.list.push(snippet);
|
||||
}
|
||||
|
||||
if !not_found.is_empty() {
|
||||
response.not_found = Some(not_found);
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user