From df76c81b5914a875e671b38bc04367caa78e944f Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Mon, 7 Sep 2026 01:24:27 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01QVPagF6jfDv78CC5Jv2wp4 --- Cargo.lock | 63 ++++++++++++++ Cargo.toml | 1 + README.md | 20 ++++- config.example.toml | 12 ++- src/config.rs | 124 ++++++++++++++++++++++++++- src/lib.rs | 1 + src/mail.rs | 155 ++++++++++++++++++++++++++++++++++ src/server.rs | 10 ++- src/web/access.rs | 103 +++++++++++++++++++++- src/web/dashboard/settings.rs | 34 +++++++- src/web/mod.rs | 20 ++++- 11 files changed, 532 insertions(+), 11 deletions(-) create mode 100644 src/mail.rs diff --git a/Cargo.lock b/Cargo.lock index 872c39a..a6110b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -902,6 +902,7 @@ dependencies = [ "hmac", "image", "jiff", + "lettre", "libc", "password-auth", "rand 0.10.2", @@ -1133,6 +1134,22 @@ dependencies = [ "serde", ] +[[package]] +name = "email-encoding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd" +dependencies = [ + "base64 0.23.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1700,6 +1717,17 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + [[package]] name = "html-escape" version = "0.2.15" @@ -2226,6 +2254,34 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" +[[package]] +name = "lettre" +version = "0.11.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae" +dependencies = [ + "async-trait", + "base64 0.23.1", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "nom", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2", + "tokio", + "tokio-rustls", + "url", + "webpki-roots", +] + [[package]] name = "libc" version = "0.2.189" @@ -2967,6 +3023,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + [[package]] name = "r-efi" version = "5.3.0" @@ -3318,6 +3380,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", diff --git a/Cargo.toml b/Cargo.toml index aaf3408..f4611b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ hex = "0.4.3" hmac = "0.13.0" image = "0.25.10" jiff = { version = "0.2.35", features = ["serde"] } +lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-rustls-tls", "hostname"] } libc = "0.2.189" password-auth = "1.0.0" rand = "0.10.2" diff --git a/README.md b/README.md index be199e7..6bf5175 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,10 @@ are case-insensitive and passwords must be 12–1024 characters. Bootstrap with `disable`/`enable`, `list`, and `logout` for later administration. Password changes and disabling a user revoke that user's sessions. `/dashboard/users` is a read-only view of roles, status, login times, and open sessions. +When `[mail]` is active and `mail.notify_to` is set, each stored request queues +a plain-text SMTP notification with a direct dashboard review link; SMTP runs +in the background and never delays the visitor's response. Mail settings are +loaded at server startup, so changes require a service restart. The Jobs page starts only the fixed job catalogue as `daily-epub-job@.service`; the web server never runs the pipeline inside @@ -263,8 +267,8 @@ articles do not yet have embeddings to compare. | `GET /issues/{date}/articles/{id}`, `/world`, `/behind` | User or admin | Private article, World Briefing, and Behind the paper chapters. | | `GET /issues/{date}/read` | User or admin | Open the Standard edition in BookOrbit's web reader when the integration is enabled. | | `GET /robots.txt`, `/static/{file}` | Public | Crawler policy and embedded CSS, JavaScript, and favicon. | -| `GET/POST /login`, `POST /logout` | Public/session | Sign in and out; login attempts are throttled per client IP. | -| `GET/POST /request-access` | Public | Request a reader account; requests are reviewed by an admin and fulfilled with the CLI. | +| `GET/POST /login`, `POST /logout` | Public/session | Sign in and out; login POSTs share a per-client-IP throttle budget with access requests. | +| `GET/POST /request-access` | Public | Request a reader account; POSTs share the login throttle, while GET stays unlimited. Requests are reviewed by an admin and fulfilled with the CLI. | | `GET /account`, `POST /account/password`, `/account/logout-all` | User or admin | Change the current password or revoke sessions. | | `POST /rate` | Admin | Append an attributed dashboard rating event. | | `GET /dashboard` | Admin | Run, budget, rating, job, and config overview. | @@ -391,6 +395,14 @@ prints what resolved. | `bookorbit.api_url` | `http://127.0.0.1:3498` | Server-facing BookOrbit base URL used for OPDS lookups. | | `bookorbit.opds_user` | unset | Dedicated OPDS user created in BookOrbit's Settings → OPDS. | | `bookorbit.opds_pass` | — | **`DAILY_EPUB_BOOKORBIT__OPDS_PASS`**, environment only. | +| `mail.enabled` | `false` | Enable outbound SMTP when the relay, sender, username, and password are configured. Mail settings require a server restart. | +| `mail.smtp_host` | `""` | SMTP relay hostname, such as `email-smtp.us-east-1.amazonaws.com`. | +| `mail.smtp_port` | `587` | SMTP relay port. Use 587 with STARTTLS or commonly 465 with implicit TLS. | +| `mail.smtp_starttls` | `true` | `true` uses STARTTLS; `false` uses implicit TLS. | +| `mail.smtp_user` | unset | SMTP username. It may be supplied as `DAILY_EPUB_MAIL__SMTP_USER`. | +| `mail.smtp_pass` | — | **`DAILY_EPUB_MAIL__SMTP_PASS`**, environment only. | +| `mail.from` | `""` | Sender mailbox, either a bare address or `Name
`. | +| `mail.notify_to` | unset | Recipient for access-request notifications. | | `xtc.enabled` | `true` | Set `false` to skip the converter entirely. | | `xtc.command` | `node` | Converter executable. | | `xtc.args` | `["/opt/epub-to-xtc-converter/cli/index.js", "convert"]` | Prefix; the code appends ` -o -f ` (plus `-c `). | @@ -401,7 +413,7 @@ prints what resolved. | `server.hmac_secret` | — | **`DAILY_EPUB_SERVER__HMAC_SECRET`** (or `DAILY_EPUB_SECRET`). Without it, generated links are rejected with 403. | | `server.basic_auth_user` / `_pass` | unset | Optional Basic auth for `/opds/*` and `/files/*`; signed-in web users may download from `/files/*` without Basic auth. | | `server.session_days` | `30` | Sliding lifetime for dashboard login sessions. | -| `server.login_attempts` | `10` | Login attempts allowed per IP in one throttle window. | +| `server.login_attempts` | `10` | Shared login and access-request POSTs allowed per IP in one throttle window. | | `server.login_window_minutes` | `15` | Length of the login throttle window. | | `server.jobs_enabled` | `true` | Allow the dashboard to start the fixed systemd job catalogue. | | `server.journal_lines` | `300` | Journal lines shown on a dashboard job page (10–5000). | @@ -456,6 +468,8 @@ DAILY_EPUB_PROVIDERS__ANTHROPIC__API_KEY=… # DAILY_EPUB_PROVIDERS__GEMINI__API_KEY=… # only if a role names "gemini" DAILY_EPUB_VOYAGE__API_KEY=… DAILY_EPUB_SERVER__HMAC_SECRET=$(openssl rand -hex 32) +DAILY_EPUB_MAIL__SMTP_USER=… +DAILY_EPUB_MAIL__SMTP_PASS=… EOF sudo chown daily-epub:daily-epub /etc/daily-epub/env && sudo chmod 0600 /etc/daily-epub/env # One key per [providers.] entry a role uses, named after the table diff --git a/config.example.toml b/config.example.toml index 8339ac3..74b1ccf 100644 --- a/config.example.toml +++ b/config.example.toml @@ -211,7 +211,7 @@ settings = "/etc/daily-epub/xtc-settings.json" bind = "127.0.0.1:3499" # listen address; keep loopback when trusting proxy IP headers public_url = "https://daily.hallada.net" # base URL for EPUB rating links and same-origin checks session_days = 30 # sliding lifetime for web login sessions -login_attempts = 10 # login POSTs allowed per client IP in one throttle window +login_attempts = 10 # shared login/access-request POSTs per client IP per window login_window_minutes = 15 # length of the login throttle window jobs_enabled = true # let the dashboard start daily-epub-job@.service journal_lines = 300 # job-page journal tail; valid range 10..=5000 @@ -227,3 +227,13 @@ public_url = "https://bookorbit.hallada.net" # what the browser opens api_url = "http://127.0.0.1:3498" # where the server talks OPDS; same host opds_user = "" # an OPDS user from BookOrbit → Settings → OPDS # opds_pass: environment only (DAILY_EPUB_BOOKORBIT__OPDS_PASS) + +[mail] +enabled = false +smtp_host = "" # e.g. email-smtp.us-east-1.amazonaws.com +smtp_port = 587 # 587 with STARTTLS; commonly 465 with implicit TLS +smtp_starttls = true # false selects implicit TLS +smtp_user = "" +# smtp_pass: environment only (DAILY_EPUB_MAIL__SMTP_PASS) +from = "" # e.g. The Daily EPUB +# notify_to = "operator@example.com" # recipient for access-request notifications diff --git a/src/config.rs b/src/config.rs index 2e27e19..0fa4028 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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, /// 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, + /// SMTP password; supply via `DAILY_EPUB_MAIL__SMTP_PASS`. + pub smtp_pass: Option, + /// Sender mailbox, either an address or `Name
`. + pub from: String, + /// Recipient for access-request notifications. + pub notify_to: Option, +} + +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 ".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!( diff --git a/src/lib.rs b/src/lib.rs index b1c5bb1..bb7b1d9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/mail.rs b/src/mail.rs new file mode 100644 index 0000000..b918025 --- /dev/null +++ b/src/mail.rs @@ -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
`. + pub to: String, + /// Message subject. + pub subject: String, + /// Plain-text message body. + pub body: String, +} + +#[derive(Clone)] +enum Transport { + Smtp(AsyncSmtpTransport), + #[cfg(test)] + Recording(Arc>>), +} + +/// 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> { + 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::::starttls_relay(&config.smtp_host) + } else { + AsyncSmtpTransport::::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>>) { + 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 { + value + .parse::() + .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 ", "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()); + } +} diff --git a/src/server.rs b/src/server.rs index 884c23f..f1d9ae1 100644 --- a/src/server.rs +++ b/src/server.rs @@ -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>>, pub config_path: Option, pub web: Arc, + /// Startup-built SMTP sender. Mail configuration changes require a restart. + pub mailer: Option, } 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::(), diff --git a/src/web/access.rs b/src/web/access.rs index 8de23d7..f45aa8d 100644 --- a/src/web/access.rs +++ b/src/web/access.rs @@ -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::("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 ".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 "); + 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; diff --git a/src/web/dashboard/settings.rs b/src/web/dashboard/settings.rs index 0ecf3f1..f4c7e10 100644 --- a/src/web/dashboard/settings.rs +++ b/src/web/dashboard/settings.rs @@ -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
. 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", ] { diff --git a/src/web/mod.rs b/src/web/mod.rs index 1113ba9..e720645 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -659,7 +659,13 @@ pub fn router(config: &crate::config::Config) -> axum::Router axum::Router