diff --git a/CHANGELOG.md b/CHANGELOG.md index d7893480..73059170 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,13 @@ # CHANGELOG.md +## 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 -- **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..fb5c2e1b 100644 --- a/examples/official-site/sqlpage/migrations/75_send_mail.sql +++ b/examples/official-site/sqlpage/migrations/75_send_mail.sql @@ -103,17 +103,19 @@ 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: - `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; +- `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 "`. @@ -141,6 +143,37 @@ 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.

'' +)); +``` + +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 ```sql @@ -201,7 +234,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; @@ -212,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`, and `cc` treat JSON `null` like an omitted field. If `from` is omitted, `smtp_from` must be configured. +- `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. @@ -250,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), 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/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/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 53a4fcbc..0f4860ca 100644 --- a/src/webserver/database/sqlpage_functions/functions/send_mail.rs +++ b/src/webserver/database/sqlpage_functions/functions/send_mail.rs @@ -63,8 +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)] @@ -271,17 +275,67 @@ 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, cc, subject, body, + body_html, + body_md, from, reply_to, attachments, } = request; + let (text_body, html_body) = resolve_bodies(config, body, body_html.as_ref(), body_md.as_ref())?; + let sender = from .as_deref() .or(config.smtp_from.as_deref()) @@ -311,15 +365,33 @@ fn build_email(config: &AppConfig, request: MailRequest<'_>) -> 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 sends_markdown_alternative_to_configured_relay() { + 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 test", + "body_md": "# Hello\n\nThis is **bold**." + }"##, + ) + .await + .unwrap(); + + let data = received.recv().unwrap(); + assert!(data.contains("Subject: Markdown test")); + assert!(data.contains("Content-Type: multipart/alternative")); + assert!(data.contains("Content-Type: text/plain")); + // The raw markdown is used as the plain-text alternative. + assert!(data.contains("# Hello")); + assert!(data.contains("This is **bold**.")); + assert!(data.contains("Content-Type: text/html")); + assert!(data.contains("

Hello

")); + 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();