feat(abonnements): paliers LLM Essentielle/Premium, admin modeles par forfait, statistiques revenus, emails marketing
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m51s

- Gamme officielle : Essentielle (Pro) = deepseek-v4-flash, glm-5.3-flash,
  minimax-m3 ; Premium (Business) = claude-sonnet-5, deepseek-v4-pro, glm-5.3
- Routage reel : le modele employe suit le palier IA du forfait (reglages
  admin > defaut du plan), garde anti-croisement de palier
- Nouvelle page admin « Modeles & abonnements » (matrice forfaits/paliers,
  modele par defaut, catalogue OpenRouter)
- Statistiques enrichies : revenus (30 j + total), MRR estime, credits,
  liste d'attente, paliers utilises
- Nouvelle page admin « Marketing » : audiences avec compteurs, apercu,
  envoi test obligatoire, historique, desabonnement public + en-tetes
  List-Unsubscribe ; .gitignore pour les donnees d'execution
- Interface (accueil + tarifs) alignee sur la gamme, 13 langues completes
- Tests : routage par palier (fonction + tache), marketing, revenus en base
This commit is contained in:
2026-09-05 12:51:49 +02:00
parent 4e6850d6b1
commit 6c26712687
76 changed files with 5289 additions and 271 deletions

View File

@@ -5,19 +5,31 @@ Story 3.5: API Versioning - Migrated from main.py
"""
import os
import json
import re
import asyncio
import html as html_module
import secrets
import time
import logging
from datetime import datetime, timezone
from typing import Optional, Literal
import hashlib
import threading
import uuid
from datetime import datetime, timedelta, timezone
from typing import Optional, Literal, List
from fastapi import APIRouter, Depends, Header, HTTPException, Form, Request, Query
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, HTMLResponse
from passlib.context import CryptContext
from pydantic import BaseModel, Field
from config import config
from models.subscription import PlanType, PLANS
from models.subscription import (
PlanType,
PLANS,
DEFAULT_AI_MODELS_ESSENTIAL,
DEFAULT_AI_MODELS_PREMIUM,
)
from services import pricing_config as pricing_cfg
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@@ -619,7 +631,122 @@ async def get_admin_stats(admin_id: str = Depends(require_admin)):
if docs > 0:
active_users += 1
cache_stats = _translation_cache.get_stats()
# Correction : TranslationCache expose stats() (get_stats n'a jamais existé —
# l'appel cassé renvoyait une erreur 500 sur GET /stats avant cette correction).
try:
cache_stats = _translation_cache.stats()
except Exception as e:
logger.warning("Cache stats unavailable: %s", e)
cache_stats = {}
# ------------------------------------------------------------------
# Statistiques enrichies (spec « Modèles & abonnements ») : revenus,
# MRR estimé, crédits achetés, liste d'attente, paliers IA utilisés.
# Toutes ces métriques sont best-effort : un échec ne casse pas /stats.
# ------------------------------------------------------------------
revenue = {
"collected_total": 0.0,
"collected_30d": 0.0,
"credits_purchased": 0.0,
"payments_30d": 0,
"currency": "EUR",
}
mrr_estimated = {"total": 0.0, "by_plan": {}}
ai_tier_usage = {"essential": 0, "premium": 0, "classic": 0, "other": 0}
waitlist_count = 0
# MRR estimé : pour chaque plan payé, abonnés × prix mensuel effectif
# (overrides admin inclus via get_effective_monthly_yearly).
for plan_value, count in plan_distribution.items():
try:
monthly, _yearly = pricing_cfg.get_effective_monthly_yearly(plan_value)
except Exception:
continue
if monthly and monthly > 0:
mrr_plan = round(monthly * count, 2)
mrr_estimated["by_plan"][plan_value] = mrr_plan
mrr_estimated["total"] = round(mrr_estimated["total"] + mrr_plan, 2)
try:
from routes.waitlist_routes import _load_waitlist
waitlist_count = len(_load_waitlist())
except Exception as e:
logger.debug("Waitlist count unavailable: %s", e)
if USE_DATABASE and DATABASE_AVAILABLE:
try:
from database.connection import get_sync_session
from database.models import PaymentHistory as DBPayment
from database.models import Translation as DBTranslation
from sqlalchemy import func as _sqlfunc
cutoff_30d = datetime.now(timezone.utc) - timedelta(days=30)
with get_sync_session() as session:
# Devise dominante (le site facture en EUR ; Stripe peut renvoyer
# une autre devise pour d'anciens paiements) — agrégation en SQL.
currency_totals = (
session.query(
DBPayment.currency,
_sqlfunc.sum(DBPayment.amount_cents),
)
.filter(DBPayment.status == "succeeded")
.group_by(DBPayment.currency)
.all()
)
if currency_totals:
main_currency, _main_total = max(
currency_totals, key=lambda row: (row[1] or 0)
)
def _sum_cents(extra_filters=()):
q = session.query(_sqlfunc.sum(DBPayment.amount_cents)).filter(
DBPayment.status == "succeeded",
DBPayment.currency == main_currency,
)
for f in extra_filters:
q = q.filter(f)
return (q.scalar() or 0) / 100.0
def _count_payments(extra_filters=()):
q = session.query(_sqlfunc.count(DBPayment.id)).filter(
DBPayment.status == "succeeded",
DBPayment.currency == main_currency,
)
for f in extra_filters:
q = q.filter(f)
return q.scalar() or 0
total = _sum_cents()
last30 = _sum_cents((DBPayment.created_at >= cutoff_30d,))
credits = _sum_cents((DBPayment.payment_type == "credits",))
n30 = _count_payments((DBPayment.created_at >= cutoff_30d,))
revenue.update(
collected_total=round(total, 2),
collected_30d=round(last30, 2),
credits_purchased=round(credits, 2),
payments_30d=n30,
currency=(main_currency or "eur").upper(),
)
# Répartition des paliers IA utilisés (traductions des 30 derniers jours).
tier_rows = (
session.query(DBTranslation.provider)
.filter(DBTranslation.created_at >= cutoff_30d)
.all()
)
for (prov,) in tier_rows:
p = (prov or "").strip().lower()
if p == "openrouter_premium":
ai_tier_usage["premium"] += 1
elif p in ("openrouter", "deepseek", "minimax", "zai"):
ai_tier_usage["essential"] += 1
elif p in ("google", "google_cloud"):
ai_tier_usage["classic"] += 1
else:
ai_tier_usage["other"] += 1
except Exception as e:
logger.warning("Revenue/tier stats unavailable: %s", e)
return {
"users": {
@@ -631,6 +758,10 @@ async def get_admin_stats(admin_id: str = Depends(require_admin)):
"docs_this_month": total_docs_translated,
"pages_this_month": total_pages_translated,
},
"revenue": revenue,
"mrr_estimated": mrr_estimated,
"ai_tier_usage": ai_tier_usage,
"waitlist_count": waitlist_count,
"cache": cache_stats,
"config": {
"translation_service": config.TRANSLATION_SERVICE,
@@ -911,6 +1042,32 @@ class SmtpSettings(BaseModel):
use_tls: bool = True
class AiTierSettings(BaseModel):
"""Palier IA : modèles actifs (l'ordre = priorité de secours) + modèle par défaut.
``default_model`` vide → premier de ``models`` → défaut du plan (PLANS).
Source des valeurs par défaut : la gamme officielle de ``models/subscription.py``.
"""
models: List[str] = Field(default_factory=list)
default_model: str = ""
class AiTiersSettings(BaseModel):
essential: AiTierSettings = Field(
default_factory=lambda: AiTierSettings(models=list(DEFAULT_AI_MODELS_ESSENTIAL))
)
premium: AiTierSettings = Field(
default_factory=lambda: AiTierSettings(models=list(DEFAULT_AI_MODELS_PREMIUM))
)
def _default_ai_tiers() -> AiTiersSettings:
return AiTiersSettings(
essential=AiTierSettings(models=list(DEFAULT_AI_MODELS_ESSENTIAL)),
premium=AiTierSettings(models=list(DEFAULT_AI_MODELS_PREMIUM)),
)
class SettingsConfig(BaseModel):
google: ProviderSettings = ProviderSettings(enabled=True)
google_cloud: ProviderSettings = ProviderSettings() # Cloud Translation API v2 (clé API)
@@ -923,11 +1080,47 @@ class SettingsConfig(BaseModel):
zai: ProviderSettings = ProviderSettings()
mistral: ProviderSettings = ProviderSettings() # OCR Mistral (PDF scannés)
smtp: SmtpSettings = SmtpSettings()
ai_tiers: AiTiersSettings = Field(default_factory=_default_ai_tiers)
fallback_chain: str = "google,google_cloud,openrouter,openrouter_premium,openai,deepseek,zai"
fallback_chain_classic: str = "google,google_cloud"
fallback_chain_llm: str = "openrouter,openrouter_premium,openai,deepseek,zai"
def normalize_ai_tiers(ai_tiers: AiTiersSettings) -> AiTiersSettings:
"""Déduplique, nettoie et complète un bloc ai_tiers avec la gamme officielle.
Lève ValueError si le défaut d'un palier appartient à la gamme de l'autre
palier (un défaut Essentielle doit rester un modèle Essentielle, et
inversement) : un utilisateur Pro ne doit jamais déclencher un modèle
Premium, et l'inverse serait une erreur de configuration.
"""
essential_official = list(DEFAULT_AI_MODELS_ESSENTIAL)
premium_official = list(DEFAULT_AI_MODELS_PREMIUM)
def _fill(tier: AiTierSettings, official: List[str], other_official: List[str],
tier_name: str, other_name: str) -> AiTierSettings:
models = [m.strip() for m in (tier.models or []) if m and m.strip()]
models = list(dict.fromkeys(models)) # dédoublonnage, ordre conservé
if not models:
models = list(official)
default = (tier.default_model or "").strip()
if default and default not in models:
models.insert(0, default)
if default in other_official:
raise ValueError(
f"ai_tiers.{tier_name}.default_model « {default} » appartient à la "
f"gamme {other_name} : choisissez un modèle de la gamme {tier_name}."
)
return AiTierSettings(models=models, default_model=default)
return AiTiersSettings(
essential=_fill(ai_tiers.essential, essential_official, premium_official,
"essential", "Premium"),
premium=_fill(ai_tiers.premium, premium_official, essential_official,
"premium", "Essentielle"),
)
def load_settings() -> SettingsConfig:
try:
import json
@@ -983,10 +1176,10 @@ async def get_settings(admin_id: str = Depends(require_admin)):
return d
payload = settings.model_dump()
# Essentielle : Gemini 3.5 Flash / DeepSeek Chat — meilleur rapport qualité/prix (juin 2026)
payload["openrouter"] = _merge_env(settings.openrouter, key_env="OPENROUTER_API_KEY", model_env="OPENROUTER_MODEL", default_model="google/gemini-3.5-flash")
# Premium : Claude Sonnet 4.6 — précision maximale sur documents complexes
payload["openrouter_premium"] = _merge_env(settings.openrouter_premium, key_env="OPENROUTER_API_KEY", model_env="OPENROUTER_PREMIUM_MODEL", default_model="anthropic/claude-sonnet-4.6")
# Essentielle : DeepSeek V4 Flash / GLM-5.3 Flash / MiniMax M3 — meilleur rapport qualité/prix (sept. 2026)
payload["openrouter"] = _merge_env(settings.openrouter, key_env="OPENROUTER_API_KEY", model_env="OPENROUTER_MODEL", default_model="deepseek/deepseek-v4-flash")
# Premium : Claude 5 (Sonnet) — précision maximale sur documents complexes
payload["openrouter_premium"] = _merge_env(settings.openrouter_premium, key_env="OPENROUTER_API_KEY", model_env="OPENROUTER_PREMIUM_MODEL", default_model="anthropic/claude-sonnet-5")
payload["openai"] = _merge_env(settings.openai, key_env="OPENAI_API_KEY", model_env="OPENAI_MODEL", default_model="gpt-4o-mini")
payload["deepseek"] = _merge_env(settings.deepseek, key_env="DEEPSEEK_API_KEY", model_env="DEEPSEEK_MODEL", default_model="deepseek-chat")
payload["minimax"] = _merge_env(settings.minimax, key_env="MINIMAX_API_KEY", model_env="MINIMAX_MODEL", default_model="abab6.5s-chat")
@@ -1036,6 +1229,20 @@ async def get_settings(admin_id: str = Depends(require_admin)):
)
def _ai_tiers_is_pristine(ai_tiers: AiTiersSettings) -> bool:
"""True si le bloc ai_tiers reçu est le défaut vierge (client qui ne gère
pas les paliers : liste vide/par défaut et aucun modèle imposé)."""
def _is_default(tier: AiTierSettings, official: List[str]) -> bool:
models = [m.strip() for m in (tier.models or []) if m and m.strip()]
return (not models or models == list(official)) and not (
tier.default_model or ""
).strip()
return _is_default(ai_tiers.essential, list(DEFAULT_AI_MODELS_ESSENTIAL)) and _is_default(
ai_tiers.premium, list(DEFAULT_AI_MODELS_PREMIUM)
)
@router.put("/settings")
async def update_settings(
settings: SettingsConfig, admin_id: str = Depends(require_admin)
@@ -1048,6 +1255,23 @@ async def update_settings(
if settings.smtp.password is not None:
settings.smtp.password = settings.smtp.password.strip() or None
# Paliers IA : un client qui renvoie le bloc par défaut vierge (liste vide ou
# gamme officielle, aucun défaut) n'écrase pas les paliers personnalisés
# existants — sinon chaque sauvegarde d'une autre page réinitialiserait la
# matrice « Modèles & abonnements ».
if _ai_tiers_is_pristine(settings.ai_tiers) and existing.ai_tiers != _default_ai_tiers():
settings.ai_tiers = existing.ai_tiers
# Paliers IA : dédoublonne et complète avec la gamme officielle (jamais vide).
# ValueError → 400 si un défaut appartient à la gamme du palier opposé.
try:
settings.ai_tiers = normalize_ai_tiers(settings.ai_tiers)
except ValueError as e:
return JSONResponse(
status_code=400,
content={"error": "INVALID_AI_TIER", "message": str(e)},
)
save_settings(settings)
logger.info(f"admin_settings_updated by {admin_id}")
return JSONResponse(
@@ -1971,3 +2195,579 @@ async def setup_stripe_webhook(request: Request, admin_id: str = Depends(require
"error": "STRIPE_API_ERROR",
"message": str(e),
})
# ============================================================
# MARKETING (relances par email — admin + désabonnement public)
# Audiences : liste d'attente (data/waitlist.json), inactifs 30 j,
# par plan (plan:<id>) et tous les comptes. Historique et
# désabonnements dans des fichiers JSON (même motif que waitlist).
# ============================================================
MARKETING_HISTORY_FILE = config.BASE_DIR / "data" / "marketing_emails.json"
UNSUBSCRIBES_FILE = config.BASE_DIR / "data" / "unsubscribes.json"
_marketing_lock = threading.Lock()
# Empreintes des campagnes réelles en cours d'envoi (anti-simultanéité).
_active_campaign_hashes: set = set()
# Audiences officielles exposées à l'admin (« par plan » couvre tous les plans).
MARKETING_BASE_AUDIENCES = ["waitlist", "inactive_30", "plan:pro", "all_users"]
def _load_unsubscribes() -> List[dict]:
if not UNSUBSCRIBES_FILE.exists():
return []
try:
with open(UNSUBSCRIBES_FILE, encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, list) else []
except Exception as e:
logger.warning(f"Failed to load unsubscribes: {e}")
return []
def _save_unsubscribes(entries: List[dict]) -> None:
UNSUBSCRIBES_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(UNSUBSCRIBES_FILE, "w", encoding="utf-8") as f:
json.dump(entries, f, indent=2, ensure_ascii=False)
def _unsubscribed_emails() -> set:
return {
(e.get("email") or "").strip().lower()
for e in _load_unsubscribes()
if e.get("email")
}
def _load_marketing_history() -> List[dict]:
if not MARKETING_HISTORY_FILE.exists():
return []
try:
with open(MARKETING_HISTORY_FILE, encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, list) else []
except Exception as e:
logger.warning(f"Failed to load marketing history: {e}")
return []
def _save_marketing_history(entries: List[dict]) -> None:
MARKETING_HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(MARKETING_HISTORY_FILE, "w", encoding="utf-8") as f:
json.dump(entries, f, indent=2, ensure_ascii=False)
def _all_accounts() -> List[dict]:
"""Comptes utilisateurs (email, plan, activité, création) — base de données ou users.json."""
from services.auth_service import USE_DATABASE, DATABASE_AVAILABLE, load_users
accounts: List[dict] = []
seen: set = set()
def _add(email: Optional[str], plan: str = "free", docs: int = 0, created_at=None):
em = (email or "").strip().lower()
if not em or em in seen:
return
seen.add(em)
accounts.append(
{
"email": em,
"plan": plan,
"docs_this_month": docs,
"created_at": created_at,
}
)
if USE_DATABASE and DATABASE_AVAILABLE:
try:
from database.connection import get_sync_session
from database.models import User as DBUser
with get_sync_session() as session:
for u in session.query(DBUser).all():
pv = u.plan.value if hasattr(u.plan, "value") else str(u.plan)
_add(
u.email,
pv,
int(u.docs_translated_this_month or 0),
u.created_at,
)
except Exception as e:
logger.warning("Marketing audiences: DB users unavailable: %s", e)
for ud in load_users().values():
pr = ud.get("plan", "free")
pv = pr.value if hasattr(pr, "value") else str(pr)
try:
docs = int(ud.get("docs_translated_this_month", 0) or 0)
except (TypeError, ValueError):
docs = 0
_add(ud.get("email"), pv, docs, ud.get("created_at"))
return accounts
def _parse_account_created_at(value) -> Optional[datetime]:
"""created_at d'un compte (ISO string ou datetime) en datetime UTC."""
if value is None:
return None
if isinstance(value, datetime):
return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value
try:
parsed = datetime.fromisoformat(str(value))
return parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed
except (TypeError, ValueError):
return None
def _is_inactive_30(account: dict, cutoff_30d: datetime) -> bool:
"""Inactif 30 j : aucune traduction ce mois ET compte créé il y a ≥ 30 jours
(un compte créé aujourd'hui n'est pas « inactif depuis 30 jours »).
Un compte sans date de création connue reste éligible (comportement prudent)."""
if account["docs_this_month"] != 0:
return False
created = _parse_account_created_at(account.get("created_at"))
return created is None or created <= cutoff_30d
def _resolve_audience(audience: str) -> List[dict]:
"""Destinataires d'une audience, désabonnés exclus et dédoublonnés."""
a = (audience or "").strip().lower()
unsub = _unsubscribed_emails()
recipients: List[dict] = []
if a == "waitlist":
from routes.waitlist_routes import _load_waitlist
for entry in _load_waitlist():
recipients.append({"email": (entry.get("email") or "").strip().lower()})
else:
accounts = _all_accounts()
if a == "all_users":
recipients = accounts
elif a == "inactive_30":
# Approximation « inactif » : aucune traduction ce mois-ci ET compte
# créé il y a au moins 30 jours (un nouveau compte n'est pas inactif).
cutoff = datetime.now(timezone.utc) - timedelta(days=30)
recipients = [acc for acc in accounts if _is_inactive_30(acc, cutoff)]
elif a.startswith("plan:"):
plan_id = a.split(":", 1)[1].strip()
recipients = [acc for acc in accounts if acc["plan"] == plan_id]
else:
return []
# Dédoublonnage + exclusion des désabonnés (jamais d'envoi à un désabonné).
final: List[dict] = []
seen: set = set()
for r in recipients:
em = (r.get("email") or "").strip().lower()
if not em or em in seen or em in unsub:
continue
seen.add(em)
final.append(r)
return final
def _marketing_content_hash(subject: str, html: str) -> str:
return hashlib.sha256(f"{subject}\n{html}".encode("utf-8")).hexdigest()
def _unsubscribe_link(email: str) -> str:
base = (
os.getenv("FRONTEND_URL", "http://localhost:3000").rstrip("/")
)
from urllib.parse import quote
return f"{base}/api/v1/marketing/unsubscribe?email={quote(email)}"
def _marketing_extra_headers(email: str) -> dict:
"""En-têtes de délivrabilité (RFC 2369 / RFC 8058) : désabonnement en un clic."""
return {
"List-Unsubscribe": f"<{_unsubscribe_link(email)}>",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
}
def _append_unsubscribe_footer(html: str, email: str) -> str:
link = _unsubscribe_link(email)
footer = (
'<div style="margin-top:32px;padding-top:16px;border-top:1px solid #e2e8f0;'
'font-size:11px;color:#64748b;text-align:center;">'
"Vous recevez cet email car vous avez un compte Wordly.art. "
f'<a href="{link}" style="color:#64748b;">Me désabonner</a></div>'
)
match = re.search(r"</body>", html, re.IGNORECASE)
if match:
idx = match.start()
return html[:idx] + footer + html[idx:]
return html + footer
class MarketingEmailRequest(BaseModel):
audience: str
subject: str = Field(..., min_length=1, max_length=200)
html: str = Field(..., min_length=1)
test_mode: bool = True
test_email: Optional[str] = None
@router.get("/marketing/audiences")
async def marketing_audiences(admin_id: str = Depends(require_admin)):
"""Comptes disponibles par audience (désabonnés exclus des comptes)."""
unsub = _unsubscribed_emails()
accounts = _all_accounts()
try:
from routes.waitlist_routes import _load_waitlist
waitlist_emails = [
(e.get("email") or "").strip().lower() for e in _load_waitlist()
]
except Exception:
waitlist_emails = []
def _count(items: List[dict]) -> int:
return len(
[
r
for r in items
if r.get("email") and r["email"].lower() not in unsub
]
)
cutoff_inactive = datetime.now(timezone.utc) - timedelta(days=30)
audiences = {
"waitlist": len([e for e in waitlist_emails if e and e not in unsub]),
"inactive_30": _count(
[acc for acc in accounts if _is_inactive_30(acc, cutoff_inactive)]
),
"all_users": _count(accounts),
}
for pt in PlanType:
audiences[f"plan:{pt.value}"] = _count(
[a for a in accounts if a["plan"] == pt.value]
)
return JSONResponse(
status_code=200,
content={
"data": {
"audiences": audiences,
"unsubscribed_count": len(unsub),
},
"meta": {},
},
)
class MarketingAudienceRequest(BaseModel):
audience: str
@router.post("/marketing/audiences")
async def marketing_audience_preview(
body: MarketingAudienceRequest, admin_id: str = Depends(require_admin)
):
"""Aperçu d'une audience arbitraire (ex. plan:starter) : nombre de
destinataires après exclusion des désabonnés, jamais leurs adresses."""
a = (body.audience or "").strip().lower()
known = a in MARKETING_BASE_AUDIENCES or a.startswith("plan:")
if not known:
return JSONResponse(
status_code=400,
content={
"error": "UNKNOWN_AUDIENCE",
"message": "Audience inconnue (waitlist, inactive_30, plan:<id>, all_users).",
},
)
recipients = _resolve_audience(a)
return JSONResponse(
status_code=200,
content={"data": {"audience": a, "count": len(recipients)}, "meta": {}},
)
@router.get("/marketing/email/history")
async def marketing_email_history(admin_id: str = Depends(require_admin)):
"""Historique des envois marketing (les plus récents d'abord)."""
history = _load_marketing_history()
history = sorted(history, key=lambda e: e.get("sent_at", ""), reverse=True)[:100]
return JSONResponse(status_code=200, content={"data": history, "meta": {}})
@router.post("/marketing/email/send")
async def marketing_email_send(
body: MarketingEmailRequest, admin_id: str = Depends(require_admin)
):
"""Envoi d'un email de relance marketing.
- ``test_mode=True`` : envoi unique à l'adresse de test (admin ou expéditeur SMTP).
- ``test_mode=False`` : envoi réel à l'audience ; exige un envoi test
préalable au contenu identique (jamais d'envoi en masse « à l'aveugle »).
Les désabonnés sont exclus de toute audience. Les échecs SMTP individuels
n'interrompent pas l'envoi et jamais en erreur HTTP 500.
"""
from services.email_service import is_smtp_configured, send_email_async
if not is_smtp_configured():
return JSONResponse(
status_code=400,
content={
"error": "SMTP_NOT_CONFIGURED",
"message": "Aucun SMTP configuré (réglages Fournisseurs ou .env).",
},
)
subject = body.subject.strip()
html = body.html.strip()
if not subject or not html:
return JSONResponse(
status_code=400,
content={"error": "INVALID_CONTENT", "message": "Sujet et HTML requis."},
)
content_hash = _marketing_content_hash(subject, html)
# --- Envoi test : un seul destinataire (admin ou adresse fournie) ---
if body.test_mode:
settings = load_settings()
recipient = (
(body.test_email or "").strip()
or (settings.smtp.from_email or "").strip()
or os.getenv("SMTP_FROM_EMAIL", "").strip()
)
if not recipient:
return JSONResponse(
status_code=400,
content={
"error": "NO_TEST_RECIPIENT",
"message": "Indiquez une adresse de test ou configurez from_email.",
},
)
sent = await send_email_async(
recipient,
subject,
_append_unsubscribe_footer(html, recipient),
_marketing_extra_headers(recipient),
)
entry = {
"id": uuid.uuid4().hex,
"sent_at": datetime.now(timezone.utc).isoformat(),
"audience": body.audience,
"subject": subject,
"test_mode": True,
"test_recipient": recipient,
"recipients_total": 1,
"sent_count": 1 if sent else 0,
"failed_count": 0 if sent else 1,
"failed": [] if sent else [{"email": recipient, "error": "send_email_async a renvoyé False"}],
"content_hash": content_hash,
}
with _marketing_lock:
history = _load_marketing_history()
history.append(entry)
_save_marketing_history(history)
if not sent:
return JSONResponse(
status_code=502,
content={
"error": "TEST_SEND_FAILED",
"message": "L'envoi test a échoué (voir les logs SMTP).",
"data": entry,
},
)
return JSONResponse(
status_code=200,
content={
"data": {
"test_mode": True,
"sent": 1,
"failed": 0,
"recipient": recipient,
},
"meta": {},
},
)
# --- Envoi réel : un test au contenu identique est obligatoire ---
history = _load_marketing_history()
has_matching_test = any(
e.get("content_hash") == content_hash and e.get("test_mode") and e.get("sent_count", 0) > 0
for e in history
)
if not has_matching_test:
return JSONResponse(
status_code=400,
content={
"error": "TEST_SEND_REQUIRED",
"message": "Effectuez d'abord un envoi test de ce contenu exact (sujet + HTML).",
},
)
recipients = _resolve_audience(body.audience)
if not recipients:
return JSONResponse(
status_code=400,
content={
"error": "AUDIENCE_EMPTY",
"message": "Aucun destinataire dans cette audience (ou tous désabonnés).",
},
)
# Une seule campagne à la fois, même contenu ou non.
with _marketing_lock:
if content_hash in _active_campaign_hashes:
return JSONResponse(
status_code=409,
content={
"error": "CAMPAIGN_ALREADY_RUNNING",
"message": "Un envoi de cette campagne est déjà en cours.",
},
)
_active_campaign_hashes.add(content_hash)
entry = {
"id": uuid.uuid4().hex,
"sent_at": datetime.now(timezone.utc).isoformat(),
"audience": (body.audience or "").strip().lower(),
"subject": subject,
"test_mode": False,
"status": "running",
"recipients_total": len(recipients),
"sent_count": 0,
"failed_count": 0,
"failed": [],
"content_hash": content_hash,
}
# Journalise l'entrée « running » AVANT la boucle d'envoi : si le serveur
# s'arrête en pleine campagne, l'historique trace l'envoi lancé.
with _marketing_lock:
history_entries = _load_marketing_history()
history_entries.append(entry)
_save_marketing_history(history_entries)
asyncio.create_task(
_execute_bulk_send(subject, html, recipients, entry, admin_id)
)
return JSONResponse(
status_code=202,
content={
"data": {
"queued": len(recipients),
"audience": entry["audience"],
"test_mode": False,
},
"meta": {},
},
)
async def _execute_bulk_send(
subject: str,
html: str,
recipients: List[dict],
entry: dict,
admin_id: str = "",
) -> None:
"""Envoi séquentiel (0,2 s d'intervalle) — n'échoue jamais globalement.
Met à jour l'entrée d'historique (statut « running » → « completed ») et
revérifie les désabonnés à CHAQUE itération : un désabonnement pendant la
campagne est honoré immédiatement.
"""
from services.email_service import send_email_async
failures: List[dict] = []
sent = 0
skipped = 0
try:
for r in recipients:
email_addr = r["email"]
# Re-vérification à chaque itération (désabonnement en cours de campagne).
if email_addr.lower() in _unsubscribed_emails():
skipped += 1
continue
try:
ok = await send_email_async(
email_addr,
subject,
_append_unsubscribe_footer(html, email_addr),
_marketing_extra_headers(email_addr),
)
except Exception as send_err: # un échec individuel n'arrête rien
logger.warning("marketing send to %s failed: %s", email_addr, send_err)
ok = False
if ok:
sent += 1
else:
failures.append({"email": email_addr, "error": "échec SMTP"})
await asyncio.sleep(0.2)
entry["sent_count"] = sent
entry["failed_count"] = len(failures)
entry["failed"] = failures[:50]
entry["skipped_unsubscribed"] = skipped
entry["status"] = "completed"
entry["completed_at"] = datetime.now(timezone.utc).isoformat()
except Exception as bulk_err: # la campagne ne doit jamais laisser un statut « running »
logger.exception("marketing_bulk_send crashed: %s", bulk_err)
entry["status"] = "failed"
entry["completed_at"] = datetime.now(timezone.utc).isoformat()
finally:
with _marketing_lock:
_active_campaign_hashes.discard(entry.get("content_hash"))
all_entries = _load_marketing_history()
for i, existing in enumerate(all_entries):
if existing.get("id") == entry.get("id"):
all_entries[i] = entry
break
else:
all_entries.append(entry)
_save_marketing_history(all_entries)
logger.info(
"marketing_bulk_send audience=%s sent=%s failed=%s skipped=%s by=%s",
entry["audience"], sent, len(failures), skipped, admin_id,
)
marketing_public_router = APIRouter(prefix="/api/v1/marketing", tags=["Marketing"])
@marketing_public_router.get("/unsubscribe", response_class=HTMLResponse)
async def marketing_unsubscribe(email: str, request: Request):
"""Désabonnement public : ajoute l'email à data/unsubscribes.json.
Idempotent ; aucune information sensible n'est renvoyée.
"""
from urllib.parse import unquote
em = unquote(email or "").strip().lower()
em_display = html_module.escape(em)
site_url = os.getenv("FRONTEND_URL", "http://localhost:3000").rstrip("/")
if not em or "@" not in em or len(em) > 320:
return HTMLResponse(
"<html><body style='font-family:sans-serif;text-align:center;padding:48px;'>"
"<p>Lien de désabonnement invalide.</p></body></html>",
status_code=400,
)
with _marketing_lock:
entries = _load_unsubscribes()
if not any((e.get("email") or "").lower() == em for e in entries):
entries.append(
{
"email": em,
"unsubscribed_at": datetime.now(timezone.utc).isoformat(),
}
)
_save_unsubscribes(entries)
logger.info("marketing_unsubscribe email=%s", em)
return HTMLResponse(
"<html><body style='font-family:sans-serif;text-align:center;padding:48px;'>"
"<h2>C'est bien noté.</h2>"
f"<p>L'adresse <strong>{em_display}</strong> ne recevra plus d'email marketing.</p>"
f"<p><a href='{site_url}'>Retour sur Wordly.art</a></p></body></html>",
status_code=200,
)