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
121 lines
4.2 KiB
Python
121 lines
4.2 KiB
Python
"""Отправка сгенерированного отчёта по 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
|
||
|