Special Page Features — HTMQL Documentation

Special Page Features

Redirecting Pages

Use the special alias redirect to cause a page to redirect to the desired location instead of rendering HTML. The SQL command used with a redirect alias must return a column named redirect, e.g. DELETE:redirect:SELECT '/list' AS [redirect];

If a method containing redirect has other SQL commands, those commands must appear before the redirect - otherwise the page will redirect before they execute.

Redirect targets are same-site by default. The target must be a relative path beginning with a single / (e.g. /invoices/42, optionally with a query string or fragment). Absolute URLs (https://…), protocol-relative URLs (//host), and values containing control characters are rejected - the user is sent to / instead and the blocked target is logged as a SECURITY event. This prevents an open redirect when user input flows into the target (e.g. SELECT '/items?back=' || @return AS redirect). A /-relative path with user input after the leading segment (such as /items?back=//x) is safe and allowed.

To permit specific off-site redirects (e.g. handing off to a payment provider), add the destination host to pages.externalRedirects in the configuration file. A target whose host matches an allowlisted entry is then permitted; everything else still falls back to /. The login flow's own return_to handling is always same-site only and never consults this allowlist.

Unsaved-Changes Detection

The standard header.html includes a built-in, opt-in "dirty guard" so a page can warn the user before they navigate away with unsaved edits, and can keep record-level actions (Print, Download, Email, etc.) disabled until the page is saved. It is implemented entirely with the bundled Hyperscript library - no configuration and no per-app JavaScript.

A page opts in with small attribute hooks:

  • _="install DirtyGuard" on a form (or an inline-editor container) makes field changes mark it unsaved; leaving via a link, the menu, a browser refresh, or a tab close then prompts.
  • _="install DirtyControl" (with data-dirty-for) on a Save/Cancel button enables it only while that form has unsaved changes; data-dirty-revert lets a Cancel reload the page to discard edits without a redundant prompt.
  • _="install DirtyDisable" on an action button disables it whenever anything on the page is unsaved.

Because the guard lives in the header (outside the swapped #page region), it works across HTMX navigation without any page-level setup beyond the hooks above. The browser Back/forward button is also guarded, but only on Chromium browsers (via the Navigation API); on Firefox/Safari it silently discards unsaved edits. Modal dialogs are the one case that isn't covered (closing a <dialog> is outside htmx's request flow) - as a rule, keep DB-writing, multi-field edits on a normal #page route rather than in a modal if they need guarding. See the full pattern, attribute reference, and the limitations rationale under Unsaved-changes guard in AGENTS.md.

File Uploads and Downloads

Uploads to Database

The maximum size of a file upload is controlled by server.maxUpload in the configuration file (default: 32 MB). Requests exceeding this limit will be rejected before the page is processed.

When POST/PUT/PATCH contains form files, that form field's name will be available with the following variables for writing file data to the database via INSERT/EXEC/etc.:

  • @[field]_name - the file name
  • @[field]_type - the MIME type of the file
  • @[field]_size - the size in bytes
  • @[field]_binary - the binary contents of the file

If multiple files are uploaded with a field, SQL commands containing the file variables will be repeated for each file and any template data they output will be appended under the same alias (NOTE: be aware of this - it means the rows returned by SELECT statements will be doubled if they reference a file upload alias)

If no files were uploaded, then the file variables will not be available - which means SQL commands using those variables will not be executed. Therefore, if you are storing image data in the same table that's being INSERTed, you will need to apply the file uploads as a secondary UPDATE command during the method.

Alternate SQL commands cannot be used with file parameters due to the complexities in handling a command that fails partway through processing multiple files. If there is a failure during a SQL command with file parameters and alternates are defined, the alternates will not be executed.

Downloads from Database

GET commands can use the special aliases download or binary paired with a SELECT or EXEC to retrieve binary data from the database. The SQL result must include columns named file_name, file_binary, file_type, and file_size. These column names only take effect when paired with the special alias.

The download alias sets Content-Disposition to attachment, prompting the browser to save the file. The binary alias sets Content-Disposition to inline, displaying the file directly in the browser.

Inline serving is restricted for safety. All file responses are sent with X-Content-Type-Options: nosniff. The binary (inline) alias renders inline only known-safe content types - images (image/png, image/jpeg, image/gif, image/webp, image/bmp, image/tiff, image/avif, icons) and application/pdf. Any other content type (e.g. text/html, image/svg+xml, application/octet-stream) is automatically downgraded to attachment so user-supplied markup or scripts cannot execute in the application's origin (stored-XSS defense). Use download when you intend the file to be saved rather than displayed.

Currently if multiple rows are returned by the query, only the first one is returned to the browser.

No HTML template is rendered after a download completes. These aliases are therefore almost always used in a standalone page, but can be paired with other SQL commands (e.g. an INSERT into an audit log) as long as those commands appear before the download alias.

Storage Locations (Filesystem, S3, and SFTP)

In addition to storing file bytes directly in the database (above), files can be written to and read from named storage locations defined in the configuration file. A location has a type of local (server filesystem), s3 (any S3-compatible object store, including AWS S3, MinIO, Cloudflare R2, and Backblaze B2), or sftp (a file read from / written to an SSH/SFTP server using key-based auth).

The sftp type is useful for surfacing files that live on an SFTP host (for example PDFs flattened by an external form-collection pipeline) to an already-authenticated HTMQL user: the server holds the shared SSH key and streams the file through, so the browser never sees the SFTP credentials and access is governed by the page's own +role rules. (The provider dials a fresh connection per request; for high-traffic use a pooled client can be added later.)

"storage": {
  "default": "docs-s3",
  "maxUploadMB": 50,
  "allowedExtensions": ["pdf", "png", "jpg", "docx"],
  "allowedMimeTypes": [],
  "locations": {
    "docs-local": {
      "type": "local",
      "basePath": "C:/htmql-data/uploads"
    },
    "forms-sftp": {
      "type": "sftp",
      "host": "${SFTP_HOST}",
      "port": 22,
      "username": "${SFTP_USERNAME}",
      "privateKeyPath": "${SFTP_KEY_PATH}",
      "privateKeyPassphrase": "${SFTP_KEY_PASSPHRASE}",
      "knownHostsPath": "${SFTP_KNOWN_HOSTS}"
    },
    "forms-sftp-password": {
      "type": "sftp",
      "host": "${SFTP_HOST}",
      "port": 22,
      "username": "${SFTP_USERNAME}",
      "password": "${SFTP_PASSWORD}",
      "knownHostsPath": "${SFTP_KNOWN_HOSTS}"
    },
    "docs-s3": {
      "type": "s3",
      "endpoint": "s3.amazonaws.com",
      "region": "ca-central-1",
      "bucket": "my-bucket",
      "prefix": "uploads/",
      "accessKeyId": "${S3_KEY}",
      "secretAccessKey": "${S3_SECRET}",
      "useSSL": true,
      "pathStyle": false,
      "publicUrlBase": "",
      "default": true
    }
  }
}

The reserved location name sql is implicit (never declared) and means "the SQL query reads or writes the bytes itself" - i.e. the database upload/download behaviour described above. This lets a single page treat database-stored, local, and S3 files uniformly by selecting the location from a column (see the download example below).

Default location resolution:

  • If exactly one location is defined, it is automatically the default.
  • Otherwise, the default is taken from storage.default (a location name) or from a location with "default": true. If both are present they must agree.
  • Setting storage.default to "" disables any default, making storage_location mandatory on every save/download.
  • When no default is configured and a page omits storage_location, the request fails with an error.

Per-location limits (maxUploadMB, allowedExtensions, allowedMimeTypes) may be set at the storage level as defaults for all locations, or under an individual location to override them. An empty/omitted allowedExtensions means any extension is allowed; an empty/omitted allowedMimeTypes skips the MIME check. When both are set, a file must satisfy both (its extension allowed and its content type allowed) - the same "How the extension and content-type checks combine" rules described under the uploads configuration settings apply identically here. These per-location allowlists are applied after the site-wide uploads filter, only for storage-provider (save-alias) writes. A location's maxUploadMB may not exceed the global server.maxUpload hard limit (the server will refuse to start otherwise).

SFTP host key verification (required). To defend against man-in-the-middle interception of the SFTP credentials and file bytes, every sftp location must verify the server's SSH host key. You supply that trust anchor in one of two ways - knownHostsPath (a standard OpenSSH known_hosts file) or hostKey (a single pinned public key). If neither is set, the location refuses to start unless you explicitly opt out with "insecureSkipHostKeyCheck": true (strongly discouraged - it disables the protection and logs a warning on every startup).

Establishing the trust anchor. Run one of the following from the HTMQL server host (or any machine on the same trusted network path to the SFTP server) and verify the fingerprint out-of-band with the SFTP server's operator before trusting it:

  • known_hosts file - scan the server's keys and save them to a file the config points at:

    # Append the SFTP server's host keys to a known_hosts file
    ssh-keyscan -p 22 sftp.example.com >> /etc/htmql/sftp_known_hosts
    
    # Confirm the fingerprints match what the operator published before trusting them
    ssh-keygen -lf /etc/htmql/sftp_known_hosts
    

    Then set "knownHostsPath": "/etc/htmql/sftp_known_hosts" (or "${SFTP_KNOWN_HOSTS}"). This file may contain entries for multiple hosts and supports the normal known_hosts format (including hashed hostnames). The host/port in the file must match the location's host/port.

    Windows / PowerShell: write the file as UTF-8. PowerShell's > redirection and Out-File default to UTF-16 LE with a BOM, which the host-key parser cannot read - it fails at startup with knownhosts: ...:1: illegal base64 data at input byte 0 even though the content looks correct. Generate the file with an explicit encoding instead:

    ssh-keyscan -p 22 sftp.example.com | Out-File -Encoding utf8 C:\htmql\sftp_known_hosts
    

    To fix a file already saved as UTF-16, re-encode it:

    $f = "C:\htmql\sftp_known_hosts"
    $lines = Get-Content -Path $f -Encoding Unicode
    [IO.File]::WriteAllLines($f, $lines, (New-Object Text.UTF8Encoding $false))
    

    A leading fffe and an xx00 xx00 byte pattern (check with Format-Hex or head -c 16 file | xxd) is the UTF-16 giveaway.

  • Single pinned key (hostKey) - copy one authorized_keys-format line for the server's preferred key type:

    # Print just the ed25519 host key line (drop -t ed25519 to see all key types)
    ssh-keyscan -t ed25519 -p 22 sftp.example.com
    # → sftp.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...
    

    Take the key-type-and-base64 portion (everything after the hostname) and set it as hostKey, e.g. "hostKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...". Pinning is simplest for a single, stable server; use knownHostsPath when the host may present multiple key types or rotate keys. If the server later rotates its host key, update whichever value you configured (otherwise connections will fail closed with a host-key-mismatch error - which is the protection working).

Verify, don't just trust-on-first-use. ssh-keyscan reports whatever key the host currently presents; on a network that is already being intercepted it would happily record the attacker's key. Always cross-check the fingerprint from ssh-keygen -lf (or the single key's fingerprint) against a value the SFTP operator gave you through a separate channel before putting it into the config.

Uploads to a Storage Location (save)

Use the special alias save during a POST/PUT/PATCH request. Its SQL command is a SELECT that returns, for each file to write, the columns:

Column Required Meaning
storage_location No (if a default is set) The configured location name, or sql to skip the filesystem write
file_path Yes Destination key/relative path within the location
file_field Yes The name of the multipart form-file input whose bytes to store
file_name No Overrides the stored/reported file name (defaults to the uploaded filename)
file_type No Overrides the content type (defaults to the detected type)

After each file is written, the following parameters become available to subsequent SQL commands in the same method (typically an INSERT recording the upload):

  • @save_location - the location the file was written to
  • @save_path - the destination path/key
  • @save_url - the object URL (S3 with publicUrlBase, otherwise empty)
  • @save_size - the size in bytes
  • @save_name - the stored file name
  • @save_type - the content type of the stored file
  • @save_error - the error message if the write failed, otherwise empty

If the save SELECT returns no rows the alias is skipped (so it can be gated with WHERE @action='upload'). If multiple files are uploaded to the field, each is written and any command referencing @save_* is repeated once per saved file - the same looping behaviour as database uploads. Mark the alias critical (!save) to roll back the transaction when a write fails.

The same @save_* results are exposed to the page template under .save (a row per saved file).

Overwrite behaviour. Writing to a file_path that already exists always overwrites the existing file - on both local (the file is truncated and replaced) and s3 (the object key is replaced) locations. There is no error, versioning, or uniqueness check. It is up to the page author to make file_path unique (e.g. by including a record id, GUID, or timestamp) where overwriting is not wanted, or to deliberately reuse a path where replacing the previous file is the intent.

Storage writes are not part of the SQL transaction. The save alias writes the bytes to the storage location before the subsequent commands (such as the recording INSERT) run. The write to local disk or S3 cannot be rolled back. This means that if a later command fails and the transaction is rolled back - including a failed critical command - the file remains written even though no database row references it, leaving an orphaned file. Because of the overwrite behaviour above, a retry that uses the same file_path simply replaces the orphan, so this is usually harmless; but for paths that embed a unique value (id/GUID/timestamp), a rolled-back upload can leave a stray object behind. If orphaned files matter for a location, reconcile them out of band (e.g. a periodic sweep comparing stored objects against the database), or order the page so the storage write is the last fallible step.

Example - store an uploaded document in S3 and record it in the database:

{{/*
#60 Upload Document
@project_id=0

POST:!save:SELECT 'docs-s3' AS storage_location,
                  'projects/' + CAST(@project_id AS varchar) + '/' + @doc_name AS file_path,
                  'doc' AS file_field;
POST:!!record:INSERT INTO documents (project_id, name, location, path, url, size)
              VALUES (@project_id, @doc_name, @save_location, @save_path, @save_url, @save_size);
POST:redirect:SELECT '/projects/' + CAST(@project_id AS varchar) AS [redirect];
*/}}

<form method="post" enctype="multipart/form-data">
  <input type="file" name="doc">
  <input name="doc_name" placeholder="Document name">
  <button>Upload</button>
</form>
Downloads from a Storage Location (download / binary)

The download and binary aliases (see Downloads from Database above) accept an optional storage_location column. When it is absent or sql (and file_binary is present) the existing database-blob behaviour applies. When it names a local, s3, or sftp location, the server fetches the object identified by file_path and streams it to the browser:

Column Meaning
storage_location Location name; absent/sql streams file_binary (database mode)
file_path Source key/path within the location (required for non-sql locations)
file_name Download filename (defaults to the base name of file_path)
file_type Content type (defaults to the type reported by the store)
delivery stream (default) to proxy the bytes through the server, or redirect to issue a short-lived presigned URL and 302-redirect (S3 only; ignored for local, unsupported for sftp)

As with database downloads, no HTML is rendered after the response, and other commands (e.g. an audit-log INSERT) may precede the alias.

Example - log access, then stream a file that may live in the database, on local disk, or in S3:

{{/*
@id=0
GET:INSERT INTO access_log (doc_id, viewer) VALUES (@id, '{{username}}');
GET:download:SELECT location AS storage_location, path AS file_path,
                    name AS file_name, mime AS file_type
             FROM documents WHERE id=@id;
*/}}

Rows whose location column is sql fall back to a file_binary column on the same row; rows naming a configured location stream from that store. To offload bandwidth for large S3 objects, add 'redirect' AS delivery to the SELECT.

External Application Execution

The alias execute can be used to run an external application, as a way of providing more extensive logic on the backend.

External application execution is disabled by default and must be explicitly enabled by setting execute.enabled=true in the configuration file.

For security, use execute.allowedExecutables to specify a whitelist of permitted executable paths. When the list is empty any executable may be called, so a whitelist is strongly recommended in production environments. Additional execution settings:

  • execute.timeoutSeconds - maximum time to wait for the executable before it is terminated (default: 30)
  • execute.maxOutputBytes - maximum bytes captured from stdout (default: 65536)
  • execute.allowOnGet - by default execute only runs during POST/PUT/PATCH/DELETE requests. Set to true to also allow it on GET requests.

The SQL command used with an execute alias must return the following columns:

  • executable - the path of the executable to run
  • mode - blocking to pause page rendering until the executable completes, or nonblocking to run it in the background
  • arg<#> - arguments to pass to the executable in sequence (e.g. arg1, arg2)

Details about the execution are available in the template data under .execute:

  • executable - the executable path that was run
  • args - the arguments passed to the executable
  • mode - the execution mode (blocking or nonblocking)
  • started - true if the executable was successfully started
  • pid - the process ID of the executable, if started
  • output - the standard output captured from the executable
  • success - true if the executable completed without error
  • error - description of why the executable failed to run, if applicable

PDF Rendering

Use the special alias pdf to render the current HTMQL page as a PDF instead of returning the page as HTML.

The pdf alias runs during GET requests. Its SQL command must return at least a file_name column. The SQL result row can include optional columns to control rendering per request:

  • pdf_mode - controls browser disposition: inline or binary to display in-browser, download or attachment to prompt a file download
  • margin_inches or margin_cms - overrides the default page margin for this render
  • timeout - overrides the default generation timeout for this render
  • attach_alias - name of another alias on the same page whose rows are existing PDF documents to append after the rendered page, or a comma-separated list of alias names (see Appending PDF documents)

Default margins and timeout can be set globally in the configuration file using pdf.marginInches, pdf.marginCms, and pdf.timeoutSeconds.

Appending PDF documents to a rendered PDF

Set attach_alias on the pdf row to concatenate extra PDF documents after the rendered page - for example, the PDF files a user has attached to a record. Point it at a sibling alias whose rows carry the document bytes using the same download-style columns the download/email aliases use (file_name, file_binary, file_type); only the file_binary column is required for appending. Each document is appended in the order the rows are returned, starting on a new page:

-- Load the stored PDF attachments only when the caller asked for them.
GET:pdf_files:SELECT FileName AS file_name, BinaryData AS file_binary, MimeType AS file_type
    FROM RecordAttachment
    WHERE RecordKey=@key AND MimeType='application/pdf' AND @attachments='1'
    ORDER BY RecordAttachmentKey;

GET:pdf:SELECT
    'Record.pdf' AS file_name,
    'download'   AS pdf_mode,
    CASE WHEN @attachments='1' THEN 'pdf_files' ELSE '' END AS attach_alias
    WHERE @action='pdf';

Every appended row must be a valid PDF. If any attachment is missing, corrupt, encrypted, or not a PDF, the whole download fails rather than returning a partial document - so a bad attachment is noticed rather than silently dropped. Gate the attachment query (and attach_alias) behind a parameter, as above, so the plain download never loads attachment data. Non-PDF file types are not converted; include only application/pdf rows for now.

PDF generation uses Chrome, Chromium, or Microsoft Edge on the server to render the page and print it to PDF. This means the PDF output uses browser-compatible HTML, CSS, images, fonts, and JavaScript. The browser will be automatically detected. If the system fails to detect the browser, or you wish to override the location, use the pdf.chromePath configuration option.

PDF rendering can be disabled by setting pdf.enabled=false in the configuration file.

Conditionally firing the pdf alias

If the pdf alias query returns no rows, PDF generation is skipped and the page renders as normal HTML. This lets a single page serve the on-screen view, a printable view, a PDF, and an email from one file, each selected by a parameter:

GET:pdf:SELECT 'Invoice.pdf' AS file_name WHERE @action='pdf';

The same skip-on-empty behaviour applies to the print and email special aliases, so one page can offer view / print / PDF / email actions chosen by an @action parameter, with only the matching alias firing.

Breaking a tool→server→tool render loop

The pdf alias renders the page in-process - it converts the page's own template output to a PDF in the same request, with no second pass. So a PDF page never re-runs itself, and there is no render-pass to detect or guard against.

A loop can still arise indirectly: if a page launches an external tool (via execute) and that tool calls back into the server to fetch the same page's server-rendered PDF, the callback would re-run the execute alias and relaunch the tool. Break it by gating the side-effecting alias on who is calling. A non-session caller authenticates with a service credential mapped to a dedicated role, so gate on the spoof-proof @auth_roles (or @auth_username):

GET:execute:SELECT 'tool.exe' AS executable, 'nonblocking' AS mode
    WHERE @auth_roles NOT LIKE '%,porenderer,%';

When the tool fetches the PDF as the porenderer service account, the clause is false and the tool is not relaunched. A normal user render leaves it true, so the tool runs as usual. (Note: avoid an underscore in such a role name - _ is a LIKE wildcard.)

Email Sending

Use the special alias email to send an email as part of a POST request. The alias must be enabled in the configuration file (email.enabled = true) and can only run during POST requests.

Unlike most special aliases, email does not prevent the page from rendering after it runs. Subsequent aliases (such as redirect) and the page's HTML template are processed normally, allowing you to navigate the user to a confirmation page or show a success message.

Like the pdf and print aliases, the email alias is skipped when its SQL command returns no rows, so it can share a page with other actions and be gated by a parameter (e.g. ... WHERE @action='email').

Recipient and sender addresses (from, to, cc) are validated before sending: each must be a well-formed email address (a comma-separated list is allowed). A malformed address - or one containing a newline (an attempt at email header injection) - fails the send with .email_error set, so untrusted values flowing in from SQL cannot inject extra headers.

After the email alias runs (in m365_send or smtp mode), the server sets two template variables:

  • .email_sent - true when the message was sent, false when it failed. Unset on requests where the alias did not run (e.g. a GET that loads the form).
  • .email_error - on failure, the reason (e.g. SMTP mode requires email.smtp.host to be configured). Render it to show the user why the send failed.

Use them to render different output for each outcome (for example, a success toast vs. an inline error):

{{if .email_sent}}
  {{/* sent - show confirmation / close the modal */}}
{{else}}
  {{/* form; on a failed POST show the reason */}}
  {{if .email_error}}<div class="bad box">{{.email_error}}</div>{{end}}
{{end}}

When messages are suppressed for the method (e.g. POST:!""), the framework does not display its own error box on a send failure; it only logs the error. This lets a page render its own error UI (for example, an inline message inside a modal) keyed off .email_sent.

Sending modes are selected via the mode column in the SQL result:

Mode Description
m365_draft Creates a draft in the user's Outlook mailbox via Microsoft Graph and redirects the browser to it in Outlook Web. The user reviews and clicks Send. The sent message appears in their Sent Items. Requires OIDC login with Mail.ReadWrite scope.
m365_send Sends immediately via Microsoft Graph on behalf of the logged-in user. The message appears in their Sent Items automatically. Requires OIDC login with Mail.Send scope.
smtp Sends via a configured SMTP server. Does not require OIDC. Sent messages do not automatically appear in anyone's Sent Items.

SQL columns returned by the email alias:

Column Required Description
mode Yes Sending mode: m365_draft, m365_send, or smtp.
to Yes Recipient email address(es), comma-separated.
subject Yes Email subject line.
cc No CC address(es), comma-separated.
bcc No BCC address(es), comma-separated. BCC addresses appear in the SMTP envelope only, not in message headers.
body No Email body - HTML, plain text, or Markdown as returned by the SQL query.
body_type No html (default) or text.
body_format No Set to markdown (or md) to render the body from Markdown into sanitized HTML before sending; this also forces body_type=html. Omit, or use html/text, to send body as-is. Useful for emailing Markdown authored with the Markdown editor.
attach_pdf_url No Server-relative URL whose binary response is attached to the email (e.g. /po/pdf?key=123). The server renders this page in-process as the current principal (its normal page authorization applies). The target page should return binary content using a pdf, binary, or download alias. If it is a pdf-alias page served over HTTPS, set pdf.renderHostname so headless Chrome can fetch its assets over TLS during rendering - see the PDF configuration TLS note.
attach_pdf_name No Filename for the URL-fetched attachment (e.g. PO-123.pdf). Derived from the URL if omitted.
attach_content_type No Override the MIME type of the URL-fetched attachment. Inferred from the response Content-Type header if omitted.
attach_alias No Name of another alias on the same page whose rows are attached as additional files, or a comma-separated list of several alias names. See Attaching files from the database.

An email can carry any number of attachments: the single attach_pdf_url (if set) plus every row returned by the attach_alias query (if set).

Attaching multiple files from the database

To attach binary files already stored in the database (rather than fetched from a URL), add a second query to the page and point attach_alias at it. Each row of that query must return the same columns used by the download special alias:

Column Description
file_name Attachment filename
file_binary Binary file contents
file_type MIME type (defaults to application/octet-stream if blank)

The attachments query must use the same HTTP method as the email alias (normally POST) and must appear before the email alias in the file, so its rows are available when the email is assembled. Rows missing file_name or file_binary are skipped with a logged warning. If the query returns no rows, the email is still sent with whatever other attachments are present.

attach_alias may also be a comma-separated list of alias names, letting an email combine several attachment queries - for example, stored database files in one alias and freshly uploaded form files in another:

'stored_files,uploaded_files' AS [attach_alias]

Uploaded form files become attachment rows naturally: a query that selects the file-upload variables (@field_name, @field_binary, @field_type) returns one row per uploaded file (the file-upload mechanism repeats the command for each file). For example, for a file input named extrafiles:

POST:uploaded_files:SELECT
    @extrafiles_name   AS file_name,
    @extrafiles_binary AS file_binary,
    @extrafiles_type   AS file_type;

Note: the Microsoft 365 modes send all attachments inline in a single Graph request. The practical ceiling is your Microsoft 365 mailbox's maximum message size - tenant-configurable, commonly tens of megabytes and up to 150 MB - not a fixed per-attachment limit; multi-megabyte attachments (and multiple of them in one message) send fine. Older Microsoft documentation cites a ~3 MB inline limit that required a chunked upload session for larger files; that request-size restriction no longer applies to sendMail/createMessage, so this alias does not need upload sessions. A message that exceeds the tenant's message-size policy will still be rejected. SMTP mode is likewise bounded only by the receiving server's message size cap.

Behavior of m365_draft

When m365_draft succeeds it returns the URL of the newly created Outlook Web draft and stops processing the page - any remaining aliases (including redirect) are skipped. The response depends on the request type and outcome:

  • AJAX success (HX-Request: true, set by HTMX or a Hyperscript fetch): 200 OK with the draft URL as the plain-text body (and an HX-Trigger: {"openEmailDraft": "<url>"} header).
  • AJAX failure: 200 OK with a small HTML error page as the body (it does not start with http), so the client can write it straight into the tab it opened.
  • Full-page success (no HX-Request): a 302 redirect to the draft URL in the current tab.

Recommended client pattern - open the draft in a new tab without a stuck spinner

To open the draft in a new tab while keeping the originating page open, the browser must open the tab synchronously inside the click handler (during the user gesture) and only then navigate it once the response arrives. Opening a tab from an asynchronous response callback (for example, from an HTMX HX-Trigger event handler) leaves the originating tab's loading spinner stuck until it is refreshed. The reliable pattern uses a Hyperscript fetch and distinguishes the URL (success) from the HTML error page (failure):

<button type="button"
        _="on fetch:beforeRequest(headers) set headers['HX-Request'] to 'true'
           on click
               -- open the blank tab now, during the user gesture
               set w to window.open('', '_blank')
               if w is null
                   alert('Pop-up blocked. Please allow pop-ups for this site.')
                   halt
               end
               -- POST to the page that runs the email alias
               fetch '/po/pdf?key=123&action=email' with method:'POST'
               then set resp to it
               then if resp.startsWith('http')
                        set w.location to resp     -- success: open the draft
                    else
                        set doc to w.document      -- failure: show the reason in the tab
                        call doc.write(resp)
                        call doc.close()
                    end
        ">
    Email to Vendor
</button>

Note: Hyperscript has no starts with operator - use the JavaScript method resp.startsWith('http'). A plain hx-post button that opens the tab from the openEmailDraft event still works, but is prone to the stuck-spinner behavior described above; prefer the gesture-bound fetch pattern.

Behavior of m365_send and smtp

The page continues rendering after the email is sent. Use a subsequent redirect alias to navigate away, or let the page template render a success message.

OIDC scope configuration for Microsoft 365 modes

Add the required scope to auth.oidc.scopes in config.json:

"auth": {
  "oidc": {
    "scopes": ["openid", "profile", "email", "Mail.ReadWrite"]
  }
}

Use Mail.ReadWrite for m365_draft or Mail.Send for m365_send. The scope must be present when the user logs in - existing sessions do not pick up newly added scopes until the user logs in again.

Example - send a PO to a vendor as an Outlook draft

{{/*
#60 Send PO Email
+procurement

POST:
!!$:po:SELECT po.number, po.id, v.email AS vendor_email
        FROM purchase_orders po
        JOIN vendors v ON v.id = po.vendor_id
        WHERE po.id = @id;

!!email:SELECT
    'm365_draft'                                   AS [mode],
    @vendor_email                                   AS [to],
    N'Purchase Order #' + CAST(@number AS NVARCHAR) AS [subject],
    N'<p>Please find the attached purchase order.</p>
      <p>Contact us with any questions.</p>'        AS [body],
    '/po/pdf?key=' + CAST(@id AS NVARCHAR)          AS [attach_pdf_url],
    N'PO-' + CAST(@number AS NVARCHAR) + '.pdf'    AS [attach_pdf_name];

-- m365_draft returns the draft URL and stops; no redirect alias needed.
-- Trigger this page with the gesture-bound fetch pattern shown above so the
-- draft opens in a new tab. (A full-page POST instead redirects the current tab.)
*/}}
{{/* No HTML template needed - the client opens the returned draft URL */}}

Example - send an invoice via SMTP

{{/*
POST:
!!$:inv:SELECT i.id, i.number, c.email AS client_email
        FROM invoices i
        JOIN clients c ON c.id = i.client_id
        WHERE i.id = @id;

!!email:SELECT
    'smtp'                                          AS [mode],
    @client_email                                   AS [to],
    N'Invoice #' + CAST(@number AS NVARCHAR)        AS [subject],
    N'<p>Please find your invoice attached.</p>'    AS [body],
    '/invoices/pdf?key=' + CAST(@id AS NVARCHAR)    AS [attach_pdf_url],
    N'Invoice-' + CAST(@number AS NVARCHAR) + '.pdf' AS [attach_pdf_name];

redirect:SELECT '/invoices/' + CAST(@id AS NVARCHAR) AS [redirect];
*/}}
{{/* No HTML template needed - redirect alias navigates away */}}

Printing

Use the special alias print to trigger a Javascript function to print the page after all HTML rendering is complete. The print alias fires when its SQL command returns at least one row; the field names and contents do not matter (e.g. SELECT 1 is sufficient). If it returns no rows, printing is skipped - not treated as an error - so the alias can be gated by a parameter, e.g. GET:print:SELECT 1 WHERE @action='print'; (see Conditionally firing the pdf alias).

Printing a server-rendered PDF

The print alias prints the page's HTML. When the printout must be the exact document the pdf alias produces — most notably when it appends stored PDF attachments, which HTML printing cannot include — skip the print alias and print the inline PDF from the client instead: a hidden iframe loads the PDF (pdf_mode = inline) and calls print() on it once loaded, opening the browser print dialog with the merged document.

<button type="button"
        _="on click set #print-frame's src to
           '/record/pdf?key=123&action=pdf&attachments=1&inline=1&t=' + Date.now()">
    Print
</button>

<iframe id="print-frame" title="Print" aria-hidden="true" tabindex="-1"
        style="position:fixed; left:-10000px; top:0; width:800px; height:1000px; border:0;"
        onload="if (this.src) { try { this.contentWindow.print(); } catch (e) { window.open(this.src, '_blank'); } }"></iframe>

With the page's pdf alias returning pdf_mode inline only on request:

GET:pdf:SELECT 'Record.pdf' AS file_name,
    CASE WHEN @inline='1' THEN 'inline' ELSE 'download' END AS pdf_mode,
    CASE WHEN @attachments='1' THEN 'pdf_files' ELSE '' END AS attach_alias
    WHERE @action='pdf';

Notes on the pattern:

  • Park the iframe off-screen with real dimensions rather than display:none or zero size — hidden frames are the flakier path for scripted PDF printing across Chrome versions.
  • The t=Date.now() cache-buster forces a fresh navigation (and a fresh load event) on every click, so repeat prints of the same document work.
  • The if (this.src) guard skips the initial empty-iframe load; the catch falls back to opening the PDF in a new tab when the viewer blocks scripted printing.
  • No pop-up blocker is involved (nothing calls window.open on the success path), so no user-gesture gymnastics are needed.
  • Known cosmetic quirk: Chrome may flash a small black box while its print machinery composites the out-of-process PDF frame. It comes from the browser itself; no iframe styling suppresses it (display:none, zero-size, and off-screen have all been tried).
  • Printing this way costs a server-side Chrome render per click, and the output is pixel-identical to the downloaded PDF — often a feature in itself.
CSS for Print Pages

Print pages benefit from a few specific CSS settings:

Color preservation - Browsers suppress background colors and images by default in print. Add this rule to preserve them:

* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }

Page margins - Since there is no application layout wrapper, control margins directly on the body:

body { margin: 1cm; }

Preventing row breaks - To stop table rows from splitting across page breaks:

tbody tr { break-inside: avoid; }

Borderless data rows with a bordered header - To remove borders from data rows while keeping the shaded header row bordered, apply a class to the table:

table.no-row-borders td { border: none !important; }

Using td (not th) means <th> elements in the header row still receive the default border styles.

When a document may span multiple pages, use flexbox on the page container combined with a small JavaScript calculation to push the footer to the very bottom of the last page.

CSS:

#page-content {
    min-height: calc(100vh - 2cm);  /* 2cm matches the body top + bottom margins */
    display: flex;
    flex-direction: column;
}

HTML structure:

<div id="page-content">
    <!-- letterhead, bill-to, line items, etc. -->

    <div style="flex: 1;"></div>  <!-- spacer: expands to push footer down -->

    <div id="page-footer">
        <!-- totals, payment instructions, etc. -->
    </div>
</div>

JavaScript - round up to a whole number of pages:

window.addEventListener('load', function() {
    var content = document.getElementById('page-content');

    // Round up page-content min-height to a whole number of pages so the
    // flex spacer pushes the footer to the bottom of the last printed page.
    var pageH = window.innerHeight - (2 * 96 / 2.54); // 100vh minus 2cm margins in px
    var pagesNeeded = Math.ceil(content.getBoundingClientRect().height / pageH);
    content.style.minHeight = (pagesNeeded * pageH) + 'px';

    setTimeout(function() {
        window.print();
        window.close();
    }, 100);
});

How it works: window.innerHeight equals one page height in CSS pixels. Subtracting 2 × (96 / 2.54) converts the 2 cm top-and-bottom body margins to pixels (96 / 2.54 is the number of CSS pixels per centimetre at the standard 96 dpi reference density). Dividing the content's actual rendered height by one page height and rounding up gives the total number of pages needed. Setting that product as min-height ensures the flex: 1 spacer stretches to fill the remaining space on the last page, landing the footer at the very bottom.

Notes and Limitations
  • position: fixed footers do not work on multi-page print documents. A fixed-position element repeats at the same viewport-relative coordinates on every printed page. On all pages except the last it will overlap the main content. Use the in-flow flexbox approach above instead.
  • The footer will only appear at the bottom of the last page. On earlier pages of a multi-page document the footer is simply absent - this is a known trade-off of the in-flow approach and is generally acceptable for business documents.
  • CSS @page rules (running headers/footers) exist for true repeating page headers/footers, but browser support is inconsistent and they are outside the scope of this framework.