SMTP submission: send as a shared mailbox with send-as #199
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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}");
|
||||
|
||||
Reference in new issue
Block a user