//! The outbox drainer: queued emails are delivered via the injected sender and //! flip to `sent`; a send failure flips them to `failed`. SMTP itself is //! abstracted (mock sender), so this exercises the §15 delivery transition logic //! without a live mail server. use std::sync::Arc; use cm_domain::{ AccessPolicy, Agent, AgentId, AgentStatus, Role, User, UserId, Workspace, WorkspaceId, }; use cm_runtime::{drain_once, EmailSender}; use tokio::sync::Mutex; use uuid::Uuid; struct MockSender { fail_for: String, calls: Arc>>, } #[async_trait::async_trait] impl EmailSender for MockSender { async fn send(&self, to: &str, _subject: &str, _body: &str) -> Result<(), String> { self.calls.lock().await.push(to.to_string()); if to == self.fail_for { Err("simulated SMTP failure".into()) } else { Ok(()) } } } async fn seed_agent(pool: &sqlx::PgPool) -> (WorkspaceId, AgentId) { let ws = Workspace { id: WorkspaceId::new(), name: "Acme".into(), plan: "team".into(), }; cm_db::repo::workspaces::insert(pool, &ws).await.unwrap(); let owner = User { id: UserId::new(), workspace_id: ws.id, email: format!("{}@acme.test", UserId::new()), role: Role::Owner, display_name: "Owner".into(), created_at: time::OffsetDateTime::UNIX_EPOCH, }; cm_db::repo::users::insert(pool, &owner).await.unwrap(); let agent = Agent { id: AgentId::new(), workspace_id: ws.id, name: "Scout".into(), job_title: "Analyst".into(), system_prompt: "concise".into(), avatar: String::new(), accent: String::new(), wallpaper: String::new(), managed_by: owner.id, status: AgentStatus::Online, }; cm_db::repo::agents::insert(pool, &agent, &AccessPolicy::default()) .await .unwrap(); (ws.id, agent.id) } async fn enqueue(pool: &sqlx::PgPool, ws: WorkspaceId, agent: AgentId, to: &str) -> Uuid { let id = Uuid::now_v7(); sqlx::query("INSERT INTO outbox (id, workspace_id, agent_id, recipient, subject, body) VALUES ($1,$2,$3,$4,$5,$6)") .bind(id) .bind(ws.as_uuid()) .bind(agent.as_uuid()) .bind(to) .bind("subject") .bind("body") .execute(pool) .await .unwrap(); id } async fn status_of(pool: &sqlx::PgPool, id: Uuid) -> String { sqlx::query_scalar::<_, String>("SELECT status FROM outbox WHERE id = $1") .bind(id) .fetch_one(pool) .await .unwrap() } #[tokio::test] async fn drain_marks_sent_and_failed() { let pool = cm_testkit::test_pool().await; let (ws, agent) = seed_agent(&pool).await; let ok_id = enqueue(&pool, ws, agent, "good@example.com").await; let bad_id = enqueue(&pool, ws, agent, "bad@example.com").await; let calls = Arc::new(Mutex::new(Vec::new())); let sender = MockSender { fail_for: "bad@example.com".into(), calls: calls.clone(), }; let (sent, failed) = drain_once(&pool, &sender).await; assert_eq!((sent, failed), (1, 1)); assert_eq!(calls.lock().await.len(), 2, "both queued emails attempted"); assert_eq!(status_of(&pool, ok_id).await, "sent"); assert_eq!(status_of(&pool, bad_id).await, "failed"); // A second drain is a no-op — nothing is queued anymore. let (sent2, failed2) = drain_once(&pool, &sender).await; assert_eq!((sent2, failed2), (0, 0)); }