Harden send_mail API and TLS configuration
This commit is contained in:
Generated
+6
-83
@@ -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"
|
||||
|
||||
+8
-1
@@ -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"
|
||||
|
||||
+4
-2
@@ -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. |
|
||||
|
||||
@@ -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 <jane@example.com>"`.
|
||||
- `to`: email address to send to, optionally including a display name such as `"Jane Doe <jane@example.com>"`.
|
||||
- `subject`: email subject.
|
||||
- `body`: plain text email body.
|
||||
|
||||
Optional properties:
|
||||
|
||||
- `sender`: sender address. Defaults to `SQLPage <sqlpage@localhost>`.
|
||||
- `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'
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
+57
-16
@@ -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::<lettre::message::Mailbox>()
|
||||
.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<String>,
|
||||
|
||||
/// SMTP server port. Defaults to 25 for plaintext, 465 for implicit TLS, and 587 for STARTTLS.
|
||||
pub smtp_port: Option<u16>,
|
||||
|
||||
/// 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<String>,
|
||||
@@ -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<String>,
|
||||
|
||||
/// Default sender used by the `sqlpage.send_mail` function when the message has no `from` property.
|
||||
pub smtp_from: Option<String>,
|
||||
|
||||
/// 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::<u16>().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::<IpAddr>().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<SocketAddr> {
|
||||
@@ -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!(
|
||||
|
||||
@@ -210,6 +210,12 @@ impl<'a, 'b: 'a> IntoCow<'a> for &'b str {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> IntoCow<'a> for () {
|
||||
fn into_cow(self) -> Option<Cow<'a, str>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, T: IntoCow<'a>> IntoCow<'a> for Option<T> {
|
||||
fn into_cow(self) -> Option<Cow<'a, str>> {
|
||||
self.and_then(IntoCow::into_cow)
|
||||
|
||||
@@ -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<Cow<'a, str>>,
|
||||
#[serde(borrow, default, rename = "from")]
|
||||
from: Option<Cow<'a, str>>,
|
||||
#[serde(borrow, default)]
|
||||
reply_to: Option<Cow<'a, str>>,
|
||||
}
|
||||
|
||||
/// 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<Cow<'a, str>>,
|
||||
) -> anyhow::Result<Option<Cow<'a, str>>> {
|
||||
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<Cow<'a, str>>,
|
||||
) -> anyhow::Result<Option<Cow<'a, str>>> {
|
||||
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 <sqlpage@localhost>")
|
||||
.or(config.smtp_from.as_deref())
|
||||
.context("Email has no from address; set its from property or configure smtp_from")?
|
||||
.parse::<Mailbox>()
|
||||
.context("Invalid sender email address")?;
|
||||
let recipient = parsed_mail_request
|
||||
.recipient
|
||||
.context("Invalid from email address")?;
|
||||
let recipient = parsed
|
||||
.to
|
||||
.parse::<Mailbox>()
|
||||
.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::<Mailbox>()
|
||||
@@ -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::<Tokio1Executor>::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::<Tokio1Executor>::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<Tls> {
|
||||
if config.smtp_tls_mode == SmtpTlsMode::None {
|
||||
return Ok(Tls::None);
|
||||
}
|
||||
|
||||
let mut parameters = TlsParameters::builder(host.to_string());
|
||||
if config.system_root_ca_certificates {
|
||||
parameters = parameters.certificate_store(CertificateStore::None);
|
||||
for certificate in native_certificate_der()? {
|
||||
parameters = parameters.add_root_certificate(
|
||||
Certificate::from_der(certificate.as_ref().to_vec())
|
||||
.context("Unable to configure an SMTP root certificate")?,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
parameters = parameters.certificate_store(CertificateStore::WebpkiRoots);
|
||||
}
|
||||
let parameters = parameters
|
||||
.build_rustls()
|
||||
.context("Unable to configure SMTP TLS")?;
|
||||
Ok(match config.smtp_tls_mode {
|
||||
SmtpTlsMode::Starttls => Tls::Required(parameters),
|
||||
SmtpTlsMode::Tls => Tls::Wrapper(parameters),
|
||||
SmtpTlsMode::None => unreachable!(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
io::{BufRead, BufReader, Write},
|
||||
net::{TcpListener, TcpStream},
|
||||
sync::mpsc,
|
||||
@@ -120,72 +135,63 @@ mod tests {
|
||||
use crate::app_config::tests::test_config;
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_mail_authenticates_to_plaintext_smtp_server_when_explicitly_enabled() {
|
||||
let (host, received) = start_authenticated_smtp_server("user", "secret");
|
||||
async fn sends_plain_text_email_to_configured_relay() {
|
||||
let (host, port, received) = start_smtp_server();
|
||||
let mut config = test_config();
|
||||
config.smtp_host = Some(host);
|
||||
config.smtp_username = Some("user".to_string());
|
||||
config.smtp_password = Some("secret".to_string());
|
||||
config.smtp_port = Some(port);
|
||||
config.smtp_tls_mode = SmtpTlsMode::None;
|
||||
|
||||
let mail_request = Cow::Borrowed(
|
||||
r#"{
|
||||
"recipient": "admin@example.com",
|
||||
"sender": "contact@example.com",
|
||||
"subject": "Authenticated SMTP",
|
||||
"body": "hello authenticated smtp"
|
||||
}"#,
|
||||
);
|
||||
let result = send_mail_with_config(
|
||||
send_mail_with_config(
|
||||
&config,
|
||||
Some(mail_request.clone()),
|
||||
r#"{
|
||||
"to": "admin@example.com",
|
||||
"from": "contact@example.com",
|
||||
"subject": "SMTP test",
|
||||
"body": "hello smtp"
|
||||
}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, Some(mail_request));
|
||||
let smtp_session = received.recv().unwrap();
|
||||
assert!(smtp_session.authenticated, "SMTP AUTH was not used");
|
||||
assert!(smtp_session.data.contains("Authenticated SMTP"));
|
||||
assert!(smtp_session.data.contains("hello authenticated smtp"));
|
||||
let data = received.recv().unwrap();
|
||||
assert!(data.contains("Subject: SMTP test"));
|
||||
assert!(data.contains("hello smtp"));
|
||||
}
|
||||
|
||||
struct SmtpSession {
|
||||
authenticated: bool,
|
||||
data: String,
|
||||
#[tokio::test]
|
||||
async fn rejects_unknown_message_fields() {
|
||||
let mut config = test_config();
|
||||
config.smtp_host = Some("localhost".to_string());
|
||||
let error = send_mail_with_config(
|
||||
&config,
|
||||
r#"{"recipient":"admin@example.com","subject":"test","body":"hello"}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("expects a JSON object"));
|
||||
}
|
||||
|
||||
fn start_authenticated_smtp_server(username: &str, password: &str) -> (String, mpsc::Receiver<SmtpSession>) {
|
||||
fn start_smtp_server() -> (String, u16, mpsc::Receiver<String>) {
|
||||
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 <CR><LF>.<CR><LF>");
|
||||
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<TcpStream>) -> 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}"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,12 @@ use anyhow::{Context, anyhow};
|
||||
use rustls_native_certs::CertificateResult;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static NATIVE_CERTS: OnceLock<anyhow::Result<rustls::RootCertStore>> = OnceLock::new();
|
||||
struct NativeCertificates {
|
||||
certificates: Vec<rustls::pki_types::CertificateDer<'static>>,
|
||||
root_store: rustls::RootCertStore,
|
||||
}
|
||||
|
||||
static NATIVE_CERTIFICATES: OnceLock<anyhow::Result<NativeCertificates>> = OnceLock::new();
|
||||
|
||||
pub fn make_http_client(config: &crate::app_config::AppConfig) -> anyhow::Result<awc::Client> {
|
||||
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<awc::Client> {
|
||||
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={:?}",
|
||||
|
||||
Reference in New Issue
Block a user