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.
380 lines
14 KiB
Rust
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
|
|
}
|
|
}
|