Envoie les emails via un relais SMTP configurable plutôt qu'en direct-to-MX

Ajoute le bloc "smtp" (host, port, identifiants, TLS) dans config.json,
plus fiable et plus simple à opérer qu'une livraison directe au MX du
destinataire. data/config.json n'est plus versionné (secrets), remplacé
par data/config.example.json.
This commit is contained in:
Antoine
2026-07-17 21:46:52 +02:00
parent 1d9380736e
commit e6a523f76f
10 changed files with 297 additions and 64 deletions

2
.gitignore vendored
View File

@ -1,6 +1,8 @@
# Données personnelles — jamais dans le dépôt
data/pointages/
data/conges.json
data/config.json
data/users/
*.ods
*.csv
*.json.bak

View File

@ -35,27 +35,40 @@ Les plages qui ne respectent pas les minimums sont signalées en rouge dès la s
## Lancement
```bash
cp data/config.example.json data/config.json # à adapter à ton domaine
docker compose up -d
```
Ouvre [http://localhost:8000](http://localhost:8000).
Les données (pointages, congés) sont stockées dans `data/` et ignorées par git.\
Seul `data/config.json` est versionné.
Les données (pointages, congés, comptes) sont stockées dans `data/` et ignorées par git.\
Seul `data/config.example.json` est versionné — copie-le en `data/config.json` et adapte-le.
---
## Configuration
`data/config.json` :
`data/config.json` (voir `data/config.example.json`) :
```json
{
"heures_jour": 7.8
"heures_jour": 7.8,
"allowed_email_domain": "exemple.fr",
"mail_from": "no-reply@exemple.fr",
"smtp": {
"host": "smtp.exemple.fr",
"port": 587,
"user": "no-reply@exemple.fr",
"password": "change-moi",
"use_tls": true
}
}
```
`7.8` = 7h48 = 39h ÷ 5 jours. Modifier cette valeur recentre tous les calculs.
- `heures_jour``7.8` = 7h48 = 39h ÷ 5 jours. Modifier cette valeur recentre tous les calculs.
- `allowed_email_domain` — seules les adresses de ce domaine peuvent se connecter.
- `mail_from` — adresse expéditrice des emails de connexion.
- `smtp` — serveur relais utilisé pour l'envoi (host, port, identifiants, TLS).
---

25
app/mail.py Normal file
View File

@ -0,0 +1,25 @@
"""Mail delivery via a configured SMTP relay (host/port/credentials in config.json)."""
import smtplib
from email.mime.text import MIMEText
def send_mail(to_addr: str, subject: str, body: str, from_addr: str, smtp_cfg: dict):
host = smtp_cfg.get("host")
if not host:
raise RuntimeError("Aucun serveur SMTP configuré (smtp.host manquant dans config.json).")
port = int(smtp_cfg.get("port", 587))
user = smtp_cfg.get("user")
password = smtp_cfg.get("password")
use_tls = smtp_cfg.get("use_tls", True)
msg = MIMEText(body)
msg["Subject"] = subject
msg["From"] = from_addr
msg["To"] = to_addr
with smtplib.SMTP(host, port, timeout=10) as smtp:
if use_tls:
smtp.starttls()
if user:
smtp.login(user, password)
smtp.send_message(msg)

View File

@ -1,18 +1,22 @@
import asyncio
import json
import re
import secrets
from datetime import datetime, timedelta
from itertools import groupby
import bcrypt
from fastapi import FastAPI, Form, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from mail import send_mail
from models import (
load_config, load_week_pointages, save_week_pointages, load_conges,
toggle_conge, list_all_weeks, needs_migration, migrate_legacy_to_user,
toggle_conge, list_all_weeks,
load_notif_config, save_notif_config, find_user_by_token, save_presence,
load_auth, save_auth, find_user_by_reset_token,
)
from calcul import compute_week, minutes_to_hhmm, hhmm_to_minutes, heures_dues
from stats import compute_all_stats
@ -30,19 +34,30 @@ templates.env.filters["zip"] = zip
app.mount("/static", StaticFiles(directory="static"), name="static")
JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi"]
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9_\-]{1,32}$")
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9_.\-]{1,64}$")
_EMAIL_RE = re.compile(r"^([a-zA-Z0-9_.\-]{1,64})@([a-zA-Z0-9.\-]{1,255})$")
# ── Auth helpers ──────────────────────────────────────────────────────────────
def _get_user(request: Request) -> str | None:
"""Extract current user from cookie.
TODO: replace with SSO token validation when integrating corporate SSO.
"""
"""Extract current user from cookie."""
uid = request.cookies.get("user_id", "").strip()
return uid if _USERNAME_RE.match(uid) else None
def allowed_email_domain() -> str:
return load_config().get("allowed_email_domain", "")
def mail_from_address() -> str:
return load_config().get("mail_from") or "no-reply@pointeuse.local"
def smtp_config() -> dict:
return load_config().get("smtp", {})
def _require_user(request: Request):
"""Return user_id or redirect to login."""
uid = _get_user(request)
@ -217,27 +232,116 @@ def _htmx_calc_and_soldes(request, date, d, conges, h_jour, user_id):
# ── Login / logout ────────────────────────────────────────────────────────────
RESET_TOKEN_VALIDITY_HOURS = 24
def _login_ctx(request, step, email="", error=None):
return {"request": request, "step": step, "email": email, "error": error}
@app.get("/login", response_class=HTMLResponse)
def login_page(request: Request):
if _get_user(request):
return RedirectResponse("/", status_code=302)
return templates.TemplateResponse("login.html", {"request": request, "error": None})
return templates.TemplateResponse("login.html", _login_ctx(request, "email"))
@app.post("/login", response_class=HTMLResponse)
def login_submit(request: Request, response: Response, username: str = Form("")):
username = username.strip().lower()
if not _USERNAME_RE.match(username):
return templates.TemplateResponse("login.html", {
"request": request,
"error": "Nom d'utilisateur invalide (lettres, chiffres, - et _ uniquement, 132 caractères).",
})
# First-ever login: migrate legacy flat data to this user's directory
if needs_migration():
migrate_legacy_to_user(username)
def login_submit(request: Request, email: str = Form("")):
email = email.strip().lower()
m = _EMAIL_RE.match(email)
domain = allowed_email_domain()
if not m or not domain or m.group(2) != domain:
return templates.TemplateResponse("login.html", _login_ctx(
request, "email", email,
f"Seules les adresses @{domain or '?'} sont autorisées.",
))
user_id = m.group(1)
auth = load_auth(user_id)
if auth.get("password_hash"):
return templates.TemplateResponse("login.html", _login_ctx(request, "password", email))
# No account yet: (re)send a link to set the password
token = secrets.token_urlsafe(32)
expires = (datetime.now() + timedelta(hours=RESET_TOKEN_VALIDITY_HOURS)).isoformat()
save_auth(user_id, email=email, token=token, token_expires=expires)
base_url = str(request.base_url).rstrip("/")
link = f"{base_url}/set-password/{token}"
try:
send_mail(
email,
"Définis ton mot de passe — Décompte Horaire",
f"Clique sur ce lien pour définir ton mot de passe (valable {RESET_TOKEN_VALIDITY_HOURS}h) :\n\n{link}\n",
mail_from_address(),
smtp_config(),
)
except Exception:
return templates.TemplateResponse("login.html", _login_ctx(
request, "email", email, "Impossible d'envoyer l'email pour le moment, réessaie plus tard.",
))
return templates.TemplateResponse("login.html", _login_ctx(request, "sent", email))
@app.post("/login/password", response_class=HTMLResponse)
def login_password(request: Request, email: str = Form(""), password: str = Form("")):
email = email.strip().lower()
m = _EMAIL_RE.match(email)
domain = allowed_email_domain()
if not m or not domain or m.group(2) != domain:
return RedirectResponse("/login", status_code=302)
user_id = m.group(1)
ph = load_auth(user_id).get("password_hash")
if not ph or not bcrypt.checkpw(password.encode(), ph.encode()):
return templates.TemplateResponse("login.html", _login_ctx(
request, "password", email, "Mot de passe incorrect.",
))
resp = RedirectResponse("/", status_code=302)
resp.set_cookie("user_id", username, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 30)
resp.set_cookie("user_id", user_id, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 30)
return resp
def _valid_reset_token(token: str) -> str | None:
user_id = find_user_by_reset_token(token)
if not user_id:
return None
expires = load_auth(user_id).get("token_expires")
if not expires or datetime.fromisoformat(expires) < datetime.now():
return None
return user_id
@app.get("/set-password/{token}", response_class=HTMLResponse)
def set_password_page(request: Request, token: str):
valid = _valid_reset_token(token) is not None
return templates.TemplateResponse("set_password.html", {
"request": request, "token": token, "valid": valid, "error": None,
})
@app.post("/set-password/{token}", response_class=HTMLResponse)
def set_password_submit(request: Request, token: str, password: str = Form(""), password2: str = Form("")):
user_id = _valid_reset_token(token)
if not user_id:
return templates.TemplateResponse("set_password.html", {
"request": request, "token": token, "valid": False, "error": None,
})
if len(password) < 8 or password != password2:
return templates.TemplateResponse("set_password.html", {
"request": request, "token": token, "valid": True,
"error": "Les mots de passe ne correspondent pas ou font moins de 8 caractères.",
})
password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
save_auth(user_id, password_hash=password_hash, token=None, token_expires=None)
resp = RedirectResponse("/", status_code=302)
resp.set_cookie("user_id", user_id, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 30)
return resp

