Files
redmine-reporter/redmine_reporter/mailer.py
Кокос Артем Николаевич 5d1cf4ab74 feat: add --my-tasks mode for open assigned issues (#71)
List open Redmine issues assigned to the current (or overridden) user,
sorted by priority rank (P1..P5) then version due date with a stable id
tie-break. Renders an OSC 8 hyperlinked console table on stdout and a
counts/hours summary line on stderr; version due dates are fetched once
per unique version, unavailable versions degrade to an empty due date.

Also migrates the codebase to ruff 0.17.3 defaults (PEP 585/604
annotations, targeted noqa for intentional blind excepts and CLI root
logging). Behavior-neutral: 408 tests pass with -W error.
2026-10-28 15:26:41 +07:00

152 lines
5.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Отправка сгенерированного отчёта по 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 .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