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.
This commit is contained in:
+5
-1
@@ -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:
|
||||
|
||||
@@ -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 <jane@example.com>"`.
|
||||
@@ -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'', ''<p>Welcome to <strong>our service</strong>.</p>''
|
||||
));
|
||||
```
|
||||
|
||||
`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'
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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, '<p>Sent to a team with an <strong>attachment</strong></p>' as value;
|
||||
select 'file' as type, 'attachment' as name, 'Attachment' as label, true as required;
|
||||
|
||||
select 'button' as component;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: <p>Sent to a team with an <strong>attachment</strong></p>
|
||||
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"
|
||||
|
||||
|
||||
@@ -65,6 +65,8 @@ struct MailRequest<'a> {
|
||||
subject: Cow<'a, str>,
|
||||
#[serde(borrow)]
|
||||
body: Cow<'a, str>,
|
||||
#[serde(borrow, default)]
|
||||
body_html: Option<Cow<'a, str>>,
|
||||
#[serde(borrow, default, rename = "from")]
|
||||
from: Option<Cow<'a, str>>,
|
||||
#[serde(borrow, default)]
|
||||
@@ -277,6 +279,7 @@ fn build_email(config: &AppConfig, request: MailRequest<'_>) -> SendMailResult<M
|
||||
cc,
|
||||
subject,
|
||||
body,
|
||||
body_html,
|
||||
from,
|
||||
reply_to,
|
||||
attachments,
|
||||
@@ -311,6 +314,16 @@ fn build_email(config: &AppConfig, request: MailRequest<'_>) -> SendMailResult<M
|
||||
email = email.reply_to(parsed_reply_to);
|
||||
}
|
||||
if attachments.is_empty() {
|
||||
if let Some(html) = body_html {
|
||||
return email
|
||||
.multipart(
|
||||
MultiPart::alternative()
|
||||
.singlepart(SinglePart::plain(body.into_owned()))
|
||||
.singlepart(SinglePart::html(html.into_owned())),
|
||||
)
|
||||
.context("Unable to build email message")
|
||||
.map_err(SendMailError::InvalidMessage);
|
||||
}
|
||||
return email
|
||||
.header(ContentType::TEXT_PLAIN)
|
||||
.body(body.into_owned())
|
||||
@@ -319,7 +332,15 @@ fn build_email(config: &AppConfig, request: MailRequest<'_>) -> SendMailResult<M
|
||||
}
|
||||
|
||||
let mut remaining_attachment_size = config.max_email_attachment_size;
|
||||
let mut multipart = MultiPart::mixed().singlepart(SinglePart::plain(body.into_owned()));
|
||||
let mut multipart = if let Some(html) = body_html {
|
||||
MultiPart::mixed().multipart(
|
||||
MultiPart::alternative()
|
||||
.singlepart(SinglePart::plain(body.into_owned()))
|
||||
.singlepart(SinglePart::html(html.into_owned())),
|
||||
)
|
||||
} else {
|
||||
MultiPart::mixed().singlepart(SinglePart::plain(body.into_owned()))
|
||||
};
|
||||
for (index, attachment) in attachments.into_iter().enumerate() {
|
||||
if attachment.filename.is_empty() {
|
||||
return Err(SendMailError::InvalidAttachmentFilename { index });
|
||||
@@ -419,6 +440,96 @@ mod tests {
|
||||
assert!(data.contains("hello smtp"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sends_html_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": "HTML test",
|
||||
"body": "hello plain",
|
||||
"body_html": "<p>hello <strong>html</strong></p>"
|
||||
}"#,
|
||||
)
|
||||
.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("<p>hello <strong>html</strong></p>"));
|
||||
}
|
||||
|
||||
#[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": "<p>hello <strong>html</strong></p>",
|
||||
"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("<p>hello <strong>html</strong></p>"));
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user