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
This commit is contained in:
Кокос Артем Николаевич
2026-07-10 15:24:27 +07:00
parent 863ad50cc3
commit e1862462af
10 changed files with 323 additions and 28 deletions

View File

@@ -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()."""