View File

@ -1,7 +1,6 @@
"""Flat-file storage: JSON files per week for pointages, one file for congés."""
import json
import secrets
import shutil
from pathlib import Path
DATA_DIR = Path("/data")
@ -9,17 +8,20 @@ CONFIG_FILE = DATA_DIR / "config.json"
DEFAULT_CONFIG = {
"heures_jour": 7.8, # 7h48 = 39h/semaine
"allowed_email_domain": "", # ex: "sdv.fr" — seules ces adresses peuvent se connecter
"mail_from": "", # ex: "no-reply@sdv.fr" — expéditeur des emails de connexion
"smtp": {
"host": "", # ex: "smtp.sdv.fr"
"port": 587,
"user": "",
"password": "",
"use_tls": True,
},
}
# ── User path helpers ──────────────────────────────────────────────────────────
def _user_root(user_id: str) -> Path:
"""Return data directory for a user.
Legacy fallback: if no users/ directory exists yet but /data/pointages/ does,
the first login migrates the data automatically (see migrate_legacy_to_user).
After migration, each user gets /data/users/{user_id}/.
"""
return DATA_DIR / "users" / user_id
@ -35,29 +37,6 @@ def _ensure_user_dirs(user_id: str):
_pointages_dir(user_id).mkdir(parents=True, exist_ok=True)
# ── Legacy migration (called once on first login) ─────────────────────────────
def needs_migration() -> bool:
"""True when legacy flat data exists and no users/ directory has been created yet."""
return (DATA_DIR / "pointages").exists() and not (DATA_DIR / "users").exists()
def migrate_legacy_to_user(user_id: str):
"""Move /data/pointages/ and /data/conges.json into /data/users/{user_id}/."""
user_root = _user_root(user_id)
user_root.mkdir(parents=True, exist_ok=True)
legacy_pt = DATA_DIR / "pointages"
dest_pt = user_root / "pointages"
if legacy_pt.exists() and not dest_pt.exists():
shutil.copytree(legacy_pt, dest_pt)
legacy_cg = DATA_DIR / "conges.json"
dest_cg = user_root / "conges.json"
if legacy_cg.exists() and not dest_cg.exists():
shutil.copy2(legacy_cg, dest_cg)
# ── User list ─────────────────────────────────────────────────────────────────
def list_users() -> list[str]:
@ -126,6 +105,33 @@ def save_conges(conges: list[dict], user_id: str = ""):
)
# ── Auth (email + password) ────────────────────────────────────────────────────
def _auth_file(user_id: str) -> Path:
return _user_root(user_id) / "auth.json"
def load_auth(user_id: str) -> dict:
f = _auth_file(user_id)
if f.exists():
return json.loads(f.read_text())
return {"email": None, "password_hash": None, "token": None, "token_expires": None}
def save_auth(user_id: str, **fields):
auth = load_auth(user_id)
auth.update(fields)
_ensure_user_dirs(user_id)
_auth_file(user_id).write_text(json.dumps(auth, indent=2, ensure_ascii=False))
def find_user_by_reset_token(token: str) -> str | None:
for user_id in list_users():
if load_auth(user_id).get("token") == token:
return user_id
return None
# ── Notifications (ntfy topic + presence tracking) ────────────────────────────
def _notif_file(user_id: str) -> Path:

