""" consult_mail.py — SMTP-логика для формы /api/consult. Изолированный модуль: НЕ импортирует ничего из gridbot (main.py, grid.py, config.py). Загружает переменные окружения CONSULT_SMTP_*, CONSULT_TO, CONSULT_FROM_NAME, CONSULT_SUBJECT_PREFIX. Если переменные не заданы — выбрасывает ConfigurationError при первом вызове (не при импорте, чтобы gridbot не падал). Использует smtplib.SMTP_SSL с timeout 10 сек. При любой ошибке — пишет в /root/grid-bot/consult.log и возвращает False, не выбрасывая исключение наружу (endpoint сам решает, что отвечать клиенту). """ import os import smtplib import ssl import logging from email.message import EmailMessage from typing import Optional # === Конфигурация === class ConfigurationError(Exception): """Не заданы обязательные переменные окружения.""" pass def _get_env(name: str, *, required: bool = True) -> Optional[str]: val = os.getenv(name, "").strip() if required and not val: raise ConfigurationError(f"Required env var {name!r} is not set") return val or None def _get_bool(name: str, default: bool) -> bool: raw = os.getenv(name, "").strip().lower() if not raw: return default return raw in ("1", "true", "yes", "on") # === Логгер (в файл, не в gridbot-логи) === _LOG_FILE = os.getenv("CONSULT_LOG_FILE", "/root/grid-bot/consult.log") def _get_logger() -> logging.Logger: log = logging.getLogger("consult_mail") if log.handlers: return log log.setLevel(logging.INFO) try: handler = logging.FileHandler(_LOG_FILE) except (PermissionError, OSError) as e: # Fallback: stderr (для локальной разработки) handler = logging.StreamHandler() handler.setFormatter(logging.Formatter( "%(asctime)s consult_mail [%(levelname)s] %(message)s" )) log.warning("Cannot open log file %s: %s — falling back to stderr", _LOG_FILE, e) handler.setFormatter(logging.Formatter( "%(asctime)s [%(levelname)s] %(message)s" )) log.addHandler(handler) log.propagate = False return log # === Защита от header injection / мусора === _CONTROL_CHARS = set(chr(i) for i in range(32) if chr(i) not in "\t") _CONTROL_CHARS.add("\r") _CONTROL_CHARS.add("\n") _CONTROL_CHARS.add("\x7f") def _sanitize(value: str, max_len: int = 500) -> str: """Strip whitespace, remove control chars, truncate. No header injection possible.""" if not isinstance(value, str): return "" cleaned = "".join(c for c in value if c not in _CONTROL_CHARS) cleaned = cleaned.strip() if len(cleaned) > max_len: cleaned = cleaned[:max_len] return cleaned _EMAIL_RE = None def _is_valid_email(s: str) -> bool: """Lightweight email check — not RFC 5322, but enough to reject obvious garbage.""" global _EMAIL_RE import re if _EMAIL_RE is None: _EMAIL_RE = re.compile(r"^[^@\s\x00-\x1f]+@[^@\s\x00-\x1f]+\.[^@\s\x00-\x1f]{2,}$") return bool(_EMAIL_RE.match(s)) # === Публичный API === def send_consult_email(form_data: dict) -> bool: """ Отправляет письмо-заявку с сайта it.kolp.pro. :param form_data: dict с полями name, email, company, phone, message :return: True если письмо ушло, False при любой ошибке :raises ConfigurationError: если не заданы env-переменные :raises ValueError: если невалидные данные (вызывающий должен перехватить → 400) """ log = _get_logger() # 1) Sanitize входные данные name = _sanitize(form_data.get("name", ""), max_len=100) email = _sanitize(form_data.get("email", ""), max_len=200) company = _sanitize(form_data.get("company", ""), max_len=200) phone = _sanitize(form_data.get("phone", ""), max_len=50) message = _sanitize(form_data.get("message", ""), max_len=2000) # 2) Валидация if not name: raise ValueError("name is required") if not email or not _is_valid_email(email): raise ValueError("valid email is required") # 3) Читаем конфиг host = _get_env("CONSULT_SMTP_HOST") port = int(_get_env("CONSULT_SMTP_PORT", required=False) or "465") use_ssl = _get_bool("CONSULT_SMTP_USE_SSL", default=True) user = _get_env("CONSULT_SMTP_USER") password = _get_env("CONSULT_SMTP_PASSWORD") sender = _get_env("CONSULT_FROM", required=False) or user sender_name = _get_env("CONSULT_FROM_NAME", required=False) or "Сайт it.kolp.pro" recipient = _get_env("CONSULT_TO") subject_prefix = _get_env("CONSULT_SUBJECT_PREFIX", required=False) or "[it.kolp.pro]" # 4) Формируем письмо msg = EmailMessage() msg["From"] = f"{sender_name} <{sender}>" msg["To"] = recipient msg["Reply-To"] = f"{name} <{email}>" subj_type = { "consultation": "Заявка", "checklist": "Чек-лист", }.get(form_data.get("_type", "consultation"), "Заявка") msg["Subject"] = f"{subject_prefix} {subj_type} от {name}" body_lines = [ f"Имя: {name}", f"Email: {email}", ] if company: body_lines.append(f"Компания: {company}") if phone: body_lines.append(f"Телефон / Telegram: {phone}") if message: body_lines.append("") body_lines.append("Что болит:") body_lines.append(message) # === Чек-лист (если передан И заполнен хотя бы частично) === # По требованию: если чек-лист НЕ заполнен, не прикреплять. # Считаем чек-лист "заполненным" если completed=True или answered >= 1. checklist = form_data.get("checklist") checklist_has_data = ( isinstance(checklist, dict) and ( checklist.get("completed") is True or int(checklist.get("answered", 0) or 0) > 0 or any((checklist.get("goals") or {}).values()) ) ) if isinstance(checklist, dict) and checklist_has_data: body_lines.append("") body_lines.append("=" * 50) body_lines.append("РЕЗУЛЬТАТ ЧЕК-ЛИСТА") body_lines.append("=" * 50) body_lines.append(f"Уровень: {checklist.get('level', '—')}") body_lines.append(f"Балл: {checklist.get('total_score', 0)} / {checklist.get('max_score', 30)}") body_lines.append(f"Отвечено: {checklist.get('answered', 0)} из {checklist.get('total_questions', 15)}") body_lines.append(f"Заполнен полностью: {'да' if checklist.get('completed') else 'нет'}") answers = checklist.get("answers", []) questions = checklist.get("questions", []) if isinstance(answers, list) and answers: body_lines.append("") body_lines.append("Ответы по вопросам:") for i, ans in enumerate(answers, 1): q_text = questions[i-1] if i-1 < len(questions) else f"Вопрос {i}" # Сократим текст вопроса до 200 символов чтобы не раздувать q_text_short = (q_text[:200] + "…") if len(q_text) > 200 else q_text body_lines.append(f" {i}. {q_text_short}") if isinstance(ans, dict): v = ans.get("v") if v is None: body_lines.append(" → (не отвечено)") else: body_lines.append(f" → {ans.get('txt', v)} ({v}/2)") else: body_lines.append(f" → {ans}") prep = checklist.get("prep_facts", {}) if isinstance(prep, dict) and prep: body_lines.append("") body_lines.append("Цифры для встречи:") for k, v in prep.items(): if v: body_lines.append(f" • {v}") goals = checklist.get("goals", {}) if isinstance(goals, dict) and any(goals.values()): body_lines.append("") body_lines.append("Чего ждёте от ИТ:") if goals.get("what_to_improve"): body_lines.append(f" Стать лучше: {goals['what_to_improve']}") if goals.get("what_to_discuss"): body_lines.append(f" Обсудить: {goals['what_to_discuss']}") body_lines.append("") body_lines.append("---") body_lines.append(f"IP отправителя: {form_data.get('_ip', 'unknown')}") body_lines.append(f"User-Agent: {form_data.get('_ua', 'unknown')}") body_lines.append(f"Тип формы: {form_data.get('_type', 'consultation')}") msg.set_content("\n".join(body_lines), charset="utf-8") # 5) Отправляем try: ctx = ssl.create_default_context() if use_ssl: with smtplib.SMTP_SSL(host, port, context=ctx, timeout=10) as smtp: smtp.ehlo() smtp.login(user, password) smtp.send_message(msg) else: with smtplib.SMTP(host, port, timeout=10) as smtp: smtp.ehlo() smtp.starttls(context=ctx) smtp.ehlo() smtp.login(user, password) smtp.send_message(msg) log.info("OK: lead from %s <%s> sent to %s", name, email, recipient) return True except smtplib.SMTPAuthenticationError as e: log.error("SMTP AUTH FAILED (check credentials): %s", str(e)[:200]) except smtplib.SMTPException as e: log.error("SMTP ERROR: %s: %s", type(e).__name__, str(e)[:200]) except (OSError, ssl.SSLError) as e: log.error("NETWORK/SSL ERROR: %s: %s", type(e).__name__, str(e)[:200]) except Exception as e: log.exception("UNEXPECTED ERROR: %s: %s", type(e).__name__, str(e)[:200]) return False