Aller au contenu principal

Notifications et communication utilisateur avec FastAPI

remarque

Ce chapitre prolonge surtout Authentification avec FastAPI. Il montre comment faire sortir ton application FastAPI de son rôle de simple API CRUD pour commencer à parler au monde extérieur : emails, vérification d’adresse, reset de mot de passe et SMS.

Introduction

Après l'authentification, le cycle utilisateur ne s'arrête pas au login. Une vraie application doit aussi pouvoir confirmer une adresse email, déclencher un reset de mot de passe et, parfois, envoyer un SMS. Le rôle de ce chapitre est d'ajouter cette couche de communication transactionnelle sans quitter l'architecture du projet FastAPI construit jusque-là.

Ce que ce chapitre va accomplir

À la fin de ce chapitre, tu auras :

  • une configuration dédiée pour :
    • l’application,
    • le mail,
    • le SMS optionnel,
  • un fichier services/notification.py,
  • un fichier core/url_tokens.py,
  • des templates d’emails HTML,
  • une évolution du modèle User avec email_verified,
  • des schémas pour :
    • demande de reset,
    • confirmation de reset,
    • envoi SMS de test,
  • de nouvelles routes utilisateur pour :
    • vérifier l’email,
    • demander un reset,
    • confirmer un reset,
    • envoyer un SMS de test,
  • une compréhension claire de :
    • BackgroundTasks,
    • tokens d’URL signés,
    • email transactionnel,
    • sécurité minimale sur reset/password flows.

Point de départ recommandé

Le prérequis vraiment important ici, c’est Authentification avec FastAPI :

  • il faut déjà avoir un modèle User,
  • un mot de passe hashé,
  • un login,
  • une manière d’identifier l’utilisateur courant.

Les chapitres sur les réponses personnalisées, les tests ou Docker restent utiles, mais ils ne sont pas indispensables pour comprendre ce flux. Ce chapitre se lit donc surtout comme une extension du cycle utilisateur, pas comme la récompense de tout le bloc avancé.

Sources et choix pédagogiques

Ce chapitre s’appuie principalement sur :

  • 18-Send Mail/103-app.zip
  • 20-Email Confirmation/110-app.zip
  • 21-Password Reset/113-app.zip
  • 22-SMS/117-app.zip

On en retient quatre idées centrales :

  • BackgroundTasks pour une première version simple,
  • fastapi-mail pour SMTP,
  • itsdangerous pour des tokens d’URL signés,
  • twilio comme option SMS.

Les sources viennent d’un autre domaine applicatif. Ici, on les réintègre dans notre structure actuelle (config.py, core/, services/, api/routers/user.py) pour garder un fil pédagogique unique.

Architecture visée après ce chapitre

Après ce chapitre, le projet peut ressembler à ceci :

fastapi-base-api/
├── main.py
├── config.py
├── .env
├── .env.example
├── requirements.txt
├── core/
│ ├── __init__.py
│ ├── security.py
│ ├── exceptions.py
│ ├── middleware.py
│ ├── responses.py
│ └── url_tokens.py
├── api/
│ ├── __init__.py
│ ├── router.py
│ ├── dependencies.py
│ ├── routers/
│ │ ├── __init__.py
│ │ ├── task.py
│ │ └── user.py
│ └── schemas/
│ ├── __init__.py
│ ├── task.py
│ └── user.py
├── database/
│ ├── __init__.py
│ ├── models.py
│ ├── session.py
│ └── redis.py
├── services/
│ ├── __init__.py
│ ├── task.py
│ ├── user.py
│ └── notification.py
├── templates/
│ ├── task_detail.html
│ ├── mail_email_verify.html
│ └── mail_password_reset.html
└── exports/
└── task-api-example.txt

Repères utiles avant le code

Avant de coder, retiens ces idées :

1. Un email de vérification n’est pas un JWT d’API

Le token d’accès sert à authentifier un utilisateur sur l’API. Le lien de vérification ou de reset sert à transporter une action ponctuelle dans une URL.

