From 72e695ec4af7f9261b37876c4673878938573654 Mon Sep 17 00:00:00 2001 From: Ophir LOJKINE Date: Fri, 10 Jul 2026 15:54:15 +0200 Subject: [PATCH 1/4] Add sqlpage.send_mail function and STMP_HOST configuration ### Motivation - Provide a built-in `sqlpage.send_mail(...)` SQL function so pages can send plain-text emails from SQL code. - Allow the SMTP server to be configured via an environment / configuration option so the function can target a deployable SMTP endpoint. ### Description - Added a new function implementation at `src/webserver/database/sqlpage_functions/functions/send_mail.rs` implementing `sqlpage.send_mail(json)` which accepts a JSON object with required `recipient`, `subject`, and `body` and optional `sender` and `reply_to`, sends the message and returns `sent` on success. - Registered the function in the SQLPage function registry by adding `send_mail` to `src/webserver/database/sqlpage_functions/functions.rs`. - Added a configuration option `stmp_host: Option` to `AppConfig` in `src/app_config.rs`, with `parse_stmp_host`/`validate_stmp_host` helpers that accept either `host` or `host:port` and default to port 25 when none is provided; validation is run from `AppConfig::validate`. - Added `lettre` to `Cargo.toml` and updated `Cargo.lock` to enable SMTP sending, and added official-site documentation and a migration at `examples/official-site/sqlpage/migrations/75_send_mail.sql` describing usage and parameters. - Documented the `stmp_host` option in `configuration.md`. ### Testing - Ran `cargo fmt --all`, which completed successfully. - Ran `git diff --check` which reported no immediate style errors. - Attempted `cargo clippy --all-targets --all-features -- -D warnings`, but it was blocked by a toolchain/build issue (a dependency `libsqlite3-sys` build script uses the unstable `cfg_select` feature) and did not complete. - Attempted `cargo test`, but it was similarly blocked by the same `libsqlite3-sys` build-script error and did not complete. --- Cargo.lock | 149 ++++++++++++- Cargo.toml | 1 + configuration.md | 3 + .../sqlpage/migrations/75_send_mail.sql | 74 +++++++ src/app_config.rs | 33 +++ .../database/sqlpage_functions/functions.rs | 1 + .../sqlpage_functions/functions/send_mail.rs | 209 ++++++++++++++++++ 7 files changed, 464 insertions(+), 6 deletions(-) create mode 100644 examples/official-site/sqlpage/migrations/75_send_mail.sql create mode 100644 src/webserver/database/sqlpage_functions/functions/send_mail.rs diff --git a/Cargo.lock b/Cargo.lock index b852248f..a02e81eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -435,7 +435,7 @@ dependencies = [ "asn1-rs-derive", "asn1-rs-impl", "displaydoc", - "nom", + "nom 7.1.3", "num-traits", "rusticata-macros", "thiserror 1.0.69", @@ -1127,7 +1127,7 @@ dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1403,7 +1403,7 @@ checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" dependencies = [ "asn1-rs", "displaydoc", - "nom", + "nom 7.1.3", "num-bigint", "num-traits", "rusticata-macros", @@ -1641,6 +1641,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64 0.22.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" @@ -1759,6 +1775,15 @@ 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" @@ -1766,7 +1791,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1780,6 +1805,12 @@ 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" @@ -2557,6 +2588,32 @@ dependencies = [ "spin", ] +[[package]] +name = "lettre" +version = "0.11.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" +dependencies = [ + "async-trait", + "base64 0.22.1", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "httpdate", + "idna", + "mime", + "native-tls", + "nom 8.0.0", + "percent-encoding", + "quoted_printable", + "socket2 0.6.4", + "tokio", + "tokio-native-tls", + "url", +] + [[package]] name = "libc" version = "0.2.186" @@ -2761,6 +2818,23 @@ 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" @@ -2810,6 +2884,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3233,12 +3316,49 @@ 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" @@ -3659,6 +3779,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" @@ -3948,7 +4074,7 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -4482,6 +4608,7 @@ dependencies = [ "hmac 0.13.0", "include_dir", "lambda-web", + "lettre", "libflate", "log", "markdown", @@ -4844,6 +4971,16 @@ 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" @@ -5576,7 +5713,7 @@ dependencies = [ "data-encoding", "der-parser", "lazy_static", - "nom", + "nom 7.1.3", "oid-registry", "rusticata-macros", "thiserror 1.0.69", diff --git a/Cargo.toml b/Cargo.toml index bbda6de8..14811859 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,7 @@ 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"] } # OpenTelemetry / tracing tracing = "0.1" diff --git a/configuration.md b/configuration.md index eddc2ca9..d2ce201b 100644 --- a/configuration.md +++ b/configuration.md @@ -41,6 +41,9 @@ 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. | +| `stmp_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 `STMP_HOST` in the environment. | +| `stmp_username` | | Optional SMTP user name for `sqlpage.send_mail`. When set, SQLPage authenticates to `STMP_HOST` using this user name and `stmp_password`. | +| `stmp_password` | | Optional SMTP password for `sqlpage.send_mail` when `stmp_username` is set. | | `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. | | `markdown_allow_dangerous_html` | false | Whether to allow raw HTML in markdown content. Only enable this if the markdown content is fully trusted (not user generated). | diff --git a/examples/official-site/sqlpage/migrations/75_send_mail.sql b/examples/official-site/sqlpage/migrations/75_send_mail.sql new file mode 100644 index 00000000..a3c60945 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/75_send_mail.sql @@ -0,0 +1,74 @@ +INSERT INTO sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md", + "return_type" + ) +VALUES ( + 'send_mail', + '0.45.0', + 'mail', + 'Sends an email using the SMTP server configured with `STMP_HOST`. + +`STMP_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. + +If your SMTP server requires authentication, configure `STMP_USERNAME` and `STMP_PASSWORD` as well. + +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 "`. +- `subject`: email subject. +- `body`: plain text email body. + +Optional properties: + +- `sender`: sender address. Defaults to `SQLPage `. +- `reply_to`: reply-to address. + +The function returns `sent` after the SMTP server accepts the message, and `NULL` when passed `NULL`. + +### Example + +```sql +select sqlpage.send_mail(json_object( + ''recipient'', ''admin@example.com'', + ''sender'', ''contact@example.com'', + ''subject'', ''New contact form message'', + ''body'', ''Hello from SQLPage!'' +)); +``` + +### Contact form example + +```sql +select ''form'' as component, ''post'' as method; +select ''email'' as name, ''email'' as type, true as required; +select ''message'' as name, ''textarea'' as type, true as required; + +select sqlpage.send_mail(json_object( + ''recipient'', ''admin@example.com'', + ''reply_to'', $email, + ''subject'', ''Website contact form'', + ''body'', $message +)) +where $message is not null; +``` +', + 'TEXT' + ); + +INSERT INTO sqlpage_function_parameters ( + "function", + "index", + "name", + "description_md", + "type" + ) +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`.', + 'JSON' + ); diff --git a/src/app_config.rs b/src/app_config.rs index 42760e78..7b597765 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -134,6 +134,10 @@ impl AppConfig { } anyhow::ensure!(self.max_pending_rows > 0, "max_pending_rows cannot be null"); + if let Some(stmp_host) = &self.stmp_host { + validate_stmp_host(stmp_host)?; + } + for path in &self.oidc_protected_paths { if !path.starts_with('/') { return Err(anyhow::anyhow!( @@ -221,6 +225,17 @@ pub struct AppConfig { #[serde(default)] 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 stmp_host: Option, + + /// Optional SMTP user name used by the `sqlpage.send_mail` function. + /// If set, `SQLPage` authenticates to `STMP_HOST` with this user name and `stmp_password`. + pub stmp_username: Option, + + /// Optional SMTP password used by the `sqlpage.send_mail` function when `stmp_username` is set. + pub stmp_password: Option, + /// Maximum size of uploaded files in bytes. The default is 10MiB (10 * 1024 * 1024 bytes) #[serde(default = "default_max_file_size")] pub max_uploaded_file_size: usize, @@ -531,6 +546,24 @@ fn default_site_prefix() -> String { '/'.to_string() } +pub(crate) fn parse_stmp_host(stmp_host: &str) -> anyhow::Result<(&str, u16)> { + let (host, port) = stmp_host + .rsplit_once(':') + .map_or((stmp_host, 25), |(host, port)| { + (host, port.parse::().unwrap_or(0)) + }); + anyhow::ensure!( + !host.is_empty() && !host.contains('/') && !host.contains(':'), + "STMP_HOST must be a host name or host:port, without a URL scheme or path" + ); + anyhow::ensure!(port > 0, "STMP_HOST port must be between 1 and 65535"); + Ok((host, port)) +} + +fn validate_stmp_host(stmp_host: &str) -> anyhow::Result<()> { + parse_stmp_host(stmp_host).map(|_| ()) +} + fn parse_socket_addr(host_str: &str) -> anyhow::Result { host_str .to_socket_addrs()? diff --git a/src/webserver/database/sqlpage_functions/functions.rs b/src/webserver/database/sqlpage_functions/functions.rs index e6709bd8..37cb7efe 100644 --- a/src/webserver/database/sqlpage_functions/functions.rs +++ b/src/webserver/database/sqlpage_functions/functions.rs @@ -38,6 +38,7 @@ sqlpage_functions! { request_body_base64, request_method, run_sql, + send_mail, set_variable, uploaded_file_mime_type, uploaded_file_name, diff --git a/src/webserver/database/sqlpage_functions/functions/send_mail.rs b/src/webserver/database/sqlpage_functions/functions/send_mail.rs new file mode 100644 index 00000000..1a6db705 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/send_mail.rs @@ -0,0 +1,209 @@ +use std::borrow::Cow; + +use anyhow::Context; +use lettre::{ + AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, + message::{Mailbox, header::ContentType}, + transport::smtp::authentication::Credentials, +}; +use serde::Deserialize; + +use crate::{ + app_config::{AppConfig, parse_stmp_host}, + webserver::http_request_info::RequestInfo, +}; + +#[derive(Deserialize)] +struct MailRequest<'a> { + #[serde(borrow)] + recipient: Cow<'a, str>, + #[serde(borrow)] + subject: Cow<'a, str>, + #[serde(borrow)] + body: Cow<'a, str>, + #[serde(borrow, default)] + sender: Option>, + #[serde(borrow, default)] + reply_to: Option>, +} + +/// Sends an email through the SMTP server configured with `STMP_HOST`. +pub(super) async fn send_mail( + request: &RequestInfo, + mail_request: Option>, +) -> anyhow::Result> { + send_mail_with_config(&request.app_state.config, mail_request).await +} + +async fn send_mail_with_config( + config: &AppConfig, + mail_request: Option>, +) -> anyhow::Result> { + let Some(mail_request) = mail_request else { + return Ok(None); + }; + let stmp_host = config + .stmp_host + .as_deref() + .context("The sqlpage.send_mail() function requires the STMP_HOST configuration option")?; + let (host, port) = parse_stmp_host(stmp_host)?; + let mail_request: MailRequest<'_> = serde_json::from_str(&mail_request) + .context("sqlpage.send_mail() expects a JSON object argument")?; + + let sender = mail_request + .sender + .as_deref() + .unwrap_or("SQLPage ") + .parse::() + .context("Invalid sender email address")?; + let recipient = mail_request + .recipient + .parse::() + .context("Invalid recipient email address")?; + + let mut email = Message::builder() + .from(sender) + .to(recipient) + .subject(mail_request.subject.as_ref()) + .header(ContentType::TEXT_PLAIN); + if let Some(reply_to) = mail_request.reply_to { + email = email.reply_to( + reply_to + .parse::() + .context("Invalid reply_to email address")?, + ); + } + let email = email + .body(mail_request.body.into_owned()) + .context("Unable to build email message")?; + + let mut mailer_builder = AsyncSmtpTransport::::builder_dangerous(host).port(port); + if let Some(username) = &config.stmp_username { + mailer_builder = mailer_builder.credentials(Credentials::new( + username.clone(), + config.stmp_password.clone().unwrap_or_default(), + )); + } + let mailer = mailer_builder.build(); + mailer + .send(email) + .await + .with_context(|| format!("Unable to send email through {stmp_host}"))?; + Ok(Some("sent")) +} + +#[cfg(test)] +mod tests { + use std::{ + borrow::Cow, + io::{BufRead, BufReader, Write}, + net::{TcpListener, TcpStream}, + sync::mpsc, + thread, + }; + + use super::send_mail_with_config; + use crate::app_config::tests::test_config; + + #[tokio::test] + async fn send_mail_authenticates_to_smtp_server() { + let (host, received) = start_authenticated_smtp_server("user", "secret"); + let mut config = test_config(); + config.stmp_host = Some(host); + config.stmp_username = Some("user".to_string()); + config.stmp_password = Some("secret".to_string()); + + let result = send_mail_with_config( + &config, + Some(Cow::Borrowed( + r#"{ + "recipient": "admin@example.com", + "sender": "contact@example.com", + "subject": "Authenticated SMTP", + "body": "hello authenticated smtp" + }"#, + )), + ) + .await + .unwrap(); + + assert_eq!(result, Some("sent")); + 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")); + } + + struct SmtpSession { + authenticated: bool, + data: String, + } + + fn start_authenticated_smtp_server(username: &str, password: &str) -> (String, 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(); + }); + (address.to_string(), receiver) + } + + fn handle_smtp_connection(mut stream: TcpStream, username: &str, password: &str) -> SmtpSession { + 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" }); + } else if command == "DATA" { + write_response(&mut stream, "354 End data with ."); + loop { + let line = read_line(&mut reader); + if line == ".\r\n" || line == ".\n" { + break; + } + data.push_str(&line); + } + write_response(&mut stream, "250 Message accepted"); + } 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"); + } + } + SmtpSession { authenticated, data } + } + + fn read_line(reader: &mut BufReader) -> String { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + line + } + + 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}")) + } +} From 77797c909e8670dbcc9b946a880c3919705e9476 Mon Sep 17 00:00:00 2001 From: Ophir LOJKINE Date: Fri, 10 Jul 2026 18:10:04 +0200 Subject: [PATCH 2/4] Fix SMTP config typo and add TLS mode support Rename the misspelled `stmp_*` configuration options to `smtp_*` and add a new `smtp_tls_mode` option (`starttls`, `tls`, `none`) to control encryption when connecting to the SMTP server. Reject credentials in plaintext mode. Change `sqlpage.send_mail` to return its JSON argument unchanged on success and update the example to use a local Mailpit SMTP server via Docker Compose. --- configuration.md | 7 +- .../sqlpage/migrations/75_send_mail.sql | 22 +++-- examples/sending emails/README.md | 79 ++++------------ examples/sending emails/docker-compose.yml | 10 ++ examples/sending emails/email.sql | 53 +++-------- examples/sending emails/index.sql | 13 ++- examples/sending emails/test.hurl | 21 ++++- src/app_config.rs | 60 +++++++++--- .../sqlpage_functions/functions/send_mail.rs | 94 +++++++++++-------- 9 files changed, 186 insertions(+), 173 deletions(-) diff --git a/configuration.md b/configuration.md index d2ce201b..037d3a8e 100644 --- a/configuration.md +++ b/configuration.md @@ -41,9 +41,10 @@ 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. | -| `stmp_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 `STMP_HOST` in the environment. | -| `stmp_username` | | Optional SMTP user name for `sqlpage.send_mail`. When set, SQLPage authenticates to `STMP_HOST` using this user name and `stmp_password`. | -| `stmp_password` | | Optional SMTP password for `sqlpage.send_mail` when `stmp_username` is set. | +| `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_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_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. | | `markdown_allow_dangerous_html` | false | Whether to allow raw HTML in markdown content. Only enable this if the markdown content is fully trusted (not user generated). | diff --git a/examples/official-site/sqlpage/migrations/75_send_mail.sql b/examples/official-site/sqlpage/migrations/75_send_mail.sql index a3c60945..16d22344 100644 --- a/examples/official-site/sqlpage/migrations/75_send_mail.sql +++ b/examples/official-site/sqlpage/migrations/75_send_mail.sql @@ -9,11 +9,13 @@ VALUES ( 'send_mail', '0.45.0', 'mail', - 'Sends an email using the SMTP server configured with `STMP_HOST`. + 'Sends an email using the SMTP server configured with `SMTP_HOST`. -`STMP_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` must contain only a host name or `host:port`; URL schemes and paths are rejected. When no port is specified, SQLPage uses port 25. -If your SMTP server requires authentication, configure `STMP_USERNAME` and `STMP_PASSWORD` as well. +`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. + +If your SMTP server requires authentication, configure `SMTP_USERNAME` and `SMTP_PASSWORD` as well. The function accepts a single JSON object argument. The required properties are: @@ -26,17 +28,18 @@ Optional properties: - `sender`: sender address. Defaults to `SQLPage `. - `reply_to`: reply-to address. -The function returns `sent` after the SMTP server accepts the message, and `NULL` when passed `NULL`. +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. ### Example ```sql -select sqlpage.send_mail(json_object( +set message = json_object( ''recipient'', ''admin@example.com'', ''sender'', ''contact@example.com'', ''subject'', ''New contact form message'', ''body'', ''Hello from SQLPage!'' -)); +); +select sqlpage.send_mail($message); ``` ### Contact form example @@ -46,16 +49,17 @@ select ''form'' as component, ''post'' as method; select ''email'' as name, ''email'' as type, true as required; select ''message'' as name, ''textarea'' as type, true as required; -select sqlpage.send_mail(json_object( +set mail = json_object( ''recipient'', ''admin@example.com'', ''reply_to'', $email, ''subject'', ''Website contact form'', ''body'', $message -)) +); +select sqlpage.send_mail($mail) where $message is not null; ``` ', - 'TEXT' + 'JSON' ); INSERT INTO sqlpage_function_parameters ( diff --git a/examples/sending emails/README.md b/examples/sending emails/README.md index e45b1b6e..9852bbe1 100644 --- a/examples/sending emails/README.md +++ b/examples/sending emails/README.md @@ -1,76 +1,31 @@ # Sending Emails with SQLPage -SQLPage lets you interact with any email service through their API, -using the [`sqlpage.fetch` function](https://sql-page.com/functions.sql?function=fetch). +This example sends plain-text email with [`sqlpage.send_mail`](https://sql-page.com/functions.sql?function=send_mail). The included Docker Compose setup uses [Mailpit](https://mailpit.axllent.org/) as a local SMTP server, so no email leaves your computer. -## Why Use an Email Service? +Run the example: -Sending emails directly from your server can be challenging: -- Many ISPs block direct email sending to prevent spam -- Email deliverability requires proper setup of SPF, DKIM, and DMARC records -- Managing bounce handling and spam complaints is complex -- Direct sending can impact your server's IP reputation - -Email services solve these problems by providing reliable APIs for sending emails while handling deliverability, tracking, and compliance. +```sh +docker compose up +``` -## Popular Email Services +Open http://localhost:8080 to send an email, then inspect it in the Mailpit inbox at http://localhost:8025. -- [Mailgun](https://www.mailgun.com/) - Developer-friendly, great for transactional emails -- [SendGrid](https://sendgrid.com/) - Powerful features, owned by Twilio -- [Amazon SES](https://aws.amazon.com/ses/) - Cost-effective for high volume -- [Postmark](https://postmarkapp.com/) - Focused on transactional email delivery -- [SMTP2GO](https://www.smtp2go.com/) - Simple SMTP service with API options +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. -## Example: Sending Emails with Mailgun +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. -Here's a complete example using Mailgun's API to send emails through SQLPage: +The form handler sends the message with a single function call: -### [`email.sql`](./email.sql) ```sql --- Configure the email request -set email_request = json_object( - 'url', 'https://api.mailgun.net/v3/' || sqlpage.environment_variable('MAILGUN_DOMAIN') || '/messages', - 'method', 'POST', - 'headers', json_object( - 'Content-Type', 'application/x-www-form-urlencoded', - 'Authorization', 'Basic ' || encode(('api:' || sqlpage.environment_variable('MAILGUN_API_KEY'))::bytea, 'base64') - ), - 'body', - 'from=Your Name ' - || '&to=' || $to_email - || '&subject=' || $subject - || '&text=' || $message_text - || '&html=' || $message_html +set message = json_object( + 'recipient', :recipient, + 'sender', :sender, + 'subject', :subject, + 'body', :body ); - --- Send the email using sqlpage.fetch -set email_response = sqlpage.fetch($email_request); - --- Handle the response -select - 'alert' as component, - case - when $email_response->>'id' is not null then 'Email sent successfully' - else 'Failed to send email: ' || ($email_response->>'message') - end as title; +set sent_message = sqlpage.send_mail($message); ``` -### Setup Instructions - -1. Sign up for a [Mailgun account](https://signup.mailgun.com/new/signup) -2. Verify your domain or use the sandbox domain for testing -3. Get your API key from the Mailgun dashboard -4. Set these environment variables in your SQLPage configuration: - ``` - MAILGUN_API_KEY=your-api-key-here - MAILGUN_DOMAIN=your-domain.com - ``` - -## Best Practices +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. -- If you share your code with others, it should not contain sensitive data like API keys - - Instead, use environment variables with [`sqlpage.environment_variable`](https://sql-page.com/functions.sql?function=environment_variable) -- Implement proper error handling -- Consider rate limiting for bulk sending -- Include unsubscribe links when sending marketing emails -- Follow email regulations (GDPR, CAN-SPAM Act) +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 8d0813b5..7ee16412 100644 --- a/examples/sending emails/docker-compose.yml +++ b/examples/sending emails/docker-compose.yml @@ -3,5 +3,15 @@ services: image: lovasoa/sqlpage:main ports: - "8080:8080" + environment: + SMTP_HOST: mailpit:1025 + SMTP_TLS_MODE: none volumes: - .:/var/www + depends_on: + - mailpit + + mailpit: + image: axllent/mailpit:v1.30.4 + ports: + - "8025:8025" diff --git a/examples/sending emails/email.sql b/examples/sending emails/email.sql index c528cc13..beaef452 100644 --- a/examples/sending emails/email.sql +++ b/examples/sending emails/email.sql @@ -1,43 +1,16 @@ --- Configure the email request - --- Obtain the authorization by encoding "api:YOUR_PERSONAL_API_KEY" in base64 -set authorization = 'YXBpOjI4ODlmODE3Njk5ZjZiNzA4MTdhODliOGUwODYyNmEyLWU2MWFlOGRkLTgzMjRjYWZm'; - --- Find the domain in your Mailgun account -set domain = 'sandbox859545b401674a95b906ab417d48c97c.mailgun.org'; - --- Set the recipient email address. ---In this demo, we accept sending any email to any address. --- If you do this in production, spammers WILL use your account to send spam. --- Your application should only allow emails to be sent to addresses you have verified. -set to_email = :to_email; - --- Set the email subject -set subject = :subject; - --- Set the email message text -set message_text = :message_text; - -set email_request = json_object( - 'url', 'https://api.mailgun.net/v3/' || $domain || '/messages', - 'method', 'POST', - 'headers', json_object( - 'Content-Type', 'application/x-www-form-urlencoded', - 'Authorization', 'Basic ' || $authorization - ), - 'body', - 'from=Your Name ' - || '&to=' || sqlpage.url_encode($to_email) - || '&subject=' || sqlpage.url_encode($subject) - || '&text=' || sqlpage.url_encode($message_text) +set message = json_object( + 'recipient', :recipient, + 'sender', :sender, + 'subject', :subject, + 'body', :body ); --- Send the email using sqlpage.fetch -set email_response = sqlpage.fetch($email_request); +set sent_message = sqlpage.send_mail($message); --- Handle the response -select +select 'alert' as component, - case - when $email_response->>'id' is not null then 'Email sent successfully' - else 'Failed to send email: ' || ($email_response->>'message') - end as title; \ No newline at end of file + 'success' as color, + 'Email sent successfully' as title +where $sent_message is not null; + +select 'button' as component; +select 'Send another email' as title, 'index.sql' as link; diff --git a/examples/sending emails/index.sql b/examples/sending emails/index.sql index dcee9813..d583b7ad 100644 --- a/examples/sending emails/index.sql +++ b/examples/sending emails/index.sql @@ -1,5 +1,10 @@ -select 'form' as component, 'Send an email' as title, 'email.sql' as action; +select + 'form' as component, + 'Send an email through SMTP' as title, + 'email.sql' as action, + 'post' as method; -select 'to_email' as name, 'To email' as label, 'recipient@example.com' as value; -select 'subject' as name, 'Subject' as label, 'Test email' as value; -select 'textarea' as type, 'message_text' as name, 'Message' as label, 'This is a test email' as value; +select 'recipient' as name, 'To' as label, 'recipient@example.com' as value, true as required; +select 'sender' as name, 'From' as label, 'SQLPage ' as value, true as required; +select 'subject' as name, 'Subject' as label, 'Test email' as value, true as required; +select 'textarea' as type, 'body' as name, 'Message' as label, 'This is a test email' as value, true as required; diff --git a/examples/sending emails/test.hurl b/examples/sending emails/test.hurl index 17ba5c63..b1c2eb7a 100644 --- a/examples/sending emails/test.hurl +++ b/examples/sending emails/test.hurl @@ -2,6 +2,23 @@ GET http://127.0.0.1:8080/ HTTP 200 [Asserts] xpath "string(//form/@action)" == "email.sql" -xpath "string(//input[@name='to_email']/@value)" == "recipient@example.com" +xpath "string(//form/@method)" == "post" +xpath "string(//input[@name='recipient']/@value)" == "recipient@example.com" xpath "string(//input[@name='subject']/@value)" == "Test email" -xpath "string(//textarea[@name='message_text'])" == "This is a test email" +xpath "string(//textarea[@name='body'])" == "This is a test email" + +POST http://127.0.0.1:8080/email.sql +[FormParams] +recipient: recipient@example.com +sender: SQLPage +subject: SMTP demo +body: Sent with sqlpage.send_mail +HTTP 200 +[Asserts] +xpath "string(//*[contains(@class, 'alert') and contains(., 'Email sent successfully')])" contains "Email sent successfully" + +GET http://127.0.0.1:8025/api/v1/search?query=subject%3A%22SMTP%20demo%22 +HTTP 200 +[Asserts] +jsonpath "$.messages[0].Subject" == "SMTP demo" +jsonpath "$.messages[0].To[0].Address" == "recipient@example.com" diff --git a/src/app_config.rs b/src/app_config.rs index 7b597765..6938a6a7 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -134,9 +134,13 @@ impl AppConfig { } anyhow::ensure!(self.max_pending_rows > 0, "max_pending_rows cannot be null"); - if let Some(stmp_host) = &self.stmp_host { - validate_stmp_host(stmp_host)?; + if let Some(smtp_host) = &self.smtp_host { + validate_smtp_host(smtp_host)?; } + anyhow::ensure!( + self.smtp_username.is_none() || self.smtp_tls_mode != SmtpTlsMode::None, + "SMTP credentials require smtp_tls_mode to be 'starttls' or 'tls'" + ); for path in &self.oidc_protected_paths { if !path.starts_with('/') { @@ -227,14 +231,18 @@ pub struct AppConfig { /// 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 stmp_host: Option, + pub smtp_host: Option, /// Optional SMTP user name used by the `sqlpage.send_mail` function. - /// If set, `SQLPage` authenticates to `STMP_HOST` with this user name and `stmp_password`. - pub stmp_username: Option, + /// If set, `SQLPage` authenticates to `SMTP_HOST` with this user name and `smtp_password`. + pub smtp_username: Option, + + /// Optional SMTP password used by the `sqlpage.send_mail` function when `smtp_username` is set. + pub smtp_password: Option, - /// Optional SMTP password used by the `sqlpage.send_mail` function when `stmp_username` is set. - pub stmp_password: Option, + /// Encryption mode used to connect to `SMTP_HOST`. + #[serde(default)] + pub smtp_tls_mode: SmtpTlsMode, /// Maximum size of uploaded files in bytes. The default is 10MiB (10 * 1024 * 1024 bytes) #[serde(default = "default_max_file_size")] @@ -546,22 +554,22 @@ fn default_site_prefix() -> String { '/'.to_string() } -pub(crate) fn parse_stmp_host(stmp_host: &str) -> anyhow::Result<(&str, u16)> { - let (host, port) = stmp_host +pub(crate) fn parse_smtp_host(smtp_host: &str) -> anyhow::Result<(&str, u16)> { + let (host, port) = smtp_host .rsplit_once(':') - .map_or((stmp_host, 25), |(host, port)| { + .map_or((smtp_host, 25), |(host, port)| { (host, port.parse::().unwrap_or(0)) }); anyhow::ensure!( !host.is_empty() && !host.contains('/') && !host.contains(':'), - "STMP_HOST must be a host name or host:port, without a URL scheme or path" + "SMTP_HOST must be a host name or host:port, without a URL scheme or path" ); - anyhow::ensure!(port > 0, "STMP_HOST port must be between 1 and 65535"); + anyhow::ensure!(port > 0, "SMTP_HOST port must be between 1 and 65535"); Ok((host, port)) } -fn validate_stmp_host(stmp_host: &str) -> anyhow::Result<()> { - parse_stmp_host(stmp_host).map(|_| ()) +fn validate_smtp_host(smtp_host: &str) -> anyhow::Result<()> { + parse_smtp_host(smtp_host).map(|_| ()) } fn parse_socket_addr(host_str: &str) -> anyhow::Result { @@ -713,6 +721,15 @@ pub enum DevOrProd { Development, Production, } + +#[derive(Debug, Deserialize, PartialEq, Clone, Copy, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum SmtpTlsMode { + None, + #[default] + Starttls, + Tls, +} impl DevOrProd { pub(crate) fn is_prod(self) -> bool { self == DevOrProd::Production @@ -777,6 +794,21 @@ mod test { assert_eq!(default_site_prefix(), "/".to_string()); } + #[test] + fn smtp_defaults_to_starttls() { + assert_eq!(tests::test_config().smtp_tls_mode, SmtpTlsMode::Starttls); + } + + #[test] + fn smtp_credentials_require_tls() { + let mut config = tests::test_config(); + config.smtp_username = Some("user".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 test_encode_uri() { assert_eq!( diff --git a/src/webserver/database/sqlpage_functions/functions/send_mail.rs b/src/webserver/database/sqlpage_functions/functions/send_mail.rs index 1a6db705..137c8987 100644 --- a/src/webserver/database/sqlpage_functions/functions/send_mail.rs +++ b/src/webserver/database/sqlpage_functions/functions/send_mail.rs @@ -4,12 +4,15 @@ use anyhow::Context; use lettre::{ AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, message::{Mailbox, header::ContentType}, - transport::smtp::authentication::Credentials, + transport::smtp::{ + authentication::Credentials, + client::{Tls, TlsParameters}, + }, }; use serde::Deserialize; use crate::{ - app_config::{AppConfig, parse_stmp_host}, + app_config::{AppConfig, SmtpTlsMode, parse_smtp_host}, webserver::http_request_info::RequestInfo, }; @@ -27,36 +30,36 @@ struct MailRequest<'a> { reply_to: Option>, } -/// Sends an email through the SMTP server configured with `STMP_HOST`. -pub(super) async fn send_mail( +/// Sends an email through the SMTP server configured with `SMTP_HOST`. +pub(super) async fn send_mail<'a>( request: &RequestInfo, - mail_request: Option>, -) -> anyhow::Result> { + mail_request: Option>, +) -> anyhow::Result>> { send_mail_with_config(&request.app_state.config, mail_request).await } -async fn send_mail_with_config( +async fn send_mail_with_config<'a>( config: &AppConfig, - mail_request: Option>, -) -> anyhow::Result> { + mail_request: Option>, +) -> anyhow::Result>> { let Some(mail_request) = mail_request else { return Ok(None); }; - let stmp_host = config - .stmp_host + let smtp_host = config + .smtp_host .as_deref() - .context("The sqlpage.send_mail() function requires the STMP_HOST configuration option")?; - let (host, port) = parse_stmp_host(stmp_host)?; - let mail_request: MailRequest<'_> = serde_json::from_str(&mail_request) + .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")?; - let sender = mail_request + let sender = parsed_mail_request .sender .as_deref() .unwrap_or("SQLPage ") .parse::() .context("Invalid sender email address")?; - let recipient = mail_request + let recipient = parsed_mail_request .recipient .parse::() .context("Invalid recipient email address")?; @@ -64,9 +67,9 @@ async fn send_mail_with_config( let mut email = Message::builder() .from(sender) .to(recipient) - .subject(mail_request.subject.as_ref()) + .subject(parsed_mail_request.subject.as_ref()) .header(ContentType::TEXT_PLAIN); - if let Some(reply_to) = mail_request.reply_to { + if let Some(reply_to) = parsed_mail_request.reply_to { email = email.reply_to( reply_to .parse::() @@ -74,22 +77,33 @@ async fn send_mail_with_config( ); } let email = email - .body(mail_request.body.into_owned()) + .body(parsed_mail_request.body.into_owned()) .context("Unable to build email message")?; - let mut mailer_builder = AsyncSmtpTransport::::builder_dangerous(host).port(port); - if let Some(username) = &config.stmp_username { + 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) + .port(port) + .tls(tls); + if let Some(username) = &config.smtp_username { mailer_builder = mailer_builder.credentials(Credentials::new( username.clone(), - config.stmp_password.clone().unwrap_or_default(), + config.smtp_password.clone().unwrap_or_default(), )); } let mailer = mailer_builder.build(); mailer .send(email) .await - .with_context(|| format!("Unable to send email through {stmp_host}"))?; - Ok(Some("sent")) + .with_context(|| format!("Unable to send email through {smtp_host}"))?; + Ok(Some(mail_request)) } #[cfg(test)] @@ -102,32 +116,34 @@ mod tests { thread, }; - use super::send_mail_with_config; + use super::{SmtpTlsMode, send_mail_with_config}; use crate::app_config::tests::test_config; #[tokio::test] - async fn send_mail_authenticates_to_smtp_server() { + async fn send_mail_authenticates_to_plaintext_smtp_server_when_explicitly_enabled() { let (host, received) = start_authenticated_smtp_server("user", "secret"); let mut config = test_config(); - config.stmp_host = Some(host); - config.stmp_username = Some("user".to_string()); - config.stmp_password = Some("secret".to_string()); - + config.smtp_host = Some(host); + config.smtp_username = Some("user".to_string()); + config.smtp_password = Some("secret".to_string()); + 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( &config, - Some(Cow::Borrowed( - r#"{ - "recipient": "admin@example.com", - "sender": "contact@example.com", - "subject": "Authenticated SMTP", - "body": "hello authenticated smtp" - }"#, - )), + Some(mail_request.clone()), ) .await .unwrap(); - assert_eq!(result, Some("sent")); + 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")); From 9659fedede13830e9e6d273cb97bec500dd7f9dc Mon Sep 17 00:00:00 2001 From: lovasoa Date: Sat, 11 Jul 2026 00:25:06 +0200 Subject: [PATCH 3/4] Harden send_mail API and TLS configuration --- Cargo.lock | 89 +------- Cargo.toml | 9 +- configuration.md | 6 +- .../sqlpage/migrations/75_send_mail.sql | 22 +- examples/sending emails/README.md | 10 +- examples/sending emails/docker-compose.yml | 3 +- examples/sending emails/email.sql | 7 +- src/app_config.rs | 73 +++++-- .../sqlpage_functions/function_traits.rs | 6 + .../sqlpage_functions/functions/send_mail.rs | 197 +++++++++--------- src/webserver/http_client.rs | 67 ++++-- 11 files changed, 246 insertions(+), 243 deletions(-) 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( + send_mail_with_config( + &config, r#"{ - "recipient": "admin@example.com", - "sender": "contact@example.com", - "subject": "Authenticated SMTP", - "body": "hello authenticated smtp" + "to": "admin@example.com", + "from": "contact@example.com", + "subject": "SMTP test", + "body": "hello smtp" }"#, - ); - let result = send_mail_with_config( - &config, - Some(mail_request.clone()), ) .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={:?}", From 00b969fe7d0d2f2bbd092e9d2672636172de6df5 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Tue, 14 Jul 2026 16:50:37 +0200 Subject: [PATCH 4/4] Add support for email attachments in sqlpage.send_mail Introduce a `max_email_attachment_size` configuration option and support for attaching files via data URLs with CC recipients in the send_mail function. Refactor data URL decoding into a shared utility. --- configuration.md | 1 + src/app_config.rs | 8 + src/render.rs | 18 +-- src/webserver/database/blob_to_data_url.rs | 40 +++++ .../sqlpage_functions/functions/send_mail.rs | 153 ++++++++++++++---- 5 files changed, 174 insertions(+), 46 deletions(-) diff --git a/configuration.md b/configuration.md index de62742d..ff71df9a 100644 --- a/configuration.md +++ b/configuration.md @@ -47,6 +47,7 @@ Here are the available configuration options and their default values: | `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. | +| `max_email_attachment_size` | 10485760 | Maximum combined decoded size, in bytes, of all attachments in one email. Defaults to 10 MiB. | | `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. | | `markdown_allow_dangerous_html` | false | Whether to allow raw HTML in markdown content. Only enable this if the markdown content is fully trusted (not user generated). | diff --git a/src/app_config.rs b/src/app_config.rs index 50e02192..ef214f9c 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -270,6 +270,10 @@ pub struct AppConfig { #[serde(default)] pub smtp_tls_mode: SmtpTlsMode, + /// Maximum combined decoded size of attachments in one email. + #[serde(default = "default_max_email_attachment_size")] + pub max_email_attachment_size: usize, + /// Maximum size of uploaded files in bytes. The default is 10MiB (10 * 1024 * 1024 bytes) #[serde(default = "default_max_file_size")] pub max_uploaded_file_size: usize, @@ -687,6 +691,10 @@ fn default_max_file_size() -> usize { 5 * 1024 * 1024 } +fn default_max_email_attachment_size() -> usize { + 10 * 1024 * 1024 +} + fn default_https_certificate_cache_dir() -> PathBuf { default_web_root().join("sqlpage").join("https") } diff --git a/src/render.rs b/src/render.rs index 17558ef7..49a1efd1 100644 --- a/src/render.rs +++ b/src/render.rs @@ -358,24 +358,12 @@ impl HeaderContext { } let data_url = get_object_str(options, "data_url") .with_context(|| "The download component requires a 'data_url' property")?; - let rest = data_url - .strip_prefix("data:") - .with_context(|| "Invalid data URL: missing 'data:' prefix")?; - let (mut content_type, data) = rest - .split_once(',') - .with_context(|| "Invalid data URL: missing comma")?; - let mut body_bytes: Cow<[u8]> = percent_encoding::percent_decode(data.as_bytes()).into(); - if let Some(stripped) = content_type.strip_suffix(";base64") { - content_type = stripped; - body_bytes = - base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &body_bytes) - .with_context(|| "Invalid base64 data in data URL")? - .into(); - } + let (content_type, body_bytes) = + crate::webserver::database::blob_to_data_url::decode_data_uri(data_url)?; if !content_type.is_empty() { self.insert_header((header::CONTENT_TYPE, content_type))?; } - self.close_with_body(body_bytes.into_owned()) + self.close_with_body(body_bytes) } fn log(self, data: &JsonValue) -> anyhow::Result { diff --git a/src/webserver/database/blob_to_data_url.rs b/src/webserver/database/blob_to_data_url.rs index b8e1fad0..ae9ec9f1 100644 --- a/src/webserver/database/blob_to_data_url.rs +++ b/src/webserver/database/blob_to_data_url.rs @@ -86,6 +86,27 @@ pub fn vec_to_data_uri_value(bytes: &[u8]) -> serde_json::Value { serde_json::Value::String(vec_to_data_uri(bytes)) } +/// Decodes a data URL into its declared media type and raw bytes. +pub fn decode_data_uri(data_url: &str) -> anyhow::Result<(&str, Vec)> { + use anyhow::Context as _; + + let rest = data_url + .strip_prefix("data:") + .context("Invalid data URL: missing 'data:' prefix")?; + let (mut media_type, data) = rest + .split_once(',') + .context("Invalid data URL: missing comma")?; + let percent_decoded = percent_encoding::percent_decode(data.as_bytes()).collect::>(); + let bytes = if let Some(stripped) = media_type.strip_suffix(";base64") { + media_type = stripped; + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, percent_decoded) + .context("Invalid base64 data in data URL")? + } else { + percent_decoded + }; + Ok((media_type, bytes)) +} + #[cfg(test)] mod tests { use super::*; @@ -141,6 +162,25 @@ mod tests { ); } + #[test] + fn decodes_base64_and_percent_encoded_data_urls() { + assert_eq!( + decode_data_uri("data:text/plain;base64,aGVsbG8=").unwrap(), + ("text/plain", b"hello".to_vec()) + ); + assert_eq!( + decode_data_uri("data:text/plain,hello%20world").unwrap(), + ("text/plain", b"hello world".to_vec()) + ); + } + + #[test] + fn rejects_invalid_data_urls() { + assert!(decode_data_uri("text/plain,hello").is_err()); + assert!(decode_data_uri("data:text/plain").is_err()); + assert!(decode_data_uri("data:;base64,not base64").is_err()); + } + #[test] fn test_vec_to_data_uri() { // Test with empty bytes diff --git a/src/webserver/database/sqlpage_functions/functions/send_mail.rs b/src/webserver/database/sqlpage_functions/functions/send_mail.rs index f0f306fa..c928c571 100644 --- a/src/webserver/database/sqlpage_functions/functions/send_mail.rs +++ b/src/webserver/database/sqlpage_functions/functions/send_mail.rs @@ -3,7 +3,7 @@ use std::borrow::Cow; use anyhow::Context; use lettre::{ AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, - message::{Mailbox, header::ContentType}, + message::{Attachment, Mailbox, MultiPart, SinglePart, header::ContentType}, transport::smtp::{ authentication::Credentials, client::{Certificate, CertificateStore, Tls, TlsParameters}, @@ -13,14 +13,53 @@ use serde::Deserialize; use crate::{ app_config::{AppConfig, SmtpTlsMode}, - webserver::{http_client::native_certificate_der, http_request_info::RequestInfo}, + webserver::{ + database::blob_to_data_url::decode_data_uri, http_client::native_certificate_der, + http_request_info::RequestInfo, + }, }; +#[derive(Deserialize)] +#[serde(untagged)] +enum Recipients { + One(String), + Many(Vec), +} + +impl Recipients { + fn parse(self, field: &str) -> anyhow::Result> { + let recipients = match self { + Self::One(recipient) => vec![recipient], + Self::Many(recipients) => recipients, + }; + anyhow::ensure!(!recipients.is_empty(), "{field} must not be an empty array"); + recipients + .into_iter() + .enumerate() + .map(|(index, recipient)| { + recipient + .parse::() + .with_context(|| format!("Invalid {field} email address at index {index}")) + }) + .collect() + } +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] -struct MailRequest<'a> { +struct MailAttachment<'a> { + #[serde(borrow)] + filename: Cow<'a, str>, #[serde(borrow)] - to: Cow<'a, str>, + data_url: Cow<'a, str>, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct MailRequest<'a> { + to: Recipients, + #[serde(default)] + cc: Option, #[serde(borrow)] subject: Cow<'a, str>, #[serde(borrow)] @@ -29,6 +68,8 @@ struct MailRequest<'a> { from: Option>, #[serde(borrow, default)] reply_to: Option>, + #[serde(borrow, default)] + attachments: Vec>, } /// Sends an email through the configured SMTP relay. @@ -47,33 +88,7 @@ async fn send_mail_with_config(config: &AppConfig, mail_request: &str) -> anyhow let parsed: MailRequest<'_> = serde_json::from_str(mail_request) .context("sqlpage.send_mail() expects a JSON object")?; - let sender = parsed - .from - .as_deref() - .or(config.smtp_from.as_deref()) - .context("Email has no from address; set its from property or configure smtp_from")? - .parse::() - .context("Invalid from email address")?; - let recipient = parsed - .to - .parse::() - .context("Invalid to email address")?; - - let mut email = Message::builder() - .from(sender) - .to(recipient) - .subject(parsed.subject.as_ref()) - .header(ContentType::TEXT_PLAIN); - if let Some(reply_to) = parsed.reply_to { - email = email.reply_to( - reply_to - .parse::() - .context("Invalid reply_to email address")?, - ); - } - let email = email - .body(parsed.body.into_owned()) - .context("Unable to build email message")?; + let email = build_email(config, parsed)?; let tls = smtp_tls(config, host)?; let port = config @@ -95,6 +110,82 @@ async fn send_mail_with_config(config: &AppConfig, mail_request: &str) -> anyhow Ok(()) } +fn build_email(config: &AppConfig, request: MailRequest<'_>) -> anyhow::Result { + let MailRequest { + to, + cc, + subject, + body, + from, + reply_to, + attachments, + } = request; + + let sender = from + .as_deref() + .or(config.smtp_from.as_deref()) + .context("Email has no from address; set its from property or configure smtp_from")? + .parse::() + .context("Invalid from email address")?; + let mut email = Message::builder() + .from(sender) + .subject(subject.as_ref()); + for recipient in to.parse("to")? { + email = email.to(recipient); + } + if let Some(cc) = cc { + for recipient in cc.parse("cc")? { + email = email.cc(recipient); + } + } + if let Some(reply_to) = reply_to { + email = email.reply_to( + reply_to + .parse::() + .context("Invalid reply_to email address")?, + ); + } + if attachments.is_empty() { + return email + .header(ContentType::TEXT_PLAIN) + .body(body.into_owned()) + .context("Unable to build email message"); + } + + let mut total_attachment_size = 0usize; + let mut multipart = MultiPart::mixed().singlepart(SinglePart::plain(body.into_owned())); + for (index, attachment) in attachments.into_iter().enumerate() { + anyhow::ensure!( + !attachment.filename.is_empty(), + "Attachment filename at index {index} must not be empty" + ); + let (media_type, bytes) = decode_data_uri(&attachment.data_url) + .with_context(|| format!("Invalid attachment data_url at index {index}"))?; + total_attachment_size = total_attachment_size + .checked_add(bytes.len()) + .context("Total attachment size overflowed")?; + anyhow::ensure!( + total_attachment_size <= config.max_email_attachment_size, + "Attachments exceed max_email_attachment_size of {} bytes", + config.max_email_attachment_size + ); + let media_type = if media_type.is_empty() { + "application/octet-stream" + } else { + media_type + }; + let content_type = ContentType::parse(media_type) + .with_context(|| format!("Invalid attachment media type at index {index}"))?; + multipart = multipart.singlepart(Attachment::new(attachment.filename.into_owned()).body( + bytes, + content_type, + )); + } + email + .multipart(multipart) + .context("Unable to build email message") +} + fn smtp_tls(config: &AppConfig, host: &str) -> anyhow::Result { if config.smtp_tls_mode == SmtpTlsMode::None { return Ok(Tls::None);