Initial import: grid-bot — grid trading bot for BTC-USDT on Cifra Markets
This commit is contained in:
+250
@@ -0,0 +1,250 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user