Donc on préfère ici un token d’URL signé plutôt que de détourner le JWT principal.

2. Il faut des salt différents selon les usages

Un token de vérification email ne doit pas être interchangeable avec un token de reset password.

3. Le reset password ne doit pas révéler si l’email existe

Sinon tu aides un attaquant à lister les comptes existants.

4. Le SMS est souvent optionnel

Le mail est plus central. Le SMS est très utile, mais il dépend d’un fournisseur, d’un coût, et d’une stratégie produit.

5. BackgroundTasks est une bonne première étape

On ne passe pas tout de suite à Celery ici. On commence avec une version plus simple et intégrée à FastAPI.

Étape 1 — ajouter les dépendances utiles

Ajoute ou complète requirements.txt avec :

fastapi
uvicorn[standard]
sqlmodel
sqlalchemy
asyncpg
pydantic-settings
scalar-fastapi
passlib[bcrypt]
PyJWT
python-multipart
email-validator
pytest
pytest-asyncio
httpx
aiosqlite
jinja2
fastapi-mail
itsdangerous
twilio

Note importante sur twilio

Si tu ne veux pas activer le SMS tout de suite, tu peux considérer twilio comme une dépendance optionnelle. Mais pour garder le chapitre cohérent, on la montre ici dans la stack complète.

Installe ensuite :

pip install -r requirements.txt

ou rebuild la stack Docker :

docker compose up --build

Étape 2 — faire évoluer config.py

On ajoute maintenant une configuration applicative et une configuration notification.

from pydantic_settings import BaseSettings, SettingsConfigDict


_base_config = SettingsConfigDict(
env_file="./.env",
env_ignore_empty=True,
extra="ignore",
)


class AppSettings(BaseSettings):
APP_NAME: str = "Task API"
APP_DOMAIN: str = "localhost:8000"

model_config = _base_config


class DatabaseSettings(BaseSettings):
POSTGRES_SERVER: str
POSTGRES_PORT: int
POSTGRES_USER: str
POSTGRES_PASSWORD: str
POSTGRES_DB: str

model_config = _base_config

@property
def POSTGRES_URL(self) -> str:
return (
f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
f"@{self.POSTGRES_SERVER}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
)


class SecuritySettings(BaseSettings):
JWT_SECRET: str
JWT_ALGORITHM: str

model_config = _base_config


class NotificationSettings(BaseSettings):
MAIL_USERNAME: str
MAIL_PASSWORD: str
MAIL_FROM: str
MAIL_PORT: int
MAIL_SERVER: str
MAIL_FROM_NAME: str = "Task API"
MAIL_STARTTLS: bool = True
MAIL_SSL_TLS: bool = False
USE_CREDENTIALS: bool = True
VALIDATE_CERTS: bool = True

TWILIO_SID: str | None = None
TWILIO_AUTH_TOKEN: str | None = None
TWILIO_NUMBER: str | None = None

model_config = _base_config


app_settings = AppSettings()
db_settings = DatabaseSettings()
security_settings = SecuritySettings()
notification_settings = NotificationSettings()

Pourquoi APP_DOMAIN est important

Parce qu’un email transactionnel doit souvent contenir une URL. Par exemple :

  • lien de vérification,
  • lien de reset password.

Il faut donc une base d’URL explicite.

Étape 3 — compléter .env.example

Ajoute maintenant une base de variables utiles :

APP_NAME=Task API
APP_DOMAIN=localhost:8000

POSTGRES_SERVER=localhost
POSTGRES_PORT=5432
POSTGRES_DB=taskapi_db
POSTGRES_USER=postgres
POSTGRES_PASSWORD=your_real_password

JWT_SECRET=your_long_random_secret
JWT_ALGORITHM=HS256

