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]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
6e87433c66
commit
e8486ee57c
+40
@@ -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"
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "UPDATE outbox SET status = 'failed' WHERE id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "4a6dc5f913e7844abc3df5762846ba2d13348c96c9d617e8a10c2ce0017bd324"
|
||||||
|
}
|
||||||
+14
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user