Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/resources/filters/modules/constants.lua
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ local kBackgroundColorCaution = "ffe5d0"
local kIncremental = "incremental"
local kNonIncremental = "nonincremental"

-- Connect version requirements
local kConnectEmailMetadataChangeVersion = "2025.11"

return {
kCitation = kCitation,
kContainerId = kContainerId,
Expand Down Expand Up @@ -254,5 +257,7 @@ return {
kBackgroundColorCaution = kBackgroundColorCaution,

kIncremental = kIncremental,
kNonIncremental = kNonIncremental
kNonIncremental = kNonIncremental,

kConnectEmailMetadataChangeVersion = kConnectEmailMetadataChangeVersion
}
131 changes: 106 additions & 25 deletions src/resources/filters/quarto-post/email.lua
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ Extension for generating email components needed for Posit Connect
(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 = "%.([^%.]+)$"
Expand Down Expand Up @@ -63,6 +65,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 = [[
<!DOCTYPE html>
<html>
Expand Down Expand Up @@ -190,6 +257,7 @@ local email_images = {}
local image_tbl = {}
local suppress_scheduled_email = false
local found_email_div = false
local use_new_email_format = false

function process_meta(meta)
if not found_email_div then
Expand All @@ -201,6 +269,12 @@ 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+ uses new format (no rsc_ prefix)
if is_connect_version_at_least(constants.kConnectEmailMetadataChangeVersion) then
use_new_email_format = true
end

if meta_email_attachments ~= nil then
for _, v in pairs(meta_email_attachments) do
if (file_exists(pandoc.utils.stringify(v))) then
Expand Down Expand Up @@ -306,6 +380,25 @@ function extract_email_div_str(doc)
return pandoc.write(pandoc.Pandoc( {doc} ), "html")
end

-- Function to build email metadata with appropriate field names
function build_email_metadata(subject, attachments, html_email_body, email_text, email_images, suppress_scheduled_email)
local prefix = use_new_email_format and "" or "rsc_"

local metadata = {}
metadata[prefix .. "email_subject"] = subject
metadata[prefix .. "email_attachments"] = attachments
metadata[prefix .. "email_body_html"] = html_email_body
metadata[prefix .. "email_body_text"] = email_text
metadata[prefix .. "email_suppress_report_attachment"] = true
metadata[prefix .. "email_suppress_scheduled"] = suppress_scheduled_email

if not is_empty_table(email_images) then
metadata[prefix .. "email_images"] = email_images
end

return metadata
end

function process_document(doc)

if not found_email_div then
Expand Down Expand Up @@ -374,30 +467,18 @@ function process_document(doc)

-- 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_images)) then

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
})

else

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
})
end
-- Uses the appropriate field names based on Connect version/configuration

local metadata = build_email_metadata(
subject,
attachments,
html_email_body,
email_text,
email_images,
suppress_scheduled_email
)

metadata_str = quarto.json.encode(metadata)

-- Determine the location of the Quarto project directory; if not defined
-- by the user then set to the location of the input file
Expand Down Expand Up @@ -450,4 +531,4 @@ function render_email()
Div = process_div,
}
}
end
end
19 changes: 19 additions & 0 deletions tests/smoke/render/render-email.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,22 @@ testRender(docs("email/email-attach.qmd"), "email", false, [fileExists(previewFi
// Test an email render that has no subject line, this verifies that `rsc_email_subject` key is present and the value is an empty string
testRender(docs("email/email-no-subject.qmd"), "email", false, [fileExists(previewFile), validJsonWithFields(jsonFile, {"rsc_email_subject": ""})], cleanupCtx);

// Test a basic email render, verifies that the outputs are without the rsc_ prefix
testRender(docs("email/email.qmd"), "email", false, [validJsonWithFields(jsonFile, {"email_subject": "The subject line."})], {
...cleanupCtx,
env: {
"SPARK_CONNECT_USER_AGENT": "posit-connect/2025.11.0"
}
});

// Test a basic email render, verifies that the outputs are with the rsc_ prefix given a 2024 version
testRender(docs("email/email.qmd"), "email", false, [validJsonWithFields(jsonFile, {"rsc_email_subject": "The subject line."})], {
...cleanupCtx,
env: {
"SPARK_CONNECT_USER_AGENT": "posit-connect/2024.12.0-dev+26-g51b853f70e"
}
});

// Render in a project with an output directory set in _quarto.yml and confirm that everything ends up in the output directory
testProjectRender(docs("email/project/email-attach.qmd"), "email", (outputDir: string) => {
const verify: Verify[]= [];
Expand All @@ -50,3 +66,6 @@ testProjectRender(docs("email/project/email-attach.qmd"), "email", (outputDir: s
verify.push(validJsonWithFields(json, {"rsc_email_subject": "The subject line.", "rsc_email_attachments": ["raw_data.csv"]}));
return verify;
});



Loading