From 72e695ec4af7f9261b37876c4673878938573654 Mon Sep 17 00:00:00 2001 From: Ophir LOJKINE Date: Fri, 10 Jul 2026 15:54:15 +0200 Subject: [PATCH 1/9] 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/9] 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/9] 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/9] 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); From 93afed919c0cf4f6fb06b1d5c723d7e13475599e Mon Sep 17 00:00:00 2001 From: Ophir LOJKINE Date: Fri, 17 Jul 2026 15:22:53 +0200 Subject: [PATCH 5/9] Email sending improvement --- CHANGELOG.md | 1 + configuration.md | 2 +- .../sqlpage/migrations/75_send_mail.sql | 231 ++++++++++-- examples/sending emails/README.md | 4 +- examples/sending emails/email.sql | 7 +- src/app_config.rs | 25 +- src/webserver/database/blob_to_data_url.rs | 39 +- .../sqlpage_functions/functions/send_mail.rs | 345 +++++++++++++++--- tests/sql_test_files/data/send_mail_null.sql | 2 + 9 files changed, 570 insertions(+), 86 deletions(-) create mode 100644 tests/sql_test_files/data/send_mail_null.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index beed7d45..459e6a50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## unreleased +- **SQLPage can now send plain-text email through an SMTP relay.** Configure the relay and optional authentication with `smtp_host`, `smtp_port`, `smtp_username`, `smtp_password`, `smtp_from`, and `smtp_tls_mode`, then call `sqlpage.send_mail` with a JSON message. It returns `{"status":"accepted"}` on SMTP acceptance or `{"status":"error","error_code":"...","error":"..."}` without stopping the request; SQL `NULL` is passed through without sending. Messages support multiple `to` and `cc` recipients, reply-to addresses, and data-URL attachments with a configurable combined decoded-size limit. SMTP passwords are redacted from startup debug logs. - **SQLPage functions can now be composed with database results.** Direct calls such as `SELECT sqlpage.url_encode(url) FROM links` already ran once per row. Per-row evaluation now also works through parentheses, concatenation, `COALESCE`, JSON constructors, and nested SQLPage functions. The database first decides which rows exist, then SQLPage evaluates the selected expression for each row. This enables patterns that were not previously possible, such as fetching only missing cached values or rendering a reusable SQL file with parameters from each row: ```sql diff --git a/configuration.md b/configuration.md index ff71df9a..546a4bb6 100644 --- a/configuration.md +++ b/configuration.md @@ -47,7 +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. | +| `max_email_attachment_size` | 10485760 | Maximum combined decoded size, in bytes, of all attachments in one email. Defaults to 10 MiB. This is independent of `max_uploaded_file_size` because attachments may come from sources other than form uploads. | | `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 e038f250..6e1b780f 100644 --- a/examples/official-site/sqlpage/migrations/75_send_mail.sql +++ b/examples/official-site/sqlpage/migrations/75_send_mail.sql @@ -1,47 +1,147 @@ INSERT INTO sqlpage_functions ( "name", + "return_type", "introduced_in_version", "icon", "description_md" ) VALUES ( 'send_mail', + 'JSON', '0.45.0', 'mail', - 'Sends an email using the SMTP server configured with `SMTP_HOST`. + 'Sends a plain-text email using the outgoing mail server configured in SQLPage. -`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`. +### Quick start -`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. +You need an [SMTP server](https://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol), which is the outgoing mail server provided by an email account or email delivery service. -If your SMTP server requires authentication, configure `SMTP_USERNAME` and `SMTP_PASSWORD` as well. +Add its connection details to `sqlpage/sqlpage.json`: -The function accepts a single JSON object argument. The required properties are: +```json +{ + "smtp_host": "smtp.example.com", + "smtp_username": "your-smtp-user", + "smtp_password": "your-smtp-password", + "smtp_from": "My application " +} +``` + +The default connection uses STARTTLS on port 587, which is the most common setup. Restart SQLPage after changing its configuration. + +**Important:** SQLPage can sign in with an SMTP username and password, but it does not support OAuth. If the provider instructions only offer OAuth or "Modern Auth", use a different SMTP relay. + +You can now send an email from any SQL file: -- `to`: email address to send to, optionally including a display name such as `"Jane Doe "`. -- `subject`: email subject. -- `body`: plain text email body. +```sql +set result = sqlpage.send_mail(json_object( + ''to'', ''alice@example.com'', + ''subject'', ''Hello from SQLPage'', + ''body'', ''Your first email is working!'' +)); +``` -Optional properties: +The sender comes from `smtp_from`. The result is a JSON object: -- `from`: sender address. It may be omitted when `SMTP_FROM` configures a default sender. -- `reply_to`: reply-to address. +```json +{"status":"accepted"} +``` -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. +If the message cannot be sent, the function returns the reason instead of stopping the request: -### Example +```json +{"status":"error","error_code":"INVALID_EMAIL_TO","error":"''xxx'' is not a valid to email address"} +``` + +For every non-`NULL` call, `status` is either `accepted` or `error`. Always check it before showing a success message or continuing work that depends on the email: ```sql -set message = json_object( - ''to'', ''admin@example.com'', - ''from'', ''contact@example.com'', - ''subject'', ''New contact form message'', - ''body'', ''Hello from SQLPage!'' -); -select sqlpage.send_mail($message); +select ''alert'' as component, + case when json_extract($result, ''$.status'') = ''accepted'' then ''success'' else ''danger'' end as color, + case when json_extract($result, ''$.status'') = ''accepted'' then ''Email sent'' else ''Email could not be sent'' end as title, + json_extract($result, ''$.error'') as description; ``` -### Contact form example +### Where to find the SMTP settings + +Search the help pages or administration panel of the service that sends email for you. Look for **SMTP**, **outgoing mail server**, **SMTP submission**, **SMTP relay**, or **send from an app or device**. + +Provider documentation may use different names for the same settings: + +| Provider documentation | SQLPage setting | +| --- | --- | +| SMTP server, outgoing server, relay, or smart host | `smtp_host` | +| Port | `smtp_port` | +| STARTTLS, SSL/TLS, or connection security | `smtp_tls_mode` | +| SMTP username | `smtp_username` | +| SMTP password, app password, token, or API key | `smtp_password` | +| Sender or From address | `smtp_from` | + +The SMTP password is often a separate app password, token, or SMTP credential rather than the password used to open webmail. Use exactly what the provider instructions specify. + +For an existing mailbox, these official guides explain the available options: + +- [Personal Google Account app passwords](https://support.google.com/accounts/answer/185833), for eligible accounts +- [Google Workspace: send email from a printer, scanner, or app](https://knowledge.workspace.google.com/admin/gmail/send-email-from-a-printer-scanner-or-app) +- [Microsoft 365: send email from a device or application](https://learn.microsoft.com/en-us/exchange/mail-flow-best-practices/how-to-set-up-a-multifunction-device-or-application-to-send-email-using-microsoft-365-or-office-365) + +Personal Outlook.com SMTP requires OAuth and is therefore not currently compatible. Microsoft 365 administrators can use the relay options described in the linked organization guide. + +Dedicated email delivery services also provide SMTP settings. Here are examples in alphabetical order: + +- [Amazon SES SMTP credentials](https://docs.aws.amazon.com/ses/latest/dg/smtp-credentials.html) +- [Mailgun SMTP](https://documentation.mailgun.com/docs/mailgun/user-manual/sending-messages/send-smtp) +- [Postmark SMTP](https://postmarkapp.com/developer/user-guide/send-email-with-smtp) +- [Resend SMTP](https://resend.com/docs/send-with-smtp) +- [Twilio SendGrid SMTP](https://www.twilio.com/docs/sendgrid/for-developers/sending-email/integrating-with-the-smtp-api) + +These links are examples, not endorsements. SQLPage is not affiliated with any of these services. Compare their requirements, limits, and pricing for your own use case. + +For local development, [Mailpit](https://mailpit.axllent.org/) accepts messages and displays them in a browser without delivering them to real recipients. The [SQLPage email example](https://github.com/sqlpage/SQLPage/tree/main/examples/sending%20emails) includes a ready-to-run Mailpit setup. + +All SMTP options can also be set with uppercase environment variables such as `SMTP_HOST` and `SMTP_PASSWORD`. See the complete [SQLPage configuration reference](https://github.com/sqlpage/SQLPage/blob/main/configuration.md). Do not commit SMTP credentials to source control. + +### Message fields + +The function takes one JSON object with three required fields: + +- `to`: the recipient email address; +- `subject`: the email subject; +- `body`: the plain-text email body. + +It also accepts: + +- `from`: overrides `smtp_from` for this message; +- `reply_to`: the address that receives replies; +- `cc`: a recipient who receives a visible copy; +- `attachments`: files to include with the message. + +`to` and `cc` can each be either one address or an array of addresses. Addresses can include a display name, for example `"Jane Doe "`. + +Most SMTP servers only allow approved sender addresses. Prefer a fixed `smtp_from`. Override `from` only when the SMTP provider allows the address. + +### Multiple recipients and attachments + +Each attachment has a file name and a [data URL](https://developer.mozilla.org/en-US/docs/Web/URI/Schemes/data) containing its data. This example attaches a file from the SQLPage server: + +```sql +set result = sqlpage.send_mail(json_object( + ''to'', json_array(''alice@example.com'', ''bob@example.com''), + ''cc'', ''team@example.com'', + ''subject'', ''Monthly report'', + ''body'', ''The report is attached.'', + ''attachments'', json_array(json_object( + ''filename'', ''report.pdf'', + ''data_url'', sqlpage.read_file_as_data_url(''report.pdf'') + )) +)); +``` + +[`sqlpage.read_file_as_data_url`](/functions.sql?function=read_file_as_data_url) is one way to create attachment data. Data URLs can also come from an uploaded file, a database value, an HTTP response, or SQL. + +The combined decoded size of all attachments is limited by `max_email_attachment_size`, which defaults to 10 MiB. This is separate from `max_uploaded_file_size` because attachments do not have to come from form uploads. + +### Contact form ```sql select ''form'' as component, ''post'' as method; @@ -50,13 +150,92 @@ select ''message'' as name, ''textarea'' as type, true as required; set mail = json_object( ''to'', ''admin@example.com'', - ''reply_to'', $email, + ''reply_to'', :email, ''subject'', ''Website contact form'', - ''body'', $message + ''body'', :message ); -select sqlpage.send_mail($mail) -where $message is not null; +set result = ( + select sqlpage.send_mail($mail) + where :message is not null +); + +select ''alert'' as component, + case when json_extract($result, ''$.status'') = ''accepted'' then ''success'' else ''danger'' end as color, + case when json_extract($result, ''$.status'') = ''accepted'' then ''Message sent'' else ''Message could not be sent'' end as title, + json_extract($result, ''$.error'') as description +where :message is not null; ``` + +On the initial page load, `:message` is `NULL`, so the query returns no row and no email is sent. After submission, `:email` and `:message` contain the form fields. + +For a public form, keep `to` fixed in SQL so visitors cannot use your server to email arbitrary recipients. Validate inputs and add suitable rate limiting and anti-abuse controls. + +### Before using this in production + +- A `status` of `accepted` is not proof of delivery. A message can still bounce or be filtered later. Check the provider logs or delivery webhooks when delivery status matters. +- The provider may require sender or domain verification and DNS records such as SPF, DKIM, or DMARC. Configure these with the provider and DNS host. +- The function waits for the SMTP server during the web request. It opens a new connection for each call and does not retry automatically or save failed messages in a queue. +- SMTP commands use a fixed 60-second timeout. SQLPage does not currently provide a setting to change it. +- A selected call runs once for every row returned by its query. If the query returns no rows, it sends no email. Avoid using it over many rows; use a background queue or provider bulk API for bulk sending. + +### Connection options + +`smtp_host` must contain only a host name or IP address. Do not include `smtp://`, `https://`, a path, or a port. + +Choose `smtp_tls_mode` according to the provider instructions: + +- `starttls` (default) requires a STARTTLS upgrade before authentication or message submission. Its default port is 587. SQLPage fails rather than continuing without encryption when STARTTLS is unavailable. +- `tls` encrypts the connection from the beginning. Its default port is 465. Providers may call this implicit TLS, SSL/TLS, or SMTPS. +- `none` sends the message without encryption. Its default port is 25. It cannot be used with a username and password and is intended only for a trusted local server such as Mailpit. + +Set `smtp_port` when the provider specifies another port, such as 2525. `smtp_username` and `smtp_password` must either both be configured or both be omitted. + +SQLPage normally validates TLS certificates using public web PKI roots. Enable `system_root_ca_certificates`, or set `SSL_CERT_FILE` or `SSL_CERT_DIR`, to use roots installed by the system administrator, including private roots. + +### Supported and unsupported features + +SQLPage supports unauthenticated SMTP servers and username/password authentication using the SMTP `PLAIN` and `LOGIN` mechanisms. Credentials are allowed only over an encrypted connection. + +SQLPage does not currently support: + +- OAuth or XOAUTH2 authentication. If a provider only allows OAuth, it is not compatible with this function; +- CRAM-MD5, DIGEST-MD5, client-certificate authentication, or a per-server custom CA file; +- opportunistic STARTTLS, direct delivery to recipient mail servers, or receiving email; +- HTML email, a text/HTML alternative body, BCC, multiple reply-to addresses, or custom email headers; +- provider-specific headers for templates, tags, tracking, scheduling, idempotency, or metadata; +- DKIM signing inside SQLPage, S/MIME, or end-to-end encryption. The SMTP provider may add DKIM signatures; +- connection pooling, automatic retries, a persistent queue, scheduled sending, or a bulk-send API; +- a configurable SMTP command timeout or EHLO client identity; +- delivery receipts, bounce processing, suppression lists, open or click tracking, or webhooks. + +### `NULL`, empty values, and invalid input + +- SQL `NULL` is passed through: `sqlpage.send_mail(NULL)` returns SQL `NULL`, sends nothing, and does not log a warning. +- A JSON value other than an object and unknown or invalid message fields produce a JSON result with `status`, `error_code`, and `error` fields. +- `to`, `subject`, and `body` are required and cannot be JSON `null`. +- `from`, `reply_to`, and `cc` treat JSON `null` like an omitted field. If `from` is omitted, `smtp_from` must be configured. +- `attachments` can be omitted or an empty array. JSON `null` is not accepted for `attachments`. +- Empty recipient arrays, JSON `null` inside recipient arrays, invalid addresses, an empty attachment file name, invalid data URLs, and unknown attachment fields produce an error result with `status`, `error_code`, and `error`. +- Empty strings are allowed for `subject` and `body`, although an SMTP server may reject them. + +### Error codes + +`error_code` is a stable, machine-readable value. `error` is the corresponding human-readable detail. + +| `error_code` | Meaning | +| --- | --- | +| `INVALID_MESSAGE` | The argument is not a valid message object, a required field is missing, or the message cannot be constructed. | +| `SMTP_NOT_CONFIGURED` | `smtp_host` is not configured. | +| `MISSING_EMAIL_FROM` | Neither the message nor `smtp_from` provides a sender. | +| `INVALID_EMAIL_FROM` | `from` is not a valid email address. | +| `INVALID_EMAIL_TO` | `to` is empty or contains an invalid email address. | +| `INVALID_EMAIL_CC` | `cc` is empty or contains an invalid email address. | +| `INVALID_EMAIL_REPLY_TO` | `reply_to` is not a valid email address. | +| `INVALID_ATTACHMENT` | An attachment has an invalid name, data URL, media type, or exceeds the configured size limit. | +| `SMTP_TLS_FAILED` | TLS certificates could not be configured or the encrypted connection failed. | +| `SMTP_TIMEOUT` | The SMTP operation timed out. | +| `SMTP_REJECTED` | The SMTP server returned a temporary or permanent rejection, including authentication failures. | +| `SMTP_CONNECTION_FAILED` | SQLPage could not connect to or communicate with the SMTP server. | ' ); @@ -71,6 +250,6 @@ VALUES ( 'send_mail', 1, 'message', - '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.', + 'A JSON object containing the email to send. Required properties are `to` (an address or non-empty address array), `subject`, and `body`. Optional properties are `from` (required unless `smtp_from` or `SMTP_FROM` is configured), `reply_to`, `cc` (an address or non-empty address array), and `attachments` (an array of `{ "filename": "...", "data_url": "data:..." }` objects). Invalid JSON and invalid or unknown properties return `{ "status": "error", "error_code": "...", "error": "..." }`. SQL `NULL` returns SQL `NULL` without sending.', 'JSON' ); diff --git a/examples/sending emails/README.md b/examples/sending emails/README.md index 71a9d896..7c1387d5 100644 --- a/examples/sending emails/README.md +++ b/examples/sending emails/README.md @@ -23,9 +23,9 @@ set message = json_object( 'subject', :subject, 'body', :body ); -set _ = sqlpage.send_mail($message); +set result = sqlpage.send_mail($message); ``` -`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. +`sqlpage.send_mail` returns `{"status":"accepted"}` after the SMTP relay accepts the message. If validation, connection, authentication, or submission fails, it returns `{"status":"error","error_code":"...","error":"..."}` instead of stopping the request. Check `status` before reporting success. Passing SQL `NULL` returns SQL `NULL` without sending or logging a warning. 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/email.sql b/examples/sending emails/email.sql index 04e24693..a2b5e022 100644 --- a/examples/sending emails/email.sql +++ b/examples/sending emails/email.sql @@ -4,12 +4,13 @@ set message = json_object( 'subject', :subject, 'body', :body ); -set sent_message = sqlpage.send_mail($message); +set result = sqlpage.send_mail($message); select 'alert' as component, - 'success' as color, - 'Email sent successfully' as title; + case when json_extract($result, '$.status') = 'accepted' then 'success' else 'danger' end as color, + case when json_extract($result, '$.status') = 'accepted' then 'Email sent successfully' else 'Email could not be sent' end as title, + json_extract($result, '$.error') as description; 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 ef214f9c..7569a018 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -8,6 +8,7 @@ use openidconnect::IssuerUrl; use percent_encoding::AsciiSet; use serde::de::Error; use serde::{Deserialize, Deserializer, Serialize}; +use std::fmt; use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; use std::path::{Path, PathBuf}; use std::str::FromStr; @@ -78,7 +79,7 @@ impl AppConfig { config.resolve_timeouts(); - log::debug!("Loaded configuration: {config:#?}"); + log::debug!("Loaded configuration: {:#?}", RedactedSmtpPassword(&config)); log::info!( "Configuration loaded from {}", config.configuration_directory.display() @@ -183,6 +184,18 @@ impl AppConfig { } } +struct RedactedSmtpPassword<'a>(&'a AppConfig); + +impl fmt::Debug for RedactedSmtpPassword<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut config = self.0.clone(); + if config.smtp_password.is_some() { + config.smtp_password = Some("[REDACTED]".to_string()); + } + config.fmt(formatter) + } +} + pub fn load_config() -> anyhow::Result { let cli = parse_cli()?; AppConfig::from_cli(&cli) @@ -858,6 +871,16 @@ mod test { assert!(error.contains("smtp_username and smtp_password")); } + #[test] + fn smtp_password_is_redacted_from_config_debug_log() { + let mut config = tests::test_config(); + config.smtp_password = Some("super-secret".to_string()); + + let debug = format!("{:?}", RedactedSmtpPassword(&config)); + assert!(debug.contains("smtp_password: Some(\"[REDACTED]\")")); + assert!(!debug.contains("super-secret")); + } + #[test] fn test_encode_uri() { assert_eq!( diff --git a/src/webserver/database/blob_to_data_url.rs b/src/webserver/database/blob_to_data_url.rs index ae9ec9f1..c451e2ba 100644 --- a/src/webserver/database/blob_to_data_url.rs +++ b/src/webserver/database/blob_to_data_url.rs @@ -88,6 +88,14 @@ pub fn vec_to_data_uri_value(bytes: &[u8]) -> serde_json::Value { /// Decodes a data URL into its declared media type and raw bytes. pub fn decode_data_uri(data_url: &str) -> anyhow::Result<(&str, Vec)> { + decode_data_uri_with_limit(data_url, usize::MAX) +} + +/// Decodes a data URL while limiting the size of the decoded bytes. +pub fn decode_data_uri_with_limit( + data_url: &str, + max_decoded_size: usize, +) -> anyhow::Result<(&str, Vec)> { use anyhow::Context as _; let rest = data_url @@ -96,7 +104,22 @@ pub fn decode_data_uri(data_url: &str) -> anyhow::Result<(&str, Vec)> { 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 is_base64 = media_type.ends_with(";base64"); + let max_percent_decoded_size = if is_base64 { + max_decoded_size + .saturating_add(2) + .saturating_div(3) + .saturating_mul(4) + } else { + max_decoded_size + }; + let percent_decoded = percent_encoding::percent_decode(data.as_bytes()) + .take(max_percent_decoded_size.saturating_add(1)) + .collect::>(); + anyhow::ensure!( + percent_decoded.len() <= max_percent_decoded_size, + "Decoded data exceeds the limit of {max_decoded_size} bytes" + ); let bytes = if let Some(stripped) = media_type.strip_suffix(";base64") { media_type = stripped; base64::Engine::decode(&base64::engine::general_purpose::STANDARD, percent_decoded) @@ -104,6 +127,10 @@ pub fn decode_data_uri(data_url: &str) -> anyhow::Result<(&str, Vec)> { } else { percent_decoded }; + anyhow::ensure!( + bytes.len() <= max_decoded_size, + "Decoded data exceeds the limit of {max_decoded_size} bytes" + ); Ok((media_type, bytes)) } @@ -174,6 +201,16 @@ mod tests { ); } + #[test] + fn limits_decoded_data_url_size_before_decoding() { + assert_eq!( + decode_data_uri_with_limit("data:text/plain;base64,aGVsbG8=", 5).unwrap(), + ("text/plain", b"hello".to_vec()) + ); + assert!(decode_data_uri_with_limit("data:text/plain;base64,aGVsbG8=", 4).is_err()); + assert!(decode_data_uri_with_limit("data:text/plain,hello%20world", 10).is_err()); + } + #[test] fn rejects_invalid_data_urls() { assert!(decode_data_uri("text/plain,hello").is_err()); diff --git a/src/webserver/database/sqlpage_functions/functions/send_mail.rs b/src/webserver/database/sqlpage_functions/functions/send_mail.rs index c928c571..53a4fcbc 100644 --- a/src/webserver/database/sqlpage_functions/functions/send_mail.rs +++ b/src/webserver/database/sqlpage_functions/functions/send_mail.rs @@ -1,4 +1,4 @@ -use std::borrow::Cow; +use std::{borrow::Cow, fmt}; use anyhow::Context; use lettre::{ @@ -14,8 +14,8 @@ use serde::Deserialize; use crate::{ app_config::{AppConfig, SmtpTlsMode}, webserver::{ - database::blob_to_data_url::decode_data_uri, http_client::native_certificate_der, - http_request_info::RequestInfo, + database::blob_to_data_url::decode_data_uri_with_limit, + http_client::native_certificate_der, http_request_info::RequestInfo, }, }; @@ -27,19 +27,20 @@ enum Recipients { } impl Recipients { - fn parse(self, field: &str) -> anyhow::Result> { + fn parse(self, field: RecipientField) -> SendMailResult> { 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"); + if recipients.is_empty() { + return Err(field.invalid_address(None)); + } recipients .into_iter() - .enumerate() - .map(|(index, recipient)| { + .map(|recipient| { recipient .parse::() - .with_context(|| format!("Invalid {field} email address at index {index}")) + .map_err(|_| field.invalid_address(Some(recipient))) }) .collect() } @@ -72,21 +73,170 @@ struct MailRequest<'a> { attachments: Vec>, } +#[derive(Debug, Clone, Copy)] +enum RecipientField { + To, + Cc, +} + +impl RecipientField { + fn invalid_address(self, address: Option) -> SendMailError { + match self { + Self::To => SendMailError::InvalidEmailTo { address }, + Self::Cc => SendMailError::InvalidEmailCc { address }, + } + } +} + +#[derive(Debug)] +enum SendMailError { + InvalidMessage(anyhow::Error), + SmtpNotConfigured, + MissingEmailFrom, + InvalidEmailFrom { address: String }, + InvalidEmailTo { address: Option }, + InvalidEmailCc { address: Option }, + InvalidEmailReplyTo { address: String }, + InvalidAttachmentFilename { index: usize }, + InvalidAttachment { + index: usize, + reason: anyhow::Error, + }, + SmtpTlsFailed(anyhow::Error), + SmtpTimeout(anyhow::Error), + SmtpRejected(anyhow::Error), + SmtpConnectionFailed(anyhow::Error), +} + +impl SendMailError { + const fn code(&self) -> &'static str { + match self { + Self::InvalidMessage(_) => "INVALID_MESSAGE", + Self::SmtpNotConfigured => "SMTP_NOT_CONFIGURED", + Self::MissingEmailFrom => "MISSING_EMAIL_FROM", + Self::InvalidEmailFrom { .. } => "INVALID_EMAIL_FROM", + Self::InvalidEmailTo { .. } => "INVALID_EMAIL_TO", + Self::InvalidEmailCc { .. } => "INVALID_EMAIL_CC", + Self::InvalidEmailReplyTo { .. } => "INVALID_EMAIL_REPLY_TO", + Self::InvalidAttachmentFilename { .. } | Self::InvalidAttachment { .. } => { + "INVALID_ATTACHMENT" + } + Self::SmtpTlsFailed(_) => "SMTP_TLS_FAILED", + Self::SmtpTimeout(_) => "SMTP_TIMEOUT", + Self::SmtpRejected(_) => "SMTP_REJECTED", + Self::SmtpConnectionFailed(_) => "SMTP_CONNECTION_FAILED", + } + } +} + +impl fmt::Display for SendMailError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidMessage(reason) => write_reason(formatter, "Invalid email message", reason), + Self::SmtpNotConfigured => formatter.write_str( + "sqlpage.send_mail() requires the smtp_host configuration option", + ), + Self::MissingEmailFrom => formatter.write_str( + "Email has no from address; set its from property or configure smtp_from", + ), + Self::InvalidEmailFrom { address } => { + write!(formatter, "'{address}' is not a valid from email address") + } + Self::InvalidEmailTo { address } => { + write_invalid_recipient(formatter, "to", address.as_deref()) + } + Self::InvalidEmailCc { address } => { + write_invalid_recipient(formatter, "cc", address.as_deref()) + } + Self::InvalidEmailReplyTo { address } => { + write!(formatter, "'{address}' is not a valid reply_to email address") + } + Self::InvalidAttachmentFilename { index } => { + write!(formatter, "Attachment filename at index {index} must not be empty") + } + Self::InvalidAttachment { index, reason } => { + write_reason(formatter, &format!("Invalid attachment at index {index}"), reason) + } + Self::SmtpTlsFailed(reason) => { + write_reason(formatter, "Unable to establish SMTP TLS", reason) + } + Self::SmtpTimeout(reason) => { + write_reason(formatter, "SMTP operation timed out", reason) + } + Self::SmtpRejected(reason) => { + write_reason(formatter, "SMTP server rejected the email", reason) + } + Self::SmtpConnectionFailed(reason) => { + write_reason(formatter, "Unable to communicate with the SMTP server", reason) + } + } + } +} + +type SendMailResult = Result; + +fn write_invalid_recipient( + formatter: &mut fmt::Formatter<'_>, + field: &str, + address: Option<&str>, +) -> fmt::Result { + match address { + Some(address) => write!(formatter, "'{address}' is not a valid {field} email address"), + None => write!(formatter, "{field} must contain at least one email address"), + } +} + +fn write_reason( + formatter: &mut fmt::Formatter<'_>, + context: &str, + reason: &anyhow::Error, +) -> fmt::Result { + write!(formatter, "{context}: ")?; + if formatter.alternate() { + write!(formatter, "{reason:#}") + } else { + fmt::Display::fmt(reason, formatter) + } +} + /// Sends an email through the configured SMTP relay. pub(super) async fn send_mail( request: &RequestInfo, - mail_request: Cow<'_, str>, -) -> anyhow::Result<()> { - send_mail_with_config(&request.app_state.config, &mail_request).await + mail_request: Option>, +) -> Option { + let mail_request = mail_request?; + Some(send_mail_result_json( + send_mail_with_config(&request.app_state.config, &mail_request).await, + )) +} + +fn send_mail_result_json(result: SendMailResult<()>) -> String { + match result { + Ok(()) => serde_json::json!({ "status": "accepted" }).to_string(), + Err(error) => { + let message = format!("{error:#}"); + log::warn!( + "sqlpage.send_mail failed with {}: {message}", + error.code() + ); + serde_json::json!({ + "status": "error", + "error_code": error.code(), + "error": message, + }) + .to_string() + } + } } -async fn send_mail_with_config(config: &AppConfig, mail_request: &str) -> anyhow::Result<()> { +async fn send_mail_with_config(config: &AppConfig, mail_request: &str) -> SendMailResult<()> { let host = config .smtp_host .as_deref() - .context("sqlpage.send_mail() requires the smtp_host configuration option")?; + .ok_or(SendMailError::SmtpNotConfigured)?; let parsed: MailRequest<'_> = serde_json::from_str(mail_request) - .context("sqlpage.send_mail() expects a JSON object")?; + .context("sqlpage.send_mail() expects a valid JSON object") + .map_err(SendMailError::InvalidMessage)?; let email = build_email(config, parsed)?; @@ -101,16 +251,27 @@ async fn send_mail_with_config(config: &AppConfig, mail_request: &str) -> anyhow mailer = mailer.credentials(Credentials::new(username.clone(), password.clone())); } - let response = mailer - .build() - .send(email) - .await - .with_context(|| format!("Unable to send email through {host}:{port}"))?; + let response = mailer.build().send(email).await.map_err(|error| { + let is_timeout = error.is_timeout(); + let is_tls = error.is_tls(); + let is_rejected = error.is_transient() || error.is_permanent(); + let reason = anyhow::Error::new(error) + .context(format!("Unable to send email through {host}:{port}")); + if is_timeout { + SendMailError::SmtpTimeout(reason) + } else if is_tls { + SendMailError::SmtpTlsFailed(reason) + } else if is_rejected { + SendMailError::SmtpRejected(reason) + } else { + SendMailError::SmtpConnectionFailed(reason) + } + })?; log::debug!("SMTP relay accepted email: {response:?}"); Ok(()) } -fn build_email(config: &AppConfig, request: MailRequest<'_>) -> anyhow::Result { +fn build_email(config: &AppConfig, request: MailRequest<'_>) -> SendMailResult { let MailRequest { to, cc, @@ -124,58 +285,61 @@ fn build_email(config: &AppConfig, request: MailRequest<'_>) -> anyhow::Result() - .context("Invalid from email address")?; + .map_err(|_| SendMailError::InvalidEmailFrom { + address: sender.to_string(), + })?; let mut email = Message::builder() .from(sender) .subject(subject.as_ref()); - for recipient in to.parse("to")? { + for recipient in to.parse(RecipientField::To)? { email = email.to(recipient); } if let Some(cc) = cc { - for recipient in cc.parse("cc")? { + for recipient in cc.parse(RecipientField::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")?, - ); + let parsed_reply_to = reply_to.parse::().map_err(|_| { + SendMailError::InvalidEmailReplyTo { + address: reply_to.to_string(), + } + })?; + email = email.reply_to(parsed_reply_to); } if attachments.is_empty() { return email .header(ContentType::TEXT_PLAIN) .body(body.into_owned()) - .context("Unable to build email message"); + .context("Unable to build email message") + .map_err(SendMailError::InvalidMessage); } - let mut total_attachment_size = 0usize; + let mut remaining_attachment_size = config.max_email_attachment_size; 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 - ); + if attachment.filename.is_empty() { + return Err(SendMailError::InvalidAttachmentFilename { index }); + } + let (media_type, bytes) = + decode_data_uri_with_limit(&attachment.data_url, remaining_attachment_size) + .with_context(|| format!("Invalid attachment data_url at index {index}")) + .map_err(|reason| SendMailError::InvalidAttachment { index, reason })?; + remaining_attachment_size = remaining_attachment_size + .checked_sub(bytes.len()) + .context("Attachments exceed max_email_attachment_size") + .map_err(|reason| SendMailError::InvalidAttachment { index, reason })?; 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}"))?; + .with_context(|| format!("Invalid attachment media type at index {index}")) + .map_err(|reason| SendMailError::InvalidAttachment { index, reason })?; multipart = multipart.singlepart(Attachment::new(attachment.filename.into_owned()).body( bytes, content_type, @@ -184,9 +348,10 @@ fn build_email(config: &AppConfig, request: MailRequest<'_>) -> anyhow::Result anyhow::Result { +fn smtp_tls(config: &AppConfig, host: &str) -> SendMailResult { if config.smtp_tls_mode == SmtpTlsMode::None { return Ok(Tls::None); } @@ -194,10 +359,11 @@ fn smtp_tls(config: &AppConfig, host: &str) -> anyhow::Result { 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()? { + for certificate in native_certificate_der().map_err(SendMailError::SmtpTlsFailed)? { parameters = parameters.add_root_certificate( Certificate::from_der(certificate.as_ref().to_vec()) - .context("Unable to configure an SMTP root certificate")?, + .context("Unable to configure an SMTP root certificate") + .map_err(SendMailError::SmtpTlsFailed)?, ); } } else { @@ -205,7 +371,8 @@ fn smtp_tls(config: &AppConfig, host: &str) -> anyhow::Result { } let parameters = parameters .build_rustls() - .context("Unable to configure SMTP TLS")?; + .context("Unable to configure SMTP TLS") + .map_err(SendMailError::SmtpTlsFailed)?; Ok(match config.smtp_tls_mode { SmtpTlsMode::Starttls => Tls::Required(parameters), SmtpTlsMode::Tls => Tls::Wrapper(parameters), @@ -222,7 +389,9 @@ mod tests { thread, }; - use super::{SmtpTlsMode, send_mail_with_config}; + use super::{ + SendMailError, SmtpTlsMode, send_mail_result_json, send_mail_with_config, + }; use crate::app_config::tests::test_config; #[tokio::test] @@ -260,7 +429,79 @@ mod tests { ) .await .unwrap_err(); - assert!(error.to_string().contains("expects a JSON object")); + assert!(matches!(&error, SendMailError::InvalidMessage(_))); + assert!(error.to_string().contains("expects a valid JSON object")); + } + + #[tokio::test] + async fn reports_the_invalid_address_field() { + let mut config = test_config(); + config.smtp_host = Some("localhost".to_string()); + let error = send_mail_with_config( + &config, + r#"{ + "to":"xxx", + "from":"contact@example.com", + "subject":"test", + "body":"hello" + }"#, + ) + .await + .unwrap_err(); + + assert!(matches!( + &error, + SendMailError::InvalidEmailTo { + address: Some(address) + } if address == "xxx" + )); + assert_eq!(error.to_string(), "'xxx' is not a valid to email address"); + } + + #[tokio::test] + async fn rejects_combined_attachments_over_decoded_size_limit() { + let mut config = test_config(); + config.smtp_host = Some("localhost".to_string()); + config.max_email_attachment_size = 4; + let error = send_mail_with_config( + &config, + r#"{ + "to":"admin@example.com", + "from":"contact@example.com", + "subject":"test", + "body":"hello", + "attachments":[ + {"filename":"one.txt","data_url":"data:text/plain;base64,YWJj"}, + {"filename":"two.txt","data_url":"data:text/plain;base64,ZGVm"} + ] + }"#, + ) + .await + .unwrap_err(); + assert!(matches!( + &error, + SendMailError::InvalidAttachment { .. } + )); + assert!(format!("{error:#}").contains("Decoded data exceeds the limit of 1 bytes")); + } + + #[test] + fn serializes_success_and_errors_as_json() { + assert_eq!( + serde_json::from_str::(&send_mail_result_json(Ok(()))).unwrap(), + serde_json::json!({ "status": "accepted" }) + ); + assert_eq!( + serde_json::from_str::(&send_mail_result_json(Err( + SendMailError::SmtpConnectionFailed(anyhow::anyhow!("SMTP failed")) + ))) + .unwrap(), + serde_json::json!({ + "status": "error", + "error_code": "SMTP_CONNECTION_FAILED", + "error": "Unable to communicate with the SMTP server: SMTP failed", + }) + ); } fn start_smtp_server() -> (String, u16, mpsc::Receiver) { diff --git a/tests/sql_test_files/data/send_mail_null.sql b/tests/sql_test_files/data/send_mail_null.sql new file mode 100644 index 00000000..96ca11e0 --- /dev/null +++ b/tests/sql_test_files/data/send_mail_null.sql @@ -0,0 +1,2 @@ +select null as expected, + sqlpage.send_mail(null) as actual; From e8e590d340552a6c709a57d26a9f7c4e5a69a002 Mon Sep 17 00:00:00 2001 From: Ophir LOJKINE Date: Fri, 17 Jul 2026 15:36:15 +0200 Subject: [PATCH 6/9] Expand sending email example --- examples/sending emails/README.md | 19 ++-- examples/sending emails/advanced.sql | 20 +++++ examples/sending emails/docker-compose.yml | 1 + examples/sending emails/index.sql | 21 +++-- examples/sending emails/send-advanced.sql | 29 ++++++ .../{email.sql => send-simple.sql} | 6 +- examples/sending emails/simple.sql | 15 ++++ examples/sending emails/test-attachment.txt | 1 + examples/sending emails/test.hurl | 90 +++++++++++++++++-- 9 files changed, 178 insertions(+), 24 deletions(-) create mode 100644 examples/sending emails/advanced.sql create mode 100644 examples/sending emails/send-advanced.sql rename examples/sending emails/{email.sql => send-simple.sql} (70%) create mode 100644 examples/sending emails/simple.sql create mode 100644 examples/sending emails/test-attachment.txt diff --git a/examples/sending emails/README.md b/examples/sending emails/README.md index 7c1387d5..4f63f856 100644 --- a/examples/sending emails/README.md +++ b/examples/sending emails/README.md @@ -8,24 +8,33 @@ Run the example: docker compose up ``` -Open http://localhost:8080 to send an email, then inspect it in the Mailpit inbox at http://localhost:8025. +Open http://localhost:8080 and choose one of two flows, then inspect the message in the Mailpit inbox at http://localhost:8025: -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. +- **Simple email** sends to one recipient with the `SMTP_FROM` sender configured in Docker Compose. +- **Advanced email** demonstrates multiple recipients, Cc, Reply-To, a per-message sender override, and an uploaded attachment. + +The SMTP server is configured in [`docker-compose.yml`](./docker-compose.yml) with `SMTP_HOST=mailpit`, `SMTP_PORT=1025`, `SMTP_TLS_MODE=none`, and a default `SMTP_FROM`. 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. -The form handler sends the message with a single function call: +The simple handler omits `from`, so SQLPage uses the configured `SMTP_FROM`: ```sql set message = json_object( 'to', :recipient, - 'from', :sender, 'subject', :subject, 'body', :body ); set result = sqlpage.send_mail($message); ``` +The advanced handler turns the temporary uploaded file into the data URL expected by an attachment: + +```sql +set attachment_path = sqlpage.uploaded_file_path('attachment'); +set attachment_data_url = sqlpage.read_file_as_data_url($attachment_path); +``` + `sqlpage.send_mail` returns `{"status":"accepted"}` after the SMTP relay accepts the message. If validation, connection, authentication, or submission fails, it returns `{"status":"error","error_code":"...","error":"..."}` instead of stopping the request. Check `status` before reporting success. Passing SQL `NULL` returns SQL `NULL` without sending or logging a warning. -Do not expose an unrestricted form like this publicly. In production, authenticate users, restrict recipients, validate input, and add rate limiting to prevent abuse. +Do not expose unrestricted forms like these publicly. In production, authenticate users, restrict recipients, validate input and uploaded files, and add rate limiting to prevent abuse. diff --git a/examples/sending emails/advanced.sql b/examples/sending emails/advanced.sql new file mode 100644 index 00000000..a6b9f47b --- /dev/null +++ b/examples/sending emails/advanced.sql @@ -0,0 +1,20 @@ +select 'shell' as component, 'Send an advanced email' as title; + +select + 'form' as component, + 'Advanced email' as title, + 'send-advanced.sql' as action, + 'post' as method, + 'Send email' as validate; + +select 'email' as type, 'recipient' as name, 'To' as label, 'first@example.com' as value, true as required; +select 'email' as type, 'second_recipient' as name, 'Second recipient' as label, 'second@example.com' as value, true as required; +select 'email' as type, 'cc' as name, 'Cc' as label, 'team@example.com' as value, true as required; +select 'email' as type, 'sender' as name, 'From override' as label, 'advanced@example.com' as value, true as required; +select 'email' as type, 'reply_to' as name, 'Reply-To' as label, 'replies@example.com' as value, true as required; +select 'subject' as name, 'Subject' as label, 'Advanced SMTP demo' as value, true as required; +select 'textarea' as type, 'body' as name, 'Message' as label, 'Sent to a team with an attachment' as value, true as required; +select 'file' as type, 'attachment' as name, 'Attachment' as label, true as required; + +select 'button' as component; +select 'Back to examples' as title, 'index.sql' as link, 'arrow-left' as icon; diff --git a/examples/sending emails/docker-compose.yml b/examples/sending emails/docker-compose.yml index b1709098..bca61ef9 100644 --- a/examples/sending emails/docker-compose.yml +++ b/examples/sending emails/docker-compose.yml @@ -7,6 +7,7 @@ services: SMTP_HOST: mailpit SMTP_PORT: 1025 SMTP_TLS_MODE: none + SMTP_FROM: SQLPage Demo volumes: - .:/var/www depends_on: diff --git a/examples/sending emails/index.sql b/examples/sending emails/index.sql index d583b7ad..cb88ff46 100644 --- a/examples/sending emails/index.sql +++ b/examples/sending emails/index.sql @@ -1,10 +1,15 @@ +select 'shell' as component, 'Sending emails with SQLPage' as title; + select - 'form' as component, - 'Send an email through SMTP' as title, - 'email.sql' as action, - 'post' as method; + 'text' as component, + 'Choose a focused example. Both send through the SMTP server configured for SQLPage.' as contents; -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; +select 'card' as component, 2 as columns; +select + 'Simple email' as title, + 'Send a plain-text message using the configured default sender.' as description, + 'simple.sql' as link; +select + 'Advanced email' as title, + 'Add multiple recipients, Cc, Reply-To, a sender override, and an uploaded attachment.' as description, + 'advanced.sql' as link; diff --git a/examples/sending emails/send-advanced.sql b/examples/sending emails/send-advanced.sql new file mode 100644 index 00000000..e13b2e2e --- /dev/null +++ b/examples/sending emails/send-advanced.sql @@ -0,0 +1,29 @@ +set attachment_name = sqlpage.uploaded_file_name('attachment'); +set attachment_path = sqlpage.uploaded_file_path('attachment'); +set attachment_data_url = sqlpage.read_file_as_data_url($attachment_path); + +set message = json_object( + 'to', json_array(:recipient, :second_recipient), + 'cc', :cc, + 'from', :sender, + 'reply_to', :reply_to, + 'subject', :subject, + 'body', :body, + 'attachments', json_array(json_object( + 'filename', $attachment_name, + 'data_url', $attachment_data_url + )) +); +set result = sqlpage.send_mail($message); + +select 'shell' as component, 'Advanced email result' as title; + +select + 'alert' as component, + case when json_extract($result, '$.status') = 'accepted' then 'success' else 'danger' end as color, + case when json_extract($result, '$.status') = 'accepted' then 'Email sent successfully' else 'Email could not be sent' end as title, + json_extract($result, '$.error') as description; + +select 'button' as component; +select 'Send another advanced email' as title, 'advanced.sql' as link; +select 'Back to examples' as title, 'index.sql' as link, 'secondary' as color; diff --git a/examples/sending emails/email.sql b/examples/sending emails/send-simple.sql similarity index 70% rename from examples/sending emails/email.sql rename to examples/sending emails/send-simple.sql index a2b5e022..ed59bb4b 100644 --- a/examples/sending emails/email.sql +++ b/examples/sending emails/send-simple.sql @@ -1,11 +1,12 @@ set message = json_object( 'to', :recipient, - 'from', :sender, 'subject', :subject, 'body', :body ); set result = sqlpage.send_mail($message); +select 'shell' as component, 'Simple email result' as title; + select 'alert' as component, case when json_extract($result, '$.status') = 'accepted' then 'success' else 'danger' end as color, @@ -13,4 +14,5 @@ select json_extract($result, '$.error') as description; select 'button' as component; -select 'Send another email' as title, 'index.sql' as link; +select 'Send another simple email' as title, 'simple.sql' as link; +select 'Back to examples' as title, 'index.sql' as link, 'secondary' as color; diff --git a/examples/sending emails/simple.sql b/examples/sending emails/simple.sql new file mode 100644 index 00000000..ab0b5be1 --- /dev/null +++ b/examples/sending emails/simple.sql @@ -0,0 +1,15 @@ +select 'shell' as component, 'Send a simple email' as title; + +select + 'form' as component, + 'Simple email' as title, + 'send-simple.sql' as action, + 'post' as method, + 'Send email' as validate; + +select 'email' as type, 'recipient' as name, 'To' as label, 'recipient@example.com' as value, true as required; +select 'subject' as name, 'Subject' as label, 'Simple SMTP demo' as value, true as required; +select 'textarea' as type, 'body' as name, 'Message' as label, 'Sent with sqlpage.send_mail' as value, true as required; + +select 'button' as component; +select 'Back to examples' as title, 'index.sql' as link, 'arrow-left' as icon; diff --git a/examples/sending emails/test-attachment.txt b/examples/sending emails/test-attachment.txt new file mode 100644 index 00000000..bdc2a85f --- /dev/null +++ b/examples/sending emails/test-attachment.txt @@ -0,0 +1 @@ +This attachment was sent by the SQLPage email example. diff --git a/examples/sending emails/test.hurl b/examples/sending emails/test.hurl index b1c2eb7a..e526e6ad 100644 --- a/examples/sending emails/test.hurl +++ b/examples/sending emails/test.hurl @@ -1,24 +1,96 @@ +DELETE http://127.0.0.1:8025/api/v1/messages +HTTP 200 + GET http://127.0.0.1:8080/ HTTP 200 [Asserts] -xpath "string(//form/@action)" == "email.sql" +xpath "string(//a[contains(., 'Simple email')]/@href)" == "simple.sql" +xpath "string(//a[contains(., 'Advanced email')]/@href)" == "advanced.sql" + +GET http://127.0.0.1:8080/simple.sql +HTTP 200 +[Asserts] +xpath "string(//form/@action)" == "send-simple.sql" 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='body'])" == "This is a test email" +xpath "string(//input[@name='subject']/@value)" == "Simple SMTP demo" +xpath "string(//textarea[@name='body'])" == "Sent with sqlpage.send_mail" -POST http://127.0.0.1:8080/email.sql +POST http://127.0.0.1:8080/send-simple.sql [FormParams] recipient: recipient@example.com -sender: SQLPage -subject: SMTP demo -body: Sent with sqlpage.send_mail +subject: Simple SMTP demo +body: Sent with the configured sender 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 +GET http://127.0.0.1:8025/api/v1/search?query=subject%3A%22Simple%20SMTP%20demo%22 HTTP 200 +[Captures] +simple_message_id: jsonpath "$.messages[0].ID" [Asserts] -jsonpath "$.messages[0].Subject" == "SMTP demo" +jsonpath "$.messages_count" == 1 +jsonpath "$.messages[0].Subject" == "Simple SMTP demo" jsonpath "$.messages[0].To[0].Address" == "recipient@example.com" + +GET http://127.0.0.1:8025/api/v1/message/{{simple_message_id}} +HTTP 200 +[Asserts] +jsonpath "$.From.Name" == "SQLPage Demo" +jsonpath "$.From.Address" == "sqlpage@example.com" +jsonpath "$.Text" contains "Sent with the configured sender" +jsonpath "$.Attachments" count == 0 + +GET http://127.0.0.1:8080/advanced.sql +HTTP 200 +[Asserts] +xpath "string(//form/@action)" == "send-advanced.sql" +xpath "string(//form/@method)" == "post" +xpath "string(//form//button[@type='submit']/@formenctype)" == "multipart/form-data" +xpath "string(//input[@name='second_recipient']/@value)" == "second@example.com" +xpath "string(//input[@name='cc']/@value)" == "team@example.com" +xpath "string(//input[@name='reply_to']/@value)" == "replies@example.com" +xpath "string(//input[@name='attachment']/@type)" == "file" + +POST http://127.0.0.1:8080/send-advanced.sql +[Multipart] +recipient: first@example.com +second_recipient: second@example.com +cc: team@example.com +sender: advanced@example.com +reply_to: replies@example.com +subject: Advanced SMTP demo +body: Sent to a team with an attachment +attachment: file,test-attachment.txt; text/plain +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%22Advanced%20SMTP%20demo%22 +HTTP 200 +[Captures] +advanced_message_id: jsonpath "$.messages[0].ID" +[Asserts] +jsonpath "$.messages_count" == 1 +jsonpath "$.messages[0].Attachments" == 1 + +GET http://127.0.0.1:8025/api/v1/message/{{advanced_message_id}} +HTTP 200 +[Captures] +attachment_part_id: jsonpath "$.Attachments[0].PartID" +[Asserts] +jsonpath "$.Subject" == "Advanced SMTP demo" +jsonpath "$.From.Address" == "advanced@example.com" +jsonpath "$.To[0].Address" == "first@example.com" +jsonpath "$.To[1].Address" == "second@example.com" +jsonpath "$.Cc[0].Address" == "team@example.com" +jsonpath "$.ReplyTo[0].Address" == "replies@example.com" +jsonpath "$.Text" contains "Sent to a team with an attachment" +jsonpath "$.Attachments[0].FileName" == "test-attachment.txt" +jsonpath "$.Attachments[0].ContentType" == "text/plain" + +GET http://127.0.0.1:8025/api/v1/message/{{advanced_message_id}}/part/{{attachment_part_id}} +HTTP 200 +[Asserts] +body contains "This attachment was sent by the SQLPage email example." From e71fe455d39668b69ec402d41281369b21eb0fb5 Mon Sep 17 00:00:00 2001 From: Ophir LOJKINE Date: Fri, 17 Jul 2026 21:14:34 +0200 Subject: [PATCH 7/9] build mail example from repo source --- examples/sending emails/README.md | 4 +++- examples/sending emails/docker-compose.yml | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/examples/sending emails/README.md b/examples/sending emails/README.md index 4f63f856..d7e85bdc 100644 --- a/examples/sending emails/README.md +++ b/examples/sending emails/README.md @@ -5,9 +5,11 @@ This example sends plain-text email with [`sqlpage.send_mail`](https://sql-page. Run the example: ```sh -docker compose up +docker compose up --build ``` +This builds SQLPage from the current repository checkout before starting the example. + Open http://localhost:8080 and choose one of two flows, then inspect the message in the Mailpit inbox at http://localhost:8025: - **Simple email** sends to one recipient with the `SMTP_FROM` sender configured in Docker Compose. diff --git a/examples/sending emails/docker-compose.yml b/examples/sending emails/docker-compose.yml index bca61ef9..7c3e0a9f 100644 --- a/examples/sending emails/docker-compose.yml +++ b/examples/sending emails/docker-compose.yml @@ -1,6 +1,8 @@ services: web: image: lovasoa/sqlpage:main + build: + context: ../.. ports: - "8080:8080" environment: From 71d0837117c1a7a417abcee1f8f7600ff842c43f Mon Sep 17 00:00:00 2001 From: Ophir LOJKINE Date: Fri, 17 Jul 2026 21:44:52 +0200 Subject: [PATCH 8/9] Link email results to Mailpit --- examples/sending emails/send-advanced.sql | 2 ++ examples/sending emails/send-simple.sql | 2 ++ examples/sending emails/test.hurl | 6 ++++-- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/examples/sending emails/send-advanced.sql b/examples/sending emails/send-advanced.sql index e13b2e2e..dfc1e00c 100644 --- a/examples/sending emails/send-advanced.sql +++ b/examples/sending emails/send-advanced.sql @@ -20,10 +20,12 @@ select 'shell' as component, 'Advanced email result' as title; select 'alert' as component, + 'send-result' as id, case when json_extract($result, '$.status') = 'accepted' then 'success' else 'danger' end as color, case when json_extract($result, '$.status') = 'accepted' then 'Email sent successfully' else 'Email could not be sent' end as title, json_extract($result, '$.error') as description; select 'button' as component; +select 'Open Mailpit inbox' as title, 'http://localhost:8025' as link, '_blank' as target, 'inbox' as icon, 'primary' as color; select 'Send another advanced email' as title, 'advanced.sql' as link; select 'Back to examples' as title, 'index.sql' as link, 'secondary' as color; diff --git a/examples/sending emails/send-simple.sql b/examples/sending emails/send-simple.sql index ed59bb4b..0fe24488 100644 --- a/examples/sending emails/send-simple.sql +++ b/examples/sending emails/send-simple.sql @@ -9,10 +9,12 @@ select 'shell' as component, 'Simple email result' as title; select 'alert' as component, + 'send-result' as id, case when json_extract($result, '$.status') = 'accepted' then 'success' else 'danger' end as color, case when json_extract($result, '$.status') = 'accepted' then 'Email sent successfully' else 'Email could not be sent' end as title, json_extract($result, '$.error') as description; select 'button' as component; +select 'Open Mailpit inbox' as title, 'http://localhost:8025' as link, '_blank' as target, 'inbox' as icon, 'primary' as color; select 'Send another simple email' as title, 'simple.sql' as link; select 'Back to examples' as title, 'index.sql' as link, 'secondary' as color; diff --git a/examples/sending emails/test.hurl b/examples/sending emails/test.hurl index e526e6ad..eaec38da 100644 --- a/examples/sending emails/test.hurl +++ b/examples/sending emails/test.hurl @@ -23,7 +23,8 @@ subject: Simple SMTP demo body: Sent with the configured sender HTTP 200 [Asserts] -xpath "string(//*[contains(@class, 'alert') and contains(., 'Email sent successfully')])" contains "Email sent successfully" +xpath "normalize-space(//*[@id='send-result'])" == "Email sent successfully" +xpath "string(//a[normalize-space(.)='Open Mailpit inbox']/@href)" == "http://localhost:8025" GET http://127.0.0.1:8025/api/v1/search?query=subject%3A%22Simple%20SMTP%20demo%22 HTTP 200 @@ -65,7 +66,8 @@ body: Sent to a team with an attachment attachment: file,test-attachment.txt; text/plain HTTP 200 [Asserts] -xpath "string(//*[contains(@class, 'alert') and contains(., 'Email sent successfully')])" contains "Email sent successfully" +xpath "normalize-space(//*[@id='send-result'])" == "Email sent successfully" +xpath "string(//a[normalize-space(.)='Open Mailpit inbox']/@href)" == "http://localhost:8025" GET http://127.0.0.1:8025/api/v1/search?query=subject%3A%22Advanced%20SMTP%20demo%22 HTTP 200 From 889277f9428218fdf9e2532b844a97b57fe4b991 Mon Sep 17 00:00:00 2001 From: Ophir LOJKINE Date: Fri, 17 Jul 2026 21:46:11 +0200 Subject: [PATCH 9/9] Support dev profile in Docker builds --- examples/sending emails/docker-compose.yml | 2 ++ scripts/build-project.sh | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/examples/sending emails/docker-compose.yml b/examples/sending emails/docker-compose.yml index 7c3e0a9f..dc8955e7 100644 --- a/examples/sending emails/docker-compose.yml +++ b/examples/sending emails/docker-compose.yml @@ -3,6 +3,8 @@ services: image: lovasoa/sqlpage:main build: context: ../.. + args: + CARGO_PROFILE: dev ports: - "8080:8080" environment: diff --git a/scripts/build-project.sh b/scripts/build-project.sh index f9a8781a..66584fe9 100755 --- a/scripts/build-project.sh +++ b/scripts/build-project.sh @@ -4,6 +4,10 @@ set -euo pipefail source /tmp/build-env.sh PROFILE="${CARGO_PROFILE:-superoptimized}" +OUTPUT_DIR="$PROFILE" +if [ "$PROFILE" = "dev" ]; then + OUTPUT_DIR="debug" +fi echo "Building project for target: $TARGET (profile: $PROFILE)" cargo build \ @@ -12,4 +16,4 @@ cargo build \ --features odbc-static \ --profile "$PROFILE" -mv "target/$TARGET/$PROFILE/sqlpage" sqlpage.bin +mv "target/$TARGET/$OUTPUT_DIR/sqlpage" sqlpage.bin