View File

@ -11,22 +11,46 @@
<div class="login-error">{{ error }}</div>
{% endif %}
{% if step == "email" %}
<form method="post" action="/login" class="login-form">
<label class="login-label" for="username">Identifiant</label>
<label class="login-label" for="email">Adresse email</label>
<input
id="username"
id="email"
class="login-input"
type="text"
name="username"
autocomplete="username"
type="email"
name="email"
value="{{ email }}"
autocomplete="email"
autofocus
placeholder="prenom.nom"
pattern="[a-zA-Z0-9_\-]{1,32}"
placeholder="prenom.nom@domaine.fr"
required
>
<p class="login-hint">Lettres, chiffres, tirets — 1 à 32 caractères.</p>
<button class="login-btn" type="submit">Entrer</button>
<button class="login-btn" type="submit">Continuer</button>
</form>
{% elif step == "password" %}
<form method="post" action="/login/password" class="login-form">
<input type="hidden" name="email" value="{{ email }}">
<label class="login-label">{{ email }}</label>
<input
id="password"
class="login-input"
type="password"
name="password"
autocomplete="current-password"
autofocus
placeholder="Mot de passe"
required
>
<button class="login-btn" type="submit">Se connecter</button>
</form>
{% elif step == "sent" %}
<p class="login-hint">
Un email a été envoyé à <b>{{ email }}</b> avec un lien pour définir ton mot de passe.
Le lien est valable 24h.
</p>
{% endif %}
</div>
</div>

