Files
email-assistant/scripts/imap_client.py
T

321 lines
15 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.
#!/usr/bin/env python3
"""
imap_client.py — общий IMAP-клиент для потоковой синхронизации почты.
Пока содержит вынесенную из mail_archive.py авторизацию как ОТДЕЛЬНУЮ функцию
imap_connect(): соединение + STARTTLS + LOGIN с re-try (экспоненциальная пауза).
Зачем отдельный модуль:
- mail_archive.py (опрос по cron) и imap_stream.py (IDLE-поток) будут
использовать один и тот же клиент и одну авторизацию.
- Авторизация на Microsoft Exchange (mail.corpoffice.tech) чувствительна к
rate-limit: серия быстрых неудачных/частых подключений приводит к тому,
что сервер начинает молчать (таймаут вместо OK/NO). Поэтому imap_connect()
делает re-try с экспоненциальной паузой и ОДНОЙ точкой входа.
Пример:
with imap_connect() as conn:
conn.sendall(b"a2 LOGIN ...\r\n") # или просто команды поверх
...
Возвращает (sock, creds). Сокет обёрнут в SSL (после STARTTLS).
"""
from __future__ import annotations
import socket
import ssl
import time
import sys
import os
import json
import threading
from pathlib import Path
from datetime import datetime, timezone
# Краткая пауза между попытками: уважать rate-limit Exchange.
RETRY_BASE = 5 # первая пауза, сек
RETRY_MAX = 60 # потолок паузы
RETRY_ATTEMPTS = 5
# ── Логирование и метрики ────────────────────────────────────────────────
# Единый лог IMAP-клиента: /opt/hermes/email/logs/imap_client.log
# Формат: JSON-строки (одна запись = одна строка), легко грепать/считать.
LOG_DIR = Path("/opt/hermes/email/logs")
LOG_FILE = LOG_DIR / "imap_client.log"
_log_lock = threading.Lock()
def _utcnow():
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def imap_log(event, **fields):
"""
Записать событие в лог IMAP-клиента (JSON-строка).
Всегда пишутся: ts, event. Остальные поля — из kwargs.
Пароль НИКОГДА не логируется (нет поля password нигде).
Ошибка записи в лог не валит вызывающий код (try/except внутри).
"""
try:
LOG_DIR.mkdir(parents=True, exist_ok=True)
record = {"ts": _utcnow(), "event": event}
record.update(fields)
line = json.dumps(record, ensure_ascii=False)
with _log_lock:
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(line + "\n")
except Exception:
pass # лог не должен ломать IMAP
def imap_metrics():
"""
Прочитать метрики из лога (доступность, успешность авторизации, сессии).
Возвращает dict:
file — путь к логу
total_auth — всего попыток авторизации (auth_ok + auth_failed)
auth_ok — успешных
auth_failed — неудачных
auth_success_rate — доля успеха (0..1)
conn_ok — соединений установлено
conn_error — ошибок соединения/недоступности сервера
last_session — последнее событие (ts, event, duration_ms, host)
sessions_active — кол-во активных сессий (session_started - session_ended),
по кратности событий в логе (приблизительно)
"""
try:
rows = []
if LOG_FILE.exists():
with open(LOG_FILE, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
rows.append(json.loads(line))
except json.JSONDecodeError:
continue
auth_ok = sum(1 for r in rows if r.get("event") == "auth_ok")
auth_fail = sum(1 for r in rows if r.get("event") == "auth_failed")
conn_ok = sum(1 for r in rows if r.get("event") == "conn_ok")
conn_err = sum(1 for r in rows if r.get("event") == "conn_error")
started = sum(1 for r in rows if r.get("event") == "session_started")
ended = sum(1 for r in rows if r.get("event") == "session_ended")
last = rows[-1] if rows else None
return {
"file": str(LOG_FILE),
"total_auth": auth_ok + auth_fail,
"auth_ok": auth_ok,
"auth_failed": auth_fail,
"auth_success_rate": round(auth_ok / (auth_ok + auth_fail), 3) if (auth_ok + auth_fail) else None,
"conn_ok": conn_ok,
"conn_error": conn_err,
"last_event": last,
"sessions_active": max(0, started - ended),
"rows": len(rows),
}
except Exception as e:
return {"error": str(e)}
def _load_credentials():
"""
Достать IMAP-учётные данные из конфига himalaya (~/.config/himalaya/config.toml).
Возвращает dict(host, port, login, password).
(Вынесено из _himalaya_imap_credentials() в mail_archive.py, чтобы иметь
единый источник кред и не дублировать парсинг конфига.)
HOME: в сессии Hermes HOME может быть /opt/hermes/.hermes/home, а конфиг
himalaya лежит в реальном HOME пользователя (/home/estorozhenko).
Учитываем HERMES_REAL_HOME (как mail_archive._himalaya_cmd()).
"""
import tomllib
from pathlib import Path
home = os.environ.get("HERMES_REAL_HOME") or str(Path.home())
cfg_path = Path(home) / ".config" / "himalaya" / "config.toml"
with open(cfg_path, "rb") as f:
cfg = tomllib.load(f)
accounts = cfg.get("accounts", {})
name = next((n for n, a in accounts.items() if a.get("default")), None)
if name is None and accounts:
name = next(iter(accounts))
if not name:
raise RuntimeError("himalaya config: no account found")
be = accounts[name].get("backend", {})
auth = be.get("auth", {})
password = auth.get("raw") or auth.get("password")
if not password:
cmd = auth.get("cmd", "")
if cmd:
# выполняем команду, выдающую пароль
import subprocess
password = subprocess.run(cmd.split(), capture_output=True,
text=True, timeout=15).stdout.strip()
if not password:
raise RuntimeError(f"himalaya config: no password for account {name}")
return {
"host": be["host"],
"port": be.get("port", 143),
"login": be["login"],
"password": password,
}
def imap_connect(host=None, port=None, login=None, password=None,
retries=RETRY_ATTEMPTS, timeout=None):
"""
Установить IMAP-соединение с STARTTLS и авторизоваться (LOGIN).
Args:
host/port/login/password: переопределение (по умолчанию — из конфига himalaya).
retries: число попыток подключения+логина (для устойчивости к rate-limit).
timeout: таймаут сокета, сек (по умолчанию 30, после логина 120 — Exchange
медленно отвечает на большие FETCH).
Returns:
(sock, creds): sock — SSL-сокет (после STARTTLS), creds — dict с host/login.
Raises:
RuntimeError: если не удалось ни подключиться, ни авторизоваться.
Сокет НЕ закрывается при выходе — вызывающий закрывает (context manager
в вызывающем коде). Авторизация выполняется простым LOGIN (как в работавшем
fetch_attachments_imaplib): на этом Exchange он проходит, а AUTHENTICATE PLAIN
отклоняется (rate-limit/fingerprint — см. README/design).
"""
if host is None:
creds = _load_credentials()
host, port, login, password = creds["host"], creds["port"], creds["login"], creds["password"]
else:
creds = {"host": host, "port": port, "login": login, "password": password}
if timeout is None:
timeout = 30
last_err = None
for attempt in range(1, retries + 1):
sock = None
t_start = time.monotonic()
try:
sock = socket.create_connection((host, port), timeout=timeout)
sock.settimeout(timeout)
greet = sock.recv(1024)
if not greet.startswith(b"* OK"):
raise RuntimeError(f"bad greeting: {greet[:80]!r}")
sock.sendall(b"a1 STARTTLS\r\n")
resp = sock.recv(1024)
if b"OK" not in resp:
raise RuntimeError(f"STARTTLS failed: {resp[:80]!r}")
ctx = ssl.create_default_context()
sock = ctx.wrap_socket(sock, server_hostname=host)
sock.settimeout(120) # после авторизации — на большие FETCH
# LOGIN — простой, как в fetch_attachments_imaplib (работает на Exchange)
login_cmd = f'a2 LOGIN {login} {password}\r\n'.encode()
sock.sendall(login_cmd)
buf = b""
while True:
d = sock.recv(65536)
if not d:
break
buf += d
# дождаться строки "a2 OK/NO/BAD"
lines = buf.split(b"\r\n")
if any(l.startswith(b"a2 ") for l in lines):
break
if b"a2 OK" not in buf:
# последняя строка (ответ) для диагностики, пароль не показываем
tail = buf[-200:].decode("utf-8", "replace")
imap_log("auth_failed", host=host, port=port, login=login,
attempt=attempt, reason="LOGIN rejected",
detail=tail, duration_ms=int((time.monotonic() - t_start) * 1000))
raise RuntimeError(f"LOGIN failed: ...{tail}")
# Успех: метрика доступности (conn_ok) + успешная авторизация
imap_log("conn_ok", host=host, port=port, attempt=attempt,
duration_ms=int((time.monotonic() - t_start) * 1000))
imap_log("auth_ok", host=host, login=login, attempt=attempt,
duration_ms=int((time.monotonic() - t_start) * 1000))
imap_log("session_started", host=host, login=login)
# вернуть сокет; пароль НЕ логируем
return sock, creds
except (socket.timeout, ConnectionError, ssl.SSLError, RuntimeError) as e:
last_err = e
# Классифицируем для метрики доступности: ошибка соединения/таймаут =
# проблема доступности сервера; обычный RuntimeError = проблема
# авторизации/протокола (уже залогирован как auth_failed выше).
if isinstance(e, (socket.timeout, ConnectionError, ssl.SSLError)):
imap_log("conn_error", host=host, port=port, attempt=attempt,
error=type(e).__name__,
duration_ms=int((time.monotonic() - t_start) * 1000),
detail=str(e)[:200])
if sock is not None:
try:
sock.close()
except Exception:
pass
if attempt < retries:
pause = min(RETRY_BASE * (2 ** (attempt - 1)), RETRY_MAX)
time.sleep(pause)
imap_log("auth_failed", host=host, port=port, login=login,
attempt=retries, reason=f"all attempts failed: {type(last_err).__name__}: {last_err}"[:200])
raise RuntimeError(f"IMAP connect/login failed after {retries} attempts: {last_err}")
class imap_session:
"""
Контекстный менеджер IMAP-сессии: авторизация + гарантированная запись
session_ended при закрытии (для метрики "активная сессия").
with imap_session() as (sock, creds):
sock.sendall(...)
Эквивалентно imap_connect(), но на выходе пишет в лог session_ended
(даже при исключении). Пароль не логируется.
"""
def __init__(self, **kwargs):
self._kwargs = kwargs
self._sock = None
self._creds = None
def __enter__(self):
self._sock, self._creds = imap_connect(**self._kwargs)
return self._sock, self._creds
def __exit__(self, exc_type, exc, tb):
if self._sock is not None:
try:
imap_log("session_ended", host=self._creds.get("host") if self._creds else None,
login=self._creds.get("login") if self._creds else None)
finally:
try:
self._sock.close()
except Exception:
pass
self._sock = None
return False # не глотаем исключения
if __name__ == "__main__":
# Быстрый самотест: установить соединение и закрыть.
# python3 imap_client.py — проверить авторизацию
# python3 imap_client.py --metrics — показать метрики из лога
if len(sys.argv) > 1 and sys.argv[1] == "--metrics":
m = imap_metrics()
print(json.dumps(m, ensure_ascii=False, indent=2))
sys.exit(0)
try:
with imap_session() as (s, creds):
print(f"OK: connected+LOGIN as {creds['login']} @ {creds['host']}")
s.sendall(b"a3 LOGOUT\r\n")
except Exception as e:
print(f"FAIL: {e}")
sys.exit(1)