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

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