Генерация и отправка кодов по email, настройка SMTP, rate limiting, безопасность.
Email OTP — популярная альтернатива SMS. Вы научитесь выбирать провайдеров, настраивать доставку и защищать от атак.
Рекомендация: Email OTP подходит для некритичных операций и как резервный метод.
| Провайдер | Лимит (free) | Цена | Deliverability |
|---|---|---|---|
| Gmail SMTP | 500/день | Бесплатно | Средняя |
| SendGrid | 100/день | $15/мес (40 000) | Высокая |
| Mailgun | 5 000/мес | $35/мес (50 000) | Высокая |
| Amazon SES | 62 000/мес (free tier) | $0.10/1000 | Высокая |
| Postmark | 100/мес | $15/мес (10 000) | Очень высокая |
| Критерий | Почему важен |
|---|---|
| Deliverability | Процент доставки в inbox, а не spam |
| SPF/DKIM поддержка | Аутентификация домена — критична для доставки |
| Webhooks | Уведомления о доставке, открытии, кликах |
| Analytics | Статистика открытий, проблем с доставкой |
| Warm-up | Постепенное увеличение объёма для новых доменов |
Для production:
1. Amazon SES — лучший price/performance
2. SendGrid/Mailgun — проще интеграция, хорошая поддержка
3. Gmail SMTP — только для разработки/тестов
# app/config.py
from pydantic import BaseSettings
class Settings(BaseSettings):
SMTP_HOST: str = "smtp.sendgrid.net"
SMTP_PORT: int = 587
SMTP_USER: str = "apikey" # Для SendGrid
SMTP_PASSWORD: str # API Key
FROM_EMAIL: str = "noreply@yourapp.com"
FROM_NAME: str = "YourApp"
class Config:
env_file = ".env"
settings = Settings()# Для SendGrid
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASSWORD=SG.xxxxxxxxxxxxxx
# Для Amazon SES
SMTP_HOST=email-smtp.us-east-1.amazonaws.com
SMTP_PORT=587
SMTP_USER=AKIAXXXXXXXXXXXXXXXX
SMTP_PASSWORD=xxxxxxxxxxxxxxxxxxxxxxxxxxDNS записи для домена yourapp.com:
1. SPF (Sender Policy Framework):
yourapp.com. TXT "v=spf1 include:sendgrid.net ~all"
2. DKIM (DomainKeys Identified Mail):
sendgrid._domainkey.yourapp.com. TXT "k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQU..."
3. DMARC (Domain-based Message Authentication):
_dmarc.yourapp.com. TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@yourapp.com"
Важно: Без SPF/DKIM письма будут попадать в spam. Настройка занимает 24-48 часов (DNS propagation).
# app/services/email.py
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from app.config import settings
import secrets
class EmailService:
def __init__(self):
self.smtp_host = settings.SMTP_HOST
self.smtp_port = settings.SMTP_PORT
self.smtp_user = settings.SMTP_USER
self.smtp_password = settings.SMTP_PASSWORD
self.from_email = settings.FROM_EMAIL
self.from_name = settings.FROM_NAME
def generate_code(self, length: int = 6) -> str:
"""Генерирует криптографически стойкий код."""
return ''.join(
secrets.choice('0123456789') for _ in range(length)
)
def create_code_email(self, email: str, code: str) -> MIMEMultipart:
"""Создаёт MIME-сообщение с кодом."""
msg = MIMEMultipart("alternative")
msg["Subject"] = "Ваш код подтверждения"
msg["From"] = f"{self.from_name} <{self.from_email}>"
msg["To"] = email
# Текстовая версия (для старых клиентов)
text = f"Ваш код подтверждения: {code}. Действителен 10 минут."
# HTML версия (красивое оформление)
html = f"""
<html>
<body style="font-family: Arial, sans-serif;">
<h2>Код подтверждения</h2>
<p>Ваш код:</p>
<div style="
background: #f4f4f4;
padding: 20px;
font-size: 32px;
font-weight: bold;
letter-spacing: 5px;
text-align: center;
border-radius: 5px;
">{code}</div>
<p>Код действителен <strong>10 минут</strong>.</p>
<hr>
<p style="color: #888; font-size: 12px;">
Если вы не запрашивали код, проигнорируйте это письмо.
</p>
</body>
</html>
"""
msg.attach(MIMEText(text, "plain"))
msg.attach(MIMEText(html, "html"))
return msg
def send_email(self, to_email: str, subject: str, body_html: str) -> bool:
"""Отправляет email через SMTP."""
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = f"{self.from_name} <{self.from_email}>"
msg["To"] = to_email
msg.attach(MIMEText("Please enable HTML to view this email.", "plain"))
msg.attach(MIMEText(body_html, "html"))
try:
with smtplib.SMTP(self.smtp_host, self.smtp_port) as server:
server.starttls()
server.login(self.smtp_user, self.smtp_password)
server.send_message(msg)
return True
except Exception as e:
print(f"Email delivery failed: {e}")
return False
def send_code(self, to_email: str, code: str) -> bool:
"""Отправляет код подтверждения."""
msg = self.create_code_email(to_email, code)
return self.send_email(to_email, msg["Subject"], msg.as_string())
email_service = EmailService()# app/routes/auth.py
from fastapi import APIRouter, HTTPException, BackgroundTasks
from pydantic import BaseModel, EmailStr
import redis
import json
router = APIRouter()
redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True)
CODE_EXPIRE_SECONDS = 600 # 10 минут
MAX_ATTEMPTS = 5
class SendEmailCodeRequest(BaseModel):
email: EmailStr
class VerifyEmailCodeRequest(BaseModel):
email: str
code: str
@router.post("/email/send")
async def send_email_code(request: SendEmailCodeRequest, background_tasks: BackgroundTasks):
"""Отправляет email с кодом подтверждения."""
email = request.email
# Rate limiting: не более 5 писем в час
rate_key = f"email_rate:{email}"
rate_count = redis_client.get(rate_key)
if rate_count and int(rate_count) >= 5:
raise HTTPException(status_code=429, detail="Слишком много запросов")
# Генерация кода
code = email_service.generate_code()
# Сохранение в Redis
code_key = f"email_code:{email}"
redis_client.setex(
code_key,
CODE_EXPIRE_SECONDS,
json.dumps({"code": code, "attempts": 0})
)
# Отправка (асинхронно)
background_tasks.add_task(email_service.send_code, email, code)
# Rate limiting
redis_client.incr(rate_key)
redis_client.expire(rate_key, 3600)
return {"status": "Email отправлено", "expires_in": CODE_EXPIRE_SECONDS}
@router.post("/email/verify")
async def verify_email_code(request: VerifyEmailCodeRequest):
"""Проверяет код из email."""
email = request.email
code_key = f"email_code:{email}"
stored = redis_client.get(code_key)
if not stored:
raise HTTPException(status_code=400, detail="Код истёк")
data = json.loads(stored)
if data["attempts"] >= MAX_ATTEMPTS:
redis_client.delete(code_key)
raise HTTPException(status_code=400, detail="Превышено количество попыток")
if data["code"] != request.code:
data["attempts"] += 1
redis_client.setex(code_key, CODE_EXPIRE_SECONDS, json.dumps(data))
raise HTTPException(status_code=400, detail="Неверный код")
redis_client.delete(code_key)
return {"status": "Email подтверждён"}До 20% писем могут попадать в spam без правильной настройки.
┌─────────────────────────────────────────────────────────┐
│ Deliverability Checklist │
├─────────────────────────────────────────────────────────┤
│ 1. SPF запись в DNS │
│ 2. DKIM подпись домена │
│ 3. DMARC политика │
│ 4. Reverse DNS (PTR запись) │
│ 5. Domain warm-up (постепенное увеличение объёма) │
│ 6. Мониторинг blacklist (Spamhaus и др.) │
│ 7. Double opt-in для подписчиков │
│ 8. Unsubscribe ссылка (для маркетинга) │
└─────────────────────────────────────────────────────────┘
# Постепенное увеличение объёма отправки
WARMUP_SCHEDULE = {
1: 50, # День 1: 50 писем
2: 100, # День 2: 100 писем
3: 200, # День 3: 200 писем
4: 500, # День 4: 500 писем
5: 1000, # День 5: 1000 писем
6: 2000, # День 6: 2000 писем
7: 5000, # День 7: 5000 писем
8: None, # День 8+: без ограничений
}
def check_warmup_limit(day: int, sent_today: int) -> bool:
"""Проверяет, можно ли отправить ещё письмо."""
limit = WARMUP_SCHEDULE.get(day)
if limit is None:
return True # Без ограничений
return sent_today < limitdef track_email_metrics(event_type: str, email: str):
"""Отслеживает метрики доставки."""
metrics = {
"sent": "email_sent_total",
"delivered": "email_delivered_total",
"bounced": "email_bounced_total",
"spam_reported": "email_spam_total",
"opened": "email_opened_total"
}
metric_name = metrics.get(event_type)
if metric_name:
# Инкремент метрики (Prometheus, StatsD и т.д.)
increment_metric(metric_name)
# Целевые метрики:
# - Delivery rate: > 95%
# - Open rate: > 60% (для OTP)
# - Spam rate: < 0.1%
# - Bounce rate: < 2%6-значный код = 1 000 000 комбинаций. Без rate limiting злоумышленник может перебрать.
# Обязательно ограничивайте попытки
MAX_ATTEMPTS = 5 # После 5 неверных попыток — блокировка
def verify_code(email, code):
data = cache.get(f"email_code:{email}")
if not data:
return False
if data["attempts"] >= MAX_ATTEMPTS:
cache.delete(f"email_code:{email}")
log_security_event("BRUTE_FORCE_SUSPECTED", email=email)
return False
if data["code"] != code:
data["attempts"] += 1
cache.set(f"email_code:{email}", data, timeout=600)
return False
return TrueЕсли проверка кода занимает разное время для верного/неверного кода, это утечка.
# Используйте constant-time сравнение
import hmac
def safe_compare(a: str, b: str) -> bool:
return hmac.compare_digest(a.encode(), b.encode())
# Вместо: if code == stored_code
# Используйте: if safe_compare(code, stored_code)def send_code_with_security_info(email, code, request):
"""Отправляет код с информацией о безопасности."""
ip = request.META.get('REMOTE_ADDR')
user_agent = request.META.get('HTTP_USER_AGENT', 'Unknown')
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
html = f"""
<html>
<body>
<h2>Код подтверждения</h2>
<div style="font-size: 32px; font-weight: bold;">{code}</div>
<hr>
<h3>Информация о безопасности</h3>
<p><strong>Время:</strong> {timestamp}</p>
<p><strong>IP-адрес:</strong> {ip}</p>
<p><strong>Устройство:</strong> {user_agent}</p>
<p style="color: #888;">
Если вы не запрашивали этот код, возможно, кто-то пытается
получить доступ к вашему аккаунту.
</p>
</body>
</html>
"""
email_service.send_email(email, "Код подтверждения", html)secrets)random для генерации┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Frontend │────▶│ FastAPI │────▶│ Redis │
│ │ │ (Email 2FA) │ │ (codes, rate)│
└──────────────┘ └──────────────┘ └──────────────┘
│
▼
┌──────────────┐ ┌──────────────┐
│ SendGrid │────▶│ Email │
│ (SMTP/API) │ │ Providers │
└──────────────┘ └──────────────┘
# .env
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASSWORD=SG.xxxxxxxxxxxxxx
FROM_EMAIL=noreply@yourapp.com
FROM_NAME=YourApp Security
# DNS записи (через регистратора домена)
# SPF: v=spf1 include:sendgrid.net ~all
# DKIM: (получить в SendGrid Dashboard)
# DMARC: v=DMARC1; p=quarantine; rua=mailto:dmarc@yourapp.comСледующая тема: FIDO2/WebAuthn: аппаратные ключи
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.