From bdfeee2ceb7ffa8644c30a7645cd3baf1b98f3fd Mon Sep 17 00:00:00 2001 From: networkException Date: Sun, 25 Jan 2026 18:37:09 +0100 Subject: [PATCH] Add `password_file` config variant for `email` and `database` --- crates/cli/src/commands/server.rs | 2 +- crates/cli/src/commands/worker.rs | 2 +- crates/cli/src/util.rs | 31 ++++++++++++++---- crates/config/src/sections/database.rs | 20 +++++++++++- crates/config/src/sections/email.rs | 45 ++++++++++++++++++++++---- docs/config.schema.json | 18 +++++++++-- 6 files changed, 100 insertions(+), 18 deletions(-) diff --git a/crates/cli/src/commands/server.rs b/crates/cli/src/commands/server.rs index 3de5e01fa..5ff5f3cf8 100644 --- a/crates/cli/src/commands/server.rs +++ b/crates/cli/src/commands/server.rs @@ -177,7 +177,7 @@ impl Options { homeserver_connection_from_config(&config.matrix, http_client.clone()).await?; if !self.no_worker { - let mailer = mailer_from_config(&config.email, &templates)?; + let mailer = mailer_from_config(&config.email, &templates).await?; test_mailer_in_background(&mailer, Duration::from_secs(30)); info!("Starting task worker"); diff --git a/crates/cli/src/commands/worker.rs b/crates/cli/src/commands/worker.rs index 3cab3f866..5b2924552 100644 --- a/crates/cli/src/commands/worker.rs +++ b/crates/cli/src/commands/worker.rs @@ -64,7 +64,7 @@ impl Options { ) .await?; - let mailer = mailer_from_config(&config.email, &templates)?; + let mailer = mailer_from_config(&config.email, &templates).await?; test_mailer_in_background(&mailer, Duration::from_secs(30)); let http_client = mas_http::reqwest_client(); diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index 5fbe20719..c9832ef79 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -59,7 +59,7 @@ pub async fn password_manager_from_config( PasswordManager::new(config.minimum_complexity(), schemes) } -pub fn mailer_from_config( +pub async fn mailer_from_config( config: &EmailConfig, templates: &Templates, ) -> Result { @@ -83,10 +83,15 @@ pub fn mailer_from_config( .mode() .context("invalid email configuration: missing mode")?; - let credentials = match (config.username(), config.password()) { + let password = config + .password() + .await + .context("invalid email configuration: unable to read password file")?; + + let credentials = match (config.username(), password) { (Some(username), Some(password)) => Some(mas_email::SmtpCredentials::new( username.to_owned(), - password.to_owned(), + password.clone(), )), (None, None) => None, _ => { @@ -287,7 +292,7 @@ pub async fn templates_from_config( .with_context(|| format!("Failed to load the templates at {}", config.path)) } -fn database_connect_options_from_config( +async fn database_connect_options_from_config( config: &DatabaseConfig, opts: &DatabaseConnectOptions, ) -> Result { @@ -317,6 +322,15 @@ fn database_connect_options_from_config( opts = opts.password(password); } + if let Some(password_file) = config.password_file.as_deref() { + opts = opts.password( + tokio::fs::read_to_string(password_file) + .await + .context("could not read database password file")? + .trim(), + ); + } + if let Some(database) = config.database.as_deref() { opts = opts.database(database); } @@ -386,7 +400,8 @@ fn database_connect_options_from_config( /// Create a database connection pool from the configuration #[tracing::instrument(name = "db.connect", skip_all)] pub async fn database_pool_from_config(config: &DatabaseConfig) -> Result { - let options = database_connect_options_from_config(config, &DatabaseConnectOptions::default())?; + let options = + database_connect_options_from_config(config, &DatabaseConnectOptions::default()).await?; PgPoolOptions::new() .max_connections(config.max_connections.into()) .min_connections(config.min_connections) @@ -424,7 +439,8 @@ impl Default for DatabaseConnectOptions { pub async fn database_connection_from_config( config: &DatabaseConfig, ) -> Result { - database_connect_options_from_config(config, &DatabaseConnectOptions::default())? + database_connect_options_from_config(config, &DatabaseConnectOptions::default()) + .await? .connect() .await .context("could not connect to the database") @@ -437,7 +453,8 @@ pub async fn database_connection_from_config_with_options( config: &DatabaseConfig, options: &DatabaseConnectOptions, ) -> Result { - database_connect_options_from_config(config, options)? + database_connect_options_from_config(config, options) + .await? .connect() .await .context("could not connect to the database") diff --git a/crates/config/src/sections/database.rs b/crates/config/src/sections/database.rs index f5cfffb05..9820953ee 100644 --- a/crates/config/src/sections/database.rs +++ b/crates/config/src/sections/database.rs @@ -47,6 +47,7 @@ impl Default for DatabaseConfig { socket: None, username: None, password: None, + password_file: None, database: None, ssl_mode: None, ssl_ca: None, @@ -137,6 +138,15 @@ pub struct DatabaseConfig { #[serde(skip_serializing_if = "Option::is_none")] pub password: Option, + /// Path to the password to be used if the server demands password + /// authentication + /// + /// This must not be specified if the `password` or `uri` option is + /// specified. + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(with = "Option")] + pub password_file: Option, + /// The database name /// /// This must not be specified if `uri` is specified. @@ -242,14 +252,22 @@ impl ConfigurationSection for DatabaseConfig { || self.socket.is_some() || self.username.is_some() || self.password.is_some() + || self.password_file.is_some() || self.database.is_some(); if self.uri.is_some() && has_split_options { return Err(annotate(figment::error::Error::from( - "uri must not be specified if host, port, socket, username, password, or database are specified".to_owned(), + "uri must not be specified if host, port, socket, username, password, password_file, or database are specified".to_owned(), )).into()); } + if self.password.is_some() && self.password_file.is_some() { + return Err(annotate(figment::error::Error::from( + "password must not be specified if password_file is specified".to_owned(), + )) + .into()); + } + if self.ssl_ca.is_some() && self.ssl_ca_file.is_some() { return Err(annotate(figment::error::Error::from( "ssl_ca must not be specified if ssl_ca_file is specified".to_owned(), diff --git a/crates/config/src/sections/email.rs b/crates/config/src/sections/email.rs index 746e4d66b..17e5b9ded 100644 --- a/crates/config/src/sections/email.rs +++ b/crates/config/src/sections/email.rs @@ -8,6 +8,7 @@ use std::{num::NonZeroU16, str::FromStr}; +use camino::Utf8PathBuf; use lettre::message::Mailbox; use schemars::JsonSchema; use serde::{Deserialize, Serialize, de::Error}; @@ -86,17 +87,25 @@ pub struct EmailConfig { /// SMTP transport: Username for use to authenticate when connecting to the /// SMTP server /// - /// Must be set if the `password` field is set + /// Must be set if the `password` or `password_file` field is set #[serde(skip_serializing_if = "Option::is_none")] username: Option, /// SMTP transport: Password for use to authenticate when connecting to the /// SMTP server /// - /// Must be set if the `username` field is set + /// Must be set if the `username` but not `password_file` field is set #[serde(skip_serializing_if = "Option::is_none")] password: Option, + /// SMTP transport: Path to the password for use to authenticate when + /// connecting to the SMTP server + /// + /// Must be set if the `username` but not `password` field is set + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(with = "Option")] + password_file: Option, + /// Sendmail transport: Command to use to send emails #[serde(skip_serializing_if = "Option::is_none")] #[schemars(default = "default_sendmail_command")] @@ -135,9 +144,21 @@ impl EmailConfig { } /// Password for use to authenticate when connecting to the SMTP server - #[must_use] - pub fn password(&self) -> Option<&str> { - self.password.as_deref() + /// + /// # Errors + /// + /// Returns an error if the password is a file and it could not be read. + pub async fn password(&self) -> Result, anyhow::Error> { + if let Some(password_file) = &self.password_file { + return Ok(Some( + tokio::fs::read_to_string(password_file) + .await? + .trim() + .to_owned(), + )); + } + + Ok(self.password.clone()) } /// Command to use to send emails @@ -158,6 +179,7 @@ impl Default for EmailConfig { port: None, username: None, password: None, + password_file: None, command: None, } } @@ -183,6 +205,10 @@ impl ConfigurationSection for EmailConfig { error_on_field(figment::error::Error::missing_field(field), field) }; + let duplicate_field = |field: &'static str, duplicate: &'static str| { + error_on_field(figment::error::Error::duplicate_field(duplicate), field) + }; + let unexpected_field = |field: &'static str, expected_fields: &'static [&'static str]| { error_on_field( figment::error::Error::unknown_field(field, expected_fields), @@ -202,7 +228,10 @@ impl ConfigurationSection for EmailConfig { return Err(error_on_field(figment::error::Error::custom(e), "reply_to").into()); } - match (self.username.is_some(), self.password.is_some()) { + match ( + self.username.is_some(), + self.password.is_some() || self.password_file.is_some(), + ) { (true, true) | (false, false) => {} (true, false) => { return Err(missing_field("password").into()); @@ -212,6 +241,10 @@ impl ConfigurationSection for EmailConfig { } } + if self.password.is_some() && self.password_file.is_some() { + return Err(duplicate_field("password", "password_file").into()); + } + if self.mode.is_none() { return Err(missing_field("mode").into()); } diff --git a/docs/config.schema.json b/docs/config.schema.json index fa3cc3b54..b2aa4a649 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -1135,6 +1135,13 @@ "null" ] }, + "password_file": { + "description": "Path to the password to be used if the server demands password\nauthentication\n\nThis must not be specified if the `password` or `uri` option is\nspecified.", + "type": [ + "string", + "null" + ] + }, "database": { "description": "The database name\n\nThis must not be specified if `uri` is specified.", "type": [ @@ -1575,14 +1582,21 @@ "maximum": 65535 }, "username": { - "description": "SMTP transport: Username for use to authenticate when connecting to the\nSMTP server\n\nMust be set if the `password` field is set", + "description": "SMTP transport: Username for use to authenticate when connecting to the\nSMTP server\n\nMust be set if the `password` or `password_file` field is set", "type": [ "string", "null" ] }, "password": { - "description": "SMTP transport: Password for use to authenticate when connecting to the\nSMTP server\n\nMust be set if the `username` field is set", + "description": "SMTP transport: Password for use to authenticate when connecting to the\nSMTP server\n\nMust be set if the `username` but not `password_file` field is set", + "type": [ + "string", + "null" + ] + }, + "password_file": { + "description": "SMTP transport: Path to the password for use to authenticate when\nconnecting to the SMTP server\n\nMust be set if the `username` but not `password` field is set", "type": [ "string", "null"