Skip to content

Commit b9a53e6

Browse files
authored
Merge pull request #23 from posit-dev/quarto_output_naming
feat: write to quarto(connect)-usable metadata secondarily, enable reading (ingress) of json with proposed new serialized field names without `rsc_` prefixes.
2 parents dd03daf + 146c9bf commit b9a53e6

File tree

6 files changed

+254
-28
lines changed

6 files changed

+254
-28
lines changed

docs/_quarto.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ quartodoc:
4848
- name: Email.write_preview_email
4949
- name: Email.write_email_message
5050
- name: Email.preview_send_email
51+
- name: Email.write_quarto_json
5152
- title: Uploading emails
5253
desc: >
5354
Converting emails to Emails,

nbmail/ingress.py

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,9 @@ def mjml_to_email(
6969
mjml_markup = processed_mjml._to_mjml()
7070
else:
7171
# String-based MJML, no preprocessing needed
72-
warnings.warn("MJMLTag not detected; treating input as plaintext MJML markup", UserWarning)
72+
warnings.warn(
73+
"MJMLTag not detected; treating input as plaintext MJML markup", UserWarning
74+
)
7375
mjml_markup = mjml_content
7476
inline_attachments = {}
7577

@@ -78,8 +80,8 @@ def mjml_to_email(
7880
i_email = Email(
7981
html=email_content,
8082
subject="",
81-
rsc_email_supress_report_attachment=False,
82-
rsc_email_supress_scheduled=False,
83+
email_suppress_report_attachment=False,
84+
email_suppress_scheduled=False,
8385
inline_attachments=inline_attachments,
8486
)
8587

@@ -173,33 +175,45 @@ def quarto_json_to_email(path: str) -> Email:
173175
with open(path, "r", encoding="utf-8") as f:
174176
metadata = json.load(f)
175177

176-
email_html = metadata.get("rsc_email_body_html", "")
177-
email_subject = metadata.get("rsc_email_subject", "")
178-
email_text = metadata.get("rsc_email_body_text", "")
179-
180-
178+
# Support both rsc_-prefixed (Quarto standard) and non-prefixed formats
179+
email_html = metadata.get("rsc_email_body_html", metadata.get("email_body_html", ""))
180+
email_subject = metadata.get("rsc_email_subject", metadata.get("email_subject", ""))
181+
email_text = metadata.get("rsc_email_body_text", metadata.get("email_body_text", ""))
181182

182183
# This is a list of paths that connect dumps attached files into.
183184
# Should be in same output directory
184185
output_files = metadata.get("rsc_output_files", [])
185-
output_files += metadata.get("rsc_email_attachments", [])
186+
output_files += metadata.get("rsc_email_attachments", metadata.get("email_attachments", []))
186187

187188
# Get email images (dictionary: {filename: base64_string})
188-
email_images = metadata.get("rsc_email_images", {})
189-
190-
supress_report_attachment = metadata.get(
191-
"rsc_email_supress_report_attachment", False
189+
email_images = metadata.get("rsc_email_images", metadata.get("email_images", {}))
190+
191+
# Support both old (suppress) and new (suppress) spellings, with both prefixes
192+
suppress_report_attachment = metadata.get(
193+
"rsc_email_suppress_report_attachment",
194+
metadata.get(
195+
"rsc_email_suppress_report_attachment",
196+
metadata.get("email_suppress_report_attachment",
197+
metadata.get("email_suppress_report_attachment", False))
198+
)
199+
)
200+
suppress_scheduled = metadata.get(
201+
"rsc_email_suppress_scheduled",
202+
metadata.get(
203+
"rsc_email_suppress_scheduled",
204+
metadata.get("email_suppress_scheduled",
205+
metadata.get("email_suppress_scheduled", False))
206+
)
192207
)
193-
supress_scheduled = metadata.get("rsc_email_supress_scheduled", False)
194208

195209
i_email = Email(
196210
html=email_html,
197211
text=email_text,
198212
inline_attachments=email_images,
199213
external_attachments=output_files,
200214
subject=email_subject,
201-
rsc_email_supress_report_attachment=supress_report_attachment,
202-
rsc_email_supress_scheduled=supress_scheduled,
215+
email_suppress_report_attachment=suppress_report_attachment,
216+
email_suppress_scheduled=suppress_scheduled,
203217
)
204218

205219
return i_email

nbmail/structs.py

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22
from dataclasses import dataclass, field
33
import re
4+
import json
45

56
from email.message import EmailMessage
67

@@ -38,10 +39,10 @@ class Email:
3839
recipients
3940
Optional list of recipient email addresses.
4041
41-
rsc_email_supress_report_attachment
42+
email_suppress_report_attachment
4243
Whether to suppress report attachments (used in some workflows).
4344
44-
rsc_email_supress_scheduled
45+
email_suppress_scheduled
4546
Whether to suppress scheduled sending (used in some workflows).
4647
4748
Examples
@@ -58,8 +59,8 @@ class Email:
5859

5960
html: str
6061
subject: str
61-
rsc_email_supress_report_attachment: bool | None = None
62-
rsc_email_supress_scheduled: bool | None = None
62+
email_suppress_report_attachment: bool | None = None
63+
email_suppress_scheduled: bool | None = None
6364

6465
# is a list of files in path from current directory
6566
external_attachments: list[str] = field(default_factory=list)
@@ -224,3 +225,69 @@ def preview_send_email(self):
224225
```
225226
"""
226227
raise NotImplementedError
228+
229+
def write_quarto_json(self, out_file: str = ".output_metadata.json") -> None:
230+
"""
231+
Write the Email to Quarto's output metadata JSON format.
232+
233+
This method serializes the Email object to JSON in the format expected by Quarto,
234+
making it compatible with Quarto's email integration workflows. This is the inverse
235+
of the `quarto_json_to_email()` ingress function.
236+
237+
Parameters
238+
----------
239+
out_file
240+
The file path to write the Quarto metadata JSON. Defaults to ".output_metadata.json".
241+
242+
Returns
243+
-------
244+
None
245+
246+
Examples
247+
--------
248+
```python
249+
email = Email(
250+
html="<p>Hello world</p>",
251+
subject="Test Email",
252+
)
253+
email.write_quarto_json("email_metadata.json")
254+
```
255+
256+
Notes
257+
------
258+
The output JSON includes:\n
259+
- email_subject: The subject line
260+
- email_attachments: List of attachment file paths
261+
- email_body_html: The HTML content of the email
262+
- email_body_text: Plain text version (if present)
263+
- email_images: Dictionary of base64-encoded inline images (only if not empty)
264+
- email_suppress_report_attachment: Suppression flag for report attachments
265+
- email_suppress_scheduled: Suppression flag for scheduled sending
266+
"""
267+
metadata = {
268+
"email_subject": self.subject,
269+
"email_attachments": self.external_attachments or [],
270+
"email_body_html": self.html,
271+
}
272+
273+
# Add optional text field if present
274+
if self.text:
275+
metadata["email_body_text"] = self.text
276+
277+
# Add inline images only if not empty
278+
if self.inline_attachments:
279+
metadata["email_images"] = self.inline_attachments
280+
281+
# Add suppression flags if they are set (not None)
282+
if self.email_suppress_report_attachment is not None:
283+
metadata["email_suppress_report_attachment"] = (
284+
self.email_suppress_report_attachment
285+
)
286+
287+
if self.email_suppress_scheduled is not None:
288+
metadata["email_suppress_scheduled"] = self.email_suppress_scheduled
289+
290+
with open(out_file, "w", encoding="utf-8") as f:
291+
json.dump(metadata, f, indent=2)
292+
293+

nbmail/tests/test_egress.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33

44
import pytest
5+
import json
6+
import tempfile
7+
import os
8+
59
from nbmail.egress import (
610
send_email_with_redmail,
711
send_email_with_yagmail,
@@ -11,6 +15,7 @@
1115
send_quarto_email_with_gmail,
1216
)
1317
from nbmail.structs import Email
18+
from nbmail.ingress import quarto_json_to_email
1419

1520

1621
def make_basic_email():
@@ -286,3 +291,142 @@ def test_not_implemented_functions(send_func):
286291
email = make_basic_email()
287292
with pytest.raises(NotImplementedError):
288293
send_func(email)
294+
295+
296+
# Tests for Email.write_quarto_json() method
297+
def test_email_write_quarto_json_basic():
298+
email = Email(
299+
html="<html><body><p>Test email</p></body></html>",
300+
subject="Test Subject",
301+
text="Plain text version",
302+
external_attachments=["file1.pdf", "file2.csv"],
303+
inline_attachments={"img1": "base64data123"},
304+
email_suppress_report_attachment=True,
305+
email_suppress_scheduled=False,
306+
)
307+
308+
with tempfile.TemporaryDirectory() as tmpdir:
309+
json_path = os.path.join(tmpdir, "test.json")
310+
email.write_quarto_json(json_path)
311+
312+
with open(json_path, "r") as f:
313+
data = json.load(f)
314+
315+
# Check that all expected fields are present (with prefix for Quarto compatibility)
316+
assert data["email_subject"] == "Test Subject"
317+
assert data["email_body_html"] == "<html><body><p>Test email</p></body></html>"
318+
assert data["email_body_text"] == "Plain text version"
319+
assert data["email_attachments"] == ["file1.pdf", "file2.csv"]
320+
assert data["email_images"] == {"img1": "base64data123"}
321+
assert data["email_suppress_report_attachment"] is True
322+
assert data["email_suppress_scheduled"] is False
323+
324+
325+
def test_email_write_quarto_json_minimal():
326+
"""Test writing a minimal email to Quarto JSON format."""
327+
email = Email(
328+
html="<html><body>Minimal</body></html>",
329+
subject="Minimal Subject",
330+
)
331+
332+
with tempfile.TemporaryDirectory() as tmpdir:
333+
json_path = os.path.join(tmpdir, "minimal.json")
334+
email.write_quarto_json(json_path)
335+
336+
with open(json_path, "r") as f:
337+
data = json.load(f)
338+
339+
# Check minimal fields
340+
assert data["email_subject"] == "Minimal Subject"
341+
assert data["email_body_html"] == "<html><body>Minimal</body></html>"
342+
assert data["email_attachments"] == []
343+
344+
# Optional fields should not be present
345+
assert "email_body_text" not in data
346+
assert "email_images" not in data
347+
assert "email_suppress_report_attachment" not in data
348+
assert "email_suppress_scheduled" not in data
349+
350+
351+
def test_email_write_quarto_json_round_trip():
352+
"""Test writing and reading back a Quarto JSON email."""
353+
original_email = Email(
354+
html="<html><body><p>Quarto email</p></body></html>",
355+
subject="Quarto Test",
356+
text="Plain text version",
357+
external_attachments=["output.pdf"],
358+
inline_attachments={"img1": "base64encodedstring"},
359+
email_suppress_report_attachment=True,
360+
email_suppress_scheduled=False,
361+
)
362+
363+
with tempfile.TemporaryDirectory() as tmpdir:
364+
json_path = os.path.join(tmpdir, "roundtrip.json")
365+
original_email.write_quarto_json(json_path)
366+
367+
# Read it back
368+
read_email = quarto_json_to_email(json_path)
369+
370+
# Verify all fields match
371+
assert read_email.subject == original_email.subject
372+
assert read_email.html == original_email.html
373+
assert read_email.text == original_email.text
374+
assert read_email.external_attachments == original_email.external_attachments
375+
assert read_email.inline_attachments == original_email.inline_attachments
376+
assert read_email.email_suppress_report_attachment == original_email.email_suppress_report_attachment
377+
assert read_email.email_suppress_scheduled == original_email.email_suppress_scheduled
378+
379+
380+
def test_email_write_quarto_json_no_attachments():
381+
"""Test writing an email without attachments or images."""
382+
email = Email(
383+
html="<html><body>No attachments</body></html>",
384+
subject="No Attachments",
385+
)
386+
387+
with tempfile.TemporaryDirectory() as tmpdir:
388+
json_path = os.path.join(tmpdir, "no_attachments.json")
389+
email.write_quarto_json(json_path)
390+
391+
with open(json_path, "r") as f:
392+
data = json.load(f)
393+
394+
# Check that attachments and images are empty
395+
assert data["email_attachments"] == []
396+
assert "email_images" not in data
397+
398+
399+
def test_email_write_quarto_json_no_text():
400+
"""Test writing an email without plain text version."""
401+
email = Email(
402+
html="<html><body>HTML only</body></html>",
403+
subject="HTML Only",
404+
)
405+
406+
with tempfile.TemporaryDirectory() as tmpdir:
407+
json_path = os.path.join(tmpdir, "html_only.json")
408+
email.write_quarto_json(json_path)
409+
410+
with open(json_path, "r") as f:
411+
data = json.load(f)
412+
413+
# Plain text should not be present
414+
assert "email_body_text" not in data
415+
416+
417+
def test_email_write_quarto_json_custom_filename():
418+
email = Email(
419+
html="<html><body>Custom</body></html>",
420+
subject="Custom Filename",
421+
)
422+
423+
with tempfile.TemporaryDirectory() as tmpdir:
424+
custom_path = os.path.join(tmpdir, "my_custom_file.json")
425+
email.write_quarto_json(custom_path)
426+
427+
assert os.path.exists(custom_path)
428+
429+
with open(custom_path, "r") as f:
430+
data = json.load(f)
431+
432+
assert data["email_subject"] == "Custom Filename"

nbmail/tests/test_ingress.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -225,8 +225,8 @@ def test_quarto_json_to_email_basic(tmp_path):
225225
"rsc_output_files": ["output.pdf"],
226226
"rsc_email_attachments": ["attachment.csv"],
227227
"rsc_email_images": {"img1": "base64encodedstring"},
228-
"rsc_email_supress_report_attachment": True,
229-
"rsc_email_supress_scheduled": False,
228+
"rsc_email_suppress_report_attachment": True,
229+
"rsc_email_suppress_scheduled": False,
230230
}
231231

232232
json_file = tmp_path / "metadata.json"
@@ -240,8 +240,8 @@ def test_quarto_json_to_email_basic(tmp_path):
240240
assert result.text == "Plain text version"
241241
assert result.external_attachments == ["output.pdf", "attachment.csv"]
242242
assert result.inline_attachments == {"img1": "base64encodedstring"}
243-
assert result.rsc_email_supress_report_attachment is True
244-
assert result.rsc_email_supress_scheduled is False
243+
assert result.email_suppress_report_attachment is True
244+
assert result.email_suppress_scheduled is False
245245

246246

247247
def test_quarto_json_to_email_minimal(tmp_path):
@@ -261,8 +261,8 @@ def test_quarto_json_to_email_minimal(tmp_path):
261261
assert result.text == ""
262262
assert result.external_attachments == []
263263
assert result.inline_attachments == {}
264-
assert result.rsc_email_supress_report_attachment is False
265-
assert result.rsc_email_supress_scheduled is False
264+
assert result.email_suppress_report_attachment is False
265+
assert result.email_suppress_scheduled is False
266266

267267

268268
def test_quarto_json_to_email_empty_lists(tmp_path):

nbmail/tests/test_structs.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ def test_subject_inserts_after_body(tmp_path):
3737
email = Email(
3838
html=html,
3939
subject="Test Subject",
40-
rsc_email_supress_report_attachment=False,
41-
rsc_email_supress_scheduled=False,
40+
email_suppress_report_attachment=False,
41+
email_suppress_scheduled=False,
4242
)
4343
out_file = tmp_path / "preview.html"
4444

0 commit comments

Comments
 (0)