MAIL_USERNAME=your_mail_user
MAIL_PASSWORD=your_mail_password
MAIL_FROM=no-reply@example.com
MAIL_PORT=587
MAIL_SERVER=smtp.example.com
MAIL_FROM_NAME=Task API
MAIL_STARTTLS=true
MAIL_SSL_TLS=false
USE_CREDENTIALS=true
VALIDATE_CERTS=true

TWILIO_SID=
TWILIO_AUTH_TOKEN=
TWILIO_NUMBER=
attention

Les secrets mail et Twilio ne doivent pas être versionnés. Le .env.example sert seulement de modèle.

Étape 4 — créer core/url_tokens.py

Les sources utilisent itsdangerous pour générer des tokens d’URL signés. On va faire pareil, mais dans un fichier dédié plus clair.

Crée core/url_tokens.py :

from datetime import timedelta

from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer

from config import security_settings


_serializer = URLSafeTimedSerializer(security_settings.JWT_SECRET)


def generate_url_token(data: dict, salt: str) -> str:
return _serializer.dumps(data, salt=salt)


def decode_url_token(
token: str,
salt: str,
expiry: timedelta | None = None,
) -> dict | None:
try:
return _serializer.loads(
token,
salt=salt,
max_age=expiry.total_seconds() if expiry else None,
)
except (BadSignature, SignatureExpired):
return None

Pourquoi ce fichier mérite sa place dans core/

Parce qu’il s’agit d’une brique transversale :

  • utilisée par les routes user,
  • indépendante de la base de données,
  • indépendante de la logique métier de tâche.

Étape 5 — créer services/notification.py

Crée services/notification.py :

from fastapi import BackgroundTasks, HTTPException, status
from fastapi_mail import ConnectionConfig, FastMail, MessageSchema, MessageType
from pydantic import EmailStr
from twilio.rest import Client

from config import notification_settings
from pathlib import Path


BASE_DIR = Path(__file__).resolve().parent.parent
TEMPLATE_DIR = BASE_DIR / "templates"


class NotificationService:
def __init__(self, tasks: BackgroundTasks):
self.tasks = tasks
self.fastmail = FastMail(
ConnectionConfig(
MAIL_USERNAME=notification_settings.MAIL_USERNAME,
MAIL_PASSWORD=notification_settings.MAIL_PASSWORD,
MAIL_FROM=notification_settings.MAIL_FROM,
MAIL_PORT=notification_settings.MAIL_PORT,
MAIL_SERVER=notification_settings.MAIL_SERVER,
MAIL_FROM_NAME=notification_settings.MAIL_FROM_NAME,
MAIL_STARTTLS=notification_settings.MAIL_STARTTLS,
MAIL_SSL_TLS=notification_settings.MAIL_SSL_TLS,
USE_CREDENTIALS=notification_settings.USE_CREDENTIALS,
VALIDATE_CERTS=notification_settings.VALIDATE_CERTS,
TEMPLATE_FOLDER=TEMPLATE_DIR,
)
)

self.twilio_client = None
if (
notification_settings.TWILIO_SID
and notification_settings.TWILIO_AUTH_TOKEN
and notification_settings.TWILIO_NUMBER
):
self.twilio_client = Client(
notification_settings.TWILIO_SID,
notification_settings.TWILIO_AUTH_TOKEN,
)

async def send_email(
self,
recipients: list[EmailStr],
subject: str,
body: str,
):
self.tasks.add_task(
self.fastmail.send_message,
MessageSchema(
recipients=recipients,
subject=subject,
body=body,
subtype=MessageType.plain,
),
)

async def send_email_template(
self,
recipients: list[EmailStr],
subject: str,
context: dict,
template_name: str,
):
self.tasks.add_task(
self.fastmail.send_message,
MessageSchema(
recipients=recipients,
subject=subject,
template_body=context,
subtype=MessageType.html,
),
template_name=template_name,
)

async def send_sms(self, to: str, body: str):
if self.twilio_client is None or notification_settings.TWILIO_NUMBER is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="SMS provider is not configured",
)

