From e1862462af3cc419af85577fd0d3eaaee6b0fc1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BA=D0=BE=D1=81=20=D0=90=D1=80=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=20=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2=D0=B8?= =?UTF-8?q?=D1=87?= Date: Fri, 10 Jul 2026 15:24:27 +0700 Subject: [PATCH] feat: add HTML email body for --send (#48) Add email.html config flag (default false). When enabled, --send includes an HTML version of the report body generated via HTMLFormatter alongside the plain-text part in a multipart/alternative message. - EmailConfig gains html: bool field - mailer.build_message/send_report accept rows for HTML generation - CLI passes rows to send_report - --init-config generates email.html: false - README.md and docs/CONFIG.md updated Bump version to 1.10.0. Closes #48 --- README.md | 9 +++ docs/CONFIG.md | 8 ++- pyproject.toml | 2 +- redmine_reporter/__init__.py | 2 +- redmine_reporter/cli.py | 9 ++- redmine_reporter/config.py | 2 + redmine_reporter/mailer.py | 28 ++++++-- tests/test_cli.py | 107 +++++++++++++++++++++++++++++ tests/test_config.py | 55 ++++++++++++++- tests/test_mailer.py | 129 +++++++++++++++++++++++++++++------ 10 files changed, 323 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 9089b2e..7f4cfc0 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ CLI-инструмент для генерации отчётов по зада - Умное разрешение `--output`: bare-формат (`xlsx`) → путь по шаблону, без расширения → автодописывание. - `--commit`: автосохранение отчёта в файл + фиксация периода в YAML-конфиге для следующего запуска. - `--send`: отправка отчёта по email через SMTP сразу после генерации. +- HTML-версия тела письма при `--send`, если включено в YAML-конфиге (`email.html: true`). - Понятные сообщения об ошибках Redmine API, SMTP и файловой системы. - Загрузка альтернативного `.env` через `--config`. @@ -84,6 +85,7 @@ report: no_time: false email: + html: false smtp: host: smtp.example.com port: 587 @@ -213,6 +215,13 @@ redmine-reporter --date 2026-06-01--2026-06-30 --output ~/report.xlsx --send redmine-reporter --date 2026-06-01--2026-06-30 --send --commit ``` +Если в секции `email` установить `html: true`, письмо будет отправлено в двух версиях: plain-text и HTML (таблица отчёта прямо в теле письма). Файл отчёта всё равно прикрепляется, если `attach: true`. + +```yaml +email: + html: true +``` + Если секция `email` не настроена — ошибка с пояснением. При ошибке SMTP файл отчёта остаётся на диске, данные не теряются. Поддерживаются `to`, `cc`, `bcc`, TLS, отключение вложения (`attach: false`). ### Фиксация периода (`--commit`) diff --git a/docs/CONFIG.md b/docs/CONFIG.md index ac56008..660e66f 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -47,6 +47,7 @@ report: no_time: false email: + html: false smtp: host: smtp.example.com port: 587 @@ -127,6 +128,7 @@ redmine-reporter --commit | `subject` | строка | `"Отчёт {author} за {period}"` | Тема письма | | `body_text` | строка | `"Во вложении отчёт."` | Текст письма (plain text) | | `attach` | bool | `true` | Прикреплять файл отчёта. Если `false` — только текст | +| `html` | bool | `false` | Добавить HTML-версию тела письма (`multipart/alternative`) | **Подстановки в `subject` и `body_text`:** @@ -152,6 +154,7 @@ redmine-reporter --commit ```yaml email: + html: false smtp: host: smtp.example.com port: 587 @@ -181,7 +184,9 @@ email: 2. Сохраняет отчёт в файл: - Если указан `--output` — по явному пути. - Если `--output` не указан — по шаблону из `output.dir` / `output.filename`. -3. Формирует MIME-письмо: тема, текст, вложение (с корректным MIME-типом). +3. Формирует MIME-письмо: + - Тема, plain-text тело и вложение (если `attach: true`). + - При `email.html: true` — дополнительно HTML-версия тела (`multipart/alternative`), сгенерированная из таблицы отчёта. 4. Отправляет через SMTP с TLS (таймаут 30 секунд). **Файл отчёта сохраняется до попытки отправки** — при ошибке SMTP файл остаётся @@ -216,6 +221,7 @@ redmine-reporter --date 2026-06-01--2026-06-30 --send --commit | `--send` | Сохранить по шаблону → отправить | | `--send --output X` | Сохранить в X → отправить | | `--send --commit` | Сохранить → отправить → зафиксировать период | +| `--send` с `email.html: true` | Письмо с plain-text + HTML-таблицей | | `--send` без `email` в конфиге | Ошибка, exit 1 | | `--send` при ошибке SMTP | Файл сохранён, ошибка в stderr, exit 1 | diff --git a/pyproject.toml b/pyproject.toml index d3137bc..2d977b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "redmine-reporter" -version = "1.9.0" +version = "1.10.0" description = "Redmine time-entry based issue reporter for internal use" readme = "README.md" authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }] diff --git a/redmine_reporter/__init__.py b/redmine_reporter/__init__.py index 0a0a43a..fcfdf38 100644 --- a/redmine_reporter/__init__.py +++ b/redmine_reporter/__init__.py @@ -1 +1 @@ -__version__ = "1.9.0" +__version__ = "1.10.0" diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py index 6bbc59f..7709150 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -76,6 +76,7 @@ def _run_init_config(config_path: str, force: bool) -> int: "no_time": False, }, "email": { + "html": False, "smtp": { "host": "", "port": 587, @@ -209,7 +210,13 @@ def _save_and_maybe_send( return 1 try: - send_report(email_config, output_arg, author, f"{from_date}--{to_date}") + send_report( + email_config, + output_arg, + author, + f"{from_date}--{to_date}", + rows=rows, + ) print(f"📧 Report sent to {', '.join(email_config.to)}") except RedmineAPIError as e: print(f"❌ {e.message}", file=sys.stderr) diff --git a/redmine_reporter/config.py b/redmine_reporter/config.py index 4bd135a..11a4e62 100644 --- a/redmine_reporter/config.py +++ b/redmine_reporter/config.py @@ -38,6 +38,7 @@ class EmailConfig: subject: str = "Отчёт {author} за {period}" body_text: str = "Во вложении отчёт." attach: bool = True + html: bool = False @dataclass @@ -193,6 +194,7 @@ class AppConfig: cls._safe_str(email_raw.get("body_text")) or "Во вложении отчёт." ), attach=cls._safe_bool(email_raw.get("attach"), True), + html=cls._safe_bool(email_raw.get("html"), False), ) @staticmethod diff --git a/redmine_reporter/mailer.py b/redmine_reporter/mailer.py index 485963c..2090012 100644 --- a/redmine_reporter/mailer.py +++ b/redmine_reporter/mailer.py @@ -6,10 +6,11 @@ import smtplib from email.mime.application import MIMEApplication from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText -from typing import Dict +from typing import Dict, List from .client import RedmineAPIError from .config import EmailConfig +from .types import ReportRow # Use 8bit transfer encoding for UTF-8 so non-ASCII text (e.g. Russian) # appears literally in MIME output instead of base64. @@ -33,13 +34,22 @@ def _resolve_mime_type(file_path: str) -> str: return MIME_TYPES.get(ext, "application/octet-stream") +def _build_html_body(rows: List[ReportRow]) -> str: + """Генерирует HTML-версию тела письма через HTMLFormatter.""" + from .formatters.html import HTMLFormatter + + formatter = HTMLFormatter() + return formatter.format(rows) + + def build_message( email_config: EmailConfig, file_path: str, author: str, period: str, + rows: List[ReportRow], ) -> MIMEMultipart: - """Формирует MIME-письмо с подстановками и вложением.""" + """Формирует MIME-письмо с подстановками, телами и вложением.""" subject = email_config.subject.replace("{author}", author).replace( "{period}", period ) @@ -54,7 +64,15 @@ def build_message( if email_config.cc: msg["Cc"] = ", ".join(email_config.cc) - msg.attach(MIMEText(body, "plain", "utf-8")) + # Тела письма: plain-text всегда, HTML по флагу + body_container = MIMEMultipart("alternative") + body_container.attach(MIMEText(body, "plain", "utf-8")) + + if email_config.html: + html_body = _build_html_body(rows) + body_container.attach(MIMEText(html_body, "html", "utf-8")) + + msg.attach(body_container) if email_config.attach: try: @@ -81,6 +99,7 @@ def send_report( file_path: str, author: str, period: str, + rows: List[ReportRow], ) -> None: """Отправляет сгенерированный отчёт по email через SMTP. @@ -89,12 +108,13 @@ def send_report( file_path: Путь к файлу отчёта для вложения. author: Имя автора (для подстановки в тему/тело). period: Строка периода (для подстановки в тему/тело). + rows: Строки отчёта (для HTML-версии тела письма). Raises: RedmineAPIError: При любой ошибке соединения или отправки. """ smtp_cfg = email_config.smtp - msg = build_message(email_config, file_path, author, period) + msg = build_message(email_config, file_path, author, period, rows) all_recipients = ( list(email_config.to) + list(email_config.cc) + list(email_config.bcc) ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 0c172da..7ec3d0e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -387,6 +387,21 @@ class TestInitConfig: assert data["redmine"]["api_key"] == "${REDMINE_API_KEY}" + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + def test_init_config_includes_email_html(self, tmp_path): + """--init-config генерирует email.html: false.""" + import yaml + + config_path = tmp_path / "config.yml" + code = main(["--init-config", "--config-path", str(config_path)]) + assert code == 0 + + with open(config_path) as fh: + data = yaml.safe_load(fh) + + assert "html" in data["email"] + assert data["email"]["html"] is False + @mock.patch.dict(os.environ, {}, clear=True) def test_init_config_no_env_vars(self, tmp_path): """Без переменных окружения --init-config создаёт скелет.""" @@ -1011,6 +1026,98 @@ class TestSendFlag: assert code == 0 +class TestSendHtmlBody: + """Tests for email.html body generation.""" + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + @mock.patch("redmine_reporter.cli.send_report") + @mock.patch("redmine_reporter.cli.get_formatter_by_extension") + def test_send_passes_rows_to_send_report( + self, mock_get, mock_send, mock_fetch, tmp_path + ): + """--send передаёт rows в send_report для генерации HTML.""" + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0)] + mock_formatter = mock.MagicMock() + mock_get.return_value = mock_formatter + + config_path = tmp_path / "config.yml" + config_path.write_text( + "email:\n" + " html: true\n" + " smtp:\n" + " host: smtp.example.com\n" + " port: 587\n" + " user: bot\n" + " password: secret\n" + " from: bot@example.com\n" + " to:\n" + " - boss@example.com\n" + ) + + output = str(tmp_path / "report.xlsx") + code = main( + [ + "--date", + "2026-06-01--2026-06-30", + "--output", + output, + "--send", + "--config-path", + str(config_path), + ] + ) + assert code == 0 + + _, kwargs = mock_send.call_args + assert "rows" in kwargs + assert len(kwargs["rows"]) == 1 + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + @mock.patch("redmine_reporter.cli.send_report") + @mock.patch("redmine_reporter.cli.get_formatter_by_extension") + def test_send_html_false_does_not_require_html_part( + self, mock_get, mock_send, mock_fetch, tmp_path + ): + """При email.html: false письмо отправляется без HTML-части.""" + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0)] + mock_formatter = mock.MagicMock() + mock_get.return_value = mock_formatter + + config_path = tmp_path / "config.yml" + config_path.write_text( + "email:\n" + " smtp:\n" + " host: smtp.example.com\n" + " port: 587\n" + " user: bot\n" + " password: secret\n" + " from: bot@example.com\n" + " to:\n" + " - boss@example.com\n" + ) + + output = str(tmp_path / "report.xlsx") + code = main( + [ + "--date", + "2026-06-01--2026-06-30", + "--output", + output, + "--send", + "--config-path", + str(config_path), + ] + ) + assert code == 0 + mock_send.assert_called_once() + _, kwargs = mock_send.call_args + assert "rows" in kwargs + + class TestResolveNoTime: """Tests for _resolve_no_time().""" diff --git a/tests/test_config.py b/tests/test_config.py index 9620990..21348d5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -5,7 +5,12 @@ from unittest import mock import pytest -from redmine_reporter.config import DEFAULT_REDMINE_VERIFY, AppConfig, Config +from redmine_reporter.config import ( + DEFAULT_REDMINE_VERIFY, + AppConfig, + Config, + EmailConfig, +) @mock.patch.dict( @@ -613,6 +618,54 @@ class TestGetEmailConfig: assert Config.get_email_config() is None +class TestEmailConfigHtml: + """Tests for EmailConfig.html field.""" + + def test_email_config_html_from_yaml(self): + """email.html: true загружается из YAML.""" + with tempfile.TemporaryDirectory() as tmp: + yaml_path = Path(tmp) / "config.yml" + yaml_path.write_text( + "email:\n html: true\n smtp:\n host: smtp.example.com\n" + ) + + cfg = AppConfig.from_yaml(yaml_path) + assert cfg.email.html is True + + def test_email_config_html_defaults_to_false(self): + """email.html по умолчанию False.""" + assert EmailConfig().html is False + + with tempfile.TemporaryDirectory() as tmp: + yaml_path = Path(tmp) / "config.yml" + yaml_path.write_text("email:\n smtp:\n host: smtp.example.com\n") + + cfg = AppConfig.from_yaml(yaml_path) + assert cfg.email.html is False + + def test_email_config_html_false_from_yaml(self): + """email.html: false явно загружается из YAML.""" + with tempfile.TemporaryDirectory() as tmp: + yaml_path = Path(tmp) / "config.yml" + yaml_path.write_text( + "email:\n html: false\n smtp:\n host: smtp.example.com\n" + ) + + cfg = AppConfig.from_yaml(yaml_path) + assert cfg.email.html is False + + def test_email_config_html_invalid_string_defaults_to_false(self): + """email.html со строкой 'invalid' приводится к False.""" + with tempfile.TemporaryDirectory() as tmp: + yaml_path = Path(tmp) / "config.yml" + yaml_path.write_text( + "email:\n html: 'invalid'\n smtp:\n host: smtp.example.com\n" + ) + + cfg = AppConfig.from_yaml(yaml_path) + assert cfg.email.html is False + + class TestReportNoTime: """Tests for Config.get_report_no_time().""" diff --git a/tests/test_mailer.py b/tests/test_mailer.py index 1211a41..e96af64 100644 --- a/tests/test_mailer.py +++ b/tests/test_mailer.py @@ -37,7 +37,7 @@ class TestBuildMessage: """{author} и {period} подставляются в тему.""" cfg = _make_email_config(subject="Отчёт {author} за {period}", attach=False) msg = build_message( - cfg, "/tmp/report.xlsx", "Кокос А.Н.", "2026-06-01--2026-06-30" + cfg, "/tmp/report.xlsx", "Кокос А.Н.", "2026-06-01--2026-06-30", [] ) assert msg["Subject"] == "Отчёт Кокос А.Н. за 2026-06-01--2026-06-30" @@ -46,43 +46,44 @@ class TestBuildMessage: cfg = _make_email_config( body_text="Автор: {author}, период: {period}", attach=False ) - msg = build_message(cfg, "/tmp/report.xlsx", "Иванов", "Q1") - plain_part = msg.get_payload()[0] - assert "Автор: Иванов, период: Q1" in plain_part.as_string() + msg = build_message(cfg, "/tmp/report.xlsx", "Иванов", "Q1", []) + plain_parts = [p for p in msg.walk() if p.get_content_type() == "text/plain"] + assert len(plain_parts) == 1 + assert "Автор: Иванов, период: Q1" in plain_parts[0].as_string() def test_from_header(self): cfg = _make_email_config(from_="sender@example.com", attach=False) - msg = build_message(cfg, "/tmp/r.xlsx", "A", "P") + msg = build_message(cfg, "/tmp/r.xlsx", "A", "P", []) assert msg["From"] == "sender@example.com" def test_to_header(self): cfg = _make_email_config(to=["a@x.com", "b@x.com"], attach=False) - msg = build_message(cfg, "/tmp/r.xlsx", "A", "P") + msg = build_message(cfg, "/tmp/r.xlsx", "A", "P", []) assert msg["To"] == "a@x.com, b@x.com" def test_cc_header(self): cfg = _make_email_config(cc=["cc@x.com"], attach=False) - msg = build_message(cfg, "/tmp/r.xlsx", "A", "P") + msg = build_message(cfg, "/tmp/r.xlsx", "A", "P", []) assert msg["Cc"] == "cc@x.com" def test_bcc_not_in_headers(self): """BCC не должен появляться в заголовках письма.""" cfg = _make_email_config(bcc=["hidden@x.com"], attach=False) - msg = build_message(cfg, "/tmp/r.xlsx", "A", "P") + msg = build_message(cfg, "/tmp/r.xlsx", "A", "P", []) assert "Bcc" not in msg def test_attachment_present_when_attach_true(self, tmp_path): report = tmp_path / "report.xlsx" report.write_text("fake xlsx content") cfg = _make_email_config(attach=True) - msg = build_message(cfg, str(report), "A", "P") + msg = build_message(cfg, str(report), "A", "P", []) assert len(msg.get_payload()) == 2 def test_no_attachment_when_attach_false(self, tmp_path): report = tmp_path / "report.xlsx" report.write_text("fake xlsx content") cfg = _make_email_config(attach=False) - msg = build_message(cfg, str(report), "A", "P") + msg = build_message(cfg, str(report), "A", "P", []) assert len(msg.get_payload()) == 1 @pytest.mark.parametrize( @@ -103,7 +104,7 @@ class TestBuildMessage: report = tmp_path / f"report{ext}" report.write_text("content") cfg = _make_email_config() - msg = build_message(cfg, str(report), "A", "P") + msg = build_message(cfg, str(report), "A", "P", []) attachment_part = msg.get_payload()[1] assert attachment_part.get_content_type() == expected_mime @@ -112,7 +113,97 @@ class TestBuildMessage: cfg = _make_email_config() missing = str(tmp_path / "nonexistent.xlsx") with pytest.raises(RedmineAPIError, match="Не удалось прочитать файл"): - build_message(cfg, missing, "A", "P") + build_message(cfg, missing, "A", "P", []) + + def test_html_part_present_when_html_true(self, tmp_path): + """При email.html: true письмо содержит multipart/alternative с HTML.""" + report = tmp_path / "report.xlsx" + report.write_text("fake content") + cfg = _make_email_config(html=True) + rows = [ + { + "project": "Project", + "version": "1.0", + "issue_id": 1, + "subject": "Task", + "status_ru": "В работе", + "time_text": "2ч", + "hours": 2.0, + } + ] + msg = build_message(cfg, str(report), "A", "P", rows) + + alternatives = [ + p for p in msg.walk() if p.get_content_type() == "multipart/alternative" + ] + assert len(alternatives) == 1 + + html_parts = [p for p in msg.walk() if p.get_content_type() == "text/html"] + assert len(html_parts) == 1 + + plain_parts = [p for p in msg.walk() if p.get_content_type() == "text/plain"] + assert len(plain_parts) == 1 + + def test_no_html_part_when_html_false(self, tmp_path): + """При email.html: false письмо содержит только plain-text.""" + report = tmp_path / "report.xlsx" + report.write_text("fake content") + cfg = _make_email_config(html=False) + msg = build_message(cfg, str(report), "A", "P", []) + + html_parts = [p for p in msg.walk() if p.get_content_type() == "text/html"] + assert len(html_parts) == 0 + + plain_parts = [p for p in msg.walk() if p.get_content_type() == "text/plain"] + assert len(plain_parts) == 1 + + def test_html_part_contains_report_data(self, tmp_path): + """HTML-часть содержит данные отчёта.""" + report = tmp_path / "report.xlsx" + report.write_text("fake content") + cfg = _make_email_config(html=True) + rows = [ + { + "project": "Project X", + "version": "2.0", + "issue_id": 42, + "subject": "Important task", + "status_ru": "В работе", + "time_text": "3ч 30м", + "hours": 3.5, + } + ] + msg = build_message(cfg, str(report), "A", "P", rows) + + html_part = [p for p in msg.walk() if p.get_content_type() == "text/html"][0] + payload = html_part.get_payload(decode=True).decode("utf-8") + assert "Project X" in payload + assert "Important task" in payload + + def test_plain_text_before_html_in_alternative(self, tmp_path): + """В multipart/alternative plain-text идёт перед HTML.""" + report = tmp_path / "report.xlsx" + report.write_text("fake content") + cfg = _make_email_config(html=True) + rows = [ + { + "project": "P", + "version": "V", + "issue_id": 1, + "subject": "S", + "status_ru": "В работе", + "time_text": "1ч", + "hours": 1.0, + } + ] + msg = build_message(cfg, str(report), "A", "P", rows) + + alternative = [ + p for p in msg.walk() if p.get_content_type() == "multipart/alternative" + ][0] + payloads = alternative.get_payload() + assert payloads[0].get_content_type() == "text/plain" + assert payloads[1].get_content_type() == "text/html" class TestSendReport: @@ -127,7 +218,7 @@ class TestSendReport: mock_smtp = mock.MagicMock() mock_smtp_class.return_value.__enter__.return_value = mock_smtp - send_report(cfg, str(report), "Кокос А.Н.", "2026-06-01--2026-06-30") + send_report(cfg, str(report), "Кокос А.Н.", "2026-06-01--2026-06-30", []) mock_smtp_class.assert_called_once_with("smtp.example.com", 587, timeout=30) mock_smtp.starttls.assert_called_once() @@ -143,7 +234,7 @@ class TestSendReport: mock_smtp = mock.MagicMock() mock_smtp_class.return_value.__enter__.return_value = mock_smtp - send_report(cfg, str(report), "A", "P") + send_report(cfg, str(report), "A", "P", []) mock_smtp.starttls.assert_not_called() @@ -154,7 +245,7 @@ class TestSendReport: with mock.patch("smtplib.SMTP", side_effect=OSError("Connection refused")): with pytest.raises(RedmineAPIError, match="Не удалось подключиться к SMTP"): - send_report(cfg, str(report), "A", "P") + send_report(cfg, str(report), "A", "P", []) def test_send_auth_error(self, tmp_path): report = tmp_path / "report.xlsx" @@ -169,7 +260,7 @@ class TestSendReport: ) with pytest.raises(RedmineAPIError, match="Ошибка аутентификации SMTP"): - send_report(cfg, str(report), "A", "P") + send_report(cfg, str(report), "A", "P", []) def test_send_timeout_error(self, tmp_path): report = tmp_path / "report.xlsx" @@ -178,7 +269,7 @@ class TestSendReport: with mock.patch("smtplib.SMTP", side_effect=TimeoutError()): with pytest.raises(RedmineAPIError, match="Таймаут соединения с SMTP"): - send_report(cfg, str(report), "A", "P") + send_report(cfg, str(report), "A", "P", []) def test_send_generic_smtp_error(self, tmp_path): report = tmp_path / "report.xlsx" @@ -193,7 +284,7 @@ class TestSendReport: ) with pytest.raises(RedmineAPIError, match="Ошибка отправки письма"): - send_report(cfg, str(report), "A", "P") + send_report(cfg, str(report), "A", "P", []) def test_send_includes_cc_and_bcc(self, tmp_path): report = tmp_path / "report.xlsx" @@ -204,7 +295,7 @@ class TestSendReport: mock_smtp = mock.MagicMock() mock_smtp_class.return_value.__enter__.return_value = mock_smtp - send_report(cfg, str(report), "A", "P") + send_report(cfg, str(report), "A", "P", []) call_args = mock_smtp.send_message.call_args msg = call_args.args[0]