from the HTML text
- email_html = string.gsub(email_html, "^
", '')
- email_html = string.gsub(email_html, "
$", '')
+ -- Determine the location of the Quarto project directory
+ local project_output_directory = quarto.project.output_directory
+ local dir
+ if (project_output_directory ~= nil) then
+ dir = project_output_directory
+ else
+ local file = quarto.doc.input_file
+ dir = pandoc.path.directory(file)
+ end
- -- Use the Connect email template components along with the `email_html` and
- -- `email_html_preview` objects to generate the email message body for Connect
- -- and the email HTML file (as a local preview)
+ quarto.log.warning("Generating V2 multi-email output format with " .. tostring(email_count) .. " email(s).")
- html_email_body = generate_html_email_from_template(
- email_html,
- connect_date_time,
- connect_report_rendering_url,
- connect_report_url,
- connect_report_subscription_url
- )
-
- html_preview_body = generate_html_email_from_template(
- email_html_preview,
- connect_date_time,
- connect_report_rendering_url,
- connect_report_url,
- connect_report_subscription_url
- )
-
- -- Right after the tag in `html_preview_body` we need to insert a subject line HTML string;
- -- this is the string to be inserted:
- subject_html_preview = "
subject: " .. subject .. "
"
-
- -- insert `subject_html_preview` into `html_preview_body` at the aforementioned location
- html_preview_body = string.gsub(html_preview_body, "", "\n" .. subject_html_preview)
-
- -- For each of the
![]()
tags we need to create a Base64-encoded representation
- -- of the image and place that into the table `email_images` (keyed by `cid`)
-
- local image_data = nil
-
- for cid, img in pairs(image_tbl) do
-
- local image_file = io.open(img, "rb")
-
- if type(image_file) == "userdata" then
- image_data = image_file:read("*all")
- image_file:close()
+ -- Process all emails and generate their previews
+ local emails_for_json = {}
+
+ for idx, email_obj in ipairs(emails) do
+
+ -- Apply document-level fallbacks with warnings
+ if email_obj.subject == "" and subject ~= "" then
+ quarto.log.warning("Email #" .. tostring(idx) .. " has no subject. Using document-level subject.")
+ email_obj.subject = subject
+ end
+
+ if email_obj.email_text == "" and email_text ~= "" then
+ quarto.log.warning("Email #" .. tostring(idx) .. " has no email-text. Using document-level email-text.")
+ email_obj.email_text = email_text
end
- local encoded_data = quarto.base64.encode(image_data)
-
- -- Insert `encoded_data` into `email_images` table with prepared key
- email_images[cid] = encoded_data
- end
+ if not email_obj.suppress_scheduled_email and suppress_scheduled_email then
+ quarto.log.warning("Email #" .. tostring(idx) .. " has no suppress-scheduled setting. Using document-level setting.")
+ email_obj.suppress_scheduled_email = suppress_scheduled_email
+ end
- -- Encode all of the strings and tables of strings into the JSON file
- -- (`.output_metadata.json`) that's needed for Connect's email feature
+ if is_empty_table(email_obj.attachments) and not is_empty_table(attachments) then
+ email_obj.attachments = attachments
+ end
- if (is_empty_table(email_images)) then
+ -- Clean up HTML
+ local email_html_clean = string.gsub(email_obj.email_html, "^
", '')
+ email_html_clean = string.gsub(email_html_clean, "
$", '')
+
+ -- Generate HTML bodies
+ local html_email_body = generate_html_email_from_template(
+ email_html_clean,
+ connect_date_time,
+ connect_report_rendering_url,
+ connect_report_url,
+ connect_report_subscription_url
+ )
+
+ local html_preview_body = generate_html_email_from_template(
+ email_obj.email_html_preview,
+ connect_date_time,
+ connect_report_rendering_url,
+ connect_report_url,
+ connect_report_subscription_url
+ )
+
+ -- Add subject to preview
+ local subject_html_preview = "
subject: " .. email_obj.subject .. "
"
+ html_preview_body = string.gsub(html_preview_body, "", "\n" .. subject_html_preview)
+
+ -- Build email object for JSON
+
+ -- rsc_email_suppress_report_attachment (now inverted to send_report_as_attachment) referred to
+ -- the attachment of the rendered report to each connect email.
+ -- This is always true for all emails unless overridden by blastula (as is the case in v1)
+ local email_json_obj = {
+ index = idx,
+ subject = email_obj.subject,
+ body_html = html_email_body,
+ body_text = email_obj.email_text,
+ attachments = email_obj.attachments,
+ suppress_scheduled = email_obj.suppress_scheduled_email,
+ send_report_as_attachment = false
+ }
- metadata_str = quarto.json.encode({
- rsc_email_subject = subject,
- rsc_email_attachments = attachments,
- rsc_email_body_html = html_email_body,
- rsc_email_body_text = email_text,
- rsc_email_suppress_report_attachment = true,
- rsc_email_suppress_scheduled = suppress_scheduled_email
- })
+ -- Only add images if present
+ if not is_empty_table(email_obj.email_images) then
+ email_json_obj.images = email_obj.email_images
+ end
- else
+ table.insert(emails_for_json, email_json_obj)
- metadata_str = quarto.json.encode({
- rsc_email_subject = subject,
- rsc_email_attachments = attachments,
- rsc_email_body_html = html_email_body,
- rsc_email_body_text = email_text,
- rsc_email_images = email_images,
- rsc_email_suppress_report_attachment = true,
- rsc_email_suppress_scheduled = suppress_scheduled_email
- })
+ -- Write individual preview file
+ if meta_email_preview ~= false then
+ local preview_filename = "email-preview/index-" .. tostring(idx) .. ".html"
+ quarto._quarto.file.write(pandoc.path.join({dir, preview_filename}), html_preview_body)
+ end
end
- -- Determine the location of the Quarto project directory; if not defined
- -- by the user then set to the location of the input file
- local project_output_directory = quarto.project.output_directory
+ -- Generate V2 JSON with version field
+ local metadata_str = quarto.json.encode({
+ rsc_email_version = 2,
+ emails = emails_for_json
+ })
- if (project_output_directory ~= nil) then
- dir = project_output_directory
- else
- local file = quarto.doc.input_file
- dir = pandoc.path.directory(file)
- end
+ -- Write V2 metadata file
+ local metadata_path_file = pandoc.path.join({dir, ".output_metadata.json"})
+ io.open(metadata_path_file, "w"):write(metadata_str):close()
- -- For all file attachments declared by the user, ensure they copied over
- -- to the project directory (`dir`)
+ -- Copy attachments to project directory
for _, v in pairs(attachments) do
-
local source_attachment_file = pandoc.utils.stringify(v)
local dest_attachment_path_file = pandoc.path.join({dir, pandoc.utils.stringify(v)})
- -- Only if the file exists should it be copied into the project directory
if (file_exists(source_attachment_file)) then
local attachment_text = io.open(source_attachment_file):read("*a")
io.open(dest_attachment_path_file, "w"):write(attachment_text):close()
end
end
-
- -- Write the `.output_metadata.json` file to the project directory
- local metadata_path_file = pandoc.path.join({dir, ".output_metadata.json"})
- io.open(metadata_path_file, "w"):write(metadata_str):close()
- -- Write the `email-preview/index.html` file unless meta_email_preview is false
- if meta_email_preview ~= false then
- quarto._quarto.file.write(pandoc.path.join({dir, "email-preview/index.html"}), html_preview_body)
- end
+ return doc
end
function render_email()
From 4d4f4953a056e2f91b28e52714f2b8a6c3e3e7f4 Mon Sep 17 00:00:00 2001
From: Jules Walzer-Goldfeld <54960783+juleswg23@users.noreply.github.com>
Date: Tue, 13 Jan 2026 15:47:55 -0500
Subject: [PATCH 2/3] change v1 trigger to the existence of top level metadata
and rename index field in json output to email_id
---
src/resources/filters/quarto-post/email.lua | 56 ++++++++++-----------
1 file changed, 27 insertions(+), 29 deletions(-)
diff --git a/src/resources/filters/quarto-post/email.lua b/src/resources/filters/quarto-post/email.lua
index 5730e01aa09..d1d680d2e53 100644
--- a/src/resources/filters/quarto-post/email.lua
+++ b/src/resources/filters/quarto-post/email.lua
@@ -197,8 +197,8 @@ local image_tbl = {}
local suppress_scheduled_email = false
local found_email_div = false
--- Track whether we detected v2-style nesting or multiple emails
-local has_nested_metadata = false
+-- Track whether we detected v1-style top-level metadata
+local has_top_level_metadata = false
local email_count = 0
function process_meta(meta)
@@ -252,7 +252,7 @@ function process_div(div)
-- Extract nested metadata from immediate children
local remaining_content = {}
- quarto.log.debug("Processing email div with nested metadata. Total children: " .. tostring(#div.content))
+ quarto.log.debug("Processing email div. Total children: " .. tostring(#div.content))
for i, child in ipairs(div.content) do
quarto.log.debug("Child " .. tostring(i) .. ": type=" .. tostring(child.t))
@@ -262,10 +262,8 @@ function process_div(div)
quarto.log.debug("FOUND nested subject!")
quarto.log.debug(" Subject content: " .. pandoc.utils.stringify(child))
current_email.subject = pandoc.utils.stringify(child)
- has_nested_metadata = true
elseif child.classes:includes("email-text") then
current_email.email_text = pandoc.write(pandoc.Pandoc({ child }), "plain")
- has_nested_metadata = true
elseif child.classes:includes("email-scheduled") then
local email_scheduled_str = str_trunc_trim(string.lower(pandoc.utils.stringify(child)), 10)
local scheduled_email = str_truthy_falsy(email_scheduled_str)
@@ -346,35 +344,35 @@ function process_document(doc)
end
-- V1 fallback: Process document-level metadata divs (not nested in email)
- -- Only consume top-level subject/email-text divs if we didn't find nested metadata
- if not has_nested_metadata then
- doc = quarto._quarto.ast.walk(doc, {
- Div = function(div)
- if div.classes:includes("subject") then
- quarto.log.debug("FOUND top-level subject!")
- subject = pandoc.utils.stringify(div)
- return {}
- elseif div.classes:includes("email-text") then
- email_text = pandoc.write(pandoc.Pandoc({ div }), "plain")
- return {}
- elseif div.classes:includes("email-scheduled") then
- local email_scheduled_str = str_trunc_trim(string.lower(pandoc.utils.stringify(div)), 10)
- local scheduled_email = str_truthy_falsy(email_scheduled_str)
- suppress_scheduled_email = not scheduled_email
- return {}
- end
- return div
+ doc = quarto._quarto.ast.walk(doc, {
+ Div = function(div)
+ if div.classes:includes("subject") then
+ quarto.log.debug("found top-level subject")
+ subject = pandoc.utils.stringify(div)
+ has_top_level_metadata = true
+ return {}
+ elseif div.classes:includes("email-text") then
+ email_text = pandoc.write(pandoc.Pandoc({ div }), "plain")
+ has_top_level_metadata = true
+ return {}
+ elseif div.classes:includes("email-scheduled") then
+ local email_scheduled_str = str_trunc_trim(string.lower(pandoc.utils.stringify(div)), 10)
+ local scheduled_email = str_truthy_falsy(email_scheduled_str)
+ suppress_scheduled_email = not scheduled_email
+ has_top_level_metadata = true
+ return {}
end
- })
- end
+ return div
+ end
+ })
-- Warn if old v1 input format detected
- if email_count == 1 and not has_nested_metadata then
+ if has_top_level_metadata then
quarto.log.warning("Old v1 email format detected (top-level subject/email-text). Outputting as v2 with single email for forward compatibility.")
end
-- In v1 mode (document-level metadata), only keep the first email
- if not has_nested_metadata and #emails > 1 then
+ if has_top_level_metadata and #emails > 1 then
quarto.log.warning("V1 format with document-level metadata should have only one email. Keeping first email only.")
emails = { emails[1] }
email_count = 1
@@ -457,7 +455,7 @@ function process_document(doc)
-- the attachment of the rendered report to each connect email.
-- This is always true for all emails unless overridden by blastula (as is the case in v1)
local email_json_obj = {
- index = idx,
+ email_id = idx,
subject = email_obj.subject,
body_html = html_email_body,
body_text = email_obj.email_text,
@@ -475,7 +473,7 @@ function process_document(doc)
-- Write individual preview file
if meta_email_preview ~= false then
- local preview_filename = "email-preview/index-" .. tostring(idx) .. ".html"
+ local preview_filename = "email-preview/email_id-" .. tostring(idx) .. ".html"
quarto._quarto.file.write(pandoc.path.join({dir, preview_filename}), html_preview_body)
end
end
From 5c2b817113d3fcea68227bd16743538c5fa0759c Mon Sep 17 00:00:00 2001
From: Jules Walzer-Goldfeld <54960783+juleswg23@users.noreply.github.com>
Date: Wed, 14 Jan 2026 16:49:59 -0500
Subject: [PATCH 3/3] sniff for connect version
---
src/resources/filters/modules/constants.lua | 8 +-
src/resources/filters/quarto-post/email.lua | 133 ++++++++++++++++++--
2 files changed, 132 insertions(+), 9 deletions(-)
diff --git a/src/resources/filters/modules/constants.lua b/src/resources/filters/modules/constants.lua
index 7153e486581..02e7bd85c39 100644
--- a/src/resources/filters/modules/constants.lua
+++ b/src/resources/filters/modules/constants.lua
@@ -161,6 +161,10 @@ local kBackgroundColorCaution = "ffe5d0"
local kIncremental = "incremental"
local kNonIncremental = "nonincremental"
+-- Connect version requirements
+-- TODO update version when email metadata changes are made in connect
+local kConnectEmailMetadataChangeVersion = "2025.11"
+
return {
kCitation = kCitation,
kContainerId = kContainerId,
@@ -254,5 +258,7 @@ return {
kBackgroundColorCaution = kBackgroundColorCaution,
kIncremental = kIncremental,
- kNonIncremental = kNonIncremental
+ kNonIncremental = kNonIncremental,
+
+ kConnectEmailMetadataChangeVersion = kConnectEmailMetadataChangeVersion
}
diff --git a/src/resources/filters/quarto-post/email.lua b/src/resources/filters/quarto-post/email.lua
index d1d680d2e53..7f48d0d67a6 100644
--- a/src/resources/filters/quarto-post/email.lua
+++ b/src/resources/filters/quarto-post/email.lua
@@ -16,7 +16,7 @@ Extension for generating email components needed for Posit Connect
6. Produces a local `index.html` file that contains the HTML email for previewing purposes
(this can be disabled by setting `email-preview: false` in the YAML header)
--]]
-
+local constants = require("modules/constants")
-- Get the file extension of any file residing on disk
function get_file_extension(file_path)
local pattern = "%.([^%.]+)$"
@@ -63,6 +63,71 @@ function str_truthy_falsy(str)
return false
end
+-- Parse Connect version from SPARK_CONNECT_USER_AGENT
+-- Format: posit-connect/2024.09.0
+--- posit-connect/2024.09.0-dev+26-g51b853f70e
+--- posit-connect/2024.09.0-dev+26-dirty-g51b853f70e
+-- Returns: "2024.09.0" or nil
+function get_connect_version()
+ local user_agent = os.getenv("SPARK_CONNECT_USER_AGENT")
+ if not user_agent then
+ return nil
+ end
+
+ -- Extract the version after "posit-connect/"
+ local version_with_suffix = string.match(user_agent, "posit%-connect/([%d%.%-+a-z]+)")
+ if not version_with_suffix then
+ return nil
+ end
+
+ -- Strip everything after the first "-" (e.g., "-dev+88-gda902918eb")
+ local idx = string.find(version_with_suffix, "-")
+ if idx then
+ return string.sub(version_with_suffix, 1, idx - 1)
+ end
+
+ return version_with_suffix
+end
+
+-- Parse a version string into components
+-- Versions are in format "X.Y.Z", with all integral components (e.g., "2025.11.0")
+-- Returns: {major=2025, minor=11, patch=0} or nil
+function parse_version_components(version_string)
+ if not version_string then
+ return nil
+ end
+
+ -- Parse version (e.g., "2025.11.0" or "2025.11")
+ local major, minor, patch = string.match(version_string, "^(%d+)%.(%d+)%.?(%d*)$")
+ if not major then
+ return nil
+ end
+
+ return {
+ major = tonumber(major),
+ minor = tonumber(minor),
+ patch = patch ~= "" and tonumber(patch) or 0
+ }
+end
+
+-- Check if Connect version is >= target version
+-- Versions are in format "YYYY.MM.patch" (e.g., "2025.11.0")
+function is_connect_version_at_least(target_version)
+ local current_version = get_connect_version()
+ local current = parse_version_components(current_version)
+ local target = parse_version_components(target_version)
+
+ if not current or not target then
+ return false
+ end
+
+ -- Convert to numeric YYYYMMPP format and compare
+ local current_num = current.major * 10000 + current.minor * 100 + current.patch
+ local target_num = target.major * 10000 + target.minor * 100 + target.patch
+
+ return current_num >= target_num
+end
+
local html_email_template_1 = [[
@@ -201,6 +266,9 @@ local found_email_div = false
local has_top_level_metadata = false
local email_count = 0
+-- Track whether to use v2 JSON format (multi-email array) or v1 format (single email)
+local use_v2_email_format = false
+
function process_meta(meta)
if not found_email_div then
return
@@ -211,6 +279,15 @@ function process_meta(meta)
local meta_email_attachments = meta["email-attachments"]
meta_email_preview = meta["email-preview"]
+ -- Auto-detect Connect version and use appropriate email format
+ -- Connect 2025.11+ supports new v2 multi-email format
+ if is_connect_version_at_least(constants.kConnectEmailMetadataChangeVersion) then
+ use_v2_email_format = true
+ quarto.log.debug("Detected Connect version >= 2025.11, using v2 multi-email format")
+ else
+ quarto.log.debug("Connect version < 2025.11 or not detected, using v1 single-email format")
+ end
+
if meta_email_attachments ~= nil then
for _, v in pairs(meta_email_attachments) do
if (file_exists(pandoc.utils.stringify(v))) then
@@ -378,6 +455,13 @@ function process_document(doc)
email_count = 1
end
+ -- If Connect doesn't support v2 format, only keep first email and warn
+ if not use_v2_email_format then
+ quarto.log.warning("Detected Connect version < 2025.11 which doesn't support multiple emails. Only the first email will be sent. Upgrade Connect to 2025.11+ for multi-email support.")
+ emails = { emails[1] }
+ email_count = 1
+ end
+
-- Get the current date and time
local connect_date_time = os.date("%Y-%m-%d %H:%M:%S")
@@ -397,7 +481,12 @@ function process_document(doc)
dir = pandoc.path.directory(file)
end
- quarto.log.warning("Generating V2 multi-email output format with " .. tostring(email_count) .. " email(s).")
+ -- Log which format we're generating
+ if use_v2_email_format then
+ quarto.log.warning("Generating V2 multi-email output format with " .. tostring(email_count) .. " email(s).")
+ else
+ quarto.log.warning("Generating V1 single-email output format (Connect < 2025.11).")
+ end
-- Process all emails and generate their previews
local emails_for_json = {}
@@ -478,13 +567,41 @@ function process_document(doc)
end
end
- -- Generate V2 JSON with version field
- local metadata_str = quarto.json.encode({
- rsc_email_version = 2,
- emails = emails_for_json
- })
+ -- Generate JSON in appropriate format
+ local metadata_str
+ if use_v2_email_format then
+ -- V2 format: array of emails with version field
+ metadata_str = quarto.json.encode({
+ rsc_email_version = 2,
+ emails = emails_for_json
+ })
+ else
+ -- V1 format: single email object with rsc_ prefix fields (backward compatible)
+ -- Take the first (and should be only) email
+ local first_email = emails_for_json[1]
+ if first_email then
+ local v1_metadata = {
+ rsc_email_subject = first_email.subject,
+ rsc_email_body_html = first_email.body_html,
+ rsc_email_body_text = first_email.body_text,
+ rsc_email_attachments = first_email.attachments,
+ rsc_email_suppress_scheduled = first_email.suppress_scheduled,
+ rsc_email_suppress_report_attachment = true
+ }
+
+ -- Only add images if present
+ if first_email.images and not is_empty_table(first_email.images) then
+ v1_metadata.rsc_email_images = first_email.images
+ end
+
+ metadata_str = quarto.json.encode(v1_metadata)
+ else
+ quarto.log.error("No emails found to generate metadata")
+ metadata_str = quarto.json.encode({})
+ end
+ end
- -- Write V2 metadata file
+ -- Write metadata file
local metadata_path_file = pandoc.path.join({dir, ".output_metadata.json"})
io.open(metadata_path_file, "w"):write(metadata_str):close()