Files
PiCloud/crates/picloud/tests/smtp_ingress.rs
MechaCat02 dbdd398d90
Some checks failed
CI / Rust — fmt, clippy, test (push) Failing after 33m37s
CI / Dashboard — check (push) Successful in 9m49s
feat(email): native SMTP-listener ingress (A3)
An opt-in receive-only SMTP listener (PICLOUD_SMTP_BIND) so an MX can point
straight at PiCloud. It speaks minimal SMTP (HELO/EHLO/MAIL/RCPT/DATA/RSET/
NOOP/QUIT), resolves each RCPT TO mailbox to the app-owned email trigger that
claims it, and inserts an Email outbox row the dispatcher fires — the same tail
as the HMAC webhook, unchanged.

- migration 0076: email_trigger_details.inbound_address (case-insensitively
  unique among app triggers) + TriggerRepo::email_inbound_target_by_address /
  SmtpInboundTarget; the interactive create-email API accepts inbound_address.
- crates/picloud/src/smtp.rs: a small tokio accept loop + session state machine
  + a testable deliver() core + a minimal RFC-5322 header/body split. DATA is
  size-capped with dot-unstuffing; no AUTH/STARTTLS (TLS terminates upstream —
  the recipient address + per-app isolation are the boundary).
- spawned in run_server alongside axum::serve, sharing the pool, on the same
  shutdown signal.

Pinned by a picloud integration test (deliver → one Email outbox row for a
known mailbox, none for an unknown one) + smtp.rs unit tests (address parse,
header/body split). Multipart/MIME decoding is a documented follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 21:01:46 +02:00

106 lines
3.8 KiB
Rust

//! A3 — the native SMTP ingress delivery core. Seeds an app-owned email trigger
//! with an inbound `RCPT TO` address, then drives `SmtpIngress::deliver`
//! directly (no socket — deterministic) and asserts it writes exactly one
//! `Email` outbox row for the known mailbox and none for an unknown one. The
//! dispatcher tail (outbox → script) is covered by the existing dispatcher
//! tests; this pins the resolve-address → outbox step the SMTP path adds.
use std::sync::Arc;
use picloud::smtp::SmtpIngress;
use picloud_manager_core::{PostgresOutboxRepo, PostgresTriggerRepo};
use sqlx::PgPool;
use uuid::Uuid;
mod common;
async fn seed(pool: &PgPool, address: &str) -> Uuid {
let group: (Uuid,) =
sqlx::query_as("INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id")
.bind(format!("smtp-g-{}", Uuid::new_v4()))
.fetch_one(pool)
.await
.expect("group");
let app: (Uuid,) =
sqlx::query_as("INSERT INTO apps (slug, name, group_id) VALUES ($1, $1, $2) RETURNING id")
.bind(format!("smtp-a-{}", Uuid::new_v4()))
.bind(group.0)
.fetch_one(pool)
.await
.expect("app");
let admin: (Uuid,) = sqlx::query_as(
"INSERT INTO admin_users (username, password_hash) VALUES ($1, 'x') RETURNING id",
)
.bind(format!("smtp-u-{}", Uuid::new_v4()))
.fetch_one(pool)
.await
.expect("admin");
let script: (Uuid,) = sqlx::query_as(
"INSERT INTO scripts (name, source, app_id) VALUES ($1, 'x', $2) RETURNING id",
)
.bind(format!("smtp-s-{}", Uuid::new_v4()))
.bind(app.0)
.fetch_one(pool)
.await
.expect("script");
let trigger: (Uuid,) = sqlx::query_as(
"INSERT INTO triggers \
(app_id, script_id, kind, enabled, dispatch_mode, retry_max_attempts, \
retry_backoff, retry_base_ms, registered_by_principal, name) \
VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3, $4) RETURNING id",
)
.bind(app.0)
.bind(script.0)
.bind(admin.0)
.bind(format!("smtp-t-{}", Uuid::new_v4()))
.fetch_one(pool)
.await
.expect("trigger");
sqlx::query("INSERT INTO email_trigger_details (trigger_id, inbound_address) VALUES ($1, $2)")
.bind(trigger.0)
.bind(address)
.execute(pool)
.await
.expect("email details");
app.0
}
async fn email_outbox_count(pool: &PgPool, app_id: Uuid) -> i64 {
let row: (i64,) =
sqlx::query_as("SELECT count(*) FROM outbox WHERE app_id = $1 AND source_kind = 'email'")
.bind(app_id)
.fetch_one(pool)
.await
.expect("count");
row.0
}
#[tokio::test]
async fn deliver_writes_one_outbox_row_for_a_known_mailbox() {
let Some(pool) = common::test_pool("smtp_ingress").await else {
return;
};
let address = "hooks@app.test";
let app_id = seed(&pool, address).await;
let ingress = SmtpIngress::new(
Arc::new(PostgresTriggerRepo::new(pool.clone())),
Arc::new(PostgresOutboxRepo::new(pool.clone())),
);
let raw = b"From: alice@example.com\r\nSubject: Hi there\r\n\r\nhello body\r\n";
// Known mailbox → one outbox row.
let delivered = ingress.deliver(&[address.to_string()], raw).await;
assert_eq!(delivered, 1, "a known mailbox must be delivered");
assert_eq!(email_outbox_count(&pool, app_id).await, 1);
// Unknown mailbox → nothing (the resolution IS the boundary).
let delivered = ingress.deliver(&["nobody@app.test".to_string()], raw).await;
assert_eq!(delivered, 0, "an unknown mailbox must not deliver");
assert_eq!(
email_outbox_count(&pool, app_id).await,
1,
"no extra outbox row for an unknown mailbox"
);
}