diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 8c1e5fafb8..65d201456a 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -42,6 +42,7 @@ All changes included in 1.9: - ([#13882](https://github.com/quarto-dev/quarto-cli/pull/13882)): Add support for multiple email outputs when rendering to `format: email` for Posit Connect. - ([#14021](https://github.com/quarto-dev/quarto-cli/issues/14021)): Add `email-version` hook to override detected Connect version when rendering emails for Posit Connect. +- ([#14098](https://github.com/quarto-dev/quarto-cli/pull/14098)): Add support for dynamic email recipients computed via Python or R code. ### `html` diff --git a/src/resources/filters/quarto-post/email.lua b/src/resources/filters/quarto-post/email.lua index 2d8e3de057..8728c889e5 100644 --- a/src/resources/filters/quarto-post/email.lua +++ b/src/resources/filters/quarto-post/email.lua @@ -65,6 +65,127 @@ function str_truthy_falsy(str) return false end +-- Parse recipients from inline code output or plain text +-- Supports multiple formats: +-- 1. Python list: ['a', 'b'] or ["a", "b"] +-- 2. R vector: "a" "b" "c" +-- 3. Comma-separated: a, b, c +-- 4. Line-separated: a\nb\nc +-- Returns an empty array if parsing fails +function parse_recipients(recipient_str) + recipient_str = str_trunc_trim(recipient_str, 10000) + + if recipient_str == "" then + return {} + end + + local recipients = {} + + -- Try Python list format ['...', '...'] or ["...", "..."] + if string.match(recipient_str, "^%[") and string.match(recipient_str, "%]$") then + local content = string.sub(recipient_str, 2, -2) + + -- Try to parse as Python/R list by splitting on commas + -- and stripping quotes and brackets from each item + recipients = {} + for item in string.gmatch(content, "[^,]+") do + local trimmed = str_trunc_trim(item, 1000) + -- Strip leading/trailing brackets + trimmed = string.gsub(trimmed, "^%[", "") + trimmed = string.gsub(trimmed, "%]$", "") + trimmed = str_trunc_trim(trimmed, 1000) + + -- Strip leading/trailing quotes (ASCII single/double and UTF-8 curly quotes) + -- ASCII single quote ' + trimmed = string.gsub(trimmed, "^'", "") + trimmed = string.gsub(trimmed, "'$", "") + -- ASCII double quote " + trimmed = string.gsub(trimmed, '^"', "") + trimmed = string.gsub(trimmed, '"$', "") + -- UTF-8 curly single quotes ' and ' (U+2018, U+2019) + trimmed = string.gsub(trimmed, "^" .. string.char(226, 128, 152), "") + trimmed = string.gsub(trimmed, string.char(226, 128, 153) .. "$", "") + -- UTF-8 curly double quotes " and " (U+201C, U+201D) + trimmed = string.gsub(trimmed, "^" .. string.char(226, 128, 156), "") + trimmed = string.gsub(trimmed, string.char(226, 128, 157) .. "$", "") + + trimmed = str_trunc_trim(trimmed, 1000) + if trimmed ~= "" then + table.insert(recipients, trimmed) + end + end + if #recipients > 0 then + return recipients + end + end + + -- Try R-style quoted format (space-separated quoted strings outside of brackets) + recipients = {} + local found_any = false + + -- Try single quotes: 'a' 'b' 'c' + for quoted_pair in string.gmatch(recipient_str, "'([^']*)'") do + local trimmed = str_trunc_trim(quoted_pair, 1000) + if trimmed ~= "" then + table.insert(recipients, trimmed) + found_any = true + end + end + if found_any then + return recipients + end + + -- Try double quotes: "a" "b" "c" + recipients = {} + for quoted_pair in string.gmatch(recipient_str, '"([^"]*)"') do + local trimmed = str_trunc_trim(quoted_pair, 1000) + if trimmed ~= "" then + table.insert(recipients, trimmed) + found_any = true + end + end + if found_any then + return recipients + end + + -- Try line-separated format (newlines or spaces) + -- Check if there are newlines or multiple space-separated emails + if string.match(recipient_str, "\n") or + (string.match(recipient_str, "@.*%s+.*@") and not string.match(recipient_str, ",")) then + recipients = {} + -- Split on newlines or spaces + for item in string.gmatch(recipient_str, "[^\n%s]+") do + local trimmed = str_trunc_trim(item, 1000) + if trimmed ~= "" and string.match(trimmed, "@") then + table.insert(recipients, trimmed) + found_any = true + end + end + if found_any then + return recipients + end + end + + -- Try comma-separated format without quotes + -- Split by comma and trim each part + recipients = {} + found_any = false + for part in string.gmatch(recipient_str, "[^,]+") do + local trimmed = str_trunc_trim(part, 1000) + if trimmed ~= "" and not string.match(trimmed, "^[%[%]]") then + table.insert(recipients, trimmed) + found_any = true + end + end + if found_any then + return recipients + end + + -- Could not parse - log warning and return empty + quarto.log.warning("Could not parse recipients format: " .. recipient_str) + return {} +end + local html_email_template_1 = [[ @@ -254,6 +375,7 @@ function process_div(div) image_tbl = {}, email_images = {}, suppress_scheduled_email = nil, -- nil means not set + recipients = {}, attachments = {} } @@ -270,6 +392,8 @@ function process_div(div) local email_scheduled_str = str_trunc_trim(string.lower(pandoc.utils.stringify(child)), 10) local scheduled_email = str_truthy_falsy(email_scheduled_str) current_email.suppress_scheduled_email = not scheduled_email + elseif child.classes:includes("recipients") then + current_email.recipients = parse_recipients(pandoc.utils.stringify(child)) else table.insert(remaining_content, child) end @@ -277,7 +401,41 @@ function process_div(div) table.insert(remaining_content, child) end end - + + -- Check for recipients attribute on the email div itself + -- This allows referencing metadata set via write_yaml_metadata_block() + if div.attributes.recipients then + local meta_key = div.attributes.recipients + local meta_value = quarto.metadata.get(meta_key) + + if meta_value then + -- Convert metadata to recipients array + if quarto.utils.type(meta_value) == "List" then + local recipients_from_meta = {} + for _, item in ipairs(meta_value) do + local recipient_str = pandoc.utils.stringify(item) + if recipient_str ~= "" then + table.insert(recipients_from_meta, recipient_str) + end + end + + -- If recipients were also found in child divs, merge them + if #current_email.recipients > 0 then + quarto.log.warning("Recipients found in both attribute and child div. Merging both lists.") + for _, recipient in ipairs(recipients_from_meta) do + table.insert(current_email.recipients, recipient) + end + else + current_email.recipients = recipients_from_meta + end + else + quarto.log.warning("Recipients metadata '" .. meta_key .. "' is not a list. Expected format: ['email1@example.com', 'email2@example.com']") + end + else + quarto.log.warning("Recipients attribute references metadata key '" .. meta_key .. "' which does not exist.") + end + end + -- Create a modified div without metadata for processing local email_without_metadata = pandoc.Div(remaining_content, div.attr) @@ -508,6 +666,11 @@ function process_document(doc) send_report_as_attachment = false } + -- Only add recipients if present + if not is_empty_table(email_obj.recipients) then + email_json_obj.recipients = email_obj.recipients + end + -- Only add images if present if not is_empty_table(email_obj.email_images) then email_json_obj.images = email_obj.email_images diff --git a/tests/docs/email/email-recipients-all-patterns-python.qmd b/tests/docs/email/email-recipients-all-patterns-python.qmd new file mode 100644 index 0000000000..b280fd54d7 --- /dev/null +++ b/tests/docs/email/email-recipients-all-patterns-python.qmd @@ -0,0 +1,125 @@ +--- +title: Email Recipients - All Patterns (Python) +author: Jules Walzer-Goldfeld +format: + email: + email-version: 2 +--- + +```{python} +#| echo: false +import yaml +from IPython.display import Markdown + +def write_yaml_metadata_block(**kwargs): + """Write YAML metadata block that will be parsed by Quarto.""" + yaml_content = yaml.dump( + kwargs, + default_flow_style=False, + allow_unicode=True, + sort_keys=False + ) + yaml_block = f"---\n{yaml_content}---\n" + return Markdown(yaml_block) +``` + +Test document demonstrating all recipient patterns with Python. + +```{python} +# Email 1: Static inline recipients +static_recipients = ["alice@example.com", "bob@example.com", "charlie@example.com"] +``` + +::: {.email} + +::: {.subject} +Email 1: Static Inline Recipients +::: + +::: {.recipients} +`{python} static_recipients` +::: + +::: {.email-text} +Text version of email with static inline recipients. +::: + +First email with static inline recipients. + +::: + +```{python} +# Email 2: Conditional inline recipients +is_weekday = True # Fixed value for deterministic testing + +if is_weekday: + conditional_recipients = ["weekday@example.com", "team@example.com"] +else: + conditional_recipients = ["weekend@example.com"] +``` + +::: {.email} + +::: {.subject} +Email 2: Conditional Inline Recipients +::: + +::: {.recipients} +`{python} conditional_recipients` +::: + +::: {.email-text} +Text version of conditional recipients email. +::: + +Second email with conditional inline recipients. + +::: + +```{python} +#| output: asis +# Email 3: Metadata attribute pattern +metadata_recipients = ["metadata1@example.com", "metadata2@example.com"] +write_yaml_metadata_block(metadata_recipients=metadata_recipients) +``` + +::: {.email recipients=metadata_recipients} + +::: {.subject} +Email 3: Metadata Attribute Pattern +::: + +::: {.email-text} +This email uses the metadata attribute pattern. +::: + +Third email using metadata attribute pattern. + +::: + +```{python} +#| output: asis +# Email 4: Conditional metadata attribute pattern +is_admin = True # Fixed for testing + +if is_admin: + admin_recipients = ["admin@example.com", "superuser@example.com"] +else: + admin_recipients = ["user@example.com"] + +write_yaml_metadata_block(admin_recipients=admin_recipients) +``` + +::: {.email recipients=admin_recipients} + +::: {.subject} +Email 4: Conditional Metadata Attribute +::: + +::: {.email-text} +This email uses conditional metadata attribute pattern. +::: + +Fourth email using conditional metadata attribute pattern. + +::: diff --git a/tests/docs/email/email-recipients-all-patterns-r.qmd b/tests/docs/email/email-recipients-all-patterns-r.qmd new file mode 100644 index 0000000000..b245680658 --- /dev/null +++ b/tests/docs/email/email-recipients-all-patterns-r.qmd @@ -0,0 +1,127 @@ +--- +title: Email Recipients - All Patterns (R) +author: Jules Walzer-Goldfeld +format: + email: + email-version: 2 +--- + +```{r} +#| echo: false +# Simple metadata block writer for testing +# (Ideally we use the quarto R package: install.packages("quarto")) +# We are awiting PR # +write_yaml_metadata_block <- function(...) { + args <- list(...) + if (length(args) == 0) { + return() + } + + yaml_content <- yaml::as.yaml(args) + yaml_block <- paste0("---\n", yaml_content, "---\n") + knitr::asis_output(yaml_block) +} +``` + +Test document demonstrating all recipient patterns with R. + +```{r} +# Email 1: Static inline recipients +static_recipients <- c("alice@example.com", "bob@example.com", "charlie@example.com") +``` + +::: {.email} + +::: {.subject} +Email 1: Static Inline Recipients +::: + +::: {.recipients} +`{r} static_recipients` +::: + +::: {.email-text} +Text version of email with static inline recipients. +::: + +First email with static inline recipients. + +::: + +```{r} +# Email 2: Conditional inline recipients +is_weekday <- TRUE # Fixed value for deterministic testing + +if (is_weekday) { + conditional_recipients <- c("weekday@example.com", "team@example.com") +} else { + conditional_recipients <- c("weekend@example.com") +} +``` + +::: {.email} + +::: {.subject} +Email 2: Conditional Inline Recipients +::: + +::: {.recipients} +`{r} conditional_recipients` +::: + +::: {.email-text} +Text version of conditional recipients email. +::: + +Second email with conditional inline recipients. + +::: + +```{r} +#| output: asis +# Email 3: Metadata attribute pattern +metadata_recipients <- c("metadata1@example.com", "metadata2@example.com") +write_yaml_metadata_block(metadata_recipients = metadata_recipients) +``` + +::: {.email recipients=metadata_recipients} + +::: {.subject} +Email 3: Metadata Attribute Pattern +::: + +::: {.email-text} +This email uses the metadata attribute pattern. +::: + +Third email using metadata attribute pattern. + +::: + +```{r} +#| output: asis +# Email 4: Conditional metadata attribute pattern +is_admin <- TRUE # Fixed for testing + +if (is_admin) { + admin_recipients <- c("admin@example.com", "superuser@example.com") +} else { + admin_recipients <- c("user@example.com") +} + +write_yaml_metadata_block(admin_recipients = admin_recipients) +``` + +::: {.email recipients=admin_recipients} + +::: {.subject} +Email 4: Conditional Metadata Attribute +::: + +::: {.email-text} +This email uses conditional metadata attribute pattern. +::: + +Fourth email using conditional metadata attribute pattern. + +::: diff --git a/tests/docs/email/email-recipients-plaintext-formats.qmd b/tests/docs/email/email-recipients-plaintext-formats.qmd new file mode 100644 index 0000000000..ded1461d21 --- /dev/null +++ b/tests/docs/email/email-recipients-plaintext-formats.qmd @@ -0,0 +1,49 @@ +--- +title: Email Recipients Formats Test Document +author: Jules Walzer-Goldfeld +format: + email: + email-version: 2 +--- + +Test document for various recipient input formats. + +::: {.email} + +::: {.subject} +Line-Separated Recipients +::: + +::: {.recipients} +alice@example.com +bob@example.com +charlie@example.com +::: + +::: {.email-text} +Email with line-separated recipients (plain text, one per line). +::: + +First email with line-separated recipients. + +::: + +::: {.email} + +::: {.subject} +Comma-Separated Recipients +::: + +::: {.recipients} +alice@example.com, bob@example.com, charlie@example.com +::: + +::: {.email-text} +Email with comma-separated recipients (plain text). +::: + +Second email with comma-separated recipients. + +::: + +Done with test emails. diff --git a/tests/smoke/render/render-email.test.ts b/tests/smoke/render/render-email.test.ts index 20787aeaf0..8e3b8be3f1 100644 --- a/tests/smoke/render/render-email.test.ts +++ b/tests/smoke/render/render-email.test.ts @@ -226,7 +226,128 @@ testRender(docs("email/email-mixed-metadata-v2.qmd"), "email", false, [ "SPARK_CONNECT_USER_AGENT": "posit-connect/2026.03.0" } }); +// Test alternative recipient formats (line-separated and comma-separated plain text) +testRender(docs("email/email-recipients-plaintext-formats.qmd"), "email", false, [ + fileExists(previewFileV2_1), + fileExists(previewFileV2_2), + validJsonWithMultipleEmails(jsonFile, 2, { + "0": { + "email_id": 1, + "subject": "Line-Separated Recipients", + "recipients": ["alice@example.com", "bob@example.com", "charlie@example.com"], + "attachments": [], + "suppress_scheduled": false, + "send_report_as_attachment": false + }, + "1": { + "email_id": 2, + "subject": "Comma-Separated Recipients", + "recipients": ["alice@example.com", "bob@example.com", "charlie@example.com"], + "attachments": [], + "suppress_scheduled": false, + "send_report_as_attachment": false + } + }) +], { + ...cleanupCtx, + env: { + "SPARK_CONNECT_USER_AGENT": "posit-connect/2026.03.0" + } +}); +// Test all recipient patterns with Python (static inline, conditional inline, metadata, conditional metadata) +testRender(docs("email/email-recipients-all-patterns-python.qmd"), "email", false, [ + fileExists(previewFileV2_1), + fileExists(previewFileV2_2), + fileExists(docs("email/email-preview/email_id-3.html")), + fileExists(docs("email/email-preview/email_id-4.html")), + validJsonWithMultipleEmails(jsonFile, 4, { + "0": { + "email_id": 1, + "subject": "Email 1: Static Inline Recipients", + "recipients": ["alice@example.com", "bob@example.com", "charlie@example.com"], + "attachments": [], + "suppress_scheduled": false, + "send_report_as_attachment": false + }, + "1": { + "email_id": 2, + "subject": "Email 2: Conditional Inline Recipients", + "recipients": ["weekday@example.com", "team@example.com"], + "attachments": [], + "suppress_scheduled": false, + "send_report_as_attachment": false + }, + "2": { + "email_id": 3, + "subject": "Email 3: Metadata Attribute Pattern", + "recipients": ["metadata1@example.com", "metadata2@example.com"], + "attachments": [], + "suppress_scheduled": false, + "send_report_as_attachment": false + }, + "3": { + "email_id": 4, + "subject": "Email 4: Conditional Metadata Attribute", + "recipients": ["admin@example.com", "superuser@example.com"], + "attachments": [], + "suppress_scheduled": false, + "send_report_as_attachment": false + } + }) +], { + ...cleanupCtx, + env: { + "SPARK_CONNECT_USER_AGENT": "posit-connect/2026.03.0" + } +}); + +// Test all recipient patterns with R (static inline, conditional inline, metadata, conditional metadata) +testRender(docs("email/email-recipients-all-patterns-r.qmd"), "email", false, [ + fileExists(previewFileV2_1), + fileExists(previewFileV2_2), + fileExists(docs("email/email-preview/email_id-3.html")), + fileExists(docs("email/email-preview/email_id-4.html")), + validJsonWithMultipleEmails(jsonFile, 4, { + "0": { + "email_id": 1, + "subject": "Email 1: Static Inline Recipients", + "recipients": ["alice@example.com", "bob@example.com", "charlie@example.com"], + "attachments": [], + "suppress_scheduled": false, + "send_report_as_attachment": false + }, + "1": { + "email_id": 2, + "subject": "Email 2: Conditional Inline Recipients", + "recipients": ["weekday@example.com", "team@example.com"], + "attachments": [], + "suppress_scheduled": false, + "send_report_as_attachment": false + }, + "2": { + "email_id": 3, + "subject": "Email 3: Metadata Attribute Pattern", + "recipients": ["metadata1@example.com", "metadata2@example.com"], + "attachments": [], + "suppress_scheduled": false, + "send_report_as_attachment": false + }, + "3": { + "email_id": 4, + "subject": "Email 4: Conditional Metadata Attribute", + "recipients": ["admin@example.com", "superuser@example.com"], + "attachments": [], + "suppress_scheduled": false, + "send_report_as_attachment": false + } + }) +], { + ...cleanupCtx, + env: { + "SPARK_CONNECT_USER_AGENT": "posit-connect/2026.03.0" + } +}); // 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[]= [];