From 88ad981b43c2df4666cb122af674d05c75519c94 Mon Sep 17 00:00:00 2001 From: Jules Walzer-Goldfeld <54960783+juleswg23@users.noreply.github.com> Date: Fri, 13 Feb 2026 16:32:17 -0500 Subject: [PATCH 1/3] plan generation --- PARSING_RESEARCH.md | 257 +++++++++++++++++++++++++++ plan.md | 413 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 670 insertions(+) create mode 100644 PARSING_RESEARCH.md create mode 100644 plan.md diff --git a/PARSING_RESEARCH.md b/PARSING_RESEARCH.md new file mode 100644 index 0000000000..6d62989362 --- /dev/null +++ b/PARSING_RESEARCH.md @@ -0,0 +1,257 @@ +# Research: Parsing Recipients in Lua Filter + +## IMPLEMENTATION SCOPE (Email v2 Recipients - Initial Implementation) + +**We are implementing ONLY**: + +- Python list syntax: `['user1@test.com', 'user2@test.com']` +- R vector output: `"user1@test.com" "user2@test.com"` + +**We are NOT implementing**: + +- JSON arrays (future enhancements) +- Comma-separated (future enhancements) +- Line-separated (future enhancements) +- Other formats (future enhancements) + +The research below documents the more general parsing approach and is available for future expansion when dedicated parsing modules become available. + +### 1. JSON Parsing + +- **`quarto.json.decode(str)`** - Parse JSON strings into Lua tables + - Implemented in `src/resources/pandoc/datadir/_json.lua` + - Handles arrays, objects, strings, numbers, booleans, null + - Example: `["user1@test.com", "user2@test.com"]` → Lua table `{1: "user1@test.com", 2: "user2@test.com"}` + - Can fail with error if JSON is invalid + - **Pattern in codebase**: Use `pcall()` to safely catch JSON parse errors + +### 2. String Utilities + +Located in `src/resources/filters/modules/string.lua`: + +- `split(str, sep)` - Split string on separator, returns table +- `trim(s)` - Remove surrounding whitespace +- Pattern matching with `string.match()`, `string.gmatch()` + +Located in `src/resources/filters/common/string.lua`: + +- `split(str, sep, allow_empty)` - Split with option for empty elements +- `trim(s)` - Whitespace trimming +- `patternEscape(str)` - Escape special characters for regex patterns + +### 3. Table Operations (Lua built-ins) + +- `table.insert(t, value)` - Add to array +- `table.concat(t, sep)` - Join array with separator +- `string.gmatch(str, pattern)` - Iterate matches in string + +### 4. Error Handling Pattern + +The codebase uses `pcall()` for safe error handling: + +```lua +local success, result = pcall(function() + return quarto.json.decode(str) +end) +if success and result then + -- use result +else + -- fallback +end +``` + +## Parsing Strategy for Recipients + +### Goal + +Accept these formats and convert to JSON array: + +1. **JSON array**: `["user1@test.com", "user2@test.com"]` +2. **Python list repr**: `['user1@test.com', 'user2@test.com']` +3. **R vector output**: `"user1@test.com" "user2@test.com"` +4. **Comma-separated**: `user1@test.com, user2@test.com` +5. **Line-separated**: `user1@test.com\nuser2@test.com` +6. **Single value**: `user1@test.com` + +### Implementation Approach + +```lua +function parse_recipients(str) + -- Step 1: Trim the input + str = str_trunc_trim(str, 10000) + + if str == "" then + return {} + end + + -- Step 2: Try JSON parsing first (most explicit) + local success, result = pcall(function() + return quarto.json.decode(str) + end) + + if success and type(result) == "table" then + -- JSON array: ["a", "b"] or ['a', 'b'] + -- Convert to list if it's a JSON array + local recipients = {} + for _, email in ipairs(result) do + table.insert(recipients, tostring(email)) + end + if #recipients > 0 then + return recipients + end + end + + -- Step 3: Try Python list format ['...', '...'] + -- Pattern: [' or " followed by content, then ' or " followed by ] + if string.match(str, "^%[") and string.match(str, "%]$") then + -- Remove brackets + local content = string.sub(str, 2, -2) + -- Try to parse as comma-separated quoted strings + local recipients = {} + for quoted in string.gmatch(content, "['\"]([^'\"]+)['\"]") do + local trimmed = str_trunc_trim(quoted, 1000) + if trimmed ~= "" then + table.insert(recipients, trimmed) + end + end + if #recipients > 0 then + return recipients + end + end + + -- Step 4: Try R vector format "a" "b" "c" + -- Look for quoted strings separated by spaces + local recipients = {} + local found_any = false + for quoted in string.gmatch(str, "['\"]([^'\"]+)['\"]") do + local trimmed = str_trunc_trim(quoted, 1000) + if trimmed ~= "" then + table.insert(recipients, trimmed) + found_any = true + end + end + if found_any then + return recipients + end + + -- Step 5: Try comma-separated + if string.find(str, ",") then + local recipients = {} + for email in string.gmatch(str, "([^,]+)") do + local trimmed = str_trunc_trim(email, 1000) + if trimmed ~= "" then + table.insert(recipients, trimmed) + end + end + if #recipients > 0 then + return recipients + end + end + + -- Step 6: Try line-separated + if string.find(str, "\n") or string.find(str, "\r") then + local recipients = {} + for email in string.gmatch(str, "([^\n\r]+)") do + local trimmed = str_trunc_trim(email, 1000) + if trimmed ~= "" then + table.insert(recipients, trimmed) + end + end + if #recipients > 0 then + return recipients + end + end + + -- Step 7: Single recipient (no delimiter) + -- Treat the whole string as one email + return {str} +end +``` + +### Why This Approach Works + +1. **JSON first**: Most explicit format. If user returns JSON, parse it directly. +2. **Python list syntax**: Recognizes Python's default `repr()` output with single or double quotes. +3. **R vector output**: Recognizes R's default `print()` format with quoted strings separated by spaces. +4. **Fallback delimiters**: Handles simpler formats (comma, newline-separated). +5. **Single value**: Always has a fallback for single email addresses. + +### Integration with email.lua + +**Current pattern in email.lua** (for subject, email-text): + +```lua +elseif child.classes:includes("subject") then + current_email.subject = pandoc.utils.stringify(child) +``` + +**For recipients**, would be: + +```lua +elseif child.classes:includes("recipients") then + local recipients_str = pandoc.utils.stringify(child) + current_email.recipients = parse_recipients(recipients_str) +end +``` + +Then in JSON output: + +```lua +local email_json_obj = { + email_id = idx, + subject = email_obj.subject, + recipients = email_obj.recipients, -- NEW + body_html = html_email_body, + -- ... rest +} +``` + +### Testing the Parser + +Test cases to validate: + +```python +# Python example - what Lua will see: +recipients = ["user1@test.com", "user2@test.com"] +# String repr: "['user1@test.com', 'user2@test.com']" + +# Or JSON string: +# String repr: '["user1@test.com", "user2@test.com"]' +``` + +```r +# R example - what Lua will see: +recipients <- c("user1@test.com", "user2@test.com") +# String repr: "user1@test.com" "user2@test.com" +``` + +### Performance Considerations + +- Parsing is done once per email during render, not a bottleneck +- Max recipients string length: 10,000 chars (reasonable limit) +- Max email length: 1,000 chars per recipient (prevents abuse) +- Pattern matching is efficient in Lua + +### Error Handling + +If parsing completely fails: + +```lua +if #recipients == 0 then + quarto.log.warning("Could not parse recipients from: " .. str) + -- Return empty array, let Connect handle missing recipients + return {} +end +``` + +This is consistent with the "recipients are optional" requirement. + +## Key Advantages of This Approach + +1. ✅ Handles all three formats (JSON, Python, R) +2. ✅ Uses built-in Lua functions (no external dependencies) +3. ✅ Graceful fallbacks (comma-separated → line-separated → single) +4. ✅ Error-safe (no exceptions on invalid input) +5. ✅ Consistent with existing email.lua patterns +6. ✅ No validation of email format (leaves to Connect) +7. ✅ Works with inline code execution (which resolves before Lua runs) diff --git a/plan.md b/plan.md new file mode 100644 index 0000000000..893ae36b07 --- /dev/null +++ b/plan.md @@ -0,0 +1,413 @@ +# Plan: Conditional Recipients for Email Format + +## Issue Summary + +Add support for conditional recipients in email format, where recipient lists can be computed dynamically using code (Python, R, etc.) similar to how subject lines and email text are handled. + +## Context from PR #13882 (Multiple Emails) + +The multiple emails PR added: + +- Support for multiple `.email` divs in a single document +- V2 JSON format with `rsc_email_version: 2` and `emails` array +- Per-email metadata extraction (subject, email-text, email-scheduled) +- Metadata divs nested inside `.email` divs (v2) vs document-level (v1) + +## Current Architecture + +### Key Files + +- **`src/resources/filters/quarto-post/email.lua`**: Main email processing filter + - Extracts email divs and their nested metadata (subject, email-text, email-scheduled) + - Processes images (CID tags for JSON, base64 for preview) + - Generates `.output_metadata.json` with email data + - Currently does NOT handle recipients +- **Test files**: `tests/docs/email/*.qmd` - various email test documents +- **Test spec**: `tests/smoke/render/render-email.test.ts` - validates email output + +### Current Metadata Extraction Flow (v2 format) + +1. Find all `.email` divs during first pass +2. For each `.email` div: + - Extract nested `.subject` div → `current_email.subject` + - Extract nested `.email-text` div → `current_email.email_text` + - Extract nested `.email-scheduled` div → `current_email.suppress_scheduled_email` + - Process remaining content as email body HTML +3. Generate JSON output with email metadata + +### Inline Code Execution + +Quarto already supports inline code execution using the pattern `` `{language} expression` ``: + +- **Pattern**: `` `{python} recipients` `` or `` `{r} get_recipients()` `` +- **Implementation**: `src/core/execute-inline.ts` - `executeInlineCodeHandler()` +- **Execution engines**: + - Jupyter (Python): `src/resources/jupyter/notebook.py` - `cell_execute_inline()` + - Knitr (R): `src/resources/rmd/execute.R` - inline expression handling +- **Process**: Code cells execute first, then inline expressions are resolved using kernel/environment state + +## Proposed Solution + +### Input Format Options + +#### Option 1: Inline Code Expression (SELECTED) + +````markdown +::: {.email} + +::: {.subject} +Weekly Report +::: + +```{python} +import datetime +if datetime.date.today().weekday() < 5: + recipients = ["user1@test.com", "user2@test.com"] +else: + recipients = ["user3@test.com"] +``` +```` + +::: {.recipients} +`{python} recipients` +::: + +Email content here. +::: + +```` + +**How it works**: +1. Code cell executes and sets `recipients` variable +2. Inline expression `` `{python} recipients` `` gets resolved to the string representation +3. Lua filter sees the resolved text (e.g., `["user1@test.com", "user2@test.com"]`) +4. Lua filter parses this string into an array + +**Pros**: +- Consistent with existing Quarto inline code patterns +- Clear separation of code execution and metadata +- Works naturally with Quarto's execution model +- User-friendly and discoverable + +### Supported Formats (Email v2 with Recipients - Initial Implementation) + +**Python list syntax** (what Lua sees): +- `['user1@test.com', 'user2@test.com']` (single quotes) +- `["user1@test.com", "user2@test.com"]` (double quotes) + +**R vector output** (what Lua sees): +- `"user1@test.com" "user2@test.com"` (space-separated quoted strings) + +**Not supported in initial implementation**: +- JSON arrays: `["a", "b"]` +- Comma-separated: `a, b` +- Line-separated: `a\nb` +- Other formats + +This limited initial scope: +- ✅ Works with standard Python and R output +- ✅ Uses only Lua's built-in string functions (no external modules needed) +- ✅ Keeps parsing simple and maintainable +- ✅ Can be extended later if dedicated parsing modules become available + +### Implementation Plan + +#### Phase 1: Recipients Support - Initial Implementation (Python and R formats only) +**Goal**: Get recipients working with Python and R inline expressions in email v2 format + +**Supported Formats** (Email v2 with Recipients - Initial Implementation): +- **Python list syntax**: `['user1@test.com', 'user2@test.com']` (single or double quotes) +- **R vector output**: `"user1@test.com" "user2@test.com"` (quoted strings separated by spaces) + +NOT supported in initial implementation: +- JSON arrays (can add later if needed) +- Comma-separated lists (can add later if needed) +- Line-separated lists (can add later if needed) + +This limited approach keeps parsing simple and maintainable. We can expand to other formats later if a dedicated parsing module becomes available. + +1. **Update email.lua** to extract `.recipients` div: + ```lua + elseif child.classes:includes("recipients") then + current_email.recipients = parse_recipients(pandoc.utils.stringify(child)) +```` + +2. **Implement parse_recipients()** function: + - Try to parse as Python list: `['a', 'b']` or `["a", "b"]` + - Try to parse as R vector: `"a" "b" "c"` (quoted strings separated by spaces) + - On failure, log warning and return empty array (recipients are optional) + +3. **Add to JSON output**: + + ```lua + local email_json_obj = { + email_id = idx, + subject = email_obj.subject, + recipients = email_obj.recipients, -- NEW + body_html = html_email_body, + -- ... rest + } + ``` + +4. **Write tests**: + - `tests/docs/email/email-recipients-python.qmd` - Python inline code + - `tests/docs/email/email-recipients-r.qmd` - R inline code + - Test conditional logic (weekday example from issue) + - Test error cases (unparseable format gracefully omits recipients) + +#### Phase 2: Multiple Emails with Different Recipients + +**Goal**: Each email can have its own recipient list + +1. **Already supported by architecture**: + - Each `current_email` object is independent + - Just need to extract recipients per email + +2. **Test scenarios**: + - Multiple emails with different Python-format recipients + - Multiple emails with different R-format recipients + - Mix of Python and R formats + +#### Phase 3: Polish and Documentation + +**Goal**: Clean up and document + +1. **Handle missing recipients**: + - Emails can have no recipients (Connect may specify recipients through other means) + - No warning on missing recipients (they're optional) + - Graceful handling of unparseable formats (logs warning, omits recipients) + +2. **Documentation**: + - Do not add documentation. We will add that in another repository + +#### Future Enhancement (v2+) + +When parsing infrastructure becomes available, can add support for: + +- JSON arrays +- Comma-separated lists +- Line-separated lists +- Other delimiter-based formats + +This will require either: + +- A dedicated string parsing utility module +- JSON parsing enhancements +- Regex support library + +### Technical Details + +### Recipient Parsing Function (Lua) + +**Supports v1 formats only**: Python list syntax and R vector output + +```lua +function parse_recipients(recipient_str) + recipient_str = str_trunc_trim(recipient_str, 10000) + + if recipient_str == "" then + return {} + end + + -- Try Python list format ['...', '...'] or ["...", "..."] + if string.match(recipient_str, "^%[") and string.match(recipient_str, "%]$") then + local content = string.sub(recipient_str, 2, -2) + local recipients = {} + for quoted in string.gmatch(content, "['\"]([^'\"]+)['\"]") do + local trimmed = str_trunc_trim(quoted, 1000) + if trimmed ~= "" then + table.insert(recipients, trimmed) + end + end + if #recipients > 0 then + return recipients + end + end + + -- Try R vector format "a" "b" "c" + local recipients = {} + local found_any = false + for quoted in string.gmatch(recipient_str, "['\"]([^'\"]+)['\"]") do + local trimmed = str_trunc_trim(quoted, 1000) + if 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 +``` + +**Limitations (v1)**: + +- Only recognizes Python list and R vector formats +- Does not support JSON, comma-separated, or line-separated +- Can be extended later when dedicated parsing modules are available + +#### JSON Output Format + +```json +{ + "rsc_email_version": 2, + "emails": [ + { + "email_id": 1, + "subject": "Weekly Report", + "recipients": ["user1@test.com", "user2@test.com"], + "body_html": "...", + "body_text": "...", + "attachments": [], + "suppress_scheduled": false, + "send_report_as_attachment": false + } + ] +} +``` + +### Test Plan + +1. **Static recipients**: + - Single recipient + - Multiple comma-separated + - Multiple as JSON array + - Multiple line-separated + +2. **Inline code recipients**: + - Python: `{python} recipients` + - R: `{r} recipients` (if supporting knitr) + - Conditional logic (weekday example from issue) + +3. **Edge cases**: + - No recipients (optional, no warning) + - Empty string + - Unparseable format (logs warning, omits recipients) + +4. **Format support**: + - Python list: `['a', 'b']` or `["a", "b"]` + - R vector: `"a" "b" "c"` + +### Design Decisions (Confirmed) + +1. **Required vs Optional**: ✅ Recipients are **OPTIONAL** + - Emails can have no recipients (Connect may specify recipients through other means) + - No warning on missing recipients + +2. **Validation**: ✅ **NO validation** of email format in Lua + - Keep Lua filter simple, let Connect handle validation + +3. **R/Knitr Support**: ✅ **YES**, support R inline expressions + - R: `recipients <- c("a@b.com", "c@d.com")` then `` `{r} recipients` `` + - Python: `recipients = ["a@b.com", "c@d.com"]` then `` `{python} recipients` `` + +4. **Document-level fallback**: ✅ **NO** document-level default recipients + - No fallback mechanism needed + - Each email stands alone + +5. **Syntax**: ✅ **Option 1** - Inline code expression + - Code cell defines variable → inline expression references it → Lua parses result + +6. **Format scope**: ✅ **Initial implementation - Limited to Python and R formats only** + - Only parse Python list syntax: `['a', 'b']` or `["a", "b"]` + - Only parse R vector output: `"a" "b" "c"` + - Do not support JSON, comma-separated, or line-separated formats + - Can extend to other formats later if parsing modules become available + +## Implementation Checklist + +- [ ] Phase 1: Email v2 Recipients Support - Initial Implementation (Python and R formats) + - [ ] Update `email.lua` to extract `.recipients` div + - [ ] Implement `parse_recipients()` function (Python + R only) + - [ ] Add recipients to JSON output + - [ ] Write tests with Python inline code + - [ ] Write tests with R inline code + +- [ ] Phase 2: Multiple emails with recipients + - [ ] Write tests for multiple emails with different recipients + - [ ] Verify per-email recipients in JSON + +- [ ] Phase 3: Polish and Documentation + - [ ] Update schema: `src/resources/schema/document-email.yml` + - [ ] Add examples and documentation + - [ ] Test edge cases (empty, unparseable formats) + +- [ ] Future Enhancements (v2+) + - [ ] Add JSON array support (when parsing modules available) + - [ ] Add comma-separated support + - [ ] Add line-separated support + - [ ] Add other delimiter-based formats + +## Files to Modify + +1. **`src/resources/filters/quarto-post/email.lua`** - Main changes: + - Add `.recipients` extraction in `process_div()` + Implementation Details + +### What Users See vs What Lua Sees + +**User writes**: + +````markdown +```{python} +recipients = ["user1@test.com", "user2@test.com"] +``` +```` + +::: {.recipients} +`{python} recipients` +::: + +```` + +**After execution engine processes markdown, Lua filter sees**: +```markdown +::: {.recipients} +['user1@test.com', 'user2@test.com'] +::: +```` + +**Or for R**: + +```markdown +::: {.recipients} +"user1@test.com" "user2@test.com" +::: +``` + +The Lua filter's job is just to parse these string representations into proper arrays. + +### Python Output Formats to Handle + +- List: `['user1@test.com', 'user2@test.com']` (Python default repr) +- JSON: `["user1@test.com", "user2@test.com"]` (if user converts to JSON) +- Comma-separated: `user1@test.com, user2@test.com` +- Single: `user1@test.com` + +### R Output Formats to Handle + +- Vector: `"user1@test.com" "user2@test.com"` (R default print) +- C syntax: `c("user1@test.com", "user2@test.com")` (if echoed) +- Comma-separated: `user1@test.com, user2@test.com` + +### Edge Cases + +1. **Empty recipients**: `.recipients` div not present or empty → omit recipients from JSON +2. **Single recipient**: String without delimiters → single-element array +3. **Whitespace**: Trim all values +4. **Invalid format**: If can't parse, log warning and use as single recipient string +5. **`news/changelog-1.9.md`** - Add to changelog (when ready) + +## Success Criteria + +- [ ] Static recipients work in v2 format +- [ ] Inline code computed recipients work with Python +- [ ] Inline code computed recipients work with R (if supporting knitr) +- [ ] Multiple emails can have different recipients +- [ ] JSON output includes recipients array +- [ ] Tests pass for all scenarios +- [ ] Documentation updated From 2d0a61cb7bbecc7fdd36193d3e6d57446aebcbf1 Mon Sep 17 00:00:00 2001 From: Jules Walzer-Goldfeld <54960783+juleswg23@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:22:47 -0500 Subject: [PATCH 2/3] parse and test new recipients field --- src/resources/filters/quarto-post/email.lua | 129 ++++++++++++++++++ tests/docs/email/email-recipients-formats.qmd | 47 +++++++ tests/docs/email/email-recipients-python.qmd | 60 ++++++++ tests/docs/email/email-recipients-r.qmd | 61 +++++++++ tests/smoke/render/render-email.test.ts | 85 ++++++++++++ 5 files changed, 382 insertions(+) create mode 100644 tests/docs/email/email-recipients-formats.qmd create mode 100644 tests/docs/email/email-recipients-python.qmd create mode 100644 tests/docs/email/email-recipients-r.qmd 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[]= []; From 3f396b1da8c6faf7d05dbb21cb50e8df835433c4 Mon Sep 17 00:00:00 2001 From: Jules Walzer-Goldfeld <54960783+juleswg23@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:23:36 -0500 Subject: [PATCH 3/3] delete claude files --- PARSING_RESEARCH.md | 257 --------------------------- plan.md | 413 -------------------------------------------- 2 files changed, 670 deletions(-) delete mode 100644 PARSING_RESEARCH.md delete mode 100644 plan.md diff --git a/PARSING_RESEARCH.md b/PARSING_RESEARCH.md deleted file mode 100644 index 6d62989362..0000000000 --- a/PARSING_RESEARCH.md +++ /dev/null @@ -1,257 +0,0 @@ -# Research: Parsing Recipients in Lua Filter - -## IMPLEMENTATION SCOPE (Email v2 Recipients - Initial Implementation) - -**We are implementing ONLY**: - -- Python list syntax: `['user1@test.com', 'user2@test.com']` -- R vector output: `"user1@test.com" "user2@test.com"` - -**We are NOT implementing**: - -- JSON arrays (future enhancements) -- Comma-separated (future enhancements) -- Line-separated (future enhancements) -- Other formats (future enhancements) - -The research below documents the more general parsing approach and is available for future expansion when dedicated parsing modules become available. - -### 1. JSON Parsing - -- **`quarto.json.decode(str)`** - Parse JSON strings into Lua tables - - Implemented in `src/resources/pandoc/datadir/_json.lua` - - Handles arrays, objects, strings, numbers, booleans, null - - Example: `["user1@test.com", "user2@test.com"]` → Lua table `{1: "user1@test.com", 2: "user2@test.com"}` - - Can fail with error if JSON is invalid - - **Pattern in codebase**: Use `pcall()` to safely catch JSON parse errors - -### 2. String Utilities - -Located in `src/resources/filters/modules/string.lua`: - -- `split(str, sep)` - Split string on separator, returns table -- `trim(s)` - Remove surrounding whitespace -- Pattern matching with `string.match()`, `string.gmatch()` - -Located in `src/resources/filters/common/string.lua`: - -- `split(str, sep, allow_empty)` - Split with option for empty elements -- `trim(s)` - Whitespace trimming -- `patternEscape(str)` - Escape special characters for regex patterns - -### 3. Table Operations (Lua built-ins) - -- `table.insert(t, value)` - Add to array -- `table.concat(t, sep)` - Join array with separator -- `string.gmatch(str, pattern)` - Iterate matches in string - -### 4. Error Handling Pattern - -The codebase uses `pcall()` for safe error handling: - -```lua -local success, result = pcall(function() - return quarto.json.decode(str) -end) -if success and result then - -- use result -else - -- fallback -end -``` - -## Parsing Strategy for Recipients - -### Goal - -Accept these formats and convert to JSON array: - -1. **JSON array**: `["user1@test.com", "user2@test.com"]` -2. **Python list repr**: `['user1@test.com', 'user2@test.com']` -3. **R vector output**: `"user1@test.com" "user2@test.com"` -4. **Comma-separated**: `user1@test.com, user2@test.com` -5. **Line-separated**: `user1@test.com\nuser2@test.com` -6. **Single value**: `user1@test.com` - -### Implementation Approach - -```lua -function parse_recipients(str) - -- Step 1: Trim the input - str = str_trunc_trim(str, 10000) - - if str == "" then - return {} - end - - -- Step 2: Try JSON parsing first (most explicit) - local success, result = pcall(function() - return quarto.json.decode(str) - end) - - if success and type(result) == "table" then - -- JSON array: ["a", "b"] or ['a', 'b'] - -- Convert to list if it's a JSON array - local recipients = {} - for _, email in ipairs(result) do - table.insert(recipients, tostring(email)) - end - if #recipients > 0 then - return recipients - end - end - - -- Step 3: Try Python list format ['...', '...'] - -- Pattern: [' or " followed by content, then ' or " followed by ] - if string.match(str, "^%[") and string.match(str, "%]$") then - -- Remove brackets - local content = string.sub(str, 2, -2) - -- Try to parse as comma-separated quoted strings - local recipients = {} - for quoted in string.gmatch(content, "['\"]([^'\"]+)['\"]") do - local trimmed = str_trunc_trim(quoted, 1000) - if trimmed ~= "" then - table.insert(recipients, trimmed) - end - end - if #recipients > 0 then - return recipients - end - end - - -- Step 4: Try R vector format "a" "b" "c" - -- Look for quoted strings separated by spaces - local recipients = {} - local found_any = false - for quoted in string.gmatch(str, "['\"]([^'\"]+)['\"]") do - local trimmed = str_trunc_trim(quoted, 1000) - if trimmed ~= "" then - table.insert(recipients, trimmed) - found_any = true - end - end - if found_any then - return recipients - end - - -- Step 5: Try comma-separated - if string.find(str, ",") then - local recipients = {} - for email in string.gmatch(str, "([^,]+)") do - local trimmed = str_trunc_trim(email, 1000) - if trimmed ~= "" then - table.insert(recipients, trimmed) - end - end - if #recipients > 0 then - return recipients - end - end - - -- Step 6: Try line-separated - if string.find(str, "\n") or string.find(str, "\r") then - local recipients = {} - for email in string.gmatch(str, "([^\n\r]+)") do - local trimmed = str_trunc_trim(email, 1000) - if trimmed ~= "" then - table.insert(recipients, trimmed) - end - end - if #recipients > 0 then - return recipients - end - end - - -- Step 7: Single recipient (no delimiter) - -- Treat the whole string as one email - return {str} -end -``` - -### Why This Approach Works - -1. **JSON first**: Most explicit format. If user returns JSON, parse it directly. -2. **Python list syntax**: Recognizes Python's default `repr()` output with single or double quotes. -3. **R vector output**: Recognizes R's default `print()` format with quoted strings separated by spaces. -4. **Fallback delimiters**: Handles simpler formats (comma, newline-separated). -5. **Single value**: Always has a fallback for single email addresses. - -### Integration with email.lua - -**Current pattern in email.lua** (for subject, email-text): - -```lua -elseif child.classes:includes("subject") then - current_email.subject = pandoc.utils.stringify(child) -``` - -**For recipients**, would be: - -```lua -elseif child.classes:includes("recipients") then - local recipients_str = pandoc.utils.stringify(child) - current_email.recipients = parse_recipients(recipients_str) -end -``` - -Then in JSON output: - -```lua -local email_json_obj = { - email_id = idx, - subject = email_obj.subject, - recipients = email_obj.recipients, -- NEW - body_html = html_email_body, - -- ... rest -} -``` - -### Testing the Parser - -Test cases to validate: - -```python -# Python example - what Lua will see: -recipients = ["user1@test.com", "user2@test.com"] -# String repr: "['user1@test.com', 'user2@test.com']" - -# Or JSON string: -# String repr: '["user1@test.com", "user2@test.com"]' -``` - -```r -# R example - what Lua will see: -recipients <- c("user1@test.com", "user2@test.com") -# String repr: "user1@test.com" "user2@test.com" -``` - -### Performance Considerations - -- Parsing is done once per email during render, not a bottleneck -- Max recipients string length: 10,000 chars (reasonable limit) -- Max email length: 1,000 chars per recipient (prevents abuse) -- Pattern matching is efficient in Lua - -### Error Handling - -If parsing completely fails: - -```lua -if #recipients == 0 then - quarto.log.warning("Could not parse recipients from: " .. str) - -- Return empty array, let Connect handle missing recipients - return {} -end -``` - -This is consistent with the "recipients are optional" requirement. - -## Key Advantages of This Approach - -1. ✅ Handles all three formats (JSON, Python, R) -2. ✅ Uses built-in Lua functions (no external dependencies) -3. ✅ Graceful fallbacks (comma-separated → line-separated → single) -4. ✅ Error-safe (no exceptions on invalid input) -5. ✅ Consistent with existing email.lua patterns -6. ✅ No validation of email format (leaves to Connect) -7. ✅ Works with inline code execution (which resolves before Lua runs) diff --git a/plan.md b/plan.md deleted file mode 100644 index 893ae36b07..0000000000 --- a/plan.md +++ /dev/null @@ -1,413 +0,0 @@ -# Plan: Conditional Recipients for Email Format - -## Issue Summary - -Add support for conditional recipients in email format, where recipient lists can be computed dynamically using code (Python, R, etc.) similar to how subject lines and email text are handled. - -## Context from PR #13882 (Multiple Emails) - -The multiple emails PR added: - -- Support for multiple `.email` divs in a single document -- V2 JSON format with `rsc_email_version: 2` and `emails` array -- Per-email metadata extraction (subject, email-text, email-scheduled) -- Metadata divs nested inside `.email` divs (v2) vs document-level (v1) - -## Current Architecture - -### Key Files - -- **`src/resources/filters/quarto-post/email.lua`**: Main email processing filter - - Extracts email divs and their nested metadata (subject, email-text, email-scheduled) - - Processes images (CID tags for JSON, base64 for preview) - - Generates `.output_metadata.json` with email data - - Currently does NOT handle recipients -- **Test files**: `tests/docs/email/*.qmd` - various email test documents -- **Test spec**: `tests/smoke/render/render-email.test.ts` - validates email output - -### Current Metadata Extraction Flow (v2 format) - -1. Find all `.email` divs during first pass -2. For each `.email` div: - - Extract nested `.subject` div → `current_email.subject` - - Extract nested `.email-text` div → `current_email.email_text` - - Extract nested `.email-scheduled` div → `current_email.suppress_scheduled_email` - - Process remaining content as email body HTML -3. Generate JSON output with email metadata - -### Inline Code Execution - -Quarto already supports inline code execution using the pattern `` `{language} expression` ``: - -- **Pattern**: `` `{python} recipients` `` or `` `{r} get_recipients()` `` -- **Implementation**: `src/core/execute-inline.ts` - `executeInlineCodeHandler()` -- **Execution engines**: - - Jupyter (Python): `src/resources/jupyter/notebook.py` - `cell_execute_inline()` - - Knitr (R): `src/resources/rmd/execute.R` - inline expression handling -- **Process**: Code cells execute first, then inline expressions are resolved using kernel/environment state - -## Proposed Solution - -### Input Format Options - -#### Option 1: Inline Code Expression (SELECTED) - -````markdown -::: {.email} - -::: {.subject} -Weekly Report -::: - -```{python} -import datetime -if datetime.date.today().weekday() < 5: - recipients = ["user1@test.com", "user2@test.com"] -else: - recipients = ["user3@test.com"] -``` -```` - -::: {.recipients} -`{python} recipients` -::: - -Email content here. -::: - -```` - -**How it works**: -1. Code cell executes and sets `recipients` variable -2. Inline expression `` `{python} recipients` `` gets resolved to the string representation -3. Lua filter sees the resolved text (e.g., `["user1@test.com", "user2@test.com"]`) -4. Lua filter parses this string into an array - -**Pros**: -- Consistent with existing Quarto inline code patterns -- Clear separation of code execution and metadata -- Works naturally with Quarto's execution model -- User-friendly and discoverable - -### Supported Formats (Email v2 with Recipients - Initial Implementation) - -**Python list syntax** (what Lua sees): -- `['user1@test.com', 'user2@test.com']` (single quotes) -- `["user1@test.com", "user2@test.com"]` (double quotes) - -**R vector output** (what Lua sees): -- `"user1@test.com" "user2@test.com"` (space-separated quoted strings) - -**Not supported in initial implementation**: -- JSON arrays: `["a", "b"]` -- Comma-separated: `a, b` -- Line-separated: `a\nb` -- Other formats - -This limited initial scope: -- ✅ Works with standard Python and R output -- ✅ Uses only Lua's built-in string functions (no external modules needed) -- ✅ Keeps parsing simple and maintainable -- ✅ Can be extended later if dedicated parsing modules become available - -### Implementation Plan - -#### Phase 1: Recipients Support - Initial Implementation (Python and R formats only) -**Goal**: Get recipients working with Python and R inline expressions in email v2 format - -**Supported Formats** (Email v2 with Recipients - Initial Implementation): -- **Python list syntax**: `['user1@test.com', 'user2@test.com']` (single or double quotes) -- **R vector output**: `"user1@test.com" "user2@test.com"` (quoted strings separated by spaces) - -NOT supported in initial implementation: -- JSON arrays (can add later if needed) -- Comma-separated lists (can add later if needed) -- Line-separated lists (can add later if needed) - -This limited approach keeps parsing simple and maintainable. We can expand to other formats later if a dedicated parsing module becomes available. - -1. **Update email.lua** to extract `.recipients` div: - ```lua - elseif child.classes:includes("recipients") then - current_email.recipients = parse_recipients(pandoc.utils.stringify(child)) -```` - -2. **Implement parse_recipients()** function: - - Try to parse as Python list: `['a', 'b']` or `["a", "b"]` - - Try to parse as R vector: `"a" "b" "c"` (quoted strings separated by spaces) - - On failure, log warning and return empty array (recipients are optional) - -3. **Add to JSON output**: - - ```lua - local email_json_obj = { - email_id = idx, - subject = email_obj.subject, - recipients = email_obj.recipients, -- NEW - body_html = html_email_body, - -- ... rest - } - ``` - -4. **Write tests**: - - `tests/docs/email/email-recipients-python.qmd` - Python inline code - - `tests/docs/email/email-recipients-r.qmd` - R inline code - - Test conditional logic (weekday example from issue) - - Test error cases (unparseable format gracefully omits recipients) - -#### Phase 2: Multiple Emails with Different Recipients - -**Goal**: Each email can have its own recipient list - -1. **Already supported by architecture**: - - Each `current_email` object is independent - - Just need to extract recipients per email - -2. **Test scenarios**: - - Multiple emails with different Python-format recipients - - Multiple emails with different R-format recipients - - Mix of Python and R formats - -#### Phase 3: Polish and Documentation - -**Goal**: Clean up and document - -1. **Handle missing recipients**: - - Emails can have no recipients (Connect may specify recipients through other means) - - No warning on missing recipients (they're optional) - - Graceful handling of unparseable formats (logs warning, omits recipients) - -2. **Documentation**: - - Do not add documentation. We will add that in another repository - -#### Future Enhancement (v2+) - -When parsing infrastructure becomes available, can add support for: - -- JSON arrays -- Comma-separated lists -- Line-separated lists -- Other delimiter-based formats - -This will require either: - -- A dedicated string parsing utility module -- JSON parsing enhancements -- Regex support library - -### Technical Details - -### Recipient Parsing Function (Lua) - -**Supports v1 formats only**: Python list syntax and R vector output - -```lua -function parse_recipients(recipient_str) - recipient_str = str_trunc_trim(recipient_str, 10000) - - if recipient_str == "" then - return {} - end - - -- Try Python list format ['...', '...'] or ["...", "..."] - if string.match(recipient_str, "^%[") and string.match(recipient_str, "%]$") then - local content = string.sub(recipient_str, 2, -2) - local recipients = {} - for quoted in string.gmatch(content, "['\"]([^'\"]+)['\"]") do - local trimmed = str_trunc_trim(quoted, 1000) - if trimmed ~= "" then - table.insert(recipients, trimmed) - end - end - if #recipients > 0 then - return recipients - end - end - - -- Try R vector format "a" "b" "c" - local recipients = {} - local found_any = false - for quoted in string.gmatch(recipient_str, "['\"]([^'\"]+)['\"]") do - local trimmed = str_trunc_trim(quoted, 1000) - if 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 -``` - -**Limitations (v1)**: - -- Only recognizes Python list and R vector formats -- Does not support JSON, comma-separated, or line-separated -- Can be extended later when dedicated parsing modules are available - -#### JSON Output Format - -```json -{ - "rsc_email_version": 2, - "emails": [ - { - "email_id": 1, - "subject": "Weekly Report", - "recipients": ["user1@test.com", "user2@test.com"], - "body_html": "...", - "body_text": "...", - "attachments": [], - "suppress_scheduled": false, - "send_report_as_attachment": false - } - ] -} -``` - -### Test Plan - -1. **Static recipients**: - - Single recipient - - Multiple comma-separated - - Multiple as JSON array - - Multiple line-separated - -2. **Inline code recipients**: - - Python: `{python} recipients` - - R: `{r} recipients` (if supporting knitr) - - Conditional logic (weekday example from issue) - -3. **Edge cases**: - - No recipients (optional, no warning) - - Empty string - - Unparseable format (logs warning, omits recipients) - -4. **Format support**: - - Python list: `['a', 'b']` or `["a", "b"]` - - R vector: `"a" "b" "c"` - -### Design Decisions (Confirmed) - -1. **Required vs Optional**: ✅ Recipients are **OPTIONAL** - - Emails can have no recipients (Connect may specify recipients through other means) - - No warning on missing recipients - -2. **Validation**: ✅ **NO validation** of email format in Lua - - Keep Lua filter simple, let Connect handle validation - -3. **R/Knitr Support**: ✅ **YES**, support R inline expressions - - R: `recipients <- c("a@b.com", "c@d.com")` then `` `{r} recipients` `` - - Python: `recipients = ["a@b.com", "c@d.com"]` then `` `{python} recipients` `` - -4. **Document-level fallback**: ✅ **NO** document-level default recipients - - No fallback mechanism needed - - Each email stands alone - -5. **Syntax**: ✅ **Option 1** - Inline code expression - - Code cell defines variable → inline expression references it → Lua parses result - -6. **Format scope**: ✅ **Initial implementation - Limited to Python and R formats only** - - Only parse Python list syntax: `['a', 'b']` or `["a", "b"]` - - Only parse R vector output: `"a" "b" "c"` - - Do not support JSON, comma-separated, or line-separated formats - - Can extend to other formats later if parsing modules become available - -## Implementation Checklist - -- [ ] Phase 1: Email v2 Recipients Support - Initial Implementation (Python and R formats) - - [ ] Update `email.lua` to extract `.recipients` div - - [ ] Implement `parse_recipients()` function (Python + R only) - - [ ] Add recipients to JSON output - - [ ] Write tests with Python inline code - - [ ] Write tests with R inline code - -- [ ] Phase 2: Multiple emails with recipients - - [ ] Write tests for multiple emails with different recipients - - [ ] Verify per-email recipients in JSON - -- [ ] Phase 3: Polish and Documentation - - [ ] Update schema: `src/resources/schema/document-email.yml` - - [ ] Add examples and documentation - - [ ] Test edge cases (empty, unparseable formats) - -- [ ] Future Enhancements (v2+) - - [ ] Add JSON array support (when parsing modules available) - - [ ] Add comma-separated support - - [ ] Add line-separated support - - [ ] Add other delimiter-based formats - -## Files to Modify - -1. **`src/resources/filters/quarto-post/email.lua`** - Main changes: - - Add `.recipients` extraction in `process_div()` - Implementation Details - -### What Users See vs What Lua Sees - -**User writes**: - -````markdown -```{python} -recipients = ["user1@test.com", "user2@test.com"] -``` -```` - -::: {.recipients} -`{python} recipients` -::: - -```` - -**After execution engine processes markdown, Lua filter sees**: -```markdown -::: {.recipients} -['user1@test.com', 'user2@test.com'] -::: -```` - -**Or for R**: - -```markdown -::: {.recipients} -"user1@test.com" "user2@test.com" -::: -``` - -The Lua filter's job is just to parse these string representations into proper arrays. - -### Python Output Formats to Handle - -- List: `['user1@test.com', 'user2@test.com']` (Python default repr) -- JSON: `["user1@test.com", "user2@test.com"]` (if user converts to JSON) -- Comma-separated: `user1@test.com, user2@test.com` -- Single: `user1@test.com` - -### R Output Formats to Handle - -- Vector: `"user1@test.com" "user2@test.com"` (R default print) -- C syntax: `c("user1@test.com", "user2@test.com")` (if echoed) -- Comma-separated: `user1@test.com, user2@test.com` - -### Edge Cases - -1. **Empty recipients**: `.recipients` div not present or empty → omit recipients from JSON -2. **Single recipient**: String without delimiters → single-element array -3. **Whitespace**: Trim all values -4. **Invalid format**: If can't parse, log warning and use as single recipient string -5. **`news/changelog-1.9.md`** - Add to changelog (when ready) - -## Success Criteria - -- [ ] Static recipients work in v2 format -- [ ] Inline code computed recipients work with Python -- [ ] Inline code computed recipients work with R (if supporting knitr) -- [ ] Multiple emails can have different recipients -- [ ] JSON output includes recipients array -- [ ] Tests pass for all scenarios -- [ ] Documentation updated