feat: auto-email sending via SMTP (--send flag)

Closes #45

- New redmine_reporter/mailer.py: SMTP email sending with
  {author}/{period} template substitution, MIME attachment
  with correct content-type per file extension
- Config.get_email_config(): returns EmailConfig from YAML
  or None when not configured
- CLI --send flag: sends report after generation, works with
  --output, --commit, or standalone (saves to template path)
- 31 new tests (22 mailer + 6 CLI + 3 config)
- 249/249 tests passing, ruff clean, mypy clean
This commit is contained in:
Кокос Артем Николаевич
2026-07-10 12:37:02 +07:00
parent b926dd0d49
commit b0e353c565
7 changed files with 693 additions and 63 deletions

120
redmine_reporter/mailer.py Normal file
View File

@@ -0,0 +1,120 @@
"""Отправка сгенерированного отчёта по email через SMTP."""
import email.charset as _charset
import os
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 .client import RedmineAPIError
from .config import EmailConfig
# Use 8bit transfer encoding for UTF-8 so non-ASCII text (e.g. Russian)
# appears literally in MIME output instead of base64.
_charset.add_charset("utf-8", _charset.SHORTEST, None, "utf-8")
SMTP_TIMEOUT = 30
MIME_TYPES: Dict[str, str] = {
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".odt": "application/vnd.oasis.opendocument.text",
".csv": "text/csv",
".html": "text/html",
".json": "application/json",
".md": "text/markdown",
}
def _resolve_mime_type(file_path: str) -> str:
"""Определяет MIME-тип по расширению файла."""
ext = os.path.splitext(file_path)[1].lower()
return MIME_TYPES.get(ext, "application/octet-stream")
def build_message(
email_config: EmailConfig,
file_path: str,
author: str,
period: str,
) -> MIMEMultipart:
"""Формирует MIME-письмо с подстановками и вложением."""
subject = email_config.subject.replace("{author}", author).replace("{period}", period)
body = email_config.body_text.replace("{author}", author).replace("{period}", period)
msg = MIMEMultipart()
msg["Subject"] = subject
msg["From"] = email_config.from_
msg["To"] = ", ".join(email_config.to)
if email_config.cc:
msg["Cc"] = ", ".join(email_config.cc)
msg.attach(MIMEText(body, "plain", "utf-8"))
if email_config.attach:
try:
with open(file_path, "rb") as fh:
attachment = MIMEApplication(fh.read())
except OSError:
raise RedmineAPIError(
f"Не удалось прочитать файл отчёта: {file_path}"
) from None
attachment.add_header(
"Content-Disposition",
"attachment",
filename=os.path.basename(file_path),
)
mime_type = _resolve_mime_type(file_path)
attachment.set_type(mime_type)
msg.attach(attachment)
return msg
def send_report(
email_config: EmailConfig,
file_path: str,
author: str,
period: str,
) -> None:
"""Отправляет сгенерированный отчёт по email через SMTP.
Args:
email_config: Настройки SMTP и письма.
file_path: Путь к файлу отчёта для вложения.
author: Имя автора (для подстановки в тему/тело).
period: Строка периода (для подстановки в тему/тело).
Raises:
RedmineAPIError: При любой ошибке соединения или отправки.
"""
smtp_cfg = email_config.smtp
msg = build_message(email_config, file_path, author, period)
all_recipients = list(email_config.to) + list(email_config.cc) + list(email_config.bcc)
try:
with smtplib.SMTP(smtp_cfg.host, smtp_cfg.port, timeout=SMTP_TIMEOUT) as server:
if smtp_cfg.tls:
server.starttls()
if smtp_cfg.user:
server.login(smtp_cfg.user, smtp_cfg.password)
server.send_message(msg, from_addr=email_config.from_, to_addrs=all_recipients)
except smtplib.SMTPAuthenticationError:
raise RedmineAPIError(
"Ошибка аутентификации SMTP. Проверьте логин и пароль."
) from None
except TimeoutError:
raise RedmineAPIError(
"Таймаут соединения с SMTP-сервером."
) from None
except smtplib.SMTPException as exc:
raise RedmineAPIError(
f"Ошибка отправки письма: {exc}"
) from exc
except OSError as exc:
raise RedmineAPIError(
f"Не удалось подключиться к SMTP-серверу {smtp_cfg.host}:{smtp_cfg.port}"
) from exc