Files
inbuxa-server/crates/email/src/message/delivery.rs
T
jcoffey-dev b2ded0a776 Merge upstream v0.16.23
Five conflicts, resolved:

- crates/common/src/auth/authentication.rs: upstream's get_directory_for_token
  and JwtClaims replace extract_jwt_domain; the per-domain directory code
  (DIR-1, DIR-5 to DIR-7) is kept, and the token lookup routes through it.
  The release's one new Enterprise snippet was the body of
  get_directory_for_issuer, which stays returning None: a token naming no
  address gets the server default, as DIR-2 specifies and as v0.16.22 did.
- crates/common/src/manager/application.rs: upstream's rewrite of the tests,
  with the temp directory names renamed again, and the 5(a) notice the
  name-purge change should have added.
- crates/common/src/network/mta.rs: both sides' imports.
- crates/main/Cargo.toml: the AGPL-only license kept, version 0.16.23.
- Cargo.lock: upstream's, with the fork's crates added by Cargo.
2026-09-22 16:57:06 -07:00

380 lines
14 KiB
Rust

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use super::ingest::{EmailIngest, IngestEmail, IngestSource};
use crate::{
mailbox::{INBOX_ID, TRASH_ID},
sieve::ingest::SieveScriptIngest,
};
use common::{
Server,
auth::BuildAccessToken,
ipc::{EmailPush, PushNotification},
};
use mail_parser::MessageParser;
use registry::schema::enums::Permission;
use std::{borrow::Cow, future::Future};
use store::ahash::AHashMap;
use types::blob_hash::BlobHash;
pub const ORCPT_ADDR_TYPE: &str = "rfc822;";
#[derive(Debug)]
pub struct IngestMessage {
pub sender_address: String,
pub sender_authenticated: bool,
pub recipients: Vec<IngestRecipient>,
pub message_blob: BlobHash,
pub message_size: u64,
pub session_id: u64,
}
#[derive(Debug)]
pub struct IngestRecipient {
pub address: String,
pub orcpt: Option<String>,
pub spam_percentage: Option<u8>,
}
impl IngestRecipient {
pub fn orcpt_parameter(&self) -> Option<String> {
self.orcpt
.as_deref()
.map(|orcpt| format!("{ORCPT_ADDR_TYPE}{orcpt}"))
}
pub fn is_spam(&self) -> bool {
self.spam_percentage
.is_some_and(|percentage| percentage >= 50)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LocalDeliveryStatus {
Success,
TemporaryFailure {
reason: Cow<'static, str>,
},
PermanentFailure {
code: [u8; 3],
reason: Cow<'static, str>,
},
}
pub struct LocalDeliveryResult {
pub status: Vec<LocalDeliveryStatus>,
pub autogenerated: Vec<AutogeneratedMessage>,
}
pub struct AutogeneratedMessage {
pub sender_address: String,
pub recipients: Vec<String>,
pub message: Vec<u8>,
}
pub trait MailDelivery: Sync + Send {
fn deliver_message(
&self,
message: IngestMessage,
) -> impl Future<Output = LocalDeliveryResult> + Send;
}
impl MailDelivery for Server {
async fn deliver_message(&self, message: IngestMessage) -> LocalDeliveryResult {
// Read message
let raw_message = match self
.core
.storage
.blob
.get_blob(message.message_blob.as_slice(), 0..usize::MAX)
.await
{
Ok(Some(raw_message)) => raw_message,
Ok(None) => {
trc::event!(
MessageIngest(trc::MessageIngestEvent::Error),
Reason = "Blob not found.",
SpanId = message.session_id,
CausedBy = trc::location!()
);
return LocalDeliveryResult {
status: (0..message.recipients.len())
.map(|_| LocalDeliveryStatus::TemporaryFailure {
reason: "Blob not found.".into(),
})
.collect::<Vec<_>>(),
autogenerated: vec![],
};
}
Err(err) => {
trc::error!(
err.details("Failed to fetch message blob.")
.span_id(message.session_id)
.caused_by(trc::location!())
);
return LocalDeliveryResult {
status: (0..message.recipients.len())
.map(|_| LocalDeliveryStatus::TemporaryFailure {
reason: "Temporary I/O error.".into(),
})
.collect::<Vec<_>>(),
autogenerated: vec![],
};
}
};
// Obtain the account IDs for each recipient
let mut account_ids: AHashMap<u32, usize> =
AHashMap::with_capacity(message.recipients.len());
let mut result = LocalDeliveryResult {
status: Vec::with_capacity(message.recipients.len()),
autogenerated: Vec::new(),
};
for rcpt in message.recipients {
// inbuxa: ME-4, ME-10: a masked address delivers to its owner
let mut mask = match inbuxa_features::masked_email::ops::resolve_recipient(
&self.core.storage.data,
self.registry(),
&rcpt.address,
)
.await
{
Ok(mask) => mask,
Err(err) => {
trc::error!(
err.details("Failed to look up masked address.")
.ctx(trc::Key::To, rcpt.address.to_string())
.span_id(message.session_id)
);
result.status.push(LocalDeliveryStatus::TemporaryFailure {
reason: "Address lookup failed.".into(),
});
continue;
}
};
let account_lookup = match &mask {
Some(mask) => Ok(Some(mask.object.account_id.document_id())),
None => self.account_id_from_email(&rcpt.address, false).await,
};
let account_id = match account_lookup {
Ok(Some(account_id)) => account_id,
Ok(None) => {
// Something went wrong
result.status.push(LocalDeliveryStatus::PermanentFailure {
code: [5, 5, 0],
reason: "Mailbox not found.".into(),
});
continue;
}
Err(err) => {
trc::error!(
err.details("Failed to lookup recipient.")
.ctx(trc::Key::To, rcpt.address.to_string())
.span_id(message.session_id)
.caused_by(trc::location!())
);
result.status.push(LocalDeliveryStatus::TemporaryFailure {
reason: "Address lookup failed.".into(),
});
continue;
}
};
// inbuxa: ME-9: rewritten at RCPT TO, the mask is the original recipient
if mask.is_none() {
match inbuxa_features::masked_email::ops::resolve_original(
&self.core.storage.data,
self.registry(),
rcpt.orcpt.as_deref(),
account_id,
)
.await
{
Ok(original) => mask = original,
Err(err) => {
trc::error!(err.span_id(message.session_id));
}
}
}
if let Some(status) = account_ids
.get(&account_id)
.and_then(|pos| result.status.get(*pos))
{
result.status.push(status.clone());
continue;
}
// inbuxa: ME-9: the message names the mask it came through
let masked = match &mask {
Some(mask) => {
let raw = inbuxa_features::masked_email::ops::with_header(
&mask.object.email,
&raw_message,
);
match self.put_temporary_blob(account_id, &raw, 600).await {
Ok((hash, _)) => Some((raw, hash)),
Err(err) => {
trc::error!(err.span_id(message.session_id));
result.status.push(LocalDeliveryStatus::TemporaryFailure {
reason: "Temporary I/O error.".into(),
});
continue;
}
}
}
None => None,
};
let (raw_message, message_blob) = masked
.as_ref()
.map(|(raw, hash)| (raw.as_slice(), hash))
.unwrap_or((raw_message.as_slice(), &message.message_blob));
// inbuxa: ME-5: a disabled mask files straight to Trash
let to_trash = mask.as_ref().is_some_and(|mask| {
mask.state == inbuxa_features::masked_email::State::Disabled
});
// Obtain access token
let status = match self.access_token(account_id).await.and_then(|token| {
token
.build()
.assert_has_permission(Permission::EmailReceive)
}) {
Ok(access_token) => {
// Check if there is an active sieve script
let active_script = if to_trash {
Ok(None)
} else {
self.sieve_script_get_active(account_id).await
};
match active_script {
Ok(None) => {
// Ingest message
self.email_ingest(IngestEmail {
raw_message,
blob_hash: Some(message_blob),
message: MessageParser::new().parse(raw_message),
access_token: &access_token,
mailbox_ids: vec![if to_trash { TRASH_ID } else { INBOX_ID }],
keywords: vec![],
received_at: None,
source: IngestSource::Smtp {
deliver_to: &rcpt.address,
is_sender_authenticated: message.sender_authenticated,
is_spam: rcpt.is_spam(),
},
session_id: message.session_id,
})
.await
}
Ok(Some(active_script)) => {
self.sieve_script_ingest(
&access_token,
message_blob,
raw_message,
&message.sender_address,
message.sender_authenticated,
&rcpt,
message.session_id,
active_script,
&mut result.autogenerated,
)
.await
}
Err(err) => Err(err),
}
}
Err(err) => Err(err),
};
let status = match status {
Ok(ingested_message) => {
// inbuxa: ME-7: the mask saw mail, and a pending one is now enabled
if let Some(mask) = &mask
&& let Err(err) = inbuxa_features::masked_email::ops::delivered(
&self.core.storage.data,
self.registry(),
mask,
)
.await
{
trc::error!(err.span_id(message.session_id));
}
// Notify state change
if ingested_message.change_id != u64::MAX {
self.broadcast_push_notification(PushNotification::EmailPush(EmailPush {
account_id,
email_id: ingested_message.document_id,
change_id: ingested_message.change_id,
}))
.await;
}
LocalDeliveryStatus::Success
}
Err(err) => {
let status = match err.as_ref() {
trc::EventType::Limit(trc::LimitEvent::Quota) => {
LocalDeliveryStatus::TemporaryFailure {
reason: "Mailbox over quota.".into(),
}
}
trc::EventType::Limit(trc::LimitEvent::TenantQuota) => {
LocalDeliveryStatus::TemporaryFailure {
reason: "Organization over quota.".into(),
}
}
trc::EventType::Security(trc::SecurityEvent::Unauthorized) => {
LocalDeliveryStatus::PermanentFailure {
code: [5, 5, 0],
reason: "This account is not authorized to receive email.".into(),
}
}
trc::EventType::MessageIngest(trc::MessageIngestEvent::Error) => {
LocalDeliveryStatus::PermanentFailure {
code: err
.value(trc::Key::Code)
.and_then(|v| v.to_uint())
.map(|n| {
[(n / 100) as u8, ((n % 100) / 10) as u8, (n % 10) as u8]
})
.unwrap_or([5, 5, 0]),
reason: err
.value_as_str(trc::Key::Reason)
.unwrap_or_default()
.to_string()
.into(),
}
}
_ => LocalDeliveryStatus::TemporaryFailure {
reason: "Transient server failure.".into(),
},
};
trc::error!(
err.ctx(trc::Key::To, rcpt.address.to_string())
.span_id(message.session_id)
);
status
}
};
// Cache response for UID to avoid duplicate deliveries
account_ids.insert(account_id, result.status.len());
result.status.push(status);
}
result
}
}