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>
This commit is contained in:
@@ -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?;
|
||||
|
||||
Reference in New Issue
Block a user