self.twilio_client.messages.create(
from_=notification_settings.TWILIO_NUMBER,
to=to,
body=body,
)

Ce qu’il faut remarquer

  • les emails sont envoyés via BackgroundTasks,
  • les templates HTML passent par fastapi-mail,
  • le SMS reste optionnel tant que Twilio n’est pas configuré,
  • la logique d’envoi est centralisée dans un service dédié.

Étape 6 — créer les templates email

templates/mail_email_verify.html

<body>
<p>Bonjour {{ username }},</p>

<p>
Merci pour ton inscription. Pour activer ton compte,
clique sur le lien ci-dessous.
</p>

<p>
<a href="{{ verification_url }}"
style="display:inline-block;padding:10px 16px;background:#7c3aed;color:white;border-radius:10px;text-decoration:none;">
Vérifier mon email
</a>
</p>

<p>Si tu n’es pas à l’origine de cette demande, ignore simplement ce message.</p>
<p>— {{ app_name }}</p>
</body>

templates/mail_password_reset.html

<body>
<p>Bonjour {{ username }},</p>

<p>
Nous avons reçu une demande de réinitialisation de mot de passe.
Si ce n’est pas toi, ignore cet email.
</p>

<p>
<a href="{{ reset_url }}"
style="display:inline-block;padding:10px 16px;background:#111827;color:white;border-radius:10px;text-decoration:none;">
Réinitialiser mon mot de passe
</a>
</p>

<p>Ce lien expirera dans 24 heures.</p>
<p>— {{ app_name }}</p>
</body>

Pourquoi des templates HTML sont utiles ici

Parce qu’un email transactionnel :

  • doit être lisible,
  • doit souvent contenir un bouton ou un lien,
  • doit pouvoir injecter un contexte dynamique.

Étape 7 — faire évoluer database/models.py

On ajoute maintenant un état de vérification email à l’utilisateur.

from pydantic import EmailStr
from sqlmodel import Field, Relationship, SQLModel


class User(SQLModel, table=True):
__tablename__ = "user"

id: int | None = Field(default=None, primary_key=True)
name: str
email: EmailStr = Field(index=True)
password_hash: str
email_verified: bool = Field(default=False)

tasks: list["Task"] = Relationship(back_populates="owner")

Pourquoi ce champ change beaucoup de choses

Avec email_verified, tu peux :

  • empêcher un login trop tôt,
  • savoir si un compte est activé,
  • conditionner certaines actions futures.

Étape 8 — faire évoluer api/schemas/user.py

On garde les schémas existants, puis on ajoute ceux liés aux notifications.

from pydantic import BaseModel, EmailStr


class BaseUser(BaseModel):
name: str
email: EmailStr


class UserRead(BaseUser):
id: int
email_verified: bool


class UserCreate(BaseUser):
password: str


class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"


class PasswordResetRequest(BaseModel):
email: EmailStr


class PasswordResetConfirm(BaseModel):
token: str
new_password: str


class SMSRequest(BaseModel):
phone_number: str
message: str

Pourquoi UserRead doit exposer email_verified

Parce que côté client, c’est une information utile :

  • afficher un état de compte,
  • guider l’utilisateur,
  • expliquer pourquoi la connexion peut être refusée.

Étape 9 — faire évoluer services/user.py

Ici, on enrichit UserService sans le transformer en service mail. Le service utilisateur garde la logique utilisateur. Le service notification garde la logique d’envoi.

from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from api.schemas.user import UserCreate
from core.security import create_access_token, hash_password, verify_password
from database.models import User


class UserService:
def __init__(self, session: AsyncSession):
self.session = session

async def get_by_email(self, email: str) -> User | None:
result = await self.session.execute(
select(User).where(User.email == email)
)
return result.scalar_one_or_none()

async def get_by_id(self, user_id: int) -> User | None:
return await self.session.get(User, user_id)

async def add_user(self, credentials: UserCreate) -> User:
existing_user = await self.get_by_email(credentials.email)
if existing_user is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Email already registered",
)

