À qui s’adresse cette annexe #
QFieldCloud auto-hébergé [E05] : l'instance de base, prête pour la production
Les fichiers qu’on touche #
Deux, et la bascule tient dans le premier. Chaque bloc de code plus bas rappelle en première ligne le fichier auquel il appartient.
| Fichier | Ce qu’on y met |
|---|---|
.env |
les cinq variables EMAIL_* et DEFAULT_FROM_EMAIL |
docker-compose.override.yml |
le profil never sur smtp4dev, ou ses trois ports dissociés tant qu’on le garde |
L’effet de bord qu’on n’avait pas vu venir #
À la fin de l’épisode 4, l’instance est passée sur AccountAdapterSignUpClosed, un adaptateur pour allauth — la bibliothèque tierce qui gère l’inscription et la connexion dans QFieldCloud. Résultat : plus personne ne peut créer son compte depuis la page d’inscription. Bonne décision de sécurité — sur une instance publiée, le défaut upstream laisse n’importe qui s’enregistrer.
Elle a une conséquence qu’il faut regarder en face : le seul chemin d’entrée en libre-service devient l’invitation par courriel. Le courriel cesse d’être un confort et devient le mécanisme d’inscription.
docker compose se lancent depuis src/, le répertoire du sous-module — c’est de là que COMPOSE_FILE est résolu — et avec --env-file ../.env. Chaque bloc le rappelle par son cd src, à ne faire qu’une fois par session. Le $ en début de ligne est le symbole du terminal, il ne se copie pas.
Une autre voie subsiste, et elle mérite d’être connue avant de se croire bloqué : l’administrateur peut créer le compte lui-même, depuis l’admin Django (/admin/, modèle Person) ou en ligne de commande.
$ cd src && docker compose --env-file ../.env exec app python manage.py createuser \
--username=dupont --email=dupont@exemple.fr --password='…'
User dupont has been successfully created
Le compte obtenu est complet : User.save() crée le UserAccount et l’abonnement courant dans la même transaction. Et comme ACCOUNT_EMAIL_VERIFICATION vaut optional, l’adresse non vérifiée n’empêche pas la connexion.
Deux réserves, cependant. La docstring de createuser annonce elle-même un usage de test, et le mot de passe passe en clair par la ligne de commande — donc par l’historique du shell : préférez l’admin Django, ou changez le mot de passe juste après. Surtout, cette voie ne remplace pas le courriel, elle le déplace : il faut transmettre l’identifiant et le mot de passe par un autre canal, et l’utilisateur qui oubliera ce dernier n’aura toujours aucun moyen de le réinitialiser seul. Elle dépanne pour trois comptes ; elle ne tient pas pour trente.
Or voici ce que fait l’instance quand on lui demande d’envoyer :
$ cd src && docker compose --env-file ../.env exec app python manage.py sendtestemail vous@exemple.fr
$ curl -s 'http://127.0.0.1:8012/api/Messages?pageSize=5'
messages piégés: 1
- 2026-09-04T14:08:19 | webmaster@localhost -> ['vous@exemple.fr']
| Test email from 7bd08766e2a9 on 2026-09-04 14:08:19
Le message n’est pas parti : il est dans le piège, consultable sur http://127.0.0.1:8012. Et son expéditeur est webmaster@localhost, valeur par défaut de DEFAULT_FROM_EMAIL — une adresse sans domaine pleinement qualifié, que le premier relais venu refuserait.
Ce qui est bloqué, concrètement #
| Fonctionnalité | Ce qui se passe |
|---|---|
| Invitation d’un utilisateur | Le message part dans le piège. Il faut y chercher le lien et le transmettre à la main |
| Réinitialisation de mot de passe | Idem — l’utilisateur attend un courriel qui n’arrivera jamais |
Vérification d’adresse (ACCOUNT_EMAIL_VERIFICATION=optional) |
Sans effet visible, puisqu’optionnelle |
| Notifications de fin de job | Perdues silencieusement |
ACCOUNT_EMAIL_VERIFICATION à mandatory en croyant durcir l’instance. Avec un piège à courriels, plus personne ne peut activer son compte — vous compris.
Pour une instance à poignée d’utilisateurs, tous connus, le compromis se défend : on relève les invitations dans l’interface de smtp4dev et on transmet le lien par le canal qu’on veut. À condition de le savoir, et de ne pas découvrir le jour J que l’invitation envoyée à un collègue n’est jamais partie.
La bascule, en cinq variables #
# .env
EMAIL_HOST="smtp.exemple.fr" # au lieu de smtp4dev
EMAIL_PORT=587 # au lieu de 25
EMAIL_USE_TLS=True # STARTTLS ; EMAIL_USE_SSL pour du 465 implicite
EMAIL_HOST_USER="…"
EMAIL_HOST_PASSWORD="…"
DEFAULT_FROM_EMAIL="qfieldcloud@votre-domaine.fr" # LA variable qu'on oublieAucune modification du sous-module. Deux confusions classiques :
EMAIL_PORT n’a rien à voir avec SMTP4DEV_SMTP_PORT. Le premier est le port que l’application compose sur le réseau Docker ; le second, le port publié sur l’hôte — déplacé sur 2525 à l’épisode 4 pour éviter le conflit avec l’agent de transport local. Changer l’un en croyant agir sur l’autre est le classique du genre.
DEFAULT_FROM_EMAIL doit appartenir à un domaine dont les enregistrements SPF autorisent le relais choisi. Faute de quoi les messages partent bien — et arrivent en indésirables, ce qui est plus difficile à diagnostiquer qu’un échec franc.
Une fois le relais en place, smtp4dev se retire exactement comme certbot :
# docker-compose.override.yml
smtp4dev:
profiles: ["never"]Avec la même réserve qu’en A01 : le profil exclut le service de la configuration, il ne supprime pas le conteneur qui tourne déjà. up -d --remove-orphans le laissera en place, invisible dans docker compose config et bien présent dans docker ps. Il faut le nommer, en réactivant le profil le temps de la commande :
$ cd src && docker compose --env-file ../.env --profile never rm -sf smtp4dev
Y accéder tant qu’il est là #
L’interface web écoute sur le port SMTP4DEV_WEB_PORT (8012 ici), et l’adresse sur laquelle elle est publiée décide de tout le reste.
Par défaut, 127.0.0.1 : elle n’est joignable que depuis la machine elle-même, donc par un tunnel SSH depuis votre poste.
$ ssh -N -L 8012:127.0.0.1:8012 utilisateur@10.0.0.10
# puis http://127.0.0.1:8012 dans le navigateur
C’est fastidieux pour une consultation quotidienne, et la tentation est grande de publier l’interface sur le LAN. Elle est légitime — à une condition près, qu’il faut avoir en tête.
Si vous la publiez, ne publiez qu’elle. Une seule variable pilote les trois ports dans le fichier upstream — web, SMTP et IMAP partent ensemble —, alors que l’IMAP donne accès aux mêmes messages que l’interface et que le SMTP accepterait n’importe quel envoi. Les dissocier dans l’override coûte trois lignes :
# docker-compose.override.yml
smtp4dev:
ports: !override
- "${SMTP4DEV_WEB_BIND_IP}:${SMTP4DEV_WEB_PORT}:80" # seule à sortir
- "127.0.0.1:${SMTP4DEV_SMTP_PORT}:25"
- "127.0.0.1:${SMTP4DEV_IMAP_PORT}:143"Une porte au lieu de trois, pour le même service rendu. Et un contrôle qui ne coûte rien, une fois le conteneur recréé :
$ docker port qfieldcloud-smtp4dev-1
25/tcp -> 127.0.0.1:2525
80/tcp -> 10.0.0.10:8012
143/tcp -> 127.0.0.1:143
Notez qu’après cette bascule, http://127.0.0.1:8012 ne répond plus depuis la machine elle-même : le port n’y est plus publié. C’est la même URL pour tout le monde, ce qui est plutôt une bonne chose.
Vérifier que ça marche vraiment #
$ cd src && docker compose --env-file ../.env exec app python manage.py sendtestemail vous@votre-domaine.fr
Puis regardez la boîte de réception, pas les journaux : Django considère l’envoi réussi dès que le relais a accepté le message. Le reste — SPF, DKIM, réputation — se joue après, et hors de son champ de vision.
Exemple concret : un Postfix dédié en LXC, relayé par Scaleway TEM #
Ce que smtp.exemple.fr recouvre plus haut, dans le détail, pour une instance auto-hébergée derrière une connexion résidentielle.
Pourquoi un relais dédié, et pourquoi pas d’envoi direct #
Postfix n’a pas besoin de tourner sur la même machine que QFieldCloud : une LXC unprivileged dédiée (512 Mo de RAM suffisent) découple la config mail d’une éventuelle réinstallation de l’appli, et permet de mutualiser ce relais avec d’autres services plus tard.
Ce Postfix ne doit en revanche jamais livrer directement aux MX des destinataires (Gmail, Outlook…). Une IP résidentielle ou même une IP de VPS sans historique part avec un désavantage rédhibitoire : pas de PTR cohérent, blacklists RBL sur les plages dynamiques, port 25 sortant souvent bloqué par le FAI. Le relais interne rebondit donc vers un smarthost externe — ici Scaleway TEM (Transactional Email), gratuit sur un volume de 300 mails — qui porte la réputation d’envoi à sa place.
QFieldCloud (Django) → Postfix interne (LXC dédiée, port 25) → Scaleway TEM (SASL, port 587) → destinataireDNS : domaine dédié à l’envoi, zone gérée hors du registrar #
Le domaine d’envoi (ex. mailrelay.allfabox.fr, distinct du domaine principal pour isoler la réputation) doit être validé dans la console Scaleway TEM, qui fournit les enregistrements à poser :
| Enregistrement | Rôle |
|---|---|
| SPF (TXT) | autorise les IP de Scaleway TEM à envoyer pour ce domaine |
| DKIM (TXT/CNAME) | signature cryptographique, générée automatiquement |
DMARC (TXT sur _dmarc.) |
politique, p=none suffit pour démarrer |
| MX blackhole | blackhole.tem.scaleway.com priorité 10 — déclare que ce sous-domaine n’accepte aucun mail entrant (RFC 7505) |
Pas à pas : valider le domaine d’envoi chez Scaleway TEM #
1. Ouvrir la console. Créez un compte ou connectez-vous sur console.scaleway.com, puis rendez-vous dans Transactional Email (console.scaleway.com/transactional-email). La liste des domaines est vide au premier passage.
2. Déclarer le domaine. Cliquez sur Ajouter un domaine, choisissez Ajouter un domaine externe à Scaleway et saisissez le sous-domaine dédié à l’envoi — ici mailrelay.allfabox.fr. Acceptez les conditions, puis Valider le nom de domaine.
3. Relever l’identifiant SMTP. La validation redirige vers l’aperçu du domaine. Le bloc Configuration SMTP y donne le serveur (smtp.tem.scaleway.com), les ports, et surtout le nom d’utilisateur — c’est lui que Postfix présentera au relais. Le mot de passe, lui, n’existe pas encore : ce sera la clé secrète d’une clé API, créée juste après.
4. Récupérer les enregistrements DNS. Onglet Enregistrements DNS : Scaleway liste les quatre entrées à poser — SPF, DKIM, DMARC et un MX blackhole. Toutes les valeurs sont copiables d’un clic.
5. Ouvrir la zone chez l’hébergeur DNS. Ici la zone de allfabox.fr est déléguée à Cloudflare : le domaine est chez OVH, mais ses serveurs de noms pointent vers Cloudflare. Direction dash.cloudflare.com → le domaine → Gestion DNS → Enregistrement DNS.
allfabox.fr, filtrée sur mailrelay : rien encore.6. Créer les quatre enregistrements. Ajouter un enregistrement, puis pour chacun choisir le type dans la liste déroulante et coller la valeur relevée chez Scaleway.
Dans l’ordre : SPF (v=spf1 include:_spf.tem.scaleway.com -all), DKIM (la longue clé publique sur le sélecteur …_domainkey), DMARC (v=DMARC1; p=none sur _dmarc.mailrelay…), puis le MX blackhole.tem.scaleway.com en priorité 10. Ce dernier déclare, au sens de la RFC 7505, que le sous-domaine n’accepte aucun courriel entrant.
7. Faire vérifier par Scaleway. De retour dans l’aperçu du domaine, Vérifier le domaine. Si les enregistrements sont en place et propagés, le statut passe au vert.
Pas à pas : la clé API SMTP, via IAM #
TransactionalEmailEmailSmtpCreate, sans quoi le relais répond 535 Invalid credentials. Trois objets à créer, dans cet ordre, chacun s’appuyant sur le précédent : une policy, une application, puis la clé.
Toute la manœuvre se passe dans IAM (console.scaleway.com/iam).
1. La policy. Onglet Policies : trois policies existent par défaut, on n’y touche pas. Créer une policy.
Nommez-la — ici sans détour, TransactionalEmailEmailSmtpCreate, du nom de la permission qu’elle porte.
Dans Ajouter des règles, cherchez le produit Transactional Email et cochez l’ensemble d’autorisations TransactionalEmailEmailSmtpCreate — « Create permission for e-mails from SMTP ». Suivant.
Périmètre sur Tous les projets, Suivant. Pas de condition à ajouter : Valider la règle.
Un dernier récapitulatif, puis Créer une policy.
La policy est créée, mais rattachée à aucun principal (Principal : Non) — c’est l’application qui va la porter.
2. L’application. Onglet Applications : une application IAM est un porteur d’identité non humain, fait pour l’accès programmatique. Créer une application.
Nommez-la (mailrelay-allfabox-fr) et attachez-lui directement la policy TransactionalEmailEmailSmtpCreate créée à l’instant.
Créer une application.
3. La clé API. Onglet Clés API, ou directement console.scaleway.com/iam/api-keys. Générer une clé API.
Choisissez le porteur Une application, sélectionnez mailrelay-allfabox-fr, expiration Jamais, et Non pour Object Storage.
Le couple ID de la clé d’accès / Clé secrète s’affiche. C’est la clé secrète qui nous intéresse : Postfix la présentera comme mot de passe SMTP. Elle n’est montrée qu’une seule fois — copiez-la maintenant.
<clé_secrète> du fichier /etc/postfix/sasl_passwd de la section suivante — le <project_id> qui la précède étant le nom d’utilisateur relevé à l’étape 3 de la validation du domaine.
Postfix : deux pièges qui donnent le même symptôme #
Configuration côté LXC dédiée (/etc/postfix/main.cf) :
postconf -e 'relayhost = [smtp.tem.scaleway.com]:587'
postconf -e 'smtp_sasl_auth_enable = yes'
postconf -e 'smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd'
postconf -e 'smtp_sasl_security_options = noanonymous'
postconf -e 'smtp_tls_security_level = encrypt'
postconf -e 'inet_interfaces = all'
postconf -e 'mynetworks = 127.0.0.0/8, <IP_du_conteneur_QFieldCloud>/32'echo '[smtp.tem.scaleway.com]:587 <project_id>:<clé_secrète>' > /etc/postfix/sasl_passwd
postmap /etc/postfix/sasl_passwd
chmod 600 /etc/postfix/sasl_passwd /etc/postfix/sasl_passwd.db
systemctl restart postfixLe login SASL est le Project ID Scaleway, pas l’Access Key d’une clé API existante. Une clé API générale (créée pour l’API HTTP, Terraform, le CLI scw…) est refusée en SMTP avec un 535 Invalid credentials même si elle est valide ailleurs — sa policy IAM doit explicitement inclure TransactionalEmailEmailSmtpCreate.
inet_interfaces vaut loopback-only par défaut sur une install Debian non interactive. Symptôme : Connection refused depuis la machine QFieldCloud alors que Postfix tourne (systemctl status au vert) — ss -lntp révèle 127.0.0.1:25 au lieu de 0.0.0.0:25. Un RST explicite comme celui-ci vient de la pile IP de la cible, pas d’un pare-feu réseau : sur un LAN commuté en L2 direct (même sous-réseau), l’UniFi ou tout autre pare-feu de périmètre n’est jamais dans le circuit.
Validation en couches #
# 1. Le port répond
$ timeout 5 bash -c 'exec 3<>/dev/tcp/<ip_mailserver>/25 && head -1 <&3'
220 mailserver.allfabox.fr ESMTP Postfix
# 2. Le relais accepte et livre
$ echo "Test" | mail -s "Test" -r noreply@mailrelay.allfabox.fr vous@exemple.fr
$ journalctl -u postfix --since "1 minute ago" --no-pager
# ... status=sent (250 2.0.0 OK: queued as ...)
# 3. L'application elle-même
$ cd src && docker compose --env-file ../.env exec -T app python -c "
from django.core.mail import send_mail
send_mail('Test', 'Corps', 'noreply@mailrelay.allfabox.fr', ['vous@exemple.fr'])
"
Un premier envoi qui atterrit en indésirables (Gmail notamment) est normal — un domaine d’envoi tout juste validé n’a encore aucune réputation. Le signaler comme non-spam accélère l’apprentissage du filtre pour les suivants.
Ce qu’il faut retenir #
- Fermer les inscriptions fait du courriel le seul chemin d’entrée en libre-service. L’administrateur garde la création à la main (
/admin/oumanage.py createuser), mais elle ne dispense pas d’un relais : la réinitialisation de mot de passe, elle, n’a pas d’équivalent manuel. -
DEFAULT_FROM_EMAILpar défaut vautwebmaster@localhost, refusé par n’importe quel relais. -
EMAIL_PORTetSMTP4DEV_SMTP_PORTdésignent deux choses différentes. -
ACCOUNT_EMAIL_VERIFICATION=mandatoryavec un piège à courriels verrouille l’instance entière. - Un envoi direct depuis une IP résidentielle finit en spam ou rejeté : le relais interne doit rebondir vers un smarthost externe.
-
535 Invalid credentialssur un relais SMTP fraîchement configuré signale presque toujours une clé API sans la bonne permission IAM, pas un mot de passe erroné. -
Connection refusedalors que le service tourne évoqueinet_interfaces = loopback-only, pas un pare-feu réseau.
Image de couverture : Scott Blake sur Unsplash.