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:
120
redmine_reporter/mailer.py
Normal file
120
redmine_reporter/mailer.py
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user