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>
106 lines
3.8 KiB
Rust
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"
|
|
);
|
|
}
|