SMTP submission: send as a shared mailbox with send-as #199

Merged
jcoffey-dev merged 1 commits from feat/smtp-send-as-delegation into main 2026-10-15 13:30:16 +00:00
7 changed files with 232 additions and 9 deletions

No files matched your search

+42 -4
View File
@@ -467,18 +467,56 @@ impl Server {
{
return;
}
self.audit_send_as_record(
token,
as_account_id,
address,
("EmailSubmission", Id::from(submission_id).to_string()),
submission_account_id,
)
.await;
}
/// MA-D0a over SMTP submission: the same record, naming the queued
/// message. The caller has already resolved the address and left out
/// the sender's own account and a locked account's delegate.
pub async fn audit_send_as_queued(
&self,
token: &AccessToken,
as_account_id: u32,
queue_id: u64,
address: &str,
) {
self.audit_send_as_record(
token,
as_account_id,
address,
("QueuedMessage", format!("{queue_id:x}")),
token.account_id(),
)
.await;
}
async fn audit_send_as_record(
&self,
token: &AccessToken,
as_account_id: u32,
address: &str,
(kind, id): (&str, String),
from_account_id: u32,
) {
let actor = self.audit_actor(token).await;
let tenant_id = self
.account(as_account_id)
.await
.ok()
.and_then(|account| account.id_tenant);
let details = if submission_account_id == as_account_id {
let details = if from_account_id == as_account_id {
format!("Sent as {address}")
} else {
format!(
"Sent as {address}, from {}",
self.audit_account_name(submission_account_id).await
self.audit_account_name(from_account_id).await
)
};
self.audit_note(Record {
@@ -488,8 +526,8 @@ impl Server {
remote_ip: None,
action: Action::Create,
target: Target {
kind: "EmailSubmission".into(),
id: Some(Id::from(submission_id).to_string()),
kind: kind.into(),
id: Some(id),
name: Some(address.to_string()),
account_id: Some(as_account_id),
tenant_id,
+6 -1
View File
@@ -9,7 +9,7 @@
use crate::{inbound::auth::SaslToken, queue::QueueId};
use common::{
Inner, Server,
auth::AccountInfo,
auth::{AccessToken, AccountInfo},
config::smtp::auth::VerifyStrategy,
network::{ServerInstance, asn::AsnGeoLookupResult},
};
@@ -80,6 +80,9 @@ pub struct SessionData {
pub message: Vec<u8>,
pub authenticated_as: Option<AccountInfo>,
// inbuxa: MA-S, AL-8: the signed-in token, for its delegations' sendAs
// and the audit record of a send as someone else (MA-D0a, AL-9)
pub access_token: Option<AccessToken>,
pub auth_errors: usize,
pub priority: i16,
@@ -195,6 +198,7 @@ impl SessionData {
mailflow_queue: None,
journal_marks: Vec::new(),
journal_added: Vec::new(),
access_token: None,
}
}
}
@@ -323,6 +327,7 @@ impl SessionData {
mailflow_queue: None,
journal_marks: Vec::new(),
journal_added: Vec::new(),
access_token: None,
}
}
}
+106 -3
View File
@@ -12,7 +12,7 @@ use common::{
network::{SessionStream, legacy::LegacyProtocol},
};
use directory::Credentials;
use mail_parser::decoders::base64::base64_decode;
use mail_parser::{HeaderName, HeaderValue, decoders::base64::base64_decode};
use registry::schema::enums::Permission;
use smtp_proto::{AUTH_LOGIN, AUTH_OAUTHBEARER, AUTH_PLAIN, AUTH_XOAUTH2, IntoString};
use trc::AuthEvent;
@@ -148,13 +148,18 @@ impl<T: SessionStream> Session<T> {
}
let result = match result {
Ok(access_token) => self.server.account_info(access_token.account_id()).await,
Ok(access_token) => self
.server
.account_info(access_token.account_id())
.await
.map(|account_info| (account_info, access_token)),
Err(err) => Err(err),
};
match result {
Ok(account_info) => {
Ok((account_info, access_token)) => {
self.data.authenticated_as = account_info.into();
self.data.access_token = access_token.into();
self.eval_post_auth_params().await;
self.write(b"235 2.7.0 Authentication succeeded.\r\n")
.await?;
@@ -262,4 +267,102 @@ impl<T: SessionStream> Session<T> {
pub fn authenticated_emails(&self) -> &[String] {
self.data.authenticated_as.as_ref().unwrap().addresses()
}
/// inbuxa: MA-S, AL-8: whether the signed-in account holds a current
/// delegation with sendAs into the account that owns `address`: a
/// shared mailbox, or a locked account handed to it. The address must
/// be the owner's own; a catch-all doesn't count.
pub async fn delegate_sends_as(&self, address: &str) -> bool {
let Some(access_token) = &self.data.access_token else {
return false;
};
match self.server.account_id_from_email(address, false).await {
Ok(Some(owner_id)) => access_token
.delegation(owner_id)
.is_some_and(|delegation| delegation.send_as),
_ => false,
}
}
/// inbuxa: MA-S3: when the message goes out as a shared mailbox the
/// signed-in account was assigned to, the first From or Reply-To
/// address that isn't the mailbox's own, as JMAP refuses it.
pub async fn shared_mailbox_foreign_address(
&self,
message: &mail_parser::Message<'_>,
) -> Option<String> {
let access_token = self.data.access_token.as_ref()?;
let mail_from = self.data.mail_from.as_ref()?;
let owner_id = self
.server
.account_id_from_email(&mail_from.address_lcase, false)
.await
.ok()??;
if owner_id == access_token.account_id() || !access_token.delegated_shared_mailbox(owner_id)
{
return None;
}
let named = message
.headers()
.iter()
.filter(|header| matches!(header.name, HeaderName::From | HeaderName::ReplyTo))
.filter_map(|header| match &header.value {
HeaderValue::Address(address) => Some(address),
_ => None,
})
.flat_map(|address| address.iter())
.filter_map(|addr| addr.address().map(str::to_string))
.collect::<Vec<_>>();
for address in named {
if self
.server
.account_id_from_email(&address, true)
.await
.ok()
.flatten()
!= Some(owner_id)
{
return Some(address);
}
}
None
}
/// inbuxa: MA-D0a, AL-9: a message queued from an address that isn't the
/// signed-in account's own is recorded: who sent it and as whom. A
/// locked account's delegate's send is that delegation's record.
pub async fn audit_queued_send_as(&self, queue_id: u64) {
let (Some(access_token), Some(mail_from)) = (&self.data.access_token, &self.data.mail_from)
else {
return;
};
let address = mail_from.address_lcase.as_str();
if address.is_empty() {
return;
}
let Ok(Some(as_account_id)) = self.server.account_id_from_email(address, true).await else {
return;
};
if as_account_id == access_token.account_id() {
return;
}
match access_token.delegation(as_account_id) {
Some(delegation) if delegation.kind.is_lock() => {
self.server
.audit_delegate(
access_token,
as_account_id,
delegation.access.as_str(),
Some("SMTP submission"),
None,
)
.await;
}
_ => {
self.server
.audit_send_as_queued(access_token, as_account_id, queue_id, address)
.await;
}
}
}
}
+12
View File
@@ -74,6 +74,16 @@ impl<T: SessionStream> Session<T> {
}
};
// inbuxa: MA-S3: a shared mailbox's people send only as it over SMTP
// too, so the answer comes back to the mailbox
if let Some(address) = self.shared_mailbox_foreign_address(&parsed_message).await {
return format!(
"550 5.7.1 A shared mailbox sends only as its own addresses, so replies come back to it; {address} isn't one.\r\n"
)
.into_bytes()
.into();
}
// Authenticate message
let mut auth_message = AuthenticatedMessage::from_parsed(
&parsed_message,
@@ -900,6 +910,8 @@ impl<T: SessionStream> Session<T> {
{
self.state = State::Accepted(queue_id);
self.data.messages_sent += 1;
// inbuxa: MA-D0a, AL-9: a send as someone else is recorded
self.audit_queued_send_as(queue_id).await;
if let (Some(draft), Some((sender, recipients, size))) = (held_draft, held_envelope)
{
self.record_held(queue_id, draft, sender, recipients, size).await;
+3
View File
@@ -259,6 +259,9 @@ impl<T: SessionStream> Session<T> {
)
.await
.unwrap_or(false)
// inbuxa: MA-S, AL-8: a delegate allowed to send as the
// account that owns the address, as JMAP allows
&& !self.delegate_sends_as(address_lcase).await
{
trc::event!(
Smtp(SmtpEvent::MailFromUnauthorized),
+1
View File
@@ -505,6 +505,7 @@ impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> {
self.data.helo_domain.clear();
self.data.spf_ehlo = None;
self.data.authenticated_as = None;
self.data.access_token = None;
}
#[inline(always)]
+62 -1
View File
@@ -12,6 +12,7 @@ use crate::{
jmap::mail::submission::{
MockMessage, assert_message_delivery, expect_nothing, spawn_mock_smtp_server,
},
smtp::session::TestSession,
utils::{
account::Account,
dns::DnsCache,
@@ -448,13 +449,14 @@ pub async fn test(test: &mut TestServer) {
"AL-9: the delegate's access and changes weren't recorded: {query}"
);
shared_mailbox(admin, &mut smtp_rx, &mut lmtp).await;
shared_mailbox(test, admin, &mut smtp_rx, &mut lmtp).await;
}
/// MA-S (specs/multi-account.md): a shared mailbox is a lock of its own
/// kind. No reason is needed, more people fit, its Sieve replies go out,
/// only what is sent as it is recorded, and it sends only as itself.
async fn shared_mailbox(
test: &TestServer,
admin: &Account,
smtp_rx: &mut tokio::sync::mpsc::Receiver<crate::jmap::mail::submission::MockMessage>,
lmtp: &mut SmtpConnection,
@@ -594,6 +596,65 @@ async fn shared_mailbox(
.collect::<Vec<_>>();
assert_eq!(kinds, ["EmailSubmission"], "MA-D0a: {records}");
// Over SMTP submission too: the agent signs in as itself and sends as
// support@, a desk without sendAs can't, and the send is recorded
let smtp_session = || {
let mut session = test.new_mta_session();
session.stream.tls = true;
session
};
let mut session = smtp_session();
session.eval_session_params().await;
session.ehlo("agent.example.com").await;
session.auth_plain("[email protected]", "agent-secret-5520", "235 2.7.0").await;
session
.send_message(
"[email protected]",
&["[email protected]"],
"From: [email protected]\r\nTo: [email protected]\r\nSubject: Re: Over SMTP\r\n\r\nThanks.\r\n",
"250",
)
.await;
assert_message_delivery(
smtp_rx,
MockMessage::new("<[email protected]>", ["<[email protected]>"], "@Re: Over SMTP"),
)
.await;
// MA-S3: and the reply still can't be steered to the agent
session
.send_message(
"[email protected]",
&["[email protected]"],
"From: [email protected]\r\nReply-To: [email protected]\r\nTo: [email protected]\r\nSubject: Write to me\r\n\r\nHi.\r\n",
"550 5.7.1",
)
.await;
expect_nothing(smtp_rx).await;
let mut session = smtp_session();
session.eval_session_params().await;
session.ehlo("desk.example.com").await;
session.auth_plain("[email protected]", "desk-secret-7781", "235 2.7.0").await;
session.mail_from("[email protected]", "501 5.5.4").await;
session.mail_from("[email protected]", "250").await;
let (_, query) = admin
.call(
"inbuxa:AuditEvent/query",
json!({"accountId": admin.id_string(),
"filter": {"actorId": agent.id_string(), "accountId": support_id}}),
)
.await;
let (_, records) = admin
.call("inbuxa:AuditEvent/get", json!({"accountId": admin.id_string(), "ids": query["ids"]}))
.await;
let mut kinds = records["list"]
.as_array()
.unwrap()
.iter()
.map(|r| r["target"]["kind"].as_str().unwrap_or_default().to_string())
.collect::<Vec<_>>();
kinds.sort();
assert_eq!(kinds, ["EmailSubmission", "QueuedMessage"], "MA-D0a over SMTP: {records}");
// Ending it needs no reason either
let response = admin.lock_set(json!({"destroy": [support_id]})).await;
assert_eq!(response["destroyed"][0], support_id.as_str(), "MA-S: {response}");