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!(
+1
View File
@@ -27,6 +27,7 @@ pub mod images;
pub mod imports;
pub mod jobs;
pub mod lock;
pub mod mail;
pub mod miniflux;
pub mod pipeline;
pub mod publish;
+155
View File
@@ -0,0 +1,155 @@
//! Minimal plain-text SMTP delivery.
#[cfg(test)]
use std::sync::Arc;
use anyhow::Context as _;
use lettre::message::{Mailbox, header::ContentType};
use lettre::transport::smtp::authentication::Credentials;
use lettre::{AsyncSmtpTransport, AsyncTransport as _, Tokio1Executor};
use crate::config::MailConfig;
/// A plain-text outbound message.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Message {
/// Recipient mailbox, either an address or `Name <address>`.
pub to: String,
/// Message subject.
pub subject: String,
/// Plain-text message body.
pub body: String,
}
#[derive(Clone)]
enum Transport {
Smtp(AsyncSmtpTransport<Tokio1Executor>),
#[cfg(test)]
Recording(Arc<std::sync::Mutex<Vec<Message>>>),
}
/// A reusable SMTP sender built from the startup configuration.
#[derive(Clone)]
pub struct Mailer {
from: Mailbox,
transport: Transport,
}
impl Mailer {
/// Build an SMTP sender, or return `None` when mail is not fully active.
pub fn from_config(config: &MailConfig) -> anyhow::Result<Option<Self>> {
if !config.is_active() {
return Ok(None);
}
let from = parse_mailbox(&config.from, "mail.from")?;
let user = config
.smtp_user
.as_deref()
.expect("active mail config has an SMTP username");
let pass = config
.smtp_pass
.as_deref()
.expect("active mail config has an SMTP password");
let builder = if config.smtp_starttls {
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&config.smtp_host)
} else {
AsyncSmtpTransport::<Tokio1Executor>::relay(&config.smtp_host)
}
.with_context(|| format!("invalid SMTP host {:?}", config.smtp_host))?;
let transport = builder
.port(config.smtp_port)
.credentials(Credentials::new(user.to_string(), pass.to_string()))
.build();
Ok(Some(Self {
from,
transport: Transport::Smtp(transport),
}))
}
/// Send one plain-text message.
pub async fn send(&self, message: Message) -> anyhow::Result<()> {
#[cfg(test)]
if let Transport::Recording(messages) = &self.transport {
messages
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.push(message);
return Ok(());
}
let to = parse_mailbox(&message.to, "message recipient")?;
let email = lettre::Message::builder()
.from(self.from.clone())
.to(to)
.subject(message.subject)
.header(ContentType::TEXT_PLAIN)
.body(message.body)
.context("building plain-text email")?;
match &self.transport {
Transport::Smtp(transport) => {
transport
.send(email)
.await
.context("sending email through SMTP")?;
}
#[cfg(test)]
Transport::Recording(_) => unreachable!("recording transport returned above"),
}
Ok(())
}
#[cfg(test)]
pub(crate) fn recording() -> (Self, Arc<std::sync::Mutex<Vec<Message>>>) {
let messages = Arc::new(std::sync::Mutex::new(Vec::new()));
(
Self {
from: "daily@example.com".parse().expect("valid test mailbox"),
transport: Transport::Recording(Arc::clone(&messages)),
},
messages,
)
}
}
fn parse_mailbox(value: &str, field: &str) -> anyhow::Result<Mailbox> {
value
.parse::<Mailbox>()
.with_context(|| format!("{field} is not a valid email mailbox"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_named_and_bare_mailboxes() {
assert!(parse_mailbox("The Daily EPUB <daily@example.com>", "from").is_ok());
assert!(parse_mailbox("operator@example.com", "to").is_ok());
assert!(parse_mailbox("not an address", "to").is_err());
}
#[test]
fn inactive_config_builds_no_mailer() {
assert!(
Mailer::from_config(&MailConfig::default())
.unwrap()
.is_none()
);
let mut config = MailConfig {
enabled: true,
smtp_host: "smtp.example.com".into(),
from: "daily@example.com".into(),
smtp_user: Some("user".into()),
smtp_pass: Some(" ".into()),
..MailConfig::default()
};
assert!(Mailer::from_config(&config).unwrap().is_none());
config.smtp_pass = Some("secret".into());
config.from = "not an address".into();
assert!(Mailer::from_config(&config).is_err());
}
}
+9 -1
View File
@@ -70,6 +70,8 @@ pub enum ServerError {
},
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("could not configure outbound mail: {0}")]
Mail(#[source] anyhow::Error),
}
/// Shared axum state.
@@ -79,6 +81,8 @@ pub struct AppState {
pub config: Arc<RwLock<Arc<Config>>>,
pub config_path: Option<PathBuf>,
pub web: Arc<crate::web::WebState>,
/// Startup-built SMTP sender. Mail configuration changes require a restart.
pub mailer: Option<crate::mail::Mailer>,
}
impl std::fmt::Debug for AppState {
@@ -102,6 +106,7 @@ impl AppState {
started_at: Timestamp::now(),
config_mtime: std::sync::Mutex::new(None),
}),
mailer: None,
}
}
@@ -201,6 +206,7 @@ pub async fn serve(
// Not fatal for the OPDS routes, but every rating link would 500.
tracing::warn!("server.hmac_secret is unset — rating links will be rejected");
}
let mailer = crate::mail::Mailer::from_config(&config.mail).map_err(ServerError::Mail)?;
let addr = config.server.bind.clone();
let listener = tokio::net::TcpListener::bind(&addr)
.await
@@ -234,7 +240,9 @@ pub async fn serve(
tracing::info!("server.jobs_enabled is false; the Jobs page cannot start units");
Arc::new(crate::web::DisabledRunner)
};
let app = router(AppState::with_jobs(db, config, config_path, jobs));
let mut state = AppState::with_jobs(db, config, config_path, jobs);
state.mailer = mailer;
let app = router(state);
axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
+101 -2
View File
@@ -71,6 +71,7 @@ pub(super) async fn submit(
return Ok(Html(view).into_response());
}
let requested_at = crate::db::fmt_ts(jiff::Timestamp::now());
sqlx::query(
"INSERT INTO account_requests (email, reason, status, requested_at)
VALUES (?, ?, 'open', ?)
@@ -81,11 +82,40 @@ pub(super) async fn submit(
)
.bind(email)
.bind((!reason.is_empty()).then_some(reason))
.bind(crate::db::fmt_ts(jiff::Timestamp::now()))
.bind(&requested_at)
.execute(state.db.pool())
.await
.map_err(|error| WebError::Db(error.into()))?;
let config = state.config();
if let (Some(mailer), Some(to)) = (
state.mailer.clone(),
config
.mail
.notify_to
.as_deref()
.filter(|value| !value.trim().is_empty()),
) {
let reason = if reason.is_empty() {
"(no reason given)"
} else {
reason
};
let message = crate::mail::Message {
to: to.to_string(),
subject: format!("Access request from {email}"),
body: format!(
"Email: {email}\nReason: {reason}\nRequested at: {requested_at}\nReview: {}/dashboard/users\n",
config.server.public_url.trim_end_matches('/')
),
};
tokio::spawn(async move {
if let Err(error) = mailer.send(message).await {
tracing::warn!(%error, "could not send access-request notification");
}
});
}
let mut view = template(viewer);
view.submitted = true;
Ok(Html(view).into_response())
@@ -117,11 +147,15 @@ mod tests {
use crate::server::{AppState, router};
async fn app() -> (tempfile::TempDir, Db, axum::Router) {
app_with_config(Config::default()).await
}
async fn app_with_config(config: Config) -> (tempfile::TempDir, Db, axum::Router) {
let dir = tempfile::tempdir().unwrap();
let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
.await
.unwrap();
let app = router(AppState::new(db.clone(), Config::default(), None));
let app = router(AppState::new(db.clone(), config, None));
(dir, db, app)
}
@@ -156,6 +190,7 @@ mod tests {
.uri("/request-access")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header("sec-fetch-site", "same-origin")
.header("x-forwarded-for", "192.0.2.30")
.body(Body::from(body.to_string()))
.unwrap(),
)
@@ -209,6 +244,70 @@ mod tests {
assert_eq!(row.get::<String, _>("status"), "open");
}
#[tokio::test]
async fn stored_request_queues_an_operator_notification() {
let dir = tempfile::tempdir().unwrap();
let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
.await
.unwrap();
let mut config = Config::default();
config.server.public_url = "https://daily.example/".into();
config.mail.notify_to = Some("Operator <operator@example.com>".into());
let (mailer, messages) = crate::mail::Mailer::recording();
let mut state = AppState::new(db, config, None);
state.mailer = Some(mailer);
let app = router(state);
let (status, _) = post(&app, "email=reader%40example.com&reason=&website=").await;
assert_eq!(status, StatusCode::OK);
tokio::time::timeout(std::time::Duration::from_secs(1), async {
loop {
if !messages
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.is_empty()
{
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("notification task did not run");
let messages = messages
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].to, "Operator <operator@example.com>");
assert_eq!(
messages[0].subject,
"Access request from reader@example.com"
);
assert!(messages[0].body.contains("Email: reader@example.com"));
assert!(messages[0].body.contains("Reason: (no reason given)"));
assert!(messages[0].body.contains("Requested at: "));
assert!(
messages[0]
.body
.contains("Review: https://daily.example/dashboard/users")
);
}
#[tokio::test]
async fn request_access_post_is_rate_limited_per_ip() {
let mut config = Config::default();
config.server.login_attempts = 3;
let (_dir, _db, app) = app_with_config(config).await;
for _ in 0..3 {
let (status, _) = post(&app, "email=reader%40example.com&reason=&website=").await;
assert_eq!(status, StatusCode::OK);
}
let (status, _) = post(&app, "email=reader%40example.com&reason=&website=").await;
assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
}
#[tokio::test]
async fn honeypot_pretends_success_without_storing() {
let (_dir, db, app) = app().await;
+33 -1
View File
@@ -205,6 +205,14 @@ pub const RESTART_REQUIRED: &[&str] = &[
"server.session_days",
"server.login_attempts",
"server.login_window_minutes",
"mail.enabled",
"mail.smtp_host",
"mail.smtp_port",
"mail.smtp_starttls",
"mail.smtp_user",
"mail.smtp_pass",
"mail.from",
"mail.notify_to",
];
/// Optional keys that `Config::default()` leaves unset (and therefore do not
@@ -219,10 +227,19 @@ const OPTIONAL_KEYS: &[(&str, FieldKind)] = &[
("server.basic_auth_pass", FieldKind::Secret),
("bookorbit.opds_user", FieldKind::Text),
("bookorbit.opds_pass", FieldKind::Secret),
("mail.smtp_user", FieldKind::Text),
("mail.smtp_pass", FieldKind::Secret),
("mail.notify_to", FieldKind::Text),
("xtc.settings", FieldKind::Path),
];
const SECRET_SUFFIXES: &[&str] = &["api_key", "hmac_secret", "basic_auth_pass", "opds_pass"];
const SECRET_SUFFIXES: &[&str] = &[
"api_key",
"hmac_secret",
"basic_auth_pass",
"opds_pass",
"smtp_pass",
];
const PATH_KEYS: &[&str] = &[
"database_path",
@@ -259,6 +276,7 @@ const GROUP_ORDER: &[&str] = &[
"server",
"miniflux",
"bookorbit",
"mail",
];
/// Help text per key, seeded from the README configuration table and the
@@ -375,6 +393,14 @@ pub const SETTINGS_HELP: &[(&str, &str)] = &[
("bookorbit.api_url", "Base URL used by the server for BookOrbit OPDS requests; usually the loopback address."),
("bookorbit.opds_user", "Dedicated OPDS user created in BookOrbit Settings → OPDS."),
("bookorbit.opds_pass", "Password for bookorbit.opds_user. Environment only."),
("mail.enabled", "Enable outbound SMTP when the relay, sender and credentials are configured. Requires a server restart."),
("mail.smtp_host", "SMTP relay hostname, such as an AWS SES SMTP endpoint. Requires a server restart."),
("mail.smtp_port", "SMTP relay port: usually 587 for STARTTLS or 465 for implicit TLS. Requires a server restart."),
("mail.smtp_starttls", "Use STARTTLS when true; false uses implicit TLS. Requires a server restart."),
("mail.smtp_user", "SMTP username. Requires a server restart."),
("mail.smtp_pass", "SMTP password. Environment only; requires a server restart."),
("mail.from", "Sender mailbox as an address or Name <address>. Requires a server restart."),
("mail.notify_to", "Recipient for new access-request notifications. Requires a server restart."),
];
/// `DAILY_EPUB_` + the path upper-cased with `.` → `__` (§13.1 item 2).
@@ -1664,6 +1690,9 @@ mod tests {
"server.basic_auth_user",
"bookorbit.opds_user",
"bookorbit.opds_pass",
"mail.smtp_user",
"mail.smtp_pass",
"mail.notify_to",
] {
field(&groups, path);
}
@@ -1695,6 +1724,7 @@ mod tests {
"server",
"miniflux",
"bookorbit",
"mail",
]
);
let anthropic = groups
@@ -1811,6 +1841,7 @@ mod tests {
config.server.hmac_secret = Some("hunter2-hmac".into());
config.server.basic_auth_pass = Some("hunter2-basic".into());
config.bookorbit.opds_pass = Some("hunter2-bookorbit".into());
config.mail.smtp_pass = Some("hunter2-smtp".into());
if let Some(provider) = config.providers.get_mut("deepseek") {
provider.api_key = Some("hunter2-deepseek".into());
}
@@ -1821,6 +1852,7 @@ mod tests {
"server.hmac_secret",
"server.basic_auth_pass",
"bookorbit.opds_pass",
"mail.smtp_pass",
"providers.deepseek.api_key",
"providers.anthropic.api_key",
] {
+18 -2
View File
@@ -659,7 +659,13 @@ pub fn router(config: &crate::config::Config) -> axum::Router<crate::server::App
.route("/login", get(session::login_page))
.route(
"/login",
post(session::login).route_layer(GovernorLayer::new(governor)),
post(session::login).route_layer(GovernorLayer::new(governor.clone())),
);
let access_routes = axum::Router::new()
.route("/request-access", get(access::page))
.route(
"/request-access",
post(access::submit).route_layer(GovernorLayer::new(governor)),
);
let account = axum::Router::new()
.route("/account", get(session::account))
@@ -695,12 +701,12 @@ pub fn router(config: &crate::config::Config) -> axum::Router<crate::server::App
axum::Router::new()
.route("/", get(public::latest))
.route("/request-access", get(access::page).post(access::submit))
.route("/issues", get(public::archive))
.route("/issues/{date}", get(public::show_issue))
.route("/feed.xml", get(public::feed))
.route("/robots.txt", get(public::robots))
.route("/static/{file}", get(static_asset))
.merge(access_routes)
.merge(login)
.merge(account)
.merge(full_issues)
@@ -1336,6 +1342,16 @@ mod tests {
.await
.unwrap();
assert_eq!(limited.status(), StatusCode::TOO_MANY_REQUESTS);
let shared = app
.clone()
.oneshot(post(
"/request-access",
"email=reader%40example.com&reason=&website=",
"192.0.2.20",
))
.await
.unwrap();
assert_eq!(shared.status(), StatusCode::TOO_MANY_REQUESTS);
let other = app
.oneshot(post(
"/login",