diff --git a/Cargo.lock b/Cargo.lock index a02e81eb..caed3da9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1127,7 +1127,7 @@ dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", "core-graphics-types", - "foreign-types 0.5.0", + "foreign-types", "libc", ] @@ -1775,15 +1775,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared 0.1.1", -] - [[package]] name = "foreign-types" version = "0.5.0" @@ -1791,7 +1782,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared 0.3.1", + "foreign-types-shared", ] [[package]] @@ -1805,12 +1796,6 @@ dependencies = [ "syn", ] -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -2604,14 +2589,16 @@ dependencies = [ "httpdate", "idna", "mime", - "native-tls", "nom 8.0.0", "percent-encoding", "quoted_printable", + "rustls", + "rustls-native-certs", "socket2 0.6.4", "tokio", - "tokio-native-tls", + "tokio-rustls", "url", + "webpki-roots 1.0.8", ] [[package]] @@ -2818,23 +2805,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e94e1e6445d314f972ff7395df2de295fe51b71821694f0b0e1e79c4f12c8577" -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "ndk" version = "0.9.0" @@ -3316,49 +3286,12 @@ dependencies = [ "url", ] -[[package]] -name = "openssl" -version = "0.10.81" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" -dependencies = [ - "bitflags 2.13.0", - "cfg-if", - "foreign-types 0.3.2", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "opentelemetry" version = "0.32.0" @@ -4971,16 +4904,6 @@ dependencies = [ "syn", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" diff --git a/Cargo.toml b/Cargo.toml index 14811859..5bb20990 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,7 +79,14 @@ openidconnect = { version = "4.0.0", default-features = false, features = ["acce encoding_rs = "0.8.35" odbc-sys = { version = "0", optional = true } regex = "1" -lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-native-tls"] } +lettre = { version = "0.11", default-features = false, features = [ + "aws-lc-rs", + "builder", + "rustls-native-certs", + "smtp-transport", + "tokio1-rustls", + "webpki-roots", +] } # OpenTelemetry / tracing tracing = "0.1" diff --git a/configuration.md b/configuration.md index 037d3a8e..de62742d 100644 --- a/configuration.md +++ b/configuration.md @@ -41,9 +41,11 @@ Here are the available configuration options and their default values: | `environment` | development | The environment in which SQLPage is running. Can be either `development` or `production`. In `production` mode, SQLPage will hide error messages and stack traces from the user, and will cache sql files in memory to avoid reloading them from disk. | | `cache_stale_duration_ms` | 1000 (prod), 0 (dev) | The duration in milliseconds that a file can be cached before its freshness is checked against the filesystem. Defaults to 1000ms (1 second) in production and 0ms in development. | | `content_security_policy` | `script-src 'self' 'nonce-{NONCE}'` | The [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) to set in the HTTP headers. If you get CSP errors in the browser console, you can set this to the empty string to disable CSP. If you want a custom CSP that contains a nonce, include the `'nonce-{NONCE}'` directive in your configuration string and it will be populated with a random value per request. | -| `smtp_host` | | SMTP server used by the `sqlpage.send_mail` function. Accepts only a host name or `host:port`; if no port is provided, SQLPage uses port 25. Set with `SMTP_HOST` in the environment. | +| `smtp_host` | | SMTP server host used by the `sqlpage.send_mail` function. Set with `SMTP_HOST` in the environment. | +| `smtp_port` | 25 (`none`), 465 (`tls`), or 587 (`starttls`) | SMTP server port. The default depends on `smtp_tls_mode`. Set this explicitly for relays using a nonstandard port. | | `smtp_username` | | Optional SMTP user name for `sqlpage.send_mail`. When set, SQLPage authenticates to `SMTP_HOST` using this user name and `smtp_password`. Credentials require `smtp_tls_mode` to be `starttls` or `tls`. | -| `smtp_password` | | Optional SMTP password for `sqlpage.send_mail` when `smtp_username` is set. | +| `smtp_password` | | Optional SMTP password for `sqlpage.send_mail`. `smtp_username` and `smtp_password` must be configured together. | +| `smtp_from` | | Default sender address for `sqlpage.send_mail`, optionally including a display name. Individual messages can override it with their `from` property. | | `smtp_tls_mode` | `starttls` | Encryption mode for `sqlpage.send_mail`: `starttls` requires a STARTTLS upgrade, `tls` uses TLS from connection start, and `none` permits plaintext only without credentials for trusted local SMTP servers. | | `system_root_ca_certificates` | false | Whether to use the system root CA certificates to validate SSL certificates when making http requests with `sqlpage.fetch`. If set to false, SQLPage will use its own set of root CA certificates. If the `SSL_CERT_FILE` or `SSL_CERT_DIR` environment variables are set, they will be used instead of the system root CA certificates. | | `max_recursion_depth` | 10 | Maximum depth of recursion allowed in the `run_sql` function. Maximum value is 255. | diff --git a/examples/official-site/sqlpage/migrations/75_send_mail.sql b/examples/official-site/sqlpage/migrations/75_send_mail.sql index 16d22344..e038f250 100644 --- a/examples/official-site/sqlpage/migrations/75_send_mail.sql +++ b/examples/official-site/sqlpage/migrations/75_send_mail.sql @@ -2,8 +2,7 @@ INSERT INTO sqlpage_functions ( "name", "introduced_in_version", "icon", - "description_md", - "return_type" + "description_md" ) VALUES ( 'send_mail', @@ -11,7 +10,7 @@ VALUES ( 'mail', 'Sends an email using the SMTP server configured with `SMTP_HOST`. -`SMTP_HOST` must contain only a host name or `host:port`; URL schemes and paths are rejected. When no port is specified, SQLPage uses port 25. +`SMTP_HOST` contains the relay host name. Set `SMTP_PORT` when the relay does not use the default for the selected encryption mode: 587 for `starttls`, 465 for `tls`, or 25 for `none`. `SMTP_TLS_MODE` defaults to `starttls`, which requires a STARTTLS upgrade before sending email or credentials. Set it to `tls` for implicit TLS, commonly used on port 465. Plaintext mode (`none`) is allowed only without credentials and should be used only for trusted local SMTP servers. @@ -19,23 +18,23 @@ If your SMTP server requires authentication, configure `SMTP_USERNAME` and `SMTP The function accepts a single JSON object argument. The required properties are: -- `recipient`: email address to send to, optionally including a display name such as `"Jane Doe "`. +- `to`: email address to send to, optionally including a display name such as `"Jane Doe "`. - `subject`: email subject. - `body`: plain text email body. Optional properties: -- `sender`: sender address. Defaults to `SQLPage `. +- `from`: sender address. It may be omitted when `SMTP_FROM` configures a default sender. - `reply_to`: reply-to address. -After the SMTP server accepts the message, the function returns its JSON argument unchanged. It returns `NULL` when passed `NULL`, and raises an error if the message cannot be sent. +The function returns `NULL` after the SMTP relay accepts the message and raises an error if the message cannot be sent. The argument is required; passing `NULL` is an error. ### Example ```sql set message = json_object( - ''recipient'', ''admin@example.com'', - ''sender'', ''contact@example.com'', + ''to'', ''admin@example.com'', + ''from'', ''contact@example.com'', ''subject'', ''New contact form message'', ''body'', ''Hello from SQLPage!'' ); @@ -50,7 +49,7 @@ select ''email'' as name, ''email'' as type, true as required; select ''message'' as name, ''textarea'' as type, true as required; set mail = json_object( - ''recipient'', ''admin@example.com'', + ''to'', ''admin@example.com'', ''reply_to'', $email, ''subject'', ''Website contact form'', ''body'', $message @@ -58,8 +57,7 @@ set mail = json_object( select sqlpage.send_mail($mail) where $message is not null; ``` -', - 'JSON' +' ); INSERT INTO sqlpage_function_parameters ( @@ -73,6 +71,6 @@ VALUES ( 'send_mail', 1, 'message', - 'A JSON object containing the email to send. Required properties are `recipient`, `subject`, and `body`. Optional properties are `sender` and `reply_to`.', + 'A JSON object containing the email to send. Required properties are `to`, `subject`, and `body`. Optional properties are `from` (required unless `SMTP_FROM` is configured) and `reply_to`. Unknown properties are rejected to catch misspellings.', 'JSON' ); diff --git a/examples/sending emails/README.md b/examples/sending emails/README.md index 9852bbe1..71a9d896 100644 --- a/examples/sending emails/README.md +++ b/examples/sending emails/README.md @@ -10,7 +10,7 @@ docker compose up Open http://localhost:8080 to send an email, then inspect it in the Mailpit inbox at http://localhost:8025. -The SMTP server is configured in [`docker-compose.yml`](./docker-compose.yml) with `SMTP_HOST=mailpit:1025` and `SMTP_TLS_MODE=none`. Plaintext mode is intended only for trusted local SMTP servers such as Mailpit. +The SMTP server is configured in [`docker-compose.yml`](./docker-compose.yml) with `SMTP_HOST=mailpit`, `SMTP_PORT=1025`, and `SMTP_TLS_MODE=none`. Plaintext mode is intended only for trusted local SMTP servers such as Mailpit. For a remote SMTP relay, keep the default `SMTP_TLS_MODE=starttls`, or set it to `tls` when the relay requires implicit TLS. Configure `SMTP_USERNAME` and `SMTP_PASSWORD` when authentication is required; SQLPage rejects credentials in plaintext mode. @@ -18,14 +18,14 @@ The form handler sends the message with a single function call: ```sql set message = json_object( - 'recipient', :recipient, - 'sender', :sender, + 'to', :recipient, + 'from', :sender, 'subject', :subject, 'body', :body ); -set sent_message = sqlpage.send_mail($message); +set _ = sqlpage.send_mail($message); ``` -After the SMTP server accepts the email, `sqlpage.send_mail` returns the message JSON unchanged. It raises an error when delivery to the SMTP server fails. +`sqlpage.send_mail` returns `NULL` after the SMTP relay accepts the message. It raises an error when the relay rejects the message or cannot be reached, so statements after the call run only on success. Do not expose an unrestricted form like this publicly. In production, authenticate users, restrict recipients, validate input, and add rate limiting to prevent abuse. diff --git a/examples/sending emails/docker-compose.yml b/examples/sending emails/docker-compose.yml index 7ee16412..b1709098 100644 --- a/examples/sending emails/docker-compose.yml +++ b/examples/sending emails/docker-compose.yml @@ -4,7 +4,8 @@ services: ports: - "8080:8080" environment: - SMTP_HOST: mailpit:1025 + SMTP_HOST: mailpit + SMTP_PORT: 1025 SMTP_TLS_MODE: none volumes: - .:/var/www diff --git a/examples/sending emails/email.sql b/examples/sending emails/email.sql index beaef452..04e24693 100644 --- a/examples/sending emails/email.sql +++ b/examples/sending emails/email.sql @@ -1,6 +1,6 @@ set message = json_object( - 'recipient', :recipient, - 'sender', :sender, + 'to', :recipient, + 'from', :sender, 'subject', :subject, 'body', :body ); @@ -9,8 +9,7 @@ set sent_message = sqlpage.send_mail($message); select 'alert' as component, 'success' as color, - 'Email sent successfully' as title -where $sent_message is not null; + 'Email sent successfully' as title; select 'button' as component; select 'Send another email' as title, 'index.sql' as link; diff --git a/src/app_config.rs b/src/app_config.rs index 6938a6a7..50e02192 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -8,7 +8,7 @@ use openidconnect::IssuerUrl; use percent_encoding::AsciiSet; use serde::de::Error; use serde::{Deserialize, Deserializer, Serialize}; -use std::net::{SocketAddr, ToSocketAddrs}; +use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::time::Duration; @@ -137,6 +137,27 @@ impl AppConfig { if let Some(smtp_host) = &self.smtp_host { validate_smtp_host(smtp_host)?; } + anyhow::ensure!( + self.smtp_host.is_some() + || (self.smtp_port.is_none() + && self.smtp_username.is_none() + && self.smtp_password.is_none() + && self.smtp_from.is_none()), + "smtp_host is required when other SMTP options are configured" + ); + anyhow::ensure!( + self.smtp_port != Some(0), + "smtp_port must be between 1 and 65535" + ); + anyhow::ensure!( + self.smtp_username.is_some() == self.smtp_password.is_some(), + "smtp_username and smtp_password must be configured together" + ); + if let Some(smtp_from) = &self.smtp_from { + smtp_from + .parse::() + .context("smtp_from is not a valid email address")?; + } anyhow::ensure!( self.smtp_username.is_none() || self.smtp_tls_mode != SmtpTlsMode::None, "SMTP credentials require smtp_tls_mode to be 'starttls' or 'tls'" @@ -230,9 +251,11 @@ pub struct AppConfig { pub allow_exec: bool, /// SMTP server host used by the `sqlpage.send_mail` function. - /// Accepts either a bare host name or `host:port`. Defaults to port 25 when no port is specified. pub smtp_host: Option, + /// SMTP server port. Defaults to 25 for plaintext, 465 for implicit TLS, and 587 for STARTTLS. + pub smtp_port: Option, + /// Optional SMTP user name used by the `sqlpage.send_mail` function. /// If set, `SQLPage` authenticates to `SMTP_HOST` with this user name and `smtp_password`. pub smtp_username: Option, @@ -240,6 +263,9 @@ pub struct AppConfig { /// Optional SMTP password used by the `sqlpage.send_mail` function when `smtp_username` is set. pub smtp_password: Option, + /// Default sender used by the `sqlpage.send_mail` function when the message has no `from` property. + pub smtp_from: Option, + /// Encryption mode used to connect to `SMTP_HOST`. #[serde(default)] pub smtp_tls_mode: SmtpTlsMode, @@ -554,22 +580,16 @@ fn default_site_prefix() -> String { '/'.to_string() } -pub(crate) fn parse_smtp_host(smtp_host: &str) -> anyhow::Result<(&str, u16)> { - let (host, port) = smtp_host - .rsplit_once(':') - .map_or((smtp_host, 25), |(host, port)| { - (host, port.parse::().unwrap_or(0)) - }); +fn validate_smtp_host(host: &str) -> anyhow::Result<()> { anyhow::ensure!( - !host.is_empty() && !host.contains('/') && !host.contains(':'), - "SMTP_HOST must be a host name or host:port, without a URL scheme or path" + !host.trim().is_empty() + && host.trim() == host + && !host.contains('/') + && !host.contains("://") + && (!host.contains(':') || host.parse::().is_ok()), + "smtp_host must be a host name or IP address, without a URL scheme, path, or surrounding whitespace" ); - anyhow::ensure!(port > 0, "SMTP_HOST port must be between 1 and 65535"); - Ok((host, port)) -} - -fn validate_smtp_host(smtp_host: &str) -> anyhow::Result<()> { - parse_smtp_host(smtp_host).map(|_| ()) + Ok(()) } fn parse_socket_addr(host_str: &str) -> anyhow::Result { @@ -730,6 +750,15 @@ pub enum SmtpTlsMode { Starttls, Tls, } +impl SmtpTlsMode { + pub(crate) const fn default_port(self) -> u16 { + match self { + Self::None => 25, + Self::Starttls => 587, + Self::Tls => 465, + } + } +} impl DevOrProd { pub(crate) fn is_prod(self) -> bool { self == DevOrProd::Production @@ -802,13 +831,25 @@ mod test { #[test] fn smtp_credentials_require_tls() { let mut config = tests::test_config(); + config.smtp_host = Some("smtp.example.com".to_string()); config.smtp_username = Some("user".to_string()); + config.smtp_password = Some("secret".to_string()); config.smtp_tls_mode = SmtpTlsMode::None; let error = config.validate().unwrap_err().to_string(); assert!(error.contains("SMTP credentials require smtp_tls_mode")); } + #[test] + fn smtp_credentials_must_be_configured_together() { + let mut config = tests::test_config(); + config.smtp_host = Some("smtp.example.com".to_string()); + config.smtp_username = Some("user".to_string()); + + let error = config.validate().unwrap_err().to_string(); + assert!(error.contains("smtp_username and smtp_password")); + } + #[test] fn test_encode_uri() { assert_eq!( diff --git a/src/webserver/database/sqlpage_functions/function_traits.rs b/src/webserver/database/sqlpage_functions/function_traits.rs index fc3c282d..7a18a9b9 100644 --- a/src/webserver/database/sqlpage_functions/function_traits.rs +++ b/src/webserver/database/sqlpage_functions/function_traits.rs @@ -210,6 +210,12 @@ impl<'a, 'b: 'a> IntoCow<'a> for &'b str { } } +impl<'a> IntoCow<'a> for () { + fn into_cow(self) -> Option> { + None + } +} + impl<'a, T: IntoCow<'a>> IntoCow<'a> for Option { fn into_cow(self) -> Option> { self.and_then(IntoCow::into_cow) diff --git a/src/webserver/database/sqlpage_functions/functions/send_mail.rs b/src/webserver/database/sqlpage_functions/functions/send_mail.rs index 137c8987..f0f306fa 100644 --- a/src/webserver/database/sqlpage_functions/functions/send_mail.rs +++ b/src/webserver/database/sqlpage_functions/functions/send_mail.rs @@ -6,70 +6,65 @@ use lettre::{ message::{Mailbox, header::ContentType}, transport::smtp::{ authentication::Credentials, - client::{Tls, TlsParameters}, + client::{Certificate, CertificateStore, Tls, TlsParameters}, }, }; use serde::Deserialize; use crate::{ - app_config::{AppConfig, SmtpTlsMode, parse_smtp_host}, - webserver::http_request_info::RequestInfo, + app_config::{AppConfig, SmtpTlsMode}, + webserver::{http_client::native_certificate_der, http_request_info::RequestInfo}, }; #[derive(Deserialize)] +#[serde(deny_unknown_fields)] struct MailRequest<'a> { #[serde(borrow)] - recipient: Cow<'a, str>, + to: Cow<'a, str>, #[serde(borrow)] subject: Cow<'a, str>, #[serde(borrow)] body: Cow<'a, str>, - #[serde(borrow, default)] - sender: Option>, + #[serde(borrow, default, rename = "from")] + from: Option>, #[serde(borrow, default)] reply_to: Option>, } -/// Sends an email through the SMTP server configured with `SMTP_HOST`. -pub(super) async fn send_mail<'a>( +/// Sends an email through the configured SMTP relay. +pub(super) async fn send_mail( request: &RequestInfo, - mail_request: Option>, -) -> anyhow::Result>> { - send_mail_with_config(&request.app_state.config, mail_request).await + mail_request: Cow<'_, str>, +) -> anyhow::Result<()> { + send_mail_with_config(&request.app_state.config, &mail_request).await } -async fn send_mail_with_config<'a>( - config: &AppConfig, - mail_request: Option>, -) -> anyhow::Result>> { - let Some(mail_request) = mail_request else { - return Ok(None); - }; - let smtp_host = config +async fn send_mail_with_config(config: &AppConfig, mail_request: &str) -> anyhow::Result<()> { + let host = config .smtp_host .as_deref() - .context("The sqlpage.send_mail() function requires the SMTP_HOST configuration option")?; - let (host, port) = parse_smtp_host(smtp_host)?; - let parsed_mail_request: MailRequest<'_> = serde_json::from_str(&mail_request) - .context("sqlpage.send_mail() expects a JSON object argument")?; + .context("sqlpage.send_mail() requires the smtp_host configuration option")?; + let parsed: MailRequest<'_> = serde_json::from_str(mail_request) + .context("sqlpage.send_mail() expects a JSON object")?; - let sender = parsed_mail_request - .sender + let sender = parsed + .from .as_deref() - .unwrap_or("SQLPage ") + .or(config.smtp_from.as_deref()) + .context("Email has no from address; set its from property or configure smtp_from")? .parse::() - .context("Invalid sender email address")?; - let recipient = parsed_mail_request - .recipient + .context("Invalid from email address")?; + let recipient = parsed + .to .parse::() - .context("Invalid recipient email address")?; + .context("Invalid to email address")?; let mut email = Message::builder() .from(sender) .to(recipient) - .subject(parsed_mail_request.subject.as_ref()) + .subject(parsed.subject.as_ref()) .header(ContentType::TEXT_PLAIN); - if let Some(reply_to) = parsed_mail_request.reply_to { + if let Some(reply_to) = parsed.reply_to { email = email.reply_to( reply_to .parse::() @@ -77,39 +72,59 @@ async fn send_mail_with_config<'a>( ); } let email = email - .body(parsed_mail_request.body.into_owned()) + .body(parsed.body.into_owned()) .context("Unable to build email message")?; - let tls = match config.smtp_tls_mode { - SmtpTlsMode::None => Tls::None, - SmtpTlsMode::Starttls => Tls::Required( - TlsParameters::new(host.to_string()).context("Invalid SMTP TLS server name")?, - ), - SmtpTlsMode::Tls => Tls::Wrapper( - TlsParameters::new(host.to_string()).context("Invalid SMTP TLS server name")?, - ), - }; - let mut mailer_builder = AsyncSmtpTransport::::builder_dangerous(host) + let tls = smtp_tls(config, host)?; + let port = config + .smtp_port + .unwrap_or_else(|| config.smtp_tls_mode.default_port()); + let mut mailer = AsyncSmtpTransport::::builder_dangerous(host) .port(port) .tls(tls); - if let Some(username) = &config.smtp_username { - mailer_builder = mailer_builder.credentials(Credentials::new( - username.clone(), - config.smtp_password.clone().unwrap_or_default(), - )); + if let (Some(username), Some(password)) = (&config.smtp_username, &config.smtp_password) { + mailer = mailer.credentials(Credentials::new(username.clone(), password.clone())); } - let mailer = mailer_builder.build(); - mailer + + let response = mailer + .build() .send(email) .await - .with_context(|| format!("Unable to send email through {smtp_host}"))?; - Ok(Some(mail_request)) + .with_context(|| format!("Unable to send email through {host}:{port}"))?; + log::debug!("SMTP relay accepted email: {response:?}"); + Ok(()) +} + +fn smtp_tls(config: &AppConfig, host: &str) -> anyhow::Result { + if config.smtp_tls_mode == SmtpTlsMode::None { + return Ok(Tls::None); + } + + let mut parameters = TlsParameters::builder(host.to_string()); + if config.system_root_ca_certificates { + parameters = parameters.certificate_store(CertificateStore::None); + for certificate in native_certificate_der()? { + parameters = parameters.add_root_certificate( + Certificate::from_der(certificate.as_ref().to_vec()) + .context("Unable to configure an SMTP root certificate")?, + ); + } + } else { + parameters = parameters.certificate_store(CertificateStore::WebpkiRoots); + } + let parameters = parameters + .build_rustls() + .context("Unable to configure SMTP TLS")?; + Ok(match config.smtp_tls_mode { + SmtpTlsMode::Starttls => Tls::Required(parameters), + SmtpTlsMode::Tls => Tls::Wrapper(parameters), + SmtpTlsMode::None => unreachable!(), + }) } #[cfg(test)] mod tests { use std::{ - borrow::Cow, io::{BufRead, BufReader, Write}, net::{TcpListener, TcpStream}, sync::mpsc, @@ -120,72 +135,63 @@ mod tests { use crate::app_config::tests::test_config; #[tokio::test] - async fn send_mail_authenticates_to_plaintext_smtp_server_when_explicitly_enabled() { - let (host, received) = start_authenticated_smtp_server("user", "secret"); + async fn sends_plain_text_email_to_configured_relay() { + let (host, port, received) = start_smtp_server(); let mut config = test_config(); config.smtp_host = Some(host); - config.smtp_username = Some("user".to_string()); - config.smtp_password = Some("secret".to_string()); + config.smtp_port = Some(port); config.smtp_tls_mode = SmtpTlsMode::None; - let mail_request = Cow::Borrowed( - r#"{ - "recipient": "admin@example.com", - "sender": "contact@example.com", - "subject": "Authenticated SMTP", - "body": "hello authenticated smtp" - }"#, - ); - let result = send_mail_with_config( + send_mail_with_config( &config, - Some(mail_request.clone()), + r#"{ + "to": "admin@example.com", + "from": "contact@example.com", + "subject": "SMTP test", + "body": "hello smtp" + }"#, ) .await .unwrap(); - assert_eq!(result, Some(mail_request)); - let smtp_session = received.recv().unwrap(); - assert!(smtp_session.authenticated, "SMTP AUTH was not used"); - assert!(smtp_session.data.contains("Authenticated SMTP")); - assert!(smtp_session.data.contains("hello authenticated smtp")); + let data = received.recv().unwrap(); + assert!(data.contains("Subject: SMTP test")); + assert!(data.contains("hello smtp")); } - struct SmtpSession { - authenticated: bool, - data: String, + #[tokio::test] + async fn rejects_unknown_message_fields() { + let mut config = test_config(); + config.smtp_host = Some("localhost".to_string()); + let error = send_mail_with_config( + &config, + r#"{"recipient":"admin@example.com","subject":"test","body":"hello"}"#, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("expects a JSON object")); } - fn start_authenticated_smtp_server(username: &str, password: &str) -> (String, mpsc::Receiver) { + fn start_smtp_server() -> (String, u16, mpsc::Receiver) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); - let username = username.to_string(); - let password = password.to_string(); let (sender, receiver) = mpsc::channel(); thread::spawn(move || { let (stream, _) = listener.accept().unwrap(); - let session = handle_smtp_connection(stream, &username, &password); - sender.send(session).unwrap(); + sender.send(handle_smtp_connection(stream)).unwrap(); }); - (address.to_string(), receiver) + (address.ip().to_string(), address.port(), receiver) } - fn handle_smtp_connection(mut stream: TcpStream, username: &str, password: &str) -> SmtpSession { + fn handle_smtp_connection(mut stream: TcpStream) -> String { let mut reader = BufReader::new(stream.try_clone().unwrap()); write_response(&mut stream, "220 localhost ESMTP test server"); - let mut authenticated = false; let mut data = String::new(); loop { let line = read_line(&mut reader); let command = line.trim_end_matches(['\r', '\n']); if command.starts_with("EHLO") || command.starts_with("HELO") { - write!( - stream, - "250-localhost\r\n250-AUTH PLAIN LOGIN\r\n250 OK\r\n" - ) - .unwrap(); - } else if let Some(auth) = command.strip_prefix("AUTH PLAIN ") { - authenticated = auth == expected_plain_auth(username, password); - write_response(&mut stream, if authenticated { "235 Authentication successful" } else { "535 Authentication failed" }); + write_response(&mut stream, "250 localhost"); } else if command == "DATA" { write_response(&mut stream, "354 End data with ."); loop { @@ -199,13 +205,11 @@ mod tests { } else if command == "QUIT" { write_response(&mut stream, "221 Bye"); break; - } else if authenticated && (command.starts_with("MAIL FROM") || command.starts_with("RCPT TO")) { - write_response(&mut stream, "250 OK"); } else { - write_response(&mut stream, "530 Authentication required"); + write_response(&mut stream, "250 OK"); } } - SmtpSession { authenticated, data } + data } fn read_line(reader: &mut BufReader) -> String { @@ -217,9 +221,4 @@ mod tests { fn write_response(stream: &mut TcpStream, response: &str) { write!(stream, "{response}\r\n").unwrap(); } - - fn expected_plain_auth(username: &str, password: &str) -> String { - use base64::{Engine as _, engine::general_purpose::STANDARD}; - STANDARD.encode(format!("\0{username}\0{password}")) - } } diff --git a/src/webserver/http_client.rs b/src/webserver/http_client.rs index e935ad46..1f934b70 100644 --- a/src/webserver/http_client.rs +++ b/src/webserver/http_client.rs @@ -3,7 +3,12 @@ use anyhow::{Context, anyhow}; use rustls_native_certs::CertificateResult; use std::sync::OnceLock; -static NATIVE_CERTS: OnceLock> = OnceLock::new(); +struct NativeCertificates { + certificates: Vec>, + root_store: rustls::RootCertStore, +} + +static NATIVE_CERTIFICATES: OnceLock> = OnceLock::new(); pub fn make_http_client(config: &crate::app_config::AppConfig) -> anyhow::Result { make_http_client_with_system_roots(config.system_root_ca_certificates) @@ -14,29 +19,51 @@ pub(crate) fn default_system_root_ca_certificates_from_env() -> bool { || std::env::var("SSL_CERT_DIR").is_ok_and(|value| !value.is_empty()) } +fn native_certificates() -> anyhow::Result<&'static NativeCertificates> { + NATIVE_CERTIFICATES + .get_or_init(|| { + log::debug!( + "Loading native certificates because system_root_ca_certificates is enabled" + ); + let CertificateResult { + certs, + errors, + .. + } = rustls_native_certs::load_native_certs(); + log::debug!("Loaded {} native TLS client certificates", certs.len()); + for error in errors { + log::error!("Unable to load native certificate: {error}"); + } + let mut root_store = rustls::RootCertStore::empty(); + for cert in &certs { + log::trace!("Adding native certificate to root store: {cert:?}"); + root_store.add(cert.clone()).with_context(|| { + format!("Unable to add certificate to root store: {cert:?}") + })?; + } + Ok(NativeCertificates { + certificates: certs, + root_store, + }) + }) + .as_ref() + .map_err(|error| { + anyhow!( + "Unable to load native certificates, make sure the system root CA certificates are available: {error}" + ) + }) +} + +pub(crate) fn native_certificate_der() +-> anyhow::Result<&'static [rustls::pki_types::CertificateDer<'static>]> { + Ok(&native_certificates()?.certificates) +} + pub(crate) fn make_http_client_with_system_roots( system_root_ca_certificates: bool, ) -> anyhow::Result { let connector = if system_root_ca_certificates { - let roots = NATIVE_CERTS - .get_or_init(|| { - log::debug!("Loading native certificates because system_root_ca_certificates is enabled"); - let CertificateResult { certs, errors, .. } = rustls_native_certs::load_native_certs(); - log::debug!("Loaded {} native HTTPS client certificates", certs.len()); - for error in errors { - log::error!("Unable to load native certificate: {error}"); - } - let mut roots = rustls::RootCertStore::empty(); - for cert in certs { - log::trace!("Adding native certificate to root store: {cert:?}"); - roots.add(cert.clone()).with_context(|| { - format!("Unable to add certificate to root store: {cert:?}") - })?; - } - Ok(roots) - }) - .as_ref() - .map_err(|e| anyhow!("Unable to load native certificates, make sure the system root CA certificates are available: {e}"))?; + let roots = &native_certificates()?.root_store; log::trace!( "Creating HTTP client with custom TLS connector using native certificates. SSL_CERT_FILE={:?}, SSL_CERT_DIR={:?}",