Импорт модулей больше не мутирует процесс:
- config.py: module-level load_dotenv(override=False) мутировал os.environ
при любом импорте. Вызов перенесён в Config.load_yaml() — точку явной
загрузки конфигурации, семантика override=False и путь по умолчанию
сохранены. --config PATH (load_dotenv(path, override=True)) не затронут.
- mailer.py: глобальная регистрация email.charset.add_charset('utf-8', ...)
меняла кодировку для всего процесса. Заменена на точечное применение
8bit UTF-8 к телу письма в _utf8_text_part() — payload в UTF-8
(surrogateescape) + Content-Transfer-Encoding: 8bit на конкретной части.
Проверено: сериализация в байты (BytesGenerator, как в send_message)
побайтово совпадает со старым поведением.
Тест test_env_var_takes_priority_over_dotenv адаптирован к новой точке
вызова load_dotenv; test_body_substitution переведён с as_string() на
декодированный payload (str-сериализация не-ASCII части без глобальной
мутации реестра теперь даёт base64; wire-формат 8bit сохранён и
зафиксирован test_wire_format_is_8bit_utf8).
Closes #64
153 lines
5.5 KiB
Python
153 lines
5.5 KiB
Python
"""Отправка сгенерированного отчёта по email через SMTP."""
|
||
|
||
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, List
|
||
|
||
from .client import RedmineAPIError
|
||
from .config import EmailConfig
|
||
from .types import ReportRow
|
||
|
||
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 _utf8_text_part(text: str, subtype: str = "plain") -> MIMEText:
|
||
"""Создаёт текстовую MIME-часть с 8bit UTF-8 телом.
|
||
|
||
Кодировка применяется точечно к части письма, без глобальной мутации
|
||
реестра email.charset: payload хранится как UTF-8 (surrogateescape),
|
||
а Content-Transfer-Encoding выставляется в 8bit, чтобы не-ASCII текст
|
||
(например, русский) передавался литерально, а не в base64.
|
||
"""
|
||
part = MIMEText("", subtype, "utf-8")
|
||
part.set_payload(text.encode("utf-8").decode("ascii", "surrogateescape"))
|
||
part.replace_header("Content-Transfer-Encoding", "8bit")
|
||
return part
|
||
|
||
|
||
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-письмо с подстановками, телами и вложением."""
|
||
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)
|
||
|
||
# Тела письма: plain-text всегда, HTML по флагу
|
||
body_container = MIMEMultipart("alternative")
|
||
body_container.attach(_utf8_text_part(body))
|
||
|
||
if email_config.html:
|
||
html_body = _build_html_body(rows)
|
||
body_container.attach(_utf8_text_part(html_body, "html"))
|
||
|
||
msg.attach(body_container)
|
||
|
||
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,
|
||
rows: List[ReportRow],
|
||
) -> None:
|
||
"""Отправляет сгенерированный отчёт по email через SMTP.
|
||
|
||
Args:
|
||
email_config: Настройки SMTP и письма.
|
||
file_path: Путь к файлу отчёта для вложения.
|
||
author: Имя автора (для подстановки в тему/тело).
|
||
period: Строка периода (для подстановки в тему/тело).
|
||
rows: Строки отчёта (для HTML-версии тела письма).
|
||
|
||
Raises:
|
||
RedmineAPIError: При любой ошибке соединения или отправки.
|
||
"""
|
||
smtp_cfg = email_config.smtp
|
||
msg = build_message(email_config, file_path, author, period, rows)
|
||
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
|