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 a5b225915e
10 changed files with 323 additions and 28 deletions

View File

@@ -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`)

View File

@@ -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 |

View File

@@ -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" }]

View File

@@ -1 +1 @@
__version__ = "1.9.0"
__version__ = "1.10.0"

View File

@@ -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)

View File

@@ -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

View File

@@ -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)
)

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

View File

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

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]