robo-email 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: robo-email
3
+ Version: 0.1.1
4
+ Summary: Reusable HTML email report rendering and local delivery utilities
5
+ Requires-Python: >=3.12,<3.13
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Programming Language :: Python :: 3.12
8
+ Requires-Dist: matplotlib (==3.11.2)
@@ -0,0 +1,16 @@
1
+ [project]
2
+ name = "robo-email"
3
+ version = "0.1.1"
4
+ description = "Reusable HTML email report rendering and local delivery utilities"
5
+ requires-python = ">=3.12,<3.13"
6
+ dependencies = [
7
+ "matplotlib==3.11.2",
8
+ ]
9
+
10
+ [tool.poetry]
11
+ packages = [{ include = "robo_email" }]
12
+ exclude = ["tools/**"]
13
+
14
+ [build-system]
15
+ requires = ["poetry-core==2.5.0"]
16
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,6 @@
1
+ """Reusable email report rendering and delivery utilities."""
2
+
3
+ from .delivery import is_valid_email, send_powershell_email
4
+ from .renderer import EmailReportGenerator
5
+
6
+ __all__ = ["EmailReportGenerator", "is_valid_email", "send_powershell_email"]
@@ -0,0 +1,183 @@
1
+ """Generic local Windows email delivery helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import re
8
+ import subprocess
9
+ import threading
10
+ from pathlib import Path
11
+ from typing import Iterable, Optional
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ _email_sent = False
16
+ _email_sent_lock = threading.Lock()
17
+ _EMAIL_RE = re.compile(r"^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$")
18
+
19
+
20
+ def is_valid_email(address: str) -> bool:
21
+ """Return whether an address matches the permitted email format."""
22
+ return bool(_EMAIL_RE.match((address or "").strip()))
23
+
24
+
25
+ def _ps_escape(value: Optional[str]) -> str:
26
+ return (value or "").replace("'", "''")
27
+
28
+
29
+ def send_powershell_email(
30
+ *,
31
+ body_path: str | Path,
32
+ recipients: Iterable[str],
33
+ subject: str,
34
+ smtp_server: str = "localhost",
35
+ smtp_port: int = 25,
36
+ smtp_from: str = "",
37
+ attachment_path: str | Path | None = None,
38
+ suppress_duplicates: bool = True,
39
+ ) -> bool:
40
+ """Send an HTML email through PowerShell ``Send-MailMessage`` on Windows.
41
+
42
+ Application code supplies recipients, subject, SMTP configuration and any
43
+ attachment. This function has no knowledge of application environment,
44
+ credentials, spreadsheets, pytest, Jenkins, or business data.
45
+ """
46
+ if os.name != "nt":
47
+ logger.warning("PowerShell email delivery is supported only on Windows.")
48
+ return False
49
+
50
+ body_path = Path(body_path).resolve()
51
+ if not body_path.is_file():
52
+ raise FileNotFoundError(f"Email body does not exist: {body_path}")
53
+
54
+ valid_recipients = sorted(
55
+ {str(value).strip() for value in recipients if is_valid_email(str(value))}
56
+ )
57
+ if not valid_recipients:
58
+ logger.warning("Email send skipped because no valid recipients were supplied.")
59
+ return False
60
+
61
+ global _email_sent
62
+ if suppress_duplicates:
63
+ with _email_sent_lock:
64
+ if _email_sent:
65
+ logger.info("Email already sent for this process; skipping duplicate send.")
66
+ return False
67
+ _email_sent = True
68
+
69
+ try:
70
+ to_list = ";".join(valid_recipients)
71
+ safe_body_path = _ps_escape(str(body_path))
72
+ has_attachment = attachment_path is not None
73
+ safe_attachment_path = (
74
+ _ps_escape(str(Path(attachment_path).resolve())) if has_attachment else ""
75
+ )
76
+
77
+ ps_script = f"""
78
+ $ErrorActionPreference = 'Stop'
79
+ $bodyPath = '{safe_body_path}'
80
+ $attachmentPath = '{safe_attachment_path}'
81
+ $bodyHtml = Get-Content -Path $bodyPath -Raw -Encoding UTF8
82
+ $configuredFrom = '{_ps_escape(smtp_from)}'
83
+ $authUpn = (whoami /upn 2>$null | Select-Object -Last 1)
84
+ function Resolve-AdMailboxSmtp([string]$upn) {{
85
+ try {{
86
+ if ([string]::IsNullOrWhiteSpace($upn) -or -not $upn.Contains('@')) {{ return $null }}
87
+ $sam = $upn.Split('@')[0]
88
+ if ([string]::IsNullOrWhiteSpace($sam)) {{ return $null }}
89
+ $root = [ADSI]"LDAP://RootDSE"
90
+ $searchBase = "LDAP://" + $root.defaultNamingContext
91
+ $searcher = New-Object System.DirectoryServices.DirectorySearcher([ADSI]$searchBase)
92
+ $searcher.Filter = "(&(objectCategory=person)(objectClass=user)(sAMAccountName=$sam))"
93
+ $null = $searcher.PropertiesToLoad.Add("mail")
94
+ $null = $searcher.PropertiesToLoad.Add("proxyAddresses")
95
+ $searcher.SizeLimit = 1
96
+ $result = $searcher.FindOne()
97
+ if ($null -eq $result) {{ return $null }}
98
+ $props = $result.Properties
99
+ if ($props.Contains("proxyaddresses")) {{
100
+ foreach ($addr in $props["proxyaddresses"]) {{
101
+ if ($addr -is [string] -and $addr.StartsWith("SMTP:")) {{
102
+ return $addr.Substring(5).Trim().ToLowerInvariant()
103
+ }}
104
+ }}
105
+ }}
106
+ if ($props.Contains("mail") -and $props["mail"].Count -gt 0) {{
107
+ $mail = [string]$props["mail"][0]
108
+ if (-not [string]::IsNullOrWhiteSpace($mail) -and $mail.Contains('@')) {{
109
+ return $mail.Trim().ToLowerInvariant()
110
+ }}
111
+ }}
112
+ }} catch {{}}
113
+ return $null
114
+ }}
115
+ $resolvedMailboxFromAd = $null
116
+ $smtpFrom = $configuredFrom
117
+ if (-not [string]::IsNullOrWhiteSpace($authUpn)) {{
118
+ $authUpn = $authUpn.Trim().ToLowerInvariant()
119
+ if ($authUpn.Contains('@')) {{
120
+ $resolvedMailboxFromAd = Resolve-AdMailboxSmtp $authUpn
121
+ if (-not [string]::IsNullOrWhiteSpace($resolvedMailboxFromAd)) {{ $smtpFrom = $resolvedMailboxFromAd }}
122
+ else {{ $smtpFrom = $authUpn }}
123
+ }}
124
+ }}
125
+ $toRecipients = '{_ps_escape(to_list)}'.Split(';') | Where-Object {{ -not [string]::IsNullOrWhiteSpace($_) }} | ForEach-Object {{ $_.Trim() }}
126
+ $attachmentExists = {'$true' if has_attachment else '$false'}
127
+ $fromCandidates = New-Object System.Collections.Generic.List[string]
128
+ function Add-FromCandidate([string]$value) {{
129
+ if ([string]::IsNullOrWhiteSpace($value)) {{ return }}
130
+ $candidate = $value.Trim().ToLowerInvariant()
131
+ if (-not $candidate.Contains('@')) {{ return }}
132
+ if (-not $fromCandidates.Contains($candidate)) {{ $fromCandidates.Add($candidate) }}
133
+ }}
134
+ Add-FromCandidate $smtpFrom
135
+ Add-FromCandidate $configuredFrom
136
+ Add-FromCandidate $resolvedMailboxFromAd
137
+ if ($toRecipients.Count -gt 0) {{ Add-FromCandidate $toRecipients[0] }}
138
+ $sendSucceeded = $false
139
+ $lastSendError = $null
140
+ foreach ($candidateFrom in $fromCandidates) {{
141
+ try {{
142
+ if ($attachmentExists) {{
143
+ Send-MailMessage -From $candidateFrom -To $toRecipients -Subject '{_ps_escape(subject)}' -Body $bodyHtml -BodyAsHtml -SmtpServer '{_ps_escape(smtp_server)}' -Port {int(smtp_port)} -Attachments $attachmentPath
144
+ }} else {{
145
+ Send-MailMessage -From $candidateFrom -To $toRecipients -Subject '{_ps_escape(subject)}' -Body $bodyHtml -BodyAsHtml -SmtpServer '{_ps_escape(smtp_server)}' -Port {int(smtp_port)}
146
+ }}
147
+ $sendSucceeded = $true
148
+ break
149
+ }} catch {{ $lastSendError = $_.Exception.Message }}
150
+ }}
151
+ if (-not $sendSucceeded) {{ throw "All sender candidates failed. Last error: $lastSendError" }}
152
+ """
153
+ completed = subprocess.run(
154
+ [
155
+ "powershell",
156
+ "-NoProfile",
157
+ "-ExecutionPolicy",
158
+ "Bypass",
159
+ "-Command",
160
+ ps_script,
161
+ ],
162
+ capture_output=True,
163
+ text=True,
164
+ check=False,
165
+ )
166
+ if completed.returncode != 0:
167
+ logger.error(
168
+ "PowerShell email delivery failed (exit=%s): %s",
169
+ completed.returncode,
170
+ (completed.stderr or completed.stdout or "").strip(),
171
+ )
172
+ if suppress_duplicates:
173
+ with _email_sent_lock:
174
+ _email_sent = False
175
+ return False
176
+
177
+ logger.info("Email sent successfully to %s", ", ".join(valid_recipients))
178
+ return True
179
+ except Exception:
180
+ if suppress_duplicates:
181
+ with _email_sent_lock:
182
+ _email_sent = False
183
+ raise
@@ -0,0 +1,430 @@
1
+ """Render Outlook-compatible HTML email reports with embedded charts.
2
+
3
+ The application owns its canonical report data and HTML template.
4
+ """
5
+
6
+ import base64
7
+ import html
8
+ import json
9
+ import logging
10
+ from io import BytesIO
11
+ from pathlib import Path
12
+
13
+ # Use a non-interactive backend for matplotlib
14
+ try:
15
+ import matplotlib
16
+
17
+ matplotlib.use("Agg")
18
+ import matplotlib.pyplot as plt
19
+ from matplotlib import ticker
20
+ except Exception as exc:
21
+ logging.getLogger(__name__).warning(
22
+ "Matplotlib is unavailable for email charts: %s", exc
23
+ )
24
+ plt = None
25
+
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class EmailReportGenerator:
31
+ """
32
+ A utility class to generate an Outlook-compatible HTML email report from test data,
33
+ rendering charts as embedded Base64 images.
34
+ """
35
+
36
+ @staticmethod
37
+ def _create_chart_image(plot_function, data, title, **kwargs):
38
+ """Render chart data as a Base64-encoded PNG data URI."""
39
+ if not plt:
40
+ return ""
41
+ try:
42
+ fig, ax = plt.subplots(figsize=(6, 4))
43
+ plot_function(fig, ax, data, title, **kwargs) # Pass fig to plot function
44
+ buf = BytesIO()
45
+ fig.savefig(buf, format="png", bbox_inches="tight", pad_inches=0.1)
46
+ plt.close(fig)
47
+ buf.seek(0)
48
+ image_base64 = base64.b64encode(buf.read()).decode("utf-8")
49
+ return f"data:image/png;base64,{image_base64}"
50
+ except Exception as exc:
51
+ logger.warning("Failed to render chart '%s': %s", title, exc)
52
+ return ""
53
+
54
+ @staticmethod
55
+ def _plot_test_summary_donut(fig, ax, data, title, **kwargs):
56
+ """Plots the overall test summary as a donut chart with color-coded center text."""
57
+ status_order = ["Passed", "Failed", "Skipped"]
58
+ sizes = []
59
+ for status in status_order:
60
+ value = data.get(status, 0)
61
+ try:
62
+ numeric_value = int(value)
63
+ except (TypeError, ValueError):
64
+ numeric_value = 0
65
+ sizes.append(max(numeric_value, 0))
66
+
67
+ # Matplotlib pie raises when all values are zero/invalid.
68
+ if sum(sizes) == 0:
69
+ ax.axis("off")
70
+ fig.text(0.5, 0.95, title, ha="center", va="top", fontsize=12)
71
+ ax.text(
72
+ 0.5,
73
+ 0.5,
74
+ "No test results available",
75
+ ha="center",
76
+ va="center",
77
+ fontsize=11,
78
+ color="#666666",
79
+ transform=ax.transAxes,
80
+ )
81
+ return
82
+
83
+ colors = {"Passed": "#66BB6A", "Failed": "#EF5350", "Skipped": "#FFA726"}
84
+
85
+ ax.pie(
86
+ sizes,
87
+ colors=[colors[status] for status in status_order],
88
+ startangle=90,
89
+ wedgeprops=dict(width=0.4),
90
+ )
91
+ centre_circle = plt.Circle((0, 0), 0.60, fc="white")
92
+ ax.add_artist(centre_circle)
93
+
94
+ y_pos = 0.2
95
+ ax.text(
96
+ 0,
97
+ y_pos,
98
+ f"Passed: {data.get('Passed', 0)}",
99
+ ha="center",
100
+ va="center",
101
+ fontsize=10,
102
+ color=colors["Passed"],
103
+ weight="bold",
104
+ )
105
+ ax.text(
106
+ 0,
107
+ 0,
108
+ f"Failed: {data.get('Failed', 0)}",
109
+ ha="center",
110
+ va="center",
111
+ fontsize=10,
112
+ color=colors["Failed"],
113
+ weight="bold",
114
+ )
115
+ ax.text(
116
+ 0,
117
+ -y_pos,
118
+ f"Skipped: {data.get('Skipped', 0)}",
119
+ ha="center",
120
+ va="center",
121
+ fontsize=10,
122
+ color=colors["Skipped"],
123
+ weight="bold",
124
+ )
125
+
126
+ ax.axis("equal")
127
+ fig.text(0.5, 0.95, title, ha="center", va="top", fontsize=12)
128
+
129
+ @staticmethod
130
+ def _plot_stacked_bar(fig, ax, data, title, is_horizontal=True, **kwargs):
131
+ """Plot passed, failed, and skipped totals as a stacked bar chart."""
132
+ labels = list(data.keys())
133
+ passed = [v.get("passed", 0) for v in data.values()]
134
+ failed = [v.get("failed", 0) for v in data.values()]
135
+ skipped = [v.get("skipped", 0) for v in data.values()]
136
+
137
+ bar_thickness = 0.5
138
+ colors = ["#66BB6A", "#EF5350", "#FFA726"]
139
+
140
+ if is_horizontal:
141
+ y_pos = range(len(labels))
142
+ p_bars = ax.barh(
143
+ y_pos, passed, height=bar_thickness, color=colors[0], label="Passed"
144
+ )
145
+ f_bars = ax.barh(
146
+ y_pos,
147
+ failed,
148
+ height=bar_thickness,
149
+ left=passed,
150
+ color=colors[1],
151
+ label="Failed",
152
+ )
153
+ s_bars = ax.barh(
154
+ y_pos,
155
+ skipped,
156
+ height=bar_thickness,
157
+ left=[p + f for p, f in zip(passed, failed)],
158
+ color=colors[2],
159
+ label="Skipped",
160
+ )
161
+ ax.set_yticks(y_pos)
162
+ ax.set_yticklabels(labels)
163
+ ax.invert_yaxis()
164
+ ax.set_xlabel("Number of Tests")
165
+ ax.xaxis.set_major_locator(ticker.MaxNLocator(integer=True))
166
+ else:
167
+ x_pos = range(len(labels))
168
+ p_bars = ax.bar(
169
+ x_pos, passed, width=bar_thickness, color=colors[0], label="Passed"
170
+ )
171
+ f_bars = ax.bar(
172
+ x_pos,
173
+ failed,
174
+ width=bar_thickness,
175
+ bottom=passed,
176
+ color=colors[1],
177
+ label="Failed",
178
+ )
179
+ s_bars = ax.bar(
180
+ x_pos,
181
+ skipped,
182
+ width=bar_thickness,
183
+ bottom=[p + f for p, f in zip(passed, failed)],
184
+ color=colors[2],
185
+ label="Skipped",
186
+ )
187
+ ax.set_xticks(x_pos)
188
+ ax.set_xticklabels(labels)
189
+ ax.set_ylabel("Number of Tests")
190
+ ax.yaxis.set_major_locator(ticker.MaxNLocator(integer=True))
191
+
192
+ fig.text(0.5, 0.95, title, ha="center", va="top", fontsize=12)
193
+ fig.legend(loc="upper center", bbox_to_anchor=(0.5, 0.9), ncol=3, frameon=False)
194
+ fig.subplots_adjust(top=0.8)
195
+
196
+ for i, (p, f, s) in enumerate(zip(passed, failed, skipped)):
197
+ if is_horizontal:
198
+ if p > 0:
199
+ ax.text(
200
+ p / 2,
201
+ i,
202
+ str(p),
203
+ ha="center",
204
+ va="center",
205
+ color="white",
206
+ fontsize=8,
207
+ )
208
+ if f > 0:
209
+ ax.text(
210
+ p + f / 2,
211
+ i,
212
+ str(f),
213
+ ha="center",
214
+ va="center",
215
+ color="white",
216
+ fontsize=8,
217
+ )
218
+ if s > 0:
219
+ ax.text(
220
+ p + f + s / 2,
221
+ i,
222
+ str(s),
223
+ ha="center",
224
+ va="center",
225
+ color="white",
226
+ fontsize=8,
227
+ )
228
+ else:
229
+ if p > 0:
230
+ ax.text(
231
+ i,
232
+ p / 2,
233
+ str(p),
234
+ ha="center",
235
+ va="center",
236
+ color="white",
237
+ fontsize=8,
238
+ )
239
+ if f > 0:
240
+ ax.text(
241
+ i,
242
+ p + f / 2,
243
+ str(f),
244
+ ha="center",
245
+ va="center",
246
+ color="white",
247
+ fontsize=8,
248
+ )
249
+ if s > 0:
250
+ ax.text(
251
+ i,
252
+ p + f + s / 2,
253
+ str(s),
254
+ ha="center",
255
+ va="center",
256
+ color="white",
257
+ fontsize=8,
258
+ )
259
+
260
+
261
+ DEFAULT_PLACEHOLDERS = {
262
+ "title": "__ROBO_EMAIL_TITLE__",
263
+ "summary_chart": "__ROBO_EMAIL_SUMMARY_CHART_CELL__",
264
+ "chart_1": "__ROBO_EMAIL_CHART_1_CELL__",
265
+ "chart_2": "__ROBO_EMAIL_CHART_2_CELL__",
266
+ "chart_3": "__ROBO_EMAIL_CHART_3_CELL__",
267
+ "report_link": "__ROBO_EMAIL_REPORT_LINK__",
268
+ "created_on": "__ROBO_EMAIL_CREATED_ON__",
269
+ }
270
+
271
+ DEFAULT_CHART_SPECS = (
272
+ ("chart_1", "group_1_status", "Status by Group 1", True),
273
+ ("chart_2", "group_2_status", "Status by Group 2", False),
274
+ ("chart_3", "group_3_status", "Status by Group 3", True),
275
+ )
276
+
277
+ @classmethod
278
+ def _load_template(cls, template_path: Path, placeholders) -> str:
279
+ """Load and validate the caller-supplied Outlook-compatible template."""
280
+ template_path = Path(template_path)
281
+ template = template_path.read_text(encoding="utf-8")
282
+ missing = [
283
+ placeholder
284
+ for placeholder in placeholders.values()
285
+ if placeholder not in template
286
+ ]
287
+ if missing:
288
+ raise ValueError(
289
+ f"Email template {template_path} is missing required "
290
+ f"placeholder(s): {', '.join(missing)}"
291
+ )
292
+ return template
293
+
294
+ @staticmethod
295
+ def _chart_cell(img_src: str, alt_text: str) -> str:
296
+ """Return an Outlook-safe chart cell or a plain fallback cell."""
297
+ safe_alt = html.escape(alt_text, quote=True)
298
+ if img_src:
299
+ safe_src = html.escape(img_src, quote=True)
300
+ return (
301
+ '<td align="center" style="padding: 10px; width: 50%;">'
302
+ f'<img src="{safe_src}" alt="{safe_alt}" '
303
+ 'style="max-width: 380px; height: auto;" />'
304
+ "</td>"
305
+ )
306
+ return (
307
+ '<td align="center" style="padding: 10px; width: 50%;">'
308
+ f"{safe_alt} chart not available"
309
+ "</td>"
310
+ )
311
+
312
+ @classmethod
313
+ def generate_email_content(
314
+ cls,
315
+ data,
316
+ *,
317
+ template_path: Path,
318
+ placeholders=None,
319
+ chart_specs=None,
320
+ ):
321
+ """Render an Outlook-compatible email body from canonical report data.
322
+
323
+ Applications provide their own template and can map any three grouped
324
+ status charts into the reusable chart slots.
325
+ """
326
+ placeholders = dict(placeholders or cls.DEFAULT_PLACEHOLDERS)
327
+ chart_specs = tuple(chart_specs or cls.DEFAULT_CHART_SPECS)
328
+ chart_data = data.get("chart_data") or {}
329
+ summary_data = chart_data.get(
330
+ "test_status_summary", {"Passed": 0, "Failed": 0, "Skipped": 0}
331
+ )
332
+
333
+ summary_chart_img = cls._create_chart_image(
334
+ cls._plot_test_summary_donut, summary_data, "Test Summary"
335
+ )
336
+
337
+ chart_cells = {}
338
+ for slot, data_key, title, is_horizontal in chart_specs:
339
+ image = cls._create_chart_image(
340
+ cls._plot_stacked_bar,
341
+ chart_data.get(data_key, {}),
342
+ title,
343
+ is_horizontal=is_horizontal,
344
+ )
345
+ chart_cells[slot] = cls._chart_cell(image, title)
346
+
347
+ title = html.escape(str(data.get("title", "Test Report")), quote=True)
348
+ report_link = html.escape(str(data.get("report_link", "#")), quote=True)
349
+ created_on = html.escape(
350
+ str(data.get("summary", {}).get("Report Created On", "N/A")),
351
+ quote=True,
352
+ )
353
+
354
+ replacements = {
355
+ placeholders["title"]: title,
356
+ placeholders["summary_chart"]: cls._chart_cell(
357
+ summary_chart_img, "Test Summary"
358
+ ),
359
+ placeholders["report_link"]: report_link,
360
+ placeholders["created_on"]: created_on,
361
+ }
362
+ for slot, _, _, _ in chart_specs:
363
+ replacements[placeholders[slot]] = chart_cells[slot]
364
+
365
+ rendered = cls._load_template(template_path, placeholders)
366
+ for placeholder, value in replacements.items():
367
+ rendered = rendered.replace(placeholder, value)
368
+ return rendered
369
+
370
+ @classmethod
371
+ def generate_from_json(
372
+ cls,
373
+ json_path: Path,
374
+ *,
375
+ template_path: Path,
376
+ placeholders=None,
377
+ chart_specs=None,
378
+ ) -> str:
379
+ """Render the email body from a persisted canonical JSON report."""
380
+ data = json.loads(Path(json_path).read_text(encoding="utf-8"))
381
+ if not isinstance(data, dict):
382
+ raise ValueError(f"Report JSON must contain an object: {json_path}")
383
+ return cls.generate_email_content(
384
+ data,
385
+ template_path=template_path,
386
+ placeholders=placeholders,
387
+ chart_specs=chart_specs,
388
+ )
389
+
390
+ @classmethod
391
+ def generate_and_save(
392
+ cls,
393
+ data,
394
+ *,
395
+ template_path: Path,
396
+ filename="email-body.html",
397
+ placeholders=None,
398
+ chart_specs=None,
399
+ ):
400
+ """Render and save an email body from canonical report data."""
401
+ content = cls.generate_email_content(
402
+ data,
403
+ template_path=template_path,
404
+ placeholders=placeholders,
405
+ chart_specs=chart_specs,
406
+ )
407
+ Path(filename).write_text(content, encoding="utf-8")
408
+ logger.info("Email HTML report successfully saved to: %s", filename)
409
+ return filename
410
+
411
+ @classmethod
412
+ def generate_from_json_and_save(
413
+ cls,
414
+ json_path: Path,
415
+ *,
416
+ template_path: Path,
417
+ filename="email-body.html",
418
+ placeholders=None,
419
+ chart_specs=None,
420
+ ):
421
+ """Render and save an email body from persisted report JSON."""
422
+ content = cls.generate_from_json(
423
+ json_path,
424
+ template_path=template_path,
425
+ placeholders=placeholders,
426
+ chart_specs=chart_specs,
427
+ )
428
+ Path(filename).write_text(content, encoding="utf-8")
429
+ logger.info("Email HTML report rendered from %s and saved to %s", json_path, filename)
430
+ return filename