Masked email: delivery through masks (ME-4, ME-5, ME-6, ME-7, ME-9, ME-10)

A live mask accepts mail at RCPT TO and delivers to its owner, with an
X-Masked-Email header naming it. A disabled mask files straight to Trash,
past the owner's Sieve script. Deleted and expired masks are refused like
unknown addresses, without being cached as unknown. Mail moves lastMessageAt
and turns a pending mask enabled. Sub-addresses on a mask work.
This commit is contained in:
2026-09-18 16:22:50 -07:00
parent 3b052a57da
commit 60d1d8b84a
4 changed files with 177 additions and 9 deletions
+23
View File
@@ -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),
+1
View File
@@ -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"] }
+83 -9
View File
@@ -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 {
+70
View File
@@ -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<Lookup> {
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<Option<Mask>> {
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<u8> {
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("[email protected]", b"Subject: hi\r\n\r\nbody");
assert_eq!(
out,
b"X-Masked-Email: [email protected]\r\nSubject: hi\r\n\r\nbody".to_vec()
);
}
}