From c6554a213ddb03c92d5d45dbfaccc4aa971f6b1c Mon Sep 17 00:00:00 2001
From: lovasoa
Date: Fri, 24 Jul 2026 23:51:43 +0200
Subject: [PATCH 1/3] 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();
From e60fda48c05790d80ec66447f69bd77c13ebf3bc Mon Sep 17 00:00:00 2001
From: lovasoa
Date: Fri, 24 Jul 2026 23:57:13 +0200
Subject: [PATCH 2/3] update send mail docs
---
examples/official-site/sqlpage/migrations/75_send_mail.sql | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/examples/official-site/sqlpage/migrations/75_send_mail.sql b/examples/official-site/sqlpage/migrations/75_send_mail.sql
index 61ff6772..f987658e 100644
--- a/examples/official-site/sqlpage/migrations/75_send_mail.sql
+++ b/examples/official-site/sqlpage/migrations/75_send_mail.sql
@@ -266,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), `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.'
+ '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'
);
From 0e9673a6ff12eee49c36a6bc64c6462a8fbf6958 Mon Sep 17 00:00:00 2001
From: lovasoa
Date: Sat, 25 Jul 2026 00:12:28 +0200
Subject: [PATCH 3/3] Add body_md parameter to sqlpage.send_mail
Renders Markdown to HTML and sends as multipart/alternative
with raw Markdown as the plain-text body. When body_md is
provided, body becomes optional. body_md cannot be combined
with body_html.
---
CHANGELOG.md | 1 +
.../sqlpage/migrations/75_send_mail.sql | 30 ++-
src/template_helpers.rs | 13 ++
.../sqlpage_functions/functions/send_mail.rs | 204 +++++++++++++++++-
4 files changed, 232 insertions(+), 16 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 99868202..73059170 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
## unreleased
- `sqlpage.send_mail` : add the ability to send HTML-formatted emails
+ - `sqlpage.send_mail` : add an optional `body_md` argument that renders Markdown to HTML and sends the message as a `multipart/alternative` with the raw Markdown as the plain-text body and the rendered HTML as the HTML body. When `body_md` is provided, `body` becomes optional.
## v0.45
diff --git a/examples/official-site/sqlpage/migrations/75_send_mail.sql b/examples/official-site/sqlpage/migrations/75_send_mail.sql
index f987658e..fb5c2e1b 100644
--- a/examples/official-site/sqlpage/migrations/75_send_mail.sql
+++ b/examples/official-site/sqlpage/migrations/75_send_mail.sql
@@ -103,11 +103,11 @@ All SMTP options can also be set with uppercase environment variables such as `S
### Message fields
-The function takes one JSON object with three required fields:
+The function takes one JSON object with two required fields:
- `to`: the recipient email address;
- `subject`: the email subject;
-- `body`: the plain-text email body.
+- `body`: the plain-text email body. Required unless `body_md` is provided.
It also accepts:
@@ -115,6 +115,7 @@ It also accepts:
- `reply_to`: the address that receives replies;
- `cc`: a recipient who receives a visible copy;
- `body_html`: an HTML version of the body;
+- `body_md`: a [Markdown](https://daringfireball.net/projects/markdown/) version of the body, rendered to HTML automatically;
- `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 "`.
@@ -155,7 +156,23 @@ set result = sqlpage.send_mail(json_object(
));
```
-`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.
+At least one of `body` or `body_md` is 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.
+
+### Markdown email
+
+Set `body_md` to send a [Markdown](https://daringfireball.net/projects/markdown/) version of the body. SQLPage renders the Markdown to HTML and sends the message as a `multipart/alternative` with two parts: the raw Markdown as the plain-text body, and the rendered HTML as the HTML body. When `body_md` is provided, `body` becomes optional.
+
+```sql
+set result = sqlpage.send_mail(json_object(
+ ''to'', ''alice@example.com'',
+ ''subject'', ''Welcome'',
+ ''body_md'', ''# Welcome\n\nWelcome to **our service**.''
+));
+```
+
+When both `body` and `body_md` are provided, `body` is used as the plain-text alternative and the rendered `body_md` is used as the HTML alternative. `body_md` cannot be combined with `body_html`; use one or the other.
+
+SQLPage renders Markdown using the same [GFM](https://github.github.com/gfm/) options as the `markdown` template helper, honoring the `markdown_allow_dangerous_html` and `markdown_allow_dangerous_protocol` configuration options.
### Contact form
@@ -228,8 +245,9 @@ 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`, `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.
+- `to` and `subject` are required and cannot be JSON `null`. At least one of `body` or `body_md` must be provided.
+- `from`, `reply_to`, `cc`, `body_html`, and `body_md` treat JSON `null` like an omitted field. If `from` is omitted, `smtp_from` must be configured. When `body_html` and `body_md` are both omitted, the message is plain text only.
+- `body_md` cannot be combined with `body_html`.
- `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.
@@ -266,6 +284,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), `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.',
+ 'A JSON object containing the email to send. Required properties are `to` (an address or non-empty address array), `subject`, and either `body` or `body_md`. Optional properties are `from` (required unless `smtp_from` or `SMTP_FROM` is configured), `reply_to`, `cc` (an address or non-empty address array), `body` (the plain-text body; required unless `body_md` is provided), `body_html` (an HTML alternative body sent as `multipart/alternative` alongside `body`), `body_md` (a Markdown body rendered to HTML and sent as `multipart/alternative` with the raw Markdown as the plain-text alternative; cannot be combined with `body_html`), 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/src/template_helpers.rs b/src/template_helpers.rs
index 9e2a5dac..24ec944e 100644
--- a/src/template_helpers.rs
+++ b/src/template_helpers.rs
@@ -267,6 +267,19 @@ impl MarkdownConfig for AppConfig {
}
}
+/// Renders a markdown source string to HTML using the application's markdown configuration.
+/// Uses the same GFM options as the `markdown` Handlebars helper with the "default" preset.
+pub fn render_markdown_to_html(
+ config: &impl MarkdownConfig,
+ markdown_src: &str,
+) -> Result {
+ let mut options = markdown::Options::gfm();
+ options.compile.allow_dangerous_html = config.allow_dangerous_html();
+ options.compile.allow_dangerous_protocol = config.allow_dangerous_protocol();
+ options.compile.allow_any_img_src = true;
+ markdown::to_html_with_options(markdown_src, &options).map_err(|e| e.to_string())
+}
+
/// Helper to render markdown with configurable options
#[derive(Default)]
struct MarkdownHelper {
diff --git a/src/webserver/database/sqlpage_functions/functions/send_mail.rs b/src/webserver/database/sqlpage_functions/functions/send_mail.rs
index 57264172..0f4860ca 100644
--- a/src/webserver/database/sqlpage_functions/functions/send_mail.rs
+++ b/src/webserver/database/sqlpage_functions/functions/send_mail.rs
@@ -63,10 +63,12 @@ struct MailRequest<'a> {
cc: Option,
#[serde(borrow)]
subject: Cow<'a, str>,
- #[serde(borrow)]
- body: Cow<'a, str>,
+ #[serde(borrow, default)]
+ body: Option>,
#[serde(borrow, default)]
body_html: Option>,
+ #[serde(borrow, default)]
+ body_md: Option>,
#[serde(borrow, default, rename = "from")]
from: Option>,
#[serde(borrow, default)]
@@ -273,6 +275,52 @@ async fn send_mail_with_config(config: &AppConfig, mail_request: &str) -> SendMa
Ok(())
}
+/// Resolves the plain-text and optional HTML body alternatives from the request fields.
+///
+/// - At least one of `body` or `body_md` must be provided.
+/// - `body_md` cannot be combined with `body_html`.
+/// - When `body_md` is provided, it is rendered to HTML. The plain-text alternative is
+/// `body` if present, otherwise the raw markdown source.
+fn resolve_bodies(
+ config: &AppConfig,
+ body: Option>,
+ body_html: Option<&Cow<'_, str>>,
+ body_md: Option<&Cow<'_, str>>,
+) -> SendMailResult<(String, Option)> {
+ if body.is_none() && body_md.is_none() {
+ return Err(SendMailError::InvalidMessage(anyhow::anyhow!(
+ "sqlpage.send_mail() requires either 'body' or 'body_md'"
+ )));
+ }
+ if body_md.is_some() && body_html.is_some() {
+ return Err(SendMailError::InvalidMessage(anyhow::anyhow!(
+ "sqlpage.send_mail() cannot combine 'body_md' with 'body_html'"
+ )));
+ }
+
+ let html_body = if let Some(markdown_src) = body_md {
+ Some(
+ crate::template_helpers::render_markdown_to_html(config, markdown_src)
+ .map_err(|reason| {
+ SendMailError::InvalidMessage(anyhow::anyhow!(
+ "Failed to render body_md as HTML: {reason}"
+ ))
+ })?,
+ )
+ } else {
+ body_html.map(std::string::ToString::to_string)
+ };
+
+ // If body is provided it takes precedence; otherwise the raw markdown is used.
+ let text_body = match body {
+ Some(body) => body.into_owned(),
+ None => body_md
+ .map(std::string::ToString::to_string)
+ .expect("body_md is present when body is None"),
+ };
+ Ok((text_body, html_body))
+}
+
fn build_email(config: &AppConfig, request: MailRequest<'_>) -> SendMailResult {
let MailRequest {
to,
@@ -280,11 +328,14 @@ fn build_email(config: &AppConfig, request: MailRequest<'_>) -> SendMailResult) -> SendMailResultHello"));
+ assert!(data.contains("bold"));
+ }
+
+ #[tokio::test]
+ async fn sends_markdown_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": "Markdown and attachment test",
+ "body_md": "# Hello",
+ "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"));
+ assert!(data.contains("Content-Type: text/html"));
+ assert!(data.contains("Hello
"));
+ assert!(data.contains("note.txt"));
+ }
+
+ #[tokio::test]
+ async fn sends_markdown_with_explicit_body_as_text() {
+ 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": "Markdown with body test",
+ "body": "plain text override",
+ "body_md": "# Hello"
+ }"##,
+ )
+ .await
+ .unwrap();
+
+ let data = received.recv().unwrap();
+ assert!(data.contains("Content-Type: multipart/alternative"));
+ assert!(data.contains("Content-Type: text/plain"));
+ assert!(data.contains("plain text override"));
+ assert!(!data.contains("# Hello"));
+ assert!(data.contains("Content-Type: text/html"));
+ assert!(data.contains("Hello
"));
+ }
+
+ #[tokio::test]
+ async fn rejects_body_md_combined_with_body_html() {
+ let mut config = test_config();
+ config.smtp_host = Some("localhost".to_string());
+ let error = send_mail_with_config(
+ &config,
+ r##"{
+ "to": "admin@example.com",
+ "from": "contact@example.com",
+ "subject": "conflict test",
+ "body_md": "# Hello",
+ "body_html": "Hello
"
+ }"##,
+ )
+ .await
+ .unwrap_err();
+ assert!(matches!(&error, SendMailError::InvalidMessage(_)));
+ assert!(error.to_string().contains("cannot combine 'body_md' with 'body_html'"));
+ }
+
+ #[tokio::test]
+ async fn rejects_missing_body_and_body_md() {
+ let mut config = test_config();
+ config.smtp_host = Some("localhost".to_string());
+ let error = send_mail_with_config(
+ &config,
+ r#"{
+ "to": "admin@example.com",
+ "from": "contact@example.com",
+ "subject": "missing body test"
+ }"#,
+ )
+ .await
+ .unwrap_err();
+ assert!(matches!(&error, SendMailError::InvalidMessage(_)));
+ assert!(error.to_string().contains("requires either 'body' or 'body_md'"));
+ }
+
#[tokio::test]
async fn rejects_unknown_message_fields() {
let mut config = test_config();