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

@@ -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": "",
"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": "",
"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]