diff --git a/src/resources/filters/quarto-post/email.lua b/src/resources/filters/quarto-post/email.lua index 2d8e3de057..3fb892650d 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 @@ -508,6 +632,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-formats.qmd b/tests/docs/email/email-recipients-formats.qmd new file mode 100644 index 0000000000..70b8d9f041 --- /dev/null +++ b/tests/docs/email/email-recipients-formats.qmd @@ -0,0 +1,47 @@ +--- +title: Email Recipients Formats Test Document +author: Jules Walzer-Goldfeld +format: email +--- + +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/docs/email/email-recipients-python.qmd b/tests/docs/email/email-recipients-python.qmd new file mode 100644 index 0000000000..af47d400fc --- /dev/null +++ b/tests/docs/email/email-recipients-python.qmd @@ -0,0 +1,60 @@ +--- +title: Email Recipients Python Test Document +author: Jules Walzer-Goldfeld +format: email +--- + +The report content. Anything that is here is not part of the email message. + +::: {.email} + +::: {.subject} +Python Recipients Email +::: + +```{python} +# Static recipients list +recipients = ["alice@example.com", "bob@example.com", "charlie@example.com"] +``` + +::: {.recipients} +`{python} recipients` +::: + +::: {.email-text} +Text version of email with Python recipients. +::: + +First email HTML content with Python recipients. + +::: + +::: {.email} + +::: {.subject} +Conditional Recipients Email (Weekday) +::: + +```{python} +# Conditional recipients based on a fixed boolean (for deterministic testing) +is_weekday = True # Fixed value for testing + +if is_weekday: + recipients = ["weekday@example.com", "team@example.com"] +else: + recipients = ["weekend@example.com"] +``` + +::: {.recipients} +`{python} recipients` +::: + +::: {.email-text} +Text version of conditional recipients email. +::: + +Second email HTML content with conditional recipients. + +::: + +Any additional report content not part of the email message. diff --git a/tests/docs/email/email-recipients-r.qmd b/tests/docs/email/email-recipients-r.qmd new file mode 100644 index 0000000000..34a42a32b6 --- /dev/null +++ b/tests/docs/email/email-recipients-r.qmd @@ -0,0 +1,61 @@ +--- +title: Email Recipients R Test Document +author: Jules Walzer-Goldfeld +format: email +--- + +The report content. Anything that is here is not part of the email message. + +::: {.email} + +::: {.subject} +R Recipients Email +::: + +```{r} +# Static recipients vector +recipients <- c("alice@example.com", "bob@example.com", "charlie@example.com") +``` + +::: {.recipients} +`{r} recipients` +::: + +::: {.email-text} +Text version of email with R recipients. +::: + +First email HTML content with R recipients. + +::: + +::: {.email} + +::: {.subject} +Conditional Recipients Email (R) +::: + +```{r} +# Conditional recipients based on a fixed boolean (for deterministic testing) +is_weekday <- TRUE # Fixed value for testing + +if (is_weekday) { + recipients <- c("weekday@example.com", "team@example.com") +} else { + recipients <- c("weekend@example.com") +} +``` + +::: {.recipients} +`{r} recipients` +::: + +::: {.email-text} +Text version of conditional recipients email (R). +::: + +Second email HTML content with conditional recipients (R). + +::: + +Any additional report content not part of the email message. diff --git a/tests/smoke/render/render-email.test.ts b/tests/smoke/render/render-email.test.ts index 20787aeaf0..72c74893d5 100644 --- a/tests/smoke/render/render-email.test.ts +++ b/tests/smoke/render/render-email.test.ts @@ -226,7 +226,92 @@ testRender(docs("email/email-mixed-metadata-v2.qmd"), "email", false, [ "SPARK_CONNECT_USER_AGENT": "posit-connect/2026.03.0" } }); +// Test Python recipients with v2 format +testRender(docs("email/email-recipients-python.qmd"), "email", false, [ + fileExists(previewFileV2_1), + fileExists(previewFileV2_2), + validJsonWithMultipleEmails(jsonFile, 2, { + "0": { + "email_id": 1, + "subject": "Python Recipients Email", + "recipients": ["alice@example.com", "bob@example.com", "charlie@example.com"], + "attachments": [], + "suppress_scheduled": false, + "send_report_as_attachment": false + }, + "1": { + "email_id": 2, + "subject": "Conditional Recipients Email (Weekday)", + "recipients": ["weekday@example.com", "team@example.com"], + "attachments": [], + "suppress_scheduled": false, + "send_report_as_attachment": false + } + }) +], { + ...cleanupCtx, + env: { + "SPARK_CONNECT_USER_AGENT": "posit-connect/2026.03.0" + } +}); +// Test R recipients with v2 format +testRender(docs("email/email-recipients-r.qmd"), "email", false, [ + fileExists(previewFileV2_1), + fileExists(previewFileV2_2), + validJsonWithMultipleEmails(jsonFile, 2, { + "0": { + "email_id": 1, + "subject": "R Recipients Email", + "recipients": ["alice@example.com", "bob@example.com", "charlie@example.com"], + "attachments": [], + "suppress_scheduled": false, + "send_report_as_attachment": false + }, + "1": { + "email_id": 2, + "subject": "Conditional Recipients Email (R)", + "recipients": ["weekday@example.com", "team@example.com"], + "attachments": [], + "suppress_scheduled": false, + "send_report_as_attachment": false + } + }) +], { + ...cleanupCtx, + env: { + "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-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" + } +}); // 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[]= [];