user = User(
**credentials.model_dump(exclude={"password"}),
password_hash=hash_password(credentials.password),
email_verified=False,
)
self.session.add(user)
await self.session.commit()
await self.session.refresh(user)
return user

async def mark_email_verified(self, user_id: int) -> User:
user = await self.get_by_id(user_id)
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)

user.email_verified = True
self.session.add(user)
await self.session.commit()
await self.session.refresh(user)
return user

async def set_password(self, user_id: int, new_password: str) -> User:
user = await self.get_by_id(user_id)
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)

user.password_hash = hash_password(new_password)
self.session.add(user)
await self.session.commit()
await self.session.refresh(user)
return user

async def authenticate(self, email: str, password: str) -> str:
user = await self.get_by_email(email)
if user is None or not verify_password(password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Email or password is incorrect",
)

if not user.email_verified:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Email not verified",
)

return create_access_token(
data={
"sub": str(user.id),
"email": user.email,
"name": user.name,
}
)

Ce qu’il faut remarquer ici

  • l’inscription crée un utilisateur non vérifié,
  • la connexion échoue tant que l’email n’est pas validé,
  • le changement de mot de passe reste centralisé dans UserService.

Étape 10 — faire évoluer api/dependencies.py

On ajoute une dépendance pour le service notification.

from typing import Annotated

from fastapi import BackgroundTasks, Depends
from sqlalchemy.ext.asyncio import AsyncSession

from database.session import get_session
from services.notification import NotificationService
from services.task import TaskService
from services.user import UserService


SessionDep = Annotated[AsyncSession, Depends(get_session)]


def get_task_service(session: SessionDep) -> TaskService:
return TaskService(session)


def get_user_service(session: SessionDep) -> UserService:
return UserService(session)


def get_notification_service(tasks: BackgroundTasks) -> NotificationService:
return NotificationService(tasks)


TaskServiceDep = Annotated[TaskService, Depends(get_task_service)]
UserServiceDep = Annotated[UserService, Depends(get_user_service)]
NotificationServiceDep = Annotated[NotificationService, Depends(get_notification_service)]

Pourquoi c’est une bonne séparation

Parce qu’on ne mélange pas :

  • session DB,
  • logique utilisateur,
  • logique notification.

Étape 11 — faire évoluer api/routers/user.py

Ici, on branche les vrais workflows.

from datetime import timedelta
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.security import OAuth2PasswordRequestForm

from api.dependencies import CurrentUserDep, NotificationServiceDep, UserServiceDep
from api.schemas.user import (
PasswordResetConfirm,
PasswordResetRequest,
SMSRequest,
TokenResponse,
UserCreate,
UserRead,
)
from config import app_settings
from core.url_tokens import decode_url_token, generate_url_token


router = APIRouter(prefix="/user", tags=["User"])


@router.post("/signup", response_model=UserRead, status_code=status.HTTP_201_CREATED)
async def register_user(
user: UserCreate,
service: UserServiceDep,
notifications: NotificationServiceDep,
):
created_user = await service.add_user(user)

token = generate_url_token(
{"user_id": created_user.id},
salt="verify-email",
)
verification_url = f"http://{app_settings.APP_DOMAIN}/user/verify-email?token={token}"

await notifications.send_email_template(
recipients=[created_user.email],
subject="Verify your email",
context={
"username": created_user.name,
"verification_url": verification_url,
"app_name": app_settings.APP_NAME,
},
template_name="mail_email_verify.html",
)

return created_user


@router.get("/verify-email")
async def verify_email(
token: Annotated[str, Query()],
service: UserServiceDep,
):
payload = decode_url_token(
token,
salt="verify-email",
expiry=timedelta(days=1),
)
if payload is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired verification token",
)

await service.mark_email_verified(int(payload["user_id"]))
return {"message": "Email verified successfully"}


