use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use mail4agent_api::{
Ack, Address, DeliveryNotification, Directory, InboxPage, MailError, Message, MessageId, ParticipantId, RoomId,
SendRequest, SendResponse, SessionCard, SessionId, UnreadCount,
};
use mail4agent_core::{MailStore, MailboxEngine, ParticipantPermissions, StoreError};
use mail4agent_store_sqlite::SqliteMailStore;
use tokio::sync::{Mutex as AsyncMutex, Notify};
#[derive(Clone, Debug)]
pub struct AuthenticatedParticipant {
pub id: ParticipantId,
pub label: Option<String>,
pub operator: bool,
}
pub struct MailboxService {
engine: Arc<AsyncMutex<MailboxEngine<SqliteMailStore>>>,
reader: Arc<SqliteMailStore>,
delivery: Arc<DeliveryNotifier>,
listener_http: reqwest::Client,
}
impl MailboxService {
pub fn new(engine_store: SqliteMailStore, reader_store: SqliteMailStore) -> Self {
Self {
engine: Arc::new(AsyncMutex::new(MailboxEngine::new(engine_store))),
reader: Arc::new(reader_store),
delivery: Arc::new(DeliveryNotifier::new()),
listener_http: listener_http_client(),
}
}
pub async fn authenticate(&self, token: &str) -> Result<AuthenticatedParticipant, MailError> {
let engine = self.engine.clone();
let reader = self.reader.clone();
let token = token.to_string();
run_blocking("authenticate", move || {
let id = {
let guard = engine.blocking_lock();
guard.authenticate(&token)?
};
let record = reader
.get_participant(&id)
.map_err(|err| store_unavailable("get_participant", err))?
.ok_or_else(|| MailError::UnknownParticipant { participant: id.clone() })?;
Ok(AuthenticatedParticipant { id, label: record.label, operator: record.operator })
})
.await
}
pub async fn ensure_session(
&self,
account: ParticipantId,
session_id: SessionId,
card: SessionCard,
now_unix_ms: u64,
) -> Result<SessionId, MailError> {
let engine = self.engine.clone();
run_blocking("ensure_session", move || {
let mut guard = engine.blocking_lock();
guard.ensure_session(account, session_id, card, now_unix_ms)
})
.await
}
pub async fn set_declared(
&self,
session: SessionId,
working_on: Option<String>,
role: Option<String>,
parent: Option<SessionId>,
) -> Result<(), MailError> {
let engine = self.engine.clone();
run_blocking("set_declared", move || {
let mut guard = engine.blocking_lock();
guard.set_declared(&session, working_on, role, parent)
})
.await
}
pub async fn session_card(&self, session: SessionId) -> Result<Option<SessionCard>, MailError> {
let reader = self.reader.clone();
run_blocking("get_session", move || {
reader
.get_session(&session)
.map(|found| found.map(|record| record.card))
.map_err(|err| store_unavailable("get_session", err))
})
.await
}
pub async fn send(&self, sender: Address, request: SendRequest) -> Result<SendResponse, MailError> {
let engine = self.engine.clone();
let now = now_unix_ms();
let to = request.to.clone();
let from = sender.clone();
let response = run_blocking("send", move || {
let mut guard = engine.blocking_lock();
guard.send(&sender, request, now)
})
.await?;
let accounts = self.accounts_for(&to).await;
self.delivery.notify_accounts(&accounts);
for account in accounts {
self.spawn_listener_delivery(account, to.clone(), from.clone(), response.message_id.clone());
}
Ok(response)
}
pub async fn inbox(
&self,
reader_address: Address,
since_unix_ms: u64,
limit: u16,
wait: Option<Duration>,
) -> Result<InboxPage, MailError> {
match wait {
Some(wait) => self.inbox_wait(reader_address, since_unix_ms, limit, wait).await,
None => self.inbox_once(reader_address, since_unix_ms, limit).await,
}
}
async fn inbox_once(&self, reader_address: Address, since_unix_ms: u64, limit: u16) -> Result<InboxPage, MailError> {
let engine = self.engine.clone();
run_blocking("inbox", move || {
let guard = engine.blocking_lock();
guard.inbox(&reader_address, since_unix_ms, limit)
})
.await
}
async fn inbox_wait(
&self,
reader_address: Address,
since_unix_ms: u64,
limit: u16,
wait: Duration,
) -> Result<InboxPage, MailError> {
let notify = reader_address.account().map(|account| self.delivery.notify_for(account));
let deadline = Instant::now() + wait;
loop {
let notified = notify.as_ref().map(|n| n.notified());
let page = self.inbox_once(reader_address.clone(), since_unix_ms, limit).await?;
if !page.messages.is_empty() {
return Ok(page);
}
let Some(notified) = notified else {
return Ok(page);
};
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Ok(page);
}
let _ = tokio::time::timeout(remaining, notified).await;
}
}
async fn accounts_for(&self, to: &Address) -> Vec<ParticipantId> {
match to {
Address::Direct { participant } | Address::Session { participant, .. } => vec![participant.clone()],
Address::Room { room } => self.room_members(room).await,
}
}
async fn room_members(&self, room: &RoomId) -> Vec<ParticipantId> {
let reader = self.reader.clone();
let room = room.clone();
run_blocking("get_room", move || {
reader.get_room(&room).map_err(|err| store_unavailable("get_room", err))
})
.await
.ok()
.flatten()
.map(|record| record.members.into_iter().collect())
.unwrap_or_default()
}
pub async fn set_listener(&self, account: ParticipantId, url: String) -> Result<(), MailError> {
let engine = self.engine.clone();
run_blocking("set_listener", move || {
let mut guard = engine.blocking_lock();
guard.set_listener(&account, url)
})
.await
}
pub async fn remove_listener(&self, account: ParticipantId) -> Result<(), MailError> {
let engine = self.engine.clone();
run_blocking("remove_listener", move || {
let mut guard = engine.blocking_lock();
guard.remove_listener(&account)
})
.await
}
fn spawn_listener_delivery(&self, account: ParticipantId, to: Address, from: Address, message_id: MessageId) {
let reader = self.reader.clone();
let http = self.listener_http.clone();
tokio::spawn(async move {
let lookup = account.clone();
let listener_url = match tokio::task::spawn_blocking(move || reader.get_participant(&lookup)).await {
Ok(Ok(Some(record))) => record.listener_url,
Ok(Ok(None)) => None,
Ok(Err(err)) => {
tracing::warn!(%account, error = %err, "delivery listener lookup failed; skipping notification");
None
}
Err(join_err) => {
tracing::warn!(%account, error = %join_err, "delivery listener lookup task panicked; skipping notification");
None
}
};
let Some(url) = listener_url else { return };
let notification = DeliveryNotification { account, to, message_id, from };
match http.post(&url).json(¬ification).send().await {
Ok(response) if response.status().is_success() => {}
Ok(response) => {
tracing::warn!(%url, status = %response.status(), "delivery listener responded with a non-success status");
}
Err(err) => {
tracing::warn!(%url, error = %err, "delivery listener notification failed");
}
}
});
}
pub async fn ack(&self, reader_address: Address, message_id: MessageId) -> Result<Ack, MailError> {
let engine = self.engine.clone();
let now = now_unix_ms();
run_blocking("ack", move || {
let mut guard = engine.blocking_lock();
guard.ack(&reader_address, &message_id, now)
})
.await
}
pub async fn message_get(&self, reader_address: Address, message_id: MessageId) -> Result<Message, MailError> {
let engine = self.engine.clone();
run_blocking("message_get", move || {
let guard = engine.blocking_lock();
guard.message_get(&reader_address, &message_id)
})
.await
}
pub async fn unread_count_of(&self, caller: Address, target: Address) -> Result<UnreadCount, MailError> {
let engine = self.engine.clone();
run_blocking("unread_count_of", move || {
let guard = engine.blocking_lock();
guard.unread_count_of(&caller, &target)
})
.await
}
pub async fn directory(&self, caller: Address) -> Result<Directory, MailError> {
let engine = self.engine.clone();
run_blocking("directory", move || {
let guard = engine.blocking_lock();
guard.directory(&caller, &mail4agent_attest::is_alive)
})
.await
}
pub async fn rooms_of(&self, participant: ParticipantId) -> Result<Vec<RoomId>, MailError> {
let reader = self.reader.clone();
run_blocking("rooms_containing", move || {
reader
.rooms_containing(&participant)
.map_err(|err| store_unavailable("rooms_containing", err))
})
.await
}
pub async fn participant_exists(&self, id: ParticipantId) -> Result<bool, MailError> {
let reader = self.reader.clone();
run_blocking("get_participant", move || {
reader
.get_participant(&id)
.map(|found| found.is_some())
.map_err(|err| store_unavailable("get_participant", err))
})
.await
}
pub async fn register_participant(
&self,
id: ParticipantId,
label: Option<String>,
permissions: ParticipantPermissions,
) -> Result<String, MailError> {
let engine = self.engine.clone();
run_blocking("register_participant", move || {
let mut guard = engine.blocking_lock();
guard.register_participant(id, label, permissions)
})
.await
}
pub async fn rotate_participant_secret(&self, id: ParticipantId) -> Result<String, MailError> {
let engine = self.engine.clone();
run_blocking("rotate_participant_secret", move || {
let mut guard = engine.blocking_lock();
guard.rotate_participant_secret(&id)
})
.await
}
pub async fn deregister_participant(&self, id: ParticipantId) -> Result<(), MailError> {
let engine = self.engine.clone();
run_blocking("deregister_participant", move || {
let mut guard = engine.blocking_lock();
guard.deregister_participant(&id)
})
.await
}
pub async fn create_room(&self, id: RoomId) -> Result<(), MailError> {
let engine = self.engine.clone();
let now = now_unix_ms();
run_blocking("create_room", move || {
let mut guard = engine.blocking_lock();
guard.create_room(id, now)
})
.await
}
pub async fn add_room_member(&self, room: RoomId, participant: ParticipantId) -> Result<(), MailError> {
let engine = self.engine.clone();
run_blocking("add_room_member", move || {
let mut guard = engine.blocking_lock();
guard.add_room_member(&room, participant)
})
.await
}
pub async fn remove_room_member(&self, room: RoomId, participant: ParticipantId) -> Result<(), MailError> {
let engine = self.engine.clone();
run_blocking("remove_room_member", move || {
let mut guard = engine.blocking_lock();
guard.remove_room_member(&room, &participant)
})
.await
}
}
struct DeliveryNotifier {
per_account: std::sync::Mutex<HashMap<ParticipantId, Arc<Notify>>>,
}
impl DeliveryNotifier {
fn new() -> Self {
Self { per_account: std::sync::Mutex::new(HashMap::new()) }
}
fn notify_for(&self, account: &ParticipantId) -> Arc<Notify> {
let mut guard = match self.per_account.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
guard.entry(account.clone()).or_insert_with(|| Arc::new(Notify::new())).clone()
}
fn notify_accounts(&self, accounts: &[ParticipantId]) {
let guard = match self.per_account.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
for account in accounts {
if let Some(notify) = guard.get(account) {
notify.notify_waiters();
}
}
}
}
const LISTENER_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
const LISTENER_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
fn listener_http_client() -> reqwest::Client {
match reqwest::Client::builder().connect_timeout(LISTENER_CONNECT_TIMEOUT).timeout(LISTENER_REQUEST_TIMEOUT).build()
{
Ok(client) => client,
Err(err) => {
tracing::error!(
error = %err,
"failed to build the delivery-listener HTTP client with its configured timeouts; \
falling back to an unbounded default client"
);
reqwest::Client::new()
}
}
}
async fn run_blocking<F, T>(operation: &'static str, f: F) -> Result<T, MailError>
where
F: FnOnce() -> Result<T, MailError> + Send + 'static,
T: Send + 'static,
{
match tokio::task::spawn_blocking(f).await {
Ok(result) => result,
Err(join_err) => {
tracing::error!(operation, error = %join_err, "mailbox blocking task panicked");
Err(MailError::StoreUnavailable { operation: operation.to_string() })
}
}
}
fn store_unavailable(operation: &'static str, err: StoreError) -> MailError {
tracing::error!(operation, error = %err, "mail store operation failed");
MailError::StoreUnavailable { operation: operation.to_string() }
}
pub(crate) fn now_unix_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
async fn test_service() -> MailboxService {
let engine_store = SqliteMailStore::open_in_memory().expect("in-memory store opens and migrates");
let reader_store = SqliteMailStore::new(engine_store.db());
MailboxService::new(engine_store, reader_store)
}
async fn register(service: &MailboxService, name: &str) -> ParticipantId {
let id = ParticipantId::new(name).expect("valid participant id");
let permissions = ParticipantPermissions { may_send: true, may_read: true, operator: false };
service.register_participant(id.clone(), None, permissions).await.expect("register test participant");
id
}
fn direct(id: &ParticipantId) -> Address {
Address::Direct { participant: id.clone() }
}
fn send_request(to: Address, subject: &str, body: &str) -> SendRequest {
SendRequest {
to,
subject: subject.to_string(),
body: body.to_string(),
reply_to: None,
correlation: None,
refs: Vec::new(),
idempotency_key: None,
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn waiting_inbox_returns_promptly_when_mail_arrives_while_waiting() {
let service = Arc::new(test_service().await);
let alice = register(&service, "alice").await;
let alice_address = direct(&alice);
let waiter = {
let service = service.clone();
let alice_address = alice_address.clone();
tokio::spawn(async move { service.inbox(alice_address, 0, 50, Some(Duration::from_secs(10))).await })
};
tokio::time::sleep(Duration::from_millis(100)).await;
service.send(alice_address.clone(), send_request(alice_address.clone(), "hi", "hi")).await.expect("send succeeds");
let started = Instant::now();
let page = tokio::time::timeout(Duration::from_secs(5), waiter)
.await
.expect("the waiter must return well before its own 10s cap once notified")
.expect("waiter task did not panic")
.expect("inbox call succeeds");
assert_eq!(page.messages.len(), 1);
assert!(started.elapsed() < Duration::from_secs(5), "must be woken, not merely time out");
}
#[tokio::test]
async fn waiting_inbox_answers_an_empty_page_on_expiry_not_an_error() {
let service = test_service().await;
let alice = register(&service, "alice").await;
let page = service
.inbox(direct(&alice), 0, 50, Some(Duration::from_millis(150)))
.await
.expect("a wait that expires with nothing to read is Ok, not Err");
assert!(page.messages.is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_waiting_read_does_not_block_a_concurrent_send_or_another_callers_read() {
let service = Arc::new(test_service().await);
let alice = register(&service, "alice").await;
let bob = register(&service, "bob").await;
let alice_address = direct(&alice);
let bob_address = direct(&bob);
let waiter = {
let service = service.clone();
let alice_address = alice_address.clone();
tokio::spawn(async move { service.inbox(alice_address, 0, 50, Some(Duration::from_secs(10))).await })
};
tokio::time::sleep(Duration::from_millis(100)).await;
let started = Instant::now();
service.send(bob_address.clone(), send_request(bob_address.clone(), "hi", "hi")).await.expect("bob's send succeeds");
let bob_page = service.inbox(bob_address.clone(), 0, 50, None).await.expect("bob's own read succeeds");
assert_eq!(bob_page.messages.len(), 1);
assert!(
started.elapsed() < Duration::from_secs(2),
"a concurrent send and read must not be stalled by alice's still-open wait (took {:?})",
started.elapsed()
);
service.send(alice_address.clone(), send_request(alice_address.clone(), "hi", "hi")).await.expect("alice's send succeeds");
let alice_page = tokio::time::timeout(Duration::from_secs(5), waiter)
.await
.expect("alice's waiter must return promptly once notified")
.expect("waiter task did not panic")
.expect("alice's waiting inbox call succeeds");
assert_eq!(alice_page.messages.len(), 1);
}
async fn start_test_listener() -> (String, tokio::sync::mpsc::UnboundedReceiver<serde_json::Value>) {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let app = axum::Router::new().route(
"/notify",
axum::routing::post(move |axum::Json(body): axum::Json<serde_json::Value>| {
let tx = tx.clone();
async move {
let _ = tx.send(body);
axum::http::StatusCode::OK
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind an ephemeral loopback port");
let addr = listener.local_addr().expect("bound listener has a local address");
tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
(format!("http://127.0.0.1:{}/notify", addr.port()), rx)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_registered_listener_receives_ids_but_never_subject_or_body() {
let service = test_service().await;
let alice = register(&service, "alice").await;
let (listener_url, mut received) = start_test_listener().await;
service.set_listener(alice.clone(), listener_url).await.expect("set_listener succeeds");
let response = service
.send(direct(&alice), send_request(direct(&alice), "SECRET SUBJECT", "SECRET BODY"))
.await
.expect("send succeeds");
let body = tokio::time::timeout(Duration::from_secs(2), received.recv())
.await
.expect("the listener must be notified within the bound")
.expect("the notification channel was not closed");
assert_eq!(body["account"], serde_json::json!("alice"));
assert_eq!(body["message_id"], serde_json::json!(response.message_id.as_str()));
assert!(body.get("subject").is_none(), "notification must never carry a subject field: {body}");
assert!(body.get("body").is_none(), "notification must never carry a body field: {body}");
let raw = body.to_string();
assert!(!raw.contains("SECRET"), "notification leaked message content: {raw}");
}
#[tokio::test]
async fn a_failing_listener_does_not_fail_the_send() {
let service = test_service().await;
let alice = register(&service, "alice").await;
let dead_port = {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind an ephemeral loopback port");
listener.local_addr().expect("bound listener has a local address").port()
};
let dead_listener_url = format!("http://127.0.0.1:{dead_port}/nobody-home");
service.set_listener(alice.clone(), dead_listener_url).await.expect("set_listener succeeds");
service
.send(direct(&alice), send_request(direct(&alice), "hi", "hi"))
.await
.expect("send succeeds even though the registered listener is unreachable");
}
#[tokio::test]
async fn a_non_loopback_listener_url_is_refused_by_name() {
let service = test_service().await;
let alice = register(&service, "alice").await;
let err = service
.set_listener(alice, "http://example.com/hook".to_string())
.await
.expect_err("a non-loopback url must be refused");
assert!(matches!(err, MailError::Malformed { field, .. } if field == "url"));
}
}