From c6554a213ddb03c92d5d45dbfaccc4aa971f6b1c Mon Sep 17 00:00:00 2001
From: lovasoa
Date: Fri, 24 Jul 2026 23:51:43 +0200
Subject: [PATCH] Add HTML body alternative to send_mail
The optional `body_html` field sends a multipart/alternative
message alongside the plain-text `body`, which remains required.
---
CHANGELOG.md | 6 +-
.../sqlpage/migrations/75_send_mail.sql | 22 +++-
examples/sending emails/README.md | 2 +-
examples/sending emails/advanced.sql | 1 +
examples/sending emails/index.sql | 2 +-
examples/sending emails/send-advanced.sql | 1 +
examples/sending emails/test.hurl | 2 +
.../sqlpage_functions/functions/send_mail.rs | 113 +++++++++++++++++-
8 files changed, 142 insertions(+), 7 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d7893480..99868202 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,8 +1,12 @@
# CHANGELOG.md
+## unreleased
+
+ - `sqlpage.send_mail` : add the ability to send HTML-formatted emails
+
## v0.45
-- **SQLPage can now send emails** 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 can now send emails** 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, an optional HTML `body_html` alternative, and data-URL attachments with a configurable combined decoded-size limit. SMTP passwords are redacted from startup debug logs.
- **Release builds are slightly smaller and faster.** Unused dependency features have been removed. SQLPage now uses the maintained AWS Lambda HTTP runtime and avoids unused SQLx macros, configuration parsers, multipart derives, CSV serialization support, and build dependencies.
- **Configuration loading now includes only the documented JSON, JSON5, TOML, and YAML formats.** The `config` dependency previously enabled its default INI and RON parsers even though SQLPage never documented those formats. Undocumented `.ini` and `.ron` configuration files are no longer loaded; migrate them to a supported format before upgrading.
- **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:
diff --git a/examples/official-site/sqlpage/migrations/75_send_mail.sql b/examples/official-site/sqlpage/migrations/75_send_mail.sql
index 6e1b780f..61ff6772 100644
--- a/examples/official-site/sqlpage/migrations/75_send_mail.sql
+++ b/examples/official-site/sqlpage/migrations/75_send_mail.sql
@@ -114,6 +114,7 @@ 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;
+- `body_html`: an HTML version of the body;
- `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 "`.
@@ -141,6 +142,21 @@ set result = sqlpage.send_mail(json_object(
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.
+### HTML email
+
+Set `body_html` to send an HTML version of the message alongside the plain-text `body`. The message is sent as a `multipart/alternative`: mail clients that prefer HTML show the HTML body, and clients that prefer text show the plain-text body.
+
+```sql
+set result = sqlpage.send_mail(json_object(
+ ''to'', ''alice@example.com'',
+ ''subject'', ''Welcome'',
+ ''body'', ''Welcome to our service.'',
+ ''body_html'', ''Welcome to our service.
''
+));
+```
+
+`body` is always required. Include a meaningful plain-text alternative for deliverability and accessibility. SQLPage does not sanitize `body_html`: the SQL author is responsible for the HTML content. Email clients ignore scripts, and styles are often stripped or sandboxed.
+
### Contact form
```sql
@@ -201,7 +217,7 @@ 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;
+- 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;
@@ -213,7 +229,7 @@ SQLPage does not currently support:
- 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.
+- `from`, `reply_to`, `cc`, and `body_html` treat JSON `null` like an omitted field. If `from` is omitted, `smtp_from` must be configured. When `body_html` is omitted, the message is plain text only.
- `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.
@@ -250,6 +266,6 @@ VALUES (
'send_mail',
1,
'message',
- '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.',
+ '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), `body_html` (an HTML alternative body sent as `multipart/alternative` alongside `body`), 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 d7e85bdc..5cae5b49 100644
--- a/examples/sending emails/README.md
+++ b/examples/sending emails/README.md
@@ -13,7 +13,7 @@ This builds SQLPage from the current repository checkout before starting the exa
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.
-- **Advanced email** demonstrates multiple recipients, Cc, Reply-To, a per-message sender override, and an uploaded attachment.
+- **Advanced email** demonstrates multiple recipients, Cc, Reply-To, a per-message sender override, an HTML alternative body, 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.
diff --git a/examples/sending emails/advanced.sql b/examples/sending emails/advanced.sql
index a6b9f47b..4e660772 100644
--- a/examples/sending emails/advanced.sql
+++ b/examples/sending emails/advanced.sql
@@ -14,6 +14,7 @@ select 'email' as type, 'sender' as name, 'From override' as label, 'advanced@ex
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 'textarea' as type, 'body_html' as name, 'HTML body (optional)' as label, 'Sent to a team with an attachment
' as value;
select 'file' as type, 'attachment' as name, 'Attachment' as label, true as required;
select 'button' as component;
diff --git a/examples/sending emails/index.sql b/examples/sending emails/index.sql
index cb88ff46..af4726ca 100644
--- a/examples/sending emails/index.sql
+++ b/examples/sending emails/index.sql
@@ -11,5 +11,5 @@ select
'simple.sql' as link;
select
'Advanced email' as title,
- 'Add multiple recipients, Cc, Reply-To, a sender override, and an uploaded attachment.' as description,
+ 'Multiple recipients, Cc, Reply-To, a sender override, an HTML body, 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
index dfc1e00c..63e635da 100644
--- a/examples/sending emails/send-advanced.sql
+++ b/examples/sending emails/send-advanced.sql
@@ -9,6 +9,7 @@ set message = json_object(
'reply_to', :reply_to,
'subject', :subject,
'body', :body,
+ 'body_html', NULLIF(:body_html, ''),
'attachments', json_array(json_object(
'filename', $attachment_name,
'data_url', $attachment_data_url
diff --git a/examples/sending emails/test.hurl b/examples/sending emails/test.hurl
index eaec38da..8e5ae06c 100644
--- a/examples/sending emails/test.hurl
+++ b/examples/sending emails/test.hurl
@@ -63,6 +63,7 @@ sender: advanced@example.com
reply_to: replies@example.com
subject: Advanced SMTP demo
body: Sent to a team with an attachment
+body_html: Sent to a team with an attachment
attachment: file,test-attachment.txt; text/plain
HTTP 200
[Asserts]
@@ -89,6 +90,7 @@ 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 "$.HTML" contains "Sent to a team with an"
jsonpath "$.Attachments[0].FileName" == "test-attachment.txt"
jsonpath "$.Attachments[0].ContentType" == "text/plain"
diff --git a/src/webserver/database/sqlpage_functions/functions/send_mail.rs b/src/webserver/database/sqlpage_functions/functions/send_mail.rs
index 53a4fcbc..57264172 100644
--- a/src/webserver/database/sqlpage_functions/functions/send_mail.rs
+++ b/src/webserver/database/sqlpage_functions/functions/send_mail.rs
@@ -65,6 +65,8 @@ struct MailRequest<'a> {
subject: Cow<'a, str>,
#[serde(borrow)]
body: Cow<'a, str>,
+ #[serde(borrow, default)]
+ body_html: Option>,
#[serde(borrow, default, rename = "from")]
from: Option>,
#[serde(borrow, default)]
@@ -277,6 +279,7 @@ fn build_email(config: &AppConfig, request: MailRequest<'_>) -> SendMailResult) -> SendMailResult) -> SendMailResulthello html
"
+ }"#,
+ )
+ .await
+ .unwrap();
+
+ let data = received.recv().unwrap();
+ assert!(data.contains("Subject: HTML test"));
+ assert!(data.contains("Content-Type: multipart/alternative"));
+ assert!(data.contains("Content-Type: text/plain"));
+ assert!(data.contains("hello plain"));
+ assert!(data.contains("Content-Type: text/html"));
+ assert!(data.contains("hello html
"));
+ }
+
+ #[tokio::test]
+ async fn sends_html_alternative_with_attachments() {
+ let (host, port, received) = start_smtp_server();
+ let mut config = test_config();
+ config.smtp_host = Some(host);
+ config.smtp_port = Some(port);
+ config.smtp_tls_mode = SmtpTlsMode::None;
+
+ send_mail_with_config(
+ &config,
+ r#"{
+ "to": "admin@example.com",
+ "from": "contact@example.com",
+ "subject": "HTML and attachment test",
+ "body": "hello plain",
+ "body_html": "hello html
",
+ "attachments": [
+ {"filename": "note.txt", "data_url": "data:text/plain;base64,aGk="}
+ ]
+ }"#,
+ )
+ .await
+ .unwrap();
+
+ let data = received.recv().unwrap();
+ assert!(data.contains("Content-Type: multipart/mixed"));
+ assert!(data.contains("Content-Type: multipart/alternative"));
+ assert!(data.contains("Content-Type: text/plain"));
+ assert!(data.contains("hello plain"));
+ assert!(data.contains("Content-Type: text/html"));
+ assert!(data.contains("hello html
"));
+ assert!(data.contains("note.txt"));
+ }
+
+ #[tokio::test]
+ async fn treats_null_body_html_as_omitted() {
+ let (host, port, received) = start_smtp_server();
+ let mut config = test_config();
+ config.smtp_host = Some(host);
+ config.smtp_port = Some(port);
+ config.smtp_tls_mode = SmtpTlsMode::None;
+
+ send_mail_with_config(
+ &config,
+ r#"{
+ "to": "admin@example.com",
+ "from": "contact@example.com",
+ "subject": "null html test",
+ "body": "hello plain",
+ "body_html": null
+ }"#,
+ )
+ .await
+ .unwrap();
+
+ let data = received.recv().unwrap();
+ assert!(data.contains("Content-Type: text/plain"));
+ assert!(!data.contains("text/html"));
+ }
+
#[tokio::test]
async fn rejects_unknown_message_fields() {
let mut config = test_config();