Skip to main content

http_smtp_rele/
lib.rs

1//! http-smtp-rele: minimal, secure HTTP-to-SMTP submission relay.
2//!
3//! # Architecture
4//!
5//! ```text
6//! External Client
7//!   | HTTPS POST /v1/send
8//! Reverse Proxy / TLS Endpoint
9//!   | HTTP localhost
10//! http-smtp-rele
11//!   | SMTP localhost:25
12//! OpenSMTPD / SMTP Server
13//! ```
14//!
15//! Responsibility: auth, validation, sanitization, rate limit, SMTP submission.
16
17use std::sync::Arc;
18
19pub use request_id::RequestId;
20
21pub mod api;
22pub mod auth;
23pub mod config;
24pub mod error;
25pub mod logging;
26pub mod mail;
27pub mod policy;
28pub mod rate_limit;
29pub mod sanitize;
30pub mod security;
31pub mod smtp;
32pub mod metrics;
33pub mod request_id;
34pub mod status;
35pub mod status_memory;
36#[cfg(feature = "sqlite")]
37pub mod status_sqlite;
38#[cfg(feature = "redis")]
39pub mod status_redis;
40pub mod validation;
41
42#[cfg(test)]
43mod tests;
44
45/// Shared application state, cloned into every Axum request via `Arc`.
46///
47/// `config` is wrapped in `ArcSwap` for atomic hot-swap on SIGHUP (RFC 305).
48/// All code accesses config via `state.config()`, which returns a snapshot
49/// `Arc<AppConfig>` valid for the lifetime of one request.
50pub struct AppState {
51    config_store: arc_swap::ArcSwap<config::AppConfig>,
52    pub smtp: smtp::SmtpTransport,
53    pub rate_limiter: Arc<rate_limit::RateLimiter>,
54    pub metrics: Arc<metrics::Metrics>,
55    /// Submission status store (RFC 086/087).
56    pub status_store: Arc<dyn status::StatusStore>,
57}
58
59impl AppState {
60    /// Build application state from a validated config.
61    pub fn new(config: config::AppConfig) -> Arc<Self> {
62        let smtp = smtp::build_transport(&config.smtp)
63            .expect("SMTP transport construction failed after config validation");
64        let rate_limiter = Arc::new(rate_limit::RateLimiter::new(&config.rate_limit));
65        let m = Arc::new(metrics::Metrics::new());
66        let status_store: Arc<dyn status::StatusStore> = if !config.status.enabled {
67            Arc::new(status_memory::NoopStatusStore)
68        } else {
69            match config.status.store.as_str() {
70                #[cfg(feature = "sqlite")]
71                "sqlite" => {
72                    let db_path = config.status.db_path.as_deref()
73                        .expect("db_path validated in config::validate_config");
74                    status_sqlite::SqliteStatusStore::open(db_path, &config.status, Arc::clone(&m))
75                        .unwrap_or_else(|e| {
76                            eprintln!("fatal: {e}");
77                            std::process::exit(1);
78                        })
79                }
80                #[cfg(feature = "redis")]
81                "redis" => {
82                    let url = config.status.redis_url.as_deref()
83                        .expect("redis_url validated in config::validate_config");
84                    status_redis::RedisStatusStore::open(url, &config.status, Arc::clone(&m))
85                        .unwrap_or_else(|e| {
86                            eprintln!("fatal: {e}");
87                            std::process::exit(1);
88                        })
89                }
90                _ => status_memory::InMemoryStatusStore::new(&config.status, Arc::clone(&m)),
91            }
92        };
93        Arc::new(Self {
94            config_store: arc_swap::ArcSwap::from_pointee(config),
95            smtp,
96            rate_limiter,
97            metrics: m,
98            status_store,
99        })
100    }
101
102    /// Load a snapshot of the current config.
103    ///
104    /// Returns an `Arc<AppConfig>` that remains valid even if a concurrent
105    /// SIGHUP reload replaces the stored config.
106    pub fn config(&self) -> Arc<config::AppConfig> {
107        self.config_store.load_full()
108    }
109
110    /// Create an `AppState` with a pre-built status store (useful for tests and SQLite).
111    pub fn new_with_store(config: config::AppConfig, store: Arc<dyn status::StatusStore>) -> Arc<Self> {
112        let smtp = smtp::build_transport(&config.smtp)
113            .expect("build_transport failed in new_with_store");
114        let rate_limiter = Arc::new(rate_limit::RateLimiter::new(&config.rate_limit));
115        let m = Arc::new(metrics::Metrics::new());
116        Arc::new(Self {
117            config_store: arc_swap::ArcSwap::from_pointee(config),
118            smtp,
119            rate_limiter,
120            metrics: m,
121            status_store: store,
122        })
123    }
124
125    /// Replace the stored config atomically (called on SIGHUP, RFC 305).
126    ///
127    /// SIGHUP-reloadable status fields: `ttl_seconds`, `max_records`, `cleanup_interval_seconds`.
128    /// Apply SIGHUP reload. Restart-required fields that changed cause a warning
129    /// and are ignored — only reloadable fields take effect (RFC 811).
130    pub fn reload_config(&self, new_config: config::AppConfig) {
131        let current = self.config();
132
133        // Warn and skip if restart-required fields changed.
134        let restart_fields = config::restart_required_changes(&current, &new_config);
135        if !restart_fields.is_empty() {
136            tracing::warn!(
137                event  = "sighup_restart_required",
138                fields = %restart_fields.join(", "),
139                "SIGHUP reload rejected for these fields — restart required"
140            );
141            // Still apply reloadable fields by building a merged config.
142        }
143
144        // Build merged config: reloadable fields from new, restart-required from current.
145        let merged = config::merge_reloadable(&current, &new_config);
146        self.status_store.reload_config(&merged.status);
147        self.config_store.store(Arc::new(merged));
148        tracing::info!(event = "config_reloaded");
149    }
150}