Door delivery: outbox drainer + SMTP transport (inert until configured)
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

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]>
This commit is contained in:
Omar Sobh
2026-06-18 03:22:27 -07:00
co-authored by Claude Opus 4.8
parent 6e87433c66
commit e8486ee57c
12 changed files with 468 additions and 1 deletions
@@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, recipient, subject, body FROM outbox\n WHERE status = 'queued' ORDER BY created_at LIMIT $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "recipient",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "subject",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "body",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "07f62c69c82653354ab10a8da1ed37357e48bfaed14b7f8da1925922c6ddf7dc"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE outbox SET status = 'failed' WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "4a6dc5f913e7844abc3df5762846ba2d13348c96c9d617e8a10c2ce0017bd324"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE outbox SET status = 'sent' WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "bb49e2498cfe38db424f215d94066ed0eeb83bb1623ed240cc8af0bcae64f68f"
}
Generated
+60 -1
View File
@@ -656,6 +656,7 @@ dependencies = [
"cm-tools", "cm-tools",
"croner", "croner",
"futures", "futures",
"lettre",
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
@@ -1090,6 +1091,22 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "email-encoding"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
dependencies = [
"base64",
"memchr",
]
[[package]]
name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -1146,7 +1163,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"nom", "nom 7.1.3",
"pin-project-lite", "pin-project-lite",
] ]
@@ -1996,6 +2013,33 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lettre"
version = "0.11.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349"
dependencies = [
"async-trait",
"base64",
"email-encoding",
"email_address",
"fastrand",
"futures-io",
"futures-util",
"httpdate",
"idna",
"mime",
"nom 8.0.0",
"percent-encoding",
"quoted_printable",
"rustls",
"socket2",
"tokio",
"tokio-rustls",
"url",
"webpki-roots 1.0.7",
]
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.186" version = "0.2.186"
@@ -2127,6 +2171,15 @@ dependencies = [
"minimal-lexical", "minimal-lexical",
] ]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -2790,6 +2843,12 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "quoted_printable"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
[[package]] [[package]]
name = "r-efi" name = "r-efi"
version = "5.3.0" version = "5.3.0"
+3
View File
@@ -200,6 +200,9 @@ async fn run() -> Result<(), String> {
// resume stale ones after a crash. Long-horizon topologies run here, not in // resume stale ones after a crash. Long-horizon topologies run here, not in
// the HTTP request. // the HTTP request.
cm_api::topology_worker::spawn(pool.clone(), std::time::Duration::from_secs(3)); cm_api::topology_worker::spawn(pool.clone(), std::time::Duration::from_secs(3));
// Outbound-email delivery: drains the §15-gated `outbox` over SMTP. Inert
// until CLAWMATES_SMTP_* is set, so it ships safely before credentials exist.
cm_runtime::spawn_drainer(pool.clone(), std::time::Duration::from_secs(10));
// Hosted identity (Clerk / OIDC): pin the issuer and load its JWKS. // Hosted identity (Clerk / OIDC): pin the issuer and load its JWKS.
let auth_verifier = match config.auth.mode { let auth_verifier = match config.auth.mode {
+1
View File
@@ -4,6 +4,7 @@ pub mod connections;
pub mod credits; pub mod credits;
pub mod files; pub mod files;
pub mod messages; pub mod messages;
pub mod outbox;
pub mod routines; pub mod routines;
pub mod run_events; pub mod run_events;
pub mod runs; pub mod runs;
+52
View File
@@ -0,0 +1,52 @@
//! The outbound-email queue. The gated `email.send` tool enqueues rows here
//! (after §15 policy); the delivery drainer (cm-runtime) sends them over SMTP
//! and flips their status.
use sqlx::PgPool;
use uuid::Uuid;
use crate::DbError;
/// A queued outbound email awaiting delivery.
pub struct QueuedEmail {
pub id: Uuid,
pub recipient: String,
pub subject: String,
pub body: String,
}
/// The oldest `queued` emails, up to `limit` (FIFO by enqueue time).
pub async fn list_queued(pool: &PgPool, limit: i64) -> Result<Vec<QueuedEmail>, DbError> {
let rows = sqlx::query!(
"SELECT id, recipient, subject, body FROM outbox
WHERE status = 'queued' ORDER BY created_at LIMIT $1",
limit,
)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| QueuedEmail {
id: r.id,
recipient: r.recipient,
subject: r.subject,
body: r.body,
})
.collect())
}
/// Mark a queued email delivered.
pub async fn mark_sent(pool: &PgPool, id: Uuid) -> Result<(), DbError> {
sqlx::query!("UPDATE outbox SET status = 'sent' WHERE id = $1", id)
.execute(pool)
.await?;
Ok(())
}
/// Mark a queued email failed (delivery error; left for inspection, not retried).
pub async fn mark_failed(pool: &PgPool, id: Uuid) -> Result<(), DbError> {
sqlx::query!("UPDATE outbox SET status = 'failed' WHERE id = $1", id)
.execute(pool)
.await?;
Ok(())
}
+6
View File
@@ -11,6 +11,12 @@ async-trait = "0.1"
chrono = { version = "0.4", default-features = false, features = ["clock"] } chrono = { version = "0.4", default-features = false, features = ["clock"] }
croner = "2" croner = "2"
futures = "0.3" futures = "0.3"
lettre = { version = "0.11", default-features = false, features = [
"tokio1",
"tokio1-rustls-tls",
"smtp-transport",
"builder",
] }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
sqlx = { workspace = true } sqlx = { workspace = true }
+2
View File
@@ -3,12 +3,14 @@
//! (the gateway streams exactly this journal, live or replayed). //! (the gateway streams exactly this journal, live or replayed).
mod events; mod events;
pub mod outbox;
mod runtime; mod runtime;
mod sandboxes; mod sandboxes;
pub mod scheduling; pub mod scheduling;
mod tools; mod tools;
pub use events::{RunEventBody, RunEventEnvelope}; pub use events::{RunEventBody, RunEventEnvelope};
pub use outbox::{drain_once, spawn_drainer, EmailSender, LettreSender, SmtpConfig};
pub use runtime::{ProviderRegistry, Runtime, RuntimeConfig, RuntimeError, StartedRun}; pub use runtime::{ProviderRegistry, Runtime, RuntimeConfig, RuntimeError, StartedRun};
pub use sandboxes::SandboxManager; pub use sandboxes::SandboxManager;
pub use tools::{ClockNow, EmailSend, Tool, ToolContext, ToolRegistry}; pub use tools::{ClockNow, EmailSend, Tool, ToolContext, ToolRegistry};
+153
View File
@@ -0,0 +1,153 @@
//! Outbound-email delivery: drains the `outbox` queue over SMTP.
//!
//! The gated `email.send` tool enqueues a row (after §15 policy + the autonomous
//! door governor); this background drainer is the transport that actually
//! delivers it. The agent never holds the SMTP credential — it only writes to
//! `outbox` through the gated door; the server-side drainer owns the transport.
//! Inert until SMTP is configured (`CLAWMATES_SMTP_*`), so it's safe to ship
//! before credentials exist.
use std::sync::Arc;
use std::time::Duration;
use sqlx::PgPool;
/// Delivers one email. Abstracted so the drain loop is testable without SMTP.
#[async_trait::async_trait]
pub trait EmailSender: Send + Sync {
async fn send(&self, to: &str, subject: &str, body: &str) -> Result<(), String>;
}
/// SMTP configuration (from `CLAWMATES_SMTP_*`). Absent → delivery disabled.
#[derive(Clone)]
pub struct SmtpConfig {
pub host: String,
pub port: u16,
pub user: String,
pub pass: String,
pub from: String,
}
impl SmtpConfig {
/// Build from env; `None` if host/from are unset (drainer stays inert).
pub fn from_env() -> Option<SmtpConfig> {
let host = std::env::var("CLAWMATES_SMTP_HOST")
.ok()
.filter(|s| !s.is_empty())?;
let from = std::env::var("CLAWMATES_SMTP_FROM")
.ok()
.filter(|s| !s.is_empty())?;
let user = std::env::var("CLAWMATES_SMTP_USER").unwrap_or_default();
let pass = std::env::var("CLAWMATES_SMTP_PASS").unwrap_or_default();
let port = std::env::var("CLAWMATES_SMTP_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(587);
Some(SmtpConfig {
host,
port,
user,
pass,
from,
})
}
}
/// Real SMTP sender (lettre, STARTTLS submission).
pub struct LettreSender {
transport: lettre::AsyncSmtpTransport<lettre::Tokio1Executor>,
from: lettre::message::Mailbox,
}
impl LettreSender {
pub fn new(cfg: &SmtpConfig) -> Result<Self, String> {
let creds = lettre::transport::smtp::authentication::Credentials::new(
cfg.user.clone(),
cfg.pass.clone(),
);
let transport = lettre::AsyncSmtpTransport::<lettre::Tokio1Executor>::starttls_relay(
&cfg.host,
)
.map_err(|e| e.to_string())?
.port(cfg.port)
.credentials(creds)
.build();
let from = cfg
.from
.parse()
.map_err(|_| "invalid CLAWMATES_SMTP_FROM".to_string())?;
Ok(Self { transport, from })
}
}
#[async_trait::async_trait]
impl EmailSender for LettreSender {
async fn send(&self, to: &str, subject: &str, body: &str) -> Result<(), String> {
use lettre::AsyncTransport;
let to_mbox: lettre::message::Mailbox =
to.parse().map_err(|_| format!("invalid recipient {to}"))?;
let email = lettre::Message::builder()
.from(self.from.clone())
.to(to_mbox)
.subject(subject)
.body(body.to_string())
.map_err(|e| e.to_string())?;
self.transport
.send(email)
.await
.map(|_| ())
.map_err(|e| e.to_string())
}
}
/// Drain all currently-queued emails once via `sender`. Returns (sent, failed).
/// A send failure marks the row `failed` (left for inspection, not retried).
pub async fn drain_once(pool: &PgPool, sender: &dyn EmailSender) -> (u32, u32) {
let (mut sent, mut failed) = (0u32, 0u32);
let queued = cm_db::repo::outbox::list_queued(pool, 50)
.await
.unwrap_or_default();
for e in queued {
match sender.send(&e.recipient, &e.subject, &e.body).await {
Ok(()) => {
let _ = cm_db::repo::outbox::mark_sent(pool, e.id).await;
sent += 1;
}
Err(err) => {
eprintln!("outbox: send to {} failed: {err}", e.recipient);
let _ = cm_db::repo::outbox::mark_failed(pool, e.id).await;
failed += 1;
}
}
}
(sent, failed)
}
/// Spawn the outbox drainer loop. No-op (logs) if SMTP isn't configured, so it's
/// safe to wire unconditionally.
pub fn spawn_drainer(pool: PgPool, interval: Duration) {
let Some(cfg) = SmtpConfig::from_env() else {
println!(
"clawmates-server: outbox delivery DISABLED \
(set CLAWMATES_SMTP_HOST/FROM/USER/PASS to enable)"
);
return;
};
let sender = match LettreSender::new(&cfg) {
Ok(s) => Arc::new(s),
Err(e) => {
eprintln!("clawmates-server: SMTP sender init failed: {e}");
return;
}
};
println!(
"clawmates-server: outbox delivery ENABLED via {}:{}",
cfg.host, cfg.port
);
tokio::spawn(async move {
loop {
drain_once(&pool, sender.as_ref()).await;
tokio::time::sleep(interval).await;
}
});
}
+113
View File
@@ -0,0 +1,113 @@
//! 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));
}
+10
View File
@@ -35,3 +35,13 @@ CLAWMATES_BOOTSTRAP_CREDITS=1250
# --- Observability (optional) ----------------------------------------------- # --- Observability (optional) -----------------------------------------------
# Export traces to an OTLP/HTTP collector. Unset = logs only, no network. # Export traces to an OTLP/HTTP collector. Unset = logs only, no network.
# CLAWMATES_TELEMETRY__OTLP_ENDPOINT=http://otel-collector:4318 # CLAWMATES_TELEMETRY__OTLP_ENDPOINT=http://otel-collector:4318
# --- Outbound email delivery (optional) -------------------------------------
# The §15 door's email_send queues to `outbox`; the server's drainer delivers
# over SMTP when these are set (otherwise it logs "outbox delivery DISABLED"
# and rows stay queued). Use an SMTP submission account (host + app password).
# CLAWMATES_SMTP_HOST=smtp.migadu.com
# CLAWMATES_SMTP_PORT=587
# [email protected]
# CLAWMATES_SMTP_PASS=<smtp app password>
# [email protected]