Skip to content

Commit 2386251

Browse files
authored
feat: email multiple recipients (#14098)
* plan generation * parse and test new recipients field * delete claude files * support write_yaml_metadata_block as a path for passing recipients * compress into fewer separate test files * update changelog. I can push this to 1.10 if we don't want to rush here. * rename test * set email version in tests * parse emails with simplified regex (and add specific plaintext tests)
1 parent 75912a3 commit 2386251

6 files changed

Lines changed: 610 additions & 1 deletion

File tree

news/changelog-1.9.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ All changes included in 1.9:
4242

4343
- ([#13882](https://github.com/quarto-dev/quarto-cli/pull/13882)): Add support for multiple email outputs when rendering to `format: email` for Posit Connect.
4444
- ([#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.
45+
- ([#14098](https://github.com/quarto-dev/quarto-cli/pull/14098)): Add support for dynamic email recipients computed via Python or R code.
4546

4647
### `html`
4748

src/resources/filters/quarto-post/email.lua

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,41 @@ function str_truthy_falsy(str)
6565
return false
6666
end
6767

68+
-- Parse recipients using regex to find email addresses
69+
-- Matches pattern: local-part@domain.tld
70+
-- Handles any format: Python lists, R vectors, comma-separated,
71+
-- space-separated, quoted, unquoted, etc.
72+
-- Returns an empty array if no valid emails found
73+
function parse_recipients(recipient_str)
74+
recipient_str = str_trunc_trim(recipient_str, 10000)
75+
76+
if recipient_str == "" then
77+
return {}
78+
end
79+
80+
local recipients = {}
81+
-- Match anything that's not a separator (quotes, commas, spaces, brackets, parens)
82+
-- This allows international characters while stopping at separators
83+
for email in string.gmatch(recipient_str, "[^%s,'\"%[%]%(%)]+@[^%s,'\"%[%]%(%)]+%.[^%s,'\"%[%]%(%)]+") do
84+
-- Strip any leading/trailing quote characters (both straight and curly)
85+
-- Straight quotes: ' "
86+
-- Curly single quotes: ' ' (U+2018, U+2019)
87+
-- Curly double quotes: " " (U+201C, U+201D)
88+
email = string.gsub(email, "^['\"" .. string.char(226, 128, 152) .. string.char(226, 128, 153) .. string.char(226, 128, 156) .. string.char(226, 128, 157) .. "]+", "")
89+
email = string.gsub(email, "['\"" .. string.char(226, 128, 152) .. string.char(226, 128, 153) .. string.char(226, 128, 156) .. string.char(226, 128, 157) .. "]+$", "")
90+
91+
if email ~= "" and string.match(email, "@") then
92+
table.insert(recipients, email)
93+
end
94+
end
95+
96+
if #recipients == 0 then
97+
quarto.log.warning("Could not parse recipients format: " .. recipient_str)
98+
end
99+
100+
return recipients
101+
end
102+
68103
local html_email_template_1 = [[
69104
<!DOCTYPE html>
70105
<html>
@@ -254,6 +289,7 @@ function process_div(div)
254289
image_tbl = {},
255290
email_images = {},
256291
suppress_scheduled_email = nil, -- nil means not set
292+
recipients = {},
257293
attachments = {}
258294
}
259295

@@ -270,14 +306,50 @@ function process_div(div)
270306
local email_scheduled_str = str_trunc_trim(string.lower(pandoc.utils.stringify(child)), 10)
271307
local scheduled_email = str_truthy_falsy(email_scheduled_str)
272308
current_email.suppress_scheduled_email = not scheduled_email
309+
elseif child.classes:includes("recipients") then
310+
current_email.recipients = parse_recipients(pandoc.utils.stringify(child))
273311
else
274312
table.insert(remaining_content, child)
275313
end
276314
else
277315
table.insert(remaining_content, child)
278316
end
279317
end
280-
318+
319+
-- Check for recipients attribute on the email div itself
320+
-- This allows referencing metadata set via write_yaml_metadata_block()
321+
if div.attributes.recipients then
322+
local meta_key = div.attributes.recipients
323+
local meta_value = quarto.metadata.get(meta_key)
324+
325+
if meta_value then
326+
-- Convert metadata to recipients array
327+
if quarto.utils.type(meta_value) == "List" then
328+
local recipients_from_meta = {}
329+
for _, item in ipairs(meta_value) do
330+
local recipient_str = pandoc.utils.stringify(item)
331+
if recipient_str ~= "" then
332+
table.insert(recipients_from_meta, recipient_str)
333+
end
334+
end
335+
336+
-- If recipients were also found in child divs, merge them
337+
if #current_email.recipients > 0 then
338+
quarto.log.warning("Recipients found in both attribute and child div. Merging both lists.")
339+
for _, recipient in ipairs(recipients_from_meta) do
340+
table.insert(current_email.recipients, recipient)
341+
end
342+
else
343+
current_email.recipients = recipients_from_meta
344+
end
345+
else
346+
quarto.log.warning("Recipients metadata '" .. meta_key .. "' is not a list. Expected format: ['email1@example.com', 'email2@example.com']")
347+
end
348+
else
349+
quarto.log.warning("Recipients attribute references metadata key '" .. meta_key .. "' which does not exist.")
350+
end
351+
end
352+
281353
-- Create a modified div without metadata for processing
282354
local email_without_metadata = pandoc.Div(remaining_content, div.attr)
283355

@@ -508,6 +580,11 @@ function process_document(doc)
508580
send_report_as_attachment = false
509581
}
510582

583+
-- Only add recipients if present
584+
if not is_empty_table(email_obj.recipients) then
585+
email_json_obj.recipients = email_obj.recipients
586+
end
587+
511588
-- Only add images if present
512589
if not is_empty_table(email_obj.email_images) then
513590
email_json_obj.images = email_obj.email_images
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
---
2+
title: Email Recipients - All Patterns (Python)
3+
author: Jules Walzer-Goldfeld
4+
format:
5+
email:
6+
email-version: 2
7+
---
8+
9+
```{python}
10+
#| echo: false
11+
import yaml
12+
from IPython.display import Markdown
13+
14+
def write_yaml_metadata_block(**kwargs):
15+
"""Write YAML metadata block that will be parsed by Quarto."""
16+
yaml_content = yaml.dump(
17+
kwargs,
18+
default_flow_style=False,
19+
allow_unicode=True,
20+
sort_keys=False
21+
)
22+
yaml_block = f"---\n{yaml_content}---\n"
23+
return Markdown(yaml_block)
24+
```
25+
26+
Test document demonstrating all recipient patterns with Python.
27+
28+
```{python}
29+
# Email 1: Static inline recipients
30+
static_recipients = ["alice@example.com", "bob@example.com", "charlie@example.com"]
31+
```
32+
33+
::: {.email}
34+
35+
::: {.subject}
36+
Email 1: Static Inline Recipients
37+
:::
38+
39+
::: {.recipients}
40+
`{python} static_recipients`
41+
:::
42+
43+
::: {.email-text}
44+
Text version of email with static inline recipients.
45+
:::
46+
47+
First email with static inline recipients.
48+
49+
:::
50+
51+
```{python}
52+
# Email 2: Conditional inline recipients
53+
is_weekday = True # Fixed value for deterministic testing
54+
55+
if is_weekday:
56+
conditional_recipients = ["weekday@example.com", "team@example.com"]
57+
else:
58+
conditional_recipients = ["weekend@example.com"]
59+
```
60+
61+
::: {.email}
62+
63+
::: {.subject}
64+
Email 2: Conditional Inline Recipients
65+
:::
66+
67+
::: {.recipients}
68+
`{python} conditional_recipients`
69+
:::
70+
71+
::: {.email-text}
72+
Text version of conditional recipients email.
73+
:::
74+
75+
Second email with conditional inline recipients.
76+
77+
:::
78+
79+
```{python}
80+
#| output: asis
81+
# Email 3: Metadata attribute pattern
82+
metadata_recipients = ["metadata1@example.com", "metadata2@example.com"]
83+
write_yaml_metadata_block(metadata_recipients=metadata_recipients)
84+
```
85+
86+
::: {.email recipients=metadata_recipients}
87+
88+
::: {.subject}
89+
Email 3: Metadata Attribute Pattern
90+
:::
91+
92+
::: {.email-text}
93+
This email uses the metadata attribute pattern.
94+
:::
95+
96+
Third email using metadata attribute pattern.
97+
98+
:::
99+
100+
```{python}
101+
#| output: asis
102+
# Email 4: Conditional metadata attribute pattern
103+
is_admin = True # Fixed for testing
104+
105+
if is_admin:
106+
admin_recipients = ["admin@example.com", "superuser@example.com"]
107+
else:
108+
admin_recipients = ["user@example.com"]
109+
110+
write_yaml_metadata_block(admin_recipients=admin_recipients)
111+
```
112+
113+
::: {.email recipients=admin_recipients}
114+
115+
::: {.subject}
116+
Email 4: Conditional Metadata Attribute
117+
:::
118+
119+
::: {.email-text}
120+
This email uses conditional metadata attribute pattern.
121+
:::
122+
123+
Fourth email using conditional metadata attribute pattern.
124+
125+
:::
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
---
2+
title: Email Recipients - All Patterns (R)
3+
author: Jules Walzer-Goldfeld
4+
format:
5+
email:
6+
email-version: 2
7+
---
8+
9+
```{r}
10+
#| echo: false
11+
# Simple metadata block writer for testing
12+
# (Ideally we use the quarto R package: install.packages("quarto"))
13+
# We are awiting PR #
14+
write_yaml_metadata_block <- function(...) {
15+
args <- list(...)
16+
if (length(args) == 0) {
17+
return()
18+
}
19+
20+
yaml_content <- yaml::as.yaml(args)
21+
yaml_block <- paste0("---\n", yaml_content, "---\n")
22+
knitr::asis_output(yaml_block)
23+
}
24+
```
25+
26+
Test document demonstrating all recipient patterns with R.
27+
28+
```{r}
29+
# Email 1: Static inline recipients
30+
static_recipients <- c("alice@example.com", "bob@example.com", "charlie@example.com")
31+
```
32+
33+
::: {.email}
34+
35+
::: {.subject}
36+
Email 1: Static Inline Recipients
37+
:::
38+
39+
::: {.recipients}
40+
`{r} static_recipients`
41+
:::
42+
43+
::: {.email-text}
44+
Text version of email with static inline recipients.
45+
:::
46+
47+
First email with static inline recipients.
48+
49+
:::
50+
51+
```{r}
52+
# Email 2: Conditional inline recipients
53+
is_weekday <- TRUE # Fixed value for deterministic testing
54+
55+
if (is_weekday) {
56+
conditional_recipients <- c("weekday@example.com", "team@example.com")
57+
} else {
58+
conditional_recipients <- c("weekend@example.com")
59+
}
60+
```
61+
62+
::: {.email}
63+
64+
::: {.subject}
65+
Email 2: Conditional Inline Recipients
66+
:::
67+
68+
::: {.recipients}
69+
`{r} conditional_recipients`
70+
:::
71+
72+
::: {.email-text}
73+
Text version of conditional recipients email.
74+
:::
75+
76+
Second email with conditional inline recipients.
77+
78+
:::
79+
80+
```{r}
81+
#| output: asis
82+
# Email 3: Metadata attribute pattern
83+
metadata_recipients <- c("metadata1@example.com", "metadata2@example.com")
84+
write_yaml_metadata_block(metadata_recipients = metadata_recipients)
85+
```
86+
87+
::: {.email recipients=metadata_recipients}
88+
89+
::: {.subject}
90+
Email 3: Metadata Attribute Pattern
91+
:::
92+
93+
::: {.email-text}
94+
This email uses the metadata attribute pattern.
95+
:::
96+
97+
Third email using metadata attribute pattern.
98+
99+
:::
100+
101+
```{r}
102+
#| output: asis
103+
# Email 4: Conditional metadata attribute pattern
104+
is_admin <- TRUE # Fixed for testing
105+
106+
if (is_admin) {
107+
admin_recipients <- c("admin@example.com", "superuser@example.com")
108+
} else {
109+
admin_recipients <- c("user@example.com")
110+
}
111+
112+
write_yaml_metadata_block(admin_recipients = admin_recipients)
113+
```
114+
115+
::: {.email recipients=admin_recipients}
116+
117+
::: {.subject}
118+
Email 4: Conditional Metadata Attribute
119+
:::
120+
121+
::: {.email-text}
122+
This email uses conditional metadata attribute pattern.
123+
:::
124+
125+
Fourth email using conditional metadata attribute pattern.
126+
127+
:::

0 commit comments

Comments
 (0)