Skip to content
Open
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
1 change: 1 addition & 0 deletions news/changelog-1.9.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
165 changes: 164 additions & 1 deletion src/resources/filters/quarto-post/email.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [[
<!DOCTYPE html>
<html>
Expand Down Expand Up @@ -254,6 +375,7 @@ function process_div(div)
image_tbl = {},
email_images = {},
suppress_scheduled_email = nil, -- nil means not set
recipients = {},
attachments = {}
}

Expand All @@ -270,14 +392,50 @@ 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
else
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)

Expand Down Expand Up @@ -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
Expand Down
125 changes: 125 additions & 0 deletions tests/docs/email/email-recipients-all-patterns-python.qmd
Original file line number Diff line number Diff line change
@@ -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.

:::
Loading
Loading