mirror of
https://github.com/stalwartlabs/mail-server.git
synced 2025-09-07 04:24:15 +08:00
Email/copy passing tests
This commit is contained in:
parent
f928be38ad
commit
ba537a43dc
17 changed files with 696 additions and 152 deletions
|
@ -15,7 +15,7 @@ use crate::{
|
|||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CopyRequest {
|
||||
pub struct CopyRequest<T> {
|
||||
pub from_account_id: Id,
|
||||
pub if_from_in_state: Option<State>,
|
||||
pub account_id: Id,
|
||||
|
@ -23,7 +23,7 @@ pub struct CopyRequest {
|
|||
pub create: VecMap<MaybeReference<Id, String>, Object<SetValue>>,
|
||||
pub on_success_destroy_original: Option<bool>,
|
||||
pub destroy_from_if_in_state: Option<State>,
|
||||
pub arguments: RequestArguments,
|
||||
pub arguments: T,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
|
@ -78,7 +78,7 @@ pub enum RequestArguments {
|
|||
Email,
|
||||
}
|
||||
|
||||
impl JsonObjectParser for CopyRequest {
|
||||
impl JsonObjectParser for CopyRequest<RequestArguments> {
|
||||
fn parse(parser: &mut Parser) -> crate::parser::Result<Self>
|
||||
where
|
||||
Self: Sized,
|
||||
|
|
|
@ -82,6 +82,12 @@ impl ToBitmaps for Object<Value> {
|
|||
}
|
||||
}
|
||||
|
||||
impl ToBitmaps for &Object<Value> {
|
||||
fn to_bitmaps(&self, _ops: &mut Vec<store::write::Operation>, _field: u8, _set: bool) {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
|
||||
const TEXT: u8 = 0;
|
||||
const UNSIGNED_INT: u8 = 1;
|
||||
const BOOL_TRUE: u8 = 2;
|
||||
|
@ -112,6 +118,12 @@ impl Deserialize for Value {
|
|||
}
|
||||
|
||||
impl Serialize for Object<Value> {
|
||||
fn serialize(self) -> Vec<u8> {
|
||||
(&self).serialize()
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for &Object<Value> {
|
||||
fn serialize(self) -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(1024);
|
||||
self.serialize_into(&mut buf);
|
||||
|
|
|
@ -113,6 +113,10 @@ impl Display for MethodName {
|
|||
}
|
||||
|
||||
impl MethodName {
|
||||
pub fn new(obj: MethodObject, fnc: MethodFunction) -> Self {
|
||||
Self { obj, fnc }
|
||||
}
|
||||
|
||||
pub fn error() -> Self {
|
||||
Self {
|
||||
obj: MethodObject::Thread,
|
||||
|
|
|
@ -13,7 +13,7 @@ use crate::{
|
|||
error::method::MethodError,
|
||||
method::{
|
||||
changes::ChangesRequest,
|
||||
copy::{CopyBlobRequest, CopyRequest},
|
||||
copy::{self, CopyBlobRequest, CopyRequest},
|
||||
get::{self, GetRequest},
|
||||
import::ImportEmailRequest,
|
||||
parse::ParseEmailRequest,
|
||||
|
@ -54,7 +54,7 @@ pub enum RequestMethod {
|
|||
Get(GetRequest<get::RequestArguments>),
|
||||
Set(SetRequest<set::RequestArguments>),
|
||||
Changes(ChangesRequest),
|
||||
Copy(CopyRequest),
|
||||
Copy(CopyRequest<copy::RequestArguments>),
|
||||
CopyBlob(CopyBlobRequest),
|
||||
ImportEmail(ImportEmailRequest),
|
||||
ParseEmail(ParseEmailRequest),
|
||||
|
|
|
@ -262,6 +262,13 @@ impl Value {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn as_uint(&self) -> Option<u64> {
|
||||
match self {
|
||||
Value::UnsignedInt(u) => Some(*u),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_cast_uint(&self) -> Option<u64> {
|
||||
match self {
|
||||
Value::UnsignedInt(u) => Some(*u),
|
||||
|
|
|
@ -26,65 +26,79 @@ impl JMAP {
|
|||
continue;
|
||||
}
|
||||
|
||||
let method_response: ResponseMethod = match call.method {
|
||||
RequestMethod::Get(mut req) => match req.take_arguments() {
|
||||
get::RequestArguments::Email(arguments) => {
|
||||
self.email_get(req.with_arguments(arguments)).await.into()
|
||||
loop {
|
||||
let mut next_call = None;
|
||||
let method_response: ResponseMethod = match call.method {
|
||||
RequestMethod::Get(mut req) => match req.take_arguments() {
|
||||
get::RequestArguments::Email(arguments) => {
|
||||
self.email_get(req.with_arguments(arguments)).await.into()
|
||||
}
|
||||
get::RequestArguments::Mailbox => self.mailbox_get(req).await.into(),
|
||||
get::RequestArguments::Thread => self.thread_get(req).await.into(),
|
||||
get::RequestArguments::Identity => todo!(),
|
||||
get::RequestArguments::EmailSubmission => todo!(),
|
||||
get::RequestArguments::PushSubscription => todo!(),
|
||||
get::RequestArguments::SieveScript => todo!(),
|
||||
get::RequestArguments::VacationResponse => todo!(),
|
||||
get::RequestArguments::Principal => todo!(),
|
||||
},
|
||||
RequestMethod::Query(mut req) => match req.take_arguments() {
|
||||
query::RequestArguments::Email(arguments) => {
|
||||
self.email_query(req.with_arguments(arguments)).await.into()
|
||||
}
|
||||
query::RequestArguments::Mailbox(arguments) => self
|
||||
.mailbox_query(req.with_arguments(arguments))
|
||||
.await
|
||||
.into(),
|
||||
query::RequestArguments::EmailSubmission => todo!(),
|
||||
query::RequestArguments::SieveScript => todo!(),
|
||||
query::RequestArguments::Principal => todo!(),
|
||||
},
|
||||
RequestMethod::Set(mut req) => match req.take_arguments() {
|
||||
set::RequestArguments::Email => self.email_set(req).await.into(),
|
||||
set::RequestArguments::Mailbox(arguments) => {
|
||||
self.mailbox_set(req.with_arguments(arguments)).await.into()
|
||||
}
|
||||
set::RequestArguments::Identity => todo!(),
|
||||
set::RequestArguments::EmailSubmission(_) => todo!(),
|
||||
set::RequestArguments::PushSubscription => todo!(),
|
||||
set::RequestArguments::SieveScript(_) => todo!(),
|
||||
set::RequestArguments::VacationResponse => todo!(),
|
||||
set::RequestArguments::Principal => todo!(),
|
||||
},
|
||||
RequestMethod::Changes(req) => self.changes(req).await.into(),
|
||||
RequestMethod::Copy(req) => self.email_copy(req, &mut next_call).await.into(),
|
||||
RequestMethod::CopyBlob(_) => todo!(),
|
||||
RequestMethod::ImportEmail(req) => self.email_import(req).await.into(),
|
||||
RequestMethod::ParseEmail(req) => self.email_parse(req).await.into(),
|
||||
RequestMethod::QueryChanges(req) => self.query_changes(req).await.into(),
|
||||
RequestMethod::SearchSnippet(req) => {
|
||||
self.email_search_snippet(req).await.into()
|
||||
}
|
||||
get::RequestArguments::Mailbox => self.mailbox_get(req).await.into(),
|
||||
get::RequestArguments::Thread => self.thread_get(req).await.into(),
|
||||
get::RequestArguments::Identity => todo!(),
|
||||
get::RequestArguments::EmailSubmission => todo!(),
|
||||
get::RequestArguments::PushSubscription => todo!(),
|
||||
get::RequestArguments::SieveScript => todo!(),
|
||||
get::RequestArguments::VacationResponse => todo!(),
|
||||
get::RequestArguments::Principal => todo!(),
|
||||
},
|
||||
RequestMethod::Query(mut req) => match req.take_arguments() {
|
||||
query::RequestArguments::Email(arguments) => {
|
||||
self.email_query(req.with_arguments(arguments)).await.into()
|
||||
}
|
||||
query::RequestArguments::Mailbox(arguments) => self
|
||||
.mailbox_query(req.with_arguments(arguments))
|
||||
.await
|
||||
.into(),
|
||||
query::RequestArguments::EmailSubmission => todo!(),
|
||||
query::RequestArguments::SieveScript => todo!(),
|
||||
query::RequestArguments::Principal => todo!(),
|
||||
},
|
||||
RequestMethod::Set(mut req) => match req.take_arguments() {
|
||||
set::RequestArguments::Email => self.email_set(req).await.into(),
|
||||
set::RequestArguments::Mailbox(arguments) => {
|
||||
self.mailbox_set(req.with_arguments(arguments)).await.into()
|
||||
}
|
||||
set::RequestArguments::Identity => todo!(),
|
||||
set::RequestArguments::EmailSubmission(_) => todo!(),
|
||||
set::RequestArguments::PushSubscription => todo!(),
|
||||
set::RequestArguments::SieveScript(_) => todo!(),
|
||||
set::RequestArguments::VacationResponse => todo!(),
|
||||
set::RequestArguments::Principal => todo!(),
|
||||
},
|
||||
RequestMethod::Changes(req) => self.changes(req).await.into(),
|
||||
RequestMethod::Copy(_) => todo!(),
|
||||
RequestMethod::CopyBlob(_) => todo!(),
|
||||
RequestMethod::ImportEmail(req) => self.email_import(req).await.into(),
|
||||
RequestMethod::ParseEmail(req) => self.email_parse(req).await.into(),
|
||||
RequestMethod::QueryChanges(req) => self.query_changes(req).await.into(),
|
||||
RequestMethod::SearchSnippet(req) => self.email_search_snippet(req).await.into(),
|
||||
RequestMethod::ValidateScript(_) => todo!(),
|
||||
RequestMethod::Echo(req) => req.into(),
|
||||
RequestMethod::Error(error) => error.into(),
|
||||
};
|
||||
RequestMethod::ValidateScript(_) => todo!(),
|
||||
RequestMethod::Echo(req) => req.into(),
|
||||
RequestMethod::Error(error) => error.into(),
|
||||
};
|
||||
|
||||
response.push_response(
|
||||
call.id,
|
||||
if !matches!(method_response, ResponseMethod::Error(_)) {
|
||||
call.name
|
||||
// Add response
|
||||
response.push_response(
|
||||
call.id,
|
||||
if !matches!(method_response, ResponseMethod::Error(_)) {
|
||||
call.name
|
||||
} else {
|
||||
MethodName::error()
|
||||
},
|
||||
method_response,
|
||||
);
|
||||
|
||||
// Process next call
|
||||
if let Some(next_call) = next_call {
|
||||
call = next_call;
|
||||
call.id = response.method_responses.last().unwrap().id.clone();
|
||||
} else {
|
||||
MethodName::error()
|
||||
},
|
||||
method_response,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
|
|
346
crates/jmap/src/email/copy.rs
Normal file
346
crates/jmap/src/email/copy.rs
Normal file
|
@ -0,0 +1,346 @@
|
|||
use jmap_proto::{
|
||||
error::{method::MethodError, set::SetError},
|
||||
method::{
|
||||
copy::{CopyRequest, CopyResponse, RequestArguments},
|
||||
set::{self, SetRequest},
|
||||
},
|
||||
object::Object,
|
||||
request::{
|
||||
method::{MethodFunction, MethodName, MethodObject},
|
||||
reference::MaybeReference,
|
||||
Call, RequestMethod,
|
||||
},
|
||||
types::{
|
||||
blob::BlobId,
|
||||
collection::Collection,
|
||||
id::Id,
|
||||
property::Property,
|
||||
value::{SetValue, Value},
|
||||
},
|
||||
};
|
||||
use mail_parser::parsers::fields::thread::thread_name;
|
||||
use store::{
|
||||
fts::term_index::TokenIndex,
|
||||
query::RawValue,
|
||||
write::{BatchBuilder, F_BITMAP, F_VALUE},
|
||||
BlobKind,
|
||||
};
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
use crate::JMAP;
|
||||
|
||||
use super::{
|
||||
index::{EmailIndexBuilder, TrimTextValue, MAX_SORT_FIELD_LENGTH},
|
||||
ingest::IngestedEmail,
|
||||
};
|
||||
|
||||
impl JMAP {
|
||||
pub async fn email_copy(
|
||||
&self,
|
||||
request: CopyRequest<RequestArguments>,
|
||||
next_call: &mut Option<Call<RequestMethod>>,
|
||||
) -> Result<CopyResponse, MethodError> {
|
||||
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(MethodError::InvalidArguments(
|
||||
"From accountId is equal to fromAccountId".to_string(),
|
||||
));
|
||||
}
|
||||
let old_state = self
|
||||
.assert_state(account_id, Collection::Email, &request.if_in_state)
|
||||
.await?;
|
||||
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_message_ids = self
|
||||
.get_document_ids(from_account_id, Collection::Email)
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
let mailbox_ids = self
|
||||
.get_document_ids(account_id, Collection::Mailbox)
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
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 {
|
||||
let id = id.unwrap();
|
||||
let from_message_id = id.document_id();
|
||||
if !from_message_ids.contains(from_message_id) {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::not_found().with_description(format!(
|
||||
"Item {} not found not found in account {}.",
|
||||
id, response.from_account_id
|
||||
)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut mailboxes = Vec::new();
|
||||
let mut keywords = Vec::new();
|
||||
let mut received_at = None;
|
||||
|
||||
for (property, value) in create.properties {
|
||||
match (property, value) {
|
||||
(Property::MailboxIds, SetValue::Value(Value::List(ids))) => {
|
||||
mailboxes = ids
|
||||
.into_iter()
|
||||
.map(|id| id.unwrap_id().document_id())
|
||||
.collect();
|
||||
}
|
||||
|
||||
(Property::MailboxIds, SetValue::Patch(patch)) => {
|
||||
let mut patch = patch.into_iter();
|
||||
let document_id = patch.next().unwrap().unwrap_id().document_id();
|
||||
if patch.next().unwrap().unwrap_bool() {
|
||||
if !mailboxes.contains(&document_id) {
|
||||
mailboxes.push(document_id);
|
||||
}
|
||||
} else {
|
||||
mailboxes.retain(|id| id != &document_id);
|
||||
}
|
||||
}
|
||||
|
||||
(Property::Keywords, SetValue::Value(Value::List(keywords_))) => {
|
||||
keywords = keywords_
|
||||
.into_iter()
|
||||
.map(|keyword| keyword.unwrap_keyword())
|
||||
.collect();
|
||||
}
|
||||
|
||||
(Property::Keywords, SetValue::Patch(patch)) => {
|
||||
let mut patch = patch.into_iter();
|
||||
let keyword = patch.next().unwrap().unwrap_keyword();
|
||||
if patch.next().unwrap().unwrap_bool() {
|
||||
if !keywords.contains(&keyword) {
|
||||
keywords.push(keyword);
|
||||
}
|
||||
} else {
|
||||
keywords.retain(|k| k != &keyword);
|
||||
}
|
||||
}
|
||||
(Property::ReceivedAt, SetValue::Value(Value::Date(value))) => {
|
||||
received_at = value.into();
|
||||
}
|
||||
(property, _) => {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(property)
|
||||
.with_description("Invalid property or value.".to_string()),
|
||||
);
|
||||
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(Property::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 !mailbox_ids.contains(*mailbox_id) {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(Property::MailboxIds)
|
||||
.with_description(format!("mailboxId {mailbox_id} does not exist.")),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
}
|
||||
|
||||
let validate_acl = "true";
|
||||
|
||||
// Obtain term index and metadata
|
||||
let (mut metadata, token_index) = if let (Some(metadata), Some(token_index)) = (
|
||||
self.get_property::<Object<Value>>(
|
||||
from_account_id,
|
||||
Collection::Email,
|
||||
from_message_id,
|
||||
Property::BodyStructure,
|
||||
)
|
||||
.await?,
|
||||
self.get_term_index::<RawValue<TokenIndex>>(
|
||||
from_account_id,
|
||||
Collection::Email,
|
||||
from_message_id,
|
||||
)
|
||||
.await?,
|
||||
) {
|
||||
(metadata, token_index)
|
||||
} else {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::not_found().with_description(format!(
|
||||
"Item {} not found not found in account {}.",
|
||||
id, response.from_account_id
|
||||
)),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
// Set receivedAt
|
||||
if let Some(received_at) = received_at {
|
||||
metadata.set(Property::ReceivedAt, Value::Date(received_at));
|
||||
}
|
||||
|
||||
// Obtain threadId
|
||||
let mut references = vec![];
|
||||
let mut subject = "";
|
||||
for (property, value) in &metadata.properties {
|
||||
match property {
|
||||
Property::MessageId
|
||||
| Property::InReplyTo
|
||||
| Property::References
|
||||
| Property::EmailIds => match value {
|
||||
Value::Text(text) => {
|
||||
references.push(text.as_str());
|
||||
}
|
||||
Value::List(list) => {
|
||||
references.extend(list.iter().filter_map(|v| v.as_string()));
|
||||
}
|
||||
_ => (),
|
||||
},
|
||||
Property::Subject => {
|
||||
if let Some(value) = value.as_string() {
|
||||
subject = thread_name(value).trim_text(MAX_SORT_FIELD_LENGTH);
|
||||
}
|
||||
if subject.is_empty() {
|
||||
subject = "!";
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
let thread_id = if !references.is_empty() {
|
||||
self.find_or_merge_thread(account_id, subject, &references)
|
||||
.await
|
||||
.map_err(|_| MethodError::ServerPartialFail)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Copy blob
|
||||
let message_id = self
|
||||
.assign_document_id(account_id, Collection::Email)
|
||||
.await?;
|
||||
let mut email = IngestedEmail {
|
||||
blob_id: BlobId::new(BlobKind::LinkedMaildir {
|
||||
account_id,
|
||||
document_id: message_id,
|
||||
}),
|
||||
size: metadata.get(&Property::Size).as_uint().unwrap_or(0) as usize,
|
||||
..Default::default()
|
||||
};
|
||||
self.store
|
||||
.copy_blob(
|
||||
&BlobKind::LinkedMaildir {
|
||||
account_id: from_account_id,
|
||||
document_id: from_message_id,
|
||||
},
|
||||
&email.blob_id.kind,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
tracing::error!(
|
||||
event = "error",
|
||||
context = "email_copy",
|
||||
from_account_id = from_account_id,
|
||||
from_message_id = from_message_id,
|
||||
account_id = account_id,
|
||||
message_id = message_id,
|
||||
error = ?err,
|
||||
"Failed to copy blob.");
|
||||
MethodError::ServerPartialFail
|
||||
})?;
|
||||
|
||||
// Build change log
|
||||
let mut changes = self.begin_changes(account_id).await?;
|
||||
let thread_id = if let Some(thread_id) = thread_id {
|
||||
changes.log_child_update(Collection::Thread, thread_id);
|
||||
thread_id
|
||||
} else {
|
||||
let thread_id = self
|
||||
.assign_document_id(account_id, Collection::Thread)
|
||||
.await?;
|
||||
changes.log_insert(Collection::Thread, thread_id);
|
||||
thread_id
|
||||
};
|
||||
email.id = Id::from_parts(thread_id, message_id);
|
||||
email.change_id = changes.change_id;
|
||||
changes.log_insert(Collection::Email, email.id);
|
||||
for mailbox_id in &mailboxes {
|
||||
changes.log_child_update(Collection::Mailbox, *mailbox_id);
|
||||
}
|
||||
|
||||
// Build batch
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch
|
||||
.with_account_id(account_id)
|
||||
.with_collection(Collection::Email)
|
||||
.create_document(message_id)
|
||||
.value(Property::ThreadId, thread_id, F_VALUE | F_BITMAP)
|
||||
.value(Property::MailboxIds, mailboxes, F_VALUE | F_BITMAP)
|
||||
.value(Property::Keywords, keywords, F_VALUE | F_BITMAP)
|
||||
.custom(EmailIndexBuilder::set(metadata))
|
||||
.custom(token_index)
|
||||
.custom(changes);
|
||||
self.store.write(batch.build()).await.map_err(|err| {
|
||||
tracing::error!(
|
||||
event = "error",
|
||||
context = "email_copy",
|
||||
error = ?err,
|
||||
"Failed to write message to database.");
|
||||
MethodError::ServerPartialFail
|
||||
})?;
|
||||
|
||||
// Update state
|
||||
response.new_state = email.change_id.into();
|
||||
|
||||
// Add response
|
||||
response.created.append(id, email.into());
|
||||
|
||||
// Add to destroy list
|
||||
if on_success_delete {
|
||||
destroy_ids.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
// 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(SetRequest {
|
||||
account_id: request.from_account_id,
|
||||
if_in_state: request.destroy_from_if_in_state,
|
||||
create: None,
|
||||
update: None,
|
||||
destroy: MaybeReference::Value(destroy_ids).into(),
|
||||
arguments: set::RequestArguments::Email,
|
||||
}),
|
||||
}
|
||||
.into();
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
|
@ -19,7 +19,7 @@ use store::{
|
|||
builder::{FtsIndexBuilder, MAX_TOKEN_LENGTH},
|
||||
Language,
|
||||
},
|
||||
write::{BatchBuilder, F_BITMAP, F_INDEX, F_VALUE},
|
||||
write::{BatchBuilder, IntoOperations, F_BITMAP, F_CLEAR, F_INDEX, F_VALUE},
|
||||
};
|
||||
|
||||
use crate::email::headers::IntoForm;
|
||||
|
@ -43,7 +43,7 @@ pub(super) trait IndexMessage {
|
|||
mailbox_ids: Vec<u32>,
|
||||
received_at: u64,
|
||||
default_language: Language,
|
||||
) -> store::Result<()>;
|
||||
) -> store::Result<&mut Self>;
|
||||
}
|
||||
|
||||
impl IndexMessage for BatchBuilder {
|
||||
|
@ -54,7 +54,7 @@ impl IndexMessage for BatchBuilder {
|
|||
mailbox_ids: Vec<u32>,
|
||||
received_at: u64,
|
||||
default_language: Language,
|
||||
) -> store::Result<()> {
|
||||
) -> store::Result<&mut Self> {
|
||||
let mut metadata = Object::with_capacity(15);
|
||||
|
||||
// Index keywords
|
||||
|
@ -360,7 +360,99 @@ impl IndexMessage for BatchBuilder {
|
|||
// Store full text index
|
||||
self.custom(fts);
|
||||
|
||||
Ok(())
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EmailIndexBuilder {
|
||||
inner: Object<Value>,
|
||||
set: bool,
|
||||
}
|
||||
|
||||
impl EmailIndexBuilder {
|
||||
pub fn set(inner: Object<Value>) -> Self {
|
||||
Self { inner, set: true }
|
||||
}
|
||||
|
||||
pub fn clear(inner: Object<Value>) -> Self {
|
||||
Self { inner, set: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoOperations for EmailIndexBuilder {
|
||||
fn build(self, batch: &mut BatchBuilder) {
|
||||
let options = if self.set {
|
||||
// Serialize metadata
|
||||
batch.value(Property::BodyStructure, &self.inner, F_VALUE);
|
||||
0
|
||||
} else {
|
||||
// Delete metadata
|
||||
batch.value(Property::BodyStructure, (), F_VALUE | F_CLEAR);
|
||||
F_CLEAR
|
||||
};
|
||||
|
||||
// Remove properties from index
|
||||
for (property, value) in self.inner.properties {
|
||||
match (&property, value) {
|
||||
(Property::Size, Value::UnsignedInt(size)) => {
|
||||
batch.value(Property::Size, size as u32, F_INDEX | options);
|
||||
}
|
||||
(Property::ReceivedAt | Property::SentAt, Value::Date(date)) => {
|
||||
batch.value(property, date.timestamp() as u64, F_INDEX | options);
|
||||
}
|
||||
(
|
||||
Property::MessageId
|
||||
| Property::InReplyTo
|
||||
| Property::References
|
||||
| Property::EmailIds,
|
||||
Value::List(ids),
|
||||
) => {
|
||||
// Remove messageIds from index
|
||||
for id in ids {
|
||||
match id {
|
||||
Value::Text(id) if id.len() < MAX_ID_LENGTH => {
|
||||
batch.value(Property::MessageId, id, F_INDEX | options);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
(
|
||||
Property::From | Property::To | Property::Cc | Property::Bcc,
|
||||
Value::List(addresses),
|
||||
) => {
|
||||
let mut sort_text = SortedAddressBuilder::new();
|
||||
'outer: for addr in addresses {
|
||||
if let Some(addr) = addr.try_unwrap_object() {
|
||||
for part in [Property::Name, Property::Email] {
|
||||
if let Some(Value::Text(value)) = addr.properties.get(&part) {
|
||||
if !sort_text.push(value) || part == Property::Email {
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
batch.value(property, sort_text.build(), F_INDEX | options);
|
||||
}
|
||||
(Property::Subject, Value::Text(value)) => {
|
||||
let thread_name = thread_name(&value);
|
||||
batch.value(
|
||||
Property::Subject,
|
||||
if !thread_name.is_empty() {
|
||||
thread_name.trim_text(MAX_SORT_FIELD_LENGTH)
|
||||
} else {
|
||||
"!"
|
||||
},
|
||||
F_INDEX | options,
|
||||
);
|
||||
}
|
||||
(Property::HasAttachment, Value::Bool(true)) => {
|
||||
batch.bitmap(Property::HasAttachment, (), options);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -23,6 +23,7 @@ use crate::{
|
|||
|
||||
use super::index::{TrimTextValue, MAX_SORT_FIELD_LENGTH};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct IngestedEmail {
|
||||
pub id: Id,
|
||||
pub change_id: u64,
|
||||
|
@ -176,9 +177,9 @@ impl JMAP {
|
|||
error = ?err,
|
||||
"Failed to index message.");
|
||||
MaybeError::Temporary
|
||||
})?;
|
||||
batch.value(Property::ThreadId, thread_id, F_VALUE | F_BITMAP);
|
||||
batch.custom(changes);
|
||||
})?
|
||||
.value(Property::ThreadId, thread_id, F_VALUE | F_BITMAP)
|
||||
.custom(changes);
|
||||
self.store.write(batch.build()).await.map_err(|err| {
|
||||
tracing::error!(
|
||||
event = "error",
|
||||
|
@ -196,7 +197,7 @@ impl JMAP {
|
|||
})
|
||||
}
|
||||
|
||||
async fn find_or_merge_thread(
|
||||
pub async fn find_or_merge_thread(
|
||||
&self,
|
||||
account_id: u32,
|
||||
thread_name: &str,
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
pub mod body;
|
||||
pub mod copy;
|
||||
pub mod get;
|
||||
pub mod headers;
|
||||
pub mod import;
|
||||
|
|
|
@ -23,13 +23,12 @@ use mail_builder::{
|
|||
mime::{BodyPart, MimePart},
|
||||
MessageBuilder,
|
||||
};
|
||||
use mail_parser::parsers::fields::thread::thread_name;
|
||||
use store::{
|
||||
ahash::AHashSet,
|
||||
fts::term_index::TokenIndex,
|
||||
write::{
|
||||
assert::HashedValue, log::ChangeLogBuilder, BatchBuilder, DeserializeFrom, SerializeInto,
|
||||
ToBitmaps, F_BITMAP, F_CLEAR, F_INDEX, F_VALUE,
|
||||
ToBitmaps, F_BITMAP, F_CLEAR, F_VALUE,
|
||||
},
|
||||
BlobKind, Serialize, ValueKey,
|
||||
};
|
||||
|
@ -38,7 +37,7 @@ use crate::JMAP;
|
|||
|
||||
use super::{
|
||||
headers::{BuildHeader, ValueToHeader},
|
||||
index::{SortedAddressBuilder, TrimTextValue, MAX_ID_LENGTH, MAX_SORT_FIELD_LENGTH},
|
||||
index::EmailIndexBuilder,
|
||||
};
|
||||
|
||||
impl JMAP {
|
||||
|
@ -53,7 +52,7 @@ impl JMAP {
|
|||
.await?;
|
||||
|
||||
// Obtain mailboxIds
|
||||
let mut mailbox_ids = self
|
||||
let mailbox_ids = self
|
||||
.get_document_ids(account_id, Collection::Mailbox)
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
|
@ -1037,8 +1036,8 @@ impl JMAP {
|
|||
return Ok(Err(SetError::not_found()));
|
||||
}
|
||||
|
||||
// Obtain message metadata
|
||||
let metadata = if let Some(metadata) = self
|
||||
// Remove message metadata
|
||||
if let Some(metadata) = self
|
||||
.get_property::<Object<Value>>(
|
||||
account_id,
|
||||
Collection::Email,
|
||||
|
@ -1047,7 +1046,7 @@ impl JMAP {
|
|||
)
|
||||
.await?
|
||||
{
|
||||
metadata
|
||||
batch.custom(EmailIndexBuilder::clear(metadata));
|
||||
} else {
|
||||
tracing::debug!(
|
||||
event = "error",
|
||||
|
@ -1059,72 +1058,6 @@ impl JMAP {
|
|||
return Ok(Err(SetError::not_found()));
|
||||
};
|
||||
|
||||
// Delete metadata
|
||||
batch.value(Property::BodyStructure, (), F_VALUE | F_CLEAR);
|
||||
|
||||
// Remove properties from index
|
||||
for (property, value) in metadata.properties {
|
||||
match (&property, value) {
|
||||
(Property::Size, Value::UnsignedInt(size)) => {
|
||||
batch.value(Property::Size, size as u32, F_INDEX | F_CLEAR);
|
||||
}
|
||||
(Property::ReceivedAt | Property::SentAt, Value::Date(date)) => {
|
||||
batch.value(property, date.timestamp() as u64, F_INDEX | F_CLEAR);
|
||||
}
|
||||
(
|
||||
Property::MessageId
|
||||
| Property::InReplyTo
|
||||
| Property::References
|
||||
| Property::EmailIds,
|
||||
Value::List(ids),
|
||||
) => {
|
||||
// Remove messageIds from index
|
||||
for id in ids {
|
||||
match id {
|
||||
Value::Text(id) if id.len() < MAX_ID_LENGTH => {
|
||||
batch.value(Property::MessageId, id, F_INDEX | F_CLEAR);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
(
|
||||
Property::From | Property::To | Property::Cc | Property::Bcc,
|
||||
Value::List(addresses),
|
||||
) => {
|
||||
let mut sort_text = SortedAddressBuilder::new();
|
||||
'outer: for addr in addresses {
|
||||
if let Some(addr) = addr.try_unwrap_object() {
|
||||
for part in [Property::Name, Property::Email] {
|
||||
if let Some(Value::Text(value)) = addr.properties.get(&part) {
|
||||
if !sort_text.push(value) || part == Property::Email {
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
batch.value(property, sort_text.build(), F_INDEX | F_CLEAR);
|
||||
}
|
||||
(Property::Subject, Value::Text(value)) => {
|
||||
let thread_name = thread_name(&value);
|
||||
batch.value(
|
||||
Property::Subject,
|
||||
if !thread_name.is_empty() {
|
||||
thread_name.trim_text(MAX_SORT_FIELD_LENGTH)
|
||||
} else {
|
||||
"!"
|
||||
},
|
||||
F_INDEX | F_CLEAR,
|
||||
);
|
||||
}
|
||||
(Property::HasAttachment, Value::Bool(true)) => {
|
||||
batch.bitmap(Property::HasAttachment, (), F_CLEAR);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete term index
|
||||
if let Some(token_index) = self
|
||||
.store
|
||||
|
|
|
@ -12,7 +12,7 @@ use store::{
|
|||
builder::MAX_TOKEN_LENGTH,
|
||||
search_snippet::generate_snippet,
|
||||
stemmer::Stemmer,
|
||||
term_index::{self},
|
||||
term_index::{self, TermIndex},
|
||||
tokenizers::Tokenizer,
|
||||
Language,
|
||||
},
|
||||
|
@ -110,7 +110,7 @@ impl JMAP {
|
|||
|
||||
// Obtain the term index and raw message
|
||||
let (term_index, raw_message) = if let (Some(term_index), Some(raw_message)) = (
|
||||
self.get_term_index(account_id, Collection::Email, document_id)
|
||||
self.get_term_index::<TermIndex>(account_id, Collection::Email, document_id)
|
||||
.await?,
|
||||
self.get_blob(
|
||||
&BlobKind::LinkedMaildir {
|
||||
|
|
|
@ -153,15 +153,15 @@ impl JMAP {
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn get_term_index(
|
||||
pub async fn get_term_index<T: Deserialize + 'static>(
|
||||
&self,
|
||||
account_id: u32,
|
||||
collection: Collection,
|
||||
document_id: u32,
|
||||
) -> Result<Option<TermIndex>, MethodError> {
|
||||
) -> Result<Option<T>, MethodError> {
|
||||
match self
|
||||
.store
|
||||
.get_value::<TermIndex>(ValueKey {
|
||||
.get_value::<T>(ValueKey {
|
||||
account_id,
|
||||
collection: collection.into(),
|
||||
document_id,
|
||||
|
|
|
@ -4,6 +4,7 @@ use ahash::AHashSet;
|
|||
use utils::map::vec_map::VecMap;
|
||||
|
||||
use crate::{
|
||||
query::RawValue,
|
||||
write::{BatchBuilder, IntoOperations, Operation},
|
||||
Serialize, HASH_EXACT, HASH_STEMMED,
|
||||
};
|
||||
|
@ -132,8 +133,8 @@ impl<'x> IntoOperations for FtsIndexBuilder<'x> {
|
|||
}
|
||||
}
|
||||
|
||||
impl IntoOperations for TokenIndex {
|
||||
fn build(self, batch: &mut BatchBuilder) {
|
||||
impl TokenIndex {
|
||||
fn build_index(self, batch: &mut BatchBuilder, set: bool) {
|
||||
for term in self.terms {
|
||||
for (term_ids, is_exact) in [(term.exact_terms, true), (term.stemmed_terms, false)] {
|
||||
for term_id in term_ids {
|
||||
|
@ -142,13 +143,18 @@ impl IntoOperations for TokenIndex {
|
|||
word,
|
||||
if is_exact { HASH_EXACT } else { HASH_STEMMED },
|
||||
term.field_id,
|
||||
false,
|
||||
set,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoOperations for TokenIndex {
|
||||
fn build(self, batch: &mut BatchBuilder) {
|
||||
self.build_index(batch, false);
|
||||
batch.ops.push(Operation::Value {
|
||||
field: u8::MAX,
|
||||
family: u8::MAX,
|
||||
|
@ -157,6 +163,17 @@ impl IntoOperations for TokenIndex {
|
|||
}
|
||||
}
|
||||
|
||||
impl IntoOperations for RawValue<TokenIndex> {
|
||||
fn build(self, batch: &mut BatchBuilder) {
|
||||
self.inner.build_index(batch, true);
|
||||
batch.ops.push(Operation::Value {
|
||||
field: u8::MAX,
|
||||
family: u8::MAX,
|
||||
set: self.raw.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ToTokens {
|
||||
fn to_tokens(&self) -> HashSet<String>;
|
||||
}
|
||||
|
|
|
@ -5,7 +5,9 @@ pub mod sort;
|
|||
|
||||
use roaring::RoaringBitmap;
|
||||
|
||||
use crate::{fts::Language, write::BitmapFamily, BitmapKey, Serialize, BM_DOCUMENT_IDS};
|
||||
use crate::{
|
||||
fts::Language, write::BitmapFamily, BitmapKey, Deserialize, Serialize, BM_DOCUMENT_IDS,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Operator {
|
||||
|
@ -220,3 +222,18 @@ impl BitmapKey<&'static [u8]> {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RawValue<T: Deserialize> {
|
||||
pub raw: Vec<u8>,
|
||||
pub inner: T,
|
||||
}
|
||||
|
||||
impl<T: Deserialize> Deserialize for RawValue<T> {
|
||||
fn deserialize(bytes: &[u8]) -> crate::Result<Self> {
|
||||
Ok(RawValue {
|
||||
inner: T::deserialize(bytes)?,
|
||||
raw: bytes.to_vec(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
98
tests/src/jmap/email_copy.rs
Normal file
98
tests/src/jmap/email_copy.rs
Normal file
|
@ -0,0 +1,98 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use jmap::JMAP;
|
||||
use jmap_client::{client::Client, mailbox::Role};
|
||||
use jmap_proto::types::id::Id;
|
||||
|
||||
pub async fn test(server: Arc<JMAP>, client: &mut Client) {
|
||||
println!("Running Email Copy tests...");
|
||||
|
||||
// Create a mailbox on account 1
|
||||
let ac1_mailbox_id = client
|
||||
.set_default_account_id(Id::new(1).to_string())
|
||||
.mailbox_create("Copy Test Ac# 1", None::<String>, Role::None)
|
||||
.await
|
||||
.unwrap()
|
||||
.take_id();
|
||||
|
||||
// Insert a message on account 1
|
||||
let ac1_email_id = client
|
||||
.email_import(
|
||||
concat!(
|
||||
"From: bill@example.com\r\n",
|
||||
"To: jdoe@example.com\r\n",
|
||||
"Subject: TPS Report\r\n",
|
||||
"\r\n",
|
||||
"I'm going to need those TPS reports ASAP. ",
|
||||
"So, if you could do that, that'd be great."
|
||||
)
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
[&ac1_mailbox_id],
|
||||
None::<Vec<&str>>,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.take_id();
|
||||
|
||||
// Create a mailbox on account 2
|
||||
let ac2_mailbox_id = client
|
||||
.set_default_account_id(Id::new(2).to_string())
|
||||
.mailbox_create("Copy Test Ac# 2", None::<String>, Role::None)
|
||||
.await
|
||||
.unwrap()
|
||||
.take_id();
|
||||
|
||||
// Copy the email and delete it from the first account
|
||||
let mut request = client.build();
|
||||
request
|
||||
.copy_email(Id::new(1).to_string())
|
||||
.on_success_destroy_original(true)
|
||||
.create(&ac1_email_id)
|
||||
.mailbox_id(&ac2_mailbox_id, true)
|
||||
.keyword("$draft", true)
|
||||
.received_at(311923920);
|
||||
let ac2_email_id = request
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.method_response_by_pos(0)
|
||||
.unwrap_copy_email()
|
||||
.unwrap()
|
||||
.created(&ac1_email_id)
|
||||
.unwrap()
|
||||
.take_id();
|
||||
|
||||
// Check that the email was copied
|
||||
let email = client
|
||||
.email_get(&ac2_email_id, None::<Vec<_>>)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
email.preview().unwrap(),
|
||||
"I'm going to need those TPS reports ASAP. So, if you could do that, that'd be great."
|
||||
);
|
||||
assert_eq!(email.subject().unwrap(), "TPS Report");
|
||||
assert_eq!(email.mailbox_ids(), &[&ac2_mailbox_id]);
|
||||
assert_eq!(email.keywords(), &["$draft"]);
|
||||
assert_eq!(email.received_at().unwrap(), 311923920);
|
||||
|
||||
// Check that the email was deleted
|
||||
assert!(client
|
||||
.set_default_account_id(Id::new(1).to_string())
|
||||
.email_get(&ac1_email_id, None::<Vec<_>>)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
|
||||
// Empty store
|
||||
client.mailbox_destroy(&ac1_mailbox_id, true).await.unwrap();
|
||||
client
|
||||
.set_default_account_id(Id::new(2).to_string())
|
||||
.mailbox_destroy(&ac2_mailbox_id, true)
|
||||
.await
|
||||
.unwrap();
|
||||
server.store.assert_is_empty().await;
|
||||
}
|
|
@ -8,6 +8,7 @@ use tokio::sync::watch;
|
|||
use crate::{add_test_certs, store::TempDir};
|
||||
|
||||
pub mod email_changes;
|
||||
pub mod email_copy;
|
||||
pub mod email_get;
|
||||
pub mod email_parse;
|
||||
pub mod email_query;
|
||||
|
@ -65,7 +66,8 @@ pub async fn jmap_tests() {
|
|||
//email_parse::test(params.server.clone(), &mut params.client).await;
|
||||
//email_search_snippet::test(params.server.clone(), &mut params.client).await;
|
||||
//email_changes::test(params.server.clone(), &mut params.client).await;
|
||||
email_query_changes::test(params.server.clone(), &mut params.client).await;
|
||||
//email_query_changes::test(params.server.clone(), &mut params.client).await;
|
||||
email_copy::test(params.server.clone(), &mut params.client).await;
|
||||
//thread_get::test(params.server.clone(), &mut params.client).await;
|
||||
//thread_merge::test(params.server.clone(), &mut params.client).await;
|
||||
//mailbox::test(params.server.clone(), &mut params.client).await;
|
||||
|
|
Loading…
Add table
Reference in a new issue