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:
Кокос Артем Николаевич
2026-07-10 12:37:02 +07:00
parent b926dd0d49
commit b0e353c565
7 changed files with 693 additions and 63 deletions

View File

@@ -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

View File

@@ -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

198
tests/test_mailer.py Normal file
View File

@@ -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"