Files
Stalwart/crates/jmap/src/email/import.rs
2026-03-17 18:15:50 +01:00

207 lines
7.6 KiB
Rust

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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};
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::MessageParser;
use std::future::Future;
use types::{acl::Acl, id::Id, keyword::Keyword};
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(),
};
'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
match self
.email_ingest(IngestEmail {
raw_message: &raw_message,
message: MessageParser::new().parse(&raw_message),
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) => {
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 !response.created.is_empty() {
response.new_state = self.get_cached_messages(account_id).await?.get_state(false);
}
Ok(response)
}
}