From b0e353c565857ce5fafc2aadab4aa4f0ddaf411d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BA=D0=BE=D1=81=20=D0=90=D1=80=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=20=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2=D0=B8?= =?UTF-8?q?=D1=87?= Date: Fri, 10 Jul 2026 12:37:02 +0700 Subject: [PATCH] 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 --- .gitignore | 1 + redmine_reporter/cli.py | 181 +++++++++++++++++++++------------ redmine_reporter/config.py | 10 ++ redmine_reporter/mailer.py | 120 ++++++++++++++++++++++ tests/test_cli.py | 196 ++++++++++++++++++++++++++++++++++++ tests/test_config.py | 50 ++++++++++ tests/test_mailer.py | 198 +++++++++++++++++++++++++++++++++++++ 7 files changed, 693 insertions(+), 63 deletions(-) create mode 100644 redmine_reporter/mailer.py create mode 100644 tests/test_mailer.py diff --git a/.gitignore b/.gitignore index c227679..6d4aa8d 100644 --- a/.gitignore +++ b/.gitignore @@ -85,6 +85,7 @@ secrets.json # Temporary files *.tmp *.bak +docs/superpowers # Just in case .~* diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py index a65cdd3..b551da6 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -13,6 +13,7 @@ from . import __version__ from .client import RedmineAPIError, fetch_issues_with_spent_time from .config import Config from .formatters.factory import get_console_formatter, get_formatter_by_extension +from .mailer import send_report from .report_builder import build_grouped_report, calculate_summary from .yaml_config import ensure_config_dir, resolve_output_path, save_period_to_config @@ -122,6 +123,79 @@ def _compute_dedup_cutoff() -> Optional[datetime]: return None +def _save_and_maybe_send( + rows, + output_arg: str, + author: str, + from_date: str, + to_date: str, + no_time: bool, + do_send: bool, +) -> int: + """Сохраняет отчёт в файл и опционально отправляет по email. + + Returns 0 on success, 1 on error. + """ + output_ext = os.path.splitext(output_arg)[1].lower() + + if not output_ext: + print( + "❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx", + file=sys.stderr, + ) + return 1 + + formatter = get_formatter_by_extension( + output_ext, + author=author, + from_date=from_date, + to_date=to_date, + no_time=no_time, + ) + + if not formatter: + if output_ext == ".odt": + print( + "❌ odfpy is not installed. Install with: pip install odfpy", + file=sys.stderr, + ) + else: + known_exts = ", ".join([".odt", ".csv", ".md", ".html", ".json", ".xlsx"]) + print( + f"❌ Неизвестный формат файла: {output_ext!r}. " + f"Поддерживаются: {known_exts}", + file=sys.stderr, + ) + return 1 + + try: + formatter.save(rows, output_arg) + print(f"✅ Report saved to {output_arg}") + except Exception as e: + fmt = output_ext.lstrip(".").upper() + print(f"❌ {fmt} export error: {e}", file=sys.stderr) + return 1 + + if do_send: + email_config = Config.get_email_config() + if email_config is None: + print( + "❌ Email не настроен. Добавьте секцию 'email' в конфиг " + "(~/.config/redmine-reporter/config.yml).", + file=sys.stderr, + ) + return 1 + + try: + send_report(email_config, output_arg, author, f"{from_date}--{to_date}") + print(f"📧 Report sent to {', '.join(email_config.to)}") + except RedmineAPIError as e: + print(f"❌ {e.message}", file=sys.stderr) + return 1 + + return 0 + + def main(argv: Optional[List[str]] = None) -> int: parser = argparse.ArgumentParser( prog="redmine-reporter", @@ -200,6 +274,11 @@ def main(argv: Optional[List[str]] = None) -> int: action="store_true", help="Save used period as last_used in YAML config and auto-commit to file", ) + parser.add_argument( + "--send", + action="store_true", + help="Send generated report via email after saving (requires email section in config)", + ) args = parser.parse_args(argv) # --init-config: обработка до всего остального @@ -215,6 +294,7 @@ def main(argv: Optional[List[str]] = None) -> int: args.user_name, args.no_time, args.by_activity, + args.send, ] if any(report_flags): print( @@ -312,65 +392,48 @@ def main(argv: Optional[List[str]] = None) -> int: activity = key.split(":", 1)[1] print(f" [{activity}]: {value}h", file=sys.stderr) - if args.output: - output_arg = resolve_output_path( - args.output, - output_dir=Config.get_output_dir(), - filename_template=Config.get_output_filename(), - default_format=Config.get_output_default_format(), - author=Config.get_author(args.author), - from_date=from_date, - to_date=to_date, - ) + if args.output or args.commit: + if args.output: + output_arg = resolve_output_path( + args.output, + output_dir=Config.get_output_dir(), + filename_template=Config.get_output_filename(), + default_format=Config.get_output_default_format(), + author=Config.get_author(args.author), + from_date=from_date, + to_date=to_date, + ) + else: + # --commit без --output: используем default_format + default_format = Config.get_output_default_format() + output_arg = resolve_output_path( + default_format, + output_dir=Config.get_output_dir(), + filename_template=Config.get_output_filename(), + default_format=default_format, + author=Config.get_author(args.author), + from_date=from_date, + to_date=to_date, + ) if output_arg is None: - print( - "❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx", - file=sys.stderr, - ) + print("❌ Не удалось определить путь для сохранения отчёта.", file=sys.stderr) return 1 - output_ext = os.path.splitext(output_arg)[1].lower() - - if not output_ext: - print( - "❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx", - file=sys.stderr, - ) - return 1 - - formatter = get_formatter_by_extension( - output_ext, + ret = _save_and_maybe_send( + rows, + output_arg, author=Config.get_author(args.author), from_date=from_date, to_date=to_date, no_time=args.no_time, + do_send=args.send, ) + if ret != 0: + return ret - if not formatter: - if output_ext == ".odt": - print( - "❌ odfpy is not installed. Install with: pip install odfpy", - file=sys.stderr, - ) - else: - known_exts = ", ".join([".odt", ".csv", ".md", ".html", ".json", ".xlsx"]) - print( - f"❌ Неизвестный формат файла: {output_ext!r}. " - f"Поддерживаются: {known_exts}", - file=sys.stderr, - ) - return 1 - - try: - formatter.save(rows, output_arg) - print(f"✅ Report saved to {output_arg}") - except Exception as e: - fmt = output_ext.lstrip(".").upper() - print(f"❌ {fmt} export error: {e}", file=sys.stderr) - return 1 - - elif args.commit: + elif args.send: + # --send без --output и --commit: сохраняем по шаблону и отправляем default_format = Config.get_output_default_format() output_arg = resolve_output_path( default_format, @@ -386,25 +449,17 @@ def main(argv: Optional[List[str]] = None) -> int: print("❌ Не удалось определить путь для сохранения отчёта.", file=sys.stderr) return 1 - output_ext = os.path.splitext(output_arg)[1].lower() - formatter = get_formatter_by_extension( - output_ext, + ret = _save_and_maybe_send( + rows, + output_arg, author=Config.get_author(args.author), from_date=from_date, to_date=to_date, no_time=args.no_time, + do_send=True, ) - - if not formatter: - print(f"❌ Не удалось создать форматтер для {output_ext}", file=sys.stderr) - return 1 - - try: - formatter.save(rows, output_arg) - print(f"✅ Report saved to {output_arg}") - except Exception as e: - print(f"❌ Export error: {e}", file=sys.stderr) - return 1 + if ret != 0: + return ret else: if args.compact: diff --git a/redmine_reporter/config.py b/redmine_reporter/config.py index a334cbd..7aeb87a 100644 --- a/redmine_reporter/config.py +++ b/redmine_reporter/config.py @@ -343,6 +343,16 @@ class Config: return cls._app.output_default_format or "xlsx" return "xlsx" + @classmethod + def get_email_config(cls) -> "EmailConfig | None": + """Возвращает EmailConfig из YAML-конфига или None, если не настроен.""" + if cls._app is None: + return None + email = cls._app.email + if not email.smtp.host: + return None + return email + @classmethod def get_default_date_range(cls) -> str: from_env = os.getenv("DEFAULT_FROM_DATE", "").strip() diff --git a/redmine_reporter/mailer.py b/redmine_reporter/mailer.py new file mode 100644 index 0000000..0e431dd --- /dev/null +++ b/redmine_reporter/mailer.py @@ -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 + diff --git a/tests/test_cli.py b/tests/test_cli.py index 7935232..dd02230 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -765,3 +765,199 @@ class TestCommitFlag: ) assert code == 0 mock_save.assert_not_called() + + +# --------------------------------------------------------------------------- +# #45: --send tests +# --------------------------------------------------------------------------- + + +class TestSendFlag: + """Tests for --send flag.""" + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + @mock.patch("redmine_reporter.cli.send_report") + @mock.patch("redmine_reporter.cli.get_formatter_by_extension") + def test_send_triggers_mailer(self, mock_get, mock_send, mock_fetch, tmp_path): + """--send с --output вызывает send_report после сохранения.""" + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0)] + mock_formatter = mock.MagicMock() + mock_get.return_value = mock_formatter + + config_path = tmp_path / "config.yml" + config_path.write_text( + "email:\n" + " smtp:\n" + " host: smtp.example.com\n" + " port: 587\n" + " user: bot\n" + " password: secret\n" + " from: bot@example.com\n" + " to:\n" + " - boss@example.com\n" + ) + + output = str(tmp_path / "report.xlsx") + code = main( + [ + "--date", "2026-06-01--2026-06-30", + "--output", output, + "--send", + "--config-path", str(config_path), + ] + ) + assert code == 0 + mock_send.assert_called_once() + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + @mock.patch("redmine_reporter.cli.send_report") + @mock.patch("redmine_reporter.cli.get_formatter_by_extension") + def test_send_without_output_saves_to_default_path(self, mock_get, mock_send, mock_fetch, tmp_path): + """--send без --output сохраняет файл по шаблону, затем отправляет.""" + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0)] + mock_formatter = mock.MagicMock() + mock_get.return_value = mock_formatter + + config_path = tmp_path / "config.yml" + config_path.write_text( + "email:\n" + " smtp:\n" + " host: smtp.example.com\n" + " port: 587\n" + " user: bot\n" + " password: secret\n" + " from: bot@example.com\n" + " to:\n" + " - boss@example.com\n" + "output:\n" + " dir: " + str(tmp_path / "reports") + "\n" + " filename: report_{date}.{ext}\n" + " default_format: xlsx\n" + ) + + code = main( + [ + "--date", "2026-06-01--2026-06-30", + "--send", + "--config-path", str(config_path), + ] + ) + assert code == 0 + mock_formatter.save.assert_called_once() + mock_send.assert_called_once() + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + def test_send_without_email_config_is_error(self, mock_fetch, tmp_path, capsys): + """--send без email-конфига — ошибка и выход 1.""" + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0)] + + config_path = tmp_path / "config.yml" + config_path.write_text("redmine:\n url: https://x.com\n api_key: token\n") + + code = main( + [ + "--date", "2026-06-01--2026-06-30", + "--output", str(tmp_path / "report.xlsx"), + "--send", + "--config-path", str(config_path), + ] + ) + assert code == 1 + captured = capsys.readouterr() + assert "Email не настроен" in captured.err + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + @mock.patch("redmine_reporter.cli.send_report") + @mock.patch("redmine_reporter.cli.get_formatter_by_extension") + def test_send_smtp_error_reported_to_stderr(self, mock_get, mock_send, mock_fetch, tmp_path, capsys): + """Ошибка SMTP выводится в stderr, exit code 1.""" + from redmine_reporter.client import RedmineAPIError + + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0)] + mock_formatter = mock.MagicMock() + mock_get.return_value = mock_formatter + mock_send.side_effect = RedmineAPIError("Не удалось подключиться к SMTP-серверу bad:587") + + config_path = tmp_path / "config.yml" + config_path.write_text( + "email:\n" + " smtp:\n" + " host: bad\n" + " port: 587\n" + " user: bot\n" + " password: secret\n" + " from: bot@example.com\n" + " to:\n" + " - boss@example.com\n" + ) + + code = main( + [ + "--date", "2026-06-01--2026-06-30", + "--output", str(tmp_path / "report.xlsx"), + "--send", + "--config-path", str(config_path), + ] + ) + assert code == 1 + captured = capsys.readouterr() + assert "SMTP" in captured.err + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + @mock.patch("redmine_reporter.cli.send_report") + @mock.patch("redmine_reporter.cli.get_formatter_by_extension") + @mock.patch("redmine_reporter.cli.save_period_to_config") + def test_send_with_commit_works_together( + self, mock_save, mock_get, mock_send, mock_fetch, tmp_path + ): + """--send и --commit работают вместе без конфликтов.""" + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0)] + mock_formatter = mock.MagicMock() + mock_get.return_value = mock_formatter + + config_path = tmp_path / "config.yml" + config_path.write_text( + "email:\n" + " smtp:\n" + " host: smtp.example.com\n" + " port: 587\n" + " user: bot\n" + " password: secret\n" + " from: bot@example.com\n" + " to:\n" + " - boss@example.com\n" + "period:\n" + " precision: date\n" + " dynamic: true\n" + ) + + code = main( + [ + "--date", "2026-06-01--2026-06-30", + "--output", str(tmp_path / "report.xlsx"), + "--send", + "--commit", + "--config-path", str(config_path), + ] + ) + assert code == 0 + mock_send.assert_called_once() + mock_save.assert_called_once() + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + def test_send_no_entries_exits_early(self, mock_fetch): + """--send без time entries просто выходит с 0.""" + mock_fetch.return_value = None + code = main(["--date", "2026-06-01--2026-06-30", "--send"]) + assert code == 0 diff --git a/tests/test_config.py b/tests/test_config.py index 06dbb51..512ee87 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -557,3 +557,53 @@ class TestDefaultDateRangeWithLastUsed: Config._app = AppConfig.from_yaml(yaml_path) result = Config.get_default_date_range() assert result == "2026-04-01--2026-04-15" + + +class TestGetEmailConfig: + """Tests for Config.get_email_config().""" + + @mock.patch.dict(os.environ, {}, clear=True) + def test_get_email_config_from_yaml(self): + """get_email_config() возвращает EmailConfig из YAML.""" + with tempfile.TemporaryDirectory() as tmp: + yaml_path = Path(tmp) / "config.yml" + yaml_path.write_text( + "email:\n" + " smtp:\n" + " host: smtp.example.com\n" + " port: 587\n" + " user: bot@example.com\n" + " password: secret\n" + " tls: true\n" + " from: bot@example.com\n" + " to:\n" + " - boss@example.com\n" + ) + + Config._app = AppConfig.from_yaml(yaml_path) + cfg = Config.get_email_config() + + assert cfg is not None + assert cfg.smtp.host == "smtp.example.com" + assert cfg.smtp.port == 587 + assert cfg.smtp.user == "bot@example.com" + assert cfg.smtp.password == "secret" + assert cfg.smtp.tls is True + assert cfg.from_ == "bot@example.com" + assert cfg.to == ["boss@example.com"] + + @mock.patch.dict(os.environ, {}, clear=True) + def test_get_email_config_returns_none_when_no_yaml(self): + """Без YAML-конфига get_email_config() возвращает None.""" + Config._app = None + assert Config.get_email_config() is None + + @mock.patch.dict(os.environ, {}, clear=True) + def test_get_email_config_returns_none_when_no_host(self): + """С YAML но без smtp.host — возвращает None.""" + with tempfile.TemporaryDirectory() as tmp: + yaml_path = Path(tmp) / "config.yml" + yaml_path.write_text("email:\n smtp:\n host: ''\n") + + Config._app = AppConfig.from_yaml(yaml_path) + assert Config.get_email_config() is None diff --git a/tests/test_mailer.py b/tests/test_mailer.py new file mode 100644 index 0000000..78901db --- /dev/null +++ b/tests/test_mailer.py @@ -0,0 +1,198 @@ +import smtplib +from unittest import mock + +import pytest + +from redmine_reporter.client import RedmineAPIError +from redmine_reporter.config import EmailConfig, SmtpConfig +from redmine_reporter.mailer import build_message, send_report + + +def _make_email_config(**overrides) -> EmailConfig: + """Создаёт EmailConfig с минимальными валидными настройками.""" + smtp = SmtpConfig( + host=overrides.pop("smtp_host", "smtp.example.com"), + port=overrides.pop("smtp_port", 587), + user=overrides.pop("smtp_user", "bot@example.com"), + password=overrides.pop("smtp_password", "secret"), + tls=overrides.pop("smtp_tls", True), + ) + return EmailConfig( + smtp=smtp, + from_=overrides.pop("from_", "bot@example.com"), + to=overrides.pop("to", ["boss@example.com"]), + cc=overrides.pop("cc", []), + bcc=overrides.pop("bcc", []), + subject=overrides.pop("subject", "Отчёт {author} за {period}"), + body_text=overrides.pop("body_text", "Во вложении отчёт."), + attach=overrides.pop("attach", True), + **overrides, + ) + + +class TestBuildMessage: + """Тесты формирования MIME-письма.""" + + def test_subject_substitution(self): + """{author} и {period} подставляются в тему.""" + cfg = _make_email_config(subject="Отчёт {author} за {period}", attach=False) + msg = build_message(cfg, "/tmp/report.xlsx", "Кокос А.Н.", "2026-06-01--2026-06-30") + assert msg["Subject"] == "Отчёт Кокос А.Н. за 2026-06-01--2026-06-30" + + def test_body_substitution(self): + """{author} и {period} подставляются в тело.""" + cfg = _make_email_config(body_text="Автор: {author}, период: {period}", attach=False) + msg = build_message(cfg, "/tmp/report.xlsx", "Иванов", "Q1") + plain_part = msg.get_payload()[0] + assert "Автор: Иванов, период: Q1" in plain_part.as_string() + + def test_from_header(self): + cfg = _make_email_config(from_="sender@example.com", attach=False) + msg = build_message(cfg, "/tmp/r.xlsx", "A", "P") + assert msg["From"] == "sender@example.com" + + def test_to_header(self): + cfg = _make_email_config(to=["a@x.com", "b@x.com"], attach=False) + msg = build_message(cfg, "/tmp/r.xlsx", "A", "P") + assert msg["To"] == "a@x.com, b@x.com" + + def test_cc_header(self): + cfg = _make_email_config(cc=["cc@x.com"], attach=False) + msg = build_message(cfg, "/tmp/r.xlsx", "A", "P") + assert msg["Cc"] == "cc@x.com" + + def test_bcc_not_in_headers(self): + """BCC не должен появляться в заголовках письма.""" + cfg = _make_email_config(bcc=["hidden@x.com"], attach=False) + msg = build_message(cfg, "/tmp/r.xlsx", "A", "P") + assert "Bcc" not in msg + + def test_attachment_present_when_attach_true(self, tmp_path): + report = tmp_path / "report.xlsx" + report.write_text("fake xlsx content") + cfg = _make_email_config(attach=True) + msg = build_message(cfg, str(report), "A", "P") + assert len(msg.get_payload()) == 2 + + def test_no_attachment_when_attach_false(self, tmp_path): + report = tmp_path / "report.xlsx" + report.write_text("fake xlsx content") + cfg = _make_email_config(attach=False) + msg = build_message(cfg, str(report), "A", "P") + assert len(msg.get_payload()) == 1 + + @pytest.mark.parametrize("ext,expected_mime", [ + (".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 test_mime_type_by_extension(self, ext, expected_mime, tmp_path): + report = tmp_path / f"report{ext}" + report.write_text("content") + cfg = _make_email_config() + msg = build_message(cfg, str(report), "A", "P") + attachment_part = msg.get_payload()[1] + assert attachment_part.get_content_type() == expected_mime + + def test_missing_attachment_file_raises_error(self, tmp_path): + """Если файл вложения не существует — RedmineAPIError.""" + cfg = _make_email_config() + missing = str(tmp_path / "nonexistent.xlsx") + with pytest.raises(RedmineAPIError, match="Не удалось прочитать файл"): + build_message(cfg, missing, "A", "P") + + +class TestSendReport: + """Тесты отправки письма.""" + + def test_send_success(self, tmp_path): + report = tmp_path / "report.xlsx" + report.write_text("fake content") + cfg = _make_email_config() + + with mock.patch("smtplib.SMTP") as mock_smtp_class: + mock_smtp = mock.MagicMock() + mock_smtp_class.return_value.__enter__.return_value = mock_smtp + + send_report(cfg, str(report), "Кокос А.Н.", "2026-06-01--2026-06-30") + + mock_smtp_class.assert_called_once_with("smtp.example.com", 587, timeout=30) + mock_smtp.starttls.assert_called_once() + mock_smtp.login.assert_called_once_with("bot@example.com", "secret") + mock_smtp.send_message.assert_called_once() + + def test_send_no_tls(self, tmp_path): + report = tmp_path / "report.xlsx" + report.write_text("fake content") + cfg = _make_email_config(smtp_tls=False) + + with mock.patch("smtplib.SMTP") as mock_smtp_class: + mock_smtp = mock.MagicMock() + mock_smtp_class.return_value.__enter__.return_value = mock_smtp + + send_report(cfg, str(report), "A", "P") + + mock_smtp.starttls.assert_not_called() + + def test_send_connection_error(self, tmp_path): + report = tmp_path / "report.xlsx" + report.write_text("fake content") + cfg = _make_email_config() + + with mock.patch("smtplib.SMTP", side_effect=OSError("Connection refused")): + with pytest.raises(RedmineAPIError, match="Не удалось подключиться к SMTP"): + send_report(cfg, str(report), "A", "P") + + def test_send_auth_error(self, tmp_path): + report = tmp_path / "report.xlsx" + report.write_text("fake content") + cfg = _make_email_config() + + with mock.patch("smtplib.SMTP") as mock_smtp_class: + mock_smtp = mock.MagicMock() + mock_smtp_class.return_value.__enter__.return_value = mock_smtp + mock_smtp.login.side_effect = smtplib.SMTPAuthenticationError(535, b"Bad auth") + + with pytest.raises(RedmineAPIError, match="Ошибка аутентификации SMTP"): + send_report(cfg, str(report), "A", "P") + + def test_send_timeout_error(self, tmp_path): + report = tmp_path / "report.xlsx" + report.write_text("fake content") + cfg = _make_email_config() + + with mock.patch("smtplib.SMTP", side_effect=TimeoutError()): + with pytest.raises(RedmineAPIError, match="Таймаут соединения с SMTP"): + send_report(cfg, str(report), "A", "P") + + def test_send_generic_smtp_error(self, tmp_path): + report = tmp_path / "report.xlsx" + report.write_text("fake content") + cfg = _make_email_config() + + with mock.patch("smtplib.SMTP") as mock_smtp_class: + mock_smtp = mock.MagicMock() + mock_smtp_class.return_value.__enter__.return_value = mock_smtp + mock_smtp.send_message.side_effect = smtplib.SMTPException("Something went wrong") + + with pytest.raises(RedmineAPIError, match="Ошибка отправки письма"): + send_report(cfg, str(report), "A", "P") + + def test_send_includes_cc_and_bcc(self, tmp_path): + report = tmp_path / "report.xlsx" + report.write_text("fake content") + cfg = _make_email_config(to=["a@x.com"], cc=["cc@x.com"], bcc=["bcc@x.com"]) + + with mock.patch("smtplib.SMTP") as mock_smtp_class: + mock_smtp = mock.MagicMock() + mock_smtp_class.return_value.__enter__.return_value = mock_smtp + + send_report(cfg, str(report), "A", "P") + + call_args = mock_smtp.send_message.call_args + msg = call_args.args[0] + assert msg["To"] == "a@x.com" + assert msg["Cc"] == "cc@x.com"