Notifications et communication utilisateur avec FastAPI
- FastAPI LLM Wiki
- API de base
- Advanced FastAPI
- Authentification avec FastAPI
- Réponses personnalisées avec FastAPI
- Gestion d’erreurs avec FastAPI
- Tests avec FastAPI
- Docker avec FastAPI
- Tâches asynchrones avec Celery et FastAPI
- Parcours de lecture
- FAQ, erreurs fréquentes et conseils pratiques
- SCHEMA
- Plan directeur
- Journal d’itération
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
Useravecemail_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.zip20-Email Confirmation/110-app.zip21-Password Reset/113-app.zip22-SMS/117-app.zip
On en retient quatre idées centrales :
BackgroundTaskspour une première version simple,fastapi-mailpour SMTP,itsdangerouspour des tokens d’URL signés,twiliocomme 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=
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-emailpassword-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 Createdemail_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 OKEmail 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_tokentoken_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 OKPassword 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_DOMAINincorrect.
Correction :
- vérifie
APP_DOMAIN=localhost:8000ou ton vrai domaine de déploiement.
Erreur 3 — le token de reset est toujours invalide
Cause fréquente :
saltincohérent,- token expiré,
- secret changé.
Correction :
- garde le même
JWT_SECRET, - garde exactement le même
saltentre 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_verifieddansauthenticate().
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,itsdangerousettwilio - j’ai ajouté
APP_DOMAINet 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_verifiedau modèleUser - 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
- Parcours de lecture — pour replacer les notifications dans le bon parcours global
- FAQ, erreurs fréquentes et conseils pratiques — pour retrouver les pièges email/reset/SMS les plus probables
- Advanced FastAPI — pour garder la vue d’ensemble du bloc avancé
- Tâches asynchrones avec Celery et FastAPI — pour déléguer ensuite ces notifications à une vraie file de jobs