diff --git a/crates/common/src/cache/principals.rs b/crates/common/src/cache/principals.rs index c105961..c97c4d2 100644 --- a/crates/common/src/cache/principals.rs +++ b/crates/common/src/cache/principals.rs @@ -293,6 +293,29 @@ impl Server { Ok(Some(result)) } else { + // inbuxa: ME-4, ME-6: a live masked address reaches its + // owner; a refused one isn't cached, as it can come back + if let Some(domain) = self.domain_by_id(domain_id).await? + && let Some(name) = domain.names.first() + { + use inbuxa_features::masked_email::ops::{Lookup, lookup}; + match lookup( + &self.core.storage.data, + self.registry(), + &format!("{local_part}@{name}"), + ) + .await? + { + Lookup::Accepts(mask) => { + return Ok(Some(EmailCache::Account( + mask.object.account_id.document_id(), + ))); + } + Lookup::Refuses => return Ok(None), + Lookup::Unknown => {} + } + } + // Cache negative result emails_negative.insert( EmailAddress::new(local_part, domain_id), diff --git a/crates/email/Cargo.toml b/crates/email/Cargo.toml index bc71398..890fb75 100644 --- a/crates/email/Cargo.toml +++ b/crates/email/Cargo.toml @@ -14,6 +14,7 @@ jmap-tools = { version = "0.1" } common = { path = "../common" } groupware = { path = "../groupware" } registry = { path = "../registry" } +inbuxa-features = { path = "../features" } mail-parser = { version = "0.11", features = ["full_encoding"] } mail-builder = { version = "1.0" } sieve-rs = { version = "0.7", features = ["rkyv"] } diff --git a/crates/email/src/message/delivery.rs b/crates/email/src/message/delivery.rs index 1ae7ade..f552919 100644 --- a/crates/email/src/message/delivery.rs +++ b/crates/email/src/message/delivery.rs @@ -5,7 +5,10 @@ */ use super::ingest::{EmailIngest, IngestEmail, IngestSource}; -use crate::{mailbox::INBOX_ID, sieve::ingest::SieveScriptIngest}; +use crate::{ + mailbox::{INBOX_ID, TRASH_ID}, + sieve::ingest::SieveScriptIngest, +}; use common::{ Server, auth::BuildAccessToken, @@ -126,7 +129,32 @@ impl MailDelivery for Server { }; for rcpt in message.recipients { - let account_id = match self.account_id_from_email(&rcpt.address, false).await { + // inbuxa: ME-4, ME-10: a masked address delivers to its owner + let 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 @@ -157,6 +185,35 @@ impl MailDelivery for Server { 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 @@ -165,15 +222,20 @@ impl MailDelivery for Server { }) { Ok(access_token) => { // Check if there is an active sieve script - match self.sieve_script_get_active(account_id).await { + 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: &raw_message, - blob_hash: Some(&message.message_blob), - message: MessageParser::new().parse(&raw_message), + raw_message, + blob_hash: Some(message_blob), + message: MessageParser::new().parse(raw_message), access_token: &access_token, - mailbox_ids: vec![INBOX_ID], + mailbox_ids: vec![if to_trash { TRASH_ID } else { INBOX_ID }], keywords: vec![], received_at: None, source: IngestSource::Smtp { @@ -188,8 +250,8 @@ impl MailDelivery for Server { Ok(Some(active_script)) => { self.sieve_script_ingest( &access_token, - &message.message_blob, - &raw_message, + message_blob, + raw_message, &message.sender_address, message.sender_authenticated, &rcpt, @@ -208,6 +270,18 @@ impl MailDelivery for Server { 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 { diff --git a/crates/features/src/masked_email/ops.rs b/crates/features/src/masked_email/ops.rs index c6fd60f..fafc961 100644 --- a/crates/features/src/masked_email/ops.rs +++ b/crates/features/src/masked_email/ops.rs @@ -332,3 +332,73 @@ pub async fn ensure_indexed(data: &Store, registry: &RegistryStore) -> trc::Resu data::set_indexed(&mut batch); data.write(batch.build_all()).await.map(|_| ()) } + +/// What an address is, as far as masks go. +#[derive(Debug)] +pub enum Lookup { + /// A mask that accepts mail. + Accepts(Mask), + /// A live mask that refuses mail now (deleted or expired), which may + /// accept it again later, so a refusal mustn't be cached (ME-6). + Refuses, + /// Not a mask, or a destroyed one. + Unknown, +} + +/// Looks an address up among masks, for accepting a recipient. +pub async fn lookup(data: &Store, registry: &RegistryStore, address: &str) -> trc::Result { + if let Some(mask) = resolve(data, registry, address).await? { + Ok(Lookup::Accepts(mask)) + } else if data::address(data, address) + .await? + .is_some_and(|entry| entry.live) + { + Ok(Lookup::Refuses) + } else { + Ok(Lookup::Unknown) + } +} + +/// The mask a recipient reaches, as written or with a `+tag` sub-address +/// removed, since a mask takes sub-addresses as the account's own addresses +/// do (ME-10). +pub async fn resolve_recipient( + data: &Store, + registry: &RegistryStore, + recipient: &str, +) -> trc::Result> { + if let Some(mask) = resolve(data, registry, recipient).await? { + return Ok(Some(mask)); + } + if let Some((local, domain)) = recipient.rsplit_once('@') + && let Some((base, _)) = local.split_once('+') + { + return resolve(data, registry, &format!("{base}@{domain}")).await; + } + Ok(None) +} + +/// The message as delivered through a mask: an `X-Masked-Email` header +/// names the mask, so the user can tell even when it was only BCC'd (ME-9). +/// Nothing else in the message changes. +pub fn with_header(address: &str, message: &[u8]) -> Vec { + let header = format!("X-Masked-Email: {address}\r\n"); + let mut out = Vec::with_capacity(header.len() + message.len()); + out.extend_from_slice(header.as_bytes()); + out.extend_from_slice(message); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn header_goes_first() { + let out = with_header("shop_ab12@example.org", b"Subject: hi\r\n\r\nbody"); + assert_eq!( + out, + b"X-Masked-Email: shop_ab12@example.org\r\nSubject: hi\r\n\r\nbody".to_vec() + ); + } +}