Files
gridbot/consult_mail.py.bak.20260619_072039
T

251 lines
10 KiB
Plaintext
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.
"""
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