View File

@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block content %}
<div class="login-wrap">
<div class="login-card">
<div class="login-header">
<span class="login-eyebrow">Décompte Horaire</span>
<h1 class="login-title">Définir ton mot de passe</h1>
</div>
{% if error %}
<div class="login-error">{{ error }}</div>
{% endif %}
{% if valid %}
<form method="post" action="/set-password/{{ token }}" class="login-form">
<label class="login-label" for="password">Nouveau mot de passe</label>
<input
id="password"
class="login-input"
type="password"
name="password"
autocomplete="new-password"
autofocus
placeholder="8 caractères minimum"
minlength="8"
required
>
<label class="login-label" for="password2">Confirmation</label>
<input
id="password2"
class="login-input"
type="password"
name="password2"
autocomplete="new-password"
placeholder="Retape le même mot de passe"
minlength="8"
required
>
<button class="login-btn" type="submit">Valider</button>
</form>
{% else %}
<p class="login-hint">
Ce lien est invalide ou a expiré. Retourne sur la
<a href="/login">page de connexion</a> pour en redemander un.
</p>
{% endif %}
</div>
</div>
{% endblock %}

12
data/config.example.json Normal file
View File

@ -0,0 +1,12 @@
{
"heures_jour": 7.8,
"allowed_email_domain": "exemple.fr",
"mail_from": "no-reply@exemple.fr",
"smtp": {
"host": "smtp.exemple.fr",
"port": 587,
"user": "no-reply@exemple.fr",
"password": "change-moi",
"use_tls": true
}
}

View File

@ -1,3 +0,0 @@
{
"heures_jour": 7.8
}

View File

@ -3,3 +3,4 @@ uvicorn[standard]==0.29.0
jinja2==3.1.4
python-multipart==0.0.9
odfpy==1.4.1
bcrypt==4.2.0