Add SMTP mail, access-request notifications, and a shared request throttle

A new [mail] section configures an SMTP relay (SES-style STARTTLS or
implicit TLS; the password is environment-only via
DAILY_EPUB_MAIL__SMTP_PASS) and src/mail.rs wraps lettre in a small
plain-text Mailer built once at server start. Each stored access request
now emails mail.notify_to in a spawned task with the address, reason,
time, and a link to /dashboard/users. POST /request-access shares the
login endpoint's per-IP limiter (server.login_attempts per window).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QVPagF6jfDv78CC5Jv2wp4
This commit is contained in:
2026-09-07 01:24:27 +00:00
co-authored by Claude Fable 5.1
parent feb887a8af
commit df76c81b59
11 changed files with 532 additions and 11 deletions
+123 -1
View File
@@ -83,6 +83,7 @@ pub struct Config {
pub xtc: XtcConfig,
pub server: ServerConfig,
pub bookorbit: BookorbitConfig,
pub mail: MailConfig,
}
impl Default for Config {
@@ -108,6 +109,7 @@ impl Default for Config {
xtc: XtcConfig::default(),
server: ServerConfig::default(),
bookorbit: BookorbitConfig::default(),
mail: MailConfig::default(),
}
}
}
@@ -704,7 +706,7 @@ pub struct ServerConfig {
pub basic_auth_pass: Option<String>,
/// Sliding web-session lifetime in days.
pub session_days: u32,
/// Login attempts allowed per IP during the configured window.
/// Login and access-request POSTs allowed per IP during the configured window.
pub login_attempts: u32,
pub login_window_minutes: u32,
/// Whether the operator dashboard may start systemd jobs.
@@ -784,6 +786,60 @@ impl BookorbitConfig {
}
}
/// `[mail]` — optional outbound SMTP delivery.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct MailConfig {
/// Whether outbound mail is enabled.
pub enabled: bool,
/// SMTP relay hostname.
pub smtp_host: String,
/// SMTP relay port.
pub smtp_port: u16,
/// Upgrade the connection with STARTTLS; false uses implicit TLS.
pub smtp_starttls: bool,
/// SMTP username.
pub smtp_user: Option<String>,
/// SMTP password; supply via `DAILY_EPUB_MAIL__SMTP_PASS`.
pub smtp_pass: Option<String>,
/// Sender mailbox, either an address or `Name <address>`.
pub from: String,
/// Recipient for access-request notifications.
pub notify_to: Option<String>,
}
impl Default for MailConfig {
fn default() -> Self {
Self {
enabled: false,
smtp_host: String::new(),
smtp_port: 587,
smtp_starttls: true,
smtp_user: None,
smtp_pass: None,
from: String::new(),
notify_to: None,
}
}
}
impl MailConfig {
/// Whether mail is enabled with all fields required for SMTP delivery.
pub fn is_active(&self) -> bool {
self.enabled
&& !self.smtp_host.trim().is_empty()
&& !self.from.trim().is_empty()
&& self
.smtp_user
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
&& self
.smtp_pass
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
}
}
/// Config keys that moved from `[deepseek]` to `[llm]`; anywhere else they are
/// a stale-configuration error.
const LLM_ROLE_KEYS: &[&str] = &[
@@ -1001,6 +1057,16 @@ impl Config {
/// Cheap sanity checks so misconfiguration fails at startup, not mid-run.
pub fn validate(&self) -> Result<(), ConfigError> {
if self.mail.enabled && self.mail.smtp_host.trim().is_empty() {
return Err(ConfigError::Invalid(
"mail.smtp_host must not be empty when mail.enabled is true".into(),
));
}
if self.mail.enabled && self.mail.from.trim().is_empty() {
return Err(ConfigError::Invalid(
"mail.from must not be empty when mail.enabled is true".into(),
));
}
if self.server.session_days == 0 {
return Err(ConfigError::Invalid(
"server.session_days must be >= 1".into(),
@@ -1328,6 +1394,14 @@ mod tests {
assert_eq!(c.bookorbit.api_url, "http://127.0.0.1:3498");
assert!(c.bookorbit.opds_user.is_none());
assert!(c.bookorbit.opds_pass.is_none());
assert!(!c.mail.enabled);
assert!(c.mail.smtp_host.is_empty());
assert_eq!(c.mail.smtp_port, 587);
assert!(c.mail.smtp_starttls);
assert!(c.mail.smtp_user.is_none());
assert!(c.mail.smtp_pass.is_none());
assert!(c.mail.from.is_empty());
assert!(c.mail.notify_to.is_none());
c.validate().unwrap();
}
@@ -1358,6 +1432,7 @@ mod tests {
jail.set_env("DAILY_EPUB_TARGET_ARTICLE_COUNT", "12");
jail.set_env("DAILY_EPUB_SERVER__HMAC_SECRET", "hunter2");
jail.set_env("DAILY_EPUB_BOOKORBIT__OPDS_PASS", "orbit-secret");
jail.set_env("DAILY_EPUB_MAIL__SMTP_PASS", "smtp-secret");
jail.set_env("DAILY_EPUB_VOYAGE__API_KEY", "voyage-key");
jail.set_env("DAILY_EPUB_VOYAGE__ENABLED", "false");
jail.set_env("DAILY_EPUB_PROVIDERS__GEMINI__API_KEY", "gemini-key");
@@ -1387,6 +1462,7 @@ mod tests {
assert_eq!(c.target_article_count, 12);
assert_eq!(c.server.hmac_secret.as_deref(), Some("hunter2"));
assert_eq!(c.bookorbit.opds_pass.as_deref(), Some("orbit-secret"));
assert_eq!(c.mail.smtp_pass.as_deref(), Some("smtp-secret"));
// untouched default
assert_eq!(c.retention_days, 21);
assert_eq!(c.timezone, "America/New_York");
@@ -1411,6 +1487,52 @@ mod tests {
assert!(!bookorbit.is_active());
}
#[test]
fn mail_defaults_activation_and_validation() {
let mut mail = MailConfig::default();
assert!(!mail.enabled);
assert_eq!(mail.smtp_port, 587);
assert!(mail.smtp_starttls);
assert!(!mail.is_active());
mail.enabled = true;
assert!(
Config {
mail: mail.clone(),
..Config::default()
}
.validate()
.is_err()
);
mail.smtp_host = "email-smtp.us-east-1.amazonaws.com".into();
assert!(
Config {
mail: mail.clone(),
..Config::default()
}
.validate()
.is_err()
);
mail.from = "The Daily EPUB <daily@example.com>".into();
assert!(
Config {
mail: mail.clone(),
..Config::default()
}
.validate()
.is_ok()
);
assert!(!mail.is_active());
mail.smtp_user = Some("smtp-user".into());
mail.smtp_pass = Some("smtp-pass".into());
assert!(mail.is_active());
mail.smtp_pass = Some(" ".into());
assert!(!mail.is_active());
}
#[test]
fn explicit_missing_path_is_an_error() {
assert!(matches!(