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]>
114 lines
3.5 KiB
Rust
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));
|
|
}
|