diff --git a/CHANGELOG.md b/CHANGELOG.md index beed7d457..459e6a50b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## unreleased +- **SQLPage can now send plain-text email through an SMTP relay.** 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 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: ```sql diff --git a/Cargo.lock b/Cargo.lock index b852248f8..caed3da93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -435,7 +435,7 @@ dependencies = [ "asn1-rs-derive", "asn1-rs-impl", "displaydoc", - "nom", + "nom 7.1.3", "num-traits", "rusticata-macros", "thiserror 1.0.69", @@ -1403,7 +1403,7 @@ checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" dependencies = [ "asn1-rs", "displaydoc", - "nom", + "nom 7.1.3", "num-bigint", "num-traits", "rusticata-macros", @@ -1641,6 +1641,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64 0.22.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -2557,6 +2573,34 @@ dependencies = [ "spin", ] +[[package]] +name = "lettre" +version = "0.11.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" +dependencies = [ + "async-trait", + "base64 0.22.1", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "httpdate", + "idna", + "mime", + "nom 8.0.0", + "percent-encoding", + "quoted_printable", + "rustls", + "rustls-native-certs", + "socket2 0.6.4", + "tokio", + "tokio-rustls", + "url", + "webpki-roots 1.0.8", +] + [[package]] name = "libc" version = "0.2.186" @@ -2810,6 +2854,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3659,6 +3712,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + [[package]] name = "r-efi" version = "5.3.0" @@ -3948,7 +4007,7 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -4482,6 +4541,7 @@ dependencies = [ "hmac 0.13.0", "include_dir", "lambda-web", + "lettre", "libflate", "log", "markdown", @@ -5576,7 +5636,7 @@ dependencies = [ "data-encoding", "der-parser", "lazy_static", - "nom", + "nom 7.1.3", "oid-registry", "rusticata-macros", "thiserror 1.0.69", diff --git a/Cargo.toml b/Cargo.toml index bbda6de81..5bb209907 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +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 = [ + "aws-lc-rs", + "builder", + "rustls-native-certs", + "smtp-transport", + "tokio1-rustls", + "webpki-roots", +] } # OpenTelemetry / tracing tracing = "0.1" diff --git a/configuration.md b/configuration.md index eddc2ca9c..546a4bb63 100644 --- a/configuration.md +++ b/configuration.md @@ -41,6 +41,13 @@ 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 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`. `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. | +| `max_email_attachment_size` | 10485760 | Maximum combined decoded size, in bytes, of all attachments in one email. Defaults to 10 MiB. This is independent of `max_uploaded_file_size` because attachments may come from sources other than form uploads. | | `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. | | `markdown_allow_dangerous_html` | false | Whether to allow raw HTML in markdown content. Only enable this if the markdown content is fully trusted (not user generated). | diff --git a/examples/official-site/sqlpage/migrations/75_send_mail.sql b/examples/official-site/sqlpage/migrations/75_send_mail.sql new file mode 100644 index 000000000..6e1b780f3 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/75_send_mail.sql @@ -0,0 +1,255 @@ +INSERT INTO sqlpage_functions ( + "name", + "return_type", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES ( + 'send_mail', + 'JSON', + '0.45.0', + 'mail', + 'Sends a plain-text email using the outgoing mail server configured in SQLPage. + +### Quick start + +You need an [SMTP server](https://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol), which is the outgoing mail server provided by an email account or email delivery service. + +Add its connection details to `sqlpage/sqlpage.json`: + +```json +{ + "smtp_host": "smtp.example.com", + "smtp_username": "your-smtp-user", + "smtp_password": "your-smtp-password", + "smtp_from": "My application " +} +``` + +The default connection uses STARTTLS on port 587, which is the most common setup. Restart SQLPage after changing its configuration. + +**Important:** SQLPage can sign in with an SMTP username and password, but it does not support OAuth. If the provider instructions only offer OAuth or "Modern Auth", use a different SMTP relay. + +You can now send an email from any SQL file: + +```sql +set result = sqlpage.send_mail(json_object( + ''to'', ''alice@example.com'', + ''subject'', ''Hello from SQLPage'', + ''body'', ''Your first email is working!'' +)); +``` + +The sender comes from `smtp_from`. The result is a JSON object: + +```json +{"status":"accepted"} +``` + +If the message cannot be sent, the function returns the reason instead of stopping the request: + +```json +{"status":"error","error_code":"INVALID_EMAIL_TO","error":"''xxx'' is not a valid to email address"} +``` + +For every non-`NULL` call, `status` is either `accepted` or `error`. Always check it before showing a success message or continuing work that depends on the email: + +```sql +select ''alert'' as component, + case when json_extract($result, ''$.status'') = ''accepted'' then ''success'' else ''danger'' end as color, + case when json_extract($result, ''$.status'') = ''accepted'' then ''Email sent'' else ''Email could not be sent'' end as title, + json_extract($result, ''$.error'') as description; +``` + +### Where to find the SMTP settings + +Search the help pages or administration panel of the service that sends email for you. Look for **SMTP**, **outgoing mail server**, **SMTP submission**, **SMTP relay**, or **send from an app or device**. + +Provider documentation may use different names for the same settings: + +| Provider documentation | SQLPage setting | +| --- | --- | +| SMTP server, outgoing server, relay, or smart host | `smtp_host` | +| Port | `smtp_port` | +| STARTTLS, SSL/TLS, or connection security | `smtp_tls_mode` | +| SMTP username | `smtp_username` | +| SMTP password, app password, token, or API key | `smtp_password` | +| Sender or From address | `smtp_from` | + +The SMTP password is often a separate app password, token, or SMTP credential rather than the password used to open webmail. Use exactly what the provider instructions specify. + +For an existing mailbox, these official guides explain the available options: + +- [Personal Google Account app passwords](https://support.google.com/accounts/answer/185833), for eligible accounts +- [Google Workspace: send email from a printer, scanner, or app](https://knowledge.workspace.google.com/admin/gmail/send-email-from-a-printer-scanner-or-app) +- [Microsoft 365: send email from a device or application](https://learn.microsoft.com/en-us/exchange/mail-flow-best-practices/how-to-set-up-a-multifunction-device-or-application-to-send-email-using-microsoft-365-or-office-365) + +Personal Outlook.com SMTP requires OAuth and is therefore not currently compatible. Microsoft 365 administrators can use the relay options described in the linked organization guide. + +Dedicated email delivery services also provide SMTP settings. Here are examples in alphabetical order: + +- [Amazon SES SMTP credentials](https://docs.aws.amazon.com/ses/latest/dg/smtp-credentials.html) +- [Mailgun SMTP](https://documentation.mailgun.com/docs/mailgun/user-manual/sending-messages/send-smtp) +- [Postmark SMTP](https://postmarkapp.com/developer/user-guide/send-email-with-smtp) +- [Resend SMTP](https://resend.com/docs/send-with-smtp) +- [Twilio SendGrid SMTP](https://www.twilio.com/docs/sendgrid/for-developers/sending-email/integrating-with-the-smtp-api) + +These links are examples, not endorsements. SQLPage is not affiliated with any of these services. Compare their requirements, limits, and pricing for your own use case. + +For local development, [Mailpit](https://mailpit.axllent.org/) accepts messages and displays them in a browser without delivering them to real recipients. The [SQLPage email example](https://github.com/sqlpage/SQLPage/tree/main/examples/sending%20emails) includes a ready-to-run Mailpit setup. + +All SMTP options can also be set with uppercase environment variables such as `SMTP_HOST` and `SMTP_PASSWORD`. See the complete [SQLPage configuration reference](https://github.com/sqlpage/SQLPage/blob/main/configuration.md). Do not commit SMTP credentials to source control. + +### Message fields + +The function takes one JSON object with three required fields: + +- `to`: the recipient email address; +- `subject`: the email subject; +- `body`: the plain-text email body. + +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; +- `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 "`. + +Most SMTP servers only allow approved sender addresses. Prefer a fixed `smtp_from`. Override `from` only when the SMTP provider allows the address. + +### Multiple recipients and attachments + +Each attachment has a file name and a [data URL](https://developer.mozilla.org/en-US/docs/Web/URI/Schemes/data) containing its data. This example attaches a file from the SQLPage server: + +```sql +set result = sqlpage.send_mail(json_object( + ''to'', json_array(''alice@example.com'', ''bob@example.com''), + ''cc'', ''team@example.com'', + ''subject'', ''Monthly report'', + ''body'', ''The report is attached.'', + ''attachments'', json_array(json_object( + ''filename'', ''report.pdf'', + ''data_url'', sqlpage.read_file_as_data_url(''report.pdf'') + )) +)); +``` + +[`sqlpage.read_file_as_data_url`](/functions.sql?function=read_file_as_data_url) is one way to create attachment data. Data URLs can also come from an uploaded file, a database value, an HTTP response, or SQL. + +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. + +### Contact form + +```sql +select ''form'' as component, ''post'' as method; +select ''email'' as name, ''email'' as type, true as required; +select ''message'' as name, ''textarea'' as type, true as required; + +set mail = json_object( + ''to'', ''admin@example.com'', + ''reply_to'', :email, + ''subject'', ''Website contact form'', + ''body'', :message +); +set result = ( + select sqlpage.send_mail($mail) + where :message is not null +); + +select ''alert'' as component, + case when json_extract($result, ''$.status'') = ''accepted'' then ''success'' else ''danger'' end as color, + case when json_extract($result, ''$.status'') = ''accepted'' then ''Message sent'' else ''Message could not be sent'' end as title, + json_extract($result, ''$.error'') as description +where :message is not null; +``` + +On the initial page load, `:message` is `NULL`, so the query returns no row and no email is sent. After submission, `:email` and `:message` contain the form fields. + +For a public form, keep `to` fixed in SQL so visitors cannot use your server to email arbitrary recipients. Validate inputs and add suitable rate limiting and anti-abuse controls. + +### Before using this in production + +- A `status` of `accepted` is not proof of delivery. A message can still bounce or be filtered later. Check the provider logs or delivery webhooks when delivery status matters. +- The provider may require sender or domain verification and DNS records such as SPF, DKIM, or DMARC. Configure these with the provider and DNS host. +- The function waits for the SMTP server during the web request. It opens a new connection for each call and does not retry automatically or save failed messages in a queue. +- SMTP commands use a fixed 60-second timeout. SQLPage does not currently provide a setting to change it. +- A selected call runs once for every row returned by its query. If the query returns no rows, it sends no email. Avoid using it over many rows; use a background queue or provider bulk API for bulk sending. + +### Connection options + +`smtp_host` must contain only a host name or IP address. Do not include `smtp://`, `https://`, a path, or a port. + +Choose `smtp_tls_mode` according to the provider instructions: + +- `starttls` (default) requires a STARTTLS upgrade before authentication or message submission. Its default port is 587. SQLPage fails rather than continuing without encryption when STARTTLS is unavailable. +- `tls` encrypts the connection from the beginning. Its default port is 465. Providers may call this implicit TLS, SSL/TLS, or SMTPS. +- `none` sends the message without encryption. Its default port is 25. It cannot be used with a username and password and is intended only for a trusted local server such as Mailpit. + +Set `smtp_port` when the provider specifies another port, such as 2525. `smtp_username` and `smtp_password` must either both be configured or both be omitted. + +SQLPage normally validates TLS certificates using public web PKI roots. Enable `system_root_ca_certificates`, or set `SSL_CERT_FILE` or `SSL_CERT_DIR`, to use roots installed by the system administrator, including private roots. + +### Supported and unsupported features + +SQLPage supports unauthenticated SMTP servers and username/password authentication using the SMTP `PLAIN` and `LOGIN` mechanisms. Credentials are allowed only over an encrypted connection. + +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; +- 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; +- a configurable SMTP command timeout or EHLO client identity; +- delivery receipts, bounce processing, suppression lists, open or click tracking, or webhooks. + +### `NULL`, empty values, and invalid input + +- 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. +- `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. + +### Error codes + +`error_code` is a stable, machine-readable value. `error` is the corresponding human-readable detail. + +| `error_code` | Meaning | +| --- | --- | +| `INVALID_MESSAGE` | The argument is not a valid message object, a required field is missing, or the message cannot be constructed. | +| `SMTP_NOT_CONFIGURED` | `smtp_host` is not configured. | +| `MISSING_EMAIL_FROM` | Neither the message nor `smtp_from` provides a sender. | +| `INVALID_EMAIL_FROM` | `from` is not a valid email address. | +| `INVALID_EMAIL_TO` | `to` is empty or contains an invalid email address. | +| `INVALID_EMAIL_CC` | `cc` is empty or contains an invalid email address. | +| `INVALID_EMAIL_REPLY_TO` | `reply_to` is not a valid email address. | +| `INVALID_ATTACHMENT` | An attachment has an invalid name, data URL, media type, or exceeds the configured size limit. | +| `SMTP_TLS_FAILED` | TLS certificates could not be configured or the encrypted connection failed. | +| `SMTP_TIMEOUT` | The SMTP operation timed out. | +| `SMTP_REJECTED` | The SMTP server returned a temporary or permanent rejection, including authentication failures. | +| `SMTP_CONNECTION_FAILED` | SQLPage could not connect to or communicate with the SMTP server. | +' + ); + +INSERT INTO sqlpage_function_parameters ( + "function", + "index", + "name", + "description_md", + "type" + ) +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.', + 'JSON' + ); diff --git a/examples/sending emails/README.md b/examples/sending emails/README.md index e45b1b6ef..4f63f856a 100644 --- a/examples/sending emails/README.md +++ b/examples/sending emails/README.md @@ -1,76 +1,40 @@ # Sending Emails with SQLPage -SQLPage lets you interact with any email service through their API, -using the [`sqlpage.fetch` function](https://sql-page.com/functions.sql?function=fetch). +This example sends plain-text email with [`sqlpage.send_mail`](https://sql-page.com/functions.sql?function=send_mail). The included Docker Compose setup uses [Mailpit](https://mailpit.axllent.org/) as a local SMTP server, so no email leaves your computer. -## Why Use an Email Service? +Run the example: -Sending emails directly from your server can be challenging: -- Many ISPs block direct email sending to prevent spam -- Email deliverability requires proper setup of SPF, DKIM, and DMARC records -- Managing bounce handling and spam complaints is complex -- Direct sending can impact your server's IP reputation +```sh +docker compose up +``` -Email services solve these problems by providing reliable APIs for sending emails while handling deliverability, tracking, and compliance. +Open http://localhost:8080 and choose one of two flows, then inspect the message in the Mailpit inbox at http://localhost:8025: -## Popular Email Services +- **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. -- [Mailgun](https://www.mailgun.com/) - Developer-friendly, great for transactional emails -- [SendGrid](https://sendgrid.com/) - Powerful features, owned by Twilio -- [Amazon SES](https://aws.amazon.com/ses/) - Cost-effective for high volume -- [Postmark](https://postmarkapp.com/) - Focused on transactional email delivery -- [SMTP2GO](https://www.smtp2go.com/) - Simple SMTP service with API options +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. -## Example: Sending Emails with Mailgun +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. -Here's a complete example using Mailgun's API to send emails through SQLPage: +The simple handler omits `from`, so SQLPage uses the configured `SMTP_FROM`: -### [`email.sql`](./email.sql) ```sql --- Configure the email request -set email_request = json_object( - 'url', 'https://api.mailgun.net/v3/' || sqlpage.environment_variable('MAILGUN_DOMAIN') || '/messages', - 'method', 'POST', - 'headers', json_object( - 'Content-Type', 'application/x-www-form-urlencoded', - 'Authorization', 'Basic ' || encode(('api:' || sqlpage.environment_variable('MAILGUN_API_KEY'))::bytea, 'base64') - ), - 'body', - 'from=Your Name ' - || '&to=' || $to_email - || '&subject=' || $subject - || '&text=' || $message_text - || '&html=' || $message_html +set message = json_object( + 'to', :recipient, + 'subject', :subject, + 'body', :body ); - --- Send the email using sqlpage.fetch -set email_response = sqlpage.fetch($email_request); - --- Handle the response -select - 'alert' as component, - case - when $email_response->>'id' is not null then 'Email sent successfully' - else 'Failed to send email: ' || ($email_response->>'message') - end as title; +set result = sqlpage.send_mail($message); ``` -### Setup Instructions +The advanced handler turns the temporary uploaded file into the data URL expected by an attachment: -1. Sign up for a [Mailgun account](https://signup.mailgun.com/new/signup) -2. Verify your domain or use the sandbox domain for testing -3. Get your API key from the Mailgun dashboard -4. Set these environment variables in your SQLPage configuration: - ``` - MAILGUN_API_KEY=your-api-key-here - MAILGUN_DOMAIN=your-domain.com - ``` +```sql +set attachment_path = sqlpage.uploaded_file_path('attachment'); +set attachment_data_url = sqlpage.read_file_as_data_url($attachment_path); +``` -## Best Practices +`sqlpage.send_mail` returns `{"status":"accepted"}` after the SMTP relay accepts the message. If validation, connection, authentication, or submission fails, it returns `{"status":"error","error_code":"...","error":"..."}` instead of stopping the request. Check `status` before reporting success. Passing SQL `NULL` returns SQL `NULL` without sending or logging a warning. -- If you share your code with others, it should not contain sensitive data like API keys - - Instead, use environment variables with [`sqlpage.environment_variable`](https://sql-page.com/functions.sql?function=environment_variable) -- Implement proper error handling -- Consider rate limiting for bulk sending -- Include unsubscribe links when sending marketing emails -- Follow email regulations (GDPR, CAN-SPAM Act) +Do not expose unrestricted forms like these publicly. In production, authenticate users, restrict recipients, validate input and uploaded files, and add rate limiting to prevent abuse. diff --git a/examples/sending emails/advanced.sql b/examples/sending emails/advanced.sql new file mode 100644 index 000000000..a6b9f47b8 --- /dev/null +++ b/examples/sending emails/advanced.sql @@ -0,0 +1,20 @@ +select 'shell' as component, 'Send an advanced email' as title; + +select + 'form' as component, + 'Advanced email' as title, + 'send-advanced.sql' as action, + 'post' as method, + 'Send email' as validate; + +select 'email' as type, 'recipient' as name, 'To' as label, 'first@example.com' as value, true as required; +select 'email' as type, 'second_recipient' as name, 'Second recipient' as label, 'second@example.com' as value, true as required; +select 'email' as type, 'cc' as name, 'Cc' as label, 'team@example.com' as value, true as required; +select 'email' as type, 'sender' as name, 'From override' as label, 'advanced@example.com' as value, true as required; +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 'file' as type, 'attachment' as name, 'Attachment' as label, true as required; + +select 'button' as component; +select 'Back to examples' as title, 'index.sql' as link, 'arrow-left' as icon; diff --git a/examples/sending emails/docker-compose.yml b/examples/sending emails/docker-compose.yml index 8d0813b59..bca61ef90 100644 --- a/examples/sending emails/docker-compose.yml +++ b/examples/sending emails/docker-compose.yml @@ -3,5 +3,17 @@ services: image: lovasoa/sqlpage:main ports: - "8080:8080" + environment: + SMTP_HOST: mailpit + SMTP_PORT: 1025 + SMTP_TLS_MODE: none + SMTP_FROM: SQLPage Demo volumes: - .:/var/www + depends_on: + - mailpit + + mailpit: + image: axllent/mailpit:v1.30.4 + ports: + - "8025:8025" diff --git a/examples/sending emails/email.sql b/examples/sending emails/email.sql deleted file mode 100644 index c528cc131..000000000 --- a/examples/sending emails/email.sql +++ /dev/null @@ -1,43 +0,0 @@ --- Configure the email request - --- Obtain the authorization by encoding "api:YOUR_PERSONAL_API_KEY" in base64 -set authorization = 'YXBpOjI4ODlmODE3Njk5ZjZiNzA4MTdhODliOGUwODYyNmEyLWU2MWFlOGRkLTgzMjRjYWZm'; - --- Find the domain in your Mailgun account -set domain = 'sandbox859545b401674a95b906ab417d48c97c.mailgun.org'; - --- Set the recipient email address. ---In this demo, we accept sending any email to any address. --- If you do this in production, spammers WILL use your account to send spam. --- Your application should only allow emails to be sent to addresses you have verified. -set to_email = :to_email; - --- Set the email subject -set subject = :subject; - --- Set the email message text -set message_text = :message_text; - -set email_request = json_object( - 'url', 'https://api.mailgun.net/v3/' || $domain || '/messages', - 'method', 'POST', - 'headers', json_object( - 'Content-Type', 'application/x-www-form-urlencoded', - 'Authorization', 'Basic ' || $authorization - ), - 'body', - 'from=Your Name ' - || '&to=' || sqlpage.url_encode($to_email) - || '&subject=' || sqlpage.url_encode($subject) - || '&text=' || sqlpage.url_encode($message_text) -); --- Send the email using sqlpage.fetch -set email_response = sqlpage.fetch($email_request); - --- Handle the response -select - 'alert' as component, - case - when $email_response->>'id' is not null then 'Email sent successfully' - else 'Failed to send email: ' || ($email_response->>'message') - end as title; \ No newline at end of file diff --git a/examples/sending emails/index.sql b/examples/sending emails/index.sql index dcee98136..cb88ff46a 100644 --- a/examples/sending emails/index.sql +++ b/examples/sending emails/index.sql @@ -1,5 +1,15 @@ -select 'form' as component, 'Send an email' as title, 'email.sql' as action; +select 'shell' as component, 'Sending emails with SQLPage' as title; -select 'to_email' as name, 'To email' as label, 'recipient@example.com' as value; -select 'subject' as name, 'Subject' as label, 'Test email' as value; -select 'textarea' as type, 'message_text' as name, 'Message' as label, 'This is a test email' as value; +select + 'text' as component, + 'Choose a focused example. Both send through the SMTP server configured for SQLPage.' as contents; + +select 'card' as component, 2 as columns; +select + 'Simple email' as title, + 'Send a plain-text message using the configured default sender.' as description, + 'simple.sql' as link; +select + 'Advanced email' as title, + 'Add multiple recipients, Cc, Reply-To, a sender override, 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 new file mode 100644 index 000000000..e13b2e2e5 --- /dev/null +++ b/examples/sending emails/send-advanced.sql @@ -0,0 +1,29 @@ +set attachment_name = sqlpage.uploaded_file_name('attachment'); +set attachment_path = sqlpage.uploaded_file_path('attachment'); +set attachment_data_url = sqlpage.read_file_as_data_url($attachment_path); + +set message = json_object( + 'to', json_array(:recipient, :second_recipient), + 'cc', :cc, + 'from', :sender, + 'reply_to', :reply_to, + 'subject', :subject, + 'body', :body, + 'attachments', json_array(json_object( + 'filename', $attachment_name, + 'data_url', $attachment_data_url + )) +); +set result = sqlpage.send_mail($message); + +select 'shell' as component, 'Advanced email result' as title; + +select + 'alert' as component, + case when json_extract($result, '$.status') = 'accepted' then 'success' else 'danger' end as color, + case when json_extract($result, '$.status') = 'accepted' then 'Email sent successfully' else 'Email could not be sent' end as title, + json_extract($result, '$.error') as description; + +select 'button' as component; +select 'Send another advanced email' as title, 'advanced.sql' as link; +select 'Back to examples' as title, 'index.sql' as link, 'secondary' as color; diff --git a/examples/sending emails/send-simple.sql b/examples/sending emails/send-simple.sql new file mode 100644 index 000000000..ed59bb4bc --- /dev/null +++ b/examples/sending emails/send-simple.sql @@ -0,0 +1,18 @@ +set message = json_object( + 'to', :recipient, + 'subject', :subject, + 'body', :body +); +set result = sqlpage.send_mail($message); + +select 'shell' as component, 'Simple email result' as title; + +select + 'alert' as component, + case when json_extract($result, '$.status') = 'accepted' then 'success' else 'danger' end as color, + case when json_extract($result, '$.status') = 'accepted' then 'Email sent successfully' else 'Email could not be sent' end as title, + json_extract($result, '$.error') as description; + +select 'button' as component; +select 'Send another simple email' as title, 'simple.sql' as link; +select 'Back to examples' as title, 'index.sql' as link, 'secondary' as color; diff --git a/examples/sending emails/simple.sql b/examples/sending emails/simple.sql new file mode 100644 index 000000000..ab0b5be18 --- /dev/null +++ b/examples/sending emails/simple.sql @@ -0,0 +1,15 @@ +select 'shell' as component, 'Send a simple email' as title; + +select + 'form' as component, + 'Simple email' as title, + 'send-simple.sql' as action, + 'post' as method, + 'Send email' as validate; + +select 'email' as type, 'recipient' as name, 'To' as label, 'recipient@example.com' as value, true as required; +select 'subject' as name, 'Subject' as label, 'Simple SMTP demo' as value, true as required; +select 'textarea' as type, 'body' as name, 'Message' as label, 'Sent with sqlpage.send_mail' as value, true as required; + +select 'button' as component; +select 'Back to examples' as title, 'index.sql' as link, 'arrow-left' as icon; diff --git a/examples/sending emails/test-attachment.txt b/examples/sending emails/test-attachment.txt new file mode 100644 index 000000000..bdc2a85f0 --- /dev/null +++ b/examples/sending emails/test-attachment.txt @@ -0,0 +1 @@ +This attachment was sent by the SQLPage email example. diff --git a/examples/sending emails/test.hurl b/examples/sending emails/test.hurl index 17ba5c638..e526e6ad5 100644 --- a/examples/sending emails/test.hurl +++ b/examples/sending emails/test.hurl @@ -1,7 +1,96 @@ +DELETE http://127.0.0.1:8025/api/v1/messages +HTTP 200 + GET http://127.0.0.1:8080/ HTTP 200 [Asserts] -xpath "string(//form/@action)" == "email.sql" -xpath "string(//input[@name='to_email']/@value)" == "recipient@example.com" -xpath "string(//input[@name='subject']/@value)" == "Test email" -xpath "string(//textarea[@name='message_text'])" == "This is a test email" +xpath "string(//a[contains(., 'Simple email')]/@href)" == "simple.sql" +xpath "string(//a[contains(., 'Advanced email')]/@href)" == "advanced.sql" + +GET http://127.0.0.1:8080/simple.sql +HTTP 200 +[Asserts] +xpath "string(//form/@action)" == "send-simple.sql" +xpath "string(//form/@method)" == "post" +xpath "string(//input[@name='recipient']/@value)" == "recipient@example.com" +xpath "string(//input[@name='subject']/@value)" == "Simple SMTP demo" +xpath "string(//textarea[@name='body'])" == "Sent with sqlpage.send_mail" + +POST http://127.0.0.1:8080/send-simple.sql +[FormParams] +recipient: recipient@example.com +subject: Simple SMTP demo +body: Sent with the configured sender +HTTP 200 +[Asserts] +xpath "string(//*[contains(@class, 'alert') and contains(., 'Email sent successfully')])" contains "Email sent successfully" + +GET http://127.0.0.1:8025/api/v1/search?query=subject%3A%22Simple%20SMTP%20demo%22 +HTTP 200 +[Captures] +simple_message_id: jsonpath "$.messages[0].ID" +[Asserts] +jsonpath "$.messages_count" == 1 +jsonpath "$.messages[0].Subject" == "Simple SMTP demo" +jsonpath "$.messages[0].To[0].Address" == "recipient@example.com" + +GET http://127.0.0.1:8025/api/v1/message/{{simple_message_id}} +HTTP 200 +[Asserts] +jsonpath "$.From.Name" == "SQLPage Demo" +jsonpath "$.From.Address" == "sqlpage@example.com" +jsonpath "$.Text" contains "Sent with the configured sender" +jsonpath "$.Attachments" count == 0 + +GET http://127.0.0.1:8080/advanced.sql +HTTP 200 +[Asserts] +xpath "string(//form/@action)" == "send-advanced.sql" +xpath "string(//form/@method)" == "post" +xpath "string(//form//button[@type='submit']/@formenctype)" == "multipart/form-data" +xpath "string(//input[@name='second_recipient']/@value)" == "second@example.com" +xpath "string(//input[@name='cc']/@value)" == "team@example.com" +xpath "string(//input[@name='reply_to']/@value)" == "replies@example.com" +xpath "string(//input[@name='attachment']/@type)" == "file" + +POST http://127.0.0.1:8080/send-advanced.sql +[Multipart] +recipient: first@example.com +second_recipient: second@example.com +cc: team@example.com +sender: advanced@example.com +reply_to: replies@example.com +subject: Advanced SMTP demo +body: Sent to a team with an attachment +attachment: file,test-attachment.txt; text/plain +HTTP 200 +[Asserts] +xpath "string(//*[contains(@class, 'alert') and contains(., 'Email sent successfully')])" contains "Email sent successfully" + +GET http://127.0.0.1:8025/api/v1/search?query=subject%3A%22Advanced%20SMTP%20demo%22 +HTTP 200 +[Captures] +advanced_message_id: jsonpath "$.messages[0].ID" +[Asserts] +jsonpath "$.messages_count" == 1 +jsonpath "$.messages[0].Attachments" == 1 + +GET http://127.0.0.1:8025/api/v1/message/{{advanced_message_id}} +HTTP 200 +[Captures] +attachment_part_id: jsonpath "$.Attachments[0].PartID" +[Asserts] +jsonpath "$.Subject" == "Advanced SMTP demo" +jsonpath "$.From.Address" == "advanced@example.com" +jsonpath "$.To[0].Address" == "first@example.com" +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 "$.Attachments[0].FileName" == "test-attachment.txt" +jsonpath "$.Attachments[0].ContentType" == "text/plain" + +GET http://127.0.0.1:8025/api/v1/message/{{advanced_message_id}}/part/{{attachment_part_id}} +HTTP 200 +[Asserts] +body contains "This attachment was sent by the SQLPage email example." diff --git a/src/app_config.rs b/src/app_config.rs index 42760e780..7569a0186 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -8,7 +8,8 @@ use openidconnect::IssuerUrl; use percent_encoding::AsciiSet; use serde::de::Error; use serde::{Deserialize, Deserializer, Serialize}; -use std::net::{SocketAddr, ToSocketAddrs}; +use std::fmt; +use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::time::Duration; @@ -78,7 +79,7 @@ impl AppConfig { config.resolve_timeouts(); - log::debug!("Loaded configuration: {config:#?}"); + log::debug!("Loaded configuration: {:#?}", RedactedSmtpPassword(&config)); log::info!( "Configuration loaded from {}", config.configuration_directory.display() @@ -134,6 +135,35 @@ impl AppConfig { } anyhow::ensure!(self.max_pending_rows > 0, "max_pending_rows cannot be null"); + 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::() + .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'" + ); + for path in &self.oidc_protected_paths { if !path.starts_with('/') { return Err(anyhow::anyhow!( @@ -154,6 +184,18 @@ impl AppConfig { } } +struct RedactedSmtpPassword<'a>(&'a AppConfig); + +impl fmt::Debug for RedactedSmtpPassword<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut config = self.0.clone(); + if config.smtp_password.is_some() { + config.smtp_password = Some("[REDACTED]".to_string()); + } + config.fmt(formatter) + } +} + pub fn load_config() -> anyhow::Result { let cli = parse_cli()?; AppConfig::from_cli(&cli) @@ -221,6 +263,30 @@ pub struct AppConfig { #[serde(default)] pub allow_exec: bool, + /// SMTP server host used by the `sqlpage.send_mail` function. + pub smtp_host: Option, + + /// SMTP server port. Defaults to 25 for plaintext, 465 for implicit TLS, and 587 for STARTTLS. + pub smtp_port: Option, + + /// 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, + + /// Optional SMTP password used by the `sqlpage.send_mail` function when `smtp_username` is set. + pub smtp_password: Option, + + /// Default sender used by the `sqlpage.send_mail` function when the message has no `from` property. + pub smtp_from: Option, + + /// Encryption mode used to connect to `SMTP_HOST`. + #[serde(default)] + pub smtp_tls_mode: SmtpTlsMode, + + /// Maximum combined decoded size of attachments in one email. + #[serde(default = "default_max_email_attachment_size")] + pub max_email_attachment_size: usize, + /// Maximum size of uploaded files in bytes. The default is 10MiB (10 * 1024 * 1024 bytes) #[serde(default = "default_max_file_size")] pub max_uploaded_file_size: usize, @@ -531,6 +597,18 @@ fn default_site_prefix() -> String { '/'.to_string() } +fn validate_smtp_host(host: &str) -> anyhow::Result<()> { + anyhow::ensure!( + !host.trim().is_empty() + && host.trim() == host + && !host.contains('/') + && !host.contains("://") + && (!host.contains(':') || host.parse::().is_ok()), + "smtp_host must be a host name or IP address, without a URL scheme, path, or surrounding whitespace" + ); + Ok(()) +} + fn parse_socket_addr(host_str: &str) -> anyhow::Result { host_str .to_socket_addrs()? @@ -626,6 +704,10 @@ fn default_max_file_size() -> usize { 5 * 1024 * 1024 } +fn default_max_email_attachment_size() -> usize { + 10 * 1024 * 1024 +} + fn default_https_certificate_cache_dir() -> PathBuf { default_web_root().join("sqlpage").join("https") } @@ -680,6 +762,24 @@ pub enum DevOrProd { Development, Production, } + +#[derive(Debug, Deserialize, PartialEq, Clone, Copy, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum SmtpTlsMode { + None, + #[default] + 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 @@ -744,6 +844,43 @@ mod test { assert_eq!(default_site_prefix(), "/".to_string()); } + #[test] + fn smtp_defaults_to_starttls() { + assert_eq!(tests::test_config().smtp_tls_mode, SmtpTlsMode::Starttls); + } + + #[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 smtp_password_is_redacted_from_config_debug_log() { + let mut config = tests::test_config(); + config.smtp_password = Some("super-secret".to_string()); + + let debug = format!("{:?}", RedactedSmtpPassword(&config)); + assert!(debug.contains("smtp_password: Some(\"[REDACTED]\")")); + assert!(!debug.contains("super-secret")); + } + #[test] fn test_encode_uri() { assert_eq!( diff --git a/src/render.rs b/src/render.rs index 17558ef74..49a1efd1d 100644 --- a/src/render.rs +++ b/src/render.rs @@ -358,24 +358,12 @@ impl HeaderContext { } let data_url = get_object_str(options, "data_url") .with_context(|| "The download component requires a 'data_url' property")?; - let rest = data_url - .strip_prefix("data:") - .with_context(|| "Invalid data URL: missing 'data:' prefix")?; - let (mut content_type, data) = rest - .split_once(',') - .with_context(|| "Invalid data URL: missing comma")?; - let mut body_bytes: Cow<[u8]> = percent_encoding::percent_decode(data.as_bytes()).into(); - if let Some(stripped) = content_type.strip_suffix(";base64") { - content_type = stripped; - body_bytes = - base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &body_bytes) - .with_context(|| "Invalid base64 data in data URL")? - .into(); - } + let (content_type, body_bytes) = + crate::webserver::database::blob_to_data_url::decode_data_uri(data_url)?; if !content_type.is_empty() { self.insert_header((header::CONTENT_TYPE, content_type))?; } - self.close_with_body(body_bytes.into_owned()) + self.close_with_body(body_bytes) } fn log(self, data: &JsonValue) -> anyhow::Result { diff --git a/src/webserver/database/blob_to_data_url.rs b/src/webserver/database/blob_to_data_url.rs index b8e1fad0e..c451e2baf 100644 --- a/src/webserver/database/blob_to_data_url.rs +++ b/src/webserver/database/blob_to_data_url.rs @@ -86,6 +86,54 @@ pub fn vec_to_data_uri_value(bytes: &[u8]) -> serde_json::Value { serde_json::Value::String(vec_to_data_uri(bytes)) } +/// Decodes a data URL into its declared media type and raw bytes. +pub fn decode_data_uri(data_url: &str) -> anyhow::Result<(&str, Vec)> { + decode_data_uri_with_limit(data_url, usize::MAX) +} + +/// Decodes a data URL while limiting the size of the decoded bytes. +pub fn decode_data_uri_with_limit( + data_url: &str, + max_decoded_size: usize, +) -> anyhow::Result<(&str, Vec)> { + use anyhow::Context as _; + + let rest = data_url + .strip_prefix("data:") + .context("Invalid data URL: missing 'data:' prefix")?; + let (mut media_type, data) = rest + .split_once(',') + .context("Invalid data URL: missing comma")?; + let is_base64 = media_type.ends_with(";base64"); + let max_percent_decoded_size = if is_base64 { + max_decoded_size + .saturating_add(2) + .saturating_div(3) + .saturating_mul(4) + } else { + max_decoded_size + }; + let percent_decoded = percent_encoding::percent_decode(data.as_bytes()) + .take(max_percent_decoded_size.saturating_add(1)) + .collect::>(); + anyhow::ensure!( + percent_decoded.len() <= max_percent_decoded_size, + "Decoded data exceeds the limit of {max_decoded_size} bytes" + ); + let bytes = if let Some(stripped) = media_type.strip_suffix(";base64") { + media_type = stripped; + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, percent_decoded) + .context("Invalid base64 data in data URL")? + } else { + percent_decoded + }; + anyhow::ensure!( + bytes.len() <= max_decoded_size, + "Decoded data exceeds the limit of {max_decoded_size} bytes" + ); + Ok((media_type, bytes)) +} + #[cfg(test)] mod tests { use super::*; @@ -141,6 +189,35 @@ mod tests { ); } + #[test] + fn decodes_base64_and_percent_encoded_data_urls() { + assert_eq!( + decode_data_uri("data:text/plain;base64,aGVsbG8=").unwrap(), + ("text/plain", b"hello".to_vec()) + ); + assert_eq!( + decode_data_uri("data:text/plain,hello%20world").unwrap(), + ("text/plain", b"hello world".to_vec()) + ); + } + + #[test] + fn limits_decoded_data_url_size_before_decoding() { + assert_eq!( + decode_data_uri_with_limit("data:text/plain;base64,aGVsbG8=", 5).unwrap(), + ("text/plain", b"hello".to_vec()) + ); + assert!(decode_data_uri_with_limit("data:text/plain;base64,aGVsbG8=", 4).is_err()); + assert!(decode_data_uri_with_limit("data:text/plain,hello%20world", 10).is_err()); + } + + #[test] + fn rejects_invalid_data_urls() { + assert!(decode_data_uri("text/plain,hello").is_err()); + assert!(decode_data_uri("data:text/plain").is_err()); + assert!(decode_data_uri("data:;base64,not base64").is_err()); + } + #[test] fn test_vec_to_data_uri() { // Test with empty bytes diff --git a/src/webserver/database/sqlpage_functions/function_traits.rs b/src/webserver/database/sqlpage_functions/function_traits.rs index fc3c282df..7a18a9b99 100644 --- a/src/webserver/database/sqlpage_functions/function_traits.rs +++ b/src/webserver/database/sqlpage_functions/function_traits.rs @@ -210,6 +210,12 @@ impl<'a, 'b: 'a> IntoCow<'a> for &'b str { } } +impl<'a> IntoCow<'a> for () { + fn into_cow(self) -> Option> { + None + } +} + impl<'a, T: IntoCow<'a>> IntoCow<'a> for Option { fn into_cow(self) -> Option> { self.and_then(IntoCow::into_cow) diff --git a/src/webserver/database/sqlpage_functions/functions.rs b/src/webserver/database/sqlpage_functions/functions.rs index 46508eb1a..0cb64a5ff 100644 --- a/src/webserver/database/sqlpage_functions/functions.rs +++ b/src/webserver/database/sqlpage_functions/functions.rs @@ -38,6 +38,7 @@ sqlpage_functions! { request_body_base64, request_method, run_sql, + send_mail, set_variable, uploaded_file_mime_type, uploaded_file_name, diff --git a/src/webserver/database/sqlpage_functions/functions/send_mail.rs b/src/webserver/database/sqlpage_functions/functions/send_mail.rs new file mode 100644 index 000000000..53a4fcbc8 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/send_mail.rs @@ -0,0 +1,556 @@ +use std::{borrow::Cow, fmt}; + +use anyhow::Context; +use lettre::{ + AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, + message::{Attachment, Mailbox, MultiPart, SinglePart, header::ContentType}, + transport::smtp::{ + authentication::Credentials, + client::{Certificate, CertificateStore, Tls, TlsParameters}, + }, +}; +use serde::Deserialize; + +use crate::{ + app_config::{AppConfig, SmtpTlsMode}, + webserver::{ + database::blob_to_data_url::decode_data_uri_with_limit, + http_client::native_certificate_der, http_request_info::RequestInfo, + }, +}; + +#[derive(Deserialize)] +#[serde(untagged)] +enum Recipients { + One(String), + Many(Vec), +} + +impl Recipients { + fn parse(self, field: RecipientField) -> SendMailResult> { + let recipients = match self { + Self::One(recipient) => vec![recipient], + Self::Many(recipients) => recipients, + }; + if recipients.is_empty() { + return Err(field.invalid_address(None)); + } + recipients + .into_iter() + .map(|recipient| { + recipient + .parse::() + .map_err(|_| field.invalid_address(Some(recipient))) + }) + .collect() + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct MailAttachment<'a> { + #[serde(borrow)] + filename: Cow<'a, str>, + #[serde(borrow)] + data_url: Cow<'a, str>, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct MailRequest<'a> { + to: Recipients, + #[serde(default)] + cc: Option, + #[serde(borrow)] + subject: Cow<'a, str>, + #[serde(borrow)] + body: Cow<'a, str>, + #[serde(borrow, default, rename = "from")] + from: Option>, + #[serde(borrow, default)] + reply_to: Option>, + #[serde(borrow, default)] + attachments: Vec>, +} + +#[derive(Debug, Clone, Copy)] +enum RecipientField { + To, + Cc, +} + +impl RecipientField { + fn invalid_address(self, address: Option) -> SendMailError { + match self { + Self::To => SendMailError::InvalidEmailTo { address }, + Self::Cc => SendMailError::InvalidEmailCc { address }, + } + } +} + +#[derive(Debug)] +enum SendMailError { + InvalidMessage(anyhow::Error), + SmtpNotConfigured, + MissingEmailFrom, + InvalidEmailFrom { address: String }, + InvalidEmailTo { address: Option }, + InvalidEmailCc { address: Option }, + InvalidEmailReplyTo { address: String }, + InvalidAttachmentFilename { index: usize }, + InvalidAttachment { + index: usize, + reason: anyhow::Error, + }, + SmtpTlsFailed(anyhow::Error), + SmtpTimeout(anyhow::Error), + SmtpRejected(anyhow::Error), + SmtpConnectionFailed(anyhow::Error), +} + +impl SendMailError { + const fn code(&self) -> &'static str { + match self { + Self::InvalidMessage(_) => "INVALID_MESSAGE", + Self::SmtpNotConfigured => "SMTP_NOT_CONFIGURED", + Self::MissingEmailFrom => "MISSING_EMAIL_FROM", + Self::InvalidEmailFrom { .. } => "INVALID_EMAIL_FROM", + Self::InvalidEmailTo { .. } => "INVALID_EMAIL_TO", + Self::InvalidEmailCc { .. } => "INVALID_EMAIL_CC", + Self::InvalidEmailReplyTo { .. } => "INVALID_EMAIL_REPLY_TO", + Self::InvalidAttachmentFilename { .. } | Self::InvalidAttachment { .. } => { + "INVALID_ATTACHMENT" + } + Self::SmtpTlsFailed(_) => "SMTP_TLS_FAILED", + Self::SmtpTimeout(_) => "SMTP_TIMEOUT", + Self::SmtpRejected(_) => "SMTP_REJECTED", + Self::SmtpConnectionFailed(_) => "SMTP_CONNECTION_FAILED", + } + } +} + +impl fmt::Display for SendMailError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidMessage(reason) => write_reason(formatter, "Invalid email message", reason), + Self::SmtpNotConfigured => formatter.write_str( + "sqlpage.send_mail() requires the smtp_host configuration option", + ), + Self::MissingEmailFrom => formatter.write_str( + "Email has no from address; set its from property or configure smtp_from", + ), + Self::InvalidEmailFrom { address } => { + write!(formatter, "'{address}' is not a valid from email address") + } + Self::InvalidEmailTo { address } => { + write_invalid_recipient(formatter, "to", address.as_deref()) + } + Self::InvalidEmailCc { address } => { + write_invalid_recipient(formatter, "cc", address.as_deref()) + } + Self::InvalidEmailReplyTo { address } => { + write!(formatter, "'{address}' is not a valid reply_to email address") + } + Self::InvalidAttachmentFilename { index } => { + write!(formatter, "Attachment filename at index {index} must not be empty") + } + Self::InvalidAttachment { index, reason } => { + write_reason(formatter, &format!("Invalid attachment at index {index}"), reason) + } + Self::SmtpTlsFailed(reason) => { + write_reason(formatter, "Unable to establish SMTP TLS", reason) + } + Self::SmtpTimeout(reason) => { + write_reason(formatter, "SMTP operation timed out", reason) + } + Self::SmtpRejected(reason) => { + write_reason(formatter, "SMTP server rejected the email", reason) + } + Self::SmtpConnectionFailed(reason) => { + write_reason(formatter, "Unable to communicate with the SMTP server", reason) + } + } + } +} + +type SendMailResult = Result; + +fn write_invalid_recipient( + formatter: &mut fmt::Formatter<'_>, + field: &str, + address: Option<&str>, +) -> fmt::Result { + match address { + Some(address) => write!(formatter, "'{address}' is not a valid {field} email address"), + None => write!(formatter, "{field} must contain at least one email address"), + } +} + +fn write_reason( + formatter: &mut fmt::Formatter<'_>, + context: &str, + reason: &anyhow::Error, +) -> fmt::Result { + write!(formatter, "{context}: ")?; + if formatter.alternate() { + write!(formatter, "{reason:#}") + } else { + fmt::Display::fmt(reason, formatter) + } +} + +/// Sends an email through the configured SMTP relay. +pub(super) async fn send_mail( + request: &RequestInfo, + mail_request: Option>, +) -> Option { + let mail_request = mail_request?; + Some(send_mail_result_json( + send_mail_with_config(&request.app_state.config, &mail_request).await, + )) +} + +fn send_mail_result_json(result: SendMailResult<()>) -> String { + match result { + Ok(()) => serde_json::json!({ "status": "accepted" }).to_string(), + Err(error) => { + let message = format!("{error:#}"); + log::warn!( + "sqlpage.send_mail failed with {}: {message}", + error.code() + ); + serde_json::json!({ + "status": "error", + "error_code": error.code(), + "error": message, + }) + .to_string() + } + } +} + +async fn send_mail_with_config(config: &AppConfig, mail_request: &str) -> SendMailResult<()> { + let host = config + .smtp_host + .as_deref() + .ok_or(SendMailError::SmtpNotConfigured)?; + let parsed: MailRequest<'_> = serde_json::from_str(mail_request) + .context("sqlpage.send_mail() expects a valid JSON object") + .map_err(SendMailError::InvalidMessage)?; + + let email = build_email(config, parsed)?; + + let tls = smtp_tls(config, host)?; + let port = config + .smtp_port + .unwrap_or_else(|| config.smtp_tls_mode.default_port()); + let mut mailer = AsyncSmtpTransport::::builder_dangerous(host) + .port(port) + .tls(tls); + if let (Some(username), Some(password)) = (&config.smtp_username, &config.smtp_password) { + mailer = mailer.credentials(Credentials::new(username.clone(), password.clone())); + } + + let response = mailer.build().send(email).await.map_err(|error| { + let is_timeout = error.is_timeout(); + let is_tls = error.is_tls(); + let is_rejected = error.is_transient() || error.is_permanent(); + let reason = anyhow::Error::new(error) + .context(format!("Unable to send email through {host}:{port}")); + if is_timeout { + SendMailError::SmtpTimeout(reason) + } else if is_tls { + SendMailError::SmtpTlsFailed(reason) + } else if is_rejected { + SendMailError::SmtpRejected(reason) + } else { + SendMailError::SmtpConnectionFailed(reason) + } + })?; + log::debug!("SMTP relay accepted email: {response:?}"); + Ok(()) +} + +fn build_email(config: &AppConfig, request: MailRequest<'_>) -> SendMailResult { + let MailRequest { + to, + cc, + subject, + body, + from, + reply_to, + attachments, + } = request; + + let sender = from + .as_deref() + .or(config.smtp_from.as_deref()) + .ok_or(SendMailError::MissingEmailFrom)?; + let sender = sender + .parse::() + .map_err(|_| SendMailError::InvalidEmailFrom { + address: sender.to_string(), + })?; + let mut email = Message::builder() + .from(sender) + .subject(subject.as_ref()); + for recipient in to.parse(RecipientField::To)? { + email = email.to(recipient); + } + if let Some(cc) = cc { + for recipient in cc.parse(RecipientField::Cc)? { + email = email.cc(recipient); + } + } + if let Some(reply_to) = reply_to { + let parsed_reply_to = reply_to.parse::().map_err(|_| { + SendMailError::InvalidEmailReplyTo { + address: reply_to.to_string(), + } + })?; + email = email.reply_to(parsed_reply_to); + } + if attachments.is_empty() { + return email + .header(ContentType::TEXT_PLAIN) + .body(body.into_owned()) + .context("Unable to build email message") + .map_err(SendMailError::InvalidMessage); + } + + let mut remaining_attachment_size = config.max_email_attachment_size; + let mut multipart = 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 }); + } + let (media_type, bytes) = + decode_data_uri_with_limit(&attachment.data_url, remaining_attachment_size) + .with_context(|| format!("Invalid attachment data_url at index {index}")) + .map_err(|reason| SendMailError::InvalidAttachment { index, reason })?; + remaining_attachment_size = remaining_attachment_size + .checked_sub(bytes.len()) + .context("Attachments exceed max_email_attachment_size") + .map_err(|reason| SendMailError::InvalidAttachment { index, reason })?; + let media_type = if media_type.is_empty() { + "application/octet-stream" + } else { + media_type + }; + let content_type = ContentType::parse(media_type) + .with_context(|| format!("Invalid attachment media type at index {index}")) + .map_err(|reason| SendMailError::InvalidAttachment { index, reason })?; + multipart = multipart.singlepart(Attachment::new(attachment.filename.into_owned()).body( + bytes, + content_type, + )); + } + email + .multipart(multipart) + .context("Unable to build email message") + .map_err(SendMailError::InvalidMessage) +} + +fn smtp_tls(config: &AppConfig, host: &str) -> SendMailResult { + 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().map_err(SendMailError::SmtpTlsFailed)? { + parameters = parameters.add_root_certificate( + Certificate::from_der(certificate.as_ref().to_vec()) + .context("Unable to configure an SMTP root certificate") + .map_err(SendMailError::SmtpTlsFailed)?, + ); + } + } else { + parameters = parameters.certificate_store(CertificateStore::WebpkiRoots); + } + let parameters = parameters + .build_rustls() + .context("Unable to configure SMTP TLS") + .map_err(SendMailError::SmtpTlsFailed)?; + 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::{ + io::{BufRead, BufReader, Write}, + net::{TcpListener, TcpStream}, + sync::mpsc, + thread, + }; + + use super::{ + SendMailError, SmtpTlsMode, send_mail_result_json, send_mail_with_config, + }; + use crate::app_config::tests::test_config; + + #[tokio::test] + 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_port = Some(port); + config.smtp_tls_mode = SmtpTlsMode::None; + + send_mail_with_config( + &config, + r#"{ + "to": "admin@example.com", + "from": "contact@example.com", + "subject": "SMTP test", + "body": "hello smtp" + }"#, + ) + .await + .unwrap(); + + let data = received.recv().unwrap(); + assert!(data.contains("Subject: SMTP test")); + assert!(data.contains("hello smtp")); + } + + #[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!(matches!(&error, SendMailError::InvalidMessage(_))); + assert!(error.to_string().contains("expects a valid JSON object")); + } + + #[tokio::test] + async fn reports_the_invalid_address_field() { + let mut config = test_config(); + config.smtp_host = Some("localhost".to_string()); + let error = send_mail_with_config( + &config, + r#"{ + "to":"xxx", + "from":"contact@example.com", + "subject":"test", + "body":"hello" + }"#, + ) + .await + .unwrap_err(); + + assert!(matches!( + &error, + SendMailError::InvalidEmailTo { + address: Some(address) + } if address == "xxx" + )); + assert_eq!(error.to_string(), "'xxx' is not a valid to email address"); + } + + #[tokio::test] + async fn rejects_combined_attachments_over_decoded_size_limit() { + let mut config = test_config(); + config.smtp_host = Some("localhost".to_string()); + config.max_email_attachment_size = 4; + let error = send_mail_with_config( + &config, + r#"{ + "to":"admin@example.com", + "from":"contact@example.com", + "subject":"test", + "body":"hello", + "attachments":[ + {"filename":"one.txt","data_url":"data:text/plain;base64,YWJj"}, + {"filename":"two.txt","data_url":"data:text/plain;base64,ZGVm"} + ] + }"#, + ) + .await + .unwrap_err(); + assert!(matches!( + &error, + SendMailError::InvalidAttachment { .. } + )); + assert!(format!("{error:#}").contains("Decoded data exceeds the limit of 1 bytes")); + } + + #[test] + fn serializes_success_and_errors_as_json() { + assert_eq!( + serde_json::from_str::(&send_mail_result_json(Ok(()))).unwrap(), + serde_json::json!({ "status": "accepted" }) + ); + assert_eq!( + serde_json::from_str::(&send_mail_result_json(Err( + SendMailError::SmtpConnectionFailed(anyhow::anyhow!("SMTP failed")) + ))) + .unwrap(), + serde_json::json!({ + "status": "error", + "error_code": "SMTP_CONNECTION_FAILED", + "error": "Unable to communicate with the SMTP server: SMTP failed", + }) + ); + } + + fn start_smtp_server() -> (String, u16, mpsc::Receiver) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (sender, receiver) = mpsc::channel(); + thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + sender.send(handle_smtp_connection(stream)).unwrap(); + }); + (address.ip().to_string(), address.port(), receiver) + } + + 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 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_response(&mut stream, "250 localhost"); + } else if command == "DATA" { + write_response(&mut stream, "354 End data with ."); + loop { + let line = read_line(&mut reader); + if line == ".\r\n" || line == ".\n" { + break; + } + data.push_str(&line); + } + write_response(&mut stream, "250 Message accepted"); + } else if command == "QUIT" { + write_response(&mut stream, "221 Bye"); + break; + } else { + write_response(&mut stream, "250 OK"); + } + } + data + } + + fn read_line(reader: &mut BufReader) -> String { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + line + } + + fn write_response(stream: &mut TcpStream, response: &str) { + write!(stream, "{response}\r\n").unwrap(); + } +} diff --git a/src/webserver/http_client.rs b/src/webserver/http_client.rs index e935ad46b..1f934b705 100644 --- a/src/webserver/http_client.rs +++ b/src/webserver/http_client.rs @@ -3,7 +3,12 @@ use anyhow::{Context, anyhow}; use rustls_native_certs::CertificateResult; use std::sync::OnceLock; -static NATIVE_CERTS: OnceLock> = OnceLock::new(); +struct NativeCertificates { + certificates: Vec>, + root_store: rustls::RootCertStore, +} + +static NATIVE_CERTIFICATES: OnceLock> = OnceLock::new(); pub fn make_http_client(config: &crate::app_config::AppConfig) -> anyhow::Result { 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 { 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={:?}", diff --git a/tests/sql_test_files/data/send_mail_null.sql b/tests/sql_test_files/data/send_mail_null.sql new file mode 100644 index 000000000..96ca11e0c --- /dev/null +++ b/tests/sql_test_files/data/send_mail_null.sql @@ -0,0 +1,2 @@ +select null as expected, + sqlpage.send_mail(null) as actual;