@router.post("/token", response_model=TokenResponse)
async def login_user(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
service: UserServiceDep,
):
token = await service.authenticate(form_data.username, form_data.password)
return {
"access_token": token,
"token_type": "bearer",
}


@router.post("/password-reset/request")
async def request_password_reset(
payload: PasswordResetRequest,
service: UserServiceDep,
notifications: NotificationServiceDep,
):
user = await service.get_by_email(payload.email)

if user is not None:
token = generate_url_token(
{"user_id": user.id},
salt="password-reset",
)
reset_url = f"http://{app_settings.APP_DOMAIN}/user/password-reset/confirm?token={token}"

await notifications.send_email_template(
recipients=[user.email],
subject="Reset your password",
context={
"username": user.name,
"reset_url": reset_url,
"app_name": app_settings.APP_NAME,
},
template_name="mail_password_reset.html",
)

return {"message": "If the account exists, a reset email has been sent"}


@router.post("/password-reset/confirm")
async def confirm_password_reset(
payload: PasswordResetConfirm,
service: UserServiceDep,
):
token_data = decode_url_token(
payload.token,
salt="password-reset",
expiry=timedelta(days=1),
)
if token_data is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired password reset token",
)

await service.set_password(int(token_data["user_id"]), payload.new_password)
return {"message": "Password reset successful"}


@router.post("/notify/sms")
async def send_test_sms(
payload: SMSRequest,
current_user: CurrentUserDep,
notifications: NotificationServiceDep,
):
await notifications.send_sms(payload.phone_number, payload.message)
return {
"message": f"SMS queued for {payload.phone_number}",
"requested_by": current_user.email,
}


@router.get("/me", response_model=UserRead)
async def get_me(current_user: CurrentUserDep):
return current_user

Pourquoi cette version est pédagogique

Elle montre clairement quatre workflows :

1. Signup + email de vérification

  • création du user,
  • génération du token,
  • envoi du mail,
  • compte encore non vérifié.

2. Vérification email

  • lecture du token,
  • validation temporelle,
  • activation du compte.

3. Password reset

  • demande via email,
  • génération d’un lien,
  • confirmation via token.

4. SMS

  • envoi via un service externe,
  • route protégée,
  • usage démonstratif.

Point de sécurité important — pourquoi le reset répond toujours pareil

La route :

return {"message": "If the account exists, a reset email has been sent"}

est volontaire.

Elle évite de révéler :

  • si un email existe,
  • si un compte est enregistré,
  • si l’utilisateur a été trouvé.

C’est une petite pratique de sécurité simple, mais très utile.

Point de sécurité important — pourquoi les salt changent

On utilise :

  • verify-email
  • password-reset

car un token ne doit pas être réutilisable pour un autre usage.

Vérifications pratiques

1. Créer un utilisateur non vérifié

curl -i \
-X POST http://localhost:8000/user/signup \
-H "Content-Type: application/json" \
-d '{
"name": "Enes",
"email": "enes@test.local",
"password": "StrongPass123!"
}'

Tu dois observer :

  • 201 Created
  • email_verified: false
  • aucun token de login encore donné
  • l’envoi d’un email déclenché en arrière-plan

2. Tester un login avant vérification

curl -i \
-X POST http://localhost:8000/user/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d 'username=enes@test.local&password=StrongPass123!'

Tu dois observer :

  • un refus 401
  • un message du type Email not verified

3. Vérifier l’email

Ici, en vrai, tu récupéreras le lien depuis l’email. Pour un test manuel, si tu connais le token :

curl -i "http://localhost:8000/user/verify-email?token=TOKEN"

Tu dois observer :

  • 200 OK
  • Email verified successfully

4. Refaire le login après vérification

curl -i \
-X POST http://localhost:8000/user/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d 'username=enes@test.local&password=StrongPass123!'

Tu dois cette fois récupérer :

  • access_token
  • token_type=bearer

5. Demander un reset de mot de passe

curl -i \
-X POST http://localhost:8000/user/password-reset/request \
-H "Content-Type: application/json" \
-d '{"email": "enes@test.local"}'

