feat(email): native SMTP-listener ingress (A3)
Some checks failed
CI / Rust — fmt, clippy, test (push) Failing after 33m37s
CI / Dashboard — check (push) Successful in 9m49s

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>
This commit is contained in:
MechaCat02
2026-07-16 21:01:46 +02:00
parent 0dd03af0c6
commit dbdd398d90
11 changed files with 557 additions and 6 deletions

View File

@@ -8,12 +8,13 @@ use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use picloud::smtp::SmtpIngress;
use picloud::{build_app, init_db, AuthDeps};
use picloud_manager_core::{
auth::{hash_password, validate_password_hash},
bootstrap_first_admin, migrations, seed_hello_world_if_fresh, AdminSessionRepository,
AdminUserRepository, HelloWorldOutcome, PostgresAppRepository, PostgresRouteRepository,
PostgresScriptRepository,
AdminUserRepository, HelloWorldOutcome, PostgresAppRepository, PostgresOutboxRepo,
PostgresRouteRepository, PostgresScriptRepository, PostgresTriggerRepo,
};
use tracing_subscriber::EnvFilter;
@@ -74,6 +75,25 @@ async fn run_server() -> anyhow::Result<()> {
// so a delayed sweep can't extend session lifetimes.
spawn_session_pruner(auth.sessions.clone());
// A3: opt-in native SMTP ingress. Binds only when PICLOUD_SMTP_BIND is set;
// shares the pool, resolves each RCPT TO to an app-owned email trigger, and
// writes Email outbox rows the dispatcher fires (same tail as the webhook).
// Its own shutdown_signal() future resolves on the same ctrl-c/SIGTERM.
if let Ok(smtp_bind) = std::env::var("PICLOUD_SMTP_BIND") {
let smtp_addr: SocketAddr = smtp_bind
.parse()
.map_err(|e| anyhow::anyhow!("invalid PICLOUD_SMTP_BIND `{smtp_bind}`: {e}"))?;
let ingress = SmtpIngress::new(
Arc::new(PostgresTriggerRepo::new(pool.clone())),
Arc::new(PostgresOutboxRepo::new(pool.clone())),
);
tokio::spawn(async move {
if let Err(e) = ingress.serve(smtp_addr, shutdown_signal()).await {
tracing::error!(?e, "smtp ingress failed");
}
});
}
let app = build_app(pool, auth, master_key).await?;
let listener = tokio::net::TcpListener::bind(addr).await?;