Files
clawmates/crates/cm-runtime/tests/outbox.rs
T
Omar SobhandClaude Opus 4.8 e8486ee57c
ci / gates (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / sandbox-k8s (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / e2e (push) Has been cancelled
Door delivery: outbox drainer + SMTP transport (inert until configured)
The §15 door's email_send queues to `outbox` but nothing delivered it. Add a
real transport: cm-db outbox repo (list_queued/mark_sent/mark_failed) + a
cm-runtime drainer — an EmailSender trait (testable), a lettre STARTTLS
LettreSender, drain_once (queued -> sent/failed), and spawn_drainer wired into
the server beside the scheduler/sweeper/topology-worker.

Config-gated: inert (logs "outbox delivery DISABLED") until CLAWMATES_SMTP_*
is set, so it ships safely before credentials exist. The agent never holds the
SMTP credential — it only writes to outbox through the gated door; the server
owns the transport.

NOTE: live delivery is still credential-blocked — Migadu's API can't send
(SMTP-only) and the admin token is invalid; no SMTP creds exist. The transport
is built + tested (drain_marks_sent_and_failed via a mock sender); set
CLAWMATES_SMTP_* to go live with zero further code. clippy clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-18 03:22:27 -07:00

114 lines
3.5 KiB
Rust

//! 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<Mutex<Vec<String>>>,
}
#[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, "[email protected]").await;
let bad_id = enqueue(&pool, ws, agent, "[email protected]").await;
let calls = Arc::new(Mutex::new(Vec::new()));
let sender = MockSender {
fail_for: "[email protected]".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));
}