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:
+23
@@ -293,6 +293,29 @@ impl Server {
|
|||||||
|
|
||||||
Ok(Some(result))
|
Ok(Some(result))
|
||||||
} else {
|
} 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
|
// Cache negative result
|
||||||
emails_negative.insert(
|
emails_negative.insert(
|
||||||
EmailAddress::new(local_part, domain_id),
|
EmailAddress::new(local_part, domain_id),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ jmap-tools = { version = "0.1" }
|
|||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
groupware = { path = "../groupware" }
|
groupware = { path = "../groupware" }
|
||||||
registry = { path = "../registry" }
|
registry = { path = "../registry" }
|
||||||
|
inbuxa-features = { path = "../features" }
|
||||||
mail-parser = { version = "0.11", features = ["full_encoding"] }
|
mail-parser = { version = "0.11", features = ["full_encoding"] }
|
||||||
mail-builder = { version = "1.0" }
|
mail-builder = { version = "1.0" }
|
||||||
sieve-rs = { version = "0.7", features = ["rkyv"] }
|
sieve-rs = { version = "0.7", features = ["rkyv"] }
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
use super::ingest::{EmailIngest, IngestEmail, IngestSource};
|
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::{
|
use common::{
|
||||||
Server,
|
Server,
|
||||||
auth::BuildAccessToken,
|
auth::BuildAccessToken,
|
||||||
@@ -126,7 +129,32 @@ impl MailDelivery for Server {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for rcpt in message.recipients {
|
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(Some(account_id)) => account_id,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
// Something went wrong
|
// Something went wrong
|
||||||
@@ -157,6 +185,35 @@ impl MailDelivery for Server {
|
|||||||
continue;
|
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
|
// Obtain access token
|
||||||
let status = match self.access_token(account_id).await.and_then(|token| {
|
let status = match self.access_token(account_id).await.and_then(|token| {
|
||||||
token
|
token
|
||||||
@@ -165,15 +222,20 @@ impl MailDelivery for Server {
|
|||||||
}) {
|
}) {
|
||||||
Ok(access_token) => {
|
Ok(access_token) => {
|
||||||
// Check if there is an active sieve script
|
// 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) => {
|
Ok(None) => {
|
||||||
// Ingest message
|
// Ingest message
|
||||||
self.email_ingest(IngestEmail {
|
self.email_ingest(IngestEmail {
|
||||||
raw_message: &raw_message,
|
raw_message,
|
||||||
blob_hash: Some(&message.message_blob),
|
blob_hash: Some(message_blob),
|
||||||
message: MessageParser::new().parse(&raw_message),
|
message: MessageParser::new().parse(raw_message),
|
||||||
access_token: &access_token,
|
access_token: &access_token,
|
||||||
mailbox_ids: vec![INBOX_ID],
|
mailbox_ids: vec![if to_trash { TRASH_ID } else { INBOX_ID }],
|
||||||
keywords: vec![],
|
keywords: vec![],
|
||||||
received_at: None,
|
received_at: None,
|
||||||
source: IngestSource::Smtp {
|
source: IngestSource::Smtp {
|
||||||
@@ -188,8 +250,8 @@ impl MailDelivery for Server {
|
|||||||
Ok(Some(active_script)) => {
|
Ok(Some(active_script)) => {
|
||||||
self.sieve_script_ingest(
|
self.sieve_script_ingest(
|
||||||
&access_token,
|
&access_token,
|
||||||
&message.message_blob,
|
message_blob,
|
||||||
&raw_message,
|
raw_message,
|
||||||
&message.sender_address,
|
&message.sender_address,
|
||||||
message.sender_authenticated,
|
message.sender_authenticated,
|
||||||
&rcpt,
|
&rcpt,
|
||||||
@@ -208,6 +270,18 @@ impl MailDelivery for Server {
|
|||||||
|
|
||||||
let status = match status {
|
let status = match status {
|
||||||
Ok(ingested_message) => {
|
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
|
// Notify state change
|
||||||
if ingested_message.change_id != u64::MAX {
|
if ingested_message.change_id != u64::MAX {
|
||||||
self.broadcast_push_notification(PushNotification::EmailPush(EmailPush {
|
self.broadcast_push_notification(PushNotification::EmailPush(EmailPush {
|
||||||
|
|||||||
@@ -332,3 +332,73 @@ pub async fn ensure_indexed(data: &Store, registry: &RegistryStore) -> trc::Resu
|
|||||||
data::set_indexed(&mut batch);
|
data::set_indexed(&mut batch);
|
||||||
data.write(batch.build_all()).await.map(|_| ())
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user