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
79 changes: 78 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,41 @@ function str_truthy_falsy(str)
return false
end

-- Parse recipients using regex to find email addresses
-- Matches pattern: local-part@domain.tld
-- Handles any format: Python lists, R vectors, comma-separated,
-- space-separated, quoted, unquoted, etc.
-- Returns an empty array if no valid emails found
function parse_recipients(recipient_str)
recipient_str = str_trunc_trim(recipient_str, 10000)

if recipient_str == "" then
return {}
end

local recipients = {}
-- Match anything that's not a separator (quotes, commas, spaces, brackets, parens)
-- This allows international characters while stopping at separators
for email in string.gmatch(recipient_str, "[^%s,'\"%[%]%(%)]+@[^%s,'\"%[%]%(%)]+%.[^%s,'\"%[%]%(%)]+") do
-- Strip any leading/trailing quote characters (both straight and curly)
-- Straight quotes: ' "
-- Curly single quotes: ' ' (U+2018, U+2019)
-- Curly double quotes: " " (U+201C, U+201D)
email = string.gsub(email, "^['\"" .. string.char(226, 128, 152) .. string.char(226, 128, 153) .. string.char(226, 128, 156) .. string.char(226, 128, 157) .. "]+", "")
email = string.gsub(email, "['\"" .. string.char(226, 128, 152) .. string.char(226, 128, 153) .. string.char(226, 128, 156) .. string.char(226, 128, 157) .. "]+$", "")

if email ~= "" and string.match(email, "@") then
table.insert(recipients, email)
end
end

if #recipients == 0 then
quarto.log.warning("Could not parse recipients format: " .. recipient_str)
end

return recipients
end

local html_email_template_1 = [[
<!DOCTYPE html>
<html>
Expand Down Expand Up @@ -254,6 +289,7 @@ function process_div(div)
image_tbl = {},
email_images = {},
suppress_scheduled_email = nil, -- nil means not set
recipients = {},
attachments = {}
}

Expand All @@ -270,14 +306,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 +580,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.

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

:::
Loading
Loading