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