Tu dois observer une réponse générique.

6. Confirmer le reset

curl -i \
-X POST http://localhost:8000/user/password-reset/confirm \
-H "Content-Type: application/json" \
-d '{
"token": "TOKEN",
"new_password": "NewStrongPass456!"
}'

Tu dois observer :

  • 200 OK
  • Password reset successful

7. Tester le SMS

TOKEN="..."

curl -i \
-X POST http://localhost:8000/user/notify/sms \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"phone_number": "+33123456789",
"message": "Task API test notification"
}'

Si Twilio n’est pas configuré, tu dois observer une erreur claire. C’est normal.

Erreurs fréquentes

Erreur 1 — les emails ne partent pas

Cause fréquente :

  • mauvaise config SMTP,
  • mauvais port,
  • mauvais identifiants,
  • certs ou TLS mal configurés.

Correction :

  • revalider MAIL_SERVER, MAIL_PORT, MAIL_USERNAME, MAIL_PASSWORD.

Erreur 2 — les liens générés pointent vers une mauvaise URL

Cause fréquente :

  • APP_DOMAIN incorrect.

Correction :

  • vérifie APP_DOMAIN=localhost:8000 ou ton vrai domaine de déploiement.

Erreur 3 — le token de reset est toujours invalide

Cause fréquente :

  • salt incohérent,
  • token expiré,
  • secret changé.

Correction :

  • garde le même JWT_SECRET,
  • garde exactement le même salt entre génération et lecture.

Erreur 4 — le login marche alors que l’email n’est pas vérifié

Cause fréquente :

  • oubli du test if not user.email_verified dans authenticate().

Correction :

  • ajoute ce garde-fou dans UserService.authenticate().

Erreur 5 — le SMS casse immédiatement

Cause fréquente :

  • Twilio non configuré.

Correction :

  • soit configure Twilio,
  • soit considère le SMS comme un sous-bloc optionnel pour l’instant.

Bonnes pratiques à retenir

1. Sépare bien user logic et notification logic

UserService gère l’utilisateur. NotificationService gère l’envoi.

2. Utilise des tokens dédiés aux liens

Ne recycle pas ton JWT principal pour tous les usages.

3. Fais expirer les tokens sensibles

Un lien de reset password éternel est une mauvaise idée.

4. Ne révèle pas l’existence des comptes

Surtout pour la route de reset.

5. Traite le SMS comme un canal additionnel

Utile, mais souvent secondaire par rapport à l’email.

Checklist de validation

À la fin, tu dois pouvoir cocher tout ceci :

  • j’ai ajouté les dépendances fastapi-mail, itsdangerous et twilio
  • j’ai ajouté APP_DOMAIN et la config notification
  • j’ai créé core/url_tokens.py
  • j’ai créé services/notification.py
  • j’ai créé les templates email HTML
  • j’ai ajouté email_verified au modèle User
  • l’inscription crée un utilisateur non vérifié
  • la connexion échoue tant que l’email n’est pas vérifié
  • je sais envoyer un lien de vérification
  • je sais envoyer un lien de reset password
  • je comprends pourquoi la route de reset répond toujours de manière générique
  • je comprends que le SMS est un canal optionnel mais intégrable

Résumé final

Dans ce chapitre, tu as transformé ton application FastAPI en backend plus crédible côté produit.

Elle ne fait plus seulement :

  • créer des comptes,
  • connecter des utilisateurs,
  • renvoyer du JSON.

Elle commence maintenant à gérer un vrai cycle utilisateur :

  • vérification d’email,
  • blocage du login tant que le compte n’est pas validé,
  • reset de mot de passe,
  • SMS de notification en option.

Le point clé à retenir est simple :

  • l’authentification identifie,
  • les notifications accompagnent le cycle de vie utilisateur,
  • et FastAPI permet de poser cette logique proprement avant même de passer à une architecture async plus lourde comme Celery.

Pour aller plus loin